1
24import { describe, expect, it } from "vitest";
25import { execFileSync } from "node:child_process";
26import { existsSync, mkdtempSync, readFileSync, readdirSync } from "node:fs";
27import { tmpdir } from "node:os";
28import path from "node:path";
29import { fileURLToPath } from "node:url";
30
31const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
32const CFG = path.join(REPO_ROOT, "gitleaks.toml");
33const CI = path.join(REPO_ROOT, ".github/workflows/ci.yml");
34const APPS = ["api", "web", "worker", "realtime"] as const;
35
36const GL = process.env.GITLEAKS_BIN ?? "gitleaks";
37
38
39const SECRET_SRC = /(^|\/)\.env(\.local|\.production|\.ci)?$|(^|\/)\.env$|\.pem$|\.key$|\.p12$|\.pfx$|(^|\/)secrets\/|^id_rsa/;
40
41const BLANKET_SRC = /^\.\.?$/;
42
43function gl(args: string[]): { code: number } {
44 const env: NodeJS.ProcessEnv = {
45 ...process.env,
46 GIT_CONFIG_GLOBAL: "/dev/null",
47 GIT_CONFIG_SYSTEM: "/dev/null",
48 GIT_TERMINAL_PROMPT: "0"
49 };
50 try {
51 execFileSync(GL, args, { env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 180_000 });
52 return { code: 0 };
53 } catch (err) {
54 return { code: (err as { status?: number }).status ?? 1 };
55 }
56}
57
58
59function dockerReady(): boolean {
60 try {
61 execFileSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: ["ignore", "pipe", "pipe"] });
62 execFileSync("docker", ["image", "inspect", "node:20-alpine"], { stdio: ["ignore", "pipe", "pipe"] });
63 return true;
64 } catch {
65 return false;
66 }
67}
68const itDocker = it.skipIf(!dockerReady());
69
70
71
72
73describe("images / Dockerfile static analysis (no secret in the image)", () => {
74 it("exactly four app Dockerfiles exist (the spec's 5th 'test image' has none)", () => {
75 for (const app of APPS) {
76 expect(existsSync(path.join(REPO_ROOT, "apps", app, "Dockerfile")), `apps/${app}/Dockerfile must exist`).toBe(true);
77 }
78
79 const all = findDockerfiles(REPO_ROOT);
80 const inApps = all.filter((p) => allDockerfileApps(p));
81 expect(inApps.length, "no Dockerfile may exist outside apps/ (there is no 'test image' to build)").toBe(4);
82 });
83
84 for (const app of APPS) {
85 it(`apps/${app}/Dockerfile copies no secret and pins the base image`, () => {
86 const lines = readFileSync(path.join(REPO_ROOT, "apps", app, "Dockerfile"), "utf8").split("\n");
87
88
89 const froms = lines.filter((l) => /^FROM\s/i.test(l)).map((l) => l.trim());
90 expect(froms.length, "at least one FROM").toBeGreaterThan(0);
91 for (const f of froms) {
92
93 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);
94 expect(f, `${app}: no floating 'latest' base`).not.toMatch(/:latest/i);
95 }
96
97
98 let sawBlanket = false;
99 for (const line of lines) {
100 const m = line.match(/^\s*(?:COPY|ADD)\s+(.+)$/i);
101 if (!m) continue;
102 const tokens = (m[1] ?? "").split(/\s+/).filter(Boolean);
103
104 const args = tokens.filter((t) => !t.startsWith("--"));
105 const dest = args[args.length - 1];
106 const sources = args.slice(0, -1);
107 for (const src of sources) {
108 if (BLANKET_SRC.test(src)) {
109 sawBlanket = true;
110 continue;
111 }
112 expect(src, `${app}: Dockerfile must not COPY a secret path "${src}" (dest ${dest})`).not.toMatch(SECRET_SRC);
113 }
114 }
115
116
117 if (sawBlanket) {
118 const di = readFileSync(path.join(REPO_ROOT, ".dockerignore"), "utf8");
119 for (const req of [".env", ".git", "node_modules", "dist", "*.pem", "*.key"]) {
120 expect(diHasLine(di, req), `${app}: blanket COPY . . requires .dockerignore to exclude "${req}"`).toBe(true);
121 }
122 }
123 });
124 }
125});
126
127
128
129
130describe("images / .dockerignore (F-120 fix)", () => {
131 it("excludes live secrets, VCS history, deps and build output from the context", () => {
132 expect(existsSync(path.join(REPO_ROOT, ".dockerignore")), "a root .dockerignore must exist (worker does COPY . .)").toBe(true);
133 const di = readFileSync(path.join(REPO_ROOT, ".dockerignore"), "utf8");
134
135 for (const req of [".env", ".git", "node_modules", "dist", "*.pem", "*.key"]) {
136 expect(diHasLine(di, req), `.dockerignore must exclude "${req}"`).toBe(true);
137 }
138 });
139});
140
141
142
143
144describe("images / CI wiring (.github/workflows/ci.yml)", () => {
145 it("the security job builds all four images and scans each rootfs for secrets", () => {
146 const ci = readFileSync(CI, "utf8");
147 expect(ci).toMatch(/for app in api web worker realtime/);
148 expect(ci).toMatch(/docker build -f "apps\/\$app\/Dockerfile"/);
149 expect(ci).toMatch(/docker export/i);
150 expect(ci).toMatch(/gitleaks detect/);
151
152 expect(ci).toMatch(/FAIL: factory-\$app ships a \.env file/);
153 });
154});
155
156
157
158
159describe("images / live build + rootfs secret scan", () => {
160 itDocker(
161 "builds each image and its rootfs is secret-free (no .env, no gitleaks findings)",
162 () => {
163 for (const app of APPS) {
164 const tag = `factory-${app}:scan`;
165 execFileSync("docker", ["build", "-f", `apps/${app}/Dockerfile`, "-t", tag, "."], {
166 cwd: REPO_ROOT,
167 stdio: ["ignore", "pipe", "pipe"],
168 timeout: 1_200_000
169 });
170 const container = execFileSync("docker", ["create", tag], { encoding: "utf8" }).trim();
171 try {
172
173 const listing = execFileSync("sh", ["-c", "docker export '" + container + "' | tar -tf - 2>/dev/null"], { encoding: "utf8" });
174 expect(listing, `${tag}: no .env may be baked into the image`).not.toMatch(/(^|\/)\.env$|(^|\/)\.env\.(local|production|ci)$/);
175
176
177 const dir = mkdtempSync(path.join(tmpdir(), `factory-img-${app}-`));
178 execFileSync("sh", ["-c", `docker export '${container}' | tar -xf - -C '${dir}'`], { stdio: ["ignore", "pipe", "pipe"] });
179 const code = gl(["detect", `--source=${dir}`, `--config=${CFG}`, "--no-banner"]);
180 expect(code, `${tag}: gitleaks found secrets in the rootfs`).toBe(0);
181 } finally {
182 execFileSync("docker", ["rm", "-f", container], { stdio: ["ignore", "pipe", "pipe"] });
183 execFileSync("docker", ["rmi", "-f", tag], { stdio: ["ignore", "pipe", "pipe"] });
184 }
185 }
186 },
187 { timeout: 1_500_000 }
188 );
189});
190
191
192
193function diHasLine(di: string, entry: string): boolean {
194 return di.split("\n").some((l) => l.trim() === entry);
195}
196function findDockerfiles(root: string): string[] {
197 const out: string[] = [];
198 const walk = (d: string) => {
199 for (const name of readdirSync(d, { withFileTypes: true })) {
200 if (name.name === "node_modules" || name.name === ".git") continue;
201 const p = path.join(d, name.name);
202 if (name.isDirectory()) walk(p);
203 else if (/^Dockerfile/.test(name.name)) out.push(p);
204 }
205 };
206 walk(root);
207 return out;
208}
209function allDockerfileApps(p: string): boolean {
210 const rel = path.relative(REPO_ROOT, p).split(path.sep);
211 return rel.length === 3 && rel[0] === "apps" && (rel[2] ?? "").startsWith("Dockerfile");
212}
213