Skip to content

Commit bdee0ea

Browse files
authored
Merge pull request #29 from CycloneDX/feat/component-external-references
FEATURE: Add support for `externalReferences` against `Component`s
2 parents 827bd1c + e7a5b5a commit bdee0ea

24 files changed

+1059
-20
lines changed

.github/workflows/docs.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ jobs:
3535

3636
- name: Build documentation
3737
run: |
38-
poetry run pdoc --html cyclonedx
38+
poetry run pdoc --template-dir doc/templates --html cyclonedx
3939
- name: Deploy documentation
4040
uses: JamesIves/[email protected]
4141
with:

cyclonedx/model/__init__.py

+123
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import hashlib
1919
from enum import Enum
20+
from typing import List, Union
2021

2122
"""
2223
Uniform set of models to represent objects within a CycloneDX software bill-of-materials.
@@ -69,6 +70,36 @@ class HashType:
6970
_algorithm: HashAlgorithm
7071
_value: str
7172

73+
@staticmethod
74+
def from_composite_str(composite_hash: str):
75+
"""
76+
Attempts to convert a string which includes both the Hash Algorithm and Hash Value and represent using our
77+
internal model classes.
78+
79+
Args:
80+
composite_hash:
81+
Composite Hash string of the format `HASH_ALGORITHM`:`HASH_VALUE`.
82+
Example: `sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b`.
83+
84+
Returns:
85+
An instance of `HashType` when possible, else `None`.
86+
"""
87+
algorithm: HashAlgorithm = None
88+
parts = composite_hash.split(':')
89+
90+
algorithm_prefix = parts[0].lower()
91+
if algorithm_prefix == 'md5':
92+
algorithm = HashAlgorithm.MD5
93+
elif algorithm_prefix[0:3] == 'sha':
94+
algorithm = getattr(HashAlgorithm, 'SHA_{}'.format(algorithm_prefix[3:]))
95+
elif algorithm_prefix[0:6] == 'blake2':
96+
algorithm = getattr(HashAlgorithm, 'BLAKE2b_{}'.format(algorithm_prefix[6:]))
97+
98+
return HashType(
99+
algorithm=algorithm,
100+
hash_value=parts[1].lower()
101+
)
102+
72103
def __init__(self, algorithm: HashAlgorithm, hash_value: str):
73104
self._algorithm = algorithm
74105
self._value = hash_value
@@ -78,3 +109,95 @@ def get_algorithm(self) -> HashAlgorithm:
78109

79110
def get_hash_value(self) -> str:
80111
return self._value
112+
113+
114+
class ExternalReferenceType(Enum):
115+
"""
116+
Enum object that defines the permissible 'types' for an External Reference according to the CycloneDX schema.
117+
118+
.. note::
119+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_externalReferenceType
120+
"""
121+
ADVISORIES = 'advisories'
122+
BOM = 'bom'
123+
BUILD_META = 'build-meta'
124+
BUILD_SYSTEM = 'build-system'
125+
CHAT = 'chat'
126+
DISTRIBUTION = 'distribution'
127+
DOCUMENTATION = 'documentation'
128+
ISSUE_TRACKER = 'issue-tracker'
129+
LICENSE = 'license'
130+
MAILING_LIST = 'mailing-list'
131+
OTHER = 'other'
132+
SOCIAL = 'social'
133+
SCM = 'vcs'
134+
SUPPORT = 'support'
135+
VCS = 'vcs'
136+
WEBSITE = 'website'
137+
138+
139+
class ExternalReference:
140+
"""
141+
This is out internal representation of an ExternalReference complex type that can be used in multiple places within
142+
a CycloneDX BOM document.
143+
144+
.. note::
145+
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_externalReference
146+
"""
147+
_reference_type: ExternalReferenceType
148+
_url: str
149+
_comment: str
150+
_hashes: List[HashType] = []
151+
152+
def __init__(self, reference_type: ExternalReferenceType, url: str, comment: str = None,
153+
hashes: List[HashType] = []):
154+
self._reference_type = reference_type
155+
self._url = url
156+
self._comment = comment
157+
self._hashes = hashes
158+
159+
def add_hash(self, our_hash: HashType):
160+
"""
161+
Adds a hash that pins/identifies this External Reference.
162+
163+
Args:
164+
our_hash:
165+
`HashType` instance
166+
"""
167+
self._hashes.append(our_hash)
168+
169+
def get_comment(self) -> Union[str, None]:
170+
"""
171+
Get the comment for this External Reference.
172+
173+
Returns:
174+
Any comment as a `str` else `None`.
175+
"""
176+
return self._comment
177+
178+
def get_hashes(self) -> List[HashType]:
179+
"""
180+
List of cryptographic hashes that identify this External Reference.
181+
182+
Returns:
183+
`List` of `HashType` objects where there are any hashes, else an empty `List`.
184+
"""
185+
return self._hashes
186+
187+
def get_reference_type(self) -> ExternalReferenceType:
188+
"""
189+
Get the type of this External Reference.
190+
191+
Returns:
192+
`ExternalReferenceType` that represents the type of this External Reference.
193+
"""
194+
return self._reference_type
195+
196+
def get_url(self) -> str:
197+
"""
198+
Get the URL/URI for this External Reference.
199+
200+
Returns:
201+
URI as a `str`.
202+
"""
203+
return self._url

cyclonedx/model/component.py

+30-4
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from packageurl import PackageURL
2323
from typing import List
2424

25-
from . import HashAlgorithm, HashType, sha1sum
25+
from . import ExternalReference, HashAlgorithm, HashType, sha1sum
2626
from .vulnerability import Vulnerability
2727

2828

@@ -62,6 +62,7 @@ class Component:
6262

6363
_hashes: List[HashType] = []
6464
_vulnerabilites: List[Vulnerability] = []
65+
_external_references: List[ExternalReference] = []
6566

6667
@staticmethod
6768
def for_file(absolute_file_path: str, path_for_bom: str = None):
@@ -92,15 +93,28 @@ def for_file(absolute_file_path: str, path_for_bom: str = None):
9293
package_url_type='generic'
9394
)
9495

95-
def __init__(self, name: str, version: str, qualifiers: str = None, hashes: List[HashType] = [],
96+
def __init__(self, name: str, version: str, qualifiers: str = None, hashes: List[HashType] = None,
9697
component_type: ComponentType = ComponentType.LIBRARY, package_url_type: str = 'pypi'):
9798
self._name = name
9899
self._version = version
99100
self._type = component_type
100101
self._qualifiers = qualifiers
101-
self._hashes = hashes
102-
self._vulnerabilites = []
102+
self._hashes.clear()
103+
if hashes:
104+
self._hashes = hashes
105+
self._vulnerabilites.clear()
103106
self._package_url_type = package_url_type
107+
self._external_references.clear()
108+
109+
def add_external_reference(self, reference: ExternalReference):
110+
"""
111+
Add an `ExternalReference` to this `Component`.
112+
113+
Args:
114+
reference:
115+
`ExternalReference` instance to add.
116+
"""
117+
self._external_references.append(reference)
104118

105119
def add_hash(self, hash: HashType):
106120
"""
@@ -143,6 +157,15 @@ def get_description(self) -> str:
143157
"""
144158
return self._description
145159

160+
def get_external_references(self) -> List[ExternalReference]:
161+
"""
162+
List of external references for this Component.
163+
164+
Returns:
165+
`List` of `ExternalReference` objects where there are any, else an empty `List`.
166+
"""
167+
return self._external_references
168+
146169
def get_hashes(self) -> List[HashType]:
147170
"""
148171
List of cryptographic hashes that identify this Component.
@@ -182,6 +205,9 @@ def get_purl(self) -> str:
182205
base_purl = '{}?{}'.format(base_purl, self._qualifiers)
183206
return base_purl
184207

