From fbda49d9470652c502e88eb3f173d73c63508f64 Mon Sep 17 00:00:00 2001 From: Bernat Gabor Date: Sat, 19 Sep 2026 08:15:32 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(build):=20add=20serialNumber?= =?UTF-8?q?=20to=20the=20generated=20SBOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit actions/attest's own CycloneDX detector requires bomFormat, serialNumber and specVersion together before it will treat a file as CycloneDX at all: serialNumber is optional in the CycloneDX spec itself, but this action's format sniffer treats its absence as an unrecognized format and refuses to attest it. Nothing in pull request CI catches this, because release.yaml only runs on a tag push, so this only ever executed for real inside the 21.8.0 release, which failed at the attestation step as a result. A random uuid4 would have satisfied the immediate check but made every build's SBOM non-reproducible even from an identical source tree. Research into how comparable projects handle this (auditwheel and maturin both hand-roll the same document shape and ship the identical missing-serialNumber gap in production; Microsoft's bocpy is the one precedent that gets this right) points at a uuid5 derived from the package name, version and the sorted bundled-wheel hashes instead, so two builds of the same commit produce a byte-identical document. No CycloneDX library was adopted: none of the surveyed precedent uses one for this exact case, because the object-model layer such libraries provide is built around resolving an installed dependency graph, and there's no such graph here - just one root and a flat list of bundled, undeclared wheels. The second, more consequential gap the incident exposed is procedural rather than a missing field: the wheel-build-and-package checks already run on every pull request, but nothing in that path inspected the SBOM's actual structure, so a future format change on the consumer side would fail silently again until the next real tag push. tasks/validate_sbom.py adds a small, stdlib-only check of the exact invariants actions/attest's format sniffer cares about, wired into the existing tox -e readme environment that already builds and checks the wheel on every PR across Linux and Windows - proved to both pass against a correct build and fail against a wheel with the serialNumber stripped, matching the original bug exactly. --- docs/changelog/3268.bugfix.rst | 3 ++ hatch_build.py | 49 ++++++++++++++--------- tasks/validate_sbom.py | 73 ++++++++++++++++++++++++++++++++++ tox.toml | 3 +- 4 files changed, 108 insertions(+), 20 deletions(-) create mode 100644 docs/changelog/3268.bugfix.rst create mode 100644 tasks/validate_sbom.py diff --git a/docs/changelog/3268.bugfix.rst b/docs/changelog/3268.bugfix.rst new file mode 100644 index 000000000..76bd5072b --- /dev/null +++ b/docs/changelog/3268.bugfix.rst @@ -0,0 +1,3 @@ +Make the embedded SBOM's ``serialNumber`` a deterministic UUID derived from the package name, version and bundled wheel +hashes, so the same source tree produces a byte-identical SBOM, and validate the SBOM's structure as part of the +packaging checks that already run on every pull request. diff --git a/hatch_build.py b/hatch_build.py index 9a5a690e8..eadf5f87e 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -3,13 +3,15 @@ import ast import json import tempfile +import uuid from pathlib import Path -from typing import Any +from typing import Any, Final from hatchling.builders.hooks.plugin.interface import BuildHookInterface -_ROOT = Path(__file__).parent -_EMBED_INIT = _ROOT / "src" / "virtualenv" / "seed" / "wheels" / "embed" / "__init__.py" +_ROOT: Final[Path] = Path(__file__).resolve().parent +_EMBED_INIT: Final[Path] = _ROOT / "src" / "virtualenv" / "seed" / "wheels" / "embed" / "__init__.py" +_SBOM_NAMESPACE: Final[uuid.UUID] = uuid.uuid5(uuid.NAMESPACE_URL, "https://github.com/pypa/virtualenv/sboms") class SbomBuildHook(BuildHookInterface): @@ -39,25 +41,11 @@ def initialize(self, version: str, build_data: dict[str, Any]) -> None: # ruff: build_data["sbom_files"].append(str(out)) -def _bundled_wheels() -> dict[str, str]: - tree = ast.parse(_EMBED_INIT.read_text(encoding="utf-8")) - sha256_by_name = {} - for node in tree.body: - if isinstance(node, ast.Assign) and any( - isinstance(target, ast.Name) and target.id == "BUNDLE_SHA256" for target in node.targets - ): - sha256_by_name = ast.literal_eval(node.value) - break - if not sha256_by_name: - msg = f"BUNDLE_SHA256 not found in {_EMBED_INIT}" - raise RuntimeError(msg) - return sha256_by_name - - def _cyclonedx_document(version: str, name: str) -> dict[str, Any]: + wheel_sha256 = _bundled_wheels() components = [] dependencies = [{"ref": f"pkg:pypi/{name}@{version}", "dependsOn": []}] - for filename, sha256 in sorted(_bundled_wheels().items()): + for filename, sha256 in sorted(wheel_sha256.items()): distribution, wheel_version = filename.split("-")[:2] purl = f"pkg:pypi/{distribution}@{wheel_version}" components.append({ @@ -80,6 +68,7 @@ def _cyclonedx_document(version: str, name: str) -> dict[str, Any]: return { "bomFormat": "CycloneDX", "specVersion": "1.6", + "serialNumber": _serial_number(name, version, wheel_sha256), "version": 1, "metadata": { "component": { @@ -93,3 +82,25 @@ def _cyclonedx_document(version: str, name: str) -> dict[str, Any]: "components": components, "dependencies": dependencies, } + + +def _bundled_wheels() -> dict[str, str]: + tree = ast.parse(_EMBED_INIT.read_text(encoding="utf-8")) + sha256_by_name = {} + for node in tree.body: + if isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id == "BUNDLE_SHA256" for target in node.targets + ): + sha256_by_name = ast.literal_eval(node.value) + break + if not sha256_by_name: + msg = f"BUNDLE_SHA256 not found in {_EMBED_INIT}" + raise RuntimeError(msg) + return sha256_by_name + + +def _serial_number(name: str, version: str, wheel_sha256: dict[str, str]) -> str: + # uuid5 rather than uuid4: deterministic on the inputs that actually change the SBOM's content, so two builds + # of the same commit against the same bundled wheels produce a byte-identical document + payload = f"{name}@{version}+{','.join(f'{k}:{v}' for k, v in sorted(wheel_sha256.items()))}" + return f"urn:uuid:{uuid.uuid5(_SBOM_NAMESPACE, payload)}" diff --git a/tasks/validate_sbom.py b/tasks/validate_sbom.py new file mode 100644 index 000000000..bf6f4d8f4 --- /dev/null +++ b/tasks/validate_sbom.py @@ -0,0 +1,73 @@ +"""Check that a built wheel's embedded SBOM satisfies what a consumer like actions/attest requires.""" + +from __future__ import annotations + +import json +import re +import sys +import zipfile +from pathlib import Path + +_SERIAL_PATTERN = re.compile(r"^urn:uuid:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") + + +def main() -> None: + directory = Path(sys.argv[1]) + wheels = sorted(directory.glob("*.whl")) + if len(wheels) != 1: + msg = f"expected exactly one wheel in {directory}, found {len(wheels)}: {[w.name for w in wheels]}" + raise SystemExit(msg) + wheel = wheels[0] + problems = validate(wheel) + if problems: + for problem in problems: + print(f"SBOM invalid: {problem}") # ruff:ignore[print] + raise SystemExit(1) + print(f"SBOM in {wheel.name} is structurally valid") # ruff:ignore[print] + + +def validate(wheel: Path) -> list[str]: + with zipfile.ZipFile(wheel) as archive: + names = archive.namelist() + sbom_name = next((name for name in names if name.endswith("/sboms/virtualenv.cdx.json")), None) + if sbom_name is None: + return ["no .dist-info/sboms/virtualenv.cdx.json found in the wheel"] + document = json.loads(archive.read(sbom_name)) + + problems = [] + if document.get("bomFormat") != "CycloneDX": + problems.append(f"bomFormat must be 'CycloneDX', got {document.get('bomFormat')!r}") + if document.get("specVersion") != "1.6": + problems.append(f"specVersion must be '1.6', got {document.get('specVersion')!r}") + if document.get("version") != 1: + problems.append(f"version must be 1, got {document.get('version')!r}") + + serial = document.get("serialNumber") + if not serial or not _SERIAL_PATTERN.match(serial): + # optional in the CycloneDX spec itself, but actions/attest's format sniffer requires it to recognize + # the document as CycloneDX at all, and silently rejects anything missing it as an unknown format + problems.append(f"serialNumber must match {_SERIAL_PATTERN.pattern}, got {serial!r}") + + root_ref = document.get("metadata", {}).get("component", {}).get("bom-ref") + if not root_ref: + problems.append("metadata.component.bom-ref is missing") + + component_refs = {component.get("bom-ref") for component in document.get("components", [])} + dependency_entries = { + entry.get("ref"): set(entry.get("dependsOn", [])) for entry in document.get("dependencies", []) + } + unknown_refs = set(dependency_entries) - component_refs - {root_ref} + if unknown_refs: + problems.append(f"dependencies entries with no matching component: {sorted(unknown_refs)}") + + root_depends_on = dependency_entries.get(root_ref) + if root_depends_on is None: + problems.append(f"no dependencies entry for the root component {root_ref!r}") + elif root_depends_on != component_refs: + problems.append(f"root dependsOn {sorted(root_depends_on)} does not match components {sorted(component_refs)}") + + return problems + + +if __name__ == "__main__": + main() diff --git a/tox.toml b/tox.toml index 7d9b6aba6..4415bde6c 100644 --- a/tox.toml +++ b/tox.toml @@ -114,13 +114,14 @@ commands = [ ] [env.readme] -description = "check that the long description is valid" +description = "check that the long description, wheel contents and embedded SBOM are valid" skip_install = true dependency_groups = [ "pkg-meta" ] commands = [ [ "uv", "build", "--sdist", "--wheel", "--out-dir", "{env_tmp_dir}", "." ], [ "twine", "check", "{env_tmp_dir}{/}*" ], [ "check-wheel-contents", "--no-config", "{env_tmp_dir}" ], + [ "python", "{tox_root}{/}tasks{/}validate_sbom.py", "{env_tmp_dir}" ], ] [env.rp]