/** * Safe Python subprocess runner. * * SECURITY: every argv element is passed as a separate argv slot to spawn(). * We NEVER use shell:true and NEVER string-concatenate user input into a * command. Callers must hand us a `script` allow-listed by relative path and * a `payload` that we JSON.stringify into a single argv[1]. * * Why JSON-as-argv-1 instead of stdin: the existing Python enricher scripts * already read `sys.argv[1]` per the spec. Keeping this convention means we * can add new scripts without rewriting the Python side. */ const { spawn } = require('node:child_process'); const path = require('node:path'); const fs = require('node:fs'); const PROJECT_ROOT = path.resolve(__dirname, '..', '..'); const VENV_PYTHON = path.join(PROJECT_ROOT, 'nba-service', 'venv', 'bin', 'python'); // Scripts ALLOW-LIST. The only paths that can be invoked. // Any value outside this set is rejected. const ALLOWED_SCRIPTS = Object.freeze({ 'nba/refs': 'nba-service/scripts/refs_cli.py', 'mlb/statcast': 'nba-service/scripts/mlb_statcast_cli.py', 'mlb/umpire': 'nba-service/scripts/mlb_umpire_cli.py', 'mlb/bvp': 'nba-service/scripts/mlb_bvp_cli.py', 'wnba/season-avg': 'nba-service/scripts/wnba_season_cli.py', }); const DEFAULT_TIMEOUT_MS = 30_000; const MAX_OUTPUT_BYTES = 4 * 1024 * 1024; // 4 MB cap class PythonScriptError extends Error { constructor(message, { code, stderr, script } = {}) { super(message); this.name = 'PythonScriptError'; this.code = code; this.stderr = stderr; this.script = script; } } function resolveScript(key) { const rel = ALLOWED_SCRIPTS[key]; if (!rel) throw new PythonScriptError(`unknown python script: ${key}`); const abs = path.join(PROJECT_ROOT, rel); if (!abs.startsWith(PROJECT_ROOT + path.sep)) { throw new PythonScriptError(`path traversal blocked: ${key}`); } if (!fs.existsSync(abs)) { throw new PythonScriptError(`python script missing on disk: ${rel}`); } return abs; } function pickInterpreter() { if (fs.existsSync(VENV_PYTHON)) return VENV_PYTHON; // Fall back to system python so dev environments without a venv still // surface a useful error rather than ENOENT. return process.env.VYNDR_PYTHON || 'python3'; } /** * Run an allow-listed Python script with a validated JSON payload. * * @param {keyof typeof ALLOWED_SCRIPTS} scriptKey * @param {object} payload Must be JSON-serializable. No functions, no Dates. * @param {object} [opts] * @param {number} [opts.timeoutMs=30000] * @param {AbortSignal} [opts.signal] Outer abort signal (HTTP cancel etc.) * @returns {Promise} parsed JSON from script stdout */ async function runPython(scriptKey, payload, opts = {}) { const scriptPath = resolveScript(scriptKey); const interpreter = pickInterpreter(); let payloadJson; try { payloadJson = JSON.stringify(payload ?? {}); } catch (err) { throw new PythonScriptError('payload not JSON-serializable', { script: scriptKey }); } if (payloadJson.length > 32_000) { throw new PythonScriptError('payload too large', { script: scriptKey }); } const timeoutMs = Math.max(1_000, Math.min(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, 120_000)); const controller = new AbortController(); const outerSignal = opts.signal; if (outerSignal) { if (outerSignal.aborted) controller.abort(outerSignal.reason); else outerSignal.addEventListener('abort', () => controller.abort(outerSignal.reason), { once: true }); } const timer = setTimeout(() => controller.abort(new Error('python timeout')), timeoutMs); return new Promise((resolve, reject) => { const child = spawn(interpreter, [scriptPath, payloadJson], { // Hard-fail safety: explicit "no shell". shell: false, // Don't inherit stdio — we collect output ourselves and cap the size. stdio: ['ignore', 'pipe', 'pipe'], // Restricted env: keep PATH for the interpreter but strip everything else. env: { PATH: process.env.PATH, PYTHONUNBUFFERED: '1', // Forward Redis URL so the enrichers can use the same cache. REDIS_URL: process.env.REDIS_URL || '', }, cwd: PROJECT_ROOT, signal: controller.signal, }); let stdout = ''; let stderr = ''; let stdoutBytes = 0; let killedForSize = false; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { stdoutBytes += Buffer.byteLength(chunk); if (stdoutBytes > MAX_OUTPUT_BYTES) { killedForSize = true; controller.abort(new Error('python stdout exceeded limit')); return; } stdout += chunk; }); child.stderr.on('data', (chunk) => { // We don't cap stderr as tightly — log it. stderr += chunk.slice(0, 8_000); }); child.on('error', (err) => { clearTimeout(timer); reject(new PythonScriptError(`spawn failed: ${err.message}`, { script: scriptKey, stderr })); }); child.on('close', (code) => { clearTimeout(timer); if (killedForSize) { return reject(new PythonScriptError('python output exceeded limit', { code, stderr, script: scriptKey })); } if (controller.signal.aborted) { return reject(new PythonScriptError('python aborted', { code, stderr, script: scriptKey })); } if (code !== 0) { return reject(new PythonScriptError(`python exited ${code}`, { code, stderr, script: scriptKey })); } try { // Tolerate scripts that print extra lines before the JSON object. const trimmed = stdout.trim(); const lastBrace = trimmed.lastIndexOf('{'); const candidate = lastBrace >= 0 ? trimmed.slice(lastBrace) : trimmed; resolve(JSON.parse(candidate)); } catch (err) { reject(new PythonScriptError('python returned non-JSON', { code, stderr, script: scriptKey })); } }); }); } module.exports = { runPython, PythonScriptError, ALLOWED_SCRIPTS };