Skip to content

Commit d4e9972

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

File tree

3 files changed

+223
-3
lines changed

3 files changed

+223
-3
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.*;sys_platform=="linux"

src/fosslight_util/output_format.py

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

0 commit comments

Comments
 (0)