SBOMs with Syft
SPDX/CycloneDX, signed and attached by digest.
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.
# 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.json247# 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:
$ 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 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.
# 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.jsonGenerating 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 certificates247
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.
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?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.