docs+fix(v25): 审计锚定的语义说明(锚定自身也写审计 → 紧随比对应显示「已增长」)+ SECURITY §4.8

真机验证时发现一个语义瑕疵:刚锚定就比对,永远显示链已增长 ——
原因是锚定调用自身也被 server 层记入审计(统一记录所有 invoke)。
功能正确(不误报篡改),但反直觉,故:
- compareWithAnchors 的 verdict 补上解释
- manifest 的 audit.anchor 描述补上说明
- SECURITY.md 新增 §4.8(含两个关键设计:四元组锚点防轮转误判、追加式防刷新抹痕),
  §5「还没做的」清单至此**全部完成**
This commit is contained in:
jkooe committed 2026-10-10 00:12:02 +08:00
1 parent bec07fc277
commit 68546e63e8
3 files changed
+44 -3

No files matched your search

+6 -1
View File
@@ -490,7 +490,12 @@ export function compareWithAnchors(customPath?: string): AnchorComparison {
ok: true,
anchors_checked: anchors.length,
latest,
verdict: '链已增长(或发生轮转)—— 锚点仍在链上,无需人工核对到该锚点为止的部分',
// ⚠️ 注意:锚定动作**自身**也会被记入审计(server 层统一记录所有 invoke),
// 所以「刚锚定就比对」几乎必然看到"已增长"——这是正常现象,不是篡改。
// 想看到"完全一致",需在同一时刻不再产生新审计(实际很难,属预期)。
verdict:
'链已增长(或发生轮转)—— 锚点仍在链上,无需人工核对到该锚点为止的部分。' +
'(刚锚定就比对通常显示"已增长",因为锚定调用自身也会写一条审计。)',
detail: { anchored_entries: latest.entries, current_entries: head.entries, rotated_delta: head.rotated_segments - latest.rotated_segments, anchored_hash: latest.head_hash, current_hash: head.head_hash },
};
}
+35 -1
View File
@@ -141,6 +141,40 @@ nodeagent keygen --id mac_01 # 输出一段 JSON(acl.clients 的一项
> 真机验证(2026-10-09):CASE A 有文件 → `ed25519` + `default_effect=deny` + clients 完整;
> CASE B 无文件 → `psk` 且**不写 acl 字段**;CASE C 缺 pubkey → 告警并回退 psk。
## 4.8 审计链外部锚定(v25)
**问题**:链内哈希只能发现"改一条"(其后全部需重算)。「有 root 的攻击者」可以
**整链重写** —— 从零重建一份自洽的假日志,`system.audit.verify` 照样通过。
**做法**:把链头记到**链外**,攻击者改不了已经落到别处的历史记录。
```bash
nodeagent --node win audit head --compare # 看链头 + 与最近锚点比对
nodeagent --node win audit anchor --note "每日例行" # 追加一个锚点
```
| 能力 | 风险 | 说明 |
|---|---|---|
| `system.audit.head` | low | 返回 `{entries, head_hash, head_ts, rotated_segments, file_bytes}`;`compare=true` 附比对结论 |
| `system.audit.anchor` | medium | 把链头**追加**写入锚点文件(默认 `<数据目录>/audit-anchors.jsonl`;指定 path 时受 `fs_roots` 约束) |
**两个关键设计**:
1. **锚点不能只记 `head_hash`** —— 日志轮转会丢弃最旧段使条目数下降,只记哈希会把
**轮转误判成篡改**。故记四元组 `{entries, head_hash, head_ts, rotated_segments}`,
比对时先按轮转对齐再判尾部。三条判定:
- 条目数减少且无轮转 → **疑似整链重写**(`ok:false`)
- 条目数与段数都没变但哈希不同 → **该段被重写**(`ok:false`)
- 条目数增长 / 发生轮转 → 锚点仍在链上(`ok:true`,附 detail 供人工核对)
2. **追加式而非覆盖式** —— 若锚点可覆盖,攻击者重写链后再"刷新"锚点即可抹掉痕迹。
**已知行为**:锚定调用自身也会写一条审计(server 层统一记录 invoke),所以
"刚锚定就比对"通常显示"链已增长" —— 属预期,不是篡改。
**如何真正用起来**:定期(建议每日)调用 `system.audit.anchor`,并用
`system.audit.head --compare` 巡检;若把锚点文件指向**另一台机器/网盘同步目录**,
外部锚定的价值才真正落地(本机文件被整盘重写时锚点也随之消失)。
## 5. 还没做的(后续批次)
| 项 | 说明 |
@@ -150,7 +184,7 @@ nodeagent keygen --id mac_01 # 输出一段 JSON(acl.clients 的一项
| ~~握手失败封禁~~ | ✅ **v23 已做**:连续失败达阈值 → 指数退避封禁(封顶 24h),成功即清零,支持白名单豁免 |
| ~~会话上限~~ | ✅ **v23 已做**:并发上限 8 + 30 分钟空闲断开(有任务/订阅在跑时保守不踢) |
| ~~安装期密钥传递~~ | ✅ **v23 已做**:`install.cmd` 改用管道喂 stdin(`-KeyFromStdin`),密钥不再出现在命令行 |
| 审计链外部锚定 | 哈希链可被「有 root 的攻击者」整链重写;需能把链头同步到独立位置以便比对 |
| ~~审计链外部锚定~~ | ✅ **v25 已做**(见 §4.8):链头可读 + 锚点追加落盘 + 锚点比对 |
## 6. 应急处置
@@ -567,7 +567,9 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
'把当前审计链头**追加**写入锚点文件(v25)。追加而非覆盖:历史锚点一旦写成就不可被后续' +
'刷新掉,否则攻击者重写链后再锚定一次即可抹除痕迹。' +
'默认写 <数据目录>/audit-anchors.jsonl;指定 path 时仍受 fs_roots 白名单约束。' +
'建议定期(如每天)调用,或用 automation 定时执行。',
'建议定期(如每天)调用,或用 automation 定时执行。' +
'注意:锚定调用**自身也会写一条审计**(server 层统一记录 invoke),' +
'所以紧接着再比对通常会显示"链已增长"——这是正常现象,不是篡改。',
risk: 'medium',
params_schema: {
type: 'object',