CI run 38380306200(提交 73ed06f)Windows 单元测试 319/321,macOS 全绿。
① 仓库内全部版本号互一致 → 报 undefined apps/console/src-tauri/Cargo.lock
根因:actions/checkout 在 Windows 默认 core.autocrlf=true → CRLF,
而 Cargo.lock 的版本提取把 \n 写死。**同一隐患也在生产代码里**:
version-write.mjs 另有一条 name…\nversion… 正则读旧值,CRLF 下返回
undefined → 把 Cargo.lock 记入 skipped 并跳过 → 发版后滞留旧版本
(正是本模块要消灭的分叉,且不报错)。
修法:读旧值收敛到 readCargoTomlVersion / setCargoLockVersion().before
单一真源,删掉重复正则;新增 splitLineEol() 拆「正文+行尾」后改写
(不能写 ("".*)$ 兜行尾 —— JS 的 . 不匹配 \r)。
② W-01 根因守卫 → 把唯一真源自己判成违规(util\quote.ts)
根因:file.slice(dir.length+1) 在 Windows 得到反斜杠路径,与白名单
的 util/quote.ts 比不中 → 守卫自己错。
修法:新增 relPosix() 归一成 /。
新增 4 条守卫:Cargo.lock / Cargo.toml 的 CRLF 纯函数用例、
CRLF 工作区上真跑 writeVersionEverywhere 再读回文件、
以及用 path.win32 形态字符串在 macOS 上直接测 Windows 分支。
注入故障实测 4 条如期变红;test:unit 325/325(321 + 4)。
199 lines
8.7 KiB
JavaScript
199 lines
8.7 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;
|
||
}
|
||
|
||
/**
|
||
* 相对路径一律归一成 `/` 分隔 —— 否则「白名单」在 Windows 上必然比不中。
|
||
*
|
||
* ⚠️ 2026-10-13 Windows CI 实证:`file.slice(dir.length + 1)` 在 Windows 上得到
|
||
* `util\quote.ts`,与白名单里的 `util/quote.ts` 不等 → **唯一真源自己被判成违规**
|
||
* (报错原文:`以下文件手写了 PS 单引号转义:util\quote.ts`)。
|
||
* 守卫自己错(铁律 3 的第 ② 类),而 macOS 上 100% 复现不出 —— 平台差异必须收敛掉。
|
||
*/
|
||
function relPosix(dir, file) {
|
||
return file.slice(dir.length + 1).replaceAll('\\', '/');
|
||
}
|
||
|
||
/**
|
||
* 去掉注释后再扫。
|
||
*
|
||
* ⚠️ 必须去:本仓库大量注释在**讲解这个漏洞**,里面会原样出现 `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 = relPosix(AGENT_SRC, file);
|
||
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 = relPosix(AGENT_SRC, file);
|
||
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')}`);
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 守卫自身的可信度:路径归一
|
||
//
|
||
// 前面两条守卫都靠「相对路径 = 白名单里的字面量」来判断。若这一步在 Windows 上
|
||
// 比不中,守卫就会**冤枉唯一真源自己**(CI 原文:`以下文件手写了 PS 单引号转义:util\quote.ts`)。
|
||
// 这里用 `path.win32` 形态的字符串在 macOS 上直接把 Windows 分支测掉 ——
|
||
// 跨平台差异不必真上 Windows 才能验(铁律 4)。
|
||
// ---------------------------------------------------------------------------
|
||
|
||
test('守卫自身的路径归一:Windows 反斜杠必须归一成 /(否则白名单比不中)', () => {
|
||
const winDir = 'C:\\x\\apps\\agent\\src';
|
||
assert.equal(relPosix(winDir, 'C:\\x\\apps\\agent\\src\\util\\quote.ts'), 'util/quote.ts');
|
||
assert.equal(relPosix(winDir, 'C:\\x\\apps\\agent\\src\\capabilities\\network.ts'), 'capabilities/network.ts');
|
||
|
||
const posixDir = '/x/apps/agent/src';
|
||
assert.equal(relPosix(posixDir, '/x/apps/agent/src/util/quote.ts'), 'util/quote.ts');
|
||
});
|