Merge pull request '[E00-S02-T08] Secrets are not embedded in image' (#389) from feature/175 into main
This commit was merged in pull request #389.
This commit is contained in:
+24
-3
@@ -3,7 +3,7 @@
|
||||
.gitignore
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build output
|
||||
@@ -11,8 +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. 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,17 @@
|
||||
# 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 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
|
||||
# build surprises, so the image must stay on a glibc base.
|
||||
|
||||
+11
-3
@@ -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,16 @@
|
||||
# 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 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
|
||||
# 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).
|
||||
|
||||
@@ -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(', ')})`,
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 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<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 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-<uuid> (root; `**/.env.*`)
|
||||
// - apps/server/.env.t08-<uuid> (nested; `**/.env.*`)
|
||||
// - apps/server/secrets/t08-<uuid>.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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user