Repository: bridgecrewio/checkov

Issue:
Support for spdx-tools >= 0.8
**Describe the issue**
The 0.8 version of the `spdx-tools`module lays the groundwork for the upcoming SPDX-3.0 implementation. Unfortunately 0.8 contains breaking changes. 

At first glance it looks like that at least `common/output/spdx.py` needs some modifications.

**Version (please complete the following information):**
 - Checkov Version: latest

**Additional context**
This issue is mainly relevant for distributions which are shipping `checkov` as package and not users who are running with a Python venv.



Discussion:
hey @fabaff thanks for reaching out.

We intentionally upper bounded it, because the `0.8.0` version will introduce breaking changes. So, thanks for letting us know, because I was not aware it was already released. I don't think it will be a heavy lift on our side, because it is just used to create the SPDX output 😄 

Resolving patch:
diff --git a/checkov/common/output/spdx.py b/checkov/common/output/spdx.py
--- a/checkov/common/output/spdx.py
+++ b/checkov/common/output/spdx.py
@@ -1,21 +1,23 @@
 from __future__ import annotations
+
 import itertools
 import logging
-
-from checkov.common.output.extra_resource import ExtraResource
-from checkov.common.output.record import SCA_PACKAGE_SCAN_CHECK_NAME, Record
-from license_expression import get_spdx_licensing
-
+from datetime import datetime, timezone
 from io import StringIO
+from uuid import uuid4
 
-from spdx.creationinfo import Tool, Organization
-from spdx.document import Document
-from spdx.license import License
-from spdx.package import Package
-from spdx.writers.tagvalue import write_document
+from license_expression import get_spdx_licensing
+from spdx_tools.spdx.model.actor import Actor, ActorType
+from spdx_tools.spdx.model.document import Document, CreationInfo
+from spdx_tools.spdx.model.package import Package
+from spdx_tools.spdx.model.spdx_none import SpdxNone
+from spdx_tools.spdx.writer.tagvalue.tagvalue_writer import write_document
 
+from checkov.common.output.extra_resource import ExtraResource
+from checkov.common.output.record import SCA_PACKAGE_SCAN_CHECK_NAME, Record
 from checkov.common.output.cyclonedx_consts import SCA_CHECKTYPES
 from checkov.common.output.report import Report
+from checkov.version import version
 
 DOCUMENT_NAME = "checkov-sbom"
 SPDXREF = "SPDXRef-"
@@ -27,84 +29,97 @@ def __init__(self, repo_id: str | None, reports: list[Report]):
         self.reports = reports
 
         self.document = self.create_document()
+        self.packages: list[Package] = []
+
+        # each entry looks like '{file_name}#{package_name}#{package_version}'
+        self._added_packages_cache: set[str] = set()
 
     def create_document(self) -> Document:
