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

syft against the published image

apt/deb, PyPI, cargo, maven, npm, Go

breadth — thousands of packages

Curated

scripts/generate_sbom.py from deps.json + versions.env

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.0 that versions.env pinned then. That was nobody’s decision: until 2026-09-09 scan-image-sbom.sh used whatever syft command -v found 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.1 by 2026-09-12, v1.52.0 since 2026-09-18, v1.54.1 since 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 → GPLv3

    62.12.102, 62.28.102, 7:8.0.1-3ubuntu2, licence NOASSERTION

    GStreamer 1.29.2 from source

    gstreamer1.0 1.28.2-1 — the Ubuntu package

    GCC 16.2.0 from source

    16.2.0, plus Ubuntu’s 15.2.0, licence NOASSERTION

    OpenCV, 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 .dll/.exe — “Microsoft® Windows Repair Disc”, “JP Japanese Keyboard Layout for NEC PC-9800”, “ApiSet Stub DLL”. Operating-system files, not dependencies you chose

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.env then pinned v1.29.0, v0.26.0 and n9.0. The published :winamd64 image was behind its pins — which means the curated SBOM and the licence pages, both generated from versions.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 .so copied into /opt during 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.