prodlint 0.10.2 → 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/README.md +20 -9
- package/action.yml +18 -30
- package/dist/cli.js +578 -282
- package/dist/index.js +578 -282
- package/dist/mcp.js +712 -295
- package/package.json +1 -1
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
|
|
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: "
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
2421
|
-
|
|
2422
|
-
const
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
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)) {
|
|
@@ -2481,15 +2733,16 @@ var deadExportsRule = {
|
|
|
2481
2733
|
};
|
|
2482
2734
|
|
|
2483
2735
|
// src/rules/shallow-catch.ts
|
|
2484
|
-
function
|
|
2485
|
-
if (
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2736
|
+
function reportsError(value, param) {
|
|
2737
|
+
if (/error/i.test(value) || /\berr|Err|status|Response|json/.test(value)) return true;
|
|
2738
|
+
return param !== void 0 && new RegExp(`(?<![\\w$])${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(value);
|
|
2739
|
+
}
|
|
2740
|
+
function scoreCatchBody({ text: body, empty, returned, param }) {
|
|
2741
|
+
if (empty) return { score: 0, label: "empty catch" };
|
|
2489
2742
|
let score = 0;
|
|
2490
2743
|
if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
|
|
2491
2744
|
if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2492
|
-
if (/\bthrow\b/.test(body) ||
|
|
2745
|
+
if (/\bthrow\b/.test(body) || returned.some((value) => reportsError(value, param)) || /\bset\w*Error\s*\(/.test(body) || /\bres\.\w+\s*\(/.test(body) || // Toast notifications (react-toastify, sonner, shadcn)
|
|
2493
2746
|
/\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
|
|
2494
2747
|
/\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
|
|
2495
2748
|
/\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
|
|
@@ -2499,13 +2752,43 @@ function scoreCatchBody(bodyLines) {
|
|
|
2499
2752
|
score = 3;
|
|
2500
2753
|
}
|
|
2501
2754
|
const labels = {
|
|
2502
|
-
0: "
|
|
2755
|
+
0: "catch swallows the error",
|
|
2503
2756
|
1: "catch only logs (no recovery or propagation)",
|
|
2504
2757
|
2: "catch logs error but does not propagate or recover",
|
|
2505
2758
|
3: "catch handles error properly"
|
|
2506
2759
|
};
|
|
2507
2760
|
return { score, label: labels[score] };
|
|
2508
2761
|
}
|
|
2762
|
+
function catchFinding(file, catchLine, body) {
|
|
2763
|
+
const { score, label } = scoreCatchBody(body);
|
|
2764
|
+
if (score > 1) return null;
|
|
2765
|
+
return {
|
|
2766
|
+
ruleId: "shallow-catch",
|
|
2767
|
+
file: file.relativePath,
|
|
2768
|
+
line: catchLine + 1,
|
|
2769
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2770
|
+
message: body.empty ? "Empty catch block \u2014 errors are silently swallowed" : score === 0 ? "Catch block swallows the error \u2014 it isn't logged, rethrown or returned" : `Decorative error handler: ${label}`,
|
|
2771
|
+
severity: score === 0 ? "warning" : "info",
|
|
2772
|
+
category: "reliability",
|
|
2773
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2774
|
+
};
|
|
2775
|
+
}
|
|
2776
|
+
function returnedValues(code, text) {
|
|
2777
|
+
const values = [];
|
|
2778
|
+
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
2779
|
+
const start = m.index + "return".length;
|
|
2780
|
+
let end = start;
|
|
2781
|
+
for (let depth = 0; end < code.length; end++) {
|
|
2782
|
+
const ch = code[end];
|
|
2783
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
2784
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
2785
|
+
if (depth-- === 0) break;
|
|
2786
|
+
} else if (depth === 0 && (ch === ";" || ch === "\n")) break;
|
|
2787
|
+
}
|
|
2788
|
+
values.push(text.slice(start, end));
|
|
2789
|
+
}
|
|
2790
|
+
return values;
|
|
2791
|
+
}
|
|
2509
2792
|
var shallowCatchRule = {
|
|
2510
2793
|
id: "shallow-catch",
|
|
2511
2794
|
name: "Shallow Error Handler",
|
|
@@ -2522,95 +2805,45 @@ var shallowCatchRule = {
|
|
|
2522
2805
|
walkAST(file.ast.program, (node) => {
|
|
2523
2806
|
if (node.type !== "CatchClause") return;
|
|
2524
2807
|
if (!node.loc) return;
|
|
2525
|
-
const
|
|
2808
|
+
const catchLine2 = node.loc.start.line - 1;
|
|
2526
2809
|
const body = node.body;
|
|
2527
|
-
if (
|
|
2528
|
-
const
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2542
|
-
});
|
|
2543
|
-
}
|
|
2810
|
+
if (body?.start == null || body.end == null) return;
|
|
2811
|
+
const returned = [];
|
|
2812
|
+
walkAST(body, (n) => {
|
|
2813
|
+
if (n.type === "ReturnStatement" && n.argument?.start != null && n.argument.end != null) {
|
|
2814
|
+
returned.push(file.content.slice(n.argument.start, n.argument.end));
|
|
2815
|
+
}
|
|
2816
|
+
});
|
|
2817
|
+
const finding = catchFinding(file, catchLine2, {
|
|
2818
|
+
text: file.content.slice(body.start + 1, body.end - 1),
|
|
2819
|
+
empty: body.body.length === 0,
|
|
2820
|
+
returned,
|
|
2821
|
+
param: node.param?.type === "Identifier" ? node.param.name : void 0
|
|
2822
|
+
});
|
|
2823
|
+
if (finding) findings.push(finding);
|
|
2544
2824
|
});
|
|
2545
2825
|
return findings;
|
|
2546
2826
|
} catch {
|
|
2547
2827
|
}
|
|
2548
2828
|
}
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
const line = file.lines[j];
|
|
2568
|
-
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
2569
|
-
for (let k = startPos; k < line.length; k++) {
|
|
2570
|
-
const ch = line[k];
|
|
2571
|
-
const prev = k > 0 ? line[k - 1] : "";
|
|
2572
|
-
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2573
|
-
if (!escaped) {
|
|
2574
|
-
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2575
|
-
inSingle = !inSingle;
|
|
2576
|
-
continue;
|
|
2577
|
-
}
|
|
2578
|
-
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2579
|
-
inDouble = !inDouble;
|
|
2580
|
-
continue;
|
|
2581
|
-
}
|
|
2582
|
-
if (ch === "`" && !inSingle && !inDouble) {
|
|
2583
|
-
inTemplate = !inTemplate;
|
|
2584
|
-
continue;
|
|
2585
|
-
}
|
|
2586
|
-
}
|
|
2587
|
-
if (inSingle || inDouble || inTemplate) continue;
|
|
2588
|
-
if (ch === "{") depth++;
|
|
2589
|
-
if (ch === "}") {
|
|
2590
|
-
depth--;
|
|
2591
|
-
if (depth === 0) {
|
|
2592
|
-
bodyEnd = j;
|
|
2593
|
-
break;
|
|
2594
|
-
}
|
|
2595
|
-
}
|
|
2596
|
-
}
|
|
2597
|
-
if (depth === 0) break;
|
|
2598
|
-
}
|
|
2599
|
-
const bodyLines = file.lines.slice(braceStart + 1, bodyEnd);
|
|
2600
|
-
const { score, label } = scoreCatchBody(bodyLines);
|
|
2601
|
-
if (score <= 1) {
|
|
2602
|
-
findings.push({
|
|
2603
|
-
ruleId: "shallow-catch",
|
|
2604
|
-
file: file.relativePath,
|
|
2605
|
-
line: i + 1,
|
|
2606
|
-
column: file.lines[i].indexOf("catch") + 1,
|
|
2607
|
-
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2608
|
-
severity: score === 0 ? "warning" : "info",
|
|
2609
|
-
category: "reliability",
|
|
2610
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2611
|
-
});
|
|
2612
|
-
}
|
|
2613
|
-
i = bodyEnd;
|
|
2829
|
+
const source = file.lines.join("\n");
|
|
2830
|
+
const code = blankOut(source, true);
|
|
2831
|
+
const noComments = blankOut(source, false);
|
|
2832
|
+
const catchRe = /(?<![\w$.])catch\s*(?:\(([^)]*)\)\s*)?\{/g;
|
|
2833
|
+
let catchLine = 0;
|
|
2834
|
+
let scanned = 0;
|
|
2835
|
+
for (let m = catchRe.exec(code); m; m = catchRe.exec(code)) {
|
|
2836
|
+
for (; scanned < m.index; scanned++) if (code[scanned] === "\n") catchLine++;
|
|
2837
|
+
const open = m.index + m[0].length - 1;
|
|
2838
|
+
const close = closingBrace(code, open);
|
|
2839
|
+
if (close === -1) continue;
|
|
2840
|
+
const finding = catchFinding(file, catchLine, {
|
|
2841
|
+
text: source.slice(open + 1, close),
|
|
2842
|
+
empty: code.slice(open + 1, close).trim() === "",
|
|
2843
|
+
returned: returnedValues(code.slice(open + 1, close), noComments.slice(open + 1, close)),
|
|
2844
|
+
param: m[1]?.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1]
|
|
2845
|
+
});
|
|
2846
|
+
if (finding) findings.push(finding);
|
|
2614
2847
|
}
|
|
2615
2848
|
return findings;
|
|
2616
2849
|
}
|
|
@@ -2717,47 +2950,46 @@ var comprehensionDebtRule = {
|
|
|
2717
2950
|
};
|
|
2718
2951
|
|
|
2719
2952
|
// src/rules/phantom-dependency.ts
|
|
2720
|
-
var PHANTOM_PACKAGES = /* @__PURE__ */ new
|
|
2721
|
-
"huggingface-cli",
|
|
2722
|
-
//
|
|
2723
|
-
"flask-hierarchical",
|
|
2724
|
-
//
|
|
2725
|
-
"beautifulsoup",
|
|
2726
|
-
//
|
|
2727
|
-
"python-dotenv",
|
|
2728
|
-
//
|
|
2729
|
-
"openai-sdk",
|
|
2730
|
-
//
|
|
2731
|
-
"anthropic-sdk",
|
|
2732
|
-
//
|
|
2733
|
-
"langchain-core",
|
|
2734
|
-
//
|
|
2735
|
-
"react-native-utils",
|
|
2736
|
-
//
|
|
2737
|
-
"next-middleware",
|
|
2738
|
-
//
|
|
2739
|
-
"supabase-client",
|
|
2740
|
-
//
|
|
2741
|
-
"stripe-sdk",
|
|
2742
|
-
//
|
|
2743
|
-
"prisma-client",
|
|
2744
|
-
//
|
|
2745
|
-
"tailwind-utils",
|
|
2746
|
-
//
|
|
2747
|
-
"express-validator-v2",
|
|
2748
|
-
//
|
|
2749
|
-
"node-postgres-pool",
|
|
2750
|
-
//
|
|
2751
|
-
"mongo-client",
|
|
2752
|
-
//
|
|
2753
|
-
"redis-client",
|
|
2754
|
-
//
|
|
2755
|
-
"aws-s3-upload",
|
|
2756
|
-
//
|
|
2757
|
-
"gpt-tokenizer"
|
|
2758
|
-
// 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
|
|
2759
2990
|
]);
|
|
2760
|
-
var
|
|
2991
|
+
var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
|
|
2992
|
+
// 2-char names
|
|
2761
2993
|
"pg",
|
|
2762
2994
|
"ws",
|
|
2763
2995
|
"ms",
|
|
@@ -2771,7 +3003,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
2771
3003
|
"io",
|
|
2772
3004
|
"db",
|
|
2773
3005
|
"fp",
|
|
2774
|
-
"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"
|
|
2775
3031
|
]);
|
|
2776
3032
|
var SUSPICIOUS_PATTERNS = [
|
|
2777
3033
|
/^[a-z]{1,2}$/,
|
|
@@ -2781,6 +3037,9 @@ var SUSPICIOUS_PATTERNS = [
|
|
|
2781
3037
|
/^(the|my|simple|easy|fast|super|mega|ultra)-/
|
|
2782
3038
|
// vanity prefixes
|
|
2783
3039
|
];
|
|
3040
|
+
function quoteList(names) {
|
|
3041
|
+
return names.map((n) => `"${n}"`).join(" or ");
|
|
3042
|
+
}
|
|
2784
3043
|
var phantomDependencyRule = {
|
|
2785
3044
|
id: "phantom-dependency",
|
|
2786
3045
|
name: "Phantom Dependency",
|
|
@@ -2799,20 +3058,21 @@ var phantomDependencyRule = {
|
|
|
2799
3058
|
...project.packageJson.devDependencies ?? {}
|
|
2800
3059
|
};
|
|
2801
3060
|
for (const [name, _version] of Object.entries(deps)) {
|
|
2802
|
-
|
|
3061
|
+
const meant = PHANTOM_PACKAGES.get(name);
|
|
3062
|
+
if (meant) {
|
|
2803
3063
|
findings.push({
|
|
2804
3064
|
ruleId: "phantom-dependency",
|
|
2805
3065
|
file: "package.json",
|
|
2806
3066
|
line: 1,
|
|
2807
3067
|
column: 1,
|
|
2808
|
-
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`,
|
|
2809
3069
|
severity: "warning",
|
|
2810
3070
|
category: "security",
|
|
2811
|
-
fix: "
|
|
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`
|
|
2812
3072
|
});
|
|
2813
3073
|
}
|
|
2814
3074
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
2815
|
-
if (pattern.test(name) && !name.startsWith("@") && !
|
|
3075
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
|
|
2816
3076
|
findings.push({
|
|
2817
3077
|
ruleId: "phantom-dependency",
|
|
2818
3078
|
file: "package.json",
|
|
@@ -2998,10 +3258,11 @@ var insecureRandomRule = {
|
|
|
2998
3258
|
};
|
|
2999
3259
|
|
|
3000
3260
|
// src/rules/next-server-action-validation.ts
|
|
3001
|
-
var USE_SERVER =
|
|
3261
|
+
var USE_SERVER = /^\s*['"]use server['"]/m;
|
|
3002
3262
|
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
3003
3263
|
var VALIDATION_PATTERNS2 = [
|
|
3004
|
-
|
|
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*\(/,
|
|
3005
3266
|
/\.safeParse\s*\(/,
|
|
3006
3267
|
/\bvalidate\s*\(/,
|
|
3007
3268
|
/\.parseAsync\s*\(/,
|
|
@@ -3152,6 +3413,30 @@ function findEnclosingFunction(node, parentMap) {
|
|
|
3152
3413
|
}
|
|
3153
3414
|
|
|
3154
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
|
+
}
|
|
3155
3440
|
var redirectInTryCatchRule = {
|
|
3156
3441
|
id: "redirect-in-try-catch",
|
|
3157
3442
|
name: "Redirect Inside Try/Catch",
|
|
@@ -3163,44 +3448,42 @@ var redirectInTryCatchRule = {
|
|
|
3163
3448
|
if (isTestFile(file.relativePath)) return [];
|
|
3164
3449
|
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
3165
3450
|
const findings = [];
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
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'
|
|
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));
|
|
3188
3461
|
});
|
|
3189
|
-
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3462
|
+
});
|
|
3463
|
+
return findings;
|
|
3464
|
+
} catch {
|
|
3465
|
+
findings.length = 0;
|
|
3466
|
+
seen.clear();
|
|
3194
3467
|
}
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
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));
|
|
3204
3487
|
}
|
|
3205
3488
|
}
|
|
3206
3489
|
return findings;
|
|
@@ -3493,7 +3776,7 @@ var missingWebhookVerificationRule = {
|
|
|
3493
3776
|
};
|
|
3494
3777
|
|
|
3495
3778
|
// src/rules/server-action-auth.ts
|
|
3496
|
-
var USE_SERVER3 =
|
|
3779
|
+
var USE_SERVER3 = /^\s*['"]use server['"]/m;
|
|
3497
3780
|
var AUTH_PATTERNS2 = [
|
|
3498
3781
|
/getServerSession\s*\(/,
|
|
3499
3782
|
/getSession\s*\(/,
|
|
@@ -3510,7 +3793,13 @@ var AUTH_PATTERNS2 = [
|
|
|
3510
3793
|
/currentUser\s*\(/,
|
|
3511
3794
|
/getAuth\s*\(/,
|
|
3512
3795
|
/cookies\(\).*auth/s,
|
|
3513
|
-
|
|
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/
|
|
3514
3803
|
];
|
|
3515
3804
|
var PUBLIC_ACTION_NAMES = [
|
|
3516
3805
|
/contact/i,
|
|
@@ -3684,13 +3973,15 @@ var nextPublicSensitiveRule = {
|
|
|
3684
3973
|
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3685
3974
|
category: "security",
|
|
3686
3975
|
severity: "critical",
|
|
3687
|
-
|
|
3976
|
+
// The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
|
|
3977
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
|
|
3688
3978
|
check(file, _project) {
|
|
3689
3979
|
if (isTestFile(file.relativePath)) return [];
|
|
3690
3980
|
const findings = [];
|
|
3981
|
+
const isEnvFile = file.ext === "env";
|
|
3691
3982
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3692
|
-
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3693
3983
|
const line = file.lines[i];
|
|
3984
|
+
if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3694
3985
|
const match = SENSITIVE_PATTERN.exec(line);
|
|
3695
3986
|
if (match) {
|
|
3696
3987
|
findings.push({
|
|
@@ -4153,7 +4444,17 @@ var unsafeFileUploadRule = {
|
|
|
4153
4444
|
|
|
4154
4445
|
// src/rules/supabase-missing-rls.ts
|
|
4155
4446
|
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+)
|
|
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
|
+
}
|
|
4157
4458
|
var supabaseMissingRlsRule = {
|
|
4158
4459
|
id: "supabase-missing-rls",
|
|
4159
4460
|
name: "Missing Row-Level Security",
|
|
@@ -4161,42 +4462,36 @@ var supabaseMissingRlsRule = {
|
|
|
4161
4462
|
category: "security",
|
|
4162
4463
|
severity: "critical",
|
|
4163
4464
|
fileExtensions: ["sql"],
|
|
4164
|
-
check(
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4465
|
+
check() {
|
|
4466
|
+
return [];
|
|
4467
|
+
},
|
|
4468
|
+
checkProject(files, _project) {
|
|
4168
4469
|
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
4470
|
const rlsTables = /* @__PURE__ */ new Set();
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
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
|
+
}
|
|
4188
4477
|
}
|
|
4189
|
-
for (const
|
|
4190
|
-
if (!
|
|
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;
|
|
4191
4486
|
findings.push({
|
|
4192
4487
|
ruleId: "supabase-missing-rls",
|
|
4193
4488
|
file: file.relativePath,
|
|
4194
|
-
line:
|
|
4489
|
+
line: content.slice(0, match.index).split("\n").length,
|
|
4195
4490
|
column: 1,
|
|
4196
|
-
message: `Table "${
|
|
4491
|
+
message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
4197
4492
|
severity: "critical",
|
|
4198
4493
|
category: "security",
|
|
4199
|
-
fix: `Add: ALTER TABLE ${
|
|
4494
|
+
fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
4200
4495
|
});
|
|
4201
4496
|
}
|
|
4202
4497
|
}
|
|
@@ -4527,10 +4822,15 @@ async function scan(options) {
|
|
|
4527
4822
|
}
|
|
4528
4823
|
}
|
|
4529
4824
|
}
|
|
4825
|
+
const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
|
|
4530
4826
|
for (const rule of rules) {
|
|
4531
4827
|
if (rule.checkProject) {
|
|
4532
|
-
const
|
|
4533
|
-
|
|
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
|
+
}
|
|
4534
4834
|
}
|
|
4535
4835
|
}
|
|
4536
4836
|
const { overallScore, categoryScores } = calculateScores(findings);
|
|
@@ -4699,11 +4999,7 @@ function reportSarif(result) {
|
|
|
4699
4999
|
]
|
|
4700
5000
|
};
|
|
4701
5001
|
if (f.fix) {
|
|
4702
|
-
sarifResult.
|
|
4703
|
-
{
|
|
4704
|
-
description: { text: f.fix }
|
|
4705
|
-
}
|
|
4706
|
-
];
|
|
5002
|
+
sarifResult.properties = { fix: f.fix };
|
|
4707
5003
|
}
|
|
4708
5004
|
return sarifResult;
|
|
4709
5005
|
});
|