Skip to content

Commit 63a86b0

Browse files
authored
Merge pull request #25 from CycloneDX/feat/additions-to-enable-integration-into-checkov
Support for representing File as Component
2 parents a655d29 + 7e0fb3c commit 63a86b0

File tree

5 files changed

+122
-31
lines changed

5 files changed

+122
-31
lines changed

cyclonedx/model/__init__.py

+11
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# SPDX-License-Identifier: Apache-2.0
1616
#
1717

18+
import hashlib
1819
from enum import Enum
1920

2021
"""
@@ -25,6 +26,16 @@
2526
"""
2627

2728

29+
def sha1sum(filename: str) -> str:
30+
h = hashlib.sha1()
31+
b = bytearray(128 * 1024)
32+
mv = memoryview(b)
33+
with open(filename, 'rb', buffering=0) as f:
34+
for n in iter(lambda: f.readinto(mv), 0):
35+
h.update(mv[:n])
36+
return h.hexdigest()
37+
38+
2839
class HashAlgorithm(Enum):
2940
"""
3041
This is out internal representation of the hashAlg simple type within the CycloneDX standard.

cyclonedx/model/component.py

+58-6
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@
1818
# Copyright (c) OWASP Foundation. All Rights Reserved.
1919

2020
from enum import Enum
21+
from os.path import exists
2122
from packageurl import PackageURL
2223
from typing import List
2324

25+
from . import HashAlgorithm, HashType, sha1sum
2426
from .vulnerability import Vulnerability
2527

26-
PURL_TYPE_PREFIX = 'pypi'
27-
2828

2929
class ComponentType(Enum):
3030
"""
@@ -51,6 +51,7 @@ class Component:
5151
See the CycloneDX Schema definition: https://cyclonedx.org/docs/1.3/#type_component
5252
"""
5353
_type: ComponentType
54+
_package_url_type: str
5455
_name: str
5556
_version: str
5657
_qualifiers: str
@@ -59,15 +60,57 @@ class Component:
5960
_description: str = None
6061
_license: str = None
6162

63+
_hashes: List[HashType] = []
6264
_vulnerabilites: List[Vulnerability] = []
6365

64-
def __init__(self, name: str, version: str, qualifiers: str = None,
65-
component_type: ComponentType = ComponentType.LIBRARY):
66+
@staticmethod
67+
def for_file(absolute_file_path: str, path_for_bom: str = None):
68+
"""
69+
Helper method to create a Component that represents the provided local file as a Component.
70+
71+
Args:
72+
absolute_file_path:
73+
Absolute path to the file you wish to represent
74+
path_for_bom:
75+
Optionally, if supplied this is the path that will be used to identify the file in the BOM
76+
77+
Returns:
78+
`Component` representing the supplied file
79+
"""
80+
if not exists(absolute_file_path):
81+
raise FileExistsError('Supplied file path \'{}\' does not exist'.format(absolute_file_path))
82+
83+
sha1_hash: str = sha1sum(filename=absolute_file_path)
84+
85+
return Component(
86+
name=path_for_bom if path_for_bom else absolute_file_path,
87+
version='0.0.0-{}'.format(sha1_hash[0:12]),
88+
hashes=[
89+
HashType(algorithm=HashAlgorithm.SHA_1, hash_value=sha1_hash)
90+
],
91+
component_type=ComponentType.FILE,
92+
package_url_type='generic'
93+
)
94+
95+
def __init__(self, name: str, version: str, qualifiers: str = None, hashes: List[HashType] = [],
96+
component_type: ComponentType = ComponentType.LIBRARY, package_url_type: str = 'pypi'):
6697
self._name = name
6798
self._version = version
6899
self._type = component_type
69100
self._qualifiers = qualifiers
101+
self._hashes = hashes
70102
self._vulnerabilites = []
103+
self._package_url_type = package_url_type
104+
105+
def add_hash(self, hash: HashType):
106+
"""
107+
Adds a hash that pins/identifies this Component.
108+
109+
Args:
110+
hash:
111+
`HashType` instance
112+
"""
113+
self._hashes.append(hash)
71114

72115
def add_vulnerability(self, vulnerability: Vulnerability):
73116
"""
@@ -100,6 +143,15 @@ def get_description(self) -> str:
100143
"""
101144
return self._description
102145

146+
def get_hashes(self) -> List[HashType]:
147+
"""
148+
List of cryptographic hashes that identify this Component.
149+
150+
Returns:
151+
`List` of `HashType` objects where there are any hashes, else an empty `List`.
152+
"""
153+
return self._hashes
154+
103155
def get_license(self) -> str:
104156
"""
105157
Get the license of this Component.
@@ -125,7 +177,7 @@ def get_purl(self) -> str:
125177
Returns:
126178
PackageURL that reflects this Component as `str`.
127179
"""
128-
base_purl = 'pkg:{}/{}@{}'.format(PURL_TYPE_PREFIX, self._name, self._version)
180+
base_purl = 'pkg:{}/{}@{}'.format(self._package_url_type, self._name, self._version)
129181
if self._qualifiers:
130182
base_purl = '{}?{}'.format(base_purl, self._qualifiers)
131183
return base_purl
@@ -213,7 +265,7 @@ def to_package_url(self) -> PackageURL:
213265
`packageurl.PackageURL` instance which represents this Component.
214266
"""""
215267
return PackageURL(
216-
type=PURL_TYPE_PREFIX,
268+
type=self._package_url_type,
217269
name=self._name,
218270
version=self._version,
219271
qualifiers=self._qualifiers

cyclonedx/output/json.py

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

56+
if len(component.get_hashes()) > 0:
57+
hashes = []
58+
for component_hash in component.get_hashes():
59+
hashes.append({
60+
"alg": component_hash.get_algorithm().value,
61+
"content": component_hash.get_hash_value()
62+
})
63+
c['hashes'] = hashes
64+
5665
if self.component_supports_author() and component.get_author() is not None:
5766
c['author'] = component.get_author()
5867

@@ -67,11 +76,22 @@ def _get_metadata_as_dict(self) -> dict:
6776
if self.bom_metadata_supports_tools() and len(bom_metadata.get_tools()) > 0:
6877
metadata['tools'] = []
6978
for tool in bom_metadata.get_tools():
70-
metadata['tools'].append({
79+
tool_dict = {
7180
"vendor": tool.get_vendor(),
7281
"name": tool.get_name(),
7382
"version": tool.get_version()
74-
})
83+
}
84+
85+
if len(tool.get_hashes()) > 0:
86+
hashes = []
87+
for tool_hash in tool.get_hashes():
88+
hashes.append({
89+
"alg": tool_hash.get_algorithm().value,
90+
"content": tool_hash.get_hash_value()
91+
})
92+
tool_dict['hashes'] = hashes
93+
94+
metadata['tools'].append(tool_dict)
7595

7696
return metadata
7797

cyclonedx/output/xml.py

+10-23
Original file line numberDiff line numberDiff line change
@@ -83,35 +83,22 @@ def _get_component_as_xml_element(self, component: Component) -> ElementTree.Ele
8383
if self.component_supports_author() and component.get_author() is not None:
8484
ElementTree.SubElement(component_element, 'author').text = component.get_author()
8585

86-
# if publisher and publisher != "UNKNOWN":
87-
# ElementTree.SubElement(component, "publisher").text = re.sub(RE_XML_ILLEGAL, "?", publisher)
88-
89-
# if name and name != "UNKNOWN":
86+
# name
9087
ElementTree.SubElement(component_element, 'name').text = component.get_name()
9188

92-
# if version and version != "UNKNOWN":
89+
# version
9390
ElementTree.SubElement(component_element, 'version').text = component.get_version()
9491

95-
# if description and description != "UNKNOWN":
96-
# ElementTree.SubElement(component, "description").text = re.sub(RE_XML_ILLEGAL, "?", description)
97-
#
98-
# if hashes:
99-
# hashes_elm = ElementTree.SubElement(component, "hashes")
100-
# for h in hashes:
101-
# ElementTree.SubElement(hashes_elm, "hash", alg=h.alg).text = h.content
102-
#
103-
# if len(licenses):
104-
# licenses_elm = ElementTree.SubElement(component, "licenses")
105-
# for component_license in licenses:
106-
# if component_license.license is not None:
107-
# license_elm = ElementTree.SubElement(licenses_elm, "license")
108-
# ElementTree.SubElement(license_elm, "name").text = re.sub(RE_XML_ILLEGAL, "?",
109-
# component_license.license.name)
110-
111-
# if purl:
92+
# purl
11293
ElementTree.SubElement(component_element, 'purl').text = component.get_purl()
11394

114-
# ElementTree.SubElement(component, "modified").text = modified if modified else "false"
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()
115102

116103
return component_element
117104

tests/test_component.py

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

20+
from os.path import dirname, join
2021
from unittest import TestCase
2122

2223
from packageurl import PackageURL
@@ -31,6 +32,9 @@ def setUpClass(cls) -> None:
3132
cls._component: Component = Component(name='setuptools', version='50.3.2')
3233
cls._component_with_qualifiers: Component = Component(name='setuptools', version='50.3.2',
3334
qualifiers='extension=tar.gz')
35+
cls._component_generic_file: Component = Component(
36+
name='/test.py', version='UNKNOWN', package_url_type='generic'
37+
)
3438

3539
def test_purl_correct(self):
3640
self.assertEqual(
@@ -95,3 +99,20 @@ def test_as_package_url_3(self):
9599
type='pypi', name='setuptools', version='50.3.2', qualifiers='extension=tar.gz'
96100
)
97101
self.assertEqual(TestComponent._component_with_qualifiers.to_package_url(), purl)
102+
103+
def test_custom_package_url_type(self):
104+
purl = PackageURL(
105+
type='generic', name='/test.py', version='UNKNOWN'
106+
)
107+
self.assertEqual(TestComponent._component_generic_file.to_package_url(), purl)
108+
109+
def test_from_file_with_path_for_bom(self):
110+
test_file = join(dirname(__file__), 'fixtures/bom_v1.3_setuptools.xml')
111+
c = Component.for_file(absolute_file_path=test_file, path_for_bom='fixtures/bom_v1.3_setuptools.xml')
112+
self.assertEqual(c.get_name(), 'fixtures/bom_v1.3_setuptools.xml')
113+
self.assertEqual(c.get_version(), '0.0.0-16932e52ed1e')
114+
purl = PackageURL(
115+
type='generic', name='fixtures/bom_v1.3_setuptools.xml', version='0.0.0-16932e52ed1e'
116+
)
117+
self.assertEqual(c.to_package_url(), purl)
118+
self.assertEqual(len(c.get_hashes()), 1)

0 commit comments

Comments
 (0)