Skip to content

Trivy Operator

Aqua Security's Trivy Operator scans everything the cluster runs and writes every result back as a Kubernetes object. Reading those objects is Vulnerability Triage; this page is the operator itself.

At a glance

Namespace trivy-system
Depends on Monitoring for the ServiceMonitor and the dashboard, Rook-Ceph for the vulnerability database volume
If it is down Reports expire after 24h and are not rebuilt; TrivyContainerNotScanned fires for every container
Health check kubectl -n trivy-system get pods,jobs, then the reconciliation below
Dashboard Trivy Operator
Files payload/platform/trivy-operator/: the chart, prometheusrule.yaml, the findings-history CronJob and a dashboard written for this cluster. The namespace labels and the default-deny policy are in Security Policies
Scanner Produces Answers
Vulnerability VulnerabilityReport Which CVEs are in the images we run
Exposed secret ExposedSecretReport Which images have credentials baked in
Config audit ConfigAuditReport Which workloads are misconfigured
RBAC assessment RbacAssessmentReport Which roles grant too much
Infra assessment InfraAssessmentReport Whether the nodes pass CIS
Compliance ClusterComplianceReport CIS 1.23, NSA/CISA 1.0, PSS baseline and restricted

The eks- and rke2- compliance specs are for other distributions and are left out rather than reported as failing.

Configuration

The values in application.yaml that are not defaults:

Setting Why
scannerReportTTL: 24h, scanOnlyCurrentRevisions, scanJobTTL: 10m Reports live in etcd. A stale report is rebuilt, superseded ReplicaSets are not scanned, and finished jobs do not accumulate
builtInTrivyServer: true ClientServer mode: one trivy-server holds the vulnerability database on the PVC. Standalone mode downloads it in every scan job, which meets registry rate limits, and a rate-limited scan reports zero findings rather than an error
ignoreUnfixed: false, every severity A finding with no fix is usually the signal that the base image is wrong, and a finding the scanner could not grade is worth reading
ignorePolicy.nextcloud, ignorePolicy.authentik Those images carry thousands of unfixed Debian findings and their reports exceed the etcd request limit. The Rego (v0 syntax, object.get because FixedVersion is absent when there is no fix) drops only findings that have no fix and are below HIGH
additionalVulnerabilityReportFields without Description and Links A report is one API write, capped at 2 MiB; Links alone is over half the payload. An oversized report is rejected with nothing but an operator log line
operator.scanJobTimeout and trivy.timeout, both 20m The first is the Job's activeDeadlineSeconds and kills the pod whatever Trivy is doing; the second is Trivy's own flag and only fires if it is the shorter. Raising one alone does nothing
clusterSbomCacheEnabled: false With the cache, every workload after the first to use an image gets an SbomReport from the cached SBOM and no VulnerabilityReport. Upstream: trivy-operator#1668
metricsVulnIdEnabled: false One series per CVE per container is a six-figure cardinality, and every label is already in the report
compliance.reportType: summary The collector skips reports in all format, so trivy_compliance_info disappears and the dashboard's failing-controls table empties. The status label is title-cased: Fail, Pass
Control-plane tolerations on nodeCollector and scanJobTolerations Without them the CIS infra assessment covers only the workers and reports a clean control plane it never looked at
Namespace enforce: privileged, audit/warn: restricted node-collector hostPath-mounts /var/lib/etcd, /var/lib/kubelet, /etc/kubernetes and /etc/cni/net.d, which baseline forbids. Scan jobs themselves drop all capabilities and run read-only
logDevMode: false Its V(1) lines are the only way to see decisions the operator makes silently, but it switches logging to console encoding. Turn it on to debug, then off

Alerting

Five rules in prometheusrule.yaml. Reports expire and are rebuilt every 24h, so a "new" finding is one absent from the reports two days ago rather than from the previous scrape, and every rule waits longer than a rebuild.

Alert Fires when Why this shape
TrivyContainerNotScanned A running container has had no VulnerabilityReport for 2h A rejected report is never written, so it looks identical to a clean one; only reconciling against what runs finds it
TrivyExposedSecretNew A secret finding, keyed by container, rule and path, was not reported two days ago The known findings (snake-oil keys, a vendored SDK's public test key) never clear, so an absolute rule would fire forever
TrivyRbacCriticalNew A Role or ClusterRole gains a critical finding it did not have two days ago Every critical ClusterRole is a chart's operator role or Kubernetes' own, see Vulnerability Triage; what matters is a new one
TrivyConfigAuditCritical Any workload fails a critical config-audit check for 2h Nothing does today, so the rule is absolute
TrivyComplianceRegression A framework has more failing controls than a day ago, for 12h Compliance is recomputed every six hours; two runs above yesterday is drift, not a rebuild

The two "new" rules also require the operator to have been reporting two days ago, so a fresh cluster does not page on every existing finding.

TrivyContainerNotScanned compares every running container against the containers that have a VulnerabilityReport and names the difference:

count by (namespace, container) (
  kube_pod_container_info
  * on (namespace, pod) group_left ()
  (max by (namespace, pod) (kube_pod_status_phase{phase="Running"} == 1))
)
unless on (namespace, container)
count by (namespace, container) (
  label_replace(trivy_image_info, "container", "$1", "container_name", "(.+)")
)
  • Keyed on (namespace, container), not the digest: containerd's platform digest and the registry's tag digest differ permanently for mirrored images.
  • Restricted to Running pods, or every completed Job counts as unscanned.
  • for: 2h, several times a full sweep, so the 24h TTL churn does not page.

The dashboard's stat and by-severity panels take max by (image_digest) before summing, and workload panels join ReplicaSets against kube_replicaset_spec_replicas > 0, so a finding counts once per image and a superseded revision drops out. The "Worst workloads" table stays per container on purpose: it answers where a finding runs.

History

Reports live 24h and Prometheus keeps ten days, so neither can answer whether a count went up over a quarter. The findings-history CronJob in findings-history.yaml lists every report kind once a day and prints one JSON line per namespace, and per compliance framework, to stdout; Alloy ships it like any container log and Loki keeps that one stream for a year (retention_stream in loki/application.yaml). The dashboard's last row reads it. Images are counted once per digest per namespace, the same rule the dashboard applies.

{namespace="trivy-system", container="findings-history"} | json | kind="vulnerabilities" | namespace="argocd"

Usage

The dashboard is a summary; the objects have the detail.

# What has been scanned, worst first
kubectl get vulnerabilityreports -A --sort-by='.report.summary.criticalCount'

# The findings themselves, for one workload
kubectl get vulnerabilityreports -n <ns> <name> -o json \
  | jq '.report.vulnerabilities[]
        | select(.severity=="CRITICAL")
        | {id: .vulnerabilityID, pkg: .resource,
           installed: .installedVersion, fixed: .fixedVersion}'

# Misconfiguration, RBAC and node checks
kubectl get configauditreports -A
kubectl get rbacassessmentreports,clusterrbacassessmentreports -A
kubectl get infraassessmentreports -A

# Which compliance controls are failing
kubectl get clustercompliancereports k8s-cis-1.23 -o json \
  | jq '.status.summary, (.status.summaryReport.controlCheck[]
        | select(.totalFail > 0))'

Health check

Reconcile running images against scanned ones; treat a difference as a missing scan until proven otherwise:

kubectl get pods -A -o json \
  | jq -r '.items[].spec.containers[].image' | sort -u | wc -l
kubectl get vulnerabilityreports -A -o json \
  | jq -r '.items[].report.artifact.repository' | sort -u | wc -l

Zero is not the same as clean

A report that reads zero may be a clean image or a scan that failed: a rate-limited pull, a timeout, an image the operator could not resolve. Check the object exists and carries a real digest before believing a zero, and look at kubectl -n trivy-system get jobs when one never appears. This is where the vulnerability-triage skill in .claude/skills/ starts.

Pitfalls

Four ways a workload ends up with no VulnerabilityReport, all silent from the outside:

Symptom Cause Where to look
No report, no SbomReport, operator logs ResourceExhausted ... larger than max or etcdserver: request is too large The report exceeds the API write ceiling Operator log. Fix: the fields and ignore policies above
No report, Job events show DeadlineExceeded scanJobTimeout elapsed on a large image kubectl -n trivy-system get events --field-selector reason=DeadlineExceeded, before scanJobTTL deletes the Job
Some containers of a multi-container workload missing; job succeeded; log has failed to analyze layer ... unexpected EOF A scan container died reading the image and the operator keeps only containers that exited 0. Hits workloads with several containers on one image (Rook, Cilium). Not the shared cache: concurrent scans with it pass Scan job pod logs
SbomReport exists, no VulnerabilityReport SBOM cache reuse path ran trivy sbom and produced nothing Confirm clusterSbomCacheEnabled is off

A completed scan job counts against concurrentScanJobsLimit until scanJobTTL deletes it, so a backlog drains in batches of ten every ten minutes; that is the expected shape, not a stall.