SBOMs with Syft

SPDX/CycloneDX, signed and attached by digest.

Advanced30 min · lesson 10 of 15

On 9 December 2021, one line in Apache log4j-core turned into Log4Shell (CVE-2021-44228, where CVE stands for Common Vulnerabilities and Exposures, the public catalog that gives every known flaw a permanent ID). Feed a crafted string into anything that logged it and you could run commands on the server. The ugly part was where that library sat. Thousands of Java services never imported it on purpose; they imported something that imported something that dragged it in. The teams that came through that weekend intact were not the fastest patchers. They were the ones who could answer one question in minutes rather than days: which of our images ship a vulnerable log4j, and where is it on disk? A box of cereal carries an ingredient list printed on the side at the factory, covering everything that went in, including the things you would never guess from the picture on the front. A Software Bill of Materials (SBOM) is that list for a build artifact: a machine-readable inventory of every component inside it. Generate one for every build and store it, and the next Log4Shell becomes a database query. Skip it, and it becomes a week of grep across live production.

What an SBOM actually lists

An SBOM lists components: operating system packages, language libraries, and their exact versions. Each one is pinned to a stable coordinate called a Package URL (pURL), which does for a software package what an ISBN does for a book. Plenty of books share a title. Only one book has a given ISBN. Read pkg:maven/org.apache.logging.log4j/[email protected] from left to right and it comes apart into a scheme (pkg), a type (maven, deb, npm, golang), an optional namespace (org.apache.logging.log4j), a name (log4j-core), a version (2.14.1), and optional qualifiers (?arch=amd64&distro=debian-12). That structured coordinate is the join key. A scanner matches a component against a CVE feed by pURL and by CPE (Common Platform Enumeration, the older product-naming scheme most feeds still carry), deterministically, with no guessing from names. Syft, Anchore's open-source cataloger, builds the inventory by inspecting what is genuinely present in an image or a filesystem. It reads the dpkg and apk package databases that Debian-family and Alpine images keep, unzips JARs to parse their manifests, and reads package-lock.json and go.mod. It reports what shipped, not what a Dockerfile or a manifest claims shipped. When those two have drifted apart, that gap is the whole reason you ran the tool.

Catalog the image and write a CycloneDX SBOM

Catalog the image by its digest, never by a tag. A tag like :latest is a nickname on a sticky note. Anyone can peel it off and stick it on a different box between your scan and your deploy, and then your SBOM describes an image you never shipped. A digest (sha256:...) is a fingerprint of the exact bytes, so it can only ever point at one image. Syft writes to a named format with the -o <format>=<path> syntax. The progress ticks below go to stderr, while the CycloneDX document itself lands in the file.

syft — catalog the image, write CycloneDX JSON
# Catalog the built image BY DIGEST and emit a CycloneDX 1.6 document:
$ syft registry.acme.internal/api@sha256:9f2c1e4b... -o cyclonedx-json=sbom.cdx.json
✔ Loaded image registry.acme.internal/api@sha256:9f2c1e4b...
✔ Parsed image sha256:7d3a0f...
✔ Cataloged contents sha256:7d3a0f...
├── ✔ Packages [247 packages]
├── ✔ File digests [1,438 files]
├── ✔ File metadata [1,438 locations]
└── ✔ Executable metadata [63 executables]
# The document is machine-readable — count the components it found:
$ jq '.components | length' sbom.cdx.json
247
# Confirm the format and spec version you emitted:
$ jq '{bomFormat, specVersion}' sbom.cdx.json
{
"bomFormat": "CycloneDX",
"specVersion": "1.6"
}

With 247 components indexed, "are we affected?" becomes a filter instead of a fire drill. Pull out the component that ruined December 2021 for so many people and read its coordinates. This is the exact record a scanner reads to decide the image is vulnerable, and the syft:location property tells whoever is on call which file to go and fix:

