Skip to content

Commit ea42cd6

Browse files
committed
[SP-2879] feat: add export dt sub-command, add cyclonedx input file validation
1 parent 0862aa1 commit ea42cd6

File tree

8 files changed

+400
-47
lines changed

8 files changed

+400
-47
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99
### Added
1010
- Upcoming changes...
1111

12+
## [1.30.0] - 2025-07-22
13+
### Added
14+
- Add `export dt` subcommand to export SBOM files to Dependency Track
15+
- Add CycloneDX file validation
16+
1217
## [1.29.0] - 2025-07-15
1318
### Changed
1419
- Updated minimum Python version to 3.9
@@ -609,4 +614,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
609614
[1.28.1]: https://github.com/scanoss/scanoss.py/compare/v1.28.0...v1.28.1
610615
[1.28.2]: https://github.com/scanoss/scanoss.py/compare/v1.28.1...v1.28.2
611616
[1.29.0]: https://github.com/scanoss/scanoss.py/compare/v1.28.2...v1.29.0
617+
[1.30.0]: https://github.com/scanoss/scanoss.py/compare/v1.29.0...v1.30.0
618+
612619

requirements.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,6 @@ importlib_resources
1212
packageurl-python
1313
pathspec
1414
jsonschema
15-
crc
15+
crc
16+
17+
cyclonedx-python-lib[validation]

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ install_requires =
3939
pathspec
4040
jsonschema
4141
crc
42+
cyclonedx-python-lib[validation]
4243

4344

4445
[options.extras_require]

src/scanoss/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,4 @@
2222
THE SOFTWARE.
2323
"""
2424

25-
__version__ = '1.29.0'
25+
__version__ = '1.30.0'

src/scanoss/cli.py

Lines changed: 99 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,18 @@
2525
import argparse
2626
import os
2727
import sys
28+
import traceback
2829
from dataclasses import asdict
2930
from pathlib import Path
3031
from typing import List
3132

3233
import pypac
3334

3435
from scanoss.cryptography import Cryptography, create_cryptography_config_from_args
36+
from scanoss.export.dependency_track import (
37+
DependencyTrackExporter,
38+
create_dependency_track_exporter_config_from_args,
39+
)
3540
from scanoss.inspection.component_summary import ComponentSummary
3641
from scanoss.inspection.license_summary import LicenseSummary
3742
from scanoss.scanners.container_scanner import (
@@ -553,13 +558,17 @@ def setup_args() -> None: # noqa: PLR0912, PLR0915
553558
####### INSPECT: License Summary ######
554559
# Inspect Sub-command: inspect license summary
555560
p_license_summary = p_inspect_sub.add_parser(
556-
'license-summary', aliases=['lic-summary', 'licsum'], description='Get license summary',
557-
help='Get detected license summary from scan results'
561+
'license-summary',
562+
aliases=['lic-summary', 'licsum'],
563+
description='Get license summary',
564+
help='Get detected license summary from scan results',
558565
)
559566

560567
p_component_summary = p_inspect_sub.add_parser(
561-
'component-summary', aliases=['comp-summary', 'compsum'], description='Get component summary',
562-
help='Get detected component summary from scan results'
568+
'component-summary',
569+
aliases=['comp-summary', 'compsum'],
570+
description='Get component summary',
571+
help='Get detected component summary from scan results',
563572
)
564573

565574
####### INSPECT: Undeclared components ######
@@ -605,6 +614,36 @@ def setup_args() -> None: # noqa: PLR0912, PLR0915
605614

606615
########################################### END INSPECT SUBCOMMAND ###########################################
607616

617+
# Sub-command: export
618+
p_export = subparsers.add_parser(
619+
'export',
620+
aliases=['exp'],
621+
description=f'Export SBOM files to external platforms: {__version__}',
622+
help='Export SBOM files to external platforms',
623+
)
624+
625+
export_sub = p_export.add_subparsers(
626+
title='Export Commands',
627+
dest='subparsercmd',
628+
description='export sub-commands',
629+
help='export sub-commands',
630+
)
631+
632+
# Export Sub-command: export dt (Dependency Track)
633+
e_dt = export_sub.add_parser(
634+
'dt',
635+
aliases=['dependency-track'],
636+
description='Export SBOM to Dependency Track',
637+
help='Upload SBOM files to Dependency Track',
638+
)
639+
e_dt.add_argument('-i', '--input', type=str, required=True, help='Input SBOM file (CycloneDX JSON format)')
640+
e_dt.add_argument('--dt-url', type=str, required=True, help='Dependency Track base URL')
641+
e_dt.add_argument('--dt-apikey', type=str, required=True, help='Dependency Track API key')
642+
e_dt.add_argument('--dt-projectid', type=str, help='Dependency Track project UUID')
643+
e_dt.add_argument('--dt-projectname', type=str, help='Dependency Track project name')
644+
e_dt.add_argument('--dt-projectversion', type=str, help='Dependency Track project version')
645+
e_dt.set_defaults(func=export_dt)
646+
608647
# Sub-command: folder-scan
609648
p_folder_scan = subparsers.add_parser(
610649
'folder-scan',
@@ -858,6 +897,7 @@ def setup_args() -> None: # noqa: PLR0912, PLR0915
858897
p_crypto_algorithms,
859898
p_crypto_hints,
860899
p_crypto_versions_in_range,
900+
e_dt,
861901
]:
862902
p.add_argument('--debug', '-d', action='store_true', help='Enable debug messages')
863903
p.add_argument('--trace', '-t', action='store_true', help='Enable trace messages, including API posts')
@@ -871,7 +911,8 @@ def setup_args() -> None: # noqa: PLR0912, PLR0915
871911
parser.print_help() # No sub command subcommand, print general help
872912
sys.exit(1)
873913
elif (
874-
args.subparser in ('utils', 'ut', 'component', 'comp', 'inspect', 'insp', 'ins', 'crypto', 'cr')
914+
args.subparser
915+
in ('utils', 'ut', 'component', 'comp', 'inspect', 'insp', 'ins', 'crypto', 'cr', 'export', 'exp')
875916
) and not args.subparsercmd:
876917
parser.parse_args([args.subparser, '--help']) # Force utils helps to be displayed
877918
sys.exit(1)
@@ -1304,6 +1345,7 @@ def convert(parser, args):
13041345
if not success:
13051346
sys.exit(1)
13061347

1348+
13071349
################################ INSPECT handlers ################################
13081350
def inspect_copyleft(parser, args):
13091351
"""
@@ -1381,16 +1423,17 @@ def inspect_undeclared(parser, args):
13811423
status, _ = i_undeclared.run()
13821424
sys.exit(status)
13831425

