<!--
Copyright (c) 2025 Kataglyphis
SPDX-License-Identifier: MIT
-->

# 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`](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](third-party-licenses.md#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`](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](#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

```bash
# 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`](../.github/workflows/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:

```bash
grype sbom:out/sbom/scanned-linux-amd64.spdx.json -o json --file out/sbom/vulns-linux-amd64.json
```

**Read [`vulnerability-scanning.md`](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

```bash
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](https://dependencytrack.org/) 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.
