VEX (Vulnerability Exploitability eXchange)¶
5-Spot publishes a signed
OpenVEX
document with every GitHub Release. The document records, per CVE, whether
the finding is not_affected, affected, fixed, or
under_investigation in this specific release of 5-Spot, plus an
OpenVEX-spec justification when we claim non-exploitability.
VEX closes the gap between "Grype flagged a CVE" and "is this CVE
actually reachable in 5-Spot?". The 5-Spot CI pipeline feeds the VEX
document into Grype (grype --vex
...) to suppress pre-triaged findings before they reach GitHub Code
Scanning; downstream consumers can do the same with Grype or with
Trivy,
so the triage burden does not land on every downstream team
independently.
What is published, and how¶
On every release of 5-Spot the CI pipeline performs the following steps:
- Parse every file under
.vex/*.json(each is a single-statement native OpenVEX document) viavexctl merge. Malformed input fails the merge — there is no separate validator to keep in sync. - Generate presence-based auto-VEX (roadmap Phase 2): the
auto-vex-presencejob runs a Grype triage scan on each image variant without VEX suppression, then emits anot_affected + component_not_presentstatement for every finding whose affected package URL is absent from the image SBOM and not already covered by a hand-authored statement. The result is uploaded as thevex-auto-presenceworkflow artifact. - Generate symbol-import reachability auto-VEX (roadmap Phase 3):
the
auto-vex-reachabilityjob inspects the release binary's dynamic symbol-import table (nm -D --undefined-only) and emits anot_affected + vulnerable_code_not_in_execute_pathstatement for every Grype finding whose CVE id is present in the curated.vex/.affected-functions.jsonmapping and whose listed library functions are all absent from the imports. The result is uploaded as thevex-auto-reachabilityartifact, with the raw symbol-import dump alongside asvex-auto-reachability-evidence. - Assemble a single OpenVEX document (
vex.openvex.json) withvexctl merge, stamped with a canonical@id = https://github.com/<owner>/<repo>/releases/tag/<tag>/vexand the release actor as the document-level author. Both auto-* documents are included in the merge on every build — there is no feature-flag gate. - Cosign-attest the document to both image digests (Chainguard and Distroless). The attestation lands in the Sigstore transparency log and is pushed to the registry alongside the image.
- GitHub attest the document with
actions/attest-build-provenancesogh attestation verifyworks for downstream pulls. - Attach
vex.openvex.jsonand its.bundleto the GitHub Release as assets and register them inchecksums.sha256.
No new GitHub secrets are required — all signing is keyless via the GitHub OIDC token and Sigstore Fulcio.
Consuming the VEX document¶
With Grype¶
Grype will suppress every CVE the document marks not_affected or
fixed for the scanned image, with the OpenVEX statement as the audit
record.
With Trivy¶
Verifying the Cosign attestation end-to-end¶
cosign verify-attestation \
--type openvex \
--certificate-identity-regexp '^https://github.com/<owner>/5-spot' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/<owner>/5-spot-chainguard@<digest>
Verifying the GitHub attestation¶
Replace <owner> with the GitHub organization hosting your 5-Spot
release (for example, finos).
How a 5-Spot maintainer adds a statement¶
When a new CVE surfaces on a release artifact, open a PR adding a
single file to .vex/.
Each file is a native OpenVEX v0.2.0 single-statement document:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://github.com/finos/5-spot/.vex/CVE-2025-12345",
"author": "maintainer@example",
"timestamp": "2026-04-19T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {"name": "CVE-2025-12345"},
"products": [{"@id": "pkg:oci/5-spot"}],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "5-Spot does not parse untrusted XML; the affected libxml2 code path is never invoked.",
"timestamp": "2026-04-19T00:00:00Z"
}
]
}
The document-level @id, author, and timestamp fields are replaced
by CI at release time with a canonical release-tag @id, the release
actor, and the release timestamp. Only the statement contents (inside
statements[]) carry forward into the merged release document.
The PR gate on build.yaml (validate-vex job) runs vexctl merge
over every .vex/*.json file; any malformed file fails the merge and
blocks the PR. The same tool runs again on release for
belt-and-suspenders.
Required fields per status (statement level)¶
status |
Extra required field | Notes |
|---|---|---|
not_affected |
justification |
OpenVEX enum. impact_statement is strongly recommended. |
affected |
action_statement |
What users should do until fixed (upgrade path, mitigation, etc.). |
under_investigation |
action_statement |
Same — give consumers something actionable. |
fixed |
— | Just declares the CVE no longer applies to this release. |
All four statuses additionally require vulnerability.name, products,
and timestamp (RFC-3339 UTC).
Validate locally with make vex-validate.
What we automate, and what stays human¶
The VEX document is a trust claim, not a compliance artifact. If 5-Spot
automatically emitted not_affected for every Grype finding, the
document would be worthless the moment Grype missed a true positive.
That constraint rules out "auto-triage everything" but not all
automation — specifically, the component_not_present justification
has a purely mechanical definition ("the vulnerable component is not
in the product"), and the SBOM is the authoritative definition of
what's in the product.
The split is therefore:
- Automated — presence (roadmap Phase 2, active on every build):
if Grype flags a CVE on a package whose
purlis not in any image SBOM, and the CVE isn't already triaged in.vex/, theauto-vex-presencejob emits anot_affected + component_not_presentstatement. The SBOM digest is the evidence backing the claim. - Automated — symbol-import reachability (roadmap Phase 3, active
on every build): if Grype flags a CVE that's listed in the curated
.vex/.affected-functions.jsonmapping, and none of the listed public-API function names appear in the release binary's dynamic symbol-import table, theauto-vex-reachabilityjob emits anot_affected + vulnerable_code_not_in_execute_pathstatement. The rawnm -D --undefined-onlyoutput is uploaded as thevex-auto-reachability-evidenceartifact and is the evidence backing the claim. - Hand-authored (everything else):
not_affectedwith any justification other thancomponent_not_present/vulnerable_code_not_in_execute_path, plus allaffected,fixed, andunder_investigationstatements, stay in.vex/*.jsonand go through PR review. Grype findings drive maintainers to write statements; the statements themselves are deliberate human decisions.
Both automated paths are merged unconditionally into the signed VEX document — there is no feature-flag gate. The Security team's downstream re-verification (see "Trust model" below) is the safety net.
Why symbol-imports, not LLVM-IR call graphs¶
The original Phase 3 plan targeted Rust LLVM-IR call-graph
reachability against RustSec advisories carrying affected.functions.
At implementation time, cargo audit reported zero open Rust-level
vulnerabilities for this codebase — every CVE in .vex/ is a
base-image glibc/zlib finding from Grype scanning the Docker image,
none of which RustSec tracks. The Rust call-graph approach addressed
zero current findings.
The mechanical equivalent for our actual data is symbol-import
absence: if the Rust binary doesn't link against glob / scanf /
iconv / etc., the affected glibc code paths cannot be reached
through their documented entry points. The nm -D --undefined-only
output is auditable and the Security team re-derives the same
conclusion by running nm against the release-attested binary
themselves.
Trust model¶
5-Spot operates a two-signature trust model for VEX:
- CI emits and Cosign-attests the merged VEX document (hand-authored + auto-presence) against both image digests on every push + release. The attestation is keyless via GitHub OIDC and lands in the Sigstore transparency log alongside the SBOM attestations, SLSA provenance, and GitHub build-provenance bundle.
- The Security team independently verifies and counter-signs. On each release they:
- Re-run
vexctl mergeover the committed.vex/*.jsonand the uploadedvex-auto-presenceartifact, diffing against the signedvex.openvex.jsonattached to the release. - For each auto-generated
component_not_presentstatement, re-derive presence by inspecting the signed SBOM attestations (cosign download attestation --predicate-type cyclonedx.json). - For each hand-authored statement, review the
impact_statementagainst the release source tree at the tagged commit. - Apply their own Cosign attestation to the same image digests if
they agree (
cosign attest --type openvexunder the Security team's OIDC identity), adding a second signature to the transparency log.
Downstream consumers can require both attestations via
cosign verify-attestation with two --certificate-identity-regexp
invocations (one matching the CI identity, one matching the Security
team's). The design keeps the VEX document auditable even when the
5-Spot team automates its generation aggressively: if the CI emits a
claim the Security team cannot substantiate, the image ships with
only one signature on the VEX — which is a discoverable condition for
any downstream gate.
This is why it is safe for 5-Spot CI to auto-generate
component_not_present statements by default: incorrect suppressions
are caught at verification time, not at emission time.
References¶
- OpenVEX specification
vexctl- Cosign OpenVEX attestation docs
- Grype
--vexflag - Repository convention:
.vex/README.md