From 7d781d132e0395a450b6587d993cd0d4b109c99e Mon Sep 17 00:00:00 2001 From: jkooe <62494645+jkooe@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:24:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(audit):=20L2=20=E7=B3=BB=E7=BB=9F=E7=BA=A7?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E5=85=9C=E5=BA=95=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=89=E7=AB=AF=E8=AE=BE=E8=AE=A1=20+=20Windows=20=E8=90=BD?= =?UTF-8?q?=E5=9C=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 它补的是 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 全绿 --- apps/agent/src/capabilities/audit-l2.ts | 366 ++++++++++++++++++ apps/agent/src/capabilities/index.ts | 3 + apps/cli/src/cmd/system.ts | 45 +++ apps/cli/src/help.ts | 5 + apps/cli/src/index.ts | 3 +- docs/AUDIT-L2.md | 155 ++++++++ .../src/capabilities/manifest/system.ts | 27 ++ packages/protocol/src/capabilities/names.ts | 2 + tests/unit/audit-l2.test.mjs | 121 ++++++ tests/unit/capabilities.test.mjs | 4 +- 10 files changed, 729 insertions(+), 2 deletions(-) create mode 100644 apps/agent/src/capabilities/audit-l2.ts create mode 100644 docs/AUDIT-L2.md create mode 100644 tests/unit/audit-l2.test.mjs diff --git a/apps/agent/src/capabilities/audit-l2.ts b/apps/agent/src/capabilities/audit-l2.ts new file mode 100644 index 0000000..6042821 --- /dev/null +++ b/apps/agent/src/capabilities/audit-l2.ts @@ -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; + +/** 归一化后的事件类别(跨平台枚举,详见 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; + /** 采集时刻。与 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, 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[]): 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; + const hash = hashEntry(full, prev); + (full as Record)['hash'] = hash; + appendFileSync(p, `${JSON.stringify(full)}\n`, 'utf8'); + prev = hash; + n += 1; + } + return n; +} + +// ---------------- Windows:Security 事件 → 归一化 ---------------- + +/** Windows 事件 ID → 归一化类别。 */ +const WIN_EVENT_KIND: Record = { + 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[]> { + // 用 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[] = []; + 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 { + 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 { + 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 { + 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 { + 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, 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; +} diff --git a/apps/agent/src/capabilities/index.ts b/apps/agent/src/capabilities/index.ts index 58c9882..f862b28 100644 --- a/apps/agent/src/capabilities/index.ts +++ b/apps/agent/src/capabilities/index.ts @@ -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 { ); } +/** + * L2 系统级审计兜底(见 docs/AUDIT-L2.md)。 + * nodeagent audit l2 [status|enable|collect|verify] [--since ] [--limit N] + */ +export async function cmdAuditL2(action: string | undefined, opts: Options): Promise { + const args: Record = { 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; + 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 { await withClient((c) => callAndPrint(c, CapabilityNames.AuditVerify, {}, opts.json, (data) => { diff --git a/apps/cli/src/help.ts b/apps/cli/src/help.ts index 28e51ba..16191bc 100644 --- a/apps/cli/src/help.ts +++ b/apps/cli/src/help.ts @@ -14,6 +14,11 @@ export const HELP = `nodeagent —— 跨机 AI 接管框架(控制端 CLI) nodeagent audit [--limit 20] [--type invoke|auth|acl|agent] [--client-id X] [--since ] 查询被控端审计日志 (v3+) nodeagent audit verify 校验审计链完整性(防篡改检测)(v11) + nodeagent audit l2 [status|enable|collect|verify] [--since ] [--limit N] + L2 系统级审计兜底:采集 OS 自身审计事件, + 补 L1 记不到的那块(agent 被停/被绕过时)(见 docs/AUDIT-L2.md) + 仅 Windows 已落地;Linux 需预装 auditd; + macOS ESF 受 Apple entitlement 限制不可用 nodeagent metrics [--since ] 成功指标:闭环率/装软件率/P95/拦截率 (v13) nodeagent audio [get] 查看被控端静音状态与主音量 (v17) nodeagent audio set [--mute on|off] [--volume 0-100] 设置静音 / 音量 diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 61bb2d0..bff1667 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -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 { 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': diff --git a/docs/AUDIT-L2.md b/docs/AUDIT-L2.md new file mode 100644 index 0000000..9146181 --- /dev/null +++ b/docs/AUDIT-L2.md @@ -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 未自动安装** —— 检测到则并用,未检测到不尝试下载(不在本仓库职责内)。 diff --git a/packages/protocol/src/capabilities/manifest/system.ts b/packages/protocol/src/capabilities/manifest/system.ts index 9ff234c..fd295fc 100644 --- a/packages/protocol/src/capabilities/manifest/system.ts +++ b/packages/protocol/src/capabilities/manifest/system.ts @@ -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', diff --git a/packages/protocol/src/capabilities/names.ts b/packages/protocol/src/capabilities/names.ts index aea46c2..f5f727e 100644 --- a/packages/protocol/src/capabilities/names.ts +++ b/packages/protocol/src/capabilities/names.ts @@ -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', diff --git a/tests/unit/audit-l2.test.mjs b/tests/unit/audit-l2.test.mjs new file mode 100644 index 0000000..b91863f --- /dev/null +++ b/tests/unit/audit-l2.test.mjs @@ -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; + }, + ); +}); diff --git a/tests/unit/capabilities.test.mjs b/tests/unit/capabilities.test.mjs index f664b29..683b15a 100644 --- a/tests/unit/capabilities.test.mjs +++ b/tests/unit/capabilities.test.mjs @@ -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 项能力'); }); // ---------- 参数校验(新能力) ----------