CertGrid CertGrid
Hands-on Lab·Docker

Docker Image Vulnerability Scanning

Scan a deliberately old base image, read what comes back, then prove the fix: the same scan against a current base returns zero findings. Most image vulnerabilities are inherited, not written by you.

Security and Production Guide 37 of 46 Intermediate

Tested on the versions above. Docker Scout is a CLI plugin and is not installed on this host, so the scans here use Trivy run as a container - no host packages were installed. CVE counts change daily as advisories are published; the method is what transfers.

One Docker host is all this guide needs. Nothing here depends on a second machine, and the hardware above is modest on purpose - a 2 core, 4 GB VM runs everything in this path.
Server NameIP AddressOSRolesCPURAMHDD
DOCKER01192.168.0.21Ubuntu 26.04 LTSDocker Host2 Core4 GB50 GB

Before you start

  1. Check what scanner you actually have

    Docker Scout is Docker's own scanner and is excellent, but it ships as a CLI plugin that is not present on every install - including this one. Rather than describe a tool that is missing, this guide uses Trivy, which runs as a container and needs nothing installed on the host.

    bash
    docker scout versiondocker: unknown command: docker scout# not installed here - so we use a scanner that runs as a container instead

    Expected resultThe plugin missing on a stock Engine install.

    Success conditionYou know which tools are available to you. If docker scout does exist on your machine, docker scout cves IMAGE is the direct equivalent of everything below.

  2. Scan something with known problems

    Scanning a current image usually returns nothing, which teaches you little. An older base image gives real findings to read. The socket mount lets the scanner read images from the local daemon; the cache volume stops it re-downloading the vulnerability database on every run.

    bash
    docker pull -q alpine:3.16docker.io/library/alpine:3.16docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --severity HIGH,CRITICAL alpine:3.16 Report Summary ┌─────────────────────────────┬────────┬─────────────────┬─────────┐│           Target            │  Type  │ Vulnerabilities │ Secrets │├─────────────────────────────┼────────┼─────────────────┼─────────┤│ alpine:3.16 (alpine 3.16.9) │ alpine │        2        │    -    │└─────────────────────────────┴────────┴─────────────────┴─────────┘Legend:- '-': Not scanned- '0': Clean (no security findings detected)  alpine:3.16 (alpine 3.16.9)===========================Total: 2 (HIGH: 2, CRITICAL: 0) ┌────────────┬────────────────┬──────────┬────────┬───────────────────┬───────────────┬────────────────────────────────────────────────────┐│  Library   │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                       Title                        │├────────────┼────────────────┼──────────┼────────┼───────────────────┼───────────────┼────────────────────────────────────────────────────┤│ musl       │ CVE-2025-26519 │ HIGH     │ fixed  │ 1.2.3-r3          │ 1.2.3-r4      │ musl libc 0.9.13 through 1.2.5 before 1.2.6 has an ││            │                │          │        │                   │               │ out-of-bounds write ......                         │

    Expected resultA summary naming the image, the OS it detected, and a count of HIGH and CRITICAL findings.

    Success conditionTwo findings at HIGH or above. Filtering by severity matters - an unfiltered scan of a large image returns hundreds of LOW entries that bury anything urgent.

  3. Read the report rather than the number

    A count is not actionable. What matters per finding is which package is affected, which CVE it is, and - the field people skip - whether a fixed version exists. A vulnerability with no fix available cannot be resolved by upgrading, and needs a different response.

    bash
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --severity HIGH,CRITICAL --format json alpine:3.16 | jq -r '.Results[].Vulnerabilities[] | "\(.VulnerabilityID) \(.PkgName) installed=\(.InstalledVersion) fixed=\(.FixedVersion // "none")"'# each line: the CVE, the package, what you have, and what fixes it# fixed=none means no upgrade resolves it - assess exposure instead

    Expected resultOne line per finding with the fix version, or none.

    Success conditionYou can separate what is fixable from what is not. fixed=none is not a reason to ignore it - it is a reason to judge whether that code path is reachable in your image.

  4. Base image or your application?

    This distinction determines who fixes it. Findings against system packages come from the base image, and you fix them by moving to a newer base - not by changing your code. Findings against your language dependencies come from your manifest and are yours. Scanners report both together, and conflating them wastes time.

    bash
    # base image findings: alpine, debian, glibc, openssl, busybox ...#   -> fix by rebuilding on a newer base tag# application findings: npm, pip, gem, go modules ...#   -> fix by updating your dependency manifest and rebuildingdocker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --vuln-type os alpine:3.16# --vuln-type os limits the scan to base-image packages

    Expected resultA scan narrowed to operating-system packages only.

    Success conditionYou can attribute a finding before deciding what to do about it. Most findings in a typical image are inherited from the base.

  5. Fix it, and prove the fix

    This is the step that closes the loop and it is the one most often skipped. The same scan against a current base returns zero. That is the evidence that the remediation worked - not the assumption that upgrading probably helped.

    bash
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --severity HIGH,CRITICAL alpine:3.22 Report Summary ┌─────────────────────────────┬────────┬─────────────────┬─────────┐│           Target            │  Type  │ Vulnerabilities │ Secrets │├─────────────────────────────┼────────┼─────────────────┼─────────┤│ alpine:3.22 (alpine 3.22.5) │ alpine │        0        │    -    │└─────────────────────────────┴────────┴─────────────────┴─────────┘Legend:- '-': Not scanned

    Expected resultZero HIGH or CRITICAL findings on the current base.

    Success conditionTwo findings became none by changing one line in the Dockerfile. Re-scanning after the rebuild is what turns a claimed fix into a verified one.

  6. Make it fail the build

    A scan nobody reads changes nothing. --exit-code 1 makes the scanner return non-zero when it finds something at or above the threshold, which fails the CI job. Start with CRITICAL only so the gate is credible, then tighten - a pipeline that fails constantly gets bypassed within a week.

    bash
    docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --exit-code 1 --severity CRITICAL myapp:latest# exit 1 when a CRITICAL is present, 0 when clean - the job fails on its own# gate on CRITICAL first; gating on everything produces alerts nobody acts on

    Expected resultA command whose exit status the pipeline can act on.

    Success conditionThe scan has consequences. Pair it with an ignore file for accepted findings, reviewed on a schedule rather than forgotten.

  7. Reduce what there is to find

    The cheapest long-term fix is a smaller image. Every package present is something that can acquire a CVE, and a runtime image built from a multi-stage build carries almost none. Scanning is how you see the problem; image design is how you stop having it.

    bash
    docker images --filter reference=cg-multi --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}"REPOSITORY:TAG     SIZEcg-multi:1         112kBcg-multi:builder   253MB# the 112kB runtime image has no shell and no package manager to report on

    Expected resultThe runtime image orders of magnitude smaller than the builder.

    Success conditionYou can connect image size to how much there is to scan. See guide 19 for how that image was produced.

  8. Clean up

    Remove the deliberately old image and the scanner's cache volume so neither lingers.

    bash
    docker rmi alpine:3.16docker volume rm trivy-cache# and docker rmi aquasec/trivy:latest if you do not intend to scan again soon

    Expected resultThe image and cache volume removed.

    Success conditiondocker volume ls no longer lists the cache. Keep the scanner image if you scan regularly - it saves the pull each time.

Troubleshooting

Official sources