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:
1 parent
2bf45fdf98
commit
7d781d132e
10 files changed
+729
-2
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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 文件传输
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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] 设置静音 / 音量
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 项能力');
|
||||
});
|
||||
|
||||
// ---------- 参数校验(新能力) ----------
|
||||
|
||||
Reference in new issue
Block a user