Files
nodeagent/tests/unit/quote.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

169 lines
7.0 KiB
JavaScript
Raw Permalink 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.
/**
* 安全字面量(`util/quote.ts`)的行为守卫 + W-01 根因的**源码形态**守卫。
*
* ⚠️ 背景(P0,真机实测 2026-10-13):`window.focus` 把 `JSON.stringify(title)` 插进
* PowerShell 双引号串,payload 直接执行了任意命令 —— 而接口**还返回「正常的业务错误」**,
* 调用方完全看不出异常(最险的正是「静默」)。
* 根因有两条:PS 里 `\` 不是转义符;PS 双引号串会插值 `$(...)`。
* 所以本文件测两件事:
* ① `psLit`/`shLit` 的行为(唯一正确的字面量构造方式);
* ② 全仓**不得**再把 `${JSON.stringify(…)}` 拼进命令串(形态守卫,防止回归)。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
psLit,
psLitArray,
shLit,
assertSingleQuotedLiteral,
isSafeBareWindowsPathArg,
} from '../../apps/agent/dist/util/quote.js';
const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(here, '..', '..');
const AGENT_SRC = join(repoRoot, 'apps', 'agent', 'src');
// ---------------------------------------------------------------------------
// ① 行为
// ---------------------------------------------------------------------------
test('psLit:单引号翻倍,其余字符($ / 反引号 / 反斜杠)一律原样保留', () => {
assert.equal(psLit("a'b"), "'a''b'");
// 关键:**不做**任何反斜杠转义(PS 里 `\` 不是转义符,转了反而是错的)
assert.equal(psLit('C:\\tmp\\a'), "'C:\\tmp\\a'");
// $ 与反引号在单引号串里不展开 —— 这是选择单引号的全部理由
assert.equal(psLit('$env:PATH'), "'$env:PATH'");
assert.equal(psLit('`n`t'), "'`n`t'");
// 每个单引号都成对,形状自检通过
assert.ok(assertSingleQuotedLiteral(psLit("x'; New-Item -Path /tmp/pwned; '")));
});
test('psLit:W-01 的原始 payload 不再能逃出字符串', () => {
const payload = 'x"; New-Item -Path C:\\pwned -ItemType File; #';
const lit = psLit(payload);
// 形状:首尾单引号,且内部没有落单的单引号
assert.ok(lit.startsWith("'") && lit.endsWith("'"));
assert.ok(assertSingleQuotedLiteral(lit), `形状自检应通过:${lit}`);
// 语义:把外层单引号剥掉,必须**一字不差**地还原原串 —— 说明没有任何字符被吞掉/被当语法
assert.equal(lit.slice(1, -1), payload);
// payload 里的 `"` 是被关在单引号里的普通字符;PS 单引号串不做插值与子表达式展开,
// 所以 `$(…)` 这种形态也必须原样保留(这才是「逃不出去」的真正判据)
const cmdSub = '$(touch /tmp/pwned)';
assert.equal(psLit(cmdSub).slice(1, -1), cmdSub);
});
test('shLit:把单引号拆成 闭合→转义→重开,单引号内无任何展开', () => {
assert.equal(shLit("a'b"), "'a'\\''b'");
assert.equal(shLit('$(touch /tmp/pwned)'), "'$(touch /tmp/pwned)'");
assert.equal(shLit('`id`'), "'`id`'");
});
test('psLitArray:数组字面量(空数组产出 @())', () => {
assert.equal(psLitArray(['a', "b'c"]), "@('a','b''c')");
assert.equal(psLitArray([]), '@()');
});
test('assertSingleQuotedLiteral:未翻倍的单引号必须被判为不合法', () => {
assert.ok(assertSingleQuotedLiteral("'a''b'"));
// `'a'b'` 内部有一个落单的 `'` → 会把字符串提前闭合
assert.equal(assertSingleQuotedLiteral("'a'b'"), false);
assert.equal(assertSingleQuotedLiteral('a'), false);
assert.equal(assertSingleQuotedLiteral("'a"), false);
});
test('isSafeBareWindowsPathArg:正常临时路径可裸拼,含元字符的必须被拒', () => {
assert.ok(isSafeBareWindowsPathArg('C:\\Users\\u\\AppData\\Local\\Temp\\na-ps-x1\\run.ps1'));
assert.ok(isSafeBareWindowsPathArg('/tmp/na-ps-x1/run.ps1'));
for (const bad of [
'C:\\a"b\\run.ps1',
"C:\\a'b\\run.ps1",
'C:\\a&b\\run.ps1',
'C:\\a|b\\run.ps1',
'C:\\a%b\\run.ps1',
'C:\\a$b\\run.ps1',
'C:\\a`b\\run.ps1',
'C:\\a<b\\run.ps1',
'C:\\dir\\',
'C:\\a\nb\\run.ps1',
'',
]) {
assert.equal(isSafeBareWindowsPathArg(bad), false, `应判为不安全:${JSON.stringify(bad)}`);
}
});
// ---------------------------------------------------------------------------
// ② 形态守卫:全仓不得再把 JSON.stringify 拼进命令串
// ---------------------------------------------------------------------------
function walk(dir) {
const out = [];
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) out.push(...walk(p));
else if (p.endsWith('.ts')) out.push(p);
}
return out;
}
/**
* 去掉注释后再扫。
*
* ⚠️ 必须去:本仓库大量注释在**讲解这个漏洞**,里面会原样出现 `replace(/'/g, "''")`
* 这种形态。不去注释就会把「讲解」当成「犯案」——守卫自己错(铁律 3 的第 ② 类)。
* 局限:只处理整行 `//` 与块注释;行尾 `//` 后面的内容仍会被扫到(宁可多报不漏报)。
*/
function stripComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
}
test('W-01 根因守卫:源文件里不得出现 `${JSON.stringify(…)}` 拼进命令串', () => {
// 命令串的判据词:出现任一,说明这一行在构造要交给 shell / PowerShell 的文本
const SHELL_MARKERS = [
'powershell',
'-Command',
'-File ',
'osascript',
'screencapture',
'winget',
'taskkill',
'netstat',
'tasklist',
'cmd /c',
'sh -c',
];
const offenders = [];
for (const file of walk(AGENT_SRC)) {
const rel = file.slice(AGENT_SRC.length + 1);
const lines = stripComments(readFileSync(file, 'utf8')).split('\n');
lines.forEach((line, i) => {
if (!line.includes('${JSON.stringify(')) return;
if (!SHELL_MARKERS.some((m) => line.includes(m))) return;
offenders.push(`${rel}:${i + 1} ${line.trim()}`);
});
}
assert.deepEqual(
offenders,
[],
`发现把 JSON.stringify 结果拼进命令串(W-01 同类,PS 里 \\ 不是转义符):\n${offenders.join('\n')}`,
);
});
test('W-01 根因守卫:PowerShell 单引号字面量必须来自 util/quote.ts(唯一真源)', () => {
// 允许的实现文件(它们就是那份唯一真源 / 明确 re-export)
const ALLOWED = new Set(['util/quote.ts', 'capabilities/network.ts']);
const offenders = [];
for (const file of walk(AGENT_SRC)) {
const rel = file.slice(AGENT_SRC.length + 1);
if (ALLOWED.has(rel)) continue;
const text = stripComments(readFileSync(file, 'utf8'));
// 手写 `replace(/'/g, "''")` 就是「又发明了一份 psLit」——两份实现必然漂移
if (/replace\(\s*\/'\s*\/g\s*,\s*["'`]{1,2}''["'`]{1,2}\s*\)/.test(text)) {
offenders.push(rel);
}
}
assert.deepEqual(offenders, [], `以下文件手写了 PS 单引号转义,应改用 util/quote.ts:\n${offenders.join('\n')}`);
});