Part 2 of 3 in the Vulnerability management that works series. Part 1: Prioritising vulnerabilities: CVSS vs EPSS vs CISA KEV.
Software bills of materials had a hype cycle. Executive Order 14028 in May 2021 told US federal suppliers to provide them, vendors rushed out SBOM features, and for a couple of years every security conference had a talk about them. A lot of organisations now generate SBOMs because a contract or a customer questionnaire asked for one, file them somewhere, and never look at them again.
That’s a shame, because there’s one job SBOMs do very well, and it’s the job that hurt most during Log4Shell. When a new vulnerability lands, they tell you where you’re running the affected component, in minutes instead of days.
What an SBOM is, concretely

An SBOM is a machine-readable inventory of the components inside a piece of software: package names, versions, identifiers, and ideally the relationships between them. There are two formats that matter. SPDX came out of the Linux Foundation and is published as ISO/IEC 5962:2021. CycloneDX came out of OWASP and has since been standardised by Ecma as ECMA-424. Both are fine. Pick the one your tooling handles best and stop debating it.
The NTIA’s 2021 minimum elements document lists the fields an SBOM should carry: supplier, component name, version, other unique identifiers, dependency relationships, the author of the SBOM data, and a timestamp. In practice the identifier that does the heavy lifting is the package URL, or PURL, something like pkg:maven/org.apache.logging.log4j/[email protected] or pkg:deb/debian/[email protected]. PURLs are what let a scanner match a component against an advisory database without guessing from file names.
Generate them at build time, from the artifact
Where you generate the SBOM matters more than which format you use. An SBOM written from a lockfile describes what you meant to ship. An SBOM generated from the built container image describes what you actually shipped, including the OS packages in the base image that no lockfile in your repo mentions.
Syft is the tool I reach for. Generating a CycloneDX SBOM for an image in CI is one line:
syft registry.example.com/payments-api:1.42.0 -o cyclonedx-json=payments-api.cdx.json
Then attach it to the image, so the inventory travels with the artifact instead of living in a CI log nobody can find:
cosign attest --yes \
--predicate payments-api.cdx.json \
--type cyclonedx \
registry.example.com/payments-api@sha256:<digest>
Attest against the digest, not the tag. Tags move. An SBOM attached to :latest is a statement about an image that may no longer exist.
Also push a copy into something you can query across the fleet. That can be a dedicated platform like OWASP Dependency-Track, or honestly a bucket of JSON files plus an index. The format is less important than being able to ask one question across every artifact you run.
The question SBOMs answer
That question is: “which of our running things contain component X at version Y?”
On 29 March 2024, Andres Freund disclosed the backdoor in xz-utils, CVE-2024-3094, affecting versions 5.6.0 and 5.6.1. If you had SBOMs for every deployed image, the first hour looked like this:
for f in sboms/*.cdx.json; do
jq -r --arg f "$f" '.components[]
| select(.name == "xz-utils" or .name == "liblzma5" or .name == "xz-libs")
| select(.version | test("^5\\.6\\.[01]"))
| "\($f) \(.name) \(.version)"' "$f"
done
If you didn’t, the first hour was a Slack thread asking every team to check their base images, followed by a day of partial answers. I’ve been on both sides of that, and the difference isn’t subtle. During Log4Shell I watched teams spend most of a week just establishing which services bundled log4j-core, because it was often a transitive dependency several levels down, or shaded inside another jar.
The same SBOM store also feeds continuous matching. Instead of rescanning every image when the vulnerability database updates, you rescan the SBOMs:
grype sbom:payments-api.cdx.json --fail-on high
That’s fast, cheap, and it works for images that are already deployed and won’t be rebuilt this week.
Where SBOMs are blind
SBOMs are only as complete as the tool that generated them, and every generator has gaps.
Statically linked and vendored code is the big one. If a C library was copied into your source tree or compiled into a binary, there’s no package manager metadata for a scanner to find. Go is better than most here because the toolchain embeds module information in the binary, which go version -m will print and Syft will read. Rust binaries carry nothing by default unless you build with cargo auditable. Copied-in JavaScript files, a single vendored .c file, or a library someone downloaded and checked in years ago will mostly be invisible.
Version strings can lie too. Distributions backport security fixes without changing the upstream version, so a Debian package might report a version that looks vulnerable while carrying the patch. Good scanners read the distro’s security tracker data for this. Bad ones generate a stream of false positives that teach engineers to ignore the scanner.
And an SBOM says nothing about reachability. A vulnerable component that’s present isn’t necessarily loaded, and a loaded library isn’t necessarily being called with attacker-controlled input.
VEX is what makes SBOMs bearable
That last gap is where VEX comes in, the Vulnerability Exploitability eXchange. A VEX document is a statement about a specific vulnerability in a specific product, with a status: not_affected, affected, fixed or under_investigation. A not_affected claim has to carry a justification, such as vulnerable_code_not_present or vulnerable_code_not_in_execute_path.
OpenVEX, CSAF and CycloneDX can all carry VEX statements, and Grype accepts OpenVEX documents to suppress findings you’ve already analysed:
grype sbom:payments-api.cdx.json --vex payments-api.openvex.json
The value here is organisational, not technical. Someone investigates a finding once, writes down why it doesn’t apply, and that decision follows the artifact. Without it, the same finding gets triaged by three different people in three different quarters, and the one who’s in a hurry marks it as a false positive with no reason recorded.
What I’d actually do
Generate SBOMs from built images in CI, attest them to the digest, and copy them into a queryable store. Rescan the stored SBOMs daily against current advisory data and feed the results into the prioritisation from part one, so a KEV entry on an exposed service jumps to the front. Record triage decisions as VEX next to the SBOM.
Skip the parts that don’t pay. I wouldn’t spend time on SBOM quality scores, on publishing SBOMs to customers who haven’t asked, or on generating them for every intermediate build. The SBOM that matters is the one for the thing running in production right now.
Part three covers the last mile: setting patch SLAs that teams can actually meet once you know what to patch and where.
Other parts: Part 1: Prioritising vulnerabilities: CVSS vs EPSS vs CISA KEV, Part 3: Patch SLAs that teams actually meet.
