Back to glossary

VEX (Vulnerability Exploitability eXchange)

Vulnerability Exploitability eXchange (VEX) is a machine-readable statement of whether a known vulnerability affects a specific product, and why.

Also known as OpenVEX, CSAF VEX, CycloneDX VEX, VEX document, VEX statement

VEX (Vulnerability Exploitability eXchange) is a machine-readable statement that says whether a specific product is affected by a specific vulnerability: not_affected, affected, fixed, or under_investigation. A not_affected status must carry a justification, such as the vulnerable code never executing. Scanners read VEX to suppress findings that do not apply. Common encodings are OpenVEX, CSAF VEX, and CycloneDX VEX.

VEX exists because of a gap in how scanners work. A scanner matches the versions in your dependency tree against the version ranges in advisories. It cannot see whether your product ever calls the vulnerable function, or with what input. Only someone with the product's code can answer that, and VEX turns their answer into a file other tools can read. VEX describes a product, not a package. In the example below, the same semver@7.5.1 is exploitable in one of our test apps and harmless in the other.

Our position: VEX is the right container for the output of reachability analysis, and every vendor that ships software built on open source should publish it. A not_affected statement is still a claim, not proof. Consumers should accept the justifications a tool can check, ask for evidence behind the ones it cannot, and remember that VEX only speaks about vulnerabilities that have IDs. A malicious package has no CVE, so no VEX document will ever mention it.

What is VEX (Vulnerability Exploitability eXchange)?

#

VEX is a format for one kind of assertion: this product, this vulnerability, this status. The OpenVEX specification writes it as a formula: statement = product(s) + vulnerability + status. A VEX document groups statements under an author, a timestamp, and a version number that must increase whenever any statement changes.

Time is part of the model. The OpenVEX spec says VEX "is designed to be a sequence of statements, each overriding, but also enriching the previous ones with new information." A product can be under_investigation on Monday and not_affected on Wednesday, and a consumer should act on the newest statement.

VEX came out of the US NTIA's software transparency process. NTIA's one-page summary, dated September 27, 2021, states the goal as "To reduce effort spent by users investigating non-exploitable vulnerabilities." A CISA-coordinated working group then published the use cases in April 2022, the five status justifications in June 2022, and the Minimum Requirements for VEX in April 2023. CISA lists four implementations of that minimum, compared below.

The four statuses each carry a different obligation:

StatusMeaningRequired with itWhat a VEX-aware scanner does
not_affectedNo remediation needed in this productA justification label or a free-text impact statementSuppresses the finding
affectedThe product is exploitableAn action statement: upgrade, mitigate, or no fix plannedKeeps the finding
fixedThis product version contains the fixNothing extraSuppresses the finding
under_investigationNot yet knownNothing extra; a later statement should replace itKeeps the finding

The minimum requirements also say VEX must be able to stand alone. A VEX statement can live inside an SBOM or an advisory, but it cannot depend on either one to make sense.

Why version matching is not exploitability

#

A scanner joins two datasets. The first is an inventory of what is installed, read from a lockfile, an SBOM, or the file system. Most of that inventory is transitive dependencies nobody on the team chose. The second is a set of advisories: CVE records and ecosystem databases such as GHSA and OSV, each listing affected version ranges. If an installed version falls in a range, the scanner reports a finding.

That join is a version comparison, and nothing more. The scanner cannot know whether your code calls the vulnerable function, whether attacker-controlled data reaches it, or whether the vulnerable file was even bundled. Those facts live in the product, so every customer of the product repeats the same triage.

The volume makes that repetition expensive. As we reported in NIST's plan for an AI-enabled NVD overhaul, the NVD had received 53,115 new CVEs in 2026 as of August 17. Most of those matches do not matter in a given application. Socket's full application reachability typically marks about 80% of vulnerabilities as unreachable. VEX lets the party who did that analysis publish the result once, so each downstream user does not redo it. In that sense VEX is the exchange layer of vulnerability prioritization.

Figure 1. A scanner can only match versions against advisory ranges. VEX carries the product owner's answer to the question the match cannot settle.

Figure 1 in text: an inventory (an SBOM or lockfile listing pkg:npm/semver@7.5.1) and an advisory (affected range >=7.0.0 <7.5.2) feed a scanner. The scanner reports CVE-2022-25883 because 7.5.1 falls in the range, but it cannot see how the product calls new Range(). A VEX statement from whoever knows the product is matched to that finding by vulnerability ID or alias, and by the product or subcomponent purl. A not_affected statement, which needs a justification or impact statement, suppresses the finding. A fixed statement, meaning this product version contains the fix, also suppresses it. An affected statement, which needs an action statement, keeps the finding. An under_investigation statement keeps it until a later statement replaces it.

