ccs-mcp-server 1.2.17 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -87
- package/dsh/cmdi.js +98 -0
- package/dsh/cordis.patch.yml +5 -0
- package/dsh/credentials.js +169 -0
- package/dsh/index.js +730 -0
- package/dsh/injection.js +75 -0
- package/dsh/package.json +5 -0
- package/dsh/policy.js +359 -0
- package/dsh/ssrf.js +170 -0
- package/package.json +51 -27
- package/src/index.js +111 -3
- package/src/locate_tampering.js +240 -0
- package/src/skillpay.js +0 -0
- package/test/run-tests.js +355 -0
package/README.md
CHANGED
|
@@ -1,28 +1,48 @@
|
|
|
1
|
-
# CCS
|
|
1
|
+
# CCS MCP Server · Agent 运行时验证与收据验签
|
|
2
2
|
|
|
3
3
|
[](https://datatracker.ietf.org/doc/draft-correctover-ccs/)
|
|
4
4
|
[](https://www.npmjs.com/package/ccs-mcp-server)
|
|
5
|
+
[](https://www.npmjs.com/package/ccs-mcp-server)
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
> **给 AI Agent 配一个运行时保安:它删库、偷密钥、闯内网、被注入诱导——当场拦下。**
|
|
8
|
+
> 它是一道**运行时拦截层**,不是事后审计工具:危险动作在执行点就被挡住;每张 Ed25519 签名收据,都是拦截/放行时当场附带的裁决凭证。
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
> **本包同时以 `agent-runtime-guard` 之名发布**,两者是同一份代码、同一个 bin 入口。已装 `agent-runtime-guard` 的用户无需迁移。
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
你给 Agent 接上 shell、网络、云凭证这些工具后,最大的风险是:**它手一抖,一条命令删掉整台机器;或被诱导,把密钥和数据偷偷传走。** 一个容易点过的"确认弹窗"挡不住。
|
|
13
|
+
|
|
14
|
+
`ccs-mcp-server` 在 **Agent 与危险操作之间**站一道确定性的拦截层——工具调用、shell 命令、网络请求、输出结果全栈覆盖,危险动作在**真正执行前**被 fail-closed 拦下,无需人工点击。
|
|
15
|
+
|
|
16
|
+
- ✅ **零运行时依赖**,纯 Node.js 标准库(Node ≥ 18),无 install 脚本
|
|
17
|
+
- ✅ **全栈拦截**:命令注入 / 路径穿越 / SSRF(含云元数据)/ 凭证外泄 / 提示注入 / 跨工具攻击链
|
|
18
|
+
- ✅ **fail-closed**:判危险、验证器出错或超时,都不转发、不执行
|
|
19
|
+
- ✅ **Ed25519 证据收据**:每次决策双哈希 + 可链,第三方可离线验证
|
|
20
|
+
- ✅ **随处可用**:MCP server(Claude Code / Cursor / Cline / Windsurf)、透明 stdio 代理、DeepSeek Harness 插件
|
|
11
21
|
|
|
12
22
|
---
|
|
13
23
|
|
|
14
|
-
## ⚡
|
|
24
|
+
## ⚡ 一键安装
|
|
25
|
+
|
|
26
|
+
**Cursor:**
|
|
27
|
+
|
|
28
|
+
[](https://cursor.com/en/install-mcp?name=ccs-mcp-server&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImNjcy1tY3Atc2VydmVyIl19)
|
|
29
|
+
|
|
30
|
+
**VS Code:**
|
|
31
|
+
|
|
32
|
+
[](https://insiders.vscode.dev/redirect/mcp/install?name=ccs-mcp-server&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22ccs-mcp-server%22%5D%7D)
|
|
33
|
+
|
|
34
|
+
手动安装:
|
|
15
35
|
|
|
16
36
|
```bash
|
|
17
37
|
npx -y ccs-mcp-server
|
|
18
38
|
```
|
|
19
39
|
|
|
20
|
-
|
|
40
|
+
把它加进任意 MCP 客户端的 `mcpServers` 配置:
|
|
21
41
|
|
|
22
42
|
```json
|
|
23
43
|
{
|
|
24
44
|
"mcpServers": {
|
|
25
|
-
"ccs-
|
|
45
|
+
"ccs-mcp-server": {
|
|
26
46
|
"command": "npx",
|
|
27
47
|
"args": ["-y", "ccs-mcp-server"]
|
|
28
48
|
}
|
|
@@ -30,121 +50,105 @@ Add it to any MCP client — paste this into your `mcpServers` config in Claude
|
|
|
30
50
|
}
|
|
31
51
|
```
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
无需 API key、账号或环境变量——首次运行自动生成 Ed25519 签名密钥(想固定持久密钥再设 `CCS_KEY_DIR` 或 `CCS_PRIVATE_KEY`/`CCS_PUBLIC_KEY`)。
|
|
34
54
|
|
|
35
|
-
|
|
55
|
+
---
|
|
36
56
|
|
|
37
|
-
|
|
57
|
+
## 🔍 命令行自检
|
|
38
58
|
|
|
39
|
-
|
|
59
|
+
不确定装没装对?不用起 MCP 客户端,直接问它:
|
|
40
60
|
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
61
|
+
```bash
|
|
62
|
+
npx -y ccs-mcp-server --version # 打印版本号
|
|
63
|
+
npx -y ccs-mcp-server --tools # 列出全部工具及用途(不启动服务)
|
|
64
|
+
npx -y ccs-mcp-server --help # 完整用法
|
|
65
|
+
```
|
|
46
66
|
|
|
47
|
-
|
|
67
|
+
`--tools` 会输出工具名与一句话说明,方便你在接入前先确认能力边界。
|
|
48
68
|
|
|
49
|
-
|
|
69
|
+
---
|
|
50
70
|
|
|
51
|
-
|
|
71
|
+
## 三种用法
|
|
72
|
+
|
|
73
|
+
| 场景 | 怎么用 |
|
|
52
74
|
|---|---|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
| `verify_intent_binding` | Verify actual tool arguments match a declared intent — zero tolerance, zero LLM calls. Catches cross-model parameter drift (planner says `amount: 100`, executor writes `amount: 10000` → DENIED). |
|
|
57
|
-
| `verify_receipt` | Offline-verify a CCS Ed25519-signed receipt: checks the signature against the embedded signer public key, reports tampering, and optionally pins an expected signer key or fingerprint. |
|
|
75
|
+
| **MCP 客户端**(Claude Code、Cursor、Cline、Windsurf) | 配置上面的 `mcpServers`,工具调用先过保安 |
|
|
76
|
+
| **DeepSeek Harness** | `dsh plugin add ccs-mcp-server`,默认 bundle patch 自动启用,住进 `tools/pre-execute` / `post-execute` |
|
|
77
|
+
| **网络边界独立拦一道** | 用透明 stdio 代理 [`agent-runtime-proxy`](https://www.npmjs.com/package/agent-runtime-proxy) 包住目标 MCP server,不信宿主内任何插件 |
|
|
58
78
|
|
|
59
|
-
|
|
79
|
+
---
|
|
60
80
|
|
|
61
|
-
|
|
81
|
+
## 它拦什么
|
|
62
82
|
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
- **
|
|
66
|
-
-
|
|
83
|
+
- **命令注入**:shell 元字符、`curl|sh`、`rm -rf`、`eval()`
|
|
84
|
+
- **路径穿越**:`../`、`/etc/passwd`、`/proc/self/`
|
|
85
|
+
- **SSRF**:`169.254.169.254` 云元数据、localhost、私网地址——跨任何工具
|
|
86
|
+
- **跨工具攻击链**:读敏感文件 → 网络外传 = `exfil_chain`
|
|
87
|
+
- **凭证外泄**:API key、secret、credential 出现在参数或输出
|
|
88
|
+
- **混淆提权**:hex/base64 编码、提权信号
|
|
89
|
+
- **数值安全**:整数溢出(>2^53-1)、NaN/Infinity
|
|
90
|
+
- **提示注入**:工具结果里的"忽略以上指令"等诱导
|
|
67
91
|
|
|
68
|
-
|
|
92
|
+
## Tools
|
|
69
93
|
|
|
70
|
-
|
|
94
|
+
| Tool | 用途 |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `verify_tool_call` | 7 维运行时验证 + 语义攻击链分析 + 数值溢出检测,**默认拦截** |
|
|
97
|
+
| `issue_evidence` | 出具防篡改证据(`content_hash` + `evidence_hash`,可链),放行和拦截都产生 |
|
|
98
|
+
| `audit_mcp_config` | 审计 MCP 配置的安全风险 |
|
|
99
|
+
| `verify_intent_binding` | 校验实际参数与声明意图是否一致——零容忍、零 LLM,抓跨模型参数漂移(规划写 `amount:100`,执行写成 `amount:10000` → 拦截) |
|
|
100
|
+
| `verify_receipt` | 离线校验 Ed25519 收据签名、检测篡改,可固定可信签名者 |
|
|
101
|
+
| `locate_tampering` | 字段级定位篡改点(RFC 6901 JSON Pointer),报出是哪个字段、多严重 |
|
|
71
102
|
|
|
72
|
-
|
|
73
|
-
// Intent declared by planner
|
|
74
|
-
{
|
|
75
|
-
"intent_id": "int-001",
|
|
76
|
-
"intent_type": "payment",
|
|
77
|
-
"fields": {
|
|
78
|
-
"amount": { "value": 100, "binding_mode": "exact" },
|
|
79
|
-
"recipient": { "value": "Alice", "binding_mode": "exact" }
|
|
80
|
-
},
|
|
81
|
-
"issued_at": 1755000000000,
|
|
82
|
-
"ttl_ms": 30000
|
|
83
|
-
}
|
|
103
|
+
## 证据链
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
{ "amount": 10000, "recipient": "Alice" }
|
|
105
|
+
每次决策产生双哈希(`content_hash` + `evidence_hash`)与链指针(`parent_evidence_hash`)。任何第三方都能独立验证证据未被篡改,无需信任运营方。收据为 Ed25519 签名、JSON 形态,可用内置 `verify_receipt` 或任意 Ed25519 库离线验证。
|
|
87
106
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
## FAQ(给 AI Agent 配运行时保安,常见疑问)
|
|
108
|
+
|
|
109
|
+
**黑名单为什么永远拦不全?** 危险命令可以用同义词、编码、拼接绕过,且新型调用不断出现。`ccs-mcp-server` 用语义判定(这段代码的意图是不是危险执行)而不是匹配关键词,未知攻击靠意图异常也能识别。
|
|
91
110
|
|
|
92
|
-
|
|
111
|
+
**`Bash(prefix*)` 放行规则够安全吗?** 不够。`git status*` 这类前缀规则会被 `git status; rm -rf /`、`&&`、管道拼接绕过。判定应基于 shell 解析后的实际命令结构,而不是字符串前缀。
|
|
93
112
|
|
|
94
|
-
|
|
95
|
-
|------|----------|---------|
|
|
96
|
-
| `exact` | Deep equality with math normalization | `100`, `100.0`, `1e2` all match; `10000` does not |
|
|
97
|
-
| `numeric_tolerance` | Absolute tolerance | `100 ± 0.01` matches `100.005` |
|
|
98
|
-
| `pattern` | Regex match on string fields | `^[A-Z]{3}$` matches `"USD"` |
|
|
113
|
+
**安全判定能依赖宿主模型/远程分类器吗?** 不建议。远程服务一挂,要么全部拒绝(业务瘫痪)要么被建议绕过(裸奔)。本层策略在本地、默认 deny,裁决与模型可用性完全解耦。
|
|
99
114
|
|
|
100
|
-
|
|
115
|
+
**子 agent 一开权限会被放大吗?** 会,这是真实出现过的问题:子 agent 把权限状态整个换成 allow-all。运行时权限状态应不可被子任务覆盖,并在每次实际调用时强制校验。
|
|
101
116
|
|
|
102
|
-
|
|
117
|
+
**凭证为什么会自动泄露给子命令?** 子进程默认继承父进程全部环境变量,provider key、云凭证随之暴露给 `/run`、`/test` 等本不需要它们的命令。应按白名单最小化传递环境变量。
|
|
103
118
|
|
|
104
|
-
|
|
105
|
-
- **Path traversal**: `../`, `/etc/passwd`, `/proc/self/`
|
|
106
|
-
- **SSRF**: `169.254.169.254` (cloud metadata), localhost, private ranges — across any tool
|
|
107
|
-
- **Cross-tool attack chains**: read sensitive file → network exfil = `exfil_chain`
|
|
108
|
-
- **Environment variable exfiltration**: API keys, secrets, credentials
|
|
109
|
-
- **Obfuscation**: hex encoding, base64, privilege escalation signals
|
|
110
|
-
- **Math safety**: integer overflow (>2^53-1), NaN/Infinity
|
|
111
|
-
- **MCP config risks**: plain HTTP, weak secrets, `--insecure`, TLS disabled
|
|
119
|
+
**拦了还是放了,事后怎么说清是谁的责任?** 每次裁决都出 Ed25519 签名收据(含内容哈希、时间、原因码、链指针),任何第三方可离线验证。这是把"AI 干的"变成可审计责任链的关键。
|
|
112
120
|
|
|
113
|
-
|
|
121
|
+
---
|
|
114
122
|
|
|
115
|
-
|
|
123
|
+
## 免费 vs 付费
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
evidence 1: allowed (fs.read_file) parent: null
|
|
119
|
-
evidence 2: denied (shell.exec curl|sh) parent: ev1
|
|
120
|
-
evidence 3: denied (http.fetch SSRF) parent: ev2
|
|
121
|
-
evidence 4: denied (fs + curl exfil) parent: ev3
|
|
122
|
-
```
|
|
125
|
+
运行时验证、证据签发、收据验签**全部免费**,工具全量开放,无需注册。
|
|
123
126
|
|
|
124
|
-
|
|
127
|
+
如果你需要**面向业务方的正式凭证**(而不是给开发者看的日志),下面三档在售:
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
| 档位 | 价格 | 你拿到什么 | 入口 |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| **单次扫描** | **¥9.9 / 次** | 上传你的 MCP 配置,出一份带评分的静态审计报告 + Ed25519 签名收据,可用于合规留档 | [correctover-pay](https://correctover-pay.pages.dev) |
|
|
132
|
+
| **配置安全体检** | **¥49.90 / 月** | 支付宝 SkillPay 货架订阅,MCP 配置静态审计与 SSRF 检测,按月出具凭证 | [支付宝 SkillPay 货架](https://skillpay.alipay.com/public/correctover) |
|
|
133
|
+
| **收据批量审计** | 按量 | 大量收据的哈希链验签与合规证据集打包,适合审计场景 | [支付宝 SkillPay 货架](https://skillpay.alipay.com/public/correctover) |
|
|
127
134
|
|
|
128
|
-
|
|
129
|
-
2. **Schema** — type, required fields, enums, ranges, string lengths
|
|
130
|
-
3. **Security** — injection, traversal, SSRF, env exfiltration, obfuscation + semantic attack chains
|
|
131
|
-
4. **Identity** — caller agent ID verification
|
|
132
|
-
5. **Integrity** — request hash validation
|
|
133
|
-
6. **Latency** — execution time budget
|
|
134
|
-
7. **Cost** — cost budget
|
|
135
|
+
> 三者都是**同一套引擎**的托管服务——免费版在自己机器上跑,付费版把结果和凭证托管出来。选哪个取决于你需要"自己看"还是"拿给别人看"。
|
|
135
136
|
|
|
136
|
-
##
|
|
137
|
+
## 协议背景
|
|
137
138
|
|
|
138
|
-
- **IETF
|
|
139
|
-
-
|
|
140
|
-
-
|
|
139
|
+
- **IETF**:[draft-correctover-ccs](https://datatracker.ietf.org/doc/draft-correctover-ccs/) —— 个人 Internet-Draft,**不是 RFC,也不代表 IETF 背书**,Datatracker 页面为权威状态
|
|
140
|
+
- **互补**:VAP(范围/预算/目的)、Microsoft ACS(策略决策)、AP2(人工授权)
|
|
141
|
+
- **不替代**:认证、支付网络、策略引擎
|
|
141
142
|
|
|
142
143
|
## Links
|
|
143
144
|
|
|
144
145
|
- npm: https://www.npmjs.com/package/ccs-mcp-server
|
|
146
|
+
- 同源别名 npm: https://www.npmjs.com/package/agent-runtime-guard
|
|
147
|
+
- 官网: https://correctover.com
|
|
145
148
|
- GitHub: https://github.com/DSHCorrectover/ccs-mcp-server
|
|
149
|
+
- 透明代理: https://github.com/DSHCorrectover/agent-runtime-proxy
|
|
146
150
|
- IETF Draft: https://datatracker.ietf.org/doc/draft-correctover-ccs/
|
|
147
|
-
- PyPI
|
|
151
|
+
- PyPI 完整验证器: https://pypi.org/project/ccs-verifier/
|
|
148
152
|
|
|
149
153
|
## License
|
|
150
154
|
|
package/dsh/cmdi.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Command injection detection — inspects subprocess argv and shell strings.
|
|
3
|
+
*
|
|
4
|
+
* Detects:
|
|
5
|
+
* - Shell metacharacter injection (;, |, &&, ||, backticks, $())
|
|
6
|
+
* - Reverse shell patterns
|
|
7
|
+
* - Download-and-execute chains (curl|wget | sh, pip install from URL)
|
|
8
|
+
* - Obfuscated commands (base64 -d | sh, hex encoding)
|
|
9
|
+
* - Fileless execution (/dev/tcp, /dev/shm)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const DANGEROUS_PATTERNS = [
|
|
13
|
+
// Shell chaining / injection
|
|
14
|
+
{ pattern: /[;&|`$]\s*(?:\$\(|`|sh\b|bash\b|zsh\b|dash\b|python\b|perl\b|ruby\b|node\b)/i, weight: 5, label: 'shell metachar + interpreter' },
|
|
15
|
+
{ pattern: /\|\s*(?:sh|bash|zsh|dash|python|perl|ruby|node)\b/i, weight: 6, label: 'pipe to interpreter' },
|
|
16
|
+
{ pattern: /`[^`]{2,}`/, weight: 4, label: 'backtick command substitution' },
|
|
17
|
+
{ pattern: /\$\([^)]{2,}\)/, weight: 4, label: '$() command substitution' },
|
|
18
|
+
{ pattern: /;\s*(?:rm|mv|cp|chmod|chown|curl|wget|nc|bash|sh|python|cat|echo)\b/i, weight: 4, label: 'chained dangerous command' },
|
|
19
|
+
|
|
20
|
+
// Reverse shells
|
|
21
|
+
{ pattern: /(?:bash|sh|zsh)\s+-i\s*>&?\s*\/dev\/(?:tcp|udp)\//i, weight: 7, label: 'reverse shell (/dev/tcp)' },
|
|
22
|
+
{ pattern: /nc\b.{0,40}-e\s+(?:sh|bash|bin\/sh)/i, weight: 7, label: 'netcat reverse shell' },
|
|
23
|
+
{ pattern: /ncat\b.{0,40}-e\s+(?:sh|bash)/i, weight: 7, label: 'ncat reverse shell' },
|
|
24
|
+
{ pattern: /python.{0,60}socket.{0,40}(?:connect|send|recv)/i, weight: 6, label: 'python reverse shell' },
|
|
25
|
+
|
|
26
|
+
// Download-and-execute
|
|
27
|
+
{ pattern: /(?:curl|wget)\b[^|;]{0,100}\|\s*(?:sh|bash|zsh|python|perl)/i, weight: 6, label: 'download-and-execute' },
|
|
28
|
+
{ pattern: /(?:curl|wget)\b[^|;]{0,200}(?:-o|--output)[^|;]{0,60}(?:\/tmp|\/dev\/shm|\/var\/tmp)/i, weight: 4, label: 'download to temp (possible staging)' },
|
|
29
|
+
|
|
30
|
+
// Fileless / memory-only
|
|
31
|
+
{ pattern: /\/dev\/(?:tcp|udp)\//i, weight: 5, label: '/dev/tcp or /dev/udp (network socket)' },
|
|
32
|
+
{ pattern: /\/dev\/shm\//i, weight: 2, label: '/dev/shm (tmpfs, no disk trace)' },
|
|
33
|
+
|
|
34
|
+
// Obfuscation
|
|
35
|
+
{ pattern: /base64\s+(?:-d|--decode)\s*\|?\s*(?:sh|bash|python|perl|eval)/i, weight: 6, label: 'base64 decode + execute' },
|
|
36
|
+
{ pattern: /xxd\s+-p?\s*-r?\s*\|?\s*(?:sh|bash)/i, weight: 5, label: 'hex decode + execute' },
|
|
37
|
+
{ pattern: /\\x[0-9a-fA-F]{2}\\x[0-9a-fA-F]{2}/, weight: 3, label: 'hex-encoded characters' },
|
|
38
|
+
{ pattern: /\$\{IFS\}/, weight: 4, label: '${IFS} obfuscation' },
|
|
39
|
+
{ pattern: /\$\{PATH:[\d:]+}/, weight: 4, label: 'PATH substring obfuscation' },
|
|
40
|
+
|
|
41
|
+
// Destructive
|
|
42
|
+
{ pattern: /\brm\s+-[rf]+\s+(?:\/|~|\*|\.\.|\/\*|~\/\*)/i, weight: 7, label: 'destructive rm (root/home/glob)' },
|
|
43
|
+
{ pattern: /\bmkfs\b/, weight: 7, label: 'mkfs (filesystem format)' },
|
|
44
|
+
{ pattern: /\bdd\s+if=\/dev\/(?:zero|random|urandom)\s+of=\/dev\//i, weight: 7, label: 'dd to device' },
|
|
45
|
+
{ pattern: /:\(\)\s*{\s*:\s*\|\s*:\s*&\s*};/, weight: 7, label: 'fork bomb' },
|
|
46
|
+
|
|
47
|
+
// Persistence
|
|
48
|
+
{ pattern: /(?:>>|>)\s*(?:~\/)?\.(?:bashrc|zshrc|profile|bash_profile)/i, weight: 5, label: 'shell profile modification (persistence)' },
|
|
49
|
+
{ pattern: /(?:chmod|chown)\s+[0-7]*777/, weight: 2, label: 'chmod 777 (overly permissive)' },
|
|
50
|
+
|
|
51
|
+
// Credential theft
|
|
52
|
+
{ pattern: /cat\s+(?:~\/)?\.ssh\/id_rsa/, weight: 7, label: 'SSH private key read' },
|
|
53
|
+
{ pattern: /cat\s+(?:~\/)?\.aws\/credentials/, weight: 7, label: 'AWS credentials read' },
|
|
54
|
+
{ pattern: /(?:env|printenv)\s*\|/, weight: 3, label: 'environment dump piped' },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Scan a command string for injection patterns.
|
|
59
|
+
* @param {string} command
|
|
60
|
+
* @param {number} [threshold=8]
|
|
61
|
+
* @returns {{ score: number, hits: Array<{label: string, weight: number}>, blocked: boolean, reason?: string }}
|
|
62
|
+
*/
|
|
63
|
+
export function detectCommandInjection(command, threshold = 6) {
|
|
64
|
+
if (!command || typeof command !== 'string') return { score: 0, hits: [], blocked: false };
|
|
65
|
+
|
|
66
|
+
let score = 0;
|
|
67
|
+
const hits = [];
|
|
68
|
+
|
|
69
|
+
for (const { pattern, weight, label } of DANGEROUS_PATTERNS) {
|
|
70
|
+
pattern.lastIndex = 0;
|
|
71
|
+
if (pattern.test(command)) {
|
|
72
|
+
score += weight;
|
|
73
|
+
hits.push({ label, weight });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const blocked = score >= threshold;
|
|
78
|
+
return {
|
|
79
|
+
score,
|
|
80
|
+
hits,
|
|
81
|
+
blocked,
|
|
82
|
+
reason: blocked ? `command injection detected (score ${score} ≥ ${threshold}): ${hits.map(h => h.label).join(', ')}` : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Inspect a subprocess spawn spec for dangerous commands.
|
|
88
|
+
* @param {object} spec - spawn spec with argv and/or command.
|
|
89
|
+
* @param {number} [threshold]
|
|
90
|
+
* @returns {{ score: number, hits: Array<{label: string, weight: number}>, blocked: boolean, reason?: string }}
|
|
91
|
+
*/
|
|
92
|
+
export function inspectSpawn(spec, threshold = 6) {
|
|
93
|
+
const argv = Array.isArray(spec?.argv) ? spec.argv : [];
|
|
94
|
+
const command = typeof spec?.command === 'string' ? spec.command : '';
|
|
95
|
+
// Join argv into a string for pattern matching
|
|
96
|
+
const combined = command || argv.join(' ');
|
|
97
|
+
return detectCommandInjection(combined, threshold);
|
|
98
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Credential protection — detects and blocks credential exfiltration.
|
|
3
|
+
*
|
|
4
|
+
* Scans tool arguments and subprocess commands for:
|
|
5
|
+
* - API keys, tokens, passwords in environment variables or arguments
|
|
6
|
+
* - Credential file paths (~/.ssh, ~/.aws/credentials, .credentials.yaml)
|
|
7
|
+
* - Patterns that look like secrets being sent to network destinations
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Patterns that indicate credential-like values. */
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
// OpenAI / Anthropic / generic API keys
|
|
13
|
+
{ pattern: /sk-[A-Za-z0-9_-]{20,}/g, label: 'API key (sk-...)' },
|
|
14
|
+
{ pattern: /ghp_[A-Za-z0-9]{36,}/g, label: 'GitHub token' },
|
|
15
|
+
{ pattern: /gho_[A-Za-z0-9]{36,}/g, label: 'GitHub OAuth token' },
|
|
16
|
+
{ pattern: /github_pat_[A-Za-z0-9_]{82,}/g, label: 'GitHub fine-grained PAT' },
|
|
17
|
+
{ pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g, label: 'Slack token' },
|
|
18
|
+
{ pattern: /AKIA[0-9A-Z]{16}/g, label: 'AWS access key' },
|
|
19
|
+
{ pattern: /ASIA[0-9A-Z]{16}/g, label: 'AWS session token' },
|
|
20
|
+
// Private key blocks
|
|
21
|
+
{ pattern: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/g, label: 'private key block' },
|
|
22
|
+
// Generic high-entropy assignment patterns
|
|
23
|
+
{ pattern: /(?:api[_-]?key|secret|token|password|passwd|auth|credential)["'\s:=]+["']?[A-Za-z0-9_\-]{16,}["']?/gi, label: 'credential assignment' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/** Credential file path patterns that should never be read by untrusted tools. */
|
|
27
|
+
const CREDENTIAL_PATHS = [
|
|
28
|
+
/\/\.ssh\//,
|
|
29
|
+
/\/\.aws\/credentials/,
|
|
30
|
+
/\/\.gnupg\//,
|
|
31
|
+
/\/\.netrc$/,
|
|
32
|
+
/\/\.pgpass$/,
|
|
33
|
+
/\.credentials\.ya?ml/,
|
|
34
|
+
/\/\.env(\.|$)/,
|
|
35
|
+
/\/\.docker\/config\.json/,
|
|
36
|
+
/\/\.kube\/config/,
|
|
37
|
+
/\/\.config\/gcloud\//,
|
|
38
|
+
/\/\.azure\//,
|
|
39
|
+
/id_rsa\b/,
|
|
40
|
+
/id_ed25519\b/,
|
|
41
|
+
/authorized_keys\b/,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Process environment variable names that hold secrets. */
|
|
45
|
+
const SECRET_ENV_VARS = /^(?:.*_)?(?:KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIAL|AUTH|PRIVATE_KEY|ACCESS_KEY|SECRET_KEY)$/i;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Scan a value (recursively) for secret patterns.
|
|
49
|
+
* @param {unknown} value - value to scan.
|
|
50
|
+
* @returns {Array<{label: string, snippet: string}>} findings.
|
|
51
|
+
*/
|
|
52
|
+
export function scanForSecrets(value) {
|
|
53
|
+
const findings = [];
|
|
54
|
+
const seen = new WeakSet();
|
|
55
|
+
|
|
56
|
+
function walk(val, path) {
|
|
57
|
+
if (val === null || val === undefined) return;
|
|
58
|
+
if (typeof val === 'string') {
|
|
59
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
60
|
+
pattern.lastIndex = 0;
|
|
61
|
+
let match;
|
|
62
|
+
while ((match = pattern.exec(val)) !== null) {
|
|
63
|
+
// Redact in snippet
|
|
64
|
+
const start = Math.max(0, match.index - 4);
|
|
65
|
+
const end = Math.min(val.length, match.index + match[0].length + 4);
|
|
66
|
+
const snippet = val.slice(start, end).replace(match[0], '[REDACTED]');
|
|
67
|
+
findings.push({ label, snippet: `...${snippet}...` });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} else if (typeof val === 'object') {
|
|
71
|
+
if (seen.has(val)) return;
|
|
72
|
+
seen.add(val);
|
|
73
|
+
if (Array.isArray(val)) {
|
|
74
|
+
val.forEach((v, i) => walk(v, `${path}[${i}]`));
|
|
75
|
+
} else {
|
|
76
|
+
for (const [k, v] of Object.entries(val)) {
|
|
77
|
+
// Check if the key name itself is secret-like
|
|
78
|
+
if (SECRET_ENV_VARS.test(k) && typeof v === 'string' && v.length > 4) {
|
|
79
|
+
findings.push({ label: `secret env/field "${k}"`, snippet: '[REDACTED]' });
|
|
80
|
+
}
|
|
81
|
+
walk(v, path ? `${path}.${k}` : k);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
walk(value, '');
|
|
88
|
+
return findings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Redact every known secret pattern inside a text string.
|
|
93
|
+
* Used by the post-execute hook to return cleaned output via
|
|
94
|
+
* `{ kind: 'accept', content }` under the v0.2.1 PostToolDecision contract.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} text - text to clean.
|
|
97
|
+
* @returns {{ text: string, found: number, labels: string[] }} cleaned text,
|
|
98
|
+
* match count, and de-duplicated pattern labels.
|
|
99
|
+
*/
|
|
100
|
+
export function redactSecretsInText(text) {
|
|
101
|
+
if (typeof text !== 'string' || text === '') return { text: text ?? '', found: 0, labels: [] };
|
|
102
|
+
let out = text;
|
|
103
|
+
let found = 0;
|
|
104
|
+
const labels = [];
|
|
105
|
+
for (const { pattern, label } of SECRET_PATTERNS) {
|
|
106
|
+
pattern.lastIndex = 0;
|
|
107
|
+
out = out.replace(pattern, (match) => {
|
|
108
|
+
found++;
|
|
109
|
+
if (!labels.includes(label)) labels.push(label);
|
|
110
|
+
return '[REDACTED]';
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return { text: out, found, labels };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check whether a file path targets a known credential location.
|
|
118
|
+
* @param {string} filePath
|
|
119
|
+
* @returns {string|null} matched pattern label, or null.
|
|
120
|
+
*/
|
|
121
|
+
export function isCredentialPath(filePath) {
|
|
122
|
+
for (const pattern of CREDENTIAL_PATHS) {
|
|
123
|
+
if (pattern.test(filePath)) {
|
|
124
|
+
return pattern.source;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check whether a subprocess command or environment includes secrets.
|
|
132
|
+
* @param {object} spec - subprocess spawn spec.
|
|
133
|
+
* @returns {Array<{label: string, detail: string}>} findings.
|
|
134
|
+
*/
|
|
135
|
+
export function scanSubprocessForCredentials(spec) {
|
|
136
|
+
const findings = [];
|
|
137
|
+
|
|
138
|
+
// Check argv for secret patterns
|
|
139
|
+
const argv = Array.isArray(spec?.argv) ? spec.argv : [];
|
|
140
|
+
const cmdStr = argv.join(' ');
|
|
141
|
+
if (cmdStr) {
|
|
142
|
+
const secretHits = scanForSecrets(cmdStr);
|
|
143
|
+
for (const hit of secretHits) {
|
|
144
|
+
findings.push({ label: hit.label, detail: `in command: ${hit.snippet}` });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Check for credential file access in the command
|
|
149
|
+
for (const arg of argv) {
|
|
150
|
+
if (typeof arg === 'string') {
|
|
151
|
+
const credPath = isCredentialPath(arg);
|
|
152
|
+
if (credPath) {
|
|
153
|
+
findings.push({ label: 'credential file access', detail: `command references ${arg}` });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Check environment variables being passed
|
|
159
|
+
const env = spec?.env;
|
|
160
|
+
if (env && typeof env === 'object') {
|
|
161
|
+
for (const [key, val] of Object.entries(env)) {
|
|
162
|
+
if (SECRET_ENV_VARS.test(key)) {
|
|
163
|
+
findings.push({ label: 'secret in subprocess env', detail: `env var ${key} exposed to child process` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return findings;
|
|
169
|
+
}
|