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/mcp.js CHANGED
@@ -80,7 +80,10 @@ function isTestFile(relativePath) {
80
80
  return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || /(?:^|\/)__tests__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
81
81
  }
82
82
  function isScriptFile(relativePath) {
83
- return /(?:^|\/)scripts?\//.test(relativePath);
83
+ if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
84
+ const name = relativePath.split("/").pop() ?? "";
85
+ const base = name.replace(/\.[^.]+$/, "");
86
+ return /^(seed|migrate|setup|bootstrap|generate|codegen|sync|deploy|cleanup|reset)$/.test(base);
84
87
  }
85
88
  function isConfigFile(relativePath) {
86
89
  const name = relativePath.split("/").pop() ?? "";
@@ -249,6 +252,100 @@ function findLoopsAST(ast) {
249
252
  });
250
253
  return loops;
251
254
  }
255
+ function getImportSources(ast) {
256
+ const sources = [];
257
+ walkAST(ast.program, (node) => {
258
+ if (node.type === "ImportDeclaration") {
259
+ const decl = node;
260
+ sources.push({ source: decl.source.value, line: decl.loc?.start.line ?? 1 });
261
+ }
262
+ if (node.type === "CallExpression") {
263
+ const call = node;
264
+ if (call.callee.type === "Identifier" && call.callee.name === "require" && call.arguments.length === 1 && call.arguments[0].type === "StringLiteral") {
265
+ sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
266
+ }
267
+ if (call.callee.type === "Import" && call.arguments.length >= 1 && call.arguments[0].type === "StringLiteral") {
268
+ sources.push({ source: call.arguments[0].value, line: node.loc?.start.line ?? 1 });
269
+ }
270
+ }
271
+ });
272
+ return sources;
273
+ }
274
+ function isUserInputNode(node) {
275
+ if (node.type === "MemberExpression") {
276
+ const mem = node;
277
+ if (mem.object.type === "MemberExpression") {
278
+ const inner = mem.object;
279
+ if (inner.object.type === "Identifier") {
280
+ const objName = inner.object.name;
281
+ if (objName === "req" || objName === "request") {
282
+ if (inner.property.type === "Identifier") {
283
+ const prop = inner.property.name;
284
+ if (prop === "query" || prop === "body" || prop === "params") return true;
285
+ }
286
+ }
287
+ }
288
+ }
289
+ }
290
+ if (node.type === "CallExpression") {
291
+ const call = node;
292
+ if (call.callee.type === "MemberExpression") {
293
+ const callee = call.callee;
294
+ if (callee.property.type === "Identifier" && callee.property.name === "get") {
295
+ if (callee.object.type === "Identifier") {
296
+ const name = callee.object.name;
297
+ if (name === "searchParams" || name === "formData") return true;
298
+ }
299
+ if (callee.object.type === "MemberExpression") {
300
+ const inner = callee.object;
301
+ if (inner.property.type === "Identifier" && inner.property.name === "searchParams") {
302
+ return true;
303
+ }
304
+ }
305
+ }
306
+ }
307
+ }
308
+ return false;
309
+ }
310
+ function isStaticString(node) {
311
+ if (node.type === "StringLiteral") return true;
312
+ if (node.type === "TemplateLiteral") {
313
+ return node.expressions.length === 0;
314
+ }
315
+ return false;
316
+ }
317
+ function findUseEffectRanges(ast) {
318
+ const ranges = [];
319
+ walkAST(ast.program, (node) => {
320
+ if (node.type !== "CallExpression") return;
321
+ const call = node;
322
+ if (call.callee.type !== "Identifier" || call.callee.name !== "useEffect") return;
323
+ const callback = call.arguments[0];
324
+ if (!callback || !callback.loc) return;
325
+ ranges.push({
326
+ start: callback.loc.start.line - 1,
327
+ end: callback.loc.end.line - 1
328
+ });
329
+ });
330
+ return ranges;
331
+ }
332
+ function subtreeContains(node, predicate) {
333
+ if (predicate(node)) return true;
334
+ for (const key of Object.keys(node)) {
335
+ if (key === "start" || key === "end" || key === "loc" || key === "type") continue;
336
+ const val = node[key];
337
+ if (Array.isArray(val)) {
338
+ for (const item of val) {
339
+ if (item && typeof item === "object" && item.type) {
340
+ if (subtreeContains(item, predicate)) return true;
341
+ }
342
+ }
343
+ } else if (val && typeof val === "object" && val.type) {
344
+ if (subtreeContains(val, predicate)) return true;
345
+ }
346
+ }
347
+ return false;
348
+ }
252
349
 
253
350
  // src/utils/frameworks.ts
254
351
  var FRAMEWORK_SAFE_METHODS = {
@@ -368,6 +465,66 @@ async function readFileContext(root, relativePath) {
368
465
  return null;
369
466
  }
370
467
  }
