Docker Image Security Scanning: Stop Shipping Vulnerable Containers to Production
Docker image security scanning explained with real production failures.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓Docker CLI basics (build, run, push)
- ✓Understanding of container images and layers
- ✓Familiarity with CI/CD pipelines
To scan a Docker image for vulnerabilities, run trivy image your-image:tag. This checks all installed packages against the CVE database. For CI/CD, add a scan step that fails the build if critical vulnerabilities are found. Use .trivyignore to suppress false positives.
Think of a Docker image like a pre-packed suitcase for your app. Security scanning is like an airport security check that X-rays every item inside. It flags any banned items (vulnerable packages) or suspicious objects (secrets). You wouldn't fly without checking your bag, so don't deploy without scanning your image.
You just pushed a Docker image to production. Two hours later, your security team calls: 'We found a critical CVE in your base image.' Now you're scrambling to rebuild, retest, and redeploy. Sound familiar? Docker image security scanning isn't optional anymore — it's the difference between a routine deploy and a breach notification.
The problem is simple: your image is a stack of layers, each potentially carrying vulnerable packages. Base images like node:18 or python:3.11 are updated frequently, but your Dockerfile might pin an old version. Without scanning, you're blind to known vulnerabilities until it's too late.
By the end of this, you'll be able to integrate vulnerability scanning into your Docker build pipeline, interpret scan results like a pro, and avoid the gotchas that burn teams in production. You'll know which tool to use when, how to handle false positives, and how to set up automated gates that stop vulnerable images from ever reaching your registry.
Why You Can't Trust Your Base Image
Every Docker image starts with a base — FROM node:18, FROM python:3.11-slim, FROM alpine:3.18. These images are maintained by communities, but they're not immune to vulnerabilities. A single outdated package in the base can expose your entire application.
Consider this: the node:18 image contains over 100 OS packages (glibc, openssl, zlib, etc.). Each has a version string that maps to CVEs. If you don't scan, you're flying blind. The worst part? Base images are updated frequently, but your Dockerfile might pin a specific tag like node:18 which points to a moving target. Today's node:18 is not tomorrow's node:18.
The fix: always pin base images by digest (sha256:...) and scan them before building your app layer. This gives you reproducible builds and a known vulnerability baseline.
node:18 without a digest means your CI might build against a different image today than yesterday. If the base image gets a security update, your build might suddenly include new packages — or worse, break. Always pin digests in production.Choosing the Right Scanner: Trivy vs Grype vs Docker Scout
You have options. Trivy (Aqua Security) is the Swiss Army knife — scans OS packages, language-specific deps, IaC misconfigurations, and even Kubernetes manifests. Grype (Anchore) focuses on container images and integrates tightly with Syft for SBOM generation. Docker Scout is Docker's own offering, baked into Docker Desktop and Hub.
Which one should you use? If you're already in the Docker ecosystem and want zero-config, Docker Scout is tempting. But it's a SaaS product — your image data leaves your network. For air-gapped environments or strict compliance, Trivy or Grype are better. Trivy wins on speed and breadth: it can scan a 1GB image in under 30 seconds. Grype is slower but produces more detailed SBOMs.
My take: use Trivy for CI/CD gates (fast, reliable) and Grype for compliance audits (detailed SBOM). Docker Scout is fine for local dev awareness but don't rely on it as your only gate.
~/.cache/trivy directory between builds to avoid downloading 50MB every time. Use --skip-db-update if the DB is fresh (e.g., updated once per day).Integrating Scanning into CI/CD: The Gate That Actually Works
A scan that doesn't block the pipeline is just a report nobody reads. You need a hard gate: if the image has any CRITICAL vulnerability with a known fix, fail the build. But beware — not all CVEs are equal. Some are in unused packages, some have no exploit in the wild, and some are in test dependencies.
The trick: use --fail-on with a severity threshold, but also maintain a .trivyignore for accepted risks. Every ignored CVE must have a comment with a ticket number and expiration date. This prevents 'ignore and forget'.
Here's a production-grade CI step for GitHub Actions that scans, fails on critical/high, and allows exemptions.
--ignore-unfixed flag is critical. Without it, you'll fail on CVEs that have no patch yet — blocking your deploy for a problem you can't fix. Always use this flag in CI, and track unfixed CVEs separately with a risk acceptance process.Handling False Positives and Ignored CVEs
No scanner is perfect. You'll get false positives — a CVE in a library you don't use, or a vulnerability that's only exploitable with a specific configuration you don't have. Ignoring them is fine, but do it right.
Create a .trivyignore file in your repo. Each line is a CVE ID followed by a comment. The comment must include a ticket number and an expiration date. This forces periodic review. If the CVE is still unfixed after 90 days, your team must re-evaluate.
Example: CVE-2024-1234 # OPS-5678: Accepted risk until 2024-06-01. Library not loaded at runtime.
Never ignore a CVE without a ticket. That's how vulnerabilities become 'technical debt' that never gets paid.
Scanning During Build: Multi-Stage and Distroless Images
Multi-stage builds are great for reducing image size, but they complicate scanning. You need to scan the final stage, not the builder stage. The builder stage might have compilers and dev tools with CVEs, but they're not in the final image.
Distroless images (e.g., gcr.io/distroless/nodejs) are minimal — no shell, no package manager. This reduces the attack surface dramatically. But they also make scanning harder because there's no package database to query. Trivy handles this by scanning the binary layers directly.
Best practice: scan both the builder stage (for awareness) and the final stage (for the gate). Fail only on the final stage.
--severity CRITICAL for builder stage, fail only on final.Secrets Scanning: The CVE Nobody Talks About
Vulnerabilities are one thing, but hardcoded secrets in your image are a direct line to your infrastructure. Docker images are often shared via registries — if you accidentally include an AWS key or a database password, anyone with pull access can use it.
Trivy has a built-in secrets scanner (--scanners secret). It checks for patterns like AWS keys, GitHub tokens, private keys, and more. Run it as part of your scan. The output is clear: file path, line number, and the type of secret.
Never build an image with secrets. Use Docker build secrets (--secret) or multi-stage builds to avoid copying sensitive files into the final image.
Scanning in Air-Gapped Environments
If your production environment has no internet access (air-gapped), you can't download vulnerability databases on the fly. You need to pre-download the DB and make it available to the scanner.
Trivy supports offline mode. Download the DB on a connected machine, then transfer it to the air-gapped environment. Use --cache-dir to point to the local DB. Update the DB periodically (e.g., weekly) and re-scan your images.
Grype has a similar approach with its grype db update command. Both tools allow you to specify a local DB path.
When Not to Use Image Scanning (And What to Do Instead)
Image scanning is not a silver bullet. It won't catch zero-day vulnerabilities (no known CVE yet), logic flaws in your application, or misconfigurations in your runtime environment. It also won't help if your base image is completely custom and not in any CVE database.
For zero-days, you need runtime security monitoring (e.g., Falco, Sysdig). For application logic, you need SAST/DAST tools. For misconfigurations, use Kubernetes security policies (OPA, Kyverno).
Also, scanning every image in a large registry daily can be expensive. Prioritize: scan production images weekly, development images on every build. Use differential scanning — only scan layers that changed since the last scan.
Cosign Attestation Integration — Attach Scan Results as Signed Attestations
Cosign, part of the Sigstore project, supports attaching arbitrary attestations to container images as OCI artifacts. You can attach vulnerability scan reports, SBOMs, test results, or SLSA provenance statements as signed attestations. The attestation is stored alongside the image in the registry and can be verified at admission time.
To integrate scan results with Cosign: run your vulnerability scanner (e.g., trivy image --format cosign-vuln), pipe the output to cosign attest, and attach to the image. The attestation includes the scan results in a standard in-toto predicate format. At admission time, Kyverno or a policy engine verifies both the image signature and the attestation — ensuring the image was scanned and passed policy before deployment.
SLSA (Supply-chain Levels for Software Artifacts) provenance attestations track where and how an image was built. A SLSA provenance attestation includes: build platform, build command, source repository, and builder identity. When combined with vulnerability scan attestations, you get a complete security pedigree for every image: who built it, how it was built, and what vulnerabilities it had at build time.
Cosign attestations support keyless signing via Fulcio and OIDC (OpenID Connect). In CI/CD, the build system (GitHub Actions, GitLab CI) obtains a short-lived certificate from Fulcio, signs the attestation, and attaches it to the image. No long-lived keys to manage or rotate. The attestation's validity can be verified against the OIDC token — proving it was created by your CI pipeline.
cosign attest without --key in CI. The Fulcio certificate automatically expires after the build. No key rotation, no secret storage, no key revocation list.cosign verify-attestation as a Kyverno admission check to block images without valid scan attestations.Kyverno Admission Controller for Image Security — Block Vulnerable Images at Deploy Time
Kyverno's ImageValidatingPolicy can enforce image security policies at Kubernetes admission time. Beyond signature verification, you can block pods that use images with known critical vulnerabilities, enforce that images come only from approved registries, or require that all images have a valid vulnerability scan attestation.
- CEL expressions: evaluate conditions like image.startsWith("trusted-registry.io/") or container name patterns.
- OCI attestation verification: Kyverno can fetch and verify Cosign attestations attached to the image. Check that the attestation contains a vulnerability report with no critical CVEs, or that a SLSA provenance attestation exists.
- Approved registries: enforce that all container images come from your private registry (e.g., registry.example.com/) or a set of approved public registries (docker.io/library/, gcr.io/distroless/*).
- Tag-to-digest mutation: use mutateDigest: true to automatically resolve mutable tags (latest, v1) to immutable digests. This prevents tag-mutation attacks where an attacker replaces the image a tag points to.
Combine with Cosign attestations for maximum coverage: policy requires (1) the image is from an approved registry, (2) the image has a valid Cosign signature, (3) the image has a vulnerability scan attestation with no critical CVEs, (4) the image has a SLSA provenance attestation showing it was built in your CI pipeline.
Deploy in Audit mode first to discover which workloads would be blocked. Kyverno reports policy violations in the resource's status and in Kyverno logs. After reviewing, switch to Enforce mode.
Notary v2 (Notation) — OCI-Compliant Image Signing Alternative to Cosign
Notation is the reference implementation of Notary v2, an OCI-compliant image signing standard. Unlike Cosign (which stores signatures as OCI artifacts in a separate repository), Notation embeds signatures directly into the OCI manifest using the OCI 1.1 referrers API. This means the signature travels with the image — no need to manage separate signature repositories or worry about signature GC.
- Signature storage: Cosign stores signatures in a separate OCI artifact (.sig tag). Notation embeds signatures in the OCI manifest via referrers API.
- Key management: Notation uses X.509 certificates managed via a trust store and trust policy. Cosign uses Cosign-generated key pairs or Fulcio short-lived certificates.
- Compliance: Notation's X.509-based model aligns with enterprise PKI infrastructure. Cosign's keyless model (Fulcio/OIDC) aligns with cloud-native CI/CD.
- Tooling: Notation is part of the OCI spec effort (CNCF). Cosign is a Sigstore tool (Linux Foundation).
Setting up Notation: generate a self-signed certificate or use an existing enterprise CA. Add it to a trust store (notation cert add). Sign images with notation sign --key
Notation integration with Kyverno: Kyverno's ImageValidatingPolicy supports Notation attestors natively. You specify trust stores and trust policies instead of Cosign public keys. This is useful for organizations that already operate an X.509 PKI.
Both Notation and Cosign achieve the same goal: proving image integrity and origin. The choice depends on your key management infrastructure. If you have an existing PKI, Notation is the natural fit. If you prefer OIDC-based keyless signing, use Cosign.
Registry-Level Scanning with Harbor — Block Vulnerable Pushes at the Registry
Harbor is an open-source cloud-native registry that integrates vulnerability scanning directly into the registry workflow. When an image is pushed to Harbor, it automatically triggers a scan (using Trivy, Grype, or Aqua CSP). Harbor evaluates the scan results against policy: images with critical vulnerabilities can be blocked from being pulled, or the push itself can be rejected.
- Push prevention: block images from being pushed if they contain critical or high CVEs.
- Pull prevention: allow push but block pull of images with vulnerabilities above a threshold.
- Quarantine: push images to a quarantine repository for manual review.
Harbor integrates with Trivy natively (built-in scanner, no configuration needed). It also supports custom scanners via the Scanner Adapter interface (Snyk, Aqua, Anchore). The scan results are displayed in the Harbor UI and exposed via API for integration with CI/CD pipelines.
Setup: deploy Harbor, enable the Trivy scanner, and create a vulnerability policy. The policy specifies the severity threshold (e.g., block if any CRITICAL), whether to scan on push, and the action (block push, block pull, or quarantine). Harbor also supports image replication with scan results — only replicate images that pass policy to your production registry.
Comparison with CI/CD scanning: CI/CD scanning blocks vulnerable images before they reach the registry. Registry-level scanning catches images that bypass CI (e.g., manual pushes, images from external pipelines, or base image drift after rebuilding). Both are necessary for defense in depth.
SBOM Format Comparison — CycloneDX vs SPDX and Legal Requirements
Choosing the right SBOM format depends on your use case: security operations, vulnerability management, or legal/license compliance. Both CycloneDX and SPDX are ISO/IEC standards, but they differ in scope and data model.
CycloneDX (OWASP) — ISO/IEC 19770-2:2024. Designed for application security and vulnerability management. Key features: - Component metadata: supplier, version, hashes, licenses - Dependency graph: explicit relationships between components - Vulnerability references: links to CVE, GHSA, and other advisory databases - Services and external references - Native support in Trivy, Syft, and Docker Scout - Best for: CI/CD security gates, vulnerability correlation, operations
SPDX (Linux Foundation) — ISO/IEC 5962:2021. Originally designed for license compliance. Key features: - File-level granularity (can list every file in a package) - Detailed license information (per-file and per-package) - Cross-reference document relationships - More verbose (3-5x larger than CycloneDX) - Best for: legal audits, M&A diligence, license compliance
NTIA Minimum Elements (EO 14028): Both formats can satisfy the six NTIA minimum elements: (1) Supplier name, (2) Component name, (3) Version, (4) Dependency relationships, (5) Author, (6) Timestamp. CycloneDX maps these naturally via supplier, name, version, dependencies, author, and timestamp fields. SPDX requires more fields but covers the same elements.
Recommendation: Generate CycloneDX for security workflows (vulnerability scanning, incident response) and SPDX for legal/compliance workflows. Most SBOM tools (Syft, Trivy) can generate both from a single scan. Store both in the registry as OCI attestations. For EO 14028 compliance, CycloneDX is the more practical choice — smaller, security-focused, and directly integrated with vulnerability databases.
The 4GB Container That Kept Dying
node:14-slim contained a vulnerable version of glibc (CVE-2021-33574) that caused a memory corruption in DNS resolution under high concurrency. The vulnerability was known for 6 months, but the image was never rescanned.node:14-slim with updated glibc (or moved to node:16-slim). Added weekly scheduled scans with trivy image --severity HIGH,CRITICAL and a CI gate that fails if any critical CVE is older than 30 days.- A vulnerability in your base image's OS packages can manifest as a hard-to-diagnose runtime crash.
- Scan early, scan often, and pin base image digests, not tags.
docker pull myapp:latest. 2. Check registry credentials: docker login. 3. Ensure image tag is correct. 4. If using private registry, pass --registry.username and --registry.password to Trivy.trivy image --update-db. 2. Verify the image has OS packages: docker run --rm myapp:latest cat /etc/os-release. 3. Try a different scanner (Grype) to cross-validate.TRIVY_CACHE_DIR and persist between runs. 2. Use --skip-db-update if DB is fresh. 3. Limit severity: --severity CRITICAL,HIGH. 4. Consider incremental scanning with --pkg-types os.docker pull myapp:latestdocker logintrivy image --registry.username $USER --registry.password $PASS myapp:latest| File | Command / Code | Purpose |
|---|---|---|
| Dockerfile | FROM node:18@sha256:abc123def456... | Why You Can't Trust Your Base Image |
| scan-comparison.sh | brew install trivy | Choosing the Right Scanner |
| .github | name: Docker Security Scan | Integrating Scanning into CI/CD |
| .trivyignore | CVE-2024-1234 # OPS-5678: Accepted until 2024-06-01. Library not loaded at runti... | Handling False Positives and Ignored CVEs |
| Dockerfile.multistage | FROM node:18-alpine AS builder | Scanning During Build |
| scan-secrets.sh | trivy image --scanners secret myapp:latest | Secrets Scanning |
| airgap-scan.sh | trivy image --download-db-only --cache-db /tmp/trivy-db | Scanning in Air-Gapped Environments |
| cosign-attestation.sh | trivy image --format cosign-vuln --output vuln.json myapp:latest | Cosign Attestation Integration |
| kyverno-image-security.yaml | apiVersion: kyverno.io/v2beta1 | Kyverno Admission Controller for Image Security |
| notation-setup.sh | notation cert generate-test --default "registry.example.com" | Notary v2 (Notation) |
| harbor-policy.yaml | apiVersion: goharbor.io/v1alpha1 | Registry-Level Scanning with Harbor |
| sbom-comparison.sh | syft myapp:latest -o cyclonedx-json > sbom.cdx.json | SBOM Format Comparison |
Key takeaways
--ignore-unfixed in CI to avoid failing on CVEs without patches; track unfixed CVEs separately with expiration dates.Interview Questions on This Topic
How does Trivy handle scanning a distroless image that has no package manager?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Docker. Mark it forged?
9 min read · try the examples if you haven't