Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

VEX: saying what is really exploitable, measured with Grype

By updated

Improve this page

A VEX (Vulnerability Exploitability eXchange) is a document that states, for a specific vulnerability and product, whether the product is really affected, with a machine-readable justification. It complements the SBOM: the SBOM says "this component is present", the VEX says "is this vulnerability exploitable here?". This lesson is for a DevOps engineer who wants to reduce the noise of scans without hiding a real risk.

Everything is measured on 1 October 2026 with vexctl 0.4.4 and Grype 0.119.0 on the python:3.13-slim image: 335 matches without VEX, 334 with a one-statement VEX. And a big trap: the same VEX has no effect at all when Grype reads a CycloneDX SBOM, whereas it applies on the image or on an SBOM in Syft format.

What you will learn

  • Explain what a VEX adds to a scan, and what it must never be used to hide.
  • Choose a vulnerability's status and justification from the standardised values of OpenVEX and CycloneDX.
  • Write an OpenVEX document with vexctl for an image designated by its digest.
  • Apply the VEX with Grype and check that it really filtered.
  • Distribute the VEX as a signed attestation attached to the image.
  • Produce the evidence for SOCLE-VLN-VEX-1 (in French) and SOCLE-VLN-VEX-3: published VEX documents, and automated triage that applies them.

What is VEX?

VEX is a standardised exchange format: each statement ties a vulnerability, one or more products, a status and, for an unaffected product, a justification. The scanner reads these statements and removes from its report what has already been analysed. The analysis work is no longer redone at every scan: it is written once, versioned and reviewed like code.

The problem: alert fatigue

A scan of an ordinary image returns hundreds of matches. Measured with Grype 0.119.0 on python:3.13-slim: 335, of which 13 critical and 104 high, spread over 45 of the 96 packages. Yet a Python application runs neither the Perl scripts of perl-base nor most system utilities of the base image. Without a way to document this, the team re-sorts the same lines at every scan, until nobody reads them: that is alert fatigue, which ends up hiding the vulnerability that mattered.

Four situations come back in almost every analysis, and the diagram shows how they lead to the same document:

Four non-exploitability situations, code never called, component disabled, attacker without access and mitigation in place, feed a VEX document carrying status and justification; Grype or Trivy apply it with the --vex option and produce a filtered report of the vulnerabilities to handle.Code never calledperl-base, libxml2Component disabledmodule, optionAttacker without accessnetwork, isolationMitigation in placeWAF, sandboxVEX documentstatus and justificationGrype or Trivy--vexFiltered reportvulnerabilities to handle
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %
  • Code never called: the package is in the image, but no execution path reaches it (perl-base under a Python application).
  • Component disabled: a build or configuration option removes the vulnerable function.
  • Attacker without access: the vulnerability requires access the container does not expose.
  • Mitigation in place: an application firewall or isolation neutralises the attack.

The four OpenVEX statuses

Each statement carries one of the four statuses of OpenVEX. The first two remove the vulnerability from the report, the other two keep it: an affected product requires action, an under_investigation product signals an ongoing analysis.

StatusWhat it assertsEffect in Grype, per its documentation
not_affectedThe product is not affected, justification mandatoryVulnerability removed from the report
fixedThe product contains the fixVulnerability removed from the report
affectedThe product is affected, action recommendedVulnerability kept
under_investigationAnalysis in progressVulnerability kept

A VEX is only worth something if it stays true. A not_affected statement written to silence an alert, without analysis, is worse than no VEX at all: it hides the risk and gives the appearance of a decision.

The three VEX formats

Three formats coexist. They express the same idea, but with different vocabularies and tools that do not all read them: that is what drives the choice in practice.

FormatBacked byWhere it livesRead by Grype 0.119.0 (--vex)
OpenVEXOpenVEX project, started by ChainguardSeparate JSON fileYes
CycloneDX VEXOWASPvulnerabilities section of a CycloneDX documentNo, measured below
CSAF VEXOASISCSAF 2.0 security advisory, VEX profileNot measured

Recommendation: to filter a Grype scan, write OpenVEX. The CycloneDX format remains useful to exchange with a tool such as Dependency-Track, which imports it; CSAF is the format of the large vendors (Red Hat, Cisco) that publish their advisories in that form.

OpenVEX: the minimal format

An OpenVEX document is a short JSON: a header (context, identifier, author, date) and a list of statements. This one was produced by vexctl create for vulnerability CVE-2026-82560 in perl-base, inside the python:3.13-slim image designated by its digest:

{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/public/vex-aec80786f97767bcf5daf7378e61732ee8d4a0dc7175fe30c456845a1bcd42d6",
"author": "securite@exemple.fr",
"version": 1,
"statements": [
{
"vulnerability": { "name": "CVE-2026-82560" },
"products": [
{
"@id": "pkg:oci/python@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06?repository_url=localhost:5000/python",
"subcomponents": [
{ "@id": "pkg:deb/debian/perl-base@5.40.1-6?arch=amd64&distro=debian-13.6" }
]
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "L'application Python n'exécute aucun script Perl ; perl-base n'est qu'une dépendance de l'image de base.",
"timestamp": "2026-10-01T09:00:00Z"
}
],
"timestamp": "2026-10-01T09:00:00Z"
}

Three fields carry the meaning. The product is the image, as an OCI purl with its digest: that is what ties the decision to this artifact and no other. The subcomponent designates the package concerned. The impact_statement is the sentence an auditor will read: it explains the decision, not just its code.

OpenVEX justifications: which one to choose?

The justification is mandatory with not_affected, and vexctl list justification gives its five allowed values. Each answers a specific question, to be asked in the order of the table: the first one answered "no" gives the justification.

JustificationQuestionExample
component_not_presentIs the component really in the product?The scanner confused two packages with the same name
vulnerable_code_not_presentIs the vulnerable code compiled into the binary?Module removed at build time
vulnerable_code_not_in_execute_pathIs the code called?perl-base under a Python application
vulnerable_code_cannot_be_controlled_by_adversaryCan an attacker supply the vulnerable input?Function fed by an internal configuration
inline_mitigations_already_existDoes a protection neutralise the attack?Kernel option or isolation that blocks the exploit

When in doubt, do not choose not_affected: the under_investigation status honestly says you do not know yet, and it filters nothing.

CycloneDX VEX: built into the document

CycloneDX carries VEX in its vulnerabilities section, each entry having an analysis block. Its vocabulary is not OpenVEX's, and confusion is common: the CycloneDX 1.7 schema has no affected state, but exploitable.

{
"id": "CVE-2026-82560",
"source": { "name": "NVD" },
"affects": [ { "ref": "pkg:deb/debian/perl-base@5.40.1-6?arch=amd64&distro=debian-13.6&package-id=c9a7a6c12ce9a73e&upstream=perl" } ],
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "Aucun script Perl n'est exécuté par l'application."
}
}

The allowed values, read in the CycloneDX 1.7 JSON schema, are the following. The affects.ref field must repeat the exact bom-ref of the component in the SBOM, here the one Syft wrote for perl-base.

FieldAllowed values (CycloneDX 1.7)
stateresolved, resolved_with_pedigree, exploitable, in_triage, false_positive, not_affected
justificationcode_not_present, code_not_reachable, requires_configuration, requires_dependency, requires_environment, protected_by_compiler, protected_at_runtime, protected_at_perimeter, protected_by_mitigating_control
responsecan_not_fix, will_not_fix, update, rollback, workaround_available

Creating and applying your first VEX

The full walkthrough has four steps, all run against a local registry registry:3 serving python:3.13-slim. What makes the exercise succeed or fail is product matching: the VEX only applies if Grype recognises in its target the image designated by the purl.

Step 1: scan and pick a vulnerability

You start from Grype's report on the image itself, designated by digest. The chosen vulnerability, CVE-2026-82560, affects perl-base and has no fix planned by Debian (won't fix): it will therefore come back at every scan, until someone documents it.

Terminal window
# Local registry served over HTTP: GRYPE_REGISTRY_INSECURE_USE_HTTP=true
grype registry:localhost:5000/python:3.13-slim@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06 -q
# 335 matches, including:
# perl-base 5.40.1-6 (won't fix) deb CVE-2026-82560 High

Step 2: write the VEX with vexctl

vexctl is the command-line tool of the OpenVEX project. Its create command writes a one-statement document; it refuses not_affected without a justification. The official image is pinned by digest, like any tool that enters the delivery chain.

