CI / Frozen lockfile install (pull_request) Successful in 50s
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.
556 lines
22 KiB
JavaScript
556 lines
22 KiB
JavaScript
/**
|
|
* 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<relPath, text>`. 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/');
|
|
});
|