Skip to content

Commit 7d1e6ef

Browse files
committed
feat: add support for tool(s) that generated the SBOM
Signed-off-by: Paul Horton <[email protected]>
1 parent cf13c68 commit 7d1e6ef

13 files changed

+235
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ _Note: We refer throughout using XPath, but the same is true for both XML and JS
161161
<td><code>/bom/metadata</code></td>
162162
<td>Y</td><td>Y</td><td>N/A</td><td>N/A</td>
163163
<td>
164-
Only <code>timestamp</code> is currently supported
164+
<code>timestamp</code> and <code>tools</code> are currently supported
165165
</td>
166166
</tr>
167167
<tr>

cyclonedx/model/__init__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,55 @@
1515
# SPDX-License-Identifier: Apache-2.0
1616
#
1717

18+
from enum import Enum
19+
1820
"""
1921
Uniform set of models to represent objects within a CycloneDX software bill-of-materials.
2022
2123
You can either create a `cyclonedx.model.bom.Bom` yourself programmatically, or generate a `cyclonedx.model.bom.Bom`
2224
from a `cyclonedx.parser.BaseParser` implementation.
2325
"""
26+
27+
28+
class HashAlgorithm(Enum):
29+
"""
30+
This is out internal representation of the hashAlg simple type within the CycloneDX standard.
31+
32+
.. note::
33+
See the CycloneDX Schema: https://cyclonedx.org/docs/1.3/#type_hashAlg
34+
"""
35+
36+
BLAKE2B_256 = 'BLAKE2b-256'
37+
BLAKE2B_384 = 'BLAKE2b-384'
38+
BLAKE2B_512 = 'BLAKE2b-512'
39+
BLAKE3 = 'BLAKE3'
40+
MD5 = 'MD5'
41+
SHA_1 = 'SHA-1'
42+
SHA_256 = 'SHA-256'
43+
SHA_384 = 'SHA-384'
44+
SHA_512 = 'SHA-512'
45+
SHA3_256 = 'SHA3-256'
46+
SHA3_384 = 'SHA3-384'
47+
SHA3_512 = 'SHA3-512'
48+
49+
50+
class HashType:
51+
"""
52+
This is out internal representation of the hashType complex type within the CycloneDX standard.
53+
54+
.. note::
55+
See the CycloneDX Schema for hashType: https://cyclonedx.org/docs/1.3/#type_hashType
56+
"""
57+
58+
_algorithm: HashAlgorithm
59+
_value: str
60+
61+
def __init__(self, algorithm: HashAlgorithm, hash_value: str):
62+
self._algorithm = algorithm
63+
self._value = hash_value
64+
65+
def get_algorithm(self) -> HashAlgorithm:
66+
return self._algorithm
67+
68+
def get_hash_value(self) -> str:
69+
return self._value

cyclonedx/model/bom.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,82 @@
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

2020
import datetime
21+
from importlib.metadata import version
2122
from typing import List
2223
from uuid import uuid4
2324

25+
from . import HashType
2426
from .component import Component
2527
from ..parser import BaseParser
2628

2729

30+
class Tool:
31+
"""
32+
This is out internal representation of the toolType complex type within the CycloneDX standard.
33+
34+
Tool(s) are the things used in the creation of the BOM.
35+
36+
.. note::
37+
See the CycloneDX Schema for toolType: https://cyclonedx.org/docs/1.3/#type_toolType
38+
"""
39+
40+
_vendor: str = None
41+
_name: str = None
42+
_version: str = None
43+
_hashes: List[HashType] = []
44+
45+
def __init__(self, vendor: str, name: str, version: str, hashes: List[HashType] = []):
46+
self._vendor = vendor
47+
self._name = name
48+
self._version = version
49+
self._hashes = hashes
50+
51+
def get_hashes(self) -> List[HashType]:
52+
"""
53+
List of cryptographic hashes that identify this version of this Tool.
54+
55+
Returns:
56+
`List` of `HashType` objects where there are any hashes, else an empty `List`.
57+
"""
58+
return self._hashes
59+
60+
def get_name(self) -> str:
61+
"""
62+
The name of this Tool.
63+
64+
Returns:
65+
`str` representing the name of the Tool
66+
"""
67+
return self._name
68+
69+
def get_vendor(self) -> str:
70+
"""
71+
The vendor of this Tool.
72+
73+
Returns:
74+
`str` representing the vendor of the Tool
75+
"""
76+
return self._vendor
77+
78+
def get_version(self) -> str:
79+
"""
80+
The version of this Tool.
81+
82+
Returns:
83+
`str` representing the version of the Tool
84+
"""
85+
return self._version
86+
87+
def __repr__(self):
88+
return '<Tool {}:{}:{}>'.format(self._vendor, self._name, self._version)
89+
90+
91+
try:
92+
ThisTool = Tool(vendor='CycloneDX', name='cyclonedx-python-lib', version=version('cyclonedx-python-lib'))
93+
except Exception:
94+
ThisTool = Tool(vendor='CycloneDX', name='cyclonedx-python-lib', version='UNKNOWN')
95+
96+
2897
class BomMetaData:
2998
"""
3099
This is our internal representation of the metadata complex type within the CycloneDX standard.
@@ -34,9 +103,13 @@ class BomMetaData:
34103
"""
35104

