## 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 全绿
117 lines
4.5 KiB
JavaScript
117 lines
4.5 KiB
JavaScript
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 });
|
||
});
|