From 76f190cf7854a298d4f4a37b4e724a8dfca874ba Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 18 Jul 2026 10:14:51 +0200 Subject: [PATCH] fix(llm-security): AST-taint clears taint on reassignment (#29) + sink test coverage (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit py-ast-taint.py Assign handler never removed a name from the tainted set on reassignment, so a source-then-constant/sanitizer rebind (g=os.getenv(); g='safe'; os.system(g), or x=shlex.quote(x)) kept stale taint and fired false AST-CMD-EXEC findings. Assign now clears taint for target names when the RHS is not a source. No cross-expression propagation added — the f-string/concat/alias recall gap (#27) stays deferred to v8. #28: added fixtures+assertions locking the tainted subprocess/os.system AST-CMD-EXEC sink and the open(...,'w') AST-FILE-WRITE sink. Suite 1931/0. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ --- scanners/lib/py-ast-taint.py | 5 +++++ tests/fixtures/ast-scan/reassign.py | 17 ++++++++++++++ tests/fixtures/ast-scan/sinks.py | 20 +++++++++++++++++ tests/scanners/ast-taint-scanner.test.mjs | 27 +++++++++++++++++++++++ 4 files changed, 69 insertions(+) create mode 100644 tests/fixtures/ast-scan/reassign.py create mode 100644 tests/fixtures/ast-scan/sinks.py diff --git a/scanners/lib/py-ast-taint.py b/scanners/lib/py-ast-taint.py index 00c69e6..205bf59 100644 --- a/scanners/lib/py-ast-taint.py +++ b/scanners/lib/py-ast-taint.py @@ -151,6 +151,11 @@ def analyze_scope(body): if src: for name in (n for t in node.targets for n in assigned_names(t)): tainted[name] = (node.lineno, src) + else: + # Rebinding to a non-source value (constant, sanitizer call, ...) + # kills the taint for that name in this scope. + for name in (n for t in node.targets for n in assigned_names(t)): + tainted.pop(name, None) if isinstance(node.value, ast.Call) and dotted(node.value.func) == "open" \ and is_write_open(node.value): for name in (n for t in node.targets for n in assigned_names(t)): diff --git a/tests/fixtures/ast-scan/reassign.py b/tests/fixtures/ast-scan/reassign.py new file mode 100644 index 0000000..7bdb83c --- /dev/null +++ b/tests/fixtures/ast-scan/reassign.py @@ -0,0 +1,17 @@ +import os +import shlex + +# Taint must CLEAR when a name is rebound to a non-source value (#29). +# Neither function below may produce a finding. + + +def reassigned_constant(): + g = os.getenv("G") # tainted source + g = "safe-constant" # rebound to a literal -> taint must clear + os.system(g) # must NOT be flagged + + +def sanitized_reassignment(): + x = input("path> ") # tainted source + x = shlex.quote(x) # rebound to a non-source call -> taint must clear + os.system(x) # must NOT be flagged diff --git a/tests/fixtures/ast-scan/sinks.py b/tests/fixtures/ast-scan/sinks.py new file mode 100644 index 0000000..ce11167 --- /dev/null +++ b/tests/fixtures/ast-scan/sinks.py @@ -0,0 +1,20 @@ +import os +import subprocess + +# Locks the subprocess/os.system command sinks and the file-write sink (#28). + + +def run_user_command(): + cmd = input("cmd> ") # tainted source + subprocess.run(cmd, shell=True) # sink: subprocess.* -> AST-CMD-EXEC + + +def shell_from_env(): + target = os.getenv("TARGET") # tainted source + os.system(target) # sink: os.system -> AST-CMD-EXEC + + +def leak_to_file(): + payload = os.environ["DATA"] # tainted source + log = open("out.txt", "w") # write handle + log.write(payload) # sink: file.write -> AST-FILE-WRITE (high) diff --git a/tests/scanners/ast-taint-scanner.test.mjs b/tests/scanners/ast-taint-scanner.test.mjs index 4a88f7b..d7fa03d 100644 --- a/tests/scanners/ast-taint-scanner.test.mjs +++ b/tests/scanners/ast-taint-scanner.test.mjs @@ -2,6 +2,8 @@ // 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) +// - reassign.py : tainted name rebound to a non-source value -> taint clears (#29) +// - sinks.py : subprocess/os.system command sinks + file-write sink (#28) // - 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 @@ -49,6 +51,31 @@ describe('ast-taint-scanner: python3 present', () => { assert.ok(/input/.test(scopeReal[0].evidence || scopeReal[0].description), 'the flagged finding should trace back to input'); }); + it('clears taint when a name is reassigned to a non-source value', { skip: !HAS_PYTHON3 }, async () => { + const result = await scan(FIXTURE, discovery); + const real = result.findings.filter(f => f.severity !== 'info' && f.file.includes('reassign.py')); + assert.equal(real.length, 0, `reassign.py must yield no real findings, got ${real.length}: ${real.map(f => `${f.evidence}@L${f.line}`).join('; ')}`); + }); + + it('reports tainted subprocess and os.system command sinks', { skip: !HAS_PYTHON3 }, async () => { + const result = await scan(FIXTURE, discovery); + const real = result.findings.filter(f => f.severity !== 'info' && f.file.includes('sinks.py')); + assert.ok(real.some(f => /AST-CMD-EXEC: input -> subprocess\.run/.test(f.evidence)), + `expected an input -> subprocess.run finding, got: ${real.map(f => f.evidence).join('; ')}`); + assert.ok(real.some(f => /AST-CMD-EXEC: os\.getenv -> os\.system/.test(f.evidence)), + `expected an os.getenv -> os.system finding, got: ${real.map(f => f.evidence).join('; ')}`); + assert.ok(real.filter(f => /AST-CMD-EXEC/.test(f.evidence)).every(f => f.severity === 'critical'), + 'command-exec findings should be critical'); + }); + + it('reports the tainted file-write sink via a write handle', { skip: !HAS_PYTHON3 }, async () => { + const result = await scan(FIXTURE, discovery); + const fw = result.findings.filter(f => f.file.includes('sinks.py') && /AST-FILE-WRITE/.test(f.evidence || '')); + assert.equal(fw.length, 1, `sinks.py should yield exactly 1 AST-FILE-WRITE finding, got ${fw.length}`); + assert.equal(fw[0].severity, 'high', 'the file-write sink is the high-severity rule'); + assert.match(fw[0].evidence, /os\.environ -> file\.write/, 'should trace os.environ into file.write'); + }); + 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');