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.
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.
"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
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).
Scan in CI to fail fast; enforce again at admission so nothing sneaks in the side door.
1
Build the image
Your CI runs `docker build` and produces a tagged image, base layers plus your app.
2
Scan the image
Trivy or Grype reads every layer, enumerates packages, and matches versions against CVE feeds.
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
Push only if clean
A passing build pushes the image to your registry. A failing build never gets that far.
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.
What
Why it bites
Tool
When
OS packages
Base image ships CVEs you never chose (openssl, glibc, curl)
Trivy, Grype
On every image build
App dependencies
Transitive npm/pip/maven packages with known CVEs
Trivy, Grype, Dependabot
On every build + on a schedule
IaC / Dockerfile
Misconfigs: running as root, open security groups, no resource limits
Trivy config, Checkov
On PR, before merge
Secrets
API keys / tokens accidentally committed or baked into a layer
Trivy, gitleaks
On 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.0with:
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 image
What's inside
Trade-off
ubuntu / debian
Full OS, shell, apt, many libs
Familiar, but the most CVEs to chase
alpine
Tiny OS + busybox shell + apk
Small and popular; musl libc can surprise some apps
distroless
Just your app + language runtime, no shell
Tiny CVE surface; harder to debug (no shell)
scratch
Literally nothing, your static binary only
Zero 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
Scanning 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.
Fat 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.
Ignoring 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.
Failing 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.
Scanning 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.
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.