Skip to content
Hogin Hogin
Go back

Trivy in CI: catch vulns and generate an SBOM before prod

9 мин чтения

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:

One scanner, five surfaces: images, filesystem, IaC, secrets, licenses

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:

# 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.

A gate in the pipeline: a clean image passes, a vulnerable one fails and gains an SBOM

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:

# 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:

# .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

From a raw report of hundreds of CVEs to what actually blocks the release

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 scanningTrivy gate in CI
When we learn of a holefrom the news / an incidentat build time, before shipping
What blocks prodnothingHIGH/CRITICAL with a fix
Answering “are we affected?“manual re-scan of imagesgrep over stored SBOMs
Noisetrimmed by --ignore-unfixed + .trivyignore + VEX
Shipping artifactimage onlyimage + SBOM (+ signature)
Cost of entry0one 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:

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.


Share this post:

Next Post
Sentry: catch prod errors before your users do