Files
nodeagent/tests/unit/audit-filter.test.mjs
T
jkooe ae02252a1d fix(agent): 修复 18 条实机复现缺陷(2×P0 / 5×P1 / 10×P2 / 1×P3)
一轮「静态审查 + 真机实测」的完整普查。基线 v2.0.2 / ad90634 上 unit 291 + e2e 30
全绿 —— 也就是说 18 条**全部落在既有覆盖之外**。报告见 bug-report-2026-10-13.html。

P0 两条:
- W-01:window.focus 把 JSON.stringify(title) 插进 PowerShell **双引号**串(PS 里 `\` 不是
  转义符、双引号串还会展开 $(...))→ 命令注入。真机实测最险的是它**静默**:接口返回的是
  「正常业务错误」E_EXECUTION_FAILED: 未找到匹配的窗口,而 payload 已经把文件创建出来了。
  新增 apps/agent/src/util/quote.ts 作 psLit/shLit 单一真源,14 处注入点收敛(window.focus /
  uiaFind / ocrFind / system.ts 计划任务参数 / ps-helper.ts 的 -File 裸拼 …)。
- A-01:ACL 省略 clients 时 aclPolicy.clients.find 抛错,而这段落在 if (hasAcl) 块**之外**
  无条件执行 → 配置少写一个字段,每条 invoke 都崩掉被控端。
  ⚠️ 静态审查当时判它是「fail-open 放行」,实测是崩溃(更严重)。

崩溃链共同根因:ws.on('message') 用 void handleMessage() 且无 .catch,全仓又没有任何
process.on('unhandledRejection') —— Node ≥22 遇未处理 rejection 直接终止进程。两条畸形报文
(hello {protocol:123} / auth {hmac:1})就能在**认证之前**打死被控端(verifyNonce 有 try/catch,
psk 默认路径的 verifyHmac 没有)。index.ts 新增进程级兜底:日志每次打、审计按 10s 限流
(否则畸形报文可高频刷爆 audit.log),且明确不退出进程(可用性优先,取舍写在注释里)。

其余 15 条:
- clients: [] → fail-open 全放行(判据改为「对象是否存在」而非「数组长度」)
- macOS screen.find 的 text 拼 bash → $(...) 实测执行(走 shLit)
- 控制台审计筛选项与协议 enum 漂移,除 invoke 外全 E_PARAM_INVALID
  → 新增 packages/protocol/src/audit-types.ts 作唯一真源,manifest enum 由它派生 + 编译期双向守卫
- monitor.start 的 target 注入、monitor id 路径穿越(读/删)→ psLit + guardPath
- keys{} + client_id:"constructor" 崩 → hasOwnProperty(原型链取值)
- 事件订阅跨控制端可见/可删、event.watch 绕开 fs_roots → 有状态资源落 owner + 统一白名单
- screen.capture 的 region 无上界 → assertRegionSane() 且**提前到枚举显示器之前**(fail fast)
- ipMatchesCidr('8.8.8.8','10.0.0.0/') → true(空掩码被 Number('') === 0 当 /0)
- CLI --timeout 静默失效(strict:false 下未声明选项被当 boolean,值掉进 positionals)
- 拒绝类事件不写审计 → 补 request.rejected / agent.error

回归:unit 321/321(+21)、e2e 33/33(+3 组硬化用例)、rust 11/11。
e2e 新增用例的姿势:每场景**独占端口 + 独立临时 NODEAGENT_HOME**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
2026-10-13 17:16:36 +08:00

126 lines
5.8 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.
/**
* C-01 守卫:审计筛选项不得与协议 enum 漂移。
*
* ⚠️ 背景(真机实测 2026-10-13,P1):控制台下拉框列了 11 个**细粒度**取值
* (`auth.success` / `acl.denied` / …),而 manifest 的 `enum` 只有
* `['invoke','auth','acl','agent']` → 被控端 schema 校验把**除 `invoke` 外**的取值
* 全部判成 `E_PARAM_INVALID`。下拉框形同虚设,而且**不报类型错**
* (和「静默空列」同一类病:类型系统抓不到,只有把真源钉死才防得住)。
*
* 真源:`packages/protocol/src/audit-types.ts` 的 `AUDIT_TYPES` / `AUDIT_TYPE_PREFIXES`。
* 本文件把三处一致性都钉住:真源 ↔ manifest enum ↔ 控制台下拉框;
* 另外用真实 `readAudit()` 验证「过滤是按前缀匹配」这一被依赖的语义。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, mkdtempSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import {
AUDIT_TYPES,
AUDIT_TYPE_PREFIXES,
AUDIT_FILTER_TYPES,
findCapability,
} from '../../packages/protocol/dist/index.js';
import { initAudit, audit, readAudit } from '../../apps/agent/dist/audit.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const AUDIT_VIEW = join(repoRoot, 'apps', 'console', 'src', 'views', 'AuditView.vue');
/** 从 AuditView.vue 的 `typeOptions` 里取出全部非空 `value:` 字面量。 */
function consoleTypeOptions() {
const text = readFileSync(AUDIT_VIEW, 'utf8');
const start = text.indexOf('const typeOptions');
assert.ok(start >= 0, 'AuditView.vue 里找不到 typeOptions');
const end = text.indexOf('];', start);
assert.ok(end > start, 'typeOptions 数组未正常结束');
const block = text.slice(start, end);
return [...block.matchAll(/value:\s*"([^"]*)"/g)].map((m) => m[1]).filter((v) => v !== '');
}
test('C-01:协议真源自洽(精确类型 ∪ 粗粒度前缀,去重)', () => {
assert.equal(new Set(AUDIT_TYPES).size, AUDIT_TYPES.length, 'AUDIT_TYPES 不得有重复项');
const expect = Array.from(new Set([...AUDIT_TYPES, ...AUDIT_TYPE_PREFIXES]));
assert.deepEqual([...AUDIT_FILTER_TYPES].sort(), expect.sort());
});
test('C-01:manifest 的 enum 与真源完全一致', () => {
const cap = findCapability('system.audit.list');
assert.ok(cap, '找不到 system.audit.list');
const enumVals = cap.params_schema.properties.type.enum;
assert.deepEqual(
[...enumVals].sort(),
[...AUDIT_FILTER_TYPES].sort(),
'manifest enum 与 AUDIT_FILTER_TYPES 漂移 —— 曾经手写成 4 个值,导致控制台筛选静默失效',
);
});
test('C-01:每个审计类型都必须能被至少一个合法筛选值命中(没有筛不到的事件)', () => {
for (const t of AUDIT_TYPES) {
const hit = AUDIT_FILTER_TYPES.some((f) => t.startsWith(f));
assert.ok(hit, `审计类型 ${t} 无法被任何合法筛选值命中 —— 用户会以为它从未发生`);
}
});
test('C-01:每个粗粒度前缀都必须真能命中至少一个类型(不得有「永远为空」的选项)', () => {
for (const p of AUDIT_TYPE_PREFIXES) {
const hits = AUDIT_TYPES.filter((t) => t.startsWith(p));
assert.ok(hits.length > 0, `前缀 ${p} 命中不了任何审计类型 —— 下拉框里会是个永远为空的选项`);
}
});
test('C-01:控制台下拉框取值 ⊆ 真源,且覆盖全部粗粒度前缀', () => {
const values = consoleTypeOptions();
assert.ok(values.length > 0, '未能从 AuditView.vue 解析出 typeOptions(解析规则需同步更新)');
for (const v of values) {
assert.ok(
AUDIT_FILTER_TYPES.includes(v),
`控制台选项 ${v} 不在 AUDIT_FILTER_TYPES 里 —— 会被判 E_PARAM_INVALID(C-01 复发)`,
);
}
for (const p of AUDIT_TYPE_PREFIXES) {
assert.ok(values.includes(p), `控制台下拉框缺少粗粒度前缀 ${p}`);
}
});
test('C-01:readAudit 的 type 过滤确实是前缀匹配(控制台粗粒度分组依赖它)', () => {
const dir = mkdtempSync(join(tmpdir(), 'na-audit-filter-'));
initAudit({ enabled: true, logArgs: false }, dir);
audit({ type: 'auth.success', client_id: 'a' });
audit({ type: 'auth.failure', client_id: 'a' });
audit({ type: 'invoke', capability: 'system.info' });
audit({ type: 'acl.denied', capability: 'fs.read' });
audit({ type: 'request.rejected', reason: '畸形报文' });
audit({ type: 'agent.start' });
const auth = readAudit({ type: 'auth', limit: 100 });
assert.equal(auth.entries.length, 2, `auth 应命中 auth.success + auth.failure,实际 ${auth.entries.length}`);
assert.ok(auth.entries.every((e) => e.type.startsWith('auth')));
const agent = readAudit({ type: 'agent', limit: 100 });
assert.ok(
agent.entries.some((e) => e.type === 'agent.start'),
'agent 前缀应命中 agent.start',
);
const exact = readAudit({ type: 'acl.denied', limit: 100 });
assert.equal(exact.entries.length, 1, '精确类型也应可用(真实类型本身就是自己的前缀)');
const rejected = readAudit({ type: 'request', limit: 100 });
assert.equal(rejected.entries.length, 1, 'request 前缀应命中 request.rejected(U-01 新增类型必须可筛)');
const all = readAudit({ limit: 100 });
assert.equal(all.entries.length, 6, '不带 type 时应返回全部');
});
test('C-01:U-01 的 request.rejected 与兜底的 agent.error 都在真源里', () => {
assert.ok(AUDIT_TYPES.includes('request.rejected'), '被拒请求必须可被筛选(否则 U-01 等于白修)');
assert.ok(AUDIT_TYPES.includes('agent.error'), '进程级兜底错误必须可被筛选');
for (const t of ['request.rejected', 'agent.error']) {
assert.ok(AUDIT_FILTER_TYPES.includes(t));
}
});