Base image versus built image: your container scan is probably looking at the wrong thing
Published August 28, 2026. About a nine-minute read.
Ask a team whether they scan their containers and you will usually get a yes. Ask what the scanner is pointed at and the answer splits into two very different things. Some teams scan the image named in their FROM line. Some teams scan the image their build actually produced. Those sound like the same job. They are not, and the gap between them is where most container vulnerabilities live.
This is not a subtle distinction that only matters to purists. A base-only scan can come back completely clean on an image that is shipping a critical remote code execution bug, a leaked API key, and a package you installed yourself three months ago and forgot about. The scan was not wrong. It was answering a question nobody meant to ask.
What is in a base image, and what your build adds
A base image is the starting point on your FROM line: node:20-alpine, python:3.13-slim, ubuntu:24.04. It contains an operating system package set, a language runtime, and not much else. When someone at Docker or Canonical or the Python project publishes a fix, a new tag appears and a scanner pointed at that tag reports on those OS packages. That is a real and useful thing to know.
It is also a small fraction of what ends up running. Your build takes that base and adds, in rough order of how much risk each one carries:
- Your application dependencies. The
npm ciorpip installstep pulls in hundreds of packages, most of them transitive, none of them present in the base image. In most real applications this is where the large majority of findings live, by a wide margin. - Packages you install yourself. Every
apk add,apt-get install, anddnf installline puts OS packages into the image that the base image maintainer has never heard of and does not patch for you. - Binaries you download. The
curl | tarthat drops a CLI tool into/usr/local/binis invisible to a package manager, so nothing tracks its version and nothing tells you when it gets a CVE. - Whatever
COPY . .swept up. Your source, and also anything your.dockerignoredid not catch. Committed.envfiles, the whole.gitdirectory, a cloud credentials JSON someone left in the repo root during a debugging session.
Scanning the base image tells you about the first layer of that stack and nothing about the other four. Scanning the built image tells you about all five, because the base is inside the built image too. The built image scan is a strict superset. That is the whole argument in one sentence, and everything below is detail on why the difference is bigger than it sounds.
A worked example
Take an ordinary Dockerfile:
FROM node:20.20.2-alpine3.22
WORKDIR /app
RUN apk add --no-cache curl imagemagick
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]Point a scanner at the base image and you get a report on the Alpine package set that Node ships with: musl, busybox, OpenSSL, a handful of others. On a freshly published tag that report is often empty or close to it, and you get a green tick.
Point the same scanner at the image the build produced and the report covers the base packages, plus curl and ImageMagick and every shared library they dragged in, plus all of node_modules, plus any secret that COPY . . happened to copy. ImageMagick alone has a long and colourful CVE history, and it is in the image because you put it there, so no base image update will ever fix it for you.
# What a lot of CI does: scan the FROM line
trivy image node:20.20.2-alpine3.22
# What actually ships
docker build -t myapp:$GIT_SHA .
trivy image --scanners vuln,secret myapp:$GIT_SHASame tool, same flags, two results that can differ by hundreds of findings. The second one is the one that boards a plane to production.
What a base-only scan cannot see
It is worth being precise about the categories, because each one is a different kind of surprise.
- Application dependency CVEs. The single biggest blind spot. A base image scan has no visibility into your lockfile, so a critical in a transitive npm or PyPI package sails straight through.
- Anything your Dockerfile installed. Packages added in
RUNlines belong to you. If the version in the distro repo was already old when you built, you inherited that and nobody will tell you. - Unmanaged binaries. Tools fetched with curl or wget do not appear in any package database, so most scanners cannot version them at all. They show up in the image and in no report. The fix is to install through a package manager where you can, and to record versions where you cannot.
- Secrets and files that should not be there. A scanner run against a built image with secret detection turned on finds the AWS key in the stray
.env, the private key indeploy/, the token in the.githistory you copied in wholesale. There is nothing in a base image for that check to find, so people run image builds for years without ever pointing it at the one artifact that could have something.
Notice that three of those four are things you did, not things a vendor did to you. That is the pattern. Base image scanning checks somebody else's work. Built image scanning checks yours.
It cuts the other way too
The mirror image of the problem is less discussed and just as corrosive. A base image scan can report vulnerabilities you have already fixed.
If your Dockerfile runs apk upgrade --no-cache or apt-get upgrade after the FROM line, the packages in your built image are newer than the ones in the base tag. The base scan flags a stale OpenSSL. Your actual image has the patched one. You go and investigate, find nothing, and learn a little bit that the scanner cries wolf.
That lesson is expensive. Alert fatigue is not a soft problem, it is the mechanism by which real findings get ignored. A scanner that reports on an artifact you do not ship will produce both false alarms and false comfort, and after a few months of both, people stop reading the output at all. Scanning the real artifact is partly a security argument and partly just a way to keep the signal worth somebody's attention.
So why look at the base at all
Because the built image scan tells you what is wrong and the base tells you whose problem it is. Those are different questions and you need both answers to act.
When a finding lands, the first thing you want to know is which layer introduced it, because that determines the fix:
- It came from the base. You cannot patch it directly. The fix is a base image bump, or a switch to a slimmer base that does not carry the package at all.
- It came from your Dockerfile. Pin a newer version, or ask whether you needed that package. A surprising number of
RUN apk addlines are leftovers from a debugging session in 2023. - It came from your lockfile. This is ordinary dependency work. Update, override the transitive version, or track the upstream fix.
The good news is you do not need a separate scan to get this. Layer attribution comes out of the built image scan itself, because the scanner knows which layer each package came from. Trivy records the layer for every finding in its JSON output. Snyk gives you base image upgrade advice if you hand it the Dockerfile alongside the image:
snyk container test myapp:$GIT_SHA --file=Dockerfile
# Docker Scout will tell you what a base bump would buy you
docker scout recommendations myapp:$GIT_SHASo the rule is not "scan the built image instead of the base". It is "scan the built image, and use the layer information to work out whether the base is where the fix goes". One scan, two answers.
How to scan the image you actually ship
The mechanics are genuinely easy, which makes the fact that so many pipelines skip it a bit frustrating. Build the image, tag it with something unique, scan that tag, then decide whether to push.
docker build -t myapp:$GIT_SHA .
# Trivy: vulnerabilities and secrets in one pass
trivy image --scanners vuln,secret \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--exit-code 1 \
myapp:$GIT_SHA
# Alternatives that do the same job
grype myapp:$GIT_SHA
docker scout cves myapp:$GIT_SHA
# And separately, lint the Dockerfile itself
trivy config DockerfileTwo details in there matter more than they look. --exit-code 1 is what turns a report into a gate; without it the scan runs, prints, and the pipeline shrugs and pushes anyway. And the scan has to happen after the build and before the push, on the local tag. A scan that runs against whatever is already in the registry is checking last week's decision.
While you are there, generate a software bill of materials from the same image. An SBOM is a machine readable list of everything inside, and it is what lets you answer "are we affected" in one query the next time a big advisory lands rather than rebuilding six images to find out. This is the container half of the broader supply chain problem.
syft myapp:$GIT_SHA -o spdx-json > sbom-$GIT_SHA.jsonMulti-stage builds and the noise problem
If your Dockerfile has more than one FROM, scan the final image, not the builder stage. The builder typically contains compilers, headers, git, dev dependencies, and a pile of tooling with its own CVE history, none of which ships. Scanning it produces a long report about risk you do not have.
This cuts the other way as an argument for multi-stage builds in the first place. Moving the build tooling into a discarded stage is one of the few changes that genuinely reduces both your attack surface and your finding count at the same time. Same for choosing a slim or distroless final base: fewer packages in the image means fewer packages that can turn out to be vulnerable in a year. A container that contains a shell, a package manager, and curl gives an attacker who gets code execution a lot more to work with than one that contains a static binary and nothing else.
A clean scan has a shelf life
The other half of this, and the part that catches careful teams, is time. Container images are immutable. Vulnerability databases are not. The exact same image digest that scanned clean on Tuesday can carry two criticals on Thursday, with nothing at all having changed on your side. The advisory database moved, not your image.
This is not hypothetical. It is common enough that any pipeline which only scans at build time will eventually be running known vulnerable images in production and reporting green, because the last scan happened before the advisory existed. If you deploy weekly and only scan on build, your production images are on average several days stale against the advisory feed, and a long-lived service that has not been rebuilt in three months has a three-month-old opinion of itself.
Two things fix this, and you want both:
- A scheduled rescan of what is deployed. A nightly job that scans the image tags currently running, not the ones you happen to be building. This is cheap to set up and it is the only thing that closes the gap between build time and now.
- Registry-side continuous scanning. Most registries will do it for you. Google Artifact Registry re-analyses images pushed in the last thirty days as new advisories arrive. Amazon ECR enhanced scanning does continuous rescanning through Inspector. Turn it on, then make sure the findings route somewhere a person reads, because a dashboard nobody opens is not a control.
The tag you scanned is not always the tag you built
One more trap, and it is the reason base scans and built scans can disagree in ways that look like a tool bug. Tags move.
FROM node:20-alpine # repointed whenever upstream feels like it
FROM node:20.20.2-alpine3.22 # better, still a mutable tag
FROM node:20.20.2-alpine3.22@sha256:9b4c1f... # exactly one image, foreverIf your CI scans node:20-alpine today, it is scanning whatever that name points at right now, which may not be what your build pulled last Friday. Pinning by digest makes the base reproducible and makes the two scans comparable. The cost is that you now own the update, so pair it with something like Dependabot or Renovate that opens a pull request when the digest moves. That is a fair trade: you get a visible, reviewable diff for a base image change instead of it happening silently inside a build.
Triage, or how to keep people reading the output
The first honest built image scan of a real application is usually demoralising. Several hundred findings, most of them low, many with no fix available, a few that matter. If you gate on all of them you will be turning the gate off by Wednesday.
- Gate on high and critical, report the rest. The gate should block things you would genuinely stop a release for. Everything else belongs in a report you look at weekly.
- Use
--ignore-unfixedon the gate. A vulnerability with no upstream patch cannot be actioned by blocking your own deploy. Track those separately rather than letting them jam the pipeline. - Expire your exceptions. Every suppression gets a reason and a date. Permanent ignore lists are how a real finding becomes invisible, and they always start as a temporary unblock that nobody revisited.
- Fix by layer, not by finding. One base image bump often clears fifty findings at once. Look at what a single upgrade would resolve before working through the list one CVE at a time.
Worth saying plainly: "zero criticals" is a policy statement, not a fact about the image. It means zero criticals that this database knew about at the moment of the scan, at the severity threshold you chose, excluding whatever you suppressed. Knowing that is the difference between a control and a comfort blanket.
What a workable setup looks like
Putting it together, a container pipeline that actually tells you the truth does five things:
- Pins the base by digest, with an automated pull request when it moves.
- Builds, then scans the built image on the local tag, then pushes. Not the other way round.
- Runs vulnerability and secret detection in the same pass, gated on high and critical with unfixed findings excluded from the gate.
- Produces an SBOM per build and keeps it, so the next big advisory is a query rather than an investigation.
- Rescans what is deployed on a schedule, because the image stopped changing and the advisory feed did not.
None of that is exotic and most of it is a handful of lines in a workflow file. The reason it so often does not happen is that scanning the base image feels like the same job and produces a green tick faster. It is worth being suspicious of any security check that has never once been inconvenient. If your container scan has been quiet for a year, the most likely explanation is not that your images are clean. It is that you are scanning something other than your images.