Trivy Operator vs CI Image Scanning
CI image scans miss cluster drift; Trivy Operator misses the build. When the two overlap, how to dedupe CVEs, and when running both is the right call.
The image was clean in GitHub Actions on Monday. Thursday, the same digest is still running, and NVD published a CRITICAL on Wednesday. CI will not notice until someone rebuilds. Trivy Operator vs CI scanning is that timing gap, not a brand preference.
This is the decision: where Trivy runs, what each miss looks like, how duplicate CVEs happen, and when both are required. It is not a generic “scan all the containers” lecture and not a pipeline-only how-to. Image and admission patterns sit in cloud-native application security.
git push
→ CI: trivy image $DIGEST fail the PR
→ registry
→ kubelet pull
→ Trivy Operator VulnerabilityReport on the owner
If either arrow is missing, you have a story, not coverage.
CI catches build
Pipeline Trivy runs against a digest you just built (or a base you just bumped). That is the right place to:
- Fail the PR on
CRITICAL/HIGHyou have not ignored - Scan the Dockerfile filesystem and the image, not only the lockfile
- Keep
.trivyignorenext to the app so ignores are reviewed like code
# GitHub Actions — pin the scanner and fail closed
- uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: ghcr.io/org/payments@${{ steps.build.outputs.digest }}
exit-code: "1"
severity: CRITICAL,HIGH
ignore-unfixed: true
Pin the action and, if you can, the DB. trivy image with a floating latest action plus a DB that updated mid-sprint is how Monday’s green build becomes Tuesday’s red rebuild with no code change. That can be desirable (new CVE) or noise (parser false positive). Decide which, in the workflow comment.
What CI cannot see:
- Images that never pass this workflow: Bitnami charts, cluster autoscaler, a debug
kubectl run --image=nocolor/ubuntu - Config and RBAC inside the cluster (Trivy can scan YAML in CI if you render Helm; most teams only scan the app image)
- A digest that was allowed last month and never rebuilt
Failure mode: CI scans tag: main while production runs tag: v1.4.2 from a different digest. Scan the digest the deploy job will pin. If the deploy job retags, scan after retag.
Operator catches drift in cluster
Trivy Operator watches workloads and writes VulnerabilityReport, ConfigAuditReport, and friends as Kubernetes objects. It answers: what is running right now, including images you did not build.
Install it in a dedicated namespace, give it only the RBAC it needs, and point reports at a collector (or scrape them). Scan interval and scanJobTTL matter: too aggressive and you melt the API server with Jobs; too slow and Thursday’s CVE waits until next Tuesday.
kubectl get vulnerabilityreports -A \
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.labels.trivy-operator\.resource\.name}{" "}{.report.summary.criticalCount}{"\n"}{end}'
Drift the operator is for:
- A Helm upgrade that changed the image without a CI rebuild of your app
- Sidecars and init containers added by a mutating webhook
- NVD/GHSA updates against an old digest that is still
Running
Failure mode: Operator in dev only. Prod is “too sensitive for extra Jobs.” Then prod is the cluster where you need the report. Run the operator where the blast radius is, with resource quotas on the scan Jobs.
The operator is not a substitute for admission. A VulnerabilityReport with criticalCount: 4 on a running Deployment means the image was admitted. Pair with Kyverno/VAP if you want to block unsigned or unscanned digests at create time—that is still application security, not this decision.
Duplicate CVEs
Same digest, two tickets, two severities, two ignore lists.
| Cause | What you see | Fix |
|---|---|---|
| Different DB age | CI HIGH, operator CRITICAL a day later | One freshness SLA; reopen on digest+CVE when severity jumps |
.trivyignore only in the app repo |
CI silent, operator loud | Mount the same ignore ConfigMap or do not ignore in-cluster |
| OS vs library classifiers | debian vs python package names |
Dedupe on CVE id + digest, not package string |
| Tag vs digest | CI scanned :latest, operator scanned yesterday’s :latest |
Dedupe on digest only |
Ticket key: sha256:abc… + CVE-2026-…. Anything else double-counts.
Do not auto-close the operator finding when CI is green on a different digest. That is how a sidecar stays vulnerable forever.
For ranking, a duplicate CRITICAL on an isolated CronJob is still one finding. An internet-facing Service plus that CVE is a path—use prioritization, not whichever scanner shouted last.
When to run both
Run both when any of these are true (they usually are):
- Developers merge app images through CI and the cluster runs third-party images.
- Production digests live longer than your patch SLA (almost always).
- You need ConfigAudit / RBAC reports from the operator that CI never rendered.
Run CI only if the cluster is a sealed appliance: every image is built in this pipeline, admission denies everything else, and you rebuild on a timer when the DB moves. That is a lab or a tightly GitOps’d fleet, not a typical shared platform.
Run operator only if you do not control the build (ISV images, inherited platform). You still want a registry scan on ingest if you can; operator is the backstop.
Hybrid wiring that does not melt Slack:
- CI: fail PR on CRITICAL for app images; do not Slack the same CVE from the operator for that digest for 24 hours
- Operator: alert on new CRITICAL on a digest with no CI ticket, or on any CRITICAL on a namespace labeled
internet-facing=true - Weekly: join reports on digest; file one owner per image, not per scanner
Checklist
- CI scans the digest the deploy job pins, not a moving tag
- Trivy action/DB pin is documented; ignore file lives with the app
- Operator runs in prod, with quotas on scan Jobs
- Tickets dedupe on
digest + CVE, one ignore source of truth - Operator alerts filtered by exposure, not raw criticalCount
- Third-party and sidecar images have an owner or an admission deny
Related: Cloud-native application security · How to prioritize cloud vulnerabilities