prodlint 0.10.3 → 0.10.5
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 +21 -10
- package/action.yml +17 -29
- package/dist/cli.js +1069 -261
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1069 -261
- package/dist/mcp.js +1203 -274
- 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();
|
|
@@ -77,7 +90,8 @@ function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
|
77
90
|
return false;
|
|
78
91
|
}
|
|
79
92
|
function isTestFile(relativePath) {
|
|
80
|
-
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) ||
|
|
93
|
+
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || // Jest's __tests__ and __mocks__, jscodeshift's __testfixtures__
|
|
94
|
+
/(?:^|\/)__(?:tests|testfixtures|fixtures|mocks)__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
|
|
81
95
|
}
|
|
82
96
|
function isScriptFile(relativePath) {
|
|
83
97
|
if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
|
|
@@ -391,6 +405,263 @@ var DEPENDENCY_TO_FRAMEWORK = {
|
|
|
391
405
|
var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
|
|
392
406
|
var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
|
|
393
407
|
|
|
408
|
+
// src/utils/middleware-matcher.ts
|
|
409
|
+
function matcherExcludesApi(content, fileName) {
|
|
410
|
+
const sources = matcherSources(content, fileName);
|
|
411
|
+
if (!sources || sources.length === 0) return false;
|
|
412
|
+
return sources.every((source) => {
|
|
413
|
+
const matcher = compile(source);
|
|
414
|
+
return matcher !== null && !matcher.patterns.some(namesApi) && !apiPaths(matcher.literals).some((path) => matcher.regexp.test(path));
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function matcherExcludesPages(content, fileName) {
|
|
418
|
+
const sources = matcherSources(content, fileName);
|
|
419
|
+
if (!sources || sources.length === 0) return false;
|
|
420
|
+
return sources.every((source) => {
|
|
421
|
+
const matcher = compile(source);
|
|
422
|
+
return matcher !== null && underApi(matcher.tokens);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function underApi(tokens) {
|
|
426
|
+
const [head, ...rest] = tokens;
|
|
427
|
+
if (typeof head !== "string") return false;
|
|
428
|
+
if (head.startsWith("/api/")) return true;
|
|
429
|
+
if (head !== "/api") return false;
|
|
430
|
+
for (const token of rest) {
|
|
431
|
+
const start = typeof token === "string" ? token : token.pattern ? prefixOf(token) : token.prefix + token.suffix;
|
|
432
|
+
if (!start.startsWith("/")) return false;
|
|
433
|
+
if (typeof token === "string" || token.modifier !== "?" && token.modifier !== "*") return true;
|
|
434
|
+
}
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
function apiPaths(literals) {
|
|
438
|
+
const paths = ["/api/x", "/api/x/y"];
|
|
439
|
+
for (const literal2 of literals) {
|
|
440
|
+
for (const head of ["", "/api", "/api/x"]) {
|
|
441
|
+
for (const tail of ["", "/x"]) paths.push(head + literal2 + tail);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return paths.filter((path) => path.startsWith("/api/"));
|
|
445
|
+
}
|
|
446
|
+
function matcherSources(content, fileName) {
|
|
447
|
+
const ast = parseFile(content, fileName);
|
|
448
|
+
if (!ast) return null;
|
|
449
|
+
for (const st of ast.program.body) {
|
|
450
|
+
if (st.type !== "ExportNamedDeclaration" || st.declaration?.type !== "VariableDeclaration") continue;
|
|
451
|
+
if (st.declaration.kind !== "const") continue;
|
|
452
|
+
for (const d of st.declaration.declarations) {
|
|
453
|
+
if (d.id.type !== "Identifier" || d.id.name !== "config" || !d.init) continue;
|
|
454
|
+
const config = unwrap(d.init);
|
|
455
|
+
if (config.type !== "ObjectExpression") return null;
|
|
456
|
+
if (config.properties.some((p) => p.type !== "ObjectProperty")) return null;
|
|
457
|
+
const matcher = property(config, "matcher");
|
|
458
|
+
if (!matcher) return void 0;
|
|
459
|
+
const values = matcher.type === "ArrayExpression" ? matcher.elements : [matcher];
|
|
460
|
+
const sources = [];
|
|
461
|
+
for (const value of values) {
|
|
462
|
+
const element = value ? unwrap(value) : null;
|
|
463
|
+
const source = element?.type === "ObjectExpression" ? property(element, "source") : element;
|
|
464
|
+
const text = source ? literal(source) : null;
|
|
465
|
+
if (text === null) return null;
|
|
466
|
+
sources.push(text);
|
|
467
|
+
}
|
|
468
|
+
return sources;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return void 0;
|
|
472
|
+
}
|
|
473
|
+
function unwrap(node) {
|
|
474
|
+
while (node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression") node = node.expression;
|
|
475
|
+
return node;
|
|
476
|
+
}
|
|
477
|
+
function property(object, name) {
|
|
478
|
+
if (object.type !== "ObjectExpression") return void 0;
|
|
479
|
+
let value;
|
|
480
|
+
for (const p of object.properties) {
|
|
481
|
+
if (p.type !== "ObjectProperty" || p.computed) continue;
|
|
482
|
+
const key = p.key.type === "Identifier" ? p.key.name : p.key.type === "StringLiteral" ? p.key.value : null;
|
|
483
|
+
if (key === name) value = unwrap(p.value);
|
|
484
|
+
}
|
|
485
|
+
return value;
|
|
486
|
+
}
|
|
487
|
+
function literal(node) {
|
|
488
|
+
if (node.type === "StringLiteral") return node.value;
|
|
489
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked ?? null;
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
function namesApi(pattern) {
|
|
493
|
+
let kept = "";
|
|
494
|
+
const negative = [];
|
|
495
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
496
|
+
const c = pattern[i];
|
|
497
|
+
const skipping = negative.includes(true);
|
|
498
|
+
if (c === "\\") {
|
|
499
|
+
if (!skipping) kept += pattern.slice(i, i + 2);
|
|
500
|
+
i++;
|
|
501
|
+
} else if (c === "(") {
|
|
502
|
+
negative.push(pattern.startsWith("(?!", i) || pattern.startsWith("(?<!", i));
|
|
503
|
+
if (!skipping) kept += c;
|
|
504
|
+
} else if (c === ")") {
|
|
505
|
+
negative.pop();
|
|
506
|
+
if (!negative.includes(true)) kept += c;
|
|
507
|
+
} else if (!skipping) {
|
|
508
|
+
kept += c;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return /\bapi\b/i.test(kept);
|
|
512
|
+
}
|
|
513
|
+
var DELIMITER = "/#?";
|
|
514
|
+
var PREFIXES = "./";
|
|
515
|
+
var escape = (s) => s.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
|
516
|
+
var prefixOf = (token) => (token.modifier === "*" || token.modifier === "+") && !token.prefix && !token.suffix ? "/" : token.prefix;
|
|
517
|
+
function lex(source) {
|
|
518
|
+
const tokens = [];
|
|
519
|
+
let i = 0;
|
|
520
|
+
while (i < source.length) {
|
|
521
|
+
const c = source[i];
|
|
522
|
+
if (c === "*" || c === "+" || c === "?") {
|
|
523
|
+
tokens.push({ type: "MODIFIER", value: source[i++] });
|
|
524
|
+
} else if (c === "\\") {
|
|
525
|
+
tokens.push({ type: "ESCAPED_CHAR", value: source[i + 1] });
|
|
526
|
+
i += 2;
|
|
527
|
+
} else if (c === "{" || c === "}") {
|
|
528
|
+
tokens.push({ type: c === "{" ? "OPEN" : "CLOSE", value: source[i++] });
|
|
529
|
+
} else if (c === ":") {
|
|
530
|
+
const name = /^\w+/.exec(source.slice(i + 1))?.[0];
|
|
531
|
+
if (!name) throw new TypeError(`Missing parameter name at ${i}`);
|
|
532
|
+
tokens.push({ type: "NAME", value: name });
|
|
533
|
+
i += 1 + name.length;
|
|
534
|
+
} else if (c === "(") {
|
|
535
|
+
let depth = 1;
|
|
536
|
+
let pattern = "";
|
|
537
|
+
let j = i + 1;
|
|
538
|
+
if (source[j] === "?") throw new TypeError(`Pattern cannot start with "?" at ${j}`);
|
|
539
|
+
while (j < source.length) {
|
|
540
|
+
if (source[j] === "\\") {
|
|
541
|
+
pattern += source[j++] + source[j++];
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
if (source[j] === ")") {
|
|
545
|
+
if (--depth === 0) {
|
|
546
|
+
j++;
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
} else if (source[j] === "(") {
|
|
550
|
+
depth++;
|
|
551
|
+
if (source[j + 1] !== "?") throw new TypeError(`Capturing groups are not allowed at ${j}`);
|
|
552
|
+
}
|
|
553
|
+
pattern += source[j++];
|
|
554
|
+
}
|
|
555
|
+
if (depth) throw new TypeError(`Unbalanced pattern at ${i}`);
|
|
556
|
+
if (!pattern) throw new TypeError(`Missing pattern at ${i}`);
|
|
557
|
+
tokens.push({ type: "PATTERN", value: pattern });
|
|
558
|
+
i = j;
|
|
559
|
+
} else {
|
|
560
|
+
tokens.push({ type: "CHAR", value: source[i++] });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
tokens.push({ type: "END", value: "" });
|
|
564
|
+
return tokens;
|
|
565
|
+
}
|
|
566
|
+
function parse2(source) {
|
|
567
|
+
const tokens = lex(source);
|
|
568
|
+
const result = [];
|
|
569
|
+
let i = 0;
|
|
570
|
+
let path = "";
|
|
571
|
+
const tryConsume = (type) => i < tokens.length && tokens[i].type === type ? tokens[i++].value : void 0;
|
|
572
|
+
const mustConsume = (type) => {
|
|
573
|
+
const value = tryConsume(type);
|
|
574
|
+
if (value === void 0) throw new TypeError(`Unexpected ${tokens[i].type}, expected ${type}`);
|
|
575
|
+
return value;
|
|
576
|
+
};
|
|
577
|
+
const consumeText = () => {
|
|
578
|
+
let text = "";
|
|
579
|
+
let value;
|
|
580
|
+
while (value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR")) text += value;
|
|
581
|
+
return text;
|
|
582
|
+
};
|
|
583
|
+
const safePattern = (prefix) => {
|
|
584
|
+
const prev = result[result.length - 1];
|
|
585
|
+
const prevText = prefix || (typeof prev === "string" ? prev : "");
|
|
586
|
+
if (prev && !prevText) throw new TypeError("Must have text between two parameters");
|
|
587
|
+
if (!prevText || [...DELIMITER].some((c) => prevText.includes(c))) return `[^${escape(DELIMITER)}]+?`;
|
|
588
|
+
return `(?:(?!${escape(prevText)})[^${escape(DELIMITER)}])+?`;
|
|
589
|
+
};
|
|
590
|
+
while (i < tokens.length) {
|
|
591
|
+
const char = tryConsume("CHAR");
|
|
592
|
+
const name = tryConsume("NAME");
|
|
593
|
+
const pattern = tryConsume("PATTERN");
|
|
594
|
+
if (name || pattern) {
|
|
595
|
+
let prefix = char || "";
|
|
596
|
+
if (!PREFIXES.includes(prefix)) {
|
|
597
|
+
path += prefix;
|
|
598
|
+
prefix = "";
|
|
599
|
+
}
|
|
600
|
+
if (path) {
|
|
601
|
+
result.push(path);
|
|
602
|
+
path = "";
|
|
603
|
+
}
|
|
604
|
+
result.push({ prefix, suffix: "", pattern: pattern || safePattern(prefix), modifier: tryConsume("MODIFIER") || "" });
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const value = char || tryConsume("ESCAPED_CHAR");
|
|
608
|
+
if (value) {
|
|
609
|
+
path += value;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (path) {
|
|
613
|
+
result.push(path);
|
|
614
|
+
path = "";
|
|
615
|
+
}
|
|
616
|
+
if (tryConsume("OPEN")) {
|
|
617
|
+
const prefix = consumeText();
|
|
618
|
+
const name2 = tryConsume("NAME") || "";
|
|
619
|
+
const pattern2 = tryConsume("PATTERN") || "";
|
|
620
|
+
const suffix = consumeText();
|
|
621
|
+
mustConsume("CLOSE");
|
|
622
|
+
result.push({ prefix, suffix, pattern: name2 && !pattern2 ? safePattern(prefix) : pattern2, modifier: tryConsume("MODIFIER") || "" });
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
mustConsume("END");
|
|
626
|
+
}
|
|
627
|
+
return result;
|
|
628
|
+
}
|
|
629
|
+
function compile(source) {
|
|
630
|
+
if (!source.startsWith("/")) return null;
|
|
631
|
+
try {
|
|
632
|
+
const tokens = parse2(source);
|
|
633
|
+
let route = "^";
|
|
634
|
+
for (const token of tokens) {
|
|
635
|
+
if (typeof token === "string") {
|
|
636
|
+
route += escape(token);
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (!token.pattern) {
|
|
640
|
+
route += `(?:${escape(token.prefix)}${escape(token.suffix)})${token.modifier}`;
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const repeat = token.modifier === "*" || token.modifier === "+";
|
|
644
|
+
const prefix = escape(prefixOf(token));
|
|
645
|
+
const suffix = escape(token.suffix);
|
|
646
|
+
if (!prefix && !suffix) {
|
|
647
|
+
route += `(${token.pattern})${token.modifier}`;
|
|
648
|
+
} else if (repeat) {
|
|
649
|
+
route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${token.modifier === "*" ? "?" : ""}`;
|
|
650
|
+
} else {
|
|
651
|
+
route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
regexp: new RegExp(`${route}[${escape(DELIMITER)}]?$`),
|
|
656
|
+
tokens,
|
|
657
|
+
literals: tokens.filter((token) => typeof token === "string"),
|
|
658
|
+
patterns: tokens.flatMap((token) => typeof token === "string" ? [] : [token.pattern])
|
|
659
|
+
};
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
394
665
|
// src/utils/file-walker.ts
|
|
395
666
|
var DEFAULT_IGNORES = [
|
|
396
667
|
"**/node_modules/**",
|
|
@@ -420,6 +691,37 @@ var SCAN_EXTENSIONS = [
|
|
|
420
691
|
];
|
|
421
692
|
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
422
693
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
694
|
+
var MIDDLEWARE_FILES = [
|
|
695
|
+
"middleware.ts",
|
|
696
|
+
"middleware.js",
|
|
697
|
+
"src/middleware.ts",
|
|
698
|
+
"src/middleware.js",
|
|
699
|
+
"proxy.ts",
|
|
700
|
+
"proxy.js",
|
|
701
|
+
"src/proxy.ts",
|
|
702
|
+
"src/proxy.js"
|
|
703
|
+
];
|
|
704
|
+
var MIDDLEWARE_AUTH_PATTERNS = [
|
|
705
|
+
/getSession/i,
|
|
706
|
+
/getUser/i,
|
|
707
|
+
/auth\(\)/,
|
|
708
|
+
/withAuth/i,
|
|
709
|
+
/clerkMiddleware/i,
|
|
710
|
+
/authMiddleware/i,
|
|
711
|
+
/NextAuth/i,
|
|
712
|
+
/supabase.*auth/i,
|
|
713
|
+
/createMiddlewareClient/i,
|
|
714
|
+
/getToken/i,
|
|
715
|
+
/verifyToken/i,
|
|
716
|
+
/jwt/i,
|
|
717
|
+
/updateSession/i,
|
|
718
|
+
// Auth.js: export { auth as proxy } from '@/auth', export default auth((req) => …)
|
|
719
|
+
/\bauth\s+as\s+(?:proxy|middleware|default)\b/,
|
|
720
|
+
/\bauth\(/,
|
|
721
|
+
// NextAuth v4: export { default } from 'next-auth/middleware'
|
|
722
|
+
/['"]next-auth\/middleware['"]/
|
|
723
|
+
];
|
|
724
|
+
var MIDDLEWARE_RATE_LIMIT_PATTERNS = [/rateLimit/i, /throttle/i];
|
|
423
725
|
async function walkFiles(root, extraIgnores = []) {
|
|
424
726
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
425
727
|
patterns.push("**/.env", "**/.env.*");
|
|
@@ -443,7 +745,8 @@ async function readFileContext(root, relativePath) {
|
|
|
443
745
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
444
746
|
const content = await readFile(absolutePath, "utf-8");
|
|
445
747
|
const lines = content.split(/\r?\n|\r/);
|
|
446
|
-
const
|
|
748
|
+
const rawExt = extname(relativePath).slice(1);
|
|
749
|
+
const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
|
|
447
750
|
let ast = void 0;
|
|
448
751
|
if (AST_EXTENSIONS.has(ext)) {
|
|
449
752
|
try {
|
|
@@ -557,6 +860,8 @@ async function buildProjectContext(root, files) {
|
|
|
557
860
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
558
861
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
559
862
|
let hasAuthMiddleware = false;
|
|
863
|
+
let authMiddlewareExcludesApi = false;
|
|
864
|
+
let authMiddlewareExcludesPages = false;
|
|
560
865
|
let hasRateLimiting = false;
|
|
561
866
|
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
562
867
|
let gitignoreContent = null;
|
|
@@ -608,45 +913,19 @@ async function buildProjectContext(root, files) {
|
|
|
608
913
|
}
|
|
609
914
|
} catch {
|
|
610
915
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
/getUser/i,
|
|
618
|
-
/auth\(\)/,
|
|
619
|
-
/withAuth/i,
|
|
620
|
-
/clerkMiddleware/i,
|
|
621
|
-
/authMiddleware/i,
|
|
622
|
-
/NextAuth/i,
|
|
623
|
-
/supabase.*auth/i,
|
|
624
|
-
/createMiddlewareClient/i,
|
|
625
|
-
/getToken/i,
|
|
626
|
-
/verifyToken/i,
|
|
627
|
-
/jwt/i,
|
|
628
|
-
/updateSession/i
|
|
629
|
-
];
|
|
630
|
-
if (authPatterns.some((p) => p.test(content))) {
|
|
631
|
-
hasAuthMiddleware = true;
|
|
632
|
-
break;
|
|
633
|
-
}
|
|
634
|
-
} catch {
|
|
635
|
-
}
|
|
916
|
+
for (const name of MIDDLEWARE_FILES) {
|
|
917
|
+
let content;
|
|
918
|
+
try {
|
|
919
|
+
content = await readFile(resolve(root, name), "utf-8");
|
|
920
|
+
} catch {
|
|
921
|
+
continue;
|
|
636
922
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
try {
|
|
642
|
-
const content = await readFile(resolve(root, name), "utf-8");
|
|
643
|
-
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
644
|
-
hasRateLimiting = true;
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
647
|
-
} catch {
|
|
648
|
-
}
|
|
923
|
+
if (MIDDLEWARE_AUTH_PATTERNS.some((p) => p.test(content))) {
|
|
924
|
+
authMiddlewareExcludesApi = (!hasAuthMiddleware || authMiddlewareExcludesApi) && matcherExcludesApi(content, name);
|
|
925
|
+
authMiddlewareExcludesPages = (!hasAuthMiddleware || authMiddlewareExcludesPages) && matcherExcludesPages(content, name);
|
|
926
|
+
hasAuthMiddleware = true;
|
|
649
927
|
}
|
|
928
|
+
if (MIDDLEWARE_RATE_LIMIT_PATTERNS.some((p) => p.test(content))) hasRateLimiting = true;
|
|
650
929
|
}
|
|
651
930
|
try {
|
|
652
931
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
@@ -659,6 +938,8 @@ async function buildProjectContext(root, files) {
|
|
|
659
938
|
declaredDependencies,
|
|
660
939
|
tsconfigPaths,
|
|
661
940
|
hasAuthMiddleware,
|
|
941
|
+
authMiddlewareExcludesApi,
|
|
942
|
+
authMiddlewareExcludesPages,
|
|
662
943
|
hasRateLimiting,
|
|
663
944
|
detectedFrameworks,
|
|
664
945
|
gitignoreContent,
|
|
@@ -712,12 +993,12 @@ function calculateScores(findings) {
|
|
|
712
993
|
}
|
|
713
994
|
let totalDeduction = 0;
|
|
714
995
|
for (const [, ruleFindings] of byRule) {
|
|
715
|
-
const
|
|
996
|
+
const bySeverity2 = { critical: 0, warning: 0, info: 0 };
|
|
716
997
|
for (const f of ruleFindings) {
|
|
717
|
-
|
|
998
|
+
bySeverity2[f.severity]++;
|
|
718
999
|
}
|
|
719
1000
|
for (const sev of ["critical", "warning", "info"]) {
|
|
720
|
-
const count = Math.min(
|
|
1001
|
+
const count = Math.min(bySeverity2[sev], PER_RULE_CAP[sev]);
|
|
721
1002
|
totalDeduction += count * DEDUCTIONS[sev];
|
|
722
1003
|
}
|
|
723
1004
|
}
|
|
@@ -749,12 +1030,21 @@ function summarizeFindings(findings) {
|
|
|
749
1030
|
}
|
|
750
1031
|
|
|
751
1032
|
// src/rules/secrets.ts
|
|
1033
|
+
function nameJwt(token) {
|
|
1034
|
+
try {
|
|
1035
|
+
const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
|
|
1036
|
+
if (claims?.role === "anon") return null;
|
|
1037
|
+
if (claims?.role === "service_role") return "Supabase service role key";
|
|
1038
|
+
} catch {
|
|
1039
|
+
}
|
|
1040
|
+
return "JWT";
|
|
1041
|
+
}
|
|
752
1042
|
var SECRET_PATTERNS = [
|
|
753
1043
|
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
754
1044
|
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
755
1045
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
756
1046
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
757
|
-
{ name: "
|
|
1047
|
+
{ name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
|
|
758
1048
|
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
759
1049
|
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
760
1050
|
{ name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
|
|
@@ -789,15 +1079,17 @@ var secretsRule = {
|
|
|
789
1079
|
for (let i = 0; i < file.lines.length; i++) {
|
|
790
1080
|
const line = file.lines[i];
|
|
791
1081
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
792
|
-
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
1082
|
+
for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
|
|
793
1083
|
const match = pattern.exec(line);
|
|
794
1084
|
if (match) {
|
|
1085
|
+
const reported = nameMatch ? nameMatch(match[0]) : name;
|
|
1086
|
+
if (!reported) continue;
|
|
795
1087
|
findings.push({
|
|
796
1088
|
ruleId: "secrets",
|
|
797
1089
|
file: file.relativePath,
|
|
798
1090
|
line: i + 1,
|
|
799
1091
|
column: match.index + 1,
|
|
800
|
-
message: `Hardcoded ${
|
|
1092
|
+
message: `Hardcoded ${reported} detected`,
|
|
801
1093
|
severity: "critical",
|
|
802
1094
|
category: "security",
|
|
803
1095
|
fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
|
|
@@ -953,12 +1245,217 @@ var AUTH_PATTERNS = [
|
|
|
953
1245
|
/createRouteHandlerClient/,
|
|
954
1246
|
/createServerComponentClient/,
|
|
955
1247
|
/createMiddlewareClient/,
|
|
956
|
-
|
|
1248
|
+
// Reading the request's Authorization header, not sending one upstream:
|
|
1249
|
+
// headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
|
|
1250
|
+
/\.(?:get|header)\(\s*['"`]authorization['"`]/i,
|
|
1251
|
+
/\.headers\??\.authorization\b(?!\s*=(?!=))/i,
|
|
1252
|
+
/\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
|
|
957
1253
|
/getAuth\s*\(/,
|
|
958
1254
|
/withPageAuth/,
|
|
959
1255
|
/cookies\(\).*auth/s
|
|
960
1256
|
];
|
|
961
|
-
var
|
|
1257
|
+
var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1258
|
+
var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
1259
|
+
var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
|
|
1260
|
+
function servedUnderApi(relativePath) {
|
|
1261
|
+
const pages = /(?:^|\/)pages\/api\/(.+)$/.exec(relativePath);
|
|
1262
|
+
if (pages) return !/^index\.\w+$/.test(pages[1]);
|
|
1263
|
+
const segments = relativePath.split("/");
|
|
1264
|
+
const app = segments.indexOf("app");
|
|
1265
|
+
if (app === -1) return false;
|
|
1266
|
+
const url = segments.slice(app + 1, -1).filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@"));
|
|
1267
|
+
return url[0] === "api" && url.length > 1;
|
|
1268
|
+
}
|
|
1269
|
+
var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
|
|
1270
|
+
var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
|
|
1271
|
+
function words(name) {
|
|
1272
|
+
return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
|
|
1273
|
+
}
|
|
1274
|
+
function saysAuth(name) {
|
|
1275
|
+
const w = words(name);
|
|
1276
|
+
return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
|
|
1277
|
+
}
|
|
1278
|
+
function isAuthHelper(name, source) {
|
|
1279
|
+
if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
|
|
1280
|
+
return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
|
|
1281
|
+
}
|
|
1282
|
+
var AUTHED = { authed: true, local: true };
|
|
1283
|
+
var LOCAL = { authed: false, local: true };
|
|
1284
|
+
var ELSEWHERE = { authed: false, local: false };
|
|
1285
|
+
function handlersFromAst(program) {
|
|
1286
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1287
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1288
|
+
for (const st of program.body) {
|
|
1289
|
+
if (st.type === "ImportDeclaration") {
|
|
1290
|
+
for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
|
|
1294
|
+
if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
|
|
1295
|
+
if (decl?.type === "VariableDeclaration") {
|
|
1296
|
+
for (const d of decl.declarations) {
|
|
1297
|
+
if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const helperOf = (callee) => {
|
|
1302
|
+
if (callee.type === "CallExpression") return helperOf(callee.callee);
|
|
1303
|
+
if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
|
|
1304
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
1305
|
+
let root = callee.object;
|
|
1306
|
+
while (root.type === "MemberExpression") root = root.object;
|
|
1307
|
+
return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
|
|
1308
|
+
}
|
|
1309
|
+
return null;
|
|
1310
|
+
};
|
|
1311
|
+
const classify = (node, seen) => {
|
|
1312
|
+
while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
|
|
1313
|
+
node = node.type === "AwaitExpression" ? node.argument : node.expression;
|
|
1314
|
+
}
|
|
1315
|
+
switch (node.type) {
|
|
1316
|
+
case "FunctionDeclaration":
|
|
1317
|
+
case "FunctionExpression":
|
|
1318
|
+
case "ArrowFunctionExpression":
|
|
1319
|
+
return LOCAL;
|
|
1320
|
+
case "Identifier": {
|
|
1321
|
+
const bound = bindings.get(node.name);
|
|
1322
|
+
if (!bound || seen.has(node.name)) return ELSEWHERE;
|
|
1323
|
+
return classify(bound, new Set(seen).add(node.name));
|
|
1324
|
+
}
|
|
1325
|
+
case "CallExpression": {
|
|
1326
|
+
const helper = helperOf(node.callee);
|
|
1327
|
+
if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
|
|
1328
|
+
let local = false;
|
|
1329
|
+
for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
|
|
1330
|
+
const verdict = classify(arg, seen);
|
|
1331
|
+
if (verdict.authed) return verdict;
|
|
1332
|
+
local ||= verdict.local;
|
|
1333
|
+
}
|
|
1334
|
+
return { authed: false, local };
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return ELSEWHERE;
|
|
1338
|
+
};
|
|
1339
|
+
const handlers = [];
|
|
1340
|
+
const add = (method, at, verdict) => {
|
|
1341
|
+
handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
|
|
1342
|
+
};
|
|
1343
|
+
for (const st of program.body) {
|
|
1344
|
+
if (st.type === "ExportNamedDeclaration") {
|
|
1345
|
+
const decl = st.declaration;
|
|
1346
|
+
if (decl?.type === "FunctionDeclaration") {
|
|
1347
|
+
if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
|
|
1348
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
1349
|
+
for (const d of decl.declarations) {
|
|
1350
|
+
if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
|
|
1351
|
+
add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
|
|
1352
|
+
} else if (d.id.type === "ObjectPattern") {
|
|
1353
|
+
for (const p of d.id.properties) {
|
|
1354
|
+
if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
|
|
1355
|
+
add(p.value.name, d, ELSEWHERE);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
} else if (!decl) {
|
|
1361
|
+
for (const s of st.specifiers) {
|
|
1362
|
+
if (s.type !== "ExportSpecifier") continue;
|
|
1363
|
+
const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
|
|
1364
|
+
if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
} else if (st.type === "ExportDefaultDeclaration") {
|
|
1368
|
+
add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
|
|
1369
|
+
} else if (st.type === "ExportAllDeclaration") {
|
|
1370
|
+
add("*", st, ELSEWHERE);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
return handlers;
|
|
1374
|
+
}
|
|
1375
|
+
var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
|
|
1376
|
+
var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
|
|
1377
|
+
var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1378
|
+
var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
|
|
1379
|
+
var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
|
|
1380
|
+
var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
|
|
1381
|
+
var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
|
|
1382
|
+
var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
|
|
1383
|
+
var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
|
|
1384
|
+
var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
|
|
1385
|
+
var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
|
|
1386
|
+
function handlersFromText(file) {
|
|
1387
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1388
|
+
for (const m of file.content.matchAll(IMPORT)) {
|
|
1389
|
+
for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
|
|
1390
|
+
const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1391
|
+
if (spec) imports.set(spec[2] ?? spec[1], m[2]);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
|
|
1395
|
+
const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
|
|
1396
|
+
const resolve5 = (name, seen) => {
|
|
1397
|
+
const id = name.replace(/\$/g, "\\$");
|
|
1398
|
+
const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
|
|
1399
|
+
const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1400
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1401
|
+
if (!code(i)) continue;
|
|
1402
|
+
if (fn.test(file.lines[i])) return LOCAL;
|
|
1403
|
+
const m = variable.exec(file.lines[i]);
|
|
1404
|
+
if (m) return classify(value(i, m[1]), seen);
|
|
1405
|
+
}
|
|
1406
|
+
return ELSEWHERE;
|
|
1407
|
+
};
|
|
1408
|
+
const classify = (text, seen) => {
|
|
1409
|
+
const t = text.trim().replace(/^await\s+/, "");
|
|
1410
|
+
if (INLINE_FUNCTION.test(t)) return LOCAL;
|
|
1411
|
+
const call = CALL.exec(t);
|
|
1412
|
+
if (call) {
|
|
1413
|
+
if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
|
|
1414
|
+
let args = t.slice(call[0].length);
|
|
1415
|
+
const curried = /^[^()]*\)\s*\(/.exec(args);
|
|
1416
|
+
if (curried) args = args.slice(curried[0].length);
|
|
1417
|
+
return classify(args, seen);
|
|
1418
|
+
}
|
|
1419
|
+
const id = IDENTIFIER.exec(t);
|
|
1420
|
+
if (id && !seen.has(id[1])) return resolve5(id[1], new Set(seen).add(id[1]));
|
|
1421
|
+
return ELSEWHERE;
|
|
1422
|
+
};
|
|
1423
|
+
const handlers = [];
|
|
1424
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1425
|
+
if (!code(i)) continue;
|
|
1426
|
+
const line = file.lines[i];
|
|
1427
|
+
let m;
|
|
1428
|
+
if (m = EXPORT_FUNCTION.exec(line)) {
|
|
1429
|
+
handlers.push({ method: m[1], line: i + 1, ...LOCAL });
|
|
1430
|
+
} else if (m = EXPORT_CONST.exec(line)) {
|
|
1431
|
+
handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
|
|
1432
|
+
} else if (m = EXPORT_DESTRUCTURED.exec(line)) {
|
|
1433
|
+
for (const part of m[1].split(",")) {
|
|
1434
|
+
const name = part.split(":").pop()?.trim() ?? "";
|
|
1435
|
+
if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
|
|
1436
|
+
}
|
|
1437
|
+
} else if (m = EXPORT_DEFAULT.exec(line)) {
|
|
1438
|
+
handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
|
|
1439
|
+
} else if (EXPORT_ALL.test(line)) {
|
|
1440
|
+
handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
|
|
1444
|
+
for (const m of file.content.matchAll(EXPORT_LIST)) {
|
|
1445
|
+
if (!code(lineAt(m.index) - 1)) continue;
|
|
1446
|
+
let offset = m.index + m[0].indexOf("{") + 1;
|
|
1447
|
+
for (const part of m[1].split(",")) {
|
|
1448
|
+
const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1449
|
+
const name = spec ? spec[2] ?? spec[1] : "";
|
|
1450
|
+
if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
|
|
1451
|
+
const line = lineAt(offset + part.length - part.trimStart().length);
|
|
1452
|
+
handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
|
|
1453
|
+
}
|
|
1454
|
+
offset += part.length + 1;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return handlers.sort((a, b) => a.line - b.line);
|
|
1458
|
+
}
|
|
962
1459
|
var authChecksRule = {
|
|
963
1460
|
id: "auth-checks",
|
|
964
1461
|
name: "Missing Auth Checks",
|
|
@@ -974,22 +1471,21 @@ var authChecksRule = {
|
|
|
974
1471
|
for (const pattern of AUTH_PATTERNS) {
|
|
975
1472
|
if (pattern.test(file.content)) return [];
|
|
976
1473
|
}
|
|
1474
|
+
const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
|
|
1475
|
+
const exposed = handlers.filter((h) => !h.authed);
|
|
1476
|
+
if (handlers.length > 0 && exposed.length === 0) return [];
|
|
1477
|
+
const middlewareSkipsRoute = project.hasAuthMiddleware && project.authMiddlewareExcludesApi === true && servedUnderApi(file.relativePath);
|
|
1478
|
+
const middlewareAuth = project.hasAuthMiddleware && !middlewareSkipsRoute;
|
|
977
1479
|
let severity;
|
|
978
|
-
if (
|
|
1480
|
+
if (middlewareAuth) {
|
|
979
1481
|
severity = "info";
|
|
980
|
-
} else if (
|
|
1482
|
+
} else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
|
|
981
1483
|
severity = "critical";
|
|
982
1484
|
} else {
|
|
983
1485
|
severity = "info";
|
|
984
1486
|
}
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
}
|
|
992
|
-
const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
|
|
1487
|
+
const handlerLine = exposed[0]?.line ?? 1;
|
|
1488
|
+
const message = middlewareAuth ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : middlewareSkipsRoute ? "API route has no authentication check (middleware auth detected, but its matcher excludes /api)" : "API route has no authentication check";
|
|
993
1489
|
return [{
|
|
994
1490
|
ruleId: "auth-checks",
|
|
995
1491
|
file: file.relativePath,
|
|
@@ -1107,7 +1603,8 @@ var errorHandlingRule = {
|
|
|
1107
1603
|
|
|
1108
1604
|
// src/rules/input-validation.ts
|
|
1109
1605
|
var VALIDATION_PATTERNS = [
|
|
1110
|
-
|
|
1606
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
1607
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
1111
1608
|
/\.safeParse\s*\(/,
|
|
1112
1609
|
/\.validate\s*\(/,
|
|
1113
1610
|
/\.validateSync\s*\(/,
|
|
@@ -1408,6 +1905,40 @@ var aiSmellsRule = {
|
|
|
1408
1905
|
};
|
|
1409
1906
|
|
|
1410
1907
|
// src/rules/unsafe-html.ts
|
|
1908
|
+
var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
|
|
1909
|
+
var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
|
|
1910
|
+
function isSanitizerCall(node) {
|
|
1911
|
+
if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
|
|
1912
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
|
|
1913
|
+
return isSanitizerCall(node.expression);
|
|
1914
|
+
}
|
|
1915
|
+
if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
|
|
1916
|
+
const callee = node.callee;
|
|
1917
|
+
if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
|
|
1918
|
+
return callee.property.type === "Identifier" && callee.property.name === "sanitize";
|
|
1919
|
+
}
|
|
1920
|
+
return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
|
|
1921
|
+
}
|
|
1922
|
+
function containsJsonStringify(node) {
|
|
1923
|
+
return subtreeContains(node, (n) => {
|
|
1924
|
+
if (n.type !== "CallExpression") return false;
|
|
1925
|
+
const call = n;
|
|
1926
|
+
if (call.callee.type === "MemberExpression") {
|
|
1927
|
+
const mem = call.callee;
|
|
1928
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1929
|
+
}
|
|
1930
|
+
return false;
|
|
1931
|
+
});
|
|
1932
|
+
}
|
|
1933
|
+
function isSanitized(value, sanitizedNames) {
|
|
1934
|
+
if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
|
|
1935
|
+
if (value.type === "ObjectExpression") {
|
|
1936
|
+
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"));
|
|
1937
|
+
return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
|
|
1938
|
+
}
|
|
1939
|
+
if (value.type === "Identifier") return sanitizedNames.has(value.name);
|
|
1940
|
+
return isSanitizerCall(value);
|
|
1941
|
+
}
|
|
1411
1942
|
var unsafeHtmlRule = {
|
|
1412
1943
|
id: "unsafe-html",
|
|
1413
1944
|
name: "Unsafe HTML Rendering",
|
|
@@ -1419,21 +1950,17 @@ var unsafeHtmlRule = {
|
|
|
1419
1950
|
const findings = [];
|
|
1420
1951
|
if (file.ast) {
|
|
1421
1952
|
try {
|
|
1953
|
+
const sanitizedNames = /* @__PURE__ */ new Set();
|
|
1954
|
+
walkAST(file.ast.program, (node) => {
|
|
1955
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
|
|
1956
|
+
sanitizedNames.add(node.id.name);
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1422
1959
|
walkAST(file.ast.program, (node) => {
|
|
1423
1960
|
if (node.type === "JSXAttribute") {
|
|
1424
1961
|
const attr = node;
|
|
1425
1962
|
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
|
-
}
|
|
1963
|
+
if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
|
|
1437
1964
|
const line = attr.loc.start.line;
|
|
1438
1965
|
findings.push({
|
|
1439
1966
|
ruleId: "unsafe-html",
|
|
@@ -1450,17 +1977,7 @@ var unsafeHtmlRule = {
|
|
|
1450
1977
|
if (node.type === "ObjectProperty") {
|
|
1451
1978
|
const prop = node;
|
|
1452
1979
|
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
|
-
}
|
|
1980
|
+
if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
|
|
1464
1981
|
const line = prop.loc.start.line;
|
|
1465
1982
|
findings.push({
|
|
1466
1983
|
ruleId: "unsafe-html",
|
|
@@ -1477,6 +1994,7 @@ var unsafeHtmlRule = {
|
|
|
1477
1994
|
if (node.type === "AssignmentExpression") {
|
|
1478
1995
|
const assign = node;
|
|
1479
1996
|
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1997
|
+
if (isSanitized(assign.right, sanitizedNames)) return;
|
|
1480
1998
|
const line = assign.loc.start.line;
|
|
1481
1999
|
findings.push({
|
|
1482
2000
|
ruleId: "unsafe-html",
|
|
@@ -1507,6 +2025,8 @@ var unsafeHtmlRule = {
|
|
|
1507
2025
|
}
|
|
1508
2026
|
const expr = context.join(" ");
|
|
1509
2027
|
if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
|
|
2028
|
+
const html = /__html\s*:/.exec(expr);
|
|
2029
|
+
if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
|
|
1510
2030
|
findings.push({
|
|
1511
2031
|
ruleId: "unsafe-html",
|
|
1512
2032
|
file: file.relativePath,
|
|
@@ -1518,7 +2038,8 @@ var unsafeHtmlRule = {
|
|
|
1518
2038
|
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1519
2039
|
});
|
|
1520
2040
|
}
|
|
1521
|
-
|
|
2041
|
+
const innerHtml = /\w\.innerHTML\s*=/.exec(line);
|
|
2042
|
+
if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
|
|
1522
2043
|
findings.push({
|
|
1523
2044
|
ruleId: "unsafe-html",
|
|
1524
2045
|
file: file.relativePath,
|
|
@@ -1697,9 +2218,16 @@ var staleFallbackRule = {
|
|
|
1697
2218
|
};
|
|
1698
2219
|
|
|
1699
2220
|
// src/rules/hallucinated-api.ts
|
|
2221
|
+
var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
|
|
2222
|
+
var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
|
|
1700
2223
|
var HALLUCINATED_APIS = [
|
|
1701
2224
|
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1702
|
-
{
|
|
2225
|
+
{
|
|
2226
|
+
pattern: /\.contains\s*\(/,
|
|
2227
|
+
fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
|
|
2228
|
+
methodName: "contains",
|
|
2229
|
+
isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
|
|
2230
|
+
},
|
|
1703
2231
|
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1704
2232
|
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1705
2233
|
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
@@ -1721,10 +2249,11 @@ var hallucinatedApiRule = {
|
|
|
1721
2249
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1722
2250
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1723
2251
|
const line = file.lines[i];
|
|
1724
|
-
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
2252
|
+
for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
|
|
1725
2253
|
const match = pattern.exec(line);
|
|
1726
2254
|
if (match) {
|
|
1727
2255
|
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
2256
|
+
if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
|
|
1728
2257
|
findings.push({
|
|
1729
2258
|
ruleId: "hallucinated-api",
|
|
1730
2259
|
file: file.relativePath,
|
|
@@ -2360,9 +2889,190 @@ var codebaseConsistencyRule = {
|
|
|
2360
2889
|
};
|
|
2361
2890
|
|
|
2362
2891
|
// src/rules/dead-exports.ts
|
|
2363
|
-
function isEntryPoint(relativePath) {
|
|
2892
|
+
function isEntryPoint(relativePath, appRoots, routeModules) {
|
|
2364
2893
|
const name = relativePath.split("/").pop() ?? "";
|
|
2365
|
-
return /^(page|layout|loading|error|not-found|route|
|
|
2894
|
+
return /^(page|layout|loading|error|not-found|route|opengraph-image|twitter-image|icon|apple-icon|sitemap|robots|manifest)\.(tsx?|jsx?)$/.test(name) || isBarrel(relativePath) || name === "global-error.tsx" || name === "global-error.jsx" || // SvelteKit's route files: +page.ts, +page.server.ts, +layout.ts, +server.ts
|
|
2895
|
+
/^\+(page|layout|server)(\.server)?\.(ts|js)$/.test(name) || // Storybook's stories: it reads a CSF file's default export as the meta, and each named
|
|
2896
|
+
// export as a story
|
|
2897
|
+
/\.stories\.(tsx?|jsx?|mjs)$/.test(name) || isNextRootFile(relativePath, appRoots) || isPagesRoute(relativePath, appRoots) || routeModules.has(relativePath);
|
|
2898
|
+
}
|
|
2899
|
+
function isBarrel(relativePath) {
|
|
2900
|
+
return /(?:^|\/)index\.(tsx?|jsx?)$/.test(relativePath);
|
|
2901
|
+
}
|
|
2902
|
+
function isAppFolder(dir, appRoots) {
|
|
2903
|
+
return appRoots.has(dir) || appRoots.has(dir.replace(/(?:^|\/)src$/, ""));
|
|
2904
|
+
}
|
|
2905
|
+
function isNextRootFile(relativePath, appRoots) {
|
|
2906
|
+
const match = /^(?:(.*)\/)?(?:middleware|proxy|instrumentation|instrumentation-client)\.(tsx?|jsx?)$/.exec(relativePath);
|
|
2907
|
+
return match !== null && isAppFolder(match[1] ?? "", appRoots);
|
|
2908
|
+
}
|
|
2909
|
+
function isPagesRoute(relativePath, appRoots) {
|
|
2910
|
+
if (!/\.(tsx?|jsx?)$/.test(relativePath)) return false;
|
|
2911
|
+
const parts = relativePath.split("/");
|
|
2912
|
+
return parts.some((part, i) => part === "pages" && isAppFolder(parts.slice(0, i).join("/"), appRoots));
|
|
2913
|
+
}
|
|
2914
|
+
function findAppRoots(paths) {
|
|
2915
|
+
const roots = /* @__PURE__ */ new Set([""]);
|
|
2916
|
+
for (const path of paths) {
|
|
2917
|
+
const match = /^(?:(.*)\/)?package\.json$/.exec(path);
|
|
2918
|
+
if (match) roots.add(match[1] ?? "");
|
|
2919
|
+
}
|
|
2920
|
+
return roots;
|
|
2921
|
+
}
|
|
2922
|
+
function findRouteModules(files, paths, appRoots) {
|
|
2923
|
+
const appFolders = [];
|
|
2924
|
+
for (const path of paths) {
|
|
2925
|
+
const match = /^(?:(.*)\/)?app\/root\.(tsx?|jsx?)$/.exec(path);
|
|
2926
|
+
if (match && appRoots.has(match[1] ?? "")) appFolders.push(path.slice(0, path.lastIndexOf("/")));
|
|
2927
|
+
}
|
|
2928
|
+
const modules = /* @__PURE__ */ new Set();
|
|
2929
|
+
for (const { relativePath, content } of files) {
|
|
2930
|
+
const folder = appFolders.find((f) => relativePath.startsWith(`${f}/`));
|
|
2931
|
+
if (folder === void 0) continue;
|
|
2932
|
+
const path = relativePath.slice(folder.length + 1);
|
|
2933
|
+
if (/^(?:root|entry\.server|routes\/.+)\.(tsx?|jsx?)$/.test(path)) {
|
|
2934
|
+
modules.add(relativePath);
|
|
2935
|
+
} else if (/^routes\.(?:[cm]?[jt]s|[jt]sx)$/.test(path)) {
|
|
2936
|
+
for (const match of content.matchAll(/['"`]([^'"`\n]+\.(?:tsx?|jsx?))['"`]/g)) {
|
|
2937
|
+
const module = normalizePath(`${folder}/${match[1]}`);
|
|
2938
|
+
if (module !== null) modules.add(module);
|
|
2939
|
+
}
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
return modules;
|
|
2943
|
+
}
|
|
2944
|
+
var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2945
|
+
var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2946
|
+
var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
|
|
2947
|
+
var DYNAMIC_IMPORT = /(\btypeof\s+)?(?:\{([^{}]*)\}\s*=\s*await\s+)?\bimport\s*\(\s*['"`]([^'"`]+)['"`]\s*\)(?:\s*\.\s*([\w$]+))?/g;
|
|
2948
|
+
var REQUIRE = /(?:\{([^{}]*)\}\s*=\s*)?\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([\w$]+))?/g;
|
|
2949
|
+
var REEXPORT_NAMED = /\bexport\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2950
|
+
var REEXPORT_ALL = /\bexport\s*\*\s*(?:as\s+([\w$]+)\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2951
|
+
var MODULE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
|
|
2952
|
+
var MODULE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
2953
|
+
function parseSpecifiers(list) {
|
|
2954
|
+
return list.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "")).filter(Boolean).map((s) => {
|
|
2955
|
+
const [name, alias] = s.split(/\s+as\s+/).map((part) => part.trim());
|
|
2956
|
+
return [name, alias ?? name];
|
|
2957
|
+
});
|
|
2958
|
+
}
|
|
2959
|
+
function normalizePath(path) {
|
|
2960
|
+
const parts = [];
|
|
2961
|
+
for (const part of path.split("/")) {
|
|
2962
|
+
if (part === "..") {
|
|
2963
|
+
if (parts.length === 0) return null;
|
|
2964
|
+
parts.pop();
|
|
2965
|
+
} else if (part && part !== ".") {
|
|
2966
|
+
parts.push(part);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
return parts.join("/");
|
|
2970
|
+
}
|
|
2971
|
+
var EXTERNAL = { files: [], folders: [] };
|
|
2972
|
+
var UNKNOWN = { files: [], folders: [""] };
|
|
2973
|
+
function isAsset(source) {
|
|
2974
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
2975
|
+
return /^[^.].*\.\w+$/.test(name) && !MODULE_EXTENSION.test(name);
|
|
2976
|
+
}
|
|
2977
|
+
function stringsIn(value) {
|
|
2978
|
+
if (typeof value === "string") return [value];
|
|
2979
|
+
if (Array.isArray(value)) return value.flatMap(stringsIn);
|
|
2980
|
+
if (value && typeof value === "object") return Object.values(value).flatMap(stringsIn);
|
|
2981
|
+
return [];
|
|
2982
|
+
}
|
|
2983
|
+
function createResolver(files, project) {
|
|
2984
|
+
const modules = new Set(files.filter((f) => MODULE_EXTENSIONS.includes(f.ext)).map((f) => f.relativePath));
|
|
2985
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2986
|
+
for (const path of modules) {
|
|
2987
|
+
const key = path.replace(/\.\w+$/, "").replace(/(?:^|\/)index$/, "");
|
|
2988
|
+
const name = key.slice(key.lastIndexOf("/") + 1);
|
|
2989
|
+
const list = byName.get(name);
|
|
2990
|
+
if (list) list.push({ path, key });
|
|
2991
|
+
else byName.set(name, [{ path, key }]);
|
|
2992
|
+
}
|
|
2993
|
+
const manifests = [];
|
|
2994
|
+
for (const file of files) {
|
|
2995
|
+
if (!/(?:^|\/)package\.json$/.test(file.relativePath)) continue;
|
|
2996
|
+
try {
|
|
2997
|
+
const json = JSON.parse(file.content);
|
|
2998
|
+
if (json && typeof json === "object") manifests.push({ folder: file.relativePath.replace(/\/?package\.json$/, ""), json });
|
|
2999
|
+
} catch {
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
const packages = /* @__PURE__ */ new Map();
|
|
3003
|
+
for (const { folder, json } of manifests) {
|
|
3004
|
+
if (typeof json.name === "string" && json.name) packages.set(json.name, [...packages.get(json.name) ?? [], folder]);
|
|
3005
|
+
}
|
|
3006
|
+
const findModule = (path) => {
|
|
3007
|
+
const stem = path.replace(/\.(?:[cm]?js|jsx)$/, "");
|
|
3008
|
+
const index = path ? `${path}/index` : "index";
|
|
3009
|
+
return [
|
|
3010
|
+
path,
|
|
3011
|
+
// TypeScript's ESM style names the compiled file: ./format.js for format.ts
|
|
3012
|
+
...stem === path ? [] : [`${stem}.ts`, `${stem}.tsx`],
|
|
3013
|
+
...MODULE_EXTENSIONS.map((ext) => `${path}.${ext}`),
|
|
3014
|
+
...MODULE_EXTENSIONS.map((ext) => `${index}.${ext}`)
|
|
3015
|
+
].find((candidate) => modules.has(candidate));
|
|
3016
|
+
};
|
|
3017
|
+
const packageEntries = /* @__PURE__ */ new Set();
|
|
3018
|
+
for (const { folder, json } of manifests) {
|
|
3019
|
+
if (json.private === true) continue;
|
|
3020
|
+
for (const target of stringsIn([json.main, json.module, json.types, json.typings, json.exports, json.bin])) {
|
|
3021
|
+
const isSource = /\.(?:[cm]?ts|tsx)$/.test(target) && !/\.d\.[cm]?ts$/.test(target);
|
|
3022
|
+
const path = isSource || target.includes("*") ? null : normalizePath(`${folder}/${target}`);
|
|
3023
|
+
if (path === null) continue;
|
|
3024
|
+
const stem = path.replace(/\.d\.ts$/, "").replace(MODULE_EXTENSION, "");
|
|
3025
|
+
const entry = findModule(path) ?? findModule(stem) ?? findModule(stem.replace(/(^|\/)(?:dist|build|lib|out|esm|cjs)\//, "$1src/"));
|
|
3026
|
+
if (entry) packageEntries.add(entry);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const resolveRelative = (path) => {
|
|
3030
|
+
const normalized = normalizePath(path);
|
|
3031
|
+
if (normalized?.includes("${")) {
|
|
3032
|
+
const parts = normalized.split(/\$\{[^}]*\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
3033
|
+
const pattern = new RegExp(`^${parts.join(".*")}(?:\\.\\w+)?$`);
|
|
3034
|
+
return { files: [...modules].filter((m) => pattern.test(m)), folders: [] };
|
|
3035
|
+
}
|
|
3036
|
+
const module = normalized === null ? void 0 : findModule(normalized);
|
|
3037
|
+
if (module) return { files: [module], folders: [] };
|
|
3038
|
+
return isAsset(path) ? EXTERNAL : UNKNOWN;
|
|
3039
|
+
};
|
|
3040
|
+
const resolveBare = (source) => {
|
|
3041
|
+
if (/^[a-z][\w+.-]*:/i.test(source)) return EXTERNAL;
|
|
3042
|
+
const name = source.startsWith("@") ? source.split("/").slice(0, 2).join("/") : source.split("/")[0];
|
|
3043
|
+
const folders = packages.get(name);
|
|
3044
|
+
if (folders) {
|
|
3045
|
+
const subpath = source.slice(name.length + 1);
|
|
3046
|
+
const found = folders.map((folder) => {
|
|
3047
|
+
const base = folder ? `${folder}/` : "";
|
|
3048
|
+
return subpath ? findModule(base + subpath) ?? findModule(`${base}src/${subpath}`) : findModule(folder) ?? findModule(`${base}src`);
|
|
3049
|
+
});
|
|
3050
|
+
return { files: found.filter((f) => f !== void 0), folders };
|
|
3051
|
+
}
|
|
3052
|
+
if (NODE_BUILTINS.has(name) || project.declaredDependencies.has(name)) return EXTERNAL;
|
|
3053
|
+
const segments = source.replace(MODULE_EXTENSION, "").split("/").filter(Boolean);
|
|
3054
|
+
const named = byName.get(segments[segments.length - 1] ?? "") ?? [];
|
|
3055
|
+
for (let i = 0; i < segments.length; i++) {
|
|
3056
|
+
const path = segments.slice(i).join("/");
|
|
3057
|
+
const matches = named.filter((m) => m.key === path || m.key.endsWith(`/${path}`));
|
|
3058
|
+
if (matches.length > 0) return i <= 1 ? { files: matches.map((m) => m.path), folders: [] } : UNKNOWN;
|
|
3059
|
+
}
|
|
3060
|
+
return isAsset(source) ? EXTERNAL : UNKNOWN;
|
|
3061
|
+
};
|
|
3062
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3063
|
+
const resolve5 = (source, from) => {
|
|
3064
|
+
const clean = source.replace(/\?.*$/, "");
|
|
3065
|
+
const relative = /^\.\.?(?:\/|$)/.test(clean);
|
|
3066
|
+
const dir = relative ? from.slice(0, from.lastIndexOf("/") + 1) : "";
|
|
3067
|
+
const key = `${dir}\0${clean}`;
|
|
3068
|
+
let target = cache.get(key);
|
|
3069
|
+
if (!target) {
|
|
3070
|
+
target = relative ? resolveRelative(dir + clean) : resolveBare(clean);
|
|
3071
|
+
cache.set(key, target);
|
|
3072
|
+
}
|
|
3073
|
+
return target;
|
|
3074
|
+
};
|
|
3075
|
+
return { resolve: resolve5, packageEntries };
|
|
2366
3076
|
}
|
|
2367
3077
|
var THRESHOLD = 5;
|
|
2368
3078
|
var deadExportsRule = {
|
|
@@ -2375,16 +3085,16 @@ var deadExportsRule = {
|
|
|
2375
3085
|
check() {
|
|
2376
3086
|
return [];
|
|
2377
3087
|
},
|
|
2378
|
-
checkProject(files,
|
|
3088
|
+
checkProject(files, project) {
|
|
2379
3089
|
const sourceFiles = files.filter(
|
|
2380
3090
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
2381
3091
|
);
|
|
3092
|
+
const appRoots = findAppRoots(project.allFiles);
|
|
3093
|
+
const routeModules = findRouteModules(files, project.allFiles, appRoots);
|
|
3094
|
+
const { resolve: resolve5, packageEntries } = createResolver(files, project);
|
|
2382
3095
|
const exports = /* @__PURE__ */ new Map();
|
|
2383
|
-
const imports = /* @__PURE__ */ new Map();
|
|
2384
|
-
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
2385
|
-
const importedFiles = /* @__PURE__ */ new Set();
|
|
2386
3096
|
for (const file of sourceFiles) {
|
|
2387
|
-
if (isEntryPoint(file.relativePath)) continue;
|
|
3097
|
+
if (isEntryPoint(file.relativePath, appRoots, routeModules) || packageEntries.has(file.relativePath)) continue;
|
|
2388
3098
|
let inTemplateLiteral = false;
|
|
2389
3099
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2390
3100
|
const line = file.lines[i];
|
|
@@ -2425,45 +3135,92 @@ var deadExportsRule = {
|
|
|
2425
3135
|
}
|
|
2426
3136
|
}
|
|
2427
3137
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2440
|
-
set.add(sym);
|
|
2441
|
-
imports.set(fromBasename, set);
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
3138
|
+
const contents = new Map(files.map((f) => [f.relativePath, f.content]));
|
|
3139
|
+
const used = /* @__PURE__ */ new Set();
|
|
3140
|
+
const usedInFolder = /* @__PURE__ */ new Map();
|
|
3141
|
+
const reexports = /* @__PURE__ */ new Map();
|
|
3142
|
+
const reexportsOf = (file) => {
|
|
3143
|
+
let list = reexports.get(file);
|
|
3144
|
+
if (!list) {
|
|
3145
|
+
list = [];
|
|
3146
|
+
const content = contents.get(file) ?? "";
|
|
3147
|
+
for (const match of content.matchAll(REEXPORT_NAMED)) {
|
|
3148
|
+
for (const [imported, exported] of parseSpecifiers(match[1])) list.push({ source: match[2], imported, exported });
|
|
2444
3149
|
}
|
|
2445
|
-
const
|
|
2446
|
-
|
|
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
|
-
}
|
|
3150
|
+
for (const match of content.matchAll(REEXPORT_ALL)) {
|
|
3151
|
+
list.push(match[1] ? { source: match[2], imported: "*", exported: match[1] } : { source: match[2], imported: "", exported: "*" });
|
|
2453
3152
|
}
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
3153
|
+
reexports.set(file, list);
|
|
3154
|
+
}
|
|
3155
|
+
return list;
|
|
3156
|
+
};
|
|
3157
|
+
const use = (target, name) => {
|
|
3158
|
+
if (name !== "*" && name !== "default") {
|
|
3159
|
+
for (const folder of target.folders) {
|
|
3160
|
+
const names = usedInFolder.get(folder) ?? /* @__PURE__ */ new Set();
|
|
3161
|
+
names.add(name);
|
|
3162
|
+
usedInFolder.set(folder, names);
|
|
3163
|
+
}
|
|
3164
|
+
}
|
|
3165
|
+
for (const file of target.files) {
|
|
3166
|
+
const key = `${file}::${name}`;
|
|
3167
|
+
if (used.has(key)) continue;
|
|
3168
|
+
used.add(key);
|
|
3169
|
+
for (const { source, imported, exported } of reexportsOf(file)) {
|
|
3170
|
+
if (exported === "*") {
|
|
3171
|
+
if (name !== "default") use(resolve5(source, file), name);
|
|
3172
|
+
} else if (name === "*" || name === exported) {
|
|
3173
|
+
use(resolve5(source, file), imported);
|
|
3174
|
+
}
|
|
2457
3175
|
}
|
|
2458
3176
|
}
|
|
3177
|
+
};
|
|
3178
|
+
const useLoaded = (target, destructured, member, typeOnly) => {
|
|
3179
|
+
if (member && !["then", "catch", "finally"].includes(member)) {
|
|
3180
|
+
use(target, member);
|
|
3181
|
+
} else if (typeOnly) {
|
|
3182
|
+
return;
|
|
3183
|
+
} else if (destructured !== void 0 && !destructured.includes("...")) {
|
|
3184
|
+
for (const name of destructured.split(",").map((s) => s.split(/[:=]/)[0].trim()).filter(Boolean)) use(target, name);
|
|
3185
|
+
} else {
|
|
3186
|
+
use(target, "*");
|
|
3187
|
+
}
|
|
3188
|
+
};
|
|
3189
|
+
for (const file of files) {
|
|
3190
|
+
const from = file.relativePath;
|
|
3191
|
+
for (const match of file.content.matchAll(IMPORT_NAMED)) {
|
|
3192
|
+
for (const [name] of parseSpecifiers(match[1])) use(resolve5(match[2], from), name);
|
|
3193
|
+
}
|
|
3194
|
+
for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
|
|
3195
|
+
const target = resolve5(match[2], from);
|
|
3196
|
+
use(target, "default");
|
|
3197
|
+
use(target, match[1]);
|
|
3198
|
+
}
|
|
3199
|
+
for (const match of file.content.matchAll(IMPORT_NAMESPACE)) use(resolve5(match[1], from), "*");
|
|
3200
|
+
for (const match of file.content.matchAll(DYNAMIC_IMPORT)) {
|
|
3201
|
+
const [, typeOnly, destructured, source, member] = match;
|
|
3202
|
+
useLoaded(resolve5(source, from), destructured, member, Boolean(typeOnly));
|
|
3203
|
+
}
|
|
3204
|
+
for (const match of file.content.matchAll(REQUIRE)) {
|
|
3205
|
+
const [, destructured, source, member] = match;
|
|
3206
|
+
useLoaded(resolve5(source, from), destructured, member, false);
|
|
3207
|
+
}
|
|
3208
|
+
if (isEntryPoint(from, appRoots, routeModules) && !isBarrel(from)) use({ files: [from], folders: [] }, "*");
|
|
2459
3209
|
}
|
|
3210
|
+
for (const entry of packageEntries) use({ files: [entry], folders: [] }, "*");
|
|
3211
|
+
const usedInAFolderOf = (file, name) => {
|
|
3212
|
+
let folder = file;
|
|
3213
|
+
while (folder) {
|
|
3214
|
+
folder = folder.slice(0, Math.max(folder.lastIndexOf("/"), 0));
|
|
3215
|
+
if (usedInFolder.get(folder)?.has(name)) return true;
|
|
3216
|
+
}
|
|
3217
|
+
return false;
|
|
3218
|
+
};
|
|
2460
3219
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
2461
3220
|
for (const [key, loc] of exports) {
|
|
2462
3221
|
const symbolName = key.split("::")[1];
|
|
2463
|
-
const
|
|
2464
|
-
|
|
2465
|
-
const isImported = importSet?.has(symbolName) ?? false;
|
|
2466
|
-
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
3222
|
+
const isUsed = used.has(key) || used.has(`${loc.file}::*`) || usedInAFolderOf(loc.file, symbolName);
|
|
3223
|
+
if (!isUsed) {
|
|
2467
3224
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
2468
3225
|
}
|
|
2469
3226
|
}
|
|
@@ -2530,19 +3287,6 @@ function catchFinding(file, catchLine, body) {
|
|
|
2530
3287
|
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2531
3288
|
};
|
|
2532
3289
|
}
|
|
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
3290
|
function returnedValues(code, text) {
|
|
2547
3291
|
const values = [];
|
|
2548
3292
|
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
@@ -2720,47 +3464,46 @@ var comprehensionDebtRule = {
|
|
|
2720
3464
|
};
|
|
2721
3465
|
|
|
2722
3466
|
// 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
|
|
3467
|
+
var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
|
|
3468
|
+
["huggingface-cli", ["@huggingface/hub"]],
|
|
3469
|
+
// a Python CLI; an npm placeholder
|
|
3470
|
+
["flask-hierarchical", []],
|
|
3471
|
+
// not on npm
|
|
3472
|
+
["beautifulsoup", ["cheerio"]],
|
|
3473
|
+
// a Python library (beautifulsoup4); not on npm
|
|
3474
|
+
["python-dotenv", ["dotenv"]],
|
|
3475
|
+
// a Python library; an npm placeholder
|
|
3476
|
+
["openai-sdk", ["openai"]],
|
|
3477
|
+
// unpublished
|
|
3478
|
+
["anthropic-sdk", ["@anthropic-ai/sdk"]],
|
|
3479
|
+
// an npm placeholder
|
|
3480
|
+
["langchain-core", ["@langchain/core"]],
|
|
3481
|
+
// deprecated
|
|
3482
|
+
["react-native-utils", []],
|
|
3483
|
+
// a 2015 native module
|
|
3484
|
+
["next-middleware", []],
|
|
3485
|
+
// a 2017 0.0.1; Next.js has middleware built in
|
|
3486
|
+
["supabase-client", ["@supabase/supabase-js"]],
|
|
3487
|
+
// unpublished
|
|
3488
|
+
["stripe-sdk", ["stripe"]],
|
|
3489
|
+
// not on npm
|
|
3490
|
+
["prisma-client", ["@prisma/client"]],
|
|
3491
|
+
// an empty 0.0.0 placeholder
|
|
3492
|
+
["tailwind-utils", []],
|
|
3493
|
+
// a 2021 alpha
|
|
3494
|
+
["express-validator-v2", ["express-validator"]],
|
|
3495
|
+
// not on npm
|
|
3496
|
+
["node-postgres-pool", ["pg"]],
|
|
3497
|
+
// not on npm
|
|
3498
|
+
["mongo-client", ["mongodb"]],
|
|
3499
|
+
// a 2013 wrapper
|
|
3500
|
+
["redis-client", ["redis", "ioredis"]],
|
|
3501
|
+
// a 2015 client
|
|
3502
|
+
["aws-s3-upload", ["@aws-sdk/client-s3"]]
|
|
3503
|
+
// a 2018 0.0.2
|
|
2762
3504
|
]);
|
|
2763
|
-
var
|
|
3505
|
+
var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
|
|
3506
|
+
// 2-char names
|
|
2764
3507
|
"pg",
|
|
2765
3508
|
"ws",
|
|
2766
3509
|
"ms",
|
|
@@ -2774,7 +3517,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
2774
3517
|
"io",
|
|
2775
3518
|
"db",
|
|
2776
3519
|
"fp",
|
|
2777
|
-
"rx"
|
|
3520
|
+
"rx",
|
|
3521
|
+
// -js suffix
|
|
3522
|
+
"crypto-js",
|
|
3523
|
+
"chroma-js",
|
|
3524
|
+
"libphonenumber-js",
|
|
3525
|
+
"ua-parser-js",
|
|
3526
|
+
"keycloak-js",
|
|
3527
|
+
"pusher-js",
|
|
3528
|
+
"matter-js",
|
|
3529
|
+
"amplitude-js",
|
|
3530
|
+
// Vanity prefixes
|
|
3531
|
+
"fast-glob",
|
|
3532
|
+
"fast-deep-equal",
|
|
3533
|
+
"fast-json-stable-stringify",
|
|
3534
|
+
"fast-xml-parser",
|
|
3535
|
+
"fast-levenshtein",
|
|
3536
|
+
"fast-safe-stringify",
|
|
3537
|
+
"fast-uri",
|
|
3538
|
+
"fast-equals",
|
|
3539
|
+
"fast-csv",
|
|
3540
|
+
"fast-check",
|
|
3541
|
+
"fast-diff",
|
|
3542
|
+
"simple-git",
|
|
3543
|
+
"simple-peer",
|
|
3544
|
+
"simple-icons"
|
|
2778
3545
|
]);
|
|
2779
3546
|
var SUSPICIOUS_PATTERNS = [
|
|
2780
3547
|
/^[a-z]{1,2}$/,
|
|
@@ -2784,6 +3551,9 @@ var SUSPICIOUS_PATTERNS = [
|
|
|
2784
3551
|
/^(the|my|simple|easy|fast|super|mega|ultra)-/
|
|
2785
3552
|
// vanity prefixes
|
|
2786
3553
|
];
|
|
3554
|
+
function quoteList(names) {
|
|
3555
|
+
return names.map((n) => `"${n}"`).join(" or ");
|
|
3556
|
+
}
|
|
2787
3557
|
var phantomDependencyRule = {
|
|
2788
3558
|
id: "phantom-dependency",
|
|
2789
3559
|
name: "Phantom Dependency",
|
|
@@ -2802,20 +3572,21 @@ var phantomDependencyRule = {
|
|
|
2802
3572
|
...project.packageJson.devDependencies ?? {}
|
|
2803
3573
|
};
|
|
2804
3574
|
for (const [name, _version] of Object.entries(deps)) {
|
|
2805
|
-
|
|
3575
|
+
const meant = PHANTOM_PACKAGES.get(name);
|
|
3576
|
+
if (meant) {
|
|
2806
3577
|
findings.push({
|
|
2807
3578
|
ruleId: "phantom-dependency",
|
|
2808
3579
|
file: "package.json",
|
|
2809
3580
|
line: 1,
|
|
2810
3581
|
column: 1,
|
|
2811
|
-
message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
3582
|
+
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
3583
|
severity: "warning",
|
|
2813
3584
|
category: "security",
|
|
2814
|
-
fix: "
|
|
3585
|
+
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
3586
|
});
|
|
2816
3587
|
}
|
|
2817
3588
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
2818
|
-
if (pattern.test(name) && !name.startsWith("@") && !
|
|
3589
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
|
|
2819
3590
|
findings.push({
|
|
2820
3591
|
ruleId: "phantom-dependency",
|
|
2821
3592
|
file: "package.json",
|
|
@@ -3001,10 +3772,11 @@ var insecureRandomRule = {
|
|
|
3001
3772
|
};
|
|
3002
3773
|
|
|
3003
3774
|
// src/rules/next-server-action-validation.ts
|
|
3004
|
-
var USE_SERVER =
|
|
3775
|
+
var USE_SERVER = /^\s*['"]use server['"]/m;
|
|
3005
3776
|
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
3006
3777
|
var VALIDATION_PATTERNS2 = [
|
|
3007
|
-
|
|
3778
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
3779
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
3008
3780
|
/\.safeParse\s*\(/,
|
|
3009
3781
|
/\bvalidate\s*\(/,
|
|
3010
3782
|
/\.parseAsync\s*\(/,
|
|
@@ -3155,6 +3927,30 @@ function findEnclosingFunction(node, parentMap) {
|
|
|
3155
3927
|
}
|
|
3156
3928
|
|
|
3157
3929
|
// src/rules/redirect-in-try-catch.ts
|
|
3930
|
+
var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
|
|
3931
|
+
function rethrowsRedirect(handler) {
|
|
3932
|
+
const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
|
|
3933
|
+
if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
|
|
3934
|
+
return true;
|
|
3935
|
+
}
|
|
3936
|
+
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"));
|
|
3937
|
+
}
|
|
3938
|
+
function bodyRethrowsRedirect(body, param) {
|
|
3939
|
+
if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
|
|
3940
|
+
return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
|
|
3941
|
+
}
|
|
3942
|
+
function redirectFinding(file, line, column) {
|
|
3943
|
+
return {
|
|
3944
|
+
ruleId: "redirect-in-try-catch",
|
|
3945
|
+
file: file.relativePath,
|
|
3946
|
+
line,
|
|
3947
|
+
column,
|
|
3948
|
+
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3949
|
+
severity: "critical",
|
|
3950
|
+
category: "reliability",
|
|
3951
|
+
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'
|
|
3952
|
+
};
|
|
3953
|
+
}
|
|
3158
3954
|
var redirectInTryCatchRule = {
|
|
3159
3955
|
id: "redirect-in-try-catch",
|
|
3160
3956
|
name: "Redirect Inside Try/Catch",
|
|
@@ -3166,44 +3962,42 @@ var redirectInTryCatchRule = {
|
|
|
3166
3962
|
if (isTestFile(file.relativePath)) return [];
|
|
3167
3963
|
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
3168
3964
|
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'
|
|
3965
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3966
|
+
if (file.ast) {
|
|
3967
|
+
try {
|
|
3968
|
+
walkAST(file.ast.program, (node) => {
|
|
3969
|
+
if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
|
|
3970
|
+
walkAST(node.block, (n) => {
|
|
3971
|
+
if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
|
|
3972
|
+
if (!n.loc || n.start == null || seen.has(n.start)) return;
|
|
3973
|
+
seen.add(n.start);
|
|
3974
|
+
findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
|
|
3191
3975
|
});
|
|
3192
|
-
}
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3976
|
+
});
|
|
3977
|
+
return findings;
|
|
3978
|
+
} catch {
|
|
3979
|
+
findings.length = 0;
|
|
3980
|
+
seen.clear();
|
|
3197
3981
|
}
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3982
|
+
}
|
|
3983
|
+
const source = file.lines.join("\n");
|
|
3984
|
+
const code = blankOut(source, true);
|
|
3985
|
+
const noComments = blankOut(source, false);
|
|
3986
|
+
for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
|
|
3987
|
+
const open = tryMatch.index + tryMatch[0].length - 1;
|
|
3988
|
+
const close = closingBrace(code, open);
|
|
3989
|
+
if (close === -1) continue;
|
|
3990
|
+
const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
|
|
3991
|
+
if (!catchMatch) continue;
|
|
3992
|
+
const catchOpen = close + 1 + catchMatch[0].length - 1;
|
|
3993
|
+
const catchClose = closingBrace(code, catchOpen);
|
|
3994
|
+
if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
|
|
3995
|
+
for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
|
|
3996
|
+
const offset = open + 1 + call.index;
|
|
3997
|
+
if (seen.has(offset)) continue;
|
|
3998
|
+
seen.add(offset);
|
|
3999
|
+
const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
|
|
4000
|
+
findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
|
|
3207
4001
|
}
|
|
3208
4002
|
}
|
|
3209
4003
|
return findings;
|
|
@@ -3496,7 +4290,7 @@ var missingWebhookVerificationRule = {
|
|
|
3496
4290
|
};
|
|
3497
4291
|
|
|
3498
4292
|
// src/rules/server-action-auth.ts
|
|
3499
|
-
var USE_SERVER3 =
|
|
4293
|
+
var USE_SERVER3 = /^\s*['"]use server['"]/m;
|
|
3500
4294
|
var AUTH_PATTERNS2 = [
|
|
3501
4295
|
/getServerSession\s*\(/,
|
|
3502
4296
|
/getSession\s*\(/,
|
|
@@ -3513,7 +4307,13 @@ var AUTH_PATTERNS2 = [
|
|
|
3513
4307
|
/currentUser\s*\(/,
|
|
3514
4308
|
/getAuth\s*\(/,
|
|
3515
4309
|
/cookies\(\).*auth/s,
|
|
3516
|
-
|
|
4310
|
+
// Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
|
|
4311
|
+
// verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
|
|
4312
|
+
/\w*session\w*\s*\(/i,
|
|
4313
|
+
// ...reading a session cookie, or using and guarding a session object
|
|
4314
|
+
/cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
|
|
4315
|
+
/\bsession\??\.user(?:Id)?\b/,
|
|
4316
|
+
/!\s*session\b/
|
|
3517
4317
|
];
|
|
3518
4318
|
var PUBLIC_ACTION_NAMES = [
|
|
3519
4319
|
/contact/i,
|
|
@@ -3534,7 +4334,6 @@ var serverActionAuthRule = {
|
|
|
3534
4334
|
check(file, project) {
|
|
3535
4335
|
if (isTestFile(file.relativePath)) return [];
|
|
3536
4336
|
if (!USE_SERVER3.test(file.content)) return [];
|
|
3537
|
-
if (project.hasAuthMiddleware) return [];
|
|
3538
4337
|
for (const p of PUBLIC_ACTION_NAMES) {
|
|
3539
4338
|
if (p.test(file.relativePath)) return [];
|
|
3540
4339
|
}
|
|
@@ -3549,15 +4348,17 @@ var serverActionAuthRule = {
|
|
|
3549
4348
|
break;
|
|
3550
4349
|
}
|
|
3551
4350
|
}
|
|
4351
|
+
const middlewareSkipsPages = project.hasAuthMiddleware && project.authMiddlewareExcludesPages === true;
|
|
4352
|
+
const middleware = project.hasAuthMiddleware && !middlewareSkipsPages;
|
|
3552
4353
|
return [{
|
|
3553
4354
|
ruleId: "server-action-auth",
|
|
3554
4355
|
file: file.relativePath,
|
|
3555
4356
|
line: reportLine,
|
|
3556
4357
|
column: 1,
|
|
3557
|
-
message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
|
|
3558
|
-
severity: "warning",
|
|
4358
|
+
message: middleware ? "Server action performs mutations without an inline auth check (middleware auth detected \u2014 verify coverage)" : middlewareSkipsPages ? "Server action performs mutations without any authentication check (middleware auth detected, but its matcher doesn't cover pages)" : "Server action performs mutations without any authentication check \u2014 anyone can call this action",
|
|
4359
|
+
severity: middleware ? "info" : "warning",
|
|
3559
4360
|
category: "security",
|
|
3560
|
-
fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
|
|
4361
|
+
fix: middleware ? 'Check the user in the action too, not only in middleware: const session = await auth(); if (!session) throw new Error("Unauthorized")' : 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
|
|
3561
4362
|
}];
|
|
3562
4363
|
}
|
|
3563
4364
|
};
|
|
@@ -3687,13 +4488,15 @@ var nextPublicSensitiveRule = {
|
|
|
3687
4488
|
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3688
4489
|
category: "security",
|
|
3689
4490
|
severity: "critical",
|
|
3690
|
-
|
|
4491
|
+
// The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
|
|
4492
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
|
|
3691
4493
|
check(file, _project) {
|
|
3692
4494
|
if (isTestFile(file.relativePath)) return [];
|
|
3693
4495
|
const findings = [];
|
|
4496
|
+
const isEnvFile = file.ext === "env";
|
|
3694
4497
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3695
|
-
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3696
4498
|
const line = file.lines[i];
|
|
4499
|
+
if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3697
4500
|
const match = SENSITIVE_PATTERN.exec(line);
|
|
3698
4501
|
if (match) {
|
|
3699
4502
|
findings.push({
|
|
@@ -4156,7 +4959,17 @@ var unsafeFileUploadRule = {
|
|
|
4156
4959
|
|
|
4157
4960
|
// src/rules/supabase-missing-rls.ts
|
|
4158
4961
|
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+)
|
|
4962
|
+
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;
|
|
4963
|
+
function isMigrationFile(file) {
|
|
4964
|
+
return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
|
|
4965
|
+
}
|
|
4966
|
+
function sqlProject(relativePath) {
|
|
4967
|
+
const dirs = relativePath.split("/").slice(0, -1);
|
|
4968
|
+
const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
|
|
4969
|
+
const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
|
|
4970
|
+
const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
|
|
4971
|
+
return dirs.slice(0, end).join("/");
|
|
4972
|
+
}
|
|
4160
4973
|
var supabaseMissingRlsRule = {
|
|
4161
4974
|
id: "supabase-missing-rls",
|
|
4162
4975
|
name: "Missing Row-Level Security",
|
|
@@ -4164,42 +4977,36 @@ var supabaseMissingRlsRule = {
|
|
|
4164
4977
|
category: "security",
|
|
4165
4978
|
severity: "critical",
|
|
4166
4979
|
fileExtensions: ["sql"],
|
|
4167
|
-
check(
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4980
|
+
check() {
|
|
4981
|
+
return [];
|
|
4982
|
+
},
|
|
4983
|
+
checkProject(files, _project) {
|
|
4171
4984
|
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
4985
|
const rlsTables = /* @__PURE__ */ new Set();
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
if (filePath === file.relativePath) continue;
|
|
4986
|
+
for (const file of files) {
|
|
4987
|
+
if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
|
|
4988
|
+
const project = sqlProject(file.relativePath);
|
|
4989
|
+
for (const match of file.content.matchAll(ENABLE_RLS)) {
|
|
4990
|
+
rlsTables.add(`${project}::${match[1].toLowerCase()}`);
|
|
4991
|
+
}
|
|
4191
4992
|
}
|
|
4192
|
-
for (const
|
|
4193
|
-
if (!
|
|
4993
|
+
for (const file of files) {
|
|
4994
|
+
if (!isMigrationFile(file)) continue;
|
|
4995
|
+
const content = file.content;
|
|
4996
|
+
const project = sqlProject(file.relativePath);
|
|
4997
|
+
for (const match of content.matchAll(CREATE_TABLE)) {
|
|
4998
|
+
const name = match[1];
|
|
4999
|
+
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
5000
|
+
if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
|
|
4194
5001
|
findings.push({
|
|
4195
5002
|
ruleId: "supabase-missing-rls",
|
|
4196
5003
|
file: file.relativePath,
|
|
4197
|
-
line:
|
|
5004
|
+
line: content.slice(0, match.index).split("\n").length,
|
|
4198
5005
|
column: 1,
|
|
4199
|
-
message: `Table "${
|
|
5006
|
+
message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
4200
5007
|
severity: "critical",
|
|
4201
5008
|
category: "security",
|
|
4202
|
-
fix: `Add: ALTER TABLE ${
|
|
5009
|
+
fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
4203
5010
|
});
|
|
4204
5011
|
}
|
|
4205
5012
|
}
|
|
@@ -4530,10 +5337,15 @@ async function scan(options) {
|
|
|
4530
5337
|
}
|
|
4531
5338
|
}
|
|
4532
5339
|
}
|
|
5340
|
+
const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
|
|
4533
5341
|
for (const rule of rules) {
|
|
4534
5342
|
if (rule.checkProject) {
|
|
4535
|
-
const
|
|
4536
|
-
|
|
5343
|
+
for (const finding of rule.checkProject(allFiles, project)) {
|
|
5344
|
+
const file = filesByPath.get(finding.file);
|
|
5345
|
+
if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
|
|
5346
|
+
findings.push(finding);
|
|
5347
|
+
}
|
|
5348
|
+
}
|
|
4537
5349
|
}
|
|
4538
5350
|
}
|
|
4539
5351
|
const { overallScore, categoryScores } = calculateScores(findings);
|
|
@@ -4723,9 +5535,9 @@ function ipv6Groups(address) {
|
|
|
4723
5535
|
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4724
5536
|
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4725
5537
|
const [head, tail] = hex.split("::");
|
|
4726
|
-
const
|
|
4727
|
-
const front =
|
|
4728
|
-
const back =
|
|
5538
|
+
const parse3 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
5539
|
+
const front = parse3(head);
|
|
5540
|
+
const back = parse3(tail);
|
|
4729
5541
|
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4730
5542
|
}
|
|
4731
5543
|
function embeddedIPv4(address) {
|
|
@@ -4891,18 +5703,61 @@ async function runWebScan(targetUrl) {
|
|
|
4891
5703
|
}
|
|
4892
5704
|
|
|
4893
5705
|
// src/mcp.ts
|
|
5706
|
+
var SEVERITIES = ["critical", "warning", "info"];
|
|
5707
|
+
var CATEGORIES2 = ["security", "reliability", "performance", "ai-quality"];
|
|
5708
|
+
var SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
|
|
5709
|
+
var DEFAULT_MAX_FINDINGS = 30;
|
|
5710
|
+
function bySeverity(a, b) {
|
|
5711
|
+
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;
|
|
5712
|
+
}
|
|
4894
5713
|
var server = new McpServer({
|
|
4895
5714
|
name: "prodlint",
|
|
4896
5715
|
version: getVersion()
|
|
4897
5716
|
});
|
|
4898
|
-
server.
|
|
5717
|
+
server.registerTool(
|
|
4899
5718
|
"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
5719
|
{
|
|
4902
|
-
|
|
4903
|
-
|
|
5720
|
+
title: "Scan a project for production readiness",
|
|
5721
|
+
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.",
|
|
5722
|
+
inputSchema: {
|
|
5723
|
+
path: z.string().describe("Absolute path to the project directory to scan; it must be inside the directory the server runs in"),
|
|
5724
|
+
ignore: z.array(z.string()).optional().describe("Glob patterns to ignore"),
|
|
5725
|
+
minSeverity: z.enum(SEVERITIES).optional().describe("Only list findings at this severity or higher (default: info, i.e. all)"),
|
|
5726
|
+
categories: z.array(z.enum(CATEGORIES2)).optional().describe("Only list findings in these categories (default: all)"),
|
|
5727
|
+
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.`)
|
|
5728
|
+
},
|
|
5729
|
+
outputSchema: {
|
|
5730
|
+
version: z.string().describe("prodlint version that ran the scan"),
|
|
5731
|
+
path: z.string().describe("Directory that was scanned"),
|
|
5732
|
+
score: z.number().describe("Overall production-readiness score, 0-100"),
|
|
5733
|
+
filesScanned: z.number(),
|
|
5734
|
+
durationMs: z.number(),
|
|
5735
|
+
categoryScores: z.array(z.object({
|
|
5736
|
+
category: z.enum(CATEGORIES2),
|
|
5737
|
+
score: z.number(),
|
|
5738
|
+
findingCount: z.number()
|
|
5739
|
+
})),
|
|
5740
|
+
summary: z.object({
|
|
5741
|
+
critical: z.number(),
|
|
5742
|
+
warning: z.number(),
|
|
5743
|
+
info: z.number()
|
|
5744
|
+
}).describe("Counts of all findings, before minSeverity and categories are applied"),
|
|
5745
|
+
matched: z.number().describe("Findings that pass the minSeverity and categories filters"),
|
|
5746
|
+
omitted: z.number().describe("Matching findings left out because of maxFindings"),
|
|
5747
|
+
findings: z.array(z.object({
|
|
5748
|
+
ruleId: z.string(),
|
|
5749
|
+
severity: z.enum(SEVERITIES),
|
|
5750
|
+
category: z.enum(CATEGORIES2),
|
|
5751
|
+
file: z.string(),
|
|
5752
|
+
line: z.number(),
|
|
5753
|
+
column: z.number(),
|
|
5754
|
+
message: z.string(),
|
|
5755
|
+
fix: z.string().optional()
|
|
5756
|
+
})).describe("Matching findings, most severe first")
|
|
5757
|
+
},
|
|
5758
|
+
annotations: { readOnlyHint: true, openWorldHint: false }
|
|
4904
5759
|
},
|
|
4905
|
-
async ({ path, ignore }) => {
|
|
5760
|
+
async ({ path, ignore, minSeverity = "info", categories, maxFindings = DEFAULT_MAX_FINDINGS }) => {
|
|
4906
5761
|
const resolved = resolve4(path);
|
|
4907
5762
|
const cwd = process.cwd();
|
|
4908
5763
|
const normalizedResolved = process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
@@ -4928,6 +5783,11 @@ server.tool(
|
|
|
4928
5783
|
};
|
|
4929
5784
|
}
|
|
4930
5785
|
const result = await scan({ path: resolved, ignore });
|
|
5786
|
+
const minRank = SEVERITY_RANK[minSeverity];
|
|
5787
|
+
const matching = result.findings.filter((f) => SEVERITY_RANK[f.severity] >= minRank && (!categories?.length || categories.includes(f.category))).sort(bySeverity);
|
|
5788
|
+
const criticalCount = matching.filter((f) => f.severity === "critical").length;
|
|
5789
|
+
const listed = matching.slice(0, Math.max(maxFindings, criticalCount));
|
|
5790
|
+
const omitted = matching.length - listed.length;
|
|
4931
5791
|
const summary = [
|
|
4932
5792
|
`## Production Readiness: ${result.overallScore}/100`,
|
|
4933
5793
|
"",
|
|
@@ -4940,26 +5800,80 @@ server.tool(
|
|
|
4940
5800
|
"",
|
|
4941
5801
|
`### Summary: ${result.summary.critical} critical, ${result.summary.warning} warnings, ${result.summary.info} info`
|
|
4942
5802
|
];
|
|
4943
|
-
if (
|
|
4944
|
-
|
|
4945
|
-
|
|
5803
|
+
if (listed.length > 0) {
|
|
5804
|
+
const scope = [
|
|
5805
|
+
minSeverity !== "info" ? `${minSeverity} and above` : "",
|
|
5806
|
+
categories?.length ? categories.join(", ") : ""
|
|
5807
|
+
].filter(Boolean).join("; ");
|
|
5808
|
+
summary.push("", `### Findings${scope ? ` (${scope})` : ""}, most severe first`);
|
|
5809
|
+
for (const f of listed) {
|
|
4946
5810
|
const line = `- **[${f.severity}]** \`${f.ruleId}\` ${f.file}:${f.line} \u2014 ${f.message}`;
|
|
4947
5811
|
summary.push(f.fix ? `${line}
|
|
4948
5812
|
- _Fix: ${f.fix}_` : line);
|
|
4949
5813
|
}
|
|
4950
|
-
if (
|
|
4951
|
-
summary.push(`- ...and ${
|
|
5814
|
+
if (omitted > 0) {
|
|
5815
|
+
summary.push(`- ...and ${omitted} more. Raise maxFindings, or narrow with minSeverity or categories.`);
|
|
4952
5816
|
}
|
|
5817
|
+
} else if (result.findings.length > 0) {
|
|
5818
|
+
summary.push("", "No findings match the minSeverity and categories filters.");
|
|
4953
5819
|
}
|
|
4954
5820
|
return {
|
|
4955
|
-
content: [{ type: "text", text: summary.join("\n") }]
|
|
5821
|
+
content: [{ type: "text", text: summary.join("\n") }],
|
|
5822
|
+
structuredContent: {
|
|
5823
|
+
version: result.version,
|
|
5824
|
+
path: result.scannedPath,
|
|
5825
|
+
score: result.overallScore,
|
|
5826
|
+
filesScanned: result.filesScanned,
|
|
5827
|
+
durationMs: result.scanDurationMs,
|
|
5828
|
+
categoryScores: result.categoryScores.map((c) => ({ category: c.category, score: c.score, findingCount: c.findingCount })),
|
|
5829
|
+
summary: result.summary,
|
|
5830
|
+
matched: matching.length,
|
|
5831
|
+
omitted,
|
|
5832
|
+
findings: listed.map((f) => ({
|
|
5833
|
+
ruleId: f.ruleId,
|
|
5834
|
+
severity: f.severity,
|
|
5835
|
+
category: f.category,
|
|
5836
|
+
file: f.file,
|
|
5837
|
+
line: f.line,
|
|
5838
|
+
column: f.column,
|
|
5839
|
+
message: f.message,
|
|
5840
|
+
fix: f.fix
|
|
5841
|
+
}))
|
|
5842
|
+
}
|
|
4956
5843
|
};
|
|
4957
5844
|
}
|
|
4958
5845
|
);
|
|
4959
|
-
server.
|
|
5846
|
+
server.registerTool(
|
|
4960
5847
|
"scan-web",
|
|
4961
|
-
|
|
4962
|
-
|
|
5848
|
+
{
|
|
5849
|
+
title: "Score a website's AI-agent readiness",
|
|
5850
|
+
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.",
|
|
5851
|
+
inputSchema: {
|
|
5852
|
+
url: z.string().describe("URL of the website to scan (e.g. https://example.com)")
|
|
5853
|
+
},
|
|
5854
|
+
outputSchema: {
|
|
5855
|
+
url: z.string(),
|
|
5856
|
+
domain: z.string(),
|
|
5857
|
+
score: z.number().describe("Agent-readiness score, 0-100"),
|
|
5858
|
+
grade: z.string(),
|
|
5859
|
+
summary: z.object({
|
|
5860
|
+
passed: z.number(),
|
|
5861
|
+
failed: z.number(),
|
|
5862
|
+
warnings: z.number(),
|
|
5863
|
+
totalChecks: z.number()
|
|
5864
|
+
}),
|
|
5865
|
+
checks: z.array(z.object({
|
|
5866
|
+
id: z.string(),
|
|
5867
|
+
name: z.string(),
|
|
5868
|
+
status: z.enum(["pass", "fail", "warn", "info"]),
|
|
5869
|
+
points: z.number(),
|
|
5870
|
+
maxPoints: z.number(),
|
|
5871
|
+
details: z.string().optional()
|
|
5872
|
+
}))
|
|
5873
|
+
},
|
|
5874
|
+
// Read-only, but it fetches the given site over the network
|
|
5875
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
5876
|
+
},
|
|
4963
5877
|
async ({ url }) => {
|
|
4964
5878
|
let normalizedUrl;
|
|
4965
5879
|
try {
|
|
@@ -4993,7 +5907,22 @@ server.tool(
|
|
|
4993
5907
|
`### Summary: ${result.summary.passed} passed \xB7 ${result.summary.failed} failed \xB7 ${result.summary.warnings} warnings`
|
|
4994
5908
|
];
|
|
4995
5909
|
return {
|
|
4996
|
-
content: [{ type: "text", text: summary.join("\n") }]
|
|
5910
|
+
content: [{ type: "text", text: summary.join("\n") }],
|
|
5911
|
+
structuredContent: {
|
|
5912
|
+
url: result.url,
|
|
5913
|
+
domain: result.domain,
|
|
5914
|
+
score: result.overallScore,
|
|
5915
|
+
grade: result.grade,
|
|
5916
|
+
summary: result.summary,
|
|
5917
|
+
checks: result.checks.map((c) => ({
|
|
5918
|
+
id: c.id,
|
|
5919
|
+
name: c.name,
|
|
5920
|
+
status: c.status,
|
|
5921
|
+
points: c.points,
|
|
5922
|
+
maxPoints: c.maxPoints,
|
|
5923
|
+
details: c.details
|
|
5924
|
+
}))
|
|
5925
|
+
}
|
|
4997
5926
|
};
|
|
4998
5927
|
}
|
|
4999
5928
|
);
|