How does VEX work with an SBOM?

#

VEX works with an SBOM by pointing at the same component identifiers. The SBOM says pkg:npm/semver@7.5.1 ships inside a product. A VEX statement naming that same package URL (purl) as a subcomponent says whether its known vulnerabilities matter in that product. The SBOM answers what is inside; the VEX answers which known flaws in those parts are real.

There are two layouts. CycloneDX can carry VEX inside the BOM's vulnerabilities array, or in a separate VEX BOM that references the SBOM's components through BOM-Link URNs. OpenVEX and CSAF are standalone documents that reference products by purl or CPE.

We recommend keeping VEX separate from the SBOM. An SBOM describes a build and is fixed once that build ships. VEX keeps changing after release, as triage finishes and new CVEs land against old versions. Embedding VEX in the SBOM forces you to reissue the inventory every time your analysis changes.

The weak point is identifiers. A VEX statement only suppresses a finding if the consumer can match the product and the vulnerability exactly. Purls are precise for packages, CPEs are coarse for products, and advisories disagree on IDs. The OpenSSF's January 2026 report on the state of VEX puts it bluntly: "Every ecosystem has its own way of naming things (PURLs, CPEs, hashes)." The worked example below shows the failure in practice.

One CVE, two products: a worked example

#

CVE-2022-25883 is a regular expression denial of service (ReDoS) in npm's semver package. The GitHub advisory says versions before 7.5.2 are vulnerable "via the function new Range, when untrusted user data is provided as a range." That condition is exactly what VEX exists to express.

We installed semver@7.5.1 and ran npm audit (npm 10.9.9, November 3, 2026). The output, trimmed:

JSON
{
  "semver": {
    "severity": "high",
    "isDirect": true,
    "via": [{
      "source": 1112921,
      "title": "semver vulnerable to Regular Expression Denial of Service",
      "url": "https://github.com/advisories/GHSA-c2qf-rxjj-qqgw",
      "cvss": { "score": 7.5, "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" },
      "range": ">=7.0.0 <7.5.2"
    }],
    "fixAvailable": { "name": "semver", "version": "7.8.6", "isSemVerMajor": false }
  }
}

The bug is real. We timed new semver.Range('1.2.3' + ' '.repeat(n) + '<') on Node 22.23.4:

Spaces in range string (n)semver 7.5.1semver 7.5.2
10028.1 msunder 2 ms
200378.0 ms1.6 ms
3001,866.5 msunder 2 ms
4005,920.5 msunder 2 ms
50014,736.6 ms1.8 ms
100,000not run1.9 ms

The cost grows roughly with the fourth power of the input length. A 506-character range string blocks Node's single thread for about 15 seconds on 7.5.1 and about 2 milliseconds on 7.5.2.

Now two products that both depend on semver@7.5.1. Product A checks the Node version at startup:

JavaScript
const semver = require('semver');
if (!semver.satisfies(process.versions.node, '>=18.0.0')) process.exit(1);

Product B lets an HTTP client choose the range:

JavaScript
const http = require('http');
const semver = require('semver');
http.createServer((req, res) => {
  const range = new URL(req.url, 'http://x').searchParams.get('range');
  res.end(String(semver.satisfies('1.2.3', range)));
}).listen(3000);

Both call the vulnerable code: satisfies() constructs a Range on every call. A call graph marks both as reachable. The difference is who controls the argument. In product A it is a constant. In product B it is the query string. We sent B a single request with a 506-character range and it took 15.2 seconds to respond, while a normal request took 7 milliseconds. Every other request to B waited behind it.

The owner of product A can publish this OpenVEX document. It validates against the OpenVEX v0.2.0 JSON schema, which rejects a not_affected statement with neither a justification nor an impact statement:

JSON
{
  "@context": "https://openvex.dev/ns/v0.2.0",
  "@id": "https://example.com/vex/acme-api-2026-11-03",
  "author": "Acme Security <security@example.com>",
  "timestamp": "2026-11-03T16:00:00Z",
  "version": 1,
  "statements": [
    {
      "vulnerability": {
        "name": "CVE-2022-25883",
        "aliases": ["GHSA-c2qf-rxjj-qqgw"]
      },
      "products": [
        {
          "@id": "pkg:npm/acme-api@3.4.0",
          "subcomponents": [{ "@id": "pkg:npm/semver@7.5.1" }]
        }
      ],
      "status": "not_affected",
      "justification": "vulnerable_code_cannot_be_controlled_by_adversary",
      "impact_statement": "new Range() is only called with the constant '>=18.0.0'; no request data reaches semver."
    }
  ]
}

