Producer statements (VEX)
A scanner reports what is installed in an image. It cannot tell you whether the vulnerable code is reachable, compiled in, or ever executed. The party who built the image can, and OpenVEX is how they say it.
Attestkeep reads such a statement only when it arrives as a signed in-toto attestation on the image itself and the policy names its signer. A VEX document in a repository is a text file anybody can write; the same document signed by the producer and attached to the image digest is a claim with a name on it. Unsigned statements, statements from a signer the policy does not name, and statements whose product is not this image are recorded as seen and ignored.
Trivy's own --vex flag is deliberately not used. Trivy can filter its report against a VEX document itself, and then the operator's records would carry the filtered result with no way to say who was trusted, what they claimed, or what was dropped. The trust decision and the audit record stay here.
The policy block
apiVersion: attestkeep.com/v1alpha1
kind: ImageSecurityPolicy
metadata:
name: default
spec:
vex:
# ignore (default): every trusted statement is verified, recorded against
# the finding it names and shown in the console, and no verdict moves.
# apply: a trusted not_affected or fixed statement takes that finding out
# of the counts admission decides on.
mode: apply
trustedIssuers:
# A keyless signer, named by the certificate identity Fulcio issued.
# A trailing "*" matches by prefix; everything else matches exactly.
- identity: https://github.com/acme/images/*
# Optional, matched exactly. Empty accepts any issuer, which is worth
# narrowing: an identity string can be reproduced under a different
# issuer, and the pair cannot.
issuer: https://token.actions.githubusercontent.com
# A keyed signer, named by the public key the signature verifies
# against. There is no identity in a keyed signature, so a verification
# that succeeds against this key IS the match.
- key: |
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END PUBLIC KEY-----
An entry carries an identity or a key, never both, and an issuer beside a key is refused rather than ignored — a keyed signature carries no certificate, so nothing would enforce it. mode: apply with an empty trustedIssuers list is refused too: it reads as a control and behaves as nothing.
A policy written before this field existed, or one that omits the block, is mode: ignore with nobody trusted. Nothing is looked up for it at all.
What each status does
| Status | Under mode: apply | Under mode: ignore |
|---|---|---|
not_affected | the finding leaves the counts | recorded and shown |
fixed | the finding leaves the counts | recorded and shown |
affected | recorded and shown | recorded and shown |
under_investigation | recorded and shown | recorded and shown |
affected and under_investigation never suppress anything: "we are looking into it" must not read as "we are fine". A suppressed finding leaves the counts the severity thresholds and the known-exploited rule decide on, and the admission decision that allowed the image says so — the ledger records …; 3 findings suppressed by VEX from "Acme Platform Security".
Start in ignore
Under ignore everything happens except the suppression. The statement is verified, the signer is checked against the trusted issuers, the document is parsed, and each matching statement is written onto the finding it names — so the console shows "the producer says not_affected" and the image detail shows the author, the signer and how many findings were answered. No count changes and no verdict changes.
This is the mode to start in. It answers "what are our suppliers actually claiming, and would I have believed them" before anything is allowed to act on it.
When a policy change takes effect
Whether a statement is believed is decided at every admission, not at the scan: switching mode off, or dropping an issuer, stops the suppression on the next pod rather than after every image happens to be rescanned. The one direction that does need a rescan is adding a trusted issuer whose attestation this operator has never verified against the image — nobody has checked that signature yet, so its statements begin to count at the next scan of that digest. Rescan from the console rather than waiting.
The scan record keeps its own position permanently: an image scanned under ignore starts being suppressed the moment the mode is switched to apply, without waiting for a rescan, while the console goes on showing the scan-time position until that digest is scanned again. A policy edited next week does not rewrite what was counted last week.
A suppressed finding is not deleted. It stays in the scan record flagged with the status, the justification and the author, the console hides it behind a show suppressed toggle, and the evidence document lists every one of them with the producer's own words beside it.
Attesting a VEX document
Write the document (OpenVEX v0.2.0), naming the image by digest:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://acme.example/vex/2026-09-09-api",
"author": "Acme Platform Security <security@acme.example>",
"timestamp": "2026-09-09T09:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {"name": "CVE-2024-21626"},
"products": [
{"@id": "pkg:oci/api@sha256%3A124c7d27...3126"}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}
Attest it to the image, with a key:
cosign attest --type openvex --predicate vex.json \
--key cosign.key ghcr.io/acme/api@sha256:124c7d27...3126
or keylessly, which is what a CI job does:
COSIGN_EXPERIMENTAL=1 cosign attest -y --type openvex --predicate vex.json \
ghcr.io/acme/api@sha256:124c7d27...3126
The operator verifies the attestation on the next scan of that digest — with cosign verify-attestation --key <the policy's key> for a keyed issuer, or keylessly plus a read of the signing certificate stored beside the attestation for a keyless one.
How a statement is matched to this image
Two things have to line up before a statement is applied.
The product must name this image. A product @id matches when it contains the image digest — which covers the spec's own pkg:oci/<name>@sha256%3A<hex> shape, where the colon is percent-escaped inside a purl, as well as a plain registry/repo@sha256:<hex> reference — or when it is exactly the reference the scan ran against. A product named only by repository, with no digest, is not matched: "my product" without a version is a claim about software in general, and findings are suppressed one image at a time.
Where the statement lists subcomponents, the finding's package must be one of them. The comparison is on the package name and version, not the purl type. A purl splits a name into a namespace and a name, and the scanner reports the name its ecosystem uses, so all three spellings are tried:
| Subcomponent purl | Matches the package the scanner calls |
|---|---|
pkg:apk/alpine/openssl@3.1.3-r0 | openssl at 3.1.3-r0 |
pkg:golang/github.com/acme/lib@v1.2.3 | github.com/acme/lib at v1.2.3 |
pkg:maven/org.acme/widget@2.0.0 | org.acme:widget at 2.0.0 |
pkg:apk/alpine/busybox | busybox at any version |
A purl that carries a version must match it exactly: a statement about openssl 3.1.3-r0 says nothing about 3.1.4-r0. The purl type is deliberately not compared — pkg:deb and pkg:apk name the same source package on two distributions, and a producer writing one where the scanner saw the other is not the mistake this check exists to catch.
When a document carries several statements about the same vulnerability, the one with the latest timestamp wins, which is the spec's own rule: a producer who has since found they are affected must not go on suppressing the finding.
What is counted, and what is refused
| Series | What it counts |
|---|---|
attestkeep_vex_statements_total{status} | Statements matched to a finding, by what the producer said — one per distinct CVE per scan. |
attestkeep_vex_rejected_total{reason} | Statements seen and not acted on: unsigned, untrusted_signer, product_mismatch, malformed. |
unsigned means the operator could not establish any signing identity behind the attestation — from a policy's point of view that is an unsigned claim, whoever produced it. untrusted_signer is the ordinary case on a cluster running other people's images, and it also covers the quieter one: a VEX attestation is attached to the image and nothing the policy holds could verify it — a keyed statement under a keyless-only issuer list, or one signed with a key you do not have. That case is invisible to cosign, which reports only that there is nothing it can check, so the operator reads the predicate type off the attestation manifest and logs that a VEX attestation is attached to this image but no trusted issuer verified it. If that manifest cannot be read at all — a private registry refusing it, a network that did not answer — the operator says so with a warning rather than reporting the image as carrying no statement. product_mismatch is usually your own mistake: a product id written for a different registry or a previous digest.
What it adds to the evidence pack
A vex section listing every suppressed finding with the producer's own words, and the producer-statement-honoured attestation, which is met when the policy applies producer statements and names at least one issuer to trust. A policy that says statements are applied and names no issuer to trust is reported as a gap: no statement can ever be read, and a policy in that shape cannot be written through this operator.