Skip to content

🐛 fix(build): make the SBOM serial number deterministic and validate its structure in CI - #3268

Merged
gaborbernat merged 1 commit into
pypa:mainfrom
gaborbernat:sbom-serial-number
Sep 19, 2026
Merged

gaborbernat merged 1 commit into
pypa:mainfrom
gaborbernat:sbom-serial-number

Conversation

@gaborbernat

@gaborbernat gaborbernat commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

actions/attest's own CycloneDX detector checks for bomFormat, serialNumber, and specVersion together before it treats 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 rather than a valid document missing an optional field, and refuses to attest it: Error: Unsupported SBOM format. Must be valid SPDX or CycloneDX JSON.

Nothing in pull request CI catches this, since release.yaml only runs on a tag push, so this code path only ever executed for real inside the 21.8.0 release, which failed at the attestation step as a result.

A random uuid4 satisfies that check but makes every build's SBOM non-reproducible even from an identical source tree. Looking at how comparable projects handle this: auditwheel and maturin both hand-roll the same CycloneDX document shape virtualenv does and ship the identical missing-serialNumber gap in production today, while Microsoft's bocpy is the one real precedent that gets this right, deriving a uuid5 from stable build inputs so rebuilds are byte-identical. hatch_build.py now does the same, seeded from the package name, version, and the sorted bundled-wheel hashes. No CycloneDX library was adopted for this: nothing surveyed uses one for this exact case, since the object-model layer such libraries provide is built around resolving an installed dependency graph, and there is no such graph here, only one root component and a flat list of bundled, undeclared wheels.

The more consequential gap 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. I confirmed it both passes against a correct build and fails against a wheel with serialNumber stripped, reproducing the original bug exactly. 🧪

@gaborbernat gaborbernat changed the title 🐛 fix(build): add serialNumber to the generated SBOM 🐛 fix(build): make the SBOM serial number deterministic and validate its structure in CI Sep 19, 2026
@gaborbernat
gaborbernat force-pushed the sbom-serial-number branch 2 times, most recently from e01737e to a50b929 Compare September 19, 2026 15:44
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.
@gaborbernat
gaborbernat merged commit 1d28796 into pypa:main Sep 19, 2026
64 checks passed
gaborbernat added a commit that referenced this pull request Sep 19, 2026
The embedded SBOM's `metadata` block only carries the root `component`.
`auditwheel` and Microsoft's `bocpy`, which hand-roll the same CycloneDX
shape virtualenv does, both go further: `auditwheel` names itself in
`metadata.tools`, and `bocpy` adds `metadata.timestamp` plus a nested
`metadata.tools.components` entry for its own generator script. Neither
puts build-environment details like the OS or Python version anywhere in
the SBOM. That's the SLSA build provenance attestation's job, and the
release workflow already generates one; duplicating it here would blur
the line between what an SBOM describes (composition) and what an
attestation describes (the build process), on top of risking the same
reproducibility bug `serialNumber` had before #3268.

`hatch_build.py` now derives `metadata.timestamp` from hatchling's own
`get_reproducible_timestamp()`, the helper it already uses for the
wheel's zip entry timestamps, so setting `SOURCE_DATE_EPOCH` for a
reproducible build still produces a byte-identical SBOM. 🔁 I built the
wheel twice with a fixed `SOURCE_DATE_EPOCH` and diffed the resulting
`metadata.timestamp` and `serialNumber` values to confirm it.
`metadata.tools.components` names `hatch_build.py` as the generator with
no version number, since it has no version of its own and ships in
lockstep with virtualenv. `tasks/validate_sbom.py` checks both fields
the same way it checks `serialNumber`.
@gaborbernat gaborbernat added the security Fixes a vulnerability or hardens the supply chain, CI or release label Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug security Fixes a vulnerability or hardens the supply chain, CI or release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant