canship 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README-zh-CN.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # canship
2
2
 
3
- 面向 JavaScript / TypeScript 项目的本地静态扫描器,检测凭据暴露和访问控制配置错误。不执行项目代码,不上传文件,扫描过程不联网。
3
+ 面向 JavaScript / TypeScript 项目的本地静态扫描器,检测凭据暴露与访问控制配置错误。扫描不执行项目代码、不上传文件、不联网。
4
+
5
+ 本文档对应 0.3.x,适用于匹配的 [npm 版本](https://www.npmjs.com/package/canship) 或本地构建。
4
6
 
5
7
  ```powershell
6
8
  npx canship .
7
9
  ```
8
10
 
9
- 要求 Node.js ≥18,无运行时依赖。Git 用于读取本地历史;仓库中无法使用 Git 时,扫描标记为未完成。首次使用 `npx` 可能需要从 npm 下载软件包。
11
+ 要求 Node.js ≥18,无运行时依赖。`npx` 可能联网下载软件包;扫描仅使用本地文件与 Git 历史。Git 仓库中无法调用 Git 时,扫描标记为未完成。
10
12
 
11
13
  [English](./README.md)
12
14
 
@@ -16,18 +18,18 @@ npx canship .
16
18
  |---|---|
17
19
  | 硬编码凭据、私钥及含密码的数据库连接串 | P0 |
18
20
  | 公开环境变量中的私密值 | P0 |
19
- | 客户端可访问的 Supabase 管理员密钥 | P0 |
21
+ | Supabase 管理员密钥暴露至客户端 | P0 |
20
22
  | Git 跟踪及历史 `.env` 文件中的凭据或疑似私密值 | P0 |
21
- | Supabase 表未启用行级安全(RLS) | P1 |
23
+ | Supabase 迁移记录中未启用行级安全(RLS)的表 | P1 |
22
24
  | Firebase 无条件访问及固定日期测试规则 | P1 |
23
- | Next.js API 数据操作缺少鉴权 | P0 / P1 |
25
+ | Next.js API 数据操作未识别到鉴权 | P0 / P1 |
24
26
  | 携带凭据的 CORS 来源回显或通配符配置 | P1 / P2 |
25
27
 
26
- 识别 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 `/api`,支持 App Router、Pages Router、路由组和工作区应用。
27
29
 
28
- 置信度分为确定(`certain`)和疑似(`likely`)。默认只展示确定结果;隐藏的疑似结果仍影响退出码。
30
+ 置信度分为确定(`certain`)和疑似(`likely`),仅描述静态证据,不验证凭据有效性或线上状态。默认只展示确定结果;隐藏的疑似结果仍影响退出码。
29
31
 
30
- ## 参数
32
+ ## 用法
31
33
 
32
34
  省略路径时扫描当前目录。
33
35
 
@@ -35,32 +37,79 @@ npx canship .
35
37
  |---|---|
36
38
  | `-a`, `--all` | 展示疑似结果 |
37
39
  | `--json` | 输出 JSON |
38
- | `--fix-prompt` | 输出编程助手修复指令及独立的人工操作清单 |
39
- | `--report[=文件]` | 写入 HTML 报告,默认 `canship-report.html` |
40
- | `--sarif[=文件]` | 写入 SARIF 2.1.0 报告,默认 `canship.sarif` |
40
+ | `--fix-prompt` | 输出修复指令及独立的人工操作清单 |
41
+ | `--report[=file]` | 写入 HTML,默认 `canship-report.html` |
42
+ | `--sarif[=file]` | 写入 SARIF 2.1.0,默认 `canship.sarif` |
41
43
  | `--best-effort` | 无结果时,允许不完整扫描退出 `0` |
42
- | `--baseline[=文件]` | 应用基线,默认 `canship-baseline.json` |
43
- | `--baseline-write[=文件]` | 记录当前结果为基线后退出 |
44
- | `--only=规则` | 仅执行匹配规则,支持逗号分隔及重复参数 |
45
- | `--skip=规则` | 排除匹配规则,支持逗号分隔及重复参数 |
44
+ | `--baseline[=file]` | 应用基线,默认 `canship-baseline.json` |
45
+ | `--baseline-write[=file]` | 写入当前结果为基线后退出,同上默认路径 |
46
+ | `--only=ids` | 仅执行匹配规则,支持逗号分隔及重复参数 |
47
+ | `--skip=ids` | 排除匹配规则,支持逗号分隔及重复参数 |
46
48
  | `--no-config` | 忽略项目配置 |
49
+ | `--list-rules` | 列出规则及限制,不扫描;支持 `--json` |
50
+ | `--no-excerpts` | 所有报告省略源码摘录,不改变结果和退出码 |
47
51
  | `-h`, `--help` | 显示帮助 |
48
52
  | `-v`, `--version` | 显示版本 |
49
53
 
50
- `--json` 与 `--fix-prompt` 互斥;HTML 和 SARIF 可与任一输出模式组合。报告正文为英文,各格式均用 `--all` 包含疑似结果。
54
+ `--json` 与 `--fix-prompt` 互斥;HTML、SARIF 可与任一模式组合。报告正文为英文,`--all` 对所有格式生效。
51
55
 
52
56
  ### 退出码
53
57
 
54
58
  | 退出码 | 含义 |
55
59
  |---|---|
56
- | `0` | 无结果且扫描完整,或由 `--best-effort` 接受不完整扫描 |
60
+ | `0` | 无结果,且扫描完整或由 `--best-effort` 接受不完整扫描 |
57
61
  | `1` | 存在 `certain` 的 P0/P1 结果 |
58
62
  | `2` | 存在其他结果,包括被隐藏的 `likely` |
59
63
  | `3` | 参数或工具错误,或未被接受的不完整扫描 |
60
64
 
61
- 结果对应的退出码优先于不完整状态;`--best-effort` 不改变 `1` 或 `2`。JSON 用 `partial`、`errors`、`skipped` 保留完整性信息,SARIF 提供执行状态和诊断通知。
65
+ 结果退出码优先于不完整状态;`--best-effort` 不改变 `1` 或 `2`。
66
+
67
+ ### 机器可读输出
68
+
69
+ JSON 使用独立于包版本的 `schemaVersion: 1`,npm 包附带 [结构定义](./schemas/scan-report-v1.schema.json)。调用方应兼容新增字段、拒绝不支持的结构版本;`--list-rules --json` 为独立的 `kind: "rule-catalog"` 文档。
70
+
71
+ `findings` 为抑制和筛选后的结果;`hiddenLikely`、`baselineSuppressed`、`baselineStale` 提供相关统计。完整性需另查 `partial`、`errors`、`skipped`、`filesScanned`;SARIF 提供执行状态与诊断通知。
72
+
73
+ ## GitHub Action
74
+
75
+ 保存为 `.github/workflows/canship.yml`,在推送和 PR 时扫描并生成统计摘要。安装扫描器需要联网,扫描不联网;不安装或执行项目依赖,默认不上传 SARIF。
76
+
77
+ 示例固定 Action 提交,安装 npm 版 `0.2.1`;`version` 不使用仓库中的未发布源码。Action 兼容 0.2.1 无 `schemaVersion` 的报告。
62
78
 
63
- ## 配置与忽略
79
+ ```yaml
80
+ name: canship
81
+ on: [push, pull_request]
82
+ permissions:
83
+ contents: read
84
+ jobs:
85
+ scan:
86
+ runs-on: ubuntu-latest
87
+ steps:
88
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
89
+ with:
90
+ fetch-depth: 0
91
+ persist-credentials: false
92
+ - uses: Tasomei/canship@37cf06e014168968a88679157564e5c489f99c50
93
+ with:
94
+ version: '0.2.1'
95
+ ```
96
+
97
+ | 输入 | 默认值 | 说明 |
98
+ |---|---|---|
99
+ | `path` | `.` | 检出目录内的扫描路径 |
100
+ | `version` | `0.2.1` | 精确 npm 版本,不接受范围或标签 |
101
+ | `fail-on` | `blocking` | `blocking`:确定的 P0/P1;`any`:全部结果;`none`:仅报告结果 |
102
+ | `only` / `skip` | 未设置 | 互斥,逗号分隔的规则选择器 |
103
+ | `baseline` | 未设置 | 相对扫描目录的已有基线 |
104
+ | `use-config` | `false` | 启用项目配置 |
105
+ | `upload-sarif` | `false` | 上传 SARIF 至 GitHub 代码扫描 |
106
+ | `category` | `canship` | 每个扫描目标使用独立分类 |
107
+
108
+ 输出:`exit-code`、`findings`、`blocking`、`partial`。策略统计包含疑似结果,基线与忽略仍生效;扫描不完整、工具错误或报告不兼容始终失败,包括 `fail-on: none`。
109
+
110
+ 上传 SARIF 需 `security-events: write` 及 [GitHub 代码扫描支持](https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/integrate-with-existing-tools/upload-sarif-file),Fork PR 可能无权限。上传前审阅报告中的路径与详情;不可信 PR 使用 `pull_request`,不要使用 `pull_request_target`。Action 为后续步骤设置 Node.js 22,需要其他版本时应使用独立扫描任务。
111
+
112
+ ## 配置与基线
64
113
 
65
114
  扫描目录中的 `canship.config.json` 支持 `baseline`、`only`、`skip`、`all`:
66
115
 
@@ -71,18 +120,20 @@ npx canship .
71
120
  }
