Skip to content

Commit 2938a6c

Browse files
authored
feat: support complete model for bom.metadata (#162)
* feat: support complete model for `bom.metadata` fix: JSON comparison in unit tests was broken chore: corrected some source license headers Signed-off-by: Paul Horton <[email protected]>
1 parent 142b8bf commit 2938a6c

12 files changed

+446
-26
lines changed

cyclonedx/model/bom.py

Lines changed: 100 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from typing import Iterable, Optional, Set
2121
from uuid import uuid4, UUID
2222

23-
from . import ExternalReference, ThisTool, Tool
23+
from . import ExternalReference, OrganizationalContact, OrganizationalEntity, LicenseChoice, Property, ThisTool, Tool
2424
from .component import Component
2525
from .service import Service
2626
from ..parser import BaseParser
@@ -31,17 +31,40 @@ class BomMetaData:
3131
This is our internal representation of the metadata complex type within the CycloneDX standard.
3232
3333
.. note::
34-
See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.3/#type_metadata
34+
See the CycloneDX Schema for Bom metadata: https://cyclonedx.org/docs/1.4/#type_metadata
3535
"""
3636

37-
def __init__(self, *, tools: Optional[Iterable[Tool]] = None) -> None:
37+
def __init__(self, *, tools: Optional[Iterable[Tool]] = None,
38+
authors: Optional[Iterable[OrganizationalContact]] = None, component: Optional[Component] = None,
39+
manufacture: Optional[OrganizationalEntity] = None,
40+
supplier: Optional[OrganizationalEntity] = None,
41+
licenses: Optional[Iterable[LicenseChoice]] = None,
42+
properties: Optional[Iterable[Property]] = None) -> None:
3843
self.timestamp = datetime.now(tz=timezone.utc)
3944
self.tools = set(tools or [])
45+
self.authors = set(authors or [])
46+
self.component = component
47+
self.manufacture = manufacture
48+
self.supplier = supplier
49+
self.licenses = set(licenses or [])
50+
self.properties = set(properties or [])
4051

4152
if not self.tools:
4253
self.tools.add(ThisTool)
4354

44-
self.component: Optional[Component] = None
55+
@property
56+
def timestamp(self) -> datetime:
57+
"""
58+
The date and time (in UTC) when this BomMetaData was created.
59+
60+
Returns:
61+
`datetime` instance in UTC timezone
62+
"""
63+
return self._timestamp
64+
65+
@timestamp.setter
66+
def timestamp(self, timestamp: datetime) -> None:
67+
self._timestamp = timestamp
4568

4669
@property
4770
def tools(self) -> Set[Tool]:
@@ -58,18 +81,22 @@ def tools(self, tools: Iterable[Tool]) -> None:
5881
self._tools = set(tools)
5982

6083
@property
61-
def timestamp(self) -> datetime:
84+
def authors(self) -> Set[OrganizationalContact]:
6285
"""
63-
The date and time (in UTC) when this BomMetaData was created.
86+
The person(s) who created the BOM.
87+
88+
Authors are common in BOMs created through manual processes.
89+
90+
BOMs created through automated means may not have authors.
6491
6592
Returns:
66-
`datetime` instance in UTC timezone
93+
Set of `OrganizationalContact`
6794
"""
68-
return self._timestamp
95+
return self._authors
6996

70-
@timestamp.setter
71-
def timestamp(self, timestamp: datetime) -> None:
72-
self._timestamp = timestamp
97+
@authors.setter
98+
def authors(self, authors: Iterable[OrganizationalContact]) -> None:
99+
self._authors = set(authors)
73100

74101
@property
75102
def component(self) -> Optional[Component]:
@@ -95,6 +122,68 @@ def component(self, component: Component) -> None:
95122
"""
96123
self._component = component
97124

125+
@property
126+
def manufacture(self) -> Optional[OrganizationalEntity]:
127+
"""
128+
The organization that manufactured the component that the BOM describes.
129+
130+
Returns:
131+
`OrganizationalEntity` if set else `None`
132+
"""
133+
return self._manufacture
134+
135+
@manufacture.setter
136+
def manufacture(self, manufacture: Optional[OrganizationalEntity]) -> None:
137+
self._manufacture = manufacture
138+
139+
@property
140+
def supplier(self) -> Optional[OrganizationalEntity]:
141+
"""
142+
The organization that supplied the component that the BOM describes.
143+
144+
The supplier may often be the manufacturer, but may also be a distributor or repackager.
145+
146+
Returns:
147+
`OrganizationalEntity` if set else `None`
148+
"""
149+
return self._supplier
150+
151+
@supplier.setter
152+
def supplier(self, supplier: Optional[OrganizationalEntity]) -> None:
153+
self._supplier = supplier
154+
155+
@property
156+
def licenses(self) -> Set[LicenseChoice]:
157+
"""
158+
A optional list of statements about how this BOM is licensed.
159+
160+
Returns:
161+
Set of `LicenseChoice`
162+
"""
163+
return self._licenses
164+
165+
@licenses.setter
166+
def licenses(self, licenses: Iterable[LicenseChoice]) -> None:
167+
self._licenses = set(licenses)
168+
169+
@property
170+
def properties(self) -> Set[Property]:
171+
"""
172+
Provides the ability to document properties in a key/value store. This provides flexibility to include data not
173+
officially supported in the standard without having to use additional namespaces or create extensions.
174+
175+
Property names of interest to the general public are encouraged to be registered in the CycloneDX Property
176+
Taxonomy - https://github.com/CycloneDX/cyclonedx-property-taxonomy. Formal registration is OPTIONAL.
177+
178+
Return:
179+
Set of `Property`
180+
"""
181+
return self._properties
182+
183+
@properties.setter
184+
def properties(self, properties: Iterable[Property]) -> None:
185+
self._properties = set(properties)
186+
98187
def __eq__(self, other: object) -> bool:
99188
if isinstance(other, BomMetaData):
100189
return hash(other) == hash(self)

cyclonedx/output/json.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
#
1717
# SPDX-License-Identifier: Apache-2.0
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
19-
2019
import json
2120
from abc import abstractmethod
2221
from typing import cast, Any, Dict, List, Optional, Union
@@ -79,13 +78,20 @@ def _specialise_output_for_schema_version(self, bom_json: Dict[Any, Any]) -> str
7978
if not self.bom_supports_metadata():
8079
if 'metadata' in bom_json.keys():
8180
del bom_json['metadata']
82-
elif not self.bom_metadata_supports_tools():
81+
82+
if not self.bom_metadata_supports_tools():
8383
del bom_json['metadata']['tools']
8484
elif not self.bom_metadata_supports_tools_external_references():
8585
for i in range(len(bom_json['metadata']['tools'])):
8686
if 'externalReferences' in bom_json['metadata']['tools'][i].keys():
8787
del bom_json['metadata']['tools'][i]['externalReferences']
8888

89+
if not self.bom_metadata_supports_licenses() and 'licenses' in bom_json['metadata'].keys():
90+
del bom_json['metadata']['licenses']
91+
92+
if not self.bom_metadata_supports_properties() and 'properties' in bom_json['metadata'].keys():
93+
del bom_json['metadata']['properties']
94+
8995
# Iterate Components
9096
if 'components' in bom_json.keys():
9197
for i in range(len(bom_json['components'])):

cyclonedx/output/schema.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ def bom_metadata_supports_tools(self) -> bool:
3838
def bom_metadata_supports_tools_external_references(self) -> bool:
3939
return True
4040

41+
def bom_metadata_supports_licenses(self) -> bool:
42+
return True
43+
44+
def bom_metadata_supports_properties(self) -> bool:
45+
return True
46+
4147
def bom_supports_services(self) -> bool:
4248
return True
4349

@@ -147,6 +153,12 @@ def schema_version_enum(self) -> SchemaVersion:
147153
def bom_metadata_supports_tools_external_references(self) -> bool:
148154
return False
149155

156+
def bom_metadata_supports_licenses(self) -> bool:
157+
return False
158+
159+
def bom_metadata_supports_properties(self) -> bool:
160+
return False
161+
150162
def services_supports_properties(self) -> bool:
151163
return False
152164

cyclonedx/output/xml.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,33 @@ def _add_metadata_element(self) -> None:
144144
for tool in bom_metadata.tools:
145145
self._add_tool(parent_element=tools_e, tool=tool)
146146

147+
if bom_metadata.authors:
148+
authors_e = ElementTree.SubElement(metadata_e, 'authors')
149+
for author in bom_metadata.authors:
150+
Xml._add_organizational_contact(
151+
parent_element=authors_e, contact=author, tag_name='author'
152+
)
153+
147154
if bom_metadata.component:
148155
metadata_e.append(self._add_component_element(component=bom_metadata.component))
149156

157+
if bom_metadata.manufacture:
158+
Xml._add_organizational_entity(
159+
parent_element=metadata_e, organization=bom_metadata.manufacture, tag_name='manufacture'
160+
)
161+
162+
if bom_metadata.supplier:
163+
Xml._add_organizational_entity(
164+
parent_element=metadata_e, organization=bom_metadata.supplier, tag_name='supplier'
165+
)
166+
167+
if self.bom_metadata_supports_licenses() and bom_metadata.licenses:
168+
licenses_e = ElementTree.SubElement(metadata_e, 'licenses')
169+
self._add_licenses_to_element(licenses=bom_metadata.licenses, parent_element=licenses_e)
170+
171+
if self.bom_metadata_supports_properties() and bom_metadata.properties:
172+
Xml._add_properties_element(properties=bom_metadata.properties, parent_element=metadata_e)
173+
150174
def _add_component_element(self, component: Component) -> ElementTree.Element:
151175
element_attributes = {'type': component.type.value}
152176
if self.component_supports_bom_ref_attribute() and component.bom_ref:

tests/base.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import sys
2323
import xml.etree.ElementTree
2424
from datetime import datetime, timezone
25+
from typing import Any
2526
from unittest import TestCase
2627
from uuid import uuid4
2728

@@ -66,10 +67,19 @@ def assertValidAgainstSchema(self, bom_json: str, schema_version: SchemaVersion)
6667
else:
6768
self.assertTrue(True, 'JSON Schema Validation is not possible in Python < 3.7')
6869

70+
@staticmethod
71+
def _sort_json_dict(item: object) -> Any:
72+
if isinstance(item, dict):
73+
return sorted((key, BaseJsonTestCase._sort_json_dict(values)) for key, values in item.items())
74+
if isinstance(item, list):
75+
return sorted(BaseJsonTestCase._sort_json_dict(x) for x in item)
76+
else:
77+
return item
78+
6979
def assertEqualJson(self, a: str, b: str) -> None:
7080
self.assertEqual(
71-
json.dumps(sorted(json.loads(a)), sort_keys=True),
72-
json.dumps(sorted(json.loads(b)), sort_keys=True)
81+
BaseJsonTestCase._sort_json_dict(json.loads(a)),
82+
BaseJsonTestCase._sort_json_dict(json.loads(b))
7383
)
7484

7585
def assertEqualJsonBom(self, a: str, b: str) -> None:
@@ -123,7 +133,7 @@ def assertValidAgainstSchema(self, bom_xml: str, schema_version: SchemaVersion)
123133
def assertEqualXml(self, a: str, b: str) -> None:
124134
diff_results = main.diff_texts(a, b, diff_options={'F': 0.5})
125135
diff_results = list(filter(lambda o: not isinstance(o, MoveNode), diff_results))
126-
self.assertEqual(len(diff_results), 0)
136+
self.assertEqual(len(diff_results), 0, f'There are XML differences: {diff_results}')
127137

128138
def assertEqualXmlBom(self, a: str, b: str, namespace: str) -> None:
129139
"""

tests/data.py

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,7 @@ def get_bom_with_component_setuptools_with_vulnerability() -> Bom:
117117
organizations=[
118118
get_org_entity_1()
119119
],
120-
individuals=[
121-
OrganizationalContact(name='A N Other', email='[email protected]', phone='+44 (0)1234 567890'),
122-
]
120+
individuals=[get_org_contact_2()]
123121
),
124122
tools=[
125123
Tool(vendor='CycloneDX', name='cyclonedx-python-lib')
@@ -148,9 +146,14 @@ def get_bom_with_component_toml_1() -> Bom:
148146

149147
def get_bom_just_complete_metadata() -> Bom:
150148
bom = Bom()
149+
bom.metadata.authors = [get_org_contact_1(), get_org_contact_2()]
151150
bom.metadata.component = Component(
152151
name='cyclonedx-python-lib', version='1.0.0', component_type=ComponentType.LIBRARY
153152
)
153+
bom.metadata.manufacture = get_org_entity_1()
154+
bom.metadata.supplier = get_org_entity_2()
155+
bom.metadata.licenses = [LicenseChoice(license_expression='Commercial')]
156+
bom.metadata.properties = get_properties_1()
154157
return bom
155158

156159

@@ -326,13 +329,23 @@ def get_issue_2() -> IssueType:
326329
)
327330

328331

332+
def get_org_contact_1() -> OrganizationalContact:
333+
return OrganizationalContact(name='Paul Horton', email='[email protected]')
334+
335+
336+
def get_org_contact_2() -> OrganizationalContact:
337+
return OrganizationalContact(name='A N Other', email='[email protected]', phone='+44 (0)1234 567890')
338+
339+
329340
def get_org_entity_1() -> OrganizationalEntity:
330341
return OrganizationalEntity(
331-
name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[
332-
OrganizationalContact(name='Paul Horton', email='[email protected]'),
333-
OrganizationalContact(name='A N Other', email='[email protected]',
334-
phone='+44 (0)1234 567890')
335-
]
342+
name='CycloneDX', urls=[XsUri('https://cyclonedx.org')], contacts=[get_org_contact_1(), get_org_contact_2()]
343+
)
344+
345+
346+
def get_org_entity_2() -> OrganizationalEntity:
347+
return OrganizationalEntity(
348+
name='Cyclone DX', urls=[XsUri('https://cyclonedx.org/')], contacts=[get_org_contact_2()]
336349
)
337350

338351

tests/fixtures/json/1.2/bom_with_full_metadata.json

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,52 @@
1313
"version": "VERSION"
1414
}
1515
],
16+
"authors": [
17+
{
18+
"name": "Paul Horton",
19+
"email": "[email protected]"
20+
},
21+
{
22+
"name": "A N Other",
23+
"email": "[email protected]",
24+
"phone": "+44 (0)1234 567890"
25+
}
26+
],
1627
"component": {
1728
"bom-ref": "0b049d09-64c0-4490-a0f5-c84d9aacf857",
1829
"type": "library",
1930
"name": "cyclonedx-python-lib",
2031
"version": "1.0.0"
32+
},
33+
"manufacture": {
34+
"name": "CycloneDX",
35+
"url": [
36+
"https://cyclonedx.org"
37+
],
38+
"contact": [
39+
{
40+
"name": "Paul Horton",
41+
"email": "[email protected]"
42+
},
43+
{
44+
"name": "A N Other",
45+
"email": "[email protected]",
46+
"phone": "+44 (0)1234 567890"
47+
}
48+
]
49+
},
50+
"supplier": {
51+
"name": "Cyclone DX",
52+
"url": [
53+
"https://cyclonedx.org/"
54+
],
55+
"contact": [
56+
{
57+
"name": "A N Other",
58+
"email": "[email protected]",
59+
"phone": "+44 (0)1234 567890"
60+
}
61+
]
2162
}
2263
},
2364
"components": []

0 commit comments

Comments
 (0)