-        document = Document(
-            version="SPDX2.3",
-            data_license=License.from_identifier(identifier="CC0-1.0"),
-            name=DOCUMENT_NAME,
+        creation_info = CreationInfo(
+            spdx_version="SPDX-2.3",
             spdx_id="SPDXRef-DOCUMENT",
-            namespace=f"{self.repo_id}{DOCUMENT_NAME}",
+            name=DOCUMENT_NAME,
+            data_license="CC0-1.0",
+            document_namespace=f"https://spdx.org/spdxdocs/{DOCUMENT_NAME}-{version}-{uuid4()}",
+            creators=[
+                Actor(ActorType.TOOL, "checkov"),
+                Actor(ActorType.ORGANIZATION, "bridgecrew", "meet@bridgecrew.io"),
+            ],
+            created=datetime.now(timezone.utc),
         )
-        document.creation_info.set_created_now()
-        document.creation_info.add_creator(Tool(name="checkov"))
-        document.creation_info.add_creator(Organization(name="bridgecrew"))
-
-        return document
+        return Document(creation_info=creation_info)
 
     def get_tag_value_output(self) -> str:
         output = StringIO()
 
         self.add_packages_to_doc()
-        write_document(document=self.document, out=output, validate=True)  # later set to True
+        write_document(document=self.document, text_output=output)
 
         return output.getvalue()
 
     def validate_licenses(self, package: Package, license_: str) -> None:
-        if license_ and license_ not in ['Unknown license', 'NOT_FOUND', 'Unknown']:
+        if license_ and license_ not in ["Unknown license", "NOT_FOUND", "Unknown"]:
             split_licenses = license_.split(",")
             licenses = []
 
             for lic in split_licenses:
                 lic = lic.strip('"')
                 try:
-                    is_spdx_license = License(get_spdx_licensing().parse(lic), lic)
-                    licenses.append(is_spdx_license)
+                    licenses.append(get_spdx_licensing().parse(lic))
                 except Exception as e:
                     logging.info(f"error occured when trying to parse the license:{split_licenses} due to error {e}")
-            package.licenses_from_files = licenses
+            package.license_info_from_files = licenses
 
     def create_package(self, check: Record | ExtraResource) -> Package:
         package_data = check.vulnerability_details
         if not package_data:
             # this shouldn't happen
             logging.error(f"Check {check.resource} doesn't have 'vulnerability_details' set")
-            return Package(name="unknown")
+            return Package(name="unknown", spdx_id=f"{SPDXREF}unknown", download_location=SpdxNone())
+
+        package_name = package_data.get("package_name")
+        if not package_name:
+            # this shouldn't happen
+            logging.error(f"Package {package_data} doesn't have 'package_name' set")
+            return Package(name="unknown", spdx_id=f"{SPDXREF}unknown", download_location=SpdxNone())
 
-        package_name = package_data.get('package_name')
         package = Package(
             name=package_name,
             spdx_id=f"{SPDXREF}{package_name}",
-            version=package_data['package_version'],
-            download_location='N/A',
-            file_name=check.file_path
+            version=package_data["package_version"],
+            download_location=SpdxNone(),
+            file_name=check.file_path,
         )
-        license_ = package_data.get('licenses', "")
+        license_ = package_data.get("licenses", "")
         self.validate_licenses(package=package, license_=license_)
 
         return package
 
     def add_packages_to_doc(self) -> None:
-        packages_set = set()
         for report in self.reports:
             for check in itertools.chain(report.passed_checks, report.skipped_checks):
                 if report.check_type in SCA_CHECKTYPES and check.check_name != SCA_PACKAGE_SCAN_CHECK_NAME:
                     continue
                 package = self.create_package(check)
-                if package not in packages_set:
-                    packages_set.add(package)
+                self.add_new_package_to_list(package)
 
             for check in report.failed_checks:
                 if report.check_type in SCA_CHECKTYPES and check.check_name != SCA_PACKAGE_SCAN_CHECK_NAME:
                     continue
                 package = self.create_package(check)
-                if package not in packages_set:
-                    packages_set.add(package)
+                self.add_new_package_to_list(package)
 
             for resource in sorted(report.extra_resources):
                 package = self.create_package(resource)
-                if package not in packages_set:
-                    packages_set.add(package)
+                self.add_new_package_to_list(package)
+
+        if self.packages:
+            self.document.packages = self.packages
+
+    def add_new_package_to_list(self, package: Package) -> None:
+        """Adds a package to the list, if it not exists"""
 
-        if packages_set:
-            self.document.packages = list(packages_set)
+        package_cache_entry = f"{package.file_name}#{package.name}#{package.version}"
+        if package_cache_entry not in self._added_packages_cache:
+            self.packages.append(package)
+            self._added_packages_cache.add(package_cache_entry)
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -109,8 +109,8 @@ def run(self) -> None:
         "requests>=2.27.0",
         "yarl",
         "openai",
-        "spdx-tools<0.8.0",
-        "license-expression==30.1.0",
+        "spdx-tools>=0.8.0,<0.9.0",
+        "license-expression",
     ],
     dependency_links=[],  # keep it empty, needed for pipenv-setup
     license="Apache License 2.0",


