🐛 fix(build): make the SBOM serial number deterministic and validate its structure in CI - #3268
Merged
Merged
Conversation
gaborbernat
force-pushed
the
sbom-serial-number
branch
from
September 19, 2026 15:38
5e5ceb0 to
edebeb7
Compare
gaborbernat
force-pushed
the
sbom-serial-number
branch
2 times, most recently
from
September 19, 2026 15:44
e01737e to
a50b929
Compare
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
force-pushed
the
sbom-serial-number
branch
from
September 19, 2026 15:46
a50b929 to
fbda49d
Compare
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`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
actions/attest's own CycloneDX detector checks forbomFormat,serialNumber, andspecVersiontogether before it treats a file as CycloneDX at all. 🔐serialNumberis 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.yamlonly 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
uuid4satisfies that check but makes every build's SBOM non-reproducible even from an identical source tree. Looking at how comparable projects handle this:auditwheelandmaturinboth hand-roll the same CycloneDX document shape virtualenv does and ship the identical missing-serialNumbergap in production today, while Microsoft'sbocpyis the one real precedent that gets this right, deriving auuid5from stable build inputs so rebuilds are byte-identical.hatch_build.pynow 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.pyadds a small, stdlib-only check of the exact invariantsactions/attest's format sniffer cares about, wired into the existingtox -e readmeenvironment 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 withserialNumberstripped, reproducing the original bug exactly. 🧪