SECURITY: canonical .dockerignore does not exclude .env, *.pem or *.key, so local secrets ship into the Docker build context #29

Open
opened 2026-08-09 07:51:11 +02:00 by clawbot · 7 comments
Collaborator

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 .dockerignore is three lines: .git, node_modules, .DS_Store.

The canonical .gitignore already 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, *.pem or *.key is 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 .gitignore covers it, so nothing surfaces the exposure.

This is live today on every consuming repo, independent of #26 and #27.

Root defect

.gitignore and .dockerignore have diverged, and the three-line .dockerignore is maintained by hand. Every future addition to .gitignore silently fails to reach .dockerignore, so this will keep regenerating findings.

Recommended fix: derive .dockerignore from .gitignore plus .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/build writes the compiled binary to the repo root as ./cattbox; it is gitignored via /cattbox but not dockerignored, so any host-side make build drops a multi-megabyte artifact into the build context and invalidates COPY . .. Same shape as .claude/worktrees — present in the Docker context, invisible to git.

Definition of done

  • Canonical .dockerignore excludes, at minimum, everything .gitignore excludes, plus .git.
  • Secret patterns (.env, .env.*, *.pem, *.key) verified absent from the build context by measuring transferring context size before and after, not by reading the file.
  • A follow-up issue exists to propagate to consuming repos, sequenced per #27's ordering rule (the tightening changes cache behavior, so it must not land before that repo's #26 counterpart).
  • Consuming repos should check whether any existing image layer already contains such a file, rather than assuming the exposure is only prospective.
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 `.dockerignore` is three lines: `.git`, `node_modules`, `.DS_Store`. The canonical `.gitignore` already 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`, `*.pem` or `*.key` is 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 `.gitignore` covers it, so nothing surfaces the exposure. This is live today on every consuming repo, independent of #26 and #27. ## Root defect `.gitignore` and `.dockerignore` have diverged, and the three-line `.dockerignore` is maintained by hand. Every future addition to `.gitignore` silently fails to reach `.dockerignore`, so this will keep regenerating findings. Recommended fix: **derive `.dockerignore` from `.gitignore` plus `.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/build` writes the compiled binary to the repo root as `./cattbox`; it is gitignored via `/cattbox` but **not** dockerignored, so any host-side `make build` drops a multi-megabyte artifact into the build context and invalidates `COPY . .`. Same shape as `.claude/worktrees` — present in the Docker context, invisible to git. ## Definition of done - Canonical `.dockerignore` excludes, at minimum, everything `.gitignore` excludes, plus `.git`. - Secret patterns (`.env`, `.env.*`, `*.pem`, `*.key`) verified absent from the build context by measuring `transferring context` size before and after, not by reading the file. - A follow-up issue exists to propagate to consuming repos, sequenced per #27's ordering rule (the tightening changes cache behavior, so it must not land before that repo's #26 counterpart). - Consuming repos should check whether any existing image layer already contains such a file, rather than assuming the exposure is only prospective.
Author
Collaborator

URGENT CORRECTION — my recommended fix above ("derive .dockerignore from .gitignore") is materially incomplete and will leave secrets exposed if applied naively. Do not implement it as written.

.dockerignore does not use .gitignore semantics. It uses Go filepath.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:

./config/.env              ./certs/server.key       ./deploy/secrets/id_rsa.key
./config/.env.production   ./certs/ca.pem           ./cmd/cattbox/cattbox.test
./web/node_modules/nestedpkg/index.js

Note the last line: the canonical three-line .dockerignore has this defect today for any nested node_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, *.key reads as solved and stops anyone looking, while config/.env still ships. It manufactures confidence, which is the same failure class as everything else found tonight.

Correct form: **/-prefix every depth-independent pattern —

**/.env
**/.env.*
**/*.pem
**/*.key
**/*.test
**/*.out
**/node_modules

— 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 context size 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, beb82b5 returns state: success, context check / 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.

**URGENT CORRECTION — my recommended fix above ("derive `.dockerignore` from `.gitignore`") is materially incomplete and will leave secrets exposed if applied naively. Do not implement it as written.** **`.dockerignore` does not use `.gitignore` semantics.** It uses Go `filepath.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: ``` ./config/.env ./certs/server.key ./deploy/secrets/id_rsa.key ./config/.env.production ./certs/ca.pem ./cmd/cattbox/cattbox.test ./web/node_modules/nestedpkg/index.js ``` Note the last line: **the canonical three-line `.dockerignore` has this defect today** for any nested `node_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`, `*.key` reads as solved and stops anyone looking, while `config/.env` still ships. It manufactures confidence, which is the same failure class as everything else found tonight. **Correct form:** `**/`-prefix every depth-independent pattern — ``` **/.env **/.env.* **/*.pem **/*.key **/*.test **/*.out **/node_modules ``` — 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 context` size 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, `beb82b5` returns `state: success`, context `check / 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.
Author
Collaborator

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. .gitignore has different semantics — an unanchored pattern already matches at any depth — so prefixing the canonical .gitignore block 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 .git breaks git describe inside every Docker stage — silently.

From the cattbox manager, hit while implementing version embedding. .dockerignore excludes .git, so git describe cannot run in any build stage. The canonical styleguide GOLDFLAGS pattern assumes .git is 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 .git exclusion 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?

**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. `.gitignore` has different semantics — an unanchored pattern already matches at any depth — so prefixing the canonical `.gitignore` block 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 `.git` breaks `git describe` inside every Docker stage — silently.** From the cattbox manager, hit while implementing version embedding. `.dockerignore` excludes `.git`, so `git describe` cannot run in any build stage. The canonical styleguide `GOLDFLAGS` pattern assumes `.git` is 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 `.git` exclusion 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?
Author
Collaborator

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

.dockerignore is not .gitignore. It uses Go filepath.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/.env and certs/server.key still 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:

.git
**/.env
**/.env.*
**/*.pem
**/*.key
**/node_modules
**/.DS_Store

Note **/node_modules in particular: the current canonical file has this defect today for any nested node_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.

  • Plant files at at least two directories deep: config/.env, config/.env.production, certs/ca.pem, certs/server.key, deploy/secrets/id_rsa.key, and a nested web/node_modules/nested/index.js. Plant root-level copies too, so the test can distinguish "root works" from "all depths work".
  • Build a probe image doing COPY . . and enumerate what actually landed in the image. Do not infer from transferring context size — a nested secret is small enough to hide in the noise. Size is a supporting signal, not the test.
  • Run the same enumeration before the fix, so the planted files are demonstrably reaching the image today. A test that passes both before and after has proved nothing.
  • Remove every planted file afterwards and confirm git status is clean. Planted secrets must not reach a commit.

Since this repo's .gitignore already 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.md says nothing about .dockerignore semantics anywhere. Add a short policy bullet stating the filepath.Match rule and the **/-prefix requirement, and stating explicitly that .gitignore patterns 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 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 `.dockerignore` is **not** `.gitignore`. It uses Go `filepath.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/.env` and `certs/server.key` still 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: ``` .git **/.env **/.env.* **/*.pem **/*.key **/node_modules **/.DS_Store ``` Note `**/node_modules` in particular: the **current** canonical file has this defect today for any nested `node_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. - Plant files at **at least two directories deep**: `config/.env`, `config/.env.production`, `certs/ca.pem`, `certs/server.key`, `deploy/secrets/id_rsa.key`, and a nested `web/node_modules/nested/index.js`. Plant root-level copies too, so the test can distinguish "root works" from "all depths work". - Build a probe image doing `COPY . .` and **enumerate what actually landed in the image**. Do not infer from `transferring context` size — a nested secret is small enough to hide in the noise. Size is a supporting signal, not the test. - Run the same enumeration **before** the fix, so the planted files are demonstrably reaching the image today. A test that passes both before and after has proved nothing. - Remove every planted file afterwards and confirm `git status` is clean. Planted secrets must not reach a commit. Since this repo's `.gitignore` already 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.md` says nothing about `.dockerignore` semantics anywhere. Add a short policy bullet stating the `filepath.Match` rule and the `**/`-prefix requirement, and stating explicitly that `.gitignore` patterns 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.
Author
Collaborator

Implementation plan (following the Implementation brief, not the retracted "derive it from .gitignore" recommendation in the issue body).

Working on next in a private clone; this lands as one commit on the existing next -> main PR (#34), on top of the #26 cache-bust, so the ordering rule from #27 is satisfied.

1. .dockerignore

Rewrite in the required form — **/-prefix every depth-independent pattern, keep genuinely root-anchored ones anchored — with a header comment stating the filepath.Match rule so the next editor does not append an unprefixed pattern:

.git

**/.env
**/.env.*
**/*.pem
**/*.key

**/node_modules

**/.DS_Store
**/Thumbs.db

**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*

.git stays root-anchored (there is exactly one, at the context root). **/node_modules fixes a defect the current three-line file has today for nested node_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 .swp appears and vanishes on every editor session — so each one is a source of COPY . . invalidation that carries no information about the source tree. Now that the checks are keyed on CHECK_EPOCH rather 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 — untouched

Different 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.md

New bullet: .dockerignore uses Go filepath.Match, * does not cross /, an unprefixed pattern is anchored at the context root, therefore **/-prefix everything depth-independent — and explicitly that .gitignore patterns must not be copied across unmodified, because a file listing .env, *.pem, *.key unprefixed reads as solved while config/.env still ships. Also adjust the adjacent .gitignore bullet so nothing there implies the two files are interchangeable. Editing prompts/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-cache scoped to that one image — no prune of any kind), doing COPY . ., then find inside the image and enumerate what actually landed. Run it three times:

  1. before — current three-line file: expect every planted file present, including nested node_modules.
  2. naive.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.
  3. after — the form above: expect all planted files absent at every depth.

transferring context size will be recorded as a supporting signal only, never as the test. Planted files get removed afterwards and the filesystem checked directly, since .gitignore makes them invisible to git status — which is the property that makes this exposure hard to notice in the first place.

5. Gate

make fmt, make check green via make targets only. TODO.md Completed Steps entry. Enumeration output posted here.

**Implementation plan** (following the Implementation brief, not the retracted "derive it from `.gitignore`" recommendation in the issue body). Working on `next` in a private clone; this lands as one commit on the existing `next` -> `main` PR ([#34](https://git.eeqj.de/sneak/prompts/pulls/34)), on top of the [#26](https://git.eeqj.de/sneak/prompts/issues/26) cache-bust, so the ordering rule from [#27](https://git.eeqj.de/sneak/prompts/issues/27) is satisfied. ## 1. `.dockerignore` Rewrite in the required form — `**/`-prefix every depth-independent pattern, keep genuinely root-anchored ones anchored — with a header comment stating the `filepath.Match` rule so the next editor does not append an unprefixed pattern: ``` .git **/.env **/.env.* **/*.pem **/*.key **/node_modules **/.DS_Store **/Thumbs.db **/*.swp **/*.swo **/*~ **/*.bak **/.idea **/.vscode **/*.sublime-* ``` `.git` stays root-anchored (there is exactly one, at the context root). `**/node_modules` fixes a defect the current three-line file has today for nested `node_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 `.swp` appears and vanishes on every editor session — so each one is a source of `COPY . .` invalidation that carries no information about the source tree. Now that the checks are keyed on `CHECK_EPOCH` rather 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` — untouched Different semantics; `**/`-prefixing it produces a file that is wrong in a way that looks careful. That belongs to [#27](https://git.eeqj.de/sneak/prompts/issues/27). No comment will be added suggesting the two files are derived from one another. ## 3. `prompts/REPO_POLICIES.md` New bullet: `.dockerignore` uses Go `filepath.Match`, `*` does not cross `/`, an unprefixed pattern is anchored at the context root, therefore `**/`-prefix everything depth-independent — and explicitly that `.gitignore` patterns must **not** be copied across unmodified, because a file listing `.env`, `*.pem`, `*.key` unprefixed reads as solved while `config/.env` still ships. Also adjust the adjacent `.gitignore` bullet so nothing there implies the two files are interchangeable. Editing `prompts/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-cache` scoped to that one image — no prune of any kind), doing `COPY . .`, then `find` inside the image and enumerate what actually landed. Run it three times: 1. **before** — current three-line file: expect every planted file present, including nested `node_modules`. 2. **naive** — `.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. 3. **after** — the form above: expect all planted files absent at every depth. `transferring context` size will be recorded as a supporting signal only, never as the test. Planted files get removed afterwards and the filesystem checked directly, since `.gitignore` makes them invisible to `git status` — which is the property that makes this exposure hard to notice in the first place. ## 5. Gate `make fmt`, `make check` green via make targets only. `TODO.md` Completed Steps entry. Enumeration output posted here.
Author
Collaborator