36105
_timestamp: datetime.datetime
106+
_tools: List[Tool] = []
37107

38-
def __init__(self):
108+
def __init__(self, tools: List[Tool] = []):
39109
self._timestamp = datetime.datetime.now(tz=datetime.timezone.utc)
110+
if len(tools) == 0:
111+
tools.append(ThisTool)
112+
self._tools = tools
40113

41114
def get_timestamp(self) -> datetime.datetime:
42115
"""
@@ -47,6 +120,15 @@ def get_timestamp(self) -> datetime.datetime:
47120
"""
48121
return self._timestamp
49122

123+
def get_tools(self) -> List[Tool]:
124+
"""
125+
Tools used to create this BOM.
126+
127+
Returns:
128+
`List` of `Tool` objects where there are any, else an empty `List`.
129+
"""
130+
return self._tools
131+
50132

51133
class Bom:
52134
"""

cyclonedx/output/json.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,22 @@ def _get_component_as_dict(self, component: Component) -> dict:
5959
return c
6060

6161
def _get_metadata_as_dict(self) -> dict:
62-
metadata = self.get_bom().get_metadata()
63-
return {
64-
"timestamp": metadata.get_timestamp().isoformat()
62+
bom_metadata = self.get_bom().get_metadata()
63+
metadata = {
64+
"timestamp": bom_metadata.get_timestamp().isoformat()
6565
}
6666

67+
if self.bom_metadata_supports_tools() and len(bom_metadata.get_tools()) > 0:
68+
metadata['tools'] = []
69+
for tool in bom_metadata.get_tools():
70+
metadata['tools'].append({
71+
"vendor": tool.get_vendor(),
72+
"name": tool.get_name(),
73+
"version": tool.get_version()
74+
})
75+
76+
return metadata
77+
6778

6879
class JsonV1Dot0(Json, SchemaVersion1Dot0):
6980
pass

cyclonedx/output/schema.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323
class BaseSchemaVersion(ABC):
2424

25+
def bom_metadata_supports_tools(self) -> bool:
26+
return True
27+
2528
def bom_supports_metadata(self) -> bool:
2629
return True
2730

@@ -49,6 +52,9 @@ def get_schema_version(self) -> str:
4952

5053
class SchemaVersion1Dot1(BaseSchemaVersion):
5154

55+
def bom_metadata_supports_tools(self) -> bool:
56+
return False
57+
5258
def bom_supports_metadata(self) -> bool:
5359
return False
5460

@@ -61,6 +67,9 @@ def get_schema_version(self) -> str:
6167

6268
class SchemaVersion1Dot0(BaseSchemaVersion):
6369

70+
def bom_metadata_supports_tools(self) -> bool:
71+
return False
72+
6473
def bom_supports_metadata(self) -> bool:
6574
return False
6675

cyclonedx/output/xml.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,8 +187,24 @@ def _get_vulnerability_as_xml_element(bom_ref: str, vulnerability: Vulnerability
187187
return vulnerability_element
188188

189189
def _add_metadata(self, bom: ElementTree.Element) -> ElementTree.Element:
190+
bom_metadata = self.get_bom().get_metadata()
191+
190192
metadata_e = ElementTree.SubElement(bom, 'metadata')
191-
ElementTree.SubElement(metadata_e, 'timestamp').text = self.get_bom().get_metadata().get_timestamp().isoformat()
193+
ElementTree.SubElement(metadata_e, 'timestamp').text = bom_metadata.get_timestamp().isoformat()
194+
195+
if self.bom_metadata_supports_tools() and len(bom_metadata.get_tools()) > 0:
196+
tools_e = ElementTree.SubElement(metadata_e, 'tools')
197+
for tool in bom_metadata.get_tools():
198+
tool_e = ElementTree.SubElement(tools_e, 'tool')
199+
ElementTree.SubElement(tool_e, 'vendor').text = tool.get_vendor()
200+
ElementTree.SubElement(tool_e, 'name').text = tool.get_name()
201+
ElementTree.SubElement(tool_e, 'version').text = tool.get_version()
202+
if len(tool.get_hashes()) > 0:
203+
hashes_e = ElementTree.SubElement(tool_e, 'hashes')
204+
for hash in tool.get_hashes():
205+
ElementTree.SubElement(hashes_e, 'hash',
206+
{'alg': hash.get_algorithm().value}).text = hash.get_hash_value()
207+
192208
return bom
193209

194210

tests/base.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,13 @@
2020
import json
2121
import xml.etree.ElementTree
2222
from datetime import datetime, timezone
23+
from importlib.metadata import version
2324
from unittest import TestCase
2425
from uuid import uuid4
2526
from xml.dom import minidom
2627

28+
cyclonedx_lib_name: str = 'cyclonedx-python-lib'
29+
cyclonedx_lib_version: str = version(cyclonedx_lib_name)
2730
single_uuid: str = 'urn:uuid:{}'.format(uuid4())
2831

2932

@@ -50,6 +53,17 @@ def assertEqualJsonBom(self, a: str, b: str):
5053
ab['metadata']['timestamp'] = now.isoformat()
5154
bb['metadata']['timestamp'] = now.isoformat()
5255

56+
# Align 'this' Tool Version
57+
if 'tools' in ab['metadata'].keys():
58+
for i, tool in enumerate(ab['metadata']['tools']):
59+
if tool['name'] == cyclonedx_lib_name:
60+
ab['metadata']['tools'][i]['version'] = cyclonedx_lib_version
61+
62+
if 'tools' in bb['metadata'].keys():
63+
for i, tool in enumerate(bb['metadata']['tools']):
64+
if tool['name'] == cyclonedx_lib_name:
65+
bb['metadata']['tools'][i]['version'] = cyclonedx_lib_version
66+
5367
self.assertEqualJson(json.dumps(ab), json.dumps(bb))
5468

5569

@@ -80,6 +94,14 @@ def assertEqualXmlBom(self, a: str, b: str, namespace: str):
8094
if metadata_ts_b is not None:
8195
metadata_ts_b.text = now.isoformat()
8296

97+
# Align 'this' Tool Version
98+
this_tool = ba.find('.//*/{{{}}}tool[{{{}}}version="VERSION"]'.format(namespace, namespace))
99+
if this_tool:
100+
this_tool.find('./{{{}}}version'.format(namespace)).text = cyclonedx_lib_version
101+
this_tool = bb.find('.//*/{{{}}}tool[{{{}}}version="VERSION"]'.format(namespace, namespace))
102+
if this_tool:
103+
this_tool.find('./{{{}}}version'.format(namespace)).text = cyclonedx_lib_version
104+
83105
self.assertEqualXml(
84106
xml.etree.ElementTree.tostring(ba, 'unicode'),
85107
xml.etree.ElementTree.tostring(bb, 'unicode')

tests/fixtures/bom_v1.2_setuptools.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
55
"version": 1,
66
"metadata": {
7-
"timestamp": "2021-09-01T10:50:42.051979+00:00"
7+
"timestamp": "2021-09-01T10:50:42.051979+00:00",
8+
"tools": [
9+
{
10+
"vendor": "CycloneDX",
11+
"name": "cyclonedx-python-lib",
12+
"version": "VERSION"
13+
}
14+
]
815
},
916
"components": [
1017
{

tests/fixtures/bom_v1.2_setuptools.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
<bom xmlns="http://cyclonedx.org/schema/bom/1.2" version="1">
33
<metadata>
44
<timestamp>2021-09-01T10:50:42.051979+00:00</timestamp>
5+
<tools>
6+
<tool>
7+
<vendor>CycloneDX</vendor>
8+
<name>cyclonedx-python-lib</name>
9+
<version>VERSION</version>
10+
</tool>
11+
</tools>
512
</metadata>
613
<components>
714
<component type="library" bom-ref="pkg:pypi/[email protected]?extension=tar.gz">

tests/fixtures/bom_v1.3_setuptools.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@
44
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
55
"version": 1,
66
"metadata": {
7-
"timestamp": "2021-09-01T10:50:42.051979+00:00"
7+
"timestamp": "2021-09-01T10:50:42.051979+00:00",
8+
"tools": [
9+
{
10+
"vendor": "CycloneDX",
11+
"name": "cyclonedx-python-lib",
12+
"version": "VERSION"
13+
}
14+
]
815
},
916
"components": [
1017
{

0 commit comments

Comments
 (0)