Back to posts
Post

Using Trivy in GitLab CI to Scan Container Images for SBOM and CVEs

Generate SBOM JSON and scan containers for vulnerabilities in GitLab CI with Trivy. Practical setup, pitfalls, and real output examples.

DevopsTrivyGitLab CISBOMcontainer scanning

I started using Trivy in GitLab CI after a security audit flagged missing SBOMs in our container pipeline. The goal was simple: fail the build if critical CVEs appear and emit a machine-readable SBOM for compliance. Trivy does both well, and integrating it into .gitlab-ci.yml took less than an hour once I knew the flags.

Why Trivy over other scanners

I evaluated Aqua, Clair, and Grype before settling on Trivy. It’s lightweight, doesn’t require a running daemon, and outputs clean JSON by default. For SBOM generation, the CycloneDX format is widely accepted, and Trivy can produce it alongside vulnerability data in one pass. In my environment, we run scans on every merge request and on scheduled nightly pipelines for main branch images.

Basic .gitlab-ci.yml setup

Here’s the core job I use. It pulls the Trivy image, scans the built container, and saves both the SBOM and vulnerability report as artifacts.

trivy_scan:
  image: aquasec/trivy:latest
  stage: test
  script:
    - trivy image --format json --output trivy-results.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    - trivy image --format cyclonedx --output sbom.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  artifacts:
    paths:
      - trivy-results.json
      - sbom.json
    expire_in: 7 days
  only:
    - merge_requests
    - main

The $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA variable ensures we scan the exact image just built and pushed earlier in the pipeline. If your build job uses a different tag, adjust accordingly.

Making the scan fail on critical issues

By default, Trivy exits with code 0 even if it finds vulnerabilities. To break the build, you need to set a severity threshold. I use --exit-code 1 for CRITICAL and HIGH, but ignore LOW and MEDIUM unless they’re in a deny list.

    - trivy image --severity CRITICAL,HIGH --exit-code 1 --format json --output trivy-results.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

This way, a single critical CVE fails the MR pipeline immediately. Developers get fast feedback, and we avoid shipping known exploitable flaws.

Speeding up scans with caching

Trivy downloads vulnerability databases on first run, which adds 10–20 seconds. In a busy CI system, that adds up. I mounted a cache volume via GitLab Runner’s Docker config to persist the Trivy DB between jobs.

In /etc/gitlab-runner/config.toml:

  [[runners]]
    [runners.docker]
      volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock:ro"]

Then in the job:

    - trivy image --download-db-only
    - trivy image --severity CRITICAL,HIGH --exit-code 1 --format json --output trivy-results.json $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

The --download-db-only step runs once per cache refresh (we refresh daily), cutting scan time from ~25s to ~8s per image.

Common pitfalls I hit

First, if you’re using a self-hosted GitLab Runner with Docker executor, make sure the runner can pull images from your registry. I once had a job fail silently because the runner lacked docker login credentials — fixed by adding a before_script step with $CI_REGISTRY_PASSWORD.

Second, Trivy’s JSON output can be large. For a busy project, trim fields you don’t need with --ignore-unfixed or --ignorefile .trivyignore to skip low-value findings.

Third, don’t confuse --format with --output. The former controls the structure (json, cyclonedx, table), the latter just names the file. Mixing them up leads to empty artifacts.

What the artifacts contain

The trivy-results.json includes:

  • Vulnerability ID (e.g., CVE-2024-XXXX)
  • Installed version vs. fixed version
  • Severity score
  • Primary link to NVD or vendor advisory

The sbom.json (CycloneDX) lists:

  • All packages in the image (OS and app-level)
  • Licenses
  • Cryptographic hashes (SHA-256) where available

I feed the SBOM into our internal compliance portal nightly. It’s proven useful during audits — when asked for an SBOM of a specific image version, I can pull it from GitLab’s artifact store in seconds.

Tip: Scan local images before pushing

You can run the same Trivy commands locally to catch issues early:

trivy image --severity CRITICAL,HIGH --exit-code 1 myapp:local-test

Add this to a pre-commit hook if you want to block pushes with known critical flaws. It’s saved me more than once.

When Trivy isn’t enough

Trivy is great for known CVEs in OS packages and language dependencies, but it doesn’t scan for misconfigurations (like a container running as root) or secrets in layers. For those, I pair it with Hadolint for Dockerfile checks and Trivy’s own --config scan for Kubernetes manifests — but that’s a separate job.

If you need runtime protection, that’s where tools like Falco or Tetragon come in. But for pre-build, pre-deploy hygiene, Trivy in GitLab CI is now a non-negotiable step in our pipeline.


Cover image: Bernard Spragg · PDM (Openverse / kamu malı) · https://www.flickr.com/photos/88123769@N02/51498704533