208+
def get_pypi_url(self) -> str:
209+
return f'https://pypi.org/project/{self.get_name()}/{self.get_version()}'
210+
185211
def get_type(self) -> ComponentType:
186212
"""
187213
Get the type of this Component.

cyclonedx/output/json.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def _get_component_as_dict(self, component: Component) -> dict:
5353
"purl": component.get_purl()
5454
}
5555

56-
if len(component.get_hashes()) > 0:
56+
if component.get_hashes():
5757
hashes = []
5858
for component_hash in component.get_hashes():
5959
hashes.append({
@@ -62,9 +62,31 @@ def _get_component_as_dict(self, component: Component) -> dict:
6262
})
6363
c['hashes'] = hashes
6464

65-
if self.component_supports_author() and component.get_author() is not None:
65+
if self.component_supports_author() and component.get_author():
6666
c['author'] = component.get_author()
6767

68+
if self.component_supports_external_references() and component.get_external_references():
69+
c['externalReferences'] = []
70+
for ext_ref in component.get_external_references():
71+
ref = {
72+
"type": ext_ref.get_reference_type().value,
73+
"url": ext_ref.get_url()
74+
}
75+
76+
if ext_ref.get_comment():
77+
ref['comment'] = ext_ref.get_comment()
78+
79+
if ext_ref.get_hashes():
80+
ref_hashes = []
81+
for ref_hash in ext_ref.get_hashes():
82+
ref_hashes.append({
83+
"alg": ref_hash.get_algorithm().value,
84+
"content": ref_hash.get_hash_value()
85+
})
86+
ref['hashes'] = ref_hashes
87+
88+
c['externalReferences'].append(ref)
89+
6890
return c
6991

7092
def _get_metadata_as_dict(self) -> dict:

cyclonedx/output/schema.py

+6
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ def component_supports_author(self) -> bool:
3434
def component_supports_bom_ref(self) -> bool:
3535
return True
3636

37+
def component_supports_external_references(self) -> bool:
38+
return True
39+
3740
def get_schema_version(self) -> str:
3841
pass
3942

@@ -79,5 +82,8 @@ def component_supports_author(self) -> bool:
7982
def component_supports_bom_ref(self) -> bool:
8083
return False
8184

85+
def component_supports_external_references(self) -> bool:
86+
return False
87+
8288
def get_schema_version(self) -> str:
8389
return '1.0'

cyclonedx/output/xml.py

+33-7
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,12 @@
1717
# SPDX-License-Identifier: Apache-2.0
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

20+
from typing import List
2021
from xml.etree import ElementTree
2122

2223
from . import BaseOutput
2324
from .schema import BaseSchemaVersion, SchemaVersion1Dot0, SchemaVersion1Dot1, SchemaVersion1Dot2, SchemaVersion1Dot3
25+
from ..model import HashType
2426
from ..model.component import Component
2527
from ..model.vulnerability import Vulnerability, VulnerabilityRating
2628

@@ -89,16 +91,32 @@ def _get_component_as_xml_element(self, component: Component) -> ElementTree.Ele
8991
# version
9092
ElementTree.SubElement(component_element, 'version').text = component.get_version()
9193

94+
# hashes
95+
if len(component.get_hashes()) > 0:
96+
Xml._add_hashes_to_element(hashes=component.get_hashes(), element=component_element)
97+
# hashes_e = ElementTree.SubElement(component_element, 'hashes')
98+
# for hash in component.get_hashes():
99+
# ElementTree.SubElement(
100+
# hashes_e, 'hash', {'alg': hash.get_algorithm().value}
101+
# ).text = hash.get_hash_value()
102+
92103
# purl
93104
ElementTree.SubElement(component_element, 'purl').text = component.get_purl()
94105

95-
# hashes
96-
if len(component.get_hashes()) > 0:
97-
hashes_e = ElementTree.SubElement(component_element, 'hashes')
98-
for hash in component.get_hashes():
99-
ElementTree.SubElement(
100-
hashes_e, 'hash', {'alg': hash.get_algorithm().value}
101-
).text = hash.get_hash_value()
106+
# externalReferences
107+
if self.component_supports_external_references() and len(component.get_external_references()) > 0:
108+
external_references_e = ElementTree.SubElement(component_element, 'externalReferences')
109+
for ext_ref in component.get_external_references():
110+
external_reference_e = ElementTree.SubElement(
111+
external_references_e, 'reference', {'type': ext_ref.get_reference_type().value}
112+
)
113+
ElementTree.SubElement(external_reference_e, 'url').text = ext_ref.get_url()
114+
115+
if ext_ref.get_comment():
116+
ElementTree.SubElement(external_reference_e, 'comment').text = ext_ref.get_comment()
117+
118+
if len(ext_ref.get_hashes()) > 0:
119+
Xml._add_hashes_to_element(hashes=ext_ref.get_hashes(), element=external_reference_e)
102120

103121
return component_element
104122

@@ -194,6 +212,14 @@ def _add_metadata(self, bom: ElementTree.Element) -> ElementTree.Element:
194212

195213
return bom
196214

215+
@staticmethod
216+
def _add_hashes_to_element(hashes: List[HashType], element: ElementTree.Element):
217+
hashes_e = ElementTree.SubElement(element, 'hashes')
218+
for h in hashes:
219+
ElementTree.SubElement(
220+
hashes_e, 'hash', {'alg': h.get_algorithm().value}
221+
).text = h.get_hash_value()
222+
197223

198224
class XmlV1Dot0(Xml, SchemaVersion1Dot0):
199225

0 commit comments

Comments
 (0)