From vexctl scripts to a governed VEX platform: building vex-ui with Next.js, keyless signing, and a Trivy-consumable repo
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_affected mechanically 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
- 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.0
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.
Comments
No comments yet. Start the discussion.