Signing and provenance answer where an artifact came from. They say nothing about what is inside the image or how vulnerable it is. And inside sit hundreds of packages from the base image, your app’s transitive dependencies, and an old openssl nobody has touched since last quarter. Trivy closes exactly that gap: one CI step looks at the packages that made it into the image, checks them against vulnerability databases, and fails the build if something with an available fix is in there. Let’s wire it up so the gate protects instead of annoys.
Table of contents
Open Table of contents
Why “just rebuild the image” doesn’t save you
A typical app image is not your code. It is your code plus a base image, plus system libraries, plus the whole runtime dependency tree. A vulnerability in any of those layers is your vulnerability, even if you never saw the line. Log4Shell hit people who knew about log4j only as “something in Spring’s dependencies.”
The problem isn’t that holes appear — they appear constantly, CVE databases grow every day. The problem is that the gap between “a patch shipped” and “we noticed” is usually months, sometimes “never.” Rebuilding by itself doesn’t cure it: if the base image is pinned to an old tag, you carefully rebuild the same hole over and over.
You need an automatic watcher that, on every build, re-checks the image’s contents against current databases and says: this is known, this has a fix, this blocks the release. Trivy is the de-facto open standard for that job: one binary, one database, covering most languages and distros.
What Trivy actually scans
The mistake is thinking of Trivy as “an image scanner.” Images are just one target. The same CLI with the same database looks at five different surfaces:
- Container images — walks OS packages layer by layer (
apk,apt,dpkg) and app dependencies (npm,pip,go.mod,gem…) and checks them against CVEs. - Filesystem and repo — the same, but on sources:
trivy fs .finds vulnerable dependencies before you even build the image. - IaC misconfigs — Terraform, Kubernetes manifests, Dockerfiles, Helm: an open
securityContext, a privileged container, a public S3 bucket. - Secrets — hardcoded tokens, private keys, an
AWS_SECRET_ACCESS_KEYbaked right into an image layer. - Licenses — which licenses your dependencies drag in; useful where GPL in a proprietary product is a legal risk.
For getting started only one thing matters: you don’t need five separate tools. One pipeline step, only the target changes — image, fs, config. From here on we focus on images, because that’s the most common and the most expensive-on-a-miss case.
The gate: how a scan starts to decide something
A scan that just prints a list to the log is useless — nobody reads it. Value appears when the scan blocks the release. Two flags control that:
--severity— which levels we care about. A gate usually rides onHIGH,CRITICALand leavesLOW,MEDIUMin the report.--exit-code 1— return a non-zero code if anything of that severity is found. A non-zero code is what fails the CI job and stops the pipeline.
# report only, blocks nothing (for observation)
trivy image --severity HIGH,CRITICAL registry.example.com/app:sha-abc123
# gate: the pipeline fails if there is a HIGH/CRITICAL with a known fix
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed \
registry.example.com/app:sha-abc123
The flag that keeps you sane is --ignore-unfixed. It drops from the gate everything the upstream has not yet fixed. The logic is simple: with no fix, failing the build changes nothing — there is nowhere to upgrade to, you just blocked a release over something you can’t repair. Those findings should be visible in the report, but should not hold up the release.
SBOM: a list of what’s inside
While Trivy takes the image apart, it already builds a full inventory of packages. Exporting it as an SBOM (Software Bill of Materials) is nearly free, and pays off a lot.
An SBOM is a machine-readable list: which components, which versions, which licenses ended up in the artifact. Two standards: CycloneDX (security-focused) and SPDX (broader, about licenses and provenance). Why you want it:
- When tomorrow’s loud new CVE drops, you answer “are we affected?” in a second — by grepping stored SBOMs, not rescanning every image.
- Audits and regulators (US EO 14028, EU CRA) increasingly ask for an SBOM as a shipping artifact.
- You can sign it and attach it to the image as an attestation — then the consumer trusts not just the image but its contents.
# CycloneDX JSON next to the image
trivy image --format cyclonedx --output sbom.cdx.json \
registry.example.com/app:sha-abc123
A nice touch: an SBOM can be fed back into Trivy — trivy sbom sbom.cdx.json rescans it for vulnerabilities without touching the image. A once-built SBOM lives its own life and can be re-evaluated against a fresh CVE database anytime.
How to quiet the noise so the gate survives
The first Trivy run on a real image almost always returns a scary wall. The team’s natural reaction is “you can’t work like this” and allow_failure: true. After that the gate is dead: it exists but blocks nothing.
The right move is not to weaken the gate but to narrow it to what actually matters and what can actually be fixed:
--ignore-unfixed— already covered: no fix, no point blocking..trivyignore— a file listing specific CVEs you have knowingly accepted (with a comment and ideally a review date). Not “silenced,” but “looked into and decided.”- VEX (Vulnerability Exploitability eXchange) — a statement that “this CVE is not exploitable in our context” (the vulnerable code is present, but the path to it is unreachable). Unlike a bare ignore, VEX carries a rationale and is machine-readable.
# .trivyignore
# openssl CVE in libcrypto — the function is never called from our code.
# Revisit after the base image bump. Owner: security, by 2026-09-01.
CVE-2026-1234
The difference between “quiet” and “silence” is the trail. .trivyignore and VEX live in the repo, go through review, and carry a review date. allow_failure: true leaves nothing but a green check on a vulnerable image.
One comparison table
| No scanning | Trivy gate in CI | |
|---|---|---|
| When we learn of a hole | from the news / an incident | at build time, before shipping |
| What blocks prod | nothing | HIGH/CRITICAL with a fix |
| Answering “are we affected?“ | manual re-scan of images | grep over stored SBOMs |
| Noise | — | trimmed by --ignore-unfixed + .trivyignore + VEX |
| Shipping artifact | image only | image + SBOM (+ signature) |
| Cost of entry | 0 | one job + DB cache |
What you need to run it in CI
The minimum is one job that scans the built image by digest, fails on a HIGH/CRITICAL with a fix, exports an SBOM, and signs it keyless (tying into the cosign post). Here’s a working .gitlab-ci.yml:
stages: [build, scan]
variables:
IMAGE: $CI_REGISTRY_IMAGE
TRIVY_CACHE_DIR: .trivycache # DB cache between builds
build:
stage: build
image: docker:27
services: [docker:27-dind]
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
- docker build -t "$IMAGE:$CI_COMMIT_SHA" .
- docker push "$IMAGE:$CI_COMMIT_SHA"
- DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:$CI_COMMIT_SHA")
- echo "DIGEST=$DIGEST" >> build.env
artifacts:
reports:
dotenv: build.env
scan:
stage: scan
image: aquasec/trivy:latest
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
cache: # so we don't pull the CVE DB every time
key: trivy-db
paths: [.trivycache]
script:
# 1. gate: fail on HIGH/CRITICAL that have a fix
- trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$DIGEST"
# 2. SBOM in CycloneDX
- trivy image --format cyclonedx --output sbom.cdx.json "$DIGEST"
# 3. keyless-sign the SBOM and bind it to the image
- cosign attest --yes --predicate sbom.cdx.json --type cyclonedx "$DIGEST"
artifacts:
paths: [sbom.cdx.json]
Three details that save you grief:
- Scan by digest, not by tag. A tag can be moved to another image between build and scan; a digest can’t. Scan exactly what was pushed to the registry.
- Cache the vulnerability DB. Without a cache, Trivy pulls a few hundred MB of DB on every run and CI crawls.
TRIVY_CACHE_DIR+cache:in GitLab fix it; in air-gapped networks you can mirror the DB to your own registry (trivy image --db-repository ...). - Step order. Gate first (fail early and cheap), then SBOM and signing — no point doing those for an image that won’t ship anyway.
How to verify it works
Check both sides of the gate — that it passes clean images and actually fails vulnerable ones. The quick way to see a block is to grab a knowingly old image:
# a knowingly vulnerable image — expect a non-zero exit code
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed python:3.4-slim
echo "exit code: $?" # not 0 — the gate fired
If the exit code is 0 on an old image, the gate has no teeth: double-check --exit-code 1 is set and that the CI job has no allow_failure: true. And peek into the SBOM — the component count should match reality:
jq '.components | length' sbom.cdx.json
Bottom line
A Trivy gate closes one specific hole in the process — “we shipped an image with a known vulnerability that had a patch.” One job that checks the image’s contents against the CVE database and fails the build on a HIGH/CRITICAL with a fix. The cost is minutes of cache setup and the discipline to keep .trivyignore meaningful rather than a growing dump. The payoff: you learn of a hole at build time instead of from an incident, and you answer any loud new CVE by grepping SBOMs in seconds.
Paired with signing and provenance from the neighboring posts, you get a closed supply-chain loop: a signature says where the artifact came from, provenance says how it was built, Trivy says what’s inside and how vulnerable it is. Three answers that together make a release verifiable from commit to cluster.