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/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/utils/file-walker.ts
|
|
2
2
|
import fg from "fast-glob";
|
|
3
3
|
import { readFile, stat, realpath } from "fs/promises";
|
|
4
|
-
import { resolve, extname, sep } from "path";
|
|
4
|
+
import { resolve, extname, basename, sep } from "path";
|
|
5
5
|
|
|
6
6
|
// src/utils/patterns.ts
|
|
7
7
|
function isApiRoute(relativePath) {
|
|
@@ -44,6 +44,19 @@ function isCommentLine(lines, lineIndex, commentMap) {
|
|
|
44
44
|
const trimmed = lines[lineIndex]?.trim() ?? "";
|
|
45
45
|
return trimmed.startsWith("//");
|
|
46
46
|
}
|
|
47
|
+
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
48
|
+
function blankOut(source, strings) {
|
|
49
|
+
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
50
|
+
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
51
|
+
}
|
|
52
|
+
function closingBrace(code, open) {
|
|
53
|
+
let depth = 0;
|
|
54
|
+
for (let i = open; i < code.length; i++) {
|
|
55
|
+
if (code[i] === "{") depth++;
|
|
56
|
+
else if (code[i] === "}" && --depth === 0) return i;
|
|
57
|
+
}
|
|
58
|
+
return -1;
|
|
59
|
+
}
|
|
47
60
|
function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
48
61
|
for (const line of lines) {
|
|
49
62
|
const trimmed = line.trim();
|
|
@@ -68,7 +81,8 @@ function isLineSuppressed(lines, lineIndex, ruleId) {
|
|
|
68
81
|
return false;
|
|
69
82
|
}
|
|
70
83
|
function isTestFile(relativePath) {
|
|
71
|
-
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) ||
|
|
84
|
+
return /\.(test|spec)\.[jt]sx?$/.test(relativePath) || // Jest's __tests__ and __mocks__, jscodeshift's __testfixtures__
|
|
85
|
+
/(?:^|\/)__(?:tests|testfixtures|fixtures|mocks)__\//.test(relativePath) || /(?:^|\/)tests?\//.test(relativePath) || /(?:^|\/)fixtures?\//.test(relativePath) || /(?:^|\/)mocks?\//.test(relativePath);
|
|
72
86
|
}
|
|
73
87
|
function isScriptFile(relativePath) {
|
|
74
88
|
if (/(?:^|\/)scripts?\//.test(relativePath)) return true;
|
|
@@ -382,6 +396,263 @@ var DEPENDENCY_TO_FRAMEWORK = {
|
|
|
382
396
|
var SQL_SAFE_ORMS = /* @__PURE__ */ new Set(["prisma", "drizzle", "knex", "typeorm", "sequelize"]);
|
|
383
397
|
var RATE_LIMIT_FRAMEWORKS = /* @__PURE__ */ new Set(["upstash-ratelimit", "express-rate-limit", "rate-limiter-flexible"]);
|
|
384
398
|
|
|
399
|
+
// src/utils/middleware-matcher.ts
|
|
400
|
+
function matcherExcludesApi(content, fileName) {
|
|
401
|
+
const sources = matcherSources(content, fileName);
|
|
402
|
+
if (!sources || sources.length === 0) return false;
|
|
403
|
+
return sources.every((source) => {
|
|
404
|
+
const matcher = compile(source);
|
|
405
|
+
return matcher !== null && !matcher.patterns.some(namesApi) && !apiPaths(matcher.literals).some((path) => matcher.regexp.test(path));
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
function matcherExcludesPages(content, fileName) {
|
|
409
|
+
const sources = matcherSources(content, fileName);
|
|
410
|
+
if (!sources || sources.length === 0) return false;
|
|
411
|
+
return sources.every((source) => {
|
|
412
|
+
const matcher = compile(source);
|
|
413
|
+
return matcher !== null && underApi(matcher.tokens);
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function underApi(tokens) {
|
|
417
|
+
const [head, ...rest] = tokens;
|
|
418
|
+
if (typeof head !== "string") return false;
|
|
419
|
+
if (head.startsWith("/api/")) return true;
|
|
420
|
+
if (head !== "/api") return false;
|
|
421
|
+
for (const token of rest) {
|
|
422
|
+
const start = typeof token === "string" ? token : token.pattern ? prefixOf(token) : token.prefix + token.suffix;
|
|
423
|
+
if (!start.startsWith("/")) return false;
|
|
424
|
+
if (typeof token === "string" || token.modifier !== "?" && token.modifier !== "*") return true;
|
|
425
|
+
}
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
function apiPaths(literals) {
|
|
429
|
+
const paths = ["/api/x", "/api/x/y"];
|
|
430
|
+
for (const literal2 of literals) {
|
|
431
|
+
for (const head of ["", "/api", "/api/x"]) {
|
|
432
|
+
for (const tail of ["", "/x"]) paths.push(head + literal2 + tail);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return paths.filter((path) => path.startsWith("/api/"));
|
|
436
|
+
}
|
|
437
|
+
function matcherSources(content, fileName) {
|
|
438
|
+
const ast = parseFile(content, fileName);
|
|
439
|
+
if (!ast) return null;
|
|
440
|
+
for (const st of ast.program.body) {
|
|
441
|
+
if (st.type !== "ExportNamedDeclaration" || st.declaration?.type !== "VariableDeclaration") continue;
|
|
442
|
+
if (st.declaration.kind !== "const") continue;
|
|
443
|
+
for (const d of st.declaration.declarations) {
|
|
444
|
+
if (d.id.type !== "Identifier" || d.id.name !== "config" || !d.init) continue;
|
|
445
|
+
const config = unwrap(d.init);
|
|
446
|
+
if (config.type !== "ObjectExpression") return null;
|
|
447
|
+
if (config.properties.some((p) => p.type !== "ObjectProperty")) return null;
|
|
448
|
+
const matcher = property(config, "matcher");
|
|
449
|
+
if (!matcher) return void 0;
|
|
450
|
+
const values = matcher.type === "ArrayExpression" ? matcher.elements : [matcher];
|
|
451
|
+
const sources = [];
|
|
452
|
+
for (const value of values) {
|
|
453
|
+
const element = value ? unwrap(value) : null;
|
|
454
|
+
const source = element?.type === "ObjectExpression" ? property(element, "source") : element;
|
|
455
|
+
const text = source ? literal(source) : null;
|
|
456
|
+
if (text === null) return null;
|
|
457
|
+
sources.push(text);
|
|
458
|
+
}
|
|
459
|
+
return sources;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return void 0;
|
|
463
|
+
}
|
|
464
|
+
function unwrap(node) {
|
|
465
|
+
while (node.type === "TSSatisfiesExpression" || node.type === "TSAsExpression") node = node.expression;
|
|
466
|
+
return node;
|
|
467
|
+
}
|
|
468
|
+
function property(object, name) {
|
|
469
|
+
if (object.type !== "ObjectExpression") return void 0;
|
|
470
|
+
let value;
|
|
471
|
+
for (const p of object.properties) {
|
|
472
|
+
if (p.type !== "ObjectProperty" || p.computed) continue;
|
|
473
|
+
const key = p.key.type === "Identifier" ? p.key.name : p.key.type === "StringLiteral" ? p.key.value : null;
|
|
474
|
+
if (key === name) value = unwrap(p.value);
|
|
475
|
+
}
|
|
476
|
+
return value;
|
|
477
|
+
}
|
|
478
|
+
function literal(node) {
|
|
479
|
+
if (node.type === "StringLiteral") return node.value;
|
|
480
|
+
if (node.type === "TemplateLiteral" && node.expressions.length === 0) return node.quasis[0].value.cooked ?? null;
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
function namesApi(pattern) {
|
|
484
|
+
let kept = "";
|
|
485
|
+
const negative = [];
|
|
486
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
487
|
+
const c = pattern[i];
|
|
488
|
+
const skipping = negative.includes(true);
|
|
489
|
+
if (c === "\\") {
|
|
490
|
+
if (!skipping) kept += pattern.slice(i, i + 2);
|
|
491
|
+
i++;
|
|
492
|
+
} else if (c === "(") {
|
|
493
|
+
negative.push(pattern.startsWith("(?!", i) || pattern.startsWith("(?<!", i));
|
|
494
|
+
if (!skipping) kept += c;
|
|
495
|
+
} else if (c === ")") {
|
|
496
|
+
negative.pop();
|
|
497
|
+
if (!negative.includes(true)) kept += c;
|
|
498
|
+
} else if (!skipping) {
|
|
499
|
+
kept += c;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return /\bapi\b/i.test(kept);
|
|
503
|
+
}
|
|
504
|
+
var DELIMITER = "/#?";
|
|
505
|
+
var PREFIXES = "./";
|
|
506
|
+
var escape = (s) => s.replace(/([.+*?=^!:${}()[\]|/\\])/g, "\\$1");
|
|
507
|
+
var prefixOf = (token) => (token.modifier === "*" || token.modifier === "+") && !token.prefix && !token.suffix ? "/" : token.prefix;
|
|
508
|
+
function lex(source) {
|
|
509
|
+
const tokens = [];
|
|
510
|
+
let i = 0;
|
|
511
|
+
while (i < source.length) {
|
|
512
|
+
const c = source[i];
|
|
513
|
+
if (c === "*" || c === "+" || c === "?") {
|
|
514
|
+
tokens.push({ type: "MODIFIER", value: source[i++] });
|
|
515
|
+
} else if (c === "\\") {
|
|
516
|
+
tokens.push({ type: "ESCAPED_CHAR", value: source[i + 1] });
|
|
517
|
+
i += 2;
|
|
518
|
+
} else if (c === "{" || c === "}") {
|
|
519
|
+
tokens.push({ type: c === "{" ? "OPEN" : "CLOSE", value: source[i++] });
|
|
520
|
+
} else if (c === ":") {
|
|
521
|
+
const name = /^\w+/.exec(source.slice(i + 1))?.[0];
|
|
522
|
+
if (!name) throw new TypeError(`Missing parameter name at ${i}`);
|
|
523
|
+
tokens.push({ type: "NAME", value: name });
|
|
524
|
+
i += 1 + name.length;
|
|
525
|
+
} else if (c === "(") {
|
|
526
|
+
let depth = 1;
|
|
527
|
+
let pattern = "";
|
|
528
|
+
let j = i + 1;
|
|
529
|
+
if (source[j] === "?") throw new TypeError(`Pattern cannot start with "?" at ${j}`);
|
|
530
|
+
while (j < source.length) {
|
|
531
|
+
if (source[j] === "\\") {
|
|
532
|
+
pattern += source[j++] + source[j++];
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (source[j] === ")") {
|
|
536
|
+
if (--depth === 0) {
|
|
537
|
+
j++;
|
|
538
|
+
break;
|
|
539
|
+
}
|
|
540
|
+
} else if (source[j] === "(") {
|
|
541
|
+
depth++;
|
|
542
|
+
if (source[j + 1] !== "?") throw new TypeError(`Capturing groups are not allowed at ${j}`);
|
|
543
|
+
}
|
|
544
|
+
pattern += source[j++];
|
|
545
|
+
}
|
|
546
|
+
if (depth) throw new TypeError(`Unbalanced pattern at ${i}`);
|
|
547
|
+
if (!pattern) throw new TypeError(`Missing pattern at ${i}`);
|
|
548
|
+
tokens.push({ type: "PATTERN", value: pattern });
|
|
549
|
+
i = j;
|
|
550
|
+
} else {
|
|
551
|
+
tokens.push({ type: "CHAR", value: source[i++] });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
tokens.push({ type: "END", value: "" });
|
|
555
|
+
return tokens;
|
|
556
|
+
}
|
|
557
|
+
function parse2(source) {
|
|
558
|
+
const tokens = lex(source);
|
|
559
|
+
const result = [];
|
|
560
|
+
let i = 0;
|
|
561
|
+
let path = "";
|
|
562
|
+
const tryConsume = (type) => i < tokens.length && tokens[i].type === type ? tokens[i++].value : void 0;
|
|
563
|
+
const mustConsume = (type) => {
|
|
564
|
+
const value = tryConsume(type);
|
|
565
|
+
if (value === void 0) throw new TypeError(`Unexpected ${tokens[i].type}, expected ${type}`);
|
|
566
|
+
return value;
|
|
567
|
+
};
|
|
568
|
+
const consumeText = () => {
|
|
569
|
+
let text = "";
|
|
570
|
+
let value;
|
|
571
|
+
while (value = tryConsume("CHAR") || tryConsume("ESCAPED_CHAR")) text += value;
|
|
572
|
+
return text;
|
|
573
|
+
};
|
|
574
|
+
const safePattern = (prefix) => {
|
|
575
|
+
const prev = result[result.length - 1];
|
|
576
|
+
const prevText = prefix || (typeof prev === "string" ? prev : "");
|
|
577
|
+
if (prev && !prevText) throw new TypeError("Must have text between two parameters");
|
|
578
|
+
if (!prevText || [...DELIMITER].some((c) => prevText.includes(c))) return `[^${escape(DELIMITER)}]+?`;
|
|
579
|
+
return `(?:(?!${escape(prevText)})[^${escape(DELIMITER)}])+?`;
|
|
580
|
+
};
|
|
581
|
+
while (i < tokens.length) {
|
|
582
|
+
const char = tryConsume("CHAR");
|
|
583
|
+
const name = tryConsume("NAME");
|
|
584
|
+
const pattern = tryConsume("PATTERN");
|
|
585
|
+
if (name || pattern) {
|
|
586
|
+
let prefix = char || "";
|
|
587
|
+
if (!PREFIXES.includes(prefix)) {
|
|
588
|
+
path += prefix;
|
|
589
|
+
prefix = "";
|
|
590
|
+
}
|
|
591
|
+
if (path) {
|
|
592
|
+
result.push(path);
|
|
593
|
+
path = "";
|
|
594
|
+
}
|
|
595
|
+
result.push({ prefix, suffix: "", pattern: pattern || safePattern(prefix), modifier: tryConsume("MODIFIER") || "" });
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const value = char || tryConsume("ESCAPED_CHAR");
|
|
599
|
+
if (value) {
|
|
600
|
+
path += value;
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (path) {
|
|
604
|
+
result.push(path);
|
|
605
|
+
path = "";
|
|
606
|
+
}
|
|
607
|
+
if (tryConsume("OPEN")) {
|
|
608
|
+
const prefix = consumeText();
|
|
609
|
+
const name2 = tryConsume("NAME") || "";
|
|
610
|
+
const pattern2 = tryConsume("PATTERN") || "";
|
|
611
|
+
const suffix = consumeText();
|
|
612
|
+
mustConsume("CLOSE");
|
|
613
|
+
result.push({ prefix, suffix, pattern: name2 && !pattern2 ? safePattern(prefix) : pattern2, modifier: tryConsume("MODIFIER") || "" });
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
mustConsume("END");
|
|
617
|
+
}
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
function compile(source) {
|
|
621
|
+
if (!source.startsWith("/")) return null;
|
|
622
|
+
try {
|
|
623
|
+
const tokens = parse2(source);
|
|
624
|
+
let route = "^";
|
|
625
|
+
for (const token of tokens) {
|
|
626
|
+
if (typeof token === "string") {
|
|
627
|
+
route += escape(token);
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (!token.pattern) {
|
|
631
|
+
route += `(?:${escape(token.prefix)}${escape(token.suffix)})${token.modifier}`;
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const repeat = token.modifier === "*" || token.modifier === "+";
|
|
635
|
+
const prefix = escape(prefixOf(token));
|
|
636
|
+
const suffix = escape(token.suffix);
|
|
637
|
+
if (!prefix && !suffix) {
|
|
638
|
+
route += `(${token.pattern})${token.modifier}`;
|
|
639
|
+
} else if (repeat) {
|
|
640
|
+
route += `(?:${prefix}((?:${token.pattern})(?:${suffix}${prefix}(?:${token.pattern}))*)${suffix})${token.modifier === "*" ? "?" : ""}`;
|
|
641
|
+
} else {
|
|
642
|
+
route += `(?:${prefix}(${token.pattern})${suffix})${token.modifier}`;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return {
|
|
646
|
+
regexp: new RegExp(`${route}[${escape(DELIMITER)}]?$`),
|
|
647
|
+
tokens,
|
|
648
|
+
literals: tokens.filter((token) => typeof token === "string"),
|
|
649
|
+
patterns: tokens.flatMap((token) => typeof token === "string" ? [] : [token.pattern])
|
|
650
|
+
};
|
|
651
|
+
} catch {
|
|
652
|
+
return null;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
385
656
|
// src/utils/file-walker.ts
|
|
386
657
|
var DEFAULT_IGNORES = [
|
|
387
658
|
"**/node_modules/**",
|
|
@@ -411,6 +682,37 @@ var SCAN_EXTENSIONS = [
|
|
|
411
682
|
];
|
|
412
683
|
var AST_EXTENSIONS = /* @__PURE__ */ new Set(["ts", "tsx", "js", "jsx", "mjs", "cjs"]);
|
|
413
684
|
var MAX_FILE_SIZE = 1024 * 1024;
|
|
685
|
+
var MIDDLEWARE_FILES = [
|
|
686
|
+
"middleware.ts",
|
|
687
|
+
"middleware.js",
|
|
688
|
+
"src/middleware.ts",
|
|
689
|
+
"src/middleware.js",
|
|
690
|
+
"proxy.ts",
|
|
691
|
+
"proxy.js",
|
|
692
|
+
"src/proxy.ts",
|
|
693
|
+
"src/proxy.js"
|
|
694
|
+
];
|
|
695
|
+
var MIDDLEWARE_AUTH_PATTERNS = [
|
|
696
|
+
/getSession/i,
|
|
697
|
+
/getUser/i,
|
|
698
|
+
/auth\(\)/,
|
|
699
|
+
/withAuth/i,
|
|
700
|
+
/clerkMiddleware/i,
|
|
701
|
+
/authMiddleware/i,
|
|
702
|
+
/NextAuth/i,
|
|
703
|
+
/supabase.*auth/i,
|
|
704
|
+
/createMiddlewareClient/i,
|
|
705
|
+
/getToken/i,
|
|
706
|
+
/verifyToken/i,
|
|
707
|
+
/jwt/i,
|
|
708
|
+
/updateSession/i,
|
|
709
|
+
// Auth.js: export { auth as proxy } from '@/auth', export default auth((req) => …)
|
|
710
|
+
/\bauth\s+as\s+(?:proxy|middleware|default)\b/,
|
|
711
|
+
/\bauth\(/,
|
|
712
|
+
// NextAuth v4: export { default } from 'next-auth/middleware'
|
|
713
|
+
/['"]next-auth\/middleware['"]/
|
|
714
|
+
];
|
|
715
|
+
var MIDDLEWARE_RATE_LIMIT_PATTERNS = [/rateLimit/i, /throttle/i];
|
|
414
716
|
async function walkFiles(root, extraIgnores = []) {
|
|
415
717
|
const patterns = SCAN_EXTENSIONS.map((ext) => `**/*.${ext}`);
|
|
416
718
|
patterns.push("**/.env", "**/.env.*");
|
|
@@ -434,7 +736,8 @@ async function readFileContext(root, relativePath) {
|
|
|
434
736
|
if (fileStats.size > MAX_FILE_SIZE) return null;
|
|
435
737
|
const content = await readFile(absolutePath, "utf-8");
|
|
436
738
|
const lines = content.split(/\r?\n|\r/);
|
|
437
|
-
const
|
|
739
|
+
const rawExt = extname(relativePath).slice(1);
|
|
740
|
+
const ext = /^\.env(?:\.|$)/.test(basename(relativePath)) && !SCAN_EXTENSIONS.includes(rawExt) ? "env" : rawExt;
|
|
438
741
|
let ast = void 0;
|
|
439
742
|
if (AST_EXTENSIONS.has(ext)) {
|
|
440
743
|
try {
|
|
@@ -548,6 +851,8 @@ async function buildProjectContext(root, files) {
|
|
|
548
851
|
let declaredDependencies = /* @__PURE__ */ new Set();
|
|
549
852
|
let tsconfigPaths = /* @__PURE__ */ new Set();
|
|
550
853
|
let hasAuthMiddleware = false;
|
|
854
|
+
let authMiddlewareExcludesApi = false;
|
|
855
|
+
let authMiddlewareExcludesPages = false;
|
|
551
856
|
let hasRateLimiting = false;
|
|
552
857
|
const detectedFrameworks = /* @__PURE__ */ new Set();
|
|
553
858
|
let gitignoreContent = null;
|
|
@@ -599,45 +904,19 @@ async function buildProjectContext(root, files) {
|
|
|
599
904
|
}
|
|
600
905
|
} catch {
|
|
601
906
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
/getUser/i,
|
|
609
|
-
/auth\(\)/,
|
|
610
|
-
/withAuth/i,
|
|
611
|
-
/clerkMiddleware/i,
|
|
612
|
-
/authMiddleware/i,
|
|
613
|
-
/NextAuth/i,
|
|
614
|
-
/supabase.*auth/i,
|
|
615
|
-
/createMiddlewareClient/i,
|
|
616
|
-
/getToken/i,
|
|
617
|
-
/verifyToken/i,
|
|
618
|
-
/jwt/i,
|
|
619
|
-
/updateSession/i
|
|
620
|
-
];
|
|
621
|
-
if (authPatterns.some((p) => p.test(content))) {
|
|
622
|
-
hasAuthMiddleware = true;
|
|
623
|
-
break;
|
|
624
|
-
}
|
|
625
|
-
} catch {
|
|
626
|
-
}
|
|
907
|
+
for (const name of MIDDLEWARE_FILES) {
|
|
908
|
+
let content;
|
|
909
|
+
try {
|
|
910
|
+
content = await readFile(resolve(root, name), "utf-8");
|
|
911
|
+
} catch {
|
|
912
|
+
continue;
|
|
627
913
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
const content = await readFile(resolve(root, name), "utf-8");
|
|
634
|
-
if (/rateLimit/i.test(content) || /throttle/i.test(content)) {
|
|
635
|
-
hasRateLimiting = true;
|
|
636
|
-
break;
|
|
637
|
-
}
|
|
638
|
-
} catch {
|
|
639
|
-
}
|
|
914
|
+
if (MIDDLEWARE_AUTH_PATTERNS.some((p) => p.test(content))) {
|
|
915
|
+
authMiddlewareExcludesApi = (!hasAuthMiddleware || authMiddlewareExcludesApi) && matcherExcludesApi(content, name);
|
|
916
|
+
authMiddlewareExcludesPages = (!hasAuthMiddleware || authMiddlewareExcludesPages) && matcherExcludesPages(content, name);
|
|
917
|
+
hasAuthMiddleware = true;
|
|
640
918
|
}
|
|
919
|
+
if (MIDDLEWARE_RATE_LIMIT_PATTERNS.some((p) => p.test(content))) hasRateLimiting = true;
|
|
641
920
|
}
|
|
642
921
|
try {
|
|
643
922
|
gitignoreContent = await readFile(resolve(root, ".gitignore"), "utf-8");
|
|
@@ -650,6 +929,8 @@ async function buildProjectContext(root, files) {
|
|
|
650
929
|
declaredDependencies,
|
|
651
930
|
tsconfigPaths,
|
|
652
931
|
hasAuthMiddleware,
|
|
932
|
+
authMiddlewareExcludesApi,
|
|
933
|
+
authMiddlewareExcludesPages,
|
|
653
934
|
hasRateLimiting,
|
|
654
935
|
detectedFrameworks,
|
|
655
936
|
gitignoreContent,
|
|
@@ -740,12 +1021,21 @@ function summarizeFindings(findings) {
|
|
|
740
1021
|
}
|
|
741
1022
|
|
|
742
1023
|
// src/rules/secrets.ts
|
|
1024
|
+
function nameJwt(token) {
|
|
1025
|
+
try {
|
|
1026
|
+
const claims = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
|
|
1027
|
+
if (claims?.role === "anon") return null;
|
|
1028
|
+
if (claims?.role === "service_role") return "Supabase service role key";
|
|
1029
|
+
} catch {
|
|
1030
|
+
}
|
|
1031
|
+
return "JWT";
|
|
1032
|
+
}
|
|
743
1033
|
var SECRET_PATTERNS = [
|
|
744
1034
|
{ name: "Stripe secret key", pattern: /sk_live_[a-zA-Z0-9]{8,}/ },
|
|
745
1035
|
{ name: "Stripe test key", pattern: /sk_test_[a-zA-Z0-9]{8,}/ },
|
|
746
1036
|
{ name: "AWS access key", pattern: /AKIA[0-9A-Z]{16}/ },
|
|
747
1037
|
{ name: "AWS secret key", pattern: /(?:aws_secret_access_key|AWS_SECRET)\s*[=:]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/ },
|
|
748
|
-
{ name: "
|
|
1038
|
+
{ name: "JWT", pattern: /eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.[A-Za-z0-9_-]{50,}\.[A-Za-z0-9_-]{20,}/, nameMatch: nameJwt },
|
|
749
1039
|
{ name: "OpenAI API key (legacy)", pattern: /sk-[a-zA-Z0-9]{20,}T3BlbkFJ[a-zA-Z0-9]{20,}/ },
|
|
750
1040
|
{ name: "OpenAI API key", pattern: /sk-proj-[a-zA-Z0-9_\-]{20,}/ },
|
|
751
1041
|
{ name: "Anthropic API key", pattern: /sk-ant-(?:api|admin)[0-9]{2}-[a-zA-Z0-9_\-]{20,}/ },
|
|
@@ -780,15 +1070,17 @@ var secretsRule = {
|
|
|
780
1070
|
for (let i = 0; i < file.lines.length; i++) {
|
|
781
1071
|
const line = file.lines[i];
|
|
782
1072
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
783
|
-
for (const { name, pattern } of SECRET_PATTERNS) {
|
|
1073
|
+
for (const { name, pattern, nameMatch } of SECRET_PATTERNS) {
|
|
784
1074
|
const match = pattern.exec(line);
|
|
785
1075
|
if (match) {
|
|
1076
|
+
const reported = nameMatch ? nameMatch(match[0]) : name;
|
|
1077
|
+
if (!reported) continue;
|
|
786
1078
|
findings.push({
|
|
787
1079
|
ruleId: "secrets",
|
|
788
1080
|
file: file.relativePath,
|
|
789
1081
|
line: i + 1,
|
|
790
1082
|
column: match.index + 1,
|
|
791
|
-
message: `Hardcoded ${
|
|
1083
|
+
message: `Hardcoded ${reported} detected`,
|
|
792
1084
|
severity: "critical",
|
|
793
1085
|
category: "security",
|
|
794
1086
|
fix: "Move the secret to an environment variable and access via process.env.SECRET_NAME"
|
|
@@ -944,12 +1236,217 @@ var AUTH_PATTERNS = [
|
|
|
944
1236
|
/createRouteHandlerClient/,
|
|
945
1237
|
/createServerComponentClient/,
|
|
946
1238
|
/createMiddlewareClient/,
|
|
947
|
-
|
|
1239
|
+
// Reading the request's Authorization header, not sending one upstream:
|
|
1240
|
+
// headers.get('authorization'), req.headers.authorization, req.headers['authorization'], req.header('Authorization')
|
|
1241
|
+
/\.(?:get|header)\(\s*['"`]authorization['"`]/i,
|
|
1242
|
+
/\.headers\??\.authorization\b(?!\s*=(?!=))/i,
|
|
1243
|
+
/\.headers\[\s*['"`]authorization['"`]\s*\](?!\s*=(?!=))/i,
|
|
948
1244
|
/getAuth\s*\(/,
|
|
949
1245
|
/withPageAuth/,
|
|
950
1246
|
/cookies\(\).*auth/s
|
|
951
1247
|
];
|
|
952
|
-
var
|
|
1248
|
+
var MUTATION_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
1249
|
+
var HANDLER_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
|
|
1250
|
+
var TRPC_ROUTE = /['"]@trpc\/server\/adapters\//;
|
|
1251
|
+
function servedUnderApi(relativePath) {
|
|
1252
|
+
const pages = /(?:^|\/)pages\/api\/(.+)$/.exec(relativePath);
|
|
1253
|
+
if (pages) return !/^index\.\w+$/.test(pages[1]);
|
|
1254
|
+
const segments = relativePath.split("/");
|
|
1255
|
+
const app = segments.indexOf("app");
|
|
1256
|
+
if (app === -1) return false;
|
|
1257
|
+
const url = segments.slice(app + 1, -1).filter((s) => !/^\(.*\)$/.test(s) && !s.startsWith("@"));
|
|
1258
|
+
return url[0] === "api" && url.length > 1;
|
|
1259
|
+
}
|
|
1260
|
+
var AUTH_WORD = /^(?:auth(?!or(?:s|ed|ing|ship)?$)|session)/i;
|
|
1261
|
+
var NO_AUTH_WORD = /^(?:without|no|non|skip|bypass|optional|public|anon|anonymous|guest)$/i;
|
|
1262
|
+
function words(name) {
|
|
1263
|
+
return name.split(/[^A-Za-z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/).filter(Boolean);
|
|
1264
|
+
}
|
|
1265
|
+
function saysAuth(name) {
|
|
1266
|
+
const w = words(name);
|
|
1267
|
+
return w.some((x) => AUTH_WORD.test(x)) && !w.some((x) => NO_AUTH_WORD.test(x));
|
|
1268
|
+
}
|
|
1269
|
+
function isAuthHelper(name, source) {
|
|
1270
|
+
if (words(name).some((x) => NO_AUTH_WORD.test(x))) return false;
|
|
1271
|
+
return saysAuth(name) || /^with[A-Z0-9_$]/.test(name) && source !== void 0 && saysAuth(source);
|
|
1272
|
+
}
|
|
1273
|
+
var AUTHED = { authed: true, local: true };
|
|
1274
|
+
var LOCAL = { authed: false, local: true };
|
|
1275
|
+
var ELSEWHERE = { authed: false, local: false };
|
|
1276
|
+
function handlersFromAst(program) {
|
|
1277
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1278
|
+
const bindings = /* @__PURE__ */ new Map();
|
|
1279
|
+
for (const st of program.body) {
|
|
1280
|
+
if (st.type === "ImportDeclaration") {
|
|
1281
|
+
for (const s of st.specifiers) imports.set(s.local.name, st.source.value);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
const decl = st.type === "ExportNamedDeclaration" || st.type === "ExportDefaultDeclaration" ? st.declaration : st;
|
|
1285
|
+
if (decl?.type === "FunctionDeclaration" && decl.id) bindings.set(decl.id.name, decl);
|
|
1286
|
+
if (decl?.type === "VariableDeclaration") {
|
|
1287
|
+
for (const d of decl.declarations) {
|
|
1288
|
+
if (d.id.type === "Identifier" && d.init) bindings.set(d.id.name, d.init);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
const helperOf = (callee) => {
|
|
1293
|
+
if (callee.type === "CallExpression") return helperOf(callee.callee);
|
|
1294
|
+
if (callee.type === "Identifier") return { name: callee.name, source: imports.get(callee.name) };
|
|
1295
|
+
if (callee.type === "MemberExpression" && callee.property.type === "Identifier") {
|
|
1296
|
+
let root = callee.object;
|
|
1297
|
+
while (root.type === "MemberExpression") root = root.object;
|
|
1298
|
+
return { name: callee.property.name, source: root.type === "Identifier" ? imports.get(root.name) : void 0 };
|
|
1299
|
+
}
|
|
1300
|
+
return null;
|
|
1301
|
+
};
|
|
1302
|
+
const classify = (node, seen) => {
|
|
1303
|
+
while (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression" || node.type === "TSTypeAssertion" || node.type === "ParenthesizedExpression" || node.type === "AwaitExpression") {
|
|
1304
|
+
node = node.type === "AwaitExpression" ? node.argument : node.expression;
|
|
1305
|
+
}
|
|
1306
|
+
switch (node.type) {
|
|
1307
|
+
case "FunctionDeclaration":
|
|
1308
|
+
case "FunctionExpression":
|
|
1309
|
+
case "ArrowFunctionExpression":
|
|
1310
|
+
return LOCAL;
|
|
1311
|
+
case "Identifier": {
|
|
1312
|
+
const bound = bindings.get(node.name);
|
|
1313
|
+
if (!bound || seen.has(node.name)) return ELSEWHERE;
|
|
1314
|
+
return classify(bound, new Set(seen).add(node.name));
|
|
1315
|
+
}
|
|
1316
|
+
case "CallExpression": {
|
|
1317
|
+
const helper = helperOf(node.callee);
|
|
1318
|
+
if (helper && isAuthHelper(helper.name, helper.source)) return AUTHED;
|
|
1319
|
+
let local = false;
|
|
1320
|
+
for (const arg of node.callee.type === "CallExpression" ? [node.callee, ...node.arguments] : node.arguments) {
|
|
1321
|
+
const verdict = classify(arg, seen);
|
|
1322
|
+
if (verdict.authed) return verdict;
|
|
1323
|
+
local ||= verdict.local;
|
|
1324
|
+
}
|
|
1325
|
+
return { authed: false, local };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
return ELSEWHERE;
|
|
1329
|
+
};
|
|
1330
|
+
const handlers = [];
|
|
1331
|
+
const add = (method, at, verdict) => {
|
|
1332
|
+
handlers.push({ method, line: at.loc?.start.line ?? 1, ...verdict });
|
|
1333
|
+
};
|
|
1334
|
+
for (const st of program.body) {
|
|
1335
|
+
if (st.type === "ExportNamedDeclaration") {
|
|
1336
|
+
const decl = st.declaration;
|
|
1337
|
+
if (decl?.type === "FunctionDeclaration") {
|
|
1338
|
+
if (decl.id && HANDLER_METHODS.has(decl.id.name)) add(decl.id.name, st, LOCAL);
|
|
1339
|
+
} else if (decl?.type === "VariableDeclaration") {
|
|
1340
|
+
for (const d of decl.declarations) {
|
|
1341
|
+
if (d.id.type === "Identifier" && HANDLER_METHODS.has(d.id.name)) {
|
|
1342
|
+
add(d.id.name, d, d.init ? classify(d.init, /* @__PURE__ */ new Set()) : ELSEWHERE);
|
|
1343
|
+
} else if (d.id.type === "ObjectPattern") {
|
|
1344
|
+
for (const p of d.id.properties) {
|
|
1345
|
+
if (p.type === "ObjectProperty" && p.value.type === "Identifier" && HANDLER_METHODS.has(p.value.name)) {
|
|
1346
|
+
add(p.value.name, d, ELSEWHERE);
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
} else if (!decl) {
|
|
1352
|
+
for (const s of st.specifiers) {
|
|
1353
|
+
if (s.type !== "ExportSpecifier") continue;
|
|
1354
|
+
const name = s.exported.type === "Identifier" ? s.exported.name : s.exported.value;
|
|
1355
|
+
if (name === "default" || HANDLER_METHODS.has(name)) add(name, s, st.source ? ELSEWHERE : classify(s.local, /* @__PURE__ */ new Set()));
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
} else if (st.type === "ExportDefaultDeclaration") {
|
|
1359
|
+
add("default", st, classify(st.declaration, /* @__PURE__ */ new Set()));
|
|
1360
|
+
} else if (st.type === "ExportAllDeclaration") {
|
|
1361
|
+
add("*", st, ELSEWHERE);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
return handlers;
|
|
1365
|
+
}
|
|
1366
|
+
var METHOD = "(GET|HEAD|POST|PUT|PATCH|DELETE)";
|
|
1367
|
+
var EXPORT_FUNCTION = new RegExp(`^\\s*export\\s+(?:async\\s+)?function\\s*\\*?\\s*${METHOD}(?![\\w$])`);
|
|
1368
|
+
var EXPORT_CONST = new RegExp(`^\\s*export\\s+(?:const|let|var)\\s+${METHOD}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1369
|
+
var EXPORT_DESTRUCTURED = /^\s*export\s+(?:const|let|var)\s*\{([^}]*)\}\s*=/;
|
|
1370
|
+
var EXPORT_DEFAULT = /^\s*export\s+default\s+(.*)$/;
|
|
1371
|
+
var EXPORT_ALL = /^\s*export\s*\*\s*from\b/;
|
|
1372
|
+
var EXPORT_LIST = /\bexport\s*\{([^}]*)\}(\s*from\b)?/g;
|
|
1373
|
+
var IMPORT = /\bimport\s+(?!type\b)([\w$*{}\s,]+?)\s+from\s*['"]([^'"]+)['"]/g;
|
|
1374
|
+
var INLINE_FUNCTION = /^(?:async\b|function\b|\([^)]*\)\s*(?::[^=]+)?=>|[\w$]+\s*=>)/;
|
|
1375
|
+
var CALL = /^([A-Za-z_$][\w$]*)(?:\s*\.\s*([A-Za-z_$][\w$]*))*\s*(?:<[^>()]*>)?\s*\(/;
|
|
1376
|
+
var IDENTIFIER = /^([A-Za-z_$][\w$]*)\s*(?:[,);\n]|$)/;
|
|
1377
|
+
function handlersFromText(file) {
|
|
1378
|
+
const imports = /* @__PURE__ */ new Map();
|
|
1379
|
+
for (const m of file.content.matchAll(IMPORT)) {
|
|
1380
|
+
for (const part of m[1].replace(/[{}]/g, ",").split(",")) {
|
|
1381
|
+
const spec = /^\s*(?:type\s+)?(?:\*\s+as\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1382
|
+
if (spec) imports.set(spec[2] ?? spec[1], m[2]);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
const code = (i) => !isCommentLine(file.lines, i, file.commentMap);
|
|
1386
|
+
const value = (i, head) => [head, ...file.lines.slice(i + 1, i + 5).filter((_, k) => code(i + 1 + k))].join("\n");
|
|
1387
|
+
const resolve4 = (name, seen) => {
|
|
1388
|
+
const id = name.replace(/\$/g, "\\$");
|
|
1389
|
+
const fn = new RegExp(`^\\s*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?function\\s*\\*?\\s*${id}(?![\\w$])`);
|
|
1390
|
+
const variable = new RegExp(`^\\s*(?:export\\s+)?(?:const|let|var)\\s+${id}(?![\\w$])\\s*(?::[^=]*)?=(?![=>])(.*)$`);
|
|
1391
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1392
|
+
if (!code(i)) continue;
|
|
1393
|
+
if (fn.test(file.lines[i])) return LOCAL;
|
|
1394
|
+
const m = variable.exec(file.lines[i]);
|
|
1395
|
+
if (m) return classify(value(i, m[1]), seen);
|
|
1396
|
+
}
|
|
1397
|
+
return ELSEWHERE;
|
|
1398
|
+
};
|
|
1399
|
+
const classify = (text, seen) => {
|
|
1400
|
+
const t = text.trim().replace(/^await\s+/, "");
|
|
1401
|
+
if (INLINE_FUNCTION.test(t)) return LOCAL;
|
|
1402
|
+
const call = CALL.exec(t);
|
|
1403
|
+
if (call) {
|
|
1404
|
+
if (isAuthHelper(call[2] ?? call[1], imports.get(call[1]))) return AUTHED;
|
|
1405
|
+
let args = t.slice(call[0].length);
|
|
1406
|
+
const curried = /^[^()]*\)\s*\(/.exec(args);
|
|
1407
|
+
if (curried) args = args.slice(curried[0].length);
|
|
1408
|
+
return classify(args, seen);
|
|
1409
|
+
}
|
|
1410
|
+
const id = IDENTIFIER.exec(t);
|
|
1411
|
+
if (id && !seen.has(id[1])) return resolve4(id[1], new Set(seen).add(id[1]));
|
|
1412
|
+
return ELSEWHERE;
|
|
1413
|
+
};
|
|
1414
|
+
const handlers = [];
|
|
1415
|
+
for (let i = 0; i < file.lines.length; i++) {
|
|
1416
|
+
if (!code(i)) continue;
|
|
1417
|
+
const line = file.lines[i];
|
|
1418
|
+
let m;
|
|
1419
|
+
if (m = EXPORT_FUNCTION.exec(line)) {
|
|
1420
|
+
handlers.push({ method: m[1], line: i + 1, ...LOCAL });
|
|
1421
|
+
} else if (m = EXPORT_CONST.exec(line)) {
|
|
1422
|
+
handlers.push({ method: m[1], line: i + 1, ...classify(value(i, m[2]), /* @__PURE__ */ new Set()) });
|
|
1423
|
+
} else if (m = EXPORT_DESTRUCTURED.exec(line)) {
|
|
1424
|
+
for (const part of m[1].split(",")) {
|
|
1425
|
+
const name = part.split(":").pop()?.trim() ?? "";
|
|
1426
|
+
if (HANDLER_METHODS.has(name)) handlers.push({ method: name, line: i + 1, ...ELSEWHERE });
|
|
1427
|
+
}
|
|
1428
|
+
} else if (m = EXPORT_DEFAULT.exec(line)) {
|
|
1429
|
+
handlers.push({ method: "default", line: i + 1, ...classify(value(i, m[1]), /* @__PURE__ */ new Set()) });
|
|
1430
|
+
} else if (EXPORT_ALL.test(line)) {
|
|
1431
|
+
handlers.push({ method: "*", line: i + 1, ...ELSEWHERE });
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
const lineAt = (offset) => file.content.slice(0, offset).split("\n").length;
|
|
1435
|
+
for (const m of file.content.matchAll(EXPORT_LIST)) {
|
|
1436
|
+
if (!code(lineAt(m.index) - 1)) continue;
|
|
1437
|
+
let offset = m.index + m[0].indexOf("{") + 1;
|
|
1438
|
+
for (const part of m[1].split(",")) {
|
|
1439
|
+
const spec = /^\s*(?:type\s+)?([\w$]+)(?:\s+as\s+([\w$]+))?\s*$/.exec(part);
|
|
1440
|
+
const name = spec ? spec[2] ?? spec[1] : "";
|
|
1441
|
+
if (spec && (name === "default" || HANDLER_METHODS.has(name))) {
|
|
1442
|
+
const line = lineAt(offset + part.length - part.trimStart().length);
|
|
1443
|
+
handlers.push({ method: name, line, ...m[2] ? ELSEWHERE : classify(spec[1], /* @__PURE__ */ new Set()) });
|
|
1444
|
+
}
|
|
1445
|
+
offset += part.length + 1;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return handlers.sort((a, b) => a.line - b.line);
|
|
1449
|
+
}
|
|
953
1450
|
var authChecksRule = {
|
|
954
1451
|
id: "auth-checks",
|
|
955
1452
|
name: "Missing Auth Checks",
|
|
@@ -965,22 +1462,21 @@ var authChecksRule = {
|
|
|
965
1462
|
for (const pattern of AUTH_PATTERNS) {
|
|
966
1463
|
if (pattern.test(file.content)) return [];
|
|
967
1464
|
}
|
|
1465
|
+
const handlers = file.ast ? handlersFromAst(file.ast.program) : handlersFromText(file);
|
|
1466
|
+
const exposed = handlers.filter((h) => !h.authed);
|
|
1467
|
+
if (handlers.length > 0 && exposed.length === 0) return [];
|
|
1468
|
+
const middlewareSkipsRoute = project.hasAuthMiddleware && project.authMiddlewareExcludesApi === true && servedUnderApi(file.relativePath);
|
|
1469
|
+
const middlewareAuth = project.hasAuthMiddleware && !middlewareSkipsRoute;
|
|
968
1470
|
let severity;
|
|
969
|
-
if (
|
|
1471
|
+
if (middlewareAuth) {
|
|
970
1472
|
severity = "info";
|
|
971
|
-
} else if (
|
|
1473
|
+
} else if (exposed.some((h) => h.local && MUTATION_METHODS.has(h.method)) && !TRPC_ROUTE.test(file.content)) {
|
|
972
1474
|
severity = "critical";
|
|
973
1475
|
} else {
|
|
974
1476
|
severity = "info";
|
|
975
1477
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
if (/export\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|handler)/i.test(file.lines[i])) {
|
|
979
|
-
handlerLine = i + 1;
|
|
980
|
-
break;
|
|
981
|
-
}
|
|
982
|
-
}
|
|
983
|
-
const message = project.hasAuthMiddleware ? "API route has no inline auth check (middleware auth detected \u2014 verify coverage)" : "API route has no authentication check";
|
|
1478
|
+
const handlerLine = exposed[0]?.line ?? 1;
|
|
1479
|
+
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";
|
|
984
1480
|
return [{
|
|
985
1481
|
ruleId: "auth-checks",
|
|
986
1482
|
file: file.relativePath,
|
|
@@ -1098,7 +1594,8 @@ var errorHandlingRule = {
|
|
|
1098
1594
|
|
|
1099
1595
|
// src/rules/input-validation.ts
|
|
1100
1596
|
var VALIDATION_PATTERNS = [
|
|
1101
|
-
|
|
1597
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
1598
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
1102
1599
|
/\.safeParse\s*\(/,
|
|
1103
1600
|
/\.validate\s*\(/,
|
|
1104
1601
|
/\.validateSync\s*\(/,
|
|
@@ -1399,6 +1896,40 @@ var aiSmellsRule = {
|
|
|
1399
1896
|
};
|
|
1400
1897
|
|
|
1401
1898
|
// src/rules/unsafe-html.ts
|
|
1899
|
+
var SANITIZER_NAME = /^(?:sanitize\w*|xss|filterXSS)$/i;
|
|
1900
|
+
var SANITIZER_CALL_TEXT = /^\s*(?:await\s+)?(?:[\w$.]*\.sanitize|sanitize\w*|xss|filterXSS)\s*\(/i;
|
|
1901
|
+
function isSanitizerCall(node) {
|
|
1902
|
+
if (node.type === "AwaitExpression") return isSanitizerCall(node.argument);
|
|
1903
|
+
if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "TSNonNullExpression") {
|
|
1904
|
+
return isSanitizerCall(node.expression);
|
|
1905
|
+
}
|
|
1906
|
+
if (node.type !== "CallExpression" && node.type !== "OptionalCallExpression") return false;
|
|
1907
|
+
const callee = node.callee;
|
|
1908
|
+
if (callee.type === "MemberExpression" || callee.type === "OptionalMemberExpression") {
|
|
1909
|
+
return callee.property.type === "Identifier" && callee.property.name === "sanitize";
|
|
1910
|
+
}
|
|
1911
|
+
return callee.type === "Identifier" && SANITIZER_NAME.test(callee.name);
|
|
1912
|
+
}
|
|
1913
|
+
function containsJsonStringify(node) {
|
|
1914
|
+
return subtreeContains(node, (n) => {
|
|
1915
|
+
if (n.type !== "CallExpression") return false;
|
|
1916
|
+
const call = n;
|
|
1917
|
+
if (call.callee.type === "MemberExpression") {
|
|
1918
|
+
const mem = call.callee;
|
|
1919
|
+
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1920
|
+
}
|
|
1921
|
+
return false;
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
function isSanitized(value, sanitizedNames) {
|
|
1925
|
+
if (value.type === "JSXExpressionContainer") return isSanitized(value.expression, sanitizedNames);
|
|
1926
|
+
if (value.type === "ObjectExpression") {
|
|
1927
|
+
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"));
|
|
1928
|
+
return html?.type === "ObjectProperty" && isSanitized(html.value, sanitizedNames);
|
|
1929
|
+
}
|
|
1930
|
+
if (value.type === "Identifier") return sanitizedNames.has(value.name);
|
|
1931
|
+
return isSanitizerCall(value);
|
|
1932
|
+
}
|
|
1402
1933
|
var unsafeHtmlRule = {
|
|
1403
1934
|
id: "unsafe-html",
|
|
1404
1935
|
name: "Unsafe HTML Rendering",
|
|
@@ -1410,21 +1941,17 @@ var unsafeHtmlRule = {
|
|
|
1410
1941
|
const findings = [];
|
|
1411
1942
|
if (file.ast) {
|
|
1412
1943
|
try {
|
|
1944
|
+
const sanitizedNames = /* @__PURE__ */ new Set();
|
|
1945
|
+
walkAST(file.ast.program, (node) => {
|
|
1946
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier" && node.init && subtreeContains(node.init, isSanitizerCall)) {
|
|
1947
|
+
sanitizedNames.add(node.id.name);
|
|
1948
|
+
}
|
|
1949
|
+
});
|
|
1413
1950
|
walkAST(file.ast.program, (node) => {
|
|
1414
1951
|
if (node.type === "JSXAttribute") {
|
|
1415
1952
|
const attr = node;
|
|
1416
1953
|
if (attr.name?.type === "JSXIdentifier" && attr.name.name === "dangerouslySetInnerHTML" && attr.loc) {
|
|
1417
|
-
if (attr.value &&
|
|
1418
|
-
if (n.type !== "CallExpression") return false;
|
|
1419
|
-
const call = n;
|
|
1420
|
-
if (call.callee.type === "MemberExpression") {
|
|
1421
|
-
const mem = call.callee;
|
|
1422
|
-
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1423
|
-
}
|
|
1424
|
-
return false;
|
|
1425
|
-
})) {
|
|
1426
|
-
return;
|
|
1427
|
-
}
|
|
1954
|
+
if (attr.value && (containsJsonStringify(attr.value) || isSanitized(attr.value, sanitizedNames))) return;
|
|
1428
1955
|
const line = attr.loc.start.line;
|
|
1429
1956
|
findings.push({
|
|
1430
1957
|
ruleId: "unsafe-html",
|
|
@@ -1441,17 +1968,7 @@ var unsafeHtmlRule = {
|
|
|
1441
1968
|
if (node.type === "ObjectProperty") {
|
|
1442
1969
|
const prop = node;
|
|
1443
1970
|
if (prop.key?.type === "Identifier" && prop.key.name === "dangerouslySetInnerHTML" && prop.loc) {
|
|
1444
|
-
if (prop.value &&
|
|
1445
|
-
if (n.type !== "CallExpression") return false;
|
|
1446
|
-
const call = n;
|
|
1447
|
-
if (call.callee.type === "MemberExpression") {
|
|
1448
|
-
const mem = call.callee;
|
|
1449
|
-
return mem.object.type === "Identifier" && mem.object.name === "JSON" && mem.property.type === "Identifier" && mem.property.name === "stringify";
|
|
1450
|
-
}
|
|
1451
|
-
return false;
|
|
1452
|
-
})) {
|
|
1453
|
-
return;
|
|
1454
|
-
}
|
|
1971
|
+
if (prop.value && (containsJsonStringify(prop.value) || isSanitized(prop.value, sanitizedNames))) return;
|
|
1455
1972
|
const line = prop.loc.start.line;
|
|
1456
1973
|
findings.push({
|
|
1457
1974
|
ruleId: "unsafe-html",
|
|
@@ -1468,6 +1985,7 @@ var unsafeHtmlRule = {
|
|
|
1468
1985
|
if (node.type === "AssignmentExpression") {
|
|
1469
1986
|
const assign = node;
|
|
1470
1987
|
if (assign.left?.type === "MemberExpression" && assign.left.property?.type === "Identifier" && assign.left.property.name === "innerHTML" && assign.loc) {
|
|
1988
|
+
if (isSanitized(assign.right, sanitizedNames)) return;
|
|
1471
1989
|
const line = assign.loc.start.line;
|
|
1472
1990
|
findings.push({
|
|
1473
1991
|
ruleId: "unsafe-html",
|
|
@@ -1498,6 +2016,8 @@ var unsafeHtmlRule = {
|
|
|
1498
2016
|
}
|
|
1499
2017
|
const expr = context.join(" ");
|
|
1500
2018
|
if (/__html\s*:\s*JSON\.stringify/.test(expr)) continue;
|
|
2019
|
+
const html = /__html\s*:/.exec(expr);
|
|
2020
|
+
if (html && SANITIZER_CALL_TEXT.test(expr.slice(html.index + html[0].length))) continue;
|
|
1501
2021
|
findings.push({
|
|
1502
2022
|
ruleId: "unsafe-html",
|
|
1503
2023
|
file: file.relativePath,
|
|
@@ -1509,7 +2029,8 @@ var unsafeHtmlRule = {
|
|
|
1509
2029
|
fix: "Sanitize HTML with DOMPurify before rendering: DOMPurify.sanitize(html)"
|
|
1510
2030
|
});
|
|
1511
2031
|
}
|
|
1512
|
-
|
|
2032
|
+
const innerHtml = /\w\.innerHTML\s*=/.exec(line);
|
|
2033
|
+
if (innerHtml && !SANITIZER_CALL_TEXT.test(line.slice(innerHtml.index + innerHtml[0].length))) {
|
|
1513
2034
|
findings.push({
|
|
1514
2035
|
ruleId: "unsafe-html",
|
|
1515
2036
|
file: file.relativePath,
|
|
@@ -1688,9 +2209,16 @@ var staleFallbackRule = {
|
|
|
1688
2209
|
};
|
|
1689
2210
|
|
|
1690
2211
|
// src/rules/hallucinated-api.ts
|
|
2212
|
+
var DOM_CONTAINS_RECEIVER = /(?:\b(?:classList|relList)|\.current|\bdocument(?:\.(?:body|documentElement|head))?)\??$/;
|
|
2213
|
+
var DOM_CONTAINS_ARGUMENT = /^\s*(?:[\w$.?]*\.(?:target|relatedTarget|currentTarget|activeElement)\b|[^)]*\bas\s+(?:Node|Element|HTML\w*Element)\b)/;
|
|
1691
2214
|
var HALLUCINATED_APIS = [
|
|
1692
2215
|
{ pattern: /\.flatten\s*\(/, fix: "Use .flat() instead of .flatten()", methodName: "flatten" },
|
|
1693
|
-
{
|
|
2216
|
+
{
|
|
2217
|
+
pattern: /\.contains\s*\(/,
|
|
2218
|
+
fix: "Use .includes() instead of .contains() on arrays and strings \u2014 DOM classList and Node .contains() are valid",
|
|
2219
|
+
methodName: "contains",
|
|
2220
|
+
isValid: (before, after) => DOM_CONTAINS_RECEIVER.test(before) || DOM_CONTAINS_ARGUMENT.test(after)
|
|
2221
|
+
},
|
|
1694
2222
|
{ pattern: /\.substr\s*\(/, fix: "Use .substring() or .slice() instead of deprecated .substr()", methodName: "substr" },
|
|
1695
2223
|
{ pattern: /\.trimLeft\s*\(/, fix: "Use .trimStart() instead of deprecated .trimLeft()", methodName: "trimLeft" },
|
|
1696
2224
|
{ pattern: /\.trimRight\s*\(/, fix: "Use .trimEnd() instead of deprecated .trimRight()", methodName: "trimRight" },
|
|
@@ -1712,10 +2240,11 @@ var hallucinatedApiRule = {
|
|
|
1712
2240
|
for (let i = 0; i < file.lines.length; i++) {
|
|
1713
2241
|
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
1714
2242
|
const line = file.lines[i];
|
|
1715
|
-
for (const { pattern, fix, methodName } of HALLUCINATED_APIS) {
|
|
2243
|
+
for (const { pattern, fix, methodName, isValid } of HALLUCINATED_APIS) {
|
|
1716
2244
|
const match = pattern.exec(line);
|
|
1717
2245
|
if (match) {
|
|
1718
2246
|
if (isFrameworkSafeMethod(methodName, frameworks)) continue;
|
|
2247
|
+
if (isValid?.(line.slice(0, match.index), line.slice(match.index + match[0].length))) continue;
|
|
1719
2248
|
findings.push({
|
|
1720
2249
|
ruleId: "hallucinated-api",
|
|
1721
2250
|
file: file.relativePath,
|
|
@@ -2351,9 +2880,190 @@ var codebaseConsistencyRule = {
|
|
|
2351
2880
|
};
|
|
2352
2881
|
|
|
2353
2882
|
// src/rules/dead-exports.ts
|
|
2354
|
-
function isEntryPoint(relativePath) {
|
|
2883
|
+
function isEntryPoint(relativePath, appRoots, routeModules) {
|
|
2355
2884
|
const name = relativePath.split("/").pop() ?? "";
|
|
2356
|
-
return /^(page|layout|loading|error|not-found|route|
|
|
2885
|
+
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
|
|
2886
|
+
/^\+(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
|
|
2887
|
+
// export as a story
|
|
2888
|
+
/\.stories\.(tsx?|jsx?|mjs)$/.test(name) || isNextRootFile(relativePath, appRoots) || isPagesRoute(relativePath, appRoots) || routeModules.has(relativePath);
|
|
2889
|
+
}
|
|
2890
|
+
function isBarrel(relativePath) {
|
|
2891
|
+
return /(?:^|\/)index\.(tsx?|jsx?)$/.test(relativePath);
|
|
2892
|
+
}
|
|
2893
|
+
function isAppFolder(dir, appRoots) {
|
|
2894
|
+
return appRoots.has(dir) || appRoots.has(dir.replace(/(?:^|\/)src$/, ""));
|
|
2895
|
+
}
|
|
2896
|
+
function isNextRootFile(relativePath, appRoots) {
|
|
2897
|
+
const match = /^(?:(.*)\/)?(?:middleware|proxy|instrumentation|instrumentation-client)\.(tsx?|jsx?)$/.exec(relativePath);
|
|
2898
|
+
return match !== null && isAppFolder(match[1] ?? "", appRoots);
|
|
2899
|
+
}
|
|
2900
|
+
function isPagesRoute(relativePath, appRoots) {
|
|
2901
|
+
if (!/\.(tsx?|jsx?)$/.test(relativePath)) return false;
|
|
2902
|
+
const parts = relativePath.split("/");
|
|
2903
|
+
return parts.some((part, i) => part === "pages" && isAppFolder(parts.slice(0, i).join("/"), appRoots));
|
|
2904
|
+
}
|
|
2905
|
+
function findAppRoots(paths) {
|
|
2906
|
+
const roots = /* @__PURE__ */ new Set([""]);
|
|
2907
|
+
for (const path of paths) {
|
|
2908
|
+
const match = /^(?:(.*)\/)?package\.json$/.exec(path);
|
|
2909
|
+
if (match) roots.add(match[1] ?? "");
|
|
2910
|
+
}
|
|
2911
|
+
return roots;
|
|
2912
|
+
}
|
|
2913
|
+
function findRouteModules(files, paths, appRoots) {
|
|
2914
|
+
const appFolders = [];
|
|
2915
|
+
for (const path of paths) {
|
|
2916
|
+
const match = /^(?:(.*)\/)?app\/root\.(tsx?|jsx?)$/.exec(path);
|
|
2917
|
+
if (match && appRoots.has(match[1] ?? "")) appFolders.push(path.slice(0, path.lastIndexOf("/")));
|
|
2918
|
+
}
|
|
2919
|
+
const modules = /* @__PURE__ */ new Set();
|
|
2920
|
+
for (const { relativePath, content } of files) {
|
|
2921
|
+
const folder = appFolders.find((f) => relativePath.startsWith(`${f}/`));
|
|
2922
|
+
if (folder === void 0) continue;
|
|
2923
|
+
const path = relativePath.slice(folder.length + 1);
|
|
2924
|
+
if (/^(?:root|entry\.server|routes\/.+)\.(tsx?|jsx?)$/.test(path)) {
|
|
2925
|
+
modules.add(relativePath);
|
|
2926
|
+
} else if (/^routes\.(?:[cm]?[jt]s|[jt]sx)$/.test(path)) {
|
|
2927
|
+
for (const match of content.matchAll(/['"`]([^'"`\n]+\.(?:tsx?|jsx?))['"`]/g)) {
|
|
2928
|
+
const module = normalizePath(`${folder}/${match[1]}`);
|
|
2929
|
+
if (module !== null) modules.add(module);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
return modules;
|
|
2934
|
+
}
|
|
2935
|
+
var IMPORT_NAMED = /\bimport\s*(?:type\s*)?(?:[\w$]+\s*,\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2936
|
+
var IMPORT_DEFAULT = /\bimport\s+([\w$]+)\s*(?:,\s*\{[^}]*\}\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2937
|
+
var IMPORT_NAMESPACE = /\bimport\s+(?:[\w$]+\s*,\s*)?\*\s*as\s+[\w$]+\s+from\s*['"]([^'"]+)['"]/g;
|
|
2938
|
+
var DYNAMIC_IMPORT = /(\btypeof\s+)?(?:\{([^{}]*)\}\s*=\s*await\s+)?\bimport\s*\(\s*['"`]([^'"`]+)['"`]\s*\)(?:\s*\.\s*([\w$]+))?/g;
|
|
2939
|
+
var REQUIRE = /(?:\{([^{}]*)\}\s*=\s*)?\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*([\w$]+))?/g;
|
|
2940
|
+
var REEXPORT_NAMED = /\bexport\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g;
|
|
2941
|
+
var REEXPORT_ALL = /\bexport\s*\*\s*(?:as\s+([\w$]+)\s*)?from\s*['"]([^'"]+)['"]/g;
|
|
2942
|
+
var MODULE_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mjs", "cjs"];
|
|
2943
|
+
var MODULE_EXTENSION = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
2944
|
+
function parseSpecifiers(list) {
|
|
2945
|
+
return list.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").split(",").map((s) => s.trim().replace(/^type\s+/, "")).filter(Boolean).map((s) => {
|
|
2946
|
+
const [name, alias] = s.split(/\s+as\s+/).map((part) => part.trim());
|
|
2947
|
+
return [name, alias ?? name];
|
|
2948
|
+
});
|
|
2949
|
+
}
|
|
2950
|
+
function normalizePath(path) {
|
|
2951
|
+
const parts = [];
|
|
2952
|
+
for (const part of path.split("/")) {
|
|
2953
|
+
if (part === "..") {
|
|
2954
|
+
if (parts.length === 0) return null;
|
|
2955
|
+
parts.pop();
|
|
2956
|
+
} else if (part && part !== ".") {
|
|
2957
|
+
parts.push(part);
|
|
2958
|
+
}
|
|
2959
|
+
}
|
|
2960
|
+
return parts.join("/");
|
|
2961
|
+
}
|
|
2962
|
+
var EXTERNAL = { files: [], folders: [] };
|
|
2963
|
+
var UNKNOWN = { files: [], folders: [""] };
|
|
2964
|
+
function isAsset(source) {
|
|
2965
|
+
const name = source.slice(source.lastIndexOf("/") + 1);
|
|
2966
|
+
return /^[^.].*\.\w+$/.test(name) && !MODULE_EXTENSION.test(name);
|
|
2967
|
+
}
|
|
2968
|
+
function stringsIn(value) {
|
|
2969
|
+
if (typeof value === "string") return [value];
|
|
2970
|
+
if (Array.isArray(value)) return value.flatMap(stringsIn);
|
|
2971
|
+
if (value && typeof value === "object") return Object.values(value).flatMap(stringsIn);
|
|
2972
|
+
return [];
|
|
2973
|
+
}
|
|
2974
|
+
function createResolver(files, project) {
|
|
2975
|
+
const modules = new Set(files.filter((f) => MODULE_EXTENSIONS.includes(f.ext)).map((f) => f.relativePath));
|
|
2976
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2977
|
+
for (const path of modules) {
|
|
2978
|
+
const key = path.replace(/\.\w+$/, "").replace(/(?:^|\/)index$/, "");
|
|
2979
|
+
const name = key.slice(key.lastIndexOf("/") + 1);
|
|
2980
|
+
const list = byName.get(name);
|
|
2981
|
+
if (list) list.push({ path, key });
|
|
2982
|
+
else byName.set(name, [{ path, key }]);
|
|
2983
|
+
}
|
|
2984
|
+
const manifests = [];
|
|
2985
|
+
for (const file of files) {
|
|
2986
|
+
if (!/(?:^|\/)package\.json$/.test(file.relativePath)) continue;
|
|
2987
|
+
try {
|
|
2988
|
+
const json = JSON.parse(file.content);
|
|
2989
|
+
if (json && typeof json === "object") manifests.push({ folder: file.relativePath.replace(/\/?package\.json$/, ""), json });
|
|
2990
|
+
} catch {
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
const packages = /* @__PURE__ */ new Map();
|
|
2994
|
+
for (const { folder, json } of manifests) {
|
|
2995
|
+
if (typeof json.name === "string" && json.name) packages.set(json.name, [...packages.get(json.name) ?? [], folder]);
|
|
2996
|
+
}
|
|
2997
|
+
const findModule = (path) => {
|
|
2998
|
+
const stem = path.replace(/\.(?:[cm]?js|jsx)$/, "");
|
|
2999
|
+
const index = path ? `${path}/index` : "index";
|
|
3000
|
+
return [
|
|
3001
|
+
path,
|
|
3002
|
+
// TypeScript's ESM style names the compiled file: ./format.js for format.ts
|
|
3003
|
+
...stem === path ? [] : [`${stem}.ts`, `${stem}.tsx`],
|
|
3004
|
+
...MODULE_EXTENSIONS.map((ext) => `${path}.${ext}`),
|
|
3005
|
+
...MODULE_EXTENSIONS.map((ext) => `${index}.${ext}`)
|
|
3006
|
+
].find((candidate) => modules.has(candidate));
|
|
3007
|
+
};
|
|
3008
|
+
const packageEntries = /* @__PURE__ */ new Set();
|
|
3009
|
+
for (const { folder, json } of manifests) {
|
|
3010
|
+
if (json.private === true) continue;
|
|
3011
|
+
for (const target of stringsIn([json.main, json.module, json.types, json.typings, json.exports, json.bin])) {
|
|
3012
|
+
const isSource = /\.(?:[cm]?ts|tsx)$/.test(target) && !/\.d\.[cm]?ts$/.test(target);
|
|
3013
|
+
const path = isSource || target.includes("*") ? null : normalizePath(`${folder}/${target}`);
|
|
3014
|
+
if (path === null) continue;
|
|
3015
|
+
const stem = path.replace(/\.d\.ts$/, "").replace(MODULE_EXTENSION, "");
|
|
3016
|
+
const entry = findModule(path) ?? findModule(stem) ?? findModule(stem.replace(/(^|\/)(?:dist|build|lib|out|esm|cjs)\//, "$1src/"));
|
|
3017
|
+
if (entry) packageEntries.add(entry);
|
|
3018
|
+
}
|
|
3019
|
+
}
|
|
3020
|
+
const resolveRelative = (path) => {
|
|
3021
|
+
const normalized = normalizePath(path);
|
|
3022
|
+
if (normalized?.includes("${")) {
|
|
3023
|
+
const parts = normalized.split(/\$\{[^}]*\}/).map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
3024
|
+
const pattern = new RegExp(`^${parts.join(".*")}(?:\\.\\w+)?$`);
|
|
3025
|
+
return { files: [...modules].filter((m) => pattern.test(m)), folders: [] };
|
|
3026
|
+
}
|
|
3027
|
+
const module = normalized === null ? void 0 : findModule(normalized);
|
|
3028
|
+
if (module) return { files: [module], folders: [] };
|
|
3029
|
+
return isAsset(path) ? EXTERNAL : UNKNOWN;
|
|
3030
|
+
};
|
|
3031
|
+
const resolveBare = (source) => {
|
|
3032
|
+
if (/^[a-z][\w+.-]*:/i.test(source)) return EXTERNAL;
|
|
3033
|
+
const name = source.startsWith("@") ? source.split("/").slice(0, 2).join("/") : source.split("/")[0];
|
|
3034
|
+
const folders = packages.get(name);
|
|
3035
|
+
if (folders) {
|
|
3036
|
+
const subpath = source.slice(name.length + 1);
|
|
3037
|
+
const found = folders.map((folder) => {
|
|
3038
|
+
const base = folder ? `${folder}/` : "";
|
|
3039
|
+
return subpath ? findModule(base + subpath) ?? findModule(`${base}src/${subpath}`) : findModule(folder) ?? findModule(`${base}src`);
|
|
3040
|
+
});
|
|
3041
|
+
return { files: found.filter((f) => f !== void 0), folders };
|
|
3042
|
+
}
|
|
3043
|
+
if (NODE_BUILTINS.has(name) || project.declaredDependencies.has(name)) return EXTERNAL;
|
|
3044
|
+
const segments = source.replace(MODULE_EXTENSION, "").split("/").filter(Boolean);
|
|
3045
|
+
const named = byName.get(segments[segments.length - 1] ?? "") ?? [];
|
|
3046
|
+
for (let i = 0; i < segments.length; i++) {
|
|
3047
|
+
const path = segments.slice(i).join("/");
|
|
3048
|
+
const matches = named.filter((m) => m.key === path || m.key.endsWith(`/${path}`));
|
|
3049
|
+
if (matches.length > 0) return i <= 1 ? { files: matches.map((m) => m.path), folders: [] } : UNKNOWN;
|
|
3050
|
+
}
|
|
3051
|
+
return isAsset(source) ? EXTERNAL : UNKNOWN;
|
|
3052
|
+
};
|
|
3053
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3054
|
+
const resolve4 = (source, from) => {
|
|
3055
|
+
const clean = source.replace(/\?.*$/, "");
|
|
3056
|
+
const relative = /^\.\.?(?:\/|$)/.test(clean);
|
|
3057
|
+
const dir = relative ? from.slice(0, from.lastIndexOf("/") + 1) : "";
|
|
3058
|
+
const key = `${dir}\0${clean}`;
|
|
3059
|
+
let target = cache.get(key);
|
|
3060
|
+
if (!target) {
|
|
3061
|
+
target = relative ? resolveRelative(dir + clean) : resolveBare(clean);
|
|
3062
|
+
cache.set(key, target);
|
|
3063
|
+
}
|
|
3064
|
+
return target;
|
|
3065
|
+
};
|
|
3066
|
+
return { resolve: resolve4, packageEntries };
|
|
2357
3067
|
}
|
|
2358
3068
|
var THRESHOLD = 5;
|
|
2359
3069
|
var deadExportsRule = {
|
|
@@ -2366,16 +3076,16 @@ var deadExportsRule = {
|
|
|
2366
3076
|
check() {
|
|
2367
3077
|
return [];
|
|
2368
3078
|
},
|
|
2369
|
-
checkProject(files,
|
|
3079
|
+
checkProject(files, project) {
|
|
2370
3080
|
const sourceFiles = files.filter(
|
|
2371
3081
|
(f) => ["ts", "tsx", "js", "jsx"].includes(f.ext) && !isTestFile(f.relativePath) && !isScriptFile(f.relativePath)
|
|
2372
3082
|
);
|
|
3083
|
+
const appRoots = findAppRoots(project.allFiles);
|
|
3084
|
+
const routeModules = findRouteModules(files, project.allFiles, appRoots);
|
|
3085
|
+
const { resolve: resolve4, packageEntries } = createResolver(files, project);
|
|
2373
3086
|
const exports = /* @__PURE__ */ new Map();
|
|
2374
|
-
const imports = /* @__PURE__ */ new Map();
|
|
2375
|
-
const allImportedSymbols = /* @__PURE__ */ new Set();
|
|
2376
|
-
const importedFiles = /* @__PURE__ */ new Set();
|
|
2377
3087
|
for (const file of sourceFiles) {
|
|
2378
|
-
if (isEntryPoint(file.relativePath)) continue;
|
|
3088
|
+
if (isEntryPoint(file.relativePath, appRoots, routeModules) || packageEntries.has(file.relativePath)) continue;
|
|
2379
3089
|
let inTemplateLiteral = false;
|
|
2380
3090
|
for (let i = 0; i < file.lines.length; i++) {
|
|
2381
3091
|
const line = file.lines[i];
|
|
@@ -2416,45 +3126,92 @@ var deadExportsRule = {
|
|
|
2416
3126
|
}
|
|
2417
3127
|
}
|
|
2418
3128
|
}
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2431
|
-
set.add(sym);
|
|
2432
|
-
imports.set(fromBasename, set);
|
|
2433
|
-
}
|
|
2434
|
-
}
|
|
3129
|
+
const contents = new Map(files.map((f) => [f.relativePath, f.content]));
|
|
3130
|
+
const used = /* @__PURE__ */ new Set();
|
|
3131
|
+
const usedInFolder = /* @__PURE__ */ new Map();
|
|
3132
|
+
const reexports = /* @__PURE__ */ new Map();
|
|
3133
|
+
const reexportsOf = (file) => {
|
|
3134
|
+
let list = reexports.get(file);
|
|
3135
|
+
if (!list) {
|
|
3136
|
+
list = [];
|
|
3137
|
+
const content = contents.get(file) ?? "";
|
|
3138
|
+
for (const match of content.matchAll(REEXPORT_NAMED)) {
|
|
3139
|
+
for (const [imported, exported] of parseSpecifiers(match[1])) list.push({ source: match[2], imported, exported });
|
|
2435
3140
|
}
|
|
2436
|
-
const
|
|
2437
|
-
|
|
2438
|
-
allImportedSymbols.add(match[1]);
|
|
2439
|
-
if (fromBasename) {
|
|
2440
|
-
const set = imports.get(fromBasename) ?? /* @__PURE__ */ new Set();
|
|
2441
|
-
set.add(match[1]);
|
|
2442
|
-
imports.set(fromBasename, set);
|
|
2443
|
-
}
|
|
3141
|
+
for (const match of content.matchAll(REEXPORT_ALL)) {
|
|
3142
|
+
list.push(match[1] ? { source: match[2], imported: "*", exported: match[1] } : { source: match[2], imported: "", exported: "*" });
|
|
2444
3143
|
}
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
3144
|
+
reexports.set(file, list);
|
|
3145
|
+
}
|
|
3146
|
+
return list;
|
|
3147
|
+
};
|
|
3148
|
+
const use = (target, name) => {
|
|
3149
|
+
if (name !== "*" && name !== "default") {
|
|
3150
|
+
for (const folder of target.folders) {
|
|
3151
|
+
const names = usedInFolder.get(folder) ?? /* @__PURE__ */ new Set();
|
|
3152
|
+
names.add(name);
|
|
3153
|
+
usedInFolder.set(folder, names);
|
|
2448
3154
|
}
|
|
2449
3155
|
}
|
|
3156
|
+
for (const file of target.files) {
|
|
3157
|
+
const key = `${file}::${name}`;
|
|
3158
|
+
if (used.has(key)) continue;
|
|
3159
|
+
used.add(key);
|
|
3160
|
+
for (const { source, imported, exported } of reexportsOf(file)) {
|
|
3161
|
+
if (exported === "*") {
|
|
3162
|
+
if (name !== "default") use(resolve4(source, file), name);
|
|
3163
|
+
} else if (name === "*" || name === exported) {
|
|
3164
|
+
use(resolve4(source, file), imported);
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
};
|
|
3169
|
+
const useLoaded = (target, destructured, member, typeOnly) => {
|
|
3170
|
+
if (member && !["then", "catch", "finally"].includes(member)) {
|
|
3171
|
+
use(target, member);
|
|
3172
|
+
} else if (typeOnly) {
|
|
3173
|
+
return;
|
|
3174
|
+
} else if (destructured !== void 0 && !destructured.includes("...")) {
|
|
3175
|
+
for (const name of destructured.split(",").map((s) => s.split(/[:=]/)[0].trim()).filter(Boolean)) use(target, name);
|
|
3176
|
+
} else {
|
|
3177
|
+
use(target, "*");
|
|
3178
|
+
}
|
|
3179
|
+
};
|
|
3180
|
+
for (const file of files) {
|
|
3181
|
+
const from = file.relativePath;
|
|
3182
|
+
for (const match of file.content.matchAll(IMPORT_NAMED)) {
|
|
3183
|
+
for (const [name] of parseSpecifiers(match[1])) use(resolve4(match[2], from), name);
|
|
3184
|
+
}
|
|
3185
|
+
for (const match of file.content.matchAll(IMPORT_DEFAULT)) {
|
|
3186
|
+
const target = resolve4(match[2], from);
|
|
3187
|
+
use(target, "default");
|
|
3188
|
+
use(target, match[1]);
|
|
3189
|
+
}
|
|
3190
|
+
for (const match of file.content.matchAll(IMPORT_NAMESPACE)) use(resolve4(match[1], from), "*");
|
|
3191
|
+
for (const match of file.content.matchAll(DYNAMIC_IMPORT)) {
|
|
3192
|
+
const [, typeOnly, destructured, source, member] = match;
|
|
3193
|
+
useLoaded(resolve4(source, from), destructured, member, Boolean(typeOnly));
|
|
3194
|
+
}
|
|
3195
|
+
for (const match of file.content.matchAll(REQUIRE)) {
|
|
3196
|
+
const [, destructured, source, member] = match;
|
|
3197
|
+
useLoaded(resolve4(source, from), destructured, member, false);
|
|
3198
|
+
}
|
|
3199
|
+
if (isEntryPoint(from, appRoots, routeModules) && !isBarrel(from)) use({ files: [from], folders: [] }, "*");
|
|
2450
3200
|
}
|
|
3201
|
+
for (const entry of packageEntries) use({ files: [entry], folders: [] }, "*");
|
|
3202
|
+
const usedInAFolderOf = (file, name) => {
|
|
3203
|
+
let folder = file;
|
|
3204
|
+
while (folder) {
|
|
3205
|
+
folder = folder.slice(0, Math.max(folder.lastIndexOf("/"), 0));
|
|
3206
|
+
if (usedInFolder.get(folder)?.has(name)) return true;
|
|
3207
|
+
}
|
|
3208
|
+
return false;
|
|
3209
|
+
};
|
|
2451
3210
|
const deadByFile = /* @__PURE__ */ new Map();
|
|
2452
3211
|
for (const [key, loc] of exports) {
|
|
2453
3212
|
const symbolName = key.split("::")[1];
|
|
2454
|
-
const
|
|
2455
|
-
|
|
2456
|
-
const isImported = importSet?.has(symbolName) ?? false;
|
|
2457
|
-
if (!isImported && !allImportedSymbols.has(symbolName)) {
|
|
3213
|
+
const isUsed = used.has(key) || used.has(`${loc.file}::*`) || usedInAFolderOf(loc.file, symbolName);
|
|
3214
|
+
if (!isUsed) {
|
|
2458
3215
|
deadByFile.set(loc.file, (deadByFile.get(loc.file) ?? 0) + 1);
|
|
2459
3216
|
}
|
|
2460
3217
|
}
|
|
@@ -2521,19 +3278,6 @@ function catchFinding(file, catchLine, body) {
|
|
|
2521
3278
|
fix: "Log the error with context and either re-throw, return an error response, or recover gracefully"
|
|
2522
3279
|
};
|
|
2523
3280
|
}
|
|
2524
|
-
var NON_CODE = /\/\/.*|\/\*[\s\S]*?\*\/|'(?:[^'\\\n]|\\[\s\S])*'|"(?:[^"\\\n]|\\[\s\S])*"|`(?:[^`\\]|\\[\s\S])*`/g;
|
|
2525
|
-
function blankOut(source, strings) {
|
|
2526
|
-
const blank = (s) => s.replace(/[^\n]/g, " ");
|
|
2527
|
-
return source.replace(NON_CODE, (m) => m[0] === "/" ? blank(m) : strings ? m[0] + blank(m.slice(1, -1)) + m[0] : m);
|
|
2528
|
-
}
|
|
2529
|
-
function closingBrace(code, open) {
|
|
2530
|
-
let depth = 0;
|
|
2531
|
-
for (let i = open; i < code.length; i++) {
|
|
2532
|
-
if (code[i] === "{") depth++;
|
|
2533
|
-
else if (code[i] === "}" && --depth === 0) return i;
|
|
2534
|
-
}
|
|
2535
|
-
return -1;
|
|
2536
|
-
}
|
|
2537
3281
|
function returnedValues(code, text) {
|
|
2538
3282
|
const values = [];
|
|
2539
3283
|
for (const m of code.matchAll(/(?<![\w$.])return\b/g)) {
|
|
@@ -2711,47 +3455,46 @@ var comprehensionDebtRule = {
|
|
|
2711
3455
|
};
|
|
2712
3456
|
|
|
2713
3457
|
// src/rules/phantom-dependency.ts
|
|
2714
|
-
var PHANTOM_PACKAGES = /* @__PURE__ */ new
|
|
2715
|
-
"huggingface-cli",
|
|
2716
|
-
//
|
|
2717
|
-
"flask-hierarchical",
|
|
2718
|
-
//
|
|
2719
|
-
"beautifulsoup",
|
|
2720
|
-
//
|
|
2721
|
-
"python-dotenv",
|
|
2722
|
-
//
|
|
2723
|
-
"openai-sdk",
|
|
2724
|
-
//
|
|
2725
|
-
"anthropic-sdk",
|
|
2726
|
-
//
|
|
2727
|
-
"langchain-core",
|
|
2728
|
-
//
|
|
2729
|
-
"react-native-utils",
|
|
2730
|
-
//
|
|
2731
|
-
"next-middleware",
|
|
2732
|
-
//
|
|
2733
|
-
"supabase-client",
|
|
2734
|
-
//
|
|
2735
|
-
"stripe-sdk",
|
|
2736
|
-
//
|
|
2737
|
-
"prisma-client",
|
|
2738
|
-
//
|
|
2739
|
-
"tailwind-utils",
|
|
2740
|
-
//
|
|
2741
|
-
"express-validator-v2",
|
|
2742
|
-
//
|
|
2743
|
-
"node-postgres-pool",
|
|
2744
|
-
//
|
|
2745
|
-
"mongo-client",
|
|
2746
|
-
//
|
|
2747
|
-
"redis-client",
|
|
2748
|
-
//
|
|
2749
|
-
"aws-s3-upload",
|
|
2750
|
-
//
|
|
2751
|
-
"gpt-tokenizer"
|
|
2752
|
-
// exists but often confused
|
|
3458
|
+
var PHANTOM_PACKAGES = /* @__PURE__ */ new Map([
|
|
3459
|
+
["huggingface-cli", ["@huggingface/hub"]],
|
|
3460
|
+
// a Python CLI; an npm placeholder
|
|
3461
|
+
["flask-hierarchical", []],
|
|
3462
|
+
// not on npm
|
|
3463
|
+
["beautifulsoup", ["cheerio"]],
|
|
3464
|
+
// a Python library (beautifulsoup4); not on npm
|
|
3465
|
+
["python-dotenv", ["dotenv"]],
|
|
3466
|
+
// a Python library; an npm placeholder
|
|
3467
|
+
["openai-sdk", ["openai"]],
|
|
3468
|
+
// unpublished
|
|
3469
|
+
["anthropic-sdk", ["@anthropic-ai/sdk"]],
|
|
3470
|
+
// an npm placeholder
|
|
3471
|
+
["langchain-core", ["@langchain/core"]],
|
|
3472
|
+
// deprecated
|
|
3473
|
+
["react-native-utils", []],
|
|
3474
|
+
// a 2015 native module
|
|
3475
|
+
["next-middleware", []],
|
|
3476
|
+
// a 2017 0.0.1; Next.js has middleware built in
|
|
3477
|
+
["supabase-client", ["@supabase/supabase-js"]],
|
|
3478
|
+
// unpublished
|
|
3479
|
+
["stripe-sdk", ["stripe"]],
|
|
3480
|
+
// not on npm
|
|
3481
|
+
["prisma-client", ["@prisma/client"]],
|
|
3482
|
+
// an empty 0.0.0 placeholder
|
|
3483
|
+
["tailwind-utils", []],
|
|
3484
|
+
// a 2021 alpha
|
|
3485
|
+
["express-validator-v2", ["express-validator"]],
|
|
3486
|
+
// not on npm
|
|
3487
|
+
["node-postgres-pool", ["pg"]],
|
|
3488
|
+
// not on npm
|
|
3489
|
+
["mongo-client", ["mongodb"]],
|
|
3490
|
+
// a 2013 wrapper
|
|
3491
|
+
["redis-client", ["redis", "ioredis"]],
|
|
3492
|
+
// a 2015 client
|
|
3493
|
+
["aws-s3-upload", ["@aws-sdk/client-s3"]]
|
|
3494
|
+
// a 2018 0.0.2
|
|
2753
3495
|
]);
|
|
2754
|
-
var
|
|
3496
|
+
var KNOWN_PACKAGES = /* @__PURE__ */ new Set([
|
|
3497
|
+
// 2-char names
|
|
2755
3498
|
"pg",
|
|
2756
3499
|
"ws",
|
|
2757
3500
|
"ms",
|
|
@@ -2765,7 +3508,31 @@ var KNOWN_SHORT_PACKAGES = /* @__PURE__ */ new Set([
|
|
|
2765
3508
|
"io",
|
|
2766
3509
|
"db",
|
|
2767
3510
|
"fp",
|
|
2768
|
-
"rx"
|
|
3511
|
+
"rx",
|
|
3512
|
+
// -js suffix
|
|
3513
|
+
"crypto-js",
|
|
3514
|
+
"chroma-js",
|
|
3515
|
+
"libphonenumber-js",
|
|
3516
|
+
"ua-parser-js",
|
|
3517
|
+
"keycloak-js",
|
|
3518
|
+
"pusher-js",
|
|
3519
|
+
"matter-js",
|
|
3520
|
+
"amplitude-js",
|
|
3521
|
+
// Vanity prefixes
|
|
3522
|
+
"fast-glob",
|
|
3523
|
+
"fast-deep-equal",
|
|
3524
|
+
"fast-json-stable-stringify",
|
|
3525
|
+
"fast-xml-parser",
|
|
3526
|
+
"fast-levenshtein",
|
|
3527
|
+
"fast-safe-stringify",
|
|
3528
|
+
"fast-uri",
|
|
3529
|
+
"fast-equals",
|
|
3530
|
+
"fast-csv",
|
|
3531
|
+
"fast-check",
|
|
3532
|
+
"fast-diff",
|
|
3533
|
+
"simple-git",
|
|
3534
|
+
"simple-peer",
|
|
3535
|
+
"simple-icons"
|
|
2769
3536
|
]);
|
|
2770
3537
|
var SUSPICIOUS_PATTERNS = [
|
|
2771
3538
|
/^[a-z]{1,2}$/,
|
|
@@ -2775,6 +3542,9 @@ var SUSPICIOUS_PATTERNS = [
|
|
|
2775
3542
|
/^(the|my|simple|easy|fast|super|mega|ultra)-/
|
|
2776
3543
|
// vanity prefixes
|
|
2777
3544
|
];
|
|
3545
|
+
function quoteList(names) {
|
|
3546
|
+
return names.map((n) => `"${n}"`).join(" or ");
|
|
3547
|
+
}
|
|
2778
3548
|
var phantomDependencyRule = {
|
|
2779
3549
|
id: "phantom-dependency",
|
|
2780
3550
|
name: "Phantom Dependency",
|
|
@@ -2793,20 +3563,21 @@ var phantomDependencyRule = {
|
|
|
2793
3563
|
...project.packageJson.devDependencies ?? {}
|
|
2794
3564
|
};
|
|
2795
3565
|
for (const [name, _version] of Object.entries(deps)) {
|
|
2796
|
-
|
|
3566
|
+
const meant = PHANTOM_PACKAGES.get(name);
|
|
3567
|
+
if (meant) {
|
|
2797
3568
|
findings.push({
|
|
2798
3569
|
ruleId: "phantom-dependency",
|
|
2799
3570
|
file: "package.json",
|
|
2800
3571
|
line: 1,
|
|
2801
3572
|
column: 1,
|
|
2802
|
-
message: `"${name}" is a commonly hallucinated package name \u2014 verify it exists and is the correct package`,
|
|
3573
|
+
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`,
|
|
2803
3574
|
severity: "warning",
|
|
2804
3575
|
category: "security",
|
|
2805
|
-
fix: "
|
|
3576
|
+
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`
|
|
2806
3577
|
});
|
|
2807
3578
|
}
|
|
2808
3579
|
for (const pattern of SUSPICIOUS_PATTERNS) {
|
|
2809
|
-
if (pattern.test(name) && !name.startsWith("@") && !
|
|
3580
|
+
if (pattern.test(name) && !name.startsWith("@") && !KNOWN_PACKAGES.has(name)) {
|
|
2810
3581
|
findings.push({
|
|
2811
3582
|
ruleId: "phantom-dependency",
|
|
2812
3583
|
file: "package.json",
|
|
@@ -2992,10 +3763,11 @@ var insecureRandomRule = {
|
|
|
2992
3763
|
};
|
|
2993
3764
|
|
|
2994
3765
|
// src/rules/next-server-action-validation.ts
|
|
2995
|
-
var USE_SERVER =
|
|
3766
|
+
var USE_SERVER = /^\s*['"]use server['"]/m;
|
|
2996
3767
|
var FORM_DATA_GET = /formData\.get\s*\(/;
|
|
2997
3768
|
var VALIDATION_PATTERNS2 = [
|
|
2998
|
-
|
|
3769
|
+
// A schema's .parse(), but not parsers that validate nothing: JSON.parse(), Date.parse()...
|
|
3770
|
+
/(?<!\b(?:JSON|Date|URL|path|qs|querystring))\.parse\s*\(/,
|
|
2999
3771
|
/\.safeParse\s*\(/,
|
|
3000
3772
|
/\bvalidate\s*\(/,
|
|
3001
3773
|
/\.parseAsync\s*\(/,
|
|
@@ -3146,6 +3918,30 @@ function findEnclosingFunction(node, parentMap) {
|
|
|
3146
3918
|
}
|
|
3147
3919
|
|
|
3148
3920
|
// src/rules/redirect-in-try-catch.ts
|
|
3921
|
+
var RETHROW_HELPERS = /^(?:unstable_rethrow|isRedirectError|isNextRouterError)$/;
|
|
3922
|
+
function rethrowsRedirect(handler) {
|
|
3923
|
+
const param = handler.param?.type === "Identifier" ? handler.param.name : void 0;
|
|
3924
|
+
if (param && handler.body.body.some((statement) => statement.type === "ThrowStatement" && statement.argument.type === "Identifier" && statement.argument.name === param)) {
|
|
3925
|
+
return true;
|
|
3926
|
+
}
|
|
3927
|
+
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"));
|
|
3928
|
+
}
|
|
3929
|
+
function bodyRethrowsRedirect(body, param) {
|
|
3930
|
+
if (/\b(?:unstable_rethrow|isRedirectError|isNextRouterError)\s*\(|NEXT_REDIRECT/.test(body)) return true;
|
|
3931
|
+
return param !== void 0 && new RegExp(`\\bthrow\\s+${param.replace(/\$/g, "\\$")}(?![\\w$])`).test(body);
|
|
3932
|
+
}
|
|
3933
|
+
function redirectFinding(file, line, column) {
|
|
3934
|
+
return {
|
|
3935
|
+
ruleId: "redirect-in-try-catch",
|
|
3936
|
+
file: file.relativePath,
|
|
3937
|
+
line,
|
|
3938
|
+
column,
|
|
3939
|
+
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3940
|
+
severity: "critical",
|
|
3941
|
+
category: "reliability",
|
|
3942
|
+
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'
|
|
3943
|
+
};
|
|
3944
|
+
}
|
|
3149
3945
|
var redirectInTryCatchRule = {
|
|
3150
3946
|
id: "redirect-in-try-catch",
|
|
3151
3947
|
name: "Redirect Inside Try/Catch",
|
|
@@ -3157,44 +3953,42 @@ var redirectInTryCatchRule = {
|
|
|
3157
3953
|
if (isTestFile(file.relativePath)) return [];
|
|
3158
3954
|
if (!/redirect\s*\(/.test(file.content)) return [];
|
|
3159
3955
|
const findings = [];
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
if (tryDepth > 0) {
|
|
3171
|
-
const match = /\bredirect\s*\(/.exec(line);
|
|
3172
|
-
if (match && !/\/\//.test(line.slice(0, match.index))) {
|
|
3173
|
-
findings.push({
|
|
3174
|
-
ruleId: "redirect-in-try-catch",
|
|
3175
|
-
file: file.relativePath,
|
|
3176
|
-
line: i + 1,
|
|
3177
|
-
column: match.index + 1,
|
|
3178
|
-
message: "redirect() inside try/catch \u2014 Next.js redirect throws internally, the catch block will intercept it",
|
|
3179
|
-
severity: "critical",
|
|
3180
|
-
category: "reliability",
|
|
3181
|
-
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'
|
|
3956
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3957
|
+
if (file.ast) {
|
|
3958
|
+
try {
|
|
3959
|
+
walkAST(file.ast.program, (node) => {
|
|
3960
|
+
if (node.type !== "TryStatement" || !node.handler || rethrowsRedirect(node.handler)) return;
|
|
3961
|
+
walkAST(node.block, (n) => {
|
|
3962
|
+
if (n.type !== "CallExpression" || n.callee.type !== "Identifier" || n.callee.name !== "redirect") return;
|
|
3963
|
+
if (!n.loc || n.start == null || seen.has(n.start)) return;
|
|
3964
|
+
seen.add(n.start);
|
|
3965
|
+
findings.push(redirectFinding(file, n.loc.start.line, n.loc.start.column + 1));
|
|
3182
3966
|
});
|
|
3183
|
-
}
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3967
|
+
});
|
|
3968
|
+
return findings;
|
|
3969
|
+
} catch {
|
|
3970
|
+
findings.length = 0;
|
|
3971
|
+
seen.clear();
|
|
3188
3972
|
}
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3973
|
+
}
|
|
3974
|
+
const source = file.lines.join("\n");
|
|
3975
|
+
const code = blankOut(source, true);
|
|
3976
|
+
const noComments = blankOut(source, false);
|
|
3977
|
+
for (const tryMatch of code.matchAll(/(?<![\w$.])try\s*\{/g)) {
|
|
3978
|
+
const open = tryMatch.index + tryMatch[0].length - 1;
|
|
3979
|
+
const close = closingBrace(code, open);
|
|
3980
|
+
if (close === -1) continue;
|
|
3981
|
+
const catchMatch = /^\s*catch\s*(?:\(\s*([A-Za-z_$][\w$]*)?[^)]*\)\s*)?\{/.exec(code.slice(close + 1));
|
|
3982
|
+
if (!catchMatch) continue;
|
|
3983
|
+
const catchOpen = close + 1 + catchMatch[0].length - 1;
|
|
3984
|
+
const catchClose = closingBrace(code, catchOpen);
|
|
3985
|
+
if (bodyRethrowsRedirect(noComments.slice(catchOpen + 1, catchClose === -1 ? code.length : catchClose), catchMatch[1])) continue;
|
|
3986
|
+
for (const call of code.slice(open + 1, close).matchAll(/(?<![\w$.])redirect\s*\(/g)) {
|
|
3987
|
+
const offset = open + 1 + call.index;
|
|
3988
|
+
if (seen.has(offset)) continue;
|
|
3989
|
+
seen.add(offset);
|
|
3990
|
+
const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
|
|
3991
|
+
findings.push(redirectFinding(file, source.slice(0, offset).split("\n").length, offset - lineStart + 1));
|
|
3198
3992
|
}
|
|
3199
3993
|
}
|
|
3200
3994
|
return findings;
|
|
@@ -3487,7 +4281,7 @@ var missingWebhookVerificationRule = {
|
|
|
3487
4281
|
};
|
|
3488
4282
|
|
|
3489
4283
|
// src/rules/server-action-auth.ts
|
|
3490
|
-
var USE_SERVER3 =
|
|
4284
|
+
var USE_SERVER3 = /^\s*['"]use server['"]/m;
|
|
3491
4285
|
var AUTH_PATTERNS2 = [
|
|
3492
4286
|
/getServerSession\s*\(/,
|
|
3493
4287
|
/getSession\s*\(/,
|
|
@@ -3504,7 +4298,13 @@ var AUTH_PATTERNS2 = [
|
|
|
3504
4298
|
/currentUser\s*\(/,
|
|
3505
4299
|
/getAuth\s*\(/,
|
|
3506
4300
|
/cookies\(\).*auth/s,
|
|
3507
|
-
|
|
4301
|
+
// Session lookups, not just any "session" text (a `sessionId` form field isn't auth):
|
|
4302
|
+
// verifySession(), getIronSession(), lucia.validateSession(), getServerAuthSession()...
|
|
4303
|
+
/\w*session\w*\s*\(/i,
|
|
4304
|
+
// ...reading a session cookie, or using and guarding a session object
|
|
4305
|
+
/cookie\w*\(?\)?\)?\s*\.get\(\s*['"`][\w-]*(?:session|auth|token)/i,
|
|
4306
|
+
/\bsession\??\.user(?:Id)?\b/,
|
|
4307
|
+
/!\s*session\b/
|
|
3508
4308
|
];
|
|
3509
4309
|
var PUBLIC_ACTION_NAMES = [
|
|
3510
4310
|
/contact/i,
|
|
@@ -3525,7 +4325,6 @@ var serverActionAuthRule = {
|
|
|
3525
4325
|
check(file, project) {
|
|
3526
4326
|
if (isTestFile(file.relativePath)) return [];
|
|
3527
4327
|
if (!USE_SERVER3.test(file.content)) return [];
|
|
3528
|
-
if (project.hasAuthMiddleware) return [];
|
|
3529
4328
|
for (const p of PUBLIC_ACTION_NAMES) {
|
|
3530
4329
|
if (p.test(file.relativePath)) return [];
|
|
3531
4330
|
}
|
|
@@ -3540,15 +4339,17 @@ var serverActionAuthRule = {
|
|
|
3540
4339
|
break;
|
|
3541
4340
|
}
|
|
3542
4341
|
}
|
|
4342
|
+
const middlewareSkipsPages = project.hasAuthMiddleware && project.authMiddlewareExcludesPages === true;
|
|
4343
|
+
const middleware = project.hasAuthMiddleware && !middlewareSkipsPages;
|
|
3543
4344
|
return [{
|
|
3544
4345
|
ruleId: "server-action-auth",
|
|
3545
4346
|
file: file.relativePath,
|
|
3546
4347
|
line: reportLine,
|
|
3547
4348
|
column: 1,
|
|
3548
|
-
message: "Server action performs mutations without any authentication check \u2014 anyone can call this action",
|
|
3549
|
-
severity: "warning",
|
|
4349
|
+
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",
|
|
4350
|
+
severity: middleware ? "info" : "warning",
|
|
3550
4351
|
category: "security",
|
|
3551
|
-
fix: 'Add auth check: const session = await auth(); if (!session) throw new Error("Unauthorized")'
|
|
4352
|
+
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")'
|
|
3552
4353
|
}];
|
|
3553
4354
|
}
|
|
3554
4355
|
};
|
|
@@ -3678,13 +4479,15 @@ var nextPublicSensitiveRule = {
|
|
|
3678
4479
|
description: "Detects NEXT_PUBLIC_ prefix on environment variables that should be server-only \u2014 exposes secrets to the browser",
|
|
3679
4480
|
category: "security",
|
|
3680
4481
|
severity: "critical",
|
|
3681
|
-
|
|
4482
|
+
// The scanner gives every dotenv file (.env, .env.local, .env.production...) the ext 'env'
|
|
4483
|
+
fileExtensions: ["ts", "tsx", "js", "jsx", "mjs", "env"],
|
|
3682
4484
|
check(file, _project) {
|
|
3683
4485
|
if (isTestFile(file.relativePath)) return [];
|
|
3684
4486
|
const findings = [];
|
|
4487
|
+
const isEnvFile = file.ext === "env";
|
|
3685
4488
|
for (let i = 0; i < file.lines.length; i++) {
|
|
3686
|
-
if (isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3687
4489
|
const line = file.lines[i];
|
|
4490
|
+
if (isEnvFile ? line.trim().startsWith("#") : isCommentLine(file.lines, i, file.commentMap)) continue;
|
|
3688
4491
|
const match = SENSITIVE_PATTERN.exec(line);
|
|
3689
4492
|
if (match) {
|
|
3690
4493
|
findings.push({
|
|
@@ -4147,7 +4950,17 @@ var unsafeFileUploadRule = {
|
|
|
4147
4950
|
|
|
4148
4951
|
// src/rules/supabase-missing-rls.ts
|
|
4149
4952
|
var CREATE_TABLE = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
|
|
4150
|
-
var ENABLE_RLS = /ALTER\s+TABLE\s+(?:(?:public|"public")\.)?(\w+)
|
|
4953
|
+
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;
|
|
4954
|
+
function isMigrationFile(file) {
|
|
4955
|
+
return file.ext === "sql" && !isTestFile(file.relativePath) && /migration|supabase|schema/i.test(file.relativePath);
|
|
4956
|
+
}
|
|
4957
|
+
function sqlProject(relativePath) {
|
|
4958
|
+
const dirs = relativePath.split("/").slice(0, -1);
|
|
4959
|
+
const supabase = dirs.map((d) => d.toLowerCase() === "supabase").lastIndexOf(true);
|
|
4960
|
+
const migrations = dirs.map((d) => /^migrations?$/i.test(d)).lastIndexOf(true);
|
|
4961
|
+
const end = supabase !== -1 ? supabase : migrations !== -1 ? migrations : dirs.length;
|
|
4962
|
+
return dirs.slice(0, end).join("/");
|
|
4963
|
+
}
|
|
4151
4964
|
var supabaseMissingRlsRule = {
|
|
4152
4965
|
id: "supabase-missing-rls",
|
|
4153
4966
|
name: "Missing Row-Level Security",
|
|
@@ -4155,42 +4968,36 @@ var supabaseMissingRlsRule = {
|
|
|
4155
4968
|
category: "security",
|
|
4156
4969
|
severity: "critical",
|
|
4157
4970
|
fileExtensions: ["sql"],
|
|
4158
|
-
check(
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4971
|
+
check() {
|
|
4972
|
+
return [];
|
|
4973
|
+
},
|
|
4974
|
+
checkProject(files, _project) {
|
|
4162
4975
|
const findings = [];
|
|
4163
|
-
const tables = [];
|
|
4164
|
-
CREATE_TABLE.lastIndex = 0;
|
|
4165
|
-
let match;
|
|
4166
|
-
while ((match = CREATE_TABLE.exec(content)) !== null) {
|
|
4167
|
-
const name = match[1];
|
|
4168
|
-
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4169
|
-
const beforeMatch = content.slice(0, match.index);
|
|
4170
|
-
const line = beforeMatch.split("\n").length;
|
|
4171
|
-
tables.push({ name, line });
|
|
4172
|
-
}
|
|
4173
|
-
if (tables.length === 0) return [];
|
|
4174
4976
|
const rlsTables = /* @__PURE__ */ new Set();
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
if (filePath === file.relativePath) continue;
|
|
4977
|
+
for (const file of files) {
|
|
4978
|
+
if (file.ext !== "sql" || isTestFile(file.relativePath)) continue;
|
|
4979
|
+
const project = sqlProject(file.relativePath);
|
|
4980
|
+
for (const match of file.content.matchAll(ENABLE_RLS)) {
|
|
4981
|
+
rlsTables.add(`${project}::${match[1].toLowerCase()}`);
|
|
4982
|
+
}
|
|
4182
4983
|
}
|
|
4183
|
-
for (const
|
|
4184
|
-
if (!
|
|
4984
|
+
for (const file of files) {
|
|
4985
|
+
if (!isMigrationFile(file)) continue;
|
|
4986
|
+
const content = file.content;
|
|
4987
|
+
const project = sqlProject(file.relativePath);
|
|
4988
|
+
for (const match of content.matchAll(CREATE_TABLE)) {
|
|
4989
|
+
const name = match[1];
|
|
4990
|
+
if (name.startsWith("_") || name === "schema_migrations") continue;
|
|
4991
|
+
if (rlsTables.has(`${project}::${name.toLowerCase()}`)) continue;
|
|
4185
4992
|
findings.push({
|
|
4186
4993
|
ruleId: "supabase-missing-rls",
|
|
4187
4994
|
file: file.relativePath,
|
|
4188
|
-
line:
|
|
4995
|
+
line: content.slice(0, match.index).split("\n").length,
|
|
4189
4996
|
column: 1,
|
|
4190
|
-
message: `Table "${
|
|
4997
|
+
message: `Table "${name}" created without ENABLE ROW LEVEL SECURITY \u2014 all rows are publicly accessible via the Supabase API`,
|
|
4191
4998
|
severity: "critical",
|
|
4192
4999
|
category: "security",
|
|
4193
|
-
fix: `Add: ALTER TABLE ${
|
|
5000
|
+
fix: `Add: ALTER TABLE ${name} ENABLE ROW LEVEL SECURITY; and create appropriate policies`
|
|
4194
5001
|
});
|
|
4195
5002
|
}
|
|
4196
5003
|
}
|
|
@@ -4521,10 +5328,15 @@ async function scan(options) {
|
|
|
4521
5328
|
}
|
|
4522
5329
|
}
|
|
4523
5330
|
}
|
|
5331
|
+
const filesByPath = new Map(allFiles.map((file) => [file.relativePath, file]));
|
|
4524
5332
|
for (const rule of rules) {
|
|
4525
5333
|
if (rule.checkProject) {
|
|
4526
|
-
const
|
|
4527
|
-
|
|
5334
|
+
for (const finding of rule.checkProject(allFiles, project)) {
|
|
5335
|
+
const file = filesByPath.get(finding.file);
|
|
5336
|
+
if (!file || !isLineSuppressed(file.lines, finding.line - 1, finding.ruleId)) {
|
|
5337
|
+
findings.push(finding);
|
|
5338
|
+
}
|
|
5339
|
+
}
|
|
4528
5340
|
}
|
|
4529
5341
|
}
|
|
4530
5342
|
const { overallScore, categoryScores } = calculateScores(findings);
|
|
@@ -4693,11 +5505,7 @@ function reportSarif(result) {
|
|
|
4693
5505
|
]
|
|
4694
5506
|
};
|
|
4695
5507
|
if (f.fix) {
|
|
4696
|
-
sarifResult.
|
|
4697
|
-
{
|
|
4698
|
-
description: { text: f.fix }
|
|
4699
|
-
}
|
|
4700
|
-
];
|
|
5508
|
+
sarifResult.properties = { fix: f.fix };
|
|
4701
5509
|
}
|
|
4702
5510
|
return sarifResult;
|
|
4703
5511
|
});
|
|
@@ -4907,9 +5715,9 @@ function ipv6Groups(address) {
|
|
|
4907
5715
|
const quad = address.match(/^(.*:)(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
|
|
4908
5716
|
const hex = quad ? `${quad[1]}${(+quad[2] << 8 | +quad[3]).toString(16)}:${(+quad[4] << 8 | +quad[5]).toString(16)}` : address;
|
|
4909
5717
|
const [head, tail] = hex.split("::");
|
|
4910
|
-
const
|
|
4911
|
-
const front =
|
|
4912
|
-
const back =
|
|
5718
|
+
const parse3 = (part) => part ? part.split(":").map((g) => parseInt(g, 16)) : [];
|
|
5719
|
+
const front = parse3(head);
|
|
5720
|
+
const back = parse3(tail);
|
|
4913
5721
|
return tail === void 0 ? front : [...front, ...Array(8 - front.length - back.length).fill(0), ...back];
|
|
4914
5722
|
}
|
|
4915
5723
|
function embeddedIPv4(address) {
|