Scan the image a Dockerfile produces, not the base image in its FROM line - #63
Merged
Conversation
grafele
force-pushed
the
soup-scan-the-built-image
branch
2 times, most recently
from
August 28, 2026 13:04
8cf008c to
f9fb1cf
Compare
…line
A container candidate takes its scan target from the Dockerfile FROM line, so
what gets scanned is the base image. Everything the Dockerfile does after that
is invisible in both directions: a package the final stage deletes is still
reported against the product, and anything the final stage installs is missing
from the inventory. A final stage that removes the base image's bundled npm
still produces CVEs for that npm on an immediate track, while an `apk add` in
the same stage appears nowhere.
The scope template made this worse than a silent gap. It instructed every
product to exclude its own deployed image because "its components are covered
by dockerfiles-image-final, which builds it" — a coverage claim that does not
hold while the candidate scans only its base. Corrected here, and the include
entry now says what it scans. The same claim sat in discover.sh's note for
release-versioned images and is corrected there too: that candidate covers the
contents only where it declares built_image.
An include entry may now carry `built_image`, with ${version} substituted from
the run. Declared rather than derived: the reference lives in a compose file
and a release workflow, and the naming between them is per-product convention.
Deriving it would be a guess, and a wrong guess scans someone else's image and
files it as ours.
One string is not enough. A product may push <version>-staging from its
staging release workflow and <version> from its production one, so a single
reference can only ever be right for one of the two — declared as a string,
the other tier's run pulls nothing and records gaps. built_image may therefore
be a map of tier to reference, keyed on the tier the pipeline already resolves.
A tier the map does not name is a gap, for the same reason an unknown version
is.
Three outcomes, and none of them is quietly keeping the base image. A
declaration that cannot be applied stops the run, because ignoring it would
leave the candidate on its base image while the scope file states otherwise. A
version the run does not know, or a tier the declaration does not name, makes
the candidate a reported gap — now carrying the candidate's own note rather
than the fixed line about templated deploy references, since the two are
unscannable for different reasons. Only a fully resolved reference redirects,
and the FROM image stays on the record as base_image. Without the declaration
nothing changes.
Registry credentials already exist as registry-username / registry-password on
the action.
Comments in the files touched here no longer name products or cite individual
reports; the same phrasing elsewhere in the tree is left for its own change.
234 passed offline, 240 with TEST_NETWORK=1, 0 failures either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
grafele
force-pushed
the
soup-scan-the-built-image
branch
from
August 28, 2026 13:16
f9fb1cf to
fadb168
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
discover.shtakes a container candidate's scan target from the Dockerfile'sFROMline. The lastFROMbecomes the-finalcandidate withships=true. So what is scanned is the base image — everything the Dockerfile does afterwards is invisible, in both directions.Deletions are ignored. A final stage that removes the base image's bundled npm:
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npxstill produces CVEs for that npm — the packages it vendors, at the exact versions the base image ships — on an immediate track with a deadline. Software that is not in the shipped artefact. A node base image carries 174 vendored packages this way.
Additions are ignored too. An
apk addin the final stage appears nowhere: the candidate contributes only the bare base packages of itsFROMimage.The scope template made it worse
It instructed every product to exclude its own deployed image:
That coverage claim does not hold while the candidate scans only its base, and it is why the gap is invisible rather than merely present: each product records a decision, with a reason, to skip the only candidate that would have scanned the real image. Corrected here, and the
includeentry now says what it actually scans. The same claim sat indiscover.sh's note for release-versioned images and is corrected there too.The change
An
includeentry may carrybuilt_image, with${version}substituted from the run:Declared, not derived. The reference is spread across a compose file (service → Dockerfile) and a release workflow (
docker push <reg>/<prefix>-<service>:<version>), and the prefix is a per-product convention. Deriving it means guessing, and a wrong guess scans someone else's image and files it as ours — the failureresolve-deployed.shalready refuses by answering "unknown".One string is not enough. A product that pushes
<version>-stagingfrom its staging release workflow and<version>from its production one cannot be described by a single reference; declared as a string, the other tier's run pulls nothing and records gaps. So the value may instead be a map, keyed on the tier the pipeline already resolves:Four outcomes, none of them a silent fallback:
base_imagekeeps the FROM image on the recordbuilt_imageon a non-container, or an unknown placeholderOrdering already holds: the
workflow_runtrigger exists so images are pushed before the SBOM runs. Credentials already exist asregistry-username/registry-passwordon the action.What it buys
apk addclass of finding becomes visible.Not everything disappears: an image that is single-stage, or that genuinely ships what the base image brought, keeps those findings. Correct rather than fewer.
Testing
7 new tests covering the redirect, the tier map, the untouched default, and all refusal paths. 234 offline, 240 with
TEST_NETWORK=1, 0 failures either way.Verified end to end against a real product's images, both tiers: the vendored npm packages disappear from the redirected candidate, the
apk addpackages appear, and the component count for that artefact rises by an order of magnitude.Rollout
This changes nothing until a product declares
built_image. Each product with a Dockerfile needs one entry per-finalcandidate; 15 repos, typically two or three each.Comments in the files touched here no longer name products or cite individual reports. The same phrasing elsewhere in the tree is left for its own change.