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]