|
|
@@ -9,19 +9,34 @@
|
|
|
|
* copies a fixed, non-secret path — never `.env` or credential files, and
|
|
|
|
* copies a fixed, non-secret path — never `.env` or credential files, and
|
|
|
|
* never a blanket `COPY . .` of the whole context; the committed
|
|
|
|
* never a blanket `COPY . .` of the whole context; the committed
|
|
|
|
* `.dockerignore` excludes local env + credential files from the build
|
|
|
|
* `.dockerignore` excludes local env + credential files from the build
|
|
|
|
* context, so a developer's secret file cannot be embedded even by
|
|
|
|
* context at the context root AND at any nested depth (`**`-prefixed
|
|
|
|
* mistake; and the committed files the Dockerfile copies into the image
|
|
|
|
* 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
|
|
|
|
* contain no default credential values. The mutation probes below prove
|
|
|
|
* the assertions are non-vacuous (adding a secret ENV/ARG, a credential
|
|
|
|
* the assertions are non-vacuous (adding a secret ENV/ARG, a credential
|
|
|
|
* URI value, a `COPY` of `.env`, a blanket `COPY . .`, or dropping a
|
|
|
|
* URI value, a `COPY` of `.env`, a blanket `COPY . .`, dropping a
|
|
|
|
* `.dockerignore` exclusion breaks the criterion).
|
|
|
|
* `.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
|
|
|
|
* - "image layers contain no secret values" → on machines with Docker, the
|
|
|
|
* real-image probe builds the committed image from the repo root with a
|
|
|
|
* real-image probe builds the committed image from the repo root with a
|
|
|
|
* marker-bearing probe env file (`.env.t08-*`, excluded by `.dockerignore`)
|
|
|
|
* marker-bearing probe env file (`.env.t08-*`, excluded by `.dockerignore`)
|
|
|
|
* present in the build context, then `docker save`s the image, extracts
|
|
|
|
* present in the build context, then `docker save`s the image, extracts
|
|
|
|
* every layer (raw + decompressed) and the image config, and confirms
|
|
|
|
* every layer (raw + decompressed) and the image config, and confirms
|
|
|
|
* neither the probe marker nor the compose default credential values
|
|
|
|
* neither the probe marker nor the compose default credential values
|
|
|
|
* appear anywhere in the layers.
|
|
|
|
* appear anywhere in the layers. CI runs this file on every PR
|
|
|
|
|
|
|
|
* (.gitea/workflows/ci.yml), so the static assertions gate merges.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* The dockerignore matcher below is a faithful port of Docker's real matcher,
|
|
|
|
|
|
|
|
* moby/patternmatcher (patternmatcher.go): every pattern is `filepath.Clean`ed
|
|
|
|
|
|
|
|
* (so `secrets` and `secrets/` are the SAME pattern), compiled to an anchored
|
|
|
|
|
|
|
|
* full-path matcher (exact / trailing-`**` prefix / leading-`**`+separator suffix /
|
|
|
|
|
|
|
|
* regexp), and a path matches when a pattern matches it OR any of its parent
|
|
|
|
|
|
|
|
* directories (docker prunes a matched directory, taking everything under it).
|
|
|
|
|
|
|
|
* It is deliberately NOT gitignore-basename matching: a slash-less pattern
|
|
|
|
|
|
|
|
* only matches at the context root.
|
|
|
|
*
|
|
|
|
*
|
|
|
|
* Run: `node --test tests/secrets-not-embedded.test.mjs`
|
|
|
|
* Run: `node --test tests/secrets-not-embedded.test.mjs`
|
|
|
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
|
|
|
* (node:test — built into Node >= 18; no dependencies, lockfile untouched.)
|
|
|
@@ -59,24 +74,54 @@ const DOCKERIGNORE_PATH = '.dockerignore';
|
|
|
|
* Env/credential path patterns that must never enter the image. The committed
|
|
|
|
* Env/credential path patterns that must never enter the image. The committed
|
|
|
|
* `.dockerignore` must exclude every one of them, and no Dockerfile `COPY` may
|
|
|
|
* `.dockerignore` must exclude every one of them, and no Dockerfile `COPY` may
|
|
|
|
* target a path matching one. Keep in sync with the committed `.dockerignore`.
|
|
|
|
* 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 = [
|
|
|
|
const SECRET_PATH_PATTERNS = [
|
|
|
|
'.env',
|
|
|
|
'**/.env',
|
|
|
|
'.env.*',
|
|
|
|
'**/.env.*',
|
|
|
|
'.npmrc',
|
|
|
|
'**/node_modules',
|
|
|
|
'.netrc',
|
|
|
|
'**/.npmrc',
|
|
|
|
'.credentials',
|
|
|
|
'**/.netrc',
|
|
|
|
'.aws',
|
|
|
|
'**/.credentials',
|
|
|
|
'.ssh',
|
|
|
|
'**/.aws',
|
|
|
|
'secrets',
|
|
|
|
'**/.ssh',
|
|
|
|
'secrets/',
|
|
|
|
'**/secrets',
|
|
|
|
'*.pem',
|
|
|
|
'**/*.pem',
|
|
|
|
'*.key',
|
|
|
|
'**/*.key',
|
|
|
|
'*.p12',
|
|
|
|
'**/*.p12',
|
|
|
|
'*.pfx',
|
|
|
|
'**/*.pfx',
|
|
|
|
'*.jks',
|
|
|
|
'**/*.jks',
|
|
|
|
'id_rsa',
|
|
|
|
'**/id_rsa',
|
|
|
|
'id_ed25519',
|
|
|
|
'**/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). */
|
|
|
|
/** Default credential values committed in compose.yaml (dev-only defaults). */
|
|
|
@@ -137,59 +182,183 @@ function copyInstructions(dockerfile) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// dockerignore-style path matching (the subset the committed patterns use)
|
|
|
|
// Docker-faithful .dockerignore matching (moby/patternmatcher port)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/** Converts a glob (`*` = non-separator run, `**` = anything, `?` = one char) to a RegExp. */
|
|
|
|
/**
|
|
|
|
function globToRegExp(pattern) {
|
|
|
|
* POSIX `filepath.Clean` for the pattern/path forms this repo uses (moby's
|
|
|
|
let re = '';
|
|
|
|
* patternmatcher runs every pattern through `filepath.Clean` before compiling
|
|
|
|
for (let i = 0; i < pattern.length; i += 1) {
|
|
|
|
* it): collapses repeated separators, resolves `.`/`..`, and drops a trailing
|
|
|
|
const ch = pattern[i];
|
|
|
|
* separator — so `secrets` and `secrets/` are the SAME pattern, and `./x`
|
|
|
|
if (ch === '*') {
|
|
|
|
* is `x`.
|
|
|
|
if (pattern[i + 1] === '*') {
|
|
|
|
*/
|
|
|
|
re += '.*';
|
|
|
|
function cleanPath(p) {
|
|
|
|
i += 1;
|
|
|
|
if (p === '') return '.';
|
|
|
|
} else {
|
|
|
|
const rooted = p.startsWith('/');
|
|
|
|
re += '[^/]*';
|
|
|
|
const out = [];
|
|
|
|
|
|
|
|
let dotdot = 0; // `..` may not backtrack past this index
|
|
|
|
|
|
|
|
for (const part of p.split('/')) {
|
|
|
|
|
|
|
|
if (part === '' || part === '.') continue;
|
|
|
|
|
|
|
|
if (part === '..') {
|
|
|
|
|
|
|
|
if (out.length > dotdot) {
|
|
|
|
|
|
|
|
out.pop();
|
|
|
|
|
|
|
|
} else if (!rooted) {
|
|
|
|
|
|
|
|
out.push('..');
|
|
|
|
|
|
|
|
dotdot = out.length;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else if (ch === '?') {
|
|
|
|
continue;
|
|
|
|
re += '[^/]';
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
out.push(part);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return new RegExp(`^${re}$`);
|
|
|
|
const result = `${rooted ? '/' : ''}${out.join('/')}`;
|
|
|
|
|
|
|
|
return result === '' ? '.' : result;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
/**
|
|
|
|
* True when `relPath` (context-relative, `/` separators) matches a
|
|
|
|
* Compiles one cleaned pattern exactly as moby/patternmatcher's `compile`
|
|
|
|
* dockerignore-style pattern: a pattern with no `/` matches any path
|
|
|
|
* does: a leading `**` followed by a separator becomes an optional
|
|
|
|
* component (docker prunes a matched directory, taking its contents); a
|
|
|
|
* "any segments" group `(.*` + separator + `)?` (or, when followed only by
|
|
|
|
* trailing `/` restricts to directories (and everything under them); `*`/`**`/
|
|
|
|
* literal chars, a suffix match that also matches the root form); a trailing
|
|
|
|
* `?` follow docker's glob rules. Supports the subset the committed
|
|
|
|
* `**` becomes a prefix match; a mid-pattern `**` becomes the same optional
|
|
|
|
* `.dockerignore` and the Dockerfile COPY sources use.
|
|
|
|
* group; `*`/`?` become `[^/]*`/`[^/]`; regexp metachars are escaped. A
|
|
|
|
|
|
|
|
* pattern with no globs is an exact full-path match.
|
|
|
|
|
|
|
|
*
|
|
|
|
|
|
|
|
* Returns `{ cleanedPattern, matchType, regexp }` with matchType one of
|
|
|
|
|
|
|
|
* 'exact' | 'prefix' | 'suffix' | 'regexp'.
|
|
|
|
*/
|
|
|
|
*/
|
|
|
|
function matchesDockerignore(relPath, pattern) {
|
|
|
|
function compilePattern(cleaned) {
|
|
|
|
const normPath = relPath.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
|
|
let regStr = '^';
|
|
|
|
let pat = pattern.replace(/^\.\//, '');
|
|
|
|
let matchType = 'exact';
|
|
|
|
const dirOnly = pat.endsWith('/');
|
|
|
|
let iter = 0; // Go scanner iteration counter (i in patternmatcher.go)
|
|
|
|
if (dirOnly) pat = pat.slice(0, -1);
|
|
|
|
let i = 0;
|
|
|
|
|
|
|
|
const n = cleaned.length;
|
|
|
|
if (dirOnly) {
|
|
|
|
while (i < n) {
|
|
|
|
if (pat.includes('/')) {
|
|
|
|
const ch = cleaned[i];
|
|
|
|
return normPath === pat || normPath.startsWith(`${pat}/`);
|
|
|
|
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';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
// A directory pattern with no slash matches a directory of that name at
|
|
|
|
} else {
|
|
|
|
// any depth — and everything under it (docker prunes matched dirs).
|
|
|
|
// Mid-pattern "**": any number of segments (incl. zero).
|
|
|
|
const re = globToRegExp(pat);
|
|
|
|
regStr += '(.*/)?';
|
|
|
|
return normPath.split('/').some((component) => re.test(component));
|
|
|
|
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 };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const re = globToRegExp(pat);
|
|
|
|
/** Matches one compiled pattern against a full cleaned path (pattern.match). */
|
|
|
|
if (!pat.includes('/')) {
|
|
|
|
function patternMatches(pattern, path) {
|
|
|
|
return normPath.split('/').some((component) => re.test(component));
|
|
|
|
const { cleanedPattern, matchType, regexp } = pattern;
|
|
|
|
|
|
|
|
if (matchType === 'exact') return path === cleanedPattern;
|
|
|
|
|
|
|
|
if (matchType === 'prefix') return path.startsWith(cleanedPattern.slice(0, -2));
|
|
|
|
|
|
|
|
if (matchType === 'suffix') {
|
|
|
|
|
|
|
|
const suffix = cleanedPattern.slice(2);
|
|
|
|
|
|
|
|
if (path.endsWith(suffix)) return true;
|
|
|
|
|
|
|
|
// "**/foo" also matches the bare "foo" at the context root.
|
|
|
|
|
|
|
|
return suffix.startsWith('/') && path === suffix.slice(1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return re.test(normPath);
|
|
|
|
if (matchType === 'regexp') return regexp.test(path);
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Parses `.dockerignore` text the way docker does (trim, skip blanks and `#`
|
|
|
|
|
|
|
|
* comments) and compiles every pattern via the moby/patternmatcher pipeline
|
|
|
|
|
|
|
|
* (TrimSpace, filepath.Clean, optional `!` exclusion prefix).
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function dockerignorePatterns(dockerignoreText) {
|
|
|
|
|
|
|
|
const patterns = [];
|
|
|
|
|
|
|
|
for (const rawLine of dockerignoreText.split(/\r?\n/)) {
|
|
|
|
|
|
|
|
let line = rawLine.trim();
|
|
|
|
|
|
|
|
if (line === '' || line.startsWith('#')) continue;
|
|
|
|
|
|
|
|
line = cleanPath(line);
|
|
|
|
|
|
|
|
let exclusion = false;
|
|
|
|
|
|
|
|
if (line.startsWith('!')) {
|
|
|
|
|
|
|
|
if (line.length === 1) throw new Error('illegal exclusion pattern: "!"');
|
|
|
|
|
|
|
|
exclusion = true;
|
|
|
|
|
|
|
|
line = line.slice(1);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
patterns.push({ exclusion, ...compilePattern(line) });
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return patterns;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
|
|
* Docker's MatchesOrParentMatches: true when `relPath` (context-relative) is
|
|
|
|
|
|
|
|
* excluded by any of the compiled patterns. A pattern matches the full cleaned
|
|
|
|
|
|
|
|
* path OR any of its parent directories (docker prunes a matched directory,
|
|
|
|
|
|
|
|
* taking everything under it). A slash-less pattern is anchored to the context
|
|
|
|
|
|
|
|
* ROOT — exactly as in moby/patternmatcher — so only `**`-prefixed patterns
|
|
|
|
|
|
|
|
* reach nested paths.
|
|
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
function matchesDockerignore(patterns, relPath) {
|
|
|
|
|
|
|
|
const file = cleanPath(relPath);
|
|
|
|
|
|
|
|
if (file === '.') return false;
|
|
|
|
|
|
|
|
const parent = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '.';
|
|
|
|
|
|
|
|
const parentDirs = parent === '.' ? [] : parent.split('/');
|
|
|
|
|
|
|
|
let matched = false;
|
|
|
|
|
|
|
|
for (const pattern of patterns) {
|
|
|
|
|
|
|
|
if (pattern.exclusion !== matched) continue;
|
|
|
|
|
|
|
|
let m = patternMatches(pattern, file);
|
|
|
|
|
|
|
|
if (!m && parent !== '.') {
|
|
|
|
|
|
|
|
for (let i = 0; i < parentDirs.length; i += 1) {
|
|
|
|
|
|
|
|
m = patternMatches(pattern, parentDirs.slice(0, i + 1).join('/'));
|
|
|
|
|
|
|
|
if (m) break;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if (m) matched = !pattern.exclusion;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return matched;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
@@ -232,6 +401,7 @@ function assertNoSecretsEmbedded(dockerfile) {
|
|
|
|
copies.length > 0,
|
|
|
|
copies.length > 0,
|
|
|
|
'the Dockerfile must declare COPY instructions (the app files must reach the image)',
|
|
|
|
'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) {
|
|
|
|
for (const copy of copies) {
|
|
|
|
const tokens = copy.split(/\s+/).filter((token) => !token.startsWith('--from='));
|
|
|
|
const tokens = copy.split(/\s+/).filter((token) => !token.startsWith('--from='));
|
|
|
|
const sources = tokens.slice(0, -1).filter((token) => !token.startsWith('/'));
|
|
|
|
const sources = tokens.slice(0, -1).filter((token) => !token.startsWith('/'));
|
|
|
@@ -241,31 +411,49 @@ function assertNoSecretsEmbedded(dockerfile) {
|
|
|
|
'.',
|
|
|
|
'.',
|
|
|
|
'the Dockerfile must not COPY the whole build context (COPY . ...) — env/credential files could be swept into the image',
|
|
|
|
'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(
|
|
|
|
assert.ok(
|
|
|
|
!matchesDockerignore(src, pattern),
|
|
|
|
!matchesDockerignore(secretPatterns, src),
|
|
|
|
`the Dockerfile COPY must not copy a secret/credential path (got "${src}", matches "${pattern}")`,
|
|
|
|
`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`
|
|
|
|
* Asserts the committed `.dockerignore` excludes every `SECRET_PATH_PATTERNS`
|
|
|
|
* entry, so a developer's env/credential files never enter the build context —
|
|
|
|
* entry — at the context root AND at any nested depth — so a developer's
|
|
|
|
* the first line of defense against embedding secrets in the image.
|
|
|
|
* 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) {
|
|
|
|
function assertDockerignoreExcludesSecrets(dockerignore) {
|
|
|
|
const patterns = dockerignore
|
|
|
|
const compiled = dockerignorePatterns(dockerignore);
|
|
|
|
.split(/\r?\n/)
|
|
|
|
const cleaned = compiled.map((p) => p.cleanedPattern);
|
|
|
|
.map((line) => line.trim())
|
|
|
|
|
|
|
|
.filter((line) => line && !line.startsWith('#'));
|
|
|
|
|
|
|
|
for (const required of SECRET_PATH_PATTERNS) {
|
|
|
|
for (const required of SECRET_PATH_PATTERNS) {
|
|
|
|
assert.ok(
|
|
|
|
assert.ok(
|
|
|
|
patterns.includes(required),
|
|
|
|
cleaned.includes(required),
|
|
|
|
`.dockerignore must exclude "${required}" so env/credential files never enter the build context ` +
|
|
|
|
`.dockerignore must exclude "${required}" (the **/-prefixed form, so the pattern applies at the ` +
|
|
|
|
`(got: ${patterns.join(', ')})`,
|
|
|
|
`context root AND any nested depth — Docker's matcher anchors slash-less patterns to the root) ` +
|
|
|
|
|
|
|
|
`(got: ${cleaned.join(', ')})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
assert.equal(
|
|
|
|
|
|
|
|
new Set(cleaned).size,
|
|
|
|
|
|
|
|
cleaned.length,
|
|
|
|
|
|
|
|
`.dockerignore must not contain redundant equivalent patterns (two patterns that clean to the same ` +
|
|
|
|
|
|
|
|
`path, e.g. \`secrets\` and \`secrets/\`, add nothing) (got: ${cleaned.join(', ')})`,
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
for (const [pattern, example] of SECRET_PATH_EXAMPLES) {
|
|
|
|
|
|
|
|
assert.ok(
|
|
|
|
|
|
|
|
matchesDockerignore(compiled, example),
|
|
|
|
|
|
|
|
`.dockerignore must exclude the nested example path "${example}" (via "${pattern}") so a local ` +
|
|
|
|
|
|
|
|
'secret file cannot be embedded even by mistake',
|
|
|
|
);
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
@@ -521,20 +709,39 @@ test('a blanket COPY of the whole build context fails the no-secrets criterion (
|
|
|
|
assert.throws(() => assertNoSecretsEmbedded(mutated), /whole build context/);
|
|
|
|
assert.throws(() => assertNoSecretsEmbedded(mutated), /whole build context/);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('removing .env.* from .dockerignore fails the exclusion criterion (mutation probe)', () => {
|
|
|
|
test('removing **/.env.* from .dockerignore fails the exclusion criterion (mutation probe)', () => {
|
|
|
|
const dockerignore = read(DOCKERIGNORE_PATH);
|
|
|
|
const dockerignore = read(DOCKERIGNORE_PATH);
|
|
|
|
const mutated = dockerignore.replace(/^\.env\.\*\s*$/m, '');
|
|
|
|
const mutated = dockerignore.replace(/^\*\*\/\.env\.\*\s*$/m, '');
|
|
|
|
assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the .env.* pattern');
|
|
|
|
assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the **/.env.* pattern');
|
|
|
|
assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.env\.\*/);
|
|
|
|
assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.env\.\*/);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('removing a credential pattern (*.key) from .dockerignore fails the exclusion criterion (mutation probe)', () => {
|
|
|
|
test('removing a credential pattern (**/*.key) from .dockerignore fails the exclusion criterion (mutation probe)', () => {
|
|
|
|
const dockerignore = read(DOCKERIGNORE_PATH);
|
|
|
|
const dockerignore = read(DOCKERIGNORE_PATH);
|
|
|
|
const mutated = dockerignore.replace(/^\*\.key\s*$/m, '');
|
|
|
|
const mutated = dockerignore.replace(/^\*\*\/\*\.key\s*$/m, '');
|
|
|
|
assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the *.key pattern');
|
|
|
|
assert.notEqual(mutated, dockerignore, 'the mutation must actually remove the **/*.key pattern');
|
|
|
|
assert.throws(() => assertDockerignoreExcludesSecrets(mutated), /\.key/);
|
|
|
|
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)', () => {
|
|
|
|
test('a copied committed file containing a default credential value fails (mutation probe)', () => {
|
|
|
|
const contents = new Map([
|
|
|
|
const contents = new Map([
|
|
|
|
['apps/server/src/index.ts', 'const url = "postgres://eppp:eppp@db:5432/eppp";'],
|
|
|
|
['apps/server/src/index.ts', 'const url = "postgres://eppp:eppp@db:5432/eppp";'],
|
|
|
@@ -542,14 +749,60 @@ test('a copied committed file containing a default credential value fails (mutat
|
|
|
|
assert.throws(() => assertCopiedFilesHaveNoCredentials(contents), /postgres:\/\/eppp:eppp@db:5432\/eppp/);
|
|
|
|
assert.throws(() => assertCopiedFilesHaveNoCredentials(contents), /postgres:\/\/eppp:eppp@db:5432\/eppp/);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
test('the dockerignore matcher excludes probe env files and keeps source files (parser probe)', () => {
|
|
|
|
test('the dockerignore matcher is Docker-faithful and excludes nested credential paths (parser probe)', () => {
|
|
|
|
assert.ok(matchesDockerignore('.env.t08-probe', '.env.*'), '.env.* must match probe env files');
|
|
|
|
const patterns = dockerignorePatterns(read(DOCKERIGNORE_PATH));
|
|
|
|
assert.ok(matchesDockerignore('.env', '.env'), '.env must match the exact file');
|
|
|
|
|
|
|
|
assert.ok(!matchesDockerignore('.env.production', '.env'), '.env must not match .env.production');
|
|
|
|
// The nested example paths the acceptance criteria call out are excluded.
|
|
|
|
assert.ok(!matchesDockerignore('apps/server/src/index.ts', '.env'), 'source files must not match .env');
|
|
|
|
assert.ok(matchesDockerignore(patterns, 'apps/server/.npmrc'), 'apps/server/.npmrc must be excluded');
|
|
|
|
assert.ok(matchesDockerignore('secrets/credentials.txt', 'secrets'), 'a path under secrets/ must be excluded');
|
|
|
|
assert.ok(matchesDockerignore(patterns, 'config/server.key'), 'config/server.key must be excluded');
|
|
|
|
assert.ok(matchesDockerignore('config/secrets/credentials.txt', 'secrets'), 'nested secrets/ dirs must be excluded');
|
|
|
|
assert.ok(matchesDockerignore(patterns, 'apps/server/secrets/db.pem'), 'apps/server/secrets/db.pem must be excluded');
|
|
|
|
assert.ok(matchesDockerignore('config/server.key', '*.key'), '*.key must match a key file at any depth');
|
|
|
|
assert.ok(matchesDockerignore(patterns, 'apps/server/.env'), 'apps/server/.env must be excluded');
|
|
|
|
assert.ok(matchesDockerignore('secrets/credentials.txt', 'secrets/'), 'secrets/ must exclude everything under it');
|
|
|
|
assert.ok(matchesDockerignore(patterns, 'apps/server/.env.local'), 'apps/server/.env.local must be excluded');
|
|
|
|
assert.ok(!matchesDockerignore('apps/server/package.json', 'secrets/'), 'source files must not match secrets/');
|
|
|
|
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',
|
|
|
|
|
|
|
|
);
|
|
|
|
});
|
|
|
|
});
|
|
|
|