"""Shared fixtures for the exe-reverse regression suite. What this file guarantees: * every input is *built*, never committed and never downloaded -- the synthetic PE images come from ``skills/exe-reverse/scripts/make_fixture_pe.py`` at test time, into a pytest temporary directory that disappears with the session; * the scripts under test are loaded by path, because ``skills/exe-reverse/scripts/`` is a flat directory of runnable files, not an importable package; * a missing prerequisite -- an older interpreter, an absent local tool -- is a *skip with a printed reason*, never a failure. A wrong result is always a failure. Nothing here touches the network, a debugger, a device or a kernel component. """ import hashlib import importlib.util import json import os import pathlib import shutil import struct import subprocess import sys import pytest TESTS_DIR = pathlib.Path(__file__).resolve().parent REPO_ROOT = TESTS_DIR.parent SCRIPTS_DIR = REPO_ROOT / "skills" / "exe-reverse" / "scripts" # The scripts declare "Python 3.9+" in their own docstrings. MIN_PYTHON = (3, 9) # Every script this suite may load by name or run as a subprocess. The last five were added with # their own layers of the suite: the capability registry and the environment doctor answer questions # about this host, the Dart snapshot reader takes an ELF rather than a PE (so its input is generated # here by `build_synthetic_elf` instead of by `make_fixture_pe.py`), the folder summarises records # too large to read (its tests assert on the bounds of its output rather than on fixtures of any # particular size), and the reducer's proof path drives an external solver, so its solver-facing # tests run against a stub and the pure parts against plain text. SCRIPT_FILES = ( "peutil.py", "pepatch.py", "hwprobe.py", "aob_scan.py", "make_fixture_pe.py", "scan_leaks.py", "capabilities.py", "doctor.py", "dart_snapshot_strings.py", "fold.py", "trace_reduce.py", ) # A hang detector, not a performance assertion. Nothing in this suite is meant to be slow. RUN_TIMEOUT = 180 # Suite name -> recipe name in make_fixture_pe.py, so recipes are referenced rather than # duplicated. `default` is PE32+ with every structure; `pe32` is the 32-bit layout; `bare` # drops exports/resources/TLS/debug; `fatalign` uses a 4 KiB file alignment, which is large # enough that .rdata has no virtual-only tail at all. # # The `no-virtual-tail` recipe is deliberately NOT used: with `rdata_virtual_pad = 0` its own # guard (`rdata_payload_len > rdata_virtual_pad`) rejects the layout it names, so the recipe # cannot be built and exits 1. `fatalign` covers the same property. Recorded in benchmark.md. FIXTURE_RECIPES = { "pe64": "default", "pe32": "pe32", "bare": "bare", "fatalign": "fat-align", } # --- synthetic leak corpus --------------------------------------------------- # One line per rule in scan_leaks.py. Each line is shaped like the thing its rule targets, and # (except for machine-guid, which is a measured observation -- see the unit tests) is not # suppressed by the exemption list. # # Every sample is assembled from fragments at import time rather than quoted whole. The suite # deliberately contains strings shaped like a pasted key, a token and a real profile path; if # they were written whole, the repository's own pre-publish scan would report this file the # moment it walked the tracked surface. Splitting them keeps the runtime value matched by the # rule while the source text matches nothing. A scanner's own fixtures have to be written this # way, and that is a property of the scan surface, not a trick. def parts(*chunks): """Join string fragments at run time (see the note above).""" return "".join(chunks) LEAK_LINES = ( ("private-key-block", parts("-----BEGIN RSA ", "PRIVATE KEY-----")), ("github-token", parts("ghp_", "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8")), ("aws-access-key", parts("AKIA", "Q7ZK3MNP2RVW8XTD")), ("slack-token", parts("xoxb-", "1234567890abcdef")), ("generic-secret-assignment", parts('api_key = "', "QwErTyUiOpAsDfGhJkLzXcVb", '"')), ("licence-blob-literal", parts('license_key: "', "ABCDEFGH1234", '"')), ("windows-user-profile", parts("C:\\Users\\", "mkorvin", "\\notes.md")), ("posix-home", parts("/home/", "mkorvin", "/notes.md")), ("machine-guid", parts("MachineGuid = ", "3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071")), ("windows-sid-literal", parts("S-1-5-21-", "1004336348-1177238915-682003330-", "1001")), ("volume-serial-literal", parts("Volume Serial Number: ", "1A2B-3C4D")), ("literal-endpoint-with-creds", parts("ftp://", "deploy:Sup3rSecret@", "45.77.", "10.199/pub")), ("public-ipv4-literal", parts("45.77.", "10.199")), ("literal-host-with-path", parts("https://licensing.", "hexweld.", "org/v2/activate")), ("product-name-with-version", parts("Hexweld Editor ", "4.2.1")), ("exe-name-with-version", parts("build: hexweld", ".exe v4.2")), ("email-address", parts("k.morozov@", "hexweld.org")), ("vendor-copyright-line", parts("Copyright (c) 2024 ", "Hexweld Systems")), ) # Lines that must never be reported: tool names, protector names, format vocabulary, platform # terms, placeholder tokens, reserved documentation addresses, well-known SIDs. EXEMPT_LINES = ( ("tool name", "IDA Pro 9.0.2 opened the image"), ("protector name", "the UPX 4.2 stub wraps a Themida section"), ("format term", "RVA versus file offset; the PDB is RSDS CodeView"), ("platform term", "Microsoft Windows 11.0 build 26100 with PowerShell"), ("placeholder token", "TARGET: APP_URL and SAMPLE with PLACEHOLDER values"), ("documentation address", "203.0.113.9 and 198.51.100.7 and 192.0.2.44"), ("well-known SID", "S-1-5-18 and S-1-5-32-544 are not machine identity"), ("loopback and example", "127.0.0.1 and localhost and example.com"), ("machine guid context", parts("MachineGuid = ", "3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071")), ("net tld", parts("https://licensing.", "hexweld.", "net/v2/activate")), ) # Measured deviations from the exemption list, pinned as observations rather than wishes. Each # is asserted by a unit test so a change to either list is visible instead of silent: # * the bare-GUID exemption matches inside the machine-guid rule's own match text, so that # rule cannot produce a finding; # * the `.NET` entry matches any `.net` TLD, so a hit inside such a URL or address is # suppressed as a platform term; # * the `<[a-z_]+>` placeholder alternative is surrounded by `\b` on both sides, so it cannot # match bracketed prose; only word-character adjacency reaches it. # # A fourth deviation was measured and then corrected while this suite was being written: for one # revision of the exemption list, a standard-name alternative matched the `HTTP` inside every # `https?://` URL and swallowed every literal-host-with-path hit. benchmark.md records both # states; the suite asserts the corrected one. SUPPRESSED_RULES = ("machine-guid",) # Lines that must never be reported: tool names, protector names, format vocabulary, platform # terms, placeholder tokens, reserved documentation addresses, well-known SIDs. EXEMPT_LINES = ( ("tool name", "IDA Pro 9.0.2 opened the image"), ("protector name", "the UPX 4.2 stub wraps a Themida section"), ("format term", "RVA versus file offset; the PDB is RSDS CodeView"), ("platform term", "Microsoft Windows 11.0 build 26100 with PowerShell"), ("placeholder token", "TARGET: APP_URL and SAMPLE with PLACEHOLDER values"), ("documentation address", "203.0.113.9 and 198.51.100.7 and 192.0.2.44"), ("well-known SID", "S-1-5-18 and S-1-5-32-544 are not machine identity"), ("loopback and example", "127.0.0.1 and localhost and example.com"), ("machine guid context", "MachineGuid = 3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071"), ("net tld", "https://licensing.hexweld.net/v2/activate"), ) # The one rule whose hits are suppressed by the exemption list. Measured, not assumed: the # bare-GUID exemption matches inside the machine-guid rule's own match text. Pinned by a unit # test so a change in either list is visible instead of silent. SUPPRESSED_RULES = ("machine-guid",) # --- synthetic ELF64 for dart_snapshot_strings.py ----------------------------- # That script's input is a Dart AOT snapshot -- an ELF shared object named `app.so` -- and this # repository may never carry a sample, not even a small one. So the suite builds the container # instead: a valid ELF64 header, a `.text` of a handful of bytes, a `.rodata` holding one one-byte # (ASCII) literal and one two-byte (UTF-16LE) literal at known offsets, and a section-name string # table. A few hundred bytes, written at test time into the session temporary directory. # # What this image is *not*: a real snapshot. It exists so the reader can be held against a container # whose offsets are known in advance, which is what proves the literal-recovery logic. The # instruction-level counts an earlier version of the reader reported were withdrawn -- a byte scan # cannot read this format, and the reference records why -- so nothing here stands in for them. # Neither `49 8B /r` (a pool load through r15) nor `4X 8B|8D 05` (a RIP-relative load) appears in # these bytes, which is what makes the zero counts below a measurement rather than an artifact. ELF_TEXT_BYTES = bytes((0x90, 0x90, 0xC3, 0xCC, 0x55, 0x48, 0x89, 0xE5, 0x5D, 0xC3, 0x0F, 0x1F, 0x44, 0x00, 0x00, 0x90)) ELF_TEXT_VADDR = 0x1000 # Large enough that a virtual address is never mistaken for a file offset: the reader treats an # `--at` value above the file size as a vaddr, and this image is under 1 KiB. ELF_RODATA_VADDR = 0x400000 # (offset within .rodata, vaddr offset, text, encoding). The ASCII run at 0x00 and the UTF-16LE run # at 0x1C are the two shapes the reader documents; the NUL padding between them keeps the two-byte # run on the phase the scan walks, because that scan advances from the section base in steps of two. # That phase dependency is a defect in the reader rather than a property of a snapshot, and it is # pinned in unit/test_dart_snapshot_strings.py; the fixture places its literals so the recovery path # can be measured at all. ELF_LITERALS = ( (0x00, "premium_license", "one-byte"), (0x10, "trial_mode", "one-byte"), (0x1C, "vip_pass", "two-byte"), ) def _elf_section_header(name_off, typ, flags, addr, offset, size, align=8, entsize=0, link=0, info=0): """One 64-byte ELF64 section header, in the order the reader unpacks it.""" return struct.pack("