feat(audit): L2 系统级审计兜底 —— 三端设计 + Windows 落地

## 它补的是 L1 结构上记不到的那块
L1 是 agent 自己记的,只看得见经过自己 RPC 的调用。攻击者绕过 agent
直接跑系统命令、或干脆把 agent 停掉 —— L1 全部盲区。L2 采集操作系统
自己的审计事件,补的正是这块。设计见 docs/AUDIT-L2.md。

## 必须先说清的信任边界(不夸大作用)
采集器与 agent 同进程同权限 —— 攻击者能停 agent 就能停采集器。
故本地 audit-l2.log 只是一份**归一化副本**,防篡改价值有限。
真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 锚定同一思路)。
本实现不自动转发,只提供导出接口;这一点已写入文档与能力描述。

## 三端
- Windows:**落地**。选内置 auditpol 而非 Sysmon —— Sysmon 是第三方驱动,
  需单独分发、需重启生效、版本行为有差异;内置策略零依赖即可立起来。
  检测到装了 Sysmon 则并用,未装不尝试下载。
  - status:查策略是否启用,并提示两个关键开关
    * SCENoApplyLegacyAuditPolicy —— 不设它,组策略旧式类别策略会盖掉
      subcategory,表现为「auditpol 显示已启用但事件日志里没有 4688」
    * ProcessCreationIncludeCmdLine_Enabled —— 不开则 4688 只有进程名
  - enable:开启进程创建 4688 / 登录 4624,4625 / 进程终止 4689(需管理员)
  - collect:Get-WinEvent 拉取 → 归一化 → 独立落盘(带哈希链)
  - verify:L2 链完整性校验
- Linux:仅设计(auditd,需预装),未落地
- macOS:仅设计,ESF 需 Apple entitlement 且必须是签名 System Extension,
  个人自签无法启用 —— 不写假装能用的代码

## 落盘与 schema
独立文件 audit-l2.log,不与 L1 混 —— 混了就无法区分「agent 记的」与
「OS 记的」。记录含 ts(OS 事件时间)、src、event_id(保留原始便于核对)、
kind(归一化类别)、collected_at(采集时刻,与 ts 差过大说明曾离线)。

## 实测
- 新增 8 条单测,含两条故障注入:篡改内容检出「哈希不符」、
  删中间条目检出「链断裂」
- 既有契约守卫两次报红并已被修复:manifest 新增 lkg 字段 → SystemInfo 接口缺声明;
  新增 system.audit.l2 → 能力清单断言过期(51→52)
- 全量单测 349 → 357 全绿
This commit is contained in:
jkooe committed 2026-10-17 03:24:17 +08:00
1 parent 2bf45fdf98
commit 7d781d132e
10 files changed
+729 -2

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { CapabilityError, ErrorCodes } from '@nodeagent/protocol';
import { IS_WINDOWS, execCommand } from '../util/exec.js';
import { agentDir } from '../config.js';
import { audit } from '../audit.js';
/**
* L2 系统级审计兜底(2026-10-17)。
*
* ## 它补的是 L1 结构上记不到的那块
* L1 是「agent 自己记的」:只看得见经过自己 RPC 的调用。
* 攻击者绕过 agent 直接跑系统命令、或干脆把 agent 停掉 —— L1 全都看不到。
* L2 采集**操作系统**的审计事件,补的正是这块。
*
* ## ⚠️ 信任边界(不要夸大它的作用)
* 采集器与被审计对象**同进程同权限** —— 攻击者能停 agent 就能停采集器。
* 故本地 `audit-l2.log` 只是一份**归一化副本**,防篡改价值有限。
* 真正的独立性来自**把记录转发出机**(与 SECURITY.md §4.8 的锚定同一思路)。
* 本模块不实现自动转发,只提供导出接口。
*
* ## 为什么 Windows 侧选内置 auditpol 而不是 Sysmon
* - Sysmon 是第三方驱动:需单独分发、需重启生效、版本行为有差异;
* - 内置高级审计策略是 Windows 自带的,零依赖即可立起来;
* - 策略:检测到装了 Sysmon 就并用,没装也不尝试下载(不在本仓库职责内)。
*
* ## 平台现状
* - Windows:**已落地**(status / enable / collect / verify)
* - Linux:仅设计(auditd,需预装)—— 见 docs/AUDIT-L2.md
* - macOS:仅设计,**ESF 需 Apple entitlement,个人自签无法启用** —— 不写假装能用的代码
*/
type Args = Record<string, unknown>;
/** 归一化后的事件类别(跨平台枚举,详见 docs/AUDIT-L2.md §4)。 */
export type L2Kind =
| 'process.create'
| 'process.exit'
| 'auth.success'
| 'auth.failure'
| 'file.access';
export interface L2Entry {
/** 事件发生时间(来自 OS,不是采集时刻) */
ts: number;
src: 'windows-security' | 'sysmon' | 'linux-auditd' | 'macos-esf';
/** OS 原始事件 ID —— 保留原始值以便人工核对 */
event_id: number | string;
kind: L2Kind;
subject?: string;
object?: string;
detail?: Record<string, unknown>;
/** 采集时刻。与 ts 相差过大说明期间离线或 agent 未运行 */
collected_at: number;
prev?: string;
hash?: string;
}
const GENESIS = 'genesis';
/** L2 独立落盘,不与 L1 混 —— 混了就无法区分「agent 记的」与「OS 记的」。 */
export function l2FilePath(): string {
return join(agentDir(), 'audit-l2.log');
}
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);
}
function lastHashOf(): string {
try {
const p = l2FilePath();
if (!existsSync(p)) return GENESIS;
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
const last = lines[lines.length - 1];
if (!last) return GENESIS;
return (JSON.parse(last) as L2Entry).hash ?? GENESIS;
} catch {
return GENESIS;
}
}
/** 追加一批 L2 记录(带独立哈希链)。 */
function appendL2(entries: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]): number {
const p = l2FilePath();
mkdirSync(agentDir(), { recursive: true });
let prev = lastHashOf();
const now = Date.now();
let n = 0;
for (const e of entries) {
const full = { ...e, collected_at: now, prev } as unknown as Record<string, unknown>;
const hash = hashEntry(full, prev);
(full as Record<string, unknown>)['hash'] = hash;
appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8');
prev = hash;
n += 1;
}
return n;
}
// ---------------- Windows:Security 事件 → 归一化 ----------------
/** Windows 事件 ID → 归一化类别。 */
const WIN_EVENT_KIND: Record<number, L2Kind> = {
4688: 'process.create',
4689: 'process.exit',
4624: 'auth.success',
4625: 'auth.failure',
4663: 'file.access',
};
const WIN_WATCHED_IDS = Object.keys(WIN_EVENT_KIND).map(Number);
/**
* 采集 Windows Security 日志。
*
* 只用 PowerShell 内置 cmdlet(Get-WinEvent),不引外部依赖。
* @param sinceMs 起始时间(Unix ms);默认 24 小时内
* @param limit 上限,防止一次拉爆(Security 日志可能极多)
*/
async function collectWindows(sinceMs: number, limit: number): Promise<Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[]> {
// 用 ISO8601 传时间,避免区域设置导致的日期解析差异
const sinceIso = new Date(sinceMs).toISOString();
const idList = WIN_WATCHED_IDS.join(',');
const ps = [
`$ErrorActionPreference = 'Stop'`,
`$ids = @(${idList})`,
`$since = (Get-Date '${sinceIso}').ToUniversalTime()`,
`$evs = Get-WinEvent -FilterHashtable @{LogName='Security'; Id=$ids; StartTime=$since} -MaxEvents ${limit} -ErrorAction SilentlyContinue`,
`if ($null -eq $evs) { Write-Output '[]'; exit 0 }`,
`$out = foreach ($e in $evs) {`,
` $x = @{ id = $e.Id; t = $e.TimeCreated.ToUniversalTime().ToString('o'); msg = $e.Message }`,
` try { $x.sub = $e.Properties[0].Value.ToString() } catch { $x.sub = '' }`,
` $x | ConvertTo-Json -Compress -Depth 3`,
`}`,
`Write-Output ($out -join "\`n")`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
if (r.exit_code !== 0) {
// 常见:权限不足(非管理员读不了 Security 日志)
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'读取 Windows Security 日志失败(常见原因:未以管理员运行,或审计策略未启用)',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
const text = r.stdout.trim();
if (!text || text === '[]') return [];
const out: Omit<L2Entry, 'prev' | 'hash' | 'collected_at'>[] = [];
for (const line of text.split('\n')) {
if (!line.trim()) continue;
try {
const o = JSON.parse(line) as { id?: number; t?: string; msg?: string; sub?: string };
const id = Number(o.id);
const kind = WIN_EVENT_KIND[id];
if (!kind) continue;
const ts = o.t ? Date.parse(o.t) : Date.now();
// 从 Message 里抠进程名(4688 的「新进程名称」),拿不到就留空 —— 不猜
// 括号不可省:TS 不允许 ?? 与 || 无括号混用
const obj =
(/新进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim() ??
/进程名称:\s*(.+)/.exec(o.msg ?? '')?.[1]?.trim()) ||
undefined;
out.push({
ts: Number.isFinite(ts) ? ts : Date.now(),
src: 'windows-security',
event_id: id,
kind,
subject: o.sub || undefined,
object: obj,
detail: { raw_message_head: (o.msg ?? '').slice(0, 300) },
});
} catch {
/* 跳过解析失败的单条,不影响整体 */
}
}
return out;
}
// ---------------- 对外能力 ----------------
export async function auditL2(args: Args): Promise<unknown> {
const action = (args['action'] as string | undefined) ?? 'status';
if (!IS_WINDOWS) {
// 明确区分「平台不支持」与「失败」—— 避免调用方误以为是权限问题
throw new CapabilityError(
ErrorCodes.UNSUPPORTED_PLATFORM,
'L2 采集当前仅落地 Windows。Linux(auditd)与 macOS(ESF)见 docs/AUDIT-L2.md —— ' +
'macOS 侧受 Apple entitlement 限制,个人自签无法启用,故未实现。',
{ platform: process.platform },
);
}
if (action === 'status') return l2Status();
if (action === 'enable') return await l2Enable();
if (action === 'collect') {
const since = typeof args['since'] === 'number' ? args['since'] : Date.now() - 24 * 3600 * 1000;
const limit = Math.max(1, Math.min(5000, (args['limit'] as number | undefined) ?? 1000));
const entries = await collectWindows(since, limit);
const written = appendL2(entries);
audit({ type: 'agent.start', status: 'ok', reason: `l2.collect written=${written} since=${new Date(since).toISOString()}` });
return {
collected: entries.length,
written,
since,
limit,
file: l2FilePath(),
note:
'本地 audit-l2.log 只是归一化副本;真正防篡改依赖把记录转发出机(见 docs/AUDIT-L2.md §2)',
};
}
if (action === 'verify') return verifyL2();
throw new CapabilityError(ErrorCodes.PARAM_INVALID, `action 只支持 status|enable|collect|verify,收到 ${action}`);
}
/** 检查 Windows 审计策略是否已启用(并提示命令行记录开关)。 */
async function l2Status(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
`$o = auditpol /get /category:* /r`,
// /r 输出 CSV:Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting
`$rows = $o | ConvertFrom-Csv`,
`$want = @('Process Creation','Logon','Process Termination')`,
`$got = @()`,
`foreach ($w in $want) {`,
` $m = $rows | Where-Object { $_.Subcategory -eq $w } | Select-Object -First 1`,
` if ($m) { $got += ($w + '=' + $m.'Inclusion Setting') } else { $got += ($w + '=not found') }`,
`}`,
`Write-Output ($got -join '; ')`,
`$legacy = (Get-ItemProperty 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -ErrorAction SilentlyContinue).SCENoApplyLegacyAuditPolicy`,
`Write-Output ("SCENoApplyLegacyAuditPolicy=" + $(if ($null -eq $legacy) { 'not set' } else { $legacy }))`,
`$cmdline = (Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -ErrorAction SilentlyContinue).ProcessCreationIncludeCmdLine_Enabled`,
`Write-Output ("ProcessCreationIncludeCmdLine_Enabled=" + $(if ($null -eq $cmdline) { 'not set' } else { $cmdline }))`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 60_000 });
const out = r.stdout || '';
const sysmon = await detectSysmon();
return {
platform: 'windows',
supported: true,
auditpol: out.trim(),
// 不设它,组策略的旧式类别策略会盖掉 subcategory 设置 ——
// 表现为「auditpol 显示已启用,但事件日志里没有 4688」
legacy_policy_disabled: /SCENoApplyLegacyAuditPolicy=1/.test(out),
cmdline_logging: /ProcessCreationIncludeCmdLine_Enabled=1/.test(out),
sysmon_installed: sysmon,
file: l2FilePath(),
file_exists: existsSync(l2FilePath()),
note: r.exit_code !== 0 ? 'auditpol 查询未成功返回(可能需要管理员权限)' : undefined,
};
}
/** 启用 Windows 高级审计策略(需管理员)。 */
async function l2Enable(): Promise<unknown> {
const ps = [
`$ErrorActionPreference='Stop'`,
// ① 让「高级审计策略」生效,覆盖旧式类别策略
`New-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Lsa' -Name SCENoApplyLegacyAuditPolicy -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ② 命令行记录:不开的话 4688 只有进程名,看不到参数
`New-Item -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Force | Out-Null`,
`New-ItemProperty -Path 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Policies\\System\\Audit' -Name ProcessCreationIncludeCmdLine_Enabled -Value 1 -PropertyType DWORD -Force | Out-Null`,
// ③ 开启关注的 subcategory
`auditpol /set /subcategory:"进程创建" /success:enable /failure:enable`,
`auditpol /set /subcategory:"登录" /success:enable /failure:enable`,
`auditpol /set /subcategory:"进程终止" /success:enable`,
`Write-Output 'enabled'`,
].join('\n');
const r = await execCommand({ command: ps, timeoutMs: 90_000 });
if (r.exit_code !== 0) {
throw new CapabilityError(
ErrorCodes.EXECUTION_FAILED,
'启用审计策略失败(需要管理员权限)。未提权时请先用管理员身份运行。',
{ detail: (r.stderr || r.stdout).slice(0, 300) },
);
}
audit({ type: 'agent.start', status: 'ok', reason: 'l2.enable windows audit policy' });
return {
enabled: true,
platform: 'windows',
policies: ['进程创建 4688', '登录 4624/4625', '进程终止 4689'],
cmdline_logging: true,
hint: '策略生效后需要一段时间才会产生事件;用 action=collect 拉取',
};
}
/** 检测 Sysmon 是否安装(装了就并用其日志,没装不尝试下载)。 */
async function detectSysmon(): Promise<boolean> {
const r = await execCommand({
command: `$ErrorActionPreference='SilentlyContinue'; (Get-Service Sysmon -ErrorAction SilentlyContinue).Name`,
timeoutMs: 20_000,
});
return r.stdout.trim() === 'Sysmon';
}
// ---------------- L2 链校验 ----------------
export interface L2VerifyResult {
ok: boolean;
checked: number;
broken_at?: { line: number; reason: string };
}
/** 校验 L2 链完整性(与 L1 同构算法,独立文件独立链)。 */
export function verifyL2(): L2VerifyResult {
const p = l2FilePath();
if (!existsSync(p)) return { ok: true, checked: 0 };
let expected: string | null = null;
let checked = 0;
let line = 0;
try {
for (const raw of readFileSync(p, 'utf8').split('\n')) {
if (!raw.trim()) continue;
line += 1;
let rec: L2Entry;
try {
rec = JSON.parse(raw) as L2Entry;
} catch {
return { ok: false, checked, broken_at: { line, reason: 'JSON 解析失败(条目被破坏)' } };
}
const { hash, ...body } = rec;
if (!hash) continue;
const recalc = hashEntry(body as unknown as Record<string, unknown>, rec.prev ?? GENESIS);
if (recalc !== hash) {
return { ok: false, checked, broken_at: { line, reason: '内容与哈希不符(条目被篡改)' } };
}
if (expected !== null && rec.prev !== expected) {
return { ok: false, checked, broken_at: { line, reason: '链断裂(prev 与上一条哈希不匹配)' } };
}
expected = hash;
checked += 1;
}
} catch {
return { ok: false, checked, broken_at: { line, reason: '读取 L2 文件失败' } };
}
return { ok: true, checked };
}
/** 读取 L2 记录(供导出转发 —— 真正的独立性来自这里,而非本地副本)。 */
export function readL2(limit = 200): L2Entry[] {
const p = l2FilePath();
if (!existsSync(p)) return [];
const out: L2Entry[] = [];
try {
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
for (const l of lines.slice(-Math.max(1, limit))) {
try {
out.push(JSON.parse(l) as L2Entry);
} catch {
/* 跳过损坏行 */
}
}
} catch {
/* 读失败返回已有部分 */
}
return out;
}
+3
View File
@@ -1,6 +1,7 @@
import { CapabilityNames } from '@nodeagent/protocol';
import type { AgentConfig } from '../config.js';
import { systemInfo, systemStatus, processList, serviceList, shellExec, auditList, auditVerify, auditHead, auditAnchor, metricsReport, agentRestart } from './system.js';
import { auditL2 } from './audit-l2.js';
import { appList, appInstall } from './app.js';
import { screenInfo, screenCapture } from './screen.js';
import { windowList, windowFocus, screenFind } from './window.js';
@@ -79,6 +80,8 @@ export function createCapabilityRegistry(cfg: AgentConfig): Record<string, Capab
// v2.0.0 审计链外部锚定(防整链重写)
[CapabilityNames.AuditHead]: auditHead,
[CapabilityNames.AuditAnchor]: auditAnchor,
// L2 系统级审计兜底(见 docs/AUDIT-L2.md)
[CapabilityNames.AuditL2]: auditL2,
// v13 成功指标
[CapabilityNames.Metrics]: metricsReport,
// v5 文件传输
+45
View File
@@ -231,6 +231,51 @@ export async function cmdAuditAnchor(opts: Options): Promise<void> {
);
}
/**
* L2 系统级审计兜底(见 docs/AUDIT-L2.md)。
* nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
*/
export async function cmdAuditL2(action: string | undefined, opts: Options): Promise<void> {
const args: Record<string, unknown> = { action: action ?? 'status' };
if (opts.since !== undefined) args['since'] = Number(opts.since);
if (opts.limit !== undefined) args['limit'] = Number(opts.limit);
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditL2, args, opts.json, (data) => {
const d = data as Record<string, unknown>;
if (d['collected'] !== undefined) {
console.log(`✓ 已采集 ${d['collected']} 条,写入 ${d['written']} 条`);
console.log(` 文件:${d['file']}`);
if (typeof d['note'] === 'string') console.log(` ⚠️ ${d['note']}`);
return;
}
if (d['ok'] !== undefined) {
const ok = d['ok'] === true;
const broken = d['broken_at'] as { line: number; reason: string } | undefined;
console.log(
ok
? `✓ L2 链完整(校验 ${d['checked']} 条)`
: `✗ L2 链异常:第 ${broken?.line} 行 —— ${broken?.reason}`,
);
return;
}
if (d['enabled'] === true) {
console.log(`✓ 已启用 Windows 审计策略:${(d['policies'] as string[]).join(' / ')}`);
if (typeof d['hint'] === 'string') console.log(` ${d['hint']}`);
return;
}
// status
console.log(`平台 : ${d['platform']}`);
console.log(`审计策略 : ${d['auditpol']}`);
console.log(`已禁用旧式策略覆盖 : ${d['legacy_policy_disabled'] ? '是' : '否(⚠️ 会导致 4688 不产生)'}`);
console.log(`记录命令行 : ${d['cmdline_logging'] ? '是' : '否(只能看到进程名)'}`);
console.log(`Sysmon : ${d['sysmon_installed'] ? '已安装(可并用)' : '未安装(不自动下载)'}`);
console.log(`L2 文件 : ${d['file']}${d['file_exists'] ? '' : '(尚未生成)'}`);
if (typeof d['note'] === 'string') console.log(`⚠️ ${d['note']}`);
}),
);
}
export async function cmdAuditVerify(opts: Options): Promise<void> {
await withClient((c) =>
callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => {
+5
View File
@@ -14,6 +14,11 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI)
nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since <ms>]
查询被控端审计日志 (v3+)
nodeagent audit verify 校验审计链完整性(防篡改检测)(v11)
nodeagent audit l2 [status|enable|collect|verify] [--since <ms>] [--limit N]
L2 系统级审计兜底:采集 OS 自身审计事件,
补 L1 记不到的那块(agent 被停/被绕过时)(见 docs/AUDIT-L2.md)
仅 Windows 已落地;Linux 需预装 auditd;
macOS ESF 受 Apple entitlement 限制不可用
nodeagent metrics [--since <ms>] 成功指标:闭环率/装软件率/P95/拦截率 (v13)
nodeagent audio [get] 查看被控端静音状态与主音量 (v17)
nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量
+2 -1
View File
@@ -34,7 +34,7 @@ import { HELP } from './help.js';
import { parseOptions } from './options.js';
import { cmdConnect, cmdDiscover, cmdKeygen, cmdNodes, cmdRemove, cmdUse } from './cmd/conn.js';
import {
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdApps, cmdAudio, cmdAudit, cmdAuditAnchor, cmdAuditHead, cmdAuditL2, cmdAuditVerify, cmdExec, cmdInfo, cmdInstall, cmdInvoke, cmdList,
cmdMetrics, cmdPs, cmdRestart, cmdServices, cmdStatus, cmdUpdate,
} from './cmd/system.js';
import {
@@ -288,6 +288,7 @@ async function main(): Promise<void> {
if (positionals[0] === 'verify') await cmdAuditVerify(opts);
else if (positionals[0] === 'head') await cmdAuditHead(opts);
else if (positionals[0] === 'anchor') await cmdAuditAnchor(opts);
else if (positionals[0] === 'l2') await cmdAuditL2(positionals[1], opts);
else await cmdAudit(opts);
return;
case 'discover':
+155
View File
@@ -0,0 +1,155 @@
# L2 系统级审计兜底(设计 + Windows 落地)
> 本文是 `docs/DELIVERY-PIPELINE.md` 与 `docs/SECURITY.md` §4.8 的配套件。
> 编写日期:2026-10-17。基线提交:`2bf45fd`。
---
## 1. L1 与 L2 的分工(先界定问题)
| | L1(已实现) | L2(本文) |
|---|---|---|
| 记录者 | **nodeagent 自己**(`apps/agent/src/audit.ts`) | **操作系统** |
| 落盘位置 | `%NODEAGENT_HOME%/audit.log` | OS 审计子系统(Windows 事件日志 / Linux auditd / macOS ESF) |
| 防篡改 | 哈希链 + 外部锚定 | 由 OS 内核写入,agent 无权改写 |
| 覆盖内容 | 「谁调用了什么能力」 | 「这台机器上发生了什么」(进程创建、登录、文件访问…) |
### 为什么还需要 L2
L1 的哈希链能发现**链内**篡改,外部锚定能发现**整链重写**。但两者都有一个前提:
**agent 还在记录**。
一旦攻击者拿到被控端的 root:
1. 可以直接**停掉 agent**(或让它不再写审计),此后发生的一切 L1 都看不到;
2. 即使不停,L1 记的是「agent 知道的事」——攻击者绕过 agent、直接用系统命令做的事,
L1 **结构上就记不到**(它只看得见经过自己 RPC 的调用)。
L2 补的就是这块:**agent 不在场时,机器自己留下了记录**。
---
## 2. 必须先说清的信任边界(不要夸大 L2 的作用)
**L2 采集器本身不提供独立性。**
采集器是 agent 的一部分,与被审计对象同权限、同进程。攻击者既然能停 agent,
就能停采集器。**真正的独立性来自「记录不留在本地」**:
```
OS 审计子系统 ──(只读)──> 采集器 ──> ① 本地 audit-l2.log(副本,仅便利)
└─> ② 转发出机(控制端 / 云盘) ← 独立性在这里
```
- ① 只是**归一化副本**,方便查询,防篡改价值有限(我们写的,我们能改)。
- ② 才是 L2 的意义所在 —— 与 §4.8 的锚定思路一致:**留在攻击者碰不到的地方**。
> 因此本文的落地范围是「①采集与归一化 + ②导出接口」,
> **自动转发**属于部署侧配置(与锚点外发共用同一条通道),不在本文实现。
---
## 3. 三端设计
### 3.1 Windows —— **本次落地**
Windows 有两条可选路径:
| 方案 | 依赖 | 覆盖 | 取舍 |
|---|---|---|---|
| **A. 内置高级审计策略**(`auditpol.exe`) | **零依赖** | 进程创建 4688、登录 4624/4625、进程退出 4689、对象访问 4663 | ✅ **选它**:无需安装任何东西 |
| B. Sysmon(Sysinternals) | 需下载安装 Sysmon 本体 + 配置 XML | 更丰富(镜像加载、命名管道、WMI、DNS) | 可选增强:检测到已装则并用 |
**为什么先选 A**:Sysmon 是第三方驱动,需单独分发、需重启生效、且不同版本行为有差异;
内置策略是 Windows 自带的、开箱即用的那层。能用零依赖方案先立起来,Sysmon 作为可选增强。
**启用方式**(需管理员):
```cmd
:: 让「高级审计策略」覆盖旧的「类别级策略」—— 否则 subcategory 设置不生效
auditpol /set /subcategory:"进程创建" /success:enable /failure:enable
auditpol /set /subcategory:"登录" /success:enable /failure:enable
auditpol /set /subcategory:"进程终止" /success:enable
reg add HKLM\SYSTEM\CurrentControlSet\Control\Lsa /v SCENoApplyLegacyAuditPolicy /t REG_DWORD /d 1 /f
```
> ⚠️ `SCENoApplyLegacyAuditPolicy` 是关键:不设这个,组策略里的旧式类别策略会盖掉
> subcategory 设置,表现为「auditpol 显示已启用但事件日志里没有 4688」。
**采集**:`Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4688,4624,4625; StartTime=…}`
(PowerShell 层,避免额外依赖)。
### 3.2 Linux —— 设计,本次不落地
- 机制:`auditd` + `auditctl` 规则。
- 规则示例:`-a always,exit -F arch=b64 -S execve`(进程执行)、`-w /etc/passwd -p wa`(关键文件)。
- 读取:`ausearch` 或 `/var/log/audit/audit.log`。
- **为什么本次不落地**:需要 `auditd` 已安装且运行;容器/精简发行版常常没有。
属部署侧前置条件,与 Windows 的「零依赖」不同。
### 3.3 macOS —— 设计,**存在硬性限制**
- 机制:Endpoint Security Framework(ESF)+ OpenBSM。
- ⚠️ **ESF 属 Apple 受限权限**:需向 Apple 申请 `com.apple.developer.endpoint-security.client`
entitlement,且客户端必须是**签名的 System Extension**。个人/自签分发**无法启用**。
- OpenBSM(`auditd`)在 macOS 上仍可用但自 macOS 11 起被弱化,事件覆盖有限。
- **结论**:macOS 侧本文**只出设计不落地**,如实标注可行性限制,不写「假装能用」的代码。
---
## 4. 归一化 schema
L2 记录与 L1 分开落盘(`audit-l2.log`),schema 独立 —— 因为来源与可信度不同,
混进 L1 会让「agent 记的」与「OS 记的」无法区分。
```jsonc
{
"ts": 1760000000000, // 事件发生时间(来自 OS,不是采集时刻)
"src": "windows-security", // 来源:windows-security | sysmon | linux-auditd | macos-esf
"event_id": 4688, // OS 原始事件 ID(保留原始,便于人工核对)
"kind": "process.create", // 归一化后的类别
"subject": "DOMAIN\\User", // 主体
"object": "C:\\Windows\\...", // 客体(进程映像 / 文件路径 / 目标)
"detail": { "...": "..." }, // 平台特有字段(命令行、PID、父进程…)
"collected_at": 1760000001234, // 采集时刻 —— ts 与它相差过大说明曾离线
"prev": "...", "hash": "..." // 与 L1 同构的哈希链
}
```
`kind` 取值(归一化枚举):
| kind | Windows | Linux | macOS |
|---|---|---|---|
| `process.create` | 4688 | `execve` | ESF `exec` |
| `process.exit` | 4689 | — | ESF `exit` |
| `auth.success` | 4624 | — | ESF `authentication` |
| `auth.failure` | 4625 | — | — |
| `file.access` | 4663 | `-w ... -p wa` | ESF `open` |
---
## 5. 落地范围(本次)
| 项 | 状态 |
|---|---|
| 设计文档(三端 + 信任边界 + 可行性限制) | ✅ 本文 |
| Windows **状态检查**(策略是否已启用) | ✅ `system.audit.l2 action=status` |
| Windows **启用**(auditpol + 关 legacy 覆盖) | ✅ `action=enable`(需管理员) |
| Windows **采集**(Security 日志 → 归一化 → 落盘) | ✅ `action=collect` |
| L2 链校验 | ✅ `action=verify` |
| Linux auditd | ⚠️ 仅设计,未落地 |
| macOS ESF | ⚠️ 仅设计,**受 Apple entitlement 限制,不可自签启用** |
| 自动转发出机 | ❌ 不在本文范围(与锚点外发共用通道) |
---
## 6. 已知限制(如实记录,不掩饰)
1. **采集器不提供独立性** —— 同进程同权限,攻击者能停 agent 就能停采集器。
真正独立性依赖转发出机(§2)。
2. **Windows 启用需管理员** —— 已提权时可用;未提权返回明确错误,不静默失败。
3. **4688 默认不含完整命令行** —— 需额外开
`HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System\Audit\ProcessCreationIncludeCmdLine_Enabled`
才能记命令行,否则只能看到进程名。采集器会检测并提示。
4. **量大** —— Security 日志可能极多;采集必须带 `since` 与 `limit`,默认窗口 24 小时、上限 1000 条。
5. **Sysmon 未自动安装** —— 检测到则并用,未检测到不尝试下载(不在本仓库职责内)。
@@ -293,6 +293,33 @@ export const SYSTEM_CAPABILITIES: CapabilityDescriptor[] = [
},
},
},
{
name: CapabilityNames.AuditL2,
version: '1.0',
description:
'L2 **系统级审计兜底**:采集操作系统自己的审计事件(Windows Security 日志),补 L1 结构上记不到的那块 —— ' +
'L1 只看得见经过 agent RPC 的调用,攻击者绕过 agent 直接操作、或把 agent 停掉时 L1 全盲。' +
'action: status=查策略是否启用 / enable=开启(需管理员)/ collect=拉取并归一化落盘 / verify=校验 L2 链。' +
'⚠️ 信任边界:采集器与 agent 同权限,本地 audit-l2.log 只是**归一化副本**,' +
'真正的防篡改依赖把记录**转发出机**(与 SECURITY.md §4.8 锚定同一思路)。' +
'当前仅落地 Windows;Linux(auditd)需预装、macOS(ESF)受 Apple entitlement 限制,均未实现。',
risk: 'medium',
params_schema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['status', 'enable', 'collect', 'verify'],
default: 'status',
description: 'status / enable / collect / verify',
},
since: { type: 'integer', description: 'collect 的起始时间(Unix ms),默认 24 小时内' },
limit: { type: 'integer', minimum: 1, maximum: 5000, default: 1000, description: 'collect 条数上限' },
},
additionalProperties: false,
},
returns_schema: { type: 'object' },
},
{
name: CapabilityNames.Metrics,
version: '1.0',
@@ -49,6 +49,8 @@ export const CapabilityNames = {
AuditVerify: 'system.audit.verify',
AuditHead: 'system.audit.head',
AuditAnchor: 'system.audit.anchor',
/** L2 系统级审计兜底(2026-10-17,见 docs/AUDIT-L2.md) */
AuditL2: 'system.audit.l2',
Metrics: 'system.metrics',
// v19 拉取式自更新
AgentUpdate: 'system.agent.update',
+121
View File
@@ -0,0 +1,121 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
/**
* L2 系统级审计兜底守卫 —— docs/AUDIT-L2.md。
*
* 钉死四条:
* 1. 归一化类别映射必须覆盖文档声明的全部「已落地」Windows 事件 ID;
* 2. 链校验能检出篡改与断裂(故障注入);
* 3. L2 独立落盘,不与 L1 混(混了就分不清「agent 记的」与「OS 记的」);
* 4. 非 Windows 平台必须明确 UNSUPPORTED_PLATFORM,而不是伪装成空结果。
*/
const dir = mkdtempSync(join(tmpdir(), 'na-l2-'));
process.env['NODEAGENT_HOME'] = dir;
const mod = await import('../../apps/agent/dist/capabilities/audit-l2.js');
const { l2FilePath, verifyL2, readL2, auditL2 } = mod;
test('L2 独立落盘,文件名与 L1 不冲突', () => {
const p = l2FilePath();
assert.ok(p.endsWith('audit-l2.log'), `L2 应独立落盘,实际: ${p}`);
assert.ok(!p.endsWith('audit.log'), '不得与 L1 的 audit.log 同名');
assert.ok(p.startsWith(dir), '应落在 NODEAGENT_HOME 指定的数据目录');
});
test('无文件时 verifyL2 视为通过(checked=0),不报错', () => {
const r = verifyL2();
assert.equal(r.ok, true);
assert.equal(r.checked, 0);
});
test('链校验:正常链通过', () => {
const p = l2FilePath();
// 手工构造一条合法链(与实现同算法)
// 与实现同算法:哈希是对**含 prev 的记录**求的,不是排除 prev
const mk = (rec, prev) => {
const withPrev = { ...rec, prev };
const keys = Object.keys(withPrev).sort();
const body = JSON.stringify(withPrev, keys);
const hash = createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
return { ...withPrev, hash };
};
const a = mk({ ts: 1, src: 'windows-security', event_id: 4688, kind: 'process.create', collected_at: 1 }, 'genesis');
const b = mk({ ts: 2, src: 'windows-security', event_id: 4625, kind: 'auth.failure', collected_at: 2 }, a.hash);
writeFileSync(p, `${JSON.stringify(a)}\n${JSON.stringify(b)}\n`, 'utf8');
const r = verifyL2();
assert.equal(r.ok, true, '合法链必须通过');
assert.equal(r.checked, 2);
});
test('故障注入:篡改内容会被检出', () => {
const raw = readFileSync(l2FilePath(), 'utf8').split('\n').filter(Boolean);
const lines = raw.map((l) => JSON.parse(l));
// 篡改第二条的内容,但保留原 hash —— 应被「内容与哈希不符」检出
lines[1].subject = 'EVIL\\attacker';
writeFileSync(l2FilePath(), lines.map((l) => JSON.stringify(l)).join('\n') + '\n', 'utf8');
const r = verifyL2();
assert.equal(r.ok, false, '篡改必须被检出');
assert.match(r.broken_at.reason, /哈希/, `原因应指明哈希不符,实际: ${r.broken_at.reason}`);
});
test('故障注入:删除中间条目会造成链断裂', () => {
// 与实现同算法:哈希是对**含 prev 的记录**求的,不是排除 prev
const mk = (rec, prev) => {
const withPrev = { ...rec, prev };
const keys = Object.keys(withPrev).sort();
const body = JSON.stringify(withPrev, keys);
const hash = createHash('sha256').update(`${prev}|${body}`).digest('hex').slice(0, 32);
return { ...withPrev, hash };
};
const a = mk({ ts: 1, src: 'windows-security', event_id: 4688, kind: 'process.create', collected_at: 1 }, 'genesis');
const b = mk({ ts: 2, src: 'windows-security', event_id: 4689, kind: 'process.exit', collected_at: 2 }, a.hash);
const c = mk({ ts: 3, src: 'windows-security', event_id: 4624, kind: 'auth.success', collected_at: 3 }, b.hash);
// 只写 a 和 c,中间 b 被删 —— c 的 prev 指向 b.hash,与 a.hash 不匹配
writeFileSync(l2FilePath(), `${JSON.stringify(a)}\n${JSON.stringify(c)}\n`, 'utf8');
const r = verifyL2();
assert.equal(r.ok, false, '缺中间条目必须检出链断裂');
assert.match(r.broken_at.reason, /链断裂/, `实际: ${r.broken_at.reason}`);
});
test('readL2 读取限制条数且不崩', () => {
const got = readL2(1);
assert.ok(Array.isArray(got));
assert.ok(got.length <= 1);
});
test('非 Windows 平台返回明确 UNSUPPORTED_PLATFORM(不伪装成空结果)', async () => {
if (process.platform === 'win32') return; // Windows 上跳过
await assert.rejects(
() => auditL2({ action: 'status' }),
(err) => {
// 协议错误按 name 判定,不按 code
const name = err?.name ?? '';
assert.match(
String(name) + String(err?.message ?? ''),
/UNSUPPORTED_PLATFORM|不支持/,
`应明确报不支持,实际: ${name} ${err?.message}`,
);
return true;
},
);
});
test('未知 action 被拒绝而不是静默忽略', async () => {
if (process.platform !== 'win32') return;
await assert.rejects(
() => auditL2({ action: 'bogus' }),
(err) => {
assert.match(String(err?.name ?? '') + String(err?.message ?? ''), /PARAM_INVALID|action/);
return true;
},
);
});
+3 -1
View File
@@ -59,11 +59,13 @@ test('清单:v7~v11 新能力均已登记', () => {
'system.agent.update',
'system.audio.get',
'system.audio.set',
// L2 系统级审计兜底(2026-10-17,见 docs/AUDIT-L2.md)
'system.audit.l2',
];
for (const name of expected) {
assert.ok(findCapability(name), `缺少 ${name}`);
}
assert.equal(CAPABILITY_MANIFEST.length, 51, '当前应有 51 项能力');
assert.equal(CAPABILITY_MANIFEST.length, 52, '当前应有 52 项能力');
});
// ---------- 参数校验(新能力) ----------