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/'); +});