Files
nodeagent/tests/unit/capabilities.test.mjs
T
jkooe 7d781d132e 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 全绿
2026-10-17 03:24:17 +08:00

278 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { capabilityDiff } from '../../packages/protocol/dist/index.js';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
CapabilityNames,
CAPABILITY_MANIFEST,
findCapability,
validate,
applyDefaults,
} from '../../packages/protocol/dist/index.js';
// ---------- 清单完整性(v7~v10 新增能力纳入守护) ----------
test('清单:能力名与 manifest 一一对应,无重复', () => {
const names = Object.values(CapabilityNames);
assert.equal(new Set(names).size, names.length, '能力名不应重复');
for (const n of names) {
assert.ok(findCapability(n), `能力 ${n} 缺少 manifest 条目`);
}
assert.equal(CAPABILITY_MANIFEST.length, names.length, 'manifest 条目数应与能力名数一致');
});
test('清单:每条都有 version/description/risk 与封闭的 params_schema', () => {
for (const c of CAPABILITY_MANIFEST) {
assert.match(c.version, /^\d+\.\d+$/, `${c.name} 版本号格式`);
assert.ok(c.description && c.description.length >= 8, `${c.name} 描述过短`);
assert.ok(['low', 'medium', 'high'].includes(c.risk), `${c.name} risk 取值非法`);
assert.equal(c.params_schema?.type, 'object', `${c.name} params_schema 应为 object`);
assert.equal(
c.params_schema?.additionalProperties,
false,
`${c.name} params_schema 应封闭(additionalProperties: false)`,
);
}
});
test('清单:v7~v11 新能力均已登记', () => {
const expected = [
'system.agent.restart',
'window.list',
'window.focus',
'screen.find',
'system.task.list',
'system.task.get',
'system.task.kill',
'clip.get',
'clip.set',
'system.audit.verify',
'input.mouse.drag',
'screen.record',
'event.watch',
'event.unwatch',
'event.list',
'event.poll',
'system.metrics',
'system.net.apply',
'system.net.confirm',
'system.net.status',
'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, 52, '当前应有 52 项能力');
});
// ---------- 参数校验(新能力) ----------
function check(capName, args) {
const cap = findCapability(capName);
return validate(args, cap.params_schema);
}
test('system.task.get:task_id 必填,offset 可选', () => {
assert.equal(check('system.task.get', { task_id: 't_1' }).length, 0);
assert.ok(check('system.task.get', {}).length > 0, '缺 task_id 应报错');
assert.equal(check('system.task.get', { task_id: 't_1', offset: 1024 }).length, 0);
});
test('system.task.list:不接受多余参数', () => {
assert.equal(check('system.task.list', {}).length, 0);
assert.ok(check('system.task.list', { bogus: 1 }).length > 0, '多余参数应被拒');
});
test('clip.set:支持文本或图片,二者可选但 handler 会强制至少一个', () => {
assert.equal(check('clip.set', { text: '中文 ✅' }).length, 0);
assert.equal(check('clip.set', { text: '' }).length, 0, '空串合法(清空剪贴板)');
assert.equal(check('clip.set', { image_base64: 'iVBORw0KGgo=' }).length, 0, '图片 Base64 合法');
// schema 层允许空对象(text/image_base64 均为可选),由 handler 报错——避免 schema 过严导致组合校验困难
assert.equal(check('clip.set', {}).length, 0, 'schema 层不拒绝空对象');
assert.ok(check('clip.set', { bogus: 1 }).length > 0, '未知参数仍应被拒');
});
test('clip.get:format 枚举受控', () => {
assert.equal(check('clip.get', {}).length, 0);
assert.equal(check('clip.get', { format: 'image' }).length, 0);
assert.ok(check('clip.get', { format: 'video' }).length > 0, '非法 format 应被拒');
});
test('input.mouse.drag:四个坐标必填,steps 有上下限', () => {
assert.equal(
check('input.mouse.drag', { from_x: 0, from_y: 0, to_x: 100, to_y: 100 }).length,
0,
);
assert.ok(check('input.mouse.drag', { from_x: 0, from_y: 0 }).length > 0, '缺坐标应报错');
assert.ok(
check('input.mouse.drag', { from_x: 0, from_y: 0, to_x: 1, to_y: 1, steps: 0 }).length > 0,
'steps 下限 1',
);
});
test('screen.find:method 枚举受控,非法值被拒', () => {
assert.equal(check('screen.find', { text: '确定' }).length, 0);
assert.equal(check('screen.find', { text: 'x', method: 'ocr' }).length, 0);
assert.ok(check('screen.find', { text: 'x', method: 'magic' }).length > 0, '非法 method 应被拒');
});
test('screen.find:v15 图像模板 —— method=image 与 template/threshold', () => {
assert.equal(check('screen.find', { method: 'image', template: 'C:\\icon.png' }).length, 0);
assert.equal(check('screen.find', { text: 'x', method: 'image', threshold: 0.9 }).length, 0);
// text 改为可选(image 模式不需要),但 handler 仍会校验「非 image 模式必须有 text」
assert.equal(check('screen.find', {}).length, 0, 'schema 层允许空对象(由 handler 判定 text/template)');
assert.ok(check('screen.find', { text: 'x', threshold: 0.1 }).length > 0, 'threshold 下限 0.3');
assert.ok(check('screen.find', { text: 'x', threshold: 1.5 }).length > 0, 'threshold 上限 0.999');
assert.ok(check('screen.find', { text: 'x', bogus: 1 }).length > 0, '未知参数应被拒');
});
test('input.key.press:支持 repeat 与 sequence', () => {
assert.equal(check('input.key.press', { keys: ['ctrl', 'c'] }).length, 0);
assert.equal(check('input.key.press', { keys: ['up'], repeat: 3 }).length, 0);
assert.equal(check('input.key.press', { sequence: [['up'], ['enter']] }).length, 0);
assert.ok(check('input.key.press', { keys: ['up'], repeat: 0 }).length > 0, 'repeat 下限 1');
assert.ok(check('input.key.press', { keys: ['up'], repeat: 99 }).length > 0, 'repeat 上限 50');
});
test('system.agent.restart:参数可省略', () => {
assert.equal(check('system.agent.restart', {}).length, 0);
assert.equal(check('system.agent.restart', { delay_ms: 3000, reason: 'cfg' }).length, 0);
assert.ok(check('system.agent.restart', { delay_ms: 0 }).length > 0, 'delay_ms 下限 1');
});
test('system.shell.exec:async 与 wait_forever 为合法布尔', () => {
assert.equal(check('system.shell.exec', { command: 'echo hi' }).length, 0);
assert.equal(check('system.shell.exec', { command: 'sleep 1', async: true }).length, 0);
assert.equal(
check('system.shell.exec', { command: 'x', async: true, wait_forever: true }).length,
0,
);
assert.ok(check('system.shell.exec', { command: 'x', async: 'yes' }).length > 0, '类型错误应被拒');
});
test('event.watch:kind 枚举受控,file 需 path,interval 有上下限', () => {
assert.equal(check('event.watch', { kind: 'file', path: 'C:\\tmp' }).length, 0);
assert.equal(check('event.watch', { kind: 'process', pattern: 'chrome*' }).length, 0);
assert.equal(check('event.watch', { kind: 'net', interval_ms: 2000 }).length, 0);
assert.ok(check('event.watch', { kind: 'disk' }).length > 0, '非法 kind 应被拒');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 100 }).length > 0, 'interval 下限 1s');
assert.ok(check('event.watch', { kind: 'net', interval_ms: 99999 }).length > 0, 'interval 上限 60s');
});
test('event.poll:游标与条数可省略,且不接受未知参数', () => {
assert.equal(check('event.poll', {}).length, 0);
assert.equal(check('event.poll', { since: 12, limit: 100 }).length, 0);
assert.ok(check('event.poll', { limit: 0 }).length > 0, 'limit 下限 1');
assert.ok(check('event.poll', { limit: 999 }).length > 0, 'limit 上限 500');
assert.ok(check('event.poll', { bogus: 1 }).length > 0, '未知参数应被拒');
});
test('system.net.apply:mode 枚举受控,static 需 ip/mask,确认窗有上下限', () => {
assert.equal(check('system.net.apply', { mode: 'dhcp' }).length, 0);
assert.equal(
check('system.net.apply', { mode: 'static', ip: '192.168.1.50', mask: '255.255.255.0', gateway: '192.168.1.1' }).length,
0,
);
assert.equal(check('system.net.apply', { mode: 'command', command: 'x' }).length, 0);
assert.ok(check('system.net.apply', { mode: 'teleport' }).length > 0, '非法 mode 应被拒');
assert.ok(check('system.net.apply', { mode: 'static', ip: '1.2.3.4', mask: '255.255.255.0', confirm_within_ms: 1000 }).length > 0, '确认窗下限 15s');
assert.ok(check('system.net.apply', { mode: 'static', ip: '1.2.3.4', mask: '255.255.255.0', confirm_within_ms: 9999999 }).length > 0, '确认窗上限 10min');
});
test('system.agent.update:url/sha256 必填,超时范围受控', () => {
assert.equal(
check('system.agent.update', { url: 'https://example.com/a.mjs', sha256: 'abcdef012345' }).length,
0,
);
assert.ok(check('system.agent.update', { url: 'https://example.com/a.mjs' }).length > 0, 'sha256 必填(安全底线)');
assert.ok(check('system.agent.update', { sha256: 'abcdef012345' }).length > 0, 'url 必填');
assert.equal(
check('system.agent.update', { url: 'https://x/a.mjs', sha256: 'a'.repeat(64), dry_run: true }).length,
0,
);
assert.ok(
check('system.agent.update', { url: 'https://x/a.mjs', sha256: 'abc', timeout_ms: 100 }).length > 0,
'超时下限 5s',
);
});
test('system.audio.get / set:参数形状与范围', () => {
assert.equal(check('system.audio.get', {}).length, 0);
assert.equal(check('system.audio.set', { mute: true }).length, 0);
assert.equal(check('system.audio.set', { volume: 35 }).length, 0);
assert.equal(check('system.audio.set', { mute: false, volume: 0 }).length, 0);
assert.ok(check('system.audio.set', { volume: 101 }).length > 0, '音量上限 100');
assert.ok(check('system.audio.set', { volume: -1 }).length > 0, '音量下限 0');
assert.ok(check('system.audio.set', { volume: '35' }).length > 0, 'volume 必须是整数');
assert.ok(check('system.audio.set', { mute: 'yes' }).length > 0, 'mute 必须是布尔');
assert.ok(check('system.audio.set', { bogus: 1 }).length > 0, '未知参数应被拒');
});
test('system.net.confirm / status:参数形状', () => {
assert.equal(check('system.net.confirm', {}).length, 0, 'task_name 可省略(取消全部)');
assert.equal(check('system.net.confirm', { task_name: 'nodeagent-netrollback-1' }).length, 0);
assert.equal(check('system.net.status', {}).length, 0);
assert.ok(check('system.net.status', { bogus: 1 }).length > 0, '未知参数应被拒');
});
// ---------- 默认值 ----------
test('applyDefaults:新能力默认值正确填充', () => {
const screenFind = applyDefaults({ text: 'x' }, findCapability('screen.find').params_schema);
assert.equal(screenFind.method, 'auto', 'screen.find 默认 auto');
const taskGet = applyDefaults({ task_id: 't' }, findCapability('system.task.get').params_schema);
assert.equal(taskGet.stream, 'stdout');
assert.equal(taskGet.offset, 0);
const exec = applyDefaults({ command: 'x' }, findCapability('system.shell.exec').params_schema);
assert.equal(exec.async, false);
assert.equal(exec.timeout_ms, 30000);
});
// ---------- v20 兼容性:能力差集 ----------
test('capabilityDiff:识别「远端较旧」与「本控制端较旧」', () => {
const local = ['system.info', 'system.audio.get', 'system.agent.update'];
const remoteOld = ['system.info'];
const d1 = capabilityDiff(local, remoteOld);
assert.deepEqual(d1.missingOnRemote, ['system.audio.get', 'system.agent.update'], '远端缺的新能力');
assert.deepEqual(d1.unknownLocally, []);
const remoteNew = ['system.info', 'system.audio.get', 'system.agent.update', 'future.thing'];
const d2 = capabilityDiff(local, remoteNew);
assert.deepEqual(d2.missingOnRemote, [], '远端不缺');
assert.deepEqual(d2.unknownLocally, ['future.thing'], '本控制端不认识的新能力');
const same = capabilityDiff(local, local);
assert.deepEqual(same, { missingOnRemote: [], unknownLocally: [] }, '完全一致应无差异');
});
test('capabilityDiff:真实清单与自身比对应无差异(防手滑改坏清单)', () => {
const names = CAPABILITY_MANIFEST.map((c) => c.name);
const d = capabilityDiff(names, names);
assert.equal(d.missingOnRemote.length, 0);
assert.equal(d.unknownLocally.length, 0);
});
// ---------- v1.5.0 快捷键扩展:新参数必须通过 schema 校验 ----------
test('input.key.press:v1.5.0 新参数(hotkey/hotkeys/preset/hold_ms/route)通过校验', () => {
assert.equal(check('input.key.press', { hotkey: 'ctrl+shift+esc' }).length, 0);
assert.equal(check('input.key.press', { hotkeys: ['ctrl+c', 'ctrl+v'] }).length, 0);
assert.equal(check('input.key.press', { preset: 'copy' }).length, 0);
assert.equal(check('input.key.press', { presets: ['copy', 'paste'] }).length, 0);
assert.equal(check('input.key.press', { hotkey: 'win+d', hold_ms: 300 }).length, 0);
assert.equal(check('input.key.press', { hotkey: 'alt+tab', route: 'post', target_pid: 4242 }).length, 0);
// 老形式仍应通过(向后兼容)
assert.equal(check('input.key.press', { keys: ['ctrl', 'c'] }).length, 0);
assert.equal(check('input.key.press', { sequence: [['up'], ['enter']] }).length, 0);
});
test('input.key.press:非法 route / 缺 target_pid 语义由 agent 层拒(schema 层只卡类型)', () => {
// route 枚举外值必须在 schema 层就被拒
assert.ok(check('input.key.press', { hotkey: 'a', route: 'sideways' }).length > 0, 'route 枚举外值应被拒');
});