72
121
  ```
73
122
 
74
- 命令行参数优先;`only` 与 `skip` 互斥,接受完整规则 ID 或命名空间。无关规则不执行;`ruleSelection.removed` 仅统计已执行规则中被过滤的结果。扫描不可信项目时使用 `--no-config`;`bestEffort` 仅支持命令行设置。
123
+ 命令行参数优先;`only`、`skip` 互斥,接受规则 ID 或命名空间。未选规则不执行,`ruleSelection.removed` 仅统计已执行规则中被过滤的结果。扫描不可信项目时使用 `--no-config`;`bestEffort` 仅限命令行设置。
124
+
125
+ ### 忽略标记
75
126
 
76
- 以独占注释行的 `canship-ignore-file` 排除整个文件,或用 `canship-ignore-next-line` 忽略下一行。后者可附加规则 ID:
127
+ 独占注释行的 `canship-ignore-file` 排除整个文件;`canship-ignore-next-line` 忽略下一行,可附规则 ID:
77
128
 
78
129
  ```ts
79
130
  // canship-ignore-next-line cors/wildcard-with-credentials
80
131
  const corsOptions = { origin: '*', credentials: true }
81
132
  ```
82
133
 
83
- 报告披露忽略、筛选和基线抑制信息。主动忽略不使扫描标记为未完成。
134
+ 报告披露忽略、规则筛选和基线抑制信息;主动排除不标记为未完成。
84
135
 
85
- ## 基线
136
+ ### 基线
86
137
 
87
138
  记录已有结果:
88
139
 
@@ -96,21 +147,22 @@ npx canship --baseline-write
96
147
  npx canship --baseline
97
148
  ```
98
149
 
99
- 裸参数使用扫描目录,显式路径相对工作目录。写入成功退出 `0`;扫描不完整或启用规则筛选时会提示。
150
+ 默认基线位于扫描目录,显式路径相对工作目录。写入成功退出 `0`,不代表无问题;扫描不完整或启用规则筛选时会提示。
100
151
 
101
- 基线格式为第 2 版,不含源码摘录,但披露路径、规则、标题和问题类型,提交前需审阅。指纹不含行号,移动行号不会产生新结果,替换凭据会。缺失、损坏及第 1 版基线均退出 `3`。
152
+ 基线格式为 v2,移动行号不改变指纹,替换凭据会改变。缺失、损坏及 v1 基线均退出 `3`。基线不含源码,但包含路径、规则和问题描述,提交前需审阅。
102
153
 
103
- ## 限制
154
+ ## 隐私与限制
104
155
 
105
- - 静态启发式分析可能误报或漏报,不验证运行时行为、限流、注入、依赖漏洞及业务授权。无发现不代表项目安全。
106
- - 脱敏仅覆盖已识别格式;未知秘密可能出现在证据行中,报告应作为内部材料。Google/Firebase/Maps 的 `AIza...` 值按公开标识符处理。
107
- - 单文件最多读取 2 MiB;单次累计读取最多 128 MiB、10,000 个文件;目录最多 16 层。跨规则每文件最多输出 100 条结果,优先保留高严重度、高置信度结果。
108
- - Git 历史每文件最多检查 100 个相关版本,单条 Git 命令超时为 30 秒。超限或超时均披露检查缺口。
109
- - 不跟随符号链接;嵌套仓库及子模块需分别扫描。扫描范围内的跳过项使扫描未完成,已排除的构建和依赖目录除外。
156
+ - 静态分析可能误报或漏报,不验证线上行为,不覆盖限流、注入、依赖漏洞或业务授权。无结果不等于安全。
157
+ - 脱敏仅覆盖已识别格式,未知秘密可能出现在源码摘录中。`--no-excerpts` 省略摘录,JSON 以 `excerptsOmitted` 标明;路径、名称、说明及基线不匿名化,分享前仍需审阅。
158
+ - Google/Firebase/Maps 的 `AIza...` 值按公开标识符处理,不单凭其值判定泄露。
159
+ - 读取上限:单文件 2 MiB,单次 128 MiB、10,000 个文件,目录 16 层。每文件跨规则最多 100 条结果,优先保留高严重度、高置信度结果。
160
+ - Git 历史每文件最多 100 个相关版本,单条 Git 命令超时 30 秒。超限、超时均报告检查缺口。
161
+ - 不跟随符号链接;嵌套仓库、子模块需单独扫描。范围内跳过项使扫描未完成,内置排除的构建和依赖目录除外。
110
162
 
111
163
  ## 开发
112
164
 
113
- 检测规则需同时包含应检出和不应检出的用例,参见 [测试夹具](./test/fixtures/)。
165
+ 新增规则需包含应检出与不应检出的 [测试用例](./test/fixtures/)。
114
166
 
115
167
  ```powershell
116
168
  npm ci
@@ -120,6 +172,28 @@ npm ci
120
172
  npm run prepublishOnly
121
173
  ```
122
174
 
175
+ 离线评估:
176
+
177
+ ```powershell
178
+ npm run evaluate
179
+ ```
180
+
181
+ 评估集含 10 个构造用例、9 个固定版本上游示例及变体,同时纳入 `npm test`;[来源与许可](./test/fixtures/evaluation/) 随样本保存。断言覆盖规则、文件、严重度、置信度与扫描完整性,不代表真实项目检出率。
182
+
183
+ 另有 5 个 [应用目录快照](./test/evaluation/projects.json)。准备阶段联网并校验 Git 对象摘要,目标须为 Git 仓库外的新目录:
184
+
185
+ ```powershell
186
+ node scripts/fetch-evaluation-projects.mjs "$env:TEMP/canship-evaluation"
187
+ ```
188
+
189
+ 随后离线评估,不安装或运行样本依赖:
190
+
191
+ ```powershell
192
+ npm run evaluate:projects -- "$env:TEMP/canship-evaluation"
193
+ ```
194
+
195
+ CI 使用同一评估集,不验证 Git 历史或线上行为。
196
+
123
197
  ## 许可
124
198
 
125
- [MIT](./LICENSE)
199
+ [MIT](./LICENSE)。Supabase、Firebase 样本保留 Apache-2.0,Next.js、`cors` 样本保留 MIT;各样本附来源及许可证。
package/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # canship
2
2
 
