periderm-cli 0.1.0
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/README.md +13 -0
- package/dist/api.js +60 -0
- package/dist/config.js +27 -0
- package/dist/constants.js +10 -0
- package/dist/index.js +406 -0
- package/dist/loading-messages.js +36 -0
- package/dist/report/markdown.js +111 -0
- package/dist/report/terminal.js +53 -0
- package/dist/report/verdict.js +42 -0
- package/dist/review/deep.js +170 -0
- package/dist/scanner/ast.js +22 -0
- package/dist/scanner/checks.js +664 -0
- package/dist/scanner/index.js +100 -0
- package/dist/scanner/policy-checks.js +134 -0
- package/dist/scanner/progress.js +26 -0
- package/dist/scanner/repo-checks.js +197 -0
- package/dist/scanner/seo-checks.js +167 -0
- package/dist/scanner/walk.js +16 -0
- package/dist/types.js +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic check layer.
|
|
3
|
+
* Each check is a pure function: (source, file, ast) => Finding[].
|
|
4
|
+
*
|
|
5
|
+
* This is intentionally focused on signal-rich, low-false-positive patterns
|
|
6
|
+
* we can detect via regex + AST without a full type-checker.
|
|
7
|
+
*/
|
|
8
|
+
import traverseDefault from "@babel/traverse";
|
|
9
|
+
// @babel/traverse ships CJS; pick the default export defensively.
|
|
10
|
+
const traverse = traverseDefault.default
|
|
11
|
+
?? traverseDefault;
|
|
12
|
+
const lineOf = (source, idx) => source.slice(0, idx).split("\n").length;
|
|
13
|
+
function stripStrings(source) {
|
|
14
|
+
return source.replace(/`[^`]*`|"[^"\n]*"|'[^'\n]*'/g, "''");
|
|
15
|
+
}
|
|
16
|
+
/** Match a pattern in code/comments only — skips string literals and JSX text. */
|
|
17
|
+
function matchInCode(source, pattern) {
|
|
18
|
+
const stripped = stripStrings(source);
|
|
19
|
+
const probe = new RegExp(pattern.source, pattern.flags);
|
|
20
|
+
if (!probe.test(stripped))
|
|
21
|
+
return null;
|
|
22
|
+
const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : pattern.flags + "g");
|
|
23
|
+
let m;
|
|
24
|
+
while ((m = re.exec(source))) {
|
|
25
|
+
const strippedBefore = stripStrings(source.slice(0, m.index)).length;
|
|
26
|
+
if (stripped.slice(strippedBefore, strippedBefore + m[0].length) === m[0])
|
|
27
|
+
return m;
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
32
|
+
// 1. Silent catch — try/catch with empty body or console-only body
|
|
33
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
34
|
+
const silentCatch = (source, file, ast) => {
|
|
35
|
+
const out = [];
|
|
36
|
+
if (!ast)
|
|
37
|
+
return out;
|
|
38
|
+
traverse(ast, {
|
|
39
|
+
CatchClause(p) {
|
|
40
|
+
const body = p.node.body.body;
|
|
41
|
+
const catchSrc = source.slice(p.node.start ?? 0, p.node.end ?? 0);
|
|
42
|
+
if (/periderm-ignore/.test(catchSrc))
|
|
43
|
+
return;
|
|
44
|
+
if (body.length === 0) {
|
|
45
|
+
out.push(mkFinding({
|
|
46
|
+
id: "silent-catch",
|
|
47
|
+
category: "Runtime Stability",
|
|
48
|
+
severity: "high",
|
|
49
|
+
file, line: p.node.loc?.start.line ?? 1,
|
|
50
|
+
message: "Empty catch block swallows errors.",
|
|
51
|
+
why: "When this fails, you and your users will have no idea. Bugs become invisible, support tickets get vague.",
|
|
52
|
+
fix: "Log the error with context, surface a user-facing error state, or report to your error tracker.",
|
|
53
|
+
aiPrompt: `In ${file} near line ${p.node.loc?.start.line ?? 1}, an empty catch block silently swallows errors. Replace it with: (1) a console.error including the operation that failed, and (2) propagate or surface a user-visible error state if this runs in a UI path.`,
|
|
54
|
+
}));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const onlyConsole = body.every((stmt) => {
|
|
58
|
+
if (stmt.type !== "ExpressionStatement")
|
|
59
|
+
return false;
|
|
60
|
+
const e = stmt.expression;
|
|
61
|
+
return e.type === "CallExpression"
|
|
62
|
+
&& e.callee.type === "MemberExpression"
|
|
63
|
+
&& e.callee.object.type === "Identifier"
|
|
64
|
+
&& e.callee.object.name === "console";
|
|
65
|
+
});
|
|
66
|
+
if (onlyConsole) {
|
|
67
|
+
out.push(mkFinding({
|
|
68
|
+
id: "silent-catch-console",
|
|
69
|
+
category: "Runtime Stability",
|
|
70
|
+
severity: "medium",
|
|
71
|
+
file, line: p.node.loc?.start.line ?? 1,
|
|
72
|
+
message: "Catch block only logs — no recovery, no user feedback.",
|
|
73
|
+
why: "Logging is not handling. Your users will still see a broken UI or stuck spinner.",
|
|
74
|
+
fix: "Set an error state, retry, or render a fallback. console.error is for you; users need a visible signal.",
|
|
75
|
+
aiPrompt: `In ${file} near line ${p.node.loc?.start.line ?? 1}, the catch block only console.errors. Add proper handling: set an error state, show a toast or inline error message, and offer the user a retry path.`,
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return out;
|
|
81
|
+
};
|
|
82
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
83
|
+
// 2. Env leak — process.env.SECRET / KEY / TOKEN used in a non-server file
|
|
84
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
85
|
+
const envLeak = (source, file) => {
|
|
86
|
+
const out = [];
|
|
87
|
+
const isServer = /\.server\.|\/api\/|\.functions\.|server-fn|createServerFn/.test(file + source);
|
|
88
|
+
if (isServer)
|
|
89
|
+
return out;
|
|
90
|
+
const re = /process\.env\.([A-Z_][A-Z0-9_]*)/g;
|
|
91
|
+
let m;
|
|
92
|
+
while ((m = re.exec(source))) {
|
|
93
|
+
const name = m[1];
|
|
94
|
+
if (/^(NODE_ENV|VITE_)/.test(name))
|
|
95
|
+
continue;
|
|
96
|
+
if (/(SECRET|KEY|TOKEN|PASSWORD|PRIVATE)/.test(name)) {
|
|
97
|
+
out.push(mkFinding({
|
|
98
|
+
id: "env-leak",
|
|
99
|
+
category: "Security",
|
|
100
|
+
severity: "critical",
|
|
101
|
+
file, line: lineOf(source, m.index),
|
|
102
|
+
message: `Secret-shaped env var \`${name}\` accessed in a non-server file.`,
|
|
103
|
+
why: "If this file ships to the client, the secret leaks. Bundlers inline process.env at build time.",
|
|
104
|
+
fix: "Move the read into a server function / server route, or rename + expose only via VITE_ if it's truly public.",
|
|
105
|
+
aiPrompt: `In ${file}, process.env.${name} is read in client-reachable code. Move this read into a server function (createServerFn handler) or a server route handler, and have the client call that function instead of reading the env directly.`,
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
};
|
|
111
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
112
|
+
// 3. ts-ignore / @ts-nocheck in critical paths
|
|
113
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
114
|
+
const tsIgnore = (source, file) => {
|
|
115
|
+
const out = [];
|
|
116
|
+
const re = /(\/\/\s*@ts-ignore|\/\/\s*@ts-nocheck|\/\/\s*@ts-expect-error)/g;
|
|
117
|
+
let m;
|
|
118
|
+
while ((m = re.exec(source))) {
|
|
119
|
+
out.push(mkFinding({
|
|
120
|
+
id: "ts-ignore",
|
|
121
|
+
category: "Data Integrity",
|
|
122
|
+
severity: "medium",
|
|
123
|
+
file, line: lineOf(source, m.index),
|
|
124
|
+
message: `\`${m[1].trim()}\` suppresses a real type error.`,
|
|
125
|
+
why: "Suppressed type errors are bug seeds. They almost always become runtime errors under unexpected input.",
|
|
126
|
+
fix: "Fix the type, narrow the value, or accept it explicitly with a typed assertion that documents why.",
|
|
127
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, remove the \`${m[1].trim()}\` and fix the underlying type error properly — either narrow the value, add a runtime guard, or refactor the API surface.`,
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
};
|
|
132
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
133
|
+
// 4. <img> without alt
|
|
134
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
135
|
+
const imgAlt = (source, file, ast) => {
|
|
136
|
+
const out = [];
|
|
137
|
+
if (!ast || !/\.(tsx|jsx)$/.test(file))
|
|
138
|
+
return out;
|
|
139
|
+
traverse(ast, {
|
|
140
|
+
JSXOpeningElement(p) {
|
|
141
|
+
const n = p.node.name;
|
|
142
|
+
if (n.type !== "JSXIdentifier" || n.name !== "img")
|
|
143
|
+
return;
|
|
144
|
+
const hasAlt = p.node.attributes.some((a) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "alt");
|
|
145
|
+
if (!hasAlt) {
|
|
146
|
+
out.push(mkFinding({
|
|
147
|
+
id: "img-alt",
|
|
148
|
+
category: "Accessibility",
|
|
149
|
+
severity: "medium",
|
|
150
|
+
file, line: p.node.loc?.start.line ?? 1,
|
|
151
|
+
message: "<img> is missing an `alt` attribute.",
|
|
152
|
+
why: "Screen readers can't describe it, and if the image fails to load there's no fallback text.",
|
|
153
|
+
fix: "Add a descriptive `alt`. Use `alt=\"\"` only for purely decorative images.",
|
|
154
|
+
aiPrompt: `In ${file} near line ${p.node.loc?.start.line ?? 1}, add a descriptive alt attribute to the <img> element. If decorative, use alt="".`,
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
return out;
|
|
160
|
+
};
|
|
161
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
162
|
+
// 5. <a href="#..."> or onClick on div — keyboard a11y
|
|
163
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
164
|
+
const clickableDiv = (source, file, ast) => {
|
|
165
|
+
const out = [];
|
|
166
|
+
if (!ast || !/\.(tsx|jsx)$/.test(file))
|
|
167
|
+
return out;
|
|
168
|
+
traverse(ast, {
|
|
169
|
+
JSXOpeningElement(p) {
|
|
170
|
+
const n = p.node.name;
|
|
171
|
+
if (n.type !== "JSXIdentifier")
|
|
172
|
+
return;
|
|
173
|
+
if (n.name !== "div" && n.name !== "span")
|
|
174
|
+
return;
|
|
175
|
+
const hasOnClick = p.node.attributes.some((a) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "onClick");
|
|
176
|
+
const hasRole = p.node.attributes.some((a) => a.type === "JSXAttribute" && a.name.type === "JSXIdentifier" && a.name.name === "role");
|
|
177
|
+
if (hasOnClick && !hasRole) {
|
|
178
|
+
out.push(mkFinding({
|
|
179
|
+
id: "clickable-div",
|
|
180
|
+
category: "Accessibility",
|
|
181
|
+
severity: "medium",
|
|
182
|
+
file, line: p.node.loc?.start.line ?? 1,
|
|
183
|
+
message: `<${n.name}> has onClick but no role/keyboard handler.`,
|
|
184
|
+
why: "Keyboard users can't activate it. Screen readers won't announce it as interactive.",
|
|
185
|
+
fix: "Use a <button>, or add role=\"button\", tabIndex={0} and an onKeyDown that fires on Enter/Space.",
|
|
186
|
+
aiPrompt: `In ${file} near line ${p.node.loc?.start.line ?? 1}, replace this clickable <${n.name}> with a <button>, or add role="button", tabIndex={0}, and an onKeyDown handler that triggers on Enter and Space.`,
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
return out;
|
|
192
|
+
};
|
|
193
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
194
|
+
// 6. fetch / await inside .map — N+1 / cost risk
|
|
195
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
196
|
+
const nPlusOne = (source, file) => {
|
|
197
|
+
const out = [];
|
|
198
|
+
const re = /\.map\s*\(\s*(?:async\s*)?\(?[^)]*\)?\s*=>\s*\{[\s\S]{0,400}?(await\s+fetch|await\s+supabase|await\s+openai)/g;
|
|
199
|
+
let m;
|
|
200
|
+
while ((m = re.exec(source))) {
|
|
201
|
+
out.push(mkFinding({
|
|
202
|
+
id: "n-plus-one",
|
|
203
|
+
category: "Runaway Cloud Costs",
|
|
204
|
+
severity: "high",
|
|
205
|
+
file, line: lineOf(source, m.index),
|
|
206
|
+
message: "Awaited network call inside .map() — N+1 pattern.",
|
|
207
|
+
why: "One user action = N sequential round trips. Slow for users, expensive on serverless / paid APIs.",
|
|
208
|
+
fix: "Batch the request, use Promise.all on a mapped array of promises, or move the loop into a single SQL/API query.",
|
|
209
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, an awaited network call sits inside a .map(). Refactor to either (a) Promise.all over .map(item => fetchSomething(item)) so calls run in parallel, or (b) replace with a single batched request that takes the array of ids.`,
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
};
|
|
214
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
215
|
+
// 7. Recursive function call with no obvious guard — runaway risk
|
|
216
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
217
|
+
const recursiveRunaway = (source, file, ast) => {
|
|
218
|
+
const out = [];
|
|
219
|
+
if (!ast)
|
|
220
|
+
return out;
|
|
221
|
+
traverse(ast, {
|
|
222
|
+
FunctionDeclaration(p) {
|
|
223
|
+
const name = p.node.id?.name;
|
|
224
|
+
if (!name)
|
|
225
|
+
return;
|
|
226
|
+
const bodySrc = source.slice(p.node.body.start ?? 0, p.node.body.end ?? 0);
|
|
227
|
+
const calls = new RegExp(`\\b${name}\\s*\\(`, "g");
|
|
228
|
+
if (!calls.test(bodySrc))
|
|
229
|
+
return;
|
|
230
|
+
const hasGuard = /\bif\s*\(|return\s|throw\s/.test(bodySrc);
|
|
231
|
+
if (!hasGuard) {
|
|
232
|
+
out.push(mkFinding({
|
|
233
|
+
id: "recursive-runaway",
|
|
234
|
+
category: "Runaway Cloud Costs",
|
|
235
|
+
severity: "critical",
|
|
236
|
+
file, line: p.node.loc?.start.line ?? 1,
|
|
237
|
+
message: `Function \`${name}\` calls itself with no visible termination guard.`,
|
|
238
|
+
why: "On serverless this can bill you for a weekend. Locally it crashes with a stack overflow on first run.",
|
|
239
|
+
fix: "Add a base case (if/return) and a hard recursion-depth limit. Consider rewriting as a loop.",
|
|
240
|
+
aiPrompt: `In ${file}, function \`${name}\` recurses without a clear base case. Add: (1) an explicit base-case if/return, (2) a depth parameter that throws when exceeded, and (3) a unit test for the base case.`,
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
return out;
|
|
246
|
+
};
|
|
247
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
248
|
+
// 8. innerHTML / dangerouslySetInnerHTML with non-literal value
|
|
249
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
250
|
+
const dangerousHtml = (source, file) => {
|
|
251
|
+
const out = [];
|
|
252
|
+
const re = /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html:\s*([^}]+)\}/g;
|
|
253
|
+
let m;
|
|
254
|
+
while ((m = re.exec(source))) {
|
|
255
|
+
const expr = m[1].trim();
|
|
256
|
+
const isLiteral = /^["'`]/.test(expr);
|
|
257
|
+
const hasSanitize = /DOMPurify\.sanitize/.test(source);
|
|
258
|
+
if (!isLiteral && !hasSanitize) {
|
|
259
|
+
out.push(mkFinding({
|
|
260
|
+
id: "dangerous-html",
|
|
261
|
+
category: "Security",
|
|
262
|
+
severity: "high",
|
|
263
|
+
file, line: lineOf(source, m.index),
|
|
264
|
+
message: "dangerouslySetInnerHTML with a non-literal value (XSS risk).",
|
|
265
|
+
why: "If any part of that value came from a user or an API, you have an XSS vector.",
|
|
266
|
+
fix: "Sanitize with DOMPurify before injecting, or render through React text/markdown components.",
|
|
267
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, dangerouslySetInnerHTML receives a dynamic value. Either render the content through a safe markdown component, or sanitize the input with DOMPurify before passing __html.`,
|
|
268
|
+
}));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
};
|
|
273
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
274
|
+
// 9. <a href> referencing a route that does not exist (cheap heuristic)
|
|
275
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
276
|
+
const deadLink = (source, file) => {
|
|
277
|
+
const out = [];
|
|
278
|
+
// We only flag obvious placeholder/dead patterns we can identify w/o full graph.
|
|
279
|
+
const re = /href\s*=\s*["'](\/(?:privacy|terms|cookies|about|contact|pricing|docs)(?:\/[a-z0-9\-]*)?)["']/gi;
|
|
280
|
+
if (!/\.(tsx|jsx|html)$/.test(file))
|
|
281
|
+
return out;
|
|
282
|
+
let m;
|
|
283
|
+
while ((m = re.exec(source))) {
|
|
284
|
+
// Note: full route-graph dead-link detection lives in the cross-file pass.
|
|
285
|
+
// This per-file pass only flags raw <a href> when a Link/router import is in scope,
|
|
286
|
+
// because <a> bypasses the type-safe router and is the more common dead-link source.
|
|
287
|
+
if (!/from\s+["']@tanstack\/react-router["']/.test(source))
|
|
288
|
+
continue;
|
|
289
|
+
out.push(mkFinding({
|
|
290
|
+
id: "raw-anchor-route",
|
|
291
|
+
category: "Routing & Navigation",
|
|
292
|
+
severity: "medium",
|
|
293
|
+
file, line: lineOf(source, m.index),
|
|
294
|
+
message: `Raw <a href="${m[1]}"> in a router-aware file — bypasses type-safe routing.`,
|
|
295
|
+
why: "If the route is renamed or removed, the type checker won't flag the broken link. Users hit a dead end.",
|
|
296
|
+
fix: `Use <Link to="${m[1]}"> from @tanstack/react-router so the route is type-checked.`,
|
|
297
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, replace the raw <a href="${m[1]}"> with TanStack Router's <Link to="${m[1]}"> so the route is type-checked at build time.`,
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
};
|
|
302
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
303
|
+
// 10. setInterval / setTimeout in useEffect with no cleanup
|
|
304
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
305
|
+
const missingCleanup = (source, file) => {
|
|
306
|
+
const out = [];
|
|
307
|
+
if (!/\.(tsx|jsx|ts|js)$/.test(file))
|
|
308
|
+
return out;
|
|
309
|
+
const re = /useEffect\s*\(\s*\(\s*\)\s*=>\s*\{([\s\S]*?)\}\s*,\s*\[/g;
|
|
310
|
+
let m;
|
|
311
|
+
while ((m = re.exec(source))) {
|
|
312
|
+
const body = m[1];
|
|
313
|
+
const usesTimer = /\b(setInterval|addEventListener|subscribe)\s*\(/.test(body);
|
|
314
|
+
const hasCleanup = /return\s*\(?\s*\(?\s*\)?\s*=>/.test(body) || /return\s+function/.test(body);
|
|
315
|
+
if (usesTimer && !hasCleanup) {
|
|
316
|
+
out.push(mkFinding({
|
|
317
|
+
id: "missing-cleanup",
|
|
318
|
+
category: "Runtime Stability",
|
|
319
|
+
severity: "high",
|
|
320
|
+
file, line: lineOf(source, m.index),
|
|
321
|
+
message: "useEffect sets up an interval/listener/subscription but returns no cleanup.",
|
|
322
|
+
why: "On every re-render or unmount you leak a timer or listener. The page slows, costs climb, and weird bugs appear.",
|
|
323
|
+
fix: "Return a cleanup function from the effect that clears the interval / removes the listener / unsubscribes.",
|
|
324
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, the useEffect creates a long-lived resource (interval, listener, or subscription) but returns no cleanup. Add a return () => { ... } that tears it down on unmount.`,
|
|
325
|
+
}));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
};
|
|
330
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
331
|
+
// 11. Hardcoded API keys / secrets
|
|
332
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
333
|
+
const hardcodedSecret = (source, file) => {
|
|
334
|
+
const out = [];
|
|
335
|
+
if (/\.(md|json|lock|env\.example)$/.test(file))
|
|
336
|
+
return out;
|
|
337
|
+
const patterns = [
|
|
338
|
+
[/sk_(live|test)_[A-Za-z0-9]{20,}/g, "Stripe secret key"],
|
|
339
|
+
[/sk-[A-Za-z0-9]{20,}/g, "OpenAI-style API key"],
|
|
340
|
+
[/AKIA[0-9A-Z]{16}/g, "AWS access key id"],
|
|
341
|
+
[/AIza[0-9A-Za-z_\-]{30,}/g, "Google API key"],
|
|
342
|
+
[/gsk_[A-Za-z0-9]{40,}/g, "Groq API key"],
|
|
343
|
+
[/eyJ[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}\.[A-Za-z0-9_\-]{20,}/g, "JWT / Supabase service-role-shaped token"],
|
|
344
|
+
];
|
|
345
|
+
for (const [re, label] of patterns) {
|
|
346
|
+
let m;
|
|
347
|
+
while ((m = re.exec(source))) {
|
|
348
|
+
out.push(mkFinding({
|
|
349
|
+
id: "hardcoded-secret",
|
|
350
|
+
category: "Security",
|
|
351
|
+
severity: "critical",
|
|
352
|
+
file, line: lineOf(source, m.index),
|
|
353
|
+
message: `Hardcoded ${label} detected in source.`,
|
|
354
|
+
why: "Anyone who reads the repo (or the built JS bundle) gets your key. Rotate it immediately.",
|
|
355
|
+
fix: "Move the value into a server-only secret (Lovable Cloud / process.env), rotate the leaked key, and read it inside a server function.",
|
|
356
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, a ${label} is hardcoded. Replace the literal with process.env.<NAME> inside a server function, add the secret to the server env, and rotate the leaked key with the provider.`,
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return out;
|
|
361
|
+
};
|
|
362
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
363
|
+
// 12. console.log left in production code
|
|
364
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
365
|
+
const consoleLog = (source, file) => {
|
|
366
|
+
const out = [];
|
|
367
|
+
if (!/\.(tsx|jsx|ts|js)$/.test(file))
|
|
368
|
+
return out;
|
|
369
|
+
if (/\.test\.|\.spec\.|\/scripts\//.test(file))
|
|
370
|
+
return out;
|
|
371
|
+
const matches = source.match(/console\.log\s*\(/g);
|
|
372
|
+
if (matches && matches.length >= 1) {
|
|
373
|
+
const idx = source.indexOf("console.log");
|
|
374
|
+
out.push(mkFinding({
|
|
375
|
+
id: "console-log-prod",
|
|
376
|
+
category: "Observability",
|
|
377
|
+
severity: "low",
|
|
378
|
+
file, line: lineOf(source, idx),
|
|
379
|
+
message: `console.log left in source (${matches.length} occurrence${matches.length === 1 ? "" : "s"}).`,
|
|
380
|
+
why: "Production logs are noisy at best and leak data at worst (tokens, user emails, internal IDs).",
|
|
381
|
+
fix: "Remove debug logs, or route through a logger you can disable in production.",
|
|
382
|
+
aiPrompt: `In ${file}, remove all console.log statements (or replace with a debug-only logger). Keep console.error / console.warn for genuine failure paths.`,
|
|
383
|
+
}));
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
};
|
|
387
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
388
|
+
// 13. TODO / FIXME left in code
|
|
389
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
390
|
+
const todoLeftover = (source, file) => {
|
|
391
|
+
const out = [];
|
|
392
|
+
const re = /\/\/\s*(TODO|FIXME|XXX|HACK)\b/g;
|
|
393
|
+
let m;
|
|
394
|
+
let count = 0;
|
|
395
|
+
let firstIdx = -1;
|
|
396
|
+
while ((m = re.exec(source))) {
|
|
397
|
+
count++;
|
|
398
|
+
if (firstIdx < 0)
|
|
399
|
+
firstIdx = m.index;
|
|
400
|
+
}
|
|
401
|
+
if (count > 0) {
|
|
402
|
+
out.push(mkFinding({
|
|
403
|
+
id: "todo-leftover",
|
|
404
|
+
category: "Polish & Embarrassment Risks",
|
|
405
|
+
severity: "low",
|
|
406
|
+
file, line: lineOf(source, firstIdx),
|
|
407
|
+
message: `${count} TODO/FIXME comment${count === 1 ? "" : "s"} left in source.`,
|
|
408
|
+
why: "Leftover TODOs are unspoken known-issues. They become bug reports the day after launch.",
|
|
409
|
+
fix: "Resolve, file as a tracked issue, or delete.",
|
|
410
|
+
aiPrompt: `In ${file}, list every TODO/FIXME comment with surrounding context and propose either a concrete fix or a one-line issue title that could be filed.`,
|
|
411
|
+
}));
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
};
|
|
415
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
416
|
+
// 14. Placeholder content (Lorem ipsum, "coming soon")
|
|
417
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
418
|
+
const placeholderContent = (source, file) => {
|
|
419
|
+
const out = [];
|
|
420
|
+
if (!/\.(tsx|jsx|md|html)$/.test(file))
|
|
421
|
+
return out;
|
|
422
|
+
const re = /(lorem\s+ipsum|coming\s+soon|under\s+construction|placeholder\s+text)/i;
|
|
423
|
+
const m = matchInCode(source, re);
|
|
424
|
+
if (m) {
|
|
425
|
+
out.push(mkFinding({
|
|
426
|
+
id: "placeholder-content",
|
|
427
|
+
category: "Polish & Embarrassment Risks",
|
|
428
|
+
severity: "medium",
|
|
429
|
+
file, line: lineOf(source, m.index),
|
|
430
|
+
message: `Placeholder content detected: "${m[1]}".`,
|
|
431
|
+
why: "Visible placeholder content is the #1 sign of an unfinished app. Users notice instantly.",
|
|
432
|
+
fix: "Replace with real copy, or remove the section until it's ready.",
|
|
433
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, replace the placeholder text "${m[1]}" with concrete copy that matches the surrounding section's purpose.`,
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
};
|
|
438
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
439
|
+
// 15. fetch call without .catch / try/catch nearby
|
|
440
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
441
|
+
const unhandledFetch = (source, file) => {
|
|
442
|
+
const out = [];
|
|
443
|
+
if (!/\.(tsx|jsx|ts|js)$/.test(file))
|
|
444
|
+
return out;
|
|
445
|
+
const re = /\bfetch\s*\(/g;
|
|
446
|
+
let m;
|
|
447
|
+
while ((m = re.exec(source))) {
|
|
448
|
+
// Look 200 chars in either direction for .catch or try
|
|
449
|
+
const window = source.slice(Math.max(0, m.index - 80), m.index + 400);
|
|
450
|
+
const handled = /\.catch\s*\(|try\s*\{[\s\S]*?await\s+[\s\S]{0,40}fetch/.test(window)
|
|
451
|
+
|| /try\s*\{/.test(source.slice(Math.max(0, m.index - 200), m.index));
|
|
452
|
+
if (!handled) {
|
|
453
|
+
out.push(mkFinding({
|
|
454
|
+
id: "unhandled-fetch",
|
|
455
|
+
category: "Runtime Stability",
|
|
456
|
+
severity: "high",
|
|
457
|
+
file, line: lineOf(source, m.index),
|
|
458
|
+
message: "fetch call has no visible .catch or try/catch.",
|
|
459
|
+
why: "Network requests fail. Without a catch, the UI silently breaks and the user has no idea why.",
|
|
460
|
+
fix: "Wrap in try/catch (for await) or chain .catch — set an error state and surface a retry path.",
|
|
461
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, wrap the fetch call in try/catch (or chain .catch), set an error UI state on failure, and offer the user a retry button.`,
|
|
462
|
+
}));
|
|
463
|
+
return out; // one per file is enough signal
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
};
|
|
468
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
469
|
+
// 16. dangerouslySetInnerHTML already covered above. Add: optional-chain on user obj
|
|
470
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
471
|
+
const unsafeUserAccess = (source, file) => {
|
|
472
|
+
const out = [];
|
|
473
|
+
if (!/\.(tsx|jsx|ts)$/.test(file))
|
|
474
|
+
return out;
|
|
475
|
+
const re = /\b(user|profile|currentUser|me)\.(avatar|photo|image|name|email|phone|bio)\.(url|src|href|toLowerCase|toUpperCase|length)\b/g;
|
|
476
|
+
const m = matchInCode(source, re);
|
|
477
|
+
if (m) {
|
|
478
|
+
out.push(mkFinding({
|
|
479
|
+
id: "unsafe-user-access",
|
|
480
|
+
category: "Reality & Resilience",
|
|
481
|
+
severity: "high",
|
|
482
|
+
file, line: lineOf(source, m.index),
|
|
483
|
+
message: `\`${m[0]}\` assumes nested user data exists.`,
|
|
484
|
+
why: "Real users often have missing avatars, names, or phones. This crashes the UI with 'Cannot read properties of undefined'.",
|
|
485
|
+
fix: "Use optional chaining (`?.`) and provide a fallback. Render a default avatar / placeholder string when data is missing.",
|
|
486
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, the expression \`${m[0]}\` assumes nested user data exists. Add optional chaining and a sensible fallback (default avatar URL, empty string, or placeholder component).`,
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
return out;
|
|
490
|
+
};
|
|
491
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
492
|
+
// 17. Source Maps Enabled in Production
|
|
493
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
494
|
+
const sourceMaps = (source, file) => {
|
|
495
|
+
const out = [];
|
|
496
|
+
if (!/(vite|next|webpack)\.config\.(ts|js)$/.test(file))
|
|
497
|
+
return out;
|
|
498
|
+
if (/sourcemap:\s*true/.test(source) && !/process\.env\.NODE_ENV\s*===\s*['"]development['"]/.test(source)) {
|
|
499
|
+
out.push(mkFinding({
|
|
500
|
+
id: "exposed-sourcemaps",
|
|
501
|
+
category: "Security",
|
|
502
|
+
severity: "medium",
|
|
503
|
+
file, line: 1,
|
|
504
|
+
message: "Source maps enabled in production.",
|
|
505
|
+
why: "Source maps expose your original, unminified source code to everyone.",
|
|
506
|
+
fix: "Set sourcemap to false in production, or restrict access to source maps.",
|
|
507
|
+
aiPrompt: `In ${file}, disable sourcemaps for production builds.`,
|
|
508
|
+
}));
|
|
509
|
+
}
|
|
510
|
+
return out;
|
|
511
|
+
};
|
|
512
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
513
|
+
// 18. Unauthenticated Admin Dashboard
|
|
514
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
515
|
+
const unauthAdmin = (source, file) => {
|
|
516
|
+
const out = [];
|
|
517
|
+
if (!/\badmin\b/i.test(file))
|
|
518
|
+
return out;
|
|
519
|
+
if (!/\.(tsx|jsx|ts)$/.test(file))
|
|
520
|
+
return out;
|
|
521
|
+
// Routes inside _authenticated/ are protected by the parent layout's beforeLoad.
|
|
522
|
+
if (/_authenticated[/\\]/.test(file))
|
|
523
|
+
return out;
|
|
524
|
+
const isRoute = /export const Route =|createFileRoute/i.test(source) || /export default function /i.test(source);
|
|
525
|
+
const hasAuth = /useAuth|getUser|requireAuth|protected|middleware/i.test(source);
|
|
526
|
+
if (isRoute && !hasAuth && !/components?\/|lib\//.test(file)) {
|
|
527
|
+
out.push(mkFinding({
|
|
528
|
+
id: "unauth-admin",
|
|
529
|
+
category: "Security",
|
|
530
|
+
severity: "critical",
|
|
531
|
+
file, line: 1,
|
|
532
|
+
message: "Admin page missing authentication check.",
|
|
533
|
+
why: "Anyone who guesses the URL can access your admin dashboard.",
|
|
534
|
+
fix: "Add an authentication check (e.g. getUser()) and redirect unauthenticated users.",
|
|
535
|
+
aiPrompt: `In ${file}, this admin route appears to be missing an authentication check. Add a check to ensure the user is logged in and has an admin role before rendering.`,
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
};
|
|
540
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
541
|
+
// 19. Missing Rate Limit on Paid APIs
|
|
542
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
543
|
+
const missingRateLimit = (source, file) => {
|
|
544
|
+
const out = [];
|
|
545
|
+
if (!/\/api\/|\.server\./.test(file))
|
|
546
|
+
return out;
|
|
547
|
+
const isRoute = /export const (POST|GET|PUT|DELETE|Route) =|createServerFn/i.test(source);
|
|
548
|
+
const touchesPaidApi = /openai|anthropic|stripe|sendgrid|resend/i.test(source);
|
|
549
|
+
const hasRateLimit = /ratelimit|throttle|upstash/i.test(source);
|
|
550
|
+
if (isRoute && touchesPaidApi && !hasRateLimit) {
|
|
551
|
+
out.push(mkFinding({
|
|
552
|
+
id: "missing-rate-limit",
|
|
553
|
+
category: "Runaway Cloud Costs",
|
|
554
|
+
severity: "high",
|
|
555
|
+
file, line: 1,
|
|
556
|
+
message: "API endpoint touching paid service lacks rate limiting.",
|
|
557
|
+
why: "A malicious user (or buggy script) can drain your API credits or run up thousands of dollars in charges.",
|
|
558
|
+
fix: "Add a rate limiter (e.g., Upstash Redis) to restrict requests per IP or user.",
|
|
559
|
+
aiPrompt: `In ${file}, this API endpoint calls a paid external service but has no rate limiting. Implement a rate limiter to prevent abuse.`,
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
};
|
|
564
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
565
|
+
// 20. IDOR Data Fetch
|
|
566
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
567
|
+
const idorDataFetch = (source, file) => {
|
|
568
|
+
const out = [];
|
|
569
|
+
if (!/\/api\/|\.server\./.test(file))
|
|
570
|
+
return out;
|
|
571
|
+
const re = /\.from\(['"][^'"]+['"]\)\s*\.select\(['"][^'"]*['"]\)\s*\.eq\(['"]id['"]\s*,\s*[^)]+\)/g;
|
|
572
|
+
let m;
|
|
573
|
+
while ((m = re.exec(source))) {
|
|
574
|
+
const window = source.slice(Math.max(0, m.index - 50), m.index + 200);
|
|
575
|
+
if (!window.includes(".eq('user_id'") && !window.includes('.eq("user_id"')) {
|
|
576
|
+
out.push(mkFinding({
|
|
577
|
+
id: "idor-data-fetch",
|
|
578
|
+
category: "Security",
|
|
579
|
+
severity: "critical",
|
|
580
|
+
file, line: lineOf(source, m.index),
|
|
581
|
+
message: "Fetching record by ID without checking user ownership.",
|
|
582
|
+
why: "Taking a user URL into another PC or browser gives access to that user's data (IDOR).",
|
|
583
|
+
fix: "Chain \`.eq('user_id', auth.uid)\` to the query so users can only access their own records.",
|
|
584
|
+
aiPrompt: `In ${file} near line ${lineOf(source, m.index)}, a database query fetches a record by ID but does not check if the current user owns it. Add an .eq("user_id", currentUserId) clause.`,
|
|
585
|
+
}));
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
};
|
|
590
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
591
|
+
// 21. Dead End Page (No Navigation/Layout)
|
|
592
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
593
|
+
const deadEndPage = (source, file) => {
|
|
594
|
+
const out = [];
|
|
595
|
+
if (!/\.(tsx|jsx)$/.test(file))
|
|
596
|
+
return out;
|
|
597
|
+
if (!/src\/routes\//.test(file))
|
|
598
|
+
return out; // Only check route pages
|
|
599
|
+
if (/__root|_layout|\.server/.test(file))
|
|
600
|
+
return out; // Ignore layouts
|
|
601
|
+
const isRoute = /createFileRoute|export default function/i.test(source);
|
|
602
|
+
if (!isRoute)
|
|
603
|
+
return out;
|
|
604
|
+
// _authenticated/ children inherit DashShell from the parent route layout.
|
|
605
|
+
if (/_authenticated[/\\]/.test(file))
|
|
606
|
+
return out;
|
|
607
|
+
const hasLayout = /DashShell|Layout|Navbar|Sidebar|Header|Footer/.test(source);
|
|
608
|
+
const hasLink = /<Link|useRouter\(\)\.history\.back|useNavigate/i.test(source);
|
|
609
|
+
const hasOutlet = /<Outlet/.test(source);
|
|
610
|
+
if (!hasLayout && !hasLink && !hasOutlet) {
|
|
611
|
+
out.push(mkFinding({
|
|
612
|
+
id: "dead-end-page",
|
|
613
|
+
category: "Routing & Navigation",
|
|
614
|
+
severity: "high",
|
|
615
|
+
file, line: 1,
|
|
616
|
+
message: "Page appears to be a dead end with no layout or navigation links.",
|
|
617
|
+
why: "If a user lands on this page, they cannot navigate away without using the browser's back button. This traps users.",
|
|
618
|
+
fix: "Wrap the page content in your application's layout component (e.g., <DashShell>) or add a <Link> back to the previous page.",
|
|
619
|
+
aiPrompt: `In ${file}, this route lacks a layout wrapper or any navigation links. Wrap the component in the standard layout (e.g., <DashShell>) so the user isn't trapped.`,
|
|
620
|
+
}));
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
};
|
|
624
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
625
|
+
// Registry
|
|
626
|
+
// ───────────────────────────────────────────────────────────────────────
|
|
627
|
+
const CHECKS = [
|
|
628
|
+
silentCatch,
|
|
629
|
+
envLeak,
|
|
630
|
+
tsIgnore,
|
|
631
|
+
imgAlt,
|
|
632
|
+
clickableDiv,
|
|
633
|
+
nPlusOne,
|
|
634
|
+
recursiveRunaway,
|
|
635
|
+
dangerousHtml,
|
|
636
|
+
deadLink,
|
|
637
|
+
missingCleanup,
|
|
638
|
+
hardcodedSecret,
|
|
639
|
+
consoleLog,
|
|
640
|
+
todoLeftover,
|
|
641
|
+
placeholderContent,
|
|
642
|
+
unhandledFetch,
|
|
643
|
+
unsafeUserAccess,
|
|
644
|
+
sourceMaps,
|
|
645
|
+
unauthAdmin,
|
|
646
|
+
missingRateLimit,
|
|
647
|
+
idorDataFetch,
|
|
648
|
+
deadEndPage,
|
|
649
|
+
];
|
|
650
|
+
export const PER_FILE_CHECK_COUNT = CHECKS.length;
|
|
651
|
+
export function runChecks(source, file, ast) {
|
|
652
|
+
const out = [];
|
|
653
|
+
for (const c of CHECKS) {
|
|
654
|
+
try {
|
|
655
|
+
out.push(...c(source, file, ast));
|
|
656
|
+
}
|
|
657
|
+
catch (e) {
|
|
658
|
+
console.error(`[periderm] Check failed on ${file}:`, e);
|
|
659
|
+
out.push(mkFinding({ id: "internal-error", category: "Runtime Stability", severity: "high", file, line: 1, message: `Scanner crashed: ${e}`, why: "Internal exception in scanner rule.", fix: "Report this issue.", aiPrompt: "Fix the crash in the scanner rule." }));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
function mkFinding(f) { return f; }
|