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'))