/** * TASK-0134 — container image secret scan (OWASP ASVS L2 §7 / "the image-scan * step runs on all 5 images ... and is clean"). * * The repo builds exactly FOUR app images (api, web, worker, realtime). The * spec names a fifth "test image" but no Dockerfile for one exists in the * repo — recorded N/A-with-justification in docs/security/audit-report.md. * * 1. Static (always runs): the four Dockerfiles copy no secret into the * image. Three use explicit `COPY `; the worker uses a blanket * `COPY . .` — which is only safe because a root `.dockerignore` excludes * `.env` (live secrets), `.git` (full history), `node_modules`, build * output, and key material. That finding (F-120) and its fix (the new * `.dockerignore`) are asserted here: remove the `.dockerignore` and the * worker image would ship `.env` + `.git`. * 2. Base image pinned to node:20-alpine (no floating `latest`). * 3. CI wiring — the security job builds all four images and scans each * rootfs with gitleaks (the authoritative scan, run where docker + the * base image are available). * 4. Live (docker-gated): build the four images, export each rootfs, and * gitleaks-scan it + assert no `.env` is baked in. Skips locally where * docker or the base image is unavailable (CI runs it). */ import { describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const CFG = path.join(REPO_ROOT, "gitleaks.toml"); const CI = path.join(REPO_ROOT, ".github/workflows/ci.yml"); const APPS = ["api", "web", "worker", "realtime"] as const; const GL = process.env.GITLEAKS_BIN ?? "gitleaks"; // A path that must never be baked into an image. const SECRET_SRC = /(^|\/)\.env(\.local|\.production|\.ci)?$|(^|\/)\.env$|\.pem$|\.key$|\.p12$|\.pfx$|(^|\/)secrets\/|^id_rsa/; // A blanket copy (the only way `.env`/`.git` would leak without the ignore file). const BLANKET_SRC = /^\.\.?$/; function gl(args: string[]): { code: number } { const env: NodeJS.ProcessEnv = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_TERMINAL_PROMPT: "0" }; try { execFileSync(GL, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 180_000 }); return { code: 0 }; } catch (err) { return { code: (err as { status?: number }).status ?? 1 }; } } /** docker + the pinned base image available? (CI yes; local no — pull denied) */ function dockerReady(): boolean { try { execFileSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: ["ignore", "pipe", "pipe"] }); execFileSync("docker", ["image", "inspect", "node:20-alpine"], { stdio: ["ignore", "pipe", "pipe"] }); return true; } catch { return false; } } const itDocker = it.skipIf(!dockerReady()); // --------------------------------------------------------------------------- // 1. Static — no secret is copied into any image // --------------------------------------------------------------------------- describe("images / Dockerfile static analysis (no secret in the image)", () => { it("exactly four app Dockerfiles exist (the spec's 5th 'test image' has none)", () => { for (const app of APPS) { expect(existsSync(path.join(REPO_ROOT, "apps", app, "Dockerfile")), `apps/${app}/Dockerfile must exist`).toBe(true); } // The spec's 5th "test image" — no Dockerfile anywhere else in the repo: const all = findDockerfiles(REPO_ROOT); const inApps = all.filter((p) => allDockerfileApps(p)); expect(inApps.length, "no Dockerfile may exist outside apps/ (there is no 'test image' to build)").toBe(4); }); for (const app of APPS) { it(`apps/${app}/Dockerfile copies no secret and pins the base image`, () => { const lines = readFileSync(path.join(REPO_ROOT, "apps", app, "Dockerfile"), "utf8").split("\n"); // Base image pinned (no floating `latest`, no other base): const froms = lines.filter((l) => /^FROM\s/i.test(l)).map((l) => l.trim()); expect(froms.length, "at least one FROM").toBeGreaterThan(0); for (const f of froms) { // `FROM node:20-alpine` or `FROM node:20-alpine AS `: expect(f, `${app}: base image must be node:20-alpine (pinned), got "${f}"`).toMatch(/^FROM\s+node:20-alpine(\s+AS\s+\w+)?\s*$/i); expect(f, `${app}: no floating 'latest' base`).not.toMatch(/:latest/i); } // COPY/ADD source paths: let sawBlanket = false; for (const line of lines) { const m = line.match(/^\s*(?:COPY|ADD)\s+(.+)$/i); if (!m) continue; const tokens = (m[1] ?? "").split(/\s+/).filter(Boolean); // drop --flag args (e.g. --from=build): const args = tokens.filter((t) => !t.startsWith("--")); const dest = args[args.length - 1]; const sources = args.slice(0, -1); for (const src of sources) { if (BLANKET_SRC.test(src)) { sawBlanket = true; continue; } expect(src, `${app}: Dockerfile must not COPY a secret path "${src}" (dest ${dest})`).not.toMatch(SECRET_SRC); } } // A blanket `COPY . .` is safe only via the root .dockerignore: if (sawBlanket) { const di = readFileSync(path.join(REPO_ROOT, ".dockerignore"), "utf8"); for (const req of [".env", ".git", "node_modules", "dist", "*.pem", "*.key"]) { expect(diHasLine(di, req), `${app}: blanket COPY . . requires .dockerignore to exclude "${req}"`).toBe(true); } } }); } }); // --------------------------------------------------------------------------- // 2. .dockerignore exists (the fix for the worker COPY . . finding) // --------------------------------------------------------------------------- describe("images / .dockerignore (F-120 fix)", () => { it("excludes live secrets, VCS history, deps and build output from the context", () => { expect(existsSync(path.join(REPO_ROOT, ".dockerignore")), "a root .dockerignore must exist (worker does COPY . .)").toBe(true); const di = readFileSync(path.join(REPO_ROOT, ".dockerignore"), "utf8"); // Secrets + VCS + deps + output — each must be a standalone entry line: for (const req of [".env", ".git", "node_modules", "dist", "*.pem", "*.key"]) { expect(diHasLine(di, req), `.dockerignore must exclude "${req}"`).toBe(true); } }); }); // --------------------------------------------------------------------------- // 3. CI wiring — all four images built + rootfs-scanned // --------------------------------------------------------------------------- describe("images / CI wiring (.github/workflows/ci.yml)", () => { it("the security job builds all four images and scans each rootfs for secrets", () => { const ci = readFileSync(CI, "utf8"); expect(ci).toMatch(/for app in api web worker realtime/); expect(ci).toMatch(/docker build -f "apps\/\$app\/Dockerfile"/); expect(ci).toMatch(/docker export/i); expect(ci).toMatch(/gitleaks detect/); // And it fails if a .env is baked into any image: expect(ci).toMatch(/FAIL: factory-\$app ships a \.env file/); }); }); // --------------------------------------------------------------------------- // 4. Live — build the four images + scan each rootfs (docker-gated) // --------------------------------------------------------------------------- describe("images / live build + rootfs secret scan", () => { itDocker( "builds each image and its rootfs is secret-free (no .env, no gitleaks findings)", () => { for (const app of APPS) { const tag = `factory-${app}:scan`; execFileSync("docker", ["build", "-f", `apps/${app}/Dockerfile`, "-t", tag, "."], { cwd: REPO_ROOT, stdio: ["ignore", "pipe", "pipe"], timeout: 1_200_000 }); const container = execFileSync("docker", ["create", tag], { encoding: "utf8" }).trim(); try { // 4a. no live .env may be in the rootfs: const listing = execFileSync("sh", ["-c", "docker export '" + container + "' | tar -tf - 2>/dev/null"], { encoding: "utf8" }); expect(listing, `${tag}: no .env may be baked into the image`).not.toMatch(/(^|\/)\.env$|(^|\/)\.env\.(local|production|ci)$/); // 4b. gitleaks over the extracted rootfs: const dir = mkdtempSync(path.join(tmpdir(), `factory-img-${app}-`)); execFileSync("sh", ["-c", `docker export '${container}' | tar -xf - -C '${dir}'`], { stdio: ["ignore", "pipe", "pipe"] }); const code = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner"]); expect(code, `${tag}: gitleaks found secrets in the rootfs`).toBe(0); } finally { execFileSync("docker", ["rm", "-f", container], { stdio: ["ignore", "pipe", "pipe"] }); execFileSync("docker", ["rmi", "-f", tag], { stdio: ["ignore", "pipe", "pipe"] }); } } }, { timeout: 1_500_000 } ); }); // ---- helpers -------------------------------------------------------------- /** True if the .dockerignore has a line that is exactly `entry` (trimmed). */ function diHasLine(di: string, entry: string): boolean { return di.split("\n").some((l) => l.trim() === entry); } function findDockerfiles(root: string): string[] { const out: string[] = []; const walk = (d: string) => { for (const name of readdirSync(d, { withFileTypes: true })) { if (name.name === "node_modules" || name.name === ".git") continue; const p = path.join(d, name.name); if (name.isDirectory()) walk(p); else if (/^Dockerfile/.test(name.name)) out.push(p); } }; walk(root); return out; } function allDockerfileApps(p: string): boolean { const rel = path.relative(REPO_ROOT, p).split(path.sep); return rel.length === 3 && rel[0] === "apps" && (rel[2] ?? "").startsWith("Dockerfile"); }