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.
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.
grype --vex vex.openvex.json \
ghcr.io/<owner>/5-spot-chainguard@<digest>Grype will suppress every CVE the document marks not_affected or
fixed for the scanned image, with the OpenVEX statement as the audit
record.
trivy image \
--vex file:vex.openvex.json \
ghcr.io/<owner>/5-spot-chainguard@<digest>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>gh attestation verify vex.openvex.json --repo <owner>/5-spotReplace <owner> with the GitHub organization hosting your 5-Spot
release (for example, finos).
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.
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.
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.
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.
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.
- Re-run
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.
- OpenVEX specification
vexctl- Cosign OpenVEX attestation docs
- Grype
--vexflag - Repository convention:
.vex/README.md