canship 0.3.0 → 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
 
@@ -46,6 +46,7 @@ npx canship .
46
46
  | `--only=ids` | 仅执行匹配规则,支持逗号分隔及重复参数 |
47
47
  | `--skip=ids` | 排除匹配规则,支持逗号分隔及重复参数 |
48
48
  | `--no-config` | 忽略项目配置 |
49
+ | `--no-ignore-markers` | 不遵从被扫描源码中的忽略标记 |
49
50
  | `--list-rules` | 列出规则及限制,不扫描;支持 `--json` |
50
51
  | `--no-excerpts` | 所有报告省略源码摘录,不改变结果和退出码 |
51
52
  | `-h`, `--help` | 显示帮助 |
@@ -74,7 +75,7 @@ JSON 使用独立于包版本的 `schemaVersion: 1`,npm 包附带 [结构定
74
75
 
75
76
  保存为 `.github/workflows/canship.yml`,在推送和 PR 时扫描并生成统计摘要。安装扫描器需要联网,扫描不联网;不安装或执行项目依赖,默认不上传 SARIF。
76
77
 
77
- 示例固定 Action 提交,安装 npm 版 `0.2.1`;`version` 不使用仓库中的未发布源码。Action 兼容 0.2.1 无 `schemaVersion` 的报告。
78
+ 示例固定 Action 提交,显式安装 npm 版 `0.3.1`;`version` 不使用仓库中的未发布源码。Action 兼容 0.2.1 无 `schemaVersion` 的报告。
78
79
 
79
80
  ```yaml
80
81
  name: canship
@@ -89,15 +90,15 @@ jobs:
89
90
  with:
90
91
  fetch-depth: 0
91
92
  persist-credentials: false
92
- - uses: Tasomei/canship@37cf06e014168968a88679157564e5c489f99c50
93
+ - uses: Tasomei/canship@2d33cce0ad8439e34f01f5218fdaaf4657215e3d
93
94
  with:
94
- version: '0.2.1'
95
+ version: '0.3.1'
95
96
  ```
96
97
 
97
98
  | 输入 | 默认值 | 说明 |
98
99
  |---|---|---|
99
100
  | `path` | `.` | 检出目录内的扫描路径 |
100
- | `version` | `0.2.1` | 精确 npm 版本,不接受范围或标签 |
101
+ | `version` | `0.3.1` | 精确 npm 版本,不接受范围或标签 |
101
102
  | `fail-on` | `blocking` | `blocking`:确定的 P0/P1;`any`:全部结果;`none`:仅报告结果 |
102
103
  | `only` / `skip` | 未设置 | 互斥,逗号分隔的规则选择器 |
103
104
  | `baseline` | 未设置 | 相对扫描目录的已有基线 |
@@ -120,7 +121,7 @@ jobs:
120
121
  }
121
122
  ```
122
123
 
123
- 命令行参数优先;`only`、`skip` 互斥,接受规则 ID 或命名空间。未选规则不执行,`ruleSelection.removed` 仅统计已执行规则中被过滤的结果。扫描不可信项目时使用 `--no-config`;`bestEffort` 仅限命令行设置。
124
+ 命令行参数优先;`only`、`skip` 互斥,接受规则 ID 或命名空间。未选规则不执行,`ruleSelection.removed` 仅统计已执行规则中被过滤的结果。扫描不可信项目时同时使用 `--no-config --no-ignore-markers`,二者均由被扫描项目控制;`bestEffort` 仅限命令行设置。
124
125
 
125
126
  ### 忽略标记
126
127
 
@@ -131,7 +132,7 @@ jobs:
131
132
  const corsOptions = { origin: '*', credentials: true }
132
133
  ```
133
134
 
134
- 报告披露忽略、规则筛选和基线抑制信息;主动排除不标记为未完成。
135
+ 报告披露忽略、规则筛选和基线抑制信息;主动排除不标记为未完成。标记可使退出码降为 `0`;`--no-config` 不影响标记,`--no-ignore-markers` 使两种标记均失效。
135
136
 
136
137
  ### 基线
137
138
 
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
 
@@ -46,6 +46,7 @@ Omitting the path scans the current directory.
46
46
  | `--only=ids` | Run matching rules; comma-separated and repeatable |
47
47
  | `--skip=ids` | Exclude matching rules; comma-separated and repeatable |
48
48
  | `--no-config` | Ignore project configuration |
