Software Bills of Materials (SBOMs)
Why this matters
Module 00 named the first of the three questions an image must answer for
itself: what is in it? Track 11 module 01 already told you a scanner builds
an SBOM under the hood and let you dump one with trivy image --format cyclonedx — but there it was a throwaway byproduct of scanning. Here the SBOM
is the product. A first-class SBOM is the thing you'll later sign, attach to
the image, and hand to admission control; it's also what turns a future
"is-package-X-in-any-of-our-images?" incident question (module 07) from a
frantic rebuild-and-grep into a fast query against artifacts you already have.
This module makes the SBOM a real, durable artifact, generated the way a
supply-chain pipeline generates it.
Concepts
What an SBOM actually contains
An SBOM is a structured inventory of every component in an artifact. For a container image, that means, per component:
- Name and version —
openssl 3.0.11,express 4.18.2. - Type/ecosystem — OS package (apk/deb/rpm), language library (npm, pip, Go module, Maven), or the image itself.
- A unique identifier — a PURL (package URL, e.g.
pkg:npm/express@4.18.2) and/or a CPE, so tools can match it against vulnerability databases unambiguously (this is the join key module 04's continuous scanning relies on). - Location/provenance hints — which layer or file it came from.
- Often licenses and checksums for each component.
That's the "what's in the box" list module 00 promised, made machine-readable. Note what it is not: an SBOM is not a vulnerability report. It lists components; a scanner joins that list against CVE feeds. Same split track 11 drew — the SBOM is the noun, the scan is the verb.
The two dominant formats: SPDX and CycloneDX
You'll meet two standards at a survey level; you don't need to author either by hand, but you must recognize them and know when each is used:
- SPDX (Software Package Data Exchange) — an ISO standard (ISO/IEC 5962),
originally license-compliance-focused, now general-purpose. It's the format
GitHub,
docker buildx, and much of the Linux Foundation world default to. Verbose, relationship-rich. - CycloneDX — an OWASP standard, security-first from the start, compact, strong at expressing vulnerability and dependency relationships. Widely used in security tooling (Trivy defaults to it, Dependency-Track consumes it).
They express the same core idea (a component inventory with identifiers); they differ in schema, verbosity, and lineage. The practical rule: produce whichever format the consumer expects. Your admission controller, your scanner, and your registry may each prefer one — good SBOM tools convert between them, and you'll do that conversion in the exercises. Don't religious-war this; treat format as an interop detail.
Syft: a dedicated SBOM generator
Track 11 used Trivy, which also emits SBOMs. This track adds Syft (from Anchore) — a tool whose single job is SBOM generation, which makes it the natural fit for a pipeline where the SBOM is a first-class deliverable rather than a scan side-effect. Key properties, echoing why track 11 liked Trivy:
- Single binary, no account, runs locally — practice for real on this machine.
- Many catalogers — it inventories OS packages and language dependencies across dozens of ecosystems, and can scan an image, a directory, or an archive.
- Multi-format output — SPDX (JSON/tag-value), CycloneDX (JSON/XML), and its own native format, selectable with one flag. This is what makes the "produce whichever the consumer wants" rule practical.
Syft pairs with Grype (its sibling scanner) exactly the way an SBOM pairs
with a scan: syft produces the inventory, grype consumes it to find CVEs.
You'll use that pairing in module 04.
Generating the SBOM at the right moment (and pinning by digest)
When you generate the SBOM matters as much as that you do. Two rules:
- Generate it in CI, against the exact image you're about to ship, right
after build — the same "shift left, in the pipeline" timing track 11 module
01 drilled for scanning, and the same slot you'll later add signing (module
- and provenance (module 03). An SBOM generated later, by hand, against a re-pulled tag, can drift from what actually shipped — the "stale SBOM" failure you'll deliberately reproduce below.
- Bind the SBOM to the image's digest, not its tag. Module 00's
digest-vs-tag point applies directly: an SBOM is only trustworthy if it's tied
to the immutable
sha256:content it describes. Attach-by-digest is how module 03 makes the binding cryptographic; here you at least record the digest the SBOM was generated against.
What an SBOM buys you later (why this isn't busywork)
An SBOM sitting in a drawer is useless; an SBOM wired into your pipeline pays off three times over, each in a later module:
- Attestation (module 03): you sign the SBOM and attach it to the image, so a consumer can verify this inventory really describes this image and came from us.
- Continuous scanning (module 04): when a new CVE lands tomorrow, you re-scan the stored SBOMs of every image instead of re-pulling and re-analyzing every image — much faster, and it works even for images no longer in a running registry.
- Incident blast-radius (module 07): when
xz-utilsis announced vulnerable, "which of our 400 images contain it, at what version?" is a query over your SBOM store, answerable in minutes instead of a fleet-wide rebuild.
That third one is the payoff that makes SBOMs worth the pipeline cost — you're pre-computing the answer to the worst question you'll ever be asked under pressure.
Command reference
| Command | What it does | Example |
|---|---|---|
syft <image> |
Generates an SBOM, printing a component table | syft python:3.12-slim |
syft <image> -o spdx-json |
Emits SPDX in JSON | syft myapp:1.0 -o spdx-json |
syft <image> -o cyclonedx-json |
Emits CycloneDX in JSON | syft myapp:1.0 -o cyclonedx-json |
syft <image> -o spdx-json=sbom.spdx.json |
Writes SBOM to a file (format=path form) | syft myapp:1.0 -o spdx-json=sbom.spdx.json |
syft <dir-or-archive> |
SBOMs a filesystem/source tree instead of an image | syft dir:. |
syft scan <ref>@sha256:<digest> |
SBOMs a specific immutable digest | syft myreg.azurecr.io/app@sha256:abc... |
syft convert <sbom> -o cyclonedx-json |
Converts an existing SBOM between formats | syft convert sbom.spdx.json -o cyclonedx-json |
docker buildx build --sbom=true ... |
Has BuildKit generate and attach an SBOM at build time | see exercise 7 |
trivy image --format spdx-json -o s.json <image> |
Trivy's SBOM output, for comparison with Syft | trivy image --format spdx-json -o s.json myapp:1.0 |
Flag breakdown for syft python:3.12-slim -o spdx-json=sbom.spdx.json --scope all-layers:
- (subject)
python:3.12-slim— the scan target. Can be an image ref, adir:path, or an archive; preferimage@sha256:<digest>when the binding to exact content matters. -o spdx-json=sbom.spdx.json— output selector informat=pathform:spdx-jsonpicks the SPDX-in-JSON format; the part after=is the file to write. Omit the=pathto print to stdout. You can pass-omultiple times to emit several formats at once.--scope all-layers— catalog packages in every layer, including files deleted in later layers (defaultsquashedsees only the final filesystem).all-layerscatches things that were installed then removed but may still matter for provenance.
Flag breakdown for docker buildx build --sbom=true --provenance=mode=max -t app:1.0 --push .:
--sbom=true— BuildKit generates an SPDX SBOM during the build and attaches it to the image as an OCI attestation (no separate Syft step).--provenance=mode=max— also attach SLSA provenance (module 03's topic); shown here only so you see SBOM and provenance are attached the same way.--push— attestations live in the registry alongside the image manifest, so attaching only makes sense when pushing.
Hands-on exercises
Syft runs locally and for real, like Trivy in track 11.
(WSL2) Install Syft (and Grype for later).
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin syft version && grype versionExpect version strings for both. (If the install script is blocked, a
brew install syft grypein WSL works too.)(WSL2) Generate your first real SBOM and read it.
syft python:3.12-slimExpect a table of components: OS packages (deb) and the Python packages present, each with a name, version, and type. Note the count — this is the full "what's in the box" list module 00 had you build by hand.
(WSL2) Emit both standard formats and eyeball the difference.
syft python:3.12-slim -o spdx-json=sbom.spdx.json syft python:3.12-slim -o cyclonedx-json=sbom.cdx.json grep -o '"name"' sbom.spdx.json | wc -l grep -o '"purl"' sbom.cdx.json | wc -lOpen both briefly. Same components, different schema (SPDX's
packages/relationshipsvs CycloneDX'scomponents). This is the format-is-interop-detail lesson made concrete.(WSL2) Convert between formats without regenerating.
syft convert sbom.spdx.json -o cyclonedx-json=converted.cdx.json diff <(grep -o '"name":"[^"]*"' sbom.cdx.json | sort) \ <(grep -o '"name":"[^"]*"' converted.cdx.json | sort) | headExpect the component set to match (minor field differences are fine). This is why you don't fight over formats — one SBOM converts to whatever a consumer wants.
(WSL2) SBOM your own image, pinned by digest. Build a tiny image (reuse any Dockerfile from track 02, or the one below), then SBOM the exact digest:
printf 'FROM python:3.12-slim\nRUN pip install requests==2.32.3\n' > Dockerfile docker build -t sbom-lab:1.0 . DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' sbom-lab:1.0 2>/dev/null) syft sbom-lab:1.0 -o spdx-json=app-sbom.json grep -o '"name":"requests"' app-sbom.jsonExpect
requeststo appear in the SBOM — your added dependency is now inventoried. (IfRepoDigestsis empty because you never pushed, that's expected locally; module 03 pins by digest against a real registry.)(WSL2) Feed the SBOM to a scanner — the noun/verb split, for real.
grype sbom:app-sbom.jsonExpect Grype to report CVEs derived from the SBOM you already made, without re-analyzing the image. This is exactly module 04's continuous-scanning pattern in miniature: scan the stored inventory, not the image.
(WSL2, optional) Let BuildKit attach the SBOM at build time. If you have
docker buildx:docker buildx build --sbom=true -t sbom-lab:2.0 --load . docker buildx imagetools inspect sbom-lab:2.0 --format '{{json .SBOM}}' 2>/dev/null | head -c 300; echoExpect an SBOM attestation to be attached to the image itself — the SBOM travels with the image, previewing module 03's attach-to-image model.
Diagnose and fix: a stale SBOM. Reproduce the classic supply-chain footgun — an SBOM that no longer matches the image it claims to describe:
# Generate an SBOM against the current image syft sbom-lab:1.0 -o spdx-json=stale-sbom.json grep -c '"name":"flask"' stale-sbom.json # expect 0 — flask isn't installed # Now change the image WITHOUT regenerating the SBOM printf 'FROM python:3.12-slim\nRUN pip install requests==2.32.3 flask==3.0.3\n' > Dockerfile docker build -t sbom-lab:1.0 .The image now contains Flask, but
stale-sbom.jsonstill doesn't mention it. Prove the drift and fix it:syft sbom-lab:1.0 -o spdx-json=fresh-sbom.json grep -c '"name":"flask"' stale-sbom.json # 0 — the lie grep -c '"name":"flask"' fresh-sbom.json # >=1 — the truthDiagnose: the SBOM was generated before the image changed, so it under-reports contents — a scanner trusting it would miss Flask's CVEs entirely. The fix is process, not a flag: generate the SBOM in the same CI step that produces the final image, bound to its digest, so an SBOM can never describe a different build than the one that shipped (module 03 makes this binding cryptographic).
(WSL2) Clean up.
rm -f sbom.spdx.json sbom.cdx.json converted.cdx.json app-sbom.json stale-sbom.json fresh-sbom.json Dockerfile docker rmi sbom-lab:1.0 sbom-lab:2.0 2>/dev/null; true
Independent challenge
No commands given — build it using this module plus the CI knowledge from track
10 and the digest-vs-tag reasoning from module 00. Take one of your own images
and produce a repeatable SBOM step: describe (in prose or as a real workflow
snippet) where in a build-scan-push pipeline the SBOM generation belongs so it
can never go stale, which format you'd emit and why given a named downstream
consumer, and how you'd bind the SBOM to the image's digest rather than its tag.
Then demonstrate the payoff: generate the SBOM, feed it to Grype to produce a
vulnerability report from the SBOM alone, and write two sentences on how this
same stored SBOM would let you answer "which of our images contain
openssl < 3.0.12?" during an incident (module 07) without touching any image.
Stuck? One hint
The SBOM step goes immediately after the build and against the just-built
digest, before or alongside the scan gate — same slot as track 11's Trivy
step, so build → SBOM(digest) → scan → sign → push. Emit whatever your
consumer needs (CycloneDX if it's a security tool like Dependency-Track, SPDX
if it's GitHub/buildx), and you can always syft convert later. For the
incident query, remember the SBOM already contains a PURL per component
(pkg:deb/openssl@...), so answering "who has vulnerable openssl?" is a
grep/query over stored SBOM files, not a rebuild.
Common mistakes & troubleshooting
- Treating the SBOM as a vulnerability report. It's the inventory (noun); a scanner joins it against CVE feeds (verb). An SBOM with zero CVEs listed isn't "clean" — it simply hasn't been scanned yet.
- Generating the SBOM by hand, later, against a re-pulled tag. That's how it drifts from what shipped (exercise 8). Generate it in CI, in the build step, bound to the digest.
- Binding the SBOM to a tag. A tag is mutable (module 00); an SBOM tied to
:1.0can silently describe a different image tomorrow. Bind tosha256:. - Fighting over SPDX vs CycloneDX. They express the same inventory; produce
what the consumer wants and
syft convertfor the rest. Format is an interop detail, not a decision worth blocking on. - Using
--scope squashedand wondering where a removed package went. The default sees only the final filesystem; use--scope all-layersif you need packages that were installed then deleted in a later layer. - Assuming an SBOM proves authenticity. It describes contents, nothing more. An attacker can generate a perfectly accurate SBOM for a malicious image. Authenticity comes from signing the SBOM and the image (modules 02–03) — the SBOM alone is claims, not proof.
Checkpoint quiz
Write down your answer to each question before expanding it — checking without attempting first is the single easiest way to fool yourself into thinking you've learned this.
Show questions
- List four things an SBOM records per component, and name the identifier that lets a scanner match a component to a CVE database.
- What is the difference between an SBOM and a vulnerability scan report?
- Name the two dominant SBOM formats, their lineage/focus, and the practical rule for choosing between them.
- Why should the SBOM be generated in CI against the image's digest rather than by hand later against its tag?
- Describe the stale-SBOM failure from exercise 8 and the process fix for it.
- Give the three later-module payoffs an SBOM enables, one sentence each.
- Does having an accurate SBOM prove an image is authentic? Why or why not?
Show answers
- Per component: name, version, type/ecosystem (OS package vs language library), a unique identifier (PURL and/or CPE), and often license and checksum plus a location hint. The PURL/CPE is the identifier scanners join against CVE databases.
- An SBOM is the inventory of components (the noun); a scan report is the result of cross-referencing that inventory against known-CVE feeds (the verb). The SBOM lists what's present; the scan says which of those have known problems.
- SPDX (Linux Foundation / ISO standard, originally license-focused, the
GitHub/
buildxdefault) and CycloneDX (OWASP, security-first, the Trivy default). Practical rule: produce whichever format the consumer expects, and convert between them withsyft convert— format is an interop detail. - Because a hand-generated, later SBOM against a mutable tag can drift from what
actually shipped (the tag may point elsewhere, or the image may have
changed), so it can under- or over-report contents. Generating in CI against
the immutable
sha256:digest guarantees the SBOM describes exactly the bytes that shipped. - The SBOM was generated before the image changed (Flask was added after), so it under-reported contents and any scanner trusting it would miss Flask's CVEs. The fix is process, not a flag: generate the SBOM in the same CI step that produces the final image, bound to its digest, so it can't describe a different build.
- Attestation (03): sign and attach the SBOM so consumers can verify it describes this image and came from you. Continuous scanning (04): re-scan stored SBOMs when new CVEs land, without re-pulling images. Incident blast-radius (07): answer "which images contain vulnerable package X?" as a fast query over stored SBOMs instead of a fleet-wide rebuild.
- No. An SBOM only describes contents — an attacker can produce an accurate SBOM for a malicious image. Authenticity requires signing the image and attesting the SBOM (modules 02–03); the SBOM by itself is an unverified claim.
Next
Continue to 02-image-signing-with-cosign-and-sigstore — you can now say what's in an image; next, answer the second question, is it authentic?, by signing it with cosign and Sigstore's keyless flow.