Init
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
"""Subprocess contract tests for skills/exe-reverse/scripts/dart_snapshot_strings.py.
|
||||
|
||||
The input is the generated ELF64 from `conftest.build_synthetic_elf`, never a sample: a valid
|
||||
container with one one-byte literal, one two-byte literal and a `.text` that carries neither of the
|
||||
idioms the pool observer counts.
|
||||
|
||||
Two conventions are pinned because they are not what the docstring's exit-code table suggests:
|
||||
|
||||
* `--json` returns 0 whatever the filter did, while the text mode returns 1 for "nothing matched";
|
||||
* `--at` widens a match by `offset + len(text)`, and for a two-byte literal that is half the bytes
|
||||
the literal occupies, so an offset inside the literal's second half is reported as a miss.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.cli
|
||||
|
||||
|
||||
def plain_lines(text):
|
||||
return [line for line in text.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def reported_count(text):
|
||||
"""The `N literal(s) reported.` line the text view ends with."""
|
||||
for line in reversed(plain_lines(text)):
|
||||
if "literal(s) reported." in line:
|
||||
return int(line.split()[0])
|
||||
raise AssertionError("no 'literal(s) reported.' line in:\n%s" % text)
|
||||
|
||||
|
||||
# --- refusals ----------------------------------------------------------------
|
||||
|
||||
def test_a_file_that_is_not_an_elf_is_refused_with_the_documented_code(run_script, tmp_path):
|
||||
path = tmp_path / "notes.txt"
|
||||
path.write_text("this is not a snapshot\n", encoding="utf-8")
|
||||
result = run_script("dart_snapshot_strings.py", path)
|
||||
assert result.returncode == 3
|
||||
assert "not an ELF image" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_a_truncated_image_is_refused_without_a_traceback(run_script, synthetic_elf, tmp_path):
|
||||
path = tmp_path / "truncated.so"
|
||||
path.write_bytes(synthetic_elf.data[:0x30])
|
||||
result = run_script("dart_snapshot_strings.py", path)
|
||||
assert result.returncode == 3
|
||||
assert "not an ELF image" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_a_missing_file_exits_two_with_a_reason(run_script, tmp_path):
|
||||
result = run_script("dart_snapshot_strings.py", tmp_path / "absent.so")
|
||||
assert result.returncode == 2
|
||||
assert "error:" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_an_elf_with_no_code_section_is_refused(run_script, synthetic_elf_without_text):
|
||||
"""A shared object without `.text` is not an AOT code image, and the reader says so."""
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf_without_text.path)
|
||||
assert result.returncode == 3
|
||||
assert "no .text section" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
# --- recovery ----------------------------------------------------------------
|
||||
|
||||
def test_the_default_run_reports_every_literal_it_recovered(run_script, synthetic_elf):
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "x86-64, shared-object" in result.stdout
|
||||
for entry in synthetic_elf.literals:
|
||||
assert hex(entry["vaddr"]) in result.stdout
|
||||
assert entry["text"] in result.stdout
|
||||
assert "literals recovered : 3" in result.stdout
|
||||
assert reported_count(result.stdout) == 3
|
||||
|
||||
|
||||
def test_min_length_filters_the_short_literals_out(run_script, synthetic_elf):
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--min-length", "12")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 1
|
||||
assert "premium_license" in result.stdout
|
||||
assert "trial_mode" not in result.stdout
|
||||
assert ">= 12 chars" in result.stdout
|
||||
|
||||
|
||||
def test_limit_caps_how_many_are_reported(run_script, synthetic_elf):
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--limit", "1")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 1
|
||||
|
||||
|
||||
def test_section_selection_is_honoured(run_script, synthetic_elf):
|
||||
only_rodata = run_script("dart_snapshot_strings.py", synthetic_elf.path,
|
||||
"--sections", ".rodata")
|
||||
assert only_rodata.returncode == 0, only_rodata.stderr
|
||||
assert reported_count(only_rodata.stdout) == 3
|
||||
|
||||
only_text = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--sections", ".text")
|
||||
assert only_text.returncode == 1
|
||||
assert reported_count(only_text.stdout) == 0
|
||||
|
||||
absent = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--sections", ".absent")
|
||||
assert absent.returncode == 1
|
||||
assert reported_count(absent.stdout) == 0
|
||||
|
||||
|
||||
# --- keyword filter ----------------------------------------------------------
|
||||
|
||||
def test_find_matches_case_insensitively(run_script, synthetic_elf):
|
||||
lower = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "premium")
|
||||
upper = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "PREMIUM")
|
||||
assert lower.returncode == 0, lower.stderr
|
||||
assert upper.returncode == 0, upper.stderr
|
||||
assert reported_count(lower.stdout) == reported_count(upper.stdout) == 1
|
||||
assert "premium_license" in lower.stdout
|
||||
assert "trial_mode" not in lower.stdout
|
||||
|
||||
|
||||
def test_find_accepts_a_comma_separated_list(run_script, synthetic_elf):
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "trial, VIP")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 2
|
||||
assert "trial_mode" in result.stdout
|
||||
assert "vip_pass" in result.stdout
|
||||
assert "premium_license" not in result.stdout
|
||||
|
||||
|
||||
def test_find_can_reach_a_two_byte_literal(run_script, synthetic_elf):
|
||||
"""A keyword search that only read ASCII would miss this one, which is why both are recovered."""
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "vip")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 1
|
||||
assert "[two-byte]" in result.stdout
|
||||
|
||||
|
||||
def test_a_keyword_that_matches_nothing_changes_the_exit_code(run_script, synthetic_elf):
|
||||
"""A miss is a negative finding, not an empty success."""
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "no_such_keyword")
|
||||
assert result.returncode == 1
|
||||
assert reported_count(result.stdout) == 0
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
# --- --at --------------------------------------------------------------------
|
||||
|
||||
def test_at_resolves_a_literal_by_file_offset(run_script, synthetic_elf):
|
||||
entry = synthetic_elf.literal("premium_license")
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--at", str(entry["offset"]))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 1
|
||||
assert "premium_license" in result.stdout
|
||||
|
||||
|
||||
def test_at_resolves_a_two_byte_literal_by_file_offset(run_script, synthetic_elf):
|
||||
entry = synthetic_elf.literal("vip_pass")
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--at", str(entry["offset"]))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 1
|
||||
assert "vip_pass" in result.stdout
|
||||
|
||||
|
||||
def test_at_resolves_a_literal_by_virtual_address(run_script, synthetic_elf):
|
||||
"""The vaddr is far above the file size, which is how the reader tells the two apart."""
|
||||
entry = synthetic_elf.literal("vip_pass")
|
||||
assert entry["vaddr"] > synthetic_elf.size
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--at", hex(entry["vaddr"]))
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert reported_count(result.stdout) == 1
|
||||
assert "vip_pass" in result.stdout
|
||||
assert hex(entry["vaddr"]) in result.stdout
|
||||
|
||||
|
||||
def test_at_inside_the_code_section_matches_nothing(run_script, synthetic_elf):
|
||||
result = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--at",
|
||||
str(synthetic_elf.text["offset"] + 1))
|
||||
assert result.returncode == 1
|
||||
assert reported_count(result.stdout) == 0
|
||||
|
||||
|
||||
def test_at_finds_every_byte_of_a_two_byte_literal(load_script, run_script, synthetic_elf):
|
||||
"""`--at` measures its window in bytes, so **every** byte offset inside the literal matches.
|
||||
|
||||
The window used to be `offset + len(text)`. For a two-byte literal the offsets are bytes and the
|
||||
text is characters, so the window covered only the first half of it and a real byte offset in
|
||||
the second half came back as a miss. Note which byte is which: in UTF-16LE an ASCII character is
|
||||
a printable byte followed by a zero byte, so the last meaningful offset in the literal is the
|
||||
high byte of its final character, which is `0x00` and is a perfectly valid offset to probe.
|
||||
"""
|
||||
entry = synthetic_elf.literal("vip_pass")
|
||||
assert entry["encoding"] == "two-byte"
|
||||
span = 2 * len(entry["text"])
|
||||
lo = entry["offset"]
|
||||
last_low_byte = lo + span - 2
|
||||
|
||||
assert synthetic_elf.data[last_low_byte] in range(0x20, 0x7F)
|
||||
assert synthetic_elf.data[lo + span - 1] == 0, "the final byte is the high byte of the character"
|
||||
|
||||
for probe in (lo, lo + 2, last_low_byte, lo + span - 1):
|
||||
found = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--at", str(probe))
|
||||
assert found.returncode == 0, (probe, found.stdout, found.stderr)
|
||||
assert reported_count(found.stdout) == 1, (probe, found.stdout)
|
||||
|
||||
# The module computes the same window the CLI does.
|
||||
recovered = load_script("dart_snapshot_strings.py").recover_strings(
|
||||
synthetic_elf.data, {"offset": synthetic_elf.rodata["offset"],
|
||||
"vaddr": synthetic_elf.rodata["vaddr"],
|
||||
"size": synthetic_elf.rodata["size"]})
|
||||
matched = [row[2] for row in recovered
|
||||
if row[0] <= lo + span - 1 < row[0] + 2 * len(row[2])]
|
||||
assert matched == ["vip_pass"]
|
||||
|
||||
|
||||
def test_at_can_be_combined_with_find(run_script, synthetic_elf):
|
||||
entry = synthetic_elf.literal("trial_mode")
|
||||
hit = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "trial",
|
||||
"--at", str(entry["offset"]))
|
||||
assert hit.returncode == 0, hit.stderr
|
||||
assert reported_count(hit.stdout) == 1
|
||||
|
||||
miss = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "premium",
|
||||
"--at", str(entry["offset"]))
|
||||
assert miss.returncode == 1
|
||||
assert reported_count(miss.stdout) == 0
|
||||
|
||||
|
||||
# --- json --------------------------------------------------------------------
|
||||
|
||||
def test_json_returns_one_when_the_filter_matched_nothing(run_script, synthetic_elf):
|
||||
"""The exit code means the same thing in both output modes.
|
||||
|
||||
It used to be 1 from the text path and 0 from the JSON path for the identical filter, so a
|
||||
caller that asked for JSON could not tell a match from a miss by reading the exit code.
|
||||
"""
|
||||
text_mode = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "no_such_word")
|
||||
json_mode = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--find", "no_such_word",
|
||||
"--json")
|
||||
assert text_mode.returncode == 1
|
||||
assert json_mode.returncode == 1
|
||||
payload = json.loads(json_mode.stdout)
|
||||
assert payload["matches"] == []
|
||||
assert payload["literals_recovered"] == 0
|
||||
|
||||
# An unfiltered run is a listing, not a search, and a listing that finds literals still exits 0.
|
||||
unfiltered = run_script("dart_snapshot_strings.py", synthetic_elf.path, "--json")
|
||||
assert unfiltered.returncode == 0
|
||||
assert json.loads(unfiltered.stdout)["matches"]
|
||||
|
||||
|
||||
def test_an_unknown_flag_is_a_usage_error(run_script):
|
||||
result = run_script("dart_snapshot_strings.py", "--definitely-not-a-flag")
|
||||
assert result.returncode == 2
|
||||
assert "usage:" in result.stderr.lower()
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_help_exits_zero(run_script):
|
||||
result = run_script("dart_snapshot_strings.py", "--help")
|
||||
assert result.returncode == 0
|
||||
assert "usage:" in result.stdout.lower()
|
||||
assert "app.so" in result.stdout
|
||||
@@ -0,0 +1,397 @@
|
||||
"""Subprocess contract tests for the machine-readable output of every script.
|
||||
|
||||
One convention is not what a reader would assume, so it is asserted explicitly rather than
|
||||
smoothed over: ``scan_leaks.py`` writes a JSON document and then a ``RESULT=`` line, so its
|
||||
stdout as a whole is not valid JSON. The document parses from the front; the verdict line is
|
||||
read from the end. A test pins that shape so a future change to either half is deliberate.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.cli
|
||||
|
||||
# The reader's own view names, produced by the flags that populate them.
|
||||
PEUTIL_VIEWS = {
|
||||
"--summary": "summary",
|
||||
"--sections": "section_detail",
|
||||
"--imports": "imports",
|
||||
"--exports": "exports",
|
||||
"--resources": "resources",
|
||||
"--rich": "rich_header",
|
||||
"--tls": "tls",
|
||||
"--debug": "debug",
|
||||
"--relocs": "base_relocations",
|
||||
"--entropy": "entropy",
|
||||
"--check-sum": "checksum",
|
||||
}
|
||||
|
||||
# Always present, whatever the flags.
|
||||
PEUTIL_ALWAYS = ("security_directory", "load_config")
|
||||
|
||||
|
||||
def json_document(text):
|
||||
return json.JSONDecoder().raw_decode(text)[0]
|
||||
|
||||
|
||||
def last_line(text):
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
return lines[-1] if lines else ""
|
||||
|
||||
|
||||
# --- peutil ------------------------------------------------------------------
|
||||
|
||||
def test_reader_json_carries_the_summary_the_text_view_shows(run_script, fixtures):
|
||||
result = run_script("peutil.py", fixtures.pe64.path, "--json")
|
||||
assert result.returncode == 0
|
||||
payload = json.loads(result.stdout)
|
||||
assert sorted(payload) == ["load_config", "security_directory", "summary"]
|
||||
summary = payload["summary"]
|
||||
assert summary["checksum_matches"] is True
|
||||
assert summary["checksum_declared"] == fixtures.pe64.meta["checksum"]
|
||||
assert summary["kind"] == "exe"
|
||||
assert summary["arch"] == "amd64"
|
||||
assert summary["file_size"] == fixtures.pe64.size
|
||||
assert summary["sections"] and len(summary["sections"]) == 5
|
||||
assert payload["security_directory"] is None
|
||||
assert payload["load_config"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag,key", sorted(PEUTIL_VIEWS.items()))
|
||||
def test_each_reader_view_lands_under_its_own_key(run_script, fixtures, flag, key):
|
||||
result = run_script("peutil.py", fixtures.pe64.path, flag, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert key in payload
|
||||
|
||||
|
||||
def test_reader_json_with_every_view_present(run_script, fixtures):
|
||||
flags = [flag for flag in sorted(PEUTIL_VIEWS)]
|
||||
result = run_script("peutil.py", fixtures.pe64.path, *flags, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
for key in list(PEUTIL_VIEWS.values()) + list(PEUTIL_ALWAYS):
|
||||
assert key in payload, key
|
||||
assert payload["resources"][0]["path"] == ["VERSION", "1", "1033"]
|
||||
assert payload["tls"]["callback_rvas"] == ["0x1000"]
|
||||
assert payload["debug"][0]["format"] == "RSDS"
|
||||
assert payload["checksum"]["matches"] is True
|
||||
assert payload["imports"][0]["dll"] == "fixture-import.dll"
|
||||
assert payload["exports"]["dll_name"] == "fixture-export.dll"
|
||||
assert payload["rich_header"] is None # the fixture has none, and says so
|
||||
assert payload["delay_imports"] == []
|
||||
assert payload["base_relocations"]["blocks"] == 1
|
||||
assert payload["section_detail"][0]["raw_tail"] == 0
|
||||
assert len(payload["entropy"]) == 5
|
||||
|
||||
|
||||
def test_reader_json_of_a_bare_recipe_reports_the_absent_structures(run_script, fixtures):
|
||||
result = run_script("peutil.py", fixtures.bare.path, "--exports", "--resources", "--tls",
|
||||
"--debug", "--json")
|
||||
payload = json.loads(result.stdout)
|
||||
assert result.returncode == 0
|
||||
assert payload["exports"] is None
|
||||
assert payload["resources"] == []
|
||||
assert payload["tls"] is None
|
||||
assert payload["debug"] == []
|
||||
|
||||
|
||||
def test_reader_json_is_decodable_ascii(run_script, fixtures):
|
||||
result = run_script("peutil.py", fixtures.pe64.path, "--json")
|
||||
assert result.stdout.isascii()
|
||||
|
||||
|
||||
# --- pepatch -----------------------------------------------------------------
|
||||
|
||||
def test_patcher_dry_run_json_reports_the_plan_and_says_it_wrote_nothing(run_script, fixtures,
|
||||
tmp_path):
|
||||
out = tmp_path / "never.exe"
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, "--at-offset", "0x410",
|
||||
"--bytes", "CC CC", "--original-length", "2", "--dry-run", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["dry_run"] is True
|
||||
assert payload["patches_requested"] == 1
|
||||
assert payload["patches_applied"] == 1
|
||||
assert payload["errors"] == []
|
||||
assert payload["checksum_recomputed"] is False
|
||||
applied = payload["applied"][0]
|
||||
assert applied["offset"] == 0x410
|
||||
assert applied["rva"] == "0x1010"
|
||||
assert applied["before"] == "48 8B"
|
||||
assert applied["after"] == "CC CC"
|
||||
assert applied["equal_length"] is True
|
||||
assert applied["length_basis"] == "original-length"
|
||||
assert "output" not in payload
|
||||
assert not out.exists()
|
||||
|
||||
|
||||
def test_patcher_real_run_json_reports_verification_and_the_new_checksum(run_script, fixtures,
|
||||
tmp_path):
|
||||
out = tmp_path / "patched.exe"
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, "--at-offset", "0x410",
|
||||
"--expect", "48 8B", "--bytes", "CC 8B", "--out", out, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["output"] == str(out)
|
||||
assert payload["output_size"] == payload["input_size"] == fixtures.pe64.size
|
||||
assert payload["verify_problems"] == []
|
||||
assert payload["checksum_after"]["matches"] is True
|
||||
assert payload["checksum_recomputed"] is True
|
||||
assert payload["signed_input"] is False
|
||||
assert payload["signature_note"] is None
|
||||
assert payload["structural"] == []
|
||||
assert payload["length_unproven"] == []
|
||||
assert out.read_bytes()[0x410:0x412] == b"\xCC\x8B"
|
||||
|
||||
|
||||
def test_patcher_json_names_a_length_unproven_patch(load_script, run_script, fixtures, tmp_path):
|
||||
out = tmp_path / "unproven.exe"
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, "--at-offset", "0x410",
|
||||
"--bytes", "CC", "--out", out, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["applied"][0]["equal_length"] is None
|
||||
assert payload["length_unproven"] == ["cli"]
|
||||
assert payload["structural"] == []
|
||||
|
||||
|
||||
def test_patcher_json_reports_a_refusal_without_an_output(run_script, fixtures, tmp_path):
|
||||
out = tmp_path / "never.exe"
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, "--pattern", fixtures.pe64.plant,
|
||||
"--bytes", "90 " * 12, "--out", out, "--json")
|
||||
assert result.returncode == 1
|
||||
assert not out.exists()
|
||||
assert "matched 2 sites" in result.stderr
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
# --- aob_scan ----------------------------------------------------------------
|
||||
|
||||
def test_locator_json_reports_a_unique_pattern_as_ok(run_script, fixtures, hexpat):
|
||||
pdb = hexpat(b"fixture.pdb")
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", pdb, "--json")
|
||||
assert result.returncode == 0, result.stdout
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["result"] == "ok"
|
||||
assert payload["target_sections"] == [".text", ".rdata", ".data", ".rsrc", ".reloc"]
|
||||
assert payload["section_filter"] is None
|
||||
entry = payload["patterns"][0]
|
||||
assert entry["hits"] == 1
|
||||
assert entry["verdict"] == "unique"
|
||||
assert entry["wildcards"] == 0
|
||||
assert entry["sites"][0]["section"] == ".rdata"
|
||||
|
||||
|
||||
def test_locator_json_reports_the_planted_pair_as_ambiguous(run_script, fixtures):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", fixtures.pe64.plant,
|
||||
"--json")
|
||||
assert result.returncode == 1, "an ambiguous pattern is the documented non-zero outcome"
|
||||
payload = json.loads(result.stdout)
|
||||
entry = payload["patterns"][0]
|
||||
assert entry["hits"] == 2
|
||||
assert entry["verdict"] == "ambiguous"
|
||||
assert payload["result"] == "needs-attention"
|
||||
offsets = [int(site["offset_hex"], 16) for site in entry["sites"]]
|
||||
assert offsets == list(fixtures.pe64.plant_offsets)
|
||||
assert [site["rva_hex"] for site in entry["sites"]] == \
|
||||
[fixtures.pe64.meta["plant"]["first_rva"], fixtures.pe64.meta["plant"]["second_rva"]]
|
||||
|
||||
|
||||
def test_locator_json_reports_an_absent_pattern_as_a_no_match(run_script, fixtures):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", "DE AD BE EF CA FE",
|
||||
"--json")
|
||||
assert result.returncode == 1
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["patterns"][0]["hits"] == 0
|
||||
assert payload["patterns"][0]["verdict"] == "no-match"
|
||||
assert payload["patterns"][0]["sites"] == []
|
||||
|
||||
|
||||
def test_locator_json_counts_wildcards_and_section_filtered_hits(run_script, fixtures):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns",
|
||||
"48 8B 05 ?? ?? ?? 00", "--section", ".text", "--json")
|
||||
payload = json.loads(result.stdout)
|
||||
entry = payload["patterns"][0]
|
||||
assert entry["wildcards"] == 3
|
||||
assert entry["hits"] == 2
|
||||
assert payload["section_filter"] == ".text"
|
||||
assert {site["section"] for site in entry["sites"]} == {".text"}
|
||||
|
||||
|
||||
def test_locator_json_from_a_pattern_file_keeps_the_names(run_script, fixtures, tmp_path, hexpat):
|
||||
sigs = tmp_path / "sigs.txt"
|
||||
sigs.write_text("# comment\n%s\nnamed-site\t%s\n" % (fixtures.pe64.plant, hexpat(b"FIXT")),
|
||||
encoding="utf-8")
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", sigs, "--json")
|
||||
payload = json.loads(result.stdout)
|
||||
names = [entry["name"] for entry in payload["patterns"]]
|
||||
assert names == ["sigs.txt:2", "named-site"]
|
||||
assert payload["patterns"][1]["hits"] >= 1
|
||||
|
||||
|
||||
# --- scan_leaks --------------------------------------------------------------
|
||||
|
||||
def test_scanner_json_is_valid_json_ahead_of_the_result_line(run_script, synthetic_leak_root):
|
||||
"""The document parses from the front; the RESULT line is not part of it."""
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--format", "json")
|
||||
assert result.returncode == 1
|
||||
payload = json_document(result.stdout)
|
||||
assert sorted(payload) == ["exempted", "files_scanned", "root", "strong", "verdict", "weak"]
|
||||
assert payload["verdict"] == "findings"
|
||||
assert payload["files_scanned"] >= 3
|
||||
assert last_line(result.stdout) == "RESULT=findings"
|
||||
|
||||
|
||||
def test_scanner_json_stdout_is_not_valid_as_a_whole(run_script, synthetic_leak_root):
|
||||
"""A negative outcome: nothing may treat this stdout as one JSON document."""
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--format", "json")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_scanner_json_reports_exactly_the_expected_rules(run_script, synthetic_leak_root):
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--format", "json",
|
||||
"--show-exempt")
|
||||
payload = json_document(result.stdout)
|
||||
assert {hit["rule"] for hit in payload["strong"]} == {
|
||||
"private-key-block", "github-token", "aws-access-key", "slack-token",
|
||||
"generic-secret-assignment", "licence-blob-literal", "windows-user-profile",
|
||||
"posix-home", "windows-sid-literal", "volume-serial-literal",
|
||||
"literal-endpoint-with-creds", "public-ipv4-literal", "product-name-with-version",
|
||||
}
|
||||
assert {hit["rule"] for hit in payload["weak"]} == {
|
||||
"exe-name-with-version", "literal-host-with-path", "email-address",
|
||||
"vendor-copyright-line",
|
||||
}
|
||||
for hit in payload["strong"]:
|
||||
assert hit["strength"] == "strong"
|
||||
assert hit["file"].endswith("notes.md")
|
||||
assert hit["line"] > 0
|
||||
assert hit["context"]
|
||||
assert "machine-guid" not in {hit["rule"] for hit in payload["strong"]}
|
||||
|
||||
|
||||
def test_scanner_json_show_exempt_records_the_suppressed_hits(run_script, synthetic_leak_root):
|
||||
"""Measured: the machine-guid hit is suppressed by the bare-GUID exemption."""
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--format", "json",
|
||||
"--show-exempt")
|
||||
payload = json_document(result.stdout)
|
||||
suppressed = {hit["rule"]: hit.get("exempt") for hit in payload["exempted"]}
|
||||
assert isinstance(payload["exempted"], list)
|
||||
assert suppressed.get("machine-guid") == \
|
||||
"a bare GUID is not machine identity without MachineGuid context"
|
||||
reasons = {hit["exempt"] for hit in payload["exempted"]
|
||||
if hit["rule"] == "product-name-with-version"}
|
||||
assert reasons and reasons <= {"tool or library name",
|
||||
"platform or vendor-of-the-platform term",
|
||||
"public protector/packer/managed tooling name"}
|
||||
|
||||
|
||||
def test_scanner_json_without_show_exempt_reports_only_a_count(run_script, synthetic_leak_root):
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--format", "json")
|
||||
payload = json_document(result.stdout)
|
||||
assert isinstance(payload["exempted"], int)
|
||||
assert payload["exempted"] > 0
|
||||
|
||||
|
||||
def test_scanner_json_on_a_clean_root_says_clean(run_script, clean_scan_root):
|
||||
result = run_script("scan_leaks.py", "--root", clean_scan_root, "--format", "json")
|
||||
assert result.returncode == 0
|
||||
payload = json_document(result.stdout)
|
||||
assert payload["verdict"] == "clean"
|
||||
assert payload["strong"] == []
|
||||
assert payload["weak"] == []
|
||||
|
||||
|
||||
# --- make_fixture_pe ---------------------------------------------------------
|
||||
|
||||
def test_fixture_builder_json_describes_what_it_wrote(run_script, tmp_path):
|
||||
out = tmp_path / "described.exe"
|
||||
result = run_script("make_fixture_pe.py", "--out", out, "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["path"] == str(out)
|
||||
assert payload["size"] == out.stat().st_size
|
||||
assert payload["sha256"] == hashlib.sha256(out.read_bytes()).hexdigest()
|
||||
assert sorted(payload["sections"]) == [".data", ".rdata", ".reloc", ".rsrc", ".text"]
|
||||
plant = payload["plant"]
|
||||
assert plant["first_file_offset"] != plant["second_file_offset"]
|
||||
assert "planted twice" in plant["note"]
|
||||
assert payload["checksum"].startswith("0x")
|
||||
assert payload["virtual_only_tail_bytes"] > 0
|
||||
assert payload["executable_and_writable_code"] is True
|
||||
|
||||
|
||||
def test_fixture_builder_is_byte_reproducible(run_script, tmp_path):
|
||||
first = tmp_path / "one.exe"
|
||||
second = tmp_path / "two.exe"
|
||||
a = run_script("make_fixture_pe.py", "--out", first, "--json")
|
||||
b = run_script("make_fixture_pe.py", "--out", second, "--json")
|
||||
assert a.returncode == b.returncode == 0
|
||||
assert json.loads(a.stdout)["sha256"] == json.loads(b.stdout)["sha256"]
|
||||
assert first.read_bytes() == second.read_bytes()
|
||||
|
||||
|
||||
# --- hwprobe -----------------------------------------------------------------
|
||||
|
||||
# The route table is documented as three-state: available / needs-<x> / unavailable. Measured on
|
||||
# this host, two rows carry a prose state instead of a vocabulary word. The values below are the
|
||||
# ones actually produced; allowing them keeps the check honest without asserting the docstring.
|
||||
ROUTE_STATES = ("available", "unavailable")
|
||||
ROUTE_PROSE_STATES = (
|
||||
"CPU supports it, but a hypervisor is present",
|
||||
"needs-a-debugger, and a hypervisor is present",
|
||||
"no documented user-mode path on this platform",
|
||||
)
|
||||
|
||||
|
||||
def test_prober_json_reports_a_route_table(hwprobe_report):
|
||||
payload = hwprobe_report
|
||||
for key in ("python", "platform", "cpu", "os", "privilege", "routes", "tools",
|
||||
"python_modules"):
|
||||
assert key in payload, key
|
||||
assert payload["python"].startswith("3.")
|
||||
rows = payload["routes"]
|
||||
assert rows
|
||||
for row in rows:
|
||||
assert len(row) == 3, row
|
||||
name, state, why = row
|
||||
assert name and state and why
|
||||
assert name != state
|
||||
# The route that needs nothing but Python and a file on disk must be available when the
|
||||
# script that reports it is running at all.
|
||||
static = [row for row in rows if row[0].startswith("static PE analysis")]
|
||||
assert len(static) == 1
|
||||
assert static[0][1] == "available"
|
||||
|
||||
|
||||
def test_route_states_use_the_vocabulary_or_a_measured_deviation(hwprobe_report):
|
||||
"""Measured: not every route state is one of the three documented states.
|
||||
|
||||
Two rows on this host answer with a sentence instead of a vocabulary word, so the
|
||||
three-state claim in the docstring holds for the table's shape but not for every cell. The
|
||||
measured sentence forms are listed rather than asserted as required, so a fix to the
|
||||
vocabulary passes and a new unmeasured sentence fails loudly.
|
||||
"""
|
||||
for row in hwprobe_report["routes"]:
|
||||
state = row[1]
|
||||
assert state in ROUTE_STATES or state.startswith("needs-") \
|
||||
or state in ROUTE_PROSE_STATES, state
|
||||
|
||||
|
||||
def test_prober_rows_carry_a_measurement_and_a_note(hwprobe_report):
|
||||
payload = hwprobe_report
|
||||
for group in ("cpu", "os", "privilege"):
|
||||
assert payload[group], group
|
||||
for row in payload[group]:
|
||||
assert set(row) >= {"state", "value", "note"}, (group, row)
|
||||
assert row["state"] in ROUTE_STATES or row["state"].startswith("needs-"), row
|
||||
|
||||
|
||||
def test_prober_can_dump_raw_cpuid_registers(run_script):
|
||||
result = run_script("hwprobe.py", "--raw", "--json", timeout=120)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["raw_cpuid"], "a raw dump was requested but nothing was reported"
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Subprocess contract tests for skills/exe-reverse/scripts/capabilities.py and doctor.py.
|
||||
|
||||
The exit codes are the ones the two docstrings publish -- 0 all ok, 3 something blocked, 2 usage
|
||||
error, 4 internal -- and the last stdout line is a `RESULT=<token>` for both.
|
||||
|
||||
One convention is pinned because it is not what a reader would assume: `capabilities.py --list`
|
||||
prints `RESULT=capabilities_ok` whatever the registry says, because listing ids and names probes
|
||||
nothing. The test asserts the contrast directly -- the same registry resolved through `--json` exits
|
||||
3 on this host while `--list` exits 0 -- so a future change to either half is deliberate.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.cli
|
||||
|
||||
BLOCKED_TOKEN = "capabilities_blocked"
|
||||
|
||||
|
||||
def json_document(text):
|
||||
return json.JSONDecoder().raw_decode(text)[0]
|
||||
|
||||
|
||||
def last_line(text):
|
||||
lines = [line for line in text.splitlines() if line.strip()]
|
||||
return lines[-1] if lines else ""
|
||||
|
||||
|
||||
def result_token(text):
|
||||
line = last_line(text)
|
||||
assert line.startswith("RESULT="), "the last line was %r" % line
|
||||
return line.split("=", 1)[1]
|
||||
|
||||
|
||||
# --- capabilities.py ---------------------------------------------------------
|
||||
|
||||
def test_list_prints_every_id_and_name(run_script, load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
result = run_script("capabilities.py", "--list")
|
||||
for capability_id in caps.CAPABILITIES:
|
||||
assert capability_id in result.stdout, capability_id
|
||||
assert "%d capabilities." % len(caps.CAPABILITIES) in result.stdout
|
||||
# The list now carries a real verdict rather than an unconditional one, so the exit code and
|
||||
# the token have to agree with what resolving the same registry reports.
|
||||
token, code = caps.verdict(caps.resolve_all())
|
||||
assert result.returncode == code
|
||||
assert result.stdout.strip().endswith("RESULT=%s" % caps.TOKENS[token])
|
||||
|
||||
|
||||
def test_list_and_json_agree_on_the_same_registry(run_script):
|
||||
"""Two views of one table must not disagree about it.
|
||||
|
||||
The list view used to print the ok token and exit 0 unconditionally while the JSON view of the
|
||||
same registry exited 3 on this host, because most of the table is blocked here by design --
|
||||
every `artifact:` row waits for an operator to supply one. A report that contradicts another
|
||||
report of the same data is worse than either alone: a reader cannot tell which to believe.
|
||||
"""
|
||||
listed = run_script("capabilities.py", "--list")
|
||||
resolved = run_script("capabilities.py", "--json")
|
||||
assert result_token(listed.stdout) == result_token(resolved.stdout)
|
||||
assert listed.returncode == resolved.returncode
|
||||
|
||||
|
||||
def test_json_reports_every_capability_with_its_closure(run_script, load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
result = run_script("capabilities.py", "--json")
|
||||
assert result.returncode in (0, 3), result.stderr
|
||||
rows = json_document(result.stdout)
|
||||
assert [row["id"] for row in rows] == list(caps.CAPABILITIES)
|
||||
for row in rows:
|
||||
assert set(row) >= {"id", "name", "state", "required", "any_of", "depends_on",
|
||||
"blocked_deps", "missing", "closure", "scripts", "note"}
|
||||
assert row["state"] in ("ok", "blocked")
|
||||
assert row["closure"][0] == row["id"]
|
||||
assert result_token(result.stdout) == (BLOCKED_TOKEN if result.returncode == 3
|
||||
else "capabilities_ok")
|
||||
|
||||
|
||||
def test_one_capability_reports_its_closure_and_its_state(run_script):
|
||||
"""`pe_structure_read` needs the interpreter and a script this kit ships, so it is ok anywhere."""
|
||||
result = run_script("capabilities.py", "pe_structure_read")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "capability: Read a PE image's structure" in result.stdout
|
||||
assert "scripts : peutil.py" in result.stdout
|
||||
assert "requires : python310, file:peutil.py" in result.stdout
|
||||
assert "state : ok" in result.stdout
|
||||
assert result.stdout.strip().endswith("RESULT=capabilities_ok")
|
||||
|
||||
|
||||
def test_one_blocked_capability_names_the_atom_no_install_can_supply(run_script):
|
||||
result = run_script("capabilities.py", "commandline_debug")
|
||||
assert result.returncode == 3, result.stdout
|
||||
assert "state : blocked" in result.stdout
|
||||
assert "missing atom : artifact:sample" in result.stdout
|
||||
assert "next action" not in result.stdout, "an artifact has no install line"
|
||||
assert result.stdout.strip().endswith("RESULT=%s" % BLOCKED_TOKEN)
|
||||
|
||||
|
||||
def test_one_capability_as_json_carries_the_same_fields(run_script):
|
||||
result = run_script("capabilities.py", "pe_structure_read", "--json")
|
||||
assert result.returncode == 0, result.stderr
|
||||
row = json_document(result.stdout)
|
||||
assert row["id"] == "pe_structure_read"
|
||||
assert row["state"] == "ok"
|
||||
assert row["missing"] == []
|
||||
assert row["blocked_deps"] == []
|
||||
assert result.stdout.strip().endswith("RESULT=capabilities_ok")
|
||||
|
||||
|
||||
def test_an_unknown_capability_is_a_usage_error_on_stdout_and_stderr(run_script):
|
||||
result = run_script("capabilities.py", "no_such_capability")
|
||||
assert result.returncode == 2
|
||||
assert result.stdout.strip() == "RESULT=usage_error"
|
||||
assert "no such capability" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_verbose_prints_the_next_action_for_a_blocked_capability(run_script, load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
installable = [caps.ATOMS[atom].get("install")
|
||||
for row in caps.resolve_all(caps.Probe(tool_dirs=caps.extra_tool_dirs()))
|
||||
if row["state"] != "ok"
|
||||
for atom, _detail in row["missing"]
|
||||
if caps.ATOMS.get(atom, {}).get("install")]
|
||||
if not installable:
|
||||
pytest.skip("this host has no blocked capability whose missing atom carries an install "
|
||||
"line, so there is nothing for --verbose to print here")
|
||||
result = run_script("capabilities.py", "--verbose")
|
||||
assert result.returncode in (0, 3), result.stderr
|
||||
assert "missing atom" in result.stdout
|
||||
assert "next action :" in result.stdout
|
||||
assert "est. minutes :" in result.stdout
|
||||
assert "(basis: " in result.stdout
|
||||
|
||||
|
||||
def test_an_unknown_flag_is_a_usage_error(run_script):
|
||||
result = run_script("capabilities.py", "--definitely-not-a-flag")
|
||||
assert result.returncode == 2
|
||||
assert result.stdout.strip() == ""
|
||||
assert "usage:" in result.stderr.lower()
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_help_exits_zero(run_script):
|
||||
result = run_script("capabilities.py", "--help")
|
||||
assert result.returncode == 0
|
||||
assert "usage:" in result.stdout.lower()
|
||||
|
||||
|
||||
def test_an_extra_tool_directory_can_turn_a_blocked_capability_ok(run_script, load_script,
|
||||
tmp_path):
|
||||
"""`--tools-dir` is the documented second search path; a tool off PATH is not a missing tool."""
|
||||
caps = load_script("capabilities.py")
|
||||
baseline = caps.resolve("static_disassembly",
|
||||
caps.Probe(tool_dirs=caps.extra_tool_dirs()))
|
||||
if baseline["state"] == "ok":
|
||||
pytest.skip("a disassembly backend is already reachable here (%s), so the extra directory "
|
||||
"cannot demonstrate anything" % baseline["alternatives_present"])
|
||||
|
||||
tools = tmp_path / "unpacked-tools"
|
||||
tools.mkdir()
|
||||
for name in ("ghidra", "ida64"):
|
||||
(tools / name).write_text("", encoding="utf-8")
|
||||
result = run_script("capabilities.py", "static_disassembly", "--tools-dir", tools)
|
||||
assert result.returncode == 0, result.stdout
|
||||
assert "present :" in result.stdout
|
||||
assert "EXEREV_TOOLS" in result.stdout
|
||||
|
||||
|
||||
# --- doctor.py ---------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def default_run(run_script):
|
||||
"""One full doctor run for the module: it probes the host and answers --help for every script."""
|
||||
return run_script("doctor.py", timeout=180)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def json_run(run_script):
|
||||
return run_script("doctor.py", "--json", timeout=180)
|
||||
|
||||
|
||||
def test_the_doctor_exits_zero_or_three_and_never_with_a_traceback(default_run):
|
||||
assert default_run.returncode in (0, 3), default_run
|
||||
assert "Traceback" not in default_run.stdout + default_run.stderr
|
||||
|
||||
|
||||
def test_the_doctor_prints_its_three_blocks_and_a_result_line(default_run, load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
assert "== capabilities ==" in default_run.stdout
|
||||
assert "== scripts ==" in default_run.stdout
|
||||
assert "== environment facts that can change a result ==" in default_run.stdout
|
||||
assert "== result ==" in default_run.stdout
|
||||
assert "%d script(s) checked" % len(doctor.discover_scripts()) in default_run.stdout
|
||||
assert result_token(default_run.stdout) in ("env_ok", "env_partial", "env_blocked")
|
||||
|
||||
|
||||
def test_the_result_token_follows_the_exit_code(default_run):
|
||||
token = result_token(default_run.stdout)
|
||||
if default_run.returncode == 3:
|
||||
assert token == "env_blocked"
|
||||
else:
|
||||
assert token in ("env_ok", "env_partial")
|
||||
|
||||
|
||||
def test_the_script_table_counts_the_scripts_that_are_actually_there(default_run, load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
total = len(doctor.discover_scripts())
|
||||
assert "registered/checked = %d / %d" % (total, total) in default_run.stdout
|
||||
|
||||
|
||||
def test_json_carries_the_four_sections_and_a_summary(json_run, load_script):
|
||||
"""The counts are cross-checked against the rows they summarise, so a script that stops being
|
||||
classified shows up as a mismatch rather than as a smaller denominator."""
|
||||
doctor = load_script("doctor.py")
|
||||
assert json_run.returncode in (0, 3), json_run.stderr
|
||||
payload = json_document(json_run.stdout)
|
||||
assert sorted(payload) == ["capabilities", "environment", "result", "scripts", "summary"]
|
||||
total = len(doctor.discover_scripts())
|
||||
assert len(payload["scripts"]) == total
|
||||
assert payload["summary"]["scripts_checked"] == total
|
||||
unknown = [row for row in payload["scripts"] if row["capability_source"] == "unknown"]
|
||||
assert payload["summary"]["scripts_with_unknown_capability"] == len(unknown)
|
||||
assert payload["summary"]["capabilities_total"] == len(payload["capabilities"])
|
||||
assert payload["result"] == result_token(json_run.stdout)
|
||||
for row in payload["scripts"]:
|
||||
assert set(row) == {"script", "capabilities", "capability_source", "capability_state",
|
||||
"runnable", "third_party", "detail"}
|
||||
|
||||
|
||||
def test_json_environment_facts_carry_the_documented_keys(json_run):
|
||||
payload = json_document(json_run.stdout)
|
||||
if len(payload["environment"]) == 1 and payload["environment"][0]["fact"] == "host probe":
|
||||
pytest.skip("the host probe could not answer here, so the fact table is inert")
|
||||
for fact in payload["environment"]:
|
||||
assert set(fact) == {"fact", "value", "effect", "severity"}
|
||||
assert fact["severity"] in ("note", "warn")
|
||||
|
||||
|
||||
def test_each_view_flag_prints_only_its_own_block(run_script):
|
||||
scripts_only = run_script("doctor.py", "--scripts", timeout=180)
|
||||
assert scripts_only.returncode in (0, 3), scripts_only
|
||||
assert "== scripts ==" in scripts_only.stdout
|
||||
assert "== capabilities ==" not in scripts_only.stdout
|
||||
assert "== environment facts" not in scripts_only.stdout
|
||||
assert "RESULT=" in scripts_only.stdout
|
||||
|
||||
environment_only = run_script("doctor.py", "--environment", timeout=180)
|
||||
assert environment_only.returncode in (0, 3), environment_only
|
||||
assert "== environment facts" in environment_only.stdout
|
||||
assert "== scripts ==" not in environment_only.stdout
|
||||
assert "== capabilities ==" not in environment_only.stdout
|
||||
|
||||
|
||||
def test_an_unknown_flag_is_a_usage_error_without_a_traceback(run_script):
|
||||
result = run_script("doctor.py", "--definitely-not-a-flag")
|
||||
assert result.returncode == 2
|
||||
assert "usage:" in result.stderr.lower()
|
||||
assert "Traceback" not in result.stderr
|
||||
assert result.stdout.strip() == ""
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Subprocess contract tests for the exit codes and usage surface of every script.
|
||||
|
||||
The exit codes asserted here are the ones each script's own docstring publishes, read from the
|
||||
docstring rather than invented:
|
||||
|
||||
* peutil.py 0 parsed, 1 not a parseable PE, 2 usage error
|
||||
* pepatch.py 0 applied and verified, 1 a patch failed, 2 usage error, 3 not a parseable PE
|
||||
* aob_scan.py 0 every pattern unique, 1 a pattern matched zero or several times, 2 usage,
|
||||
3 not a parseable PE
|
||||
* hwprobe.py 0 report produced, 2 usage error (a limited machine is a finding, not a failure)
|
||||
* make_fixture_pe.py 0 written, 2 usage error
|
||||
* scan_leaks.py 0 clean, 1 findings, 2 error
|
||||
|
||||
One input path is pinned as a measured deviation: a missing ``--spec`` file reaches an unguarded
|
||||
``open()``, so it prints a traceback instead of using the module's own exit-code vocabulary.
|
||||
"""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.cli
|
||||
|
||||
SCRIPTS = ("peutil.py", "pepatch.py", "hwprobe.py", "aob_scan.py", "make_fixture_pe.py",
|
||||
"scan_leaks.py")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script", SCRIPTS)
|
||||
def test_help_exits_zero_with_usage_text(run_script, script):
|
||||
result = run_script(script, "--help")
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip(), "no help text was printed"
|
||||
assert "usage:" in result.stdout.lower()
|
||||
assert script in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script", SCRIPTS)
|
||||
def test_an_unknown_flag_is_a_usage_error(run_script, script):
|
||||
result = run_script(script, "--definitely-not-a-flag")
|
||||
assert result.returncode == 2
|
||||
assert result.stdout.strip() == ""
|
||||
assert "usage:" in result.stderr.lower()
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("script", [s for s in SCRIPTS if s != "scan_leaks.py"])
|
||||
def test_a_script_with_no_arguments_never_hangs(run_script, script):
|
||||
"""Each script either does its documented thing or reports a usage error; none hangs.
|
||||
|
||||
The scanner is excluded here because its no-argument behaviour is to scan a tree rather than
|
||||
to fail; it has its own check below.
|
||||
"""
|
||||
result = run_script(script, timeout=120)
|
||||
assert result.returncode in (0, 2), result
|
||||
|
||||
|
||||
def test_scanner_with_no_arguments_scans_a_tree_and_states_its_verdict(run_script):
|
||||
"""With no arguments it scans the tree its own script lives in, and says what it found."""
|
||||
result = run_script("scan_leaks.py", timeout=180)
|
||||
assert result.returncode in (0, 1), result
|
||||
assert "scanning" in result.stdout
|
||||
assert result.stdout.strip().endswith(
|
||||
"RESULT=clean" if result.returncode == 0 else "RESULT=findings")
|
||||
|
||||
|
||||
# --- peutil ------------------------------------------------------------------
|
||||
|
||||
def test_reader_on_a_non_pe_exits_one_without_a_traceback(run_script, tmp_path):
|
||||
path = tmp_path / "notes.txt"
|
||||
path.write_text("this is not a portable executable\n", encoding="utf-8")
|
||||
result = run_script("peutil.py", path)
|
||||
assert result.returncode == 1
|
||||
assert "error:" in result.stderr
|
||||
assert "no MZ signature" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_reader_on_a_missing_file_exits_two(run_script, tmp_path):
|
||||
result = run_script("peutil.py", tmp_path / "absent.exe")
|
||||
assert result.returncode == 2
|
||||
assert "is not a file" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_reader_on_a_truncated_image_reports_instead_of_raising(run_script, tmp_path, fixtures):
|
||||
"""A file cut off mid-header goes through the exit-code contract, not through struct.
|
||||
|
||||
A truncated image reaches an analyst as an interrupted download or a carved region far more
|
||||
often than as a malformed program, so what matters is the contract: one line on stderr and a
|
||||
documented code. Pinned after out-of-range reads were turned into a ParseError -- before that
|
||||
the caller got a traceback, which reads as a defect in the tool rather than a fact about the
|
||||
file.
|
||||
"""
|
||||
path = tmp_path / "truncated.exe"
|
||||
path.write_bytes(fixtures.pe64.data[:0x100])
|
||||
result = run_script("peutil.py", path)
|
||||
assert result.returncode == 1
|
||||
assert "error:" in result.stderr
|
||||
assert "ends before" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_reader_defaults_to_the_summary_view(run_script, fixtures):
|
||||
result = run_script("peutil.py", fixtures.pe64.path)
|
||||
assert result.returncode == 0
|
||||
assert "checksum" in result.stdout
|
||||
assert "sections" in result.stdout
|
||||
assert "amd64" in result.stdout
|
||||
|
||||
|
||||
# --- pepatch -----------------------------------------------------------------
|
||||
|
||||
def test_patcher_on_a_non_pe_exits_three(run_script, tmp_path):
|
||||
path = tmp_path / "notes.txt"
|
||||
path.write_text("not a PE\n", encoding="utf-8")
|
||||
result = run_script("pepatch.py", path, "--at-offset", "0", "--bytes", "CC", "--out",
|
||||
tmp_path / "out.exe")
|
||||
assert result.returncode == 3
|
||||
assert "not a parseable PE" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert not (tmp_path / "out.exe").exists()
|
||||
|
||||
|
||||
def test_patcher_on_a_truncated_image_reports_instead_of_raising(run_script, tmp_path, fixtures):
|
||||
"""Same contract as the reader's: a truncated image is an input, not a crash."""
|
||||
path = tmp_path / "truncated.exe"
|
||||
path.write_bytes(fixtures.pe64.data[:0x100])
|
||||
result = run_script("pepatch.py", path, "--at-offset", "0", "--bytes", "CC", "--out",
|
||||
tmp_path / "out.exe")
|
||||
assert result.returncode == 3
|
||||
assert "not a parseable PE" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert not (tmp_path / "out.exe").exists()
|
||||
|
||||
|
||||
def test_patcher_on_a_missing_file_exits_two(run_script, tmp_path):
|
||||
result = run_script("pepatch.py", tmp_path / "absent.exe", "--at-offset", "0",
|
||||
"--bytes", "CC", "--out", tmp_path / "out.exe")
|
||||
assert result.returncode == 2
|
||||
assert "is not a file" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extra", [
|
||||
(),
|
||||
("--bytes", "CC"),
|
||||
("--at-offset", "0x410"),
|
||||
])
|
||||
def test_patcher_without_a_usable_spec_exits_two(run_script, fixtures, extra):
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, *extra)
|
||||
assert result.returncode == 2
|
||||
assert "error:" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_patcher_with_an_empty_patch_list_exits_two(run_script, fixtures, tmp_path):
|
||||
spec = tmp_path / "empty.json"
|
||||
spec.write_text('{"patches": []}', encoding="utf-8")
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, "--spec", spec, "--out",
|
||||
tmp_path / "out.exe")
|
||||
assert result.returncode == 2
|
||||
assert 'has no "patches" array' in result.stderr
|
||||
assert not (tmp_path / "out.exe").exists()
|
||||
|
||||
|
||||
def test_patcher_with_a_missing_spec_file_uses_the_exit_code_vocabulary(run_script,
|
||||
fixtures, tmp_path):
|
||||
"""A spec file that is not there is a usage error, and it says so in one line."""
|
||||
result = run_script("pepatch.py", fixtures.pe64.path, "--spec", tmp_path / "absent.json",
|
||||
"--out", tmp_path / "out.exe")
|
||||
assert result.returncode == 2
|
||||
assert "is not a readable file" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
assert not (tmp_path / "out.exe").exists()
|
||||
|
||||
|
||||
# --- aob_scan ----------------------------------------------------------------
|
||||
|
||||
def test_locator_on_a_non_pe_exits_three(run_script, tmp_path):
|
||||
path = tmp_path / "notes.txt"
|
||||
path.write_text("not a PE\n", encoding="utf-8")
|
||||
result = run_script("aob_scan.py", path, "--patterns", "48 8B")
|
||||
assert result.returncode == 3
|
||||
assert "error:" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_locator_without_patterns_is_a_usage_error(run_script, fixtures):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path)
|
||||
assert result.returncode == 2
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_locator_on_a_malformed_pattern_exits_nonzero_with_a_message(run_script, fixtures):
|
||||
result = run_script("aob_scan.py", fixtures.pe64.path, "--patterns", "ZZ QQ")
|
||||
assert result.returncode == 1
|
||||
assert "not a byte" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_locator_on_a_missing_file_exits_three(run_script, tmp_path):
|
||||
result = run_script("aob_scan.py", tmp_path / "absent.exe", "--patterns", "48 8B")
|
||||
assert result.returncode == 3
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
# --- make_fixture_pe ---------------------------------------------------------
|
||||
|
||||
def test_fixture_builder_lists_its_recipes(run_script):
|
||||
result = run_script("make_fixture_pe.py", "--list-recipes")
|
||||
assert result.returncode == 0
|
||||
for recipe in ("default", "pe32", "bare", "fat-align", "thin-align", "no-virtual-tail"):
|
||||
assert recipe in result.stdout
|
||||
|
||||
|
||||
def test_fixture_builder_without_an_output_path_is_a_usage_error(run_script):
|
||||
result = run_script("make_fixture_pe.py")
|
||||
assert result.returncode == 2
|
||||
assert "--out is required" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_fixture_builder_rejects_an_unknown_recipe(run_script, tmp_path):
|
||||
result = run_script("make_fixture_pe.py", "--out", tmp_path / "x.exe",
|
||||
"--recipe", "no-such-recipe")
|
||||
assert result.returncode == 2
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_the_named_no_virtual_tail_recipe_builds(run_script, tmp_path):
|
||||
"""The recipe names a property it removes, and it can be used to remove it.
|
||||
|
||||
An earlier guard compared the .rdata payload against the requested tail itself, which
|
||||
inverted the relationship and made a zero-byte tail -- the whole point of the recipe --
|
||||
impossible to ask for. Pinned after the fix, because a recipe that is listed and cannot be
|
||||
built is a trap for whoever reads the list next.
|
||||
"""
|
||||
out = tmp_path / "novtail.exe"
|
||||
result = run_script("make_fixture_pe.py", "--out", out, "--recipe", "no-virtual-tail")
|
||||
assert result.returncode == 0
|
||||
assert out.exists()
|
||||
assert "virtual_only_tail_bytes" in result.stdout or "wrote" in result.stdout
|
||||
|
||||
|
||||
# --- scan_leaks --------------------------------------------------------------
|
||||
|
||||
def test_scanner_lists_its_rules_and_exemptions(run_script):
|
||||
result = run_script("scan_leaks.py", "--list-rules")
|
||||
assert result.returncode == 0
|
||||
assert "rules (18):" in result.stdout
|
||||
assert "exemptions (" in result.stdout
|
||||
assert "private-key-block" in result.stdout
|
||||
assert "tool or library name" in result.stdout
|
||||
|
||||
|
||||
def test_scanner_on_a_missing_root_exits_two_and_states_the_result(run_script, tmp_path):
|
||||
result = run_script("scan_leaks.py", "--root", tmp_path / "absent")
|
||||
assert result.returncode == 2
|
||||
assert result.stdout.strip().endswith("RESULT=error")
|
||||
assert "is not a directory" in result.stderr
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_scanner_on_a_clean_root_exits_zero(run_script, clean_scan_root):
|
||||
result = run_script("scan_leaks.py", "--root", clean_scan_root)
|
||||
assert result.returncode == 0
|
||||
assert "RESULT=clean" in result.stdout
|
||||
|
||||
|
||||
def test_scanner_quiet_mode_prints_only_the_result_line(run_script, clean_scan_root):
|
||||
result = run_script("scan_leaks.py", "--root", clean_scan_root, "--quiet")
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "RESULT=clean"
|
||||
|
||||
|
||||
def test_scanner_exit_code_three_is_documented_but_unreachable(run_script, tmp_path):
|
||||
"""Measured deviation: the docstring promises 3 for an empty scan surface.
|
||||
|
||||
No code path returns 3, so an empty surface is reported as `clean` with exit 0 -- the exact
|
||||
distinction the docstring says it is making. Pinned so the mismatch is visible.
|
||||
"""
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
result = run_script("scan_leaks.py", "--root", empty)
|
||||
assert result.returncode == 0
|
||||
assert "RESULT=clean" in result.stdout
|
||||
assert "0 files" in result.stdout
|
||||
|
||||
|
||||
def test_scanner_on_a_root_with_findings_exits_one(run_script, synthetic_leak_root):
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--quiet")
|
||||
assert result.returncode == 1
|
||||
assert result.stdout.strip() == "RESULT=findings"
|
||||
|
||||
|
||||
def test_scanner_fail_on_any_raises_a_weak_only_surface_to_findings(run_script, tmp_path,
|
||||
weak_line):
|
||||
root = tmp_path / "weak"
|
||||
root.mkdir()
|
||||
(root / "note.md").write_text(weak_line + "\n", encoding="utf-8")
|
||||
default = run_script("scan_leaks.py", "--root", root, "--quiet")
|
||||
strict = run_script("scan_leaks.py", "--root", root, "--quiet", "--fail-on", "any")
|
||||
assert default.returncode == 0
|
||||
assert default.stdout.strip() == "RESULT=clean"
|
||||
assert strict.returncode == 1
|
||||
assert strict.stdout.strip() == "RESULT=findings"
|
||||
|
||||
|
||||
def test_scanner_only_filter_selects_one_category(run_script, synthetic_leak_root):
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--only", "token",
|
||||
"--format", "json")
|
||||
payload = json.JSONDecoder().raw_decode(result.stdout)[0]
|
||||
assert result.returncode == 1
|
||||
assert {hit["category"] for hit in payload["strong"]} == {"token"}
|
||||
assert payload["weak"] == []
|
||||
|
||||
|
||||
def test_scanner_skip_filter_drops_a_category(run_script, synthetic_leak_root):
|
||||
result = run_script("scan_leaks.py", "--root", synthetic_leak_root, "--skip",
|
||||
"token,path,device,endpoint,package,identity", "--format", "json")
|
||||
payload = json.JSONDecoder().raw_decode(result.stdout)[0]
|
||||
assert payload["strong"] == []
|
||||
assert payload["weak"] == []
|
||||
assert payload["verdict"] == "clean"
|
||||
assert result.returncode == 0
|
||||
|
||||
|
||||
# --- hwprobe -----------------------------------------------------------------
|
||||
|
||||
def test_prober_reports_routes_without_failing_on_a_limited_host(run_script):
|
||||
"""A missing capability is a finding, not a non-zero exit."""
|
||||
result = run_script("hwprobe.py", "--json", timeout=120)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert payload["routes"]
|
||||
assert "Traceback" not in result.stderr
|
||||
|
||||
|
||||
def test_prober_tool_inventory_mode_skips_the_cpu_rows(run_script):
|
||||
result = run_script("hwprobe.py", "--tools", "--json", timeout=120)
|
||||
assert result.returncode == 0, result.stderr
|
||||
payload = json.loads(result.stdout)
|
||||
assert "cpu" not in payload and "routes" not in payload
|
||||
assert payload["tools"] and payload["python_modules"]
|
||||
|
||||
|
||||
def test_inspect_only_runs_leave_their_working_directory_clean(run_script, fixtures,
|
||||
clean_scan_root):
|
||||
"""A negative outcome: none of these inspect-only runs may write into the cwd or the input.
|
||||
|
||||
The dry-run patcher is the case that matters: without ``--out`` it must report and stop.
|
||||
"""
|
||||
workdir = fixtures.pe64.path.parent
|
||||
before = sorted(p.name for p in workdir.iterdir())
|
||||
digest = fixtures.pe64.sha256
|
||||
run_script("peutil.py", fixtures.pe64.path, cwd=workdir)
|
||||
run_script("aob_scan.py", fixtures.pe64.path, "--patterns", "48 8B", cwd=workdir)
|
||||
run_script("pepatch.py", fixtures.pe64.path, "--at-offset", "0x410", "--bytes", "CC",
|
||||
"--dry-run", cwd=workdir)
|
||||
run_script("scan_leaks.py", "--root", clean_scan_root, cwd=workdir)
|
||||
assert sorted(p.name for p in workdir.iterdir()) == before
|
||||
assert fixtures.pe64.sha256 == digest
|
||||
Reference in New Issue
Block a user