feat: pin external images referenced by COPY --from= - #54
Conversation
Resolve and pin `COPY --from=<image>` the same way `FROM` is already pinned, so
an image copied from at build time is covered by the same supply-chain guarantee
as a base image.
What is pinned and what is not follows BuildKit's own reading of the flag:
- `--from=<image[:tag]>` is pinned, including a ref that already carries a digest
(re-resolved with `--update`).
- `--from=<stage>` is skipped. Every stage name in the file is collected up front,
so a name declared below the COPY is still recognised as a stage rather than
mistaken for an image; using one that way is a build error about stage order,
not an unpinned image.
- `--from=0` is skipped. BuildKit classifies the value with strconv.Atoi before
anything else, so a numeric value always selects a stage by position.
- `--from=scratch` and an empty `--from=` are skipped.
- `--from=<name>` matching no stage is treated as an image, as `FROM ubuntu` is.
A named build context is written the same way and cannot be told apart from the
Dockerfile alone; `--ignore-images` excludes one.
- `--from=image:${TAG}` is skipped: CopyCommand.Expand covers --chown, --chmod and
the paths but not --from, so BuildKit reads the value verbatim and the build
fails to parse the stage name (moby/buildkit#2374). `FROM` does expand, and
still does.
- `ADD --from=`, `RUN --mount=...,from=` and `--FROM=` are left alone: none of
them is a COPY --from flag, and `docker build` rejects the last two outright.
Two fixes fall out of the same code:
- ARG defaults are still collected in file order, so an ARG below a FROM does not
expand that FROM's ref. Collecting them up front — which a two-pass parse
invites — would silently pin a ref docker never expands.
- A rewrite now searches every line an instruction covers instead of only its
first, so a reference written after a "\" continuation is pinned rather than
silently reported as pinned and left unchanged. This also fixes `FROM \` +
newline, which had the same problem before COPY existed.
A COPY replacement is anchored at the `--from=` flag, so the same text elsewhere
on the line (another flag's value, a source path) is never rewritten by mistake.
Tests cover parsing, rewriting, the `run`/`check` command paths and the whole
pipeline end to end, including the reproduction from the issue, a testdata
Dockerfile round trip that asserts a second pass changes nothing, and the
`--update` path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CERP67PPQ72qxDHKqpXkcR
|
@codex review Generated by Claude Code |
Three defects Codex flagged, each confirmed against the BuildKit sources this project parses with. ONBUILD COPY --from was invisible. The parser hangs the wrapped instruction off the ONBUILD node instead of listing it at the top level, so scanning only top-level nodes missed it entirely: `check` said nothing and `run` never pinned it. The wrapped instruction is now unwrapped and given the ONBUILD node's line span and source text, which the parser leaves unset on the child. A reference split by a "\" continuation was silently not rewritten. Searching each physical line on its own cannot match `COPY --from=\` + newline + `nginx:1.27`, because no line holds the text the parser reports — so the digest resolved, the file did not change, and `run` still counted the image as pinned. The rewrite now runs over the instruction as the parser joins it, mapping each byte back to the line and column it came from, so the digest lands in the right place however the instruction is broken up. That covers a split value, a split flag name, and an existing digest spanning the continuation. Belt and braces for the same class of bug: RewriteFileReport returns the instructions it was handed a digest for but could not rewrite, and `run` warns about them and leaves them out of its "pinned N image(s)" count rather than overstating what it did. A digest no longer turns an image into a stage reference. BuildKit matches the whole value against its stage names, so `COPY --from=nginx@sha256:...` finds no stage named `nginx` and resolves from the registry; stripping the digest before the lookup marked it as a stage, so `check` never verified it and `run --update` could not refresh it. The same rule governs FROM, where the stripping was equally wrong. Each fix is covered by tests that fail when it is reverted, plus an ONBUILD trigger in the testdata round trip. Verified against the real registry: an ONBUILD COPY --from and a split reference both pin and then check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CERP67PPQ72qxDHKqpXkcR
|
Note on CI: the The push that added that commit did not create a CI run — GitHub's managed CodeQL ran on it and passed, but The commit is verified locally with the same commands the workflow runs:
Flagging it so the missing checks aren't read as a failure. Happy to be told otherwise if you'd rather I do something else about it. Generated by Claude Code |
|
Correction to my previous comment: CI did run on The run simply started late: pushed at 15:52, run created at 16:13. I checked at ~15:56 and again at ~16:00, saw no run, and concluded from that gap that the Generated by Claude Code |
|
@codex review |
Two more defects Codex flagged, both confirmed against BuildKit. An ONBUILD trigger was resolved against the wrong file's stages. dispatchOnbuild only records the trigger into this image's config; it never runs here. dispatchOnBuildTriggers executes it later, inside whichever build uses this image as its base, resolving it against *that* Dockerfile's dispatch states. The stage names declared in this file are gone by then, so passing them meant `ONBUILD COPY --from=nginx` in a file that also declares `AS nginx` was skipped as a stage reference and left unpinned, when it is an image to resolve. ONBUILD sources are now classified without any stage names. A stage index and scratch do not depend on a stage name and are still not images. The rewrite assumed a backslash continuation. A Dockerfile may change that character with the "# escape=" parser directive, and with "# escape=`" a reference split as "COPY --from=nginx:`" + newline + "1.27" left the backtick in the rebuilt instruction, so the reference was never found and the image went unpinned. The parser's configured escape token now travels on the instruction and the rewrite strips that character instead of a hard-coded one. Under a backtick escape a backslash is an ordinary character, so Windows-style paths in the same instruction are left alone. Both are covered by tests that fail when the fix is reverted. Verified against the real registry: a file combining "# escape=`", a reference split on a backtick, and an ONBUILD COPY --from pins all three images and then checks clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CERP67PPQ72qxDHKqpXkcR
Point at the official documentation for "# escape=" from both places that depend on it: the EscapeToken field that carries the parser's choice, and the default the rewriter falls back to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CERP67PPQ72qxDHKqpXkcR
<!-- Release notes generated using configuration in .github/release.yml at main --> ## What's Changed ### Other Changes * feat: pin external images referenced by COPY --from= by @azu in #54 **Full Changelog**: v1.4.1...v1.5.0 Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
|
Hey @azu, thank you for continuing the work here! My PR slipped my table a bit, sorry for that |
closes #53
closes #47
Pins the external image in a
COPY --from=the same wayFROMis already pinned, so an image copied from at build time gets the same supply-chain guarantee as a base image.This re-does the stalled #47 on current
main, keeping its approach and addressing the review comments left there. Credit to @jon4hz for the original, and @hairmare for triaging the review feedback.Result
Input from #53:
dockerfile-pin run -f Dockerfile --write, run against the real registry:What is pinned, and why
--fromaccepts an image, a build stage, or a named context, so each rule follows BuildKit's own reading of the flag rather than a guess.--from=image:tag--from=image:tag@sha256:…--updateFROM--from=stage-name--from=0strconv.Atoifirst, so a numeric value always selects a stage by position--from=scratch,--from=FROM ubuntualready does here--from=image:$TAGONBUILD COPY --from=image:tag# escape=directiveADD --from=,RUN --mount=…,from=,--FROM=COPY --fromflag;docker buildrejects the last two outrightTwo cases are worth calling out because #47 handled them differently:
Variables are not expanded.
CopyCommand.Expandcovers--chown,--chmodand the paths, but notFrom— BuildKit reads the value verbatim, so a variable there fails the build withfailed to parse stage name(moby/buildkit#2374). Expanding it would write a digest onto a line docker can never build, andcheckwould then call that line OK. It is reported as skipped instead.FROMdoes expand, and still does — that asymmetry is pinned down by a test.A stage declared below the COPY is still a stage. Using one is a build error about stage order, not an unpinned image, so all stage names are collected up front and there is nothing to rewrite.
FROMdeliberately does not get that treatment: BuildKit resolves eachFROMagainst the stages declared above it only, so a name defined later really is an image there.Review comments from #47
ARGbelow aFROMwould expand that FROM's ref — which docker does not do. ARG defaults are still collected in file order; only stage names are gathered up front.TestParse_ARGScopeIsSequentialcovers it.ImageRefleft empty on skipped COPY refs — now always populated, socheckreports the ref it skipped.Parsereturning more thanFROM— kept as one function.internal/dockerfileis not importable outside the module and both callers (run,check) want both kinds, so an option flag would be dead weight. The type and function docs say what is returned, andIsCopyFromdistinguishes them.Codex review, round 1 (fixed in f1177ec)
Each was reproduced first and confirmed against the BuildKit sources this project parses with.
ONBUILD COPY --from=was invisible — it parsed to zero instructions. The parser puts the wrapped instruction innode.Next.Children[0], not inresult.AST.Children, sochecksaid nothing andrunnever pinned it. It is now unwrapped, taking the ONBUILD node's line span and source text since the parser leaves the child's unset.\continuation was silently not rewritten — the digest resolved, the file was left unchanged, andrunstill counted the image as pinned. The rewrite now runs over the instruction as the parser joins it, mapping each byte back to the line and column it came from, so the digest lands correctly however the instruction is broken up: a split value, a split flag name, theFROMequivalent, or an existing digest spanning the continuation.nginx@sha256:…finds no stage namednginx. Stripping the digest before the lookup marked it as a stage, sochecknever verified it andrun --updatecould not refresh it. This was equally wrong forFROM, and predates this PR; both shareclassifyRef, so one change fixes both.Codex review, round 2 (fixed in 380d593)
dispatchOnbuildonly records the trigger into this image's config;dispatchOnBuildTriggersexecutes it later, inside whichever build uses the image as its base, resolving it against that Dockerfile's dispatch states. SoONBUILD COPY --from=nginxin a file that also declaresAS nginxwas skipped as a stage reference and left unpinned, when it is an image to resolve. ONBUILD sources are now classified with no stage names. A stage index andscratchdo not depend on one, so those are still not images.# escape=parser directive, and under a backtick escape a reference split across the continuation kept the backtick in the rebuilt instruction, so it was never found and the image went unpinned. The parser'sEscapeTokennow travels on the instruction and the rewrite strips that character. The reverse case has its own test: under a backtick escape a backslash is an ordinary character, so a Windows-style path in the same instruction must survive untouched.The
RewriteFileReportbackstop added in round 1 is what kept the second one from being silent — the reference came back as unrewritten instead of being counted as pinned.Other fixes that fall out of the same code
\is now pinned rather than silently reported as pinned and left unchanged. This also fixesFROM \+ newline, which had the bug before COPY existed.--from=flag, so the same text elsewhere on the line never gets the digest by mistake — e.g.COPY --chown=node:20 --from=node:20 /app /app.RewriteFileReportreturns the instructions handed a digest that could not be rewritten;runwarns about those and leaves them out of itspinned N image(s)count, so this class of bug cannot be silent again.Tests
Enriched at four levels;
go test ./... -race,go vetandgolangci-lintall clean locally.isStageIndex.AddCopyFromDigestincluding the anchoring guard, plus whole-file rewrites: the Feature request: support pinning external images referenced viaCOPY --from=#53 reproduction compared byte for byte, a multi-stage file, line continuations and split references, the# escape=directive,--update, repeated refs, and skipped instructions staying untouched even when handed a digest. The split and escape cases re-parse the output and assert the digest really landed.parseFilecollecting the right refs (never a stage name or index),--ignore-imagesapplying to COPY refs,applyDockerfilewriting to disk, and the status/line/original textcheckreports for each form.COPY --from=#53 reproduction and the--updatepath through the full pipeline,checkstatuses, and atestdata/copy_from.Dockerfileround trip that writes the file out, asserts every pinnable ref carries a digest, and re-runs to confirm a second pass changes nothing. Resolution there is strict, so a ref that should have been skipped fails the test instead of quietly reaching the registry.Every behavior worth relying on was checked by reverting it and confirming a test goes red: the anchoring, the continuation span, the ARG ordering, and each of the five Codex findings.
Also verified by hand against the real registry: the run above,
check --syntax-onlybefore and after, idempotence on a second run, a continuedCOPY \with--chown, anONBUILD COPY --from, a split reference, and a file combining# escape=with a backtick-split reference.