inspect one component in the CycloneDX SBOM
$ jq '.components[] | select(.name=="log4j-core")' sbom.cdx.json
{
"bom-ref": "pkg:maven/org.apache.logging.log4j/[email protected]?package-id=8b1c...",
"type": "library",
"group": "org.apache.logging.log4j",
"name": "log4j-core",
"version": "2.14.1",
"cpe": "cpe:2.3:a:apache:log4j-core:2.14.1:*:*:*:*:*:*:*",
"purl": "pkg:maven/org.apache.logging.log4j/[email protected]",
"properties": [
{ "name": "syft:package:foundBy", "value": "java-archive-cataloger" },
{ "name": "syft:location:0:path", "value": "/app/lib/log4j-core-2.14.1.jar" }
]
}

CycloneDX and SPDX: same inventory, different family tree

Two formats dominate, and both carry pURLs and CPEs, so any competent scanner reads either. What differs is where they came from and how they are shaped. CycloneDX comes from OWASP (the Open Worldwide Application Security Project) and was built security-first: compact, and VEX-native, meaning the same document family can carry vulnerability and exploitability data next to the inventory. It is the common default for signing and scanning inside a pipeline. SPDX (Software Package Data Exchange) is a Linux Foundation standard, published as ISO/IEC 5962, and it grew out of license compliance. That upbringing shows in its shape. It models rich relationships between packages and files (CONTAINS, DEPENDS_ON, DESCRIBES), and it is what procurement teams, lawyers and many government requirements (US Executive Order 14028) expect to receive. The rule that ends the argument: emit whatever your consumer ingests. Scanning your own images internally? CycloneDX. Handing an SBOM to a customer's compliance team or a regulator? SPDX. Syft produces both from the same catalog run, so the choice costs you one extra flag rather than a second pipeline.

syft — the same component in SPDX 2.3
$ syft registry.acme.internal/api@sha256:9f2c1e4b... -o spdx-json=sbom.spdx.json
$ jq '{spdxVersion} + (.packages[] | select(.name=="log4j-core") | {name, versionInfo, externalRefs})' sbom.spdx.json
{
"spdxVersion": "SPDX-2.3",
"name": "log4j-core",
"versionInfo": "2.14.1",
"externalRefs": [
{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": "pkg:maven/org.apache.logging.log4j/[email protected]"
},
{
"referenceCategory": "SECURITY",
"referenceType": "cpe23Type",
"referenceLocator": "cpe:2.3:a:apache:log4j-core:2.14.1:*:*:*:*:*:*:*"
}
]
}

Bind the SBOM to the image, or it is not evidence

A sbom.cdx.json sitting on a release page is a note taped to a crate. Anyone can peel it off, rewrite it, tape it back, or leave it hanging there while the crate underneath gets swapped. To make the inventory worth trusting, you bind it to the exact artifact it describes. cosign attest wraps the SBOM in an in-toto Statement (subject = the image digest, predicate = the SBOM) and signs it as a DSSE envelope, short for Dead Simple Signing Envelope. That envelope is a small JSON object with three fields: a payloadType of application/vnd.in-toto+json, the base64-encoded payload (the Statement itself), and one or more signatures. The signature is computed over a PAE (Pre-Authentication Encoding) of the type and the payload together, not over the raw payload bytes. That detail is load-bearing, because it stops an attacker lifting a valid signature and reinterpreting the same bytes under a different type. Since the Statement's subject is the digest, the signed SBOM is welded to that one image, and there is no grafting it onto a different build. With keyless signing (the default in cosign v2) the signing key is ephemeral and the identity comes from your CI system's OIDC (OpenID Connect) token, so no long-lived key sits anywhere waiting to leak. Key custody stops being your problem.

cosign — attach the SBOM as a signed attestation, then verify
# Attach the CycloneDX SBOM as a signed in-toto attestation (keyless, OIDC identity):
$ cosign attest \
--predicate sbom.cdx.json \
--type cyclonedx \
registry.acme.internal/api@sha256:9f2c1e4b...
Using payload from: sbom.cdx.json
Generating ephemeral keys...
Retrieving signed certificate...
Successfully verified SCT...
tlog entry created with index: 156843092
# Anyone can verify the attestation BY IDENTITY and pull the SBOM back out, bound to the digest:
$ cosign verify-attestation \
--type cyclonedx \
--certificate-identity="https://github.com/acme/api/.github/workflows/release.yml@refs/tags/v1.4.2" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
registry.acme.internal/api@sha256:9f2c1e4b... \
| jq -r '.payload | @base64d | fromjson | .predicate.components | length'
Verification for registry.acme.internal/api@sha256:9f2c1e4b... --
The following checks were performed on each of these signatures:
- The cert was verified against the Fulcio roots
- The claims were present in the transparency log
- The code-signing certificate was verified using trusted certificate authority certificates
247

