From 1ac31f4cb14b6c466e092ff38ee2aa472c883c5d Mon Sep 17 00:00:00 2001 From: Artem Smotrakov Date: Thu, 13 Jan 2022 14:41:36 +0000 Subject: [PATCH] feat: add support for `bom.metadata.component` (#118) * Add support for metadata component Part of #6 Signed-off-by: Artem Smotrakov * Better docs and simpler ifs Signed-off-by: Artem Smotrakov --- cyclonedx/model/bom.py | 26 +++++++++++++++++++ cyclonedx/output/json.py | 25 +++++++++++++----- cyclonedx/output/xml.py | 3 +++ .../bom_v1.3_with_metadata_component.json | 23 ++++++++++++++++ .../bom_v1.3_with_metadata_component.xml | 18 +++++++++++++ tests/test_bom.py | 10 +++++++ tests/test_e2e_environment.py | 2 ++ tests/test_output_json.py | 13 +++++++++- tests/test_output_xml.py | 13 +++++++++- 9 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/bom_v1.3_with_metadata_component.json create mode 100644 tests/fixtures/bom_v1.3_with_metadata_component.xml diff --git a/cyclonedx/model/bom.py b/cyclonedx/model/bom.py index c6108126..d0aa0068 100644 --- a/cyclonedx/model/bom.py +++ b/cyclonedx/model/bom.py @@ -41,6 +41,8 @@ def __init__(self, tools: Optional[List[Tool]] = None) -> None: if not self.tools: self.add_tool(ThisTool) + self.component: Optional[Component] = None + @property def tools(self) -> List[Tool]: """ @@ -80,6 +82,30 @@ def timestamp(self) -> datetime: def timestamp(self, timestamp: datetime) -> None: self._timestamp = timestamp + @property + def component(self) -> Optional[Component]: + """ + The (optional) component that the BOM describes. + + Returns: + `cyclonedx.model.component.Component` instance for this Bom Metadata. + """ + return self._component + + @component.setter + def component(self, component: Component) -> None: + """ + The (optional) component that the BOM describes. + + Args: + component + `cyclonedx.model.component.Component` instance to add to this Bom Metadata. + + Returns: + None + """ + self._component = component + class Bom: """ diff --git a/cyclonedx/output/json.py b/cyclonedx/output/json.py index 1a6c301a..1b7bcdec 100644 --- a/cyclonedx/output/json.py +++ b/cyclonedx/output/json.py @@ -28,6 +28,13 @@ from ..model.bom import Bom +ComponentDict = Dict[str, Union[ + str, + List[Dict[str, str]], + List[Dict[str, Dict[str, str]]], + List[Dict[str, Union[str, List[Dict[str, str]]]]]]] + + class Json(BaseOutput, BaseSchemaVersion): def __init__(self, bom: Bom) -> None: @@ -73,15 +80,19 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str del bom_json['metadata']['tools'][i]['externalReferences'] # Iterate Components - for i in range(len(bom_json['components'])): - if not self.component_supports_author() and 'author' in bom_json['components'][i].keys(): - del bom_json['components'][i]['author'] + if 'components' in bom_json.keys(): + for i in range(len(bom_json['components'])): + if not self.component_supports_author() and 'author' in bom_json['components'][i].keys(): + del bom_json['components'][i]['author'] - if not self.component_supports_mime_type_attribute() and 'mime-type' in bom_json['components'][i].keys(): - del bom_json['components'][i]['mime-type'] + if not self.component_supports_mime_type_attribute() \ + and 'mime-type' in bom_json['components'][i].keys(): + del bom_json['components'][i]['mime-type'] - if not self.component_supports_release_notes() and 'releaseNotes' in bom_json['components'][i].keys(): - del bom_json['components'][i]['releaseNotes'] + if not self.component_supports_release_notes() and 'releaseNotes' in bom_json['components'][i].keys(): + del bom_json['components'][i]['releaseNotes'] + else: + bom_json['components'] = [] # Iterate Vulnerabilities if 'vulnerabilities' in bom_json.keys(): diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 5e443ed5..93a2c282 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -113,6 +113,9 @@ def _add_metadata_element(self) -> None: for tool in bom_metadata.tools: self._add_tool(parent_element=tools_e, tool=tool) + if bom_metadata.component: + metadata_e.append(self._add_component_element(component=bom_metadata.component)) + def _add_component_element(self, component: Component) -> ElementTree.Element: element_attributes = {'type': component.type.value} if self.component_supports_bom_ref_attribute() and component.bom_ref: diff --git a/tests/fixtures/bom_v1.3_with_metadata_component.json b/tests/fixtures/bom_v1.3_with_metadata_component.json new file mode 100644 index 00000000..1cb8628a --- /dev/null +++ b/tests/fixtures/bom_v1.3_with_metadata_component.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.3.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.3", + "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79", + "version": 1, + "metadata": { + "timestamp": "2021-09-01T10:50:42.051979+00:00", + "tools": [ + { + "vendor": "CycloneDX", + "name": "cyclonedx-python-lib", + "version": "VERSION" + } + ], + "component": { + "type": "library", + "name": "cyclonedx-python-lib", + "version": "1.0.0" + } + }, + "components": [] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_with_metadata_component.xml b/tests/fixtures/bom_v1.3_with_metadata_component.xml new file mode 100644 index 00000000..6baf1884 --- /dev/null +++ b/tests/fixtures/bom_v1.3_with_metadata_component.xml @@ -0,0 +1,18 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + cyclonedx-python-lib + 1.0.0 + + + + \ No newline at end of file diff --git a/tests/test_bom.py b/tests/test_bom.py index 634f80ae..c37fa193 100644 --- a/tests/test_bom.py +++ b/tests/test_bom.py @@ -20,6 +20,7 @@ from unittest import TestCase from cyclonedx.model.bom import Bom, ThisTool, Tool +from cyclonedx.model.component import Component, ComponentType class TestBom(TestCase): @@ -36,3 +37,12 @@ def test_bom_metadata_tool_multiple_tools(self) -> None: Tool(vendor='TestVendor', name='TestTool', version='0.0.0') ) self.assertEqual(len(bom.metadata.tools), 2) + + def test_metadata_component(self) -> None: + metadata = Bom().metadata + self.assertTrue(metadata.component is None) + hextech = Component(name='Hextech', version='1.0.0', + component_type=ComponentType.LIBRARY) + metadata.component = hextech + self.assertFalse(metadata.component is None) + self.assertEquals(metadata.component, hextech) diff --git a/tests/test_e2e_environment.py b/tests/test_e2e_environment.py index 9a2a664c..e850d821 100644 --- a/tests/test_e2e_environment.py +++ b/tests/test_e2e_environment.py @@ -49,6 +49,8 @@ def setUpClass(cls) -> None: def test_json_defaults(self) -> None: outputter: Json = get_instance(bom=TestE2EEnvironment.bom, output_format=OutputFormat.JSON) bom_json = json.loads(outputter.output_as_string()) + self.assertTrue('metadata' in bom_json) + self.assertFalse('component' in bom_json['metadata']) component_this_library = next( (x for x in bom_json['components'] if x['purl'] == 'pkg:pypi/{}@{}'.format(OUR_PACKAGE_NAME, OUR_PACKAGE_VERSION)), None diff --git a/tests/test_output_json.py b/tests/test_output_json.py index 27bfbe17..3f4a69d4 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -25,7 +25,7 @@ from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, LicenseChoice, Note, \ NoteText, OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri from cyclonedx.model.bom import Bom -from cyclonedx.model.component import Component +from cyclonedx.model.component import Component, ComponentType from cyclonedx.model.issue import IssueClassification, IssueType from cyclonedx.model.release_note import ReleaseNotes from cyclonedx.model.vulnerability import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ @@ -327,3 +327,14 @@ def test_simple_bom_v1_4_with_vulnerabilities(self) -> None: self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) expected_json.close() + + def test_bom_v1_3_with_metadata_component(self) -> None: + bom = Bom() + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY) + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON) + self.assertIsInstance(outputter, JsonV1Dot3) + with open(join(dirname(__file__), 'fixtures/bom_v1.3_with_metadata_component.json')) as expected_json: + self.assertEqualJsonBom(outputter.output_as_string(), expected_json.read()) + + maxDiff = None diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index 67e5a340..a6cb097d 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -25,7 +25,7 @@ from cyclonedx.model import Encoding, ExternalReference, ExternalReferenceType, HashType, Note, NoteText, \ OrganizationalContact, OrganizationalEntity, Property, Tool, XsUri from cyclonedx.model.bom import Bom -from cyclonedx.model.component import Component +from cyclonedx.model.component import Component, ComponentType from cyclonedx.model.impact_analysis import ImpactAnalysisState, ImpactAnalysisJustification, ImpactAnalysisResponse, \ ImpactAnalysisAffectedStatus from cyclonedx.model.issue import IssueClassification, IssueType @@ -433,3 +433,14 @@ def test_with_component_release_notes_post_1_4(self) -> None: self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), namespace=outputter.get_target_namespace()) expected_xml.close() + + def test_bom_v1_3_with_metadata_component(self) -> None: + bom = Bom() + bom.metadata.component = Component( + name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY) + outputter: Xml = get_instance(bom=bom) + self.assertIsInstance(outputter, XmlV1Dot3) + with open(join(dirname(__file__), 'fixtures/bom_v1.3_with_metadata_component.xml')) as expected_xml: + self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), + namespace=outputter.get_target_namespace()) + expected_xml.close()