49
+ | `--no-ignore-markers` | Disregard ignore markers in scanned source |
49
50
  | `--list-rules` | List rules and limits without scanning; supports `--json` |
50
51
  | `--no-excerpts` | Omit source excerpts from every report; preserve findings and exit status |
51
52
  | `-h`, `--help` | Show help |
@@ -74,7 +75,7 @@ JSON uses `schemaVersion: 1`, independent of the package version; the npm packag
74
75
 
75
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.
76
77
 
77
- The example pins the Action commit and installs npm version `0.2.1`; `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`.
78
79
 
79
80
  ```yaml
80
81
  name: canship
@@ -89,15 +90,15 @@ jobs:
89
90
  with:
90
91
  fetch-depth: 0
91
92
  persist-credentials: false
92
- - uses: Tasomei/canship@37cf06e014168968a88679157564e5c489f99c50
93
+ - uses: Tasomei/canship@2d33cce0ad8439e34f01f5218fdaaf4657215e3d
93
94
  with:
94
- version: '0.2.1'
95
+ version: '0.3.1'
95
96
  ```
96
97
 
97
98
  | Input | Default | Meaning |
98
99
  |---|---|---|
99
100
  | `path` | `.` | Directory within the checkout |
100
- | `version` | `0.2.1` | Exact npm version; no ranges or tags |
101
+ | `version` | `0.3.1` | Exact npm version; no ranges or tags |
101
102
  | `fail-on` | `blocking` | `blocking`: certain P0/P1; `any`: all findings; `none`: findings only reported |
102
103
  | `only` / `skip` | unset | Mutually exclusive, comma-separated rule selectors |
103
104
  | `baseline` | unset | Existing baseline relative to the scanned directory |
@@ -120,7 +121,7 @@ Place `canship.config.json` in the scanned directory. Supported keys: `baseline`
120
121
  }
121
122
  ```
122
123
 
123
- CLI options take precedence. `only` and `skip` are mutually exclusive and accept rule IDs or namespaces. Unselected rules do not run; `ruleSelection.removed` counts filtered findings only from executed rules. Use `--no-config` for untrusted projects. `bestEffort` is CLI-only.
124
+ CLI options take precedence. `only` and `skip` are mutually exclusive and accept rule IDs or namespaces. Unselected rules do not run; `ruleSelection.removed` counts filtered findings only from executed rules. For untrusted projects use `--no-config --no-ignore-markers`; the scanned project controls both. `bestEffort` is CLI-only.
124
125
 
125
126
  ### Ignore markers
126
127
 
@@ -131,7 +132,7 @@ A standalone `canship-ignore-file` comment excludes a file; `canship-ignore-next
131
132
  const corsOptions = { origin: '*', credentials: true }