3
- A local static scanner for JavaScript and TypeScript projects. Detects exposed credentials and access-control misconfigurations without executing project code, uploading files, or making network requests during scans.
3
+ A local static scanner for JavaScript and TypeScript projects. Detects exposed credentials and access-control misconfigurations. Scans do not execute project code, upload files, or use the network.
4
+
5
+ This documentation covers 0.3.x: use a matching [npm version](https://www.npmjs.com/package/canship) or local build.
4
6
 
5
7
  ```powershell
6
8
  npx canship .
7
9
  ```
8
10
 
9
- Requires Node.js ≥18; no runtime dependencies. Git is used for local history checks. Unavailable Git in a repository makes the scan incomplete. On first use, `npx` may download the package from npm.
11
+ Requires Node.js ≥18; no runtime dependencies. `npx` may download the package; scans use only local files and Git history. Unavailable Git in a repository marks the scan incomplete.
10
12
 
11
13
  [简体中文](./README-zh-CN.md)
12
14
 
@@ -16,18 +18,18 @@ Requires Node.js ≥18; no runtime dependencies. Git is used for local history c
16
18
  |---|---|
17
19
  | Hardcoded credentials, private keys, and database URLs containing passwords | P0 |
18
20
  | Private values in public environment variables | P0 |
19
- | Client-accessible Supabase admin keys | P0 |
21
+ | Supabase admin keys exposed to clients | P0 |
20
22
  | Credentials or suspected private values in Git-tracked and historical `.env` files | P0 |
21
- | Supabase tables without Row Level Security (RLS) | P1 |
23
+ | Supabase tables without Row Level Security (RLS) in migrations | P1 |
22
24
  | Firebase unconditional access and date-based test rules | P1 |
23
- | Next.js API data operations without authentication | P0 / P1 |
25
+ | Next.js API data operations without recognised authentication | P0 / P1 |
24
26
  | Credentialed CORS with reflected or wildcard origins | P1 / P2 |
25
27
 
26
- Recognises OpenAI, Anthropic, AWS, Stripe, GitHub, npm, Slack, SendGrid, and other credential formats, plus common frontend public environment prefixes. API authentication checks cover Next.js `/api` handlers, including 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 only Next.js `/api`: App Router, Pages Router, route groups, and workspace applications.
27
29
 
28
- Confidence is `certain` or `likely`. Only certain findings are shown by default; hidden likely findings still affect the exit code.
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.
29
31
 
30
- ## Options
32
+ ## Usage
31
33
 
32
34
  Omitting the path scans the current directory.
33
35
 
@@ -35,32 +37,79 @@ Omitting the path scans the current directory.
35
37
  |---|---|
36
38
  | `-a`, `--all` | Include likely findings |
37
39
  | `--json` | Output JSON |
38
- | `--fix-prompt` | Output assistant instructions and separate manual actions |
40
+ | `--fix-prompt` | Output remediation instructions and separate manual actions |
39
41
  | `--report[=file]` | Write HTML; default: `canship-report.html` |
40
42
  | `--sarif[=file]` | Write SARIF 2.1.0; default: `canship.sarif` |
41
43
  | `--best-effort` | Permit exit `0` for an incomplete scan with no findings |
42
44
  | `--baseline[=file]` | Apply a baseline; default: `canship-baseline.json` |
43
- | `--baseline-write[=file]` | Record current findings as a baseline and exit |
45
+ | `--baseline-write[=file]` | Write current findings as a baseline and exit; same default path |
44
46
  | `--only=ids` | Run matching rules; comma-separated and repeatable |
45
47
  | `--skip=ids` | Exclude matching rules; comma-separated and repeatable |
46
48
  | `--no-config` | Ignore project configuration |
49
+ | `--list-rules` | List rules and limits without scanning; supports `--json` |
50
+ | `--no-excerpts` | Omit source excerpts from every report; preserve findings and exit status |
47
51
  | `-h`, `--help` | Show help |
48
52
  | `-v`, `--version` | Show version |
49
53
 
50
- `--json` and `--fix-prompt` are mutually exclusive. HTML and SARIF may be combined with either. Reports are in English; use `--all` to include likely findings in any format.
54
+ `--json` and `--fix-prompt` are mutually exclusive; HTML and SARIF work with either. Reports are in English. `--all` applies to every format.
51
55
 
52
56
  ### Exit codes
53
57
 
54
58
  | Code | Meaning |
55
59
  |---|---|
56
- | `0` | No findings and a complete scan, or an incomplete scan accepted with `--best-effort` |
60
+ | `0` | No findings, with a complete scan or an incomplete scan accepted by `--best-effort` |
57
61
  | `1` | At least one certain P0/P1 finding |
58
62
  | `2` | Other findings, including hidden likely findings |
59
63
  | `3` | Invalid arguments, a tool error, or an unaccepted incomplete scan |
60
64
 
61
- Findings take precedence over incompleteness; `--best-effort` does not change `1` or `2`. JSON retains `partial`, `errors`, and `skipped`; SARIF includes execution status and diagnostic notifications.
65
+ Finding exit codes take precedence over incompleteness; `--best-effort` does not change `1` or `2`.
66
+
67
+ ### Machine-readable output
68
+
69
+ JSON uses `schemaVersion: 1`, independent of the package version; the npm package includes its [schema](./schemas/scan-report-v1.schema.json). Accept additive fields and reject unsupported schema versions. `--list-rules --json` is a separate `kind: "rule-catalog"` document.
70
+
71
+ `findings` contains results after suppressions and filtering; `hiddenLikely`, `baselineSuppressed`, and `baselineStale` provide related counts. Check coverage separately through `partial`, `errors`, `skipped`, and `filesScanned`. SARIF includes execution status and diagnostic notifications.
72
+
73
+ ## GitHub Action
74
+
75
+ 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
+ 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`.
62
78
 
63
- ## Configuration and suppressions
79
+ ```yaml
80
+ name: canship
81
+ on: [push, pull_request]
82
+ permissions:
83
+ contents: read
84
+ jobs:
85
+ scan:
86
+ runs-on: ubuntu-latest
87
+ steps:
88
+ - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803
89
+ with:
90
+ fetch-depth: 0
91
+ persist-credentials: false
92
+ - uses: Tasomei/canship@37cf06e014168968a88679157564e5c489f99c50
93
+ with:
94
+ version: '0.2.1'
95
+ ```
96
+
97
+ | Input | Default | Meaning |
98
+ |---|---|---|
99
+ | `path` | `.` | Directory within the checkout |
100
+ | `version` | `0.2.1` | Exact npm version; no ranges or tags |
101
+ | `fail-on` | `blocking` | `blocking`: certain P0/P1; `any`: all findings; `none`: findings only reported |
102
+ | `only` / `skip` | unset | Mutually exclusive, comma-separated rule selectors |
103
+ | `baseline` | unset | Existing baseline relative to the scanned directory |
104
+ | `use-config` | `false` | Enable project configuration |
105
+ | `upload-sarif` | `false` | Upload SARIF to GitHub code scanning |
106
+ | `category` | `canship` | Distinct SARIF category for each scan target |
107
+
108
+ Outputs: `exit-code`, `findings`, `blocking`, `partial`. Policies include likely findings; baselines and exclusions still apply. Incomplete scans, tool errors, and incompatible reports always fail, including with `fail-on: none`.
109
+
110
+ SARIF upload requires `security-events: write` and [GitHub code scanning support](https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/integrate-with-existing-tools/upload-sarif-file); fork PRs may lack permission. Review paths and finding details before upload. Use `pull_request`, not `pull_request_target`, for untrusted PRs. The Action sets Node.js 22 for subsequent steps; use a separate scan job if another version is needed.
111
+
112
+ ## Configuration and baselines
64
113
 
65
114
  Place `canship.config.json` in the scanned directory. Supported keys: `baseline`, `only`, `skip`, `all`.
66
115
 
@@ -71,18 +120,20 @@ Place `canship.config.json` in the scanned directory. Supported keys: `baseline`
71
120
  }
72
121
  ```
73
122
 
74
- CLI options take precedence. `only` and `skip` are mutually exclusive and accept complete rule IDs or namespaces. Unrelated rules do not run; `ruleSelection.removed` counts only filtered findings from rules that ran. Use `--no-config` for untrusted projects. `bestEffort` is CLI-only.
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
+
125
+ ### Ignore markers
75
126
 
76
- A standalone comment containing `canship-ignore-file` excludes a file. `canship-ignore-next-line` suppresses the next line and accepts an optional rule ID:
127
+ A standalone `canship-ignore-file` comment excludes a file; `canship-ignore-next-line` suppresses the next line, with an optional rule ID:
77
128
 
78
129
  ```ts
79
130
  // canship-ignore-next-line cors/wildcard-with-credentials
80
131
  const corsOptions = { origin: '*', credentials: true }
81
132
  ```
82
133
 
83
- Reports disclose exclusions, rule selection, and baseline suppression. Deliberate exclusions do not make the scan incomplete.
134
+ Reports disclose exclusions, rule selection, and baseline suppression. Deliberate exclusions do not mark the scan incomplete.
84
135
 
85
- ## Baselines
136
+ ### Baselines
86
137
 
87
138
  Record existing findings:
88
139
 
@@ -96,21 +147,22 @@ Report only new findings:
96
147
  npx canship --baseline
97
148
  ```
98
149
 
99
- Bare options use the scanned directory; explicit paths are relative to the working directory. A successful write exits `0`, with a warning for incomplete or selectively scanned input.
150
+ The default baseline is in the scanned directory; explicit paths are relative to the working directory. A successful write exits `0`, regardless of findings; incomplete or selectively scanned input produces a warning.
100
151
 
101
- Baseline format v2 contains no source excerpts but discloses paths, rules, titles, and issue types. Review it before committing. Fingerprints exclude line numbers: moving lines preserves identity, replacing credentials changes it. Missing, malformed, and v1 baselines exit `3`.
152
+ Baseline format v2 fingerprints survive line moves but change when credentials change. Missing, malformed, and v1 baselines exit `3`. Baselines omit source but retain paths, rules, and issue descriptions; review before committing.
102
153
 
103
- ## Limitations
154
+ ## Privacy and limitations
104
155
 
105
- - Static heuristics may produce false positives or negatives. Runtime behaviour, rate limiting, injection, dependency vulnerabilities, and business authorisation are outside scope. No findings does not prove security.
106
- - Redaction covers recognised formats only; unknown secrets may appear in evidence. Treat reports as internal material. Google/Firebase/Maps `AIza...` values are treated as public identifiers.
107
- - Reads are limited to 2 MiB per file, 128 MiB and 10,000 files per scan, and 16 directory levels. Across rules, at most 100 findings per file are reported, prioritising severity and confidence.
108
- - Git history checks cover up to 100 relevant revisions per file. Each Git command has a 30-second timeout. Exceeded limits and timeouts are reported as incomplete coverage.
109
- - Symbolic links are not followed. Scan nested repositories and submodules separately. Skipped paths within scope make the scan incomplete; excluded build and dependency directories remain excluded.
156
+ - Static analysis may produce false positives or negatives. Deployed behaviour, rate limiting, injection, dependency vulnerabilities, and business authorisation are outside scope. No findings does not prove security.
157
+ - Redaction covers recognised formats only; unknown secrets may appear in source excerpts. `--no-excerpts` omits excerpts, recorded as `excerptsOmitted` in JSON. Paths, names, descriptions, and baselines are not anonymised; review before sharing.
158
+ - Google/Firebase/Maps `AIza...` values are public identifiers, not evidence of a leak on their own.
159
+ - Read limits: 2 MiB per file, 128 MiB and 10,000 files per scan, 16 directory levels. At most 100 findings per file across rules, prioritising severity and confidence.
160
+ - Git history checks cover up to 100 relevant revisions per file, with a 30-second timeout per Git command. Exceeded limits and timeouts report coverage gaps.
161
+ - Symbolic links are not followed; scan nested repositories and submodules separately. Skipped paths within scope mark the scan incomplete; built-in build and dependency exclusions do not.
110
162
 
111
163
  ## Development
112
164
 
113
- Detection rules require positive and negative cases; see [test fixtures](./test/fixtures/).
165
+ New rules require positive and negative [test cases](./test/fixtures/).
114
166
 
115
167
  ```powershell
116
168
  npm ci
@@ -120,6 +172,28 @@ npm ci
120
172
  npm run prepublishOnly
121
173
  ```
122
174
 
175
+ Offline evaluation:
176
+
177
+ ```powershell
178
+ npm run evaluate
179
+ ```
180
+
181
+ The corpus has 10 synthetic cases and nine pinned upstream examples and mutations, also run by `npm test`. [Sources and licences](./test/fixtures/evaluation/) accompany the fixtures. Assertions cover rules, files, severity, confidence, and coverage, not real-world detection rates.
182
+
183
+ Five [application-directory snapshots](./test/evaluation/projects.json) provide additional evaluation. Preparation uses the network and verifies Git object hashes; the target must be a new directory outside Git:
184
+
185
+ ```powershell
186
+ node scripts/fetch-evaluation-projects.mjs "$env:TEMP/canship-evaluation"
187
+ ```
188
+
189
+ Then evaluate offline without installing or running sample dependencies:
190
+
191
+ ```powershell
192
+ npm run evaluate:projects -- "$env:TEMP/canship-evaluation"
193
+ ```
194
+
195
+ CI uses the same corpus. Git history and deployed behaviour are outside this evaluation.
196
+
123
197
  ## License
124
198
 
125
- [MIT](./LICENSE)
199
+ [MIT](./LICENSE). Supabase and Firebase fixtures retain Apache-2.0; Next.js and `cors` fixtures retain MIT. Each includes its source and licence.
package/dist/cli.js CHANGED
@@ -952,71 +952,68 @@ function endOfString(src, start, quote) {
952
952
  }
953
953
  return length;
954
954
  }
955
- function maskTemplate(src, out, start) {
955
+ function maskTemplate(src, out, start, maskStrings = true) {
956
956
  const length = src.length;
957
+ const stack = [{ kind: "literal", from: start + 1 }];
957
958
  let i = start + 1;
958
- let literalFrom = i;
959
- while (i < length) {
959
+ while (i < length && stack.length > 0) {
960
+ const frame = stack[stack.length - 1];
960
961
  const ch = src.charCodeAt(i);
961
- if (ch === BACKSLASH) {
962
- i += 2;
962
+ if (frame.kind === "literal") {
963
+ if (ch === BACKSLASH) {
964
+ i = Math.min(i + 2, length);
965
+ continue;
966
+ }
967
+ if (ch === BACKTICK) {
968
+ if (maskStrings) blank(out, frame.from, i);
969
+ stack.pop();
970
+ i++;
971
+ continue;
972
+ }
973
+ if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
974
+ if (maskStrings) blank(out, frame.from, i);
975
+ stack.push({ kind: "expression", depth: 1 });
976
+ i += 2;
977
+ continue;
978
+ }
979
+ i++;
980
+ continue;
981
+ }
982
+ if (ch === SLASH) {
983
+ const next = src.charCodeAt(i + 1);
984
+ if (next === SLASH || next === STAR) {
985
+ const close = next === SLASH ? src.indexOf("\n", i) : src.indexOf("*/", i + 2);
986
+ const stop = close === -1 ? length : close + (next === STAR ? 2 : 0);
987
+ blank(out, i, stop);
988
+ i = stop;
989
+ continue;
990
+ }
991
+ }
992
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
993
+ const stop = endOfString(src, i, ch);
994
+ if (maskStrings) blank(out, i + 1, stop - 1);
995
+ i = stop;
963
996
  continue;
964
997
  }
965
998
  if (ch === BACKTICK) {
966
- blank(out, literalFrom, i);
967
- return i + 1;
999
+ stack.push({ kind: "literal", from: i + 1 });
1000
+ i++;
1001
+ continue;
968
1002
  }
969
- if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
970
- blank(out, literalFrom, i);
971
- let depth = 0;
972
- let j = i + 1;
973
- while (j < length) {
974
- const inner = src.charCodeAt(j);
975
- if (inner === SLASH) {
976
- const next = src.charCodeAt(j + 1);
977
- if (next === SLASH) {
978
- const end = src.indexOf("\n", j);
979
- const stop = end === -1 ? length : end;
980
- blank(out, j, stop);
981
- j = stop;
982
- continue;
983
- }
984
- if (next === STAR) {
985
- const close = src.indexOf("*/", j + 2);
986
- const stop = close === -1 ? length : close + 2;
987
- blank(out, j, stop);
988
- j = stop;
989
- continue;
990
- }
991
- }
992
- if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
993
- const stop = endOfString(src, j, inner);
994
- blank(out, j + 1, stop - 1);
995
- j = stop;
996
- continue;
997
- }
998
- if (inner === BACKTICK) {
999
- j = maskTemplate(src, out, j);
1000
- continue;
1001
- }
1002
- if (inner === OPEN_BRACE) depth++;
1003
- else if (inner === CLOSE_BRACE) {
1004
- depth--;
1005
- if (depth === 0) {
1006
- j++;
1007
- break;
1008
- }
1009
- }
1010
- j++;
1003
+ if (ch === OPEN_BRACE) frame.depth++;
1004
+ else if (ch === CLOSE_BRACE) {
1005
+ frame.depth--;
1006
+ if (frame.depth === 0) {
1007
+ stack.pop();
1008
+ const parent = stack[stack.length - 1];
1009
+ if (parent.kind === "literal") parent.from = i + 1;
1011
1010
  }
1012
- i = j;
1013
- literalFrom = i;
1014
- continue;
1015
1011
  }
1016
1012
  i++;
1017
1013
  }
1018
- blank(out, literalFrom, length);
1019
- return length;
1014
+ const last = stack[stack.length - 1];
1015
+ if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
1016
+ return i;
1020
1017
  }
1021
1018
  function maskJsComments(src) {
1022
1019
  const length = src.length;
@@ -1041,10 +1038,14 @@ function maskJsComments(src) {
1041
1038
  continue;
1042
1039
  }
1043
1040
  }
1044
- if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE || ch === BACKTICK) {
1041
+ if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
1045
1042
  i = endOfString(src, i, ch);
1046
1043
  continue;
1047
1044
  }
1045
+ if (ch === BACKTICK) {
1046
+ i = maskTemplate(src, out, i, false);
1047
+ continue;
1048
+ }
1048
1049
  i++;
1049
1050
  }
1050
1051
  return stringOf(out);
@@ -2102,8 +2103,8 @@ var supabaseRlsRule = {
2102
2103
  line: entry.line,
2103
2104
  excerpt: null,
2104
2105
  why: [
2105
- `Supabase exposes your database to the browser directly, and the anon key that reaches it is public by design \u2014 it ships inside your frontend. Row Level Security is the only thing that decides who can read or write a row.`,
2106
- `No "ALTER TABLE ${renderIdent(entry.table)} ENABLE ROW LEVEL SECURITY" appears anywhere in your SQL, and new tables do not get it by default. If that is the real state, anyone who visits your site can list this entire table with a single request \u2014 and depending on your policies, write to it too.`,
2106
+ `Supabase's browser clients use public identifiers. For exposed tables, database grants and Row Level Security determine which operations and rows a caller can access.`,
2107
+ `After replaying the scanned migrations, the final recorded state of ${renderIdent(entry.table)} does not have RLS enabled. It may never have been enabled or may have been disabled by a later migration. Without RLS, any access granted to the caller is not restricted by row policies.`,
2107
2108
  `If you enabled RLS from the Supabase dashboard instead, this file simply cannot show it. Check the Authentication -> Policies page to confirm.`
2108
2109
  ],
