SBOMs: generate, store, query
SPDX, CycloneDX, and the zero-day drill.
Every boxed food has an ingredients label on the back. Peanuts, wheat, red dye number 40, listed by law so anyone with an allergy can check before they take a bite. Software has the same problem and, for most of its history, no label. One container image can pull in hundreds of open-source libraries, most of which you never chose on purpose, and any single one of them can turn out to be poison. A Software Bill of Materials (SBOM, an inventory of every component inside a piece of software, written so a machine can read it) is that ingredients label. It lists every package and version you ship: the ones you picked, and the ones those picks dragged in behind them.
The reason to care fits in one sentence. You can't check for a bad ingredient you don't know is in the box. When a vulnerability lands in some library buried four levels deep in your dependency tree, the only fast way to answer "are we shipping that?" is to have written the label down at build time and stored it somewhere you can search. An SBOM turns "what's in this image?" from a research project into a file you grep.
SPDX and CycloneDX, the two labels
Two formats dominate, and they're two languages saying the same thing. SPDX (Software Package Data Exchange, a format that started at the Linux Foundation and is now an ISO standard) grew up around open-source license compliance. CycloneDX (a format from OWASP, the Open Worldwide Application Security Project) grew up around security and carries vulnerability and exploitability data more naturally. Both are usually written as JSON (JavaScript Object Notation, a plain-text data format that tools can parse). Pick one and stay consistent, because most scanners read either. The piece that makes both useful is the purl (package URL, a standard string like pkg:deb/debian/[email protected] that names a package with no ambiguity about ecosystem, name, and version). When a CVE advisory says "affects this purl," your query is exact instead of a guess.
Generate at build time
Write the label while you're cooking, not by squinting at the finished dish. Build time is when you have the most context: the source tree, the lockfiles (files that pin the exact version of every dependency, like package-lock.json or go.sum), and the final image, all in one place. The common tool is Syft, an open-source SBOM generator from Anchore. Pin the image by its digest (its content hash, the SHA-256 fingerprint of the exact bytes) so the label describes precisely what you built and not whatever a moving tag points at later.
# pin by digest so the SBOM matches exactly what you builtIMAGE=ghcr.io/acme/checkoutDIGEST=sha256:9b2a3e0d5c41e...c41e# one pass, write both formats to disksyft "$IMAGE@$DIGEST" \-o spdx-json=sbom.spdx.json \-o cyclonedx-json=sbom.cdx.json
That ran once and produced two files. Now peek inside one, because an SBOM you never open is a file you're trusting on faith. Count the components, then pull a single entry to see the shape of the data.
# how many components, and what does one entry look like?jq '.components | length' sbom.cdx.jsonjq '.components[] | select(.name=="openssl") | {name, version, purl}' sbom.cdx.json
The purl is the payoff of that peek. It says, with no wiggle room, exactly which OpenSSL this is: the Debian 12 build of version 3.0.13 for amd64. That single string is what lets a query on Monday match an advisory published on Friday.
Store it so the label travels with the box
A label in a filing cabinet in another building does you no good when you're standing there holding the box. So staple it to the box and seal it with a tamper-evident sticker. Technically: attach the SBOM to the image in the registry as a signed attestation. An attestation is a signed statement about an artifact ("this SBOM belongs to this image"). Signing it with Cosign (a tool from the Sigstore project) makes it tamper-evident, so if anyone edits one byte of the SBOM afterward, the signature stops matching and verification fails. Now the label moves with the image everywhere it's pulled, and you can prove it's the one you generated.
# attach the SBOM to the image as a signed attestationcosign attest --yes \--key cosign.key \--predicate sbom.spdx.json \--type spdxjson \"$IMAGE@$DIGEST"
That last line is Rekor, Sigstore's public transparency log (an append-only ledger that records the fact that this signature happened, so it can't be quietly backdated or later denied). With the label attached and signed, you can pull it back out of the registry from anywhere and check the seal in the same step.
# verify the signature, then extract the SBOM back out of the registrycosign verify-attestation \--key cosign.pub \--type spdxjson \"$IMAGE@$DIGEST" \| jq -r '.payload | @base64d | fromjson | .predicate' \> sbom.pulled.json
Query without rebuilding
Here's the whole reason you wrote the label down: you can now answer questions about the software without touching the software. Scan the stored SBOM against today's vulnerability data with no re-pull and no rebuild. Grype (Anchore's scanner) reads an SBOM directly and matches every component against known CVEs (Common Vulnerabilities and Exposures, the public catalog of disclosed security flaws).
grype sbom:sbom.pulled.json --fail-on high
Read the columns and you get a work order: the package, the version you're running, the version that fixes it, and the severity. The zlib row is the interesting one. It's a genuine critical (an integer overflow in zlib's MiniZip code), but Debian marks it "won't fix" because their build doesn't compile the affected part, so it isn't reachable in this image. That kind of detail is exactly why you scan the real inventory instead of eyeballing version numbers. And because you passed --fail-on high, Grype exits with status 1 here, which lets a CI job (continuous integration, the automated pipeline that builds and tests every change) block the build before a high-severity image ever ships.
The zero-day drill
This is the day the whole practice pays for itself. In December 2021 a critical bug (CVE-2021-44228, nicknamed Log4Shell) turned up in Log4j, a Java logging library that runs almost everywhere. A crafted string dropped into a log line let an attacker run their own code on your server. Within hours the entire internet was being scanned for it. Every security team had to answer one question, fast: which of our things run a vulnerable Log4j, and where exactly? With stored SBOMs, that answer is one loop over your inventory.
# list every stored SBOM that ships log4j-core, and at what versionfor f in inventory/*.cdx.json; dojq -r --arg img "$(basename "$f" .cdx.json)" '.components[]| select(.name=="log4j-core")| "\($img)\t\(.version)"' "$f"done
Three services carry Log4j. Two are on 2.14.1 and vulnerable, one is already on 2.17.1 and patched. In the time it takes to run a shell loop you have a patch list and a blast radius, before the attacker finishes their first scan of your address space. Without SBOMs, the same answer means days of pulling images, unpacking layers, and grepping for jar files (the zip archives that bundle Java classes) while the incident channel fills up. The SBOM is insurance you pay for on every build and cash in on the worst day of the quarter.
The same query catches supply-chain attacks as well as ordinary CVEs. When a specific package version is disclosed as malicious, say a hijacked release on npm (the package registry for JavaScript) from a stolen maintainer account, or a typosquat (a package named one typo away from a popular one) that shipped a backdoor, you don't sit there wondering whether you pulled it. You search your SBOMs for that exact name and version, and get a yes or no in seconds across everything you run.
So make the practice real, not decorative. Generate on every build and regenerate on every rebuild so the label never describes an old box. Sign each one so you can trust it later. Store them where you can search across all of them at once, not in a bucket nobody opens. Then run the drill: pick a CVE from the last month, and time how long it takes you to list every affected image. If that number is measured in days, your SBOM program isn't real yet, and you'll find out the hard way the next time the internet starts scanning.
Try this
Run jq '.components | length' sbom.cdx.json on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: verify the seal before you trust the label. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.