Skip to content

Commit 72be803

Browse files
committed
Support cycloneDx format
Signed-off-by: jiyeong.seok <[email protected]>
1 parent f8e339c commit 72be803

File tree

3 files changed

+218
-1
lines changed

3 files changed

+218
-1
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ numpy>=1.22.2; python_version >= '3.8'
1515
npm
1616
requests
1717
GitPython
18+
cyclonedx-python-lib==8.5.0

src/fosslight_util/output_format.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
from fosslight_util.write_opossum import write_opossum
99
from fosslight_util.write_yaml import write_yaml
1010
from fosslight_util.write_spdx import write_spdx
11+
from fosslight_util.write_cyclonedx import write_cyclonedx
1112
from typing import Tuple
1213

1314
SUPPORT_FORMAT = {'excel': '.xlsx', 'csv': '.csv', 'opossum': '.json', 'yaml': '.yaml',
1415
'spdx-yaml': '.yaml', 'spdx-json': '.json', 'spdx-xml': '.xml',
15-
'spdx-tag': '.tag'}
16+
'spdx-tag': '.tag', 'cyclonedx-json': '.json', 'cyclonedx-xml': '.xml'}
1617

1718

1819
def check_output_format(output='', format='', customized_format={}):
@@ -188,6 +189,8 @@ def write_output_file(output_file_without_ext: str, file_extension: str, scan_it
188189
msg = f'{platform.system()} not support spdx format.'
189190
else:
190191
success, msg, _ = write_spdx(output_file_without_ext, file_extension, scan_item, spdx_version)
192+
elif format.startswith('cyclonedx'):
193+
success, msg, _ = write_cyclonedx(output_file_without_ext, file_extension, scan_item)
191194
else:
192195
if file_extension == '.xlsx':
193196
success, msg = write_result_to_excel(result_file, scan_item, extended_header, hide_header)
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
# Copyright (c) 2024 LG Electronics Inc.
4+
# Copyright (c) OWASP Foundation.
5+
# SPDX-License-Identifier: Apache-2.0
6+
7+
import os
8+
import sys
9+
import logging
10+
import re
11+
import json
12+
from pathlib import Path
13+
from datetime import datetime
14+
from fosslight_util.spdx_licenses import get_spdx_licenses_json, get_license_from_nick
15+
from fosslight_util.constant import (LOGGER_NAME, FOSSLIGHT_DEPENDENCY, FOSSLIGHT_SCANNER,
16+
FOSSLIGHT_BINARY, FOSSLIGHT_SOURCE)
17+
from fosslight_util.oss_item import CHECKSUM_NULL, get_checksum_sha1
18+
from packageurl import PackageURL
19+
import traceback
20+
from cyclonedx.builder.this import this_component as cdx_lib_component
21+
from cyclonedx.exception import MissingOptionalDependencyException
22+
from cyclonedx.factory.license import LicenseFactory
23+
from cyclonedx.model import XsUri, ExternalReferenceType
24+
from cyclonedx.model.bom import Bom
25+
from cyclonedx.model.component import Component, ComponentType, HashAlgorithm, HashType, ExternalReference
26+
from cyclonedx.model.contact import OrganizationalEntity
27+
from cyclonedx.output import make_outputter, BaseOutput
28+
from cyclonedx.output.json import JsonV1Dot6
29+
from cyclonedx.schema import OutputFormat, SchemaVersion
30+
from cyclonedx.validation import make_schemabased_validator
31+
from cyclonedx.validation.json import JsonStrictValidator
32+
from cyclonedx.output.json import Json as JsonOutputter
33+
from cyclonedx.output.xml import Xml as XmlOutputter
34+
from cyclonedx.validation.xml import XmlValidator
35+
36+
logger = logging.getLogger(LOGGER_NAME)
37+
38+
39+
def write_cyclonedx(output_file_without_ext, output_extension, scan_item):
40+
success = True
41+
error_msg = ''
42+
43+
bom = Bom()
44+
if scan_item:
45+
try:
46+
cover_name = scan_item.cover.get_print_json()["Tool information"].split('(').pop(0).strip()
47+
match = re.search(r"(.+) v([0-9.]+)", cover_name)
48+
if match:
49+
scanner_name = match.group(1)
50+
else:
51+
scanner_name = FOSSLIGHT_SCANNER
52+
except Exception:
53+
cover_name = FOSSLIGHT_SCANNER
54+
scanner_name = FOSSLIGHT_SCANNER
55+
56+
lc_factory = LicenseFactory()
57+
bom.metadata.tools.components.add(cdx_lib_component())
58+
bom.metadata.tools.components.add(Component(name=scanner_name.upper(),
59+
type=ComponentType.APPLICATION))
60+
comp_id = 0
61+
bom.metadata.component = root_component = Component(name='Root Component',
62+
type=ComponentType.APPLICATION,
63+
bom_ref=str(comp_id))
64+
relation_tree = {}
65+
bom_ref_packages = []
66+
67+
output_dir = os.path.dirname(output_file_without_ext)
68+
Path(output_dir).mkdir(parents=True, exist_ok=True)
69+
try:
70+
root_package = False
71+
for scanner_name, file_items in scan_item.file_items.items():
72+
for file_item in file_items:
73+
if file_item.exclude:
74+
continue
75+
if scanner_name == FOSSLIGHT_SOURCE:
76+
comp_type = ComponentType.FILE
77+
else:
78+
comp_type = ComponentType.LIBRARY
79+
80+
for oss_item in file_item.oss_items:
81+
if oss_item.name == '':
82+
if scanner_name == FOSSLIGHT_DEPENDENCY:
83+
continue
84+
else:
85+
comp_name = file_item.source_name_or_path
86+
else:
87+
comp_name = oss_item.name
88+
89+
comp_id += 1
90+
comp = Component(type=comp_type,
91+
name=comp_name,
92+
bom_ref=str(comp_id))
93+
94+
if oss_item.version != '':
95+
comp.version = oss_item.version
96+
if oss_item.copyright != '':
97+
comp.copyright = oss_item.copyright
98+
if scanner_name == FOSSLIGHT_DEPENDENCY and file_item.purl:
99+
comp.purl = PackageURL.from_string(file_item.purl)
100+
if scanner_name != FOSSLIGHT_DEPENDENCY:
101+
comp.hashes = [HashType(alg=HashAlgorithm.SHA_1, content=file_item.checksum)]
102+
103+
if oss_item.download_location != '':
104+
comp.external_references = [ExternalReference(url=XsUri(oss_item.download_location),
105+
type=ExternalReferenceType.WEBSITE)]
106+
107+
oss_licenses = []
108+
for ol in oss_item.license:
109+
try:
110+
oss_licenses.append(lc_factory.make_from_string(ol))
111+
except Exception:
112+
logger.info(f'No spdx license name: {oi}')
113+
if oss_licenses:
114+
comp.licenses = oss_licenses
115+
116+
root_package = False
117+
if scanner_name == FOSSLIGHT_DEPENDENCY:
118+
if oss_item.comment:
119+
oss_comment = oss_item.comment.split('/')
120+
for oc in oss_comment:
121+
if oc in ['direct', 'transitive', 'root package']:
122+
if oc == 'direct':
123+
bom.register_dependency(root_component, [comp])
124+
elif oc == 'root package':
125+
root_package = True
126+
root_component.name = comp_name
127+
root_component.type = comp_type
128+
comp_id -= 1
129+
else:
130+
bom.register_dependency(root_component, [comp])
131+
if len(file_item.depends_on) > 0:
132+
purl = file_item.purl
133+
relation_tree[purl] = []
134+
relation_tree[purl].extend(file_item.depends_on)
135+
136+
if not root_package:
137+
bom.components.add(comp)
138+
139+
if len(bom.components) > 0:
140+
for comp_purl in relation_tree:
141+
comp = bom.get_component_by_purl(PackageURL.from_string(comp_purl))
142+
if comp:
143+
dep_comp_list = []
144+
for dep_comp_purl in relation_tree[comp_purl]:
145+
dep_comp = bom.get_component_by_purl(PackageURL.from_string(dep_comp_purl))
146+
if dep_comp:
147+
dep_comp_list.append(dep_comp)
148+
bom.register_dependency(comp, dep_comp_list)
149+
150+
except Exception as e:
151+
success = False
152+
error_msg = f'Failed to create CycloneDX document object:{e}, {traceback.format_exc()}'
153+
else:
154+
success = False
155+
error_msg = 'No item to write in output file.'
156+
157+
result_file = ''
158+
if success:
159+
result_file = output_file_without_ext + output_extension
160+
try:
161+
if output_extension == '.json':
162+
write_cyclonedx_json(bom, result_file)
163+
elif output_extension == '.xml':
164+
write_cyclonedx_xml(bom, result_file)
165+
else:
166+
success = False
167+
error_msg = f'Not supported output_extension({output_extension})'
168+
except Exception as e:
169+
success = False
170+
error_msg = f'Failed to write CycloneDX document: {e}'
171+
if os.path.exists(result_file):
172+
os.remove(result_file)
173+
174+
return success, error_msg, result_file
175+
176+
177+
def write_cyclonedx_json(bom, result_file):
178+
success = True
179+
try:
180+
my_json_outputter: 'JsonOutputter' = JsonV1Dot6(bom)
181+
my_json_outputter.output_to_file(result_file)
182+
serialized_json = my_json_outputter.output_as_string(indent=2)
183+
my_json_validator = JsonStrictValidator(SchemaVersion.V1_6)
184+
try:
185+
validation_errors = my_json_validator.validate_str(serialized_json)
186+
if validation_errors:
187+
logger.warning(f'JSON invalid, ValidationError: {repr(validation_errors)}')
188+
except MissingOptionalDependencyException as error:
189+
logger.debug(f'JSON-validation was skipped due to {error}')
190+
except Exception as e:
191+
success = False
192+
return success
193+
194+
195+
196+
def write_cyclonedx_xml(bom, result_file):
197+
success = True
198+
try:
199+
my_xml_outputter: BaseOutput = make_outputter(bom=bom,
200+
output_format=OutputFormat.XML,
201+
schema_version=SchemaVersion.V1_6)
202+
my_xml_outputter.output_to_file(filename=result_file)
203+
serialized_xml = my_xml_outputter.output_as_string(indent=2)
204+
my_xml_validator = XmlValidator(SchemaVersion.V1_6)
205+
try:
206+
validation_errors = my_xml_validator.validate_str(serialized_xml)
207+
if validation_errors:
208+
logger.warning(f'XML invalid, ValidationError: {repr(validation_errors)}')
209+
except MissingOptionalDependencyException as error:
210+
logger.debug(f'XML-validation was skipped due to {error}')
211+
except Exception as e:
212+
success = False
213+
return success

0 commit comments

Comments
 (0)