Run Trivy against almost any vendor container image and you'll get a wall of
findings. Most of them don't matter, the vulnerable code path is never
executed in your deployment. The problem is that knowing that has
traditionally lived in spreadsheets and Jira comments, where no scanner can
see it.
VEX (Vulnerability Exploitability eXchange) turns that triage into
machine-readable statements that scanners apply automatically. This post
walks through a complete, runnable workflow, baseline scan → OpenVEX
generation → a hosted VEX repository → suppressed re-scan, and covers three
gotchas I hit that the docs don't mention.
Everything here is in a one-script demo repo:
github.com/darkedges/trivy-vex-demo
⚠️ The demo marks every CVE
not_affectedmechanically to exercise the
plumbing. In real life the assessment is the valuable part, don't ship
VEX statements you can't defend.
The setup
Target: pingidentity/pingaccess:8.3.4-edge (digest
sha256:51689e8c…). Tools, pinned and current at time of writing: Trivy
v0.71.0, vexctl v0.4.1, OpenVEX v0.2.0, and the
VEX Repository Specification v0.1.
A small Alpine-based toolchain image carries everything:
FROM alpine:latest
ARG TRIVY_VERSION=0.71.0
ARG VEXCTL_VERSION=0.4.1
RUN apk update && apk upgrade --no-cache && \
apk add --no-cache ca-certificates curl jq
RUN curl -fsSL -o /tmp/trivy.tar.gz \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz" && \
tar -xzf /tmp/trivy.tar.gz -C /usr/local/bin trivy && rm /tmp/trivy.tar.gz
RUN curl -fsSL -o /usr/local/bin/vexctl \
"https://github.com/openvex/vexctl/releases/download/v${VEXCTL_VERSION}/vexctl-linux-amd64" && \
chmod +x /usr/local/bin/vexctl
Step 1, Baseline
trivy image --format json --output baseline-report.json \
pingidentity/pingaccess:8.3.4-edge
Result: 70 findings (2 CRITICAL / 18 HIGH / 42 MEDIUM / 8 LOW) across 51
unique CVE/GHSA IDs, all in bundled Java jars and git-lfs. The Alpine OS
layer itself: zero.
Step 2, Generate OpenVEX statements
One statement per CVE with vexctl. The critical detail is the product
identifier: a pkg:oci purl pinned to the image digest, never the tag,
tags move, VEX assertions shouldn't.
vexctl create \
--product="pkg:oci/pingaccess@sha256:51689e8ccf1ec6bef28c855a2f2fafdd3556f753609adad2e258580e3bc9397c" \
--vuln="CVE-2022-46337" \
--status="not_affected" \
--justification="vulnerable_code_not_in_execute_path" \
--status-note="DEMO assessment for a suppression-workflow POC." \
--author="DarkEdges Security <nirving@darkedges.com>" \
--file="CVE-2022-46337.openvex.json"
Which produces:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://darkedges.com/vex/demo/pingaccess/CVE-2022-46337",
"author": "DarkEdges Security <nirving@darkedges.com>",
"statements": [
{
"vulnerability": { "name": "CVE-2022-46337" },
"products": [
{ "@id": "pkg:oci/pingaccess@sha256:51689e8c…" }
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"status_notes": "DEMO assessment for a suppression-workflow POC."
}
]
}
Loop that over the 51 IDs from the baseline JSON, then consolidate:
vexctl merge statements/*.openvex.json > pingaccess-8.3.4-edge.openvex.json
Tip: leave the purl qualifiers off in statement products. Per Trivy's
matching rules, a purl without qualifiers matches regardless of arch or
repository_url; with qualifiers, you're signing up for exact matching.
Step 3, The instant win: --vex flag
trivy image --show-suppressed \
--vex pingaccess-8.3.4-edge.openvex.json \
pingidentity/pingaccess:8.3.4-edge
70 findings → 0 reported, 70 suppressed. Each suppression is recorded in
the JSON report under Results[].ExperimentalModifiedFindings with
Type: ignored and the justification, auditable, not deleted.
Step 4, Publish via a VEX repository
A VEX repository is just static files: a manifest at
/.well-known/vex-repository.json and a tar.gz archive containing an index
plus the documents.
vex-repo/ # webroot
├── .well-known/vex-repository.json
└── v0.1/vex-data.tar.gz # contains: index.json + pkg/oci/pingaccess/vex.json
The manifest:
{
"name": "DarkEdges Demo VEX Repository",
"versions": [{
"spec_version": "0.1",
"locations": [{ "url": "http://vex-server/v0.1/vex-data.tar.gz" }],
"update_interval": "1h"
}]
}
Register it in ~/.trivy/vex/repository.yaml:
repositories:
- name: darkedges-demo
url: http://vex-server
enabled: true
Then trivy vex repo download fetches it, and scanning with --vex repo
gives the same result: 0 findings, 70 suppressed, except now the
statements are centrally hosted, versioned, and every Trivy in your org picks
them up automatically on the manifest's update interval.
The three gotchas
1. The repository index needs repository_url for OCI purls
This one cost me an hour. The vex-repo-spec says index ids are purls
"without version and qualifiers", so pkg:oci/pingaccess, right?
No match. Silently. Trivy's index lookup (pkg/vex/repo.go) makes an
exception for OCI purls and keeps the repository_url qualifier as part
of the package identity. The index entry must be:
{
"id": "pkg:oci/pingaccess?repository_url=index.docker.io%2Fpingidentity%2Fpingaccess",
"location": "pkg/oci/pingaccess/vex.json",
"format": "openvex"
}
If your repo downloads fine but suppresses nothing, check this first.
2. Embedding VEX in the image does nothing (for scanning)
Docker's Hardened Images article mentions copying VEX documents into the
image at build time:
FROM pingidentity/pingaccess:8.3.4-edge
COPY pingaccess-8.3.4-edge.openvex.json /usr/share/vex/
I tested it: Trivy does not auto-discover VEX from the image filesystem.
A plain scan of the derived image still reports all 70 findings. Embedding is
a distribution mechanism, the consumer must extract the file and pass it
via --vex. (What Trivy can auto-discover is VEX attached as a signed OCI
registry attestation, trivy image --vex oci, which requires pushing.)
3. VEX binds to the digest, derived images don't inherit it
The statements identify the product by the base image's digest. My derived
image (built locally, never pushed) has no repo digest at all, so neither
the local file nor the repository suppressed anything on it. Expected, but
worth internalizing: issue VEX for the digest you actually ship, and
regenerate when it changes.
| mechanism | original image (digest-pinned) | derived image |
|---|---|---|
--vex <file> |
✅ 70/70 suppressed | ❌ 0 |
--vex repo |
✅ 70/70 suppressed | ❌ 0 |
| embedded file alone | n/a | ❌ 0 |
What about Wiz?
Wiz ingests OpenVEX automatically, zero config, but from registry
attestations, not files or repositories. The hand-off from this workflow is
one command against the image in a Wiz-scanned registry:
docker scout attestation add \
--file pingaccess-8.3.4-edge.openvex.json \
--predicate-type https://openvex.dev/ns/v0.2.0 \
<registry>/<org>/pingaccess:8.3.4-edge
# or: cosign attest --type openvex --predicate <file> <image@digest>
Same OpenVEX documents, two consumers: Trivy via the repository, Wiz via the
attestation. That's the point of a standard.
Try it
The repo runs the whole thing, toolchain build, baseline, 51 statements,
hosted repository, all the re-scans, with one command and colored output
(there's a GIF of the full run in the README):
git clone https://github.com/darkedges/trivy-vex-demo
cd trivy-vex-demo
./run.sh # pauses after each step; ./run.sh -y to run unattended
Nothing is pushed anywhere; the only network traffic is Docker Hub pulls and
Trivy DB downloads. PowerShell users get an equivalent run.ps1.
If you've hit other VEX edge cases, CycloneDX VEX, CSAF, Grype's matching
behaviour, I'd love to hear about them in the comments.

Top comments (0)