Files
nodeagent/tests/unit/audit-chain.test.mjs
T
jkooe eb54c60c7c feat(B): 安全加固 —— 密钥库保护 + 审计防篡改 + ACL 细化(能力 28 -> 29)
## B1 私钥不再明文落盘
- 新增 packages/client/src/secret.ts:macOS Keychain / Windows DPAPI(CurrentUser)
- keys.ts:落盘文件只留 secret 描述符(backend/label/protected),不含 privateKey;
  老格式明文密钥**首次加载自动迁移**;后端不可用时回退明文 + 告警(绝不因保护而不可用)
- 真机验证(隔离目录):
  * keygen -> '✓ 私钥已存入系统密钥库(keychain)',文件中无 privateKey,钥匙串有条目
  * loadKeys 回读私钥成功(64 字符)
  * 老格式明文文件 -> 自动迁移、文件清理、二次回读正常
  * Windows DPAPI 往返一致(ROUNDTRIP_OK=True,密文不含明文)

## B2 审计链式哈希(防篡改)
- 每条审计记录加 prev/hash(prev+规范化正文 sha256 前 32 位);轮转跨文件连续
- agent 重启从日志尾部恢复锚点,链不断
- 新能力 system.audit.verify + CLI 'nodeagent audit verify'
- 真机:'✓ 审计链完整(校验 21 条,跳过历史条目 857 条)'
- 单测覆盖:篡改检出、删除中间条目检出(链断裂)、历史条目 legacy 跳过、重启续链

## B3 ACL 细化
- 协议层新增纯函数:normalizeIp / ipMatchesCidr / ipAllowed / inTimeWindow / rateLimitFor
- AclClient 扩展:allow_cidr / deny_cidr / allow_window(days+from/to,支持跨零点) / rate_limits
- server.ts:IP 白黑名单与生效时段校验(命中即 audit acl.denied);限速改为按能力
  (rate_limits 精确优先、其次 glob、回退 max_calls_per_min),窗口按限速档位分桶

### 真机踩坑(已修复并回归)
Node 的 socket.remoteAddress 在双栈监听下是 **IPv4-mapped IPv6**(::ffff:192.168.1.100),
原用 split(':')[0] 取到空串 -> CIDR 永不匹配。新增 normalizeIp() 归一,单测覆盖该场景。
真机复测:'✗ E_ACL_DENIED: 来源 IP 不被允许';移除黑名单后 fs.list 连调 3 次 ->
第 3 次 '✗ E_RATE_LIMITED: 调用频率超限(fs.list 上限 2/分钟)'

单测 37 -> 59、e2e 21/21 全绿
2026-09-29 11:27:52 +08:00

117 lines
4.5 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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { initAudit, audit, verifyAudit } from '../../apps/agent/dist/audit.js';
function freshDir() {
return mkdtempSync(join(tmpdir(), 'na-audit-'));
}
test('审计链:正常写入后可校验通过,且条目带 prev/hash', () => {
const dir = freshDir();
initAudit({ enabled: true }, dir);
for (let i = 0; i < 5; i += 1) {
audit({ type: 'invoke', capability: 'system.info', status: 'ok', duration_ms: i });
}
const first = JSON.parse(readFileSync(join(dir, 'audit.log'), 'utf8').split('\n')[0]);
assert.equal(first.prev, 'genesis', '首条 prev 应为 genesis');
assert.equal(typeof first.hash, 'string');
assert.equal(first.hash.length, 32);
const r = verifyAudit();
assert.equal(r.ok, true, `应校验通过,实际: ${JSON.stringify(r.broken_at)}`);
assert.equal(r.checked, 5);
assert.equal(r.legacy, 0);
rmSync(dir, { recursive: true, force: true });
});
test('审计链:链式相连(第二条 prev = 第一条 hash)', () => {
const dir = freshDir();
initAudit({ enabled: true }, dir);
audit({ type: 'agent.start' });
audit({ type: 'auth.success', client_id: 'mac_01' });
const lines = readFileSync(join(dir, 'audit.log'), 'utf8').trim().split('\n');
const a = JSON.parse(lines[0]);
const b = JSON.parse(lines[1]);
assert.equal(b.prev, a.hash, '第二条 prev 应指向第一条 hash');
rmSync(dir, { recursive: true, force: true });
});
test('审计链:内容被篡改会被检出', () => {
const dir = freshDir();
initAudit({ enabled: true }, dir);
audit({ type: 'invoke', capability: 'fs.write', status: 'ok' });
audit({ type: 'invoke', capability: 'system.info', status: 'ok' });
audit({ type: 'auth.success', client_id: 'mac_01' });
// 篡改第二条的 capability(保持 JSON 合法,仅改内容)
const p = join(dir, 'audit.log');
const lines = readFileSync(p, 'utf8').trim().split('\n');
const rec = JSON.parse(lines[1]);
rec.capability = 'fs.read';
lines[1] = JSON.stringify(rec);
writeFileSync(p, lines.join('\n') + '\n');
const r = verifyAudit();
assert.equal(r.ok, false, '篡改后应校验失败');
assert.equal(r.broken_at.line, 2);
assert.match(r.broken_at.reason, /哈希/);
rmSync(dir, { recursive: true, force: true });
});
test('审计链:删除中间条目会被检出(链断裂)', () => {
const dir = freshDir();
initAudit({ enabled: true }, dir);
audit({ type: 'agent.start' });
audit({ type: 'invoke', capability: 'system.info', status: 'ok' });
audit({ type: 'auth.success', client_id: 'mac_01' });
const p = join(dir, 'audit.log');
const lines = readFileSync(p, 'utf8').trim().split('\n');
// 删掉中间一条
writeFileSync(p, [lines[0], lines[2]].join('\n') + '\n');
const r = verifyAudit();
assert.equal(r.ok, false, '删除中间条目后应校验失败');
assert.match(r.broken_at.reason, /链断裂/);
rmSync(dir, { recursive: true, force: true });
});
test('审计链:历史无链字段条目记为 legacy 且不误报', () => {
const dir = freshDir();
// 手写两条「老版本」条目(无 prev/hash)
const legacy = [
{ ts: Date.now() - 2000, type: 'agent.start' },
{ ts: Date.now() - 1000, type: 'invoke', capability: 'system.info', status: 'ok' },
];
writeFileSync(join(dir, 'audit.log'), legacy.map((e) => JSON.stringify(e)).join('\n') + '\n');
initAudit({ enabled: true }, dir);
audit({ type: 'auth.success', client_id: 'mac_01' }); // 新条目带链字段
const r = verifyAudit();
assert.equal(r.ok, true, '老条目应跳过校验而非报错');
assert.equal(r.legacy, 2);
assert.equal(r.checked, 1);
rmSync(dir, { recursive: true, force: true });
});
test('审计链:agent 重启后链仍连续(从尾部恢复锚点)', () => {
const dir = freshDir();
initAudit({ enabled: true }, dir);
audit({ type: 'agent.start' });
const firstHash = JSON.parse(readFileSync(join(dir, 'audit.log'), 'utf8').trim().split('\n')[0]).hash;
// 模拟重启:重新 initAudit(会从文件尾部恢复 lastHash)
initAudit({ enabled: true }, dir);
audit({ type: 'agent.stop' });
const lines = readFileSync(join(dir, 'audit.log'), 'utf8').trim().split('\n');
const second = JSON.parse(lines[1]);
assert.equal(second.prev, firstHash, '重启后新条目应接续原链');
assert.equal(verifyAudit().ok, true);
rmSync(dir, { recursive: true, force: true });
});