prodlint 0.10.3 → 0.10.4

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
@@ -1,7 +1,7 @@
1
1
  // src/utils/file-walker.ts
2
2
  import fg from "fast-glob";
3
3
  import { readFile, stat, realpath } from "fs/promises";
4
- import { resolve, extname, sep } from "path";
4
+ import { resolve, extname, basename, sep } from "path";
5
5
 
6
6
  // src/utils/patterns.ts
7
7
  function isApiRoute(relativePath) {
@@ -44,6 +44,19 @@ function isCommentLine(lines, lineIndex, commentMap) {
44
44
  const trimmed = lines[lineIndex]?.trim() ?? "";
45
45
  return trimmed.startsWith("//");
46
46
  }
47
+ var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
48
+ function blankOut(source, strings) {
49
+ const blank = (s) => s.replace(/[^\n]/g, " ");
50
+ return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
51
+ }
52
+ function closingBrace(code, open) {
53
+ let depth = 0;
54
+ for (let i = open; i < code.length; i++) {
55
+ if (code[i] === "{") depth++;
56
+ else if (code[i] === "}" && --depth === 0) return i;
57
+ }
58
+ return -1;
59
+ }
47
60
  function isLineSuppressed(lines, lineIndex, ruleId) {
48
61
  for (const line of lines) {
49
62
  const trimmed = line.trim();
@@ -434,7 +447,8 @@ async function readFileContext(root, relativePath) {
434
447
  if (fileStats.size > MAX_FILE_SIZE) return null;
435
448
  const content = await readFile(absolutePath, "utf-8");
436
449
  const lines = content.split(/\r?\n|\r/);
437
- const ext = extname(relativePath).slice(1);
450
+ const rawExt = extname(relativePath).slice(1);
451
+ const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
438
452
  let ast = void 0;
439
453
  if (AST_EXTENSIONS.has(ext)) {
440
454
  try {
@@ -740,12 +754,21 @@ function summarizeFindings(findings) {
740
754
  }
741
755
 
742
756
  // src/rules/secrets.ts
757
+ function nameJwt(token) {
758
+ try {
759
+ const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
760
+ if (claims?.role === "anon") return null;
761
+ if (claims?.role === "service_role") return "Supabase service role key";
762
+ } catch {
763
+ }
764
+ return "JWT";
765
+ }
743
766
  var SECRET_PATTERNS = [
744
767
  { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
745
768
  { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
746
769
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
747
770
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
748
- { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
771
+ { name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
749
772
  { name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
750
773
  { name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
751
774
  { name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
@@ -780,15 +803,17 @@ var secretsRule = {
780
803
  for (let i = 0; i < file.lines.length; i++) {
781
804
  const line = file.lines[i];
782
805
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
783
- for (const { name, pattern } of SECRET_PATTERNS) {
806
+ for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
784
807
  const match = pattern.exec(line);
785
808
  if (match) {
809
+ const reported = nameMatch ? nameMatch(match[0]) : name;
810
+ if (!reported) continue;
786
811
  findings.push({
787
812
  ruleId: "secrets",
788
813
  file: file.relativePath,
789
814
  line: i + 1,
790
815
  column: match.index + 1,
791
- message: `Hardcoded ${name} detected`,
816
+ message: `Hardcoded ${reported} detected`,
792
817
  severity: "critical",
793
818
  category: "security",
794
819
  fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
@@ -944,12 +969,208 @@ var AUTH_PATTERNS = [
944
969
  /createRouteHandlerClient/,
945
970
  /createServerComponentClient/,
946
971
  /createMiddlewareClient/,
947
- /authorization/i,
972
+ // Reading the request's Authorization header, not sending one upstream:
973
+ // headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
974
+ /\.(?:get|header)\(\s*['"`]authorization['"`]/i,
975
+ /\.headers\??\.authorization\b(?!\s*=(?!=))/i,
976
+ /\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
948
977
  /getAuth\s*\(/,
949
978
  /withPageAuth/,
950
979
  /cookies\(\).*auth/s
951
980
  ];
952
- var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
981
+ var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
982
+ var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
983
+ var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
984
+ var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
985
+ var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
986
+ function words(name) {
987
+ return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
988
+ }
989
+ function saysAuth(name) {
990
+ const w = words(name);
991
+ return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
992
+ }
993
+ function isAuthHelper(name, source) {
994
+ if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
995
+ return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
996
+ }
997
+ var AUTHED = { authed: true, local: true };
998
+ var LOCAL = { authed: false, local: true };
999
+ var ELSEWHERE = { authed: false, local: false };
1000
+ function handlersFromAst(program) {
1001
+ const imports = /* @__PURE__ */ new Map();
1002
+ const bindings = /* @__PURE__ */ new Map();
1003
+ for (const st of program.body) {
1004
+ if (st.type === "ImportDeclaration") {
1005
+ for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
1006
+ continue;
1007
+ }
1008
+ const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
1009
+ if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
1010
+ if (decl?.type === "VariableDeclaration") {
1011
+ for (const d of decl.declarations) {
1012
+ if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
1013
+ }
1014
+ }
1015
+ }
1016
+ const helperOf = (callee) => {
1017
+ if (callee.type === "CallExpression") return helperOf(callee.callee);
1018
+ if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
1019
+ if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
1020
+ let root = callee.object;
1021
+ while (root.type === "MemberExpression") root = root.object;
1022
+ return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
1023
+ }
1024
+ return null;
1025
+ };
1026
+ const classify = (node, seen) => {
1027
+ while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
1028
+ node = node.type === "AwaitExpression" ? node.argument : node.expression;
1029
+ }
1030
+ switch (node.type) {
1031
+ case "FunctionDeclaration":
1032
+ case "FunctionExpression":
1033
+ case "ArrowFunctionExpression":
1034
+ return LOCAL;
1035
+ case "Identifier": {
1036
+ const bound = bindings.get(node.name);
1037
+ if (!bound || seen.has(node.name)) return ELSEWHERE;
1038
+ return classify(bound, new Set(seen).add(node.name));
1039
+ }
1040
+ case "CallExpression": {
1041
+ const helper = helperOf(node.callee);
1042
+ if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
1043
+ let local = false;
1044
+ for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
1045
+ const verdict = classify(arg, seen);
1046
+ if (verdict.authed) return verdict;
1047
+ local ||= verdict.local;
1048
+ }
1049
+ return { authed: false, local };
1050
+ }
1051
+ }
1052
+ return ELSEWHERE;
1053
+ };
1054
+ const handlers = [];
1055
+ const add = (method, at, verdict) => {
1056
+ handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
1057
+ };
1058
+ for (const st of program.body) {
1059
+ if (st.type === "ExportNamedDeclaration") {
1060
+ const decl = st.declaration;
1061
+ if (decl?.type === "FunctionDeclaration") {
1062
+ if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
1063
+ } else if (decl?.type === "VariableDeclaration") {
1064
+ for (const d of decl.declarations) {
1065
+ if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
1066
+ add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
1067
+ } else if (d.id.type === "ObjectPattern") {
1068
+ for (const p of d.id.properties) {
1069
+ if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
1070
+ add(p.value.name, d, ELSEWHERE);
1071
+ }
1072
+ }
1073
+ }
1074
+ }
1075
+ } else if (!decl) {
1076
+ for (const s of st.specifiers) {
1077
+ if (s.type !== "ExportSpecifier") continue;
1078
+ const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
1079
+ if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
1080
+ }
1081
+ }
1082
+ } else if (st.type === "ExportDefaultDeclaration") {
1083
+ add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
1084
+ } else if (st.type === "ExportAllDeclaration") {
1085
+ add("*", st, ELSEWHERE);
1086
+ }
1087
+ }
1088
+ return handlers;
1089
+ }
1090
+ var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
1091
+ var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
1092
+ var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
1093
+ var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
1094
+ var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
1095
+ var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
1096
+ var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
1097
+ var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
1098
+ var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
1099
+ var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
1100
+ var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
1101
+ function handlersFromText(file) {
1102
+ const imports = /* @__PURE__ */ new Map();
1103
+ for (const m of file.content.matchAll(IMPORT)) {
1104
+ for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
1105
+ const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
1106
+ if (spec) imports.set(spec[2] ?? spec[1], m[2]);
1107
+ }
1108
+ }
1109
+ const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
1110
+ const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
1111
+ const resolve4 = (name, seen) => {
1112
+ const id = name.replace(/\$/g, "\\$");
1113
+ const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
1114
+ const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
1115
+ for (let i = 0; i < file.lines.length; i++) {
1116
+ if (!code(i)) continue;
1117
+ if (fn.test(file.lines[i])) return LOCAL;
1118
+ const m = variable.exec(file.lines[i]);
1119
+ if (m) return classify(value(i, m[1]), seen);
1120
+ }
1121
+ return ELSEWHERE;
1122
+ };
1123
+ const classify = (text, seen) => {
1124
+ const t = text.trim().replace(/^await\s+/, "");
1125
+ if (INLINE_FUNCTION.test(t)) return LOCAL;
1126
+ const call = CALL.exec(t);
1127
+ if (call) {
1128
+ if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
1129
+ let args = t.slice(call[0].length);
1130
+ const curried = /^[^()]*\)\s*\(/.exec(args);
1131
+ if (curried) args = args.slice(curried[0].length);
1132
+ return classify(args, seen);
1133
+ }
1134
+ const id = IDENTIFIER.exec(t);
1135
+ if (id && !seen.has(id[1])) return resolve4(id[1], new Set(seen).add(id[1]));
1136
+ return ELSEWHERE;
1137
+ };
1138
+ const handlers = [];
1139
+ for (let i = 0; i < file.lines.length; i++) {
1140
+ if (!code(i)) continue;
1141
+ const line = file.lines[i];
1142
+ let m;
1143
+ if (m = EXPORT_FUNCTION.exec(line)) {
1144
+ handlers.push({ method: m[1], line: i + 1, ...LOCAL });
1145
+ } else if (m = EXPORT_CONST.exec(line)) {
1146
+ handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
1147
+ } else if (m = EXPORT_DESTRUCTURED.exec(line)) {
1148
+ for (const part of m[1].split(",")) {
1149
+ const name = part.split(":").pop()?.trim() ?? "";
1150
+ if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
1151
+ }
1152
+ } else if (m = EXPORT_DEFAULT.exec(line)) {
1153
+ handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
1154
+ } else if (EXPORT_ALL.test(line)) {
1155
+ handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
1156
+ }
1157
+ }
1158
+ const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
1159
+ for (const m of file.content.matchAll(EXPORT_LIST)) {
1160
+ if (!code(lineAt(m.index) - 1)) continue;
1161
+ let offset = m.index + m[0].indexOf("{") + 1;
1162
+ for (const part of m[1].split(",")) {
1163
+ const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
1164
+ const name = spec ? spec[2] ?? spec[1] : "";
1165
+ if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
1166
+ const line = lineAt(offset + part.length - part.trimStart().length);
1167
+ handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
1168
+ }
1169
+ offset += part.length + 1;
1170
+ }
1171
+ }
1172
+ return handlers.sort((a, b) => a.line - b.line);
1173
+ }
953
1174
  var authChecksRule = {
954
1175
  id: "auth-checks",
955
1176
  name: "Missing Auth Checks",
@@ -965,21 +1186,18 @@ var authChecksRule = {
965
1186
  for (const pattern of AUTH_PATTERNS) {
966
1187
  if (pattern.test(file.content)) return [];
967
1188
  }
1189
+ const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
1190
+ const exposed = handlers.filter((h) => !h.authed);
1191
+ if (handlers.length > 0 && exposed.length === 0) return [];
968
1192
  let severity;
969
1193
  if (project.hasAuthMiddleware) {
970
1194
  severity = "info";
971
- } else if (MUTATION_EXPORT.test(file.content)) {
1195
+ } else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
972
1196
  severity = "critical";
973
1197
  } else {
974
1198
  severity = "info";
975
1199
  }
976
- let handlerLine = 1;
977
- for (let i = 0; i < file.lines.length; i++) {
978
- if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
979
- handlerLine = i + 1;
980
- break;
981
- }
982
- }
1200
+ const handlerLine = exposed[0]?.line ?? 1;
983
1201
  const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
984
1202
  return [{
985
1203
  ruleId: "auth-checks",
@@ -1098,7 +1316,8 @@ var errorHandlingRule = {
1098
1316
 
1099
1317
  // src/rules/input-validation.ts
1100
1318
  var VALIDATION_PATTERNS = [
1101
- /\.parse\s*\(/,
1319
+ // A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
1320
+ /(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
1102
1321
  /\.safeParse\s*\(/,
1103
1322
  /\.validate\s*\(/,
1104
1323
  /\.validateSync\s*\(/,
@@ -1399,6 +1618,40 @@ var aiSmellsRule = {
1399
1618
  };
1400
1619
 
1401
1620
  // src/rules/unsafe-html.ts
1621
+ var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
1622
+ var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
1623
+ function isSanitizerCall(node) {
1624
+ if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
1625
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
1626
+ return isSanitizerCall(node.expression);
1627
+ }
1628
+ if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
1629
+ const callee = node.callee;
1630
+ if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
1631
+ return callee.property.type === "Identifier" && callee.property.name === "sanitize";
1632
+ }
1633
+ return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
1634
+ }
1635
+ function containsJsonStringify(node) {
1636
+ return subtreeContains(node, (n) => {
1637
+ if (n.type !== "CallExpression") return false;
1638
+ const call = n;
1639
+ if (call.callee.type === "MemberExpression") {
1640
+ const mem = call.callee;
1641
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1642
+ }
1643
+ return false;
1644
+ });
1645
+ }
1646
+ function isSanitized(value, sanitizedNames) {
1647
+ if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
1648
+ if (value.type === "ObjectExpression") {
1649
+ const html = value.properties.find((p) => p.type === "ObjectProperty" && (p.key.type === "Identifier" && p.key.name === "__html" || p.key.type === "StringLiteral" && p.key.value === "__html"));
1650
+ return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
1651
+ }
1652
+ if (value.type === "Identifier") return sanitizedNames.has(value.name);
1653
+ return isSanitizerCall(value);
1654
+ }
1402
1655
  var unsafeHtmlRule = {
1403
1656
  id: "unsafe-html",
1404
1657
  name: "Unsafe HTML Rendering",
@@ -1410,21 +1663,17 @@ var unsafeHtmlRule = {
1410
1663
  const findings = [];
1411
1664
  if (file.ast) {
1412
1665
  try {
1666
+ const sanitizedNames = /* @__PURE__ */ new Set();
1667
+ walkAST(file.ast.program, (node) => {
1668
+ if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
1669
+ sanitizedNames.add(node.id.name);
1670
+ }
1671
+ });
1413
1672
  walkAST(file.ast.program, (node) => {
1414
1673
  if (node.type === "JSXAttribute") {
1415
1674
  const attr = node;
1416
1675
  if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
1417
- if (attr.value && subtreeContains(attr.value, (n) => {
1418
- if (n.type !== "CallExpression") return false;
1419
- const call = n;
1420
- if (call.callee.type === "MemberExpression") {
1421
- const mem = call.callee;
1422
- return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1423
- }
1424
- return false;
1425
- })) {
1426
- return;
1427
- }
1676
+ if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
1428
1677
  const line = attr.loc.start.line;
1429
1678
  findings.push({
1430
1679
  ruleId: "unsafe-html",
@@ -1441,17 +1690,7 @@ var unsafeHtmlRule = {
1441
1690
  if (node.type === "ObjectProperty") {
1442
1691
  const prop = node;
1443
1692
  if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
1444
- if (prop.value && subtreeContains(prop.value, (n) => {
1445
- if (n.type !== "CallExpression") return false;
1446
- const call = n;
1447
- if (call.callee.type === "MemberExpression") {
1448
- const mem = call.callee;
1449
- return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1450
- }
1451
- return false;
1452
- })) {
1453
- return;
1454
- }
1693
+ if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
1455
1694
  const line = prop.loc.start.line;
1456
1695
  findings.push({
1457
1696
  ruleId: "unsafe-html",
@@ -1468,6 +1707,7 @@ var unsafeHtmlRule = {
1468
1707
  if (node.type === "AssignmentExpression") {
1469
1708
  const assign = node;
1470
1709
  if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
1710
+ if (isSanitized(assign.right, sanitizedNames)) return;
1471
1711
  const line = assign.loc.start.line;
1472
1712
  findings.push({
1473
1713
  ruleId: "unsafe-html",
@@ -1498,6 +1738,8 @@ var unsafeHtmlRule = {
1498
1738
  }
1499
1739
  const expr = context.join(" ");
1500
1740
  if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
1741
+ const html = /__html\s*:/.exec(expr);
1742
+ if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
1501
1743
  findings.push({
1502
1744
  ruleId: "unsafe-html",
1503
1745
  file: file.relativePath,
@@ -1509,7 +1751,8 @@ var unsafeHtmlRule = {
1509
1751
  fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1510
1752
  });
1511
1753
  }
1512
- if (/\w\.innerHTML\s*=/.test(line)) {
1754
+ const innerHtml = /\w\.innerHTML\s*=/.exec(line);
1755
+ if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
1513
1756
  findings.push({
1514
1757
  ruleId: "unsafe-html",
1515
1758
  file: file.relativePath,
@@ -1688,9 +1931,16 @@ var staleFallbackRule = {
1688
1931
  };
1689
1932
 
1690
1933
  // src/rules/hallucinated-api.ts
1934
+ var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
1935
+ var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
1691
1936
  var HALLUCINATED_APIS = [
1692
1937
  { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1693
- { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1938
+ {
1939
+ pattern: /\.contains\s*\(/,
1940
+ fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
1941
+ methodName: "contains",
1942
+ isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
1943
+ },
1694
1944
  { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1695
1945
  { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1696
1946
  { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
@@ -1712,10 +1962,11 @@ var hallucinatedApiRule = {
1712
1962
  for (let i = 0; i < file.lines.length; i++) {
1713
1963
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1714
1964
  const line = file.lines[i];
1715
- for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1965
+ for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
1716
1966
  const match = pattern.exec(line);
1717
1967
  if (match) {
1718
1968
  if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1969
+ if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
1719
1970
  findings.push({
1720
1971
  ruleId: "hallucinated-api",
1721
1972
  file: file.relativePath,
@@ -2355,6 +2606,13 @@ function isEntryPoint(relativePath) {
2355
2606
  const name = relativePath.split("/").pop() ?? "";
2356
2607
  return /^(page|layout|loading|error|not-found|route|middleware|instrumentation|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(tsx?|jsx?)$/.test(name) || /^index\.(tsx?|jsx?)$/.test(name) || name === "global-error.tsx" || name === "global-error.jsx";
2357
2608
  }
2609
+ var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
2610
+ var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
2611
+ var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
2612
+ var FROM_SOURCE = /from\s+['"]([^'"]+)['"]/g;
2613
+ function sourceBasename(source) {
2614
+ return source.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2615
+ }
2358
2616
  var THRESHOLD = 5;
2359
2617
  var deadExportsRule = {
2360
2618
  id: "dead-exports",
@@ -2374,6 +2632,7 @@ var deadExportsRule = {
2374
2632
  const imports = /* @__PURE__ */ new Map();
2375
2633
  const allImportedSymbols = /* @__PURE__ */ new Set();
2376
2634
  const importedFiles = /* @__PURE__ */ new Set();
2635
+ const namespaceImported = /* @__PURE__ */ new Set();
2377
2636
  for (const file of sourceFiles) {
2378
2637
  if (isEntryPoint(file.relativePath)) continue;
2379
2638
  let inTemplateLiteral = false;
@@ -2416,42 +2675,35 @@ var deadExportsRule = {
2416
2675
  }
2417
2676
  }
2418
2677
  }
2678
+ const addImport = (sym, source) => {
2679
+ allImportedSymbols.add(sym);
2680
+ const fromBasename = sourceBasename(source);
2681
+ if (fromBasename) {
2682
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2683
+ set.add(sym);
2684
+ imports.set(fromBasename, set);
2685
+ }
2686
+ };
2419
2687
  for (const file of files) {
2420
- for (const line of file.lines) {
2421
- let match;
2422
- const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
2423
- const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
2424
- const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
2425
- while ((match = bracesRe.exec(line)) !== null) {
2426
- const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
2427
- for (const sym of symbols) {
2428
- allImportedSymbols.add(sym);
2429
- if (fromBasename) {
2430
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2431
- set.add(sym);
2432
- imports.set(fromBasename, set);
2433
- }
2434
- }
2435
- }
2436
- const defaultRe = /import\s+(\w+)\s+from/g;
2437
- while ((match = defaultRe.exec(line)) !== null) {
2438
- allImportedSymbols.add(match[1]);
2439
- if (fromBasename) {
2440
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2441
- set.add(match[1]);
2442
- imports.set(fromBasename, set);
2443
- }
2444
- }
2445
- const fromRe = /from\s+['"]([^'"]+)['"]/g;
2446
- while ((match = fromRe.exec(line)) !== null) {
2447
- importedFiles.add(match[1]);
2448
- }
2688
+ for (const match of file.content.matchAll(IMPORT_NAMED)) {
2689
+ const symbols = match[1].replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "").split(/\s+as\s+/)[0].trim()).filter(Boolean);
2690
+ for (const sym of symbols) addImport(sym, match[2]);
2691
+ }
2692
+ for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
2693
+ addImport(match[1], match[2]);
2694
+ }
2695
+ for (const match of file.content.matchAll(IMPORT_NAMESPACE)) {
2696
+ namespaceImported.add(sourceBasename(match[1]));
2697
+ }
2698
+ for (const match of file.content.matchAll(FROM_SOURCE)) {
2699
+ importedFiles.add(match[1]);
2449
2700
  }
2450
2701
  }
2451
2702
  const deadByFile = /* @__PURE__ */ new Map();
2452
2703
  for (const [key, loc] of exports) {
2453
2704
  const symbolName = key.split("::")[1];
2454
2705
  const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2706
+ if (namespaceImported.has(exportFileBasename)) continue;
2455
2707
  const importSet = imports.get(exportFileBasename);
2456
2708
  const isImported = importSet?.has(symbolName) ?? false;
2457
2709
  if (!isImported && !allImportedSymbols.has(symbolName)) {
@@ -2521,19 +2773,6 @@ function catchFinding(file, catchLine, body) {
2521
2773
  fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2522
2774
  };
2523
2775
  }
2524
- var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
2525
- function blankOut(source, strings) {
2526
- const blank = (s) => s.replace(/[^\n]/g, " ");
2527
- return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
2528
- }
2529
- function closingBrace(code, open) {
2530
- let depth = 0;
2531
- for (let i = open; i < code.length; i++) {
2532
- if (code[i] === "{") depth++;
2533
- else if (code[i] === "}" && --depth === 0) return i;
2534
- }
2535
- return -1;
2536
- }
2537
2776
  function returnedValues(code, text) {
2538
2777
  const values = [];
2539
2778
  for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
@@ -2711,47 +2950,46 @@ var comprehensionDebtRule = {
2711
2950
  };
2712
2951
 
2713
2952
  // src/rules/phantom-dependency.ts
2714
- var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
2715
- "huggingface-cli",
2716
- // hallucinated, real: huggingface_hub
2717
- "flask-hierarchical",
2718
- // hallucinated
2719
- "beautifulsoup",
2720
- // real is beautifulsoup4
2721
- "python-dotenv",
2722
- // real is python-dotenv (this one exists but confusable)
2723
- "openai-sdk",
2724
- // hallucinated, real: openai
2725
- "anthropic-sdk",
2726
- // hallucinated, real: @anthropic-ai/sdk
2727
- "langchain-core",
2728
- // confusable with @langchain/core
2729
- "react-native-utils",
2730
- // hallucinated generic
2731
- "next-middleware",
2732
- // hallucinated
2733
- "supabase-client",
2734
- // hallucinated, real: @supabase/supabase-js
2735
- "stripe-sdk",
2736
- // hallucinated, real: stripe
2737
- "prisma-client",
2738
- // hallucinated, real: @prisma/client
2739
- "tailwind-utils",
2740
- // hallucinated
2741
- "express-validator-v2",
2742
- // hallucinated
2743
- "node-postgres-pool",
2744
- // hallucinated, real: pg
2745
- "mongo-client",
2746
- // hallucinated, real: mongodb
2747
- "redis-client",
2748
- // hallucinated, real: redis or ioredis
2749
- "aws-s3-upload",
2750
- // hallucinated
2751
- "gpt-tokenizer"
2752
- // exists but often confused
2953
+ var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
2954
+ ["huggingface-cli", ["@huggingface/hub"]],
2955
+ // a Python CLI; an npm placeholder
2956
+ ["flask-hierarchical", []],
2957
+ // not on npm
2958
+ ["beautifulsoup", ["cheerio"]],
2959
+ // a Python library (beautifulsoup4); not on npm
2960
+ ["python-dotenv", ["dotenv"]],
2961
+ // a Python library; an npm placeholder
2962
+ ["openai-sdk", ["openai"]],
2963
+ // unpublished
2964
+ ["anthropic-sdk", ["@anthropic-ai/sdk"]],
2965
+ // an npm placeholder
2966
+ ["langchain-core", ["@langchain/core"]],
2967
+ // deprecated
2968
+ ["react-native-utils", []],
2969
+ // a 2015 native module
2970
+ ["next-middleware", []],
2971
+ // a 2017 0.0.1; Next.js has middleware built in
2972
+ ["supabase-client", ["@supabase/supabase-js"]],
2973
+ // unpublished
2974
+ ["stripe-sdk", ["stripe"]],
2975
+ // not on npm
2976
+ ["prisma-client", ["@prisma/client"]],
2977
+ // an empty 0.0.0 placeholder
2978
+ ["tailwind-utils", []],
2979
+ // a 2021 alpha
2980
+ ["express-validator-v2", ["express-validator"]],
2981
+ // not on npm
2982
+ ["node-postgres-pool", ["pg"]],
2983
+ // not on npm
2984
+ ["mongo-client", ["mongodb"]],
2985
+ // a 2013 wrapper
2986
+ ["redis-client", ["redis", "ioredis"]],
2987
+ // a 2015 client
2988
+ ["aws-s3-upload", ["@aws-sdk/client-s3"]]
2989
+ // a 2018 0.0.2
2753
2990
  ]);
2754
- var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2991
+ var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
2992
+ // 2-char names
2755
2993
  "pg",
2756
2994
  "ws",
2757
2995
  "ms",
@@ -2765,7 +3003,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2765
3003
  "io",
2766
3004
  "db",
2767
3005
  "fp",
2768
- "rx"
3006
+ "rx",
3007
+ // -js suffix
3008
+ "crypto-js",
3009
+ "chroma-js",
3010
+ "libphonenumber-js",
3011
+ "ua-parser-js",
3012
+ "keycloak-js",
3013
+ "pusher-js",
3014
+ "matter-js",
3015
+ "amplitude-js",
3016
+ // Vanity prefixes
3017
+ "fast-glob",
3018
+ "fast-deep-equal",
3019
+ "fast-json-stable-stringify",
3020
+ "fast-xml-parser",
3021
+ "fast-levenshtein",
3022
+ "fast-safe-stringify",
3023
+ "fast-uri",
3024
+ "fast-equals",
3025
+ "fast-csv",
3026
+ "fast-check",
3027
+ "fast-diff",
3028
+ "simple-git",
3029
+ "simple-peer",
3030
+ "simple-icons"
2769
3031
  ]);
2770
3032
  var SUSPICIOUS_PATTERNS = [
2771
3033
  /^[a-z]{1,2}$/,
@@ -2775,6 +3037,9 @@ var SUSPICIOUS_PATTERNS = [
2775
3037
  /^(the|my|simple|easy|fast|super|mega|ultra)-/
2776
3038
  // vanity prefixes
2777
3039
  ];
3040
+ function quoteList(names) {
3041
+ return names.map((n) => `"${n}"`).join(" or ");
3042
+ }
2778
3043
  var phantomDependencyRule = {
2779
3044
  id: "phantom-dependency",
2780
3045
  name: "Phantom Dependency",
@@ -2793,20 +3058,21 @@ var phantomDependencyRule = {
2793
3058
  ...project.packageJson.devDependencies ?? {}
2794
3059
  };
2795
3060
  for (const [name, _version] of Object.entries(deps)) {
2796
- if (PHANTOM_PACKAGES.has(name)) {
3061
+ const meant = PHANTOM_PACKAGES.get(name);
3062
+ if (meant) {
2797
3063
  findings.push({
2798
3064
  ruleId: "phantom-dependency",
2799
3065
  file: "package.json",
2800
3066
  line: 1,
2801
3067
  column: 1,
2802
- message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
3068
+ message: meant.length > 0 ? `"${name}" is a commonly hallucinated package name \u2014 did you mean ${quoteList(meant)}?` : `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
2803
3069
  severity: "warning",
2804
3070
  category: "security",
2805
- fix: "Add the missing package to dependencies in package.json, or remove the import if the package is hallucinated"
3071
+ fix: meant.length > 0 ? `Check "${name}" on npm before relying on it. If you meant ${quoteList(meant)}, install that instead and update the code that imports "${name}"` : `Check "${name}" on npm before relying on it \u2014 it may not exist, or may not be the package you meant`
2806
3072
  });
2807
3073
  }
2808
3074
  for (const pattern of SUSPICIOUS_PATTERNS) {
2809
- if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
3075
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
2810
3076
  findings.push({
2811
3077
  ruleId: "phantom-dependency",
2812
3078
  file: "package.json",
@@ -2992,10 +3258,11 @@ var insecureRandomRule = {
2992
3258
  };
2993
3259
 
2994
3260
  // src/rules/next-server-action-validation.ts
2995
- var USE_SERVER = /['"]use server['"]/;
3261
+ var USE_SERVER = /^\s*['"]use server['"]/m;
2996
3262
  var FORM_DATA_GET = /formData\.get\s*\(/;
2997
3263
  var VALIDATION_PATTERNS2 = [
2998
- /\.parse\s*\(/,
3264
+ // A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
3265
+ /(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
2999
3266
  /\.safeParse\s*\(/,
3000
3267
  /\bvalidate\s*\(/,
3001
3268
  /\.parseAsync\s*\(/,
@@ -3146,6 +3413,30 @@ function findEnclosingFunction(node, parentMap) {
3146
3413
  }
3147
3414
 
3148
3415
  // src/rules/redirect-in-try-catch.ts
3416
+ var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
3417
+ function rethrowsRedirect(handler) {
3418
+ const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
3419
+ if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
3420
+ return true;
3421
+ }
3422
+ return subtreeContains(handler.body, (n) => n.type === "CallExpression" && n.callee.type === "Identifier" && RETHROW_HELPERS.test(n.callee.name) || n.type === "StringLiteral" && n.value.includes("NEXT_REDIRECT"));
3423
+ }
3424
+ function bodyRethrowsRedirect(body, param) {
3425
+ if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
3426
+ return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
3427
+ }
3428
+ function redirectFinding(file, line, column) {
3429
+ return {
3430
+ ruleId: "redirect-in-try-catch",
3431
+ file: file.relativePath,
3432
+ line,
3433
+ column,
3434
+ message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
3435
+ severity: "critical",
3436
+ category: "reliability",
3437
+ fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
3438
+ };
3439
+ }
3149
3440
  var redirectInTryCatchRule = {
3150
3441
  id: "redirect-in-try-catch",
3151
3442
  name: "Redirect Inside Try/Catch",
@@ -3157,44 +3448,42 @@ var redirectInTryCatchRule = {
3157
3448
  if (isTestFile(file.relativePath)) return [];
3158
3449
  if (!/redirect\s*\(/.test(file.content)) return [];
3159
3450
  const findings = [];
3160
- let tryDepth = 0;
3161
- for (let i = 0; i < file.lines.length; i++) {
3162
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
3163
- const line = file.lines[i];
3164
- const trimmed = line.trim();
3165
- if (/\btry\s*\{/.test(trimmed) || trimmed === "try {") {
3166
- tryDepth++;
3167
- }
3168
- if (/\}\s*catch\s*[\s(]/.test(trimmed)) {
3169
- }
3170
- if (tryDepth > 0) {
3171
- const match = /\bredirect\s*\(/.exec(line);
3172
- if (match && !/\/\//.test(line.slice(0, match.index))) {
3173
- findings.push({
3174
- ruleId: "redirect-in-try-catch",
3175
- file: file.relativePath,
3176
- line: i + 1,
3177
- column: match.index + 1,
3178
- message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
3179
- severity: "critical",
3180
- category: "reliability",
3181
- fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
3451
+ const seen = /* @__PURE__ */ new Set();
3452
+ if (file.ast) {
3453
+ try {
3454
+ walkAST(file.ast.program, (node) => {
3455
+ if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
3456
+ walkAST(node.block, (n) => {
3457
+ if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
3458
+ if (!n.loc || n.start == null || seen.has(n.start)) return;
3459
+ seen.add(n.start);
3460
+ findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
3182
3461
  });
3183
- }
3184
- }
3185
- for (const ch of trimmed) {
3186
- if (ch === "{" && tryDepth > 0) {
3187
- }
3462
+ });
3463
+ return findings;
3464
+ } catch {
3465
+ findings.length = 0;
3466
+ seen.clear();
3188
3467
  }
3189
- if (tryDepth > 0 && /^\}\s*$/.test(trimmed)) {
3190
- let nextLine = "";
3191
- for (let j = i + 1; j < file.lines.length; j++) {
3192
- nextLine = file.lines[j].trim();
3193
- if (nextLine) break;
3194
- }
3195
- if (!/^catch\b/.test(nextLine) && !/^finally\b/.test(nextLine)) {
3196
- tryDepth--;
3197
- }
3468
+ }
3469
+ const source = file.lines.join("\n");
3470
+ const code = blankOut(source, true);
3471
+ const noComments = blankOut(source, false);
3472
+ for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
3473
+ const open = tryMatch.index + tryMatch[0].length - 1;
3474
+ const close = closingBrace(code, open);
3475
+ if (close === -1) continue;
3476
+ const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
3477
+ if (!catchMatch) continue;
3478
+ const catchOpen = close + 1 + catchMatch[0].length - 1;
3479
+ const catchClose = closingBrace(code, catchOpen);
3480
+ if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
3481
+ for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
3482
+ const offset = open + 1 + call.index;
3483
+ if (seen.has(offset)) continue;
3484
+ seen.add(offset);
3485
+ const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
3486
+ findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
3198
3487
  }
3199
3488
  }
3200
3489
  return findings;
@@ -3487,7 +3776,7 @@ var missingWebhookVerificationRule = {
3487
3776
  };
3488
3777
 
3489
3778
  // src/rules/server-action-auth.ts
3490
- var USE_SERVER3 = /['"]use server['"]/;
3779
+ var USE_SERVER3 = /^\s*['"]use server['"]/m;
3491
3780
  var AUTH_PATTERNS2 = [
3492
3781
  /getServerSession\s*\(/,
3493
3782
  /getSession\s*\(/,
@@ -3504,7 +3793,13 @@ var AUTH_PATTERNS2 = [
3504
3793
  /currentUser\s*\(/,
3505
3794
  /getAuth\s*\(/,
3506
3795
  /cookies\(\).*auth/s,
3507
- /session/i
3796
+ // Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
3797
+ // verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
3798
+ /\w*session\w*\s*\(/i,
3799
+ // ...reading a session cookie, or using and guarding a session object
3800
+ /cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
3801
+ /\bsession\??\.user(?:Id)?\b/,
3802
+ /!\s*session\b/
3508
3803
  ];
3509
3804
  var PUBLIC_ACTION_NAMES = [
3510
3805
  /contact/i,
@@ -3678,13 +3973,15 @@ var nextPublicSensitiveRule = {
3678
3973
  description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
3679
3974
  category: "security",
3680
3975
  severity: "critical",
3681
- fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env", "env.local", "env.production"],
3976
+ // The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
3977
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
3682
3978
  check(file, _project) {
3683
3979
  if (isTestFile(file.relativePath)) return [];
3684
3980
  const findings = [];
3981
+ const isEnvFile = file.ext === "env";
3685
3982
  for (let i = 0; i < file.lines.length; i++) {
3686
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
3687
3983
  const line = file.lines[i];
3984
+ if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
3688
3985
  const match = SENSITIVE_PATTERN.exec(line);
3689
3986
  if (match) {
3690
3987
  findings.push({
@@ -4147,7 +4444,17 @@ var unsafeFileUploadRule = {
4147
4444
 
4148
4445
  // src/rules/supabase-missing-rls.ts
4149
4446
  var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
4150
- var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
4447
+ var ENABLE_RLS = /ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(?:(?:public|"public")\.)?"?(\w+)"?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
4448
+ function isMigrationFile(file) {
4449
+ return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
4450
+ }
4451
+ function sqlProject(relativePath) {
4452
+ const dirs = relativePath.split("/").slice(0, -1);
4453
+ const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
4454
+ const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
4455
+ const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
4456
+ return dirs.slice(0, end).join("/");
4457
+ }
4151
4458
  var supabaseMissingRlsRule = {
4152
4459
  id: "supabase-missing-rls",
4153
4460
  name: "Missing Row-Level Security",
@@ -4155,42 +4462,36 @@ var supabaseMissingRlsRule = {
4155
4462
  category: "security",
4156
4463
  severity: "critical",
4157
4464
  fileExtensions: ["sql"],
4158
- check(file, project) {
4159
- if (isTestFile(file.relativePath)) return [];
4160
- if (!/migration|supabase|schema/i.test(file.relativePath)) return [];
4161
- const content = file.content;
4465
+ check() {
4466
+ return [];
4467
+ },
4468
+ checkProject(files, _project) {
4162
4469
  const findings = [];
4163
- const tables = [];
4164
- CREATE_TABLE.lastIndex = 0;
4165
- let match;
4166
- while ((match = CREATE_TABLE.exec(content)) !== null) {
4167
- const name = match[1];
4168
- if (name.startsWith("_") || name === "schema_migrations") continue;
4169
- const beforeMatch = content.slice(0, match.index);
4170
- const line = beforeMatch.split("\n").length;
4171
- tables.push({ name, line });
4172
- }
4173
- if (tables.length === 0) return [];
4174
4470
  const rlsTables = /* @__PURE__ */ new Set();
4175
- ENABLE_RLS.lastIndex = 0;
4176
- while ((match = ENABLE_RLS.exec(content)) !== null) {
4177
- rlsTables.add(match[1].toLowerCase());
4178
- }
4179
- for (const filePath of project.allFiles) {
4180
- if (!filePath.endsWith(".sql")) continue;
4181
- if (filePath === file.relativePath) continue;
4471
+ for (const file of files) {
4472
+ if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
4473
+ const project = sqlProject(file.relativePath);
4474
+ for (const match of file.content.matchAll(ENABLE_RLS)) {
4475
+ rlsTables.add(`${project}::${match[1].toLowerCase()}`);
4476
+ }
4182
4477
  }
4183
- for (const table of tables) {
4184
- if (!rlsTables.has(table.name.toLowerCase())) {
4478
+ for (const file of files) {
4479
+ if (!isMigrationFile(file)) continue;
4480
+ const content = file.content;
4481
+ const project = sqlProject(file.relativePath);
4482
+ for (const match of content.matchAll(CREATE_TABLE)) {
4483
+ const name = match[1];
4484
+ if (name.startsWith("_") || name === "schema_migrations") continue;
4485
+ if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
4185
4486
  findings.push({
4186
4487
  ruleId: "supabase-missing-rls",
4187
4488
  file: file.relativePath,
4188
- line: table.line,
4489
+ line: content.slice(0, match.index).split("\n").length,
4189
4490
  column: 1,
4190
- message: `Table "${table.name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
4491
+ message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
4191
4492
  severity: "critical",
4192
4493
  category: "security",
4193
- fix: `Add: ALTER TABLE ${table.name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
4494
+ fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
4194
4495
  });
4195
4496
  }
4196
4497
  }
@@ -4521,10 +4822,15 @@ async function scan(options) {
4521
4822
  }
4522
4823
  }
4523
4824
  }
4825
+ const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
4524
4826
  for (const rule of rules) {
4525
4827
  if (rule.checkProject) {
4526
- const projectFindings = rule.checkProject(allFiles, project);
4527
- findings.push(...projectFindings);
4828
+ for (const finding of rule.checkProject(allFiles, project)) {
4829
+ const file = filesByPath.get(finding.file);
4830
+ if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
4831
+ findings.push(finding);
4832
+ }
4833
+ }
4528
4834
  }
4529
4835
  }
4530
4836
  const { overallScore, categoryScores } = calculateScores(findings);
@@ -4693,11 +4999,7 @@ function reportSarif(result) {
4693
4999
  ]
4694
5000
  };
4695
5001
  if (f.fix) {
4696
- sarifResult.fixes = [
4697
- {
4698
- description: { text: f.fix }
4699
- }
4700
- ];
5002
+ sarifResult.properties = { fix: f.fix };
4701
5003
  }
4702
5004
  return sarifResult;
4703
5005
  });