1 Commits

Author SHA1 Message Date
533fc61817 Keep secrets out of the Docker build context at every depth (closes #29)
All checks were successful
check / check (push) Successful in 7s
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.

Coverage is not limited to the three patterns the issue names, because the
enumeration found more shapes reaching the image. `**/*.env` covers the
prod.env / local.env convention, which the .env and .env.* spellings miss
entirely; `**/.envrc` is a secrets file by direnv convention; `**/*.p12` and
`**/*.pfx` are bundles carrying private keys; and `**/id_rsa`, `**/id_dsa`,
`**/id_ecdsa`, `**/id_ed25519` are the extensionless SSH keys that were
covered before only when someone happened to append a .key suffix.

Matching is case-sensitive, so `**/*.key` does not match certs/SERVER.KEY,
which is reachable on the case-insensitive filesystems most laptops use.
Doubling each pattern with an ALL-CAPS twin is not the fix: measured against
the planted set it still ships certs/Server.Key and certs/Ca.Pem while reading
as though case were handled, which is this issue's failure mode restated in a
new place. filepath.Match supports character ranges, so the secret-material
extensions use `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, `**/*.[pP]12` and
`**/*.[pP][fF][xX]`, covering every capitalisation in one line each. Names
that exist in exactly one spelling because a tool writes them -- .env, .envrc,
id_rsa -- stay literal.

Deliberately not covered: bare `key` and `pem` filenames, which no tool
produces and which collide with legitimate paths (a `**/key` pattern would
delete an internal/key/ package directory from the context); `**/id_*`, which
would match ordinary source such as id_generator.go; and *.crt and *.cer,
which are public certificates rather than secrets and are sometimes a
legitimate build input. Each of those is planted as a positive control and
verified present in the image after the change.

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. That comment gives the anchored form explicitly, `/myapp` rather than
`**/myapp`, because the prefixed spelling also matches cmd/myapp/ and deletes
the package directory. The header comment is the only part of this guidance a
consuming repo actually receives, since it is vendored with the file.

.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 case-sensitivity rule
and 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 requirements, since they are what an agent reads
while extending the file.

Verified by planting twenty-eight secret shapes at the root and up to three
directories below it -- including CA.PEM, SERVER.KEY, Server.Key, Ca.Pem,
config/prod.env, config/local.env, deploy/secrets/.envrc, extensionless SSH
keys and PKCS#12 bundles -- alongside seven positive controls, then building a
standalone probe image doing `COPY . .` and listing what actually landed
inside it. Before: nineteen of the twenty-eight in the image. Against three
naive forms: unprefixed leaks twenty-one, `**/`-prefixed lowercase-only leaks
six, ALL-CAPS-doubled still leaks two. After: zero, with all seven controls
still present, including internal/key/key.go, pkg/pem/decode.go,
internal/id_generator.go and certs/ca.crt.

Transferred-context size is recorded but load-bearing on nothing, and the runs
show why: a 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:27:17 +00:00
3 changed files with 43 additions and 62 deletions

View File

@@ -1,10 +1,8 @@
# Docker matches this file with moby/patternmatcher: Go filepath.Match
# semantics plus a `**` extension, compiled to a regexp. Plain
# filepath.Match has no `**` at all. What follows from that: `*` does not
# cross `/`, and a pattern without a leading `**/` is anchored at the
# build-context root. Every depth-independent pattern therefore needs the
# `**/` prefix — without it `config/.env` and `certs/server.key` still
# ship while the file reads as solved.
# .dockerignore uses Go filepath.Match, NOT .gitignore semantics: `*`
# does not cross `/`, and a pattern without a leading `**/` is anchored
# at the build-context root. Every depth-independent pattern therefore
# needs the `**/` prefix — without it `config/.env` and
# `certs/server.key` still ship while the file reads as solved.
#
# Root-anchored entries are for paths that occur exactly once, at the
# context root. A host-built binary is the usual case, and it must be
@@ -13,18 +11,11 @@
# context.
#
# Matching is case-sensitive, so `**/*.key` does not match
# `certs/SERVER.KEY`, which is reachable on the case-insensitive
# filesystems most laptops use. Adding an ALL-CAPS twin per pattern is
# not the fix: it still misses `Server.Key` while reading as though case
# were handled. Character ranges cover every spelling in one line, so
# every secret name below is written that way — including the
# extensionless SSH keys and `.envrc`, because on those same
# case-insensitive filesystems direnv reads `.ENVRC` and ssh reads
# `ID_RSA`.
#
# `**/*.[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`.
# `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
@@ -34,11 +25,13 @@
# Repository metadata: exactly one, at the context root.
.git
# Environment files. `*.env` covers both the bare `.env` name (`*` matches
# the empty string) and the `prod.env` convention.
# Environment files. `*.env` covers the `prod.env` / `local.env`
# convention; the `.env` and `.env.*` spellings are listed explicitly
# because they are what most tooling writes.
**/.env
**/.env.*
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
**/.envrc
# Private keys and the bundles that carry them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are not secrets and are
@@ -47,10 +40,10 @@
**/*.[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
**/id_rsa
**/id_dsa
**/id_ecdsa
**/id_ed25519
# Dependencies: restored inside the image, never copied in.
**/node_modules

22
TODO.md
View File

@@ -25,17 +25,17 @@ fmt-check, and commit.
developer's local `.env`, `*.pem` or `*.key` was reaching the Docker build
context under `COPY . .`, invisible to every git-based check because
`.gitignore` covers it. The patterns are written to `.dockerignore`'s own
`moby/patternmatcher` semantics — `**/`-prefixed so they hold at every depth,
which also fixes nested `node_modules` — rather than transplanted from
`.gitignore`, whose unprefixed form protects only the repository root while
reading as solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the
`prod.env` convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys,
every one of them case-folded with character ranges because matching is
case-sensitive and an ALL-CAPS twin per pattern still misses `Server.Key`.
`REPO_POLICIES.md` 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.
`filepath.Match` semantics — `**/`-prefixed so they hold at every depth, which
also fixes nested `node_modules` — rather than transplanted from `.gitignore`,
whose unprefixed form protects only the repository root while reading as
solved. Coverage extends past the `.env`/`.pem`/`.key` trio to the `prod.env`
convention, `.envrc`, PKCS#12 bundles and extensionless SSH keys, with
capitalisation handled by character classes because matching is case-sensitive
and an ALL-CAPS twin per pattern still misses `Server.Key`. `REPO_POLICIES.md`
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
now carries the canonical `script/bootstrap` snippet for Go repos, which
installs when the installed version does not match the pin (the old

View File

@@ -338,17 +338,14 @@ style conventions are in separate documents:
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: Go `filepath.Match` semantics plus a `**` extension,
compiled to a regexp — plain `filepath.Match` has no `**` at all. So `*` does
not cross `/`, and a pattern without a leading `**/` is anchored at the
build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root; `config/.env` and
`certs/server.key` still reach the context and can land in an image layer.
That file is more dangerous than a short one with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix — `**/node_modules`,
`**/.DS_Store`, and the secret patterns in the canonical file, which are
additionally case-folded per the rule below — and leave only genuinely
Go `filepath.Match`: `*` does not cross `/`, and a pattern without a leading
`**/` is anchored at the build-context root. A `.dockerignore` listing `.env`,
`*.pem` and `*.key` therefore excludes only the copies at the repository root;
`config/.env` and `certs/server.key` still reach the context and can land in
an image layer. That file is more dangerous than a short one with no secret
patterns at all, because it reads as solved and stops anyone looking. Give
every depth-independent pattern the `**/` prefix — `**/.env`, `**/.env.*`,
`**/*.pem`, `**/*.key`, `**/node_modules` — and leave only genuinely
root-anchored entries such as `.git` unprefixed. The inverse move is equally
wrong: never apply `**/` to `.gitignore`, where it is redundant and produces a
file that is wrong in a way that looks careful. Each file is written to its
@@ -367,19 +364,10 @@ style conventions are in separate documents:
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. The matcher supports character ranges, so one line covers every
spelling: `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`. Apply this to every secret
name, not only to extensions: the extensionless SSH keys and `.envrc` need it
for the same reason, since on the very filesystems that make `SERVER.KEY`
reachable, direnv reads `.ENVRC` and ssh reads `ID_RSA`. Note that `*` matches
the empty string, so `**/*.[eE][nN][vV]` already covers a bare `.ENV` and no
separate literal `.env` entry is needed.
- **A pattern that also catches something the build needs is re-included with a
negation, not deleted.** The canonical `**/*.[eE][nN][vV]` excludes a
committed env template such as `example.env`; a repo whose build genuinely
reads one adds `!docs/example.env` after the pattern. Deleting the pattern
instead reopens the exposure for every other file it covers.
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
patterns.** Plant files at the root _and_ at least two directories deep, build