prodlint 0.6.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/dist/index.js CHANGED
@@ -240,6 +240,81 @@ function findLoopsAST(ast) {
240
240
  });
241
241
  return loops;
242
242
  }
243
+ function isUserInputNode(node) {
244
+ if (node.type === "MemberExpression") {
245
+ const mem = node;
246
+ if (mem.object.type === "MemberExpression") {
247
+ const inner = mem.object;
248
+ if (inner.object.type === "Identifier") {
249
+ const objName = inner.object.name;
250
+ if (objName === "req" || objName === "request") {
251
+ if (inner.property.type === "Identifier") {
252
+ const prop = inner.property.name;
253
+ if (prop === "query" || prop === "body" || prop === "params") return true;
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ if (node.type === "CallExpression") {
260
+ const call = node;
261
+ if (call.callee.type === "MemberExpression") {
262
+ const callee = call.callee;
263
+ if (callee.property.type === "Identifier" && callee.property.name === "get") {
264
+ if (callee.object.type === "Identifier") {
265
+ const name = callee.object.name;
266
+ if (name === "searchParams" || name === "formData") return true;
267
+ }
268
+ if (callee.object.type === "MemberExpression") {
269
+ const inner = callee.object;
270
+ if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
271
+ return true;
272
+ }
273
+ }
274
+ }
275
+ }
276
+ }
277
+ return false;
278
+ }
279
+ function isStaticString(node) {
280
+ if (node.type === "StringLiteral") return true;
281
+ if (node.type === "TemplateLiteral") {
282
+ return node.expressions.length === 0;
283
+ }
284
+ return false;
285
+ }
286
+ function findUseEffectRanges(ast) {
287
+ const ranges = [];
288
+ walkAST(ast.program, (node) => {
289
+ if (node.type !== "CallExpression") return;
290
+ const call = node;
291
+ if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
292
+ const callback = call.arguments[0];
293
+ if (!callback || !callback.loc) return;
294
+ ranges.push({
295
+ start: callback.loc.start.line - 1,
296
+ end: callback.loc.end.line - 1
297
+ });
298
+ });
299
+ return ranges;
300
+ }
301
+ function subtreeContains(node, predicate) {
302
+ if (predicate(node)) return true;
303
+ for (const key of Object.keys(node)) {
304
+ if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
305
+ const val = node[key];
306
+ if (Array.isArray(val)) {
307
+ for (const item of val) {
308
+ if (item && typeof item === "object" && item.type) {
309
+ if (subtreeContains(item, predicate)) return true;
310
+ }
311
+ }
312
+ } else if (val && typeof val === "object" && val.type) {
313
+ if (subtreeContains(val, predicate)) return true;
314
+ }
315
+ }
316
+ return false;
317
+ }
243
318
 
244
319
  // src/utils/frameworks.ts
245
320
  var FRAMEWORK_SAFE_METHODS = {
@@ -1142,6 +1217,81 @@ var unsafeHtmlRule = {
1142
1217
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1143
1218
  check(file, _project) {
1144
1219
  const findings = [];
1220
+ if (file.ast) {
1221
+ try {
1222
+ walkAST(file.ast.program, (node) => {
1223
+ if (node.type === "JSXAttribute") {
1224
+ const attr = node;
1225
+ if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
1226
+ if (attr.value && subtreeContains(attr.value, (n) => {
1227
+ if (n.type !== "CallExpression") return false;
1228
+ const call = n;
1229
+ if (call.callee.type === "MemberExpression") {
1230
+ const mem = call.callee;
1231
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1232
+ }
1233
+ return false;
1234
+ })) {
1235
+ return;
1236
+ }
1237
+ const line = attr.loc.start.line;
1238
+ findings.push({
1239
+ ruleId: "unsafe-html",
1240
+ file: file.relativePath,
1241
+ line,
1242
+ column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1243
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1244
+ severity: "critical",
1245
+ category: "security"
1246
+ });
1247
+ }
1248
+ }
1249
+ if (node.type === "ObjectProperty") {
1250
+ const prop = node;
1251
+ if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
1252
+ if (prop.value && subtreeContains(prop.value, (n) => {
1253
+ if (n.type !== "CallExpression") return false;
1254
+ const call = n;
1255
+ if (call.callee.type === "MemberExpression") {
1256
+ const mem = call.callee;
1257
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1258
+ }
1259
+ return false;
1260
+ })) {
1261
+ return;
1262
+ }
1263
+ const line = prop.loc.start.line;
1264
+ findings.push({
1265
+ ruleId: "unsafe-html",
1266
+ file: file.relativePath,
1267
+ line,
1268
+ column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1269
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1270
+ severity: "critical",
1271
+ category: "security"
1272
+ });
1273
+ }
1274
+ }
1275
+ if (node.type === "AssignmentExpression") {
1276
+ const assign = node;
1277
+ if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
1278
+ const line = assign.loc.start.line;
1279
+ findings.push({
1280
+ ruleId: "unsafe-html",
1281
+ file: file.relativePath,
1282
+ line,
1283
+ column: file.lines[line - 1].indexOf(".innerHTML") + 1,
1284
+ message: "Direct innerHTML assignment is an XSS risk",
1285
+ severity: "critical",
1286
+ category: "security"
1287
+ });
1288
+ }
1289
+ }
1290
+ });
1291
+ return findings;
1292
+ } catch {
1293
+ }
1294
+ }
1145
1295
  for (let i = 0; i < file.lines.length; i++) {
1146
1296
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1147
1297
  const line = file.lines[i];
@@ -1395,6 +1545,22 @@ var DIRECT_INPUT_PATTERNS = [
1395
1545
  var WARNING_PATTERNS = [
1396
1546
  /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1397
1547
  ];
1548
+ var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
1549
+ "url",
1550
+ "returnUrl",
1551
+ "returnTo",
1552
+ "redirectUrl",
1553
+ "redirectTo",
1554
+ "next",
1555
+ "callbackUrl",
1556
+ "destination",
1557
+ "redirect",
1558
+ "goto",
1559
+ "to",
1560
+ "target",
1561
+ "uri",
1562
+ "href"
1563
+ ]);
1398
1564
  var openRedirectRule = {
1399
1565
  id: "open-redirect",
1400
1566
  name: "Open Redirect",
@@ -1404,6 +1570,62 @@ var openRedirectRule = {
1404
1570
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1405
1571
  check(file, _project) {
1406
1572
  const findings = [];
1573
+ if (file.ast) {
1574
+ try {
1575
+ walkAST(file.ast.program, (node) => {
1576
+ if (node.type !== "CallExpression") return;
1577
+ const call = node;
1578
+ if (!call.loc) return;
1579
+ let isRedirect = false;
1580
+ if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
1581
+ isRedirect = true;
1582
+ }
1583
+ if (call.callee.type === "MemberExpression") {
1584
+ const mem = call.callee;
1585
+ if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
1586
+ isRedirect = true;
1587
+ }
1588
+ }
1589
+ if (!isRedirect) return;
1590
+ const arg = call.arguments[0];
1591
+ if (!arg) return;
1592
+ if (isStaticString(arg)) return;
1593
+ let targetArg = arg;
1594
+ if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
1595
+ targetArg = arg.arguments[0];
1596
+ if (!targetArg) return;
1597
+ if (isStaticString(targetArg)) return;
1598
+ }
1599
+ const lineNum = call.loc.start.line;
1600
+ const col = call.loc.start.column + 1;
1601
+ if (isUserInputNode(targetArg)) {
1602
+ findings.push({
1603
+ ruleId: "open-redirect",
1604
+ file: file.relativePath,
1605
+ line: lineNum,
1606
+ column: col,
1607
+ message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1608
+ severity: "warning",
1609
+ category: "security"
1610
+ });
1611
+ return;
1612
+ }
1613
+ if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
1614
+ findings.push({
1615
+ ruleId: "open-redirect",
1616
+ file: file.relativePath,
1617
+ line: lineNum,
1618
+ column: col,
1619
+ message: "Possible user input in redirect \u2014 verify the URL is validated before use",
1620
+ severity: "warning",
1621
+ category: "security"
1622
+ });
1623
+ }
1624
+ });
1625
+ return findings;
1626
+ } catch {
1627
+ }
1628
+ }
1407
1629
  for (let i = 0; i < file.lines.length; i++) {
1408
1630
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1409
1631
  const line = file.lines[i];
@@ -2051,6 +2273,34 @@ var shallowCatchRule = {
2051
2273
  if (isTestFile(file.relativePath)) return [];
2052
2274
  if (isScriptFile(file.relativePath)) return [];
2053
2275
  const findings = [];
2276
+ if (file.ast) {
2277
+ try {
2278
+ walkAST(file.ast.program, (node) => {
2279
+ if (node.type !== "CatchClause") return;
2280
+ if (!node.loc) return;
2281
+ const catchLine = node.loc.start.line - 1;
2282
+ const body = node.body;
2283
+ if (!body || !body.loc) return;
2284
+ const bodyStart = body.loc.start.line - 1;
2285
+ const bodyEnd = body.loc.end.line - 1;
2286
+ const bodyLines = file.lines.slice(bodyStart + 1, bodyEnd);
2287
+ const { score, label } = scoreCatchBody(bodyLines);
2288
+ if (score <= 1) {
2289
+ findings.push({
2290
+ ruleId: "shallow-catch",
2291
+ file: file.relativePath,
2292
+ line: catchLine + 1,
2293
+ column: file.lines[catchLine].indexOf("catch") + 1,
2294
+ message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2295
+ severity: score === 0 ? "warning" : "info",
2296
+ category: "reliability"
2297
+ });
2298
+ }
2299
+ });
2300
+ return findings;
2301
+ } catch {
2302
+ }
2303
+ }
2054
2304
  for (let i = 0; i < file.lines.length; i++) {
2055
2305
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
2056
2306
  const trimmed = file.lines[i].trim();
@@ -2384,6 +2634,7 @@ var insecureCookieRule = {
2384
2634
 
2385
2635
  // src/rules/leaked-env-in-logs.ts
2386
2636
  var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2637
+ var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
2387
2638
  var leakedEnvInLogsRule = {
2388
2639
  id: "leaked-env-in-logs",
2389
2640
  name: "Leaked Env in Logs",
@@ -2395,6 +2646,48 @@ var leakedEnvInLogsRule = {
2395
2646
  if (isTestFile(file.relativePath)) return [];
2396
2647
  if (isScriptFile(file.relativePath)) return [];
2397
2648
  const findings = [];
2649
+ if (file.ast) {
2650
+ try {
2651
+ walkAST(file.ast.program, (node) => {
2652
+ if (node.type !== "CallExpression") return;
2653
+ const call = node;
2654
+ if (!call.loc) return;
2655
+ if (call.callee.type !== "MemberExpression") return;
2656
+ const mem = call.callee;
2657
+ if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
2658
+ if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
2659
+ let hasEnvAccess = false;
2660
+ for (const arg of call.arguments) {
2661
+ if (subtreeContains(arg, (n) => {
2662
+ if (n.type !== "MemberExpression") return false;
2663
+ const m = n;
2664
+ if (m.object.type === "MemberExpression") {
2665
+ const inner = m.object;
2666
+ return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
2667
+ }
2668
+ return false;
2669
+ })) {
2670
+ hasEnvAccess = true;
2671
+ break;
2672
+ }
2673
+ }
2674
+ if (hasEnvAccess) {
2675
+ findings.push({
2676
+ ruleId: "leaked-env-in-logs",
2677
+ file: file.relativePath,
2678
+ line: call.loc.start.line,
2679
+ column: call.loc.start.column + 1,
2680
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2681
+ severity: "warning",
2682
+ category: "security",
2683
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2684
+ });
2685
+ }
2686
+ });
2687
+ return findings;
2688
+ } catch {
2689
+ }
2690
+ }
2398
2691
  for (let i = 0; i < file.lines.length; i++) {
2399
2692
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
2400
2693
  const line = file.lines[i];
@@ -2499,6 +2792,15 @@ var nextServerActionValidationRule = {
2499
2792
  // src/rules/missing-transaction.ts
2500
2793
  var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2501
2794
  var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2795
+ var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
2796
+ "create",
2797
+ "update",
2798
+ "delete",
2799
+ "upsert",
2800
+ "createMany",
2801
+ "updateMany",
2802
+ "deleteMany"
2803
+ ]);
2502
2804
  var missingTransactionRule = {
2503
2805
  id: "missing-transaction",
2504
2806
  name: "Missing Transaction",
@@ -2510,6 +2812,60 @@ var missingTransactionRule = {
2510
2812
  if (isTestFile(file.relativePath)) return [];
2511
2813
  if (isScriptFile(file.relativePath)) return [];
2512
2814
  if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2815
+ if (file.ast) {
2816
+ try {
2817
+ const parentMap = /* @__PURE__ */ new Map();
2818
+ walkAST(file.ast.program, (node, parent) => {
2819
+ if (parent) parentMap.set(node, parent);
2820
+ });
2821
+ const writesByScope = /* @__PURE__ */ new Map();
2822
+ const transactionScopes = /* @__PURE__ */ new Set();
2823
+ walkAST(file.ast.program, (node) => {
2824
+ if (node.type !== "CallExpression") return;
2825
+ const call = node;
2826
+ if (!call.loc) return;
2827
+ if (call.callee.type === "MemberExpression") {
2828
+ const mem = call.callee;
2829
+ if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
2830
+ const scope2 = findEnclosingFunction(node, parentMap);
2831
+ transactionScopes.add(scope2);
2832
+ return;
2833
+ }
2834
+ }
2835
+ if (call.callee.type !== "MemberExpression") return;
2836
+ const outerMem = call.callee;
2837
+ if (outerMem.property.type !== "Identifier") return;
2838
+ if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
2839
+ if (outerMem.object.type !== "MemberExpression") return;
2840
+ const innerMem = outerMem.object;
2841
+ if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
2842
+ const scope = findEnclosingFunction(node, parentMap);
2843
+ const existing = writesByScope.get(scope);
2844
+ if (existing) {
2845
+ existing.count++;
2846
+ } else {
2847
+ writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
2848
+ }
2849
+ });
2850
+ const findings = [];
2851
+ for (const [scope, { count, firstLine }] of writesByScope) {
2852
+ if (count < 2) continue;
2853
+ if (transactionScopes.has(scope)) continue;
2854
+ findings.push({
2855
+ ruleId: "missing-transaction",
2856
+ file: file.relativePath,
2857
+ line: firstLine,
2858
+ column: 1,
2859
+ message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2860
+ severity: "warning",
2861
+ category: "reliability",
2862
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2863
+ });
2864
+ }
2865
+ return findings;
2866
+ } catch {
2867
+ }
2868
+ }
2513
2869
  let writeCount = 0;
2514
2870
  let firstWriteLine = -1;
2515
2871
  const hasTransaction = PRISMA_TRANSACTION.test(file.content);
@@ -2533,6 +2889,16 @@ var missingTransactionRule = {
2533
2889
  }];
2534
2890
  }
2535
2891
  };
2892
+ function findEnclosingFunction(node, parentMap) {
2893
+ let current = parentMap.get(node);
2894
+ while (current) {
2895
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
2896
+ return current;
2897
+ }
2898
+ current = parentMap.get(current);
2899
+ }
2900
+ return null;
2901
+ }
2536
2902
 
2537
2903
  // src/rules/redirect-in-try-catch.ts
2538
2904
  var redirectInTryCatchRule = {
@@ -3099,6 +3465,14 @@ var VALIDATION_PATTERNS3 = [
3099
3465
  /\.startsWith\s*\(\s*['"]https?:\/\//,
3100
3466
  /\.hostname\s*[!=]==?\s*['"`]/
3101
3467
  ];
3468
+ var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
3469
+ "url",
3470
+ "href",
3471
+ "endpoint",
3472
+ "target",
3473
+ "link",
3474
+ "src"
3475
+ ]);
3102
3476
  var ssrfRiskRule = {
3103
3477
  id: "ssrf-risk",
3104
3478
  name: "SSRF Risk",
@@ -3109,9 +3483,63 @@ var ssrfRiskRule = {
3109
3483
  check(file, _project) {
3110
3484
  if (isTestFile(file.relativePath)) return [];
3111
3485
  if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
3486
+ const findings = [];
3487
+ if (file.ast) {
3488
+ try {
3489
+ const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3490
+ walkAST(file.ast.program, (node) => {
3491
+ if (node.type !== "CallExpression") return;
3492
+ const call = node;
3493
+ if (!call.loc) return;
3494
+ let isFetchLike = false;
3495
+ if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
3496
+ isFetchLike = true;
3497
+ }
3498
+ if (call.callee.type === "MemberExpression") {
3499
+ const mem = call.callee;
3500
+ if (mem.object.type === "Identifier" && mem.object.name === "axios") {
3501
+ isFetchLike = true;
3502
+ }
3503
+ }
3504
+ if (!isFetchLike) return;
3505
+ const arg = call.arguments[0];
3506
+ if (!arg) return;
3507
+ if (isStaticString(arg)) return;
3508
+ const lineNum = call.loc.start.line;
3509
+ const col = call.loc.start.column + 1;
3510
+ if (isUserInputNode(arg)) {
3511
+ findings.push({
3512
+ ruleId: "ssrf-risk",
3513
+ file: file.relativePath,
3514
+ line: lineNum,
3515
+ column: col,
3516
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3517
+ severity: "warning",
3518
+ category: "security",
3519
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3520
+ });
3521
+ return;
3522
+ }
3523
+ if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
3524
+ if (hasValidationInCode) return;
3525
+ findings.push({
3526
+ ruleId: "ssrf-risk",
3527
+ file: file.relativePath,
3528
+ line: lineNum,
3529
+ column: col,
3530
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3531
+ severity: "warning",
3532
+ category: "security",
3533
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3534
+ });
3535
+ }
3536
+ });
3537
+ return findings;
3538
+ } catch {
3539
+ }
3540
+ }
3112
3541
  const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3113
3542
  if (hasValidation) return [];
3114
- const findings = [];
3115
3543
  for (let i = 0; i < file.lines.length; i++) {
3116
3544
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3117
3545
  const line = file.lines[i];
@@ -3153,6 +3581,25 @@ var SANITIZATION_PATTERNS = [
3153
3581
  /sanitize/i,
3154
3582
  /realpath/
3155
3583
  ];
3584
+ var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
3585
+ "readFile",
3586
+ "readFileSync",
3587
+ "createReadStream",
3588
+ "writeFile",
3589
+ "writeFileSync",
3590
+ "createWriteStream",
3591
+ "unlink",
3592
+ "unlinkSync",
3593
+ "rm",
3594
+ "rmSync"
3595
+ ]);
3596
+ var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
3597
+ "filePath",
3598
+ "fileName",
3599
+ "path",
3600
+ "file",
3601
+ "name"
3602
+ ]);
3156
3603
  var pathTraversalRule = {
3157
3604
  id: "path-traversal",
3158
3605
  name: "Path Traversal",
@@ -3162,9 +3609,73 @@ var pathTraversalRule = {
3162
3609
  fileExtensions: ["ts", "tsx", "js", "jsx"],
3163
3610
  check(file, _project) {
3164
3611
  if (isTestFile(file.relativePath)) return [];
3612
+ const findings = [];
3613
+ if (file.ast) {
3614
+ try {
3615
+ walkAST(file.ast.program, (node) => {
3616
+ if (node.type !== "CallExpression") return;
3617
+ const call = node;
3618
+ if (!call.loc) return;
3619
+ let fnName = null;
3620
+ if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
3621
+ fnName = call.callee.name;
3622
+ }
3623
+ if (call.callee.type === "MemberExpression") {
3624
+ const mem = call.callee;
3625
+ if (mem.property.type === "Identifier") {
3626
+ if (FS_FUNCTION_NAMES.has(mem.property.name)) {
3627
+ fnName = mem.property.name;
3628
+ }
3629
+ if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
3630
+ fnName = "path.join";
3631
+ }
3632
+ if (mem.property.name === "sendFile") {
3633
+ fnName = "sendFile";
3634
+ }
3635
+ }
3636
+ }
3637
+ if (!fnName) return;
3638
+ const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
3639
+ for (const arg of argsToCheck) {
3640
+ if (!arg) continue;
3641
+ if (isStaticString(arg)) continue;
3642
+ const lineNum = call.loc.start.line;
3643
+ const col = call.loc.start.column + 1;
3644
+ if (isUserInputNode(arg)) {
3645
+ const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
3646
+ findings.push({
3647
+ ruleId: "path-traversal",
3648
+ file: file.relativePath,
3649
+ line: lineNum,
3650
+ column: col,
3651
+ 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`,
3652
+ severity: "critical",
3653
+ category: "security",
3654
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3655
+ });
3656
+ return;
3657
+ }
3658
+ if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
3659
+ findings.push({
3660
+ ruleId: "path-traversal",
3661
+ file: file.relativePath,
3662
+ line: lineNum,
3663
+ column: col,
3664
+ message: `File operation with potentially user-controlled path \u2014 validate before use`,
3665
+ severity: "warning",
3666
+ category: "security",
3667
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3668
+ });
3669
+ return;
3670
+ }
3671
+ }
3672
+ });
3673
+ return findings;
3674
+ } catch {
3675
+ }
3676
+ }
3165
3677
  const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
3166
3678
  if (hasSanitization) return [];
3167
- const findings = [];
3168
3679
  for (let i = 0; i < file.lines.length; i++) {
3169
3680
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3170
3681
  const line = file.lines[i];
@@ -3216,18 +3727,31 @@ var hydrationMismatchRule = {
3216
3727
  if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3217
3728
  if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3218
3729
  const findings = [];
3730
+ let useEffectRanges = [];
3731
+ if (file.ast) {
3732
+ try {
3733
+ useEffectRanges = findUseEffectRanges(file.ast);
3734
+ } catch {
3735
+ }
3736
+ }
3737
+ const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
3219
3738
  let insideUseEffect = false;
3220
3739
  for (let i = 0; i < file.lines.length; i++) {
3221
3740
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3222
3741
  const line = file.lines[i];
3223
- if (/\buseEffect\s*\(/.test(line)) {
3224
- insideUseEffect = true;
3225
- }
3226
- if (insideUseEffect) {
3227
- if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
3228
- insideUseEffect = false;
3742
+ if (hasAstRanges) {
3743
+ const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
3744
+ if (inEffect) continue;
3745
+ } else {
3746
+ if (/\buseEffect\s*\(/.test(line)) {
3747
+ insideUseEffect = true;
3748
+ }
3749
+ if (insideUseEffect) {
3750
+ if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
3751
+ insideUseEffect = false;
3752
+ }
3753
+ continue;
3229
3754
  }
3230
- continue;
3231
3755
  }
3232
3756
  for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
3233
3757
  const match = pattern.exec(line);
@@ -3452,6 +3976,7 @@ var HAS_EXPIRY = [
3452
3976
  /expirationTime/,
3453
3977
  /maxAge/
3454
3978
  ];
3979
+ var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
3455
3980
  var jwtNoExpiryRule = {
3456
3981
  id: "jwt-no-expiry",
3457
3982
  name: "JWT Without Expiration",
@@ -3463,6 +3988,55 @@ var jwtNoExpiryRule = {
3463
3988
  if (isTestFile(file.relativePath)) return [];
3464
3989
  if (!JWT_SIGN.test(file.content)) return [];
3465
3990
  const findings = [];
3991
+ if (file.ast) {
3992
+ try {
3993
+ walkAST(file.ast.program, (node) => {
3994
+ if (node.type !== "CallExpression") return;
3995
+ const call = node;
3996
+ if (!call.loc) return;
3997
+ if (call.callee.type !== "MemberExpression") return;
3998
+ const mem = call.callee;
3999
+ if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
4000
+ if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
4001
+ let hasExpiry = false;
4002
+ for (const arg of call.arguments) {
4003
+ if (arg.type === "ObjectExpression") {
4004
+ const obj = arg;
4005
+ for (const prop of obj.properties) {
4006
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
4007
+ hasExpiry = true;
4008
+ break;
4009
+ }
4010
+ }
4011
+ }
4012
+ if (hasExpiry) break;
4013
+ }
4014
+ if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
4015
+ const payload = call.arguments[0];
4016
+ for (const prop of payload.properties) {
4017
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
4018
+ hasExpiry = true;
4019
+ break;
4020
+ }
4021
+ }
4022
+ }
4023
+ if (!hasExpiry) {
4024
+ findings.push({
4025
+ ruleId: "jwt-no-expiry",
4026
+ file: file.relativePath,
4027
+ line: call.loc.start.line,
4028
+ column: call.loc.start.column + 1,
4029
+ message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
4030
+ severity: "warning",
4031
+ category: "security",
4032
+ fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
4033
+ });
4034
+ }
4035
+ });
4036
+ return findings;
4037
+ } catch {
4038
+ }
4039
+ }
3466
4040
  for (let i = 0; i < file.lines.length; i++) {
3467
4041
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3468
4042
  const line = file.lines[i];