/** * TASK-0134 — secrets scan (gitleaks) configuration + baseline + CI wiring. * * The authoritative full-git-history scan runs in CI via * `gitleaks/gitleaks-action@v2` (config `gitleaks.toml`, baseline * `gitleaks-baseline.json`) — a full-history `detect` over 135 commits takes * ~8 min and is the CI job's job, not this suite's. This suite makes the * *gate itself* verifiable anywhere: * * 1. `gitleaks.toml` (config) — the pinned v8.18.4 default ruleset is * UNMODIFIED (no rule weakened/removed) and the allowlist carries the * spec-required reserved test-value patterns (`fk_test_*`, * `whsec_test_*`, `ghp_test_*` — allowlisted BY PATTERN, not by file) + * the `.env.example`/config-file path allowlist. * 2. `gitleaks-baseline.json` (baseline) — valid gitleaks report format * (loads + suppresses via `--baseline-path`) where EVERY entry is a * tracked remediation: `{File, RuleID, Secret, Fingerprint, Commit, * remediation:{finding, note, status, recorded}}`. The spec: "baseline * file for any pre-existing finding (each baseline entry = a tracked * remediation task)". * 3. Detection proof (fixture, runs when a gitleaks binary is available — * `GITLEAKS_BIN` or on PATH; CI installs it): a scratch git repo with a * planted REAL secret + an `fk_test_` value + a `.env.example` must flag * the real secret and NOT the fixture/example; the scan report used as a * baseline must suppress it; a NEW secret in a later commit must re-arm * the gate (proves the baseline does not become a blanket ignore). * 4. CI wiring — the `security` job runs `pnpm test:security` (all 10 * suites), the gitleaks-action with this config, and the image * build+scan step (5 images per spec; the 5th "test image" has no * Dockerfile in the repo — recorded N/A in the audit report). */ import { describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, writeFileSync } 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 BASELINE = path.join(REPO_ROOT, "gitleaks-baseline.json"); const CI = path.join(REPO_ROOT, ".github/workflows/ci.yml"); const GL = process.env.GITLEAKS_BIN ?? "gitleaks"; const glAvailable = (() => { try { execFileSync(GL, ["version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); return true; } catch { return false; } })(); const itGl = it.skipIf(!glAvailable); /** Run gitleaks with a sanitized git env. Returns the exit code (gitleaks * `detect` exits 1 when leaks are found, 0 when clean) + any captured output. * NOTE: execFileSync discards stderr on the success path, so the exit code * + `--report-path` file are the authoritative signals (as in CI). */ function gl(args: string[]): { code: number; stdout: string } { const env: NodeJS.ProcessEnv = { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_TERMINAL_PROMPT: "0" }; try { const stdout = execFileSync(GL, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 }) as string; return { code: 0, stdout }; } catch (err) { const e = err as { status?: number; stdout?: string }; return { code: e.status ?? 1, stdout: e.stdout ?? "" }; } } function git(cwd: string, args: string[]): void { execFileSync("git", args, { cwd, env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_SYSTEM: "/dev/null", GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" }, stdio: ["ignore", "pipe", "pipe"], timeout: 60_000 }); } // --------------------------------------------------------------------------- // 1. Config — pinned ruleset (unweakened) + the spec's test-value allowlist // --------------------------------------------------------------------------- describe("gitleaks / config (gitleaks.toml)", () => { it("exists and carries the pinned v8.18.4 default ruleset (no rule weakened)", () => { const cfg = readFileSync(CFG, "utf8"); // The default ruleset is ~170 [[rules]] blocks (v8.18.4); a handful of // the canonical ids must be present (their absence = a weakened ruleset). // (ids verified against the pinned v8.18.4 config — later versions // renamed e.g. aws-access-token → aws-secret-access-key) for (const id of ["aws-access-token", "generic-api-key", "private-key", "jwt", "github-pat", "npm-access-token"]) { expect(cfg, `ruleset must still contain the default rule "${id}"`).toContain(`id = "${id}"`); } const ruleCount = (cfg.match(/\[\[rules\]\]/g) ?? []).length; expect(ruleCount, "the default ruleset has ~170 rules; far fewer means rules were removed").toBeGreaterThanOrEqual(150); }); it("allowlists the reserved test-value syntax BY PATTERN (spec) — fk_test_/whsec_test_/ghp_test_", () => { const cfg = readFileSync(CFG, "utf8"); // The spec: "test fixtures with fk_test_…/whsec_test_… values are // allowlisted by pattern, not by file (test-value syntax is reserved: // *_test_*)". Each marker must be a global allowlist regex: for (const marker of ["fk_test_", "whsec_test_", "ghp_test_"]) { expect(cfg, `allowlist must contain the reserved pattern "${marker}"`).toContain(`''${marker}'''`); } // And it must live in the allowlist, not be a disabled rule: expect(cfg).toMatch(/\[allowlist\]/); expect(cfg).toMatch(/regexes\s*=/); }); it("allowlists .env.example + the scanner's own config/baseline files", () => { const cfg = readFileSync(CFG, "utf8"); expect(cfg).toContain(".env.example"); expect(cfg).toContain("gitleaks\\.toml"); expect(cfg).toContain("gitleaks-baseline\\.json"); }); }); // --------------------------------------------------------------------------- // 2. Baseline — loadable gitleaks report + every entry a tracked remediation // --------------------------------------------------------------------------- describe("gitleaks / baseline (gitleaks-baseline.json)", () => { type BaselineEntry = { File?: string; RuleID?: string; Secret?: string; Fingerprint?: string; Commit?: string; remediation?: { finding?: string; note?: string; status?: string; recorded?: string }; }; it("is a JSON array of gitleaks findings, each with a tracked remediation", () => { const raw = readFileSync(BASELINE, "utf8"); const entries = JSON.parse(raw) as BaselineEntry[]; expect(Array.isArray(entries), "baseline must be a JSON array").toBe(true); for (const e of entries) { // gitleaks report fields (so --baseline-path can load + suppress): expect(typeof e.File, `entry missing File`).toBe("string"); expect(e.File, "File must be repo-relative").not.toMatch(/^\/|^\.\.\//); expect(e.File, "File must not reference the scanner internals").not.toContain(".git/"); expect(typeof e.RuleID).toBe("string"); expect(typeof e.Secret).toBe("string"); expect(typeof e.Fingerprint).toBe("string"); // gitleaks fingerprint = ":::"; the leading sha1 // is the stable dedupe key the baseline matches on: expect(e.Fingerprint, "Fingerprint must start with a sha1 (40 hex) hash").toMatch(/^[0-9a-f]{40}:/); expect(typeof e.Commit).toBe("string"); // spec: each baseline entry = a tracked remediation task: expect(e.remediation, `entry ${e.File} has no remediation block`).toBeTruthy(); expect(typeof e.remediation!.finding, `entry ${e.File} missing remediation.finding (task/finding id)`).toBe("string"); expect(e.remediation!.finding!.length).toBeGreaterThan(0); expect(typeof e.remediation!.note).toBe("string"); expect(e.remediation!.note!.length, "the remediation note must not be empty").toBeGreaterThan(5); expect(["accepted", "mitigated", "fixed", "review-required"], `entry ${e.File} has an unknown remediation status`).toContain(e.remediation!.status); expect(/^\d{4}-\d{2}-\d{2}$/.test(e.remediation!.recorded ?? ""), `entry ${e.File} missing recorded date (YYYY-MM-DD)`).toBe(true); } }); it("has no entry pointing outside the repo or into a secrets file", () => { const entries = JSON.parse(readFileSync(BASELINE, "utf8")) as BaselineEntry[]; for (const e of entries) { expect(e.File, "no baseline entry may target .env (a real secret file)").not.toMatch(/^\.env$|^\.env\.local/); expect(e.File, "no baseline entry may target a private-key file").not.toMatch(/\.(pem|key|p12|pfx)$/); } }); }); // --------------------------------------------------------------------------- // 3. Detection proof (fixture) — the config + baseline actually gate // --------------------------------------------------------------------------- describe("gitleaks / detection proof (planted fixture in a scratch repo)", () => { itGl( "flags a real secret, allowlists fk_test_ + .env.example, baseline suppresses, new secret re-arms", () => { const dir = mkdtempSync(path.join(tmpdir(), "factory-gl-fixture-")); git(dir, ["init", "-q"]); // commit 1: one REAL secret + reserved-syntax fixture + .env.example. writeFileSync(path.join(dir, "secrets.txt"), [ "real_key = AKIAIOSFODNN7EXAMPLE", "fixture_key = fk_test_abcdef1234567890abcdef1234567890abcdef", "" ].join("\n")); writeFileSync(path.join(dir, ".env.example"), "JWT_SECRET=change-me-in-.env\n"); git(dir, ["add", "."]); git(dir, ["commit", "-qm", "c1"]); const report = path.join(dir, "report.json"); // gitleaks writes findings to --report-path (exit 1 when leaks found): const scan1 = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner", "--report-format", "json", `--report-path=${report}`]); const readReport = (p: string): { RuleID: string; Secret: string; File: string }[] => existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as { RuleID: string; Secret: string; File: string }[]) : []; const findings1 = readReport(report); // 1a. the REAL secret is flagged (and gitleaks reports a leak → exit 1): expect(findings1.some((f) => f.Secret === "AKIAIOSFODNN7EXAMPLE"), "the planted real secret must be flagged").toBe(true); expect(scan1.code, "a leak must make gitleaks exit non-zero (the CI gate)").toBe(1); // 1b. the reserved-syntax fixture + .env.example are NOT flagged: expect(findings1.some((f) => f.Secret.includes("fk_test_")), "fk_test_ fixtures must be allowlisted by pattern").toBe(false); expect(findings1.some((f) => f.File.endsWith(".env.example")), ".env.example must be allowlisted").toBe(false); // 2. the scan report used as a baseline suppresses the finding: const report2 = path.join(dir, "report2.json"); const scan2 = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner", "--report-format", "json", `--report-path=${report2}`, `--baseline-path=${report}`]); const findings2 = readReport(report2); expect(findings2, "with the baseline the planted finding must be suppressed (0 findings)").toEqual([]); expect(scan2.code, "a clean scan (baseline applied) must exit 0").toBe(0); // 3. a NEW secret in a later commit re-arms the gate (no blanket ignore): writeFileSync(path.join(dir, "more.txt"), "new_secret = AKIAZZZZZZZZZZZZZZZ1\n"); git(dir, ["add", "."]); git(dir, ["commit", "-qm", "c2"]); const report3 = path.join(dir, "report3.json"); const scan3 = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner", "--report-format", "json", `--report-path=${report3}`, `--baseline-path=${report}`]); const findings3 = readReport(report3); expect(findings3.some((f) => f.Secret === "AKIAZZZZZZZZZZZZZZZ1"), "a new secret must be flagged even when a baseline exists for old ones").toBe(true); expect(scan3.code, "a new leak must make gitleaks exit non-zero again").toBe(1); }, { timeout: 180_000 } ); }); // --------------------------------------------------------------------------- // 4. CI wiring — the security job enforces the gate // --------------------------------------------------------------------------- describe("gitleaks / CI wiring (.github/workflows/ci.yml)", () => { it("the security job runs the full test:security suite", () => { const ci = readFileSync(CI, "utf8"); // The security stage runs all 10 suites: expect(ci).toMatch(/pnpm test:security/); // Live-app suites must FAIL (not skip) when the stack is down in CI: expect(ci).toMatch(/SEC_REQUIRE_LIVE:\s*['"]1['"]/); }); it("the security job scans full git history with this config + baseline (a leaked secret fails the build)", () => { const ci = readFileSync(CI, "utf8"); // The gitleaks binary is pinned, and the full-history `protect` runs with // the pinned config + the tracked-remediation baseline. Without the // baseline, the pre-existing findings would (correctly) fail the build — // the baseline is what makes the gate pass while still catching ANY new // secret (a new finding not in the baseline → non-zero exit → red build). expect(ci).toMatch(/gitleaks[^\n]*8\.18\.4/); expect(ci).toMatch(/gitleaks protect/); expect(ci).toMatch(/--config gitleaks\.toml/); expect(ci).toMatch(/--baseline-path gitleaks-baseline\.json/); }); it("the security job builds the images and scans them for secrets", () => { const ci = readFileSync(CI, "utf8"); // Image build + rootfs secret scan (spec: "the image-scan step runs on all // 5 images ... and is clean"). The four app images are built in a loop // (`factory-$app:scan` for app in api web worker realtime) and each rootfs // is gitleaks-scanned; the 5th "test image" has no Dockerfile (recorded // N/A in the audit report). expect(ci).toMatch(/docker build/i); expect(ci).toMatch(/gitleaks protect/); expect(ci).toMatch(/for app in api web worker realtime/); expect(ci).toMatch(/factory-\$app:scan/); // No image may ship a .env (the .dockerignore keeps the context clean — // the scan asserts it): expect(ci).toMatch(/\.env\b/); }); });