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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TcQyMTQfyrsAapaCMPxTtQ
This commit is contained in:
parent
629a5e6e8c
commit
76f190cf78
4 changed files with 69 additions and 0 deletions
|
|
@ -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)):
|
||||
|
|
|
|||
17
tests/fixtures/ast-scan/reassign.py
vendored
Normal file
17
tests/fixtures/ast-scan/reassign.py
vendored
Normal file
|
|
@ -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
|
||||
20
tests/fixtures/ast-scan/sinks.py
vendored
Normal file
20
tests/fixtures/ast-scan/sinks.py
vendored
Normal file
|
|
@ -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)
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue