Almost every organization that has taken software supply chain security seriously in the last few years converges on the same three moves: turn on a scanner in CI, generate an SBOM at build time, wire up a dashboard that counts CVEs. Then they declare the problem “in progress.”
That checklist is the easy 20% of the work, and it produces most of the noise with very little of the risk reduction. The parts that actually determine whether the next Log4Shell or xz event hurts you live in the plumbing almost nobody operationalizes: component identity, exploitability inference, and artifact integrity. This post goes into that plumbing.

The pipeline most teams are actually running
build → generate SBOM → match against CVE feed → count vulns → ticket the criticals
It satisfies auditors and produces impressive burndown charts. It fails on three fronts that rarely make the architecture diagram:
- The SBOM doesn’t describe what’s actually running, because component identification is lossy.
- The CVE count doesn’t describe what’s exploitable, because matching is a fuzzy join and reachability is unmodeled.
- Nothing proves the artifact you scanned is the artifact you shipped, because provenance is unverified.
Let’s take them in order, at the level of the actual data.
Miss #1: Component identity is lossy, and matching amplifies the loss
PURL vs. CPE: the fault line under every scanner
There are two incompatible naming systems in play, and most false positives and false negatives trace back to the seam between them.
Package URL (PURL) is the ecosystem-native identity. It is precise and deterministic:
pkg:npm/%40scope/name@1.2.3
pkg:golang/github.com/gin-gonic/gin@v1.9.1
pkg:deb/debian/openssl@3.0.11-1~deb12u2?arch=amd64&distro=debian-12
Common Platform Enumeration (CPE) is NVD’s naming, and it is a human-curated, frequently ambiguous 13-field string:
cpe:2.3:a:openssl:openssl:3.0.11:*:*:*:*:*:*:*
The problem: NVD indexes CVEs against CPEs, not PURLs. So a scanner holding a clean PURL for your component has to guess the CPE(s) NVD used — vendor and product strings that were typed by a human analyst, are not stable, and frequently collide (apache:log4j vs apache:log4j2 vs apache:logging-log4j). A single wrong token in that guess flips a finding between “missed entirely” and “reported against the wrong product.”
This is why two scanners run over the identical SBOM disagree. Grype, for language ecosystems, prefers GitHub Security Advisory (GHSA) and OSV data keyed by PURL + version range, and only falls back to CPE matching — and the CPE fallback is precisely where the noisy false positives come from. Trivy makes different fallback choices. Neither is “wrong”; they are resolving the PURL↔CPE ambiguity differently.
The situation degraded further in 2024 when NVD’s CPE enrichment pipeline developed a large backlog: thousands of CVEs sat without the CPE applicability data (configurations) that CPE-based matchers depend on. If your matching strategy leaned on NVD’s CPE dictionary, your coverage silently dropped for a period, with no error and no signal on the dashboard.
Practical consequence: you need to know, per finding, which data source and match type produced it. Grype exposes this — treat cpe-match findings as lower-confidence than exact-direct-match from GHSA/OSV, and route them differently in triage.
# surface the match provenance, not just the CVE list
grype myorg/api@sha256:<digest> -o json \
| jq '.matches[] | {cve: .vulnerability.id,
type: .matchDetails[0].type,
source: .matchDetails[0].matcher}'
# type = "exact-direct-match" | "cpe-match" | "exact-indirect-match" ...
Version-range comparison is not string comparison
Even with correct identity, “is version X in the vulnerable range” is nontrivial. Advisories express ranges in ecosystem-specific semantics — npm’s semver with ^/~/pre-release precedence, Debian’s dpkg --compare-versions with epochs and ~ sorting before everything, Python PEP 440 with its own ordering. The vers spec (vers:npm/>=1.0.0|<2.0.0) exists to normalize this, but the matcher still has to implement each scheme’s comparison rules correctly. Debian epoch handling and RPM release fields are common places where naive implementations produce off-by-one range errors.
Where components come from — and where they vanish
An SBOM is a snapshot of one artifact at one moment. Teams treat it as ground truth for “what we run.” The two diverge structurally:
Build-time vs. runtime. An SBOM built from package.json or a lockfile captures declared app dependencies and nothing else. The shipped container adds a base-OS layer, packages installed during image build, and anything an entrypoint fetches. OS-layer packages are where a large share of exploitable CVEs actually live. Generate against the pushed artifact, addressed by digest:
syft registry:myorg/api@sha256:<digest> -o cyclonedx-json > api.cdx.json
How catalogers actually find things — and their blind spots. Tools like Syft run per-ecosystem catalogers: parse package-lock.json, go.sum, RPM/dpkg databases, etc. What defeats them:
- Statically linked binaries. A Go binary embeds module info in a way
go version -m ./bin(and Syft’sgo-module-binarycataloger) can read — if it wasn’t stripped with-ldflags "-s -w"in a way that removes it. Rust embeds nothing by default; you needcargo auditableto inject a compressed dependency manifest into a custom ELF section. Statically linked C/C++ is effectively opaque — the dependency graph is dissolved into the binary with no metadata to recover. Your SBOM will simply not contain those components. - Distroless /
scratchimages. No package database, so file-based OS catalogers find nothing. The base layer contributes real code and zero SBOM entries. - Vendored / copy-pasted source. A file lifted from an upstream project carries its vulnerabilities but no package manifest, so no cataloger attributes anything to it. This is a true blind spot, not a low coverage percentage.
- Multi-stage build leakage and layer squashing. Build-only tooling can leave artifacts in the final image; conversely, squashing can hide provenance. Neither is modeled by a lockfile-based SBOM.
CycloneDX vs. SPDX: pick for the graph, not the logo
Both formats carry components; the operational difference is the dependency graph and how you’ll query it. CycloneDX expresses edges in a dependencies array (ref → dependsOn), which maps cleanly onto “compute the blast radius of component X.” SPDX uses relationships with a rich relationship-type vocabulary (DEPENDS_ON, CONTAINS, STATIC_LINK, DYNAMIC_LINK, GENERATED_FROM) that captures how a component is included — which is exactly the signal reachability wants. If you’re going to do downstream graph analysis, choose the format for its relationship model and normalize early; don’t let format be an accident of which tool shipped a default.
Miss #2: A CVE count is not a risk measurement
A mid-sized service image routinely surfaces hundreds to low-thousands of findings. Treating that as a work queue is how programs collapse. The exploitable subset is a small fraction, and the entire game is separating the two with defensible, persisted logic.
Reachability: from “the library is present” to “your code calls the sink”
A CVE means a vulnerable function exists somewhere in the transitive graph. It says nothing about whether your program reaches it. Reachability analysis answers that by constructing a call graph from your entry points down to the vulnerable symbol (the “sink”) and asking whether a path exists.
The mechanics — and why it’s hard:
- Static call-graph construction uses techniques like Class Hierarchy Analysis (CHA), Rapid Type Analysis (RTA), or points-to analysis. Each trades soundness against precision: CHA over-approximates (few false negatives, many false positives on virtual dispatch); points-to is precise but expensive and doesn’t scale trivially to large graphs.
- Dynamic dispatch, reflection, and DI frameworks break static graphs. Spring’s dependency injection, Java reflection, Go’s
interfacedispatch, and plugin loaders all create edges no static analyzer sees without framework-specific models. Under-model these and you get false negatives — the dangerous kind. - Interpreted / dynamic languages are worse. Python monkeypatching,
importlib, JavaScript’s dynamicrequire/import(), and duck typing mean the “call graph” is partly a runtime property. Reachability tools for these ecosystems are heuristic, and you should treat “unreachable” as “probably unreachable” rather than proof. - Native and FFI boundaries (JNI, cgo, Python C extensions) sever the graph entirely unless explicitly bridged.
Used honestly — as a prioritization signal, not a suppression oracle — reachability removes a large share of findings from the top of the queue. Used naively as auto-close, it will eventually hide a reachable-via-reflection sink and burn you.
VEX: the artifact that lets triage decisions survive the next scan
The failure mode that burns out teams is re-litigating the same 400 findings on every rescan. The Vulnerability Exploitability eXchange (VEX) exists to persist the statement “present but not exploitable, and here’s the machine-readable reason.” A not_affected status must carry one of a fixed set of justifications, and this constraint is the point — it forces auditable reasoning:
component_not_presentvulnerable_code_not_presentvulnerable_code_not_in_execute_pathvulnerable_code_cannot_be_controlled_by_adversaryinline_mitigations_already_exist
An OpenVEX statement:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"author": "secops@myorg",
"statements": [{
"vulnerability": { "name": "CVE-2024-XXXXX" },
"products": [{ "@id": "pkg:oci/api@sha256:<digest>" }],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "Affected parser is gated behind feature flag Y, disabled in this build."
}]
}
Be aware there are three competing VEX encodings — OpenVEX (standalone, minimal), CycloneDX VEX (embeddable in or alongside the SBOM), and the OASIS CSAF 2.0 VEX profile (richer, used by large vendors like Red Hat and Cisco for distribution). Pick one internal canonical form and translate at the boundaries; don’t try to store all three.
The structural insight: VEX is the mechanism that lets human judgment compound. Without it, every scan resets triage to zero.
Prioritization: severity is a property of the vuln, priority is a property of your deployment
CVSS base scores describe a vulnerability in the abstract; almost nobody populates the CVSS environmental metrics that would contextualize it. Bolt on two feeds that are nearly free:
- EPSS (Exploit Prediction Scoring System, FIRST): a daily-updated model emitting a probability [0,1] that a CVE will be exploited in the wild within 30 days. It re-sorts your backlog toward what attackers actually use.
- CISA KEV: a ground-truth catalog of vulnerabilities known to be exploited. A KEV hit is not a probability — it’s a “drop what you’re doing” flag.
A CVSS 9.8 in an offline batch job with no untrusted input is lower priority than a CVSS 6.5 with EPSS > 0.5 on your internet-facing auth path. Prioritization = severity × exposure × exploit-likelihood × reachability, not severity alone.
Miss #3: Integrity — proving the SBOM and the artifact belong together
The question that quietly undermines the whole program: how do you know the SBOM you scanned describes the binary in production? If an artifact can be swapped after SBOM generation, or an SBOM can be handed to you that doesn’t match its artifact, the apparatus is theater. This layer is the least implemented of the three.
in-toto attestations: the envelope, the statement, the predicate
The industry-standard structure is the in-toto attestation, wrapped in a DSSE (Dead Simple Signing Envelope). Three nested layers:
- DSSE envelope — the signed wrapper. It uses PAE (Pre-Authentication Encoding) so signatures bind the payload and its type, preventing type-confusion attacks.
- Statement — binds a claim to a specific artifact by digest:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{ "name": "myorg/api",
"digest": { "sha256": "<digest>" } }],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": { /* the actual claim */ }
}
- Predicate — the payload, typed by
predicateType. It might be a SLSA provenance predicate (how the artifact was built), a CycloneDX SBOM predicate (what’s inside it), or a VEX predicate. Because the SBOM travels inside a signed statement whose subject is the artifact digest, “does this SBOM describe this binary” becomes cryptographically checkable rather than a matter of faith.
SLSA levels: what “provenance” actually guarantees
SLSA v1.0’s Build track defines what the provenance is worth:
- L1 — provenance exists and is distributed. Tells you the claimed build process.
- L2 — provenance is signed and generated by a hosted build platform. Tampering after the fact is detectable.
- L3 — the build runs on a hardened, isolated, ephemeral platform such that the provenance is unforgeable even by someone who can submit builds. This is the level that resists a compromised build step forging its own attestation.
Most teams that “produce provenance” are at L1 and mistake it for L3. The jump is the isolation of the builder, not the existence of a JSON file.
Sigstore: keyless signing, and why the transparency log matters
Managing signing keys is where signing programs die. Sigstore removes long-lived keys:
- Fulcio issues a short-lived (~10-minute) X.509 certificate bound to an OIDC identity (e.g. your CI’s workload identity). You sign within that window; the cert expires before it can be stolen and reused.
- Rekor is an append-only, Merkle-tree transparency log. The signature and cert are recorded publicly, so “was this artifact ever signed by that identity” is auditable after the ephemeral key is long gone. The log’s inclusion proof is what makes short-lived keys workable.
# attach the SBOM as a signed attestation (keyless via CI OIDC)
cosign attest --predicate api.cdx.json --type cyclonedx \
myorg/api@sha256:<digest>
The control nobody wires up: verification at admission
Producing attestations is common; rejecting deployments that lack a valid one is rare. An attestation nobody verifies is a comment, not a control. Enforce at the cluster admission boundary with a policy engine (Sigstore policy-controller, Kyverno, or an OPA/Gatekeeper policy) that verifies the DSSE signature, the certificate identity, the OIDC issuer, and the predicate contents:
cosign verify-attestation --type cyclonedx \
--certificate-identity-regexp 'https://github.com/myorg/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
myorg/api@sha256:<digest>
A Kyverno / policy-controller rule then does the equivalent inside the cluster, blocking any image whose attestation is missing, unsigned, or signed by the wrong identity.
Inbound integrity: xz was a maintenance attack, not a CVE
The xz-utils backdoor wasn’t “a dependency with a CVE” — it was a dependency whose maintenance was socially engineered over years. No SBOM entry and no CVE feed would have flagged it before disclosure. The controls that address inbound risk are different in kind:
- OpenSSF Scorecard signals: is the project actively maintained, are commits reviewed, does it require signed releases, does it pin its own dependencies, does it run fuzzing.
- Digest pinning, not tag pinning. Pin container bases and dependencies by content digest; a tag is mutable and can be re-pointed. In language ecosystems, use hash-locked installs (
pip install --require-hashes,npm ciagainst a committed lockfile with integrity hashes,go.sum). - Prefer dependencies that publish provenance. A dependency shipping SLSA provenance is one you can verify rather than trust.
The operational miss underneath all three: point-in-time thinking
Every failure above shares a root cause — treating supply chain security as a build-time gate instead of a continuous property of a queryable inventory.
An SBOM built Tuesday is a historical document by Wednesday. No new code has to ship for your exposure to change: a CVE disclosed against a library you shipped six months ago instantly changes your risk, and your build pipeline will never re-run to notice. The correct architecture:
- Generate SBOMs at build, store them keyed by artifact digest in a platform that continuously re-evaluates the stored inventory against fresh vuln/KEV feeds — OWASP Dependency-Track is the common choice for this (ingest CycloneDX, portfolio-wide re-analysis on every feed update). An SBOM you can’t query across the fleet is a file, not a program.
- Model the relationships, not just the parts. GUAC (Graph for Understanding Artifact Composition) ingests SBOMs, SLSA attestations, and VEX into a graph so “which of our deployed services transitively depend on
pkg:...@vulnerable, and which have a VEX saying not-affected” is a single query — the difference between answering the next disclosure in minutes vs. days. - Carry VEX and prioritization forward so triage compounds instead of resetting.
- Verify provenance at admission, not just produce it at build.
The value was never the SBOM. It’s the ability to answer, in the ninety minutes after the next disclosure: where do we run this, does our code reach it, and can we prove what’s deployed — without a fire drill.
A more honest maturity model
| Level | What it looks like | What it buys you | The technical control that defines it |
|---|---|---|---|
| 0 | Scanner in CI, CVE dashboard | Compliance checkbox, alert fatigue | CPE-fallback matching, no dedup of match confidence |
| 1 | SBOM of the shipped artifact, stored by digest | You know what you run | Syft against the pushed image; digest-keyed inventory |
| 2 | Reachability + VEX + EPSS/KEV | You know what’s exploitable | Call-graph reachability as a prioritizer; OpenVEX persisted |
| 3 | Signed attestations verified at admission | You can prove what’s deployed | in-toto/DSSE, SLSA L2+, cosign verify in an admission policy |
| 4 | Continuous re-eval + graph + upstream health | You answer the next disclosure in minutes | Dependency-Track/GUAC, Scorecard, digest pinning |
Most programs that believe they’re “done” are at Level 0 with a nicer dashboard. The gap from 0 to 4 isn’t more scanning — it’s identity you can trust, exploitability you can defend, and integrity you can prove.
Where to start if this is you
- Move SBOM generation to the shipped artifact (image/binary by digest, not the repo). Highest leverage, often cheapest.
- Store SBOMs in a queryable, digest-keyed inventory (Dependency-Track). This turns the next zero-day into a query instead of spreadsheet archaeology.
- Split findings by match confidence — treat CPE-fallback matches differently from PURL/OSV exact matches. Instant noise reduction, zero new tooling.
- Add KEV + EPSS to prioritization. A feed join that re-sorts your whole backlog toward reality.
- Adopt one VEX format and persist triage so you stop re-litigating suppressed findings.
- Sign artifacts and verify at admission — warn-only first, then enforce.
- Layer in reachability and Scorecard/pinning once the above is stable.
The scanner and the SBOM generator were the on-ramp. The road is identity you can trust, exploitability you can defend, and integrity you can prove — and it’s the road almost everyone forgets to build.