CoursesSoftware supply chain securitySBOMs: generate, store, query

SBOMs: generate, store, query

SPDX, CycloneDX, and the zero-day drill.

Advanced12 min · lesson 11 of 18

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.

terminal
# pin by digest so the SBOM matches exactly what you built
IMAGE=ghcr.io/acme/checkout
DIGEST=sha256:9b2a3e0d5c41e...c41e
# one pass, write both formats to disk
syft "$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.

terminal
# how many components, and what does one entry look like?
jq '.components | length' sbom.cdx.json
jq '.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.

terminal
# attach the SBOM to the image as a signed attestation
cosign 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.

terminal
# verify the signature, then extract the SBOM back out of the registry
cosign verify-attestation \
--key cosign.pub \
--type spdxjson \
"$IMAGE@$DIGEST" \
| jq -r '.payload | @base64d | fromjson | .predicate' \
> sbom.pulled.json
Verify the seal before you trust the label
An unsigned SBOM pulled from a registry is a note that an attacker could have swapped for a friendlier one. Always run cosign verify-attestation before you query the SBOM to decide "are we affected." A forged clean SBOM is worse than no SBOM at all, because it tells you you're safe while you're not.

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).

terminal
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.

terminal
# list every stored SBOM that ships log4j-core, and at what version
for f in inventory/*.cdx.json; do
jq -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.

The lifecycle of a queryable SBOM
1Generate
syft at build, pinned by image digest
2Sign & attest
cosign attest, tamper-evident
3Store
in the registry plus a searchable inventory
4Query
grype / jq, no re-pull or rebuild
5Zero-day drill
affected images listed in minutes
An SBOM is only as honest as its cataloguer
Syft lists what it knows how to parse: OS packages, language lockfiles, common archive formats. A binary you curled into the image, a C library compiled straight into another program (statically linked, so there's no package record to find), or a shaded jar (dependencies repacked and renamed inside another jar) can be present and still invisible to the label. If your SBOM says "no log4j" but someone fat-jarred it into a service, you'll trust a label that lied. Generate from build context where you can, spot-check against what you know is in there, and treat "not in the SBOM" as "not proven absent," never as "proven safe."
Quick check
01You are hunting Log4Shell. Your loop over inventory/*.cdx.json prints no rows for a Java service, and cosign verify-attestation passed on that service's SBOM. What can you honestly say?
Incorrect — Signing protects the file from edits after the fact. It says nothing about what the cataloguer managed to see when the label was written.
Correct — An empty result means undetected. Repacked jars and binaries curled straight into the image leave no package record for Syft to read.
Incorrect — Syft does read language lockfiles and common archive formats, so ordinary jars normally appear. The blind spot is code hidden inside another artifact.
Incorrect — Verification checks the seal, not the coverage. A genuinely empty answer is a real outcome here, not evidence that your filter broke.
02Two teams describe what turns out to be the same library, one calling it openssl 3.0.13 and the other libssl3. How does the purl in each SBOM entry settle the argument?
Incorrect — Nothing rewrites your entries during a scan. The purl works because both sides already wrote the identifier the same way before anyone scanned.
Incorrect — Content hashes are what a digest gives you for an image. A purl answers a different question: which package, from which ecosystem, at which version.
Incorrect — Fix status lives in the vulnerability data Grype matches against, like the zlib row Debian declines to patch. The purl only identifies the package.
Correct — That precision is the payoff. Your query and a later advisory agree on the identifier, so matching becomes a lookup rather than a debate about names.
03grype sbom:sbom.pulled.json --fail-on high flags zlib as CRITICAL, and the notes say Debian will not fix it because the affected MiniZip code is not compiled into their build. How do you act on that row?
Correct — The version genuinely matches, but the vulnerable path was never built into this image, so the risk sits on paper instead of in production.
Incorrect — Severity ranks how bad the flaw is once you can reach it. The exit code tells you to look at the row, it does not settle whether the code runs.
Incorrect — Trimming the inventory to make a scan pass turns your label into fiction. Note the reasoning on the finding instead so the next person can follow it.
Incorrect — A version match plus an advisory is where the work starts, not where it ends. Debian's build detail is the fact that changes the answer here.

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.

Related