Product B's owner should publish affected, and the schema then requires an action_statement, such as "Upgrade semver to 7.5.2 or later."

As of npm v12, npm audit has no option to read VEX or any exception file. To apply the document, we wrote the matching step every VEX-aware scanner performs: index statements by vulnerability ID and alias plus subcomponent purl, then look up each finding.

Python
import json, sys
audit = json.load(open(sys.argv[1]))
vex = json.load(open(sys.argv[2]))

status = {}
for st in vex["statements"]:
    ids = [st["vulnerability"]["name"], *st["vulnerability"].get("aliases", [])]
    for prod in st["products"]:
        for sub in prod.get("subcomponents", []):
            for vid in ids:
                status[(vid, sub["@id"])] = st

lock = json.load(open("package-lock.json"))["packages"]
for name, v in audit["vulnerabilities"].items():
    for node in v["nodes"]:
        purl = f"pkg:npm/{name}@{lock[node]['version']}"
        for adv in (a for a in v["via"] if isinstance(a, dict)):
            ghsa = adv["url"].rsplit("/", 1)[-1]
            st = status.get((ghsa, purl))
            verdict = st["status"] + " / " + st.get("justification", "") if st else "no VEX statement"
            print(f"{purl}  {ghsa}  {adv['severity']}  ->  {verdict}")
Bash
$ python3 apply_vex.py audit.json acme-api.openvex.json
pkg:npm/semver@7.5.1  GHSA-c2qf-rxjj-qqgw  high  ->  not_affected / vulnerable_code_cannot_be_controlled_by_adversary

# Same document with the "aliases" line deleted:
$ python3 apply_vex.py audit.json acme-api-no-alias.openvex.json
pkg:npm/semver@7.5.1  GHSA-c2qf-rxjj-qqgw  high  ->  no VEX statement

The second run is the identifier problem in two lines. npm audit reports the GHSA ID. The author wrote the CVE ID. Without the alias, a correct VEX statement silently fails to apply, and the finding stays open.

Which not_affected justification applies?

#

A not_affected justification comes from a fixed list of five labels, and they differ in how hard they are to verify. We order them from the claim a tool can check to the claim only a person can defend, and we recommend picking the first one that holds.

Figure 2. The five not_affected justifications, ordered from the claim a tool can check to the claim only a person can defend.

Figure 2 in text, in order. First, if the vulnerable component is not in the product at all, the label is component_not_present, and the SBOM or lockfile is the evidence. Second, if the component ships but the vulnerable code does not (compiled out, tree-shaken, or a Go package that is never imported), the label is vulnerable_code_not_present, and the build output is the evidence. Third, if the product can never call the vulnerable function, the label is vulnerable_code_not_in_execute_path, and a call graph from every entry point is the evidence. A tool can check each of these first three. Fourth, if the code runs but an attacker cannot control its input, the label is vulnerable_code_cannot_be_controlled_by_adversary, which takes data-flow reasoning about every caller. Fifth, if built-in protections stop every known exploit and no user can disable them, the label is inline_mitigations_already_exist, which is an argument about the mitigation. The last two are judgment calls. If none of the five holds, the status is affected, with an action statement.

Product A lands on the fourth rung. Call-graph reachability cannot produce that answer, because the vulnerable function does run. Proving that the argument is always a constant takes data-flow analysis or a human reading the code.

Automated VEX generators stay on the lower rungs for this reason. Go's govulncheck -format openvex emits only vulnerable_code_not_present and vulnerable_code_not_in_execute_path, and it writes the product @id as the literal string "Unknown Product". Tools can compute reachability. Only the producer knows what the product is called.

The OpenVEX spec flags the upper rungs itself. Of the last two labels it says: "This justification could be difficult to prove conclusively." Consumers should treat the five labels differently. Accept the first three from a producer you trust. For the last two, require an impact_statement that names the specific caller or mitigation.

OpenVEX vs CSAF VEX vs CycloneDX VEX vs SPDX 3.0

#

Four formats implement the CISA minimum, and they disagree on vocabulary:

OpenVEX v0.2.0CSAF 2.0 VEX profileCycloneDX 1.7SPDX 3.0
ContainerStandalone JSON-LD documentStandalone advisory, category csaf_vexInside a BOM or a separate VEX BOMRelationships in the Security profile
Status namesnot_affected, affected, fixed, under_investigationknown_not_affected, known_affected, fixed, under_investigationnot_affected, exploitable, resolved, in_triage (plus false_positive, resolved_with_pedigree)doesNotAffect, affects, fixedIn, underInvestigationFor relationships
not_affected justificationsCISA's five labelsCISA's five labels, as flagsNine CycloneDX-specific valuesCISA's five labels, in camelCase
Required for not_affectedjustification or impact_statement (schema-enforced)A flag, or a threats entry of category impactA justification is recommended, not requiredjustificationType or impactStatement
Required for affectedaction_statement (schema-enforced)A remediation entry as the action statementNothing beyond the stateactionStatement (required)
Best forBuild pipelines and container images; easy to emit and signVendors publishing advisories at scaleTeams that already produce CycloneDX SBOMsTeams standardized on SPDX 3.0

The status names map one to one. The justifications do not. OpenVEX, CSAF, and SPDX 3.0 share CISA's five labels. CycloneDX defines nine of its own (code_not_present, code_not_reachable, requires_configuration, requires_dependency, requires_environment, protected_by_compiler, protected_at_runtime, protected_at_perimeter, protected_by_mitigating_control), and none of them means "the attacker cannot control the input." Our product A statement validates in CycloneDX 1.7 only by dropping the justification and keeping the reasoning as free text:

JSON
"analysis": {
  "state": "not_affected",
  "detail": "new Range() is only called with the constant '>=18.0.0'; no request data reaches semver."
}

Any converter between CycloneDX and the other three formats loses information in one direction or the other. Keep the original document, and treat converted VEX as a summary of it.

Verdict: publish OpenVEX if you ship containers or libraries and want something your build can emit and sign. Publish CSAF VEX if you are a vendor issuing advisories at scale, or if customers' regulatory processes expect CSAF. Use CycloneDX VEX only if you already produce CycloneDX SBOMs, and then as a separate VEX BOM. Consumers should accept all of them.

Who publishes and consumes VEX

#

As of November 2026, the producers are mostly operating system and image vendors. Red Hat publishes CSAF VEX for its products and announced a restructured feed in April 2026 with per-stream product IDs. Microsoft began publishing CSAF VEX for third-party CVEs on October 22, 2025, starting with Azure Linux. Docker Hardened Images ship with OpenVEX alongside their SBOMs and signatures.

On the consuming side, Grype applies --vex documents and moves not_affected and fixed matches to its ignored list by default. It reports affected and under_investigation statements only when configured with vex-add. The OpenSSF report found adoption uneven enough to conclude that "VEX feels more like a promise than a practice," and named discovery, trust, and identifier matching as the main blockers.

Socket generates VEX from reachability. The Export OpenVEX Document (Beta) API turns a full scan into an OpenVEX v0.2.0 document with one statement per artifact and vulnerability pair. Unreachable vulnerabilities become not_affected with a justification, reachable ones become affected, ones addressed by an applied Socket patch become fixed, and unknown or pending results become under_investigation. Since a June 2026 change, each statement's timestamp is the original scan time rather than the export time. Our reachability analysis is intentionally over-approximating, so product A would come out affected. That is the error we choose. A false affected costs a reviewer some time. A false not_affected hides a live bug from everyone downstream.

What VEX cannot tell you

#

VEX is only as trustworthy as its author. The OpenVEX spec says the author's identity "SHOULD be cryptographically associated with the signature," but the OpenSSF report found that many consumers trust VEX files based on where they are hosted. The report also sets the right boundary: "VEX should describe exploitability, not serve as a policy tool for ignoring issues." An unsigned not_affected from a party that benefits from fewer findings deserves the same scrutiny as a waiver.

A VEX statement also goes stale. It is true for one product version. If someone later refactors product A so a request parameter reaches semver.satisfies(), the old statement becomes wrong without anyone editing it. Bind statements to exact product versions with purls or hashes, regenerate them on every release, and do not carry not_affected forward by default.

VEX does nothing for supply chain attacks that do not get a CVE. Its unit is a vulnerability ID. A compromised package that runs a credential stealer at install time has no CVE, executes before any scanner reads a VEX file, and is invisible to the whole SBOM-plus-VEX pipeline. Defending against that requires analyzing what a package actually does before it is installed.

The open problem is not the schema. The party best placed to answer "are we affected?" is also the party that benefits most from answering "no." VEX becomes trustworthy when each not_affected ships with evidence a machine can re-check, such as a signed call graph or a reproducible test. Until then, read VEX like a vendor's changelog: specific, useful, and written by the party being graded.