架构审查两个 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 全绿
131 lines
7.0 KiB
JavaScript
131 lines
7.0 KiB
JavaScript
/**
|
||
* 参数摘要守卫(`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 });
|
||
}
|
||
});
|