Implemented and pushed. Commit b8d21d1 on next, the third on PR #34 (which now carries a section for it). make fmt and make check green; make docker re-run afterwards and the check layer executed rather than being served from cache, so the CHECK_EPOCH verification still holds under the altered build context.

The file

.git

**/.env
**/.env.*
**/*.pem
**/*.key

**/node_modules

**/.DS_Store
**/Thumbs.db

**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*

Plus a header comment stating the filepath.Match rule, so the next person to append a pattern does not append an unprefixed one. .gitignore untouched.

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, plus web/src/app.js as a positive control. Standalone probe Dockerfile held outside the context (FROM alpine, WORKDIR /ctx, COPY . .), built with -f and --no-cache scoped to that one image — no prune of any kind — then docker run --rm --entrypoint find TAG /ctx -type f and the planted paths checked against that listing.

1. BEFORE — the three-line file

--- .dockerignore in effect ---
.git
node_modules
.DS_Store
--- build ---
#4 transferring context: 152.69kB done
--- planted files: did they land in the image? ---
LANDED IN IMAGE  .env
LANDED IN IMAGE  server.key
LANDED IN IMAGE  ca.pem
LANDED IN IMAGE  config/.env
LANDED IN IMAGE  config/.env.production
LANDED IN IMAGE  certs/ca.pem
LANDED IN IMAGE  certs/server.key
LANDED IN IMAGE  deploy/secrets/id_rsa.key
LANDED IN IMAGE  web/node_modules/nested/index.js
LANDED IN IMAGE  web/src/app.js
LANDED IN IMAGE  notes.swp.dir/x.swp
excluded         .git/
--- total files in image /ctx ---
46

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 unmodified

--- .dockerignore in effect ---
.git
.DS_Store
Thumbs.db
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
node_modules/
.env
.env.*
*.pem
*.key
--- build ---
#4 transferring context: 2.18kB done
--- planted files: did they land in the image? ---
excluded         .env
excluded         server.key
excluded         ca.pem
LANDED IN IMAGE  config/.env
LANDED IN IMAGE  config/.env.production
LANDED IN IMAGE  certs/ca.pem
LANDED IN IMAGE  certs/server.key
LANDED IN IMAGE  deploy/secrets/id_rsa.key
LANDED IN IMAGE  web/node_modules/nested/index.js
LANDED IN IMAGE  web/src/app.js
LANDED IN IMAGE  notes.swp.dir/x.swp
excluded         .git/
--- total files in image /ctx ---
43

