Files
exe-reverse/docs/tool-verification/FINDINGS.md
T
root 63b2aca8d8 Fix the budget gate and add CI, lint and coverage guards
- check_budget.py escalated a hard index-row violation to 'warn' on both
  branches, so INDEX_ROW_HARD could never fail anything. It fails now, and
  tests/unit/test_budget_measurement.py pins both thresholds.
- The corpus note counted scripts/__pycache__/*.pyc, so the same commit
  reported 27 scripts after the suite had run and 16 on a clean checkout.
- TOKENS_PER_CHAR is re-derived from a measurement (26,795 chars -> 6,065
  tokens = 4.418); the comment's old figure and its arithmetic were both
  wrong for this file.
- check_repo.py's yaml sentinel carries the type ignore, so mypy is clean
  with PyYAML installed as well as without it.
- The CPUID test now skips with the probe's own note on a host without
  CPUID instead of failing, and pytest.ini points at tests/README.md, which
  exists, instead of an EXTENSION file that never did.

Tooling: .flake8 (a measured 120-column limit), .gitattributes, .gitignore
and .github/workflows/ci.yml (the five gates, the suite on 3.11-3.14 and a
lint job), which is what CONTRIBUTING.md already claimed CI runs. The
pull-request template it refers to now exists.

Content: pitfalls P26-P34 -- the analysis-level failures the catalogue was
missing (sampling read as observation, a self-built parser trusted, a
verification that could not have failed, a shell-mangled command line) --
registered in the symptom index and its routing mirror. FINDINGS F11-F15
record the defects above.

Tests: the coverage guard derives its subject from scripts/ rather than a
hand-written list, pe_triage.py and pe_diff.py gain their first tests, and
plan.py / preflight.py are declared uncovered with reasons.
2026-10-24 14:55:02 +08:00

13 KiB

Findings

Defects that this repository's own tools, gates and fixtures found in each other, during the pass that built it. Each entry states the symptom, the root cause, how it surfaced, and the check that now catches it.

These are recorded because the interesting part is not the fix. It is that every one of these produced output that looked correct: a table of plausible flag names, a resource count that was merely large, a length guard that never fired. That is the failure shape this whole repository is organised against.

Parser defects

F1. The DLL-characteristics table was shifted by six bits, and produced a confident wrong answer.

Symptom: parsing a stock system binary reported no_seh, no_bind, wdm_driver, 32bit_dll. Those flags on a modern executable are implausible — a user-mode program with no exception handling, marked as a kernel driver and as 32-bit, on a 64-bit host.

Root cause: the constant table was written from recall rather than from the specification, and every entry was displaced: what the code called no_seh was the high-entropy-VA bit, and so on through the whole table. The output was well-formed, self-consistent, and wrong.

How it surfaced: by parsing a real image whose expected flags are known, rather than by testing against the parser's own fixtures. A fixture built from the same wrong table would have agreed with it.

Fix and check: the values were taken from the published format specification and the table now records where it came from. The regression is a test asserting the exact flag names for a real binary's header, not merely that a list is non-empty.

F2. The section-alignment field was treated as a set of independent bits.

Symptom: a code section reported two alignment flags at once.

Root cause: the alignment of a section is bits [23:20] of the characteristics field — an index, not a set of bits — and the specification states the values are valid only for object files. Testing them one bit at a time turns the execute and read bits into two imaginary alignment flags.

How it surfaced: the same real-image run, on a section whose characteristics value is a plain execute|read pair.

Fix and check: alignment is decoded with a mask and the table carries the note about its scope. A unit test asserts that a section with execute|read reports exactly those two flags and no alignment.

F3. Resource counts are 16-bit fields read as 32-bit, which inflated one resource into 478.

Symptom: a fixture with exactly one resource entry reported 478 entries.

Root cause: the resource directory's two count fields are 16 bits each. Reading either as a 32-bit value swallows the first two bytes of the following entry and the walk then chases table bytes as if they were entries.

How it surfaced: only after the fixture generator was written. Real system images hid it — their counts and neighbouring bytes happen to combine into values that do not explode — while a fixture with exactly one entry could not.

Fix and check: both counts are read as 16-bit. The fixture declares its expected entry count and a test asserts the walk returns exactly that.

Patcher defects

F4. The ambiguity guard was defeated by its own default.

Symptom: a pattern matching dozens of sites was silently patched at the first one.

Root cause: the command-line path always wrote an explicit hit index into the request, and its default value was 0. The guard exists to refuse a multi-site match unless a site is chosen, and a default of zero counted as a choice.

How it surfaced: by testing the guard deliberately with a pattern that is obviously common (an indirect call sequence) instead of with a pattern known to be unique.

Fix and check: the index is carried only when it was actually supplied, and the refusal now lists the sites it found. A test calls the guard with the common pattern and asserts the refusal.

F5. The equal-length check compared a value with itself, so it never fired.

Symptom: the length guard existed, was documented, and could not fail.

Root cause: the "original" bytes were sliced to the length of the replacement, so the two lengths were equal by construction. Worse, the script cannot in general know the length of a site without disassembling it — which means the honest answer is not "equal" but "not established".

How it surfaced: by trying to make the guard fire. A guard that cannot fail is not a guard.

Fix and check: length is only judged when a reference exists (the expected bytes, or an explicit declared length), and the three states — equal, changed, unproven — are printed distinctly. A test asserts that a request with no reference prints the unproven marker, and that a declared length that disagrees with the replacement is refused. The first version also printed the unproven case as a length change, because a missing value is falsy; that is now a three-state marker.

Fixture defects

F6. SizeOfHeaders was computed without the section table.

Symptom: every section's payload sat at a different file offset than the one the header declared.

Root cause: the header size calculation covered the DOS header, the PE signature, the file header and the optional header, and omitted the section table — 40 bytes per section. The declared value was therefore smaller than the real header block, and everything after it was misplaced.

How it surfaced: an assertion in the generator comparing the planted byte sequence at its declared file offset with the bytes actually written there. Without that assertion the file would have been written, would have parsed, and would have been wrong.

Fix and check: the section table is included in the calculation, with an assertion that the aligned size is at least the computed size. The generator asserts every structural claim it makes.

F7. Two structures were placed at estimated offsets, and overlapped.

Symptom: the debug directory was written into the region the import table occupied.

Root cause: the first version of the generator gave the import table, the debug directory and the TLS directory each a hand-picked offset. Two of those choices collided.

How it surfaced: while computing where the import table actually ended, which is not something one does while writing estimated offsets.

Fix and check: the payload is laid out with a single cursor that allocates each structure in turn and refuses to overflow the buffer. An overlap is now impossible by construction rather than by care.

F8. The debug-directory structure had the wrong field count, and a file offset was filled with an RVA.

Symptom: the generator failed to build, then — once it built — the record could not be located.

Root cause: the structure has eight fields, not seven, and its last field is a file offset while the field before it is an RVA. Two different address spaces inside one 28-byte structure.

Fix and check: both fields are written correctly and the reader resolves the PDB path from the file offset, which is a round-trip the test asserts.

Probe defects

F9. A pseudo-handle was passed as a 32-bit integer, and the probe looked like a policy denial.

Symptom: the privilege probe reported that opening the process token failed with error 6 (ERROR_INVALID_HANDLE) on an elevated session.

Root cause: the pseudo-handle for the current process is (HANDLE)-1. Without an explicit pointer return type the binding layer narrows it, the call receives the wrong value, and the failure looks exactly like a machine where the operation is not permitted.

How it surfaced: by disbelieving the result. "Elevated session cannot query its own token" is not a plausible measurement.

Fix and check: the call signature declares its types, and a test asserts the probe reports elevation on this host rather than an error.

F10. Long values were truncated by the printer.

Symptom: a raw capability value printed as 0x000, losing its significant digits.

Root cause: a fixed column width applied to a value string. The data was correct; the report was not.

Fix and check: long values wrap onto continuation lines instead of being cut. A test asserts a long value survives into the output intact.

Gate, harness and lint defects

F11. The hard index-row limit was computed and then unable to fail anything.

Symptom: none. The gate reported ok on an index row of 450 characters, past the 400-character limit it declares, and CI stayed green.

Root cause: the finding was appended as ('warn' if hard else 'warn', ...) -- both branches the same expression. INDEX_ROW_HARD was defined, the comparison was performed, and the level was then thrown away. The message text even said "that is a paragraph, not an index entry", which is what made it read as enforcement.

How it surfaced: by injecting an over-long row into a scratch copy and requiring the gate to exit non-zero. It exited 0. A gate that has only ever been seen passing is not evidence that it can see anything, and this class of defect is invisible to every test that runs the gate on a conforming tree.

Fix and check: the escalated level is fail. tests/unit/test_budget_measurement.py asserts that a row past INDEX_ROW_HARD produces fail and a row past INDEX_ROW_SOFT produces warn, so the two thresholds cannot silently collapse into each other again.

F12. The corpus measurement counted a bytecode cache, so its own number changed when a reader ran the suite.

Symptom: the budget gate reported 36 references + 27 scripts = 63 files on a tree that ships 16 scripts. The count moved between runs on the same commit.

Root cause: the script half of the corpus was glob(scripts/**/*) filtered by isfile, which includes scripts/__pycache__/*.pyc. Reading a directory is a measurement of the repository; reading a directory that the reader has been scribbling in is a measurement of the reader.

How it surfaced: comparing the number the gate printed against the number of files the routing gate said it covers. Two gates measuring the same tree disagreed, and that disagreement was the finding.

Fix and check: paths under __pycache__ are excluded from the corpus. The count now matches the routing inventory, and re-running the suite no longer changes it.

F13. A test asserted a capability it never checked, so a host without it failed instead of skipping.

Symptom: test_prober_can_dump_raw_cpuid_registers failed on a non-x64 host with "a raw dump was requested but nothing was reported", while the probe's own report said CPUID was unavailable there.

Root cause: the test asserted that the dump was non-empty without asking whether the host could produce one. The script was right and the test encoded a Windows assumption. It also contradicted the suite's own rule that a missing prerequisite is a skip with a printed reason.

Fix and check: the test reads the probe's cpu row first and skips with that row's note when the state is not available; the non-empty assertion still runs where CPUID is reachable. The skip text names the reason, so a green run on Linux is not mistaken for a green run on Windows.

F14. A configuration comment pointed at a document that does not exist.

Symptom: pytest.ini sent a reader to docs/tool-verification/EXTENSION-test-harness.md for the suite's coverage policy. No such file has ever been in this repository.

Root cause: the file was inherited from the Android-side repository along with the harness, and the structural gates check the skill tree's references, not a comment in an ini file. A stale path in a comment fails nothing, which is exactly why it survives.

Fix and check: the comment points at tests/README.md, which carries a "what it deliberately does not do" section. Recorded here rather than silently repaired because the class -- a reference in a non-markdown file that no gate walks -- is still open.

F15. The type checker rejected a sentinel assignment the moment its optional dependency was installed.

Symptom: mypy *.py skills/exe-reverse/scripts/*.py reported check_repo.py:34: Incompatible types in assignment (expression has type "None", variable has type Module) on a machine with PyYAML, and no error at all on a machine without it.

Root cause: yaml = None in the except ImportError branch is ill-typed only where the import succeeded, which is the environment the lint job runs in. The repository had no lint job at the time, so the two environments never had to agree.

Fix and check: the assignment carries # type: ignore[assignment], with warn_unused_ignores off (already the configuration) so the bare-machine environment stays clean too. mypy.ini and .flake8 are now enforced by the lint job in CI.

What this list is for

Fifteen defects, all in code written or inherited during this pass, all of which produced output that looked right. The pattern is consistent enough to state as a rule:

A check that cannot fail is not a check, and a table written from recall is a table that is wrong in one place you will not notice. Both are why this repository prefers a value taken from a specification with its source named, an assertion over an estimate, and a fixture that can disagree with the tool reading it.