An SBOM lists every component you ship. VEX declares which CVEs from those components do not affect you - and why. The gap between them is where supply-chain noise lives.
An SBOM tells you what components are in your application. VEX tells a machine which CVEs from those components do not affect you - and why. The gap between "this CVE exists in a component we import" and "this CVE is reachable and exploitable in our product" is where most supply-chain noise lives. Closing it requires proof, not a reviewer's note.
A Software Bill of Materials is a structured, machine-readable inventory of every component in a release artifact: name, version, origin, content hash, license, and relationship to the parent. Formats converge around CycloneDX and SPDX. An SBOM generated at build time from the resolved dependency graph captures the actual transitive tree - not the declared tree, which can diverge under version pinning and workspaces.
What an SBOM does not tell you is whether any of those components matter. A transitive dependency that sits in node_modules but is never imported by any reachable code path appears in the SBOM identical to a dependency your critical payment handler calls on every request. Every CVE scanner that takes the SBOM at face value conflates them - which is where the noise originates.
Without VEX, a CVE in a component creates a binary: the CVE is present or it is not. Every tool downstream treats "present" as "exploitable" unless a human says otherwise, and that human comment is not machine-readable, not signed, not attached to the artifact, and not carried into the next pipeline run. The reviewer's verdict disappears the moment the ticket closes.
VEX (Vulnerability Exploitability eXchange) replaces that silence with a signed, traceable assertion per CVE per artifact version. The four VEX statuses are not_affected, affected, fixed, and under_investigation. A not_affected assertion requires a justification that a machine can validate: component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, or inline_mitigations_already_exist. Each justification class carries a different proof requirement.
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"vulnerabilities": [{
"id": "CVE-2024-31234",
"affects": [{ "ref": "pkg:npm/unused-lib@2.1.0" }],
"analysis": {
"state": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"detail": "Call-graph analysis: no entry point reaches parseUntrustedXml(). "
"Confirmed by apPosture reachability pass on commit a3f9b12."
}
}]
}The statement is co-located with the SBOM, versioned to the artifact, and consumable by downstream tooling without a human in the loop. A VEX consumer can suppress the CVE from a report automatically - but only for the specific version and justification class declared. A new version of the artifact needs a new analysis pass; the assertion does not carry forward silently.
A VEX assertion backed by "we reviewed it and it is fine" is an opinion. An assertion backed by call-graph reachability analysis is a fact - and the difference matters when an auditor asks you to substantiate a not_affected claim six months after the release.
Reachability analysis builds the static call graph for the resolved dependency tree: every function import, every dynamic dispatch that can be resolved statically, every entry point (HTTP handler, CLI command, event listener). The question for each vulnerable function is whether any path from any entry point can reach it. "Can reach" is the conservative bound - if the path exists even if guarded by a runtime condition that looks impossible, the finding stays open. Only a path that structurally cannot exist justifies vulnerable_code_not_in_execute_path.
The contrast is what makes the system defensible. A CVE in a component that is structurally unreachable gets a machine-readable not_affected assertion with a traceable justification. A CVE in a component that sits on a taint path from a user-controlled entry point gets a finding, a severity, and an SLA - not a note saying "we will look at it."
Generating an SBOM once, at release, is a starting point. The posture that survives an audit is one where the SBOM is generated per build artifact, enriched against live CVE feeds (OSV, NVD, vendor advisories), reachability is run on the same commit, and a VEX document is emitted with every not_affected assertion traceable to the reachability result that proved it. The chain must be unbroken: artifact hash -> SBOM -> VEX -> reachability evidence -> commit.
| Format | Primary use | VEX support | Tooling fit |
|---|---|---|---|
| CycloneDX | Security-first; vulnerability metadata native | First-class in spec v1.4+ | Strong: grype, trivy, apPosture |
| SPDX | License compliance, NTIA minimum elements | Via external VEX doc (OpenVEX) | Broad: GitHub, FOSSA, FOSSology |
| SWID | Enterprise software inventory (ISO 19770-2) | Not native; requires tooling bridge | Narrow: mostly government/enterprise |
An air-gapped pipeline does not need to phone out to OSV at scan time. A curated CVE cache updated on a controlled schedule carries the same enrichment without an outbound connection - which is both a data-sovereignty requirement and a supply-chain integrity point. The tool that enriches your SBOM must not itself be a soft target.
The posture question an SBOM answers is "what is here." The posture question a VEX-backed reachability pass answers is "which of what is here can actually hurt you." Both are required; neither replaces the other. Together they close the loop between a CVE advisory and an evidence-backed decision about whether your specific artifact is affected - and they do it in a form a machine can consume, an auditor can verify, and a pipeline gate can enforce.