Files
nodeagent/tests/unit/audit-args-digest.test.mjs
T
jkooe 560b3c44e4 fix(agent): 限速桶 key 改能力名 + describeArgs 截断(A-P0-2 / A-P0-4)
架构审查两个 P0:

A-P0-2 限速桶 key 用错值(server.ts:364):
原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**
当桶名——ACL 支持按能力限速(精确 > glob > 全局),配
「screen.capture ≤5/min、app.install ≤5/min」两条同值规则时
两者 key 都是 `clientId|5` 共用一个桶:截图打满后装软件被误伤,
且两者总量仍按 5/min(漏过)。同时 rateBuckets 只增不删 +
psk 模式 client_id 自报 → 外部可枚举任意 client_id 造键 →
长期运行内存无界增长。
- 抽到 util/rate-limit.ts:key = `${认证身份 ?? 远端地址}:${能力名}`
  (未认证时退化按远端地址,不用空串把所有人混为一桶)
- 过期键清扫(滑动窗口边界与旧实现逐字节一致:now - t < 60_000
  才算窗口内)+ 容量上限淘汰最旧(Map 插入序 = 时间序)
- e2e limited 客户端改配两个同限速值能力(各 2/min),
  打满 system.info 后 system.audit.list 必须仍有完整配额

A-P0-4 describeArgs 大参数同步序列化阻塞事件循环(audit.ts:172):
原实现每次 invoke 全量 JSON.stringify + sha256。fs.write 的 data
可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,
期间 GUI 注入、事件推送、保活 ping 全部延迟。
- 超过 64KB 只哈希前 64KB(按**字节**切:多字节字符下按字符切
  会得到非确定性边界),总长记 args_bytes、截断记 args_truncated
- 口径从「全量哈希」变为「前 64KB 哈希」只影响审计摘要,
  不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)

测试(均故障注入实测会红):
- rate-limit 6 例:桶名是能力名 / 身份进 key / 不限速不建桶 /
  过期键清扫(59.999s 窗口内、恰好 60s 出窗)/ 容量上限 /
  同值多能力各占一桶
- audit-args-digest 6 例:小参数口径不回退 / 大参数截断口径 /
  按字节切(字符切口径必须不同)/ 尾部不进摘要 / 端到端入链
  校验通过 / logArgs 默认关不产预览
- e2e 注入验证:桶名退回 String(rateLimit) → v3 场景红;恢复 → 绿

单测 445/445、e2e 33/33、typecheck 全绿
2026-10-26 00:14:17 +08:00

