How container image scanners work and why CI needs one

A container image scanner builds an inventory of what is inside an image and compares it against known vulnerabilities. It unpacks the image layers and reads package manager databases, such as the dpkg status file on Debian-based images or the apk database on Alpine. It also reads language manifests and lockfiles, including package-lock.json, go.sum and Python site-packages metadata. The output is a list of package names and versions. The scanner then matches that list against vulnerability data from distribution security trackers, language ecosystem advisories and the CVE record. It reports every match with a severity and, where one is known, a fixed version.

Teams run CVE scanning in CI for two reasons. The first is to stop a known-vulnerable component from reaching a release. The second is to rescan images that are already deployed, because new CVEs are published against software that was clean when it shipped.

Detection quality depends mostly on two things: how well the scanner identifies packages, and which data sources it consults. The scanner's brand matters less. Package detection is also where scanning breaks down:

  • Statically linked binaries carry their dependencies inside a single file.
  • Software copied in with COPY or curl leaves no package metadata behind.
  • Stripped or minimal images may have the package database removed.

In each case the scanner cannot report what it cannot see, so a clean report is not proof that an image is safe.

For the same reasons, two scanners often report different CVE counts for the same image. They may detect different packages, use different advisory feeds or treat unfixed and disputed entries differently. A lower count does not mean a more secure image. It can just mean the scanner detected fewer packages.

Trivy vs Grype vs Docker Scout: scope, data and dependencies

The three tools overlap on the core job but differ in their scope and in what they need from outside your pipeline.

Trivy is a single binary that scans images, filesystems, Git repositories and infrastructure-as-code. It keeps its vulnerability database in a local cache, so CI runners can reuse it between jobs. Exit codes can be tied to severity thresholds, so the scanner itself can act as the gate. If you want one tool for both CVEs and misconfiguration checks, this breadth helps. It also means more surface area to configure.

Grype does one thing: it matches packages against vulnerability data. It can scan an image directly or read an SBOM generated earlier, often by Syft. That split helps when you build the SBOM once at build time and rescan it later as new advisories appear, without pulling the image again.

Docker Scout is built into the Docker CLI and registry. Its strengths are base-image update recommendations and policy views across many repositories. Some of these features require a Docker account and an organization setup, which adds an identity and service dependency to your pipeline.

Criterion Trivy Grype Docker Scout
Gating Severity-based exit codes Fail-on severity threshold Exit codes plus policy evaluation
SBOM workflow Generates and scans SBOMs Consumes pre-built SBOMs Builds image SBOM and attestations
Suppression Ignore file or policy Ignore rules in config VEX and exceptions
Offline/cached Local DB cache Local DB cache Limited; relies on service
External dependency DB download DB download Docker account for full features

Where to scan in the pipeline

Scan at three points, because each answers a different question.

On pull requests, scan the image built from the branch, before it is pushed anywhere. The goal is fast feedback: a developer who bumps a base image or adds a package learns about new findings while the change is still small. Keep this scan quick and report only new findings so it stays useful.

At the release gate, scan the image by its pushed digest, not its tag. A tag can be moved to a different image after the scan, so a pass recorded against a tag proves little. The digest identifies exactly the image you will deploy. That makes it the right place to enforce policy.

On a schedule, rescan what is running. An image that has not changed in months can gain critical CVEs overnight, because the vulnerability database changes even when the image does not. Without rescans, the release gate only tells you the image was clean on the day it shipped.

Rescans do not need the image itself. Generate an SBOM at build time and store it as an artifact or registry attestation. Trivy and Grype can both scan an SBOM directly, so the scheduled job matches a stored package list against a fresh database. No registry pull or layer extraction is needed.

Cache the vulnerability database between jobs. This avoids a download on every run and keeps results consistent within a pipeline.

 PR ──► build ──► scan (fast feedback)
                       │
 main ─► build ─► push ─► digest ─► scan gate ─► deploy
            │                ▲
            └─► SBOM ─► store│
                        │    │
 cron ─► rescan SBOMs ◄─┘    │
            ▲                │
            └── DB cache ────┘ (refreshed on schedule)
 PULL REQUEST: fast feedback, image is not pushed            ┌───────────┐
 ┌──────────┐  ┌───────┐  ┌──────────────────┐               │ vuln DB   │
 │ PR branch├─►│ build ├─►│ scan local image │◄──────────────┤ cache     │
 └──────────┘  └───────┘  └────────┬─────────┘               │ (refresh  │
                                   ▼                         │ on cron)  │
                          report new findings only           └─────┬─────┘
 RELEASE: enforce policy on the pushed digest                      │
 ┌──────────┐  ┌───────┐  ┌──────┐  ┌──────────────────┐           │
 │   main   ├─►│ build ├─►│ push ├─►│ scan @sha256:... │◄──────────┤
 └──────────┘  └───┬───┘  └──────┘  │  (release gate)  │           │
                   │                └───┬──────────┬───┘           │
                   │                pass▼      fail▼               │
                   │                 deploy      block             │
                   ▼                                               │
          ┌────────────────┐                                       │
          │ SBOM (once, at │                                       │
          │  build time)   │                                       │
          └───────┬────────┘                                       │
                  ▼                                                │
          ┌──────────────────────┐                                 │
          │ artifact store or    │                                 │
          │ registry attestation │                                 │
          └───────┬──────────────┘                                 │
 SCHEDULED RESCAN │ (no image pull)                                │
 ┌──────────┐  ┌──▼───────────────┐                                │
 │   cron   ├─►│ scan stored SBOM │◄───────────────────────────────┘
 └──────────┘  └────────┬─────────┘
                        ▼
                alert on new CVEs