2109
2110
  fix: [
@@ -2130,9 +2131,7 @@ var supabaseRlsRule = {
2130
2131
  // src/rules/firebase.ts
2131
2132
  import { basename as basename5 } from "path";
2132
2133
  function isRulesFile(file) {
2133
- const name = basename5(file.path).toLowerCase();
2134
- if (name.endsWith(".rules")) return true;
2135
- return name === "firestore.rules" || name === "storage.rules";
2134
+ return basename5(file.path).toLowerCase().endsWith(".rules");
2136
2135
  }
2137
2136
  function productOf(path) {
2138
2137
  const name = basename5(path).toLowerCase();
@@ -2244,7 +2243,7 @@ var firebaseRulesRule = {
2244
2243
  excerpt: m[0].trim(),
2245
2244
  why: canWrite ? [
2246
2245
  `"if true" grants access unconditionally \u2014 no sign-in, no ownership check, nothing. The Firebase client SDK talks to your database straight from the browser, so these rules are the only access control that exists.`,
2247
- `Anyone who finds your project id can read every document here, overwrite it, or delete all of it. Project ids are not secret; they ship inside your frontend bundle.`
2246
+ `Anyone can perform the allowed ${ops} operations on this matched path without authentication. Other operations depend on their own rules. Project ids are public identifiers, not access controls.`
2248
2247
  ] : [
2249
2248
  `"if true" grants read access unconditionally, so anyone who finds your project id can list every document in this collection. Project ids are not secret; they ship inside your frontend bundle.`,
2250
2249
  `Writes are still denied by default, so this is only a problem if the data is not meant to be public. If it is a public catalogue or announcements, ignore this \u2014 and consider adding "allow write: if false;" to make that intent explicit.`
@@ -2922,9 +2921,9 @@ var apiAuthRule = {
2922
2921
  line,
2923
2922
  excerpt,
2924
2923
  why: [
2925
- `This route uses the service_role key, which bypasses every Row Level Security policy you have. Whatever your database would normally refuse, this route performs.`,
2926
- `Nothing in this file checks who is calling \u2014 no session lookup, no token check, no 401 anywhere \u2014 and no middleware covers it. The URL is not a secret either: it is spelled out by the file path, and it appears in your frontend bundle as soon as anything calls it.`,
2927
- `So a single curl to ${url} gets the same access your admin key has.`,
2924
+ `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.`,
2926
+ `If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
2928
2927
  `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.`
2929
2928
  ],
2930
2929
  fix: [
@@ -2950,8 +2949,8 @@ var apiAuthRule = {
2950
2949
  line,
2951
2950
  excerpt,
2952
2951
  why: [
2953
- `This route changes data, and nothing in the file checks who is calling \u2014 no session lookup, no token check, no 401 anywhere.`,
2954
- `Route URLs are not secret; this one is spelled out by its file path. Anyone who sends a request can trigger the same write.`,
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.`,
2953
+ `If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
2955
2954
  `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.`
2956
2955
  ],
2957
2956
  fix: [
@@ -2968,8 +2967,31 @@ var apiAuthRule = {
2968
2967
 
2969
2968
  // src/rules/cors.ts
2970
2969
  var CORS_MARKER = /Access-Control-Allow-Origin|\bcors\s*\(/i;
2971
- var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)?([^\n]+)/gi;
2972
- var CORS_ORIGIN_OPTION = /\borigin\s*:\s*(true|['"`]\*['"`])/gi;
2970
+ var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)?/gi;
2971
+ function headerExpression(content, start) {
2972
+ const closes = [];
2973
+ let quote = null;
2974
+ let end = start;
2975
+ for (; end < content.length; end++) {
2976
+ const ch = content[end];
2977
+ if (ch === "\n" || ch === "\r") break;
2978
+ if (quote !== null) {
2979
+ if (ch === "\\") end++;
2980
+ else if (ch === quote) quote = null;
2981
+ continue;
2982
+ }
2983
+ if (ch === "'" || ch === '"' || ch === "`") quote = ch;
2984
+ else if (ch === "(") closes.push(")");
2985
+ else if (ch === "[") closes.push("]");
2986
+ else if (ch === "{") closes.push("}");
2987
+ else if (ch === ")" || ch === "]" || ch === "}") {
2988
+ if (closes.at(-1) !== ch) break;
2989
+ closes.pop();
2990
+ } else if (closes.length === 0 && (ch === "," || ch === ";")) break;
2991
+ }
2992
+ return content.slice(start, end);
2993
+ }
2994
+ var CORS_ORIGIN_OPTION = /\borigin\s*:\s*/gi;
2973
2995
  var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*)?\s*)?\(/gi;
2974
2996
  var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
2975
2997
  function closingParameterList(source, open) {
@@ -3010,6 +3032,8 @@ function readOriginCallback(content, match, kind) {
3010
3032
  }
3011
3033
  return {
3012
3034
  index: match.index,
3035
+ parametersStart: open + 1,
3036
+ parametersEnd: close,
3013
3037
  params: content.slice(open + 1, close),
3014
3038
  body: content.slice(bodyStart, bodyStart + 400)
3015
3039
  };
@@ -3118,31 +3142,63 @@ function classifyOrigin(raw) {
3118
3142
  return /\b(?:req|request|headers?|ctx|event)/i.test(compact) ? "reflected" : "unknown";
3119
3143
  }
3120
3144
  var PAIRING_DISTANCE = 25;
3145
+ function optionScopes(file, positions) {
3146
+ const scopes = /* @__PURE__ */ new Map();
3147
+ const wanted = [...new Set(positions)].sort((a, b) => a - b);
3148
+ if (wanted.length === 0) return scopes;
3149
+ const content = noiseMaskedOf(file);
3150
+ const stack = [-1];
3151
+ let next = 0;
3152
+ for (let at = 0; at < content.length && next < wanted.length; at++) {
3153
+ if (at === wanted[next]) {
3154
+ scopes.set(at, stack[stack.length - 1]);
3155
+ next++;
3156
+ }
3157
+ if (content[at] === "{") stack.push(at);
3158
+ else if (content[at] === "}" && stack.length > 1) stack.pop();
3159
+ }
3160
+ return scopes;
3161
+ }
3121
3162
  function collectOrigins(file) {
3122
3163
  const marks = [];
3123
3164
  let m;
3124
3165
  const content = commentsMaskedOf(file);
3125
3166
  const contentLines = lineStartsOf(content);
3167
+ const code = noiseMaskedOf(file);
3168
+ const callbacks = originCallbacks(content).filter((callback) => code[callback.index] === content[callback.index]);
3169
+ const callbackIndices = new Set(callbacks.map((callback) => callback.index));
3170
+ const parameterRanges = [...callbacks].sort((a, b) => a.parametersStart - b.parametersStart);
3171
+ let parameterRange = 0;
3126
3172
  ACAO.lastIndex = 0;
3127
3173
  while ((m = ACAO.exec(content)) !== null) {
3174
+ if (code[m.index] !== content[m.index]) continue;
3128
3175
  const line = lineNumberAt(contentLines, m.index);
3129
- marks.push({ line, kind: classifyOrigin(m[1] ?? ""), excerpt: (file.lines[line - 1] ?? "").trim() });
3176
+ const expression = headerExpression(content, ACAO.lastIndex);
3177
+ ACAO.lastIndex += expression.length;
3178
+ marks.push({ line, at: m.index, mode: "header", kind: classifyOrigin(expression), excerpt: (file.lines[line - 1] ?? "").trim() });
3130
3179
  }
3131
3180
  CORS_ORIGIN_OPTION.lastIndex = 0;
3132
3181
  while ((m = CORS_ORIGIN_OPTION.exec(content)) !== null) {
3182
+ if (code[m.index] !== content[m.index]) continue;
3183
+ while (parameterRange < parameterRanges.length && parameterRanges[parameterRange].parametersEnd < m.index) parameterRange++;
3184
+ if (parameterRanges[parameterRange] && parameterRanges[parameterRange].parametersStart <= m.index) continue;
3185
+ if (callbackIndices.has(m.index)) continue;
3133
3186
  const line = lineNumberAt(contentLines, m.index);
3187
+ const expression = headerExpression(content, CORS_ORIGIN_OPTION.lastIndex);
3188
+ CORS_ORIGIN_OPTION.lastIndex += expression.length;
3134
3189
  marks.push({
3135
3190
  line,
3136
- kind: (m[1] ?? "") === "true" ? "reflected" : "wildcard",
3191
+ at: m.index,
3192
+ mode: "option",
3193
+ kind: expression.trim() === "true" ? "reflected" : classifyOrigin(expression),
3137
3194
  excerpt: (file.lines[line - 1] ?? "").trim()
3138
3195
  });
3139
3196
  }
3140
- for (const callback of originCallbacks(content)) {
3197
+ for (const callback of callbacks) {
3141
3198
  const answer = callbackAnswer(callback.params, callback.body);
3142
- if (answer === null) continue;
3143
- if (ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index))) continue;
3199
+ const kind = answer !== null && !ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index)) ? answer.kind : "unknown";
3144
3200
  const line = lineNumberAt(contentLines, callback.index);
3145
- marks.push({ line, kind: answer.kind, excerpt: (file.lines[line - 1] ?? "").trim() });
3201
+ marks.push({ line, at: callback.index, mode: "option", kind, excerpt: (file.lines[line - 1] ?? "").trim() });
3146
3202
  }
3147
3203
  return marks;
3148
3204
  }
@@ -3150,11 +3206,16 @@ function collectCredentialLines(file) {
3150
3206
  const lines = [];
3151
3207
  let m;
3152
3208
  const content = commentsMaskedOf(file);
3209
+ const code = noiseMaskedOf(file);
3153
3210
  const contentLines = lineStartsOf(content);
3154
3211
  ACAC_HEADER.lastIndex = 0;
3155
- while ((m = ACAC_HEADER.exec(content)) !== null) lines.push(lineNumberAt(contentLines, m.index));
3212
+ while ((m = ACAC_HEADER.exec(content)) !== null) {
3213
+ if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "header" });
3214
+ }
3156
3215
  CORS_CREDENTIALS_OPTION.lastIndex = 0;
3157
- while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null) lines.push(lineNumberAt(contentLines, m.index));
3216
+ while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null) {
3217
+ if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "option" });
3218
+ }
3158
3219
  return lines;
