From 0938da8a73f8403386f486234c6c95faf7020740 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 01:28:26 +0000 Subject: [PATCH 1/4] feat: keep secrets out of the app image (E00-S02-T08) - .dockerignore: exclude env + credential files (.npmrc, .netrc, .aws, .ssh, secrets/, *.pem, *.key, *.p12, *.pfx, *.jks, id_rsa, id_ed25519, ...) from the build context so a local secret file cannot be embedded in the image - Dockerfile: document the T08 guarantee (no secret ARG/ENV, fixed non-secret COPY paths, runtime credentials via Compose environment) - compose.yaml: T08 in scope; runtime credentials stay in service environment, never in the image --- .dockerignore | 18 ++++++++++++++++++ apps/server/Dockerfile | 9 +++++++++ compose.yaml | 12 +++++++++--- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.dockerignore b/.dockerignore index 95e1211..227d2ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,5 +14,23 @@ coverage .env .env.* +# Secrets & credentials (E00-S02-T08) — never part of the build context, so a +# secret-bearing file cannot be embedded in the image even if a developer has +# one locally. Keep this list in sync with tests/secrets-not-embedded.test.mjs. +.npmrc +.netrc +.credentials +.aws +.ssh +secrets +secrets/ +*.pem +*.key +*.p12 +*.pfx +*.jks +id_rsa +id_ed25519 + # Logs *.log diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 18a879a..d93d424 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -17,6 +17,15 @@ # T05 the runtime stage drops root privileges (runs as the image's non-root # `node` user). # +# T08: the image embeds no secrets. The Dockerfile declares no secret-bearing +# ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY +# copies a fixed, non-secret path (manifests, source, compiled dist) — never +# `.env` or credential files; `.dockerignore` additionally excludes env and +# credential files from the build context, so a local secret file cannot be +# embedded even by mistake. Runtime credentials (e.g. DATABASE_URL) are +# injected by Compose at run time (compose.yaml `app.environment`), never baked +# into the image. Tests: tests/secrets-not-embedded.test.mjs. +# # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module # build surprises, so the image must stay on a glibc base. diff --git a/compose.yaml b/compose.yaml index 08903b4..8d648e5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,4 +1,4 @@ -# EPPP Docker Compose baseline — [E00-S02-T01..T07] +# EPPP Docker Compose baseline — [E00-S02-T01..T08] # # `docker compose up -d` starts both the database (PostgreSQL) and the # application (@personal-blog/server). Rollback: `docker compose down`. @@ -37,8 +37,14 @@ # so local runs and the T01..T06 real-stack probes are unaffected. Rollback: # drop the `platforms` list from the `app` build config. # -# Explicitly out of scope for T01..T07 (land in a later E00-S02 task): -# - secrets not embedded (T08) +# Secrets not embedded (T08): the app image carries no secrets — the committed +# apps/server/Dockerfile declares no secret-bearing ARG/ENV instruction and +# copies only fixed, non-secret paths, and the committed .dockerignore excludes +# env and credential files from the build context. Runtime credentials are +# injected here, at run time, via service `environment` values (the app's +# DATABASE_URL, the db service's POSTGRES_* defaults) — they live in +# Compose/deploy config, never in the image. Rollback: rebuild the image after +# removing any embedded secret. Tests: tests/secrets-not-embedded.test.mjs. # # All values have defaults so `docker compose up -d` works from a clean clone # without a .env file (a committed .env.example template lands in E00-S04). From b3ad55efe8e73774d549dab49f4dd153786eb573 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 01:28:26 +0000 Subject: [PATCH 2/4] test: lock in no-secrets-in-image criteria (E00-S02-T08) tests/secrets-not-embedded.test.mjs: static assertions that the Dockerfile embeds no secrets (no secret-bearing ARG/ENV, no secret-path or blanket COPY) and .dockerignore excludes env/credential files; non-vacuous mutation probes for every assertion; Docker-gated probe that builds the image with a marker env file in the context and scans every layer + image config for secret values. --- tests/secrets-not-embedded.test.mjs | 555 ++++++++++++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 tests/secrets-not-embedded.test.mjs diff --git a/tests/secrets-not-embedded.test.mjs b/tests/secrets-not-embedded.test.mjs new file mode 100644 index 0000000..ae26d30 --- /dev/null +++ b/tests/secrets-not-embedded.test.mjs @@ -0,0 +1,555 @@ +/** + * Secrets-not-embedded test — locks in the [E00-S02-T08] guarantee that no + * secrets are embedded in the workspace server application image. + * + * Acceptance criteria covered (each test fails without the committed state): + * - "secrets are not embedded in the image" → the committed + * `apps/server/Dockerfile` declares no secret-bearing `ARG`/`ENV` + * instruction (the only ENV is `NODE_ENV=production`) and every `COPY` + * copies a fixed, non-secret path — never `.env` or credential files, and + * never a blanket `COPY . .` of the whole context; the committed + * `.dockerignore` excludes local env + credential files from the build + * context, so a developer's secret file cannot be embedded even by + * mistake; and the committed files the Dockerfile copies into the image + * contain no default credential values. The mutation probes below prove + * the assertions are non-vacuous (adding a secret ENV/ARG, a credential + * URI value, a `COPY` of `.env`, a blanket `COPY . .`, or dropping a + * `.dockerignore` exclusion breaks the criterion). + * - "image layers contain no secret values" → on machines with Docker, the + * real-image probe builds the committed image from the repo root with a + * marker-bearing probe env file (`.env.t08-*`, excluded by `.dockerignore`) + * present in the build context, then `docker save`s the image, extracts + * every layer (raw + decompressed) and the image config, and confirms + * neither the probe marker nor the compose default credential values + * appear anywhere in the layers. + * + * Run: `node --test tests/secrets-not-embedded.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { gunzipSync } from 'node:zlib'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The committed app image definition and build context filters under test. */ +const DOCKERFILE_PATH = 'apps/server/Dockerfile'; +const DOCKERIGNORE_PATH = '.dockerignore'; + +/** + * Env/credential path patterns that must never enter the image. The committed + * `.dockerignore` must exclude every one of them, and no Dockerfile `COPY` may + * target a path matching one. Keep in sync with the committed `.dockerignore`. + */ +const SECRET_PATH_PATTERNS = [ + '.env', + '.env.*', + '.npmrc', + '.netrc', + '.credentials', + '.aws', + '.ssh', + 'secrets', + 'secrets/', + '*.pem', + '*.key', + '*.p12', + '*.pfx', + '*.jks', + 'id_rsa', + 'id_ed25519', +]; + +/** Default credential values committed in compose.yaml (dev-only defaults). */ +const COMPOSE_CREDENTIAL_VALUES = [ + 'postgres://eppp:eppp@db:5432/eppp', + 'eppp', +]; + +/** Variable names that must never appear on an ARG/ENV instruction. */ +const SECRET_NAME_RE = + /(password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret|private[_-]?key|credential|database[_-]?url)\b/i; + +/** A credential URI (`scheme://user:pass@host`) embedded as a literal value. */ +const CREDENTIAL_URI_RE = /:\/\/[^/\s]+:[^@\s]+@/; + +/** A long random-looking value (JWT/API-key/token-shaped literal). */ +const LONG_SECRET_RE = /^[A-Za-z0-9+/=_-]{32,}$/; + +// --------------------------------------------------------------------------- +// Dockerfile structure helpers +// --------------------------------------------------------------------------- + +/** + * Extracts every single-line `ENV`/`ARG` instruction from the committed + * Dockerfile. (The committed file uses single-line instructions; like the + * repo's block-YAML parser, this supports the subset the committed file uses.) + */ +function envArgInstructions(dockerfile) { + const instructions = []; + for (const line of dockerfile.split(/\r?\n/)) { + const match = /^\s*(ENV|ARG)\s+(.+)$/.exec(line); + if (match) instructions.push({ kind: match[1], rest: match[2].trim() }); + } + return instructions; +} + +/** Splits one `ENV key=value` / `ENV key value` / `ARG key[=value]` line. */ +function parseInstruction(rest) { + const eq = rest.indexOf('='); + const sp = rest.search(/\s/); + if (eq !== -1 && (sp === -1 || eq < sp)) { + return { name: rest.slice(0, eq).trim(), value: rest.slice(eq + 1).trim() }; + } + if (sp !== -1) { + return { name: rest.slice(0, sp).trim(), value: rest.slice(sp + 1).trim() }; + } + return { name: rest.trim(), value: '' }; +} + +/** Extracts every single-line `COPY` instruction (raw argument list). */ +function copyInstructions(dockerfile) { + const copies = []; + for (const line of dockerfile.split(/\r?\n/)) { + const match = /^\s*COPY\s+(.+)$/.exec(line); + if (match) copies.push(match[1].trim()); + } + return copies; +} + +// --------------------------------------------------------------------------- +// dockerignore-style path matching (the subset the committed patterns use) +// --------------------------------------------------------------------------- + +/** Converts a glob (`*` = non-separator run, `**` = anything, `?` = one char) to a RegExp. */ +function globToRegExp(pattern) { + let re = ''; + for (let i = 0; i < pattern.length; i += 1) { + const ch = pattern[i]; + if (ch === '*') { + if (pattern[i + 1] === '*') { + re += '.*'; + i += 1; + } else { + re += '[^/]*'; + } + } else if (ch === '?') { + re += '[^/]'; + } else { + re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + } + return new RegExp(`^${re}$`); +} + +/** + * True when `relPath` (context-relative, `/` separators) matches a + * dockerignore-style pattern: a pattern with no `/` matches any path + * component (docker prunes a matched directory, taking its contents); a + * trailing `/` restricts to directories (and everything under them); `*`/`**`/ + * `?` follow docker's glob rules. Supports the subset the committed + * `.dockerignore` and the Dockerfile COPY sources use. + */ +function matchesDockerignore(relPath, pattern) { + const normPath = relPath.replace(/^\.\//, '').replace(/\/+$/, ''); + let pat = pattern.replace(/^\.\//, ''); + const dirOnly = pat.endsWith('/'); + if (dirOnly) pat = pat.slice(0, -1); + + if (dirOnly) { + if (pat.includes('/')) { + return normPath === pat || normPath.startsWith(`${pat}/`); + } + // A directory pattern with no slash matches a directory of that name at + // any depth — and everything under it (docker prunes matched dirs). + const re = globToRegExp(pat); + return normPath.split('/').some((component) => re.test(component)); + } + + const re = globToRegExp(pat); + if (!pat.includes('/')) { + return normPath.split('/').some((component) => re.test(component)); + } + return re.test(normPath); +} + +// --------------------------------------------------------------------------- +// Criterion assertions +// --------------------------------------------------------------------------- + +/** + * Asserts the committed Dockerfile embeds no secrets: + * - no `ARG`/`ENV` instruction names a secret-bearing variable, embeds a + * credential URI, or embeds a long secret-looking literal (runtime + * credentials belong in Compose environment, never in the image); + * - every `COPY` copies fixed, non-secret paths — none matches a + * `SECRET_PATH_PATTERNS` pattern and none is a blanket copy of the whole + * build context (`COPY . .`) that could sweep env/credential files in. + */ +function assertNoSecretsEmbedded(dockerfile) { + const instructions = envArgInstructions(dockerfile); + for (const { kind, rest } of instructions) { + const { name, value } = parseInstruction(rest); + assert.doesNotMatch( + name, + SECRET_NAME_RE, + `the Dockerfile must not declare a secret-bearing ${kind} variable (got "${name}") — ` + + 'runtime credentials belong in Compose environment (compose.yaml), never baked into the image', + ); + assert.doesNotMatch( + value, + CREDENTIAL_URI_RE, + `the Dockerfile ${kind} "${name}" must not embed a credential URI (got "${value}")`, + ); + assert.doesNotMatch( + value, + LONG_SECRET_RE, + `the Dockerfile ${kind} "${name}" must not embed a long secret-looking value (got "${value}")`, + ); + } + + const copies = copyInstructions(dockerfile); + assert.ok( + copies.length > 0, + 'the Dockerfile must declare COPY instructions (the app files must reach the image)', + ); + for (const copy of copies) { + const tokens = copy.split(/\s+/).filter((token) => !token.startsWith('--from=')); + const sources = tokens.slice(0, -1).filter((token) => !token.startsWith('/')); + for (const src of sources) { + assert.notEqual( + src.replace(/\/+$/, ''), + '.', + 'the Dockerfile must not COPY the whole build context (COPY . ...) — env/credential files could be swept into the image', + ); + for (const pattern of SECRET_PATH_PATTERNS) { + assert.ok( + !matchesDockerignore(src, pattern), + `the Dockerfile COPY must not copy a secret/credential path (got "${src}", matches "${pattern}")`, + ); + } + } + } +} + +/** + * Asserts the committed `.dockerignore` excludes every `SECRET_PATH_PATTERNS` + * entry, so a developer's env/credential files never enter the build context — + * the first line of defense against embedding secrets in the image. + */ +function assertDockerignoreExcludesSecrets(dockerignore) { + const patterns = dockerignore + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + for (const required of SECRET_PATH_PATTERNS) { + assert.ok( + patterns.includes(required), + `.dockerignore must exclude "${required}" so env/credential files never enter the build context ` + + `(got: ${patterns.join(', ')})`, + ); + } +} + +/** + * Asserts none of the committed files that the Dockerfile copies into the + * image contains a default credential value — source-level proof that the + * image's inputs carry no secrets. + */ +function assertCopiedFilesHaveNoCredentials(contentsByPath) { + for (const [relPath, text] of contentsByPath) { + for (const needle of COMPOSE_CREDENTIAL_VALUES) { + assert.ok( + !text.includes(needle), + `committed file "${relPath}" (copied into the image) must not contain the default credential value ` + + `"${needle}" — a secret would be embedded in the image`, + ); + } + } +} + +/** + * Collects the committed files the Dockerfile COPY instructions pull from the + * repo (stage-local `/...` sources and `--from=` flags are ignored) as a + * `Map`. Directories are walked; gitignored build output and + * dependency trees are skipped (they are not committed image inputs). + */ +function copiedFileContents(dockerfile) { + const contents = new Map(); + const SKIP_DIRS = new Set(['node_modules', '.pnpm-store', 'dist', 'coverage', '.git']); + + const addPath = (relPath) => { + const full = path.join(REPO_ROOT, relPath); + if (!existsSync(full)) return; + const st = statSync(full); + if (st.isDirectory()) { + for (const entry of readdirSync(full)) { + if (SKIP_DIRS.has(entry)) continue; + addPath(path.posix.join(relPath, entry)); + } + return; + } + try { + contents.set(relPath, readFileSync(full, 'utf8')); + } catch { + // unreadable/binary files are not text inputs; skip + } + }; + + for (const copy of copyInstructions(dockerfile)) { + const tokens = copy.split(/\s+/).filter((token) => !token.startsWith('--from=')); + const sources = tokens.slice(0, -1).filter((token) => !token.startsWith('/')); + for (const src of sources) { + const rel = src.replace(/^\.\//, ''); + if (rel === '.' || rel === '') continue; + addPath(rel); + } + } + return contents; +} + +// --------------------------------------------------------------------------- +// Docker probe helpers (integration tests skip cleanly without Docker) +// --------------------------------------------------------------------------- + +function run(cmd, args, opts = {}) { + return spawnSync(cmd, args, { + encoding: 'utf8', + timeout: 600_000, + ...opts, + }); +} + +/** True when a reachable Docker daemon exists. */ +function dockerDaemonAvailable() { + try { + return run('docker', ['info'], { timeout: 15_000 }).status === 0; + } catch { + return false; + } +} + +const DOCKER_DAEMON = dockerDaemonAvailable(); + +/** + * Walks `dir`, returning which of `needles` occur in any file's raw content or + * (for gzip-compressed layer blobs) its decompressed content. The image config + * JSON is part of the save output, so baked `ENV` secrets are caught too. + */ +function anyFileContains(dir, needles) { + const needleBufs = needles.map((needle) => Buffer.from(needle, 'utf8')); + const found = new Set(); + + const walk = (d) => { + for (const entry of readdirSync(d)) { + const full = path.join(d, entry); + const st = statSync(full); + if (st.isDirectory()) { + walk(full); + continue; + } + const bufs = [readFileSync(full)]; + const raw = bufs[0]; + if (raw.length > 2 && raw[0] === 0x1f && raw[1] === 0x8b) { + try { + bufs.push(gunzipSync(raw)); + } catch { + // not a real gzip stream — raw content is already searched + } + } + for (const buf of bufs) { + for (let i = 0; i < needleBufs.length; i += 1) { + if (buf.indexOf(needleBufs[i]) !== -1) found.add(needles[i]); + } + } + } + }; + + walk(dir); + return [...found]; +} + +// --------------------------------------------------------------------------- +// Criterion tests +// --------------------------------------------------------------------------- + +test('the app image embeds no secrets (Dockerfile declares no secret ENV/ARG and copies no secret paths)', () => { + assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); + assertNoSecretsEmbedded(read(DOCKERFILE_PATH)); +}); + +test('the build context excludes env and credential files (.dockerignore)', () => { + assert.ok( + existsSync(path.join(REPO_ROOT, DOCKERIGNORE_PATH)), + `committed ${DOCKERIGNORE_PATH} must exist so local secrets stay out of the build context`, + ); + assertDockerignoreExcludesSecrets(read(DOCKERIGNORE_PATH)); +}); + +test('the committed files copied into the image contain no default credential values', () => { + const dockerfile = read(DOCKERFILE_PATH); + const contents = copiedFileContents(dockerfile); + assert.ok(contents.size > 0, 'the Dockerfile must copy committed files that the test can scan'); + assertCopiedFilesHaveNoCredentials(contents); +}); + +test('the built image layers contain no secret values (docker build + layer scan)', { skip: !DOCKER_DAEMON }, () => { + // Build the committed image from the repo root with a marker-bearing probe + // env file in the build context (`.env.t08-*` matches the `.env.*` + // exclusion), then scan every layer blob (raw + decompressed) and the image + // config for the marker and the compose default credential values. + const marker = `T08_PROBE_${randomUUID().replace(/-/g, '')}`; + const probeName = `.env.t08-${randomUUID().slice(0, 8)}`; + const probePath = path.join(REPO_ROOT, probeName); + const tag = `personal-blog:t08-probe-${randomUUID().slice(0, 8)}`; + const tmp = mkdtempSync(path.join(os.tmpdir(), 't08-layer-scan-')); + writeFileSync(probePath, `T08_PROBE_SECRET=${marker}\nPOSTGRES_PASSWORD=${marker}\n`); + + try { + const build = run('docker', ['build', '--file', 'apps/server/Dockerfile', '--tag', tag, '.'], { + cwd: REPO_ROOT, + }); + assert.equal( + build.status, + 0, + `"docker build" must exit 0:\n${(build.stdout || '')}\n${(build.stderr || '')}`.trim(), + ); + + const save = run('docker', ['save', '--output', path.join(tmp, 'image.tar'), tag], { timeout: 300_000 }); + assert.equal( + save.status, + 0, + `"docker save" must exit 0:\n${(save.stdout || '')}\n${(save.stderr || '')}`.trim(), + ); + + const extractDir = path.join(tmp, 'extracted'); + mkdirSync(extractDir, { recursive: true }); + const untar = run('tar', ['-xf', path.join(tmp, 'image.tar'), '-C', extractDir], { timeout: 300_000 }); + assert.equal( + untar.status, + 0, + `"tar -xf" must exit 0:\n${(untar.stdout || '')}\n${(untar.stderr || '')}`.trim(), + ); + + const found = anyFileContains(extractDir, [marker, ...COMPOSE_CREDENTIAL_VALUES]); + assert.deepEqual( + found, + [], + `the image layers must contain no secret values; found in the image: ${found.join(', ')} ` + + `(scan of every layer + image config of "${tag}"; see \`docker history ${tag}\` for the layer list)`, + ); + } finally { + try { + unlinkSync(probePath); + } catch { + // probe env file already gone + } + rmSync(tmp, { recursive: true, force: true }); + run('docker', ['image', 'rm', '-f', tag]); + } +}); + +// --------------------------------------------------------------------------- +// Non-vacuous probes — the assertions above really do fail on violations +// --------------------------------------------------------------------------- + +test('adding a secret-bearing ENV makes the no-secrets criterion fail (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const mutated = `${dockerfile}\nENV POSTGRES_PASSWORD=not-a-real-secret\n`; + assert.throws(() => assertNoSecretsEmbedded(mutated), /POSTGRES_PASSWORD/); +}); + +test('adding an ARG with a secret name makes the no-secrets criterion fail (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const mutated = `${dockerfile}\nARG DATABASE_URL=postgres://eppp:eppp@db:5432/eppp\n`; + assert.throws(() => assertNoSecretsEmbedded(mutated), /DATABASE_URL/); +}); + +test('embedding a credential URI in an ENV value fails the no-secrets criterion (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const mutated = dockerfile.replace( + 'ENV NODE_ENV=production', + 'ENV NODE_ENV=production\nENV DB_URI=postgres://user:pass@host:5432/db', + ); + assert.notEqual(mutated, dockerfile, 'the mutation must actually add the credential URI ENV'); + assert.throws(() => assertNoSecretsEmbedded(mutated), /credential URI/); +}); + +test('a long secret-looking ENV value fails the no-secrets criterion (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const mutated = dockerfile.replace( + 'ENV NODE_ENV=production', + 'ENV NODE_ENV=production\nENV SOMETHING=abcdef0123456789ABCDEF0123456789abcdef0123456789', + ); + assert.notEqual(mutated, dockerfile, 'the mutation must actually add the long value ENV'); + assert.throws(() => assertNoSecretsEmbedded(mutated), /long secret-looking/); +}); + +test('COPYing .env into the image fails the no-secrets criterion (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const mutated = dockerfile.replace( + 'COPY apps/server apps/server', + 'COPY apps/server apps/server\nCOPY .env .env', + ); + assert.notEqual(mutated, dockerfile, 'the mutation must actually add the .env COPY'); + assert.throws(() => assertNoSecretsEmbedded(mutated), /\.env/); +}); + +test('a blanket COPY of the whole build context fails the no-secrets criterion (mutation probe)', () => { + const dockerfile = read(DOCKERFILE_PATH); + const mutated = dockerfile.replace('COPY apps/server apps/server', 'COPY . .'); + assert.notEqual(mutated, dockerfile, 'the mutation must actually add the blanket COPY'); + assert.throws(() => assertNoSecretsEmbedded(mutated), /whole build context/); +}); + +test('removing .env.* from .dockerignore fails the exclusion criterion (mutation probe)', () => { + const dockerignore = read(DOCKERIGNORE_PATH); + const mutated = dockerignore.replace(/^\.env\.\*\s*$/m, ''); + assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the .env.* pattern'); + assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.env\.\*/); +}); + +test('removing a credential pattern (*.key) from .dockerignore fails the exclusion criterion (mutation probe)', () => { + const dockerignore = read(DOCKERIGNORE_PATH); + const mutated = dockerignore.replace(/^\*\.key\s*$/m, ''); + assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the *.key pattern'); + assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.key/); +}); + +test('a copied committed file containing a default credential value fails (mutation probe)', () => { + const contents = new Map([ + ['apps/server/src/index.ts', 'const url = "postgres://eppp:eppp@db:5432/eppp";'], + ]); + assert.throws(() => assertCopiedFilesHaveNoCredentials(contents), /postgres:\/\/eppp:eppp@db:5432\/eppp/); +}); + +test('the dockerignore matcher excludes probe env files and keeps source files (parser probe)', () => { + assert.ok(matchesDockerignore('.env.t08-probe', '.env.*'), '.env.* must match probe env files'); + assert.ok(matchesDockerignore('.env', '.env'), '.env must match the exact file'); + assert.ok(!matchesDockerignore('.env.production', '.env'), '.env must not match .env.production'); + assert.ok(!matchesDockerignore('apps/server/src/index.ts', '.env'), 'source files must not match .env'); + assert.ok(matchesDockerignore('secrets/credentials.txt', 'secrets'), 'a path under secrets/ must be excluded'); + assert.ok(matchesDockerignore('config/secrets/credentials.txt', 'secrets'), 'nested secrets/ dirs must be excluded'); + assert.ok(matchesDockerignore('config/server.key', '*.key'), '*.key must match a key file at any depth'); + assert.ok(matchesDockerignore('secrets/credentials.txt', 'secrets/'), 'secrets/ must exclude everything under it'); + assert.ok(!matchesDockerignore('apps/server/package.json', 'secrets/'), 'source files must not match secrets/'); +}); From f00c13d57ae81eb1662dc1208a61d8068024beaa Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 01:49:07 +0000 Subject: [PATCH 3/4] fix: make .dockerignore exclusions apply at any depth (E00-S02-T08) Resolve the security review of #389 (findings 1-4): - .dockerignore: every env/credential pattern is now **/-prefixed (**/.env, **/.env.*, **/node_modules, **/.npmrc, ..., **/secrets, **/*.pem, **/*.key, ...) and the redundant 'secrets/' line is dropped. Docker's matcher (moby/patternmatcher) anchors slash-less patterns to the context root, so the bare forms excluded nothing under apps/server/; **/ matches the root AND any nested depth. (finding 1, 3) - tests/secrets-not-embedded.test.mjs: the dockerignore matcher is now a faithful port of moby/patternmatcher (filepath.Clean + anchored full-path match + parent-directory propagation), not gitignore basename semantics; asserts nested example paths (apps/server/.npmrc, config/server.key, apps/server/secrets/...) are excluded; requires no redundant equivalent patterns verbatim; adds mutation probes for bare-pattern and duplicate-pattern regressions. (finding 2, 3) - apps/server/Dockerfile + compose.yaml: guarantee restated precisely (credential files excluded at the context root AND at any depth). - .gitea/workflows/ci.yml: new job runs 'node --test tests/secrets-not-embedded.test.mjs' on every PR; the docker-gated layer-scan probe runs where a daemon exists, skips cleanly otherwise. (finding 4) - tests/compose-config.test.mjs: .dockerignore presence list updated to the **/-prefixed forms (node_modules, .env). Tested: secrets suite 16 tests -> 15 pass / 1 docker-gated skip / 0 fail; full suite 101 pass / 12 fail / 8 skip, failures identical to clean main (env-dependent pnpm/Node-24 suites); matcher port verified against the moby/patternmatcher evidence table. --- .dockerignore | 39 +-- .gitea/workflows/ci.yml | 15 + apps/server/Dockerfile | 10 +- compose.yaml | 4 +- tests/compose-config.test.mjs | 5 +- tests/secrets-not-embedded.test.mjs | 437 ++++++++++++++++++++++------ 6 files changed, 394 insertions(+), 116 deletions(-) diff --git a/.dockerignore b/.dockerignore index 227d2ea..3514e2a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,7 +3,7 @@ .gitignore # Dependencies -node_modules +**/node_modules .pnpm-store # Build output @@ -11,26 +11,29 @@ dist coverage # Local environment files (a committed .env.example lands in E00-S04) -.env -.env.* +**/.env +**/.env.* # Secrets & credentials (E00-S02-T08) — never part of the build context, so a # secret-bearing file cannot be embedded in the image even if a developer has -# one locally. Keep this list in sync with tests/secrets-not-embedded.test.mjs. -.npmrc -.netrc -.credentials -.aws -.ssh -secrets -secrets/ -*.pem -*.key -*.p12 -*.pfx -*.jks -id_rsa -id_ed25519 +# one locally. Every pattern is `**/`-prefixed because Docker's matcher +# (moby/patternmatcher) anchors a slash-less pattern to the context ROOT — a +# bare `.npmrc`/`*.key`/`secrets` would exclude nothing under `apps/server/…`. +# `**/` matches the file at the root AND at any nested depth. Keep this list +# in sync with tests/secrets-not-embedded.test.mjs. +**/.npmrc +**/.netrc +**/.credentials +**/.aws +**/.ssh +**/secrets +**/*.pem +**/*.key +**/*.p12 +**/*.pfx +**/*.jks +**/id_rsa +**/id_ed25519 # Logs *.log diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6d10a90..def04b1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -21,3 +21,18 @@ jobs: run: pnpm install --frozen-lockfile - name: Verify workspace groups run: pnpm -r list --depth -1 + + # E00-S02-T08: the static assertions of tests/secrets-not-embedded.test.mjs + # gate every PR (the docker-gated layer-scan probe inside the same file runs + # where a Docker daemon is available and skips cleanly otherwise). + secrets-not-embedded: + name: Secrets not embedded (E00-S02-T08) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Run secrets-not-embedded test suite + run: node --test tests/secrets-not-embedded.test.mjs diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index d93d424..aac22f9 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -21,10 +21,12 @@ # ARG/ENV instruction (the only ENV is `NODE_ENV=production`) and every COPY # copies a fixed, non-secret path (manifests, source, compiled dist) — never # `.env` or credential files; `.dockerignore` additionally excludes env and -# credential files from the build context, so a local secret file cannot be -# embedded even by mistake. Runtime credentials (e.g. DATABASE_URL) are -# injected by Compose at run time (compose.yaml `app.environment`), never baked -# into the image. Tests: tests/secrets-not-embedded.test.mjs. +# credential files from the build context at the context root AND at any +# nested depth (its patterns are `**/`-prefixed because Docker's matcher +# anchors slash-less patterns to the context root), so a local secret file +# cannot be embedded even by mistake. Runtime credentials (e.g. DATABASE_URL) +# are injected by Compose at run time (compose.yaml `app.environment`), never +# baked into the image. Tests: tests/secrets-not-embedded.test.mjs. # # Image base: node:24.19.0-bookworm-slim (glibc Debian) per Technology-Stack # §5.4 — argon2 is a native dependency and musl/Alpine causes native-module diff --git a/compose.yaml b/compose.yaml index 8d648e5..34574cc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -40,7 +40,9 @@ # Secrets not embedded (T08): the app image carries no secrets — the committed # apps/server/Dockerfile declares no secret-bearing ARG/ENV instruction and # copies only fixed, non-secret paths, and the committed .dockerignore excludes -# env and credential files from the build context. Runtime credentials are +# env and credential files from the build context at the context root AND at +# any nested depth (`**/`-prefixed patterns — Docker's matcher anchors +# slash-less patterns to the context root). Runtime credentials are # injected here, at run time, via service `environment` values (the app's # DATABASE_URL, the db service's POSTGRES_* defaults) — they live in # Compose/deploy config, never in the image. Rollback: rebuild the image after diff --git a/tests/compose-config.test.mjs b/tests/compose-config.test.mjs index 8a62ae3..cb9d601 100644 --- a/tests/compose-config.test.mjs +++ b/tests/compose-config.test.mjs @@ -445,7 +445,10 @@ test('the build context excludes local artifacts and environment files', () => { .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); - for (const required of ['node_modules', 'dist', '.env', '.git']) { + // T08 hardened these to their `**/`-prefixed forms so the exclusions also + // apply at any nested depth (Docker's matcher anchors slash-less patterns to + // the context root). + for (const required of ['**/node_modules', 'dist', '**/.env', '.git']) { assert.ok( patterns.includes(required), `.dockerignore must exclude "${required}" (got: ${patterns.join(', ')})`, diff --git a/tests/secrets-not-embedded.test.mjs b/tests/secrets-not-embedded.test.mjs index ae26d30..bc816c5 100644 --- a/tests/secrets-not-embedded.test.mjs +++ b/tests/secrets-not-embedded.test.mjs @@ -9,19 +9,34 @@ * copies a fixed, non-secret path — never `.env` or credential files, and * never a blanket `COPY . .` of the whole context; the committed * `.dockerignore` excludes local env + credential files from the build - * context, so a developer's secret file cannot be embedded even by - * mistake; and the committed files the Dockerfile copies into the image + * context at the context root AND at any nested depth (`**`-prefixed + * patterns — Docker's matcher anchors slash-less patterns to the context + * root, so a bare `.npmrc`/`*.key`/`secrets` would exclude nothing under + * `apps/server/…`), so a developer's secret file cannot be embedded even + * by mistake; and the committed files the Dockerfile copies into the image * contain no default credential values. The mutation probes below prove * the assertions are non-vacuous (adding a secret ENV/ARG, a credential - * URI value, a `COPY` of `.env`, a blanket `COPY . .`, or dropping a - * `.dockerignore` exclusion breaks the criterion). + * URI value, a `COPY` of `.env`, a blanket `COPY . .`, dropping a + * `.dockerignore` exclusion, replacing a `**`-prefixed pattern with its + * root-anchored bare form, or adding a redundant equivalent pattern all + * break the criterion). * - "image layers contain no secret values" → on machines with Docker, the * real-image probe builds the committed image from the repo root with a * marker-bearing probe env file (`.env.t08-*`, excluded by `.dockerignore`) * present in the build context, then `docker save`s the image, extracts * every layer (raw + decompressed) and the image config, and confirms * neither the probe marker nor the compose default credential values - * appear anywhere in the layers. + * appear anywhere in the layers. CI runs this file on every PR + * (.gitea/workflows/ci.yml), so the static assertions gate merges. + * + * The dockerignore matcher below is a faithful port of Docker's real matcher, + * moby/patternmatcher (patternmatcher.go): every pattern is `filepath.Clean`ed + * (so `secrets` and `secrets/` are the SAME pattern), compiled to an anchored + * full-path matcher (exact / trailing-`**` prefix / leading-`**`+separator suffix / + * regexp), and a path matches when a pattern matches it OR any of its parent + * directories (docker prunes a matched directory, taking everything under it). + * It is deliberately NOT gitignore-basename matching: a slash-less pattern + * only matches at the context root. * * Run: `node --test tests/secrets-not-embedded.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) @@ -59,24 +74,54 @@ const DOCKERIGNORE_PATH = '.dockerignore'; * Env/credential path patterns that must never enter the image. The committed * `.dockerignore` must exclude every one of them, and no Dockerfile `COPY` may * target a path matching one. Keep in sync with the committed `.dockerignore`. + * + * Every pattern is `**`-prefixed: Docker's matcher (moby/patternmatcher) + * anchors a slash-less pattern to the context root, so a bare `.npmrc`/ + * `*.key`/`secrets` would exclude nothing under `apps/server/…`. A `**`- + * prefixed `foo` matches `foo` at the root AND at any nested depth. */ const SECRET_PATH_PATTERNS = [ - '.env', - '.env.*', - '.npmrc', - '.netrc', - '.credentials', - '.aws', - '.ssh', - 'secrets', - 'secrets/', - '*.pem', - '*.key', - '*.p12', - '*.pfx', - '*.jks', - 'id_rsa', - 'id_ed25519', + '**/.env', + '**/.env.*', + '**/node_modules', + '**/.npmrc', + '**/.netrc', + '**/.credentials', + '**/.aws', + '**/.ssh', + '**/secrets', + '**/*.pem', + '**/*.key', + '**/*.p12', + '**/*.pfx', + '**/*.jks', + '**/id_rsa', + '**/id_ed25519', +]; + +/** + * One representative NESTED context path per required pattern — the acceptance + * criteria call these out explicitly (`apps/server/.npmrc`, `config/server.key`, + * `apps/server/secrets/…`). The committed `.dockerignore` (the full pattern + * set) must exclude every one of them under Docker's anchored matcher. + */ +const SECRET_PATH_EXAMPLES = [ + ['**/.env', 'apps/server/.env'], + ['**/.env.*', 'apps/server/.env.local'], + ['**/node_modules', 'apps/server/node_modules/pkg/index.js'], + ['**/.npmrc', 'apps/server/.npmrc'], + ['**/.netrc', 'packages/core/.netrc'], + ['**/.credentials', 'config/.credentials'], + ['**/.aws', 'apps/server/.aws/credentials'], + ['**/.ssh', 'apps/server/.ssh/id_ed25519'], + ['**/secrets', 'apps/server/secrets/db.pem'], + ['**/*.pem', 'config/server.pem'], + ['**/*.key', 'config/server.key'], + ['**/*.p12', 'certs/app.p12'], + ['**/*.pfx', 'certs/app.pfx'], + ['**/*.jks', 'certs/app.jks'], + ['**/id_rsa', 'apps/server/id_rsa'], + ['**/id_ed25519', 'apps/server/.ssh/id_ed25519'], ]; /** Default credential values committed in compose.yaml (dev-only defaults). */ @@ -137,59 +182,183 @@ function copyInstructions(dockerfile) { } // --------------------------------------------------------------------------- -// dockerignore-style path matching (the subset the committed patterns use) +// Docker-faithful .dockerignore matching (moby/patternmatcher port) // --------------------------------------------------------------------------- -/** Converts a glob (`*` = non-separator run, `**` = anything, `?` = one char) to a RegExp. */ -function globToRegExp(pattern) { - let re = ''; - for (let i = 0; i < pattern.length; i += 1) { - const ch = pattern[i]; - if (ch === '*') { - if (pattern[i + 1] === '*') { - re += '.*'; - i += 1; - } else { - re += '[^/]*'; +/** + * POSIX `filepath.Clean` for the pattern/path forms this repo uses (moby's + * patternmatcher runs every pattern through `filepath.Clean` before compiling + * it): collapses repeated separators, resolves `.`/`..`, and drops a trailing + * separator — so `secrets` and `secrets/` are the SAME pattern, and `./x` + * is `x`. + */ +function cleanPath(p) { + if (p === '') return '.'; + const rooted = p.startsWith('/'); + const out = []; + let dotdot = 0; // `..` may not backtrack past this index + for (const part of p.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') { + if (out.length > dotdot) { + out.pop(); + } else if (!rooted) { + out.push('..'); + dotdot = out.length; } - } else if (ch === '?') { - re += '[^/]'; - } else { - re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + continue; } + out.push(part); } - return new RegExp(`^${re}$`); + const result = `${rooted ? '/' : ''}${out.join('/')}`; + return result === '' ? '.' : result; } /** - * True when `relPath` (context-relative, `/` separators) matches a - * dockerignore-style pattern: a pattern with no `/` matches any path - * component (docker prunes a matched directory, taking its contents); a - * trailing `/` restricts to directories (and everything under them); `*`/`**`/ - * `?` follow docker's glob rules. Supports the subset the committed - * `.dockerignore` and the Dockerfile COPY sources use. + * Compiles one cleaned pattern exactly as moby/patternmatcher's `compile` + * does: a leading `**` followed by a separator becomes an optional + * "any segments" group `(.*` + separator + `)?` (or, when followed only by + * literal chars, a suffix match that also matches the root form); a trailing + * `**` becomes a prefix match; a mid-pattern `**` becomes the same optional + * group; `*`/`?` become `[^/]*`/`[^/]`; regexp metachars are escaped. A + * pattern with no globs is an exact full-path match. + * + * Returns `{ cleanedPattern, matchType, regexp }` with matchType one of + * 'exact' | 'prefix' | 'suffix' | 'regexp'. */ -function matchesDockerignore(relPath, pattern) { - const normPath = relPath.replace(/^\.\//, '').replace(/\/+$/, ''); - let pat = pattern.replace(/^\.\//, ''); - const dirOnly = pat.endsWith('/'); - if (dirOnly) pat = pat.slice(0, -1); - - if (dirOnly) { - if (pat.includes('/')) { - return normPath === pat || normPath.startsWith(`${pat}/`); +function compilePattern(cleaned) { + let regStr = '^'; + let matchType = 'exact'; + let iter = 0; // Go scanner iteration counter (i in patternmatcher.go) + let i = 0; + const n = cleaned.length; + while (i < n) { + const ch = cleaned[i]; + if (ch === '*') { + if (i + 1 < n && cleaned[i + 1] === '*') { + i += 2; + // Treat "**/" as "**" — eat the following separator. + if (i < n && cleaned[i] === '/') i += 1; + if (i >= n) { + // Trailing "**": match everything from here on. + if (matchType === 'exact') matchType = 'prefix'; + else { + regStr += '.*'; + matchType = 'regexp'; + } + } else { + // Mid-pattern "**": any number of segments (incl. zero). + regStr += '(.*/)?'; + matchType = 'regexp'; + } + // A leading "**/..." with no further globs is a suffix match. + if (iter === 0) matchType = 'suffix'; + } else { + // "*" matches anything but a separator. + regStr += '[^/]*'; + matchType = 'regexp'; + i += 1; + } + } else if (ch === '?') { + regStr += '[^/]'; + matchType = 'regexp'; + i += 1; + } else if ('.+()|{}$'.includes(ch)) { + // Regexp metachars that are not filepath pattern chars get escaped. + regStr += `\\${ch}`; + i += 1; + } else if (ch === '\\') { + // Escape the next char (a trailing lone backslash is kept literal). + if (i + 1 < n) { + regStr += `\\${cleaned[i + 1]}`; + i += 2; + matchType = 'regexp'; + } else { + regStr += '\\'; + i += 1; + } + } else if (ch === '[' || ch === ']') { + // Brackets are passed through to the regexp (char classes). + regStr += ch; + matchType = 'regexp'; + i += 1; + } else { + regStr += ch; + i += 1; } - // A directory pattern with no slash matches a directory of that name at - // any depth — and everything under it (docker prunes matched dirs). - const re = globToRegExp(pat); - return normPath.split('/').some((component) => re.test(component)); + iter += 1; } + let regexp = null; + if (matchType === 'regexp') { + regStr += '$'; + regexp = new RegExp(regStr); + } + return { cleanedPattern: cleaned, matchType, regexp }; +} - const re = globToRegExp(pat); - if (!pat.includes('/')) { - return normPath.split('/').some((component) => re.test(component)); +/** Matches one compiled pattern against a full cleaned path (pattern.match). */ +function patternMatches(pattern, path) { + const { cleanedPattern, matchType, regexp } = pattern; + if (matchType === 'exact') return path === cleanedPattern; + if (matchType === 'prefix') return path.startsWith(cleanedPattern.slice(0, -2)); + if (matchType === 'suffix') { + const suffix = cleanedPattern.slice(2); + if (path.endsWith(suffix)) return true; + // "**/foo" also matches the bare "foo" at the context root. + return suffix.startsWith('/') && path === suffix.slice(1); } - return re.test(normPath); + if (matchType === 'regexp') return regexp.test(path); + return false; +} + +/** + * Parses `.dockerignore` text the way docker does (trim, skip blanks and `#` + * comments) and compiles every pattern via the moby/patternmatcher pipeline + * (TrimSpace, filepath.Clean, optional `!` exclusion prefix). + */ +function dockerignorePatterns(dockerignoreText) { + const patterns = []; + for (const rawLine of dockerignoreText.split(/\r?\n/)) { + let line = rawLine.trim(); + if (line === '' || line.startsWith('#')) continue; + line = cleanPath(line); + let exclusion = false; + if (line.startsWith('!')) { + if (line.length === 1) throw new Error('illegal exclusion pattern: "!"'); + exclusion = true; + line = line.slice(1); + } + patterns.push({ exclusion, ...compilePattern(line) }); + } + return patterns; +} + +/** + * Docker's MatchesOrParentMatches: true when `relPath` (context-relative) is + * excluded by any of the compiled patterns. A pattern matches the full cleaned + * path OR any of its parent directories (docker prunes a matched directory, + * taking everything under it). A slash-less pattern is anchored to the context + * ROOT — exactly as in moby/patternmatcher — so only `**`-prefixed patterns + * reach nested paths. + */ +function matchesDockerignore(patterns, relPath) { + const file = cleanPath(relPath); + if (file === '.') return false; + const parent = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.'; + const parentDirs = parent === '.' ? [] : parent.split('/'); + let matched = false; + for (const pattern of patterns) { + if (pattern.exclusion !== matched) continue; + let m = patternMatches(pattern, file); + if (!m && parent !== '.') { + for (let i = 0; i < parentDirs.length; i += 1) { + m = patternMatches(pattern, parentDirs.slice(0, i + 1).join('/')); + if (m) break; + } + } + if (m) matched = !pattern.exclusion; + } + return matched; } // --------------------------------------------------------------------------- @@ -232,6 +401,7 @@ function assertNoSecretsEmbedded(dockerfile) { copies.length > 0, 'the Dockerfile must declare COPY instructions (the app files must reach the image)', ); + const secretPatterns = dockerignorePatterns(SECRET_PATH_PATTERNS.join('\n')); for (const copy of copies) { const tokens = copy.split(/\s+/).filter((token) => !token.startsWith('--from=')); const sources = tokens.slice(0, -1).filter((token) => !token.startsWith('/')); @@ -241,31 +411,49 @@ function assertNoSecretsEmbedded(dockerfile) { '.', 'the Dockerfile must not COPY the whole build context (COPY . ...) — env/credential files could be swept into the image', ); - for (const pattern of SECRET_PATH_PATTERNS) { - assert.ok( - !matchesDockerignore(src, pattern), - `the Dockerfile COPY must not copy a secret/credential path (got "${src}", matches "${pattern}")`, - ); - } + assert.ok( + !matchesDockerignore(secretPatterns, src), + `the Dockerfile COPY must not copy a secret/credential path (got "${src}") — the build context ` + + 'excludes env/credential files at any depth via .dockerignore', + ); } } } /** * Asserts the committed `.dockerignore` excludes every `SECRET_PATH_PATTERNS` - * entry, so a developer's env/credential files never enter the build context — - * the first line of defense against embedding secrets in the image. + * entry — at the context root AND at any nested depth — so a developer's + * env/credential files never enter the build context, the first line of + * defense against embedding secrets in the image. Concretely: + * - every required `**`-prefixed pattern is present verbatim (a bare + * `.npmrc`/`*.key`/`secrets` would only exclude the context root); + * - no two patterns clean to the same path (redundant equivalent patterns + * such as `secrets` + `secrets/` are never required); + * - every representative NESTED example path is excluded by the full pattern + * set under the Docker-faithful matcher. */ function assertDockerignoreExcludesSecrets(dockerignore) { - const patterns = dockerignore - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith('#')); + const compiled = dockerignorePatterns(dockerignore); + const cleaned = compiled.map((p) => p.cleanedPattern); for (const required of SECRET_PATH_PATTERNS) { assert.ok( - patterns.includes(required), - `.dockerignore must exclude "${required}" so env/credential files never enter the build context ` + - `(got: ${patterns.join(', ')})`, + cleaned.includes(required), + `.dockerignore must exclude "${required}" (the **/-prefixed form, so the pattern applies at the ` + + `context root AND any nested depth — Docker's matcher anchors slash-less patterns to the root) ` + + `(got: ${cleaned.join(', ')})`, + ); + } + assert.equal( + new Set(cleaned).size, + cleaned.length, + `.dockerignore must not contain redundant equivalent patterns (two patterns that clean to the same ` + + `path, e.g. \`secrets\` and \`secrets/\`, add nothing) (got: ${cleaned.join(', ')})`, + ); + for (const [pattern, example] of SECRET_PATH_EXAMPLES) { + assert.ok( + matchesDockerignore(compiled, example), + `.dockerignore must exclude the nested example path "${example}" (via "${pattern}") so a local ` + + 'secret file cannot be embedded even by mistake', ); } } @@ -521,20 +709,39 @@ test('a blanket COPY of the whole build context fails the no-secrets criterion ( assert.throws(() => assertNoSecretsEmbedded(mutated), /whole build context/); }); -test('removing .env.* from .dockerignore fails the exclusion criterion (mutation probe)', () => { +test('removing **/.env.* from .dockerignore fails the exclusion criterion (mutation probe)', () => { const dockerignore = read(DOCKERIGNORE_PATH); - const mutated = dockerignore.replace(/^\.env\.\*\s*$/m, ''); - assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the .env.* pattern'); + const mutated = dockerignore.replace(/^\*\*\/\.env\.\*\s*$/m, ''); + assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the **/.env.* pattern'); assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.env\.\*/); }); -test('removing a credential pattern (*.key) from .dockerignore fails the exclusion criterion (mutation probe)', () => { +test('removing a credential pattern (**/*.key) from .dockerignore fails the exclusion criterion (mutation probe)', () => { const dockerignore = read(DOCKERIGNORE_PATH); - const mutated = dockerignore.replace(/^\*\.key\s*$/m, ''); - assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the *.key pattern'); + const mutated = dockerignore.replace(/^\*\*\/\*\.key\s*$/m, ''); + assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the **/*.key pattern'); assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.key/); }); +test('replacing a **/-prefixed exclusion with its root-anchored bare form fails (mutation probe)', () => { + // A bare `secrets` matches only the context root in Docker's matcher, so it + // cannot satisfy the "excluded at any depth" criterion — only `**/secrets` + // can. This locks in why the committed patterns are `**/`-prefixed. + const dockerignore = read(DOCKERIGNORE_PATH); + const mutated = dockerignore.replace('**/secrets', 'secrets'); + assert.notEqual(mutated, dockerignore, 'the mutation must actually replace **/secrets with secrets'); + assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /secrets/); +}); + +test('redundant equivalent .dockerignore patterns (secrets + secrets/) fail the exclusion criterion (mutation probe)', () => { + // `secrets` and `secrets/` clean to the same path, so requiring both is + // redundant; the no-redundancy assertion must catch them. + const dockerignore = read(DOCKERIGNORE_PATH); + const mutated = dockerignore.replace('**/secrets', 'secrets\nsecrets/'); + assert.notEqual(mutated, dockerignore, 'the mutation must actually split **/secrets into the bare forms'); + assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /secrets/); +}); + test('a copied committed file containing a default credential value fails (mutation probe)', () => { const contents = new Map([ ['apps/server/src/index.ts', 'const url = "postgres://eppp:eppp@db:5432/eppp";'], @@ -542,14 +749,60 @@ test('a copied committed file containing a default credential value fails (mutat assert.throws(() => assertCopiedFilesHaveNoCredentials(contents), /postgres:\/\/eppp:eppp@db:5432\/eppp/); }); -test('the dockerignore matcher excludes probe env files and keeps source files (parser probe)', () => { - assert.ok(matchesDockerignore('.env.t08-probe', '.env.*'), '.env.* must match probe env files'); - assert.ok(matchesDockerignore('.env', '.env'), '.env must match the exact file'); - assert.ok(!matchesDockerignore('.env.production', '.env'), '.env must not match .env.production'); - assert.ok(!matchesDockerignore('apps/server/src/index.ts', '.env'), 'source files must not match .env'); - assert.ok(matchesDockerignore('secrets/credentials.txt', 'secrets'), 'a path under secrets/ must be excluded'); - assert.ok(matchesDockerignore('config/secrets/credentials.txt', 'secrets'), 'nested secrets/ dirs must be excluded'); - assert.ok(matchesDockerignore('config/server.key', '*.key'), '*.key must match a key file at any depth'); - assert.ok(matchesDockerignore('secrets/credentials.txt', 'secrets/'), 'secrets/ must exclude everything under it'); - assert.ok(!matchesDockerignore('apps/server/package.json', 'secrets/'), 'source files must not match secrets/'); +test('the dockerignore matcher is Docker-faithful and excludes nested credential paths (parser probe)', () => { + const patterns = dockerignorePatterns(read(DOCKERIGNORE_PATH)); + + // The nested example paths the acceptance criteria call out are excluded. + assert.ok(matchesDockerignore(patterns, 'apps/server/.npmrc'), 'apps/server/.npmrc must be excluded'); + assert.ok(matchesDockerignore(patterns, 'config/server.key'), 'config/server.key must be excluded'); + assert.ok(matchesDockerignore(patterns, 'apps/server/secrets/db.pem'), 'apps/server/secrets/db.pem must be excluded'); + assert.ok(matchesDockerignore(patterns, 'apps/server/.env'), 'apps/server/.env must be excluded'); + assert.ok(matchesDockerignore(patterns, 'apps/server/.env.local'), 'apps/server/.env.local must be excluded'); + assert.ok( + matchesDockerignore(patterns, 'apps/server/node_modules/pkg/index.js'), + 'apps/server/node_modules/pkg/index.js must be excluded', + ); + assert.ok(matchesDockerignore(patterns, '.npmrc'), 'the root .npmrc must be excluded too'); + assert.ok(matchesDockerignore(patterns, '.env.t08-probe'), 'the probe env file must be excluded'); + + // Source files and committed manifests are kept. + assert.ok(!matchesDockerignore(patterns, 'apps/server/src/index.ts'), 'source files must not be excluded'); + assert.ok(!matchesDockerignore(patterns, 'apps/server/package.json'), 'committed manifests must not be excluded'); + assert.ok(!matchesDockerignore(patterns, 'package.json'), 'the root manifest must not be excluded'); + + // Docker semantics (moby/patternmatcher), NOT gitignore basename semantics: + // a slash-less pattern is anchored to the context root, so the bare forms + // exclude nothing under apps/server/… — this is exactly why the committed + // patterns are `**/`-prefixed. + assert.ok( + !matchesDockerignore(dockerignorePatterns('.npmrc'), 'apps/server/.npmrc'), + 'bare .npmrc must not match a nested .npmrc (Docker anchors it to the root)', + ); + assert.ok( + !matchesDockerignore(dockerignorePatterns('.env'), 'apps/server/.env'), + 'bare .env must not match a nested .env (Docker anchors it to the root)', + ); + assert.ok( + !matchesDockerignore(dockerignorePatterns('*.key'), 'config/server.key'), + 'bare *.key must not match a nested key file (Docker anchors it to the root)', + ); + assert.ok( + !matchesDockerignore(dockerignorePatterns('secrets'), 'apps/server/secrets/creds.txt'), + 'bare secrets must not prune a nested secrets/ dir (Docker anchors it to the root)', + ); + + // Parent-directory propagation: a pattern that matches a directory prunes + // everything under it — at the root (bare form) and at any depth (**/ form). + assert.ok( + matchesDockerignore(dockerignorePatterns('secrets'), 'secrets/credentials.txt'), + 'a root-level secrets/ dir must be pruned by the bare pattern', + ); + assert.ok( + matchesDockerignore(dockerignorePatterns('**/secrets'), 'apps/server/secrets/credentials.txt'), + 'a nested secrets/ dir must be pruned by the **/-prefixed pattern', + ); + assert.ok( + matchesDockerignore(dockerignorePatterns('**/.npmrc'), '.npmrc'), + '**/.npmrc must also match the root form', + ); }); From 719fb4380be78899b9a5113197521aa2716fa236 Mon Sep 17 00:00:00 2001 From: implementer Date: Sat, 29 Aug 2026 01:53:57 +0000 Subject: [PATCH 4/4] test: plant nested marker files in the layer-scan probe (E00-S02-T08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker-gated probe only planted a root-level .env.t08-* marker, which no Dockerfile COPY instruction ever copies — so it could not observe a nested build-context leak in the image layers. Plant additional marker files at nested paths the Dockerfile's COPY apps/server apps/server would sweep into the build-stage image (apps/server/.env.t08-*, apps/server/secrets/t08-*.pem) so the end-to-end scan actually verifies the 'any depth' exclusion guarantee, not just the root form. --- tests/secrets-not-embedded.test.mjs | 69 ++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/tests/secrets-not-embedded.test.mjs b/tests/secrets-not-embedded.test.mjs index bc816c5..990e9a4 100644 --- a/tests/secrets-not-embedded.test.mjs +++ b/tests/secrets-not-embedded.test.mjs @@ -21,12 +21,15 @@ * root-anchored bare form, or adding a redundant equivalent pattern all * break the criterion). * - "image layers contain no secret values" → on machines with Docker, the - * real-image probe builds the committed image from the repo root with a - * marker-bearing probe env file (`.env.t08-*`, excluded by `.dockerignore`) - * present in the build context, then `docker save`s the image, extracts + * real-image probe builds the committed image from the repo root with + * marker-bearing probe files planted in the build context at the root + * (`.env.t08-*`) AND at nested paths the Dockerfile's + * `COPY apps/server apps/server` would sweep into the build-stage image + * (`apps/server/.env.t08-*`, `apps/server/secrets/t08-*.pem`) unless + * `.dockerignore` excludes them, then `docker save`s the image, extracts * every layer (raw + decompressed) and the image config, and confirms - * neither the probe marker nor the compose default credential values - * appear anywhere in the layers. CI runs this file on every PR + * neither the markers nor the compose default credential values appear + * anywhere in the layers. CI runs this file on every PR * (.gitea/workflows/ci.yml), so the static assertions gate merges. * * The dockerignore matcher below is a faithful port of Docker's real matcher, @@ -601,18 +604,37 @@ test('the committed files copied into the image contain no default credential va }); test('the built image layers contain no secret values (docker build + layer scan)', { skip: !DOCKER_DAEMON }, () => { - // Build the committed image from the repo root with a marker-bearing probe - // env file in the build context (`.env.t08-*` matches the `.env.*` - // exclusion), then scan every layer blob (raw + decompressed) and the image - // config for the marker and the compose default credential values. - const marker = `T08_PROBE_${randomUUID().replace(/-/g, '')}`; - const probeName = `.env.t08-${randomUUID().slice(0, 8)}`; - const probePath = path.join(REPO_ROOT, probeName); + // Build the committed image from the repo root with marker-bearing probe + // files planted in the build context at the root AND at nested paths the + // Dockerfile's `COPY apps/server apps/server` would sweep into the + // build-stage image if `.dockerignore` did not exclude them: + // - .env.t08- (root; `**/.env.*`) + // - apps/server/.env.t08- (nested; `**/.env.*`) + // - apps/server/secrets/t08-.pem (nested; `**/secrets`, `**/*.pem`) + // then scan every layer blob (raw + decompressed) and the image config for + // the markers and the compose default credential values. The nested markers + // are the observable end-to-end check of the "any depth" guarantee: a leak + // at apps/server/… would land in the build-stage layers via the COPY. + const rootMarker = `T08_ROOT_SECRET_${randomUUID().replace(/-/g, '')}`; + const nestedEnvMarker = `T08_NESTED_ENV_SECRET_${randomUUID().replace(/-/g, '')}`; + const nestedPemMarker = `T08_NESTED_PEM_SECRET_${randomUUID().replace(/-/g, '')}`; + const rootProbeName = `.env.t08-${randomUUID().slice(0, 8)}`; + const nestedEnvProbeName = `.env.t08-${randomUUID().slice(0, 8)}`; + const nestedPemName = `t08-${randomUUID().slice(0, 8)}.pem`; const tag = `personal-blog:t08-probe-${randomUUID().slice(0, 8)}`; const tmp = mkdtempSync(path.join(os.tmpdir(), 't08-layer-scan-')); - writeFileSync(probePath, `T08_PROBE_SECRET=${marker}\nPOSTGRES_PASSWORD=${marker}\n`); + const rootProbePath = path.join(REPO_ROOT, rootProbeName); + const nestedEnvProbePath = path.join(REPO_ROOT, 'apps/server', nestedEnvProbeName); + const secretsDir = path.join(REPO_ROOT, 'apps/server/secrets'); + const nestedPemPath = path.join(secretsDir, nestedPemName); + const hadSecretsDir = existsSync(secretsDir); try { + writeFileSync(rootProbePath, `T08_PROBE_ROOT_SECRET=${rootMarker}\n`); + writeFileSync(nestedEnvProbePath, `T08_PROBE_NESTED_ENV_SECRET=${nestedEnvMarker}\n`); + mkdirSync(secretsDir, { recursive: true }); + writeFileSync(nestedPemPath, `T08_PROBE_NESTED_PEM_SECRET=${nestedPemMarker}\n`); + const build = run('docker', ['build', '--file', 'apps/server/Dockerfile', '--tag', tag, '.'], { cwd: REPO_ROOT, }); @@ -638,19 +660,32 @@ test('the built image layers contain no secret values (docker build + layer scan `"tar -xf" must exit 0:\n${(untar.stdout || '')}\n${(untar.stderr || '')}`.trim(), ); - const found = anyFileContains(extractDir, [marker, ...COMPOSE_CREDENTIAL_VALUES]); + const found = anyFileContains(extractDir, [rootMarker, nestedEnvMarker, nestedPemMarker, ...COMPOSE_CREDENTIAL_VALUES]); assert.deepEqual( found, [], `the image layers must contain no secret values; found in the image: ${found.join(', ')} ` + - `(scan of every layer + image config of "${tag}"; see \`docker history ${tag}\` for the layer list)`, + `(scan of every layer + image config of "${tag}"; probe files planted at the root (${rootProbeName}) ` + + `and at nested paths (apps/server/${nestedEnvProbeName}, apps/server/secrets/${nestedPemName}) — ` + + `see \`docker history ${tag}\` for the layer list)`, ); } finally { try { - unlinkSync(probePath); + unlinkSync(rootProbePath); } catch { - // probe env file already gone + // probe file already gone } + try { + unlinkSync(nestedEnvProbePath); + } catch { + // probe file already gone + } + try { + unlinkSync(nestedPemPath); + } catch { + // probe file already gone + } + if (!hadSecretsDir) rmSync(secretsDir, { recursive: true, force: true }); rmSync(tmp, { recursive: true, force: true }); run('docker', ['image', 'rm', '-f', tag]); }