1
32import { describe, expect, it } from "vitest";
33import { execFileSync } from "node:child_process";
34import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
35import { tmpdir } from "node:os";
36import path from "node:path";
37import { fileURLToPath } from "node:url";
38
39const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
40const CFG = path.join(REPO_ROOT, "gitleaks.toml");
41const BASELINE = path.join(REPO_ROOT, "gitleaks-baseline.json");
42const CI = path.join(REPO_ROOT, ".github/workflows/ci.yml");
43
44const GL = process.env.GITLEAKS_BIN ?? "gitleaks";
45const glAvailable = (() => {
46 try {
47 execFileSync(GL, ["version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
48 return true;
49 } catch {
50 return false;
51 }
52})();
53const itGl = it.skipIf(!glAvailable);
54
55
59function gl(args: string[]): { code: number; stdout: string } {
60 const env: NodeJS.ProcessEnv = {
61 ...process.env,
62 GIT_CONFIG_GLOBAL: "/dev/null",
63 GIT_CONFIG_SYSTEM: "/dev/null",
64 GIT_TERMINAL_PROMPT: "0"
65 };
66 try {
67 const stdout = execFileSync(GL, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120_000 }) as string;
68 return { code: 0, stdout };
69 } catch (err) {
70 const e = err as { status?: number; stdout?: string };
71 return { code: e.status ?? 1, stdout: e.stdout ?? "" };
72 }
73}
74
75function git(cwd: string, args: string[]): void {
76 execFileSync("git", args, {
77 cwd,
78 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" },
79 stdio: ["ignore", "pipe", "pipe"],
80 timeout: 60_000
81 });
82}
83
84
85
86
87describe("gitleaks / config (gitleaks.toml)", () => {
88 it("exists and carries the pinned v8.18.4 default ruleset (no rule weakened)", () => {
89 const cfg = readFileSync(CFG, "utf8");
90
91
92
93
94 for (const id of ["aws-access-token", "generic-api-key", "private-key", "jwt", "github-pat", "npm-access-token"]) {
95 expect(cfg, `ruleset must still contain the default rule "${id}"`).toContain(`id = "${id}"`);
96 }
97 const ruleCount = (cfg.match(/\[\[rules\]\]/g) ?? []).length;
98 expect(ruleCount, "the default ruleset has ~170 rules; far fewer means rules were removed").toBeGreaterThanOrEqual(150);
99 });
100
101 it("allowlists the reserved test-value syntax BY PATTERN (spec) — fk_test_/whsec_test_/ghp_test_", () => {
102 const cfg = readFileSync(CFG, "utf8");
103
104
105
106 for (const marker of ["fk_test_", "whsec_test_", "ghp_test_"]) {
107 expect(cfg, `allowlist must contain the reserved pattern "${marker}"`).toContain(`''${marker}'''`);
108 }
109
110 expect(cfg).toMatch(/\[allowlist\]/);
111 expect(cfg).toMatch(/regexes\s*=/);
112 });
113
114 it("allowlists .env.example + the scanner's own config/baseline files", () => {
115 const cfg = readFileSync(CFG, "utf8");
116 expect(cfg).toContain(".env.example");
117 expect(cfg).toContain("gitleaks\\.toml");
118 expect(cfg).toContain("gitleaks-baseline\\.json");
119 });
120});
121
122
123
124
125describe("gitleaks / baseline (gitleaks-baseline.json)", () => {
126 type BaselineEntry = {
127 File?: string;
128 RuleID?: string;
129 Secret?: string;
130 Fingerprint?: string;
131 Commit?: string;
132 remediation?: { finding?: string; note?: string; status?: string; recorded?: string };
133 };
134
135 it("is a JSON array of gitleaks findings, each with a tracked remediation", () => {
136 const raw = readFileSync(BASELINE, "utf8");
137 const entries = JSON.parse(raw) as BaselineEntry[];
138 expect(Array.isArray(entries), "baseline must be a JSON array").toBe(true);
139
140 for (const e of entries) {
141
142 expect(typeof e.File, `entry missing File`).toBe("string");
143 expect(e.File, "File must be repo-relative").not.toMatch(/^\/|^\.\.\
144 expect(e.File, "File must not reference the scanner internals").not.toContain(".git/");
145 expect(typeof e.RuleID).toBe("string");
146 expect(typeof e.Secret).toBe("string");
147 expect(typeof e.Fingerprint).toBe("string");
148
149
150 expect(e.Fingerprint, "Fingerprint must start with a sha1 (40 hex) hash").toMatch(/^[0-9a-f]{40}:/);
151 expect(typeof e.Commit).toBe("string");
152
153
154 expect(e.remediation, `entry ${e.File} has no remediation block`).toBeTruthy();
155 expect(typeof e.remediation!.finding, `entry ${e.File} missing remediation.finding (task/finding id)`).toBe("string");
156 expect(e.remediation!.finding!.length).toBeGreaterThan(0);
157 expect(typeof e.remediation!.note).toBe("string");
158 expect(e.remediation!.note!.length, "the remediation note must not be empty").toBeGreaterThan(5);
159 expect(["accepted", "mitigated", "fixed", "review-required"], `entry ${e.File} has an unknown remediation status`).toContain(e.remediation!.status);
160 expect(/^\d{4}-\d{2}-\d{2}$/.test(e.remediation!.recorded ?? ""), `entry ${e.File} missing recorded date (YYYY-MM-DD)`).toBe(true);
161 }
162 });
163
164 it("has no entry pointing outside the repo or into a secrets file", () => {
165 const entries = JSON.parse(readFileSync(BASELINE, "utf8")) as BaselineEntry[];
166 for (const e of entries) {
167 expect(e.File, "no baseline entry may target .env (a real secret file)").not.toMatch(/^\.env$|^\.env\.local/);
168 expect(e.File, "no baseline entry may target a private-key file").not.toMatch(/\.(pem|key|p12|pfx)$/);
169 }
170 });
171});
172
173
174
175
176describe("gitleaks / detection proof (planted fixture in a scratch repo)", () => {
177 itGl(
178 "flags a real secret, allowlists fk_test_ + .env.example, baseline suppresses, new secret re-arms",
179 () => {
180 const dir = mkdtempSync(path.join(tmpdir(), "factory-gl-fixture-"));
181 git(dir, ["init", "-q"]);
182
183
184 writeFileSync(path.join(dir, "secrets.txt"), [
185 "real_key = AKIAIOSFODNN7EXAMPLE",
186 "fixture_key = fk_test_abcdef1234567890abcdef1234567890abcdef",
187 ""
188 ].join("\n"));
189 writeFileSync(path.join(dir, ".env.example"), "JWT_SECRET=change-me-in-.env\n");
190 git(dir, ["add", "."]);
191 git(dir, ["commit", "-qm", "c1"]);
192
193 const report = path.join(dir, "report.json");
194
195 const scan1 = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner", "--report-format", "json", `--report-path=${report}`]);
196 const readReport = (p: string): { RuleID: string; Secret: string; File: string }[] =>
197 existsSync(p) ? (JSON.parse(readFileSync(p, "utf8")) as { RuleID: string; Secret: string; File: string }[]) : [];
198 const findings1 = readReport(report);
199
200
201 expect(findings1.some((f) => f.Secret === "AKIAIOSFODNN7EXAMPLE"), "the planted real secret must be flagged").toBe(true);
202 expect(scan1.code, "a leak must make gitleaks exit non-zero (the CI gate)").toBe(1);
203
204 expect(findings1.some((f) => f.Secret.includes("fk_test_")), "fk_test_ fixtures must be allowlisted by pattern").toBe(false);
205 expect(findings1.some((f) => f.File.endsWith(".env.example")), ".env.example must be allowlisted").toBe(false);
206
207
208 const report2 = path.join(dir, "report2.json");
209 const scan2 = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner", "--report-format", "json", `--report-path=${report2}`, `--baseline-path=${report}`]);
210 const findings2 = readReport(report2);
211 expect(findings2, "with the baseline the planted finding must be suppressed (0 findings)").toEqual([]);
212 expect(scan2.code, "a clean scan (baseline applied) must exit 0").toBe(0);
213
214
215 writeFileSync(path.join(dir, "more.txt"), "new_secret = AKIAZZZZZZZZZZZZZZZ1\n");
216 git(dir, ["add", "."]);
217 git(dir, ["commit", "-qm", "c2"]);
218 const report3 = path.join(dir, "report3.json");
219 const scan3 = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner", "--report-format", "json", `--report-path=${report3}`, `--baseline-path=${report}`]);
220 const findings3 = readReport(report3);
221 expect(findings3.some((f) => f.Secret === "AKIAZZZZZZZZZZZZZZZ1"), "a new secret must be flagged even when a baseline exists for old ones").toBe(true);
222 expect(scan3.code, "a new leak must make gitleaks exit non-zero again").toBe(1);
223 },
224 { timeout: 180_000 }
225 );
226});
227
228
229
230
231describe("gitleaks / CI wiring (.github/workflows/ci.yml)", () => {
232 it("the security job runs the full test:security suite", () => {
233 const ci = readFileSync(CI, "utf8");
234
235 expect(ci).toMatch(/pnpm test:security/);
236
237 expect(ci).toMatch(/SEC_REQUIRE_LIVE:\s*['"]1['"]/);
238 });
239
240 it("the security job scans full git history with this config + baseline (a leaked secret fails the build)", () => {
241 const ci = readFileSync(CI, "utf8");
242
243
244
245
246
247 expect(ci).toMatch(/gitleaks[^\n]*8\.18\.4/);
248 expect(ci).toMatch(/gitleaks protect/);
249 expect(ci).toMatch(/--config gitleaks\.toml/);
250 expect(ci).toMatch(/--baseline-path gitleaks-baseline\.json/);
251 });
252
253 it("the security job builds the images and scans them for secrets", () => {
254 const ci = readFileSync(CI, "utf8");
255
256
257
258
259
260 expect(ci).toMatch(/docker build/i);
261 expect(ci).toMatch(/gitleaks protect/);
262 expect(ci).toMatch(/for app in api web worker realtime/);
263 expect(ci).toMatch(/factory-\$app:scan/);
264
265
266 expect(ci).toMatch(/\.env\b/);
267 });
268});
269