When a serious vulnerability is announced in a widely used library, every security team asks the same question within the hour: where do we run it? Teams without an inventory answer by grepping repositories, asking owners in chat and scanning whatever images they can find, and the answer arrives days later, still incomplete. A Software Bill of Materials (SBOM) is the inventory that makes the answer a query. It is a machine-readable list of the components inside one piece of software, with names, versions, identifiers, hashes, licenses and the relationships between them.
An SBOM is not a security control by itself. It does not block a bad package or prove where a binary came from. It is data, and its value depends on three things: how complete it is, how precisely it identifies each component, and whether anyone can query it when it matters. This article covers the two standard formats, the identifiers that make matching work, where in the pipeline to generate an SBOM and what each point misses, VEX documents that record which vulnerabilities do not apply, signing SBOMs as attestations, and an inventory you can query. A worked example then follows one hypothetical advisory from announcement to answer. The rest of the chain, from pinning and provenance to signing and admission, is covered in software supply chain security.
Architecture: from build to answer
What an SBOM is and the question it answers
Think of an SBOM as the ingredient label for a release. For each component it records a name, version, supplier, unique identifier, hashes, license, and its relationships to other components. The document as a whole records who produced it, which tool produced it, when, and which artifact it describes.
The US NTIA published minimum elements for an SBOM in 2021: supplier, component name, version, other unique identifiers, dependency relationship, author of the SBOM data and timestamp. In August 2025 CISA published a draft update of those minimum elements that adds, among other things, a component hash, the license and the name of the generating tool. It was a draft when this article was written, so check its current status before you cite it in a contract. In the EU, the Cyber Resilience Act makes vulnerability reporting mandatory for manufacturers from 11 September 2026, with the full set of obligations, including keeping an SBOM for products with digital elements, applying from 11 December 2027.
The two formats: SPDX and CycloneDX
Two formats dominate, and both can express the minimum elements. SPDX comes from the Linux Foundation and started as a license-compliance format. Version 2.2.1 was published as ISO/IEC 5962:2021, and SPDX 3.0, released in 2024, rebuilt the model around a core set of elements and relationships with profiles for software, security, licensing, builds, AI models and datasets. CycloneDX comes from OWASP and started as a security format. It has been standardised by Ecma as ECMA-424, version 1.7 was released in October 2025, and it can also describe services, hardware, ML models and vulnerability analysis.
The choice matters less than people expect, because most tools handle both. Pick the one your main consumers ask for, emit JSON rather than XML or tag-value, and never hand-convert between them, because conversions lose relationships and hashes. A minimal CycloneDX component looks like this:
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"metadata": {
"timestamp": "2026-10-03T09:12:44Z",
"tools": {"components": [{"type": "application", "name": "syft"}]},
"component": {"type": "container", "name": "registry.example.com/payments-api",
"version": "sha256:4f1c..."}
},
"components": [
{
"type": "library",
"group": "com.fasterxml.jackson.core",
"name": "jackson-databind",
"version": "2.17.1",
"purl": "pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.17.1",
"hashes": [{"alg": "SHA-256", "content": "9b0c..."}],
"licenses": [{"license": {"id": "Apache-2.0"}}]
}
],
"dependencies": [
{"ref": "pkg:maven/com.example/payments-api@4.2.0",
"dependsOn": ["pkg:maven/com.fasterxml.jackson.core/jackson-databind@2.17.1"]}
]
}Two fields deserve attention. The metadata component says which artifact this SBOM describes. For an image, use the digest rather than a mutable tag, or you cannot tell which build the list belongs to. The dependencies array turns the flat list into a graph.
Identifiers: purl, CPE and why matching fails
Matching fails far more often than generation does. A component called openssl, version 3.0.11, is ambiguous: is it the upstream source, the Debian package with backported patches, or a copy statically linked into a Go binary? Two identifier schemes try to remove the ambiguity.
The package URL, or purl, names a component the way its ecosystem does: pkg:maven/org.apache.logging.log4j/log4j-core@2.20.0, pkg:npm/%40angular/core@17.3.0, pkg:pypi/requests@2.32.3, or pkg:deb/debian/openssl@3.0.11-1~deb12u2?arch=amd64&distro=debian-12. A purl comes straight from the package manager, so it can be matched exactly against advisory databases such as OSV. The distro qualifier matters: Debian may fix a vulnerability by backporting a patch without changing the upstream version, so only the distribution's own advisory can say whether that exact package is affected.
The Common Platform Enumeration (CPE), for example cpe:2.3:a:apache:log4j:2.20.0:*:*:*:*:*:*:*, is the identifier the NVD uses. CPEs are assigned by vendor and product name, not by package coordinates, so mapping a Maven artifact or an npm package to a CPE is guesswork. Guessing produces both false positives and false negatives. Prefer purl for matching, carry a CPE only when a tool or contract needs one, and treat CPE-only matches as leads to verify, not findings.
Where you generate it decides what it sees
Where you generate an SBOM decides what it can see. There are three common points, and each misses something different.
| Point | Sees | Misses |
|---|---|---|
| Source and lockfile | Declared and locked dependencies, before build | Anything resolved at build time, OS packages, vendored or copied code, build plugins |
| Build (package manager plugin) | The exact resolved dependency graph, with scopes and hashes | The base image and OS packages, and whatever gets added after the build step |
| Built image or binary | OS packages, language packages found on disk, the base image | Dependency relationships, test versus runtime scope, statically linked or shaded code without metadata |
The build-time SBOM is the most accurate description of your own code, because the package manager knows exactly what it resolved. The image SBOM is the most accurate description of what actually ships, because it sees the base image's packages. Mature pipelines produce both, keyed to the same image digest.
Watch for invisible components. A shaded fat jar loses its Maven metadata unless the shade plugin preserves it, C libraries compiled in from source leave no package record, and vendored files appear in no lockfile. When an incident hits one of these, the SBOM will say you are clean when you are not. List the known gaps for each build, and add a manual component entry for vendored code.
Generating, gating and signing in a pipeline
A practical pipeline generates the build SBOM with the package manager's plugin, the image SBOM with a scanner after the image is pushed, and attaches both to the image as signed attestations. The commands below use the CycloneDX Maven plugin, Syft, Grype and Cosign, which are common open-source choices. Confirm flags against the versions you install.
# 1. Build-time SBOM from the resolved Maven graph (writes target/bom.json)
mvn -B package org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
# 2. Push the image, then refer to it by digest, never by tag
IMAGE=registry.example.com/payments-api
DIGEST=$(crane digest "$IMAGE:$GIT_SHA")
REF="$IMAGE@$DIGEST"
# 3. Image SBOM, including OS packages from the base image
syft "$REF" -o cyclonedx-json=image.cdx.json
# 4. Gate: fail the build on fixable critical findings
grype sbom:./image.cdx.json --only-fixed --fail-on critical
# 5. Attach both SBOMs as signed in-toto attestations on the digest
cosign attest --yes --key env://COSIGN_KEY --type cyclonedx \
--predicate image.cdx.json "$REF"
# 6. Consumers verify before trusting the contents
cosign verify-attestation --key cosign.pub --type cyclonedx "$REF" \
| jq -r '.payload' | base64 -d | jq '.predicate.components | length'Signing matters because an unsigned SBOM is a claim anyone could have edited. An attestation binds the document to an image digest and to the identity that signed it, so an admission controller or auditor can check that this list was produced by your pipeline for this exact artifact.
VEX: recording what does not apply
A scanner run against a typical image reports dozens or hundreds of vulnerabilities, and most do not matter. The vulnerable function is never called, the affected feature is compiled out, or the package is present only in a build stage. Without a durable record, every team re-triages the same findings every week.
Vulnerability Exploitability eXchange (VEX) is that record. A VEX statement says, for one product and one vulnerability, which of four statuses applies: not_affected, affected, fixed or under_investigation. A not_affected statement must carry a justification. The CISA minimum requirements list five: component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary and inline_mitigations_already_exist. OpenVEX is a small standalone format for these statements, and CycloneDX can carry the same analysis inside a BOM with its own state names.
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/payments-api-2026-10-03",
"author": "payments-security@example.com",
"timestamp": "2026-10-03T11:40:00Z",
"version": 1,
"statements": [{
"vulnerability": {"name": "CVE-2026-00000"},
"products": [{"@id": "pkg:oci/payments-api@sha256%3A4f1c..."}],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "Only the XML parser is affected; this service parses JSON only and the XML module is never loaded."
}]
}The CVE identifier above is a placeholder. Review VEX statements like code, scope them to a product digest, and expire them when the code changes.
An inventory you can query
SBOMs are only useful if you can ask the whole fleet a question in seconds. The inventory needs three properties: one record per artifact digest, linked to the services and environments that run it; components indexed by purl so an advisory can be matched with an exact lookup; and continuous re-matching, because the vulnerability data changes daily while the SBOM does not change at all.
OWASP Dependency-Track is a widely used open-source inventory that ingests CycloneDX, tracks projects and versions, and re-evaluates components as feeds update. The minimal script below answers the question every incident starts with; it compares versions as dotted integers, so use each ecosystem's own version rules in production.
import json, pathlib, re, sys
def components(node):
for comp in node.get("components", []):
yield comp
yield from components(comp) # nested components, e.g. shaded jars
def vtuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v)[:4])
def affected(sbom_dir, purl_prefix, fixed_in):
hits = []
for path in pathlib.Path(sbom_dir).glob("*.cdx.json"):
bom = json.loads(path.read_text())
subject = bom["metadata"]["component"]
for comp in components(bom):
purl = comp.get("purl", "")
if purl.startswith(purl_prefix + "@"):
version = purl.split("@", 1)[1].split("?", 1)[0]
if vtuple(version) < vtuple(fixed_in):
hits.append((subject["name"], subject.get("version"), version))
return hits
if __name__ == "__main__":
for name, digest, version in affected(sys.argv[1], sys.argv[2], sys.argv[3]):
print(f"{name} {digest} ships {version}")
Worked example: a new advisory at 09:00
Here is how it plays out with a hypothetical advisory. At 09:00 an advisory lands for a widely used Java parsing library, pkg:maven/com.example.parse/fastparse, with a remote code execution flaw in versions below 3.4.2. The company runs 340 services across three clusters.
The on-call engineer runs the query against the SBOM store at 09:07 and gets 41 image digests, 29 services, that ship a version below 3.4.2. A join against deployment data cuts that to 23 services in production.
The build-time SBOMs show the dependency graph. In 17 services the library is a direct dependency and the fix is a version bump. In 6 it arrives through an internal shared client, so one fix in that client plus a rebuild clears all six. Two teams find the vulnerable entry point unreachable and publish scoped VEX statements. By 15:00 every production service is either rebuilt or covered by a reviewed VEX statement, and the admission policy now rejects any new image whose attested SBOM still contains a vulnerable version.
One affected service never showed up: it shaded the library with its Maven metadata stripped, so neither SBOM listed it. A runtime scan of loaded classes caught it, and the fix was to make the shade plugin keep the metadata.
Failure modes
- Tag instead of digest. An SBOM attached to
:latestdescribes whatever was pushed last, not what is running. - Stale SBOMs. Generated once at release and never refreshed, while base images are patched in place. Regenerate on every rebuild, and treat SBOM age as a metric.
- Invisible components. Shaded, vendored, statically linked or downloaded-at-runtime code has no package metadata.
- CPE-only matching. Name collisions flood triage with false positives until people stop reading the alerts.
- Unsigned documents. An SBOM anyone can edit cannot support an audit or an admission decision.
- Blanket VEX. A not_affected statement scoped to a component everywhere silently hides the one service where the code path is reachable.
Trade-offs
The cheapest useful setup is a build-time and an image SBOM per digest, stored in one queryable place and matched daily against OSV. Signing, VEX and admission policies add assurance, but each adds a process someone must own. If you share SBOMs with customers, publish VEX alongside them. Finally, an SBOM proves inventory, not integrity. It tells you what a build contains, not whether the build was tampered with. Provenance attestations and reproducible builds answer that question, and they are covered in the supply chain overview. Related reading: container security, SAST and DAST, secrets scanning and OWASP Dependency-Check for Java.
What to do next
- Pick one format (CycloneDX or SPDX JSON) and one generator, and write both down as the standard.
- Add a build-time SBOM step to the three most critical services, using the package manager's own plugin.
- Add an image SBOM step that runs after push and records the image digest as the subject.
- Attach both SBOMs as signed attestations, and verify one by hand with
cosign verify-attestation. - Load every SBOM into one queryable store, then rehearse: pick a real past advisory and time how long the fleet-wide answer takes.
- Start a VEX file per product, require a justification and a reviewer for every not_affected statement, and set them to expire.
- List the known blind spots (shaded, vendored and static code) and add a check or a manual entry for each.