Files
nodeagent/apps/agent/src/audit.ts
T
jkooe 5a2c5f9c55 feat: 三端暴露 L2 系统级审计(MCP na_audit_l2 + 控制台 L2 分区 + agent.l2 类型)
决策「L2 同时暴露 MCP+控制台」。此前 system.audit.l2 只存在于
被控端实现里:MCP 无工具(模型看不到)、控制台无入口无类型
(用户点不到)、审计事件写成 agent.start(与进程生命周期混类筛不出)。

MCP(41 → 42 个工具):
- defs.ts 在 na_audit 后新增 na_audit_l2(action: status/enable/
  collect/verify + since/limit,additionalProperties: false)
- index.ts TOOL_TO_CAPABILITY 映射 CapabilityNames.AuditL2,
  分发走 default 直通(参数名与被控端一致,无需改写)
- mcp-tools 守卫同步:41→42、BASELINE_SEQ_SHA 重算、
  新增「na_audit_l2 必须紧随 na_audit」顺序断言

控制台:
- conn.ts WINDOWS_ONLY_CAPS += system.audit.l2(非 Windows 被控端
  上按钮禁用并明示原因,不搞「点了才报错」)
- types/index.ts 新增 AuditL2Result(四 action 属性并集,对齐
  manifest returns_schema)+ AuditType += agent.l2;
  AuditEntry 同步补 args_bytes/args_truncated(A-P0-4 新字段)
- AuditView.vue 新增 L2 卡片:查状态/拉取采集/校验 L2 链/启用策略
  (NPopconfirm 二次确认 + 管理员权限提示);信任边界如实说明
  (采集器与 agent 同权限,防篡改依赖转发出机,不夸大本地副本)

agent.l2 审计类型四端同源:
- protocol audit-types.ts AUDIT_TYPES(真源)+= agent.l2
- agent audit.ts AuditType 联合同步(AssertExact 编译期守卫兜底)
- audit-l2.ts 两处写入点 agent.start → agent.l2(L2 策略操作与
  进程生命周期无关,混类会污染两边统计与筛选)

守卫加强(console-contract 平台门控):
- audit-l2.ts 平台门控由裸 throw 改用统一 assertWindows
  (错误信息格式统一 + 可被守卫扫到)
- 守卫扫描范围 +audit-l2.ts、模式 +assertWindows —— 覆盖面扩大;
  否则把 system.audit.l2 加进 WINDOWS_ONLY_CAPS 反而报红
- mcp-tools 正则盲区修复:原 na_[a-z_]+ 匹配不了带数字的工具名,
  na_audit_l2 会整个漏扫(新增了工具两个守卫却无觉)→ 放至 [a-z0-9_]+

新增 tests/unit/console-l2.test.mjs 5 例:前端字段 ⊆ returns_schema /
契约关键字段 ⊇ 前端渲染 / MCP 工具存在且映射 / agent.l2 四端同源 /
界面纪律(信任边界文案 + enable 二次确认 + 平台门控)
故障注入实测:审计类型退回 agent.start、WINDOWS_ONLY_CAPS 删项 → 3 处红;恢复 → 绿

单测 451/451、e2e 33/33、typecheck 全绿
2026-10-26 00:28:44 +08:00

