一轮「静态审查 + 真机实测」的完整普查。基线 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**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
102 lines
5.0 KiB
JavaScript
102 lines
5.0 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
buildApplyCommand,
|
||
buildRollbackScript,
|
||
buildSelfCheck,
|
||
maskToPrefix,
|
||
} from '../../apps/agent/dist/capabilities/network.js';
|
||
|
||
const snap = {
|
||
alias: 'Ethernet',
|
||
dhcp: false,
|
||
ipv4: [{ ip: '192.168.1.159', prefix: 24 }],
|
||
gateway: ['192.168.1.1'],
|
||
dns: ['223.5.5.5'],
|
||
};
|
||
const args = { mode: 'static', ip: '192.168.1.88', mask: '255.255.255.0', gateway: '192.168.1.1' };
|
||
|
||
test('掩码换算:常见掩码与前缀长度互转', () => {
|
||
assert.equal(maskToPrefix('255.255.255.0'), 24);
|
||
assert.equal(maskToPrefix('255.255.0.0'), 16);
|
||
assert.equal(maskToPrefix('255.255.255.192'), 26);
|
||
assert.equal(maskToPrefix('255.255.255.255'), 32);
|
||
assert.equal(maskToPrefix('乱码'), 24, '非法输入回退 24');
|
||
});
|
||
|
||
/**
|
||
* 真机事故回归:曾经「先删旧地址 → 再加新地址」,Windows 残留的默认路由让
|
||
* New-NetIPAddress 报 already exists → 新地址没加上 → 机器掉到 APIPA 彻底失联。
|
||
* 下面几条断言就是那道防线。
|
||
*/
|
||
test('apply(static):必须先加新地址、再清旧地址(顺序不可颠倒)', () => {
|
||
const script = buildApplyCommand('static', 'Ethernet', args, snap);
|
||
const iAdd = script.indexOf('New-NetIPAddress');
|
||
const iRemove = script.indexOf('Remove-NetIPAddress');
|
||
assert.ok(iAdd > 0 && iRemove > 0, '既要加也要清');
|
||
assert.ok(iAdd < iRemove, '必须先 New-NetIPAddress 后 Remove-NetIPAddress(事故根因)');
|
||
});
|
||
|
||
test('apply(static):建路由之前必须先清掉旧默认路由(否则 already exists)', () => {
|
||
const script = buildApplyCommand('static', 'Ethernet', args, snap);
|
||
const iRmRoute = script.indexOf('Remove-NetRoute');
|
||
const iAddRoute = script.indexOf('New-NetRoute');
|
||
assert.ok(iRmRoute > 0, '必须显式清理默认路由');
|
||
assert.ok(iAddRoute > 0, '必须重建默认路由');
|
||
assert.ok(iRmRoute < iAddRoute, 'Remove-NetRoute 必须在 New-NetRoute 之前');
|
||
});
|
||
|
||
test('apply:不得使用会在 Win11 挂起的 netsh set address', () => {
|
||
const staticScript = buildApplyCommand('static', 'Ethernet', args, snap);
|
||
const dhcpScript = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
|
||
for (const s of [staticScript, dhcpScript]) {
|
||
assert.ok(!/netsh interface ipv4 set address/.test(s), 'netsh set address 在 Win11 会挂起(真机实测)');
|
||
assert.ok(!/netsh interface ipv4 set dnsservers/.test(s), 'netsh set dnsservers 同类问题');
|
||
}
|
||
assert.match(staticScript, /Set-NetIPInterface|New-NetIPAddress/, '应使用原生 cmdlet');
|
||
});
|
||
|
||
test('apply(dhcp):切 DHCP 用原生 cmdlet', () => {
|
||
const s = buildApplyCommand('dhcp', 'Ethernet', { mode: 'dhcp' }, snap);
|
||
// ⚠️ 断言方向在 v2.0.3 **翻转过**:原来写的是 `-InterfaceAlias "Ethernet"`(双引号)。
|
||
// 双引号形态正是 W-01 的根因 —— PS 双引号串会做变量插值与 `$(...)` 展开,
|
||
// 而 `JSON.stringify` 产出的 `\"` 在 PS 里根本不是转义(PS 用反引号)。
|
||
// 现在接口名统一走 `psLit()`(单引号字面量,什么都不展开)。
|
||
// 「CI 断言会过期、而且方向可能反」—— 这条就是活的例子。
|
||
assert.match(s, /Set-NetIPInterface -InterfaceAlias 'Ethernet' -Dhcp Enabled/);
|
||
assert.match(s, /Set-DnsClientServerAddress/);
|
||
assert.ok(
|
||
!/Set-NetIPInterface[^\n]*"/.test(s),
|
||
'接口名不得再出现在双引号串里(PS 双引号会插值,`${`…`}` 形同注入入口)',
|
||
);
|
||
});
|
||
|
||
test('自检:失败时自动执行回滚脚本(自愈)', () => {
|
||
const chk = buildSelfCheck('Ethernet', '192.168.1.88', 'C:/rb.ps1');
|
||
assert.match(chk, /Get-NetIPAddress/);
|
||
assert.match(chk, /192\.168\.1\.88/, '应校验目标地址是否真的生效');
|
||
assert.match(chk, /selfcheck FAILED -> auto rollback/);
|
||
// 同上:路径改为 PS 单引号字面量(原为 `& "C:/rb.ps1"`)
|
||
assert.match(chk, /& 'C:\/rb\.ps1'/, '失败必须回滚,而不是把机器留在无地址状态');
|
||
});
|
||
|
||
test('rollback:同样先加后清 + 先清路由(否则回滚自己也会失败)', () => {
|
||
const rb = buildRollbackScript(snap, 'C:/backup.txt', 'C:/rb.log');
|
||
const iAdd = rb.indexOf('New-NetIPAddress');
|
||
const iRemove = rb.indexOf('Remove-NetIPAddress');
|
||
const iRmRoute = rb.indexOf('Remove-NetRoute');
|
||
const iAddRoute = rb.indexOf('New-NetRoute');
|
||
assert.ok(iAdd < iRemove, '回滚也必须先加后清');
|
||
assert.ok(iRmRoute < iAddRoute, '回滚也必须先清路由');
|
||
assert.match(rb, /192\.168\.1\.159/, '必须恢复快照里的原地址');
|
||
assert.match(rb, /192\.168\.1\.1/, '必须恢复原网关');
|
||
assert.match(rb, /223\.5\.5\.5/, '必须恢复原 DNS');
|
||
assert.ok(!/netsh -f/.test(rb), '不得用 netsh -f 回滚(备份编码 + set address 双重问题)');
|
||
});
|
||
|
||
test('rollback:DHCP 快照走「切回 DHCP」而非设静态', () => {
|
||
const rb = buildRollbackScript({ ...snap, dhcp: true, ipv4: [] }, 'C:/backup.txt', 'C:/rb.log');
|
||
assert.match(rb, /-Dhcp Enabled/);
|
||
assert.ok(!/New-NetIPAddress/.test(rb), 'DHCP 快照不应写静态地址');
|
||
});
|