Pull requests get a fast scan of the locally built image, the release gate scans the pushed image by digest, and a cron job rescans the stored SBOM; all three scans read one cached vulnerability database that is refreshed on a schedule.

Implementing a gate teams will not bypass

Gate on what a developer can act on

Fail the build only when a fix exists and the severity is at or above your threshold. Anything else teaches people to rerun the job until someone disables it. With Trivy, trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 against the pushed digest does this. With Grype, grype <image> --only-fixed --fail-on high does the same against an image, and grype sbom:./sbom.json --fail-on high reuses the SBOM you already produced. Grype's --fail-on covers that severity and everything above it.

Run a second, non-gating pass that reports everything, including unfixed and lower-severity findings, so nothing is hidden. Write it as JSON or SARIF (--format json or --format sarif in Trivy, -o json or -o sarif in Grype), store it as a build artifact, and feed it to dashboards. Use table output in the job log, because that is what a person reads when the build goes red.

Keep the policy reviewable

Put the threshold, the scanner config (trivy.yaml or .grype.yaml) and the ignore files in the repository. When someone loosens the gate, the change then goes through code review and shows up in history.

Make results reproducible

Pin the scanner to an exact release or image digest. A floating latest can change detection behavior between two runs of the same commit.

Cache the vulnerability database between runs, using Trivy's --cache-dir or Grype's database cache directory. This cuts download time and rate-limit failures.

Record which database state produced each verdict. Save the output of trivy version or grype db status next to the report. When a build that passed yesterday fails today, you can then tell whether the image changed or the data did.

#!/usr/bin/env bash
# CI scan step: report everything, gate only on fixable HIGH/CRITICAL.
# Scanner binaries are installed earlier at pinned releases (never 'latest').
# trivy.yaml/.trivyignore and .grype.yaml live in the repo and are read from the working directory.
set -euo pipefail

IMAGE="registry.example.com/payments/api@sha256:<digest-from-push-step>"
REPORTS=reports
mkdir -p "$REPORTS"

# Cache dirs are restored and saved by the CI cache step between runs.
export TRIVY_CACHE_DIR="$PWD/.cache/trivy"
export GRYPE_DB_CACHE_DIR="$PWD/.cache/grype"

# Refresh the databases once, then record exactly which data produced this verdict.
trivy image --download-db-only
grype db update
trivy version > "$REPORTS/trivy-version.txt"
grype db status > "$REPORTS/grype-db-status.txt"

# Non-gating pass: every finding, machine-readable, kept as build artifacts.
trivy image --format json --output "$REPORTS/trivy-full.json" "$IMAGE"
trivy image --format sarif --output "$REPORTS/trivy-full.sarif" "$IMAGE"
grype sbom:./sbom.json -o json --file "$REPORTS/grype-full.json"

# Gating pass: table output for the job log; fail only on fixable HIGH and above.
status=0
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
  --format table "$IMAGE" || status=1
grype sbom:./sbom.json --only-fixed --fail-on high -o table || status=1

exit "$status"

A CI scan step that caches and records the vulnerability database state, writes full JSON/SARIF reports as artifacts, and fails the build only on fixable HIGH or CRITICAL findings from Trivy (against the image digest) and Grype (against the existing SBOM).

Managing false positives, accepted risk and unfixable CVEs

Every suppression should live in the repository as a per-scanner ignore entry: the CVE ID, the affected package, who approved it, why, and an expiry date. The expiry turns a permanent blind spot into a scheduled review. Where a scanner's ignore format has no native expiry field, record the date in a comment and add a CI step that fails when any listed date has passed.

Keep two kinds of entries separate:

  • Not affected. The vulnerable code is present but cannot be reached. For example, the flaw is in a module the service never loads. This is a statement about the image and can carry a long expiry. VEX documents are the standard way to publish it.
  • Accepted for now. The risk is real but tolerated, usually until a fix ships. Give it a short expiry, measured in weeks.

The example ignore file follows this pattern. Each entry has a comment that records its category, its justification and the date it must be reviewed.

