Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
118 lines
4.8 KiB
JavaScript
118 lines
4.8 KiB
JavaScript
// ast-taint-scanner.mjs — AST: Python taint analysis via a shipped python3 helper
|
|
//
|
|
// The regex taint-tracer (taint-tracer.mjs) has ~70% recall and no scope or
|
|
// cross-statement awareness. This scanner shells out to a PARSE-ONLY python3
|
|
// helper (scanners/lib/py-ast-taint.py) that does variable-level, scope-aware
|
|
// taint analysis of Python skill code — higher recall/precision — and falls
|
|
// back gracefully to the regex tracer whenever python3 is unavailable.
|
|
//
|
|
// The helper only PARSES the target (ast.parse); it never executes it, so
|
|
// analysing hostile code is side-effect-free.
|
|
//
|
|
// OWASP coverage: LLM01 (prompt injection / untrusted input to action),
|
|
// LLM02 (sensitive information disclosure); AST02 (skills framework).
|
|
// Zero external dependencies — Node.js builtins only. python3 is optional.
|
|
|
|
import { spawnSync } from 'node:child_process';
|
|
import { join, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { finding, scannerResult } from './lib/output.mjs';
|
|
import { getPolicyValue } from './lib/policy-loader.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const HELPER = join(__dirname, 'lib', 'py-ast-taint.py');
|
|
|
|
/** True if the given python interpreter is runnable. */
|
|
function pythonAvailable(pythonPath, timeoutMs) {
|
|
const probe = spawnSync(pythonPath, ['--version'], { encoding: 'utf8', timeout: timeoutMs });
|
|
return !probe.error; // ENOENT (binary missing) sets probe.error
|
|
}
|
|
|
|
/**
|
|
* Scan Python files for taint flows using the AST helper.
|
|
*
|
|
* @param {string} targetPath - Absolute root path being scanned
|
|
* @param {{ files: import('./lib/file-discovery.mjs').FileInfo[] }} discovery
|
|
* @returns {Promise<object>} - scannerResult envelope
|
|
*/
|
|
export async function scan(targetPath, discovery) {
|
|
const startMs = Date.now();
|
|
const findings = [];
|
|
let filesScanned = 0;
|
|
|
|
const enabled = getPolicyValue('ast', 'enabled', true, targetPath);
|
|
if (enabled === false) {
|
|
return scannerResult('ast-taint-scanner', 'skipped', findings, 0, Date.now() - startMs);
|
|
}
|
|
const pythonPath = getPolicyValue('ast', 'python_path', 'python3', targetPath);
|
|
const timeoutMs = getPolicyValue('ast', 'timeout_ms', 5000, targetPath);
|
|
|
|
if (!pythonAvailable(pythonPath, timeoutMs)) {
|
|
// Graceful degradation: regex taint-tracer still runs in the orchestrator.
|
|
return scannerResult('ast-taint-scanner', 'skipped', findings, 0, Date.now() - startMs);
|
|
}
|
|
|
|
try {
|
|
for (const fileInfo of discovery.files) {
|
|
if (fileInfo.ext !== '.py') continue;
|
|
filesScanned++;
|
|
|
|
const proc = spawnSync(pythonPath, [HELPER, fileInfo.absPath], { encoding: 'utf8', timeout: timeoutMs });
|
|
|
|
// Spawn-level error for THIS file (e.g. timeout) — note and continue.
|
|
if (proc.error) {
|
|
findings.push(noteFinding(fileInfo.relPath, `AST analysis could not run (${proc.error.code || proc.error.message})`));
|
|
continue;
|
|
}
|
|
// Helper exited non-zero (parse error / read error) — note and continue.
|
|
if (proc.status !== 0) {
|
|
findings.push(noteFinding(fileInfo.relPath, 'AST helper reported a parse/read error; file skipped'));
|
|
continue;
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(proc.stdout);
|
|
} catch {
|
|
findings.push(noteFinding(fileInfo.relPath, 'AST helper produced unparseable output; file skipped'));
|
|
continue;
|
|
}
|
|
if (!parsed || parsed.status !== 'ok' || !Array.isArray(parsed.findings)) {
|
|
findings.push(noteFinding(fileInfo.relPath, 'AST helper returned an unexpected payload; file skipped'));
|
|
continue;
|
|
}
|
|
|
|
for (const hf of parsed.findings) {
|
|
findings.push(finding({
|
|
scanner: 'AST',
|
|
severity: hf.severity || 'high',
|
|
title: `Python taint: ${hf.source} -> ${hf.sink}`,
|
|
description: hf.message || `Tainted data from ${hf.source} reaches ${hf.sink}.`,
|
|
file: fileInfo.relPath,
|
|
line: hf.line || null,
|
|
evidence: `${hf.rule}: ${hf.source} -> ${hf.sink}`,
|
|
owasp: 'LLM01',
|
|
recommendation: 'Validate or sanitize the value before it reaches the sink, or remove the dangerous sink.',
|
|
}));
|
|
}
|
|
}
|
|
|
|
return scannerResult('ast-taint-scanner', 'ok', findings, filesScanned, Date.now() - startMs);
|
|
|
|
} catch (err) {
|
|
return scannerResult('ast-taint-scanner', 'error', findings, filesScanned, Date.now() - startMs, err.message);
|
|
}
|
|
}
|
|
|
|
/** Build an info-level note for a file the helper could not analyse. */
|
|
function noteFinding(relPath, reason) {
|
|
return finding({
|
|
scanner: 'AST',
|
|
severity: 'info',
|
|
title: 'AST taint analysis skipped for file',
|
|
description: `${reason}. The regex taint-tracer still covers this file.`,
|
|
file: relPath,
|
|
owasp: 'LLM01',
|
|
recommendation: 'No action required; informational.',
|
|
});
|
|
}
|