Terminal window
docker run --rm -v "$PWD":/w -w /w \
ghcr.io/openvex/vexctl:v0.4.4@sha256:ce15c9c85ffc66cb630c0ce9c50a85e9241be77ca4e6e841ab14a0bf2a96bf7e \
create \
--author="securite@exemple.fr" \
--product="pkg:oci/python@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06?repository_url=localhost:5000/python" \
--subcomponents="pkg:deb/debian/perl-base@5.40.1-6?arch=amd64&distro=debian-13.6" \
--vuln="CVE-2026-82560" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--impact-statement="L'application Python n'exécute aucun script Perl ; perl-base n'est qu'une dépendance de l'image de base." \
> python.vex.json

The product's OCI purl is built from the short image name, the digest and the repository_url parameter. That is the form Grype recognised in the measurement. To add a second statement, you create another document and merge them with vexctl merge; measured, the merged document carries the author Unknown Author unless you pass --author to the merge.

Step 3: apply the VEX and check that it filtered

Grype applies the VEX with the --vex option, which is repeatable. The essential check is the --show-suppressed option, which shows the removed lines instead of hiding them: without it, nothing distinguishes an applied VEX from an ignored one.

Terminal window
grype registry:localhost:5000/python:3.13-slim@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06 \
--vex python.vex.json --show-suppressed -q
# perl-base 5.40.1-6 (won't fix) deb CVE-2026-82560 High ... (suppressed by VEX)

Measured: 334 matches instead of 335, and the JSON output puts the removed line in ignoredMatches with the rule vex-status: not_affected. Two documents passed through two --vex, or a single document merged by vexctl merge, give the same result: 333 matches, two suppressions.

Step 4: the CycloneDX SBOM trap

The same VEX, applied to an SBOM, does not always give the same result. The diagram sums up the three measurements made with the same OpenVEX document:

The same OpenVEX document, whose product is pkg:oci/python with digest, is applied to three targets: on the image designated by digest and on the SBOM in syft-json format, Grype shows 334 matches and suppresses one; on the CycloneDX SBOM, it shows 335 and the VEX has no effect.OpenVEXproduct pkg:oci/python@sha256grype registry:image@digestgrype sbom:image.syft.jsongrype sbom:image.cdx.json334 shown1 suppressed by VEX335 shownVEX has no effect
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The reason lies in the product. An SBOM in Syft's native format (syft-json) keeps the digest of the source image; the CycloneDX SBOM from Syft 1.52.0 only keeps the name and tag (localhost:5000/python, 3.13-slim). Grype does not find the VEX purl in it, and the statement is silently ignored, without any error message.

Terminal window
syft scan registry:localhost:5000/python:3.13-slim@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06 \
-o syft-json > python.syft.json
grype sbom:./python.syft.json --vex python.vex.json -q # 334: VEX applied
grype sbom:./python.cdx.json --vex python.vex.json -q # 335: VEX ignored

Two more tests complete the picture. A CycloneDX SBOM that embeds its own vulnerabilities section with not_affected is not taken into account: 335 matches. And a CycloneDX file passed to --vex is refused with "unable to detect document format": the option only accepts OpenVEX.

Trivy also offers a --vex option, still marked [EXPERIMENTAL] in the help of version 0.74.0; it was not measured here. Whatever the tool, the rule is the same: check the suppression with the detailed output, never assume it.

Distributing and signing VEX documents

A VEX is a security assertion: anyone who can modify it can make a vulnerability disappear from your reports. It is therefore distributed like the SBOM, as close to the image as possible, and it is signed. Three options exist, from the simplest to the most robust.

MethodCommandWhat it guarantees
File versioned in the repositorygitHistory and review, no link to the image
Unsigned OCI referreroras attachStored next to the image, without proof of origin
Signed attestationcosign attest --type openvexVerifiable origin and integrity, tied to the digest

The cosign attach artifact command, quoted in many examples, is deprecated: Cosign v3.1.3 announces that attach will be removed in v4.0.0 and points to oras. The attestation, for its part, is measured on the local registry with a key pair and a signing configuration without a transparency log, so as to publish nothing on the public Sigstore instance:

Terminal window
cosign attest --key cosign.key --signing-config signing-config.json \
--type openvex --predicate python.vex.json --allow-insecure-registry \
localhost:5000/python:3.13-slim@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06
cosign verify-attestation --key cosign.pub --type openvex \
--insecure-ignore-tlog=true --allow-insecure-registry \
localhost:5000/python:3.13-slim@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06

Verification exits with code 0 and returns a statement whose predicateType is https://openvex.dev/ns. The empty signing configuration and the --insecure-* options are only for the test registry; the Cosign lesson details keyless signing and identity-based verification, used in production.

Automating VEX in CI/CD

In CI, the VEX lives in the repository, next to the code, and every change goes through review like any security decision. The scan applies it to the published image, designated by digest, for the reason measured above: it is the target on which Grype recognises the product.

.github/workflows/scan-vex.yml
name: scan-vex
on:
workflow_dispatch:
inputs:
digest:
description: "Digest of the published image (sha256:...)"
required: true
type: string
permissions: {}
jobs:
scan:
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Scan the image with the VEX decisions
uses: anchore/scan-action@27805bf3b4e84b4a5c980df22ed233c00390a439 # v7.4.2
with:
image: ghcr.io/exemple-org/order-service@${{ inputs.digest }}
vex: vex/order-service.vex.json
only-fixed: true
severity-cutoff: high
output-format: table

This workflow passes actionlint 1.7.12, zizmor 1.30.0 and poutine 1.1.6 without any finding. The vex input of anchore/scan-action v7.4.2 passes the file to Grype, and severity-cutoff: high fails the job on a high vulnerability not covered by the VEX. The example image is assumed to be public; a private image requires a registry login before the scan.

VEX and regulatory compliance

VEX helps demonstrate a vulnerability analysis, but none of the regulations below mandates it under that name. The nuance matters: presenting VEX as a legal obligation is a common mistake.

  • Cyber Resilience Act (EU Regulation 2024/2847): the manufacturer must provide an SBOM in its technical documentation and, from 11 September 2026, report actively exploited vulnerabilities, with an early warning within 24 hours. VEX is a good way to document why a vulnerability does not fall under this obligation. See the CRA guide (in French).
  • NIS2: supplier risk management benefits from receiving the vendor's VEX along with its SBOM. See the NIS2 guide (in French).
  • Executive Order 14028 (United States): it asks the supplier for an SBOM; VEX falls under CISA recommendations, which published its minimum requirements for a VEX document in 2023.

Good practices and common mistakes

A VEX degrades like any documentation: it becomes false when the product changes and nobody reviews it. Each practice below answers a mistake that has been seen.

  • One VEX per digest, never a single file for every version: a not_affected vulnerability in 1.2 can become exploitable in 1.3.
  • One analysis per statement: marking 200 vulnerabilities not_affected without reading them destroys trust in the whole document.
  • An explicit impact_statement: it is the only part an auditor will read, and it must stand on its own.
  • A re-evaluation at every functional change: a new feature can call the code you said was unreachable.
  • A suppression check with --show-suppressed or ignoredMatches in CI, to detect a VEX that no longer applies.
  • Dependency-Track imports VEX in CycloneDX format through its /api/v1/vex API: the not_affected, false_positive and resolved states suppress the corresponding finding there.

Key points

  • A VEX states, for a vulnerability and a product, whether it is really affected: four statuses in OpenVEX, and a mandatory justification for not_affected.
  • Measured on python:3.13-slim: 335 matches without VEX, 334 with one statement on perl-base.
  • The VEX product is the image as an OCI purl with digest: that is what ties the decision to this artifact.
  • Grype 0.119.0 applies an OpenVEX VEX on the image or a syft-json SBOM, but silently ignores it on a CycloneDX SBOM, and refuses a CycloneDX VEX passed to --vex.
  • You check each application with --show-suppressed: an ignored VEX produces no message.
  • CycloneDX says exploitable where OpenVEX says affected: the two vocabularies do not copy over.
  • The VEX is signed (cosign attest --type openvex): unsigned, it can make any vulnerability disappear from a report.
  • The evidence expected by SOCLE-VLN-VEX-1 is a set of VEX documents produced and published, with a status per vulnerability; that of SOCLE-VLN-VEX-3 is automated triage that applies them in CI.

Knowledge check

Check that the essentials of this lesson are understood. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn