← All guides

VEX: Vulnerability Exploitability eXchange

Build & artifacts
On this page

In plain terms

An SBOM says what your software is made of. A scanner says which known vulnerabilities touch those components. Neither answers the question your customers are asking, which is whether any of it matters.

VEX (Vulnerability Exploitability eXchange) is that answer, written down. For each vulnerability, a short machine-readable statement of whether it affects the product, and on what grounds.

{
  "vulnerability": { "name": "CVE-2021-44228" },
  "products": [ { "@id": "pkg:oci/acme/api@2.4.0" } ],
  "status": "not_affected",
  "justification": "vulnerable_code_not_in_execute_path"
}

Why it matters

Ship an SBOM without a VEX and you have published a list of every CVE anyone can match against your components. Most of them will not affect you, because the vulnerable function is never called, the feature is compiled out, the component is a build-time dependency that never reaches production. But nobody downstream can tell which.

So they ask. Every customer, separately, by email, about the same CVE, and somebody on your side answers from memory, inconsistently, for as long as that release is supported. VEX replaces that with one document their tooling applies without a human reading it.

The reverse case matters as much. A vulnerability that does affect you, stated plainly with what you are doing about it, is far better received than silence, and silence is what a bare SBOM communicates about everything in it.

The four statuses

StatusMeans
not_affectedThe vulnerability is present but cannot affect this product.
affectedIt does affect the product. An action statement says what is being done.
fixedA remediation has been applied.
under_investigationKnown, not yet concluded.

not_affected is the load-bearing one, and the specification does not let you assert it. You must give either a justification from a fixed vocabulary, component_not_present, vulnerable_code_not_present, vulnerable_code_not_in_execute_path, vulnerable_code_cannot_be_controlled_by_adversary, inline_mitigations_already_exist, or a prose impact statement. The closed list is the entire point, because a consumer can act on it without reading English.

Where it fits

VEX travels alongside an SBOM, not inside it. The SBOM describes the product; the VEX describes your assessment of it, and the two are updated on different rhythms. A new build regenerates the inventory, while a triage decision changes only the assessment.

Consumers apply it at scan time. Both Trivy and Grype accept a VEX document and drop the statements you have marked not_affected from their results, which is what makes the whole exchange worth doing. The noise disappears on their side, without anybody asking.

What a customer's scanner does with what you shipped On the left, a customer receives only an inventory. Their scanner matches every known vulnerability against the components it lists, and every customer separately writes to ask which of them matter. On the right, the same inventory arrives with an assessment beside it. Their scanner applies the assessment and removes the statements marked not affected before a person sees the results, so nobody writes. Both cases depend on the product identifier in the statement matching the one their own tooling uses. WHAT THEIR SCANNER DOES WITH WHAT YOU SHIPPED An SBOM on its own their scanner matches every known vulnerability against the components you listed Nothing in the list separates the ones that matter from the ones that do not, so the only way to find out is to ask you, and every customer asks separately. An SBOM with a VEX beside it their scanner reads your statements first and drops the ones you marked not_affected Trivy and Grype both do this unprompted. The noise is gone before a person sees it, and the ones that remain are the ones you wanted them to read. AND THE ONES THAT DO AFFECT YOU Saying so plainly, with what you are doing about it, reads far better than the silence an inventory alone communicates.
The exchange pays off because their tooling applies it with nobody in the loop, and a statement is worth writing for either answer, since what an inventory alone publishes is no answer at all.

How it works

The matching is by product identifier. A statement names a product, conventionally a package URL, and a consumer applies it when that identifier matches what their own SBOM calls the thing they are scanning. Get the identifier wrong and the document is read, understood, and applied to nothing.

Three dialects exist and all express the same idea:

DialectWhat it is
OpenVEXStandalone JSON, small, from the OpenSSF. Works whether or not you ship an SBOM.
CycloneDX VEXEmbedded in, or paired with, a CycloneDX BOM.
CSAF VEXThe OASIS standard, used by large vendors publishing security advisories.

Common pitfalls

The pitfallWhy it bites
Asserting not_affected without evidenceIt is a claim of safety, published over your name, that a customer will act on. Reachability analysis, a build flag, a configuration you enforce. The justification should point at something someone could check.
Generating it from triage prose after the fact"Not reachable" and "we are living with this until Q3" are both perfectly good reasons for setting a finding aside, and they are opposite VEX statuses. Anything that infers the status from free text will eventually publish the wrong one.
Letting it go staleA VEX describes a product version. When you ship a new one, the assessment has to be re-made, because a not_affected that was true in 2.3 says nothing about 2.4.
Overstating scopeA VEX statement covers what you assessed. If you have only reviewed the components you ship in the container, say so; a document implying you reviewed the whole dependency tree when you reviewed the top level is worse than a narrower one.
How Draugr fits. VEX generation ships today: --report vex emits an OpenVEX document from the run. Most of it is already in your descriptor, because a suppression in Draugr is not a delete. config.exclude requires a reason and records when the acceptance lapses, which is most of a VEX statement and the part most producers never captured.

Draugr will not guess the status from your prose. A finding nobody has triaged is published as under_investigation; a suppressed one as affected, carrying your reason. To claim not_affected you say so, in a vex: block on the exclusion. Overstating your exposure costs a reader some wasted triage; understating it tells a customer they are safe when they are not, so the default sits on the side you can survive being wrong on. See publish a VEX document.

Draugr also reads one. Point a component at a document a supplier published, whether a file, a URL, or a path inside their repository, and their analysis is applied to your findings, with their name on it. That is the part worth having, because a supplier's not_affected retyped into your own config becomes indistinguishable from a decision you made and are answerable for, while an imported one keeps the author, the document and the date it was asserted. Only not_affected and fixed excuse anything; a supplier saying you are affected changes nothing, and your own exclusions still win. See read a supplier's VEX.

Keep learning