canship 0.3.1 → 0.4.0
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-zh-CN.md +110 -62
- package/README.md +117 -69
- package/dist/cli.js +1576 -223
- package/dist/index.d.ts +159 -0
- package/dist/index.js +4972 -0
- package/package.json +12 -1
- package/schemas/scan-report-v1.schema.json +26 -0
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,8 @@ var SECRET_PATTERNS = [
|
|
|
12
12
|
{
|
|
13
13
|
id: "openai",
|
|
14
14
|
name: "OpenAI API key",
|
|
15
|
-
|
|
15
|
+
// OpenRouter 密钥同样以 sk- 开头,由下方的专用格式识别。
|
|
16
|
+
pattern: /\bsk-(?!ant-|or-v1-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
|
|
16
17
|
impact: "Anyone with this key can spend your OpenAI credit. Leaked keys are typically abused within minutes of going public.",
|
|
17
18
|
rotateAt: "https://platform.openai.com/api-keys"
|
|
18
19
|
},
|
|
@@ -23,6 +24,50 @@ var SECRET_PATTERNS = [
|
|
|
23
24
|
impact: "Anyone with this key can spend your Anthropic credit.",
|
|
24
25
|
rotateAt: "https://console.anthropic.com/settings/keys"
|
|
25
26
|
},
|
|
27
|
+
// 以下前缀取自各服务官方文档;除 Replicate 外文档未给出长度,只设最小长度以排除示例占位符。
|
|
28
|
+
{
|
|
29
|
+
id: "openrouter",
|
|
30
|
+
name: "OpenRouter API key",
|
|
31
|
+
pattern: /\bsk-or-v1-[A-Za-z0-9]{32,}\b/g,
|
|
32
|
+
impact: "Anyone with this key can spend your OpenRouter credit on any model it routes to, up to the key's limit.",
|
|
33
|
+
rotateAt: "https://openrouter.ai/settings/keys"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "groq",
|
|
37
|
+
name: "Groq API key",
|
|
38
|
+
pattern: /\bgsk_[A-Za-z0-9]{40,}\b/g,
|
|
39
|
+
impact: "Anyone with this key can run models on your Groq account and use up its rate limits and billing.",
|
|
40
|
+
rotateAt: "https://console.groq.com/keys"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: "huggingface",
|
|
44
|
+
name: "Hugging Face access token",
|
|
45
|
+
pattern: /\bhf_[A-Za-z0-9]{30,}\b/g,
|
|
46
|
+
impact: "Depending on its scope, this token can read your private models and datasets, push to your repositories, and spend on paid inference.",
|
|
47
|
+
rotateAt: "https://huggingface.co/settings/tokens"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "replicate",
|
|
51
|
+
name: "Replicate API token",
|
|
52
|
+
// 官方文档:共 40 个字符,始终以 r8_ 开头。
|
|
53
|
+
pattern: /\br8_[A-Za-z0-9]{37}\b/g,
|
|
54
|
+
impact: "Anyone with this token can run paid predictions on your Replicate account and read your models and predictions.",
|
|
55
|
+
rotateAt: "https://replicate.com/account/api-tokens"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "xai",
|
|
59
|
+
name: "xAI API key",
|
|
60
|
+
pattern: /\bxai-[A-Za-z0-9]{40,}\b/g,
|
|
61
|
+
impact: "Anyone with this key can call Grok models on your xAI account and spend its credit.",
|
|
62
|
+
rotateAt: "https://console.x.ai"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: "perplexity",
|
|
66
|
+
name: "Perplexity API key",
|
|
67
|
+
pattern: /\bpplx-[A-Za-z0-9]{40,}\b/g,
|
|
68
|
+
impact: "Anyone with this key can run Perplexity API requests billed to your account.",
|
|
69
|
+
rotateAt: "https://console.perplexity.ai"
|
|
70
|
+
},
|
|
26
71
|
{
|
|
27
72
|
id: "aws-access-key-id",
|
|
28
73
|
name: "AWS access key ID",
|
|
@@ -134,7 +179,7 @@ function isPlaceholder(secret) {
|
|
|
134
179
|
if (LONG_PLACEHOLDER_SEGMENT.test(secret)) return true;
|
|
135
180
|
if (namesItselfTwice(secret)) return true;
|
|
136
181
|
if (PLACEHOLDER_SHAPE.test(secret)) return true;
|
|
137
|
-
const body = lower.replace(/^(sk-ant-|sk-proj-|sk-|rk_live_|sk_live_|akia|aiza|sg\.|gh[pousr]_)/, "");
|
|
182
|
+
const body = lower.replace(/^(sk-ant-|sk-proj-|sk-or-v1-|sk-|rk_live_|sk_live_|akia|aiza|sg\.|gh[pousr]_|gsk_|hf_|r8_|xai-|pplx-)/, "");
|
|
138
183
|
if (body.length >= 8) {
|
|
139
184
|
const distinct = new Set(body.replace(/[^a-z0-9]/g, "")).size;
|
|
140
185
|
if (distinct <= 3) return true;
|
|
@@ -1364,15 +1409,13 @@ function parseEnv(file) {
|
|
|
1364
1409
|
var exposureRule = {
|
|
1365
1410
|
id: "exposure/public-env",
|
|
1366
1411
|
severity: "P0",
|
|
1367
|
-
appliesTo(
|
|
1368
|
-
|
|
1369
|
-
if (isEnvFile(name)) return true;
|
|
1370
|
-
return /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/.test(name);
|
|
1412
|
+
appliesTo() {
|
|
1413
|
+
return true;
|
|
1371
1414
|
},
|
|
1372
1415
|
/** 使用有界缓冲区保留结果,超限时记录扫描缺口。 */
|
|
1373
1416
|
check(file, ctx) {
|
|
1374
1417
|
const name = basename3(file.path);
|
|
1375
|
-
const findings = isEnvFile(name) ? checkEnvFile(file) : checkSourceFile(file);
|
|
1418
|
+
const findings = isEnvFile(name) ? checkEnvFile(file) : FRONTEND_SOURCE.test(name) ? checkSourceFile(file) : checkOtherFile(file);
|
|
1376
1419
|
if (!findings.overflow) return findings.items;
|
|
1377
1420
|
ctx.reportIncomplete(
|
|
1378
1421
|
"exposure/public-env",
|
|
@@ -1460,38 +1503,46 @@ function checkEnvFile(file) {
|
|
|
1460
1503
|
}
|
|
1461
1504
|
return findings;
|
|
1462
1505
|
}
|
|
1506
|
+
var FRONTEND_SOURCE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|vue|astro)$/;
|
|
1507
|
+
function checkOtherFile(file) {
|
|
1508
|
+
const findings = new FindingBuffer();
|
|
1509
|
+
file.lines.forEach((line, i) => pushServiceRoleJwts(findings, file, line, i, false));
|
|
1510
|
+
return findings;
|
|
1511
|
+
}
|
|
1512
|
+
function pushServiceRoleJwts(findings, file, line, i, clientSide) {
|
|
1513
|
+
JWT_SHAPED2.lastIndex = 0;
|
|
1514
|
+
const jwtMatches = line.match(JWT_SHAPED2);
|
|
1515
|
+
if (!jwtMatches) return;
|
|
1516
|
+
for (const jwt of jwtMatches) {
|
|
1517
|
+
if (!isSupabaseServiceRole(jwt)) continue;
|
|
1518
|
+
findings.push({
|
|
1519
|
+
ruleId: "exposure/supabase-service-role-in-client",
|
|
1520
|
+
severity: "P0",
|
|
1521
|
+
confidence: "certain",
|
|
1522
|
+
title: clientSide ? "Your Supabase admin key is hardcoded in a client component" : "Your Supabase admin key is hardcoded in source code",
|
|
1523
|
+
file: file.path,
|
|
1524
|
+
line: i + 1,
|
|
1525
|
+
excerpt: redactLine(line, jwt),
|
|
1526
|
+
why: [
|
|
1527
|
+
`This is the service_role key \u2014 it bypasses every Row Level Security policy and is effectively your database root password.`,
|
|
1528
|
+
clientSide ? `This file starts with 'use client', so it is shipped to the browser in full. Any visitor can read this key.` : `Hardcoding it in source means it is in your git history, and it will be bundled anywhere this file is imported from client code.`
|
|
1529
|
+
],
|
|
1530
|
+
fix: [
|
|
1531
|
+
`Remove the key from the source file entirely.`,
|
|
1532
|
+
`Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
|
|
1533
|
+
],
|
|
1534
|
+
humanOnly: [
|
|
1535
|
+
`Rotate the key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
|
|
1536
|
+
]
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1463
1540
|
function checkSourceFile(file) {
|
|
1464
1541
|
const findings = new FindingBuffer();
|
|
1465
1542
|
const clientSide = isClientCode(file);
|
|
1466
1543
|
const commentless = commentsMaskedOf(file).split(/\r?\n/);
|
|
1467
1544
|
file.lines.forEach((line, i) => {
|
|
1468
|
-
|
|
1469
|
-
const jwtMatches = line.match(JWT_SHAPED2);
|
|
1470
|
-
if (jwtMatches) {
|
|
1471
|
-
for (const jwt of jwtMatches) {
|
|
1472
|
-
if (!isSupabaseServiceRole(jwt)) continue;
|
|
1473
|
-
findings.push({
|
|
1474
|
-
ruleId: "exposure/supabase-service-role-in-client",
|
|
1475
|
-
severity: "P0",
|
|
1476
|
-
confidence: "certain",
|
|
1477
|
-
title: clientSide ? "Your Supabase admin key is hardcoded in a client component" : "Your Supabase admin key is hardcoded in source code",
|
|
1478
|
-
file: file.path,
|
|
1479
|
-
line: i + 1,
|
|
1480
|
-
excerpt: redactLine(line, jwt),
|
|
1481
|
-
why: [
|
|
1482
|
-
`This is the service_role key \u2014 it bypasses every Row Level Security policy and is effectively your database root password.`,
|
|
1483
|
-
clientSide ? `This file starts with 'use client', so it is shipped to the browser in full. Any visitor can read this key.` : `Hardcoding it in source means it is in your git history, and it will be bundled anywhere this file is imported from client code.`
|
|
1484
|
-
],
|
|
1485
|
-
fix: [
|
|
1486
|
-
`Remove the key from the source file entirely.`,
|
|
1487
|
-
`Put it in .env as SUPABASE_SERVICE_ROLE_KEY (no public prefix) and read it via process.env on the server only.`
|
|
1488
|
-
],
|
|
1489
|
-
humanOnly: [
|
|
1490
|
-
`Rotate the key in your Supabase dashboard (Project Settings -> API) \u2014 the current one must be treated as compromised.`
|
|
1491
|
-
]
|
|
1492
|
-
});
|
|
1493
|
-
}
|
|
1494
|
-
}
|
|
1545
|
+
pushServiceRoleJwts(findings, file, line, i, clientSide);
|
|
1495
1546
|
const envRef = /(?:process\.env|import\.meta\.env)(?:\.([A-Z_][A-Z0-9_]*)|\[\s*['"]([A-Z_][A-Z0-9_]*)['"]\s*\])/g;
|
|
1496
1547
|
let m;
|
|
1497
1548
|
while ((m = envRef.exec(commentless[i] ?? "")) !== null) {
|
|
@@ -1640,6 +1691,8 @@ function historicalEvidence(root, gitExecutable, entry) {
|
|
|
1640
1691
|
"log",
|
|
1641
1692
|
"--no-ext-diff",
|
|
1642
1693
|
"--no-textconv",
|
|
1694
|
+
"--no-renames",
|
|
1695
|
+
"--diff-filter=d",
|
|
1643
1696
|
"--all",
|
|
1644
1697
|
"--format=%H",
|
|
1645
1698
|
`--max-count=${MAX_HISTORY_REVISIONS + 1}`,
|
|
@@ -1785,6 +1838,194 @@ var gitleakRule = {
|
|
|
1785
1838
|
}
|
|
1786
1839
|
};
|
|
1787
1840
|
|
|
1841
|
+
// src/rules/sqlpolicy.ts
|
|
1842
|
+
var MAX_STATEMENT = 4e3;
|
|
1843
|
+
function statementEnd(masked, from, onTruncated) {
|
|
1844
|
+
const limit = Math.min(masked.length, from + MAX_STATEMENT);
|
|
1845
|
+
const semicolon = masked.slice(from, limit).indexOf(";");
|
|
1846
|
+
if (semicolon === -1 && limit < masked.length && masked[limit] !== ";") onTruncated?.();
|
|
1847
|
+
return semicolon === -1 ? limit : from + semicolon;
|
|
1848
|
+
}
|
|
1849
|
+
function parenthesized(masked, open, end) {
|
|
1850
|
+
let depth = 0;
|
|
1851
|
+
for (let i = open; i < end; i++) {
|
|
1852
|
+
if (masked[i] === "(") depth++;
|
|
1853
|
+
else if (masked[i] === ")" && --depth === 0) return { from: open + 1, to: i };
|
|
1854
|
+
}
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1857
|
+
function normalizeExpression(text) {
|
|
1858
|
+
const parts = [];
|
|
1859
|
+
for (let i = 0; i < text.length; ) {
|
|
1860
|
+
const ch = text[i];
|
|
1861
|
+
if (ch === "'") {
|
|
1862
|
+
const start2 = i++;
|
|
1863
|
+
let closed = false;
|
|
1864
|
+
while (i < text.length) {
|
|
1865
|
+
if (text[i++] !== "'") continue;
|
|
1866
|
+
if (text[i] === "'") {
|
|
1867
|
+
i++;
|
|
1868
|
+
continue;
|
|
1869
|
+
}
|
|
1870
|
+
closed = true;
|
|
1871
|
+
break;
|
|
1872
|
+
}
|
|
1873
|
+
if (!closed) return "";
|
|
1874
|
+
parts.push(text.slice(start2, i));
|
|
1875
|
+
} else if (text.startsWith("--", i)) {
|
|
1876
|
+
const end2 = text.indexOf("\n", i + 2);
|
|
1877
|
+
i = end2 === -1 ? text.length : end2;
|
|
1878
|
+
} else if (text.startsWith("/*", i)) {
|
|
1879
|
+
let depth = 1;
|
|
1880
|
+
i += 2;
|
|
1881
|
+
while (i < text.length && depth > 0) {
|
|
1882
|
+
if (text.startsWith("/*", i)) {
|
|
1883
|
+
depth++;
|
|
1884
|
+
i += 2;
|
|
1885
|
+
} else if (text.startsWith("*/", i)) {
|
|
1886
|
+
depth--;
|
|
1887
|
+
i += 2;
|
|
1888
|
+
} else i++;
|
|
1889
|
+
}
|
|
1890
|
+
if (depth !== 0) return "";
|
|
1891
|
+
} else {
|
|
1892
|
+
if (!/\s/.test(ch)) parts.push(ch.toLowerCase());
|
|
1893
|
+
i++;
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
const out = parts.join("");
|
|
1897
|
+
let start = 0;
|
|
1898
|
+
let end = out.length;
|
|
1899
|
+
while (out[start] === "(") start++;
|
|
1900
|
+
while (end > start && out[end - 1] === ")") end--;
|
|
1901
|
+
return start === out.length - end ? out.slice(start, end) : out;
|
|
1902
|
+
}
|
|
1903
|
+
function isAlwaysTrue(expression) {
|
|
1904
|
+
const e = normalizeExpression(expression);
|
|
1905
|
+
if (e === "true") return true;
|
|
1906
|
+
const numbers = /^(\d+)=(\d+)$/.exec(e);
|
|
1907
|
+
if (numbers) return numbers[1] === numbers[2];
|
|
1908
|
+
const strings = /^'((?:[^']|'')*)'='((?:[^']|'')*)'$/.exec(e);
|
|
1909
|
+
return strings !== null && strings[1] === strings[2];
|
|
1910
|
+
}
|
|
1911
|
+
function bucketOnlyCondition(expression) {
|
|
1912
|
+
return /^(?:(?:storage\.)?objects\.)?bucket_id='((?:[^']|'')*)'$/.exec(normalizeExpression(expression))?.[1]?.replace(/''/g, "'") ?? null;
|
|
1913
|
+
}
|
|
1914
|
+
function unquoteIdent(ident) {
|
|
1915
|
+
const quoted = /^"(.*)"$/.exec(ident);
|
|
1916
|
+
return quoted ? quoted[1].replace(/""/g, '"') : ident.toLowerCase();
|
|
1917
|
+
}
|
|
1918
|
+
var IDENT = String.raw`(?:"[^"]+"|[a-z_][\w$]*)`;
|
|
1919
|
+
var TARGET = String.raw`(${IDENT})\s+on\s+(?:(${IDENT})\s*\.\s*)?(${IDENT})`;
|
|
1920
|
+
var CREATE_POLICY = new RegExp(String.raw`\bcreate\s+policy\s+${TARGET}`, "gid");
|
|
1921
|
+
var DROP_POLICY = new RegExp(String.raw`\bdrop\s+policy\s+(?:if\s+exists\s+)?${TARGET}`, "gid");
|
|
1922
|
+
var ALTER_POLICY = new RegExp(String.raw`\balter\s+policy\s+${TARGET}`, "gid");
|
|
1923
|
+
function identAt(source, match, group) {
|
|
1924
|
+
const range = match.indices?.[group];
|
|
1925
|
+
return range ? unquoteIdent(source.slice(range[0], range[1])) : void 0;
|
|
1926
|
+
}
|
|
1927
|
+
function policyClauses(masked, source, from, end) {
|
|
1928
|
+
const text = masked.slice(from, end);
|
|
1929
|
+
const clauses = {};
|
|
1930
|
+
const firstExpression = text.search(/\busing\s*\(|\bwith\s+check\s*\(/i);
|
|
1931
|
+
const head = firstExpression === -1 ? text : text.slice(0, firstExpression);
|
|
1932
|
+
const rename = /\brename\s+to\s+("[^"]+"|[a-z_][\w$]*)/id.exec(head);
|
|
1933
|
+
if (rename) {
|
|
1934
|
+
const [start, stop] = rename.indices[1];
|
|
1935
|
+
clauses.renamedTo = unquoteIdent(source.slice(from + start, from + stop));
|
|
1936
|
+
return clauses;
|
|
1937
|
+
}
|
|
1938
|
+
const as = /\bas\s+(permissive|restrictive)\b/i.exec(head);
|
|
1939
|
+
if (as) clauses.restrictive = as[1].toLowerCase() === "restrictive";
|
|
1940
|
+
const command = /\bfor\s+(all|select|insert|update|delete)\b/i.exec(head);
|
|
1941
|
+
if (command) clauses.command = command[1].toLowerCase();
|
|
1942
|
+
const roles = /\bto\s+([\s\S]+)$/i.exec(head);
|
|
1943
|
+
if (roles) clauses.roles = roles[1].split(",").map((role) => unquoteIdent(role.trim())).filter(Boolean);
|
|
1944
|
+
for (const [key, pattern] of [["using", /\busing\s*\(/i], ["check", /\bwith\s+check\s*\(/i]]) {
|
|
1945
|
+
const at = text.search(pattern);
|
|
1946
|
+
if (at === -1) continue;
|
|
1947
|
+
const open = from + at + text.slice(at).indexOf("(");
|
|
1948
|
+
const range = parenthesized(masked, open, end);
|
|
1949
|
+
if (range) clauses[key] = source.slice(range.from, range.to);
|
|
1950
|
+
}
|
|
1951
|
+
return clauses;
|
|
1952
|
+
}
|
|
1953
|
+
function literal(source) {
|
|
1954
|
+
const m = /^\s*'((?:[^']|'')*)'/.exec(source);
|
|
1955
|
+
return m ? m[1].replace(/''/g, "'") : null;
|
|
1956
|
+
}
|
|
1957
|
+
function topLevelValues(masked, from, to) {
|
|
1958
|
+
const values = [];
|
|
1959
|
+
let depth = 0;
|
|
1960
|
+
let start = from;
|
|
1961
|
+
for (let i = from; i < to; i++) {
|
|
1962
|
+
const ch = masked[i];
|
|
1963
|
+
if (ch === "(") depth++;
|
|
1964
|
+
else if (ch === ")") depth--;
|
|
1965
|
+
else if (ch === "," && depth === 0) {
|
|
1966
|
+
values.push({ from: start, to: i });
|
|
1967
|
+
start = i + 1;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
values.push({ from: start, to });
|
|
1971
|
+
return values;
|
|
1972
|
+
}
|
|
1973
|
+
var INSERT_BUCKETS = /\binsert\s+into\s+storage\s*\.\s*buckets\s*\(([^()]{0,500})\)\s*values\s*/gi;
|
|
1974
|
+
var UPDATE_BUCKETS = /\bupdate\s+storage\s*\.\s*buckets\s+set\b/gi;
|
|
1975
|
+
function bucketDeclarations(masked, source, onTruncated) {
|
|
1976
|
+
const found = [];
|
|
1977
|
+
let m;
|
|
1978
|
+
INSERT_BUCKETS.lastIndex = 0;
|
|
1979
|
+
while ((m = INSERT_BUCKETS.exec(masked)) !== null) {
|
|
1980
|
+
const columns = m[1].split(",").map((c) => unquoteIdent(c.trim()));
|
|
1981
|
+
const publicIndex = columns.indexOf("public");
|
|
1982
|
+
const idIndex = columns.indexOf("id") !== -1 ? columns.indexOf("id") : columns.indexOf("name");
|
|
1983
|
+
const end = statementEnd(masked, m.index, onTruncated);
|
|
1984
|
+
let at = m.index + m[0].length;
|
|
1985
|
+
while (at < end) {
|
|
1986
|
+
while (at < end && /[\s,]/.test(masked[at])) at++;
|
|
1987
|
+
if (masked[at] !== "(") break;
|
|
1988
|
+
const tuple = parenthesized(masked, at, end);
|
|
1989
|
+
if (!tuple) break;
|
|
1990
|
+
const values = topLevelValues(masked, tuple.from, tuple.to);
|
|
1991
|
+
const id = idIndex === -1 ? null : literal(source.slice(values[idIndex]?.from ?? 0, values[idIndex]?.to ?? 0));
|
|
1992
|
+
const isPublic = publicIndex !== -1 && source.slice(values[publicIndex]?.from ?? 0, values[publicIndex]?.to ?? 0).trim().toLowerCase() === "true";
|
|
1993
|
+
if (id !== null) found.push({ id, public: isPublic, at: m.index });
|
|
1994
|
+
at = tuple.to + 1;
|
|
1995
|
+
}
|
|
1996
|
+
INSERT_BUCKETS.lastIndex = Math.max(INSERT_BUCKETS.lastIndex, end);
|
|
1997
|
+
}
|
|
1998
|
+
UPDATE_BUCKETS.lastIndex = 0;
|
|
1999
|
+
while ((m = UPDATE_BUCKETS.exec(masked)) !== null) {
|
|
2000
|
+
const end = statementEnd(masked, m.index, onTruncated);
|
|
2001
|
+
const statement = masked.slice(m.index, end);
|
|
2002
|
+
const setPublic = /\bpublic\s*=\s*(true|false)\b/i.exec(statement);
|
|
2003
|
+
const where = /\bwhere\s+(?:id|name)\s*=/i.exec(statement);
|
|
2004
|
+
const id = where ? literal(source.slice(m.index + where.index + where[0].length, end)) : null;
|
|
2005
|
+
if (setPublic && id !== null) found.push({ id, public: setPublic[1].toLowerCase() === "true", at: m.index });
|
|
2006
|
+
UPDATE_BUCKETS.lastIndex = Math.max(UPDATE_BUCKETS.lastIndex, end);
|
|
2007
|
+
}
|
|
2008
|
+
return found.sort((a, b) => a.at - b.at);
|
|
2009
|
+
}
|
|
2010
|
+
function configPublicBuckets(toml) {
|
|
2011
|
+
const found = [];
|
|
2012
|
+
let current = null;
|
|
2013
|
+
toml.split(/\r?\n/).forEach((raw, index) => {
|
|
2014
|
+
const line = raw.replace(/#.*$/, "").trim();
|
|
2015
|
+
const section = /^\[\s*storage\.buckets\.("?)([^"\]]+)\1\s*\]$/.exec(line);
|
|
2016
|
+
if (section) {
|
|
2017
|
+
current = { id: section[2], line: index + 1 };
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
if (line.startsWith("[")) {
|
|
2021
|
+
current = null;
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
if (current && /^public\s*=\s*true$/i.test(line)) found.push(current);
|
|
2025
|
+
});
|
|
2026
|
+
return found;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1788
2029
|
// src/rules/supabase.ts
|
|
1789
2030
|
var INTERNAL_SCHEMAS = /* @__PURE__ */ new Set([
|
|
1790
2031
|
"auth",
|
|
@@ -2011,6 +2252,10 @@ var supabaseRlsRule = {
|
|
|
2011
2252
|
).sort((a, b) => a.path.localeCompare(b.path));
|
|
2012
2253
|
for (const file of sqlFiles) {
|
|
2013
2254
|
const sql = maskSqlNoise(file.content);
|
|
2255
|
+
const truncatedStatement = () => ctx.reportIncomplete(
|
|
2256
|
+
"supabase/policy-parse-limit",
|
|
2257
|
+
`${file.path} contains a policy or bucket statement longer than the 4000-character parse limit`
|
|
2258
|
+
);
|
|
2014
2259
|
const scope = replayScopeOf(file, projectScopeOf(file.path, projectScopes));
|
|
2015
2260
|
const sqlLines = lineStartsOf(sql);
|
|
2016
2261
|
let m;
|
|
@@ -2062,11 +2307,62 @@ var supabaseRlsRule = {
|
|
|
2062
2307
|
line: lineNumberAt(sqlLines, m.index)
|
|
2063
2308
|
});
|
|
2064
2309
|
}
|
|
2310
|
+
for (const [pattern, kind] of [
|
|
2311
|
+
[CREATE_POLICY, "policy"],
|
|
2312
|
+
[DROP_POLICY, "policy-drop"],
|
|
2313
|
+
[ALTER_POLICY, "policy-alter"]
|
|
2314
|
+
]) {
|
|
2315
|
+
pattern.lastIndex = 0;
|
|
2316
|
+
while ((m = pattern.exec(sql)) !== null) {
|
|
2317
|
+
const end = statementEnd(sql, m.index, truncatedStatement);
|
|
2318
|
+
events.push({
|
|
2319
|
+
kind,
|
|
2320
|
+
policy: identAt(file.content, m, 1),
|
|
2321
|
+
schema: m[2] ? unquote(m[2]) : "public",
|
|
2322
|
+
table: unquote(m[3]),
|
|
2323
|
+
...kind === "policy-drop" ? {} : { clauses: policyClauses(sql, file.content, m.index + m[0].length, end) },
|
|
2324
|
+
file: file.path,
|
|
2325
|
+
scope,
|
|
2326
|
+
at: m.index,
|
|
2327
|
+
line: lineNumberAt(sqlLines, m.index)
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
for (const bucket of bucketDeclarations(sql, file.content, truncatedStatement)) {
|
|
2332
|
+
events.push({
|
|
2333
|
+
kind: "bucket",
|
|
2334
|
+
bucket: { id: bucket.id, public: bucket.public },
|
|
2335
|
+
schema: "storage",
|
|
2336
|
+
table: "buckets",
|
|
2337
|
+
file: file.path,
|
|
2338
|
+
scope,
|
|
2339
|
+
at: bucket.at,
|
|
2340
|
+
line: lineNumberAt(sqlLines, bucket.at)
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2065
2343
|
}
|
|
2066
2344
|
const fileOrder = new Map(sqlFiles.map((f, i) => [f.path, i]));
|
|
2067
2345
|
events.sort((a, b) => fileOrder.get(a.file) - fileOrder.get(b.file) || a.at - b.at);
|
|
2068
2346
|
const live = /* @__PURE__ */ new Map();
|
|
2069
2347
|
const keyOf = (scope, schema, table) => JSON.stringify([scope, schema, table]);
|
|
2348
|
+
const policies = /* @__PURE__ */ new Map();
|
|
2349
|
+
const policyKey = (scope, schema, table, name) => JSON.stringify([scope, schema, table, name]);
|
|
2350
|
+
const byTable = /* @__PURE__ */ new Map();
|
|
2351
|
+
const setPolicy = (k, p) => {
|
|
2352
|
+
policies.set(k, p);
|
|
2353
|
+
const tableKey = keyOf(p.scope, p.schema, p.table);
|
|
2354
|
+
const set = byTable.get(tableKey) ?? /* @__PURE__ */ new Set();
|
|
2355
|
+
set.add(k);
|
|
2356
|
+
byTable.set(tableKey, set);
|
|
2357
|
+
};
|
|
2358
|
+
const deletePolicy = (k) => {
|
|
2359
|
+
const p = policies.get(k);
|
|
2360
|
+
if (!p) return;
|
|
2361
|
+
policies.delete(k);
|
|
2362
|
+
byTable.get(keyOf(p.scope, p.schema, p.table))?.delete(k);
|
|
2363
|
+
};
|
|
2364
|
+
const policiesOn = (key) => [...byTable.get(key) ?? []].map((k) => [k, policies.get(k)]);
|
|
2365
|
+
const buckets = /* @__PURE__ */ new Map();
|
|
2070
2366
|
for (const ev of events) {
|
|
2071
2367
|
const scope = ev.scope;
|
|
2072
2368
|
const key = keyOf(scope, ev.schema, ev.table);
|
|
@@ -2075,15 +2371,66 @@ var supabaseRlsRule = {
|
|
|
2075
2371
|
live.set(key, { schema: ev.schema, table: ev.table, file: ev.file, line: ev.line, rls: false });
|
|
2076
2372
|
} else if (ev.kind === "drop") {
|
|
2077
2373
|
live.delete(key);
|
|
2374
|
+
for (const [k] of policiesOn(key)) deletePolicy(k);
|
|
2078
2375
|
} else if (ev.kind === "rename") {
|
|
2079
2376
|
const cur = live.get(key);
|
|
2080
2377
|
if (cur) {
|
|
2081
2378
|
live.delete(key);
|
|
2082
2379
|
live.set(keyOf(scope, ev.schema, ev.renamedTo), { ...cur, table: ev.renamedTo });
|
|
2083
2380
|
}
|
|
2084
|
-
|
|
2381
|
+
for (const [k, p] of policiesOn(key)) {
|
|
2382
|
+
deletePolicy(k);
|
|
2383
|
+
setPolicy(policyKey(scope, ev.schema, ev.renamedTo, p.name), { ...p, table: ev.renamedTo });
|
|
2384
|
+
}
|
|
2385
|
+
} else if (ev.kind === "enable-rls" || ev.kind === "disable-rls") {
|
|
2085
2386
|
const cur = live.get(key);
|
|
2086
2387
|
if (cur) cur.rls = ev.kind === "enable-rls";
|
|
2388
|
+
} else if (ev.kind === "policy") {
|
|
2389
|
+
const c = ev.clauses ?? {};
|
|
2390
|
+
setPolicy(policyKey(scope, ev.schema, ev.table, ev.policy), {
|
|
2391
|
+
name: ev.policy,
|
|
2392
|
+
scope,
|
|
2393
|
+
schema: ev.schema,
|
|
2394
|
+
table: ev.table,
|
|
2395
|
+
file: ev.file,
|
|
2396
|
+
line: ev.line,
|
|
2397
|
+
restrictive: c.restrictive ?? false,
|
|
2398
|
+
command: c.command ?? "all",
|
|
2399
|
+
roles: c.roles ?? ["public"],
|
|
2400
|
+
...c.using === void 0 ? {} : { using: c.using },
|
|
2401
|
+
...c.check === void 0 ? {} : { check: c.check }
|
|
2402
|
+
});
|
|
2403
|
+
} else if (ev.kind === "policy-drop") {
|
|
2404
|
+
deletePolicy(policyKey(scope, ev.schema, ev.table, ev.policy));
|
|
2405
|
+
} else if (ev.kind === "policy-alter") {
|
|
2406
|
+
const k = policyKey(scope, ev.schema, ev.table, ev.policy);
|
|
2407
|
+
const cur = policies.get(k);
|
|
2408
|
+
const c = ev.clauses ?? {};
|
|
2409
|
+
if (cur) {
|
|
2410
|
+
const next = {
|
|
2411
|
+
...cur,
|
|
2412
|
+
...c.roles ? { roles: c.roles } : {},
|
|
2413
|
+
// 角色或条件改变时,证据位置跟随实际修改;单纯改名保留原条件位置。
|
|
2414
|
+
...c.roles !== void 0 || c.using !== void 0 || c.check !== void 0 ? { file: ev.file, line: ev.line } : {},
|
|
2415
|
+
...c.using === void 0 ? {} : { using: c.using },
|
|
2416
|
+
...c.check === void 0 ? {} : { check: c.check }
|
|
2417
|
+
};
|
|
2418
|
+
deletePolicy(k);
|
|
2419
|
+
const name = c.renamedTo ?? cur.name;
|
|
2420
|
+
setPolicy(policyKey(scope, ev.schema, ev.table, name), { ...next, name });
|
|
2421
|
+
}
|
|
2422
|
+
} else if (ev.kind === "bucket") {
|
|
2423
|
+
buckets.set(JSON.stringify([scope, ev.bucket.id]), { public: ev.bucket.public, file: ev.file, line: ev.line });
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
for (const file of ctx.files) {
|
|
2427
|
+
if (!/(?:^|\/)supabase\/config\.toml$/.test(file.path)) continue;
|
|
2428
|
+
const projectScope = projectScopeOf(file.path, projectScopes);
|
|
2429
|
+
if (!activeScopes.has(projectScope)) continue;
|
|
2430
|
+
const scope = replayScopeOf(file, projectScope);
|
|
2431
|
+
for (const bucket of configPublicBuckets(file.content)) {
|
|
2432
|
+
const k = JSON.stringify([scope, bucket.id]);
|
|
2433
|
+
if (!buckets.has(k)) buckets.set(k, { public: true, file: file.path, line: bucket.line });
|
|
2087
2434
|
}
|
|
2088
2435
|
}
|
|
2089
2436
|
const findings = [];
|
|
@@ -2124,22 +2471,249 @@ var supabaseRlsRule = {
|
|
|
2124
2471
|
`${unreported} further ${unreported === 1 ? "table has" : "tables have"} no Row Level Security in your migrations beyond the ${MAX_FINDINGS_PER_FILE} listed; they were not reported individually`
|
|
2125
2472
|
);
|
|
2126
2473
|
}
|
|
2474
|
+
findings.push(...policyFindings(ctx, [...policies.values()], live, keyOf, buckets));
|
|
2127
2475
|
return findings;
|
|
2128
2476
|
}
|
|
2129
2477
|
};
|
|
2478
|
+
var PRIVILEGED_ROLES = /* @__PURE__ */ new Set(["service_role", "postgres", "supabase_admin"]);
|
|
2479
|
+
function audienceOf(roles) {
|
|
2480
|
+
if (roles.includes("public") || roles.includes("anon")) return "anyone, signed in or not,";
|
|
2481
|
+
if (roles.includes("authenticated")) return "any signed-in user";
|
|
2482
|
+
return `the ${roles.join(", ")} role${roles.length === 1 ? "" : "s"}`;
|
|
2483
|
+
}
|
|
2484
|
+
function policyFindings(ctx, policies, live, keyOf, buckets) {
|
|
2485
|
+
const lines = new Map(ctx.files.map((file) => [file.path, file.lines]));
|
|
2486
|
+
const excerpt = (file, line) => lines.get(file)?.[line - 1]?.trim() ?? null;
|
|
2487
|
+
const findings = [];
|
|
2488
|
+
let unreported = 0;
|
|
2489
|
+
const push = (finding) => {
|
|
2490
|
+
if (findings.length >= MAX_FINDINGS_PER_FILE) unreported++;
|
|
2491
|
+
else findings.push(finding);
|
|
2492
|
+
};
|
|
2493
|
+
const publicBuckets = /* @__PURE__ */ new Map();
|
|
2494
|
+
for (const [key, bucket] of buckets) {
|
|
2495
|
+
if (!bucket.public) continue;
|
|
2496
|
+
const [scope, id] = JSON.parse(key);
|
|
2497
|
+
const ids = publicBuckets.get(scope) ?? [];
|
|
2498
|
+
ids.push(id);
|
|
2499
|
+
publicBuckets.set(scope, ids);
|
|
2500
|
+
}
|
|
2501
|
+
const restrictiveCommands = /* @__PURE__ */ new Map();
|
|
2502
|
+
for (const policy of policies) {
|
|
2503
|
+
if (!policy.restrictive) continue;
|
|
2504
|
+
const key = keyOf(policy.scope, policy.schema, policy.table);
|
|
2505
|
+
const commands = restrictiveCommands.get(key) ?? /* @__PURE__ */ new Set();
|
|
2506
|
+
commands.add(policy.command);
|
|
2507
|
+
restrictiveCommands.set(key, commands);
|
|
2508
|
+
}
|
|
2509
|
+
for (const p of policies) {
|
|
2510
|
+
if (p.restrictive || p.roles.every((role) => PRIVILEGED_ROLES.has(role))) continue;
|
|
2511
|
+
if (live.get(keyOf(p.scope, p.schema, p.table))?.rls === false) continue;
|
|
2512
|
+
const table = `${renderIdent(p.schema)}.${renderIdent(p.table)}`;
|
|
2513
|
+
const audience = audienceOf(p.roles);
|
|
2514
|
+
const restrictions = restrictiveCommands.get(keyOf(p.scope, p.schema, p.table));
|
|
2515
|
+
const hasRestriction = restrictions !== void 0 && (p.command === "all" || restrictions.has("all") || restrictions.has(p.command));
|
|
2516
|
+
if (p.schema === "storage" && p.table === "objects" && (p.command === "select" || p.command === "all") && p.using !== void 0) {
|
|
2517
|
+
const onlyBucket = bucketOnlyCondition(p.using);
|
|
2518
|
+
const everyBucket = isAlwaysTrue(p.using);
|
|
2519
|
+
const listable = (publicBuckets.get(p.scope) ?? []).filter((id) => everyBucket || onlyBucket === id);
|
|
2520
|
+
for (const id of listable) {
|
|
2521
|
+
push({
|
|
2522
|
+
ruleId: "supabase/public-bucket-listing",
|
|
2523
|
+
severity: "P2",
|
|
2524
|
+
confidence: hasRestriction ? "likely" : "certain",
|
|
2525
|
+
title: hasRestriction ? `Policy "${p.name}" permits listing the public "${id}" bucket, subject to restrictive policies` : `Policy "${p.name}" lets ${audience} list every file in the public "${id}" bucket`,
|
|
2526
|
+
file: p.file,
|
|
2527
|
+
line: p.line,
|
|
2528
|
+
excerpt: excerpt(p.file, p.line),
|
|
2529
|
+
why: [
|
|
2530
|
+
`Files in a public bucket can already be downloaded by anyone who has their URL, without any policy. This SELECT policy on storage.objects adds something else: it lets callers list the bucket's contents.`,
|
|
2531
|
+
`Listing reveals every file name, including files uploaded by other users that were only meant to be reachable by someone who already had the link.`,
|
|
2532
|
+
...hasRestriction ? ["Restrictive policies also apply with AND; their conditions and role coverage require review."] : []
|
|
2533
|
+
],
|
|
2534
|
+
fix: [
|
|
2535
|
+
`If the app only serves files by URL, drop this policy: public object URLs keep working without it.`,
|
|
2536
|
+
`If the app really needs listing, make the bucket private and scope the policy to the owner, e.g. USING (bucket_id = '${id.replace(/'/g, "''")}' AND owner_id = (select auth.uid()::text)).`
|
|
2537
|
+
]
|
|
2538
|
+
});
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
const usingTrue = p.using !== void 0 && isAlwaysTrue(p.using);
|
|
2542
|
+
const checkTrue = p.check !== void 0 && isAlwaysTrue(p.check);
|
|
2543
|
+
const opensRows = usingTrue && p.command !== "insert";
|
|
2544
|
+
const opensWrites = checkTrue && (p.command === "insert" || p.command === "update" || p.command === "all");
|
|
2545
|
+
if (!opensRows && !opensWrites) continue;
|
|
2546
|
+
const modifiesAny = usingTrue && (p.command === "update" || p.command === "delete" || p.command === "all");
|
|
2547
|
+
const limitedCheck = (p.command === "update" || p.command === "all") && p.check !== void 0 && !checkTrue;
|
|
2548
|
+
const needsReview = hasRestriction || limitedCheck;
|
|
2549
|
+
const verb = { all: "read, change and delete", select: "read", insert: "insert any data into", update: "change", delete: "delete" }[p.command];
|
|
2550
|
+
push({
|
|
2551
|
+
ruleId: "supabase/permissive-policy",
|
|
2552
|
+
severity: "P1",
|
|
2553
|
+
confidence: modifiesAny && !needsReview ? "certain" : "likely",
|
|
2554
|
+
title: needsReview ? `Policy "${p.name}" has an unrestricted condition on ${table}; other conditions require review` : opensRows ? `Policy "${p.name}" lets ${audience} ${verb} every row of ${table}` : `Policy "${p.name}" lets ${audience} write any values into ${table}`,
|
|
2555
|
+
file: p.file,
|
|
2556
|
+
line: p.line,
|
|
2557
|
+
excerpt: excerpt(p.file, p.line),
|
|
2558
|
+
why: [
|
|
2559
|
+
`This policy contains an always-true condition. Permissive policies combine with OR, while applicable restrictive policies combine with AND. Database grants, SELECT policies and WITH CHECK conditions may further limit access.`,
|
|
2560
|
+
...hasRestriction ? ["A restrictive policy on this table may limit access; verify its command and role coverage."] : [],
|
|
2561
|
+
...limitedCheck ? ["The WITH CHECK condition is not known to be always true, so unrestricted updates are not established."] : [],
|
|
2562
|
+
...p.command === "select" || p.command === "insert" ? [`Public read-only tables and open forms can be intentional. If this one is, keep it and consider restricting the columns the API exposes.`] : []
|
|
2563
|
+
],
|
|
2564
|
+
fix: [
|
|
2565
|
+
`Replace the condition with one that ties each row to its owner, e.g. USING ((select auth.uid()) = user_id)${p.command === "insert" || p.command === "update" || p.command === "all" ? " and WITH CHECK ((select auth.uid()) = user_id)" : ""}.`,
|
|
2566
|
+
`If a broad policy is needed as a base, add an AS RESTRICTIVE policy alongside it to limit which rows it reaches.`,
|
|
2567
|
+
`Keep the change in a migration, so the rule travels with your code.`
|
|
2568
|
+
],
|
|
2569
|
+
humanOnly: [
|
|
2570
|
+
`Check the policy in the Supabase dashboard (Authentication -> Policies); a policy changed there is not visible in the repository.`
|
|
2571
|
+
]
|
|
2572
|
+
});
|
|
2573
|
+
}
|
|
2574
|
+
if (unreported > 0) {
|
|
2575
|
+
ctx.reportIncomplete(
|
|
2576
|
+
"supabase/permissive-policy",
|
|
2577
|
+
`${unreported} further permissive ${unreported === 1 ? "policy was" : "policies were"} not reported individually`
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
return findings;
|
|
2581
|
+
}
|
|
2130
2582
|
|
|
2131
2583
|
// src/rules/firebase.ts
|
|
2132
2584
|
import { basename as basename5 } from "path";
|
|
2133
2585
|
function isRulesFile(file) {
|
|
2134
2586
|
return basename5(file.path).toLowerCase().endsWith(".rules");
|
|
2135
2587
|
}
|
|
2588
|
+
function isRealtimeRulesFile(file) {
|
|
2589
|
+
if (!/\.(?:json|rules)$/i.test(file.path)) return false;
|
|
2590
|
+
if (!/"\.(?:read|write)"/.test(file.content)) return false;
|
|
2591
|
+
return /^\s*\{\s*"rules"\s*:\s*\{/.test(commentsMaskedOf(file));
|
|
2592
|
+
}
|
|
2593
|
+
function booleanConstant(value) {
|
|
2594
|
+
if (value === void 0) return null;
|
|
2595
|
+
let decoded;
|
|
2596
|
+
try {
|
|
2597
|
+
decoded = JSON.parse(value);
|
|
2598
|
+
} catch {
|
|
2599
|
+
return null;
|
|
2600
|
+
}
|
|
2601
|
+
if (typeof decoded === "boolean") return decoded;
|
|
2602
|
+
if (typeof decoded !== "string") return null;
|
|
2603
|
+
const match = /^\s*(?:\(\s*)*(true|false)(?:\s*\))*\s*$/.exec(decoded);
|
|
2604
|
+
if (!match || [...decoded.matchAll(/\(/g)].length !== [...decoded.matchAll(/\)/g)].length) return null;
|
|
2605
|
+
return match[1] === "true";
|
|
2606
|
+
}
|
|
2607
|
+
function grantsEveryone(value) {
|
|
2608
|
+
return booleanConstant(value) === true;
|
|
2609
|
+
}
|
|
2610
|
+
function deniesEveryone(value) {
|
|
2611
|
+
return booleanConstant(value) === false;
|
|
2612
|
+
}
|
|
2613
|
+
function decodeKey(token) {
|
|
2614
|
+
try {
|
|
2615
|
+
return JSON.parse(token);
|
|
2616
|
+
} catch {
|
|
2617
|
+
return token.replace(/^"|"$/g, "");
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
function checkRealtimeRules(file, ctx) {
|
|
2621
|
+
const text = commentsMaskedOf(file);
|
|
2622
|
+
try {
|
|
2623
|
+
JSON.parse(text);
|
|
2624
|
+
} catch {
|
|
2625
|
+
ctx.reportIncomplete("firebase/open-rules", `${file.path} contains invalid Realtime Database rule JSON; rules were not evaluated`);
|
|
2626
|
+
return [];
|
|
2627
|
+
}
|
|
2628
|
+
const lineStarts = lineStartsOf(text);
|
|
2629
|
+
const findings = [];
|
|
2630
|
+
const stack = [];
|
|
2631
|
+
let pendingKey = null;
|
|
2632
|
+
const report = (node) => {
|
|
2633
|
+
const read = node.rules.get(".read");
|
|
2634
|
+
const write = node.rules.get(".write");
|
|
2635
|
+
const openRead = grantsEveryone(read?.value);
|
|
2636
|
+
const openWrite = grantsEveryone(write?.value);
|
|
2637
|
+
if (!openRead && !openWrite) return;
|
|
2638
|
+
if (!openWrite && deniesEveryone(write?.value)) return;
|
|
2639
|
+
if (findings.length >= MAX_FINDINGS_PER_FILE) {
|
|
2640
|
+
ctx.reportIncomplete(
|
|
2641
|
+
"firebase/open-rules",
|
|
2642
|
+
`${file.path} holds more than ${MAX_FINDINGS_PER_FILE} open rules; the rest were not reported`
|
|
2643
|
+
);
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
const segments = stack.slice(2).map((entry) => entry.key).concat(stack.length >= 2 ? [node.key] : []);
|
|
2647
|
+
const path = `/${segments.join("/")}`.replace(/\/{2,}/g, "/");
|
|
2648
|
+
const at = (openWrite ? write : read).index;
|
|
2649
|
+
const line = lineNumberAt(lineStarts, at);
|
|
2650
|
+
const ops = openRead && openWrite ? "read and write" : openWrite ? "write" : "read";
|
|
2651
|
+
findings.push({
|
|
2652
|
+
ruleId: "firebase/open-rules",
|
|
2653
|
+
severity: "P1",
|
|
2654
|
+
confidence: openWrite ? "certain" : "likely",
|
|
2655
|
+
title: openWrite ? `Your Realtime Database rules let anyone ${ops} ${path === "/" ? "your entire database" : path}` : `Your Realtime Database rules make ${path === "/" ? "your entire database" : path} publicly readable`,
|
|
2656
|
+
file: file.path,
|
|
2657
|
+
line,
|
|
2658
|
+
excerpt: (file.lines[line - 1] ?? "").trim(),
|
|
2659
|
+
why: [
|
|
2660
|
+
`This rule grants ${ops} access to everyone, with no sign-in required. The Firebase client SDK talks to your database straight from the browser, so these rules are the only access control that exists.`,
|
|
2661
|
+
`Realtime Database rules cascade: access granted at ${path} also applies to everything beneath it, and a stricter rule deeper down cannot take it back.`,
|
|
2662
|
+
...openWrite ? [] : [
|
|
2663
|
+
`If this data is meant to be public, add ".write": false next to it to make that intent explicit.`
|
|
2664
|
+
]
|
|
2665
|
+
],
|
|
2666
|
+
fix: [
|
|
2667
|
+
`Require sign-in and ownership instead, for example: ".read": "auth !== null && auth.uid === $uid" under a "$uid" node.`,
|
|
2668
|
+
`Test the new rules with the Firebase emulator before deploying.`,
|
|
2669
|
+
...openWrite ? [`If this database has been open for a while, assume the data has already been copied or changed.`] : []
|
|
2670
|
+
]
|
|
2671
|
+
});
|
|
2672
|
+
};
|
|
2673
|
+
let i = 0;
|
|
2674
|
+
while (i < text.length) {
|
|
2675
|
+
const ch = text[i];
|
|
2676
|
+
if (ch === '"') {
|
|
2677
|
+
let j = i + 1;
|
|
2678
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
2679
|
+
const token = text.slice(i, j + 1);
|
|
2680
|
+
let k = j + 1;
|
|
2681
|
+
while (k < text.length && /\s/.test(text[k])) k++;
|
|
2682
|
+
if (text[k] === ":" && pendingKey === null) pendingKey = { key: decodeKey(token), index: i };
|
|
2683
|
+
else if (pendingKey !== null) {
|
|
2684
|
+
stack[stack.length - 1]?.rules.set(pendingKey.key, { value: token, index: pendingKey.index });
|
|
2685
|
+
pendingKey = null;
|
|
2686
|
+
}
|
|
2687
|
+
i = j + 1;
|
|
2688
|
+
continue;
|
|
2689
|
+
}
|
|
2690
|
+
if (ch === "{") {
|
|
2691
|
+
stack.push({ key: pendingKey?.key ?? "", rules: /* @__PURE__ */ new Map() });
|
|
2692
|
+
pendingKey = null;
|
|
2693
|
+
} else if (ch === "}") {
|
|
2694
|
+
const node = stack.pop();
|
|
2695
|
+
if (node) report(node);
|
|
2696
|
+
} else if (pendingKey !== null && /[A-Za-z0-9-]/.test(ch)) {
|
|
2697
|
+
let j = i;
|
|
2698
|
+
while (j < text.length && /[A-Za-z0-9.+-]/.test(text[j])) j++;
|
|
2699
|
+
stack[stack.length - 1]?.rules.set(pendingKey.key, { value: text.slice(i, j), index: pendingKey.index });
|
|
2700
|
+
pendingKey = null;
|
|
2701
|
+
i = j;
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
i++;
|
|
2705
|
+
}
|
|
2706
|
+
return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
|
|
2707
|
+
}
|
|
2136
2708
|
function productOf(path) {
|
|
2137
2709
|
const name = basename5(path).toLowerCase();
|
|
2138
2710
|
if (name.includes("storage")) return "Storage";
|
|
2139
2711
|
if (name.includes("firestore")) return "Firestore";
|
|
2140
2712
|
return "Firebase";
|
|
2141
2713
|
}
|
|
2142
|
-
var
|
|
2714
|
+
var OPS = String.raw`([a-z]{1,16}(?:\s*,\s*[a-z]{1,16}){0,8})`;
|
|
2715
|
+
var ALLOW_IF_TRUE = new RegExp(String.raw`\ballow\s+${OPS}\s*:\s*if\s+true\s*;`, "gi");
|
|
2716
|
+
var ALLOW_IF_FALSE = new RegExp(String.raw`\ballow\s+${OPS}\s*:\s*if\s+false\s*;`, "gi");
|
|
2143
2717
|
var WRITE_OPS = /* @__PURE__ */ new Set(["write", "create", "update", "delete"]);
|
|
2144
2718
|
function parseOps(raw) {
|
|
2145
2719
|
return raw.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
@@ -2147,57 +2721,44 @@ function parseOps(raw) {
|
|
|
2147
2721
|
function neutralizePathWildcards(content) {
|
|
2148
2722
|
return content.replace(/\{[a-zA-Z_]\w*(?:\s*=\s*\*\*)?\}/g, (m) => "_".repeat(m.length));
|
|
2149
2723
|
}
|
|
2150
|
-
function
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
if (depth === 0) {
|
|
2179
|
-
j++;
|
|
2180
|
-
break;
|
|
2181
|
-
}
|
|
2182
|
-
}
|
|
2183
|
-
}
|
|
2184
|
-
i = j;
|
|
2185
|
-
continue;
|
|
2724
|
+
function matchBlocksOf(neutralized) {
|
|
2725
|
+
const blocks = [];
|
|
2726
|
+
const stack = [];
|
|
2727
|
+
let lastDelimiter = -1;
|
|
2728
|
+
for (let i = 0; i < neutralized.length; i++) {
|
|
2729
|
+
const ch = neutralized[i];
|
|
2730
|
+
if (ch === "{") {
|
|
2731
|
+
stack.push({ open: i, isMatch: /^\s*match\b/.test(neutralized.slice(lastDelimiter + 1, i)) });
|
|
2732
|
+
lastDelimiter = i;
|
|
2733
|
+
} else if (ch === "}") {
|
|
2734
|
+
const top = stack.pop();
|
|
2735
|
+
if (top?.isMatch) blocks.push({ open: top.open, close: i });
|
|
2736
|
+
lastDelimiter = i;
|
|
2737
|
+
} else if (ch === ";") {
|
|
2738
|
+
lastDelimiter = i;
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
for (const top of stack) if (top.isMatch) blocks.push({ open: top.open, close: neutralized.length });
|
|
2742
|
+
return blocks.sort((a, b) => a.open - b.open);
|
|
2743
|
+
}
|
|
2744
|
+
function innermostBlocks(blocks, positions) {
|
|
2745
|
+
const owners = [];
|
|
2746
|
+
const active = [];
|
|
2747
|
+
let next = 0;
|
|
2748
|
+
for (const at of positions) {
|
|
2749
|
+
while (next < blocks.length && blocks[next].open < at) {
|
|
2750
|
+
while (active.length && blocks[active[active.length - 1]].close < blocks[next].open) active.pop();
|
|
2751
|
+
active.push(next++);
|
|
2186
2752
|
}
|
|
2187
|
-
|
|
2188
|
-
|
|
2753
|
+
while (active.length && blocks[active[active.length - 1]].close < at) active.pop();
|
|
2754
|
+
owners.push(active.length ? active[active.length - 1] : -1);
|
|
2189
2755
|
}
|
|
2190
|
-
return
|
|
2756
|
+
return owners;
|
|
2191
2757
|
}
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
if (parseOps(m[1] ?? "").some((op) => WRITE_OPS.has(op))) return true;
|
|
2197
|
-
}
|
|
2198
|
-
return false;
|
|
2199
|
-
}
|
|
2200
|
-
var TEST_MODE = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;/gi;
|
|
2758
|
+
var TEST_MODE = new RegExp(
|
|
2759
|
+
String.raw`\ballow\s+${OPS}\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;`,
|
|
2760
|
+
"gi"
|
|
2761
|
+
);
|
|
2201
2762
|
function describeOps(raw) {
|
|
2202
2763
|
const ops = parseOps(raw);
|
|
2203
2764
|
if (ops.includes("write") && ops.includes("read")) return "read and write";
|
|
@@ -2208,9 +2769,10 @@ var firebaseRulesRule = {
|
|
|
2208
2769
|
id: "firebase/open-rules",
|
|
2209
2770
|
severity: "P1",
|
|
2210
2771
|
appliesTo(file) {
|
|
2211
|
-
return isRulesFile(file);
|
|
2772
|
+
return isRulesFile(file) || isRealtimeRulesFile(file);
|
|
2212
2773
|
},
|
|
2213
2774
|
check(file, ctx) {
|
|
2775
|
+
if (isRealtimeRulesFile(file)) return checkRealtimeRules(file, ctx);
|
|
2214
2776
|
const findings = [];
|
|
2215
2777
|
const product = productOf(file.path);
|
|
2216
2778
|
const capReached = () => {
|
|
@@ -2224,13 +2786,18 @@ var firebaseRulesRule = {
|
|
|
2224
2786
|
const content = noiseMaskedOf(file);
|
|
2225
2787
|
const neutralized = neutralizePathWildcards(content);
|
|
2226
2788
|
const contentLines = lineStartsOf(content);
|
|
2227
|
-
|
|
2789
|
+
const blocks = matchBlocksOf(neutralized);
|
|
2790
|
+
const denials = [...neutralized.matchAll(ALLOW_IF_FALSE)].filter((d) => parseOps(d[1] ?? "").some((op) => WRITE_OPS.has(op)));
|
|
2791
|
+
const denyingBlocks = new Set(innermostBlocks(blocks, denials.map((d) => d.index)));
|
|
2792
|
+
const opens = [...content.matchAll(ALLOW_IF_TRUE)];
|
|
2793
|
+
const openOwners = innermostBlocks(blocks, opens.map((o) => o.index));
|
|
2228
2794
|
let m;
|
|
2229
|
-
|
|
2795
|
+
for (const [index, open] of opens.entries()) {
|
|
2796
|
+
m = open;
|
|
2230
2797
|
if (capReached()) break;
|
|
2231
2798
|
const rawOps = m[1] ?? "";
|
|
2232
2799
|
const canWrite = parseOps(rawOps).some((op) => WRITE_OPS.has(op));
|
|
2233
|
-
if (!canWrite &&
|
|
2800
|
+
if (!canWrite && denyingBlocks.has(openOwners[index])) continue;
|
|
2234
2801
|
const ops = describeOps(rawOps);
|
|
2235
2802
|
findings.push({
|
|
2236
2803
|
ruleId: "firebase/open-rules",
|
|
@@ -2299,27 +2866,266 @@ var firebaseRulesRule = {
|
|
|
2299
2866
|
|
|
2300
2867
|
// src/rules/apiauth.ts
|
|
2301
2868
|
import { posix } from "path";
|
|
2869
|
+
|
|
2870
|
+
// src/rules/bindings.ts
|
|
2871
|
+
var cache = /* @__PURE__ */ new WeakMap();
|
|
2872
|
+
function bindingsOf(file) {
|
|
2873
|
+
const cached = cache.get(file);
|
|
2874
|
+
if (cached) return cached;
|
|
2875
|
+
const source = commentsMaskedOf(file);
|
|
2876
|
+
const code = noiseMaskedOf(file);
|
|
2877
|
+
const result = { imports: [], exports: [], stars: [] };
|
|
2878
|
+
const lists = /\b(import|export)\s+(type\s+)?\{([^{}]{0,4000})\}(?:\s*from\s*['"]([^'"\r\n]{1,1024})['"])?/g;
|
|
2879
|
+
for (const match of source.matchAll(lists)) {
|
|
2880
|
+
if (match[2] || code.slice(match.index, match.index + match[1].length) !== match[1]) continue;
|
|
2881
|
+
if (match[1] === "import" && !match[4]) continue;
|
|
2882
|
+
for (const part of match[3].split(",")) {
|
|
2883
|
+
const names = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(part);
|
|
2884
|
+
if (!names) continue;
|
|
2885
|
+
const imported = names[1];
|
|
2886
|
+
const local = names[2] ?? imported;
|
|
2887
|
+
const target = match[1] === "import" ? result.imports : result.exports;
|
|
2888
|
+
target.push({
|
|
2889
|
+
imported,
|
|
2890
|
+
local,
|
|
2891
|
+
...match[4] ? { spec: match[4] } : {}
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
for (const match of source.matchAll(/\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"\r\n]{1,1024})['"]/g)) {
|
|
2896
|
+
if (code.slice(match.index, match.index + 6) === "import" && match[1] !== "type") {
|
|
2897
|
+
result.imports.push({ local: match[1], imported: "default", spec: match[2] });
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
for (const match of source.matchAll(/\bexport\s*\*\s*from\s*['"]([^'"\r\n]{1,1024})['"]/g)) {
|
|
2901
|
+
if (code.slice(match.index, match.index + 6) === "export") result.stars.push(match[1]);
|
|
2902
|
+
}
|
|
2903
|
+
cache.set(file, result);
|
|
2904
|
+
return result;
|
|
2905
|
+
}
|
|
2906
|
+
function namePattern(names) {
|
|
2907
|
+
return [...names].map((name) => name.replace(/\$/g, "\\$")).join("|");
|
|
2908
|
+
}
|
|
2909
|
+
|
|
2910
|
+
// src/rules/apiauth.ts
|
|
2302
2911
|
var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
|
|
2303
2912
|
var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
|
|
2304
|
-
|
|
2305
|
-
|
|
2913
|
+
var APP_ROUTE_FILE = /(?:^|\/)app\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
|
|
2914
|
+
var NEXT_METHODS = "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS";
|
|
2915
|
+
var NEXT_METHOD_EXPORT = new RegExp(
|
|
2916
|
+
String.raw`\bexport\s+(?:async\s+)?function\s+(?:${NEXT_METHODS})\b|\bexport\s+const\s+(?:${NEXT_METHODS})\b|` + // export { handler as GET } 与 Auth.js 的 export const { GET, POST } = handlers;长度有界以免回溯。
|
|
2917
|
+
String.raw`\bexport\s+(?:const\s*)?\{[^{}]{0,500}\b(?:${NEXT_METHODS})\b`
|
|
2918
|
+
);
|
|
2919
|
+
var SVELTEKIT_ENDPOINT = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+server\.[mc]?[jt]s$/;
|
|
2920
|
+
var SVELTEKIT_PAGE_SERVER = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+page\.server\.[mc]?[jt]s$/;
|
|
2921
|
+
var SVELTEKIT_ACTIONS = /\bexport\s+const\s+actions\b\s*(?::[^=;{}]{0,200})?=\s*\{/;
|
|
2922
|
+
var NUXT_ROUTE = /^(.*?\/)?server\/(api|routes)\/(.+)\.[mc]?[jt]s$/;
|
|
2923
|
+
var H3_HANDLER = /\b(?:defineEventHandler|eventHandler|defineCachedEventHandler|defineLazyEventHandler)\s*\(/;
|
|
2924
|
+
var NUXT_METHOD_SUFFIX = /\.(?:get|post|put|patch|delete|head|options|connect|trace)$/i;
|
|
2925
|
+
var REMIX_ROUTE = /^(.*?\/)?app\/routes\/(.+)\.[mc]?[jt]sx?$/;
|
|
2926
|
+
var REMIX_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:loader|action)\b|\bexport\s+const\s+(?:loader|action)\b/;
|
|
2927
|
+
var ASTRO_ENDPOINT = /^(.*?\/)?src\/pages\/(.+)\.[mc]?[jt]s$/;
|
|
2928
|
+
var ASTRO_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b|\bexport\s+const\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
|
|
2929
|
+
function isAstroEndpoint(file) {
|
|
2930
|
+
const code = noiseMaskedOf(file);
|
|
2931
|
+
return ASTRO_EXPORT.test(code) && !/\bexport\s+default\b/.test(code);
|
|
2306
2932
|
}
|
|
2933
|
+
function serverActionRoutes(file) {
|
|
2934
|
+
if (!/use server/.test(file.content) || !/\.[mc]?[jt]sx?$/.test(file.path)) return [];
|
|
2935
|
+
const code = noiseMaskedOf(file);
|
|
2936
|
+
const source = commentsMaskedOf(file);
|
|
2937
|
+
const fileLevel = startsWithDirective(source, 0, "use server");
|
|
2938
|
+
const pairs = delimiterPairs(code);
|
|
2939
|
+
const openers = /* @__PURE__ */ new Map();
|
|
2940
|
+
for (const [open, close] of pairs) openers.set(close, open);
|
|
2941
|
+
const exportedList = /* @__PURE__ */ new Set();
|
|
2942
|
+
for (const m of code.matchAll(/\bexport\s*\{([^{}]{0,1000})\}/g)) {
|
|
2943
|
+
for (const part of m[1].split(",")) {
|
|
2944
|
+
const name = /^\s*([A-Za-z_$][\w$]*)/.exec(part)?.[1];
|
|
2945
|
+
if (name) exportedList.add(name);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2948
|
+
const scope = /^(.*?\/)?(?:src|app|lib|actions|server|utils)\//.exec(file.path)?.[1] ?? "";
|
|
2949
|
+
const routes = [];
|
|
2950
|
+
for (const body of functionBodies(code, pairs)) {
|
|
2951
|
+
const inline = startsWithDirective(source, body.start + 1, "use server");
|
|
2952
|
+
const declared = declarationOf(code, body, openers);
|
|
2953
|
+
const exported = fileLevel && declared !== null && (declared.exported || exportedList.has(declared.name));
|
|
2954
|
+
if (!inline && !exported) continue;
|
|
2955
|
+
const name = declared?.name ?? "anonymous";
|
|
2956
|
+
routes.push({
|
|
2957
|
+
file,
|
|
2958
|
+
framework: "next",
|
|
2959
|
+
url: `the server action ${name}`,
|
|
2960
|
+
scope,
|
|
2961
|
+
reachable: { start: body.start, end: body.end },
|
|
2962
|
+
action: name
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
return routes;
|
|
2966
|
+
}
|
|
2967
|
+
function startsWithDirective(source, from, directive) {
|
|
2968
|
+
let i = from;
|
|
2969
|
+
while (i < source.length) {
|
|
2970
|
+
while (i < source.length && /\s/.test(source[i])) i++;
|
|
2971
|
+
const quote = source[i++];
|
|
2972
|
+
if (quote !== "'" && quote !== '"') return false;
|
|
2973
|
+
const start = i;
|
|
2974
|
+
while (i < source.length && source[i] !== quote && !/[\r\n]/.test(source[i])) {
|
|
2975
|
+
if (source[i] === "\\") i++;
|
|
2976
|
+
i++;
|
|
2977
|
+
}
|
|
2978
|
+
if (source[i] !== quote) return false;
|
|
2979
|
+
const value = source.slice(start, i++);
|
|
2980
|
+
let newline = false;
|
|
2981
|
+
while (i < source.length && /\s/.test(source[i])) {
|
|
2982
|
+
if (/[\r\n]/.test(source[i])) newline = true;
|
|
2983
|
+
i++;
|
|
2984
|
+
}
|
|
2985
|
+
const next = source[i];
|
|
2986
|
+
const terminated = next === ";" || next === "}" || next === void 0 || newline && !/[([.`+\-*/%?:,<>=!&|^]/.test(next);
|
|
2987
|
+
if (!terminated) return false;
|
|
2988
|
+
if (value === directive) return true;
|
|
2989
|
+
if (next === ";") i++;
|
|
2990
|
+
}
|
|
2991
|
+
return false;
|
|
2992
|
+
}
|
|
2993
|
+
function declarationOf(code, body, openers) {
|
|
2994
|
+
if (/^function\s*\(/.test(code.slice(body.declaration, body.declaration + 40)) && /\bexport\s+default\s+(?:async\s+)?$/.test(code.slice(Math.max(0, body.declaration - 80), body.declaration))) {
|
|
2995
|
+
return { name: "default", exported: true };
|
|
2996
|
+
}
|
|
2997
|
+
const named = /^function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(code.slice(body.declaration, body.declaration + 200));
|
|
2998
|
+
if (named) {
|
|
2999
|
+
const before = code.slice(Math.max(0, body.declaration - 40), body.declaration);
|
|
3000
|
+
return { name: named[1], exported: /\bexport\s+(?:default\s+)?(?:async\s+)?$/.test(before) };
|
|
3001
|
+
}
|
|
3002
|
+
let i = body.declaration - 1;
|
|
3003
|
+
const skipSpace = () => {
|
|
3004
|
+
while (i >= 0 && /\s/.test(code[i])) i--;
|
|
3005
|
+
};
|
|
3006
|
+
const readWord = () => {
|
|
3007
|
+
const end = i + 1;
|
|
3008
|
+
while (i >= 0 && /[\w$]/.test(code[i])) i--;
|
|
3009
|
+
return code.slice(i + 1, end);
|
|
3010
|
+
};
|
|
3011
|
+
const window = code.slice(Math.max(0, body.declaration - 200), body.declaration);
|
|
3012
|
+
const close = window.lastIndexOf(")");
|
|
3013
|
+
if (close !== -1 && /^\s*(?::[^=;{()]*)?$/.test(window.slice(close + 1))) {
|
|
3014
|
+
const open = openers.get(body.declaration - window.length + close);
|
|
3015
|
+
if (open === void 0) return null;
|
|
3016
|
+
i = open - 1;
|
|
3017
|
+
} else {
|
|
3018
|
+
skipSpace();
|
|
3019
|
+
if (readWord() === "") return null;
|
|
3020
|
+
}
|
|
3021
|
+
skipSpace();
|
|
3022
|
+
const maybeAsync = i;
|
|
3023
|
+
if (readWord() !== "async") i = maybeAsync;
|
|
3024
|
+
skipSpace();
|
|
3025
|
+
if (code[i] !== "=" || code[i - 1] === "=" || code[i - 1] === "!") return null;
|
|
3026
|
+
i--;
|
|
3027
|
+
skipSpace();
|
|
3028
|
+
let name = readWord();
|
|
3029
|
+
skipSpace();
|
|
3030
|
+
if (code[i] === ":") {
|
|
3031
|
+
i--;
|
|
3032
|
+
skipSpace();
|
|
3033
|
+
name = readWord();
|
|
3034
|
+
skipSpace();
|
|
3035
|
+
}
|
|
3036
|
+
if (name === "" || !/^(?:const|let|var)$/.test(readWord())) return null;
|
|
3037
|
+
skipSpace();
|
|
3038
|
+
return { name, exported: readWord() === "export" };
|
|
3039
|
+
}
|
|
3040
|
+
var AUTH_ACTION_NAMES = /^(?:sign[_-]?(?:in|up|out)|log[_-]?(?:in|out)|register|reset[_-]?password|forgot[_-]?password|verify(?:[_-]?(?:email|otp))?|confirm(?:[_-]?email)?|send[_-]?magic[_-]?link)(?:action)?$/i;
|
|
2307
3041
|
function routePathOf(path) {
|
|
2308
3042
|
return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
|
|
2309
3043
|
}
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
3044
|
+
function routeOf(file) {
|
|
3045
|
+
const path = routePathOf(file.path);
|
|
3046
|
+
if (PAGES_ROUTER.test(file.path) && ASTRO_ENDPOINT.test(path) && isAstroEndpoint(file)) {
|
|
3047
|
+
const astro2 = ASTRO_ENDPOINT.exec(path);
|
|
3048
|
+
return { file, framework: "astro", url: `/${astro2[2].replace(/(?:^|\/)index$/, "")}`, scope: astro2[1] ?? "" };
|
|
3049
|
+
}
|
|
3050
|
+
if (PAGES_ROUTER.test(file.path)) {
|
|
3051
|
+
const scope = /^(.*?)(?:src\/)?pages\/api\//.exec(path)?.[1] ?? "";
|
|
3052
|
+
return { file, framework: "next", url: nextRouteUrl(path), scope };
|
|
3053
|
+
}
|
|
3054
|
+
if (APP_ROUTE_FILE.test(path)) {
|
|
3055
|
+
const app = nextAppRoute(path);
|
|
3056
|
+
const code = noiseMaskedOf(file);
|
|
3057
|
+
const handler = APP_ROUTER.test(path) || NEXT_METHOD_EXPORT.test(code) && !REMIX_EXPORT.test(code);
|
|
3058
|
+
if (app && handler) return app.url === null ? null : { file, framework: "next", url: app.url, scope: app.scope };
|
|
3059
|
+
}
|
|
3060
|
+
const svelte = SVELTEKIT_ENDPOINT.exec(path);
|
|
3061
|
+
if (svelte) return { file, framework: "sveltekit", url: `/${svelte[2] ?? ""}`, scope: svelte[1] ?? "" };
|
|
3062
|
+
const page = SVELTEKIT_PAGE_SERVER.exec(path);
|
|
3063
|
+
if (page) {
|
|
3064
|
+
const code = noiseMaskedOf(file);
|
|
3065
|
+
const actions = SVELTEKIT_ACTIONS.exec(code);
|
|
3066
|
+
if (!actions) return null;
|
|
3067
|
+
const open = actions.index + actions[0].length - 1;
|
|
3068
|
+
const close = closingDelimiter(code, open, "{", "}");
|
|
3069
|
+
return {
|
|
3070
|
+
file,
|
|
3071
|
+
framework: "sveltekit",
|
|
3072
|
+
url: `/${page[2] ?? ""}`,
|
|
3073
|
+
scope: page[1] ?? "",
|
|
3074
|
+
reachable: { start: open, end: close ?? code.length }
|
|
3075
|
+
};
|
|
3076
|
+
}
|
|
3077
|
+
const nuxt = NUXT_ROUTE.exec(path);
|
|
3078
|
+
if (nuxt && H3_HANDLER.test(noiseMaskedOf(file))) {
|
|
3079
|
+
const rest = nuxt[3].replace(NUXT_METHOD_SUFFIX, "").replace(/(?:^|\/)index$/, "");
|
|
3080
|
+
const url = nuxt[2] === "api" ? `/api/${rest}` : `/${rest}`;
|
|
3081
|
+
return { file, framework: "nuxt", url: url.replace(/\/$/, "") || "/", scope: nuxt[1] ?? "" };
|
|
3082
|
+
}
|
|
3083
|
+
const remix = REMIX_ROUTE.exec(path);
|
|
3084
|
+
if (remix && REMIX_EXPORT.test(noiseMaskedOf(file))) {
|
|
3085
|
+
return { file, framework: "remix", url: remixRouteUrl(remix[2]), scope: remix[1] ?? "" };
|
|
3086
|
+
}
|
|
3087
|
+
const astro = ASTRO_ENDPOINT.exec(path);
|
|
3088
|
+
if (astro && isAstroEndpoint(file)) {
|
|
3089
|
+
const rest = astro[2].replace(/(?:^|\/)index$/, "");
|
|
3090
|
+
return { file, framework: "astro", url: `/${rest}`, scope: astro[1] ?? "" };
|
|
3091
|
+
}
|
|
3092
|
+
return null;
|
|
3093
|
+
}
|
|
3094
|
+
function nextAppRoute(path) {
|
|
3095
|
+
const m = /^(.*?\/)?src\/app\/(.+)$/.exec(path) ?? /^(.*?\/)?app\/(.+)$/.exec(path);
|
|
3096
|
+
if (!m) return null;
|
|
3097
|
+
const segments = m[2].split("/").slice(0, -1);
|
|
3098
|
+
const url = segments.some((segment) => segment.startsWith("_")) ? null : `/${segments.map((segment) => segment.replace(/^%5F/i, "_")).join("/")}`;
|
|
3099
|
+
return { scope: m[1] ?? "", url };
|
|
2314
3100
|
}
|
|
2315
|
-
function
|
|
2316
|
-
const m = /(?:^|\/)
|
|
3101
|
+
function nextRouteUrl(path) {
|
|
3102
|
+
const m = /(?:^|\/)pages\/(api\/.*)$/.exec(path);
|
|
2317
3103
|
if (!m) return `/${path}`;
|
|
2318
3104
|
const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
|
|
2319
3105
|
return `/${url}`;
|
|
2320
3106
|
}
|
|
2321
|
-
|
|
2322
|
-
|
|
3107
|
+
function remixRouteUrl(name) {
|
|
3108
|
+
const LITERAL_DOT = "\0";
|
|
3109
|
+
const segments = name.replace(/\/route$/, "").replace(/\[\.\]/g, LITERAL_DOT).split(/[./]/).filter((segment) => segment !== "" && !segment.startsWith("_")).map((segment) => segment.replace(/_$/, "").replace(/[[\]]/g, "").split(LITERAL_DOT).join("."));
|
|
3110
|
+
if (segments[segments.length - 1] === "index") segments.pop();
|
|
3111
|
+
return `/${segments.join("/")}`;
|
|
3112
|
+
}
|
|
3113
|
+
var AUTH_ENDPOINT_NAMES = /^(?:\/api)?\/auth\/(?:sign[-_]?in|sign[-_]?up|sign[-_]?out|log[-_]?in|log[-_]?out|register|session|verify|confirm|reset(?:[-_]password)?|forgot(?:[-_]password)?|magic[-_]?link|otp)$/;
|
|
3114
|
+
var AUTH_CALLBACK = /^(?:\/api)?\/auth\/callback(?:\/[^/]+)?$/;
|
|
3115
|
+
var PROVIDER_CALLBACK = /^(?:\/api)?\/(?:auth|oauth|login|sign-?in)\/[^/]+\/callback$/;
|
|
3116
|
+
var OAUTH_HANDLER = /\bdefineOAuth\w*EventHandler\s*\(/;
|
|
3117
|
+
function isAuthEndpoint(route) {
|
|
3118
|
+
if (route.action !== void 0) return AUTH_ACTION_NAMES.test(route.action);
|
|
3119
|
+
return AUTH_ENDPOINT_NAMES.test(route.url) || AUTH_CALLBACK.test(route.url) || PROVIDER_CALLBACK.test(route.url) || OAUTH_HANDLER.test(noiseMaskedOf(route.file));
|
|
3120
|
+
}
|
|
3121
|
+
var AUTH_ENFORCING_CALL = /\b(?:NextAuth|require(?:Auth|User|Session|Admin)\w*|withAuth|verifyAuth|ensureAuth|assertAuth(?:enticated)?|verifyIdToken|constructEvent)\s*\(/i;
|
|
3122
|
+
var AUTH_CONDITION = /\b(?:session|token|user|userid|user_id|authorization|bearer|jwt|auth|signature|CRON_SECRET|WEBHOOK_SECRET|REVALIDATE_SECRET|ADMIN_SECRET)\b|\blocals\s*\.\s*user\b|\b(?:getUser|getSession|getServerSession|currentUser|getAuth|isAuthenticated|checkAuth|verifyAuth|ensureAuth|verifyIdToken|timingSafeEqual)\s*\(/i;
|
|
3123
|
+
var ACCESS_CHAIN = String.raw`[\w$]+(?:\??\.[\w$]+)*`;
|
|
3124
|
+
var NEGATED_IDENTITY = new RegExp(`^!\\s*${ACCESS_CHAIN}(?:\\s*\\([^=]*\\))?$`);
|
|
3125
|
+
var IDENTITY_IS_EMPTY = new RegExp(`^${ACCESS_CHAIN}\\s*={2,3}\\s*(?:null|undefined|false)$`);
|
|
3126
|
+
var IDENTITY_MISMATCH = new RegExp(`^${ACCESS_CHAIN}\\s*!={1,2}\\s*(?!(?:null|undefined|false)\\b)${ACCESS_CHAIN}$`);
|
|
3127
|
+
var STOPS_REQUEST = /^(?:(?:return|throw|redirect|notFound)\b|error\s*\(\s*40[13]\b)/;
|
|
3128
|
+
var DENIED_STATUS = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus(?:Code)?\s*[:(=]\s*(?:401|403)\b|\berror\s*\(\s*40[13]\b/i;
|
|
2323
3129
|
function closingDelimiter(source, start, open, close) {
|
|
2324
3130
|
let depth = 0;
|
|
2325
3131
|
for (let i = start; i < source.length; i++) {
|
|
@@ -2343,6 +3149,10 @@ function controlledStatement(source, afterCondition) {
|
|
|
2343
3149
|
const end = semicolon === -1 ? Math.min(source.length, start + 400) : Math.min(semicolon + 1, start + 400);
|
|
2344
3150
|
return source.slice(start, end);
|
|
2345
3151
|
}
|
|
3152
|
+
function rejectsMissingIdentity(term, returnsDeniedStatus) {
|
|
3153
|
+
const rejects = NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term);
|
|
3154
|
+
return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
|
|
3155
|
+
}
|
|
2346
3156
|
function hasConditionalAuthGuard(code) {
|
|
2347
3157
|
const starts = /\bif\s*\(/g;
|
|
2348
3158
|
let match;
|
|
@@ -2356,7 +3166,7 @@ function hasConditionalAuthGuard(code) {
|
|
|
2356
3166
|
const pairs = delimiterPairs(body);
|
|
2357
3167
|
let stopsRequest = false;
|
|
2358
3168
|
for (let i = 0; i < body.length; i++) {
|
|
2359
|
-
if (
|
|
3169
|
+
if (STOPS_REQUEST.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
|
|
2360
3170
|
stopsRequest = true;
|
|
2361
3171
|
break;
|
|
2362
3172
|
}
|
|
@@ -2368,19 +3178,55 @@ function hasConditionalAuthGuard(code) {
|
|
|
2368
3178
|
while (/\s/.test(code[statementStart] ?? "")) statementStart++;
|
|
2369
3179
|
starts.lastIndex = statementStart + statement.length;
|
|
2370
3180
|
if (!stopsRequest) continue;
|
|
2371
|
-
const returnsDeniedStatus =
|
|
2372
|
-
const negative =
|
|
2373
|
-
const term = part.trim();
|
|
2374
|
-
const rejects = /^!\s*[\w$.]+(?:\s*\([^=]*\))?$/.test(term) || /^[\w$.]+\s*={2,3}\s*(?:null|undefined|false)$/.test(term) || /^[\w$.]+\s*!={1,2}\s*(?!(?:null|undefined|false)\b)[\w$.]+$/.test(term);
|
|
2375
|
-
return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
|
|
2376
|
-
});
|
|
3181
|
+
const returnsDeniedStatus = DENIED_STATUS.test(statement);
|
|
3182
|
+
const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus));
|
|
2377
3183
|
if (negative) return true;
|
|
2378
3184
|
}
|
|
2379
3185
|
return false;
|
|
2380
3186
|
}
|
|
3187
|
+
function containsConditionalAuthCheck(code) {
|
|
3188
|
+
const pairs = delimiterPairs(code);
|
|
3189
|
+
for (const match of code.matchAll(/\bif\s*\(/g)) {
|
|
3190
|
+
const open = match.index + match[0].length - 1;
|
|
3191
|
+
const close = pairs.get(open);
|
|
3192
|
+
if (close === void 0) continue;
|
|
3193
|
+
let start = close + 1;
|
|
3194
|
+
while (/\s/.test(code[start] ?? "")) start++;
|
|
3195
|
+
let bodyStart = start;
|
|
3196
|
+
let bodyEnd;
|
|
3197
|
+
if (code[start] === "{") {
|
|
3198
|
+
bodyStart = start + 1;
|
|
3199
|
+
bodyEnd = pairs.get(start) ?? Math.min(code.length, start + 600);
|
|
3200
|
+
} else {
|
|
3201
|
+
const semicolon = code.slice(start, start + 400).indexOf(";");
|
|
3202
|
+
bodyEnd = semicolon === -1 ? Math.min(code.length, start + 400) : start + semicolon + 1;
|
|
3203
|
+
}
|
|
3204
|
+
let stop = -1;
|
|
3205
|
+
for (let i = bodyStart; i < bodyEnd; i++) {
|
|
3206
|
+
const window = code.slice(i, i + 16);
|
|
3207
|
+
if (STOPS_REQUEST.test(window) && (i === 0 || !/[\w$]/.test(code[i - 1]))) {
|
|
3208
|
+
stop = i;
|
|
3209
|
+
break;
|
|
3210
|
+
}
|
|
3211
|
+
if (/^if\s*\(/.test(window)) break;
|
|
3212
|
+
const end = pairs.get(i);
|
|
3213
|
+
if (end !== void 0) i = end;
|
|
3214
|
+
}
|
|
3215
|
+
if (stop === -1) continue;
|
|
3216
|
+
const returnsDeniedStatus = DENIED_STATUS.test(code.slice(stop, stop + 400));
|
|
3217
|
+
const condition = code.slice(open + 1, close);
|
|
3218
|
+
if (condition.split(/\|\||&&/).some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus))) return true;
|
|
3219
|
+
}
|
|
3220
|
+
return false;
|
|
3221
|
+
}
|
|
3222
|
+
var authSignalCache = /* @__PURE__ */ new WeakMap();
|
|
2381
3223
|
function hasAuthSignal(file) {
|
|
3224
|
+
const cached = authSignalCache.get(file);
|
|
3225
|
+
if (cached !== void 0) return cached;
|
|
2382
3226
|
const code = noiseMaskedOf(file);
|
|
2383
|
-
|
|
3227
|
+
const found = AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
|
|
3228
|
+
authSignalCache.set(file, found);
|
|
3229
|
+
return found;
|
|
2384
3230
|
}
|
|
2385
3231
|
function delimiterPairs(code) {
|
|
2386
3232
|
const pairs = /* @__PURE__ */ new Map();
|
|
@@ -2417,7 +3263,7 @@ function functionBodies(code, pairs) {
|
|
|
2417
3263
|
}
|
|
2418
3264
|
return bodies;
|
|
2419
3265
|
}
|
|
2420
|
-
function
|
|
3266
|
+
function statementEnd2(code, start, limit, pairs) {
|
|
2421
3267
|
if (code[start] === "{") return (pairs.get(start) ?? limit) + 1;
|
|
2422
3268
|
for (let i = start; i < limit; i++) {
|
|
2423
3269
|
if (code[i] === ";" || code[i] === "\n") return i + 1;
|
|
@@ -2426,7 +3272,36 @@ function statementEnd(code, start, limit, pairs) {
|
|
|
2426
3272
|
}
|
|
2427
3273
|
return limit;
|
|
2428
3274
|
}
|
|
2429
|
-
|
|
3275
|
+
var unguardedCache = /* @__PURE__ */ new WeakMap();
|
|
3276
|
+
function unguardedOpsOf(file) {
|
|
3277
|
+
let hit = unguardedCache.get(file);
|
|
3278
|
+
if (hit === void 0) {
|
|
3279
|
+
hit = unguardedOperations(file, findDataOps(file));
|
|
3280
|
+
unguardedCache.set(file, hit);
|
|
3281
|
+
}
|
|
3282
|
+
return hit;
|
|
3283
|
+
}
|
|
3284
|
+
var lineStartsCache = /* @__PURE__ */ new WeakMap();
|
|
3285
|
+
function lineStartsCached(file) {
|
|
3286
|
+
let hit = lineStartsCache.get(file);
|
|
3287
|
+
if (hit === void 0) {
|
|
3288
|
+
hit = lineStartsOf(file.content);
|
|
3289
|
+
lineStartsCache.set(file, hit);
|
|
3290
|
+
}
|
|
3291
|
+
return hit;
|
|
3292
|
+
}
|
|
3293
|
+
function cachedByFile(check) {
|
|
3294
|
+
const cache2 = /* @__PURE__ */ new WeakMap();
|
|
3295
|
+
return (file) => {
|
|
3296
|
+
let hit = cache2.get(file);
|
|
3297
|
+
if (hit === void 0) {
|
|
3298
|
+
hit = check(file);
|
|
3299
|
+
cache2.set(file, hit);
|
|
3300
|
+
}
|
|
3301
|
+
return hit;
|
|
3302
|
+
};
|
|
3303
|
+
}
|
|
3304
|
+
function unguardedOperations(file, ops, extra) {
|
|
2430
3305
|
if (ops.length === 0) return ops;
|
|
2431
3306
|
const code = noiseMaskedOf(file);
|
|
2432
3307
|
const pairs = delimiterPairs(code);
|
|
@@ -2434,12 +3309,18 @@ function unguardedOperations(file, ops) {
|
|
|
2434
3309
|
const declarations = new Map(bodies.map((body) => [body.declaration, body]));
|
|
2435
3310
|
const functionStarts = new Set(bodies.map((body) => body.start));
|
|
2436
3311
|
const guardEnds = /* @__PURE__ */ new Map();
|
|
3312
|
+
const guardNames = /* @__PURE__ */ new Map();
|
|
3313
|
+
const extraCalls = extra?.guards.size ? new RegExp(`^(${namePattern(extra.guards)})\\s*\\(`) : null;
|
|
2437
3314
|
const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
|
|
2438
3315
|
const active = [];
|
|
2439
3316
|
let blockIndex = 0;
|
|
2440
|
-
const
|
|
3317
|
+
const wrapperPattern = extra?.wrappers.size ? new RegExp(`(?<![\\w$.])(${namePattern(extra.wrappers)})\\s*\\(`, "g") : null;
|
|
3318
|
+
const wrappers = [
|
|
3319
|
+
...code.matchAll(/\b(withAuth|NextAuth)\s*\(/g),
|
|
3320
|
+
...wrapperPattern ? code.matchAll(wrapperPattern) : []
|
|
3321
|
+
].map((match) => {
|
|
2441
3322
|
const open = match.index + match[0].length - 1;
|
|
2442
|
-
return { start: open, end: pairs.get(open) ?? open };
|
|
3323
|
+
return { start: open, end: pairs.get(open) ?? open, name: match[1] };
|
|
2443
3324
|
});
|
|
2444
3325
|
const guardEnd = (owner) => {
|
|
2445
3326
|
for (let i = owner.start + 1; i < owner.end; i++) {
|
|
@@ -2449,7 +3330,7 @@ function unguardedOperations(file, ops) {
|
|
|
2449
3330
|
continue;
|
|
2450
3331
|
}
|
|
2451
3332
|
if (code.startsWith("=>", i)) {
|
|
2452
|
-
i =
|
|
3333
|
+
i = statementEnd2(code, i + 2, owner.end, pairs) - 1;
|
|
2453
3334
|
continue;
|
|
2454
3335
|
}
|
|
2455
3336
|
if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
|
|
@@ -2460,12 +3341,13 @@ function unguardedOperations(file, ops) {
|
|
|
2460
3341
|
if (close2 === void 0) return Infinity;
|
|
2461
3342
|
let start = close2 + 1;
|
|
2462
3343
|
while (/\s/.test(code[start] ?? "")) start++;
|
|
2463
|
-
const end =
|
|
3344
|
+
const end = statementEnd2(code, start, owner.end, pairs);
|
|
2464
3345
|
if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
|
|
2465
3346
|
i = Math.min(end, owner.end) - 1;
|
|
2466
3347
|
continue;
|
|
2467
3348
|
}
|
|
2468
|
-
const
|
|
3349
|
+
const indirectCall = i > 0 && /[\w$.]/.test(code[i - 1]) ? null : extraCalls?.exec(code.slice(i, i + 100));
|
|
3350
|
+
const call = indirectCall ?? AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
|
|
2469
3351
|
if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
|
|
2470
3352
|
const prefixStart = Math.max(
|
|
2471
3353
|
owner.start + 1,
|
|
@@ -2478,7 +3360,10 @@ function unguardedOperations(file, ops) {
|
|
|
2478
3360
|
const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
|
|
2479
3361
|
if (!awaited && !synchronous) continue;
|
|
2480
3362
|
const close2 = pairs.get(i + call[0].length - 1);
|
|
2481
|
-
if (close2 !== void 0 && close2 < owner.end)
|
|
3363
|
+
if (close2 !== void 0 && close2 < owner.end) {
|
|
3364
|
+
if (indirectCall) guardNames.set(owner.start, indirectCall[1]);
|
|
3365
|
+
return close2 + 1;
|
|
3366
|
+
}
|
|
2482
3367
|
}
|
|
2483
3368
|
const close = pairs.get(i);
|
|
2484
3369
|
if (close !== void 0) i = close;
|
|
@@ -2492,7 +3377,11 @@ function unguardedOperations(file, ops) {
|
|
|
2492
3377
|
active.push(block);
|
|
2493
3378
|
}
|
|
2494
3379
|
while (active.length && active[active.length - 1].end < op.index) active.pop();
|
|
2495
|
-
|
|
3380
|
+
const wrapper = wrappers.find((w) => w.start < op.index && w.end > op.index);
|
|
3381
|
+
if (wrapper) {
|
|
3382
|
+
if (extra?.wrappers.has(wrapper.name)) extra.onGuard(op.index, wrapper.name);
|
|
3383
|
+
return false;
|
|
3384
|
+
}
|
|
2496
3385
|
let ownerIndex = active.length - 1;
|
|
2497
3386
|
while (ownerIndex >= 0 && !functionStarts.has(active[ownerIndex].start)) ownerIndex--;
|
|
2498
3387
|
if (ownerIndex < 0) return true;
|
|
@@ -2503,12 +3392,31 @@ function unguardedOperations(file, ops) {
|
|
|
2503
3392
|
end = guardEnd(block);
|
|
2504
3393
|
guardEnds.set(block.start, end);
|
|
2505
3394
|
}
|
|
2506
|
-
if (end <= op.index)
|
|
3395
|
+
if (end <= op.index) {
|
|
3396
|
+
const name = guardNames.get(block.start);
|
|
3397
|
+
if (name) extra?.onGuard(op.index, name);
|
|
3398
|
+
return false;
|
|
3399
|
+
}
|
|
2507
3400
|
}
|
|
2508
3401
|
return true;
|
|
2509
3402
|
});
|
|
2510
3403
|
}
|
|
2511
|
-
var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}
|
|
3404
|
+
var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>\s*)?\(/;
|
|
3405
|
+
var constructorCache = /* @__PURE__ */ new WeakMap();
|
|
3406
|
+
function clientConstructorAt(file) {
|
|
3407
|
+
const cached = constructorCache.get(file);
|
|
3408
|
+
if (cached !== void 0) return cached ?? void 0;
|
|
3409
|
+
const code = noiseMaskedOf(file);
|
|
3410
|
+
const direct = CLIENT_CONSTRUCTOR.exec(code)?.index;
|
|
3411
|
+
if (direct !== void 0) {
|
|
3412
|
+
constructorCache.set(file, direct);
|
|
3413
|
+
return direct;
|
|
3414
|
+
}
|
|
3415
|
+
const aliases = bindingsOf(file).imports.filter((binding) => /^@supabase\/(?:supabase-js|ssr)$/.test(binding.spec ?? "") && ["createClient", "createServerClient"].includes(binding.imported)).map((binding) => binding.local);
|
|
3416
|
+
const found = aliases.length === 0 ? void 0 : new RegExp(`(?<![\\w$.])(?:${namePattern(aliases)})\\s*(?:<[^()]{0,200}>\\s*)?\\(`).exec(code)?.index;
|
|
3417
|
+
constructorCache.set(file, found ?? null);
|
|
3418
|
+
return found;
|
|
3419
|
+
}
|
|
2512
3420
|
var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
|
|
2513
3421
|
var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
|
|
2514
3422
|
var ENV_BRACKET_ACCESS = /(?:process\.env|import\.meta\.env)\s*\[\s*['"]([^'"]+)['"]\s*\]/g;
|
|
@@ -2521,9 +3429,13 @@ function referencesServiceRole(code, source) {
|
|
|
2521
3429
|
}
|
|
2522
3430
|
return false;
|
|
2523
3431
|
}
|
|
2524
|
-
|
|
3432
|
+
var NUXT_SUPABASE_ADMIN = /\bserverSupabaseServiceRole\s*(?:<[^()]{0,200}>\s*)?\(/;
|
|
3433
|
+
var NUXT_SUPABASE_SESSION = /\bserverSupabaseClient\s*(?:<[^()]{0,200}>\s*)?\(/;
|
|
3434
|
+
var buildsAdminClient = cachedByFile(buildsAdminClientUncached);
|
|
3435
|
+
function buildsAdminClientUncached(file) {
|
|
2525
3436
|
const code = noiseMaskedOf(file);
|
|
2526
|
-
if (
|
|
3437
|
+
if (NUXT_SUPABASE_ADMIN.test(code)) return true;
|
|
3438
|
+
if (clientConstructorAt(file) === void 0) return false;
|
|
2527
3439
|
const source = commentsMaskedOf(file);
|
|
2528
3440
|
if (referencesServiceRole(code, source)) return true;
|
|
2529
3441
|
SERVICE_ROLE_LITERAL.lastIndex = 0;
|
|
@@ -2533,9 +3445,12 @@ function buildsAdminClient(file) {
|
|
|
2533
3445
|
}
|
|
2534
3446
|
return false;
|
|
2535
3447
|
}
|
|
2536
|
-
|
|
3448
|
+
var buildsSessionClient = cachedByFile(buildsSessionClientUncached);
|
|
3449
|
+
function buildsSessionClientUncached(file) {
|
|
2537
3450
|
const code = noiseMaskedOf(file);
|
|
2538
|
-
if (
|
|
3451
|
+
if (NUXT_SUPABASE_ADMIN.test(code)) return false;
|
|
3452
|
+
if (NUXT_SUPABASE_SESSION.test(code)) return true;
|
|
3453
|
+
if (clientConstructorAt(file) === void 0) return false;
|
|
2539
3454
|
if (referencesServiceRole(code, commentsMaskedOf(file))) return false;
|
|
2540
3455
|
return /\bcookies\b/.test(code);
|
|
2541
3456
|
}
|
|
@@ -2564,19 +3479,34 @@ function normalizeSpec(spec, fromPath) {
|
|
|
2564
3479
|
alias: false
|
|
2565
3480
|
};
|
|
2566
3481
|
}
|
|
2567
|
-
const
|
|
3482
|
+
const lib = /^\$lib\/(.+)$/.exec(spec);
|
|
3483
|
+
if (lib) return { key: moduleKey(`src/lib/${lib[1]}`), alias: true };
|
|
3484
|
+
const alias = /^(?:[@~#]|~~|@@)\/(.+)$/.exec(spec);
|
|
2568
3485
|
return alias ? { key: moduleKey(alias[1]), alias: true } : null;
|
|
2569
3486
|
}
|
|
2570
3487
|
function usesAdminClient(route, adminModules, allFiles) {
|
|
2571
|
-
return buildsAdminClient(route) || importsAnyOf(route, adminModules, allFiles);
|
|
3488
|
+
return buildsAdminClient(route.file) || importsAnyOf(route, adminModules, allFiles) || autoImportsAnyOf(route, adminModules);
|
|
2572
3489
|
}
|
|
2573
3490
|
function usesSessionClient(route, sessionModules, allFiles) {
|
|
2574
|
-
|
|
3491
|
+
if (route.framework === "sveltekit" && /\blocals\s*\.\s*supabase\b/.test(noiseMaskedOf(route.file))) return true;
|
|
3492
|
+
return buildsSessionClient(route.file) || importsAnyOf(route, sessionModules, allFiles) || autoImportsAnyOf(route, sessionModules);
|
|
3493
|
+
}
|
|
3494
|
+
function autoImportsAnyOf(route, modules) {
|
|
3495
|
+
return autoImportedModule(route, modules) !== void 0;
|
|
3496
|
+
}
|
|
3497
|
+
function autoImportedModule(route, modules) {
|
|
3498
|
+
if (route.framework !== "nuxt") return void 0;
|
|
3499
|
+
const utils = `${route.scope}server/utils/`;
|
|
3500
|
+
const code = noiseMaskedOf(route.file);
|
|
3501
|
+
return modules.find((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
|
|
2575
3502
|
}
|
|
2576
|
-
function
|
|
2577
|
-
|
|
3503
|
+
function exportedNames(file) {
|
|
3504
|
+
const names = noiseMaskedOf(file).matchAll(
|
|
3505
|
+
/\bexport\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+|var\s+)([A-Za-z_$][\w$]*)/g
|
|
3506
|
+
);
|
|
3507
|
+
return [...names].map((m) => m[1]);
|
|
2578
3508
|
}
|
|
2579
|
-
function
|
|
3509
|
+
function importedEdges(file, allFiles, aliasScope) {
|
|
2580
3510
|
const found = [];
|
|
2581
3511
|
const source = commentsMaskedOf(file);
|
|
2582
3512
|
const code = noiseMaskedOf(file);
|
|
@@ -2587,40 +3517,200 @@ function importedModules(file, allFiles, aliasScope) {
|
|
|
2587
3517
|
const target = normalizeSpec(m[1], file.path);
|
|
2588
3518
|
if (!target) continue;
|
|
2589
3519
|
const index = moduleIndexOf(allFiles);
|
|
3520
|
+
const append = (targets) => {
|
|
3521
|
+
for (const to of targets) found.push({ from: file, to, line: lineNumberAt(lineStartsCached(file), m.index) });
|
|
3522
|
+
};
|
|
2590
3523
|
if (!target.alias) {
|
|
2591
|
-
|
|
3524
|
+
append(index.get(target.key) ?? []);
|
|
2592
3525
|
} else {
|
|
2593
3526
|
const prefix = aliasScope;
|
|
2594
3527
|
for (const key of [
|
|
2595
3528
|
moduleKey(`${prefix}${target.key}`),
|
|
2596
|
-
moduleKey(`${prefix}src/${target.key}`)
|
|
3529
|
+
moduleKey(`${prefix}src/${target.key}`),
|
|
3530
|
+
moduleKey(`${prefix}app/${target.key}`)
|
|
2597
3531
|
]) {
|
|
2598
|
-
|
|
3532
|
+
append(index.get(key) ?? []);
|
|
2599
3533
|
}
|
|
2600
3534
|
}
|
|
2601
3535
|
}
|
|
2602
3536
|
return found;
|
|
2603
3537
|
}
|
|
3538
|
+
var importsCache = /* @__PURE__ */ new WeakMap();
|
|
2604
3539
|
function importsAnyOf(route, modules, allFiles) {
|
|
2605
|
-
|
|
3540
|
+
return importChain(route, modules, allFiles) !== null;
|
|
3541
|
+
}
|
|
3542
|
+
function importChain(route, modules, allFiles) {
|
|
3543
|
+
if (modules.length === 0) return null;
|
|
3544
|
+
const byFile = importsCache.get(modules) ?? /* @__PURE__ */ new WeakMap();
|
|
3545
|
+
importsCache.set(modules, byFile);
|
|
3546
|
+
const byScope = byFile.get(route.file) ?? /* @__PURE__ */ new Map();
|
|
3547
|
+
byFile.set(route.file, byScope);
|
|
3548
|
+
const cached = byScope.get(route.scope);
|
|
3549
|
+
if (cached !== void 0) return cached;
|
|
3550
|
+
const result = importChainUncached(route, modules, allFiles);
|
|
3551
|
+
byScope.set(route.scope, result);
|
|
3552
|
+
return result;
|
|
3553
|
+
}
|
|
3554
|
+
function importChainUncached(route, modules, allFiles) {
|
|
2606
3555
|
const targetPaths = new Set(modules.map((file) => file.path));
|
|
2607
|
-
const visited = /* @__PURE__ */ new Set();
|
|
2608
|
-
const
|
|
2609
|
-
const queue =
|
|
2610
|
-
|
|
2611
|
-
const
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
3556
|
+
const visited = /* @__PURE__ */ new Set([route.file.path]);
|
|
3557
|
+
const parents = /* @__PURE__ */ new Map();
|
|
3558
|
+
const queue = [route.file];
|
|
3559
|
+
for (let i = 0; i < queue.length; i++) {
|
|
3560
|
+
for (const edge of importedEdges(queue[i], allFiles, route.scope)) {
|
|
3561
|
+
if (visited.has(edge.to.path)) continue;
|
|
3562
|
+
visited.add(edge.to.path);
|
|
3563
|
+
parents.set(edge.to.path, edge);
|
|
3564
|
+
if (targetPaths.has(edge.to.path)) {
|
|
3565
|
+
const chain = [];
|
|
3566
|
+
let current = edge;
|
|
3567
|
+
while (current) {
|
|
3568
|
+
chain.push(current);
|
|
3569
|
+
current = parents.get(current.from.path);
|
|
3570
|
+
}
|
|
3571
|
+
return chain.reverse();
|
|
3572
|
+
}
|
|
3573
|
+
queue.push(edge.to);
|
|
3574
|
+
}
|
|
2616
3575
|
}
|
|
2617
|
-
return
|
|
3576
|
+
return null;
|
|
3577
|
+
}
|
|
3578
|
+
function adminEvidence(route, modules, allFiles, line) {
|
|
3579
|
+
const chain = buildsAdminClient(route.file) ? [] : importChain(route, modules, allFiles);
|
|
3580
|
+
const evidence = [{
|
|
3581
|
+
kind: "operation",
|
|
3582
|
+
file: route.file.path,
|
|
3583
|
+
line,
|
|
3584
|
+
description: "Data operation selected for authentication review."
|
|
3585
|
+
}];
|
|
3586
|
+
for (const edge of (chain ?? []).slice(0, 22)) evidence.push({
|
|
3587
|
+
kind: "import",
|
|
3588
|
+
file: edge.from.path,
|
|
3589
|
+
line: edge.line,
|
|
3590
|
+
description: `Statically imports ${edge.to.path}.`
|
|
3591
|
+
});
|
|
3592
|
+
const target = chain?.at(-1)?.to ?? (buildsAdminClient(route.file) ? route.file : autoImportedModule(route, modules));
|
|
3593
|
+
if (target) {
|
|
3594
|
+
const at = clientConstructorAt(target) ?? NUXT_SUPABASE_ADMIN.exec(noiseMaskedOf(target))?.index;
|
|
3595
|
+
evidence.push({
|
|
3596
|
+
kind: "admin-client",
|
|
3597
|
+
file: target.path,
|
|
3598
|
+
line: at === void 0 ? null : lineNumberAt(lineStartsCached(target), at),
|
|
3599
|
+
description: "Admin-client construction detected in this module; imports are not proof of runtime data flow."
|
|
3600
|
+
});
|
|
3601
|
+
}
|
|
3602
|
+
return { evidence, evidenceTruncated: (chain?.length ?? 0) > 22 };
|
|
3603
|
+
}
|
|
3604
|
+
var definitionsCache = /* @__PURE__ */ new WeakMap();
|
|
3605
|
+
function guardDefinitions(file) {
|
|
3606
|
+
const cached = definitionsCache.get(file);
|
|
3607
|
+
if (cached) return cached;
|
|
3608
|
+
const result = { locals: /* @__PURE__ */ new Map(), exports: /* @__PURE__ */ new Map() };
|
|
3609
|
+
const code = noiseMaskedOf(file);
|
|
3610
|
+
const pairs = delimiterPairs(code);
|
|
3611
|
+
const openers = new Map([...pairs].map(([open, close]) => [close, open]));
|
|
3612
|
+
const bodies = functionBodies(code, pairs);
|
|
3613
|
+
const byDeclaration = new Map(bodies.map((body) => [body.declaration, body]));
|
|
3614
|
+
const candidates = [];
|
|
3615
|
+
let until = -1;
|
|
3616
|
+
for (const body of bodies) {
|
|
3617
|
+
if (body.start < until) continue;
|
|
3618
|
+
until = body.end;
|
|
3619
|
+
const declaration = declarationOf(code, body, openers);
|
|
3620
|
+
if (!declaration) continue;
|
|
3621
|
+
const returned = /\breturn\s+(?:async\s+)?(?:function\b|(?:\([^)]{0,200}\)|[A-Za-z_$][\w$]*)\s*=>)/.exec(code.slice(body.start + 1, body.end));
|
|
3622
|
+
const nestedAt = returned ? body.start + 1 + returned.index + (returned[0].includes("=>") ? returned[0].lastIndexOf("=>") : returned[0].lastIndexOf("function")) : -1;
|
|
3623
|
+
const target = byDeclaration.get(nestedAt) ?? body;
|
|
3624
|
+
let at = target.end;
|
|
3625
|
+
for (let i = target.start + 1; i < target.end; i++) {
|
|
3626
|
+
if (code.startsWith("return", i) && !/[\w$]/.test(code[i - 1] ?? "") && !/[\w$]/.test(code[i + 6] ?? "")) {
|
|
3627
|
+
at = i;
|
|
3628
|
+
break;
|
|
3629
|
+
}
|
|
3630
|
+
const close = pairs.get(i);
|
|
3631
|
+
if (close !== void 0) i = close;
|
|
3632
|
+
}
|
|
3633
|
+
const definition = {
|
|
3634
|
+
file,
|
|
3635
|
+
line: lineNumberAt(lineStartsCached(file), body.declaration),
|
|
3636
|
+
wrapper: target !== body
|
|
3637
|
+
};
|
|
3638
|
+
const exported = !declaration.exported ? null : /\bexport\s+default\s+(?:async\s+)?$/.test(code.slice(Math.max(0, body.declaration - 80), body.declaration)) ? "default" : declaration.name;
|
|
3639
|
+
candidates.push({ name: declaration.name, exported, definition, at });
|
|
3640
|
+
}
|
|
3641
|
+
const unguarded = new Set(unguardedOperations(
|
|
3642
|
+
file,
|
|
3643
|
+
candidates.map((candidate) => ({ index: candidate.at, writes: false })).sort((a, b) => a.index - b.index)
|
|
3644
|
+
).map((hit) => hit.index));
|
|
3645
|
+
for (const candidate of candidates) {
|
|
3646
|
+
if (unguarded.has(candidate.at)) continue;
|
|
3647
|
+
result.locals.set(candidate.name, candidate.definition);
|
|
3648
|
+
if (candidate.exported) result.exports.set(candidate.exported, candidate.definition);
|
|
3649
|
+
}
|
|
3650
|
+
definitionsCache.set(file, result);
|
|
3651
|
+
return result;
|
|
3652
|
+
}
|
|
3653
|
+
function bindingModule(spec, file, allFiles, scope) {
|
|
3654
|
+
const target = normalizeSpec(spec, file.path);
|
|
3655
|
+
if (!target) return null;
|
|
3656
|
+
const keys = target.alias ? [`${scope}${target.key}`, `${scope}src/${target.key}`, `${scope}app/${target.key}`] : [target.key];
|
|
3657
|
+
const found = new Set(keys.flatMap((key) => moduleIndexOf(allFiles).get(moduleKey(key)) ?? []));
|
|
3658
|
+
return found.size === 1 ? [...found][0] : null;
|
|
3659
|
+
}
|
|
3660
|
+
function exportedGuard(file, name, allFiles, scope, depth = 0, seen = /* @__PURE__ */ new Set()) {
|
|
3661
|
+
const key = `${file.path}\0${name}`;
|
|
3662
|
+
if (depth >= 8 || seen.has(key)) return null;
|
|
3663
|
+
seen.add(key);
|
|
3664
|
+
const definitions = guardDefinitions(file);
|
|
3665
|
+
const direct = definitions.exports.get(name);
|
|
3666
|
+
if (direct) return direct;
|
|
3667
|
+
const bindings = bindingsOf(file);
|
|
3668
|
+
const exported = bindings.exports.find((binding) => binding.local === name);
|
|
3669
|
+
const follow = (spec, imported) => {
|
|
3670
|
+
const target = bindingModule(spec, file, allFiles, scope);
|
|
3671
|
+
return target ? exportedGuard(target, imported, allFiles, scope, depth + 1, seen) : null;
|
|
3672
|
+
};
|
|
3673
|
+
if (exported) {
|
|
3674
|
+
if (exported.spec) return follow(exported.spec, exported.imported);
|
|
3675
|
+
const local = definitions.locals.get(exported.imported);
|
|
3676
|
+
if (local) return local;
|
|
3677
|
+
const imported = bindings.imports.find((binding) => binding.local === exported.imported);
|
|
3678
|
+
if (imported?.spec) return follow(imported.spec, imported.imported);
|
|
3679
|
+
}
|
|
3680
|
+
if (name !== "default") for (const spec of bindings.stars) {
|
|
3681
|
+
const found = follow(spec, name);
|
|
3682
|
+
if (found) return found;
|
|
3683
|
+
}
|
|
3684
|
+
return null;
|
|
3685
|
+
}
|
|
3686
|
+
function indirectGuardOperations(route, allFiles) {
|
|
3687
|
+
const names = /* @__PURE__ */ new Map();
|
|
3688
|
+
const code = noiseMaskedOf(route.file);
|
|
3689
|
+
for (const binding of bindingsOf(route.file).imports) {
|
|
3690
|
+
if (!binding.spec) continue;
|
|
3691
|
+
const name = namePattern([binding.local]);
|
|
3692
|
+
if (new RegExp(`\\b(?:const|let|var|function|class)\\s+${name}(?![\\w$])|[({,]\\s*${name}\\s*[,}):=]|(?<![\\w$])${name}\\s*=>`).test(code)) continue;
|
|
3693
|
+
const target = bindingModule(binding.spec, route.file, allFiles, route.scope);
|
|
3694
|
+
const definition = target ? exportedGuard(target, binding.imported, allFiles, route.scope) : null;
|
|
3695
|
+
if (definition) names.set(binding.local, definition);
|
|
3696
|
+
}
|
|
3697
|
+
const protectedOps = /* @__PURE__ */ new Map();
|
|
3698
|
+
if (names.size === 0) return protectedOps;
|
|
3699
|
+
unguardedOperations(route.file, unguardedOpsOf(route.file), {
|
|
3700
|
+
guards: new Set([...names].filter(([, definition]) => !definition.wrapper).map(([name]) => name)),
|
|
3701
|
+
wrappers: new Set([...names].filter(([, definition]) => definition.wrapper).map(([name]) => name)),
|
|
3702
|
+
onGuard: (index, name) => {
|
|
3703
|
+
const definition = names.get(name);
|
|
3704
|
+
if (definition) protectedOps.set(index, definition);
|
|
3705
|
+
}
|
|
3706
|
+
});
|
|
3707
|
+
return protectedOps;
|
|
2618
3708
|
}
|
|
2619
3709
|
var SUPABASE_TABLE = /\.from\(\s*['"`][^'"`]+['"`]\s*\)\s*\.?\s*(\w+)?/g;
|
|
2620
3710
|
var SUPABASE_ADMIN_API = /\bauth\s*\.\s*admin\s*\.\s*(\w+)\s*\(/g;
|
|
2621
3711
|
var SUPABASE_WRITES = /* @__PURE__ */ new Set(["insert", "update", "upsert", "delete"]);
|
|
2622
|
-
var PRISMA_OP = /\
|
|
2623
|
-
var PRISMA_RAW = /\
|
|
3712
|
+
var PRISMA_OP = /\b(?:prisma|db)\s*\.\s*\$?(\w+)\s*\.\s*(findMany|findFirst|findUnique|findUniqueOrThrow|create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
|
|
3713
|
+
var PRISMA_RAW = /\b(?:prisma|db)\s*\.\s*\$(queryRaw|executeRaw)/g;
|
|
2624
3714
|
var PRISMA_WRITES = /^(?:create|createMany|update|updateMany|upsert|delete|deleteMany)$/;
|
|
2625
3715
|
var DRIZZLE_OP = /\bdb\s*\.\s*(select|insert|update|delete)\s*\(/g;
|
|
2626
3716
|
var MONGO_OP = /\.(?:deleteMany|deleteOne|updateMany|updateOne|insertMany|insertOne|findOneAndDelete|findOneAndUpdate)\s*\(/g;
|
|
@@ -2663,20 +3753,29 @@ function findDataOps(file) {
|
|
|
2663
3753
|
}
|
|
2664
3754
|
return hits.sort((a, b) => a.index - b.index);
|
|
2665
3755
|
}
|
|
2666
|
-
var
|
|
3756
|
+
var NEXT_MIDDLEWARE_FILE = /(?:^|\/)(?:src\/)?(?:middleware|proxy)\.[mc]?[jt]s$/;
|
|
3757
|
+
var ASTRO_MIDDLEWARE_FILE = /(?:^|\/)src\/middleware(?:\/index)?\.[mc]?[jt]s$/;
|
|
3758
|
+
function isMiddlewareFile(path, framework) {
|
|
3759
|
+
return framework === "astro" ? ASTRO_MIDDLEWARE_FILE.test(path) : NEXT_MIDDLEWARE_FILE.test(path);
|
|
3760
|
+
}
|
|
3761
|
+
function isAnyMiddlewareFile(path) {
|
|
3762
|
+
return NEXT_MIDDLEWARE_FILE.test(path) || ASTRO_MIDDLEWARE_FILE.test(path);
|
|
3763
|
+
}
|
|
2667
3764
|
function middlewareScopeOf(path) {
|
|
2668
|
-
return path.replace(/(?:src\/)?middleware\.[mc]?[jt]s$/, "");
|
|
3765
|
+
return path.replace(/(?:src\/)?(?:middleware(?:\/index)?|proxy)\.[mc]?[jt]s$/, "");
|
|
2669
3766
|
}
|
|
2670
|
-
function
|
|
2671
|
-
let best =
|
|
3767
|
+
function middlewaresFor(ctx, routePath, framework) {
|
|
3768
|
+
let best = [];
|
|
2672
3769
|
let bestDepth = -1;
|
|
2673
3770
|
for (const file of ctx.files) {
|
|
2674
|
-
if (!
|
|
3771
|
+
if (!isMiddlewareFile(file.path, framework)) continue;
|
|
2675
3772
|
const scope = middlewareScopeOf(file.path);
|
|
2676
3773
|
if (!routePath.startsWith(scope)) continue;
|
|
2677
3774
|
if (scope.length > bestDepth) {
|
|
2678
|
-
best = file;
|
|
3775
|
+
best = [file];
|
|
2679
3776
|
bestDepth = scope.length;
|
|
3777
|
+
} else if (scope.length === bestDepth) {
|
|
3778
|
+
best.push(file);
|
|
2680
3779
|
}
|
|
2681
3780
|
}
|
|
2682
3781
|
return best;
|
|
@@ -2710,14 +3809,14 @@ function sliceQuoted(text) {
|
|
|
2710
3809
|
}
|
|
2711
3810
|
return null;
|
|
2712
3811
|
}
|
|
2713
|
-
function
|
|
2714
|
-
const
|
|
3812
|
+
function topLevelMemberStarts(text, container) {
|
|
3813
|
+
const starts = [1];
|
|
2715
3814
|
let quote = null;
|
|
2716
3815
|
let braces = 0;
|
|
2717
3816
|
let brackets = 0;
|
|
2718
3817
|
let parentheses = 0;
|
|
2719
|
-
for (let i = 0; i <
|
|
2720
|
-
const ch =
|
|
3818
|
+
for (let i = 0; i < text.length; i++) {
|
|
3819
|
+
const ch = text[i];
|
|
2721
3820
|
if (quote !== null) {
|
|
2722
3821
|
if (ch === "\\") i++;
|
|
2723
3822
|
else if (ch === quote) quote = null;
|
|
@@ -2730,18 +3829,41 @@ function topLevelMatcherValueStart(objectText) {
|
|
|
2730
3829
|
else if (ch === "]") brackets--;
|
|
2731
3830
|
else if (ch === "(") parentheses++;
|
|
2732
3831
|
else if (ch === ")") parentheses--;
|
|
2733
|
-
else if (ch === "," && braces === 1 && brackets === 0 &&
|
|
2734
|
-
|
|
3832
|
+
else if (ch === "," && parentheses === 0 && (container === "{" ? braces === 1 && brackets === 0 : brackets === 1 && braces === 0)) {
|
|
3833
|
+
starts.push(i + 1);
|
|
2735
3834
|
}
|
|
2736
3835
|
}
|
|
2737
|
-
|
|
3836
|
+
return starts;
|
|
3837
|
+
}
|
|
3838
|
+
function topLevelPropertyStart(objectText, name) {
|
|
3839
|
+
const property = new RegExp(`^(?:${name}|['"]${name}['"])\\s*:\\s*`);
|
|
3840
|
+
for (const start of topLevelMemberStarts(objectText, "{")) {
|
|
2738
3841
|
let cursor = start;
|
|
2739
3842
|
while (/\s/.test(objectText[cursor] ?? "")) cursor++;
|
|
2740
|
-
const
|
|
2741
|
-
if (
|
|
3843
|
+
const match = property.exec(objectText.slice(cursor));
|
|
3844
|
+
if (match) return cursor + match[0].length;
|
|
2742
3845
|
}
|
|
2743
3846
|
return null;
|
|
2744
3847
|
}
|
|
3848
|
+
function quotedValue(text) {
|
|
3849
|
+
const quoted = sliceQuoted(text);
|
|
3850
|
+
return quoted === null || quoted.length < 3 ? null : quoted.slice(1, -1);
|
|
3851
|
+
}
|
|
3852
|
+
function matcherEntry(text) {
|
|
3853
|
+
const trimmed = text.trimStart();
|
|
3854
|
+
if (trimmed.startsWith("{")) {
|
|
3855
|
+
const object = sliceDelimited(trimmed, "{", "}");
|
|
3856
|
+
if (object === null) return null;
|
|
3857
|
+
if (topLevelPropertyStart(object, "has") !== null || topLevelPropertyStart(object, "missing") !== null) {
|
|
3858
|
+
return { conditional: true };
|
|
3859
|
+
}
|
|
3860
|
+
const source = topLevelPropertyStart(object, "source");
|
|
3861
|
+
const pattern2 = source === null ? null : quotedValue(object.slice(source));
|
|
3862
|
+
return pattern2 === null ? null : { pattern: pattern2 };
|
|
3863
|
+
}
|
|
3864
|
+
const pattern = quotedValue(trimmed);
|
|
3865
|
+
return pattern === null ? null : { pattern };
|
|
3866
|
+
}
|
|
2745
3867
|
function extractMatcherConfig(file) {
|
|
2746
3868
|
const code = noiseMaskedOf(file);
|
|
2747
3869
|
const declaration = /\bexport\s+const\s+config\b/.exec(code);
|
|
@@ -2755,13 +3877,28 @@ function extractMatcherConfig(file) {
|
|
|
2755
3877
|
const masked = commentsMaskedOf(file);
|
|
2756
3878
|
const objectText = sliceDelimited(masked.slice(objectStart), "{", "}");
|
|
2757
3879
|
if (objectText === null) return { kind: "unreadable" };
|
|
2758
|
-
const valueStart =
|
|
3880
|
+
const valueStart = topLevelPropertyStart(objectText, "matcher");
|
|
2759
3881
|
if (valueStart === null) return { kind: "absent" };
|
|
2760
3882
|
const rest = objectText.slice(valueStart);
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
|
|
3883
|
+
if (!rest.startsWith("[")) {
|
|
3884
|
+
const entry = matcherEntry(rest);
|
|
3885
|
+
return entry === null ? { kind: "unreadable" } : "pattern" in entry ? { kind: "patterns", patterns: [entry.pattern] } : { kind: "patterns", patterns: [] };
|
|
3886
|
+
}
|
|
3887
|
+
const array = sliceBracketed(rest);
|
|
3888
|
+
if (array === null) return { kind: "unreadable" };
|
|
3889
|
+
const patterns = [];
|
|
3890
|
+
let conditional = 0;
|
|
3891
|
+
const starts = topLevelMemberStarts(array, "[");
|
|
3892
|
+
for (const [index, start] of starts.entries()) {
|
|
3893
|
+
const member = array.slice(start, index + 1 < starts.length ? starts[index + 1] - 1 : array.length - 1);
|
|
3894
|
+
if (member.trim() === "") continue;
|
|
3895
|
+
const entry = matcherEntry(member);
|
|
3896
|
+
if (entry === null) continue;
|
|
3897
|
+
if ("pattern" in entry) patterns.push(entry.pattern);
|
|
3898
|
+
else conditional++;
|
|
3899
|
+
}
|
|
3900
|
+
if (patterns.length === 0 && conditional === 0) return { kind: "unreadable" };
|
|
3901
|
+
return { kind: "patterns", patterns };
|
|
2765
3902
|
}
|
|
2766
3903
|
var MAX_MATCHER_LENGTH = 300;
|
|
2767
3904
|
function withoutGroupPrefix(body) {
|
|
@@ -2852,11 +3989,12 @@ function matcherToRegex(pattern) {
|
|
|
2852
3989
|
return null;
|
|
2853
3990
|
}
|
|
2854
3991
|
}
|
|
2855
|
-
function middlewareCovers(ctx,
|
|
2856
|
-
|
|
2857
|
-
|
|
3992
|
+
function middlewareCovers(ctx, route) {
|
|
3993
|
+
return middlewaresFor(ctx, route.file.path, route.framework).some((mw) => covers(ctx, mw, route.url));
|
|
3994
|
+
}
|
|
3995
|
+
function covers(ctx, mw, url) {
|
|
2858
3996
|
if (!hasAuthSignal(mw)) return false;
|
|
2859
|
-
const config =
|
|
3997
|
+
const { config, readable } = compiledMatchersOf(mw);
|
|
2860
3998
|
if (config.kind === "absent") return true;
|
|
2861
3999
|
if (config.kind === "unreadable") {
|
|
2862
4000
|
ctx.reportIncomplete(
|
|
@@ -2865,11 +4003,40 @@ function middlewareCovers(ctx, routePath, url) {
|
|
|
2865
4003
|
);
|
|
2866
4004
|
return true;
|
|
2867
4005
|
}
|
|
2868
|
-
|
|
2869
|
-
const readable = parsed.filter((re) => re !== null);
|
|
4006
|
+
if (config.patterns.length === 0) return false;
|
|
2870
4007
|
if (readable.length === 0) return true;
|
|
2871
4008
|
return readable.some((re) => re.test(url));
|
|
2872
4009
|
}
|
|
4010
|
+
var matcherCache = /* @__PURE__ */ new WeakMap();
|
|
4011
|
+
function compiledMatchersOf(file) {
|
|
4012
|
+
const cached = matcherCache.get(file);
|
|
4013
|
+
if (cached !== void 0) return cached;
|
|
4014
|
+
const config = extractMatcherConfig(file);
|
|
4015
|
+
const readable = config.kind === "patterns" ? config.patterns.map(matcherToRegex).filter((re) => re !== null) : [];
|
|
4016
|
+
const compiled = { config, readable };
|
|
4017
|
+
matcherCache.set(file, compiled);
|
|
4018
|
+
return compiled;
|
|
4019
|
+
}
|
|
4020
|
+
function globalGuardFor(ctx, route) {
|
|
4021
|
+
const relative3 = (file) => file.path.startsWith(route.scope) ? file.path.slice(route.scope.length) : null;
|
|
4022
|
+
const candidates = ctx.files.filter((file) => {
|
|
4023
|
+
const path = relative3(file);
|
|
4024
|
+
if (path === null) return false;
|
|
4025
|
+
if (route.framework === "sveltekit") return /^src\/hooks\.server\.[mc]?[jt]s$/.test(path);
|
|
4026
|
+
if (route.framework === "nuxt") return /^server\/middleware\/.+\.[mc]?[jt]s$/.test(path);
|
|
4027
|
+
return false;
|
|
4028
|
+
});
|
|
4029
|
+
return candidates.find(containsAuthCheck)?.path ?? null;
|
|
4030
|
+
}
|
|
4031
|
+
var authCheckCache = /* @__PURE__ */ new WeakMap();
|
|
4032
|
+
function containsAuthCheck(file) {
|
|
4033
|
+
const cached = authCheckCache.get(file);
|
|
4034
|
+
if (cached !== void 0) return cached;
|
|
4035
|
+
const code = noiseMaskedOf(file);
|
|
4036
|
+
const found = AUTH_ENFORCING_CALL.test(code) || containsConditionalAuthCheck(code);
|
|
4037
|
+
authCheckCache.set(file, found);
|
|
4038
|
+
return found;
|
|
4039
|
+
}
|
|
2873
4040
|
var SECRET_SHAPED = /['"`]([A-Za-z0-9_\-.]{32,})['"`]/g;
|
|
2874
4041
|
function excerptFor(file, line) {
|
|
2875
4042
|
const raw = (file.lines[line - 1] ?? "").trim();
|
|
@@ -2885,9 +4052,12 @@ var apiAuthRule = {
|
|
|
2885
4052
|
id: "api/db-access-without-auth",
|
|
2886
4053
|
severity: "P0",
|
|
2887
4054
|
check(ctx) {
|
|
2888
|
-
const routes = ctx.files.
|
|
4055
|
+
const routes = ctx.files.flatMap((file) => {
|
|
4056
|
+
const route = routeOf(file);
|
|
4057
|
+
return route === null ? serverActionRoutes(file) : [route];
|
|
4058
|
+
});
|
|
2889
4059
|
if (routes.length === 0) return [];
|
|
2890
|
-
for (const middlewareFile of ctx.files.filter((f) =>
|
|
4060
|
+
for (const middlewareFile of ctx.files.filter((f) => isAnyMiddlewareFile(f.path))) {
|
|
2891
4061
|
const config = extractMatcherConfig(middlewareFile);
|
|
2892
4062
|
const refused = config.kind === "patterns" ? config.patterns.filter((p) => matcherToRegex(p) === null) : [];
|
|
2893
4063
|
if (refused.length > 0) {
|
|
@@ -2900,29 +4070,54 @@ var apiAuthRule = {
|
|
|
2900
4070
|
const adminModules = ctx.files.filter(buildsAdminClient);
|
|
2901
4071
|
const sessionModules = ctx.files.filter(buildsSessionClient);
|
|
2902
4072
|
const findings = [];
|
|
4073
|
+
const indirectByFile = /* @__PURE__ */ new Map();
|
|
2903
4074
|
for (const route of routes) {
|
|
2904
|
-
const
|
|
4075
|
+
const reachable = route.reachable;
|
|
4076
|
+
let ops = unguardedOpsOf(route.file).filter((op) => reachable === void 0 || op.index > reachable.start && op.index < reachable.end);
|
|
2905
4077
|
if (ops.length === 0) continue;
|
|
2906
|
-
|
|
2907
|
-
if (
|
|
2908
|
-
|
|
4078
|
+
let indirectOps = indirectByFile.get(route.file);
|
|
4079
|
+
if (!indirectOps) {
|
|
4080
|
+
indirectOps = indirectGuardOperations(route, ctx.files);
|
|
4081
|
+
indirectByFile.set(route.file, indirectOps);
|
|
4082
|
+
}
|
|
4083
|
+
const unprotected = ops.filter((op) => !indirectOps.has(op.index));
|
|
4084
|
+
if (unprotected.length > 0) ops = unprotected;
|
|
4085
|
+
const url = route.url;
|
|
4086
|
+
if (isAuthEndpoint(route)) continue;
|
|
4087
|
+
if ((route.framework === "next" || route.framework === "astro") && route.action === void 0 && middlewareCovers(ctx, route)) continue;
|
|
4088
|
+
const globalGuard = globalGuardFor(ctx, route);
|
|
4089
|
+
const globalNote = globalGuard === null ? [] : [
|
|
4090
|
+
`${globalGuard} contains an authentication check that may cover ${url}. Its path conditions are written in code, so the scan cannot tell which routes it applies to; this finding is reported at lower confidence. Confirm that check runs for ${url}.`
|
|
4091
|
+
];
|
|
2909
4092
|
const admin = usesAdminClient(route, adminModules, ctx.files);
|
|
2910
4093
|
const hit = ops.find((o) => o.writes) ?? ops[0];
|
|
2911
|
-
const
|
|
2912
|
-
const
|
|
4094
|
+
const indirect = indirectOps.get(hit.index);
|
|
4095
|
+
const line = lineNumberAt(lineStartsCached(route.file), hit.index);
|
|
4096
|
+
const excerpt = excerptFor(route.file, line);
|
|
4097
|
+
const indirectEvidence = indirect ? [{
|
|
4098
|
+
kind: "auth-helper",
|
|
4099
|
+
file: indirect.file.path,
|
|
4100
|
+
line: indirect.line,
|
|
4101
|
+
description: "Local authentication helper recognized through imports; verify its runtime behaviour and caller coverage."
|
|
4102
|
+
}] : [];
|
|
4103
|
+
if (indirect) globalNote.push("A local authentication helper or wrapper was recognized before this operation. This finding is retained for review because indirect control flow is not a proof of authorization.");
|
|
2913
4104
|
if (admin) {
|
|
4105
|
+
const trace = adminEvidence(route, adminModules, ctx.files, line);
|
|
2914
4106
|
findings.push({
|
|
2915
4107
|
ruleId: "api/admin-db-access-without-auth",
|
|
4108
|
+
...trace,
|
|
4109
|
+
evidence: [...trace.evidence ?? [], ...indirectEvidence],
|
|
2916
4110
|
severity: "P0",
|
|
2917
|
-
//
|
|
2918
|
-
confidence: "certain",
|
|
2919
|
-
title: `Anyone can call ${url} and it queries your database as admin`,
|
|
2920
|
-
file: route.path,
|
|
4111
|
+
// 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
|
|
4112
|
+
confidence: globalGuard === null && !indirect ? "certain" : "likely",
|
|
4113
|
+
title: indirect ? `Review indirect authentication for admin database access in ${url}` : `Anyone can call ${url} and it queries your database as admin`,
|
|
4114
|
+
file: route.file.path,
|
|
2921
4115
|
line,
|
|
2922
4116
|
excerpt,
|
|
2923
4117
|
why: [
|
|
4118
|
+
...globalNote,
|
|
2924
4119
|
`This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
|
|
2925
|
-
`The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
|
|
4120
|
+
indirect ? "A local guard was recognized, but its runtime enforcement and business authorization still require verification." : `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
|
|
2926
4121
|
`If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
|
|
2927
4122
|
`If this endpoint is meant to be public \u2014 handing out a guest session, taking a waitlist signup \u2014 then the problem is not that it is open, it is that it is open *and* holds the admin key. Give it a client that can only do the one thing it needs.`
|
|
2928
4123
|
],
|
|
@@ -2944,12 +4139,20 @@ var apiAuthRule = {
|
|
|
2944
4139
|
severity: "P1",
|
|
2945
4140
|
// 公开写入可能是业务设计,保留疑似置信度。
|
|
2946
4141
|
confidence: "likely",
|
|
2947
|
-
|
|
2948
|
-
|
|
4142
|
+
// Server Action 的名称以小写开头,放在句首时首字母大写。
|
|
4143
|
+
title: indirect ? `Review indirect authentication for database writes in ${url}` : `${url.charAt(0).toUpperCase()}${url.slice(1)} writes to your database with no sign-in check`,
|
|
4144
|
+
file: route.file.path,
|
|
2949
4145
|
line,
|
|
2950
4146
|
excerpt,
|
|
4147
|
+
...indirect ? { evidence: [{
|
|
4148
|
+
kind: "operation",
|
|
4149
|
+
file: route.file.path,
|
|
4150
|
+
line,
|
|
4151
|
+
description: "Data write using a local authentication helper."
|
|
4152
|
+
}, ...indirectEvidence] } : {},
|
|
2951
4153
|
why: [
|
|
2952
|
-
|
|
4154
|
+
...globalNote,
|
|
4155
|
+
indirect ? "This route changes data after a local authentication helper; verify that it rejects unauthorized callers." : `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
|
|
2953
4156
|
`If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
|
|
2954
4157
|
`If this is a public form \u2014 a waitlist, a contact box \u2014 that may be intentional. It is still worth rate limiting, because an open write endpoint is what gets a database filled with spam overnight.`
|
|
2955
4158
|
],
|
|
@@ -2992,7 +4195,7 @@ function headerExpression(content, start) {
|
|
|
2992
4195
|
return content.slice(start, end);
|
|
2993
4196
|
}
|
|
2994
4197
|
var CORS_ORIGIN_OPTION = /\borigin\s*:\s*/gi;
|
|
2995
|
-
var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]
|
|
4198
|
+
var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*\s*)?)?\(/gi;
|
|
2996
4199
|
var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
|
|
2997
4200
|
function closingParameterList(source, open) {
|
|
2998
4201
|
let depth = 0;
|
|
@@ -3114,8 +4317,14 @@ function callbackAnswer(params, body) {
|
|
|
3114
4317
|
var ORIGIN_IS_CHECKED = /\bincludes\s*\(|\bindexOf\s*\(|===|!==|==|!=|\.test\s*\(|\.some\s*\(|\bstartsWith\s*\(|\.match\s*\(|\.has\s*\(|\bif\b|\?|\ballow(?:ed|list)?\b|\bwhitelist\b/i;
|
|
3115
4318
|
var ACAC_HEADER = /['"]Access-Control-Allow-Credentials['"][\s\S]{0,40}?\btrue\b/gi;
|
|
3116
4319
|
var CORS_CREDENTIALS_OPTION = /\bcredentials\s*:\s*true\b/g;
|
|
4320
|
+
var TRAILING_PUNCTUATION = /[\s;,)}\]!]/;
|
|
4321
|
+
function trimTrailingPunctuation(text) {
|
|
4322
|
+
let end = text.length;
|
|
4323
|
+
while (end > 0 && TRAILING_PUNCTUATION.test(text[end - 1])) end--;
|
|
4324
|
+
return text.slice(0, end);
|
|
4325
|
+
}
|
|
3117
4326
|
function classifyOrigin(raw) {
|
|
3118
|
-
let v = raw.trim()
|
|
4327
|
+
let v = trimTrailingPunctuation(raw.trim());
|
|
3119
4328
|
const opening = v[0];
|
|
3120
4329
|
if (opening === '"' || opening === "'" || opening === "`") {
|
|
3121
4330
|
const close = v.indexOf(opening, 1);
|
|
@@ -3218,21 +4427,37 @@ function collectCredentialLines(file) {
|
|
|
3218
4427
|
}
|
|
3219
4428
|
return lines;
|
|
3220
4429
|
}
|
|
3221
|
-
function
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
4430
|
+
function indexOrigins(origins) {
|
|
4431
|
+
const byLine = /* @__PURE__ */ new Map();
|
|
4432
|
+
for (const { mark, order } of origins) {
|
|
4433
|
+
const entry = byLine.get(mark.line);
|
|
4434
|
+
if (entry === void 0) byLine.set(mark.line, { line: mark.line, kinds: /* @__PURE__ */ new Set([mark.kind]), first: mark, order });
|
|
4435
|
+
else {
|
|
4436
|
+
entry.kinds.add(mark.kind);
|
|
4437
|
+
if (order < entry.order) {
|
|
4438
|
+
entry.first = mark;
|
|
4439
|
+
entry.order = order;
|
|
4440
|
+
}
|
|
3231
4441
|
}
|
|
3232
4442
|
}
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
4443
|
+
return [...byLine.values()].sort((a, b) => a.line - b.line);
|
|
4444
|
+
}
|
|
4445
|
+
function nearestOrigin(lines, credLine) {
|
|
4446
|
+
let lo = 0;
|
|
4447
|
+
let hi = lines.length;
|
|
4448
|
+
while (lo < hi) {
|
|
4449
|
+
const mid = lo + hi >> 1;
|
|
4450
|
+
if (lines[mid].line < credLine) lo = mid + 1;
|
|
4451
|
+
else hi = mid;
|
|
4452
|
+
}
|
|
4453
|
+
const below = lo > 0 ? lines[lo - 1] : void 0;
|
|
4454
|
+
const above = lines[lo];
|
|
4455
|
+
const best = Math.min(below ? credLine - below.line : Infinity, above ? above.line - credLine : Infinity);
|
|
4456
|
+
if (best > PAIRING_DISTANCE) return null;
|
|
4457
|
+
const closest = [below, above].filter((entry) => entry !== void 0 && Math.abs(entry.line - credLine) === best);
|
|
4458
|
+
const kinds = new Set(closest.flatMap((entry) => [...entry.kinds]));
|
|
4459
|
+
if (kinds.size !== 1) return null;
|
|
4460
|
+
return closest.reduce((a, b) => b.order < a.order ? b : a).first;
|
|
3236
4461
|
}
|
|
3237
4462
|
var corsRule = {
|
|
3238
4463
|
id: "cors/credentialed-cross-origin",
|
|
@@ -3248,9 +4473,17 @@ var corsRule = {
|
|
|
3248
4473
|
const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
|
|
3249
4474
|
const findings = [];
|
|
3250
4475
|
const reported = /* @__PURE__ */ new Set();
|
|
4476
|
+
const groupOf = (mark) => mark.mode === "header" ? "header" : `option:${scopes.get(mark.at)}`;
|
|
4477
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
4478
|
+
origins.forEach((mark, order) => {
|
|
4479
|
+
const group = grouped.get(groupOf(mark)) ?? [];
|
|
4480
|
+
group.push({ mark, order });
|
|
4481
|
+
grouped.set(groupOf(mark), group);
|
|
4482
|
+
});
|
|
4483
|
+
const indexes = new Map([...grouped].map(([group, marks]) => [group, indexOrigins(marks)]));
|
|
3251
4484
|
for (const credential of credentialLines) {
|
|
3252
|
-
|
|
3253
|
-
const origin = nearestOrigin(
|
|
4485
|
+
if (reported.size === 2) break;
|
|
4486
|
+
const origin = nearestOrigin(indexes.get(groupOf(credential)) ?? [], credential.line);
|
|
3254
4487
|
if (!origin) continue;
|
|
3255
4488
|
if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
|
|
3256
4489
|
if (reported.has(origin.kind)) continue;
|
|
@@ -3319,6 +4552,8 @@ var RULE_IDS = [
|
|
|
3319
4552
|
"firebase/test-mode-rules",
|
|
3320
4553
|
"gitleak/env-in-history",
|
|
3321
4554
|
"gitleak/env-tracked",
|
|
4555
|
+
"supabase/permissive-policy",
|
|
4556
|
+
"supabase/public-bucket-listing",
|
|
3322
4557
|
"supabase/rls-not-enabled",
|
|
3323
4558
|
// 凭据规则 ID 从共享格式表生成。
|
|
3324
4559
|
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
@@ -3384,6 +4619,15 @@ function sanitize(findings, files) {
|
|
|
3384
4619
|
file: f.file === null ? null : clean(f.file),
|
|
3385
4620
|
// 先脱敏再截断,避免截断导致凭据特征失效。
|
|
3386
4621
|
excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
|
|
4622
|
+
...f.evidence ? {
|
|
4623
|
+
evidence: f.evidence.slice(0, 24).map((step) => ({
|
|
4624
|
+
kind: step.kind,
|
|
4625
|
+
file: clean(step.file),
|
|
4626
|
+
line: step.line,
|
|
4627
|
+
description: clean(step.description)
|
|
4628
|
+
})),
|
|
4629
|
+
evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
|
|
4630
|
+
} : {},
|
|
3387
4631
|
fix: f.fix.map(clean),
|
|
3388
4632
|
...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
|
|
3389
4633
|
}));
|
|
@@ -3552,6 +4796,9 @@ var gray = wrap(90, 39);
|
|
|
3552
4796
|
var BLOCKING = /* @__PURE__ */ new Set(["P0", "P1"]);
|
|
3553
4797
|
|
|
3554
4798
|
// src/report/shared.ts
|
|
4799
|
+
function changeViewNotice(view) {
|
|
4800
|
+
return `Changed-file view: ${view.changedFiles} changed files; ${view.hiddenFindings} findings hidden. Full scan: ${view.totalFindings} findings, ${view.totalBlocking} blocking. Scan scope and exit status are unchanged.`;
|
|
4801
|
+
}
|
|
3555
4802
|
function plural(n, word) {
|
|
3556
4803
|
return n === 1 ? word : `${word}s`;
|
|
3557
4804
|
}
|
|
@@ -3611,12 +4858,14 @@ function renderReport(result, opts) {
|
|
|
3611
4858
|
`${INDENT}${bold("canship")} ${dim(`scanned ${result.filesScanned} ${plural(result.filesScanned, "file")} in ${result.durationMs}ms`)}`
|
|
3612
4859
|
);
|
|
3613
4860
|
out.push(`${INDENT}${dim(opts.root)}`);
|
|
4861
|
+
if (result.changeView) out.push(`${INDENT}${yellow(changeViewNotice(result.changeView))}`);
|
|
3614
4862
|
out.push("");
|
|
3615
4863
|
if (findings.length === 0) {
|
|
3616
4864
|
out.push(...renderClean(result, opts));
|
|
3617
4865
|
return out.join("\n");
|
|
3618
4866
|
}
|
|
3619
|
-
const { blocking, minor: confirmedMinor, unsure } = verdictOf(findings);
|
|
4867
|
+
const { blocking: visibleBlocking, minor: confirmedMinor, unsure } = verdictOf(findings);
|
|
4868
|
+
const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
|
|
3620
4869
|
if (blocking > 0) {
|
|
3621
4870
|
out.push(`${INDENT}${red(bold(`\u2717 ${blocking} critical ${plural(blocking, "issue")} \u2014 do not deploy`))}`);
|
|
3622
4871
|
} else if (confirmedMinor > 0) {
|
|
@@ -3658,6 +4907,11 @@ function renderFinding(f, index) {
|
|
|
3658
4907
|
out.push("");
|
|
3659
4908
|
out.push(`${INDENT}${INDENT}${gray(f.excerpt)}`);
|
|
3660
4909
|
}
|
|
4910
|
+
if (f.evidence?.length) {
|
|
4911
|
+
out.push("", `${INDENT}${INDENT}${bold("Evidence (static relationships):")}`);
|
|
4912
|
+
for (const step of f.evidence) out.push(`${INDENT}${INDENT} ${locationOf(step)} \u2014 ${step.description}`);
|
|
4913
|
+
if (f.evidenceTruncated) out.push(`${INDENT}${INDENT} Additional dependency steps omitted.`);
|
|
4914
|
+
}
|
|
3661
4915
|
out.push("");
|
|
3662
4916
|
for (const line of wrapText(f.why.join("\n\n"), 76)) {
|
|
3663
4917
|
out.push(line === "" ? "" : `${INDENT}${INDENT}${line}`);
|
|
@@ -3712,6 +4966,8 @@ function renderClean(result, opts) {
|
|
|
3712
4966
|
if (result.partial) {
|
|
3713
4967
|
const headline = opts.hiddenLikely > 0 ? `! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden, and not everything was checked` : "! No findings \u2014 but not everything was checked";
|
|
3714
4968
|
out.push(`${INDENT}${yellow(bold(headline))}`);
|
|
4969
|
+
} else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
|
|
4970
|
+
out.push(`${INDENT}${yellow(bold("! No visible findings in changed files \u2014 other findings still exist"))}`);
|
|
3715
4971
|
} else if (opts.hiddenLikely > 0) {
|
|
3716
4972
|
out.push(
|
|
3717
4973
|
`${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
|
|
@@ -3732,9 +4988,9 @@ function renderClean(result, opts) {
|
|
|
3732
4988
|
out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
|
|
3733
4989
|
out.push(`${INDENT}${dim(" \xB7 Supabase service_role keys reachable from the client")}`);
|
|
3734
4990
|
out.push(`${INDENT}${dim(" \xB7 .env files committed to git, including in history")}`);
|
|
3735
|
-
out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security")}`);
|
|
4991
|
+
out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security, or policies open to everyone")}`);
|
|
3736
4992
|
out.push(`${INDENT}${dim(" \xB7 Firebase rules left open to anyone")}`);
|
|
3737
|
-
out.push(`${INDENT}${dim(" \xB7 API routes that query your database with no sign-in check")}`);
|
|
4993
|
+
out.push(`${INDENT}${dim(" \xB7 API routes and server actions that query your database with no sign-in check")}`);
|
|
3738
4994
|
out.push(`${INDENT}${dim(" \xB7 CORS that lets other sites act as your signed-in visitors")}`);
|
|
3739
4995
|
out.push("");
|
|
3740
4996
|
out.push(`${INDENT}${dim("It does not check rate limiting, injection, or whether the checks it")}`);
|
|
@@ -3871,8 +5127,12 @@ function renderInstruction(f, index) {
|
|
|
3871
5127
|
const location = defuseMarkers(locationOf(f));
|
|
3872
5128
|
lines.push(`${index}. ${location} \u2014 ${defuseMarkers(f.title)}`);
|
|
3873
5129
|
if (f.excerpt) lines.push(` Found: ${defuseMarkers(f.excerpt)}`);
|
|
5130
|
+
for (const step of f.evidence ?? []) {
|
|
5131
|
+
lines.push(` Static evidence: ${defuseMarkers(locationOf(step))} \u2014 ${defuseMarkers(step.description)}`);
|
|
5132
|
+
}
|
|
5133
|
+
if (f.evidenceTruncated) lines.push(" Additional dependency steps omitted.");
|
|
3874
5134
|
for (const step of f.fix) {
|
|
3875
|
-
lines.push(` - ${step}`);
|
|
5135
|
+
lines.push(` - ${defuseMarkers(step)}`);
|
|
3876
5136
|
}
|
|
3877
5137
|
return lines.join("\n");
|
|
3878
5138
|
}
|
|
@@ -3883,6 +5143,7 @@ function renderFixPrompt(findings, ctx) {
|
|
|
3883
5143
|
const baselineSuppressed = ctx?.baselineSuppressed ?? 0;
|
|
3884
5144
|
const silenced = ctx?.silenced ?? [];
|
|
3885
5145
|
const suppressedNotes = [
|
|
5146
|
+
ctx?.changeView ? changeViewNotice(ctx.changeView) : null,
|
|
3886
5147
|
!ctx?.ignoredFiles?.length ? null : `Note: ${ctx.ignoredFiles.length} file(s) excluded by canship-ignore-file: ${defuseMarkers(ctx.ignoredFiles.join(", "))}. Their contents were not checked. Do not treat this as a clean result.`,
|
|
3887
5148
|
baselineSuppressed === 0 ? null : `Note: ${baselineSuppressed} ${baselineSuppressed === 1 ? "finding was" : "findings were"} hidden by a baseline. Those problems still exist and are not listed below. Re-run without --baseline to see them.`,
|
|
3888
5149
|
silenced.length === 0 ? null : `Note: ${silenced.length} ${silenced.length === 1 ? "finding was" : "findings were"} silenced by a canship-ignore-next-line marker in the source, at ${defuseMarkers(silenced.join(", "))}. Those problems still exist and are not listed below.`,
|
|
@@ -3946,7 +5207,7 @@ function renderFixPrompt(findings, ctx) {
|
|
|
3946
5207
|
for (const { step } of humanSteps) {
|
|
3947
5208
|
if (seen.has(step)) continue;
|
|
3948
5209
|
seen.add(step);
|
|
3949
|
-
out.push(`- ${step}`);
|
|
5210
|
+
out.push(`- ${defuseMarkers(step)}`);
|
|
3950
5211
|
}
|
|
3951
5212
|
out.push("");
|
|
3952
5213
|
out.push("Until these are done, the exposure is still live \u2014 the code fix alone does not close it.");
|
|
@@ -3980,6 +5241,7 @@ function renderFinding2(f, index) {
|
|
|
3980
5241
|
</header>
|
|
3981
5242
|
<div class="loc">${esc(location)}${f.confidence === "likely" ? ' <span class="tag">lower confidence</span>' : ""}</div>
|
|
3982
5243
|
${f.excerpt ? `<pre><code>${esc(f.excerpt)}</code></pre>` : ""}
|
|
5244
|
+
${f.evidence?.length ? `<h4>Evidence (static relationships)</h4><ol>${f.evidence.map((step) => `<li><code>${esc(locationOf(step))}</code> \u2014 ${esc(step.description)}</li>`).join("")}</ol>${f.evidenceTruncated ? "<p>Additional dependency steps omitted.</p>" : ""}` : ""}
|
|
3983
5245
|
<div class="why">${linkify(paragraphs(f.why))}</div>
|
|
3984
5246
|
${fixList}
|
|
3985
5247
|
${humanList}
|
|
@@ -3990,14 +5252,15 @@ function renderHtml(result, opts) {
|
|
|
3990
5252
|
const hiddenLikely = opts.hiddenLikely ?? 0;
|
|
3991
5253
|
const baselineSuppressed = opts.baselineSuppressed ?? 0;
|
|
3992
5254
|
const baselineStale = opts.baselineStale ?? 0;
|
|
3993
|
-
const { blocking
|
|
5255
|
+
const { blocking, minor, unsure } = verdictOf(findings);
|
|
5256
|
+
const certain = result.changeView?.totalBlocking ?? blocking;
|
|
3994
5257
|
const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
|
|
3995
5258
|
// 未扫描文件时不显示通过结论。
|
|
3996
5259
|
`<div class="verdict warn">No files were scanned — nothing was checked</div>`
|
|
3997
5260
|
) : result.partial ? (
|
|
3998
5261
|
// 扫描不完整时不得显示正常通过。
|
|
3999
5262
|
hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings — but not everything was checked</div>`
|
|
4000
|
-
) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
5263
|
+
) : (result.changeView?.hiddenFindings ?? 0) > 0 ? `<div class="verdict warn">No visible findings in changed files — other findings still exist</div>` : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings — ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
|
|
4001
5264
|
// 基线抑制结果时明确说明,避免误报为项目无问题。
|
|
4002
5265
|
baselineSuppressed > 0 ? `<div class="verdict warn">No new findings — ${baselineSuppressed} ${plural(baselineSuppressed, "finding")} accepted by the baseline</div>` : `<div class="verdict clean">No exposed credentials found</div>`
|
|
4003
5266
|
) : certain > 0 ? `<div class="verdict bad">${certain} critical ${plural(certain, "issue")} — do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix — nothing exposed</div>` : `<div class="verdict warn">${unsure} possible ${plural(unsure, "issue")} to review</div>`;
|
|
@@ -4010,7 +5273,7 @@ function renderHtml(result, opts) {
|
|
|
4010
5273
|
gitignored or build output that canship skips. If the project lives in a
|
|
4011
5274
|
subdirectory, point canship at it: <code>npx canship ./app</code>.</p>
|
|
4012
5275
|
</div>`
|
|
4013
|
-
) : hiddenLikely > 0 ? `<div class="clean-note">
|
|
5276
|
+
) : (result.changeView?.hiddenFindings ?? 0) > 0 ? '<div class="clean-note">This view hides existing findings. Re-run without --changed-since for the full report.</div>' : hiddenLikely > 0 ? `<div class="clean-note">
|
|
4014
5277
|
<p><strong>This is not a finding-free result.</strong> The default report hides
|
|
4015
5278
|
${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
|
|
4016
5279
|
<p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
|
|
@@ -4020,7 +5283,7 @@ function renderHtml(result, opts) {
|
|
|
4020
5283
|
<p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
|
|
4021
5284
|
</div>` : `<div class="clean-note">
|
|
4022
5285
|
<p>canship checked for hardcoded API keys, server secrets exposed to the browser,
|
|
4023
|
-
Supabase tables without Row Level Security, open Firebase rules, API routes that reach
|
|
5286
|
+
Supabase tables without Row Level Security or with policies open to everyone, open Firebase rules, API routes and server actions that reach
|
|
4024
5287
|
the database with no sign-in check, CORS that lets other sites use your visitors’
|
|
4025
5288
|
sessions, and <code>.env</code> files committed to git.</p>
|
|
4026
5289
|
<p><strong>A clean result means those checks passed — not that your app is secure.</strong>
|
|
@@ -4123,6 +5386,7 @@ function renderHtml(result, opts) {
|
|
|
4123
5386
|
<h1>canship report</h1>
|
|
4124
5387
|
<div class="meta">${esc(opts.root)}<br>${esc(opts.generatedAt)} · ${result.filesScanned} ${plural(result.filesScanned, "file")} scanned in ${result.durationMs}ms</div>
|
|
4125
5388
|
${verdict}
|
|
5389
|
+
${result.changeView ? `<p class="opted-out">${esc(changeViewNotice(result.changeView))}</p>` : ""}
|
|
4126
5390
|
<div class="notice">
|
|
4127
5391
|
Credential values canship recognises are masked in this report. One in a format it has no
|
|
4128
5392
|
pattern for can still appear inside a quoted line, and this report lists your
|
|
@@ -4268,6 +5532,7 @@ function applyBaseline(findings, baseline) {
|
|
|
4268
5532
|
var INFORMATION_URI = "https://github.com/Tasomei/canship";
|
|
4269
5533
|
function suppressionNotes(result, opts) {
|
|
4270
5534
|
const notes = [];
|
|
5535
|
+
if (result.changeView) notes.push({ level: "warning", message: { text: changeViewNotice(result.changeView) } });
|
|
4271
5536
|
const baseline = opts.baselineSuppressed ?? 0;
|
|
4272
5537
|
const hidden = opts.hiddenLikely ?? 0;
|
|
4273
5538
|
if (baseline > 0) {
|
|
@@ -4345,7 +5610,18 @@ function resultsOf(findings) {
|
|
|
4345
5610
|
}
|
|
4346
5611
|
}
|
|
4347
5612
|
],
|
|
4348
|
-
partialFingerprints: { canshipFindingV2: fingerprintOf(f) }
|
|
5613
|
+
partialFingerprints: { canshipFindingV2: fingerprintOf(f) },
|
|
5614
|
+
...f.evidence?.length ? {
|
|
5615
|
+
relatedLocations: f.evidence.map((step, index) => ({
|
|
5616
|
+
id: index + 1,
|
|
5617
|
+
message: { text: step.description },
|
|
5618
|
+
physicalLocation: {
|
|
5619
|
+
artifactLocation: { uri: step.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
|
|
5620
|
+
...step.line === null ? {} : { region: { startLine: step.line } }
|
|
5621
|
+
}
|
|
5622
|
+
})),
|
|
5623
|
+
properties: { evidenceTruncated: f.evidenceTruncated ?? false }
|
|
5624
|
+
} : {}
|
|
4349
5625
|
}));
|
|
4350
5626
|
}
|
|
4351
5627
|
function renderSarif(result, opts) {
|
|
@@ -4408,7 +5684,8 @@ function createJsonReport(result, options) {
|
|
|
4408
5684
|
baselineSuppressed: options.baselineSuppressed,
|
|
4409
5685
|
baselineStale: options.baselineStale,
|
|
4410
5686
|
excerptsOmitted: options.excerptsOmitted ?? false,
|
|
4411
|
-
findings: result.findings
|
|
5687
|
+
findings: result.findings,
|
|
5688
|
+
...result.changeView ? { changeView: result.changeView } : {}
|
|
4412
5689
|
};
|
|
4413
5690
|
}
|
|
4414
5691
|
|
|
@@ -4510,15 +5787,15 @@ var RULE_CATALOG = [
|
|
|
4510
5787
|
"Admin database access without a recognized auth guard",
|
|
4511
5788
|
"P0",
|
|
4512
5789
|
"certain",
|
|
4513
|
-
"Next.js
|
|
4514
|
-
"Syntactic guards only; runtime authentication and business authorization are not verified."
|
|
5790
|
+
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro, including workspace applications.",
|
|
5791
|
+
"Syntactic guards only; runtime authentication and business authorization are not verified. Local auth imports/re-exports (up to eight hops), SvelteKit hooks and Nuxt middleware may lower confidence, without removing findings."
|
|
4515
5792
|
),
|
|
4516
5793
|
rule(
|
|
4517
5794
|
"api/db-write-without-auth",
|
|
4518
5795
|
"Database write without a recognized auth guard",
|
|
4519
5796
|
"P1",
|
|
4520
5797
|
"likely",
|
|
4521
|
-
"Next.js
|
|
5798
|
+
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
|
|
4522
5799
|
"Session-scoped clients and indirect guards may need manual review."
|
|
4523
5800
|
),
|
|
4524
5801
|
rule(
|
|
@@ -4558,7 +5835,7 @@ var RULE_CATALOG = [
|
|
|
4558
5835
|
"Supabase admin credential exposed in source or public env",
|
|
4559
5836
|
"P0",
|
|
4560
5837
|
"certain",
|
|
4561
|
-
"Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
|
|
5838
|
+
"Recognized service_role JWTs in scanned text files, including configuration and non-JavaScript source; Supabase admin credentials in public environment variables.",
|
|
4562
5839
|
"Source presence is observable; actual browser delivery and key validity are not verified."
|
|
4563
5840
|
),
|
|
4564
5841
|
rule(
|
|
@@ -4566,7 +5843,7 @@ var RULE_CATALOG = [
|
|
|
4566
5843
|
"Unconditional Firebase access",
|
|
4567
5844
|
"P1",
|
|
4568
5845
|
"varies",
|
|
4569
|
-
"Firebase .rules files: unconditional writes are certain; public reads require review.",
|
|
5846
|
+
"Firebase .rules files and Realtime Database JSON rules: unconditional writes are certain; public reads require review.",
|
|
4570
5847
|
"Public reads may be intentional; runtime rules and business authorization are not verified."
|
|
4571
5848
|
),
|
|
4572
5849
|
rule(
|
|
@@ -4593,6 +5870,22 @@ var RULE_CATALOG = [
|
|
|
4593
5870
|
"Tracked environment files; templates and public-only values are treated separately.",
|
|
4594
5871
|
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
4595
5872
|
),
|
|
5873
|
+
rule(
|
|
5874
|
+
"supabase/permissive-policy",
|
|
5875
|
+
"Row Level Security policy with an always-true condition",
|
|
5876
|
+
"P1",
|
|
5877
|
+
"varies",
|
|
5878
|
+
"Supabase SQL migrations, replayed by application scope: permissive policies whose USING or WITH CHECK is always true (true, 1=1, 'a'='a'). Changing or deleting any row is certain; reading or inserting any row requires review.",
|
|
5879
|
+
"Missing clauses and dashboard changes are not checked; matching restrictive policies and nontrivial WITH CHECK conditions lower confidence."
|
|
5880
|
+
),
|
|
5881
|
+
rule(
|
|
5882
|
+
"supabase/public-bucket-listing",
|
|
5883
|
+
"Public storage bucket whose contents can be listed",
|
|
5884
|
+
"P2",
|
|
5885
|
+
"varies",
|
|
5886
|
+
"Public buckets declared in migrations or supabase/config.toml, paired with a SELECT policy on storage.objects that is always true or filters only by that bucket.",
|
|
5887
|
+
"Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
|
|
5888
|
+
),
|
|
4596
5889
|
rule(
|
|
4597
5890
|
"supabase/rls-not-enabled",
|
|
4598
5891
|
"Table without RLS in migrations",
|
|
@@ -4624,8 +5917,53 @@ function renderRuleCatalog() {
|
|
|
4624
5917
|
).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
|
|
4625
5918
|
}
|
|
4626
5919
|
|
|
5920
|
+
// src/changes.ts
|
|
5921
|
+
var ChangeViewError = class extends Error {
|
|
5922
|
+
};
|
|
5923
|
+
function changedFilesSince(root, ref) {
|
|
5924
|
+
if (!ref || ref.length > 1024 || /[\0\r\n]/.test(ref)) throw new ChangeViewError("Invalid changed-since reference.");
|
|
5925
|
+
const git2 = resolveGitExecutable(root);
|
|
5926
|
+
if (!git2 || !hasContainedGitMetadata(root)) throw new ChangeViewError("Changed-file views require a readable local Git repository.");
|
|
5927
|
+
try {
|
|
5928
|
+
const baseCommit = execGitSync(git2, root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).trim();
|
|
5929
|
+
const mergeBase = execGitSync(git2, root, ["merge-base", baseCommit, "HEAD"]).trim();
|
|
5930
|
+
const hash = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
|
|
5931
|
+
if (!hash.test(baseCommit) || !hash.test(mergeBase)) throw new Error("Invalid object ID");
|
|
5932
|
+
const tracked = execGitSync(git2, root, [
|
|
5933
|
+
"diff",
|
|
5934
|
+
"--no-ext-diff",
|
|
5935
|
+
"--no-textconv",
|
|
5936
|
+
"--no-renames",
|
|
5937
|
+
"--relative",
|
|
5938
|
+
"--name-only",
|
|
5939
|
+
"-z",
|
|
5940
|
+
mergeBase,
|
|
5941
|
+
"--",
|
|
5942
|
+
"."
|
|
5943
|
+
]);
|
|
5944
|
+
const untracked = execGitSync(git2, root, ["ls-files", "--others", "--exclude-standard", "-z", "--", "."]);
|
|
5945
|
+
const paths = new Set((tracked + untracked).split("\0").filter(Boolean).map(cleanForOutput));
|
|
5946
|
+
return { baseCommit, mergeBase, paths };
|
|
5947
|
+
} catch {
|
|
5948
|
+
throw new ChangeViewError("Cannot compute changed files. Check that the base commit and merge history are available locally.");
|
|
5949
|
+
}
|
|
5950
|
+
}
|
|
5951
|
+
function changedFileView(result, changed) {
|
|
5952
|
+
const findings = result.findings.filter((finding) => finding.file === null || finding.evidenceTruncated || changed.paths.has(finding.file) || finding.evidence?.some((step) => changed.paths.has(step.file)));
|
|
5953
|
+
const verdict = verdictOf(result.findings);
|
|
5954
|
+
return { ...result, findings, changeView: {
|
|
5955
|
+
baseCommit: changed.baseCommit,
|
|
5956
|
+
mergeBase: changed.mergeBase,
|
|
5957
|
+
changedFiles: changed.paths.size,
|
|
5958
|
+
hiddenFindings: result.findings.length - findings.length,
|
|
5959
|
+
totalFindings: result.findings.length,
|
|
5960
|
+
totalBlocking: verdict.blocking,
|
|
5961
|
+
totalLikely: verdict.unsure
|
|
5962
|
+
} };
|
|
5963
|
+
}
|
|
5964
|
+
|
|
4627
5965
|
// src/cli.ts
|
|
4628
|
-
var VERSION = true ? "0.
|
|
5966
|
+
var VERSION = true ? "0.4.0" : "0.0.0-dev";
|
|
4629
5967
|
function finish(code) {
|
|
4630
5968
|
process.exitCode = code;
|
|
4631
5969
|
}
|
|
@@ -4694,10 +6032,16 @@ function parseArgs(argv) {
|
|
|
4694
6032
|
help: false,
|
|
4695
6033
|
version: false,
|
|
4696
6034
|
listRules: false,
|
|
4697
|
-
noExcerpts: false
|
|
6035
|
+
noExcerpts: false,
|
|
6036
|
+
changedSince: null
|
|
4698
6037
|
};
|
|
4699
6038
|
const positional = [];
|
|
4700
6039
|
for (const arg of argv) {
|
|
6040
|
+
if (arg.startsWith("--changed-since=")) {
|
|
6041
|
+
if (args.changedSince !== null || arg === "--changed-since=") argumentError("--changed-since requires one non-empty reference");
|
|
6042
|
+
args.changedSince = arg.slice("--changed-since=".length);
|
|
6043
|
+
continue;
|
|
6044
|
+
}
|
|
4701
6045
|
const list = (name) => {
|
|
4702
6046
|
if (!arg.startsWith(`${name}=`)) return null;
|
|
4703
6047
|
const value = arg.slice(name.length + 1);
|
|
@@ -4815,6 +6159,8 @@ var HELP = `
|
|
|
4815
6159
|
markers; use with --no-config for untrusted projects
|
|
4816
6160
|
--list-rules List rule IDs, scope, and limits; add --json for structured output
|
|
4817
6161
|
--no-excerpts Omit source excerpts from every report; paths and descriptions remain
|
|
6162
|
+
--changed-since=REF Show changed-file findings since the local merge base;
|
|
6163
|
+
scan scope and exit status remain unchanged
|
|
4818
6164
|
-h, --help Show this help
|
|
4819
6165
|
-v, --version Show version
|
|
4820
6166
|
|
|
@@ -4863,6 +6209,10 @@ async function main() {
|
|
|
4863
6209
|
`);
|
|
4864
6210
|
return finish(3);
|
|
4865
6211
|
}
|
|
6212
|
+
if (args.changedSince !== null && (args.baselineWrite !== null || args.baselineWriteDefault)) {
|
|
6213
|
+
argumentError("--changed-since cannot be combined with --baseline-write");
|
|
6214
|
+
}
|
|
6215
|
+
const changed = args.changedSince === null ? null : changedFilesSince(args.root, args.changedSince);
|
|
4866
6216
|
let config;
|
|
4867
6217
|
try {
|
|
4868
6218
|
config = args.noConfig ? {} : loadConfig(args.root).config;
|
|
@@ -4955,10 +6305,13 @@ ${cleanForOutput(String(err))}
|
|
|
4955
6305
|
}
|
|
4956
6306
|
const displayRoot = cleanForOutput(args.root);
|
|
4957
6307
|
if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
|
|
6308
|
+
const fullResult = result;
|
|
6309
|
+
if (changed) result = changedFileView(result, changed);
|
|
4958
6310
|
const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
|
|
4959
6311
|
const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
|
|
4960
6312
|
if (args.fixPrompt) {
|
|
4961
6313
|
const prompt = renderFixPrompt(shown, {
|
|
6314
|
+
...result.changeView ? { changeView: result.changeView } : {},
|
|
4962
6315
|
partial: result.partial,
|
|
4963
6316
|
filesScanned: result.filesScanned,
|
|
4964
6317
|
hiddenLikely,
|
|
@@ -5065,13 +6418,13 @@ ${cleanForOutput(String(err))}
|
|
|
5065
6418
|
return finish(3);
|
|
5066
6419
|
}
|
|
5067
6420
|
}
|
|
5068
|
-
if (verdictOf(
|
|
5069
|
-
if (
|
|
6421
|
+
if (verdictOf(fullResult.findings).blocking > 0) return finish(1);
|
|
6422
|
+
if (fullResult.findings.length > 0) return finish(2);
|
|
5070
6423
|
if (result.partial && !bestEffort) return finish(3);
|
|
5071
6424
|
return finish(0);
|
|
5072
6425
|
}
|
|
5073
6426
|
main().catch((err) => {
|
|
5074
|
-
if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
|
|
6427
|
+
if (err instanceof ArgumentError || err instanceof ChangeViewError) process.stderr.write(`canship: ${err.message}
|
|
5075
6428
|
`);
|
|
5076
6429
|
else process.stderr.write(`${red("canship: unexpected error")}
|
|
5077
6430
|
${cleanForOutput(String(err))}
|