@vibecheckai/cli 3.2.0 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/runners/lib/agent-firewall/change-packet/builder.js +214 -0
  2. package/bin/runners/lib/agent-firewall/change-packet/schema.json +228 -0
  3. package/bin/runners/lib/agent-firewall/change-packet/store.js +200 -0
  4. package/bin/runners/lib/agent-firewall/claims/claim-types.js +21 -0
  5. package/bin/runners/lib/agent-firewall/claims/extractor.js +214 -0
  6. package/bin/runners/lib/agent-firewall/claims/patterns.js +24 -0
  7. package/bin/runners/lib/agent-firewall/evidence/auth-evidence.js +88 -0
  8. package/bin/runners/lib/agent-firewall/evidence/contract-evidence.js +75 -0
  9. package/bin/runners/lib/agent-firewall/evidence/env-evidence.js +118 -0
  10. package/bin/runners/lib/agent-firewall/evidence/resolver.js +102 -0
  11. package/bin/runners/lib/agent-firewall/evidence/route-evidence.js +142 -0
  12. package/bin/runners/lib/agent-firewall/evidence/side-effect-evidence.js +145 -0
  13. package/bin/runners/lib/agent-firewall/fs-hook/daemon.js +19 -0
  14. package/bin/runners/lib/agent-firewall/fs-hook/installer.js +87 -0
  15. package/bin/runners/lib/agent-firewall/fs-hook/watcher.js +184 -0
  16. package/bin/runners/lib/agent-firewall/git-hook/pre-commit.js +163 -0
  17. package/bin/runners/lib/agent-firewall/ide-extension/cursor.js +107 -0
  18. package/bin/runners/lib/agent-firewall/ide-extension/vscode.js +68 -0
  19. package/bin/runners/lib/agent-firewall/ide-extension/windsurf.js +66 -0
  20. package/bin/runners/lib/agent-firewall/interceptor/base.js +304 -0
  21. package/bin/runners/lib/agent-firewall/interceptor/cursor.js +35 -0
  22. package/bin/runners/lib/agent-firewall/interceptor/vscode.js +35 -0
  23. package/bin/runners/lib/agent-firewall/interceptor/windsurf.js +34 -0
  24. package/bin/runners/lib/agent-firewall/policy/default-policy.json +84 -0
  25. package/bin/runners/lib/agent-firewall/policy/engine.js +72 -0
  26. package/bin/runners/lib/agent-firewall/policy/loader.js +143 -0
  27. package/bin/runners/lib/agent-firewall/policy/rules/auth-drift.js +50 -0
  28. package/bin/runners/lib/agent-firewall/policy/rules/contract-drift.js +50 -0
  29. package/bin/runners/lib/agent-firewall/policy/rules/fake-success.js +61 -0
  30. package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +50 -0
  31. package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +50 -0
  32. package/bin/runners/lib/agent-firewall/policy/rules/scope.js +93 -0
  33. package/bin/runners/lib/agent-firewall/policy/rules/unsafe-side-effect.js +57 -0
  34. package/bin/runners/lib/agent-firewall/policy/schema.json +183 -0
  35. package/bin/runners/lib/agent-firewall/policy/verdict.js +54 -0
  36. package/bin/runners/lib/agent-firewall/truthpack/index.js +67 -0
  37. package/bin/runners/lib/agent-firewall/truthpack/loader.js +116 -0
  38. package/bin/runners/lib/agent-firewall/unblock/planner.js +337 -0
  39. package/bin/runners/lib/analysis-core.js +198 -180
  40. package/bin/runners/lib/analyzers.js +1119 -536
  41. package/bin/runners/lib/cli-output.js +236 -210
  42. package/bin/runners/lib/detectors-v2.js +547 -785
  43. package/bin/runners/lib/fingerprint.js +377 -0
  44. package/bin/runners/lib/route-truth.js +1167 -322
  45. package/bin/runners/lib/scan-output.js +144 -738
  46. package/bin/runners/lib/ship-output-enterprise.js +239 -0
  47. package/bin/runners/lib/terminal-ui.js +188 -770
  48. package/bin/runners/lib/truth.js +1004 -321
  49. package/bin/runners/lib/unified-output.js +162 -158
  50. package/bin/runners/runAgent.js +161 -0
  51. package/bin/runners/runFirewall.js +134 -0
  52. package/bin/runners/runFirewallHook.js +56 -0
  53. package/bin/runners/runScan.js +113 -10
  54. package/bin/runners/runShip.js +7 -8
  55. package/bin/runners/runTruth.js +89 -0
  56. package/mcp-server/agent-firewall-interceptor.js +164 -0
  57. package/mcp-server/index.js +347 -313
  58. package/mcp-server/truth-context.js +131 -90
  59. package/mcp-server/truth-firewall-tools.js +1412 -1045
  60. package/package.json +1 -1
@@ -1,167 +1,686 @@
1
1
  // bin/runners/lib/analyzers.js
2
+ "use strict";
3
+
2
4
  const fs = require("fs");
3
5
  const path = require("path");
4
6
  const fg = require("fast-glob");
5
7
  const crypto = require("crypto");
8
+ const { URL } = require("url");
6
9
  const parser = require("@babel/parser");
7
10
  const traverse = require("@babel/traverse").default;
8
11
  const t = require("@babel/types");
12
+
9
13
  const { routeMatches } = require("./claims");
10
14
  const { matcherCoversPath } = require("./auth-truth");
11
15
 
16
+ /* ============================================================================
17
+ * WORLD-CLASS INFRA HELPERS
18
+ * - file caching (speed + consistent evidence)
19
+ * - stable IDs (diff-friendly)
20
+ * - safe regex usage (fixes /g + .test() state bugs)
21
+ * - memory management (clearFileCache for monorepos)
22
+ * ========================================================================== */
23
+
24
+ const _FILE_TEXT = new Map();
25
+ const _FILE_LINES = new Map();
26
+
27
+ function readFileCached(fileAbs) {
28
+ if (_FILE_TEXT.has(fileAbs)) return _FILE_TEXT.get(fileAbs);
29
+ const txt = fs.readFileSync(fileAbs, "utf8");
30
+ _FILE_TEXT.set(fileAbs, txt);
31
+ return txt;
32
+ }
33
+
34
+ function readLinesCached(fileAbs) {
35
+ if (_FILE_LINES.has(fileAbs)) return _FILE_LINES.get(fileAbs);
36
+ const lines = readFileCached(fileAbs).split(/\r?\n/);
37
+ _FILE_LINES.set(fileAbs, lines);
38
+ return lines;
39
+ }
40
+
41
+ /**
42
+ * V3: Clear file cache to prevent memory leaks in large monorepos.
43
+ * Call this after a scan completes or between major steps.
44
+ */
45
+ function clearFileCache() {
46
+ _FILE_TEXT.clear();
47
+ _FILE_LINES.clear();
48
+ }
49
+
50
+ /**
51
+ * V3: Shannon Entropy calculator for detecting high-randomness strings (likely secrets).
52
+ * Entropy > 4.5 typically indicates a random/secret string vs structured data.
53
+ * Git SHAs (hex only) have lower effective entropy due to limited charset.
54
+ */
55
+ function getShannonEntropy(str) {
56
+ if (!str || str.length === 0) return 0;
57
+ const len = str.length;
58
+ const frequencies = {};
59
+ for (let i = 0; i < len; i++) {
60
+ const char = str[i];
61
+ frequencies[char] = (frequencies[char] || 0) + 1;
62
+ }
63
+
64
+ let entropy = 0;
65
+ for (const char in frequencies) {
66
+ const p = frequencies[char] / len;
67
+ entropy -= p * Math.log2(p);
68
+ }
69
+ return entropy;
70
+ }
71
+
12
72
  function sha256(text) {
13
- return "sha256:" + crypto.createHash("sha256").update(text).digest("hex");
73
+ return "sha256:" + crypto.createHash("sha256").update(String(text || "")).digest("hex");
74
+ }
75
+
76
+ function stableId(prefix, key) {
77
+ const h = crypto.createHash("sha256").update(String(key || "")).digest("hex").slice(0, 10);
78
+ return `${prefix}_${h}`;
14
79
  }
15
80
 
16
- function parseFile(code) {
17
- return parser.parse(code, { sourceType: "unambiguous", plugins: ["typescript", "jsx"] });
81
+ // IMPORTANT: /g + .test() is stateful. This helper makes it deterministic.
82
+ function rxTest(rx, s) {
83
+ if (!rx) return false;
84
+ rx.lastIndex = 0;
85
+ return rx.test(s);
86
+ }
87
+
88
+ function parseFile(code, fileAbsForErrors = "") {
89
+ // Error recovery avoids hard-failing on mixed TS/JS/JSX edge cases.
90
+ return parser.parse(code, {
91
+ sourceType: "unambiguous",
92
+ errorRecovery: true,
93
+ allowReturnOutsideFunction: true,
94
+ plugins: [
95
+ "typescript",
96
+ "jsx",
97
+ "dynamicImport",
98
+ "topLevelAwait",
99
+ "classProperties",
100
+ "classPrivateProperties",
101
+ "classPrivateMethods",
102
+ "decorators-legacy",
103
+ "optionalChaining",
104
+ "nullishCoalescingOperator",
105
+ ],
106
+ });
18
107
  }
19
108
 
20
109
  function evidenceFromLoc(fileAbs, repoRoot, loc, reason) {
21
110
  if (!loc) return null;
22
111
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
23
- const lines = fs.readFileSync(fileAbs, "utf8").split(/\r?\n/);
112
+ const lines = readLinesCached(fileAbs);
24
113
  const start = Math.max(1, loc.start?.line || 1);
25
114
  const end = Math.max(start, loc.end?.line || start);
26
115
  const snippet = lines.slice(start - 1, end).join("\n");
27
- return { id: `ev_${crypto.randomBytes(4).toString("hex")}`, file: fileRel, lines: `${start}-${end}`, snippetHash: sha256(snippet), reason };
116
+ return {
117
+ id: stableId("ev", `${fileRel}:${start}-${end}:${reason || ""}:${sha256(snippet)}`),
118
+ file: fileRel,
119
+ lines: `${start}-${end}`,
120
+ snippetHash: sha256(snippet),
121
+ reason,
122
+ };
123
+ }
124
+
125
+ /* ============================================================================
126
+ * ROUTE GAP ENGINE (world-class missing route logic)
127
+ * ========================================================================== */
128
+
129
+ function safeUrlParse(maybeUrl) {
130
+ try {
131
+ // URL() needs protocol; allow //host/path too
132
+ if (typeof maybeUrl !== "string") return null;
133
+ if (/^https?:\/\//i.test(maybeUrl)) return new URL(maybeUrl);
134
+ if (/^\/\//.test(maybeUrl)) return new URL("https:" + maybeUrl);
135
+ return null;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function normalizePath(raw) {
142
+ if (!raw) return "/";
143
+ let p = String(raw).trim();
144
+
145
+ // If full URL, strip to pathname.
146
+ const u = safeUrlParse(p);
147
+ if (u) p = u.pathname || "/";
148
+
149
+ // Strip query/hash if present
150
+ p = p.split("?")[0].split("#")[0];
151
+
152
+ // Decode safely
153
+ try {
154
+ p = decodeURIComponent(p);
155
+ } catch {
156
+ // keep original
157
+ }
158
+
159
+ // Ensure leading slash
160
+ if (!p.startsWith("/")) p = "/" + p;
161
+
162
+ // Collapse duplicate slashes
163
+ p = p.replace(/\/{2,}/g, "/");
164
+
165
+ // Remove trailing slash (except root)
166
+ if (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
167
+
168
+ return p;
169
+ }
170
+
171
+ function pathLooksLikeAsset(p) {
172
+ const s = String(p || "");
173
+ // Common Next/static + file extensions that are not API routes
174
+ if (/^\/(_next|static|assets)\b/i.test(s)) return true;
175
+ if (/\.(png|jpg|jpeg|gif|webp|svg|ico|css|js|map|txt|xml|woff2?|ttf|eot)$/i.test(s)) return true;
176
+ return false;
177
+ }
178
+
179
+ function isInternalUtilityRoute(p) {
180
+ return !!rxTest(/^\/(health|metrics|ready|live|version|debug|internal|security|websocket|ws|admin|dashboard|_|\.)/i, p);
181
+ }
182
+
183
+ function looksInventedRoute(p) {
184
+ // Stuff AI loves to hallucinate - but NOT legitimate test endpoints like /test-email
185
+ // Only flag truly fake/placeholder routes, not common test/debug endpoints
186
+ if (rxTest(/^\/(fake|dummy|foo|bar|baz|xxx|yyy|placeholder|asdf|qwerty|lorem|ipsum)\b/i, p)) return true;
187
+ // Random hashes in path
188
+ if (rxTest(/\/[a-f0-9]{32,}\b/i, p)) return true;
189
+ // Obvious "ai generated" patterns
190
+ if (rxTest(/^\/(generated|auto[-_]?gen)\b/i, p)) return true;
191
+ // Obvious placeholder test data patterns (not legitimate /test-* endpoints)
192
+ if (rxTest(/\/(test123|abc123|demo123|sample123)\b/i, p)) return true;
193
+ return false;
194
+ }
195
+
196
+ function canonicalizeDynamicSegments(p) {
197
+ // Convert common dynamic segments to a stable token so "/users/123" can match "/users/:id"
198
+ // NOTE: This function returns a string, not a boolean - name is for canonicalization, not validation
199
+ const segs = normalizePath(p).split("/").filter(Boolean);
200
+ const canon = segs.map((seg) => {
201
+ if (!seg) return seg;
202
+ // UUID
203
+ if (rxTest(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, seg)) return ":id";
204
+ // Numeric IDs
205
+ if (rxTest(/^\d{1,18}$/i, seg)) return ":id";
206
+ // Long hex
207
+ if (rxTest(/^(0x)?[0-9a-f]{16,}$/i, seg)) return ":id";
208
+ // Next-ish catchalls
209
+ if (seg === "[...slug]" || seg === "[[...slug]]") return ":slug";
210
+ return seg;
211
+ });
212
+ return "/" + canon.join("/");
213
+ }
214
+
215
+ function firstSegment(p) {
216
+ const seg = normalizePath(p).split("/").filter(Boolean)[0];
217
+ return seg || "";
218
+ }
219
+
220
+ function inferDominantPrefix(paths, minShare = 0.7) {
221
+ // Find a dominant first segment like "api" across a set of paths
222
+ const counts = new Map();
223
+ for (const p of paths) {
224
+ const seg = firstSegment(p);
225
+ if (!seg) continue;
226
+ counts.set(seg, (counts.get(seg) || 0) + 1);
227
+ }
228
+ let best = { seg: "", n: 0 };
229
+ for (const [seg, n] of counts.entries()) {
230
+ if (n > best.n) best = { seg, n };
231
+ }
232
+ if (!best.seg) return null;
233
+ const share = best.n / Math.max(1, paths.length);
234
+ return share >= minShare ? "/" + best.seg : null;
235
+ }
236
+
237
+ function buildServerRouteIndex(serverRoutes) {
238
+ // Index by method + first segment for fast shortlist
239
+ const byMethod = new Map(); // method -> seg -> routes[]
240
+ const all = [];
241
+
242
+ for (const r of serverRoutes) {
243
+ const method = String(r.method || "*").toUpperCase();
244
+ const pNorm = normalizePath(r.path);
245
+ const seg = firstSegment(pNorm);
246
+
247
+ const rec = { ...r, _method: method, _pathNorm: pNorm, _seg: seg, _canon: canonicalizeDynamicSegments(pNorm) };
248
+ all.push(rec);
249
+
250
+ if (!byMethod.has(method)) byMethod.set(method, new Map());
251
+ const segMap = byMethod.get(method);
252
+ if (!segMap.has(seg)) segMap.set(seg, []);
253
+ segMap.get(seg).push(rec);
254
+
255
+ // Also index wildcard bucket
256
+ if (!byMethod.has("*")) byMethod.set("*", new Map());
257
+ const w = byMethod.get("*");
258
+ if (!w.has(seg)) w.set(seg, []);
259
+ w.get(seg).push(rec);
260
+ }
261
+
262
+ return { byMethod, all };
263
+ }
264
+
265
+ function shortlistServerRoutes(index, method, pNorm) {
266
+ const m = String(method || "*").toUpperCase();
267
+ const seg = firstSegment(pNorm);
268
+
269
+ const pick = (meth) => {
270
+ const segMap = index.byMethod.get(meth);
271
+ if (!segMap) return [];
272
+ const bucket = segMap.get(seg) || [];
273
+ // If seg is empty or dynamic roots exist, include a fallback bucket
274
+ const rootBucket = segMap.get("") || [];
275
+ return bucket.concat(rootBucket);
276
+ };
277
+
278
+ // prioritize exact method, then wildcard
279
+ const a = pick(m);
280
+ const b = pick("*");
281
+ // de-dupe by path+method
282
+ const seen = new Set();
283
+ const out = [];
284
+ for (const r of a.concat(b)) {
285
+ const k = `${r._method}:${r._pathNorm}`;
286
+ if (seen.has(k)) continue;
287
+ seen.add(k);
288
+ out.push(r);
289
+ }
290
+ return out.length ? out : index.all;
291
+ }
292
+
293
+ function routeSimilarityScore(refPath, serverPathPattern) {
294
+ // Score 0..1 based on static segment overlap + prefix alignment
295
+ const a = canonicalizeDynamicSegments(refPath).split("/").filter(Boolean);
296
+ const b = canonicalizeDynamicSegments(serverPathPattern).split("/").filter(Boolean);
297
+
298
+ if (!a.length || !b.length) return 0;
299
+
300
+ const aStatic = a.filter((s) => !s.startsWith(":") && !s.startsWith("["));
301
+ const bStatic = b.filter((s) => !s.startsWith(":") && !s.startsWith("["));
302
+
303
+ const setA = new Set(aStatic);
304
+ const setB = new Set(bStatic);
305
+
306
+ let inter = 0;
307
+ for (const s of setA) if (setB.has(s)) inter++;
308
+
309
+ const union = new Set([...setA, ...setB]).size || 1;
310
+
311
+ const jaccard = inter / union;
312
+
313
+ // prefix bonus if first 1-2 segments align
314
+ const prefix1 = a[0] && b[0] && a[0] === b[0] ? 0.15 : 0;
315
+ const prefix2 = a[1] && b[1] && a[1] === b[1] ? 0.10 : 0;
316
+
317
+ // length penalty if wildly different
318
+ const lenPenalty = Math.min(0.25, Math.abs(a.length - b.length) * 0.05);
319
+
320
+ const score = Math.max(0, Math.min(1, jaccard + prefix1 + prefix2 - lenPenalty));
321
+ return score;
322
+ }
323
+
324
+ function compileAllowPatterns(patterns) {
325
+ const out = [];
326
+ for (const p of patterns || []) {
327
+ if (!p) continue;
328
+ if (p instanceof RegExp) {
329
+ out.push(p);
330
+ continue;
331
+ }
332
+ const s = String(p);
333
+ // Support "/.../i" style
334
+ const m = s.match(/^\/(.+)\/([gimsuy]*)$/);
335
+ if (m) {
336
+ try {
337
+ out.push(new RegExp(m[1], m[2]));
338
+ continue;
339
+ } catch {
340
+ // fall through
341
+ }
342
+ }
343
+ // Simple wildcard "*" and "?" -> regex
344
+ const esc = s
345
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
346
+ .replace(/\*/g, ".*")
347
+ .replace(/\?/g, ".");
348
+ try {
349
+ out.push(new RegExp("^" + esc + "$", "i"));
350
+ } catch {
351
+ // ignore bad patterns
352
+ }
353
+ }
354
+ return out;
28
355
  }
29
356
 
30
357
  function findMissingRoutes(truthpack) {
31
358
  const findings = [];
32
- const server = truthpack.routes.server || [];
33
- const refs = truthpack.routes.clientRefs || [];
34
- const gaps = truthpack.routes.gaps || [];
35
-
36
- // If we have route detection gaps, be less aggressive with BLOCKs
359
+
360
+ const server = truthpack?.routes?.server || [];
361
+ const refs = truthpack?.routes?.clientRefs || [];
362
+ const gaps = truthpack?.routes?.gaps || [];
363
+
37
364
  const hasGaps = gaps.length > 0;
38
-
39
- // In monorepos/microservices, many client refs may be to external services
40
- // Only flag routes that look clearly invented/hallucinated
41
- const serverRouteCount = server.length;
42
- const clientRefCount = refs.length;
43
-
44
- // If client refs >> server routes, this is likely a monorepo or microservice architecture
45
- // Be very lenient in this case
46
- const isLikelyMonorepo = clientRefCount > serverRouteCount * 3;
47
-
48
- // Build a set of known route path prefixes for smarter matching
49
- const knownPrefixes = new Set();
50
- for (const r of server) {
51
- const parts = r.path.split('/').filter(Boolean);
52
- if (parts.length >= 2) {
53
- knownPrefixes.add('/' + parts[0] + '/' + parts[1]);
365
+
366
+ // Allowlist/suppressions (lets users kill known false positives cleanly)
367
+ const allowMethods = new Set((truthpack?.routes?.allowlistMethods || []).map((m) => String(m).toUpperCase()));
368
+ const allowPatterns = compileAllowPatterns(truthpack?.routes?.allowlist || truthpack?.routes?.allowlistPatterns || []);
369
+ const ignorePatterns = compileAllowPatterns(truthpack?.routes?.ignore || truthpack?.routes?.ignorePatterns || []);
370
+
371
+ const isSuppressed = (method, pNorm) => {
372
+ const m = String(method || "*").toUpperCase();
373
+ if (allowMethods.size && !allowMethods.has(m) && !allowMethods.has("*")) {
374
+ // method not allowed by allowMethods -> don't suppress
54
375
  }
55
- if (parts.length >= 1) {
56
- knownPrefixes.add('/' + parts[0]);
376
+ for (const rx of ignorePatterns) if (rxTest(rx, `${m} ${pNorm}`) || rxTest(rx, pNorm)) return true;
377
+ for (const rx of allowPatterns) if (rxTest(rx, `${m} ${pNorm}`) || rxTest(rx, pNorm)) return true;
378
+ return false;
379
+ };
380
+
381
+ const serverCount = server.length;
382
+ const refCount = refs.length;
383
+
384
+ // Monorepo heuristic (keep, but make it less dumb)
385
+ const isLikelyMonorepo = refCount > Math.max(30, serverCount * 2.5);
386
+
387
+ const serverPaths = server.map((r) => normalizePath(r.path));
388
+ const refPaths = refs.map((r) => normalizePath(r.path));
389
+
390
+ // Dominant prefixes (commonly "/api")
391
+ const dominantServerPrefix = inferDominantPrefix(serverPaths, 0.7);
392
+ const dominantRefPrefix = inferDominantPrefix(refPaths, 0.7);
393
+
394
+ const serverPrefix = dominantServerPrefix || null;
395
+ const refPrefix = dominantRefPrefix || null;
396
+
397
+ const index = buildServerRouteIndex(server);
398
+
399
+ // Route-map quality gating:
400
+ // If we have unresolved gaps or tiny server map, DO NOT BLOCK unless obviously invented.
401
+ const routeMapQuality =
402
+ serverCount >= 10 && !hasGaps ? "strong" :
403
+ serverCount >= 5 ? "medium" :
404
+ "weak";
405
+
406
+ // More generous caps (but still bounded)
407
+ const MAX_WARNINGS = isLikelyMonorepo ? 35 : 60;
408
+ const MAX_BLOCKS = 15;
409
+
410
+ let warnCount = 0;
411
+ let blockCount = 0;
412
+
413
+ // Summaries to help you fix extraction instead of drowning in noise
414
+ const unmatchedByPrefix = new Map();
415
+ const externalRefs = [];
416
+
417
+ function addUnmatchedPrefix(pNorm) {
418
+ const seg = firstSegment(pNorm) || "/";
419
+ unmatchedByPrefix.set(seg, (unmatchedByPrefix.get(seg) || 0) + 1);
420
+ }
421
+
422
+ function tryMatch(method, pNorm) {
423
+ const shortlist = shortlistServerRoutes(index, method, pNorm);
424
+
425
+ // Try exact + canonicalized matching
426
+ const pCanon = canonicalizeDynamicSegments(pNorm);
427
+
428
+ for (const r of shortlist) {
429
+ if (routeMatches(r, method, pNorm) || routeMatches(r, "*", pNorm)) return { ok: true, matched: r };
430
+ // Try canonicalized path vs canonicalized server path
431
+ if (routeMatches({ ...r, path: r._canon }, method, pCanon) || routeMatches({ ...r, path: r._canon }, "*", pCanon)) {
432
+ return { ok: true, matched: r, usedCanon: true };
433
+ }
57
434
  }
435
+ return { ok: false };
58
436
  }
59
437
 
60
- // Track how many warnings we emit - show more to demonstrate value
61
- let warningCount = 0;
62
- const MAX_WARNINGS = 50; // Show more to demonstrate thoroughness
438
+ function closestSuggestions(method, pNorm) {
439
+ // Use a bounded scan: shortlist first, then broaden if needed
440
+ const shortlist = shortlistServerRoutes(index, method, pNorm);
441
+ const pool = shortlist.length ? shortlist : index.all;
442
+
443
+ const scored = pool
444
+ .map((r) => ({ r, score: routeSimilarityScore(pNorm, r._pathNorm) }))
445
+ .sort((a, b) => b.score - a.score)
446
+ .slice(0, 3);
447
+
448
+ return scored.filter((x) => x.score >= 0.35).map((x) => ({
449
+ method: x.r._method,
450
+ path: x.r._pathNorm,
451
+ score: Number(x.score.toFixed(2)),
452
+ }));
453
+ }
454
+
455
+ function detectMethodMismatch(pNorm, method) {
456
+ // If the path exists but only under other method(s), that's not "missing route"
457
+ const methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "*"];
458
+ const hits = [];
459
+ for (const m of methods) {
460
+ if (String(m) === String(method).toUpperCase()) continue;
461
+ const res = tryMatch(m, pNorm);
462
+ if (res.ok) hits.push(m);
463
+ }
464
+ return hits.length ? hits : null;
465
+ }
63
466
 
64
467
  for (const ref of refs) {
65
- const method = ref.method || "*";
66
- const p = ref.path;
468
+ const rawPath = ref?.path;
469
+ const method = String(ref?.method || "*").toUpperCase();
67
470
 
68
- const ok = server.some(r => routeMatches(r, method, p) || routeMatches(r, "*", p));
69
- if (ok) continue;
70
-
71
- // Check if route shares a prefix with known routes (might be undetected sibling)
72
- const refParts = p.split('/').filter(Boolean);
73
- const refPrefix1 = refParts.length >= 1 ? '/' + refParts[0] : '/';
74
- const refPrefix2 = refParts.length >= 2 ? '/' + refParts[0] + '/' + refParts[1] : refPrefix1;
75
- const sharesPrefix = knownPrefixes.has(refPrefix1) || knownPrefixes.has(refPrefix2);
76
-
77
- // In monorepos, still show routes but as warnings (demonstrates thoroughness)
78
- // Skip only if route EXACTLY matches a known prefix AND monorepo
79
- if (sharesPrefix && isLikelyMonorepo && warningCount > MAX_WARNINGS) continue;
80
-
81
- // Determine severity based on confidence and context
82
- // Only BLOCK for clearly invented routes
471
+ // Normalize & classify
472
+ const u = safeUrlParse(rawPath);
473
+ const pNorm = normalizePath(rawPath);
474
+
475
+ // Skip asset-ish refs
476
+ if (pathLooksLikeAsset(pNorm)) continue;
477
+
478
+ // External refs: if full URL and host isn't localhost, treat as external service.
479
+ if (u && u.host && !/^(localhost|127\.0\.0\.1)(:\d+)?$/i.test(u.host)) {
480
+ externalRefs.push({ host: u.host, method, path: pNorm, evidence: ref.evidence || [] });
481
+ continue;
482
+ }
483
+
484
+ // Skip suppressed
485
+ if (isSuppressed(method, pNorm)) continue;
486
+
487
+ // Build candidate variants (this kills a lot of false positives)
488
+ const candidates = new Set();
489
+ candidates.add(pNorm);
490
+
491
+ // trailing slash variant
492
+ if (pNorm.length > 1) {
493
+ candidates.add(pNorm + "/");
494
+ candidates.add(pNorm.replace(/\/+$/g, ""));
495
+ }
496
+
497
+ // Toggle dominant server prefix (ex: /api) if mismatch likely
498
+ if (serverPrefix && serverPrefix !== "/" && !pNorm.startsWith(serverPrefix + "/") && pNorm !== serverPrefix) {
499
+ candidates.add(normalizePath(serverPrefix + pNorm));
500
+ }
501
+ if (serverPrefix && serverPrefix !== "/" && pNorm.startsWith(serverPrefix + "/")) {
502
+ candidates.add(normalizePath(pNorm.slice(serverPrefix.length)));
503
+ }
504
+
505
+ // Toggle dominant ref prefix similarly (sometimes refs have /api but server routes stored without it)
506
+ if (refPrefix && refPrefix !== "/" && !pNorm.startsWith(refPrefix + "/") && pNorm !== refPrefix) {
507
+ candidates.add(normalizePath(refPrefix + pNorm));
508
+ }
509
+ if (refPrefix && refPrefix !== "/" && pNorm.startsWith(refPrefix + "/")) {
510
+ candidates.add(normalizePath(pNorm.slice(refPrefix.length)));
511
+ }
512
+
513
+ // Canonicalized variant
514
+ candidates.add(canonicalizeDynamicSegments(pNorm));
515
+
516
+ // Try match all candidates
517
+ let matched = null;
518
+ let usedCanon = false;
519
+ for (const cand of candidates) {
520
+ const res = tryMatch(method, cand);
521
+ if (res.ok) {
522
+ matched = res.matched;
523
+ usedCanon = !!res.usedCanon;
524
+ break;
525
+ }
526
+ }
527
+ if (matched) continue;
528
+
529
+ addUnmatchedPrefix(pNorm);
530
+
531
+ // Method mismatch detection (not missing route)
532
+ const methodMismatch = detectMethodMismatch(pNorm, method);
533
+ if (methodMismatch) {
534
+ // Keep as WARN (not missing route) — reduces noise dramatically
535
+ if (warnCount >= MAX_WARNINGS) continue;
536
+ warnCount++;
537
+
538
+ findings.push({
539
+ id: stableId("F_ROUTE_METHOD_MISMATCH", `${method} ${pNorm}`),
540
+ severity: "WARN",
541
+ category: "MissingRoute",
542
+ title: `Method mismatch for route: ${method} ${pNorm}`,
543
+ why: `A server route exists for this path, but not for method ${method}. This is often a client bug or an incorrect assumption.`,
544
+ confidence: routeMapQuality === "strong" ? "high" : "med",
545
+ evidence: ref.evidence || [],
546
+ fixHints: [
547
+ `Check the client call method. Server supports: ${methodMismatch.join(", ")} for ${pNorm}`,
548
+ "If this is intentional, update the server to accept this method or adjust the client.",
549
+ ],
550
+ });
551
+ continue;
552
+ }
553
+
554
+ const invented = looksInventedRoute(pNorm);
555
+ const internal = isInternalUtilityRoute(pNorm);
556
+
557
+ // Similarity suggestions
558
+ const suggestions = closestSuggestions(method, pNorm);
559
+
560
+ // Confidence + severity gating
561
+ let confidence = "low";
83
562
  let severity = "WARN";
84
-
85
- // Only BLOCK if the route looks clearly invented/hallucinated
86
- const looksInvented = /\/(fake|test|mock|dummy|example|foo|bar|baz|xxx|yyy|placeholder|asdf|qwerty|lorem|ipsum)/i.test(p);
87
- const looksGenerated = /\/[a-f0-9]{32,}/i.test(p); // Random hash in path
88
-
89
- if (looksInvented || looksGenerated) {
563
+
564
+ if (invented && !internal) {
90
565
  severity = "BLOCK";
91
- }
92
-
93
- // Always WARN (not BLOCK) for common internal/utility routes
94
- const isInternalRoute = /^\/(health|metrics|ready|live|version|debug|internal|suggestions|security|analyze|websocket|dashboard|admin|_|\.)/i.test(p);
95
- if (isInternalRoute) {
566
+ confidence = "high";
567
+ } else if (routeMapQuality === "strong" && !isLikelyMonorepo && !internal) {
568
+ // Only escalate if route map quality is strong and it doesn't look like a monorepo
569
+ const best = suggestions[0]?.score ?? 0;
570
+ // If there's no close suggestion, it's more likely actually missing
571
+ if (best < 0.40) {
572
+ severity = "WARN"; // keep WARN by default; you can flip to BLOCK if you want
573
+ confidence = "med";
574
+ } else {
575
+ confidence = "low";
576
+ }
577
+ } else {
578
+ confidence = "low";
96
579
  severity = "WARN";
97
580
  }
98
-
99
- // Cap warnings to avoid noise
100
- if (severity === "WARN") {
101
- if (warningCount >= MAX_WARNINGS) continue;
102
- warningCount++;
581
+
582
+ // caps
583
+ if (severity === "BLOCK") {
584
+ if (blockCount >= MAX_BLOCKS) continue;
585
+ blockCount++;
586
+ } else {
587
+ if (warnCount >= MAX_WARNINGS) continue;
588
+ warnCount++;
103
589
  }
104
590
 
591
+ const didYouMean = suggestions.length
592
+ ? `Closest server routes: ${suggestions.map((s) => `${s.method} ${s.path} (${s.score})`).join(" • ")}`
593
+ : "No close server route candidates were found (based on static segment similarity).";
594
+
105
595
  findings.push({
106
- id: `F_MISSING_ROUTE_${String(findings.length + 1).padStart(3, "0")}`,
596
+ id: stableId("F_MISSING_ROUTE", `${method} ${pNorm}`),
107
597
  severity,
108
598
  category: "MissingRoute",
109
- title: `Client references route that does not exist: ${method} ${p}`,
110
- why: severity === "BLOCK"
111
- ? "AI frequently invents endpoints. Shipping this = broken flows (404 / silent failure)."
112
- : "Route reference found but server route not detected. May be a false positive in monorepo/microservice setups.",
113
- confidence: severity === "BLOCK" ? "high" : "low",
599
+ title: `Client references route not found in detected server map: ${method} ${pNorm}`,
600
+ why:
601
+ severity === "BLOCK"
602
+ ? "This looks invented. Shipping this will break flows (404 / silent failure)."
603
+ : routeMapQuality === "weak" || hasGaps
604
+ ? "Route reference didn't match the detected server map. Route detection may be incomplete (dynamic registration, plugins, prefixes)."
605
+ : "Route reference didn't match the detected server map. This can be a real missing endpoint or an undetected server route.",
606
+ confidence,
114
607
  evidence: ref.evidence || [],
115
608
  fixHints: [
116
- "Update the client call to a real server route (see route map).",
117
- "If the route exists but wasn't detected, it may use dynamic registration.",
118
- "If this is an external service, this warning can be ignored."
119
- ]
609
+ didYouMean,
610
+ usedCanon ? "Note: matching tried canonicalized ID segments (/:id normalization)." : "Matching tried normalization (origin/query/trailing slash/prefix toggles).",
611
+ hasGaps ? `Route map had ${gaps.length} unresolved sources; fix route extraction to reduce false positives.` : "If this is a real endpoint, add it server-side or correct the client call.",
612
+ isLikelyMonorepo ? "Monorepo/microservices likely: consider allowlisting external services or feeding service domains into truthpack." : "If this is an external service call, store it as external/allowlisted so it won't be flagged.",
613
+ ],
120
614
  });
121
615
  }
122
616
 
123
- // If route scan had gaps, add a WARN so users know why some routes may be unknown
617
+ // Route map diagnostics (actionable, reduces "blame the analyzer" loops)
124
618
  if (hasGaps) {
125
619
  findings.push({
126
- id: `F_ROUTE_MAP_GAPS_001`,
620
+ id: stableId("F_ROUTE_MAP_GAPS", String(gaps.length)),
127
621
  severity: "WARN",
128
622
  category: "RouteMapGaps",
129
- title: `Route map incomplete (${gaps.length} unresolved sources)`,
130
- why: "Some routes may not be detected due to dynamic registration or unresolved plugins.",
131
- confidence: "low",
623
+ title: `Route map incomplete (${gaps.length} unresolved route sources)`,
624
+ why: "Dynamic registration, unresolved plugin imports, or non-standard routing prevented complete detection. Missing route findings may be false positives.",
625
+ confidence: "med",
132
626
  evidence: [],
133
627
  fixHints: [
134
- "Routes registered dynamically or via unresolved imports may not be detected.",
135
- "Consider using explicit route registration for better static analysis.",
136
- "Run with --verbose to see detection gaps."
137
- ]
628
+ "Fix route extraction: resolve Fastify plugins and prefix registration (fastify.register(...,{ prefix })) and inline fastify.get/post routes.",
629
+ "If using Next App Router: ensure route handlers (app/**/route.ts) are included in server route extraction.",
630
+ "Add allowlistPatterns for external services to silence expected gaps.",
631
+ ],
138
632
  });
139
633
  }
140
-
141
- // If we capped warnings, note that
142
- if (warningCount >= MAX_WARNINGS) {
634
+
635
+ // External refs summary (useful in microservices)
636
+ if (externalRefs.length) {
637
+ const topHosts = new Map();
638
+ for (const r of externalRefs) topHosts.set(r.host, (topHosts.get(r.host) || 0) + 1);
639
+ const hostSummary = [...topHosts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
640
+ .map(([h, n]) => `${h} (${n})`).join(", ");
641
+
143
642
  findings.push({
144
- id: `F_MISSING_ROUTE_CAPPED`,
145
- severity: "WARN",
643
+ id: stableId("F_EXTERNAL_ROUTE_REFS", hostSummary),
644
+ severity: "INFO",
146
645
  category: "MissingRoute",
147
- title: `${clientRefCount - serverRouteCount - MAX_WARNINGS} additional unmatched routes not shown`,
148
- why: "Many client references don't match detected server routes. This is common in monorepos/microservices.",
149
- confidence: "low",
646
+ title: `External service routes detected in client refs (${externalRefs.length})`,
647
+ why: "These are full-URL calls to non-local hosts; they are not expected to match server route maps.",
648
+ confidence: "high",
150
649
  evidence: [],
151
650
  fixHints: [
152
- "This codebase appears to be a monorepo or use microservices.",
153
- "Many client refs may be to external services not detected by static analysis.",
154
- "Use vibecheck scan --allowlist to suppress known false positives."
155
- ]
651
+ `Top hosts: ${hostSummary}`,
652
+ "If you want to validate external APIs, add a separate analyzer that checks OpenAPI/spec contracts for those services.",
653
+ "Optionally add allowlistPatterns like '/^https?:\\/\\/(api\\.stripe\\.com|...)/' at truthpack.routes.allowlistPatterns.",
654
+ ],
655
+ });
656
+ }
657
+
658
+ // Biggest unmatched prefixes (points directly at extraction gaps)
659
+ if (unmatchedByPrefix.size) {
660
+ const top = [...unmatchedByPrefix.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
661
+ const summary = top.map(([k, v]) => `${k}:${v}`).join(" • ");
662
+ findings.push({
663
+ id: stableId("F_ROUTE_UNMATCHED_PREFIXES", summary),
664
+ severity: "INFO",
665
+ category: "MissingRoute",
666
+ title: "Unmatched client route prefixes (helps fix extraction/allowlists)",
667
+ why: "When one prefix dominates unmatched refs, it usually means server extraction missed a router/plugin prefix or the client is calling a different service.",
668
+ confidence: "med",
669
+ evidence: [],
670
+ fixHints: [
671
+ `Top unmatched prefixes: ${summary}`,
672
+ serverPrefix ? `Dominant server prefix inferred: ${serverPrefix}` : "No dominant server prefix detected.",
673
+ "If these should be local, improve route extraction for that prefix (Fastify register(prefix), Next middleware rewrites, basePath, etc.).",
674
+ ],
156
675
  });
157
676
  }
158
677
 
159
678
  return findings;
160
679
  }
161
680
 
162
- // ============================================================================
163
- // ENV GAPS ANALYZER
164
- // ============================================================================
681
+ /* ============================================================================
682
+ * ENV GAPS ANALYZER (tightened + fewer false positives)
683
+ * ========================================================================== */
165
684
 
166
685
  function findEnvGaps(truthpack) {
167
686
  const findings = [];
@@ -169,163 +688,162 @@ function findEnvGaps(truthpack) {
169
688
  const declared = new Set(truthpack?.env?.declared || []);
170
689
  const declaredSources = truthpack?.env?.declaredSources || [];
171
690
 
172
- // Well-known system/CI env vars that shouldn't be flagged as undeclared
691
+ // Well-known system/CI env vars that shouldn't be flagged
173
692
  const systemEnvVars = new Set([
174
- // System
175
- 'HOME', 'USER', 'PATH', 'PWD', 'SHELL', 'TERM', 'LANG', 'TZ', 'TMPDIR', 'TEMP', 'TMP',
176
- 'COLORTERM', 'FORCE_COLOR', 'NO_COLOR', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION',
177
- // Windows
178
- 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE', 'COMPUTERNAME', 'USERNAME', 'HOMEDRIVE', 'HOMEPATH',
179
- 'SYSTEMROOT', 'WINDIR', 'PROGRAMFILES', 'PROGRAMDATA', 'COMMONPROGRAMFILES',
180
- // Node.js
181
- 'NODE_ENV', 'NODE_OPTIONS', 'NODE_PATH', 'NODE_DEBUG', 'NODE_NO_WARNINGS',
182
- // CI/CD platforms
183
- 'CI', 'CONTINUOUS_INTEGRATION', 'BUILD_NUMBER', 'BUILD_ID',
184
- 'GITHUB_ACTIONS', 'GITHUB_WORKFLOW', 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_SHA', 'GITHUB_REF',
185
- 'GITLAB_CI', 'CI_COMMIT_SHA', 'CI_PIPELINE_ID', 'CI_JOB_ID',
186
- 'CIRCLECI', 'CIRCLE_BUILD_NUM', 'CIRCLE_SHA1', 'CIRCLE_BRANCH',
187
- 'TRAVIS', 'TRAVIS_BUILD_NUMBER', 'TRAVIS_COMMIT',
188
- 'JENKINS_URL', 'BUILD_TAG', 'GIT_COMMIT',
189
- 'BUILDKITE', 'BUILDKITE_BUILD_NUMBER', 'BUILDKITE_COMMIT',
190
- 'CODEBUILD_BUILD_ID', 'CODEBUILD_RESOLVED_SOURCE_VERSION',
191
- 'VERCEL', 'VERCEL_ENV', 'VERCEL_URL', 'VERCEL_GIT_COMMIT_SHA',
192
- 'NETLIFY', 'CONTEXT', 'DEPLOY_PRIME_URL',
193
- 'RAILWAY_ENVIRONMENT', 'RAILWAY_GIT_COMMIT_SHA',
194
- 'HEROKU', 'DYNO', 'RENDER', 'FLY_APP_NAME',
195
- // CI user info
196
- 'GITHUB_ACTOR', 'GITLAB_USER_LOGIN', 'GITLAB_USER_NAME', 'GITLAB_USER_EMAIL',
197
- // Network/proxy
198
- 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
199
- 'HOSTNAME', 'HOST',
200
- // Debug/logging
201
- 'DEBUG', 'VERBOSE', 'LOG_LEVEL',
202
- // Editor/IDE
203
- 'EDITOR', 'VISUAL', 'VSCODE_PID', 'TERM_SESSION_ID',
204
- // Common optional vars that are often checked but not required
205
- 'PORT', 'npm_package_version', 'npm_package_name',
693
+ "HOME","USER","PATH","PWD","SHELL","TERM","LANG","TZ","TMPDIR","TEMP","TMP","COLORTERM","FORCE_COLOR","NO_COLOR",
694
+ "APPDATA","LOCALAPPDATA","USERPROFILE","COMPUTERNAME","USERNAME","HOMEDRIVE","HOMEPATH","SYSTEMROOT","WINDIR",
695
+ "PROGRAMFILES","PROGRAMDATA","COMMONPROGRAMFILES",
696
+ "NODE_ENV","NODE_OPTIONS","NODE_PATH","NODE_DEBUG","NODE_NO_WARNINGS",
697
+ "CI","CONTINUOUS_INTEGRATION","BUILD_NUMBER","BUILD_ID",
698
+ "GITHUB_ACTIONS","GITHUB_WORKFLOW","GITHUB_RUN_ID","GITHUB_RUN_NUMBER","GITHUB_SHA","GITHUB_REF","GITHUB_ACTOR",
699
+ "GITLAB_CI","CI_COMMIT_SHA","CI_PIPELINE_ID","CI_JOB_ID",
700
+ "CIRCLECI","CIRCLE_BUILD_NUM","CIRCLE_SHA1","CIRCLE_BRANCH",
701
+ "TRAVIS","TRAVIS_BUILD_NUMBER","TRAVIS_COMMIT",
702
+ "JENKINS_URL","BUILD_TAG","GIT_COMMIT",
703
+ "BUILDKITE","BUILDKITE_BUILD_NUMBER","BUILDKITE_COMMIT",
704
+ "CODEBUILD_BUILD_ID","CODEBUILD_RESOLVED_SOURCE_VERSION",
705
+ "VERCEL","VERCEL_ENV","VERCEL_URL","VERCEL_GIT_COMMIT_SHA",
706
+ "NETLIFY","CONTEXT","DEPLOY_PRIME_URL",
707
+ "RAILWAY_ENVIRONMENT","RAILWAY_GIT_COMMIT_SHA",
708
+ "HEROKU","DYNO","RENDER","FLY_APP_NAME",
709
+ "HTTP_PROXY","HTTPS_PROXY","NO_PROXY","http_proxy","https_proxy","no_proxy",
710
+ "HOSTNAME","HOST",
711
+ "DEBUG","VERBOSE","LOG_LEVEL",
712
+ "EDITOR","VISUAL","VSCODE_PID","TERM_SESSION_ID",
713
+ "PORT","npm_package_version","npm_package_name",
206
714
  ]);
207
-
208
- // Patterns for env vars that are commonly optional/internal and shouldn't BLOCK
715
+
716
+ // Patterns for env vars that are commonly optional/internal
209
717
  const optionalPatterns = [
210
- /^(OPENAI|ANTHROPIC|COHERE|AZURE|AWS|GCP|GOOGLE)_/i, // AI/Cloud providers (often optional)
211
- /^(STRIPE|PAYPAL|PLAID)_/i, // Payment providers (often optional in dev)
212
- /^(SENDGRID|RESEND|MAILGUN|SES)_/i, // Email providers
213
- /^(SENTRY|DATADOG|NEWRELIC|LOGROCKET)_/i, // Monitoring (optional)
214
- /^(REDIS|POSTGRES|MYSQL|MONGO|DATABASE)_/i, // Database (often has defaults)
215
- /^(NEXT_|NUXT_|VITE_|REACT_APP_)/i, // Framework prefixes
216
- /^(VIBECHECK|GUARDRAIL)_/i, // Our own vars
217
- /_(URL|KEY|SECRET|TOKEN|ID|PASSWORD|HOST|PORT)$/i, // Common suffixes (often optional)
218
- /^(ENABLE_|DISABLE_|USE_|SKIP_|ALLOW_|NO_)/i, // Feature flags (optional by nature)
219
- /^(MAX_|MIN_|DEFAULT_|TIMEOUT_|LIMIT_|RATE_)/i, // Config limits (have defaults)
220
- /^(LOG_|DEBUG_|VERBOSE_|TRACE_)/i, // Logging config
221
- /^(TEST_|DEV_|STAGING_|PROD_)/i, // Environment-specific
222
- /^(ARTIFACTS_|CACHE_|TMP_|OUTPUT_)/i, // Paths (have defaults)
223
- /^npm_/i, // npm internal
718
+ /^(OPENAI|ANTHROPIC|COHERE|AZURE|AWS|GCP|GOOGLE)_/i,
719
+ /^(STRIPE|PAYPAL|PLAID)_/i,
720
+ /^(SENDGRID|RESEND|MAILGUN|SES)_/i,
721
+ /^(SENTRY|DATADOG|NEWRELIC|LOGROCKET)_/i,
722
+ /^(REDIS|POSTGRES|MYSQL|MONGO|DATABASE)_/i,
723
+ /^(NEXT_|NUXT_|VITE_|REACT_APP_)/i,
724
+ /^(VIBECHECK|GUARDRAIL)_/i,
725
+ /_(URL|KEY|SECRET|TOKEN|ID|PASSWORD|HOST|PORT)$/i,
726
+ /^(ENABLE_|DISABLE_|USE_|SKIP_|ALLOW_|NO_)/i,
727
+ /^(MAX_|MIN_|DEFAULT_|TIMEOUT_|LIMIT_|RATE_)/i,
728
+ /^(LOG_|DEBUG_|VERBOSE_|TRACE_)/i,
729
+ /^(TEST_|DEV_|STAGING_|PROD_)/i,
730
+ /^(ARTIFACTS_|CACHE_|TMP_|OUTPUT_)/i,
731
+ /^npm_/i,
224
732
  ];
225
-
733
+
226
734
  function isOptionalEnvVar(name) {
227
- return optionalPatterns.some(p => p.test(name));
735
+ return optionalPatterns.some((p) => rxTest(p, name));
736
+ }
737
+
738
+ // Heuristic: treat vars referenced only in tooling/scripts as WARN (not BLOCK)
739
+ function evidenceIsToolingOnly(v) {
740
+ const refs = v.references || [];
741
+ if (!refs.length) return false;
742
+ return refs.every((r) => {
743
+ const f = String(r.file || "");
744
+ return /(^|\/)(scripts|tools|bin|cli|devops|infra|config)\//i.test(f);
745
+ });
228
746
  }
229
747
 
230
- // 1) USED but not declared in templates/examples
231
- // Only BLOCK for truly required vars, WARN for everything else, skip optional patterns
232
748
  for (const v of used) {
749
+ if (!v?.name) continue;
233
750
  if (declared.has(v.name)) continue;
234
- // Skip well-known system/CI env vars
235
751
  if (systemEnvVars.has(v.name)) continue;
236
- // Skip vars that match optional patterns (very common, likely have defaults)
237
752
  if (isOptionalEnvVar(v.name)) continue;
238
-
239
- // Only BLOCK if:
240
- // 1. Explicitly marked required AND
241
- // 2. No fallback detected AND
242
- // 3. Not a common optional pattern
243
- const isReallyRequired = v.required && !v.hasFallback;
753
+
754
+ const toolingOnly = evidenceIsToolingOnly(v);
755
+
756
+ // Only BLOCK if it's truly required, no fallback, and not tooling-only
757
+ const isReallyRequired = !!v.required && !v.hasFallback && !toolingOnly;
244
758
  const sev = isReallyRequired ? "BLOCK" : "WARN";
245
-
759
+
246
760
  findings.push({
247
761
  id: `F_ENV_UNDECLARED_${v.name}`,
248
762
  severity: sev,
249
763
  category: "EnvContract",
250
764
  title: `Env var used but not declared in env templates: ${v.name}`,
251
765
  why: isReallyRequired
252
- ? "Required env var is used with no fallback. Vibecoders will ship a broken app if it's not documented."
253
- : "Env var appears optional but should still be documented to prevent guesswork.",
766
+ ? "Required env var is used with no fallback and not documented. This ships broken installs."
767
+ : toolingOnly
768
+ ? "Env var appears used in tooling/scripts. Document it if users need it; otherwise ignore."
769
+ : "Env var appears optional/guarded but should still be documented to prevent guesswork.",
254
770
  confidence: isReallyRequired ? "high" : "low",
255
771
  evidence: v.references || [],
256
772
  fixHints: [
257
773
  `Add ${v.name}= to .env.example (or .env.template).`,
258
- "If it's truly optional, ensure the code has an explicit fallback or guard."
259
- ]
774
+ "If optional, ensure there's an explicit fallback or guard (and document expected behavior).",
775
+ ],
260
776
  });
261
777
  }
262
778
 
263
- // 2) Declared but never used => WARN (hygiene)
264
- const usedSet = new Set(used.map(v => v.name));
779
+ // Declared but never used
780
+ const usedSet = new Set(used.map((v) => v.name));
265
781
  for (const name of declared) {
266
782
  if (usedSet.has(name)) continue;
267
-
268
783
  findings.push({
269
784
  id: `F_ENV_UNUSED_${name}`,
270
785
  severity: "WARN",
271
786
  category: "EnvContract",
272
787
  title: `Env var declared but never used: ${name}`,
273
- why: "Dead config creates confusion and encourages hallucinated wiring.",
788
+ why: "Dead config creates confusion and invites hallucinated wiring.",
274
789
  confidence: "med",
275
790
  evidence: [],
276
791
  fixHints: [
277
- "Remove it from templates if it's obsolete, or wire it into code intentionally.",
278
- "If used at runtime only (in infra), document that explicitly."
279
- ]
792
+ "Remove it from templates if obsolete, or wire it intentionally.",
793
+ "If used only in infra/runtime, document that explicitly (where/why).",
794
+ ],
280
795
  });
281
796
  }
282
797
 
283
- // 3) If no template sources exist, warn loudly
284
798
  if (!declaredSources.length && used.length) {
285
799
  findings.push({
286
800
  id: "F_ENV_NO_TEMPLATE",
287
801
  severity: "WARN",
288
802
  category: "EnvContract",
289
803
  title: "No .env.example/.env.template found",
290
- why: "Without an env contract file, AI and humans will guess env vars and ship broken setups.",
804
+ why: "Without an env contract, humans and AI guess env vars and ship broken setups.",
291
805
  confidence: "high",
292
806
  evidence: [],
293
- fixHints: ["Add a .env.example that lists required/optional vars with comments."]
807
+ fixHints: ["Add a .env.example listing required/optional vars with comments."],
294
808
  });
295
809
  }
296
810
 
297
811
  return findings;
298
812
  }
299
813
 
300
- // ============================================================================
301
- // FAKE SUCCESS ANALYZER (INV_NO_FAKE_SUCCESS)
302
- // ============================================================================
814
+ /* ============================================================================
815
+ * FAKE SUCCESS ANALYZER (kept, but made safer & less noisy)
816
+ * ========================================================================== */
303
817
 
304
818
  function isToastSuccessCall(node) {
305
- return t.isCallExpression(node) &&
819
+ return !!(
820
+ t.isCallExpression(node) &&
306
821
  t.isMemberExpression(node.callee) &&
307
822
  t.isIdentifier(node.callee.object, { name: "toast" }) &&
308
- t.isIdentifier(node.callee.property, { name: "success" });
823
+ t.isIdentifier(node.callee.property, { name: "success" })
824
+ );
309
825
  }
310
826
 
311
827
  function isRouterPushCall(node) {
312
- return t.isCallExpression(node) && (
313
- (t.isMemberExpression(node.callee) &&
314
- t.isIdentifier(node.callee.property, { name: "push" })) ||
315
- (t.isIdentifier(node.callee) && (node.callee.name === "navigate"))
828
+ return (
829
+ t.isCallExpression(node) &&
830
+ ((t.isMemberExpression(node.callee) && t.isIdentifier(node.callee.property, { name: "push" })) ||
831
+ (t.isIdentifier(node.callee) && node.callee.name === "navigate"))
316
832
  );
317
833
  }
318
834
 
319
835
  function isFetchCall(node) {
320
- return t.isCallExpression(node) && t.isIdentifier(node.callee, { name: "fetch" });
836
+ return !!(t.isCallExpression(node) && t.isIdentifier(node.callee, { name: "fetch" }));
321
837
  }
322
838
 
323
839
  function isAxiosCall(node) {
324
- return t.isCallExpression(node) &&
840
+ return !!(
841
+ t.isCallExpression(node) &&
325
842
  t.isMemberExpression(node.callee) &&
326
843
  t.isIdentifier(node.callee.object, { name: "axios" }) &&
327
844
  t.isIdentifier(node.callee.property) &&
328
- ["get","post","put","patch","delete"].includes(node.callee.property.name);
845
+ ["get", "post", "put", "patch", "delete"].includes(node.callee.property.name)
846
+ );
329
847
  }
330
848
 
331
849
  function findFakeSuccess(repoRoot) {
@@ -333,78 +851,103 @@ function findFakeSuccess(repoRoot) {
333
851
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
334
852
  cwd: repoRoot,
335
853
  absolute: true,
336
- ignore: ["**/node_modules/**","**/.next/**","**/dist/**","**/build/**"]
854
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
337
855
  });
338
856
 
339
857
  for (const fileAbs of files) {
340
- const code = fs.readFileSync(fileAbs, "utf8");
858
+ const code = readFileCached(fileAbs);
859
+
860
+ // V3: FAST PATH OPTIMIZATION
861
+ // AST parsing is 100x slower than regex. Skip files that don't contain
862
+ // relevant keywords (toast/push/navigate AND fetch/axios).
863
+ const hasSuccessUI = /\b(toast|\.push|navigate)\b/.test(code);
864
+ const hasNetworkCall = /\b(fetch|axios)\b/.test(code);
865
+ if (!hasSuccessUI || !hasNetworkCall) {
866
+ continue;
867
+ }
868
+
341
869
  let ast;
342
- try { ast = parseFile(code); } catch { continue; }
870
+ try {
871
+ ast = parseFile(code, fileAbs);
872
+ } catch {
873
+ continue;
874
+ }
343
875
 
876
+ try {
344
877
  traverse(ast, {
345
878
  Function(pathFn) {
346
- let hasSuccess = false;
347
- let successLoc = null;
348
- let hasNetwork = false;
349
- let hasAwaitNetwork = false;
350
- let hasOkCheck = false;
879
+ // Collect call sites with positions to reduce false positives.
880
+ const successCalls = [];
881
+ const networkCalls = [];
882
+ const okChecks = [];
351
883
 
352
884
  pathFn.traverse({
353
885
  CallExpression(p) {
354
886
  const n = p.node;
355
887
 
356
888
  if (isToastSuccessCall(n) || isRouterPushCall(n)) {
357
- hasSuccess = true;
358
- successLoc = successLoc || n.loc;
889
+ successCalls.push({ loc: n.loc, pos: n.start ?? 0 });
359
890
  }
360
891
 
361
892
  if (isFetchCall(n) || isAxiosCall(n)) {
362
- hasNetwork = true;
363
- if (p.parentPath && p.parentPath.isAwaitExpression()) hasAwaitNetwork = true;
893
+ const isAwaited = p.parentPath && p.parentPath.isAwaitExpression();
894
+ networkCalls.push({ pos: n.start ?? 0, awaited: !!isAwaited });
364
895
  }
365
896
  },
366
897
  IfStatement(p) {
367
898
  const test = p.node.test;
368
- const text = code.slice(test.start || 0, test.end || 0);
369
- if (/\b(ok|status)\b/.test(text) && /(res|response)/.test(text)) {
370
- hasOkCheck = true;
371
- }
372
- }
899
+ const txt = code.slice(test.start || 0, test.end || 0);
900
+ if (/\b(res|response)\b/i.test(txt) && /\b(ok|status)\b/i.test(txt)) okChecks.push({ pos: p.node.start ?? 0 });
901
+ },
373
902
  });
374
903
 
375
- if (!hasSuccess || !hasNetwork) return;
904
+ if (!successCalls.length || !networkCalls.length) return;
376
905
 
377
- const severity = hasAwaitNetwork ? (hasOkCheck ? null : "WARN") : "BLOCK";
378
- if (!severity) return;
906
+ // For each success call: if there exists an awaited network call before it, it's less severe.
907
+ for (const sc of successCalls) {
908
+ const netBefore = networkCalls.filter((n) => n.pos < sc.pos);
909
+ const awaitedBefore = netBefore.some((n) => n.awaited);
910
+ const okCheckBefore = okChecks.some((c) => c.pos < sc.pos);
379
911
 
380
- const ev = evidenceFromLoc(fileAbs, repoRoot, successLoc, "Success UI call in networked flow");
381
- findings.push({
382
- id: `F_FAKE_SUCCESS_${String(findings.length + 1).padStart(3, "0")}`,
383
- severity,
384
- category: "FakeSuccess",
385
- title: severity === "BLOCK"
386
- ? "Success UI triggered without awaiting network call"
387
- : "Success UI triggered without verifying network result (res.ok/status)",
388
- why: severity === "BLOCK"
389
- ? "This ships lies. Users see success even when the request never completed."
390
- : "This often ships lies. You're not gating success on a real response.",
391
- confidence: "med",
392
- evidence: ev ? [ev] : [],
393
- fixHints: [
394
- "Await the network call (await fetch/await axios...).",
395
- "Gate success UI behind res.ok / status checks; surface errors otherwise."
396
- ]
397
- });
398
- }
912
+ // If no awaited call before success -> strong signal
913
+ const severity = awaitedBefore ? (okCheckBefore ? null : "WARN") : "BLOCK";
914
+ if (!severity) continue;
915
+
916
+ const ev = evidenceFromLoc(fileAbs, repoRoot, sc.loc, "Success UI call in networked flow");
917
+ findings.push({
918
+ id: stableId("F_FAKE_SUCCESS", `${path.relative(repoRoot, fileAbs)}:${sc.pos}:${severity}`),
919
+ severity,
920
+ category: "FakeSuccess",
921
+ title:
922
+ severity === "BLOCK"
923
+ ? "Success UI triggered without awaiting network call"
924
+ : "Success UI triggered without verifying network result (res.ok/status)",
925
+ why:
926
+ severity === "BLOCK"
927
+ ? "This ships lies. Users see success even when the request never completed."
928
+ : "You're not gating success on a real response; this often ships false success.",
929
+ confidence: "med",
930
+ evidence: ev ? [ev] : [],
931
+ fixHints: [
932
+ "Await the network call (await fetch/await axios...).",
933
+ "Gate success UI behind res.ok / status checks; surface errors otherwise.",
934
+ ],
935
+ });
936
+ }
937
+ },
399
938
  });
939
+ } catch {
940
+ // Babel traverse can fail on some edge-case files; skip them
941
+ continue;
942
+ }
400
943
  }
401
944
 
402
945
  return findings;
403
946
  }
404
947
 
405
- // ============================================================================
406
- // GHOST AUTH ANALYZER (INV_NO_GHOST_AUTH)
407
- // ============================================================================
948
+ /* ============================================================================
949
+ * GHOST AUTH ANALYZER (kept)
950
+ * ========================================================================== */
408
951
 
409
952
  function looksSensitive(pathStr) {
410
953
  const p = String(pathStr || "");
@@ -423,14 +966,13 @@ function looksSensitive(pathStr) {
423
966
 
424
967
  function hasRouteLevelProtection(routeDef) {
425
968
  const hooks = routeDef.hooks || [];
426
- if (hooks.includes("preHandler") || hooks.includes("onRequest") || hooks.includes("preValidation")) return true;
427
- return false;
969
+ return !!(hooks.includes("preHandler") || hooks.includes("onRequest") || hooks.includes("preValidation"));
428
970
  }
429
971
 
430
972
  function handlerHasAuthSignal(repoRoot, handlerRel) {
431
973
  const abs = path.join(repoRoot, handlerRel);
432
974
  if (!fs.existsSync(abs)) return false;
433
- const code = fs.readFileSync(abs, "utf8");
975
+ const code = readFileCached(abs);
434
976
 
435
977
  return (
436
978
  /\bgetServerSession\b|\bauth\(\)\b|\bclerk\b|@clerk\/nextjs|\bcreateRouteHandlerClient\b|@supabase/i.test(code) ||
@@ -441,7 +983,7 @@ function handlerHasAuthSignal(repoRoot, handlerRel) {
441
983
 
442
984
  function isProtectedByNextMiddleware(truthpack, routePath) {
443
985
  const patterns = truthpack?.auth?.nextMatcherPatterns || [];
444
- return matcherCoversPath(patterns, routePath);
986
+ return !!matcherCoversPath(patterns, routePath);
445
987
  }
446
988
 
447
989
  function findGhostAuth(truthpack, repoRoot) {
@@ -459,26 +1001,25 @@ function findGhostAuth(truthpack, repoRoot) {
459
1001
 
460
1002
  if (!protectedSomehow) {
461
1003
  findings.push({
462
- id: `F_GHOST_AUTH_${r.method}_${r.path}`.replace(/[^A-Z0-9_\/:*-]/gi, "_"),
1004
+ id: stableId("F_GHOST_AUTH", `${r.method} ${r.path}`),
463
1005
  severity: "BLOCK",
464
1006
  category: "GhostAuth",
465
1007
  title: `Sensitive endpoint appears unprotected: ${r.method} ${r.path}`,
466
- why: "This is how apps get owned. UI gating doesn't matter. If the server doesn't enforce auth, it's public.",
1008
+ why: "If the server doesn't enforce auth, it's public. UI gating is irrelevant.",
467
1009
  confidence: "med",
468
1010
  evidence: (r.evidence || []).slice(0, 2),
469
1011
  fixHints: [
470
- "Add server-side auth verification in the handler (session/jwt).",
471
- "Or protect the path via Next middleware matcher (and verify it actually applies).",
472
- "If Fastify: add preHandler/onRequest auth hook and ensure it's registered for this route."
473
- ]
1012
+ "Add server-side auth verification (session/jwt).",
1013
+ "Or protect the path via middleware matcher (verify it actually applies).",
1014
+ "If Fastify: add preHandler/onRequest auth hook and ensure it's registered for this route.",
1015
+ ],
474
1016
  });
475
1017
  }
476
1018
  }
477
1019
 
478
- // If there IS middleware but it doesn't cover obvious sensitive prefixes, warn
479
1020
  const patterns = truthpack?.auth?.nextMatcherPatterns || [];
480
1021
  if (patterns.length) {
481
- const coversApi = patterns.some(p => String(p).includes("/api"));
1022
+ const coversApi = patterns.some((p) => String(p).includes("/api"));
482
1023
  if (!coversApi) {
483
1024
  findings.push({
484
1025
  id: "F_MIDDLEWARE_NOT_COVERING_API",
@@ -488,7 +1029,7 @@ function findGhostAuth(truthpack, repoRoot) {
488
1029
  why: "People assume middleware protects APIs. Often it doesn't. Verify matcher patterns.",
489
1030
  confidence: "high",
490
1031
  evidence: (truthpack?.auth?.nextMiddleware?.[0]?.evidence || []).slice(0, 3),
491
- fixHints: ["Add /api/:path* to middleware matcher if your design expects API auth protection."]
1032
+ fixHints: ["Add /api/:path* to middleware matcher if your design expects API auth protection."],
492
1033
  });
493
1034
  }
494
1035
  }
@@ -496,9 +1037,9 @@ function findGhostAuth(truthpack, repoRoot) {
496
1037
  return findings;
497
1038
  }
498
1039
 
499
- // ============================================================================
500
- // STRIPE WEBHOOK VIOLATIONS (INV_WEBHOOK_VERIFIED + INV_WEBHOOK_IDEMPOTENT)
501
- // ============================================================================
1040
+ /* ============================================================================
1041
+ * STRIPE WEBHOOK VIOLATIONS (kept)
1042
+ * ========================================================================== */
502
1043
 
503
1044
  function findStripeWebhookViolations(truthpack) {
504
1045
  const findings = [];
@@ -514,10 +1055,10 @@ function findStripeWebhookViolations(truthpack) {
514
1055
  severity: "WARN",
515
1056
  category: "Billing",
516
1057
  title: "Stripe appears used but no webhook handler candidate detected",
517
- why: "If you bill with Stripe, webhooks are usually required. Missing webhooks often means subscription state desync.",
1058
+ why: "Stripe billing usually needs webhooks; missing them causes subscription state desync.",
518
1059
  confidence: "med",
519
1060
  evidence: [],
520
- fixHints: ["Add a Stripe webhook handler with signature verification and idempotency."]
1061
+ fixHints: ["Add a Stripe webhook handler with signature verification and idempotency."],
521
1062
  });
522
1063
  return findings;
523
1064
  }
@@ -528,34 +1069,34 @@ function findStripeWebhookViolations(truthpack) {
528
1069
 
529
1070
  if (!verified) {
530
1071
  findings.push({
531
- id: `F_STRIPE_WEBHOOK_NOT_VERIFIED_${w.file.replace(/[^a-z0-9]/gi, "_")}`,
1072
+ id: stableId("F_STRIPE_WEBHOOK_NOT_VERIFIED", w.file),
532
1073
  severity: "BLOCK",
533
1074
  category: "Billing",
534
1075
  title: `Stripe webhook handler not clearly signature-verified: ${w.file}`,
535
- why: "Unverified webhooks = spoofable billing state. That's catastrophic.",
1076
+ why: "Unverified webhooks = spoofable billing state.",
536
1077
  confidence: "high",
537
1078
  evidence: (w.evidence || []).slice(0, 4),
538
1079
  fixHints: [
539
1080
  "Use stripe.webhooks.constructEvent(rawBody, sigHeader, STRIPE_WEBHOOK_SECRET).",
540
1081
  "Ensure raw body is used (disable bodyParser in pages router; in app router read req.text()/arrayBuffer).",
541
- "Reject if signature missing/invalid."
542
- ]
1082
+ "Reject if signature missing/invalid.",
1083
+ ],
543
1084
  });
544
1085
  }
545
1086
 
546
1087
  if (!idempotent) {
547
1088
  findings.push({
548
- id: `F_STRIPE_WEBHOOK_NOT_IDEMPOTENT_${w.file.replace(/[^a-z0-9]/gi, "_")}`,
1089
+ id: stableId("F_STRIPE_WEBHOOK_NOT_IDEMPOTENT", w.file),
549
1090
  severity: "BLOCK",
550
1091
  category: "Billing",
551
1092
  title: `Stripe webhook handler not clearly idempotent: ${w.file}`,
552
- why: "Stripe retries webhooks. Without dedupe, you'll double-grant access, double-send emails, or double-write state.",
1093
+ why: "Stripe retries webhooks; without dedupe you can double-grant access or double-write state.",
553
1094
  confidence: "med",
554
1095
  evidence: (w.evidence || []).slice(0, 4),
555
1096
  fixHints: [
556
1097
  "Persist event.id as processed (DB/Redis). If seen, return 200 immediately.",
557
- "Wrap state mutation in a transaction keyed by event.id."
558
- ]
1098
+ "Wrap state mutation in a transaction keyed by event.id.",
1099
+ ],
559
1100
  });
560
1101
  }
561
1102
  }
@@ -563,176 +1104,186 @@ function findStripeWebhookViolations(truthpack) {
563
1104
  return findings;
564
1105
  }
565
1106
 
566
- // ============================================================================
567
- // PAID SURFACE NOT ENFORCED (INV_PAID_FEATURE_ENFORCED_SERVER_SIDE)
568
- // ============================================================================
1107
+ /* ============================================================================
1108
+ * PAID SURFACE NOT ENFORCED (kept)
1109
+ * ========================================================================== */
569
1110
 
570
1111
  function findPaidSurfaceNotEnforced(truthpack) {
571
1112
  const findings = [];
572
1113
  const enforcement = truthpack?.enforcement;
573
-
574
1114
  const checks = enforcement?.checks || [];
1115
+
575
1116
  for (const c of checks) {
576
1117
  if (c.enforced) continue;
577
-
578
1118
  findings.push({
579
- id: `F_PAID_SURFACE_NOT_ENFORCED_${c.method}_${c.path}`.replace(/[^a-z0-9]/gi, "_"),
1119
+ id: stableId("F_PAID_SURFACE_NOT_ENFORCED", `${c.method} ${c.path}`),
580
1120
  severity: "BLOCK",
581
1121
  category: "Entitlements",
582
1122
  title: `Paid surface appears un-enforced server-side: ${c.method} ${c.path}`,
583
- why: "If enforcement is only in the CLI/UI, users can call the endpoint directly. That's a free enterprise bypass.",
1123
+ why: "If enforcement is only in the CLI/UI, users can call the endpoint directly.",
584
1124
  confidence: "med",
585
1125
  evidence: [],
586
1126
  fixHints: [
587
- "Add enforceFeature/enforceLimit in the server handler BEFORE doing work.",
1127
+ "Enforce in the server handler BEFORE doing work.",
588
1128
  "Return 402/403 with a structured error code.",
589
- "Make the CLI treat that code as an upgrade prompt."
590
- ]
1129
+ "Make the CLI treat that code as an upgrade prompt.",
1130
+ ],
591
1131
  });
592
1132
  }
593
1133
  return findings;
594
1134
  }
595
1135
 
596
- // ============================================================================
597
- // OWNER MODE BYPASS (INV_NO_OWNER_MODE_BYPASS)
598
- // ============================================================================
1136
+ /* ============================================================================
1137
+ * OWNER MODE BYPASS (kept; uses deterministic regex testing)
1138
+ * ========================================================================== */
599
1139
 
600
1140
  function findOwnerModeBypass(repoRoot) {
601
1141
  const findings = [];
602
1142
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
603
1143
  cwd: repoRoot,
604
1144
  absolute: true,
605
- ignore: ["**/node_modules/**","**/.next/**","**/dist/**","**/build/**"]
1145
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
606
1146
  });
607
1147
 
608
1148
  const patterns = [
609
1149
  /OWNER_MODE/i,
610
1150
  /GUARDRAIL_OWNER_MODE/i,
611
1151
  /VIBECHECK_OWNER_MODE/i,
612
- /process\.env\.[A-Z0-9_]*OWNER[A-Z0-9_]*/i
1152
+ /process\.env\.[A-Z0-9_]*OWNER[A-Z0-9_]*/i,
613
1153
  ];
614
1154
 
615
1155
  for (const fileAbs of files) {
616
- const code = fs.readFileSync(fileAbs, "utf8");
617
- const hit = patterns.some(rx => rx.test(code));
1156
+ const code = readFileCached(fileAbs);
1157
+ const hit = patterns.some((rx) => rxTest(rx, code));
618
1158
  if (!hit) continue;
619
1159
 
620
1160
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
621
1161
 
622
1162
  findings.push({
623
- id: `F_OWNER_MODE_BYPASS_${fileRel.replace(/[^a-z0-9]/gi, "_")}`,
1163
+ id: stableId("F_OWNER_MODE_BYPASS", fileRel),
624
1164
  severity: "BLOCK",
625
1165
  category: "Security",
626
1166
  title: `Owner mode / env bypass signal detected: ${fileRel}`,
627
- why: "This is a production backdoor unless it's cryptographically gated. It cannot ship.",
1167
+ why: "This is a production backdoor unless cryptographically gated. It cannot ship.",
628
1168
  confidence: "high",
629
1169
  evidence: [],
630
1170
  fixHints: [
631
1171
  "Delete owner mode bypass. If you need dev override, require a signed admin token + non-prod environment.",
632
- "Add a test that asserts no OWNER_MODE env var grants entitlements."
633
- ]
1172
+ "Add a test that asserts no OWNER_MODE env var grants entitlements.",
1173
+ ],
634
1174
  });
635
1175
  }
636
1176
 
637
1177
  return findings;
638
1178
  }
639
1179
 
640
- // ============================================================================
641
- // MOCK DATA DETECTOR
642
- // ============================================================================
1180
+ /* ============================================================================
1181
+ * MOCK DATA DETECTOR (fixed /g+.test() bug + better line discovery)
1182
+ * ========================================================================== */
643
1183
 
644
1184
  function findMockData(repoRoot) {
645
1185
  const findings = [];
646
1186
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
647
1187
  cwd: repoRoot,
648
1188
  absolute: true,
649
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/*.test.*", "**/*.spec.*", "**/tests/**", "**/test/**", "**/__tests__/**", "**/mocks/**", "**/__mocks__/**"]
1189
+ ignore: [
1190
+ "**/node_modules/**",
1191
+ "**/.next/**",
1192
+ "**/dist/**",
1193
+ "**/build/**",
1194
+ "**/*.test.*",
1195
+ "**/*.spec.*",
1196
+ "**/tests/**",
1197
+ "**/test/**",
1198
+ "**/__tests__/**",
1199
+ "**/mocks/**",
1200
+ "**/__mocks__/**",
1201
+ ],
650
1202
  });
651
1203
 
652
1204
  const mockPatterns = [
653
- { rx: /\bmockData\b/gi, label: "mockData variable" },
654
- { rx: /\bfakeData\b/gi, label: "fakeData variable" },
655
- { rx: /\bdummyData\b/gi, label: "dummyData variable" },
656
- { rx: /\btestData\b/gi, label: "testData variable (in production code)" },
657
- { rx: /\bsampleData\b/gi, label: "sampleData variable" },
658
- { rx: /['"]fake[_-]?user['"]|['"]test[_-]?user['"]|['"]demo[_-]?user['"]/gi, label: "Hardcoded test user" },
659
- { rx: /['"]password123['"]|['"]test123['"]|['"]admin123['"]|['"]secret123['"]/gi, label: "Hardcoded test password" },
660
- { rx: /['"]test@(test|example|fake)\.com['"]/gi, label: "Hardcoded test email" },
661
- { rx: /\bMOCK_API\b|\bFAKE_API\b|\bDUMMY_API\b/gi, label: "Mock API reference" },
662
- { rx: /setTimeout\([^)]*[5-9]\d{3,}|setTimeout\([^)]*\d{5,}/g, label: "Long setTimeout (simulated delay?)" },
663
- { rx: /Math\.random\(\)\s*[*<>]\s*\d+/g, label: "Random data generation" },
664
- { rx: /\bplaceholder\b.*\bdata\b|\bdata\b.*\bplaceholder\b/gi, label: "Placeholder data" },
1205
+ { rx: /\bmockData\b/i, label: "mockData variable" },
1206
+ { rx: /\bfakeData\b/i, label: "fakeData variable" },
1207
+ { rx: /\bdummyData\b/i, label: "dummyData variable" },
1208
+ { rx: /\btestData\b/i, label: "testData variable (in production code)" },
1209
+ { rx: /\bsampleData\b/i, label: "sampleData variable" },
1210
+ { rx: /['"]fake[_-]?user['"]|['"]test[_-]?user['"]|['"]demo[_-]?user['"]/i, label: "Hardcoded test user" },
1211
+ { rx: /['"]password123['"]|['"]test123['"]|['"]admin123['"]|['"]secret123['"]/i, label: "Hardcoded test password" },
1212
+ { rx: /['"]test@(test|example|fake)\.com['"]/i, label: "Hardcoded test email" },
1213
+ { rx: /\b(MOCK_API|FAKE_API|DUMMY_API)\b/i, label: "Mock API reference" },
1214
+ { rx: /setTimeout\([^)]*[5-9]\d{3,}|setTimeout\([^)]*\d{5,}/i, label: "Long setTimeout (simulated delay?)" },
1215
+ { rx: /Math\.random\(\)\s*[*<>]\s*\d+/i, label: "Random data generation" },
1216
+ { rx: /\bplaceholder\b.*\bdata\b|\bdata\b.*\bplaceholder\b/i, label: "Placeholder data" },
665
1217
  ];
666
1218
 
667
1219
  for (const fileAbs of files) {
668
1220
  try {
669
- const code = fs.readFileSync(fileAbs, "utf8");
1221
+ const code = readFileCached(fileAbs);
670
1222
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
671
-
672
- // Skip if file looks like a test/mock file even if not in test folder
1223
+
673
1224
  if (/\.(test|spec|mock|fake|stub)\./i.test(fileRel)) continue;
674
1225
  if (/mock|fake|test|spec|fixture/i.test(fileRel) && !/src\//.test(fileRel)) continue;
675
-
1226
+
1227
+ const lines = code.split("\n");
1228
+
676
1229
  for (const { rx, label } of mockPatterns) {
677
- const matches = code.match(rx);
678
- if (matches && matches.length > 0) {
679
- const lines = code.split('\n');
680
- let lineNum = 1;
681
- for (let i = 0; i < lines.length; i++) {
682
- if (rx.test(lines[i])) {
683
- lineNum = i + 1;
684
- break;
685
- }
1230
+ if (!rxTest(rx, code)) continue;
1231
+
1232
+ let lineNum = 1;
1233
+ for (let i = 0; i < lines.length; i++) {
1234
+ if (rxTest(rx, lines[i])) {
1235
+ lineNum = i + 1;
1236
+ break;
686
1237
  }
687
-
688
- findings.push({
689
- id: `F_MOCK_DATA_${fileRel.replace(/[^a-z0-9]/gi, "_")}_${label.replace(/[^a-z0-9]/gi, "_")}`,
690
- severity: "WARN",
691
- category: "MockData",
692
- title: `${label} in production code: ${fileRel}`,
693
- why: "Mock/fake data in production causes embarrassing bugs and makes your app look unfinished.",
694
- confidence: "med",
695
- evidence: [{ file: fileRel, lines: `${lineNum}`, reason: label }],
696
- fixHints: [
697
- "Replace mock data with real API calls or database queries.",
698
- "If this is intentional sample data, move to a clearly marked demo mode."
699
- ]
700
- });
701
- break; // One finding per file per pattern type
702
1238
  }
1239
+
1240
+ findings.push({
1241
+ id: stableId("F_MOCK_DATA", `${fileRel}:${label}`),
1242
+ severity: "WARN",
1243
+ category: "MockData",
1244
+ title: `${label} in production code: ${fileRel}`,
1245
+ why: "Mock/fake data in production causes embarrassing bugs and makes your app look unfinished.",
1246
+ confidence: "med",
1247
+ evidence: [{ file: fileRel, lines: `${lineNum}`, reason: label }],
1248
+ fixHints: [
1249
+ "Replace mock data with real API calls or database queries.",
1250
+ "If intentional sample data, move to a clearly marked demo mode.",
1251
+ ],
1252
+ });
1253
+ break;
703
1254
  }
704
- } catch (e) {
705
- // Skip unreadable files
1255
+ } catch {
1256
+ // skip
706
1257
  }
707
1258
  }
708
1259
 
709
1260
  return findings;
710
1261
  }
711
1262
 
712
- // ============================================================================
713
- // TODO/FIXME DETECTOR
714
- // ============================================================================
1263
+ /* ============================================================================
1264
+ * TODO/FIXME DETECTOR (fixed /g+.test() bug)
1265
+ * ========================================================================== */
715
1266
 
716
1267
  function findTodoFixme(repoRoot) {
717
1268
  const findings = [];
718
1269
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
719
1270
  cwd: repoRoot,
720
1271
  absolute: true,
721
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"]
1272
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
722
1273
  });
723
1274
 
724
1275
  const todoPatterns = [
725
- { rx: /\/\/\s*TODO[\s:]/gi, label: "TODO comment", severity: "WARN" },
726
- { rx: /\/\/\s*FIXME[\s:]/gi, label: "FIXME comment", severity: "WARN" },
727
- { rx: /\/\/\s*HACK[\s:]/gi, label: "HACK comment", severity: "WARN" },
728
- { rx: /\/\/\s*XXX[\s:]/gi, label: "XXX comment", severity: "WARN" },
729
- { rx: /\/\/\s*BUG[\s:]/gi, label: "BUG comment", severity: "BLOCK" },
730
- { rx: /\/\/\s*BROKEN[\s:]/gi, label: "BROKEN comment", severity: "BLOCK" },
731
- { rx: /\/\/\s*URGENT[\s:]/gi, label: "URGENT comment", severity: "BLOCK" },
732
- { rx: /\/\/\s*SECURITY[\s:]/gi, label: "SECURITY comment", severity: "BLOCK" },
733
- { rx: /\/\/\s*DANGER[\s:]/gi, label: "DANGER comment", severity: "BLOCK" },
734
- { rx: /\/\*\s*TODO[\s:]/gi, label: "TODO block comment", severity: "WARN" },
735
- { rx: /\/\*\s*FIXME[\s:]/gi, label: "FIXME block comment", severity: "WARN" },
1276
+ { rx: /\/\/\s*TODO[\s:]/i, label: "TODO comment", severity: "WARN" },
1277
+ { rx: /\/\/\s*FIXME[\s:]/i, label: "FIXME comment", severity: "WARN" },
1278
+ { rx: /\/\/\s*HACK[\s:]/i, label: "HACK comment", severity: "WARN" },
1279
+ { rx: /\/\/\s*XXX[\s:]/i, label: "XXX comment", severity: "WARN" },
1280
+ { rx: /\/\/\s*BUG[\s:]/i, label: "BUG comment", severity: "BLOCK" },
1281
+ { rx: /\/\/\s*BROKEN[\s:]/i, label: "BROKEN comment", severity: "BLOCK" },
1282
+ { rx: /\/\/\s*URGENT[\s:]/i, label: "URGENT comment", severity: "BLOCK" },
1283
+ { rx: /\/\/\s*SECURITY[\s:]/i, label: "SECURITY comment", severity: "BLOCK" },
1284
+ { rx: /\/\/\s*DANGER[\s:]/i, label: "DANGER comment", severity: "BLOCK" },
1285
+ { rx: /\/\*\s*TODO[\s:]/i, label: "TODO block comment", severity: "WARN" },
1286
+ { rx: /\/\*\s*FIXME[\s:]/i, label: "FIXME block comment", severity: "WARN" },
736
1287
  ];
737
1288
 
738
1289
  let todoCount = 0;
@@ -741,51 +1292,49 @@ function findTodoFixme(repoRoot) {
741
1292
 
742
1293
  for (const fileAbs of files) {
743
1294
  try {
744
- const code = fs.readFileSync(fileAbs, "utf8");
1295
+ const code = readFileCached(fileAbs);
745
1296
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
746
- const lines = code.split('\n');
747
-
1297
+ const lines = code.split("\n");
1298
+
748
1299
  for (let i = 0; i < lines.length; i++) {
749
1300
  const line = lines[i];
750
-
751
1301
  for (const { rx, label, severity } of todoPatterns) {
752
- if (rx.test(line)) {
753
- if (label.includes("TODO")) todoCount++;
754
- if (label.includes("FIXME")) fixmeCount++;
755
-
756
- // Only emit individual findings up to limit
757
- if (findings.length < MAX_INDIVIDUAL_FINDINGS) {
758
- const snippet = line.trim().slice(0, 80);
759
- findings.push({
760
- id: `F_TODO_${fileRel.replace(/[^a-z0-9]/gi, "_")}_L${i + 1}`,
761
- severity,
762
- category: "TodoFixme",
763
- title: `${label}: ${snippet}${line.length > 80 ? '...' : ''}`,
764
- why: severity === "BLOCK"
1302
+ if (!rxTest(rx, line)) continue;
1303
+
1304
+ if (label.includes("TODO")) todoCount++;
1305
+ if (label.includes("FIXME")) fixmeCount++;
1306
+
1307
+ if (findings.length < MAX_INDIVIDUAL_FINDINGS) {
1308
+ const snippet = line.trim().slice(0, 80);
1309
+ findings.push({
1310
+ id: stableId("F_TODO", `${fileRel}:${i + 1}:${label}`),
1311
+ severity,
1312
+ category: "TodoFixme",
1313
+ title: `${label}: ${snippet}${line.length > 80 ? "..." : ""}`,
1314
+ why:
1315
+ severity === "BLOCK"
765
1316
  ? "This comment indicates a known critical issue that must be addressed before shipping."
766
1317
  : "Unfinished work markers suggest the code isn't production-ready.",
767
- confidence: "high",
768
- evidence: [{ file: fileRel, lines: `${i + 1}`, reason: label }],
769
- fixHints: [
770
- "Complete the TODO or remove it if already done.",
771
- "If deferring, create a tracked issue and reference it in the comment."
772
- ]
773
- });
774
- }
775
- break; // One finding per line
1318
+ confidence: "high",
1319
+ evidence: [{ file: fileRel, lines: `${i + 1}`, reason: label }],
1320
+ fixHints: [
1321
+ "Complete the TODO or remove it if already done.",
1322
+ "If deferring, create a tracked issue and reference it in the comment.",
1323
+ ],
1324
+ });
776
1325
  }
1326
+ break;
777
1327
  }
778
1328
  }
779
- } catch (e) {
780
- // Skip unreadable files
1329
+ } catch {
1330
+ // skip
781
1331
  }
782
1332
  }
783
1333
 
784
- // Add summary finding if there are many TODOs
785
1334
  const totalTodos = todoCount + fixmeCount;
786
1335
  if (totalTodos > MAX_INDIVIDUAL_FINDINGS) {
787
1336
  findings.push({
788
- id: `F_TODO_SUMMARY`,
1337
+ id: "F_TODO_SUMMARY",
789
1338
  severity: "WARN",
790
1339
  category: "TodoFixme",
791
1340
  title: `${totalTodos} TODO/FIXME comments found (${totalTodos - MAX_INDIVIDUAL_FINDINGS} more not shown)`,
@@ -794,24 +1343,35 @@ function findTodoFixme(repoRoot) {
794
1343
  evidence: [],
795
1344
  fixHints: [
796
1345
  "Review and address high-priority TODOs before shipping.",
797
- `Run: grep -rn "TODO\\|FIXME" --include="*.ts" --include="*.js" .`
798
- ]
1346
+ `Run: grep -rn "TODO\\|FIXME" --include="*.ts" --include="*.js" .`,
1347
+ ],
799
1348
  });
800
1349
  }
801
1350
 
802
1351
  return findings;
803
1352
  }
804
1353
 
805
- // ============================================================================
806
- // CONSOLE.LOG DETECTOR
807
- // ============================================================================
1354
+ /* ============================================================================
1355
+ * CONSOLE.LOG DETECTOR (kept)
1356
+ * ========================================================================== */
808
1357
 
809
1358
  function findConsoleLogs(repoRoot) {
810
1359
  const findings = [];
811
1360
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
812
1361
  cwd: repoRoot,
813
1362
  absolute: true,
814
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**", "**/scripts/**", "**/bin/**"]
1363
+ ignore: [
1364
+ "**/node_modules/**",
1365
+ "**/.next/**",
1366
+ "**/dist/**",
1367
+ "**/build/**",
1368
+ "**/*.test.*",
1369
+ "**/*.spec.*",
1370
+ "**/tests/**",
1371
+ "**/__tests__/**",
1372
+ "**/scripts/**",
1373
+ "**/bin/**",
1374
+ ],
815
1375
  });
816
1376
 
817
1377
  let consoleCount = 0;
@@ -819,327 +1379,344 @@ function findConsoleLogs(repoRoot) {
819
1379
 
820
1380
  for (const fileAbs of files) {
821
1381
  try {
822
- const code = fs.readFileSync(fileAbs, "utf8");
1382
+ const code = readFileCached(fileAbs);
823
1383
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
824
-
825
- // Skip config/setup files
1384
+
826
1385
  if (/config|setup|jest|vitest|eslint|prettier/i.test(fileRel)) continue;
827
-
828
- const lines = code.split('\n');
829
-
1386
+
1387
+ const lines = code.split("\n");
1388
+
830
1389
  for (let i = 0; i < lines.length; i++) {
831
1390
  const line = lines[i];
832
-
833
- // Match console.log, console.warn, console.error, console.debug
834
1391
  if (/console\.(log|warn|debug|info|trace)\s*\(/.test(line)) {
835
- // Skip if it's commented out
836
1392
  if (/^\s*\/\//.test(line)) continue;
837
-
1393
+
838
1394
  consoleCount++;
839
-
1395
+
840
1396
  if (findings.length < MAX_INDIVIDUAL_FINDINGS) {
841
1397
  const snippet = line.trim().slice(0, 60);
842
1398
  findings.push({
843
- id: `F_CONSOLE_LOG_${fileRel.replace(/[^a-z0-9]/gi, "_")}_L${i + 1}`,
1399
+ id: stableId("F_CONSOLE_LOG", `${fileRel}:${i + 1}`),
844
1400
  severity: "WARN",
845
1401
  category: "ConsoleLog",
846
1402
  title: `console.log in production code: ${fileRel}:${i + 1}`,
847
- why: "Console statements leak debugging info to users and clutter browser console.",
1403
+ why: "Console statements leak debugging info and clutter logs/console.",
848
1404
  confidence: "high",
849
1405
  evidence: [{ file: fileRel, lines: `${i + 1}`, reason: snippet }],
850
- fixHints: [
851
- "Remove console.log or replace with a proper logger.",
852
- "Use a logger that can be silenced in production."
853
- ]
1406
+ fixHints: ["Remove console.log or replace with a proper logger.", "Use a logger that can be silenced in production."],
854
1407
  });
855
1408
  }
856
1409
  }
857
1410
  }
858
- } catch (e) {
859
- // Skip unreadable files
1411
+ } catch {
1412
+ // skip
860
1413
  }
861
1414
  }
862
1415
 
863
- // Add summary if there are many console logs
864
1416
  if (consoleCount > MAX_INDIVIDUAL_FINDINGS) {
865
1417
  findings.push({
866
- id: `F_CONSOLE_LOG_SUMMARY`,
1418
+ id: "F_CONSOLE_LOG_SUMMARY",
867
1419
  severity: "WARN",
868
1420
  category: "ConsoleLog",
869
1421
  title: `${consoleCount} console.log statements found (${consoleCount - MAX_INDIVIDUAL_FINDINGS} more not shown)`,
870
1422
  why: "Large numbers of console statements suggest debugging code left in production.",
871
1423
  confidence: "high",
872
1424
  evidence: [],
873
- fixHints: [
874
- "Use ESLint no-console rule to catch these automatically.",
875
- "Replace with a proper logging library (pino, winston, etc.)."
876
- ]
1425
+ fixHints: ["Use ESLint no-console to catch automatically.", "Replace with a proper logging library (pino, winston, etc.)."],
877
1426
  });
878
1427
  }
879
1428
 
880
1429
  return findings;
881
1430
  }
882
1431
 
883
- // ============================================================================
884
- // HARDCODED SECRETS DETECTOR
885
- // ============================================================================
1432
+ /* ============================================================================
1433
+ * HARDCODED SECRETS DETECTOR (kept)
1434
+ * ========================================================================== */
886
1435
 
887
1436
  function findHardcodedSecrets(repoRoot) {
888
1437
  const findings = [];
889
1438
  const files = fg.sync(["**/*.{ts,tsx,js,jsx,json}"], {
890
1439
  cwd: repoRoot,
891
1440
  absolute: true,
892
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/package*.json", "**/*.test.*", "**/tests/**"]
1441
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/package*.json", "**/*.test.*", "**/tests/**"],
893
1442
  });
894
1443
 
895
- const secretPatterns = [
896
- { rx: /['"]sk_live_[a-zA-Z0-9]{20,}['"]/g, label: "Stripe live secret key" },
897
- { rx: /['"]sk_test_[a-zA-Z0-9]{20,}['"]/g, label: "Stripe test secret key" },
898
- { rx: /['"]pk_live_[a-zA-Z0-9]{20,}['"]/g, label: "Stripe live publishable key" },
899
- { rx: /['"]AKIA[0-9A-Z]{16}['"]/g, label: "AWS Access Key ID" },
900
- { rx: /['"][a-zA-Z0-9+\/]{40}['"]/g, label: "Possible AWS Secret Key" },
901
- { rx: /['"]ghp_[a-zA-Z0-9]{36}['"]/g, label: "GitHub Personal Access Token" },
902
- { rx: /['"]gho_[a-zA-Z0-9]{36}['"]/g, label: "GitHub OAuth Token" },
903
- { rx: /['"]xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}['"]/g, label: "Slack Token" },
904
- { rx: /['"]eyJ[a-zA-Z0-9_-]{100,}\.[a-zA-Z0-9_-]{100,}\.[a-zA-Z0-9_-]{43,}['"]/g, label: "JWT Token (hardcoded)" },
905
- { rx: /password\s*[:=]\s*['"][^'"]{8,}['"]/gi, label: "Hardcoded password" },
906
- { rx: /api[_-]?key\s*[:=]\s*['"][a-zA-Z0-9]{20,}['"]/gi, label: "Hardcoded API key" },
907
- { rx: /secret\s*[:=]\s*['"][a-zA-Z0-9]{16,}['"]/gi, label: "Hardcoded secret" },
1444
+ // V3: Split patterns into "specific" (prefix-based, high confidence) and "generic" (entropy-based)
1445
+ // Specific patterns have distinctive prefixes - no entropy check needed
1446
+ const specificPatterns = [
1447
+ { rx: /['"]sk_live_[a-zA-Z0-9]{20,}['"]/g, label: "Stripe live secret key", severity: "BLOCK" },
1448
+ { rx: /['"]sk_test_[a-zA-Z0-9]{20,}['"]/g, label: "Stripe test secret key", severity: "WARN" },
1449
+ { rx: /['"]pk_live_[a-zA-Z0-9]{20,}['"]/g, label: "Stripe live publishable key", severity: "BLOCK" },
1450
+ { rx: /['"]AKIA[0-9A-Z]{16}['"]/g, label: "AWS Access Key ID", severity: "BLOCK" },
1451
+ { rx: /['"]ghp_[a-zA-Z0-9]{36}['"]/g, label: "GitHub Personal Access Token", severity: "BLOCK" },
1452
+ { rx: /['"]gho_[a-zA-Z0-9]{36}['"]/g, label: "GitHub OAuth Token", severity: "BLOCK" },
1453
+ { rx: /['"]xox[baprs]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24}['"]/g, label: "Slack Token", severity: "BLOCK" },
1454
+ { rx: /['"]eyJ[a-zA-Z0-9_-]{100,}\.[a-zA-Z0-9_-]{100,}\.[a-zA-Z0-9_-]{43,}['"]/g, label: "JWT Token (hardcoded)", severity: "WARN" },
1455
+ ];
1456
+
1457
+ // V3: Generic patterns need Shannon entropy check to avoid false positives (Git SHAs, image IDs, etc.)
1458
+ const genericPatterns = [
1459
+ { rx: /['"]([a-zA-Z0-9+/]{40})['"]/g, label: "Possible AWS Secret Key", minEntropy: 4.5 },
1460
+ { rx: /password\s*[:=]\s*['"]([^'"]{8,})['"]/gi, label: "Hardcoded password", minEntropy: 3.0 },
1461
+ { rx: /api[_-]?key\s*[:=]\s*['"]([a-zA-Z0-9]{20,})['"]/gi, label: "Hardcoded API key", minEntropy: 4.0 },
1462
+ { rx: /secret\s*[:=]\s*['"]([a-zA-Z0-9]{16,})['"]/gi, label: "Hardcoded secret", minEntropy: 4.0 },
908
1463
  ];
909
1464
 
910
1465
  for (const fileAbs of files) {
911
1466
  try {
912
- const code = fs.readFileSync(fileAbs, "utf8");
1467
+ const code = readFileCached(fileAbs);
913
1468
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
914
-
915
- // Skip env files (they're supposed to have secrets, just not committed)
916
1469
  if (/\.env/.test(fileRel)) continue;
917
1470
 
918
- for (const { rx, label } of secretPatterns) {
1471
+ let foundInFile = false;
1472
+
1473
+ // 1. Check specific patterns (prefix-based, high confidence - no entropy needed)
1474
+ for (const { rx, label, severity } of specificPatterns) {
1475
+ rx.lastIndex = 0;
919
1476
  const matches = code.match(rx);
920
1477
  if (matches && matches.length > 0) {
921
1478
  findings.push({
922
- id: `F_SECRET_${fileRel.replace(/[^a-z0-9]/gi, "_")}_${label.replace(/[^a-z0-9]/gi, "_")}`,
923
- severity: "BLOCK",
1479
+ id: stableId("F_SECRET", `${fileRel}:${label}`),
1480
+ severity,
924
1481
  category: "HardcodedSecret",
925
1482
  title: `${label} detected in: ${fileRel}`,
926
- why: "Hardcoded secrets in code get committed to git and leaked. This is a critical security issue.",
1483
+ why: "Hardcoded secrets get committed and leaked. This is critical.",
927
1484
  confidence: "high",
928
1485
  evidence: [{ file: fileRel, reason: label }],
929
1486
  fixHints: [
930
1487
  "Move the secret to environment variables.",
931
1488
  "Rotate the compromised secret immediately.",
932
- "Add the file to .gitignore if it shouldn't be committed."
933
- ]
1489
+ "Add the file to .gitignore if it shouldn't be committed.",
1490
+ ],
934
1491
  });
935
- break; // One finding per file per secret type
1492
+ foundInFile = true;
1493
+ break;
936
1494
  }
937
1495
  }
938
- } catch (e) {
939
- // Skip unreadable files
1496
+
1497
+ if (foundInFile) continue;
1498
+
1499
+ // 2. Check generic patterns WITH Shannon entropy to reduce false positives
1500
+ for (const { rx, label, minEntropy } of genericPatterns) {
1501
+ rx.lastIndex = 0;
1502
+ let match;
1503
+ while ((match = rx.exec(code)) !== null) {
1504
+ const potentialSecret = match[1] || match[0];
1505
+
1506
+ // Skip hex-only strings (likely Git SHAs, image IDs, not secrets)
1507
+ if (/^[a-f0-9]+$/i.test(potentialSecret)) continue;
1508
+
1509
+ // Skip common false positive patterns
1510
+ if (/^(undefined|null|true|false|localhost|example|placeholder)/i.test(potentialSecret)) continue;
1511
+
1512
+ const entropy = getShannonEntropy(potentialSecret);
1513
+ if (entropy >= minEntropy) {
1514
+ findings.push({
1515
+ id: stableId("F_SECRET_ENTROPY", `${fileRel}:${label}:${potentialSecret.slice(0, 8)}`),
1516
+ severity: "WARN", // Entropy is probabilistic, use WARN not BLOCK
1517
+ category: "HardcodedSecret",
1518
+ title: `${label} detected (high entropy ${entropy.toFixed(2)}): ${fileRel}`,
1519
+ why: "This string looks mathematically random, which usually indicates a hardcoded secret key.",
1520
+ confidence: "med",
1521
+ evidence: [{ file: fileRel, reason: `Entropy: ${entropy.toFixed(2)} >= ${minEntropy}` }],
1522
+ fixHints: [
1523
+ "Move the secret to environment variables.",
1524
+ "If this is not a secret, consider using a more descriptive variable name.",
1525
+ ],
1526
+ });
1527
+ foundInFile = true;
1528
+ break;
1529
+ }
1530
+ }
1531
+ if (foundInFile) break;
1532
+ }
1533
+ } catch {
1534
+ // skip
940
1535
  }
941
1536
  }
942
1537
 
943
1538
  return findings;
944
1539
  }
945
1540
 
946
- // ============================================================================
947
- // DEAD CODE / UNUSED EXPORTS DETECTOR
948
- // ============================================================================
1541
+ /* ============================================================================
1542
+ * DEAD CODE / UNUSED EXPORTS DETECTOR (fixed /g+.test() bug)
1543
+ * ========================================================================== */
949
1544
 
950
1545
  function findDeadCode(repoRoot) {
951
1546
  const findings = [];
952
1547
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
953
1548
  cwd: repoRoot,
954
1549
  absolute: true,
955
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/*.d.ts"]
1550
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**", "**/*.d.ts"],
956
1551
  });
957
1552
 
958
1553
  const deadCodePatterns = [
959
- { rx: /^\s*\/\/\s*export\s+(const|function|class|interface|type)/gm, label: "Commented out export" },
960
- { rx: /^\s*\/\*[\s\S]*?export[\s\S]*?\*\//gm, label: "Block-commented export" },
961
- { rx: /if\s*\(\s*false\s*\)\s*\{/g, label: "if (false) block" },
962
- { rx: /if\s*\(\s*0\s*\)\s*\{/g, label: "if (0) block" },
963
- { rx: /return;\s*\n\s*[^}]/g, label: "Unreachable code after return" },
964
- { rx: /throw\s+new\s+Error[^;]*;\s*\n\s*[^}]/g, label: "Unreachable code after throw" },
1554
+ { rx: /^\s*\/\/\s*export\s+(const|function|class|interface|type)/m, label: "Commented out export" },
1555
+ { rx: /^\s*\/\*[\s\S]*?export[\s\S]*?\*\//m, label: "Block-commented export" },
1556
+ { rx: /if\s*\(\s*false\s*\)\s*\{/m, label: "if (false) block" },
1557
+ { rx: /if\s*\(\s*0\s*\)\s*\{/m, label: "if (0) block" },
1558
+ { rx: /return;\s*\n\s*[^}]/m, label: "Unreachable code after return" },
1559
+ { rx: /throw\s+new\s+Error[^;]*;\s*\n\s*[^}]/m, label: "Unreachable code after throw" },
965
1560
  ];
966
1561
 
967
1562
  for (const fileAbs of files) {
968
1563
  try {
969
- const code = fs.readFileSync(fileAbs, "utf8");
1564
+ const code = readFileCached(fileAbs);
970
1565
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
971
-
1566
+
972
1567
  for (const { rx, label } of deadCodePatterns) {
973
- if (rx.test(code)) {
974
- findings.push({
975
- id: `F_DEAD_CODE_${fileRel.replace(/[^a-z0-9]/gi, "_")}_${label.replace(/[^a-z0-9]/gi, "_")}`,
976
- severity: "WARN",
977
- category: "DeadCode",
978
- title: `${label} in: ${fileRel}`,
979
- why: "Dead code adds confusion and maintenance burden. It often indicates incomplete refactoring.",
980
- confidence: "med",
981
- evidence: [{ file: fileRel, reason: label }],
982
- fixHints: [
983
- "Remove the dead code entirely.",
984
- "If needed for reference, check git history instead of commenting."
985
- ]
986
- });
987
- break; // One finding per file
988
- }
1568
+ if (!rxTest(rx, code)) continue;
1569
+ findings.push({
1570
+ id: stableId("F_DEAD_CODE", `${fileRel}:${label}`),
1571
+ severity: "WARN",
1572
+ category: "DeadCode",
1573
+ title: `${label} in: ${fileRel}`,
1574
+ why: "Dead code adds confusion and maintenance burden and usually indicates incomplete refactoring.",
1575
+ confidence: "med",
1576
+ evidence: [{ file: fileRel, reason: label }],
1577
+ fixHints: ["Remove the dead code entirely.", "If needed for reference, use git history instead of commenting."],
1578
+ });
1579
+ break;
989
1580
  }
990
- } catch (e) {
991
- // Skip unreadable files
1581
+ } catch {
1582
+ // skip
992
1583
  }
993
1584
  }
994
1585
 
995
1586
  return findings;
996
1587
  }
997
1588
 
998
- // ============================================================================
999
- // DEPRECATED API USAGE DETECTOR
1000
- // ============================================================================
1589
+ /* ============================================================================
1590
+ * DEPRECATED API USAGE DETECTOR (kept; deterministic)
1591
+ * ========================================================================== */
1001
1592
 
1002
1593
  function findDeprecatedApis(repoRoot) {
1003
1594
  const findings = [];
1004
1595
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
1005
1596
  cwd: repoRoot,
1006
1597
  absolute: true,
1007
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"]
1598
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
1008
1599
  });
1009
1600
 
1010
1601
  const deprecatedPatterns = [
1011
- { rx: /\bcomponentWillMount\b/g, label: "componentWillMount (deprecated React lifecycle)" },
1012
- { rx: /\bcomponentWillReceiveProps\b/g, label: "componentWillReceiveProps (deprecated)" },
1013
- { rx: /\bcomponentWillUpdate\b/g, label: "componentWillUpdate (deprecated)" },
1014
- { rx: /\bgetInitialProps\b/g, label: "getInitialProps (legacy Next.js)" },
1015
- { rx: /\bsubstr\s*\(/g, label: "String.substr() (deprecated, use slice)" },
1016
- { rx: /\bdocument\.write\b/g, label: "document.write (deprecated)" },
1017
- { rx: /new\s+Buffer\s*\(/g, label: "new Buffer() (deprecated, use Buffer.from)" },
1018
- { rx: /\brequire\(['"]fs['"]\)\.exists\b/g, label: "fs.exists (deprecated)" },
1019
- { rx: /\.__proto__\b/g, label: "__proto__ (deprecated)" },
1602
+ { rx: /\bcomponentWillMount\b/, label: "componentWillMount (deprecated React lifecycle)" },
1603
+ { rx: /\bcomponentWillReceiveProps\b/, label: "componentWillReceiveProps (deprecated)" },
1604
+ { rx: /\bcomponentWillUpdate\b/, label: "componentWillUpdate (deprecated)" },
1605
+ { rx: /\bgetInitialProps\b/, label: "getInitialProps (legacy Next.js)" },
1606
+ { rx: /\bsubstr\s*\(/, label: "String.substr() (deprecated, use slice)" },
1607
+ { rx: /\bdocument\.write\b/, label: "document.write (deprecated)" },
1608
+ { rx: /new\s+Buffer\s*\(/, label: "new Buffer() (deprecated, use Buffer.from)" },
1609
+ { rx: /\brequire\(['"]fs['"]\)\.exists\b/, label: "fs.exists (deprecated)" },
1610
+ { rx: /\.__proto__\b/, label: "__proto__ (deprecated)" },
1020
1611
  ];
1021
1612
 
1022
1613
  for (const fileAbs of files) {
1023
1614
  try {
1024
- const code = fs.readFileSync(fileAbs, "utf8");
1615
+ const code = readFileCached(fileAbs);
1025
1616
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
1026
-
1617
+
1027
1618
  for (const { rx, label } of deprecatedPatterns) {
1028
- const matches = code.match(rx);
1029
- if (matches && matches.length > 0) {
1030
- findings.push({
1031
- id: `F_DEPRECATED_${fileRel.replace(/[^a-z0-9]/gi, "_")}_${label.replace(/[^a-z0-9]/gi, "_")}`,
1032
- severity: "WARN",
1033
- category: "DeprecatedApi",
1034
- title: `${label}: ${fileRel}`,
1035
- why: "Deprecated APIs may stop working in future versions and often have security issues.",
1036
- confidence: "high",
1037
- evidence: [{ file: fileRel, reason: `${matches.length} occurrence(s)` }],
1038
- fixHints: [
1039
- "Update to the modern API equivalent.",
1040
- "Check migration guides for the specific deprecation."
1041
- ]
1042
- });
1043
- break; // One finding per file per deprecated API
1044
- }
1619
+ if (!rxTest(rx, code)) continue;
1620
+ const matches = code.match(new RegExp(rx.source, "g")) || [];
1621
+ findings.push({
1622
+ id: stableId("F_DEPRECATED", `${fileRel}:${label}`),
1623
+ severity: "WARN",
1624
+ category: "DeprecatedApi",
1625
+ title: `${label}: ${fileRel}`,
1626
+ why: "Deprecated APIs may break in future versions and sometimes carry security issues.",
1627
+ confidence: "high",
1628
+ evidence: [{ file: fileRel, reason: `${matches.length} occurrence(s)` }],
1629
+ fixHints: ["Update to the modern API equivalent.", "Check migration guides for the specific deprecation."],
1630
+ });
1631
+ break;
1045
1632
  }
1046
- } catch (e) {
1047
- // Skip unreadable files
1633
+ } catch {
1634
+ // skip
1048
1635
  }
1049
1636
  }
1050
1637
 
1051
1638
  return findings;
1052
1639
  }
1053
1640
 
1054
- // ============================================================================
1055
- // EMPTY CATCH BLOCKS DETECTOR
1056
- // ============================================================================
1641
+ /* ============================================================================
1642
+ * EMPTY CATCH BLOCKS DETECTOR (kept)
1643
+ * ========================================================================== */
1057
1644
 
1058
1645
  function findEmptyCatch(repoRoot) {
1059
1646
  const findings = [];
1060
1647
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
1061
1648
  cwd: repoRoot,
1062
1649
  absolute: true,
1063
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"]
1650
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
1064
1651
  });
1065
1652
 
1066
1653
  for (const fileAbs of files) {
1067
1654
  try {
1068
- const code = fs.readFileSync(fileAbs, "utf8");
1655
+ const code = readFileCached(fileAbs);
1069
1656
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
1070
-
1071
- // Match catch blocks that are empty or only have comments
1657
+
1072
1658
  const emptyCatchRx = /catch\s*\([^)]*\)\s*\{\s*(\/\/[^\n]*)?\s*\}/g;
1073
1659
  const matches = code.match(emptyCatchRx);
1074
-
1660
+
1075
1661
  if (matches && matches.length > 0) {
1076
1662
  findings.push({
1077
- id: `F_EMPTY_CATCH_${fileRel.replace(/[^a-z0-9]/gi, "_")}`,
1663
+ id: stableId("F_EMPTY_CATCH", fileRel),
1078
1664
  severity: "WARN",
1079
1665
  category: "EmptyCatch",
1080
1666
  title: `Empty catch block(s) in: ${fileRel} (${matches.length} found)`,
1081
- why: "Empty catch blocks silently swallow errors, making debugging impossible.",
1667
+ why: "Empty catch blocks swallow errors and make debugging impossible.",
1082
1668
  confidence: "high",
1083
1669
  evidence: [{ file: fileRel, reason: `${matches.length} empty catch block(s)` }],
1084
- fixHints: [
1085
- "Log the error or handle it appropriately.",
1086
- "If intentionally ignoring, add a comment explaining why."
1087
- ]
1670
+ fixHints: ["Log the error or handle it appropriately.", "If intentionally ignoring, add a comment explaining why."],
1088
1671
  });
1089
1672
  }
1090
- } catch (e) {
1091
- // Skip unreadable files
1673
+ } catch {
1674
+ // skip
1092
1675
  }
1093
1676
  }
1094
1677
 
1095
1678
  return findings;
1096
1679
  }
1097
1680
 
1098
- // ============================================================================
1099
- // UNSAFE REGEX DETECTOR
1100
- // ============================================================================
1681
+ /* ============================================================================
1682
+ * UNSAFE REGEX DETECTOR (fixed /g+.test() bug)
1683
+ * ========================================================================== */
1101
1684
 
1102
1685
  function findUnsafeRegex(repoRoot) {
1103
1686
  const findings = [];
1104
1687
  const files = fg.sync(["**/*.{ts,tsx,js,jsx}"], {
1105
1688
  cwd: repoRoot,
1106
1689
  absolute: true,
1107
- ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"]
1690
+ ignore: ["**/node_modules/**", "**/.next/**", "**/dist/**", "**/build/**"],
1108
1691
  });
1109
1692
 
1110
- // Patterns that can cause ReDoS (catastrophic backtracking)
1111
1693
  const unsafePatterns = [
1112
- { rx: /new\s+RegExp\s*\([^)]*\+[^)]*\)/g, label: "Dynamic regex with concatenation" },
1113
- { rx: /\(\.\*\)\+|\(\.\+\)\+|\(\.\*\)\*|\(\.\+\)\*/g, label: "Nested quantifiers (ReDoS risk)" },
1114
- { rx: /\([^)]+\|[^)]+\)\+/g, label: "Alternation with quantifier (ReDoS risk)" },
1694
+ { rx: /new\s+RegExp\s*\([^)]*\+[^)]*\)/, label: "Dynamic regex with concatenation" },
1695
+ { rx: /\(\.\*\)\+|\(\.\+\)\+|\(\.\*\)\*|\(\.\+\)\*/, label: "Nested quantifiers (ReDoS risk)" },
1696
+ { rx: /\([^)]+\|[^)]+\)\+/, label: "Alternation with quantifier (ReDoS risk)" },
1115
1697
  ];
1116
1698
 
1117
1699
  for (const fileAbs of files) {
1118
1700
  try {
1119
- const code = fs.readFileSync(fileAbs, "utf8");
1701
+ const code = readFileCached(fileAbs);
1120
1702
  const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
1121
-
1703
+
1122
1704
  for (const { rx, label } of unsafePatterns) {
1123
- if (rx.test(code)) {
1124
- findings.push({
1125
- id: `F_UNSAFE_REGEX_${fileRel.replace(/[^a-z0-9]/gi, "_")}_${label.replace(/[^a-z0-9]/gi, "_")}`,
1126
- severity: "WARN",
1127
- category: "UnsafeRegex",
1128
- title: `${label}: ${fileRel}`,
1129
- why: "Unsafe regex patterns can cause denial of service via catastrophic backtracking.",
1130
- confidence: "med",
1131
- evidence: [{ file: fileRel, reason: label }],
1132
- fixHints: [
1133
- "Use atomic groups or possessive quantifiers if supported.",
1134
- "Validate input length before applying regex.",
1135
- "Consider using a regex linting tool."
1136
- ]
1137
- });
1138
- break;
1139
- }
1705
+ if (!rxTest(rx, code)) continue;
1706
+ findings.push({
1707
+ id: stableId("F_UNSAFE_REGEX", `${fileRel}:${label}`),
1708
+ severity: "WARN",
1709
+ category: "UnsafeRegex",
1710
+ title: `${label}: ${fileRel}`,
1711
+ why: "Unsafe regex patterns can cause denial of service via catastrophic backtracking.",
1712
+ confidence: "med",
1713
+ evidence: [{ file: fileRel, reason: label }],
1714
+ fixHints: ["Validate input length before applying regex.", "Consider safer parsing or a regex linter.", "Avoid nested quantifiers."],
1715
+ });
1716
+ break;
1140
1717
  }
1141
- } catch (e) {
1142
- // Skip unreadable files
1718
+ } catch {
1719
+ // skip
1143
1720
  }
1144
1721
  }
1145
1722
 
@@ -1147,6 +1724,13 @@ function findUnsafeRegex(repoRoot) {
1147
1724
  }
1148
1725
 
1149
1726
  module.exports = {
1727
+ // V3: Cache management - call after scan completes to prevent memory leaks
1728
+ clearFileCache,
1729
+
1730
+ // V3: Entropy helper - exported for testing/reuse
1731
+ getShannonEntropy,
1732
+
1733
+ // Analyzers
1150
1734
  findMissingRoutes,
1151
1735
  findEnvGaps,
1152
1736
  findFakeSuccess,
@@ -1154,7 +1738,6 @@ module.exports = {
1154
1738
  findStripeWebhookViolations,
1155
1739
  findPaidSurfaceNotEnforced,
1156
1740
  findOwnerModeBypass,
1157
- // New analyzers
1158
1741
  findMockData,
1159
1742
  findTodoFixme,
1160
1743
  findConsoleLogs,