Source code for tests.test_sbom

# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import json
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, cast

import pytest

# The CycloneDX and SPDX writer stacks are optional (the [sbom-offline] extra),
# and their imports are guarded in meta_package_manager.sbom. Skip the whole
# module when they are absent so a hermetic packager build collects it cleanly
# instead of crashing on the direct writer imports below.
pytest.importorskip("cyclonedx")
pytest.importorskip("spdx_tools")

# The top-level spdx_tools package can import while the deeper submodules mpm
# actually needs (spdx_tools.spdx.model, the writers) fail to — for instance on
# a brand-new Python like 3.15 whose wheels are still incomplete. mpm records
# that real availability in its own spdx_support sentinel, so gate on it rather
# than the coarse package import above, which would let a partial install
# through and crash the SPDX tests with a NameError. A fully-absent extra is
# already handled by the importorskip calls; this only catches partial installs.
from meta_package_manager.sbom.spdx import spdx_support

if not spdx_support:
    pytest.skip("spdx-tools writer stack not fully importable", allow_module_level=True)

from cyclonedx.schema import OutputFormat, SchemaVersion
from cyclonedx.validation import make_schemabased_validator
from cyclonedx.validation.json import JsonStrictValidator

from meta_package_manager.manager import PackageManager
from meta_package_manager.package import (
    EMPTY_METADATA,
    Checksum,
    ChecksumAlgorithm,
    Dependency,
    DependencyScope,
    Originator,
    Package,
    PackageMetadata,
    Supplier,
)
from meta_package_manager.sbom.base import SBOM, ExportFormat
from meta_package_manager.sbom.cyclonedx import CycloneDX
from meta_package_manager.sbom.spdx import SPDX
from meta_package_manager.sbom.vulnerabilities import Vulnerability


class _StubManager:
    """Unit tests of the SBOM renderers and their shared base class.

    Hermetic: pure-function tests over duck-typed manager stubs, covering export
    format autodetection, the SPDX and CycloneDX writers, upstream-document
    merging, stats and vulnerability rendering. The `mpm sbom` command driving
    these renderers is exercised in {mod}`tests.test_cli_sbom`.
    """

    def __init__(self, manager_id: str, name: str) -> None:
        self.id = manager_id
        self.name = name


def _as_manager(stub: _StubManager) -> PackageManager:
    """Cast a duck-typed stub to the typed {class}`PackageManager` API.

    The SBOM renderers only read `id` and `name` from the manager, which is
    why instantiating real concrete managers (with CLI discovery, version
    parsing) is sidestepped here.
    """
    return cast("PackageManager", stub)


def _make_package(manager_id: str, package_id: str, version: str) -> Package:
    return Package(id=package_id, manager_id=manager_id, installed_version=version)


[docs] def assert_valid_cyclonedx(content: str, export_format: ExportFormat | str) -> None: """Assert a CycloneDX export validates against its schema. This guarantee used to live in {meth}`meta_package_manager.sbom.cyclonedx.CycloneDX.export` at runtime. It moved here so the `jsonschema`-based validation stack (`rfc3987-syntax`, `lark`, `lxml`) stays out of `mpm`'s runtime dependencies. See {mod}`meta_package_manager.sbom`. """ validator: Any if export_format == ExportFormat.JSON: validator = JsonStrictValidator(SchemaVersion.V1_7) else: validator = make_schemabased_validator(OutputFormat.XML, SchemaVersion.V1_7) errors = validator.validate_str(content) assert not errors, f"Invalid CycloneDX {export_format} export: {errors}"
[docs] @pytest.mark.parametrize( ("file_path", "expected"), ( # .json ("export.json", ExportFormat.JSON), ("export.jSon", ExportFormat.JSON), ("export.json.random", None), ("exportjson", None), (".json", None), # .xml ("export.xml", ExportFormat.XML), ("export.xMl", ExportFormat.XML), ("export.xml.random", None), ("exportxml", None), (".xml", None), # .yaml ("export.yaml", ExportFormat.YAML), ("export.yAml", ExportFormat.YAML), ("export.yaml.random", None), ("exportyaml", None), (".yaml", None), # .yml ("export.yml", ExportFormat.YAML), ("export.yMl", ExportFormat.YAML), ("export.yml.random", None), ("exportyml", None), (".yml", None), # .tag ("export.tag", ExportFormat.TAG_VALUE), ("export.tAg", ExportFormat.TAG_VALUE), ("export.tag.random", None), ("exporttag", None), (".tag", None), # .spdx ("export.spdx", ExportFormat.TAG_VALUE), ("export.sPdx", ExportFormat.TAG_VALUE), ("export.spdx.random", None), ("exportspdx", None), (".spdx", None), # .rdf ("export.rdf", ExportFormat.RDF_XML), ("export.rDf", ExportFormat.RDF_XML), ("export.rdf.random", None), ("exportrdf", None), (".rdf", None), # .rdf.xml ("export.rdf.xml", ExportFormat.RDF_XML), ("export.Rdf.xMl", ExportFormat.RDF_XML), ("export.rdf.xml.random", None), ("export.rdfxml", None), ("exportrdfxml", None), (".rdf.xml", ExportFormat.XML), # Unidentified extension ("export.random", None), ("export", None), ), ) def test_file_autodetect(file_path, expected): assert SBOM.autodetect_export_format(Path(file_path)) == expected
[docs] @pytest.mark.parametrize( ("raw_str", "expected"), ( ("SPDXRef-Package-brew-openjdk@11", "SPDXRef-Package-brew-openjdk-11"), ("SPDXRef-my.Super.package", "SPDXRef-my.Super.package"), ("SPDXRef-my---Super.package-------", "SPDXRef-my-Super.package"), ( "pkg:alpm/arch/pacman@6.0.1-1?arch=x86_64", "pkg-alpm-arch-pacman-6.0.1-1-arch-x86-64", ), ( "pkg:alpm/arch/containers-common@1:0.47.4-4?arch=x86_64", "pkg-alpm-arch-containers-common-1-0.47.4-4-arch-x86-64", ), ( "pkg:bitnami/wordpress@6.2.0?arch=arm64&distro=debian-12", "pkg-bitnami-wordpress-6.2.0-arch-arm64-distro-debian-12", ), ( "pkg:cocoapods/GoogleUtilities@7.5.2#NSData+zlib", "pkg-cocoapods-GoogleUtilities-7.5.2-NSData-zlib", ), ( "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", "pkg-deb-debian-curl-7.50.3-1-arch-i386-distro-jessie", ), ), ) def test_normalize_spdx_id(raw_str, expected): assert SPDX.normalize_spdx_id(raw_str) == expected
def _rich_metadata() -> PackageMetadata: """Realistic metadata used by the renderer-level tests below.""" return PackageMetadata( download_url="https://curl.se/download/curl-8.9.0.tar.xz", homepage="https://curl.se", vcs_url="https://github.com/curl/curl", license_declared="MIT", license_concluded="MIT", supplier=Supplier(name="Homebrew Formulae", url="https://brew.sh"), originator=Originator(name="Daniel Stenberg", email="daniel@haxx.se"), description="HTTP transfer library", summary="HTTP transfer library", checksums=(Checksum(ChecksumAlgorithm.SHA256, "a" * 64),), dependencies=(Dependency(target_id="openssl", scope=DependencyScope.RUNTIME),), )
[docs] def test_minimal_mode_emits_bare_spdx_payload(): """Minimal mode must reproduce the legacy bare output: rich metadata is ignored, no relationships beyond `DESCRIBES` are emitted, and `download_location` falls back to `NOASSERTION`. """ s = SPDX() s.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) pkg = _make_package("brew", "curl", "8.9.0") s.add_package(manager, pkg) # No metadata, simulating minimal mode. s.finalize() doc = json.loads(s.export()) package = doc["packages"][0] assert package["name"] == "curl" assert package["downloadLocation"] == "NOASSERTION" assert "licenseDeclared" not in package assert all(r["relationshipType"] == "DESCRIBES" for r in doc["relationships"])
[docs] def test_bundled_mode_spdx_populates_rich_fields(): """A populated {class}`PackageMetadata` flows into the SPDX document: license, supplier override, originator, checksum, and a dependency relationship resolved at {meth}`finalize` time. """ s = SPDX() s.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) curl = _make_package("brew", "curl", "8.9.0") openssl = _make_package("brew", "openssl", "3.3.1") s.add_package(manager, curl, _rich_metadata()) s.add_package(manager, openssl, EMPTY_METADATA) s.finalize() doc = json.loads(s.export()) curl_pkg = next(p for p in doc["packages"] if p["name"] == "curl") assert curl_pkg["downloadLocation"].endswith("curl-8.9.0.tar.xz") assert curl_pkg["homepage"] == "https://curl.se" assert curl_pkg["licenseDeclared"] == "MIT" assert curl_pkg["checksums"][0]["algorithm"] == "SHA256" assert "Daniel Stenberg" in curl_pkg["originator"] rels = [r["relationshipType"] for r in doc["relationships"]] assert "RUNTIME_DEPENDENCY_OF" in rels
[docs] def test_bundled_mode_cyclonedx_populates_rich_fields(): """CycloneDX renderer projects the same metadata into hashes, licenses, supplier, external references, and dependency edges. """ c = CycloneDX() c.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) curl = _make_package("brew", "curl", "8.9.0") openssl = _make_package("brew", "openssl", "3.3.1") c.add_package(manager, curl, _rich_metadata()) c.add_package(manager, openssl, EMPTY_METADATA) c.finalize() doc = json.loads(c.export()) curl_comp = next(comp for comp in doc["components"] if comp["name"] == "curl") assert curl_comp["hashes"][0]["alg"] == "SHA-256" assert curl_comp["licenses"][0]["license"]["id"] == "MIT" ref_types = {ref["type"] for ref in curl_comp["externalReferences"]} assert "website" in ref_types assert "vcs" in ref_types deps_for_curl = next( dep for dep in doc["dependencies"] if dep["ref"] == curl.purl.to_string() ) assert openssl.purl.to_string() in deps_for_curl["dependsOn"] assert_valid_cyclonedx(c.export(), ExportFormat.JSON)
[docs] @pytest.mark.parametrize( ("expression", "expected_identifiers"), ( # Two-license compound expression. ("MIT AND Apache-2.0", ("Apache-2.0", "MIT")), # License combined with an SPDX exception via WITH. ( "MIT WITH Classpath-exception-2.0", ("Classpath-exception-2.0", "MIT"), ), # Nested parenthesized expression with three identifiers. ( "MIT OR (Apache-2.0 AND BSD-3-Clause)", ("Apache-2.0", "BSD-3-Clause", "MIT"), ), # Duplicates in the source collapse to a single `details` entry. ("MIT AND MIT", ("MIT",)), ), ) def test_cyclonedx_compound_license_expression_details( expression, expected_identifiers ): """Compound expressions emit `LicenseExpression.details` with a canonical SPDX URL per identifier, deduped and sorted by identifier. """ c = CycloneDX() c.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) pkg = _make_package("brew", "curl", "8.9.0") md = PackageMetadata(license_declared=expression, license_concluded=expression) c.add_package(manager, pkg, md) c.finalize() doc = json.loads(c.export()) license_obj = doc["components"][0]["licenses"][0] assert license_obj["expression"] == expression details = license_obj["expressionDetails"] assert tuple(d["licenseIdentifier"] for d in details) == expected_identifiers assert tuple(d["url"] for d in details) == tuple( f"https://spdx.org/licenses/{ident}.html" for ident in expected_identifiers ) assert_valid_cyclonedx(c.export(), ExportFormat.JSON)
[docs] @pytest.mark.parametrize( ("declared", "expected_present"), ( # Plain SPDX IDs survive as-is. ("MIT", True), ("Apache-2.0", True), # NOASSERTION sentinels collapse to the typed singleton. ("NOASSERTION", False), # Free-text licenses tend to fail strict parsing — they fall back # to NOASSERTION rather than tripping the validator. ("Some custom license text the parser will reject", False), # LicenseRef-* without an extracted-license declaration is the # exact failure mode that Homebrew's per-formula SBOMs exposed. # It must also collapse to NOASSERTION to keep the doc valid. ("LicenseRef-Homebrew-public-domain", False), # Compound expressions stay if every symbol is known. ("MIT AND Apache-2.0", True), ), ) def test_spdx_license_normalization(declared, expected_present): """The SPDX renderer must produce a document that validates against the SPDX schema for every input it accepts. """ s = SPDX() s.init_doc() md = PackageMetadata(license_declared=declared) s.add_package( _as_manager(_StubManager("brew", "Homebrew Formulae")), _make_package("brew", "curl", "8.9.0"), md, ) s.finalize() doc = json.loads(s.export()) # exporting also validates the doc. pkg = doc["packages"][0] if expected_present: assert pkg["licenseDeclared"] == declared else: assert pkg.get("licenseDeclared") in (None, "NOASSERTION")
[docs] def test_spdx_merges_external_per_package_sbom(tmp_path): """The renderer adopts transitive deps from a per-package upstream SPDX file and records the merge in `externalDocumentRefs`. The fixture mirrors the shape of Homebrew's `<prefix>/Cellar/<formula>/<version>/sbom.spdx.json`. """ upstream = { "SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3", "documentNamespace": "https://example.org/sbom/curl-8.9.0", "documentDescribes": ["SPDXRef-Package-curl"], "packages": [ { "SPDXID": "SPDXRef-Package-curl", "name": "curl", "versionInfo": "8.9.0", "downloadLocation": "https://curl.se/download/curl-8.9.0.tar.xz", "licenseDeclared": "MIT", "homepage": "https://curl.se", }, { "SPDXID": "SPDXRef-Package-zlib", "name": "zlib", "versionInfo": "1.3", "downloadLocation": "https://zlib.net/zlib-1.3.tar.gz", "licenseDeclared": "Zlib", }, ], "relationships": [ { "spdxElementId": "SPDXRef-Package-curl", "relationshipType": "DEPENDS_ON", "relatedSpdxElement": "SPDXRef-Package-zlib", } ], } sbom_file = tmp_path / "sbom.spdx.json" sbom_file.write_text(json.dumps(upstream)) md = PackageMetadata(external_sbom_path=sbom_file) s = SPDX() s.init_doc() s.add_package( _as_manager(_StubManager("brew", "Homebrew Formulae")), _make_package("brew", "curl", "8.9.0"), md, ) s.finalize() doc = json.loads(s.export()) names = sorted(p["name"] for p in doc["packages"]) assert names == ["curl", "zlib"] edrs = doc.get("externalDocumentRefs") or [] assert len(edrs) == 1 assert edrs[0]["checksum"]["algorithm"] == "SHA1"
[docs] def test_stats_track_per_manager_and_merge_counts(tmp_path): """`SBOM.stats()` must reflect what landed in the document. Two packages from a single manager land normally; one of them carries an external SBOM file containing one transitive dep, which gets spliced in. The SPDX-specific stats then report: inventory=2, in_document=3, merged_documents=1. """ upstream = { "SPDXID": "SPDXRef-DOCUMENT", "spdxVersion": "SPDX-2.3", "documentNamespace": "https://example.org/sbom/curl-8.9.0", "documentDescribes": ["SPDXRef-Package-curl"], "packages": [ { "SPDXID": "SPDXRef-Package-curl", "name": "curl", "versionInfo": "8.9.0", "downloadLocation": "NOASSERTION", }, { "SPDXID": "SPDXRef-Package-zlib", "name": "zlib", "versionInfo": "1.3", "downloadLocation": "NOASSERTION", }, ], "relationships": [ { "spdxElementId": "SPDXRef-Package-curl", "relationshipType": "DEPENDS_ON", "relatedSpdxElement": "SPDXRef-Package-zlib", } ], } sbom_file = tmp_path / "sbom.spdx.json" sbom_file.write_text(json.dumps(upstream)) s = SPDX() s.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) s.add_package( manager, _make_package("brew", "curl", "8.9.0"), PackageMetadata( external_sbom_path=sbom_file, license_declared="MIT", ), ) s.add_package( manager, _make_package("brew", "openssl", "3.3.1"), EMPTY_METADATA, ) s.finalize() stats = s.stats() assert stats["packages_total"] == 2 assert stats["packages_per_manager"] == {"brew": 2} assert stats["enriched_per_manager"] == {"brew": 1} assert stats["packages_in_document"] == 3 assert stats["transitive_packages_merged"] == 1 assert stats["merged_documents"] == 1
[docs] def test_cyclonedx_stats_count_external_bom_refs(tmp_path): """CycloneDX stats report external BOM refs and dependency edges.""" sbom_file = tmp_path / "sbom.spdx.json" sbom_file.write_text("{}") c = CycloneDX() c.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) c.add_package( manager, _make_package("brew", "curl", "8.9.0"), PackageMetadata( external_sbom_path=sbom_file, dependencies=(Dependency(target_id="zlib"),), ), ) c.add_package( manager, _make_package("brew", "zlib", "1.3"), EMPTY_METADATA, ) c.finalize() stats = c.stats() assert stats["packages_total"] == 2 assert stats["external_bom_references"] == 1 assert cast("int", stats["dependency_edges"]) >= 1
def _sample_vulnerability(): """Build a sample `Vulnerability` for the render tests.""" return Vulnerability( id="GHSA-aaaa-bbbb-cccc", source="OSV", summary="XSS in example", description="Long detail.", severity="critical", cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H", cwe_ids=("CWE-79",), aliases=("CVE-2021-99999",), references=("https://example.com/advisory",), fixed_versions=("1.0.1",), published_date=datetime(2021, 1, 1, tzinfo=timezone.utc), modified_date=datetime(2021, 6, 1, tzinfo=timezone.utc), advisory_url="https://osv.dev/vulnerability/GHSA-aaaa-bbbb-cccc", )
[docs] def test_spdx_renders_attached_vulnerabilities(): """Attached advisories become SECURITY external refs in SPDX.""" s = SPDX() s.init_doc() manager = _as_manager(_StubManager("pip", "Python pip")) package = _make_package("pip", "django", "1.0.0") s.add_package(manager, package, EMPTY_METADATA) purl = package.purl.to_string() s.attach_vulnerabilities({purl: (_sample_vulnerability(),)}) s.finalize() doc = json.loads(s.export()) # exporting also validates the document. django = next(p for p in doc["packages"] if p["name"] == "django") security_refs = [ ref for ref in django.get("externalRefs", []) if ref["referenceCategory"] == "SECURITY" and ref["referenceType"] == "advisory" ] assert len(security_refs) == 1 assert "GHSA-aaaa-bbbb-cccc" in security_refs[0]["referenceLocator"] assert s.stats()["vulnerabilities_total"] == 1 assert s.stats()["vulnerable_packages"] == 1
[docs] def test_cyclonedx_renders_attached_vulnerabilities(): """Attached advisories become a CycloneDX vulnerabilities array entry.""" c = CycloneDX() c.init_doc() manager = _as_manager(_StubManager("pip", "Python pip")) package = _make_package("pip", "django", "1.0.0") c.add_package(manager, package, EMPTY_METADATA) purl = package.purl.to_string() c.attach_vulnerabilities({purl: (_sample_vulnerability(),)}) c.finalize() content = c.export() assert_valid_cyclonedx(content, ExportFormat.JSON) doc = json.loads(content) vulns = doc.get("vulnerabilities", []) assert len(vulns) == 1 assert vulns[0]["id"] == "GHSA-aaaa-bbbb-cccc" affects = {target["ref"] for target in vulns[0]["affects"]} assert affects == {purl} assert vulns[0]["ratings"][0]["severity"] == "critical"
[docs] def test_shared_advisory_deduplicated_in_cyclonedx(): """One advisory affecting two components yields a single record with two affects targets.""" c = CycloneDX() c.init_doc() manager = _as_manager(_StubManager("pip", "Python pip")) django = _make_package("pip", "django", "1.0.0") flask = _make_package("pip", "flask", "2.0.0") c.add_package(manager, django, EMPTY_METADATA) c.add_package(manager, flask, EMPTY_METADATA) vuln = _sample_vulnerability() c.attach_vulnerabilities({ django.purl.to_string(): (vuln,), flask.purl.to_string(): (vuln,), }) c.finalize() doc = json.loads(c.export()) vulns = doc.get("vulnerabilities", []) assert len(vulns) == 1 affects = {target["ref"] for target in vulns[0]["affects"]} assert affects == {django.purl.to_string(), flask.purl.to_string()}