canship 0.3.2 → 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 +1404 -193
- 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
|
-
|
|
2189
|
-
}
|
|
2190
|
-
return out;
|
|
2191
|
-
}
|
|
2192
|
-
function blockDeniesWrites(block) {
|
|
2193
|
-
const denial = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+false\s*;/gi;
|
|
2194
|
-
let m;
|
|
2195
|
-
while ((m = denial.exec(block)) !== null) {
|
|
2196
|
-
if (parseOps(m[1] ?? "").some((op) => WRITE_OPS.has(op))) return true;
|
|
2753
|
+
while (active.length && blocks[active[active.length - 1]].close < at) active.pop();
|
|
2754
|
+
owners.push(active.length ? active[active.length - 1] : -1);
|
|
2197
2755
|
}
|
|
2198
|
-
return
|
|
2756
|
+
return owners;
|
|
2199
2757
|
}
|
|
2200
|
-
var TEST_MODE =
|
|
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,9 +2866,59 @@ 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?$/;
|
|
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
|
+
);
|
|
2304
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*\{/;
|
|
2305
2922
|
var NUXT_ROUTE = /^(.*?\/)?server\/(api|routes)\/(.+)\.[mc]?[jt]s$/;
|
|
2306
2923
|
var H3_HANDLER = /\b(?:defineEventHandler|eventHandler|defineCachedEventHandler|defineLazyEventHandler)\s*\(/;
|
|
2307
2924
|
var NUXT_METHOD_SUFFIX = /\.(?:get|post|put|patch|delete|head|options|connect|trace)$/i;
|
|
@@ -2309,17 +2926,154 @@ var REMIX_ROUTE = /^(.*?\/)?app\/routes\/(.+)\.[mc]?[jt]sx?$/;
|
|
|
2309
2926
|
var REMIX_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:loader|action)\b|\bexport\s+const\s+(?:loader|action)\b/;
|
|
2310
2927
|
var ASTRO_ENDPOINT = /^(.*?\/)?src\/pages\/(.+)\.[mc]?[jt]s$/;
|
|
2311
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);
|
|
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;
|
|
2312
3041
|
function routePathOf(path) {
|
|
2313
3042
|
return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
|
|
2314
3043
|
}
|
|
2315
3044
|
function routeOf(file) {
|
|
2316
3045
|
const path = routePathOf(file.path);
|
|
2317
|
-
if (
|
|
2318
|
-
const
|
|
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] ?? "";
|
|
2319
3052
|
return { file, framework: "next", url: nextRouteUrl(path), scope };
|
|
2320
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
|
+
}
|
|
2321
3060
|
const svelte = SVELTEKIT_ENDPOINT.exec(path);
|
|
2322
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
|
+
}
|
|
2323
3077
|
const nuxt = NUXT_ROUTE.exec(path);
|
|
2324
3078
|
if (nuxt && H3_HANDLER.test(noiseMaskedOf(file))) {
|
|
2325
3079
|
const rest = nuxt[3].replace(NUXT_METHOD_SUFFIX, "").replace(/(?:^|\/)index$/, "");
|
|
@@ -2331,14 +3085,21 @@ function routeOf(file) {
|
|
|
2331
3085
|
return { file, framework: "remix", url: remixRouteUrl(remix[2]), scope: remix[1] ?? "" };
|
|
2332
3086
|
}
|
|
2333
3087
|
const astro = ASTRO_ENDPOINT.exec(path);
|
|
2334
|
-
if (astro &&
|
|
3088
|
+
if (astro && isAstroEndpoint(file)) {
|
|
2335
3089
|
const rest = astro[2].replace(/(?:^|\/)index$/, "");
|
|
2336
3090
|
return { file, framework: "astro", url: `/${rest}`, scope: astro[1] ?? "" };
|
|
2337
3091
|
}
|
|
2338
3092
|
return null;
|
|
2339
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 };
|
|
3100
|
+
}
|
|
2340
3101
|
function nextRouteUrl(path) {
|
|
2341
|
-
const m = /(?:^|\/)
|
|
3102
|
+
const m = /(?:^|\/)pages\/(api\/.*)$/.exec(path);
|
|
2342
3103
|
if (!m) return `/${path}`;
|
|
2343
3104
|
const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
|
|
2344
3105
|
return `/${url}`;
|
|
@@ -2354,6 +3115,7 @@ var AUTH_CALLBACK = /^(?:\/api)?\/auth\/callback(?:\/[^/]+)?$/;
|
|
|
2354
3115
|
var PROVIDER_CALLBACK = /^(?:\/api)?\/(?:auth|oauth|login|sign-?in)\/[^/]+\/callback$/;
|
|
2355
3116
|
var OAUTH_HANDLER = /\bdefineOAuth\w*EventHandler\s*\(/;
|
|
2356
3117
|
function isAuthEndpoint(route) {
|
|
3118
|
+
if (route.action !== void 0) return AUTH_ACTION_NAMES.test(route.action);
|
|
2357
3119
|
return AUTH_ENDPOINT_NAMES.test(route.url) || AUTH_CALLBACK.test(route.url) || PROVIDER_CALLBACK.test(route.url) || OAUTH_HANDLER.test(noiseMaskedOf(route.file));
|
|
2358
3120
|
}
|
|
2359
3121
|
var AUTH_ENFORCING_CALL = /\b(?:NextAuth|require(?:Auth|User|Session|Admin)\w*|withAuth|verifyAuth|ensureAuth|assertAuth(?:enticated)?|verifyIdToken|constructEvent)\s*\(/i;
|
|
@@ -2501,7 +3263,7 @@ function functionBodies(code, pairs) {
|
|
|
2501
3263
|
}
|
|
2502
3264
|
return bodies;
|
|
2503
3265
|
}
|
|
2504
|
-
function
|
|
3266
|
+
function statementEnd2(code, start, limit, pairs) {
|
|
2505
3267
|
if (code[start] === "{") return (pairs.get(start) ?? limit) + 1;
|
|
2506
3268
|
for (let i = start; i < limit; i++) {
|
|
2507
3269
|
if (code[i] === ";" || code[i] === "\n") return i + 1;
|
|
@@ -2510,7 +3272,36 @@ function statementEnd(code, start, limit, pairs) {
|
|
|
2510
3272
|
}
|
|
2511
3273
|
return limit;
|
|
2512
3274
|
}
|
|
2513
|
-
|
|
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) {
|
|
2514
3305
|
if (ops.length === 0) return ops;
|
|
2515
3306
|
const code = noiseMaskedOf(file);
|
|
2516
3307
|
const pairs = delimiterPairs(code);
|
|
@@ -2518,12 +3309,18 @@ function unguardedOperations(file, ops) {
|
|
|
2518
3309
|
const declarations = new Map(bodies.map((body) => [body.declaration, body]));
|
|
2519
3310
|
const functionStarts = new Set(bodies.map((body) => body.start));
|
|
2520
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;
|
|
2521
3314
|
const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
|
|
2522
3315
|
const active = [];
|
|
2523
3316
|
let blockIndex = 0;
|
|
2524
|
-
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) => {
|
|
2525
3322
|
const open = match.index + match[0].length - 1;
|
|
2526
|
-
return { start: open, end: pairs.get(open) ?? open };
|
|
3323
|
+
return { start: open, end: pairs.get(open) ?? open, name: match[1] };
|
|
2527
3324
|
});
|
|
2528
3325
|
const guardEnd = (owner) => {
|
|
2529
3326
|
for (let i = owner.start + 1; i < owner.end; i++) {
|
|
@@ -2533,7 +3330,7 @@ function unguardedOperations(file, ops) {
|
|
|
2533
3330
|
continue;
|
|
2534
3331
|
}
|
|
2535
3332
|
if (code.startsWith("=>", i)) {
|
|
2536
|
-
i =
|
|
3333
|
+
i = statementEnd2(code, i + 2, owner.end, pairs) - 1;
|
|
2537
3334
|
continue;
|
|
2538
3335
|
}
|
|
2539
3336
|
if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
|
|
@@ -2544,12 +3341,13 @@ function unguardedOperations(file, ops) {
|
|
|
2544
3341
|
if (close2 === void 0) return Infinity;
|
|
2545
3342
|
let start = close2 + 1;
|
|
2546
3343
|
while (/\s/.test(code[start] ?? "")) start++;
|
|
2547
|
-
const end =
|
|
3344
|
+
const end = statementEnd2(code, start, owner.end, pairs);
|
|
2548
3345
|
if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
|
|
2549
3346
|
i = Math.min(end, owner.end) - 1;
|
|
2550
3347
|
continue;
|
|
2551
3348
|
}
|
|
2552
|
-
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));
|
|
2553
3351
|
if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
|
|
2554
3352
|
const prefixStart = Math.max(
|
|
2555
3353
|
owner.start + 1,
|
|
@@ -2562,7 +3360,10 @@ function unguardedOperations(file, ops) {
|
|
|
2562
3360
|
const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
|
|
2563
3361
|
if (!awaited && !synchronous) continue;
|
|
2564
3362
|
const close2 = pairs.get(i + call[0].length - 1);
|
|
2565
|
-
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
|
+
}
|
|
2566
3367
|
}
|
|
2567
3368
|
const close = pairs.get(i);
|
|
2568
3369
|
if (close !== void 0) i = close;
|
|
@@ -2576,7 +3377,11 @@ function unguardedOperations(file, ops) {
|
|
|
2576
3377
|
active.push(block);
|
|
2577
3378
|
}
|
|
2578
3379
|
while (active.length && active[active.length - 1].end < op.index) active.pop();
|
|
2579
|
-
|
|
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
|
+
}
|
|
2580
3385
|
let ownerIndex = active.length - 1;
|
|
2581
3386
|
while (ownerIndex >= 0 && !functionStarts.has(active[ownerIndex].start)) ownerIndex--;
|
|
2582
3387
|
if (ownerIndex < 0) return true;
|
|
@@ -2587,12 +3392,31 @@ function unguardedOperations(file, ops) {
|
|
|
2587
3392
|
end = guardEnd(block);
|
|
2588
3393
|
guardEnds.set(block.start, end);
|
|
2589
3394
|
}
|
|
2590
|
-
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
|
+
}
|
|
2591
3400
|
}
|
|
2592
3401
|
return true;
|
|
2593
3402
|
});
|
|
2594
3403
|
}
|
|
2595
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
|
+
}
|
|
2596
3420
|
var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
|
|
2597
3421
|
var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
|
|
2598
3422
|
var ENV_BRACKET_ACCESS = /(?:process\.env|import\.meta\.env)\s*\[\s*['"]([^'"]+)['"]\s*\]/g;
|
|
@@ -2607,10 +3431,11 @@ function referencesServiceRole(code, source) {
|
|
|
2607
3431
|
}
|
|
2608
3432
|
var NUXT_SUPABASE_ADMIN = /\bserverSupabaseServiceRole\s*(?:<[^()]{0,200}>\s*)?\(/;
|
|
2609
3433
|
var NUXT_SUPABASE_SESSION = /\bserverSupabaseClient\s*(?:<[^()]{0,200}>\s*)?\(/;
|
|
2610
|
-
|
|
3434
|
+
var buildsAdminClient = cachedByFile(buildsAdminClientUncached);
|
|
3435
|
+
function buildsAdminClientUncached(file) {
|
|
2611
3436
|
const code = noiseMaskedOf(file);
|
|
2612
3437
|
if (NUXT_SUPABASE_ADMIN.test(code)) return true;
|
|
2613
|
-
if (
|
|
3438
|
+
if (clientConstructorAt(file) === void 0) return false;
|
|
2614
3439
|
const source = commentsMaskedOf(file);
|
|
2615
3440
|
if (referencesServiceRole(code, source)) return true;
|
|
2616
3441
|
SERVICE_ROLE_LITERAL.lastIndex = 0;
|
|
@@ -2620,11 +3445,12 @@ function buildsAdminClient(file) {
|
|
|
2620
3445
|
}
|
|
2621
3446
|
return false;
|
|
2622
3447
|
}
|
|
2623
|
-
|
|
3448
|
+
var buildsSessionClient = cachedByFile(buildsSessionClientUncached);
|
|
3449
|
+
function buildsSessionClientUncached(file) {
|
|
2624
3450
|
const code = noiseMaskedOf(file);
|
|
2625
3451
|
if (NUXT_SUPABASE_ADMIN.test(code)) return false;
|
|
2626
3452
|
if (NUXT_SUPABASE_SESSION.test(code)) return true;
|
|
2627
|
-
if (
|
|
3453
|
+
if (clientConstructorAt(file) === void 0) return false;
|
|
2628
3454
|
if (referencesServiceRole(code, commentsMaskedOf(file))) return false;
|
|
2629
3455
|
return /\bcookies\b/.test(code);
|
|
2630
3456
|
}
|
|
@@ -2666,10 +3492,13 @@ function usesSessionClient(route, sessionModules, allFiles) {
|
|
|
2666
3492
|
return buildsSessionClient(route.file) || importsAnyOf(route, sessionModules, allFiles) || autoImportsAnyOf(route, sessionModules);
|
|
2667
3493
|
}
|
|
2668
3494
|
function autoImportsAnyOf(route, modules) {
|
|
2669
|
-
|
|
3495
|
+
return autoImportedModule(route, modules) !== void 0;
|
|
3496
|
+
}
|
|
3497
|
+
function autoImportedModule(route, modules) {
|
|
3498
|
+
if (route.framework !== "nuxt") return void 0;
|
|
2670
3499
|
const utils = `${route.scope}server/utils/`;
|
|
2671
3500
|
const code = noiseMaskedOf(route.file);
|
|
2672
|
-
return modules.
|
|
3501
|
+
return modules.find((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
|
|
2673
3502
|
}
|
|
2674
3503
|
function exportedNames(file) {
|
|
2675
3504
|
const names = noiseMaskedOf(file).matchAll(
|
|
@@ -2677,7 +3506,7 @@ function exportedNames(file) {
|
|
|
2677
3506
|
);
|
|
2678
3507
|
return [...names].map((m) => m[1]);
|
|
2679
3508
|
}
|
|
2680
|
-
function
|
|
3509
|
+
function importedEdges(file, allFiles, aliasScope) {
|
|
2681
3510
|
const found = [];
|
|
2682
3511
|
const source = commentsMaskedOf(file);
|
|
2683
3512
|
const code = noiseMaskedOf(file);
|
|
@@ -2688,8 +3517,11 @@ function importedModules(file, allFiles, aliasScope) {
|
|
|
2688
3517
|
const target = normalizeSpec(m[1], file.path);
|
|
2689
3518
|
if (!target) continue;
|
|
2690
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
|
+
};
|
|
2691
3523
|
if (!target.alias) {
|
|
2692
|
-
|
|
3524
|
+
append(index.get(target.key) ?? []);
|
|
2693
3525
|
} else {
|
|
2694
3526
|
const prefix = aliasScope;
|
|
2695
3527
|
for (const key of [
|
|
@@ -2697,32 +3529,188 @@ function importedModules(file, allFiles, aliasScope) {
|
|
|
2697
3529
|
moduleKey(`${prefix}src/${target.key}`),
|
|
2698
3530
|
moduleKey(`${prefix}app/${target.key}`)
|
|
2699
3531
|
]) {
|
|
2700
|
-
|
|
3532
|
+
append(index.get(key) ?? []);
|
|
2701
3533
|
}
|
|
2702
3534
|
}
|
|
2703
3535
|
}
|
|
2704
3536
|
return found;
|
|
2705
3537
|
}
|
|
3538
|
+
var importsCache = /* @__PURE__ */ new WeakMap();
|
|
2706
3539
|
function importsAnyOf(route, modules, allFiles) {
|
|
2707
|
-
|
|
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) {
|
|
2708
3555
|
const targetPaths = new Set(modules.map((file) => file.path));
|
|
2709
|
-
const visited = /* @__PURE__ */ new Set();
|
|
2710
|
-
const
|
|
2711
|
-
const queue =
|
|
2712
|
-
|
|
2713
|
-
const
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
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
|
+
}
|
|
2718
3575
|
}
|
|
2719
|
-
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;
|
|
2720
3708
|
}
|
|
2721
3709
|
var SUPABASE_TABLE = /\.from\(\s*['"`][^'"`]+['"`]\s*\)\s*\.?\s*(\w+)?/g;
|
|
2722
3710
|
var SUPABASE_ADMIN_API = /\bauth\s*\.\s*admin\s*\.\s*(\w+)\s*\(/g;
|
|
2723
3711
|
var SUPABASE_WRITES = /* @__PURE__ */ new Set(["insert", "update", "upsert", "delete"]);
|
|
2724
|
-
var PRISMA_OP = /\
|
|
2725
|
-
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;
|
|
2726
3714
|
var PRISMA_WRITES = /^(?:create|createMany|update|updateMany|upsert|delete|deleteMany)$/;
|
|
2727
3715
|
var DRIZZLE_OP = /\bdb\s*\.\s*(select|insert|update|delete)\s*\(/g;
|
|
2728
3716
|
var MONGO_OP = /\.(?:deleteMany|deleteOne|updateMany|updateOne|insertMany|insertOne|findOneAndDelete|findOneAndUpdate)\s*\(/g;
|
|
@@ -2765,20 +3753,29 @@ function findDataOps(file) {
|
|
|
2765
3753
|
}
|
|
2766
3754
|
return hits.sort((a, b) => a.index - b.index);
|
|
2767
3755
|
}
|
|
2768
|
-
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
|
+
}
|
|
2769
3764
|
function middlewareScopeOf(path) {
|
|
2770
|
-
return path.replace(/(?:src\/)?middleware\.[mc]?[jt]s$/, "");
|
|
3765
|
+
return path.replace(/(?:src\/)?(?:middleware(?:\/index)?|proxy)\.[mc]?[jt]s$/, "");
|
|
2771
3766
|
}
|
|
2772
|
-
function
|
|
2773
|
-
let best =
|
|
3767
|
+
function middlewaresFor(ctx, routePath, framework) {
|
|
3768
|
+
let best = [];
|
|
2774
3769
|
let bestDepth = -1;
|
|
2775
3770
|
for (const file of ctx.files) {
|
|
2776
|
-
if (!
|
|
3771
|
+
if (!isMiddlewareFile(file.path, framework)) continue;
|
|
2777
3772
|
const scope = middlewareScopeOf(file.path);
|
|
2778
3773
|
if (!routePath.startsWith(scope)) continue;
|
|
2779
3774
|
if (scope.length > bestDepth) {
|
|
2780
|
-
best = file;
|
|
3775
|
+
best = [file];
|
|
2781
3776
|
bestDepth = scope.length;
|
|
3777
|
+
} else if (scope.length === bestDepth) {
|
|
3778
|
+
best.push(file);
|
|
2782
3779
|
}
|
|
2783
3780
|
}
|
|
2784
3781
|
return best;
|
|
@@ -2812,14 +3809,14 @@ function sliceQuoted(text) {
|
|
|
2812
3809
|
}
|
|
2813
3810
|
return null;
|
|
2814
3811
|
}
|
|
2815
|
-
function
|
|
2816
|
-
const
|
|
3812
|
+
function topLevelMemberStarts(text, container) {
|
|
3813
|
+
const starts = [1];
|
|
2817
3814
|
let quote = null;
|
|
2818
3815
|
let braces = 0;
|
|
2819
3816
|
let brackets = 0;
|
|
2820
3817
|
let parentheses = 0;
|
|
2821
|
-
for (let i = 0; i <
|
|
2822
|
-
const ch =
|
|
3818
|
+
for (let i = 0; i < text.length; i++) {
|
|
3819
|
+
const ch = text[i];
|
|
2823
3820
|
if (quote !== null) {
|
|
2824
3821
|
if (ch === "\\") i++;
|
|
2825
3822
|
else if (ch === quote) quote = null;
|
|
@@ -2832,18 +3829,41 @@ function topLevelMatcherValueStart(objectText) {
|
|
|
2832
3829
|
else if (ch === "]") brackets--;
|
|
2833
3830
|
else if (ch === "(") parentheses++;
|
|
2834
3831
|
else if (ch === ")") parentheses--;
|
|
2835
|
-
else if (ch === "," && braces === 1 && brackets === 0 &&
|
|
2836
|
-
|
|
3832
|
+
else if (ch === "," && parentheses === 0 && (container === "{" ? braces === 1 && brackets === 0 : brackets === 1 && braces === 0)) {
|
|
3833
|
+
starts.push(i + 1);
|
|
2837
3834
|
}
|
|
2838
3835
|
}
|
|
2839
|
-
|
|
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, "{")) {
|
|
2840
3841
|
let cursor = start;
|
|
2841
3842
|
while (/\s/.test(objectText[cursor] ?? "")) cursor++;
|
|
2842
|
-
const
|
|
2843
|
-
if (
|
|
3843
|
+
const match = property.exec(objectText.slice(cursor));
|
|
3844
|
+
if (match) return cursor + match[0].length;
|
|
2844
3845
|
}
|
|
2845
3846
|
return null;
|
|
2846
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
|
+
}
|
|
2847
3867
|
function extractMatcherConfig(file) {
|
|
2848
3868
|
const code = noiseMaskedOf(file);
|
|
2849
3869
|
const declaration = /\bexport\s+const\s+config\b/.exec(code);
|
|
@@ -2857,13 +3877,28 @@ function extractMatcherConfig(file) {
|
|
|
2857
3877
|
const masked = commentsMaskedOf(file);
|
|
2858
3878
|
const objectText = sliceDelimited(masked.slice(objectStart), "{", "}");
|
|
2859
3879
|
if (objectText === null) return { kind: "unreadable" };
|
|
2860
|
-
const valueStart =
|
|
3880
|
+
const valueStart = topLevelPropertyStart(objectText, "matcher");
|
|
2861
3881
|
if (valueStart === null) return { kind: "absent" };
|
|
2862
3882
|
const rest = objectText.slice(valueStart);
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
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 };
|
|
2867
3902
|
}
|
|
2868
3903
|
var MAX_MATCHER_LENGTH = 300;
|
|
2869
3904
|
function withoutGroupPrefix(body) {
|
|
@@ -2954,9 +3989,10 @@ function matcherToRegex(pattern) {
|
|
|
2954
3989
|
return null;
|
|
2955
3990
|
}
|
|
2956
3991
|
}
|
|
2957
|
-
function middlewareCovers(ctx,
|
|
2958
|
-
|
|
2959
|
-
|
|
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) {
|
|
2960
3996
|
if (!hasAuthSignal(mw)) return false;
|
|
2961
3997
|
const { config, readable } = compiledMatchersOf(mw);
|
|
2962
3998
|
if (config.kind === "absent") return true;
|
|
@@ -2967,6 +4003,7 @@ function middlewareCovers(ctx, routePath, url) {
|
|
|
2967
4003
|
);
|
|
2968
4004
|
return true;
|
|
2969
4005
|
}
|
|
4006
|
+
if (config.patterns.length === 0) return false;
|
|
2970
4007
|
if (readable.length === 0) return true;
|
|
2971
4008
|
return readable.some((re) => re.test(url));
|
|
2972
4009
|
}
|
|
@@ -3015,9 +4052,12 @@ var apiAuthRule = {
|
|
|
3015
4052
|
id: "api/db-access-without-auth",
|
|
3016
4053
|
severity: "P0",
|
|
3017
4054
|
check(ctx) {
|
|
3018
|
-
const routes = ctx.files.
|
|
4055
|
+
const routes = ctx.files.flatMap((file) => {
|
|
4056
|
+
const route = routeOf(file);
|
|
4057
|
+
return route === null ? serverActionRoutes(file) : [route];
|
|
4058
|
+
});
|
|
3019
4059
|
if (routes.length === 0) return [];
|
|
3020
|
-
for (const middlewareFile of ctx.files.filter((f) =>
|
|
4060
|
+
for (const middlewareFile of ctx.files.filter((f) => isAnyMiddlewareFile(f.path))) {
|
|
3021
4061
|
const config = extractMatcherConfig(middlewareFile);
|
|
3022
4062
|
const refused = config.kind === "patterns" ? config.patterns.filter((p) => matcherToRegex(p) === null) : [];
|
|
3023
4063
|
if (refused.length > 0) {
|
|
@@ -3030,34 +4070,54 @@ var apiAuthRule = {
|
|
|
3030
4070
|
const adminModules = ctx.files.filter(buildsAdminClient);
|
|
3031
4071
|
const sessionModules = ctx.files.filter(buildsSessionClient);
|
|
3032
4072
|
const findings = [];
|
|
4073
|
+
const indirectByFile = /* @__PURE__ */ new Map();
|
|
3033
4074
|
for (const route of routes) {
|
|
3034
|
-
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);
|
|
3035
4077
|
if (ops.length === 0) continue;
|
|
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;
|
|
3036
4085
|
const url = route.url;
|
|
3037
4086
|
if (isAuthEndpoint(route)) continue;
|
|
3038
|
-
if ((route.framework === "next" || route.framework === "astro") && middlewareCovers(ctx, route
|
|
4087
|
+
if ((route.framework === "next" || route.framework === "astro") && route.action === void 0 && middlewareCovers(ctx, route)) continue;
|
|
3039
4088
|
const globalGuard = globalGuardFor(ctx, route);
|
|
3040
4089
|
const globalNote = globalGuard === null ? [] : [
|
|
3041
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}.`
|
|
3042
4091
|
];
|
|
3043
4092
|
const admin = usesAdminClient(route, adminModules, ctx.files);
|
|
3044
4093
|
const hit = ops.find((o) => o.writes) ?? ops[0];
|
|
3045
|
-
const
|
|
4094
|
+
const indirect = indirectOps.get(hit.index);
|
|
4095
|
+
const line = lineNumberAt(lineStartsCached(route.file), hit.index);
|
|
3046
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.");
|
|
3047
4104
|
if (admin) {
|
|
4105
|
+
const trace = adminEvidence(route, adminModules, ctx.files, line);
|
|
3048
4106
|
findings.push({
|
|
3049
4107
|
ruleId: "api/admin-db-access-without-auth",
|
|
4108
|
+
...trace,
|
|
4109
|
+
evidence: [...trace.evidence ?? [], ...indirectEvidence],
|
|
3050
4110
|
severity: "P0",
|
|
3051
4111
|
// 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
|
|
3052
|
-
confidence: globalGuard === null ? "certain" : "likely",
|
|
3053
|
-
title: `Anyone can call ${url} and it queries your database as admin`,
|
|
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`,
|
|
3054
4114
|
file: route.file.path,
|
|
3055
4115
|
line,
|
|
3056
4116
|
excerpt,
|
|
3057
4117
|
why: [
|
|
3058
4118
|
...globalNote,
|
|
3059
4119
|
`This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
|
|
3060
|
-
`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.`,
|
|
3061
4121
|
`If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
|
|
3062
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.`
|
|
3063
4123
|
],
|
|
@@ -3079,13 +4139,20 @@ var apiAuthRule = {
|
|
|
3079
4139
|
severity: "P1",
|
|
3080
4140
|
// 公开写入可能是业务设计,保留疑似置信度。
|
|
3081
4141
|
confidence: "likely",
|
|
3082
|
-
|
|
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`,
|
|
3083
4144
|
file: route.file.path,
|
|
3084
4145
|
line,
|
|
3085
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] } : {},
|
|
3086
4153
|
why: [
|
|
3087
4154
|
...globalNote,
|
|
3088
|
-
`This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
|
|
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.`,
|
|
3089
4156
|
`If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
|
|
3090
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.`
|
|
3091
4158
|
],
|
|
@@ -3360,21 +4427,37 @@ function collectCredentialLines(file) {
|
|
|
3360
4427
|
}
|
|
3361
4428
|
return lines;
|
|
3362
4429
|
}
|
|
3363
|
-
function
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
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
|
+
}
|
|
3373
4441
|
}
|
|
3374
4442
|
}
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
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;
|
|
3378
4461
|
}
|
|
3379
4462
|
var corsRule = {
|
|
3380
4463
|
id: "cors/credentialed-cross-origin",
|
|
@@ -3390,9 +4473,17 @@ var corsRule = {
|
|
|
3390
4473
|
const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
|
|
3391
4474
|
const findings = [];
|
|
3392
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)]));
|
|
3393
4484
|
for (const credential of credentialLines) {
|
|
3394
|
-
|
|
3395
|
-
const origin = nearestOrigin(
|
|
4485
|
+
if (reported.size === 2) break;
|
|
4486
|
+
const origin = nearestOrigin(indexes.get(groupOf(credential)) ?? [], credential.line);
|
|
3396
4487
|
if (!origin) continue;
|
|
3397
4488
|
if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
|
|
3398
4489
|
if (reported.has(origin.kind)) continue;
|
|
@@ -3461,6 +4552,8 @@ var RULE_IDS = [
|
|
|
3461
4552
|
"firebase/test-mode-rules",
|
|
3462
4553
|
"gitleak/env-in-history",
|
|
3463
4554
|
"gitleak/env-tracked",
|
|
4555
|
+
"supabase/permissive-policy",
|
|
4556
|
+
"supabase/public-bucket-listing",
|
|
3464
4557
|
"supabase/rls-not-enabled",
|
|
3465
4558
|
// 凭据规则 ID 从共享格式表生成。
|
|
3466
4559
|
...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
|
|
@@ -3526,6 +4619,15 @@ function sanitize(findings, files) {
|
|
|
3526
4619
|
file: f.file === null ? null : clean(f.file),
|
|
3527
4620
|
// 先脱敏再截断,避免截断导致凭据特征失效。
|
|
3528
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
|
+
} : {},
|
|
3529
4631
|
fix: f.fix.map(clean),
|
|
3530
4632
|
...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
|
|
3531
4633
|
}));
|
|
@@ -3694,6 +4796,9 @@ var gray = wrap(90, 39);
|
|
|
3694
4796
|
var BLOCKING = /* @__PURE__ */ new Set(["P0", "P1"]);
|
|
3695
4797
|
|
|
3696
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
|
+
}
|
|
3697
4802
|
function plural(n, word) {
|
|
3698
4803
|
return n === 1 ? word : `${word}s`;
|
|
3699
4804
|
}
|
|
@@ -3753,12 +4858,14 @@ function renderReport(result, opts) {
|
|
|
3753
4858
|
`${INDENT}${bold("canship")} ${dim(`scanned ${result.filesScanned} ${plural(result.filesScanned, "file")} in ${result.durationMs}ms`)}`
|
|
3754
4859
|
);
|
|
3755
4860
|
out.push(`${INDENT}${dim(opts.root)}`);
|
|
4861
|
+
if (result.changeView) out.push(`${INDENT}${yellow(changeViewNotice(result.changeView))}`);
|
|
3756
4862
|
out.push("");
|
|
3757
4863
|
if (findings.length === 0) {
|
|
3758
4864
|
out.push(...renderClean(result, opts));
|
|
3759
4865
|
return out.join("\n");
|
|
3760
4866
|
}
|
|
3761
|
-
const { blocking, minor: confirmedMinor, unsure } = verdictOf(findings);
|
|
4867
|
+
const { blocking: visibleBlocking, minor: confirmedMinor, unsure } = verdictOf(findings);
|
|
4868
|
+
const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
|
|
3762
4869
|
if (blocking > 0) {
|
|
3763
4870
|
out.push(`${INDENT}${red(bold(`\u2717 ${blocking} critical ${plural(blocking, "issue")} \u2014 do not deploy`))}`);
|
|
3764
4871
|
} else if (confirmedMinor > 0) {
|
|
@@ -3800,6 +4907,11 @@ function renderFinding(f, index) {
|
|
|
3800
4907
|
out.push("");
|
|
3801
4908
|
out.push(`${INDENT}${INDENT}${gray(f.excerpt)}`);
|
|
3802
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
|
+
}
|
|
3803
4915
|
out.push("");
|
|
3804
4916
|
for (const line of wrapText(f.why.join("\n\n"), 76)) {
|
|
3805
4917
|
out.push(line === "" ? "" : `${INDENT}${INDENT}${line}`);
|
|
@@ -3854,6 +4966,8 @@ function renderClean(result, opts) {
|
|
|
3854
4966
|
if (result.partial) {
|
|
3855
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";
|
|
3856
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"))}`);
|
|
3857
4971
|
} else if (opts.hiddenLikely > 0) {
|
|
3858
4972
|
out.push(
|
|
3859
4973
|
`${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
|
|
@@ -3874,9 +4988,9 @@ function renderClean(result, opts) {
|
|
|
3874
4988
|
out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
|
|
3875
4989
|
out.push(`${INDENT}${dim(" \xB7 Supabase service_role keys reachable from the client")}`);
|
|
3876
4990
|
out.push(`${INDENT}${dim(" \xB7 .env files committed to git, including in history")}`);
|
|
3877
|
-
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")}`);
|
|
3878
4992
|
out.push(`${INDENT}${dim(" \xB7 Firebase rules left open to anyone")}`);
|
|
3879
|
-
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")}`);
|
|
3880
4994
|
out.push(`${INDENT}${dim(" \xB7 CORS that lets other sites act as your signed-in visitors")}`);
|
|
3881
4995
|
out.push("");
|
|
3882
4996
|
out.push(`${INDENT}${dim("It does not check rate limiting, injection, or whether the checks it")}`);
|
|
@@ -4013,8 +5127,12 @@ function renderInstruction(f, index) {
|
|
|
4013
5127
|
const location = defuseMarkers(locationOf(f));
|
|
4014
5128
|
lines.push(`${index}. ${location} \u2014 ${defuseMarkers(f.title)}`);
|
|
4015
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.");
|
|
4016
5134
|
for (const step of f.fix) {
|
|
4017
|
-
lines.push(` - ${step}`);
|
|
5135
|
+
lines.push(` - ${defuseMarkers(step)}`);
|
|
4018
5136
|
}
|
|
4019
5137
|
return lines.join("\n");
|
|
4020
5138
|
}
|
|
@@ -4025,6 +5143,7 @@ function renderFixPrompt(findings, ctx) {
|
|
|
4025
5143
|
const baselineSuppressed = ctx?.baselineSuppressed ?? 0;
|
|
4026
5144
|
const silenced = ctx?.silenced ?? [];
|
|
4027
5145
|
const suppressedNotes = [
|
|
5146
|
+
ctx?.changeView ? changeViewNotice(ctx.changeView) : null,
|
|
4028
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.`,
|
|
4029
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.`,
|
|
4030
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.`,
|
|
@@ -4088,7 +5207,7 @@ function renderFixPrompt(findings, ctx) {
|
|
|
4088
5207
|
for (const { step } of humanSteps) {
|
|
4089
5208
|
if (seen.has(step)) continue;
|
|
4090
5209
|
seen.add(step);
|
|
4091
|
-
out.push(`- ${step}`);
|
|
5210
|
+
out.push(`- ${defuseMarkers(step)}`);
|
|
4092
5211
|
}
|
|
4093
5212
|
out.push("");
|
|
4094
5213
|
out.push("Until these are done, the exposure is still live \u2014 the code fix alone does not close it.");
|
|
@@ -4122,6 +5241,7 @@ function renderFinding2(f, index) {
|
|
|
4122
5241
|
</header>
|
|
4123
5242
|
<div class="loc">${esc(location)}${f.confidence === "likely" ? ' <span class="tag">lower confidence</span>' : ""}</div>
|
|
4124
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>" : ""}` : ""}
|
|
4125
5245
|
<div class="why">${linkify(paragraphs(f.why))}</div>
|
|
4126
5246
|
${fixList}
|
|
4127
5247
|
${humanList}
|
|
@@ -4132,14 +5252,15 @@ function renderHtml(result, opts) {
|
|
|
4132
5252
|
const hiddenLikely = opts.hiddenLikely ?? 0;
|
|
4133
5253
|
const baselineSuppressed = opts.baselineSuppressed ?? 0;
|
|
4134
5254
|
const baselineStale = opts.baselineStale ?? 0;
|
|
4135
|
-
const { blocking
|
|
5255
|
+
const { blocking, minor, unsure } = verdictOf(findings);
|
|
5256
|
+
const certain = result.changeView?.totalBlocking ?? blocking;
|
|
4136
5257
|
const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
|
|
4137
5258
|
// 未扫描文件时不显示通过结论。
|
|
4138
5259
|
`<div class="verdict warn">No files were scanned — nothing was checked</div>`
|
|
4139
5260
|
) : result.partial ? (
|
|
4140
5261
|
// 扫描不完整时不得显示正常通过。
|
|
4141
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>`
|
|
4142
|
-
) : 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>` : (
|
|
4143
5264
|
// 基线抑制结果时明确说明,避免误报为项目无问题。
|
|
4144
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>`
|
|
4145
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>`;
|
|
@@ -4152,7 +5273,7 @@ function renderHtml(result, opts) {
|
|
|
4152
5273
|
gitignored or build output that canship skips. If the project lives in a
|
|
4153
5274
|
subdirectory, point canship at it: <code>npx canship ./app</code>.</p>
|
|
4154
5275
|
</div>`
|
|
4155
|
-
) : 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">
|
|
4156
5277
|
<p><strong>This is not a finding-free result.</strong> The default report hides
|
|
4157
5278
|
${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
|
|
4158
5279
|
<p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
|
|
@@ -4162,7 +5283,7 @@ function renderHtml(result, opts) {
|
|
|
4162
5283
|
<p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
|
|
4163
5284
|
</div>` : `<div class="clean-note">
|
|
4164
5285
|
<p>canship checked for hardcoded API keys, server secrets exposed to the browser,
|
|
4165
|
-
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
|
|
4166
5287
|
the database with no sign-in check, CORS that lets other sites use your visitors’
|
|
4167
5288
|
sessions, and <code>.env</code> files committed to git.</p>
|
|
4168
5289
|
<p><strong>A clean result means those checks passed — not that your app is secure.</strong>
|
|
@@ -4265,6 +5386,7 @@ function renderHtml(result, opts) {
|
|
|
4265
5386
|
<h1>canship report</h1>
|
|
4266
5387
|
<div class="meta">${esc(opts.root)}<br>${esc(opts.generatedAt)} · ${result.filesScanned} ${plural(result.filesScanned, "file")} scanned in ${result.durationMs}ms</div>
|
|
4267
5388
|
${verdict}
|
|
5389
|
+
${result.changeView ? `<p class="opted-out">${esc(changeViewNotice(result.changeView))}</p>` : ""}
|
|
4268
5390
|
<div class="notice">
|
|
4269
5391
|
Credential values canship recognises are masked in this report. One in a format it has no
|
|
4270
5392
|
pattern for can still appear inside a quoted line, and this report lists your
|
|
@@ -4410,6 +5532,7 @@ function applyBaseline(findings, baseline) {
|
|
|
4410
5532
|
var INFORMATION_URI = "https://github.com/Tasomei/canship";
|
|
4411
5533
|
function suppressionNotes(result, opts) {
|
|
4412
5534
|
const notes = [];
|
|
5535
|
+
if (result.changeView) notes.push({ level: "warning", message: { text: changeViewNotice(result.changeView) } });
|
|
4413
5536
|
const baseline = opts.baselineSuppressed ?? 0;
|
|
4414
5537
|
const hidden = opts.hiddenLikely ?? 0;
|
|
4415
5538
|
if (baseline > 0) {
|
|
@@ -4487,7 +5610,18 @@ function resultsOf(findings) {
|
|
|
4487
5610
|
}
|
|
4488
5611
|
}
|
|
4489
5612
|
],
|
|
4490
|
-
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
|
+
} : {}
|
|
4491
5625
|
}));
|
|
4492
5626
|
}
|
|
4493
5627
|
function renderSarif(result, opts) {
|
|
@@ -4550,7 +5684,8 @@ function createJsonReport(result, options) {
|
|
|
4550
5684
|
baselineSuppressed: options.baselineSuppressed,
|
|
4551
5685
|
baselineStale: options.baselineStale,
|
|
4552
5686
|
excerptsOmitted: options.excerptsOmitted ?? false,
|
|
4553
|
-
findings: result.findings
|
|
5687
|
+
findings: result.findings,
|
|
5688
|
+
...result.changeView ? { changeView: result.changeView } : {}
|
|
4554
5689
|
};
|
|
4555
5690
|
}
|
|
4556
5691
|
|
|
@@ -4652,15 +5787,15 @@ var RULE_CATALOG = [
|
|
|
4652
5787
|
"Admin database access without a recognized auth guard",
|
|
4653
5788
|
"P0",
|
|
4654
5789
|
"certain",
|
|
4655
|
-
"Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro, including workspace applications.",
|
|
4656
|
-
"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."
|
|
4657
5792
|
),
|
|
4658
5793
|
rule(
|
|
4659
5794
|
"api/db-write-without-auth",
|
|
4660
5795
|
"Database write without a recognized auth guard",
|
|
4661
5796
|
"P1",
|
|
4662
5797
|
"likely",
|
|
4663
|
-
"Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro.",
|
|
5798
|
+
"Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
|
|
4664
5799
|
"Session-scoped clients and indirect guards may need manual review."
|
|
4665
5800
|
),
|
|
4666
5801
|
rule(
|
|
@@ -4700,7 +5835,7 @@ var RULE_CATALOG = [
|
|
|
4700
5835
|
"Supabase admin credential exposed in source or public env",
|
|
4701
5836
|
"P0",
|
|
4702
5837
|
"certain",
|
|
4703
|
-
"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.",
|
|
4704
5839
|
"Source presence is observable; actual browser delivery and key validity are not verified."
|
|
4705
5840
|
),
|
|
4706
5841
|
rule(
|
|
@@ -4708,7 +5843,7 @@ var RULE_CATALOG = [
|
|
|
4708
5843
|
"Unconditional Firebase access",
|
|
4709
5844
|
"P1",
|
|
4710
5845
|
"varies",
|
|
4711
|
-
"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.",
|
|
4712
5847
|
"Public reads may be intentional; runtime rules and business authorization are not verified."
|
|
4713
5848
|
),
|
|
4714
5849
|
rule(
|
|
@@ -4735,6 +5870,22 @@ var RULE_CATALOG = [
|
|
|
4735
5870
|
"Tracked environment files; templates and public-only values are treated separately.",
|
|
4736
5871
|
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
4737
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
|
+
),
|
|
4738
5889
|
rule(
|
|
4739
5890
|
"supabase/rls-not-enabled",
|
|
4740
5891
|
"Table without RLS in migrations",
|
|
@@ -4766,8 +5917,53 @@ function renderRuleCatalog() {
|
|
|
4766
5917
|
).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
|
|
4767
5918
|
}
|
|
4768
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
|
+
|
|
4769
5965
|
// src/cli.ts
|
|
4770
|
-
var VERSION = true ? "0.
|
|
5966
|
+
var VERSION = true ? "0.4.0" : "0.0.0-dev";
|
|
4771
5967
|
function finish(code) {
|
|
4772
5968
|
process.exitCode = code;
|
|
4773
5969
|
}
|
|
@@ -4836,10 +6032,16 @@ function parseArgs(argv) {
|
|
|
4836
6032
|
help: false,
|
|
4837
6033
|
version: false,
|
|
4838
6034
|
listRules: false,
|
|
4839
|
-
noExcerpts: false
|
|
6035
|
+
noExcerpts: false,
|
|
6036
|
+
changedSince: null
|
|
4840
6037
|
};
|
|
4841
6038
|
const positional = [];
|
|
4842
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
|
+
}
|
|
4843
6045
|
const list = (name) => {
|
|
4844
6046
|
if (!arg.startsWith(`${name}=`)) return null;
|
|
4845
6047
|
const value = arg.slice(name.length + 1);
|
|
@@ -4957,6 +6159,8 @@ var HELP = `
|
|
|
4957
6159
|
markers; use with --no-config for untrusted projects
|
|
4958
6160
|
--list-rules List rule IDs, scope, and limits; add --json for structured output
|
|
4959
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
|
|
4960
6164
|
-h, --help Show this help
|
|
4961
6165
|
-v, --version Show version
|
|
4962
6166
|
|
|
@@ -5005,6 +6209,10 @@ async function main() {
|
|
|
5005
6209
|
`);
|
|
5006
6210
|
return finish(3);
|
|
5007
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);
|
|
5008
6216
|
let config;
|
|
5009
6217
|
try {
|
|
5010
6218
|
config = args.noConfig ? {} : loadConfig(args.root).config;
|
|
@@ -5097,10 +6305,13 @@ ${cleanForOutput(String(err))}
|
|
|
5097
6305
|
}
|
|
5098
6306
|
const displayRoot = cleanForOutput(args.root);
|
|
5099
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);
|
|
5100
6310
|
const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
|
|
5101
6311
|
const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
|
|
5102
6312
|
if (args.fixPrompt) {
|
|
5103
6313
|
const prompt = renderFixPrompt(shown, {
|
|
6314
|
+
...result.changeView ? { changeView: result.changeView } : {},
|
|
5104
6315
|
partial: result.partial,
|
|
5105
6316
|
filesScanned: result.filesScanned,
|
|
5106
6317
|
hiddenLikely,
|
|
@@ -5207,13 +6418,13 @@ ${cleanForOutput(String(err))}
|
|
|
5207
6418
|
return finish(3);
|
|
5208
6419
|
}
|
|
5209
6420
|
}
|
|
5210
|
-
if (verdictOf(
|
|
5211
|
-
if (
|
|
6421
|
+
if (verdictOf(fullResult.findings).blocking > 0) return finish(1);
|
|
6422
|
+
if (fullResult.findings.length > 0) return finish(2);
|
|
5212
6423
|
if (result.partial && !bestEffort) return finish(3);
|
|
5213
6424
|
return finish(0);
|
|
5214
6425
|
}
|
|
5215
6426
|
main().catch((err) => {
|
|
5216
|
-
if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
|
|
6427
|
+
if (err instanceof ArgumentError || err instanceof ChangeViewError) process.stderr.write(`canship: ${err.message}
|
|
5217
6428
|
`);
|
|
5218
6429
|
else process.stderr.write(`${red("canship: unexpected error")}
|
|
5219
6430
|
${cleanForOutput(String(err))}
|