1426+
13841427
def inspect_license_summary(parser, args):
13851428
"""
1386-
Run the "inspect" sub-command
1387-
Parameters
1388-
----------
1389-
parser: ArgumentParser
1390-
command line parser object
1391-
args: Namespace
1392-
Parsed arguments
1393-
"""
1429+
Run the "inspect" sub-command
1430+
Parameters
1431+
----------
1432+
parser: ArgumentParser
1433+
command line parser object
1434+
args: Namespace
1435+
Parsed arguments
1436+
"""
13941437
if args.input is None:
13951438
print_stderr('Please specify an input file to inspect')
13961439
parser.parse_args([args.subparser, args.subparsercmd, '-h'])
@@ -1412,16 +1455,17 @@ def inspect_license_summary(parser, args):
14121455
)
14131456
i_license_summary.run()
14141457

1458+
14151459
def inspect_component_summary(parser, args):
14161460
"""
1417-
Run the "inspect" sub-command
1418-
Parameters
1419-
----------
1420-
parser: ArgumentParser
1421-
command line parser object
1422-
args: Namespace
1423-
Parsed arguments
1424-
"""
1461+
Run the "inspect" sub-command
1462+
Parameters
1463+
----------
1464+
parser: ArgumentParser
1465+
command line parser object
1466+
args: Namespace
1467+
Parsed arguments
1468+
"""
14251469
if args.input is None:
14261470
print_stderr('Please specify an input file to inspect')
14271471
parser.parse_args([args.subparser, args.subparsercmd, '-h'])
@@ -1440,8 +1484,42 @@ def inspect_component_summary(parser, args):
14401484
)
14411485
i_component_summary.run()
14421486

1487+
14431488
################################ End inspect handlers ################################
14441489

