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