The problem
A pipeline step runs a security scanner, prints a wall of red findings, and the build still goes green. That is the situation I wanted to reproduce with Trivy, an Apache-2.0 licensed scanner for dependencies, Dockerfiles, Terraform and secrets. Its latest push-triggered run on main, the Canary build workflow, concluded with success. The question for this article is narrow: how do I make Trivy a real gate that blocks a bad commit, without drowning in noise and without silently trusting it more than I should? I answer it on a small test project and report every limit I met.
The test bed
The sandbox is a clean container provided for this session, not a production machine, and I make no claims about its hardware or location beyond what these two commands print. Everything below ran inside it, and Trivy had to download its vulnerability database and checks bundle on a scan, as described later.
uname -a
Linux vm 6.18.44-fc-v378 #1 SMP PREEMPT_DYNAMIC @0 x86_64 x86_64 x86_64 GNU/Linux
cat /etc/os-release
PRETTY_NAME="Ubuntu 24.04.5 LTS"
NAME="Ubuntu"
VERSION_ID="24.04"
VERSION="24.04.5 LTS (Noble Numbat)"
VERSION_CODENAME=noble
I built Trivy from a shallow clone and created a small project in /tmp/work with four deliberately bad files: a requirements.txt pinning old Flask, requests and PyYAML, a Dockerfile that runs as root, a Terraform file with a public-read S3 bucket, and a config.env holding a randomly generated AWS-shaped key pair. The first scan found 13 HIGH or CRITICAL issues, and the process still exited with code 0.
cd /tmp/work && /tmp/trivy-bin fs --quiet --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --format json --output /tmp/base.json . ; echo "exit=$?"
exit=0
To see what the report contained, I listed it with a small jq filter. Four dependency CVEs, one Dockerfile check, six Terraform checks and two secrets were all present, and the exit code ignored every one of them. A CI job that only looks at the exit status would have passed this commit without any complaint.
jq -r '.Results[] | .Target as $t | ((.Vulnerabilities//[])[] | "\($t) \(.VulnerabilityID) \(.Severity)"), ((.Misconfigurations//[])[] | "\($t) \(.ID) \(.Severity)"), ((.Secrets//[])[] | "\($t) \(.RuleID) \(.Severity)")' /tmp/base.json
requirements.txt CVE-2023-30861 HIGH
requirements.txt CVE-2020-14343 CRITICAL
requirements.txt CVE-2020-1747 CRITICAL
requirements.txt CVE-2018-18074 HIGH
Dockerfile DS-0002 HIGH
main.tf AWS-0086 HIGH
main.tf AWS-0087 HIGH
main.tf AWS-0091 HIGH
main.tf AWS-0092 HIGH
main.tf AWS-0093 HIGH
main.tf AWS-0132 HIGH
config.env aws-access-key-id CRITICAL
config.env aws-secret-access-key CRITICAL
The cause
Nothing is broken here. Trivy is a reporting tool first, and its exit status describes whether the scan itself worked, not whether it found problems. A CI system only looks at the exit status, so a scanner that exits 0 is indistinguishable from a clean scan. The findings exist in the log, but a log line cannot fail a job.
There is a second, quieter cause that matters once you do turn the gate on: a gate is only as good as the rules behind it. Trivy ships built-in allow rules that deliberately skip some content, and I ran into one of them while testing. I describe it in the review at the end, because it changes how far you should trust a green result.
Source of truth
The behaviour is documented in the repository itself. The exit code page states that Trivy exits with code 0 even when security issues are detected, and that the exit-code option changes this. I printed those lines from the cloned tree rather than trusting my memory.
cd /tmp/trivy && sed -n 30,31p docs/guide/configuration/others.md
By default, `Trivy` exits with code 0 even when security issues are detected.
Use the `--exit-code` option if you want to exit with a non-zero exit code.
The filtering page documents the .trivyignore file, including an exp: date after which an ignore entry stops applying. For the secret scanner's skip behaviour, the source of truth is the code: builtin-allow-rules.go. The flag itself is declared in report_flags.go, where its help text says it sets the exit code when any security issues are found. Together these confirm the default, the switch, the exception syntax and the one built-in skip I will return to.
Possible solutions
There are several ways to turn findings into a pipeline decision, and they trade strictness against maintenance effort. The table lists the four I considered, together with the main weak point of each, so you can pick according to how much logic you want to own yourself.
| Method | What it does | Weak point |
|---|---|---|
| Parse the JSON report yourself | Full control, any policy you can express in jq or code | You own the logic and must keep it correct |
| Exit code with severity filter | Non-zero exit only for the severities you choose | Blunt: any single match fails the build |
| Exit code plus an ignore file | Same gate, with reviewed and expiring exceptions | Exceptions can rot if nobody reads them |
| A packaged CI integration | Official actions and plugins listed in the CI/CD docs | Another layer between you and the CLI behaviour |
Why this fix
I picked the exit code with a severity filter, plus a .trivyignore file whose entries carry an expiry date, and put the settings into a config file. It uses only flags the project documents, it needs no extra tooling, and it works the same in any CI system because the only interface is the process exit status.
I rejected hand-parsing JSON for the gate itself because the exit-code option already does the same job in one flag. I kept JSON output only to inspect results while testing. A packaged integration is a good choice when you already live on one platform, but I wanted to see the raw CLI behaviour first.
Step by step
Build the binary
The contributor guide builds with Mage, but I did not install Mage and built the main package directly with go build instead. The sandbox has Go 1.24.7, while the repository's go.mod asks for a newer release, which turned out not to matter.
go version
go version go1.24.7 linux/amd64
cd /tmp/trivy && grep -n "^go " go.mod
3:go 1.27.0
Go downloaded the matching toolchain on its own, as described in the Go toolchain documentation. Inside the repository directory the version changes to the required one. The first build also fetched all modules and was slow, so I started it and waited; I only show the timing of the repeat build, which was served from the build cache.
cd /tmp/trivy && go version
go version go1.27.0 linux/amd64
cd /tmp/trivy && time go build -o /tmp/trivy-bin ./cmd/trivy
real 0m0.680s
On the first scan Trivy downloaded its vulnerability database (about 133 MiB, roughly 1.7 GB unpacked on disk) and its checks bundle. This needs outbound network access, which the sandbox had; a locked-down CI runner would need a mirror or a pre-seeded cache. The built binary reports the state of that cache with its version flag:
/tmp/trivy-bin --version
Version: dev
Vulnerability DB:
Version: 2
UpdatedAt: 2026-11-12 12:59:19.777478911 +0000 UTC
Turn on the gate
Adding --exit-code 1 to the same scan turns the 13 findings into a failing step. Nothing else changed in the command, the project or the cache, so the exit status is the only difference between the green run above and the red run below.
cd /tmp/work && /tmp/trivy-bin fs --quiet --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 --format json --output /tmp/gate.json . ; echo "exit=$?"
exit=1
Fix the real problems
Next I fixed the genuine problems instead of silencing them. I bumped the three dependencies to newer releases, added a USER line to the Dockerfile, and replaced the literal keys with environment variable references. These two files show the result of those edits, printed from the sandbox.
cd /tmp/work && cat requirements.txt
flask==3.1.3
requests==2.33.0
pyyaml==6.0.2
cd /tmp/work && cat config.env
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
The gate still failed, and the remaining list was only the six Terraform checks. That matches what I expected, since I had not touched main.tf yet. The dependency, Dockerfile and secret findings were gone after those three small edits, which is the payoff of a gate: it points at specific lines.
cd /tmp/work && /tmp/trivy-bin fs --quiet --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 --format json --output /tmp/gate2.json . ; echo "exit=$?"
exit=1
I then set the bucket ACL to private and added a public access block resource to main.tf. After that one check remained, and it is a judgement call about key management rather than an outright defect, so it is a reasonable candidate for a documented exception.
jq -r '.Results[] | .Target as $t | ((.Misconfigurations//[])[] | "\($t) \(.ID) \(.Severity) \(.Title)")' /tmp/gate3.json
main.tf AWS-0132 HIGH S3 encryption should use Customer Managed Keys
Accept one risk with an expiry
For that remaining check I wrote an ignore entry with a comment giving the reason and an expiry date, using the syntax from the filtering page. The entry applies automatically because Trivy loads a .trivyignore file from the scanned directory by default, and the scan then passes.
cd /tmp/work && cat .trivyignore
# Accepted: SSE-S3 is enough for this bucket
AWS-0132 exp:2027-03-01
cd /tmp/work && /tmp/trivy-bin fs --quiet --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 --format json --output /tmp/gate4.json . ; echo "exit=$?"
exit=0
Move the settings into a config file
Repeating five flags in every pipeline is error-prone, so I put them into a trivy.yaml and passed it with the config flag. The file, as written in the sandbox, is printed below, and the later runs in this article use it.
cd /tmp/work && cat trivy.yaml
scan:
scanners:
- vuln
- secret
- misconfig
severity:
- HIGH
- CRITICAL
exit-code: 1
quiet: true
With the config file the clean tree passes. To prove the gate catches a regression, I added a file leak.env containing one randomly generated key-shaped value and scanned again. The step failed and the report named the file and the rule that matched, which is exactly the feedback a developer needs in a pull request.
cd /tmp/work && /tmp/trivy-bin fs --config trivy.yaml --format json --output /tmp/cfg.json . ; echo "exit=$?"
exit=0
cd /tmp/work && /tmp/trivy-bin fs --config trivy.yaml --format json --output /tmp/cfg2.json . ; echo "exit=$?"
exit=1
jq -r '.Results[] | select(.Secrets) | .Target, (.Secrets[] | .RuleID)' /tmp/cfg2.json
leak.env
aws-access-key-id
Both runs used the same command. The only difference between them was the new leak.env, which I deleted afterwards. I also ran the unit tests of the two packages this article touches, ./pkg/fanal/secret/... and ./pkg/flag/..., and both reported ok. I did not run the whole test suite.
cd /tmp/trivy && go test -count=1 ./pkg/fanal/secret/... ./pkg/flag/...
ok github.com/aquasecurity/trivy/pkg/fanal/secret 0.298s
ok github.com/aquasecurity/trivy/pkg/flag 0.053s
Check that the expiry works
A green gate only means something if exceptions cannot live forever. I checked that the expiry actually works by changing the date to 2026-01-01, which is in the past. The finding came back and the exit code returned to 1, so a forgotten exception cannot hide a problem forever.
cd /tmp/work && cat .trivyignore
AWS-0132 exp:2026-01-01
cd /tmp/work && /tmp/trivy-bin fs --config trivy.yaml --format json --output /tmp/exp.json . ; echo "exit=$?"
exit=1
jq -r '.Results[] | select(.Misconfigurations) | .Misconfigurations[] | "\(.ID) \(.Severity)"' /tmp/exp.json
AWS-0132 HIGH
Short review
What worked: the exit-code gate, the severity filter, the expiring ignore entry and the config file all behaved as the documentation says, and a deliberate regression turned the step red. I did not benchmark scan speed, so I make no performance claims. Its main strength in this test was that each result was explicit and repeatable.
The limit I did not expect
The secret scanner skipped the well-known AWS documentation key pair. I put it in creds.env and scanned it. The command exited 0 and the JSON report contained no results at all, while my randomly generated key in the same shape was caught earlier.
cat /tmp/work2/creds.env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
cd /tmp/work2 && /tmp/trivy-bin fs --quiet --scanners secret --exit-code 1 --format json --output /tmp/ex.json . ; echo "exit=$?"
exit=0
jq -c '.Results // "no results"' /tmp/ex.json
"no results"
The code explains it. A built-in allow rule named examples skips any match that contains the word example, case-insensitively, as the lines below from the repository show. The same file defines other built-in skips for test files and vendor directories, so it is worth reading once before you rely on the scanner.
cd /tmp/trivy && sed -n 16,21p pkg/fanal/secret/builtin-allow-rules.go
{
ID: "examples",
Description: "Avoid example files and paths", // e.g. https://github.com/boto/botocore/blob/develop/botocore/data/organizations/2016-11-28/examples-1.json
Path: MustCompile(`example`),
Regex: MustCompile("(?i)example"),
},
That is sensible for documentation samples, and it is why my first attempt with the textbook key found nothing. The consequence is practical: a real credential whose value happens to contain that word would be skipped too, and any test of your gate needs a key that does not look like a sample. A clean exit proves the absence of findings the rules cover, not the absence of secrets.
When to choose another method
If you need nuanced policy, such as failing only on fixable vulnerabilities or on new findings compared with the main branch, parse the JSON report or use the project's other filtering options described on the filtering page. If your CI is GitHub Actions, the official action is worth a look. I tested only the filesystem scan of a toy project, not container images, and the sandbox had no running Docker daemon, so I make no claims about image scanning here.