This 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 form

--- planted files: did they land in the image? ---
excluded         .env
excluded         server.key
excluded         ca.pem
excluded         config/.env
excluded         config/.env.production
excluded         certs/ca.pem
excluded         certs/server.key
excluded         deploy/secrets/id_rsa.key
excluded         web/node_modules/nested/index.js
LANDED IN IMAGE  web/src/app.js
excluded         notes.swp.dir/x.swp
excluded         .git/
--- total files in image /ctx ---
36

web/src/app.js still lands, which is what shows the exclusions are real exclusions rather than a COPY that stopped copying nested files.

On context size

transferring context is 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 against git status, which never saw them. git status --porcelain --ignored shows 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 of COPY . . invalidation carrying no information about the source tree. Now that the checks are keyed on CHECK_EPOCH from #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 ./cattbox example from the issue body: a host make build drops a multi-megabyte binary into the context where .gitignore hides it from every git-based check.

Policy text

prompts/REPO_POLICIES.md gains two bullets — the .dockerignore semantics 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 .gitignore bullet 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-root REPO_POLICIES.md is untouched and remains a symlink.

Surprises worth recording

  • BuildKit's transferring context is 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.
  • **/foo matches at the context root too, so **/.env covers root .env and no separate unprefixed entry is needed. Confirmed by run 3 rather than assumed.
