Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog/3268.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
49 changes: 30 additions & 19 deletions hatch_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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({
Expand All @@ -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": {
Expand All @@ -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)}"
73 changes: 73 additions & 0 deletions tasks/validate_sbom.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion tox.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down