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/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import { readFileSync as readFileSync2, writeFileSync, existsSync } from "fs";
|
|
|
7
7
|
// src/utils/file-walker.ts
|
|
8
8
|
import fg from "fast-glob";
|
|
9
9
|
import { readFile, stat, realpath } from "fs/promises";
|
|
10
|
-
import { resolve, extname, sep } from "path";
|
|
10
|
+
import { resolve, extname, basename, sep } from "path";
|
|
11
11
|
|
|
12
12
|
// src/utils/patterns.ts
|
|
13
13
|
function isApiRoute(relativePath) {
|
|
@@ -50,6 +50,19 @@ function isCommentLine(lines, lineIndex, commentMap) {
|
|
|
50
50
|
const trimmed = lines[lineIndex]?.trim() ?? "";
|
|
51
51
|
return trimmed.startsWith("//");
|
|
52
52
|
}
|
|
53
|
+
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
54
|
+
function blankOut(source, strings) {
|
|
55
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
56
|
+
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
57
|
+
}
|
|
58
|
+
function closingBrace(code, open) {
|
|
59
|
+
let depth = 0;
|
|
60
|
+
for (let i = open; i < code.length; i++) {
|
|
61
|
+
if (code[i] === "{") depth++;
|
|
62
|
+
else if (code[i] === "}" && --depth === 0) return i;
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
53
66
|
function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
54
67
|
for (const line of lines) {
|
|
55
68
|
const trimmed = line.trim();
|
|
@@ -74,7 +87,8 @@ function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
|
74
87
|
return false;
|
|
75
88
|
}
|
|
76
89
|
function isTestFile(relativePath) {
|
|
77
|
-
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) ||
|
|
90
|
+
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || // Jest's __tests__ and __mocks__, jscodeshift's __testfixtures__
|
|
91
|
+
/(?:^|\/)__(?:tests|testfixtures|fixtures|mocks)__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
|
|
78
92
|
}
|
|
79
93
|
function isScriptFile(relativePath) {
|
|
80
94
|
if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
|
|
@@ -388,6 +402,263 @@ var DEPENDENCY_TO_FRAMEWORK = {
|
|
|
388
402
|
var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
|
|
389
403
|
var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
|
|
390
404
|
|
|
405
|
+
// src/utils/middleware-matcher.ts
|
|
406
|
+
function matcherExcludesApi(content, fileName) {
|
|
407
|
+
const sources = matcherSources(content, fileName);
|
|
408
|
+
if (!sources || sources.length === 0) return false;
|
|
409
|
+
return sources.every((source) => {
|
|
410
|
+
const matcher = compile(source);
|
|
411
|
+
return matcher !== null && !matcher.patterns.some(namesApi) && !apiPaths(matcher.literals).some((path) => matcher.regexp.test(path));
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
function matcherExcludesPages(content, fileName) {
|
|
415
|
+
const sources = matcherSources(content, fileName);
|
|
416
|
+
if (!sources || sources.length === 0) return false;
|
|
417
|
+
return sources.every((source) => {
|
|
418
|
+
const matcher = compile(source);
|
|
419
|
+
return matcher !== null && underApi(matcher.tokens);
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
function underApi(tokens) {
|
|
423
|
+
const [head, ...rest] = tokens;
|
|
424
|
+
if (typeof head !== "string") return false;
|
|
425
|
+
if (head.startsWith("/api/")) return true;
|
|
426
|
+
if (head !== "/api") return false;
|
|
427
|
+
for (const token of rest) {
|
|
428
|
+
const start = typeof token === "string" ? token : token.pattern ? prefixOf(token) : token.prefix + token.suffix;
|
|
429
|
+
if (!start.startsWith("/")) return false;
|
|
430
|
+
if (typeof token === "string" || token.modifier !== "?" && token.modifier !== "*") return true;
|
|
431
|
+
}
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
function apiPaths(literals) {
|
|
435
|
+
const paths = ["/api/x", "/api/x/y"];
|
|
436
|
+
for (const literal2 of literals) {
|
|
437
|
+
for (const head of ["", "/api", "/api/x"]) {
|
|
438
|
+
for (const tail of ["", "/x"]) paths.push(head + literal2 + tail);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return paths.filter((path) => path.startsWith("/api/"));
|
|
442
|
+
}
|
|
443
|
+
function matcherSources(content, fileName) {
|
|
444
|
+
const ast = parseFile(content, fileName);
|
|
445
|
+
if (!ast) return null;
|
|
446
|
+
for (const st of ast.program.body) {
|
|
447
|
+
if (st.type !== "ExportNamedDeclaration" || st.declaration?.type !== "VariableDeclaration") continue;
|
|
448
|
+
if (st.declaration.kind !== "const") continue;
|
|
449
|
+
for (const d of st.declaration.declarations) {
|
|
450
|
+
if (d.id.type !== "Identifier" || d.id.name !== "config" || !d.init) continue;
|
|
451
|
+
const config = unwrap(d.init);
|
|
452
|
+
if (config.type !== "ObjectExpression") return null;
|
|
453
|
+
if (config.properties.some((p) => p.type !== "ObjectProperty")) return null;
|
|
454
|
+
const matcher = property(config, "matcher");
|
|
455
|
+
if (!matcher) return void 0;
|
|
456
|
+
const values = matcher.type === "ArrayExpression" ? matcher.elements : [matcher];
|
|
457
|
+
const sources = [];
|
|
458
|
+
for (const value of values) {
|
|
459
|
+
const element = value ? unwrap(value) : null;
|
|
460
|
+
const source = element?.type === "ObjectExpression" ? property(element, "source") : element;
|
|
461
|
+
const text = source ? literal(source) : null;
|
|
462
|
+
if (text === null) return null;
|
|
463
|
+
sources.push(text);
|
|
464
|
+
}
|
|
465
|
+
return sources;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return void 0;
|
|
469
|
+
}
|
|
470
|
+
function unwrap(node) {
|
|
471
|
+
while (node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression") node = node.expression;
|
|
472
|
+
return node;
|
|
473
|
+
}
|
|
474
|
+
function property(object, name) {
|
|
475
|
+
if (object.type !== "ObjectExpression") return void 0;
|
|
476
|
+
let value;
|
|
477
|
+
for (const p of object.properties) {
|
|
478
|
+
if (p.type !== "ObjectProperty" || p.computed) continue;
|
|
479
|
+
const key = p.key.type === "Identifier" ? p.key.name : p.key.type === "StringLiteral" ? p.key.value : null;
|
|
480
|
+
if (key === name) value = unwrap(p.value);
|
|
481
|
+
}
|
|
482
|
+
return value;
|
|
483
|
+
}
|
|
484
|
+
function literal(node) {
|
|
485
|
+
if (node.type === "StringLiteral") return node.value;
|
|
486
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked ?? null;
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
function namesApi(pattern) {
|
|
490
|
+
let kept = "";
|
|
491
|
+
const negative = [];
|
|
492
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
493
|
+
const c = pattern[i];
|
|
494
|
+
const skipping = negative.includes(true);
|
|
495
|
+
if (c === "\\") {
|
|
496
|
+
if (!skipping) kept += pattern.slice(i, i + 2);
|
|
497
|
+
i++;
|
|
498
|
+
} else if (c === "(") {
|
|
499
|
+
negative.push(pattern.startsWith("(?!", i) || pattern.startsWith("(?<!", i));
|
|
500
|
+
if (!skipping) kept += c;
|
|
501
|
+
} else if (c === ")") {
|
|
502
|
+
negative.pop();
|
|
503
|
+
if (!negative.includes(true)) kept += c;
|
|
504
|
+
} else if (!skipping) {
|
|
505
|
+
kept += c;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return /\bapi\b/i.test(kept);
|
|
509
|
+
}
|
|
510
|
+
var DELIMITER = "/#?";
|
|
511
|
+
var PREFIXES = "./";
|
|
512
|
+
var escape = (s) => s.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
|
513
|
+
var prefixOf = (token) => (token.modifier === "*" || token.modifier === "+") && !token.prefix && !token.suffix ? "/" : token.prefix;
|
|
514
|
+
function lex(source) {
|
|
515
|
+
const tokens = [];
|
|
516
|
+
let i = 0;
|
|
517
|
+
while (i < source.length) {
|
|
518
|
+
const c = source[i];
|
|
519
|
+
if (c === "*" || c === "+" || c === "?") {
|
|
520
|
+
tokens.push({ type: "MODIFIER", value: source[i++] });
|
|
521
|
+
} else if (c === "\\") {
|
|
522
|
+
tokens.push({ type: "ESCAPED_CHAR", value: source[i + 1] });
|
|
523
|
+
i += 2;
|
|
524
|
+
} else if (c === "{" || c === "}") {
|
|
525
|
+
tokens.push({ type: c === "{" ? "OPEN" : "CLOSE", value: source[i++] });
|
|
526
|
+
} else if (c === ":") {
|
|
527
|
+
const name = /^\w+/.exec(source.slice(i + 1))?.[0];
|
|
528
|
+
if (!name) throw new TypeError(`Missing parameter name at ${i}`);
|
|
529
|
+
tokens.push({ type: "NAME", value: name });
|
|
530
|
+
i += 1 + name.length;
|
|
531
|
+
} else if (c === "(") {
|
|
532
|
+
let depth = 1;
|
|
533
|
+
let pattern = "";
|
|
534
|
+
let j = i + 1;
|
|
535
|
+
if (source[j] === "?") throw new TypeError(`Pattern cannot start with "?" at ${j}`);
|
|
536
|
+
while (j < source.length) {
|
|
537
|
+
if (source[j] === "\\") {
|
|
538
|
+
pattern += source[j++] + source[j++];
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (source[j] === ")") {
|
|
542
|
+
if (--depth === 0) {
|
|
543
|
+
j++;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
} else if (source[j] === "(") {
|
|
547
|
+
depth++;
|
|
548
|
+
if (source[j + 1] !== "?") throw new TypeError(`Capturing groups are not allowed at ${j}`);
|
|
549
|
+
}
|
|
550
|
+
pattern += source[j++];
|
|
551
|
+
}
|
|
552
|
+
if (depth) throw new TypeError(`Unbalanced pattern at ${i}`);
|
|
553
|
+
if (!pattern) throw new TypeError(`Missing pattern at ${i}`);
|
|
554
|
+
tokens.push({ type: "PATTERN", value: pattern });
|
|
555
|
+
i = j;
|
|
556
|
+
} else {
|
|
557
|
+
tokens.push({ type: "CHAR", value: source[i++] });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
tokens.push({ type: "END", value: "" });
|
|
561
|
+
return tokens;
|
|
562
|
+
}
|
|
563
|
+
function parse2(source) {
|
|
564
|
+
const tokens = lex(source);
|
|
565
|
+
const result = [];
|
|
566
|
+
let i = 0;
|
|
567
|
+
let path = "";
|
|
568
|
+
const tryConsume = (type) => i < tokens.length && tokens[i].type === type ? tokens[i++].value : void 0;
|
|
569
|
+
const mustConsume = (type) => {
|
|
570
|
+
const value = tryConsume(type);
|
|
571
|
+
if (value === void 0) throw new TypeError(`Unexpected ${tokens[i].type}, expected ${type}`);
|
|
572
|
+
return value;
|
|
573
|
+
};
|
|
574
|
+
const consumeText = () => {
|
|
575
|
+
let text = "";
|
|
576
|
+
let value;
|
|
577
|
+
while (value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR")) text += value;
|
|
578
|
+
return text;
|
|
579
|
+
};
|
|
580
|
+
const safePattern = (prefix) => {
|
|
581
|
+
const prev = result[result.length - 1];
|
|
582
|
+
const prevText = prefix || (typeof prev === "string" ? prev : "");
|
|
583
|
+
if (prev && !prevText) throw new TypeError("Must have text between two parameters");
|
|
584
|
+
if (!prevText || [...DELIMITER].some((c) => prevText.includes(c))) return `[^${escape(DELIMITER)}]+?`;
|
|
585
|
+
return `(?:(?!${escape(prevText)})[^${escape(DELIMITER)}])+?`;
|
|
586
|
+
};
|
|
587
|
+
while (i < tokens.length) {
|
|
588
|
+
const char = tryConsume("CHAR");
|
|
589
|
+
const name = tryConsume("NAME");
|
|
590
|
+
const pattern = tryConsume("PATTERN");
|
|
591
|
+
if (name || pattern) {
|
|
592
|
+
let prefix = char || "";
|
|
593
|
+
if (!PREFIXES.includes(prefix)) {
|
|
594
|
+
path += prefix;
|
|
595
|
+
prefix = "";
|
|
596
|
+
}
|
|
597
|
+
if (path) {
|
|
598
|
+
result.push(path);
|
|
599
|
+
path = "";
|
|
600
|
+
}
|
|
601
|
+
result.push({ prefix, suffix: "", pattern: pattern || safePattern(prefix), modifier: tryConsume("MODIFIER") || "" });
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
const value = char || tryConsume("ESCAPED_CHAR");
|
|
605
|
+
if (value) {
|
|
606
|
+
path += value;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
if (path) {
|
|
610
|
+
result.push(path);
|
|
611
|
+
path = "";
|
|
612
|
+
}
|
|
613
|
+
if (tryConsume("OPEN")) {
|
|
614
|
+
const prefix = consumeText();
|
|
615
|
+
const name2 = tryConsume("NAME") || "";
|
|
616
|
+
const pattern2 = tryConsume("PATTERN") || "";
|
|
617
|
+
const suffix = consumeText();
|
|
618
|
+
mustConsume("CLOSE");
|
|
619
|
+
result.push({ prefix, suffix, pattern: name2 && !pattern2 ? safePattern(prefix) : pattern2, modifier: tryConsume("MODIFIER") || "" });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
mustConsume("END");
|
|
623
|
+
}
|
|
624
|
+
return result;
|
|
625
|
+
}
|
|
626
|
+
function compile(source) {
|
|
627
|
+
if (!source.startsWith("/")) return null;
|
|
628
|
+
try {
|
|
629
|
+
const tokens = parse2(source);
|
|
630
|
+
let route = "^";
|
|
631
|
+
for (const token of tokens) {
|
|
632
|
+
if (typeof token === "string") {
|
|
633
|
+
route += escape(token);
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
if (!token.pattern) {
|
|
637
|
+
route += `(?:${escape(token.prefix)}${escape(token.suffix)})${token.modifier}`;
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
const repeat = token.modifier === "*" || token.modifier === "+";
|
|
641
|
+
const prefix = escape(prefixOf(token));
|
|
642
|
+
const suffix = escape(token.suffix);
|
|
643
|
+
if (!prefix && !suffix) {
|
|
644
|
+
route += `(${token.pattern})${token.modifier}`;
|
|
645
|
+
} else if (repeat) {
|
|
646
|
+
route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${token.modifier === "*" ? "?" : ""}`;
|
|
647
|
+
} else {
|
|
648
|
+
route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
regexp: new RegExp(`${route}[${escape(DELIMITER)}]?$`),
|
|
653
|
+
tokens,
|
|
654
|
+
literals: tokens.filter((token) => typeof token === "string"),
|
|
655
|
+
patterns: tokens.flatMap((token) => typeof token === "string" ? [] : [token.pattern])
|
|
656
|
+
};
|
|
657
|
+
} catch {
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
391
662
|
// src/utils/file-walker.ts
|
|
392
663
|
var DEFAULT_IGNORES = [
|
|
393
664
|
"**/node_modules/**",
|
|
@@ -417,6 +688,37 @@ var SCAN_EXTENSIONS = [
|
|
|
417
688
|
];
|
|
418
689
|
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
419
690
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
691
|
+
var MIDDLEWARE_FILES = [
|
|
692
|
+
"middleware.ts",
|
|
693
|
+
"middleware.js",
|
|
694
|
+
"src/middleware.ts",
|
|
695
|
+
"src/middleware.js",
|
|
696
|
+
"proxy.ts",
|
|
697
|
+
"proxy.js",
|
|
698
|
+
"src/proxy.ts",
|
|
699
|
+
"src/proxy.js"
|
|
700
|
+
];
|
|
701
|
+
var MIDDLEWARE_AUTH_PATTERNS = [
|
|
702
|
+
/getSession/i,
|
|
703
|
+
/getUser/i,
|
|
704
|
+
/auth\(\)/,
|
|
705
|
+
/withAuth/i,
|
|
706
|
+
/clerkMiddleware/i,
|
|
707
|
+
/authMiddleware/i,
|
|
708
|
+
/NextAuth/i,
|
|
709
|
+
/supabase.*auth/i,
|
|
710
|
+
/createMiddlewareClient/i,
|
|
711
|
+
/getToken/i,
|
|
712
|
+
/verifyToken/i,
|
|
713
|
+
/jwt/i,
|
|
714
|
+
/updateSession/i,
|
|
715
|
+
// Auth.js: export { auth as proxy } from '@/auth', export default auth((req) => …)
|
|
716
|
+
/\bauth\s+as\s+(?:proxy|middleware|default)\b/,
|
|
717
|
+
/\bauth\(/,
|
|
718
|
+
// NextAuth v4: export { default } from 'next-auth/middleware'
|
|
719
|
+
/['"]next-auth\/middleware['"]/
|
|
720
|
+
];
|
|
721
|
+
var MIDDLEWARE_RATE_LIMIT_PATTERNS = [/rateLimit/i, /throttle/i];
|
|
420
722
|
async function walkFiles(root, extraIgnores = []) {
|
|
421
723
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
422
724
|
patterns.push("**/.env", "**/.env.*");
|
|
@@ -440,7 +742,8 @@ async function readFileContext(root, relativePath) {
|
|
|
440
742
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
441
743
|
const content = await readFile(absolutePath, "utf-8");
|
|
442
744
|
const lines = content.split(/\r?\n|\r/);
|
|
443
|
-
const
|
|
745
|
+
const rawExt = extname(relativePath).slice(1);
|
|
746
|
+
const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
|
|
444
747
|
let ast = void 0;
|
|
445
748
|
if (AST_EXTENSIONS.has(ext)) {
|
|
446
749
|
try {
|
|
@@ -554,6 +857,8 @@ async function buildProjectContext(root, files) {
|
|
|
554
857
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
555
858
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
556
859
|
let hasAuthMiddleware = false;
|
|
860
|
+
let authMiddlewareExcludesApi = false;
|
|
861
|
+
let authMiddlewareExcludesPages = false;
|
|
557
862
|
let hasRateLimiting = false;
|
|
558
863
|
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
559
864
|
let gitignoreContent = null;
|
|
@@ -605,45 +910,19 @@ async function buildProjectContext(root, files) {
|
|
|
605
910
|
}
|
|
606
911
|
} catch {
|
|
607
912
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
/getUser/i,
|
|
615
|
-
/auth\(\)/,
|
|
616
|
-
/withAuth/i,
|
|
617
|
-
/clerkMiddleware/i,
|
|
618
|
-
/authMiddleware/i,
|
|
619
|
-
/NextAuth/i,
|
|
620
|
-
/supabase.*auth/i,
|
|
621
|
-
/createMiddlewareClient/i,
|
|
622
|
-
/getToken/i,
|
|
623
|
-
/verifyToken/i,
|
|
624
|
-
/jwt/i,
|
|
625
|
-
/updateSession/i
|
|
626
|
-
];
|
|
627
|
-
if (authPatterns.some((p) => p.test(content))) {
|
|
628
|
-
hasAuthMiddleware = true;
|
|
629
|
-
break;
|
|
630
|
-
}
|
|
631
|
-
} catch {
|
|
632
|
-
}
|
|
913
|
+
for (const name of MIDDLEWARE_FILES) {
|
|
914
|
+
let content;
|
|
915
|
+
try {
|
|
916
|
+
content = await readFile(resolve(root, name), "utf-8");
|
|
917
|
+
} catch {
|
|
918
|
+
continue;
|
|
633
919
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
try {
|
|
639
|
-
const content = await readFile(resolve(root, name), "utf-8");
|
|
640
|
-
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
641
|
-
hasRateLimiting = true;
|
|
642
|
-
break;
|
|
643
|
-
}
|
|
644
|
-
} catch {
|
|
645
|
-
}
|
|
920
|
+
if (MIDDLEWARE_AUTH_PATTERNS.some((p) => p.test(content))) {
|
|
921
|
+
authMiddlewareExcludesApi = (!hasAuthMiddleware || authMiddlewareExcludesApi) && matcherExcludesApi(content, name);
|
|
922
|
+
authMiddlewareExcludesPages = (!hasAuthMiddleware || authMiddlewareExcludesPages) && matcherExcludesPages(content, name);
|
|
923
|
+
hasAuthMiddleware = true;
|
|
646
924
|
}
|
|
925
|
+
if (MIDDLEWARE_RATE_LIMIT_PATTERNS.some((p) => p.test(content))) hasRateLimiting = true;
|
|
647
926
|
}
|
|
648
927
|
try {
|
|
649
928
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
@@ -656,6 +935,8 @@ async function buildProjectContext(root, files) {
|
|
|
656
935
|
declaredDependencies,
|
|
657
936
|
tsconfigPaths,
|
|
658
937
|
hasAuthMiddleware,
|
|
938
|
+
authMiddlewareExcludesApi,
|
|
939
|
+
authMiddlewareExcludesPages,
|
|
659
940
|
hasRateLimiting,
|
|
660
941
|
detectedFrameworks,
|
|
661
942
|
gitignoreContent,
|
|
@@ -746,12 +1027,21 @@ function summarizeFindings(findings) {
|
|
|
746
1027
|
}
|
|
747
1028
|
|
|
748
1029
|
// src/rules/secrets.ts
|
|
1030
|
+
function nameJwt(token) {
|
|
1031
|
+
try {
|
|
1032
|
+
const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
|
|
1033
|
+
if (claims?.role === "anon") return null;
|
|
1034
|
+
if (claims?.role === "service_role") return "Supabase service role key";
|
|
1035
|
+
} catch {
|
|
1036
|
+
}
|
|
1037
|
+
return "JWT";
|
|
1038
|
+
}
|
|
749
1039
|
var SECRET_PATTERNS = [
|
|
750
1040
|
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
751
1041
|
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
752
1042
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
753
1043
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
754
|
-
{ name: "
|
|
1044
|
+
{ name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
|
|
755
1045
|
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
756
1046
|
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
757
1047
|
{ name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
|
|
@@ -786,15 +1076,17 @@ var secretsRule = {
|
|
|
786
1076
|
for (let i = 0; i < file.lines.length; i++) {
|
|
787
1077
|
const line = file.lines[i];
|
|
788
1078
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
789
|
-
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
1079
|
+
for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
|
|
790
1080
|
const match = pattern.exec(line);
|
|
791
1081
|
if (match) {
|
|
1082
|
+
const reported = nameMatch ? nameMatch(match[0]) : name;
|
|
1083
|
+
if (!reported) continue;
|
|
792
1084
|
findings.push({
|
|
793
1085
|
ruleId: "secrets",
|
|
794
1086
|
file: file.relativePath,
|
|
795
1087
|
line: i + 1,
|
|
796
1088
|
column: match.index + 1,
|
|
797
|
-
message: `Hardcoded ${
|
|
1089
|
+
message: `Hardcoded ${reported} detected`,
|
|
798
1090
|
severity: "critical",
|
|
799
1091
|
category: "security",
|
|
800
1092
|
fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
|
|
@@ -950,12 +1242,217 @@ var AUTH_PATTERNS = [
|
|
|
950
1242
|
/createRouteHandlerClient/,
|
|
951
1243
|
/createServerComponentClient/,
|
|
952
1244
|
/createMiddlewareClient/,
|
|
953
|
-
|
|
1245
|
+
// Reading the request's Authorization header, not sending one upstream:
|
|
1246
|
+
// headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
|
|
1247
|
+
/\.(?:get|header)\(\s*['"`]authorization['"`]/i,
|
|
1248
|
+
/\.headers\??\.authorization\b(?!\s*=(?!=))/i,
|
|
1249
|
+
/\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
|
|
954
1250
|
/getAuth\s*\(/,
|
|
955
1251
|
/withPageAuth/,
|
|
956
1252
|
/cookies\(\).*auth/s
|
|
957
1253
|
];
|
|
958
|
-
var
|
|
1254
|
+
var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1255
|
+
var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
1256
|
+
var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
|
|
1257
|
+
function servedUnderApi(relativePath) {
|
|
1258
|
+
const pages = /(?:^|\/)pages\/api\/(.+)$/.exec(relativePath);
|
|
1259
|
+
if (pages) return !/^index\.\w+$/.test(pages[1]);
|
|
1260
|
+
const segments = relativePath.split("/");
|
|
1261
|
+
const app = segments.indexOf("app");
|
|
1262
|
+
if (app === -1) return false;
|
|
1263
|
+
const url = segments.slice(app + 1, -1).filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@"));
|
|
1264
|
+
return url[0] === "api" && url.length > 1;
|
|
1265
|
+
}
|
|
1266
|
+
var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
|
|
1267
|
+
var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
|
|
1268
|
+
function words(name) {
|
|
1269
|
+
return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
|
|
1270
|
+
}
|
|
1271
|
+
function saysAuth(name) {
|
|
1272
|
+
const w = words(name);
|
|
1273
|
+
return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
|
|
1274
|
+
}
|
|
1275
|
+
function isAuthHelper(name, source) {
|
|
1276
|
+
if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
|
|
1277
|
+
return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
|
|
1278
|
+
}
|
|
1279
|
+
var AUTHED = { authed: true, local: true };
|
|
1280
|
+
var LOCAL = { authed: false, local: true };
|
|
1281
|
+
var ELSEWHERE = { authed: false, local: false };
|
|
1282
|
+
function handlersFromAst(program) {
|
|
1283
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1284
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1285
|
+
for (const st of program.body) {
|
|
1286
|
+
if (st.type === "ImportDeclaration") {
|
|
1287
|
+
for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
|
|
1288
|
+
continue;
|
|
1289
|
+
}
|
|
1290
|
+
const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
|
|
1291
|
+
if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
|
|
1292
|
+
if (decl?.type === "VariableDeclaration") {
|
|
1293
|
+
for (const d of decl.declarations) {
|
|
1294
|
+
if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
const helperOf = (callee) => {
|
|
1299
|
+
if (callee.type === "CallExpression") return helperOf(callee.callee);
|
|
1300
|
+
if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
|
|
1301
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
1302
|
+
let root = callee.object;
|
|
1303
|
+
while (root.type === "MemberExpression") root = root.object;
|
|
1304
|
+
return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
|
|
1305
|
+
}
|
|
1306
|
+
return null;
|
|
1307
|
+
};
|
|
1308
|
+
const classify = (node, seen) => {
|
|
1309
|
+
while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
|
|
1310
|
+
node = node.type === "AwaitExpression" ? node.argument : node.expression;
|
|
1311
|
+
}
|
|
1312
|
+
switch (node.type) {
|
|
1313
|
+
case "FunctionDeclaration":
|
|
1314
|
+
case "FunctionExpression":
|
|
1315
|
+
case "ArrowFunctionExpression":
|
|
1316
|
+
return LOCAL;
|
|
1317
|
+
case "Identifier": {
|
|
1318
|
+
const bound = bindings.get(node.name);
|
|
1319
|
+
if (!bound || seen.has(node.name)) return ELSEWHERE;
|
|
1320
|
+
return classify(bound, new Set(seen).add(node.name));
|
|
1321
|
+
}
|
|
1322
|
+
case "CallExpression": {
|
|
1323
|
+
const helper = helperOf(node.callee);
|
|
1324
|
+
if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
|
|
1325
|
+
let local = false;
|
|
1326
|
+
for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
|
|
1327
|
+
const verdict = classify(arg, seen);
|
|
1328
|
+
if (verdict.authed) return verdict;
|
|
1329
|
+
local ||= verdict.local;
|
|
1330
|
+
}
|
|
1331
|
+
return { authed: false, local };
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return ELSEWHERE;
|
|
1335
|
+
};
|
|
1336
|
+
const handlers = [];
|
|
1337
|
+
const add = (method, at, verdict) => {
|
|
1338
|
+
handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
|
|
1339
|
+
};
|
|
1340
|
+
for (const st of program.body) {
|
|
1341
|
+
if (st.type === "ExportNamedDeclaration") {
|
|
1342
|
+
const decl = st.declaration;
|
|
1343
|
+
if (decl?.type === "FunctionDeclaration") {
|
|
1344
|
+
if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
|
|
1345
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
1346
|
+
for (const d of decl.declarations) {
|
|
1347
|
+
if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
|
|
1348
|
+
add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
|
|
1349
|
+
} else if (d.id.type === "ObjectPattern") {
|
|
1350
|
+
for (const p of d.id.properties) {
|
|
1351
|
+
if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
|
|
1352
|
+
add(p.value.name, d, ELSEWHERE);
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
} else if (!decl) {
|
|
1358
|
+
for (const s of st.specifiers) {
|
|
1359
|
+
if (s.type !== "ExportSpecifier") continue;
|
|
1360
|
+
const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
|
|
1361
|
+
if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
} else if (st.type === "ExportDefaultDeclaration") {
|
|
1365
|
+
add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
|
|
1366
|
+
} else if (st.type === "ExportAllDeclaration") {
|
|
1367
|
+
add("*", st, ELSEWHERE);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
return handlers;
|
|
1371
|
+
}
|
|
1372
|
+
var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
|
|
1373
|
+
var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
|
|
1374
|
+
var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1375
|
+
var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
|
|
1376
|
+
var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
|
|
1377
|
+
var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
|
|
1378
|
+
var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
|
|
1379
|
+
var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
|
|
1380
|
+
var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
|
|
1381
|
+
var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
|
|
1382
|
+
var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
|
|
1383
|
+
function handlersFromText(file) {
|
|
1384
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1385
|
+
for (const m of file.content.matchAll(IMPORT)) {
|
|
1386
|
+
for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
|
|
1387
|
+
const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1388
|
+
if (spec) imports.set(spec[2] ?? spec[1], m[2]);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
|
|
1392
|
+
const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
|
|
1393
|
+
const resolve4 = (name, seen) => {
|
|
1394
|
+
const id = name.replace(/\$/g, "\\$");
|
|
1395
|
+
const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
|
|
1396
|
+
const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1397
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1398
|
+
if (!code(i)) continue;
|
|
1399
|
+
if (fn.test(file.lines[i])) return LOCAL;
|
|
1400
|
+
const m = variable.exec(file.lines[i]);
|
|
1401
|
+
if (m) return classify(value(i, m[1]), seen);
|
|
1402
|
+
}
|
|
1403
|
+
return ELSEWHERE;
|
|
1404
|
+
};
|
|
1405
|
+
const classify = (text, seen) => {
|
|
1406
|
+
const t = text.trim().replace(/^await\s+/, "");
|
|
1407
|
+
if (INLINE_FUNCTION.test(t)) return LOCAL;
|
|
1408
|
+
const call = CALL.exec(t);
|
|
1409
|
+
if (call) {
|
|
1410
|
+
if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
|
|
1411
|
+
let args = t.slice(call[0].length);
|
|
1412
|
+
const curried = /^[^()]*\)\s*\(/.exec(args);
|
|
1413
|
+
if (curried) args = args.slice(curried[0].length);
|
|
1414
|
+
return classify(args, seen);
|
|
1415
|
+
}
|
|
1416
|
+
const id = IDENTIFIER.exec(t);
|
|
1417
|
+
if (id && !seen.has(id[1])) return resolve4(id[1], new Set(seen).add(id[1]));
|
|
1418
|
+
return ELSEWHERE;
|
|
1419
|
+
};
|
|
1420
|
+
const handlers = [];
|
|
1421
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1422
|
+
if (!code(i)) continue;
|
|
1423
|
+
const line = file.lines[i];
|
|
1424
|
+
let m;
|
|
1425
|
+
if (m = EXPORT_FUNCTION.exec(line)) {
|
|
1426
|
+
handlers.push({ method: m[1], line: i + 1, ...LOCAL });
|
|
1427
|
+
} else if (m = EXPORT_CONST.exec(line)) {
|
|
1428
|
+
handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
|
|
1429
|
+
} else if (m = EXPORT_DESTRUCTURED.exec(line)) {
|
|
1430
|
+
for (const part of m[1].split(",")) {
|
|
1431
|
+
const name = part.split(":").pop()?.trim() ?? "";
|
|
1432
|
+
if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
|
|
1433
|
+
}
|
|
1434
|
+
} else if (m = EXPORT_DEFAULT.exec(line)) {
|
|
1435
|
+
handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
|
|
1436
|
+
} else if (EXPORT_ALL.test(line)) {
|
|
1437
|
+
handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
|
|
1441
|
+
for (const m of file.content.matchAll(EXPORT_LIST)) {
|
|
1442
|
+
if (!code(lineAt(m.index) - 1)) continue;
|
|
1443
|
+
let offset = m.index + m[0].indexOf("{") + 1;
|
|
1444
|
+
for (const part of m[1].split(",")) {
|
|
1445
|
+
const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1446
|
+
const name = spec ? spec[2] ?? spec[1] : "";
|
|
1447
|
+
if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
|
|
1448
|
+
const line = lineAt(offset + part.length - part.trimStart().length);
|
|
1449
|
+
handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
|
|
1450
|
+
}
|
|
1451
|
+
offset += part.length + 1;
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return handlers.sort((a, b) => a.line - b.line);
|
|
1455
|
+
}
|
|
959
1456
|
var authChecksRule = {
|
|
960
1457
|
id: "auth-checks",
|
|
961
1458
|
name: "Missing Auth Checks",
|
|
@@ -971,22 +1468,21 @@ var authChecksRule = {
|
|
|
971
1468
|
for (const pattern of AUTH_PATTERNS) {
|
|
972
1469
|
if (pattern.test(file.content)) return [];
|
|
973
1470
|
}
|
|
1471
|
+
const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
|
|
1472
|
+
const exposed = handlers.filter((h) => !h.authed);
|
|
1473
|
+
if (handlers.length > 0 && exposed.length === 0) return [];
|
|
1474
|
+
const middlewareSkipsRoute = project.hasAuthMiddleware && project.authMiddlewareExcludesApi === true && servedUnderApi(file.relativePath);
|
|
1475
|
+
const middlewareAuth = project.hasAuthMiddleware && !middlewareSkipsRoute;
|
|
974
1476
|
let severity;
|
|
975
|
-
if (
|
|
1477
|
+
if (middlewareAuth) {
|
|
976
1478
|
severity = "info";
|
|
977
|
-
} else if (
|
|
1479
|
+
} else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
|
|
978
1480
|
severity = "critical";
|
|
979
1481
|
} else {
|
|
980
1482
|
severity = "info";
|
|
981
1483
|
}
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
985
|
-
handlerLine = i + 1;
|
|
986
|
-
break;
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
|
|
1484
|
+
const handlerLine = exposed[0]?.line ?? 1;
|
|
1485
|
+
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";
|
|
990
1486
|
return [{
|
|
991
1487
|
ruleId: "auth-checks",
|
|
992
1488
|
file: file.relativePath,
|
|
@@ -1104,7 +1600,8 @@ var errorHandlingRule = {
|
|
|
1104
1600
|
|
|
1105
1601
|
// src/rules/input-validation.ts
|
|
1106
1602
|
var VALIDATION_PATTERNS = [
|
|
1107
|
-
|
|
1603
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
1604
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
1108
1605
|
/\.safeParse\s*\(/,
|
|
1109
1606
|
/\.validate\s*\(/,
|
|
1110
1607
|
/\.validateSync\s*\(/,
|
|
@@ -1405,6 +1902,40 @@ var aiSmellsRule = {
|
|
|
1405
1902
|
};
|
|
1406
1903
|
|
|
1407
1904
|
// src/rules/unsafe-html.ts
|
|
1905
|
+
var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
|
|
1906
|
+
var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
|
|
1907
|
+
function isSanitizerCall(node) {
|
|
1908
|
+
if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
|
|
1909
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
|
|
1910
|
+
return isSanitizerCall(node.expression);
|
|
1911
|
+
}
|
|
1912
|
+
if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
|
|
1913
|
+
const callee = node.callee;
|
|
1914
|
+
if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
|
|
1915
|
+
return callee.property.type === "Identifier" && callee.property.name === "sanitize";
|
|
1916
|
+
}
|
|
1917
|
+
return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
|
|
1918
|
+
}
|
|
1919
|
+
function containsJsonStringify(node) {
|
|
1920
|
+
return subtreeContains(node, (n) => {
|
|
1921
|
+
if (n.type !== "CallExpression") return false;
|
|
1922
|
+
const call = n;
|
|
1923
|
+
if (call.callee.type === "MemberExpression") {
|
|
1924
|
+
const mem = call.callee;
|
|
1925
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1926
|
+
}
|
|
1927
|
+
return false;
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
function isSanitized(value, sanitizedNames) {
|
|
1931
|
+
if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
|
|
1932
|
+
if (value.type === "ObjectExpression") {
|
|
1933
|
+
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"));
|
|
1934
|
+
return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
|
|
1935
|
+
}
|
|
1936
|
+
if (value.type === "Identifier") return sanitizedNames.has(value.name);
|
|
1937
|
+
return isSanitizerCall(value);
|
|
1938
|
+
}
|
|
1408
1939
|
var unsafeHtmlRule = {
|
|
1409
1940
|
id: "unsafe-html",
|
|
1410
1941
|
name: "Unsafe HTML Rendering",
|
|
@@ -1416,21 +1947,17 @@ var unsafeHtmlRule = {
|
|
|
1416
1947
|
const findings = [];
|
|
1417
1948
|
if (file.ast) {
|
|
1418
1949
|
try {
|
|
1950
|
+
const sanitizedNames = /* @__PURE__ */ new Set();
|
|
1951
|
+
walkAST(file.ast.program, (node) => {
|
|
1952
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
|
|
1953
|
+
sanitizedNames.add(node.id.name);
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1419
1956
|
walkAST(file.ast.program, (node) => {
|
|
1420
1957
|
if (node.type === "JSXAttribute") {
|
|
1421
1958
|
const attr = node;
|
|
1422
1959
|
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1423
|
-
if (attr.value &&
|
|
1424
|
-
if (n.type !== "CallExpression") return false;
|
|
1425
|
-
const call = n;
|
|
1426
|
-
if (call.callee.type === "MemberExpression") {
|
|
1427
|
-
const mem = call.callee;
|
|
1428
|
-
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1429
|
-
}
|
|
1430
|
-
return false;
|
|
1431
|
-
})) {
|
|
1432
|
-
return;
|
|
1433
|
-
}
|
|
1960
|
+
if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
|
|
1434
1961
|
const line = attr.loc.start.line;
|
|
1435
1962
|
findings.push({
|
|
1436
1963
|
ruleId: "unsafe-html",
|
|
@@ -1447,17 +1974,7 @@ var unsafeHtmlRule = {
|
|
|
1447
1974
|
if (node.type === "ObjectProperty") {
|
|
1448
1975
|
const prop = node;
|
|
1449
1976
|
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1450
|
-
if (prop.value &&
|
|
1451
|
-
if (n.type !== "CallExpression") return false;
|
|
1452
|
-
const call = n;
|
|
1453
|
-
if (call.callee.type === "MemberExpression") {
|
|
1454
|
-
const mem = call.callee;
|
|
1455
|
-
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1456
|
-
}
|
|
1457
|
-
return false;
|
|
1458
|
-
})) {
|
|
1459
|
-
return;
|
|
1460
|
-
}
|
|
1977
|
+
if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
|
|
1461
1978
|
const line = prop.loc.start.line;
|
|
1462
1979
|
findings.push({
|
|
1463
1980
|
ruleId: "unsafe-html",
|
|
@@ -1474,6 +1991,7 @@ var unsafeHtmlRule = {
|
|
|
1474
1991
|
if (node.type === "AssignmentExpression") {
|
|
1475
1992
|
const assign = node;
|
|
1476
1993
|
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1994
|
+
if (isSanitized(assign.right, sanitizedNames)) return;
|
|
1477
1995
|
const line = assign.loc.start.line;
|
|
1478
1996
|
findings.push({
|
|
1479
1997
|
ruleId: "unsafe-html",
|
|
@@ -1504,6 +2022,8 @@ var unsafeHtmlRule = {
|
|
|
1504
2022
|
}
|
|
1505
2023
|
const expr = context.join(" ");
|
|
1506
2024
|
if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
|
|
2025
|
+
const html = /__html\s*:/.exec(expr);
|
|
2026
|
+
if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
|
|
1507
2027
|
findings.push({
|
|
1508
2028
|
ruleId: "unsafe-html",
|
|
1509
2029
|
file: file.relativePath,
|
|
@@ -1515,7 +2035,8 @@ var unsafeHtmlRule = {
|
|
|
1515
2035
|
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1516
2036
|
});
|
|
1517
2037
|
}
|
|
1518
|
-
|
|
2038
|
+
const innerHtml = /\w\.innerHTML\s*=/.exec(line);
|
|
2039
|
+
if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
|
|
1519
2040
|
findings.push({
|
|
1520
2041
|
ruleId: "unsafe-html",
|
|
1521
2042
|
file: file.relativePath,
|
|
@@ -1694,9 +2215,16 @@ var staleFallbackRule = {
|
|
|
1694
2215
|
};
|
|
1695
2216
|
|
|
1696
2217
|
// src/rules/hallucinated-api.ts
|
|
2218
|
+
var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
|
|
2219
|
+
var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
|
|
1697
2220
|
var HALLUCINATED_APIS = [
|
|
1698
2221
|
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1699
|
-
{
|
|
2222
|
+
{
|
|
2223
|
+
pattern: /\.contains\s*\(/,
|
|
2224
|
+
fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
|
|
2225
|
+
methodName: "contains",
|
|
2226
|
+
isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
|
|
2227
|
+
},
|
|
1700
2228
|
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1701
2229
|
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1702
2230
|
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
@@ -1718,10 +2246,11 @@ var hallucinatedApiRule = {
|
|
|
1718
2246
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1719
2247
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1720
2248
|
const line = file.lines[i];
|
|
1721
|
-
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
2249
|
+
for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
|
|
1722
2250
|
const match = pattern.exec(line);
|
|
1723
2251
|
if (match) {
|
|
1724
2252
|
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
2253
|
+
if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
|
|
1725
2254
|
findings.push({
|
|
1726
2255
|
ruleId: "hallucinated-api",
|
|
1727
2256
|
file: file.relativePath,
|
|
@@ -2357,9 +2886,190 @@ var codebaseConsistencyRule = {
|
|
|
2357
2886
|
};
|
|
2358
2887
|
|
|
2359
2888
|
// src/rules/dead-exports.ts
|
|
2360
|
-
function isEntryPoint(relativePath) {
|
|
2889
|
+
function isEntryPoint(relativePath, appRoots, routeModules) {
|
|
2361
2890
|
const name = relativePath.split("/").pop() ?? "";
|
|
2362
|
-
return /^(page|layout|loading|error|not-found|route|
|
|
2891
|
+
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
|
|
2892
|
+
/^\+(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
|
|
2893
|
+
// export as a story
|
|
2894
|
+
/\.stories\.(tsx?|jsx?|mjs)$/.test(name) || isNextRootFile(relativePath, appRoots) || isPagesRoute(relativePath, appRoots) || routeModules.has(relativePath);
|
|
2895
|
+
}
|
|
2896
|
+
function isBarrel(relativePath) {
|
|
2897
|
+
return /(?:^|\/)index\.(tsx?|jsx?)$/.test(relativePath);
|
|
2898
|
+
}
|
|
2899
|
+
function isAppFolder(dir, appRoots) {
|
|
2900
|
+
return appRoots.has(dir) || appRoots.has(dir.replace(/(?:^|\/)src$/, ""));
|
|
2901
|
+
}
|
|
2902
|
+
function isNextRootFile(relativePath, appRoots) {
|
|
2903
|
+
const match = /^(?:(.*)\/)?(?:middleware|proxy|instrumentation|instrumentation-client)\.(tsx?|jsx?)$/.exec(relativePath);
|
|
2904
|
+
return match !== null && isAppFolder(match[1] ?? "", appRoots);
|
|
2905
|
+
}
|
|
2906
|
+
function isPagesRoute(relativePath, appRoots) {
|
|
2907
|
+
if (!/\.(tsx?|jsx?)$/.test(relativePath)) return false;
|
|
2908
|
+
const parts = relativePath.split("/");
|
|
2909
|
+
return parts.some((part, i) => part === "pages" && isAppFolder(parts.slice(0, i).join("/"), appRoots));
|
|
2910
|
+
}
|
|
2911
|
+
function findAppRoots(paths) {
|
|
2912
|
+
const roots = /* @__PURE__ */ new Set([""]);
|
|
2913
|
+
for (const path of paths) {
|
|
2914
|
+
const match = /^(?:(.*)\/)?package\.json$/.exec(path);
|
|
2915
|
+
if (match) roots.add(match[1] ?? "");
|
|
2916
|
+
}
|
|
2917
|
+
return roots;
|
|
2918
|
+
}
|
|
2919
|
+
function findRouteModules(files, paths, appRoots) {
|
|
2920
|
+
const appFolders = [];
|
|
2921
|
+
for (const path of paths) {
|
|
2922
|
+
const match = /^(?:(.*)\/)?app\/root\.(tsx?|jsx?)$/.exec(path);
|
|
2923
|
+
if (match && appRoots.has(match[1] ?? "")) appFolders.push(path.slice(0, path.lastIndexOf("/")));
|
|
2924
|
+
}
|
|
2925
|
+
const modules = /* @__PURE__ */ new Set();
|
|
2926
|
+
for (const { relativePath, content } of files) {
|
|
2927
|
+
const folder = appFolders.find((f) => relativePath.startsWith(`${f}/`));
|
|
2928
|
+
if (folder === void 0) continue;
|
|
2929
|
+
const path = relativePath.slice(folder.length + 1);
|
|
2930
|
+
if (/^(?:root|entry\.server|routes\/.+)\.(tsx?|jsx?)$/.test(path)) {
|
|
2931
|
+
modules.add(relativePath);
|
|
2932
|
+
} else if (/^routes\.(?:[cm]?[jt]s|[jt]sx)$/.test(path)) {
|
|
2933
|
+
for (const match of content.matchAll(/['"`]([^'"`\n]+\.(?:tsx?|jsx?))['"`]/g)) {
|
|
2934
|
+
const module = normalizePath(`${folder}/${match[1]}`);
|
|
2935
|
+
if (module !== null) modules.add(module);
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
}
|
|
2939
|
+
return modules;
|
|
2940
|
+
}
|
|
2941
|
+
var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2942
|
+
var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2943
|
+
var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
|
|
2944
|
+
var DYNAMIC_IMPORT = /(\btypeof\s+)?(?:\{([^{}]*)\}\s*=\s*await\s+)?\bimport\s*\(\s*['"`]([^'"`]+)['"`]\s*\)(?:\s*\.\s*([\w$]+))?/g;
|
|
2945
|
+
var REQUIRE = /(?:\{([^{}]*)\}\s*=\s*)?\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([\w$]+))?/g;
|
|
2946
|
+
var REEXPORT_NAMED = /\bexport\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2947
|
+
var REEXPORT_ALL = /\bexport\s*\*\s*(?:as\s+([\w$]+)\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2948
|
+
var MODULE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
|
|
2949
|
+
var MODULE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
2950
|
+
function parseSpecifiers(list) {
|
|
2951
|
+
return list.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "")).filter(Boolean).map((s) => {
|
|
2952
|
+
const [name, alias] = s.split(/\s+as\s+/).map((part) => part.trim());
|
|
2953
|
+
return [name, alias ?? name];
|
|
2954
|
+
});
|
|
2955
|
+
}
|
|
2956
|
+
function normalizePath(path) {
|
|
2957
|
+
const parts = [];
|
|
2958
|
+
for (const part of path.split("/")) {
|
|
2959
|
+
if (part === "..") {
|
|
2960
|
+
if (parts.length === 0) return null;
|
|
2961
|
+
parts.pop();
|
|
2962
|
+
} else if (part && part !== ".") {
|
|
2963
|
+
parts.push(part);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
return parts.join("/");
|
|
2967
|
+
}
|
|
2968
|
+
var EXTERNAL = { files: [], folders: [] };
|
|
2969
|
+
var UNKNOWN = { files: [], folders: [""] };
|
|
2970
|
+
function isAsset(source) {
|
|
2971
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
2972
|
+
return /^[^.].*\.\w+$/.test(name) && !MODULE_EXTENSION.test(name);
|
|
2973
|
+
}
|
|
2974
|
+
function stringsIn(value) {
|
|
2975
|
+
if (typeof value === "string") return [value];
|
|
2976
|
+
if (Array.isArray(value)) return value.flatMap(stringsIn);
|
|
2977
|
+
if (value && typeof value === "object") return Object.values(value).flatMap(stringsIn);
|
|
2978
|
+
return [];
|
|
2979
|
+
}
|
|
2980
|
+
function createResolver(files, project) {
|
|
2981
|
+
const modules = new Set(files.filter((f) => MODULE_EXTENSIONS.includes(f.ext)).map((f) => f.relativePath));
|
|
2982
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2983
|
+
for (const path of modules) {
|
|
2984
|
+
const key = path.replace(/\.\w+$/, "").replace(/(?:^|\/)index$/, "");
|
|
2985
|
+
const name = key.slice(key.lastIndexOf("/") + 1);
|
|
2986
|
+
const list = byName.get(name);
|
|
2987
|
+
if (list) list.push({ path, key });
|
|
2988
|
+
else byName.set(name, [{ path, key }]);
|
|
2989
|
+
}
|
|
2990
|
+
const manifests = [];
|
|
2991
|
+
for (const file of files) {
|
|
2992
|
+
if (!/(?:^|\/)package\.json$/.test(file.relativePath)) continue;
|
|
2993
|
+
try {
|
|
2994
|
+
const json = JSON.parse(file.content);
|
|
2995
|
+
if (json && typeof json === "object") manifests.push({ folder: file.relativePath.replace(/\/?package\.json$/, ""), json });
|
|
2996
|
+
} catch {
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
const packages = /* @__PURE__ */ new Map();
|
|
3000
|
+
for (const { folder, json } of manifests) {
|
|
3001
|
+
if (typeof json.name === "string" && json.name) packages.set(json.name, [...packages.get(json.name) ?? [], folder]);
|
|
3002
|
+
}
|
|
3003
|
+
const findModule = (path) => {
|
|
3004
|
+
const stem = path.replace(/\.(?:[cm]?js|jsx)$/, "");
|
|
3005
|
+
const index = path ? `${path}/index` : "index";
|
|
3006
|
+
return [
|
|
3007
|
+
path,
|
|
3008
|
+
// TypeScript's ESM style names the compiled file: ./format.js for format.ts
|
|
3009
|
+
...stem === path ? [] : [`${stem}.ts`, `${stem}.tsx`],
|
|
3010
|
+
...MODULE_EXTENSIONS.map((ext) => `${path}.${ext}`),
|
|
3011
|
+
...MODULE_EXTENSIONS.map((ext) => `${index}.${ext}`)
|
|
3012
|
+
].find((candidate) => modules.has(candidate));
|
|
3013
|
+
};
|
|
3014
|
+
const packageEntries = /* @__PURE__ */ new Set();
|
|
3015
|
+
for (const { folder, json } of manifests) {
|
|
3016
|
+
if (json.private === true) continue;
|
|
3017
|
+
for (const target of stringsIn([json.main, json.module, json.types, json.typings, json.exports, json.bin])) {
|
|
3018
|
+
const isSource = /\.(?:[cm]?ts|tsx)$/.test(target) && !/\.d\.[cm]?ts$/.test(target);
|
|
3019
|
+
const path = isSource || target.includes("*") ? null : normalizePath(`${folder}/${target}`);
|
|
3020
|
+
if (path === null) continue;
|
|
3021
|
+
const stem = path.replace(/\.d\.ts$/, "").replace(MODULE_EXTENSION, "");
|
|
3022
|
+
const entry = findModule(path) ?? findModule(stem) ?? findModule(stem.replace(/(^|\/)(?:dist|build|lib|out|esm|cjs)\//, "$1src/"));
|
|
3023
|
+
if (entry) packageEntries.add(entry);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
const resolveRelative = (path) => {
|
|
3027
|
+
const normalized = normalizePath(path);
|
|
3028
|
+
if (normalized?.includes("${")) {
|
|
3029
|
+
const parts = normalized.split(/\$\{[^}]*\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
3030
|
+
const pattern = new RegExp(`^${parts.join(".*")}(?:\\.\\w+)?$`);
|
|
3031
|
+
return { files: [...modules].filter((m) => pattern.test(m)), folders: [] };
|
|
3032
|
+
}
|
|
3033
|
+
const module = normalized === null ? void 0 : findModule(normalized);
|
|
3034
|
+
if (module) return { files: [module], folders: [] };
|
|
3035
|
+
return isAsset(path) ? EXTERNAL : UNKNOWN;
|
|
3036
|
+
};
|
|
3037
|
+
const resolveBare = (source) => {
|
|
3038
|
+
if (/^[a-z][\w+.-]*:/i.test(source)) return EXTERNAL;
|
|
3039
|
+
const name = source.startsWith("@") ? source.split("/").slice(0, 2).join("/") : source.split("/")[0];
|
|
3040
|
+
const folders = packages.get(name);
|
|
3041
|
+
if (folders) {
|
|
3042
|
+
const subpath = source.slice(name.length + 1);
|
|
3043
|
+
const found = folders.map((folder) => {
|
|
3044
|
+
const base = folder ? `${folder}/` : "";
|
|
3045
|
+
return subpath ? findModule(base + subpath) ?? findModule(`${base}src/${subpath}`) : findModule(folder) ?? findModule(`${base}src`);
|
|
3046
|
+
});
|
|
3047
|
+
return { files: found.filter((f) => f !== void 0), folders };
|
|
3048
|
+
}
|
|
3049
|
+
if (NODE_BUILTINS.has(name) || project.declaredDependencies.has(name)) return EXTERNAL;
|
|
3050
|
+
const segments = source.replace(MODULE_EXTENSION, "").split("/").filter(Boolean);
|
|
3051
|
+
const named = byName.get(segments[segments.length - 1] ?? "") ?? [];
|
|
3052
|
+
for (let i = 0; i < segments.length; i++) {
|
|
3053
|
+
const path = segments.slice(i).join("/");
|
|
3054
|
+
const matches = named.filter((m) => m.key === path || m.key.endsWith(`/${path}`));
|
|
3055
|
+
if (matches.length > 0) return i <= 1 ? { files: matches.map((m) => m.path), folders: [] } : UNKNOWN;
|
|
3056
|
+
}
|
|
3057
|
+
return isAsset(source) ? EXTERNAL : UNKNOWN;
|
|
3058
|
+
};
|
|
3059
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3060
|
+
const resolve4 = (source, from) => {
|
|
3061
|
+
const clean = source.replace(/\?.*$/, "");
|
|
3062
|
+
const relative = /^\.\.?(?:\/|$)/.test(clean);
|
|
3063
|
+
const dir = relative ? from.slice(0, from.lastIndexOf("/") + 1) : "";
|
|
3064
|
+
const key = `${dir}\0${clean}`;
|
|
3065
|
+
let target = cache.get(key);
|
|
3066
|
+
if (!target) {
|
|
3067
|
+
target = relative ? resolveRelative(dir + clean) : resolveBare(clean);
|
|
3068
|
+
cache.set(key, target);
|
|
3069
|
+
}
|
|
3070
|
+
return target;
|
|
3071
|
+
};
|
|
3072
|
+
return { resolve: resolve4, packageEntries };
|
|
2363
3073
|
}
|
|
2364
3074
|
var THRESHOLD = 5;
|
|
2365
3075
|
var deadExportsRule = {
|
|
@@ -2372,16 +3082,16 @@ var deadExportsRule = {
|
|
|
2372
3082
|
check() {
|
|
2373
3083
|
return [];
|
|
2374
3084
|
},
|
|
2375
|
-
checkProject(files,
|
|
3085
|
+
checkProject(files, project) {
|
|
2376
3086
|
const sourceFiles = files.filter(
|
|
2377
3087
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
2378
3088
|
);
|
|
3089
|
+
const appRoots = findAppRoots(project.allFiles);
|
|
3090
|
+
const routeModules = findRouteModules(files, project.allFiles, appRoots);
|
|
3091
|
+
const { resolve: resolve4, packageEntries } = createResolver(files, project);
|
|
2379
3092
|
const exports = /* @__PURE__ */ new Map();
|
|
2380
|
-
const imports = /* @__PURE__ */ new Map();
|
|
2381
|
-
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
2382
|
-
const importedFiles = /* @__PURE__ */ new Set();
|
|
2383
3093
|
for (const file of sourceFiles) {
|
|
2384
|
-
if (isEntryPoint(file.relativePath)) continue;
|
|
3094
|
+
if (isEntryPoint(file.relativePath, appRoots, routeModules) || packageEntries.has(file.relativePath)) continue;
|
|
2385
3095
|
let inTemplateLiteral = false;
|
|
2386
3096
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2387
3097
|
const line = file.lines[i];
|
|
@@ -2422,45 +3132,92 @@ var deadExportsRule = {
|
|
|
2422
3132
|
}
|
|
2423
3133
|
}
|
|
2424
3134
|
}
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2437
|
-
set.add(sym);
|
|
2438
|
-
imports.set(fromBasename, set);
|
|
2439
|
-
}
|
|
2440
|
-
}
|
|
3135
|
+
const contents = new Map(files.map((f) => [f.relativePath, f.content]));
|
|
3136
|
+
const used = /* @__PURE__ */ new Set();
|
|
3137
|
+
const usedInFolder = /* @__PURE__ */ new Map();
|
|
3138
|
+
const reexports = /* @__PURE__ */ new Map();
|
|
3139
|
+
const reexportsOf = (file) => {
|
|
3140
|
+
let list = reexports.get(file);
|
|
3141
|
+
if (!list) {
|
|
3142
|
+
list = [];
|
|
3143
|
+
const content = contents.get(file) ?? "";
|
|
3144
|
+
for (const match of content.matchAll(REEXPORT_NAMED)) {
|
|
3145
|
+
for (const [imported, exported] of parseSpecifiers(match[1])) list.push({ source: match[2], imported, exported });
|
|
2441
3146
|
}
|
|
2442
|
-
const
|
|
2443
|
-
|
|
2444
|
-
allImportedSymbols.add(match[1]);
|
|
2445
|
-
if (fromBasename) {
|
|
2446
|
-
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2447
|
-
set.add(match[1]);
|
|
2448
|
-
imports.set(fromBasename, set);
|
|
2449
|
-
}
|
|
3147
|
+
for (const match of content.matchAll(REEXPORT_ALL)) {
|
|
3148
|
+
list.push(match[1] ? { source: match[2], imported: "*", exported: match[1] } : { source: match[2], imported: "", exported: "*" });
|
|
2450
3149
|
}
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
3150
|
+
reexports.set(file, list);
|
|
3151
|
+
}
|
|
3152
|
+
return list;
|
|
3153
|
+
};
|
|
3154
|
+
const use = (target, name) => {
|
|
3155
|
+
if (name !== "*" && name !== "default") {
|
|
3156
|
+
for (const folder of target.folders) {
|
|
3157
|
+
const names = usedInFolder.get(folder) ?? /* @__PURE__ */ new Set();
|
|
3158
|
+
names.add(name);
|
|
3159
|
+
usedInFolder.set(folder, names);
|
|
2454
3160
|
}
|
|
2455
3161
|
}
|
|
3162
|
+
for (const file of target.files) {
|
|
3163
|
+
const key = `${file}::${name}`;
|
|
3164
|
+
if (used.has(key)) continue;
|
|
3165
|
+
used.add(key);
|
|
3166
|
+
for (const { source, imported, exported } of reexportsOf(file)) {
|
|
3167
|
+
if (exported === "*") {
|
|
3168
|
+
if (name !== "default") use(resolve4(source, file), name);
|
|
3169
|
+
} else if (name === "*" || name === exported) {
|
|
3170
|
+
use(resolve4(source, file), imported);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
};
|
|
3175
|
+
const useLoaded = (target, destructured, member, typeOnly) => {
|
|
3176
|
+
if (member && !["then", "catch", "finally"].includes(member)) {
|
|
3177
|
+
use(target, member);
|
|
3178
|
+
} else if (typeOnly) {
|
|
3179
|
+
return;
|
|
3180
|
+
} else if (destructured !== void 0 && !destructured.includes("...")) {
|
|
3181
|
+
for (const name of destructured.split(",").map((s) => s.split(/[:=]/)[0].trim()).filter(Boolean)) use(target, name);
|
|
3182
|
+
} else {
|
|
3183
|
+
use(target, "*");
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
for (const file of files) {
|
|
3187
|
+
const from = file.relativePath;
|
|
3188
|
+
for (const match of file.content.matchAll(IMPORT_NAMED)) {
|
|
3189
|
+
for (const [name] of parseSpecifiers(match[1])) use(resolve4(match[2], from), name);
|
|
3190
|
+
}
|
|
3191
|
+
for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
|
|
3192
|
+
const target = resolve4(match[2], from);
|
|
3193
|
+
use(target, "default");
|
|
3194
|
+
use(target, match[1]);
|
|
3195
|
+
}
|
|
3196
|
+
for (const match of file.content.matchAll(IMPORT_NAMESPACE)) use(resolve4(match[1], from), "*");
|
|
3197
|
+
for (const match of file.content.matchAll(DYNAMIC_IMPORT)) {
|
|
3198
|
+
const [, typeOnly, destructured, source, member] = match;
|
|
3199
|
+
useLoaded(resolve4(source, from), destructured, member, Boolean(typeOnly));
|
|
3200
|
+
}
|
|
3201
|
+
for (const match of file.content.matchAll(REQUIRE)) {
|
|
3202
|
+
const [, destructured, source, member] = match;
|
|
3203
|
+
useLoaded(resolve4(source, from), destructured, member, false);
|
|
3204
|
+
}
|
|
3205
|
+
if (isEntryPoint(from, appRoots, routeModules) && !isBarrel(from)) use({ files: [from], folders: [] }, "*");
|
|
2456
3206
|
}
|
|
3207
|
+
for (const entry of packageEntries) use({ files: [entry], folders: [] }, "*");
|
|
3208
|
+
const usedInAFolderOf = (file, name) => {
|
|
3209
|
+
let folder = file;
|
|
3210
|
+
while (folder) {
|
|
3211
|
+
folder = folder.slice(0, Math.max(folder.lastIndexOf("/"), 0));
|
|
3212
|
+
if (usedInFolder.get(folder)?.has(name)) return true;
|
|
3213
|
+
}
|
|
3214
|
+
return false;
|
|
3215
|
+
};
|
|
2457
3216
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
2458
3217
|
for (const [key, loc] of exports) {
|
|
2459
3218
|
const symbolName = key.split("::")[1];
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
const isImported = importSet?.has(symbolName) ?? false;
|
|
2463
|
-
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
3219
|
+
const isUsed = used.has(key) || used.has(`${loc.file}::*`) || usedInAFolderOf(loc.file, symbolName);
|
|
3220
|
+
if (!isUsed) {
|
|
2464
3221
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
2465
3222
|
}
|
|
2466
3223
|
}
|
|
@@ -2527,19 +3284,6 @@ function catchFinding(file, catchLine, body) {
|
|
|
2527
3284
|
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2528
3285
|
};
|
|
2529
3286
|
}
|
|
2530
|
-
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
2531
|
-
function blankOut(source, strings) {
|
|
2532
|
-
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
2533
|
-
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
2534
|
-
}
|
|
2535
|
-
function closingBrace(code, open) {
|
|
2536
|
-
let depth = 0;
|
|
2537
|
-
for (let i = open; i < code.length; i++) {
|
|
2538
|
-
if (code[i] === "{") depth++;
|
|
2539
|
-
else if (code[i] === "}" && --depth === 0) return i;
|
|
2540
|
-
}
|
|
2541
|
-
return -1;
|
|
2542
|
-
}
|
|
2543
3287
|
function returnedValues(code, text) {
|
|
2544
3288
|
const values = [];
|
|
2545
3289
|
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
@@ -2717,47 +3461,46 @@ var comprehensionDebtRule = {
|
|
|
2717
3461
|
};
|
|
2718
3462
|
|
|
2719
3463
|
// src/rules/phantom-dependency.ts
|
|
2720
|
-
var PHANTOM_PACKAGES = /* @__PURE__ */ new
|
|
2721
|
-
"huggingface-cli",
|
|
2722
|
-
//
|
|
2723
|
-
"flask-hierarchical",
|
|
2724
|
-
//
|
|
2725
|
-
"beautifulsoup",
|
|
2726
|
-
//
|
|
2727
|
-
"python-dotenv",
|
|
2728
|
-
//
|
|
2729
|
-
"openai-sdk",
|
|
2730
|
-
//
|
|
2731
|
-
"anthropic-sdk",
|
|
2732
|
-
//
|
|
2733
|
-
"langchain-core",
|
|
2734
|
-
//
|
|
2735
|
-
"react-native-utils",
|
|
2736
|
-
//
|
|
2737
|
-
"next-middleware",
|
|
2738
|
-
//
|
|
2739
|
-
"supabase-client",
|
|
2740
|
-
//
|
|
2741
|
-
"stripe-sdk",
|
|
2742
|
-
//
|
|
2743
|
-
"prisma-client",
|
|
2744
|
-
//
|
|
2745
|
-
"tailwind-utils",
|
|
2746
|
-
//
|
|
2747
|
-
"express-validator-v2",
|
|
2748
|
-
//
|
|
2749
|
-
"node-postgres-pool",
|
|
2750
|
-
//
|
|
2751
|
-
"mongo-client",
|
|
2752
|
-
//
|
|
2753
|
-
"redis-client",
|
|
2754
|
-
//
|
|
2755
|
-
"aws-s3-upload",
|
|
2756
|
-
//
|
|
2757
|
-
"gpt-tokenizer"
|
|
2758
|
-
// exists but often confused
|
|
3464
|
+
var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
|
|
3465
|
+
["huggingface-cli", ["@huggingface/hub"]],
|
|
3466
|
+
// a Python CLI; an npm placeholder
|
|
3467
|
+
["flask-hierarchical", []],
|
|
3468
|
+
// not on npm
|
|
3469
|
+
["beautifulsoup", ["cheerio"]],
|
|
3470
|
+
// a Python library (beautifulsoup4); not on npm
|
|
3471
|
+
["python-dotenv", ["dotenv"]],
|
|
3472
|
+
// a Python library; an npm placeholder
|
|
3473
|
+
["openai-sdk", ["openai"]],
|
|
3474
|
+
// unpublished
|
|
3475
|
+
["anthropic-sdk", ["@anthropic-ai/sdk"]],
|
|
3476
|
+
// an npm placeholder
|
|
3477
|
+
["langchain-core", ["@langchain/core"]],
|
|
3478
|
+
// deprecated
|
|
3479
|
+
["react-native-utils", []],
|
|
3480
|
+
// a 2015 native module
|
|
3481
|
+
["next-middleware", []],
|
|
3482
|
+
// a 2017 0.0.1; Next.js has middleware built in
|
|
3483
|
+
["supabase-client", ["@supabase/supabase-js"]],
|
|
3484
|
+
// unpublished
|
|
3485
|
+
["stripe-sdk", ["stripe"]],
|
|
3486
|
+
// not on npm
|
|
3487
|
+
["prisma-client", ["@prisma/client"]],
|
|
3488
|
+
// an empty 0.0.0 placeholder
|
|
3489
|
+
["tailwind-utils", []],
|
|
3490
|
+
// a 2021 alpha
|
|
3491
|
+
["express-validator-v2", ["express-validator"]],
|
|
3492
|
+
// not on npm
|
|
3493
|
+
["node-postgres-pool", ["pg"]],
|
|
3494
|
+
// not on npm
|
|
3495
|
+
["mongo-client", ["mongodb"]],
|
|
3496
|
+
// a 2013 wrapper
|
|
3497
|
+
["redis-client", ["redis", "ioredis"]],
|
|
3498
|
+
// a 2015 client
|
|
3499
|
+
["aws-s3-upload", ["@aws-sdk/client-s3"]]
|
|
3500
|
+
// a 2018 0.0.2
|
|
2759
3501
|
]);
|
|
2760
|
-
var
|
|
3502
|
+
var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
|
|
3503
|
+
// 2-char names
|
|
2761
3504
|
"pg",
|
|
2762
3505
|
"ws",
|
|
2763
3506
|
"ms",
|
|
@@ -2771,7 +3514,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
2771
3514
|
"io",
|
|
2772
3515
|
"db",
|
|
2773
3516
|
"fp",
|
|
2774
|
-
"rx"
|
|
3517
|
+
"rx",
|
|
3518
|
+
// -js suffix
|
|
3519
|
+
"crypto-js",
|
|
3520
|
+
"chroma-js",
|
|
3521
|
+
"libphonenumber-js",
|
|
3522
|
+
"ua-parser-js",
|
|
3523
|
+
"keycloak-js",
|
|
3524
|
+
"pusher-js",
|
|
3525
|
+
"matter-js",
|
|
3526
|
+
"amplitude-js",
|
|
3527
|
+
// Vanity prefixes
|
|
3528
|
+
"fast-glob",
|
|
3529
|
+
"fast-deep-equal",
|
|
3530
|
+
"fast-json-stable-stringify",
|
|
3531
|
+
"fast-xml-parser",
|
|
3532
|
+
"fast-levenshtein",
|
|
3533
|
+
"fast-safe-stringify",
|
|
3534
|
+
"fast-uri",
|
|
3535
|
+
"fast-equals",
|
|
3536
|
+
"fast-csv",
|
|
3537
|
+
"fast-check",
|
|
3538
|
+
"fast-diff",
|
|
3539
|
+
"simple-git",
|
|
3540
|
+
"simple-peer",
|
|
3541
|
+
"simple-icons"
|
|
2775
3542
|
]);
|
|
2776
3543
|
var SUSPICIOUS_PATTERNS = [
|
|
2777
3544
|
/^[a-z]{1,2}$/,
|
|
@@ -2781,6 +3548,9 @@ var SUSPICIOUS_PATTERNS = [
|
|
|
2781
3548
|
/^(the|my|simple|easy|fast|super|mega|ultra)-/
|
|
2782
3549
|
// vanity prefixes
|
|
2783
3550
|
];
|
|
3551
|
+
function quoteList(names) {
|
|
3552
|
+
return names.map((n) => `"${n}"`).join(" or ");
|
|
3553
|
+
}
|
|
2784
3554
|
var phantomDependencyRule = {
|
|
2785
3555
|
id: "phantom-dependency",
|
|
2786
3556
|
name: "Phantom Dependency",
|
|
@@ -2799,20 +3569,21 @@ var phantomDependencyRule = {
|
|
|
2799
3569
|
...project.packageJson.devDependencies ?? {}
|
|
2800
3570
|
};
|
|
2801
3571
|
for (const [name, _version] of Object.entries(deps)) {
|
|
2802
|
-
|
|
3572
|
+
const meant = PHANTOM_PACKAGES.get(name);
|
|
3573
|
+
if (meant) {
|
|
2803
3574
|
findings.push({
|
|
2804
3575
|
ruleId: "phantom-dependency",
|
|
2805
3576
|
file: "package.json",
|
|
2806
3577
|
line: 1,
|
|
2807
3578
|
column: 1,
|
|
2808
|
-
message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
3579
|
+
message: meant.length > 0 ? `"${name}" is a commonly hallucinated package name \u2014 did you mean ${quoteList(meant)}?` : `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
2809
3580
|
severity: "warning",
|
|
2810
3581
|
category: "security",
|
|
2811
|
-
fix: "
|
|
3582
|
+
fix: meant.length > 0 ? `Check "${name}" on npm before relying on it. If you meant ${quoteList(meant)}, install that instead and update the code that imports "${name}"` : `Check "${name}" on npm before relying on it \u2014 it may not exist, or may not be the package you meant`
|
|
2812
3583
|
});
|
|
2813
3584
|
}
|
|
2814
3585
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
2815
|
-
if (pattern.test(name) && !name.startsWith("@") && !
|
|
3586
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
|
|
2816
3587
|
findings.push({
|
|
2817
3588
|
ruleId: "phantom-dependency",
|
|
2818
3589
|
file: "package.json",
|
|
@@ -2998,10 +3769,11 @@ var insecureRandomRule = {
|
|
|
2998
3769
|
};
|
|
2999
3770
|
|
|
3000
3771
|
// src/rules/next-server-action-validation.ts
|
|
3001
|
-
var USE_SERVER =
|
|
3772
|
+
var USE_SERVER = /^\s*['"]use server['"]/m;
|
|
3002
3773
|
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
3003
3774
|
var VALIDATION_PATTERNS2 = [
|
|
3004
|
-
|
|
3775
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
3776
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
3005
3777
|
/\.safeParse\s*\(/,
|
|
3006
3778
|
/\bvalidate\s*\(/,
|
|
3007
3779
|
/\.parseAsync\s*\(/,
|
|
@@ -3152,6 +3924,30 @@ function findEnclosingFunction(node, parentMap) {
|
|
|
3152
3924
|
}
|
|
3153
3925
|
|
|
3154
3926
|
// src/rules/redirect-in-try-catch.ts
|
|
3927
|
+
var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
|
|
3928
|
+
function rethrowsRedirect(handler) {
|
|
3929
|
+
const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
|
|
3930
|
+
if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
|
|
3931
|
+
return true;
|
|
3932
|
+
}
|
|
3933
|
+
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"));
|
|
3934
|
+
}
|
|
3935
|
+
function bodyRethrowsRedirect(body, param) {
|
|
3936
|
+
if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
|
|
3937
|
+
return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
|
|
3938
|
+
}
|
|
3939
|
+
function redirectFinding(file, line, column) {
|
|
3940
|
+
return {
|
|
3941
|
+
ruleId: "redirect-in-try-catch",
|
|
3942
|
+
file: file.relativePath,
|
|
3943
|
+
line,
|
|
3944
|
+
column,
|
|
3945
|
+
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3946
|
+
severity: "critical",
|
|
3947
|
+
category: "reliability",
|
|
3948
|
+
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'
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3155
3951
|
var redirectInTryCatchRule = {
|
|
3156
3952
|
id: "redirect-in-try-catch",
|
|
3157
3953
|
name: "Redirect Inside Try/Catch",
|
|
@@ -3163,44 +3959,42 @@ var redirectInTryCatchRule = {
|
|
|
3163
3959
|
if (isTestFile(file.relativePath)) return [];
|
|
3164
3960
|
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
3165
3961
|
const findings = [];
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
if (tryDepth > 0) {
|
|
3177
|
-
const match = /\bredirect\s*\(/.exec(line);
|
|
3178
|
-
if (match && !/\/\//.test(line.slice(0, match.index))) {
|
|
3179
|
-
findings.push({
|
|
3180
|
-
ruleId: "redirect-in-try-catch",
|
|
3181
|
-
file: file.relativePath,
|
|
3182
|
-
line: i + 1,
|
|
3183
|
-
column: match.index + 1,
|
|
3184
|
-
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3185
|
-
severity: "critical",
|
|
3186
|
-
category: "reliability",
|
|
3187
|
-
fix: 'Move redirect() outside the try/catch block, or re-throw redirect errors in the catch: if (e instanceof Error && e.message === "NEXT_REDIRECT") throw e'
|
|
3962
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3963
|
+
if (file.ast) {
|
|
3964
|
+
try {
|
|
3965
|
+
walkAST(file.ast.program, (node) => {
|
|
3966
|
+
if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
|
|
3967
|
+
walkAST(node.block, (n) => {
|
|
3968
|
+
if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
|
|
3969
|
+
if (!n.loc || n.start == null || seen.has(n.start)) return;
|
|
3970
|
+
seen.add(n.start);
|
|
3971
|
+
findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
|
|
3188
3972
|
});
|
|
3189
|
-
}
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3973
|
+
});
|
|
3974
|
+
return findings;
|
|
3975
|
+
} catch {
|
|
3976
|
+
findings.length = 0;
|
|
3977
|
+
seen.clear();
|
|
3194
3978
|
}
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3979
|
+
}
|
|
3980
|
+
const source = file.lines.join("\n");
|
|
3981
|
+
const code = blankOut(source, true);
|
|
3982
|
+
const noComments = blankOut(source, false);
|
|
3983
|
+
for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
|
|
3984
|
+
const open = tryMatch.index + tryMatch[0].length - 1;
|
|
3985
|
+
const close = closingBrace(code, open);
|
|
3986
|
+
if (close === -1) continue;
|
|
3987
|
+
const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
|
|
3988
|
+
if (!catchMatch) continue;
|
|
3989
|
+
const catchOpen = close + 1 + catchMatch[0].length - 1;
|
|
3990
|
+
const catchClose = closingBrace(code, catchOpen);
|
|
3991
|
+
if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
|
|
3992
|
+
for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
|
|
3993
|
+
const offset = open + 1 + call.index;
|
|
3994
|
+
if (seen.has(offset)) continue;
|
|
3995
|
+
seen.add(offset);
|
|
3996
|
+
const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
|
|
3997
|
+
findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
|
|
3204
3998
|
}
|
|
3205
3999
|
}
|
|
3206
4000
|
return findings;
|
|
@@ -3493,7 +4287,7 @@ var missingWebhookVerificationRule = {
|
|
|
3493
4287
|
};
|
|
3494
4288
|
|
|
3495
4289
|
// src/rules/server-action-auth.ts
|
|
3496
|
-
var USE_SERVER3 =
|
|
4290
|
+
var USE_SERVER3 = /^\s*['"]use server['"]/m;
|
|
3497
4291
|
var AUTH_PATTERNS2 = [
|
|
3498
4292
|
/getServerSession\s*\(/,
|
|
3499
4293
|
/getSession\s*\(/,
|
|
@@ -3510,7 +4304,13 @@ var AUTH_PATTERNS2 = [
|
|
|
3510
4304
|
/currentUser\s*\(/,
|
|
3511
4305
|
/getAuth\s*\(/,
|
|
3512
4306
|
/cookies\(\).*auth/s,
|
|
3513
|
-
|
|
4307
|
+
// Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
|
|
4308
|
+
// verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
|
|
4309
|
+
/\w*session\w*\s*\(/i,
|
|
4310
|
+
// ...reading a session cookie, or using and guarding a session object
|
|
4311
|
+
/cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
|
|
4312
|
+
/\bsession\??\.user(?:Id)?\b/,
|
|
4313
|
+
/!\s*session\b/
|
|
3514
4314
|
];
|
|
3515
4315
|
var PUBLIC_ACTION_NAMES = [
|
|
3516
4316
|
/contact/i,
|
|
@@ -3531,7 +4331,6 @@ var serverActionAuthRule = {
|
|
|
3531
4331
|
check(file, project) {
|
|
3532
4332
|
if (isTestFile(file.relativePath)) return [];
|
|
3533
4333
|
if (!USE_SERVER3.test(file.content)) return [];
|
|
3534
|
-
if (project.hasAuthMiddleware) return [];
|
|
3535
4334
|
for (const p of PUBLIC_ACTION_NAMES) {
|
|
3536
4335
|
if (p.test(file.relativePath)) return [];
|
|
3537
4336
|
}
|
|
@@ -3546,15 +4345,17 @@ var serverActionAuthRule = {
|
|
|
3546
4345
|
break;
|
|
3547
4346
|
}
|
|
3548
4347
|
}
|
|
4348
|
+
const middlewareSkipsPages = project.hasAuthMiddleware && project.authMiddlewareExcludesPages === true;
|
|
4349
|
+
const middleware = project.hasAuthMiddleware && !middlewareSkipsPages;
|
|
3549
4350
|
return [{
|
|
3550
4351
|
ruleId: "server-action-auth",
|
|
3551
4352
|
file: file.relativePath,
|
|
3552
4353
|
line: reportLine,
|
|
3553
4354
|
column: 1,
|
|
3554
|
-
message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
|
|
3555
|
-
severity: "warning",
|
|
4355
|
+
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",
|
|
4356
|
+
severity: middleware ? "info" : "warning",
|
|
3556
4357
|
category: "security",
|
|
3557
|
-
fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
|
|
4358
|
+
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")'
|
|
3558
4359
|
}];
|
|
3559
4360
|
}
|
|
3560
4361
|
};
|
|
@@ -3684,13 +4485,15 @@ var nextPublicSensitiveRule = {
|
|
|
3684
4485
|
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3685
4486
|
category: "security",
|
|
3686
4487
|
severity: "critical",
|
|
3687
|
-
|
|
4488
|
+
// The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
|
|
4489
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
|
|
3688
4490
|
check(file, _project) {
|
|
3689
4491
|
if (isTestFile(file.relativePath)) return [];
|
|
3690
4492
|
const findings = [];
|
|
4493
|
+
const isEnvFile = file.ext === "env";
|
|
3691
4494
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3692
|
-
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3693
4495
|
const line = file.lines[i];
|
|
4496
|
+
if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3694
4497
|
const match = SENSITIVE_PATTERN.exec(line);
|
|
3695
4498
|
if (match) {
|
|
3696
4499
|
findings.push({
|
|
@@ -4153,7 +4956,17 @@ var unsafeFileUploadRule = {
|
|
|
4153
4956
|
|
|
4154
4957
|
// src/rules/supabase-missing-rls.ts
|
|
4155
4958
|
var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
|
|
4156
|
-
var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)
|
|
4959
|
+
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;
|
|
4960
|
+
function isMigrationFile(file) {
|
|
4961
|
+
return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
|
|
4962
|
+
}
|
|
4963
|
+
function sqlProject(relativePath) {
|
|
4964
|
+
const dirs = relativePath.split("/").slice(0, -1);
|
|
4965
|
+
const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
|
|
4966
|
+
const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
|
|
4967
|
+
const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
|
|
4968
|
+
return dirs.slice(0, end).join("/");
|
|
4969
|
+
}
|
|
4157
4970
|
var supabaseMissingRlsRule = {
|
|
4158
4971
|
id: "supabase-missing-rls",
|
|
4159
4972
|
name: "Missing Row-Level Security",
|
|
@@ -4161,42 +4974,36 @@ var supabaseMissingRlsRule = {
|
|
|
4161
4974
|
category: "security",
|
|
4162
4975
|
severity: "critical",
|
|
4163
4976
|
fileExtensions: ["sql"],
|
|
4164
|
-
check(
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4977
|
+
check() {
|
|
4978
|
+
return [];
|
|
4979
|
+
},
|
|
4980
|
+
checkProject(files, _project) {
|
|
4168
4981
|
const findings = [];
|
|
4169
|
-
const tables = [];
|
|
4170
|
-
CREATE_TABLE.lastIndex = 0;
|
|
4171
|
-
let match;
|
|
4172
|
-
while ((match = CREATE_TABLE.exec(content)) !== null) {
|
|
4173
|
-
const name = match[1];
|
|
4174
|
-
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4175
|
-
const beforeMatch = content.slice(0, match.index);
|
|
4176
|
-
const line = beforeMatch.split("\n").length;
|
|
4177
|
-
tables.push({ name, line });
|
|
4178
|
-
}
|
|
4179
|
-
if (tables.length === 0) return [];
|
|
4180
4982
|
const rlsTables = /* @__PURE__ */ new Set();
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
if (filePath === file.relativePath) continue;
|
|
4983
|
+
for (const file of files) {
|
|
4984
|
+
if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
|
|
4985
|
+
const project = sqlProject(file.relativePath);
|
|
4986
|
+
for (const match of file.content.matchAll(ENABLE_RLS)) {
|
|
4987
|
+
rlsTables.add(`${project}::${match[1].toLowerCase()}`);
|
|
4988
|
+
}
|
|
4188
4989
|
}
|
|
4189
|
-
for (const
|
|
4190
|
-
if (!
|
|
4990
|
+
for (const file of files) {
|
|
4991
|
+
if (!isMigrationFile(file)) continue;
|
|
4992
|
+
const content = file.content;
|
|
4993
|
+
const project = sqlProject(file.relativePath);
|
|
4994
|
+
for (const match of content.matchAll(CREATE_TABLE)) {
|
|
4995
|
+
const name = match[1];
|
|
4996
|
+
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4997
|
+
if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
|
|
4191
4998
|
findings.push({
|
|
4192
4999
|
ruleId: "supabase-missing-rls",
|
|
4193
5000
|
file: file.relativePath,
|
|
4194
|
-
line:
|
|
5001
|
+
line: content.slice(0, match.index).split("\n").length,
|
|
4195
5002
|
column: 1,
|
|
4196
|
-
message: `Table "${
|
|
5003
|
+
message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
4197
5004
|
severity: "critical",
|
|
4198
5005
|
category: "security",
|
|
4199
|
-
fix: `Add: ALTER TABLE ${
|
|
5006
|
+
fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
4200
5007
|
});
|
|
4201
5008
|
}
|
|
4202
5009
|
}
|
|
@@ -4527,10 +5334,15 @@ async function scan(options) {
|
|
|
4527
5334
|
}
|
|
4528
5335
|
}
|
|
4529
5336
|
}
|
|
5337
|
+
const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
|
|
4530
5338
|
for (const rule of rules) {
|
|
4531
5339
|
if (rule.checkProject) {
|
|
4532
|
-
const
|
|
4533
|
-
|
|
5340
|
+
for (const finding of rule.checkProject(allFiles, project)) {
|
|
5341
|
+
const file = filesByPath.get(finding.file);
|
|
5342
|
+
if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
|
|
5343
|
+
findings.push(finding);
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
4534
5346
|
}
|
|
4535
5347
|
}
|
|
4536
5348
|
const { overallScore, categoryScores } = calculateScores(findings);
|
|
@@ -4699,11 +5511,7 @@ function reportSarif(result) {
|
|
|
4699
5511
|
]
|
|
4700
5512
|
};
|
|
4701
5513
|
if (f.fix) {
|
|
4702
|
-
sarifResult.
|
|
4703
|
-
{
|
|
4704
|
-
description: { text: f.fix }
|
|
4705
|
-
}
|
|
4706
|
-
];
|
|
5514
|
+
sarifResult.properties = { fix: f.fix };
|
|
4707
5515
|
}
|
|
4708
5516
|
return sarifResult;
|
|
4709
5517
|
});
|
|
@@ -4948,9 +5756,9 @@ function ipv6Groups(address) {
|
|
|
4948
5756
|
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4949
5757
|
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4950
5758
|
const [head, tail] = hex.split("::");
|
|
4951
|
-
const
|
|
4952
|
-
const front =
|
|
4953
|
-
const back =
|
|
5759
|
+
const parse3 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
5760
|
+
const front = parse3(head);
|
|
5761
|
+
const back = parse3(tail);
|
|
4954
5762
|
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4955
5763
|
}
|
|
4956
5764
|
function embeddedIPv4(address) {
|