Skip to content

Scan the image a Dockerfile produces, not the base image in its FROM line - #63

Merged
grafele merged 1 commit into
mainfrom
soup-scan-the-built-image
Aug 28, 2026
Merged

Scan the image a Dockerfile produces, not the base image in its FROM line#63
grafele merged 1 commit into
mainfrom
soup-scan-the-built-image

Conversation

@grafele

@grafele grafele commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

The defect

discover.sh takes a container candidate's scan target from the Dockerfile's FROM line. The last FROM becomes the -final candidate with ships=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/npx

still 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 add in the final stage appears nowhere: the candidate contributes only the bare base packages of its FROM image.

The scope template made it worse

It instructed every product to exclude its own deployed image:

deployed-service--version- — Our own image at the release version. […] its components are covered by dockerfiles-image-final, which builds it.

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 include entry now says what it actually scans. The same claim sat in discover.sh's note for release-versioned images and is corrected there too.

The change

An include entry may carry built_image, with ${version} substituted from the run:

include:
  - id: dockerfiles-image-final
    reason: Final stage of the service Dockerfile — the image we ship.
    built_image: ourregistry/our-service:${version}

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 failure resolve-deployed.sh already refuses by answering "unknown".

One string is not enough. A product that pushes <version>-staging from 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:

    built_image:
      staging: ourregistry/our-service:${version}-staging
      candidate: ourregistry/our-service:${version}

Four outcomes, none of them a silent fallback:

situation result
resolved reference redirected; base_image keeps the FROM image on the record
built_image on a non-container, or an unknown placeholder run stops — ignoring it leaves the candidate on its base image while the scope file states otherwise
run knows no version, or the map does not name this tier reported gap, never the base image instead. The gap carries the candidate's own note; the fixed line about templated deploy references named the wrong cause
no declaration unchanged behaviour

Ordering already holds: the workflow_run trigger exists so images are pushed before the SBOM runs. Credentials already exist as registry-username / registry-password on the action.

What it buys

  • Packages the final stage deletes stop being reported, with their deadlines.
  • The apk add class of finding becomes visible.
  • Findings that only exist in the shipped image — an application dependency tree, a runtime the base image does not carry — become visible for the first time.
  • No product writes a VEX statement for base-image tooling it already deleted.

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 add packages 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 -final candidate; 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.

@grafele
grafele force-pushed the soup-scan-the-built-image branch 2 times, most recently from 8cf008c to f9fb1cf Compare August 28, 2026 13:04
…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
grafele force-pushed the soup-scan-the-built-image branch from f9fb1cf to fadb168 Compare August 28, 2026 13:16
@grafele
grafele merged commit f7dec18 into main Aug 28, 2026
@grafele
grafele deleted the soup-scan-the-built-image branch August 28, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant