Files
vyndr/src/services/pythonRunner.js
T

165 lines
5.9 KiB
JavaScript

/**
* 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<any>} 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 };