13. 安全运行 Bash、Python、R 与第三方程序
首选:直接执行最终程序
调用 Python、Rscript、FFmpeg、samtools 等程序时,将命令和参数分开:
import { spawn } from "node:child_process";
import { resolve } from "node:path";
const scriptPath = resolve("scripts/analyze.py");
const inputPath = resolve("data/sample.csv");
const child = spawn("python", [
scriptPath,
"--input",
inputPath,
"--format",
"jsonl",
], {
cwd: resolve("work/task-123"),
env: {
PATH: process.env.PATH,
PYTHONUNBUFFERED: "1",
LANG: "C.UTF-8",
},
shell: false,
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
PYTHONUNBUFFERED=1 能让 Python 更及时刷新管道输出。env 中 undefined 值应先过滤;更稳妥的生产部署还会使用已知绝对可执行路径。
参数数组降低 Shell 注入风险,但不能防止路径穿越、覆盖任意文件、危险业务选项和资源耗尽。路径仍须限制在允许目录内。
什么时候执行 Bash 脚本
脚本确实依赖管道、重定向或 Bash 语法时,明确调用 Bash:
const child = spawn("bash", [
resolve("scripts/pipeline.sh"),
"--input",
inputPath,
], {
shell: false,
stdio: ["ignore", "pipe", "pipe"],
});
这里 shell: false:Node.js 直接启动 bash,参数仍不会先被外层 Shell 拼接解释。脚本内部应使用严格模式并正确引用变量:
#!/usr/bin/env bash
set -Eeuo pipefail
input_path="$2"
python3 analyze.py --input "$input_path" | gzip > result.json.gz
pipefail 让管道中前段命令失败时,脚本不会只报告最后一个命令的成功。脚本还应使用 trap 做必要清理。
不要这样拼命令
// 危险:文件名可插入分号、重定向、命令替换等 Shell 语法。
exec(`bash scripts/analyze.sh ${request.body.fileName}`);
也不要为了让 .sh “能运行”随意设置 shell: true。在 Windows 原生环境通常没有 Bash;应明确依赖 Git Bash、WSL,或改用跨平台 Node.js/Python 脚本。WSL 路径、环境和进程生命周期与 Windows 原生进程也不同。
设计机器可读协议
推荐约定:
stdout JSON Lines 结果或进度
stderr 人类可读日志、警告、诊断
exit 0 成功
exit 2 参数或输入错误
exit 3 数据处理失败
exit 4 下游工具失败
示例 stdout:
{"type":"progress","percent":30}
{"type":"progress","percent":80}
{"type":"result","outputPath":"result/report.json"}
不要在 stdout 中混入 Starting... 等普通日志,否则调用方无法稳定解析 JSON。
逐行解析并验证
import { createInterface } from "node:readline";
interface ProgressEvent {
type: "progress";
percent: number;
}
const lines = createInterface({ input: child.stdout });
lines.on("line", (line: string) => {
let value: unknown;
try {
value = JSON.parse(line);
} catch {
console.error("无效 JSON 行");
return;
}
if (isProgressEvent(value)) {
console.log(`进度:${value.percent}%`);
}
});
function isProgressEvent(value: unknown): value is ProgressEvent {
if (typeof value !== "object" || value === null) return false;
const candidate = value as Record<string, unknown>;
return candidate.type === "progress"
&& typeof candidate.percent === "number"
&& candidate.percent >= 0
&& candidate.percent <= 100;
}
TypeScript 类型不会验证来自进程边界的运行时数据,必须进行真实校验。
如何界定最终成功
进程成功启动
→ 持续消费 stdout/stderr
→ close(code=0, signal=null)
→ stdout 协议完整
→ 结果路径位于任务目录
→ 文件存在、类型和大小合理
→ 才把业务任务标记为成功
stderr 即使有警告也可成功;code 0 但缺少结果仍是业务失败。对 stdout/stderr 都设置限额,并保存必要的尾部诊断,而不是无限入库。
环境、目录与版本
cwd决定相对路径基准,不等于脚本所在目录。- PM2/systemd 中的 PATH 可能与交互式终端不同。
- 不将数据库密码、JWT 等完整
process.env交给无关脚本。 - 记录 Python/R/工具版本、参数摘要和参考数据版本,便于复现。
- Python 虚拟环境可直接使用其解释器绝对路径,避免依赖
activate。 - Linux 直接执行脚本需要正确 Shebang、换行符和可执行权限;显式
bash script.sh不依赖脚本自身执行位。
R 与其他工具
调用方式完全相同:
spawn("Rscript", ["scripts/report.R", "--input", inputPath]);
spawn("ffmpeg", ["-i", inputPath, "-f", "null", "-"]);
spawn("samtools", ["view", "-b", inputPath]);
FFmpeg 等工具经常把正常进度写到 stderr,再次说明 stderr 不代表失败。某些命令使用 - 表示 stdin/stdout,应阅读工具文档并正确消费管道。
常见错误
- 在
exec()字符串中拼接用户输入。 - stdout 同时输出日志和 JSON。
- 仅凭 stderr 内容判失败。
- 仅凭 code 0 判成功,不校验结果。
- 假设线上 PATH、cwd、语言环境与开发机一致。
- 用完整
process.env暴露秘密。 - 忽略第三方工具许可证、版本和资源限制。
最佳实践
- 能直接启动最终程序时,不增加 Bash 中间层。
- 固定命令,参数数组传值,路径限制在任务目录。
- 明确 stdout/stderr/退出码协议和最大字节数。
- 使用
close汇总,随后验证业务产物。 - 设置超时、取消、并发上限和资源隔离。
- 日志记录 PID、工具版本、code、signal、时长,敏感参数脱敏。
练习
- Python 脚本输出 JSON Lines 进度,Node.js 做运行时校验。
- Bash 管道中让第一段失败,对比有无
pipefail的退出码。 - 模拟 code 0 但缺少结果文件,确保任务最终失败。
- 文件名包含空格和引号时,用参数数组安全传递。
验收清单
- [ ] 默认使用
spawn(program, args)而不是拼命令。 - [ ] 能设计 stdout、stderr 和退出码协议。
- [ ] 会验证进程边界数据及最终文件。
- [ ] 能说明 Bash 在 Windows 上的环境差异。
- [ ] 会控制环境变量、工作目录、超时、输出和并发。