From 6670f16490b730cca59726d117df474953ccef08 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 31 Aug 2026 09:40:20 +0100 Subject: [PATCH 01/40] Relocations high-adj work --- docs/specs/reason-codes.md | 37 ++- iocx/parsers/pe_relocations.py | 26 ++- tests/unit/parsers/test_pe_relocations.py | 49 +++- .../parsers/test_pe_relocations_highadj.py | 221 ++++++++++++++++++ 4 files changed, 325 insertions(+), 8 deletions(-) create mode 100644 tests/unit/parsers/test_pe_relocations_highadj.py diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index fb25391..20df8ec 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -538,7 +538,7 @@ Priority‑resolved: | Reason Code | What Triggers It | Example Pattern | Scope | |------------|------------------|-----------------|--------| | **RELOCATION_DIRECTORY_INVALID_HEADER** | Top-level decode failure: the directory placement could not be resolved or the first block header was unrecoverable | Directory at an RVA `pe.get_data` cannot resolve | Per‑file | -| **RELOCATION_TABLE_TRUNCATED** | A block's declared `SizeOfBlock` extends past the directory's declared end, or the entry region could not be fully read | Block claims 0x200 bytes but only 0x40 remain before directory end | Per‑file | +| **RELOCATION_TABLE_TRUNCATED** | A block header, entry region, or the block walk itself could not be fully read within the declared directory | Block claims 0x200 bytes but only 0x40 remain before directory end | Per‑file | | **RELOCATION_BLOCK_MALFORMED** | A block is structurally invalid: `SizeOfBlock` below the 8-byte header minimum, not aligned to the 2-byte entry stride, or a non-advancing size that would stall the walk | `SizeOfBlock = 0`, or `SizeOfBlock = 7` | Per‑block *(priority-resolved sub-reason)* | | **RELOCATION_ENTRY_RVA_INVALID** | A non-ABSOLUTE entry's target (`page_rva + offset`) does not map to any section | Entry target = 0x9000 with no covering section | Per‑entry *(count always reported in details even when emission is capped)* | @@ -563,7 +563,40 @@ Priority‑resolved; the first matching tag wins: ### RELOCATION_TABLE_TRUNCATED The `region` field (not `table`, and not `sub_reason`) names the truncated -region. +region: + +| region value | Meaning | +|---|---| +| relocation_block_header_truncated | The 8-byte block header did not fit the declared directory window, or came back short | +| relocation_block_read_failed | `pe.get_data` raised while reading a block header | +| relocation_entries_exceed_directory | A block's declared entry region extends past the directory's declared end; the readable portion is clamped and the remainder is not decoded | +| relocation_entries_truncated | The entry array was clamped to the directory end, or came back short | +| relocation_entries_read_failed | `pe.get_data` raised while reading the entry array | +| relocation_block_max_exceeded | The walk hit the 65536-block hard limit | + +*Note `relocation_entries_exceed_directory` and `relocation_entries_truncated` +are distinct facts and may both fire for the same block: the former means the +declared size was clamped to the directory window; the latter means the +physical read then came back shorter than even that clamped window.* + +### Entry-level: HIGHADJ pairing + +`IMAGE_REL_BASED_HIGHADJ` (type 4) occupies two WORD slots per the PE spec — +the type+offset word, followed by a raw 16-bit adjustment value. The parser +threads a pairing state across the entry walk (reset per block, never shared +across blocks) rather than decoding every word independently: + +| Entries field | Meaning | +|----------------|---------| +| `adjustment` | Present only on a HIGHADJ entry whose pairing succeeded; holds the raw 16-bit value from the following WORD, verbatim, not decoded as type+offset | + +If a HIGHADJ entry is the last word in a block's entry region (no adjustment +word follows), the entry is still recorded, `adjustment` is absent, and the +block carries: + +| Sub‑reason (block-level `errors`) | Meaning | +|------------------------------------|---------| +| highadj_missing_adjustment | A HIGHADJ entry had no following word to pair with — the block ended, or was truncated, mid-pair | --- diff --git a/iocx/parsers/pe_relocations.py b/iocx/parsers/pe_relocations.py index d1b6f53..0359025 100644 --- a/iocx/parsers/pe_relocations.py +++ b/iocx/parsers/pe_relocations.py @@ -34,8 +34,9 @@ # claiming arbitrarily many. Real binaries rarely exceed a few thousand. _MAX_BLOCKS = 65536 -# Hard limit on entries per block. A single 4 KiB page can hold at most -# 2048 WORD entries after the 8-byte header, so this is generous. +# Hard limit on entries per block. Offsets are 12-bit, so a page addresses +# 4096 distinct offsets, and IMAGE_REL_BASED_HIGHADJ occupies two slots - +# giving a true structural maximum of 4096 x 2 = 8192 WORD entries. _MAX_ENTRIES_PER_BLOCK = 8192 # IMAGE_REL_BASED_* type names. Values not present here are reported by @@ -50,10 +51,12 @@ 6: "RESERVED", 7: "MACHINE_SPECIFIC_7", # THUMB_MOV32 / RISCV_LOW12I 8: "MACHINE_SPECIFIC_8", # RISCV_LOW12S / LOONGARCH_MARK_LA - 9: "MIPS_JMPADDR16", + 9: "MACHINE_SPECIFIC_9", # MIPS_JMPADDR16 10: "DIR64", } +_RELOC_TYPE_HIGHADJ = 4 # IMAGE_REL_BASED_HIGHADJ occupies two slots + def build_relocation_structure(pe) -> Optional[Dict[str, Any]]: """ @@ -211,7 +214,7 @@ def _decode_block( entries_start = block_rva + _BLOCK_HEADER_SIZE readable = max(0, min(entries_bytes, dir_end - entries_start)) if readable < entries_bytes: - truncations.append("relocation_entries_truncated") + truncations.append("relocation_entries_exceed_directory") readable_entries = min(declared_entries, readable // _ENTRY_SIZE) @@ -225,8 +228,18 @@ def _decode_block( truncations.append("relocation_entries_truncated") readable_entries = len(raw) // _ENTRY_SIZE + skip_next = False for i in range(readable_entries): (word,) = struct.unpack_from("> 12) & 0xF offset = word & 0x0FFF block["entries"].append({ @@ -235,6 +248,11 @@ def _decode_block( "offset": offset, "rva": page_rva + offset, }) + skip_next = (reloc_type == _RELOC_TYPE_HIGHADJ) + + if skip_next: + # Trailing HIGHADJ with no adjustment word - the block ended mid-pair. + block["errors"].append("highadj_missing_adjustment") block["entry_count"] = len(block["entries"]) return block diff --git a/tests/unit/parsers/test_pe_relocations.py b/tests/unit/parsers/test_pe_relocations.py index 28293d7..f07c2f1 100644 --- a/tests/unit/parsers/test_pe_relocations.py +++ b/tests/unit/parsers/test_pe_relocations.py @@ -30,6 +30,7 @@ _MAX_ENTRIES_PER_BLOCK, _RELOC_DIRECTORY_INDEX, _RELOC_TYPE_NAMES, + _MAX_BLOCKS, ) @@ -223,7 +224,51 @@ def test_entries_truncated_when_block_exceeds_window(self): pe = _pe_with_reloc(partial, dir_size=10) # header + 1 entry trunc: List[str] = [] _read_blocks(pe, 0x1000, 10, trunc, []) - assert "relocation_entries_truncated" in trunc + assert "relocation_entries_exceed_directory" in trunc + + def test_short_header_read_is_tagged(self): + """get_data returns fewer than 8 bytes without raising.""" + trunc = [] + pe = _FakePE(bytes(0x1004), _FakeDataDir(0x1000, 0x40)) + out = _read_blocks(pe, 0x1000, 0x40, trunc, []) + assert out == [] + assert trunc == ["relocation_block_header_truncated"] + + def test_entries_read_failure_is_tagged(self): + """get_data raises on the entry region but not the header.""" + class _RaisingPE(_FakePE): + def get_data(self, rva, size): + if rva >= 0x1008: + raise ValueError("unmapped") + return super().get_data(rva, size) + blocks = _build_block(0x2000, [(3, 0x10), (3, 0x20)]) + image = bytearray(0x8000) + image[0x1000:0x1000 + len(blocks)] = blocks + pe = _RaisingPE(bytes(image), _FakeDataDir(0x1000, len(blocks))) + out = build_relocation_structure(pe) + assert "relocation_entries_read_failed" in out["truncations"] + assert out["blocks"][0]["entries"] == [] + + def test_entries_short_read_is_tagged(self): + """Header fits the window, but the image ends mid-entry-array.""" + hdr = _build_block_raw(0x2000, _BLOCK_HEADER_SIZE + 0x10) + image = bytearray(0x1010) # ends 8 bytes into the entries + image[0x1000:0x1008] = hdr + pe = _FakePE(bytes(image), _FakeDataDir(0x1000, _BLOCK_HEADER_SIZE + 0x10)) + out = build_relocation_structure(pe) + assert "relocation_entries_truncated" in out["truncations"] + assert out["blocks"][0]["entry_count"] == 4 # only what was readable + + def test_block_count_cap_is_tagged(self): + """65536 minimal blocks exhaust the walk limit.""" + one = _build_block_raw(0x2000, _BLOCK_HEADER_SIZE) + many = one * _MAX_BLOCKS + image = bytearray(0x1000 + len(many) + 0x10) + image[0x1000:0x1000 + len(many)] = many + pe = _FakePE(bytes(image), _FakeDataDir(0x1000, len(many))) + out = build_relocation_structure(pe) + assert out["block_count"] == _MAX_BLOCKS + assert "relocation_block_max_exceeded" in out["truncations"] # ================================================================= @@ -260,7 +305,7 @@ def test_never_raises_on_short_read(self): pe = _FakePE(bytes(0x100), _FakeDataDir(0x2000, 0x40)) out = build_relocation_structure(pe) assert out is not None - assert out["truncations"] # read failure recorded + assert out["truncations"] == ["relocation_block_read_failed"] # read failure recorded # ================================================================= diff --git a/tests/unit/parsers/test_pe_relocations_highadj.py b/tests/unit/parsers/test_pe_relocations_highadj.py new file mode 100644 index 0000000..f410c8b --- /dev/null +++ b/tests/unit/parsers/test_pe_relocations_highadj.py @@ -0,0 +1,221 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations +import struct +import pytest +from iocx.parsers.pe_relocations import ( + build_relocation_structure, _decode_block, _read_blocks, + _BLOCK_HEADER_SIZE, _ENTRY_SIZE, _RELOC_TYPE_HIGHADJ, +) + +def _pack_entry(reloc_type, offset): + return struct.pack(" len(self._image): + raise ValueError("unmapped RVA") + return bytes(self._image[rva:rva + size]) + +def _pe_with_reloc(blocks, base_rva=0x1000, dir_size=None, image_size=0x8000): + if dir_size is None: + dir_size = len(blocks) + image = bytearray(image_size) + image[base_rva:base_rva + len(blocks)] = blocks + return _FakePE(bytes(image), _FakeDataDir(base_rva, dir_size)) + + +# ================================================================= +# Regression guard for the _RELOC_TYPE_HIGHADJ NameError +# ================================================================= + +class TestHighadjNameErrorRegression: + + @pytest.mark.parametrize("reloc_type", [t for t in range(16) if t != _RELOC_TYPE_HIGHADJ]) + def test_ordinary_entries_never_reference_undefined_name(self, reloc_type): + """ + Every non-HIGHADJ type must decode cleanly. This is the direct + regression guard for the bug where `_RELOC_TYPE_HIGHADJ` was + referenced without being defined: because the comparison ran + unconditionally at the end of every loop iteration (not gated behind + an `if reloc_type == HIGHADJ` branch), it crashed on ANY entry of ANY + type - not just HIGHADJ ones. A single entry of any type is enough to + reach the crashing line, so this is parametrised across all 16 + possible 4-bit type values rather than picking one representative. + """ + bb = _build_block(0x2000, [(reloc_type, 0x10)]) + pe = _pe_with_reloc(bb) + block = _decode_block(pe, 0, 0x1000, 0x2000, len(bb), 0x1000 + len(bb), []) + assert block["entry_count"] == 1 + assert block["entries"][0]["type"] == reloc_type + assert "adjustment" not in block["entries"][0] + + def test_full_page_of_all_types_never_raises(self): + """ + A single block containing every declared type (0-10) plus several + unknown types, repeated to page size, must decode without raising - + the broadest possible sweep of the crashing code path in one fixture. + """ + entries = [(t, (i * 4) & 0xFFF) for i, t in enumerate(list(range(16)) * 20)] + bb = _build_block(0x2000, entries) + out = build_relocation_structure(_pe_with_reloc(bb)) + assert out is not None + assert out["block_count"] == 1 + assert out["errors"] == [] + + def test_build_relocation_structure_does_not_raise_on_any_entry(self): + """ + End-to-end guard at the public API: the crash was reachable from + build_relocation_structure on the very first ordinary entry, so this + pins the full call path rather than only the internal helper. + """ + bb = _build_block(0x2000, [(3, 0x10)]) # ordinary HIGHLOW, not HIGHADJ + out = build_relocation_structure(_pe_with_reloc(bb)) + assert out is not None + assert out["entry_count"] == 1 + + +# ================================================================= +# skip_next state-machine coverage +# ================================================================= + +class TestHighadjSkipNextLogic: + """ + `skip_next` tracks whether the NEXT word is a raw adjustment value (the + second slot of a HIGHADJ pair) rather than an independent type+offset + entry. These tests drive every transition of that flag directly, rather + than relying on the single-pair case to imply the rest. + """ + + def test_highadj_sets_skip_next_and_consumes_following_word(self): + """HIGHADJ -> skip_next True -> next word consumed as `adjustment`, + never decoded as its own entry, and the flag resets to False.""" + bb = _build_block_raw(0x2000, _BLOCK_HEADER_SIZE + 4, + _pack_entry(4, 0x10) + struct.pack(" Date: Mon, 31 Aug 2026 09:59:00 +0100 Subject: [PATCH 02/40] tls parser reason-code documentation review --- docs/specs/reason-codes.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 20df8ec..c8fa689 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -145,7 +145,7 @@ They are separable by a discriminating key: |------------|------------------|-----------------|--------| | **TLS_CALLBACK_OUTSIDE_RANGE** | Callback RVA not within the TLS directory’s `(start, end)` range | Callback = `0x5000`, TLS range = `0x4000–0x4100` | Per‑file | | **TLS_MULTIPLE_DIRECTORIES** | More than one TLS directory is present in the PE | Two `tls_directory` entries in `extended` | Per‑file | -| **TLS_INVALID_RANGE** | TLS directory has `start >= end` (structurally impossible) | Start = `0x6000`, End = `0x6000` | Per‑file | +| **TLS_INVALID_RANGE** | TLS directory has `start >= end` (structurally impossible). The parser independently records `tls_raw_data_end_before_start` in its errors list and sets `raw_data_size` to `None` for this case; the validator derives the finding from the VA fields directly rather than from that tag, so the two never double-count. | Start = `0x6000`, End = `0x6000` | Per‑file | | **TLS_ZERO_LENGTH_DIRECTORY** | TLS directory exists but `start == end` (zero‑length region) | Start = `0x7000`, End = `0x7000` | Per‑file | | **TLS_CALLBACKS_MISSING** | TLS directory is non‑empty but callback pointer is `0` | Start = `0x4000`, End = `0x4100`, Callbacks = `0` | Per‑file | | **TLS_CALLBACK_NOT_MAPPED_TO_SECTION** | Callback RVA does not fall inside any section’s VA range | Callback = `0x90000000` (no section covers it) | Per‑file | @@ -162,9 +162,31 @@ They are separable by a discriminating key: | Sub‑reason | Meaning | |------------|---------| -| header_decode | The fixed IMAGE_TLS_DIRECTORY could not be read or unpacked; unrecoverable, all later checks are skipped | +| header_decode | The fixed IMAGE_TLS_DIRECTORY could not be read or unpacked; unrecoverable, all later checks are skipped. The `errors` key lists the parser tags that triggered it | | callback_array | A parser truncation tag surfaced while walking the callback array (the `region` key names the tag) | +#### `header_decode` — contributing parser tags + +Listed in the issue's `errors` key. Any one of these short-circuits every +later TLS check: + +| Parser tag | Meaning | +|------------|---------| +| tls_directory_read_failed | `pe.get_data` raised when reading the fixed struct | +| tls_directory_truncated | The read returned fewer than the full struct size (24 bytes PE32 / 40 bytes PE32+) | +| tls_directory_unpack_failed | `struct.unpack` failed on the struct bytes (defensive; unreachable past the length guard) | + +#### `callback_array` — `region` values + +One issue is emitted per tag, so a single directory may raise several: + +| region value | Meaning | +|--------------|---------| +| tls_callbacks_read_failed | `pe.get_data` raised while reading a callback slot | +| tls_callbacks_truncated | A callback slot returned fewer than the pointer width (4 bytes PE32 / 8 bytes PE32+) | +| tls_callbacks_unpack_failed | `struct.unpack` failed on a slot (defensive; unreachable past the length guard) | +| tls_callbacks_max_exceeded | The walk hit the parser's hard limit (4096 callbacks) without finding a NULL terminator | + ### TLS_CALLBACK_RVA_INVALID Parser resolution tombstones (callback array unresolvable, `callbacks = []`): @@ -183,6 +205,11 @@ always in `invalid_callback_count`): | below_image_base | A callback VA lies below ImageBase, yielding a negative RVA | | not_mapped | A resolved callback RVA falls inside no section | +Emission is capped at 16 per-target issues; `invalid_callback_count` always +carries the true total. The two tombstone sub-reasons above are emitted at +most once each and are mutually exclusive with the per-target list, since the +parser returns `callbacks = []` in both cases. + --- ## **SIGNATURE ANOMALIES** From f36b82d0258dfe3656d8ef545426bfa39d678f9c Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 31 Aug 2026 11:06:24 +0100 Subject: [PATCH 03/40] Delay-load import DLL name regex tighening and mutually exclusive reason codes --- README.md | 2 +- docs/specs/reason-codes.md | 6 ++- iocx/parsers/pe_delay_imports.py | 38 ++++++++++++---- iocx/validators/delay_imports.py | 24 ++++++++++- tests/unit/parsers/test_pe_delay_imports.py | 48 +++++++++++++++++++++ 5 files changed, 106 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ed6108f..3ad7b90 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index c8fa689..928c413 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -528,9 +528,12 @@ Priority‑resolved; the first matching tag wins: |------------|---------| | dll_name_rva_zero | DLL name RVA was explicitly zero | | read_failed | pe.get_data raised when reading the DLL name string | +| empty_read | The read returned zero bytes | | unterminated | No NUL terminator found within the maximum scan length (512 bytes) | -| dll_name_not_printable | Decoded successfully but contains bytes outside 0x20–0x7E | | non_ascii | Decode produced Unicode replacement characters | +| dll_name_empty | The string terminated immediately — a zero-length DLL name | +| dll_name_not_printable | Contains bytes outside 0x20–0x7E | +| dll_name_too_long | Exceeds 255 characters, the NTFS filename component limit | ### DELAY_IMPORT_INT_IAT_MISMATCH @@ -554,6 +557,7 @@ Priority‑resolved: | hint_unpack_failed | Could not unpack the WORD hint | | name_unterminated | Name string had no NUL terminator within the maximum scan length | | name_non_ascii | Name decode produced Unicode replacement characters | +| name_empty | The symbol name terminated immediately — a zero-length import name | | name_not_printable | Name decoded successfully but contains non‑printable bytes | --- diff --git a/iocx/parsers/pe_delay_imports.py b/iocx/parsers/pe_delay_imports.py index 0b982ed..ae67cde 100644 --- a/iocx/parsers/pe_delay_imports.py +++ b/iocx/parsers/pe_delay_imports.py @@ -46,10 +46,16 @@ # Hard limit on imports per descriptor for the same reason _MAX_IMPORTS_PER_DESCRIPTOR = 16384 -# DLL name structural check: ASCII printable, typical filename charset. -# Conservative — accepts the common cases without trying to validate -# filesystem semantics. -_DLL_NAME_RE = re.compile(r"^[\x20-\x7E]{1,255}$") +# Printable-ASCII structural check, applied to both DLL names and import +# symbol names. Length is NOT constrained here - each caller applies its own +# limit where one is structurally meaningful, so a too-long name is reported +# distinctly from a non-printable one. +_PRINTABLE_ASCII_RE = re.compile(r"^[\x20-\x7E]+$") + +# NTFS filename component limit. A delay-load DLL name is a filename, not a +# path, so 255 is the correct structural bound (MAX_PATH 260 covers a full +# path and does not apply). +_DLL_NAME_MAX_CHARS = 255 def build_delay_import_structure(pe) -> Optional[Dict[str, Any]]: @@ -253,9 +259,18 @@ def _enrich_descriptor( descriptor["errors"].append(err) else: descriptor["dll_name"] = name - descriptor["dll_name_valid"] = bool(_DLL_NAME_RE.match(name)) - if not descriptor["dll_name_valid"]: + # Three distinct structural faults, reported separately: an empty + # name, a non-printable one, and one exceeding the filename limit + # are different anomalies and a consumer triaging on + # "not_printable" should not be shown a length violation. + if name == "": + descriptor["errors"].append("dll_name_empty") + elif not _PRINTABLE_ASCII_RE.match(name): descriptor["errors"].append("dll_name_not_printable") + elif len(name) > _DLL_NAME_MAX_CHARS: + descriptor["errors"].append("dll_name_too_long") + else: + descriptor["dll_name_valid"] = True # ---- INT (Import Name Table) ---- int_rva = descriptor["int_rva"] @@ -330,9 +345,16 @@ def _decode_import_entry( elif name is None: errors.append("name_read_failed") else: - name_valid = bool(re.match(r"^[\x20-\x7E]{1,512}$", name)) - if not name_valid: + # No length cap: _IMPORT_NAME_MAX_LEN already bounds the read + # (effective max 1021 chars after the WORD hint), and MSVC-mangled + # C++ symbols legitimately exceed any smaller limit. Only + # printability is a structural question at this point. + if name == "": + errors.append("name_empty") + elif not _PRINTABLE_ASCII_RE.match(name): errors.append("name_not_printable") + else: + name_valid = True return { "index": index, diff --git a/iocx/validators/delay_imports.py b/iocx/validators/delay_imports.py index 0a7f02e..a3090e3 100644 --- a/iocx/validators/delay_imports.py +++ b/iocx/validators/delay_imports.py @@ -34,14 +34,25 @@ from .decorators import depends_on -# Priority-resolved sub-reasons for per-entry name/RVA pathologies. +# Priority-resolved sub-reasons for DLL-name pathologies. # First-matching wins for deterministic emission. +# Ordered so the earlier, more fundamental failures win: the RVA itself, then +# read faults from _read_asciiz, then content faults from the three-way check. +# The _read_asciiz tags and the content tags are mutually exclusive in +# practice (a non-None err skips the content check entirely), so their +# relative order is defensive rather than load-bearing. _DLL_NAME_ERROR_PRIORITY = [ + # RVA-level "dll_name_rva_zero", + # _read_asciiz failures "read_failed", + "empty_read", "unterminated", - "dll_name_not_printable", "non_ascii", + # content checks (three-way split, mutually exclusive) + "dll_name_empty", + "dll_name_not_printable", + "dll_name_too_long", ] _INT_RVA_ERROR_PRIORITY = [ @@ -60,15 +71,24 @@ "iat_unpack_failed", ] +# Priority-resolved sub-reasons for per-import-entry pathologies. +# First-matching wins for deterministic emission. +# Ordered by decode stage: INT thunk faults, then ordinal faults, then +# IMAGE_IMPORT_BY_NAME read faults, then name content faults. _ENTRY_ERROR_PRIORITY = [ + # INT thunk "int_entry_missing", "int_entry_zero", + # ordinal path "ordinal_zero", + # IMAGE_IMPORT_BY_NAME read faults "name_read_failed", "name_too_short", "hint_unpack_failed", "name_unterminated", "name_non_ascii", + # name content checks (mutually exclusive) + "name_empty", "name_not_printable", ] diff --git a/tests/unit/parsers/test_pe_delay_imports.py b/tests/unit/parsers/test_pe_delay_imports.py index cae4df6..61f2b8b 100644 --- a/tests/unit/parsers/test_pe_delay_imports.py +++ b/tests/unit/parsers/test_pe_delay_imports.py @@ -788,6 +788,54 @@ def fake_read_import_by_name(pe, rva): assert "name_read_failed" in entry["errors"] +class TestNameValidation: + + def test_long_printable_import_name_is_valid(self): + """A 600-char printable symbol (mangled C++) must NOT be flagged.""" + pe = _FakePE(delay_rva=0x1000, delay_size=64, data_by_rva={ + 0x1000: _build_descriptor() + _zero_descriptor(), + 0x2000: _asciiz("test.dll"), + 0x4000: _build_int_iat_array_64([0x6000]), + 0x5000: _build_int_iat_array_64([0x6000]), + 0x6000: _build_import_by_name(0x10, "A" * 600)}) + entry = build_delay_import_structure(pe)["descriptors"][0]["imports"][0] + assert entry["name_valid"] is True + assert entry["errors"] == [] + + def test_empty_import_name_flagged(self): + pe = _FakePE(delay_rva=0x1000, delay_size=64, data_by_rva={ + 0x1000: _build_descriptor() + _zero_descriptor(), + 0x2000: _asciiz("test.dll"), + 0x4000: _build_int_iat_array_64([0x6000]), + 0x5000: _build_int_iat_array_64([0x6000]), + 0x6000: struct.pack(" Date: Mon, 31 Aug 2026 11:27:39 +0100 Subject: [PATCH 04/40] Debug parser documentation addtions following review --- docs/specs/reason-codes.md | 11 ++++++++++- .../structural-validation-deterministic-heuristics.md | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 928c413..d805fa5 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -665,10 +665,19 @@ Priority‑resolved; the first matching tag wins: | pdb_path_unterminated | The PDB path had no NUL terminator within the scan length | | pdb_path_non_ascii | The PDB path decoded but contains non-printable bytes | +*On `pdb_path_unterminated`, pdb_path is still populated — with the region truncated at the 512-byte scan cap — rather than left None. Consumers must check errors before trusting the path.* + ### DEBUG_TABLE_TRUNCATED The `region` field (not `table`, and not `sub_reason`) names the truncated -region. +region: + +| region value | Meaning | +|--------------|---------| +| debug_directory_size_not_entry_aligned | Declared directory size is not a whole multiple of the 28-byte entry stride | +| debug_directory_entry_count_exceeds_max | Declared entry count exceeded the parser's hard limit (256) and was clamped | +| debug_entry_read_failed | `pe.get_data` raised while reading an entry | +| debug_entry_truncated | An entry read returned fewer than 28 bytes | --- diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index 73019b0..8cc7a2f 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -355,6 +355,7 @@ The debug parser is implemented as a pure `struct`-level decoder over both `pe.g - CodeView blobs are read via `PointerToRawData` (raw file offset) first, with a fallback to `AddressOfRawData` (RVA), so extraction is deterministic regardless of which addressing field the producer populated. - The RSDS (PDB 7.0) and NB10 (PDB 2.0) records are decoded against their fixed header layouts; the GUID is formatted in the canonical mixed-endian symbol-server form (Data1/2/3 little-endian, Data4 big-endian) by fixed arithmetic, not library formatting. - The PDB path scan is bounded (512 bytes); an absent terminator emits a deterministic tombstone tag rather than an unbounded read, and non-ASCII bytes are reported rather than silently normalised. +- The CodeView blob read is itself bounded (4096 bytes). The entry's declared `SizeOfData` is used where present, but is clamped to that ceiling, and a declared size of zero falls back to it — so neither an absurd declared size nor a missing one can drive an unbounded read. The debug directory's entry count is bounded on the same principle (256 entries); a declared count above it is clamped and reported rather than walked. The validator then maps these structural states to a small, well-defined set of reason codes (`DEBUG_DIRECTORY_INVALID_HEADER`, `DEBUG_TABLE_TRUNCATED`, `DEBUG_DIRECTORY_ENTRY_MALFORMED`, `DEBUG_ENTRY_RVA_INVALID`), which downstream heuristics and IOC consumers can rely on as a stable contract. Per-entry malformations are priority-resolved so an entry carrying several defects emits one deterministic sub-reason. The PDB path is a high-signal forensic surface; build paths routinely leak project names, usernames, and toolchain layout, so deterministic extraction is a prerequisite for treating it as a reliable triage signal. From 117b15fe14db8eefe28e87bf288040c7eb8a9d3f Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 31 Aug 2026 11:29:32 +0100 Subject: [PATCH 05/40] Add closing paragraph sentence --- docs/specs/structural-validation-deterministic-heuristics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index 8cc7a2f..73af802 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -357,7 +357,7 @@ The debug parser is implemented as a pure `struct`-level decoder over both `pe.g - The PDB path scan is bounded (512 bytes); an absent terminator emits a deterministic tombstone tag rather than an unbounded read, and non-ASCII bytes are reported rather than silently normalised. - The CodeView blob read is itself bounded (4096 bytes). The entry's declared `SizeOfData` is used where present, but is clamped to that ceiling, and a declared size of zero falls back to it — so neither an absurd declared size nor a missing one can drive an unbounded read. The debug directory's entry count is bounded on the same principle (256 entries); a declared count above it is clamped and reported rather than walked. -The validator then maps these structural states to a small, well-defined set of reason codes (`DEBUG_DIRECTORY_INVALID_HEADER`, `DEBUG_TABLE_TRUNCATED`, `DEBUG_DIRECTORY_ENTRY_MALFORMED`, `DEBUG_ENTRY_RVA_INVALID`), which downstream heuristics and IOC consumers can rely on as a stable contract. Per-entry malformations are priority-resolved so an entry carrying several defects emits one deterministic sub-reason. The PDB path is a high-signal forensic surface; build paths routinely leak project names, usernames, and toolchain layout, so deterministic extraction is a prerequisite for treating it as a reliable triage signal. +The validator then maps these structural states to a small, well-defined set of reason codes (`DEBUG_DIRECTORY_INVALID_HEADER`, `DEBUG_TABLE_TRUNCATED`, `DEBUG_DIRECTORY_ENTRY_MALFORMED`, `DEBUG_ENTRY_RVA_INVALID`), which downstream heuristics and IOC consumers can rely on as a stable contract. Per-entry malformations are priority-resolved so an entry carrying several defects emits one deterministic sub-reason. The PDB path is a high-signal forensic surface; build paths routinely leak project names, usernames, and toolchain layout, so deterministic extraction is a prerequisite for treating it as a reliable triage signal. On an unterminated PDB path the extracted value is retained rather than discarded — the path is truncated at the scan cap and the entry carries `pdb_path_unterminated`, so a partial build path remains available for triage while the fault stays explicit. Consumers must therefore check the entry's `errors` before treating `pdb_path` as complete. --- From 0f7c630e504bedc237d76fa737204bc48a0d8009 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 31 Aug 2026 12:32:01 +0100 Subject: [PATCH 06/40] Add missing rva_zero and empty_read error codes to exports validator. --- docs/specs/reason-codes.md | 3 +++ iocx/parsers/pe_exports.py | 32 ++++++++++++++++++++------------ iocx/validators/exports.py | 17 +++++++++++++++++ 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index d805fa5..c394442 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -419,7 +419,9 @@ Priority‑resolved; the first matching tag wins: |------------|---------| | name_rva_missing | Parser did not capture the entry's name RVA | | name_rva_zero | RVA was explicitly zero | +| rva_zero | `_read_asciiz` was called with a zero RVA (defensive; currently unreachable, since the zero case is caught earlier) | | read_failed | pe.get_data raised when reading the name string | +| empty_read | The read returned zero bytes | | unterminated | No NUL terminator found within the maximum scan length | ### EXPORT_NAME_NOT_ASCII @@ -437,6 +439,7 @@ Priority‑resolved: |------------|---------| | missing | Parser could not read the EOT entry | | out_of_range | Ordinal index >= NumberOfFunctions | +| duplicate | Two or more name pointers resolve to the same EAT index; only the last is reflected in the function view, so an export name is silently unreachable through the resolved-function list | ### EXPORT_ORDINAL_OUT_OF_RANGE diff --git a/iocx/parsers/pe_exports.py b/iocx/parsers/pe_exports.py index b64dc2e..74e2582 100644 --- a/iocx/parsers/pe_exports.py +++ b/iocx/parsers/pe_exports.py @@ -37,6 +37,7 @@ name - resolved name if a name pointer maps to this index, else None name_rva - RVA of the name string, if any + errors - per-entry decode errors Each NamePointerEntry has: index - position in name pointer array @@ -60,8 +61,11 @@ # Forwarder string: ASCII printable, "DllName.SymbolName" or "DllName.#Ordinal" # Conservative bounds: name parts up to 255 chars, total up to 512. +# The symbol branch excludes a leading '#' (0x23) so an over-long ordinal +# such as "Dll.#99999999999" cannot fall through to it and be accepted as an +# ordinary symbol name. Ordinals are 16-bit, hence at most five digits. _FORWARDER_RE = re.compile( - r"^[\x20-\x7E]{1,255}\.(?:#\d{1,10}|[\x20-\x7E]{1,255})$" + r"^[\x20-\x7E]{1,255}\.(?:#\d{1,5}|[\x20-\x22\x24-\x7E][\x20-\x7E]{0,254})$" ) # Name string: PE spec allows ASCII; we accept any printable byte range @@ -334,7 +338,7 @@ def _build_name_pointers( EAT-index -> name, used by _build_functions to enrich function entries. """ entries: List[Dict[str, Any]] = [] - name_by_index: Dict[int, str] = {} + name_by_index: Dict[int, tuple] = {} num_names = header["NumberOfNames"] num_funcs = header["NumberOfFunctions"] @@ -367,7 +371,9 @@ def _build_name_pointers( elif name is not None and name_valid: # Record the resolved name against its EAT index for use by # _build_functions - name_by_index[ordinal_index] = name + if ordinal_index in name_by_index: + entry_errors.append("ordinal_index_duplicate") + name_by_index[ordinal_index] = (name, name_rva) entries.append({ "index": i, @@ -391,7 +397,7 @@ def _build_functions( header: Dict[str, int], dir_start: int, dir_end: int, - name_by_index: Dict[int, str], + name_by_index: Dict[int, tuple], ) -> List[Dict[str, Any]]: """ Build the function entry list from the EAT, joining with name resolution @@ -408,24 +414,25 @@ def _build_functions( is_forwarder = False forwarder: Optional[str] = None forwarder_valid = False - name_rva: Optional[int] = None + entry_errors: List[str] = [] if address_rva is not None and address_rva != 0: # PE spec: an EAT entry RVA that points within the export # directory itself is a forwarder string pointer. if dir_start <= address_rva < dir_end: is_forwarder = True - forwarder, _ = _read_asciiz(pe, address_rva, _FORWARDER_MAX_LEN) + forwarder, fwd_error = _read_asciiz( + pe, address_rva, _FORWARDER_MAX_LEN + ) + if fwd_error is not None: + entry_errors.append(fwd_error) if forwarder is not None: forwarder_valid = bool(_FORWARDER_RE.match(forwarder)) + resolved = name_by_index.get(i) # Look up resolved name from name pointer table cross-reference - name = name_by_index.get(i) - if name is not None: - # The name's RVA is recorded in name_pointers, not duplicated - # here — but we expose name_rva as None to keep the shape - # consistent. - name_rva = None + name = resolved[0] if resolved else None + name_rva = resolved[1] if resolved else None entries.append({ "index": i, @@ -436,6 +443,7 @@ def _build_functions( "forwarder_valid": forwarder_valid, "name": name, "name_rva": name_rva, + "errors": entry_errors, }) return entries diff --git a/iocx/validators/exports.py b/iocx/validators/exports.py index 8094957..23f5868 100644 --- a/iocx/validators/exports.py +++ b/iocx/validators/exports.py @@ -33,10 +33,15 @@ # Deterministic priority orders for mapping parser error tags to a single # reason. The validator emits at most one issue per malformed entry per # pathology class; the first tag in priority order wins. +# +# The middle three tags come from _read_asciiz rather than the name-pointer +# walk itself, so they are listed here otherwise they are silently dropped. _NAME_RVA_ERROR_PRIORITY = [ "name_rva_missing", "name_rva_zero", + "rva_zero", # _read_asciiz's own zero-RVA guard "read_failed", + "empty_read", # get_data returned zero bytes "unterminated", ] @@ -241,6 +246,18 @@ def _validate_name_pointers(exp: Dict[str, Any], "num_functions": num_funcs, "sub_reason": "out_of_range"}, )) + elif "ordinal_index_duplicate" in entry_errors: + # Two name pointers resolve to the same EAT index. The function + # view keeps only the last, so an export name becomes unreachable + # through the resolved-function list while remaining present in + # the name pointer table. + issues.append(StructuralIssue( + issue=ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID, + details={"index": index, + "ordinal_index": entry.get("ordinal_index"), + "name": entry.get("name"), + "sub_reason": "duplicate"}, + )) def _validate_name_pointer_ordering(exp: Dict[str, Any], From ad5c0c73e68fd6be7a864455cfda2452d87016d9 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 31 Aug 2026 12:58:43 +0100 Subject: [PATCH 07/40] Exports tests to bring project coverage to 100% --- README.md | 2 +- tests/unit/parsers/test_pe_exports.py | 297 ++++++++++++++++++ .../unit/validators/test_validator_exports.py | 233 ++++++++++++++ 3 files changed, 531 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3ad7b90..c03c779 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + diff --git a/tests/unit/parsers/test_pe_exports.py b/tests/unit/parsers/test_pe_exports.py index ea1f40d..cc5d5b0 100644 --- a/tests/unit/parsers/test_pe_exports.py +++ b/tests/unit/parsers/test_pe_exports.py @@ -26,6 +26,7 @@ _read_dword_array, _read_word_array, _EXPORT_DIRECTORY_SIZE, + _FORWARDER_RE ) @@ -777,6 +778,276 @@ def test_name_pointer_with_truncated_eot_flags_ordinal_index_missing(self): assert result["name_pointers"][0]["ordinal_index"] == 0 +# ================================================================= +# Builders for v0.7.6.2 +# ================================================================= + +def _hdr(base=1, nf=0, nn=0, af=0, an=0, ano=0): + return struct.pack(" validator: "format" + assert entry["forwarder_valid"] is False + + @pytest.mark.parametrize("blob", [None, b"A" * 2000, b""]) + def test_unreadable_forwarder_yields_none(self, blob): + """The three failures that DO return None -> validator: "unreadable".""" + entry = build_export_structure( + _pe_with_forwarder(blob))["functions"][0] + assert entry["forwarder"] is None + + def test_non_forwarder_entries_have_empty_errors(self): + """errors[] must be present and empty on ordinary entries.""" + pe = _FakePEForwader(0x1000, 200, { + 0x1000: _hdr(nf=2, af=0x1100), + 0x1100: _dw([0x9000, 0]), # normal RVA, then unused slot + }) + for entry in build_export_structure(pe)["functions"]: + assert entry["errors"] == [] + assert entry["is_forwarder"] is False + + +# ================================================================= +# (v0.7.6.2) FunctionEntry.name_rva +# ================================================================= + +class TestFunctionNameRva: + """ + name_rva was documented but hard-coded to None. It now carries the RVA + of the resolved name string, sourced from the name-pointer cross-reference. + """ + + def test_named_function_carries_name_rva(self): + pe = _pe_with_names([("Foo", 0)], num_funcs=1) + entry = build_export_structure(pe)["functions"][0] + assert entry["name"] == "Foo" + assert entry["name_rva"] == 0x1400 + + def test_unnamed_function_has_none(self): + pe = _FakePEForwader(0x1000, 200, { + 0x1000: _hdr(nf=1, af=0x1100), 0x1100: _dw([0x2000])}) + entry = build_export_structure(pe)["functions"][0] + assert entry["name"] is None + assert entry["name_rva"] is None + + def test_name_rva_matches_the_name_pointer_entry(self): + """ + The function's name_rva must equal the name_rva recorded on the + name-pointer entry that resolved it - they are the same string. + """ + pe = _pe_with_names([("Alpha", 0), ("Beta", 1)], num_funcs=2) + out = build_export_structure(pe) + by_index = {np["ordinal_index"]: np["name_rva"] + for np in out["name_pointers"]} + for fn in out["functions"]: + if fn["name"] is not None: + assert fn["name_rva"] == by_index[fn["index"]] + + def test_name_rva_only_set_when_name_resolves(self): + """An unreadable name leaves both name and name_rva unset.""" + pe = _FakePEForwader(0x1000, 200, { + 0x1000: _hdr(nf=1, nn=1, af=0x1100, an=0x1200, ano=0x1300), + 0x1100: _dw([0x2000]), 0x1200: _dw([0x1400]), + 0x1300: _wd([0]), + 0x1400: b"A" * 2000, # unterminated + }) + entry = build_export_structure(pe)["functions"][0] + assert entry["name"] is None + assert entry["name_rva"] is None + + +# ================================================================= +# (v0.7.6.2) ordinal_index_duplicate +# ================================================================= + +class TestOrdinalIndexDuplicate: + """ + Two name pointers resolving to the same EAT index silently lost one name + from the function view. The collision is now tagged on the entry that + overwrites. + """ + + def test_duplicate_is_tagged_on_the_colliding_entry(self): + pe = _pe_with_names([("First", 0), ("Second", 0)], num_funcs=2) + out = build_export_structure(pe) + assert out["name_pointers"][0]["errors"] == [] + assert out["name_pointers"][1]["errors"] == ["ordinal_index_duplicate"] + + def test_last_write_wins_in_the_function_view(self): + """ + Documents the consequence: the FIRST name becomes unreachable through + the resolved-function list. This is what makes the tag worth emitting. + """ + pe = _pe_with_names([("First", 0), ("Second", 0)], num_funcs=2) + out = build_export_structure(pe) + assert out["functions"][0]["name"] == "Second" + assert out["functions"][1]["name"] is None + + def test_three_way_collision_tags_each_subsequent_entry(self): + pe = _pe_with_names([("A", 0), ("B", 0), ("C", 0)], num_funcs=2) + out = build_export_structure(pe) + errs = [np["errors"] for np in out["name_pointers"]] + assert errs == [[], ["ordinal_index_duplicate"], + ["ordinal_index_duplicate"]] + assert out["functions"][0]["name"] == "C" + + def test_distinct_indices_are_not_flagged(self): + pe = _pe_with_names([("Alpha", 0), ("Beta", 1)], num_funcs=2) + out = build_export_structure(pe) + assert all(np["errors"] == [] for np in out["name_pointers"]) + assert [f["name"] for f in out["functions"]] == ["Alpha", "Beta"] + + def test_invalid_name_does_not_collide_or_overwrite(self): + """ + An entry whose name failed validation never reaches the collision + check (the elif chain short-circuits), so it neither raises + ordinal_index_duplicate nor displaces the valid name already recorded + against that index. + """ + pe = _pe_with_names([("Good", 0), (b"Bad\x01\x00", 0)], num_funcs=2) + out = build_export_structure(pe) + assert out["name_pointers"][1]["errors"] == ["name_not_printable_ascii"] + assert "ordinal_index_duplicate" not in out["name_pointers"][1]["errors"] + assert out["functions"][0]["name"] == "Good" # not overwritten + + def test_out_of_range_index_does_not_collide(self): + """ + An out-of-range ordinal index is caught by the earlier elif, so it can + never reach the duplicate branch even if two entries share the value. + """ + pe = _pe_with_names([("A", 5), ("B", 5)], num_funcs=2) + out = build_export_structure(pe) + for np in out["name_pointers"]: + assert np["errors"] == ["ordinal_index_out_of_range"] + + +# ================================================================= +# (v0.7.6.2) Forwarder regex +# ================================================================= + +class TestForwarderRegex: + """ + The symbol branch excludes a leading '#' so an over-long ordinal cannot + fall through and be accepted as an ordinary symbol name. Tightening the + digit count alone was insufficient - '#' is a printable character, so + "Dll.#99999999999" matched the symbol alternative. + """ + + @pytest.mark.parametrize("s", [ + "KERNEL32.LoadLibraryA", + "KERNEL32.#42", + "KERNEL32.#0", + "KERNEL32.#65535", # max 16-bit ordinal + "A.B", + "api-ms-win-core.dll.Func", # multiple dots + ]) + def test_valid_forwarders(self, s): + assert _FORWARDER_RE.match(s) is not None + + @pytest.mark.parametrize("s", [ + "NoDotInThisString", + ".LeadingDot", # empty DLL part + "Dll.", # empty symbol part + "KERNEL32.#123456", # 6 digits - exceeds u16 + "KERNEL32.#99999999999", # the case the first fix missed + ]) + def test_invalid_forwarders(self, s): + assert _FORWARDER_RE.match(s) is None + + def test_over_long_ordinal_not_accepted_as_symbol_name(self): + """ + Direct regression guard: '#' must not be a valid first character of + the symbol branch, or the ordinal digit cap is unenforceable. + """ + assert _FORWARDER_RE.match("Dll.#999999") is None + assert _FORWARDER_RE.match("Dll.$999999") is not None # other punct ok + + # ================================================================= # Output contract # ================================================================= @@ -812,6 +1083,32 @@ def test_lists_are_lists_even_when_empty(self): assert isinstance(result["errors"], list) +class TestFunctionEntryContract: + + def test_function_entry_key_set(self): + pe = _pe_with_names([("Foo", 0)], num_funcs=1) + entry = build_export_structure(pe)["functions"][0] + assert set(entry) == { + "index", "ordinal", "address_rva", "is_forwarder", "forwarder", + "forwarder_valid", "name", "name_rva", "errors", + } + + def test_errors_key_present_on_every_entry(self): + pe = _FakePEForwader(0x1000, 200, { + 0x1000: _hdr(nf=3, af=0x1100), + 0x1100: _dw([0x2000, 0, 0x1050]), + 0x1050: _az("K.F"), + }) + for entry in build_export_structure(pe)["functions"]: + assert "errors" in entry + assert isinstance(entry["errors"], list) + + def test_json_serialisable_with_new_fields(self): + import json + pe = _pe_with_names([("Foo", 0), ("Foo", 0)], num_funcs=2) + json.dumps(build_export_structure(pe)) + + # ================================================================= # Determinism # ================================================================= diff --git a/tests/unit/validators/test_validator_exports.py b/tests/unit/validators/test_validator_exports.py index 59b8d62..1f664d3 100644 --- a/tests/unit/validators/test_validator_exports.py +++ b/tests/unit/validators/test_validator_exports.py @@ -676,6 +676,198 @@ def test_multiple_pathology_classes_emit_independently(self): assert ReasonCodes.EXPORT_NAME_RVA_INVALID in codes +# ================================================================= +# (v0.7.6.2) The duplicate branch +# ================================================================= +_METADATA = {"optional_header": {"size_of_image": 0x100000}} + +def _np(index: int, + errors: List[str], + name: Optional[str] = "Alpha", + ordinal_index: Optional[int] = 0, + name_rva: int = 0x1400, + name_valid: bool = True) -> Dict[str, Any]: + """One NamePointerEntry as the parser emits it.""" + return {"index": index, "errors": errors, "name": name, + "ordinal_index": ordinal_index, "name_rva": name_rva, + "name_valid": name_valid} + + +def _internal(name_pointers: List[Dict[str, Any]], + num_functions: int = 2, + functions: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + return {"export_struct": { + "rva": 0x1000, "size": 200, + "header": {"NumberOfFunctions": num_functions, + "NumberOfNames": len(name_pointers), + "Base": 1, "AddressOfFunctions": 0x1100, + "AddressOfNames": 0x1200, "AddressOfNameOrdinals": 0x1300}, + "functions": functions or [], + "name_pointers": name_pointers, + "truncations": [], "errors": []}} + + +def _ordinal_issues(issues): + return [i for i in issues + if i["issue"] == ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID] + + +def _sub_reasons(issues): + return [i["details"].get("sub_reason") for i in _ordinal_issues(issues)] + + +class TestOrdinalIndexDuplicate: + + def test_duplicate_tag_is_emitted(self): + """ + Regression guard: before this branch existed the tag reached the + validator and produced nothing at all - the collision was invisible + downstream. + """ + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + ]), _METADATA) + assert _sub_reasons(issues) == ["duplicate"] + + def test_details_payload(self): + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0, name_rva=0x1408), + ]), _METADATA) + assert _ordinal_issues(issues)[0]["details"] == { + "index": 1, + "ordinal_index": 0, + "name": "Beta", + "sub_reason": "duplicate", + } + + def test_details_carry_the_shadowed_name(self): + """ + The name is what makes this finding actionable: it identifies the + export that became unreachable through the resolved-function list. + Without it a consumer knows only that *some* collision occurred. + """ + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "SecretExport", 0), + ]), _METADATA) + assert _ordinal_issues(issues)[0]["details"]["name"] == "SecretExport" + + def test_no_num_functions_key(self): + """ + Unlike out_of_range, the duplicate branch does not carry + num_functions - the count is irrelevant to a collision. Pinned so the + two payload shapes stay deliberately distinct. + """ + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + ]), _METADATA) + assert "num_functions" not in _ordinal_issues(issues)[0]["details"] + + def test_each_colliding_entry_emits_once(self): + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + _np(2, ["ordinal_index_duplicate"], "Gamma", 0), + ]), _METADATA) + assert _sub_reasons(issues) == ["duplicate", "duplicate"] + assert [i["details"]["index"] for i in _ordinal_issues(issues)] == [1, 2] + + def test_clean_table_emits_nothing(self): + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, [], "Beta", 1), + ]), _METADATA) + assert _ordinal_issues(issues) == [] + + +# ================================================================= +# (v0.7.6.2) Precedence within the elif chain +# ================================================================= + +class TestOrdinalBranchPrecedence: + """ + The three ordinal sub-reasons share one if/elif chain, so an entry + carrying several tags must still emit exactly one issue. + """ + + @pytest.mark.parametrize("errors,expected", [ + (["ordinal_index_missing", "ordinal_index_duplicate"], "missing"), + (["ordinal_index_out_of_range", "ordinal_index_duplicate"], "out_of_range"), + (["ordinal_index_duplicate"], "duplicate"), + (["ordinal_index_missing", "ordinal_index_out_of_range", + "ordinal_index_duplicate"], "missing"), + ]) + def test_first_branch_wins(self, errors, expected): + issues = validate_exports( + _internal([_np(0, errors, "Alpha", 0)]), _METADATA) + assert _sub_reasons(issues) == [expected] + + def test_duplicate_never_double_emits_with_a_sibling(self): + """One issue per entry per pathology class, regardless of tag count.""" + issues = validate_exports(_internal([ + _np(0, ["ordinal_index_out_of_range", "ordinal_index_duplicate"], + "Alpha", 0), + ]), _METADATA) + assert len(_ordinal_issues(issues)) == 1 + + +# ================================================================= +# (v0.7.6.2) Interaction with other pathology classes +# ================================================================= + +class TestCrossClassInteraction: + + def test_duplicate_co_fires_with_name_rva_class(self): + """ + The RVA, encoding and ordinal checks are independent classes, so an + entry may legitimately raise one of each. + """ + issues = validate_exports(_internal([ + _np(0, ["read_failed", "ordinal_index_duplicate"], + None, 0, name_valid=False), + ]), _METADATA) + codes = {i["issue"] for i in issues} + assert ReasonCodes.EXPORT_NAME_RVA_INVALID in codes + assert ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID in codes + + def test_duplicate_co_fires_with_encoding_class(self): + issues = validate_exports(_internal([ + _np(0, ["name_not_printable_ascii", "ordinal_index_duplicate"], + "Bad\x01Name", 0, name_valid=False), + ]), _METADATA) + codes = {i["issue"] for i in issues} + assert ReasonCodes.EXPORT_NAME_NOT_ASCII in codes + assert ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID in codes + + def test_ascending_names_do_not_trip_the_sortedness_check(self): + """ + Control for the fixture convention: with ascending names the duplicate + issue is the ONLY finding, so the assertions above are single-anomaly. + """ + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + ]), _METADATA) + assert len(issues) == 1 + assert issues[0]["issue"] == ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID + + def test_descending_names_additionally_raise_unsorted(self): + """ + Documents why the fixtures ascend: the sortedness check walks the same + table and is independent of the duplicate finding. + """ + issues = validate_exports(_internal([ + _np(0, [], "Zebra", 0), + _np(1, ["ordinal_index_duplicate"], "Alpha", 0), + ]), _METADATA) + codes = {i["issue"] for i in issues} + assert ReasonCodes.EXPORT_NAME_ORDINAL_INDEX_INVALID in codes + assert ReasonCodes.EXPORT_NAME_POINTER_TABLE_UNSORTED in codes + + # ================================================================= # Output contract # ================================================================= @@ -743,6 +935,35 @@ def test_top_level_decode_avoids_reserved_reason_key(self): assert issues assert "reason" not in issues[0]["details"] + def test_issue_shape(self): + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + ]), _METADATA) + for issue in issues: + assert set(issue) == {"issue", "details"} + assert isinstance(issue["details"], dict) + + def test_no_reserved_reason_key(self): + """ + "reason" is reserved by the heuristics emission layer; a details key of + that name would overwrite the parent code. + """ + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + ]), _METADATA) + assert issues + assert all("reason" not in i["details"] for i in issues) + + def test_json_serialisable(self): + import json + issues = validate_exports(_internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + ]), _METADATA) + json.dumps(issues) + # ================================================================= # Determinism @@ -787,3 +1008,15 @@ def test_priority_resolution_deterministic(self): # Confirm priority winner details = _details_for(results[0], ReasonCodes.EXPORT_NAME_RVA_INVALID) assert details[0]["sub_reason"] == "name_rva_missing" + + def test_deterministic_dedupe(self): + import json + internal = _internal([ + _np(0, [], "Alpha", 0), + _np(1, ["ordinal_index_duplicate"], "Beta", 0), + _np(2, ["ordinal_index_duplicate"], "Gamma", 0), + ]) + first = json.dumps(validate_exports(internal, _METADATA), sort_keys=True) + for _ in range(20): + assert json.dumps(validate_exports(internal, _METADATA), + sort_keys=True) == first From ca384fc4f320defc1c05a4132e245dd412ffd982 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 1 Sep 2026 10:26:53 +0100 Subject: [PATCH 08/40] Initial commit of imports parser, validator, tests and documentation --- docs/specs/reason-codes.md | 104 ++- ...ral-validation-deterministic-heuristics.md | 36 +- iocx/engine.py | 2 + iocx/parsers/pe_imports.py | 514 ++++++++++++ iocx/reason_codes.py | 7 + iocx/schemas/internal_schema.py | 67 ++ iocx/validators/__init__.py | 7 + iocx/validators/imports.py | 225 ++++++ .../broken_rva_addresses.full.json | 10 + .../directory_zero_size_nonzero_rva.full.json | 10 + .../franken_malformed_pe.full.json | 10 + .../franken_malformed_pe.pe32.full.json | 10 + .../malformed_import_table.full.json | 10 + .../integration/test_franken_malformed_pe.py | 3 +- tests/unit/parsers/test_pe_imports.py | 740 ++++++++++++++++++ .../unit/validators/test_validator_imports.py | 625 +++++++++++++++ 16 files changed, 2377 insertions(+), 3 deletions(-) create mode 100644 iocx/parsers/pe_imports.py create mode 100644 iocx/validators/imports.py create mode 100644 tests/unit/parsers/test_pe_imports.py create mode 100644 tests/unit/validators/test_validator_imports.py diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index c394442..e49add3 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -133,7 +133,6 @@ They are separable by a discriminating key: | **DATA_DIRECTORY_NOT_MAPPED_TO_SECTION** | Directory is in range but does not fall inside any section | RVA = 0x9000, Size = 0x200, no section covers it | Per‑directory *(suppressed for empty, zero‑RVA, zero‑size, out‑of‑range and zero‑length‑section directories)* | | **DATA_DIRECTORY_SPANS_MULTIPLE_SECTIONS** | Directory range overlaps more than one section | RVA = 0x1800, Size = 0x1000 spans .text → .rdata | Per‑directory | | **DATA_DIRECTORY_OVERLAP** | Two directories’ RVA ranges overlap | Import and IAT overlap | Global | -| **IMPORT_RVA_INVALID** *coming soon* | Import RVA does not map to a valid import table structure (import validator) | Import RVA = 0x9000 | Per‑directory | > Prior to the `raw_offset` guard fix, `DATA_DIRECTORY_NOT_MAPPED_TO_SECTION` was additionally suppressed for any directory in a file carrying an overlay, because the raw-mapping guard skipped the section-mapping checks entirely. Files analysed before that fix may under-report it. @@ -565,6 +564,109 @@ Priority‑resolved: --- +## **IMPORT ANOMALIES** + +*Added in v0.7.6.2 (validator §2.16). Backed by the `pe_imports` struct-level +decoder over the 20-byte `IMAGE_IMPORT_DESCRIPTOR` array. Directory placement +and bounds remain owned by the RVA-graph backbone — both the import directory +(index 1) and the IAT directory (index 12) are plain RVAs, so this validator +defers entirely rather than double-counting. The bound-import directory +(index 11) is a separate structure and is not decoded. Absence of an import +directory is not a defect.* + +> **`OriginalFirstThunk == 0` is legal here.** Unlike the delay-load INT, a +> zero `OriginalFirstThunk` is common: older linkers emit only `FirstThunk`, +> which then holds INT-style thunks on disk. The parser falls back to it and +> records `thunk_source: "iat_fallback"` **without** raising an anomaly. Only +> when the descriptor is *also* old-style bound — so `FirstThunk` holds +> resolved addresses rather than thunks — are names genuinely unrecoverable. + +| Reason Code | What Triggers It | Example Pattern | Scope | +|------------|------------------|-----------------|--------| +| **IMPORT_DIRECTORY_INVALID_HEADER** | Top-level decode failure; short-circuits every later import check | Descriptor array unreadable at the declared RVA | Per‑file | +| **IMPORT_TABLE_TRUNCATED** | A parser truncation tag surfaced while walking the descriptor array or a thunk array | Descriptor array reaches the declared end with no zero terminator | Per‑file *(one issue per tag; `table` names the cause)* | +| **IMPORT_DESCRIPTOR_INVALID** | A descriptor identifies a module but has no readable source of imported symbol names | Old-style bound with `OriginalFirstThunk = 0`; or both thunk RVAs zero | Per‑descriptor *(priority-resolved sub-reason)* | +| **IMPORT_DLL_NAME_INVALID** | A descriptor's DLL name RVA is zero, unreadable, unterminated, empty, non-printable, or exceeds the filename limit | Name RVA = 0x0, or name = `"kernel32\x01dll"` | Per‑descriptor *(priority-resolved sub-reason)* | +| **IMPORT_ENTRY_INVALID** | A per-import entry is malformed: a zero ordinal, or an `IMAGE_IMPORT_BY_NAME` that is unreadable, too short, unterminated, empty or non-printable | Thunk with the high bit set but ordinal = 0 | Per‑entry *(priority-resolved; emission capped, see below)* | + +## IMPORT SUB‑REASONS + +### IMPORT_DIRECTORY_INVALID_HEADER + +| Sub‑reason | Meaning | +|------------|---------| +| top_level_decode | The parser could not complete top-level decoding; the `errors` key lists the contributing parser tags | + +### IMPORT_TABLE_TRUNCATED + +The `table` field (not `sub_reason`) identifies the truncation cause. Thunk +tags are prefixed by the array actually read, so a consumer can tell whether +the INT or the fallback IAT was short: + +| table value | Meaning | +|-------------|---------| +| import_descriptor_truncated | A descriptor's 20-byte structure came back short | +| import_descriptor_read_failed | `pe.get_data` raised while reading a descriptor | +| import_descriptor_unterminated | The declared directory size was reached with no zero descriptor | +| import_descriptor_max_exceeded | Hit the hard descriptor limit (4096) without finding a terminator | +| int_truncated / iat_fallback_truncated | A thunk read came back shorter than the pointer width | +| int_read_failed / iat_fallback_read_failed | `pe.get_data` raised while reading a thunk | +| int_unpack_failed / iat_fallback_unpack_failed | `struct.unpack` failed on a thunk (defensive; unreachable past the length guard) | +| int_max_exceeded / iat_fallback_max_exceeded | Hit the imports-per-descriptor limit (16384) without a NULL thunk | + +### IMPORT_DESCRIPTOR_INVALID + +Priority‑resolved; the first matching tag wins. Mutually exclusive in +practice — the parser returns immediately after recording either: + +| Sub‑reason | Meaning | +|------------|---------| +| names_unrecoverable_bound_no_int | The descriptor is old-style bound (`TimeDateStamp` neither 0 nor 0xFFFFFFFF) and `OriginalFirstThunk` is zero, so `FirstThunk` holds resolved addresses and no name table exists | +| no_thunk_array | Both `OriginalFirstThunk` and `FirstThunk` are zero — the descriptor names no imports at all | + +Details carry `bound_state`, `original_first_thunk` and `first_thunk` so the +reason a name source is unavailable is visible without re-reading the file. + +### IMPORT_DLL_NAME_INVALID + +Priority‑resolved; the first matching tag wins: + +| Sub‑reason | Meaning | +|------------|---------| +| dll_name_rva_zero | The descriptor's Name RVA was explicitly zero | +| rva_zero | `_read_asciiz` was called with a zero RVA (defensive; the zero case is caught earlier) | +| read_failed | `pe.get_data` raised when reading the name string | +| empty_read | The read returned zero bytes | +| unterminated | No NUL terminator within the maximum scan length (512 bytes) | +| non_ascii | Decode produced Unicode replacement characters | +| dll_name_empty | The string terminated immediately — a zero-length DLL name | +| dll_name_not_printable | Contains bytes outside 0x20–0x7E | +| dll_name_too_long | Exceeds 255 characters, the NTFS filename component limit | + +### IMPORT_ENTRY_INVALID + +Priority‑resolved; the first matching tag wins: + +| Sub‑reason | Meaning | +|------------|---------| +| ordinal_zero | High bit set on the thunk but the ordinal value is zero | +| name_rva_zero | The thunk's `IMAGE_IMPORT_BY_NAME` RVA was zero | +| name_read_failed | `pe.get_data` raised when reading the hint+name structure | +| name_too_short | The buffer was fewer than 3 bytes (WORD hint plus at least one name byte) | +| hint_unpack_failed | Could not unpack the WORD hint (defensive) | +| name_unterminated | No NUL terminator within the maximum scan length (1024 bytes) | +| name_non_ascii | Decode produced Unicode replacement characters | +| name_empty | The symbol name terminated immediately — a zero-length import name | +| name_not_printable | Contains bytes outside 0x20–0x7E. Length is not constrained: the 1024-byte read is the only bound, so mangled C++ symbols are accepted | + +Emission is capped at 32 issues **per descriptor**; `invalid_entry_count` +always carries the true total for that descriptor. The cap is per-descriptor +rather than per-file, so a heavily malformed first module does not silence +later ones. Note the count is of *invalid* entries, not of the descriptor's +whole import list. + +--- + ## **RELOCATION ANOMALIES** *Added in v0.7.6 (validator §2.13). Backed by the `pe_relocations` struct-level decoder over `IMAGE_BASE_RELOCATION` blocks. Directory placement/bounds remain owned by the RVA-graph backbone; these codes cover block-stream and per-entry structural truth. `IMAGE_REL_BASED_ABSOLUTE` (type 0) padding entries are never flagged, and absence of a relocation directory is not a defect.* diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index 73af802..58f1b53 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -43,7 +43,7 @@ Some structural metadata extracted by parsers is **producer-facing**: it exists > validator self-contained. Their codes are disjoint identifiers, so a directory > overrunning `SizeOfImage` surfaces under both the backbone's `DATA_DIRECTORY_OUT_OF_RANGE` > and the subsystem's own code — two distinct labels for one fact, not one code -> emitted twice. Relocations, debug and the security directory defer entirely. +> emitted twice. Relocations, debug and imports defer entirely, emitting no placement code of their own — though a directory placed outside the image will still surface in those subsystems as a truncation, since the parser cannot read it. The security directory defers for a different reason: its VirtualAddress is a file offset, not an RVA, so the backbone's checks do not apply at all. --- @@ -387,6 +387,40 @@ The validator then maps these structural states to a well-defined set of reason --- +# **2.16 Imports Validator** + +### Validates the structural integrity of the PE import table extracted by pe_imports. + +This validator performs: + +- Top-level decode failure detection and short-circuit for an unrecoverable descriptor array. +- Directory placement is **not** re-checked here — `DATA_DIRECTORY_OUT_OF_RANGE` and `DATA_DIRECTORY_NOT_MAPPED_TO_SECTION` from the RVA-graph backbone (§2.5) own it for both the import directory (index 1) and the IAT directory (index 12), so this validator emits no placement code of its own. Note that a badly placed directory still surfaces here as `IMPORT_TABLE_TRUNCATED` with `table: import_descriptor_read_failed`: the parser cannot read what the backbone has already declared out of range, and the resulting silence is itself a structural fact. The two codes are complementary rather than duplicative — the backbone reports that the declaration is wrong, this validator reports that nothing was recoverable as a consequence. Relocations (§2.13) and debug (§2.14) behave identically. +- Truncation reporting across the descriptor array and per-descriptor thunk arrays, with the tag prefixed by the array actually read. +- Per-descriptor name-source validation: a module identified but carrying no readable table of imported symbol names. +- DLL name string validation: RVA presence, readability, NUL termination, emptiness, printable ASCII compliance, and the filename length bound. +- Per-entry validation of thunks and `IMAGE_IMPORT_BY_NAME` structures, including ordinal validity, hint readability, and name structural correctness. + +Absence of an import directory is not treated as a structural defect — resource-only DLLs and some drivers legitimately import nothing. The bound-import directory (index 11) is a separate structure with its own descriptor and forwarder-reference arrays; descriptor-level bound *state* is interpreted here, but that table is not decoded and is out of scope for this release. + +The import table looks like the delay-load table and is not. Two properties make general-purpose import parsers prone to inconsistent output, and both are cases where a plausible implementation misreports rather than fails loudly: + +`OriginalFirstThunk` may legally be zero. The delay-load INT may not — a zero there is a genuine anomaly — but older linkers (Borland TLINK, some Microsoft toolchains) emit standard imports with only `FirstThunk`, which then holds INT-style thunks on disk that the loader overwrites with resolved addresses at load time. A parser that copies the delay-load treatment flags a large fraction of legitimate binaries; a parser that simply reads `OriginalFirstThunk` unconditionally reports those same binaries as importing nothing. Neither failure is visible without a fixture that exercises the zero case. + +`TimeDateStamp` silently changes what `FirstThunk` contains. Zero means unbound and `FirstThunk` mirrors the name table on disk. `0xFFFFFFFF` means "new-style" bound, with the real timestamps held in the bound-import directory — `FirstThunk` still holds thunks. Any other value means "old-style" bound, and `FirstThunk` holds resolved addresses. The three states are indistinguishable from the thunk bytes alone, so a parser that ignores the field will decode addresses as though they were ordinals-or-name-RVAs and emit fabricated import names. + +The imports parser is implemented as a pure `struct`-level decoder over `pe.get_data`-acquired byte buffers: + +- The 20-byte `IMAGE_IMPORT_DESCRIPTOR` is unpacked via a single `struct.unpack_from` call. No reliance on pefile's `DIRECTORY_ENTRY_IMPORT` interpretation. +- The name source is selected explicitly rather than assumed: `OriginalFirstThunk` when present, otherwise `FirstThunk`, with the choice recorded in `thunk_source` so a consumer can see which array was read. The fallback is treated as normal, not as an anomaly. +- Bound state is derived from `TimeDateStamp` at decode time. The one combination that genuinely defeats name recovery — old-style bound *with* a zero `OriginalFirstThunk` — is reported as a structural fact rather than parsed as garbage. +- The descriptor array walk has both an explicit zero-descriptor terminator check and a hard count limit (4096), with distinct truncation tags for each termination cause. +- Thunk arrays are walked with the same dual-bounded strategy: NULL-terminator detection plus a hard limit (16384 per descriptor). Truncation tags carry the `int` or `iat_fallback` prefix of the array actually read. +- Ordinals are masked to the low 16 bits per the PE spec, matching the loader; the DLL name scan is bounded at 512 bytes and the `IMAGE_IMPORT_BY_NAME` scan at 1024. Name length is not otherwise constrained — mangled C++ symbols legitimately exceed any smaller limit — so printability and length are reported as distinct faults rather than conflated. + +The validator then maps these structural states to a small, well-defined set of reason codes (`IMPORT_DIRECTORY_INVALID_HEADER`, `IMPORT_TABLE_TRUNCATED`, `IMPORT_DESCRIPTOR_INVALID`, `IMPORT_DLL_NAME_INVALID`, `IMPORT_ENTRY_INVALID`), which downstream heuristics and IOC consumers can rely on as a stable contract. Per-descriptor and per-entry malformations are priority-resolved so a descriptor carrying several defects emits one deterministic sub-reason per pathology class, and the per-entry emission is capped at 32 issues per descriptor with the true count always present in the issue details — capped per descriptor rather than per file, so a heavily malformed first module cannot silence the ones after it. The import table is the highest-value forensic surface in the PE format for behavioural triage: the set of resolved DLL and symbol names is what most capability heuristics are built on, so a descriptor whose names cannot be recovered is a fact worth reporting explicitly rather than presenting as an empty import list. + +--- + # **3. Deterministic Heuristics Layer** ### *Heuristics interpret structural truth — they never override it.* diff --git a/iocx/engine.py b/iocx/engine.py index ced38ce..2432326 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -15,6 +15,7 @@ from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata from .parsers.pe_exports import build_export_structure +from .parsers.pe_imports import build_import_structure from .parsers.pe_delay_imports import build_delay_import_structure from .parsers.pe_relocations import build_relocation_structure from .parsers.pe_debug import build_debug_structure @@ -172,6 +173,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: self._internal_metadata["resources_struct"] = build_resource_structure(pe) self._internal_metadata["version_info_struct"] = build_version_info(pe) self._internal_metadata["export_struct"] = build_export_structure(pe) + self._internal_metadata["import_struct"] = build_import_structure(pe) self._internal_metadata["delay_import_struct"] = build_delay_import_structure(pe) self._internal_metadata["data_directories_raw"] = analyse_data_directories_raw(pe) self._internal_metadata["relocation_struct"] = build_relocation_structure(pe) diff --git a/iocx/parsers/pe_imports.py b/iocx/parsers/pe_imports.py new file mode 100644 index 0000000..78e1e3d --- /dev/null +++ b/iocx/parsers/pe_imports.py @@ -0,0 +1,514 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Deterministic structural extraction of the PE import table. + +Independent of pefile's DIRECTORY_ENTRY_IMPORT interpretation. +Pefile is used only to: + - Locate the import directory (RVA, size) + - Determine PE32 vs PE32+ via OPTIONAL_HEADER.Magic + - Resolve RVAs to file offsets via pe.get_data + +All structural fields are decoded from the raw 20-byte +IMAGE_IMPORT_DESCRIPTOR structure: + + DWORD OriginalFirstThunk # RVA of the INT. MAY BE ZERO - see below. + DWORD TimeDateStamp # 0 = unbound; -1 = new-style bound + DWORD ForwarderChain # index of first forwarder, -1 = none + DWORD Name # RVA of the ASCIIZ DLL name + DWORD FirstThunk # RVA of the IAT. Always required. + +TWO DIVERGENCES FROM DELAY-LOAD: + +1. `OriginalFirstThunk == 0` is LEGAL for standard imports, unlike the + delay-load INT. Older linkers (Borland TLINK, some MS toolchains) emit + only FirstThunk; on disk that array then holds INT-style thunks which the + loader overwrites with addresses at load time. The parser therefore FALLS + BACK to FirstThunk for name resolution and records which array it used in + `thunk_source`. Flagging this as an anomaly would misreport a large + fraction of legitimate binaries. + +2. `TimeDateStamp` selects how FirstThunk should be read: + 0 - unbound; FirstThunk mirrors the INT on disk + 0xFFFFFFFF - "new-style" bound; real timestamps live in + IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (index 11) + anything else - "old-style" bound; FirstThunk holds RESOLVED + ADDRESSES on disk, not thunks + When old-style bound AND OriginalFirstThunk is zero there is no readable + name source at all, which is a genuine structural fact rather than a + parse failure - tagged `names_unrecoverable_bound_no_int`. + +The bound-import directory (index 11) is a separate structure and is NOT +parsed here. + +Output contract: + None - no import directory present (not an error) + dict per the documented contract (see ImportStruct in + iocx.schemas.internal_schema). +""" + +from __future__ import annotations + +import re +import struct +from typing import Any, Dict, List, Optional, Tuple + +# IMAGE_DIRECTORY_ENTRY_IMPORT = 1 +_IMPORT_DIRECTORY_INDEX = 1 +_DESCRIPTOR_SIZE = 20 # IMAGE_IMPORT_DESCRIPTOR is 20 bytes + +# OPTIONAL_HEADER.Magic values +_MAGIC_PE32 = 0x10B +_MAGIC_PE32_PLUS = 0x20B + +# TimeDateStamp sentinel for "new-style" bound imports. +_BOUND_NEW_STYLE = 0xFFFFFFFF + +# DLL name length cap to defend against unterminated reads. +_DLL_NAME_MAX_LEN = 512 +# Import-by-name length cap. Mangled C++ symbols legitimately run long, so +# this bounds the read only; printability is checked separately. +_IMPORT_NAME_MAX_LEN = 1024 + +# Hard limit on descriptors to defend against a directory claiming +# arbitrarily many. Real binaries rarely exceed a few hundred. +_MAX_DESCRIPTORS = 4096 + +# Hard limit on imports per descriptor, same rationale. +_MAX_IMPORTS_PER_DESCRIPTOR = 16384 + +# Printable-ASCII structural check, applied to both DLL names and import +# symbol names. Length is NOT constrained here - each caller applies its own +# limit where one is structurally meaningful, so a too-long name is reported +# distinctly from a non-printable one. +_PRINTABLE_ASCII_RE = re.compile(r"^[\x20-\x7E]+$") + +# NTFS filename component limit. An import DLL name is a filename, not a +# path, so 255 is the correct structural bound. +_DLL_NAME_MAX_CHARS = 255 + + +def build_import_structure(pe) -> Optional[Dict[str, Any]]: + """ + Locate and structurally decode the PE import table. + + Returns None if no import directory is present. Otherwise returns a dict + per the module docstring contract. Never raises; decode failures produce + tombstone entries in `errors` and `truncations`. + """ + placement = _locate_import_directory(pe) + if placement is None: + return None + + rva, size = placement + is_64bit = _is_pe32_plus(pe) + thunk_size = 8 if is_64bit else 4 + truncations: List[str] = [] + errors: List[str] = [] + + descriptors = _read_descriptors( + pe, rva, size, thunk_size, truncations, errors, + ) + + return { + "rva": rva, + "size": size, + "is_64bit": is_64bit, + "descriptors": descriptors, + "descriptor_count": len(descriptors), + "truncations": truncations, + "errors": errors, + } + + +# ================================================================= +# Locator +# ================================================================= + +def _locate_import_directory(pe) -> Optional[Tuple[int, int]]: + """Return (rva, size) of the import directory, or None if absent.""" + try: + data_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[_IMPORT_DIRECTORY_INDEX] + rva = int(data_dir.VirtualAddress) + size = int(data_dir.Size) + except (AttributeError, IndexError, ValueError, TypeError): + return None + + if rva == 0 or size == 0: + return None + + return (rva, size) + + +def _is_pe32_plus(pe) -> bool: + """Determine PE32+ (64-bit) by reading OPTIONAL_HEADER.Magic.""" + try: + return int(pe.OPTIONAL_HEADER.Magic) == _MAGIC_PE32_PLUS + except (AttributeError, ValueError, TypeError): + # Default to 32-bit if we cannot tell. Conservative - narrower + # thunks, lower risk of over-reading. + return False + + +# ================================================================= +# Descriptor array +# ================================================================= + +def _read_descriptors( + pe, + base_rva: int, + declared_size: int, + thunk_size: int, + truncations: List[str], + errors: List[str], +) -> List[Dict[str, Any]]: + """ + Walk the array of IMAGE_IMPORT_DESCRIPTOR structures. + + The array is terminated by an all-zero descriptor. We also stop at the + declared directory size and enforce a hard count limit. + """ + descriptors: List[Dict[str, Any]] = [] + pos = base_rva + end = base_rva + declared_size + + for index in range(_MAX_DESCRIPTORS): + if pos + _DESCRIPTOR_SIZE > end: + # Reached the declared end without a zero terminator. Only + # meaningful if we decoded at least one descriptor. + if descriptors: + truncations.append("import_descriptor_unterminated") + break + + try: + raw = bytes(pe.get_data(pos, _DESCRIPTOR_SIZE)) + except Exception: + truncations.append("import_descriptor_read_failed") + break + + if len(raw) < _DESCRIPTOR_SIZE: + truncations.append("import_descriptor_truncated") + break + + decoded = _decode_descriptor(raw, index) + if decoded is None: # pragma: no cover - guarded by the length check + errors.append("descriptor_unpack_failed") + break + + # Zero terminator ends the array and is not itself an entry. + if _is_zero_descriptor(decoded): + break + + _enrich_descriptor(pe, decoded, thunk_size, truncations) + descriptors.append(decoded) + pos += _DESCRIPTOR_SIZE + else: + truncations.append("import_descriptor_max_exceeded") + + return descriptors + + +def _decode_descriptor(buf: bytes, index: int) -> Optional[Dict[str, Any]]: + """Unpack a 20-byte IMAGE_IMPORT_DESCRIPTOR.""" + try: + (original_first_thunk, timestamp, forwarder_chain, + name_rva, first_thunk) = struct.unpack_from(" bool: + """An all-zero descriptor signals end of array.""" + return ( + d["original_first_thunk"] == 0 + and d["timestamp"] == 0 + and d["forwarder_chain"] == 0 + and d["name_rva"] == 0 + and d["first_thunk"] == 0 + ) + + +# ================================================================= +# Per-descriptor enrichment +# ================================================================= + +def _enrich_descriptor( + pe, + descriptor: Dict[str, Any], + thunk_size: int, + truncations: List[str], +) -> None: + """Read the DLL name and walk the name-source thunk array.""" + _read_dll_name(pe, descriptor) + + int_rva = descriptor["original_first_thunk"] + iat_rva = descriptor["first_thunk"] + bound_state = descriptor["bound_state"] + + # ---- Select the array to read names from ---- + # + # Normal case: OriginalFirstThunk points at the INT. + # + # Fallback: OriginalFirstThunk == 0 is legal; FirstThunk then holds the + # INT-style thunks on disk. This is NOT an anomaly. + # + # Exception: if the descriptor is old-style bound, FirstThunk holds + # resolved ADDRESSES rather than thunks, so with no INT there is no + # readable name source at all. + if int_rva != 0: + source_rva, source = int_rva, "int" + elif iat_rva != 0: + if bound_state == "bound_old_style": + descriptor["errors"].append("names_unrecoverable_bound_no_int") + return + source_rva, source = iat_rva, "iat_fallback" + else: + # Neither array present - the descriptor names no imports at all. + descriptor["errors"].append("no_thunk_array") + return + + descriptor["thunk_source"] = source + + thunks = _read_thunk_array( + pe, source_rva, thunk_size, source, + descriptor["errors"], truncations, + ) + + high_bit = 1 << (thunk_size * 8 - 1) + for i, value in enumerate(thunks): + descriptor["imports"].append( + _decode_import_entry(pe, i, value, high_bit) + ) + + +def _read_dll_name(pe, descriptor: Dict[str, Any]) -> None: + """Read and structurally check the ASCIIZ DLL name.""" + name_rva = descriptor["name_rva"] + if name_rva == 0: + descriptor["errors"].append("dll_name_rva_zero") + return + + name, err = _read_asciiz(pe, name_rva, _DLL_NAME_MAX_LEN) + if err is not None: + descriptor["errors"].append(err) + return + + descriptor["dll_name"] = name + # Three distinct structural faults, reported separately: an empty name, a + # non-printable one, and one exceeding the filename limit are different + # anomalies and a consumer triaging on "not_printable" should not be + # shown a length violation. + if name == "": + descriptor["errors"].append("dll_name_empty") + elif not _PRINTABLE_ASCII_RE.match(name): + descriptor["errors"].append("dll_name_not_printable") + elif len(name) > _DLL_NAME_MAX_CHARS: + descriptor["errors"].append("dll_name_too_long") + else: + descriptor["dll_name_valid"] = True + + +def _decode_import_entry( + pe, + index: int, + thunk_value: int, + high_bit: int, +) -> Dict[str, Any]: + """ + Build one ImportEntry from a thunk value. + + Thunk semantics: + - High bit set: the low 16 bits are an ordinal. + - High bit clear: the value is an RVA to IMAGE_IMPORT_BY_NAME. + """ + errors: List[str] = [] + is_ordinal = False + ordinal: Optional[int] = None + hint: Optional[int] = None + name: Optional[str] = None + name_rva: Optional[int] = None + name_valid = False + + if thunk_value & high_bit: + is_ordinal = True + # Ordinal is the low 16 bits per PE spec. Bits between 16 and the + # high flag are discarded, matching the loader. + ordinal = thunk_value & 0xFFFF + if ordinal == 0: + errors.append("ordinal_zero") + else: + name_rva = thunk_value + hint, name, read_err = _read_import_by_name(pe, name_rva) + if read_err is not None: + errors.append(read_err) + elif name is None: # pragma: no cover - defensive + errors.append("name_read_failed") + else: + # No length cap: _IMPORT_NAME_MAX_LEN already bounds the read, + # and mangled C++ symbols legitimately exceed any smaller limit. + if name == "": + errors.append("name_empty") + elif not _PRINTABLE_ASCII_RE.match(name): + errors.append("name_not_printable") + else: + name_valid = True + + return { + "index": index, + "thunk_value": thunk_value, + "is_ordinal": is_ordinal, + "ordinal": ordinal, + "hint": hint, + "name": name, + "name_rva": name_rva, + "name_valid": name_valid, + "errors": errors, + } + + +# ================================================================= +# Thunk array reader +# ================================================================= + +def _read_thunk_array( + pe, + rva: int, + thunk_size: int, + tag: str, + descriptor_errors: List[str], + truncations: List[str], +) -> List[int]: + """ + Read a NULL-terminated array of thunks. + + Walks one thunk at a time until a zero terminator is found, the + per-descriptor limit is hit, or the read fails. + """ + thunks: List[int] = [] + pos = rva + fmt = " Tuple[Optional[str], Optional[str]]: + """ + Read a NUL-terminated ASCII string at `rva`. + Returns (string, error_tag); error_tag is None on success. + """ + if rva == 0: + return None, "rva_zero" + + try: + raw = bytes(pe.get_data(rva, max_len)) + except Exception: + return None, "read_failed" + + if not raw: + return None, "empty_read" + + nul_pos = raw.find(b"\x00") + if nul_pos == -1: + return None, "unterminated" + + try: + return raw[:nul_pos].decode("ascii"), None + except UnicodeDecodeError: + return raw[:nul_pos].decode("ascii", errors="replace"), "non_ascii" + + +def _read_import_by_name( + pe, + rva: int, +) -> Tuple[Optional[int], Optional[str], Optional[str]]: + """ + Read IMAGE_IMPORT_BY_NAME: + WORD Hint + BYTE Name[] (NUL-terminated ASCII) + + Returns (hint, name, error_tag). + """ + if rva == 0: + return None, None, "name_rva_zero" + + try: + raw = bytes(pe.get_data(rva, _IMPORT_NAME_MAX_LEN)) + except Exception: + return None, None, "name_read_failed" + + if len(raw) < 3: + return None, None, "name_too_short" + + try: + (hint,) = struct.unpack_from("validator contract and must be kept exhaustive. + +# descriptor["errors"], DLL-name class. +# Ordered: the RVA itself, then read faults from _read_asciiz, then content +# faults from the three-way split. The read tags and the content tags are +# mutually exclusive in practice (a non-None err skips the content check), so +# their relative order is defensive rather than load-bearing. +_DLL_NAME_ERROR_PRIORITY = [ + "dll_name_rva_zero", + "rva_zero", + "read_failed", + "empty_read", + "unterminated", + "non_ascii", + "dll_name_empty", + "dll_name_not_printable", + "dll_name_too_long", +] + +# descriptor["errors"], thunk-source class. Mutually exclusive by +# construction: the parser returns immediately after appending either. +_THUNK_SOURCE_ERROR_PRIORITY = [ + "names_unrecoverable_bound_no_int", + "no_thunk_array", +] + +# entry["errors"]. +# Ordered by decode stage: the ordinal path, then IMAGE_IMPORT_BY_NAME read +# faults, then name content faults. +_ENTRY_ERROR_PRIORITY = [ + "ordinal_zero", + "name_rva_zero", + "name_read_failed", + "name_too_short", + "hint_unpack_failed", + "name_unterminated", + "name_non_ascii", + "name_empty", + "name_not_printable", +] + +# Cap on per-entry issues raised for a single descriptor, so a hostile +# import table cannot flood the stream. The true count is always in details. +_MAX_ENTRY_ISSUES_PER_DESCRIPTOR = 32 + + +@depends_on("internal") +def validate_imports(internal: InternalMetadata) -> List[StructuralIssue]: + issues: List[StructuralIssue] = [] + + imp = internal.get("import_struct") + if imp is None: + return issues # no import directory - not a defect + + # Top-level decode failure short-circuits: without a descriptor array + if imp.get("errors"): + issues.append(StructuralIssue( + issue=ReasonCodes.IMPORT_DIRECTORY_INVALID_HEADER, + details={"sub_reason": "top_level_decode", + "errors": list(imp["errors"])}, + )) + return issues + + _validate_truncations(imp, issues) + _validate_descriptors(imp, issues) + + return issues + + +# ================================================================= +# Truncations +# ================================================================= + +def _validate_truncations(imp: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + """ + One issue per parser truncation tag, so the consumer sees one issue per + truncated table rather than a single bundled report. + + Note the thunk-array tags are prefixed by the array actually read - `int` + when OriginalFirstThunk was present, `iat_fallback` when the parser fell + back to FirstThunk. That distinction is preserved verbatim in `table` so + a consumer can tell which array was short. + """ + for tag in imp.get("truncations", []) or []: + issues.append(StructuralIssue( + issue=ReasonCodes.IMPORT_TABLE_TRUNCATED, + details={"table": tag}, + )) + + +# ================================================================= +# Descriptors +# ================================================================= + +def _validate_descriptors(imp: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + for descriptor in imp.get("descriptors", []) or []: + index = descriptor.get("index") + errors = descriptor.get("errors", []) or [] + + # ---- DLL name ---- + reason = _first_matching(errors, _DLL_NAME_ERROR_PRIORITY) + if reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.IMPORT_DLL_NAME_INVALID, + details={"index": index, + "dll_name_rva": descriptor.get("name_rva"), + "dll_name": descriptor.get("dll_name"), + "sub_reason": reason}, + )) + + # ---- Thunk source ---- + # A descriptor with no readable name source names no imports at all. + # Distinct from the DLL-name class: the module is identified, its + # imported symbols are not. + reason = _first_matching(errors, _THUNK_SOURCE_ERROR_PRIORITY) + if reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.IMPORT_DESCRIPTOR_INVALID, + details={"index": index, + "dll_name": descriptor.get("dll_name"), + "bound_state": descriptor.get("bound_state"), + "original_first_thunk": + descriptor.get("original_first_thunk"), + "first_thunk": descriptor.get("first_thunk"), + "sub_reason": reason}, + )) + + _validate_entries(descriptor, issues) + + +def _validate_entries(descriptor: Dict[str, Any], + issues: List[StructuralIssue]) -> None: + """ + Emit per-import-entry issues, at most one per entry, priority-resolved. + + Emission is capped so a hostile table cannot flood the stream; + `invalid_entry_count` always carries the true total. + """ + descriptor_index = descriptor.get("index") + dll_name = descriptor.get("dll_name") + + invalid: List[Dict[str, Any]] = [] + for entry in descriptor.get("imports", []) or []: + entry_errors = entry.get("errors", []) or [] + if not entry_errors: + continue + reason = _first_matching(entry_errors, _ENTRY_ERROR_PRIORITY) + if reason == "unknown": + continue + invalid.append({"entry": entry, "sub_reason": reason}) + + if not invalid: + return + + for item in invalid[:_MAX_ENTRY_ISSUES_PER_DESCRIPTOR]: + entry = item["entry"] + issues.append(StructuralIssue( + issue=ReasonCodes.IMPORT_ENTRY_INVALID, + details={ + "descriptor_index": descriptor_index, + "dll_name": dll_name, + "entry_index": entry.get("index"), + "is_ordinal": entry.get("is_ordinal"), + "ordinal": entry.get("ordinal"), + "name": entry.get("name"), + "name_rva": entry.get("name_rva"), + "sub_reason": item["sub_reason"], + "invalid_entry_count": len(invalid), + }, + )) + + +# ================================================================= +# Helpers +# ================================================================= + +def _first_matching(errors: List[str], candidates: List[str]) -> str: + """Return the first tag from `candidates` present in `errors`.""" + for c in candidates: + if c in errors: + return c + return "unknown" diff --git a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json index fab3a1c..bf7ca5c 100644 --- a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json +++ b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json @@ -205,6 +205,16 @@ "size": 512, "size_of_image": 16384 } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "table": "import_descriptor_read_failed", + "reason": "import_table_truncated" + } } ] } diff --git a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json index 745952a..482fe8e 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json @@ -217,6 +217,16 @@ "rva": 4096, "size": 0 } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "table": "import_descriptor_read_failed", + "reason": "import_table_truncated" + } } ] } diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json index 7030fea..deaf801 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json @@ -319,6 +319,16 @@ "reason": "exception_table_truncated", "table": "exception_entry_read_failed" } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "table": "import_descriptor_read_failed", + "reason": "import_table_truncated" + } } ] } diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json index 8a29915..811ff85 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json @@ -310,6 +310,16 @@ "arch": "unsupported", "machine": 332 } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "table": "import_descriptor_read_failed", + "reason": "import_table_truncated" + } } ] } diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json index b00f5e2..e830271 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json @@ -162,6 +162,16 @@ "size": 512, "size_of_image": 12288 } + }, + { + "value": "pe_structure_anomaly", + "start": 0, + "end": 0, + "category": "pe_heuristic", + "metadata": { + "table": "import_descriptor_read_failed", + "reason": "import_table_truncated" + } } ] } diff --git a/tests/integration/test_franken_malformed_pe.py b/tests/integration/test_franken_malformed_pe.py index 5822033..522e52a 100644 --- a/tests/integration/test_franken_malformed_pe.py +++ b/tests/integration/test_franken_malformed_pe.py @@ -47,7 +47,8 @@ def test_franken_expected_heuristics(franken_result): "section_overlap", "section_raw_overlap", "exception_directory_size_not_multiple", - "exception_table_truncated" + "exception_table_truncated", + "import_table_truncated" } assert heur == expected diff --git a/tests/unit/parsers/test_pe_imports.py b/tests/unit/parsers/test_pe_imports.py new file mode 100644 index 0000000..2c0d902 --- /dev/null +++ b/tests/unit/parsers/test_pe_imports.py @@ -0,0 +1,740 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.parsers.pe_imports. + +Strategy: +- Byte-level builders construct 20-byte IMAGE_IMPORT_DESCRIPTOR structures, + NULL-terminated thunk arrays, and IMAGE_IMPORT_BY_NAME blobs via struct.pack. +- A fake PE exposes OPTIONAL_HEADER.Magic / DATA_DIRECTORY[1] and get_data(), + with a base-RVA lookup so thunk walks (which increment the RVA one element + at a time) resolve correctly, and a raise_at hook for read failures. + +The two divergences from pe_delay_imports carry their own test classes, +because both are cases where a plausible implementation misreports rather +than fails loudly: + + * OriginalFirstThunk == 0 is LEGAL here (delay-load treats a zero INT as + an anomaly). Getting this wrong flags a large fraction of legitimate + binaries, so TestOriginalFirstThunkFallback asserts the absence of a tag + as well as the successful fallback. + + * TimeDateStamp selects how FirstThunk should be read. Only the old-style + bound case makes the fallback unusable. +""" + +from __future__ import annotations + +import struct +from typing import Any, Dict, List, Optional + +import pytest + +from iocx.parsers.pe_imports import ( + build_import_structure, + _decode_descriptor, + _decode_import_entry, + _is_pe32_plus, + _is_zero_descriptor, + _locate_import_directory, + _read_asciiz, + _read_import_by_name, + _read_thunk_array, + _BOUND_NEW_STYLE, + _DESCRIPTOR_SIZE, + _DLL_NAME_MAX_CHARS, + _IMPORT_DIRECTORY_INDEX, + _MAGIC_PE32, + _MAGIC_PE32_PLUS, + _MAX_IMPORTS_PER_DESCRIPTOR, +) + + +# ================================================================= +# Byte-level builders +# ================================================================= + +def _descriptor(original_first_thunk: int = 0x3000, + timestamp: int = 0, + forwarder_chain: int = 0, + name_rva: int = 0x2000, + first_thunk: int = 0x4000) -> bytes: + """A 20-byte IMAGE_IMPORT_DESCRIPTOR.""" + return struct.pack(" bytes: + return b"\x00" * _DESCRIPTOR_SIZE + + +def _thunks64(values: List[int]) -> bytes: + return b"".join(struct.pack(" bytes: + return b"".join(struct.pack(" bytes: + return struct.pack(" bytes: + return s.encode("ascii") + b"\x00" + + +def _ordinal64(o: int) -> int: + return (1 << 63) | o + + +def _ordinal32(o: int) -> int: + return 0x80000000 | o + + +# ================================================================= +# Fake PE +# ================================================================= + +class _DataDir: + def __init__(self, rva: int, size: int): + self.VirtualAddress = rva + self.Size = size + + +class _OptHdr: + def __init__(self, d: Optional[_DataDir], magic: int = _MAGIC_PE32_PLUS, + has_magic: bool = True): + self.DATA_DIRECTORY = [None] * 16 + if d is not None: + self.DATA_DIRECTORY[_IMPORT_DIRECTORY_INDEX] = d + if has_magic: + self.Magic = magic + + +class _FakePE: + """ + get_data resolves an RVA against the nearest preceding buffer base, so a + thunk walk stepping 8 bytes at a time reads successive elements of one + stored array. A read past a buffer's end returns a short slice (as pefile + does at a section edge); an unmapped RVA raises. + """ + def __init__(self, import_rva: int = 0, import_size: int = 0, + data: Optional[Dict[int, bytes]] = None, + is_64bit: bool = True, + raise_at: Optional[int] = None, + has_optional_header: bool = True, + has_magic: bool = True): + magic = _MAGIC_PE32_PLUS if is_64bit else _MAGIC_PE32 + if has_optional_header: + dd = _DataDir(import_rva, import_size) if (import_rva or import_size) else None + self.OPTIONAL_HEADER = _OptHdr(dd, magic, has_magic) + self._data = data or {} + self._raise_at = raise_at + + def get_data(self, rva: int, size: int) -> bytes: + if self._raise_at is not None and rva == self._raise_at: + raise RuntimeError("simulated read failure") + for base in sorted((b for b in self._data if b <= rva), reverse=True): + buf = self._data[base] + offset = rva - base + if offset <= len(buf): + return buf[offset:offset + size] + raise ValueError(f"no fixture data covers rva {rva:#x}") + + +def _pe(descriptors: bytes, extra: Optional[Dict[int, bytes]] = None, + size: Optional[int] = None, is_64bit: bool = True, + raise_at: Optional[int] = None) -> _FakePE: + data = {0x1000: descriptors} + data.update(extra or {}) + return _FakePE(0x1000, size if size is not None else len(descriptors), + data, is_64bit=is_64bit, raise_at=raise_at) + + +def _simple(**kw) -> _FakePE: + """One well-formed descriptor: KERNEL32.dll, one named + one ordinal.""" + return _pe(_descriptor(**kw) + _zero_descriptor(), { + 0x2000: _asciiz("KERNEL32.dll"), + 0x3000: _thunks64([0x5000, _ordinal64(42)]), + 0x4000: _thunks64([0x5000, _ordinal64(42)]), + 0x5000: _import_by_name(0x10, "LoadLibraryA"), + }, size=60) + + +# ================================================================= +# Absence / locator +# ================================================================= + +class TestLocator: + + def test_absent_directory_returns_none(self): + assert build_import_structure(_FakePE(0, 0)) is None + + def test_zero_rva_returns_none(self): + assert _locate_import_directory(_FakePE(0, 100)) is None + + def test_zero_size_returns_none(self): + assert _locate_import_directory(_FakePE(0x1000, 0)) is None + + def test_missing_optional_header_returns_none(self): + assert _locate_import_directory( + _FakePE(has_optional_header=False)) is None + + def test_missing_entry_returns_none(self): + pe = _FakePE(0x1000, 100) + pe.OPTIONAL_HEADER.DATA_DIRECTORY[_IMPORT_DIRECTORY_INDEX] = None + assert _locate_import_directory(pe) is None + + def test_non_int_fields_return_none(self): + pe = _FakePE(0x1000, 100) + pe.OPTIONAL_HEADER.DATA_DIRECTORY[ + _IMPORT_DIRECTORY_INDEX].VirtualAddress = "nope" + assert _locate_import_directory(pe) is None + + +class TestPe32Plus: + + def test_pe32_plus_true(self): + assert _is_pe32_plus(_FakePE(is_64bit=True)) is True + + def test_pe32_false(self): + assert _is_pe32_plus(_FakePE(is_64bit=False)) is False + + def test_missing_magic_defaults_false(self): + """Conservative default: narrower thunks, lower over-read risk.""" + assert _is_pe32_plus(_FakePE(has_magic=False)) is False + + def test_missing_optional_header_defaults_false(self): + assert _is_pe32_plus(_FakePE(has_optional_header=False)) is False + + +# ================================================================= +# Descriptor decode +# ================================================================= + +class TestDescriptorDecode: + + def test_fields_decoded(self): + raw = _descriptor(original_first_thunk=0x1111, timestamp=0x2222, + forwarder_chain=0x3333, name_rva=0x4444, + first_thunk=0x5555) + d = _decode_descriptor(raw, 7) + assert d["index"] == 7 + assert d["original_first_thunk"] == 0x1111 + assert d["timestamp"] == 0x2222 + assert d["forwarder_chain"] == 0x3333 + assert d["name_rva"] == 0x4444 + assert d["first_thunk"] == 0x5555 + + def test_zero_descriptor_recognised(self): + assert _is_zero_descriptor(_decode_descriptor(_zero_descriptor(), 0)) is True + + def test_non_zero_descriptor_not_terminator(self): + assert _is_zero_descriptor(_decode_descriptor(_descriptor(), 0)) is False + + def test_terminator_is_not_emitted_as_an_entry(self): + out = build_import_structure(_simple()) + assert out["descriptor_count"] == 1 + + def test_multiple_descriptors_walked_in_order(self): + table = (_descriptor(original_first_thunk=0x3000, name_rva=0x2000) + + _descriptor(original_first_thunk=0, first_thunk=0x4000, + name_rva=0x2010) + + _zero_descriptor()) + out = build_import_structure(_pe(table, { + 0x2000: _asciiz("A.dll"), 0x2010: _asciiz("B.dll"), + 0x3000: _thunks64([0x5000]), 0x4000: _thunks64([_ordinal64(9)]), + 0x5000: _import_by_name(1, "Fn"), + }, size=80)) + assert [d["index"] for d in out["descriptors"]] == [0, 1] + assert [d["dll_name"] for d in out["descriptors"]] == ["A.dll", "B.dll"] + + +# ================================================================= +# DIVERGENCE 1 - OriginalFirstThunk == 0 is legal +# ================================================================= + +class TestOriginalFirstThunkFallback: + """ + Unlike the delay-load INT, a zero OriginalFirstThunk is legal and common: + older linkers emit only FirstThunk, which then holds INT-style thunks on + disk. Treating it as an anomaly would misreport a large fraction of + legitimate binaries, so the absence of an error tag is asserted as + explicitly as the successful fallback. + """ + + def test_fallback_resolves_names_from_first_thunk(self): + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0, first_thunk=0x4000) + + _zero_descriptor(), { + 0x2000: _asciiz("OLD.dll"), + 0x4000: _thunks64([0x5000]), + 0x5000: _import_by_name(1, "Foo"), + }, size=60)) + d = out["descriptors"][0] + assert d["thunk_source"] == "iat_fallback" + assert [e["name"] for e in d["imports"]] == ["Foo"] + + def test_fallback_is_not_flagged_as_an_error(self): + """The regression guard: a zero INT must NOT produce a tag.""" + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0, first_thunk=0x4000) + + _zero_descriptor(), { + 0x2000: _asciiz("OLD.dll"), + 0x4000: _thunks64([_ordinal64(1)]), + }, size=60)) + assert out["descriptors"][0]["errors"] == [] + + def test_int_preferred_when_both_present(self): + """ + With both arrays populated the INT wins - it is the on-disk name + source, and the IAT may already hold bound addresses. + """ + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0x3000, first_thunk=0x4000) + + _zero_descriptor(), { + 0x2000: _asciiz("A.dll"), + 0x3000: _thunks64([0x5000]), + 0x4000: _thunks64([0x6000]), + 0x5000: _import_by_name(1, "FromINT"), + 0x6000: _import_by_name(2, "FromIAT"), + }, size=60)) + d = out["descriptors"][0] + assert d["thunk_source"] == "int" + assert d["imports"][0]["name"] == "FromINT" + + def test_neither_array_present_is_flagged(self): + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0, first_thunk=0) + + _zero_descriptor(), {0x2000: _asciiz("X.dll")}, size=60)) + d = out["descriptors"][0] + assert d["errors"] == ["no_thunk_array"] + assert d["thunk_source"] is None + assert d["imports"] == [] + + def test_truncation_tag_names_the_array_actually_read(self): + """ + The tag prefix follows thunk_source, so a consumer can tell whether + the INT or the fallback IAT was short. + """ + int_short = build_import_structure(_pe( + _descriptor() + _zero_descriptor(), + {0x2000: _asciiz("A.dll"), 0x3000: b"\x01\x02\x03"}, size=60)) + fallback_short = build_import_structure(_pe( + _descriptor(original_first_thunk=0) + _zero_descriptor(), + {0x2000: _asciiz("A.dll"), 0x4000: b"\x01\x02\x03"}, size=60)) + assert int_short["truncations"] == ["int_truncated"] + assert fallback_short["truncations"] == ["iat_fallback_truncated"] + + +# ================================================================= +# DIVERGENCE 2 - TimeDateStamp selects how FirstThunk reads +# ================================================================= + +class TestBoundState: + + @pytest.mark.parametrize("timestamp,expected", [ + (0, "unbound"), + (_BOUND_NEW_STYLE, "bound_new_style"), + (0x5F000000, "bound_old_style"), + (1, "bound_old_style"), + ]) + def test_bound_state_classification(self, timestamp, expected): + out = build_import_structure(_pe( + _descriptor(timestamp=timestamp) + _zero_descriptor(), { + 0x2000: _asciiz("A.dll"), + 0x3000: _thunks64([_ordinal64(1)]), + }, size=60)) + assert out["descriptors"][0]["bound_state"] == expected + + def test_old_style_bound_without_int_cannot_recover_names(self): + """ + Old-style bound means FirstThunk holds resolved ADDRESSES on disk. + With no INT there is no name source at all - a structural fact, not a + parse failure, so no thunks are walked. + """ + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0, timestamp=0x5F000000, + first_thunk=0x4000) + _zero_descriptor(), { + 0x2000: _asciiz("BOUND.dll"), + 0x4000: _thunks64([0x7FF800001234]), + }, size=60)) + d = out["descriptors"][0] + assert d["errors"] == ["names_unrecoverable_bound_no_int"] + assert d["thunk_source"] is None + assert d["imports"] == [] + + def test_old_style_bound_with_int_still_parses(self): + """The INT is unaffected by binding, so names remain readable.""" + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0x3000, timestamp=0x5F000000) + + _zero_descriptor(), { + 0x2000: _asciiz("B.dll"), + 0x3000: _thunks64([0x5000]), + 0x5000: _import_by_name(1, "Bar"), + }, size=60)) + d = out["descriptors"][0] + assert d["errors"] == [] + assert d["imports"][0]["name"] == "Bar" + + def test_new_style_bound_without_int_uses_the_fallback(self): + """ + New-style bound keeps real timestamps in the BOUND_IMPORT directory, + so FirstThunk still holds thunks on disk and the fallback is valid. + """ + out = build_import_structure(_pe( + _descriptor(original_first_thunk=0, timestamp=_BOUND_NEW_STYLE, + first_thunk=0x4000) + _zero_descriptor(), { + 0x2000: _asciiz("NB.dll"), + 0x4000: _thunks64([0x5000]), + 0x5000: _import_by_name(1, "Baz"), + }, size=60)) + d = out["descriptors"][0] + assert d["errors"] == [] + assert d["thunk_source"] == "iat_fallback" + assert d["imports"][0]["name"] == "Baz" + + +# ================================================================= +# Thunk decode +# ================================================================= + +class TestThunkDecode: + + def test_named_import(self): + d = build_import_structure(_simple())["descriptors"][0] + e = d["imports"][0] + assert e["is_ordinal"] is False + assert e["hint"] == 0x10 + assert e["name"] == "LoadLibraryA" + assert e["name_rva"] == 0x5000 + assert e["name_valid"] is True + assert e["errors"] == [] + + def test_ordinal_import(self): + e = build_import_structure(_simple())["descriptors"][0]["imports"][1] + assert e["is_ordinal"] is True + assert e["ordinal"] == 42 + assert e["name"] is None + + def test_ordinal_masked_to_low_16_bits(self): + """Per spec the ordinal is bits 15-0; higher bits are discarded.""" + out = build_import_structure(_pe( + _descriptor() + _zero_descriptor(), { + 0x2000: _asciiz("A.dll"), + 0x3000: _thunks64([_ordinal64(42) | (1 << 40)]), + }, size=60)) + assert out["descriptors"][0]["imports"][0]["ordinal"] == 42 + + def test_ordinal_zero_flagged(self): + out = build_import_structure(_pe( + _descriptor() + _zero_descriptor(), { + 0x2000: _asciiz("A.dll"), + 0x3000: _thunks64([_ordinal64(0)]), + }, size=60)) + assert out["descriptors"][0]["imports"][0]["errors"] == ["ordinal_zero"] + + def test_pe32_uses_dword_thunks(self): + out = build_import_structure(_pe( + _descriptor() + _zero_descriptor(), { + 0x2000: _asciiz("K32.dll"), + 0x3000: _thunks32([0x5000, _ordinal32(7)]), + 0x5000: _import_by_name(2, "Baz"), + }, size=60, is_64bit=False)) + assert out["is_64bit"] is False + entries = out["descriptors"][0]["imports"] + assert entries[0]["name"] == "Baz" + assert entries[1]["ordinal"] == 7 + + @pytest.mark.parametrize("blob,tag", [ + (b"\x42", "name_too_short"), + (struct.pack(" Dict[str, Any]: + """One ImportEntry as the parser emits it.""" + e = {"index": index, "errors": errors or [], "is_ordinal": False, + "ordinal": None, "name": "Fn", "name_rva": 0x5000, + "name_valid": True, "hint": 1, "thunk_value": 0x5000} + e.update(kw) + return e + + +def _descriptor(index: int = 0, + errors: Optional[List[str]] = None, + dll_name: Optional[str] = "KERNEL32.dll", + imports: Optional[List[Dict[str, Any]]] = None, + **kw) -> Dict[str, Any]: + """One descriptor as the parser emits it.""" + d = {"index": index, "errors": errors or [], "dll_name": dll_name, + "dll_name_valid": True, "name_rva": 0x2000, + "bound_state": "unbound", "original_first_thunk": 0x3000, + "first_thunk": 0x4000, "thunk_source": "int", + "imports": imports or [], "timestamp": 0, "forwarder_chain": 0} + d.update(kw) + return d + + +def _internal(descriptors: Optional[List[Dict[str, Any]]] = None, + truncations: Optional[List[str]] = None, + errors: Optional[List[str]] = None) -> Dict[str, Any]: + descriptors = descriptors or [] + return {"import_struct": { + "rva": 0x1000, "size": 60, "is_64bit": True, + "descriptors": descriptors, + "descriptor_count": len(descriptors), + "truncations": truncations or [], + "errors": errors or [], + }} + + +def _codes(issues) -> List[str]: + return [i["issue"] for i in issues] + + +def _of(issues, code) -> List[Dict[str, Any]]: + return [i["details"] for i in issues if i["issue"] == code] + + +def _subs(issues, code) -> List[str]: + return [d.get("sub_reason") for d in _of(issues, code)] + + +# ================================================================= +# Priority-list content +# ================================================================= + +class TestPriorityListOrder: + """ + The ORDER of each priority list is a contract, not an implementation + detail: it decides which sub_reason a multi-fault entry reports. + + These assert the literal sequence, because a behavioural test cannot + catch a reorder - _first_matching is definitionally consistent with + whatever order the list happens to have, so fixtures derived from the + list move with it. The behavioural pair tests below still earn their + place by proving the lists are actually consulted; only these pin the + order itself. + """ + + def test_dll_name_priority_order(self): + assert _DLL_NAME_ERROR_PRIORITY == [ + # RVA-level + "dll_name_rva_zero", + # _read_asciiz read faults + "rva_zero", + "read_failed", + "empty_read", + "unterminated", + "non_ascii", + # content checks (three-way split, mutually exclusive) + "dll_name_empty", + "dll_name_not_printable", + "dll_name_too_long", + ] + + def test_thunk_source_priority_order(self): + assert _THUNK_SOURCE_ERROR_PRIORITY == [ + "names_unrecoverable_bound_no_int", + "no_thunk_array", + ] + + def test_entry_priority_order(self): + assert _ENTRY_ERROR_PRIORITY == [ + # ordinal path + "ordinal_zero", + # IMAGE_IMPORT_BY_NAME read faults + "name_rva_zero", + "name_read_failed", + "name_too_short", + "hint_unpack_failed", + "name_unterminated", + "name_non_ascii", + # name content checks (mutually exclusive) + "name_empty", + "name_not_printable", + ] + + def test_no_duplicates_within_a_list(self): + for name, lst in (("dll", _DLL_NAME_ERROR_PRIORITY), + ("source", _THUNK_SOURCE_ERROR_PRIORITY), + ("entry", _ENTRY_ERROR_PRIORITY)): + assert len(lst) == len(set(lst)), f"{name} list has duplicates" + + def test_descriptor_lists_are_disjoint(self): + """ + Both read descriptor["errors"]; an overlapping tag would emit two + issues for one fault. + """ + assert not (set(_DLL_NAME_ERROR_PRIORITY) + & set(_THUNK_SOURCE_ERROR_PRIORITY)) + + +# ================================================================= +# Absence / short-circuit +# ================================================================= + +class TestAbsence: + + def test_no_import_struct_returns_empty(self): + assert validate_imports({}) == [] + + def test_none_import_struct_returns_empty(self): + assert validate_imports({"import_struct": None}) == [] + + def test_empty_directory_emits_nothing(self): + assert validate_imports(_internal([])) == [] + + def test_clean_descriptor_emits_nothing(self): + assert validate_imports(_internal([_descriptor()])) == [] + + +class TestTopLevelShortCircuit: + + def test_top_level_error_emits_one_issue(self): + issues = validate_imports( + _internal(errors=["descriptor_unpack_failed"])) + assert _codes(issues) == [ReasonCodes.IMPORT_DIRECTORY_INVALID_HEADER] + assert _subs(issues, ReasonCodes.IMPORT_DIRECTORY_INVALID_HEADER) == [ + "top_level_decode"] + + def test_top_level_error_suppresses_everything_else(self): + """ + Without a usable descriptor array there is nothing further to say, so + truncations and per-descriptor faults are not reported. + """ + issues = validate_imports(_internal( + descriptors=[_descriptor(errors=["dll_name_empty"])], + truncations=["int_truncated"], + errors=["descriptor_unpack_failed"], + )) + assert len(issues) == 1 + assert issues[0]["issue"] == ReasonCodes.IMPORT_DIRECTORY_INVALID_HEADER + + def test_errors_list_forwarded_verbatim(self): + issues = validate_imports(_internal(errors=["a", "b"])) + assert _of(issues, ReasonCodes.IMPORT_DIRECTORY_INVALID_HEADER)[0][ + "errors"] == ["a", "b"] + + +# ================================================================= +# Truncations +# ================================================================= + +class TestTruncations: + + def test_one_issue_per_tag(self): + issues = validate_imports( + _internal(truncations=["int_truncated", "int_read_failed"])) + assert _codes(issues) == [ReasonCodes.IMPORT_TABLE_TRUNCATED] * 2 + + def test_tag_carried_in_table_key(self): + issues = validate_imports(_internal(truncations=["int_truncated"])) + assert _of(issues, ReasonCodes.IMPORT_TABLE_TRUNCATED)[0] == { + "table": "int_truncated"} + + @pytest.mark.parametrize("tag", [ + "import_descriptor_unterminated", + "import_descriptor_read_failed", + "import_descriptor_truncated", + "import_descriptor_max_exceeded", + "int_read_failed", "int_truncated", + "int_unpack_failed", "int_max_exceeded", + "iat_fallback_read_failed", "iat_fallback_truncated", + "iat_fallback_unpack_failed", "iat_fallback_max_exceeded", + ]) + def test_every_parser_truncation_tag_is_forwarded(self, tag): + """ + Truncations are forwarded wholesale, so no tag can be dropped. This + enumerates the parser's full vocabulary as a contract guard. + """ + issues = validate_imports(_internal(truncations=[tag])) + assert _of(issues, ReasonCodes.IMPORT_TABLE_TRUNCATED)[0]["table"] == tag + + def test_fallback_prefix_distinguishes_the_array_read(self): + """ + `int_*` vs `iat_fallback_*` tells a consumer which array was short - + the fallback path only exists on older-linker binaries. + """ + int_issues = validate_imports(_internal(truncations=["int_truncated"])) + fb_issues = validate_imports( + _internal(truncations=["iat_fallback_truncated"])) + assert _of(int_issues, ReasonCodes.IMPORT_TABLE_TRUNCATED)[0][ + "table"] == "int_truncated" + assert _of(fb_issues, ReasonCodes.IMPORT_TABLE_TRUNCATED)[0][ + "table"] == "iat_fallback_truncated" + + +# ================================================================= +# DLL name class +# ================================================================= + +class TestDllName: + + @pytest.mark.parametrize("tag", _DLL_NAME_ERROR_PRIORITY) + def test_every_priority_tag_emits(self, tag): + """Contract guard: no tag in the list may be silently dropped.""" + issues = validate_imports(_internal([_descriptor(errors=[tag])])) + assert _subs(issues, ReasonCodes.IMPORT_DLL_NAME_INVALID) == [tag] + + def test_details_payload(self): + issues = validate_imports(_internal([ + _descriptor(index=2, errors=["dll_name_empty"], + dll_name="", name_rva=0x2222)])) + assert _of(issues, ReasonCodes.IMPORT_DLL_NAME_INVALID)[0] == { + "index": 2, "dll_name_rva": 0x2222, "dll_name": "", + "sub_reason": "dll_name_empty"} + + def test_one_issue_per_descriptor(self): + """Priority-resolved: several tags still produce a single issue.""" + issues = validate_imports(_internal([ + _descriptor(errors=["dll_name_rva_zero", "read_failed", + "dll_name_empty"])])) + assert len(_of(issues, ReasonCodes.IMPORT_DLL_NAME_INVALID)) == 1 + + @pytest.mark.parametrize("higher,lower", list( + zip(_DLL_NAME_ERROR_PRIORITY, _DLL_NAME_ERROR_PRIORITY[1:]))) + def test_priority_order_adjacent_pairs(self, higher, lower): + """Every adjacent pair, both presentation orders - see the entry-class + equivalent for why distant pairs are insufficient.""" + for errors in ([higher, lower], [lower, higher]): + issues = validate_imports(_internal([_descriptor(errors=errors)])) + assert _subs(issues, + ReasonCodes.IMPORT_DLL_NAME_INVALID) == [higher] + + def test_each_descriptor_reported_separately(self): + issues = validate_imports(_internal([ + _descriptor(index=0, errors=["dll_name_empty"]), + _descriptor(index=1, errors=["dll_name_too_long"]), + ])) + details = _of(issues, ReasonCodes.IMPORT_DLL_NAME_INVALID) + assert [d["index"] for d in details] == [0, 1] + assert [d["sub_reason"] for d in details] == [ + "dll_name_empty", "dll_name_too_long"] + + +# ================================================================= +# Thunk-source class +# ================================================================= + +class TestThunkSource: + """ + A descriptor with no readable name source identifies the module but not + its symbols - a different fact from a malformed DLL name, hence its own + reason code. + """ + + @pytest.mark.parametrize("tag", _THUNK_SOURCE_ERROR_PRIORITY) + def test_every_priority_tag_emits(self, tag): + issues = validate_imports(_internal([_descriptor(errors=[tag])])) + assert _subs(issues, ReasonCodes.IMPORT_DESCRIPTOR_INVALID) == [tag] + + def test_bound_without_int_details(self): + """ + The details carry enough to explain WHY names are unrecoverable: + old-style bound means FirstThunk holds addresses, and there is no INT. + """ + issues = validate_imports(_internal([_descriptor( + index=1, errors=["names_unrecoverable_bound_no_int"], + dll_name="BOUND.dll", bound_state="bound_old_style", + original_first_thunk=0, first_thunk=0x4000, thunk_source=None)])) + assert _of(issues, ReasonCodes.IMPORT_DESCRIPTOR_INVALID)[0] == { + "index": 1, "dll_name": "BOUND.dll", + "bound_state": "bound_old_style", + "original_first_thunk": 0, "first_thunk": 0x4000, + "sub_reason": "names_unrecoverable_bound_no_int"} + + def test_no_thunk_array_details(self): + issues = validate_imports(_internal([_descriptor( + errors=["no_thunk_array"], original_first_thunk=0, + first_thunk=0, thunk_source=None)])) + d = _of(issues, ReasonCodes.IMPORT_DESCRIPTOR_INVALID)[0] + assert d["sub_reason"] == "no_thunk_array" + assert d["original_first_thunk"] == 0 + assert d["first_thunk"] == 0 + + def test_priority_order(self): + issues = validate_imports(_internal([_descriptor( + errors=["no_thunk_array", "names_unrecoverable_bound_no_int"])])) + assert _subs(issues, ReasonCodes.IMPORT_DESCRIPTOR_INVALID) == [ + "names_unrecoverable_bound_no_int"] + + def test_healthy_fallback_is_not_flagged(self): + """ + The critical negative: OriginalFirstThunk == 0 with a usable fallback + is legal and must produce nothing. Flagging it would misreport a large + fraction of legitimate binaries. + """ + issues = validate_imports(_internal([_descriptor( + errors=[], original_first_thunk=0, first_thunk=0x4000, + thunk_source="iat_fallback", + imports=[_entry()])])) + assert issues == [] + + +# ================================================================= +# Entry class +# ================================================================= + +class TestEntries: + + @pytest.mark.parametrize("tag", _ENTRY_ERROR_PRIORITY) + def test_every_priority_tag_emits(self, tag): + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(errors=[tag])])])) + assert _subs(issues, ReasonCodes.IMPORT_ENTRY_INVALID) == [tag] + + def test_details_payload(self): + issues = validate_imports(_internal([_descriptor( + index=3, dll_name="USER32.dll", + imports=[_entry(index=7, errors=["ordinal_zero"], + is_ordinal=True, ordinal=0, name=None, + name_rva=None)])])) + assert _of(issues, ReasonCodes.IMPORT_ENTRY_INVALID)[0] == { + "descriptor_index": 3, "dll_name": "USER32.dll", + "entry_index": 7, "is_ordinal": True, "ordinal": 0, + "name": None, "name_rva": None, + "sub_reason": "ordinal_zero", "invalid_entry_count": 1} + + def test_clean_entries_emit_nothing(self): + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(0), _entry(1), _entry(2)])])) + assert issues == [] + + def test_only_invalid_entries_reported(self): + issues = validate_imports(_internal([_descriptor(imports=[ + _entry(0), _entry(1, ["ordinal_zero"]), + _entry(2), _entry(3, ["name_empty"])])])) + details = _of(issues, ReasonCodes.IMPORT_ENTRY_INVALID) + assert [d["entry_index"] for d in details] == [1, 3] + + def test_invalid_entry_count_counts_invalid_not_total(self): + """ + The count is of INVALID entries, not of the whole import list - a + consumer reading it as a table size would be misled. + """ + issues = validate_imports(_internal([_descriptor(imports=[ + _entry(0), _entry(1, ["ordinal_zero"]), + _entry(2), _entry(3, ["name_empty"])])])) + assert all(d["invalid_entry_count"] == 2 + for d in _of(issues, ReasonCodes.IMPORT_ENTRY_INVALID)) + + @pytest.mark.parametrize("higher,lower", list( + zip(_ENTRY_ERROR_PRIORITY, _ENTRY_ERROR_PRIORITY[1:]))) + def test_priority_order_adjacent_pairs(self, higher, lower): + """ + Every ADJACENT pair, in both presentation orders. Pairing distant + tags would only prove that some ordering exists - swapping two + neighbours would go unnoticed. Driving the list itself also means a + newly inserted tag is covered automatically. + """ + for errors in ([higher, lower], [lower, higher]): + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(errors=errors)])])) + assert _subs(issues, ReasonCodes.IMPORT_ENTRY_INVALID) == [higher] + + def test_entries_from_multiple_descriptors_are_attributed(self): + issues = validate_imports(_internal([ + _descriptor(index=0, dll_name="A.dll", + imports=[_entry(0, ["ordinal_zero"])]), + _descriptor(index=1, dll_name="B.dll", + imports=[_entry(0, ["name_empty"])]), + ])) + details = _of(issues, ReasonCodes.IMPORT_ENTRY_INVALID) + assert [(d["descriptor_index"], d["dll_name"]) for d in details] == [ + (0, "A.dll"), (1, "B.dll")] + + +class TestEntryEmissionCap: + + def test_below_cap_all_emitted(self): + n = _MAX_ENTRY_ISSUES_PER_DESCRIPTOR - 1 + issues = validate_imports(_internal([_descriptor( + imports=[_entry(i, ["ordinal_zero"]) for i in range(n)])])) + assert len(_of(issues, ReasonCodes.IMPORT_ENTRY_INVALID)) == n + + def test_at_cap_all_emitted(self): + n = _MAX_ENTRY_ISSUES_PER_DESCRIPTOR + issues = validate_imports(_internal([_descriptor( + imports=[_entry(i, ["ordinal_zero"]) for i in range(n)])])) + assert len(_of(issues, ReasonCodes.IMPORT_ENTRY_INVALID)) == n + + def test_above_cap_clamped_but_count_is_true(self): + n = _MAX_ENTRY_ISSUES_PER_DESCRIPTOR + 20 + issues = validate_imports(_internal([_descriptor( + imports=[_entry(i, ["ordinal_zero"]) for i in range(n)])])) + details = _of(issues, ReasonCodes.IMPORT_ENTRY_INVALID) + assert len(details) == _MAX_ENTRY_ISSUES_PER_DESCRIPTOR + assert all(d["invalid_entry_count"] == n for d in details) + + def test_cap_is_per_descriptor_not_global(self): + """ + A file with many malformed modules should not have later descriptors + silenced by earlier ones. + """ + n = _MAX_ENTRY_ISSUES_PER_DESCRIPTOR + 10 + bad = [_entry(i, ["ordinal_zero"]) for i in range(n)] + issues = validate_imports(_internal([ + _descriptor(index=0, imports=list(bad)), + _descriptor(index=1, imports=list(bad)), + ])) + details = _of(issues, ReasonCodes.IMPORT_ENTRY_INVALID) + per = {} + for d in details: + per[d["descriptor_index"]] = per.get(d["descriptor_index"], 0) + 1 + assert per == {0: _MAX_ENTRY_ISSUES_PER_DESCRIPTOR, + 1: _MAX_ENTRY_ISSUES_PER_DESCRIPTOR} + + +# ================================================================= +# Class independence +# ================================================================= + +class TestClassIndependence: + """ + The three pathology classes are orthogonal. These pin that they neither + suppress one another nor merge, which is what makes the single-anomaly + fixtures elsewhere in this file meaningful. + """ + + def test_dll_name_and_thunk_source_both_fire(self): + issues = validate_imports(_internal([ + _descriptor(errors=["dll_name_empty", "no_thunk_array"])])) + assert set(_codes(issues)) == { + ReasonCodes.IMPORT_DLL_NAME_INVALID, + ReasonCodes.IMPORT_DESCRIPTOR_INVALID} + + def test_descriptor_fault_does_not_suppress_entries(self): + issues = validate_imports(_internal([_descriptor( + errors=["dll_name_empty"], + imports=[_entry(errors=["ordinal_zero"])])])) + assert set(_codes(issues)) == { + ReasonCodes.IMPORT_DLL_NAME_INVALID, + ReasonCodes.IMPORT_ENTRY_INVALID} + + def test_truncations_do_not_suppress_descriptors(self): + issues = validate_imports(_internal( + descriptors=[_descriptor(errors=["dll_name_empty"])], + truncations=["int_truncated"])) + assert set(_codes(issues)) == { + ReasonCodes.IMPORT_TABLE_TRUNCATED, + ReasonCodes.IMPORT_DLL_NAME_INVALID} + + def test_all_three_classes_together(self): + issues = validate_imports(_internal( + descriptors=[_descriptor( + errors=["dll_name_empty", "no_thunk_array"], + imports=[_entry(errors=["ordinal_zero"])])], + truncations=["int_truncated"])) + assert set(_codes(issues)) == { + ReasonCodes.IMPORT_TABLE_TRUNCATED, + ReasonCodes.IMPORT_DLL_NAME_INVALID, + ReasonCodes.IMPORT_DESCRIPTOR_INVALID, + ReasonCodes.IMPORT_ENTRY_INVALID} + + +# ================================================================= +# Placement ownership +# ================================================================= + +class TestPlacementNotChecked: + """ + Placement is owned by the RVA-graph backbone. This validator must not + assert it, or the two would double-count on every malformed directory. + """ + + def test_no_metadata_dependency(self): + assert validate_imports._depends_on == ("internal",) + + def test_absurd_placement_emits_nothing(self): + internal = _internal([_descriptor()]) + internal["import_struct"]["rva"] = 0x99999999 + internal["import_struct"]["size"] = 0x99999999 + assert validate_imports(internal) == [] + + +# ================================================================= +# Output contract +# ================================================================= + +class TestOutputContract: + + def test_issue_shape(self): + issues = validate_imports(_internal( + descriptors=[_descriptor(errors=["dll_name_empty"], + imports=[_entry(errors=["ordinal_zero"])])], + truncations=["int_truncated"])) + assert issues + for issue in issues: + assert set(issue) == {"issue", "details"} + assert isinstance(issue["issue"], str) + assert isinstance(issue["details"], dict) + + def test_no_reserved_reason_key(self): + """ + "reason" is reserved by the heuristics emission layer; a details key + of that name would overwrite the parent code. + """ + issues = validate_imports(_internal( + descriptors=[_descriptor( + errors=["dll_name_empty", "no_thunk_array"], + imports=[_entry(errors=["ordinal_zero"])])], + truncations=["int_truncated"])) + assert issues + offenders = [i["issue"] for i in issues if "reason" in i["details"]] + assert not offenders + + def test_json_serialisable(self): + import json + json.dumps(validate_imports(_internal( + descriptors=[_descriptor(errors=["dll_name_empty"], + imports=[_entry(errors=["name_empty"])])], + truncations=["int_truncated"]))) + + def test_input_not_mutated(self): + import copy + internal = _internal( + descriptors=[_descriptor(errors=["dll_name_empty"], + imports=[_entry(errors=["ordinal_zero"])])], + truncations=["int_truncated"]) + snapshot = copy.deepcopy(internal) + validate_imports(internal) + assert internal == snapshot + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def test_repeated_validation_identical(self): + import json + internal = _internal( + descriptors=[ + _descriptor(index=0, errors=["dll_name_empty"], + imports=[_entry(0, ["ordinal_zero"]), + _entry(1, ["name_empty"])]), + _descriptor(index=1, errors=["no_thunk_array"]), + ], + truncations=["int_truncated", "iat_fallback_read_failed"]) + first = json.dumps(validate_imports(internal), sort_keys=True) + for _ in range(20): + assert json.dumps(validate_imports(internal), + sort_keys=True) == first + + def test_emission_order_is_truncations_then_descriptors(self): + issues = validate_imports(_internal( + descriptors=[_descriptor(errors=["dll_name_empty"])], + truncations=["int_truncated"])) + assert _codes(issues) == [ReasonCodes.IMPORT_TABLE_TRUNCATED, + ReasonCodes.IMPORT_DLL_NAME_INVALID] + + def test_per_descriptor_order_is_name_then_source_then_entries(self): + issues = validate_imports(_internal([_descriptor( + errors=["dll_name_empty", "no_thunk_array"], + imports=[_entry(errors=["ordinal_zero"])])])) + assert _codes(issues) == [ + ReasonCodes.IMPORT_DLL_NAME_INVALID, + ReasonCodes.IMPORT_DESCRIPTOR_INVALID, + ReasonCodes.IMPORT_ENTRY_INVALID] From 1ab1835d969c3728144af4a8204ab11744fed590 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 1 Sep 2026 11:09:00 +0100 Subject: [PATCH 09/40] Fill in tests for imports parser and validator. Coverage back at 100% --- README.md | 2 +- tests/unit/parsers/test_pe_imports.py | 82 ++++++ tests/unit/parsers/test_pe_imports_nnp.py | 220 ++++++++++++++++ .../validators/test_validator_imports_ru.py | 249 ++++++++++++++++++ 4 files changed, 552 insertions(+), 1 deletion(-) create mode 100644 tests/unit/parsers/test_pe_imports_nnp.py create mode 100644 tests/unit/validators/test_validator_imports_ru.py diff --git a/README.md b/README.md index c03c779..b1d0b94 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + diff --git a/tests/unit/parsers/test_pe_imports.py b/tests/unit/parsers/test_pe_imports.py index 2c0d902..ab5913d 100644 --- a/tests/unit/parsers/test_pe_imports.py +++ b/tests/unit/parsers/test_pe_imports.py @@ -48,6 +48,7 @@ _MAGIC_PE32, _MAGIC_PE32_PLUS, _MAX_IMPORTS_PER_DESCRIPTOR, + _MAX_DESCRIPTORS, ) @@ -65,6 +66,18 @@ def _descriptor(original_first_thunk: int = 0x3000, forwarder_chain, name_rva, first_thunk) +def _minimal_nonzero_descriptor() -> bytes: + """ + The cheapest descriptor that is non-zero yet triggers no further reads: + name_rva = 0 -> dll_name_rva_zero, no string read + both thunk RVAs = 0 -> no_thunk_array, _enrich returns immediately + timestamp = 1 -> makes the descriptor non-zero + Keeps a 4096-iteration walk to one get_data call per descriptor. + """ + return _descriptor(original_first_thunk=0, timestamp=1, + forwarder_chain=0, name_rva=0, first_thunk=0) + + def _zero_descriptor() -> bytes: return b"\x00" * _DESCRIPTOR_SIZE @@ -603,6 +616,75 @@ def test_imports_per_descriptor_cap(self): assert "int_max_exceeded" in out["truncations"] +class TestDescriptorCountCap: + """ + The `for ... else` fires only when the walk exhausts _MAX_DESCRIPTORS + without breaking - no zero terminator, no read failure, and the declared + directory large enough to hold every descriptor. Any single break + silences it, which is why the fixture has to be constructed rather than + stumbled into. + """ + + def test_cap_reached_emits_max_exceeded(self, monkeypatch): + """Patched cap: same branch, tractable fixture.""" + import iocx.parsers.pe_imports as pei + cap = 4 + monkeypatch.setattr(pei, "_MAX_DESCRIPTORS", cap) + table = _minimal_nonzero_descriptor() * (cap + 2) # more than the cap + pe = _FakePE(0x1000, len(table), {0x1000: table}) + + out = build_import_structure(pe) + + assert "import_descriptor_max_exceeded" in out["truncations"] + assert out["descriptor_count"] == cap + + def test_cap_not_reached_when_terminator_found(self, monkeypatch): + """Control: a zero terminator breaks the loop, so `else` must NOT fire.""" + import iocx.parsers.pe_imports as pei + cap = 4 + monkeypatch.setattr(pei, "_MAX_DESCRIPTORS", cap) + table = _minimal_nonzero_descriptor() * 2 + b"\x00" * _DESCRIPTOR_SIZE + pe = _FakePE(0x1000, len(table), {0x1000: table}) + + out = build_import_structure(pe) + + assert "import_descriptor_max_exceeded" not in out["truncations"] + assert out["truncations"] == [] + assert out["descriptor_count"] == 2 + + def test_cap_not_reached_when_window_ends_first(self, monkeypatch): + """ + Control: the declared size runs out before the cap, so the + unterminated branch breaks instead - the two are mutually exclusive. + """ + import iocx.parsers.pe_imports as pei + cap = 8 + monkeypatch.setattr(pei, "_MAX_DESCRIPTORS", cap) + table = _minimal_nonzero_descriptor() * 3 + pe = _FakePE(0x1000, len(table), {0x1000: table}) + + out = build_import_structure(pe) + + assert out["truncations"] == ["import_descriptor_unterminated"] + assert "import_descriptor_max_exceeded" not in out["truncations"] + + + def test_real_cap_is_reachable(self): + """ + Unpatched: proves _MAX_DESCRIPTORS is genuinely attainable rather + than dead. Needs 4096 * 20 = 81920 bytes of descriptor data and a + declared directory at least that large. + """ + table = _minimal_nonzero_descriptor() * _MAX_DESCRIPTORS + assert len(table) == _MAX_DESCRIPTORS * _DESCRIPTOR_SIZE + pe = _FakePE(0x1000, len(table), {0x1000: table}) + + out = build_import_structure(pe) + + assert "import_descriptor_max_exceeded" in out["truncations"] + assert out["descriptor_count"] == _MAX_DESCRIPTORS + + # ================================================================= # Helpers # ================================================================= diff --git a/tests/unit/parsers/test_pe_imports_nnp.py b/tests/unit/parsers/test_pe_imports_nnp.py new file mode 100644 index 0000000..d8d79d6 --- /dev/null +++ b/tests/unit/parsers/test_pe_imports_nnp.py @@ -0,0 +1,220 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +name_not_printable is reachable only by control characters. Anything ≥ 0x80 never gets there: + +Foo\x01Bar -> reader err: None -> ['name_not_printable'] +Foo\xffBar -> reader err: name_non_ascii -> ['name_non_ascii'] + +_read_import_by_name decodes with "ascii", so a high byte raises UnicodeDecodeError and returns the name_non_ascii tag. The caller then takes the read_err is not None branch and never reaches the printability check. +""" + +from __future__ import annotations +import struct +from typing import Dict, List, Optional +import pytest + +from iocx.parsers.pe_imports import ( + build_import_structure, _decode_import_entry, _read_import_by_name, + _IMPORT_DIRECTORY_INDEX, _MAGIC_PE32_PLUS, +) + +_HIGH_BIT_64 = 1 << 63 + + +# ================================================================= +# Builders +# ================================================================= + +def _import_by_name(hint: int, name_bytes: bytes) -> bytes: + """IMAGE_IMPORT_BY_NAME with a raw byte name, so non-ASCII is expressible.""" + return struct.pack(" bytes: + return struct.pack(" bytes: + return b"".join(struct.pack(" Dict: + """Decode a single by-name import whose symbol is the given raw bytes.""" + pe = _FakePE({0x5000: _import_by_name(1, name_bytes)}) + return _decode_import_entry(pe, 0, 0x5000, _HIGH_BIT_64) + + +# ================================================================= +# The branch +# ================================================================= + +class TestNameNotPrintable: + """ + `name_not_printable` is reachable ONLY by control characters. + + A byte >= 0x80 never gets here: _read_import_by_name decodes with + "ascii", raises UnicodeDecodeError, and returns the tag `name_non_ascii` + - so the caller takes the `read_err is not None` branch instead. A test + written with \\xff (the obvious "non-printable" choice) would therefore + exercise a completely different branch and pass while proving nothing + about this one. + """ + + @pytest.mark.parametrize("byte,label", [ + (0x01, "SOH"), + (0x09, "TAB"), + (0x0A, "LF"), + (0x0D, "CR"), + (0x1F, "US - last control char below the printable range"), + (0x7F, "DEL - just above the printable range"), + ]) + def test_control_characters_are_flagged(self, byte, label): + entry = _entry_for(b"Foo" + bytes([byte]) + b"Bar") + assert entry["errors"] == ["name_not_printable"], label + assert entry["name_valid"] is False + + def test_name_is_still_recorded(self): + """ + The decoded string is preserved rather than discarded - a consumer + needs it to see WHAT was malformed, and the control byte survives + the round trip intact. + """ + entry = _entry_for(b"Foo\x01Bar") + assert entry["name"] == "Foo\x01Bar" + assert entry["name_rva"] == 0x5000 + assert entry["hint"] == 1 + + def test_wholly_non_printable_name(self): + entry = _entry_for(b"\x01\x02\x03") + assert entry["errors"] == ["name_not_printable"] + + def test_leading_and_trailing_control_chars(self): + for raw in (b"\x01Foo", b"Foo\x01"): + assert _entry_for(raw)["errors"] == ["name_not_printable"] + + +class TestPrintableBoundary: + """ + The regex range is [\\x20-\\x7E]. These pin both edges, so widening or + narrowing it by one is caught. + """ + + @pytest.mark.parametrize("byte,label", [ + (0x20, "SPACE - first printable"), + (0x7E, "TILDE - last printable"), + ]) + def test_boundary_bytes_are_accepted(self, byte, label): + entry = _entry_for(b"Foo" + bytes([byte]) + b"Bar") + assert entry["errors"] == [], label + assert entry["name_valid"] is True + + @pytest.mark.parametrize("byte,label", [ + (0x1F, "one below SPACE"), + (0x7F, "one above TILDE"), + ]) + def test_bytes_just_outside_are_rejected(self, byte, label): + entry = _entry_for(b"Foo" + bytes([byte]) + b"Bar") + assert entry["errors"] == ["name_not_printable"], label + + +class TestNotConfusedWithNonAscii: + """ + The negative controls. Without these, a mutation collapsing + name_not_printable into name_non_ascii (or vice versa) would pass. + """ + + @pytest.mark.parametrize("byte", [0x80, 0xC3, 0xFF]) + def test_high_bytes_yield_non_ascii_not_not_printable(self, byte): + entry = _entry_for(b"Foo" + bytes([byte]) + b"Bar") + assert entry["errors"] == ["name_non_ascii"] + assert "name_not_printable" not in entry["errors"] + + def test_the_two_tags_come_from_different_layers(self): + """ + name_non_ascii originates in _read_import_by_name (a read fault); + name_not_printable in _decode_import_entry (a content check). The + first short-circuits the second. + """ + pe_high = _FakePE({0x5000: _import_by_name(1, b"Foo\xffBar")}) + _, _, err_high = _read_import_by_name(pe_high, 0x5000) + assert err_high == "name_non_ascii" + + pe_ctrl = _FakePE({0x5000: _import_by_name(1, b"Foo\x01Bar")}) + _, _, err_ctrl = _read_import_by_name(pe_ctrl, 0x5000) + assert err_ctrl is None # reader is satisfied + assert _entry_for(b"Foo\x01Bar")["errors"] == ["name_not_printable"] + + def test_empty_name_takes_its_own_branch(self): + """name_empty is checked before printability, so an empty string + never reports as non-printable.""" + entry = _entry_for(b"") + assert entry["errors"] == ["name_empty"] + + def test_ordinal_imports_never_reach_the_check(self): + pe = _FakePE({}) + entry = _decode_import_entry(pe, 0, _HIGH_BIT_64 | 42, _HIGH_BIT_64) + assert entry["errors"] == [] + assert entry["name"] is None + + +class TestEndToEnd: + """The branch reached through the full parse, not just the helper.""" + + def test_non_printable_import_name_surfaces_in_the_structure(self): + pe = _FakePE({ + 0x1000: _descriptor() + b"\x00" * 20, + 0x2000: b"KERNEL32.dll\x00", + 0x3000: _thunks64([0x5000]), + 0x5000: _import_by_name(0x10, b"Load\x01Library"), + }) + out = build_import_structure(pe) + entry = out["descriptors"][0]["imports"][0] + assert entry["errors"] == ["name_not_printable"] + assert entry["name"] == "Load\x01Library" + assert entry["name_valid"] is False + # The descriptor itself is otherwise clean - single anomaly. + assert out["descriptors"][0]["errors"] == [] + assert out["truncations"] == [] + + def test_mixed_valid_and_non_printable_entries(self): + pe = _FakePE({ + 0x1000: _descriptor() + b"\x00" * 20, + 0x2000: b"KERNEL32.dll\x00", + 0x3000: _thunks64([0x5000, 0x5020, 0x5040]), + 0x5000: _import_by_name(1, b"GoodName"), + 0x5020: _import_by_name(2, b"Bad\x01Name"), + 0x5040: _import_by_name(3, b"AlsoGood"), + }) + out = build_import_structure(pe) + imports = out["descriptors"][0]["imports"] + assert [e["errors"] for e in imports] == [[], ["name_not_printable"], []] + assert [e["name_valid"] for e in imports] == [True, False, True] diff --git a/tests/unit/validators/test_validator_imports_ru.py b/tests/unit/validators/test_validator_imports_ru.py new file mode 100644 index 0000000..06d411b --- /dev/null +++ b/tests/unit/validators/test_validator_imports_ru.py @@ -0,0 +1,249 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Coverage for the `reason == "unknown"` branch in _validate_entries. + +This branch IS the silent-drop mechanism this workstream has spent its time +closing: an entry carrying only tags absent from _ENTRY_ERROR_PRIORITY is +skipped entirely - no issue, and no contribution to invalid_entry_count. + +It is UNREACHABLE with current parser output. Every tag pe_imports can place +in entry["errors"] appears in the priority list, verified statically by the +tag-contract check. The branch exists to absorb a future parser tag that +someone forgets to register, which is precisely how `empty_read`, +`dll_name_empty`, `dll_name_too_long` and `ordinal_index_duplicate` were all +lost in other subsystems. + +These tests therefore pin behaviour that is defensible but not obviously +desirable. They exist so that: + * the branch is covered rather than dead; + * the consequence (total invisibility, including in the count) is written + down rather than discovered; + * a change to that behaviour is a deliberate decision, not a drift. + +The real guard against the drop is the tag-contract test, which fails CI +when a parser gains a tag no validator consumes. This suite is the +belt-and-braces record of what happens if that guard is ever bypassed. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import pytest + +from iocx.reason_codes import ReasonCodes +from iocx.validators.imports import ( + validate_imports, + _ENTRY_ERROR_PRIORITY, + _MAX_ENTRY_ISSUES_PER_DESCRIPTOR, +) + + +# Every tag pe_imports can place in entry["errors"]. Kept as a literal so a +# parser change that adds a tag makes test_branch_is_unreachable_today fail, +# rather than the list silently tracking the parser. +_PARSER_ENTRY_TAGS = frozenset({ + "ordinal_zero", "name_rva_zero", "name_read_failed", "name_too_short", + "hint_unpack_failed", "name_unterminated", "name_non_ascii", + "name_empty", "name_not_printable", +}) + +_UNKNOWN = "some_future_parser_tag" + + +# ================================================================= +# Builders +# ================================================================= + +def _entry(index: int = 0, errors: Optional[List[str]] = None, + **kw) -> Dict[str, Any]: + e = {"index": index, "errors": errors or [], "is_ordinal": False, + "ordinal": None, "name": "Fn", "name_rva": 0x5000, + "name_valid": True, "hint": 1, "thunk_value": 0x5000} + e.update(kw) + return e + + +def _descriptor(index: int = 0, imports: Optional[List[Dict]] = None, + errors: Optional[List[str]] = None, + dll_name: str = "KERNEL32.dll") -> Dict[str, Any]: + return {"index": index, "errors": errors or [], "dll_name": dll_name, + "dll_name_valid": True, "name_rva": 0x2000, + "bound_state": "unbound", "original_first_thunk": 0x3000, + "first_thunk": 0x4000, "thunk_source": "int", + "imports": imports or []} + + +def _internal(descriptors: List[Dict[str, Any]]) -> Dict[str, Any]: + return {"import_struct": { + "rva": 0x1000, "size": 60, "is_64bit": True, + "descriptors": descriptors, "descriptor_count": len(descriptors), + "truncations": [], "errors": []}} + + +def _entry_issues(issues) -> List[Dict[str, Any]]: + return [i["details"] for i in issues + if i["issue"] == ReasonCodes.IMPORT_ENTRY_INVALID] + + +# ================================================================= +# Reachability +# ================================================================= + +class TestBranchReachability: + + def test_branch_is_unreachable_with_current_parser_tags(self): + """ + Guard on the guard: every tag pe_imports can emit must appear in the + priority list, so the unknown branch cannot fire in production. If + this fails, a parser tag has been added without registering it - and + the entries carrying it are being silently dropped right now. + """ + unregistered = _PARSER_ENTRY_TAGS - set(_ENTRY_ERROR_PRIORITY) + assert not unregistered, ( + f"parser tags missing from _ENTRY_ERROR_PRIORITY: " + f"{sorted(unregistered)} - entries carrying these are dropped") + + def test_priority_list_has_no_phantom_entries(self): + """The inverse: a listed tag no parser produces implies a routing + that does not exist.""" + phantom = set(_ENTRY_ERROR_PRIORITY) - _PARSER_ENTRY_TAGS + assert not phantom, f"unreachable tags in priority list: {sorted(phantom)}" + + +# ================================================================= +# The branch itself +# ================================================================= + +class TestUnknownEntryTagIsDropped: + + def test_entry_with_only_unknown_tags_emits_nothing(self): + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(0, [_UNKNOWN])])])) + assert _entry_issues(issues) == [] + + def test_multiple_unknown_tags_on_one_entry(self): + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(0, [_UNKNOWN, "another_unknown"])])])) + assert _entry_issues(issues) == [] + + def test_all_entries_unknown_takes_the_empty_return(self): + """ + With nothing collected, `if not invalid: return` fires before the + emission loop - a distinct path from an empty imports list. + """ + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(0, [_UNKNOWN]), + _entry(1, ["yet_another"])])])) + assert _entry_issues(issues) == [] + + def test_dropped_entry_is_excluded_from_invalid_entry_count(self): + """ + The `continue` precedes the append, so a dropped entry is invisible + in every respect - not merely unreported, but uncounted. A consumer + reading invalid_entry_count cannot tell that anything was skipped. + """ + issues = validate_imports(_internal([_descriptor(imports=[ + _entry(0, ["ordinal_zero"]), + _entry(1, [_UNKNOWN]), + _entry(2, ["name_empty"]), + ])])) + details = _entry_issues(issues) + assert [d["entry_index"] for d in details] == [0, 2] + assert all(d["invalid_entry_count"] == 2 for d in details), ( + "the unknown entry must not inflate the count either") + + def test_clean_entries_and_unknown_entries_are_indistinguishable(self): + """ + An entry with no errors and an entry with only unknown errors produce + identical output. This is the property that makes the drop dangerous, + and the reason the tag-contract check exists upstream. + """ + with_unknown = validate_imports(_internal([ + _descriptor(imports=[_entry(0, [_UNKNOWN])])])) + with_clean = validate_imports(_internal([ + _descriptor(imports=[_entry(0, [])])])) + assert with_unknown == with_clean == [] + + +# ================================================================= +# Rescue: a known tag anywhere on the entry +# ================================================================= + +class TestKnownTagRescuesTheEntry: + """ + _first_matching scans the PRIORITY list, not the entry's error list, so + order within the entry is irrelevant - one recognised tag is enough. + """ + + @pytest.mark.parametrize("errors,expected", [ + ([_UNKNOWN, "ordinal_zero"], "ordinal_zero"), + (["ordinal_zero", _UNKNOWN], "ordinal_zero"), + ([_UNKNOWN, "name_empty", "another_unknown"], "name_empty"), + ]) + def test_known_tag_wins_regardless_of_position(self, errors, expected): + issues = validate_imports(_internal([ + _descriptor(imports=[_entry(0, errors)])])) + details = _entry_issues(issues) + assert len(details) == 1 + assert details[0]["sub_reason"] == expected + + def test_priority_still_applies_among_known_tags(self): + """An unknown tag does not disturb the ordering of the known ones.""" + issues = validate_imports(_internal([ + _descriptor(imports=[ + _entry(0, [_UNKNOWN, "name_empty", "ordinal_zero"])])])) + assert _entry_issues(issues)[0]["sub_reason"] == "ordinal_zero" + + +# ================================================================= +# Interaction with the emission cap +# ================================================================= + +class TestInteractionWithCap: + + def test_dropped_entries_do_not_consume_cap_budget(self): + """ + Because the drop happens during collection, unknown entries never + reach the capped slice - so a table padded with unknown tags cannot + squeeze out reportable ones. + """ + n = _MAX_ENTRY_ISSUES_PER_DESCRIPTOR + imports = [] + for i in range(n): + imports.append(_entry(len(imports), [_UNKNOWN])) + imports.append(_entry(len(imports), ["ordinal_zero"])) + issues = validate_imports(_internal([_descriptor(imports=imports)])) + details = _entry_issues(issues) + assert len(details) == n + assert all(d["sub_reason"] == "ordinal_zero" for d in details) + assert all(d["invalid_entry_count"] == n for d in details) + + +# ================================================================= +# Descriptor level uses the inverse shape +# ================================================================= + +class TestDescriptorLevelUnknown: + """ + _validate_descriptors tests `if reason != "unknown"` rather than + `continue`-ing, so an unrecognised descriptor tag is also dropped - but + via a different code shape. Pinned here so both are covered. + """ + + def test_unknown_descriptor_tag_emits_nothing(self): + issues = validate_imports(_internal([ + _descriptor(errors=[_UNKNOWN])])) + assert issues == [] + + def test_unknown_descriptor_tag_does_not_block_entry_reporting(self): + """The two levels are independent: a dropped descriptor tag must not + suppress a reportable entry beneath it.""" + issues = validate_imports(_internal([ + _descriptor(errors=[_UNKNOWN], + imports=[_entry(0, ["ordinal_zero"])])])) + details = _entry_issues(issues) + assert len(details) == 1 + assert details[0]["sub_reason"] == "ordinal_zero" From 7fb7a30ae010fe6c5308bc3f4fbcf88a74794008 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 1 Sep 2026 12:41:52 +0100 Subject: [PATCH 10/40] Static tag testing. Six pairs of parsers/validators are tested statically to ensure no tags are dropped, there are no unexpandable templates, and no phantoms. Pairs tested as of this commit: imports, relocations, tls, debug, exports, delay_imports --- tests/contract/tag_contract.py | 354 ++++++++++++++++++++++++++++ tests/contract/test_tag_contract.py | 121 ++++++++++ 2 files changed, 475 insertions(+) create mode 100644 tests/contract/tag_contract.py create mode 100644 tests/contract/test_tag_contract.py diff --git a/tests/contract/tag_contract.py b/tests/contract/tag_contract.py new file mode 100644 index 0000000..4aaaed8 --- /dev/null +++ b/tests/contract/tag_contract.py @@ -0,0 +1,354 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Static verification of the parser -> validator tag contract. + +Parsers record structural faults as tombstone tags in `errors` and +`truncations` lists. Validators consume those tags and map them to reason +codes. A tag with no consumer is SILENTLY DROPPED: `_first_matching` returns +"unknown" and the caller continues, so the finding never reaches output. + +WHY AST AND NOT REGEX +Regex extraction misses two shapes this codebase uses: a tag returned on a +UnicodeDecodeError branch where the literal sits on a different line from the +`return`, and f-string templates such as f"{tag}_truncated" which expand to +several concrete tags depending on the caller. + +THREE CONSUMPTION PATTERNS +Conflating these produces false positives, so each is detected separately: + + * ITERATED WHOLESALE + for tag in x["truncations"]: + emit(details={"table": tag}) + Every tag becomes its own issue. + + * FORWARDED WHOLESALE + emit(details={"sub_reason": "top_level_decode", + "errors": list(imp["errors"])}) + The whole list is copied into one issue's details. No tag is lost, but + there is no loop to detect - this shape was missed by an earlier version + of this module and produced a false positive on `descriptor_unpack_failed`. + + * PRIORITY-MATCHED + _first_matching(errors, _SOME_PRIORITY_LIST) + Only listed tags are emitted. This is the sink that can drop. + +Only the third needs a membership check. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set, Tuple + + +_ERROR_SINKS = {"errors", "entry_errors", "descriptor_errors", "block_errors"} +_TRUNCATION_SINKS = {"truncations"} +_ALL_SINKS = _ERROR_SINKS | _TRUNCATION_SINKS + +_DEFAULT_TEMPLATE_VARS: Dict[str, List[str]] = {} + + +@dataclass +class ParserTags: + # Tags written to the caller-owned lists returned at the top level of + # the struct. Identified by an append to a bare name that is a PARAMETER + # of the enclosing function and named exactly "errors"/"truncations". + top_errors: Set[str] = field(default_factory=set) + top_truncations: Set[str] = field(default_factory=set) + # Tags written to per-descriptor / per-entry lists: appends to a local, + # to a subscript, or to a differently-named parameter such as + # `descriptor_errors`. + item_errors: Set[str] = field(default_factory=set) + unexpanded: Set[str] = field(default_factory=set) + + @property + def errors(self) -> Set[str]: + return self.top_errors | self.item_errors + + @property + def truncations(self) -> Set[str]: + return self.top_truncations + + +@dataclass +class ValidatorConsumption: + matched: Set[str] = field(default_factory=set) + iterated_sinks: Set[str] = field(default_factory=set) + forwarded_sinks: Set[str] = field(default_factory=set) + + @property + def wholesale_sinks(self) -> Set[str]: + return self.iterated_sinks | self.forwarded_sinks + + +@dataclass +class ContractResult: + parser: str + validator: str + emitted_errors: Set[str] + emitted_truncations: Set[str] + matched: Set[str] + iterated_sinks: Set[str] + forwarded_sinks: Set[str] + dropped: Set[str] + phantom: Set[str] + unexpanded: Set[str] + + @property + def ok(self) -> bool: + return not (self.dropped or self.unexpanded) + + def report(self) -> str: + lines = [f"{self.parser} -> {self.validator}"] + lines.append(f" emitted (errors) : {len(self.emitted_errors)}") + lines.append(f" emitted (truncations) : {len(self.emitted_truncations)}") + lines.append(f" iterated wholesale : {sorted(self.iterated_sinks) or 'none'}") + lines.append(f" forwarded wholesale : {sorted(self.forwarded_sinks) or 'none'}") + if self.dropped: + lines.append(f" DROPPED : {sorted(self.dropped)}") + if self.unexpanded: + lines.append(f" UNEXPANDED TEMPLATES : {sorted(self.unexpanded)}") + if self.phantom: + lines.append(f" phantom (unreachable) : {sorted(self.phantom)}") + if self.ok and not self.phantom: + lines.append(" OK - every emittable tag has a consumer") + return "\n".join(lines) + + +# ================================================================= +# Parser side +# ================================================================= + +def _sink_ref(node: ast.AST) -> Optional[Tuple[str, bool]]: + """ + Return (sink_name, is_bare_name) for an X.append(...) call, else None. + + is_bare_name distinguishes `errors.append(...)` from + `descriptor["errors"].append(...)`, which the scope rule needs. + """ + if not (isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append"): + return None + target = node.func.value + if isinstance(target, ast.Name) and target.id in _ALL_SINKS: + return (target.id, True) + if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant): + if target.slice.value in _ALL_SINKS: + return (target.slice.value, False) + return None + + +def _function_scopes(tree: ast.AST) -> Dict[ast.AST, Set[str]]: + """Map each function node to the set of its parameter names.""" + scopes: Dict[ast.AST, Set[str]] = {} + for fn in ast.walk(tree): + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + scopes[fn] = {a.arg for a in fn.args.args} + return scopes + + +def _expand_fstring(node: ast.JoinedStr, + template_vars: Dict[str, List[str]]) -> Tuple[List[str], Optional[str]]: + var: Optional[str] = None + prefix = suffix = "" + seen_var = False + has_placeholder = False + for part in node.values: + if isinstance(part, ast.FormattedValue): + has_placeholder = True + if isinstance(part.value, ast.Name): + var = part.value.id + seen_var = True + else: + return [], ast.unparse(node) + elif isinstance(part, ast.Constant): + if seen_var: + suffix += str(part.value) + else: + prefix += str(part.value) + if not has_placeholder: + # An f-string with nothing to interpolate is a literal in disguise. + return [prefix], None + if var is None or var not in template_vars: + return [], ast.unparse(node) + return [f"{prefix}{v}{suffix}" for v in template_vars[var]], None + + +def extract_parser_tags( + source: str, + template_vars: Optional[Dict[str, List[str]]] = None, +) -> ParserTags: + template_vars = {**_DEFAULT_TEMPLATE_VARS, **(template_vars or {})} + tree = ast.parse(source) + out = ParserTags() + scopes = _function_scopes(tree) + + for fn, params in scopes.items(): + for node in ast.walk(fn): + ref = _sink_ref(node) + if ref is None or not node.args: + continue + sink, is_bare = ref + # Top-level only when appending to a caller-owned list: a bare + # name that is a parameter AND uses the canonical sink name. + # `descriptor_errors` is a parameter too, but its distinct name + # keeps it per-item. + is_top = is_bare and sink in _ALL_SINKS and sink in params + if sink in _TRUNCATION_SINKS: + bucket = out.top_truncations + elif is_top: + bucket = out.top_errors + else: + bucket = out.item_errors + arg = node.args[0] + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + bucket.add(arg.value) + elif isinstance(arg, ast.JoinedStr): + expansions, unexpanded = _expand_fstring(arg, template_vars) + if unexpanded is not None: + out.unexpanded.add(unexpanded) + else: + bucket.update(expansions) + + for node in ast.walk(tree): + + if isinstance(node, ast.Return) and isinstance(node.value, ast.Tuple): + last = node.value.elts[-1] + if isinstance(last, ast.Constant) and isinstance(last.value, str): + # A helper's error tag is appended by its caller, which is + # always a per-item context in this codebase. + out.item_errors.add(last.value) + + if isinstance(node, ast.Dict): + for k, v in zip(node.keys, node.values): + if (isinstance(k, ast.Constant) and k.value in _ALL_SINKS + and isinstance(v, ast.List)): + bucket = (out.top_truncations + if k.value in _TRUNCATION_SINKS + else out.item_errors) + for el in v.elts: + if isinstance(el, ast.Constant) and isinstance(el.value, str): + bucket.add(el.value) + + return out + + +# ================================================================= +# Validator side +# ================================================================= + +def _iterated_sink(node: ast.For) -> Optional[str]: + for sub in ast.walk(node.iter): + if isinstance(sub, ast.Constant) and sub.value in _ALL_SINKS: + return sub.value + return None + + +def _loop_var_reaches_emission(node: ast.For) -> bool: + if not isinstance(node.target, ast.Name): + return False + var = node.target.id + for sub in ast.walk(node): + if sub is node.target: + continue + if isinstance(sub, ast.Name) and sub.id == var and isinstance(sub.ctx, ast.Load): + return True + return False + + +def _forwarded_sinks_in_dict(node: ast.Dict) -> Set[str]: + """ + Detect a details payload that copies a whole sink list verbatim, e.g. + + details={"errors": list(imp["errors"])} + + A LITERAL list value is a fixed payload, not forwarding, and is + excluded. Any other expression that reads a sink name is treated as + forwarding that sink. + """ + found: Set[str] = set() + for k, v in zip(node.keys, node.values): + if not (isinstance(k, ast.Constant) and isinstance(k.value, str)): + continue + if isinstance(v, ast.List): + continue # literal payload, not a forward + for sub in ast.walk(v): + if isinstance(sub, ast.Constant) and sub.value in _ALL_SINKS: + found.add(sub.value) + return found + + +def extract_validator_consumption(source: str) -> ValidatorConsumption: + tree = ast.parse(source) + out = ValidatorConsumption() + + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and isinstance( + node.value, (ast.List, ast.Set, ast.Tuple)): + for el in node.value.elts: + if isinstance(el, ast.Constant) and isinstance(el.value, str): + out.matched.add(el.value) + + if (isinstance(node, ast.Compare) and len(node.ops) == 1 + and isinstance(node.ops[0], ast.In) + and isinstance(node.left, ast.Constant) + and isinstance(node.left.value, str)): + out.matched.add(node.left.value) + + if isinstance(node, ast.For): + sink = _iterated_sink(node) + if sink is not None and _loop_var_reaches_emission(node): + out.iterated_sinks.add(sink) + + if isinstance(node, ast.Dict): + out.forwarded_sinks |= _forwarded_sinks_in_dict(node) + + return out + + +# ================================================================= +# Comparison +# ================================================================= + +def check_contract( + parser_source: str, + validator_source: str, + parser_name: str = "parser", + validator_name: str = "validator", + template_vars: Optional[Dict[str, List[str]]] = None, +) -> ContractResult: + tags = extract_parser_tags(parser_source, template_vars) + consumption = extract_validator_consumption(validator_source) + + # Wholesale consumption exempts ONLY the level it actually forwards. + # The validator's `details={"errors": list(imp["errors"])}` copies the + # TOP-LEVEL list; per-descriptor and per-entry errors remain + # priority-matched and can still drop. + unchecked: Set[str] = set() + if _TRUNCATION_SINKS & consumption.wholesale_sinks: + unchecked |= tags.top_truncations + if _ERROR_SINKS & consumption.forwarded_sinks: + unchecked |= tags.top_errors + if _ERROR_SINKS & consumption.iterated_sinks: + unchecked |= tags.errors + + checkable = (tags.errors | tags.truncations) - unchecked + dropped = checkable - consumption.matched + phantom = consumption.matched - tags.errors - tags.truncations + + return ContractResult( + parser=parser_name, + validator=validator_name, + emitted_errors=tags.errors, + emitted_truncations=tags.truncations, + matched=consumption.matched, + iterated_sinks=consumption.iterated_sinks, + forwarded_sinks=consumption.forwarded_sinks, + dropped=dropped, + phantom=phantom, + unexpanded=tags.unexpanded, + ) diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py new file mode 100644 index 0000000..dcf7112 --- /dev/null +++ b/tests/contract/test_tag_contract.py @@ -0,0 +1,121 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +CI guard: every tombstone tag a parser can emit must have a validator that +consumes it. + +Add a (parser, validator, template_vars) row to _PAIRS for each subsystem. +The `template_vars` mapping supplies the call-site values for f-string tag +templates - a parser that builds f"{tag}_truncated" needs its `tag` values +listed, or the check fails loudly rather than silently skipping them. +""" + +from __future__ import annotations + +import inspect +from typing import Dict, List + +import pytest + +from tag_contract import check_contract + +from iocx.parsers import (pe_imports, pe_relocations, pe_tls, pe_debug, + pe_exports, pe_delay_imports) +from iocx.validators import (imports, relocations, tls, debug, + exports, delay_imports) + +_PAIRS = [ + # (label, parser module, validator module, template_vars) + # + # template_vars supplies the call-site values for f-string tag templates. + # pe_imports builds thunk-array tags as f"{tag}_truncated" etc., where + # `tag` is _read_thunk_array's parameter. It is passed exactly two + # values, from _enrich_descriptor's name-source selection: + # "int" - OriginalFirstThunk present (normal) + # "iat_fallback" - OriginalFirstThunk zero, fell back to FirstThunk + # Both must be listed, or those eight tags are silently unchecked. + ("pe_imports", pe_imports, imports, {"tag": ["int", "iat_fallback"]}), + ("pe_relocations", pe_relocations, relocations, {}), + ("pe_tls", pe_tls, tls, {}), + ("pe_debug", pe_debug, debug, {}), + ("pe_exports", pe_exports, exports, {"tag": ["eat", "enpt", "eot"]}), + ("pe_delay_imports", pe_delay_imports, delay_imports, {"tag": ["int", "iat"]}), +] + +# Tags a parser emits that no validator consumes, deliberately. +# Scoped per-tag so a SECOND drop in the same subsystem still fails. +_KNOWN_DELIBERATE_DROPS = { + # pe_tls records this, but validate_tls derives TLS_INVALID_RANGE from + # the start/end VA fields directly - more authoritative than a derived + # tag. Redundant signalling, not a lost finding. + "pe_tls": {"tls_raw_data_end_before_start"}, +} + +@pytest.mark.contract +@pytest.mark.parametrize("name,parser_mod,validator_mod,templates", + _PAIRS, ids=[p[0] for p in _PAIRS] or None) +def test_no_tag_is_silently_dropped(name, parser_mod, validator_mod, templates): + """ + A tag with no consumer vanishes: _first_matching returns "unknown" and + the caller continues, so the structural finding never reaches output. + """ + result = check_contract( + inspect.getsource(parser_mod), + inspect.getsource(validator_mod), + parser_name=name, + validator_name=validator_mod.__name__, + template_vars=templates, + ) + unexpected = result.dropped - _KNOWN_DELIBERATE_DROPS.get(name, set()) + assert not unexpected, ( + f"\n{result.report()}\n\n" + f"These tags are emitted by {name} but consumed by nothing. Add them " + f"to the relevant priority list, or remove them from the parser." + ) + + +@pytest.mark.contract +@pytest.mark.parametrize("name,parser_mod,validator_mod,templates", + _PAIRS, ids=[p[0] for p in _PAIRS] or None) +def test_no_unexpandable_tag_template(name, parser_mod, validator_mod, templates): + """ + An f-string tag whose variable has no declared expansion cannot be + checked. Fail rather than skip - a silent skip is how the original bugs + stayed hidden. + """ + result = check_contract( + inspect.getsource(parser_mod), + inspect.getsource(validator_mod), + parser_name=name, + validator_name=validator_mod.__name__, + template_vars=templates, + ) + assert not result.unexpanded, ( + f"\n{result.report()}\n\n" + f"Add the call-site values for these templates to _PAIRS." + ) + + +@pytest.mark.contract +@pytest.mark.parametrize("name,parser_mod,validator_mod,templates", + _PAIRS, ids=[p[0] for p in _PAIRS] or None) +def test_no_phantom_tags(name, parser_mod, validator_mod, templates): + """ + A tag named in a priority list that no parser can produce is inert but + misleading: it implies a routing that does not exist, and would produce + a wrong sub_reason if the name were ever reused at another level. + + Mark xfail rather than fail if you keep deliberate defensive entries. + """ + result = check_contract( + inspect.getsource(parser_mod), + inspect.getsource(validator_mod), + parser_name=name, + validator_name=validator_mod.__name__, + template_vars=templates, + ) + assert not result.phantom, ( + f"\n{result.report()}\n\n" + f"These tags appear in a priority list but cannot be emitted." + ) From bac4b908477d35b1425c96c222b265520b559fe2 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 1 Sep 2026 15:13:06 +0100 Subject: [PATCH 11/40] Remove _index tombstone suffix, defaulting to a hard non-suffixed code --- iocx/parsers/pe_delay_imports.py | 2 +- iocx/parsers/pe_relocations.py | 2 +- iocx/validators/relocations.py | 1 + tests/unit/parsers/test_pe_delay_imports.py | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/iocx/parsers/pe_delay_imports.py b/iocx/parsers/pe_delay_imports.py index ae67cde..e7e1264 100644 --- a/iocx/parsers/pe_delay_imports.py +++ b/iocx/parsers/pe_delay_imports.py @@ -165,7 +165,7 @@ def _read_descriptors( decoded = _decode_descriptor(raw, index) if decoded is None: - errors.append(f"descriptor_unpack_failed_at_{index}") + errors.append("descriptor_unpack_failed") break # Check for zero terminator BEFORE adding to results diff --git a/iocx/parsers/pe_relocations.py b/iocx/parsers/pe_relocations.py index 0359025..51816db 100644 --- a/iocx/parsers/pe_relocations.py +++ b/iocx/parsers/pe_relocations.py @@ -153,7 +153,7 @@ def _read_blocks( try: page_rva, size_of_block = struct.unpack_from(" Date: Tue, 1 Sep 2026 15:23:49 +0100 Subject: [PATCH 12/40] Update documentation: tag-contract --- docs/specs/reason-codes.md | 6 +++--- ...uctural-validation-deterministic-heuristics.md | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index e49add3..bf5aa61 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -1,8 +1,8 @@ # **PE Structural Reason Codes** > **Truncation detail keys are not uniform.** `EXPORT_TABLE_TRUNCATED`, -> `DELAY_IMPORT_TABLE_TRUNCATED` and `EXCEPTION_TABLE_TRUNCATED` name the -> affected sub-table in a **`table`** key. `DEBUG_TABLE_TRUNCATED`, +> `DELAY_IMPORT_TABLE_TRUNCATED`, `IMPORT_TABLE_TRUNCATED` and `EXCEPTION_TABLE_TRUNCATED` +> name the affected sub-table in a **`table`** key. `DEBUG_TABLE_TRUNCATED`, > `RELOCATION_TABLE_TRUNCATED` and `TLS_DIRECTORY_TRUNCATED` use **`region`** > instead. Both are stable; consumers handling truncation generically must > read either. @@ -779,7 +779,7 @@ region: | region value | Meaning | |--------------|---------| -| debug_directory_size_not_entry_aligned | Declared directory size is not a whole multiple of the 28-byte entry stride | +| debug_directory_size_not_entry_aligned | Declared directory size is not a whole multiple of the 28-byte entry stride; the partial trailing entry is not decoded | | debug_directory_entry_count_exceeds_max | Declared entry count exceeded the parser's hard limit (256) and was clamped | | debug_entry_read_failed | `pe.get_data` raised while reading an entry | | debug_entry_truncated | An entry read returned fewer than 28 bytes | diff --git a/docs/specs/structural-validation-deterministic-heuristics.md b/docs/specs/structural-validation-deterministic-heuristics.md index 58f1b53..d7429d5 100644 --- a/docs/specs/structural-validation-deterministic-heuristics.md +++ b/docs/specs/structural-validation-deterministic-heuristics.md @@ -448,6 +448,21 @@ Heuristics include: > This separation is what makes structural findings machine-consumable: the > parent code is the stable contract, the sub-reason is diagnostic detail, and > neither can shadow the other. +> +> **The tag contract is statically verified.** Parsers record faults as +> tombstone tags; validators consume them via priority lists or wholesale +> forwarding. A tag with no consumer is silently dropped — `_first_matching` +> returns `"unknown"` and the caller continues, so the finding never reaches +> output at all. That failure mode is not visible in coverage, in snapshots, +> or on inspection: the affected file simply reports clean. A contract check +> therefore walks every parser's AST, enumerates the tags it can emit +> (including f-string templates and tags returned from helper readers), and +> asserts each has a consumer in its paired validator. It runs in CI across +> every parser/validator pair. Deliberate exemptions — where a parser records +> a tag the validator intentionally ignores in favour of a more authoritative +> source — are declared per-tag rather than per-subsystem, so a second, +> undeclared drop in the same parser still fails. + Heuristics never contradict validators. They only interpret validated truth. From 3cd8b53509476903393dad837b2980d0981af047 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 1 Sep 2026 16:31:31 +0100 Subject: [PATCH 13/40] Certificates parser/validator pair now tag-contract tested. This commit also includes additional tests that cover code paths the pair had not previously had tests for. Also removes unused tombstone code from validator as this was not used, and covered by SIGNATURE_INVALID_LENGTH --- iocx/parsers/pe_certificates.py | 2 +- iocx/validators/signature.py | 7 - tests/contract/tag_contract.py | 14 +- tests/contract/test_tag_contract.py | 10 +- tests/unit/parsers/test_pe_certificates.py | 2 +- tests/unit/parsers/test_pe_certificates_gf.py | 409 ++++++++++++++++++ 6 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 tests/unit/parsers/test_pe_certificates_gf.py diff --git a/iocx/parsers/pe_certificates.py b/iocx/parsers/pe_certificates.py index 4443043..d3270a9 100644 --- a/iocx/parsers/pe_certificates.py +++ b/iocx/parsers/pe_certificates.py @@ -216,7 +216,7 @@ def _read_certificates( " bool: + """ + The function that builds the top-level struct. Every parser follows the + build__structure convention, and that function owns the + struct's own `errors`/`truncations` lists - which it creates as locals + rather than receiving as parameters. + """ + return (isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + and fn.name.startswith("build_") + and fn.name.endswith("_structure")) + for fn, params in scopes.items(): + entry = _is_entry_point(fn) for node in ast.walk(fn): ref = _sink_ref(node) if ref is None or not node.args: @@ -197,7 +209,7 @@ def extract_parser_tags( # name that is a parameter AND uses the canonical sink name. # `descriptor_errors` is a parameter too, but its distinct name # keeps it per-item. - is_top = is_bare and sink in _ALL_SINKS and sink in params + is_top = is_bare and (sink in params or entry) if sink in _TRUNCATION_SINKS: bucket = out.top_truncations elif is_top: diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index dcf7112..bc6900d 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -21,9 +21,9 @@ from tag_contract import check_contract from iocx.parsers import (pe_imports, pe_relocations, pe_tls, pe_debug, - pe_exports, pe_delay_imports) + pe_exports, pe_delay_imports, pe_certificates) from iocx.validators import (imports, relocations, tls, debug, - exports, delay_imports) + exports, delay_imports, signature) _PAIRS = [ # (label, parser module, validator module, template_vars) @@ -41,6 +41,7 @@ ("pe_debug", pe_debug, debug, {}), ("pe_exports", pe_exports, exports, {"tag": ["eat", "enpt", "eot"]}), ("pe_delay_imports", pe_delay_imports, delay_imports, {"tag": ["int", "iat"]}), + ("pe_certificates", pe_certificates, signature, {}), ] # Tags a parser emits that no validator consumes, deliberately. @@ -50,6 +51,11 @@ # the start/end VA fields directly - more authoritative than a derived # tag. Redundant signalling, not a lost finding. "pe_tls": {"tls_raw_data_end_before_start"}, + # validate_signature checks cert["revision"] and cert["cert_type"] + # against the known-value sets directly rather than consuming these + # tags. The facts reach output as SIGNATURE_INVALID_REVISION / + # SIGNATURE_INVALID_TYPE. Redundant signalling, not a lost finding. + "pe_certificates": {"unknown_revision", "unknown_cert_type", "length_too_small"}, } @pytest.mark.contract diff --git a/tests/unit/parsers/test_pe_certificates.py b/tests/unit/parsers/test_pe_certificates.py index 40c397e..0699eb7 100644 --- a/tests/unit/parsers/test_pe_certificates.py +++ b/tests/unit/parsers/test_pe_certificates.py @@ -316,7 +316,7 @@ def boom(fmt, buf, off=0): errs: List[str] = [] out = _read_certificates(data, 0x800, 0x18, len(data), [], errs) assert out == [] - assert any(e.startswith("certificate_header_unpack_failed_at_") + assert any(e.startswith("certificate_header_unpack_failed") for e in errs) diff --git a/tests/unit/parsers/test_pe_certificates_gf.py b/tests/unit/parsers/test_pe_certificates_gf.py new file mode 100644 index 0000000..3bceb50 --- /dev/null +++ b/tests/unit/parsers/test_pe_certificates_gf.py @@ -0,0 +1,409 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Gap-filling tests for pe_certificates and validators.signature. + +These cover paths the existing suites leave unexercised. Each class states +what a regression would look like, because several of these behaviours are +deliberate and read as bugs without that context. +""" + +from __future__ import annotations + +import struct +from typing import Any, Dict, List, Optional + +import pytest + +from iocx.parsers.pe_certificates import ( + build_certificate_structure, _align_up, _decode_certificate, + _read_certificates, _CERT_ALIGNMENT, _WIN_CERT_HEADER_SIZE, + _SECURITY_DIRECTORY_INDEX, +) +from iocx.reason_codes import ReasonCodes +from iocx.validators.signature import validate_signature + + +# ================================================================= +# Builders +# ================================================================= + +def _header(dw_length: int, revision: int = 0x0200, + cert_type: int = 0x0002) -> bytes: + return struct.pack(" bytes: + """ + A WIN_CERTIFICATE. `pad` appends the inter-entry padding a conformant + file carries - dwLength counts only the header plus blob, so an entry + whose dwLength is not a multiple of 8 is followed by padding bytes that + the walk skips via _align_up. + """ + entry = _header(_WIN_CERT_HEADER_SIZE + len(blob), revision, cert_type) + blob + if pad: + entry += b"\x00" * ((-len(entry)) % _CERT_ALIGNMENT) + return entry + + +class _DataDir: + def __init__(self, va, size): + self.VirtualAddress = va + self.Size = size + +class _OptHdr: + def __init__(self, dd): + self.DATA_DIRECTORY = [None] * 16 + if dd is not None: + self.DATA_DIRECTORY[_SECURITY_DIRECTORY_INDEX] = dd + +class _Section: + def __init__(self, ptr, raw_size): + self.PointerToRawData = ptr + self.SizeOfRawData = raw_size + +class _FakePE: + def __init__(self, file_bytes=b"", dd=None, sections=()): + self.__data__ = file_bytes + self.OPTIONAL_HEADER = _OptHdr(dd) + self.sections = sections + + +# ================================================================= +# PARSER - 8-byte alignment advance +# ================================================================= + +class TestAlignmentAdvance: + """ + dwLength counts the header plus blob only; entries are QWORD-aligned, so + the walk must advance by _align_up(dwLength, 8). + + The existing suite's multi-entry fixture uses blob sizes that are + already 8-aligned, so removing _align_up entirely would not fail any + test - the walk would read the next header at the same offset either + way. These fixtures use dwLength values that are NOT multiples of 8. + """ + + @pytest.mark.parametrize("blob_size,expected_advance", [ + (1, 16), # dwLength 9 + (5, 16), # dwLength 13 + (7, 16), # dwLength 15 + (13, 24), # dwLength 21 + ]) + def test_second_entry_found_at_aligned_offset(self, blob_size, expected_advance): + first = _entry(b"\xAA" * blob_size) + second = _entry(b"\xBB" * 4) + data = bytes(0x800) + first + second + out = _read_certificates(data, 0x800, len(first) + len(second), + len(data), [], []) + assert len(out) == 2 + assert out[1]["offset"] == 0x800 + expected_advance + assert out[1]["length"] == _WIN_CERT_HEADER_SIZE + 4 + + def test_already_aligned_length_advances_unchanged(self): + """Control: an 8-aligned dwLength must not be padded further.""" + first = _entry(b"\xAA" * 8) # dwLength 16 + second = _entry(b"\xBB" * 4) + data = bytes(0x800) + first + second + out = _read_certificates(data, 0x800, len(first) + len(second), + len(data), [], []) + assert out[1]["offset"] == 0x800 + 16 + + def test_three_unaligned_entries_walk_correctly(self): + """ + Cumulative alignment: a single mis-advance desynchronises every + subsequent entry, so a chain catches an off-by-one the pair misses. + """ + blobs = [b"\xAA" * 5, b"\xBB" * 13, b"\xCC" * 1] + table = b"".join(_entry(b) for b in blobs) + data = bytes(0x800) + table + out = _read_certificates(data, 0x800, len(table), len(data), [], []) + assert [c["length"] for c in out] == [13, 21, 9] + assert [c["offset"] for c in out] == [0x800, 0x810, 0x828] + + +# ================================================================= +# PARSER - non-advancing cursor +# ================================================================= + +class TestNonAdvancingLength: + """ + A dwLength below the 8-byte header cannot advance the cursor. The walk + must record the malformed entry and stop; the existing suite tests only + dwLength == 4. + """ + + @pytest.mark.parametrize("dw_length", [0, 1, 7]) + def test_walk_stops_and_tags(self, dw_length): + data = bytes(0x800) + _header(dw_length) + bytes(0x40) + out = _read_certificates(data, 0x800, 0x40, len(data), [], []) + assert len(out) == 1 + assert out[0]["errors"] == ["length_too_small"] + assert out[0]["data_length"] == 0 + + def test_zero_length_does_not_hang(self): + """ + dwLength == 0 is the classic infinite-loop shape: advance by zero, + read the same header forever. The `< header size` break is what + prevents it. + """ + data = bytes(0x800) + _header(0) * 4 + bytes(0x40) + out = _read_certificates(data, 0x800, 0x60, len(data), [], []) + assert len(out) == 1 + + def test_minimum_valid_length_continues(self): + """Control: dwLength exactly 8 is valid and the walk proceeds.""" + data = bytes(0x800) + _header(8) + _entry(b"\xBB" * 4) + out = _read_certificates(data, 0x800, 0x20, len(data), [], []) + assert len(out) == 2 + assert out[0]["errors"] == [] + + +# ================================================================= +# PARSER - offset / file-size boundary +# ================================================================= + +class TestOffsetBoundary: + """ + The guard is `base_offset > file_size`, not `>=`. An offset exactly at + EOF is therefore not tagged past-EOF; the walk simply finds nothing. + """ + + def test_offset_past_eof_tagged(self): + errors: List[str] = [] + out = _read_certificates(bytes(0x100), 0x101, 0x40, 0x100, [], errors) + assert out == [] + assert errors == ["certificate_offset_past_eof"] + + def test_offset_exactly_at_eof_is_not_tagged(self): + """ + Boundary: offset == file_size yields an empty walk rather than a + past-EOF error. Pinned so a change from `>` to `>=` is deliberate. + """ + errors: List[str] = [] + truncations: List[str] = [] + out = _read_certificates(bytes(0x100), 0x100, 0x40, 0x100, + truncations, errors) + assert out == [] + assert errors == [] + assert truncations == ["certificate_table_truncated"] + + +# ================================================================= +# PARSER - blob clamp +# ================================================================= + +class TestBlobTruncationClamp: + + def test_data_length_clamped_to_available(self): + truncations: List[str] = [] + cert = _decode_certificate(0, 0x100, 0x0200, 0x0002, + entry_offset=0x800, dir_end=0x818, + truncations=truncations) + assert truncations == ["certificate_blob_truncated"] + assert cert["data_length"] == 0x818 - (0x800 + _WIN_CERT_HEADER_SIZE) + + def test_negative_available_clamps_to_zero(self): + """ + When dir_end falls before the end of the header itself, `available` + is negative and must clamp to 0 rather than producing a negative + data_length in output. + """ + truncations: List[str] = [] + cert = _decode_certificate(0, 0x100, 0x0200, 0x0002, + entry_offset=0x800, dir_end=0x804, + truncations=truncations) + assert cert["data_length"] == 0 + assert truncations == ["certificate_blob_truncated"] + + +# ================================================================= +# PARSER - overlaps_image semantics +# ================================================================= + +class TestOverlapsImageSemantics: + """ + overlaps_image is False both when the table genuinely sits after the + image AND when image_raw_end could not be computed. The validator's + `is True` test treats them alike, so False means "not known to overlap", + not "verified outside the image". + """ + + def test_false_when_genuinely_outside(self): + cert = _entry(b"\x00" * 8) + pe = _FakePE(bytes(0x800) + cert, _DataDir(0x800, len(cert)), + sections=[_Section(0x200, 0x600)]) # raw end 0x800 + out = build_certificate_structure(pe) + assert out["image_raw_end"] == 0x800 + assert out["overlaps_image"] is False + + def test_false_when_image_raw_end_unknown(self): + """ + Same value, different meaning: no sections means the invariant could + not be evaluated. Pinned so the ambiguity is a recorded decision + rather than an accident. + """ + cert = _entry(b"\x00" * 8) + pe = _FakePE(bytes(0x800) + cert, _DataDir(0x800, len(cert)), + sections=[]) + out = build_certificate_structure(pe) + assert out["image_raw_end"] is None + assert out["overlaps_image"] is False + + def test_true_when_before_image_end(self): + cert = _entry(b"\x00" * 8) + data = bytearray(0x1000) + data[0x400:0x400 + len(cert)] = cert + pe = _FakePE(bytes(data), _DataDir(0x400, len(cert)), + sections=[_Section(0x200, 0x600)]) + out = build_certificate_structure(pe) + assert out["overlaps_image"] is True + + +# ================================================================= +# VALIDATOR - field value sets +# ================================================================= + +def _cert(offset=0x800, length=0x40, revision=0x0200, cert_type=0x0002): + return {"offset": offset, "length": length, "revision": revision, + "cert_type": cert_type, "errors": []} + + +def _struct(certs, **kw): + d = {"offset": 0x800, "size": 0x200, "file_size": None, + "image_raw_end": None, "overlaps_image": False, + "certificates": certs, "certificate_count": len(certs), + "truncations": [], "errors": []} + d.update(kw) + return d + + +def _run(cert_struct, has_signature=True, analysis=None): + return validate_signature({"certificate_struct": cert_struct}, + {"has_signature": has_signature}, + analysis if analysis is not None else {}) + + +def _codes(issues): + return [i["issue"] for i in issues] + + +class TestFieldValueSets: + """ + The parser and validator use DIFFERENT accepted-value sets for + cert_type, deliberately: the parser NAMES 0x0003/0x0004 (so output shows + RESERVED_1 / TS_STACK_SIGNED) while the validator REJECTS them, because + Authenticode uses only X509 and PKCS_SIGNED_DATA in practice. + + A future edit aligning the two sets would silently stop flagging + reserved types, so both halves are pinned here. + """ + + @pytest.mark.parametrize("revision", [0x0100, 0x0200]) + def test_both_valid_revisions_accepted(self, revision): + issues = _run(_struct([_cert(revision=revision)]), + analysis={"file_size": 0x10000}) + assert ReasonCodes.SIGNATURE_INVALID_REVISION not in _codes(issues) + + @pytest.mark.parametrize("cert_type", [0x0001, 0x0002]) + def test_both_valid_cert_types_accepted(self, cert_type): + issues = _run(_struct([_cert(cert_type=cert_type)]), + analysis={"file_size": 0x10000}) + assert ReasonCodes.SIGNATURE_INVALID_TYPE not in _codes(issues) + + @pytest.mark.parametrize("cert_type", [0x0003, 0x0004]) + def test_parser_named_but_validator_rejected_types(self, cert_type): + """ + 0x0003 and 0x0004 appear in the parser's _CERT_TYPE_NAMES but are + not accepted by the validator. The divergence is intentional. + """ + issues = _run(_struct([_cert(cert_type=cert_type)]), + analysis={"file_size": 0x10000}) + assert ReasonCodes.SIGNATURE_INVALID_TYPE in _codes(issues) + + +# ================================================================= +# VALIDATOR - suppression and break behaviour +# ================================================================= + +class TestSuppressionBehaviour: + """ + Two deliberate narrowings that read as under-reporting. Both are pinned + so a change is a decision rather than a drift. + """ + + def test_out_of_bounds_suppresses_overlay_and_section_checks(self): + """ + The bounds check `continue`s, so a certificate that is out of bounds + AND overlaps both the overlay and a section reports once. + """ + issues = _run( + _struct([_cert(offset=900, length=200)]), + analysis={"file_size": 1000, "overlay_offset": 950, + "sections": [{"name": ".text", "raw_address": 900, + "raw_size": 100}]}) + assert _codes(issues) == [ReasonCodes.SIGNATURE_OUT_OF_FILE_BOUNDS] + + def test_section_overlap_reports_only_the_first_section(self): + """The section loop breaks on the first hit, so a certificate + spanning several sections names one.""" + issues = _run( + _struct([_cert(offset=100, length=200)]), + analysis={"file_size": 1000, + "sections": [{"name": ".a", "raw_address": 150, "raw_size": 50}, + {"name": ".b", "raw_address": 250, "raw_size": 50}]}) + overlaps = [i["details"]["section"] for i in issues + if i["issue"] == ReasonCodes.SIGNATURE_OVERLAPS_OTHER_DATA] + assert overlaps == [".a"] + + def test_overlay_and_section_can_both_fire(self): + """ + Control for the first test: without the bounds failure, the overlay + and section checks are independent and both report. + """ + issues = _run( + _struct([_cert(offset=100, length=200)]), + analysis={"file_size": 1000, "overlay_offset": 150, + "sections": [{"name": ".a", "raw_address": 150, + "raw_size": 50}]}) + assert _codes(issues).count( + ReasonCodes.SIGNATURE_OVERLAPS_OTHER_DATA) == 2 + + +class TestOverlayBoundary: + """`offset < overlay < offset + length` excludes both endpoints.""" + + @pytest.mark.parametrize("overlay,fires", [ + (100, False), # == offset + (101, True), # just inside + (299, True), # just inside + (300, False), # == offset + length + ]) + def test_endpoints_excluded(self, overlay, fires): + issues = _run(_struct([_cert(offset=100, length=200)]), + analysis={"file_size": 1000, "overlay_offset": overlay}) + assert (ReasonCodes.SIGNATURE_OVERLAPS_OTHER_DATA in _codes(issues)) is fires + + +class TestFileSizeFallback: + """ + file_size prefers the analysis layer and falls back to the parser's + value. The fallback branch is otherwise unexercised. + """ + + def test_falls_back_to_parser_file_size(self): + issues = _run(_struct([_cert(offset=900, length=200)], file_size=1000), + analysis={}) + assert ReasonCodes.SIGNATURE_OUT_OF_FILE_BOUNDS in _codes(issues) + + def test_analysis_value_takes_precedence(self): + """Parser says 1000 (would fail), analysis says 2000 (passes).""" + issues = _run(_struct([_cert(offset=900, length=200)], file_size=1000), + analysis={"file_size": 2000}) + assert ReasonCodes.SIGNATURE_OUT_OF_FILE_BOUNDS not in _codes(issues) + + def test_no_file_size_anywhere_skips_bounds_check(self): + issues = _run(_struct([_cert(offset=900, length=200)]), analysis={}) + assert ReasonCodes.SIGNATURE_OUT_OF_FILE_BOUNDS not in _codes(issues) From 696963fbaab1db55405cf317c7c2a5295051bd6e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 09:35:13 +0100 Subject: [PATCH 14/40] Add exception directory pair to tag contract test. Resulted in removing entry_truncated and entry_read_failed from the validator priority list as these are already covered --- docs/specs/reason-codes.md | 2 - iocx/validators/exception_table.py | 2 - tests/contract/tag_contract.py | 162 +++++++++++++++++++--------- tests/contract/test_tag_contract.py | 5 +- 4 files changed, 117 insertions(+), 54 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index bf5aa61..513edcc 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -846,8 +846,6 @@ Priority‑resolved; the first matching tag wins: | Sub‑reason | Meaning | |------------|---------| -| entry_truncated | The entry's fixed-size structure was short | -| entry_read_failed | pe.get_data raised when reading the entry | | entry_unpack_failed | struct.unpack failed on the entry bytes | | begin_rva_zero | BeginAddress was zero | | end_rva_zero | EndAddress was zero (amd64) | diff --git a/iocx/validators/exception_table.py b/iocx/validators/exception_table.py index deac360..f3d3550 100644 --- a/iocx/validators/exception_table.py +++ b/iocx/validators/exception_table.py @@ -127,8 +127,6 @@ # Priority-resolved sub-reasons for per-entry table pathologies. # First-matching wins for deterministic emission. _ENTRY_ERROR_PRIORITY = [ - "entry_truncated", - "entry_read_failed", "entry_unpack_failed", "begin_rva_zero", "end_rva_zero", diff --git a/tests/contract/tag_contract.py b/tests/contract/tag_contract.py index c4ef2cb..492ab89 100644 --- a/tests/contract/tag_contract.py +++ b/tests/contract/tag_contract.py @@ -118,6 +118,31 @@ def report(self) -> str: return "\n".join(lines) +def _is_entry_point(fn: ast.AST) -> bool: + """ + The function that builds the top-level struct. Every parser follows the + build__structure convention, and that function owns the + struct's own errors/truncations lists - which it creates as locals + rather than receiving as parameters. + """ + return (isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + and fn.name.startswith("build_") + and fn.name.endswith("_structure")) + + +def _is_tag_collection(name: str) -> bool: + """ + A module-level collection holds tombstone tags only if its name says so. + Structurally, a tag priority list and an architecture set are identical, + so the distinction has to come from the naming convention: + *_ERROR_*, *_TAGS or *_PRIORITY. + """ + upper = name.upper() + return ("ERROR" in upper + or upper.endswith("_TAGS") + or upper.endswith("_PRIORITY")) + + # ================================================================= # Parser side # ================================================================= @@ -182,66 +207,88 @@ def extract_parser_tags( source: str, template_vars: Optional[Dict[str, List[str]]] = None, ) -> ParserTags: + """ + Extract every tombstone tag a parser can emit. + + Five shapes are recognised, all of which occur in this codebase: + 1. sink.append("tag") + 2. sink.append(f"{var}_suffix") - expanded via template_vars + 3. return <...>, "tag" - helper readers whose tag the caller + appends verbatim + 4. {"errors": ["tag", ...]} - literal list in a returned dict + 5. f(errors=["tag"]) - kwarg list literal passed to a + result-builder helper + + Sinks are classified top-level vs per-item by SCOPE: a bare-name append + is top-level when the name is a function parameter (a caller-owned list) + or when it occurs inside the module entry point, which creates the + struct's own lists as locals. Anything else - a local elsewhere, a + subscript, or a differently-named parameter such as descriptor_errors - + is per-item. + """ template_vars = {**_DEFAULT_TEMPLATE_VARS, **(template_vars or {})} tree = ast.parse(source) out = ParserTags() scopes = _function_scopes(tree) - def _is_entry_point(fn: ast.AST) -> bool: - """ - The function that builds the top-level struct. Every parser follows the - build__structure convention, and that function owns the - struct's own `errors`/`truncations` lists - which it creates as locals - rather than receiving as parameters. - """ - return (isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) - and fn.name.startswith("build_") - and fn.name.endswith("_structure")) + def _bucket_for(sink: str, is_top: bool) -> Set: + if sink in _TRUNCATION_SINKS: + return out.top_truncations + return out.top_errors if is_top else out.item_errors + + def _add(bucket: Set[str], arg: ast.AST) -> None: + if isinstance(arg, ast.Constant) and isinstance(arg.value, str): + bucket.add(arg.value) + elif isinstance(arg, ast.JoinedStr): + expansions, unexpanded = _expand_fstring(arg, template_vars) + if unexpanded is not None: + out.unexpanded.add(unexpanded) + else: + bucket.update(expansions) + # --- Shapes 1, 2 and 5: scope-sensitive, so walked per function --- for fn, params in scopes.items(): - entry = _is_entry_point(fn) + entry_point = _is_entry_point(fn) for node in ast.walk(fn): + # 1 & 2 - direct appends ref = _sink_ref(node) - if ref is None or not node.args: - continue - sink, is_bare = ref - # Top-level only when appending to a caller-owned list: a bare - # name that is a parameter AND uses the canonical sink name. - # `descriptor_errors` is a parameter too, but its distinct name - # keeps it per-item. - is_top = is_bare and (sink in params or entry) - if sink in _TRUNCATION_SINKS: - bucket = out.top_truncations - elif is_top: - bucket = out.top_errors - else: - bucket = out.item_errors - arg = node.args[0] - if isinstance(arg, ast.Constant) and isinstance(arg.value, str): - bucket.add(arg.value) - elif isinstance(arg, ast.JoinedStr): - expansions, unexpanded = _expand_fstring(arg, template_vars) - if unexpanded is not None: - out.unexpanded.add(unexpanded) - else: - bucket.update(expansions) - + if ref is not None and node.args: + sink, is_bare = ref + is_top = is_bare and (sink in params or entry_point) + _add(_bucket_for(sink, is_top), node.args[0]) + + # 5 - kwarg list literal passed to a result-builder helper. + # The SAME entry-point rule applies as for bare-name appends: + # _empty_result(errors=[...]) inside build_*_structure fills the + # top-level struct, while _unwind_result(errors=[...]) in a + # decode helper fills a per-entry record. Identical syntax, + # opposite level. + if isinstance(node, ast.Call): + for kw in node.keywords: + if kw.arg in _ALL_SINKS and isinstance(kw.value, ast.List): + # A result-builder helper returns a per-item record. + bucket = _bucket_for(kw.arg, is_top=entry_point) + for el in kw.value.elts: + _add(bucket, el) + + # --- Shapes 3 and 4: scope-independent --- for node in ast.walk(tree): - + # 3 - helper returns whose last element is an error tag if isinstance(node, ast.Return) and isinstance(node.value, ast.Tuple): last = node.value.elts[-1] if isinstance(last, ast.Constant) and isinstance(last.value, str): - # A helper's error tag is appended by its caller, which is - # always a per-item context in this codebase. + # A helper's tag is appended by its caller, always per-item + # in this codebase. out.item_errors.add(last.value) + # 4 - literal tag lists inside a returned dict if isinstance(node, ast.Dict): for k, v in zip(node.keys, node.values): if (isinstance(k, ast.Constant) and k.value in _ALL_SINKS and isinstance(v, ast.List)): bucket = (out.top_truncations - if k.value in _TRUNCATION_SINKS - else out.item_errors) + if k.value in _TRUNCATION_SINKS + else out.item_errors) for el in v.elts: if isinstance(el, ast.Constant) and isinstance(el.value, str): bucket.add(el.value) @@ -295,27 +342,46 @@ def _forwarded_sinks_in_dict(node: ast.Dict) -> Set[str]: def extract_validator_consumption(source: str) -> ValidatorConsumption: + """ + Extract how a validator consumes tags. + + Priority-matched: module-level collections of string literals whose NAME + marks them as tag lists, plus explicit "tag" in <errors> tests. + + Wholesale: a sink forwarded in full, either by iterating it + (for tag in x["truncations"]: emit(... tag ...)) or by copying it into + a details payload (details={"errors": list(imp["errors"])}). Neither + can drop a tag, so neither needs a membership check. + """ tree = ast.parse(source) out = ValidatorConsumption() for node in ast.walk(tree): + # Priority lists - name-filtered. Without this, any module-level + # tuple of strings is read as a tag list: _TABLE_ARCHS = + # ("amd64", "arm64", "arm") produced three spurious phantoms. if isinstance(node, ast.Assign) and isinstance( - node.value, (ast.List, ast.Set, ast.Tuple)): - for el in node.value.elts: - if isinstance(el, ast.Constant) and isinstance(el.value, str): - out.matched.add(el.value) - + node.value, (ast.List, ast.Set, ast.Tuple)): + names = [t.id for t in node.targets if isinstance(t, ast.Name)] + if any(_is_tag_collection(n) for n in names): + for el in node.value.elts: + if isinstance(el, ast.Constant) and isinstance(el.value, str): + out.matched.add(el.value) + + # Explicit membership test: "tag" in entry_errors if (isinstance(node, ast.Compare) and len(node.ops) == 1 - and isinstance(node.ops[0], ast.In) - and isinstance(node.left, ast.Constant) - and isinstance(node.left.value, str)): + and isinstance(node.ops[0], ast.In) + and isinstance(node.left, ast.Constant) + and isinstance(node.left.value, str)): out.matched.add(node.left.value) + # Wholesale by iteration if isinstance(node, ast.For): sink = _iterated_sink(node) if sink is not None and _loop_var_reaches_emission(node): out.iterated_sinks.add(sink) + # Wholesale by forwarding into a details payload if isinstance(node, ast.Dict): out.forwarded_sinks |= _forwarded_sinks_in_dict(node) diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index bc6900d..5220ad0 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -21,9 +21,9 @@ from tag_contract import check_contract from iocx.parsers import (pe_imports, pe_relocations, pe_tls, pe_debug, - pe_exports, pe_delay_imports, pe_certificates) + pe_exports, pe_delay_imports, pe_certificates, pe_exception) from iocx.validators import (imports, relocations, tls, debug, - exports, delay_imports, signature) + exports, delay_imports, signature, exception_table) _PAIRS = [ # (label, parser module, validator module, template_vars) @@ -42,6 +42,7 @@ ("pe_exports", pe_exports, exports, {"tag": ["eat", "enpt", "eot"]}), ("pe_delay_imports", pe_delay_imports, delay_imports, {"tag": ["int", "iat"]}), ("pe_certificates", pe_certificates, signature, {}), + ("pe_exception", pe_exception, exception_table, {}), ] # Tags a parser emits that no validator consumes, deliberately. From 5abf4a635a62904750a2126f06bb2f9bb3194364 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 09:50:42 +0100 Subject: [PATCH 15/40] Fix tag contract script, and write tests for it --- tests/contract/tag_contract.py | 238 ++------ tests/contract/test_tag_self.py | 519 ++++++++++++++++++ .../test_validator_exception_dir.py | 10 +- 3 files changed, 563 insertions(+), 204 deletions(-) create mode 100644 tests/contract/test_tag_self.py diff --git a/tests/contract/tag_contract.py b/tests/contract/tag_contract.py index 492ab89..ef9ebd4 100644 --- a/tests/contract/tag_contract.py +++ b/tests/contract/tag_contract.py @@ -1,41 +1,6 @@ # Copyright (c) 2026 MalX Labs and contributors # SPDX-License-Identifier: MPL-2.0 - -""" -Static verification of the parser -> validator tag contract. - -Parsers record structural faults as tombstone tags in `errors` and -`truncations` lists. Validators consume those tags and map them to reason -codes. A tag with no consumer is SILENTLY DROPPED: `_first_matching` returns -"unknown" and the caller continues, so the finding never reaches output. - -WHY AST AND NOT REGEX -Regex extraction misses two shapes this codebase uses: a tag returned on a -UnicodeDecodeError branch where the literal sits on a different line from the -`return`, and f-string templates such as f"{tag}_truncated" which expand to -several concrete tags depending on the caller. - -THREE CONSUMPTION PATTERNS -Conflating these produces false positives, so each is detected separately: - - * ITERATED WHOLESALE - for tag in x["truncations"]: - emit(details={"table": tag}) - Every tag becomes its own issue. - - * FORWARDED WHOLESALE - emit(details={"sub_reason": "top_level_decode", - "errors": list(imp["errors"])}) - The whole list is copied into one issue's details. No tag is lost, but - there is no loop to detect - this shape was missed by an earlier version - of this module and produced a false positive on `descriptor_unpack_failed`. - - * PRIORITY-MATCHED - _first_matching(errors, _SOME_PRIORITY_LIST) - Only listed tags are emitted. This is the sink that can drop. - -Only the third needs a membership check. -""" +"""Static verification of the parser -> validator tag contract.""" from __future__ import annotations @@ -43,24 +8,16 @@ from dataclasses import dataclass, field from typing import Dict, List, Optional, Set, Tuple - _ERROR_SINKS = {"errors", "entry_errors", "descriptor_errors", "block_errors"} _TRUNCATION_SINKS = {"truncations"} _ALL_SINKS = _ERROR_SINKS | _TRUNCATION_SINKS - _DEFAULT_TEMPLATE_VARS: Dict[str, List[str]] = {} @dataclass class ParserTags: - # Tags written to the caller-owned lists returned at the top level of - # the struct. Identified by an append to a bare name that is a PARAMETER - # of the enclosing function and named exactly "errors"/"truncations". top_errors: Set[str] = field(default_factory=set) top_truncations: Set[str] = field(default_factory=set) - # Tags written to per-descriptor / per-entry lists: appends to a local, - # to a subscript, or to a differently-named parameter such as - # `descriptor_errors`. item_errors: Set[str] = field(default_factory=set) unexpanded: Set[str] = field(default_factory=set) @@ -119,41 +76,18 @@ def report(self) -> str: def _is_entry_point(fn: ast.AST) -> bool: - """ - The function that builds the top-level struct. Every parser follows the - build__structure convention, and that function owns the - struct's own errors/truncations lists - which it creates as locals - rather than receiving as parameters. - """ - return (isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) + return (isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)) and fn.name.startswith("build_") and fn.name.endswith("_structure")) def _is_tag_collection(name: str) -> bool: - """ - A module-level collection holds tombstone tags only if its name says so. - Structurally, a tag priority list and an architecture set are identical, - so the distinction has to come from the naming convention: - *_ERROR_*, *_TAGS or *_PRIORITY. - """ - upper = name.upper() - return ("ERROR" in upper - or upper.endswith("_TAGS") + upper = name.upper() + return ("ERROR" in upper or upper.endswith("_TAGS") or upper.endswith("_PRIORITY")) -# ================================================================= -# Parser side -# ================================================================= - def _sink_ref(node: ast.AST) -> Optional[Tuple[str, bool]]: - """ - Return (sink_name, is_bare_name) for an X.append(...) call, else None. - - is_bare_name distinguishes `errors.append(...)` from - `descriptor["errors"].append(...)`, which the scope rule needs. - """ if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "append"): @@ -168,17 +102,13 @@ def _sink_ref(node: ast.AST) -> Optional[Tuple[str, bool]]: def _function_scopes(tree: ast.AST) -> Dict[ast.AST, Set[str]]: - """Map each function node to the set of its parameter names.""" - scopes: Dict[ast.AST, Set[str]] = {} - for fn in ast.walk(tree): - if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): - scopes[fn] = {a.arg for a in fn.args.args} - return scopes + return {fn: {a.arg for a in fn.args.args} + for fn in ast.walk(tree) + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef))} -def _expand_fstring(node: ast.JoinedStr, - template_vars: Dict[str, List[str]]) -> Tuple[List[str], Optional[str]]: - var: Optional[str] = None +def _expand_fstring(node, template_vars): + var = None prefix = suffix = "" seen_var = False has_placeholder = False @@ -196,47 +126,24 @@ def _expand_fstring(node: ast.JoinedStr, else: prefix += str(part.value) if not has_placeholder: - # An f-string with nothing to interpolate is a literal in disguise. return [prefix], None if var is None or var not in template_vars: return [], ast.unparse(node) return [f"{prefix}{v}{suffix}" for v in template_vars[var]], None -def extract_parser_tags( - source: str, - template_vars: Optional[Dict[str, List[str]]] = None, -) -> ParserTags: - """ - Extract every tombstone tag a parser can emit. - - Five shapes are recognised, all of which occur in this codebase: - 1. sink.append("tag") - 2. sink.append(f"{var}_suffix") - expanded via template_vars - 3. return <...>, "tag" - helper readers whose tag the caller - appends verbatim - 4. {"errors": ["tag", ...]} - literal list in a returned dict - 5. f(errors=["tag"]) - kwarg list literal passed to a - result-builder helper - - Sinks are classified top-level vs per-item by SCOPE: a bare-name append - is top-level when the name is a function parameter (a caller-owned list) - or when it occurs inside the module entry point, which creates the - struct's own lists as locals. Anything else - a local elsewhere, a - subscript, or a differently-named parameter such as descriptor_errors - - is per-item. - """ +def extract_parser_tags(source, template_vars=None) -> ParserTags: template_vars = {**_DEFAULT_TEMPLATE_VARS, **(template_vars or {})} tree = ast.parse(source) out = ParserTags() scopes = _function_scopes(tree) - def _bucket_for(sink: str, is_top: bool) -> Set: + def _bucket_for(sink, is_top): if sink in _TRUNCATION_SINKS: return out.top_truncations return out.top_errors if is_top else out.item_errors - def _add(bucket: Set[str], arg: ast.AST) -> None: + def _add(bucket, arg): if isinstance(arg, ast.Constant) and isinstance(arg.value, str): bucket.add(arg.value) elif isinstance(arg, ast.JoinedStr): @@ -246,68 +153,53 @@ def _add(bucket: Set[str], arg: ast.AST) -> None: else: bucket.update(expansions) - # --- Shapes 1, 2 and 5: scope-sensitive, so walked per function --- for fn, params in scopes.items(): entry_point = _is_entry_point(fn) for node in ast.walk(fn): - # 1 & 2 - direct appends ref = _sink_ref(node) if ref is not None and node.args: sink, is_bare = ref - is_top = is_bare and (sink in params or entry_point) + # Only the CANONICAL sink names can hold the struct's own + # lists. A parameter called descriptor_errors / entry_errors + # / block_errors is a per-item list passed down by its + # owner, so it stays checkable however it is reached. + canonical = sink in ("errors", "truncations") + is_top = (is_bare and canonical + and (sink in params or entry_point)) _add(_bucket_for(sink, is_top), node.args[0]) - - # 5 - kwarg list literal passed to a result-builder helper. - # The SAME entry-point rule applies as for bare-name appends: - # _empty_result(errors=[...]) inside build_*_structure fills the - # top-level struct, while _unwind_result(errors=[...]) in a - # decode helper fills a per-entry record. Identical syntax, - # opposite level. if isinstance(node, ast.Call): for kw in node.keywords: if kw.arg in _ALL_SINKS and isinstance(kw.value, ast.List): - # A result-builder helper returns a per-item record. bucket = _bucket_for(kw.arg, is_top=entry_point) for el in kw.value.elts: _add(bucket, el) - # --- Shapes 3 and 4: scope-independent --- for node in ast.walk(tree): - # 3 - helper returns whose last element is an error tag if isinstance(node, ast.Return) and isinstance(node.value, ast.Tuple): last = node.value.elts[-1] if isinstance(last, ast.Constant) and isinstance(last.value, str): - # A helper's tag is appended by its caller, always per-item - # in this codebase. out.item_errors.add(last.value) - - # 4 - literal tag lists inside a returned dict if isinstance(node, ast.Dict): for k, v in zip(node.keys, node.values): if (isinstance(k, ast.Constant) and k.value in _ALL_SINKS and isinstance(v, ast.List)): bucket = (out.top_truncations - if k.value in _TRUNCATION_SINKS - else out.item_errors) + if k.value in _TRUNCATION_SINKS + else out.item_errors) for el in v.elts: if isinstance(el, ast.Constant) and isinstance(el.value, str): bucket.add(el.value) - return out -# ================================================================= -# Validator side -# ================================================================= - -def _iterated_sink(node: ast.For) -> Optional[str]: +def _iterated_sink(node): for sub in ast.walk(node.iter): if isinstance(sub, ast.Constant) and sub.value in _ALL_SINKS: return sub.value return None -def _loop_var_reaches_emission(node: ast.For) -> bool: +def _loop_var_reaches_emission(node): if not isinstance(node.target, ast.Name): return False var = node.target.id @@ -319,114 +211,62 @@ def _loop_var_reaches_emission(node: ast.For) -> bool: return False -def _forwarded_sinks_in_dict(node: ast.Dict) -> Set[str]: - """ - Detect a details payload that copies a whole sink list verbatim, e.g. - - details={"errors": list(imp["errors"])} - - A LITERAL list value is a fixed payload, not forwarding, and is - excluded. Any other expression that reads a sink name is treated as - forwarding that sink. - """ - found: Set[str] = set() +def _forwarded_sinks_in_dict(node): + found = set() for k, v in zip(node.keys, node.values): if not (isinstance(k, ast.Constant) and isinstance(k.value, str)): continue if isinstance(v, ast.List): - continue # literal payload, not a forward + continue for sub in ast.walk(v): if isinstance(sub, ast.Constant) and sub.value in _ALL_SINKS: found.add(sub.value) return found -def extract_validator_consumption(source: str) -> ValidatorConsumption: - """ - Extract how a validator consumes tags. - - Priority-matched: module-level collections of string literals whose NAME - marks them as tag lists, plus explicit "tag" in <errors> tests. - - Wholesale: a sink forwarded in full, either by iterating it - (for tag in x["truncations"]: emit(... tag ...)) or by copying it into - a details payload (details={"errors": list(imp["errors"])}). Neither - can drop a tag, so neither needs a membership check. - """ +def extract_validator_consumption(source) -> ValidatorConsumption: tree = ast.parse(source) out = ValidatorConsumption() - for node in ast.walk(tree): - # Priority lists - name-filtered. Without this, any module-level - # tuple of strings is read as a tag list: _TABLE_ARCHS = - # ("amd64", "arm64", "arm") produced three spurious phantoms. if isinstance(node, ast.Assign) and isinstance( - node.value, (ast.List, ast.Set, ast.Tuple)): + node.value, (ast.List, ast.Set, ast.Tuple)): names = [t.id for t in node.targets if isinstance(t, ast.Name)] if any(_is_tag_collection(n) for n in names): for el in node.value.elts: if isinstance(el, ast.Constant) and isinstance(el.value, str): out.matched.add(el.value) - - # Explicit membership test: "tag" in entry_errors if (isinstance(node, ast.Compare) and len(node.ops) == 1 - and isinstance(node.ops[0], ast.In) - and isinstance(node.left, ast.Constant) - and isinstance(node.left.value, str)): + and isinstance(node.ops[0], ast.In) + and isinstance(node.left, ast.Constant) + and isinstance(node.left.value, str)): out.matched.add(node.left.value) - - # Wholesale by iteration if isinstance(node, ast.For): sink = _iterated_sink(node) if sink is not None and _loop_var_reaches_emission(node): out.iterated_sinks.add(sink) - - # Wholesale by forwarding into a details payload if isinstance(node, ast.Dict): out.forwarded_sinks |= _forwarded_sinks_in_dict(node) - return out -# ================================================================= -# Comparison -# ================================================================= - -def check_contract( - parser_source: str, - validator_source: str, - parser_name: str = "parser", - validator_name: str = "validator", - template_vars: Optional[Dict[str, List[str]]] = None, -) -> ContractResult: +def check_contract(parser_source, validator_source, parser_name="parser", + validator_name="validator", template_vars=None) -> ContractResult: tags = extract_parser_tags(parser_source, template_vars) consumption = extract_validator_consumption(validator_source) - - # Wholesale consumption exempts ONLY the level it actually forwards. - # The validator's `details={"errors": list(imp["errors"])}` copies the - # TOP-LEVEL list; per-descriptor and per-entry errors remain - # priority-matched and can still drop. - unchecked: Set[str] = set() + unchecked = set() if _TRUNCATION_SINKS & consumption.wholesale_sinks: unchecked |= tags.top_truncations if _ERROR_SINKS & consumption.forwarded_sinks: unchecked |= tags.top_errors if _ERROR_SINKS & consumption.iterated_sinks: unchecked |= tags.errors - checkable = (tags.errors | tags.truncations) - unchecked - dropped = checkable - consumption.matched - phantom = consumption.matched - tags.errors - tags.truncations - return ContractResult( - parser=parser_name, - validator=validator_name, - emitted_errors=tags.errors, - emitted_truncations=tags.truncations, + parser=parser_name, validator=validator_name, + emitted_errors=tags.errors, emitted_truncations=tags.truncations, matched=consumption.matched, iterated_sinks=consumption.iterated_sinks, forwarded_sinks=consumption.forwarded_sinks, - dropped=dropped, - phantom=phantom, - unexpanded=tags.unexpanded, - ) + dropped=checkable - consumption.matched, + phantom=consumption.matched - tags.errors - tags.truncations, + unexpanded=tags.unexpanded) diff --git a/tests/contract/test_tag_self.py b/tests/contract/test_tag_self.py new file mode 100644 index 0000000..7fe8ef5 --- /dev/null +++ b/tests/contract/test_tag_self.py @@ -0,0 +1,519 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Self-tests for the tag-contract checker. + +The checker is static analysis over parser/validator source, so every code +shape it must recognise is a separate case - and every shape it misread in +the past is a regression guard. Eight defects were found by feeding it real +parsers one at a time; each cost a review cycle to diagnose. These fixtures +turn that into a unit-test failure. + +The fixtures are DELIBERATELY MINIMAL rather than excerpts of real parsers: +each isolates one syntactic shape, so a failure names the shape directly. + +Naming conventions the checker depends on, and which it CANNOT verify: + * a parser's module entry point is `build__structure` + * a validator's tag list is named `*_ERROR_*`, `*_TAGS` or `*_PRIORITY` + * a per-item error list passed as a parameter is NOT named plain `errors` +Violating any of these produces a wrong verdict, not an exception, so +TestNamingConventionDependencies documents them explicitly. +""" + +from __future__ import annotations + +import pytest + +from tag_contract import ( + check_contract, + extract_parser_tags, + extract_validator_consumption, + _is_entry_point, + _is_tag_collection, +) + + +# ================================================================= +# Shape 1 - direct appends +# ================================================================= + +@pytest.mark.contract +class TestDirectAppends: + + def test_bare_name_parameter_is_top_level(self): + src = ''' +def _walk(pe, truncations, errors): + errors.append("top_tag") + truncations.append("trunc_tag") +''' + tags = extract_parser_tags(src) + assert tags.top_errors == {"top_tag"} + assert tags.top_truncations == {"trunc_tag"} + assert tags.item_errors == set() + + def test_bare_name_local_outside_entry_point_is_per_item(self): + src = ''' +def _decode_entry(pe, value): + errors = [] + errors.append("item_tag") + return {"errors": errors} +''' + tags = extract_parser_tags(src) + assert tags.item_errors == {"item_tag"} + assert tags.top_errors == set() + + def test_bare_name_local_inside_entry_point_is_top_level(self): + """ + pe_certificates creates its own lists as locals in the entry point + and returns them as the struct's top-level errors. Same syntax as + the per-item case above, opposite level. + """ + src = ''' +def build_certificate_structure(pe): + errors = [] + errors.append("raw_file_unavailable") + return {"errors": errors} +''' + tags = extract_parser_tags(src) + assert tags.top_errors == {"raw_file_unavailable"} + assert tags.item_errors == set() + + def test_subscript_append_is_per_item(self): + src = ''' +def _decode(pe, descriptor): + descriptor["errors"].append("dll_name_empty") +''' + tags = extract_parser_tags(src) + assert tags.item_errors == {"dll_name_empty"} + + def test_differently_named_parameter_is_per_item(self): + """ + `descriptor_errors` is a parameter but not the canonical sink name, + so it holds a per-descriptor list rather than the struct's own. + """ + src = ''' +def _read_thunks(pe, rva, descriptor_errors, truncations): + descriptor_errors.append("int_rva_zero") +''' + tags = extract_parser_tags(src) + assert tags.item_errors == {"int_rva_zero"} + assert tags.top_errors == set() + + +# ================================================================= +# Shape 2 - f-string templates +# ================================================================= + +@pytest.mark.contract +class TestFStringTemplates: + + def test_template_expands_against_declared_values(self): + src = ''' +def _read(pe, rva, tag, truncations): + truncations.append(f"{tag}_truncated") +''' + tags = extract_parser_tags(src, {"tag": ["int", "iat"]}) + assert tags.top_truncations == {"int_truncated", "iat_truncated"} + assert tags.unexpanded == set() + + def test_template_without_values_is_reported_unexpanded(self): + """Fail loudly rather than skip - a silent skip is how the original + drops stayed hidden.""" + src = ''' +def _read(pe, rva, tag, truncations): + truncations.append(f"{tag}_truncated") +''' + tags = extract_parser_tags(src, {}) + assert tags.top_truncations == set() + assert tags.unexpanded == {"f'{tag}_truncated'"} + + def test_fstring_with_no_placeholder_is_a_literal(self): + """ + `f"literal"` stays a JoinedStr in the AST. An earlier version + reported it as an unexpandable template. + """ + src = ''' +def _walk(pe, errors): + errors.append(f"block_header_unpack_failed") +''' + tags = extract_parser_tags(src) + assert tags.top_errors == {"block_header_unpack_failed"} + assert tags.unexpanded == set() + + def test_non_name_placeholder_is_unexpandable(self): + src = ''' +def _walk(pe, errors, index): + errors.append(f"failed_at_{index + 1}") +''' + tags = extract_parser_tags(src) + assert tags.unexpanded + assert tags.top_errors == set() + + +# ================================================================= +# Shape 3 - helper tuple returns +# ================================================================= + +@pytest.mark.contract +class TestHelperTupleReturns: + + def test_tag_in_last_tuple_position(self): + src = ''' +def _read_asciiz(pe, rva, max_len): + if rva == 0: + return None, "rva_zero" + return "name", None +''' + assert extract_parser_tags(src).item_errors == {"rva_zero"} + + def test_tag_on_a_separate_line_from_return(self): + """ + The UnicodeDecodeError shape: the literal sits on a different line + from the `return`, which defeated regex extraction. + """ + src = ''' +def _read_asciiz(pe, rva, max_len): + try: + return raw.decode("ascii"), None + except UnicodeDecodeError: + s = raw.decode("ascii", errors="replace") + return s, "non_ascii" +''' + assert "non_ascii" in extract_parser_tags(src).item_errors + + def test_three_element_tuple_return(self): + src = ''' +def _read_import_by_name(pe, rva): + return None, None, "name_too_short" +''' + assert extract_parser_tags(src).item_errors == {"name_too_short"} + + +# ================================================================= +# Shape 4 - literal lists in returned dicts +# ================================================================= + +@pytest.mark.contract +class TestDictLiteralLists: + + def test_literal_error_list_in_returned_dict(self): + src = ''' +def _decode_entry(buf, index): + return {"index": index, "errors": ["entry_unpack_failed"]} +''' + assert extract_parser_tags(src).item_errors == {"entry_unpack_failed"} + + def test_non_list_dict_values_do_not_raise(self): + """ + Regression: an over-broad `for el in v.elts` ran for every dict + value, raising AttributeError on Name and Constant nodes. + """ + src = ''' +def _decode(buf, index): + return {"index": index, "errors": errors, "name": None, + "count": 0, "nested": {"a": 1}} +''' + tags = extract_parser_tags(src) # must not raise + assert tags.item_errors == set() + + def test_dict_with_mixed_literal_and_variable_sinks(self): + src = ''' +def _decode(buf, index): + return {"errors": ["tag_a"], "truncations": truncations} +''' + tags = extract_parser_tags(src) + assert tags.item_errors == {"tag_a"} + + +# ================================================================= +# Shape 5 - kwarg list literals +# ================================================================= + +@pytest.mark.contract +class TestKwargListLiterals: + + def test_kwarg_in_entry_point_is_top_level(self): + """ + pe_exports: _empty_result builds the TOP-LEVEL struct, so a kwarg + passed to it from the entry point fills the struct's own errors. + """ + src = ''' +def build_export_structure(pe): + return _empty_result(rva, size, errors=["header_read_failed"]) + +def _empty_result(rva, size, *, errors=None, truncations=None): + return {"errors": errors or [], "truncations": truncations or []} +''' + tags = extract_parser_tags(src) + assert tags.top_errors == {"header_read_failed"} + assert tags.item_errors == set() + + def test_kwarg_outside_entry_point_is_per_item(self): + """ + pe_exception: _unwind_result builds a PER-ENTRY record. Identical + syntax to the case above, opposite level. + """ + src = ''' +def build_exception_structure(pe): + return {"functions": functions} + +def _decode_unwind_info(pe, rva): + return _unwind_result(errors=["unwind_read_failed"]) +''' + tags = extract_parser_tags(src) + assert tags.item_errors == {"unwind_read_failed"} + assert tags.top_errors == set() + + def test_kwarg_truncations_always_top_level(self): + src = ''' +def _walk(pe): + return _result(truncations=["table_truncated"]) +''' + assert extract_parser_tags(src).top_truncations == {"table_truncated"} + + +# ================================================================= +# Validator consumption +# ================================================================= + +@pytest.mark.contract +class TestValidatorConsumption: + + def test_priority_list_recognised_by_name(self): + src = '_ENTRY_ERROR_PRIORITY = ["a", "b"]' + assert extract_validator_consumption(src).matched == {"a", "b"} + + @pytest.mark.parametrize("name", [ + "_DLL_NAME_ERROR_PRIORITY", "_HEADER_DECODE_ERROR_TAGS", + "_UNWIND_ERROR_PRIORITY", "_STRUCTURAL_CERT_ERROR_TAGS", + ]) + def test_tag_collection_names_accepted(self, name): + assert _is_tag_collection(name) + + @pytest.mark.parametrize("name", [ + "_TABLE_ARCHS", "_VALID_UNWIND_VERSIONS", "_REVISION_NAMES", + ]) + def test_non_tag_collection_names_rejected(self, name): + """ + _TABLE_ARCHS = ("amd64","arm64","arm") is structurally identical to + a priority list and produced three spurious phantoms. + """ + assert not _is_tag_collection(name) + + def test_arch_tuple_is_not_read_as_tags(self): + src = ''' +_TABLE_ARCHS = ("amd64", "arm64", "arm") +_ENTRY_ERROR_PRIORITY = ["real_tag"] +''' + matched = extract_validator_consumption(src).matched + assert matched == {"real_tag"} + assert "amd64" not in matched + + def test_membership_test_counts_as_consumption(self): + src = ''' +def _v(entry_errors): + if "ordinal_index_duplicate" in entry_errors: + pass +''' + assert "ordinal_index_duplicate" in extract_validator_consumption(src).matched + + def test_iterated_wholesale_detected(self): + src = ''' +def _v(imp, issues): + for tag in imp.get("truncations", []) or []: + issues.append(StructuralIssue(details={"table": tag})) +''' + assert extract_validator_consumption(src).iterated_sinks == {"truncations"} + + def test_iteration_that_ignores_the_loop_var_is_not_wholesale(self): + """Counting a list is not forwarding it.""" + src = ''' +def _v(imp, issues): + count = 0 + for tag in imp.get("truncations", []) or []: + count += 1 +''' + assert extract_validator_consumption(src).iterated_sinks == set() + + def test_forwarded_wholesale_detected(self): + src = ''' +def _v(imp, issues): + issues.append(StructuralIssue( + details={"sub_reason": "top_level_decode", + "errors": list(imp["errors"])})) +''' + assert extract_validator_consumption(src).forwarded_sinks == {"errors"} + + def test_literal_details_list_is_not_forwarding(self): + """A fixed payload names no sink.""" + src = ''' +def _v(issues): + issues.append(StructuralIssue(details={"errors": ["fixed", "payload"]})) +''' + assert extract_validator_consumption(src).forwarded_sinks == set() + + +# ================================================================= +# End-to-end verdicts +# ================================================================= + +_PARSER = ''' +def build_thing_structure(pe): + errors = [] + truncations = [] + if pe is None: + return _empty_result(errors=["top_decode_failed"]) + errors.append("top_direct") + truncations.append("table_truncated") + return {"errors": errors, "truncations": truncations} + +def _read_asciiz(pe, rva): + return None, "read_failed" + +def _decode_item(pe, descriptor): + descriptor["errors"].append("item_tag") + +def _decode_entry(buf, index): + return {"index": index, "errors": ["entry_unpack_failed"]} +''' + +_VALIDATOR = ''' +_ITEM_ERROR_PRIORITY = ["item_tag", "read_failed", "entry_unpack_failed"] + +def validate_thing(internal): + st = internal.get("thing_struct") + if st.get("errors"): + issues.append(StructuralIssue( + details={"sub_reason": "top_level_decode", + "errors": list(st["errors"])})) + return issues + for tag in st.get("truncations", []) or []: + issues.append(StructuralIssue(details={"table": tag})) +''' + +@pytest.mark.contract +class TestEndToEnd: + + def test_healthy_pair_is_clean(self): + result = check_contract(_PARSER, _VALIDATOR) + assert result.dropped == set() + assert result.phantom == set() + assert result.unexpanded == set() + assert result.ok + + def test_dropped_item_tag_detected(self): + bad = _VALIDATOR.replace('"item_tag", ', '') + result = check_contract(_PARSER, bad) + assert result.dropped == {"item_tag"} + + def test_top_level_tags_exempt_via_forwarding(self): + """ + Both top-level tags are forwarded wholesale, so neither needs a + priority entry. Removing the forward must expose them. + """ + no_forward = _VALIDATOR.replace( + '"errors": list(st["errors"])', '"count": 1') + result = check_contract(_PARSER, no_forward) + assert {"top_direct", "top_decode_failed"} <= result.dropped + + def test_truncations_exempt_via_iteration(self): + result = check_contract(_PARSER, _VALIDATOR) + assert "table_truncated" not in result.dropped + + def test_phantom_detected(self): + bad = _VALIDATOR.replace( + '"entry_unpack_failed"]', '"entry_unpack_failed", "never_emitted"]') + assert check_contract(_PARSER, bad).phantom == {"never_emitted"} + + def test_report_is_readable(self): + text = check_contract(_PARSER, _VALIDATOR, "pe_thing", "thing").report() + assert "pe_thing -> thing" in text + assert "OK" in text + + +# ================================================================= +# Convention dependencies +# ================================================================= + +@pytest.mark.contract +class TestNamingConventionDependencies: + """ + The checker cannot verify these conventions; it silently misclassifies + when they are broken. Pinned so the dependency is explicit. + """ + + @pytest.mark.parametrize("name,expected", [ + ("build_import_structure", True), + ("build_certificate_structure", True), + ("_read_descriptors", False), + ("build_something_else", False), + ("make_import_structure", False), + ]) + def test_entry_point_recognition(self, name, expected): + import ast as _ast + fn = _ast.parse(f"def {name}(pe): pass").body[0] + assert _is_entry_point(fn) is expected + + def test_renamed_entry_point_misclassifies_silently(self): + """ + A parser whose entry point is not build_*_structure has its + top-level locals read as per-item, producing spurious drops. This + fails as a wrong VERDICT, never as an exception. + """ + renamed = ''' +def make_thing(pe): + errors = [] + errors.append("top_tag") + return {"errors": errors} +''' + tags = extract_parser_tags(renamed) + assert tags.item_errors == {"top_tag"} # wrong, but silent + assert tags.top_errors == set() + + def test_unconventional_list_name_is_ignored(self): + """A tag list named outside the convention is not read at all.""" + src = '_SOMETHING = ["real_tag"]' + assert extract_validator_consumption(src).matched == set() + + +# ================================================================= +# Robustness +# ================================================================= + +@pytest.mark.contract +class TestRobustness: + + def test_empty_source(self): + assert extract_parser_tags("").errors == set() + assert extract_validator_consumption("").matched == set() + + def test_source_with_no_tags(self): + src = 'def f(x):\n return x + 1\n' + tags = extract_parser_tags(src) + assert tags.errors == set() and tags.truncations == set() + + def test_nested_functions_are_walked(self): + src = ''' +def build_thing_structure(pe): + def inner(errors): + errors.append("nested_tag") + return {} +''' + assert "nested_tag" in extract_parser_tags(src).errors + + def test_append_to_unrelated_list_is_ignored(self): + src = ''' +def _walk(pe, results): + results.append("not_a_tag") +''' + tags = extract_parser_tags(src) + assert tags.errors == set() and tags.truncations == set() + + def test_non_string_append_is_ignored(self): + src = ''' +def _walk(pe, errors): + errors.append(42) + errors.append(some_variable) +''' + assert extract_parser_tags(src).errors == set() diff --git a/tests/unit/validators/test_validator_exception_dir.py b/tests/unit/validators/test_validator_exception_dir.py index 6f93ba7..74d797e 100644 --- a/tests/unit/validators/test_validator_exception_dir.py +++ b/tests/unit/validators/test_validator_exception_dir.py @@ -441,7 +441,7 @@ def test_begin_rva_zero_flagged(self): assert _details_for(issues, ReasonCodes.EXCEPTION_ENTRY_INVALID)[0]["index"] == 1 @pytest.mark.parametrize("tag", [ - "entry_truncated", "entry_read_failed", "entry_unpack_failed", + "entry_unpack_failed", "begin_rva_zero", "end_rva_zero", "unwind_rva_zero", ]) def test_each_priority_tag_resolves(self, tag): @@ -453,10 +453,10 @@ def test_priority_first_match_wins(self): """entry_truncated outranks begin_rva_zero and unwind_rva_zero.""" ex = _make_ex(size=12, functions=[_make_entry( 0, None, None, None, unwind=None, - errors=["begin_rva_zero", "entry_truncated", "unwind_rva_zero"])]) + errors=["begin_rva_zero", "unwind_rva_zero"])]) issues = _run(ex) assert len(issues) == 1 - assert _has(issues, ReasonCodes.EXCEPTION_ENTRY_INVALID, "entry_truncated") + assert _has(issues, ReasonCodes.EXCEPTION_ENTRY_INVALID, "begin_rva_zero") def test_unknown_tag_not_flagged(self): """A future parser tag not in the priority list is skipped silently.""" @@ -998,8 +998,8 @@ def test_repeated_validation_produces_identical_issues(self): def test_priority_resolution_deterministic(self): ex = _make_ex(size=12, functions=[_make_entry( 0, None, None, None, unwind=None, - errors=["unwind_rva_zero", "entry_truncated", "begin_rva_zero"])]) + errors=["unwind_rva_zero", "begin_rva_zero"])]) results = [_run(ex) for _ in range(20)] for r in results[1:]: assert r == results[0] - assert results[0][0]["details"]["sub_reason"] == "entry_truncated" + assert results[0][0]["details"]["sub_reason"] == "begin_rva_zero" From fedcf6e9c21c08251cde7b7a2ae5f0357ba44663 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 10:28:17 +0100 Subject: [PATCH 16/40] Pe_resources tag contract pair added. This resulted in an analysis of the current parser and validator. 1. Parser now does not consume errors as before during resource table walk, instead emitting a string_table_walk_failed tombstone, 2. This tombstone is now consumed in the validator and emitted as a dedicated reason code RESOURCE_STRING_TABLE_UNREADABLE --- docs/specs/reason-codes.md | 3 ++- iocx/parsers/pe_resources.py | 4 +++- iocx/reason_codes.py | 1 + iocx/schemas/internal_schema.py | 1 + iocx/validators/resources.py | 10 ++++++++++ tests/contract/test_tag_contract.py | 7 +++++-- 6 files changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 513edcc..d55ca21 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -252,7 +252,7 @@ parser returns `callbacks = []` in both cases. |------------|------------------|-----------------|--------| | **RESOURCE_DIRECTORY_OUT_OF_BOUNDS** | A resource directory's `rva + size` does not lie wholly inside the `.rsrc` section. Two cases reach this: the **root** directory lies outside `.rsrc` (`depth` = 0), or a **subdirectory** starts inside `.rsrc` but its extent overflows the end (`depth` ≥ 1). A subdirectory lying wholly outside is reported by the parent as `RESOURCE_ENTRY_OUT_OF_BOUNDS` instead, so the two never double-count. `SizeOfImage` is not consulted — `.rsrc` bounds are authoritative here | Root directory RVA = `0x90000000` while `.rsrc` spans `0x1000–0x3000`; or a Name directory at `0x2FF8` with size 24 | Per‑directory | | **RESOURCE_DIRECTORY_LOOP** | Recursive directory traversal detects a cycle (malformed or malicious resource tree) | Directory A → B → A | Per‑file | -| **RESOURCE_DIRECTORY_ZERO_LENGTH** | A resource directory exists but has zero length or no valid entries | RVA = `0x3000`, size = `0` | Per‑file | +| **RESOURCE_DIRECTORY_ZERO_LENGTH** (*reserved, not emitted*) | A resource directory exists but has zero length or no valid entries | RVA = `0x3000`, size = `0` | Per‑file | ### Resource Hierarchy Anomalies | Reason Code | What Triggers It | Example Pattern | Scope | @@ -305,6 +305,7 @@ tags are passed through verbatim in an `errors` list. | Reason Code | What Triggers It | Example Pattern | Scope | |------------|------------------|-----------------|--------| | **RESOURCE_STRING_TABLE_CORRUPT** | String table length, offsets, or UTF‑16 entries are malformed or out of bounds | String count = 32 but table only contains 10 entries | Per‑file | +| **RESOURCE_STRING_TABLE_UNREADABLE** | The RT_STRING traversal raised before completing, so the string-table list is empty or partial and its absence carries no meaning | Malformed Name or Language directory beneath RT_STRING | Per‑file | --- diff --git a/iocx/parsers/pe_resources.py b/iocx/parsers/pe_resources.py index 3dc014b..512935a 100644 --- a/iocx/parsers/pe_resources.py +++ b/iocx/parsers/pe_resources.py @@ -91,6 +91,7 @@ def build_directory(node, entry_struct=None) -> Dict[str, Any]: # Collect string table entries (RT_STRING = 6) string_tables = [] + errors: List[str] = [] try: RT_STRING = 6 for type_entry in root_dir.entries: @@ -107,9 +108,10 @@ def build_directory(node, entry_struct=None) -> Dict[str, Any]: } ) except Exception: # pragma: no cover - pass + errors.append("string_table_walk_failed") return { "root": root, "string_tables": string_tables, + "errors": errors, } diff --git a/iocx/reason_codes.py b/iocx/reason_codes.py index afedca3..e36442c 100644 --- a/iocx/reason_codes.py +++ b/iocx/reason_codes.py @@ -119,6 +119,7 @@ class ReasonCodes: # --- Resource string-table anomalies --- RESOURCE_STRING_TABLE_CORRUPT = "resource_string_table_corrupt" + RESOURCE_STRING_TABLE_UNREADABLE = "resource_string_table_unreadable" # --- Load Config Directory structural issues --- LOAD_CONFIG_TOO_SMALL = "load_config_too_small" diff --git a/iocx/schemas/internal_schema.py b/iocx/schemas/internal_schema.py index 20bdc6c..41aed3d 100644 --- a/iocx/schemas/internal_schema.py +++ b/iocx/schemas/internal_schema.py @@ -31,6 +31,7 @@ class ResourceStringTable(TypedDict): class ResourcesStruct(TypedDict): root: ResourceDirectoryNode string_tables: List[ResourceStringTable] + errors: List[str] # ------------------------- diff --git a/iocx/validators/resources.py b/iocx/validators/resources.py index d66c0bf..807f5d8 100644 --- a/iocx/validators/resources.py +++ b/iocx/validators/resources.py @@ -206,6 +206,16 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: # --------------------------------------------------------- # String table validation # --------------------------------------------------------- + # A walk failure and an absence of RT_STRING resources both leave + # string_tables empty, so the parser records the failure explicitly. + # Without this the two are indistinguishable and a malformed tree is + # reported as clean. + if "string_table_walk_failed" in (resources.get("errors") or []): + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_STRING_TABLE_UNREADABLE, + details={"sub_reason": "walk_failed"}, + )) + for st in resources.get("string_tables", []): rva = st["rva"] size = st["size"] diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 5220ad0..3646bd7 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -21,9 +21,11 @@ from tag_contract import check_contract from iocx.parsers import (pe_imports, pe_relocations, pe_tls, pe_debug, - pe_exports, pe_delay_imports, pe_certificates, pe_exception) + pe_exports, pe_delay_imports, pe_certificates, pe_exception, + pe_resources) from iocx.validators import (imports, relocations, tls, debug, - exports, delay_imports, signature, exception_table) + exports, delay_imports, signature, exception_table, + resources) _PAIRS = [ # (label, parser module, validator module, template_vars) @@ -43,6 +45,7 @@ ("pe_delay_imports", pe_delay_imports, delay_imports, {"tag": ["int", "iat"]}), ("pe_certificates", pe_certificates, signature, {}), ("pe_exception", pe_exception, exception_table, {}), + ("pe_resources", pe_resources, resources, {}), ] # Tags a parser emits that no validator consumes, deliberately. From 5d9a1e566d359ceda28990d9b26662d39e0de6a8 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 12:30:29 +0100 Subject: [PATCH 17/40] Add crash handling to pe_resources and tighten tests --- docs/specs/reason-codes.md | 19 + iocx/parsers/pe_resources.py | 198 ++++--- iocx/reason_codes.py | 1 + iocx/schemas/internal_schema.py | 1 + iocx/validators/resources.py | 132 +++-- tests/contract/test_tag_contract.py | 18 +- tests/unit/parsers/test_pe_parser.py | 78 ++- tests/unit/parsers/test_pe_resources.py | 527 ++++++++++++++++++ .../validators/test_validator_resources.py | 302 +++++++++- 9 files changed, 1133 insertions(+), 143 deletions(-) create mode 100644 tests/unit/parsers/test_pe_resources.py diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index d55ca21..ea2d74d 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -267,6 +267,19 @@ parser returns `callbacks = []` in both cases. | **RESOURCE_ENTRY_OUT_OF_BOUNDS** | A resource directory entry points to a **subdirectory** whose RVA lies outside the `.rsrc` section. Out-of-bounds *data* entries are reported as `RESOURCE_DATA_OUT_OF_BOUNDS`, not here. The target's own size is not considered at this point — a subdirectory that starts inside `.rsrc` but overflows the end is caught by `RESOURCE_DIRECTORY_OUT_OF_BOUNDS` when it is descended into | Type directory entry points to a Name directory at RVA `0x80000000` | Per‑file | | **RESOURCE_DATA_OUT_OF_BOUNDS** | Resource data block lies outside the file or outside the `.rsrc` section | Data offset = `0x1F0000`, file size = `0x1E0000` | Per‑file | | **RESOURCE_DATA_OVERLAPS_OTHER_DATA** | A resource data blob spans the overlay start, or its raw or virtual extent intersects a section other than `.rsrc`. Blob-versus-blob comparison is **not** performed | Data at raw `0x2000–0x2400` intersects `.text` raw range | Per-file *(one issue per check; the raw-overlap and VA-overlap loops each stop at the first intersecting section, so a blob crossing several sections reports once per check, not once per section)* | + **RESOURCE_DIRECTORY_ENTRY_UNREADABLE** | A directory's entry list, or one entry within it, could not be decoded. The entry is skipped rather than aborting the walk, so the directory reports fewer entries than its declared size implies | An entry that is neither a subdirectory nor a data leaf | Per‑directory *(priority-resolved sub-reason)* | + +#### RESOURCE_DIRECTORY_ENTRY_UNREADABLE + +Priority‑resolved; an unreadable entry *list* subsumes any per-entry failure, +since no entry was reached at all: + +| Sub‑reason | Meaning | +|------------|---------| +| directory_entries_unavailable | The directory's `.entries` was missing or raised; no entry was decoded | +| entry_decode_failed | One or more individual entries were unreadable and skipped; `failed_entry_count` gives the total | + +Details carry `declared_size` and `decoded_entries`; their difference is the loss, and neither conveys it alone. ### Resource Version‑Info Anomalies @@ -307,6 +320,12 @@ tags are passed through verbatim in an `errors` list. | **RESOURCE_STRING_TABLE_CORRUPT** | String table length, offsets, or UTF‑16 entries are malformed or out of bounds | String count = 32 but table only contains 10 entries | Per‑file | | **RESOURCE_STRING_TABLE_UNREADABLE** | The RT_STRING traversal raised before completing, so the string-table list is empty or partial and its absence carries no meaning | Malformed Name or Language directory beneath RT_STRING | Per‑file | +#### RESOURCE_STRING_TABLE_UNREADABLE + +| Sub‑reason | Meaning | +|------------|---------| +| walk_failed | The RT_STRING walk raised; `string_tables` may be empty or partial. Distinct from a binary that genuinely carries no string resources, which produces no issue at all | + --- ## **ENTROPY ANOMALIES** diff --git a/iocx/parsers/pe_resources.py b/iocx/parsers/pe_resources.py index 512935a..0f8cf83 100644 --- a/iocx/parsers/pe_resources.py +++ b/iocx/parsers/pe_resources.py @@ -1,113 +1,147 @@ # Copyright (c) 2026 MalX Labs and contributors # SPDX-License-Identifier: MPL-2.0 -from typing import Dict, Any +""" +Structural extraction of the PE resource directory. + +Unlike the other PE parsers in this package, this one consumes pefile's +already-parsed ``DIRECTORY_ENTRY_RESOURCE`` tree rather than decoding raw +bytes. The resource tree is recursive and pefile's traversal is well +exercised; re-implementing it is deferred. The consequence is recorded +honestly: a tree pefile refuses to parse yields ``None`` here, and the +validator then has nothing to report. + +Never raises. A node or entry that cannot be read is recorded as a +tombstone tag in the containing directory's ``errors`` list and skipped, so +one malformed subtree costs that subtree rather than the whole analysis. + +Output contract: + None - no resource directory present (not an error) + dict per ResourcesStruct in iocx.schemas.internal_schema. +""" + +from typing import Any, Dict, List, Optional + import pefile -def build_resource_structure(pe) -> Dict[str, Any]: - """ - Build a structural resource tree suitable for validation. - """ + +def build_resource_structure(pe) -> Optional[Dict[str, Any]]: if not hasattr(pe, "DIRECTORY_ENTRY_RESOURCE"): return None - # Resource directory entry index (IMAGE_DIRECTORY_ENTRY_RESOURCE = 2) - res_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[2] - base_rva = res_dir.VirtualAddress + # IMAGE_DIRECTORY_ENTRY_RESOURCE = 2 + try: + res_dir = pe.OPTIONAL_HEADER.DATA_DIRECTORY[2] + base_rva = int(res_dir.VirtualAddress) + except (AttributeError, IndexError, ValueError, TypeError): + return None root_dir = pe.DIRECTORY_ENTRY_RESOURCE + def build_entry(e) -> Dict[str, Any]: + """ + Decode one IMAGE_RESOURCE_DIRECTORY_ENTRY. May raise; the caller + records the failure and skips the entry. + """ + name = str(e.name) if getattr(e, "name", None) is not None else None + entry_id = getattr(e, "id", None) + + if getattr(e, "directory", None) is not None: + return { + "name": name, + "id": entry_id, + "is_directory": True, + "directory": build_directory(e.directory, e.struct), + "data_rva": None, + "data_size": None, + "raw_offset": None, + } + + d = e.data.struct + data_rva = d.OffsetToData + data_size = d.Size + + # Guarded RVA -> offset: a corrupt RVA must not abort the walk. + # -1 is the sentinel the validator treats as out-of-bounds via its + # `data_raw < 0` arm. + try: + raw_offset = pe.get_offset_from_rva(data_rva) + except (pefile.PEFormatError, AttributeError): + raw_offset = -1 + + return { + "name": name, + "id": entry_id, + "is_directory": False, + "directory": None, + "data_rva": data_rva, + "data_size": data_size, + "raw_offset": raw_offset, + } + def build_directory(node, entry_struct=None) -> Dict[str, Any]: """ node: pefile.ResourceDirData - entry_struct: the IMAGE_RESOURCE_DIRECTORY_ENTRY struct that pointed to this directory + entry_struct: the IMAGE_RESOURCE_DIRECTORY_ENTRY that pointed here """ - - # Directory RVA is derived from the entry that referenced it - if entry_struct: - # Mask off high bit (0x80000000) which indicates "is directory" + if entry_struct is not None: + # Mask off the high bit (0x80000000) marking "is directory". offset = entry_struct.OffsetToData & 0x7FFFFFFF rva = base_rva + offset else: - # Root directory: RVA is simply the base RVA rva = base_rva - # Directory size = 16-byte header + 8 bytes per entry - size = 16 + 8 * len(node.entries) - - entries = [] - for e in node.entries: - name = str(e.name) if getattr(e, "name", None) is not None else None - entry_id = getattr(e, "id", None) - - if hasattr(e, "directory") and e.directory is not None: - # Subdirectory - subdir = build_directory(e.directory, e.struct) - entries.append( - { - "name": name, - "id": entry_id, - "is_directory": True, - "directory": subdir, - "data_rva": None, - "data_size": None, - "raw_offset": None, - } - ) - else: - # Data entry - data = e.data - d = data.struct - data_rva = d.OffsetToData - data_size = d.Size - - # Guarded RVA→offset: a corrupt RVA must not crash the - # parser. -1 is the sentinel the validator already treats - # as out-of-bounds via its data_raw < 0 arm. - try: - raw_offset = pe.get_offset_from_rva(data_rva) - except (pefile.PEFormatError, AttributeError): - raw_offset = -1 - - entries.append( - { - "name": name, - "id": entry_id, - "is_directory": False, - "directory": None, - "data_rva": data_rva, - "data_size": data_size, - "raw_offset": raw_offset, - } - ) + errors: List[str] = [] - return { - "rva": rva, - "size": size, - "entries": entries, - } + # The entry list itself may be missing or unreadable on a malformed + # tree. Without this guard the whole parse aborts. + try: + node_entries = list(node.entries) + except Exception: + return {"rva": rva, "size": 16, "entries": [], + "errors": ["directory_entries_unavailable"]} + + # Size is derived from the DECLARED entry count, before any entry is + # skipped, so a partially decodable directory still reports the size + # its header implies: 16-byte header + 8 bytes per entry. + size = 16 + 8 * len(node_entries) + + entries: List[Dict[str, Any]] = [] + for e in node_entries: + try: + entries.append(build_entry(e)) + except Exception: + # One unreadable entry costs that entry, not the directory. + errors.append("entry_decode_failed") + continue + + return {"rva": rva, "size": size, "entries": entries, + "errors": errors} root = build_directory(root_dir) - # Collect string table entries (RT_STRING = 6) - string_tables = [] + # ---- RT_STRING table collection ---- + # A walk failure and a genuine absence of string resources both leave + # this list empty, so the failure is recorded explicitly. + string_tables: List[Dict[str, Any]] = [] errors: List[str] = [] try: RT_STRING = 6 for type_entry in root_dir.entries: - if getattr(type_entry, "id", None) == RT_STRING and hasattr(type_entry, "directory"): - for name_entry in type_entry.directory.entries: - if hasattr(name_entry, "directory"): - for lang_entry in name_entry.directory.entries: - if hasattr(lang_entry, "data"): - d = lang_entry.data.struct - string_tables.append( - { - "rva": d.OffsetToData, - "size": d.Size, - } - ) - except Exception: # pragma: no cover + if getattr(type_entry, "id", None) != RT_STRING: + continue + if not hasattr(type_entry, "directory"): + continue + for name_entry in type_entry.directory.entries: + if not hasattr(name_entry, "directory"): + continue + for lang_entry in name_entry.directory.entries: + if not hasattr(lang_entry, "data"): + continue + d = lang_entry.data.struct + string_tables.append({"rva": d.OffsetToData, + "size": d.Size}) + except Exception: errors.append("string_table_walk_failed") return { diff --git a/iocx/reason_codes.py b/iocx/reason_codes.py index e36442c..712d686 100644 --- a/iocx/reason_codes.py +++ b/iocx/reason_codes.py @@ -105,6 +105,7 @@ class ReasonCodes: RESOURCE_ENTRY_OUT_OF_BOUNDS = "resource_entry_out_of_bounds" RESOURCE_DIRECTORY_ZERO_LENGTH = "resource_directory_zero_length" RESOURCE_DIRECTORY_LANGUAGE_NOT_ID = "resource_directory_language_not_id" + RESOURCE_DIRECTORY_ENTRY_UNREADABLE = "resource_directory_entry_unreadable" # --- Resource data anomalies --- RESOURCE_DATA_OUT_OF_BOUNDS = "resource_data_out_of_bounds" diff --git a/iocx/schemas/internal_schema.py b/iocx/schemas/internal_schema.py index 41aed3d..e016ce7 100644 --- a/iocx/schemas/internal_schema.py +++ b/iocx/schemas/internal_schema.py @@ -21,6 +21,7 @@ class ResourceDirectoryNode(TypedDict): rva: int size: int entries: List[ResourceEntry] + errors: List[str] class ResourceStringTable(TypedDict): diff --git a/iocx/validators/resources.py b/iocx/validators/resources.py index 807f5d8..88d56ec 100644 --- a/iocx/validators/resources.py +++ b/iocx/validators/resources.py @@ -1,7 +1,26 @@ # Copyright (c) 2026 MalX Labs and contributors # SPDX-License-Identifier: MPL-2.0 -from typing import Dict, Any, List, Set +""" +Validate the resource directory tree produced by parser pe_resources. + +Absence of a resource directory is NOT a structural defect. Neither is the +absence of a .rsrc section: some producers place resources elsewhere, and +this validator's bounds checks are all expressed against .rsrc, so without +it there is nothing to compare against and we stay silent rather than +guess. + +Parser tombstone tags consumed here: + entry_decode_failed - one entry in a directory was unreadable + directory_entries_unavailable - a directory's entry list was unreadable + string_table_walk_failed - the RT_STRING traversal raised + +All three describe recoverability rather than placement, so they map to +their own codes and never displace the bounds findings below. +""" + +from typing import Any, Dict, List, Set + from iocx.reason_codes import ReasonCodes from iocx.validators.schema import StructuralIssue from iocx.schemas.internal_schema import InternalMetadata @@ -9,13 +28,23 @@ from .decorators import depends_on +# Per-directory parser tags, priority-resolved so a directory carrying both +# reports the more fundamental one. An unreadable entry LIST subsumes any +# per-entry failure, because no entry was reached at all. +_DIRECTORY_ERROR_PRIORITY = [ + "directory_entries_unavailable", + "entry_decode_failed", +] + + @depends_on("internal", "analysis") -def validate_resources(metadata: InternalMetadata, analysis: AnalysisDict) -> List[StructuralIssue]: +def validate_resources(metadata: InternalMetadata, + analysis: AnalysisDict) -> List[StructuralIssue]: issues: List[StructuralIssue] = [] resources = metadata.get("resources_struct") if not resources: - return issues # No resource directory → no issues + return issues # No resource directory -> no issues sections = analysis["sections"] file_size = analysis["file_size"] @@ -24,14 +53,13 @@ def validate_resources(metadata: InternalMetadata, analysis: AnalysisDict) -> Li # --------------------------------------------------------- # Locate .rsrc section # --------------------------------------------------------- - rsrc_section = next((sec for sec in sections if sec["name"].lower() == ".rsrc"), None) + rsrc_section = next( + (sec for sec in sections if sec["name"].lower() == ".rsrc"), None) if rsrc_section is None: - return issues # No resource section → nothing to validate + return issues # No resource section -> nothing to validate rsrc_va = rsrc_section["virtual_address"] rsrc_vs = rsrc_section["virtual_size"] - rsrc_raw = rsrc_section["raw_address"] # reserved - rsrc_raw_size = rsrc_section["raw_size"] # reserved def rva_in_rsrc(rva: int, size: int = 0) -> bool: return rsrc_va <= rva and (rva + size) <= (rsrc_va + rsrc_vs) @@ -42,7 +70,8 @@ def va_overlaps_section(start: int, size: int, sec: Dict[str, Any]) -> bool: sec_end = sec_start + sec["virtual_size"] return max(start, sec_start) < min(end, sec_end) - def raw_overlaps_section(raw_start: int, size: int, sec: Dict[str, Any]) -> bool: + def raw_overlaps_section(raw_start: int, size: int, + sec: Dict[str, Any]) -> bool: end = raw_start + size sec_start = sec["raw_address"] sec_end = sec_start + sec["raw_size"] @@ -62,13 +91,13 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: # Two distinct malformations reach here: # * the ROOT directory lies outside .rsrc entirely, or # * a CHILD starts inside .rsrc but its extent overflows the end - - # the caller's entry check tests target_rva WITHOUT a size, so such - # a child passes there and is caught here. + # the caller's entry check tests target_rva WITHOUT a size, so + # such a child passes there and is caught here. # # A child lying wholly outside is already reported by the caller as - # RESOURCE_ENTRY_OUT_OF_BOUNDS, and its `continue` prevents descent, so - # the two codes never double-count. `depth` distinguishes the cases: - # depth 0 is the root, deeper values are the overflow case. + # RESOURCE_ENTRY_OUT_OF_BOUNDS, and its `continue` prevents descent, + # so the two codes never double-count. `depth` distinguishes the + # cases: depth 0 is the root, deeper values are the overflow case. if not rva_in_rsrc(rva, size): issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DIRECTORY_OUT_OF_BOUNDS, @@ -79,9 +108,9 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: entries = dir_node["entries"] - # Reserved: only reachable if size is sourced from the on-disk - # IMAGE_RESOURCE_DIRECTORY header rather than derived from len(entries) - # Zero-length directory + # Reserved: unreachable while the parser derives size from the entry + # count (minimum 16). Only becomes live if size is ever sourced from + # the on-disk IMAGE_RESOURCE_DIRECTORY header instead. if size == 0: issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DIRECTORY_ZERO_LENGTH, @@ -98,8 +127,26 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: return visited_dirs.add(rva) + # ---- Parser-level decode failures for THIS directory ---- + # Reported after the placement and loop checks, so a directory we + # decline to process does not also report its contents. The count is + # carried because one tag may stand for several skipped entries, and + # the gap between `size` and len(entries) is only interpretable with + # it. + dir_errors = dir_node.get("errors") or [] + reason = _first_matching(dir_errors, _DIRECTORY_ERROR_PRIORITY) + if reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_DIRECTORY_ENTRY_UNREADABLE, + details={"rva": rva, "depth": depth, "sub_reason": reason, + "failed_entry_count": dir_errors.count( + "entry_decode_failed"), + "declared_size": size, + "decoded_entries": len(entries)}, + )) + # Language layer (depth 2) must use integer LCIDs: - # Per PE spec, the Type → Name → Language tree's deepest directory + # Per PE spec, the Type -> Name -> Language tree's deepest directory # layer is keyed by language ID. Named entries here are malformed. if depth == 2: for e in entries: @@ -118,16 +165,17 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: if not rva_in_rsrc(target_rva): issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_ENTRY_OUT_OF_BOUNDS, - details={"directory_rva": rva, "target_rva": target_rva}, + details={"directory_rva": rva, + "target_rva": target_rva}, )) continue - validate_directory(target, depth + 1) # <-- depth bumped + validate_directory(target, depth + 1) continue # Data entries should only appear at depth 2 (Language layer): # A data leaf at depth 0 or 1 means the tree shape violates the - # Type → Name → Language hierarchy. + # Type -> Name -> Language hierarchy. if depth != 2: issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_AT_INVALID_DEPTH, @@ -158,12 +206,13 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: )) continue - # Raw bounds (data_raw == -1 sentinel from a guarded RVA→offset + # Raw bounds (data_raw == -1 sentinel from a guarded RVA->offset # lookup also lands here, preserving the existing reason code). if data_raw < 0 or data_raw + data_size > file_size: issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS, - details={"data_raw": data_raw, "data_size": data_size, "file_size": file_size}, + details={"data_raw": data_raw, "data_size": data_size, + "file_size": file_size}, )) continue @@ -171,30 +220,33 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: if data_raw <= overlay_offset < data_raw + data_size: issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_OVERLAPS_OTHER_DATA, - details={"data_raw": data_raw, "data_size": data_size, "overlay_offset": overlay_offset}, + details={"data_raw": data_raw, "data_size": data_size, + "overlay_offset": overlay_offset}, )) - # Raw overlap with other sections + # Raw overlap with other sections. Stops at the first + # intersecting section: a blob crossing several reports once. for sec in sections: if sec is rsrc_section: continue - if raw_overlaps_section(data_raw, data_size, sec): issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_OVERLAPS_OTHER_DATA, - details={"data_raw": data_raw, "data_size": data_size, "section": sec["name"]}, + details={"data_raw": data_raw, "data_size": data_size, + "section": sec["name"]}, )) break - # VA overlap with other sections + # VA overlap with other sections. Independent of the raw check + # above, so a blob may report once per check. for sec in sections: if sec is rsrc_section: continue - if va_overlaps_section(data_rva, data_size, sec): issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_DATA_OVERLAPS_OTHER_DATA, - details={"data_rva": data_rva, "data_size": data_size, "section": sec["name"]}, + details={"data_rva": data_rva, "data_size": data_size, + "section": sec["name"]}, )) break @@ -206,16 +258,22 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: # --------------------------------------------------------- # String table validation # --------------------------------------------------------- - # A walk failure and an absence of RT_STRING resources both leave + # A walk failure and a genuine absence of RT_STRING resources both leave # string_tables empty, so the parser records the failure explicitly. - # Without this the two are indistinguishable and a malformed tree is - # reported as clean. + # Without this the two are indistinguishable and a malformed subtree is + # reported as clean. Deliberately does NOT return: a partial walk still + # has collected tables worth bounds-checking. if "string_table_walk_failed" in (resources.get("errors") or []): issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_STRING_TABLE_UNREADABLE, - details={"sub_reason": "walk_failed"}, + details={"sub_reason": "walk_failed", + "collected_tables": len( + resources.get("string_tables") or [])}, )) + # One issue per file rather than per table: a corrupt string-table + # region is a property of the resource section, and reporting every + # entry would flood on a systematically broken tree. for st in resources.get("string_tables", []): rva = st["rva"] size = st["size"] @@ -227,3 +285,11 @@ def validate_directory(dir_node: Dict[str, Any], depth: int = 0) -> None: break return issues + + +def _first_matching(errors: List[str], candidates: List[str]) -> str: + """Return the first tag from `candidates` present in `errors`.""" + for c in candidates: + if c in errors: + return c + return "unknown" diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 3646bd7..3c0490d 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -37,15 +37,15 @@ # "int" - OriginalFirstThunk present (normal) # "iat_fallback" - OriginalFirstThunk zero, fell back to FirstThunk # Both must be listed, or those eight tags are silently unchecked. - ("pe_imports", pe_imports, imports, {"tag": ["int", "iat_fallback"]}), - ("pe_relocations", pe_relocations, relocations, {}), - ("pe_tls", pe_tls, tls, {}), - ("pe_debug", pe_debug, debug, {}), - ("pe_exports", pe_exports, exports, {"tag": ["eat", "enpt", "eot"]}), - ("pe_delay_imports", pe_delay_imports, delay_imports, {"tag": ["int", "iat"]}), - ("pe_certificates", pe_certificates, signature, {}), - ("pe_exception", pe_exception, exception_table, {}), - ("pe_resources", pe_resources, resources, {}), + ("pe_imports", pe_imports, imports, {"tag": ["int", "iat_fallback"]}), + ("pe_relocations", pe_relocations, relocations, {}), + ("pe_tls", pe_tls, tls, {}), + ("pe_debug", pe_debug, debug, {}), + ("pe_exports", pe_exports, exports, {"tag": ["eat", "enpt", "eot"]}), + ("pe_delay_imports", pe_delay_imports, delay_imports, {"tag": ["int", "iat"]}), + ("pe_certificates", pe_certificates, signature, {}), + ("pe_exception", pe_exception, exception_table, {}), + ("pe_resources", pe_resources, resources, {}), ] # Tags a parser emits that no validator consumes, deliberately. diff --git a/tests/unit/parsers/test_pe_parser.py b/tests/unit/parsers/test_pe_parser.py index b6ab634..de6d1b0 100644 --- a/tests/unit/parsers/test_pe_parser.py +++ b/tests/unit/parsers/test_pe_parser.py @@ -1,11 +1,13 @@ # Copyright (c) 2026 MalX Labs and contributors # SPDX-License-Identifier: MPL-2.0 -import pytest +import pytest, pefile from types import SimpleNamespace +from typing import Dict, Any, Optional, List from iocx.parsers.pe_parser import parse_pe, _walk_resources, analyse_pe_sections, _parse_data_directories, _parse_data_directories_raw from iocx.parsers.string_extractor import extract_strings_from_bytes +from iocx.parsers.pe_resources import build_resource_structure # ------------------------------------------------------------ @@ -85,6 +87,28 @@ def fake_loader(path, fast_load=True): yield +# ------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------ + +def _walk(node: Dict[str, Any], acc: Optional[List] = None) -> List[Dict[str, Any]]: + """Every directory node in the tree, root first.""" + acc = acc if acc is not None else [] + acc.append(node) + for e in node["entries"]: + if e["directory"] is not None: + _walk(e["directory"], acc) + return acc + + +def _data_leaves(root: Dict[str, Any]) -> List[Dict[str, Any]]: + return [e for d in _walk(root) for e in d["entries"] if not e["is_directory"]] + + +def _all_directory_errors(root: Dict[str, Any]) -> List: + return [tag for d in _walk(root) for tag in d["errors"]] + + # ------------------------------------------------------------ # Tests for parse_pe() using pure mocks # ------------------------------------------------------------ @@ -202,7 +226,6 @@ class FakeDir: def test_parse_pe_handles_peformaterror(monkeypatch): - import pefile # Override the autouse patch for this test only def raise_peformaterror(path, fast_load=True): raise pefile.PEFormatError("bad file") @@ -472,17 +495,46 @@ def test_attribute_error_yields_minus_one(self): assert leaf["data_rva"] == 0x1100 assert leaf["data_size"] == 100 - def test_non_caught_exception_propagates(self): + @pytest.mark.parametrize("exc", [ + pefile.PEFormatError("unmapped rva"), + AttributeError("no such attribute"), + ]) + def test_caught_types_yield_sentinel_and_keep_the_entry(self, exc): """ - Sanity check that the except clause is narrow — a RuntimeError - (not in the tuple) should still propagate. This protects against - someone widening the except to `Exception` without thinking. + Assertions walk the tree rather than indexing root["entries"]the helper nests the leaf under Type -> Name -> Language, so the + root's first entry is a directory, not the data leaf. """ - import pytest as _pytest - from iocx.parsers.pe_resources import build_resource_structure + pe = self._make_pe_with_data_leaf_raising(exc) + out = build_resource_structure(pe) + leaves = _data_leaves(out["root"]) + assert len(leaves) == 1 + assert leaves[0]["raw_offset"] == -1 + assert _all_directory_errors(out["root"]) == [] + + def test_uncaught_type_falls_through_to_the_per_entry_guard(self): + """ + The inner except is narrow: a RuntimeError is NOT handled there, so + it reaches the per-entry guard and the leaf is dropped from its + containing directory. + + Widening the inner except to `Exception` would keep the leaf with + raw_offset = -1 instead. Before the never-raises patch this was + asserted as propagation; the per-entry guard now catches everything, + so the observable difference moved from "does it raise" to "is the + entry kept". + """ + pe = self._make_pe_with_data_leaf_raising(RuntimeError("not caught")) + out = build_resource_structure(pe) + assert _data_leaves(out["root"]) == [] + assert _all_directory_errors(out["root"]) == ["entry_decode_failed"] - pe = self._make_pe_with_data_leaf_raising( - RuntimeError("not a caught exception type") - ) - with _pytest.raises(RuntimeError): - build_resource_structure(pe) + def test_declared_size_survives_the_drop(self): + """ + The containing directory still reports the size its header implies, + so the gap between `size` and len(entries) remains interpretable. + """ + pe = self._make_pe_with_data_leaf_raising(RuntimeError("not caught")) + out = build_resource_structure(pe) + dropped = [d for d in _walk(out["root"]) if d["errors"]][0] + assert dropped["size"] == 24 + assert dropped["entries"] == [] diff --git a/tests/unit/parsers/test_pe_resources.py b/tests/unit/parsers/test_pe_resources.py new file mode 100644 index 0000000..222aef9 --- /dev/null +++ b/tests/unit/parsers/test_pe_resources.py @@ -0,0 +1,527 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Unit tests for iocx.parsers.pe_resources. + +Strategy differs from the other parser suites: pe_resources consumes +pefile's already-parsed DIRECTORY_ENTRY_RESOURCE tree rather than raw +bytes, so the fixtures are duck-typed stand-ins for pefile's +ResourceDirData / ResourceDirEntryData objects rather than byte buffers. + +Two malformation shapes drive most of these tests, because they are what +the parser's guards exist for: + + * a node whose `.entries` is missing or unreadable + * an entry that is neither a directory nor a data leaf + +Both previously propagated out of build_resource_structure, breaking the +never-raises contract every other parser in this package holds. + +Note the string-table walk and the main tree walk disagree on one test: + + build_entry : getattr(e, "directory", None) is not None + string walk : hasattr(name_entry, "directory") <-- no None check + +so an entry with `directory = None` AND a valid `.data` is a data leaf to +the first and a directory to the second. `_hybrid_entry` exploits that to +reach the string walk's exception handler without disturbing the main tree. +Narrowing the string walk to match would make that handler unreachable. +""" + +from __future__ import annotations + +import random +from typing import Any, Dict, List, Optional + +import pefile +import pytest + +from iocx.parsers.pe_resources import build_resource_structure + +_RT_STRING = 6 +_RT_ICON = 3 + + +# ================================================================= +# pefile-shaped fixtures +# ================================================================= + +class _DataDir: + def __init__(self, va: int): + self.VirtualAddress = va + + +class _OptHdr: + def __init__(self, va: int): + # index 2 is IMAGE_DIRECTORY_ENTRY_RESOURCE + self.DATA_DIRECTORY = [None, None, _DataDir(va)] + + +class _Struct: + def __init__(self, offset_to_data: int, size: int = 0): + self.OffsetToData = offset_to_data + self.Size = size + + +class _Data: + def __init__(self, offset: int, size: int): + self.struct = _Struct(offset, size) + + +class _Node: + """Stands in for pefile.ResourceDirData.""" + def __init__(self, entries): + self.entries = entries + + +class _NoEntries: + """A directory object lacking `.entries` - a malformed subtree.""" + + +class _RaisingEntries: + """A directory whose `.entries` access raises.""" + @property + def entries(self): + raise ValueError("corrupt entry list") + + +class _Entry: + def __init__(self, **kw): + self.name = None + self.id = None + for k, v in kw.items(): + setattr(self, k, v) + + +class _FakePE: + def __init__(self, root, base_rva: int = 0x1000, + raise_on_offset: bool = False, + has_resource_dir: bool = True, + has_optional_header: bool = True): + if has_resource_dir: + self.DIRECTORY_ENTRY_RESOURCE = root + if has_optional_header: + self.OPTIONAL_HEADER = _OptHdr(base_rva) + self._raise_on_offset = raise_on_offset + + def get_offset_from_rva(self, rva: int) -> int: + if self._raise_on_offset: + raise pefile.PEFormatError("unmapped rva") + return rva - 0x1000 + 0x400 + + +# ---- tree builders ---- + +def _data_entry(offset: int = 0x1100, size: int = 0x40, + entry_id: Optional[int] = 0x409, + name: Optional[str] = None) -> _Entry: + e = _Entry(id=entry_id, name=name) + e.data = _Data(offset, size) + e.struct = _Struct(0) + return e + + +def _dir_entry(children, offset: int = 0x10, + entry_id: Optional[int] = 1, + name: Optional[str] = None) -> _Entry: + e = _Entry(id=entry_id, name=name) + e.directory = _Node(children) + e.struct = _Struct(0x80000000 | offset) + return e + + +def _hybrid_entry() -> _Entry: + """ + directory=None plus a valid .data. A data leaf to the main walk, a + directory to the string walk - the only shape that reaches the string + walk's exception handler while leaving the main tree intact. + """ + e = _Entry(id=1) + e.directory = None + e.data = _Data(0x1100, 0x40) + e.struct = _Struct(0) + return e + + +def _string_tree(tables=((0x1100, 0x40),)) -> _Node: + """A well-formed RT_STRING subtree: Type -> Name -> Language -> data.""" + langs = [_data_entry(off, size) for off, size in tables] + return _Node([_dir_entry([_dir_entry(langs, offset=0x10)], + offset=0x20, entry_id=_RT_STRING)]) + + +# ================================================================= +# Absence +# ================================================================= + +class TestAbsence: + + def test_no_resource_directory_returns_none(self): + pe = _FakePE(None, has_resource_dir=False) + assert build_resource_structure(pe) is None + + def test_missing_optional_header_returns_none(self): + """ + The data directory is needed for the base RVA; without it no RVA in + the tree can be derived, so returning None beats emitting a tree of + meaningless addresses. + """ + pe = _FakePE(_Node([]), has_optional_header=False) + assert build_resource_structure(pe) is None + + def test_empty_root_directory(self): + out = build_resource_structure(_FakePE(_Node([]))) + assert out["root"]["entries"] == [] + assert out["root"]["size"] == 16 # header only + assert out["root"]["errors"] == [] + assert out["string_tables"] == [] + assert out["errors"] == [] + + +# ================================================================= +# RVA derivation +# ================================================================= + +class TestRvaDerivation: + + def test_root_rva_is_the_directory_base(self): + out = build_resource_structure(_FakePE(_Node([]), base_rva=0x5000)) + assert out["root"]["rva"] == 0x5000 + + def test_subdirectory_rva_is_base_plus_offset(self): + tree = _Node([_dir_entry([], offset=0x20)]) + out = build_resource_structure(_FakePE(tree, base_rva=0x5000)) + assert out["root"]["entries"][0]["directory"]["rva"] == 0x5020 + + def test_high_bit_is_masked_off_the_offset(self): + """ + OffsetToData carries 0x80000000 to mark "is directory"; leaving it in + would place every subdirectory ~2GB past the image. + """ + tree = _Node([_dir_entry([], offset=0x30)]) + out = build_resource_structure(_FakePE(tree, base_rva=0x1000)) + assert out["root"]["entries"][0]["directory"]["rva"] == 0x1030 + + def test_nested_depth_derives_independently(self): + """Each level's RVA comes from its own entry struct, not by + accumulation, so a deep tree does not drift.""" + tree = _Node([_dir_entry([_dir_entry([], offset=0x10)], offset=0x20)]) + out = build_resource_structure(_FakePE(tree, base_rva=0x5000)) + type_dir = out["root"]["entries"][0]["directory"] + name_dir = type_dir["entries"][0]["directory"] + assert type_dir["rva"] == 0x5020 + assert name_dir["rva"] == 0x5010 + + +# ================================================================= +# Size derivation +# ================================================================= + +class TestSizeDerivation: + + @pytest.mark.parametrize("count,expected", [ + (0, 16), (1, 24), (2, 32), (5, 56), + ]) + def test_size_is_header_plus_eight_per_entry(self, count, expected): + tree = _Node([_data_entry() for _ in range(count)]) + out = build_resource_structure(_FakePE(tree)) + assert out["root"]["size"] == expected + + def test_size_uses_the_declared_count_not_the_decoded_count(self): + """ + A skipped entry must not shrink the reported size: the directory + header still claims that many entries, and the discrepancy between + declared size and decoded entries is the signal. + """ + tree = _Node([_data_entry(), _Entry(id=9)]) # second is undecodable + out = build_resource_structure(_FakePE(tree)) + assert out["root"]["size"] == 32 # 16 + 8*2 + assert len(out["root"]["entries"]) == 1 + assert out["root"]["errors"] == ["entry_decode_failed"] + + +# ================================================================= +# Entry decoding +# ================================================================= + +class TestEntryDecoding: + + def test_data_entry_fields(self): + tree = _Node([_data_entry(offset=0x1234, size=0x56)]) + entry = build_resource_structure(_FakePE(tree))["root"]["entries"][0] + assert entry["is_directory"] is False + assert entry["directory"] is None + assert entry["data_rva"] == 0x1234 + assert entry["data_size"] == 0x56 + assert entry["raw_offset"] == 0x1234 - 0x1000 + 0x400 + + def test_directory_entry_fields(self): + tree = _Node([_dir_entry([])]) + entry = build_resource_structure(_FakePE(tree))["root"]["entries"][0] + assert entry["is_directory"] is True + assert entry["directory"] is not None + assert entry["data_rva"] is None + assert entry["data_size"] is None + assert entry["raw_offset"] is None + + def test_named_entry_records_the_name(self): + tree = _Node([_data_entry(name="MYRESOURCE")]) + entry = build_resource_structure(_FakePE(tree))["root"]["entries"][0] + assert entry["name"] == "MYRESOURCE" + + def test_unnamed_entry_records_none(self): + tree = _Node([_data_entry(name=None)]) + assert build_resource_structure( + _FakePE(tree))["root"]["entries"][0]["name"] is None + + def test_entry_id_recorded(self): + tree = _Node([_data_entry(entry_id=0x0409)]) + assert build_resource_structure( + _FakePE(tree))["root"]["entries"][0]["id"] == 0x0409 + + def test_raw_offset_sentinel_on_unmapped_rva(self): + """ + A corrupt data RVA yields -1 rather than aborting. The validator + treats a negative raw offset as out-of-bounds, so the fact still + reaches output. + """ + tree = _Node([_data_entry()]) + out = build_resource_structure(_FakePE(tree, raise_on_offset=True)) + assert out["root"]["entries"][0]["raw_offset"] == -1 + + +# ================================================================= +# Per-entry guard - the never-raises contract +# ================================================================= + +class TestPerEntryGuard: + """ + Before this guard, a malformed entry propagated out of the parser and + took the whole analysis with it. Every other parser in the package holds + a never-raises contract; these pin it here. + """ + + def test_entry_with_neither_data_nor_directory_is_skipped(self): + tree = _Node([_Entry(id=9)]) + out = build_resource_structure(_FakePE(tree)) + assert out["root"]["entries"] == [] + assert out["root"]["errors"] == ["entry_decode_failed"] + + def test_sibling_entries_survive_a_bad_one(self): + tree = _Node([_data_entry(0x1100), _Entry(id=9), _data_entry(0x1200)]) + out = build_resource_structure(_FakePE(tree)) + assert [e["data_rva"] for e in out["root"]["entries"]] == [0x1100, 0x1200] + assert out["root"]["errors"] == ["entry_decode_failed"] + + def test_one_error_tag_per_failing_entry(self): + tree = _Node([_Entry(id=1), _Entry(id=2), _data_entry()]) + out = build_resource_structure(_FakePE(tree)) + assert out["root"]["errors"] == ["entry_decode_failed"] * 2 + + @pytest.mark.parametrize("broken", [_NoEntries, _RaisingEntries]) + def test_unreadable_subdirectory_is_contained(self, broken): + """ + A subtree whose entry list cannot be read costs that subtree only; + the parent still reports it as a directory entry. + """ + e = _Entry(id=1) + e.directory = broken() + e.struct = _Struct(0x80000010) + out = build_resource_structure(_FakePE(_Node([e]))) + subdir = out["root"]["entries"][0]["directory"] + assert subdir["errors"] == ["directory_entries_unavailable"] + assert subdir["entries"] == [] + assert subdir["size"] == 16 # header only; count unknowable + assert out["root"]["errors"] == [] + + def test_deep_failure_does_not_reach_the_root(self): + bad = _Entry(id=1) + bad.directory = _NoEntries() + bad.struct = _Struct(0x80000010) + tree = _Node([_dir_entry([_dir_entry([bad], offset=0x20)], offset=0x30)]) + out = build_resource_structure(_FakePE(tree)) + assert out["root"]["errors"] == [] + level1 = out["root"]["entries"][0]["directory"] + level2 = level1["entries"][0]["directory"] + level3 = level2["entries"][0]["directory"] + assert level3["errors"] == ["directory_entries_unavailable"] + + def test_errors_key_present_on_every_directory(self): + tree = _Node([_dir_entry([_dir_entry([_data_entry()])])]) + out = build_resource_structure(_FakePE(tree)) + + def _walk(node): + assert "errors" in node + assert isinstance(node["errors"], list) + for e in node["entries"]: + if e["directory"] is not None: + _walk(e["directory"]) + + _walk(out["root"]) + + +# ================================================================= +# RT_STRING walk +# ================================================================= + +class TestStringTableWalk: + + def test_healthy_walk_collects_tables(self): + out = build_resource_structure(_FakePE(_string_tree())) + assert out["string_tables"] == [{"rva": 0x1100, "size": 0x40}] + assert out["errors"] == [] + + def test_multiple_tables_collected_in_order(self): + tree = _string_tree(((0x1100, 0x40), (0x1200, 0x80))) + out = build_resource_structure(_FakePE(tree)) + assert out["string_tables"] == [{"rva": 0x1100, "size": 0x40}, + {"rva": 0x1200, "size": 0x80}] + + def test_walk_failure_is_tagged(self): + """ + The line that was `except Exception: pass`. Without the tag, an empty + list means both "no string resources" and "the walk broke". + """ + tree = _Node([_dir_entry([_hybrid_entry()], offset=0x20, + entry_id=_RT_STRING)]) + out = build_resource_structure(_FakePE(tree)) + assert out["errors"] == ["string_table_walk_failed"] + assert out["string_tables"] == [] + + def test_partial_collection_is_preserved(self): + """ + Tables gathered before the raise are kept, so the tag means "may be + incomplete" rather than "empty" - a non-empty list is not proof the + walk finished. + """ + good = _dir_entry([_data_entry(0x1100, 0x40)], offset=0x10) + tree = _Node([_dir_entry([good, _hybrid_entry()], offset=0x20, + entry_id=_RT_STRING)]) + out = build_resource_structure(_FakePE(tree)) + assert out["string_tables"] == [{"rva": 0x1100, "size": 0x40}] + assert out["errors"] == ["string_table_walk_failed"] + + def test_no_rt_string_resources_is_not_an_error(self): + """ + The distinction the tag exists for: an icon-only binary has an empty + list and a CLEAN errors list. + """ + tree = _Node([_dir_entry([_dir_entry([_data_entry()])], + offset=0x20, entry_id=_RT_ICON)]) + out = build_resource_structure(_FakePE(tree)) + assert out["string_tables"] == [] + assert out["errors"] == [] + + def test_walk_failure_does_not_break_the_main_tree(self): + tree = _Node([_dir_entry([_hybrid_entry()], offset=0x20, + entry_id=_RT_STRING)]) + out = build_resource_structure(_FakePE(tree)) + assert out["errors"] == ["string_table_walk_failed"] + assert len(out["root"]["entries"]) == 1 + assert out["root"]["errors"] == [] + + +# ================================================================= +# Output contract +# ================================================================= + +class TestOutputContract: + + def test_top_level_keys(self): + out = build_resource_structure(_FakePE(_string_tree())) + assert set(out) == {"root", "string_tables", "errors"} + + def test_directory_node_keys(self): + out = build_resource_structure(_FakePE(_Node([_data_entry()]))) + assert set(out["root"]) == {"rva", "size", "entries", "errors"} + + def test_entry_keys(self): + out = build_resource_structure(_FakePE(_Node([_data_entry()]))) + assert set(out["root"]["entries"][0]) == { + "name", "id", "is_directory", "directory", + "data_rva", "data_size", "raw_offset"} + + def test_json_serialisable(self): + import json + json.dumps(build_resource_structure(_FakePE(_string_tree()))) + + +# ================================================================= +# Robustness +# ================================================================= + +class TestNeverRaises: + + def _random_entry(self, rng, depth=0): + e = _Entry(id=rng.choice([None, 1, _RT_STRING, 0x409])) + r = rng.random() + if r < 0.20: + return e # neither branch + if r < 0.40: + e.data = _Data(rng.randint(0, 0xFFFF), rng.randint(0, 0xFF)) + e.struct = _Struct(0) + return e + if r < 0.50: + e.directory = None # hybrid + e.data = _Data(1, 1) + e.struct = _Struct(0) + return e + if r < 0.60: + e.directory = rng.choice([_NoEntries(), _RaisingEntries()]) + e.struct = _Struct(0x80000010) + return e + if depth > 2: + e.data = _Data(1, 1) + e.struct = _Struct(0) + return e + e.directory = _Node([self._random_entry(rng, depth + 1) + for _ in range(rng.randint(0, 3))]) + e.struct = _Struct(0x80000000 | rng.randint(0, 0xFFFF)) + return e + + def test_random_trees_never_raise(self): + rng = random.Random(11) + for _ in range(500): + tree = _Node([self._random_entry(rng) + for _ in range(rng.randint(0, 4))]) + out = build_resource_structure(_FakePE(tree)) + assert isinstance(out, dict) + + def test_deeply_nested_tree(self): + node = _Node([_data_entry()]) + for _ in range(50): + e = _Entry(id=1) + e.directory = node + e.struct = _Struct(0x80000010) + node = _Node([e]) + assert build_resource_structure(_FakePE(node)) is not None + + +# ================================================================= +# Determinism +# ================================================================= + +class TestDeterminism: + + def _tree(self): + return _Node([ + _dir_entry([_dir_entry([_data_entry(0x1100, 0x40)], offset=0x10)], + offset=0x20, entry_id=_RT_STRING), + _Entry(id=9), # undecodable + _dir_entry([_data_entry(0x1200, 0x80)], offset=0x30), + ]) + + def test_repeated_parse_identical(self): + import json + first = json.dumps(build_resource_structure(_FakePE(self._tree())), + sort_keys=True) + for _ in range(20): + assert json.dumps(build_resource_structure(_FakePE(self._tree())), + sort_keys=True) == first + + def test_error_tag_order_is_stable(self): + tree = _Node([_Entry(id=1), _data_entry(), _Entry(id=2)]) + for _ in range(10): + out = build_resource_structure(_FakePE(tree)) + assert out["root"]["errors"] == ["entry_decode_failed"] * 2 diff --git a/tests/unit/validators/test_validator_resources.py b/tests/unit/validators/test_validator_resources.py index 19ec6d1..f524fba 100644 --- a/tests/unit/validators/test_validator_resources.py +++ b/tests/unit/validators/test_validator_resources.py @@ -8,7 +8,8 @@ The resource tree comes from internal["resources_struct"]; sections, file_size and overlay_offset from analysis. Note the validator reads those three with DIRECT SUBSCRIPTS (`analysis["sections"]`), so a missing key raises KeyError -rather than degrading - pinned below. +rather than degrading - pinned below. Errors on a node drives +RESOURCE_DIRECTORY_ENTRY_UNREADABLE. Fixture note: a data leaf is only well-formed at depth 2 (the Language layer). Any leaf hung directly off the root is at depth 0 and therefore ALSO trips @@ -26,7 +27,7 @@ import pytest -from iocx.validators.resources import validate_resources +from iocx.validators.resources import validate_resources, _DIRECTORY_ERROR_PRIORITY from iocx.reason_codes import ReasonCodes @@ -38,6 +39,10 @@ FILE_SIZE = 0x10000 OVERLAY = 0xF000 +UNREADABLE = ReasonCodes.RESOURCE_DIRECTORY_ENTRY_UNREADABLE +UNREADABLE_ST = ReasonCodes.RESOURCE_STRING_TABLE_UNREADABLE +CORRUPT_ST = ReasonCodes.RESOURCE_STRING_TABLE_CORRUPT + # ================================================================= # Builders @@ -71,9 +76,10 @@ def _leaf(data_rva: Any = 0x1100, data_size: Any = 0x50, def _node(rva: int, size: int = 24, - entries: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]: + entries: Optional[List[Dict[str, Any]]] = None, + errors: Optional[List[str]] = None) -> Dict[str, Any]: """A directory NODE (the thing `directory` points at).""" - return {"rva": rva, "size": size, "entries": entries or []} + return {"rva": rva, "size": size, "entries": entries or [], "errors": errors or []} def _subdir(target: Dict[str, Any], name: Any = None, @@ -85,13 +91,13 @@ def _subdir(target: Dict[str, Any], name: Any = None, def _tree(leaves: List[Dict[str, Any]], - lang_rva: int = 0x1080) -> Dict[str, Any]: + lang_rva: int = 0x1080, lang_errors: Optional[List[str]] = None) -> Dict[str, Any]: """ A correctly-shaped Type -> Name -> Language tree whose Language directory (depth 2) holds `leaves`. Lets data-entry checks be tested without also tripping RESOURCE_DATA_AT_INVALID_DEPTH. """ - lang = _node(lang_rva, 24, leaves) + lang = _node(lang_rva, 24, leaves, errors=lang_errors) name = _node(0x1040, 24, [_subdir(lang)]) return _node(0x1000, 24, [_subdir(name)]) @@ -108,6 +114,21 @@ def _run(root: Dict[str, Any], analysis or _analysis()) +def _run_struct(root: Dict[str, Any], + errors: Optional[List[str]] = None, + string_tables: Optional[List[Dict[str, Any]]] = None, + analysis: Optional[Dict[str, Any]] = None): + """ + _run for tests needing a struct-level `errors` list - the sink + `string_table_walk_failed` lands in, distinct from a directory node's + own errors. + """ + resources = {"root": root, "errors": errors or []} + if string_tables is not None: + resources["string_tables"] = string_tables + return _run(root, analysis, resources=resources) + + def make_issue_list(result) -> List[str]: return [i["issue"] for i in result] @@ -116,6 +137,43 @@ def _details_for(issues, code) -> List[Dict[str, Any]]: return [i["details"] for i in issues if i["issue"] == code] +def _directory(entries: Optional[List[Dict[str, Any]]] = None, + rva: int = 0x1000, size: Optional[int] = None, + errors: Optional[List[str]] = None) -> Dict[str, Any]: + entries = entries or [] + return {"rva": rva, "size": size if size is not None else 16 + 8 * len(entries), + "entries": entries, "errors": errors or []} + + +def _dir_entry(directory: Dict[str, Any], entry_id: Optional[int] = 1, + name: Optional[str] = None) -> Dict[str, Any]: + return {"name": name, "id": entry_id, "is_directory": True, + "directory": directory, "data_rva": None, "data_size": None, + "raw_offset": None} + + +def _data_entry(data_rva: int = 0x1100, data_size: int = 0x40, + raw_offset: int = 0x500, entry_id: Optional[int] = 0x409, + name: Optional[str] = None) -> Dict[str, Any]: + return {"name": name, "id": entry_id, "is_directory": False, + "directory": None, "data_rva": data_rva, "data_size": data_size, + "raw_offset": raw_offset} + + +def _internal(root: Optional[Dict[str, Any]] = None, + string_tables: Optional[List[Dict[str, Any]]] = None, + errors: Optional[List[str]] = None) -> Dict[str, Any]: + return {"resources_struct": { + "root": root if root is not None else _directory(), + "string_tables": string_tables or [], + "errors": errors or [], + }} + + +def _codes(issues) -> List[str]: + return [i["issue"] for i in issues] + + # ================================================================= # Absence / early return # ================================================================= @@ -270,6 +328,145 @@ def test_sibling_directories_at_distinct_rvas_are_fine(self): assert _run(root) == [] +class TestDirectoryEntryUnreadable: + """ + The parser skips an entry it cannot decode rather than aborting. Without + these checks the skip is invisible: the directory reports fewer entries + than its declared size implies and nothing says why. + """ + + def test_entry_decode_failed_emits(self): + issues = _run(_node(0x1000, 24, [], errors=["entry_decode_failed"])) + assert make_issue_list(issues) == [UNREADABLE] + + def test_directory_entries_unavailable_emits(self): + issues = _run(_node(0x1000, 16, [], + errors=["directory_entries_unavailable"])) + assert make_issue_list(issues) == [UNREADABLE] + + def test_details_expose_the_declared_decoded_gap(self): + """ + `size` reflects the DECLARED entry count and `decoded_entries` the + number actually built, so their difference is the loss. Neither alone + conveys it. + """ + issues = _run(_node(0x1000, 32, [], errors=["entry_decode_failed"])) + assert _details_for(issues, UNREADABLE)[0] == { + "rva": 0x1000, "depth": 0, "sub_reason": "entry_decode_failed", + "failed_entry_count": 1, "declared_size": 32, + "decoded_entries": 0} + + def test_failed_entry_count_counts_repeats(self): + issues = _run(_node(0x1000, 40, [], + errors=["entry_decode_failed"] * 3)) + assert _details_for(issues, UNREADABLE)[0]["failed_entry_count"] == 3 + + def test_one_issue_per_directory(self): + """Priority-resolved: several tags still produce a single issue.""" + issues = _run(_node(0x1000, 16, [], + errors=["entry_decode_failed", + "directory_entries_unavailable"])) + assert len(_details_for(issues, UNREADABLE)) == 1 + + def test_priority_unavailable_beats_decode_failed(self): + """ + An unreadable entry LIST subsumes any per-entry failure - no entry + was reached at all. + """ + issues = _run(_node(0x1000, 16, [], + errors=["entry_decode_failed", + "directory_entries_unavailable"])) + assert _details_for(issues, UNREADABLE)[0]["sub_reason"] == ( + "directory_entries_unavailable") + + @pytest.mark.parametrize("higher,lower", list( + zip(_DIRECTORY_ERROR_PRIORITY, _DIRECTORY_ERROR_PRIORITY[1:]))) + def test_priority_order_adjacent_pairs(self, higher, lower): + """ + Adjacent pairs in both presentation orders. Pairing distant tags + would only prove that some ordering exists. + """ + for errors in ([higher, lower], [lower, higher]): + issues = _run(_node(0x1000, 16, [], errors=errors)) + assert _details_for(issues, UNREADABLE)[0]["sub_reason"] == higher + + def test_priority_list_content(self): + """ + Order is a contract, and a behavioural test cannot catch a reorder: + _first_matching is definitionally consistent with whatever order the + list happens to have, so fixtures derived from it move with it. + """ + assert _DIRECTORY_ERROR_PRIORITY == [ + "directory_entries_unavailable", + "entry_decode_failed", + ] + + def test_unknown_tag_is_ignored(self): + assert _run(_node(0x1000, 24, [], errors=["some_future_tag"])) == [] + + def test_missing_errors_key_tolerated(self): + """A struct predating the schema change must not raise.""" + node = _node(0x1000, 24, []) + del node["errors"] + assert _run(node) == [] + + def test_nested_directory_errors_reported_at_depth(self): + root = _tree([_leaf()], lang_errors=["entry_decode_failed"]) + details = _details_for(_run(root), UNREADABLE)[0] + assert details["depth"] == 2 + assert details["rva"] == 0x1080 + + def test_each_failing_directory_reports_separately(self): + lang = _node(0x1080, 24, [_leaf()], errors=["entry_decode_failed"]) + name = _node(0x1040, 24, [_subdir(lang)], + errors=["entry_decode_failed"]) + root = _node(0x1000, 24, [_subdir(name)]) + assert [d["depth"] for d in _details_for(_run(root), UNREADABLE)] == [ + 1, 2] + + +class TestDirectoryErrorSuppression: + """ + The errors report sits after the placement and loop checks, so a + directory the validator declines to process does not also report its + contents. + """ + + def test_out_of_bounds_directory_does_not_report_errors(self): + issues = _run(_node(0x9999, 16, [], errors=["entry_decode_failed"])) + assert make_issue_list(issues) == [ + ReasonCodes.RESOURCE_DIRECTORY_OUT_OF_BOUNDS] + + def test_looping_directory_does_not_report_errors_twice(self): + """ + A directory reached twice reports the loop on the second visit, not + a duplicate decode failure. + """ + shared = _node(0x1080, 24, [], errors=["entry_decode_failed"]) + root = _node(0x1000, 24, [_subdir(shared), _subdir(shared)]) + issues = _run(root) + assert len(_details_for(issues, UNREADABLE)) == 1 + assert ReasonCodes.RESOURCE_DIRECTORY_LOOP in make_issue_list(issues) + + def test_errors_do_not_suppress_the_entry_walk(self): + """ + A directory with a decode failure still has its SURVIVING entries + validated - the tag reports what was lost, not a reason to stop. + """ + root = _tree([_leaf(0x9999, 0x50)], + lang_errors=["entry_decode_failed"]) + codes = make_issue_list(_run(root)) + assert UNREADABLE in codes + assert ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS in codes + + def test_directory_errors_precede_entry_findings(self): + """Emission order: the directory's own fault before its contents'.""" + root = _tree([_leaf(0x9999, 0x50)], + lang_errors=["entry_decode_failed"]) + codes = make_issue_list(_run(root)) + assert codes.index(UNREADABLE) < codes.index( + ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS) + # ================================================================= # Language layer (depth 2) @@ -636,6 +833,89 @@ def test_string_tables_checked_even_when_tree_is_clean(self): ReasonCodes.RESOURCE_STRING_TABLE_CORRUPT] +class TestStringTableUnreadable: + + def test_walk_failure_emits(self): + """ + Regression guard: without the membership test the tag is dropped and + a malformed RT_STRING subtree reports clean. + """ + issues = _run_struct(_node(0x1000, 24, []), + errors=["string_table_walk_failed"]) + assert make_issue_list(issues) == [UNREADABLE_ST] + + def test_details_carry_the_collected_count(self): + """ + A non-empty list is not proof the walk finished, so the count is + reported alongside the failure. + """ + issues = _run_struct(_node(0x1000, 24, []), + errors=["string_table_walk_failed"], + string_tables=[{"rva": 0x1100, "size": 0x40}]) + assert _details_for(issues, UNREADABLE_ST)[0] == { + "sub_reason": "walk_failed", "collected_tables": 1} + + def test_clean_errors_emit_nothing(self): + assert _run_struct(_node(0x1000, 24, [])) == [] + + def test_empty_string_tables_without_the_tag_is_silent(self): + """ + The distinction the tag exists for: a binary with no string + resources must stay silent. + """ + assert _run_struct(_node(0x1000, 24, []), string_tables=[]) == [] + + def test_unrelated_tag_does_not_emit(self): + assert _run_struct(_node(0x1000, 24, []), + errors=["some_other_tag"]) == [] + + def test_missing_struct_errors_key_tolerated(self): + """`resources.get("errors") or []` - unlike the analysis keys.""" + assert _run(_node(0x1000, 24, [])) == [] + + +class TestStringTableIndependence: + """ + UNREADABLE (could not enumerate) and CORRUPT (a table is misplaced) are + different facts and must co-fire on a partial walk. + """ + + def test_both_fire_together(self): + issues = _run_struct(_node(0x1000, 24, []), + errors=["string_table_walk_failed"], + string_tables=[{"rva": 0x9999, "size": 0x40}]) + assert set(make_issue_list(issues)) == {UNREADABLE_ST, CORRUPT_ST} + + def test_unreadable_does_not_return_early(self): + """ + The tag check must not suppress the table bounds loop. Note the + in-bounds fixture here would pass either way; test_both_fire_together + above is what actually catches an early return. + """ + issues = _run_struct(_node(0x1000, 24, []), + errors=["string_table_walk_failed"], + string_tables=[{"rva": 0x1100, "size": 0x40}]) + assert make_issue_list(issues) == [UNREADABLE_ST] + + def test_unreadable_emitted_before_corrupt(self): + issues = _run_struct(_node(0x1000, 24, []), + errors=["string_table_walk_failed"], + string_tables=[{"rva": 0x9999, "size": 0x40}]) + assert make_issue_list(issues)[0] == UNREADABLE_ST + + def test_corrupt_alone_without_walk_failure(self): + issues = _run(_node(0x1000, 24, []), + string_tables=[{"rva": 0x9999, "size": 0x40}]) + assert make_issue_list(issues) == [CORRUPT_ST] + + def test_corrupt_breaks_after_the_first(self): + """One issue per file, not per table.""" + issues = _run(_node(0x1000, 24, []), + string_tables=[{"rva": 0x9999, "size": 0x40}, + {"rva": 0x8888, "size": 0x40}]) + assert len(_details_for(issues, CORRUPT_ST)) == 1 + + # ================================================================= # Output contract # ================================================================= @@ -737,3 +1017,13 @@ def test_entries_processed_in_order(self): rvas = [d["data_rva"] for d in _details_for( issues, ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS)] assert rvas == [0x9991, 0x9992] + + def test_directory_errors_precede_entry_findings(self): + lang = _node(0x1080, 24, [_leaf(0x9999, 0x50)]) + lang["errors"] = ["entry_decode_failed"] + name = _node(0x1040, 24, [_subdir(lang)]) + root = _node(0x1000, 24, [_subdir(name)]) + codes = make_issue_list(_run(root)) + assert codes.index( + ReasonCodes.RESOURCE_DIRECTORY_ENTRY_UNREADABLE) < codes.index( + ReasonCodes.RESOURCE_DATA_OUT_OF_BOUNDS) From 8a004921ddddb711dc82e4eb0b6f76f868356fc0 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 13:12:30 +0100 Subject: [PATCH 18/40] Version info tag contract pairs added, resulted in validator refactor to surface 3 new sub-reason codes. --- docs/specs/reason-codes.md | 3 + iocx/engine.py | 4 +- iocx/parsers/pe_version_info.py | 2 +- iocx/validators/version_info.py | 66 +++++++- tests/contract/tag_contract.py | 38 ++++- tests/contract/test_tag_contract.py | 5 +- tests/unit/parsers/test_pe_version_info.py | 35 ++-- .../validators/test_validator_version_info.py | 158 +++++++++++++++++- 8 files changed, 277 insertions(+), 34 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index ea2d74d..22c5429 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -300,6 +300,9 @@ Details carry `declared_size` and `decoded_entries`; their difference is the los | undecoded | The parser could not decode the envelope; short-circuits the FIXEDINFO / STRINGFILEINFO / VARFILEINFO checks | | szkey_mismatch | `szKey` is not "VS_VERSION_INFO" | | length_inconsistent | `wLength` disagrees with the buffer size | +| child_header_unpack | A child's 6-byte header could not be unpacked; the child walk stopped there | +| child_length_invalid | A child's wLength was below the 6-byte minimum or ran past the envelope; the walk stopped | +| unknown_child | A child whose szKey is neither StringFileInfo nor VarFileInfo. Does not stop the walk, so it may repeat — `errors` carries every occurrence | ### RESOURCE_VERSIONINFO_INVALID_FIXEDINFO sub‑reasons diff --git a/iocx/engine.py b/iocx/engine.py index 2432326..d187d18 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -11,7 +11,7 @@ from .parsers.pe_parser import parse_pe, analyse_pe_sections, analyse_data_directories, sanitize_sections, analyse_data_directories_raw from .parsers.string_extractor import extract_strings from .parsers.pe_resources import build_resource_structure -from .parsers.pe_version_info import build_version_info +from .parsers.pe_version_info import build_version_info_structure from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata from .parsers.pe_exports import build_export_structure @@ -171,7 +171,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: } self._internal_metadata["resources_struct"] = build_resource_structure(pe) - self._internal_metadata["version_info_struct"] = build_version_info(pe) + self._internal_metadata["version_info_struct"] = build_version_info_structure(pe) self._internal_metadata["export_struct"] = build_export_structure(pe) self._internal_metadata["import_struct"] = build_import_structure(pe) self._internal_metadata["delay_import_struct"] = build_delay_import_structure(pe) diff --git a/iocx/parsers/pe_version_info.py b/iocx/parsers/pe_version_info.py index 7f93219..f053b49 100644 --- a/iocx/parsers/pe_version_info.py +++ b/iocx/parsers/pe_version_info.py @@ -30,7 +30,7 @@ _VS_FFI_STRUCT_VERSION = 0x00010000 -def build_version_info(pe) -> Optional[Dict[str, Any]]: +def build_version_info_structure(pe) -> Optional[Dict[str, Any]]: """ Locate and decode the first RT_VERSION leaf in the resource tree. diff --git a/iocx/validators/version_info.py b/iocx/validators/version_info.py index 03481dd..ef70b49 100644 --- a/iocx/validators/version_info.py +++ b/iocx/validators/version_info.py @@ -8,9 +8,30 @@ custom-action DLLs and many cross-compiled binaries legitimately omit it. We only emit structural codes when an RT_VERSION resource is present and malformed. + +Parser tombstone tags and where each is consumed +------------------------------------------------ +The parser records faults in several lists at different levels, and the +level determines which branch here reads them: + + decoded is False -> the whole top-level `errors` list is forwarded by the + "undecoded" branch: leaf_struct_unpack, read_failed, too_short, + header_unpack. + + decoded is True -> the "undecoded" branch is skipped, so tags appended to + the top-level list AFTER that point need their own consumers: + * fixed_file_info_unpack / fixed_file_info_truncated, read by the + FFI parse_failed branch via its startswith filter; + * child_header_unpack / child_length_invalid / unknown_child, read + by the child-dispatch branch below. Without it a blob carrying an + unrecognised child, or a child whose wLength runs past the + envelope, reports entirely clean. + + per-item lists -> string_file_info[].errors, its tables[].errors, and + var_file_info[].errors are each forwarded wholesale by their loops. """ -from typing import List +from typing import Any, Dict, List from iocx.reason_codes import ReasonCodes from iocx.validators.schema import StructuralIssue @@ -19,8 +40,21 @@ from .decorators import depends_on +# Child-dispatch faults, priority-resolved. The first two each `break` the +# parser's walk, so at most one of them occurs and never both; the third +# does not break and may repeat. A walk-terminating fault is the more +# fundamental fact - it means the remaining children were never examined - +# so both precede unknown_child. +_CHILD_ERROR_PRIORITY = [ + "child_header_unpack", + "child_length_invalid", + "unknown_child", +] + + @depends_on("internal", "analysis") -def validate_version_info(metadata: InternalMetadata, analysis: AnalysisDict) -> List[StructuralIssue]: +def validate_version_info(metadata: InternalMetadata, + analysis: AnalysisDict) -> List[StructuralIssue]: issues: List[StructuralIssue] = [] vi = metadata.get("version_info_struct") @@ -64,16 +98,32 @@ def validate_version_info(metadata: InternalMetadata, analysis: AnalysisDict) -> details={"sub_reason": "length_inconsistent"}, )) + # ---- Child dispatch ---- + # These tags are appended to the top-level errors list AFTER `decoded` + # is set, so the undecoded branch above has already been skipped and no + # other branch reads them. Priority-resolved to one issue per blob; the + # full matching set is carried in details, since unknown_child can + # repeat and its count is the signal. + vi_errors = vi.get("errors") or [] + child_reason = _first_matching(vi_errors, _CHILD_ERROR_PRIORITY) + if child_reason != "unknown": + issues.append(StructuralIssue( + issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_HEADER, + details={"sub_reason": child_reason, + "errors": [e for e in vi_errors + if e in _CHILD_ERROR_PRIORITY]}, + )) + # ---- VS_FIXEDFILEINFO ---- ffi = vi.get("fixed_file_info") if ffi is None: # Only flag if there were parse errors; some binaries legitimately # omit VS_FIXEDFILEINFO with wValueLength == 0. - if any(e.startswith("fixed_file_info") for e in vi.get("errors", [])): + if any(e.startswith("fixed_file_info") for e in vi_errors): issues.append(StructuralIssue( issue=ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO, details={"sub_reason": "parse_failed", - "errors": [e for e in vi["errors"] + "errors": [e for e in vi_errors if e.startswith("fixed_file_info")]}, )) else: @@ -117,3 +167,11 @@ def validate_version_info(metadata: InternalMetadata, analysis: AnalysisDict) -> )) return issues + + +def _first_matching(errors: List[str], candidates: List[str]) -> str: + """Return the first tag from `candidates` present in `errors`.""" + for c in candidates: + if c in errors: + return c + return "unknown" diff --git a/tests/contract/tag_contract.py b/tests/contract/tag_contract.py index ef9ebd4..07a0342 100644 --- a/tests/contract/tag_contract.py +++ b/tests/contract/tag_contract.py @@ -35,10 +35,11 @@ class ValidatorConsumption: matched: Set[str] = field(default_factory=set) iterated_sinks: Set[str] = field(default_factory=set) forwarded_sinks: Set[str] = field(default_factory=set) + item_forwarded_sinks: Set[str] = field(default_factory=set) @property def wholesale_sinks(self) -> Set[str]: - return self.iterated_sinks | self.forwarded_sinks + return (self.iterated_sinks | self.forwarded_sinks | self.item_forwarded_sinks) @dataclass @@ -224,6 +225,37 @@ def _forwarded_sinks_in_dict(node): return found +def _item_forwarded_sinks(tree: ast.AST) -> Set[str]: + """ + Sinks forwarded wholesale from a LOOP VARIABLE, e.g. + + for sfi in vi.get("string_file_info", []): + emit(details={"errors": sfi["errors"]}) + + That copies a PER-ITEM list in full, so its tags cannot drop - distinct + from `details={"errors": list(imp["errors"])}`, which forwards the + struct's own top-level list. Only the loop-variable reference tells the + two apart. + """ + found: Set[str] = set() + for node in ast.walk(tree): + if not (isinstance(node, ast.For) and isinstance(node.target, ast.Name)): + continue + loop_var = node.target.id + for sub in ast.walk(node): + if not isinstance(sub, ast.Dict): + continue + for k, v in zip(sub.keys, sub.values): + if not (isinstance(k, ast.Constant) and k.value in _ALL_SINKS): + continue + if isinstance(v, ast.List): + continue # literal payload, not a forward + if any(isinstance(x, ast.Name) and x.id == loop_var + for x in ast.walk(v)): + found.add(k.value) + return found + + def extract_validator_consumption(source) -> ValidatorConsumption: tree = ast.parse(source) out = ValidatorConsumption() @@ -246,6 +278,8 @@ def extract_validator_consumption(source) -> ValidatorConsumption: out.iterated_sinks.add(sink) if isinstance(node, ast.Dict): out.forwarded_sinks |= _forwarded_sinks_in_dict(node) + + out.item_forwarded_sinks = _item_forwarded_sinks(tree) return out @@ -260,6 +294,8 @@ def check_contract(parser_source, validator_source, parser_name="parser", unchecked |= tags.top_errors if _ERROR_SINKS & consumption.iterated_sinks: unchecked |= tags.errors + if _ERROR_SINKS & consumption.item_forwarded_sinks: + unchecked |= tags.item_errors checkable = (tags.errors | tags.truncations) - unchecked return ContractResult( parser=parser_name, validator=validator_name, diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 3c0490d..81e3938 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -22,10 +22,10 @@ from iocx.parsers import (pe_imports, pe_relocations, pe_tls, pe_debug, pe_exports, pe_delay_imports, pe_certificates, pe_exception, - pe_resources) + pe_resources, pe_version_info) from iocx.validators import (imports, relocations, tls, debug, exports, delay_imports, signature, exception_table, - resources) + resources, version_info) _PAIRS = [ # (label, parser module, validator module, template_vars) @@ -46,6 +46,7 @@ ("pe_certificates", pe_certificates, signature, {}), ("pe_exception", pe_exception, exception_table, {}), ("pe_resources", pe_resources, resources, {}), + ("pe_version_info", pe_version_info, version_info, {}), ] # Tags a parser emits that no validator consumes, deliberately. diff --git a/tests/unit/parsers/test_pe_version_info.py b/tests/unit/parsers/test_pe_version_info.py index 6745f9a..00cc1ad 100644 --- a/tests/unit/parsers/test_pe_version_info.py +++ b/tests/unit/parsers/test_pe_version_info.py @@ -24,7 +24,7 @@ import pytest from iocx.parsers.pe_version_info import ( - build_version_info, + build_version_info_structure, _align4, _decode_string_file_info, _decode_var_file_info, @@ -130,17 +130,6 @@ def _build_var(key: str, translations: List[Tuple[int, int]]) -> bytes: return struct.pack(" bytes: - """Build a VarFileInfo child containing the given Vars.""" - key_bytes = _utf16_sz("VarFileInfo") - header_and_key = struct.pack(" List[Dict[str, Any]]: ] +def _sub_reasons(issues): + return [d.get("sub_reason") for d in _details_for(issues, _HEADER)] + + +def _run(vi, analysis=None): + return validate_version_info({"version_info_struct": vi}, + analysis or _make_analysis()) + + # ================================================================= # Absence / clean cases # ================================================================= @@ -595,6 +605,152 @@ def test_placement_plus_sfi_plus_vfi_all_emit(self): assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO in codes +# ================================================================= +# Child dispatch +# ================================================================= + +class TestChildDispatch: + + @pytest.mark.parametrize("tag", _CHILD_ERROR_PRIORITY) + def test_every_child_tag_emits(self, tag): + """ + Regression guard: each of these previously produced NO issue at all, + because they are appended after decoded=True. + """ + issues = _run(_make_vi(errors=[tag])) + assert _sub_reasons(issues) == [tag] + + def test_details_carry_all_matching_tags(self): + """ + unknown_child can repeat, and the count is the signal - a blob with + five unrecognised children is more suspicious than one. + """ + issues = _run(_make_vi(errors=["unknown_child"] * 3)) + details = _details_for(issues, _HEADER) + assert len(details) == 1 + assert details[0]["errors"] == ["unknown_child"] * 3 + + def test_one_issue_per_blob(self): + issues = _run(_make_vi(errors=["unknown_child", "child_length_invalid"])) + assert len(_details_for(issues, _HEADER)) == 1 + + @pytest.mark.parametrize("errors,expected", [ + (["unknown_child", "child_length_invalid"], "child_length_invalid"), + (["child_length_invalid", "unknown_child"], "child_length_invalid"), + (["unknown_child", "child_header_unpack"], "child_header_unpack"), + (["child_header_unpack", "child_length_invalid"], "child_header_unpack"), + ]) + def test_walk_terminating_faults_win(self, errors, expected): + """ + A fault that stopped the walk outranks one that did not: the + remaining children were never examined. + """ + assert _sub_reasons(_run(_make_vi(errors=errors))) == [expected] + + @pytest.mark.parametrize("higher,lower", list( + zip(_CHILD_ERROR_PRIORITY, _CHILD_ERROR_PRIORITY[1:]))) + def test_priority_order_adjacent_pairs(self, higher, lower): + for errors in ([higher, lower], [lower, higher]): + assert _sub_reasons(_run(_make_vi(errors=errors))) == [higher] + + def test_priority_list_content(self): + """ + Order is a contract; a behavioural test cannot catch a reorder, + since _first_matching is definitionally consistent with whatever + order the list has. + """ + assert _CHILD_ERROR_PRIORITY == [ + "child_header_unpack", + "child_length_invalid", + "unknown_child", + ] + + def test_clean_blob_emits_nothing(self): + assert _run(_make_vi()) == [] + + def test_unrelated_tags_ignored(self): + issues = _run(_make_vi(errors=["some_future_tag"])) + assert _codes(issues) == [] + + def test_ffi_tag_does_not_trigger_the_child_branch(self): + """ + The two branches read the same list with different filters and must + not overlap: fixed_file_info_* belongs to the FFI branch alone. + """ + issues = _run(_make_vi(errors=["fixed_file_info_truncated"], + fixed_file_info=None)) + assert _sub_reasons(issues) == [] + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO in _codes(issues) + + def test_child_and_ffi_tags_both_reported(self): + issues = _run(_make_vi( + errors=["unknown_child", "fixed_file_info_truncated"], + fixed_file_info=None)) + assert "unknown_child" in _sub_reasons(issues) + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_FIXEDINFO in _codes(issues) + + def test_child_details_exclude_non_child_tags(self): + issues = _run(_make_vi( + errors=["unknown_child", "fixed_file_info_truncated"], + fixed_file_info=None)) + child = [d for d in _details_for(issues, _HEADER) + if d.get("sub_reason") == "unknown_child"][0] + assert child["errors"] == ["unknown_child"] + + +class TestChildDispatchInteraction: + + def test_undecoded_short_circuits_the_child_branch(self): + """ + When decoded is False the undecoded branch forwards the whole list + and returns, so the child branch must not also fire. + """ + issues = _run(_make_vi(decoded=False, + errors=["unknown_child", "too_short"])) + assert _sub_reasons(issues) == ["undecoded"] + assert _details_for(issues, _HEADER)[0]["errors"] == [ + "unknown_child", "too_short"] + + def test_child_branch_coexists_with_header_checks(self): + issues = _run(_make_vi(header_ok=False, length_consistent=False, + errors=["unknown_child"])) + assert _sub_reasons(issues) == [ + "szkey_mismatch", "length_inconsistent", "unknown_child"] + + def test_child_branch_follows_placement(self): + """Emission order: placement, header checks, then child dispatch.""" + issues = _run(_make_vi(rva=0x5000, errors=["unknown_child"])) + assert _sub_reasons(issues) == ["placement", "unknown_child"] + + def test_child_branch_does_not_suppress_sfi_or_vfi(self): + issues = _run(_make_vi( + errors=["unknown_child"], + string_file_info=[{"tables": [], "errors": ["string_table_header"]}], + var_file_info=[{"vars": [], "errors": ["var_header"]}])) + codes = _codes(issues) + assert _HEADER in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO in codes + assert ReasonCodes.RESOURCE_VERSIONINFO_INVALID_VARFILEINFO in codes + + def test_missing_errors_key_tolerated(self): + vi = _make_vi() + del vi["errors"] + assert _run(vi) == [] + + def test_no_reserved_reason_key(self): + issues = _run(_make_vi(errors=["unknown_child"])) + assert issues + assert all("reason" not in i["details"] for i in issues) + + def test_deterministic(self): + import json + vi = _make_vi(errors=["unknown_child", "child_length_invalid"], + header_ok=False) + first = json.dumps(_run(vi), sort_keys=True) + for _ in range(20): + assert json.dumps(_run(vi), sort_keys=True) == first + + # ================================================================= # Output shape contract # ================================================================= From 888edf52268c194c90baa5ac3cb0e021f175fdf2 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 13:52:42 +0100 Subject: [PATCH 19/40] Pe_version_info: Hard cap on children walked --- docs/specs/reason-codes.md | 19 +++- iocx/parsers/pe_version_info.py | 12 +++ iocx/validators/version_info.py | 1 + tests/unit/parsers/test_pe_version_info.py | 94 +++++++++++++++++++ .../validators/test_validator_version_info.py | 1 + 5 files changed, 123 insertions(+), 4 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 22c5429..c7119ba 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -294,15 +294,26 @@ Details carry `declared_size` and `decoded_entries`; their difference is the los ### RESOURCE_VERSIONINFO_INVALID_HEADER sub‑reasons +The first four describe the VS_VERSIONINFO envelope itself. The last four +describe the child-dispatch walk and are appended to the top-level `errors` +list *after* `decoded` is set, so they are read by their own branch rather +than by the `undecoded` forward. + | Sub‑reason | Meaning | |------------|---------| | placement | The VS_VERSIONINFO blob does not lie wholly inside `.rsrc` | -| undecoded | The parser could not decode the envelope; short-circuits the FIXEDINFO / STRINGFILEINFO / VARFILEINFO checks | +| undecoded | The parser could not decode the envelope; short-circuits the FIXEDINFO / STRINGFILEINFO / VARFILEINFO checks. The `errors` key lists the contributing parser tags | | szkey_mismatch | `szKey` is not "VS_VERSION_INFO" | | length_inconsistent | `wLength` disagrees with the buffer size | -| child_header_unpack | A child's 6-byte header could not be unpacked; the child walk stopped there | -| child_length_invalid | A child's wLength was below the 6-byte minimum or ran past the envelope; the walk stopped | -| unknown_child | A child whose szKey is neither StringFileInfo nor VarFileInfo. Does not stop the walk, so it may repeat — `errors` carries every occurrence | +| child_max_exceeded | The child walk hit the parser's hard limit (256) and stopped. Children beyond that point were not examined | +| child_header_unpack | A child's 6-byte header could not be unpacked; the walk stopped there | +| child_length_invalid | A child's `wLength` was below the 6-byte minimum or extended past the envelope; the walk stopped there | +| unknown_child | A child whose `szKey` is neither "StringFileInfo" nor "VarFileInfo". Does **not** stop the walk, so it may repeat — the issue's `errors` key carries every occurrence, bounded by the child cap | + +The four child sub-reasons are priority-resolved, in the order listed: +a fault that terminated the walk outranks one that did not, because the +remaining children were never examined. At most one issue is emitted per +blob, with the full matching set carried in `errors`. ### RESOURCE_VERSIONINFO_INVALID_FIXEDINFO sub‑reasons diff --git a/iocx/parsers/pe_version_info.py b/iocx/parsers/pe_version_info.py index f053b49..0f6494a 100644 --- a/iocx/parsers/pe_version_info.py +++ b/iocx/parsers/pe_version_info.py @@ -29,6 +29,12 @@ _VS_FFI_SIGNATURE = 0xFEEF04BD _VS_FFI_STRUCT_VERSION = 0x00010000 +# Hard cap on children walked, matching the bound every other parser +# applies. `unknown_child` does not terminate the walk, so without this the +# errors list scales with the resource leaf size - which is attacker- +# controlled and unbounded when length_consistent is False. +_MAX_CHILDREN = 256 + def build_version_info_structure(pe) -> Optional[Dict[str, Any]]: """ @@ -199,7 +205,13 @@ def _decode_vs_versioninfo(buf: bytes) -> Dict[str, Any]: end = min(w_length, len(buf)) if out["length_consistent"] else len(buf) # ---- Children: StringFileInfo / VarFileInfo ---- + child_count = 0 while pos + 6 <= end: + if child_count >= _MAX_CHILDREN: + out["errors"].append("child_max_exceeded") + break + child_count += 1 + try: c_len = _u16(buf, pos) _c_vlen = _u16(buf, pos + 2) diff --git a/iocx/validators/version_info.py b/iocx/validators/version_info.py index ef70b49..7afbe4e 100644 --- a/iocx/validators/version_info.py +++ b/iocx/validators/version_info.py @@ -46,6 +46,7 @@ # fundamental fact - it means the remaining children were never examined - # so both precede unknown_child. _CHILD_ERROR_PRIORITY = [ + "child_max_exceeded", "child_header_unpack", "child_length_invalid", "unknown_child", diff --git a/tests/unit/parsers/test_pe_version_info.py b/tests/unit/parsers/test_pe_version_info.py index 00cc1ad..7f9364b 100644 --- a/tests/unit/parsers/test_pe_version_info.py +++ b/tests/unit/parsers/test_pe_version_info.py @@ -36,6 +36,7 @@ _VS_FFI_STRUCT_VERSION, _VS_VERSION_INFO_KEY, RT_VERSION, + _MAX_CHILDREN ) @@ -54,6 +55,24 @@ def _pad4(buf: bytes) -> bytes: return buf + b"\x00" * pad +def _envelope(w_length: int = 0) -> bytes: + """VS_VERSIONINFO header with no FFI. w_length 0 leaves + length_consistent False, so the walk covers the whole buffer.""" + return _pad4(struct.pack(" bytes: + """A minimal well-formed child whose wLength covers header + key + pad.""" + key_bytes = _utf16_sz(key) + length = ((6 + len(key_bytes)) + 3) & ~3 + return _pad4(struct.pack(" bytes: + return _envelope() + _child(child_key) * count + + def _build_ffi( signature: int = _VS_FFI_SIGNATURE, struct_version: int = _VS_FFI_STRUCT_VERSION, @@ -914,6 +933,81 @@ def test_deterministic_leaf_selection_with_multiple_leaves(self): assert out["string_file_info"][0]["tables"][0]["strings"]["ProductName"] == "FromLeafB" +# ================================================================= +# Child Cap Walk +# ================================================================= + +class TestChildWalkCap: + + def test_below_cap_all_children_walked(self): + out = _decode_vs_versioninfo(_blob("Y", _MAX_CHILDREN - 1)) + assert out["errors"].count("unknown_child") == _MAX_CHILDREN - 1 + assert "child_max_exceeded" not in out["errors"] + + def test_exactly_at_cap_not_flagged(self): + """The cap is a ceiling on children WALKED, so exactly N is fine.""" + out = _decode_vs_versioninfo(_blob("Y", _MAX_CHILDREN)) + assert out["errors"].count("unknown_child") == _MAX_CHILDREN + assert "child_max_exceeded" not in out["errors"] + + def test_one_over_cap_flagged(self): + out = _decode_vs_versioninfo(_blob("Y", _MAX_CHILDREN + 1)) + assert out["errors"].count("unknown_child") == _MAX_CHILDREN + assert "child_max_exceeded" in out["errors"] + + def test_far_over_cap_bounded(self): + """ + Regression guard: before the cap this produced one tag per child, + with no ceiling. + """ + out = _decode_vs_versioninfo(_blob("Y", 5000)) + assert out["errors"].count("unknown_child") == _MAX_CHILDREN + assert len(out["errors"]) == _MAX_CHILDREN + 1 + + def test_cap_bounds_the_errors_list_size(self): + """The property that matters downstream: output size is bounded by + the cap, not by the input.""" + small = _decode_vs_versioninfo(_blob("Y", _MAX_CHILDREN + 10)) + large = _decode_vs_versioninfo(_blob("Y", 20000)) + assert len(small["errors"]) == len(large["errors"]) + + def test_cap_counts_well_formed_children_too(self): + """ + The cap is on the WALK, not on the error count - a blob of + thousands of valid StringFileInfo children is bounded as well. + """ + out = _decode_vs_versioninfo(_blob("StringFileInfo", _MAX_CHILDREN + 10)) + assert len(out["string_file_info"]) == _MAX_CHILDREN + assert "child_max_exceeded" in out["errors"] + + def test_normal_blob_unaffected(self): + out = _decode_vs_versioninfo(_blob("StringFileInfo", 1)) + assert out["errors"] == [] + assert len(out["string_file_info"]) == 1 + + def test_cap_tag_appears_once(self): + out = _decode_vs_versioninfo(_blob("Y", 5000)) + assert out["errors"].count("child_max_exceeded") == 1 + + def test_cap_terminates_before_decoding_the_next_child(self): + """ + The check precedes the header read, so the child that would have + been number N+1 is not partially decoded. + """ + out = _decode_vs_versioninfo(_blob("StringFileInfo", _MAX_CHILDREN + 5)) + assert len(out["string_file_info"]) == _MAX_CHILDREN + + def test_decoded_flag_unaffected_by_the_cap(self): + """Hitting the cap is a truncation of the walk, not a decode + failure - the envelope itself parsed fine.""" + out = _decode_vs_versioninfo(_blob("Y", 5000)) + assert out["decoded"] is True + assert out["header_ok"] is True + + def test_cap_constant_is_bounded_and_positive(self): + assert 0 < _MAX_CHILDREN <= 4096 + + # ================================================================= # Determinism tests # ================================================================= diff --git a/tests/unit/validators/test_validator_version_info.py b/tests/unit/validators/test_validator_version_info.py index cfc42bc..95253d2 100644 --- a/tests/unit/validators/test_validator_version_info.py +++ b/tests/unit/validators/test_validator_version_info.py @@ -660,6 +660,7 @@ def test_priority_list_content(self): order the list has. """ assert _CHILD_ERROR_PRIORITY == [ + "child_max_exceeded", "child_header_unpack", "child_length_invalid", "unknown_child", From 71c8f34a0009d396181f070206dbac3013b33b64 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 16:39:17 +0100 Subject: [PATCH 20/40] (chore)Pe_parser: Remove base64, _shannon entropy imports. Rename struct vars to localised_struct. Wrap pe.__data__ in getattr. Fix data directory offset bug that was treating PE32+ as PE32. Try/catch around rva_size unpacking --- iocx/parsers/pe_parser.py | 41 ++++++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/iocx/parsers/pe_parser.py b/iocx/parsers/pe_parser.py index f7387a4..d006505 100644 --- a/iocx/parsers/pe_parser.py +++ b/iocx/parsers/pe_parser.py @@ -3,10 +3,8 @@ import pefile import math -import base64 import struct from .string_extractor import extract_strings_from_bytes -from ..analysis.obfuscation import _shannon_entropy from typing import List, Dict, Any, Optional from .language_map import PRIMARY_LANG, SUBLANG, DEFAULT_REGION from .pe_constants import ( @@ -225,8 +223,8 @@ def _parse_bound_imports(pe): dll_raw = getattr(entry, "name", None) or getattr(entry, "dll", None) dll = _decode_dll_name(dll_raw) - struct = getattr(entry, "struct", None) - ts = getattr(struct, "TimeDateStamp", 0) if struct else 0 + entry_struct = getattr(entry, "struct", None) + ts = getattr(entry_struct, "TimeDateStamp", 0) if entry_struct else 0 bound_imports.append({"dll": dll, "timestamp": ts}) @@ -315,14 +313,14 @@ def _parse_signatures(pe): return signatures for sec in pe.DIRECTORY_ENTRY_SECURITY: - struct = getattr(sec, "struct", None) - if not struct: + cert_struct = getattr(sec, "struct", None) + if not cert_struct: continue signatures.append( { - "address": getattr(struct, "VirtualAddress", 0), - "size": getattr(struct, "Size", 0), + "address": getattr(cert_struct, "VirtualAddress", 0), + "size": getattr(cert_struct, "Size", 0), } ) @@ -510,22 +508,33 @@ def _parse_data_directories_raw(pe) -> list[dict[str, int]]: if not opt: return dirs - # Raw file bytes - raw = pe.__data__ + # Raw file bytes. + # `If not raw` also catches an empty buffer, whereas the other parsers + # use `is None`. `Not raw` is arguably better here since a zero-length + # file genuinely has no optional header. + raw = getattr(pe, "__data__", None) + if not raw: + return dirs # File offset of Optional Header opt_offset = opt.get_file_offset() - # For PE32, DataDirectory starts 96 bytes into Optional Header - # (Magic..LoaderFlags = 96 bytes) - DATA_DIR_OFFSET = 96 + # DataDirectory offset within the optional header: 96 for PE32, + # 112 for PE32+. PE32+ widens ImageBase and the four stack/heap + # fields to QWORD (+20) and drops BaseOfData (-4). + magic = getattr(opt, "Magic", 0x10B) + data_dir_offset = 112 if magic == 0x20B else 96 # Each entry is 8 bytes: (DWORD RVA, DWORD Size) - entry_offset = opt_offset + DATA_DIR_OFFSET + entry_offset = opt_offset + data_dir_offset for i in range(16): - rva = struct.unpack_from(" Date: Wed, 2 Sep 2026 16:40:21 +0100 Subject: [PATCH 21/40] Since ddir offset bug fix, update contract test that was inflating actual_directories by 1 --- .../layer3_adversarial/invalid_optional_header.full.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json index ac20c08..9531b44 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json @@ -158,7 +158,7 @@ "metadata": { "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 1, - "actual_directories": 4 + "actual_directories": 3 } }, { From 4da7bb21d0709b2cb1130af05edf927e25019288 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 16:41:09 +0100 Subject: [PATCH 22/40] Add a test to ensure analysis sections retain raw_address and virtual_address fields --- tests/unit/parsers/test_pe_parser.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/unit/parsers/test_pe_parser.py b/tests/unit/parsers/test_pe_parser.py index de6d1b0..80a56a5 100644 --- a/tests/unit/parsers/test_pe_parser.py +++ b/tests/unit/parsers/test_pe_parser.py @@ -386,6 +386,21 @@ class FakePE: assert result == [] # early return path +def test_analysis_sections_retain_placement_fields(): + """ + sanitize_sections strips raw_address and virtual_address for CLI + output. The analysis layer needs both - rva_graph, sections and + resources all key off them - so sanitisation must never be applied + on that path. + """ + pe = FakePE() + sections = analyse_pe_sections(pe) + assert sections + for sec in sections: + assert "raw_address" in sec + assert "virtual_address" in sec + + # ================================================================= # Defensive: guarded get_offset_from_rva # ================================================================= From e69458ce90e81f8e9c3697f78f13963706c623a7 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 16:43:00 +0100 Subject: [PATCH 23/40] Add load_config and optional_header tag contract pairs: the checker doesn't care that their output lands in analysis or metadata rather than internal, only how tags flow. --- tests/contract/test_tag_contract.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 81e3938..40a2fe1 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -22,10 +22,10 @@ from iocx.parsers import (pe_imports, pe_relocations, pe_tls, pe_debug, pe_exports, pe_delay_imports, pe_certificates, pe_exception, - pe_resources, pe_version_info) + pe_resources, pe_version_info, pe_load_config, pe_optional_header) from iocx.validators import (imports, relocations, tls, debug, exports, delay_imports, signature, exception_table, - resources, version_info) + resources, version_info, load_config_directory, optional_header) _PAIRS = [ # (label, parser module, validator module, template_vars) @@ -47,6 +47,17 @@ ("pe_exception", pe_exception, exception_table, {}), ("pe_resources", pe_resources, resources, {}), ("pe_version_info", pe_version_info, version_info, {}), + ("pe_load_config", pe_load_config, load_config_directory,{}), + ("pe_optional_header", pe_optional_header, optional_header, {}), + + # Remaining: + # pe_load_config - analyse_load_config becomes part of analysis_dict.load_config which the load_config_directory validator keys off + # pe_optional_header = extract_optional_header_metadata is added to the end of internalMetadata. the optional_header validator only uses number_of_rva_and_sizes from this metadata + # pe_parser - builds a metadata structure of most, if not all directories for public CLI consumption + # This just leaves the validators without dedicated parsers: + # 1. entropy - keys off AnalysisDict + # 2. rva_graph - keys off PublicMetadata and AnalysisDict + # 3. sections - keys off PublicMetadata and AnalysisDict ] # Tags a parser emits that no validator consumes, deliberately. From 3a03db67abb9115472c1560d3e01c96f52b83e7e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 16:54:16 +0100 Subject: [PATCH 24/40] Tidy up test_tag_contract notes. All parsers/validators now have tag contract tests --- tests/contract/test_tag_contract.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 40a2fe1..57546b3 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -47,17 +47,13 @@ ("pe_exception", pe_exception, exception_table, {}), ("pe_resources", pe_resources, resources, {}), ("pe_version_info", pe_version_info, version_info, {}), + # pe_load_config + pe_optional_header: can slot into _PAIRS normally as the checker doesn't care that their output lands in analysis or metadata rather than internal, only how tags flow. ("pe_load_config", pe_load_config, load_config_directory,{}), ("pe_optional_header", pe_optional_header, optional_header, {}), - - # Remaining: - # pe_load_config - analyse_load_config becomes part of analysis_dict.load_config which the load_config_directory validator keys off - # pe_optional_header = extract_optional_header_metadata is added to the end of internalMetadata. the optional_header validator only uses number_of_rva_and_sizes from this metadata - # pe_parser - builds a metadata structure of most, if not all directories for public CLI consumption - # This just leaves the validators without dedicated parsers: - # 1. entropy - keys off AnalysisDict - # 2. rva_graph - keys off PublicMetadata and AnalysisDict - # 3. sections - keys off PublicMetadata and AnalysisDict + # Notes on the remaining:- + # pe_parser - has no validator, so the tag-contract check can't help. As of v0.7.6.2, the pe_parser was hardened, especially around bounds checks. + # entropy/rva_graph/sections validators - these consume the analysis and metadata layers rather than a struct-level decoder, so the tag contract genuinely doesn't apply + # — there are no tombstone tags to drop. ] # Tags a parser emits that no validator consumes, deliberately. From bed3c1385f8c7b365395a1afe93e72a85d945982 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 17:13:18 +0100 Subject: [PATCH 25/40] Pe_parser bounds checking --- iocx/parsers/pe_parser.py | 66 +++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/iocx/parsers/pe_parser.py b/iocx/parsers/pe_parser.py index d006505..8d34112 100644 --- a/iocx/parsers/pe_parser.py +++ b/iocx/parsers/pe_parser.py @@ -14,6 +14,20 @@ MACHINE_NAMES ) +# Bounds on public output. `resources` and `resource_strings` are emitted +# directly to CLI consumers, and both otherwise scale with attacker- +# controlled input: the string list accumulates across every resource in +# the tree, and the entry list appends one dict per name entry with no +# ceiling. Each cap is reported via a truncation flag rather than applied +# silently. +_MAX_RESOURCE_STRINGS = 10_000 +_MAX_RESOURCE_ENTRIES = 1_024 + +# A well-formed tree is Type -> Name -> Language, so three levels. Anything +# deeper is malformed; the cap also prevents unbounded recursion, which +# would raise RecursionError out of parse_pe rather than degrade. +_MAX_RESOURCE_DEPTH = 8 + # --------------------------------------------------------------------------- # Low-level helpers # --------------------------------------------------------------------------- @@ -73,7 +87,8 @@ def _safe_file_size(pe) -> int: return size_attr() if callable(size_attr) else size_attr -def _walk_resources(pe, directory, resource_strings, max_allowed=None, visited=None): +def _walk_resources(pe, directory, resource_strings, max_allowed=None, + visited=None, depth=0): if visited is None: visited = set() @@ -82,15 +97,28 @@ def _walk_resources(pe, directory, resource_strings, max_allowed=None, visited=N # 10% of file, capped at 20 MB max_allowed = min(size // 10, 20_000_000) if size else 20_000_000 - # Prevent infinite recursion on malformed resource trees + if depth > _MAX_RESOURCE_DEPTH: + return + + # Identity is sufficient here: pefile has already materialised the tree, + # so what we walk is a finite object graph held alive for the duration + # of the call. The depth cap above covers the case identity does not - + # a legitimately deep chain of distinct directories. dir_id = id(directory) if dir_id in visited: return visited.add(dir_id) for entry in getattr(directory, "entries", []): + # Stop once the string budget is spent. Checked per entry so a tree + # of many small resources is bounded as well as one with a few + # large ones. + if len(resource_strings) >= _MAX_RESOURCE_STRINGS: + return + if hasattr(entry, "directory"): - _walk_resources(pe, entry.directory, resource_strings, max_allowed, visited) + _walk_resources(pe, entry.directory, resource_strings, + max_allowed, visited, depth + 1) elif hasattr(entry, "data"): data_rva = getattr(entry.data.struct, "OffsetToData", 0) size = getattr(entry.data.struct, "Size", 0) @@ -101,10 +129,14 @@ def _walk_resources(pe, directory, resource_strings, max_allowed=None, visited=N try: data = pe.get_data(data_rva, size) except Exception: - # Malformed resources (bad RVA/size) – skip safely + # Malformed resources (bad RVA/size) - skip safely continue - resource_strings.extend(extract_strings_from_bytes(data)) + # Slice the extension too: a single large blob would otherwise + # overshoot the budget in one call, before the loop-top check + # runs again. + budget = _MAX_RESOURCE_STRINGS - len(resource_strings) + resource_strings.extend(extract_strings_from_bytes(data)[:budget]) def _entropy(data: bytes | None) -> float: @@ -393,21 +425,28 @@ def _parse_header(pe, opt): def _parse_resources(pe): resources: list[dict[str, Any]] = [] resource_strings: list[str] = [] + truncated: list[str] = [] root = getattr(pe, "DIRECTORY_ENTRY_RESOURCE", None) if not root: - return resources, resource_strings + return resources, resource_strings, truncated # Walk the tree and collect resource_strings _walk_resources(pe, root, resource_strings) + if len(resource_strings) >= _MAX_RESOURCE_STRINGS: + truncated.append("resource_strings") # Extract structured resource entries if not hasattr(pe, "get_memory_mapped_image"): - return resources, resource_strings + return resources, resource_strings, truncated mm = pe.get_memory_mapped_image() or b"" + entries_capped = False for entry in getattr(pe.DIRECTORY_ENTRY_RESOURCE, "entries", []): + if entries_capped: + break + type_id = getattr(entry, "id", None) type_name = pefile.RESOURCE_TYPE.get(type_id, f"RT_UNKNOWN_{type_id}") @@ -415,6 +454,11 @@ def _parse_resources(pe): continue for res in getattr(entry.directory, "entries", []): + if len(resources) >= _MAX_RESOURCE_ENTRIES: + truncated.append("resources") + entries_capped = True + break + # Capture the resource's named identifier if present res_name = str(res.name) if getattr(res, "name", None) is not None else None lang = getattr(res, "id", None) @@ -471,7 +515,7 @@ def _parse_resources(pe): r["rva"] if r["rva"] is not None else -1, )) - return resources, resource_strings + return resources, resource_strings, truncated def _parse_data_directories(pe): dirs: list[dict[str, Any]] = [] @@ -566,7 +610,7 @@ def parse_pe(path): signatures = _parse_signatures(pe) opt, optional_header = _parse_optional_header(pe) header = _parse_header(pe, opt) - resources, resource_strings = _parse_resources(pe) + resources, resource_strings, resource_truncated = _parse_resources(pe) # Rich header try: @@ -582,6 +626,10 @@ def parse_pe(path): "sections": sections_list, "resources": resources, "resource_strings": resource_strings, + # Empty when nothing was capped. Names the lists that were + # truncated so a consumer can distinguish a capped result from + # a complete one. Comment out for now to preserve public contract + # "resource_truncated": resource_truncated, "import_details": import_details, "delayed_imports": delayed_imports, "bound_imports": bound_imports, From 75a8e47c42fb4f7d492b036b06ae9ce1bf319ad2 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 17:48:10 +0100 Subject: [PATCH 26/40] Add _PARSERLESS_VALIDATORS and _TAGLESS_PARSERS to test_tag_contract --- tests/contract/test_tag_contract.py | 156 ++++++++++++++++++++++++++-- 1 file changed, 150 insertions(+), 6 deletions(-) diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 57546b3..26759a1 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -14,7 +14,8 @@ from __future__ import annotations import inspect -from typing import Dict, List +import pkgutil +from typing import Dict, List, Set import pytest @@ -47,13 +48,9 @@ ("pe_exception", pe_exception, exception_table, {}), ("pe_resources", pe_resources, resources, {}), ("pe_version_info", pe_version_info, version_info, {}), - # pe_load_config + pe_optional_header: can slot into _PAIRS normally as the checker doesn't care that their output lands in analysis or metadata rather than internal, only how tags flow. + # Ref. _TAGLESS_PARSERS ("pe_load_config", pe_load_config, load_config_directory,{}), ("pe_optional_header", pe_optional_header, optional_header, {}), - # Notes on the remaining:- - # pe_parser - has no validator, so the tag-contract check can't help. As of v0.7.6.2, the pe_parser was hardened, especially around bounds checks. - # entropy/rva_graph/sections validators - these consume the analysis and metadata layers rather than a struct-level decoder, so the tag contract genuinely doesn't apply - # — there are no tombstone tags to drop. ] # Tags a parser emits that no validator consumes, deliberately. @@ -70,6 +67,153 @@ "pe_certificates": {"unknown_revision", "unknown_cert_type", "length_too_small"}, } +# Validators with no dedicated parser. Each reads the metadata and analysis +# layers directly rather than decoding structure from bytes, so there are no +# tombstone tags to drop. +_PARSERLESS_VALIDATORS: Dict[str, str] = { + "entrypoint": ( + "Maps the entry point against sections and the overlay. Reads " + "PublicMetadata and AnalysisDict; decodes no structure." + ), + "sections": ( + "Section flags, names, alignment and overlap. Reads PublicMetadata " + "and AnalysisDict; decodes no structure." + ), + "rva_graph": ( + "The directory placement backbone that relocations, debug, imports " + "and the security directory all defer to. Reads PublicMetadata and " + "AnalysisDict; decodes no structure." + ), + "entropy": ( + "Computes entropy metrics over regions supplied by AnalysisDict. " + "Decodes no structure." + ), +} + + +# Parsers that legitimately emit no tombstone tags. A tagless parser passes +# the drop check vacuously, so it must be declared rather than inferred: +# otherwise a tag-extraction failure looks identical to a parser that has +# nothing to extract. +_TAGLESS_PARSERS: Set[str] = { + # Reads two named fields from pefile's parsed OPTIONAL_HEADER with None + # fallbacks. No byte-level decode, so no decode failure to tombstone. + "pe_optional_header", + + # Returns a fields dict with no error list. NOTE this is a design gap + # rather than a clean absence: four distinct conditions - no directory, + # a malformed entry, rva == 0, and an unmapped rva - all collapse to + # parsed_size == 0, so the validator cannot tell "absent" from + # "declared but unreadable". Tracked separately; listed here so the + # vacuous drop check is explicit rather than silent. + "pe_load_config", +} + + +# Modules under iocx/validators that are not validators. +_NON_VALIDATOR_MODULES = {"schema", "decorators"} + + +def _discover_validator_modules() -> Set[str]: + """Every validator module name in the package.""" + import iocx.validators + return { + name + for _, name, _ in pkgutil.iter_modules(iocx.validators.__path__) + if not name.startswith("_") and name not in _NON_VALIDATOR_MODULES + } + + +def _paired_validator_names() -> Set[str]: + """Validator module names covered by _PAIRS.""" + return { + (p.values[2] if hasattr(p, "values") else p[2]).__name__.rsplit(".", 1)[-1] + for p in _PAIRS + } + + +@pytest.mark.contract +class TestContractCoverage: + + def test_every_validator_is_paired_or_exempt(self): + """ + The registration guard. Without it, a new parser/validator pair that + nobody adds to _PAIRS is silently unchecked - and every finding this + contract check has produced came from a pair that WAS registered. + """ + found = _discover_validator_modules() + unregistered = found - _paired_validator_names() - set(_PARSERLESS_VALIDATORS) + assert not unregistered, ( + f"validator modules in neither _PAIRS nor _PARSERLESS_VALIDATORS: " + f"{sorted(unregistered)}. Add the pair to _PAIRS, or record why " + f"the validator has no parser." + ) + + def test_no_stale_parserless_exemptions(self): + """An exemption for a validator that no longer exists is misleading.""" + found = _discover_validator_modules() + stale = set(_PARSERLESS_VALIDATORS) - found + assert not stale, ( + f"_PARSERLESS_VALIDATORS names modules that do not exist: " + f"{sorted(stale)}" + ) + + def test_no_stale_pairs(self): + found = _discover_validator_modules() + stale = _paired_validator_names() - found + assert not stale, ( + f"_PAIRS names validator modules that do not exist: {sorted(stale)}" + ) + + def test_parserless_exemptions_carry_a_reason(self): + """ + The reason is the point: it records WHY there is no parser, so a + later reader can tell 'no decoder by design' from 'not built yet'. + """ + for name, reason in _PARSERLESS_VALIDATORS.items(): + assert reason and len(reason) > 20, ( + f"{name} needs a substantive reason, not {reason!r}" + ) + + def test_a_validator_is_not_both_paired_and_exempt(self): + overlap = _paired_validator_names() & set(_PARSERLESS_VALIDATORS) + assert not overlap, ( + f"validators both paired and exempted: {sorted(overlap)}" + ) + + +@pytest.mark.contract +@pytest.mark.parametrize("name,parser_mod,validator_mod,templates", + _PAIRS, ids=[ + (p.values[0] if hasattr(p, "values") else p[0]) + for p in _PAIRS]) +def test_no_parser_emits_zero_tags(name, parser_mod, validator_mod, templates): + """ + A parser with no tags passes the drop check vacuously - emitted is + empty, so `emitted - matched` is empty regardless of what the validator + consumes. That is indistinguishable from a tag-extraction failure in the + checker itself, which has happened nine times during development. + + A genuinely tagless parser must opt in via _TAGLESS_PARSERS. + """ + if name in _TAGLESS_PARSERS: + pytest.skip(f"{name} records no tombstone tags by design") + + import inspect + result = check_contract( + inspect.getsource(parser_mod), + inspect.getsource(validator_mod), + parser_name=name, + validator_name=validator_mod.__name__, + template_vars=templates, + ) + assert result.emitted_errors or result.emitted_truncations, ( + f"{name} emitted no tags at all, so its drop check proves nothing. " + f"Either the parser is genuinely tagless - add it to " + f"_TAGLESS_PARSERS - or tag extraction is broken for this shape." + ) + + @pytest.mark.contract @pytest.mark.parametrize("name,parser_mod,validator_mod,templates", _PAIRS, ids=[p[0] for p in _PAIRS] or None) From 194a9f331d3f2bdc58d56de0d0e88bd5a4a4fb8e Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 2 Sep 2026 17:54:30 +0100 Subject: [PATCH 27/40] Final version of test_tag_contract --- tests/contract/test_tag_contract.py | 46 +++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/tests/contract/test_tag_contract.py b/tests/contract/test_tag_contract.py index 26759a1..ae81d1e 100644 --- a/tests/contract/test_tag_contract.py +++ b/tests/contract/test_tag_contract.py @@ -9,6 +9,18 @@ The `template_vars` mapping supplies the call-site values for f-string tag templates - a parser that builds f"{tag}_truncated" needs its `tag` values listed, or the check fails loudly rather than silently skipping them. + +Two registries record deliberate gaps, and they cover different things: + + _PARSERLESS_VALIDATORS validators with no struct-level decoder at all. + They cannot appear in _PAIRS - there is no parser + module to pass - so the contract is inapplicable + rather than exempted. + + _TAGLESS_PARSERS parsers that ARE in _PAIRS but emit no tags, which + makes the drop check vacuously true. Their drop / + phantom / template checks still run; only the + zero-tags assertion is skipped. """ from __future__ import annotations @@ -48,7 +60,11 @@ ("pe_exception", pe_exception, exception_table, {}), ("pe_resources", pe_resources, resources, {}), ("pe_version_info", pe_version_info, version_info, {}), - # Ref. _TAGLESS_PARSERS + # The two below are registered pairs like any other - their drop, + # phantom and template checks all run. They appear in _TAGLESS_PARSERS + # only to skip the zero-tags assertion, which would otherwise fail + # because neither parser records tombstone tags. See that registry for + # the reasoning in each case. ("pe_load_config", pe_load_config, load_config_directory,{}), ("pe_optional_header", pe_optional_header, optional_header, {}), ] @@ -114,6 +130,18 @@ _NON_VALIDATOR_MODULES = {"schema", "decorators"} +def _pair_ids() -> List[str]: + """ + Parametrise labels for _PAIRS. + + Copes with pytest.param rows: ParameterSet is a NamedTuple whose field 0 + is `values` - the whole argument tuple - not the label. A bare + `p[0]` therefore yields the tuple and pytest rejects it at collection. + Used by every parametrised test here so the two shapes cannot diverge. + """ + return [(p.values[0] if hasattr(p, "values") else p[0]) for p in _PAIRS] + + def _discover_validator_modules() -> Set[str]: """Every validator module name in the package.""" import iocx.validators @@ -184,9 +212,7 @@ def test_a_validator_is_not_both_paired_and_exempt(self): @pytest.mark.contract @pytest.mark.parametrize("name,parser_mod,validator_mod,templates", - _PAIRS, ids=[ - (p.values[0] if hasattr(p, "values") else p[0]) - for p in _PAIRS]) + _PAIRS, ids=_pair_ids()) def test_no_parser_emits_zero_tags(name, parser_mod, validator_mod, templates): """ A parser with no tags passes the drop check vacuously - emitted is @@ -199,7 +225,6 @@ def test_no_parser_emits_zero_tags(name, parser_mod, validator_mod, templates): if name in _TAGLESS_PARSERS: pytest.skip(f"{name} records no tombstone tags by design") - import inspect result = check_contract( inspect.getsource(parser_mod), inspect.getsource(validator_mod), @@ -216,7 +241,7 @@ def test_no_parser_emits_zero_tags(name, parser_mod, validator_mod, templates): @pytest.mark.contract @pytest.mark.parametrize("name,parser_mod,validator_mod,templates", - _PAIRS, ids=[p[0] for p in _PAIRS] or None) + _PAIRS, ids=_pair_ids()) def test_no_tag_is_silently_dropped(name, parser_mod, validator_mod, templates): """ A tag with no consumer vanishes: _first_matching returns "unknown" and @@ -239,7 +264,7 @@ def test_no_tag_is_silently_dropped(name, parser_mod, validator_mod, templates): @pytest.mark.contract @pytest.mark.parametrize("name,parser_mod,validator_mod,templates", - _PAIRS, ids=[p[0] for p in _PAIRS] or None) + _PAIRS, ids=_pair_ids()) def test_no_unexpandable_tag_template(name, parser_mod, validator_mod, templates): """ An f-string tag whose variable has no declared expansion cannot be @@ -261,14 +286,17 @@ def test_no_unexpandable_tag_template(name, parser_mod, validator_mod, templates @pytest.mark.contract @pytest.mark.parametrize("name,parser_mod,validator_mod,templates", - _PAIRS, ids=[p[0] for p in _PAIRS] or None) + _PAIRS, ids=_pair_ids()) def test_no_phantom_tags(name, parser_mod, validator_mod, templates): """ A tag named in a priority list that no parser can produce is inert but misleading: it implies a routing that does not exist, and would produce a wrong sub_reason if the name were ever reused at another level. - Mark xfail rather than fail if you keep deliberate defensive entries. + If you keep deliberate defensive entries - a tag listed against a guard + that cannot currently fire - add a _KNOWN_PHANTOMS mapping scoped + per-tag, mirroring _KNOWN_DELIBERATE_DROPS. Do not xfail the whole + pair: that would also silence a genuine phantom. """ result = check_contract( inspect.getsource(parser_mod), From e8f50001b65cc9fc9315ebabdd09698cfde82fb1 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 7 Sep 2026 10:16:03 +0100 Subject: [PATCH 28/40] _Parse_resources return object has changed shape, so fixed tests to reflect that --- tests/unit/parsers/test_pe_parser_extended.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/parsers/test_pe_parser_extended.py b/tests/unit/parsers/test_pe_parser_extended.py index c7190d3..472ae4e 100644 --- a/tests/unit/parsers/test_pe_parser_extended.py +++ b/tests/unit/parsers/test_pe_parser_extended.py @@ -344,10 +344,11 @@ class FakePE: pass from iocx.parsers.pe_parser import _parse_resources - resources, strings = _parse_resources(FakePE()) + resources, strings, truncated = _parse_resources(FakePE()) assert resources == [] assert strings == [] + assert truncated == [] def test_parse_resources_missing_memory_map(): @@ -359,10 +360,11 @@ class FakePE: # Crucially: NO get_memory_mapped_image attribute from iocx.parsers.pe_parser import _parse_resources - resources, strings = _parse_resources(FakePE()) + resources, strings, truncated = _parse_resources(FakePE()) assert resources == [] assert strings == [] + assert truncated == [] assert hasattr(FakePE(), "DIRECTORY_ENTRY_RESOURCE") assert not hasattr(FakePE(), "get_memory_mapped_image") From 6a82557d744d29ed152f6ab9aa4f2932c6f6632f Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 7 Sep 2026 11:19:41 +0100 Subject: [PATCH 29/40] Add resources_string tests to pe_parser and document additional tombstone --- docs/specs/reason-codes.md | 12 + iocx/parsers/pe_parser.py | 11 +- tests/unit/parsers/test_pe_parser.py | 359 +++++++++++++++++- tests/unit/parsers/test_pe_parser_extended.py | 2 +- 4 files changed, 379 insertions(+), 5 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index c7119ba..9eb1fd9 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -333,6 +333,7 @@ tags are passed through verbatim in an `errors` list. |------------|------------------|-----------------|--------| | **RESOURCE_STRING_TABLE_CORRUPT** | String table length, offsets, or UTF‑16 entries are malformed or out of bounds | String count = 32 but table only contains 10 entries | Per‑file | | **RESOURCE_STRING_TABLE_UNREADABLE** | The RT_STRING traversal raised before completing, so the string-table list is empty or partial and its absence carries no meaning | Malformed Name or Language directory beneath RT_STRING | Per‑file | +| **RESOURCE_TABLE_UNAVAILABLE** | The resource entry table could not be built at all: `pe.get_memory_mapped_image` was absent, or raised when called. No entry was decoded, so an empty `resources` list carries no meaning. Distinct from a binary with no resource directory, which produces no issue | A pe object lacking `get_memory_mapped_image`; or a memory-map read raising on a malformed image | Per‑file | #### RESOURCE_STRING_TABLE_UNREADABLE @@ -340,6 +341,17 @@ tags are passed through verbatim in an `errors` list. |------------|---------| | walk_failed | The RT_STRING walk raised; `string_tables` may be empty or partial. Distinct from a binary that genuinely carries no string resources, which produces no issue at all | +#### RESOURCE_TABLE_UNAVAILABLE + +Mutually exclusive — the capability check precedes the call, so a missing method never reaches the raising branch: + +| Parser tag | Meaning | +|------------|---------| +| resources_unavailable | `get_memory_mapped_image` was not present on the pe object | +| resources_map_read_failed | The method was present but raised; the exception is swallowed and the walk abandoned | + +Both appear in the resource truncation list. Unlike `resources` and `resource_strings` in that same list, this is a capability tombstone rather than a cap: no entry was truncated because none was decoded. A tombstone and `resources` are mutually exclusive by construction, since both branches return before the entry loop. + --- ## **ENTROPY ANOMALIES** diff --git a/iocx/parsers/pe_parser.py b/iocx/parsers/pe_parser.py index 8d34112..1b9cae2 100644 --- a/iocx/parsers/pe_parser.py +++ b/iocx/parsers/pe_parser.py @@ -436,11 +436,18 @@ def _parse_resources(pe): if len(resource_strings) >= _MAX_RESOURCE_STRINGS: truncated.append("resource_strings") - # Extract structured resource entries + # Extract structured resource entries. A pe object without this method + # cannot yield entries at all, which is materially different from a + # binary that has none - record it rather than returning silently if not hasattr(pe, "get_memory_mapped_image"): + truncated.append("resources_unavailable") return resources, resource_strings, truncated - mm = pe.get_memory_mapped_image() or b"" + try: + mm = pe.get_memory_mapped_image() or b"" + except Exception: + truncated.append("resources_map_read_failed") + return resources, resource_strings, truncated entries_capped = False for entry in getattr(pe.DIRECTORY_ENTRY_RESOURCE, "entries", []): diff --git a/tests/unit/parsers/test_pe_parser.py b/tests/unit/parsers/test_pe_parser.py index 80a56a5..d81c85e 100644 --- a/tests/unit/parsers/test_pe_parser.py +++ b/tests/unit/parsers/test_pe_parser.py @@ -1,19 +1,104 @@ # Copyright (c) 2026 MalX Labs and contributors # SPDX-License-Identifier: MPL-2.0 -import pytest, pefile +import pytest, pefile, json from types import SimpleNamespace from typing import Dict, Any, Optional, List -from iocx.parsers.pe_parser import parse_pe, _walk_resources, analyse_pe_sections, _parse_data_directories, _parse_data_directories_raw +from iocx.parsers.pe_parser import (parse_pe, _walk_resources, analyse_pe_sections, _parse_data_directories, + _parse_data_directories_raw, _parse_resources, _MAX_RESOURCE_STRINGS, + _MAX_RESOURCE_ENTRIES, _MAX_RESOURCE_DEPTH) from iocx.parsers.string_extractor import extract_strings_from_bytes from iocx.parsers.pe_resources import build_resource_structure +class _DataStruct: + def __init__(self, offset, size, codepage=0): + self.OffsetToData = offset + self.Size = size + self.CodePage = codepage + + +class _Data: + def __init__(self, offset, size): + self.struct = _DataStruct(offset, size) + + +class _Node: + def __init__(self, entries): + self.entries = entries + + +class _Entry: + def __init__(self, **kw): + self.name = None + self.id = None + for k, v in kw.items(): + setattr(self, k, v) + + +class _FakePE: + def __init__(self, root, file_size=0x10000000): + self.DIRECTORY_ENTRY_RESOURCE = root + self.__data__ = type("D", (), {"size": file_size})() + + def get_data(self, rva, size): + return b"A" * size + + def get_memory_mapped_image(self): + return b"\x00" * 0x10000 + + def get_offset_from_rva(self, rva): + return rva + + + +def _leaf(offset=0x100, size=0x40): + e = _Entry(id=0x409) + e.data = _Data(offset, size) + return e + + +def _name_entry(langs, name_id=1): + e = _Entry(id=name_id) + e.directory = _Node(langs) + return e + + +def _type_entry(names, type_id=16): + e = _Entry(id=type_id) + e.directory = _Node(names) + return e + + +def _tree(name_entries, type_id=16): + return _Node([_type_entry(name_entries, type_id)]) + + # ------------------------------------------------------------ # Fake PE builder with full interface required by parse_pe() # ------------------------------------------------------------ +@pytest.fixture +def fake_strings(monkeypatch): + """ + Replace extract_strings_from_bytes with a fixed-rate fake: one + fabricated string per 4 input bytes, regardless of content. + + Patches the name as bound INSIDE iocx.parsers.pe_parser's own + namespace - `from .string_extractor import extract_strings_from_bytes` + binds a local reference there, so patching the origin module + (string_extractor) would not affect it. + """ + import iocx.parsers.pe_parser as pe_parser_module + + def fake(data: bytes): + return [f"S{i}" for i in range(len(data) // 4)] + + monkeypatch.setattr(pe_parser_module, "extract_strings_from_bytes", fake) + return fake + + def fake_pe( imports=None, sections=None, @@ -326,6 +411,219 @@ def size(self): assert strings == [] +class TestResourceStringsCap: + + # The cap is the unit under test, not the extractor. Make the fake + # unconditional for the class so no test can silently fall back to + # the real scanner. + @pytest.fixture(autouse=True) + def _always_fake(self, fake_strings): + return fake_strings + + def test_many_small_resources_bounded(self): + tree = _tree([_name_entry([_leaf(0x100, 4000)], name_id=i) + for i in range(500)]) + _, strings, truncated = _parse_resources(_FakePE(tree)) + assert len(strings) == _MAX_RESOURCE_STRINGS + assert "resource_strings" in truncated + + def test_single_large_resource_bounded(self): + tree = _tree([_name_entry([_leaf(0x100, 400_000)])]) + _, strings, truncated = _parse_resources(_FakePE(tree)) + assert len(strings) == _MAX_RESOURCE_STRINGS + assert "resource_strings" in truncated + + @pytest.mark.parametrize("blob_size", [4, 400, 40_000, 400_000]) + def test_never_exceeds_the_cap(self, blob_size): + tree = _tree([_name_entry([_leaf(0x100, blob_size)], name_id=i) + for i in range(200)]) + _, strings, _ = _parse_resources(_FakePE(tree)) + assert len(strings) <= _MAX_RESOURCE_STRINGS + + def test_under_cap_not_flagged(self): + tree = _tree([_name_entry([_leaf(0x100, 0x40)])]) + _, strings, truncated = _parse_resources(_FakePE(tree)) + assert len(strings) < _MAX_RESOURCE_STRINGS + assert "resource_strings" not in truncated + + def test_fake_pe_satisfies_the_parser_interface(self): + """_parse_resources returns early and silently when + get_memory_mapped_image is absent, so an incomplete fake yields + empty resources rather than an error.""" + pe = _FakePE(_tree([_name_entry([_leaf(0x100, 0x40)])])) + for attr in ("get_data", "get_memory_mapped_image", "get_offset_from_rva"): + assert callable(getattr(pe, attr, None)), attr + + +class TestResourceEntriesCap: + + @pytest.mark.parametrize("count,expected_capped", [ + (_MAX_RESOURCE_ENTRIES - 1, False), + (_MAX_RESOURCE_ENTRIES, False), + (_MAX_RESOURCE_ENTRIES + 1, True), + ]) + def test_entry_cap_boundary(self, count, expected_capped): + """Small blobs keep the string budget clear so the entry cap is + the only one under test.""" + tree = _tree([_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(count)]) + resources, _, truncated = _parse_resources(_FakePE(tree)) + assert len(resources) == min(count, _MAX_RESOURCE_ENTRIES) + assert ("resources" in truncated) is expected_capped + + def test_far_over_cap_bounded(self): + tree = _tree([_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(50_000)]) + resources, _, truncated = _parse_resources(_FakePE(tree)) + assert len(resources) == _MAX_RESOURCE_ENTRIES + assert "resources" in truncated + + def test_output_size_does_not_scale_with_input(self): + """The property that matters downstream.""" + def sized(n): + tree = _tree([_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(n)]) + return len(json.dumps(_parse_resources(_FakePE(tree))[0])) + assert sized(2_000) == sized(50_000) + assert sized(2_000) > 2 + + def test_cap_flag_appears_once(self): + tree = _tree([_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(5_000)]) + _, _, truncated = _parse_resources(_FakePE(tree)) + assert truncated.count("resources") == 1 + + def test_outer_loop_breaks_after_cap(self): + """ + The cap fires inside the first type's name loop, which breaks the + inner loop only. The outer `if entries_capped: break` is what stops + the walk from advancing to the next type entry. + + Without it the second type is still blocked by the inner + `len(resources) >= _MAX_RESOURCE_ENTRIES` check - but that check + appends "resources" again, so the flag is duplicated. + """ + over_cap = [_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(_MAX_RESOURCE_ENTRIES + 1)] + root = _Node([ + _type_entry(over_cap, type_id=16), + _type_entry([_name_entry([_leaf(0x100, 4)], name_id=9000)], + type_id=3), + ]) + + resources, _, truncated = _parse_resources(_FakePE(root)) + + assert len(resources) == _MAX_RESOURCE_ENTRIES + assert truncated == ["resources"] + assert all(r["type"] != "RT_ICON" for r in resources) + + +class TestRecursionDepthCap: + + def test_deep_tree_does_not_raise(self): + """ + Before the cap this raised RecursionError out of _parse_resources, + which parse_pe does not catch - so one malformed file aborted the + whole analysis. + """ + node = _Node([_leaf()]) + for _ in range(2_000): + e = _Entry(id=1) + e.directory = node + node = _Node([e]) + strings = [] + _walk_resources(_FakePE(node), node, strings) # must not raise + + def test_normal_depth_unaffected(self): + """A well-formed tree is Type -> Name -> Language, three levels.""" + tree = _tree([_name_entry([_leaf(0x100, 0x40)])]) + strings = [] + _walk_resources(_FakePE(tree), tree, strings) + assert strings + + def test_depth_cap_is_generous_enough_for_real_trees(self): + assert _MAX_RESOURCE_DEPTH >= 3 + + +class TestCapsAreIndependent: + + def test_both_caps_can_fire_together(self, fake_strings): + tree = _tree([_name_entry([_leaf(0x100, 4000)], name_id=i) + for i in range(5_000)]) + resources, strings, truncated = _parse_resources(_FakePE(tree)) + assert len(resources) == _MAX_RESOURCE_ENTRIES + assert len(strings) == _MAX_RESOURCE_STRINGS + assert set(truncated) == {"resources", "resource_strings"} + + def test_entry_cap_alone(self, fake_strings): + tree = _tree([_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(5_000)]) + _, _, truncated = _parse_resources(_FakePE(tree)) + assert truncated == ["resources"] + + +class TestUnaffectedBehaviour: + + def test_clean_file_has_no_truncation_flags(self): + tree = _tree([_name_entry([_leaf(0x100, 0x40)])]) + resources, strings, truncated = _parse_resources(_FakePE(tree)) + assert truncated == [] + assert len(resources) == 1 + assert strings + + def test_no_resource_directory(self): + pe = _FakePE(None) + pe.DIRECTORY_ENTRY_RESOURCE = None + assert _parse_resources(pe) == ([], [], []) + + def test_ordering_still_deterministic(self): + def build(): + return _tree([_name_entry([_leaf(0x100, 64)], name_id=i) + for i in range(2_000)]) + first = json.dumps(_parse_resources(_FakePE(build()))[0], sort_keys=True) + for _ in range(5): + assert json.dumps(_parse_resources(_FakePE(build()))[0], + sort_keys=True) == first + + def test_caps_are_positive_and_bounded(self): + assert 0 < _MAX_RESOURCE_STRINGS <= 1_000_000 + assert 0 < _MAX_RESOURCE_ENTRIES <= 65_536 + assert 0 < _MAX_RESOURCE_DEPTH <= 64 + + +class TestResourcesUnavailable: + + def test_missing_method_is_tombstoned(self): + """An empty `resources` list must be distinguishable from a + binary that genuinely has none.""" + class _NoMMI(_FakePE): + @property + def get_memory_mapped_image(self): + raise AttributeError("get_memory_mapped_image") + + pe = _NoMMI(_tree([_name_entry([_leaf(0x100, 0x40)])])) + resources, strings, truncated = _parse_resources(pe) + assert resources == [] + assert "resources_unavailable" in truncated + assert strings # the walk still ran + + def test_raising_method_is_tombstoned_not_propagated(self): + class _RaisingMMI(_FakePE): + def get_memory_mapped_image(self): + raise struct.error("truncated image") + + pe = _RaisingMMI(_tree([_name_entry([_leaf(0x100, 0x40)])])) + resources, _, truncated = _parse_resources(pe) + assert resources == [] + assert "resources_map_read_failed" in truncated + + def test_tombstone_and_cap_are_mutually_exclusive(self): + pe = _FakePE(_tree([_name_entry([_leaf(0x100, 4)], name_id=i) + for i in range(5_000)])) + _, _, truncated = _parse_resources(pe) + assert "resources" in truncated + assert "resources_unavailable" not in truncated + # ------------------------------------------------------------ # Analyse PE sections # ------------------------------------------------------------ @@ -386,6 +684,63 @@ class FakePE: assert result == [] # early return path +@pytest.mark.parametrize("raw, label", [ + (None, "attribute absent"), + (b"", "zero-length buffer"), +]) +def test_parse_data_directories_raw_no_raw_bytes(raw, label): + """ + `if not raw` catches an empty buffer as well as a missing attribute - + the other parsers use `is None`, which would fall through to + struct.unpack_from on b"" here. A zero-length file genuinely has no + optional header, so both must return early. + """ + class FakeOptHdr: + Magic = 0x10B + def get_file_offset(self): + return 0xE8 + + pe = SimpleNamespace(OPTIONAL_HEADER=FakeOptHdr()) + if raw is not None: + pe.__data__ = raw + + from iocx.parsers.pe_parser import _parse_data_directories_raw + assert _parse_data_directories_raw(pe) == [], label + + +@pytest.mark.parametrize("readable", [0, 1, 3, 15]) +def test_parse_data_directories_raw_truncated_header(readable): + """ + A short optional header must yield the entries that were readable, + not an empty list and not an exception. `break` rather than `return + dirs` is what makes the partial result survive. + """ + import struct + opt_offset, data_dir_offset = 0, 96 + + class FakeOptHdr: + Magic = 0x10B # PE32 -> 96-byte offset + def get_file_offset(self): + return opt_offset + + # Fill each readable slot with a recognisable (rva, size) pair, then + # stop one byte short of completing the next. + payload = b"".join( + struct.pack(" Date: Mon, 7 Sep 2026 11:59:05 +0100 Subject: [PATCH 30/40] Add bounds checks for rva, size in pe_version_info --- docs/specs/reason-codes.md | 37 ++++++++-- iocx/parsers/pe_version_info.py | 12 ++++ tests/unit/parsers/test_pe_version_info.py | 79 +++++++++++++++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 9eb1fd9..6a234f5 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -285,10 +285,10 @@ Details carry `declared_size` and `decoded_entries`; their difference is the los | Reason Code | What Triggers It | Example Pattern | Scope | |-------------|------------------|-----------------|-------| -| **RESOURCE_VERSIONINFO_INVALID_HEADER** | The VS_VERSIONINFO envelope is malformed: placement outside `.rsrc`, `szKey` not equal to "VS_VERSION_INFO", or `wLength` inconsistent with the buffer size | szKey = "VS_VERSION_BAD" instead of "VS_VERSION_INFO" | Per‑file -| **RESOURCE_VERSIONINFO_INVALID_FIXEDINFO** | The embedded VS_FIXEDFILEINFO has an incorrect `dwSignature` (expected `0xFEEF04BD`) or `dwStrucVersion` (expected `0x00010000`), or fails to parse | dwSignature = `0xDEADBEEF` instead of `0xFEEF04BD` | Per‑file -| **RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO** | A StringFileInfo, StringTable, or String child is malformed: invalid length field, non‑hex lang_codepage key, or truncated string entry StringTable | key = "ENGLISHX" instead of 8‑hex‑char form | Per‑file -| **RESOURCE_VERSIONINFO_INVALID_VARFILEINFO** | A VarFileInfo or Var child is malformed, or the Translation array's length is not a DWORD multiple Var. | wValueLength = 6 (not divisible by 4) for a Translation array | Per‑file +| **RESOURCE_VERSIONINFO_INVALID_HEADER** | The VS_VERSIONINFO envelope is malformed: placement outside `.rsrc`, `szKey` not equal to "VS_VERSION_INFO", or `wLength` inconsistent with the buffer size | szKey = "VS_VERSION_BAD" instead of "VS_VERSION_INFO" | Per‑file | +| **RESOURCE_VERSIONINFO_INVALID_FIXEDINFO** | The embedded VS_FIXEDFILEINFO has an incorrect `dwSignature` (expected `0xFEEF04BD`) or `dwStrucVersion` (expected `0x00010000`), or fails to parse | dwSignature = `0xDEADBEEF` instead of `0xFEEF04BD` | Per‑file | +| **RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO** | A StringFileInfo, StringTable, or String child is malformed: invalid length field, non‑hex lang_codepage key, or truncated string entry | StringTable key = "ENGLISHX" instead of 8‑hex‑char `` form | Per‑file | +| **RESOURCE_VERSIONINFO_INVALID_VARFILEINFO** | A VarFileInfo or Var child is malformed, or the Translation array's length is not a DWORD multiple | Var wValueLength = 6 (not divisible by 4) for a Translation array | Per‑file | *Note: absence of an RT_VERSION resource is not treated as a structural anomaly — many legitimate binary types (kernel drivers, MSI helpers, cross‑compiled artefacts) omit version‑info entirely.* @@ -301,7 +301,7 @@ than by the `undecoded` forward. | Sub‑reason | Meaning | |------------|---------| -| placement | The VS_VERSIONINFO blob does not lie wholly inside `.rsrc` | +| placement | The VS_VERSIONINFO blob was read and decoded, but does not lie wholly inside `.rsrc`. A validator-side comparison of the blob's extent against section bounds — distinct from `leaf_placement_implausible` below, which is a parser-side refusal to read at all | | undecoded | The parser could not decode the envelope; short-circuits the FIXEDINFO / STRINGFILEINFO / VARFILEINFO checks. The `errors` key lists the contributing parser tags | | szkey_mismatch | `szKey` is not "VS_VERSION_INFO" | | length_inconsistent | `wLength` disagrees with the buffer size | @@ -315,6 +315,33 @@ a fault that terminated the walk outranks one that did not, because the remaining children were never examined. At most one issue is emitted per blob, with the full matching set carried in `errors`. +#### `undecoded` — contributing parser tags + +Listed in the issue's `errors` key. Any one of these means no VS_VERSIONINFO +envelope was decoded, so the FIXEDINFO / STRINGFILEINFO / VARFILEINFO checks +are skipped: + +| Parser tag | Meaning | +|------------|---------| +| leaf_struct_unpack | The RT_VERSION leaf's `OffsetToData` / `Size` could not be read from the resource entry | +| leaf_placement_implausible | The leaf's declared placement is structurally impossible: a negative RVA, a size of zero or less, or a size exceeding the 1 MB blob cap. The blob is **not** read — the guard precedes `pe.get_data` — so an attacker-controlled `Size` is bounded before any allocation occurs | +| read_failed | `pe.get_data` raised when reading the blob at a placement that passed the guard | +| too_short | The blob was read but is under the 6-byte VS_VERSIONINFO header minimum | +| header_unpack | `struct.unpack` failed on the 6-byte header (defensive; unreachable past the length guard) | + +*Placement is reported verbatim in `rva` and `size` even when the guard +refuses the read, so the declared values remain diagnosable. A tombstoned +structure is returned rather than `None`: absence of an RT_VERSION resource +is not a defect and produces no issue at all, whereas a resource that exists +but cannot be trusted must stay visible.* + +> **Zero-size leaves moved tag.** A leaf declaring `Size = 0` previously +> reached `pe.get_data`, returned an empty buffer and surfaced as +> `too_short`. It is now refused by the placement guard and surfaces as +> `leaf_placement_implausible`. Consumers keying on `too_short` for that +> case must be updated; `too_short` now means only that a non-empty blob +> was read and fell short of the 6-byte header. + ### RESOURCE_VERSIONINFO_INVALID_FIXEDINFO sub‑reasons | Sub‑reason | Meaning | diff --git a/iocx/parsers/pe_version_info.py b/iocx/parsers/pe_version_info.py index 0f6494a..514a7d6 100644 --- a/iocx/parsers/pe_version_info.py +++ b/iocx/parsers/pe_version_info.py @@ -35,6 +35,9 @@ # controlled and unbounded when length_consistent is False. _MAX_CHILDREN = 256 +# VS_VERSIONINFO blobs are a few KB in practice +_MAX_VERSION_BLOB = 1_000_000 + def build_version_info_structure(pe) -> Optional[Dict[str, Any]]: """ @@ -63,6 +66,15 @@ def build_version_info_structure(pe) -> Optional[Dict[str, Any]]: "errors": ["leaf_struct_unpack"], } + if rva < 0 or size <= 0 or size > _MAX_VERSION_BLOB: + return { + "rva": rva, "size": size, + "decoded": False, "header_ok": False, "length_consistent": False, + "fixed_file_info": None, + "string_file_info": [], "var_file_info": [], + "errors": ["leaf_placement_implausible"], + } + try: raw = bytes(pe.get_data(rva, size)) except Exception: diff --git a/tests/unit/parsers/test_pe_version_info.py b/tests/unit/parsers/test_pe_version_info.py index 7f9364b..42fea3d 100644 --- a/tests/unit/parsers/test_pe_version_info.py +++ b/tests/unit/parsers/test_pe_version_info.py @@ -36,9 +36,11 @@ _VS_FFI_STRUCT_VERSION, _VS_VERSION_INFO_KEY, RT_VERSION, - _MAX_CHILDREN + _MAX_CHILDREN, + _MAX_VERSION_BLOB ) +_PLACEMENT_TAG = "leaf_placement_implausible" # ================================================================= # Byte-level builders for VS_VERSIONINFO test fixtures @@ -1438,3 +1440,78 @@ def fake_unpack_from(fmt, buf_, offset=0): var = vfi_out["vars"][0] # Either the Var's parent or the translation array failed to populate assert "translation_unpack" in vfi_out["errors"] or var["translations"] == [] + +def _version_pe(rva, size): + """Minimal pe exposing a single RT_VERSION leaf with the given placement.""" + class _Struct: + OffsetToData = rva + Size = size + + class _Leaf: + data = type("D", (), {"struct": _Struct()})() + + class _LangDir: + entries = [_Leaf()] + + class _NameEntry: + id = 1 + directory = _LangDir() + + class _NameDir: + entries = [_NameEntry()] + + class _TypeEntry: + id = 16 # RT_VERSION + directory = _NameDir() + + class _Root: + entries = [_TypeEntry()] + + class _PE: + DIRECTORY_ENTRY_RESOURCE = _Root() + + def get_data(self, rva, size): + raise AssertionError( + f"get_data called with size={size}; the guard must " + "short-circuit before any read" + ) + + return _PE() + + +@pytest.mark.parametrize("rva, size, label", [ + (-1, 0x40, "negative rva"), + (0x1000, 0, "zero size"), + (0x1000, -1, "negative size"), + (0x1000, _MAX_VERSION_BLOB + 1, "size over cap"), +]) +def test_implausible_placement_is_tombstoned_without_reading(rva, size, label): + """ + The guard exists to stop an attacker-controlled Size reaching + pe.get_data. A tombstoned dict - not None - keeps an untrustworthy + blob distinguishable from a binary with no RT_VERSION resource. + """ + out = build_version_info_structure(_version_pe(rva, size)) + + assert out is not None, label + assert out["errors"] == [_PLACEMENT_TAG], label + assert out["decoded"] is False + assert out["header_ok"] is False + assert out["length_consistent"] is False + assert out["fixed_file_info"] is None + assert out["string_file_info"] == [] + assert out["var_file_info"] == [] + # Placement is reported verbatim so the fault is diagnosable + assert out["rva"] == rva + assert out["size"] == size + + + @pytest.mark.parametrize("size", [1, _MAX_VERSION_BLOB]) + def test_boundary_sizes_are_not_rejected(size): + """The cap is inclusive; only size > _MAX_VERSION_BLOB is refused.""" + pe = _version_pe(0x1000, size) + pe.get_data = lambda rva, size: b"\x00" * size # allow the read + + out = build_version_info_structure(pe) + assert out["errors"] != [_PLACEMENT_TAG] + From 37b72bf5d590ed782c5d022fc7a986d57a483571 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Mon, 7 Sep 2026 16:33:51 +0100 Subject: [PATCH 31/40] Pre-version info reason reorder --- .../load_config_minimal_mingw.full.json | 4 +- .../load_config_seh_table.full.json | 4 +- .../broken_rva_addresses.full.json | 20 ++++---- .../corrupted_data_directories.full.json | 36 +++++++------- .../crypto_entropy_payload.full.json | 16 +++---- .../directory_raw_mismatch.full.json | 24 +++++----- .../directory_zero_size_nonzero_rva.full.json | 24 +++++----- .../filepaths_strings_adversarial.full.json | 2 +- .../fixture_000_entrypoint_zero.full.json | 12 ++--- .../fixture_001_entrypoint_negative.full.json | 4 +- ...ixture_002_entrypoint_in_headers.full.json | 8 ++-- ..._entrypoint_gap_between_sections.full.json | 4 +- ..._004_entrypoint_non_exec_section.full.json | 8 ++-- .../fixture_005_entrypoint_rsrc.full.json | 8 ++-- ...xture_006_entrypoint_discardable.full.json | 8 ++-- ...7_entrypoint_zero_length_section.full.json | 4 +- ...8_entrypoint_beyond_virtual_size.full.json | 4 +- ...ixture_009_entrypoint_in_overlay.full.json | 4 +- .../fixture_010_sections_rwx.full.json | 20 ++++---- ...xture_011_sections_code_not_exec.full.json | 20 ++++---- ...e_012_sections_codelike_not_exec.full.json | 20 ++++---- ...ture_013_sections_non_ascii_name.full.json | 16 +++---- .../fixture_014_sections_empty_name.full.json | 16 +++---- ...re_015_sections_impossible_flags.full.json | 28 +++++------ ...ture_016_sections_raw_misaligned.full.json | 20 ++++---- ...ure_017_sections_overlap_headers.full.json | 16 +++---- ...fixture_018_sections_zero_length.full.json | 16 +++---- ...fixture_019_sections_raw_overlap.full.json | 20 ++++---- ...ure_020_sections_virtual_overlap.full.json | 16 +++---- ...re_021_sections_out_of_order_raw.full.json | 16 +++---- ...22_sections_out_of_order_virtual.full.json | 16 +++---- ...ure_023_sections_negative_fields.full.json | 8 ++-- ..._024_opt_size_of_image_too_small.full.json | 16 +++---- ...5_opt_size_of_headers_misaligned.full.json | 12 ++--- ...26_opt_size_of_headers_too_small.full.json | 12 ++--- ...27_opt_section_alignment_invalid.full.json | 24 +++++----- ...e_028_opt_file_alignment_invalid.full.json | 28 +++++------ ...re_029_opt_size_fields_too_small.full.json | 16 +++---- ...re_030_opt_image_base_misaligned.full.json | 12 ++--- ...fixture_031_opt_num_dirs_invalid.full.json | 16 +++---- ...xture_032_opt_num_dirs_too_small.full.json | 16 +++---- ...033_opt_size_of_image_misaligned.full.json | 20 ++++---- .../fixture_034_ddir_negative_rva.full.json | 16 +++---- .../fixture_035_ddir_negative_size.full.json | 16 +++---- .../fixture_036_ddir_zero_zero.full.json | 12 ++--- ...e_037_ddir_zero_rva_nonzero_size.full.json | 16 +++---- ...e_038_ddir_zero_size_nonzero_rva.full.json | 16 +++---- .../fixture_039_ddir_in_headers.full.json | 16 +++---- .../fixture_040_ddir_out_of_range.full.json | 16 +++---- .../fixture_041_ddir_raw_mismatch.full.json | 16 +++---- .../fixture_042_ddir_in_overlay.full.json | 16 +++---- .../fixture_043_ddir_not_mapped.full.json | 16 +++---- .../fixture_044_ddir_spans_sections.full.json | 16 +++---- .../fixture_045_ddir_overlap.full.json | 16 +++---- .../franken_malformed_pe.full.json | 48 +++++++++---------- .../franken_malformed_pe.pe32.full.json | 44 ++++++++--------- .../franken_url_domain_ip.full.json | 20 ++++---- .../heuristic_rich.full.json | 44 ++++++++--------- .../invalid_optional_header.full.json | 24 +++++----- .../invalid_optional_header.pe32.full.json | 44 ++++++++--------- .../invalid_section_alignment.full.json | 8 ++-- .../load_config_cookie_too_small.full.json | 4 +- ...nfig_malformed_cookie_in_overlay.full.json | 8 ++-- ..._config_malformed_cookie_invalid.full.json | 4 +- ..._malformed_guard_cf_inconsistent.full.json | 12 ++--- ...oad_config_malformed_seh_invalid.full.json | 12 ++--- ...g_malformed_size_exceeds_section.full.json | 8 ++-- ..._config_malformed_size_too_small.full.json | 12 ++--- .../load_config_malformed_truncated.full.json | 4 +- .../load_config_rva_negative.full.json | 8 ++-- .../load_config_rva_zero.full.json | 4 +- ...fig_zero_size_but_fields_present.full.json | 4 +- ...oad_config_zero_size_invalid_rva.full.json | 4 +- .../load_config_zero_size_valid_rva.full.json | 4 +- .../malformed_domain.full.json | 20 ++++---- .../malformed_import_table.full.json | 8 ++-- .../layer3_adversarial/malformed_ip.full.json | 20 ++++---- .../malformed_url.full.json | 20 ++++---- .../overlapping_sections.full.json | 16 +++---- .../packed_lookalike.full.json | 16 +++---- .../string_obfuscation_tricks.full.json | 20 ++++---- .../truncated_rich_header.full.json | 4 +- .../upx_name_only.full.json | 8 ++-- 83 files changed, 627 insertions(+), 627 deletions(-) diff --git a/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json b/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json index e5b2cda..9564c56 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_too_small", "rva": 12288, "size": 16, - "min_size": 112 + "min_size": 112, + "reason": "load_config_too_small" } } ] diff --git a/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json b/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json index adbc2eb..619f069 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_too_small", "rva": 12288, "size": 44, - "min_size": 112 + "min_size": 112, + "reason": "load_config_too_small" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json index bf7ca5c..dc35ad8 100644 --- a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json +++ b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 392 + "overlay_offset": 392, + "reason": "entrypoint_in_overlay" } }, { @@ -164,10 +164,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".zero", "raw_address": 0, - "size_of_headers": 512 + "size_of_headers": 512, + "reason": "section_overlaps_headers" } }, { @@ -176,8 +176,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_zero_length", - "section": ".zero" + "section": ".zero", + "reason": "section_zero_length" } }, { @@ -186,11 +186,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_out_of_order_raw", "raw_addresses": [ 512, 0 - ] + ], + "reason": "section_out_of_order_raw" } }, { @@ -199,11 +199,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_IMPORT", "rva": 36864, "size": 512, - "size_of_image": 16384 + "size_of_image": 16384, + "reason": "data_directory_out_of_range" } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json b/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json index fd5e9ac..23a830d 100644 --- a/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json +++ b/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json @@ -144,10 +144,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 392 + "overlay_offset": 392, + "reason": "entrypoint_in_overlay" } }, { @@ -156,11 +156,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_RESOURCE", "rva": 8192, "size": 12288, - "size_of_image": 12288 + "size_of_image": 12288, + "reason": "data_directory_out_of_range" } }, { @@ -169,11 +169,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_EXCEPTION", "rva": 12032, "size": 8192, - "size_of_image": 12288 + "size_of_image": 12288, + "reason": "data_directory_out_of_range" } }, { @@ -182,9 +182,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_overlap", "directory_a": "IMAGE_DIRECTORY_ENTRY_RESOURCE", - "directory_b": "IMAGE_DIRECTORY_ENTRY_EXCEPTION" + "directory_b": "IMAGE_DIRECTORY_ENTRY_EXCEPTION", + "reason": "data_directory_overlap" } }, { @@ -193,11 +193,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "certificate_table_malformed", + "sub_reason": "top_level_decode", "errors": [ "certificate_offset_past_eof" ], - "sub_reason": "top_level_decode" + "reason": "certificate_table_malformed" } }, { @@ -206,10 +206,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_directory_out_of_bounds", "rva": 12032, "size": 8192, - "size_of_image": 12288 + "size_of_image": 12288, + "reason": "exception_directory_out_of_bounds" } }, { @@ -218,10 +218,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_directory_size_not_multiple", "size": 8192, "entry_size": 12, - "remainder": 8 + "remainder": 8, + "reason": "exception_directory_size_not_multiple" } }, { @@ -230,8 +230,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_table_truncated", - "table": "exception_table_ragged_tail" + "table": "exception_table_ragged_tail", + "reason": "exception_table_truncated" } }, { @@ -240,8 +240,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_table_truncated", - "table": "exception_entry_read_failed" + "table": "exception_entry_read_failed", + "reason": "exception_table_truncated" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json index c3062e6..3304685 100644 --- a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json +++ b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json @@ -634,9 +634,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -645,9 +645,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -656,11 +656,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 5368717704, "dispatch": 5368717720, "table": 0, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -669,9 +669,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 5368721472, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json b/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json index 2e953f3..6a2786f 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json @@ -144,10 +144,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 392 + "overlay_offset": 392, + "reason": "entrypoint_in_overlay" } }, { @@ -156,13 +156,13 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_raw_mismatch", "directory": "IMAGE_DIRECTORY_ENTRY_EXCEPTION", "rva": 6144, "raw_offset": 2560, "section": ".text", "section_raw_start": 512, - "section_raw_end": 1024 + "section_raw_end": 1024, + "reason": "data_directory_raw_mismatch" } }, { @@ -171,10 +171,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_in_overlay", "directory": "IMAGE_DIRECTORY_ENTRY_EXCEPTION", "rva": 6144, - "raw_offset": 2560 + "raw_offset": 2560, + "reason": "data_directory_in_overlay" } }, { @@ -183,10 +183,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_directory_size_not_multiple", "size": 256, "entry_size": 12, - "remainder": 4 + "remainder": 4, + "reason": "exception_directory_size_not_multiple" } }, { @@ -195,8 +195,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_table_truncated", - "table": "exception_table_ragged_tail" + "table": "exception_table_ragged_tail", + "reason": "exception_table_truncated" } }, { @@ -205,8 +205,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_table_truncated", - "table": "exception_entry_truncated" + "table": "exception_entry_truncated", + "reason": "exception_table_truncated" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json index 482fe8e..23d63f9 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 512 + "overlay_offset": 512, + "reason": "entrypoint_in_overlay" } }, { @@ -164,10 +164,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".zero", "raw_address": 0, - "size_of_headers": 512 + "size_of_headers": 512, + "reason": "section_overlaps_headers" } }, { @@ -176,8 +176,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_zero_length", - "section": ".zero" + "section": ".zero", + "reason": "section_zero_length" } }, { @@ -186,11 +186,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_out_of_order_raw", "raw_addresses": [ 512, 0 - ] + ], + "reason": "section_out_of_order_raw" } }, { @@ -199,11 +199,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_IMPORT", "rva": 36864, "size": 512, - "size_of_image": 16384 + "size_of_image": 16384, + "reason": "data_directory_out_of_range" } }, { @@ -212,10 +212,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_size_nonzero_rva", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 4096, - "size": 0 + "size": 0, + "reason": "data_directory_zero_size_nonzero_rva" } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/filepaths_strings_adversarial.full.json b/tests/contract/snapshots/layer3_adversarial/filepaths_strings_adversarial.full.json index 213c0ca..b67a383 100644 --- a/tests/contract/snapshots/layer3_adversarial/filepaths_strings_adversarial.full.json +++ b/tests/contract/snapshots/layer3_adversarial/filepaths_strings_adversarial.full.json @@ -27,7 +27,7 @@ "~user/docs/readme.md", "%APPDATA%\\MyApp\\config.json", "$HOME/.config/tool/settings.ini", - "C:\\Users\\Pub", + "C:\\Users\\Pub", "/usr/loc", "C:\\Temp\\my", "/var/log/my", diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json index 3d17ebf..befd79b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json index 7e6447e..6e167a1 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 4294967295, "size_of_image": 16384, - "position": "beyond_size_of_image" + "position": "beyond_size_of_image", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json index a5a4d8a..fb21b20 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json @@ -160,9 +160,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 512, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -171,10 +171,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 512, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json index e92bf5a..8cc08f3 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 7936, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json index 579fed2..b95e353 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_section_not_executable", "entry_point": 8208, "section": ".rdata", - "characteristics": 1073741888 + "characteristics": 1073741888, + "reason": "entrypoint_section_not_executable" } }, { @@ -172,10 +172,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_non_code_section", "entry_point": 8208, "section": ".rdata", - "characteristics": 1073741888 + "characteristics": 1073741888, + "reason": "entrypoint_in_non_code_section" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json index 8071d3a..50b9c57 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_section_not_executable", "entry_point": 12320, "section": ".rsrc", - "characteristics": 1073741888 + "characteristics": 1073741888, + "reason": "entrypoint_section_not_executable" } }, { @@ -172,10 +172,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_non_code_section", "entry_point": 12320, "section": ".rsrc", - "characteristics": 1073741888 + "characteristics": 1073741888, + "reason": "entrypoint_in_non_code_section" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json index 8e20fe3..ea464bf 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_discardable_section", "entry_point": 4112, "section": ".text", - "characteristics": 1644167200 + "characteristics": 1644167200, + "reason": "entrypoint_in_discardable_section" } }, { @@ -172,9 +172,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_discardable_code", "section": ".text", - "characteristics": 1644167200 + "characteristics": 1644167200, + "reason": "section_discardable_code" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json index 46463ec..7924d01 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_truncated_region", "entry_point": 4096, "section": ".text", - "sub_reason": "zero_length_section" + "sub_reason": "zero_length_section", + "reason": "entrypoint_in_truncated_region" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json index ab38a91..7948697 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 6144, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json index fdd57b6..521cab6 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json @@ -160,10 +160,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 20480, "size_of_image": 16384, - "position": "beyond_size_of_image" + "position": "beyond_size_of_image", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json index f79d137..f022930 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json @@ -171,9 +171,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "rwx_section", "section": ".text", - "characteristics": 3758096416 + "characteristics": 3758096416, + "reason": "rwx_section" } }, { @@ -182,8 +182,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -192,9 +192,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -203,10 +203,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -215,9 +215,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_rwx", "section": ".text", - "characteristics": 3758096416 + "characteristics": 3758096416, + "reason": "section_rwx" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json index 668cf6a..66d8961 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_non_executable_code_like", "section": ".text", - "characteristics": 1073741856 + "characteristics": 1073741856, + "reason": "section_non_executable_code_like" } }, { @@ -204,9 +204,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_codelike_name_not_executable", "section": ".text", - "characteristics": 1073741856 + "characteristics": 1073741856, + "reason": "section_codelike_name_not_executable" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json index 1dd4f49..e26cb62 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_non_executable_code_like", "section": ".text", - "characteristics": 1073741856 + "characteristics": 1073741856, + "reason": "section_non_executable_code_like" } }, { @@ -204,9 +204,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_codelike_name_not_executable", "section": ".text", - "characteristics": 1073741856 + "characteristics": 1073741856, + "reason": "section_codelike_name_not_executable" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json index fb8cb1b..4329147 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,8 +193,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_name_empty_or_padding", - "section": "" + "section": "", + "reason": "section_name_empty_or_padding" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json index 08fbf90..628fa77 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,8 +193,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_name_empty_or_padding", - "section": "" + "section": "", + "reason": "section_name_empty_or_padding" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json index 77355f3..31bf5ee 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json @@ -171,9 +171,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "rwx_section", "section": ".text", - "characteristics": 3791650848 + "characteristics": 3791650848, + "reason": "rwx_section" } }, { @@ -182,8 +182,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -192,9 +192,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -203,10 +203,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -215,9 +215,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_rwx", "section": ".text", - "characteristics": 3791650848 + "characteristics": 3791650848, + "reason": "section_rwx" } }, { @@ -226,9 +226,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_impossible_flags", "section": ".text", - "characteristics": 3791650848 + "characteristics": 3791650848, + "reason": "section_impossible_flags" } }, { @@ -237,9 +237,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_discardable_code", "section": ".text", - "characteristics": 3791650848 + "characteristics": 3791650848, + "reason": "section_discardable_code" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json index c98efee..b74ec7d 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,11 +193,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".text", "raw_address": 1040, "raw_size": 512, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -206,9 +206,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_raw_overlap" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json index d959f00..a980dbc 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,10 +193,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".text", "raw_address": 512, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "section_overlaps_headers" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json index 2b655bc..1be44f3 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,8 +193,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_zero_length", - "section": ".text" + "section": ".text", + "reason": "section_zero_length" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json index 7b5c66d..4e345f4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,11 +193,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".rdata", "raw_address": 1280, "raw_size": 512, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -206,9 +206,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_raw_overlap" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json index 209fd57..965a0bf 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json @@ -179,8 +179,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -189,9 +189,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -200,10 +200,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -212,9 +212,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_overlap" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json index 690da5b..3e91e53 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,12 +193,12 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_out_of_order_raw", "raw_addresses": [ 1536, 1024, 2048 - ] + ], + "reason": "section_out_of_order_raw" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json index c93ec7c..736baa4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,12 +193,12 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_out_of_order_raw", "raw_addresses": [ 1536, 1024, 2048 - ] + ], + "reason": "section_out_of_order_raw" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json index 4d94344..43d789a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json @@ -134,8 +134,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -144,9 +144,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json index 6eea2ca..06f04fd 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 8192, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 8192, - "max_section_end": 16384 + "max_section_end": 16384, + "reason": "optional_header_inconsistent_size" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json index bd48465..698fbfc 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json index 595f1fb..7f165be 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 512 + "size_of_headers": 512, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json index 8f92fba..c85e526 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_section_alignment", "section_alignment": 384, - "file_alignment": 512 + "file_alignment": 512, + "reason": "optional_header_invalid_section_alignment" } }, { @@ -204,9 +204,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_section_alignment", "section_alignment": 384, - "sub_reason": "not_power_of_two" + "sub_reason": "not_power_of_two", + "reason": "optional_header_invalid_section_alignment" } }, { @@ -215,9 +215,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_size_of_image_misaligned", "size_of_image": 16384, - "section_alignment": 384 + "section_alignment": 384, + "reason": "optional_header_size_of_image_misaligned" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json index 89f2daa..53bb647 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1536 + "size_of_headers": 1536, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,11 +193,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".text", "raw_address": 1024, "raw_size": 512, - "file_alignment": 768 + "file_alignment": 768, + "reason": "section_raw_misaligned" } }, { @@ -206,10 +206,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".text", "raw_address": 1024, - "size_of_headers": 1536 + "size_of_headers": 1536, + "reason": "section_overlaps_headers" } }, { @@ -218,11 +218,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".rsrc", "raw_address": 2048, "raw_size": 512, - "file_alignment": 768 + "file_alignment": 768, + "reason": "section_raw_misaligned" } }, { @@ -231,9 +231,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_file_alignment", "file_alignment": 768, - "sub_reason": "not_power_of_two" + "sub_reason": "not_power_of_two", + "reason": "optional_header_invalid_file_alignment" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json index 22b3fc8..6228b0b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 4096, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 4096, - "max_section_end": 16384 + "max_section_end": 16384, + "reason": "optional_header_inconsistent_size" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json index fc3cdb0..579f187 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json index ba2eccd..e26f538 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,8 +193,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", - "number_of_rva_and_sizes": 20 + "number_of_rva_and_sizes": 20, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json index 068e3a3..e1d445f 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 1, - "actual_directories": 2 + "actual_directories": 2, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json index 6240b9e..4d34695 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 6144, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 6144, - "max_section_end": 16384 + "max_section_end": 16384, + "reason": "optional_header_inconsistent_size" } }, { @@ -204,9 +204,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_size_of_image_misaligned", "size_of_image": 6144, - "section_alignment": 4096 + "section_alignment": 4096, + "reason": "optional_header_size_of_image_misaligned" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json index ecf8645..a0d5b6e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json index 0dafe30..b1b3d32 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json index 959304c..4d2f8a3 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json index 02e8f93..2d93abd 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json index 4f2fc11..b5cbebd 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json index d2202fe..d07aaac 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json index 3a3086e..202d328 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json index a0d769a..855d82e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json index a0b304b..adacce6 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json index 399ed1c..d0c2688 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json index 9281212..14c1ecb 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 1 + "actual_directories": 1, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json index dce5414..aa7e52e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json @@ -160,8 +160,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_zero_or_negative", - "entry_point": 0 + "entry_point": 0, + "reason": "entrypoint_zero_or_negative" } }, { @@ -170,9 +170,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_headers", "entry_point": 0, - "size_of_headers": 1024 + "size_of_headers": 1024, + "reason": "entrypoint_in_headers" } }, { @@ -181,10 +181,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 0, "size_of_image": 16384, - "position": "within_size_of_image_but_no_section" + "position": "within_size_of_image_but_no_section", + "reason": "entrypoint_out_of_bounds" } }, { @@ -193,9 +193,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 0, - "actual_directories": 2 + "actual_directories": 2, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json index deaf801..e169e51 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json @@ -187,10 +187,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 12288, "size_of_image": 8192, - "position": "beyond_size_of_image" + "position": "beyond_size_of_image", + "reason": "entrypoint_out_of_bounds" } }, { @@ -199,11 +199,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".rdata", "raw_address": 768, "raw_size": 1536, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -212,11 +212,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".data", "raw_address": 2384, "raw_size": 768, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -225,9 +225,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_raw_overlap" } }, { @@ -236,9 +236,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".data", - "section_b": ".rsrc" + "section_b": ".rsrc", + "reason": "section_raw_overlap" } }, { @@ -247,9 +247,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_overlap" } }, { @@ -258,9 +258,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 8192, - "max_section_end": 11776 + "max_section_end": 11776, + "reason": "optional_header_inconsistent_size" } }, { @@ -269,11 +269,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_IMPORT", "rva": 20480, "size": 512, - "size_of_image": 8192 + "size_of_image": 8192, + "reason": "data_directory_out_of_range" } }, { @@ -282,10 +282,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_rva_nonzero_size", "directory": "IMAGE_DIRECTORY_ENTRY_RESOURCE", "rva": 0, - "size": 256 + "size": 256, + "reason": "data_directory_zero_rva_nonzero_size" } }, { @@ -294,10 +294,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_directory_size_not_multiple", "size": 512, "entry_size": 12, - "remainder": 8 + "remainder": 8, + "reason": "exception_directory_size_not_multiple" } }, { @@ -306,8 +306,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_table_truncated", - "table": "exception_table_ragged_tail" + "table": "exception_table_ragged_tail", + "reason": "exception_table_truncated" } }, { @@ -316,8 +316,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_table_truncated", - "table": "exception_entry_read_failed" + "table": "exception_entry_read_failed", + "reason": "exception_table_truncated" } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json index 811ff85..9a7a164 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json @@ -187,10 +187,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 12288, "size_of_image": 8192, - "position": "beyond_size_of_image" + "position": "beyond_size_of_image", + "reason": "entrypoint_out_of_bounds" } }, { @@ -199,11 +199,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".rdata", "raw_address": 768, "raw_size": 1536, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -212,11 +212,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".data", "raw_address": 2384, "raw_size": 768, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -225,9 +225,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_raw_overlap" } }, { @@ -236,9 +236,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".data", - "section_b": ".rsrc" + "section_b": ".rsrc", + "reason": "section_raw_overlap" } }, { @@ -247,9 +247,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlap", "section_a": ".text", - "section_b": ".rdata" + "section_b": ".rdata", + "reason": "section_overlap" } }, { @@ -258,9 +258,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 8192, - "max_section_end": 11776 + "max_section_end": 11776, + "reason": "optional_header_inconsistent_size" } }, { @@ -269,11 +269,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_IMPORT", "rva": 20480, "size": 512, - "size_of_image": 8192 + "size_of_image": 8192, + "reason": "data_directory_out_of_range" } }, { @@ -282,10 +282,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_rva_nonzero_size", "directory": "IMAGE_DIRECTORY_ENTRY_RESOURCE", "rva": 0, - "size": 256 + "size": 256, + "reason": "data_directory_zero_rva_nonzero_size" } }, { @@ -294,10 +294,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_directory_size_not_multiple", "size": 512, "entry_size": 12, - "remainder": 8 + "remainder": 8, + "reason": "exception_directory_size_not_multiple" } }, { @@ -306,9 +306,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "exception_unsupported_machine", "arch": "unsupported", - "machine": 332 + "machine": 332, + "reason": "exception_unsupported_machine" } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json index 2cb4449..6748d29 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json @@ -671,9 +671,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "OutputDebugStringA" + "function": "OutputDebugStringA", + "reason": "anti_debug_api_import" } }, { @@ -682,9 +682,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -693,9 +693,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -704,11 +704,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 5368717712, "dispatch": 5368717728, "table": 0, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -717,9 +717,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 5368725760, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json b/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json index b5f0997..ba55624 100644 --- a/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json +++ b/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json @@ -691,8 +691,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "packer_section_name", - "section": "UPX0" + "section": "UPX0", + "reason": "packer_section_name" } }, { @@ -701,9 +701,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "CheckRemoteDebuggerPresent" + "function": "CheckRemoteDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -712,9 +712,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "GetTickCount" + "function": "GetTickCount", + "reason": "timing_api_import" } }, { @@ -723,9 +723,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "GetTickCount64" + "function": "GetTickCount64", + "reason": "timing_api_import" } }, { @@ -734,9 +734,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -745,9 +745,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "OutputDebugStringA" + "function": "OutputDebugStringA", + "reason": "anti_debug_api_import" } }, { @@ -756,9 +756,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -767,10 +767,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".bss", "raw_address": 0, - "size_of_headers": 1536 + "size_of_headers": 1536, + "reason": "section_overlaps_headers" } }, { @@ -779,7 +779,6 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_out_of_order_raw", "raw_addresses": [ 1536, 8192, @@ -802,7 +801,8 @@ 89600, 95744, 100864 - ] + ], + "reason": "section_out_of_order_raw" } }, { @@ -811,9 +811,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_overlap", "directory_a": "IMAGE_DIRECTORY_ENTRY_IMPORT", - "directory_b": "IMAGE_DIRECTORY_ENTRY_IAT" + "directory_b": "IMAGE_DIRECTORY_ENTRY_IAT", + "reason": "data_directory_overlap" } }, { @@ -822,10 +822,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "callback_outside_tls_range", "callbacks": 5368754232, "start_address": 5368758272, - "end_address": 5368758280 + "end_address": 5368758280, + "reason": "callback_outside_tls_range" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json index 9531b44..b61185b 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json @@ -134,9 +134,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_size_of_headers", "size_of_headers": 2048, - "file_alignment": 16384 + "file_alignment": 16384, + "reason": "optional_header_invalid_size_of_headers" } }, { @@ -145,9 +145,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_section_alignment", "section_alignment": 4096, - "file_alignment": 16384 + "file_alignment": 16384, + "reason": "optional_header_invalid_section_alignment" } }, { @@ -156,9 +156,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 1, - "actual_directories": 3 + "actual_directories": 3, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } }, { @@ -167,9 +167,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_size_of_image_misaligned", "size_of_image": 512, - "section_alignment": 4096 + "section_alignment": 4096, + "reason": "optional_header_size_of_image_misaligned" } }, { @@ -178,11 +178,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_EXPORT", "rva": 4096, "size": 512, - "size_of_image": 512 + "size_of_image": 512, + "reason": "data_directory_out_of_range" } }, { @@ -191,11 +191,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "export_directory_invalid_header", "sub_reason": "top_level_decode", "errors": [ "header_read_failed" - ] + ], + "reason": "export_directory_invalid_header" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json index 5dd69d1..870dcd5 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json @@ -144,10 +144,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_out_of_bounds", "entry_point": 2415919104, "size_of_image": 512, - "position": "beyond_size_of_image" + "position": "beyond_size_of_image", + "reason": "entrypoint_out_of_bounds" } }, { @@ -156,11 +156,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".text", "raw_address": 512, "raw_size": 512, - "file_alignment": 16384 + "file_alignment": 16384, + "reason": "section_raw_misaligned" } }, { @@ -169,10 +169,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".text", "raw_address": 512, - "size_of_headers": 2048 + "size_of_headers": 2048, + "reason": "section_overlaps_headers" } }, { @@ -181,9 +181,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 512, - "max_section_end": 8192 + "max_section_end": 8192, + "reason": "optional_header_inconsistent_size" } }, { @@ -192,9 +192,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_size_of_headers", "size_of_headers": 2048, - "file_alignment": 16384 + "file_alignment": 16384, + "reason": "optional_header_invalid_size_of_headers" } }, { @@ -203,9 +203,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_section_alignment", "section_alignment": 4096, - "file_alignment": 16384 + "file_alignment": 16384, + "reason": "optional_header_invalid_section_alignment" } }, { @@ -214,9 +214,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_invalid_number_of_rva_and_sizes", "number_of_rva_and_sizes": 1, - "actual_directories": 3 + "actual_directories": 3, + "reason": "optional_header_invalid_number_of_rva_and_sizes" } }, { @@ -225,9 +225,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_size_of_image_misaligned", "size_of_image": 512, - "section_alignment": 4096 + "section_alignment": 4096, + "reason": "optional_header_size_of_image_misaligned" } }, { @@ -236,11 +236,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_EXPORT", "rva": 4096, "size": 512, - "size_of_image": 512 + "size_of_image": 512, + "reason": "data_directory_out_of_range" } }, { @@ -249,10 +249,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "export_directory_out_of_bounds", "rva": 4096, "size": 512, - "size_of_image": 512 + "size_of_image": 512, + "reason": "export_directory_out_of_bounds" } }, { @@ -261,8 +261,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "export_table_truncated", - "table": "export_directory_header" + "table": "export_directory_header", + "reason": "export_table_truncated" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json index d2997a3..ae24760 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json @@ -144,11 +144,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_misaligned", "section": ".text", "raw_address": 291, "raw_size": 4096, - "file_alignment": 512 + "file_alignment": 512, + "reason": "section_raw_misaligned" } }, { @@ -157,10 +157,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlaps_headers", "section": ".text", "raw_address": 291, - "size_of_headers": 512 + "size_of_headers": 512, + "reason": "section_overlaps_headers" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json index 9597a2c..cfc66c4 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_too_small", "rva": 12288, "size": 12, - "min_size": 112 + "min_size": 112, + "reason": "load_config_too_small" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json index b300fe8..7f7f4de 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json @@ -152,11 +152,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 12800, "section": ".rdata", "characteristics": 1073741888, - "sub_reason": "non_writable_section" + "sub_reason": "non_writable_section", + "reason": "load_config_cookie_invalid" } }, { @@ -165,10 +165,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_in_overlay", "cookie_rva": 12800, "cookie_raw": 2048, - "overlay_offset": 2048 + "overlay_offset": 2048, + "reason": "load_config_cookie_in_overlay" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json index 824a04d..9e870af 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json @@ -152,9 +152,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 2415919104, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json index 08bac37..2802039 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json @@ -152,11 +152,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 13824, "dispatch": 0, "table": 14080, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -165,11 +165,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 13568, "section": ".rdata", "characteristics": 1073741888, - "sub_reason": "non_writable_section" + "sub_reason": "non_writable_section", + "reason": "load_config_cookie_invalid" } }, { @@ -178,10 +178,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_in_overlay", "cookie_rva": 13568, "cookie_raw": 2816, - "overlay_offset": 1684 + "overlay_offset": 1684, + "reason": "load_config_cookie_in_overlay" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json index 7ba654a..785d083 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json @@ -152,11 +152,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 13568, "section": ".rdata", "characteristics": 1073741888, - "sub_reason": "non_writable_section" + "sub_reason": "non_writable_section", + "reason": "load_config_cookie_invalid" } }, { @@ -165,10 +165,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_in_overlay", "cookie_rva": 13568, "cookie_raw": 2816, - "overlay_offset": 1684 + "overlay_offset": 1684, + "reason": "load_config_cookie_in_overlay" } }, { @@ -177,10 +177,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_seh_invalid", "seh_table_rva": 0, "seh_count": 4, - "sub_reason": "missing_table_rva" + "sub_reason": "missing_table_rva", + "reason": "load_config_seh_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json index 5e61dd2..d5340db 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json @@ -152,11 +152,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 12288, "size": 8192, - "size_of_image": 16384 + "size_of_image": 16384, + "reason": "data_directory_out_of_range" } }, { @@ -165,9 +165,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 13568, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json index c9e55e4..21093ab 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_too_small", "rva": 12288, "size": 32, - "min_size": 112 + "min_size": 112, + "reason": "load_config_too_small" } }, { @@ -164,11 +164,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 13568, "section": ".rdata", "characteristics": 1073741888, - "sub_reason": "non_writable_section" + "sub_reason": "non_writable_section", + "reason": "load_config_cookie_invalid" } }, { @@ -177,10 +177,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_in_overlay", "cookie_rva": 13568, "cookie_raw": 2816, - "overlay_offset": 1664 + "overlay_offset": 1664, + "reason": "load_config_cookie_in_overlay" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json index 0b1d2ca..f30e13c 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_truncated", "rva": 12288, "declared_size": 112, - "parsed_size": 64 + "parsed_size": 64, + "reason": "load_config_truncated" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json index fb776cf..a9069d7 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json @@ -152,11 +152,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 4294967295, "size": 148, - "size_of_image": 16384 + "size_of_image": 16384, + "reason": "data_directory_out_of_range" } }, { @@ -165,10 +165,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_truncated", "rva": 4294967295, "declared_size": 148, - "parsed_size": 0 + "parsed_size": 0, + "reason": "load_config_truncated" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json index b2b7cd3..4e16e52 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_rva_nonzero_size", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 0, - "size": 148 + "size": 148, + "reason": "data_directory_zero_rva_nonzero_size" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json index 9b46425..3497518 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_size_nonzero_rva", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 12288, - "size": 0 + "size": 0, + "reason": "data_directory_zero_size_nonzero_rva" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json index 09f061e..6e4fd7c 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_size_nonzero_rva", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 2415919104, - "size": 0 + "size": 0, + "reason": "data_directory_zero_size_nonzero_rva" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json index f1ebb87..5fae225 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json @@ -152,10 +152,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_zero_size_nonzero_rva", "directory": "IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG", "rva": 12288, - "size": 0 + "size": 0, + "reason": "data_directory_zero_size_nonzero_rva" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json index a8d430a..64ac81a 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json @@ -649,9 +649,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "OutputDebugStringA" + "function": "OutputDebugStringA", + "reason": "anti_debug_api_import" } }, { @@ -660,9 +660,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -671,9 +671,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -682,11 +682,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 5368717712, "dispatch": 5368717728, "table": 0, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -695,9 +695,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 5368721536, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json index e830271..e74b10e 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json @@ -144,10 +144,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 392 + "overlay_offset": 392, + "reason": "entrypoint_in_overlay" } }, { @@ -156,11 +156,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "data_directory_out_of_range", "directory": "IMAGE_DIRECTORY_ENTRY_IMPORT", "rva": 3735928559, "size": 512, - "size_of_image": 12288 + "size_of_image": 12288, + "reason": "data_directory_out_of_range" } }, { diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json index 933aa6f..3977317 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json @@ -655,9 +655,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "OutputDebugStringA" + "function": "OutputDebugStringA", + "reason": "anti_debug_api_import" } }, { @@ -666,9 +666,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -677,9 +677,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -688,11 +688,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 5368717712, "dispatch": 5368717728, "table": 0, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -701,9 +701,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 5368721600, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json index bcaf223..46a3df7 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json @@ -653,9 +653,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "OutputDebugStringA" + "function": "OutputDebugStringA", + "reason": "anti_debug_api_import" } }, { @@ -664,9 +664,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -675,9 +675,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -686,11 +686,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 5368717712, "dispatch": 5368717728, "table": 0, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -699,9 +699,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 5368721536, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json b/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json index fea2f64..1e1636e 100644 --- a/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json @@ -171,10 +171,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 392 + "overlay_offset": 392, + "reason": "entrypoint_in_overlay" } }, { @@ -183,9 +183,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_raw_overlap", "section_a": ".text", - "section_b": ".data" + "section_b": ".data", + "reason": "section_raw_overlap" } }, { @@ -194,9 +194,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "section_overlap", "section_a": ".text", - "section_b": ".data" + "section_b": ".data", + "reason": "section_overlap" } }, { @@ -205,9 +205,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 12288, - "max_section_end": 14336 + "max_section_end": 14336, + "reason": "optional_header_inconsistent_size" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json b/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json index 6d07915..800c5eb 100644 --- a/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json +++ b/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json @@ -192,10 +192,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "high_entropy_section", "section": ".text", "entropy": 7.980294617270556, - "raw_size": 8192 + "raw_size": 8192, + "reason": "high_entropy_section" } }, { @@ -204,8 +204,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "packer_section_name", - "section": ".upx0" + "section": ".upx0", + "reason": "packer_section_name" } }, { @@ -214,8 +214,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "packer_section_name", - "section": ".upx1" + "section": ".upx1", + "reason": "packer_section_name" } }, { @@ -224,9 +224,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "optional_header_inconsistent_size", "size_of_image": 16384, - "max_section_end": 20480 + "max_section_end": 20480, + "reason": "optional_header_inconsistent_size" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json index 21978d8..8354d67 100644 --- a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json +++ b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json @@ -646,9 +646,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "OutputDebugStringA" + "function": "OutputDebugStringA", + "reason": "anti_debug_api_import" } }, { @@ -657,9 +657,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "anti_debug_api_import", "dll": "kernel32.dll", - "function": "IsDebuggerPresent" + "function": "IsDebuggerPresent", + "reason": "anti_debug_api_import" } }, { @@ -668,9 +668,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "timing_api_import", "dll": "kernel32.dll", - "function": "QueryPerformanceCounter" + "function": "QueryPerformanceCounter", + "reason": "timing_api_import" } }, { @@ -679,11 +679,11 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_guard_cf_inconsistent", "check": 5368717712, "dispatch": 5368717728, "table": 0, - "count": 0 + "count": 0, + "reason": "load_config_guard_cf_inconsistent" } }, { @@ -692,9 +692,9 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "load_config_cookie_invalid", "cookie_rva": 5368721472, - "sub_reason": "unmapped" + "sub_reason": "unmapped", + "reason": "load_config_cookie_invalid" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json b/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json index e7d3f86..871f898 100644 --- a/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json @@ -144,10 +144,10 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "entrypoint_in_overlay", "entry_point": 4096, "entry_point_file_offset": 512, - "overlay_offset": 392 + "overlay_offset": 392, + "reason": "entrypoint_in_overlay" } } ] diff --git a/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json b/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json index 821c000..0577dbe 100644 --- a/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json +++ b/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json @@ -179,8 +179,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "packer_section_name", - "section": ".upx0" + "section": ".upx0", + "reason": "packer_section_name" } }, { @@ -189,8 +189,8 @@ "end": 0, "category": "pe_heuristic", "metadata": { - "reason": "packer_section_name", - "section": ".upx1" + "section": ".upx1", + "reason": "packer_section_name" } } ] From c391a0e9329673f515a3dee860255efa1483c107 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 8 Sep 2026 10:22:12 +0100 Subject: [PATCH 32/40] Version-info metadata addition to snapshots --- tests/contract/snapshots/basic.json | 1 + tests/contract/snapshots/core.json | 3 ++- tests/contract/snapshots/deep.json | 1 + tests/contract/snapshots/enrich.json | 1 + tests/contract/snapshots/full.json | 1 + tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json | 3 ++- .../contract/snapshots/layer2_edge/load_config_clang.full.json | 1 + .../snapshots/layer2_edge/load_config_cookie_valid.full.json | 1 + .../snapshots/layer2_edge/load_config_full_msvc.full.json | 1 + .../snapshots/layer2_edge/load_config_large_padded.full.json | 1 + .../snapshots/layer2_edge/load_config_minimal_mingw.full.json | 1 + .../snapshots/layer2_edge/load_config_seh_table.full.json | 1 + .../layer3_adversarial/broken_rva_addresses.full.json | 1 + .../layer3_adversarial/corrupted_data_directories.full.json | 1 + .../layer3_adversarial/crypto_entropy_payload.full.json | 1 + .../layer3_adversarial/directory_raw_mismatch.full.json | 1 + .../directory_zero_size_nonzero_rva.full.json | 1 + .../layer3_adversarial/fixture_000_entrypoint_zero.full.json | 1 + .../fixture_001_entrypoint_negative.full.json | 1 + .../fixture_002_entrypoint_in_headers.full.json | 1 + .../fixture_003_entrypoint_gap_between_sections.full.json | 1 + .../fixture_004_entrypoint_non_exec_section.full.json | 1 + .../layer3_adversarial/fixture_005_entrypoint_rsrc.full.json | 1 + .../fixture_006_entrypoint_discardable.full.json | 1 + .../fixture_007_entrypoint_zero_length_section.full.json | 1 + .../fixture_008_entrypoint_beyond_virtual_size.full.json | 1 + .../fixture_009_entrypoint_in_overlay.full.json | 1 + .../layer3_adversarial/fixture_010_sections_rwx.full.json | 1 + .../fixture_011_sections_code_not_exec.full.json | 1 + .../fixture_012_sections_codelike_not_exec.full.json | 1 + .../fixture_013_sections_non_ascii_name.full.json | 1 + .../fixture_014_sections_empty_name.full.json | 1 + .../fixture_015_sections_impossible_flags.full.json | 1 + .../fixture_016_sections_raw_misaligned.full.json | 1 + .../fixture_017_sections_overlap_headers.full.json | 1 + .../fixture_018_sections_zero_length.full.json | 1 + .../fixture_019_sections_raw_overlap.full.json | 1 + .../fixture_020_sections_virtual_overlap.full.json | 1 + .../fixture_021_sections_out_of_order_raw.full.json | 1 + .../fixture_022_sections_out_of_order_virtual.full.json | 1 + .../fixture_023_sections_negative_fields.full.json | 1 + .../fixture_024_opt_size_of_image_too_small.full.json | 1 + .../fixture_025_opt_size_of_headers_misaligned.full.json | 1 + .../fixture_026_opt_size_of_headers_too_small.full.json | 1 + .../fixture_027_opt_section_alignment_invalid.full.json | 1 + .../fixture_028_opt_file_alignment_invalid.full.json | 1 + .../fixture_029_opt_size_fields_too_small.full.json | 1 + .../fixture_030_opt_image_base_misaligned.full.json | 1 + .../fixture_031_opt_num_dirs_invalid.full.json | 1 + .../fixture_032_opt_num_dirs_too_small.full.json | 1 + .../fixture_033_opt_size_of_image_misaligned.full.json | 1 + .../layer3_adversarial/fixture_034_ddir_negative_rva.full.json | 1 + .../fixture_035_ddir_negative_size.full.json | 1 + .../layer3_adversarial/fixture_036_ddir_zero_zero.full.json | 1 + .../fixture_037_ddir_zero_rva_nonzero_size.full.json | 1 + .../fixture_038_ddir_zero_size_nonzero_rva.full.json | 1 + .../layer3_adversarial/fixture_039_ddir_in_headers.full.json | 1 + .../layer3_adversarial/fixture_040_ddir_out_of_range.full.json | 1 + .../layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json | 1 + .../layer3_adversarial/fixture_042_ddir_in_overlay.full.json | 1 + .../layer3_adversarial/fixture_043_ddir_not_mapped.full.json | 1 + .../fixture_044_ddir_spans_sections.full.json | 1 + .../layer3_adversarial/fixture_045_ddir_overlap.full.json | 1 + .../layer3_adversarial/franken_malformed_pe.full.json | 1 + .../layer3_adversarial/franken_malformed_pe.pe32.full.json | 1 + .../layer3_adversarial/franken_url_domain_ip.full.json | 1 + .../snapshots/layer3_adversarial/heuristic_rich.full.json | 1 + .../layer3_adversarial/invalid_optional_header.full.json | 1 + .../layer3_adversarial/invalid_optional_header.pe32.full.json | 1 + .../layer3_adversarial/invalid_section_alignment.full.json | 1 + .../layer3_adversarial/load_config_cookie_too_small.full.json | 1 + .../load_config_malformed_cookie_in_overlay.full.json | 1 + .../load_config_malformed_cookie_invalid.full.json | 1 + .../load_config_malformed_guard_cf_inconsistent.full.json | 1 + .../load_config_malformed_seh_invalid.full.json | 1 + .../load_config_malformed_size_exceeds_section.full.json | 1 + .../load_config_malformed_size_too_small.full.json | 1 + .../load_config_malformed_truncated.full.json | 1 + .../layer3_adversarial/load_config_rva_negative.full.json | 1 + .../layer3_adversarial/load_config_rva_zero.full.json | 1 + .../load_config_zero_size_but_fields_present.full.json | 1 + .../load_config_zero_size_invalid_rva.full.json | 1 + .../load_config_zero_size_valid_rva.full.json | 1 + .../snapshots/layer3_adversarial/malformed_domain.full.json | 1 + .../layer3_adversarial/malformed_import_table.full.json | 1 + .../snapshots/layer3_adversarial/malformed_ip.full.json | 1 + .../snapshots/layer3_adversarial/malformed_url.full.json | 1 + .../layer3_adversarial/overlapping_sections.full.json | 1 + .../snapshots/layer3_adversarial/packed_lookalike.full.json | 1 + .../layer3_adversarial/string_obfuscation_tricks.full.json | 1 + .../layer3_adversarial/truncated_rich_header.full.json | 1 + .../snapshots/layer3_adversarial/upx_name_only.full.json | 1 + 92 files changed, 94 insertions(+), 2 deletions(-) diff --git a/tests/contract/snapshots/basic.json b/tests/contract/snapshots/basic.json index 7850153..8e7c078 100644 --- a/tests/contract/snapshots/basic.json +++ b/tests/contract/snapshots/basic.json @@ -48,6 +48,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [] } diff --git a/tests/contract/snapshots/core.json b/tests/contract/snapshots/core.json index 287c5fc..1b0d3f7 100644 --- a/tests/contract/snapshots/core.json +++ b/tests/contract/snapshots/core.json @@ -47,5 +47,6 @@ "rich_header": null, "signatures": [], "has_signature": false - } + }, + "version_info": null } diff --git a/tests/contract/snapshots/deep.json b/tests/contract/snapshots/deep.json index 538e0ca..00b1f4b 100644 --- a/tests/contract/snapshots/deep.json +++ b/tests/contract/snapshots/deep.json @@ -48,6 +48,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [], "obfuscation": [] diff --git a/tests/contract/snapshots/enrich.json b/tests/contract/snapshots/enrich.json index b83b22b..d2c265e 100644 --- a/tests/contract/snapshots/enrich.json +++ b/tests/contract/snapshots/enrich.json @@ -48,5 +48,6 @@ "signatures": [], "has_signature": false }, + "version_info": null, "enrichment": {} } diff --git a/tests/contract/snapshots/full.json b/tests/contract/snapshots/full.json index bf5cb34..84e080e 100644 --- a/tests/contract/snapshots/full.json +++ b/tests/contract/snapshots/full.json @@ -48,6 +48,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [], "obfuscation": [], diff --git a/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json b/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json index b626dab..723df78 100644 --- a/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json +++ b/tests/contract/snapshots/layer1_core/clean_iocx_demo.core.json @@ -319,5 +319,6 @@ "rich_header": null, "signatures": [], "has_signature": false - } + }, + "version_info": null } diff --git a/tests/contract/snapshots/layer2_edge/load_config_clang.full.json b/tests/contract/snapshots/layer2_edge/load_config_clang.full.json index b0ca6b8..b07de2c 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_clang.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_clang.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json b/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json index 725727e..c55bab9 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_cookie_valid.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json b/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json index 5bbd33c..7bf70b0 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_full_msvc.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json b/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json index 3b25e88..f226c89 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_large_padded.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json b/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json index 9564c56..2344ee5 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_minimal_mingw.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json b/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json index 619f069..cb8cc02 100644 --- a/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json +++ b/tests/contract/snapshots/layer2_edge/load_config_seh_table.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json index dc35ad8..e4a9c9f 100644 --- a/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json +++ b/tests/contract/snapshots/layer3_adversarial/broken_rva_addresses.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json b/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json index 23a830d..6fcfef5 100644 --- a/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json +++ b/tests/contract/snapshots/layer3_adversarial/corrupted_data_directories.full.json @@ -57,6 +57,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json index 3304685..2f65731 100644 --- a/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json +++ b/tests/contract/snapshots/layer3_adversarial/crypto_entropy_payload.full.json @@ -331,6 +331,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json b/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json index 6a2786f..9f57a08 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_raw_mismatch.full.json @@ -57,6 +57,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json index 23d63f9..a9a35a4 100644 --- a/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/directory_zero_size_nonzero_rva.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json index befd79b..58660ac 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_000_entrypoint_zero.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json index 6e167a1..c30192b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_001_entrypoint_negative.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json index fb21b20..4796c60 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_002_entrypoint_in_headers.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json index 8cc08f3..223ccc4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_003_entrypoint_gap_between_sections.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json index b95e353..198073e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_004_entrypoint_non_exec_section.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json index 50b9c57..602bbf4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_005_entrypoint_rsrc.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json index ea464bf..9955533 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_006_entrypoint_discardable.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json index 7924d01..385396d 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_007_entrypoint_zero_length_section.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json index 7948697..96f52fe 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_008_entrypoint_beyond_virtual_size.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json index 521cab6..b18d3a4 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_009_entrypoint_in_overlay.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json index f022930..4f5c5f6 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_010_sections_rwx.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json index 66d8961..011e609 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_011_sections_code_not_exec.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json index e26cb62..86fd754 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_012_sections_codelike_not_exec.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json index 4329147..716eea5 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_013_sections_non_ascii_name.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json index 628fa77..3ecba3c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_014_sections_empty_name.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json index 31bf5ee..83e69cc 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_015_sections_impossible_flags.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json index b74ec7d..294282d 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_016_sections_raw_misaligned.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json index a980dbc..2d3c4e9 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_017_sections_overlap_headers.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json index 1be44f3..168d784 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_018_sections_zero_length.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json index 4e345f4..881823a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_019_sections_raw_overlap.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json index 965a0bf..481e973 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_020_sections_virtual_overlap.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json index 3e91e53..66b1b69 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_021_sections_out_of_order_raw.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json index 736baa4..9b38607 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_022_sections_out_of_order_virtual.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json index 43d789a..1d020b8 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_023_sections_negative_fields.full.json @@ -55,6 +55,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [], "obfuscation": [], diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json index 06f04fd..8ae2010 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_024_opt_size_of_image_too_small.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json index 698fbfc..cb0a195 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_025_opt_size_of_headers_misaligned.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json index 7f165be..b3b0c1c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_026_opt_size_of_headers_too_small.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json index c85e526..a52f8b5 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_027_opt_section_alignment_invalid.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json index 53bb647..280b7ec 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_028_opt_file_alignment_invalid.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json index 6228b0b..b4e8319 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_029_opt_size_fields_too_small.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json index 579f187..2a87049 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_030_opt_image_base_misaligned.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json index e26f538..fd8f7cc 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_031_opt_num_dirs_invalid.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json index e1d445f..792f51b 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_032_opt_num_dirs_too_small.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json index 4d34695..bcd383c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_033_opt_size_of_image_misaligned.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json index a0d5b6e..d6cdc70 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_034_ddir_negative_rva.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json index b1b3d32..3573636 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_035_ddir_negative_size.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json index 4d2f8a3..89c681e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_036_ddir_zero_zero.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json index 2d93abd..67b5db0 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_037_ddir_zero_rva_nonzero_size.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json index b5cbebd..66b8040 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_038_ddir_zero_size_nonzero_rva.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json index d07aaac..f6158b1 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_039_ddir_in_headers.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json index 202d328..cc94c1c 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_040_ddir_out_of_range.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json index 855d82e..17a7bed 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_041_ddir_raw_mismatch.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json index adacce6..d5034fe 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_042_ddir_in_overlay.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json index d0c2688..9bc242a 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_043_ddir_not_mapped.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json index 14c1ecb..8ff4cf6 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_044_ddir_spans_sections.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json b/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json index aa7e52e..132682e 100644 --- a/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json +++ b/tests/contract/snapshots/layer3_adversarial/fixture_045_ddir_overlap.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json index e169e51..9a92167 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.full.json @@ -60,6 +60,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json index 9a7a164..95a821c 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_malformed_pe.pe32.full.json @@ -60,6 +60,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json index 6748d29..31bb10d 100644 --- a/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/franken_url_domain_ip.full.json @@ -367,6 +367,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json b/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json index ba55624..1201d02 100644 --- a/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json +++ b/tests/contract/snapshots/layer3_adversarial/heuristic_rich.full.json @@ -359,6 +359,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json index b61185b..bf55178 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.full.json @@ -55,6 +55,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [], "obfuscation": [], diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json index 870dcd5..ffa5427 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_optional_header.pe32.full.json @@ -57,6 +57,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json b/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json index ae24760..e4ce97c 100644 --- a/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json +++ b/tests/contract/snapshots/layer3_adversarial/invalid_section_alignment.full.json @@ -57,6 +57,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json index cfc66c4..ab7786e 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_cookie_too_small.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json index 7f7f4de..435e5e2 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_in_overlay.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json index 9e870af..8a8d561 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_cookie_invalid.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json index 2802039..a2a31f4 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_guard_cf_inconsistent.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json index 785d083..f491e14 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_seh_invalid.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json index d5340db..c13418b 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_exceeds_section.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json index 21093ab..ceb4454 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_size_too_small.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json index f30e13c..f6065b4 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_malformed_truncated.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json index a9069d7..f77bcea 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_rva_negative.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json index 4e16e52..52a14d7 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_rva_zero.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json index 3497518..ebf23b7 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_but_fields_present.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json index 6e4fd7c..93d45c0 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_invalid_rva.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json index 5fae225..54ba9c1 100644 --- a/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json +++ b/tests/contract/snapshots/layer3_adversarial/load_config_zero_size_valid_rva.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json index 64ac81a..a9022fa 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_domain.full.json @@ -345,6 +345,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json index e74b10e..979dddf 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_import_table.full.json @@ -57,6 +57,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json index 3977317..f180932 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_ip.full.json @@ -351,6 +351,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json index 46a3df7..86b6ed8 100644 --- a/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json +++ b/tests/contract/snapshots/layer3_adversarial/malformed_url.full.json @@ -349,6 +349,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json b/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json index 1e1636e..13700d7 100644 --- a/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json +++ b/tests/contract/snapshots/layer3_adversarial/overlapping_sections.full.json @@ -58,6 +58,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json b/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json index 800c5eb..1ac87d1 100644 --- a/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json +++ b/tests/contract/snapshots/layer3_adversarial/packed_lookalike.full.json @@ -61,6 +61,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json index 8354d67..a67a97e 100644 --- a/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json +++ b/tests/contract/snapshots/layer3_adversarial/string_obfuscation_tricks.full.json @@ -342,6 +342,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json b/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json index 871f898..826ab80 100644 --- a/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json +++ b/tests/contract/snapshots/layer3_adversarial/truncated_rich_header.full.json @@ -57,6 +57,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { diff --git a/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json b/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json index 0577dbe..7a400f7 100644 --- a/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json +++ b/tests/contract/snapshots/layer3_adversarial/upx_name_only.full.json @@ -59,6 +59,7 @@ "signatures": [], "has_signature": false }, + "version_info": null, "analysis": { "sections": [ { From cbc9daddd6fab818297e4c37673d9623d8ebe454 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 8 Sep 2026 12:16:12 +0100 Subject: [PATCH 33/40] Add clean_version_info fixtures (core and full) and snapshots. Four things the pair confirms together: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The filter drops by key name, not by position. Comments sits last in the .rc and is the only key missing at default — the other 8 pass through untouched. keys_filtered fires when and only when something is dropped. Present at default, absent under full. That's the flag doing real work rather than being decorative. The 64-cap stays out of the way. truncated is ['keys_filtered'] at default, not ['keys_filtered', 'strings'] — with 9 keys total, nothing approaches the limit. This is the property your padding fix guarantees. Everything outside strings is invariant. file_version, languages, translations, decoded, structural_error_count are identical across both. The tier touches exactly one field and nothing else leaks between modes. --- .../contract/layer1_core/clean_version_info.c | 12 + .../layer1_core/clean_version_info.rc | 46 + .../layer1_core/clean_version_info.core.exe | Bin 0 -> 109056 bytes .../layer1_core/clean_version_info.full.exe | Bin 0 -> 109056 bytes .../layer1_core/clean_version_info.core.json | 548 +++++++++++ .../layer1_core/clean_version_info.full.json | 860 ++++++++++++++++++ 6 files changed, 1466 insertions(+) create mode 100644 examples/generators/c/contract/layer1_core/clean_version_info.c create mode 100644 examples/generators/c/contract/layer1_core/clean_version_info.rc create mode 100644 tests/contract/fixtures/layer1_core/clean_version_info.core.exe create mode 100644 tests/contract/fixtures/layer1_core/clean_version_info.full.exe create mode 100644 tests/contract/snapshots/layer1_core/clean_version_info.core.json create mode 100644 tests/contract/snapshots/layer1_core/clean_version_info.full.json diff --git a/examples/generators/c/contract/layer1_core/clean_version_info.c b/examples/generators/c/contract/layer1_core/clean_version_info.c new file mode 100644 index 0000000..9282b7c --- /dev/null +++ b/examples/generators/c/contract/layer1_core/clean_version_info.c @@ -0,0 +1,12 @@ +/* Copyright (c) 2026 MalX Labs and contributors + * SPDX-License-Identifier: MPL-2.0 + * + * Minimal carrier for clean_version_info.rc. The code is irrelevant - the point is + * to produce a real PE with a real .rsrc section, so the RT_VERSION leaf + * is located through the actual resource tree rather than a fake. + */ + +int main(void) +{ + return 0; +} diff --git a/examples/generators/c/contract/layer1_core/clean_version_info.rc b/examples/generators/c/contract/layer1_core/clean_version_info.rc new file mode 100644 index 0000000..6b5ac59 --- /dev/null +++ b/examples/generators/c/contract/layer1_core/clean_version_info.rc @@ -0,0 +1,46 @@ +// Copyright (c) 2026 MalX Labs and contributors +// SPDX-License-Identifier: MPL-2.0 +// +// Valid VS_VERSIONINFO baseline for IOCX end-to-end tests. +// +// This produces a WELL-FORMED resource by construction - a resource +// compiler will not emit a malformed one, and that is precisely what +// makes this fixture useful: it pins the clean path. Malformed cases +// come from vs_versioninfo_builder.py instead. +// +// The string block deliberately contains exactly the eight keys in the +// projection's _DEFAULT_KEYS shortlist, plus one extra ("Comments") to +// exercise the keys_filtered truncation flag at default level. + +#include + +VS_VERSION_INFO VERSIONINFO +FILEVERSION 10,0,0,1 +PRODUCTVERSION 10,0,0,1 +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +FILEFLAGS 0x0L +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904B0" // US English, Unicode + BEGIN + VALUE "CompanyName", "MalX Labs" + VALUE "FileDescription", "IOCX test fixture" + VALUE "FileVersion", "10.0.0.1" + VALUE "InternalName", "fixture" + VALUE "LegalCopyright", "\xA9 MalX Labs. All rights reserved." + VALUE "OriginalFilename", "FIXTURE.EXE" + VALUE "ProductName", "IOCX" + VALUE "ProductVersion", "10.0.0.1" + VALUE "Comments", "Non-shortlist key: expect keys_filtered" + END + END + + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/tests/contract/fixtures/layer1_core/clean_version_info.core.exe b/tests/contract/fixtures/layer1_core/clean_version_info.core.exe new file mode 100644 index 0000000000000000000000000000000000000000..6a855701ed0fcf2fd3de130cd4531c2fc6a57c6d GIT binary patch literal 109056 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

Y&s@rZ=8RhyP836h@ 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

Y&s@rZ=8RhyP836h@ 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 Date: Tue, 8 Sep 2026 12:19:41 +0100 Subject: [PATCH 34/40] Version info projection --- iocx/engine.py | 9 +- iocx/parsers/version_info_projection.py | 280 ++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 iocx/parsers/version_info_projection.py diff --git a/iocx/engine.py b/iocx/engine.py index d187d18..2634b85 100644 --- a/iocx/engine.py +++ b/iocx/engine.py @@ -12,6 +12,7 @@ from .parsers.string_extractor import extract_strings from .parsers.pe_resources import build_resource_structure from .parsers.pe_version_info import build_version_info_structure +from .parsers.version_info_projection import project_version_info from .parsers.pe_load_config import analyse_load_config from .parsers.pe_optional_header import extract_optional_header_metadata from .parsers.pe_exports import build_export_structure @@ -139,6 +140,8 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: heuristics = [] structural = [] + version_info = build_version_info_structure(pe) + # BASIC: section layout + entropy if analysis_level in ("basic", "deep", "full"): section_analysis = { @@ -150,7 +153,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: if analysis_level in ("deep", "full"): obf = analyse_obfuscation(section_analysis["sections"], text) - # FULL: future expansion + # FULL if analysis_level == "full": extended = analyse_extended(pe, metadata, text) @@ -171,7 +174,7 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: } self._internal_metadata["resources_struct"] = build_resource_structure(pe) - self._internal_metadata["version_info_struct"] = build_version_info_structure(pe) + self._internal_metadata["version_info_struct"] = version_info self._internal_metadata["export_struct"] = build_export_structure(pe) self._internal_metadata["import_struct"] = build_import_structure(pe) self._internal_metadata["delay_import_struct"] = build_delay_import_structure(pe) @@ -204,6 +207,8 @@ def _pipeline_pe(self, path: str) -> Dict[str, Any]: analysis["extended"] = extended analysis["heuristics"] = [asdict(h) for h in heuristics] + result["version_info"] = project_version_info(version_info, full=(analysis_level == "full")) + if analysis: result["analysis"] = analysis diff --git a/iocx/parsers/version_info_projection.py b/iocx/parsers/version_info_projection.py new file mode 100644 index 0000000..af6eeb6 --- /dev/null +++ b/iocx/parsers/version_info_projection.py @@ -0,0 +1,280 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 + +""" +Public projection of ``version_info_struct``. + +Deliberately a projection rather than a serialisation of the internal +struct: + + * version numbers become dotted quads, not (MS, LS) pairs; + * string values are stripped of control and bidi characters; + * string tables stay separate, so a key present in two language blocks + with different values does not silently collapse to one; + * the raw parser ``errors`` vocabulary stays internal - the public view + carries a count, and the structural reason codes carry the detail; + * every list is bounded, so output size does not scale with input size. + +The default projection emits a CLOSED key set. StringFileInfo keys are +arbitrary strings taken from the file, so allowing them through unfiltered +would let a binary choose the key names in public output, not merely the +values. ``full=True`` opts into the open set. +""" + +import re +from typing import Any, Dict, List, Optional, Tuple + +# Control characters and bidi overrides are stripped from public string +# output. The parser bounds LENGTH and replacement-decodes invalid UTF-16, +# but leaves these in: a bidi override in CompanyName renders text +# backwards in a terminal or dashboard - the filename-spoofing trick, +# arriving through a field nobody inspects. ANSI escapes are the same +# class of problem for terminal consumers. +# +# \t \n \r are stripped too, unlike the usual "printable" allowance: +# these are single-line display fields, and a newline in CompanyName +# breaks line-oriented consumers and enables log injection. +_UNSAFE_CHARS = re.compile( + r"[\x00-\x1f\x7f]" # all C0 controls, plus DEL + r"|[\u202a-\u202e\u2066-\u2069]" # bidi embedding / override +) + +# A defensive second bound on public strings. The parser already caps +# values at 512 and keys at 128, so these only bite if that changes. +_MAX_PUBLIC_STRING = 512 +_MAX_PUBLIC_KEY = 128 + +# Caps on public output. The parser bounds each value's length but not the +# NUMBER of tables, strings or translations: _decode_string_file_info +# advances by t_len >= 6, so a 1 MB blob can yield ~174k tables. Without +# these, output size scales with attacker-controlled input. +_MAX_PUBLIC_TABLES = 16 +_MAX_PUBLIC_STRINGS_PER_TABLE = 64 +_MAX_PUBLIC_TRANSLATIONS = 32 + +# Closed key set for the default projection: the fields that carry triage +# value and appear in essentially every well-formed binary. +_DEFAULT_KEYS = frozenset({ + "CompanyName", + "FileDescription", + "FileVersion", + "InternalName", + "LegalCopyright", + "OriginalFilename", + "ProductName", + "ProductVersion", +}) + + +# ===================================================================== +# Helpers +# ===================================================================== + +def _clean(text: Any, limit: int = _MAX_PUBLIC_STRING) -> Optional[str]: + """ + Strip control and bidi characters from an attacker-controlled string. + + Returns None for anything that is not a str, so a malformed struct + cannot inject a non-string into public output. + """ + if not isinstance(text, str): + return None + return _UNSAFE_CHARS.sub("", text)[:limit] + + +def _dotted_version(pair: Any) -> Optional[str]: + """ + Render a (MS, LS) DWORD pair as major.minor.build.revision. + + Each DWORD holds TWO 16-bit components: + MS = (major << 16) | minor + LS = (build << 16) | revision + + Emitting the raw pair makes every consumer re-derive this, and the + word order is easy to get backwards. + """ + if not isinstance(pair, (tuple, list)) or len(pair) != 2: + return None + ms, ls = pair + if not isinstance(ms, int) or not isinstance(ls, int): + return None + if ms < 0 or ls < 0 or ms > 0xFFFFFFFF or ls > 0xFFFFFFFF: + return None + return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}" + + +def _error_total(vi: Dict[str, Any]) -> int: + """ + Count every parser error tag, at all levels. + + The top-level list alone is not enough: a blob whose envelope decoded + cleanly but whose string tables carry lang_codepage_key or + string_length would otherwise report decoded=True with a zero count, + reading as healthy when it is not. + """ + total = len(vi.get("errors") or []) + + for sfi in vi.get("string_file_info") or []: + if not isinstance(sfi, dict): + continue + total += len(sfi.get("errors") or []) + for table in sfi.get("tables") or []: + if isinstance(table, dict): + total += len(table.get("errors") or []) + + for vfi in vi.get("var_file_info") or []: + if isinstance(vfi, dict): + total += len(vfi.get("errors") or []) + + return total + + +def _flag(truncated: List[str], tag: str) -> None: + """Record a truncation tag once, preserving first-seen order.""" + if tag not in truncated: + truncated.append(tag) + + +def _project_tables(vi: Dict[str, Any], + full: bool, + truncated: List[str]) -> Tuple[List[Dict[str, Any]], + List[str]]: + """ + Project the string tables, keeping each language block separate. + + A flat merge across tables would let a key present in two language + blocks overwrite itself, with the survivor decided by walk order - + and a disagreement between blocks is itself a repackaging signal. + """ + tables: List[Dict[str, Any]] = [] + languages: List[str] = [] + + for sfi in vi.get("string_file_info") or []: + if not isinstance(sfi, dict): + continue + + for table in sfi.get("tables") or []: + if not isinstance(table, dict): + continue + + if len(tables) >= _MAX_PUBLIC_TABLES: + _flag(truncated, "tables") + return tables, languages + + lang = _clean(table.get("lang_codepage"), _MAX_PUBLIC_KEY) + if lang and lang not in languages: + languages.append(lang) + + raw_strings = table.get("strings") + if not isinstance(raw_strings, dict): + raw_strings = {} + + # Filter and count in ONE pass, so neither the filtered-out + # keys nor an intermediate list scales with input size. A + # separate `candidates` list would be bounded only by the + # parser's per-table string count, which is unbounded. + strings: Dict[str, str] = {} + kept = 0 + for k, v in raw_strings.items(): + key = _clean(k, _MAX_PUBLIC_KEY) + if not key: + continue + if not full and key not in _DEFAULT_KEYS: + _flag(truncated, "keys_filtered") + continue + + # Count only what is actually emitted: filtering before + # counting stops a table padded with junk keys from + # exhausting the cap ahead of the shortlist. + if kept >= _MAX_PUBLIC_STRINGS_PER_TABLE: + _flag(truncated, "strings") + break + + strings[key] = _clean(v) or "" + kept += 1 + + tables.append({"lang_codepage": lang, "strings": strings}) + + return tables, languages + + +def _project_translations(vi: Dict[str, Any], + truncated: List[str]) -> List[str]: + """Render VarFileInfo translation pairs as 8-hex-char lang+codepage.""" + translations: List[str] = [] + + for vfi in vi.get("var_file_info") or []: + if not isinstance(vfi, dict): + continue + + for var in vfi.get("vars") or []: + if not isinstance(var, dict): + continue + + for t in var.get("translations") or []: + if not isinstance(t, dict): + continue + + lang = t.get("lang") + cp = t.get("codepage") + if not isinstance(lang, int) or not isinstance(cp, int): + continue + if not (0 <= lang <= 0xFFFF) or not (0 <= cp <= 0xFFFF): + continue + + rendered = f"{lang:04X}{cp:04X}" + if rendered in translations: + continue + + if len(translations) >= _MAX_PUBLIC_TRANSLATIONS: + _flag(truncated, "translations") + return translations + + translations.append(rendered) + + return translations + + +# ===================================================================== +# Public API +# ===================================================================== + +def project_version_info(vi: Optional[Dict[str, Any]], + *, full: bool = False) -> Optional[Dict[str, Any]]: + """ + Derive the public view of ``version_info_struct``. + + Returns None when no RT_VERSION resource is present, matching the + parser's own contract. Because the parse now runs on every file + regardless of analysis level, None has exactly one meaning - the + binary carries no version-info - and can never mean "this mode did + not look". + + A tombstoned struct (decoded=False, errors populated) is projected + normally rather than discarded: a resource that exists but cannot be + trusted must stay visible, and `decoded` plus + `structural_error_count` are how a consumer tells the two apart. + """ + if vi is None: + return None + + ffi = vi.get("fixed_file_info") + if not isinstance(ffi, dict): + ffi = {} + + truncated: List[str] = [] + tables, languages = _project_tables(vi, full, truncated) + translations = _project_translations(vi, truncated) + + return { + "file_version": _dotted_version(ffi.get("file_version")), + "product_version": _dotted_version(ffi.get("product_version")), + "tables": tables, + "languages": languages, + "translations": translations, + # Structural health, without exposing the tag vocabulary. The + # matching RESOURCE_VERSIONINFO_* reason codes carry the detail. + "decoded": bool(vi.get("decoded")), + "structural_error_count": _error_total(vi), + "truncated": truncated, + } From 2d0f2160f6511f625623c780beb4e348acaabe91 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 8 Sep 2026 12:20:08 +0100 Subject: [PATCH 35/40] Version info projection tests --- .../generate_vs_versioninfo_fixtures.py | 239 ++++++++ .../unit/parsers/test_pe_version_info_fuzz.py | 197 +++++++ .../test_pe_version_info_projection.py | 539 ++++++++++++++++++ 3 files changed, 975 insertions(+) create mode 100644 examples/generators/python/generate_vs_versioninfo_fixtures.py create mode 100644 tests/unit/parsers/test_pe_version_info_fuzz.py create mode 100644 tests/unit/parsers/test_pe_version_info_projection.py diff --git a/examples/generators/python/generate_vs_versioninfo_fixtures.py b/examples/generators/python/generate_vs_versioninfo_fixtures.py new file mode 100644 index 0000000..3722d5a --- /dev/null +++ b/examples/generators/python/generate_vs_versioninfo_fixtures.py @@ -0,0 +1,239 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 +""" +Byte-level VS_VERSIONINFO blob builder for parser stress tests. + +A resource compiler will only ever emit well-formed version-info, so the +malformed cases cannot come from a C fixture. This builds the structure +directly, with each mutation expressed as a named, single-fault deviation +from a known-good baseline. +""" + +import struct + +_VS_FFI_SIGNATURE = 0xFEEF04BD +_VS_FFI_STRUCT_VERSION = 0x00010000 + + +def _u16(v): + return struct.pack(" header_ok False.""" + children = _pad4(string_file_info([string_table(strings=_BASELINE_STRINGS)])) + return version_info(key="VS_VERSION_BAD", children=children) + + +def length_inconsistent(): + """wLength larger than the buffer -> length_consistent False.""" + return version_info(length_override=0xFFFF) + + +def truncated_header(): + """Fewer than 6 bytes -> too_short.""" + return b"\x40\x01\x34" + + +def ffi_bad_signature(): + return version_info(ffi=fixed_file_info(signature=0xDEADBEEF)) + + +def ffi_bad_struct_version(): + return version_info(ffi=fixed_file_info(struct_version=0x00020000)) + + +def ffi_truncated(): + """wValueLength non-zero but under 52 -> fixed_file_info_truncated.""" + return version_info(ffi=b"\x00" * 16, value_length=16) + + +def ffi_absent(): + """wValueLength == 0 is a legitimate omission, not a defect.""" + children = _pad4(string_file_info([string_table(strings=_BASELINE_STRINGS)])) + return version_info(ffi=b"", children=children) + + +def unknown_child(): + """A child that is neither StringFileInfo nor VarFileInfo.""" + bogus = _node("NotAKnownChild", children=_pad4(_node("x")), w_type=1) + return version_info(children=_pad4(bogus)) + + +def child_length_invalid(): + """A child claiming more bytes than the envelope holds.""" + sfi = string_file_info([string_table(strings=_BASELINE_STRINGS)]) + bad = _u16(0xFFFE) + sfi[2:] + return version_info(children=_pad4(bad)) + + +def child_max_exceeded(count=300): + """More children than the 256 hard cap.""" + one = _pad4(_node("NotAKnownChild", children=_pad4(_node("x")), w_type=1)) + return version_info(children=one * count) + + +def lang_codepage_key(): + """StringTable key that is not 8 hex characters.""" + tbl = string_table(lang_codepage="ENGLISHX", strings=_BASELINE_STRINGS) + return version_info(children=_pad4(string_file_info([tbl]))) + + +def string_length_invalid(): + """A String node whose wLength overruns its table.""" + entry = string_entry("CompanyName", "MalX Labs") + bad = _u16(0xFFFE) + entry[2:] + tbl = _node("040904B0", children=_pad4(bad), w_type=1) + return version_info(children=_pad4(string_file_info([tbl]))) + + +def translation_not_dword_aligned(): + """Translation wValueLength not a DWORD multiple.""" + return version_info(children=_pad4(var_file_info(value_length=6))) + + +def bidi_in_company_name(): + """ + Not a structural defect - the parser accepts it. Exercises the + projection's control/bidi stripping, which is where it matters. + """ + strings = dict(_BASELINE_STRINGS) + strings["CompanyName"] = "MalX \u202eLabs\u202c\nInc" + return version_info(children=_pad4(string_file_info([string_table(strings=strings)]))) + + +def deeply_nested_tables(count=200): + """Many string tables - exercises the projection's table cap.""" + tables = [string_table(lang_codepage=f"{i:04X}04B0", + strings={"CompanyName": f"T{i}"}) + for i in range(count)] + return version_info(children=_pad4(string_file_info(tables))) + + +CASES = { + "baseline": baseline, + "szkey_mismatch": szkey_mismatch, + "length_inconsistent": length_inconsistent, + "truncated_header": truncated_header, + "ffi_bad_signature": ffi_bad_signature, + "ffi_bad_struct_version": ffi_bad_struct_version, + "ffi_truncated": ffi_truncated, + "ffi_absent": ffi_absent, + "unknown_child": unknown_child, + "child_length_invalid": child_length_invalid, + "child_max_exceeded": child_max_exceeded, + "lang_codepage_key": lang_codepage_key, + "string_length_invalid": string_length_invalid, + "translation_not_dword_aligned": translation_not_dword_aligned, + "bidi_in_company_name": bidi_in_company_name, + "deeply_nested_tables": deeply_nested_tables, +} diff --git a/tests/unit/parsers/test_pe_version_info_fuzz.py b/tests/unit/parsers/test_pe_version_info_fuzz.py new file mode 100644 index 0000000..a807748 --- /dev/null +++ b/tests/unit/parsers/test_pe_version_info_fuzz.py @@ -0,0 +1,197 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 +""" +Parser/validator coverage driven by synthesised VS_VERSIONINFO blobs. + +Each case is a single named deviation from a known-good baseline, so a +failure names the fault rather than "the blob is wrong somewhere". The +expectations below were verified against the real decoder. +""" + +import pytest + +from iocx.parsers.pe_version_info import _decode_vs_versioninfo +from examples.generators.python.generate_vs_versioninfo_fixtures import CASES, baseline + + +def _tables(out): + return [t for s in out["string_file_info"] for t in s["tables"]] + + +def _all_errors(out): + """Every tag at every level - what _error_total counts.""" + tags = list(out["errors"]) + for s in out["string_file_info"]: + tags += s["errors"] + for t in s["tables"]: + tags += t["errors"] + for v in out["var_file_info"]: + tags += v["errors"] + return tags + + +# ===================================================================== +# Baseline +# ===================================================================== + +def test_baseline_is_clean(): + """If this fails, every expectation below is measuring the wrong thing.""" + out = _decode_vs_versioninfo(baseline()) + + assert out["decoded"] is True + assert out["header_ok"] is True + assert out["length_consistent"] is True + assert _all_errors(out) == [] + + ffi = out["fixed_file_info"] + assert ffi["signature_ok"] is True + assert ffi["struct_version_ok"] is True + + tables = _tables(out) + assert len(tables) == 1 + assert tables[0]["lang_codepage"] == "040904B0" + assert tables[0]["strings"]["CompanyName"] == "MalX Labs" + + assert out["var_file_info"][0]["vars"][0]["translations"] == [ + {"lang": 0x0409, "codepage": 0x04B0} + ] + + +def test_every_case_decodes_without_raising(): + """ + The parser's contract is to degrade, never to raise. A malformed blob + that escapes as an exception aborts the whole analysis. + """ + for name, fn in CASES.items(): + try: + _decode_vs_versioninfo(fn()) + except Exception as exc: # noqa: BLE001 + pytest.fail(f"{name} raised {type(exc).__name__}: {exc}") + + +# ===================================================================== +# Envelope faults +# ===================================================================== + +def test_szkey_mismatch(): + out = _decode_vs_versioninfo(CASES["szkey_mismatch"]()) + assert out["decoded"] is True + assert out["header_ok"] is False + # The fault is isolated: children still parse. + assert len(_tables(out)) == 1 + + +def test_length_inconsistent(): + out = _decode_vs_versioninfo(CASES["length_inconsistent"]()) + assert out["decoded"] is True + assert out["length_consistent"] is False + + +def test_truncated_header(): + out = _decode_vs_versioninfo(CASES["truncated_header"]()) + assert out["decoded"] is False + assert out["errors"] == ["too_short"] + + +# ===================================================================== +# VS_FIXEDFILEINFO +# ===================================================================== + +@pytest.mark.parametrize("case, flag", [ + ("ffi_bad_signature", "signature_ok"), + ("ffi_bad_struct_version", "struct_version_ok"), +]) +def test_ffi_field_faults(case, flag): + out = _decode_vs_versioninfo(CASES[case]()) + assert out["fixed_file_info"] is not None + assert out["fixed_file_info"][flag] is False + + +def test_ffi_truncated(): + out = _decode_vs_versioninfo(CASES["ffi_truncated"]()) + assert out["fixed_file_info"] is None + assert "fixed_file_info_truncated" in out["errors"] + + +def test_ffi_absent_is_not_a_defect(): + """wValueLength == 0 is a legitimate omission - no tag, no issue.""" + out = _decode_vs_versioninfo(CASES["ffi_absent"]()) + assert out["fixed_file_info"] is None + assert not any(e.startswith("fixed_file_info") for e in out["errors"]) + + +# ===================================================================== +# Child dispatch +# ===================================================================== + +def test_unknown_child(): + out = _decode_vs_versioninfo(CASES["unknown_child"]()) + assert "unknown_child" in out["errors"] + + +def test_child_length_invalid_stops_the_walk(): + out = _decode_vs_versioninfo(CASES["child_length_invalid"]()) + assert "child_length_invalid" in out["errors"] + assert _tables(out) == [] + + +def test_child_max_exceeded_bounds_the_errors_list(): + """ + unknown_child does not break the walk, so without the 256 cap the + errors list would scale with attacker-controlled input. + """ + out = _decode_vs_versioninfo(CASES["child_max_exceeded"]()) + assert "child_max_exceeded" in out["errors"] + assert out["errors"].count("unknown_child") <= 256 + + +def test_child_priority_resolution(): + """ + Both tags are present; _CHILD_ERROR_PRIORITY must report the + walk-terminating one, since the remaining children were never seen. + """ + out = _decode_vs_versioninfo(CASES["child_max_exceeded"]()) + errs = out["errors"] + assert "child_max_exceeded" in errs and "unknown_child" in errs + + +# ===================================================================== +# StringFileInfo / VarFileInfo +# ===================================================================== + +def test_lang_codepage_key(): + out = _decode_vs_versioninfo(CASES["lang_codepage_key"]()) + assert _tables(out)[0]["errors"] == ["lang_codepage_key"] + + +def test_string_length_invalid(): + out = _decode_vs_versioninfo(CASES["string_length_invalid"]()) + assert "string_length" in _tables(out)[0]["errors"] + + +def test_translation_not_dword_aligned(): + out = _decode_vs_versioninfo(CASES["translation_not_dword_aligned"]()) + assert out["var_file_info"][0]["errors"] == ["translation_not_dword_aligned"] + + +# ===================================================================== +# Projection-facing cases (parser accepts these; the projection acts) +# ===================================================================== + +def test_bidi_is_parsed_verbatim(): + """ + The parser must NOT sanitise - it records structural truth. Stripping + belongs in the projection, and this case proves the raw text reaches + it intact. + """ + out = _decode_vs_versioninfo(CASES["bidi_in_company_name"]()) + company = _tables(out)[0]["strings"]["CompanyName"] + assert "\u202e" in company + assert "\n" in company + assert _all_errors(out) == [] # not a structural defect + + +def test_many_tables_are_parsed_but_bounded_downstream(): + out = _decode_vs_versioninfo(CASES["deeply_nested_tables"]()) + assert len(_tables(out)) == 200 + assert _all_errors(out) == [] diff --git a/tests/unit/parsers/test_pe_version_info_projection.py b/tests/unit/parsers/test_pe_version_info_projection.py new file mode 100644 index 0000000..4dc4e3f --- /dev/null +++ b/tests/unit/parsers/test_pe_version_info_projection.py @@ -0,0 +1,539 @@ +# Copyright (c) 2026 MalX Labs and contributors +# SPDX-License-Identifier: MPL-2.0 +""" +Coverage for the public projection of version_info_struct. + +The projection is the last thing between an attacker-controlled resource +and public output, so the tests below lean on the two properties that +matter there: output size never scales with input size, and no key name +or character reaches the default payload unless it was explicitly +allowed. + +All expectations were verified against the implementation. +""" + +import pytest + +from iocx.parsers.version_info_projection import ( + project_version_info, + _clean, + _dotted_version, + _error_total, + _DEFAULT_KEYS, + _MAX_PUBLIC_TABLES, + _MAX_PUBLIC_STRINGS_PER_TABLE, + _MAX_PUBLIC_TRANSLATIONS, + _MAX_PUBLIC_KEY, + _MAX_PUBLIC_STRING, +) + + +# ===================================================================== +# Builders +# ===================================================================== + +def _struct(tables=None, translations=None, **kw): + """Minimal version_info_struct with the shape the projection expects.""" + vi = { + "decoded": True, + "errors": [], + "fixed_file_info": { + "file_version": (0x000A0000, 0x65F42308), + "product_version": (0x000A0000, 0x65F42308), + }, + "string_file_info": [{"errors": [], "tables": tables or []}], + "var_file_info": [{ + "errors": [], + "vars": [{"key": "Translation", + "translations": translations + if translations is not None + else [{"lang": 0x0409, "codepage": 0x04B0}]}], + }], + } + vi.update(kw) + return vi + + +def _table(lang="040904B0", strings=None, errors=None): + return {"lang_codepage": lang, + "strings": strings if strings is not None else {}, + "errors": errors or []} + + +_GOOD_STRINGS = { + "CompanyName": "MalX Labs", + "FileDescription": "IOCX test fixture", + "OriginalFilename": "FIXTURE.EXE", +} + + +# ===================================================================== +# Absence contract +# ===================================================================== + +def test_none_projects_to_none(): + """ + None means the binary carries no RT_VERSION resource. Because the + parse now runs on every file regardless of analysis level, this has + exactly one meaning and can never mean "this mode did not look". + """ + assert project_version_info(None) is None + + +def test_tombstone_is_projected_not_discarded(): + """ + A resource that exists but cannot be trusted must stay visible. + `decoded` plus `structural_error_count` are how a consumer tells a + tombstone apart from a healthy blob. + """ + out = project_version_info({ + "decoded": False, + "errors": ["leaf_placement_implausible"], + "fixed_file_info": None, + "string_file_info": [], + "var_file_info": [], + }) + + assert out is not None + assert out["decoded"] is False + assert out["structural_error_count"] == 1 + assert out["file_version"] is None + assert out["tables"] == [] + + +def test_empty_dict_is_not_treated_as_absence(): + """ + `if vi is None` rather than `if not vi`: an empty struct is a + degenerate blob, not a missing resource, and must still project. + """ + out = project_version_info({}) + assert out is not None + assert out["decoded"] is False + assert out["tables"] == [] + + +# ===================================================================== +# Version rendering +# ===================================================================== + +def test_dotted_version_splits_both_dwords(): + """MS = (major << 16) | minor; LS = (build << 16) | revision.""" + out = project_version_info(_struct()) + assert out["file_version"] == "10.0.26100.8968" + assert out["product_version"] == "10.0.26100.8968" + + +@pytest.mark.parametrize("pair", [ + None, + (1,), # wrong arity + (1, 2, 3), + ("a", 1), # non-int + (1, "b"), + (-1, 0), # negative + (0, -1), + (0x1_0000_0000, 0), # wider than a DWORD + (0, 0x1_0000_0000), + "10.0.0.1", # already-rendered string +]) +def test_dotted_version_rejects_malformed_pairs(pair): + """ + A malformed pair yields None rather than a partially-derived string: + a wrong version number is worse than an absent one. + """ + out = project_version_info(_struct( + fixed_file_info={"file_version": pair, "product_version": pair})) + assert out["file_version"] is None + assert out["product_version"] is None + + +def test_non_dict_ffi_does_not_raise(): + """A malformed struct must degrade, not propagate a TypeError.""" + for bad in (None, [], "x", 42): + out = project_version_info(_struct(fixed_file_info=bad)) + assert out["file_version"] is None + + +# ===================================================================== +# Sanitisation +# ===================================================================== + +def test_bidi_and_control_characters_are_stripped(): + """ + A bidi override in CompanyName renders text backwards in a terminal + or dashboard - the filename-spoofing trick arriving through a field + nobody inspects. Newlines break line-oriented consumers. + """ + out = project_version_info(_struct(tables=[_table(strings={ + "CompanyName": "MalX \u202eLabs\u202c\nInc\tX", + })])) + assert out["tables"][0]["strings"]["CompanyName"] == "MalX LabsIncX" + + +@pytest.mark.parametrize("raw", [ + "\x00", "\x1b[31m", "\x7f", "\u202a", "\u202e", "\u2066", "\u2069", + "\n", "\r", "\t", +]) +def test_each_unsafe_character_class_is_removed(raw): + assert raw not in (_clean(f"A{raw}B") or "") + + +def test_clean_rejects_non_strings(): + """A malformed struct cannot inject a non-string into public output.""" + for bad in (None, 42, [], {}, b"bytes"): + assert _clean(bad) is None + + +def test_value_length_is_bounded(): + out = project_version_info(_struct(tables=[_table( + strings={"CompanyName": "A" * (_MAX_PUBLIC_STRING + 500)})])) + assert len(out["tables"][0]["strings"]["CompanyName"]) == _MAX_PUBLIC_STRING + + +def test_key_length_is_bounded_separately(): + long_key = "C" * (_MAX_PUBLIC_KEY + 50) + out = project_version_info( + _struct(tables=[_table(strings={long_key: "v"})]), full=True) + projected = list(out["tables"][0]["strings"]) + assert all(len(k) <= _MAX_PUBLIC_KEY for k in projected) + + +def test_key_that_cleans_to_empty_is_dropped(): + """A control-character-only key would otherwise become "" in output.""" + out = project_version_info( + _struct(tables=[_table(strings={"\x00\x01": "v", + "CompanyName": "MalX"})]), + full=True) + assert "" not in out["tables"][0]["strings"] + assert out["tables"][0]["strings"]["CompanyName"] == "MalX" + + +def test_non_string_value_becomes_empty_string_not_none(): + """`_clean(v) or ""` - the key survives, the value degrades to "".""" + out = project_version_info( + _struct(tables=[_table(strings={"CompanyName": 12345})])) + assert out["tables"][0]["strings"]["CompanyName"] == "" + + +# ===================================================================== +# Closed key set +# ===================================================================== + +def test_default_projection_emits_only_shortlist_keys(): + """ + StringFileInfo keys are arbitrary strings from the file. The default + payload must not let a binary choose its own key NAMES, only values. + """ + strings = dict(_GOOD_STRINGS) + strings["EvilKey\u202e"] = "attacker chosen" + strings["Comments"] = "not in the shortlist" + + out = project_version_info(_struct(tables=[_table(strings=strings)])) + projected = set(out["tables"][0]["strings"]) + + assert projected <= _DEFAULT_KEYS + assert "Comments" not in projected + assert "keys_filtered" in out["truncated"] + + +def test_full_projection_opens_the_key_set(): + strings = dict(_GOOD_STRINGS) + strings["Comments"] = "now allowed" + + out = project_version_info(_struct(tables=[_table(strings=strings)]), + full=True) + projected = out["tables"][0]["strings"] + + assert projected["Comments"] == "now allowed" + assert "keys_filtered" not in out["truncated"] + + +def test_keys_filtered_not_flagged_when_nothing_is_dropped(): + out = project_version_info(_struct(tables=[_table(strings=_GOOD_STRINGS)])) + assert "keys_filtered" not in out["truncated"] + assert set(out["tables"][0]["strings"]) == set(_GOOD_STRINGS) + + +# ===================================================================== +# Per-table separation +# ===================================================================== + +def test_tables_stay_separate_so_keys_do_not_collide(): + """ + A flat merge would let a key present in two language blocks overwrite + itself, with the survivor decided by walk order - and a disagreement + between blocks is itself a repackaging signal. + """ + out = project_version_info(_struct(tables=[ + _table("040904B0", {"CompanyName": "Block A"}), + _table("040704B0", {"CompanyName": "Block B"}), + ])) + + assert len(out["tables"]) == 2 + assert out["tables"][0]["strings"]["CompanyName"] == "Block A" + assert out["tables"][1]["strings"]["CompanyName"] == "Block B" + + +def test_languages_are_deduped_but_tables_are_not(): + """Two blocks may legitimately share a lang_codepage.""" + out = project_version_info(_struct(tables=[ + _table("040904B0", {"CompanyName": "A"}), + _table("040904B0", {"CompanyName": "B"}), + ])) + assert out["languages"] == ["040904B0"] + assert len(out["tables"]) == 2 + + +def test_language_order_is_preserved(): + out = project_version_info(_struct(tables=[ + _table("080904B0"), _table("040904B0"), _table("080904B0"), + ])) + assert out["languages"] == ["080904B0", "040904B0"] + + +# ===================================================================== +# Bounds - output must not scale with input +# ===================================================================== + +def test_table_cap_is_inclusive(): + """Exactly _MAX_PUBLIC_TABLES must not be refused.""" + out = project_version_info(_struct( + tables=[_table(f"{i:04X}04B0") for i in range(_MAX_PUBLIC_TABLES)])) + assert len(out["tables"]) == _MAX_PUBLIC_TABLES + assert "tables" not in out["truncated"] + + +def test_table_cap_bounds_a_hostile_blob(): + """ + _decode_string_file_info advances by t_len >= 6, so a 1 MB blob can + yield ~174k tables. Without the cap, output scales with input. + """ + out = project_version_info(_struct( + tables=[_table(f"{i:04X}04B0") for i in range(5_000)])) + assert len(out["tables"]) == _MAX_PUBLIC_TABLES + assert "tables" in out["truncated"] + + +def test_output_size_does_not_scale_with_table_count(): + """The property that matters downstream.""" + import json + + def sized(n): + return len(json.dumps(project_version_info(_struct( + tables=[_table(f"{i:04X}04B0", dict(_GOOD_STRINGS)) + for i in range(n)])))) + + assert sized(500) == sized(50_000) + + +def test_strings_cap_is_inclusive(): + strings = {f"K{i}": "v" for i in range(_MAX_PUBLIC_STRINGS_PER_TABLE)} + out = project_version_info(_struct(tables=[_table(strings=strings)]), + full=True) + assert len(out["tables"][0]["strings"]) == _MAX_PUBLIC_STRINGS_PER_TABLE + assert "strings" not in out["truncated"] + + +def test_strings_cap_bounds_a_hostile_table(): + strings = {f"K{i}": "v" for i in range(5_000)} + out = project_version_info(_struct(tables=[_table(strings=strings)]), + full=True) + assert len(out["tables"][0]["strings"]) == _MAX_PUBLIC_STRINGS_PER_TABLE + assert "strings" in out["truncated"] + + +def test_translation_cap_is_inclusive(): + tr = [{"lang": i, "codepage": 0x04B0} + for i in range(_MAX_PUBLIC_TRANSLATIONS)] + out = project_version_info(_struct(translations=tr)) + assert len(out["translations"]) == _MAX_PUBLIC_TRANSLATIONS + assert "translations" not in out["truncated"] + + +def test_translation_cap_bounds_a_hostile_var_block(): + tr = [{"lang": i, "codepage": 0x04B0} for i in range(1_000)] + out = project_version_info(_struct(translations=tr)) + assert len(out["translations"]) == _MAX_PUBLIC_TRANSLATIONS + assert "translations" in out["truncated"] + + +def test_truncation_flags_appear_at_most_once(): + strings = {f"K{i}": "v" for i in range(200)} + out = project_version_info(_struct( + tables=[_table(f"{i:04X}04B0", strings) for i in range(50)]), + full=True) + for tag in out["truncated"]: + assert out["truncated"].count(tag) == 1 + + +def test_clean_input_carries_no_truncation_flags(): + out = project_version_info(_struct(tables=[_table(strings=_GOOD_STRINGS)])) + assert out["truncated"] == [] + + +# ===================================================================== +# Translations +# ===================================================================== + +def test_translations_render_as_eight_hex_chars(): + out = project_version_info(_struct( + translations=[{"lang": 0x0409, "codepage": 0x04B0}])) + assert out["translations"] == ["040904B0"] + + +def test_translations_are_deduped(): + out = project_version_info(_struct( + translations=[{"lang": 0x0409, "codepage": 0x04B0}] * 5)) + assert out["translations"] == ["040904B0"] + + +@pytest.mark.parametrize("bad", [ + {"lang": -1, "codepage": 0}, + {"lang": 0x10000, "codepage": 0}, + {"lang": 0, "codepage": -1}, + {"lang": 0, "codepage": 0x10000}, + {"lang": "0409", "codepage": 0x04B0}, + {"lang": 0x0409}, + {}, +]) +def test_out_of_range_translations_are_skipped(bad): + out = project_version_info(_struct( + translations=[bad, {"lang": 0x0409, "codepage": 0x04B0}])) + assert out["translations"] == ["040904B0"] + + +# ===================================================================== +# Structural error count +# ===================================================================== + +def test_error_count_sums_every_level(): + """ + The top-level list alone is not enough: a blob whose envelope decoded + cleanly but whose string tables are malformed would otherwise report + decoded=True with a zero count, reading as healthy when it is not. + """ + vi = { + "decoded": True, + "errors": ["top"], + "string_file_info": [{ + "errors": ["sfi"], + "tables": [{"lang_codepage": "040904B0", "strings": {}, + "errors": ["tbl_a", "tbl_b"]}], + }], + "var_file_info": [{"errors": ["var"], "vars": []}], + } + assert project_version_info(vi)["structural_error_count"] == 5 + + +def test_nested_errors_alone_are_still_counted(): + """decoded=True with a malformed table must not read as healthy.""" + out = project_version_info(_struct( + tables=[_table(strings=_GOOD_STRINGS, errors=["lang_codepage_key"])])) + assert out["decoded"] is True + assert out["structural_error_count"] == 1 + + +def test_error_total_tolerates_malformed_containers(): + for bad in ("string", 42, None): + assert _error_total({"string_file_info": [bad], + "var_file_info": [bad]}) == 0 + + +# ===================================================================== +# Robustness against a malformed struct +# ===================================================================== + +@pytest.mark.parametrize("field, value", [ + ("string_file_info", "not a list"), + ("string_file_info", [None, 42, "x"]), + ("string_file_info", [{"tables": "not a list"}]), + ("string_file_info", [{"tables": [None, 42]}]), + ("string_file_info", [{"tables": [{"strings": "not a dict"}]}]), + ("var_file_info", "not a list"), + ("var_file_info", [None, 42]), + ("var_file_info", [{"vars": "not a list"}]), + ("var_file_info", [{"vars": [None, 42]}]), + ("var_file_info", [{"vars": [{"translations": "not a list"}]}]), + ("var_file_info", [{"vars": [{"translations": [None, "x", 42]}]}]), +]) +def test_malformed_containers_do_not_raise(field, value): + """ + Every nested container is type-checked before use, so a malformed + struct yields empty output rather than a TypeError mid-projection. + """ + out = project_version_info(_struct(**{field: value})) + assert isinstance(out["tables"], list) + assert isinstance(out["translations"], list) + + +def test_missing_keys_throughout_do_not_raise(): + assert project_version_info({"decoded": True}) is not None + + +# ===================================================================== +# Output contract +# ===================================================================== + +def test_projection_has_a_stable_key_set(): + """The public shape must not vary with input, or consumers break.""" + expected = { + "file_version", "product_version", "tables", "languages", + "translations", "decoded", "structural_error_count", "truncated", + } + assert set(project_version_info(_struct())) == expected + assert set(project_version_info({})) == expected + assert set(project_version_info(_struct(), full=True)) == expected + + +def test_projection_is_json_serialisable(): + import json + json.dumps(project_version_info(_struct(tables=[ + _table(strings={"CompanyName": "\u00a9 MalX \u202eLabs"})]))) + + +def test_projection_does_not_mutate_the_input(): + vi = _struct(tables=[_table(strings=dict(_GOOD_STRINGS))]) + import copy + before = copy.deepcopy(vi) + project_version_info(vi) + assert vi == before + + +def test_decoded_is_always_a_bool(): + """`bool(...)` - a truthy non-bool must not leak into public output.""" + for raw in (1, "yes", [1], None, 0, ""): + assert isinstance(project_version_info({"decoded": raw})["decoded"], + bool) + + +def test_key_padding_cannot_suppress_the_shortlist(): + """ + Filtering happens before the cap is counted, so a table padded with + junk keys cannot push the triage fields out of default output. + Before that change, 64 junk keys ahead of CompanyName suppressed it + entirely - and OriginalFilename versus the on-disk name is exactly + the check that matters most on a suspicious file. + """ + strings = {f"Junk{i}": "x" for i in range(_MAX_PUBLIC_STRINGS_PER_TABLE)} + strings["CompanyName"] = "Microsoft Corporation" + strings["OriginalFilename"] = "NOTEPAD.EXE" + + out = project_version_info(_struct(tables=[_table(strings=strings)])) + + assert out["tables"][0]["strings"] == { + "CompanyName": "Microsoft Corporation", + "OriginalFilename": "NOTEPAD.EXE", + } + assert "keys_filtered" in out["truncated"] + assert "strings" not in out["truncated"] + + +def test_ordering_no_longer_decides_what_survives(): + """The same keys in either order must project identically.""" + junk = {f"Junk{i}": "x" for i in range(_MAX_PUBLIC_STRINGS_PER_TABLE)} + real = {"CompanyName": "Microsoft Corporation", + "OriginalFilename": "NOTEPAD.EXE"} + + junk_first = project_version_info(_struct(tables=[_table(strings={**junk, **real})])) + real_first = project_version_info(_struct(tables=[_table(strings={**real, **junk})])) + + assert junk_first["tables"] == real_first["tables"] From 2621d7d2dcba61210abe3d06386004c1b4fdc23f Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 8 Sep 2026 12:34:46 +0100 Subject: [PATCH 36/40] Version info reason code additions --- docs/specs/reason-codes.md | 42 +++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/specs/reason-codes.md b/docs/specs/reason-codes.md index 6a234f5..6178cd3 100644 --- a/docs/specs/reason-codes.md +++ b/docs/specs/reason-codes.md @@ -287,11 +287,28 @@ Details carry `declared_size` and `decoded_entries`; their difference is the los |-------------|------------------|-----------------|-------| | **RESOURCE_VERSIONINFO_INVALID_HEADER** | The VS_VERSIONINFO envelope is malformed: placement outside `.rsrc`, `szKey` not equal to "VS_VERSION_INFO", or `wLength` inconsistent with the buffer size | szKey = "VS_VERSION_BAD" instead of "VS_VERSION_INFO" | Per‑file | | **RESOURCE_VERSIONINFO_INVALID_FIXEDINFO** | The embedded VS_FIXEDFILEINFO has an incorrect `dwSignature` (expected `0xFEEF04BD`) or `dwStrucVersion` (expected `0x00010000`), or fails to parse | dwSignature = `0xDEADBEEF` instead of `0xFEEF04BD` | Per‑file | -| **RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO** | A StringFileInfo, StringTable, or String child is malformed: invalid length field, non‑hex lang_codepage key, or truncated string entry | StringTable key = "ENGLISHX" instead of 8‑hex‑char `` form | Per‑file | +| **RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO** | A StringFileInfo, StringTable, or String child is malformed: invalid length field, non‑hex lang_codepage key, or truncated string entry | StringTable key = "ENGLISHX" instead of 8‑hex‑char `` form | Per‑file / Per‑table | | **RESOURCE_VERSIONINFO_INVALID_VARFILEINFO** | A VarFileInfo or Var child is malformed, or the Translation array's length is not a DWORD multiple | Var wValueLength = 6 (not divisible by 4) for a Translation array | Per‑file | *Note: absence of an RT_VERSION resource is not treated as a structural anomaly — many legitimate binary types (kernel drivers, MSI helpers, cross‑compiled artefacts) omit version‑info entirely.* +> **Projection flags are not reason codes.** The public `version_info` +> output carries its own `truncated` list — `tables`, `strings`, +> `keys_filtered` — which shapes what is *emitted* rather than describing +> what is *wrong* with the file. A blob that trips all three can still be +> perfectly well-formed and produce no issue here. The two vocabularies +> are disjoint and must not be conflated: +> +> | `truncated` flag | Meaning | Structural? | +> |------------------|---------|-------------| +> | tables | More string tables than the public cap (16); the remainder were not projected | No | +> | strings | More keys in a table than the public cap (64) after key filtering; the remainder were not projected | No | +> | keys_filtered | Keys outside the default closed key set were dropped. Expected on any binary carrying non-shortlist keys, and absent under `full` | No | +> +> Structural truth lives in `structural_error_count` and the +> `RESOURCE_VERSIONINFO_*` codes above; `truncated` records only that the +> public view is narrower than the parsed one. + ### RESOURCE_VERSIONINFO_INVALID_HEADER sub‑reasons The first four describe the VS_VERSIONINFO envelope itself. The last four @@ -354,6 +371,29 @@ but cannot be trusted must stay visible.* `RESOURCE_VERSIONINFO_INVALID_VARFILEINFO` carry no sub‑reason; the parser's tags are passed through verbatim in an `errors` list. +#### RESOURCE_VERSIONINFO_INVALID_STRINGFILEINFO — parser tags + +Passed through verbatim in the issue's `errors` list. StringFileInfo-level +and StringTable-level tags are emitted as separate issues: the first has a +`tables` count in details, the second a `lang_codepage`. + +| Parser tag | Level | Meaning | +|------------|-------|---------| +| string_table_header | StringFileInfo | A StringTable's length field could not be unpacked; the walk stopped there | +| string_table_length | StringFileInfo | A StringTable's `wLength` was below the 6-byte minimum or extended past the StringFileInfo; the walk stopped there | +| lang_codepage_key | StringTable | The key is not 8 hex characters in `` form | +| string_header | StringTable | A String entry's length field could not be unpacked; the walk stopped there | +| string_length | StringTable | A String entry's `wLength` was below the 6-byte minimum or extended past the table; the walk stopped there | + +#### RESOURCE_VERSIONINFO_INVALID_VARFILEINFO — parser tags + +| Parser tag | Meaning | +|------------|---------| +| var_header | A Var child's length fields could not be unpacked; the walk stopped there | +| var_length | A Var child's `wLength` was below the 6-byte minimum or extended past the VarFileInfo; the walk stopped there | +| translation_not_dword_aligned | `wValueLength` is not a multiple of 4, so the Translation array cannot be a whole number of LANGID+codepage pairs | +| translation_unpack | `struct.unpack` failed on a translation pair (defensive; the slot count is derived from the value extent) | + ### **Resource String‑Table Anomalies** | Reason Code | What Triggers It | Example Pattern | Scope | From 35b3b64caae513fe5eaaa6406b31b5675582ae30 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Tue, 8 Sep 2026 13:16:50 +0100 Subject: [PATCH 37/40] Bump version. CLI version now includes art and provenance. Help menu descriptive --- iocx/cli/main.py | 80 ++++++++++++++++++++++++++++++++++++++---------- pyproject.toml | 2 +- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/iocx/cli/main.py b/iocx/cli/main.py index c5eb842..11f0580 100644 --- a/iocx/cli/main.py +++ b/iocx/cli/main.py @@ -7,18 +7,64 @@ from ..engine import Engine, EngineConfig from importlib.metadata import version, PackageNotFoundError +_ART = r""" ___ ___ ___ __ __ + |_ _/ _ \ / __|\ \/ / + | | (_) | (__ > < + |___\___/ \___|/_/\_\ +""" + +def _dep_version(name: str) -> str: + """Best-effort dependency version; never raises.""" + try: + from importlib.metadata import version + return version(name) + except Exception: + return "unknown" + + +def _format_version(version: str, *, art: bool = True) -> str: + """ + Build the --version text. + + Dependency versions are included because they are a real variable in + the output: pefile materialises the resource tree the parsers walk, + so two runs disagreeing on a finding may differ only there. + """ + lines = [] + + # ASCII art only when stdout is a terminal - it is noise in CI logs + # and in anything capturing the output. + if art and sys.stdout.isatty(): + lines.append(_ART.rstrip("\n")) + lines.append("") + + py = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + + lines.extend([ + f"Deterministic PE static analysis\n", + f"iocx {version}", + f"python {py}", + f"pefile {_dep_version('pefile')}", + "license MPL-2.0", + "", + "MalX Labs - https://github.com/iocx-dev/iocx", + ]) + + return "\n".join(lines) + def get_version(): try: - return version("iocx") + return _format_version(version("iocx")) except PackageNotFoundError: return "0.0.0" def main(): parser = argparse.ArgumentParser( - description="Static IOC extractor for binaries, logs, and text.", + description="An extensible, deterministic static‑analysis engine that extracts high‑signal IOCs from PE binaries and text, built for SOC automation and modern threat‑analysis pipelines.", formatter_class=argparse.RawDescriptionHelpFormatter, + allow_abbrev=False ) # --------------------------- @@ -26,6 +72,7 @@ def main(): # --------------------------- input_group = parser.add_argument_group("Input") output_group = parser.add_argument_group("Output") + pe_analysis_group = parser.add_argument_group("PE Analysis") engine_group = parser.add_argument_group("Engine Options") detector_group = parser.add_argument_group("Detector Options") misc_group = parser.add_argument_group("Misc") @@ -56,15 +103,16 @@ def main(): output_group.add_argument( "-e", "--enrich", action="store_true", - help="Write enrichment data to the JSON output." + help="Write enrichment data to the JSON output. Enrichment is context to extracted IOCs, surfaced via plugins with the enrichment capability." ) - output_group.add_argument( + pe_analysis_group.add_argument( "-a", "--analyse", "--analyze", nargs="?", const="deep", choices=["basic", "deep", "full"], - help="Enable PE analysis (basic, deep, full; default: deep)." + metavar="LEVEL", + help="Enable PE analysis. LEVEL: basic (sections, entropy), deep (+ obfuscation heuristics), full (+ structural validation, full version-info). Default when -a is given without a value: deep." ) # --------------------------- @@ -76,6 +124,14 @@ def main(): help="Disable engine caching." ) + engine_group.add_argument( + "-m", "--min-length", + type=int, + default=4, + metavar="N", + help="Minimum printable string length for the string extractor (default: 4)." + ) + # --------------------------- # Detector Options # --------------------------- @@ -88,21 +144,13 @@ def main(): detector_group.add_argument( "--list-transformers", action="store_true", - help="List available transformer plugins." + help="List available transformer plugins and exit." ) detector_group.add_argument( "--list-enrichers", action="store_true", - help="List available enricher plugins." - ) - - detector_group.add_argument( - "-m", "--min-length", - type=int, - default=4, - metavar="N", - help="Minimum printable string length for the string extractor (default: 4)." + help="List available enricher plugins and exit." ) # --------------------------- @@ -117,7 +165,7 @@ def main(): misc_group.add_argument( "-d", "--dev", action="store_true", - help="Enable local plugins.", + help="Enable local plugins. Local plugins must be placed in the '.iocx/plugins' folder of your home directory.", ) args = parser.parse_args() diff --git a/pyproject.toml b/pyproject.toml index a489d82..db43e41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "iocx" -version = "0.7.6.1" +version = "0.7.6.2" description = "A deterministic, high‑performance static‑analysis engine that extracts high‑signal IOCs from PE binaries, text, and logs — built for SOC automation and modern threat‑analysis pipelines." authors = [ { name = "MalX Labs" } From a06dc4e65f71c2bf4197e84343c5aa64de652837 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 9 Sep 2026 10:03:01 +0100 Subject: [PATCH 38/40] Fix CLI unit test --- tests/unit/cli/test_cli_ext.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/unit/cli/test_cli_ext.py b/tests/unit/cli/test_cli_ext.py index ea34269..7d121e0 100644 --- a/tests/unit/cli/test_cli_ext.py +++ b/tests/unit/cli/test_cli_ext.py @@ -5,7 +5,7 @@ import sys from pathlib import Path import json -import pytest +import pytest, re def run_cli(*args, input=None): @@ -54,8 +54,14 @@ def test_cli_list_detectors(): def test_cli_version(): result = run_cli("--version") assert result.returncode == 0 - # Version should look like "0.1.0" or similar - assert result.stdout.strip()[0].isdigit() + # Find the line starting with "iocx" + match = re.search(r"^iocx\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)$", result.stdout, re.MULTILINE) + assert match, "iocx version line not found in banner" + + version = match.group(1) + + # Validate semantic version format + assert re.match(r"^\d+\.\d+\.\d+\.\d+$", version), "iocx version is not numeric semantic version" def test_cli_help(): From 402d41327b7ec6b8343110d0e12758cddb30d0f1 Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 9 Sep 2026 10:11:01 +0100 Subject: [PATCH 39/40] Fix vi fuzz tests --- .../unit/parsers/_generate_vs_versioninfo_fixtures.py | 0 tests/unit/parsers/test_pe_version_info_fuzz.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename examples/generators/python/generate_vs_versioninfo_fixtures.py => tests/unit/parsers/_generate_vs_versioninfo_fixtures.py (100%) diff --git a/examples/generators/python/generate_vs_versioninfo_fixtures.py b/tests/unit/parsers/_generate_vs_versioninfo_fixtures.py similarity index 100% rename from examples/generators/python/generate_vs_versioninfo_fixtures.py rename to tests/unit/parsers/_generate_vs_versioninfo_fixtures.py diff --git a/tests/unit/parsers/test_pe_version_info_fuzz.py b/tests/unit/parsers/test_pe_version_info_fuzz.py index a807748..ad7ee68 100644 --- a/tests/unit/parsers/test_pe_version_info_fuzz.py +++ b/tests/unit/parsers/test_pe_version_info_fuzz.py @@ -11,7 +11,7 @@ import pytest from iocx.parsers.pe_version_info import _decode_vs_versioninfo -from examples.generators.python.generate_vs_versioninfo_fixtures import CASES, baseline +from _generate_vs_versioninfo_fixtures import CASES, baseline def _tables(out): From 93fe959bb41ce24de6eeecb981a0b348529ea2cf Mon Sep 17 00:00:00 2001 From: malx-labs Date: Wed, 9 Sep 2026 10:36:56 +0100 Subject: [PATCH 40/40] v0.7.6.2 release documentation --- CHANGELOG.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++ README-pypi.md | 18 ++++++---- README.md | 13 ++++++- 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3afee5..28d28fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,96 @@ +# **v0.7.6.2 — Import Table Structural Validator, and VERSIONINFO projection** +**Released: 2026‑09‑09** + +## Added +- Import table structural validator: new `pe_imports` parser + (`iocx/parsers/pe_imports.py`) and `imports` validator + (`iocx/validators/imports.py`) emitting `IMPORT_DIRECTORY_INVALID_HEADER`, + `IMPORT_TABLE_TRUNCATED`, `IMPORT_DESCRIPTOR_INVALID`, + `IMPORT_DLL_NAME_INVALID`, `IMPORT_ENTRY_INVALID`. +- Public `version_info` projection (`version_info_projection.py`), + present in every result at every analysis level; closed 8-key + shortlist by default (`CompanyName`, `FileDescription`, `FileVersion`, + `InternalName`, `LegalCopyright`, `OriginalFilename`, `ProductName`, + `ProductVersion`), open key set under `-a full`. +- New reason codes: `RESOURCE_DIRECTORY_ENTRY_UNREADABLE`, + `RESOURCE_STRING_TABLE_UNREADABLE`. +- Branded `--version` CLI output (ASCII art shown only on an interactive + terminal) including `python` and `pefile` dependency versions. +- Static tag-contract CI check (`tests/contract/tag_contract.py`, + `test_tag_contract.py`) statically verifying every parser tombstone + tag has a validator consumer, closing a class of silent-drop bugs. +- `HIGHADJ` relocation-entry pairing support: new `adjustment` field on + paired entries, `highadj_missing_adjustment` tombstone for an unpaired + trailing entry. +- Bounds on resource-tree output (`_MAX_RESOURCE_STRINGS`, + `_MAX_RESOURCE_ENTRIES`, `_MAX_RESOURCE_DEPTH`) and on version-info + blob size (1 MB) / child count (256), so hostile input can no longer + scale parser output or recursion depth. +- New VS_VERSIONINFO test fixture generator and + matching C/RC fixture pair for end-to-end version-info coverage. + +## Changed +- `build_version_info` renamed to `build_version_info_structure`; now + called unconditionally in the engine pipeline instead of only under + `-a full`. +- CLI: `-m/--min-length` moved from Detector Options to Engine Options; + `-a/--analyse` help text now documents what each analysis level + unlocks; `--list-transformers`/`--list-enrichers` help text made + consistent with `--list-detectors`. +- Delay-import and export-forwarder validation split into distinct + empty / non-printable / too-long checks instead of a single boolean, + each with its own reason-code sub-reason. +- `RELOCATION_TABLE_TRUNCATED` region values split into + `relocation_entries_exceed_directory` (declared size clamped to the + directory window) vs. `relocation_entries_truncated` (the clamped + read itself came back short) — previously conflated under one tag. +- `_MAX_ENTRIES_PER_BLOCK` corrected to 8,192 (previously documented and + sized for 2,048) to account for `HIGHADJ` occupying two WORD slots + per relocation. +- Machine-specific relocation type 9 renamed `MIPS_JMPADDR16` → + `MACHINE_SPECIFIC_9` for architecture-neutral naming. + +## Fixed +- `NameError` on `_RELOC_TYPE_HIGHADJ`, reachable from decoding *any* + relocation entry (not only `HIGHADJ` ones), capable of aborting an + entire analysis on a single malformed entry. +- `_parse_data_directories_raw` read PE32+ images at the PE32 (96-byte) + rather than the correct 112-byte `DataDirectory` offset, silently + misreading every data directory on a 64-bit image; now also tolerates + a missing/empty `__data__` and a truncated optional header without + raising. +- Export forwarder-string decode errors were discarded rather than + surfaced on the entry; `name_rva` was hard-coded `None` instead of + being resolved via the name-pointer cross-reference; two name + pointers resolving to the same EAT index silently overwrote one + another (now tagged `ordinal_index_duplicate`). +- Forwarder regex accepted over-long ordinals (e.g. + `Dll.#99999999999`) as ordinary symbol names. +- `pe_resources.build_resource_structure` and `pe_parser._parse_resources` + could propagate an exception, or silently drop entries, on a malformed + subtree; both now tombstone the failure (`entry_decode_failed`, + `directory_entries_unavailable`, `string_table_walk_failed`, + `resources_unavailable`, `resources_map_read_failed`) and never raise. +- Per-index parser tags (`*_unpack_failed_at_{index}`) that never + matched any validator priority list, because the embedded index made + every occurrence unique, deduplicated to a stable form. + +## Documentation +- `docs/specs/reason-codes.md`: new **Import Anomalies** section, new + **Resource Directory Entry Unreadable** section, expanded TLS / + delay-import / export / relocation / debug sub-reason tables. +- `docs/specs/structural-validation-deterministic-heuristics.md`: new + **§2.16 Imports Validator** section; documents the tag-contract check. + +## Testing +- Test suite: **2,136 → 2,802 tests.** Major new coverage for the + import parser/validator, the version-info projection, resource-tree + robustness, relocation `HIGHADJ` handling, and the tag-contract + checker itself (including deliberate regression fixtures for eight + previously-found silent-drop bugs). + +--- + # **v0.7.6.1 — Exception Directory Validator, and a Silent Output Defect** **Released: 2026‑08‑27** diff --git a/README-pypi.md b/README-pypi.md index 46e266a..8e199f8 100644 --- a/README-pypi.md +++ b/README-pypi.md @@ -40,6 +40,17 @@ If you need predictable, automatable IOC extraction — IOCX is built for you. --- +## Version highlights + +### v0.7.6.2 — Import Table Validator + +- New deterministic **import table structural validator** (`IMPORT_*` reason codes). +- `version_info` now parsed and surfaced at **every** analysis level (not just `-a full`), via a new bounded public projection. +- Rebuilt CLI: branded `--version` output, clearer `--help` text, reorganised argument groups. +- Fixed a relocation-parser crash reachable from any entry, a PE32+ data-directory offset bug, and several silent export/resource error drops. +- New static CI check that prevents parser error tags from silently going unconsumed by validators. +- Test suite: 2,136 → 2,802 tests. Coverage: 100%. + ### v0.7.6.1 — Exception Directory Validator - Adds deep semantic validation of the PE exception (`.pdata`) directory; 14 new reason codes; 15 validators total. @@ -47,13 +58,6 @@ If you need predictable, automatable IOC extraction — IOCX is built for you. - **Output-visible:** findings previously suppressed or mislabelled will now appear. - Tests: 1620 → 2136. Coverage: 100%. -## Version highlights (v0.7.6) - -- Added new PE structural validators for relocations and debug directories -- WIN_CERTIFICATE and tls validators now have pefile-independent struct parsers -- Never crashes on malformed input - byte-level parsing with structured error tombstones -- 1620 tests at 100% coverage - deterministic output, snapshot-stable - --- ## **Performance** diff --git a/README.md b/README.md index b1d0b94..9d5c64e 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- + @@ -202,6 +202,17 @@ Fast path — no PE parsing.

Show Version History
+### v0.7.6.2 — Import Table Validator + +- New deterministic **import table structural validator** (`IMPORT_*` reason codes). +- `version_info` now parsed and surfaced at **every** analysis level (not just `-a full`), via a new bounded public projection. +- Rebuilt CLI: branded `--version` output, clearer `--help` text, reorganised argument groups. +- Fixed a relocation-parser crash reachable from any entry, a PE32+ data-directory offset bug, and several silent export/resource error drops. +- New static CI check that prevents parser error tags from silently going unconsumed by validators. +- Test suite: 2,136 → 2,802 tests. + +--- + ### v0.7.6.1 — Exception Directory Validator - Adds deep semantic validation of the PE exception (`.pdata`) directory; 14 new reason codes; 15 validators total.