Skip to content

Commit 9edf6c9

Browse files
committed
feat: support services in XML BOMs
feat: support nested services in JSON and XML BOMs Signed-off-by: Paul Horton <[email protected]>
1 parent a35d540 commit 9edf6c9

22 files changed

+1699
-220
lines changed

cyclonedx/model/service.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ def __init__(self, name: str, bom_ref: Optional[str] = None, provider: Optional[
4343
licenses: Optional[List[LicenseChoice]] = None,
4444
external_references: Optional[List[ExternalReference]] = None,
4545
properties: Optional[List[Property]] = None,
46-
# services: Optional[List[Service]] = None, -- I have no clue how to do this,
47-
# commenting out so someone else can
46+
services: Optional[List['Service']] = None,
4847
release_notes: Optional[ReleaseNotes] = None,
4948
) -> None:
5049
self.bom_ref = bom_ref or str(uuid4())
@@ -59,7 +58,7 @@ def __init__(self, name: str, bom_ref: Optional[str] = None, provider: Optional[
5958
self.data = data
6059
self.licenses = licenses or []
6160
self.external_references = external_references or []
62-
# self.services = services -- no clue
61+
self.services = services
6362
self.release_notes = release_notes
6463
self.properties = properties
6564

@@ -264,6 +263,40 @@ def add_external_reference(self, reference: ExternalReference) -> None:
264263
"""
265264
self.external_references = self._external_references + [reference]
266265

266+
@property
267+
def services(self) -> Optional[List['Service']]:
268+
"""
269+
A list of services included or deployed behind the parent service.
270+
271+
This is not a dependency tree.
272+
273+
It provides a way to specify a hierarchical representation of service assemblies.
274+
275+
Returns:
276+
List of `Service`s or `None`
277+
"""
278+
return self._services
279+
280+
@services.setter
281+
def services(self, services: Optional[List['Service']]) -> None:
282+
self._services = services
283+
284+
def has_service(self, service: 'Service') -> bool:
285+
"""
286+
Check whether this Service contains the given Service.
287+
288+
Args:
289+
service:
290+
The instance of `cyclonedx.model.service.Service` to check if this Service contains.
291+
292+
Returns:
293+
`bool` - `True` if the supplied Service is part of this Service, `False` otherwise.
294+
"""
295+
if not self.services:
296+
return False
297+
298+
return service in self.services
299+
267300
@property
268301
def release_notes(self) -> Optional[ReleaseNotes]:
269302
"""
@@ -292,3 +325,18 @@ def properties(self) -> Optional[List[Property]]:
292325
@properties.setter
293326
def properties(self, properties: Optional[List[Property]]) -> None:
294327
self._properties = properties
328+
329+
def __eq__(self, other: object) -> bool:
330+
if isinstance(other, Service):
331+
return hash(other) == hash(self)
332+
return False
333+
334+
def __hash__(self) -> int:
335+
return hash((
336+
self.authenticated, self.data, self.description, str(self.endpoints),
337+
str(self.external_references), self.group, str(self.licenses), self.name, self.properties, self.provider,
338+
self.release_notes, str(self.services), self.version, self.x_trust_boundary
339+
))
340+
341+
def __repr__(self) -> str:
342+
return f'<Service name={self.name}, version={self.version}, bom-ref={self.bom_ref}>'

cyclonedx/output/xml.py

Lines changed: 179 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@
2424
from . import BaseOutput, SchemaVersion
2525
from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3, \
2626
SchemaVersion1Dot4
27-
from ..model import ExternalReference, HashType, OrganizationalEntity, OrganizationalContact, Tool
27+
from ..model import ExternalReference, HashType, OrganizationalEntity, OrganizationalContact, Property, Tool
2828
from ..model.bom import Bom
2929
from ..model.component import Component
30+
from ..model.release_note import ReleaseNotes
31+
from ..model.service import Service
3032
from ..model.vulnerability import Vulnerability, VulnerabilityRating, VulnerabilitySource, BomTargetVersionRange
3133

3234

@@ -75,13 +77,19 @@ def generate(self, force_regeneration: bool = False) -> None:
7577
elif component.has_vulnerabilities():
7678
has_vulnerabilities = True
7779

78-
if self.bom_supports_vulnerabilities() and has_vulnerabilities:
79-
vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities')
80-
for component in cast(List[Component], self.get_bom().components):
81-
for vulnerability in component.get_vulnerabilities():
82-
vulnerabilities_element.append(
83-
self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability)
84-
)
80+
if self.bom_supports_services():
81+
if self.get_bom().services:
82+
services_element = ElementTree.SubElement(self._root_bom_element, 'services')
83+
for service in cast(List[Service], self.get_bom().services):
84+
services_element.append(self._add_service_element(service=service))
85+
86+
if self.bom_supports_vulnerabilities() and has_vulnerabilities:
87+
vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities')
88+
for component in cast(List[Component], self.get_bom().components):
89+
for vulnerability in component.get_vulnerabilities():
90+
vulnerabilities_element.append(
91+
self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability)
92+
)
8593

8694
self.generated = True
8795

@@ -213,74 +221,172 @@ def _add_component_element(self, component: Component) -> ElementTree.Element:
213221

214222
# releaseNotes
215223
if self.component_supports_release_notes() and component.release_notes:
216-
release_notes_e = ElementTree.SubElement(component_element, 'releaseNotes')
217-
release_notes = component.release_notes
218-
219-
ElementTree.SubElement(release_notes_e, 'type').text = release_notes.type
220-
if release_notes.title:
221-
ElementTree.SubElement(release_notes_e, 'title').text = release_notes.title
222-
if release_notes.featured_image:
223-
ElementTree.SubElement(release_notes_e,
224-
'featuredImage').text = str(release_notes.featured_image)
225-
if release_notes.social_image:
226-
ElementTree.SubElement(release_notes_e,
227-
'socialImage').text = str(release_notes.social_image)
228-
if release_notes.description:
229-
ElementTree.SubElement(release_notes_e,
230-
'description').text = release_notes.description
231-
if release_notes.timestamp:
232-
ElementTree.SubElement(release_notes_e, 'timestamp').text = release_notes.timestamp.isoformat()
233-
if release_notes.aliases:
234-
release_notes_aliases_e = ElementTree.SubElement(release_notes_e, 'aliases')
235-
for alias in release_notes.aliases:
236-
ElementTree.SubElement(release_notes_aliases_e, 'alias').text = alias
237-
if release_notes.tags:
238-
release_notes_tags_e = ElementTree.SubElement(release_notes_e, 'tags')
239-
for tag in release_notes.tags:
240-
ElementTree.SubElement(release_notes_tags_e, 'tag').text = tag
241-
if release_notes.resolves:
242-
release_notes_resolves_e = ElementTree.SubElement(release_notes_e, 'resolves')
243-
for issue in release_notes.resolves:
244-
issue_e = ElementTree.SubElement(
245-
release_notes_resolves_e, 'issue', {'type': issue.get_classification().value}
246-
)
247-
if issue.get_id():
248-
ElementTree.SubElement(issue_e, 'id').text = issue.get_id()
249-
if issue.get_name():
250-
ElementTree.SubElement(issue_e, 'name').text = issue.get_name()
251-
if issue.get_description():
252-
ElementTree.SubElement(issue_e, 'description').text = issue.get_description()
253-
if issue.source:
254-
issue_source_e = ElementTree.SubElement(issue_e, 'source')
255-
if issue.source.name:
256-
ElementTree.SubElement(issue_source_e, 'name').text = issue.source.name
257-
if issue.source.url:
258-
ElementTree.SubElement(issue_source_e, 'url').text = str(issue.source.url)
259-
if issue.get_references():
260-
issue_references_e = ElementTree.SubElement(issue_e, 'references')
261-
for reference in issue.get_references():
262-
ElementTree.SubElement(issue_references_e, 'url').text = str(reference)
263-
if release_notes.notes:
264-
release_notes_notes_e = ElementTree.SubElement(release_notes_e, 'notes')
265-
for note in release_notes.notes:
266-
note_e = ElementTree.SubElement(release_notes_notes_e, 'note')
267-
if note.locale:
268-
ElementTree.SubElement(note_e, 'locale').text = note.locale
269-
text_attrs = {}
270-
if note.text.content_type:
271-
text_attrs['content-type'] = note.text.content_type
272-
if note.text.encoding:
273-
text_attrs['encoding'] = note.text.encoding.value
274-
ElementTree.SubElement(note_e, 'text', text_attrs).text = note.text.content
275-
if release_notes.properties:
276-
release_notes_properties_e = ElementTree.SubElement(release_notes_e, 'properties')
277-
for prop in release_notes.properties:
278-
ElementTree.SubElement(
279-
release_notes_properties_e, 'property', {'name': prop.get_name()}
280-
).text = prop.get_value()
224+
Xml._add_release_notes_element(release_notes=component.release_notes, parent_element=component_element)
281225

282226
return component_element
283227

228+
@staticmethod
229+
def _add_release_notes_element(release_notes: ReleaseNotes, parent_element: ElementTree.Element) -> None:
230+
release_notes_e = ElementTree.SubElement(parent_element, 'releaseNotes')
231+
232+
ElementTree.SubElement(release_notes_e, 'type').text = release_notes.type
233+
if release_notes.title:
234+
ElementTree.SubElement(release_notes_e, 'title').text = release_notes.title
235+
if release_notes.featured_image:
236+
ElementTree.SubElement(release_notes_e,
237+
'featuredImage').text = str(release_notes.featured_image)
238+
if release_notes.social_image:
239+
ElementTree.SubElement(release_notes_e,
240+
'socialImage').text = str(release_notes.social_image)
241+
if release_notes.description:
242+
ElementTree.SubElement(release_notes_e,
243+
'description').text = release_notes.description
244+
if release_notes.timestamp:
245+
ElementTree.SubElement(release_notes_e, 'timestamp').text = release_notes.timestamp.isoformat()
246+
if release_notes.aliases:
247+
release_notes_aliases_e = ElementTree.SubElement(release_notes_e, 'aliases')
248+
for alias in release_notes.aliases:
249+
ElementTree.SubElement(release_notes_aliases_e, 'alias').text = alias
250+
if release_notes.tags:
251+
release_notes_tags_e = ElementTree.SubElement(release_notes_e, 'tags')
252+
for tag in release_notes.tags:
253+
ElementTree.SubElement(release_notes_tags_e, 'tag').text = tag
254+
if release_notes.resolves:
255+
release_notes_resolves_e = ElementTree.SubElement(release_notes_e, 'resolves')
256+
for issue in release_notes.resolves:
257+
issue_e = ElementTree.SubElement(
258+
release_notes_resolves_e, 'issue', {'type': issue.get_classification().value}
259+
)
260+
if issue.get_id():
261+
ElementTree.SubElement(issue_e, 'id').text = issue.get_id()
262+
if issue.get_name():
263+
ElementTree.SubElement(issue_e, 'name').text = issue.get_name()
264+
if issue.get_description():
265+
ElementTree.SubElement(issue_e, 'description').text = issue.get_description()
266+
if issue.source:
267+
issue_source_e = ElementTree.SubElement(issue_e, 'source')
268+
if issue.source.name:
269+
ElementTree.SubElement(issue_source_e, 'name').text = issue.source.name
270+
if issue.source.url:
271+
ElementTree.SubElement(issue_source_e, 'url').text = str(issue.source.url)
272+
if issue.get_references():
273+
issue_references_e = ElementTree.SubElement(issue_e, 'references')
274+
for reference in issue.get_references():
275+
ElementTree.SubElement(issue_references_e, 'url').text = str(reference)
276+
if release_notes.notes:
277+
release_notes_notes_e = ElementTree.SubElement(release_notes_e, 'notes')
278+
for note in release_notes.notes:
279+
note_e = ElementTree.SubElement(release_notes_notes_e, 'note')
280+
if note.locale:
281+
ElementTree.SubElement(note_e, 'locale').text = note.locale
282+
text_attrs = {}
283+
if note.text.content_type:
284+
text_attrs['content-type'] = note.text.content_type
285+
if note.text.encoding:
286+
text_attrs['encoding'] = note.text.encoding.value
287+
ElementTree.SubElement(note_e, 'text', text_attrs).text = note.text.content
288+
if release_notes.properties:
289+
Xml._add_properties_element(properties=release_notes.properties, parent_element=release_notes_e)
290+
291+
@staticmethod
292+
def _add_properties_element(properties: List[Property], parent_element: ElementTree.Element) -> None:
293+
properties_e = ElementTree.SubElement(parent_element, 'properties')
294+
for property in properties:
295+
ElementTree.SubElement(
296+
properties_e, 'property', {'name': property.get_name()}
297+
).text = property.get_value()
298+
299+
def _add_service_element(self, service: Service) -> ElementTree.Element:
300+
element_attributes = {}
301+
if service.bom_ref:
302+
element_attributes['bom-ref'] = service.bom_ref
303+
304+
service_element = ElementTree.Element('service', element_attributes)
305+
306+
# provider
307+
if service.provider:
308+
self._add_organizational_entity(
309+
parent_element=service_element, organization=service.provider, tag_name='provider'
310+
)
311+
312+
# group
313+
if service.group:
314+
ElementTree.SubElement(service_element, 'group').text = service.group
315+
316+
# name
317+
ElementTree.SubElement(service_element, 'name').text = service.name
318+
319+
# version
320+
if service.version:
321+
ElementTree.SubElement(service_element, 'version').text = service.version
322+
323+
# description
324+
if service.description:
325+
ElementTree.SubElement(service_element, 'description').text = service.description
326+
327+
# endpoints
328+
if service.endpoints:
329+
endpoints_e = ElementTree.SubElement(service_element, 'endpoints')
330+
for endpoint in service.endpoints:
331+
ElementTree.SubElement(endpoints_e, 'endpoint').text = str(endpoint)
332+
333+
# authenticated
334+
if isinstance(service.authenticated, bool):
335+
ElementTree.SubElement(service_element, 'authenticated').text = str(service.authenticated).lower()
336+
337+
# x-trust-boundary
338+
if isinstance(service.x_trust_boundary, bool):
339+
ElementTree.SubElement(service_element, 'x-trust-boundary').text = str(service.x_trust_boundary).lower()
340+
341+
# data
342+
if service.data:
343+
data_e = ElementTree.SubElement(service_element, 'data')
344+
for data in service.data:
345+
ElementTree.SubElement(data_e, 'classification', {'flow': data.flow.value}).text = data.classification
346+
347+
# licenses
348+
if service.licenses:
349+
licenses_e = ElementTree.SubElement(service_element, 'licenses')
350+
for license in service.licenses:
351+
if license.license:
352+
license_e = ElementTree.SubElement(licenses_e, 'license')
353+
if license.license.id:
354+
ElementTree.SubElement(license_e, 'id').text = license.license.id
355+
elif license.license.name:
356+
ElementTree.SubElement(license_e, 'name').text = license.license.name
357+
if license.license.text:
358+
license_text_e_attrs = {}
359+
if license.license.text.content_type:
360+
license_text_e_attrs['content-type'] = license.license.text.content_type
361+
if license.license.text.encoding:
362+
license_text_e_attrs['encoding'] = license.license.text.encoding.value
363+
ElementTree.SubElement(license_e, 'text',
364+
license_text_e_attrs).text = license.license.text.content
365+
366+
ElementTree.SubElement(license_e, 'text').text = license.license.id
367+
else:
368+
ElementTree.SubElement(licenses_e, 'expression').text = license.expression
369+
370+
# externalReferences
371+
if service.external_references:
372+
self._add_external_references_to_element(ext_refs=service.external_references, element=service_element)
373+
374+
# properties
375+
if service.properties and self.services_supports_properties():
376+
Xml._add_properties_element(properties=service.properties, parent_element=service_element)
377+
378+
# services
379+
if service.services:
380+
services_element = ElementTree.SubElement(service_element, 'services')
381+
for sub_service in service.services:
382+
services_element.append(self._add_service_element(service=sub_service))
383+
384+
# releaseNotes
385+
if service.release_notes and self.services_supports_release_notes():
386+
Xml._add_release_notes_element(release_notes=service.release_notes, parent_element=service_element)
387+
388+
return service_element
389+
284390
def _get_vulnerability_as_xml_element_post_1_4(self, vulnerability: Vulnerability) -> ElementTree.Element:
285391
vulnerability_element = ElementTree.Element(
286392
'vulnerability',

0 commit comments

Comments
 (0)