Software Bill of Materials (SBOM)¶
A Software Bill of Materials is a machine-readable inventory of every dependency
that ships in (or is resolved for) the framework. It lets a consumer answer "am I
affected by CVE-X?" without guessing, and is increasingly expected of any software
that processes untrusted enterprise data (see docs/THREAT-MODEL.md
→ Boundary 6 — Supply chain).
The single source of truth for versions is the committed uv.lock (the uv
workspace lockfile). The SBOM is derived from it, so it always matches what a
uv sync installs — it is never hand-maintained.
Generating an SBOM¶
bash scripts/generate-sbom.sh # writes sbom.cdx.json (CycloneDX JSON)
bash scripts/generate-sbom.sh out.json # custom output path
The script (scripts/generate-sbom.sh):
- runs
uv export --frozen --all-packagesto resolveuv.lockinto a fully-pinned, hashed requirements list covering every workspace package, not just the root dev project, then - checks that the export really covers every package in
packages/— and fails closed if it does not, before any SBOM is written (see Scope below), then - runs
cyclonedx-py requirements(fromcyclonedx-bom) to emit a CycloneDX 1.6 SBOM in JSON.
Scope — why --all-packages is load-bearing¶
The root latence-framework-dev project deliberately does not depend on the heavy
Provider packages (ADR-0016, thin core). So a plain uv export resolves only that
meta-project and yields a requirements list covering a fraction of what a release ships:
on this tree, 12 of the 32 distributions, with torch, transformers, gliner,
openai, presidio-analyzer, sentence-transformers, peft, huggingface-hub and
others absent — even though they are required runtime dependencies of released
packages. An adopter scanning that document with grype or Dependency-Track would get a
clean report for a CVE they are in fact exposed to.
--all-packages fixes the scope; the coverage check makes the omission impossible to
reintroduce silently. uv export --no-emit-project leaves one ./packages/<name> path
requirement per resolved member, so the script compares that set against
packages/*/pyproject.toml and aborts on any shortfall.
The coverage check is enforced twice, at two different levels, and that redundancy is deliberate:
| where | what it reads | what it catches |
|---|---|---|
scripts/generate-sbom.sh |
the intermediate requirements export | a wrong resolution scope (a dropped --all-packages, a member missing from the workspace globs) |
scripts/release-integrity.sh |
the final CycloneDX document, before stamping | everything the first check cannot see |
The second one is the load-bearing guard, because generate-sbom.sh is not on every path to
a signed release. release-integrity.sh --sbom PATH accepts a caller-supplied document and
skips the generator entirely — a stale cache, a partial regeneration or a hand-edited file
would otherwise be stamped latence-framework <version>, digest-recorded in SHA256SUMS
and Sigstore-signed without anything having compared it against the distributions actually
being published. release-integrity.sh is the choke point every release SBOM passes
through, so the check runs there on the exact bytes that get signed: it reads back the
./packages/<name> path components, compares them against packages/*/pyproject.toml, and
fails closed — naming the missing packages — before SHA256SUMS is written.
Both tools run via uv run --with … --no-project, so nothing is added to the
workspace's own dependency set.
Where it fits (and where it deliberately does not)¶
- Not a CI merge gate. The script is intentionally not wired into
.github/workflows/ci.ymlorscripts/verify-local.sh. Generating an SBOM does not gate a merge; it is a release-time artifact. - Release usage — automatic.
.github/workflows/release.ymlrunsscripts/release-integrity.shon everyv*tag. That script callsscripts/generate-sbom.sh, stamps the resulting document with the release identity (metadata.component=latence-frameworkat the tag's version, pluslatence:release:tag/latence:release:repositoryproperties), and attaches it to the GitHub Release assbom.cdx.jsonalongside the wheels. The SBOM's digest is recorded in the release'sSHA256SUMS, which is signed with keyless Sigstore — so the SBOM is not just published, it is authenticated. SeeRELEASING.md→ Release integrity. - Fail-closed. A document that is not CycloneDX, has no
specVersion, or lists no components is rejected rather than attached. An empty SBOM is worse than none: it looks like evidence while answering nothing. - Format. CycloneDX JSON is emitted because it is the OWASP-standard,
tool-friendly format (consumable by
grype, Dependency-Track, GitHub's dependency graph, etc.). Add SPDX output later if a consumer needs it.
Related supply-chain controls¶
- Dependabot (
.github/dependabot.yml) — weeklypip+github-actionsupdates, grouped minor/patch; surfaces dependency advisories. - CodeQL (
.github/workflows/codeql.yml) — SAST over the Python sources. - OpenSSF Scorecard (
.github/workflows/scorecard.yml) — supply-chain posture scoring. - detect-secrets gate —
scripts/secret_scan_gate.py, run in CI andscripts/verify-local.sh.
Dependency-CVE gating (hard-failing a build on a known-vulnerable dependency)
remains a Phase-2 hardening item, tracked in SECURITY.md. The SBOM is the input
that makes such a gate possible.