DevOps13 min readJun 2026 Last verified Jun 2026

Container & Image Security Scanning

Shift-left scanning for containers: catch CVEs in base images and dependencies before they ship, fail the build on criticals, and lock down what reaches prod.

DevOpsSecurityContainersScanning
SB

Sri Balaji

Founder · TheSimplifiedTech

On this page

TL;DR

Catch CVEs in base images and dependencies before they ship by scanning early and failing the build on HIGH or CRITICAL. Shrink the attack surface with distroless bases and add an admission gate at runtime.

The CVE you shipped without writing a line of code

Friday afternoon. Your service is healthy, your tests are green, your code review was spotless. On Monday, security pages you: your production image contains a critical remote-code-execution CVE. You didn't write the vulnerable code, it came in your base image, three layers down, in an OpenSSL build you never chose and never knew was there.

This is the uncomfortable truth of containers: when you write FROM node:20, you inherit an entire operating system, hundreds of OS packages, each with its own vulnerability history. Add your npm install and you pull in a tree of transitive dependencies that no human has read. The average image ships with dozens of known CVEs before your code even runs. The fix isn't heroics at 2 a.m. It's moving the check to the left, scanning every image, on every build, and refusing to ship the broken ones.

Who this is for

Junior-to-mid engineers who build and ship Docker images and want a CI pipeline that *stops* vulnerable images from reaching production. You should be comfortable with a Dockerfile and a basic CI YAML file. No security background required, we build the mental model from zero.

Shift left: find it where it's cheap to fix

A vulnerability caught in CI costs minutes to fix. The same vulnerability caught in production costs an incident, a postmortem, and your weekend.
The shift-left principle

"Shift left" means moving security checks earlier in the timeline, to the *left* of the pipeline, toward the developer, instead of bolting them on at the end. A scanner is just a tool that reads what's inside an image (its packages and their versions), looks each one up in public vulnerability databases, and tells you which ones have known CVEs. The leverage isn't the scan; it's *where* you run it and *whether it can stop the line*.

X-ray machine at the gateThe scanner (Trivy / Grype) reading every layer
The watchlist of banned itemsCVE databases (NVD, GitHub Advisory, distro feeds)
Confiscating the item before boardingFailing the build on a critical finding
Screening luggage AND carry-onScanning OS packages AND your app dependencies
A second check at the jet bridgeAdmission control rejecting unsigned/unscanned images at deploy
Image scanning maps cleanly onto airport security.

The picture: build, gate, deploy

