Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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 };
|
||||
Reference in New Issue
Block a user