468
+ async function getWorkspacePatterns(root, packageJson) {
469
+ const patterns = [];
470
+ if (packageJson) {
471
+ const workspaces = packageJson.workspaces;
472
+ if (Array.isArray(workspaces)) {
473
+ patterns.push(...workspaces);
474
+ } else if (workspaces && typeof workspaces === "object" && Array.isArray(workspaces.packages)) {
475
+ patterns.push(...workspaces.packages);
476
+ }
477
+ }
478
+ try {
479
+ const raw = await readFile(resolve(root, "pnpm-workspace.yaml"), "utf-8");
480
+ let inPackages = false;
481
+ for (const line of raw.split(/\r?\n/)) {
482
+ const trimmed = line.trim();
483
+ if (/^packages\s*:/.test(trimmed)) {
484
+ inPackages = true;
485
+ continue;
486
+ }
487
+ if (inPackages) {
488
+ if (/^-\s+/.test(trimmed)) {
489
+ const glob = trimmed.replace(/^-\s+/, "").replace(/^['"]|['"]$/g, "");
490
+ if (glob) patterns.push(glob);
491
+ } else if (trimmed && !trimmed.startsWith("#")) {
492
+ break;
493
+ }
494
+ }
495
+ }
496
+ } catch {
497
+ }
498
+ return patterns;
499
+ }
500
+ async function collectWorkspaceDependencies(root, patterns) {
501
+ const deps = /* @__PURE__ */ new Set();
502
+ const globPatterns = patterns.map((p) => `${p}/package.json`);
503
+ try {
504
+ const pkgFiles = await fg(globPatterns, {
505
+ cwd: root,
506
+ absolute: false,
507
+ ignore: ["**/node_modules/**"]
508
+ });
509
+ for (const pkgFile of pkgFiles) {
510
+ try {
511
+ const raw = await readFile(resolve(root, pkgFile), "utf-8");
512
+ const pkg = JSON.parse(raw);
513
+ if (pkg.name) deps.add(pkg.name);
514
+ for (const key of ["dependencies", "devDependencies", "peerDependencies"]) {
515
+ if (pkg[key] && typeof pkg[key] === "object") {
516
+ for (const dep of Object.keys(pkg[key])) {
517
+ deps.add(dep);
518
+ }
519
+ }
520
+ }
521
+ } catch {
522
+ }
523
+ }
524
+ } catch {
525
+ }
526
+ return deps;
527
+ }
371
528
  async function buildProjectContext(root, files) {
372
529
  let packageJson = null;
373
530
  let declaredDependencies = /* @__PURE__ */ new Set();
@@ -386,19 +543,26 @@ async function buildProjectContext(root, files) {
386
543
  ...packageJson?.peerDependencies ?? {}
387
544
  };
388
545
  declaredDependencies = new Set(Object.keys(deps));
389
- for (const dep of declaredDependencies) {
390
- const framework = DEPENDENCY_TO_FRAMEWORK[dep];
391
- if (framework) {
392
- detectedFrameworks.add(framework);
393
- }
546
+ } catch {
547
+ }
548
+ const workspacePatterns = await getWorkspacePatterns(root, packageJson);
549
+ if (workspacePatterns.length > 0) {
550
+ const workspaceDeps = await collectWorkspaceDependencies(root, workspacePatterns);
551
+ for (const dep of workspaceDeps) {
552
+ declaredDependencies.add(dep);
394
553
  }
395
- for (const framework of detectedFrameworks) {
396
- if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
397
- hasRateLimiting = true;
398
- break;
399
- }
554
+ }
555
+ for (const dep of declaredDependencies) {
556
+ const framework = DEPENDENCY_TO_FRAMEWORK[dep];
557
+ if (framework) {
558
+ detectedFrameworks.add(framework);
559
+ }
560
+ }
561
+ for (const framework of detectedFrameworks) {
562
+ if (RATE_LIMIT_FRAMEWORKS.has(framework)) {
563
+ hasRateLimiting = true;
564
+ break;
400
565
  }
401
- } catch {
402
566
  }
403
567
  try {
404
568
  const raw = await readFile(resolve(root, "tsconfig.json"), "utf-8");
@@ -653,6 +817,32 @@ var hallucinatedImportsRule = {
653
817
  const findings = [];
654
818
  const seen = /* @__PURE__ */ new Set();
655
819
  const isNonProd = isTestFile(file.relativePath) || isScriptFile(file.relativePath);
820
+ if (file.ast) {
821
+ try {
822
+ const imports = getImportSources(file.ast);
823
+ for (const { source: importPath, line } of imports) {
824
+ if (importPath.startsWith(".") || importPath.startsWith("/")) continue;
825
+ const pkgName = getPackageName(importPath);
826
+ if (seen.has(pkgName)) continue;
827
+ seen.add(pkgName);
828
+ if (isPathAlias(importPath, project.tsconfigPaths)) continue;
829
+ if (isNodeBuiltin(pkgName)) continue;
830
+ if (IMPLICIT_PACKAGES.has(importPath) || IMPLICIT_PACKAGES.has(pkgName)) continue;
831
+ if (project.declaredDependencies.has(pkgName)) continue;
832
+ findings.push({
833
+ ruleId: "hallucinated-imports",
834
+ file: file.relativePath,
835
+ line,
836
+ column: 1,
837
+ message: `Package "${pkgName}" is imported but not in package.json`,
838
+ severity: isNonProd ? "warning" : "critical",
839
+ category: "reliability"
840
+ });
841
+ }
842
+ return findings;
843
+ } catch {
844
+ }
845
+ }
656
846
  for (let i = 0; i < file.lines.length; i++) {
657
847
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
658
848
  const line = file.lines[i];
@@ -1151,6 +1341,81 @@ var unsafeHtmlRule = {
1151
1341
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1152
1342
  check(file, _project) {
1153
1343
  const findings = [];
1344
+ if (file.ast) {
1345
+ try {
1346
+ walkAST(file.ast.program, (node) => {
1347
+ if (node.type === "JSXAttribute") {
1348
+ const attr = node;
1349
+ if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
1350
+ if (attr.value && subtreeContains(attr.value, (n) => {
1351
+ if (n.type !== "CallExpression") return false;
1352
+ const call = n;
1353
+ if (call.callee.type === "MemberExpression") {
1354
+ const mem = call.callee;
1355
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1356
+ }
1357
+ return false;
1358
+ })) {
1359
+ return;
1360
+ }
1361
+ const line = attr.loc.start.line;
1362
+ findings.push({
1363
+ ruleId: "unsafe-html",
1364
+ file: file.relativePath,
1365
+ line,
1366
+ column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1367
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1368
+ severity: "critical",
1369
+ category: "security"
1370
+ });
1371
+ }
1372
+ }
1373
+ if (node.type === "ObjectProperty") {
1374
+ const prop = node;
1375
+ if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
1376
+ if (prop.value && subtreeContains(prop.value, (n) => {
1377
+ if (n.type !== "CallExpression") return false;
1378
+ const call = n;
1379
+ if (call.callee.type === "MemberExpression") {
1380
+ const mem = call.callee;
1381
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1382
+ }
1383
+ return false;
1384
+ })) {
1385
+ return;
1386
+ }
1387
+ const line = prop.loc.start.line;
1388
+ findings.push({
1389
+ ruleId: "unsafe-html",
1390
+ file: file.relativePath,
1391
+ line,
1392
+ column: file.lines[line - 1].indexOf("dangerouslySetInnerHTML") + 1,
1393
+ message: "dangerouslySetInnerHTML is an XSS risk \u2014 sanitize with DOMPurify or similar",
1394
+ severity: "critical",
1395
+ category: "security"
1396
+ });
1397
+ }
1398
+ }
1399
+ if (node.type === "AssignmentExpression") {
1400
+ const assign = node;
1401
+ if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
1402
+ const line = assign.loc.start.line;
1403
+ findings.push({
1404
+ ruleId: "unsafe-html",
1405
+ file: file.relativePath,
1406
+ line,
1407
+ column: file.lines[line - 1].indexOf(".innerHTML") + 1,
1408
+ message: "Direct innerHTML assignment is an XSS risk",
1409
+ severity: "critical",
1410
+ category: "security"
1411
+ });
1412
+ }
1413
+ }
1414
+ });
1415
+ return findings;
1416
+ } catch {
1417
+ }
1418
+ }
1154
1419
  for (let i = 0; i < file.lines.length; i++) {
1155
1420
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1156
1421
  const line = file.lines[i];
@@ -1404,6 +1669,22 @@ var DIRECT_INPUT_PATTERNS = [
1404
1669
  var WARNING_PATTERNS = [
1405
1670
  /redirect\s*\(\s*(?:url|returnUrl|returnTo|redirectUrl|redirectTo|next|callbackUrl|destination|redirect|goto|to|target|uri|href)\s*[,)]/
1406
1671
  ];
1672
+ var SUSPICIOUS_VAR_NAMES = /* @__PURE__ */ new Set([
1673
+ "url",
1674
+ "returnUrl",
1675
+ "returnTo",
1676
+ "redirectUrl",
1677
+ "redirectTo",
1678
+ "next",
1679
+ "callbackUrl",
1680
+ "destination",
1681
+ "redirect",
1682
+ "goto",
1683
+ "to",
1684
+ "target",
1685
+ "uri",
1686
+ "href"
1687
+ ]);
1407
1688
  var openRedirectRule = {
1408
1689
  id: "open-redirect",
1409
1690
  name: "Open Redirect",
@@ -1413,6 +1694,62 @@ var openRedirectRule = {
1413
1694
  fileExtensions: ["ts", "tsx", "js", "jsx"],
1414
1695
  check(file, _project) {
1415
1696
  const findings = [];
1697
+ if (file.ast) {
1698
+ try {
1699
+ walkAST(file.ast.program, (node) => {
1700
+ if (node.type !== "CallExpression") return;
1701
+ const call = node;
1702
+ if (!call.loc) return;
1703
+ let isRedirect = false;
1704
+ if (call.callee.type === "Identifier" && call.callee.name === "redirect") {
1705
+ isRedirect = true;
1706
+ }
1707
+ if (call.callee.type === "MemberExpression") {
1708
+ const mem = call.callee;
1709
+ if (mem.object.type === "Identifier" && mem.object.name === "NextResponse" && mem.property.type === "Identifier" && mem.property.name === "redirect") {
1710
+ isRedirect = true;
1711
+ }
1712
+ }
1713
+ if (!isRedirect) return;
1714
+ const arg = call.arguments[0];
1715
+ if (!arg) return;
1716
+ if (isStaticString(arg)) return;
1717
+ let targetArg = arg;
1718
+ if (arg.type === "NewExpression" && arg.callee.type === "Identifier" && arg.callee.name === "URL") {
1719
+ targetArg = arg.arguments[0];
1720
+ if (!targetArg) return;
1721
+ if (isStaticString(targetArg)) return;
1722
+ }
1723
+ const lineNum = call.loc.start.line;
1724
+ const col = call.loc.start.column + 1;
1725
+ if (isUserInputNode(targetArg)) {
1726
+ findings.push({
1727
+ ruleId: "open-redirect",
1728
+ file: file.relativePath,
1729
+ line: lineNum,
1730
+ column: col,
1731
+ message: "User input in redirect \u2014 validate against an allowlist to prevent open redirect",
1732
+ severity: "warning",
1733
+ category: "security"
1734
+ });
1735
+ return;
1736
+ }
1737
+ if (targetArg.type === "Identifier" && SUSPICIOUS_VAR_NAMES.has(targetArg.name)) {
1738
+ findings.push({
1739
+ ruleId: "open-redirect",
1740
+ file: file.relativePath,
1741
+ line: lineNum,
1742
+ column: col,
1743
+ message: "Possible user input in redirect \u2014 verify the URL is validated before use",
1744
+ severity: "warning",
1745
+ category: "security"
1746
+ });
1747
+ }
1748
+ });
1749
+ return findings;
1750
+ } catch {
1751
+ }
1752
+ }
1416
1753
  for (let i = 0; i < file.lines.length; i++) {
1417
1754
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1418
1755
  const line = file.lines[i];
@@ -2037,8 +2374,14 @@ function scoreCatchBody(bodyLines) {
2037
2374
  const body = bodyLines.join("\n");
2038
2375
  let score = 0;
2039
2376
  if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
2040
- if (/console\.(error|warn)\s*\(/.test(body) && /\b(err|error|e)\b/.test(body)) score = 2;
2041
- 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)) {
2377
+ if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
2378
+ 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)
2379
+ /\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
2380
+ /\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
2381
+ /\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
2382
+ /\b(handleError|reportError|logError|notifyError|showError|onError|trackError)\s*\(/.test(body) || // Express middleware: next(err)
2383
+ /\bnext\s*\(\s*(err|error|e)\s*\)/.test(body) || // Next.js notFound()
2384
+ /\bnotFound\s*\(/.test(body)) {
2042
2385
  score = 3;
2043
2386
  }
2044
2387
  const labels = {
@@ -2060,6 +2403,34 @@ var shallowCatchRule = {
2060
2403
  if (isTestFile(file.relativePath)) return [];
2061
2404
  if (isScriptFile(file.relativePath)) return [];
2062
2405
  const findings = [];
2406
+ if (file.ast) {
2407
+ try {
2408
+ walkAST(file.ast.program, (node) => {
2409
+ if (node.type !== "CatchClause") return;
2410
+ if (!node.loc) return;
2411
+ const catchLine = node.loc.start.line - 1;
2412
+ const body = node.body;
2413
+ if (!body || !body.loc) return;
2414
+ const bodyStart = body.loc.start.line - 1;
2415
+ const bodyEnd = body.loc.end.line - 1;
2416
+ const bodyLines = bodyStart === bodyEnd ? [file.lines[bodyStart]] : file.lines.slice(bodyStart + 1, bodyEnd);
2417
+ const { score, label } = scoreCatchBody(bodyLines);
2418
+ if (score <= 1) {
2419
+ findings.push({
2420
+ ruleId: "shallow-catch",
2421
+ file: file.relativePath,
2422
+ line: catchLine + 1,
2423
+ column: file.lines[catchLine].indexOf("catch") + 1,
2424
+ message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
2425
+ severity: score === 0 ? "warning" : "info",
2426
+ category: "reliability"
2427
+ });
2428
+ }
2429
+ });
2430
+ return findings;
2431
+ } catch {
2432
+ }
2433
+ }
2063
2434
  for (let i = 0; i < file.lines.length; i++) {
2064
2435
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
2065
2436
  const trimmed = file.lines[i].trim();
@@ -2393,6 +2764,7 @@ var insecureCookieRule = {
2393
2764
 
2394
2765
  // src/rules/leaked-env-in-logs.ts
2395
2766
  var CONSOLE_WITH_ENV = /console\.(log|warn|error|info|debug)\s*\([^)]*process\.env\./;
2767
+ var CONSOLE_METHODS = /* @__PURE__ */ new Set(["log", "warn", "error", "info", "debug"]);
2396
2768
  var leakedEnvInLogsRule = {
2397
2769
  id: "leaked-env-in-logs",
2398
2770
  name: "Leaked Env in Logs",
@@ -2404,6 +2776,48 @@ var leakedEnvInLogsRule = {
2404
2776
  if (isTestFile(file.relativePath)) return [];
2405
2777
  if (isScriptFile(file.relativePath)) return [];
2406
2778
  const findings = [];
2779
+ if (file.ast) {
2780
+ try {
2781
+ walkAST(file.ast.program, (node) => {
2782
+ if (node.type !== "CallExpression") return;
2783
+ const call = node;
2784
+ if (!call.loc) return;
2785
+ if (call.callee.type !== "MemberExpression") return;
2786
+ const mem = call.callee;
2787
+ if (mem.object.type !== "Identifier" || mem.object.name !== "console") return;
2788
+ if (mem.property.type !== "Identifier" || !CONSOLE_METHODS.has(mem.property.name)) return;
2789
+ let hasEnvAccess = false;
2790
+ for (const arg of call.arguments) {
2791
+ if (subtreeContains(arg, (n) => {
2792
+ if (n.type !== "MemberExpression") return false;
2793
+ const m = n;
2794
+ if (m.object.type === "MemberExpression") {
2795
+ const inner = m.object;
2796
+ return inner.object.type === "Identifier" && inner.object.name === "process" && inner.property.type === "Identifier" && inner.property.name === "env";
2797
+ }
2798
+ return false;
2799
+ })) {
2800
+ hasEnvAccess = true;
2801
+ break;
2802
+ }
2803
+ }
2804
+ if (hasEnvAccess) {
2805
+ findings.push({
2806
+ ruleId: "leaked-env-in-logs",
2807
+ file: file.relativePath,
2808
+ line: call.loc.start.line,
2809
+ column: call.loc.start.column + 1,
2810
+ message: "process.env value in console output \u2014 may leak secrets in production logs",
2811
+ severity: "warning",
2812
+ category: "security",
2813
+ fix: "Remove process.env.* from console output \u2014 log a redacted summary instead"
2814
+ });
2815
+ }
2816
+ });
2817
+ return findings;
2818
+ } catch {
2819
+ }
2820
+ }
2407
2821
  for (let i = 0; i < file.lines.length; i++) {
2408
2822
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
2409
2823
  const line = file.lines[i];
@@ -2508,6 +2922,15 @@ var nextServerActionValidationRule = {
2508
2922
  // src/rules/missing-transaction.ts
2509
2923
  var PRISMA_WRITE_OPS = /prisma\.\w+\.(?:create|update|delete|upsert|createMany|updateMany|deleteMany)\s*\(/;
2510
2924
  var PRISMA_TRANSACTION = /\$transaction\s*\(/;
2925
+ var PRISMA_WRITE_METHODS = /* @__PURE__ */ new Set([
2926
+ "create",
2927
+ "update",
2928
+ "delete",
2929
+ "upsert",
2930
+ "createMany",
2931
+ "updateMany",
2932
+ "deleteMany"
2933
+ ]);
2511
2934
  var missingTransactionRule = {
2512
2935
  id: "missing-transaction",
2513
2936
  name: "Missing Transaction",
@@ -2519,6 +2942,60 @@ var missingTransactionRule = {
2519
2942
  if (isTestFile(file.relativePath)) return [];
2520
2943
  if (isScriptFile(file.relativePath)) return [];
2521
2944
  if (!project.declaredDependencies.has("@prisma/client") && !project.detectedFrameworks.has("prisma")) return [];
2945
+ if (file.ast) {
2946
+ try {
2947
+ const parentMap = /* @__PURE__ */ new Map();
2948
+ walkAST(file.ast.program, (node, parent) => {
2949
+ if (parent) parentMap.set(node, parent);
2950
+ });
2951
+ const writesByScope = /* @__PURE__ */ new Map();
2952
+ const transactionScopes = /* @__PURE__ */ new Set();
2953
+ walkAST(file.ast.program, (node) => {
2954
+ if (node.type !== "CallExpression") return;
2955
+ const call = node;
2956
+ if (!call.loc) return;
2957
+ if (call.callee.type === "MemberExpression") {
2958
+ const mem = call.callee;
2959
+ if (mem.property.type === "Identifier" && mem.property.name === "$transaction") {
2960
+ const scope2 = findEnclosingFunction(node, parentMap);
2961
+ transactionScopes.add(scope2);
2962
+ return;
2963
+ }
2964
+ }
2965
+ if (call.callee.type !== "MemberExpression") return;
2966
+ const outerMem = call.callee;
2967
+ if (outerMem.property.type !== "Identifier") return;
2968
+ if (!PRISMA_WRITE_METHODS.has(outerMem.property.name)) return;
2969
+ if (outerMem.object.type !== "MemberExpression") return;
2970
+ const innerMem = outerMem.object;
2971
+ if (innerMem.object.type !== "Identifier" || innerMem.object.name !== "prisma") return;
2972
+ const scope = findEnclosingFunction(node, parentMap);
2973
+ const existing = writesByScope.get(scope);
2974
+ if (existing) {
2975
+ existing.count++;
2976
+ } else {
2977
+ writesByScope.set(scope, { count: 1, firstLine: call.loc.start.line });
2978
+ }
2979
+ });
2980
+ const findings = [];
2981
+ for (const [scope, { count, firstLine }] of writesByScope) {
2982
+ if (count < 2) continue;
2983
+ if (transactionScopes.has(scope)) continue;
2984
+ findings.push({
2985
+ ruleId: "missing-transaction",
2986
+ file: file.relativePath,
2987
+ line: firstLine,
2988
+ column: 1,
2989
+ message: `${count} Prisma write operations without $transaction \u2014 partial writes may leave inconsistent state`,
2990
+ severity: "warning",
2991
+ category: "reliability",
2992
+ fix: "Wrap sequential writes in prisma.$transaction([...]) for atomicity"
2993
+ });
2994
+ }
2995
+ return findings;
2996
+ } catch {
2997
+ }
2998
+ }
2522
2999
  let writeCount = 0;
2523
3000
  let firstWriteLine = -1;
2524
3001
  const hasTransaction = PRISMA_TRANSACTION.test(file.content);
@@ -2542,6 +3019,16 @@ var missingTransactionRule = {
2542
3019
  }];
2543
3020
  }
2544
3021
  };
3022
+ function findEnclosingFunction(node, parentMap) {
3023
+ let current = parentMap.get(node);
3024
+ while (current) {
3025
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
3026
+ return current;
3027
+ }
3028
+ current = parentMap.get(current);
3029
+ }
3030
+ return null;
3031
+ }
2545
3032
 
2546
3033
  // src/rules/redirect-in-try-catch.ts
2547
3034
  var redirectInTryCatchRule = {
@@ -3108,6 +3595,14 @@ var VALIDATION_PATTERNS3 = [
3108
3595
  /\.startsWith\s*\(\s*['"]https?:\/\//,
3109
3596
  /\.hostname\s*[!=]==?\s*['"`]/
3110
3597
  ];
3598
+ var SUSPICIOUS_URL_VARS = /* @__PURE__ */ new Set([
3599
+ "url",
3600
+ "href",
3601
+ "endpoint",
3602
+ "target",
3603
+ "link",
3604
+ "src"
3605
+ ]);
3111
3606
  var ssrfRiskRule = {
3112
3607
  id: "ssrf-risk",
3113
3608
  name: "SSRF Risk",
@@ -3118,9 +3613,63 @@ var ssrfRiskRule = {
3118
3613
  check(file, _project) {
3119
3614
  if (isTestFile(file.relativePath)) return [];
3120
3615
  if (!isApiRoute(file.relativePath) && !/['"]use server['"]/.test(file.content)) return [];
3616
+ const findings = [];
3617
+ if (file.ast) {
3618
+ try {
3619
+ const hasValidationInCode = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3620
+ walkAST(file.ast.program, (node) => {
3621
+ if (node.type !== "CallExpression") return;
3622
+ const call = node;
3623
+ if (!call.loc) return;
3624
+ let isFetchLike = false;
3625
+ if (call.callee.type === "Identifier" && call.callee.name === "fetch") {
3626
+ isFetchLike = true;
3627
+ }
3628
+ if (call.callee.type === "MemberExpression") {
3629
+ const mem = call.callee;
3630
+ if (mem.object.type === "Identifier" && mem.object.name === "axios") {
3631
+ isFetchLike = true;
3632
+ }
3633
+ }
3634
+ if (!isFetchLike) return;
3635
+ const arg = call.arguments[0];
3636
+ if (!arg) return;
3637
+ if (isStaticString(arg)) return;
3638
+ const lineNum = call.loc.start.line;
3639
+ const col = call.loc.start.column + 1;
3640
+ if (isUserInputNode(arg)) {
3641
+ findings.push({
3642
+ ruleId: "ssrf-risk",
3643
+ file: file.relativePath,
3644
+ line: lineNum,
3645
+ column: col,
3646
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3647
+ severity: "warning",
3648
+ category: "security",
3649
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3650
+ });
3651
+ return;
3652
+ }
3653
+ if (arg.type === "Identifier" && SUSPICIOUS_URL_VARS.has(arg.name)) {
3654
+ if (hasValidationInCode) return;
3655
+ findings.push({
3656
+ ruleId: "ssrf-risk",
3657
+ file: file.relativePath,
3658
+ line: lineNum,
3659
+ column: col,
3660
+ message: "User-controlled URL passed to fetch \u2014 validate against an allowlist to prevent SSRF",
3661
+ severity: "warning",
3662
+ category: "security",
3663
+ fix: "Validate the URL against an allowlist of permitted domains before making the request"
3664
+ });
3665
+ }
3666
+ });
3667
+ return findings;
3668
+ } catch {
3669
+ }
3670
+ }
3121
3671
  const hasValidation = VALIDATION_PATTERNS3.some((p) => p.test(file.content));
3122
3672
  if (hasValidation) return [];
3123
- const findings = [];
3124
3673
  for (let i = 0; i < file.lines.length; i++) {
3125
3674
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3126
3675
  const line = file.lines[i];
@@ -3162,6 +3711,25 @@ var SANITIZATION_PATTERNS = [
3162
3711
  /sanitize/i,
3163
3712
  /realpath/
3164
3713
  ];
3714
+ var FS_FUNCTION_NAMES = /* @__PURE__ */ new Set([
3715
+ "readFile",
3716
+ "readFileSync",
3717
+ "createReadStream",
3718
+ "writeFile",
3719
+ "writeFileSync",
3720
+ "createWriteStream",
3721
+ "unlink",
3722
+ "unlinkSync",
3723
+ "rm",
3724
+ "rmSync"
3725
+ ]);
3726
+ var SUSPICIOUS_PATH_VARS = /* @__PURE__ */ new Set([
3727
+ "filePath",
3728
+ "fileName",
3729
+ "path",
3730
+ "file",
3731
+ "name"
3732
+ ]);
3165
3733
  var pathTraversalRule = {
3166
3734
  id: "path-traversal",
3167
3735
  name: "Path Traversal",
@@ -3171,9 +3739,73 @@ var pathTraversalRule = {
3171
3739
  fileExtensions: ["ts", "tsx", "js", "jsx"],
3172
3740
  check(file, _project) {
3173
3741
  if (isTestFile(file.relativePath)) return [];
3742
+ const findings = [];
3743
+ if (file.ast) {
3744
+ try {
3745
+ walkAST(file.ast.program, (node) => {
3746
+ if (node.type !== "CallExpression") return;
3747
+ const call = node;
3748
+ if (!call.loc) return;
3749
+ let fnName = null;
3750
+ if (call.callee.type === "Identifier" && FS_FUNCTION_NAMES.has(call.callee.name)) {
3751
+ fnName = call.callee.name;
3752
+ }
3753
+ if (call.callee.type === "MemberExpression") {
3754
+ const mem = call.callee;
3755
+ if (mem.property.type === "Identifier") {
3756
+ if (FS_FUNCTION_NAMES.has(mem.property.name)) {
3757
+ fnName = mem.property.name;
3758
+ }
3759
+ if (mem.property.name === "join" && mem.object.type === "Identifier" && mem.object.name === "path") {
3760
+ fnName = "path.join";
3761
+ }
3762
+ if (mem.property.name === "sendFile") {
3763
+ fnName = "sendFile";
3764
+ }
3765
+ }
3766
+ }
3767
+ if (!fnName) return;
3768
+ const argsToCheck = fnName === "path.join" ? call.arguments : [call.arguments[0]];
3769
+ for (const arg of argsToCheck) {
3770
+ if (!arg) continue;
3771
+ if (isStaticString(arg)) continue;
3772
+ const lineNum = call.loc.start.line;
3773
+ const col = call.loc.start.column + 1;
3774
+ if (isUserInputNode(arg)) {
3775
+ const action = fnName.includes("write") || fnName.includes("Write") ? "write" : fnName.includes("unlink") || fnName === "rm" || fnName === "rmSync" ? "delete" : fnName === "sendFile" ? "send" : "read";
3776
+ findings.push({
3777
+ ruleId: "path-traversal",
3778
+ file: file.relativePath,
3779
+ line: lineNum,
3780
+ column: col,
3781
+ 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`,
3782
+ severity: "critical",
3783
+ category: "security",
3784
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3785
+ });
3786
+ return;
3787
+ }
3788
+ if (arg.type === "Identifier" && SUSPICIOUS_PATH_VARS.has(arg.name)) {
3789
+ findings.push({
3790
+ ruleId: "path-traversal",
3791
+ file: file.relativePath,
3792
+ line: lineNum,
3793
+ column: col,
3794
+ message: `File operation with potentially user-controlled path \u2014 validate before use`,
3795
+ severity: "warning",
3796
+ category: "security",
3797
+ fix: "Validate the resolved path starts with an expected base directory: path.resolve(base, input).startsWith(path.resolve(base))"
3798
+ });
3799
+ return;
3800
+ }
3801
+ }
3802
+ });
3803
+ return findings;
3804
+ } catch {
3805
+ }
3806
+ }
3174
3807
  const hasSanitization = SANITIZATION_PATTERNS.some((p) => p.test(file.content));
3175
3808
  if (hasSanitization) return [];
3176
- const findings = [];
3177
3809
  for (let i = 0; i < file.lines.length; i++) {
3178
3810
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3179
3811
  const line = file.lines[i];
@@ -3225,18 +3857,31 @@ var hydrationMismatchRule = {
3225
3857
  if (!/(?:^|\/)(?:src\/)?app\//.test(file.relativePath)) return [];
3226
3858
  if (/route\.[jt]sx?$/.test(file.relativePath)) return [];
3227
3859
  const findings = [];
3860
+ let useEffectRanges = [];
3861
+ if (file.ast) {
3862
+ try {
3863
+ useEffectRanges = findUseEffectRanges(file.ast);
3864
+ } catch {
3865
+ }
3866
+ }
3867
+ const hasAstRanges = useEffectRanges.length > 0 || file.ast != null;
3228
3868
  let insideUseEffect = false;
3229
3869
  for (let i = 0; i < file.lines.length; i++) {
3230
3870
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3231
3871
  const line = file.lines[i];
3232
- if (/\buseEffect\s*\(/.test(line)) {
3233
- insideUseEffect = true;
3234
- }
3235
- if (insideUseEffect) {
3236
- if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
3237
- insideUseEffect = false;
3872
+ if (hasAstRanges) {
3873
+ const inEffect = useEffectRanges.some((r) => i >= r.start && i <= r.end);
3874
+ if (inEffect) continue;
3875
+ } else {
3876
+ if (/\buseEffect\s*\(/.test(line)) {
3877
+ insideUseEffect = true;
3878
+ }
3879
+ if (insideUseEffect) {
3880
+ if (/^\s*\}\s*,\s*\[/.test(line) || /^\s*\}\s*\)\s*;?\s*$/.test(line)) {
3881
+ insideUseEffect = false;
3882
+ }
3883
+ continue;
3238
3884
  }
3239
- continue;
3240
3885
  }
3241
3886
  for (const { pattern, msg } of [...BROWSER_ONLY_PATTERNS, ...NONDETERMINISTIC_PATTERNS]) {
3242
3887
  const match = pattern.exec(line);
@@ -3461,6 +4106,7 @@ var HAS_EXPIRY = [
3461
4106
  /expirationTime/,
3462
4107
  /maxAge/
3463
4108
  ];
4109
+ var EXPIRY_KEYS = /* @__PURE__ */ new Set(["expiresIn", "exp", "expirationTime", "maxAge"]);
3464
4110
  var jwtNoExpiryRule = {
3465
4111
  id: "jwt-no-expiry",
3466
4112
  name: "JWT Without Expiration",
@@ -3472,6 +4118,55 @@ var jwtNoExpiryRule = {
3472
4118
  if (isTestFile(file.relativePath)) return [];
3473
4119
  if (!JWT_SIGN.test(file.content)) return [];
3474
4120
  const findings = [];
4121
+ if (file.ast) {
4122
+ try {
4123
+ walkAST(file.ast.program, (node) => {
4124
+ if (node.type !== "CallExpression") return;
4125
+ const call = node;
4126
+ if (!call.loc) return;
4127
+ if (call.callee.type !== "MemberExpression") return;
4128
+ const mem = call.callee;
4129
+ if (mem.object.type !== "Identifier" || mem.object.name !== "jwt") return;
4130
+ if (mem.property.type !== "Identifier" || mem.property.name !== "sign") return;
4131
+ let hasExpiry = false;
4132
+ for (const arg of call.arguments) {
4133
+ if (arg.type === "ObjectExpression") {
4134
+ const obj = arg;
4135
+ for (const prop of obj.properties) {
4136
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && EXPIRY_KEYS.has(prop.key.name)) {
4137
+ hasExpiry = true;
4138
+ break;
4139
+ }
4140
+ }
4141
+ }
4142
+ if (hasExpiry) break;
4143
+ }
4144
+ if (!hasExpiry && call.arguments[0]?.type === "ObjectExpression") {
4145
+ const payload = call.arguments[0];
4146
+ for (const prop of payload.properties) {
4147
+ if (prop.type === "ObjectProperty" && prop.key.type === "Identifier" && prop.key.name === "exp") {
4148
+ hasExpiry = true;
4149
+ break;
4150
+ }
4151
+ }
4152
+ }
4153
+ if (!hasExpiry) {
4154
+ findings.push({
4155
+ ruleId: "jwt-no-expiry",
4156
+ file: file.relativePath,
4157
+ line: call.loc.start.line,
4158
+ column: call.loc.start.column + 1,
4159
+ message: "jwt.sign() without expiresIn \u2014 tokens never expire, a compromised token is valid forever",
4160
+ severity: "warning",
4161
+ category: "security",
4162
+ fix: 'Add expiration: jwt.sign(payload, secret, { expiresIn: "1h" })'
4163
+ });
4164
+ }
4165
+ });
4166
+ return findings;
4167
+ } catch {
4168
+ }
4169
+ }
3475
4170
  for (let i = 0; i < file.lines.length; i++) {
3476
4171
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
3477
4172
  const line = file.lines[i];