一轮「静态审查 + 真机实测」的完整普查。基线 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**(首版复用端口,
连上了上一块的残留实例,把对照组误判成「实现有缺陷」)。
新增守卫均按铁律注入故障实测:确认会变红、还原后复核一致。
225 lines
9.3 KiB
TypeScript
225 lines
9.3 KiB
TypeScript
import { closeSync, existsSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
||
import fsSync from 'node:fs';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path, { join } from 'node:path';
|
||
import { parseArgs } from 'node:util';
|
||
import {
|
||
NodeAgentClient,
|
||
loadMacroFile,
|
||
runMacro,
|
||
ClientError,
|
||
loadConfig,
|
||
saveConfig,
|
||
configPath,
|
||
toWsUrl,
|
||
loadKeys,
|
||
createKeys,
|
||
keysFilePath,
|
||
discoverOnce,
|
||
resolveTarget,
|
||
resolveNodeSelector,
|
||
emptyConfig,
|
||
type ClientConfig,
|
||
type ResolvedTarget,
|
||
type NodeProfile,
|
||
} from '@nodeagent/client';
|
||
import {
|
||
CapabilityNames,
|
||
matchPattern,
|
||
DEFAULT_DISCOVERY_PORT,
|
||
type CapabilityDescriptor,
|
||
type InvokeResult,
|
||
} from '@nodeagent/protocol';
|
||
import { type Options } from './types.js';
|
||
|
||
// 命令行参数解析(从 index.ts 拆出)
|
||
export function parseOptions(rest: string[]): { opts: Options; positionals: string[] } {
|
||
const { values, positionals } = parseArgs({
|
||
args: rest,
|
||
options: {
|
||
// ⚠️ 这两个是 L-01 同类问题的「漏网之鱼」,由 cli-options 守卫扫出(v2.0.3):
|
||
// `--help` 原来靠 positionals[0]===undefined 偶然命中;`--no-tls` 则绕过解析器
|
||
// 直接在 cmd/conn.ts 里扫 `process.argv`。两者都是「能跑,但没接进解析器」。
|
||
help: { type: 'boolean', default: false },
|
||
'no-tls': { type: 'boolean', default: false },
|
||
json: { type: 'boolean', default: false },
|
||
insecure: { type: 'boolean', default: false },
|
||
port: { type: 'string' },
|
||
key: { type: 'string' },
|
||
id: { type: 'string' },
|
||
limit: { type: 'string' },
|
||
args: { type: 'string' },
|
||
config: { type: 'boolean', default: false },
|
||
// v2 图形操作
|
||
out: { type: 'string' },
|
||
format: { type: 'string' },
|
||
scale: { type: 'string' },
|
||
region: { type: 'string' },
|
||
button: { type: 'string' },
|
||
duration: { type: 'string' },
|
||
interval: { type: 'string' },
|
||
tail: { type: 'boolean' },
|
||
compare: { type: 'boolean' },
|
||
'anchor-path': { type: 'string' },
|
||
note: { type: 'string' },
|
||
level: { type: 'string' },
|
||
target: { type: 'string' },
|
||
source: { type: 'string' },
|
||
where: { type: 'string' },
|
||
any_of: { type: 'string' },
|
||
title: { type: 'string' },
|
||
text: { type: 'string' },
|
||
process: { type: 'string' },
|
||
state: { type: 'string' },
|
||
condition: { type: 'string' },
|
||
// 媒体键定向投递的目标进程 PID(v1.2)
|
||
pid: { type: 'string' },
|
||
hold: { type: 'string' },
|
||
route: { type: 'string' },
|
||
'auth-mode': { type: 'string' },
|
||
since: { type: 'string' },
|
||
type: { type: 'string' },
|
||
'client-id': { type: 'string' },
|
||
wait: { type: 'string' },
|
||
'discovery-port': { type: 'string' },
|
||
node: { type: 'string' },
|
||
name: { type: 'string' },
|
||
recursive: { type: 'boolean', default: false },
|
||
pattern: { type: 'string' },
|
||
'create-dirs': { type: 'boolean', default: false },
|
||
'hub-token': { type: 'string' },
|
||
'hub-node': { type: 'string' },
|
||
// v7 受控重启
|
||
delay: { type: 'string' },
|
||
// v10 后台任务 / 剪贴板
|
||
'timeout-ms': { type: 'string' },
|
||
// ⚠️ L-01(v2.0.3 修):`--timeout` 曾经**没在这里声明**。因为 `strict: false`,
|
||
// 未声明选项会被当成 boolean 塞进 `values.timeout`(`true`),而它的值
|
||
// `30000` 则掉进 `positionals` —— 于是 `--timeout` 静默失效,
|
||
// 并且**污染位置参数**(可能把子命令/主机名解析错)。
|
||
// 帮助文本与 `cmd/gui.ts` 的 await 命令用的都是 `--timeout`,故必须显式声明。
|
||
timeout: { type: 'string' },
|
||
kill: { type: 'boolean', default: false },
|
||
offset: { type: 'string' },
|
||
set: { type: 'string' },
|
||
'image-file': { type: 'string' },
|
||
fps: { type: 'string' },
|
||
nodes: { type: 'string' },
|
||
path: { type: 'string' },
|
||
kind: { type: 'string' },
|
||
var: { type: 'string', multiple: true },
|
||
mode: { type: 'string' },
|
||
iface: { type: 'string' },
|
||
ip: { type: 'string' },
|
||
mask: { type: 'string' },
|
||
gateway: { type: 'string' },
|
||
dns: { type: 'string' },
|
||
command: { type: 'string' },
|
||
'confirm-within': { type: 'string' },
|
||
'task-name': { type: 'string' },
|
||
yes: { type: 'boolean', default: false },
|
||
url: { type: 'string' },
|
||
sha256: { type: 'string' },
|
||
'dry-run': { type: 'boolean', default: false },
|
||
psk: { type: 'boolean', default: false },
|
||
'discover-secret': { type: 'string' },
|
||
'forget-cert': { type: 'boolean', default: false },
|
||
check: { type: 'boolean', default: false },
|
||
force: { type: 'boolean', default: false },
|
||
mute: { type: 'string' },
|
||
volume: { type: 'string' },
|
||
seconds: { type: 'string' },
|
||
'interval-ms': { type: 'string' },
|
||
},
|
||
allowPositionals: true,
|
||
strict: false,
|
||
});
|
||
return {
|
||
opts: {
|
||
help: Boolean(values['help']),
|
||
noTls: Boolean(values['no-tls']),
|
||
json: Boolean(values['json']),
|
||
insecure: Boolean(values['insecure']),
|
||
port: values['port'] as string | undefined,
|
||
key: values['key'] as string | undefined,
|
||
id: values['id'] as string | undefined,
|
||
limit: values['limit'] as string | undefined,
|
||
args: values['args'] as string | undefined,
|
||
out: values['out'] as string | undefined,
|
||
format: values['format'] as string | undefined,
|
||
scale: values['scale'] as string | undefined,
|
||
region: values['region'] as string | undefined,
|
||
button: values['button'] as string | undefined,
|
||
duration: values['duration'] as string | undefined,
|
||
interval: values['interval'] as string | undefined,
|
||
tail: values['tail'] as boolean | undefined,
|
||
compare: values['compare'] as boolean | undefined,
|
||
anchorPath: values['anchor-path'] as string | undefined,
|
||
note: values['note'] as string | undefined,
|
||
level: values['level'] as string | undefined,
|
||
target: values['target'] as string | undefined,
|
||
source: values['source'] as string | undefined,
|
||
where: values['where'] as string | undefined,
|
||
any_of: values['any_of'] as string | undefined,
|
||
condition: values['condition'] as string | undefined,
|
||
state: values['state'] as string | undefined,
|
||
process: values['process'] as string | undefined,
|
||
text: values['text'] as string | undefined,
|
||
title: values['title'] as string | undefined,
|
||
pid: values['pid'] as string | undefined,
|
||
hold: values['hold'] as string | undefined,
|
||
route: values['route'] as string | undefined,
|
||
authMode: values['auth-mode'] as string | undefined,
|
||
since: values['since'] as string | undefined,
|
||
type: values['type'] as string | undefined,
|
||
clientId: values['client-id'] as string | undefined,
|
||
wait: values['wait'] as string | undefined,
|
||
discoveryPort: values['discovery-port'] as string | undefined,
|
||
node: values['node'] as string | undefined,
|
||
delay: values['delay'] as string | undefined,
|
||
// 两个写法都接受:`--timeout` 是帮助文本/`await` 命令里的正式写法,
|
||
// `--timeout-ms` 是早期在 `bg` 命令里引入的别名。二者同时给时以 `--timeout` 为准。
|
||
timeoutMs: (values['timeout'] ?? values['timeout-ms']) as string | undefined,
|
||
kill: Boolean(values['kill']),
|
||
offset: values['offset'] as string | undefined,
|
||
set: values['set'] as string | undefined,
|
||
imageFile: values['image-file'] as string | undefined,
|
||
fps: values['fps'] as string | undefined,
|
||
nodes: values['nodes'] as string | undefined,
|
||
path: values['path'] as string | undefined,
|
||
kind: values['kind'] as string | undefined,
|
||
vars: (values['var'] as string[] | undefined) ?? [],
|
||
mode: values['mode'] as string | undefined,
|
||
iface: values['iface'] as string | undefined,
|
||
ip: values['ip'] as string | undefined,
|
||
mask: values['mask'] as string | undefined,
|
||
gateway: values['gateway'] as string | undefined,
|
||
dns: values['dns'] as string | undefined,
|
||
command: values['command'] as string | undefined,
|
||
confirmWithin: values['confirm-within'] as string | undefined,
|
||
taskName: values['task-name'] as string | undefined,
|
||
yes: Boolean(values['yes']),
|
||
url: values['url'] as string | undefined,
|
||
sha256: values['sha256'] as string | undefined,
|
||
dryRun: Boolean(values['dry-run']),
|
||
psk: Boolean(values['psk']),
|
||
discoverSecret: values['discover-secret'] as string | undefined,
|
||
forgetCert: Boolean(values['forget-cert']),
|
||
check: Boolean(values['check']),
|
||
force: Boolean(values['force']),
|
||
mute: values['mute'] as string | undefined,
|
||
volume: values['volume'] as string | undefined,
|
||
seconds: values['seconds'] as string | undefined,
|
||
intervalMs: values['interval-ms'] as string | undefined,
|
||
name: values['name'] as string | undefined,
|
||
recursive: Boolean(values['recursive']),
|
||
pattern: values['pattern'] as string | undefined,
|
||
createDirs: Boolean(values['create-dirs']),
|
||
hubToken: values['hub-token'] as string | undefined,
|
||
hubNode: values['hub-node'] as string | undefined,
|
||
},
|
||
positionals,
|
||
};
|
||
}
|