Skip to content

Commit 938169c

Browse files
authored
Merge pull request #210 from CycloneDX/feat/support-bom-dependencies
feat: add support for Dependency Graph in Model and output serialisation (JSON and XML)
2 parents 67ecfac + 2551545 commit 938169c

File tree

88 files changed

+1465
-105
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

88 files changed

+1465
-105
lines changed

cyclonedx/exception/model.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class NoPropertiesProvidedException(CycloneDxModelException):
6969
pass
7070

7171

72+
class UnknownComponentDependencyException(CycloneDxModelException):
73+
"""
74+
Exception raised when a dependency has been noted for a Component that is NOT a Component BomRef in this Bom.
75+
"""
76+
77+
7278
class UnknownHashTypeException(CycloneDxModelException):
7379
"""
7480
Exception raised when we are unable to determine the type of hash from a composite hash string.

cyclonedx/model/bom.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616
#
1717
# SPDX-License-Identifier: Apache-2.0
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
19-
19+
import warnings
2020
from datetime import datetime, timezone
2121
from typing import Iterable, Optional, Set
2222
from uuid import UUID, uuid4
2323

24+
from ..exception.model import UnknownComponentDependencyException
2425
from ..parser import BaseParser
2526
from . import ExternalReference, LicenseChoice, OrganizationalContact, OrganizationalEntity, Property, ThisTool, Tool
2627
from .component import Component
@@ -363,6 +364,37 @@ def has_vulnerabilities(self) -> bool:
363364
"""
364365
return any(c.has_vulnerabilities() for c in self.components)
365366

367+
def validate(self) -> bool:
368+
"""
369+
Perform data-model level validations to make sure we have some known data integrity prior to attempting output
370+
of this `Bom`
371+
372+
Returns:
373+
`bool`
374+
"""
375+
376+
# 1. Make sure dependencies are all in this Bom.
377+
all_bom_refs = set([self.metadata.component.bom_ref] if self.metadata.component else []) | set(
378+
map(lambda c: c.bom_ref, self.components)) | set(map(lambda s: s.bom_ref, self.services))
379+
380+
all_dependency_bom_refs = set().union(*(c.dependencies for c in self.components))
381+
dependency_diff = all_dependency_bom_refs - all_bom_refs
382+
if len(dependency_diff) > 0:
383+
raise UnknownComponentDependencyException(
384+
f'One or more Components have Dependency references to Components/Services that are not known in this '
385+
f'BOM. They are: {dependency_diff}')
386+
387+
# 2. Dependencies should exist for the Component this BOM is describing, if one is set
388+
if self.metadata.component and not self.metadata.component.dependencies:
389+
warnings.warn(
390+
f'The Component this BOM is describing {self.metadata.component.purl} has no defined dependencies'
391+
f'which means the Dependency Graph is incomplete - you should add direct dependencies to this Component'
392+
f'to complete the Dependency Graph data.',
393+
UserWarning
394+
)
395+
396+
return True
397+
366398
def __eq__(self, other: object) -> bool:
367399
if isinstance(other, Bom):
368400
return hash(other) == hash(self)

cyclonedx/model/bom_ref.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def __hash__(self) -> int:
5151
return hash(self.value)
5252

5353
def __repr__(self) -> str:
54-
return f'<BomRef {self.value}'
54+
return f'<BomRef {self.value}>'
5555

5656
def __str__(self) -> str:
5757
return self.value

cyclonedx/model/component.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,7 @@ def __init__(self, *, name: str, component_type: ComponentType = ComponentType.L
743743
if not licenses:
744744
self.licenses = {LicenseChoice(license_expression=license_str)}
745745

746+
self.__dependencies: Set[BomRef] = set()
746747
self.__vulnerabilites: Set[Vulnerability] = set()
747748

748749
@property
@@ -1094,6 +1095,20 @@ def release_notes(self) -> Optional[ReleaseNotes]:
10941095
def release_notes(self, release_notes: Optional[ReleaseNotes]) -> None:
10951096
self._release_notes = release_notes
10961097

1098+
@property
1099+
def dependencies(self) -> Set[BomRef]:
1100+
"""
1101+
Set of `BomRef` that this Component depends on.
1102+
1103+
Returns:
1104+
Set of `BomRef`
1105+
"""
1106+
return self.__dependencies
1107+
1108+
@dependencies.setter
1109+
def dependencies(self, dependencies: Iterable[BomRef]) -> None:
1110+
self.__dependencies = set(dependencies)
1111+
10971112
def add_vulnerability(self, vulnerability: Vulnerability) -> None:
10981113
"""
10991114
Add a Vulnerability to this Component.

cyclonedx/model/dependency.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# encoding: utf-8
2+
3+
# This file is part of CycloneDX Python Lib
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
# SPDX-License-Identifier: Apache-2.0
18+
# Copyright (c) OWASP Foundation. All Rights Reserved.
19+
20+
from typing import Iterable, Optional, Set
21+
22+
from .bom_ref import BomRef
23+
24+
25+
class Dependency:
26+
"""
27+
This is our internal representation of a Dependency for a Component.
28+
29+
.. note::
30+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.4/xml/#type_dependencyType
31+
"""
32+
33+
def __init__(self, *, ref: BomRef, depends_on: Optional[Iterable[BomRef]] = None) -> None:
34+
self._ref = ref
35+
self.depends_on = set(depends_on or [])
36+
37+
@property
38+
def ref(self) -> BomRef:
39+
return self._ref
40+
41+
@property
42+
def depends_on(self) -> Set[BomRef]:
43+
return self._depends_on
44+
45+
@depends_on.setter
46+
def depends_on(self, depends_on: Iterable[BomRef]) -> None:
47+
self._depends_on = set(depends_on)

cyclonedx/output/json.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
import json
2121
from abc import abstractmethod
22-
from typing import Any, Dict, List, Optional, Union, cast
22+
from typing import Any, Dict, Iterable, List, Optional, Union
2323

2424
from ..exception.output import FormatNotSupportedException
2525
from ..model.bom import Bom
@@ -56,28 +56,43 @@ def generate(self, force_regeneration: bool = False) -> None:
5656
if self.generated and not force_regeneration:
5757
return
5858

59+
bom = self.get_bom()
60+
bom.validate()
61+
5962
schema_uri: Optional[str] = self._get_schema_uri()
6063
if not schema_uri:
6164
raise FormatNotSupportedException(
62-
f'JSON is not supported by CycloneDX in schema version {self.schema_version.to_version()}'
63-
)
64-
65-
vulnerabilities: Dict[str, List[Dict[Any, Any]]] = {"vulnerabilities": []}
66-
if self.get_bom().components:
67-
for component in cast(List[Component], self.get_bom().components):
68-
for vulnerability in component.get_vulnerabilities():
69-
vulnerabilities['vulnerabilities'].append(
70-
json.loads(json.dumps(vulnerability, cls=CycloneDxJSONEncoder))
71-
)
72-
73-
bom_json = json.loads(json.dumps(self.get_bom(), cls=CycloneDxJSONEncoder))
65+
f'JSON is not supported by CycloneDX in schema version {self.schema_version.to_version()}')
66+
67+
extras = {}
68+
if self.bom_supports_dependencies():
69+
dep_components: Iterable[Component] = bom.components
70+
if bom.metadata.component:
71+
dep_components = [bom.metadata.component, *dep_components]
72+
dependencies = []
73+
for component in dep_components:
74+
dependencies.append({
75+
'ref': str(component.bom_ref),
76+
'dependsOn': [*map(str, component.dependencies)]
77+
})
78+
if dependencies:
79+
extras["dependencies"] = dependencies
80+
del dep_components
81+
82+
if self.bom_supports_vulnerabilities():
83+
vulnerabilities: List[Dict[Any, Any]] = []
84+
if bom.components:
85+
for component in bom.components:
86+
for vulnerability in component.get_vulnerabilities():
87+
vulnerabilities.append(
88+
json.loads(json.dumps(vulnerability, cls=CycloneDxJSONEncoder))
89+
)
90+
if vulnerabilities:
91+
extras["vulnerabilities"] = vulnerabilities
92+
93+
bom_json = json.loads(json.dumps(bom, cls=CycloneDxJSONEncoder))
7494
bom_json = json.loads(self._specialise_output_for_schema_version(bom_json=bom_json))
75-
if self.bom_supports_vulnerabilities() and vulnerabilities['vulnerabilities']:
76-
self._json_output = json.dumps(
77-
{**self._create_bom_element(), **bom_json, **vulnerabilities}
78-
)
79-
else:
80-
self._json_output = json.dumps({**self._create_bom_element(), **bom_json})
95+
self._json_output = json.dumps({**self._create_bom_element(), **bom_json, **extras})
8196

8297
self.generated = True
8398

cyclonedx/output/schema.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ def bom_supports_services(self) -> bool:
5050
def bom_supports_external_references(self) -> bool:
5151
return True
5252

53+
def bom_supports_dependencies(self) -> bool:
54+
return True
55+
5356
def services_supports_properties(self) -> bool:
5457
return True
5558

@@ -223,6 +226,9 @@ def pedigree_supports_patches(self) -> bool:
223226
def services_supports_release_notes(self) -> bool:
224227
return False
225228

229+
def bom_supports_dependencies(self) -> bool:
230+
return False
231+
226232
def bom_supports_vulnerabilities(self) -> bool:
227233
return False
228234

@@ -278,6 +284,9 @@ def bom_supports_services(self) -> bool:
278284
def bom_supports_external_references(self) -> bool:
279285
return False
280286

287+
def bom_supports_dependencies(self) -> bool:
288+
return False
289+
281290
def services_supports_properties(self) -> bool:
282291
return False
283292

cyclonedx/output/xml.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

2020
import warnings
21-
from typing import Optional, Set
21+
from typing import Iterable, Optional, Set
2222
from xml.etree import ElementTree
2323

2424
from ..model import (
@@ -67,14 +67,17 @@ def generate(self, force_regeneration: bool = False) -> None:
6767
elif self.generated:
6868
return
6969

70+
bom = self.get_bom()
71+
bom.validate()
72+
7073
if self.bom_supports_metadata():
7174
self._add_metadata_element()
7275

73-
components_element = ElementTree.SubElement(self._root_bom_element, 'components')
74-
7576
has_vulnerabilities: bool = False
76-
if self.get_bom().components:
77-
for component in self.get_bom().components:
77+
78+
components_element = ElementTree.SubElement(self._root_bom_element, 'components')
79+
if bom.components:
80+
for component in bom.components:
7881
component_element = self._add_component_element(component=component)
7982
components_element.append(component_element)
8083
if self.bom_supports_vulnerabilities_via_extension() and component.has_vulnerabilities():
@@ -94,22 +97,35 @@ def generate(self, force_regeneration: bool = False) -> None:
9497
elif component.has_vulnerabilities():
9598
has_vulnerabilities = True
9699

97-
if self.bom_supports_services():
98-
if self.get_bom().services:
99-
services_element = ElementTree.SubElement(self._root_bom_element, 'services')
100-
for service in self.get_bom().services:
101-
services_element.append(self._add_service_element(service=service))
102-
103-
if self.bom_supports_external_references():
104-
if self.get_bom().external_references:
105-
self._add_external_references_to_element(
106-
ext_refs=self.get_bom().external_references,
107-
element=self._root_bom_element
108-
)
100+
if self.bom_supports_services() and bom.services:
101+
services_element = ElementTree.SubElement(self._root_bom_element, 'services')
102+
for service in bom.services:
103+
services_element.append(self._add_service_element(service=service))
104+
105+
if self.bom_supports_external_references() and bom.external_references:
106+
self._add_external_references_to_element(
107+
ext_refs=bom.external_references,
108+
element=self._root_bom_element
109+
)
110+
111+
if self.bom_supports_dependencies() and (bom.metadata.component or bom.components):
112+
dep_components: Iterable[Component] = bom.components
113+
if bom.metadata.component:
114+
dep_components = [bom.metadata.component, *dep_components]
115+
dependencies_element = ElementTree.SubElement(self._root_bom_element, 'dependencies')
116+
for component in dep_components:
117+
dependency_element = ElementTree.SubElement(dependencies_element, 'dependency', {
118+
'ref': str(component.bom_ref)
119+
})
120+
for dependency in component.dependencies:
121+
ElementTree.SubElement(dependency_element, 'dependency', {
122+
'ref': str(dependency)
123+
})
124+
del dep_components
109125

110126
if self.bom_supports_vulnerabilities() and has_vulnerabilities:
111127
vulnerabilities_element = ElementTree.SubElement(self._root_bom_element, 'vulnerabilities')
112-
for component in self.get_bom().components:
128+
for component in bom.components:
113129
for vulnerability in component.get_vulnerabilities():
114130
vulnerabilities_element.append(
115131
self._get_vulnerability_as_xml_element_post_1_4(vulnerability=vulnerability)
@@ -126,13 +142,14 @@ def get_target_namespace(self) -> str:
126142

127143
# Builder Methods
128144
def _create_bom_element(self) -> ElementTree.Element:
145+
bom = self.get_bom()
129146
root_attributes = {
130147
'xmlns': self.get_target_namespace(),
131148
'version': '1',
132-
'serialNumber': self.get_bom().get_urn_uuid()
149+
'serialNumber': bom.get_urn_uuid()
133150
}
134151

135-
if self.bom_supports_vulnerabilities_via_extension() and self.get_bom().has_vulnerabilities():
152+
if self.bom_supports_vulnerabilities_via_extension() and bom.has_vulnerabilities():
136153
root_attributes['xmlns:v'] = Xml.VULNERABILITY_EXTENSION_NAMESPACE
137154
ElementTree.register_namespace('v', Xml.VULNERABILITY_EXTENSION_NAMESPACE)
138155

docs/modelling.rst

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,8 @@ Vulnerabilities are supported by the Model as of version 0.3.0.
2323
**Note:** Known vulnerabilities associated with Components can be sourced from various data sources, but this library
2424
will not source them for you. Perhaps look at `Jake`_ if you're interested in this.
2525

26-
Examples
27-
--------
28-
29-
From a Parser
30-
~~~~~~~~~~~~~
26+
Example BOM using a Parser
27+
--------------------------
3128

3229
**Note:** Concreate parser implementations were moved out of this library and into `CycloneDX Python`_ as of version
3330
``1.0.0``.
@@ -40,6 +37,19 @@ From a Parser
4037
parser = EnvironmentParser()
4138
bom = Bom.from_parser(parser=parser)
4239
40+
Example BOM created programmatically
41+
------------------------------------
42+
43+
.. note::
44+
45+
It is recommended that you have a good understanding of the `CycloneDX Schema`_ before attempting to create a BOM
46+
programmatically with this library.
47+
48+
49+
For the most up-to-date in-depth examples, look at our `Unit Tests`_.
50+
4351

4452
.. _CycloneDX Python: https://github.com/CycloneDX/cyclonedx-python
45-
.. _Jake: https://pypi.org/project/jake
53+
.. _Jake: https://pypi.org/project/jake
54+
.. _CycloneDX Schema: https://cyclonedx.org/docs/latest
55+
.. _Unit Tests: https://github.com/CycloneDX/cyclonedx-python-lib/tree/main/tests

docs/schema-support.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ supported in prior versions of the CycloneDX schema.
3636
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
3737
| ``bom.externalReferences`` | Yes | |
3838
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
39-
| ``bom.dependencies`` | No | |
39+
| ``bom.dependencies`` | Yes | Since ``2.3.0`` |
4040
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+
4141
| ``bom.compositions`` | No | |
4242
+----------------------------+---------------+---------------------------------------------------------------------------------------------------+

0 commit comments

Comments
 (0)