Severity is not exploitability

CVSS scores the worst case of the flaw. It does not score how the flaw behaves in your container. A critical bug in a parser your binary never calls blocks the build, while a medium in an exposed HTTP library passes. If you gate only on score, you train teams to file suppressions without reading them.

Two sources produce much of the noise. Language lockfiles often include dev-only dependencies. Vendored or statically linked binaries can be matched to the wrong version, or their dependencies can be missed entirely.

No fix available

If you gate on findings that have no fix, the build stays blocked and no one can unblock it. Report these findings without failing the build. Track them against the base image. In practice, the remedy is usually to rebuild on a newer or smaller base image, such as a distroless or minimal one, so the vulnerable package is no longer in the image.

# .trivyignore
# One entry per suppressed finding. Every entry needs all four comment fields.
# CI greps the 'expires:' lines and fails the build once any date has passed,
# so each suppression becomes a scheduled review.
# CVE IDs, packages and names below are placeholders.

# category: not-affected
# package:  libxml2 (OS package from base image)
# why:      flaw is in the XPath module; the service only links libxml2 for
#           schema validation and never calls XPath. Published in our VEX doc.
# approved: j.doe (security), 2026-10-20
# expires:  2027-04-20
CVE-2026-10001

# category: accepted-for-now
# package:  openssl (OS package from base image)
# why:      no fixed version in the base image yet. The service is internal-only
#           behind mTLS. Remove this entry when the base image ships the fix.
# approved: a.smith (platform), 2026-10-28
# expires:  2026-11-25
CVE-2026-10002

# category: not-affected
# package:  example-test-runner (npm, devDependency in package-lock.json)
# why:      dev-only dependency, not copied into the runtime stage of the image.
# approved: j.doe (security), 2026-10-20
# expires:  2027-04-20
CVE-2026-10003

A Trivy .trivyignore file in which each suppressed CVE carries comments recording its category (not affected or accepted for now), justification, approver and expiry date. A CI step reads the expiry dates and fails the build once any of them has passed.

When to avoid a strict gate, and failure modes to plan for

Gate without a suppression path

A strict gate is the wrong choice if it ships before the exception workflow. When the only way to unblock a release is to edit the pipeline, engineers will edit the pipeline, and the scanner ends up commented out or set to report-only for good. Start in report-only mode, put suppressions with owners and expiry dates in place, and then turn on blocking.

Database drift on pinned images

Vulnerability databases change daily, so an image digest that passed on Monday can fail on Tuesday with no code change. Write down what happens next. A common policy is to block new builds on newly disclosed criticals but let a hotfix ship from an already-scanned digest. The new finding gets a ticket instead of a blocked incident fix.

Fail open or fail closed

Scanners need to download their databases. Some Docker Scout features also depend on reaching Docker's service with an authenticated account. Registry rate limits, expired tokens and outages all become build failures. Choose deliberately:

  • Fail closed on release branches.
  • Fail open with a visible warning on feature branches.

You can also cache or mirror the database inside your network to remove most of these failure points.

More scanners, more noise

Running Trivy and Grype together, or turning on misconfiguration and secret scanning, adds minutes to every job. Different matching logic also produces duplicate and conflicting findings. Do this only if a named person or team owns deduplicating and triaging the combined output. Otherwise the extra findings go unread.

Choosing a scanner: cost, engineering time and a decision guide

second scanner adds more triage work than it adds coverage. Triage hours are the main cost to plan for, whichever tool you pick.

The recurring costs are CI minutes, vulnerability database downloads and engineer time spent on findings. Scan time and downloads are usually small. Triage is where most of the time goes.

Illustrative estimate (assumed numbers, not a benchmark)

Monthly triage hours = new findings per week × share needing human review × minutes per finding × 4 ÷ 60

Assume 40 new findings a week, half of them reviewed, at 10 minutes each: 40 × 0.5 × 10 × 4 ÷ 60 ≈ 13 hours a month. If you gate only on fixable findings and the reviewed share drops to 20%, the total falls to about 5 hours.

Four practices reduce this cost:

  • Cache the vulnerability database between jobs so that each run does not download it again.
  • Gate only on fixable findings, so engineers review issues they can act on.
  • Use suppressions that expire so accepted risk is reviewed again. Permanent ignores tend to be forgotten.
  • Update base images on a regular schedule. One rebuild can clear many OS-package findings at once.
Team situation Recommended Why
Wants one tool for images, filesystems and config Trivy Broad coverage with one configuration to maintain
Already produces SBOMs and wants to rescan them as new CVEs are published Grype Scans SBOMs directly, so the image does not need to be pulled again
Builds and stores images with Docker's registry and tooling Docker Scout Findings appear in tools the team already uses. Check your plan's limits
Small team with no SBOM process yet Trivy Fewest moving parts to set up

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to container image scanning ci, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.