|
21 | 21 | # Visit https://github.com/nexB/scancode.io for support and download.
|
22 | 22 |
|
23 | 23 | import csv
|
| 24 | +import decimal |
24 | 25 | import json
|
25 | 26 | import re
|
26 | 27 | from operator import attrgetter
|
|
34 | 35 |
|
35 | 36 | import saneyaml
|
36 | 37 | import xlsxwriter
|
37 |
| -from cyclonedx import output as cyclonedx_output |
38 |
| -from cyclonedx.model import bom as cyclonedx_bom |
39 |
| -from cyclonedx.model import component as cyclonedx_component |
| 38 | +from cyclonedx.model import bom as cdx_bom |
| 39 | +from cyclonedx.model import component as cdx_component |
| 40 | +from cyclonedx.model import vulnerability as cdx_vulnerability |
| 41 | +from cyclonedx.output import OutputFormat |
| 42 | +from cyclonedx.output import make_outputter |
| 43 | +from cyclonedx.schema import SchemaVersion |
| 44 | +from cyclonedx.validation.json import JsonStrictValidator |
40 | 45 | from license_expression import Licensing
|
41 | 46 | from license_expression import ordered_unique
|
42 | 47 | from licensedcode.cache import build_spdx_license_expression
|
@@ -579,65 +584,156 @@ def to_spdx(project, include_files=False):
|
579 | 584 | return output_file
|
580 | 585 |
|
581 | 586 |
|
| 587 | +def vulnerability_as_cyclonedx(vulnerability_data, component_bom_ref): |
| 588 | + affects = [cdx_vulnerability.BomTarget(ref=f"urn:cdx:{component_bom_ref}")] |
| 589 | + |
| 590 | + source = cdx_vulnerability.VulnerabilitySource( |
| 591 | + name="VulnerableCode", |
| 592 | + url=vulnerability_data.get("url"), |
| 593 | + ) |
| 594 | + |
| 595 | + references = [] |
| 596 | + ratings = [] |
| 597 | + for reference in vulnerability_data.get("references", []): |
| 598 | + source = cdx_vulnerability.VulnerabilitySource( |
| 599 | + url=reference.get("reference_url"), |
| 600 | + ) |
| 601 | + |
| 602 | + references.append( |
| 603 | + cdx_vulnerability.VulnerabilityReference( |
| 604 | + id=reference.get("reference_id"), |
| 605 | + source=source, |
| 606 | + ) |
| 607 | + ) |
| 608 | + |
| 609 | + for score_entry in reference.get("scores", []): |
| 610 | + # CycloneDX only support a float value for the score field, |
| 611 | + # where on the VulnerableCode data it can be either a score float value |
| 612 | + # or a severity string value. |
| 613 | + score_value = score_entry.get("value") |
| 614 | + try: |
| 615 | + score = decimal.Decimal(score_value) |
| 616 | + severity = None |
| 617 | + except decimal.DecimalException: |
| 618 | + score = None |
| 619 | + severity = getattr( |
| 620 | + cdx_vulnerability.VulnerabilitySeverity, |
| 621 | + score_value.upper(), |
| 622 | + None, |
| 623 | + ) |
| 624 | + |
| 625 | + ratings.append( |
| 626 | + cdx_vulnerability.VulnerabilityRating( |
| 627 | + source=source, |
| 628 | + score=score, |
| 629 | + severity=severity, |
| 630 | + # Providing a value for method raise a AssertionError |
| 631 | + # method=score_entry.get("scoring_system"), |
| 632 | + vector=score_entry.get("scoring_elements"), |
| 633 | + ) |
| 634 | + ) |
| 635 | + |
| 636 | + cwes = [ |
| 637 | + weakness.get("cwe_id") for weakness in vulnerability_data.get("weaknesses", []) |
| 638 | + ] |
| 639 | + |
| 640 | + return cdx_vulnerability.Vulnerability( |
| 641 | + id=vulnerability_data.get("vulnerability_id"), |
| 642 | + source=source, |
| 643 | + description=vulnerability_data.get("summary"), |
| 644 | + affects=affects, |
| 645 | + references=references, |
| 646 | + cwes=cwes, |
| 647 | + ratings=ratings, |
| 648 | + ) |
| 649 | + |
| 650 | + |
582 | 651 | def get_cyclonedx_bom(project):
|
583 | 652 | """
|
584 | 653 | Return a CycloneDX `Bom` object filled with provided `project` data.
|
585 | 654 | See https://cyclonedx.org/use-cases/#dependency-graph
|
586 | 655 | """
|
587 |
| - components = [ |
588 |
| - *get_queryset(project, "discoveredpackage"), |
589 |
| - ] |
590 |
| - |
591 |
| - cyclonedx_components = [component.as_cyclonedx() for component in components] |
592 |
| - |
593 |
| - bom = cyclonedx_bom.Bom(components=cyclonedx_components) |
594 |
| - |
595 |
| - project_as_cyclonedx = cyclonedx_component.Component( |
| 656 | + project_as_root_component = cdx_component.Component( |
596 | 657 | name=project.name,
|
597 | 658 | bom_ref=str(project.uuid),
|
598 | 659 | )
|
599 | 660 |
|
600 |
| - project_as_cyclonedx.dependencies.update( |
601 |
| - [component.bom_ref for component in cyclonedx_components] |
602 |
| - ) |
603 |
| - |
604 |
| - bom.metadata = cyclonedx_bom.BomMetaData( |
605 |
| - component=project_as_cyclonedx, |
| 661 | + bom = cdx_bom.Bom() |
| 662 | + bom.metadata = cdx_bom.BomMetaData( |
| 663 | + component=project_as_root_component, |
606 | 664 | tools=[
|
607 |
| - cyclonedx_bom.Tool( |
| 665 | + cdx_bom.Tool( |
608 | 666 | name="ScanCode.io",
|
609 | 667 | version=scancodeio_version,
|
610 | 668 | )
|
611 | 669 | ],
|
612 | 670 | properties=[
|
613 |
| - cyclonedx_bom.Property( |
| 671 | + cdx_bom.Property( |
614 | 672 | name="notice",
|
615 | 673 | value=SCAN_NOTICE,
|
616 | 674 | )
|
617 | 675 | ],
|
618 | 676 | )
|
619 | 677 |
|
| 678 | + components = [] |
| 679 | + vulnerabilities = [] |
| 680 | + for package in get_queryset(project, "discoveredpackage"): |
| 681 | + component = package.as_cyclonedx() |
| 682 | + components.append(component) |
| 683 | + |
| 684 | + for vulnerability_data in package.affected_by_vulnerabilities: |
| 685 | + vulnerabilities.append( |
| 686 | + vulnerability_as_cyclonedx( |
| 687 | + vulnerability_data=vulnerability_data, |
| 688 | + component_bom_ref=component.bom_ref, |
| 689 | + ) |
| 690 | + ) |
| 691 | + |
| 692 | + for component in components: |
| 693 | + bom.components.add(component) |
| 694 | + bom.register_dependency(project_as_root_component, [component]) |
| 695 | + |
| 696 | + bom.vulnerabilities = vulnerabilities |
| 697 | + |
620 | 698 | return bom
|
621 | 699 |
|
622 | 700 |
|
623 |
| -def to_cyclonedx(project): |
| 701 | +def sort_bom_with_schema_ordering(bom_as_dict, schema_version): |
| 702 | + """Sort the ``bom_as_dict`` using the ordering from the ``schema_version``.""" |
| 703 | + schema_file = JsonStrictValidator(schema_version)._schema_file |
| 704 | + with open(schema_file) as sf: |
| 705 | + schema_dict = json.loads(sf.read()) |
| 706 | + |
| 707 | + order_from_schema = list(schema_dict.get("properties", {}).keys()) |
| 708 | + ordered_dict = { |
| 709 | + key: bom_as_dict.get(key) for key in order_from_schema if key in bom_as_dict |
| 710 | + } |
| 711 | + |
| 712 | + return json.dumps(ordered_dict, indent=2) |
| 713 | + |
| 714 | + |
| 715 | +def to_cyclonedx(project, schema_version=SchemaVersion.V1_5): |
624 | 716 | """
|
625 | 717 | Generate output for the provided ``project`` in CycloneDX BOM format.
|
626 | 718 | The output file is created in the ``project`` "output/" directory.
|
627 | 719 | Return the path of the generated output file.
|
628 | 720 | """
|
629 | 721 | output_file = project.get_output_file_path("results", "cdx.json")
|
630 | 722 |
|
631 |
| - cyclonedx_bom = get_cyclonedx_bom(project) |
| 723 | + bom = get_cyclonedx_bom(project) |
| 724 | + json_outputter = make_outputter(bom, OutputFormat.JSON, schema_version) |
632 | 725 |
|
633 |
| - outputter = cyclonedx_output.get_instance( |
634 |
| - bom=cyclonedx_bom, |
635 |
| - output_format=cyclonedx_output.OutputFormat.JSON, |
636 |
| - ) |
| 726 | + # Using the internal API in place of the output_as_string() method to avoid |
| 727 | + # a round of deserialization/serialization while fixing the field ordering. |
| 728 | + json_outputter.generate() |
| 729 | + bom_as_dict = json_outputter._bom_json |
| 730 | + |
| 731 | + # The default order out of the outputter is not great, the following sorts the |
| 732 | + # bom using the order from the schema. |
| 733 | + sorted_json = sort_bom_with_schema_ordering(bom_as_dict, schema_version) |
637 | 734 |
|
638 |
| - bom_json = outputter.output_as_string() |
639 | 735 | with output_file.open("w") as file:
|
640 |
| - file.write(bom_json) |
| 736 | + file.write(sorted_json) |
641 | 737 |
|
642 | 738 | return output_file
|
643 | 739 |
|
|
0 commit comments