From 269ee155f203d5771c56edb92f7279466bf2012f Mon Sep 17 00:00:00 2001 From: jblu42 <82205623+jblu42@users.noreply.github.com> Date: Mon, 24 Jan 2022 13:13:50 +0100 Subject: [PATCH] feat: add CPE to component (#138) * Added CPE to component Setting CPE was missing for component, now it is possible to set CPE and output CPE for a component. Signed-off-by: Jens Lucius * Fixing problems with CPE addition - Fixed styling errors - Added reference to CPE Spec - Adding CPE parameter as last parameter to not break arguments Signed-off-by: Jens Lucius * Again fixes for Style and CPE reference Missing in the last commit Signed-off-by: Jens Lucius * Added CPE as argument before deprecated arguments Signed-off-by: Jens Lucius * Added testing for CPE addition and error fixing - Added output tests for CPE in XML and JSON - Fixes style error in components - Fixes order for CPE output in XML (CPE has to come before PURL) Signed-off-by: Jens Lucius * Fixed output tests CPE was still in the wrong position in one of the tests - fixed Signed-off-by: Jens Lucius * Fixed minor test fixtures issues - cpe was still in wrong position in 1.2 JSON - Indentation fixed in 1.4 JSON Signed-off-by: Jens Lucius * Fixed missing comma in JSON 1.2 test file Signed-off-by: Jens Lucius --- cyclonedx/model/component.py | 19 +++- cyclonedx/output/xml.py | 4 + .../fixtures/bom_v1.0_setuptools_with_cpe.xml | 12 +++ .../fixtures/bom_v1.1_setuptools_with_cpe.xml | 12 +++ .../bom_v1.2_setuptools_with_cpe.json | 28 ++++++ .../fixtures/bom_v1.2_setuptools_with_cpe.xml | 21 +++++ .../bom_v1.3_setuptools_with_cpe.json | 32 +++++++ .../fixtures/bom_v1.3_setuptools_with_cpe.xml | 21 +++++ .../bom_v1.4_setuptools_with_cpe.json | 61 +++++++++++++ .../fixtures/bom_v1.4_setuptools_with_cpe.xml | 47 ++++++++++ tests/test_output_json.py | 54 ++++++++++++ tests/test_output_xml.py | 86 +++++++++++++++++++ 12 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/bom_v1.0_setuptools_with_cpe.xml create mode 100644 tests/fixtures/bom_v1.1_setuptools_with_cpe.xml create mode 100644 tests/fixtures/bom_v1.2_setuptools_with_cpe.json create mode 100644 tests/fixtures/bom_v1.2_setuptools_with_cpe.xml create mode 100644 tests/fixtures/bom_v1.3_setuptools_with_cpe.json create mode 100644 tests/fixtures/bom_v1.3_setuptools_with_cpe.xml create mode 100644 tests/fixtures/bom_v1.4_setuptools_with_cpe.json create mode 100644 tests/fixtures/bom_v1.4_setuptools_with_cpe.xml diff --git a/cyclonedx/model/component.py b/cyclonedx/model/component.py index c442725f..f0b1e6ad 100644 --- a/cyclonedx/model/component.py +++ b/cyclonedx/model/component.py @@ -106,6 +106,7 @@ def __init__(self, name: str, component_type: ComponentType = ComponentType.LIBR copyright: Optional[str] = None, purl: Optional[PackageURL] = None, external_references: Optional[List[ExternalReference]] = None, properties: Optional[List[Property]] = None, release_notes: Optional[ReleaseNotes] = None, + cpe: Optional[str] = None, # Deprecated parameters kept for backwards compatibility namespace: Optional[str] = None, license_str: Optional[str] = None ) -> None: @@ -124,6 +125,7 @@ def __init__(self, name: str, component_type: ComponentType = ComponentType.LIBR self.licenses = licenses or [] self.copyright = copyright self.purl = purl + self.cpe = cpe self.external_references = external_references if external_references else [] self.properties = properties @@ -392,6 +394,21 @@ def purl(self) -> Optional[PackageURL]: def purl(self, purl: Optional[PackageURL]) -> None: self._purl = purl + @property + def cpe(self) -> Optional[str]: + """ + Specifies a well-formed CPE name that conforms to the CPE 2.2 or 2.3 specification. + See https://nvd.nist.gov/products/cpe + + Returns: + `str` if set else `None` + """ + return self._cpe + + @cpe.setter + def cpe(self, cpe: Optional[str]) -> None: + self._cpe = cpe + @property def external_references(self) -> List[ExternalReference]: """ @@ -492,7 +509,7 @@ def __hash__(self) -> int: return hash(( self.author, self.bom_ref, self.copyright, self.description, str(self.external_references), self.group, str(self.hashes), str(self.licenses), self.mime_type, self.name, self.properties, self.publisher, self.purl, - self.release_notes, self.scope, self.supplier, self.type, self.version + self.release_notes, self.scope, self.supplier, self.type, self.version, self.cpe )) def __repr__(self) -> str: diff --git a/cyclonedx/output/xml.py b/cyclonedx/output/xml.py index 93a2c282..c4ba91fc 100644 --- a/cyclonedx/output/xml.py +++ b/cyclonedx/output/xml.py @@ -175,6 +175,10 @@ def _add_component_element(self, component: Component) -> ElementTree.Element: else: ElementTree.SubElement(licenses_e, 'expression').text = license.expression + # cpe + if component.cpe: + ElementTree.SubElement(component_element, 'cpe').text = component.cpe + # purl if component.purl: ElementTree.SubElement(component_element, 'purl').text = component.purl.to_string() diff --git a/tests/fixtures/bom_v1.0_setuptools_with_cpe.xml b/tests/fixtures/bom_v1.0_setuptools_with_cpe.xml new file mode 100644 index 00000000..3c617136 --- /dev/null +++ b/tests/fixtures/bom_v1.0_setuptools_with_cpe.xml @@ -0,0 +1,12 @@ + + + + + setuptools + 50.3.2 + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + false + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.1_setuptools_with_cpe.xml b/tests/fixtures/bom_v1.1_setuptools_with_cpe.xml new file mode 100644 index 00000000..d4657548 --- /dev/null +++ b/tests/fixtures/bom_v1.1_setuptools_with_cpe.xml @@ -0,0 +1,12 @@ + + + + + setuptools + 50.3.2 + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.2_setuptools_with_cpe.json b/tests/fixtures/bom_v1.2_setuptools_with_cpe.json new file mode 100644 index 00000000..78d2c8fe --- /dev/null +++ b/tests/fixtures/bom_v1.2_setuptools_with_cpe.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.2a.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.2", + "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" + } + ] + }, + "components": [ + { + "type": "library", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "author": "Test Author", + "name": "setuptools", + "version": "50.3.2", + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.2_setuptools_with_cpe.xml b/tests/fixtures/bom_v1.2_setuptools_with_cpe.xml new file mode 100644 index 00000000..6c19439c --- /dev/null +++ b/tests/fixtures/bom_v1.2_setuptools_with_cpe.xml @@ -0,0 +1,21 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + setuptools + 50.3.2 + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_setuptools_with_cpe.json b/tests/fixtures/bom_v1.3_setuptools_with_cpe.json new file mode 100644 index 00000000..14f551f7 --- /dev/null +++ b/tests/fixtures/bom_v1.3_setuptools_with_cpe.json @@ -0,0 +1,32 @@ +{ + "$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" + } + ] + }, + "components": [ + { + "type": "library", + "name": "setuptools", + "version": "50.3.2", + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "licenses": [ + { + "expression": "MIT License" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.3_setuptools_with_cpe.xml b/tests/fixtures/bom_v1.3_setuptools_with_cpe.xml new file mode 100644 index 00000000..17ae9d66 --- /dev/null +++ b/tests/fixtures/bom_v1.3_setuptools_with_cpe.xml @@ -0,0 +1,21 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + + + + setuptools + 50.3.2 + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_cpe.json b/tests/fixtures/bom_v1.4_setuptools_with_cpe.json new file mode 100644 index 00000000..fe9995a8 --- /dev/null +++ b/tests/fixtures/bom_v1.4_setuptools_with_cpe.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://cyclonedx.org/schema/bom-1.4.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "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", + "externalReferences": [ + { + "type": "build-system", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/actions" + }, + { + "type": "distribution", + "url": "https://pypi.org/project/cyclonedx-python-lib/" + }, + { + "type": "documentation", + "url": "https://cyclonedx.github.io/cyclonedx-python-lib/" + }, + { + "type": "issue-tracker", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/issues" + }, + { + "type": "license", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE" + }, + { + "type": "release-notes", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md" + }, + { + "type": "vcs", + "url": "https://github.com/CycloneDX/cyclonedx-python-lib" + }, + { + "type": "website", + "url": "https://cyclonedx.org" + } + ] + } + ] + }, + "components": [ + { + "type": "library", + "name": "setuptools", + "version": "50.3.2", + "cpe": "cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*", + "purl": "pkg:pypi/setuptools@50.3.2?extension=tar.gz", + "bom-ref": "pkg:pypi/setuptools@50.3.2?extension=tar.gz" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/bom_v1.4_setuptools_with_cpe.xml b/tests/fixtures/bom_v1.4_setuptools_with_cpe.xml new file mode 100644 index 00000000..113848b8 --- /dev/null +++ b/tests/fixtures/bom_v1.4_setuptools_with_cpe.xml @@ -0,0 +1,47 @@ + + + + 2021-09-01T10:50:42.051979+00:00 + + + CycloneDX + cyclonedx-python-lib + VERSION + + + https://github.com/CycloneDX/cyclonedx-python-lib/actions + + + https://pypi.org/project/cyclonedx-python-lib/ + + + https://cyclonedx.github.io/cyclonedx-python-lib/ + + + https://github.com/CycloneDX/cyclonedx-python-lib/issues + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/LICENSE + + + https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md + + + https://github.com/CycloneDX/cyclonedx-python-lib + + + https://cyclonedx.org + + + + + + + + setuptools + 50.3.2 + cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:* + pkg:pypi/setuptools@50.3.2?extension=tar.gz + + + \ No newline at end of file diff --git a/tests/test_output_json.py b/tests/test_output_json.py index 3f4a69d4..cd043f38 100644 --- a/tests/test_output_json.py +++ b/tests/test_output_json.py @@ -90,6 +90,60 @@ def test_simple_bom_v1_2(self) -> None: self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) expected_json.close() + def test_simple_bom_v1_4_with_cpe(self) -> None: + bom = Bom() + c = Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ) + ) + bom.add_component(c) + + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_4) + self.assertIsInstance(outputter, JsonV1Dot4) + with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_with_cpe.json')) as expected_json: + 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_simple_bom_v1_3_with_cpe(self) -> None: + bom = Bom() + c = Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), license_str='MIT License' + ) + bom.add_component(c) + + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON) + self.assertIsInstance(outputter, JsonV1Dot3) + with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools_with_cpe.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) + self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) + expected_json.close() + + def test_simple_bom_v1_2_with_cpe(self) -> None: + bom = Bom() + bom.add_component( + Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ), author='Test Author' + ) + ) + outputter = get_instance(bom=bom, output_format=OutputFormat.JSON, schema_version=SchemaVersion.V1_2) + self.assertIsInstance(outputter, JsonV1Dot2) + with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools_with_cpe.json')) as expected_json: + self.assertValidAgainstSchema(bom_json=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) + self.assertEqualJsonBom(expected_json.read(), outputter.output_as_string()) + expected_json.close() + def test_bom_v1_3_with_component_hashes(self) -> None: bom = Bom() c = Component( diff --git a/tests/test_output_xml.py b/tests/test_output_xml.py index a6cb097d..087b1416 100644 --- a/tests/test_output_xml.py +++ b/tests/test_output_xml.py @@ -121,6 +121,92 @@ def test_simple_bom_v1_0(self) -> None: namespace=outputter.get_target_namespace()) expected_xml.close() + def test_simple_bom_v1_4_with_cpe(self) -> None: + bom = Bom() + bom.add_component(Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ) + )) + outputter: Xml = get_instance(bom=bom, schema_version=SchemaVersion.V1_4) + self.assertIsInstance(outputter, XmlV1Dot4) + with open(join(dirname(__file__), 'fixtures/bom_v1.4_setuptools_with_cpe.xml')) as expected_xml: + self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_4) + self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), + namespace=outputter.get_target_namespace()) + expected_xml.close() + + def test_simple_bom_v1_3_with_cpe(self) -> None: + bom = Bom() + bom.add_component(Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ) + )) + outputter: Xml = get_instance(bom=bom) + self.assertIsInstance(outputter, XmlV1Dot3) + with open(join(dirname(__file__), 'fixtures/bom_v1.3_setuptools_with_cpe.xml')) as expected_xml: + self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_3) + self.assertEqualXmlBom(a=outputter.output_as_string(), b=expected_xml.read(), + namespace=outputter.get_target_namespace()) + expected_xml.close() + + def test_simple_bom_v1_2_with_cpe(self) -> None: + bom = Bom() + bom.add_component(Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ) + )) + outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_2) + self.assertIsInstance(outputter, XmlV1Dot2) + with open(join(dirname(__file__), 'fixtures/bom_v1.2_setuptools_with_cpe.xml')) as expected_xml: + self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_2) + self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), + namespace=outputter.get_target_namespace()) + expected_xml.close() + + def test_simple_bom_v1_1_with_cpe(self) -> None: + bom = Bom() + bom.add_component(Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ) + )) + outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_1) + self.assertIsInstance(outputter, XmlV1Dot1) + with open(join(dirname(__file__), 'fixtures/bom_v1.1_setuptools_with_cpe.xml')) as expected_xml: + self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_1) + self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), + namespace=outputter.get_target_namespace()) + expected_xml.close() + + def test_simple_bom_v1_0_with_cpe(self) -> None: + bom = Bom() + bom.add_component(Component( + name='setuptools', version='50.3.2', bom_ref='pkg:pypi/setuptools@50.3.2?extension=tar.gz', + cpe='cpe:2.3:a:python:setuptools:50.3.2:*:*:*:*:*:*:*', + purl=PackageURL( + type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz' + ) + )) + self.assertEqual(len(bom.components), 1) + outputter = get_instance(bom=bom, schema_version=SchemaVersion.V1_0) + self.assertIsInstance(outputter, XmlV1Dot0) + with open(join(dirname(__file__), 'fixtures/bom_v1.0_setuptools_with_cpe.xml')) as expected_xml: + self.assertValidAgainstSchema(bom_xml=outputter.output_as_string(), schema_version=SchemaVersion.V1_0) + self.assertEqualXmlBom(outputter.output_as_string(), expected_xml.read(), + namespace=outputter.get_target_namespace()) + expected_xml.close() + def test_simple_bom_v1_4_with_vulnerabilities(self) -> None: bom = Bom() nvd = VulnerabilitySource(name='NVD', url=XsUri('https://nvd.nist.gov/vuln/detail/CVE-2018-7489'))