Skip to content

fix(build): re-include local bin overrides in the generated .dockerignore - #374

Open
initializ-mk wants to merge 2 commits into
mainfrom
fix/dockerignore-local-bins
Open

fix(build): re-include local bin overrides in the generated .dockerignore#374
initializ-mk wants to merge 2 commits into
mainfrom
fix/dockerignore-local-bins

Conversation

@initializ-mk

@initializ-mk initializ-mk commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Problem

The generated .dockerignore unconditionally excludes .local-bins/, but a build with local bin overrides (--local-bin name=/path or bin_overrides.<name>.local) generates a Dockerfile that COPYs each override straight from the build context:

COPY .local-bins/forge /usr/local/bin/forge

docker build then fails:

ERROR [3/6] COPY .local-bins/forge /usr/local/bin/forge
CopyIgnoredFile: Attempting to Copy file ".local-bins/forge" that is excluded by .dockerignore (line 21)
failed to compute cache key: "/.local-bins/forge": not found

Hit in a real CI pipeline (Buildkite, demo-weather agent) using --local-bin forge=… — the documented path for baking a vnext-prerelease forge runtime into the image, since the snapshot binary's self-reported version is not a real release tag the Dockerfile could pin.

Fix

Append a !.local-bins/<name> negation per override after the blanket .local-bins/ exclusion — dockerignore is last-match-wins, so each override re-enters the context while any unrelated stash content stays excluded. The config+CLI override union is extracted into localBins(), shared with copyLocalBins so the two can't drift.

No behavior change for builds without local bin overrides (pinned by a new test).

Testing

  • TestDockerignore_ReincludesLocalBinOverrides — negations present for both config (bin_overrides) and CLI (--local-bin) overrides, ordered after the exclusion
  • TestDockerignore_NoLocalBins_KeepsPlainExclusion — no negations in the default path
  • Full forge-cli suite passes; golangci-lint clean; gofmt clean
…nore

The generated .dockerignore unconditionally excludes .local-bins/, but a
build with local bin overrides (--local-bin or bin_overrides.*.local)
emits `COPY .local-bins/<name> /usr/local/bin/<name>` lines that read
straight from the build context — docker then fails with:

  failed to calculate checksum of ref …: "/.local-bins/forge": not found

Append a !.local-bins/<name> negation per override after the blanket
exclusion (dockerignore last-match-wins), keeping unrelated stash content
excluded. Extracts the config+CLI override union into localBins(), shared
with copyLocalBins.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: re-include local bin overrides in .dockerignore

The fix is correct, well-scoped, and well-tested — it unblocks a real CI failure. One defense-in-depth finding on unvalidated bin-override names (pre-existing, but this PR extends it to a new sink). Not blocking; can be a fast follow-up. CI fully green.

The fix is right ✅

  • dockerignore semantics: !.local-bins/<name> negations appended after the blanket .local-bins/ exclusion — last-match-wins re-enters each override while unrelated stash content stays excluded.
  • Deterministic: names sorted → reproducible .dockerignore (cache-stable).
  • localBins() extraction shared between copyLocalBins and the dockerignore generation prevents drift — the right refactor, since a mismatch would reintroduce this exact bug.
  • No default-path change (len(bins) > 0 gate), pinned by TestDockerignore_NoLocalBins_KeepsPlainExclusion; the re-inclusion test checks config + CLI overrides AND the ordering-after-exclusion invariant. Good coverage.

Finding — Low/Medium (defense-in-depth): bin-override names are unvalidated

name reaches three sinks unsanitized, and nothing validates its charset (parseLocalBins checks the path, not the name; ValidateForgeConfig has no bin_overrides check):

  1. fs writedst := filepath.Join(localBinsDir, name) in copyLocalBins; a ../ name traverses outside .local-bins/ (pre-existing).
  2. DockerfileCOPY .local-bins/<name> /usr/local/bin/<name> (pre-existing).
  3. .dockerignore — the new !.local-bins/<name> line (see inline) — a newline injects arbitrary dockerignore patterns; a glob re-includes the whole stash.

Threat model: for local forge build on your own agent this is self-inflicted → a non-issue. But Forge's CI/BYO story includes a platform building untrusted BYO forge.yaml, where a crafted bin_overrides.<name> becomes an injection vector across all three sinks. Impact is bounded (traversal → build host's output area; dockerignore re-includes limited to context contents), but a real hygiene gap once configs aren't self-authored.

Recommendation: validate the name against a strict binary-name pattern (e.g. ^[a-zA-Z0-9_.-]{1,64}$) in both parseLocalBins (CLI) and ValidateForgeConfig (bin_overrides) — closes all three sinks at the source. Since the more dangerous sinks (fs traversal, COPY) already exist on main, fair to land this dockerignore fix now and validate names in a fast follow-up.

The dockerignore fix is sound and merge-ready on its own.

}
sort.Strings(names)
for _, name := range names {
dockerignoreContent += fmt.Sprintf("!.local-bins/%s\n", name)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defense-in-depth (Low/Medium): name is written here unsanitized. It's fully user-controlled (bin_overrides.<name> in forge.yaml, or --local-bin name=…) and nothing validates its charset — parseLocalBins checks only the path, and ValidateForgeConfig has no bin_overrides check. A newline in a name injects arbitrary .dockerignore lines here (e.g. !../secrets or !. re-including unintended build-context paths); a glob like * re-includes the whole .local-bins/ stash you just excluded.

This PR adds .dockerignore as a third sink alongside the pre-existing ones — copyLocalBins's filepath.Join(localBinsDir, name) (a ../ name traverses outside .local-bins/) and the Dockerfile COPY .local-bins/<name> /usr/local/bin/<name>. For local self-authored builds this is self-inflicted, but for a platform building untrusted BYO forge.yaml it's a real injection vector.

Closing it at the source — validate the name against ^[a-zA-Z0-9_.-]{1,64}$ in parseLocalBins and ValidateForgeConfig — fixes all three sinks at once. Pre-existing on main, so it needn't block this fix; worth a fast follow-up (a bin_overrides name is a binary basename, so the strict pattern costs nothing).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e793ad1 — took the recommendation and closed it at the source in both entry points: validate.ValidBinOverrideName (exported from forge-core/validate) enforces ^[a-zA-Z0-9_.-]{1,64}$ in ValidateForgeConfig (package.bin_overrides) and parseLocalBins (--local-bin).

One addition beyond the suggested pattern: an explicit pure-dot rejection. . and .. pass the charset class (dots are in it), and .. is exactly the traversal case the validation exists to stop — so the helper also requires strings.Trim(name, ".") != "".

Tests cover both directions at both entry points: traversal (../x, ..), newline injection, glob *, dockerignore ! metacharacter, whitespace, over-length; plus accepted realistic names (forge, jq, tool.v2, my-tool_2). Full forge-core + forge-cli suites green, lint clean.

Review follow-up: a bin-override name (forge.yaml bin_overrides.<name> /
--local-bin name=…) reaches three sinks verbatim — the .local-bins/
filesystem path (filepath.Join traversal), the Dockerfile COPY line, and
the .dockerignore re-include negation this PR adds (newline → arbitrary
pattern injection, glob → stash-wide re-include) — and nothing validated
its charset.

Add validate.ValidBinOverrideName (^[a-zA-Z0-9_.-]{1,64}$, plus an
explicit pure-dot rejection: "." and ".." pass the charset class but are
path navigation, ".." being exactly the traversal case) and enforce it in
both ValidateForgeConfig (package.bin_overrides) and parseLocalBins
(--local-bin), closing all three sinks at the source.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant