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/cli.js CHANGED
@@ -7,7 +7,7 @@ import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
7
7
  // src/utils/file-walker.ts
8
8
  import fg from "fast-glob";
9
9
  import { readFile, stat, realpath } from "fs/promises";
10
- import { resolve, extname, sep } from "path";
10
+ import { resolve, extname, basename, sep } from "path";
11
11
 
12
12
  // src/utils/patterns.ts
13
13
  function isApiRoute(relativePath) {
@@ -50,6 +50,19 @@ function isCommentLine(lines, lineIndex, commentMap) {
50
50
  const trimmed = lines[lineIndex]?.trim() ?? "";
51
51
  return trimmed.startsWith("//");
52
52
  }
53
+ var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
54
+ function blankOut(source, strings) {
55
+ const blank = (s) => s.replace(/[^\n]/g, " ");
56
+ return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
57
+ }
58
+ function closingBrace(code, open) {
59
+ let depth = 0;
60
+ for (let i = open; i < code.length; i++) {
61
+ if (code[i] === "{") depth++;
62
+ else if (code[i] === "}" && --depth === 0) return i;
63
+ }
64
+ return -1;
65
+ }
53
66
  function isLineSuppressed(lines, lineIndex, ruleId) {
54
67
  for (const line of lines) {
55
68
  const trimmed = line.trim();
@@ -440,7 +453,8 @@ async function readFileContext(root, relativePath) {
440
453
  if (fileStats.size > MAX_FILE_SIZE) return null;
441
454
  const content = await readFile(absolutePath, "utf-8");
442
455
  const lines = content.split(/\r?\n|\r/);
443
- const ext = extname(relativePath).slice(1);
456
+ const rawExt = extname(relativePath).slice(1);
457
+ const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
444
458
  let ast = void 0;
445
459
  if (AST_EXTENSIONS.has(ext)) {
446
460
  try {
@@ -746,12 +760,21 @@ function summarizeFindings(findings) {
746
760
  }
747
761
 
748
762
  // src/rules/secrets.ts
763
+ function nameJwt(token) {
764
+ try {
765
+ const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
766
+ if (claims?.role === "anon") return null;
767
+ if (claims?.role === "service_role") return "Supabase service role key";
768
+ } catch {
769
+ }
770
+ return "JWT";
771
+ }
749
772
  var SECRET_PATTERNS = [
750
773
  { name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
751
774
  { name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
752
775
  { name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
753
776
  { name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
754
- { name: "Supabase service role key", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/ },
777
+ { name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
755
778
  { name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
756
779
  { name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
757
780
  { name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
@@ -786,15 +809,17 @@ var secretsRule = {
786
809
  for (let i = 0; i < file.lines.length; i++) {
787
810
  const line = file.lines[i];
788
811
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
789
- for (const { name, pattern } of SECRET_PATTERNS) {
812
+ for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
790
813
  const match = pattern.exec(line);
791
814
  if (match) {
815
+ const reported = nameMatch ? nameMatch(match[0]) : name;
816
+ if (!reported) continue;
792
817
  findings.push({
793
818
  ruleId: "secrets",
794
819
  file: file.relativePath,
795
820
  line: i + 1,
796
821
  column: match.index + 1,
797
- message: `Hardcoded ${name} detected`,
822
+ message: `Hardcoded ${reported} detected`,
798
823
  severity: "critical",
799
824
  category: "security",
800
825
  fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
@@ -950,12 +975,208 @@ var AUTH_PATTERNS = [
950
975
  /createRouteHandlerClient/,
951
976
  /createServerComponentClient/,
952
977
  /createMiddlewareClient/,
953
- /authorization/i,
978
+ // Reading the request's Authorization header, not sending one upstream:
979
+ // headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
980
+ /\.(?:get|header)\(\s*['"`]authorization['"`]/i,
981
+ /\.headers\??\.authorization\b(?!\s*=(?!=))/i,
982
+ /\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
954
983
  /getAuth\s*\(/,
955
984
  /withPageAuth/,
956
985
  /cookies\(\).*auth/s
957
986
  ];
958
- var MUTATION_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/;
987
+ var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
988
+ var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
989
+ var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
990
+ var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
991
+ var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
992
+ function words(name) {
993
+ return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
994
+ }
995
+ function saysAuth(name) {
996
+ const w = words(name);
997
+ return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
998
+ }
999
+ function isAuthHelper(name, source) {
1000
+ if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
1001
+ return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
1002
+ }
1003
+ var AUTHED = { authed: true, local: true };
1004
+ var LOCAL = { authed: false, local: true };
1005
+ var ELSEWHERE = { authed: false, local: false };
1006
+ function handlersFromAst(program) {
1007
+ const imports = /* @__PURE__ */ new Map();
1008
+ const bindings = /* @__PURE__ */ new Map();
1009
+ for (const st of program.body) {
1010
+ if (st.type === "ImportDeclaration") {
1011
+ for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
1012
+ continue;
1013
+ }
1014
+ const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
1015
+ if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
1016
+ if (decl?.type === "VariableDeclaration") {
1017
+ for (const d of decl.declarations) {
1018
+ if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
1019
+ }
1020
+ }
1021
+ }
1022
+ const helperOf = (callee) => {
1023
+ if (callee.type === "CallExpression") return helperOf(callee.callee);
1024
+ if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
1025
+ if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
1026
+ let root = callee.object;
1027
+ while (root.type === "MemberExpression") root = root.object;
1028
+ return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
1029
+ }
1030
+ return null;
1031
+ };
1032
+ const classify = (node, seen) => {
1033
+ while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
1034
+ node = node.type === "AwaitExpression" ? node.argument : node.expression;
1035
+ }
1036
+ switch (node.type) {
1037
+ case "FunctionDeclaration":
1038
+ case "FunctionExpression":
1039
+ case "ArrowFunctionExpression":
1040
+ return LOCAL;
1041
+ case "Identifier": {
1042
+ const bound = bindings.get(node.name);
1043
+ if (!bound || seen.has(node.name)) return ELSEWHERE;
1044
+ return classify(bound, new Set(seen).add(node.name));
1045
+ }
1046
+ case "CallExpression": {
1047
+ const helper = helperOf(node.callee);
1048
+ if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
1049
+ let local = false;
1050
+ for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
1051
+ const verdict = classify(arg, seen);
1052
+ if (verdict.authed) return verdict;
1053
+ local ||= verdict.local;
1054
+ }
1055
+ return { authed: false, local };
1056
+ }
1057
+ }
1058
+ return ELSEWHERE;
1059
+ };
1060
+ const handlers = [];
1061
+ const add = (method, at, verdict) => {
1062
+ handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
1063
+ };
1064
+ for (const st of program.body) {
1065
+ if (st.type === "ExportNamedDeclaration") {
1066
+ const decl = st.declaration;
1067
+ if (decl?.type === "FunctionDeclaration") {
1068
+ if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
1069
+ } else if (decl?.type === "VariableDeclaration") {
1070
+ for (const d of decl.declarations) {
1071
+ if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
1072
+ add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
1073
+ } else if (d.id.type === "ObjectPattern") {
1074
+ for (const p of d.id.properties) {
1075
+ if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
1076
+ add(p.value.name, d, ELSEWHERE);
1077
+ }
1078
+ }
1079
+ }
1080
+ }
1081
+ } else if (!decl) {
1082
+ for (const s of st.specifiers) {
1083
+ if (s.type !== "ExportSpecifier") continue;
1084
+ const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
1085
+ if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
1086
+ }
1087
+ }
1088
+ } else if (st.type === "ExportDefaultDeclaration") {
1089
+ add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
1090
+ } else if (st.type === "ExportAllDeclaration") {
1091
+ add("*", st, ELSEWHERE);
1092
+ }
1093
+ }
1094
+ return handlers;
1095
+ }
1096
+ var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
1097
+ var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
1098
+ var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
1099
+ var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
1100
+ var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
1101
+ var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
1102
+ var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
1103
+ var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
1104
+ var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
1105
+ var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
1106
+ var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
1107
+ function handlersFromText(file) {
1108
+ const imports = /* @__PURE__ */ new Map();
1109
+ for (const m of file.content.matchAll(IMPORT)) {
1110
+ for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
1111
+ const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
1112
+ if (spec) imports.set(spec[2] ?? spec[1], m[2]);
1113
+ }
1114
+ }
1115
+ const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
1116
+ const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
1117
+ const resolve4 = (name, seen) => {
1118
+ const id = name.replace(/\$/g, "\\$");
1119
+ const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
1120
+ const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
1121
+ for (let i = 0; i < file.lines.length; i++) {
1122
+ if (!code(i)) continue;
1123
+ if (fn.test(file.lines[i])) return LOCAL;
1124
+ const m = variable.exec(file.lines[i]);
1125
+ if (m) return classify(value(i, m[1]), seen);
1126
+ }
1127
+ return ELSEWHERE;
1128
+ };
1129
+ const classify = (text, seen) => {
1130
+ const t = text.trim().replace(/^await\s+/, "");
1131
+ if (INLINE_FUNCTION.test(t)) return LOCAL;
1132
+ const call = CALL.exec(t);
1133
+ if (call) {
1134
+ if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
1135
+ let args = t.slice(call[0].length);
1136
+ const curried = /^[^()]*\)\s*\(/.exec(args);
1137
+ if (curried) args = args.slice(curried[0].length);
1138
+ return classify(args, seen);
1139
+ }
1140
+ const id = IDENTIFIER.exec(t);
1141
+ if (id && !seen.has(id[1])) return resolve4(id[1], new Set(seen).add(id[1]));
1142
+ return ELSEWHERE;
1143
+ };
1144
+ const handlers = [];
1145
+ for (let i = 0; i < file.lines.length; i++) {
1146
+ if (!code(i)) continue;
1147
+ const line = file.lines[i];
1148
+ let m;
1149
+ if (m = EXPORT_FUNCTION.exec(line)) {
1150
+ handlers.push({ method: m[1], line: i + 1, ...LOCAL });
1151
+ } else if (m = EXPORT_CONST.exec(line)) {
1152
+ handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
1153
+ } else if (m = EXPORT_DESTRUCTURED.exec(line)) {
1154
+ for (const part of m[1].split(",")) {
1155
+ const name = part.split(":").pop()?.trim() ?? "";
1156
+ if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
1157
+ }
1158
+ } else if (m = EXPORT_DEFAULT.exec(line)) {
1159
+ handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
1160
+ } else if (EXPORT_ALL.test(line)) {
1161
+ handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
1162
+ }
1163
+ }
1164
+ const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
1165
+ for (const m of file.content.matchAll(EXPORT_LIST)) {
1166
+ if (!code(lineAt(m.index) - 1)) continue;
1167
+ let offset = m.index + m[0].indexOf("{") + 1;
1168
+ for (const part of m[1].split(",")) {
1169
+ const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
1170
+ const name = spec ? spec[2] ?? spec[1] : "";
1171
+ if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
1172
+ const line = lineAt(offset + part.length - part.trimStart().length);
1173
+ handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
1174
+ }
1175
+ offset += part.length + 1;
1176
+ }
1177
+ }
1178
+ return handlers.sort((a, b) => a.line - b.line);
1179
+ }
959
1180
  var authChecksRule = {
960
1181
  id: "auth-checks",
961
1182
  name: "Missing Auth Checks",
@@ -971,21 +1192,18 @@ var authChecksRule = {
971
1192
  for (const pattern of AUTH_PATTERNS) {
972
1193
  if (pattern.test(file.content)) return [];
973
1194
  }
1195
+ const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
1196
+ const exposed = handlers.filter((h) => !h.authed);
1197
+ if (handlers.length > 0 && exposed.length === 0) return [];
974
1198
  let severity;
975
1199
  if (project.hasAuthMiddleware) {
976
1200
  severity = "info";
977
- } else if (MUTATION_EXPORT.test(file.content)) {
1201
+ } else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
978
1202
  severity = "critical";
979
1203
  } else {
980
1204
  severity = "info";
981
1205
  }
982
- let handlerLine = 1;
983
- for (let i = 0; i < file.lines.length; i++) {
984
- if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
985
- handlerLine = i + 1;
986
- break;
987
- }
988
- }
1206
+ const handlerLine = exposed[0]?.line ?? 1;
989
1207
  const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
990
1208
  return [{
991
1209
  ruleId: "auth-checks",
@@ -1104,7 +1322,8 @@ var errorHandlingRule = {
1104
1322
 
1105
1323
  // src/rules/input-validation.ts
1106
1324
  var VALIDATION_PATTERNS = [
1107
- /\.parse\s*\(/,
1325
+ // A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
1326
+ /(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
1108
1327
  /\.safeParse\s*\(/,
1109
1328
  /\.validate\s*\(/,
1110
1329
  /\.validateSync\s*\(/,
@@ -1405,6 +1624,40 @@ var aiSmellsRule = {
1405
1624
  };
1406
1625
 
1407
1626
  // src/rules/unsafe-html.ts
1627
+ var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
1628
+ var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
1629
+ function isSanitizerCall(node) {
1630
+ if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
1631
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
1632
+ return isSanitizerCall(node.expression);
1633
+ }
1634
+ if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
1635
+ const callee = node.callee;
1636
+ if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
1637
+ return callee.property.type === "Identifier" && callee.property.name === "sanitize";
1638
+ }
1639
+ return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
1640
+ }
1641
+ function containsJsonStringify(node) {
1642
+ return subtreeContains(node, (n) => {
1643
+ if (n.type !== "CallExpression") return false;
1644
+ const call = n;
1645
+ if (call.callee.type === "MemberExpression") {
1646
+ const mem = call.callee;
1647
+ return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1648
+ }
1649
+ return false;
1650
+ });
1651
+ }
1652
+ function isSanitized(value, sanitizedNames) {
1653
+ if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
1654
+ if (value.type === "ObjectExpression") {
1655
+ 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"));
1656
+ return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
1657
+ }
1658
+ if (value.type === "Identifier") return sanitizedNames.has(value.name);
1659
+ return isSanitizerCall(value);
1660
+ }
1408
1661
  var unsafeHtmlRule = {
1409
1662
  id: "unsafe-html",
1410
1663
  name: "Unsafe HTML Rendering",
@@ -1416,21 +1669,17 @@ var unsafeHtmlRule = {
1416
1669
  const findings = [];
1417
1670
  if (file.ast) {
1418
1671
  try {
1672
+ const sanitizedNames = /* @__PURE__ */ new Set();
1673
+ walkAST(file.ast.program, (node) => {
1674
+ if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
1675
+ sanitizedNames.add(node.id.name);
1676
+ }
1677
+ });
1419
1678
  walkAST(file.ast.program, (node) => {
1420
1679
  if (node.type === "JSXAttribute") {
1421
1680
  const attr = node;
1422
1681
  if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
1423
- if (attr.value && subtreeContains(attr.value, (n) => {
1424
- if (n.type !== "CallExpression") return false;
1425
- const call = n;
1426
- if (call.callee.type === "MemberExpression") {
1427
- const mem = call.callee;
1428
- return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1429
- }
1430
- return false;
1431
- })) {
1432
- return;
1433
- }
1682
+ if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
1434
1683
  const line = attr.loc.start.line;
1435
1684
  findings.push({
1436
1685
  ruleId: "unsafe-html",
@@ -1447,17 +1696,7 @@ var unsafeHtmlRule = {
1447
1696
  if (node.type === "ObjectProperty") {
1448
1697
  const prop = node;
1449
1698
  if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
1450
- if (prop.value && subtreeContains(prop.value, (n) => {
1451
- if (n.type !== "CallExpression") return false;
1452
- const call = n;
1453
- if (call.callee.type === "MemberExpression") {
1454
- const mem = call.callee;
1455
- return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
1456
- }
1457
- return false;
1458
- })) {
1459
- return;
1460
- }
1699
+ if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
1461
1700
  const line = prop.loc.start.line;
1462
1701
  findings.push({
1463
1702
  ruleId: "unsafe-html",
@@ -1474,6 +1713,7 @@ var unsafeHtmlRule = {
1474
1713
  if (node.type === "AssignmentExpression") {
1475
1714
  const assign = node;
1476
1715
  if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
1716
+ if (isSanitized(assign.right, sanitizedNames)) return;
1477
1717
  const line = assign.loc.start.line;
1478
1718
  findings.push({
1479
1719
  ruleId: "unsafe-html",
@@ -1504,6 +1744,8 @@ var unsafeHtmlRule = {
1504
1744
  }
1505
1745
  const expr = context.join(" ");
1506
1746
  if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
1747
+ const html = /__html\s*:/.exec(expr);
1748
+ if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
1507
1749
  findings.push({
1508
1750
  ruleId: "unsafe-html",
1509
1751
  file: file.relativePath,
@@ -1515,7 +1757,8 @@ var unsafeHtmlRule = {
1515
1757
  fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
1516
1758
  });
1517
1759
  }
1518
- if (/\w\.innerHTML\s*=/.test(line)) {
1760
+ const innerHtml = /\w\.innerHTML\s*=/.exec(line);
1761
+ if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
1519
1762
  findings.push({
1520
1763
  ruleId: "unsafe-html",
1521
1764
  file: file.relativePath,
@@ -1694,9 +1937,16 @@ var staleFallbackRule = {
1694
1937
  };
1695
1938
 
1696
1939
  // src/rules/hallucinated-api.ts
1940
+ var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
1941
+ var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
1697
1942
  var HALLUCINATED_APIS = [
1698
1943
  { pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
1699
- { pattern: /\.contains\s*\(/, fix: "Use .includes() instead of .contains()", methodName: "contains" },
1944
+ {
1945
+ pattern: /\.contains\s*\(/,
1946
+ fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
1947
+ methodName: "contains",
1948
+ isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
1949
+ },
1700
1950
  { pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
1701
1951
  { pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
1702
1952
  { pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
@@ -1718,10 +1968,11 @@ var hallucinatedApiRule = {
1718
1968
  for (let i = 0; i < file.lines.length; i++) {
1719
1969
  if (isCommentLine(file.lines, i, file.commentMap)) continue;
1720
1970
  const line = file.lines[i];
1721
- for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
1971
+ for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
1722
1972
  const match = pattern.exec(line);
1723
1973
  if (match) {
1724
1974
  if (isFrameworkSafeMethod(methodName, frameworks)) continue;
1975
+ if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
1725
1976
  findings.push({
1726
1977
  ruleId: "hallucinated-api",
1727
1978
  file: file.relativePath,
@@ -2361,6 +2612,13 @@ function isEntryPoint(relativePath) {
2361
2612
  const name = relativePath.split("/").pop() ?? "";
2362
2613
  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";
2363
2614
  }
2615
+ var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
2616
+ var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
2617
+ var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
2618
+ var FROM_SOURCE = /from\s+['"]([^'"]+)['"]/g;
2619
+ function sourceBasename(source) {
2620
+ return source.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2621
+ }
2364
2622
  var THRESHOLD = 5;
2365
2623
  var deadExportsRule = {
2366
2624
  id: "dead-exports",
@@ -2380,6 +2638,7 @@ var deadExportsRule = {
2380
2638
  const imports = /* @__PURE__ */ new Map();
2381
2639
  const allImportedSymbols = /* @__PURE__ */ new Set();
2382
2640
  const importedFiles = /* @__PURE__ */ new Set();
2641
+ const namespaceImported = /* @__PURE__ */ new Set();
2383
2642
  for (const file of sourceFiles) {
2384
2643
  if (isEntryPoint(file.relativePath)) continue;
2385
2644
  let inTemplateLiteral = false;
@@ -2422,42 +2681,35 @@ var deadExportsRule = {
2422
2681
  }
2423
2682
  }
2424
2683
  }
2684
+ const addImport = (sym, source) => {
2685
+ allImportedSymbols.add(sym);
2686
+ const fromBasename = sourceBasename(source);
2687
+ if (fromBasename) {
2688
+ const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2689
+ set.add(sym);
2690
+ imports.set(fromBasename, set);
2691
+ }
2692
+ };
2425
2693
  for (const file of files) {
2426
- for (const line of file.lines) {
2427
- let match;
2428
- const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
2429
- const fromBasename = fromMatch ? fromMatch[1].split("/").pop()?.replace(/\.\w+$/, "") ?? "" : "";
2430
- const bracesRe = /import\s*(?:type\s*)?\{([^}]+)\}\s*from/g;
2431
- while ((match = bracesRe.exec(line)) !== null) {
2432
- const symbols = match[1].split(",").map((s) => s.trim().split(/\s+as\s+/)[0].trim()).filter(Boolean);
2433
- for (const sym of symbols) {
2434
- allImportedSymbols.add(sym);
2435
- if (fromBasename) {
2436
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2437
- set.add(sym);
2438
- imports.set(fromBasename, set);
2439
- }
2440
- }
2441
- }
2442
- const defaultRe = /import\s+(\w+)\s+from/g;
2443
- while ((match = defaultRe.exec(line)) !== null) {
2444
- allImportedSymbols.add(match[1]);
2445
- if (fromBasename) {
2446
- const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
2447
- set.add(match[1]);
2448
- imports.set(fromBasename, set);
2449
- }
2450
- }
2451
- const fromRe = /from\s+['"]([^'"]+)['"]/g;
2452
- while ((match = fromRe.exec(line)) !== null) {
2453
- importedFiles.add(match[1]);
2454
- }
2694
+ for (const match of file.content.matchAll(IMPORT_NAMED)) {
2695
+ 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);
2696
+ for (const sym of symbols) addImport(sym, match[2]);
2697
+ }
2698
+ for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
2699
+ addImport(match[1], match[2]);
2700
+ }
2701
+ for (const match of file.content.matchAll(IMPORT_NAMESPACE)) {
2702
+ namespaceImported.add(sourceBasename(match[1]));
2703
+ }
2704
+ for (const match of file.content.matchAll(FROM_SOURCE)) {
2705
+ importedFiles.add(match[1]);
2455
2706
  }
2456
2707
  }
2457
2708
  const deadByFile = /* @__PURE__ */ new Map();
2458
2709
  for (const [key, loc] of exports) {
2459
2710
  const symbolName = key.split("::")[1];
2460
2711
  const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
2712
+ if (namespaceImported.has(exportFileBasename)) continue;
2461
2713
  const importSet = imports.get(exportFileBasename);
2462
2714
  const isImported = importSet?.has(symbolName) ?? false;
2463
2715
  if (!isImported && !allImportedSymbols.has(symbolName)) {
@@ -2527,19 +2779,6 @@ function catchFinding(file, catchLine, body) {
2527
2779
  fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
2528
2780
  };
2529
2781
  }
2530
- var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
2531
- function blankOut(source, strings) {
2532
- const blank = (s) => s.replace(/[^\n]/g, " ");
2533
- return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
2534
- }
2535
- function closingBrace(code, open) {
2536
- let depth = 0;
2537
- for (let i = open; i < code.length; i++) {
2538
- if (code[i] === "{") depth++;
2539
- else if (code[i] === "}" && --depth === 0) return i;
2540
- }
2541
- return -1;
2542
- }
2543
2782
  function returnedValues(code, text) {
2544
2783
  const values = [];
2545
2784
  for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
@@ -2717,47 +2956,46 @@ var comprehensionDebtRule = {
2717
2956
  };
2718
2957
 
2719
2958
  // src/rules/phantom-dependency.ts
2720
- var PHANTOM_PACKAGES = /* @__PURE__ */ new Set([
2721
- "huggingface-cli",
2722
- // hallucinated, real: huggingface_hub
2723
- "flask-hierarchical",
2724
- // hallucinated
2725
- "beautifulsoup",
2726
- // real is beautifulsoup4
2727
- "python-dotenv",
2728
- // real is python-dotenv (this one exists but confusable)
2729
- "openai-sdk",
2730
- // hallucinated, real: openai
2731
- "anthropic-sdk",
2732
- // hallucinated, real: @anthropic-ai/sdk
2733
- "langchain-core",
2734
- // confusable with @langchain/core
2735
- "react-native-utils",
2736
- // hallucinated generic
2737
- "next-middleware",
2738
- // hallucinated
2739
- "supabase-client",
2740
- // hallucinated, real: @supabase/supabase-js
2741
- "stripe-sdk",
2742
- // hallucinated, real: stripe
2743
- "prisma-client",
2744
- // hallucinated, real: @prisma/client
2745
- "tailwind-utils",
2746
- // hallucinated
2747
- "express-validator-v2",
2748
- // hallucinated
2749
- "node-postgres-pool",
2750
- // hallucinated, real: pg
2751
- "mongo-client",
2752
- // hallucinated, real: mongodb
2753
- "redis-client",
2754
- // hallucinated, real: redis or ioredis
2755
- "aws-s3-upload",
2756
- // hallucinated
2757
- "gpt-tokenizer"
2758
- // exists but often confused
2959
+ var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
2960
+ ["huggingface-cli", ["@huggingface/hub"]],
2961
+ // a Python CLI; an npm placeholder
2962
+ ["flask-hierarchical", []],
2963
+ // not on npm
2964
+ ["beautifulsoup", ["cheerio"]],
2965
+ // a Python library (beautifulsoup4); not on npm
2966
+ ["python-dotenv", ["dotenv"]],
2967
+ // a Python library; an npm placeholder
2968
+ ["openai-sdk", ["openai"]],
2969
+ // unpublished
2970
+ ["anthropic-sdk", ["@anthropic-ai/sdk"]],
2971
+ // an npm placeholder
2972
+ ["langchain-core", ["@langchain/core"]],
2973
+ // deprecated
2974
+ ["react-native-utils", []],
2975
+ // a 2015 native module
2976
+ ["next-middleware", []],
2977
+ // a 2017 0.0.1; Next.js has middleware built in
2978
+ ["supabase-client", ["@supabase/supabase-js"]],
2979
+ // unpublished
2980
+ ["stripe-sdk", ["stripe"]],
2981
+ // not on npm
2982
+ ["prisma-client", ["@prisma/client"]],
2983
+ // an empty 0.0.0 placeholder
2984
+ ["tailwind-utils", []],
2985
+ // a 2021 alpha
2986
+ ["express-validator-v2", ["express-validator"]],
2987
+ // not on npm
2988
+ ["node-postgres-pool", ["pg"]],
2989
+ // not on npm
2990
+ ["mongo-client", ["mongodb"]],
2991
+ // a 2013 wrapper
2992
+ ["redis-client", ["redis", "ioredis"]],
2993
+ // a 2015 client
2994
+ ["aws-s3-upload", ["@aws-sdk/client-s3"]]
2995
+ // a 2018 0.0.2
2759
2996
  ]);
2760
- var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2997
+ var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
2998
+ // 2-char names
2761
2999
  "pg",
2762
3000
  "ws",
2763
3001
  "ms",
@@ -2771,7 +3009,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
2771
3009
  "io",
2772
3010
  "db",
2773
3011
  "fp",
2774
- "rx"
3012
+ "rx",
3013
+ // -js suffix
3014
+ "crypto-js",
3015
+ "chroma-js",
3016
+ "libphonenumber-js",
3017
+ "ua-parser-js",
3018
+ "keycloak-js",
3019
+ "pusher-js",
3020
+ "matter-js",
3021
+ "amplitude-js",
3022
+ // Vanity prefixes
3023
+ "fast-glob",
3024
+ "fast-deep-equal",
3025
+ "fast-json-stable-stringify",
3026
+ "fast-xml-parser",
3027
+ "fast-levenshtein",
3028
+ "fast-safe-stringify",
3029
+ "fast-uri",
3030
+ "fast-equals",
3031
+ "fast-csv",
3032
+ "fast-check",
3033
+ "fast-diff",
3034
+ "simple-git",
3035
+ "simple-peer",
3036
+ "simple-icons"
2775
3037
  ]);
2776
3038
  var SUSPICIOUS_PATTERNS = [
2777
3039
  /^[a-z]{1,2}$/,
@@ -2781,6 +3043,9 @@ var SUSPICIOUS_PATTERNS = [
2781
3043
  /^(the|my|simple|easy|fast|super|mega|ultra)-/
2782
3044
  // vanity prefixes
2783
3045
  ];
3046
+ function quoteList(names) {
3047
+ return names.map((n) => `"${n}"`).join(" or ");
3048
+ }
2784
3049
  var phantomDependencyRule = {
2785
3050
  id: "phantom-dependency",
2786
3051
  name: "Phantom Dependency",
@@ -2799,20 +3064,21 @@ var phantomDependencyRule = {
2799
3064
  ...project.packageJson.devDependencies ?? {}
2800
3065
  };
2801
3066
  for (const [name, _version] of Object.entries(deps)) {
2802
- if (PHANTOM_PACKAGES.has(name)) {
3067
+ const meant = PHANTOM_PACKAGES.get(name);
3068
+ if (meant) {
2803
3069
  findings.push({
2804
3070
  ruleId: "phantom-dependency",
2805
3071
  file: "package.json",
2806
3072
  line: 1,
2807
3073
  column: 1,
2808
- message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
3074
+ 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`,
2809
3075
  severity: "warning",
2810
3076
  category: "security",
2811
- fix: "Add the missing package to dependencies in package.json, or remove the import if the package is hallucinated"
3077
+ 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`
2812
3078
  });
2813
3079
  }
2814
3080
  for (const pattern of SUSPICIOUS_PATTERNS) {
2815
- if (pattern.test(name) && !name.startsWith("@") && !KNOWN_SHORT_PACKAGES.has(name)) {
3081
+ if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
2816
3082
  findings.push({
2817
3083
  ruleId: "phantom-dependency",
2818
3084
  file: "package.json",
@@ -2998,10 +3264,11 @@ var insecureRandomRule = {
2998
3264
  };
2999
3265
 
3000
3266
  // src/rules/next-server-action-validation.ts
3001
- var USE_SERVER = /['"]use server['"]/;
3267
+ var USE_SERVER = /^\s*['"]use server['"]/m;
3002
3268
  var FORM_DATA_GET = /formData\.get\s*\(/;
3003
3269
  var VALIDATION_PATTERNS2 = [
3004
- /\.parse\s*\(/,
3270
+ // A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
3271
+ /(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
3005
3272
  /\.safeParse\s*\(/,
3006
3273
  /\bvalidate\s*\(/,
3007
3274
  /\.parseAsync\s*\(/,
@@ -3152,6 +3419,30 @@ function findEnclosingFunction(node, parentMap) {
3152
3419
  }
3153
3420
 
3154
3421
  // src/rules/redirect-in-try-catch.ts
3422
+ var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
3423
+ function rethrowsRedirect(handler) {
3424
+ const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
3425
+ if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
3426
+ return true;
3427
+ }
3428
+ 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"));
3429
+ }
3430
+ function bodyRethrowsRedirect(body, param) {
3431
+ if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
3432
+ return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
3433
+ }
3434
+ function redirectFinding(file, line, column) {
3435
+ return {
3436
+ ruleId: "redirect-in-try-catch",
3437
+ file: file.relativePath,
3438
+ line,
3439
+ column,
3440
+ message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
3441
+ severity: "critical",
3442
+ category: "reliability",
3443
+ 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'
3444
+ };
3445
+ }
3155
3446
  var redirectInTryCatchRule = {
3156
3447
  id: "redirect-in-try-catch",
3157
3448
  name: "Redirect Inside Try/Catch",
@@ -3163,44 +3454,42 @@ var redirectInTryCatchRule = {
3163
3454
  if (isTestFile(file.relativePath)) return [];
3164
3455
  if (!/redirect\s*\(/.test(file.content)) return [];
3165
3456
  const findings = [];
3166
- let tryDepth = 0;
3167
- for (let i = 0; i < file.lines.length; i++) {
3168
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
3169
- const line = file.lines[i];
3170
- const trimmed = line.trim();
3171
- if (/\btry\s*\{/.test(trimmed) || trimmed === "try {") {
3172
- tryDepth++;
3173
- }
3174
- if (/\}\s*catch\s*[\s(]/.test(trimmed)) {
3175
- }
3176
- if (tryDepth > 0) {
3177
- const match = /\bredirect\s*\(/.exec(line);
3178
- if (match && !/\/\//.test(line.slice(0, match.index))) {
3179
- findings.push({
3180
- ruleId: "redirect-in-try-catch",
3181
- file: file.relativePath,
3182
- line: i + 1,
3183
- column: match.index + 1,
3184
- message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
3185
- severity: "critical",
3186
- category: "reliability",
3187
- 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'
3457
+ const seen = /* @__PURE__ */ new Set();
3458
+ if (file.ast) {
3459
+ try {
3460
+ walkAST(file.ast.program, (node) => {
3461
+ if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
3462
+ walkAST(node.block, (n) => {
3463
+ if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
3464
+ if (!n.loc || n.start == null || seen.has(n.start)) return;
3465
+ seen.add(n.start);
3466
+ findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
3188
3467
  });
3189
- }
3190
- }
3191
- for (const ch of trimmed) {
3192
- if (ch === "{" && tryDepth > 0) {
3193
- }
3468
+ });
3469
+ return findings;
3470
+ } catch {
3471
+ findings.length = 0;
3472
+ seen.clear();
3194
3473
  }
3195
- if (tryDepth > 0 && /^\}\s*$/.test(trimmed)) {
3196
- let nextLine = "";
3197
- for (let j = i + 1; j < file.lines.length; j++) {
3198
- nextLine = file.lines[j].trim();
3199
- if (nextLine) break;
3200
- }
3201
- if (!/^catch\b/.test(nextLine) && !/^finally\b/.test(nextLine)) {
3202
- tryDepth--;
3203
- }
3474
+ }
3475
+ const source = file.lines.join("\n");
3476
+ const code = blankOut(source, true);
3477
+ const noComments = blankOut(source, false);
3478
+ for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
3479
+ const open = tryMatch.index + tryMatch[0].length - 1;
3480
+ const close = closingBrace(code, open);
3481
+ if (close === -1) continue;
3482
+ const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
3483
+ if (!catchMatch) continue;
3484
+ const catchOpen = close + 1 + catchMatch[0].length - 1;
3485
+ const catchClose = closingBrace(code, catchOpen);
3486
+ if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
3487
+ for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
3488
+ const offset = open + 1 + call.index;
3489
+ if (seen.has(offset)) continue;
3490
+ seen.add(offset);
3491
+ const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
3492
+ findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
3204
3493
  }
3205
3494
  }
3206
3495
  return findings;
@@ -3493,7 +3782,7 @@ var missingWebhookVerificationRule = {
3493
3782
  };
3494
3783
 
3495
3784
  // src/rules/server-action-auth.ts
3496
- var USE_SERVER3 = /['"]use server['"]/;
3785
+ var USE_SERVER3 = /^\s*['"]use server['"]/m;
3497
3786
  var AUTH_PATTERNS2 = [
3498
3787
  /getServerSession\s*\(/,
3499
3788
  /getSession\s*\(/,
@@ -3510,7 +3799,13 @@ var AUTH_PATTERNS2 = [
3510
3799
  /currentUser\s*\(/,
3511
3800
  /getAuth\s*\(/,
3512
3801
  /cookies\(\).*auth/s,
3513
- /session/i
3802
+ // Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
3803
+ // verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
3804
+ /\w*session\w*\s*\(/i,
3805
+ // ...reading a session cookie, or using and guarding a session object
3806
+ /cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
3807
+ /\bsession\??\.user(?:Id)?\b/,
3808
+ /!\s*session\b/
3514
3809
  ];
3515
3810
  var PUBLIC_ACTION_NAMES = [
3516
3811
  /contact/i,
@@ -3684,13 +3979,15 @@ var nextPublicSensitiveRule = {
3684
3979
  description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
3685
3980
  category: "security",
3686
3981
  severity: "critical",
3687
- fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env", "env.local", "env.production"],
3982
+ // The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
3983
+ fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
3688
3984
  check(file, _project) {
3689
3985
  if (isTestFile(file.relativePath)) return [];
3690
3986
  const findings = [];
3987
+ const isEnvFile = file.ext === "env";
3691
3988
  for (let i = 0; i < file.lines.length; i++) {
3692
- if (isCommentLine(file.lines, i, file.commentMap)) continue;
3693
3989
  const line = file.lines[i];
3990
+ if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
3694
3991
  const match = SENSITIVE_PATTERN.exec(line);
3695
3992
  if (match) {
3696
3993
  findings.push({
@@ -4153,7 +4450,17 @@ var unsafeFileUploadRule = {
4153
4450
 
4154
4451
  // src/rules/supabase-missing-rls.ts
4155
4452
  var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
4156
- var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
4453
+ 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;
4454
+ function isMigrationFile(file) {
4455
+ return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
4456
+ }
4457
+ function sqlProject(relativePath) {
4458
+ const dirs = relativePath.split("/").slice(0, -1);
4459
+ const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
4460
+ const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
4461
+ const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
4462
+ return dirs.slice(0, end).join("/");
4463
+ }
4157
4464
  var supabaseMissingRlsRule = {
4158
4465
  id: "supabase-missing-rls",
4159
4466
  name: "Missing Row-Level Security",
@@ -4161,42 +4468,36 @@ var supabaseMissingRlsRule = {
4161
4468
  category: "security",
4162
4469
  severity: "critical",
4163
4470
  fileExtensions: ["sql"],
4164
- check(file, project) {
4165
- if (isTestFile(file.relativePath)) return [];
4166
- if (!/migration|supabase|schema/i.test(file.relativePath)) return [];
4167
- const content = file.content;
4471
+ check() {
4472
+ return [];
4473
+ },
4474
+ checkProject(files, _project) {
4168
4475
  const findings = [];
4169
- const tables = [];
4170
- CREATE_TABLE.lastIndex = 0;
4171
- let match;
4172
- while ((match = CREATE_TABLE.exec(content)) !== null) {
4173
- const name = match[1];
4174
- if (name.startsWith("_") || name === "schema_migrations") continue;
4175
- const beforeMatch = content.slice(0, match.index);
4176
- const line = beforeMatch.split("\n").length;
4177
- tables.push({ name, line });
4178
- }
4179
- if (tables.length === 0) return [];
4180
4476
  const rlsTables = /* @__PURE__ */ new Set();
4181
- ENABLE_RLS.lastIndex = 0;
4182
- while ((match = ENABLE_RLS.exec(content)) !== null) {
4183
- rlsTables.add(match[1].toLowerCase());
4184
- }
4185
- for (const filePath of project.allFiles) {
4186
- if (!filePath.endsWith(".sql")) continue;
4187
- if (filePath === file.relativePath) continue;
4477
+ for (const file of files) {
4478
+ if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
4479
+ const project = sqlProject(file.relativePath);
4480
+ for (const match of file.content.matchAll(ENABLE_RLS)) {
4481
+ rlsTables.add(`${project}::${match[1].toLowerCase()}`);
4482
+ }
4188
4483
  }
4189
- for (const table of tables) {
4190
- if (!rlsTables.has(table.name.toLowerCase())) {
4484
+ for (const file of files) {
4485
+ if (!isMigrationFile(file)) continue;
4486
+ const content = file.content;
4487
+ const project = sqlProject(file.relativePath);
4488
+ for (const match of content.matchAll(CREATE_TABLE)) {
4489
+ const name = match[1];
4490
+ if (name.startsWith("_") || name === "schema_migrations") continue;
4491
+ if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
4191
4492
  findings.push({
4192
4493
  ruleId: "supabase-missing-rls",
4193
4494
  file: file.relativePath,
4194
- line: table.line,
4495
+ line: content.slice(0, match.index).split("\n").length,
4195
4496
  column: 1,
4196
- message: `Table "${table.name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
4497
+ message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
4197
4498
  severity: "critical",
4198
4499
  category: "security",
4199
- fix: `Add: ALTER TABLE ${table.name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
4500
+ fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
4200
4501
  });
4201
4502
  }
4202
4503
  }
@@ -4527,10 +4828,15 @@ async function scan(options) {
4527
4828
  }
4528
4829
  }
4529
4830
  }
4831
+ const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
4530
4832
  for (const rule of rules) {
4531
4833
  if (rule.checkProject) {
4532
- const projectFindings = rule.checkProject(allFiles, project);
4533
- findings.push(...projectFindings);
4834
+ for (const finding of rule.checkProject(allFiles, project)) {
4835
+ const file = filesByPath.get(finding.file);
4836
+ if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
4837
+ findings.push(finding);
4838
+ }
4839
+ }
4534
4840
  }
4535
4841
  }
4536
4842
  const { overallScore, categoryScores } = calculateScores(findings);
@@ -4699,11 +5005,7 @@ function reportSarif(result) {
4699
5005
  ]
4700
5006
  };
4701
5007
  if (f.fix) {
4702
- sarifResult.fixes = [
4703
- {
4704
- description: { text: f.fix }
4705
- }
4706
- ];
5008
+ sarifResult.properties = { fix: f.fix };
4707
5009
  }
4708
5010
  return sarifResult;
4709
5011
  });