prodlint 0.10.3 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -9
- package/action.yml +17 -29
- package/dist/cli.js +507 -205
- package/dist/index.js +507 -205
- package/dist/mcp.js +641 -218
- package/package.json +1 -1
package/dist/mcp.js
CHANGED
|
@@ -10,7 +10,7 @@ import { resolve as resolve4, sep as sep2 } from "path";
|
|
|
10
10
|
// src/utils/file-walker.ts
|
|
11
11
|
import fg from "fast-glob";
|
|
12
12
|
import { readFile, stat, realpath } from "fs/promises";
|
|
13
|
-
import { resolve, extname, sep } from "path";
|
|
13
|
+
import { resolve, extname, basename, sep } from "path";
|
|
14
14
|
|
|
15
15
|
// src/utils/patterns.ts
|
|
16
16
|
function isApiRoute(relativePath) {
|
|
@@ -53,6 +53,19 @@ function isCommentLine(lines, lineIndex, commentMap) {
|
|
|
53
53
|
const trimmed = lines[lineIndex]?.trim() ?? "";
|
|
54
54
|
return trimmed.startsWith("//");
|
|
55
55
|
}
|
|
56
|
+
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
57
|
+
function blankOut(source, strings) {
|
|
58
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
59
|
+
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
60
|
+
}
|
|
61
|
+
function closingBrace(code, open) {
|
|
62
|
+
let depth = 0;
|
|
63
|
+
for (let i = open; i < code.length; i++) {
|
|
64
|
+
if (code[i] === "{") depth++;
|
|
65
|
+
else if (code[i] === "}" && --depth === 0) return i;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
}
|
|
56
69
|
function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
57
70
|
for (const line of lines) {
|
|
58
71
|
const trimmed = line.trim();
|
|
@@ -443,7 +456,8 @@ async function readFileContext(root, relativePath) {
|
|
|
443
456
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
444
457
|
const content = await readFile(absolutePath, "utf-8");
|
|
445
458
|
const lines = content.split(/\r?\n|\r/);
|
|
446
|
-
const
|
|
459
|
+
const rawExt = extname(relativePath).slice(1);
|
|
460
|
+
const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
|
|
447
461
|
let ast = void 0;
|
|
448
462
|
if (AST_EXTENSIONS.has(ext)) {
|
|
449
463
|
try {
|
|
@@ -712,12 +726,12 @@ function calculateScores(findings) {
|
|
|
712
726
|
}
|
|
713
727
|
let totalDeduction = 0;
|
|
714
728
|
for (const [, ruleFindings] of byRule) {
|
|
715
|
-
const
|
|
729
|
+
const bySeverity2 = { critical: 0, warning: 0, info: 0 };
|
|
716
730
|
for (const f of ruleFindings) {
|
|
717
|
-
|
|
731
|
+
bySeverity2[f.severity]++;
|
|
718
732
|
}
|
|
719
733
|
for (const sev of ["critical", "warning", "info"]) {
|
|
720
|
-
const count = Math.min(
|
|
734
|
+
const count = Math.min(bySeverity2[sev], PER_RULE_CAP[sev]);
|
|
721
735
|
totalDeduction += count * DEDUCTIONS[sev];
|
|
722
736
|
}
|
|
723
737
|
}
|
|
@@ -749,12 +763,21 @@ function summarizeFindings(findings) {
|
|
|
749
763
|
}
|
|
750
764
|
|
|
751
765
|
// src/rules/secrets.ts
|
|
766
|
+
function nameJwt(token) {
|
|
767
|
+
try {
|
|
768
|
+
const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
|
|
769
|
+
if (claims?.role === "anon") return null;
|
|
770
|
+
if (claims?.role === "service_role") return "Supabase service role key";
|
|
771
|
+
} catch {
|
|
772
|
+
}
|
|
773
|
+
return "JWT";
|
|
774
|
+
}
|
|
752
775
|
var SECRET_PATTERNS = [
|
|
753
776
|
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
754
777
|
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
755
778
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
756
779
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
757
|
-
{ name: "
|
|
780
|
+
{ name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
|
|
758
781
|
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
759
782
|
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
760
783
|
{ name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
|
|
@@ -789,15 +812,17 @@ var secretsRule = {
|
|
|
789
812
|
for (let i = 0; i < file.lines.length; i++) {
|
|
790
813
|
const line = file.lines[i];
|
|
791
814
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
792
|
-
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
815
|
+
for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
|
|
793
816
|
const match = pattern.exec(line);
|
|
794
817
|
if (match) {
|
|
818
|
+
const reported = nameMatch ? nameMatch(match[0]) : name;
|
|
819
|
+
if (!reported) continue;
|
|
795
820
|
findings.push({
|
|
796
821
|
ruleId: "secrets",
|
|
797
822
|
file: file.relativePath,
|
|
798
823
|
line: i + 1,
|
|
799
824
|
column: match.index + 1,
|
|
800
|
-
message: `Hardcoded ${
|
|
825
|
+
message: `Hardcoded ${reported} detected`,
|
|
801
826
|
severity: "critical",
|
|
802
827
|
category: "security",
|
|
803
828
|
fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
|
|
@@ -953,12 +978,208 @@ var AUTH_PATTERNS = [
|
|
|
953
978
|
/createRouteHandlerClient/,
|
|
954
979
|
/createServerComponentClient/,
|
|
955
980
|
/createMiddlewareClient/,
|
|
956
|
-
|
|
981
|
+
// Reading the request's Authorization header, not sending one upstream:
|
|
982
|
+
// headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
|
|
983
|
+
/\.(?:get|header)\(\s*['"`]authorization['"`]/i,
|
|
984
|
+
/\.headers\??\.authorization\b(?!\s*=(?!=))/i,
|
|
985
|
+
/\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
|
|
957
986
|
/getAuth\s*\(/,
|
|
958
987
|
/withPageAuth/,
|
|
959
988
|
/cookies\(\).*auth/s
|
|
960
989
|
];
|
|
961
|
-
var
|
|
990
|
+
var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
991
|
+
var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
992
|
+
var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
|
|
993
|
+
var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
|
|
994
|
+
var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
|
|
995
|
+
function words(name) {
|
|
996
|
+
return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
|
|
997
|
+
}
|
|
998
|
+
function saysAuth(name) {
|
|
999
|
+
const w = words(name);
|
|
1000
|
+
return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
|
|
1001
|
+
}
|
|
1002
|
+
function isAuthHelper(name, source) {
|
|
1003
|
+
if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
|
|
1004
|
+
return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
|
|
1005
|
+
}
|
|
1006
|
+
var AUTHED = { authed: true, local: true };
|
|
1007
|
+
var LOCAL = { authed: false, local: true };
|
|
1008
|
+
var ELSEWHERE = { authed: false, local: false };
|
|
1009
|
+
function handlersFromAst(program) {
|
|
1010
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1011
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1012
|
+
for (const st of program.body) {
|
|
1013
|
+
if (st.type === "ImportDeclaration") {
|
|
1014
|
+
for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
|
|
1015
|
+
continue;
|
|
1016
|
+
}
|
|
1017
|
+
const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
|
|
1018
|
+
if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
|
|
1019
|
+
if (decl?.type === "VariableDeclaration") {
|
|
1020
|
+
for (const d of decl.declarations) {
|
|
1021
|
+
if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const helperOf = (callee) => {
|
|
1026
|
+
if (callee.type === "CallExpression") return helperOf(callee.callee);
|
|
1027
|
+
if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
|
|
1028
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
1029
|
+
let root = callee.object;
|
|
1030
|
+
while (root.type === "MemberExpression") root = root.object;
|
|
1031
|
+
return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
|
|
1032
|
+
}
|
|
1033
|
+
return null;
|
|
1034
|
+
};
|
|
1035
|
+
const classify = (node, seen) => {
|
|
1036
|
+
while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
|
|
1037
|
+
node = node.type === "AwaitExpression" ? node.argument : node.expression;
|
|
1038
|
+
}
|
|
1039
|
+
switch (node.type) {
|
|
1040
|
+
case "FunctionDeclaration":
|
|
1041
|
+
case "FunctionExpression":
|
|
1042
|
+
case "ArrowFunctionExpression":
|
|
1043
|
+
return LOCAL;
|
|
1044
|
+
case "Identifier": {
|
|
1045
|
+
const bound = bindings.get(node.name);
|
|
1046
|
+
if (!bound || seen.has(node.name)) return ELSEWHERE;
|
|
1047
|
+
return classify(bound, new Set(seen).add(node.name));
|
|
1048
|
+
}
|
|
1049
|
+
case "CallExpression": {
|
|
1050
|
+
const helper = helperOf(node.callee);
|
|
1051
|
+
if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
|
|
1052
|
+
let local = false;
|
|
1053
|
+
for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
|
|
1054
|
+
const verdict = classify(arg, seen);
|
|
1055
|
+
if (verdict.authed) return verdict;
|
|
1056
|
+
local ||= verdict.local;
|
|
1057
|
+
}
|
|
1058
|
+
return { authed: false, local };
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return ELSEWHERE;
|
|
1062
|
+
};
|
|
1063
|
+
const handlers = [];
|
|
1064
|
+
const add = (method, at, verdict) => {
|
|
1065
|
+
handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
|
|
1066
|
+
};
|
|
1067
|
+
for (const st of program.body) {
|
|
1068
|
+
if (st.type === "ExportNamedDeclaration") {
|
|
1069
|
+
const decl = st.declaration;
|
|
1070
|
+
if (decl?.type === "FunctionDeclaration") {
|
|
1071
|
+
if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
|
|
1072
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
1073
|
+
for (const d of decl.declarations) {
|
|
1074
|
+
if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
|
|
1075
|
+
add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
|
|
1076
|
+
} else if (d.id.type === "ObjectPattern") {
|
|
1077
|
+
for (const p of d.id.properties) {
|
|
1078
|
+
if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
|
|
1079
|
+
add(p.value.name, d, ELSEWHERE);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
} else if (!decl) {
|
|
1085
|
+
for (const s of st.specifiers) {
|
|
1086
|
+
if (s.type !== "ExportSpecifier") continue;
|
|
1087
|
+
const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
|
|
1088
|
+
if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
} else if (st.type === "ExportDefaultDeclaration") {
|
|
1092
|
+
add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
|
|
1093
|
+
} else if (st.type === "ExportAllDeclaration") {
|
|
1094
|
+
add("*", st, ELSEWHERE);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
return handlers;
|
|
1098
|
+
}
|
|
1099
|
+
var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
|
|
1100
|
+
var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
|
|
1101
|
+
var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1102
|
+
var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
|
|
1103
|
+
var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
|
|
1104
|
+
var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
|
|
1105
|
+
var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
|
|
1106
|
+
var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
|
|
1107
|
+
var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
|
|
1108
|
+
var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
|
|
1109
|
+
var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
|
|
1110
|
+
function handlersFromText(file) {
|
|
1111
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1112
|
+
for (const m of file.content.matchAll(IMPORT)) {
|
|
1113
|
+
for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
|
|
1114
|
+
const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1115
|
+
if (spec) imports.set(spec[2] ?? spec[1], m[2]);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
|
|
1119
|
+
const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
|
|
1120
|
+
const resolve5 = (name, seen) => {
|
|
1121
|
+
const id = name.replace(/\$/g, "\\$");
|
|
1122
|
+
const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
|
|
1123
|
+
const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1124
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1125
|
+
if (!code(i)) continue;
|
|
1126
|
+
if (fn.test(file.lines[i])) return LOCAL;
|
|
1127
|
+
const m = variable.exec(file.lines[i]);
|
|
1128
|
+
if (m) return classify(value(i, m[1]), seen);
|
|
1129
|
+
}
|
|
1130
|
+
return ELSEWHERE;
|
|
1131
|
+
};
|
|
1132
|
+
const classify = (text, seen) => {
|
|
1133
|
+
const t = text.trim().replace(/^await\s+/, "");
|
|
1134
|
+
if (INLINE_FUNCTION.test(t)) return LOCAL;
|
|
1135
|
+
const call = CALL.exec(t);
|
|
1136
|
+
if (call) {
|
|
1137
|
+
if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
|
|
1138
|
+
let args = t.slice(call[0].length);
|
|
1139
|
+
const curried = /^[^()]*\)\s*\(/.exec(args);
|
|
1140
|
+
if (curried) args = args.slice(curried[0].length);
|
|
1141
|
+
return classify(args, seen);
|
|
1142
|
+
}
|
|
1143
|
+
const id = IDENTIFIER.exec(t);
|
|
1144
|
+
if (id && !seen.has(id[1])) return resolve5(id[1], new Set(seen).add(id[1]));
|
|
1145
|
+
return ELSEWHERE;
|
|
1146
|
+
};
|
|
1147
|
+
const handlers = [];
|
|
1148
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1149
|
+
if (!code(i)) continue;
|
|
1150
|
+
const line = file.lines[i];
|
|
1151
|
+
let m;
|
|
1152
|
+
if (m = EXPORT_FUNCTION.exec(line)) {
|
|
1153
|
+
handlers.push({ method: m[1], line: i + 1, ...LOCAL });
|
|
1154
|
+
} else if (m = EXPORT_CONST.exec(line)) {
|
|
1155
|
+
handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
|
|
1156
|
+
} else if (m = EXPORT_DESTRUCTURED.exec(line)) {
|
|
1157
|
+
for (const part of m[1].split(",")) {
|
|
1158
|
+
const name = part.split(":").pop()?.trim() ?? "";
|
|
1159
|
+
if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
|
|
1160
|
+
}
|
|
1161
|
+
} else if (m = EXPORT_DEFAULT.exec(line)) {
|
|
1162
|
+
handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
|
|
1163
|
+
} else if (EXPORT_ALL.test(line)) {
|
|
1164
|
+
handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
|
|
1168
|
+
for (const m of file.content.matchAll(EXPORT_LIST)) {
|
|
1169
|
+
if (!code(lineAt(m.index) - 1)) continue;
|
|
1170
|
+
let offset = m.index + m[0].indexOf("{") + 1;
|
|
1171
|
+
for (const part of m[1].split(",")) {
|
|
1172
|
+
const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1173
|
+
const name = spec ? spec[2] ?? spec[1] : "";
|
|
1174
|
+
if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
|
|
1175
|
+
const line = lineAt(offset + part.length - part.trimStart().length);
|
|
1176
|
+
handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
|
|
1177
|
+
}
|
|
1178
|
+
offset += part.length + 1;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return handlers.sort((a, b) => a.line - b.line);
|
|
1182
|
+
}
|
|
962
1183
|
var authChecksRule = {
|
|
963
1184
|
id: "auth-checks",
|
|
964
1185
|
name: "Missing Auth Checks",
|
|
@@ -974,21 +1195,18 @@ var authChecksRule = {
|
|
|
974
1195
|
for (const pattern of AUTH_PATTERNS) {
|
|
975
1196
|
if (pattern.test(file.content)) return [];
|
|
976
1197
|
}
|
|
1198
|
+
const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
|
|
1199
|
+
const exposed = handlers.filter((h) => !h.authed);
|
|
1200
|
+
if (handlers.length > 0 && exposed.length === 0) return [];
|
|
977
1201
|
let severity;
|
|
978
1202
|
if (project.hasAuthMiddleware) {
|
|
979
1203
|
severity = "info";
|
|
980
|
-
} else if (
|
|
1204
|
+
} else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
|
|
981
1205
|
severity = "critical";
|
|
982
1206
|
} else {
|
|
983
1207
|
severity = "info";
|
|
984
1208
|
}
|
|
985
|
-
|
|
986
|
-
for (let i = 0; i < file.lines.length; i++) {
|
|
987
|
-
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
988
|
-
handlerLine = i + 1;
|
|
989
|
-
break;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
1209
|
+
const handlerLine = exposed[0]?.line ?? 1;
|
|
992
1210
|
const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
|
|
993
1211
|
return [{
|
|
994
1212
|
ruleId: "auth-checks",
|
|
@@ -1107,7 +1325,8 @@ var errorHandlingRule = {
|
|
|
1107
1325
|
|
|
1108
1326
|
// src/rules/input-validation.ts
|
|
1109
1327
|
var VALIDATION_PATTERNS = [
|
|
1110
|
-
|
|
1328
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
1329
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
1111
1330
|
/\.safeParse\s*\(/,
|
|
1112
1331
|
/\.validate\s*\(/,
|
|
1113
1332
|
/\.validateSync\s*\(/,
|
|
@@ -1408,6 +1627,40 @@ var aiSmellsRule = {
|
|
|
1408
1627
|
};
|
|
1409
1628
|
|
|
1410
1629
|
// src/rules/unsafe-html.ts
|
|
1630
|
+
var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
|
|
1631
|
+
var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
|
|
1632
|
+
function isSanitizerCall(node) {
|
|
1633
|
+
if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
|
|
1634
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
|
|
1635
|
+
return isSanitizerCall(node.expression);
|
|
1636
|
+
}
|
|
1637
|
+
if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
|
|
1638
|
+
const callee = node.callee;
|
|
1639
|
+
if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
|
|
1640
|
+
return callee.property.type === "Identifier" && callee.property.name === "sanitize";
|
|
1641
|
+
}
|
|
1642
|
+
return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
|
|
1643
|
+
}
|
|
1644
|
+
function containsJsonStringify(node) {
|
|
1645
|
+
return subtreeContains(node, (n) => {
|
|
1646
|
+
if (n.type !== "CallExpression") return false;
|
|
1647
|
+
const call = n;
|
|
1648
|
+
if (call.callee.type === "MemberExpression") {
|
|
1649
|
+
const mem = call.callee;
|
|
1650
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1651
|
+
}
|
|
1652
|
+
return false;
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
function isSanitized(value, sanitizedNames) {
|
|
1656
|
+
if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
|
|
1657
|
+
if (value.type === "ObjectExpression") {
|
|
1658
|
+
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"));
|
|
1659
|
+
return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
|
|
1660
|
+
}
|
|
1661
|
+
if (value.type === "Identifier") return sanitizedNames.has(value.name);
|
|
1662
|
+
return isSanitizerCall(value);
|
|
1663
|
+
}
|
|
1411
1664
|
var unsafeHtmlRule = {
|
|
1412
1665
|
id: "unsafe-html",
|
|
1413
1666
|
name: "Unsafe HTML Rendering",
|
|
@@ -1419,21 +1672,17 @@ var unsafeHtmlRule = {
|
|
|
1419
1672
|
const findings = [];
|
|
1420
1673
|
if (file.ast) {
|
|
1421
1674
|
try {
|
|
1675
|
+
const sanitizedNames = /* @__PURE__ */ new Set();
|
|
1676
|
+
walkAST(file.ast.program, (node) => {
|
|
1677
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
|
|
1678
|
+
sanitizedNames.add(node.id.name);
|
|
1679
|
+
}
|
|
1680
|
+
});
|
|
1422
1681
|
walkAST(file.ast.program, (node) => {
|
|
1423
1682
|
if (node.type === "JSXAttribute") {
|
|
1424
1683
|
const attr = node;
|
|
1425
1684
|
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1426
|
-
if (attr.value &&
|
|
1427
|
-
if (n.type !== "CallExpression") return false;
|
|
1428
|
-
const call = n;
|
|
1429
|
-
if (call.callee.type === "MemberExpression") {
|
|
1430
|
-
const mem = call.callee;
|
|
1431
|
-
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1432
|
-
}
|
|
1433
|
-
return false;
|
|
1434
|
-
})) {
|
|
1435
|
-
return;
|
|
1436
|
-
}
|
|
1685
|
+
if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
|
|
1437
1686
|
const line = attr.loc.start.line;
|
|
1438
1687
|
findings.push({
|
|
1439
1688
|
ruleId: "unsafe-html",
|
|
@@ -1450,17 +1699,7 @@ var unsafeHtmlRule = {
|
|
|
1450
1699
|
if (node.type === "ObjectProperty") {
|
|
1451
1700
|
const prop = node;
|
|
1452
1701
|
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1453
|
-
if (prop.value &&
|
|
1454
|
-
if (n.type !== "CallExpression") return false;
|
|
1455
|
-
const call = n;
|
|
1456
|
-
if (call.callee.type === "MemberExpression") {
|
|
1457
|
-
const mem = call.callee;
|
|
1458
|
-
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1459
|
-
}
|
|
1460
|
-
return false;
|
|
1461
|
-
})) {
|
|
1462
|
-
return;
|
|
1463
|
-
}
|
|
1702
|
+
if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
|
|
1464
1703
|
const line = prop.loc.start.line;
|
|
1465
1704
|
findings.push({
|
|
1466
1705
|
ruleId: "unsafe-html",
|
|
@@ -1477,6 +1716,7 @@ var unsafeHtmlRule = {
|
|
|
1477
1716
|
if (node.type === "AssignmentExpression") {
|
|
1478
1717
|
const assign = node;
|
|
1479
1718
|
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1719
|
+
if (isSanitized(assign.right, sanitizedNames)) return;
|
|
1480
1720
|
const line = assign.loc.start.line;
|
|
1481
1721
|
findings.push({
|
|
1482
1722
|
ruleId: "unsafe-html",
|
|
@@ -1507,6 +1747,8 @@ var unsafeHtmlRule = {
|
|
|
1507
1747
|
}
|
|
1508
1748
|
const expr = context.join(" ");
|
|
1509
1749
|
if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
|
|
1750
|
+
const html = /__html\s*:/.exec(expr);
|
|
1751
|
+
if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
|
|
1510
1752
|
findings.push({
|
|
1511
1753
|
ruleId: "unsafe-html",
|
|
1512
1754
|
file: file.relativePath,
|
|
@@ -1518,7 +1760,8 @@ var unsafeHtmlRule = {
|
|
|
1518
1760
|
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1519
1761
|
});
|
|
1520
1762
|
}
|
|
1521
|
-
|
|
1763
|
+
const innerHtml = /\w\.innerHTML\s*=/.exec(line);
|
|
1764
|
+
if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
|
|
1522
1765
|
findings.push({
|
|
1523
1766
|
ruleId: "unsafe-html",
|
|
1524
1767
|
file: file.relativePath,
|
|
@@ -1697,9 +1940,16 @@ var staleFallbackRule = {
|
|
|
1697
1940
|
};
|
|
1698
1941
|
|
|
1699
1942
|
// src/rules/hallucinated-api.ts
|
|
1943
|
+
var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
|
|
1944
|
+
var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
|
|
1700
1945
|
var HALLUCINATED_APIS = [
|
|
1701
1946
|
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1702
|
-
{
|
|
1947
|
+
{
|
|
1948
|
+
pattern: /\.contains\s*\(/,
|
|
1949
|
+
fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
|
|
1950
|
+
methodName: "contains",
|
|
1951
|
+
isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
|
|
1952
|
+
},
|
|
1703
1953
|
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1704
1954
|
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1705
1955
|
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
@@ -1721,10 +1971,11 @@ var hallucinatedApiRule = {
|
|
|
1721
1971
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1722
1972
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1723
1973
|
const line = file.lines[i];
|
|
1724
|
-
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
1974
|
+
for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
|
|
1725
1975
|
const match = pattern.exec(line);
|
|
1726
1976
|
if (match) {
|
|
1727
1977
|
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
1978
|
+
if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
|
|
1728
1979
|
findings.push({
|
|
1729
1980
|
ruleId: "hallucinated-api",
|
|
1730
1981
|
file: file.relativePath,
|
|
@@ -2364,6 +2615,13 @@ function isEntryPoint(relativePath) {
|
|
|
2364
2615
|
const name = relativePath.split("/").pop() ?? "";
|
|
2365
2616
|
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";
|
|
2366
2617
|
}
|
|
2618
|
+
var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2619
|
+
var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2620
|
+
var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
|
|
2621
|
+
var FROM_SOURCE = /from\s+['"]([^'"]+)['"]/g;
|
|
2622
|
+
function sourceBasename(source) {
|
|
2623
|
+
return source.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
2624
|
+
}
|
|
2367
2625
|
var THRESHOLD = 5;
|
|
2368
2626
|
var deadExportsRule = {
|
|
2369
2627
|
id: "dead-exports",
|
|
@@ -2383,6 +2641,7 @@ var deadExportsRule = {
|
|
|
2383
2641
|
const imports = /* @__PURE__ */ new Map();
|
|
2384
2642
|
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
2385
2643
|
const importedFiles = /* @__PURE__ */ new Set();
|
|
2644
|
+
const namespaceImported = /* @__PURE__ */ new Set();
|
|
2386
2645
|
for (const file of sourceFiles) {
|
|
2387
2646
|
if (isEntryPoint(file.relativePath)) continue;
|
|
2388
2647
|
let inTemplateLiteral = false;
|
|
@@ -2425,42 +2684,35 @@ var deadExportsRule = {
|
|
|
2425
2684
|
}
|
|
2426
2685
|
}
|
|
2427
2686
|
}
|
|
2687
|
+
const addImport = (sym, source) => {
|
|
2688
|
+
allImportedSymbols.add(sym);
|
|
2689
|
+
const fromBasename = sourceBasename(source);
|
|
2690
|
+
if (fromBasename) {
|
|
2691
|
+
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2692
|
+
set.add(sym);
|
|
2693
|
+
imports.set(fromBasename, set);
|
|
2694
|
+
}
|
|
2695
|
+
};
|
|
2428
2696
|
for (const file of files) {
|
|
2429
|
-
for (const
|
|
2430
|
-
|
|
2431
|
-
const
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
imports.set(fromBasename, set);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
2445
|
-
const defaultRe = /import\s+(\w+)\s+from/g;
|
|
2446
|
-
while ((match = defaultRe.exec(line)) !== null) {
|
|
2447
|
-
allImportedSymbols.add(match[1]);
|
|
2448
|
-
if (fromBasename) {
|
|
2449
|
-
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2450
|
-
set.add(match[1]);
|
|
2451
|
-
imports.set(fromBasename, set);
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
const fromRe = /from\s+['"]([^'"]+)['"]/g;
|
|
2455
|
-
while ((match = fromRe.exec(line)) !== null) {
|
|
2456
|
-
importedFiles.add(match[1]);
|
|
2457
|
-
}
|
|
2697
|
+
for (const match of file.content.matchAll(IMPORT_NAMED)) {
|
|
2698
|
+
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);
|
|
2699
|
+
for (const sym of symbols) addImport(sym, match[2]);
|
|
2700
|
+
}
|
|
2701
|
+
for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
|
|
2702
|
+
addImport(match[1], match[2]);
|
|
2703
|
+
}
|
|
2704
|
+
for (const match of file.content.matchAll(IMPORT_NAMESPACE)) {
|
|
2705
|
+
namespaceImported.add(sourceBasename(match[1]));
|
|
2706
|
+
}
|
|
2707
|
+
for (const match of file.content.matchAll(FROM_SOURCE)) {
|
|
2708
|
+
importedFiles.add(match[1]);
|
|
2458
2709
|
}
|
|
2459
2710
|
}
|
|
2460
2711
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
2461
2712
|
for (const [key, loc] of exports) {
|
|
2462
2713
|
const symbolName = key.split("::")[1];
|
|
2463
2714
|
const exportFileBasename = loc.file.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
2715
|
+
if (namespaceImported.has(exportFileBasename)) continue;
|
|
2464
2716
|
const importSet = imports.get(exportFileBasename);
|
|
2465
2717
|
const isImported = importSet?.has(symbolName) ?? false;
|
|
2466
2718
|
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
@@ -2530,19 +2782,6 @@ function catchFinding(file, catchLine, body) {
|
|
|
2530
2782
|
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2531
2783
|
};
|
|
2532
2784
|
}
|
|
2533
|
-
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
2534
|
-
function blankOut(source, strings) {
|
|
2535
|
-
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
2536
|
-
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
2537
|
-
}
|
|
2538
|
-
function closingBrace(code, open) {
|
|
2539
|
-
let depth = 0;
|
|
2540
|
-
for (let i = open; i < code.length; i++) {
|
|
2541
|
-
if (code[i] === "{") depth++;
|
|
2542
|
-
else if (code[i] === "}" && --depth === 0) return i;
|
|
2543
|
-
}
|
|
2544
|
-
return -1;
|
|
2545
|
-
}
|
|
2546
2785
|
function returnedValues(code, text) {
|
|
2547
2786
|
const values = [];
|
|
2548
2787
|
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
@@ -2720,47 +2959,46 @@ var comprehensionDebtRule = {
|
|
|
2720
2959
|
};
|
|
2721
2960
|
|
|
2722
2961
|
// src/rules/phantom-dependency.ts
|
|
2723
|
-
var PHANTOM_PACKAGES = /* @__PURE__ */ new
|
|
2724
|
-
"huggingface-cli",
|
|
2725
|
-
//
|
|
2726
|
-
"flask-hierarchical",
|
|
2727
|
-
//
|
|
2728
|
-
"beautifulsoup",
|
|
2729
|
-
//
|
|
2730
|
-
"python-dotenv",
|
|
2731
|
-
//
|
|
2732
|
-
"openai-sdk",
|
|
2733
|
-
//
|
|
2734
|
-
"anthropic-sdk",
|
|
2735
|
-
//
|
|
2736
|
-
"langchain-core",
|
|
2737
|
-
//
|
|
2738
|
-
"react-native-utils",
|
|
2739
|
-
//
|
|
2740
|
-
"next-middleware",
|
|
2741
|
-
//
|
|
2742
|
-
"supabase-client",
|
|
2743
|
-
//
|
|
2744
|
-
"stripe-sdk",
|
|
2745
|
-
//
|
|
2746
|
-
"prisma-client",
|
|
2747
|
-
//
|
|
2748
|
-
"tailwind-utils",
|
|
2749
|
-
//
|
|
2750
|
-
"express-validator-v2",
|
|
2751
|
-
//
|
|
2752
|
-
"node-postgres-pool",
|
|
2753
|
-
//
|
|
2754
|
-
"mongo-client",
|
|
2755
|
-
//
|
|
2756
|
-
"redis-client",
|
|
2757
|
-
//
|
|
2758
|
-
"aws-s3-upload",
|
|
2759
|
-
//
|
|
2760
|
-
"gpt-tokenizer"
|
|
2761
|
-
// exists but often confused
|
|
2962
|
+
var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
|
|
2963
|
+
["huggingface-cli", ["@huggingface/hub"]],
|
|
2964
|
+
// a Python CLI; an npm placeholder
|
|
2965
|
+
["flask-hierarchical", []],
|
|
2966
|
+
// not on npm
|
|
2967
|
+
["beautifulsoup", ["cheerio"]],
|
|
2968
|
+
// a Python library (beautifulsoup4); not on npm
|
|
2969
|
+
["python-dotenv", ["dotenv"]],
|
|
2970
|
+
// a Python library; an npm placeholder
|
|
2971
|
+
["openai-sdk", ["openai"]],
|
|
2972
|
+
// unpublished
|
|
2973
|
+
["anthropic-sdk", ["@anthropic-ai/sdk"]],
|
|
2974
|
+
// an npm placeholder
|
|
2975
|
+
["langchain-core", ["@langchain/core"]],
|
|
2976
|
+
// deprecated
|
|
2977
|
+
["react-native-utils", []],
|
|
2978
|
+
// a 2015 native module
|
|
2979
|
+
["next-middleware", []],
|
|
2980
|
+
// a 2017 0.0.1; Next.js has middleware built in
|
|
2981
|
+
["supabase-client", ["@supabase/supabase-js"]],
|
|
2982
|
+
// unpublished
|
|
2983
|
+
["stripe-sdk", ["stripe"]],
|
|
2984
|
+
// not on npm
|
|
2985
|
+
["prisma-client", ["@prisma/client"]],
|
|
2986
|
+
// an empty 0.0.0 placeholder
|
|
2987
|
+
["tailwind-utils", []],
|
|
2988
|
+
// a 2021 alpha
|
|
2989
|
+
["express-validator-v2", ["express-validator"]],
|
|
2990
|
+
// not on npm
|
|
2991
|
+
["node-postgres-pool", ["pg"]],
|
|
2992
|
+
// not on npm
|
|
2993
|
+
["mongo-client", ["mongodb"]],
|
|
2994
|
+
// a 2013 wrapper
|
|
2995
|
+
["redis-client", ["redis", "ioredis"]],
|
|
2996
|
+
// a 2015 client
|
|
2997
|
+
["aws-s3-upload", ["@aws-sdk/client-s3"]]
|
|
2998
|
+
// a 2018 0.0.2
|
|
2762
2999
|
]);
|
|
2763
|
-
var
|
|
3000
|
+
var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
|
|
3001
|
+
// 2-char names
|
|
2764
3002
|
"pg",
|
|
2765
3003
|
"ws",
|
|
2766
3004
|
"ms",
|
|
@@ -2774,7 +3012,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
2774
3012
|
"io",
|
|
2775
3013
|
"db",
|
|
2776
3014
|
"fp",
|
|
2777
|
-
"rx"
|
|
3015
|
+
"rx",
|
|
3016
|
+
// -js suffix
|
|
3017
|
+
"crypto-js",
|
|
3018
|
+
"chroma-js",
|
|
3019
|
+
"libphonenumber-js",
|
|
3020
|
+
"ua-parser-js",
|
|
3021
|
+
"keycloak-js",
|
|
3022
|
+
"pusher-js",
|
|
3023
|
+
"matter-js",
|
|
3024
|
+
"amplitude-js",
|
|
3025
|
+
// Vanity prefixes
|
|
3026
|
+
"fast-glob",
|
|
3027
|
+
"fast-deep-equal",
|
|
3028
|
+
"fast-json-stable-stringify",
|
|
3029
|
+
"fast-xml-parser",
|
|
3030
|
+
"fast-levenshtein",
|
|
3031
|
+
"fast-safe-stringify",
|
|
3032
|
+
"fast-uri",
|
|
3033
|
+
"fast-equals",
|
|
3034
|
+
"fast-csv",
|
|
3035
|
+
"fast-check",
|
|
3036
|
+
"fast-diff",
|
|
3037
|
+
"simple-git",
|
|
3038
|
+
"simple-peer",
|
|
3039
|
+
"simple-icons"
|
|
2778
3040
|
]);
|
|
2779
3041
|
var SUSPICIOUS_PATTERNS = [
|
|
2780
3042
|
/^[a-z]{1,2}$/,
|
|
@@ -2784,6 +3046,9 @@ var SUSPICIOUS_PATTERNS = [
|
|
|
2784
3046
|
/^(the|my|simple|easy|fast|super|mega|ultra)-/
|
|
2785
3047
|
// vanity prefixes
|
|
2786
3048
|
];
|
|
3049
|
+
function quoteList(names) {
|
|
3050
|
+
return names.map((n) => `"${n}"`).join(" or ");
|
|
3051
|
+
}
|
|
2787
3052
|
var phantomDependencyRule = {
|
|
2788
3053
|
id: "phantom-dependency",
|
|
2789
3054
|
name: "Phantom Dependency",
|
|
@@ -2802,20 +3067,21 @@ var phantomDependencyRule = {
|
|
|
2802
3067
|
...project.packageJson.devDependencies ?? {}
|
|
2803
3068
|
};
|
|
2804
3069
|
for (const [name, _version] of Object.entries(deps)) {
|
|
2805
|
-
|
|
3070
|
+
const meant = PHANTOM_PACKAGES.get(name);
|
|
3071
|
+
if (meant) {
|
|
2806
3072
|
findings.push({
|
|
2807
3073
|
ruleId: "phantom-dependency",
|
|
2808
3074
|
file: "package.json",
|
|
2809
3075
|
line: 1,
|
|
2810
3076
|
column: 1,
|
|
2811
|
-
message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
3077
|
+
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`,
|
|
2812
3078
|
severity: "warning",
|
|
2813
3079
|
category: "security",
|
|
2814
|
-
fix: "
|
|
3080
|
+
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`
|
|
2815
3081
|
});
|
|
2816
3082
|
}
|
|
2817
3083
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
2818
|
-
if (pattern.test(name) && !name.startsWith("@") && !
|
|
3084
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
|
|
2819
3085
|
findings.push({
|
|
2820
3086
|
ruleId: "phantom-dependency",
|
|
2821
3087
|
file: "package.json",
|
|
@@ -3001,10 +3267,11 @@ var insecureRandomRule = {
|
|
|
3001
3267
|
};
|
|
3002
3268
|
|
|
3003
3269
|
// src/rules/next-server-action-validation.ts
|
|
3004
|
-
var USE_SERVER =
|
|
3270
|
+
var USE_SERVER = /^\s*['"]use server['"]/m;
|
|
3005
3271
|
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
3006
3272
|
var VALIDATION_PATTERNS2 = [
|
|
3007
|
-
|
|
3273
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
3274
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
3008
3275
|
/\.safeParse\s*\(/,
|
|
3009
3276
|
/\bvalidate\s*\(/,
|
|
3010
3277
|
/\.parseAsync\s*\(/,
|
|
@@ -3155,6 +3422,30 @@ function findEnclosingFunction(node, parentMap) {
|
|
|
3155
3422
|
}
|
|
3156
3423
|
|
|
3157
3424
|
// src/rules/redirect-in-try-catch.ts
|
|
3425
|
+
var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
|
|
3426
|
+
function rethrowsRedirect(handler) {
|
|
3427
|
+
const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
|
|
3428
|
+
if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
|
|
3429
|
+
return true;
|
|
3430
|
+
}
|
|
3431
|
+
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"));
|
|
3432
|
+
}
|
|
3433
|
+
function bodyRethrowsRedirect(body, param) {
|
|
3434
|
+
if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
|
|
3435
|
+
return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
|
|
3436
|
+
}
|
|
3437
|
+
function redirectFinding(file, line, column) {
|
|
3438
|
+
return {
|
|
3439
|
+
ruleId: "redirect-in-try-catch",
|
|
3440
|
+
file: file.relativePath,
|
|
3441
|
+
line,
|
|
3442
|
+
column,
|
|
3443
|
+
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3444
|
+
severity: "critical",
|
|
3445
|
+
category: "reliability",
|
|
3446
|
+
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'
|
|
3447
|
+
};
|
|
3448
|
+
}
|
|
3158
3449
|
var redirectInTryCatchRule = {
|
|
3159
3450
|
id: "redirect-in-try-catch",
|
|
3160
3451
|
name: "Redirect Inside Try/Catch",
|
|
@@ -3166,44 +3457,42 @@ var redirectInTryCatchRule = {
|
|
|
3166
3457
|
if (isTestFile(file.relativePath)) return [];
|
|
3167
3458
|
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
3168
3459
|
const findings = [];
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
if (tryDepth > 0) {
|
|
3180
|
-
const match = /\bredirect\s*\(/.exec(line);
|
|
3181
|
-
if (match && !/\/\//.test(line.slice(0, match.index))) {
|
|
3182
|
-
findings.push({
|
|
3183
|
-
ruleId: "redirect-in-try-catch",
|
|
3184
|
-
file: file.relativePath,
|
|
3185
|
-
line: i + 1,
|
|
3186
|
-
column: match.index + 1,
|
|
3187
|
-
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3188
|
-
severity: "critical",
|
|
3189
|
-
category: "reliability",
|
|
3190
|
-
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'
|
|
3460
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3461
|
+
if (file.ast) {
|
|
3462
|
+
try {
|
|
3463
|
+
walkAST(file.ast.program, (node) => {
|
|
3464
|
+
if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
|
|
3465
|
+
walkAST(node.block, (n) => {
|
|
3466
|
+
if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
|
|
3467
|
+
if (!n.loc || n.start == null || seen.has(n.start)) return;
|
|
3468
|
+
seen.add(n.start);
|
|
3469
|
+
findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
|
|
3191
3470
|
});
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3471
|
+
});
|
|
3472
|
+
return findings;
|
|
3473
|
+
} catch {
|
|
3474
|
+
findings.length = 0;
|
|
3475
|
+
seen.clear();
|
|
3197
3476
|
}
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3477
|
+
}
|
|
3478
|
+
const source = file.lines.join("\n");
|
|
3479
|
+
const code = blankOut(source, true);
|
|
3480
|
+
const noComments = blankOut(source, false);
|
|
3481
|
+
for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
|
|
3482
|
+
const open = tryMatch.index + tryMatch[0].length - 1;
|
|
3483
|
+
const close = closingBrace(code, open);
|
|
3484
|
+
if (close === -1) continue;
|
|
3485
|
+
const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
|
|
3486
|
+
if (!catchMatch) continue;
|
|
3487
|
+
const catchOpen = close + 1 + catchMatch[0].length - 1;
|
|
3488
|
+
const catchClose = closingBrace(code, catchOpen);
|
|
3489
|
+
if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
|
|
3490
|
+
for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
|
|
3491
|
+
const offset = open + 1 + call.index;
|
|
3492
|
+
if (seen.has(offset)) continue;
|
|
3493
|
+
seen.add(offset);
|
|
3494
|
+
const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
|
|
3495
|
+
findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
|
|
3207
3496
|
}
|
|
3208
3497
|
}
|
|
3209
3498
|
return findings;
|
|
@@ -3496,7 +3785,7 @@ var missingWebhookVerificationRule = {
|
|
|
3496
3785
|
};
|
|
3497
3786
|
|
|
3498
3787
|
// src/rules/server-action-auth.ts
|
|
3499
|
-
var USE_SERVER3 =
|
|
3788
|
+
var USE_SERVER3 = /^\s*['"]use server['"]/m;
|
|
3500
3789
|
var AUTH_PATTERNS2 = [
|
|
3501
3790
|
/getServerSession\s*\(/,
|
|
3502
3791
|
/getSession\s*\(/,
|
|
@@ -3513,7 +3802,13 @@ var AUTH_PATTERNS2 = [
|
|
|
3513
3802
|
/currentUser\s*\(/,
|
|
3514
3803
|
/getAuth\s*\(/,
|
|
3515
3804
|
/cookies\(\).*auth/s,
|
|
3516
|
-
|
|
3805
|
+
// Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
|
|
3806
|
+
// verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
|
|
3807
|
+
/\w*session\w*\s*\(/i,
|
|
3808
|
+
// ...reading a session cookie, or using and guarding a session object
|
|
3809
|
+
/cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
|
|
3810
|
+
/\bsession\??\.user(?:Id)?\b/,
|
|
3811
|
+
/!\s*session\b/
|
|
3517
3812
|
];
|
|
3518
3813
|
var PUBLIC_ACTION_NAMES = [
|
|
3519
3814
|
/contact/i,
|
|
@@ -3687,13 +3982,15 @@ var nextPublicSensitiveRule = {
|
|
|
3687
3982
|
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3688
3983
|
category: "security",
|
|
3689
3984
|
severity: "critical",
|
|
3690
|
-
|
|
3985
|
+
// The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
|
|
3986
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
|
|
3691
3987
|
check(file, _project) {
|
|
3692
3988
|
if (isTestFile(file.relativePath)) return [];
|
|
3693
3989
|
const findings = [];
|
|
3990
|
+
const isEnvFile = file.ext === "env";
|
|
3694
3991
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3695
|
-
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3696
3992
|
const line = file.lines[i];
|
|
3993
|
+
if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3697
3994
|
const match = SENSITIVE_PATTERN.exec(line);
|
|
3698
3995
|
if (match) {
|
|
3699
3996
|
findings.push({
|
|
@@ -4156,7 +4453,17 @@ var unsafeFileUploadRule = {
|
|
|
4156
4453
|
|
|
4157
4454
|
// src/rules/supabase-missing-rls.ts
|
|
4158
4455
|
var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
|
|
4159
|
-
var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)
|
|
4456
|
+
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;
|
|
4457
|
+
function isMigrationFile(file) {
|
|
4458
|
+
return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
|
|
4459
|
+
}
|
|
4460
|
+
function sqlProject(relativePath) {
|
|
4461
|
+
const dirs = relativePath.split("/").slice(0, -1);
|
|
4462
|
+
const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
|
|
4463
|
+
const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
|
|
4464
|
+
const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
|
|
4465
|
+
return dirs.slice(0, end).join("/");
|
|
4466
|
+
}
|
|
4160
4467
|
var supabaseMissingRlsRule = {
|
|
4161
4468
|
id: "supabase-missing-rls",
|
|
4162
4469
|
name: "Missing Row-Level Security",
|
|
@@ -4164,42 +4471,36 @@ var supabaseMissingRlsRule = {
|
|
|
4164
4471
|
category: "security",
|
|
4165
4472
|
severity: "critical",
|
|
4166
4473
|
fileExtensions: ["sql"],
|
|
4167
|
-
check(
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4474
|
+
check() {
|
|
4475
|
+
return [];
|
|
4476
|
+
},
|
|
4477
|
+
checkProject(files, _project) {
|
|
4171
4478
|
const findings = [];
|
|
4172
|
-
const tables = [];
|
|
4173
|
-
CREATE_TABLE.lastIndex = 0;
|
|
4174
|
-
let match;
|
|
4175
|
-
while ((match = CREATE_TABLE.exec(content)) !== null) {
|
|
4176
|
-
const name = match[1];
|
|
4177
|
-
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4178
|
-
const beforeMatch = content.slice(0, match.index);
|
|
4179
|
-
const line = beforeMatch.split("\n").length;
|
|
4180
|
-
tables.push({ name, line });
|
|
4181
|
-
}
|
|
4182
|
-
if (tables.length === 0) return [];
|
|
4183
4479
|
const rlsTables = /* @__PURE__ */ new Set();
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
if (filePath === file.relativePath) continue;
|
|
4480
|
+
for (const file of files) {
|
|
4481
|
+
if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
|
|
4482
|
+
const project = sqlProject(file.relativePath);
|
|
4483
|
+
for (const match of file.content.matchAll(ENABLE_RLS)) {
|
|
4484
|
+
rlsTables.add(`${project}::${match[1].toLowerCase()}`);
|
|
4485
|
+
}
|
|
4191
4486
|
}
|
|
4192
|
-
for (const
|
|
4193
|
-
if (!
|
|
4487
|
+
for (const file of files) {
|
|
4488
|
+
if (!isMigrationFile(file)) continue;
|
|
4489
|
+
const content = file.content;
|
|
4490
|
+
const project = sqlProject(file.relativePath);
|
|
4491
|
+
for (const match of content.matchAll(CREATE_TABLE)) {
|
|
4492
|
+
const name = match[1];
|
|
4493
|
+
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4494
|
+
if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
|
|
4194
4495
|
findings.push({
|
|
4195
4496
|
ruleId: "supabase-missing-rls",
|
|
4196
4497
|
file: file.relativePath,
|
|
4197
|
-
line:
|
|
4498
|
+
line: content.slice(0, match.index).split("\n").length,
|
|
4198
4499
|
column: 1,
|
|
4199
|
-
message: `Table "${
|
|
4500
|
+
message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
4200
4501
|
severity: "critical",
|
|
4201
4502
|
category: "security",
|
|
4202
|
-
fix: `Add: ALTER TABLE ${
|
|
4503
|
+
fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
4203
4504
|
});
|
|
4204
4505
|
}
|
|
4205
4506
|
}
|
|
@@ -4530,10 +4831,15 @@ async function scan(options) {
|
|
|
4530
4831
|
}
|
|
4531
4832
|
}
|
|
4532
4833
|
}
|
|
4834
|
+
const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
|
|
4533
4835
|
for (const rule of rules) {
|
|
4534
4836
|
if (rule.checkProject) {
|
|
4535
|
-
const
|
|
4536
|
-
|
|
4837
|
+
for (const finding of rule.checkProject(allFiles, project)) {
|
|
4838
|
+
const file = filesByPath.get(finding.file);
|
|
4839
|
+
if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
|
|
4840
|
+
findings.push(finding);
|
|
4841
|
+
}
|
|
4842
|
+
}
|
|
4537
4843
|
}
|
|
4538
4844
|
}
|
|
4539
4845
|
const { overallScore, categoryScores } = calculateScores(findings);
|
|
@@ -4891,18 +5197,61 @@ async function runWebScan(targetUrl) {
|
|
|
4891
5197
|
}
|
|
4892
5198
|
|
|
4893
5199
|
// src/mcp.ts
|
|
5200
|
+
var SEVERITIES = ["critical", "warning", "info"];
|
|
5201
|
+
var CATEGORIES2 = ["security", "reliability", "performance", "ai-quality"];
|
|
5202
|
+
var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
5203
|
+
var DEFAULT_MAX_FINDINGS = 30;
|
|
5204
|
+
function bySeverity(a, b) {
|
|
5205
|
+
return SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || (a.file < b.file ? -1 : a.file > b.file ? 1 : 0) || a.line - b.line || a.column - b.column;
|
|
5206
|
+
}
|
|
4894
5207
|
var server = new McpServer({
|
|
4895
5208
|
name: "prodlint",
|
|
4896
5209
|
version: getVersion()
|
|
4897
5210
|
});
|
|
4898
|
-
server.
|
|
5211
|
+
server.registerTool(
|
|
4899
5212
|
"scan",
|
|
4900
|
-
"Check a vibe-coded project's production readiness. Returns a 0-100 score with findings across security, reliability, performance, and AI quality categories.",
|
|
4901
5213
|
{
|
|
4902
|
-
|
|
4903
|
-
|
|
5214
|
+
title: "Scan a project for production readiness",
|
|
5215
|
+
description: "Check a vibe-coded JS/TS project's production readiness. Returns a 0-100 score and findings across security, reliability, performance, and AI quality, most severe first. Every critical finding is always included; use minSeverity, categories, and maxFindings to narrow or widen the rest.",
|
|
5216
|
+
inputSchema: {
|
|
5217
|
+
path: z.string().describe("Absolute path to the project directory to scan; it must be inside the directory the server runs in"),
|
|
5218
|
+
ignore: z.array(z.string()).optional().describe("Glob patterns to ignore"),
|
|
5219
|
+
minSeverity: z.enum(SEVERITIES).optional().describe("Only list findings at this severity or higher (default: info, i.e. all)"),
|
|
5220
|
+
categories: z.array(z.enum(CATEGORIES2)).optional().describe("Only list findings in these categories (default: all)"),
|
|
5221
|
+
maxFindings: z.number().int().min(1).max(500).optional().describe(`Maximum number of findings to list (default: ${DEFAULT_MAX_FINDINGS}). Critical findings are always listed, even past this limit.`)
|
|
5222
|
+
},
|
|
5223
|
+
outputSchema: {
|
|
5224
|
+
version: z.string().describe("prodlint version that ran the scan"),
|
|
5225
|
+
path: z.string().describe("Directory that was scanned"),
|
|
5226
|
+
score: z.number().describe("Overall production-readiness score, 0-100"),
|
|
5227
|
+
filesScanned: z.number(),
|
|
5228
|
+
durationMs: z.number(),
|
|
5229
|
+
categoryScores: z.array(z.object({
|
|
5230
|
+
category: z.enum(CATEGORIES2),
|
|
5231
|
+
score: z.number(),
|
|
5232
|
+
findingCount: z.number()
|
|
5233
|
+
})),
|
|
5234
|
+
summary: z.object({
|
|
5235
|
+
critical: z.number(),
|
|
5236
|
+
warning: z.number(),
|
|
5237
|
+
info: z.number()
|
|
5238
|
+
}).describe("Counts of all findings, before minSeverity and categories are applied"),
|
|
5239
|
+
matched: z.number().describe("Findings that pass the minSeverity and categories filters"),
|
|
5240
|
+
omitted: z.number().describe("Matching findings left out because of maxFindings"),
|
|
5241
|
+
findings: z.array(z.object({
|
|
5242
|
+
ruleId: z.string(),
|
|
5243
|
+
severity: z.enum(SEVERITIES),
|
|
5244
|
+
category: z.enum(CATEGORIES2),
|
|
5245
|
+
file: z.string(),
|
|
5246
|
+
line: z.number(),
|
|
5247
|
+
column: z.number(),
|
|
5248
|
+
message: z.string(),
|
|
5249
|
+
fix: z.string().optional()
|
|
5250
|
+
})).describe("Matching findings, most severe first")
|
|
5251
|
+
},
|
|
5252
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
4904
5253
|
},
|
|
4905
|
-
async ({ path, ignore }) => {
|
|
5254
|
+
async ({ path, ignore, minSeverity = "info", categories, maxFindings = DEFAULT_MAX_FINDINGS }) => {
|
|
4906
5255
|
const resolved = resolve4(path);
|
|
4907
5256
|
const cwd = process.cwd();
|
|
4908
5257
|
const normalizedResolved = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
@@ -4928,6 +5277,11 @@ server.tool(
|
|
|
4928
5277
|
};
|
|
4929
5278
|
}
|
|
4930
5279
|
const result = await scan({ path: resolved, ignore });
|
|
5280
|
+
const minRank = SEVERITY_RANK[minSeverity];
|
|
5281
|
+
const matching = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank && (!categories?.length || categories.includes(f.category))).sort(bySeverity);
|
|
5282
|
+
const criticalCount = matching.filter((f) => f.severity === "critical").length;
|
|
5283
|
+
const listed = matching.slice(0, Math.max(maxFindings, criticalCount));
|
|
5284
|
+
const omitted = matching.length - listed.length;
|
|
4931
5285
|
const summary = [
|
|
4932
5286
|
`## Production Readiness: ${result.overallScore}/100`,
|
|
4933
5287
|
"",
|
|
@@ -4940,26 +5294,80 @@ server.tool(
|
|
|
4940
5294
|
"",
|
|
4941
5295
|
`### Summary: ${result.summary.critical} critical, ${result.summary.warning} warnings, ${result.summary.info} info`
|
|
4942
5296
|
];
|
|
4943
|
-
if (
|
|
4944
|
-
|
|
4945
|
-
|
|
5297
|
+
if (listed.length > 0) {
|
|
5298
|
+
const scope = [
|
|
5299
|
+
minSeverity !== "info" ? `${minSeverity} and above` : "",
|
|
5300
|
+
categories?.length ? categories.join(", ") : ""
|
|
5301
|
+
].filter(Boolean).join("; ");
|
|
5302
|
+
summary.push("", `### Findings${scope ? ` (${scope})` : ""}, most severe first`);
|
|
5303
|
+
for (const f of listed) {
|
|
4946
5304
|
const line = `- **[${f.severity}]** \`${f.ruleId}\` ${f.file}:${f.line} \u2014 ${f.message}`;
|
|
4947
5305
|
summary.push(f.fix ? `${line}
|
|
4948
5306
|
- _Fix: ${f.fix}_` : line);
|
|
4949
5307
|
}
|
|
4950
|
-
if (
|
|
4951
|
-
summary.push(`- ...and ${
|
|
5308
|
+
if (omitted > 0) {
|
|
5309
|
+
summary.push(`- ...and ${omitted} more. Raise maxFindings, or narrow with minSeverity or categories.`);
|
|
4952
5310
|
}
|
|
5311
|
+
} else if (result.findings.length > 0) {
|
|
5312
|
+
summary.push("", "No findings match the minSeverity and categories filters.");
|
|
4953
5313
|
}
|
|
4954
5314
|
return {
|
|
4955
|
-
content: [{ type: "text", text: summary.join("\n") }]
|
|
5315
|
+
content: [{ type: "text", text: summary.join("\n") }],
|
|
5316
|
+
structuredContent: {
|
|
5317
|
+
version: result.version,
|
|
5318
|
+
path: result.scannedPath,
|
|
5319
|
+
score: result.overallScore,
|
|
5320
|
+
filesScanned: result.filesScanned,
|
|
5321
|
+
durationMs: result.scanDurationMs,
|
|
5322
|
+
categoryScores: result.categoryScores.map((c) => ({ category: c.category, score: c.score, findingCount: c.findingCount })),
|
|
5323
|
+
summary: result.summary,
|
|
5324
|
+
matched: matching.length,
|
|
5325
|
+
omitted,
|
|
5326
|
+
findings: listed.map((f) => ({
|
|
5327
|
+
ruleId: f.ruleId,
|
|
5328
|
+
severity: f.severity,
|
|
5329
|
+
category: f.category,
|
|
5330
|
+
file: f.file,
|
|
5331
|
+
line: f.line,
|
|
5332
|
+
column: f.column,
|
|
5333
|
+
message: f.message,
|
|
5334
|
+
fix: f.fix
|
|
5335
|
+
}))
|
|
5336
|
+
}
|
|
4956
5337
|
};
|
|
4957
5338
|
}
|
|
4958
5339
|
);
|
|
4959
|
-
server.
|
|
5340
|
+
server.registerTool(
|
|
4960
5341
|
"scan-web",
|
|
4961
|
-
|
|
4962
|
-
|
|
5342
|
+
{
|
|
5343
|
+
title: "Score a website's AI-agent readiness",
|
|
5344
|
+
description: "Check a deployed website's AI agent-readiness. Returns a 0-100 score across 14 checks including robots.txt AI directives, llms.txt, AgentCard, WebMCP, and more.",
|
|
5345
|
+
inputSchema: {
|
|
5346
|
+
url: z.string().describe("URL of the website to scan (e.g. https://example.com)")
|
|
5347
|
+
},
|
|
5348
|
+
outputSchema: {
|
|
5349
|
+
url: z.string(),
|
|
5350
|
+
domain: z.string(),
|
|
5351
|
+
score: z.number().describe("Agent-readiness score, 0-100"),
|
|
5352
|
+
grade: z.string(),
|
|
5353
|
+
summary: z.object({
|
|
5354
|
+
passed: z.number(),
|
|
5355
|
+
failed: z.number(),
|
|
5356
|
+
warnings: z.number(),
|
|
5357
|
+
totalChecks: z.number()
|
|
5358
|
+
}),
|
|
5359
|
+
checks: z.array(z.object({
|
|
5360
|
+
id: z.string(),
|
|
5361
|
+
name: z.string(),
|
|
5362
|
+
status: z.enum(["pass", "fail", "warn", "info"]),
|
|
5363
|
+
points: z.number(),
|
|
5364
|
+
maxPoints: z.number(),
|
|
5365
|
+
details: z.string().optional()
|
|
5366
|
+
}))
|
|
5367
|
+
},
|
|
5368
|
+
// Read-only, but it fetches the given site over the network
|
|
5369
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
5370
|
+
},
|
|
4963
5371
|
async ({ url }) => {
|
|
4964
5372
|
let normalizedUrl;
|
|
4965
5373
|
try {
|
|
@@ -4993,7 +5401,22 @@ server.tool(
|
|
|
4993
5401
|
`### Summary: ${result.summary.passed} passed \xB7 ${result.summary.failed} failed \xB7 ${result.summary.warnings} warnings`
|
|
4994
5402
|
];
|
|
4995
5403
|
return {
|
|
4996
|
-
content: [{ type: "text", text: summary.join("\n") }]
|
|
5404
|
+
content: [{ type: "text", text: summary.join("\n") }],
|
|
5405
|
+
structuredContent: {
|
|
5406
|
+
url: result.url,
|
|
5407
|
+
domain: result.domain,
|
|
5408
|
+
score: result.overallScore,
|
|
5409
|
+
grade: result.grade,
|
|
5410
|
+
summary: result.summary,
|
|
5411
|
+
checks: result.checks.map((c) => ({
|
|
5412
|
+
id: c.id,
|
|
5413
|
+
name: c.name,
|
|
5414
|
+
status: c.status,
|
|
5415
|
+
points: c.points,
|
|
5416
|
+
maxPoints: c.maxPoints,
|
|
5417
|
+
details: c.details
|
|
5418
|
+
}))
|
|
5419
|
+
}
|
|
4997
5420
|
};
|
|
4998
5421
|
}
|
|
4999
5422
|
);
|