A distroless container image built on gcr.io/distroless/java17-debian12 will typically generate 400–600 CVE findings the first time you run Grype or Trivy against it. The overwhelming majority — often 95% or more — are not exploitable in your deployment context. The vulnerable code path does not exist in the stripped-down image. The feature requiring the library is compiled out. The CVE affects a Windows-only code path. The package is present but its vulnerable function is never called by any reachable code.
Security teams that treat all 500 findings as equivalent will spend their time triaging noise rather than remediating real risk. VEX — Vulnerability Exploitability eXchange — is the mechanism for encoding and communicating these exploitability determinations in a machine-readable format that scanners and vulnerability management platforms can consume.
The Problem VEX Solves
Vulnerability scanners work by matching component versions in an SBOM against CVE databases. A match means the component version falls within the affected range — it does not mean the vulnerability is reachable, exploitable, or even relevant in your specific build and deployment configuration.
Consider libssl3 appearing in a distroless image. The image might contain the library only because it is a transitive runtime dependency of the JVM. A CVE affecting OpenSSL’s DTLS implementation is present in the version range. But your application uses neither DTLS nor any direct TLS socket code — it runs behind an Envoy sidecar that terminates TLS. The CVE match is technically accurate and entirely irrelevant to your risk posture.
At scale, this noise creates three concrete problems. Triage fatigue causes engineers to begin ignoring scanner output entirely. CI gates that block on any CVE become roadblocks that teams learn to bypass. And genuinely critical vulnerabilities — the ones affecting code paths that are reachable — get buried in lists of hundreds of informational findings.
SBOMs tell you what is present. VEX documents tell you whether what is present is actually exploitable. They are complementary: you need an SBOM to know what components exist before you can make exploitability assertions about specific CVEs against specific components. See SBOM generation and consumption for the foundational SBOM workflow.
VEX Status Values
VEX defines four status values that cover the complete lifecycle of a CVE finding against a specific product version.
not_affected — The product is not affected by the vulnerability, despite containing a version of the component that falls in the vulnerable range. This is the most commonly asserted status and covers the majority of distroless image noise. The assertion must include a justification.
affected — The product is confirmed to be affected. This assertion is often paired with remediation information and a timeline.
fixed — A fix is available. The product previously had this vulnerability, and the identified version or patch resolves it.
under_investigation — The vendor or operator is actively determining exploitability status. This is a transitional state, not a terminal one, and should carry a timestamp indicating when the investigation began.
The not_affected status requires a justification code to prevent the status from becoming a blanket suppression mechanism with no accountability:
component_not_present— The component identified in the CVE is not actually present in the product (e.g., the SBOM match was spurious).vulnerable_code_not_present— The specific vulnerable code path does not exist in this build (e.g., it was compiled out, or the affected function is not included in the binary).vulnerable_code_not_in_execute_path— The vulnerable code is present but is never reached during execution given the product’s architecture and configuration.vulnerable_code_cannot_be_controlled_by_adversary— The vulnerable code runs in a context where an attacker cannot influence its inputs.inline_mitigations_already_exist— Compensating controls (WAF rules, network segmentation, seccomp profiles) make exploitation infeasible.
OpenVEX Format
OpenVEX is a lightweight JSON-LD specification developed by Chainguard and maintained under the OpenVEX project on GitHub. It defines a document structure containing one or more statements, each asserting a status for a specific CVE against a specific product.
A minimal OpenVEX document for a not_affected assertion looks like this:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@type": "OpenVEXDocument",
"author": "security@example.com",
"role": "Operator",
"timestamp": "2026-05-09T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"@id": "https://www.cve.org/CVERecord?id=CVE-2024-12345",
"name": "CVE-2024-12345",
"description": "Buffer overflow in OpenSSL DTLS implementation"
},
"products": [
{
"@id": "pkg:oci/myapp@sha256:4a7f2c9e3b1d8f6a0e5c2d7b4a8f1c3e9d6b2a7f4c1e8b5d2a9f6c3b0e7d4a"
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "Application does not use DTLS. TLS termination is handled by the Envoy sidecar. libssl3 is present as a transitive JVM dependency only.",
"action_statement_timestamp": "2026-05-09T00:00:00Z"
}
]
}
The products field uses Package URLs (purls) or OCI digest references to identify the specific product version the assertion applies to. Asserting against an OCI digest rather than a tag ensures the statement remains bound to an exact image version, not a mutable pointer.
A single OpenVEX document can contain multiple statements, and multiple CVEs can appear in the same document. Where a single CVE has different exploitability status across different product versions, it appears as separate statements.
For an affected status where a patch is not yet available:
{
"vulnerability": {
"@id": "https://www.cve.org/CVERecord?id=CVE-2024-99999",
"name": "CVE-2024-99999"
},
"products": [
{
"@id": "pkg:oci/myapp@sha256:4a7f2c9e3b1d8f6a0e5c2d7b4a8f1c3e9d6b2a7f4c1e8b5d2a9f6c3b0e7d4a"
}
],
"status": "affected",
"action_statement": "Upgrade to myapp 2.4.1 when released. Interim mitigation: restrict network access to port 8080 to trusted CIDR ranges only.",
"action_statement_timestamp": "2026-05-09T00:00:00Z"
}
CycloneDX VEX
CycloneDX supports VEX in two forms: embedded within an SBOM document, and as a standalone VEX document that references an existing SBOM by BOM-ref.
In the embedded form, vulnerabilities and their exploitability assessments appear in the vulnerabilities array of a standard CycloneDX BOM:
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
"version": 1,
"vulnerabilities": [
{
"id": "CVE-2024-12345",
"source": {
"name": "NVD",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2024-12345"
},
"ratings": [
{
"source": { "name": "NVD" },
"score": 7.5,
"severity": "high",
"method": "CVSSv3"
}
],
"affects": [
{
"ref": "pkg:deb/debian/libssl3@3.0.13-1~deb12u1",
"versions": [
{
"version": "3.0.13-1~deb12u1",
"status": "affected"
}
]
}
],
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"response": ["will_not_fix"],
"detail": "Application does not invoke DTLS code paths. TLS terminated externally by Envoy."
}
}
]
}
The standalone CycloneDX VEX document is structurally identical to the embedded form but omits the components array and references the associated SBOM via the externalReferences field. This is the preferred approach for operator-generated VEX: the vendor ships the SBOM, the operator separately generates VEX assertions reflecting their deployment context, and both documents are stored and versioned independently.
The tradeoff between embedded and standalone is straightforward. Vendors shipping SBOMs with embedded VEX assertions get atomic distribution — one document contains both the component inventory and the vendor’s exploitability claims. Operators consuming those SBOMs add their own contextual VEX as standalone documents that do not require modifying vendor-supplied artifacts.
Creating VEX Documents with vexctl
vexctl is the reference CLI for creating, merging, and attesting OpenVEX documents. Install it from the Chainguard releases or via go install:
go install github.com/openvex/vexctl@latest
Create a not_affected assertion for a specific CVE against a container image:
vexctl create \
--author "security@example.com" \
--product "pkg:oci/myapp@sha256:4a7f2c9e3b1d8f6a0e5c2d7b4a8f1c3e9d6b2a7f4c1e8b5d2a9f6c3b0e7d4a" \
--vuln "CVE-2024-12345" \
--status "not_affected" \
--justification "vulnerable_code_not_in_execute_path" \
--impact "Application does not use DTLS. TLS is terminated by the Envoy sidecar."
The output is a JSON OpenVEX document that you can redirect to a file:
vexctl create \
--author "security@example.com" \
--product "pkg:oci/myapp@sha256:4a7f2c9e..." \
--vuln "CVE-2024-12345" \
--status "not_affected" \
--justification "vulnerable_code_not_in_execute_path" \
> CVE-2024-12345.vex.json
When you have accumulated multiple per-CVE VEX files and need a single document for scanner consumption, use vexctl merge:
vexctl merge \
--product "pkg:oci/myapp@sha256:4a7f2c9e..." \
CVE-2024-12345.vex.json \
CVE-2024-67890.vex.json \
CVE-2024-11111.vex.json \
> myapp-vex-2026-05-09.vex.json
The merge operation consolidates statements and resolves conflicts by preferring the most recent timestamp. If the same CVE appears in multiple input files with different statuses, the merge emits a warning and uses the most recent action_statement_timestamp.
For attesting VEX documents to an OCI registry alongside the image (so they are co-located and discoverable):
vexctl attest \
--attach \
--sign \
myapp-vex-2026-05-09.vex.json \
myapp@sha256:4a7f2c9e...
This uses Cosign under the hood to attach the VEX document as an OCI attestation with the https://openvex.dev/ns predicate type.
Consuming VEX in Grype
Grype 0.65+ supports the --vex flag, which accepts a path to an OpenVEX or CycloneDX VEX document and filters scan results accordingly. Findings with a not_affected or fixed status in the VEX document are suppressed from output.
grype \
myapp@sha256:4a7f2c9e... \
--vex myapp-vex-2026-05-09.vex.json \
--fail-on high
Without VEX, this might report 340 findings across all severities, with 12 classified as high. With the VEX document asserting not_affected for 320 of those findings, you get 20 findings, with 2 genuine highs that require actual remediation.
The --vex flag can be specified multiple times to load multiple VEX documents:
grype myapp@sha256:4a7f2c9e... \
--vex vendor-supplied.vex.json \
--vex operator-context.vex.json \
--fail-on critical
Grype matches VEX statements to scan results using the product identifier. For OCI image scans, the digest in the VEX products field must match the scanned image digest exactly. A VEX document asserting status against myapp:latest will not match a scan of myapp@sha256:4a7f2c9e.... Always use digest references in VEX documents intended for automated consumption.
You can verify how many findings a VEX document suppresses before applying it to a gate:
grype myapp@sha256:4a7f2c9e... -o json | jq '.matches | length'
grype myapp@sha256:4a7f2c9e... --vex myapp-vex.json -o json | jq '.matches | length'
OWASP Dependency-Track VEX Import
Dependency-Track stores vulnerabilities as findings against projects. When you import a VEX document, it creates suppression records that persist across rescans, attributing each suppression to the VEX assertion that caused it. This is important for audit purposes: suppressions are not anonymous, they are traceable to a specific VEX document, author, and timestamp.
Import a CycloneDX VEX document via the REST API:
curl -X PUT \
"https://dtrack.example.com/api/v1/vex" \
-H "X-Api-Key: ${DTRACK_API_KEY}" \
-H "Content-Type: multipart/form-data" \
-F "project=${PROJECT_UUID}" \
-F "vex=@myapp-vex-cdx.json"
For OpenVEX documents, Dependency-Track requires conversion to CycloneDX VEX format. The cyclonedx-gomod and vexctl tools can produce CycloneDX-format output:
vexctl merge --format cyclonedx \
--product "pkg:oci/myapp@sha256:4a7f2c9e..." \
*.vex.json \
> myapp-vex-cdx.json
After import, the Dependency-Track UI shows suppressed findings with a shield icon and the suppression reason. The API returns suppressed findings in the findings list with "suppressed": true and an analysis object containing state, justification, and the VEX document details.
Dependency-Track also supports automatic VEX feeds. The Red Hat VEX feed (https://access.redhat.com/security/data/csaf/v2/vex/) publishes VEX documents for all Red Hat-shipped packages in CSAF/VEX format. If your images are based on UBI or RHEL, configuring this feed means Red Hat’s own exploitability determinations automatically suppress irrelevant findings for packages they ship. Similarly, the Chainguard Images project publishes VEX data for their distroless base images.
To configure a CSAF VEX feed in Dependency-Track, navigate to Administration > Analyzers > Vulnerability Aggregator and add the feed URL. Dependency-Track polls the feed on a configurable interval and applies the VEX assertions to matching projects automatically.
VEX Lifecycle: Who Creates VEX and When
VEX operates at two layers with distinct responsibilities.
Vendor VEX is created by the organisation that builds and ships the software. A container base image maintainer (Red Hat, Chainguard, Google) publishes VEX assertions about CVEs in packages they ship. These assertions reflect their knowledge of which code paths are present, which compilation options are used, and which features are enabled. Vendor VEX is authoritative for questions about the software as built.
Operator VEX is created by the organisation deploying the software. It reflects deployment-specific context: network segmentation that makes a network-exploitable vulnerability unexploitable, runtime policy that prevents a vulnerable API endpoint from being called, or compensating controls that reduce risk to acceptable levels. Operators should never modify vendor-supplied VEX artifacts; they create supplementary documents asserting additional context.
VEX assertions do not expire automatically, but they must be re-evaluated when relevant conditions change:
- A new CVE disclosure affects a component that has existing
not_affectedassertions for other CVEs. The new CVE may have a different attack surface. - A base image update adds or removes packages, invalidating assertions about component presence or code path availability.
- A deployment architecture change — adding or removing the Envoy sidecar, changing network policy — invalidates assertions that relied on that architecture.
- A CVE in
under_investigationstatus must receive a terminal status (not_affected,affected, orfixed) within a defined SLA, typically 30–90 days.
The version field in an OpenVEX document is an integer that increments when the document is updated. Downstream consumers should track the latest version of a VEX document for a given product and treat older versions as superseded. The canonical location of a VEX document can be asserted in the SBOM’s externalReferences array, making it discoverable by any tool that processes the SBOM.
For supply chain risk management purposes, VEX documents should be treated as security-sensitive artifacts. A fraudulent not_affected assertion could mask a real vulnerability and prevent remediation. Sign VEX documents using Sigstore/Cosign before distributing them, and verify signatures before consuming them in automated pipelines. See dependency confusion defence for related supply chain integrity patterns.
Automation: Generating VEX from OS Vendor Feeds
Manual VEX authoring does not scale. For teams maintaining dozens of container images, generating initial VEX drafts from vendor feeds is the practical starting point.
The Red Hat CSAF VEX feed covers all CVEs affecting packages in RHEL and UBI. Download and parse it to extract assertions for packages in your SBOM:
curl -s "https://access.redhat.com/security/data/csaf/v2/vex/2024/cve-2024-12345.json" \
| jq '.vulnerabilities[].threats[] | select(.category == "impact") | .details'
Chainguard publishes VEX data for their images directly alongside the image manifests as OCI attestations. Retrieve them with cosign:
cosign verify-attestation \
--type https://openvex.dev/ns \
cgr.dev/chainguard/python:latest-dev \
| jq -r '.payload' | base64 -d | jq .
For Debian-based images, the Debian Security Team publishes machine-readable security tracker data. The debsecan tool queries it and can identify which CVEs have been evaluated as low-priority or ignored for specific reasons — a useful signal for bootstrapping operator VEX assertions.
A practical automation pipeline looks like this: on each image build, run Grype to generate a finding list, cross-reference the findings against the relevant OS vendor VEX feed to auto-apply vendor assertions, flag remaining unresolved findings for human triage, and generate a consolidated VEX document combining vendor and operator assertions. Attach the VEX document to the image in the registry as an OCI attestation alongside the SBOM. Configure Grype in CI to consume both the vendor SBOM and the combined VEX document so that pull request checks reflect real exploitability rather than raw CVE count.
This reduces the human triage burden from hundreds of findings per image to the small residual set where vendor VEX is absent and operator context is required. For most mature teams running on distroless or UBI base images, the unanswered finding count drops from 400+ to 10–30 — a triage workload that is actually sustainable.
VEX does not eliminate the need for patch management. Findings with affected or under_investigation status still require remediation or escalation. What VEX eliminates is the pretence that a CVE match in a version range is equivalent to an exploitable vulnerability — a confusion that has historically made vulnerability management programs nearly impossible to run effectively.