526 lines
19 KiB
TypeScript
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 { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { agentDir } from './config.js';
import {
judgeAnchors,
parseAnchorsFromJsonl,
AUDIT_TYPES,
type AnchorComparison,
type AnchorRecord,
} from '@nodeagent/protocol';
export type AuditType =
| 'agent.start'
| 'agent.stop'
| 'auth.success'
| 'auth.failure'
| 'invoke'
| 'acl.denied'
| 'capability.disabled'
| 'rate.limited'
/** v16:网络配置变更(两阶段提交) */
| 'net.change'
/** v21:来源不在 allow_from 被拒 */
| 'net.deny'
/** v19:agent 自更新(拉取式) */
| 'agent.update'
/**
* v2.1.0:**L2 系统级审计事件**(Windows Security 日志归一化副本)。
* 真源注释见 `packages/protocol/src/audit-types.ts`——不复用 `agent.start`:
* L2 策略操作与进程生命周期无关,混入会污染两边的统计与筛选。
*/
| 'agent.l2'
/** v2.0.0:连接空闲被主动断开 */
| 'session.idle_close'
/**
* v2.0.3:**被拒的请求**统一留痕。
*
* 为什么单独一个类型:原来「畸形报文 / 未知方法 / 未认证调用 / 参数非法 / 能力不存在」
* 这类拒绝**完全不留痕**(U-01)—— 被扫描、被爆破、被误用时事后无从复盘。
* 归到 `invoke` 会污染正常调用统计,归到 `acl.denied` 又不准确(很多拒绝与 ACL 无关)。
*/
| 'request.rejected'
/**
* v2.0.3:**进程级未捕获错误 / 未处理 Promise 拒绝**。
*
* 为什么单独一个类型:这类错误是「最后一次兜底拦截」(`index.ts` 的
* `unhandledRejection` / `uncaughtException`)。既然选择了**不退出进程**,
* 就必须留下证据 —— 归到 `request.rejected` 会让「被扫描」与「我们自己有 bug」
* 混在同一类里,事后无法区分。
*/
| 'agent.error';
/**
* 编译期守卫(v2.0.3,修 C-01):本文件这份联合类型必须与 `@nodeagent/protocol` 的
* `AUDIT_TYPES` **双向一致**。
*
* 少了 → 「写进日志但协议不认」(控制台筛不到,用户以为没发生);
* 多了 → 「协议认但从不写」(下拉框里是个永远为空的选项)。
* 两种漂移都**不报错**,所以必须让 `tsc` 来说话。真正的真源在
* `packages/protocol/src/audit-types.ts`,此处保留逐条注释只为记录「哪个版本引入的」。
*/
type AssertExact<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
const _auditTypeInSyncWithProtocol: AssertExact<AuditType, (typeof AUDIT_TYPES)[number]> = true;
void _auditTypeInSyncWithProtocol;
/** 一条审计记录(JSONL 单行)。 */
export interface AuditEntry {
/** Unix 毫秒时间戳 */
ts: number;
type: AuditType;
client_id?: string;
/** 来源地址 */
remote?: string;
capability?: string;
status?: 'ok' | 'failed' | 'denied';
duration_ms?: number;
error?: string | null;
/** 参数摘要(sha256 前缀),用于比对而不泄露内容 */
args_digest?: string;
/**
* v2.1.0(A-P0-4):参数序列化后的**总字节数**。
* 与 `args_digest_truncated` 同时出现即表示「摘要只覆盖前 64KB」。
*/
args_bytes?: number;
/** v2.1.0(A-P0-4):摘要是否为截断哈希(序列化超过 64KB 时只哈希前 64KB)。 */
args_truncated?: boolean;
/** 参数预览(默认不记录;开启后自动脱敏) */
args_preview?: string;
reason?: string;
/** v11 防篡改:上一条记录哈希(首条为 "genesis") */
prev?: string;
/** v11 防篡改:本条记录哈希(prev + 规范化正文 的 sha256 前 32 位) */
hash?: string;
}
export interface AuditOptions {
enabled: boolean;
maxBytes: number;
maxFiles: number;
/** 是否记录参数预览(默认为 false,只记摘要) */
logArgs: boolean;
}
const DEFAULTS: AuditOptions = {
enabled: true,
maxBytes: 10 * 1024 * 1024,
maxFiles: 5,
logArgs: false,
};
let opts: AuditOptions = { ...DEFAULTS };
let currentPath = '';
/** v11:链式哈希锚点(进程内维护;启动时从日志尾部恢复) */
const GENESIS = 'genesis';
let lastHash = GENESIS;
/** 规范化后计算条目哈希:键排序保证跨进程/跨版本稳定。 */
function hashEntry(record: Record<string, unknown>, prev: string): string {
const keys = Object.keys(record).sort();
const body = JSON.stringify(record, keys);
return createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
}
/** 从日志尾部恢复链锚点,保证 agent 重启后链不断。 */
function recoverChainAnchor(): void {
try {
const p = auditFilePath();
if (!existsSync(p)) {
lastHash = GENESIS;
return;
}
const lines = readFileSync(p, 'utf8').split('\n');
for (let i = lines.length - 1; i >= 0; i -= 1) {
const line = lines[i]?.trim();
if (!line) continue;
const rec = JSON.parse(line) as AuditEntry;
lastHash = rec.hash ?? GENESIS;
return;
}
lastHash = GENESIS;
} catch {
lastHash = GENESIS;
}
}
/** 初始化审计(返回审计文件路径)。显式 undefined 不会覆盖默认值。 */
export function initAudit(options: Partial<AuditOptions> = {}, dir?: string): string {
opts = {
enabled: options.enabled ?? DEFAULTS.enabled,
maxBytes: options.maxBytes ?? DEFAULTS.maxBytes,
maxFiles: options.maxFiles ?? DEFAULTS.maxFiles,
logArgs: options.logArgs ?? DEFAULTS.logArgs,
};
const base = dir ?? agentDir();
mkdirSync(base, { recursive: true });
currentPath = join(base, 'audit.log');
recoverChainAnchor();
return currentPath;
}
export function auditFilePath(): string {
return currentPath || join(agentDir(), 'audit.log');
}
const SENSITIVE_KEY = /(pass|pwd|secret|token|key|credential|auth)/i;
/** 递归脱敏 + 截断,避免审计本身成为泄露渠道。 */
function sanitize(value: unknown, depth = 0): unknown {
if (depth > 3) return '[deep]';
if (Array.isArray(value)) return value.slice(0, 20).map((v) => sanitize(v, depth + 1));
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = SENSITIVE_KEY.test(k) ? '***' : sanitize(v, depth + 1);
}
return out;
}
if (typeof value === 'string' && value.length > 200) return `${value.slice(0, 200)}…`;
return value;
}
/**
* 参数摘要阈值(字节):超过即只哈希前 64KB。
*
* ⚠️ A-P0-4(2026-10-25):原实现每次 invoke 都全量 `JSON.stringify` + sha256。
* `fs.write` 的 data 可达数 MB(协议单帧,ws 默认 maxPayload 隐式放行)——
* 同步序列化 + 哈希一个 8MB 字符串 ≈ 数十毫秒级 CPU,且**阻塞事件循环**:
* 期间 GUI 注入(`input.*`)、事件推送、保活 ping 全部延迟。
* 截断后:摘要口径从「全量哈希」变为「前 64KB 哈希」,**只影响审计摘要,
* 不影响任何安全属性**(摘要用途是比对与去泄密,从来不是完整性证明)。
* 总长记入 `args_bytes`,截断记入 `args_truncated`,语义不断代。
*/
const ARGS_DIGEST_MAX_BYTES = 64 * 1024;
/** 生成参数摘要与(可选)脱敏预览 —— 字段名与 AuditEntry 对齐,便于展开。 */
export function describeArgs(args: unknown): {
args_digest: string;
args_bytes?: number;
args_truncated?: boolean;
args_preview?: string;
} {
const json = JSON.stringify(args ?? {});
const bytes = Buffer.byteLength(json, 'utf8');
// 截断按**字节**切(多字节字符下按字符切会得到非确定性的 64KB 边界)
const payload = bytes > ARGS_DIGEST_MAX_BYTES ? Buffer.from(json, 'utf8').subarray(0, ARGS_DIGEST_MAX_BYTES) : json;
const digest = `sha256:${createHash('sha256').update(payload).digest('hex').slice(0, 16)}`;
const base = bytes > ARGS_DIGEST_MAX_BYTES ? { args_bytes: bytes, args_truncated: true } : {};
if (!opts.logArgs) return { args_digest: digest, ...base };
return { args_digest: digest, ...base, args_preview: JSON.stringify(sanitize(args)) };
}
/** 写入一条审计记录。任何异常都被吞掉 —— 审计绝不阻断主流程。 */
export function audit(entry: Omit<AuditEntry, 'ts' | 'prev' | 'hash'>): void {
if (!opts.enabled) return;
try {
const full: AuditEntry = { ts: Date.now(), ...entry };
// v11 链式哈希:prev 指向上一条的 hash,形成不可静默篡改的链
const record: AuditEntry = { ...full, prev: lastHash };
record.hash = hashEntry(record as unknown as Record<string, unknown>, lastHash);
rotateIfNeeded();
appendFileSync(auditFilePath(), `${JSON.stringify(record)}\n`);
lastHash = record.hash;
} catch {
/* 审计失败不影响业务 */
}
}
export interface AuditVerifyResult {
ok: boolean;
/** 参与校验的条目总数 */
checked: number;
/** 无链字段的历史条目数(老版本写入,跳过校验) */
legacy: number;
/** 首个异常位置(文件 + 行号 + 原因) */
broken_at?: { file: string; line: number; reason: string };
}
/**
* 校验审计链完整性(v11)。
* 按时间顺序遍历「轮转文件(旧→新)+ 当前文件」,逐条重算哈希并比对 prev 链接。
* 老版本(无 hash 字段)条目记为 legacy 跳过,保证升级后不误报。
*/
export function verifyAudit(): AuditVerifyResult {
const p = auditFilePath();
const files: string[] = [];
for (let i = 20; i >= 1; i -= 1) {
const f = `${p}.${i}`;
if (existsSync(f)) files.push(f);
}
files.push(p);
let checked = 0;
let legacy = 0;
// null = 尚未锚定。轮转会丢弃最旧文件,故首个带链条目只作锚点、不校验 prev;
// 之后逐条比对 prev,链中间被篡改/删除必被发现。
let expected: string | null = null;
for (const f of files) {
if (!existsSync(f)) continue;
const lines = readFileSync(f, 'utf8').split('\n');
for (let i = 0; i < lines.length; i += 1) {
const raw = lines[i]?.trim();
if (!raw) continue;
let rec: AuditEntry;
try {
rec = JSON.parse(raw) as AuditEntry;
} catch {
return { ok: false, checked, legacy, broken_at: { file: f, line: i + 1, reason: 'JSON 解析失败(条目被破坏)' } };
}
if (!rec.hash) {
legacy += 1;
expected = null; // 链接锚点未知,等待下一条带链字段的记录重新锚定
continue;
}
const { hash, ...body } = rec;
const recalculated = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
if (recalculated !== hash) {
return {
ok: false,
checked,
legacy,
broken_at: { file: f, line: i + 1, reason: '内容与哈希不符(条目被篡改)' },
};
}
if (expected !== null && rec.prev !== expected) {
return {
ok: false,
checked,
legacy,
broken_at: { file: f, line: i + 1, reason: '链断裂(prev 与上一条哈希不匹配,可能被删除或替换)' },
};
}
expected = hash;
checked += 1;
}
}
return { ok: true, checked, legacy };
}
/** 大小超限时轮转:audit.log → .1 → .2 …(超出 maxFiles 的最旧文件被丢弃)。 */
function rotateIfNeeded(): void {
const p = auditFilePath();
if (!existsSync(p)) return;
try {
if (statSync(p).size < opts.maxBytes) return;
for (let i = opts.maxFiles - 1; i >= 1; i -= 1) {
const from = `${p}.${i}`;
if (existsSync(from)) {
try {
renameSync(from, `${p}.${i + 1}`);
} catch {
/* 忽略单个文件轮转失败 */
}
}
}
renameSync(p, `${p}.1`);
} catch {
/* 忽略 */
}
}
export interface AuditQuery {
limit?: number;
since?: number;
clientId?: string;
type?: string;
}
function readEntriesFromFile(file: string): AuditEntry[] {
if (!existsSync(file)) return [];
const out: AuditEntry[] = [];
try {
const lines = readFileSync(file, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
out.push(JSON.parse(line) as AuditEntry);
} catch {
/* 跳过损坏行 */
}
}
} catch {
/* 单文件读取失败不影响整体 */
}
return out;
}
/**
* 读取审计记录(取最新 limit 条,按时间升序返回)。
* 会同时读取轮转文件(audit.log.1/.2/…),避免历史记录被遗漏。
*/
/**
* 读取全部审计条目(跨轮转文件,按时间升序)。
* 供指标聚合等「需要全窗口样本」的场景使用 —— readAudit 有 limit 上限(最多 1000 条)。
* 安全上限 20 万条,防止极端情况下把内存吃爆。
*/
export function readAuditAll(since?: number): AuditEntry[] {
const p = auditFilePath();
const files: string[] = [];
for (let i = 20; i >= 1; i -= 1) {
const f = `${p}.${i}`;
if (existsSync(f)) files.push(f);
}
files.push(p);
const out: AuditEntry[] = [];
for (const f of files) {
for (const e of readEntriesFromFile(f)) {
if (since !== undefined && e.ts <= since) continue;
out.push(e);
if (out.length >= 200_000) break;
}
if (out.length >= 200_000) break;
}
out.sort((a, b) => a.ts - b.ts);
return out;
}
export function readAudit(query: AuditQuery = {}): { entries: AuditEntry[]; total: number; file: string } {
const p = auditFilePath();
if (!existsSync(p)) return { entries: [], total: 0, file: p };
const limit = Math.max(1, Math.min(query.limit ?? 50, 1000));
// 收集候选文件:当前文件 + 轮转文件(从新到旧)
const files: string[] = [p];
for (let i = 1; i <= 20 && existsSync(`${p}.${i}`); i += 1) files.push(`${p}.${i}`);
const collected: AuditEntry[] = [];
let total = 0;
for (const f of files) {
const list = readEntriesFromFile(f);
total += list.length;
for (const e of list) {
if (query.since !== undefined && e.ts <= query.since) continue;
if (query.clientId && e.client_id !== query.clientId) continue;
if (query.type && !e.type.startsWith(query.type)) continue;
collected.push(e);
}
// 已凑够需要的条数,可提前停止(避免无谓地读完所有轮转文件)
if (collected.length >= limit) break;
}
collected.sort((a, b) => a.ts - b.ts);
return { entries: collected.slice(-limit), total, file: p };
}
// ---------------- v2.0.0 审计链外部锚定 ----------------
//
// ## 为什么需要
// 审计链的哈希使**链内**篡改可被发现(改一条就要重算其后全部)。但「有 root 的攻击者」
// 可以**整链重写**:从零重建一份自洽的假日志,链校验照样通过。
// 外部锚定就是打破这一点:定期把**链头哈希 + 条目数**记到链外的独立位置
// (另一台机器 / 网盘 / U 盘)。事后比对时,若「当前链在某锚点之后的部分对不上」,
// 说明这段被重写过 —— 攻击者无法回到过去改掉已经落到别处的锚点。
//
// ## 记什么(关键:不能只记哈希)
// 轮转会丢弃最旧的段,所以条目数会**下降**。只记 head_hash 会导致轮转后被误判为篡改。
// 故锚点记 {entries, head_hash, head_ts, rotated_segments} 四元组,
// 比对时先按"轮转了几段"对齐,再判断尾部是否一致。
export interface AuditHead {
/** 当前链可见条目数(含轮转段) */
entries: number;
/** 链头哈希(末条的 hash)—— 这是要外部留存的核心值 */
head_hash: string | null;
/** 末条时间戳 */
head_ts: number | null;
/** 存在的轮转段数(.log.1 … .log.20) */
rotated_segments: number;
file: string;
file_bytes: number;
computed_at: number;
}
/** 计算当前审计链的头部信息(只读)。 */
export function computeAuditHead(): AuditHead {
const p = auditFilePath();
let rotated = 0;
for (let i = 1; i <= 20; i += 1) if (existsSync(`${p}.${i}`)) rotated += 1;
const entries = readAuditAll();
const last = entries.length > 0 ? entries[entries.length - 1]! : null;
let fileBytes = 0;
try {
fileBytes = statSync(p).size;
} catch {
/* 文件可能尚不存在(还没写过审计) */
}
return {
entries: entries.length,
head_hash: last?.hash ?? null,
head_ts: last?.ts ?? null,
rotated_segments: rotated,
file: p,
file_bytes: fileBytes,
computed_at: Date.now(),
};
}
// 锚点记录与比对结果的定义**移到了 protocol**(唯一实现,控制端脚本也用它)——
// 见 packages/protocol/src/audit-anchor.ts 顶部的说明。此处 re-export 以保持既有导入路径可用。
export type { AnchorRecord, AnchorComparison } from '@nodeagent/protocol';
/** 锚点文件路径(默认与审计同目录)。 */
export function anchorFilePath(custom?: string): string {
return custom && custom.trim().length > 0 ? custom.trim() : join(agentDir(), 'audit-anchors.jsonl');
}
/** 把当前链头**追加**到锚点文件(追加式:历史锚点不可被后续覆盖)。 */
export function anchorAudit(opts: { path?: string; note?: string } = {}): {
file: string;
record: AnchorRecord;
total_lines: number;
} {
const head = computeAuditHead();
const file = anchorFilePath(opts.path);
const rec: AnchorRecord = {
ts: Date.now(),
entries: head.entries,
head_hash: head.head_hash,
head_ts: head.head_ts,
rotated_segments: head.rotated_segments,
...(opts.note ? { note: opts.note } : {}),
};
appendFileSync(file, `${JSON.stringify(rec)}\n`, 'utf8');
let total = 0;
try {
total = readFileSync(file, 'utf8').split('\n').filter((l) => l.trim().length > 0).length;
} catch {
total = 1;
}
return { file, record: rec, total_lines: total };
}
/**
* 与最近一条锚点比对,判断当前链是否与锚定时刻自洽。
*
* 判定逻辑(三条):
* - 条目数应 **≥** 锚点记录(只增不减;减少只能由轮转解释,轮转会让 rotated_segments 变大)
* - 若条目数与轮转段数都没变 → 链头哈希必须**完全一致**
* - 若已轮转(段数变大)→ 只校验"当前 entries ≥ 锚点 entries",并提示需人工核对
*/
export function compareWithAnchors(customPath?: string): AnchorComparison {
const file = anchorFilePath(customPath);
if (!existsSync(file)) {
return { ok: true, anchors_checked: 0, verdict: '尚无锚点:请先调用 system.audit.anchor 建立外部锚点' };
}
let anchors: AnchorRecord[] | null = null;
try {
anchors = parseAnchorsFromJsonl(readFileSync(file, 'utf8'));
} catch {
return { ok: false, anchors_checked: 0, verdict: '锚点文件损坏,无法比对' };
}
if (!anchors) {
return { ok: true, anchors_checked: 0, verdict: '锚点文件为空' };
}
// 判定逻辑在 protocol(judgeAnchors)—— 与「控制端对云盘锚点」的比对共用同一份实现,
// 避免两处规则漂移(那会导致同一份日志两端结论不同)。
return judgeAnchors(computeAuditHead(), anchors);
}