canship 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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++;
2753
+ while (active.length && blocks[active[active.length - 1]].close < at) active.pop();
2754
+ owners.push(active.length ? active[active.length - 1] : -1);
2189
2755
  }
2190
- return out;
2756
+ return owners;
2191
2757
  }
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;
2197
- }
2198
- return false;
2199
- }
2200
- var TEST_MODE = /\ballow\s+([a-z,\s]+?)\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;/gi;
2758
+ var TEST_MODE = new RegExp(
2759
+ String.raw`\ballow\s+${OPS}\s*:\s*if\s+request\.time\s*<\s*timestamp\.date\(\s*(\d{4})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\)\s*;`,
2760
+ "gi"
2761
+ );
2201
2762
  function describeOps(raw) {
2202
2763
  const ops = parseOps(raw);
2203
2764
  if (ops.includes("write") && ops.includes("read")) return "read and write";
@@ -2208,9 +2769,10 @@ var firebaseRulesRule = {
2208
2769
  id: "firebase/open-rules",
2209
2770
  severity: "P1",
2210
2771
  appliesTo(file) {
2211
- return isRulesFile(file);
2772
+ return isRulesFile(file) || isRealtimeRulesFile(file);
2212
2773
  },
2213
2774
  check(file, ctx) {
2775
+ if (isRealtimeRulesFile(file)) return checkRealtimeRules(file, ctx);
2214
2776
  const findings = [];
2215
2777
  const product = productOf(file.path);
2216
2778
  const capReached = () => {
@@ -2224,13 +2786,18 @@ var firebaseRulesRule = {
2224
2786
  const content = noiseMaskedOf(file);
2225
2787
  const neutralized = neutralizePathWildcards(content);
2226
2788
  const contentLines = lineStartsOf(content);
2227
- 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,27 +2866,266 @@ var firebaseRulesRule = {
2299
2866
 
2300
2867
  // src/rules/apiauth.ts
2301
2868
  import { posix } from "path";
2869
+
2870
+ // src/rules/bindings.ts
2871
+ var cache = /* @__PURE__ */ new WeakMap();
2872
+ function bindingsOf(file) {
2873
+ const cached = cache.get(file);
2874
+ if (cached) return cached;
2875
+ const source = commentsMaskedOf(file);
2876
+ const code = noiseMaskedOf(file);
2877
+ const result = { imports: [], exports: [], stars: [] };
2878
+ const lists = /\b(import|export)\s+(type\s+)?\{([^{}]{0,4000})\}(?:\s*from\s*['"]([^'"\r\n]{1,1024})['"])?/g;
2879
+ for (const match of source.matchAll(lists)) {
2880
+ if (match[2] || code.slice(match.index, match.index + match[1].length) !== match[1]) continue;
2881
+ if (match[1] === "import" && !match[4]) continue;
2882
+ for (const part of match[3].split(",")) {
2883
+ const names = /^\s*([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?\s*$/.exec(part);
2884
+ if (!names) continue;
2885
+ const imported = names[1];
2886
+ const local = names[2] ?? imported;
2887
+ const target = match[1] === "import" ? result.imports : result.exports;
2888
+ target.push({
2889
+ imported,
2890
+ local,
2891
+ ...match[4] ? { spec: match[4] } : {}
2892
+ });
2893
+ }
2894
+ }
2895
+ for (const match of source.matchAll(/\bimport\s+([A-Za-z_$][\w$]*)\s+from\s*['"]([^'"\r\n]{1,1024})['"]/g)) {
2896
+ if (code.slice(match.index, match.index + 6) === "import" && match[1] !== "type") {
2897
+ result.imports.push({ local: match[1], imported: "default", spec: match[2] });
2898
+ }
2899
+ }
2900
+ for (const match of source.matchAll(/\bexport\s*\*\s*from\s*['"]([^'"\r\n]{1,1024})['"]/g)) {
2901
+ if (code.slice(match.index, match.index + 6) === "export") result.stars.push(match[1]);
2902
+ }
2903
+ cache.set(file, result);
2904
+ return result;
2905
+ }
2906
+ function namePattern(names) {
2907
+ return [...names].map((name) => name.replace(/\$/g, "\\$")).join("|");
2908
+ }
2909
+
2910
+ // src/rules/apiauth.ts
2302
2911
  var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2303
2912
  var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
2304
- function isApiRoute(path) {
2305
- return APP_ROUTER.test(routePathOf(path)) || PAGES_ROUTER.test(path);
2913
+ var APP_ROUTE_FILE = /(?:^|\/)app\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2914
+ var NEXT_METHODS = "GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS";
2915
+ var NEXT_METHOD_EXPORT = new RegExp(
2916
+ String.raw`\bexport\s+(?:async\s+)?function\s+(?:${NEXT_METHODS})\b|\bexport\s+const\s+(?:${NEXT_METHODS})\b|` + // export { handler as GET } 与 Auth.js 的 export const { GET, POST } = handlers;长度有界以免回溯。
2917
+ String.raw`\bexport\s+(?:const\s*)?\{[^{}]{0,500}\b(?:${NEXT_METHODS})\b`
2918
+ );
2919
+ var SVELTEKIT_ENDPOINT = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+server\.[mc]?[jt]s$/;
2920
+ var SVELTEKIT_PAGE_SERVER = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+page\.server\.[mc]?[jt]s$/;
2921
+ var SVELTEKIT_ACTIONS = /\bexport\s+const\s+actions\b\s*(?::[^=;{}]{0,200})?=\s*\{/;
2922
+ var NUXT_ROUTE = /^(.*?\/)?server\/(api|routes)\/(.+)\.[mc]?[jt]s$/;
2923
+ var H3_HANDLER = /\b(?:defineEventHandler|eventHandler|defineCachedEventHandler|defineLazyEventHandler)\s*\(/;
2924
+ var NUXT_METHOD_SUFFIX = /\.(?:get|post|put|patch|delete|head|options|connect|trace)$/i;
2925
+ var REMIX_ROUTE = /^(.*?\/)?app\/routes\/(.+)\.[mc]?[jt]sx?$/;
2926
+ var REMIX_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:loader|action)\b|\bexport\s+const\s+(?:loader|action)\b/;
2927
+ var ASTRO_ENDPOINT = /^(.*?\/)?src\/pages\/(.+)\.[mc]?[jt]s$/;
2928
+ var ASTRO_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b|\bexport\s+const\s+(?:GET|POST|PUT|PATCH|DELETE|ALL)\b/;
2929
+ function isAstroEndpoint(file) {
2930
+ const code = noiseMaskedOf(file);
2931
+ return ASTRO_EXPORT.test(code) && !/\bexport\s+default\b/.test(code);
2306
2932
  }
2933
+ function serverActionRoutes(file) {
2934
+ if (!/use server/.test(file.content) || !/\.[mc]?[jt]sx?$/.test(file.path)) return [];
2935
+ const code = noiseMaskedOf(file);
2936
+ const source = commentsMaskedOf(file);
2937
+ const fileLevel = startsWithDirective(source, 0, "use server");
2938
+ const pairs = delimiterPairs(code);
2939
+ const openers = /* @__PURE__ */ new Map();
2940
+ for (const [open, close] of pairs) openers.set(close, open);
2941
+ const exportedList = /* @__PURE__ */ new Set();
2942
+ for (const m of code.matchAll(/\bexport\s*\{([^{}]{0,1000})\}/g)) {
2943
+ for (const part of m[1].split(",")) {
2944
+ const name = /^\s*([A-Za-z_$][\w$]*)/.exec(part)?.[1];
2945
+ if (name) exportedList.add(name);
2946
+ }
2947
+ }
2948
+ const scope = /^(.*?\/)?(?:src|app|lib|actions|server|utils)\//.exec(file.path)?.[1] ?? "";
2949
+ const routes = [];
2950
+ for (const body of functionBodies(code, pairs)) {
2951
+ const inline = startsWithDirective(source, body.start + 1, "use server");
2952
+ const declared = declarationOf(code, body, openers);
2953
+ const exported = fileLevel && declared !== null && (declared.exported || exportedList.has(declared.name));
2954
+ if (!inline && !exported) continue;
2955
+ const name = declared?.name ?? "anonymous";
2956
+ routes.push({
2957
+ file,
2958
+ framework: "next",
2959
+ url: `the server action ${name}`,
2960
+ scope,
2961
+ reachable: { start: body.start, end: body.end },
2962
+ action: name
2963
+ });
2964
+ }
2965
+ return routes;
2966
+ }
2967
+ function startsWithDirective(source, from, directive) {
2968
+ let i = from;
2969
+ while (i < source.length) {
2970
+ while (i < source.length && /\s/.test(source[i])) i++;
2971
+ const quote = source[i++];
2972
+ if (quote !== "'" && quote !== '"') return false;
2973
+ const start = i;
2974
+ while (i < source.length && source[i] !== quote && !/[\r\n]/.test(source[i])) {
2975
+ if (source[i] === "\\") i++;
2976
+ i++;
2977
+ }
2978
+ if (source[i] !== quote) return false;
2979
+ const value = source.slice(start, i++);
2980
+ let newline = false;
2981
+ while (i < source.length && /\s/.test(source[i])) {
2982
+ if (/[\r\n]/.test(source[i])) newline = true;
2983
+ i++;
2984
+ }
2985
+ const next = source[i];
2986
+ const terminated = next === ";" || next === "}" || next === void 0 || newline && !/[([.`+\-*/%?:,<>=!&|^]/.test(next);
2987
+ if (!terminated) return false;
2988
+ if (value === directive) return true;
2989
+ if (next === ";") i++;
2990
+ }
2991
+ return false;
2992
+ }
2993
+ function declarationOf(code, body, openers) {
2994
+ if (/^function\s*\(/.test(code.slice(body.declaration, body.declaration + 40)) && /\bexport\s+default\s+(?:async\s+)?$/.test(code.slice(Math.max(0, body.declaration - 80), body.declaration))) {
2995
+ return { name: "default", exported: true };
2996
+ }
2997
+ const named = /^function\s*\*?\s*([A-Za-z_$][\w$]*)/.exec(code.slice(body.declaration, body.declaration + 200));
2998
+ if (named) {
2999
+ const before = code.slice(Math.max(0, body.declaration - 40), body.declaration);
3000
+ return { name: named[1], exported: /\bexport\s+(?:default\s+)?(?:async\s+)?$/.test(before) };
3001
+ }
3002
+ let i = body.declaration - 1;
3003
+ const skipSpace = () => {
3004
+ while (i >= 0 && /\s/.test(code[i])) i--;
3005
+ };
3006
+ const readWord = () => {
3007
+ const end = i + 1;
3008
+ while (i >= 0 && /[\w$]/.test(code[i])) i--;
3009
+ return code.slice(i + 1, end);
3010
+ };
3011
+ const window = code.slice(Math.max(0, body.declaration - 200), body.declaration);
3012
+ const close = window.lastIndexOf(")");
3013
+ if (close !== -1 && /^\s*(?::[^=;{()]*)?$/.test(window.slice(close + 1))) {
3014
+ const open = openers.get(body.declaration - window.length + close);
3015
+ if (open === void 0) return null;
3016
+ i = open - 1;
3017
+ } else {
3018
+ skipSpace();
3019
+ if (readWord() === "") return null;
3020
+ }
3021
+ skipSpace();
3022
+ const maybeAsync = i;
3023
+ if (readWord() !== "async") i = maybeAsync;
3024
+ skipSpace();
3025
+ if (code[i] !== "=" || code[i - 1] === "=" || code[i - 1] === "!") return null;
3026
+ i--;
3027
+ skipSpace();
3028
+ let name = readWord();
3029
+ skipSpace();
3030
+ if (code[i] === ":") {
3031
+ i--;
3032
+ skipSpace();
3033
+ name = readWord();
3034
+ skipSpace();
3035
+ }
3036
+ if (name === "" || !/^(?:const|let|var)$/.test(readWord())) return null;
3037
+ skipSpace();
3038
+ return { name, exported: readWord() === "export" };
3039
+ }
3040
+ var AUTH_ACTION_NAMES = /^(?:sign[_-]?(?:in|up|out)|log[_-]?(?:in|out)|register|reset[_-]?password|forgot[_-]?password|verify(?:[_-]?(?:email|otp))?|confirm(?:[_-]?email)?|send[_-]?magic[_-]?link)(?:action)?$/i;
2307
3041
  function routePathOf(path) {
2308
3042
  return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
2309
3043
  }
2310
- var AUTH_ENDPOINT_NAMES = /^\/api\/auth\/(?:sign[-_]?in|sign[-_]?up|sign[-_]?out|log[-_]?in|log[-_]?out|register|session|verify|confirm|reset(?:[-_]password)?|forgot(?:[-_]password)?|magic[-_]?link|otp)$/;
2311
- var AUTH_CALLBACK = /^\/api\/auth\/callback(?:\/[^/]+)?$/;
2312
- function isAuthEndpoint(url) {
2313
- return AUTH_ENDPOINT_NAMES.test(url) || AUTH_CALLBACK.test(url);
3044
+ function routeOf(file) {
3045
+ const path = routePathOf(file.path);
3046
+ if (PAGES_ROUTER.test(file.path) && ASTRO_ENDPOINT.test(path) && isAstroEndpoint(file)) {
3047
+ const astro2 = ASTRO_ENDPOINT.exec(path);
3048
+ return { file, framework: "astro", url: `/${astro2[2].replace(/(?:^|\/)index$/, "")}`, scope: astro2[1] ?? "" };
3049
+ }
3050
+ if (PAGES_ROUTER.test(file.path)) {
3051
+ const scope = /^(.*?)(?:src\/)?pages\/api\//.exec(path)?.[1] ?? "";
3052
+ return { file, framework: "next", url: nextRouteUrl(path), scope };
3053
+ }
3054
+ if (APP_ROUTE_FILE.test(path)) {
3055
+ const app = nextAppRoute(path);
3056
+ const code = noiseMaskedOf(file);
3057
+ const handler = APP_ROUTER.test(path) || NEXT_METHOD_EXPORT.test(code) && !REMIX_EXPORT.test(code);
3058
+ if (app && handler) return app.url === null ? null : { file, framework: "next", url: app.url, scope: app.scope };
3059
+ }
3060
+ const svelte = SVELTEKIT_ENDPOINT.exec(path);
3061
+ if (svelte) return { file, framework: "sveltekit", url: `/${svelte[2] ?? ""}`, scope: svelte[1] ?? "" };
3062
+ const page = SVELTEKIT_PAGE_SERVER.exec(path);
3063
+ if (page) {
3064
+ const code = noiseMaskedOf(file);
3065
+ const actions = SVELTEKIT_ACTIONS.exec(code);
3066
+ if (!actions) return null;
3067
+ const open = actions.index + actions[0].length - 1;
3068
+ const close = closingDelimiter(code, open, "{", "}");
3069
+ return {
3070
+ file,
3071
+ framework: "sveltekit",
3072
+ url: `/${page[2] ?? ""}`,
3073
+ scope: page[1] ?? "",
3074
+ reachable: { start: open, end: close ?? code.length }
3075
+ };
3076
+ }
3077
+ const nuxt = NUXT_ROUTE.exec(path);
3078
+ if (nuxt && H3_HANDLER.test(noiseMaskedOf(file))) {
3079
+ const rest = nuxt[3].replace(NUXT_METHOD_SUFFIX, "").replace(/(?:^|\/)index$/, "");
3080
+ const url = nuxt[2] === "api" ? `/api/${rest}` : `/${rest}`;
3081
+ return { file, framework: "nuxt", url: url.replace(/\/$/, "") || "/", scope: nuxt[1] ?? "" };
3082
+ }
3083
+ const remix = REMIX_ROUTE.exec(path);
3084
+ if (remix && REMIX_EXPORT.test(noiseMaskedOf(file))) {
3085
+ return { file, framework: "remix", url: remixRouteUrl(remix[2]), scope: remix[1] ?? "" };
3086
+ }
3087
+ const astro = ASTRO_ENDPOINT.exec(path);
3088
+ if (astro && isAstroEndpoint(file)) {
3089
+ const rest = astro[2].replace(/(?:^|\/)index$/, "");
3090
+ return { file, framework: "astro", url: `/${rest}`, scope: astro[1] ?? "" };
3091
+ }
3092
+ return null;
3093
+ }
3094
+ function nextAppRoute(path) {
3095
+ const m = /^(.*?\/)?src\/app\/(.+)$/.exec(path) ?? /^(.*?\/)?app\/(.+)$/.exec(path);
3096
+ if (!m) return null;
3097
+ const segments = m[2].split("/").slice(0, -1);
3098
+ const url = segments.some((segment) => segment.startsWith("_")) ? null : `/${segments.map((segment) => segment.replace(/^%5F/i, "_")).join("/")}`;
3099
+ return { scope: m[1] ?? "", url };
2314
3100
  }
2315
- function routeUrl(path) {
2316
- const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(routePathOf(path));
3101
+ function nextRouteUrl(path) {
3102
+ const m = /(?:^|\/)pages\/(api\/.*)$/.exec(path);
2317
3103
  if (!m) return `/${path}`;
2318
3104
  const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
2319
3105
  return `/${url}`;
2320
3106
  }
2321
- var AUTH_ENFORCING_CALL = /\b(?:NextAuth|require(?:Auth|User|Session|Admin)|withAuth|verifyAuth|ensureAuth|assertAuth(?:enticated)?|verifyIdToken|constructEvent)\s*\(/i;
2322
- var AUTH_CONDITION = /\b(?:session|token|user|authorization|bearer|jwt|auth|signature|CRON_SECRET|WEBHOOK_SECRET|REVALIDATE_SECRET|ADMIN_SECRET)\b|\blocals\s*\.\s*user\b|\b(?:getUser|getSession|getServerSession|currentUser|getAuth|isAuthenticated|checkAuth|verifyAuth|ensureAuth|verifyIdToken|timingSafeEqual)\s*\(/i;
3107
+ function remixRouteUrl(name) {
3108
+ const LITERAL_DOT = "\0";
3109
+ const segments = name.replace(/\/route$/, "").replace(/\[\.\]/g, LITERAL_DOT).split(/[./]/).filter((segment) => segment !== "" && !segment.startsWith("_")).map((segment) => segment.replace(/_$/, "").replace(/[[\]]/g, "").split(LITERAL_DOT).join("."));
3110
+ if (segments[segments.length - 1] === "index") segments.pop();
3111
+ return `/${segments.join("/")}`;
3112
+ }
3113
+ var AUTH_ENDPOINT_NAMES = /^(?:\/api)?\/auth\/(?:sign[-_]?in|sign[-_]?up|sign[-_]?out|log[-_]?in|log[-_]?out|register|session|verify|confirm|reset(?:[-_]password)?|forgot(?:[-_]password)?|magic[-_]?link|otp)$/;
3114
+ var AUTH_CALLBACK = /^(?:\/api)?\/auth\/callback(?:\/[^/]+)?$/;
3115
+ var PROVIDER_CALLBACK = /^(?:\/api)?\/(?:auth|oauth|login|sign-?in)\/[^/]+\/callback$/;
3116
+ var OAUTH_HANDLER = /\bdefineOAuth\w*EventHandler\s*\(/;
3117
+ function isAuthEndpoint(route) {
3118
+ if (route.action !== void 0) return AUTH_ACTION_NAMES.test(route.action);
3119
+ return AUTH_ENDPOINT_NAMES.test(route.url) || AUTH_CALLBACK.test(route.url) || PROVIDER_CALLBACK.test(route.url) || OAUTH_HANDLER.test(noiseMaskedOf(route.file));
3120
+ }
3121
+ var AUTH_ENFORCING_CALL = /\b(?:NextAuth|require(?:Auth|User|Session|Admin)\w*|withAuth|verifyAuth|ensureAuth|assertAuth(?:enticated)?|verifyIdToken|constructEvent)\s*\(/i;
3122
+ var AUTH_CONDITION = /\b(?:session|token|user|userid|user_id|authorization|bearer|jwt|auth|signature|CRON_SECRET|WEBHOOK_SECRET|REVALIDATE_SECRET|ADMIN_SECRET)\b|\blocals\s*\.\s*user\b|\b(?:getUser|getSession|getServerSession|currentUser|getAuth|isAuthenticated|checkAuth|verifyAuth|ensureAuth|verifyIdToken|timingSafeEqual)\s*\(/i;
3123
+ var ACCESS_CHAIN = String.raw`[\w$]+(?:\??\.[\w$]+)*`;
3124
+ var NEGATED_IDENTITY = new RegExp(`^!\\s*${ACCESS_CHAIN}(?:\\s*\\([^=]*\\))?$`);
3125
+ var IDENTITY_IS_EMPTY = new RegExp(`^${ACCESS_CHAIN}\\s*={2,3}\\s*(?:null|undefined|false)$`);
3126
+ var IDENTITY_MISMATCH = new RegExp(`^${ACCESS_CHAIN}\\s*!={1,2}\\s*(?!(?:null|undefined|false)\\b)${ACCESS_CHAIN}$`);
3127
+ var STOPS_REQUEST = /^(?:(?:return|throw|redirect|notFound)\b|error\s*\(\s*40[13]\b)/;
3128
+ var DENIED_STATUS = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus(?:Code)?\s*[:(=]\s*(?:401|403)\b|\berror\s*\(\s*40[13]\b/i;
2323
3129
  function closingDelimiter(source, start, open, close) {
2324
3130
  let depth = 0;
2325
3131
  for (let i = start; i < source.length; i++) {
@@ -2343,6 +3149,10 @@ function controlledStatement(source, afterCondition) {
2343
3149
  const end = semicolon === -1 ? Math.min(source.length, start + 400) : Math.min(semicolon + 1, start + 400);
2344
3150
  return source.slice(start, end);
2345
3151
  }
3152
+ function rejectsMissingIdentity(term, returnsDeniedStatus) {
3153
+ const rejects = NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term);
3154
+ return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
3155
+ }
2346
3156
  function hasConditionalAuthGuard(code) {
2347
3157
  const starts = /\bif\s*\(/g;
2348
3158
  let match;
@@ -2356,7 +3166,7 @@ function hasConditionalAuthGuard(code) {
2356
3166
  const pairs = delimiterPairs(body);
2357
3167
  let stopsRequest = false;
2358
3168
  for (let i = 0; i < body.length; i++) {
2359
- if (/^(?:return|throw|redirect|notFound)\b/.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
3169
+ if (STOPS_REQUEST.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
2360
3170
  stopsRequest = true;
2361
3171
  break;
2362
3172
  }
@@ -2368,19 +3178,55 @@ function hasConditionalAuthGuard(code) {
2368
3178
  while (/\s/.test(code[statementStart] ?? "")) statementStart++;
2369
3179
  starts.lastIndex = statementStart + statement.length;
2370
3180
  if (!stopsRequest) continue;
2371
- const returnsDeniedStatus = /\b(?:return|throw)\b[\s\S]{0,300}\bstatus\s*[:(=]\s*(?:401|403)\b/i.test(statement);
2372
- const negative = !/&&|\?/.test(condition) && condition.split("||").some((part) => {
2373
- const term = part.trim();
2374
- const rejects = /^!\s*[\w$.]+(?:\s*\([^=]*\))?$/.test(term) || /^[\w$.]+\s*={2,3}\s*(?:null|undefined|false)$/.test(term) || /^[\w$.]+\s*!={1,2}\s*(?!(?:null|undefined|false)\b)[\w$.]+$/.test(term);
2375
- return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
2376
- });
3181
+ const returnsDeniedStatus = DENIED_STATUS.test(statement);
3182
+ const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus));
2377
3183
  if (negative) return true;
2378
3184
  }
2379
3185
  return false;
2380
3186
  }
3187
+ function containsConditionalAuthCheck(code) {
3188
+ const pairs = delimiterPairs(code);
3189
+ for (const match of code.matchAll(/\bif\s*\(/g)) {
3190
+ const open = match.index + match[0].length - 1;
3191
+ const close = pairs.get(open);
3192
+ if (close === void 0) continue;
3193
+ let start = close + 1;
3194
+ while (/\s/.test(code[start] ?? "")) start++;
3195
+ let bodyStart = start;
3196
+ let bodyEnd;
3197
+ if (code[start] === "{") {
3198
+ bodyStart = start + 1;
3199
+ bodyEnd = pairs.get(start) ?? Math.min(code.length, start + 600);
3200
+ } else {
3201
+ const semicolon = code.slice(start, start + 400).indexOf(";");
3202
+ bodyEnd = semicolon === -1 ? Math.min(code.length, start + 400) : start + semicolon + 1;
3203
+ }
3204
+ let stop = -1;
3205
+ for (let i = bodyStart; i < bodyEnd; i++) {
3206
+ const window = code.slice(i, i + 16);
3207
+ if (STOPS_REQUEST.test(window) && (i === 0 || !/[\w$]/.test(code[i - 1]))) {
3208
+ stop = i;
3209
+ break;
3210
+ }
3211
+ if (/^if\s*\(/.test(window)) break;
3212
+ const end = pairs.get(i);
3213
+ if (end !== void 0) i = end;
3214
+ }
3215
+ if (stop === -1) continue;
3216
+ const returnsDeniedStatus = DENIED_STATUS.test(code.slice(stop, stop + 400));
3217
+ const condition = code.slice(open + 1, close);
3218
+ if (condition.split(/\|\||&&/).some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus))) return true;
3219
+ }
3220
+ return false;
3221
+ }
3222
+ var authSignalCache = /* @__PURE__ */ new WeakMap();
2381
3223
  function hasAuthSignal(file) {
3224
+ const cached = authSignalCache.get(file);
3225
+ if (cached !== void 0) return cached;
2382
3226
  const code = noiseMaskedOf(file);
2383
- return AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
3227
+ const found = AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
3228
+ authSignalCache.set(file, found);
3229
+ return found;
2384
3230
  }
2385
3231
  function delimiterPairs(code) {
2386
3232
  const pairs = /* @__PURE__ */ new Map();
@@ -2417,7 +3263,7 @@ function functionBodies(code, pairs) {
2417
3263
  }
2418
3264
  return bodies;
2419
3265
  }
2420
- function statementEnd(code, start, limit, pairs) {
3266
+ function statementEnd2(code, start, limit, pairs) {
2421
3267
  if (code[start] === "{") return (pairs.get(start) ?? limit) + 1;
2422
3268
  for (let i = start; i < limit; i++) {
2423
3269
  if (code[i] === ";" || code[i] === "\n") return i + 1;
@@ -2426,7 +3272,36 @@ function statementEnd(code, start, limit, pairs) {
2426
3272
  }
2427
3273
  return limit;
2428
3274
  }
2429
- 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) {
2430
3305
  if (ops.length === 0) return ops;
2431
3306
  const code = noiseMaskedOf(file);
2432
3307
  const pairs = delimiterPairs(code);
@@ -2434,12 +3309,18 @@ function unguardedOperations(file, ops) {
2434
3309
  const declarations = new Map(bodies.map((body) => [body.declaration, body]));
2435
3310
  const functionStarts = new Set(bodies.map((body) => body.start));
2436
3311
  const guardEnds = /* @__PURE__ */ new Map();
3312
+ const guardNames = /* @__PURE__ */ new Map();
3313
+ const extraCalls = extra?.guards.size ? new RegExp(`^(${namePattern(extra.guards)})\\s*\\(`) : null;
2437
3314
  const blocks = [...pairs].filter(([start]) => code[start] === "{").map(([start, end]) => ({ start, end })).sort((a, b) => a.start - b.start);
2438
3315
  const active = [];
2439
3316
  let blockIndex = 0;
2440
- const 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) => {
2441
3322
  const open = match.index + match[0].length - 1;
2442
- return { start: open, end: pairs.get(open) ?? open };
3323
+ return { start: open, end: pairs.get(open) ?? open, name: match[1] };
2443
3324
  });
2444
3325
  const guardEnd = (owner) => {
2445
3326
  for (let i = owner.start + 1; i < owner.end; i++) {
@@ -2449,7 +3330,7 @@ function unguardedOperations(file, ops) {
2449
3330
  continue;
2450
3331
  }
2451
3332
  if (code.startsWith("=>", i)) {
2452
- i = statementEnd(code, i + 2, owner.end, pairs) - 1;
3333
+ i = statementEnd2(code, i + 2, owner.end, pairs) - 1;
2453
3334
  continue;
2454
3335
  }
2455
3336
  if (i > 0 && /[\w$]/.test(code[i - 1])) continue;
@@ -2460,12 +3341,13 @@ function unguardedOperations(file, ops) {
2460
3341
  if (close2 === void 0) return Infinity;
2461
3342
  let start = close2 + 1;
2462
3343
  while (/\s/.test(code[start] ?? "")) start++;
2463
- const end = statementEnd(code, start, owner.end, pairs);
3344
+ const end = statementEnd2(code, start, owner.end, pairs);
2464
3345
  if (end <= owner.end && hasConditionalAuthGuard(code.slice(i, end))) return end;
2465
3346
  i = Math.min(end, owner.end) - 1;
2466
3347
  continue;
2467
3348
  }
2468
- const 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));
2469
3351
  if (call?.index === 0 && !/^(?:withAuth|NextAuth)\b/i.test(call[0]) && !/\bfunction\s*$/.test(code.slice(Math.max(owner.start, i - 30), i))) {
2470
3352
  const prefixStart = Math.max(
2471
3353
  owner.start + 1,
@@ -2478,7 +3360,10 @@ function unguardedOperations(file, ops) {
2478
3360
  const synchronous = /^(?:assertAuth(?:enticated)?|constructEvent)\b/i.test(call[0]) && /^(?:(?:const|let|var)\s+[\w$]+\s*=\s*)?(?:[\w$]+\.)*$/.test(prefix);
2479
3361
  if (!awaited && !synchronous) continue;
2480
3362
  const close2 = pairs.get(i + call[0].length - 1);
2481
- if (close2 !== void 0 && close2 < owner.end) 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
+ }
2482
3367
  }
2483
3368
  const close = pairs.get(i);
2484
3369
  if (close !== void 0) i = close;
@@ -2492,7 +3377,11 @@ function unguardedOperations(file, ops) {
2492
3377
  active.push(block);
2493
3378
  }
2494
3379
  while (active.length && active[active.length - 1].end < op.index) active.pop();
2495
- 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
+ }
2496
3385
  let ownerIndex = active.length - 1;
2497
3386
  while (ownerIndex >= 0 && !functionStarts.has(active[ownerIndex].start)) ownerIndex--;
2498
3387
  if (ownerIndex < 0) return true;
@@ -2503,12 +3392,31 @@ function unguardedOperations(file, ops) {
2503
3392
  end = guardEnd(block);
2504
3393
  guardEnds.set(block.start, end);
2505
3394
  }
2506
- if (end <= op.index) 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
+ }
2507
3400
  }
2508
3401
  return true;
2509
3402
  });
2510
3403
  }
2511
- var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>)?\s*\(/;
3404
+ var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>\s*)?\(/;
3405
+ var constructorCache = /* @__PURE__ */ new WeakMap();
3406
+ function clientConstructorAt(file) {
3407
+ const cached = constructorCache.get(file);
3408
+ if (cached !== void 0) return cached ?? void 0;
3409
+ const code = noiseMaskedOf(file);
3410
+ const direct = CLIENT_CONSTRUCTOR.exec(code)?.index;
3411
+ if (direct !== void 0) {
3412
+ constructorCache.set(file, direct);
3413
+ return direct;
3414
+ }
3415
+ const aliases = bindingsOf(file).imports.filter((binding) => /^@supabase\/(?:supabase-js|ssr)$/.test(binding.spec ?? "") && ["createClient", "createServerClient"].includes(binding.imported)).map((binding) => binding.local);
3416
+ const found = aliases.length === 0 ? void 0 : new RegExp(`(?<![\\w$.])(?:${namePattern(aliases)})\\s*(?:<[^()]{0,200}>\\s*)?\\(`).exec(code)?.index;
3417
+ constructorCache.set(file, found ?? null);
3418
+ return found;
3419
+ }
2512
3420
  var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
2513
3421
  var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
2514
3422
  var ENV_BRACKET_ACCESS = /(?:process\.env|import\.meta\.env)\s*\[\s*['"]([^'"]+)['"]\s*\]/g;
@@ -2521,9 +3429,13 @@ function referencesServiceRole(code, source) {
2521
3429
  }
2522
3430
  return false;
2523
3431
  }
2524
- function buildsAdminClient(file) {
3432
+ var NUXT_SUPABASE_ADMIN = /\bserverSupabaseServiceRole\s*(?:<[^()]{0,200}>\s*)?\(/;
3433
+ var NUXT_SUPABASE_SESSION = /\bserverSupabaseClient\s*(?:<[^()]{0,200}>\s*)?\(/;
3434
+ var buildsAdminClient = cachedByFile(buildsAdminClientUncached);
3435
+ function buildsAdminClientUncached(file) {
2525
3436
  const code = noiseMaskedOf(file);
2526
- if (!CLIENT_CONSTRUCTOR.test(code)) return false;
3437
+ if (NUXT_SUPABASE_ADMIN.test(code)) return true;
3438
+ if (clientConstructorAt(file) === void 0) return false;
2527
3439
  const source = commentsMaskedOf(file);
2528
3440
  if (referencesServiceRole(code, source)) return true;
2529
3441
  SERVICE_ROLE_LITERAL.lastIndex = 0;
@@ -2533,9 +3445,12 @@ function buildsAdminClient(file) {
2533
3445
  }
2534
3446
  return false;
2535
3447
  }
2536
- function buildsSessionClient(file) {
3448
+ var buildsSessionClient = cachedByFile(buildsSessionClientUncached);
3449
+ function buildsSessionClientUncached(file) {
2537
3450
  const code = noiseMaskedOf(file);
2538
- if (!CLIENT_CONSTRUCTOR.test(code)) return false;
3451
+ if (NUXT_SUPABASE_ADMIN.test(code)) return false;
3452
+ if (NUXT_SUPABASE_SESSION.test(code)) return true;
3453
+ if (clientConstructorAt(file) === void 0) return false;
2539
3454
  if (referencesServiceRole(code, commentsMaskedOf(file))) return false;
2540
3455
  return /\bcookies\b/.test(code);
2541
3456
  }
@@ -2564,19 +3479,34 @@ function normalizeSpec(spec, fromPath) {
2564
3479
  alias: false
2565
3480
  };
2566
3481
  }
2567
- const alias = /^[@~#]\/(.+)$/.exec(spec);
3482
+ const lib = /^\$lib\/(.+)$/.exec(spec);
3483
+ if (lib) return { key: moduleKey(`src/lib/${lib[1]}`), alias: true };
3484
+ const alias = /^(?:[@~#]|~~|@@)\/(.+)$/.exec(spec);
2568
3485
  return alias ? { key: moduleKey(alias[1]), alias: true } : null;
2569
3486
  }
2570
3487
  function usesAdminClient(route, adminModules, allFiles) {
2571
- return buildsAdminClient(route) || importsAnyOf(route, adminModules, allFiles);
3488
+ return buildsAdminClient(route.file) || importsAnyOf(route, adminModules, allFiles) || autoImportsAnyOf(route, adminModules);
2572
3489
  }
2573
3490
  function usesSessionClient(route, sessionModules, allFiles) {
2574
- return buildsSessionClient(route) || importsAnyOf(route, sessionModules, allFiles);
3491
+ if (route.framework === "sveltekit" && /\blocals\s*\.\s*supabase\b/.test(noiseMaskedOf(route.file))) return true;
3492
+ return buildsSessionClient(route.file) || importsAnyOf(route, sessionModules, allFiles) || autoImportsAnyOf(route, sessionModules);
3493
+ }
3494
+ function autoImportsAnyOf(route, modules) {
3495
+ return autoImportedModule(route, modules) !== void 0;
3496
+ }
3497
+ function autoImportedModule(route, modules) {
3498
+ if (route.framework !== "nuxt") return void 0;
3499
+ const utils = `${route.scope}server/utils/`;
3500
+ const code = noiseMaskedOf(route.file);
3501
+ return modules.find((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
2575
3502
  }
2576
- function moduleScopeOf(routePath) {
2577
- return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePathOf(routePath))?.[1] ?? "";
3503
+ function exportedNames(file) {
3504
+ const names = noiseMaskedOf(file).matchAll(
3505
+ /\bexport\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+|var\s+)([A-Za-z_$][\w$]*)/g
3506
+ );
3507
+ return [...names].map((m) => m[1]);
2578
3508
  }
2579
- function importedModules(file, allFiles, aliasScope) {
3509
+ function importedEdges(file, allFiles, aliasScope) {
2580
3510
  const found = [];
2581
3511
  const source = commentsMaskedOf(file);
2582
3512
  const code = noiseMaskedOf(file);
@@ -2587,40 +3517,200 @@ function importedModules(file, allFiles, aliasScope) {
2587
3517
  const target = normalizeSpec(m[1], file.path);
2588
3518
  if (!target) continue;
2589
3519
  const index = moduleIndexOf(allFiles);
3520
+ const append = (targets) => {
3521
+ for (const to of targets) found.push({ from: file, to, line: lineNumberAt(lineStartsCached(file), m.index) });
3522
+ };
2590
3523
  if (!target.alias) {
2591
- found.push(...index.get(target.key) ?? []);
3524
+ append(index.get(target.key) ?? []);
2592
3525
  } else {
2593
3526
  const prefix = aliasScope;
2594
3527
  for (const key of [
2595
3528
  moduleKey(`${prefix}${target.key}`),
2596
- moduleKey(`${prefix}src/${target.key}`)
3529
+ moduleKey(`${prefix}src/${target.key}`),
3530
+ moduleKey(`${prefix}app/${target.key}`)
2597
3531
  ]) {
2598
- found.push(...index.get(key) ?? []);
3532
+ append(index.get(key) ?? []);
2599
3533
  }
2600
3534
  }
2601
3535
  }
2602
3536
  return found;
2603
3537
  }
3538
+ var importsCache = /* @__PURE__ */ new WeakMap();
2604
3539
  function importsAnyOf(route, modules, allFiles) {
2605
- 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) {
2606
3555
  const targetPaths = new Set(modules.map((file) => file.path));
2607
- const visited = /* @__PURE__ */ new Set();
2608
- const aliasScope = moduleScopeOf(route.path);
2609
- const queue = importedModules(route, allFiles, aliasScope);
2610
- while (queue.length > 0) {
2611
- const file = queue.pop();
2612
- if (targetPaths.has(file.path)) return true;
2613
- if (visited.has(file.path)) continue;
2614
- visited.add(file.path);
2615
- 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
+ }
2616
3575
  }
2617
- 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;
2618
3708
  }
2619
3709
  var SUPABASE_TABLE = /\.from\(\s*['"`][^'"`]+['"`]\s*\)\s*\.?\s*(\w+)?/g;
2620
3710
  var SUPABASE_ADMIN_API = /\bauth\s*\.\s*admin\s*\.\s*(\w+)\s*\(/g;
2621
3711
  var SUPABASE_WRITES = /* @__PURE__ */ new Set(["insert", "update", "upsert", "delete"]);
2622
- var PRISMA_OP = /\bprisma\s*\.\s*\$?(\w+)\s*\.\s*(findMany|findFirst|findUnique|findUniqueOrThrow|create|createMany|update|updateMany|upsert|delete|deleteMany)\s*\(/g;
2623
- 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;
2624
3714
  var PRISMA_WRITES = /^(?:create|createMany|update|updateMany|upsert|delete|deleteMany)$/;
2625
3715
  var DRIZZLE_OP = /\bdb\s*\.\s*(select|insert|update|delete)\s*\(/g;
2626
3716
  var MONGO_OP = /\.(?:deleteMany|deleteOne|updateMany|updateOne|insertMany|insertOne|findOneAndDelete|findOneAndUpdate)\s*\(/g;
@@ -2663,20 +3753,29 @@ function findDataOps(file) {
2663
3753
  }
2664
3754
  return hits.sort((a, b) => a.index - b.index);
2665
3755
  }
2666
- var 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
+ }
2667
3764
  function middlewareScopeOf(path) {
2668
- return path.replace(/(?:src\/)?middleware\.[mc]?[jt]s$/, "");
3765
+ return path.replace(/(?:src\/)?(?:middleware(?:\/index)?|proxy)\.[mc]?[jt]s$/, "");
2669
3766
  }
2670
- function middlewareFor(ctx, routePath) {
2671
- let best = null;
3767
+ function middlewaresFor(ctx, routePath, framework) {
3768
+ let best = [];
2672
3769
  let bestDepth = -1;
2673
3770
  for (const file of ctx.files) {
2674
- if (!MIDDLEWARE_FILE.test(file.path)) continue;
3771
+ if (!isMiddlewareFile(file.path, framework)) continue;
2675
3772
  const scope = middlewareScopeOf(file.path);
2676
3773
  if (!routePath.startsWith(scope)) continue;
2677
3774
  if (scope.length > bestDepth) {
2678
- best = file;
3775
+ best = [file];
2679
3776
  bestDepth = scope.length;
3777
+ } else if (scope.length === bestDepth) {
3778
+ best.push(file);
2680
3779
  }
2681
3780
  }
2682
3781
  return best;
@@ -2710,14 +3809,14 @@ function sliceQuoted(text) {
2710
3809
  }
2711
3810
  return null;
2712
3811
  }
2713
- function topLevelMatcherValueStart(objectText) {
2714
- const candidates = [1];
3812
+ function topLevelMemberStarts(text, container) {
3813
+ const starts = [1];
2715
3814
  let quote = null;
2716
3815
  let braces = 0;
2717
3816
  let brackets = 0;
2718
3817
  let parentheses = 0;
2719
- for (let i = 0; i < objectText.length; i++) {
2720
- const ch = objectText[i];
3818
+ for (let i = 0; i < text.length; i++) {
3819
+ const ch = text[i];
2721
3820
  if (quote !== null) {
2722
3821
  if (ch === "\\") i++;
2723
3822
  else if (ch === quote) quote = null;
@@ -2730,18 +3829,41 @@ function topLevelMatcherValueStart(objectText) {
2730
3829
  else if (ch === "]") brackets--;
2731
3830
  else if (ch === "(") parentheses++;
2732
3831
  else if (ch === ")") parentheses--;
2733
- else if (ch === "," && braces === 1 && brackets === 0 && parentheses === 0) {
2734
- candidates.push(i + 1);
3832
+ else if (ch === "," && parentheses === 0 && (container === "{" ? braces === 1 && brackets === 0 : brackets === 1 && braces === 0)) {
3833
+ starts.push(i + 1);
2735
3834
  }
2736
3835
  }
2737
- 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, "{")) {
2738
3841
  let cursor = start;
2739
3842
  while (/\s/.test(objectText[cursor] ?? "")) cursor++;
2740
- const property = /^(?:matcher|['"]matcher['"])\s*:\s*/.exec(objectText.slice(cursor));
2741
- if (property) return cursor + property[0].length;
3843
+ const match = property.exec(objectText.slice(cursor));
3844
+ if (match) return cursor + match[0].length;
2742
3845
  }
2743
3846
  return null;
2744
3847
  }
3848
+ function quotedValue(text) {
3849
+ const quoted = sliceQuoted(text);
3850
+ return quoted === null || quoted.length < 3 ? null : quoted.slice(1, -1);
3851
+ }
3852
+ function matcherEntry(text) {
3853
+ const trimmed = text.trimStart();
3854
+ if (trimmed.startsWith("{")) {
3855
+ const object = sliceDelimited(trimmed, "{", "}");
3856
+ if (object === null) return null;
3857
+ if (topLevelPropertyStart(object, "has") !== null || topLevelPropertyStart(object, "missing") !== null) {
3858
+ return { conditional: true };
3859
+ }
3860
+ const source = topLevelPropertyStart(object, "source");
3861
+ const pattern2 = source === null ? null : quotedValue(object.slice(source));
3862
+ return pattern2 === null ? null : { pattern: pattern2 };
3863
+ }
3864
+ const pattern = quotedValue(trimmed);
3865
+ return pattern === null ? null : { pattern };
3866
+ }
2745
3867
  function extractMatcherConfig(file) {
2746
3868
  const code = noiseMaskedOf(file);
2747
3869
  const declaration = /\bexport\s+const\s+config\b/.exec(code);
@@ -2755,13 +3877,28 @@ function extractMatcherConfig(file) {
2755
3877
  const masked = commentsMaskedOf(file);
2756
3878
  const objectText = sliceDelimited(masked.slice(objectStart), "{", "}");
2757
3879
  if (objectText === null) return { kind: "unreadable" };
2758
- const valueStart = topLevelMatcherValueStart(objectText);
3880
+ const valueStart = topLevelPropertyStart(objectText, "matcher");
2759
3881
  if (valueStart === null) return { kind: "absent" };
2760
3882
  const rest = objectText.slice(valueStart);
2761
- const raw = rest.startsWith("[") ? sliceBracketed(rest) : sliceQuoted(rest);
2762
- if (raw === null) return { kind: "unreadable" };
2763
- const patterns = [...raw.matchAll(/['"`]([^'"`]+)['"`]/g)].map((m) => m[1]);
2764
- 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 };
2765
3902
  }
2766
3903
  var MAX_MATCHER_LENGTH = 300;
2767
3904
  function withoutGroupPrefix(body) {
@@ -2852,11 +3989,12 @@ function matcherToRegex(pattern) {
2852
3989
  return null;
2853
3990
  }
2854
3991
  }
2855
- function middlewareCovers(ctx, routePath, url) {
2856
- const mw = middlewareFor(ctx, routePath);
2857
- 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) {
2858
3996
  if (!hasAuthSignal(mw)) return false;
2859
- const config = extractMatcherConfig(mw);
3997
+ const { config, readable } = compiledMatchersOf(mw);
2860
3998
  if (config.kind === "absent") return true;
2861
3999
  if (config.kind === "unreadable") {
2862
4000
  ctx.reportIncomplete(
@@ -2865,11 +4003,40 @@ function middlewareCovers(ctx, routePath, url) {
2865
4003
  );
2866
4004
  return true;
2867
4005
  }
2868
- const parsed = config.patterns.map(matcherToRegex);
2869
- const readable = parsed.filter((re) => re !== null);
4006
+ if (config.patterns.length === 0) return false;
2870
4007
  if (readable.length === 0) return true;
2871
4008
  return readable.some((re) => re.test(url));
2872
4009
  }
4010
+ var matcherCache = /* @__PURE__ */ new WeakMap();
4011
+ function compiledMatchersOf(file) {
4012
+ const cached = matcherCache.get(file);
4013
+ if (cached !== void 0) return cached;
4014
+ const config = extractMatcherConfig(file);
4015
+ const readable = config.kind === "patterns" ? config.patterns.map(matcherToRegex).filter((re) => re !== null) : [];
4016
+ const compiled = { config, readable };
4017
+ matcherCache.set(file, compiled);
4018
+ return compiled;
4019
+ }
4020
+ function globalGuardFor(ctx, route) {
4021
+ const relative3 = (file) => file.path.startsWith(route.scope) ? file.path.slice(route.scope.length) : null;
4022
+ const candidates = ctx.files.filter((file) => {
4023
+ const path = relative3(file);
4024
+ if (path === null) return false;
4025
+ if (route.framework === "sveltekit") return /^src\/hooks\.server\.[mc]?[jt]s$/.test(path);
4026
+ if (route.framework === "nuxt") return /^server\/middleware\/.+\.[mc]?[jt]s$/.test(path);
4027
+ return false;
4028
+ });
4029
+ return candidates.find(containsAuthCheck)?.path ?? null;
4030
+ }
4031
+ var authCheckCache = /* @__PURE__ */ new WeakMap();
4032
+ function containsAuthCheck(file) {
4033
+ const cached = authCheckCache.get(file);
4034
+ if (cached !== void 0) return cached;
4035
+ const code = noiseMaskedOf(file);
4036
+ const found = AUTH_ENFORCING_CALL.test(code) || containsConditionalAuthCheck(code);
4037
+ authCheckCache.set(file, found);
4038
+ return found;
4039
+ }
2873
4040
  var SECRET_SHAPED = /['"`]([A-Za-z0-9_\-.]{32,})['"`]/g;
2874
4041
  function excerptFor(file, line) {
2875
4042
  const raw = (file.lines[line - 1] ?? "").trim();
@@ -2885,9 +4052,12 @@ var apiAuthRule = {
2885
4052
  id: "api/db-access-without-auth",
2886
4053
  severity: "P0",
2887
4054
  check(ctx) {
2888
- const routes = ctx.files.filter((f) => isApiRoute(f.path));
4055
+ const routes = ctx.files.flatMap((file) => {
4056
+ const route = routeOf(file);
4057
+ return route === null ? serverActionRoutes(file) : [route];
4058
+ });
2889
4059
  if (routes.length === 0) return [];
2890
- for (const middlewareFile of ctx.files.filter((f) => MIDDLEWARE_FILE.test(f.path))) {
4060
+ for (const middlewareFile of ctx.files.filter((f) => isAnyMiddlewareFile(f.path))) {
2891
4061
  const config = extractMatcherConfig(middlewareFile);
2892
4062
  const refused = config.kind === "patterns" ? config.patterns.filter((p) => matcherToRegex(p) === null) : [];
2893
4063
  if (refused.length > 0) {
@@ -2900,29 +4070,54 @@ var apiAuthRule = {
2900
4070
  const adminModules = ctx.files.filter(buildsAdminClient);
2901
4071
  const sessionModules = ctx.files.filter(buildsSessionClient);
2902
4072
  const findings = [];
4073
+ const indirectByFile = /* @__PURE__ */ new Map();
2903
4074
  for (const route of routes) {
2904
- const ops = unguardedOperations(route, findDataOps(route));
4075
+ const reachable = route.reachable;
4076
+ let ops = unguardedOpsOf(route.file).filter((op) => reachable === void 0 || op.index > reachable.start && op.index < reachable.end);
2905
4077
  if (ops.length === 0) continue;
2906
- const url = routeUrl(route.path);
2907
- if (isAuthEndpoint(url)) continue;
2908
- if (middlewareCovers(ctx, route.path, url)) 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;
4085
+ const url = route.url;
4086
+ if (isAuthEndpoint(route)) continue;
4087
+ if ((route.framework === "next" || route.framework === "astro") && route.action === void 0 && middlewareCovers(ctx, route)) continue;
4088
+ const globalGuard = globalGuardFor(ctx, route);
4089
+ const globalNote = globalGuard === null ? [] : [
4090
+ `${globalGuard} contains an authentication check that may cover ${url}. Its path conditions are written in code, so the scan cannot tell which routes it applies to; this finding is reported at lower confidence. Confirm that check runs for ${url}.`
4091
+ ];
2909
4092
  const admin = usesAdminClient(route, adminModules, ctx.files);
2910
4093
  const hit = ops.find((o) => o.writes) ?? ops[0];
2911
- const line = lineNumberAt(lineStartsOf(route.content), hit.index);
2912
- const excerpt = excerptFor(route, line);
4094
+ const indirect = indirectOps.get(hit.index);
4095
+ const line = lineNumberAt(lineStartsCached(route.file), hit.index);
4096
+ const excerpt = excerptFor(route.file, line);
4097
+ const indirectEvidence = indirect ? [{
4098
+ kind: "auth-helper",
4099
+ file: indirect.file.path,
4100
+ line: indirect.line,
4101
+ description: "Local authentication helper recognized through imports; verify its runtime behaviour and caller coverage."
4102
+ }] : [];
4103
+ if (indirect) globalNote.push("A local authentication helper or wrapper was recognized before this operation. This finding is retained for review because indirect control flow is not a proof of authorization.");
2913
4104
  if (admin) {
4105
+ const trace = adminEvidence(route, adminModules, ctx.files, line);
2914
4106
  findings.push({
2915
4107
  ruleId: "api/admin-db-access-without-auth",
4108
+ ...trace,
4109
+ evidence: [...trace.evidence ?? [], ...indirectEvidence],
2916
4110
  severity: "P0",
2917
- // 管理员客户端缺少鉴权时使用确定置信度。
2918
- confidence: "certain",
2919
- title: `Anyone can call ${url} and it queries your database as admin`,
2920
- file: route.path,
4111
+ // 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
4112
+ confidence: globalGuard === null && !indirect ? "certain" : "likely",
4113
+ title: indirect ? `Review indirect authentication for admin database access in ${url}` : `Anyone can call ${url} and it queries your database as admin`,
4114
+ file: route.file.path,
2921
4115
  line,
2922
4116
  excerpt,
2923
4117
  why: [
4118
+ ...globalNote,
2924
4119
  `This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
2925
- `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
4120
+ indirect ? "A local guard was recognized, but its runtime enforcement and business authorization still require verification." : `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
2926
4121
  `If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
2927
4122
  `If this endpoint is meant to be public \u2014 handing out a guest session, taking a waitlist signup \u2014 then the problem is not that it is open, it is that it is open *and* holds the admin key. Give it a client that can only do the one thing it needs.`
2928
4123
  ],
@@ -2944,12 +4139,20 @@ var apiAuthRule = {
2944
4139
  severity: "P1",
2945
4140
  // 公开写入可能是业务设计,保留疑似置信度。
2946
4141
  confidence: "likely",
2947
- title: `${url} writes to your database with no sign-in check`,
2948
- file: route.path,
4142
+ // Server Action 的名称以小写开头,放在句首时首字母大写。
4143
+ title: indirect ? `Review indirect authentication for database writes in ${url}` : `${url.charAt(0).toUpperCase()}${url.slice(1)} writes to your database with no sign-in check`,
4144
+ file: route.file.path,
2949
4145
  line,
2950
4146
  excerpt,
4147
+ ...indirect ? { evidence: [{
4148
+ kind: "operation",
4149
+ file: route.file.path,
4150
+ line,
4151
+ description: "Data write using a local authentication helper."
4152
+ }, ...indirectEvidence] } : {},
2951
4153
  why: [
2952
- `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
4154
+ ...globalNote,
4155
+ indirect ? "This route changes data after a local authentication helper; verify that it rejects unauthorized callers." : `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
2953
4156
  `If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
2954
4157
  `If this is a public form \u2014 a waitlist, a contact box \u2014 that may be intentional. It is still worth rate limiting, because an open write endpoint is what gets a database filled with spam overnight.`
2955
4158
  ],
@@ -2992,7 +4195,7 @@ function headerExpression(content, start) {
2992
4195
  return content.slice(start, end);
2993
4196
  }
2994
4197
  var CORS_ORIGIN_OPTION = /\borigin\s*:\s*/gi;
2995
- var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*)?\s*)?\(/gi;
4198
+ var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*\s*)?)?\(/gi;
2996
4199
  var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
2997
4200
  function closingParameterList(source, open) {
2998
4201
  let depth = 0;
@@ -3114,8 +4317,14 @@ function callbackAnswer(params, body) {
3114
4317
  var ORIGIN_IS_CHECKED = /\bincludes\s*\(|\bindexOf\s*\(|===|!==|==|!=|\.test\s*\(|\.some\s*\(|\bstartsWith\s*\(|\.match\s*\(|\.has\s*\(|\bif\b|\?|\ballow(?:ed|list)?\b|\bwhitelist\b/i;
3115
4318
  var ACAC_HEADER = /['"]Access-Control-Allow-Credentials['"][\s\S]{0,40}?\btrue\b/gi;
3116
4319
  var CORS_CREDENTIALS_OPTION = /\bcredentials\s*:\s*true\b/g;
4320
+ var TRAILING_PUNCTUATION = /[\s;,)}\]!]/;
4321
+ function trimTrailingPunctuation(text) {
4322
+ let end = text.length;
4323
+ while (end > 0 && TRAILING_PUNCTUATION.test(text[end - 1])) end--;
4324
+ return text.slice(0, end);
4325
+ }
3117
4326
  function classifyOrigin(raw) {
3118
- let v = raw.trim().replace(/[\s;,)}\]!]+$/, "");
4327
+ let v = trimTrailingPunctuation(raw.trim());
3119
4328
  const opening = v[0];
3120
4329
  if (opening === '"' || opening === "'" || opening === "`") {
3121
4330
  const close = v.indexOf(opening, 1);
@@ -3218,21 +4427,37 @@ function collectCredentialLines(file) {
3218
4427
  }
3219
4428
  return lines;
3220
4429
  }
3221
- function nearestOrigin(origins, credLine) {
3222
- let best = Infinity;
3223
- let closest = [];
3224
- for (const o of origins) {
3225
- const d = Math.abs(o.line - credLine);
3226
- if (d < best) {
3227
- best = d;
3228
- closest = [o];
3229
- } else if (d === best) {
3230
- 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
+ }
3231
4441
  }
3232
4442
  }
3233
- if (best > PAIRING_DISTANCE || closest.length === 0) return null;
3234
- const kinds = new Set(closest.map((o) => o.kind));
3235
- 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;
3236
4461
  }
3237
4462
  var corsRule = {
3238
4463
  id: "cors/credentialed-cross-origin",
@@ -3248,9 +4473,17 @@ var corsRule = {
3248
4473
  const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
3249
4474
  const findings = [];
3250
4475
  const reported = /* @__PURE__ */ new Set();
4476
+ const groupOf = (mark) => mark.mode === "header" ? "header" : `option:${scopes.get(mark.at)}`;
4477
+ const grouped = /* @__PURE__ */ new Map();
4478
+ origins.forEach((mark, order) => {
4479
+ const group = grouped.get(groupOf(mark)) ?? [];
4480
+ group.push({ mark, order });
4481
+ grouped.set(groupOf(mark), group);
4482
+ });
4483
+ const indexes = new Map([...grouped].map(([group, marks]) => [group, indexOrigins(marks)]));
3251
4484
  for (const credential of credentialLines) {
3252
- const candidates = origins.filter((origin2) => origin2.mode === credential.mode && (credential.mode === "header" || scopes.get(origin2.at) === scopes.get(credential.at)));
3253
- const origin = nearestOrigin(candidates, credential.line);
4485
+ if (reported.size === 2) break;
4486
+ const origin = nearestOrigin(indexes.get(groupOf(credential)) ?? [], credential.line);
3254
4487
  if (!origin) continue;
3255
4488
  if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
3256
4489
  if (reported.has(origin.kind)) continue;
@@ -3319,6 +4552,8 @@ var RULE_IDS = [
3319
4552
  "firebase/test-mode-rules",
3320
4553
  "gitleak/env-in-history",
3321
4554
  "gitleak/env-tracked",
4555
+ "supabase/permissive-policy",
4556
+ "supabase/public-bucket-listing",
3322
4557
  "supabase/rls-not-enabled",
3323
4558
  // 凭据规则 ID 从共享格式表生成。
3324
4559
  ...SECRET_PATTERNS.map((p) => `secrets/hardcoded/${p.id}`)
@@ -3384,6 +4619,15 @@ function sanitize(findings, files) {
3384
4619
  file: f.file === null ? null : clean(f.file),
3385
4620
  // 先脱敏再截断,避免截断导致凭据特征失效。
3386
4621
  excerpt: f.excerpt === null ? null : truncate(clean(f.excerpt)),
4622
+ ...f.evidence ? {
4623
+ evidence: f.evidence.slice(0, 24).map((step) => ({
4624
+ kind: step.kind,
4625
+ file: clean(step.file),
4626
+ line: step.line,
4627
+ description: clean(step.description)
4628
+ })),
4629
+ evidenceTruncated: f.evidenceTruncated === true || f.evidence.length > 24
4630
+ } : {},
3387
4631
  fix: f.fix.map(clean),
3388
4632
  ...f.humanOnly ? { humanOnly: f.humanOnly.map(clean) } : {}
3389
4633
  }));
@@ -3552,6 +4796,9 @@ var gray = wrap(90, 39);
3552
4796
  var BLOCKING = /* @__PURE__ */ new Set(["P0", "P1"]);
3553
4797
 
3554
4798
  // src/report/shared.ts
4799
+ function changeViewNotice(view) {
4800
+ return `Changed-file view: ${view.changedFiles} changed files; ${view.hiddenFindings} findings hidden. Full scan: ${view.totalFindings} findings, ${view.totalBlocking} blocking. Scan scope and exit status are unchanged.`;
4801
+ }
3555
4802
  function plural(n, word) {
3556
4803
  return n === 1 ? word : `${word}s`;
3557
4804
  }
@@ -3611,12 +4858,14 @@ function renderReport(result, opts) {
3611
4858
  `${INDENT}${bold("canship")} ${dim(`scanned ${result.filesScanned} ${plural(result.filesScanned, "file")} in ${result.durationMs}ms`)}`
3612
4859
  );
3613
4860
  out.push(`${INDENT}${dim(opts.root)}`);
4861
+ if (result.changeView) out.push(`${INDENT}${yellow(changeViewNotice(result.changeView))}`);
3614
4862
  out.push("");
3615
4863
  if (findings.length === 0) {
3616
4864
  out.push(...renderClean(result, opts));
3617
4865
  return out.join("\n");
3618
4866
  }
3619
- const { blocking, minor: confirmedMinor, unsure } = verdictOf(findings);
4867
+ const { blocking: visibleBlocking, minor: confirmedMinor, unsure } = verdictOf(findings);
4868
+ const blocking = result.changeView?.totalBlocking ?? visibleBlocking;
3620
4869
  if (blocking > 0) {
3621
4870
  out.push(`${INDENT}${red(bold(`\u2717 ${blocking} critical ${plural(blocking, "issue")} \u2014 do not deploy`))}`);
3622
4871
  } else if (confirmedMinor > 0) {
@@ -3658,6 +4907,11 @@ function renderFinding(f, index) {
3658
4907
  out.push("");
3659
4908
  out.push(`${INDENT}${INDENT}${gray(f.excerpt)}`);
3660
4909
  }
4910
+ if (f.evidence?.length) {
4911
+ out.push("", `${INDENT}${INDENT}${bold("Evidence (static relationships):")}`);
4912
+ for (const step of f.evidence) out.push(`${INDENT}${INDENT} ${locationOf(step)} \u2014 ${step.description}`);
4913
+ if (f.evidenceTruncated) out.push(`${INDENT}${INDENT} Additional dependency steps omitted.`);
4914
+ }
3661
4915
  out.push("");
3662
4916
  for (const line of wrapText(f.why.join("\n\n"), 76)) {
3663
4917
  out.push(line === "" ? "" : `${INDENT}${INDENT}${line}`);
@@ -3712,6 +4966,8 @@ function renderClean(result, opts) {
3712
4966
  if (result.partial) {
3713
4967
  const headline = opts.hiddenLikely > 0 ? `! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden, and not everything was checked` : "! No findings \u2014 but not everything was checked";
3714
4968
  out.push(`${INDENT}${yellow(bold(headline))}`);
4969
+ } else if ((result.changeView?.hiddenFindings ?? 0) > 0) {
4970
+ out.push(`${INDENT}${yellow(bold("! No visible findings in changed files \u2014 other findings still exist"))}`);
3715
4971
  } else if (opts.hiddenLikely > 0) {
3716
4972
  out.push(
3717
4973
  `${INDENT}${yellow(bold(`! No certain findings \u2014 ${opts.hiddenLikely} lower-confidence ${plural(opts.hiddenLikely, "finding")} hidden`))}`
@@ -3732,9 +4988,9 @@ function renderClean(result, opts) {
3732
4988
  out.push(`${INDENT}${dim(" \xB7 Server-side secrets exposed to the browser via public env prefixes")}`);
3733
4989
  out.push(`${INDENT}${dim(" \xB7 Supabase service_role keys reachable from the client")}`);
3734
4990
  out.push(`${INDENT}${dim(" \xB7 .env files committed to git, including in history")}`);
3735
- out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security")}`);
4991
+ out.push(`${INDENT}${dim(" \xB7 Supabase tables with no Row Level Security, or policies open to everyone")}`);
3736
4992
  out.push(`${INDENT}${dim(" \xB7 Firebase rules left open to anyone")}`);
3737
- out.push(`${INDENT}${dim(" \xB7 API routes that query your database with no sign-in check")}`);
4993
+ out.push(`${INDENT}${dim(" \xB7 API routes and server actions that query your database with no sign-in check")}`);
3738
4994
  out.push(`${INDENT}${dim(" \xB7 CORS that lets other sites act as your signed-in visitors")}`);
3739
4995
  out.push("");
3740
4996
  out.push(`${INDENT}${dim("It does not check rate limiting, injection, or whether the checks it")}`);
@@ -3871,8 +5127,12 @@ function renderInstruction(f, index) {
3871
5127
  const location = defuseMarkers(locationOf(f));
3872
5128
  lines.push(`${index}. ${location} \u2014 ${defuseMarkers(f.title)}`);
3873
5129
  if (f.excerpt) lines.push(` Found: ${defuseMarkers(f.excerpt)}`);
5130
+ for (const step of f.evidence ?? []) {
5131
+ lines.push(` Static evidence: ${defuseMarkers(locationOf(step))} \u2014 ${defuseMarkers(step.description)}`);
5132
+ }
5133
+ if (f.evidenceTruncated) lines.push(" Additional dependency steps omitted.");
3874
5134
  for (const step of f.fix) {
3875
- lines.push(` - ${step}`);
5135
+ lines.push(` - ${defuseMarkers(step)}`);
3876
5136
  }
3877
5137
  return lines.join("\n");
3878
5138
  }
@@ -3883,6 +5143,7 @@ function renderFixPrompt(findings, ctx) {
3883
5143
  const baselineSuppressed = ctx?.baselineSuppressed ?? 0;
3884
5144
  const silenced = ctx?.silenced ?? [];
3885
5145
  const suppressedNotes = [
5146
+ ctx?.changeView ? changeViewNotice(ctx.changeView) : null,
3886
5147
  !ctx?.ignoredFiles?.length ? null : `Note: ${ctx.ignoredFiles.length} file(s) excluded by canship-ignore-file: ${defuseMarkers(ctx.ignoredFiles.join(", "))}. Their contents were not checked. Do not treat this as a clean result.`,
3887
5148
  baselineSuppressed === 0 ? null : `Note: ${baselineSuppressed} ${baselineSuppressed === 1 ? "finding was" : "findings were"} hidden by a baseline. Those problems still exist and are not listed below. Re-run without --baseline to see them.`,
3888
5149
  silenced.length === 0 ? null : `Note: ${silenced.length} ${silenced.length === 1 ? "finding was" : "findings were"} silenced by a canship-ignore-next-line marker in the source, at ${defuseMarkers(silenced.join(", "))}. Those problems still exist and are not listed below.`,
@@ -3946,7 +5207,7 @@ function renderFixPrompt(findings, ctx) {
3946
5207
  for (const { step } of humanSteps) {
3947
5208
  if (seen.has(step)) continue;
3948
5209
  seen.add(step);
3949
- out.push(`- ${step}`);
5210
+ out.push(`- ${defuseMarkers(step)}`);
3950
5211
  }
3951
5212
  out.push("");
3952
5213
  out.push("Until these are done, the exposure is still live \u2014 the code fix alone does not close it.");
@@ -3980,6 +5241,7 @@ function renderFinding2(f, index) {
3980
5241
  </header>
3981
5242
  <div class="loc">${esc(location)}${f.confidence === "likely" ? ' <span class="tag">lower confidence</span>' : ""}</div>
3982
5243
  ${f.excerpt ? `<pre><code>${esc(f.excerpt)}</code></pre>` : ""}
5244
+ ${f.evidence?.length ? `<h4>Evidence (static relationships)</h4><ol>${f.evidence.map((step) => `<li><code>${esc(locationOf(step))}</code> \u2014 ${esc(step.description)}</li>`).join("")}</ol>${f.evidenceTruncated ? "<p>Additional dependency steps omitted.</p>" : ""}` : ""}
3983
5245
  <div class="why">${linkify(paragraphs(f.why))}</div>
3984
5246
  ${fixList}
3985
5247
  ${humanList}
@@ -3990,14 +5252,15 @@ function renderHtml(result, opts) {
3990
5252
  const hiddenLikely = opts.hiddenLikely ?? 0;
3991
5253
  const baselineSuppressed = opts.baselineSuppressed ?? 0;
3992
5254
  const baselineStale = opts.baselineStale ?? 0;
3993
- const { blocking: certain, minor, unsure } = verdictOf(findings);
5255
+ const { blocking, minor, unsure } = verdictOf(findings);
5256
+ const certain = result.changeView?.totalBlocking ?? blocking;
3994
5257
  const verdict = findings.length === 0 ? result.filesScanned === 0 ? (
3995
5258
  // 未扫描文件时不显示通过结论。
3996
5259
  `<div class="verdict warn">No files were scanned &mdash; nothing was checked</div>`
3997
5260
  ) : result.partial ? (
3998
5261
  // 扫描不完整时不得显示正常通过。
3999
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>`
4000
- ) : 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>` : (
4001
5264
  // 基线抑制结果时明确说明,避免误报为项目无问题。
4002
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>`
4003
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>`;
@@ -4010,7 +5273,7 @@ function renderHtml(result, opts) {
4010
5273
  gitignored or build output that canship skips. If the project lives in a
4011
5274
  subdirectory, point canship at it: <code>npx canship ./app</code>.</p>
4012
5275
  </div>`
4013
- ) : hiddenLikely > 0 ? `<div class="clean-note">
5276
+ ) : (result.changeView?.hiddenFindings ?? 0) > 0 ? '<div class="clean-note">This view hides existing findings. Re-run without --changed-since for the full report.</div>' : hiddenLikely > 0 ? `<div class="clean-note">
4014
5277
  <p><strong>This is not a finding-free result.</strong> The default report hides
4015
5278
  ${hiddenLikely} lower-confidence ${plural(hiddenLikely, "finding")}.</p>
4016
5279
  <p>Re-run with <code>--all --report</code> to include ${hiddenLikely === 1 ? "it" : "them"} in the report.</p>
@@ -4020,7 +5283,7 @@ function renderHtml(result, opts) {
4020
5283
  <p>Those problems still exist. Re-run without <code>--baseline</code> to see ${baselineSuppressed === 1 ? "it" : "them"}.</p>
4021
5284
  </div>` : `<div class="clean-note">
4022
5285
  <p>canship checked for hardcoded API keys, server secrets exposed to the browser,
4023
- Supabase tables without Row Level Security, open Firebase rules, API routes that reach
5286
+ Supabase tables without Row Level Security or with policies open to everyone, open Firebase rules, API routes and server actions that reach
4024
5287
  the database with no sign-in check, CORS that lets other sites use your visitors&rsquo;
4025
5288
  sessions, and <code>.env</code> files committed to git.</p>
4026
5289
  <p><strong>A clean result means those checks passed &mdash; not that your app is secure.</strong>
@@ -4123,6 +5386,7 @@ function renderHtml(result, opts) {
4123
5386
  <h1>canship report</h1>
4124
5387
  <div class="meta">${esc(opts.root)}<br>${esc(opts.generatedAt)} &middot; ${result.filesScanned} ${plural(result.filesScanned, "file")} scanned in ${result.durationMs}ms</div>
4125
5388
  ${verdict}
5389
+ ${result.changeView ? `<p class="opted-out">${esc(changeViewNotice(result.changeView))}</p>` : ""}
4126
5390
  <div class="notice">
4127
5391
  Credential values canship recognises are masked in this report. One in a format it has no
4128
5392
  pattern for can still appear inside a quoted line, and this report lists your
@@ -4268,6 +5532,7 @@ function applyBaseline(findings, baseline) {
4268
5532
  var INFORMATION_URI = "https://github.com/Tasomei/canship";
4269
5533
  function suppressionNotes(result, opts) {
4270
5534
  const notes = [];
5535
+ if (result.changeView) notes.push({ level: "warning", message: { text: changeViewNotice(result.changeView) } });
4271
5536
  const baseline = opts.baselineSuppressed ?? 0;
4272
5537
  const hidden = opts.hiddenLikely ?? 0;
4273
5538
  if (baseline > 0) {
@@ -4345,7 +5610,18 @@ function resultsOf(findings) {
4345
5610
  }
4346
5611
  }
4347
5612
  ],
4348
- partialFingerprints: { canshipFindingV2: fingerprintOf(f) }
5613
+ partialFingerprints: { canshipFindingV2: fingerprintOf(f) },
5614
+ ...f.evidence?.length ? {
5615
+ relatedLocations: f.evidence.map((step, index) => ({
5616
+ id: index + 1,
5617
+ message: { text: step.description },
5618
+ physicalLocation: {
5619
+ artifactLocation: { uri: step.file.split("/").map((part) => encodeURIComponent(part)).join("/") },
5620
+ ...step.line === null ? {} : { region: { startLine: step.line } }
5621
+ }
5622
+ })),
5623
+ properties: { evidenceTruncated: f.evidenceTruncated ?? false }
5624
+ } : {}
4349
5625
  }));
4350
5626
  }
4351
5627
  function renderSarif(result, opts) {
@@ -4408,7 +5684,8 @@ function createJsonReport(result, options) {
4408
5684
  baselineSuppressed: options.baselineSuppressed,
4409
5685
  baselineStale: options.baselineStale,
4410
5686
  excerptsOmitted: options.excerptsOmitted ?? false,
4411
- findings: result.findings
5687
+ findings: result.findings,
5688
+ ...result.changeView ? { changeView: result.changeView } : {}
4412
5689
  };
4413
5690
  }
4414
5691
 
@@ -4510,15 +5787,15 @@ var RULE_CATALOG = [
4510
5787
  "Admin database access without a recognized auth guard",
4511
5788
  "P0",
4512
5789
  "certain",
4513
- "Next.js App/Pages API handlers, including workspace applications.",
4514
- "Syntactic guards only; runtime authentication and business authorization are not verified."
5790
+ "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro, including workspace applications.",
5791
+ "Syntactic guards only; runtime authentication and business authorization are not verified. Local auth imports/re-exports (up to eight hops), SvelteKit hooks and Nuxt middleware may lower confidence, without removing findings."
4515
5792
  ),
4516
5793
  rule(
4517
5794
  "api/db-write-without-auth",
4518
5795
  "Database write without a recognized auth guard",
4519
5796
  "P1",
4520
5797
  "likely",
4521
- "Next.js App/Pages API handlers.",
5798
+ "Server routes and Next.js Server Functions in Next.js, SvelteKit (endpoints and form actions), Nuxt, Remix/React Router, and Astro.",
4522
5799
  "Session-scoped clients and indirect guards may need manual review."
4523
5800
  ),
4524
5801
  rule(
@@ -4558,7 +5835,7 @@ var RULE_CATALOG = [
4558
5835
  "Supabase admin credential exposed in source or public env",
4559
5836
  "P0",
4560
5837
  "certain",
4561
- "Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
5838
+ "Recognized service_role JWTs in scanned text files, including configuration and non-JavaScript source; Supabase admin credentials in public environment variables.",
4562
5839
  "Source presence is observable; actual browser delivery and key validity are not verified."
4563
5840
  ),
4564
5841
  rule(
@@ -4566,7 +5843,7 @@ var RULE_CATALOG = [
4566
5843
  "Unconditional Firebase access",
4567
5844
  "P1",
4568
5845
  "varies",
4569
- "Firebase .rules files: unconditional writes are certain; public reads require review.",
5846
+ "Firebase .rules files and Realtime Database JSON rules: unconditional writes are certain; public reads require review.",
4570
5847
  "Public reads may be intentional; runtime rules and business authorization are not verified."
4571
5848
  ),
4572
5849
  rule(
@@ -4593,6 +5870,22 @@ var RULE_CATALOG = [
4593
5870
  "Tracked environment files; templates and public-only values are treated separately.",
4594
5871
  "Unrecognized private values are heuristic findings, not verified credentials."
4595
5872
  ),
5873
+ rule(
5874
+ "supabase/permissive-policy",
5875
+ "Row Level Security policy with an always-true condition",
5876
+ "P1",
5877
+ "varies",
5878
+ "Supabase SQL migrations, replayed by application scope: permissive policies whose USING or WITH CHECK is always true (true, 1=1, 'a'='a'). Changing or deleting any row is certain; reading or inserting any row requires review.",
5879
+ "Missing clauses and dashboard changes are not checked; matching restrictive policies and nontrivial WITH CHECK conditions lower confidence."
5880
+ ),
5881
+ rule(
5882
+ "supabase/public-bucket-listing",
5883
+ "Public storage bucket whose contents can be listed",
5884
+ "P2",
5885
+ "varies",
5886
+ "Public buckets declared in migrations or supabase/config.toml, paired with a SELECT policy on storage.objects that is always true or filters only by that bucket.",
5887
+ "Buckets created from the dashboard or at runtime are not visible; restrictive policies may limit listing and lower confidence."
5888
+ ),
4596
5889
  rule(
4597
5890
  "supabase/rls-not-enabled",
4598
5891
  "Table without RLS in migrations",
@@ -4624,8 +5917,53 @@ function renderRuleCatalog() {
4624
5917
  ).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
4625
5918
  }
4626
5919
 
5920
+ // src/changes.ts
5921
+ var ChangeViewError = class extends Error {
5922
+ };
5923
+ function changedFilesSince(root, ref) {
5924
+ if (!ref || ref.length > 1024 || /[\0\r\n]/.test(ref)) throw new ChangeViewError("Invalid changed-since reference.");
5925
+ const git2 = resolveGitExecutable(root);
5926
+ if (!git2 || !hasContainedGitMetadata(root)) throw new ChangeViewError("Changed-file views require a readable local Git repository.");
5927
+ try {
5928
+ const baseCommit = execGitSync(git2, root, ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).trim();
5929
+ const mergeBase = execGitSync(git2, root, ["merge-base", baseCommit, "HEAD"]).trim();
5930
+ const hash = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
5931
+ if (!hash.test(baseCommit) || !hash.test(mergeBase)) throw new Error("Invalid object ID");
5932
+ const tracked = execGitSync(git2, root, [
5933
+ "diff",
5934
+ "--no-ext-diff",
5935
+ "--no-textconv",
5936
+ "--no-renames",
5937
+ "--relative",
5938
+ "--name-only",
5939
+ "-z",
5940
+ mergeBase,
5941
+ "--",
5942
+ "."
5943
+ ]);
5944
+ const untracked = execGitSync(git2, root, ["ls-files", "--others", "--exclude-standard", "-z", "--", "."]);
5945
+ const paths = new Set((tracked + untracked).split("\0").filter(Boolean).map(cleanForOutput));
5946
+ return { baseCommit, mergeBase, paths };
5947
+ } catch {
5948
+ throw new ChangeViewError("Cannot compute changed files. Check that the base commit and merge history are available locally.");
5949
+ }
5950
+ }
5951
+ function changedFileView(result, changed) {
5952
+ const findings = result.findings.filter((finding) => finding.file === null || finding.evidenceTruncated || changed.paths.has(finding.file) || finding.evidence?.some((step) => changed.paths.has(step.file)));
5953
+ const verdict = verdictOf(result.findings);
5954
+ return { ...result, findings, changeView: {
5955
+ baseCommit: changed.baseCommit,
5956
+ mergeBase: changed.mergeBase,
5957
+ changedFiles: changed.paths.size,
5958
+ hiddenFindings: result.findings.length - findings.length,
5959
+ totalFindings: result.findings.length,
5960
+ totalBlocking: verdict.blocking,
5961
+ totalLikely: verdict.unsure
5962
+ } };
5963
+ }
5964
+
4627
5965
  // src/cli.ts
4628
- var VERSION = true ? "0.3.1" : "0.0.0-dev";
5966
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
4629
5967
  function finish(code) {
4630
5968
  process.exitCode = code;
4631
5969
  }
@@ -4694,10 +6032,16 @@ function parseArgs(argv) {
4694
6032
  help: false,
4695
6033
  version: false,
4696
6034
  listRules: false,
4697
- noExcerpts: false
6035
+ noExcerpts: false,
6036
+ changedSince: null
4698
6037
  };
4699
6038
  const positional = [];
4700
6039
  for (const arg of argv) {
6040
+ if (arg.startsWith("--changed-since=")) {
6041
+ if (args.changedSince !== null || arg === "--changed-since=") argumentError("--changed-since requires one non-empty reference");
6042
+ args.changedSince = arg.slice("--changed-since=".length);
6043
+ continue;
6044
+ }
4701
6045
  const list = (name) => {
4702
6046
  if (!arg.startsWith(`${name}=`)) return null;
4703
6047
  const value = arg.slice(name.length + 1);
@@ -4815,6 +6159,8 @@ var HELP = `
4815
6159
  markers; use with --no-config for untrusted projects
4816
6160
  --list-rules List rule IDs, scope, and limits; add --json for structured output
4817
6161
  --no-excerpts Omit source excerpts from every report; paths and descriptions remain
6162
+ --changed-since=REF Show changed-file findings since the local merge base;
6163
+ scan scope and exit status remain unchanged
4818
6164
  -h, --help Show this help
4819
6165
  -v, --version Show version
4820
6166
 
@@ -4863,6 +6209,10 @@ async function main() {
4863
6209
  `);
4864
6210
  return finish(3);
4865
6211
  }
6212
+ if (args.changedSince !== null && (args.baselineWrite !== null || args.baselineWriteDefault)) {
6213
+ argumentError("--changed-since cannot be combined with --baseline-write");
6214
+ }
6215
+ const changed = args.changedSince === null ? null : changedFilesSince(args.root, args.changedSince);
4866
6216
  let config;
4867
6217
  try {
4868
6218
  config = args.noConfig ? {} : loadConfig(args.root).config;
@@ -4955,10 +6305,13 @@ ${cleanForOutput(String(err))}
4955
6305
  }
4956
6306
  const displayRoot = cleanForOutput(args.root);
4957
6307
  if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
6308
+ const fullResult = result;
6309
+ if (changed) result = changedFileView(result, changed);
4958
6310
  const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
4959
6311
  const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
4960
6312
  if (args.fixPrompt) {
4961
6313
  const prompt = renderFixPrompt(shown, {
6314
+ ...result.changeView ? { changeView: result.changeView } : {},
4962
6315
  partial: result.partial,
4963
6316
  filesScanned: result.filesScanned,
4964
6317
  hiddenLikely,
@@ -5065,13 +6418,13 @@ ${cleanForOutput(String(err))}
5065
6418
  return finish(3);
5066
6419
  }
5067
6420
  }
5068
- if (verdictOf(result.findings).blocking > 0) return finish(1);
5069
- 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);
5070
6423
  if (result.partial && !bestEffort) return finish(3);
5071
6424
  return finish(0);
5072
6425
  }
5073
6426
  main().catch((err) => {
5074
- if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
6427
+ if (err instanceof ArgumentError || err instanceof ChangeViewError) process.stderr.write(`canship: ${err.message}
5075
6428
  `);
5076
6429
  else process.stderr.write(`${red("canship: unexpected error")}
5077
6430
  ${cleanForOutput(String(err))}