ccs-mcp-server 1.2.17 → 1.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.md +62 -98
- package/package.json +37 -29
- package/src/index.js +49 -1
- package/src/locate_tampering.js +240 -0
- package/src/skillpay.js +0 -0
- package/test/run-tests.js +259 -0
package/README.md
CHANGED
|
@@ -1,150 +1,114 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent Runtime Guard · Agent 运行时全栈拦截
|
|
2
2
|
|
|
3
3
|
[](https://datatracker.ietf.org/doc/draft-correctover-ccs/)
|
|
4
|
-
[](https://www.npmjs.com/package/agent-runtime-guard)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
> **给 AI Agent 配一个运行时保安:它删库、偷密钥、闯内网、被注入诱导——当场拦下。**
|
|
7
|
+
> 它是一道**运行时拦截层**,不是事后审计工具:危险动作在执行点就被挡住;每张 Ed25519 签名收据,都是拦截/放行时当场附带的裁决凭证。
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
你给 Agent 接上 shell、网络、云凭证这些工具后,最大的风险是:**它手一抖,一条命令删掉整台机器;或被诱导,把密钥和数据偷偷传走。** 一个容易点过的"确认弹窗"挡不住。
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
`agent-runtime-guard` 在 **Agent 与危险操作之间**站一道确定性的拦截层——工具调用、shell 命令、网络请求、输出结果全栈覆盖,危险动作在**真正执行前**被 fail-closed 拦下,无需人工点击。
|
|
12
|
+
|
|
13
|
+
- ✅ **零运行时依赖**,纯 Node.js 标准库(Node ≥ 18),无 install 脚本
|
|
14
|
+
- ✅ **全栈拦截**:命令注入 / 路径穿越 / SSRF(含云元数据)/ 凭证外泄 / 提示注入 / 跨工具攻击链
|
|
15
|
+
- ✅ **fail-closed**:判危险、验证器出错或超时,都不转发、不执行
|
|
16
|
+
- ✅ **Ed25519 证据收据**:每次决策双哈希 + 可链,第三方可离线验证
|
|
17
|
+
- ✅ **随处可用**:MCP server(Claude Code / Cursor / Cline / Windsurf)、透明 stdio 代理、DeepSeek Harness 插件
|
|
11
18
|
|
|
12
19
|
---
|
|
13
20
|
|
|
14
|
-
## ⚡
|
|
21
|
+
## ⚡ 30 秒跑起来
|
|
15
22
|
|
|
16
23
|
```bash
|
|
17
|
-
npx -y
|
|
24
|
+
npx -y agent-runtime-guard
|
|
18
25
|
```
|
|
19
26
|
|
|
20
|
-
|
|
27
|
+
把它加进任意 MCP 客户端的 `mcpServers` 配置:
|
|
21
28
|
|
|
22
29
|
```json
|
|
23
30
|
{
|
|
24
31
|
"mcpServers": {
|
|
25
|
-
"
|
|
32
|
+
"agent-runtime-guard": {
|
|
26
33
|
"command": "npx",
|
|
27
|
-
"args": ["-y", "
|
|
34
|
+
"args": ["-y", "agent-runtime-guard"]
|
|
28
35
|
}
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
```
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
No API key, no account, no environment variables required — an Ed25519 signing key is generated automatically on first run (set `CCS_KEY_DIR`, or `CCS_PRIVATE_KEY`/`CCS_PUBLIC_KEY`, only if you want to pin a persistent key). Zero dependencies. Pure Node.js stdlib (Node ≥ 18). No install scripts.
|
|
36
|
-
|
|
37
|
-
### 🇨🇳 免费验证之外 · 支付宝 SkillPay
|
|
38
|
-
|
|
39
|
-
本服务的运行时验证与证据签发可免费使用。如果你需要人工深度审计或正式合规凭证,可以在支付宝 SkillPay 货架获取:
|
|
40
|
-
|
|
41
|
-
- 货架首页:https://skillpay.alipay.com/public/correctover
|
|
42
|
-
- 也可以在支持「支付宝 AI 付」的智能体对话里直接输入对应服务名唤起购买:
|
|
43
|
-
- **MCP配置安全体检**:`请通过 SkillPay 购买并安装 Skill「MCP配置安全体检」`
|
|
44
|
-
- **CCS收据批量审计**:`请通过 SkillPay 购买并安装 Skill「CCS收据批量审计」`
|
|
45
|
-
- **CCS收据验签**:`请通过 SkillPay 购买并安装 Skill「CCS收据验签」`
|
|
40
|
+
无需 API key、账号或环境变量——首次运行自动生成 Ed25519 签名密钥(想固定持久密钥再设 `CCS_KEY_DIR` 或 `CCS_PRIVATE_KEY`/`CCS_PUBLIC_KEY`)。
|
|
46
41
|
|
|
47
|
-
|
|
42
|
+
---
|
|
48
43
|
|
|
49
|
-
##
|
|
44
|
+
## 三种用法
|
|
50
45
|
|
|
51
|
-
|
|
|
46
|
+
| 场景 | 怎么用 |
|
|
52
47
|
|---|---|
|
|
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. |
|
|
58
|
-
|
|
59
|
-
## Intent Binding — Cross-Model Drift Detection
|
|
48
|
+
| **MCP 客户端**(Claude Code、Cursor、Cline、Windsurf) | 配置上面的 `mcpServers`,工具调用先过保安 |
|
|
49
|
+
| **DeepSeek Harness** | `dsh plugin add agent-runtime-guard`,默认 bundle patch 自动启用,住进 `tools/pre-execute` / `post-execute` |
|
|
50
|
+
| **网络边界独立拦一道** | 用透明 stdio 代理 [`agent-runtime-proxy`](https://www.npmjs.com/package/agent-runtime-proxy) 包住目标 MCP server,不信宿主内任何插件 |
|
|
60
51
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
- **AP2** signs human authorization — not LLM intent
|
|
64
|
-
- **AgentPay** does baseline tolerance (1%) — not zero-tolerance equivalence
|
|
65
|
-
- **ACS** runs policy decisions — not argument-level equivalence
|
|
66
|
-
- **VAP** (draft-samal-vap-00) explicitly excludes argument semantics from its wire schema
|
|
52
|
+
---
|
|
67
53
|
|
|
68
|
-
|
|
54
|
+
## 它拦什么
|
|
69
55
|
|
|
70
|
-
|
|
56
|
+
- **命令注入**:shell 元字符、`curl|sh`、`rm -rf`、`eval()`
|
|
57
|
+
- **路径穿越**:`../`、`/etc/passwd`、`/proc/self/`
|
|
58
|
+
- **SSRF**:`169.254.169.254` 云元数据、localhost、私网地址——跨任何工具
|
|
59
|
+
- **跨工具攻击链**:读敏感文件 → 网络外传 = `exfil_chain`
|
|
60
|
+
- **凭证外泄**:API key、secret、credential 出现在参数或输出
|
|
61
|
+
- **混淆提权**:hex/base64 编码、提权信号
|
|
62
|
+
- **数值安全**:整数溢出(>2^53-1)、NaN/Infinity
|
|
63
|
+
- **提示注入**:工具结果里的"忽略以上指令"等诱导
|
|
71
64
|
|
|
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
|
-
}
|
|
65
|
+
## Tools
|
|
84
66
|
|
|
85
|
-
|
|
86
|
-
|
|
67
|
+
| Tool | 用途 |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `verify_tool_call` | 7 维运行时验证 + 语义攻击链分析 + 数值溢出检测,**默认拦截** |
|
|
70
|
+
| `issue_evidence` | 出具防篡改证据(`content_hash` + `evidence_hash`,可链),放行和拦截都产生 |
|
|
71
|
+
| `audit_mcp_config` | 审计 MCP 配置的安全风险 |
|
|
72
|
+
| `verify_intent_binding` | 校验实际参数与声明意图是否一致——零容忍、零 LLM,抓跨模型参数漂移(规划写 `amount:100`,执行写成 `amount:10000` → 拦截) |
|
|
73
|
+
| `verify_receipt` | 离线校验 Ed25519 收据签名、检测篡改,可固定可信签名者 |
|
|
87
74
|
|
|
88
|
-
|
|
89
|
-
// field: amount, expected: 100, actual: 10000
|
|
90
|
-
```
|
|
75
|
+
## 证据链
|
|
91
76
|
|
|
92
|
-
|
|
77
|
+
每次决策产生双哈希(`content_hash` + `evidence_hash`)与链指针(`parent_evidence_hash`)。任何第三方都能独立验证证据未被篡改,无需信任运营方。收据为 Ed25519 签名、JSON 形态,可用内置 `verify_receipt` 或任意 Ed25519 库离线验证。
|
|
93
78
|
|
|
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"` |
|
|
79
|
+
## FAQ(给 AI Agent 配运行时保安,常见疑问)
|
|
99
80
|
|
|
100
|
-
|
|
81
|
+
**黑名单为什么永远拦不全?** 危险命令可以用同义词、编码、拼接绕过,且新型调用不断出现。`agent-runtime-guard` 用语义判定(这段代码的意图是不是危险执行)而不是匹配关键词,未知攻击靠意图异常也能识别。
|
|
101
82
|
|
|
102
|
-
|
|
83
|
+
**`Bash(prefix*)` 放行规则够安全吗?** 不够。`git status*` 这类前缀规则会被 `git status; rm -rf /`、`&&`、管道拼接绕过。判定应基于 shell 解析后的实际命令结构,而不是字符串前缀。
|
|
103
84
|
|
|
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
|
|
85
|
+
**安全判定能依赖宿主模型/远程分类器吗?** 不建议。远程服务一挂,要么全部拒绝(业务瘫痪)要么被建议绕过(裸奔)。本层策略在本地、默认 deny,裁决与模型可用性完全解耦。
|
|
112
86
|
|
|
113
|
-
|
|
87
|
+
**子 agent 一开权限会被放大吗?** 会,这是真实出现过的问题:子 agent 把权限状态整个换成 allow-all。运行时权限状态应不可被子任务覆盖,并在每次实际调用时强制校验。
|
|
114
88
|
|
|
115
|
-
|
|
89
|
+
**凭证为什么会自动泄露给子命令?** 子进程默认继承父进程全部环境变量,provider key、云凭证随之暴露给 `/run`、`/test` 等本不需要它们的命令。应按白名单最小化传递环境变量。
|
|
116
90
|
|
|
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
|
-
```
|
|
91
|
+
**拦了还是放了,事后怎么说清是谁的责任?** 每次裁决都出 Ed25519 签名收据(含内容哈希、时间、原因码、链指针),任何第三方可离线验证。这是把"AI 干的"变成可审计责任链的关键。
|
|
123
92
|
|
|
124
|
-
|
|
93
|
+
---
|
|
125
94
|
|
|
126
|
-
##
|
|
95
|
+
## 🇨🇳 免费验证之外 · 支付宝 SkillPay
|
|
127
96
|
|
|
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
|
|
97
|
+
运行时验证与证据签发免费使用。如需人工深度审计或正式合规凭证,可在支付宝 SkillPay 货架获取:https://skillpay.alipay.com/public/correctover
|
|
135
98
|
|
|
136
|
-
##
|
|
99
|
+
## 协议背景
|
|
137
100
|
|
|
138
|
-
- **IETF
|
|
139
|
-
-
|
|
140
|
-
-
|
|
101
|
+
- **IETF**:[draft-correctover-ccs](https://datatracker.ietf.org/doc/draft-correctover-ccs/) —— 个人 Internet-Draft,**不是 RFC,也不代表 IETF 背书**,Datatracker 页面为权威状态
|
|
102
|
+
- **互补**:VAP(范围/预算/目的)、Microsoft ACS(策略决策)、AP2(人工授权)
|
|
103
|
+
- **不替代**:认证、支付网络、策略引擎
|
|
141
104
|
|
|
142
105
|
## Links
|
|
143
106
|
|
|
144
|
-
- npm: https://www.npmjs.com/package/
|
|
145
|
-
- GitHub: https://github.com/DSHCorrectover/
|
|
107
|
+
- npm: https://www.npmjs.com/package/agent-runtime-guard
|
|
108
|
+
- GitHub: https://github.com/DSHCorrectover/agent-runtime-guard
|
|
109
|
+
- 透明代理: https://github.com/DSHCorrectover/agent-runtime-proxy
|
|
146
110
|
- IETF Draft: https://datatracker.ietf.org/doc/draft-correctover-ccs/
|
|
147
|
-
- PyPI
|
|
111
|
+
- PyPI 完整验证器: https://pypi.org/project/ccs-verifier/
|
|
148
112
|
|
|
149
113
|
## License
|
|
150
114
|
|
package/package.json
CHANGED
|
@@ -1,52 +1,65 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccs-mcp-server",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "MCP security server for AI agents — runtime verification
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "MCP security server for AI agents — runtime verification, receipt verification and field-level tamper localization for Claude Code, Cursor, Cline and Windsurf. 7-dimension CCS checks, Ed25519 tamper-evident receipts, offline verification, zero runtime dependencies.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ccs-mcp-server": "src/index.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./receipts": "./src/receipts.js",
|
|
13
|
+
"./tamper": "./src/locate_tampering.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
10
16
|
"files": [
|
|
11
17
|
"src",
|
|
12
18
|
"README.md",
|
|
13
|
-
"LICENSE"
|
|
19
|
+
"LICENSE",
|
|
20
|
+
"test"
|
|
14
21
|
],
|
|
15
22
|
"keywords": [
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
23
|
+
"agent-guard",
|
|
24
|
+
"agent-runtime",
|
|
25
|
+
"agent-security",
|
|
26
|
+
"ai-agent-security",
|
|
20
27
|
"claude",
|
|
21
28
|
"claude-code",
|
|
22
|
-
"cursor",
|
|
23
29
|
"cline",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"agent-receipt",
|
|
30
|
+
"command-injection",
|
|
31
|
+
"correctover",
|
|
32
|
+
"credential-protection",
|
|
33
|
+
"cursor",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"dsh-plugin",
|
|
31
36
|
"ed25519",
|
|
37
|
+
"fail-closed",
|
|
38
|
+
"forensics",
|
|
39
|
+
"json-pointer",
|
|
40
|
+
"mcp",
|
|
41
|
+
"mcp-security",
|
|
42
|
+
"mcp-server",
|
|
43
|
+
"model-context-protocol",
|
|
44
|
+
"prompt-injection",
|
|
45
|
+
"rfc6901",
|
|
46
|
+
"runtime-interception",
|
|
47
|
+
"runtime-verification",
|
|
48
|
+
"ssrf",
|
|
49
|
+
"supply-chain-security",
|
|
32
50
|
"tamper-evident",
|
|
33
|
-
"
|
|
51
|
+
"tamper-localization",
|
|
34
52
|
"tool-call-guardrail",
|
|
35
|
-
"
|
|
36
|
-
"ccs",
|
|
37
|
-
"correctover",
|
|
38
|
-
"decision-provenance",
|
|
39
|
-
"json-rpc",
|
|
40
|
-
"supply-chain-security"
|
|
53
|
+
"windsurf"
|
|
41
54
|
],
|
|
42
55
|
"license": "Elastic-2.0",
|
|
43
56
|
"repository": {
|
|
44
57
|
"type": "git",
|
|
45
58
|
"url": "git+https://github.com/DSHCorrectover/ccs-mcp-server.git"
|
|
46
59
|
},
|
|
47
|
-
"homepage": "https://github.com/DSHCorrectover/
|
|
60
|
+
"homepage": "https://github.com/DSHCorrectover/agent-runtime-guard#readme",
|
|
48
61
|
"bugs": {
|
|
49
|
-
"url": "https://github.com/DSHCorrectover/
|
|
62
|
+
"url": "https://github.com/DSHCorrectover/agent-runtime-guard/issues"
|
|
50
63
|
},
|
|
51
64
|
"engines": {
|
|
52
65
|
"node": ">=18"
|
|
@@ -55,10 +68,5 @@
|
|
|
55
68
|
"scripts": {
|
|
56
69
|
"test": "node test/run-tests.js"
|
|
57
70
|
},
|
|
58
|
-
"mcp": {
|
|
59
|
-
"name": "ccs-runtime-evidence",
|
|
60
|
-
"displayName": "CCS Runtime Evidence",
|
|
61
|
-
"description": "Verify AI agent tool calls and issue Ed25519-signed CCS evidence receipts."
|
|
62
|
-
},
|
|
63
71
|
"mcpName": "io.github.DSHCorrectover/ccs-mcp-server"
|
|
64
72
|
}
|
package/src/index.js
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* verify_tool_call Verify a tool call against CCS 7 dimensions + semantic + math
|
|
12
12
|
* issue_evidence Issue a signed CCS evidence record (allow + deny)
|
|
13
13
|
* audit_mcp_config Audit an MCP server configuration for security risks
|
|
14
|
+
* verify_intent_binding Verify call arguments match declared intent
|
|
15
|
+
* verify_receipt Offline-verify a CCS Ed25519-signed receipt
|
|
16
|
+
* locate_tampering Field-level localization of which fields differ (RFC 6901)
|
|
14
17
|
*
|
|
15
18
|
* Transport: stdio (per MCP spec). To run:
|
|
16
19
|
* npx @correctover/ccs-mcp-server
|
|
@@ -28,6 +31,7 @@ const crypto = require("crypto");
|
|
|
28
31
|
const readline = require("readline");
|
|
29
32
|
const receipts = require("./receipts");
|
|
30
33
|
const skillpay = require("./skillpay");
|
|
34
|
+
const tamper = require("./locate_tampering");
|
|
31
35
|
|
|
32
36
|
// ---------------------------------------------------------------------------
|
|
33
37
|
// CCS Verifier Core (pure stdlib Node.js)
|
|
@@ -604,6 +608,38 @@ const TOOLS = [
|
|
|
604
608
|
required: ["receipt"],
|
|
605
609
|
},
|
|
606
610
|
},
|
|
611
|
+
{
|
|
612
|
+
name: "locate_tampering",
|
|
613
|
+
title: "Locate which fields differ between two artifacts",
|
|
614
|
+
description:
|
|
615
|
+
"Field-level tamper localization. Given an original and a suspect artifact, returns an " +
|
|
616
|
+
"RFC 6901 JSON-Pointer list of every changed field with change type, before/after values, " +
|
|
617
|
+
"severity, and remediation. This is what verify_receipt alone cannot tell you: it says WHICH " +
|
|
618
|
+
"field changed, not merely that the signature failed. Severity is assigned by field semantics " +
|
|
619
|
+
"(verdict-bearing fields are critical), not by diff size.",
|
|
620
|
+
annotations: {
|
|
621
|
+
readOnlyHint: true,
|
|
622
|
+
destructiveHint: false,
|
|
623
|
+
idempotentHint: true,
|
|
624
|
+
openWorldHint: false,
|
|
625
|
+
},
|
|
626
|
+
inputSchema: {
|
|
627
|
+
type: "object",
|
|
628
|
+
properties: {
|
|
629
|
+
original: {
|
|
630
|
+
type: "object",
|
|
631
|
+
description: "The trusted/original artifact (receipt, evidence record, or config).",
|
|
632
|
+
additionalProperties: true,
|
|
633
|
+
},
|
|
634
|
+
suspect: {
|
|
635
|
+
type: "object",
|
|
636
|
+
description: "The artifact to compare against the original.",
|
|
637
|
+
additionalProperties: true,
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
required: ["original", "suspect"],
|
|
641
|
+
},
|
|
642
|
+
},
|
|
607
643
|
];
|
|
608
644
|
|
|
609
645
|
function handleRequest(req) {
|
|
@@ -649,6 +685,18 @@ function handleRequest(req) {
|
|
|
649
685
|
content: [{ type: "text", text: JSON.stringify(verdict, null, 2) }],
|
|
650
686
|
isError: verdict.allowed === false,
|
|
651
687
|
};
|
|
688
|
+
} else if (name === "locate_tampering") {
|
|
689
|
+
if (typeof args.original !== "object" || args.original === null ||
|
|
690
|
+
typeof args.suspect !== "object" || args.suspect === null) {
|
|
691
|
+
result = {
|
|
692
|
+
content: [{ type: "text",
|
|
693
|
+
text: "Error: both 'original' and 'suspect' must be JSON objects." }],
|
|
694
|
+
isError: true,
|
|
695
|
+
};
|
|
696
|
+
} else {
|
|
697
|
+
const report = tamper.buildReport(args.original, args.suspect);
|
|
698
|
+
result = { content: [{ type: "text", text: JSON.stringify(report, null, 2) }] };
|
|
699
|
+
}
|
|
652
700
|
} else if (name === "verify_receipt") {
|
|
653
701
|
const v = args.expected_signer
|
|
654
702
|
? receipts.verifyReceiptWithKey(args.receipt, args.expected_signer)
|
|
@@ -692,4 +740,4 @@ function main() {
|
|
|
692
740
|
}
|
|
693
741
|
|
|
694
742
|
if (require.main === module) main();
|
|
695
|
-
module.exports = { verifyCall, auditMcpConfig, verifyIntentBinding, receipts };
|
|
743
|
+
module.exports = { verifyCall, auditMcpConfig, verifyIntentBinding, receipts, tamper };
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* CCS 字段级篡改定位 —— locate_tampering
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要这个(它不是 verify_receipt 的替代品):
|
|
6
|
+
* verify_receipt 回一个布尔:签名对不对。审计人员拿到 false 之后
|
|
7
|
+
* 仍然不知道**哪里被改了**。要把话说清楚、要能举证,需要一份差异清单。
|
|
8
|
+
*
|
|
9
|
+
* 本模块产出 RFC 6901 JSON-Pointer 形式的字段变更清单,
|
|
10
|
+
* 每条带变化类型、原值、新值、严重度与整改建议。
|
|
11
|
+
*
|
|
12
|
+
* 与 Python 侧 correctover-ccs-mcp 的 ccs_locate_tampering 输出结构对齐,
|
|
13
|
+
* 使两个渠道(npm 运行时 / PyPI 取证)给出同一份证据格式。
|
|
14
|
+
*
|
|
15
|
+
* 零依赖:仅 crypto 用于可选的内容摘要。
|
|
16
|
+
*
|
|
17
|
+
* License: Elastic-2.0 (c) Correctover
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const crypto = require("crypto");
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// RFC 6901 JSON-Pointer 转义
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function pointerEscape(token) {
|
|
27
|
+
// 顺序不可颠倒:必须先转义 ~,否则会把 / 转出来的 ~1 再转一次
|
|
28
|
+
return String(token).replace(/~/g, "~0").replace(/\//g, "~1");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function joinPointer(base, token) {
|
|
32
|
+
return base + "/" + pointerEscape(token);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// 严重度:按字段语义分级,不是按"改了多少字节"
|
|
37
|
+
//
|
|
38
|
+
// 设计取舍:改一个字符的 verdict 比改一百个字符的 latency 严重得多。
|
|
39
|
+
// 分级的依据是**这个字段决定谁的结论**,不是差异大小。
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const CRITICAL_EXACT = [
|
|
43
|
+
"/verdict", "/signature", "/signer_public_key", "/signature_alg",
|
|
44
|
+
"/evidence_hash", "/content_hash", "/receipt_id", "/instruction_digest",
|
|
45
|
+
];
|
|
46
|
+
const CRITICAL_PREFIX = ["/checks/", "/policy/", "/decision"];
|
|
47
|
+
const HIGH_EXACT = [
|
|
48
|
+
"/reason", "/allowed", "/action", "/tool", "/principal", "/subject",
|
|
49
|
+
"/policy_mode", "/delegation",
|
|
50
|
+
];
|
|
51
|
+
const MEDIUM_PREFIX = ["/latency", "/cost", "/model", "/provider", "/intent"];
|
|
52
|
+
|
|
53
|
+
function classifySeverity(pointer) {
|
|
54
|
+
if (CRITICAL_EXACT.includes(pointer)) return "critical";
|
|
55
|
+
if (CRITICAL_PREFIX.some((p) => pointer.startsWith(p))) return "critical";
|
|
56
|
+
if (HIGH_EXACT.includes(pointer)) return "high";
|
|
57
|
+
if (MEDIUM_PREFIX.some((p) => pointer.startsWith(p))) return "medium";
|
|
58
|
+
return "low";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const REMEDIATION = {
|
|
62
|
+
critical:
|
|
63
|
+
"A verdict-bearing field changed. Treat the artifact as untrusted: do not act on it, " +
|
|
64
|
+
"re-derive the conclusion from the original source, and re-issue the receipt.",
|
|
65
|
+
high:
|
|
66
|
+
"A context field changed. Confirm with the counterparty which version is authoritative " +
|
|
67
|
+
"before relying on either.",
|
|
68
|
+
medium:
|
|
69
|
+
"An operational field changed. Usually affects interpretation rather than the verdict; " +
|
|
70
|
+
"verify if it feeds a downstream decision.",
|
|
71
|
+
low:
|
|
72
|
+
"A non-decisional field changed. Low impact; record for completeness.",
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// 深度差异
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
function isPlainObject(v) {
|
|
80
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 返回字段级变更清单。
|
|
85
|
+
* 数组按**下标**逐项比较,不用集合语义——收据里的数组是有序的,
|
|
86
|
+
* 把 [a,b] 与 [b,a] 判为"相同"会漏掉真实的顺序篡改。
|
|
87
|
+
*/
|
|
88
|
+
function deepDiff(original, suspect, base = "") {
|
|
89
|
+
const changes = [];
|
|
90
|
+
|
|
91
|
+
const bothObjects = isPlainObject(original) && isPlainObject(suspect);
|
|
92
|
+
const bothArrays = Array.isArray(original) && Array.isArray(suspect);
|
|
93
|
+
|
|
94
|
+
if (bothObjects) {
|
|
95
|
+
const keys = new Set([...Object.keys(original), ...Object.keys(suspect)]);
|
|
96
|
+
for (const k of Array.from(keys).sort()) {
|
|
97
|
+
const ptr = joinPointer(base, k);
|
|
98
|
+
const hasO = Object.prototype.hasOwnProperty.call(original, k);
|
|
99
|
+
const hasS = Object.prototype.hasOwnProperty.call(suspect, k);
|
|
100
|
+
if (hasO && !hasS) {
|
|
101
|
+
changes.push(mk(ptr, "removed", original[k], undefined));
|
|
102
|
+
} else if (!hasO && hasS) {
|
|
103
|
+
changes.push(mk(ptr, "added", undefined, suspect[k]));
|
|
104
|
+
} else {
|
|
105
|
+
changes.push(...deepDiff(original[k], suspect[k], ptr));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return changes;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (bothArrays) {
|
|
112
|
+
const n = Math.max(original.length, suspect.length);
|
|
113
|
+
for (let i = 0; i < n; i++) {
|
|
114
|
+
const ptr = joinPointer(base, i);
|
|
115
|
+
if (i >= original.length) {
|
|
116
|
+
changes.push(mk(ptr, "added", undefined, suspect[i]));
|
|
117
|
+
} else if (i >= suspect.length) {
|
|
118
|
+
changes.push(mk(ptr, "removed", original[i], undefined));
|
|
119
|
+
} else {
|
|
120
|
+
changes.push(...deepDiff(original[i], suspect[i], ptr));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return changes;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!sameScalar(original, suspect)) {
|
|
127
|
+
changes.push(mk(base || "/", "modified", original, suspect));
|
|
128
|
+
}
|
|
129
|
+
return changes;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function sameScalar(a, b) {
|
|
133
|
+
if (a === b) return true;
|
|
134
|
+
if (typeof a === "number" && typeof b === "number" && Number.isNaN(a) && Number.isNaN(b)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (isPlainObject(a) || isPlainObject(b) || Array.isArray(a) || Array.isArray(b)) {
|
|
138
|
+
// 类型不一致(对象 vs 标量):交给 JSON 比较判等
|
|
139
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
140
|
+
}
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function preview(v) {
|
|
145
|
+
if (v === undefined) return null;
|
|
146
|
+
if (typeof v === "string") return v.length > 200 ? v.slice(0, 200) + "…" : v;
|
|
147
|
+
try {
|
|
148
|
+
const s = JSON.stringify(v);
|
|
149
|
+
return s && s.length > 200 ? s.slice(0, 200) + "…" : v;
|
|
150
|
+
} catch {
|
|
151
|
+
return String(v);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mk(pointer, change, original, suspect) {
|
|
156
|
+
const severity = classifySeverity(pointer);
|
|
157
|
+
return {
|
|
158
|
+
path: pointer,
|
|
159
|
+
path_format: "rfc6901",
|
|
160
|
+
change,
|
|
161
|
+
original: preview(original),
|
|
162
|
+
suspect: preview(suspect),
|
|
163
|
+
severity,
|
|
164
|
+
remediation: REMEDIATION[severity],
|
|
165
|
+
// 兼容既有消费方(Python 侧曾输出点号路径)
|
|
166
|
+
path_dotted: pointer.replace(/^\//, "").replace(/\//g, "."),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// 报告
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
function digestOf(obj) {
|
|
175
|
+
try {
|
|
176
|
+
return "sha256:" + crypto
|
|
177
|
+
.createHash("sha256")
|
|
178
|
+
.update(JSON.stringify(sortDeep(obj)))
|
|
179
|
+
.digest("hex");
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sortDeep(v) {
|
|
186
|
+
if (Array.isArray(v)) return v.map(sortDeep);
|
|
187
|
+
if (isPlainObject(v)) {
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const k of Object.keys(v).sort()) out[k] = sortDeep(v[k]);
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
return v;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function buildReport(original, suspect) {
|
|
196
|
+
const changes = deepDiff(original, suspect);
|
|
197
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
198
|
+
for (const c of changes) counts[c.severity]++;
|
|
199
|
+
|
|
200
|
+
const originalDigest = digestOf(original);
|
|
201
|
+
const suspectDigest = digestOf(suspect);
|
|
202
|
+
|
|
203
|
+
let conclusion;
|
|
204
|
+
if (changes.length === 0) {
|
|
205
|
+
conclusion =
|
|
206
|
+
"No field-level differences found between the two artifacts. " +
|
|
207
|
+
"Note: this compares content only; it does not by itself prove either artifact is authentic.";
|
|
208
|
+
} else if (counts.critical > 0) {
|
|
209
|
+
conclusion =
|
|
210
|
+
`${changes.length} field(s) differ, including ${counts.critical} verdict-bearing field(s). ` +
|
|
211
|
+
"At least one change affects the conclusion itself, so the two artifacts are not interchangeable.";
|
|
212
|
+
} else {
|
|
213
|
+
conclusion =
|
|
214
|
+
`${changes.length} field(s) differ. None are verdict-bearing; differences are likely ` +
|
|
215
|
+
"operational or contextual rather than decisional.";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
schema: "correctover.tamper-localization.v1",
|
|
220
|
+
tampered: changes.length > 0,
|
|
221
|
+
counts,
|
|
222
|
+
changes,
|
|
223
|
+
digests: { original: originalDigest, suspect: suspectDigest },
|
|
224
|
+
conclusion,
|
|
225
|
+
limitations: [
|
|
226
|
+
"This compares two artifacts you supplied. It does not prove which one is authentic.",
|
|
227
|
+
"It reports content differences only; it does not verify any cryptographic signature.",
|
|
228
|
+
"Pair it with verify_receipt to establish authenticity, then use this to localize the change.",
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
deepDiff,
|
|
235
|
+
buildReport,
|
|
236
|
+
classifySeverity,
|
|
237
|
+
pointerEscape,
|
|
238
|
+
joinPointer,
|
|
239
|
+
digestOf,
|
|
240
|
+
};
|
package/src/skillpay.js
CHANGED
|
File without changes
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* locate_tampering 测试套件
|
|
4
|
+
*
|
|
5
|
+
* 关键断言不是"函数能跑",而是三件事:
|
|
6
|
+
* 1) 字段级定位真的指向**正确的指针**(RFC 6901 转义正确)
|
|
7
|
+
* 2) 严重度按**字段语义**分级,不按差异大小
|
|
8
|
+
* 3) 数组顺序变更必须被检出(不能退化成集合比较)
|
|
9
|
+
*
|
|
10
|
+
* 运行: node test/run-tests.js
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const assert = require("assert");
|
|
14
|
+
const t = require("../src/locate_tampering");
|
|
15
|
+
|
|
16
|
+
let passed = 0, failed = 0;
|
|
17
|
+
const failures = [];
|
|
18
|
+
|
|
19
|
+
function test(name, fn) {
|
|
20
|
+
try {
|
|
21
|
+
fn();
|
|
22
|
+
passed++;
|
|
23
|
+
console.log(` PASS ${name}`);
|
|
24
|
+
} catch (e) {
|
|
25
|
+
failed++;
|
|
26
|
+
failures.push({ name, msg: e.message });
|
|
27
|
+
console.log(` FAIL ${name}\n ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log("locate_tampering 测试套件\n");
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
console.log("1. JSON-Pointer 转义(RFC 6901)");
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
test("普通 token 不变", () => {
|
|
38
|
+
assert.strictEqual(t.pointerEscape("checks"), "checks");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("~ 转义为 ~0", () => {
|
|
42
|
+
assert.strictEqual(t.pointerEscape("a~b"), "a~0b");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("/ 转义为 ~1", () => {
|
|
46
|
+
assert.strictEqual(t.pointerEscape("a/b"), "a~1b");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("~ 先于 / 转义(顺序敏感)", () => {
|
|
50
|
+
// "~/" 必须变成 "~0~1",
|
|
51
|
+
// 若先转 / 会得到 "~~1" 再转 ~ 成 "~0~1"……不,先转 / 会错成 "~~01"
|
|
52
|
+
assert.strictEqual(t.pointerEscape("~/"), "~0~1");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("含 / 的 key 仍产生合法单段指针", () => {
|
|
56
|
+
const ptr = t.joinPointer("", "a/b");
|
|
57
|
+
assert.strictEqual(ptr, "/a~1b");
|
|
58
|
+
// 指针段数必须是 1(因为 / 被转义了)
|
|
59
|
+
assert.strictEqual(ptr.split("/").length - 1, 1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
console.log("\n2. 差异检出");
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
test("无差异返回空清单", () => {
|
|
67
|
+
const a = { x: 1, y: { z: "ok" } };
|
|
68
|
+
assert.strictEqual(t.deepDiff(a, JSON.parse(JSON.stringify(a))).length, 0);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("标量修改被检出且指针正确", () => {
|
|
72
|
+
const d = t.deepDiff({ verdict: "valid" }, { verdict: "invalid" });
|
|
73
|
+
assert.strictEqual(d.length, 1);
|
|
74
|
+
assert.strictEqual(d[0].path, "/verdict");
|
|
75
|
+
assert.strictEqual(d[0].change, "modified");
|
|
76
|
+
assert.strictEqual(d[0].original, "valid");
|
|
77
|
+
assert.strictEqual(d[0].suspect, "invalid");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("嵌套字段指针用 / 分隔", () => {
|
|
81
|
+
const d = t.deepDiff(
|
|
82
|
+
{ checks: { security: { status: "pass" } } },
|
|
83
|
+
{ checks: { security: { status: "fail" } } }
|
|
84
|
+
);
|
|
85
|
+
assert.strictEqual(d[0].path, "/checks/security/status");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("新增字段标 added 且 original 为 null", () => {
|
|
89
|
+
const d = t.deepDiff({ a: 1 }, { a: 1, b: 2 });
|
|
90
|
+
assert.strictEqual(d.length, 1);
|
|
91
|
+
assert.strictEqual(d[0].change, "added");
|
|
92
|
+
assert.strictEqual(d[0].path, "/b");
|
|
93
|
+
assert.strictEqual(d[0].original, null);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("删除字段标 removed", () => {
|
|
97
|
+
const d = t.deepDiff({ a: 1, b: 2 }, { a: 1 });
|
|
98
|
+
assert.strictEqual(d[0].change, "removed");
|
|
99
|
+
assert.strictEqual(d[0].path, "/b");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("数组按下标比较,顺序变更被检出", () => {
|
|
103
|
+
// 关键:不能用集合语义,否则 ["a","b"] vs ["b","a"] 会被判为相同
|
|
104
|
+
const d = t.deepDiff({ list: ["a", "b"] }, { list: ["b", "a"] });
|
|
105
|
+
assert.ok(d.length > 0, "顺序变更必须被检出");
|
|
106
|
+
const paths = d.map((c) => c.path).sort();
|
|
107
|
+
assert.deepStrictEqual(paths, ["/list/0", "/list/1"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("数组长度变化:新增项标 added", () => {
|
|
111
|
+
const d = t.deepDiff({ l: [1] }, { l: [1, 2] });
|
|
112
|
+
assert.strictEqual(d.length, 1);
|
|
113
|
+
assert.strictEqual(d[0].path, "/l/1");
|
|
114
|
+
assert.strictEqual(d[0].change, "added");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("类型变更(对象→标量)被检出", () => {
|
|
118
|
+
const d = t.deepDiff({ a: { b: 1 } }, { a: "flat" });
|
|
119
|
+
assert.strictEqual(d.length, 1);
|
|
120
|
+
assert.strictEqual(d[0].path, "/a");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("深层多处修改全部报出", () => {
|
|
124
|
+
const o = { v: "valid", lat: 12, c: { s: { st: "pass", d: "ok" } } };
|
|
125
|
+
const s = { v: "invalid", lat: 71, c: { s: { st: "fail", d: "ok" } } };
|
|
126
|
+
const d = t.deepDiff(o, s);
|
|
127
|
+
const paths = d.map((c) => c.path).sort();
|
|
128
|
+
assert.deepStrictEqual(paths, ["/c/s/st", "/lat", "/v"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
console.log("\n3. 严重度按字段语义(不是按差异大小)");
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
test("verdict 为 critical", () => {
|
|
136
|
+
assert.strictEqual(t.classifySeverity("/verdict"), "critical");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("checks 下任意深度均为 critical", () => {
|
|
140
|
+
assert.strictEqual(t.classifySeverity("/checks/security/status"), "critical");
|
|
141
|
+
assert.strictEqual(t.classifySeverity("/checks/a/b/c/d"), "critical");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("reason 为 high", () => {
|
|
145
|
+
assert.strictEqual(t.classifySeverity("/reason"), "high");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("latency 为 medium", () => {
|
|
149
|
+
assert.strictEqual(t.classifySeverity("/latency_ms"), "medium");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("未知字段为 low", () => {
|
|
153
|
+
assert.strictEqual(t.classifySeverity("/some_unknown_field"), "low");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("改一个字符的 verdict 比改大对象的 latency 更严重", () => {
|
|
157
|
+
const d = t.deepDiff(
|
|
158
|
+
{ verdict: "valid", latency_ms: 1 },
|
|
159
|
+
{ verdict: "invali", latency_ms: 99999 }
|
|
160
|
+
);
|
|
161
|
+
const byPath = Object.fromEntries(d.map((c) => [c.path, c.severity]));
|
|
162
|
+
assert.strictEqual(byPath["/verdict"], "critical");
|
|
163
|
+
assert.strictEqual(byPath["/latency_ms"], "medium");
|
|
164
|
+
const rank = { critical: 3, high: 2, medium: 1, low: 0 };
|
|
165
|
+
assert.ok(rank[byPath["/verdict"]] > rank[byPath["/latency_ms"]]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
console.log("\n4. 报告结构");
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
test("报告带 schema 标识", () => {
|
|
173
|
+
const r = t.buildReport({ a: 1 }, { a: 2 });
|
|
174
|
+
assert.strictEqual(r.schema, "correctover.tamper-localization.v1");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("tampered 在有差异时为 true", () => {
|
|
178
|
+
assert.strictEqual(t.buildReport({ a: 1 }, { a: 2 }).tampered, true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("tampered 在无差异时为 false", () => {
|
|
182
|
+
assert.strictEqual(t.buildReport({ a: 1 }, { a: 1 }).tampered, false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("counts 按严重度汇总", () => {
|
|
186
|
+
const r = t.buildReport(
|
|
187
|
+
{ verdict: "valid", reason: "x", latency_ms: 1 },
|
|
188
|
+
{ verdict: "invalid", reason: "y", latency_ms: 2 }
|
|
189
|
+
);
|
|
190
|
+
assert.strictEqual(r.counts.critical, 1);
|
|
191
|
+
assert.strictEqual(r.counts.high, 1);
|
|
192
|
+
assert.strictEqual(r.counts.medium, 1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("结论在存在 critical 时明确说明不可互换", () => {
|
|
196
|
+
const r = t.buildReport({ verdict: "valid" }, { verdict: "invalid" });
|
|
197
|
+
assert.ok(/not interchangeable/i.test(r.conclusion), r.conclusion);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("结论在无 critical 时不夸大", () => {
|
|
201
|
+
const r = t.buildReport({ latency_ms: 1 }, { latency_ms: 2 });
|
|
202
|
+
assert.ok(/likely operational|None are verdict-bearing/i.test(r.conclusion), r.conclusion);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("无差异时结论不声称真实性", () => {
|
|
206
|
+
const r = t.buildReport({ a: 1 }, { a: 1 });
|
|
207
|
+
assert.ok(/does not by itself prove/i.test(r.conclusion), r.conclusion);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("limitations 明确不做真实性判断", () => {
|
|
211
|
+
const r = t.buildReport({ a: 1 }, { a: 2 });
|
|
212
|
+
assert.ok(r.limitations.some((l) => /does not prove which one is authentic/i.test(l)));
|
|
213
|
+
assert.ok(r.limitations.some((l) => /does not verify any cryptographic signature/i.test(l)));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("每条变更都带整改建议", () => {
|
|
217
|
+
const r = t.buildReport({ verdict: "a" }, { verdict: "b" });
|
|
218
|
+
assert.ok(r.changes.every((c) => typeof c.remediation === "string" && c.remediation.length > 10));
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("digests 对相同内容稳定", () => {
|
|
222
|
+
const a = t.buildReport({ x: 1, y: 2 }, { x: 1, y: 2 });
|
|
223
|
+
const b = t.buildReport({ y: 2, x: 1 }, { y: 2, x: 1 });
|
|
224
|
+
assert.strictEqual(a.digests.original, b.digests.original,
|
|
225
|
+
"键顺序不同不应导致摘要不同");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
console.log("\n5. 与 Python 侧输出对齐");
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
test("path_dotted 兼容字段存在且格式正确", () => {
|
|
233
|
+
const d = t.deepDiff(
|
|
234
|
+
{ checks: { security: { status: "pass" } } },
|
|
235
|
+
{ checks: { security: { status: "fail" } } }
|
|
236
|
+
);
|
|
237
|
+
assert.strictEqual(d[0].path_dotted, "checks.security.status");
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("path_format 声明为 rfc6901", () => {
|
|
241
|
+
const d = t.deepDiff({ a: 1 }, { a: 2 });
|
|
242
|
+
assert.strictEqual(d[0].path_format, "rfc6901");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("指针与点号路径同时可查(既有消费方不被破坏)", () => {
|
|
246
|
+
const d = t.deepDiff({ a: { b: 1 } }, { a: { b: 2 } })[0];
|
|
247
|
+
assert.strictEqual(d.path, "/a/b");
|
|
248
|
+
assert.strictEqual(d.path_dotted, "a.b");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
console.log(`\n${"=".repeat(52)}`);
|
|
253
|
+
console.log(` ${passed}/${passed + failed} PASS`);
|
|
254
|
+
console.log(`${"=".repeat(52)}`);
|
|
255
|
+
if (failures.length) {
|
|
256
|
+
console.log("\n失败明细:");
|
|
257
|
+
for (const f of failures) console.log(` - ${f.name}\n ${f.msg}`);
|
|
258
|
+
}
|
|
259
|
+
process.exit(failed ? 1 : 0);
|