canship 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-zh-CN.md CHANGED
@@ -22,10 +22,10 @@ npx canship .
22
22
  | Git 跟踪及历史 `.env` 文件中的凭据或疑似私密值 | P0 |
23
23
  | Supabase 迁移记录中未启用行级安全(RLS)的表 | P1 |
24
24
  | Firebase 无条件访问及固定日期测试规则 | P1 |
25
- | Next.js API 数据操作未识别到鉴权 | P0 / P1 |
25
+ | 服务端路由数据操作未识别到鉴权 | P0 / P1 |
26
26
  | 携带凭据的 CORS 来源回显或通配符配置 | P1 / P2 |
27
27
 
28
- 支持 OpenAI、Anthropic、AWS、Stripe、GitHub、npm、Slack、SendGrid 等凭据格式及常见前端公开环境变量前缀。API 鉴权检查仅覆盖 Next.js `/api`,支持 App Router、Pages Router、路由组和工作区应用。
28
+ 支持 OpenAI、Anthropic、AWS、Stripe、GitHub、npm、Slack、SendGrid 等凭据格式及常见前端公开环境变量前缀。API 鉴权检查覆盖以下框架的服务端路由:Next.js(App Router 与 Pages Router 的 `/api`)、SvelteKit(`+server` 端点)、Nuxt(`server/api` 与 `server/routes`)、Remix 与 React Router(`app/routes` 中导出 `loader` 或 `action` 的模块)、Astro(`src/pages` 中的端点),支持路由组和工作区应用。不检查 SvelteKit 的页面 load 与表单 action。SvelteKit `hooks.server` 或 Nuxt `server/middleware` 中的鉴权会降低结果置信度而不是直接隐藏结果,因为它覆盖哪些路由由代码决定。
29
29
 
30
30
  置信度分为确定(`certain`)和疑似(`likely`),仅描述静态证据,不验证凭据有效性或线上状态。默认只展示确定结果;隐藏的疑似结果仍影响退出码。
31
31
 
