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/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
|
|
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: "
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
|
2427
|
-
|
|
2428
|
-
const
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
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)) {
|
|
@@ -2487,15 +2739,16 @@ var deadExportsRule = {
|
|
|
2487
2739
|
};
|
|
2488
2740
|
|
|
2489
2741
|
// src/rules/shallow-catch.ts
|
|
2490
|
-
function
|
|
2491
|
-
if (
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2742
|
+
function reportsError(value, param) {
|
|
2743
|
+
if (/error/i.test(value) || /\berr|Err|status|Response|json/.test(value)) return true;
|
|
2744
|
+
return param !== void 0 && new RegExp(`(?<![\\w$])${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(value);
|
|
2745
|
+
}
|
|
2746
|
+
function scoreCatchBody({ text: body, empty, returned, param }) {
|
|
2747
|
+
if (empty) return { score: 0, label: "empty catch" };
|
|
2495
2748
|
let score = 0;
|
|
2496
2749
|
if (/console\.(log|warn|error|info)\s*\(/.test(body)) score = 1;
|
|
2497
2750
|
if ((/console\.(error|warn)\s*\(/.test(body) || /\b(logger|log)\.(error|warn)\s*\(/.test(body)) && /\b(err|error|e)\b/.test(body)) score = 2;
|
|
2498
|
-
if (/\bthrow\b/.test(body) ||
|
|
2751
|
+
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)
|
|
2499
2752
|
/\btoast\.(error|warn|warning)\s*\(/.test(body) || /\btoast\s*\(\s*\{/.test(body) || // Error monitoring (Sentry, etc.)
|
|
2500
2753
|
/\b(Sentry\.)?captureException\s*\(/.test(body) || /\b(Sentry\.)?captureMessage\s*\(/.test(body) || // Structured loggers
|
|
2501
2754
|
/\b(logger|log)\.(error|fatal)\s*\(/.test(body) || // Error handler utilities
|
|
@@ -2505,13 +2758,43 @@ function scoreCatchBody(bodyLines) {
|
|
|
2505
2758
|
score = 3;
|
|
2506
2759
|
}
|
|
2507
2760
|
const labels = {
|
|
2508
|
-
0: "
|
|
2761
|
+
0: "catch swallows the error",
|
|
2509
2762
|
1: "catch only logs (no recovery or propagation)",
|
|
2510
2763
|
2: "catch logs error but does not propagate or recover",
|
|
2511
2764
|
3: "catch handles error properly"
|
|
2512
2765
|
};
|
|
2513
2766
|
return { score, label: labels[score] };
|
|
2514
2767
|
}
|
|
2768
|
+
function catchFinding(file, catchLine, body) {
|
|
2769
|
+
const { score, label } = scoreCatchBody(body);
|
|
2770
|
+
if (score > 1) return null;
|
|
2771
|
+
return {
|
|
2772
|
+
ruleId: "shallow-catch",
|
|
2773
|
+
file: file.relativePath,
|
|
2774
|
+
line: catchLine + 1,
|
|
2775
|
+
column: file.lines[catchLine].indexOf("catch") + 1,
|
|
2776
|
+
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}`,
|
|
2777
|
+
severity: score === 0 ? "warning" : "info",
|
|
2778
|
+
category: "reliability",
|
|
2779
|
+
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
function returnedValues(code, text) {
|
|
2783
|
+
const values = [];
|
|
2784
|
+
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
2785
|
+
const start = m.index + "return".length;
|
|
2786
|
+
let end = start;
|
|
2787
|
+
for (let depth = 0; end < code.length; end++) {
|
|
2788
|
+
const ch = code[end];
|
|
2789
|
+
if (ch === "(" || ch === "[" || ch === "{") depth++;
|
|
2790
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
2791
|
+
if (depth-- === 0) break;
|
|
2792
|
+
} else if (depth === 0 && (ch === ";" || ch === "\n")) break;
|
|
2793
|
+
}
|
|
2794
|
+
values.push(text.slice(start, end));
|
|
2795
|
+
}
|
|
2796
|
+
return values;
|
|
2797
|
+
}
|
|
2515
2798
|
var shallowCatchRule = {
|
|
2516
2799
|
id: "shallow-catch",
|
|
2517
2800
|
name: "Shallow Error Handler",
|
|
@@ -2528,95 +2811,45 @@ var shallowCatchRule = {
|
|
|
2528
2811
|
walkAST(file.ast.program, (node) => {
|
|
2529
2812
|
if (node.type !== "CatchClause") return;
|
|
2530
2813
|
if (!node.loc) return;
|
|
2531
|
-
const
|
|
2814
|
+
const catchLine2 = node.loc.start.line - 1;
|
|
2532
2815
|
const body = node.body;
|
|
2533
|
-
if (
|
|
2534
|
-
const
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2548
|
-
});
|
|
2549
|
-
}
|
|
2816
|
+
if (body?.start == null || body.end == null) return;
|
|
2817
|
+
const returned = [];
|
|
2818
|
+
walkAST(body, (n) => {
|
|
2819
|
+
if (n.type === "ReturnStatement" && n.argument?.start != null && n.argument.end != null) {
|
|
2820
|
+
returned.push(file.content.slice(n.argument.start, n.argument.end));
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
const finding = catchFinding(file, catchLine2, {
|
|
2824
|
+
text: file.content.slice(body.start + 1, body.end - 1),
|
|
2825
|
+
empty: body.body.length === 0,
|
|
2826
|
+
returned,
|
|
2827
|
+
param: node.param?.type === "Identifier" ? node.param.name : void 0
|
|
2828
|
+
});
|
|
2829
|
+
if (finding) findings.push(finding);
|
|
2550
2830
|
});
|
|
2551
2831
|
return findings;
|
|
2552
2832
|
} catch {
|
|
2553
2833
|
}
|
|
2554
2834
|
}
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
const line = file.lines[j];
|
|
2574
|
-
const startPos = j === braceStart ? line.indexOf("{") : 0;
|
|
2575
|
-
for (let k = startPos; k < line.length; k++) {
|
|
2576
|
-
const ch = line[k];
|
|
2577
|
-
const prev = k > 0 ? line[k - 1] : "";
|
|
2578
|
-
const escaped = prev === "\\" && (k < 2 || line[k - 2] !== "\\");
|
|
2579
|
-
if (!escaped) {
|
|
2580
|
-
if (ch === "'" && !inDouble && !inTemplate) {
|
|
2581
|
-
inSingle = !inSingle;
|
|
2582
|
-
continue;
|
|
2583
|
-
}
|
|
2584
|
-
if (ch === '"' && !inSingle && !inTemplate) {
|
|
2585
|
-
inDouble = !inDouble;
|
|
2586
|
-
continue;
|
|
2587
|
-
}
|
|
2588
|
-
if (ch === "`" && !inSingle && !inDouble) {
|
|
2589
|
-
inTemplate = !inTemplate;
|
|
2590
|
-
continue;
|
|
2591
|
-
}
|
|
2592
|
-
}
|
|
2593
|
-
if (inSingle || inDouble || inTemplate) continue;
|
|
2594
|
-
if (ch === "{") depth++;
|
|
2595
|
-
if (ch === "}") {
|
|
2596
|
-
depth--;
|
|
2597
|
-
if (depth === 0) {
|
|
2598
|
-
bodyEnd = j;
|
|
2599
|
-
break;
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
}
|
|
2603
|
-
if (depth === 0) break;
|
|
2604
|
-
}
|
|
2605
|
-
const bodyLines = file.lines.slice(braceStart + 1, bodyEnd);
|
|
2606
|
-
const { score, label } = scoreCatchBody(bodyLines);
|
|
2607
|
-
if (score <= 1) {
|
|
2608
|
-
findings.push({
|
|
2609
|
-
ruleId: "shallow-catch",
|
|
2610
|
-
file: file.relativePath,
|
|
2611
|
-
line: i + 1,
|
|
2612
|
-
column: file.lines[i].indexOf("catch") + 1,
|
|
2613
|
-
message: score === 0 ? "Empty catch block \u2014 errors are silently swallowed" : `Decorative error handler: ${label}`,
|
|
2614
|
-
severity: score === 0 ? "warning" : "info",
|
|
2615
|
-
category: "reliability",
|
|
2616
|
-
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2617
|
-
});
|
|
2618
|
-
}
|
|
2619
|
-
i = bodyEnd;
|
|
2835
|
+
const source = file.lines.join("\n");
|
|
2836
|
+
const code = blankOut(source, true);
|
|
2837
|
+
const noComments = blankOut(source, false);
|
|
2838
|
+
const catchRe = /(?<![\w$.])catch\s*(?:\(([^)]*)\)\s*)?\{/g;
|
|
2839
|
+
let catchLine = 0;
|
|
2840
|
+
let scanned = 0;
|
|
2841
|
+
for (let m = catchRe.exec(code); m; m = catchRe.exec(code)) {
|
|
2842
|
+
for (; scanned < m.index; scanned++) if (code[scanned] === "\n") catchLine++;
|
|
2843
|
+
const open = m.index + m[0].length - 1;
|
|
2844
|
+
const close = closingBrace(code, open);
|
|
2845
|
+
if (close === -1) continue;
|
|
2846
|
+
const finding = catchFinding(file, catchLine, {
|
|
2847
|
+
text: source.slice(open + 1, close),
|
|
2848
|
+
empty: code.slice(open + 1, close).trim() === "",
|
|
2849
|
+
returned: returnedValues(code.slice(open + 1, close), noComments.slice(open + 1, close)),
|
|
2850
|
+
param: m[1]?.match(/^\s*([A-Za-z_$][\w$]*)/)?.[1]
|
|
2851
|
+
});
|
|
2852
|
+
if (finding) findings.push(finding);
|
|
2620
2853
|
}
|
|
2621
2854
|
return findings;
|
|
2622
2855
|
}
|
|
@@ -2723,47 +2956,46 @@ var comprehensionDebtRule = {
|
|
|
2723
2956
|
};
|
|
2724
2957
|
|
|
2725
2958
|
// src/rules/phantom-dependency.ts
|
|
2726
|
-
var PHANTOM_PACKAGES = /* @__PURE__ */ new
|
|
2727
|
-
"huggingface-cli",
|
|
2728
|
-
//
|
|
2729
|
-
"flask-hierarchical",
|
|
2730
|
-
//
|
|
2731
|
-
"beautifulsoup",
|
|
2732
|
-
//
|
|
2733
|
-
"python-dotenv",
|
|
2734
|
-
//
|
|
2735
|
-
"openai-sdk",
|
|
2736
|
-
//
|
|
2737
|
-
"anthropic-sdk",
|
|
2738
|
-
//
|
|
2739
|
-
"langchain-core",
|
|
2740
|
-
//
|
|
2741
|
-
"react-native-utils",
|
|
2742
|
-
//
|
|
2743
|
-
"next-middleware",
|
|
2744
|
-
//
|
|
2745
|
-
"supabase-client",
|
|
2746
|
-
//
|
|
2747
|
-
"stripe-sdk",
|
|
2748
|
-
//
|
|
2749
|
-
"prisma-client",
|
|
2750
|
-
//
|
|
2751
|
-
"tailwind-utils",
|
|
2752
|
-
//
|
|
2753
|
-
"express-validator-v2",
|
|
2754
|
-
//
|
|
2755
|
-
"node-postgres-pool",
|
|
2756
|
-
//
|
|
2757
|
-
"mongo-client",
|
|
2758
|
-
//
|
|
2759
|
-
"redis-client",
|
|
2760
|
-
//
|
|
2761
|
-
"aws-s3-upload",
|
|
2762
|
-
//
|
|
2763
|
-
"gpt-tokenizer"
|
|
2764
|
-
// 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
|
|
2765
2996
|
]);
|
|
2766
|
-
var
|
|
2997
|
+
var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
|
|
2998
|
+
// 2-char names
|
|
2767
2999
|
"pg",
|
|
2768
3000
|
"ws",
|
|
2769
3001
|
"ms",
|
|
@@ -2777,7 +3009,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
2777
3009
|
"io",
|
|
2778
3010
|
"db",
|
|
2779
3011
|
"fp",
|
|
2780
|
-
"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"
|
|
2781
3037
|
]);
|
|
2782
3038
|
var SUSPICIOUS_PATTERNS = [
|
|
2783
3039
|
/^[a-z]{1,2}$/,
|
|
@@ -2787,6 +3043,9 @@ var SUSPICIOUS_PATTERNS = [
|
|
|
2787
3043
|
/^(the|my|simple|easy|fast|super|mega|ultra)-/
|
|
2788
3044
|
// vanity prefixes
|
|
2789
3045
|
];
|
|
3046
|
+
function quoteList(names) {
|
|
3047
|
+
return names.map((n) => `"${n}"`).join(" or ");
|
|
3048
|
+
}
|
|
2790
3049
|
var phantomDependencyRule = {
|
|
2791
3050
|
id: "phantom-dependency",
|
|
2792
3051
|
name: "Phantom Dependency",
|
|
@@ -2805,20 +3064,21 @@ var phantomDependencyRule = {
|
|
|
2805
3064
|
...project.packageJson.devDependencies ?? {}
|
|
2806
3065
|
};
|
|
2807
3066
|
for (const [name, _version] of Object.entries(deps)) {
|
|
2808
|
-
|
|
3067
|
+
const meant = PHANTOM_PACKAGES.get(name);
|
|
3068
|
+
if (meant) {
|
|
2809
3069
|
findings.push({
|
|
2810
3070
|
ruleId: "phantom-dependency",
|
|
2811
3071
|
file: "package.json",
|
|
2812
3072
|
line: 1,
|
|
2813
3073
|
column: 1,
|
|
2814
|
-
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`,
|
|
2815
3075
|
severity: "warning",
|
|
2816
3076
|
category: "security",
|
|
2817
|
-
fix: "
|
|
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`
|
|
2818
3078
|
});
|
|
2819
3079
|
}
|
|
2820
3080
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
2821
|
-
if (pattern.test(name) && !name.startsWith("@") && !
|
|
3081
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
|
|
2822
3082
|
findings.push({
|
|
2823
3083
|
ruleId: "phantom-dependency",
|
|
2824
3084
|
file: "package.json",
|
|
@@ -3004,10 +3264,11 @@ var insecureRandomRule = {
|
|
|
3004
3264
|
};
|
|
3005
3265
|
|
|
3006
3266
|
// src/rules/next-server-action-validation.ts
|
|
3007
|
-
var USE_SERVER =
|
|
3267
|
+
var USE_SERVER = /^\s*['"]use server['"]/m;
|
|
3008
3268
|
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
3009
3269
|
var VALIDATION_PATTERNS2 = [
|
|
3010
|
-
|
|
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*\(/,
|
|
3011
3272
|
/\.safeParse\s*\(/,
|
|
3012
3273
|
/\bvalidate\s*\(/,
|
|
3013
3274
|
/\.parseAsync\s*\(/,
|
|
@@ -3158,6 +3419,30 @@ function findEnclosingFunction(node, parentMap) {
|
|
|
3158
3419
|
}
|
|
3159
3420
|
|
|
3160
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
|
+
}
|
|
3161
3446
|
var redirectInTryCatchRule = {
|
|
3162
3447
|
id: "redirect-in-try-catch",
|
|
3163
3448
|
name: "Redirect Inside Try/Catch",
|
|
@@ -3169,44 +3454,42 @@ var redirectInTryCatchRule = {
|
|
|
3169
3454
|
if (isTestFile(file.relativePath)) return [];
|
|
3170
3455
|
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
3171
3456
|
const findings = [];
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
if (tryDepth > 0) {
|
|
3183
|
-
const match = /\bredirect\s*\(/.exec(line);
|
|
3184
|
-
if (match && !/\/\//.test(line.slice(0, match.index))) {
|
|
3185
|
-
findings.push({
|
|
3186
|
-
ruleId: "redirect-in-try-catch",
|
|
3187
|
-
file: file.relativePath,
|
|
3188
|
-
line: i + 1,
|
|
3189
|
-
column: match.index + 1,
|
|
3190
|
-
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3191
|
-
severity: "critical",
|
|
3192
|
-
category: "reliability",
|
|
3193
|
-
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));
|
|
3194
3467
|
});
|
|
3195
|
-
}
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3468
|
+
});
|
|
3469
|
+
return findings;
|
|
3470
|
+
} catch {
|
|
3471
|
+
findings.length = 0;
|
|
3472
|
+
seen.clear();
|
|
3200
3473
|
}
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
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));
|
|
3210
3493
|
}
|
|
3211
3494
|
}
|
|
3212
3495
|
return findings;
|
|
@@ -3499,7 +3782,7 @@ var missingWebhookVerificationRule = {
|
|
|
3499
3782
|
};
|
|
3500
3783
|
|
|
3501
3784
|
// src/rules/server-action-auth.ts
|
|
3502
|
-
var USE_SERVER3 =
|
|
3785
|
+
var USE_SERVER3 = /^\s*['"]use server['"]/m;
|
|
3503
3786
|
var AUTH_PATTERNS2 = [
|
|
3504
3787
|
/getServerSession\s*\(/,
|
|
3505
3788
|
/getSession\s*\(/,
|
|
@@ -3516,7 +3799,13 @@ var AUTH_PATTERNS2 = [
|
|
|
3516
3799
|
/currentUser\s*\(/,
|
|
3517
3800
|
/getAuth\s*\(/,
|
|
3518
3801
|
/cookies\(\).*auth/s,
|
|
3519
|
-
|
|
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/
|
|
3520
3809
|
];
|
|
3521
3810
|
var PUBLIC_ACTION_NAMES = [
|
|
3522
3811
|
/contact/i,
|
|
@@ -3690,13 +3979,15 @@ var nextPublicSensitiveRule = {
|
|
|
3690
3979
|
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3691
3980
|
category: "security",
|
|
3692
3981
|
severity: "critical",
|
|
3693
|
-
|
|
3982
|
+
// The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
|
|
3983
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
|
|
3694
3984
|
check(file, _project) {
|
|
3695
3985
|
if (isTestFile(file.relativePath)) return [];
|
|
3696
3986
|
const findings = [];
|
|
3987
|
+
const isEnvFile = file.ext === "env";
|
|
3697
3988
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3698
|
-
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3699
3989
|
const line = file.lines[i];
|
|
3990
|
+
if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3700
3991
|
const match = SENSITIVE_PATTERN.exec(line);
|
|
3701
3992
|
if (match) {
|
|
3702
3993
|
findings.push({
|
|
@@ -4159,7 +4450,17 @@ var unsafeFileUploadRule = {
|
|
|
4159
4450
|
|
|
4160
4451
|
// src/rules/supabase-missing-rls.ts
|
|
4161
4452
|
var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
|
|
4162
|
-
var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)
|
|
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
|
+
}
|
|
4163
4464
|
var supabaseMissingRlsRule = {
|
|
4164
4465
|
id: "supabase-missing-rls",
|
|
4165
4466
|
name: "Missing Row-Level Security",
|
|
@@ -4167,42 +4468,36 @@ var supabaseMissingRlsRule = {
|
|
|
4167
4468
|
category: "security",
|
|
4168
4469
|
severity: "critical",
|
|
4169
4470
|
fileExtensions: ["sql"],
|
|
4170
|
-
check(
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4471
|
+
check() {
|
|
4472
|
+
return [];
|
|
4473
|
+
},
|
|
4474
|
+
checkProject(files, _project) {
|
|
4174
4475
|
const findings = [];
|
|
4175
|
-
const tables = [];
|
|
4176
|
-
CREATE_TABLE.lastIndex = 0;
|
|
4177
|
-
let match;
|
|
4178
|
-
while ((match = CREATE_TABLE.exec(content)) !== null) {
|
|
4179
|
-
const name = match[1];
|
|
4180
|
-
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4181
|
-
const beforeMatch = content.slice(0, match.index);
|
|
4182
|
-
const line = beforeMatch.split("\n").length;
|
|
4183
|
-
tables.push({ name, line });
|
|
4184
|
-
}
|
|
4185
|
-
if (tables.length === 0) return [];
|
|
4186
4476
|
const rlsTables = /* @__PURE__ */ new Set();
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
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
|
+
}
|
|
4194
4483
|
}
|
|
4195
|
-
for (const
|
|
4196
|
-
if (!
|
|
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;
|
|
4197
4492
|
findings.push({
|
|
4198
4493
|
ruleId: "supabase-missing-rls",
|
|
4199
4494
|
file: file.relativePath,
|
|
4200
|
-
line:
|
|
4495
|
+
line: content.slice(0, match.index).split("\n").length,
|
|
4201
4496
|
column: 1,
|
|
4202
|
-
message: `Table "${
|
|
4497
|
+
message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
4203
4498
|
severity: "critical",
|
|
4204
4499
|
category: "security",
|
|
4205
|
-
fix: `Add: ALTER TABLE ${
|
|
4500
|
+
fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
4206
4501
|
});
|
|
4207
4502
|
}
|
|
4208
4503
|
}
|
|
@@ -4533,10 +4828,15 @@ async function scan(options) {
|
|
|
4533
4828
|
}
|
|
4534
4829
|
}
|
|
4535
4830
|
}
|
|
4831
|
+
const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
|
|
4536
4832
|
for (const rule of rules) {
|
|
4537
4833
|
if (rule.checkProject) {
|
|
4538
|
-
const
|
|
4539
|
-
|
|
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
|
+
}
|
|
4540
4840
|
}
|
|
4541
4841
|
}
|
|
4542
4842
|
const { overallScore, categoryScores } = calculateScores(findings);
|
|
@@ -4705,11 +5005,7 @@ function reportSarif(result) {
|
|
|
4705
5005
|
]
|
|
4706
5006
|
};
|
|
4707
5007
|
if (f.fix) {
|
|
4708
|
-
sarifResult.
|
|
4709
|
-
{
|
|
4710
|
-
description: { text: f.fix }
|
|
4711
|
-
}
|
|
4712
|
-
];
|
|
5008
|
+
sarifResult.properties = { fix: f.fix };
|
|
4713
5009
|
}
|
|
4714
5010
|
return sarifResult;
|
|
4715
5011
|
});
|