132
133
  ```
133
134
 
134
- Reports disclose exclusions, rule selection, and baseline suppression. Deliberate exclusions do not mark the scan incomplete.
135
+ Reports disclose exclusions, rule selection, and baseline suppression. Deliberate exclusions do not mark the scan incomplete. Markers can lower the exit code to `0`; `--no-config` does not affect them, `--no-ignore-markers` disables both kinds.
135
136
 
136
137
  ### Baselines
137
138
 
package/dist/cli.js CHANGED
@@ -779,7 +779,7 @@ function isExampleContext(relPath) {
779
779
  if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
780
780
  return false;
781
781
  }
782
- function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}) {
782
+ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true) {
783
783
  const skipped = [];
784
784
  const ignored = [];
785
785
  const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
@@ -863,7 +863,7 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
863
863
  continue;
864
864
  }
865
865
  const lines = content.split(/\r?\n/);
866
- if (hasIgnoreMarker(lines)) {
866
+ if (honorIgnoreMarkers && hasIgnoreMarker(lines)) {
867
867
  ignored.push(relPath);
868
868
  continue;
869
869
  }
@@ -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);
@@ -3453,7 +3595,8 @@ async function scan(root, options = {}) {
3453
3595
  const started = Date.now();
3454
3596
  const gitExecutable = resolveGitExecutable(root);
3455
3597
  const git2 = detectGitRepo(root, gitExecutable);
3456
- const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
3598
+ const honorIgnoreMarkers = options.honorIgnoreMarkers !== false;
3599
+ const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable, {}, honorIgnoreMarkers);
3457
3600
  const findings = [];
3458
3601
  const errors = [];
3459
3602
  const fileRules = FILE_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
@@ -3488,10 +3631,8 @@ async function scan(root, options = {}) {
3488
3631
  errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
3489
3632
  }
3490
3633
  }
3491
- const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
3492
- dedupe(downgradeExampleContext(findings, files)),
3493
- files
3494
- );
3634
+ const deduped = dedupe(downgradeExampleContext(findings, files));
3635
+ const { kept, ignored: ignoredFindings } = honorIgnoreMarkers ? suppressIgnoredLines(deduped, files) : { kept: deduped, ignored: [] };
3495
3636
  const selected = applyRuleSelection(kept, options);
3496
3637
  const counts = /* @__PURE__ */ new Map();
3497
3638
  const bounded = sortFindings(selected.kept).filter((finding) => {
@@ -4511,15 +4652,15 @@ var RULE_CATALOG = [
4511
4652
  "Admin database access without a recognized auth guard",
4512
4653
  "P0",
4513
4654
  "certain",
4514
- "Next.js App/Pages API handlers, including workspace applications.",
4515
- "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."
4516
4657
  ),
4517
4658
  rule(
4518
4659
  "api/db-write-without-auth",
4519
4660
  "Database write without a recognized auth guard",
4520
4661
  "P1",
4521
4662
  "likely",
4522
- "Next.js App/Pages API handlers.",
4663
+ "Server routes in Next.js, SvelteKit, Nuxt, Remix/React Router, and Astro.",
4523
4664
  "Session-scoped clients and indirect guards may need manual review."
4524
4665
  ),
4525
4666
  rule(
@@ -4626,7 +4767,7 @@ function renderRuleCatalog() {
4626
4767
  }
4627
4768
 
4628
4769
  // src/cli.ts
4629
- var VERSION = true ? "0.3.0" : "0.0.0-dev";
4770
+ var VERSION = true ? "0.3.2" : "0.0.0-dev";
4630
4771
  function finish(code) {
4631
4772
  process.exitCode = code;
4632
4773
  }
@@ -4691,6 +4832,7 @@ function parseArgs(argv) {
4691
4832
  skip: [],
4692
4833
  sarif: null,
4693
4834
  noConfig: false,
4835
+ noIgnoreMarkers: false,
4694
4836
  help: false,
4695
4837
  version: false,
4696
4838
  listRules: false,
@@ -4759,6 +4901,9 @@ function parseArgs(argv) {
4759
4901
  case "--no-config":
4760
4902
  args.noConfig = true;
4761
4903
  break;
4904
+ case "--no-ignore-markers":
4905
+ args.noIgnoreMarkers = true;
4906
+ break;
4762
4907
  case "--list-rules":
4763
4908
  args.listRules = true;
4764
4909
  break;
@@ -4807,6 +4952,9 @@ var HELP = `
4807
4952
  --sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
4808
4953
  (default canship.sarif)
4809
4954
  --no-config Ignore canship.config.json in the scanned directory
4955
+ --no-ignore-markers
4956
+ Disregard canship-ignore-file and canship-ignore-next-line
4957
+ markers; use with --no-config for untrusted projects
4810
4958
  --list-rules List rule IDs, scope, and limits; add --json for structured output
4811
4959
  --no-excerpts Omit source excerpts from every report; paths and descriptions remain
4812
4960
  -h, --help Show this help
@@ -4887,7 +5035,7 @@ async function main() {
4887
5035
  const showAll = args.showAll || config.all === true;
4888
5036
  const bestEffort = args.bestEffort;
4889
5037
  const baselinePath = args.baseline !== null ? resolve2(args.baseline) : args.baselineDefault ? resolve2(args.root, DEFAULT_BASELINE_PATH) : config.baseline !== void 0 ? insideProject(args.root, config.baseline) : null;
4890
- const scanned = await scan(args.root, { only, skip });
5038
+ const scanned = await scan(args.root, { only, skip, honorIgnoreMarkers: !args.noIgnoreMarkers });
4891
5039
  if (args.baselineWrite !== null || args.baselineWriteDefault) {
4892
5040
  const target = args.baselineWrite !== null ? resolve2(args.baselineWrite) : resolve2(args.root, DEFAULT_BASELINE_PATH);
4893
5041
  const baseline = buildBaseline(scanned.findings);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canship",
3
- "version": "0.3.0",
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",