1490+
1491+
def export_dt(parser, args):
1492+
"""
1493+
Run the "export dt" sub-command
1494+
Parameters
1495+
----------
1496+
parser: ArgumentParser
1497+
command line parser object
1498+
args: Namespace
1499+
Parsed arguments
1500+
"""
1501+
1502+
try:
1503+
config = create_dependency_track_exporter_config_from_args(args)
1504+
dt_exporter = DependencyTrackExporter(
1505+
config=config,
1506+
debug=args.debug,
1507+
trace=args.trace,
1508+
quiet=args.quiet,
1509+
)
1510+
1511+
success = dt_exporter.upload_sbom(args.input)
1512+
1513+
if not success:
1514+
sys.exit(1)
1515+
1516+
except Exception as e:
1517+
print_stderr(f'ERROR: {e}')
1518+
if args.debug:
1519+
traceback.print_exc()
1520+
sys.exit(1)
1521+
1522+
14451523
def utils_certloc(*_):
14461524
"""
14471525
Run the "utils certloc" sub-command

src/scanoss/cyclonedx.py

Lines changed: 41 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@
2828
import sys
2929
import uuid
3030

31+
from cyclonedx.schema import SchemaVersion
32+
from cyclonedx.validation.json import JsonValidator
33+
3134
from . import __version__
3235
from .scanossbase import ScanossBase
3336
from .spdxlite import SpdxLite
@@ -296,13 +299,13 @@ def _normalize_vulnerability_id(self, vuln: dict) -> tuple[str, str]:
296299
"""
297300
vuln_id = vuln.get('ID', '') or vuln.get('id', '')
298301
vuln_cve = vuln.get('CVE', '') or vuln.get('cve', '')
299-
302+
300303
# Skip CPE entries, use CVE if available
301304
if vuln_id.upper().startswith('CPE:') and vuln_cve:
302305
vuln_id = vuln_cve
303-
306+
304307
return vuln_id, vuln_cve
305-
308+
306309
def _create_vulnerability_entry(self, vuln_id: str, vuln: dict, vuln_cve: str, purl: str) -> dict:
307310
"""
308311
Create a new vulnerability entry for CycloneDX format.
@@ -313,61 +316,56 @@ def _create_vulnerability_entry(self, vuln_id: str, vuln: dict, vuln_cve: str, p
313316
'source': {
314317
'name': 'NVD' if vuln_source == 'nvd' else 'GitHub Advisories',
315318
'url': f'https://nvd.nist.gov/vuln/detail/{vuln_cve}'
316-
if vuln_source == 'nvd'
317-
else f'https://github.com/advisories/{vuln_id}'
319+
if vuln_source == 'nvd'
320+
else f'https://github.com/advisories/{vuln_id}',
318321
},
319322
'ratings': [{'severity': self._sev_lookup(vuln.get('severity', 'unknown').lower())}],
320-
'affects': [{'ref': purl}]
323+
'affects': [{'ref': purl}],
321324
}
322-
325+
323326
def append_vulnerabilities(self, cdx_dict: dict, vulnerabilities_data: dict, purl: str) -> dict:
324327
"""
325328
Append vulnerabilities to an existing CycloneDX dictionary
326-
329+
327330
Args:
328331
cdx_dict (dict): The existing CycloneDX dictionary
329332
vulnerabilities_data (dict): The vulnerabilities data from get_vulnerabilities_json
330333
purl (str): The PURL of the component these vulnerabilities affect
331-
334+
332335
Returns:
333336
dict: The updated CycloneDX dictionary with vulnerabilities appended
334337
"""
335338
if not cdx_dict or not vulnerabilities_data:
336339
return cdx_dict
337-
340+
338341
if 'vulnerabilities' not in cdx_dict:
339342
cdx_dict['vulnerabilities'] = []
340-
343+
341344
# Extract vulnerabilities from the response
342345
vulns_list = vulnerabilities_data.get('purls', [])
343346
if not vulns_list:
344347
return cdx_dict
345-
348+
346349
vuln_items = vulns_list[0].get('vulnerabilities', [])
347-
350+
348351
for vuln in vuln_items:
349352
vuln_id, vuln_cve = self._normalize_vulnerability_id(vuln)
350-
353+
351354
# Skip empty IDs or CPE-only entries
352355
if not vuln_id or vuln_id.upper().startswith('CPE:'):
353356
continue
354-
357+
355358
# Check if vulnerability already exists
356-
existing_vuln = next(
357-
(v for v in cdx_dict['vulnerabilities'] if v.get('id') == vuln_id),
358-
None
359-
)
360-
359+
existing_vuln = next((v for v in cdx_dict['vulnerabilities'] if v.get('id') == vuln_id), None)
360+
361361
if existing_vuln:
362362
# Add this PURL to the affects list if not already present
363363
if not any(ref.get('ref') == purl for ref in existing_vuln.get('affects', [])):
364364
existing_vuln['affects'].append({'ref': purl})
365365
else:
366366
# Create new vulnerability entry
367-
cdx_dict['vulnerabilities'].append(
368-
self._create_vulnerability_entry(vuln_id, vuln, vuln_cve, purl)
369-
)
370-
367+
cdx_dict['vulnerabilities'].append(self._create_vulnerability_entry(vuln_id, vuln, vuln_cve, purl))
368+
371369
return cdx_dict
372370

373371
@staticmethod
@@ -388,6 +386,25 @@ def _sev_lookup(value: str):
388386
'unknown': 'unknown',
389387
}.get(value, 'unknown')
390388

389+
def is_cyclonedx_json(self, json_string: str) -> bool:
390+
"""
391+
Validate if the given JSON string is a valid CycloneDX JSON string
392+
Args:
393+
json_string (str): JSON string to validate
394+
Returns:
395+
bool: True if the JSON string is valid, False otherwise
396+
"""
397+
try:
398+
cdx_json_validator = JsonValidator(SchemaVersion.V1_6)
399+
json_validation_errors = cdx_json_validator.validate_str(json_string)
400+
if json_validation_errors:
401+
self.print_stderr(f'ERROR: Problem parsing input JSON: {json_validation_errors}')
402+
return False
403+
return True
404+
except Exception as e:
405+
self.print_stderr(f'ERROR: Problem parsing input JSON: {e}')
406+
return False
407+
391408

392409
#
393410
# End of CycloneDX Class

0 commit comments

Comments
 (0)