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/dist/cli.js CHANGED
@@ -12,7 +12,8 @@ var SECRET_PATTERNS = [
12
12
  {
13
13
  id: "openai",
14
14
  name: "OpenAI API key",
15
- pattern: /\bsk-(?!ant-)(?:proj-)?[A-Za-z0-9_-]{20,}\b/g,
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(file) {
1368
- const name = basename3(file.path);
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
- JWT_SHAPED2.lastIndex = 0;
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
- } else {
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 ALLOW_IF_TRUE = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+true\s*;/gi;
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 enclosingMatchBlock(neutralized, index) {
2151
- const matchStart = neutralized.slice(0, index).lastIndexOf("match ");
2152
- if (matchStart < 0) return neutralized;
2153
- const braceStart = neutralized.indexOf("{", matchStart);
2154
- if (braceStart < 0 || braceStart > index) return neutralized;
2155
- let depth = 0;
2156
- for (let i = braceStart; i < neutralized.length; i++) {
2157
- if (neutralized[i] === "{") depth++;
2158
- else if (neutralized[i] === "}") {
2159
- depth--;
2160
- if (depth === 0) return neutralized.slice(braceStart, i + 1);
2161
- }
2162
- }
2163
- return neutralized.slice(braceStart);
2164
- }
2165
- function ownStatements(block) {
2166
- let out = "";
2167
- let i = 0;
2168
- while (i < block.length) {
2169
- const rest = block.slice(i);
2170
- const nested = /^\s*match\s+[^{]*\{/.exec(rest);
2171
- if (nested && i > 0) {
2172
- let depth = 0;
2173
- let j = i + nested[0].length - 1;
2174
- for (; j < block.length; j++) {
2175
- if (block[j] === "{") depth++;
2176
- else if (block[j] === "}") {
2177
- depth--;
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
- out += block[i];
2188
- i++;
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 false;
2756
+ return owners;
2199
2757
  }
2200
- var TEST_MODE = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;/gi;
2758
+ var TEST_MODE = new RegExp(
2759
+ String.raw`\ballow\s+${OPS}\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;`,
2760
+ "gi"
2761
+ );
2201
2762
  function describeOps(raw) {
2202
2763
  const ops = parseOps(raw);
2203
2764
  if (ops.includes("write") && ops.includes("read")) return "read and write";
@@ -2208,9 +2769,10 @@ var firebaseRulesRule = {
2208
2769
  id: "firebase/open-rules",
2209
2770
  severity: "P1",
2210
2771
  appliesTo(file) {
2211
- return isRulesFile(file);
2772
+ return isRulesFile(file) || isRealtimeRulesFile(file);
2212
2773
  },
2213
2774
  check(file, ctx) {
2775
+ if (isRealtimeRulesFile(file)) return checkRealtimeRules(file, ctx);
2214
2776
  const findings = [];
2215
2777
  const product = productOf(file.path);
2216
2778
  const capReached = () => {
@@ -2224,13 +2786,18 @@ var firebaseRulesRule = {
2224
2786
  const content = noiseMaskedOf(file);
2225
2787
  const neutralized = neutralizePathWildcards(content);
2226
2788
  const contentLines = lineStartsOf(content);
2227
- ALLOW_IF_TRUE.lastIndex = 0;
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
- while ((m = ALLOW_IF_TRUE.exec(content)) !== null) {
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 && blockDeniesWrites(ownStatements(enclosingMatchBlock(neutralized, m.index)))) continue;
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 (APP_ROUTER.test(path) || PAGES_ROUTER.test(file.path)) {
2318
- const scope = /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(path)?.[1] ?? "";
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 && ASTRO_EXPORT.test(noiseMaskedOf(file))) {
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 = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
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 statementEnd(code, start, limit, pairs) {
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
- function unguardedOperations(file, ops) {
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 wrappers = [...code.matchAll(/\b(?:withAuth|NextAuth)\s*\(/g)].map((match) => {
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 = statementEnd(code, i + 2, owner.end, pairs) - 1;
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 = statementEnd(code, start, owner.end, pairs);
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 call = AUTH_ENFORCING_CALL.exec(code.slice(i, i + 100));
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) return close2 + 1;
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
- if (wrappers.some((w) => w.start < op.index && w.end > op.index)) return false;
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) return false;
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
- function buildsAdminClient(file) {
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 (!CLIENT_CONSTRUCTOR.test(code)) return false;
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
- function buildsSessionClient(file) {
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 (!CLIENT_CONSTRUCTOR.test(code)) return false;
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
- if (route.framework !== "nuxt") return false;
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.some((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
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 importedModules(file, allFiles, aliasScope) {
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
- found.push(...index.get(target.key) ?? []);
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
- found.push(...index.get(key) ?? []);
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
- if (modules.length === 0) return false;
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 aliasScope = route.scope;
2711
- const queue = importedModules(route.file, allFiles, aliasScope);
2712
- while (queue.length > 0) {
2713
- const file = queue.pop();
2714
- if (targetPaths.has(file.path)) return true;
2715
- if (visited.has(file.path)) continue;
2716
- visited.add(file.path);
2717
- queue.push(...importedModules(file, allFiles, aliasScope));
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 false;
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 = /\bprisma\s*\.\s*\$?(\w+)\s*\.\s*(findMany|findFirst|findUnique|findUniqueOrThrow|create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
2725
- var PRISMA_RAW = /\bprisma\s*\.\s*\$(queryRaw|executeRaw)/g;
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 MIDDLEWARE_FILE = /(?:^|\/)(?:src\/)?middleware\.[mc]?[jt]s$/;
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 middlewareFor(ctx, routePath) {
2773
- let best = null;
3767
+ function middlewaresFor(ctx, routePath, framework) {
3768
+ let best = [];
2774
3769
  let bestDepth = -1;
2775
3770
  for (const file of ctx.files) {
2776
- if (!MIDDLEWARE_FILE.test(file.path)) continue;
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 topLevelMatcherValueStart(objectText) {
2816
- const candidates = [1];
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 < objectText.length; i++) {
2822
- const ch = objectText[i];
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 && parentheses === 0) {
2836
- candidates.push(i + 1);
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
- for (const start of candidates) {
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 property = /^(?:matcher|['"]matcher['"])\s*:\s*/.exec(objectText.slice(cursor));
2843
- if (property) return cursor + property[0].length;
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 = topLevelMatcherValueStart(objectText);
3880
+ const valueStart = topLevelPropertyStart(objectText, "matcher");
2861
3881
  if (valueStart === null) return { kind: "absent" };
2862
3882
  const rest = objectText.slice(valueStart);
2863
- const raw = rest.startsWith("[") ? sliceBracketed(rest) : sliceQuoted(rest);
2864
- if (raw === null) return { kind: "unreadable" };
2865
- const patterns = [...raw.matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]);
2866
- return patterns.length === 0 ? { kind: "unreadable" } : { kind: "patterns", patterns };
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, routePath, url) {
2958
- const mw = middlewareFor(ctx, routePath);
2959
- if (!mw) return false;
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.map(routeOf).filter((route) => route !== null);
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) => MIDDLEWARE_FILE.test(f.path))) {
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 ops = unguardedOperations(route.file, findDataOps(route.file));
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.file.path, url)) continue;
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 line = lineNumberAt(lineStartsOf(route.file.content), hit.index);
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
- title: `${url} writes to your database with no sign-in check`,
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 nearestOrigin(origins, credLine) {
3364
- let best = Infinity;
3365
- let closest = [];
3366
- for (const o of origins) {
3367
- const d = Math.abs(o.line - credLine);
3368
- if (d < best) {
3369
- best = d;
3370
- closest = [o];
3371
- } else if (d === best) {
3372
- closest.push(o);
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
- if (best > PAIRING_DISTANCE || closest.length === 0) return null;
3376
- const kinds = new Set(closest.map((o) => o.kind));
3377
- return kinds.size === 1 ? closest[0] : null;
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
- const candidates = origins.filter((origin2) => origin2.mode === credential.mode && (credential.mode === "header" || scopes.get(origin2.at) === scopes.get(credential.at)));
3395
- const origin = nearestOrigin(candidates, credential.line);
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: certain, minor, unsure } = verdictOf(findings);
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 &mdash; nothing was checked</div>`
4139
5260
  ) : result.partial ? (
4140
5261
  // 扫描不完整时不得显示正常通过。
4141
5262
  hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden, and not everything was checked</div>` : `<div class="verdict warn">No findings &mdash; but not everything was checked</div>`
4142
- ) : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
5263
+ ) : (result.changeView?.hiddenFindings ?? 0) > 0 ? `<div class="verdict warn">No visible findings in changed files &mdash; other findings still exist</div>` : hiddenLikely > 0 ? `<div class="verdict warn">No certain findings &mdash; ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")} hidden</div>` : (
4143
5264
  // 基线抑制结果时明确说明,避免误报为项目无问题。
4144
5265
  baselineSuppressed > 0 ? `<div class="verdict warn">No new findings &mdash; ${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")} &mdash; do not deploy</div>` : minor > 0 ? `<div class="verdict warn">${minor} ${plural(minor, "thing")} to fix &mdash; 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&rsquo;
4167
5288
  sessions, and <code>.env</code> files committed to git.</p>
4168
5289
  <p><strong>A clean result means those checks passed &mdash; 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)} &middot; ${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. Guards in SvelteKit hooks or Nuxt server middleware lower confidence."
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.3.2" : "0.0.0-dev";
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(result.findings).blocking > 0) return finish(1);
5211
- if (result.findings.length > 0) return finish(2);
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))}