1 Commits

Author SHA1 Message Date
b8d21d1592 Keep secrets out of the Docker build context at every depth (closes #29)
All checks were successful
check / check (push) Successful in 8s
The canonical .dockerignore was three lines -- .git, node_modules, .DS_Store
-- while the canonical Dockerfile does `COPY . .`, so a developer's local
.env, *.pem or *.key was shipped into the build context and could land in an
image layer. Nothing surfaced it because .gitignore covers those patterns, so
the files are invisible to every git-based check.

The obvious repair, copying .gitignore's secret patterns across, is worse than
the gap it closes. .dockerignore does not use .gitignore semantics: Docker
matches with Go filepath.Match, `*` does not cross `/`, and a pattern without
a leading `**/` is anchored at the build-context root. A file listing .env,
*.pem and *.key therefore reads as solved, reviews as solved, and protects
only the repository root, while config/.env and certs/server.key still ship.
The three-line file at least invited scrutiny; the transplanted form
manufactures confidence and stops anyone looking.

So every depth-independent pattern here carries the `**/` prefix and only
genuinely root-anchored entries stay unprefixed. `**/node_modules` fixes a
defect the three-line file had today for any nested node_modules,
independently of the secret exposure.

The OS and editor patterns are included on their own merits rather than by
mirroring .gitignore. None of them is ever a build input, and editor state in
particular churns under a developer's hands, so each one is a source of
`COPY . .` invalidation carrying no information about the source tree. Now
that the checks are keyed on CHECK_EPOCH rather than on accidental context
churn, there is no reason left to keep churn in the context. Language build
artifacts are deliberately absent: they are per-repo, and the file's header
comment tells consuming repos to add their own host-built binaries, which is
the case that actually bites -- a host `make build` drops a multi-megabyte
artifact into the context where .gitignore hides it from every git-based
check.

.gitignore is untouched. Its semantics are the inverse: an unanchored pattern
already matches at any depth, so `**/`-prefixing it produces a file that is
wrong in a way that looks careful. That asymmetry is why "derive one from the
other" was the wrong instruction, and it is now written down in
REPO_POLICIES.md in both directions, together with the requirement to verify
by enumerating the image rather than by reading the patterns. Every consuming
repo inherits .dockerignore by copy, so the trap has to live where the next
person looks, not only be fixed once here. Both repo checklists gain the same
requirement, since they are what an agent reads while extending the file.

Verified by planting .env, server.key and ca.pem at the root plus config/.env,
config/.env.production, certs/ca.pem, certs/server.key,
deploy/secrets/id_rsa.key, web/node_modules/nested/index.js and a nested .swp
below it, then building a standalone probe image doing `COPY . .` and listing
what actually landed inside it. Before: all eleven planted files in the image.
Against the naive unprefixed form: the three root-level files excluded and
every nested one still present, which is what shows the enumeration can detect
the failure mode at all. After: every planted file excluded at every depth,
with web/src/app.js still present to prove the probe was copying nested files
rather than copying nothing.

Transferred-context size is recorded but load-bearing on nothing, and the 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.

Planted files were removed and their absence confirmed against the filesystem
rather than against `git status`, which could not have seen them.

`make docker` re-run after the change: the check layer executed rather than
being served from cache, so the CHECK_EPOCH verification still holds under the
altered build context.
2026-08-09 16:10:45 +00:00
5 changed files with 15 additions and 66 deletions

View File

@@ -2,48 +2,21 @@
# does not cross `/`, and a pattern without a leading `**/` is anchored # does not cross `/`, and a pattern without a leading `**/` is anchored
# at the build-context root. Every depth-independent pattern therefore # at the build-context root. Every depth-independent pattern therefore
# needs the `**/` prefix — without it `config/.env` and # needs the `**/` prefix — without it `config/.env` and
# `certs/server.key` still ship while the file reads as solved. # `certs/server.key` still ship while the file reads as solved. Entries
# # that are genuinely root-anchored stay unprefixed. Extend this file
# Root-anchored entries are for paths that occur exactly once, at the # with the repo's own host-built artifacts (compiled binaries, test
# context root. A host-built binary is the usual case, and it must be # binaries, coverage output); those are per-repo and belong here because
# written anchored: `/myapp`, never `**/myapp`. The prefixed form also # a host build otherwise drops them into the context.
# matches `cmd/myapp/`, which deletes the package directory from the
# context.
#
# Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`. The secret-material extensions below use character
# classes, which cover every capitalisation in one line — a doubled
# ALL-CAPS pattern would still miss `Server.Key` while reading as though
# case were handled. Names that only ever exist in one spelling because
# a tool writes them (`.env`, `.envrc`, `id_rsa`) stay literal.
#
# Extend this file with the repo's own host-built artifacts (compiled
# binaries, test binaries, coverage output); those are per-repo and
# belong here because a host build otherwise drops them into the
# context.
# Repository metadata: exactly one, at the context root. # Repository metadata: exactly one, at the context root.
.git .git
# Environment files. `*.env` covers the `prod.env` / `local.env` # Environment and secrets. These are the reason the prefixes matter: a
# convention; the `.env` and `.env.*` spellings are listed explicitly # developer's local copy is invisible to every git-based check.
# because they are what most tooling writes.
**/.env **/.env
**/.env.* **/.env.*
**/*.[eE][nN][vV] **/*.pem
**/.envrc **/*.key
# Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are
# sometimes a legitimate build input.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/id_rsa
**/id_dsa
**/id_ecdsa
**/id_ed25519
# Dependencies: restored inside the image, never copied in. # Dependencies: restored inside the image, never copied in.
**/node_modules **/node_modules

12
TODO.md
View File

@@ -28,14 +28,10 @@ fmt-check, and commit.
`filepath.Match` semantics — `**/`-prefixed so they hold at every depth, which `filepath.Match` semantics — `**/`-prefixed so they hold at every depth, which
also fixes nested `node_modules` — rather than transplanted from `.gitignore`, also fixes nested `node_modules` — rather than transplanted from `.gitignore`,
whose unprefixed form protects only the repository root while reading as whose unprefixed form protects only the repository root while reading as
solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the `prod.env` solved. `REPO_POLICIES.md` and both repo checklists now state that asymmetry
convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys, with and require verification by enumerating the image rather than by reading the
capitalisation handled by character classes because matching is case-sensitive patterns. Verified with a probe image before, against the naive unprefixed
and an ALL-CAPS twin per pattern still misses `Server.Key`. `REPO_POLICIES.md` form, and after.
and both repo checklists now state that asymmetry and require verification by
enumerating the image rather than by reading the patterns. Verified with a
probe image before, against three naive forms (unprefixed, lowercase-only,
ALL-CAPS-doubled), and after.
- 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md - 2026-08-09: Made the pinned golangci-lint actually propagate: REPO_POLICIES.md
now carries the canonical `script/bootstrap` snippet for Go repos, which now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old installs when the installed version does not match the pin (the old

View File

@@ -37,11 +37,6 @@ with your task.
`CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is `CHECK_EPOCH` rule in `REPO_POLICIES.md`. Without them the check layer is
served from cache on an unchanged tree and the build reports a green it served from cache on an unchanged tree and the build reports a green it
never ran. never ran.
- [ ] `.dockerignore` excludes the repo's own host-built artifacts (compiled
binaries, test binaries, coverage output), written root-anchored —
`/myapp`, never `**/myapp`, which would also match `cmd/myapp/`. An
existing repo is where such a binary is likeliest to already be sitting in
the build context, invisible to git.
- [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix; - [ ] Every depth-independent pattern in `.dockerignore` carries a `**/` prefix;
only genuinely root-anchored entries such as `.git` are unprefixed, and only genuinely root-anchored entries such as `.git` are unprefixed, and
`.gitignore`'s patterns have not been transplanted unmodified. `.gitignore`'s patterns have not been transplanted unmodified.

View File

@@ -53,9 +53,7 @@ Template files can be fetched from:
- [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from - [ ] `Dockerfile` and `.dockerignore` — fetch `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore`
- Extend `.dockerignore` with the repo's own host-built artifacts, giving - Extend `.dockerignore` with the repo's own host-built artifacts, giving
every depth-independent pattern a `**/` prefix — but write a repo-root every depth-independent pattern a `**/` prefix. Do not transplant
binary anchored, `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory. Do not transplant
`.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at `.gitignore`'s patterns: `.dockerignore` anchors an unprefixed pattern at
the context root, so the copied form leaves `config/.env` in the build the context root, so the copied form leaves `config/.env` in the build
context while reading as solved. See the `.dockerignore` rule in context while reading as solved. See the `.dockerignore` rule in

View File

@@ -354,20 +354,7 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own host-built artifacts — a host `make build` that leaves it with the repo's own host-built artifacts — a host `make build` that leaves
a compiled binary in the repo root puts that binary in the build context, a compiled binary in the repo root puts that binary in the build context,
where `.gitignore` hides it from every git-based check. Write that binary where `.gitignore` hides it from every git-based check.
anchored, `/myapp` and never `**/myapp`: the prefixed form also matches
`cmd/myapp/` and deletes the package directory from the context.
- **`.dockerignore` matching is case-sensitive, so cover capitalisation with
character classes rather than by doubling patterns.** `**/*.key` does not
match `certs/SERVER.KEY`, which is reachable on the case-insensitive
filesystems most laptops use. Adding an ALL-CAPS twin for each pattern is not
the fix: it still misses `Server.Key` and `Ca.Pem` while reading as though
case were handled — the same manufactured confidence as the root-anchored
form. `filepath.Match` supports character ranges, so one line covers every
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to
secret-material extensions; names that exist in exactly one spelling because a
tool writes them (`.env`, `.envrc`, `id_rsa`) stay literal.
- **Verify `.dockerignore` by enumerating the image, not by reading the - **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build patterns.** Plant files at the root _and_ at least two directories deep, build