165 lines
5.9 KiB
JavaScript
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 };
|