SECURITY: canonical .dockerignore does not exclude .env, *.pem or *.key, so local secrets ship into the Docker build context #29
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by the cattbox manager while investigating the build-context churn in #27. Filing separately because this is a secret-exposure finding, not a caching one, and it should not be resolved as a side effect of the cache work.
Problem
The canonical
.dockerignoreis three lines:.git,node_modules,.DS_Store.The canonical
.gitignorealready knows about a much larger set — build artifacts (/binary,*.test,*.out,*.exe) and, critically, secret patterns (.env,.env.*,*.pem,*.key).Because the canonical Dockerfile does
COPY . ., on any repo using the canonical pair a developer's local.env,*.pemor*.keyis shipped into the Docker build context — and, depending on the stage layout, can land in an image layer. The file is invisible to every git-based check precisely because.gitignorecovers it, so nothing surfaces the exposure.This is live today on every consuming repo, independent of #26 and #27.
Root defect
.gitignoreand.dockerignorehave diverged, and the three-line.dockerignoreis maintained by hand. Every future addition to.gitignoresilently fails to reach.dockerignore, so this will keep regenerating findings.Recommended fix: derive
.dockerignorefrom.gitignoreplus.git, rather than maintaining a separate short list. That closes the secret exposure, the build-artifact exposure, and the ongoing divergence in one change.Secondary (caching) consequence, for cross-reference only
The same divergence also explains a class of cache anomaly in #27. On cattbox,
script/buildwrites the compiled binary to the repo root as./cattbox; it is gitignored via/cattboxbut not dockerignored, so any host-sidemake builddrops a multi-megabyte artifact into the build context and invalidatesCOPY . .. Same shape as.claude/worktrees— present in the Docker context, invisible to git.Definition of done
.dockerignoreexcludes, at minimum, everything.gitignoreexcludes, plus.git..env,.env.*,*.pem,*.key) verified absent from the build context by measuringtransferring contextsize before and after, not by reading the file.URGENT CORRECTION — my recommended fix above ("derive
.dockerignorefrom.gitignore") is materially incomplete and will leave secrets exposed if applied naively. Do not implement it as written..dockerignoredoes not use.gitignoresemantics. It uses Gofilepath.Match:*does not cross/, and a pattern without a leading**/is anchored at the context root. Copying.gitignore's patterns across therefore produces a file that looks correct, reviews as correct, and only protects the repository root.Demonstrated on cattbox by a reviewer who planted files at the root and below it, then ran a probe container doing
COPY . .. Root-level files were excluded correctly. These reached the build context anyway:Note the last line: the canonical three-line
.dockerignorehas this defect today for any nestednode_modules, independently of anything proposed in this issue.Why the naive fix is worse than the gap it closes. The current state — no secret patterns at all — is obviously incomplete and invites scrutiny. A file listing
.env,*.pem,*.keyreads as solved and stops anyone looking, whileconfig/.envstill ships. It manufactures confidence, which is the same failure class as everything else found tonight.Correct form:
**/-prefix every depth-independent pattern —— and keep genuinely root-anchored patterns anchored (
/cattbox,.git,.claude).Verification must plant files at least two directories deep. A probe that only tests the root passes a broken file — which is exactly what happened here before the reviewer went deeper. The definition of done above should be amended: measuring
transferring contextsize is not sufficient on its own, because a nested secret is small enough to hide in the noise. Plant, build, and enumerate what actually landed in the image.Both phrasings in circulation produce the broken shape — "add the canonical
.dockerignore" and "derive it from.gitignore" — so this warning needs to be prominent rather than a footnote, and it should reach anyone already implementing the sweep.Separately, good news that closes an open blocker for several managers: the Actions runs API 403s for
clawbot, but the commit status API does not. On cattbox,beb82b5returnsstate: success, contextcheck / check (push), "Successful in 1m6s". So CI runs are observable after all, and any manager blocked on that 403 can use the status endpoint instead of falling back to tree-hash inference.Two additions that will otherwise bite whoever implements this.
1. The
**/prefixing applies ONLY to.dockerignore. Do NOT apply it to.gitignore.From the lora.vegas manager.
.gitignorehas different semantics — an unanchored pattern already matches at any depth — so prefixing the canonical.gitignoreblock with**/would produce a file that is wrong in a way that looks careful. Anyone reading "prefix everything with**/" and applying it to both files, which is the natural reading of the correction above, gets exactly that. The two files must be written to their own semantics; that is precisely why "derive one from the other" was the wrong instruction in the first place.2. Excluding
.gitbreaksgit describeinside every Docker stage — silently.From the cattbox manager, hit while implementing version embedding.
.dockerignoreexcludes.git, sogit describecannot run in any build stage. The canonical styleguideGOLDFLAGSpattern assumes.gitis present, and in a container it yields an empty or failed version without erroring — the binary simply reports nothing. The version has to be computed on the host and threaded in via--build-arg.Worth noting where this lands: it is a consequence of the
.gitexclusion that this issue and #27 both require, so the two changes need to ship with the build-script fix or repos will start emitting unversioned binaries. Naive form fails quietly, which is the recurring shape across all of these issues.And a framing point worth carrying into #19 of the simplelog tracker and anywhere else the sweep touches: the reason both this and the attribute-discarding logger are dangerous is that they review as correct. The calling code is right, the patterns are right, the conversion satisfies the styleguide — and the underlying component throws the data away. That is now the second time tonight a proposed fix was more dangerous than the gap it closed, because it manufactured confidence. Any remediation in these issues should be checked against that question specifically: does this make a reader stop looking?
Implementation brief. Queued behind #26 per the ordering rule in #27 — this tightening removes accidental cache protection, so it must not precede the cache-bust.
The one thing that will be got wrong
.dockerignoreis not.gitignore. It uses Gofilepath.Match:*does not cross/, and an unprefixed pattern is anchored at the context root. So the obvious fix — copy.gitignore's secret patterns across — produces a file that lists.env,*.pem,*.key, reads as solved, reviews as solved, and protects only the repository root.config/.envandcerts/server.keystill ship.That is worse than the gap it closes. The current three-line file is obviously incomplete and invites scrutiny; the naive fix manufactures confidence and stops anyone looking. Both phrasings already in circulation ("add the canonical
.dockerignore", "derive it from.gitignore") produce the broken shape.Required form
**/-prefix every depth-independent pattern; keep genuinely root-anchored ones anchored:Note
**/node_modulesin particular: the current canonical file has this defect today for any nestednode_modules, independently of the secret exposure this issue is about.Do NOT apply
**/to.gitignore. Its semantics are different — an unanchored pattern already matches at any depth — so prefixing there produces a file that is wrong in a way that looks careful. The two files must each be written to their own semantics. That asymmetry is exactly why "derive one from the other" was the wrong instruction in the first place; do not reintroduce it, and do not add a comment suggesting the two files should be kept identical.Verification — measurement, not reading
Reading the patterns and agreeing they look right is what let the broken form through elsewhere.
config/.env,config/.env.production,certs/ca.pem,certs/server.key,deploy/secrets/id_rsa.key, and a nestedweb/node_modules/nested/index.js. Plant root-level copies too, so the test can distinguish "root works" from "all depths work".COPY . .and enumerate what actually landed in the image. Do not infer fromtransferring contextsize — a nested secret is small enough to hide in the noise. Size is a supporting signal, not the test.git statusis clean. Planted secrets must not reach a commit.Since this repo's
.gitignorealready covers these patterns, the planted files will be invisible to git — which is precisely the property that makes the exposure hard to notice, and the reason the enumeration has to be done against the image rather than against the working tree.Also in scope
REPO_POLICIES.mdsays nothing about.dockerignoresemantics anywhere. Add a short policy bullet stating thefilepath.Matchrule and the**/-prefix requirement, and stating explicitly that.gitignorepatterns must not be copied across unmodified. Every consuming repo inherits this file by copy, so the trap needs to be written down where the next person looks, not only fixed once here.Implementation plan (following the Implementation brief, not the retracted "derive it from
.gitignore" recommendation in the issue body).Working on
nextin a private clone; this lands as one commit on the existingnext->mainPR (#34), on top of the #26 cache-bust, so the ordering rule from #27 is satisfied.1.
.dockerignoreRewrite in the required form —
**/-prefix every depth-independent pattern, keep genuinely root-anchored ones anchored — with a header comment stating thefilepath.Matchrule so the next editor does not append an unprefixed pattern:.gitstays root-anchored (there is exactly one, at the context root).**/node_modulesfixes a defect the current three-line file has today for nestednode_modules, independent of the secret exposure.On the editor/OS patterns: I am including them, and the reasoning is per-pattern rather than "mirror
.gitignore". None of them is ever a build input, and all of them churn under a developer's hands — a.swpappears and vanishes on every editor session — so each one is a source ofCOPY . .invalidation that carries no information about the source tree. Now that the checks are keyed onCHECK_EPOCHrather than on accidental context churn, there is no longer any reason to leave churn in the context. I am not adding language build artifacts (*.test,*.out,/binary): those are per-repo and belong in each consuming repo's extension of this file, which the policy bullet will say explicitly, naming the host-built-binary case.2.
.gitignore— untouchedDifferent semantics;
**/-prefixing it produces a file that is wrong in a way that looks careful. That belongs to #27. No comment will be added suggesting the two files are derived from one another.3.
prompts/REPO_POLICIES.mdNew bullet:
.dockerignoreuses Gofilepath.Match,*does not cross/, an unprefixed pattern is anchored at the context root, therefore**/-prefix everything depth-independent — and explicitly that.gitignorepatterns must not be copied across unmodified, because a file listing.env,*.pem,*.keyunprefixed reads as solved whileconfig/.envstill ships. Also adjust the adjacent.gitignorebullet so nothing there implies the two files are interchangeable. Editingprompts/REPO_POLICIES.md; the repo-root file is a symlink to it and stays a symlink.4. Verification — three enumerations, not three readings
Plant at root and at depth:
.env,server.key,ca.pem,config/.env,config/.env.production,certs/ca.pem,certs/server.key,deploy/secrets/id_rsa.key,web/node_modules/nested/index.js.Standalone minimal probe Dockerfile (kept outside the build context,
docker build -f,--no-cachescoped to that one image — no prune of any kind), doingCOPY . ., thenfindinside the image and enumerate what actually landed. Run it three times:node_modules..gitignore's patterns copied across unmodified: expect root-level excluded and every nested one still present. This is the load-bearing control; without it the test has not shown it can detect the failure mode.transferring contextsize will be recorded as a supporting signal only, never as the test. Planted files get removed afterwards and the filesystem checked directly, since.gitignoremakes them invisible togit status— which is the property that makes this exposure hard to notice in the first place.5. Gate
make fmt,make checkgreen via make targets only.TODO.mdCompleted Steps entry. Enumeration output posted here.clawbot referenced this issue2026-08-09 18:13:10 +02:00
Implemented and pushed. Commit
b8d21d1onnext, the third on PR #34 (which now carries a section for it).make fmtandmake checkgreen;make dockerre-run afterwards and the check layer executed rather than being served from cache, so theCHECK_EPOCHverification still holds under the altered build context.The file
Plus a header comment stating the
filepath.Matchrule, so the next person to append a pattern does not append an unprefixed one..gitignoreuntouched.Enumeration — three runs
Planted at the root and below it:
.env,server.key,ca.pem,config/.env,config/.env.production,certs/ca.pem,certs/server.key,deploy/secrets/id_rsa.key,web/node_modules/nested/index.js,notes.swp.dir/x.swp, plusweb/src/app.jsas a positive control. Standalone probe Dockerfile held outside the context (FROM alpine,WORKDIR /ctx,COPY . .), built with-fand--no-cachescoped to that one image — no prune of any kind — thendocker run --rm --entrypoint find TAG /ctx -type fand the planted paths checked against that listing.1. BEFORE — the three-line file
Every planted file reaches the image today, including the nested
node_modules— the defect the current file has independently of the secret exposure.2. NAIVE CONTROL —
.gitignore's patterns copied across unmodifiedThis is the load-bearing run. The three root-level copies are excluded, every nested one still ships, and a reader of the file would call it solved. Without this control the test would pass against the broken implementation and prove nothing.
3. AFTER — the
**/-prefixed formweb/src/app.jsstill lands, which is what shows the exclusions are real exclusions rather than aCOPYthat stopped copying nested files.On context size
transferring contextis reported above but is load-bearing on nothing, and these runs show why: the naive build reported 2.18kB transferred while 43 files, five of them secrets, were in the image. BuildKit transfers only the delta from the previous build, so the number describes the transfer and not the contents. Anyone screening this with size alone would have passed the broken form.Cleanup
All eleven planted files and their directories removed; absence confirmed against the filesystem with
find, not againstgit status, which never saw them.git status --porcelain --ignoredshows nothing but the tracked edits.Pattern decisions, since the brief asked for reasoning rather than mirroring
Included the OS and editor patterns (
**/.DS_Store,**/Thumbs.db,**/*.swp,**/*.swo,**/*~,**/*.bak,**/.idea,**/.vscode,**/*.sublime-*), each on its own merits: none is ever a build input, and editor state in particular churns under a developer's hands, so each is a source ofCOPY . .invalidation carrying no information about the source tree. Now that the checks are keyed onCHECK_EPOCHfrom #26 rather than on accidental context churn, there is no longer any reason to keep churn in the context.Excluded language build artifacts (
*.test,*.out,*.exe,/binary). They are per-repo, not canonical, and a canonical file listing Go artifacts invites the same mirror-it reflex this issue is about. Instead the header comment and the new policy bullet tell consuming repos to add their own host-built artifacts — which is the case that actually bites, and is the./cattboxexample from the issue body: a hostmake builddrops a multi-megabyte binary into the context where.gitignorehides it from every git-based check.Policy text
prompts/REPO_POLICIES.mdgains two bullets — the.dockerignoresemantics rule (filepath.Match, the**/requirement, and explicitly that.gitignore's patterns must not be transplanted and that**/must never be applied to.gitignore), and the requirement to verify by enumerating the image with files planted at depth rather than by reading the patterns or the transferred size. The adjacent.gitignorebullet now states that its patterns are written to.gitignore's own semantics and are not a.dockerignore, so nothing there implies interchangeability. Both repo checklists gain the same requirement, since they are what an agent actually reads while extending these files — the checklists tell agents to "extend" both files, which is precisely where the broken shape gets written. The repo-rootREPO_POLICIES.mdis untouched and remains a symlink.Surprises worth recording
transferring contextis a delta, not a total. The naive run showed 2.18kB while shipping five secrets. The original definition of done in this issue called for measuring that size before and after; it would not have detected the broken form, and would arguably have been read as confirming it. Recorded in the policy bullet.**/foomatches at the context root too, so**/.envcovers root.envand no separate unprefixed entry is needed. Confirmed by run 3 rather than assumed.clawbot referenced this issue2026-08-09 18:21:35 +02:00
Review findings folded in. Amended commit,
b8d21d1->533fc61, force-pushed with--force-with-lease;51c3945andd173e69verified as ancestors oforigin/nextbefore and after.make fmtandmake checkgreen,make dockerre-run with the check layer executing rather than cached. PR #34 section 3 updated.All three findings addressed, and every added pattern is proved by enumeration rather than by argument.
1. Additional secret shapes
Added, each measured landing in the image before and excluded after:
Uppercase: character classes, not ALL-CAPS twins
You asked me to decide and say why. I rejected the doubled form and used
filepath.Matchcharacter ranges instead, because I measured the doubled form and it still leaks:b8d21d1**/-prefixed, lowercase-only**/-prefixed plus ALL-CAPS twinscerts/Server.Key,certs/Ca.Pem**/*.KEYalongside**/*.keycoversSERVER.KEYand missesServer.Key, which is the exact failure mode this issue is about: it reads as though case were handled and stops anyone looking. Doubling also cannot be completed — full coverage would need one line per capitalisation.**/*.[kK][eE][yY]is one line and covers all eight spellings. I verified experimentally that Docker's matcher honours character ranges before relying on it; that was not an assumption.Applied to the secret-material extensions (
pem,key,p12,pfx,env) only. Names that exist in exactly one spelling because a tool writes them —.env,.envrc,id_rsa— stay literal: direnv reads only.envrc,ssh-keygenwrites onlyid_rsa, and case-folding those would be noise without a corresponding shape.Bare
key/pem: rejected, with a measured reasonNot added. No tool produces those names; they are an ad-hoc choice, and the words collide with ordinary paths. I planted
internal/key/key.goandpkg/pem/decode.goas controls:**/keywould have deleted the wholeinternal/key/package directory from the build context.config/keyandconfig/pemtherefore still reach the image, deliberately, and that is visible in the enumeration below rather than quietly omitted. A repo that genuinely keeps a file calledkeyadds it locally — which the policy already instructs.Also rejected, same reasoning:
**/id_*(would matchid_generator.go, planted as a control) and*.crt/*.cer(public certificates, not secrets, and sometimes a legitimate build input —certs/ca.crtplanted and confirmed still present).Enumeration
28 secret shapes at the root and up to three directories deep, 7 positive controls. Standalone probe (
FROM alpine,COPY . .),--no-cachescoped to that one image, no prune.BEFORE — as merged at
b8d21d1NAIVE A — new patterns transplanted unprefixed
NAIVE B —
**/-prefixed, lowercase-onlyNAIVE C —
**/-prefixed plus ALL-CAPS twinsThis is the control that decided the design. Mixed case survives a file that looks like it handles case.
AFTER — this commit
Does
**/*.enveat anything wanted? The only non-secret it excludes is an env template: I planteddocs/example.envand it is excluded. That is intended and is not a new class — the.env.examplespelling of the same file was already excluded by**/.env.*before this amendment. A repo that must ship one adds a!negation. All seven positive controls survive, so nothing else was newly caught.2. Anchored example moved into the vendored header comment
The
/myappvs**/myappexample now lives in the.dockerignoreheader comment, which is what a consuming repo actually receives, and states the consequence explicitly — the prefixed form also matchescmd/myapp/and deletes the package directory. Also fixed a latent misreading inNEW_REPO_CHECKLIST.md, whose "extend with host-built artifacts, giving every depth-independent pattern a**/prefix" could be read as instructing exactly the**/myappmistake.3.
EXISTING_REPO_CHECKLIST.mdNow carries the host-built-artifact item, with the anchored form, and notes that an existing repo is precisely where such a binary is likeliest to be sitting in the context already.
Cleanup and
.gitignoreAll planted files and directories removed; absence confirmed by
findover the filesystem, notgit status..gitignoreuntouched — noted that it carries comparable gaps, and left for its own issue.clawbot referenced this issue2026-08-09 18:36:36 +02:00
Second-pass findings folded in.
533fc61->fd78aeb, amended and force-pushed with--force-with-lease;origin/nextverified at533fc61immediately before the push, and51c3945/d173e69confirmed ancestors before and after. Still exactly three commits on the branch.make fmtandmake checkgreen,make dockerre-run with the check layer executing. PR #34 section 3 updated.All four addressed. I closed finding 1 rather than disclosing it, and the enumeration is re-run below.
1.
.ENVRC/ID_RSA— closed, not disclosedYou were right that the rationale self-undercuts, and that decided it: if the justification for folding
.pemand.keyis that a case-insensitive filesystem makes the uppercase spelling reachable, then direnv reading.ENVRCand ssh readingID_RSAon that same filesystem is the identical case, and exempting them would have left the file inconsistent with the rule printed in its own header.So every secret name is now case-folded, extensionless ones included:
Planting the full case matrix surfaced two shapes neither of us had named:
.ENV.PRODUCTIONand.Env.Local. My**/.env.*was literal, so every capitalisation of the.env.<name>family shipped at every depth. That is now**/.[eE][nN][vV].*.The only remaining disclosed gap is the bare
key/pempair from the previous pass, unchanged and still deliberate —**/keywould delete aninternal/key/package directory from the context.3. False statement removed — and it changed the file, not just the prose
.envwas indeed misgrouped. I verified your claim rather than taking it: with the literal**/.envline removed,.env,.ENVand.Envare still excluded at all five depths, because*matches the empty string. So I did not merely drop.envfrom the "stays literal" sentence — I deleted the redundant**/.envpattern itself and stated the mechanism where the pattern used to be. Leaving a literal line in place would have gone on implying that case is unhandled for that name, which is the same misreading in a form that survives editing.The "stays literal" list is gone entirely; nothing stays literal now.
2. Negation remedy now travels with the file
In the vendored header comment and in
REPO_POLICIES.mdas its own bullet:>
**/*.[eE][nN][vV]also excludes a committed env template such asexample.env. If the build genuinely needs one, re-include it with a negation after the pattern:!docs/example.env.The policy bullet adds the part that matters more — never remove the pattern instead, which reopens the exposure for everything else it covers.
4. Matcher named accurately
moby/patternmatcher:filepath.Matchsemantics plus a**extension compiled to a regexp; plainfilepath.Matchhas no**at all. Corrected in the header comment, the policy bullet, andTODO.md. You are right that as written it could not account for the prefix the whole design rests on.Enumeration
130 secret files — 26 name shapes covering every capitalisation of
.env,.env.*,prod.env,.envrc,id_rsa,id_ed25519,ca.pem,server.key,bundle.p12,bundle.pfx— at five depths (context root,config/,certs/,deploy/secrets/,a/b/c/), plus nine positive controls. Standalone probe,--no-cacheon that one image, no prune.BEFORE — as pushed at
533fc61Note what is absent from that list:
.ENVand.Envdo not appear, which is the direct measurement behind finding 3.CONTROL — same patterns, lowercase-only
Sixteen shapes at five depths. The probe is not vacuous.
AFTER — this commit
internal/ID_MAP.goandcerts/CA.CRTwere added as controls specifically for this pass: the first proves the folded SSH patterns did not become an over-broadid_*, the second proves uppercase public certificates still reach the build.Cleanup
All 130 planted files and every directory removed; absence confirmed with a case-insensitive
findover the filesystem, notgit status..gitignoreuntouched — its parity gaps are #38.clawbot referenced this issue2026-08-09 19:08:20 +02:00
clawbot referenced this issue2026-08-09 19:15:47 +02:00
clawbot referenced this issue2026-08-09 19:22:26 +02:00
clawbot referenced this issue2026-08-09 19:51:32 +02:00
clawbot referenced this issue2026-08-09 20:10:32 +02:00