Exit Codes Are the Cheapest Gate You Will Ship

One flag decides whether a Trivy scan blocks a pipeline. On the same image, --severity CRITICAL exits 0 and HIGH,CRITICAL --ignore-unfixed exits 1 — and the gate that actually fires is the one that costs 115 milliseconds.

Exit Codes Are the Cheapest Gate You Will Ship

Before any admission controller, any policy engine, any operator reconciling VulnerabilityReport CRDs, there is a gate that costs one flag: trivy image --exit-code 1. It runs in the pipeline you already have, it needs no cluster, and it either returns 0 or it returns 1. The interesting part of measuring it was not that it works. It was that two reasonable-looking severity selections disagree about the same image.

The same image, two answers

Fixture tc07b is an old ubuntu:jammy-20230624. Gated on any CRITICAL:

trivy image --quiet --exit-code 1 --severity CRITICAL poc/tc07b:v1
# EXIT_CODE=0   elapsed_ms=1980

Gated on fixable HIGH-or-above:

trivy image --quiet --exit-code 1 --severity HIGH,CRITICAL \
  --ignore-unfixed poc/tc07b:v1
# EXIT_CODE=1   elapsed_ms=115

Pass, then fail, on unchanged content. The counts explain it exactly: tc07b carries 238 findings total, 0 of them CRITICAL, 4 HIGH, and 4 fixable across CRITICAL+HIGH. A CRITICAL-only gate on that image is a gate with nothing to catch. Widen one notch to HIGH and the same image has four actionable, patchable findings — and the pipeline stops.

Same image, two severity selections, two exit codes
Same image, two severity selections, two exit codes

One image, one content digest: the CRITICAL-only gate passes it, the fixable-HIGH+ gate fails it, and the counts behind each verdict.

Do not read the timings as cost

The 1980 ms above is the tempting number, and it is the wrong one. It was the first invocation of the run and carries the warm-up the later ones do not. Every subsequent scan in the same sequence lands between 115 ms and 332 ms — tc01 at 203 ms and 140 ms, tc06b at 332 ms and 116 ms, and a report-only control (--exit-code 0) on tc07b at 119 ms. That is the honest figure to budget: a couple hundred milliseconds per image, not two seconds, and nothing that shows up next to a build step.

Cheap is the whole argument. A gate you can afford to run on every push is a gate that gets run.

Why --ignore-unfixed belongs in the day-one gate

tc01 (debian:12.4-slim) reports 269 findings, 7 CRITICAL, 41 HIGH — and 26 fixable across CRITICAL+HIGH. tc06b (alpine:3.18.0) reports 49 total, 3 CRITICAL, 6 HIGH, 9 fixable. The gap between "HIGH and CRITICAL" and "HIGH and CRITICAL that someone has shipped a fix for" is the gap between a gate a team can clear and a gate a team learns to bypass. --ignore-unfixed is not leniency; it is the difference between a blocking signal and a backlog.

The other exit code nobody wires up

trivy config is the same mechanism pointed at source, and it fires too: exit 1 on the fixtures directory (1196 ms) and exit 1 on the Kyverno test manifests (1149 ms). 26 misconfigurations total, and the breakdown is the familiar shape — one CRITICAL for secrets passed via build-args or env (DS-0031), 11 HIGH for an image user of root (DS-0002), 11 LOW for no HEALTHCHECK (DS-0026), two MEDIUM for a :latest tag (DS-0001), one MEDIUM for port 22 exposed (DS-0004).

Note where that second run pointed: at our own policy test manifests. The config gate flags the repo that holds the security policies. That is not embarrassing, it is the point — the gate has no opinion about whose YAML it is reading.

Everything downstream of this — admission webhooks, signature verification, registry pull policy — is more powerful and vastly more expensive to operate. Ship the exit code first.

Next: nine compliance specs hidden in the binary — the frameworks Trivy already ships that nobody enabled.