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 +107 -33
- package/README.md +102 -28
- package/dist/cli.js +345 -124
- package/package.json +6 -1
- package/schemas/scan-report-v1.schema.json +95 -0
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
|
|
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
|
-
|
|
|
21
|
+
| Supabase 管理员密钥暴露至客户端 | P0 |
|
|
20
22
|
| Git 跟踪及历史 `.env` 文件中的凭据或疑似私密值 | P0 |
|
|
21
|
-
| Supabase
|
|
23
|
+
| Supabase 迁移记录中未启用行级安全(RLS)的表 | P1 |
|
|
22
24
|
| Firebase 无条件访问及固定日期测试规则 | P1 |
|
|
23
|
-
| Next.js API
|
|
25
|
+
| Next.js API 数据操作未识别到鉴权 | P0 / P1 |
|
|
24
26
|
| 携带凭据的 CORS 来源回显或通配符配置 | P1 / P2 |
|
|
25
27
|
|
|
26
|
-
|
|
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[
|
|
40
|
-
| `--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[
|
|
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
|
|
54
|
+
`--json` 与 `--fix-prompt` 互斥;HTML、SARIF 可与任一模式组合。报告正文为英文,`--all` 对所有格式生效。
|
|
51
55
|
|
|
52
56
|
### 退出码
|
|
53
57
|
|
|
54
58
|
| 退出码 | 含义 |
|
|
55
59
|
|---|---|
|
|
56
|
-
| `0` |
|
|
60
|
+
| `0` | 无结果,且扫描完整或由 `--best-effort` 接受不完整扫描 |
|
|
57
61
|
| `1` | 存在 `certain` 的 P0/P1 结果 |
|
|
58
62
|
| `2` | 存在其他结果,包括被隐藏的 `likely` |
|
|
59
63
|
| `3` | 参数或工具错误,或未被接受的不完整扫描 |
|
|
60
64
|
|
|
61
|
-
|
|
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
|
|
123
|
+
命令行参数优先;`only`、`skip` 互斥,接受规则 ID 或命名空间。未选规则不执行,`ruleSelection.removed` 仅统计已执行规则中被过滤的结果。扫描不可信项目时使用 `--no-config`;`bestEffort` 仅限命令行设置。
|
|
124
|
+
|
|
125
|
+
### 忽略标记
|
|
75
126
|
|
|
76
|
-
|
|
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
|
-
|
|
150
|
+
默认基线位于扫描目录,显式路径相对工作目录。写入成功退出 `0`,不代表无问题;扫描不完整或启用规则筛选时会提示。
|
|
100
151
|
|
|
101
|
-
|
|
152
|
+
基线格式为 v2,移动行号不改变指纹,替换凭据会改变。缺失、损坏及 v1 基线均退出 `3`。基线不含源码,但包含路径、规则和问题描述,提交前需审阅。
|
|
102
153
|
|
|
103
|
-
##
|
|
154
|
+
## 隐私与限制
|
|
104
155
|
|
|
105
|
-
-
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
|
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]` |
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
134
|
+
Reports disclose exclusions, rule selection, and baseline suppression. Deliberate exclusions do not mark the scan incomplete.
|
|
84
135
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
154
|
+
## Privacy and limitations
|
|
104
155
|
|
|
105
|
-
- Static
|
|
106
|
-
- Redaction covers recognised formats only; unknown secrets may appear in
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
959
|
+
while (i < length && stack.length > 0) {
|
|
960
|
+
const frame = stack[stack.length - 1];
|
|
960
961
|
const ch = src.charCodeAt(i);
|
|
961
|
-
if (
|
|
962
|
-
|
|
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
|
-
|
|
967
|
-
|
|
999
|
+
stack.push({ kind: "literal", from: i + 1 });
|
|
1000
|
+
i++;
|
|
1001
|
+
continue;
|
|
968
1002
|
}
|
|
969
|
-
if (ch ===
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const
|
|
975
|
-
if (
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
|
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
|
|
2106
|
-
`
|
|
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
|
-
|
|
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
|
|
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
|
|
2926
|
-
`
|
|
2927
|
-
`
|
|
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
|
|
2954
|
-
`
|
|
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*)
|
|
2972
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3197
|
+
for (const callback of callbacks) {
|
|
3141
3198
|
const answer = callbackAnswer(callback.params, callback.body);
|
|
3142
|
-
|
|
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,
|
|
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)
|
|
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)
|
|
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
|
|
3190
|
-
const
|
|
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((
|
|
3397
|
-
const projectRules = PROJECT_RULES.filter((
|
|
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
|
|
3475
|
+
for (const rule2 of fileRules) {
|
|
3413
3476
|
try {
|
|
3414
|
-
if (!
|
|
3415
|
-
findings.push(...
|
|
3477
|
+
if (!rule2.appliesTo(file)) continue;
|
|
3478
|
+
findings.push(...rule2.check(file, ctx));
|
|
3416
3479
|
} catch (err) {
|
|
3417
|
-
errors.push({ ruleId:
|
|
3480
|
+
errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
|
|
3418
3481
|
}
|
|
3419
3482
|
}
|
|
3420
3483
|
}
|
|
3421
|
-
for (const
|
|
3484
|
+
for (const rule2 of projectRules) {
|
|
3422
3485
|
try {
|
|
3423
|
-
findings.push(...await
|
|
3486
|
+
findings.push(...await rule2.check(ctx));
|
|
3424
3487
|
} catch (err) {
|
|
3425
|
-
errors.push({ ruleId:
|
|
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
|
|
4254
|
+
const fp = fingerprintOf(f);
|
|
4255
|
+
const budget = remaining.get(fp) ?? 0;
|
|
4192
4256
|
if (budget > 0) {
|
|
4193
|
-
remaining.set(
|
|
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.
|
|
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
|
-
|
|
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
|
|
4834
|
+
return finish(0);
|
|
4613
4835
|
}
|
|
4614
4836
|
if (args.version) {
|
|
4615
4837
|
process.stdout.write(`${VERSION}
|
|
4616
4838
|
`);
|
|
4617
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
5059
|
+
return finish(3);
|
|
4841
5060
|
}
|
|
4842
5061
|
}
|
|
4843
|
-
if (verdictOf(result.findings).blocking > 0) return
|
|
4844
|
-
if (result.findings.length > 0) return
|
|
4845
|
-
if (result.partial && !bestEffort) return
|
|
4846
|
-
return
|
|
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(
|
|
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
|
-
|
|
5073
|
+
finish(3);
|
|
4853
5074
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canship",
|
|
3
|
-
"version": "0.
|
|
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
|
+
}
|