prodlint 0.5.0 → 0.7.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 +31 -11
- package/action.yml +2 -2
- package/dist/cli.js +1650 -3
- package/dist/index.js +1649 -2
- package/dist/mcp.js +1651 -4
- package/package.json +2 -2
package/dist/mcp.js
CHANGED
|
@@ -249,6 +249,81 @@ function findLoopsAST(ast) {
|
|
|
249
249
|
});
|
|
250
250
|
return loops;
|
|
251
251
|
}
|
|
252
|
+
function isUserInputNode(node) {
|
|
253
|
+
if (node.type === "MemberExpression") {
|
|
254
|
+
const mem = node;
|
|
255
|
+
if (mem.object.type === "MemberExpression") {
|
|
256
|
+
const inner = mem.object;
|
|
257
|
+
if (inner.object.type === "Identifier") {
|
|
258
|
+
const objName = inner.object.name;
|
|
259
|
+
if (objName === "req" || objName === "request") {
|
|
260
|
+
if (inner.property.type === "Identifier") {
|
|
261
|
+
const prop = inner.property.name;
|
|
262
|
+
if (prop === "query" || prop === "body" || prop === "params") return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (node.type === "CallExpression") {
|
|
269
|
+
const call = node;
|
|
270
|
+
if (call.callee.type === "MemberExpression") {
|
|
271
|
+
const callee = call.callee;
|
|
272
|
+
if (callee.property.type === "Identifier" && callee.property.name === "get") {
|
|
273
|
+
if (callee.object.type === "Identifier") {
|
|
274
|
+
const name = callee.object.name;
|
|
275
|
+
if (name === "searchParams" || name === "formData") return true;
|
|
276
|
+
}
|
|
277
|
+
if (callee.object.type === "MemberExpression") {
|
|
278
|
+
const inner = callee.object;
|
|
279
|
+
if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
function isStaticString(node) {
|
|
289
|
+
if (node.type === "StringLiteral") return true;
|
|
290
|
+
if (node.type === "TemplateLiteral") {
|
|
291
|
+
return node.expressions.length === 0;
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
function findUseEffectRanges(ast) {
|
|
296
|
+
const ranges = [];
|
|
297
|
+
walkAST(ast.program, (node) => {
|
|
298
|
+
if (node.type !== "CallExpression") return;
|
|
299
|
+
const call = node;
|
|
300
|
+
if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
|
|
301
|
+
const callback = call.arguments[0];
|
|
302
|
+
if (!callback || !callback.loc) return;
|
|
303
|
+
ranges.push({
|
|
304
|
+
start: callback.loc.start.line - 1,
|
|
305
|
+
end: callback.loc.end.line - 1
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
return ranges;
|
|
309
|
+
}
|
|
310
|
+
function subtreeContains(node, predicate) {
|
|
311
|
+
if (predicate(node)) return true;
|
|
312
|
+
for (const key of Object.keys(node)) {
|
|
313
|
+
if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
|
|
314
|
+
const val = node[key];
|
|
315
|
+
if (Array.isArray(val)) {
|
|
316
|
+
for (const item of val) {
|
|
317
|
+
if (item && typeof item === "object" && item.type) {
|
|
318
|
+
if (subtreeContains(item, predicate)) return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} else if (val && typeof val === "object" && val.type) {
|
|
322
|
+
if (subtreeContains(val, predicate)) return true;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
252
327
|
|
|
253
328
|
// src/utils/frameworks.ts
|
|
254
329
|
var FRAMEWORK_SAFE_METHODS = {
|
|
@@ -318,7 +393,8 @@ var SCAN_EXTENSIONS = [
|
|
|
318
393
|
"jsx",
|
|
319
394
|
"mjs",
|
|
320
395
|
"cjs",
|
|
321
|
-
"json"
|
|
396
|
+
"json",
|
|
397
|
+
"sql"
|
|
322
398
|
];
|
|
323
399
|
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
324
400
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
@@ -1150,6 +1226,81 @@ var unsafeHtmlRule = {
|
|
|
1150
1226
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1151
1227
|
check(file, _project) {
|
|
1152
1228
|
const findings = [];
|
|
1229
|
+
if (file.ast) {
|
|
1230
|
+
try {
|
|
1231
|
+
walkAST(file.ast.program, (node) => {
|
|
1232
|
+
if (node.type === "JSXAttribute") {
|
|
1233
|
+
const attr = node;
|
|
1234
|
+
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1235
|
+
if (attr.value && subtreeContains(attr.value, (n) => {
|
|
1236
|
+
if (n.type !== "CallExpression") return false;
|
|
1237
|
+
const call = n;
|
|
1238
|
+
if (call.callee.type === "MemberExpression") {
|
|
1239
|
+
const mem = call.callee;
|
|
1240
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1241
|
+
}
|
|
1242
|
+
return false;
|
|
1243
|
+
})) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
const line = attr.loc.start.line;
|
|
1247
|
+
findings.push({
|
|
1248
|
+
ruleId: "unsafe-html",
|
|
1249
|
+
file: file.relativePath,
|
|
1250
|
+
line,
|
|
1251
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1252
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1253
|
+
severity: "critical",
|
|
1254
|
+
category: "security"
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (node.type === "ObjectProperty") {
|
|
1259
|
+
const prop = node;
|
|
1260
|
+
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1261
|
+
if (prop.value && subtreeContains(prop.value, (n) => {
|
|
1262
|
+
if (n.type !== "CallExpression") return false;
|
|
1263
|
+
const call = n;
|
|
1264
|
+
if (call.callee.type === "MemberExpression") {
|
|
1265
|
+
const mem = call.callee;
|
|
1266
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1267
|
+
}
|
|
1268
|
+
return false;
|
|
1269
|
+
})) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
const line = prop.loc.start.line;
|
|
1273
|
+
findings.push({
|
|
1274
|
+
ruleId: "unsafe-html",
|
|
1275
|
+
file: file.relativePath,
|
|
1276
|
+
line,
|
|
1277
|
+
column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
|
|
1278
|
+
message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
|
|
1279
|
+
severity: "critical",
|
|
1280
|
+
category: "security"
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (node.type === "AssignmentExpression") {
|
|
1285
|
+
const assign = node;
|
|
1286
|
+
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1287
|
+
const line = assign.loc.start.line;
|
|
1288
|
+
findings.push({
|
|
1289
|
+
ruleId: "unsafe-html",
|
|
1290
|
+
file: file.relativePath,
|
|
1291
|
+
line,
|
|
1292
|
+
column: file.lines[line - 1].indexOf(".innerHTML") + 1,
|
|
1293
|
+
message: "Direct innerHTML assignment is an XSS risk",
|
|
1294
|
+
severity: "critical",
|
|
1295
|
+
category: "security"
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
return findings;
|
|
1301
|
+
} catch {
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1153
1304
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1154
1305
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1155
1306
|
const line = file.lines[i];
|
|
@@ -1403,6 +1554,22 @@ var DIRECT_INPUT_PATTERNS = [
|
|
|
1403
1554
|
var WARNING_PATTERNS = [
|
|
1404
1555
|
/redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
|
|
1405
1556
|
];
|
|
1557
|
+
var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
|
|
1558
|
+
"url",
|
|
1559
|
+
"returnUrl",
|
|
1560
|
+
"returnTo",
|
|
1561
|
+
"redirectUrl",
|
|
1562
|
+
"redirectTo",
|
|
1563
|
+
"next",
|
|
1564
|
+
"callbackUrl",
|
|
1565
|
+
"destination",
|
|
1566
|
+
"redirect",
|
|
1567
|
+
"goto",
|
|
1568
|
+
"to",
|
|
1569
|
+
"target",
|
|
1570
|
+
"uri",
|
|
1571
|
+
"href"
|
|
1572
|
+
]);
|
|
1406
1573
|
var openRedirectRule = {
|
|
1407
1574
|
id: "open-redirect",
|
|
1408
1575
|
name: "Open Redirect",
|
|
@@ -1412,6 +1579,62 @@ var openRedirectRule = {
|
|
|
1412
1579
|
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
1413
1580
|
check(file, _project) {
|
|
1414
1581
|
const findings = [];
|
|
1582
|
+
if (file.ast) {
|
|
1583
|
+
try {
|
|
1584
|
+
walkAST(file.ast.program, (node) => {
|
|
1585
|
+
if (node.type !== "CallExpression") return;
|
|
1586
|
+
const call = node;
|
|
1587
|
+
if (!call.loc) return;
|
|
1588
|
+
let isRedirect = false;
|
|
1589
|
+
if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
|
|
1590
|
+
isRedirect = true;
|
|
1591
|
+
}
|
|
1592
|
+
if (call.callee.type === "MemberExpression") {
|
|
1593
|
+
const mem = call.callee;
|
|
1594
|
+
if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
|
|
1595
|
+
isRedirect = true;
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
if (!isRedirect) return;
|
|
1599
|
+
const arg = call.arguments[0];
|
|
1600
|
+
if (!arg) return;
|
|
1601
|
+
if (isStaticString(arg)) return;
|
|
1602
|
+
let targetArg = arg;
|
|
1603
|
+
if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
|
|
1604
|
+
targetArg = arg.arguments[0];
|
|
1605
|
+
if (!targetArg) return;
|
|
1606
|
+
if (isStaticString(targetArg)) return;
|
|
1607
|
+
}
|
|
1608
|
+
const lineNum = call.loc.start.line;
|
|
1609
|
+
const col = call.loc.start.column + 1;
|
|
1610
|
+
if (isUserInputNode(targetArg)) {
|
|
1611
|
+
findings.push({
|
|
1612
|
+
ruleId: "open-redirect",
|
|
1613
|
+
file: file.relativePath,
|
|
1614
|
+
line: lineNum,
|
|
1615
|
+
column: col,
|
|
1616
|
+
message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
|
|
1617
|
+
severity: "warning",
|
|
1618
|
+
category: "security"
|
|
1619
|
+
});
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
|
|
1623
|
+
findings.push({
|
|
1624
|
+
ruleId: "open-redirect",
|
|
1625
|
+
file: file.relativePath,
|
|
1626
|
+
line: lineNum,
|
|
1627
|
+
column: col,
|
|
1628
|
+
message: "Possible user input in redirect \u2014 verify the URL is validated before use",
|
|
1629
|
+
severity: "warning",
|
|
1630
|
+
category: "security"
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
});
|
|
1634
|
+
return findings;
|
|
1635
|
+
} catch {
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1415
1638
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1416
1639
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1417
1640
|
const line = file.lines[i];
|
|
@@ -2059,6 +2282,34 @@ var shallowCatchRule = {
|
|
|
2059
2282
|
if (isTestFile(file.relativePath)) return [];
|
|
2060
2283
|
if (isScriptFile(file.relativePath)) return [];
|
|
2061
2284
|
const findings = [];
|
|
2285
|
+
if (file.ast) {
|
|
2286
|
+
try {
|
|
2287
|
+
walkAST(file.ast.program, (node) => {
|
|
2288
|
+
if (node.type !== "CatchClause") return;
|
|
2289
|
+
if (!node.loc) return;
|
|
2290
|
+
const catchLine = node.loc.start.line - 1;
|
|
2291
|
+
const body = node.body;
|
|
2292
|
+
if (!body || !body.loc) return;
|
|
2293
|
+
const bodyStart = body.loc.start.line - 1;
|
|
2294
|
+
const bodyEnd = body.loc.end.line - 1;
|
|
2295
|
+
const bodyLines = file.lines.slice(bodyStart + 1, bodyEnd);
|
|
2296
|
+
const { score, label } = scoreCatchBody(bodyLines);
|
|
2297
|
+
if (score <= 1) {
|
|
2298
|
+
findings.push({
|
|
2299
|
+
ruleId: "shallow-catch",
|
|
2300
|
+
file: file.relativePath,
|
|
2301
|
+
line: catchLine + 1,
|
|
2302
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2303
|
+
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2304
|
+
severity: score === 0 ? "warning" : "info",
|
|
2305
|
+
category: "reliability"
|
|
2306
|
+
});
|
|
2307
|
+
}
|
|
2308
|
+
});
|
|
2309
|
+
return findings;
|
|
2310
|
+
} catch {
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2062
2313
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2063
2314
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2064
2315
|
const trimmed = file.lines[i].trim();
|
|
@@ -2392,6 +2643,7 @@ var insecureCookieRule = {
|
|
|
2392
2643
|
|
|
2393
2644
|
// src/rules/leaked-env-in-logs.ts
|
|
2394
2645
|
var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
|
|
2646
|
+
var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
|
|
2395
2647
|
var leakedEnvInLogsRule = {
|
|
2396
2648
|
id: "leaked-env-in-logs",
|
|
2397
2649
|
name: "Leaked Env in Logs",
|
|
@@ -2403,6 +2655,48 @@ var leakedEnvInLogsRule = {
|
|
|
2403
2655
|
if (isTestFile(file.relativePath)) return [];
|
|
2404
2656
|
if (isScriptFile(file.relativePath)) return [];
|
|
2405
2657
|
const findings = [];
|
|
2658
|
+
if (file.ast) {
|
|
2659
|
+
try {
|
|
2660
|
+
walkAST(file.ast.program, (node) => {
|
|
2661
|
+
if (node.type !== "CallExpression") return;
|
|
2662
|
+
const call = node;
|
|
2663
|
+
if (!call.loc) return;
|
|
2664
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2665
|
+
const mem = call.callee;
|
|
2666
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
|
|
2667
|
+
if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
|
|
2668
|
+
let hasEnvAccess = false;
|
|
2669
|
+
for (const arg of call.arguments) {
|
|
2670
|
+
if (subtreeContains(arg, (n) => {
|
|
2671
|
+
if (n.type !== "MemberExpression") return false;
|
|
2672
|
+
const m = n;
|
|
2673
|
+
if (m.object.type === "MemberExpression") {
|
|
2674
|
+
const inner = m.object;
|
|
2675
|
+
return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
|
|
2676
|
+
}
|
|
2677
|
+
return false;
|
|
2678
|
+
})) {
|
|
2679
|
+
hasEnvAccess = true;
|
|
2680
|
+
break;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
if (hasEnvAccess) {
|
|
2684
|
+
findings.push({
|
|
2685
|
+
ruleId: "leaked-env-in-logs",
|
|
2686
|
+
file: file.relativePath,
|
|
2687
|
+
line: call.loc.start.line,
|
|
2688
|
+
column: call.loc.start.column + 1,
|
|
2689
|
+
message: "process.env value in console output \u2014 may leak secrets in production logs",
|
|
2690
|
+
severity: "warning",
|
|
2691
|
+
category: "security",
|
|
2692
|
+
fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
|
|
2693
|
+
});
|
|
2694
|
+
}
|
|
2695
|
+
});
|
|
2696
|
+
return findings;
|
|
2697
|
+
} catch {
|
|
2698
|
+
}
|
|
2699
|
+
}
|
|
2406
2700
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2407
2701
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2408
2702
|
const line = file.lines[i];
|
|
@@ -2507,6 +2801,15 @@ var nextServerActionValidationRule = {
|
|
|
2507
2801
|
// src/rules/missing-transaction.ts
|
|
2508
2802
|
var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
|
|
2509
2803
|
var PRISMA_TRANSACTION = /\$transaction\s*\(/;
|
|
2804
|
+
var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
2805
|
+
"create",
|
|
2806
|
+
"update",
|
|
2807
|
+
"delete",
|
|
2808
|
+
"upsert",
|
|
2809
|
+
"createMany",
|
|
2810
|
+
"updateMany",
|
|
2811
|
+
"deleteMany"
|
|
2812
|
+
]);
|
|
2510
2813
|
var missingTransactionRule = {
|
|
2511
2814
|
id: "missing-transaction",
|
|
2512
2815
|
name: "Missing Transaction",
|
|
@@ -2518,6 +2821,60 @@ var missingTransactionRule = {
|
|
|
2518
2821
|
if (isTestFile(file.relativePath)) return [];
|
|
2519
2822
|
if (isScriptFile(file.relativePath)) return [];
|
|
2520
2823
|
if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
|
|
2824
|
+
if (file.ast) {
|
|
2825
|
+
try {
|
|
2826
|
+
const parentMap = /* @__PURE__ */ new Map();
|
|
2827
|
+
walkAST(file.ast.program, (node, parent) => {
|
|
2828
|
+
if (parent) parentMap.set(node, parent);
|
|
2829
|
+
});
|
|
2830
|
+
const writesByScope = /* @__PURE__ */ new Map();
|
|
2831
|
+
const transactionScopes = /* @__PURE__ */ new Set();
|
|
2832
|
+
walkAST(file.ast.program, (node) => {
|
|
2833
|
+
if (node.type !== "CallExpression") return;
|
|
2834
|
+
const call = node;
|
|
2835
|
+
if (!call.loc) return;
|
|
2836
|
+
if (call.callee.type === "MemberExpression") {
|
|
2837
|
+
const mem = call.callee;
|
|
2838
|
+
if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
|
|
2839
|
+
const scope2 = findEnclosingFunction(node, parentMap);
|
|
2840
|
+
transactionScopes.add(scope2);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
}
|
|
2844
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
2845
|
+
const outerMem = call.callee;
|
|
2846
|
+
if (outerMem.property.type !== "Identifier") return;
|
|
2847
|
+
if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
|
|
2848
|
+
if (outerMem.object.type !== "MemberExpression") return;
|
|
2849
|
+
const innerMem = outerMem.object;
|
|
2850
|
+
if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
|
|
2851
|
+
const scope = findEnclosingFunction(node, parentMap);
|
|
2852
|
+
const existing = writesByScope.get(scope);
|
|
2853
|
+
if (existing) {
|
|
2854
|
+
existing.count++;
|
|
2855
|
+
} else {
|
|
2856
|
+
writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2859
|
+
const findings = [];
|
|
2860
|
+
for (const [scope, { count, firstLine }] of writesByScope) {
|
|
2861
|
+
if (count < 2) continue;
|
|
2862
|
+
if (transactionScopes.has(scope)) continue;
|
|
2863
|
+
findings.push({
|
|
2864
|
+
ruleId: "missing-transaction",
|
|
2865
|
+
file: file.relativePath,
|
|
2866
|
+
line: firstLine,
|
|
2867
|
+
column: 1,
|
|
2868
|
+
message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
|
|
2869
|
+
severity: "warning",
|
|
2870
|
+
category: "reliability",
|
|
2871
|
+
fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
|
|
2872
|
+
});
|
|
2873
|
+
}
|
|
2874
|
+
return findings;
|
|
2875
|
+
} catch {
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2521
2878
|
let writeCount = 0;
|
|
2522
2879
|
let firstWriteLine = -1;
|
|
2523
2880
|
const hasTransaction = PRISMA_TRANSACTION.test(file.content);
|
|
@@ -2541,6 +2898,1276 @@ var missingTransactionRule = {
|
|
|
2541
2898
|
}];
|
|
2542
2899
|
}
|
|
2543
2900
|
};
|
|
2901
|
+
function findEnclosingFunction(node, parentMap) {
|
|
2902
|
+
let current = parentMap.get(node);
|
|
2903
|
+
while (current) {
|
|
2904
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
2905
|
+
return current;
|
|
2906
|
+
}
|
|
2907
|
+
current = parentMap.get(current);
|
|
2908
|
+
}
|
|
2909
|
+
return null;
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// src/rules/redirect-in-try-catch.ts
|
|
2913
|
+
var redirectInTryCatchRule = {
|
|
2914
|
+
id: "redirect-in-try-catch",
|
|
2915
|
+
name: "Redirect Inside Try/Catch",
|
|
2916
|
+
description: "Detects Next.js redirect() inside try/catch blocks \u2014 redirect throws internally and the catch swallows it",
|
|
2917
|
+
category: "reliability",
|
|
2918
|
+
severity: "critical",
|
|
2919
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2920
|
+
check(file, _project) {
|
|
2921
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2922
|
+
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
2923
|
+
const findings = [];
|
|
2924
|
+
let tryDepth = 0;
|
|
2925
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
2926
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
2927
|
+
const line = file.lines[i];
|
|
2928
|
+
const trimmed = line.trim();
|
|
2929
|
+
if (/\btry\s*\{/.test(trimmed) || trimmed === "try {") {
|
|
2930
|
+
tryDepth++;
|
|
2931
|
+
}
|
|
2932
|
+
if (/\}\s*catch\s*[\s(]/.test(trimmed)) {
|
|
2933
|
+
}
|
|
2934
|
+
if (tryDepth > 0) {
|
|
2935
|
+
const match = /\bredirect\s*\(/.exec(line);
|
|
2936
|
+
if (match && !/\/\//.test(line.slice(0, match.index))) {
|
|
2937
|
+
findings.push({
|
|
2938
|
+
ruleId: "redirect-in-try-catch",
|
|
2939
|
+
file: file.relativePath,
|
|
2940
|
+
line: i + 1,
|
|
2941
|
+
column: match.index + 1,
|
|
2942
|
+
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
2943
|
+
severity: "critical",
|
|
2944
|
+
category: "reliability",
|
|
2945
|
+
fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
|
|
2946
|
+
});
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
for (const ch of trimmed) {
|
|
2950
|
+
if (ch === "{" && tryDepth > 0) {
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
if (tryDepth > 0 && /^\}\s*$/.test(trimmed)) {
|
|
2954
|
+
let nextLine = "";
|
|
2955
|
+
for (let j = i + 1; j < file.lines.length; j++) {
|
|
2956
|
+
nextLine = file.lines[j].trim();
|
|
2957
|
+
if (nextLine) break;
|
|
2958
|
+
}
|
|
2959
|
+
if (!/^catch\b/.test(nextLine) && !/^finally\b/.test(nextLine)) {
|
|
2960
|
+
tryDepth--;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
return findings;
|
|
2965
|
+
}
|
|
2966
|
+
};
|
|
2967
|
+
|
|
2968
|
+
// src/rules/missing-revalidation.ts
|
|
2969
|
+
var USE_SERVER2 = /['"]use server['"]/;
|
|
2970
|
+
var DB_MUTATIONS = [
|
|
2971
|
+
/\.insert\s*\(/,
|
|
2972
|
+
/\.update\s*\(/,
|
|
2973
|
+
/\.delete\s*\(/,
|
|
2974
|
+
/\.upsert\s*\(/,
|
|
2975
|
+
/\.create\s*\(/,
|
|
2976
|
+
/\.createMany\s*\(/,
|
|
2977
|
+
/\.updateMany\s*\(/,
|
|
2978
|
+
/\.deleteMany\s*\(/,
|
|
2979
|
+
/\.remove\s*\(/,
|
|
2980
|
+
/\.save\s*\(/,
|
|
2981
|
+
/\.destroy\s*\(/
|
|
2982
|
+
];
|
|
2983
|
+
var REVALIDATION = [
|
|
2984
|
+
/revalidatePath\s*\(/,
|
|
2985
|
+
/revalidateTag\s*\(/,
|
|
2986
|
+
/redirect\s*\(/
|
|
2987
|
+
];
|
|
2988
|
+
var missingRevalidationRule = {
|
|
2989
|
+
id: "missing-revalidation",
|
|
2990
|
+
name: "Missing Revalidation After Mutation",
|
|
2991
|
+
description: "Detects server actions that mutate data without calling revalidatePath or revalidateTag \u2014 UI shows stale data",
|
|
2992
|
+
category: "reliability",
|
|
2993
|
+
severity: "warning",
|
|
2994
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
2995
|
+
check(file, _project) {
|
|
2996
|
+
if (isTestFile(file.relativePath)) return [];
|
|
2997
|
+
if (!USE_SERVER2.test(file.content)) return [];
|
|
2998
|
+
const hasMutation = DB_MUTATIONS.some((p) => p.test(file.content));
|
|
2999
|
+
if (!hasMutation) return [];
|
|
3000
|
+
const hasRevalidation = REVALIDATION.some((p) => p.test(file.content));
|
|
3001
|
+
if (hasRevalidation) return [];
|
|
3002
|
+
let reportLine = 1;
|
|
3003
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3004
|
+
if (DB_MUTATIONS.some((p) => p.test(file.lines[i]))) {
|
|
3005
|
+
reportLine = i + 1;
|
|
3006
|
+
break;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
3009
|
+
return [{
|
|
3010
|
+
ruleId: "missing-revalidation",
|
|
3011
|
+
file: file.relativePath,
|
|
3012
|
+
line: reportLine,
|
|
3013
|
+
column: 1,
|
|
3014
|
+
message: "Server action mutates data without revalidatePath() or revalidateTag() \u2014 UI will show stale data",
|
|
3015
|
+
severity: "warning",
|
|
3016
|
+
category: "reliability",
|
|
3017
|
+
fix: 'Add revalidatePath("/affected-route") after the mutation'
|
|
3018
|
+
}];
|
|
3019
|
+
}
|
|
3020
|
+
};
|
|
3021
|
+
|
|
3022
|
+
// src/rules/use-client-overuse.ts
|
|
3023
|
+
var CLIENT_APIS = [
|
|
3024
|
+
/\buseState\b/,
|
|
3025
|
+
/\buseEffect\b/,
|
|
3026
|
+
/\buseRef\b/,
|
|
3027
|
+
/\buseReducer\b/,
|
|
3028
|
+
/\buseCallback\b/,
|
|
3029
|
+
/\buseMemo\b/,
|
|
3030
|
+
/\buseContext\b/,
|
|
3031
|
+
/\buseLayoutEffect\b/,
|
|
3032
|
+
/\buseInsertionEffect\b/,
|
|
3033
|
+
/\buseTransition\b/,
|
|
3034
|
+
/\buseDeferredValue\b/,
|
|
3035
|
+
/\buseSyncExternalStore\b/,
|
|
3036
|
+
/\buseFormStatus\b/,
|
|
3037
|
+
/\buseFormState\b/,
|
|
3038
|
+
/\buseOptimistic\b/,
|
|
3039
|
+
/\bonClick\b\s*[=:]/,
|
|
3040
|
+
/\bonChange\b\s*[=:]/,
|
|
3041
|
+
/\bonSubmit\b\s*[=:]/,
|
|
3042
|
+
/\bonBlur\b\s*[=:]/,
|
|
3043
|
+
/\bonFocus\b\s*[=:]/,
|
|
3044
|
+
/\bonKeyDown\b\s*[=:]/,
|
|
3045
|
+
/\bonKeyUp\b\s*[=:]/,
|
|
3046
|
+
/\bonMouseDown\b\s*[=:]/,
|
|
3047
|
+
/\bonMouseUp\b\s*[=:]/,
|
|
3048
|
+
/\bonScroll\b\s*[=:]/,
|
|
3049
|
+
/\bonInput\b\s*[=:]/,
|
|
3050
|
+
/\bonDrag\b/,
|
|
3051
|
+
/\bonDrop\b/,
|
|
3052
|
+
/\bonTouchStart\b/,
|
|
3053
|
+
/\bcreateContext\b/,
|
|
3054
|
+
/\bwindow\./,
|
|
3055
|
+
/\bdocument\./,
|
|
3056
|
+
/\blocalStorage\b/,
|
|
3057
|
+
/\bsessionStorage\b/,
|
|
3058
|
+
/\bnavigator\b/,
|
|
3059
|
+
/\bIntersectionObserver\b/,
|
|
3060
|
+
/\bResizeObserver\b/,
|
|
3061
|
+
/\bMutationObserver\b/
|
|
3062
|
+
];
|
|
3063
|
+
var useClientOveruseRule = {
|
|
3064
|
+
id: "use-client-overuse",
|
|
3065
|
+
name: '"use client" Overuse',
|
|
3066
|
+
description: `Detects files with "use client" that don't use any client-side APIs \u2014 unnecessary client rendering`,
|
|
3067
|
+
category: "ai-quality",
|
|
3068
|
+
severity: "info",
|
|
3069
|
+
fileExtensions: ["tsx", "jsx"],
|
|
3070
|
+
check(file, _project) {
|
|
3071
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3072
|
+
if (isConfigFile(file.relativePath)) return [];
|
|
3073
|
+
if (!isClientComponent(file.content)) return [];
|
|
3074
|
+
const usesClientApi = CLIENT_APIS.some((p) => p.test(file.content));
|
|
3075
|
+
if (usesClientApi) return [];
|
|
3076
|
+
return [{
|
|
3077
|
+
ruleId: "use-client-overuse",
|
|
3078
|
+
file: file.relativePath,
|
|
3079
|
+
line: 1,
|
|
3080
|
+
column: 1,
|
|
3081
|
+
message: '"use client" directive but no client-side APIs (hooks, event handlers, browser APIs) \u2014 this component could be a server component',
|
|
3082
|
+
severity: "info",
|
|
3083
|
+
category: "ai-quality",
|
|
3084
|
+
fix: 'Remove "use client" to let Next.js render this as a server component for better performance'
|
|
3085
|
+
}];
|
|
3086
|
+
}
|
|
3087
|
+
};
|
|
3088
|
+
|
|
3089
|
+
// src/rules/env-fallback-secret.ts
|
|
3090
|
+
var SENSITIVE_ENV = /process\.env\.(JWT_SECRET|SECRET_KEY|AUTH_SECRET|SESSION_SECRET|ENCRYPTION_KEY|API_SECRET|PRIVATE_KEY|SIGNING_KEY|NEXTAUTH_SECRET|TOKEN_SECRET|APP_SECRET|COOKIE_SECRET|HASH_SECRET)\s*(?:\|\||&&|\?\?)\s*['"`]/i;
|
|
3091
|
+
var ENV_FALLBACK = /process\.env\.\w*(SECRET|KEY|PASSWORD|TOKEN)\w*\s*(?:\|\||\?\?)\s*['"`]/i;
|
|
3092
|
+
var envFallbackSecretRule = {
|
|
3093
|
+
id: "env-fallback-secret",
|
|
3094
|
+
name: "Secret with Fallback Value",
|
|
3095
|
+
description: "Detects security-sensitive env vars with hardcoded fallback values \u2014 if the env var is missing, the fallback becomes the production secret",
|
|
3096
|
+
category: "security",
|
|
3097
|
+
severity: "critical",
|
|
3098
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
3099
|
+
check(file, _project) {
|
|
3100
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3101
|
+
const findings = [];
|
|
3102
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3103
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3104
|
+
const line = file.lines[i];
|
|
3105
|
+
const directMatch = SENSITIVE_ENV.exec(line);
|
|
3106
|
+
if (directMatch) {
|
|
3107
|
+
findings.push({
|
|
3108
|
+
ruleId: "env-fallback-secret",
|
|
3109
|
+
file: file.relativePath,
|
|
3110
|
+
line: i + 1,
|
|
3111
|
+
column: directMatch.index + 1,
|
|
3112
|
+
message: `Secret env var has a hardcoded fallback \u2014 if ${directMatch[1] || "the var"} is unset, this literal becomes the production secret`,
|
|
3113
|
+
severity: "critical",
|
|
3114
|
+
category: "security",
|
|
3115
|
+
fix: 'Throw an error if the env var is missing: const secret = process.env.SECRET ?? (() => { throw new Error("SECRET is required") })()'
|
|
3116
|
+
});
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
const genericMatch = ENV_FALLBACK.exec(line);
|
|
3120
|
+
if (genericMatch && !isConfigFile(file.relativePath)) {
|
|
3121
|
+
findings.push({
|
|
3122
|
+
ruleId: "env-fallback-secret",
|
|
3123
|
+
file: file.relativePath,
|
|
3124
|
+
line: i + 1,
|
|
3125
|
+
column: genericMatch.index + 1,
|
|
3126
|
+
message: "Security-sensitive env var has a hardcoded fallback \u2014 defaults to a literal string when missing",
|
|
3127
|
+
severity: "warning",
|
|
3128
|
+
category: "security",
|
|
3129
|
+
fix: "Fail fast when required env vars are missing instead of falling back to a default value"
|
|
3130
|
+
});
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
return findings;
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
|
|
3137
|
+
// src/rules/verbose-error-response.ts
|
|
3138
|
+
var ERROR_LEAK_PATTERNS = [
|
|
3139
|
+
{ pattern: /error\.stack/, msg: "error.stack exposed \u2014 leaks internal file paths and code structure" },
|
|
3140
|
+
{ pattern: /error\.message/, msg: "error.message may leak internal details to clients" }
|
|
3141
|
+
];
|
|
3142
|
+
var verboseErrorResponseRule = {
|
|
3143
|
+
id: "verbose-error-response",
|
|
3144
|
+
name: "Verbose Error Response",
|
|
3145
|
+
description: "Detects error details (stack traces, error messages) sent directly in API responses",
|
|
3146
|
+
category: "security",
|
|
3147
|
+
severity: "warning",
|
|
3148
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3149
|
+
check(file, _project) {
|
|
3150
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3151
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3152
|
+
const findings = [];
|
|
3153
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3154
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3155
|
+
const line = file.lines[i];
|
|
3156
|
+
for (const { pattern, msg } of ERROR_LEAK_PATTERNS) {
|
|
3157
|
+
const match = pattern.exec(line);
|
|
3158
|
+
if (match) {
|
|
3159
|
+
const severity = pattern.source.includes("stack") ? "warning" : "info";
|
|
3160
|
+
findings.push({
|
|
3161
|
+
ruleId: "verbose-error-response",
|
|
3162
|
+
file: file.relativePath,
|
|
3163
|
+
line: i + 1,
|
|
3164
|
+
column: match.index + 1,
|
|
3165
|
+
message: msg,
|
|
3166
|
+
severity,
|
|
3167
|
+
category: "security",
|
|
3168
|
+
fix: 'Return a generic error message: { error: "Internal server error" }. Log the real error server-side.'
|
|
3169
|
+
});
|
|
3170
|
+
break;
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
return findings;
|
|
3175
|
+
}
|
|
3176
|
+
};
|
|
3177
|
+
|
|
3178
|
+
// src/rules/missing-webhook-verification.ts
|
|
3179
|
+
var WEBHOOK_PATH = /webhook/i;
|
|
3180
|
+
var VERIFICATION_PATTERNS = [
|
|
3181
|
+
/constructEvent\s*\(/,
|
|
3182
|
+
// Stripe
|
|
3183
|
+
/webhooks\.verify\s*\(/,
|
|
3184
|
+
// Clerk, GitHub
|
|
3185
|
+
/verify\s*\(/,
|
|
3186
|
+
// Generic
|
|
3187
|
+
/verifySignature\s*\(/,
|
|
3188
|
+
// Generic
|
|
3189
|
+
/validateWebhook\s*\(/,
|
|
3190
|
+
// Generic
|
|
3191
|
+
/svix.*verify/i,
|
|
3192
|
+
// Svix (used by Clerk, Resend)
|
|
3193
|
+
/crypto\.timingSafeEqual\s*\(/,
|
|
3194
|
+
// Manual HMAC comparison
|
|
3195
|
+
/hmac/i,
|
|
3196
|
+
// HMAC verification
|
|
3197
|
+
/x-hub-signature/i,
|
|
3198
|
+
// GitHub webhooks
|
|
3199
|
+
/stripe-signature/i,
|
|
3200
|
+
// Stripe signature header
|
|
3201
|
+
/svix-signature/i,
|
|
3202
|
+
// Svix signature header
|
|
3203
|
+
/webhook-secret/i
|
|
3204
|
+
];
|
|
3205
|
+
var missingWebhookVerificationRule = {
|
|
3206
|
+
id: "missing-webhook-verification",
|
|
3207
|
+
name: "Missing Webhook Verification",
|
|
3208
|
+
description: "Detects webhook endpoints without signature verification \u2014 anyone can send fake events",
|
|
3209
|
+
category: "security",
|
|
3210
|
+
severity: "critical",
|
|
3211
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3212
|
+
check(file, _project) {
|
|
3213
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3214
|
+
if (!isApiRoute(file.relativePath)) return [];
|
|
3215
|
+
if (!WEBHOOK_PATH.test(file.relativePath)) return [];
|
|
3216
|
+
const hasVerification = VERIFICATION_PATTERNS.some((p) => p.test(file.content));
|
|
3217
|
+
if (hasVerification) return [];
|
|
3218
|
+
let handlerLine = 1;
|
|
3219
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3220
|
+
if (/export\s+(async\s+)?function\s+POST\b/.test(file.lines[i])) {
|
|
3221
|
+
handlerLine = i + 1;
|
|
3222
|
+
break;
|
|
3223
|
+
}
|
|
3224
|
+
}
|
|
3225
|
+
return [{
|
|
3226
|
+
ruleId: "missing-webhook-verification",
|
|
3227
|
+
file: file.relativePath,
|
|
3228
|
+
line: handlerLine,
|
|
3229
|
+
column: 1,
|
|
3230
|
+
message: "Webhook endpoint has no signature verification \u2014 anyone can forge events to this route",
|
|
3231
|
+
severity: "critical",
|
|
3232
|
+
category: "security",
|
|
3233
|
+
fix: "Verify the webhook signature before processing. For Stripe: stripe.webhooks.constructEvent(body, sig, secret)"
|
|
3234
|
+
}];
|
|
3235
|
+
}
|
|
3236
|
+
};
|
|
3237
|
+
|
|
3238
|
+
// src/rules/server-action-auth.ts
|
|
3239
|
+
var USE_SERVER3 = /['"]use server['"]/;
|
|
3240
|
+
var AUTH_PATTERNS2 = [
|
|
3241
|
+
/getServerSession\s*\(/,
|
|
3242
|
+
/getSession\s*\(/,
|
|
3243
|
+
/\.auth\.getUser\s*\(/,
|
|
3244
|
+
/auth\(\)/,
|
|
3245
|
+
/authenticate\s*\(/,
|
|
3246
|
+
/isAuthenticated/,
|
|
3247
|
+
/requireAuth/,
|
|
3248
|
+
/withAuth/,
|
|
3249
|
+
/getToken\s*\(/,
|
|
3250
|
+
/verifyToken\s*\(/,
|
|
3251
|
+
/jwt\.verify\s*\(/,
|
|
3252
|
+
/createServerComponentClient/,
|
|
3253
|
+
/currentUser\s*\(/,
|
|
3254
|
+
/getAuth\s*\(/,
|
|
3255
|
+
/cookies\(\).*auth/s,
|
|
3256
|
+
/session/i
|
|
3257
|
+
];
|
|
3258
|
+
var PUBLIC_ACTION_NAMES = [
|
|
3259
|
+
/contact/i,
|
|
3260
|
+
/subscribe/i,
|
|
3261
|
+
/newsletter/i,
|
|
3262
|
+
/feedback/i,
|
|
3263
|
+
/signup/i,
|
|
3264
|
+
/login/i,
|
|
3265
|
+
/register/i
|
|
3266
|
+
];
|
|
3267
|
+
var serverActionAuthRule = {
|
|
3268
|
+
id: "server-action-auth",
|
|
3269
|
+
name: "Server Action Without Auth",
|
|
3270
|
+
description: "Detects server actions that perform mutations without any authentication check",
|
|
3271
|
+
category: "security",
|
|
3272
|
+
severity: "warning",
|
|
3273
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3274
|
+
check(file, project) {
|
|
3275
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3276
|
+
if (!USE_SERVER3.test(file.content)) return [];
|
|
3277
|
+
if (project.hasAuthMiddleware) return [];
|
|
3278
|
+
for (const p of PUBLIC_ACTION_NAMES) {
|
|
3279
|
+
if (p.test(file.relativePath)) return [];
|
|
3280
|
+
}
|
|
3281
|
+
const hasAuth = AUTH_PATTERNS2.some((p) => p.test(file.content));
|
|
3282
|
+
if (hasAuth) return [];
|
|
3283
|
+
const hasMutation = /\.(insert|update|delete|create|upsert|remove|destroy|save|push|set)\s*\(/i.test(file.content) || /\b(INSERT|UPDATE|DELETE)\b/.test(file.content);
|
|
3284
|
+
if (!hasMutation) return [];
|
|
3285
|
+
let reportLine = 1;
|
|
3286
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3287
|
+
if (USE_SERVER3.test(file.lines[i])) {
|
|
3288
|
+
reportLine = i + 1;
|
|
3289
|
+
break;
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
3292
|
+
return [{
|
|
3293
|
+
ruleId: "server-action-auth",
|
|
3294
|
+
file: file.relativePath,
|
|
3295
|
+
line: reportLine,
|
|
3296
|
+
column: 1,
|
|
3297
|
+
message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
|
|
3298
|
+
severity: "warning",
|
|
3299
|
+
category: "security",
|
|
3300
|
+
fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
|
|
3301
|
+
}];
|
|
3302
|
+
}
|
|
3303
|
+
};
|
|
3304
|
+
|
|
3305
|
+
// src/rules/eval-injection.ts
|
|
3306
|
+
var EVAL_PATTERNS = [
|
|
3307
|
+
{ pattern: /\beval\s*\(/, msg: "eval() executes arbitrary code \u2014 never use with dynamic input" },
|
|
3308
|
+
{ pattern: /\bnew\s+Function\s*\(/, msg: "new Function() is equivalent to eval \u2014 avoid dynamic code execution" },
|
|
3309
|
+
{ pattern: /\bsetTimeout\s*\(\s*['"`]/, msg: "setTimeout with a string argument is eval \u2014 pass a function instead" },
|
|
3310
|
+
{ pattern: /\bsetInterval\s*\(\s*['"`]/, msg: "setInterval with a string argument is eval \u2014 pass a function instead" }
|
|
3311
|
+
];
|
|
3312
|
+
var evalInjectionRule = {
|
|
3313
|
+
id: "eval-injection",
|
|
3314
|
+
name: "Eval / Code Injection",
|
|
3315
|
+
description: "Detects eval(), new Function(), and string arguments to setTimeout/setInterval",
|
|
3316
|
+
category: "security",
|
|
3317
|
+
severity: "critical",
|
|
3318
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
3319
|
+
check(file, _project) {
|
|
3320
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3321
|
+
const findings = [];
|
|
3322
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3323
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3324
|
+
const line = file.lines[i];
|
|
3325
|
+
for (const { pattern, msg } of EVAL_PATTERNS) {
|
|
3326
|
+
const match = pattern.exec(line);
|
|
3327
|
+
if (match) {
|
|
3328
|
+
findings.push({
|
|
3329
|
+
ruleId: "eval-injection",
|
|
3330
|
+
file: file.relativePath,
|
|
3331
|
+
line: i + 1,
|
|
3332
|
+
column: match.index + 1,
|
|
3333
|
+
message: msg,
|
|
3334
|
+
severity: "critical",
|
|
3335
|
+
category: "security"
|
|
3336
|
+
});
|
|
3337
|
+
break;
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
return findings;
|
|
3342
|
+
}
|
|
3343
|
+
};
|
|
3344
|
+
|
|
3345
|
+
// src/rules/missing-useeffect-cleanup.ts
|
|
3346
|
+
var NEEDS_CLEANUP = [
|
|
3347
|
+
/\bsetInterval\s*\(/,
|
|
3348
|
+
/\baddEventListener\s*\(/,
|
|
3349
|
+
/\.subscribe\s*\(/,
|
|
3350
|
+
/\.on\s*\(\s*['"`]/,
|
|
3351
|
+
/new\s+WebSocket\s*\(/,
|
|
3352
|
+
/new\s+EventSource\s*\(/,
|
|
3353
|
+
/new\s+IntersectionObserver\s*\(/,
|
|
3354
|
+
/new\s+ResizeObserver\s*\(/,
|
|
3355
|
+
/new\s+MutationObserver\s*\(/
|
|
3356
|
+
];
|
|
3357
|
+
var missingUseEffectCleanupRule = {
|
|
3358
|
+
id: "missing-useeffect-cleanup",
|
|
3359
|
+
name: "Missing useEffect Cleanup",
|
|
3360
|
+
description: "Detects useEffect hooks with subscriptions or timers but no cleanup return function \u2014 causes memory leaks",
|
|
3361
|
+
category: "reliability",
|
|
3362
|
+
severity: "warning",
|
|
3363
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
3364
|
+
check(file, _project) {
|
|
3365
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3366
|
+
if (!isClientComponent(file.content)) return [];
|
|
3367
|
+
if (!/useEffect/.test(file.content)) return [];
|
|
3368
|
+
const findings = [];
|
|
3369
|
+
const lines = file.lines;
|
|
3370
|
+
for (let i = 0; i < lines.length; i++) {
|
|
3371
|
+
const line = lines[i];
|
|
3372
|
+
if (!/\buseEffect\s*\(/.test(line)) continue;
|
|
3373
|
+
let braceDepth = 0;
|
|
3374
|
+
let effectStart = -1;
|
|
3375
|
+
let effectEnd = -1;
|
|
3376
|
+
let started = false;
|
|
3377
|
+
for (let j = i; j < lines.length && j < i + 100; j++) {
|
|
3378
|
+
for (const ch of lines[j]) {
|
|
3379
|
+
if (ch === "(") {
|
|
3380
|
+
if (!started) {
|
|
3381
|
+
started = true;
|
|
3382
|
+
}
|
|
3383
|
+
braceDepth++;
|
|
3384
|
+
} else if (ch === ")") {
|
|
3385
|
+
braceDepth--;
|
|
3386
|
+
if (started && braceDepth === 0) {
|
|
3387
|
+
effectEnd = j;
|
|
3388
|
+
break;
|
|
3389
|
+
}
|
|
3390
|
+
} else if (ch === "{" && effectStart === -1 && started) {
|
|
3391
|
+
effectStart = j;
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3394
|
+
if (effectEnd !== -1) break;
|
|
3395
|
+
}
|
|
3396
|
+
if (effectStart === -1 || effectEnd === -1) continue;
|
|
3397
|
+
const effectBody = lines.slice(effectStart, effectEnd + 1).join("\n");
|
|
3398
|
+
const needsCleanup = NEEDS_CLEANUP.some((p) => p.test(effectBody));
|
|
3399
|
+
if (!needsCleanup) continue;
|
|
3400
|
+
const hasReturn = /return\s*(?:\(\s*\)\s*=>|function|\(\))/.test(effectBody) || /return\s*\(\s*\)\s*\{/.test(effectBody) || /return\s+\w+\s*;?\s*$/.test(effectBody);
|
|
3401
|
+
if (!hasReturn) {
|
|
3402
|
+
const hasCleanupReturn = /return\s+(?:\(\)|(?:\(\s*\)\s*=>)|(?:function))/.test(effectBody);
|
|
3403
|
+
if (!hasCleanupReturn) {
|
|
3404
|
+
findings.push({
|
|
3405
|
+
ruleId: "missing-useeffect-cleanup",
|
|
3406
|
+
file: file.relativePath,
|
|
3407
|
+
line: i + 1,
|
|
3408
|
+
column: 1,
|
|
3409
|
+
message: "useEffect with subscription/timer but no cleanup return \u2014 will leak memory on unmount",
|
|
3410
|
+
severity: "warning",
|
|
3411
|
+
category: "reliability",
|
|
3412
|
+
fix: "Return a cleanup function: useEffect(() => { const id = setInterval(...); return () => clearInterval(id); }, [])"
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
return findings;
|
|
3418
|
+
}
|
|
3419
|
+
};
|
|
3420
|
+
|
|
3421
|
+
// src/rules/next-public-sensitive.ts
|
|
3422
|
+
var SENSITIVE_PATTERN = /NEXT_PUBLIC_\w*(SECRET|PRIVATE|PASSWORD|DATABASE_URL|SERVICE_ROLE|SERVICE_KEY|ADMIN_KEY|sk_live|sk_test|SIGNING|ENCRYPTION)/i;
|
|
3423
|
+
var nextPublicSensitiveRule = {
|
|
3424
|
+
id: "next-public-sensitive",
|
|
3425
|
+
name: "Sensitive Env Var with NEXT_PUBLIC_ Prefix",
|
|
3426
|
+
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3427
|
+
category: "security",
|
|
3428
|
+
severity: "critical",
|
|
3429
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env", "env.local", "env.production"],
|
|
3430
|
+
check(file, _project) {
|
|
3431
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3432
|
+
const findings = [];
|
|
3433
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3434
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3435
|
+
const line = file.lines[i];
|
|
3436
|
+
const match = SENSITIVE_PATTERN.exec(line);
|
|
3437
|
+
if (match) {
|
|
3438
|
+
findings.push({
|
|
3439
|
+
ruleId: "next-public-sensitive",
|
|
3440
|
+
file: file.relativePath,
|
|
3441
|
+
line: i + 1,
|
|
3442
|
+
column: match.index + 1,
|
|
3443
|
+
message: `NEXT_PUBLIC_ prefix on a sensitive env var \u2014 this value will be embedded in the client-side JavaScript bundle`,
|
|
3444
|
+
severity: "critical",
|
|
3445
|
+
category: "security",
|
|
3446
|
+
fix: "Remove the NEXT_PUBLIC_ prefix. Access this value only in server components, API routes, or server actions."
|
|
3447
|
+
});
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
return findings;
|
|
3451
|
+
}
|
|
3452
|
+
};
|
|
3453
|
+
|
|
3454
|
+
// src/rules/ssrf-risk.ts
|
|
3455
|
+
var USER_INPUT_IN_FETCH = [
|
|
3456
|
+
/fetch\s*\(\s*(?:req|request)\.(?:body|query|params|nextUrl)/,
|
|
3457
|
+
/fetch\s*\(\s*(?:url|href|endpoint|target|link|src)\s*[,)]/,
|
|
3458
|
+
/fetch\s*\(\s*searchParams\.get\s*\(/,
|
|
3459
|
+
/fetch\s*\(\s*formData\.get\s*\(/,
|
|
3460
|
+
/new\s+URL\s*\(\s*(?:req|request)\.(?:body|query)/,
|
|
3461
|
+
/axios\s*[.(]\s*(?:req|request)\.(?:body|query)/,
|
|
3462
|
+
/axios\.get\s*\(\s*(?:url|href|endpoint|target|link)\s*[,)]/
|
|
3463
|
+
];
|
|
3464
|
+
var VALIDATION_PATTERNS3 = [
|
|
3465
|
+
/allowlist/i,
|
|
3466
|
+
/allowedUrls/i,
|
|
3467
|
+
/allowedHosts/i,
|
|
3468
|
+
/allowedDomains/i,
|
|
3469
|
+
/whitelist/i,
|
|
3470
|
+
/validUrl/i,
|
|
3471
|
+
/validateUrl/i,
|
|
3472
|
+
/URL\.canParse/,
|
|
3473
|
+
/new\s+URL\s*\(.*\)\.host/,
|
|
3474
|
+
/\.startsWith\s*\(\s*['"]https?:\/\//,
|
|
3475
|
+
/\.hostname\s*[!=]==?\s*['"`]/
|
|
3476
|
+
];
|
|
3477
|
+
var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
|
|
3478
|
+
"url",
|
|
3479
|
+
"href",
|
|
3480
|
+
"endpoint",
|
|
3481
|
+
"target",
|
|
3482
|
+
"link",
|
|
3483
|
+
"src"
|
|
3484
|
+
]);
|
|
3485
|
+
var ssrfRiskRule = {
|
|
3486
|
+
id: "ssrf-risk",
|
|
3487
|
+
name: "SSRF Risk",
|
|
3488
|
+
description: "Detects fetch/HTTP calls with user-controlled URLs without validation \u2014 allows attackers to probe internal services",
|
|
3489
|
+
category: "security",
|
|
3490
|
+
severity: "warning",
|
|
3491
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3492
|
+
check(file, _project) {
|
|
3493
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3494
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3495
|
+
const findings = [];
|
|
3496
|
+
if (file.ast) {
|
|
3497
|
+
try {
|
|
3498
|
+
const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3499
|
+
walkAST(file.ast.program, (node) => {
|
|
3500
|
+
if (node.type !== "CallExpression") return;
|
|
3501
|
+
const call = node;
|
|
3502
|
+
if (!call.loc) return;
|
|
3503
|
+
let isFetchLike = false;
|
|
3504
|
+
if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
|
|
3505
|
+
isFetchLike = true;
|
|
3506
|
+
}
|
|
3507
|
+
if (call.callee.type === "MemberExpression") {
|
|
3508
|
+
const mem = call.callee;
|
|
3509
|
+
if (mem.object.type === "Identifier" && mem.object.name === "axios") {
|
|
3510
|
+
isFetchLike = true;
|
|
3511
|
+
}
|
|
3512
|
+
}
|
|
3513
|
+
if (!isFetchLike) return;
|
|
3514
|
+
const arg = call.arguments[0];
|
|
3515
|
+
if (!arg) return;
|
|
3516
|
+
if (isStaticString(arg)) return;
|
|
3517
|
+
const lineNum = call.loc.start.line;
|
|
3518
|
+
const col = call.loc.start.column + 1;
|
|
3519
|
+
if (isUserInputNode(arg)) {
|
|
3520
|
+
findings.push({
|
|
3521
|
+
ruleId: "ssrf-risk",
|
|
3522
|
+
file: file.relativePath,
|
|
3523
|
+
line: lineNum,
|
|
3524
|
+
column: col,
|
|
3525
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3526
|
+
severity: "warning",
|
|
3527
|
+
category: "security",
|
|
3528
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3529
|
+
});
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
|
|
3533
|
+
if (hasValidationInCode) return;
|
|
3534
|
+
findings.push({
|
|
3535
|
+
ruleId: "ssrf-risk",
|
|
3536
|
+
file: file.relativePath,
|
|
3537
|
+
line: lineNum,
|
|
3538
|
+
column: col,
|
|
3539
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3540
|
+
severity: "warning",
|
|
3541
|
+
category: "security",
|
|
3542
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3543
|
+
});
|
|
3544
|
+
}
|
|
3545
|
+
});
|
|
3546
|
+
return findings;
|
|
3547
|
+
} catch {
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
|
|
3551
|
+
if (hasValidation) return [];
|
|
3552
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3553
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3554
|
+
const line = file.lines[i];
|
|
3555
|
+
for (const pattern of USER_INPUT_IN_FETCH) {
|
|
3556
|
+
const match = pattern.exec(line);
|
|
3557
|
+
if (match) {
|
|
3558
|
+
findings.push({
|
|
3559
|
+
ruleId: "ssrf-risk",
|
|
3560
|
+
file: file.relativePath,
|
|
3561
|
+
line: i + 1,
|
|
3562
|
+
column: match.index + 1,
|
|
3563
|
+
message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
|
|
3564
|
+
severity: "warning",
|
|
3565
|
+
category: "security",
|
|
3566
|
+
fix: "Validate the URL against an allowlist of permitted domains before making the request"
|
|
3567
|
+
});
|
|
3568
|
+
break;
|
|
3569
|
+
}
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
return findings;
|
|
3573
|
+
}
|
|
3574
|
+
};
|
|
3575
|
+
|
|
3576
|
+
// src/rules/path-traversal.ts
|
|
3577
|
+
var FS_WITH_USER_INPUT = [
|
|
3578
|
+
{ pattern: /(?:readFile|readFileSync|createReadStream)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File read with user-controlled path \u2014 allows reading arbitrary files" },
|
|
3579
|
+
{ pattern: /(?:readFile|readFileSync|createReadStream)\s*\(\s*(?:filePath|fileName|path|file|name)\s*[,)]/, msg: "File read with potentially user-controlled path \u2014 validate before use" },
|
|
3580
|
+
{ pattern: /(?:writeFile|writeFileSync|createWriteStream)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File write with user-controlled path \u2014 allows writing arbitrary files" },
|
|
3581
|
+
{ pattern: /(?:unlink|unlinkSync|rm|rmSync)\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "File delete with user-controlled path \u2014 allows deleting arbitrary files" },
|
|
3582
|
+
{ pattern: /path\.join\s*\([^)]*(?:req|request)\.(?:query|body|params)/, msg: "path.join with user input \u2014 still vulnerable to traversal with ../" },
|
|
3583
|
+
{ pattern: /\.sendFile\s*\(\s*(?:req|request)\.(?:query|body|params)/, msg: "express.sendFile with user-controlled path \u2014 validate against a base directory" }
|
|
3584
|
+
];
|
|
3585
|
+
var SANITIZATION_PATTERNS = [
|
|
3586
|
+
/path\.resolve\s*\(.*\)\.startsWith/,
|
|
3587
|
+
/\.replace\s*\(\s*['"]\.\.['"],?\s*['"].*['"]\s*\)/,
|
|
3588
|
+
/\.includes\s*\(\s*['"]\.\.['"].*\)/,
|
|
3589
|
+
/normalize/,
|
|
3590
|
+
/sanitize/i,
|
|
3591
|
+
/realpath/
|
|
3592
|
+
];
|
|
3593
|
+
var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
|
|
3594
|
+
"readFile",
|
|
3595
|
+
"readFileSync",
|
|
3596
|
+
"createReadStream",
|
|
3597
|
+
"writeFile",
|
|
3598
|
+
"writeFileSync",
|
|
3599
|
+
"createWriteStream",
|
|
3600
|
+
"unlink",
|
|
3601
|
+
"unlinkSync",
|
|
3602
|
+
"rm",
|
|
3603
|
+
"rmSync"
|
|
3604
|
+
]);
|
|
3605
|
+
var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
|
|
3606
|
+
"filePath",
|
|
3607
|
+
"fileName",
|
|
3608
|
+
"path",
|
|
3609
|
+
"file",
|
|
3610
|
+
"name"
|
|
3611
|
+
]);
|
|
3612
|
+
var pathTraversalRule = {
|
|
3613
|
+
id: "path-traversal",
|
|
3614
|
+
name: "Path Traversal",
|
|
3615
|
+
description: "Detects filesystem operations with user-controlled paths \u2014 allows reading/writing arbitrary files via ../ sequences",
|
|
3616
|
+
category: "security",
|
|
3617
|
+
severity: "critical",
|
|
3618
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3619
|
+
check(file, _project) {
|
|
3620
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3621
|
+
const findings = [];
|
|
3622
|
+
if (file.ast) {
|
|
3623
|
+
try {
|
|
3624
|
+
walkAST(file.ast.program, (node) => {
|
|
3625
|
+
if (node.type !== "CallExpression") return;
|
|
3626
|
+
const call = node;
|
|
3627
|
+
if (!call.loc) return;
|
|
3628
|
+
let fnName = null;
|
|
3629
|
+
if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
|
|
3630
|
+
fnName = call.callee.name;
|
|
3631
|
+
}
|
|
3632
|
+
if (call.callee.type === "MemberExpression") {
|
|
3633
|
+
const mem = call.callee;
|
|
3634
|
+
if (mem.property.type === "Identifier") {
|
|
3635
|
+
if (FS_FUNCTION_NAMES.has(mem.property.name)) {
|
|
3636
|
+
fnName = mem.property.name;
|
|
3637
|
+
}
|
|
3638
|
+
if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
|
|
3639
|
+
fnName = "path.join";
|
|
3640
|
+
}
|
|
3641
|
+
if (mem.property.name === "sendFile") {
|
|
3642
|
+
fnName = "sendFile";
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
}
|
|
3646
|
+
if (!fnName) return;
|
|
3647
|
+
const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
|
|
3648
|
+
for (const arg of argsToCheck) {
|
|
3649
|
+
if (!arg) continue;
|
|
3650
|
+
if (isStaticString(arg)) continue;
|
|
3651
|
+
const lineNum = call.loc.start.line;
|
|
3652
|
+
const col = call.loc.start.column + 1;
|
|
3653
|
+
if (isUserInputNode(arg)) {
|
|
3654
|
+
const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
|
|
3655
|
+
findings.push({
|
|
3656
|
+
ruleId: "path-traversal",
|
|
3657
|
+
file: file.relativePath,
|
|
3658
|
+
line: lineNum,
|
|
3659
|
+
column: col,
|
|
3660
|
+
message: fnName === "path.join" ? "path.join with user input \u2014 still vulnerable to traversal with ../" : `File ${action} with user-controlled path \u2014 allows ${action === "send" ? "sending" : action + "ing"} arbitrary files`,
|
|
3661
|
+
severity: "critical",
|
|
3662
|
+
category: "security",
|
|
3663
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3664
|
+
});
|
|
3665
|
+
return;
|
|
3666
|
+
}
|
|
3667
|
+
if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
|
|
3668
|
+
findings.push({
|
|
3669
|
+
ruleId: "path-traversal",
|
|
3670
|
+
file: file.relativePath,
|
|
3671
|
+
line: lineNum,
|
|
3672
|
+
column: col,
|
|
3673
|
+
message: `File operation with potentially user-controlled path \u2014 validate before use`,
|
|
3674
|
+
severity: "warning",
|
|
3675
|
+
category: "security",
|
|
3676
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3677
|
+
});
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
});
|
|
3682
|
+
return findings;
|
|
3683
|
+
} catch {
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
|
|
3687
|
+
if (hasSanitization) return [];
|
|
3688
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3689
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3690
|
+
const line = file.lines[i];
|
|
3691
|
+
for (const { pattern, msg } of FS_WITH_USER_INPUT) {
|
|
3692
|
+
const match = pattern.exec(line);
|
|
3693
|
+
if (match) {
|
|
3694
|
+
const severity = /req|request/.test(match[0]) ? "critical" : "warning";
|
|
3695
|
+
findings.push({
|
|
3696
|
+
ruleId: "path-traversal",
|
|
3697
|
+
file: file.relativePath,
|
|
3698
|
+
line: i + 1,
|
|
3699
|
+
column: match.index + 1,
|
|
3700
|
+
message: msg,
|
|
3701
|
+
severity,
|
|
3702
|
+
category: "security",
|
|
3703
|
+
fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
|
|
3704
|
+
});
|
|
3705
|
+
break;
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
return findings;
|
|
3710
|
+
}
|
|
3711
|
+
};
|
|
3712
|
+
|
|
3713
|
+
// src/rules/hydration-mismatch.ts
|
|
3714
|
+
var BROWSER_ONLY_PATTERNS = [
|
|
3715
|
+
{ pattern: /\bwindow\./, msg: "window access in server-rendered code \u2014 will differ between server and client" },
|
|
3716
|
+
{ pattern: /\bdocument\./, msg: "document access in server-rendered code \u2014 undefined on the server" },
|
|
3717
|
+
{ pattern: /\blocalStorage\b/, msg: "localStorage in render path \u2014 undefined on server, causes hydration mismatch" },
|
|
3718
|
+
{ pattern: /\bsessionStorage\b/, msg: "sessionStorage in render path \u2014 undefined on server, causes hydration mismatch" },
|
|
3719
|
+
{ pattern: /\bnavigator\./, msg: "navigator access in render path \u2014 undefined on server" }
|
|
3720
|
+
];
|
|
3721
|
+
var NONDETERMINISTIC_PATTERNS = [
|
|
3722
|
+
{ pattern: /\bnew\s+Date\s*\(\s*\)/, msg: "new Date() in render path \u2014 server and client will have different timestamps, causing hydration mismatch" },
|
|
3723
|
+
{ pattern: /\bDate\.now\s*\(\s*\)/, msg: "Date.now() in render path \u2014 different on server vs client" },
|
|
3724
|
+
{ pattern: /\bMath\.random\s*\(\s*\)/, msg: "Math.random() in render path \u2014 produces different values on server vs client" }
|
|
3725
|
+
];
|
|
3726
|
+
var hydrationMismatchRule = {
|
|
3727
|
+
id: "hydration-mismatch",
|
|
3728
|
+
name: "Hydration Mismatch Risk",
|
|
3729
|
+
description: "Detects browser-only APIs and non-deterministic calls in server component render paths",
|
|
3730
|
+
category: "reliability",
|
|
3731
|
+
severity: "warning",
|
|
3732
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
3733
|
+
check(file, _project) {
|
|
3734
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3735
|
+
if (isClientComponent(file.content)) return [];
|
|
3736
|
+
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3737
|
+
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3738
|
+
const findings = [];
|
|
3739
|
+
let useEffectRanges = [];
|
|
3740
|
+
if (file.ast) {
|
|
3741
|
+
try {
|
|
3742
|
+
useEffectRanges = findUseEffectRanges(file.ast);
|
|
3743
|
+
} catch {
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
|
|
3747
|
+
let insideUseEffect = false;
|
|
3748
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3749
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3750
|
+
const line = file.lines[i];
|
|
3751
|
+
if (hasAstRanges) {
|
|
3752
|
+
const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
|
|
3753
|
+
if (inEffect) continue;
|
|
3754
|
+
} else {
|
|
3755
|
+
if (/\buseEffect\s*\(/.test(line)) {
|
|
3756
|
+
insideUseEffect = true;
|
|
3757
|
+
}
|
|
3758
|
+
if (insideUseEffect) {
|
|
3759
|
+
if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
|
|
3760
|
+
insideUseEffect = false;
|
|
3761
|
+
}
|
|
3762
|
+
continue;
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
|
|
3766
|
+
const match = pattern.exec(line);
|
|
3767
|
+
if (match) {
|
|
3768
|
+
findings.push({
|
|
3769
|
+
ruleId: "hydration-mismatch",
|
|
3770
|
+
file: file.relativePath,
|
|
3771
|
+
line: i + 1,
|
|
3772
|
+
column: match.index + 1,
|
|
3773
|
+
message: msg,
|
|
3774
|
+
severity: "warning",
|
|
3775
|
+
category: "reliability",
|
|
3776
|
+
fix: 'Move this to a useEffect hook, or add "use client" if this component needs browser APIs'
|
|
3777
|
+
});
|
|
3778
|
+
break;
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
}
|
|
3782
|
+
return findings;
|
|
3783
|
+
}
|
|
3784
|
+
};
|
|
3785
|
+
|
|
3786
|
+
// src/rules/server-component-fetch-self.ts
|
|
3787
|
+
var SELF_FETCH_PATTERNS = [
|
|
3788
|
+
/fetch\s*\(\s*['"`]\/api\//,
|
|
3789
|
+
/fetch\s*\(\s*['"`]http:\/\/localhost/,
|
|
3790
|
+
/fetch\s*\(\s*['"`]https?:\/\/localhost/,
|
|
3791
|
+
/fetch\s*\(\s*`\$\{.*\}\/api\//,
|
|
3792
|
+
/fetch\s*\(\s*(?:process\.env\.\w+\s*\+\s*)?['"`]\/api\//
|
|
3793
|
+
];
|
|
3794
|
+
var serverComponentFetchSelfRule = {
|
|
3795
|
+
id: "server-component-fetch-self",
|
|
3796
|
+
name: "Server Component Fetching Own API",
|
|
3797
|
+
description: "Detects server components that fetch their own API routes instead of calling data logic directly \u2014 unnecessary network roundtrip",
|
|
3798
|
+
category: "performance",
|
|
3799
|
+
severity: "info",
|
|
3800
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
3801
|
+
check(file, _project) {
|
|
3802
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3803
|
+
if (isClientComponent(file.content)) return [];
|
|
3804
|
+
if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
|
|
3805
|
+
if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
|
|
3806
|
+
const findings = [];
|
|
3807
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3808
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3809
|
+
const line = file.lines[i];
|
|
3810
|
+
for (const pattern of SELF_FETCH_PATTERNS) {
|
|
3811
|
+
const match = pattern.exec(line);
|
|
3812
|
+
if (match) {
|
|
3813
|
+
findings.push({
|
|
3814
|
+
ruleId: "server-component-fetch-self",
|
|
3815
|
+
file: file.relativePath,
|
|
3816
|
+
line: i + 1,
|
|
3817
|
+
column: match.index + 1,
|
|
3818
|
+
message: "Server component fetches its own API route \u2014 call the data logic directly instead of making a network request to yourself",
|
|
3819
|
+
severity: "info",
|
|
3820
|
+
category: "performance",
|
|
3821
|
+
fix: 'Import and call the data function directly instead of fetch("/api/...")'
|
|
3822
|
+
});
|
|
3823
|
+
break;
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
}
|
|
3827
|
+
return findings;
|
|
3828
|
+
}
|
|
3829
|
+
};
|
|
3830
|
+
|
|
3831
|
+
// src/rules/unsafe-file-upload.ts
|
|
3832
|
+
var UPLOAD_PATTERNS = [
|
|
3833
|
+
/\.get\s*\(\s*['"`]file['"`]\s*\)/,
|
|
3834
|
+
/\.get\s*\(\s*['"`]image['"`]\s*\)/,
|
|
3835
|
+
/\.get\s*\(\s*['"`]upload['"`]\s*\)/,
|
|
3836
|
+
/\.get\s*\(\s*['"`]attachment['"`]\s*\)/,
|
|
3837
|
+
/\.get\s*\(\s*['"`]document['"`]\s*\)/,
|
|
3838
|
+
/\.get\s*\(\s*['"`]avatar['"`]\s*\)/,
|
|
3839
|
+
/\.get\s*\(\s*['"`]photo['"`]\s*\)/,
|
|
3840
|
+
/\.type\s*===?\s*['"`]file['"`]/,
|
|
3841
|
+
/req\.file\b/,
|
|
3842
|
+
/multer/i,
|
|
3843
|
+
/busboy/i,
|
|
3844
|
+
/formidable/i
|
|
3845
|
+
];
|
|
3846
|
+
var VALIDATION_PATTERNS4 = [
|
|
3847
|
+
/\.type\b.*(?:image|video|audio|pdf|text)\//,
|
|
3848
|
+
/content-type/i,
|
|
3849
|
+
/mime/i,
|
|
3850
|
+
/\.size\s*[><!]/,
|
|
3851
|
+
/maxFileSize/i,
|
|
3852
|
+
/maxSize/i,
|
|
3853
|
+
/fileSizeLimit/i,
|
|
3854
|
+
/allowedTypes/i,
|
|
3855
|
+
/acceptedTypes/i,
|
|
3856
|
+
/fileFilter/i,
|
|
3857
|
+
/\.endsWith\s*\(\s*['"`]\./,
|
|
3858
|
+
/\.extension/i
|
|
3859
|
+
];
|
|
3860
|
+
var unsafeFileUploadRule = {
|
|
3861
|
+
id: "unsafe-file-upload",
|
|
3862
|
+
name: "Unsafe File Upload",
|
|
3863
|
+
description: "Detects file upload handlers without type or size validation",
|
|
3864
|
+
category: "security",
|
|
3865
|
+
severity: "warning",
|
|
3866
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
3867
|
+
check(file, _project) {
|
|
3868
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3869
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
3870
|
+
const hasUpload = UPLOAD_PATTERNS.some((p) => p.test(file.content));
|
|
3871
|
+
if (!hasUpload) return [];
|
|
3872
|
+
const hasValidation = VALIDATION_PATTERNS4.some((p) => p.test(file.content));
|
|
3873
|
+
if (hasValidation) return [];
|
|
3874
|
+
let reportLine = 1;
|
|
3875
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3876
|
+
if (UPLOAD_PATTERNS.some((p) => p.test(file.lines[i]))) {
|
|
3877
|
+
reportLine = i + 1;
|
|
3878
|
+
break;
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
return [{
|
|
3882
|
+
ruleId: "unsafe-file-upload",
|
|
3883
|
+
file: file.relativePath,
|
|
3884
|
+
line: reportLine,
|
|
3885
|
+
column: 1,
|
|
3886
|
+
message: "File upload without type or size validation \u2014 accepts any file type and size",
|
|
3887
|
+
severity: "warning",
|
|
3888
|
+
category: "security",
|
|
3889
|
+
fix: "Validate file type (check MIME type, not just extension) and enforce a size limit before processing"
|
|
3890
|
+
}];
|
|
3891
|
+
}
|
|
3892
|
+
};
|
|
3893
|
+
|
|
3894
|
+
// src/rules/supabase-missing-rls.ts
|
|
3895
|
+
var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
|
|
3896
|
+
var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
|
|
3897
|
+
var supabaseMissingRlsRule = {
|
|
3898
|
+
id: "supabase-missing-rls",
|
|
3899
|
+
name: "Missing Row-Level Security",
|
|
3900
|
+
description: "Detects SQL migrations that create tables without enabling Row-Level Security \u2014 all data is publicly accessible",
|
|
3901
|
+
category: "security",
|
|
3902
|
+
severity: "critical",
|
|
3903
|
+
fileExtensions: ["sql"],
|
|
3904
|
+
check(file, project) {
|
|
3905
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3906
|
+
if (!/migration|supabase|schema/i.test(file.relativePath)) return [];
|
|
3907
|
+
const content = file.content;
|
|
3908
|
+
const findings = [];
|
|
3909
|
+
const tables = [];
|
|
3910
|
+
CREATE_TABLE.lastIndex = 0;
|
|
3911
|
+
let match;
|
|
3912
|
+
while ((match = CREATE_TABLE.exec(content)) !== null) {
|
|
3913
|
+
const name = match[1];
|
|
3914
|
+
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
3915
|
+
const beforeMatch = content.slice(0, match.index);
|
|
3916
|
+
const line = beforeMatch.split("\n").length;
|
|
3917
|
+
tables.push({ name, line });
|
|
3918
|
+
}
|
|
3919
|
+
if (tables.length === 0) return [];
|
|
3920
|
+
const rlsTables = /* @__PURE__ */ new Set();
|
|
3921
|
+
ENABLE_RLS.lastIndex = 0;
|
|
3922
|
+
while ((match = ENABLE_RLS.exec(content)) !== null) {
|
|
3923
|
+
rlsTables.add(match[1].toLowerCase());
|
|
3924
|
+
}
|
|
3925
|
+
for (const filePath of project.allFiles) {
|
|
3926
|
+
if (!filePath.endsWith(".sql")) continue;
|
|
3927
|
+
if (filePath === file.relativePath) continue;
|
|
3928
|
+
}
|
|
3929
|
+
for (const table of tables) {
|
|
3930
|
+
if (!rlsTables.has(table.name.toLowerCase())) {
|
|
3931
|
+
findings.push({
|
|
3932
|
+
ruleId: "supabase-missing-rls",
|
|
3933
|
+
file: file.relativePath,
|
|
3934
|
+
line: table.line,
|
|
3935
|
+
column: 1,
|
|
3936
|
+
message: `Table "${table.name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
3937
|
+
severity: "critical",
|
|
3938
|
+
category: "security",
|
|
3939
|
+
fix: `Add: ALTER TABLE ${table.name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
return findings;
|
|
3944
|
+
}
|
|
3945
|
+
};
|
|
3946
|
+
|
|
3947
|
+
// src/rules/deprecated-oauth-flow.ts
|
|
3948
|
+
var IMPLICIT_GRANT = /response_type\s*[=:]\s*['"`]?token['"`]?/;
|
|
3949
|
+
var deprecatedOauthFlowRule = {
|
|
3950
|
+
id: "deprecated-oauth-flow",
|
|
3951
|
+
name: "Deprecated OAuth Flow",
|
|
3952
|
+
description: "Detects OAuth Implicit Grant flow (response_type=token) \u2014 deprecated in OAuth 2.1, vulnerable to token interception",
|
|
3953
|
+
category: "security",
|
|
3954
|
+
severity: "warning",
|
|
3955
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
3956
|
+
check(file, _project) {
|
|
3957
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3958
|
+
const findings = [];
|
|
3959
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
3960
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3961
|
+
const line = file.lines[i];
|
|
3962
|
+
const match = IMPLICIT_GRANT.exec(line);
|
|
3963
|
+
if (match) {
|
|
3964
|
+
findings.push({
|
|
3965
|
+
ruleId: "deprecated-oauth-flow",
|
|
3966
|
+
file: file.relativePath,
|
|
3967
|
+
line: i + 1,
|
|
3968
|
+
column: match.index + 1,
|
|
3969
|
+
message: "OAuth Implicit Grant flow (response_type=token) is deprecated \u2014 tokens are exposed in the URL fragment",
|
|
3970
|
+
severity: "warning",
|
|
3971
|
+
category: "security",
|
|
3972
|
+
fix: "Use Authorization Code flow with PKCE: response_type=code with code_challenge and code_verifier"
|
|
3973
|
+
});
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
return findings;
|
|
3977
|
+
}
|
|
3978
|
+
};
|
|
3979
|
+
|
|
3980
|
+
// src/rules/jwt-no-expiry.ts
|
|
3981
|
+
var JWT_SIGN = /jwt\.sign\s*\(/;
|
|
3982
|
+
var HAS_EXPIRY = [
|
|
3983
|
+
/expiresIn/,
|
|
3984
|
+
/exp\s*:/,
|
|
3985
|
+
/expirationTime/,
|
|
3986
|
+
/maxAge/
|
|
3987
|
+
];
|
|
3988
|
+
var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
|
|
3989
|
+
var jwtNoExpiryRule = {
|
|
3990
|
+
id: "jwt-no-expiry",
|
|
3991
|
+
name: "JWT Without Expiration",
|
|
3992
|
+
description: "Detects jwt.sign() calls without an expiresIn option \u2014 tokens never expire, compromised tokens are valid forever",
|
|
3993
|
+
category: "security",
|
|
3994
|
+
severity: "warning",
|
|
3995
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "cjs"],
|
|
3996
|
+
check(file, _project) {
|
|
3997
|
+
if (isTestFile(file.relativePath)) return [];
|
|
3998
|
+
if (!JWT_SIGN.test(file.content)) return [];
|
|
3999
|
+
const findings = [];
|
|
4000
|
+
if (file.ast) {
|
|
4001
|
+
try {
|
|
4002
|
+
walkAST(file.ast.program, (node) => {
|
|
4003
|
+
if (node.type !== "CallExpression") return;
|
|
4004
|
+
const call = node;
|
|
4005
|
+
if (!call.loc) return;
|
|
4006
|
+
if (call.callee.type !== "MemberExpression") return;
|
|
4007
|
+
const mem = call.callee;
|
|
4008
|
+
if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
|
|
4009
|
+
if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
|
|
4010
|
+
let hasExpiry = false;
|
|
4011
|
+
for (const arg of call.arguments) {
|
|
4012
|
+
if (arg.type === "ObjectExpression") {
|
|
4013
|
+
const obj = arg;
|
|
4014
|
+
for (const prop of obj.properties) {
|
|
4015
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
|
|
4016
|
+
hasExpiry = true;
|
|
4017
|
+
break;
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
if (hasExpiry) break;
|
|
4022
|
+
}
|
|
4023
|
+
if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
|
|
4024
|
+
const payload = call.arguments[0];
|
|
4025
|
+
for (const prop of payload.properties) {
|
|
4026
|
+
if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
|
|
4027
|
+
hasExpiry = true;
|
|
4028
|
+
break;
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
}
|
|
4032
|
+
if (!hasExpiry) {
|
|
4033
|
+
findings.push({
|
|
4034
|
+
ruleId: "jwt-no-expiry",
|
|
4035
|
+
file: file.relativePath,
|
|
4036
|
+
line: call.loc.start.line,
|
|
4037
|
+
column: call.loc.start.column + 1,
|
|
4038
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
4039
|
+
severity: "warning",
|
|
4040
|
+
category: "security",
|
|
4041
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
4042
|
+
});
|
|
4043
|
+
}
|
|
4044
|
+
});
|
|
4045
|
+
return findings;
|
|
4046
|
+
} catch {
|
|
4047
|
+
}
|
|
4048
|
+
}
|
|
4049
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
4050
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
4051
|
+
const line = file.lines[i];
|
|
4052
|
+
const match = JWT_SIGN.exec(line);
|
|
4053
|
+
if (!match) continue;
|
|
4054
|
+
const context = file.lines.slice(i, i + 6).join("\n");
|
|
4055
|
+
const hasExpiry = HAS_EXPIRY.some((p) => p.test(context));
|
|
4056
|
+
if (!hasExpiry) {
|
|
4057
|
+
findings.push({
|
|
4058
|
+
ruleId: "jwt-no-expiry",
|
|
4059
|
+
file: file.relativePath,
|
|
4060
|
+
line: i + 1,
|
|
4061
|
+
column: match.index + 1,
|
|
4062
|
+
message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
|
|
4063
|
+
severity: "warning",
|
|
4064
|
+
category: "security",
|
|
4065
|
+
fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
|
|
4066
|
+
});
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
return findings;
|
|
4070
|
+
}
|
|
4071
|
+
};
|
|
4072
|
+
|
|
4073
|
+
// src/rules/client-side-auth-only.ts
|
|
4074
|
+
var CLIENT_AUTH_PATTERNS = [
|
|
4075
|
+
/localStorage\.(?:get|set)Item\s*\(\s*['"`](?:token|auth|session|jwt|access_token|user)['"`]/,
|
|
4076
|
+
/sessionStorage\.(?:get|set)Item\s*\(\s*['"`](?:token|auth|session|jwt|access_token|user)['"`]/
|
|
4077
|
+
];
|
|
4078
|
+
var PASSWORD_CHECK = /(?:password|passwd)\s*[!=]==?\s*['"`]/;
|
|
4079
|
+
var clientSideAuthOnlyRule = {
|
|
4080
|
+
id: "client-side-auth-only",
|
|
4081
|
+
name: "Client-Side Auth Only",
|
|
4082
|
+
description: "Detects authentication logic implemented only in client-side code \u2014 easily bypassed via browser DevTools",
|
|
4083
|
+
category: "security",
|
|
4084
|
+
severity: "critical",
|
|
4085
|
+
fileExtensions: ["tsx", "jsx", "ts", "js"],
|
|
4086
|
+
check(file, _project) {
|
|
4087
|
+
if (isTestFile(file.relativePath)) return [];
|
|
4088
|
+
if (!isClientComponent(file.content)) return [];
|
|
4089
|
+
const findings = [];
|
|
4090
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
4091
|
+
const line = file.lines[i];
|
|
4092
|
+
const match = PASSWORD_CHECK.exec(line);
|
|
4093
|
+
if (match) {
|
|
4094
|
+
findings.push({
|
|
4095
|
+
ruleId: "client-side-auth-only",
|
|
4096
|
+
file: file.relativePath,
|
|
4097
|
+
line: i + 1,
|
|
4098
|
+
column: match.index + 1,
|
|
4099
|
+
message: "Password comparison in client-side code \u2014 the password is visible in the JavaScript bundle",
|
|
4100
|
+
severity: "critical",
|
|
4101
|
+
category: "security",
|
|
4102
|
+
fix: "Move authentication logic to a server action or API route"
|
|
4103
|
+
});
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
4106
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
4107
|
+
const line = file.lines[i];
|
|
4108
|
+
for (const pattern of CLIENT_AUTH_PATTERNS) {
|
|
4109
|
+
const match = pattern.exec(line);
|
|
4110
|
+
if (match) {
|
|
4111
|
+
findings.push({
|
|
4112
|
+
ruleId: "client-side-auth-only",
|
|
4113
|
+
file: file.relativePath,
|
|
4114
|
+
line: i + 1,
|
|
4115
|
+
column: match.index + 1,
|
|
4116
|
+
message: "Auth token in localStorage \u2014 accessible to any script on the page (XSS risk). Use httpOnly cookies instead.",
|
|
4117
|
+
severity: "warning",
|
|
4118
|
+
category: "security",
|
|
4119
|
+
fix: "Store auth tokens in httpOnly cookies set by the server, not in localStorage"
|
|
4120
|
+
});
|
|
4121
|
+
break;
|
|
4122
|
+
}
|
|
4123
|
+
}
|
|
4124
|
+
}
|
|
4125
|
+
return findings;
|
|
4126
|
+
}
|
|
4127
|
+
};
|
|
4128
|
+
|
|
4129
|
+
// src/rules/missing-abort-controller.ts
|
|
4130
|
+
var FETCH_CALL = /\bfetch\s*\(/;
|
|
4131
|
+
var HAS_TIMEOUT = [
|
|
4132
|
+
/AbortController/,
|
|
4133
|
+
/abort/i,
|
|
4134
|
+
/signal\s*:/,
|
|
4135
|
+
/timeout/i,
|
|
4136
|
+
/setTimeout.*abort/s
|
|
4137
|
+
];
|
|
4138
|
+
var missingAbortControllerRule = {
|
|
4139
|
+
id: "missing-abort-controller",
|
|
4140
|
+
name: "Missing Abort Controller",
|
|
4141
|
+
description: "Detects fetch calls in API routes without timeout or AbortController \u2014 requests can hang indefinitely",
|
|
4142
|
+
category: "performance",
|
|
4143
|
+
severity: "info",
|
|
4144
|
+
fileExtensions: ["ts", "tsx", "js", "jsx"],
|
|
4145
|
+
check(file, _project) {
|
|
4146
|
+
if (isTestFile(file.relativePath)) return [];
|
|
4147
|
+
if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
|
|
4148
|
+
if (!FETCH_CALL.test(file.content)) return [];
|
|
4149
|
+
const hasTimeout = HAS_TIMEOUT.some((p) => p.test(file.content));
|
|
4150
|
+
if (hasTimeout) return [];
|
|
4151
|
+
let reportLine = 1;
|
|
4152
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
4153
|
+
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
4154
|
+
if (FETCH_CALL.test(file.lines[i])) {
|
|
4155
|
+
reportLine = i + 1;
|
|
4156
|
+
break;
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
return [{
|
|
4160
|
+
ruleId: "missing-abort-controller",
|
|
4161
|
+
file: file.relativePath,
|
|
4162
|
+
line: reportLine,
|
|
4163
|
+
column: 1,
|
|
4164
|
+
message: "fetch() without timeout or AbortController \u2014 request will hang indefinitely if the upstream server doesn't respond",
|
|
4165
|
+
severity: "info",
|
|
4166
|
+
category: "performance",
|
|
4167
|
+
fix: "Add a timeout: const controller = new AbortController(); setTimeout(() => controller.abort(), 10000); fetch(url, { signal: controller.signal })"
|
|
4168
|
+
}];
|
|
4169
|
+
}
|
|
4170
|
+
};
|
|
2544
4171
|
|
|
2545
4172
|
// src/rules/index.ts
|
|
2546
4173
|
var rules = [
|
|
@@ -2559,6 +4186,19 @@ var rules = [
|
|
|
2559
4186
|
leakedEnvInLogsRule,
|
|
2560
4187
|
insecureRandomRule,
|
|
2561
4188
|
nextServerActionValidationRule,
|
|
4189
|
+
envFallbackSecretRule,
|
|
4190
|
+
verboseErrorResponseRule,
|
|
4191
|
+
missingWebhookVerificationRule,
|
|
4192
|
+
serverActionAuthRule,
|
|
4193
|
+
evalInjectionRule,
|
|
4194
|
+
nextPublicSensitiveRule,
|
|
4195
|
+
ssrfRiskRule,
|
|
4196
|
+
pathTraversalRule,
|
|
4197
|
+
unsafeFileUploadRule,
|
|
4198
|
+
supabaseMissingRlsRule,
|
|
4199
|
+
deprecatedOauthFlowRule,
|
|
4200
|
+
jwtNoExpiryRule,
|
|
4201
|
+
clientSideAuthOnlyRule,
|
|
2562
4202
|
// Reliability
|
|
2563
4203
|
hallucinatedImportsRule,
|
|
2564
4204
|
errorHandlingRule,
|
|
@@ -2567,11 +4207,17 @@ var rules = [
|
|
|
2567
4207
|
missingLoadingStateRule,
|
|
2568
4208
|
missingErrorBoundaryRule,
|
|
2569
4209
|
missingTransactionRule,
|
|
4210
|
+
redirectInTryCatchRule,
|
|
4211
|
+
missingRevalidationRule,
|
|
4212
|
+
missingUseEffectCleanupRule,
|
|
4213
|
+
hydrationMismatchRule,
|
|
2570
4214
|
// Performance
|
|
2571
4215
|
noSyncFsRule,
|
|
2572
4216
|
noNPlusOneRule,
|
|
2573
4217
|
noUnboundedQueryRule,
|
|
2574
4218
|
noDynamicImportLoopRule,
|
|
4219
|
+
serverComponentFetchSelfRule,
|
|
4220
|
+
missingAbortControllerRule,
|
|
2575
4221
|
// AI Quality
|
|
2576
4222
|
aiSmellsRule,
|
|
2577
4223
|
placeholderContentRule,
|
|
@@ -2579,7 +4225,8 @@ var rules = [
|
|
|
2579
4225
|
staleFallbackRule,
|
|
2580
4226
|
comprehensionDebtRule,
|
|
2581
4227
|
codebaseConsistencyRule,
|
|
2582
|
-
deadExportsRule
|
|
4228
|
+
deadExportsRule,
|
|
4229
|
+
useClientOveruseRule
|
|
2583
4230
|
];
|
|
2584
4231
|
|
|
2585
4232
|
// src/scanner.ts
|
|
@@ -2634,7 +4281,7 @@ var server = new McpServer({
|
|
|
2634
4281
|
});
|
|
2635
4282
|
server.tool(
|
|
2636
4283
|
"scan",
|
|
2637
|
-
"Scan a project for production
|
|
4284
|
+
"Scan a vibe-coded project for production issues. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
|
|
2638
4285
|
{
|
|
2639
4286
|
path: z.string().describe("Absolute path to the project directory to scan"),
|
|
2640
4287
|
ignore: z.array(z.string()).optional().describe("Glob patterns to ignore")
|
|
@@ -2657,7 +4304,7 @@ server.tool(
|
|
|
2657
4304
|
}
|
|
2658
4305
|
const result = await scan({ path: resolved, ignore });
|
|
2659
4306
|
const summary = [
|
|
2660
|
-
`##
|
|
4307
|
+
`## Prodlint Score: ${result.overallScore}/100`,
|
|
2661
4308
|
"",
|
|
2662
4309
|
`Scanned ${result.filesScanned} files in ${result.scanDurationMs}ms`,
|
|
2663
4310
|
"",
|