/** * 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 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 . .`, 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 * 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 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, * 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.) */ 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`. * * 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.*', '**/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). */ 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; } // --------------------------------------------------------------------------- // Docker-faithful .dockerignore matching (moby/patternmatcher port) // --------------------------------------------------------------------------- /** * 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; } continue; } out.push(part); } const result = `${rooted ? '/' : ''}${out.join('/')}`; return result === '' ? '.' : result; } /** * 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 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; } iter += 1; } let regexp = null; if (matchType === 'regexp') { regStr += '$'; regexp = new RegExp(regStr); } return { cleanedPattern: cleaned, matchType, regexp }; } /** 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); } 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; } // --------------------------------------------------------------------------- // 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)', ); 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('/')); 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', ); 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 — 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 compiled = dockerignorePatterns(dockerignore); const cleaned = compiled.map((p) => p.cleanedPattern); for (const required of SECRET_PATH_PATTERNS) { assert.ok( 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', ); } } /** * 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 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-')); 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, }); 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, [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}"; 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(rootProbePath); } catch { // 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]); } }); // --------------------------------------------------------------------------- // 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('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";'], ]); assert.throws(() => assertCopiedFilesHaveNoCredentials(contents), /postgres:\/\/eppp:eppp@db:5432\/eppp/); }); 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', ); });