**Implemented and pushed.** Commit `b8d21d1` on `next`, the third on [PR #34](https://git.eeqj.de/sneak/prompts/pulls/34) (which now carries a section for it). `make fmt` and `make check` green; `make docker` re-run afterwards and the check layer executed rather than being served from cache, so the `CHECK_EPOCH` verification still holds under the altered build context. ## The file ``` .git **/.env **/.env.* **/*.pem **/*.key **/node_modules **/.DS_Store **/Thumbs.db **/*.swp **/*.swo **/*~ **/*.bak **/.idea **/.vscode **/*.sublime-* ``` Plus a header comment stating the `filepath.Match` rule, so the next person to append a pattern does not append an unprefixed one. `.gitignore` untouched. ## 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`, plus `web/src/app.js` as a positive control. Standalone probe Dockerfile held outside the context (`FROM alpine`, `WORKDIR /ctx`, `COPY . .`), built with `-f` and `--no-cache` scoped to that one image — no prune of any kind — then `docker run --rm --entrypoint find TAG /ctx -type f` and the planted paths checked against that listing. ### 1. BEFORE — the three-line file ``` --- .dockerignore in effect --- .git node_modules .DS_Store --- build --- #4 transferring context: 152.69kB done --- planted files: did they land in the image? --- LANDED IN IMAGE .env LANDED IN IMAGE server.key LANDED IN IMAGE ca.pem LANDED IN IMAGE config/.env LANDED IN IMAGE config/.env.production LANDED IN IMAGE certs/ca.pem LANDED IN IMAGE certs/server.key LANDED IN IMAGE deploy/secrets/id_rsa.key LANDED IN IMAGE web/node_modules/nested/index.js LANDED IN IMAGE web/src/app.js LANDED IN IMAGE notes.swp.dir/x.swp excluded .git/ --- total files in image /ctx --- 46 ``` 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 unmodified ``` --- .dockerignore in effect --- .git .DS_Store Thumbs.db *.swp *.swo *~ *.bak .idea/ .vscode/ *.sublime-* node_modules/ .env .env.* *.pem *.key --- build --- #4 transferring context: 2.18kB done --- planted files: did they land in the image? --- excluded .env excluded server.key excluded ca.pem LANDED IN IMAGE config/.env LANDED IN IMAGE config/.env.production LANDED IN IMAGE certs/ca.pem LANDED IN IMAGE certs/server.key LANDED IN IMAGE deploy/secrets/id_rsa.key LANDED IN IMAGE web/node_modules/nested/index.js LANDED IN IMAGE web/src/app.js LANDED IN IMAGE notes.swp.dir/x.swp excluded .git/ --- total files in image /ctx --- 43 ``` This 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 form ``` --- planted files: did they land in the image? --- excluded .env excluded server.key excluded ca.pem excluded config/.env excluded config/.env.production excluded certs/ca.pem excluded certs/server.key excluded deploy/secrets/id_rsa.key excluded web/node_modules/nested/index.js LANDED IN IMAGE web/src/app.js excluded notes.swp.dir/x.swp excluded .git/ --- total files in image /ctx --- 36 ``` `web/src/app.js` still lands, which is what shows the exclusions are real exclusions rather than a `COPY` that stopped copying nested files. ### On context size `transferring context` is 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 against `git status`, which never saw them. `git status --porcelain --ignored` shows 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 of `COPY . .` invalidation carrying no information about the source tree. Now that the checks are keyed on `CHECK_EPOCH` from [#26](https://git.eeqj.de/sneak/prompts/issues/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 `./cattbox` example from the issue body: a host `make build` drops a multi-megabyte binary into the context where `.gitignore` hides it from every git-based check. ## Policy text `prompts/REPO_POLICIES.md` gains two bullets — the `.dockerignore` semantics 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 `.gitignore` bullet 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-root `REPO_POLICIES.md` is untouched and remains a symlink. ## Surprises worth recording - **BuildKit's `transferring context` is 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. - **`**/foo` matches at the context root too**, so `**/.env` covers root `.env` and no separate unprefixed entry is needed. Confirmed by run 3 rather than assumed.
Author
Collaborator

Review findings folded in. Amended commit, b8d21d1 -> 533fc61, force-pushed with --force-with-lease; 51c3945 and d173e69 verified as ancestors of origin/next before and after. make fmt and make check green, make docker re-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:

**/*.[eE][nN][vV]      prod.env / local.env convention
**/.envrc              direnv
**/*.[pP]12            PKCS#12 bundle
**/*.[pP][fF][xX]      PFX bundle
**/id_rsa **/id_dsa **/id_ecdsa **/id_ed25519

Uppercase: character classes, not ALL-CAPS twins

You asked me to decide and say why. I rejected the doubled form and used filepath.Match character ranges instead, because I measured the doubled form and it still leaks:

form under test secrets in image (of 28 planted)
as merged at b8d21d1 19
new patterns transplanted unprefixed 21
**/-prefixed, lowercase-only 6
**/-prefixed plus ALL-CAPS twins 2certs/Server.Key, certs/Ca.Pem
character classes (this commit) 0

**/*.KEY alongside **/*.key covers SERVER.KEY and misses Server.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-keygen writes only id_rsa, and case-folding those would be noise without a corresponding shape.

Bare key / pem: rejected, with a measured reason

Not added. No tool produces those names; they are an ad-hoc choice, and the words collide with ordinary paths. I planted internal/key/key.go and pkg/pem/decode.go as controls: **/key would have deleted the whole internal/key/ package directory from the build context. config/key and config/pem therefore still reach the image, deliberately, and that is visible in the enumeration below rather than quietly omitted. A repo that genuinely keeps a file called key adds it locally — which the policy already instructs.

Also rejected, same reasoning: **/id_* (would match id_generator.go, planted as a control) and *.crt / *.cer (public certificates, not secrets, and sometimes a legitimate build input — certs/ca.crt planted 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-cache scoped to that one image, no prune.

BEFORE — as merged at b8d21d1

LANDED    .envrc
LANDED    prod.env
LANDED    id_rsa
LANDED    bundle.p12
LANDED    CA.PEM
LANDED    SERVER.KEY
LANDED    config/prod.env
LANDED    config/local.env
LANDED    certs/CA.PEM
LANDED    certs/SERVER.KEY
LANDED    certs/Server.Key
LANDED    certs/Ca.Pem
LANDED    certs/bundle.p12
LANDED    certs/bundle.pfx
LANDED    deploy/secrets/id_rsa
LANDED    deploy/secrets/id_ed25519
LANDED    deploy/secrets/.envrc
LANDED    config/id_dsa
LANDED    config/id_ecdsa
excluded  .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
--- leaked / total files ---
19        64

NAIVE A — new patterns transplanted unprefixed

LANDED    CA.PEM, SERVER.KEY, config/.env, config/.env.production,
          config/prod.env, config/local.env, certs/ca.pem, certs/server.key,
          certs/CA.PEM, certs/SERVER.KEY, certs/Server.Key, certs/Ca.Pem,
          certs/bundle.p12, certs/bundle.pfx, deploy/secrets/id_rsa.key,
          deploy/secrets/id_rsa, deploy/secrets/id_ed25519,
          deploy/secrets/.envrc, config/id_dsa, config/id_ecdsa,
          web/node_modules/nested/index.js
--- leaked / total files ---
21        66

NAIVE B — **/-prefixed, lowercase-only

LANDED    CA.PEM
LANDED    SERVER.KEY
LANDED    certs/CA.PEM
LANDED    certs/SERVER.KEY
LANDED    certs/Server.Key
LANDED    certs/Ca.Pem
--- leaked / total files ---
6         50

NAIVE C — **/-prefixed plus ALL-CAPS twins

LANDED    certs/Server.Key
LANDED    certs/Ca.Pem
--- leaked / total files ---
2         46

This is the control that decided the design. Mixed case survives a file that looks like it handles case.

AFTER — this commit

excluded  .env              excluded  certs/ca.pem
excluded  server.key        excluded  certs/server.key
excluded  ca.pem            excluded  certs/CA.PEM
excluded  .envrc            excluded  certs/SERVER.KEY
excluded  prod.env          excluded  certs/Server.Key
excluded  id_rsa            excluded  certs/Ca.Pem
excluded  bundle.p12        excluded  certs/bundle.p12
excluded  CA.PEM            excluded  certs/bundle.pfx
excluded  SERVER.KEY        excluded  deploy/secrets/id_rsa.key
excluded  config/.env       excluded  deploy/secrets/id_rsa
excluded  config/.env.production   excluded  deploy/secrets/id_ed25519
excluded  config/prod.env   excluded  deploy/secrets/.envrc
excluded  config/local.env  excluded  config/id_dsa
excluded  web/node_modules/nested/index.js   excluded  config/id_ecdsa
--- deliberately not covered (see reasoning) ---
LANDED    config/key
LANDED    config/pem
--- positive controls: must all be PRESENT ---
present   web/src/app.js
present   internal/key/key.go
present   pkg/pem/decode.go
present   internal/id_generator.go
present   docs/environments.md
present   cmd/probe/main.go
present   certs/ca.crt
--- leaked / total files ---
0         44

Does **/*.env eat anything wanted? The only non-secret it excludes is an env template: I planted docs/example.env and it is excluded. That is intended and is not a new class — the .env.example spelling 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 /myapp vs **/myapp example now lives in the .dockerignore header comment, which is what a consuming repo actually receives, and states the consequence explicitly — the prefixed form also matches cmd/myapp/ and deletes the package directory. Also fixed a latent misreading in NEW_REPO_CHECKLIST.md, whose "extend with host-built artifacts, giving every depth-independent pattern a **/ prefix" could be read as instructing exactly the **/myapp mistake.

3. EXISTING_REPO_CHECKLIST.md

Now 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 .gitignore

All planted files and directories removed; absence confirmed by find over the filesystem, not git status. .gitignore untouched — noted that it carries comparable gaps, and left for its own issue.

**Review findings folded in.** Amended commit, `b8d21d1` -> `533fc61`, force-pushed with `--force-with-lease`; `51c3945` and `d173e69` verified as ancestors of `origin/next` before and after. `make fmt` and `make check` green, `make docker` re-run with the check layer executing rather than cached. [PR #34](https://git.eeqj.de/sneak/prompts/pulls/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: ``` **/*.[eE][nN][vV] prod.env / local.env convention **/.envrc direnv **/*.[pP]12 PKCS#12 bundle **/*.[pP][fF][xX] PFX bundle **/id_rsa **/id_dsa **/id_ecdsa **/id_ed25519 ``` ### Uppercase: character classes, not ALL-CAPS twins You asked me to decide and say why. I rejected the doubled form and used `filepath.Match` character ranges instead, because **I measured the doubled form and it still leaks**: | form under test | secrets in image (of 28 planted) | | --- | --- | | as merged at `b8d21d1` | 19 | | new patterns transplanted unprefixed | 21 | | `**/`-prefixed, lowercase-only | 6 | | `**/`-prefixed **plus ALL-CAPS twins** | **2** — `certs/Server.Key`, `certs/Ca.Pem` | | character classes (this commit) | **0** | `**/*.KEY` alongside `**/*.key` covers `SERVER.KEY` and misses `Server.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-keygen` writes only `id_rsa`, and case-folding those would be noise without a corresponding shape. ### Bare `key` / `pem`: rejected, with a measured reason Not added. No tool produces those names; they are an ad-hoc choice, and the words collide with ordinary paths. I planted `internal/key/key.go` and `pkg/pem/decode.go` as controls: `**/key` would have deleted the whole `internal/key/` package directory from the build context. `config/key` and `config/pem` therefore still reach the image, deliberately, and that is visible in the enumeration below rather than quietly omitted. A repo that genuinely keeps a file called `key` adds it locally — which the policy already instructs. Also rejected, same reasoning: `**/id_*` (would match `id_generator.go`, planted as a control) and `*.crt` / `*.cer` (public certificates, not secrets, and sometimes a legitimate build input — `certs/ca.crt` planted 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-cache` scoped to that one image, no prune. ### BEFORE — as merged at `b8d21d1` ``` LANDED .envrc LANDED prod.env LANDED id_rsa LANDED bundle.p12 LANDED CA.PEM LANDED SERVER.KEY LANDED config/prod.env LANDED config/local.env LANDED certs/CA.PEM LANDED certs/SERVER.KEY LANDED certs/Server.Key LANDED certs/Ca.Pem LANDED certs/bundle.p12 LANDED certs/bundle.pfx LANDED deploy/secrets/id_rsa LANDED deploy/secrets/id_ed25519 LANDED deploy/secrets/.envrc LANDED config/id_dsa LANDED config/id_ecdsa excluded .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 --- leaked / total files --- 19 64 ``` ### NAIVE A — new patterns transplanted unprefixed ``` LANDED CA.PEM, SERVER.KEY, config/.env, config/.env.production, config/prod.env, config/local.env, certs/ca.pem, certs/server.key, certs/CA.PEM, certs/SERVER.KEY, certs/Server.Key, certs/Ca.Pem, certs/bundle.p12, certs/bundle.pfx, deploy/secrets/id_rsa.key, deploy/secrets/id_rsa, deploy/secrets/id_ed25519, deploy/secrets/.envrc, config/id_dsa, config/id_ecdsa, web/node_modules/nested/index.js --- leaked / total files --- 21 66 ``` ### NAIVE B — `**/`-prefixed, lowercase-only ``` LANDED CA.PEM LANDED SERVER.KEY LANDED certs/CA.PEM LANDED certs/SERVER.KEY LANDED certs/Server.Key LANDED certs/Ca.Pem --- leaked / total files --- 6 50 ``` ### NAIVE C — `**/`-prefixed plus ALL-CAPS twins ``` LANDED certs/Server.Key LANDED certs/Ca.Pem --- leaked / total files --- 2 46 ``` This is the control that decided the design. Mixed case survives a file that looks like it handles case. ### AFTER — this commit ``` excluded .env excluded certs/ca.pem excluded server.key excluded certs/server.key excluded ca.pem excluded certs/CA.PEM excluded .envrc excluded certs/SERVER.KEY excluded prod.env excluded certs/Server.Key excluded id_rsa excluded certs/Ca.Pem excluded bundle.p12 excluded certs/bundle.p12 excluded CA.PEM excluded certs/bundle.pfx excluded SERVER.KEY excluded deploy/secrets/id_rsa.key excluded config/.env excluded deploy/secrets/id_rsa excluded config/.env.production excluded deploy/secrets/id_ed25519 excluded config/prod.env excluded deploy/secrets/.envrc excluded config/local.env excluded config/id_dsa excluded web/node_modules/nested/index.js excluded config/id_ecdsa --- deliberately not covered (see reasoning) --- LANDED config/key LANDED config/pem --- positive controls: must all be PRESENT --- present web/src/app.js present internal/key/key.go present pkg/pem/decode.go present internal/id_generator.go present docs/environments.md present cmd/probe/main.go present certs/ca.crt --- leaked / total files --- 0 44 ``` **Does `**/*.env` eat anything wanted?** The only non-secret it excludes is an env *template*: I planted `docs/example.env` and it is excluded. That is intended and is not a new class — the `.env.example` spelling 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 `/myapp` vs `**/myapp` example now lives in the `.dockerignore` header comment, which is what a consuming repo actually receives, and states the consequence explicitly — the prefixed form also matches `cmd/myapp/` and deletes the package directory. Also fixed a latent misreading in `NEW_REPO_CHECKLIST.md`, whose "extend with host-built artifacts, giving every depth-independent pattern a `**/` prefix" could be read as instructing exactly the `**/myapp` mistake. ## 3. `EXISTING_REPO_CHECKLIST.md` Now 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 `.gitignore` All planted files and directories removed; absence confirmed by `find` over the filesystem, not `git status`. `.gitignore` untouched — noted that it carries comparable gaps, and left for its own issue.
Author
Collaborator

Second-pass findings folded in. 533fc61 -> fd78aeb, amended and force-pushed with --force-with-lease; origin/next verified at 533fc61 immediately before the push, and 51c3945 / d173e69 confirmed ancestors before and after. Still exactly three commits on the branch. make fmt and make check green, make docker re-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 disclosed

You were right that the rationale self-undercuts, and that decided it: if the justification for folding .pem and .key is that a case-insensitive filesystem makes the uppercase spelling reachable, then direnv reading .ENVRC and ssh reading ID_RSA on 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:

**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][dD]25519

Planting the full case matrix surfaced two shapes neither of us had named: .ENV.PRODUCTION and .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 / pem pair from the previous pass, unchanged and still deliberate — **/key would delete an internal/key/ package directory from the context.

3. False statement removed — and it changed the file, not just the prose

.env was indeed misgrouped. I verified your claim rather than taking it: with the literal **/.env line removed, .env, .ENV and .Env are still excluded at all five depths, because * matches the empty string. So I did not merely drop .env from the "stays literal" sentence — I deleted the redundant **/.env pattern 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.md as its own bullet:

> **/*.[eE][nN][vV] also excludes a committed env template such as example.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.Match semantics plus a ** extension compiled to a regexp; plain filepath.Match has no ** at all. Corrected in the header comment, the policy bullet, and TODO.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-cache on that one image, no prune.

BEFORE — as pushed at 533fc61

LEAKED 5/5  .ENV.PRODUCTION
LEAKED 5/5  .Env.Local
LEAKED 5/5  .ENVRC
LEAKED 5/5  .Envrc
LEAKED 5/5  ID_RSA
LEAKED 5/5  Id_Rsa
LEAKED 5/5  ID_ED25519
--- TOTAL leaked secret files (of 130) / total files in image ---
35        81

Note what is absent from that list: .ENV and .Env do not appear, which is the direct measurement behind finding 3.

CONTROL — same patterns, lowercase-only

LEAKED 5/5  .ENV, .Env, .ENV.PRODUCTION, .Env.Local, PROD.ENV, .ENVRC, .Envrc,
            ID_RSA, Id_Rsa, ID_ED25519, CA.PEM, Ca.Pem, SERVER.KEY, Server.Key,
            BUNDLE.P12, BUNDLE.PFX
--- TOTAL leaked secret files (of 130) ---
80

Sixteen shapes at five depths. The probe is not vacuous.

AFTER — this commit

--- secret shapes still in the image, by name ---
(none)
--- disclosed-gap probes ---
in image  config/key
in image  config/pem
--- positive controls (must all be present) ---
present   web/src/app.js
present   internal/key/key.go
present   pkg/pem/decode.go
present   internal/id_generator.go
present   internal/ID_MAP.go
present   docs/environments.md
present   cmd/probe/main.go
present   certs/ca.crt
present   certs/CA.CRT
excluded  docs/example.env (env template — the disclosed false positive)
excluded  web/node_modules/nested/index.js
--- TOTAL leaked secret files (of 130) / total files in image ---
0         46

internal/ID_MAP.go and certs/CA.CRT were added as controls specifically for this pass: the first proves the folded SSH patterns did not become an over-broad id_*, 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 find over the filesystem, not git status. .gitignore untouched — its parity gaps are #38.

**Second-pass findings folded in.** `533fc61` -&gt; `fd78aeb`, amended and force-pushed with `--force-with-lease`; `origin/next` verified at `533fc61` immediately before the push, and `51c3945` / `d173e69` confirmed ancestors before and after. Still exactly three commits on the branch. `make fmt` and `make check` green, `make docker` re-run with the check layer executing. [PR #34](https://git.eeqj.de/sneak/prompts/pulls/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 disclosed You were right that the rationale self-undercuts, and that decided it: if the justification for folding `.pem` and `.key` is that a case-insensitive filesystem makes the uppercase spelling reachable, then direnv reading `.ENVRC` and ssh reading `ID_RSA` on 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: ``` **/*.[eE][nN][vV] **/.[eE][nN][vV].* **/.[eE][nN][vV][rR][cC] **/*.[pP][eE][mM] **/*.[kK][eE][yY] **/*.[pP]12 **/*.[pP][fF][xX] **/[iI][dD]_[rR][sS][aA] **/[iI][dD]_[dD][sS][aA] **/[iI][dD]_[eE][cC][dD][sS][aA] **/[iI][dD]_[eE][dD]25519 ``` Planting the full case matrix surfaced **two shapes neither of us had named**: `.ENV.PRODUCTION` and `.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` / `pem` pair from the previous pass, unchanged and still deliberate — `**/key` would delete an `internal/key/` package directory from the context. ## 3. False statement removed — and it changed the file, not just the prose `.env` was indeed misgrouped. I verified your claim rather than taking it: with the literal `**/.env` line **removed**, `.env`, `.ENV` and `.Env` are still excluded at all five depths, because `*` matches the empty string. So I did not merely drop `.env` from the "stays literal" sentence — I deleted the redundant `**/.env` pattern 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.md` as its own bullet: &gt; `**/*.[eE][nN][vV]` also excludes a committed env template such as `example.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.Match` semantics **plus** a `**` extension compiled to a regexp; plain `filepath.Match` has no `**` at all. Corrected in the header comment, the policy bullet, and `TODO.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-cache` on that one image, no prune. ### BEFORE — as pushed at `533fc61` ``` LEAKED 5/5 .ENV.PRODUCTION LEAKED 5/5 .Env.Local LEAKED 5/5 .ENVRC LEAKED 5/5 .Envrc LEAKED 5/5 ID_RSA LEAKED 5/5 Id_Rsa LEAKED 5/5 ID_ED25519 --- TOTAL leaked secret files (of 130) / total files in image --- 35 81 ``` Note what is absent from that list: `.ENV` and `.Env` do **not** appear, which is the direct measurement behind finding 3. ### CONTROL — same patterns, lowercase-only ``` LEAKED 5/5 .ENV, .Env, .ENV.PRODUCTION, .Env.Local, PROD.ENV, .ENVRC, .Envrc, ID_RSA, Id_Rsa, ID_ED25519, CA.PEM, Ca.Pem, SERVER.KEY, Server.Key, BUNDLE.P12, BUNDLE.PFX --- TOTAL leaked secret files (of 130) --- 80 ``` Sixteen shapes at five depths. The probe is not vacuous. ### AFTER — this commit ``` --- secret shapes still in the image, by name --- (none) --- disclosed-gap probes --- in image config/key in image config/pem --- positive controls (must all be present) --- present web/src/app.js present internal/key/key.go present pkg/pem/decode.go present internal/id_generator.go present internal/ID_MAP.go present docs/environments.md present cmd/probe/main.go present certs/ca.crt present certs/CA.CRT excluded docs/example.env (env template — the disclosed false positive) excluded web/node_modules/nested/index.js --- TOTAL leaked secret files (of 130) / total files in image --- 0 46 ``` `internal/ID_MAP.go` and `certs/CA.CRT` were added as controls specifically for this pass: the first proves the folded SSH patterns did not become an over-broad `id_*`, 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 `find` over the filesystem, not `git status`. `.gitignore` untouched — its parity gaps are [#38](https://git.eeqj.de/sneak/prompts/issues/38).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/prompts#29