From 9f37ef782dc7f46646bc61234d615f104b54a8b3 Mon Sep 17 00:00:00 2001 From: Bernat Gabor Date: Sat, 19 Sep 2026 00:24:22 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(build):=20ship=20a=20CycloneDX?= =?UTF-8?q?=20SBOM=20in=20every=20wheel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream consumers scanning for bundled software cannot see the pip and setuptools wheels virtualenv embeds under src/virtualenv/seed/wheels/embed: syft, cyclonedx-py and GitHub's own dependency-graph export all read declared dependency metadata or an installed environment, and these wheels are neither. They are data files, invisible to every one of those tools, verified empirically against a real build of this project. PEP 770 exists for exactly this case. A build hook now writes a CycloneDX 1.6 document into the wheel's .dist-info/sboms/ directory, declaring each bundled wheel with its name, version, licence and the SHA-256 already computed for integrity checking at install time. The hook reads BUNDLE_SUPPORT and BUNDLE_SHA256 straight out of the existing embed/__init__.py with ast.literal_eval, the same approach tasks/upgrade_wheels.py already uses, so a wheel bump keeps the SBOM current with no separate update step. No scanner runs in CI. Every one tested against this repository either found nothing or a component list that omitted the four things worth declaring, so adding one would add a dependency and an upgrade treadmill to reproduce data pyproject.toml already states. hatchling>=1.28 is required for sbom-files support, but that release also dropped Python 3.9 from hatchling itself, which is still a supported build environment here. build-system.requires now splits on python_version so anything older stays on hatchling<1.28, and the hook checks for the sbom_files key before touching it rather than assuming the newer hatchling ran, so those builds proceed without an SBOM instead of failing. The release workflow extracts the SBOM from the built wheel and attests it against the sdist and wheel with actions/attest, verified against the action's own documented required permissions. --- .github/workflows/release.yaml | 13 +++++ docs/changelog/3265.feature.rst | 3 ++ docs/development.rst | 5 ++ hatch_build.py | 95 +++++++++++++++++++++++++++++++++ pyproject.toml | 5 +- 5 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 docs/changelog/3265.feature.rst create mode 100644 hatch_build.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 07befb34d..02de64dd5 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,6 +12,7 @@ jobs: contents: read id-token: write attestations: write + artifact-metadata: write steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -28,6 +29,13 @@ jobs: run: uv python install 3.14 - name: Build sdist and wheel run: uv build --python 3.14 --python-preference only-managed --sdist --wheel . --out-dir dist + - name: Extract the SBOM the wheel build embedded + run: python -m zipfile -e dist/*.whl sbom-extract/ && cp sbom-extract/*.dist-info/sboms/virtualenv.cdx.json . + - name: Attest the SBOM against the sdist and wheel + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/* + sbom-path: virtualenv.cdx.json - name: Build zipapp run: uv tool run --with tox-uv tox r -e zipapp - name: Attest provenance for the zipapp @@ -44,6 +52,11 @@ jobs: with: name: ${{ env.dists-artifact-name }} path: dist/* + - name: Store the SBOM + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: virtualenv-sbom + path: virtualenv.cdx.json - name: Store the zipapp uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/docs/changelog/3265.feature.rst b/docs/changelog/3265.feature.rst new file mode 100644 index 000000000..d2efc04bf --- /dev/null +++ b/docs/changelog/3265.feature.rst @@ -0,0 +1,3 @@ +Every published wheel now carries a `CycloneDX `_ SBOM at +``.dist-info/sboms/virtualenv.cdx.json`` (:PEP:`770`), declaring the bundled ``pip`` and ``setuptools`` wheels that +generic SBOM tools cannot see on their own. GitHub attests it against the released sdist and wheel. diff --git a/docs/development.rst b/docs/development.rst index 115fbea9e..b5abefe7b 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -235,6 +235,11 @@ virtualenv is distributed under the MIT License, and everything in the repositor and are redistributed unchanged. - Adding a runtime dependency or bumping an embedded wheel is a maintainer decision; checking the license of the new version is part of that review. +- Every wheel virtualenv publishes carries a `CycloneDX `_ SBOM at + ``.dist-info/sboms/virtualenv.cdx.json``, generated at build time by ``hatch_build.py`` from the same + ``BUNDLE_SUPPORT``/``BUNDLE_SHA256`` tables that back the embedded wheels above, so a wheel bump keeps it current + automatically. GitHub attests it against the release's sdist and wheel; verify with ``gh attestation verify -R + pypa/virtualenv --predicate-type https://cyclonedx.org/bom``. Automated testing ================= diff --git a/hatch_build.py b/hatch_build.py new file mode 100644 index 000000000..9a5a690e8 --- /dev/null +++ b/hatch_build.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import ast +import json +import tempfile +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + +_ROOT = Path(__file__).parent +_EMBED_INIT = _ROOT / "src" / "virtualenv" / "seed" / "wheels" / "embed" / "__init__.py" + + +class SbomBuildHook(BuildHookInterface): + """Write a PEP 770 CycloneDX SBOM into the wheel's ``.dist-info/sboms/`` directory. + + General-purpose SBOM scanners (syft, cyclonedx-py, GitHub's dependency graph) read declared dependency metadata or + an installed environment, and virtualenv's bundled pip and setuptools wheels are neither: they are data files + embedded under ``src/virtualenv/seed/wheels/embed/``, invisible to every one of those tools. Declaring them here, + read straight out of the ``BUNDLE_SUPPORT``/``BUNDLE_SHA256`` tables that ``tasks/upgrade_wheels.py`` already + maintains, means the SBOM tracks a wheel bump automatically instead of needing its own update step. + + """ + + PLUGIN_NAME = "sbom" + + def initialize(self, version: str, build_data: dict[str, Any]) -> None: # ruff:ignore[unused-method-argument] + # `version` here is hatchling's build variant name (e.g. "standard"), not the package version + if self.target_name != "wheel" or "sbom_files" not in build_data: + # sbom_files only exists on hatchling>=1.28, which build-system.requires excludes for the + # Python versions old enough to still need hatchling<1.28 for building at all + return + document = _cyclonedx_document(self.metadata.version, self.metadata.core.name) + # written outside self.directory, which hatchling also uses as the final wheel output location, so a + # stray copy here would sit next to the built artifacts and trip `twine check dist/*` + out = Path(tempfile.mkdtemp(prefix="virtualenv-sbom-")) / "virtualenv.cdx.json" + out.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + 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]: + components = [] + dependencies = [{"ref": f"pkg:pypi/{name}@{version}", "dependsOn": []}] + for filename, sha256 in sorted(_bundled_wheels().items()): + distribution, wheel_version = filename.split("-")[:2] + purl = f"pkg:pypi/{distribution}@{wheel_version}" + components.append({ + "type": "library", + "name": distribution, + "version": wheel_version, + "purl": purl, + "bom-ref": purl, + "licenses": [{"license": {"id": "MIT"}}], + "hashes": [{"alg": "SHA-256", "content": sha256}], + "externalReferences": [ + {"type": "distribution", "url": f"https://pypi.org/project/{distribution}/{wheel_version}/"}, + ], + "properties": [ + {"name": "virtualenv:bundled-wheel", "value": f"src/virtualenv/seed/wheels/embed/{filename}"}, + ], + }) + dependencies[0]["dependsOn"].append(purl) + dependencies.append({"ref": purl, "dependsOn": []}) + return { + "bomFormat": "CycloneDX", + "specVersion": "1.6", + "version": 1, + "metadata": { + "component": { + "type": "application", + "name": name, + "version": version, + "purl": f"pkg:pypi/{name}@{version}", + "bom-ref": f"pkg:pypi/{name}@{version}", + }, + }, + "components": components, + "dependencies": dependencies, + } diff --git a/pyproject.toml b/pyproject.toml index ff34d9e67..b729a9d50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,8 @@ build-backend = "hatchling.build" requires = [ "hatch-vcs>=0.4", - "hatchling>=1.27", + "hatchling>=1.27,<1.28; python_version<'3.10'", + "hatchling>=1.28; python_version>='3.10'", ] [project] @@ -159,11 +160,13 @@ property = [ [tool.hatch] version.source = "vcs" build.hooks.vcs.version-file = "src/virtualenv/version.py" +build.hooks.custom.path = "hatch_build.py" build.targets.sdist.include = [ "/src", "/tasks", "/tests", "/tox.toml", + "/hatch_build.py", ] [tool.ruff]