Test patch:
diff --git a/tests/common/output/test_spdx.py b/tests/common/output/test_spdx.py
new file mode 100644
--- /dev/null
+++ b/tests/common/output/test_spdx.py
@@ -0,0 +1,110 @@
+from freezegun import freeze_time
+
+from checkov.common.bridgecrew.check_type import CheckType
+from checkov.common.output.extra_resource import ExtraResource
+from checkov.common.output.report import Report
+from checkov.common.output.spdx import SPDX
+from checkov.common.sca.output import create_report_cve_record
+
+
+@freeze_time("2022-12-24")
+def test_sca_package_output():
+    # given
+    rootless_file_path = "requirements.txt"
+    file_abs_path = "/path/to/requirements.txt"
+    check_class = "checkov.sca_package.scanner.Scanner"
+    vulnerability_details = {
+        "id": "CVE-2019-19844",
+        "status": "fixed in 3.0.1, 2.2.9, 1.11.27",
+        "cvss": 9.8,
+        "vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
+        "description": "Django before 1.11.27, 2.x before 2.2.9, and 3.x before 3.0.1 allows account takeover. ...",
+        "severity": "moderate",
+        "packageName": "django",
+        "packageVersion": "1.2",
+        "link": "https://nvd.nist.gov/vuln/detail/CVE-2019-19844",
+        "riskFactors": ["Attack complexity: low", "Attack vector: network", "Critical severity", "Has fix"],
+        "impactedVersions": ["<1.11.27"],
+        "publishedDate": "2019-12-18T20:15:00+01:00",
+        "discoveredDate": "2019-12-18T19:15:00Z",
+        "fixDate": "2019-12-18T20:15:00+01:00",
+    }
+
+    record = create_report_cve_record(
+        rootless_file_path=rootless_file_path,
+        file_abs_path=file_abs_path,
+        check_class=check_class,
+        vulnerability_details=vulnerability_details,
+        licenses="OSI_BDS",
+        package={"package_registry": "https://registry.npmjs.org/", "is_private_registry": False},
+    )
+    # also add a BC_VUL_2 record
+    bc_record = create_report_cve_record(
+        rootless_file_path=rootless_file_path,
+        file_abs_path=file_abs_path,
+        check_class=check_class,
+        vulnerability_details=vulnerability_details,
+        licenses="OSI_BDS",
+        package={"package_registry": "https://registry.npmjs.org/", "is_private_registry": False},
+    )
+    bc_record.check_id = "BC_VUL_2"
+
+    report = Report(CheckType.SCA_PACKAGE)
+    report.add_resource(record.resource)
+    report.add_record(record)
+    report.add_record(bc_record)
+
+    report.extra_resources.add(
+        ExtraResource(
+            file_abs_path=file_abs_path,
+            file_path=f"/{rootless_file_path}",
+            resource=f"{rootless_file_path}.testpkg",
+            vulnerability_details={"package_name": "testpkg", "package_version": "1.1.1", "licenses": "MIT"},
+        )
+    )
+
+    # when
+    spdx = SPDX(repo_id="example", reports=[report])
+
+    # override dynamic data
+    spdx.document.creation_info.document_namespace = "https://spdx.org/spdxdocs/checkov-sbom-9.9.9"
+
+    # then
+    output = spdx.get_tag_value_output()
+
+    # remove dynamic data
+    assert output == "".join(
+        [
+            "## Document Information\n",
+            "SPDXVersion: SPDX-2.3\n",
+            "DataLicense: CC0-1.0\n",
+            "SPDXID: SPDXRef-DOCUMENT\n",
+            "DocumentName: checkov-sbom\n",
+            "DocumentNamespace: https://spdx.org/spdxdocs/checkov-sbom-9.9.9\n",
+            "\n",
+            "## Creation Information\n",
+            "Creator: Tool: checkov\n",
+            "Creator: Organization: bridgecrew (meet@bridgecrew.io)\n",
+            "Created: 2022-12-24T00:00:00+00:00Z\n",
+            "\n",
+            "## Package Information\n",
+            "PackageName: django\n",
+            "SPDXID: SPDXRef-django\n",
+            "PackageVersion: 1.2\n",
+            "PackageFileName: /requirements.txt\n",
+            "PackageDownloadLocation: NONE\n",
+            "FilesAnalyzed: True\n",
+            "PackageLicenseInfoFromFiles: OSI_BDS\n",
+            "\n",
+            "## Package Information\n",
+            "PackageName: testpkg\n",
+            "SPDXID: SPDXRef-testpkg\n",
+            "PackageVersion: 1.1.1\n",
+            "PackageFileName: /requirements.txt\n",
+            "PackageDownloadLocation: NONE\n",
+            "FilesAnalyzed: True\n",
+            "PackageLicenseInfoFromFiles: MIT\n",
+            "\n",
+            "\n",
+        ]
+    )