@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.
- package/bin/runners/lib/agent-firewall/change-packet/builder.js +214 -0
- package/bin/runners/lib/agent-firewall/change-packet/schema.json +228 -0
- package/bin/runners/lib/agent-firewall/change-packet/store.js +200 -0
- package/bin/runners/lib/agent-firewall/claims/claim-types.js +21 -0
- package/bin/runners/lib/agent-firewall/claims/extractor.js +214 -0
- package/bin/runners/lib/agent-firewall/claims/patterns.js +24 -0
- package/bin/runners/lib/agent-firewall/evidence/auth-evidence.js +88 -0
- package/bin/runners/lib/agent-firewall/evidence/contract-evidence.js +75 -0
- package/bin/runners/lib/agent-firewall/evidence/env-evidence.js +118 -0
- package/bin/runners/lib/agent-firewall/evidence/resolver.js +102 -0
- package/bin/runners/lib/agent-firewall/evidence/route-evidence.js +142 -0
- package/bin/runners/lib/agent-firewall/evidence/side-effect-evidence.js +145 -0
- package/bin/runners/lib/agent-firewall/fs-hook/daemon.js +19 -0
- package/bin/runners/lib/agent-firewall/fs-hook/installer.js +87 -0
- package/bin/runners/lib/agent-firewall/fs-hook/watcher.js +184 -0
- package/bin/runners/lib/agent-firewall/git-hook/pre-commit.js +163 -0
- package/bin/runners/lib/agent-firewall/ide-extension/cursor.js +107 -0
- package/bin/runners/lib/agent-firewall/ide-extension/vscode.js +68 -0
- package/bin/runners/lib/agent-firewall/ide-extension/windsurf.js +66 -0
- package/bin/runners/lib/agent-firewall/interceptor/base.js +304 -0
- package/bin/runners/lib/agent-firewall/interceptor/cursor.js +35 -0
- package/bin/runners/lib/agent-firewall/interceptor/vscode.js +35 -0
- package/bin/runners/lib/agent-firewall/interceptor/windsurf.js +34 -0
- package/bin/runners/lib/agent-firewall/policy/default-policy.json +84 -0
- package/bin/runners/lib/agent-firewall/policy/engine.js +72 -0
- package/bin/runners/lib/agent-firewall/policy/loader.js +143 -0
- package/bin/runners/lib/agent-firewall/policy/rules/auth-drift.js +50 -0
- package/bin/runners/lib/agent-firewall/policy/rules/contract-drift.js +50 -0
- package/bin/runners/lib/agent-firewall/policy/rules/fake-success.js +61 -0
- package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +50 -0
- package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +50 -0
- package/bin/runners/lib/agent-firewall/policy/rules/scope.js +93 -0
- package/bin/runners/lib/agent-firewall/policy/rules/unsafe-side-effect.js +57 -0
- package/bin/runners/lib/agent-firewall/policy/schema.json +183 -0
- package/bin/runners/lib/agent-firewall/policy/verdict.js +54 -0
- package/bin/runners/lib/agent-firewall/truthpack/index.js +67 -0
- package/bin/runners/lib/agent-firewall/truthpack/loader.js +116 -0
- package/bin/runners/lib/agent-firewall/unblock/planner.js +337 -0
- package/bin/runners/lib/analysis-core.js +198 -180
- package/bin/runners/lib/analyzers.js +1119 -536
- package/bin/runners/lib/cli-output.js +236 -210
- package/bin/runners/lib/detectors-v2.js +547 -785
- package/bin/runners/lib/fingerprint.js +377 -0
- package/bin/runners/lib/route-truth.js +1167 -322
- package/bin/runners/lib/scan-output.js +144 -738
- package/bin/runners/lib/ship-output-enterprise.js +239 -0
- package/bin/runners/lib/terminal-ui.js +188 -770
- package/bin/runners/lib/truth.js +1004 -321
- package/bin/runners/lib/unified-output.js +162 -158
- package/bin/runners/runAgent.js +161 -0
- package/bin/runners/runFirewall.js +134 -0
- package/bin/runners/runFirewallHook.js +56 -0
- package/bin/runners/runScan.js +113 -10
- package/bin/runners/runShip.js +7 -8
- package/bin/runners/runTruth.js +89 -0
- package/mcp-server/agent-firewall-interceptor.js +164 -0
- package/mcp-server/index.js +347 -313
- package/mcp-server/truth-context.js +131 -90
- package/mcp-server/truth-firewall-tools.js +1412 -1045
- 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
|
-
|
|
17
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
66
|
-
const
|
|
468
|
+
const rawPath = ref?.path;
|
|
469
|
+
const method = String(ref?.method || "*").toUpperCase();
|
|
67
470
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
//
|
|
100
|
-
if (severity === "
|
|
101
|
-
if (
|
|
102
|
-
|
|
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:
|
|
596
|
+
id: stableId("F_MISSING_ROUTE", `${method} ${pNorm}`),
|
|
107
597
|
severity,
|
|
108
598
|
category: "MissingRoute",
|
|
109
|
-
title: `Client references route
|
|
110
|
-
why:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
"
|
|
118
|
-
"If this is
|
|
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
|
-
//
|
|
617
|
+
// Route map diagnostics (actionable, reduces "blame the analyzer" loops)
|
|
124
618
|
if (hasGaps) {
|
|
125
619
|
findings.push({
|
|
126
|
-
id:
|
|
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: "
|
|
131
|
-
confidence: "
|
|
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
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
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
|
-
//
|
|
142
|
-
if (
|
|
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:
|
|
145
|
-
severity: "
|
|
643
|
+
id: stableId("F_EXTERNAL_ROUTE_REFS", hostSummary),
|
|
644
|
+
severity: "INFO",
|
|
146
645
|
category: "MissingRoute",
|
|
147
|
-
title:
|
|
148
|
-
why: "
|
|
149
|
-
confidence: "
|
|
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
|
-
|
|
153
|
-
"
|
|
154
|
-
"
|
|
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
|
-
|
|
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
|
|
691
|
+
// Well-known system/CI env vars that shouldn't be flagged
|
|
173
692
|
const systemEnvVars = new Set([
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
715
|
+
|
|
716
|
+
// Patterns for env vars that are commonly optional/internal
|
|
209
717
|
const optionalPatterns = [
|
|
210
|
-
/^(OPENAI|ANTHROPIC|COHERE|AZURE|AWS|GCP|GOOGLE)_/i,
|
|
211
|
-
/^(STRIPE|PAYPAL|PLAID)_/i,
|
|
212
|
-
/^(SENDGRID|RESEND|MAILGUN|SES)_/i,
|
|
213
|
-
/^(SENTRY|DATADOG|NEWRELIC|LOGROCKET)_/i,
|
|
214
|
-
/^(REDIS|POSTGRES|MYSQL|MONGO|DATABASE)_/i,
|
|
215
|
-
/^(NEXT_|NUXT_|VITE_|REACT_APP_)/i,
|
|
216
|
-
/^(VIBECHECK|GUARDRAIL)_/i,
|
|
217
|
-
/_(URL|KEY|SECRET|TOKEN|ID|PASSWORD|HOST|PORT)$/i,
|
|
218
|
-
/^(ENABLE_|DISABLE_|USE_|SKIP_|ALLOW_|NO_)/i,
|
|
219
|
-
/^(MAX_|MIN_|DEFAULT_|TIMEOUT_|LIMIT_|RATE_)/i,
|
|
220
|
-
/^(LOG_|DEBUG_|VERBOSE_|TRACE_)/i,
|
|
221
|
-
/^(TEST_|DEV_|STAGING_|PROD_)/i,
|
|
222
|
-
/^(ARTIFACTS_|CACHE_|TMP_|OUTPUT_)/i,
|
|
223
|
-
/^npm_/i,
|
|
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
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
//
|
|
242
|
-
|
|
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
|
|
253
|
-
:
|
|
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
|
|
259
|
-
]
|
|
774
|
+
"If optional, ensure there's an explicit fallback or guard (and document expected behavior).",
|
|
775
|
+
],
|
|
260
776
|
});
|
|
261
777
|
}
|
|
262
778
|
|
|
263
|
-
//
|
|
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
|
|
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
|
|
278
|
-
"If used
|
|
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
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
814
|
+
/* ============================================================================
|
|
815
|
+
* FAKE SUCCESS ANALYZER (kept, but made safer & less noisy)
|
|
816
|
+
* ========================================================================== */
|
|
303
817
|
|
|
304
818
|
function isToastSuccessCall(node) {
|
|
305
|
-
return
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
|
369
|
-
if (/\b(
|
|
370
|
-
|
|
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 (!
|
|
904
|
+
if (!successCalls.length || !networkCalls.length) return;
|
|
376
905
|
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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: "
|
|
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
|
|
471
|
-
"Or protect the path via
|
|
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
|
-
|
|
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: "
|
|
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:
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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 =
|
|
617
|
-
const hit = patterns.some(rx => rx
|
|
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:
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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/
|
|
654
|
-
{ rx: /\bfakeData\b/
|
|
655
|
-
{ rx: /\bdummyData\b/
|
|
656
|
-
{ rx: /\btestData\b/
|
|
657
|
-
{ rx: /\bsampleData\b/
|
|
658
|
-
{ rx: /['"]fake[_-]?user['"]|['"]test[_-]?user['"]|['"]demo[_-]?user['"]/
|
|
659
|
-
{ rx: /['"]password123['"]|['"]test123['"]|['"]admin123['"]|['"]secret123['"]/
|
|
660
|
-
{ rx: /['"]test@(test|example|fake)\.com['"]/
|
|
661
|
-
{ rx: /\
|
|
662
|
-
{ rx: /setTimeout\([^)]*[5-9]\d{3,}|setTimeout\([^)]*\d{5,}/
|
|
663
|
-
{ rx: /Math\.random\(\)\s*[*<>]\s*\d+/
|
|
664
|
-
{ rx: /\bplaceholder\b.*\bdata\b|\bdata\b.*\bplaceholder\b/
|
|
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 =
|
|
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
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
705
|
-
//
|
|
1255
|
+
} catch {
|
|
1256
|
+
// skip
|
|
706
1257
|
}
|
|
707
1258
|
}
|
|
708
1259
|
|
|
709
1260
|
return findings;
|
|
710
1261
|
}
|
|
711
1262
|
|
|
712
|
-
|
|
713
|
-
|
|
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:]/
|
|
726
|
-
{ rx: /\/\/\s*FIXME[\s:]/
|
|
727
|
-
{ rx: /\/\/\s*HACK[\s:]/
|
|
728
|
-
{ rx: /\/\/\s*XXX[\s:]/
|
|
729
|
-
{ rx: /\/\/\s*BUG[\s:]/
|
|
730
|
-
{ rx: /\/\/\s*BROKEN[\s:]/
|
|
731
|
-
{ rx: /\/\/\s*URGENT[\s:]/
|
|
732
|
-
{ rx: /\/\/\s*SECURITY[\s:]/
|
|
733
|
-
{ rx: /\/\/\s*DANGER[\s:]/
|
|
734
|
-
{ rx: /\/\*\s*TODO[\s:]/
|
|
735
|
-
{ rx: /\/\*\s*FIXME[\s:]/
|
|
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 =
|
|
1295
|
+
const code = readFileCached(fileAbs);
|
|
745
1296
|
const fileRel = path.relative(repoRoot, fileAbs).replace(/\\/g, "/");
|
|
746
|
-
const lines = code.split(
|
|
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
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
|
780
|
-
//
|
|
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:
|
|
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
|
-
|
|
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: [
|
|
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 =
|
|
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(
|
|
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:
|
|
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
|
|
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
|
|
859
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
{ rx: /['"]
|
|
899
|
-
{ rx: /['"]
|
|
900
|
-
{ rx: /['"][a-zA-Z0-9
|
|
901
|
-
{ rx: /['"]
|
|
902
|
-
{ rx: /['"]
|
|
903
|
-
{ rx: /['"]
|
|
904
|
-
{ rx: /['"]
|
|
905
|
-
{ rx: /
|
|
906
|
-
|
|
907
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
923
|
-
severity
|
|
1479
|
+
id: stableId("F_SECRET", `${fileRel}:${label}`),
|
|
1480
|
+
severity,
|
|
924
1481
|
category: "HardcodedSecret",
|
|
925
1482
|
title: `${label} detected in: ${fileRel}`,
|
|
926
|
-
why: "Hardcoded secrets
|
|
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
|
-
|
|
1492
|
+
foundInFile = true;
|
|
1493
|
+
break;
|
|
936
1494
|
}
|
|
937
1495
|
}
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
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)/
|
|
960
|
-
{ rx: /^\s*\/\*[\s\S]*?export[\s\S]*?\*\//
|
|
961
|
-
{ rx: /if\s*\(\s*false\s*\)\s*\{/
|
|
962
|
-
{ rx: /if\s*\(\s*0\s*\)\s*\{/
|
|
963
|
-
{ rx: /return;\s*\n\s*[^}]/
|
|
964
|
-
{ rx: /throw\s+new\s+Error[^;]*;\s*\n\s*[^}]/
|
|
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 =
|
|
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
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
|
991
|
-
//
|
|
1581
|
+
} catch {
|
|
1582
|
+
// skip
|
|
992
1583
|
}
|
|
993
1584
|
}
|
|
994
1585
|
|
|
995
1586
|
return findings;
|
|
996
1587
|
}
|
|
997
1588
|
|
|
998
|
-
|
|
999
|
-
|
|
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
|
|
1012
|
-
{ rx: /\bcomponentWillReceiveProps\b
|
|
1013
|
-
{ rx: /\bcomponentWillUpdate\b
|
|
1014
|
-
{ rx: /\bgetInitialProps\b
|
|
1015
|
-
{ rx: /\bsubstr\s*\(
|
|
1016
|
-
{ rx: /\bdocument\.write\b
|
|
1017
|
-
{ rx: /new\s+Buffer\s*\(
|
|
1018
|
-
{ rx: /\brequire\(['"]fs['"]\)\.exists\b
|
|
1019
|
-
{ rx: /\.__proto__\b
|
|
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 =
|
|
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
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
|
1047
|
-
//
|
|
1633
|
+
} catch {
|
|
1634
|
+
// skip
|
|
1048
1635
|
}
|
|
1049
1636
|
}
|
|
1050
1637
|
|
|
1051
1638
|
return findings;
|
|
1052
1639
|
}
|
|
1053
1640
|
|
|
1054
|
-
|
|
1055
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
1091
|
-
//
|
|
1673
|
+
} catch {
|
|
1674
|
+
// skip
|
|
1092
1675
|
}
|
|
1093
1676
|
}
|
|
1094
1677
|
|
|
1095
1678
|
return findings;
|
|
1096
1679
|
}
|
|
1097
1680
|
|
|
1098
|
-
|
|
1099
|
-
|
|
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*\([^)]*\+[^)]*\)
|
|
1113
|
-
{ rx: /\(\.\*\)\+|\(\.\+\)\+|\(\.\*\)\*|\(\.\+\)
|
|
1114
|
-
{ rx: /\([^)]+\|[^)]+\)
|
|
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 =
|
|
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
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
|
1142
|
-
//
|
|
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,
|