
Security
Vulnerability Management Life Cycle When You Run the Scanner
Run the vulnerability management life cycle yourself: Trivy and Grype scans, KEV and EPSS triage, and a verify stage that re-scans the digest actually running.
Many explanations of the lifecycle describe a platform's workflow: a finding appears in a dashboard, someone is assigned, a ticket closes. Run the scanner yourself and the platform is a CI job, a registry, an operator and an admission controller, so every stage must leave something another engineer can check. The stage easiest to lose is verification: rebuilding an image feels like fixing it, and nothing in the default tooling checks that the rebuilt image is running.
Tool behaviour below is read from Trivy v0.74.0 (released 14 August 2026), Trivy Operator v0.34.0 (24 August 2026), Grype v0.118.0 (27 August 2026) and Kyverno 1.19; re-check each default when you move a pin.
Five Stages, Two Scan Planes
On a cluster you operate, the five stages run on two planes. The build plane scans an image in CI, signs the result as an attestation, and lets an admission policy refuse images without one. The run plane re-scans what is deployed. Trivy Operator writes a VulnerabilityReport custom resource that records an updateTimestamp and the scanned artifact's digest, and with OPERATOR_SCANNER_REPORT_TTL at its default of 24h, an expired report is deleted and the controller creates a new one.
Both planes exist because a clean scan decays while the image stays identical. The Trivy database is built every six hours, and the default update interval written into its metadata is 24 hours. Grype fails scans by default when its database is more than five days old; the threshold is db.max-allowed-built-age. A build-time pass records what the database knew on build day; the run plane asks again against newer data.
Discover: Scan the Digest, Record the Database
Kubernetes' own documentation states the first rule: tags can be moved to point to different images, but digests are fixed. A scan of web:1.4 describes whatever the tag pointed at when the scanner resolved it. A scan of a digest describes one artefact permanently. Resolve the tag once and scan the digest.
The second rule is to record which database the scan used, and Trivy's report does not do that for you. Its JSON output records CreatedAt, ArtifactName and the image's RepoDigests, but its Trivy block holds only the client version and, in client/server mode, server information: no vulnerability-database metadata. trivy version --format json does report the database's UpdatedAt, NextUpdate and DownloadedAt, so capture it beside every report. Grype's database status structure carries a built timestamp, which grype db status prints.
The script below refreshes the database once, records it, then scans with updates disabled, so the recorded build is the one the scan used. It gates on critical findings counted from the full JSON instead of filtering the report by severity, because the evidence should keep every finding.
#!/usr/bin/env bash
# discover-and-record.sh: scan one image by digest and keep the evidence.
# Written against Trivy v0.74.0; crane is from go-containerregistry.
# usage: discover-and-record.sh <image-ref> [evidence-root]
set -euo pipefail
REF="${1:?usage: discover-and-record.sh <image-ref> [evidence-root]}"
ROOT="${2:-./evidence}"
IGNOREFILE="${IGNOREFILE:-.trivyignore.yaml}"
for bin in trivy crane jq; do
command -v "$bin" >/dev/null || { echo "missing: $bin" >&2; exit 127; }
done
# Tags move, digests do not: resolve once, then scan only the digest.
name="${REF%@*}"
[[ "${name##*/}" == *:* ]] && name="${name%:*}"
DIGEST="$(crane digest "$REF")"
PINNED="${name}@${DIGEST}"
OUT="$ROOT/${DIGEST#sha256:}"
mkdir -p "$OUT"
printf '%s\n%s\n' "$REF" "$PINNED" >"$OUT/reference.txt"
# The JSON report has no vulnerability-DB metadata. Refresh the DB once,
# record it, then scan without updating so the record matches the scan.
trivy image --download-db-only --quiet
trivy version --format json >"$OUT/trivy-version.json"
args=(--skip-db-update --quiet --format json --output "$OUT/trivy.json")
if [[ -f "$IGNOREFILE" ]]; then
cp "$IGNOREFILE" "$OUT/"
args+=(--ignorefile "$IGNOREFILE")
fi
trivy image "${args[@]}" "$PINNED"
jq -r '"db updatedAt: \(.VulnerabilityDB.UpdatedAt)"' "$OUT/trivy-version.json"
# Gate on the full report: the evidence keeps every severity.
critical="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' "$OUT/trivy.json")"
echo "critical findings for $PINNED: $critical"
((critical == 0)) || exit 1A scanner reports only what it can identify, so what Syft and Grype can see in an image bounds every later stage.
Prioritise: Severity Is an Input, Not the Queue
Sorting by CVSS base severity is the obvious queue, and the CVSS v4.0 specification warns against stopping there: consumers should enrich the Base metrics with Threat and Environmental values specific to their use of the vulnerable system. Two public sources supply the threat half:
- EPSS, published by FIRST, estimates the probability that a published CVE will be exploited in the wild in the next 30 days, and publishes a score every day for every CVE.
- CISA's Known Exploited Vulnerabilities (KEV) catalog adds a vulnerability on three criteria: it has a CVE ID, there is clear remediation guidance, and there is reliable evidence of exploitation in the wild.
The federal framing around KEV changed this year. CISA's page for BOD 22-01 now marks that directive revoked, and BOD 26-04, "Prioritizing Security Updates Based on Risk", issued on 10 June 2026, carries the KEV criteria forward. It sets remediation urgency from four variables: whether the vulnerable asset is publicly exposed, whether the CVE is on KEV, whether an adversary can automate every step of exploitation, and whether exploitation gives partial or total control. The directive binds federal information systems, not private operators, so its timelines are not yours to inherit; the four variables are still a sound triage model for anyone.
Grype already does part of this. The pull request merged on 5 May 2025 made results sort highest-risk-first by default, where risk approximates EPSS-weighted severity with a boost for KEV entries. Its CLI documents --sort-by with epss and kev among the options, and --fail-on sets exit code 2 when a match at or above a given severity is found. Treat the risk sort as a starting order, not a formula.
Then choose a response, one finding at a time. NIST SP 800-40 Rev. 4 (April 2022) names four: accept, mitigate, transfer and avoid. Mitigate covers patching as well as compensating controls such as segmenting a vulnerable asset; avoid means removing the attack surface. The publication also notes that an organisation accepts the risk of its software by default, so an unrecorded decision is still an acceptance. Findings from a third-party VAPT report join the same queue.
Remediate: A New Digest Is Not Yet a Fix
For a container, remediation is almost always a rebuild: a bumped dependency, or a base image you build yourself rebuilt against patched packages. The rebuild produces a new digest in the registry and changes nothing in the cluster. A Deployment's rollout is triggered if and only if its pod template changes, so a pipeline that pushes a patched image under an unchanged tag has fixed the registry, not the workload. Write the new digest into the pod template: the change becomes the rollout, and the rollout becomes what you verify.
At admission, the gate is a signed vulnerability attestation, produced the way the supply-chain pipeline signs its SBOMs. Trivy can emit results as cosign-vuln, and cosign's attestation specification defines that predicate with optional scanner.db.uri and scanner.db.version fields and a required metadata.scanFinishedOn timestamp. Kyverno's legacy policy library ships a ClusterPolicy that admits an image only if that timestamp is no older than 168 hours. Do not start a new cluster on it. Kyverno 1.17 marked ClusterPolicy deprecated, 1.19 (August 2026) is the final release with full support for it, and removal is planned for 1.20, estimated November 2026.
The replacement is an ImageValidatingPolicy, whose CEL environment provides verifyAttestationSignatures and extractPayload, plus the time.now() function added in 1.17. Two details decide whether it works. First, intoto.type must equal the predicateType in your attestations: cosign's v3.1.3 specification writes the vuln predicate type with an https:// scheme, while Kyverno's documentation example omits it. Second, extractPayload returns the in-toto payload, so the scan timestamp sits under predicate. Test with kyverno apply against an image your own pipeline attested before enforcing; Kyverno versus Gatekeeper covers the wider policy model.
# require-vuln-attestation.yaml: Kyverno 1.19 ImageValidatingPolicy.
# intoto.type must equal the predicateType your attestations carry.
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: require-vuln-attestation
spec:
validationActions: [Deny]
failurePolicy: Fail
webhookConfiguration:
timeoutSeconds: 15
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
operations: ["CREATE", "UPDATE"]
matchImageReferences:
- glob: "registry.example.com/platform/*"
attestors:
- name: ci
cosign:
keyless:
identities:
- subject: "https://git.example.com/platform/images/.forgejo/workflows/build.yaml@refs/heads/main"
issuer: "https://token.example.com"
ctlog:
url: "https://rekor.sigstore.dev"
attestations:
- name: vuln
intoto:
type: https://cosign.sigstore.dev/attestation/vuln/v1
validations:
- expression: >-
images.containers.map(image,
verifyAttestationSignatures(image, attestations.vuln, [attestors.ci])).all(n, n > 0)
message: "every image needs a signed cosign-vuln attestation from CI"
# Proves when the predicate was written, not which vulnerability DB was
# used: Trivy's cosign-vuln output leaves scanner.db empty.
- expression: >-
images.containers.map(image,
verifyAttestationSignatures(image, attestations.vuln, [attestors.ci]) > 0
? time.now() - timestamp(extractPayload(image, attestations.vuln).predicate.metadata.scanFinishedOn)
<= duration("168h")
: false).all(fresh, fresh)
message: "vulnerability attestation is older than 168h; re-scan and re-attest"Read the second validation for what it proves. Trivy's cosign-vuln writer, unchanged between v0.72.0 and v0.74.0, sets the scanner URI, version and result but never sets scanner.db, and it assigns scanStartedOn and scanFinishedOn the same clock reading, taken when the predicate is written. The check proves the attestation is recent, not that the database was; that gap closes at verification.
Suppress on Purpose: VEX, Ignore Files and Expiry
Some findings have no fix; some are real but unreachable. Both deserve a recorded decision, and the common shortcut records nothing. Trivy's --ignore-unfixed is shorthand for --ignore-status with affected, will_not_fix, fix_deferred and end_of_life, and will_not_fix means the package is affected and there is currently no intention to fix it. The flag drops exactly the findings that most need a risk decision.
A suppression worth keeping is scoped to a package, carries a reason, and comes back on its own. Trivy's .trivyignore.yaml supports all three, and an entry without expired_at is valid forever:
# .trivyignore.yaml: accepted risks, each scoped to a package and dated.
# An entry without expired_at never expires. Keys checked at Trivy v0.74.0.
vulnerabilities:
- id: CVE-2021-23337
purls:
- pkg:npm/lodash@4.17.20
statement: "SEC-142: affected function never receives untrusted input; upgrade scheduled"
expired_at: 2026-10-31
- id: CVE-2020-28500
purls:
- pkg:npm/lodash@4.17.20
statement: "SEC-143: affected functions not reachable from request handlers"
expired_at: 2026-12-31For a finding that does not apply, write a VEX statement. OpenVEX requires a not_affected statement to carry either a status justification or an impact statement, and defines justifications such as vulnerable_code_not_in_execute_path. The document is portable: both Trivy and Grype accept it through --vex. Trivy marks its VEX file support EXPERIMENTAL, in its documentation and in the flag's own help text, so pin the version you depend on.
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://example.com/vex/lodash-2026-09-15",
"author": "Platform Security <security@example.com>",
"timestamp": "2026-09-15T00:00:00Z",
"version": 1,
"statements": [
{
"vulnerability": {
"name": "CVE-2021-23337",
"aliases": ["GHSA-35jh-r3h4-6jhm"]
},
"products": [{ "@id": "pkg:npm/lodash@4.17.20" }],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path",
"impact_statement": "The service never calls the affected function, so the vulnerable code is not executed."
}
]
}The aliases field is not decoration. GitHub advisory GHSA-35jh-r3h4-6jhm is the lodash command-injection advisory for CVE-2021-23337, fixed in 4.17.21: one flaw, two identifiers, and which one a scanner reports as primary depends on the advisory source for that ecosystem. In Grype v0.118.0, an ignore rule's vulnerability is compared with the match's own ID, and with its related IDs only when include-aliases is true. Its OpenVEX filter looks statements up by the match's own ID, and go-vex v0.2.8, the version Grype pins, accepts a statement whose ID, name or listed alias equals it.
So if a lodash match comes back keyed as the GHSA, a CVE rule ignores it only with include-aliases, and a CVE statement applies only if it lists the GHSA. Check .matches[].vulnerability.id and .matches[].relatedVulnerabilities[].id in Grype's JSON first. Grype rules also have no expiry field:
# .grype.yaml: Grype v0.118.0 ignore rules have no expiry field. Keep the
# end date in the OpenVEX document or in the review that owns this rule.
ignore:
- vulnerability: CVE-2021-23337
# Also match when Grype reports the finding under an alias such as
# GHSA-35jh-r3h4-6jhm and lists the CVE as a related vulnerability.
include-aliases: true
package:
name: lodash
version: 4.17.20
type: npm
reason: "SEC-142: affected function never receives untrusted input"Verify: The Stage Self-Run Pipelines Skip
NIST SP 800-40 Rev. 4 defines the step precisely: for patching, verification means confirming that the patch is installed and has taken effect. For a container, installed means running, and the source of truth is the kubelet, not the manifest. Each container status reports an imageID, the image the runtime resolved, which may not match the image written in the pod spec.
Closing a finding is then mechanical. Read every imageID for the workload, re-scan each digest against a freshly updated database, and assert that neither the finding's ID nor any alias appears. Print the database build used: a clean re-scan against a stale database is as unchecked as a closed ticket. The script exits non-zero while the finding is open on any running digest, so it can gate a pipeline.
#!/usr/bin/env bash
# verify-closure.sh: a finding is closed only if the digests actually running
# no longer carry it. Written against Trivy v0.74.0 and Trivy Operator v0.34.0.
# Run it where no ignore file is picked up: closure is about the image, not the waiver.
# usage: verify-closure.sh <namespace> <deployment> <finding-id> [alias-id ...]
set -euo pipefail
NS="${1:?usage: verify-closure.sh <namespace> <deployment> <finding-id> [alias-id ...]}"
DEPLOY="${2:?missing deployment}"
shift 2
(($# > 0)) || { echo "missing finding id" >&2; exit 64; }
IDS="$(printf '%s\n' "$@" | jq -R . | jq -sc .)"
for bin in kubectl trivy jq; do
command -v "$bin" >/dev/null || { echo "missing: $bin" >&2; exit 127; }
done
selector="$(kubectl -n "$NS" get deployment "$DEPLOY" -o json |
jq -r '.spec.selector.matchLabels | to_entries | map("\(.key)=\(.value)") | join(",")')"
# What the kubelet reports running, not what the manifest asked for.
mapfile -t running < <(kubectl -n "$NS" get pods -l "$selector" -o json |
jq -r '.items[].status.containerStatuses[]?.imageID' |
sed 's#^docker-pullable://##' | sort -u)
((${#running[@]} > 0)) || { echo "no running containers for $NS/$DEPLOY" >&2; exit 1; }
trivy image --download-db-only --quiet
echo "vulnerability DB updatedAt: $(trivy version --format json | jq -r '.VulnerabilityDB.UpdatedAt')"
reports="$(kubectl -n "$NS" get vulnerabilityreports -o json 2>/dev/null || echo '{"items":[]}')"
status=0
for ref in "${running[@]}"; do
if [[ "$ref" != *@sha256:* ]]; then
echo "UNVERIFIED $ref: no repository digest to re-scan" >&2
status=1
continue
fi
# Trivy lists other advisory IDs for a finding, such as a GHSA, under VendorIDs.
hits="$(trivy image --skip-db-update --quiet --format json "$ref" |
jq -r --argjson ids "$IDS" '
[.Results[]?.Vulnerabilities[]?
| select([.VulnerabilityID, (.VendorIDs // [])[]] | any(IN($ids[])))
| "\(.VulnerabilityID) \(.PkgName) \(.InstalledVersion)"]
| unique | .[]')"
if [[ -n "$hits" ]]; then
echo "OPEN $ref"
while IFS= read -r line; do echo " $line"; done <<<"$hits"
status=1
else
echo "CLOSED $ref"
fi
jq -r --arg d "${ref##*@}" '.items[]
| select(.report.artifact.digest == $d)
| " operator report \(.metadata.name), updateTimestamp \(.report.updateTimestamp)"' <<<"$reports"
done
exit "$status"The Operator's report is a cross-check, not a substitute. Before trusting a clean VulnerabilityReport, confirm that its artifact digest is the digest now running and that its updateTimestamp is later than the rollout; a report about the previous digest says nothing about the new one. Keep the closure output: it answers "is it fixed?" with an observation, not an assertion.
Report: Evidence Someone Else Can Re-Run
Reporting is where the lifecycle meets an assessor, and the controls it maps to leave the numbers to you. NIST SP 800-53 Rev. 5 RA-5(2) requires updating the vulnerabilities to be scanned at an organisation-defined frequency, before a new scan, or when new vulnerabilities are identified. SI-2(c) requires installing security-relevant updates within an organisation-defined time period of their release. Both are assignments, so an organisation that never wrote its period down cannot show it meets one. The NIST 800-53 control-mapping article covers the wider catalogue; for this lifecycle, keep per digest:
- the requested reference and the pinned digest;
- the scanner version and database build time, from
trivy version --format jsonorgrype db status; - the full JSON report, plus the ignore files and VEX documents in force, from version control;
- the risk response chosen for each finding, with its reason and expiry;
- the closure re-scan of the running digest, with the database build it used.
With the digest, scanner version and database build on file, a reviewer who was not there can re-run the scan and explain any difference.
Exit Ramps and the Long Game: The Scanner Is in Scope
The scanner is a dependency, and in March 2026 it was the attack path. On 19 March, compromised credentials were used to publish a malicious Trivy v0.69.4, force-push 76 of 77 version tags in aquasecurity/trivy-action to credential-stealing malware, and replace all 7 tags in aquasecurity/setup-trivy (CVE-2026-33634). The advisory notes that v0.69.3 was protected by GitHub's immutable releases, and lists SHA pinning to a safe commit among the configurations that were not affected. Treat scanner binaries and CI actions like any other supply-chain input: pin actions by commit and scanner images by digest, served from a registry you run.
Keep decisions portable. Suppressions written as OpenVEX are read by both Trivy and Grype, so changing scanners does not mean re-deciding every accepted risk; keep tool-specific ignore files thin. Budget for policy-API churn too: an admission gate written against a deprecated type has an end date nobody chose.
The lifecycle outlives every tool in it. The Trivy database rebuilds every six hours, policy types are deprecated, directives are revoked and replaced. What lasts is the shape of the evidence: a digest, the data it was judged against, a decision with an end date, and a re-scan of what is actually running. Build that once, and replacing the scanner becomes a configuration change.
§FAQ/Common questions
Frequently asked
What are the stages of the vulnerability management life cycle?
Five: discover, prioritise, remediate, verify and report. When you run the tooling yourself, each stage should leave evidence: a scan of an image digest with the database build recorded, a triage decision using CVSS, EPSS and KEV data, a rebuilt image rolled out through a pod template change, a re-scan of the digest the kubelet reports running, and a stored record an assessor can re-run.
Why is a rebuilt image not proof that a vulnerability is fixed?
Pushing a patched image changes the registry, not the cluster. A Deployment rolls out only when its pod template changes, and each container status reports the imageID the runtime actually resolved. A finding is closed when a re-scan of that running digest, against a current vulnerability database, no longer reports the finding or its aliases.
Is trivy --ignore-unfixed safe to use in a CI gate?
It hides findings rather than resolving them. In Trivy v0.74.0 the flag is shorthand for ignoring the statuses affected, will_not_fix, fix_deferred and end_of_life, and will_not_fix means the package is affected with no current intention to fix it. Record a risk decision for those instead: a scoped .trivyignore.yaml entry with expired_at, or an OpenVEX not_affected statement with a justification.
Why does a Grype ignore rule for a CVE not suppress the finding?
Grype may report the match under another identifier, such as a GitHub advisory ID, with the CVE listed as a related vulnerability. In Grype v0.118.0 an ignore rule compares its vulnerability value with the match's own ID, and with related IDs only when include-aliases is true. An OpenVEX statement for the CVE likewise applies to a GHSA-keyed match only if it lists that GHSA as an alias.
Does CISA BOD 26-04 apply to private companies?
No. BOD 26-04, issued on 10 June 2026, applies to federal information systems. Its four decision variables, asset exposure, KEV status, exploit automation and technical impact, are still a useful triage model for any operator, but its remediation timelines are not a private organisation's obligation. Set your own periods, as NIST SP 800-53 SI-2(c) expects.
Further reading
- Syft SBOM Blind Spots: Catalogers, Unknowns and Grype
- Supply Chain Security: SBOM, Sigstore and Admission Control
- Distroless Without a Vendor: Building Your Own Base Images
- Kyverno vs OPA Gatekeeper: Policy as Code at Cluster Scale
- NIST SP 800-53 on Kubernetes You Own: AC, AU, CM, SC, SI
- Vulnerability Assessment and Penetration Testing: The Report
- Own the Registry: Harbor and Zot for Air-Gapped Images
Executive Briefing
Thirty minutes to clarify your infrastructure risk
Walk us through your vendor footprint and regulatory constraints. We will tell you honestly where sovereignty creates leverage — and where it does not. No pitch deck. No obligation.