Source code for tests.test_sbom

# Copyright Kevin Deldycke <[email protected]> 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 UTC, datetime
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.cyclonedx import cyclonedx_support
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)

# The same partial-install hazard on the CycloneDX side, which this module had
# no guard for: `cyclonedx` imports while the symbols mpm's renderer needs are
# absent from an older release, and every CycloneDX test then dies on a
# NameError rather than skipping. NixOS carries such a cyclonedx-python-lib.
if not cyclonedx_support:
    pytest.skip(
        "cyclonedx-python-lib 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.package import (
    EMPTY_METADATA,
    Checksum,
    ChecksumAlgorithm,
    Dependency,
    DependencyScope,
    Originator,
    Package,
    PackageMetadata,
    Supplier,
)
from meta_package_manager.sbom._purl import package_purl, registered_purl
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

TYPE_CHECKING = False
if TYPE_CHECKING:
    from meta_package_manager.manager import PackageManager


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] def test_package_purl_carries_the_namespace(): """The pURL of a scoped npm package holds its scope as namespace. As the npm type definition expects. """ package = Package(id="@babel/core", manager_id="npm", installed_version="7.26.0") assert package_purl(package) == "pkg:npm/%40babel/[email protected]"
[docs] @pytest.mark.parametrize( ("manager_id", "package_id", "version", "arch", "vendor", "expected"), ( ("pip", "Django_Rest", "3.15", None, None, "pkg:pypi/[email protected]"), ("uv", "yt-dlp", "2026.8.19", None, None, "pkg:pypi/[email protected]"), # npm names are case-sensitive. ("npm", "JSONStream", "1.3.5", None, None, "pkg:npm/[email protected]"), ("yarn", "@babel/core", "7.26.0", None, None, "pkg:npm/%40babel/[email protected]"), ("brew", "[email protected]", "3.12.7", None, None, "pkg:brew/python%[email protected]"), ( "brew", "hashicorp/tap/terraform", "1.9.8", None, None, "pkg:brew/hashicorp/tap/[email protected]", ), ( "cask", "firefox", "120.0", None, None, "pkg:brew/homebrew/cask/[email protected]", ), ( "vscode", "ms-python.python", "2024.0.1", None, None, "pkg:vscode-extension/ms-python/[email protected]", ), ( "apt", "curl", "8.5.0-2ubuntu10", "amd64", "ubuntu", "pkg:deb/ubuntu/[email protected]?arch=amd64", ), ( "pacman", "pacman", "6.0.1-1", "x86_64", "arch", "pkg:alpm/arch/[email protected]?arch=x86_64", ), ( "vcpkg", "zlib:x64-linux", "1.3.1#2", None, None, "pkg:vcpkg/[email protected]?port_version=2&triplet=x64-linux", ), ( "go", "github.com/nao1215/gup", "v1.8.1", None, None, "pkg:golang/github.com/nao1215/[email protected]", ), # Outside a distribution, a system package has no vendor. ("apt", "curl", "8.5.0-2ubuntu10", "amd64", None, None), # The type requires a part `mpm` does not know. ("julia", "Example", "0.5.3", None, None, None), ("cpan", "Text::CharWidth", "0.04", None, None, None), ("dkp-pacman", "devkitarm-rules", "1.5.1-1", None, "arch", None), # No registered type covers the manager. ("mas", "497799835", "16.0", None, None, None), ), ) def test_registered_purl( monkeypatch, manager_id, package_id, version, arch, vendor, expected ): """A package gets the pURL of its registered type, when `mpm` can build one.""" monkeypatch.setattr("meta_package_manager.sbom._purl.distro_vendor", lambda: vendor) package = Package( id=package_id, manager_id=manager_id, installed_version=version, arch=arch ) assert registered_purl(package) == expected
[docs] def test_cyclonedx_publishes_the_registered_purl(): """A component carries the pURL of its type, and its manager pURL as `bom-ref`. packageurl-python would lowercase the npm name, which the npm type keeps. """ c = CycloneDX() c.init_doc() c.add_package( _as_manager(_StubManager("pip", "Python pip")), _make_package("pip", "django", "1.0.0"), EMPTY_METADATA, ) c.add_package( _as_manager(_StubManager("npm", "npm")), _make_package("npm", "JSONStream", "1.3.5"), EMPTY_METADATA, ) c.finalize() content = c.export() assert_valid_cyclonedx(content, ExportFormat.JSON) components = { component["name"]: (component["purl"], component["bom-ref"]) for component in json.loads(content)["components"] } assert components["django"] == ("pkg:pypi/[email protected]", "pkg:pip/[email protected]") assert components["JSONStream"] == ( "pkg:npm/[email protected]", "pkg:npm/[email protected]", )
[docs] def test_spdx_leads_with_the_registered_purl(): """The pURL of the registered type comes first, the manager pURL second.""" s = SPDX() s.init_doc() s.add_package( _as_manager(_StubManager("pip", "Python pip")), _make_package("pip", "django", "1.0.0"), EMPTY_METADATA, ) s.finalize() doc = json.loads(s.export()) django = next(p for p in doc["packages"] if p["name"] == "django") purls = [ ref["referenceLocator"] for ref in django["externalRefs"] if ref["referenceType"] == "purl" ] assert purls == ["pkg:pypi/[email protected]", "pkg:pip/[email protected]"]
[docs] @pytest.mark.parametrize("renderer_class", (SPDX, CycloneDX)) def test_advisory_of_the_registered_purl_reaches_the_package(renderer_class): """An advisory found under the pURL of the registered type lands on the package.""" renderer = renderer_class() renderer.init_doc() package = _make_package("pip", "django", "1.0.0") renderer.add_package( _as_manager(_StubManager("pip", "Python pip")), package, EMPTY_METADATA ) assert "pkg:pypi/[email protected]" in set(renderer.all_purls()) assert renderer.resolve_purl_targets("pkg:pypi/[email protected]") == ( "pkg:pypi/[email protected]", package_purl(package), )
[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/[email protected]?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/[email protected]?arch=arm64&distro=debian-12", "pkg-bitnami-wordpress-6.2.0-arch-arm64-distro-debian-12", ), ( "pkg:cocoapods/[email protected]#NSData+zlib", "pkg-cocoapods-GoogleUtilities-7.5.2-NSData-zlib", ), ( "pkg:deb/debian/[email protected]?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="[email protected]"), 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 reproduces 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 package metadata flows into the SPDX document. A {class}`~meta_package_manager.package.PackageMetadata` gives the license, supplier override, originator, checksum, and a dependency relationship resolved at {meth}`~meta_package_manager.sbom.base.SBOM.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(): """The 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"] == package_purl(curl) ) assert package_purl(openssl) 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 produces a document valid 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 an upstream SPDX file. A per-package upstream file, and it 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=UTC), modified_date=datetime(2021, 6, 1, tzinfo=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(package) 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(package) 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"
UPSTREAM_ALIAS = "pkg:pypi/[email protected]" """Registry coordinate Homebrew records beside a formula's own purl. Stands in for what the Homebrew extractor lifts out of `sbom.spdx.json`: OSV answers for this one, never for the `pkg:brew/...` the inventory pass assigns. """ def _aliased_brew_package(): """A formula carrying its upstream registry coordinate.""" package = _make_package("brew", "yt-dlp", "2026.8.19") metadata = PackageMetadata( extra_purls=(UPSTREAM_ALIAS,), ) return package, metadata
[docs] @pytest.mark.parametrize("renderer_class", (SPDX, CycloneDX)) def test_all_purls_yields_aliases(renderer_class): """The scan queries the alias: it is the only answerable coordinate.""" renderer = renderer_class() renderer.init_doc() package, metadata = _aliased_brew_package() renderer.add_package( _as_manager(_StubManager("brew", "Homebrew Formulae")), package, metadata ) purls = set(renderer.all_purls()) assert purls == {package_purl(package), UPSTREAM_ALIAS}
[docs] @pytest.mark.parametrize("renderer_class", (SPDX, CycloneDX)) def test_resolve_purl_targets_names_every_owner(renderer_class): """The key comes first, then its alias owners. An unknown key stands alone.""" renderer = renderer_class() renderer.init_doc() package, metadata = _aliased_brew_package() renderer.add_package( _as_manager(_StubManager("brew", "Homebrew Formulae")), package, metadata ) primary = package_purl(package) assert renderer.resolve_purl_targets(UPSTREAM_ALIAS) == (UPSTREAM_ALIAS, primary) assert renderer.resolve_purl_targets(primary) == (primary,) assert renderer.resolve_purl_targets("pkg:pypi/[email protected]") == ( "pkg:pypi/[email protected]", )
[docs] @pytest.mark.parametrize("renderer_class", (SPDX, CycloneDX)) def test_one_coordinate_shared_by_two_packages_reaches_both(renderer_class): """Two formulae built from one upstream both own its advisories.""" renderer = renderer_class() renderer.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) metadata = PackageMetadata(extra_purls=(UPSTREAM_ALIAS,)) first = _make_package("brew", "yt-dlp", "2026.8.19") second = _make_package("brew", "yt-dlp@stable", "2026.8.19") renderer.add_package(manager, first, metadata) renderer.add_package(manager, second, metadata) assert renderer.resolve_purl_targets(UPSTREAM_ALIAS) == ( UPSTREAM_ALIAS, package_purl(first), package_purl(second), )
[docs] def test_cyclonedx_one_advisory_reaches_every_alias_owner(): """An advisory shared through one alias emits once, affecting both.""" c = CycloneDX() c.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) metadata = PackageMetadata(extra_purls=(UPSTREAM_ALIAS,)) first = _make_package("brew", "yt-dlp", "2026.8.19") second = _make_package("brew", "yt-dlp@stable", "2026.8.19") c.add_package(manager, first, metadata) c.add_package(manager, second, metadata) c.attach_vulnerabilities({UPSTREAM_ALIAS: (_sample_vulnerability(),)}) c.finalize() content = c.export() assert_valid_cyclonedx(content, ExportFormat.JSON) vulns = json.loads(content).get("vulnerabilities", []) assert len(vulns) == 1 affects = {target["ref"] for target in vulns[0]["affects"]} assert affects == { package_purl(first), package_purl(second), }
[docs] @pytest.mark.parametrize("renderer_class", (SPDX, CycloneDX)) def test_stats_count_the_packages_an_alias_reaches(renderer_class): """`vulnerable_packages` counts packages, not the coordinates queried.""" renderer = renderer_class() renderer.init_doc() manager = _as_manager(_StubManager("brew", "Homebrew Formulae")) metadata = PackageMetadata(extra_purls=(UPSTREAM_ALIAS,)) renderer.add_package( manager, _make_package("brew", "yt-dlp", "2026.8.19"), metadata ) renderer.add_package( manager, _make_package("brew", "yt-dlp@stable", "2026.8.19"), metadata ) renderer.attach_vulnerabilities({UPSTREAM_ALIAS: (_sample_vulnerability(),)}) renderer.finalize() stats = renderer.stats() assert stats["vulnerabilities_total"] == 1 # One advisory, found under one alias, reaching both formulae. Counting # the queried coordinate instead would report a single package here. assert stats["vulnerable_packages"] == 2
[docs] def test_spdx_advisory_found_under_an_alias_lands_on_the_formula(): """An advisory keyed by the upstream purl attaches to the brew package.""" s = SPDX() s.init_doc() package, metadata = _aliased_brew_package() s.add_package( _as_manager(_StubManager("brew", "Homebrew Formulae")), package, metadata ) s.attach_vulnerabilities({UPSTREAM_ALIAS: (_sample_vulnerability(),)}) s.finalize() doc = json.loads(s.export()) # exporting also validates the document. formula = next(p for p in doc["packages"] if p["name"] == "yt-dlp") security_refs = [ ref for ref in formula.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"] # The alias is published beside the formula's own purl, so a consumer # can retrace which coordinate the advisory was found under. package_manager_refs = { ref["referenceLocator"] for ref in formula["externalRefs"] # `spdx-tools` serializes the category with an underscore, where # the SPDX 2.3 JSON schema spells it `PACKAGE-MANAGER`. if ref["referenceCategory"] == "PACKAGE_MANAGER" } assert package_manager_refs == {package_purl(package), UPSTREAM_ALIAS}
[docs] def test_cyclonedx_advisory_found_under_an_alias_affects_the_formula(): """`affects` addresses the component by its own `bom_ref`, not the alias.""" c = CycloneDX() c.init_doc() package, metadata = _aliased_brew_package() c.add_package( _as_manager(_StubManager("brew", "Homebrew Formulae")), package, metadata ) c.attach_vulnerabilities({UPSTREAM_ALIAS: (_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 affects = {target["ref"] for target in vulns[0]["affects"]} assert affects == {package_purl(package)}
[docs] def test_shared_advisory_deduplicated_in_cyclonedx(): """One advisory affecting two components yields a single record. The record has 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({ package_purl(django): (vuln,), package_purl(flask): (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 == { package_purl(django), package_purl(flask), }