@@ -75,7 +75,7 @@ JSON 使用独立于包版本的 `schemaVersion: 1`,npm 包附带 [结构定
75
75
 
76
76
  保存为 `.github/workflows/canship.yml`,在推送和 PR 时扫描并生成统计摘要。安装扫描器需要联网,扫描不联网;不安装或执行项目依赖,默认不上传 SARIF。
77
77
 
78
- 示例固定 Action 提交,显式安装 npm 版 `0.3.0`;`version` 不使用仓库中的未发布源码。Action 兼容 0.2.1 无 `schemaVersion` 的报告。
78
+ 示例固定 Action 提交,显式安装 npm 版 `0.3.1`;`version` 不使用仓库中的未发布源码。Action 兼容 0.2.1 无 `schemaVersion` 的报告。
79
79
 
80
80
  ```yaml
81
81
  name: canship
@@ -90,15 +90,15 @@ jobs:
90
90
  with:
91
91
  fetch-depth: 0
92
92
  persist-credentials: false
93
- - uses: Tasomei/canship@8b1a3aa88c77e92e2806b343af6003372855bc70
93
+ - uses: Tasomei/canship@2d33cce0ad8439e34f01f5218fdaaf4657215e3d
94
94
  with:
95
- version: '0.3.0'
95
+ version: '0.3.1'
96
96
  ```
97
97
 
98
98
  | 输入 | 默认值 | 说明 |
99
99
  |---|---|---|
100
100
  | `path` | `.` | 检出目录内的扫描路径 |
101
- | `version` | `0.3.0` | 精确 npm 版本,不接受范围或标签 |
101
+ | `version` | `0.3.1` | 精确 npm 版本,不接受范围或标签 |
102
102
  | `fail-on` | `blocking` | `blocking`:确定的 P0/P1;`any`:全部结果;`none`:仅报告结果 |
103
103
  | `only` / `skip` | 未设置 | 互斥,逗号分隔的规则选择器 |
104
104
  | `baseline` | 未设置 | 相对扫描目录的已有基线 |
package/README.md CHANGED
@@ -22,10 +22,10 @@ Requires Node.js ≥18; no runtime dependencies. `npx` may download the package;
22
22
  | Credentials or suspected private values in Git-tracked and historical `.env` files | P0 |
23
23
  | Supabase tables without Row Level Security (RLS) in migrations | P1 |
24
24
  | Firebase unconditional access and date-based test rules | P1 |
25
- | Next.js API data operations without recognised authentication | P0 / P1 |
25
+ | Server route data operations without recognised authentication | P0 / P1 |
26
26
  | Credentialed CORS with reflected or wildcard origins | P1 / P2 |
27
27
 
28
- Supports OpenAI, Anthropic, AWS, Stripe, GitHub, npm, Slack, SendGrid, and other credential formats, plus common frontend public environment prefixes. API authentication checks cover only Next.js `/api`: App Router, Pages Router, route groups, and workspace applications.
28
+ Supports OpenAI, Anthropic, AWS, Stripe, GitHub, npm, Slack, SendGrid, and other credential formats, plus common frontend public environment prefixes. API authentication checks cover server routes in Next.js (`/api` in the App and Pages Router), SvelteKit (`+server` endpoints), Nuxt (`server/api` and `server/routes`), Remix and React Router (modules in `app/routes` exporting `loader` or `action`), and Astro (endpoints in `src/pages`), including route groups and workspace applications. SvelteKit page loads and form actions are not checked. An authentication check in SvelteKit `hooks.server` or Nuxt `server/middleware` lowers confidence instead of suppressing findings, because the routes it covers are decided in code.
29
29
 
30
30
  Confidence is `certain` or `likely`, describing static evidence rather than credential validity or deployed state. Only certain findings are shown by default; hidden likely findings still affect the exit code.
31
31
 
@@ -75,7 +75,7 @@ JSON uses `schemaVersion: 1`, independent of the package version; the npm packag
75
75
 
76
76
  Save as `.github/workflows/canship.yml` to scan on pushes and pull requests, with a counts-only summary. Scanner installation requires network access; scanning does not. Project dependencies are not installed or executed, and SARIF upload is disabled by default.
77
77
 
78
- The example pins the Action commit and explicitly installs npm version `0.3.0`; `version` does not use unreleased repository source. The Action accepts 0.2.1 reports without `schemaVersion`.
78
+ The example pins the Action commit and explicitly installs npm version `0.3.1`; `version` does not use unreleased repository source. The Action accepts 0.2.1 reports without `schemaVersion`.
79
79
 
80
80
  ```yaml
81
81
  name: canship
@@ -90,15 +90,15 @@ jobs:
90
90
  with:
91
91
  fetch-depth: 0
92
92
  persist-credentials: false
93
- - uses: Tasomei/canship@8b1a3aa88c77e92e2806b343af6003372855bc70
93
+ - uses: Tasomei/canship@2d33cce0ad8439e34f01f5218fdaaf4657215e3d
94
94
  with:
95
- version: '0.3.0'
95
+ version: '0.3.1'
96
96
  ```
97
97
 
98
98
  | Input | Default | Meaning |
99
99
  |---|---|---|
100
100
  | `path` | `.` | Directory within the checkout |
101
- | `version` | `0.3.0` | Exact npm version; no ranges or tags |
101
+ | `version` | `0.3.1` | Exact npm version; no ranges or tags |
102
102
  | `fail-on` | `blocking` | `blocking`: certain P0/P1; `any`: all findings; `none`: findings only reported |
103
103
  | `only` / `skip` | unset | Mutually exclusive, comma-separated rule selectors |
104
104
  | `baseline` | unset | Existing baseline relative to the scanned directory |
package/dist/cli.js CHANGED
@@ -2301,25 +2301,69 @@ var firebaseRulesRule = {
2301
2301
  import { posix } from "path";
2302
2302
  var APP_ROUTER = /(?:^|\/)app\/api\/(?:.+\/)?route\.[mc]?[jt]sx?$/;
2303
2303
  var PAGES_ROUTER = /(?:^|\/)pages\/api\/.+\.[mc]?[jt]sx?$/;
2304
- function isApiRoute(path) {
2305
- return APP_ROUTER.test(routePathOf(path)) || PAGES_ROUTER.test(path);
2306
- }
2304
+ var SVELTEKIT_ENDPOINT = /^(.*?\/)?src\/routes\/(?:(.*)\/)?\+server\.[mc]?[jt]s$/;
2305
+ var NUXT_ROUTE = /^(.*?\/)?server\/(api|routes)\/(.+)\.[mc]?[jt]s$/;
2306
+ var H3_HANDLER = /\b(?:defineEventHandler|eventHandler|defineCachedEventHandler|defineLazyEventHandler)\s*\(/;
2307
+ var NUXT_METHOD_SUFFIX = /\.(?:get|post|put|patch|delete|head|options|connect|trace)$/i;
2308
+ var REMIX_ROUTE = /^(.*?\/)?app\/routes\/(.+)\.[mc]?[jt]sx?$/;
2309
+ var REMIX_EXPORT = /\bexport\s+(?:async\s+)?function\s+(?:loader|action)\b|\bexport\s+const\s+(?:loader|action)\b/;
2310
+ var ASTRO_ENDPOINT = /^(.*?\/)?src\/pages\/(.+)\.[mc]?[jt]s$/;
2311
+ 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/;
2307
2312
  function routePathOf(path) {
2308
2313
  return path.replace(/(^|\/)\([^/]+\)(?=\/)/g, "$1").replace(/\/{2,}/g, "/");
2309
2314
  }
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);
2315
+ function routeOf(file) {
2316
+ const path = routePathOf(file.path);
2317
+ if (APP_ROUTER.test(path) || PAGES_ROUTER.test(file.path)) {
2318
+ const scope = /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(path)?.[1] ?? "";
2319
+ return { file, framework: "next", url: nextRouteUrl(path), scope };
2320
+ }
2321
+ const svelte = SVELTEKIT_ENDPOINT.exec(path);
2322
+ if (svelte) return { file, framework: "sveltekit", url: `/${svelte[2] ?? ""}`, scope: svelte[1] ?? "" };
2323
+ const nuxt = NUXT_ROUTE.exec(path);
2324
+ if (nuxt && H3_HANDLER.test(noiseMaskedOf(file))) {
2325
+ const rest = nuxt[3].replace(NUXT_METHOD_SUFFIX, "").replace(/(?:^|\/)index$/, "");
2326
+ const url = nuxt[2] === "api" ? `/api/${rest}` : `/${rest}`;
2327
+ return { file, framework: "nuxt", url: url.replace(/\/$/, "") || "/", scope: nuxt[1] ?? "" };
2328
+ }
2329
+ const remix = REMIX_ROUTE.exec(path);
2330
+ if (remix && REMIX_EXPORT.test(noiseMaskedOf(file))) {
2331
+ return { file, framework: "remix", url: remixRouteUrl(remix[2]), scope: remix[1] ?? "" };
2332
+ }
2333
+ const astro = ASTRO_ENDPOINT.exec(path);
2334
+ if (astro && ASTRO_EXPORT.test(noiseMaskedOf(file))) {
2335
+ const rest = astro[2].replace(/(?:^|\/)index$/, "");
2336
+ return { file, framework: "astro", url: `/${rest}`, scope: astro[1] ?? "" };
2337
+ }
2338
+ return null;
2314
2339
  }
2315
- function routeUrl(path) {
2316
- const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(routePathOf(path));
2340
+ function nextRouteUrl(path) {
2341
+ const m = /(?:^|\/)(?:app|pages)\/(api\/.*)$/.exec(path);
2317
2342
  if (!m) return `/${path}`;
2318
2343
  const url = m[1].replace(/\/route\.[mc]?[jt]sx?$/, "").replace(/\/index\.[mc]?[jt]sx?$/, "").replace(/\.[mc]?[jt]sx?$/, "");
2319
2344
  return `/${url}`;
2320
2345
  }
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;
2346
+ function remixRouteUrl(name) {
2347
+ const LITERAL_DOT = "\0";
2348
+ 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("."));
2349
+ if (segments[segments.length - 1] === "index") segments.pop();
2350
+ return `/${segments.join("/")}`;
2351
+ }
2352
+ 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)$/;
2353
+ var AUTH_CALLBACK = /^(?:\/api)?\/auth\/callback(?:\/[^/]+)?$/;
2354
+ var PROVIDER_CALLBACK = /^(?:\/api)?\/(?:auth|oauth|login|sign-?in)\/[^/]+\/callback$/;
2355
+ var OAUTH_HANDLER = /\bdefineOAuth\w*EventHandler\s*\(/;
2356
+ function isAuthEndpoint(route) {
2357
+ return AUTH_ENDPOINT_NAMES.test(route.url) || AUTH_CALLBACK.test(route.url) || PROVIDER_CALLBACK.test(route.url) || OAUTH_HANDLER.test(noiseMaskedOf(route.file));
2358
+ }
2359
+ var AUTH_ENFORCING_CALL = /\b(?:NextAuth|require(?:Auth|User|Session|Admin)\w*|withAuth|verifyAuth|ensureAuth|assertAuth(?:enticated)?|verifyIdToken|constructEvent)\s*\(/i;
2360
+ 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;
2361
+ var ACCESS_CHAIN = String.raw`[\w$]+(?:\??\.[\w$]+)*`;
2362
+ var NEGATED_IDENTITY = new RegExp(`^!\\s*${ACCESS_CHAIN}(?:\\s*\\([^=]*\\))?$`);
2363
+ var IDENTITY_IS_EMPTY = new RegExp(`^${ACCESS_CHAIN}\\s*={2,3}\\s*(?:null|undefined|false)$`);
2364
+ var IDENTITY_MISMATCH = new RegExp(`^${ACCESS_CHAIN}\\s*!={1,2}\\s*(?!(?:null|undefined|false)\\b)${ACCESS_CHAIN}$`);
2365
+ var STOPS_REQUEST = /^(?:(?:return|throw|redirect|notFound)\b|error\s*\(\s*40[13]\b)/;
2366
+ 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
2367
  function closingDelimiter(source, start, open, close) {
2324
2368
  let depth = 0;
2325
2369
  for (let i = start; i < source.length; i++) {
@@ -2343,6 +2387,10 @@ function controlledStatement(source, afterCondition) {
2343
2387
  const end = semicolon === -1 ? Math.min(source.length, start + 400) : Math.min(semicolon + 1, start + 400);
2344
2388
  return source.slice(start, end);
2345
2389
  }
2390
+ function rejectsMissingIdentity(term, returnsDeniedStatus) {
2391
+ const rejects = NEGATED_IDENTITY.test(term) || IDENTITY_IS_EMPTY.test(term) || IDENTITY_MISMATCH.test(term);
2392
+ return rejects && (AUTH_CONDITION.test(term) || returnsDeniedStatus);
2393
+ }
2346
2394
  function hasConditionalAuthGuard(code) {
2347
2395
  const starts = /\bif\s*\(/g;
2348
2396
  let match;
@@ -2356,7 +2404,7 @@ function hasConditionalAuthGuard(code) {
2356
2404
  const pairs = delimiterPairs(body);
2357
2405
  let stopsRequest = false;
2358
2406
  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]))) {
2407
+ if (STOPS_REQUEST.test(body.slice(i, i + 16)) && (i === 0 || !/[\w$]/.test(body[i - 1]))) {
2360
2408
  stopsRequest = true;
2361
2409
  break;
2362
2410
  }
@@ -2368,19 +2416,55 @@ function hasConditionalAuthGuard(code) {
2368
2416
  while (/\s/.test(code[statementStart] ?? "")) statementStart++;
2369
2417
  starts.lastIndex = statementStart + statement.length;
2370
2418
  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
- });
2419
+ const returnsDeniedStatus = DENIED_STATUS.test(statement);
2420
+ const negative = !/&&|\?(?!\.)/.test(condition) && condition.split("||").some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus));
2377
2421
  if (negative) return true;
2378
2422
  }
2379
2423
  return false;
2380
2424
  }
2425
+ function containsConditionalAuthCheck(code) {
2426
+ const pairs = delimiterPairs(code);
2427
+ for (const match of code.matchAll(/\bif\s*\(/g)) {
2428
+ const open = match.index + match[0].length - 1;
2429
+ const close = pairs.get(open);
2430
+ if (close === void 0) continue;
2431
+ let start = close + 1;
2432
+ while (/\s/.test(code[start] ?? "")) start++;
2433
+ let bodyStart = start;
2434
+ let bodyEnd;
2435
+ if (code[start] === "{") {
2436
+ bodyStart = start + 1;
2437
+ bodyEnd = pairs.get(start) ?? Math.min(code.length, start + 600);
2438
+ } else {
2439
+ const semicolon = code.slice(start, start + 400).indexOf(";");
2440
+ bodyEnd = semicolon === -1 ? Math.min(code.length, start + 400) : start + semicolon + 1;
2441
+ }
2442
+ let stop = -1;
2443
+ for (let i = bodyStart; i < bodyEnd; i++) {
2444
+ const window = code.slice(i, i + 16);
2445
+ if (STOPS_REQUEST.test(window) && (i === 0 || !/[\w$]/.test(code[i - 1]))) {
2446
+ stop = i;
2447
+ break;
2448
+ }
2449
+ if (/^if\s*\(/.test(window)) break;
2450
+ const end = pairs.get(i);
2451
+ if (end !== void 0) i = end;
2452
+ }
2453
+ if (stop === -1) continue;
2454
+ const returnsDeniedStatus = DENIED_STATUS.test(code.slice(stop, stop + 400));
2455
+ const condition = code.slice(open + 1, close);
2456
+ if (condition.split(/\|\||&&/).some((part) => rejectsMissingIdentity(part.trim(), returnsDeniedStatus))) return true;
2457
+ }
2458
+ return false;
2459
+ }
2460
+ var authSignalCache = /* @__PURE__ */ new WeakMap();
2381
2461
  function hasAuthSignal(file) {
2462
+ const cached = authSignalCache.get(file);
2463
+ if (cached !== void 0) return cached;
2382
2464
  const code = noiseMaskedOf(file);
2383
- return AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
2465
+ const found = AUTH_ENFORCING_CALL.test(code) || hasConditionalAuthGuard(code);
2466
+ authSignalCache.set(file, found);
2467
+ return found;
2384
2468
  }
2385
2469
  function delimiterPairs(code) {
2386
2470
  const pairs = /* @__PURE__ */ new Map();
@@ -2508,7 +2592,7 @@ function unguardedOperations(file, ops) {
2508
2592
  return true;
2509
2593
  });
2510
2594
  }
2511
- var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>)?\s*\(/;
2595
+ var CLIENT_CONSTRUCTOR = /\b(?:createClient|createServerClient)\s*(?:<[^()]{0,200}>\s*)?\(/;
2512
2596
  var SERVICE_ROLE_ENV = /\bSUPABASE_SERVICE_ROLE(?:_KEY)?\b|\bSERVICE_ROLE_KEY\b|\bSUPABASE_SECRET_KEY\b/;
2513
2597
  var SERVICE_ROLE_LITERAL = new RegExp(String.raw`['"\`](${JWT_SOURCE}|${SB_SECRET_SOURCE})['"\`]`, "g");
2514
2598
  var ENV_BRACKET_ACCESS = /(?:process\.env|import\.meta\.env)\s*\[\s*['"]([^'"]+)['"]\s*\]/g;
@@ -2521,8 +2605,11 @@ function referencesServiceRole(code, source) {
2521
2605
  }
2522
2606
  return false;
2523
2607
  }
2608
+ var NUXT_SUPABASE_ADMIN = /\bserverSupabaseServiceRole\s*(?:<[^()]{0,200}>\s*)?\(/;
2609
+ var NUXT_SUPABASE_SESSION = /\bserverSupabaseClient\s*(?:<[^()]{0,200}>\s*)?\(/;
2524
2610
  function buildsAdminClient(file) {
2525
2611
  const code = noiseMaskedOf(file);
2612
+ if (NUXT_SUPABASE_ADMIN.test(code)) return true;
2526
2613
  if (!CLIENT_CONSTRUCTOR.test(code)) return false;
2527
2614
  const source = commentsMaskedOf(file);
2528
2615
  if (referencesServiceRole(code, source)) return true;
@@ -2535,6 +2622,8 @@ function buildsAdminClient(file) {
2535
2622
  }
2536
2623
  function buildsSessionClient(file) {
2537
2624
  const code = noiseMaskedOf(file);
2625
+ if (NUXT_SUPABASE_ADMIN.test(code)) return false;
2626
+ if (NUXT_SUPABASE_SESSION.test(code)) return true;
2538
2627
  if (!CLIENT_CONSTRUCTOR.test(code)) return false;
2539
2628
  if (referencesServiceRole(code, commentsMaskedOf(file))) return false;
2540
2629
  return /\bcookies\b/.test(code);
@@ -2564,17 +2653,29 @@ function normalizeSpec(spec, fromPath) {
2564
2653
  alias: false
2565
2654
  };
2566
2655
  }
2567
- const alias = /^[@~#]\/(.+)$/.exec(spec);
2656
+ const lib = /^\$lib\/(.+)$/.exec(spec);
2657
+ if (lib) return { key: moduleKey(`src/lib/${lib[1]}`), alias: true };
2658
+ const alias = /^(?:[@~#]|~~|@@)\/(.+)$/.exec(spec);
2568
2659
  return alias ? { key: moduleKey(alias[1]), alias: true } : null;
2569
2660
  }
2570
2661
  function usesAdminClient(route, adminModules, allFiles) {
2571
- return buildsAdminClient(route) || importsAnyOf(route, adminModules, allFiles);
2662
+ return buildsAdminClient(route.file) || importsAnyOf(route, adminModules, allFiles) || autoImportsAnyOf(route, adminModules);
2572
2663
  }
2573
2664
  function usesSessionClient(route, sessionModules, allFiles) {
2574
- return buildsSessionClient(route) || importsAnyOf(route, sessionModules, allFiles);
2575
- }
2576
- function moduleScopeOf(routePath) {
2577
- return /^(.*?)(?:src\/)?(?:app|pages)\/api\//.exec(routePathOf(routePath))?.[1] ?? "";
2665
+ if (route.framework === "sveltekit" && /\blocals\s*\.\s*supabase\b/.test(noiseMaskedOf(route.file))) return true;
2666
+ return buildsSessionClient(route.file) || importsAnyOf(route, sessionModules, allFiles) || autoImportsAnyOf(route, sessionModules);
2667
+ }
2668
+ function autoImportsAnyOf(route, modules) {
2669
+ if (route.framework !== "nuxt") return false;
2670
+ const utils = `${route.scope}server/utils/`;
2671
+ const code = noiseMaskedOf(route.file);
2672
+ return modules.some((module) => module.path.startsWith(utils) && exportedNames(module).some((name) => new RegExp(`(?<![\\w$.])${name.replace(/\$/g, "\\$")}\\s*\\(`).test(code)));
2673
+ }
2674
+ function exportedNames(file) {
2675
+ const names = noiseMaskedOf(file).matchAll(
2676
+ /\bexport\s+(?:async\s+)?(?:function\s*\*?\s*|const\s+|let\s+|var\s+)([A-Za-z_$][\w$]*)/g
2677
+ );
2678
+ return [...names].map((m) => m[1]);
2578
2679
  }
2579
2680
  function importedModules(file, allFiles, aliasScope) {
2580
2681
  const found = [];
@@ -2593,7 +2694,8 @@ function importedModules(file, allFiles, aliasScope) {
2593
2694
  const prefix = aliasScope;
2594
2695
  for (const key of [
2595
2696
  moduleKey(`${prefix}${target.key}`),
2596
- moduleKey(`${prefix}src/${target.key}`)
2697
+ moduleKey(`${prefix}src/${target.key}`),
2698
+ moduleKey(`${prefix}app/${target.key}`)
2597
2699
  ]) {
2598
2700
  found.push(...index.get(key) ?? []);
2599
2701
  }
@@ -2605,8 +2707,8 @@ function importsAnyOf(route, modules, allFiles) {
2605
2707
  if (modules.length === 0) return false;
2606
2708
  const targetPaths = new Set(modules.map((file) => file.path));
2607
2709
  const visited = /* @__PURE__ */ new Set();
2608
- const aliasScope = moduleScopeOf(route.path);
2609
- const queue = importedModules(route, allFiles, aliasScope);
2710
+ const aliasScope = route.scope;
2711
+ const queue = importedModules(route.file, allFiles, aliasScope);
2610
2712
  while (queue.length > 0) {
2611
2713
  const file = queue.pop();
2612
2714
  if (targetPaths.has(file.path)) return true;
@@ -2856,7 +2958,7 @@ function middlewareCovers(ctx, routePath, url) {
2856
2958
  const mw = middlewareFor(ctx, routePath);
2857
2959
  if (!mw) return false;
2858
2960
  if (!hasAuthSignal(mw)) return false;
2859
- const config = extractMatcherConfig(mw);
2961
+ const { config, readable } = compiledMatchersOf(mw);
2860
2962
  if (config.kind === "absent") return true;
2861
2963
  if (config.kind === "unreadable") {
2862
2964
  ctx.reportIncomplete(
@@ -2865,11 +2967,39 @@ function middlewareCovers(ctx, routePath, url) {
2865
2967
  );
2866
2968
  return true;
2867
2969
  }
2868
- const parsed = config.patterns.map(matcherToRegex);
2869
- const readable = parsed.filter((re) => re !== null);
2870
2970
  if (readable.length === 0) return true;
2871
2971
  return readable.some((re) => re.test(url));
2872
2972
  }
2973
+ var matcherCache = /* @__PURE__ */ new WeakMap();
2974
+ function compiledMatchersOf(file) {
2975
+ const cached = matcherCache.get(file);
2976
+ if (cached !== void 0) return cached;
2977
+ const config = extractMatcherConfig(file);
2978
+ const readable = config.kind === "patterns" ? config.patterns.map(matcherToRegex).filter((re) => re !== null) : [];
2979
+ const compiled = { config, readable };
2980
+ matcherCache.set(file, compiled);
2981
+ return compiled;
2982
+ }
2983
+ function globalGuardFor(ctx, route) {
2984
+ const relative3 = (file) => file.path.startsWith(route.scope) ? file.path.slice(route.scope.length) : null;
2985
+ const candidates = ctx.files.filter((file) => {
2986
+ const path = relative3(file);
2987
+ if (path === null) return false;
2988
+ if (route.framework === "sveltekit") return /^src\/hooks\.server\.[mc]?[jt]s$/.test(path);
2989
+ if (route.framework === "nuxt") return /^server\/middleware\/.+\.[mc]?[jt]s$/.test(path);
2990
+ return false;
2991
+ });
2992
+ return candidates.find(containsAuthCheck)?.path ?? null;
2993
+ }
2994
+ var authCheckCache = /* @__PURE__ */ new WeakMap();
2995
+ function containsAuthCheck(file) {
2996
+ const cached = authCheckCache.get(file);
2997
+ if (cached !== void 0) return cached;
2998
+ const code = noiseMaskedOf(file);
2999
+ const found = AUTH_ENFORCING_CALL.test(code) || containsConditionalAuthCheck(code);
3000
+ authCheckCache.set(file, found);
3001
+ return found;
3002
+ }
2873
3003
  var SECRET_SHAPED = /['"`]([A-Za-z0-9_\-.]{32,})['"`]/g;
2874
3004
  function excerptFor(file, line) {
2875
3005
  const raw = (file.lines[line - 1] ?? "").trim();
@@ -2885,7 +3015,7 @@ var apiAuthRule = {
2885
3015
  id: "api/db-access-without-auth",
2886
3016
  severity: "P0",
2887
3017
  check(ctx) {
2888
- const routes = ctx.files.filter((f) => isApiRoute(f.path));
3018
+ const routes = ctx.files.map(routeOf).filter((route) => route !== null);
2889
3019
  if (routes.length === 0) return [];
2890
3020
  for (const middlewareFile of ctx.files.filter((f) => MIDDLEWARE_FILE.test(f.path))) {
2891
3021
  const config = extractMatcherConfig(middlewareFile);
@@ -2901,26 +3031,31 @@ var apiAuthRule = {
2901
3031
  const sessionModules = ctx.files.filter(buildsSessionClient);
2902
3032
  const findings = [];
2903
3033
  for (const route of routes) {
2904
- const ops = unguardedOperations(route, findDataOps(route));
3034
+ const ops = unguardedOperations(route.file, findDataOps(route.file));
2905
3035
  if (ops.length === 0) continue;
2906
- const url = routeUrl(route.path);
2907
- if (isAuthEndpoint(url)) continue;
2908
- if (middlewareCovers(ctx, route.path, url)) continue;
3036
+ const url = route.url;
3037
+ if (isAuthEndpoint(route)) continue;
3038
+ if ((route.framework === "next" || route.framework === "astro") && middlewareCovers(ctx, route.file.path, url)) continue;
3039
+ const globalGuard = globalGuardFor(ctx, route);
3040
+ const globalNote = globalGuard === null ? [] : [
3041
+ `${globalGuard} contains an authentication check that may cover ${url}. Its path conditions are written in code, so the scan cannot tell which routes it applies to; this finding is reported at lower confidence. Confirm that check runs for ${url}.`
3042
+ ];
2909
3043
  const admin = usesAdminClient(route, adminModules, ctx.files);
2910
3044
  const hit = ops.find((o) => o.writes) ?? ops[0];
2911
- const line = lineNumberAt(lineStartsOf(route.content), hit.index);
2912
- const excerpt = excerptFor(route, line);
3045
+ const line = lineNumberAt(lineStartsOf(route.file.content), hit.index);
3046
+ const excerpt = excerptFor(route.file, line);
2913
3047
  if (admin) {
2914
3048
  findings.push({
2915
3049
  ruleId: "api/admin-db-access-without-auth",
2916
3050
  severity: "P0",
2917
- // 管理员客户端缺少鉴权时使用确定置信度。
2918
- confidence: "certain",
3051
+ // 管理员客户端缺少鉴权时使用确定置信度;可能受全局鉴权覆盖时降为疑似。
3052
+ confidence: globalGuard === null ? "certain" : "likely",
2919
3053
  title: `Anyone can call ${url} and it queries your database as admin`,
2920
- file: route.path,
3054
+ file: route.file.path,
2921
3055
  line,
2922
3056
  excerpt,
2923
3057
  why: [
3058
+ ...globalNote,
2924
3059
  `This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
2925
3060
  `The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
2926
3061
  `If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
@@ -2945,10 +3080,11 @@ var apiAuthRule = {
2945
3080
  // 公开写入可能是业务设计,保留疑似置信度。
2946
3081
  confidence: "likely",
2947
3082
  title: `${url} writes to your database with no sign-in check`,
2948
- file: route.path,
3083
+ file: route.file.path,
2949
3084
  line,
2950
3085
  excerpt,
2951
3086
  why: [
3087
+ ...globalNote,
2952
3088
  `This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
2953
3089
  `If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
2954
3090
  `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.`
@@ -2992,7 +3128,7 @@ function headerExpression(content, start) {
2992
3128
  return content.slice(start, end);
2993
3129
  }
2994
3130
  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;
3131
+ var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*\s*)?)?\(/gi;
2996
3132
  var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
2997
3133
  function closingParameterList(source, open) {
2998
3134
  let depth = 0;
@@ -3114,8 +3250,14 @@ function callbackAnswer(params, body) {
3114
3250
  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
3251
  var ACAC_HEADER = /['"]Access-Control-Allow-Credentials['"][\s\S]{0,40}?\btrue\b/gi;
3116
3252
  var CORS_CREDENTIALS_OPTION = /\bcredentials\s*:\s*true\b/g;
3253
+ var TRAILING_PUNCTUATION = /[\s;,)}\]!]/;
3254
+ function trimTrailingPunctuation(text) {
3255
+ let end = text.length;
3256
+ while (end > 0 && TRAILING_PUNCTUATION.test(text[end - 1])) end--;
3257
+ return text.slice(0, end);
3258
+ }
3117
3259
  function classifyOrigin(raw) {
3118
- let v = raw.trim().replace(/[\s;,)}\]!]+$/, "");
3260
+ let v = trimTrailingPunctuation(raw.trim());
3119
3261
  const opening = v[0];
3120
3262
  if (opening === '"' || opening === "'" || opening === "`") {
3121
3263
  const close = v.indexOf(opening, 1);
@@ -4510,15 +4652,15 @@ var RULE_CATALOG = [
4510
4652
  "Admin database access without a recognized auth guard",
4511
4653
  "P0",
4512
4654
  "certain",
4513
- "Next.js App/Pages API handlers, including workspace applications.",
4514
- "Syntactic guards only; runtime authentication and business authorization are not verified."
4655
+ "Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro, including workspace applications.",
4656
+ "Syntactic guards only; runtime authentication and business authorization are not verified. Guards in SvelteKit hooks or Nuxt server middleware lower confidence."
4515
4657
  ),
4516
4658
  rule(
4517
4659
  "api/db-write-without-auth",
4518
4660
  "Database write without a recognized auth guard",
4519
4661
  "P1",
4520
4662
  "likely",
4521
- "Next.js App/Pages API handlers.",
4663
+ "Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro.",
4522
4664
  "Session-scoped clients and indirect guards may need manual review."
4523
4665
  ),
4524
4666
  rule(
@@ -4625,7 +4767,7 @@ function renderRuleCatalog() {
4625
4767
  }
4626
4768
 
4627
4769
  // src/cli.ts
4628
- var VERSION = true ? "0.3.1" : "0.0.0-dev";
4770
+ var VERSION = true ? "0.3.2" : "0.0.0-dev";
4629
4771
  function finish(code) {
4630
4772
  process.exitCode = code;
4631
4773
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canship",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Static scanner for exposed credentials and open access rules in JS/TS front-end projects. Scans local files without executing project code or making network requests.",
5
5
  "keywords": [
6
6
  "security",