一轮「静态审查 + 真机实测」的完整普查。基线 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**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
126 lines
5.8 KiB
JavaScript
126 lines
5.8 KiB
JavaScript
/**
|
||
* 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));
|
||
}
|
||
});
|