That verify-attestation call does three jobs in one pass. It checks that the attestation was signed by the exact workflow identity you pinned, so a merely valid signature from anywhere does not get through (the cosign lesson covers why that distinction bites). It decodes the DSSE payload. And it hands back the same 247-component inventory you generated at build time. Keep those attestations and you can re-answer "are we exposed?" against tomorrow's vulnerability data without rebuilding anything, which is exactly where the next lesson starts: feeding the stored SBOM into Grype and Trivy, then trimming the false positives with VEX. Generate the inventory once, at build time, while your view of what went in is still sharp. Verify it as often as the news gives you a reason to.

Where the SBOM lives decides whether you can trust it
Distributing the SBOM
how does a downstream consumer get an inventory it can trust?
signed
in-toto attestation (cosign attest)
bound to the image digest by a DSSE envelope, tamper-evident, verifiable by identity (recommended)
unsigned
OCI referrer artifact
travels with the image in the registry, but proves nothing about integrity until you sign it too
detached
release-page asset (sbom.json)
handy, forgeable, and it goes stale quietly: not evidence you can lean on mid-incident
Identical SBOM bytes in all three. Only the signed attestation counts as evidence when a CVE lands.
Nothing found is not the same as nothing there
A cataloger can only inventory what left a fingerprint. Statically-linked C libraries, Rust binaries built without cargo-auditable metadata, vendored source copied in with no package metadata attached, and anything installed by a raw curl | make leave no dpkg, apk or JAR record behind. None of it shows up in the SBOM, so "no components matched a CVE" quietly turns into false confidence. What helps: build from base images whose package databases are intact, prefer distroless or apko-built images where the contents are fully declared, and generate SBOMs at build time from lockfiles and the dependency graph (syft reads go.mod, package-lock.json, requirements.txt) instead of only after the fact from the finished image, where the evidence of how a binary got there is already gone.
Quick check
01Your release page offers the image and a downloadable sbom.json right beside it. You are mid-incident. Why can a downstream team not treat that file as trustworthy evidence of what is inside the image they actually pulled?
Incorrect — Where the file is hosted makes no difference to a scanner. It reads the same JSON wherever it came from.
Correct — An unsigned file sitting beside an image is a claim, not proof. Only a signed in-toto attestation welds the inventory to one specific digest through the DSSE subject.
Incorrect — SBOMs have no expiry. They describe one fixed artifact and stay accurate for as long as that digest exists.
Incorrect — Download speed is not the trust problem. Even a file that arrives perfectly proves nothing about the image without a signature over the digest.
02Your compliance team has to hand an SBOM to a government customer who cites US Executive Order 14028, while your own pipeline scans images internally. Which format goes where, and why?
Incorrect — Both formats carry pURLs and CPEs, so any competent scanner reads either one.
Incorrect — The roles are the wrong way round. SPDX is the one published as ISO/IEC 5962, and it grew out of license compliance.
Incorrect — Procurement teams and regulators ask for SPDX by name, so what the consumer expects drives the choice, not file size.
Correct — Same inventory, different family tree. You match the format to whoever is reading it, and it costs one extra flag.
03Part of an image was built by curl https://… | make install, which dropped a statically-linked C library into /usr/local/bin. Syft catalogs the image and Grype reports 'no components matched a known CVE.' Why is that not a clean bill of health?
Correct — A cataloger inventories what left a package fingerprint, and a curl|make binary leaves none, which produces a clean-looking report and false confidence.
Incorrect — Both formats are scannable. The gap here is a missing component, not a format problem.
Incorrect — Static linking bakes the library into the binary. It stays vulnerable, it is only missing from the itemized list.
Incorrect — No feed refresh can match a component that never appears in the inventory in the first place.

Try this

Work through “Bind the SBOM to the image, or it is not evidence” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: nothing found is not the same as nothing there. 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