DevSecOps in Depth · Software Bills of Materials

Software Bills of Materials

Container & supply-chain security introduced the SBOM in a paragraph: a structured inventory of what's inside a build, in SPDX or CycloneDX, generated by a tool like Syft. This page is that paragraph taken apart and put back together — the two formats and why they're shaped so differently, the real difference between generating an SBOM as part of the build versus reconstructing one from an artifact you didn't build, where SBOMs get stored and signed so they're actually queryable later, and the one operational scenario that justifies all of it: a new CVE drops, and you need to know exactly which of your two hundred services are affected, right now, not by Friday.

☺ Explain it like I'm 10

Picture a bakery that keeps a card taped to every cake, listing every ingredient and where it came from — this flour, that batch of eggs, this jar of cinnamon. Nobody reads the card on a normal Tuesday. Then the news says a cinnamon supplier just shipped a bad batch. A bakery with cards taped to every cake pulls the affected cakes off the shelf in the next five minutes, by reading cards. A bakery without cards has to cut open every cake in the building to check. The SBOM is the card. It's boring right up until the one day it's the only thing that saves you.

🐦Your host for this topic: Pip the Hummingbird — never lands, never carries anything it can't vouch for, and checks the signature on every image, attestation, and dependency that arrived from somewhere else. SBOMs are Pip's half of container & supply-chain security, the chain-of-custody half, taken all the way down.

What actually lives inside an SBOM

☺ Like you're 10: Not just a list of names — a list of exactly which version of each ingredient, who made it, and which other ingredients it's secretly made of.

An SBOM is not a flat list of library names. At minimum, a usable entry for one component carries a name, a version, a supplier, one or more unique identifiers — almost always a package URL (PURL), a compact string like pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1 that names the ecosystem, the package, and the exact version in one machine-parseable token — a license, and often a cryptographic hash of the artifact itself. That's the ingredient card for one item. The part that makes an SBOM more than a spreadsheet is the dependency relationship graph sitting underneath the flat list: which component depends on which other component, and at what depth. A vulnerable library your team imported directly is a one-hop lookup; the same library pulled in transitively by a dependency of a dependency is invisible unless the graph is actually recorded, not just the flat set of names.

The U.S. National Telecommunications and Information Administration published a baseline for this in July 2021 — the NTIA Minimum Elements for an SBOM — naming seven fields any usable SBOM should carry: Supplier Name, Component Name, Version, other Unique Identifiers (PURL/CPE), Dependency Relationship, Author of SBOM Data, and Timestamp. Responsibility for the initiative has since moved to CISA, and the guidance has been refined since 2021, so treat the seven fields as the durable shape of the requirement and check CISA's current SBOM guidance for the latest wording rather than citing this page as the source of record.

SPDX vs CycloneDX: two standards, two lineages

☺ Like you're 10: Two different clubs invented two different ways to write the ingredient card — one club started by caring about who owns the recipe, the other started by caring about which ingredients might be dangerous.

SPDX (Software Package Data Exchange) is the older of the two, started under the Linux Foundation in 2010 to solve a license-compliance problem: when you ship a product built from a pile of open-source components, which licenses actually apply, and what do you owe each project in attribution? That heritage still shows — SPDX's data model is unusually thorough on licensing (licenseConcluded vs licenseDeclared, copyright text, and a whole license-expression grammar for combinations like Apache-2.0 OR MIT). SPDX became an ISO/IEC international standard — ISO/IEC 5962:2021 — which is a large part of why it's the format government and enterprise procurement paperwork defaults to naming by name. It can be expressed as tag-value text, RDF, spreadsheet, or JSON; JSON is what nearly all tooling emits today.

CycloneDX is newer — it grew out of the OWASP Dependency-Track project starting in 2017 — and it was designed security-first rather than licensing-first. Its data model has native, first-class support for things a security team needs that SPDX had to bolt on later: a structured place for known vulnerabilities against each component, and native support for VEX (Vulnerability Exploitability eXchange) statements — see the blast-radius section below for why that pairing matters. CycloneDX serializes to JSON, XML, or a compact Protocol Buffers form for high-volume pipelines, and it has its own OWASP-governed specification track rather than an ISO number, though it's widely adopted enough that most scanners and registries treat it as a de facto standard regardless.

Where each format is strong

SPDXCycloneDX
OriginLinux Foundation, 2010 — license complianceOWASP, 2017 — security tooling (born from Dependency-Track)
Standards statusISO/IEC 5962:2021OWASP specification, ECMA submission in progress
Native serializationsTag-value, RDF/XML, spreadsheet, JSONJSON, XML, Protocol Buffers
Strongest atLicense expressions, copyright, legal provenanceVulnerability data, VEX, dependency graphs for security tooling
Where you'll be asked for itGovernment/enterprise procurement paperwork, legal reviewVulnerability scanners, Dependency-Track, most CI-native SBOM tools

In practice most build-time generators — Syft, cdxgen, Trivy — emit either one on request, so the choice is rarely a tooling constraint. It's usually a consumer constraint: produce SPDX for a procurement questionnaire that names it by name, CycloneDX for anything you intend to feed into a vulnerability-matching pipeline, and both if you don't yet know which audience will ask first.

// The same component — log4j-core 2.14.1 — as a fragment of each format.

// SPDX 2.3 JSON: license fields front and center
{
  "SPDXID": "SPDXRef-Package-log4j-core",
  "name": "log4j-core",
  "versionInfo": "2.14.1",
  "downloadLocation": "https://repo1.maven.org/maven2/org/apache/logging/log4j/log4j-core/2.14.1/",
  "licenseConcluded": "Apache-2.0",
  "licenseDeclared": "Apache-2.0",
  "copyrightText": "NOASSERTION",
  "externalRefs": [{
    "referenceCategory": "PACKAGE-MANAGER",
    "referenceType": "purl",
    "referenceLocator": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
  }]
}

// CycloneDX 1.5 JSON: purl and hashes front and center, vulnerabilities is a sibling array
{
  "type": "library",
  "bom-ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
  "group": "org.apache.logging.log4j",
  "name": "log4j-core",
  "version": "2.14.1",
  "purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
  "licenses": [{ "license": { "id": "Apache-2.0" } }],
  "hashes": [{ "alg": "SHA-256", "content": "a5e3...redacted" }]
}

SBOM "types": where in the SDLC it gets made

☺ Like you're 10: An ingredient card written before you shop is a guess. One written after you cook is a fact. Both are useful, for different questions.

CycloneDX's own specification names a small set of lifecycle phases an SBOM can be tagged with — the exact enumeration has grown across spec revisions, so check the current CycloneDX docs for the authoritative list, but the shape is stable enough to reason about: a design-phase SBOM is a plan, assembled from a proposed dependency manifest before anything is built. A source-phase SBOM is generated from a repository's manifest and lockfile — real dependencies, but not yet what a build will actually produce. A build-phase SBOM is generated during compilation or packaging, and can capture build-only tooling that never ships. A deployed or runtime SBOM reflects what's actually running in production right now, which can differ from what was built if someone patched a container in place or a runtime pulled in something dynamically. Each phase answers a slightly different question, and conflating them is a common source of false confidence: a source-phase SBOM generated once at project kickoff and never touched again tells you nothing true about what's running eighteen months later.

Generating an SBOM at build time vs reconstructing one after the fact

☺ Like you're 10: You can write the ingredient card while you're cooking, when you know exactly what went in — or you can write it afterward by tasting the finished cake and guessing. Both work; they catch different mistakes.

This is the operational decision that actually matters, and most teams need to make it twice — once for artifacts they build, and once for artifacts they don't.

Build-time (source) generation — for what you build yourself

Generating the SBOM as a CI step, scanning the source tree and its lockfile before or during packaging, has three advantages a post-hoc scan can't fully replicate. It sees the full dependency tree exactly as resolved, including development and test dependencies that get stripped out of a final image — useful for a complete audit trail even though those aren't part of the deployed attack surface. It can be directly tied to build provenance — the exact source commit, the CI run ID, the builder identity — because it's generated inside the same job that has that context. And it's cheap to run on every commit, since scanning a manifest and lockfile is fast compared to unpacking a full container image layer by layer.

# Build-time SBOM: scan the source + lockfile, before anything gets packaged
syft packages dir:. -o cyclonedx-json=sbom.cdx.json
syft packages dir:. -o spdx-json=sbom.spdx.json

# cdxgen handles polyglot monorepos well — Node, Python, Go, Java in one pass
cdxgen -o bom.json .

# BuildKit can attach one as a build attestation with no separate tool call
docker buildx build --sbom=true -t registry.acme.io/checkout:1.8.2 .
docker buildx imagetools inspect registry.acme.io/checkout:1.8.2 --format '{{ json .SBOM }}'

Post-hoc (image/binary) reconstruction — for what you didn't build

Sometimes you don't control the build — a vendor's container image, a third-party appliance, a legacy artifact nobody has the original CI job for anymore. Sometimes you do control the build but still want a second, independent check on what actually landed. Either way, the technique is to point a scanner at the finished artifact and reconstruct its component list by inspecting installed packages, package manager metadata left inside image layers, and language-runtime fingerprints. This catches something source-time scanning structurally cannot: OS packages the base image pulled in via apt/yum/apk during its own build, which never appear in your application's manifest because your application never asked for them — they came along for free with FROM debian:bookworm-slim. It also reflects reality when a multi-stage build strips things the source-time SBOM assumed would ship. The tradeoff runs the other way too: a compiled, statically-linked binary can strip most language-level metadata, so a post-hoc scan of a Go binary sees far less than a source-time scan of the same project's go.mod would have.

# Post-hoc SBOM: scan the artifact that actually shipped, after the fact
syft packages docker:registry.acme.io/checkout:1.8.2 -o spdx-json=sbom.spdx.json
trivy image --format cyclonedx --output sbom.cdx.json registry.acme.io/checkout:1.8.2

# The same trick works against a bare filesystem or a VM image, not just a container —
# exactly what you reach for on a vendor appliance you didn't build
syft packages dir:/mnt/legacy-appliance -o cyclonedx-json=legacy-sbom.json

Do both, and diff them

The mature pattern isn't choosing one — it's generating a source-time SBOM in CI and a post-hoc SBOM against the image that CI just produced, then diffing the two. Anything present in the image SBOM but absent from the source SBOM is either an expected base-image package (fine, and now documented) or something unexpected that snuck into the build environment — a compromised base image, a build step that reached out and installed something nobody declared, a cache poisoned by an earlier stage. That diff is itself a lightweight supply-chain detection technique, not just a data-quality exercise.

# Reconcile the two views of the same artifact
cyclonedx-cli diff --input-file1 sbom-source.cdx.json --input-file2 sbom-image.cdx.json \
  --component-versions
⚠ Watch out

A stale SBOM is worse than no SBOM, because it looks authoritative while being wrong. Generating one at project kickoff and never regenerating it is the single most common SBOM failure mode — the artifact it describes stops existing the moment the next commit merges. Regenerate on every build, version the SBOM alongside the artifact digest it describes (never alongside a floating tag), and treat "SBOM present" and "SBOM current" as two separate checks in your pipeline gate.

Storing, signing, and querying SBOMs at scale

☺ Like you're 10: A card taped to one cake is useless during a recall unless every card, from every cake in the building, is filed somewhere you can search all at once.

An SBOM sitting as a build artifact in one CI run's logs is nearly worthless for the scenario that justifies generating it — you'd have to know which run to look in before you could look. Two things need to happen to make an SBOM actually useful later: it needs to be attached to the artifact it describes in a way that travels with it, and it needs to be indexed somewhere queryable across every artifact your organization has ever shipped.

Attaching and signing

The modern pattern attaches the SBOM to the OCI artifact itself as an attestation, using Sigstore's cosign and the in-toto attestation framework, so anyone who pulls the image can also pull and verify its SBOM without a separate lookup. Signing the SBOM matters as much as generating it — an unsigned SBOM is just an unverified claim sitting next to an image, no more trustworthy than a comment in a README. See Sigstore & cosign for the signing mechanics in depth.

# Attach the SBOM to the image as a signed attestation
cosign attest --predicate sbom.cdx.json --type cyclonedx \
  registry.acme.io/checkout@sha256:9f2c1e4a...

# Verify it later, from anywhere, before trusting what it claims
cosign verify-attestation --type cyclonedx registry.acme.io/checkout@sha256:9f2c1e4a...

Indexing centrally

Attestations solve per-artifact trust; they don't solve the "search every artifact we've ever shipped" problem on their own. That needs a store built for the query. OWASP Dependency-Track ingests CycloneDX BOMs per project and continuously matches every component against vulnerability feeds, so a new CVE against a known component surfaces automatically instead of waiting for the next scan. GUAC (Graph for Understanding Artifact Composition, an OpenSSF-adjacent project) goes further, building a knowledge graph that links SBOMs, build provenance, and vulnerability data together so a single query can cross all three — "which deployed artifacts contain this component, built by which pipeline, currently affected by which open CVEs." GUAC's CLI surface is still evolving, so treat command syntax below as illustrative and check the project's own docs for the current form.

# Push a freshly generated BOM into Dependency-Track
curl -X POST "https://dtrack.acme.internal/api/v1/bom" \
  -H "X-Api-Key: $DTRACK_API_KEY" \
  -F "project=checkout-api" -F "projectVersion=1.8.2" \
  -F "bom=@sbom.cdx.json"

SBOM is not provenance

It's worth being precise about a distinction that gets blurred constantly: an SBOM claims what's inside an artifact. A SLSA (Supply-chain Levels for Software Artifacts, an OpenSSF project) provenance attestation claims how and where that artifact was built — which builder produced it, from which source commit, with which build parameters, and whether the build environment was isolated from tampering. They answer different questions and are meant to travel together as sibling in-toto attestations on the same artifact, not to substitute for each other. An artifact can have a perfect, complete SBOM and still have untrustworthy provenance if the build that produced it ran on a compromised runner — the SBOM would faithfully describe exactly what that compromised build shipped.

The operational payoff: a Log4Shell-style blast-radius walkthrough

☺ Like you're 10: The recall notice comes in. With filed cards, you find every affected cake in minutes. Without them, you're opening every cake in the building by hand.

This is the scenario every earlier section in this page was setting up for. On December 9, 2021, CVE-2021-44228 — "Log4Shell" — was disclosed against Log4j 2, affecting versions from 2.0-beta9 through 2.14.1 and fixed in 2.15.0 (with two follow-on CVEs, 2021-45046 and 2021-45105, patched shortly after). The vulnerability was in one of the most widely embedded Java logging libraries in existence, often pulled in three or four dependency hops deep, sometimes shaded into a fat JAR where its own identity was obscured. The question every security team needed answered within hours was simple to state and, for most organizations at the time, brutally hard to answer: which of our services actually contain this, at which version, and are any of them reachable enough to matter?

Two ways an SBOM gets made — one indexed store either way Build-time SBOM syft dir:. · cdxgen — scans source + lockfile in CI catches dev deps & exact resolved versions Post-hoc SBOM syft docker:img · trivy image — scans the built artifact catches OS packages the base image pulled in SBOM store Dependency-Track / GUAC indexed by PURL, one record per artifact digest CVE-2021-44228 disclosed log4j-core versions before 2.15.0 — Log4Shell checkout-api affected auth-svc not affected search-svc affected billing-svc not affected report-gen affected gateway not affected One PURL query across every stored SBOM — minutes, not a days-long grep across every repo.

