prodlint 0.6.0 → 0.7.1

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
@@ -71,7 +71,10 @@ function isTestFile(relativePath) {
71
71
  return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
72
72
  }
73
73
  function isScriptFile(relativePath) {
74
- return /(?:^|\/)scripts?\//.test(relativePath);
74
+ if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
75
+ const name = relativePath.split("/").pop() ?? "";
76
+ const base = name.replace(/\.[^.]+$/, "");
77
+ return /^(seed|migrate|setup|bootstrap|generate|codegen|sync|deploy|cleanup|reset)$/.test(base);
75
78
  }
76
79
  function isConfigFile(relativePath) {
77
80
  const name = relativePath.split("/").pop() ?? "";
@@ -240,6 +243,100 @@ function findLoopsAST(ast) {
240
243
  });
241
244
  return loops;
242
245
  }
246
+ function getImportSources(ast) {
247
+ const sources = [];
248
+ walkAST(ast.program, (node) => {
249
+ if (node.type === "ImportDeclaration") {
250
+ const decl = node;
251
+ sources.push({ source: decl.source.value, line: decl.loc?.start.line ?? 1 });
252
+ }
253
+ if (node.type === "CallExpression") {
254
+ const call = node;
255
+ if (call.callee.type === "Identifier" && call.callee.name === "require" && call.arguments.length === 1 && call.arguments[0].type === "StringLiteral") {
256
+ sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
257
+ }
258
+ if (call.callee.type === "Import" && call.arguments.length >= 1 && call.arguments[0].type === "StringLiteral") {
259
+ sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
260
+ }
261
+ }
262
+ });
263
+ return sources;
264
+ }
265
+ function isUserInputNode(node) {
266
+ if (node.type === "MemberExpression") {
267
+ const mem = node;
268
+ if (mem.object.type === "MemberExpression") {
269
+ const inner = mem.object;
270
+ if (inner.object.type === "Identifier") {
271
+ const objName = inner.object.name;
272
+ if (objName === "req" || objName === "request") {
273
+ if (inner.property.type === "Identifier") {
274
+ const prop = inner.property.name;
275
+ if (prop === "query" || prop === "body" || prop === "params") return true;
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+ if (node.type === "CallExpression") {
282
+ const call = node;
283
+ if (call.callee.type === "MemberExpression") {
284
+ const callee = call.callee;
285
+ if (callee.property.type === "Identifier" && callee.property.name === "get") {
286
+ if (callee.object.type === "Identifier") {
287
+ const name = callee.object.name;
288
+ if (name === "searchParams" || name === "formData") return true;
289
+ }
290
+ if (callee.object.type === "MemberExpression") {
291
+ const inner = callee.object;
292
+ if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
293
+ return true;
294
+ }
295
+ }
296
+ }
297
+ }
298
+ }
299
+ return false;
300
+ }
301
+ function isStaticString(node) {
302
+ if (node.type === "StringLiteral") return true;
303
+ if (node.type === "TemplateLiteral") {
304
+ return node.expressions.length === 0;
305
+ }
306
+ return false;
307
+ }
308
+ function findUseEffectRanges(ast) {
309
+ const ranges = [];
310
+ walkAST(ast.program, (node) => {
311
+ if (node.type !== "CallExpression") return;
312
+ const call = node;
313
+ if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
314
+ const callback = call.arguments[0];
315
+ if (!callback || !callback.loc) return;
316
+ ranges.push({
317
+ start: callback.loc.start.line - 1,
318
+ end: callback.loc.end.line - 1
319
+ });
320
+ });
321
+ return ranges;
322
+ }
323
+ function subtreeContains(node, predicate) {
324
+ if (predicate(node)) return true;
325
+ for (const key of Object.keys(node)) {
326
+ if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
327
+ const val = node[key];
328
+ if (Array.isArray(val)) {
329
+ for (const item of val) {
330
+ if (item && typeof item === "object" && item.type) {
331
+ if (subtreeContains(item, predicate)) return true;
332
+ }
333
+ }
334
+ } else if (val && typeof val === "object" && val.type) {
335
+ if (subtreeContains(val, predicate)) return true;
336
+ }
337
+ }
338
+ return false;
339
+ }
243
340
 
244
341
  // src/utils/frameworks.ts
245
342
  var FRAMEWORK_SAFE_METHODS = {
@@ -359,6 +456,66 @@ async function readFileContext(root, relativePath) {
359
456
  return null;
360
457
  }
361
458
  }
459
+ async function getWorkspacePatterns(root, packageJson) {
460
+ const patterns = [];
461
+ if (packageJson) {
462
+ const workspaces = packageJson.workspaces;
463
+ if (Array.isArray(workspaces)) {
464
+ patterns.push(...workspaces);
465
+ } else if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) {
466
+ patterns.push(...workspaces.packages);
467
+ }
468
+ }
469
+ try {
470
+ const raw = await readFile(resolve(root, "pnpm-workspace.yaml"), "utf-8");
471
+ let inPackages = false;
472
+ for (const line of raw.split(/\r?\n/)) {
473
+ const trimmed = line.trim();
474
+ if (/^packages\s*:/.test(trimmed)) {
475
+ inPackages = true;
476
+ continue;
477
+ }
478
+ if (inPackages) {
479
+ if (/^-\s+/.test(trimmed)) {
480
+ const glob = trimmed.replace(/^-\s+/, "").replace(/^['"]|['"]$/g, "");
481
+ if (glob) patterns.push(glob);
482
+ } else if (trimmed && !trimmed.startsWith("#")) {
483
+ break;
484
+ }
485
+ }
486
+ }
487
+ } catch {
488
+ }
489
+ return patterns;
490
+ }
491
+ async function collectWorkspaceDependencies(root, patterns) {
492
+ const deps = /* @__PURE__ */ new Set();
493
+ const globPatterns = patterns.map((p) => `${p}/package.json`);
494
+ try {
495
+ const pkgFiles = await fg(globPatterns, {
496
+ cwd: root,
497
+ absolute: false,
498
+ ignore: ["**/node_modules/**"]
499
+ });
500
+ for (const pkgFile of pkgFiles) {
501
+ try {
502
+ const raw = await readFile(resolve(root, pkgFile), "utf-8");
503
+ const pkg = JSON.parse(raw);
504
+ if (pkg.name) deps.add(pkg.name);
505
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
506
+ if (pkg[key] && typeof pkg[key] === "object") {
507
+ for (const dep of Object.keys(pkg[key])) {
508
+ deps.add(dep);
509
+ }
510
+ }
511
+ }
512
+ } catch {
513
+ }
514
+ }
515
+ } catch {
516
+ }
517
+ return deps;
518
+ }
362
519
  async function buildProjectContext(root, files) {
363
520
  let packageJson = null;
364
521
  let declaredDependencies = /* @__PURE__ */ new Set();
@@ -377,19 +534,26 @@ async function buildProjectContext(root, files) {
377
534
  ...packageJson?.peerDependencies ?? {}
378
535
  };
379
536
  declaredDependencies = new Set(Object.keys(deps));
380
- for (const dep of declaredDependencies) {
381
- const framework = DEPENDENCY_TO_FRAMEWORK[dep];
382
- if (framework) {
383
- detectedFrameworks.add(framework);
384
- }
537
+ } catch {
538
+ }
539
+ const workspacePatterns = await getWorkspacePatterns(root, packageJson);
540
+ if (workspacePatterns.length > 0) {
541
+ const workspaceDeps = await collectWorkspaceDependencies(root, workspacePatterns);
542
+ for (const dep of workspaceDeps) {
543
+ declaredDependencies.add(dep);
385
544
  }
386
- for (const framework of detectedFrameworks) {
387
- if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
388
- hasRateLimiting = true;
389
- break;
390
- }
545
+ }
546
+ for (const dep of declaredDependencies) {
547
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
548
+ if (framework) {
549
+ detectedFrameworks.add(framework);
550
+ }
551
+ }
552
+ for (const framework of detectedFrameworks) {
553
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
554
+ hasRateLimiting = true;
555
+ break;
391
556
  }
392
- } catch {
393
557
  }
394
558
  try {
395
559
  const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
@@ -644,6 +808,32 @@ var hallucinatedImportsRule = {
644
808
  const findings = [];
645
809
  const seen = /* @__PURE__ */ new Set();
646
810
  const isNonProd = isTestFile(file.relativePath) || isScriptFile(file.relativePath);
811
+ if (file.ast) {
812
+ try {
813
+ const imports = getImportSources(file.ast);
814
+ for (const { source: importPath, line } of imports) {
815
+ if (importPath.startsWith(".") || importPath.startsWith("/")) continue;
816
+ const pkgName = getPackageName(importPath);
817
+ if (seen.has(pkgName)) continue;
818
+ seen.add(pkgName);
819
+ if (isPathAlias(importPath, project.tsconfigPaths)) continue;
820
+ if (isNodeBuiltin(pkgName)) continue;
821
+ if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
822
+ if (project.declaredDependencies.has(pkgName)) continue;
823
+ findings.push({
824
+ ruleId: "hallucinated-imports",
825
+ file: file.relativePath,
826
+ line,
827
+ column: 1,
828
+ message: `Package "${pkgName}" is imported but not in package.json`,
829
+ severity: isNonProd ? "warning" : "critical",
830
+ category: "reliability"
831
+ });
832
+ }
833
+ return findings;
834
+ } catch {
835
+ }
836
+ }
647
837
  for (let i = 0; i < file.lines.length; i++) {
648
838
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
649
839
  const line = file.lines[i];
@@ -1142,6 +1332,81 @@ var unsafeHtmlRule = {
1142
1332
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1143
1333
  check(file, _project) {
1144
1334
  const findings = [];
1335
+ if (file.ast) {
1336
+ try {
1337
+ walkAST(file.ast.program, (node) => {
1338
+ if (node.type === "JSXAttribute") {
1339
+ const attr = node;
1340
+ if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
1341
+ if (attr.value && subtreeContains(attr.value, (n) => {
1342
+ if (n.type !== "CallExpression") return false;
1343
+ const call = n;
1344
+ if (call.callee.type === "MemberExpression") {
1345
+ const mem = call.callee;
1346
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1347
+ }
1348
+ return false;
1349
+ })) {
1350
+ return;
1351
+ }
1352
+ const line = attr.loc.start.line;
1353
+ findings.push({
1354
+ ruleId: "unsafe-html",
1355
+ file: file.relativePath,
1356
+ line,
1357
+ column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1358
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1359
+ severity: "critical",
1360
+ category: "security"
1361
+ });
1362
+ }
1363
+ }
1364
+ if (node.type === "ObjectProperty") {
1365
+ const prop = node;
1366
+ if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
1367
+ if (prop.value && subtreeContains(prop.value, (n) => {
1368
+ if (n.type !== "CallExpression") return false;
1369
+ const call = n;
1370
+ if (call.callee.type === "MemberExpression") {
1371
+ const mem = call.callee;
1372
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1373
+ }
1374
+ return false;
1375
+ })) {
1376
+ return;
1377
+ }
1378
+ const line = prop.loc.start.line;
1379
+ findings.push({
1380
+ ruleId: "unsafe-html",
1381
+ file: file.relativePath,
1382
+ line,
1383
+ column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1384
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1385
+ severity: "critical",
1386
+ category: "security"
1387
+ });
1388
+ }
1389
+ }
1390
+ if (node.type === "AssignmentExpression") {
1391
+ const assign = node;
1392
+ if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
1393
+ const line = assign.loc.start.line;
1394
+ findings.push({
1395
+ ruleId: "unsafe-html",
1396
+ file: file.relativePath,
1397
+ line,
1398
+ column: file.lines[line - 1].indexOf(".innerHTML") + 1,
1399
+ message: "Direct innerHTML assignment is an XSS risk",
1400
+ severity: "critical",
1401
+ category: "security"
1402
+ });
1403
+ }
1404
+ }
1405
+ });
1406
+ return findings;
1407
+ } catch {
1408
+ }
1409
+ }
1145
1410
  for (let i = 0; i < file.lines.length; i++) {
1146
1411
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1147
1412
  const line = file.lines[i];
@@ -1395,6 +1660,22 @@ var DIRECT_INPUT_PATTERNS = [
1395
1660
  var WARNING_PATTERNS = [
1396
1661
  /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1397
1662
  ];
1663
+ var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
1664
+ "url",
1665
+ "returnUrl",
1666
+ "returnTo",
1667
+ "redirectUrl",
1668
+ "redirectTo",
1669
+ "next",
1670
+ "callbackUrl",
1671
+ "destination",
1672
+ "redirect",
1673
+ "goto",
1674
+ "to",
1675
+ "target",
1676
+ "uri",
1677
+ "href"
1678
+ ]);
1398
1679
  var openRedirectRule = {
1399
1680
  id: "open-redirect",
1400
1681
  name: "Open Redirect",
@@ -1404,6 +1685,62 @@ var openRedirectRule = {
1404
1685
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1405
1686
  check(file, _project) {
1406
1687
  const findings = [];
1688
+ if (file.ast) {
1689
+ try {
1690
+ walkAST(file.ast.program, (node) => {
1691
+ if (node.type !== "CallExpression") return;
1692
+ const call = node;
1693
+ if (!call.loc) return;
1694
+ let isRedirect = false;
1695
+ if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
1696
+ isRedirect = true;
1697
+ }
1698
+ if (call.callee.type === "MemberExpression") {
1699
+ const mem = call.callee;
1700
+ if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
1701
+ isRedirect = true;
1702
+ }
1703
+ }
1704
+ if (!isRedirect) return;
1705
+ const arg = call.arguments[0];
1706
+ if (!arg) return;
1707
+ if (isStaticString(arg)) return;
1708
+ let targetArg = arg;
1709
+ if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
1710
+ targetArg = arg.arguments[0];
1711
+ if (!targetArg) return;
1712
+ if (isStaticString(targetArg)) return;
1713
+ }
1714
+ const lineNum = call.loc.start.line;
1715
+ const col = call.loc.start.column + 1;
1716
+ if (isUserInputNode(targetArg)) {
1717
+ findings.push({
1718
+ ruleId: "open-redirect",
1719
+ file: file.relativePath,
1720
+ line: lineNum,
1721
+ column: col,
1722
+ message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1723
+ severity: "warning",
1724
+ category: "security"
1725
+ });
1726
+ return;
1727
+ }
1728
+ if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
1729
+ findings.push({
1730
+ ruleId: "open-redirect",
1731
+ file: file.relativePath,
1732
+ line: lineNum,
1733
+ column: col,
1734
+ message: "Possible user input in redirect \u2014 verify the URL is validated before use",
1735
+ severity: "warning",
1736
+ category: "security"
1737
+ });
1738
+ }
1739
+ });
1740
+ return findings;
1741
+ } catch {
1742
+ }
1743
+ }
1407
1744
  for (let i = 0; i < file.lines.length; i++) {
1408
1745
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1409
1746
  const line = file.lines[i];
@@ -2028,8 +2365,14 @@ function scoreCatchBody(bodyLines) {
2028
2365
  const body = bodyLines.join("\n");
2029
2366
  let score = 0;
2030
2367
  if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
2031
- if (/console\.(error|warn)\s*\(/.test(body) && /\b(err|error|e)\b/.test(body)) score = 2;
2032
- if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body)) {
2368
+ if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
2369
+ if (/\bthrow\b/.test(body) || /\breturn\b.*(?:error|err|status|Response|NextResponse|json)/.test(body) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
2370
+ /\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
2371
+ /\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
2372
+ /\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
2373
+ /\b(handleError|reportError|logError|notifyError|showError|onError|trackError)\s*\(/.test(body) || // Express middleware: next(err)
2374
+ /\bnext\s*\(\s*(err|error|e)\s*\)/.test(body) || // Next.js notFound()
2375
+ /\bnotFound\s*\(/.test(body)) {
2033
2376
  score = 3;
2034
2377
  }
2035
2378
  const labels = {
@@ -2051,6 +2394,34 @@ var shallowCatchRule = {
2051
2394
  if (isTestFile(file.relativePath)) return [];
2052
2395
  if (isScriptFile(file.relativePath)) return [];
2053
2396
  const findings = [];
2397
+ if (file.ast) {
2398
+ try {
2399
+ walkAST(file.ast.program, (node) => {
2400
+ if (node.type !== "CatchClause") return;
2401
+ if (!node.loc) return;
2402
+ const catchLine = node.loc.start.line - 1;
2403
+ const body = node.body;
2404
+ if (!body || !body.loc) return;
2405
+ const bodyStart = body.loc.start.line - 1;
2406
+ const bodyEnd = body.loc.end.line - 1;
2407
+ const bodyLines = bodyStart === bodyEnd ? [file.lines[bodyStart]] : file.lines.slice(bodyStart + 1, bodyEnd);
2408
+ const { score, label } = scoreCatchBody(bodyLines);
2409
+ if (score <= 1) {
2410
+ findings.push({
2411
+ ruleId: "shallow-catch",
2412
+ file: file.relativePath,
2413
+ line: catchLine + 1,
2414
+ column: file.lines[catchLine].indexOf("catch") + 1,
2415
+ message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2416
+ severity: score === 0 ? "warning" : "info",
2417
+ category: "reliability"
2418
+ });
2419
+ }
2420
+ });
2421
+ return findings;
2422
+ } catch {
2423
+ }
2424
+ }
2054
2425
  for (let i = 0; i < file.lines.length; i++) {
2055
2426
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
2056
2427
  const trimmed = file.lines[i].trim();
@@ -2384,6 +2755,7 @@ var insecureCookieRule = {
2384
2755
 
2385
2756
  // src/rules/leaked-env-in-logs.ts
2386
2757
  var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2758
+ var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
2387
2759
  var leakedEnvInLogsRule = {
2388
2760
  id: "leaked-env-in-logs",
2389
2761
  name: "Leaked Env in Logs",
@@ -2395,6 +2767,48 @@ var leakedEnvInLogsRule = {
2395
2767
  if (isTestFile(file.relativePath)) return [];
2396
2768
  if (isScriptFile(file.relativePath)) return [];
2397
2769
  const findings = [];
2770
+ if (file.ast) {
2771
+ try {
2772
+ walkAST(file.ast.program, (node) => {
2773
+ if (node.type !== "CallExpression") return;
2774
+ const call = node;
2775
+ if (!call.loc) return;
2776
+ if (call.callee.type !== "MemberExpression") return;
2777
+ const mem = call.callee;
2778
+ if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
2779
+ if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
2780
+ let hasEnvAccess = false;
2781
+ for (const arg of call.arguments) {
2782
+ if (subtreeContains(arg, (n) => {
2783
+ if (n.type !== "MemberExpression") return false;
2784
+ const m = n;
2785
+ if (m.object.type === "MemberExpression") {
2786
+ const inner = m.object;
2787
+ return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
2788
+ }
2789
+ return false;
2790
+ })) {
2791
+ hasEnvAccess = true;
2792
+ break;
2793
+ }
2794
+ }
2795
+ if (hasEnvAccess) {
2796
+ findings.push({
2797
+ ruleId: "leaked-env-in-logs",
2798
+ file: file.relativePath,
2799
+ line: call.loc.start.line,
2800
+ column: call.loc.start.column + 1,
2801
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2802
+ severity: "warning",
2803
+ category: "security",
2804
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2805
+ });
2806
+ }
2807
+ });
2808
+ return findings;
2809
+ } catch {
2810
+ }
2811
+ }
2398
2812
  for (let i = 0; i < file.lines.length; i++) {
2399
2813
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
2400
2814
  const line = file.lines[i];
@@ -2499,6 +2913,15 @@ var nextServerActionValidationRule = {
2499
2913
  // src/rules/missing-transaction.ts
2500
2914
  var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2501
2915
  var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2916
+ var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
2917
+ "create",
2918
+ "update",
2919
+ "delete",
2920
+ "upsert",
2921
+ "createMany",
2922
+ "updateMany",
2923
+ "deleteMany"
2924
+ ]);
2502
2925
  var missingTransactionRule = {
2503
2926
  id: "missing-transaction",
2504
2927
  name: "Missing Transaction",
@@ -2510,6 +2933,60 @@ var missingTransactionRule = {
2510
2933
  if (isTestFile(file.relativePath)) return [];
2511
2934
  if (isScriptFile(file.relativePath)) return [];
2512
2935
  if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2936
+ if (file.ast) {
2937
+ try {
2938
+ const parentMap = /* @__PURE__ */ new Map();
2939
+ walkAST(file.ast.program, (node, parent) => {
2940
+ if (parent) parentMap.set(node, parent);
2941
+ });
2942
+ const writesByScope = /* @__PURE__ */ new Map();
2943
+ const transactionScopes = /* @__PURE__ */ new Set();
2944
+ walkAST(file.ast.program, (node) => {
2945
+ if (node.type !== "CallExpression") return;
2946
+ const call = node;
2947
+ if (!call.loc) return;
2948
+ if (call.callee.type === "MemberExpression") {
2949
+ const mem = call.callee;
2950
+ if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
2951
+ const scope2 = findEnclosingFunction(node, parentMap);
2952
+ transactionScopes.add(scope2);
2953
+ return;
2954
+ }
2955
+ }
2956
+ if (call.callee.type !== "MemberExpression") return;
2957
+ const outerMem = call.callee;
2958
+ if (outerMem.property.type !== "Identifier") return;
2959
+ if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
2960
+ if (outerMem.object.type !== "MemberExpression") return;
2961
+ const innerMem = outerMem.object;
2962
+ if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
2963
+ const scope = findEnclosingFunction(node, parentMap);
2964
+ const existing = writesByScope.get(scope);
2965
+ if (existing) {
2966
+ existing.count++;
2967
+ } else {
2968
+ writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
2969
+ }
2970
+ });
2971
+ const findings = [];
2972
+ for (const [scope, { count, firstLine }] of writesByScope) {
2973
+ if (count < 2) continue;
2974
+ if (transactionScopes.has(scope)) continue;
2975
+ findings.push({
2976
+ ruleId: "missing-transaction",
2977
+ file: file.relativePath,
2978
+ line: firstLine,
2979
+ column: 1,
2980
+ message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2981
+ severity: "warning",
2982
+ category: "reliability",
2983
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2984
+ });
2985
+ }
2986
+ return findings;
2987
+ } catch {
2988
+ }
2989
+ }
2513
2990
  let writeCount = 0;
2514
2991
  let firstWriteLine = -1;
2515
2992
  const hasTransaction = PRISMA_TRANSACTION.test(file.content);
@@ -2533,6 +3010,16 @@ var missingTransactionRule = {
2533
3010
  }];
2534
3011
  }
2535
3012
  };
3013
+ function findEnclosingFunction(node, parentMap) {
3014
+ let current = parentMap.get(node);
3015
+ while (current) {
3016
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
3017
+ return current;
3018
+ }
3019
+ current = parentMap.get(current);
3020
+ }
3021
+ return null;
3022
+ }
2536
3023
 
2537
3024
  // src/rules/redirect-in-try-catch.ts
2538
3025
  var redirectInTryCatchRule = {
@@ -3099,6 +3586,14 @@ var VALIDATION_PATTERNS3 = [
3099
3586
  /\.startsWith\s*\(\s*['"]https?:\/\//,
3100
3587
  /\.hostname\s*[!=]==?\s*['"`]/
3101
3588
  ];
3589
+ var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
3590
+ "url",
3591
+ "href",
3592
+ "endpoint",
3593
+ "target",
3594
+ "link",
3595
+ "src"
3596
+ ]);
3102
3597
  var ssrfRiskRule = {
3103
3598
  id: "ssrf-risk",
3104
3599
  name: "SSRF Risk",
@@ -3109,9 +3604,63 @@ var ssrfRiskRule = {
3109
3604
  check(file, _project) {
3110
3605
  if (isTestFile(file.relativePath)) return [];
3111
3606
  if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
3607
+ const findings = [];
3608
+ if (file.ast) {
3609
+ try {
3610
+ const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3611
+ walkAST(file.ast.program, (node) => {
3612
+ if (node.type !== "CallExpression") return;
3613
+ const call = node;
3614
+ if (!call.loc) return;
3615
+ let isFetchLike = false;
3616
+ if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
3617
+ isFetchLike = true;
3618
+ }
3619
+ if (call.callee.type === "MemberExpression") {
3620
+ const mem = call.callee;
3621
+ if (mem.object.type === "Identifier" && mem.object.name === "axios") {
3622
+ isFetchLike = true;
3623
+ }
3624
+ }
3625
+ if (!isFetchLike) return;
3626
+ const arg = call.arguments[0];
3627
+ if (!arg) return;
3628
+ if (isStaticString(arg)) return;
3629
+ const lineNum = call.loc.start.line;
3630
+ const col = call.loc.start.column + 1;
3631
+ if (isUserInputNode(arg)) {
3632
+ findings.push({
3633
+ ruleId: "ssrf-risk",
3634
+ file: file.relativePath,
3635
+ line: lineNum,
3636
+ column: col,
3637
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3638
+ severity: "warning",
3639
+ category: "security",
3640
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3641
+ });
3642
+ return;
3643
+ }
3644
+ if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
3645
+ if (hasValidationInCode) return;
3646
+ findings.push({
3647
+ ruleId: "ssrf-risk",
3648
+ file: file.relativePath,
3649
+ line: lineNum,
3650
+ column: col,
3651
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3652
+ severity: "warning",
3653
+ category: "security",
3654
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3655
+ });
3656
+ }
3657
+ });
3658
+ return findings;
3659
+ } catch {
3660
+ }
3661
+ }
3112
3662
  const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3113
3663
  if (hasValidation) return [];
3114
- const findings = [];
3115
3664
  for (let i = 0; i < file.lines.length; i++) {
3116
3665
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3117
3666
  const line = file.lines[i];
@@ -3153,6 +3702,25 @@ var SANITIZATION_PATTERNS = [
3153
3702
  /sanitize/i,
3154
3703
  /realpath/
3155
3704
  ];
3705
+ var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
3706
+ "readFile",
3707
+ "readFileSync",
3708
+ "createReadStream",
3709
+ "writeFile",
3710
+ "writeFileSync",
3711
+ "createWriteStream",
3712
+ "unlink",
3713
+ "unlinkSync",
3714
+ "rm",
3715
+ "rmSync"
3716
+ ]);
3717
+ var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
3718
+ "filePath",
3719
+ "fileName",
3720
+ "path",
3721
+ "file",
3722
+ "name"
3723
+ ]);
3156
3724
  var pathTraversalRule = {
3157
3725
  id: "path-traversal",
3158
3726
  name: "Path Traversal",
@@ -3162,9 +3730,73 @@ var pathTraversalRule = {
3162
3730
  fileExtensions: ["ts", "tsx", "js", "jsx"],
3163
3731
  check(file, _project) {
3164
3732
  if (isTestFile(file.relativePath)) return [];
3733
+ const findings = [];
3734
+ if (file.ast) {
3735
+ try {
3736
+ walkAST(file.ast.program, (node) => {
3737
+ if (node.type !== "CallExpression") return;
3738
+ const call = node;
3739
+ if (!call.loc) return;
3740
+ let fnName = null;
3741
+ if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
3742
+ fnName = call.callee.name;
3743
+ }
3744
+ if (call.callee.type === "MemberExpression") {
3745
+ const mem = call.callee;
3746
+ if (mem.property.type === "Identifier") {
3747
+ if (FS_FUNCTION_NAMES.has(mem.property.name)) {
3748
+ fnName = mem.property.name;
3749
+ }
3750
+ if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
3751
+ fnName = "path.join";
3752
+ }
3753
+ if (mem.property.name === "sendFile") {
3754
+ fnName = "sendFile";
3755
+ }
3756
+ }
3757
+ }
3758
+ if (!fnName) return;
3759
+ const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
3760
+ for (const arg of argsToCheck) {
3761
+ if (!arg) continue;
3762
+ if (isStaticString(arg)) continue;
3763
+ const lineNum = call.loc.start.line;
3764
+ const col = call.loc.start.column + 1;
3765
+ if (isUserInputNode(arg)) {
3766
+ const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
3767
+ findings.push({
3768
+ ruleId: "path-traversal",
3769
+ file: file.relativePath,
3770
+ line: lineNum,
3771
+ column: col,
3772
+ 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`,
3773
+ severity: "critical",
3774
+ category: "security",
3775
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3776
+ });
3777
+ return;
3778
+ }
3779
+ if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
3780
+ findings.push({
3781
+ ruleId: "path-traversal",
3782
+ file: file.relativePath,
3783
+ line: lineNum,
3784
+ column: col,
3785
+ message: `File operation with potentially user-controlled path \u2014 validate before use`,
3786
+ severity: "warning",
3787
+ category: "security",
3788
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3789
+ });
3790
+ return;
3791
+ }
3792
+ }
3793
+ });
3794
+ return findings;
3795
+ } catch {
3796
+ }
3797
+ }
3165
3798
  const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
3166
3799
  if (hasSanitization) return [];
3167
- const findings = [];
3168
3800
  for (let i = 0; i < file.lines.length; i++) {
3169
3801
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3170
3802
  const line = file.lines[i];
@@ -3216,18 +3848,31 @@ var hydrationMismatchRule = {
3216
3848
  if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3217
3849
  if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3218
3850
  const findings = [];
3851
+ let useEffectRanges = [];
3852
+ if (file.ast) {
3853
+ try {
3854
+ useEffectRanges = findUseEffectRanges(file.ast);
3855
+ } catch {
3856
+ }
3857
+ }
3858
+ const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
3219
3859
  let insideUseEffect = false;
3220
3860
  for (let i = 0; i < file.lines.length; i++) {
3221
3861
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3222
3862
  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;
3863
+ if (hasAstRanges) {
3864
+ const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
3865
+ if (inEffect) continue;
3866
+ } else {
3867
+ if (/\buseEffect\s*\(/.test(line)) {
3868
+ insideUseEffect = true;
3869
+ }
3870
+ if (insideUseEffect) {
3871
+ if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
3872
+ insideUseEffect = false;
3873
+ }
3874
+ continue;
3229
3875
  }
3230
- continue;
3231
3876
  }
3232
3877
  for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
3233
3878
  const match = pattern.exec(line);
@@ -3452,6 +4097,7 @@ var HAS_EXPIRY = [
3452
4097
  /expirationTime/,
3453
4098
  /maxAge/
3454
4099
  ];
4100
+ var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
3455
4101
  var jwtNoExpiryRule = {
3456
4102
  id: "jwt-no-expiry",
3457
4103
  name: "JWT Without Expiration",
@@ -3463,6 +4109,55 @@ var jwtNoExpiryRule = {
3463
4109
  if (isTestFile(file.relativePath)) return [];
3464
4110
  if (!JWT_SIGN.test(file.content)) return [];
3465
4111
  const findings = [];
4112
+ if (file.ast) {
4113
+ try {
4114
+ walkAST(file.ast.program, (node) => {
4115
+ if (node.type !== "CallExpression") return;
4116
+ const call = node;
4117
+ if (!call.loc) return;
4118
+ if (call.callee.type !== "MemberExpression") return;
4119
+ const mem = call.callee;
4120
+ if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
4121
+ if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
4122
+ let hasExpiry = false;
4123
+ for (const arg of call.arguments) {
4124
+ if (arg.type === "ObjectExpression") {
4125
+ const obj = arg;
4126
+ for (const prop of obj.properties) {
4127
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
4128
+ hasExpiry = true;
4129
+ break;
4130
+ }
4131
+ }
4132
+ }
4133
+ if (hasExpiry) break;
4134
+ }
4135
+ if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
4136
+ const payload = call.arguments[0];
4137
+ for (const prop of payload.properties) {
4138
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
4139
+ hasExpiry = true;
4140
+ break;
4141
+ }
4142
+ }
4143
+ }
4144
+ if (!hasExpiry) {
4145
+ findings.push({
4146
+ ruleId: "jwt-no-expiry",
4147
+ file: file.relativePath,
4148
+ line: call.loc.start.line,
4149
+ column: call.loc.start.column + 1,
4150
+ message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
4151
+ severity: "warning",
4152
+ category: "security",
4153
+ fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
4154
+ });
4155
+ }
4156
+ });
4157
+ return findings;
4158
+ } catch {
4159
+ }
4160
+ }
3466
4161
  for (let i = 0; i < file.lines.length; i++) {
3467
4162
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3468
4163
  const line = file.lines[i];