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
vexctlfor 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:
- Code never called: the package is in the image, but no execution path
reaches it (
perl-baseunder 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.
| Status | What it asserts | Effect in Grype, per its documentation |
|---|---|---|
not_affected | The product is not affected, justification mandatory | Vulnerability removed from the report |
fixed | The product contains the fix | Vulnerability removed from the report |
affected | The product is affected, action recommended | Vulnerability kept |
under_investigation | Analysis in progress | Vulnerability 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.
| Format | Backed by | Where it lives | Read by Grype 0.119.0 (--vex) |
|---|---|---|---|
| OpenVEX | OpenVEX project, started by Chainguard | Separate JSON file | Yes |
| CycloneDX VEX | OWASP | vulnerabilities section of a CycloneDX document | No, measured below |
| CSAF VEX | OASIS | CSAF 2.0 security advisory, VEX profile | Not 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.
| Justification | Question | Example |
|---|---|---|
component_not_present | Is the component really in the product? | The scanner confused two packages with the same name |
vulnerable_code_not_present | Is the vulnerable code compiled into the binary? | Module removed at build time |
vulnerable_code_not_in_execute_path | Is the code called? | perl-base under a Python application |
vulnerable_code_cannot_be_controlled_by_adversary | Can an attacker supply the vulnerable input? | Function fed by an internal configuration |
inline_mitigations_already_exist | Does 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.
| Field | Allowed values (CycloneDX 1.7) |
|---|---|
state | resolved, resolved_with_pedigree, exploitable, in_triage, false_positive, not_affected |
justification | 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 |
response | can_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.
# Local registry served over HTTP: GRYPE_REGISTRY_INSECURE_USE_HTTP=truegrype 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 HighStep 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.
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.jsonThe 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.
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 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.
syft scan registry:localhost:5000/python:3.13-slim@sha256:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06 \ -o syft-json > python.syft.jsongrype sbom:./python.syft.json --vex python.vex.json -q # 334: VEX appliedgrype sbom:./python.cdx.json --vex python.vex.json -q # 335: VEX ignoredTwo 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.
| Method | Command | What it guarantees |
|---|---|---|
| File versioned in the repository | git | History and review, no link to the image |
| Unsigned OCI referrer | oras attach | Stored next to the image, without proof of origin |
| Signed attestation | cosign attest --type openvex | Verifiable 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:
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:afe189875f1d2f9b45e287834fb9f2c273a5d59d354ae4050ab9affbf0a6ba06Verification 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.
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: tableThis 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_affectedvulnerability in 1.2 can become exploitable in 1.3. - One analysis per statement: marking 200 vulnerabilities
not_affectedwithout 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-suppressedorignoredMatchesin CI, to detect a VEX that no longer applies. - Dependency-Track imports VEX in CycloneDX format through its
/api/v1/vexAPI: thenot_affected,false_positiveandresolvedstates 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 onperl-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-jsonSBOM, 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
exploitablewhere OpenVEX saysaffected: 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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: Cosign: signing and verifying your container images
A new full quiz with random questions
Work again only on the questions you missed