P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道; clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/ Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线 P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块 (web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate); run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭 P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal, 超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/ getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一 P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新 (app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON); web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher) 测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/ filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/ OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
544 lines
22 KiB
TypeScript
544 lines
22 KiB
TypeScript
/**
|
||
* 命令工具(1 个)
|
||
*
|
||
* run_command — 在沙箱环境中执行 Shell 命令
|
||
*
|
||
* 安全增强(v0.2.0):
|
||
* 1. 接入 SandboxManager.scanCode 进行静态代码安全扫描
|
||
* 2. 通过 SandboxManager.validatePath 校验工作目录
|
||
* 3. 命令注入模式检测扩展
|
||
*
|
||
* C-4 修复(v0.3.1):双层命令注入防御
|
||
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
|
||
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法
|
||
*
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — 9 个基础工具 + run_command 安全规则
|
||
*/
|
||
|
||
import { exec, execFile } from 'child_process';
|
||
import { promisify } from 'util';
|
||
import { resolve } from 'path';
|
||
import { parse as shellQuoteParse } from 'shell-quote';
|
||
import log from 'electron-log';
|
||
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
|
||
import type { MetonaToolDef } from '../../../harness/types';
|
||
import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types';
|
||
import { commandTouchesProtectedFile, isPathWithinWorkspace } from './file-guard';
|
||
import type { SandboxManager } from '../../sandbox/sandbox';
|
||
|
||
const execAsync = promisify(exec);
|
||
const execFileAsync = promisify(execFile);
|
||
|
||
// ===== Windows 编码智能解码 =====
|
||
// 某些 Windows 程序(dir、systeminfo、ipconfig 等)不响应 chcp 65001,
|
||
// 仍按系统代码页(GBK/CP936)输出字节流。
|
||
// 策略:先尝试严格 UTF-8 解码,失败则用 GBK 解码,最后降级为 UTF-8 宽松模式。
|
||
function decodeBuffer(buf: Buffer): string {
|
||
if (buf.length === 0) return '';
|
||
try {
|
||
// 严格模式:包含非法 UTF-8 字节序列时抛错
|
||
return new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
||
} catch {
|
||
// UTF-8 解码失败 → 用 GBK/CP936 解码(Windows 中文系统默认代码页)
|
||
try {
|
||
return new TextDecoder('gbk').decode(buf);
|
||
} catch {
|
||
// 最终降级:UTF-8 宽松模式(用 替换字符 代替非法字节)
|
||
return buf.toString('utf-8');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* #9 修复 + 审查修复: 构建安全的子进程环境变量
|
||
*
|
||
* 审查修复: 原白名单方案遗漏了 GIT_* / PYTHONPATH / HTTP_PROXY 等常用变量,导致子进程功能破坏。
|
||
* 改为黑名单方案:剔除包含敏感后缀的变量,保留其余。
|
||
*/
|
||
function buildSafeCommandEnv(isWindows: boolean): Record<string, string> {
|
||
// 敏感变量后缀黑名单
|
||
const SENSITIVE_SUFFIXES = [
|
||
'_API_KEY',
|
||
'_TOKEN',
|
||
'_SECRET',
|
||
'_PASSWORD',
|
||
'_PASSWD',
|
||
'_CREDENTIAL',
|
||
'_CREDENTIALS',
|
||
'_PRIVATE_KEY',
|
||
];
|
||
// 敏感变量名黑名单(精确匹配)
|
||
const SENSITIVE_KEYS = new Set([
|
||
'DEEPSEEK_API_KEY',
|
||
'AGNES_API_KEY',
|
||
'MIMO_API_KEY',
|
||
'GITEA_PASSWORD',
|
||
'DATABASE_PASSWORD',
|
||
]);
|
||
|
||
const env: Record<string, string> = {};
|
||
for (const [key, val] of Object.entries(process.env)) {
|
||
if (!val) continue;
|
||
if (SENSITIVE_KEYS.has(key)) continue;
|
||
if (SENSITIVE_SUFFIXES.some((suffix) => key.toUpperCase().endsWith(suffix))) continue;
|
||
env[key] = val;
|
||
}
|
||
|
||
// 添加必要的运行时变量
|
||
env.NODE_ENV = 'production';
|
||
if (isWindows) {
|
||
env.PYTHONIOENCODING = 'utf-8';
|
||
env.LANG = 'zh_CN.UTF-8';
|
||
env.LC_ALL = 'zh_CN.UTF-8';
|
||
}
|
||
|
||
return env;
|
||
}
|
||
|
||
/**
|
||
* v0.4.1: Windows 白名单命令集合 — 这些工具的简单命令(无 shell 运算符)走
|
||
* execFile('cmd.exe', ['/c', ...words]) 执行:参数以数组形式显式传递,不经过
|
||
* shell 解析,从根本上去掉 exec() 的字符串拼接注入面(无法通过参数注入新命令)。
|
||
*
|
||
* 仅收录最常见的开发工具(小步灰度);其余命令仍走 exec + 双层校验的既有路径。
|
||
* Node 18.20+/Electron 35 在 Windows 上直接 spawn .cmd 批处理会被拒绝(EINVAL),
|
||
* 因此必须通过 cmd.exe /c 中转,但参数分离已足够收窄注入面。
|
||
*/
|
||
const WINDOWS_EXEC_FILE_WHITELIST = new Set(['git', 'node', 'npm', 'npx', 'pnpm', 'yarn', 'tsc']);
|
||
|
||
/**
|
||
* v0.6.4 修复(cmd.exe /c 白名单通道元字符守门):
|
||
*
|
||
* 缺口:shell-quote 解析会把引号包裹的字符还原为普通 word —— 例如
|
||
* `git config --set "x&whoami"` 中含 & 的参数若不含空格,libuv 合成 Windows 命令行时
|
||
* 只对"含空格"的参数加引号;该无空格参数原样拼进 cmd.exe 命令行后被当作命令分隔符,
|
||
* `&whoami` 部分会被 cmd 真实执行(命令注入)。
|
||
*
|
||
* 守门规则:白名单通道仅接受任何位置都不含 cmd.exe 元字符 [& | ^ < > % " 换行] 的
|
||
* 参数;命中即放弃 execFile('cmd.exe') 通道,降级回 exec 整串路径
|
||
* (该路径仍有 SandboxManager.scanCode + validateCommand 双层校验与确认弹窗兜底)。
|
||
*/
|
||
export function argsSafeForCmdExecChannel(args: string[]): boolean {
|
||
return !args.some((arg) => /[&|^<>%"\r\n]/.test(arg));
|
||
}
|
||
|
||
/** v0.4.1: 提取命令 basename(处理 C:\Program Files\nodejs\npm.cmd 等路径形式) */
|
||
function commandBasename(cmd: string): string {
|
||
const base = cmd.split(/[\\/]/).pop() ?? cmd;
|
||
// 去掉 .exe/.cmd/.bat 扩展名(大小写不敏感)
|
||
return base.replace(/\.(exe|cmd|bat)$/i, '');
|
||
}
|
||
|
||
// ===== 9. run_command =====
|
||
|
||
export class RunCommandTool implements IMetonaTool {
|
||
readonly definition: MetonaToolDef = {
|
||
name: 'run_command',
|
||
description:
|
||
'Execute a shell command in a sandboxed environment. Commands run in the workspace directory. High-risk commands require user confirmation. Passes through SandboxManager static code scan and path validation.',
|
||
parameters: {
|
||
type: 'object',
|
||
properties: {
|
||
command: { type: 'string', description: 'Shell command to execute' },
|
||
workdir: { type: 'string', description: 'Working directory (default: workspace root)' },
|
||
timeout: { type: 'number', description: 'Timeout in milliseconds (default 120000)' },
|
||
},
|
||
required: ['command'],
|
||
},
|
||
category: MetonaToolCategory.CODE_EXECUTION,
|
||
riskLevel: MetonaRiskLevel.HIGH,
|
||
requiresPermission: true,
|
||
timeoutMs: 120_000,
|
||
};
|
||
|
||
/** v0.2.0: 可选注入 SandboxManager 进行双重安全校验 */
|
||
private sandboxManager: SandboxManager | null = null;
|
||
|
||
/** 注入 SandboxManager(由 main.ts 在注册时调用) */
|
||
setSandboxManager(manager: SandboxManager): void {
|
||
this.sandboxManager = manager;
|
||
}
|
||
|
||
async execute(args: Record<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
const command = args.command as string;
|
||
const workdir = (args.workdir as string) ?? context.workspacePath;
|
||
const timeout = Math.min(300_000, Math.max(1_000, (args.timeout as number) ?? 120_000));
|
||
|
||
// 安全校验:workdir 必须在工作空间内
|
||
const resolvedWorkdir = resolve(context.workspacePath, workdir);
|
||
if (!isPathWithinWorkspace(workdir, context.workspacePath)) {
|
||
return {
|
||
success: false,
|
||
error: `Working directory must be within workspace: ${workdir}`,
|
||
command,
|
||
};
|
||
}
|
||
|
||
// v0.2.0: SandboxManager 双重安全校验 — fail-closed 设计
|
||
// 若 SandboxManager 未注入(配置错误),拒绝执行高风险命令
|
||
if (!this.sandboxManager) {
|
||
log.error('[RunCommandTool] SandboxManager not injected — refusing to execute (fail-closed)');
|
||
return {
|
||
success: false,
|
||
error: 'Security sandbox unavailable — command execution disabled',
|
||
command,
|
||
};
|
||
}
|
||
|
||
// SandboxManager 静态代码扫描
|
||
const scanResult = this.sandboxManager.scanCode(command);
|
||
if (!scanResult.safe) {
|
||
return { success: false, error: 'Command blocked by security policy', command };
|
||
}
|
||
// SandboxManager 路径校验
|
||
const pathResult = this.sandboxManager.validatePath(resolvedWorkdir);
|
||
if (!pathResult.allowed) {
|
||
return { success: false, error: 'Command blocked by security policy', command };
|
||
}
|
||
|
||
// 命令安全校验(内置硬阻止列表)
|
||
const validation = this.validateCommand(command);
|
||
if (!validation.allowed) {
|
||
return { success: false, error: validation.reason, command };
|
||
}
|
||
|
||
try {
|
||
// Windows 中文编码修复:通过 chcp 65001 切换控制台到 UTF-8 代码页
|
||
// 避免 GBK/CP936 输出被 Node.js 按 UTF-8 解码导致中文乱码
|
||
const isWindows = process.platform === 'win32';
|
||
|
||
// #8 修复: 优先使用 execFile(不经过 shell,从根本上防止命令注入)
|
||
// 仅当命令为简单命令(无管道、重定向、&& 等 shell 运算符)时使用 execFile
|
||
// 复杂命令(含 shell 语法)降级到 exec(已有 SandboxManager + validateCommand 双层校验)
|
||
const simpleCmd = this.parseCommandSimple(command);
|
||
|
||
// 使用 encoding: 'buffer' 获取原始字节,手动智能解码
|
||
// 部分程序(如 dir、systeminfo)不响应 chcp 65001,仍输出 GBK 字节流
|
||
// #9 修复: 不透传完整 process.env,仅保留子进程运行所需的最小环境变量集合
|
||
// 防止 API keys / tokens / passwords 通过环境变量泄露给子进程
|
||
const execEnv = buildSafeCommandEnv(isWindows);
|
||
const execOpts = {
|
||
cwd: resolvedWorkdir,
|
||
timeout,
|
||
maxBuffer: 1024 * 1024, // 1MB
|
||
encoding: 'buffer' as const, // 返回 Buffer 而非字符串,便于智能解码
|
||
env: execEnv,
|
||
// P0-4: 用户中断(引擎 abort)时终止子进程,防止命令在后台继续执行
|
||
signal: context.signal,
|
||
};
|
||
|
||
let stdout: Buffer;
|
||
let stderr: Buffer;
|
||
|
||
// #8 修复 + 审查修复: 简单命令使用 execFile(不经过 shell,防止命令注入)
|
||
// 但 Windows 上 npm/npx/yarn/pnpm/tsc 等是 .cmd 批处理,execFile 无法直接执行(ENOENT/EINVAL)
|
||
// v0.4.1: Windows 上白名单工具(git/node/npm/npx/pnpm/yarn/tsc)的简单命令改用
|
||
// execFile('cmd.exe', ['/c', ...args]) — 参数显式分离传递,不经 shell 字符串解析,
|
||
// 相比 exec() 的整串拼接显著收窄注入面
|
||
// 非 Windows 上对简单命令直接 execFile
|
||
if (simpleCmd && !isWindows) {
|
||
const result = await execFileAsync(simpleCmd.command, simpleCmd.args, execOpts);
|
||
stdout = result.stdout;
|
||
stderr = result.stderr;
|
||
} else if (
|
||
simpleCmd &&
|
||
isWindows &&
|
||
WINDOWS_EXEC_FILE_WHITELIST.has(commandBasename(simpleCmd.command)) &&
|
||
// v0.6.4 元字符守门:参数含 cmd.exe 元字符时本通道会被命令行合成规则
|
||
// 撕开注入口(见 argsSafeForCmdExecChannel 注释),降级 exec 路径
|
||
argsSafeForCmdExecChannel(simpleCmd.args)
|
||
) {
|
||
// v0.4.1: 白名单工具通过 cmd.exe /c + 参数数组执行(参数不经 shell 解析)
|
||
const result = await execFileAsync(
|
||
'cmd.exe',
|
||
['/c', simpleCmd.command, ...simpleCmd.args],
|
||
execOpts,
|
||
);
|
||
stdout = result.stdout;
|
||
stderr = result.stderr;
|
||
} else {
|
||
// 复杂命令(含管道/重定向/&& 等 shell 语法)或 Windows — 使用 exec
|
||
// 已有 SandboxManager.scanCode + validateCommand 双层安全校验
|
||
const finalCommand = isWindows ? `chcp 65001 >nul 2>&1 && ${command}` : command;
|
||
const result = await execAsync(finalCommand, execOpts);
|
||
stdout = result.stdout;
|
||
stderr = result.stderr;
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
stdout: decodeBuffer(stdout).slice(0, 50_000),
|
||
stderr: decodeBuffer(stderr).slice(0, 10_000),
|
||
command,
|
||
};
|
||
} catch (error) {
|
||
const err = error as { message: string; code?: number; stdout?: Buffer; stderr?: Buffer };
|
||
return {
|
||
success: false,
|
||
error: err.message,
|
||
exit_code: err.code,
|
||
stdout: decodeBuffer(err.stdout ?? Buffer.alloc(0)).slice(0, 10_000),
|
||
stderr: decodeBuffer(err.stderr ?? Buffer.alloc(0)).slice(0, 10_000),
|
||
command,
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 命令安全校验
|
||
*
|
||
* C-4 修复: 采用双层防御
|
||
* 1. 主层 — 使用 shell-quote 解析命令为 token 数组,对每个 token 做模式匹配
|
||
* 可防御所有字符串拼接绕过(如 `r"m" -rf /`、`$'rm'`、`$(echo rm)`、`r''m`)
|
||
* 2. 补充层 — 保留原正则检测作为 fallback,覆盖 Windows cmd 语法和 shell-quote 无法解析的场景
|
||
*
|
||
* @see docs/MetonaAI-Desktop 架构与交互设计.html — run_command 安全规则
|
||
* @see standard/开发规范.md — 使用 shell-quote 解析命令(禁止简单字符串匹配)
|
||
*/
|
||
private validateCommand(command: string): { allowed: boolean; reason?: string } {
|
||
const cmd = command.trim().toLowerCase();
|
||
|
||
// 受保护文件检查:禁止通过命令行读写工作空间根目录的 MEMORY.md
|
||
if (commandTouchesProtectedFile(command)) {
|
||
return {
|
||
allowed: false,
|
||
reason:
|
||
'Access denied: MEMORY.md is managed by the memory system and cannot be accessed via command execution',
|
||
};
|
||
}
|
||
|
||
// P0-5: 剥离 Windows chcp 前缀("chcp 65001 >nul 2>&1 &&" 会破坏 shell-quote
|
||
// 解析,使 token 级检测退化到正则补充层,存在绕过面)
|
||
const parseableCommand = command.replace(/^\s*chcp\s+\d+\s*>\s*nul\s+2>&1\s*&&\s*/i, '');
|
||
|
||
// ===== 主层: shell-quote token-level 检测 =====
|
||
// 解析失败(Windows cmd 语法等)时降级到正则补充层
|
||
const tokenBlock = this.checkTokens(parseableCommand);
|
||
if (tokenBlock !== null) return tokenBlock;
|
||
|
||
// ===== 补充层: 原正则检测(保留所有原模式) =====
|
||
// 覆盖 shell-quote 无法解析的场景:
|
||
// - Windows cmd 语法(&、|、>nul、chcp 等)
|
||
// - 复杂管道序列(curl ... | sh)
|
||
// - Fork bomb 等特殊语法
|
||
const hardBlocks = [
|
||
// 文件系统破坏
|
||
{ pattern: /\brm\b.*\//, reason: 'rm with absolute path is forbidden' },
|
||
{
|
||
pattern:
|
||
/\brm\s+-rf?\s+\/(?:[^|;&\s]*\s)*?(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/i,
|
||
reason: 'rm on system directories is forbidden',
|
||
},
|
||
{ pattern: /\b(sudo|su|doas)\b/, reason: 'Privilege escalation commands are forbidden' },
|
||
// 系统控制
|
||
{
|
||
pattern: /\b(shutdown|reboot|halt|poweroff)\b/,
|
||
reason: 'System shutdown commands are forbidden',
|
||
},
|
||
{ pattern: /\b(killall|pkill)\s+-9\b/, reason: 'Force kill all processes is forbidden' },
|
||
// 远程代码执行
|
||
{ pattern: /curl.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
|
||
{ pattern: /wget.*\|\s*(ba)?sh/, reason: 'Remote code execution via pipe is forbidden' },
|
||
{
|
||
pattern: /\bcurl\s+.*\s*-o\s+\/etc\//i,
|
||
reason: 'Writing to system directories via curl is forbidden',
|
||
},
|
||
// 设备文件
|
||
{ pattern: /\bdd\b.*of=\/dev\//, reason: 'Writing to device files is forbidden' },
|
||
// 磁盘格式化
|
||
{ pattern: /\b(mkfs|fdisk)\b/, reason: 'Disk formatting commands are forbidden' },
|
||
// 权限滥用
|
||
{ pattern: /\bchmod\s+777\b/, reason: 'chmod 777 is forbidden' },
|
||
{
|
||
pattern: /\bchown\s+-R\s+\S+\s+\/(?:\s|$)/i,
|
||
reason: 'Recursive chown on root is forbidden',
|
||
},
|
||
// 环境变量窃取
|
||
{
|
||
pattern: /\b(env|export|printenv)\s*\|.*\b(curl|wget|nc|ncat)\b/i,
|
||
reason: 'Exfiltrating environment variables is forbidden',
|
||
},
|
||
// 反向 shell
|
||
{
|
||
pattern: /\b(bash|sh|zsh)\s+-i\s+>\s*&\s*\/dev\/tcp\//i,
|
||
reason: 'Reverse shell via /dev/tcp is forbidden',
|
||
},
|
||
{ pattern: /\bnc\s+.*\s+-e\s+(bash|sh)/i, reason: 'Reverse shell via netcat is forbidden' },
|
||
// Windows 危险命令
|
||
{ pattern: /\b(format|diskpart)\b/i, reason: 'Disk formatting commands are forbidden' },
|
||
{ pattern: /\bshutdown\s*\//i, reason: 'System shutdown commands are forbidden' },
|
||
{
|
||
pattern: /\breg\s+(add|delete|import|restore)/i,
|
||
reason: 'Registry modification commands are forbidden',
|
||
},
|
||
{
|
||
pattern: /\b(taskkill|kill)\s*\//i,
|
||
reason: 'Process termination with system flags is forbidden',
|
||
},
|
||
{
|
||
pattern: /\bpowershell\s+-enc\s+/i,
|
||
reason: 'PowerShell encoded command execution is forbidden',
|
||
},
|
||
// 后台进程与管道炸弹
|
||
{ pattern: /&\s*\(/, reason: 'Background subshell execution is forbidden' },
|
||
{ pattern: /\|\s*&/, reason: 'Pipe to background process is forbidden' },
|
||
{ pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/, reason: 'Fork bomb is forbidden' },
|
||
];
|
||
|
||
for (const block of hardBlocks) {
|
||
if (block.pattern.test(cmd)) {
|
||
return { allowed: false, reason: block.reason };
|
||
}
|
||
}
|
||
|
||
return { allowed: true };
|
||
}
|
||
|
||
/**
|
||
* C-4 修复: shell-quote token-level 安全检测
|
||
*
|
||
* 将命令解析为 token 数组,提取所有命令名和参数(忽略 shell 运算符),
|
||
* 对每个 token 做精确匹配。可防御所有字符串拼接绕过:
|
||
* - `r"m" -rf /` → 解析为 ['rm', '-rf', '/'] → 命中 rm 检测
|
||
* - `$'rm'` → 解析为 ['rm'] → 命中 rm 检测
|
||
* - `$(echo rm) -rf /` → 命令替换会被 shell-quote 识别为运算符序列
|
||
*
|
||
* @returns null 表示通过检测;非 null 表示被阻止(含 reason)
|
||
*/
|
||
private checkTokens(command: string): { allowed: boolean; reason?: string } | null {
|
||
let tokens: ReturnType<typeof shellQuoteParse>;
|
||
try {
|
||
tokens = shellQuoteParse(command);
|
||
} catch {
|
||
// 解析失败(Windows cmd 语法、不完整的引号等)— 降级到正则补充层
|
||
return null;
|
||
}
|
||
|
||
// 提取所有 word token(命令名和参数),忽略运算符(&&、|、; 等)
|
||
// 同时跟踪管道运算符,检测危险组合(如 `| sh`)
|
||
const words: string[] = [];
|
||
let prevWasPipe = false;
|
||
|
||
for (const entry of tokens) {
|
||
if (typeof entry === 'string') {
|
||
// 裸字符串 token — 若前一 token 是管道,检测是否为 sh/bash(远程代码执行)
|
||
if (prevWasPipe && /^(ba)?sh$/i.test(entry)) {
|
||
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
|
||
}
|
||
words.push(entry);
|
||
prevWasPipe = false;
|
||
} else if (typeof entry === 'object' && entry !== null) {
|
||
const obj = entry as { word?: unknown; op?: unknown };
|
||
if (typeof obj.word === 'string') {
|
||
// 引号包裹或变量替换后的 token
|
||
if (prevWasPipe && /^(ba)?sh$/i.test(obj.word)) {
|
||
return { allowed: false, reason: 'Remote code execution via pipe is forbidden' };
|
||
}
|
||
words.push(obj.word);
|
||
prevWasPipe = false;
|
||
} else if (typeof obj.op === 'string') {
|
||
// 跟踪管道运算符,用于下一轮检测 `| sh`
|
||
prevWasPipe = obj.op === '|';
|
||
}
|
||
}
|
||
}
|
||
|
||
// 危险命令名 token(精确匹配,大小写不敏感)
|
||
const dangerousCommands = new Set([
|
||
'sudo',
|
||
'su',
|
||
'doas',
|
||
'shutdown',
|
||
'reboot',
|
||
'halt',
|
||
'poweroff',
|
||
'mkfs',
|
||
'fdisk',
|
||
'format',
|
||
'diskpart',
|
||
]);
|
||
// 危险参数 token
|
||
const dangerousArgs = new Set([
|
||
'-enc',
|
||
'-encodedcommand', // PowerShell 编码执行
|
||
]);
|
||
|
||
for (const word of words) {
|
||
const lower = word.toLowerCase();
|
||
// 1. 危险命令名(精确匹配,或 mkfs/fdisk 前缀匹配如 mkfs.ext4)
|
||
if (dangerousCommands.has(lower) || /^(mkfs|fdisk)\b/.test(lower)) {
|
||
if (['sudo', 'su', 'doas'].includes(lower)) {
|
||
return { allowed: false, reason: 'Privilege escalation commands are forbidden' };
|
||
}
|
||
if (['shutdown', 'reboot', 'halt', 'poweroff'].includes(lower)) {
|
||
return { allowed: false, reason: 'System shutdown commands are forbidden' };
|
||
}
|
||
// mkfs/fdisk/format/diskpart + mkfs.ext4 等前缀匹配
|
||
return { allowed: false, reason: 'Disk formatting commands are forbidden' };
|
||
}
|
||
// 2. 危险参数(精确匹配)
|
||
if (dangerousArgs.has(lower)) {
|
||
return { allowed: false, reason: 'PowerShell encoded command execution is forbidden' };
|
||
}
|
||
// 3. dd 写设备文件:of=/dev/...(不依赖系统目录前置,独立检测)
|
||
if (/^of=\/dev\//.test(lower)) {
|
||
return { allowed: false, reason: 'Writing to device files is forbidden' };
|
||
}
|
||
}
|
||
|
||
// 4. 检测 rm 与系统目录的组合(token 序列检测)
|
||
// 例如: ['rm', '-rf', '/etc'] 应被拦截
|
||
let hasRm = false;
|
||
let hasSystemPath = false;
|
||
for (const word of words) {
|
||
const lower = word.toLowerCase();
|
||
if (lower === 'rm') hasRm = true;
|
||
if (/^\/(?:bin|boot|dev|etc|lib|proc|root|sbin|sys|usr|var)\b/.test(lower)) {
|
||
hasSystemPath = true;
|
||
}
|
||
}
|
||
if (hasRm && hasSystemPath) {
|
||
return { allowed: false, reason: 'rm on system directories is forbidden' };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* #8 修复: 将命令解析为简单的 command + args 数组
|
||
*
|
||
* 使用 shell-quote 解析命令,如果命令只包含 word tokens(无管道、重定向、&& 等 shell 运算符),
|
||
* 返回 { command, args } 供 execFile 使用(不经过 shell,从根本上防止命令注入)。
|
||
* 如果包含 shell 运算符或解析失败,返回 null,调用方降级到 exec(已有安全校验)。
|
||
*
|
||
* @returns 简单命令的 { command, args },或 null 表示复杂命令
|
||
*/
|
||
private parseCommandSimple(command: string): { command: string; args: string[] } | null {
|
||
let tokens: ReturnType<typeof shellQuoteParse>;
|
||
try {
|
||
tokens = shellQuoteParse(command);
|
||
} catch {
|
||
// 解析失败(Windows cmd 语法、不完整引号等)— 降级到 exec
|
||
return null;
|
||
}
|
||
|
||
const words: string[] = [];
|
||
for (const entry of tokens) {
|
||
if (typeof entry === 'string') {
|
||
words.push(entry);
|
||
} else if (typeof entry === 'object' && entry !== null) {
|
||
const obj = entry as { word?: unknown; op?: unknown };
|
||
if (typeof obj.word === 'string') {
|
||
words.push(obj.word);
|
||
} else if (typeof obj.op === 'string') {
|
||
// 遇到 shell 运算符(&&、|、;、> 等)— 复杂命令,降级到 exec
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (words.length === 0) return null;
|
||
return { command: words[0], args: words.slice(1) };
|
||
}
|
||
}
|