3159
3220
  }
3160
3221
  function nearestOrigin(origins, credLine) {
@@ -3184,10 +3245,12 @@ var corsRule = {
3184
3245
  if (credentialLines.length === 0) return [];
3185
3246
  const origins = collectOrigins(file);
3186
3247
  if (origins.length === 0) return [];
3248
+ const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
3187
3249
  const findings = [];
3188
3250
  const reported = /* @__PURE__ */ new Set();
3189
- for (const credLine of credentialLines) {
3190
- const origin = nearestOrigin(origins, credLine);
3251
+ 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);
3191
3254
  if (!origin) continue;
3192
3255
  if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
3193
3256
  if (reported.has(origin.kind)) continue;
@@ -3393,8 +3456,8 @@ async function scan(root, options = {}) {
3393
3456
  const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
3394
3457
  const findings = [];
3395
3458
  const errors = [];
3396
- const fileRules = FILE_RULES.filter((rule) => shouldRunRule(rule.id, options.only ?? [], options.skip ?? []));
3397
- const projectRules = PROJECT_RULES.filter((rule) => shouldRunRule(rule.id, options.only ?? [], options.skip ?? []));
3459
+ const fileRules = FILE_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
3460
+ const projectRules = PROJECT_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
3398
3461
  const incompleteSeen = /* @__PURE__ */ new Set();
3399
3462
  const ctx = {
3400
3463
  root,
@@ -3409,20 +3472,20 @@ async function scan(root, options = {}) {
3409
3472
  }
3410
3473
  };
3411
3474
  for (const file of files) {
3412
- for (const rule of fileRules) {
3475
+ for (const rule2 of fileRules) {
3413
3476
  try {
3414
- if (!rule.appliesTo(file)) continue;
3415
- findings.push(...rule.check(file, ctx));
3477
+ if (!rule2.appliesTo(file)) continue;
3478
+ findings.push(...rule2.check(file, ctx));
3416
3479
  } catch (err) {
3417
- errors.push({ ruleId: rule.id, file: file.path, message: messageOf(err), kind: "crashed" });
3480
+ errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
3418
3481
  }
3419
3482
  }
3420
3483
  }
3421
- for (const rule of projectRules) {
3484
+ for (const rule2 of projectRules) {
3422
3485
  try {
3423
- findings.push(...await rule.check(ctx));
3486
+ findings.push(...await rule2.check(ctx));
3424
3487
  } catch (err) {
3425
- errors.push({ ruleId: rule.id, file: null, message: messageOf(err), kind: "crashed" });
3488
+ errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
3426
3489
  }
3427
3490
  }
3428
3491
  const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
@@ -4188,9 +4251,10 @@ function applyBaseline(findings, baseline) {
4188
4251
  const kept = [];
4189
4252
  let suppressed = 0;
4190
4253
  for (const f of findings) {
4191
- const budget = remaining.get(fingerprintOf(f)) ?? 0;
4254
+ const fp = fingerprintOf(f);
4255
+ const budget = remaining.get(fp) ?? 0;
4192
4256
  if (budget > 0) {
4193
- remaining.set(fingerprintOf(f), budget - 1);
4257
+ remaining.set(fp, budget - 1);
4194
4258
  suppressed++;
4195
4259
  continue;
4196
4260
  }
@@ -4326,6 +4390,29 @@ function renderSarif(result, opts) {
4326
4390
  `;
4327
4391
  }
4328
4392
 
4393
+ // src/report/json.ts
4394
+ function createJsonReport(result, options) {
4395
+ return {
4396
+ schemaVersion: 1,
4397
+ version: options.version,
4398
+ root: options.root,
4399
+ filesScanned: result.filesScanned,
4400
+ durationMs: result.durationMs,
4401
+ partial: result.partial,
4402
+ errors: result.errors,
4403
+ skipped: result.skipped,
4404
+ ignored: result.ignored,
4405
+ ignoredFindings: result.ignoredFindings,
4406
+ ruleSelection: result.ruleSelection,
4407
+ vendored: result.vendored,
4408
+ hiddenLikely: options.hiddenLikely,
4409
+ baselineSuppressed: options.baselineSuppressed,
4410
+ baselineStale: options.baselineStale,
4411
+ excerptsOmitted: options.excerptsOmitted ?? false,
4412
+ findings: result.findings
4413
+ };
4414
+ }
4415
+
4329
4416
  // src/config.ts
4330
4417
  import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
4331
4418
  import { join as join3 } from "path";
@@ -4416,12 +4503,137 @@ function loadConfig(root) {
4416
4503
  return { config: parseConfig(text, path), path };
4417
4504
  }
4418
4505
 
4506
+ // src/rules/catalog.ts
4507
+ var rule = (id, name, severity, confidence, scope, limitation) => ({ id, name, severity, confidence, scope, limitation, reportsFindings: true });
4508
+ var RULE_CATALOG = [
4509
+ rule(
4510
+ "api/admin-db-access-without-auth",
4511
+ "Admin database access without a recognized auth guard",
4512
+ "P0",
4513
+ "certain",
4514
+ "Next.js App/Pages API handlers, including workspace applications.",
4515
+ "Syntactic guards only; runtime authentication and business authorization are not verified."
4516
+ ),
4517
+ rule(
4518
+ "api/db-write-without-auth",
4519
+ "Database write without a recognized auth guard",
4520
+ "P1",
4521
+ "likely",
4522
+ "Next.js App/Pages API handlers.",
4523
+ "Session-scoped clients and indirect guards may need manual review."
4524
+ ),
4525
+ rule(
4526
+ "cors/reflected-origin-with-credentials",
4527
+ "Reflected origin with credentials",
4528
+ "P1",
4529
+ "certain",
4530
+ "Response headers and cors middleware options.",
4531
+ "Static pairing does not prove the deployed policy or cookie behavior."
4532
+ ),
4533
+ rule(
4534
+ "cors/wildcard-with-credentials",
4535
+ "Wildcard origin with credentials",
4536
+ "P2",
4537
+ "certain",
4538
+ "Response headers and cors middleware options.",
4539
+ "Browsers reject this combination; it is not proof of data exposure."
4540
+ ),
4541
+ rule(
4542
+ "exposure/private-name-in-public-env",
4543
+ "Private-looking name with a public env prefix",
4544
+ "P0",
4545
+ "likely",
4546
+ "Environment files and source references using recognized public prefixes.",
4547
+ "A variable name is not proof that its value is a secret."
4548
+ ),
4549
+ rule(
4550
+ "exposure/secret-in-public-env",
4551
+ "Recognized secret in a public env variable",
4552
+ "P0",
4553
+ "certain",
4554
+ "Environment files using recognized public prefixes.",
4555
+ "Credential validity and actual deployment are not verified."
4556
+ ),
4557
+ rule(
4558
+ "exposure/supabase-service-role-in-client",
4559
+ "Supabase admin credential exposed in source or public env",
4560
+ "P0",
4561
+ "certain",
4562
+ "Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
4563
+ "Source presence is observable; actual browser delivery and key validity are not verified."
4564
+ ),
4565
+ rule(
4566
+ "firebase/open-rules",
4567
+ "Unconditional Firebase access",
4568
+ "P1",
4569
+ "varies",
4570
+ "Firebase .rules files: unconditional writes are certain; public reads require review.",
4571
+ "Public reads may be intentional; runtime rules and business authorization are not verified."
4572
+ ),
4573
+ rule(
4574
+ "firebase/test-mode-rules",
4575
+ "Date-based Firebase test rules",
4576
+ "P1",
4577
+ "certain",
4578
+ "Firebase .rules files using a fixed timestamp cutoff.",
4579
+ "Expired rules may deny access; deployment state is not verified."
4580
+ ),
4581
+ rule(
4582
+ "gitleak/env-in-history",
4583
+ "Private env values in local Git history",
4584
+ "P0",
4585
+ "varies",
4586
+ "Relevant historical versions of environment files in the local repository.",
4587
+ "History and resource limits apply; remote refs not available locally are not checked."
4588
+ ),
4589
+ rule(
4590
+ "gitleak/env-tracked",
4591
+ "Private env values tracked by Git",
4592
+ "P0",
4593
+ "varies",
4594
+ "Tracked environment files; templates and public-only values are treated separately.",
4595
+ "Unrecognized private values are heuristic findings, not verified credentials."
4596
+ ),
4597
+ rule(
4598
+ "supabase/rls-not-enabled",
4599
+ "Table without RLS in migrations",
4600
+ "P1",
4601
+ "certain",
4602
+ "Supabase SQL migrations, replayed by application scope.",
4603
+ "Migration state is not deployed database state; grants and policy correctness are outside this check."
4604
+ ),
4605
+ ...SECRET_PATTERNS.map((pattern) => ({
4606
+ ...rule(
4607
+ `secrets/hardcoded/${pattern.id}`,
4608
+ pattern.name,
4609
+ "P0",
4610
+ "certain",
4611
+ "Recognized credential formats in scanned text files.",
4612
+ "Validity, revocation, and use are not checked; example contexts lower confidence."
4613
+ ),
4614
+ reportsFindings: !pattern.publicByDesign,
4615
+ ...pattern.publicByDesign ? { limitation: "Treated as a public identifier; this format alone does not produce a finding." } : {}
4616
+ }))
4617
+ ];
4618
+ function renderRuleCatalog() {
4619
+ return "canship rules\n\n" + RULE_CATALOG.map(
4620
+ (item) => `${item.id}
4621
+ ${item.name}
4622
+ ${item.reportsFindings ? `${item.severity}; ${item.confidence}` : "Public identifier; not reported"}
4623
+ Scope: ${item.scope}
4624
+ Limit: ${item.limitation}`
4625
+ ).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
4626
+ }
4627
+
4419
4628
  // src/cli.ts
4420
- var VERSION = true ? "0.2.1" : "0.0.0-dev";
4629
+ var VERSION = true ? "0.3.0" : "0.0.0-dev";
4630
+ function finish(code) {
4631
+ process.exitCode = code;
4632
+ }
4633
+ var ArgumentError = class extends Error {
4634
+ };
4421
4635
  function argumentError(message) {
4422
- process.stderr.write(`canship: ${cleanForOutput(message)}
4423
- `);
4424
- process.exit(3);
4636
+ throw new ArgumentError(cleanForOutput(message));
4425
4637
  }
4426
4638
  function optionalValue(arg, name, fallback) {
4427
4639
  if (arg === name) return fallback;
@@ -4480,7 +4692,9 @@ function parseArgs(argv) {
4480
4692
  sarif: null,
4481
4693
  noConfig: false,
4482
4694
  help: false,
4483
- version: false
4695
+ version: false,
4696
+ listRules: false,
4697
+ noExcerpts: false
4484
4698
  };
4485
4699
  const positional = [];
4486
4700
  for (const arg of argv) {
@@ -4545,6 +4759,12 @@ function parseArgs(argv) {
4545
4759
  case "--no-config":
4546
4760
  args.noConfig = true;
4547
4761
  break;
4762
+ case "--list-rules":
4763
+ args.listRules = true;
4764
+ break;
4765
+ case "--no-excerpts":
4766
+ args.noExcerpts = true;
4767
+ break;
4548
4768
  case "--help":
4549
4769
  case "-h":
4550
4770
  args.help = true;
@@ -4587,6 +4807,8 @@ var HELP = `
4587
4807
  --sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
4588
4808
  (default canship.sarif)
4589
4809
  --no-config Ignore canship.config.json in the scanned directory
4810
+ --list-rules List rule IDs, scope, and limits; add --json for structured output
4811
+ --no-excerpts Omit source excerpts from every report; paths and descriptions remain
4590
4812
  -h, --help Show this help
4591
4813
  -v, --version Show version
4592
4814
 
@@ -4609,12 +4831,20 @@ async function main() {
4609
4831
  if (args.help) {
4610
4832
  process.stdout.write(`${HELP}
4611
4833
  `);
4612
- return process.exit(0);
4834
+ return finish(0);
4613
4835
  }
4614
4836
  if (args.version) {
4615
4837
  process.stdout.write(`${VERSION}
4616
4838
  `);
4617
- return process.exit(0);
4839
+ return finish(0);
4840
+ }
4841
+ if (args.listRules) {
4842
+ if (process.argv.slice(2).some((arg) => arg !== "--list-rules" && arg !== "--json")) {
4843
+ argumentError("--list-rules only supports --json; scan options and paths cannot be combined with it");
4844
+ }
4845
+ process.stdout.write(args.json ? `${JSON.stringify({ schemaVersion: 1, kind: "rule-catalog", version: VERSION, rules: RULE_CATALOG }, null, 2)}
4846
+ ` : renderRuleCatalog());
4847
+ return;
4618
4848
  }
4619
4849
  if (args.json && args.fixPrompt) {
4620
4850
  argumentError("--json and --fix-prompt are mutually exclusive");
@@ -4625,7 +4855,7 @@ async function main() {
4625
4855
  if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
4626
4856
  process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
4627
4857
  `);
4628
- return process.exit(3);
4858
+ return finish(3);
4629
4859
  }
4630
4860
  let config;
4631
4861
  try {
@@ -4634,7 +4864,7 @@ async function main() {
4634
4864
  if (err instanceof ConfigError) {
4635
4865
  process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
4636
4866
  `);
4637
- return process.exit(3);
4867
+ return finish(3);
4638
4868
  }
4639
4869
  throw err;
4640
4870
  }
@@ -4669,7 +4899,7 @@ async function main() {
4669
4899
  ${cleanForOutput(String(err))}
4670
4900
  `
4671
4901
  );
4672
- return process.exit(3);
4902
+ return finish(3);
4673
4903
  }
4674
4904
  const accepted = scanned.findings.length;
4675
4905
  process.stdout.write(
@@ -4696,7 +4926,7 @@ ${cleanForOutput(String(err))}
4696
4926
  `
4697
4927
  );
4698
4928
  }
4699
- return process.exit(0);
4929
+ return finish(0);
4700
4930
  }
4701
4931
  let baselineSuppressed = 0;
4702
4932
  let baselineStale = 0;
@@ -4712,12 +4942,13 @@ ${cleanForOutput(String(err))}
4712
4942
  if (err instanceof BaselineError) {
4713
4943
  process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
4714
4944
  `);
4715
- return process.exit(3);
4945
+ return finish(3);
4716
4946
  }
4717
4947
  throw err;
4718
4948
  }
4719
4949
  }
4720
4950
  const displayRoot = cleanForOutput(args.root);
4951
+ if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
4721
4952
  const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
4722
4953
  const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
4723
4954
  if (args.fixPrompt) {
@@ -4737,26 +4968,14 @@ ${cleanForOutput(String(err))}
4737
4968
  } else if (args.json) {
4738
4969
  process.stdout.write(
4739
4970
  `${JSON.stringify(
4740
- {
4971
+ createJsonReport({ ...result, findings: shown }, {
4741
4972
  version: VERSION,
4742
4973
  root: displayRoot,
4743
- filesScanned: result.filesScanned,
4744
- durationMs: result.durationMs,
4745
- // 空结果不能掩盖扫描未完成。
4746
- partial: result.partial,
4747
- errors: result.errors,
4748
- skipped: result.skipped,
4749
- ignored: result.ignored,
4750
- ignoredFindings: result.ignoredFindings,
4751
- ruleSelection: result.ruleSelection,
4752
- vendored: result.vendored,
4753
- // 隐藏详情时仍披露疑似结果数量。
4754
4974
  hiddenLikely,
4755
- // 明确披露基线抑制和过期条目数量。
4756
4975
  baselineSuppressed,
4757
4976
  baselineStale,
4758
- findings: shown
4759
- },
4977
+ excerptsOmitted: args.noExcerpts
4978
+ }),
4760
4979
  null,
4761
4980
  2
4762
4981
  )}
@@ -4805,7 +5024,7 @@ ${cleanForOutput(String(err))}
4805
5024
  ${cleanForOutput(String(err))}
4806
5025
  `
4807
5026
  );
4808
- return process.exit(3);
5027
+ return finish(3);
4809
5028
  }
4810
5029
  }
4811
5030
  if (args.report) {
@@ -4837,17 +5056,19 @@ ${cleanForOutput(String(err))}
4837
5056
  ${cleanForOutput(String(err))}
4838
5057
  `
4839
5058
  );
4840
- return process.exit(3);
5059
+ return finish(3);
4841
5060
  }
4842
5061
  }
4843
- if (verdictOf(result.findings).blocking > 0) return process.exit(1);
4844
- if (result.findings.length > 0) return process.exit(2);
4845
- if (result.partial && !bestEffort) return process.exit(3);
4846
- return process.exit(0);
5062
+ if (verdictOf(result.findings).blocking > 0) return finish(1);
5063
+ if (result.findings.length > 0) return finish(2);
5064
+ if (result.partial && !bestEffort) return finish(3);
5065
+ return finish(0);
4847
5066
  }
4848
5067
  main().catch((err) => {
4849
- process.stderr.write(`${red("canship: unexpected error")}
5068
+ if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
5069
+ `);
5070
+ else process.stderr.write(`${red("canship: unexpected error")}
4850
5071
  ${cleanForOutput(String(err))}
4851
5072
  `);
4852
- process.exit(3);
5073
+ finish(3);
4853
5074
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canship",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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",
@@ -30,6 +30,7 @@
30
30
  },
31
31
  "files": [
32
32
  "dist",
33
+ "schemas",
33
34
  "README.md",
34
35
  "README-zh-CN.md",
35
36
  "LICENSE"
@@ -42,6 +43,9 @@
42
43
  "dev": "tsup --watch",
43
44
  "typecheck": "tsc --noEmit",
44
45
  "test": "node scripts/run-tests.mjs",
46
+ "evaluate": "tsx scripts/evaluate.ts",
47
+ "evaluate:projects": "tsx scripts/evaluate-projects.ts",
48
+ "test:package": "npm run build && node scripts/package-smoke.mjs",
45
49
  "prepublishOnly": "npm run typecheck && npm run test && npm run build"
46
50
  },
47
51
  "overrides": {
@@ -49,6 +53,7 @@
49
53
  },
50
54
  "devDependencies": {
51
55
  "@types/node": "^24.0.0",
56
+ "ajv": "^8.20.0",
52
57
  "tsup": "^8.3.5",
53
58
  "tsx": "^4.19.2",
54
59
  "typescript": "^5.7.2"
@@ -0,0 +1,95 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "canship scan report v1",
4
+ "type": "object",
5
+ "required": ["schemaVersion", "version", "root", "filesScanned", "durationMs", "partial", "errors", "skipped", "ignored", "ignoredFindings", "ruleSelection", "vendored", "hiddenLikely", "baselineSuppressed", "baselineStale", "findings"],
6
+ "properties": {
7
+ "schemaVersion": { "const": 1 },
8
+ "version": { "type": "string", "minLength": 1 },
9
+ "root": { "type": "string" },
10
+ "filesScanned": { "$ref": "#/definitions/count" },
11
+ "durationMs": { "type": "number", "minimum": 0 },
12
+ "partial": { "type": "boolean" },
13
+ "vendored": { "$ref": "#/definitions/count" },
14
+ "hiddenLikely": { "$ref": "#/definitions/count" },
15
+ "baselineSuppressed": { "$ref": "#/definitions/count" },
16
+ "baselineStale": { "$ref": "#/definitions/count" },
17
+ "excerptsOmitted": { "type": "boolean" },
18
+ "ignored": { "$ref": "#/definitions/strings" },
19
+ "errors": {
20
+ "type": "array",
21
+ "items": {
22
+ "type": "object",
23
+ "required": ["ruleId", "file", "message", "kind"],
24
+ "properties": {
25
+ "ruleId": { "type": "string" },
26
+ "file": { "type": ["string", "null"] },
27
+ "message": { "type": "string" },
28
+ "kind": { "enum": ["crashed", "incomplete"] }
29
+ }
30
+ }
31
+ },
32
+ "skipped": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "required": ["path", "reason"],
37
+ "properties": {
38
+ "path": { "type": "string" },
39
+ "reason": { "enum": ["too-large", "unreadable", "directory-unreadable", "binary", "symlink", "nested-repository"] },
40
+ "detail": { "type": "string" }
41
+ }
42
+ }
43
+ },
44
+ "ignoredFindings": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "object",
48
+ "required": ["file", "line", "ruleId"],
49
+ "properties": {
50
+ "file": { "type": "string" },
51
+ "line": { "type": "integer", "minimum": 1 },
52
+ "ruleId": { "type": "string" }
53
+ }
54
+ }
55
+ },
56
+ "ruleSelection": {
57
+ "anyOf": [
58
+ { "type": "null" },
59
+ {
60
+ "type": "object",
61
+ "required": ["only", "skip", "removed"],
62
+ "properties": {
63
+ "only": { "$ref": "#/definitions/strings" },
64
+ "skip": { "$ref": "#/definitions/strings" },
65
+ "removed": { "$ref": "#/definitions/count" }
66
+ }
67
+ }
68
+ ]
69
+ },
70
+ "findings": {
71
+ "type": "array",
72
+ "items": {
73
+ "type": "object",
74
+ "required": ["ruleId", "severity", "confidence", "title", "file", "line", "excerpt", "why", "fix"],
75
+ "properties": {
76
+ "ruleId": { "type": "string", "minLength": 1 },
77
+ "severity": { "enum": ["P0", "P1", "P2"] },
78
+ "confidence": { "enum": ["certain", "likely"] },
79
+ "title": { "type": "string" },
80
+ "file": { "type": ["string", "null"] },
81
+ "line": { "anyOf": [{ "type": "integer", "minimum": 1 }, { "type": "null" }] },
82
+ "excerpt": { "type": ["string", "null"] },
83
+ "sourceFingerprint": { "type": "string" },
84
+ "why": { "$ref": "#/definitions/strings" },
85
+ "fix": { "$ref": "#/definitions/strings" },
86
+ "humanOnly": { "$ref": "#/definitions/strings" }
87
+ }
88
+ }
89
+ }
90
+ },
91
+ "definitions": {
92
+ "count": { "type": "integer", "minimum": 0 },
93
+ "strings": { "type": "array", "items": { "type": "string" } }
94
+ }
95
+ }