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 更及时刷新管道输出。envundefined 值应先过滤;更稳妥的生产部署还会使用已知绝对可执行路径。

参数数组降低 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 都设置限额,并保存必要的尾部诊断,而不是无限入库。

环境、目录与版本

R 与其他工具

调用方式完全相同:

spawn("Rscript", ["scripts/report.R", "--input", inputPath]);
spawn("ffmpeg", ["-i", inputPath, "-f", "null", "-"]);
spawn("samtools", ["view", "-b", inputPath]);

FFmpeg 等工具经常把正常进度写到 stderr,再次说明 stderr 不代表失败。某些命令使用 - 表示 stdin/stdout,应阅读工具文档并正确消费管道。

常见错误

最佳实践

练习

  1. Python 脚本输出 JSON Lines 进度,Node.js 做运行时校验。
  2. Bash 管道中让第一段失败,对比有无 pipefail 的退出码。
  3. 模拟 code 0 但缺少结果文件,确保任务最终失败。
  4. 文件名包含空格和引号时,用参数数组安全传递。

验收清单