131 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 参数摘要守卫(`apps/agent/src/audit.ts` describeArgs,A-P0-4,2026-10-25)。
*
* ⚠️ 背景:原实现每次 invoke 都全量 `JSON.stringify` + sha256。`fs.write` 的 data
* 可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,期间 GUI
* 注入、事件推送、保活 ping 全部延迟。现改为:超过 64KB 只哈希**前 64KB**
* (按字节切),总长记 `args_bytes`、截断记 `args_truncated`。
* 摘要口径从「全量」变为「前 64KB」,只影响审计摘要,不影响任何安全属性
* (摘要用途是比对与防泄密,不是完整性证明)。
*
* 本文件钉死五条(均可用故障注入打红):
* ① 小参数口径不回退:digest 与独立重算的 sha256(json).slice(0,16) 一致;
* ② 大参数截断口径:digest = sha256(前 64KB 字节).slice(0,16),args_bytes/args_truncated 正确;
* ③ 按**字节**切而非按字符切(多字节边界,字符切口径必须不同);
* ④ 尾部不进摘要:前 64KB 相同、尾部不同的两个大参数 digest 相同;
* ⑤ 端到端:audit() 写入的记录带两字段,链校验仍通过;logArgs 预览行为不变。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initAudit, audit, describeArgs, verifyAudit } from '../../apps/agent/dist/audit.js';
const MAX_BYTES = 64 * 1024;
/** 独立重算摘要(不 import 实现内的私有常量路径,口径在此显式重写一遍)。 */
function digestOf(payload) {
return `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
}
function bigArgs(totalBytes, tailFill = '') {
// 构造 {capability, data},使 JSON 序列化后的总字节数 ≥ totalBytes。
// data 主体为 'x'(1 字节/字符,字节数可精确预估),tailFill 可接一段不同内容的尾部,
// 用于「前 64KB 相同、尾部不同」的对照。
const skeleton = { capability: 'fs.write', data: '' };
const baseBytes = Buffer.byteLength(JSON.stringify(skeleton), 'utf8');
const need = Math.max(1, totalBytes - baseBytes - tailFill.length);
return { capability: 'fs.write', data: 'x'.repeat(need) + tailFill };
}
test('A-P0-4①:小参数口径不回退 —— digest 与旧实现逐字节一致,无截断字段', () => {
const args = { path: '/tmp/a.txt', data: 'hello' };
const r = describeArgs(args);
assert.equal(r.args_digest, digestOf(JSON.stringify(args)), '小参数必须仍走全量哈希');
assert.equal('args_bytes' in r, false, '小参数不得出现 args_bytes');
assert.equal('args_truncated' in r, false, '小参数不得出现 args_truncated');
assert.match(r.args_digest, /^sha256:[0-9a-f]{16}$/, 'digest 形状不变');
});
test('A-P0-4②:大参数截断口径 —— 只哈希前 64KB,总长与截断标记正确', () => {
const total = 8 * 1024 * 1024; // 8MB,模拟 fs.write 大 data
const args = bigArgs(total);
const json = JSON.stringify(args);
const bytes = Buffer.byteLength(json, 'utf8');
assert.ok(bytes > MAX_BYTES, '夹具必须真的超过 64KB');
const r = describeArgs(args);
assert.equal(r.args_bytes, bytes, 'args_bytes 必须是序列化总字节数');
assert.equal(r.args_truncated, true);
// 口径:sha256(前 64KB 字节) 的前 16 位十六进制
const expected = digestOf(Buffer.from(json, 'utf8').subarray(0, MAX_BYTES));
assert.equal(r.args_digest, expected, '大参数 digest 必须等于前 64KB 的哈希');
// 且必须与全量哈希不同(证明真的截断了)
assert.notEqual(r.args_digest, digestOf(json), '不得退化成全量哈希');
});
test('A-P0-4③:按字节切而非按字符切 —— 多字节边界下字符切口径必须不同', () => {
// 用中文填充:'中' = 3 字节,64KB(65536) 不是 3 的整数倍 → 边界必然落字符中间
const args = { capability: 'fs.write', data: '中'.repeat(40000) }; // ≈120KB
const json = JSON.stringify(args);
assert.ok(Buffer.byteLength(json, 'utf8') > MAX_BYTES);
const r = describeArgs(args);
const byBytes = digestOf(Buffer.from(json, 'utf8').subarray(0, MAX_BYTES));
assert.equal(r.args_digest, byBytes, '必须按字节切');
// 若有人改成按字符切(json.slice(0, 65536)),边界落在 65535 字节处 → 口径不同 → 本断言打红
const byChars = digestOf(json.slice(0, MAX_BYTES));
assert.notEqual(r.args_digest, byChars, '按字符切是错误口径,必须被此断言挡住');
});
test('A-P0-4④:尾部不进摘要 —— 前 64KB 相同、尾部不同 → digest 相同但 args_bytes 不同', () => {
const a = bigArgs(2 * 1024 * 1024, '');
const b = bigArgs(3 * 1024 * 1024, 'y'.repeat(1024 * 1024)); // 更长且尾部内容不同
const ra = describeArgs(a);
const rb = describeArgs(b);
assert.equal(ra.args_digest, rb.args_digest, '前 64KB 一致则摘要必须一致');
assert.notEqual(ra.args_bytes, rb.args_bytes, '总长仍如实记录');
assert.equal(ra.args_truncated, true);
assert.equal(rb.args_truncated, true);
});
test('A-P0-4⑤:端到端 —— audit() 写入带两字段且链校验通过;logArgs 预览行为不变', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-audit-args-'));
try {
initAudit({ enabled: true, logArgs: true }, dir);
const big = bigArgs(1024 * 1024);
audit({ type: 'invoke', capability: 'fs.write', status: 'ok', ...describeArgs(big) });
audit({ type: 'invoke', capability: 'fs.write', status: 'ok', ...describeArgs({ path: '/tmp/x', data: 'y' }) });
const lines = readFileSync(join(dir, 'audit.log'), 'utf8').trim().split('\n');
const rec1 = JSON.parse(lines[0]);
const rec2 = JSON.parse(lines[1]);
assert.equal(rec1.args_truncated, true, '大参数记录必须带截断标记');
assert.equal(rec1.args_bytes, Buffer.byteLength(JSON.stringify(big), 'utf8'));
assert.equal(rec1.args_truncated, true);
assert.equal('args_bytes' in rec2, false, '小参数记录不得带截断字段');
assert.equal(typeof rec1.args_preview, 'string', 'logArgs 开启时预览仍产出(大参数也一样)');
const v = verifyAudit();
assert.equal(v.ok, true, `新字段不得破坏审计链,实际: ${JSON.stringify(v.broken_at)}`);
assert.equal(v.checked, 2);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('A-P0-4⑤b:logArgs 默认关闭时不产出预览(行为不回退)', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-audit-args2-'));
try {
initAudit({ enabled: true }, dir); // logArgs 默认 false
const r = describeArgs({ path: '/tmp/x', data: 'hello' });
assert.equal('args_preview' in r, false, '默认不得记录预览');
assert.equal(r.args_digest, digestOf(JSON.stringify({ path: '/tmp/x', data: 'hello' })));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});