Here is the full path an image travels from your laptop to a running pod. The two control points that matter are the gate in CI (fail on critical) and admission control at the cluster (reject anything that didn't go through the gate).

imagereportpassfailpulladmit
Build

docker build

Scan Image

Trivy / Grype

Gate

fail on CRITICAL

Registry

push if clean

Admission Control

verify at deploy

Kubernetes

running pod

Blocked Build

fix & retry

Scan in CI to fail fast; enforce again at admission so nothing sneaks in the side door.

  1. 1

    Build the image

    Your CI runs docker build and produces a tagged image, base layers plus your app.

  2. 2

    Scan the image

    Trivy or Grype reads every layer, enumerates packages, and matches versions against CVE feeds.

  3. 3

    Gate on severity

    The scanner exits non-zero if it finds a CRITICAL (or HIGH). A non-zero exit fails the CI job, the line stops.

  4. 4

    Push only if clean

    A passing build pushes the image to your registry. A failing build never gets that far.

  5. 5

    Enforce at admission

    When something tries to deploy, an admission controller checks the image was scanned/signed and rejects it otherwise.

What to scan (it's more than OS packages)

A common mistake is thinking "image scanning" means "OS package scanning." Your attack surface is wider: the application dependencies you bundle, the infrastructure-as-code that provisions everything, and any secrets that accidentally got baked into a layer. Good news, modern scanners like Trivy cover all four from one binary.

WhatWhy it bitesToolWhen
OS packagesBase image ships CVEs you never chose (openssl, glibc, curl)Trivy, GrypeOn every image build
App dependenciesTransitive npm/pip/maven packages with known CVEsTrivy, Grype, DependabotOn every build + on a schedule
IaC / DockerfileMisconfigs: running as root, open security groups, no resource limitsTrivy config, CheckovOn PR, before merge
SecretsAPI keys / tokens accidentally committed or baked into a layerTrivy, gitleaksOn PR + on image build
Four things to scan, and where each check belongs in the pipeline.

Scan the same image you ship

Scan the *built artifact*, not just your source tree. A Dockerfile scan won't catch a CVE that arrived through the base image's pre-installed packages, only scanning the final image will.

The gate: a CI step that fails on HIGH/CRITICAL

This is the whole point of the article. Scanning that only *reports* is theatre, someone has to read the report, and nobody does. The gate makes the pipeline read it for you. Here's a GitHub Actions job that builds the image, scans it with Trivy, and fails the build the moment a HIGH or CRITICAL with a known fix appears.

.github/workflows/image-scan.yml
yaml
name: build-and-scan

on:
  pull_request:
  push:
    branches: [main]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build image
        run: docker build -t app:${{ github.sha }} .

      - name: Scan image with Trivy
        uses: aquasecurity/trivy-action@0.24.0
        with:
          image-ref: app:${{ github.sha }}
          format: table
          # Fail the job on these severities...
          severity: HIGH,CRITICAL
          # ...but only when an upstream fix actually exists.
          ignore-unfixed: true
          # exit-code: 1 turns a finding into a failed build (the gate).
          exit-code: "1"

      - name: Push image
        # Only runs if the scan step above passed.
        if: github.ref == 'refs/heads/main'
        run: |
          echo "Scan clean, pushing image"
          docker push app:${{ github.sha }}
  • `exit-code: "1"` is the line that turns a scan into a *gate*. Without it, Trivy prints findings and exits 0, the build stays green and nothing stops.
  • `ignore-unfixed: true` avoids failing on CVEs that have no patch yet, you can't fix what upstream hasn't fixed, so failing there just trains people to ignore the gate.
  • `severity: HIGH,CRITICAL` is your threshold. Start strict on CRITICAL, add HIGH once your images are clean, and resist the urge to also fail on LOW (noise kills trust in the gate).
  • The push step is guarded by if: so a failed scan never reaches the registry.

Give yourself an escape hatch, a narrow one

For an unfixable CVE that genuinely doesn't apply to you, use a .trivyignore file listing the specific CVE ID with a comment and an expiry date. Never blanket-disable a severity to get a release out, that's how the base-image CVE shipped in the first place.

Shrink the target: distroless & minimal bases

The cheapest vulnerability to fix is the one that was never in the image. Every package in your base is a package that can have a CVE. A full ubuntu or node image carries a shell, a package manager, and hundreds of libraries your app never calls, pure attack surface. Distroless and minimal base images strip that down to just your app and its runtime.

Base imageWhat's insideTrade-off
ubuntu / debianFull OS, shell, apt, many libsFamiliar, but the most CVEs to chase
alpineTiny OS + busybox shell + apkSmall and popular; musl libc can surprise some apps
distrolessJust your app + language runtime, no shellTiny CVE surface; harder to debug (no shell)
scratchLiterally nothing, your static binary onlyZero OS CVEs; only works for static binaries (Go, Rust)
Smaller base = fewer packages = fewer CVEs to triage.

Pair this with a multi-stage build: compile in a fat builder stage, then copy only the artifact into a distroless final stage. You get a small, fast image with almost nothing for a scanner to flag.

Generate an SBOM while you're at it

A Software Bill of Materials (SBOM) is a machine-readable inventory of everything in your image. Trivy can emit one (trivy image --format cyclonedx). The win: when the *next* big CVE drops, you don't rebuild and rescan every image to find out who's affected, you query your stored SBOMs. Scanning tells you what's vulnerable *today*; the SBOM lets you answer that question instantly *tomorrow*. This is the heart of supply-chain security, know exactly what you ship.

One more gate: runtime & admission control

CI scanning assumes everything reaches production *through* CI. In practice, someone will kubectl apply a hand-tagged image, or a stale one will get redeployed. Admission control is the cluster's own gate: a controller (Kyverno, OPA Gatekeeper, or a registry policy) inspects every image at deploy time and rejects ones that weren't scanned, weren't signed, or pull from an untrusted registry. CI is the lock on the front door; admission control is the lock on the back door, you want both.

  • Require signed images, only admit images signed by your CI (e.g. with cosign), so a random :latest from Docker Hub can't deploy.
  • Restrict registries, allow pulls only from your own registry, where every image has already passed the gate.
  • Block privileged pods, reject containers asking for root, host networking, or privileged: true at admission.
  • Runtime monitoring, even an admitted image can be exploited later; tools like Falco watch for suspicious behavior in running containers.

Common mistakes that cost hours

  1. 1Scanning but never gating. Trivy in a step with exit-code: 0 produces a beautiful report nobody reads. If a finding can't fail the build, it doesn't exist.
  2. 2Fat base images. FROM ubuntu then chasing 80 CVEs every week. Move to distroless/alpine and most of them simply vanish, you can't have a CVE in a package you didn't install.
  3. 3Ignoring transitive dependencies. You audited your direct package.json, but the CVE is four levels deep in a dependency-of-a-dependency. Scanners walk the *whole* tree; lockfiles are where the real risk lives.
  4. 4Failing on unfixable CVEs. Gating on findings with no available patch just teaches everyone to slap || true on the scan. Use ignore-unfixed and a dated .trivyignore.
  5. 5Scanning only at release. A scan once a quarter means you find out about Friday's CVE in three months. Scan every build, and re-scan on a schedule since new CVEs land against images you already shipped.

Takeaways

The whole article in seven lines

  • Most container CVEs come from the base image and transitive deps, code you didn't write.
  • Shift left: scan on every build, where a fix costs minutes, not an incident.
  • Scan four things, OS packages, app deps, IaC/Dockerfile, secrets.
  • The gate is exit-code: 1 on HIGH/CRITICAL, a scan that can't fail the build is theatre.
  • Use ignore-unfixed + a dated .trivyignore so the gate stays trusted, not bypassed.
  • Shrink the target with distroless/minimal bases; emit an SBOM for tomorrow's CVE.
  • Add admission control so only scanned, signed images from trusted registries ever deploy.

Where to go next

Scanning is one pillar of secure delivery. Pair it with images that are small by design and a supply chain you can trust end to end.

Your CI scan flags a HIGH or CRITICAL CVE in your container image. What do you do?

Check your understanding

1. Why does the article insist you scan the built image rather than only the Dockerfile or source tree?

2. What makes a scanning setup effective rather than "theatre" according to the article?

Frequently asked questions

How can my image have a critical CVE if I never wrote the vulnerable code?

When you write FROM node:20 you inherit an entire operating system with hundreds of OS packages, each with its own vulnerability history, plus a tree of transitive dependencies from your package install. The average image ships with dozens of known CVEs before your code even runs.

What does "shift left" actually mean for security scanning?

It means moving security checks earlier in the timeline, toward the developer, instead of bolting them on at the end. A vulnerability caught in CI costs minutes to fix, while the same one caught in production costs an incident, a postmortem, and your weekend.

Is it enough to scan only OS packages?

No, that is a common mistake. Your attack surface also includes application dependencies you bundle, the infrastructure-as-code that provisions everything, and any secrets accidentally baked into a layer, and modern scanners like Trivy cover all four from one binary.

Why does scanning matter if I already get a report?

Scanning that only reports is theatre, because someone has to read the report and nobody does. The leverage comes from a gate in CI that fails the build on HIGH or CRITICAL findings, backed by admission control at the cluster that rejects anything that did not pass the gate.

Was this article helpful?

Want to go deeper?

This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.