Without an indexed store

The pre-SBOM version of this exercise, which is exactly how most organizations actually spent that December, looked like this: a message to every service owner asking them to check. Each owner greps their own source tree for log4j, misses the copy that arrived shaded inside a third-party JAR under a renamed package path, misses the copy sitting in a vendor appliance nobody has source access to, and reports back "clean" with real but incomplete confidence. Multiply that across a couple hundred services and the honest answer to "are we affected" doesn't exist for days, precisely the days an actively-exploited, trivially-weaponized vulnerability was being scanned for across the entire internet.

With an indexed store, plus VEX to cut the noise

With SBOMs generated at build time, reconciled against post-hoc image scans, and indexed centrally, the same question becomes one query: find every stored component record matching the PURL pkg:maven/org.apache.logging.log4j/log4j-core at a version before 2.15.0, and return the artifacts and projects that own them. Minutes, not days, and — critically — complete, because it isn't relying on any individual engineer's memory of what their service imports four layers down.

# Dependency-Track: which projects currently contain this exact component?
curl -s "https://dtrack.acme.internal/api/v1/component/byPurl?purl=pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" \
  -H "X-Api-Key: $DTRACK_API_KEY" | jq '.[].project.name'

That query returns "affected" for every artifact containing the vulnerable component, but not every one of those is actually exploitable, and that gap is where VEX (Vulnerability Exploitability eXchange) earns its place. A VEX statement lets a team assert, per component per artifact, whether a known-present vulnerability is genuinely reachable — status values like affected, not_affected, fixed, and under_investigation, each with a machine-readable justification (for Log4Shell, a common one was vulnerable_code_not_in_execute_path, because a service that logs but never resolves untrusted input through the vulnerable lookup mechanism isn't exploitable even though the library is present). Pairing a component-level "present" answer from the SBOM with an exploitability-level "actually exploitable" answer from VEX is what turns a flood of theoretically-affected alerts into a short, actionable list. See vulnerability management & triage for how VEX statements fit into a full triage workflow.

// An OpenVEX-shaped statement, simplified for illustration — check the OpenVEX
// spec for the authoritative schema before wiring this into a real pipeline.
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://acme.example/vex/2021-44228-checkout-api",
  "statements": [{
    "vulnerability": { "name": "CVE-2021-44228" },
    "products": [{
      "@id": "pkg:oci/checkout-api@sha256:9f2c1e4a...",
      "subcomponents": [{ "@id": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1" }]
    }],
    "status": "not_affected",
    "justification": "vulnerable_code_not_in_execute_path",
    "impact_statement": "JNDI message lookups are disabled (log4j2.formatMsgNoLookups=true) and no untrusted input reaches a Logger call in this service."
  }]
}
◆ Key idea

The SBOM answers "is the component present." VEX answers "does it matter here." Neither one alone is the operational win — a present-only answer floods responders with false urgency, and an exploitability claim with nothing to anchor it to isn't verifiable. The pairing, generated continuously and stored where it can be queried in one shot, is what turns "we think we're mostly fine" into "here are the exact four services, ranked by whether they're actually reachable."

The compliance angle — and why it's the least interesting part

☺ Like you're 10: The government asking for the ingredient card is a fine reason to start keeping one. It's a bad reason to stop caring about it the moment the inspector leaves.

Executive Order 14028, "Improving the Nation's Cybersecurity," issued in May 2021, directed federal agencies to require an SBOM from software vendors selling into the government, which is what drove the NTIA's minimum-elements work referenced earlier and, downstream of it, a wave of procurement questionnaires now naming SPDX or CycloneDX by name. That's a legitimate, growing reason organizations start generating SBOMs — compliance as code at scale covers how to produce that evidence continuously rather than scrambling for it once a year. But treating an SBOM as a document you generate once to satisfy a questionnaire and then never look at again gets you all of the maintenance burden and none of the payoff demonstrated above. The organizations that got real value out of their SBOMs during Log4Shell weren't the ones with a PDF on file for an auditor — they were the ones with a queryable, current index they could actually ask a question of, in the fifteen minutes right after the disclosure hit, before the exploitation started. Build for that use case, and the compliance checkbox falls out for free; build only for the checkbox, and you'll have paperwork on the one day you needed a query instead.

🎬 At the Shift-Left Squad
🦫

Benny the Beaver: Image's built, tagged 1.8.2, pushed. Next.

🐢

Timmy the Turtle: Did the SBOM regenerate against this build, or is it still the one from three releases back?

🦫

Benny the Beaver: ...It's wired into the pipeline. It regenerates every time. Probably.

🐦

Pip the Hummingbird: I checked. It did — and I verified the attestation on it before I said anything. An SBOM nobody signed is just Benny's word on a piece of paper.

🐿️

Nutty the Squirrel: Filed. And this time it's actually queryable — during Log4Shell I was digging through a shared spreadsheet by hand. Never again.

🦊

Foxy: Genuine question — isn't this just paperwork? Nobody reads an SBOM for fun.

🐦

Pip the Hummingbird: Nobody reads it on a normal Tuesday. Everybody needs it the day a CVE drops against something we all forgot we were carrying three dependencies deep. That's not paperwork — that's the one query that saves you a week.

✓ Checkpoint

1. Name one thing SPDX is structurally stronger at than CycloneDX, and one thing CycloneDX is structurally stronger at than SPDX — and say why, given each format's origin. 2. A build-time SBOM and a post-hoc SBOM of the same shipped image will often differ. What's the most common reason, and why is diffing the two useful beyond just data hygiene? 3. Walk through, in a sentence or two, why an SBOM store indexed by PURL turns a Log4Shell-style disclosure into a minutes-long query instead of a days-long audit. 4. What does an SBOM claim that a SLSA provenance attestation does not, and vice versa — and why do you want both, not one instead of the other?

Check your answers
  1. SPDX is stronger on licensing — licenseConcluded/licenseDeclared distinctions, copyright text, and a full license-expression grammar — because it started in 2010 as a Linux Foundation project solving open-source license compliance. CycloneDX is stronger on security data — native vulnerability records and native VEX support — because it grew out of OWASP's Dependency-Track project with security tooling as the founding use case.
  2. The most common reason is OS packages the base image pulled in during its own build (via apt/yum/apk), which never appear in the application's own manifest because the application never declared them — they arrive for free with the base image. Diffing the two catches that expected drift, but it also surfaces genuinely unexpected components — something that snuck into the build environment via a compromised base image or a rogue build step — which makes the diff a lightweight supply-chain detection technique, not just cleanup.
  3. Because every artifact's exact component list, keyed by package URL, already sits in one queryable store before the CVE is ever disclosed. Instead of asking every service owner to manually check their own dependency tree — slow, and prone to missing anything nested a few hops deep or shaded into a third-party JAR — you run one query for the vulnerable PURL and get back the complete, authoritative list of affected artifacts directly.
  4. An SBOM claims what's inside an artifact — its component inventory. A SLSA provenance attestation claims how and where the artifact was built — which builder, from which source commit, under what build conditions. Neither substitutes for the other: an artifact can have a complete, accurate SBOM and still have been produced by an untrustworthy build process, and a strong provenance claim says nothing about what's actually inside the resulting artifact. They're meant to travel together as sibling attestations on the same artifact.

You now have the format choice, the generation strategy, and the operational case for why any of it matters. From here, Syft & Grype is the hands-on tool page — Syft generating exactly the SBOMs described above, Grype consuming one directly with grype sbom:./sbom.json to turn inventory into findings without a second scan of the artifact. For the license-risk half of what an SBOM makes possible, see dependency & license risk management; for the exploitability layer this page only introduced, see vulnerability management & triage.