SBOM — generating it, and what it is actually for#
A Software Bill of Materials is only worth the work if something consumes it. This page covers both halves of this project’s SBOM, how to produce them, and — the part usually left out — what you can do with the result.
Licence obligations are a different question and live in
third-party-licenses.md. The SBOM tells you what
is in the image; that page tells you what each licence requires of you, and
its Maintaining this list
section is the procedure for adding a component to the curated half.
Why there are two halves#
Half |
Produced by |
Covers |
Strength |
|---|---|---|---|
Scanner |
|
apt/deb, PyPI, cargo, maven, npm, Go |
breadth — thousands of packages |
Curated |
|
the source-built stack |
correctness — right version, right licence, source pointer |
Neither is sufficient alone, and the reason is measurable rather than
theoretical. From a real scan of :latest-cross (today’s :latest;
linux/amd64, syft 1.51.0, 2026-08-25):
Which syft produced the numbers on this page, and why it is not the pinned one. Every figure below, and in the Windows section under it, comes from syft 1.51.0 — not from the
SYFT_VERSION=v1.20.0thatversions.envpinned then. That was nobody’s decision: until 2026-09-09scan-image-sbom.shused whateversyftcommand -vfound on PATH and bootstrapped the pin only when there was none, so these counts were measured by whichever scanner the workstation happened to have — thirty-one minor releases past the pin, on a page whose own Generating them section calls the scanner pinned. The script now refuses a syft that is not the pinned version, from PATH or from a stale bootstrap cache, which is what makes a re-run reproducible at all. The pin itself has moved on:v1.51.1by 2026-09-12,v1.52.0since 2026-09-18,v1.54.1since 2026-10-07. Until someone re-measures under the pin — it needs the published image and a network — read the numbers below as dated evidence for the SHAPE of the two halves (thousands of packages, most carrying no declared licence, every copyleft component reported as the distro copy or not at all) rather than as figures a pinned run will reproduce. Replace the numbers and this note together.
4,112 packages catalogued (2,202 distinct names) — maven 1,340, deb 1,255, cargo 1,072, pypi 228, npm 149, go 13. No human maintains that by hand.
73 % of them carry no declared licence, and 94 % no concluded licence.
For the components that actually carry a copyleft obligation, the scan reports the distro copy at a different version, or nothing at all:
Built and shipped here
What the scan reports
FFmpeg
n9.0,--enable-gpl --enable-version3→ GPLv362.12.102,62.28.102,7:8.0.1-3ubuntu2, licenceNOASSERTIONGStreamer
1.29.2from sourcegstreamer1.0 1.28.2-1— the Ubuntu packageGCC
16.2.0from source16.2.0, plus Ubuntu’s15.2.0, licenceNOASSERTIONOpenCV, TVM, Abseil, VVdeC
not found at all
The Windows image behaves differently again#
Scanning :winamd64 (syft 1.51.0, 2026-08-25 — the unpinned scanner the note
above describes) returns 26,253 packages — six times the Linux count, and
far noisier:
Ecosystem |
Count |
What it really is |
|---|---|---|
no-purl |
14,014 |
PE version resources read out of every |
nuget |
10,201 |
MSVC/Build Tools package graph |
cargo |
1,554 |
Rust crates |
pypi |
449 |
wheels |
98.5 % carry no declared licence. Treat the Windows scan as raw evidence rather than an inventory: it needs filtering before it means anything, and the package count is not a quality signal.
It also caught a real drift. The scan reports ONNX Runtime 1.27.0, TVM 0.25.0 and FFmpeg 8.0.git, while
versions.envthen pinned v1.29.0, v0.26.0 and n9.0. The published:winamd64image was behind its pins — which means the curated SBOM and the licence pages, both generated fromversions.env, describe what the pins say rather than what was last published. Re-publish the image, or read the curated documents as describing the next build rather than the current tag.
So a source-offer question cannot be answered from the scanner SBOM. That is what the curated half exists for: it carries the exact upstream revision, the build flags that determine the licence, and the corresponding-source pointer.
Generating them#
# Curated half — offline, no image needed, byte-reproducible
python3 docs/scripts/generate_sbom.py --write # -> docs/deps/sbom-curated.spdx.json
python3 docs/scripts/generate_sbom.py --check # gated in preflight as slug `sbom`
# Scanner half — reads straight from the registry, no daemon, no local
# build. ONE driver: it bootstraps syft at tool-pins.env's SYFT_VERSION (a syft
# on PATH is used only when it IS that version, and any other one — from PATH
# or from a stale bootstrap cache — is refused rather than silently used),
# scans, refuses a scan that catalogued under 50 packages, then runs the
# comparison below. The image argument is
# optional — omitted, it is composed from IMAGE_REGISTRY_PREFIX +
# CI_IMAGE_LINUX_TAG in versions.env, so a local scan and every CI lane target
# the same tag by construction.
bash linux/scripts/scan-image-sbom.sh linux/amd64
# -> out/sbom/scanned-linux-amd64.{spdx,cdx}.json
# What each half sees, and what only the curated one covers. The driver runs
# this itself; this is the standalone form.
python3 docs/scripts/compare_sbom.py out/sbom/scanned-linux-amd64.spdx.json
:latest is a manifest list — a scan without --platform silently
picks one architecture. Scan each one you publish.
Budget memory, not just disk. On :latest amd64 (2026-09-30) syft’s
index of the image’s files reached 11 GB before the first cataloguer ran. The
scan peaked at 31 GB with runner-sized parallelism, and at 24 GB unconstrained.
A 16 GB machine is OOM-killed; on a GitHub runner that surfaces as “The runner
has received a shutdown signal”, not as an error from syft. SYFT_PARALLELISM
and GOMEMLIMIT do not help, because the index is live heap. Swap does: 15 GB
of RAM plus 16 GB of swap finished in 16 minutes with the same 4056 packages.
That is what sbom.yml gives each scan job.
CI runs the scan weekly in sbom.yml — the
same scan-image-sbom.sh invocation, once per published platform — and
verifies the curated document has not drifted from deps.json.
What you can actually do with it#
1. Find vulnerabilities without rebuilding#
The immediate payoff. grype reads an SBOM directly, so a published image can
be re-checked against today’s CVE feed in seconds instead of a multi-gigabyte
pull — which is what makes it viable on a schedule:
grype sbom:out/sbom/scanned-linux-amd64.spdx.json -o json --file out/sbom/vulns-linux-amd64.json
Read vulnerability-scanning.md before acting on
the output. On these images the raw count is close to meaningless: 98.5 % of
the Linux matches are deb packages that grype could not version-match because
Ubuntu 26.04 is not in its database, and 87 % of the Windows matches are CPE
guesses against DLL version resources. That page has the filter that extracts
the real findings, a measured baseline to compare against, and why
--fail-on high is the wrong gate here.
2. Answer a customer or procurement request#
“Send us your SBOM” is now a file, not a project. Give them both documents: the scanner half for breadth, the curated half because it is the one with correct licences. SPDX and CycloneDX are both emitted — ask which they want rather than guessing; tooling on their side usually accepts only one.
3. Diff two releases#
python3 - <<'PY'
import json
def names(p): return {x["name"]: x.get("versionInfo","") for x in json.load(open(p))["packages"]}
a, b = names("sbom-old.spdx.json"), names("sbom-new.spdx.json")
print("added: ", sorted(set(b) - set(a))[:20])
print("removed:", sorted(set(a) - set(b))[:20])
print("changed:", [k for k in a.keys() & b.keys() if a[k] != b[k]][:20])
PY
A dependency that appeared without anyone deciding to add it is exactly what this catches — and on this chain, a new transitive crate or wheel arrives without a commit that mentions it.
4. Gate on policy#
Feed the SBOM to a policy engine and fail a release on rules you choose:
no new copyleft in a shipped layer, no package above a CVE severity, no
component without a licence. grype --fail-on, Anchore policy bundles and
OSS Review Toolkit all read these formats.
5. Track it over time#
Dependency-Track ingests CycloneDX and keeps a running inventory across releases, alerting when a previously shipped version becomes vulnerable. That is the case a point-in-time scan cannot cover: the image did not change, the world did.
6. Regulatory readiness#
The EU Cyber Resilience Act obliges manufacturers of products with digital elements to maintain an SBOM covering at least the top-level dependencies. Having one generated and gated is materially easier than producing one later under a deadline. This is context, not legal advice.
What it does not tell you#
Whether you may redistribute a component. The proprietary entries (CUDA, Visual Studio Build Tools, Windows Server Core) carry vendor EULAs; an SBOM records their presence, not your right to ship them.
Whether a CVE is exploitable in your usage. A hit is a prompt to look, not a verdict. VEX is the vocabulary for recording that judgement.
Complete licence coverage from the scan alone — 73 % of scanned packages declare no licence at all. Treat the scanner half as an inventory and the curated half as the licence record.
Whether the scan and the curated half describe the SAME artifact. The curated document is generated from
versions.env, so it describes the pins. The scan describes a published tag, which may have been built earlier — as of 2026-08-25 the Windows image was behind on three components. Compare the two before quoting either as current.Anything about components with no metadata. A
.socopied into/optduring a source build is invisible to any scanner. That gap is precisely what the curated half fills, and why adding a copyleft component without a source pointer fails the gate.