Init
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/aob_scan.py.
|
||||
|
||||
The tool's real output is the *verdict*: a pattern that matches twice is a coin flip, and the
|
||||
whole point is that the caller is told so. The boundary table is asserted at both sides of each
|
||||
threshold, and the pattern-file parser is tested in all four of its accepted shapes.
|
||||
"""
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def ns(**kwargs):
|
||||
base = {"patterns": []}
|
||||
base.update(kwargs)
|
||||
return types.SimpleNamespace(**base)
|
||||
|
||||
|
||||
# --- verdict boundaries ------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("count,expected", [
|
||||
(0, "no-match"),
|
||||
(1, "unique"),
|
||||
(2, "ambiguous"),
|
||||
(3, "ambiguous"),
|
||||
(7, "ambiguous"),
|
||||
(8, "pervasive"),
|
||||
(64, "pervasive"),
|
||||
])
|
||||
def test_verdict_boundaries(load_script, count, expected):
|
||||
assert load_script("aob_scan.py").verdict(count, count) == expected
|
||||
|
||||
|
||||
def test_verdict_thresholds_are_the_documented_ones(load_script):
|
||||
aob = load_script("aob_scan.py")
|
||||
assert aob.NOTICEABLE_AT == 8
|
||||
assert aob.AMBIGUOUS_AT == 2
|
||||
assert aob.verdict(aob.NOTICEABLE_AT - 1, 0) == "ambiguous"
|
||||
assert aob.verdict(aob.NOTICEABLE_AT, 0) == "pervasive"
|
||||
|
||||
|
||||
def test_verdict_ignores_the_unused_total_argument(load_script):
|
||||
aob = load_script("aob_scan.py")
|
||||
assert aob.verdict(1, 999) == aob.verdict(1, 0) == "unique"
|
||||
|
||||
|
||||
# --- locate ------------------------------------------------------------------
|
||||
|
||||
def test_locate_reports_offset_rva_and_section(load_script, fixtures):
|
||||
aob = load_script("aob_scan.py")
|
||||
peutil = load_script("peutil.py")
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = peutil.PE(fixtures.pe64.data, None)
|
||||
pat = pepatch.parse_bytes(fixtures.pe64.plant)[0]
|
||||
rows = aob.locate(pe, fixtures.pe64.data, pat)
|
||||
assert [r["offset"] for r in rows] == list(fixtures.pe64.plant_offsets)
|
||||
assert [r["rva_hex"] for r in rows] == [fixtures.pe64.meta["plant"]["first_rva"],
|
||||
fixtures.pe64.meta["plant"]["second_rva"]]
|
||||
assert [r["section"] for r in rows] == [".text", ".text"]
|
||||
assert rows[0]["offset_hex"] == fixtures.pe64.meta["plant"]["first_file_offset"]
|
||||
|
||||
|
||||
def test_locate_section_filter_drops_hits_elsewhere(load_script, fixtures):
|
||||
aob = load_script("aob_scan.py")
|
||||
peutil = load_script("peutil.py")
|
||||
pat = [0x66, 0x69, 0x78, 0x74, 0x75, 0x72, 0x65, 0x2E, 0x70, 0x64, 0x62] # "fixture.pdb"
|
||||
pe = peutil.PE(fixtures.pe64.data, None)
|
||||
assert [r["section"] for r in aob.locate(pe, fixtures.pe64.data, pat)] == [".rdata"]
|
||||
assert aob.locate(pe, fixtures.pe64.data, pat, section_filter=".text") == []
|
||||
assert len(aob.locate(pe, fixtures.pe64.data, pat, section_filter=".rdata")) == 1
|
||||
|
||||
|
||||
def test_locate_context_is_centred_on_the_match(load_script, fixtures):
|
||||
aob = load_script("aob_scan.py")
|
||||
peutil = load_script("peutil.py")
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = peutil.PE(fixtures.pe64.data, None)
|
||||
pat = pepatch.parse_bytes(fixtures.pe64.plant)[0]
|
||||
rows = aob.locate(pe, fixtures.pe64.data, pat, context=8)
|
||||
assert "context" in rows[0]
|
||||
assert rows[0]["context_marks"] == "pre 8 bytes, match 12, post 8"
|
||||
assert len(pepatch.parse_bytes(rows[0]["context"])[0]) == len(pat) + 16
|
||||
|
||||
|
||||
def test_locate_returns_no_rows_when_the_pattern_is_absent(load_script, fixtures):
|
||||
"""A negative outcome: an absent pattern yields an empty list, not a nearest match."""
|
||||
aob = load_script("aob_scan.py")
|
||||
peutil = load_script("peutil.py")
|
||||
pe = peutil.PE(fixtures.bare.data, None)
|
||||
pat = [0x66, 0x69, 0x78, 0x74, 0x75, 0x72, 0x65, 0x2E, 0x70, 0x64, 0x62]
|
||||
assert aob.locate(pe, fixtures.bare.data, pat) == []
|
||||
|
||||
|
||||
# --- load_patterns -----------------------------------------------------------
|
||||
|
||||
def test_load_patterns_accepts_an_inline_pattern(load_script):
|
||||
aob = load_script("aob_scan.py")
|
||||
assert aob.load_patterns(ns(patterns=["48 8B ?? 05"])) == [
|
||||
("inline", [0x48, 0x8B, None, 0x05], "48 8B ?? 05")]
|
||||
|
||||
|
||||
def test_load_patterns_without_patterns_is_empty(load_script):
|
||||
assert load_script("aob_scan.py").load_patterns(ns()) == []
|
||||
assert load_script("aob_scan.py").load_patterns(ns(patterns=[])) == []
|
||||
|
||||
|
||||
def test_load_patterns_parses_a_text_file_with_comments_and_names(load_script, tmp_path):
|
||||
aob = load_script("aob_scan.py")
|
||||
path = tmp_path / "sigs.txt"
|
||||
path.write_text("# a comment\n\n48 8B 05\nuse-site\t90 90 90\n", encoding="utf-8")
|
||||
got = aob.load_patterns(ns(patterns=[str(path)]))
|
||||
assert got == [("sigs.txt:3", [0x48, 0x8B, 0x05], "48 8B 05"),
|
||||
("use-site", [0x90, 0x90, 0x90], "90 90 90")]
|
||||
|
||||
|
||||
def test_load_patterns_parses_a_json_object(load_script, tmp_path):
|
||||
aob = load_script("aob_scan.py")
|
||||
obj = tmp_path / "named.json"
|
||||
obj.write_text('{"patterns": [{"name": "gate", "pattern": "74 05"}, "EB 05"]}',
|
||||
encoding="utf-8")
|
||||
assert aob.load_patterns(ns(patterns=[str(obj)])) == [
|
||||
("gate", [0x74, 0x05], "74 05"),
|
||||
("named.json[1]", [0xEB, 0x05], "EB 05")]
|
||||
|
||||
|
||||
def test_a_bare_json_list_is_accepted(load_script, tmp_path):
|
||||
"""A top-level array is as reasonable a shape as an object with a "patterns" key.
|
||||
|
||||
The reader used to call ``.get`` on whatever ``json.load`` returned, so a top-level array
|
||||
raised AttributeError instead of the SystemExit this module uses for every other bad input.
|
||||
Both shapes are read now, and anything else is refused with a message naming what was found.
|
||||
"""
|
||||
aob = load_script("aob_scan.py")
|
||||
bare = tmp_path / "bare.json"
|
||||
bare.write_text('["CC CC"]', encoding="utf-8")
|
||||
assert aob.load_patterns(ns(patterns=[str(bare)])) == [("bare.json[0]", [0xCC, 0xCC], "CC CC")]
|
||||
|
||||
|
||||
def test_a_json_scalar_is_refused_with_a_message(load_script, tmp_path):
|
||||
"""The shape that is still not a pattern file says so instead of raising."""
|
||||
aob = load_script("aob_scan.py")
|
||||
scalar = tmp_path / "scalar.json"
|
||||
scalar.write_text('42', encoding="utf-8")
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
aob.load_patterns(ns(patterns=[str(scalar)]))
|
||||
assert "must hold an array or an object" in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("ZZ QQ", "not a byte"),
|
||||
("?? ??", "empty or all wildcards"),
|
||||
])
|
||||
def test_load_patterns_exits_on_a_pattern_it_cannot_parse(load_script, tmp_path, text,
|
||||
expected):
|
||||
aob = load_script("aob_scan.py")
|
||||
path = tmp_path / "bad.txt"
|
||||
path.write_text(text + "\n", encoding="utf-8")
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
aob.load_patterns(ns(patterns=[str(path)]))
|
||||
assert expected in str(excinfo.value)
|
||||
@@ -0,0 +1,509 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/capabilities.py, the capability registry.
|
||||
|
||||
The registry's value is that a verdict is *computed* from a probed closure rather than read off a
|
||||
name, so what is asserted here is the table it computes over: every atom a capability names, every
|
||||
atom's kind and basis, and the transitive closure the resolution walks.
|
||||
|
||||
One part of the table is not well formed, and the tests pin the exact extent rather than asserting
|
||||
the invariant that fails. `Probe.atom` answers "atom is not defined" for an id with no `ATOMS` row,
|
||||
so a capability naming a *capability* in `required` or `any_of` has an atom nothing can ever
|
||||
satisfy. Two rows do that today (`dump_capture`, `hypervisor_observe`); they are named in
|
||||
`UNTABLED_REFERENCES` so a third one fails the suite instead of arriving silently. Reported as a
|
||||
defect in the digest: these tests do not repair the script.
|
||||
|
||||
`declared_capability` gets its own group, because the convention it reads is documented inside this
|
||||
file's own docstring. A looser pattern once read that documentation as a declaration and reported
|
||||
the registry itself as declaring `capability: <capability_id>[, <capability_id>]`; the template is
|
||||
pinned as *not* a declaration below.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# Measured, not assumed: the `required` / `any_of` entries that name a capability id instead of an
|
||||
# atom id. Both are answered by `Probe.atom` with the literal detail "atom is not defined", which is
|
||||
# pinned separately -- the mechanism is what makes the reference unrepairable from outside the file.
|
||||
UNTABLED_REFERENCES = frozenset({
|
||||
("dump_capture", "any_of", "commandline_debug"),
|
||||
("hypervisor_observe", "required", "kernel_driver_load"),
|
||||
})
|
||||
|
||||
# The kinds `Probe.atom` dispatches on, and the field each one is probed through.
|
||||
PROBE_HANDLED_KINDS = {
|
||||
"python": None,
|
||||
"module": "module",
|
||||
"tool": "tool",
|
||||
"file": "script",
|
||||
"host": "probe",
|
||||
"artifact": None,
|
||||
}
|
||||
|
||||
# The probe names `_host_flag` understands. Not decoration: a `host` atom whose probe name is not in
|
||||
# this set falls through to `None`, which reads as "not available on this host" forever.
|
||||
HOST_PROBE_NAMES = frozenset({
|
||||
"elevation", "hypervisor", "intel_pt", "breakpoints", "test_signing", "etw",
|
||||
})
|
||||
|
||||
# Both are the documented strength labels of the *measurement*, not of the route.
|
||||
BASIS_LABELS = frozenset({"observed", "unverified"})
|
||||
|
||||
|
||||
def probe(caps):
|
||||
"""A fresh probe with no extra tool directories, so PATH alone decides."""
|
||||
return caps.Probe(tool_dirs=[])
|
||||
|
||||
|
||||
def untabled_references(caps):
|
||||
out = set()
|
||||
for capability_id, spec in caps.CAPABILITIES.items():
|
||||
for key in ("required", "any_of"):
|
||||
for atom in spec.get(key, []):
|
||||
if atom not in caps.ATOMS:
|
||||
out.add((capability_id, key, atom))
|
||||
return out
|
||||
|
||||
|
||||
def reference_closure(caps, capability_id):
|
||||
"""An independent breadth-first walk to compare the module's ordering against."""
|
||||
out, queue = [], [capability_id]
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if current in out:
|
||||
continue
|
||||
out.append(current)
|
||||
queue.extend(caps.CAPABILITIES.get(current, {}).get("depends_on", []))
|
||||
return out
|
||||
|
||||
|
||||
# --- the atom table ----------------------------------------------------------
|
||||
|
||||
def test_the_registry_is_not_empty(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert len(caps.ATOMS) > 1
|
||||
assert len(caps.CAPABILITIES) > 1
|
||||
|
||||
|
||||
def test_every_capability_row_carries_a_name_and_a_required_list(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for capability_id, spec in caps.CAPABILITIES.items():
|
||||
assert spec.get("name"), "%s has no name" % capability_id
|
||||
required = spec.get("required")
|
||||
assert isinstance(required, list), "%s has no required list" % capability_id
|
||||
# An empty `required` is legitimate when the row's prerequisites are other capabilities:
|
||||
# `hypervisor_observe` needs no atom of its own, it needs the capability below it. What is
|
||||
# not legitimate is a row that names nothing at all, because then no probe can decide it.
|
||||
assert required or spec.get("any_of") or spec.get("depends_on"), \
|
||||
"%s names nothing at all" % capability_id
|
||||
|
||||
|
||||
def test_capability_rows_use_only_the_documented_keys(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
allowed = {"name", "required", "any_of", "depends_on", "scripts", "note"}
|
||||
for capability_id, spec in caps.CAPABILITIES.items():
|
||||
assert set(spec) <= allowed, "%s carries %r" % (capability_id, sorted(set(spec) - allowed))
|
||||
|
||||
|
||||
def test_no_unrecorded_atom_reference_is_outside_the_atom_table(load_script):
|
||||
"""Every name in a `required` or `any_of` list has a row in the atom table.
|
||||
|
||||
This was false for two entries: a **capability** id had been written into an atom slot, so the
|
||||
probe answered `atom is not defined` and the branch could never be satisfied -- and one of them,
|
||||
`hypervisor_observe`, was therefore permanently blocked with no next action to print. A slot
|
||||
whose vocabulary is wrong is silent, which is why it is asserted as a property now rather than
|
||||
as the measured extent of the breakage.
|
||||
"""
|
||||
caps = load_script("capabilities.py")
|
||||
assert untabled_references(caps) == set()
|
||||
|
||||
|
||||
def test_a_capability_named_where_an_atom_belongs_is_answered_as_undefined(load_script):
|
||||
"""The mechanism behind the pinned deviation, so the two rows are not merely suspicious names."""
|
||||
caps = load_script("capabilities.py")
|
||||
for _capability_id, _key, named in sorted(UNTABLED_REFERENCES):
|
||||
assert named in caps.CAPABILITIES, "%s should be a capability id" % named
|
||||
result = probe(caps).atom(named)
|
||||
assert result["ok"] is False
|
||||
assert result["detail"] == "atom is not defined"
|
||||
|
||||
|
||||
def test_dependencies_name_capabilities_rather_than_atoms(load_script):
|
||||
"""`depends_on` is a capability edge. An atom id here would be silently unresolvable."""
|
||||
caps = load_script("capabilities.py")
|
||||
for capability_id, spec in caps.CAPABILITIES.items():
|
||||
for dep in spec.get("depends_on", []):
|
||||
assert dep in caps.CAPABILITIES, \
|
||||
"%s depends on %r, which is not a capability" % (capability_id, dep)
|
||||
assert dep not in caps.ATOMS, \
|
||||
"%s depends on %r, which is an atom: closure() would treat it as a capability" \
|
||||
% (capability_id, dep)
|
||||
assert dep != capability_id, "%s depends on itself" % capability_id
|
||||
|
||||
|
||||
def test_the_script_map_points_only_at_capabilities_that_exist(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.SCRIPT_CAPABILITIES
|
||||
for script, ids in caps.SCRIPT_CAPABILITIES.items():
|
||||
assert ids, "%s is mapped to an empty list, which reads as a claim" % script
|
||||
for capability_id in ids:
|
||||
assert capability_id in caps.CAPABILITIES, \
|
||||
"%s is mapped to unknown capability %r" % (script, capability_id)
|
||||
|
||||
|
||||
def test_the_dependency_graph_is_acyclic(load_script):
|
||||
"""A cycle would make `resolve` recurse forever rather than report a blocked capability."""
|
||||
caps = load_script("capabilities.py")
|
||||
state = {}
|
||||
|
||||
def walk(capability_id):
|
||||
stack = state.get(capability_id, 0)
|
||||
assert stack != 1, "dependency cycle through %s" % capability_id
|
||||
if stack == 2:
|
||||
return
|
||||
state[capability_id] = 1
|
||||
for dep in caps.CAPABILITIES.get(capability_id, {}).get("depends_on", []):
|
||||
walk(dep)
|
||||
state[capability_id] = 2
|
||||
|
||||
for capability_id in caps.CAPABILITIES:
|
||||
walk(capability_id)
|
||||
|
||||
|
||||
def test_every_atom_carries_the_basis_label_the_registry_documents(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for atom_id, spec in caps.ATOMS.items():
|
||||
assert spec.get("basis") in BASIS_LABELS, \
|
||||
"%s has basis %r, which is neither %s" % (atom_id, spec.get("basis"),
|
||||
" nor ".join(sorted(BASIS_LABELS)))
|
||||
|
||||
|
||||
def test_every_atom_kind_is_one_the_probe_handles(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for atom_id, spec in caps.ATOMS.items():
|
||||
assert spec.get("kind") in PROBE_HANDLED_KINDS, \
|
||||
"%s has kind %r, which Probe.atom does not dispatch on" % (atom_id, spec.get("kind"))
|
||||
|
||||
|
||||
def test_every_atom_carries_the_field_its_kind_is_probed_through(load_script):
|
||||
"""`Probe.atom` subscripts these keys, so a missing one is a KeyError, not a verdict."""
|
||||
caps = load_script("capabilities.py")
|
||||
for atom_id, spec in caps.ATOMS.items():
|
||||
field = PROBE_HANDLED_KINDS[spec["kind"]]
|
||||
if field is not None:
|
||||
assert spec.get(field), "%s (%s) has no %r" % (atom_id, spec["kind"], field)
|
||||
|
||||
|
||||
def test_every_host_atom_names_a_probe_the_host_reader_implements(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for atom_id, spec in caps.ATOMS.items():
|
||||
if spec["kind"] == "host":
|
||||
assert spec["probe"] in HOST_PROBE_NAMES, \
|
||||
"%s probes %r, which _host_flag does not answer (it would be false forever)" \
|
||||
% (atom_id, spec["probe"])
|
||||
|
||||
|
||||
def test_atom_ids_are_prefixed_with_their_kind(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for atom_id, spec in caps.ATOMS.items():
|
||||
if spec["kind"] == "python":
|
||||
continue
|
||||
assert atom_id.startswith(spec["kind"] + ":"), \
|
||||
"%s does not name its kind (%s)" % (atom_id, spec["kind"])
|
||||
|
||||
|
||||
def test_every_atom_answers_something_other_than_undefined(load_script):
|
||||
"""Each row is reachable through the probe: no row falls through to the undefined branch."""
|
||||
caps = load_script("capabilities.py")
|
||||
p = probe(caps)
|
||||
for atom_id in caps.ATOMS:
|
||||
result = p.atom(atom_id)
|
||||
assert isinstance(result.get("ok"), bool), "%s answered %r" % (atom_id, result)
|
||||
assert result["detail"] != "atom is not defined", \
|
||||
"%s has a row but the probe still calls it undefined" % atom_id
|
||||
|
||||
|
||||
def test_which_falls_back_to_an_extra_directory(load_script, tmp_path):
|
||||
"""A tool that is installed off PATH is not a missing tool, which is the whole point of the
|
||||
second search path. The file is never executed -- only its presence is checked."""
|
||||
caps = load_script("capabilities.py")
|
||||
tools = tmp_path / "unpacked"
|
||||
tools.mkdir()
|
||||
(tools / "ghidra_off_path_probe").write_text("", encoding="utf-8")
|
||||
p = caps.Probe(tool_dirs=[str(tools)])
|
||||
found, via = p.which("ghidra_off_path_probe")
|
||||
assert found == str(tools / "ghidra_off_path_probe")
|
||||
assert via == "EXEREV_TOOLS"
|
||||
assert p.which("no_such_tool_anywhere_xyz") == (None, None)
|
||||
|
||||
|
||||
def test_extra_tool_dirs_reads_both_environment_variable_names(load_script, monkeypatch, tmp_path):
|
||||
caps = load_script("capabilities.py")
|
||||
monkeypatch.setenv("EXEREV_TOOLS", str(tmp_path / "one"))
|
||||
monkeypatch.setenv("EXE_REVERSE_TOOLS", str(tmp_path / "two") + os.pathsep +
|
||||
str(tmp_path / "three"))
|
||||
assert caps.extra_tool_dirs() == [str(tmp_path / "one"), str(tmp_path / "two"),
|
||||
str(tmp_path / "three")]
|
||||
monkeypatch.delenv("EXEREV_TOOLS")
|
||||
monkeypatch.delenv("EXE_REVERSE_TOOLS")
|
||||
assert caps.extra_tool_dirs() == []
|
||||
|
||||
|
||||
# --- closure -----------------------------------------------------------------
|
||||
|
||||
def test_closure_of_a_capability_with_no_dependencies_is_its_only_member(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.closure("pe_structure_read") == ["pe_structure_read"]
|
||||
|
||||
|
||||
def test_closure_matches_an_independent_walk_over_the_real_table(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for capability_id in caps.CAPABILITIES:
|
||||
expected = reference_closure(caps, capability_id)
|
||||
assert caps.closure(capability_id) == expected, capability_id
|
||||
|
||||
|
||||
def test_closure_terminates_and_lists_each_capability_once(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
for capability_id in caps.CAPABILITIES:
|
||||
chain = caps.closure(capability_id)
|
||||
assert chain[0] == capability_id
|
||||
assert len(chain) == len(set(chain)), "%s repeats a member: %r" % (capability_id, chain)
|
||||
assert set(chain) <= set(caps.CAPABILITIES), \
|
||||
"%s reaches something that is not a capability: %r" % (capability_id, chain)
|
||||
|
||||
|
||||
def test_closure_follows_more_than_one_hop_and_deduplicates_a_diamond(load_script, monkeypatch):
|
||||
"""Synthetic: the real table is only one hop deep, so a diamond is built to reach the walk."""
|
||||
caps = load_script("capabilities.py")
|
||||
for name, deps in (("synthetic_top", ["synthetic_left", "synthetic_right"]),
|
||||
("synthetic_left", ["synthetic_bottom"]),
|
||||
("synthetic_right", ["synthetic_bottom"]),
|
||||
("synthetic_bottom", [])):
|
||||
monkeypatch.setitem(caps.CAPABILITIES, name,
|
||||
{"name": name, "required": ["python310"], "depends_on": deps})
|
||||
assert caps.closure("synthetic_top") == ["synthetic_top", "synthetic_left",
|
||||
"synthetic_right", "synthetic_bottom"]
|
||||
|
||||
|
||||
def test_closure_of_an_id_that_is_not_a_capability_is_the_id_alone(load_script):
|
||||
"""Documented behaviour: no error, no members. Pinned so a caller can see what it gets."""
|
||||
assert load_script("capabilities.py").closure("no_such_capability") == ["no_such_capability"]
|
||||
|
||||
|
||||
# --- resolve -----------------------------------------------------------------
|
||||
|
||||
def synthetic_capability(monkeypatch, caps, capability_id, **spec):
|
||||
body = {"name": "Synthetic " + capability_id, "required": ["python310"]}
|
||||
body.update(spec)
|
||||
monkeypatch.setitem(caps.CAPABILITIES, capability_id, body)
|
||||
|
||||
|
||||
def test_resolve_reports_blocked_and_names_the_missing_atom(load_script, monkeypatch):
|
||||
caps = load_script("capabilities.py")
|
||||
synthetic_capability(monkeypatch, caps, "synthetic_missing",
|
||||
required=["python310", "artifact:sample"])
|
||||
result = caps.resolve("synthetic_missing", probe(caps))
|
||||
assert result["state"] == "blocked"
|
||||
assert [atom for atom, _detail in result["missing"]] == ["artifact:sample"]
|
||||
|
||||
|
||||
def test_resolve_of_a_real_capability_names_the_artifact_it_cannot_probe(load_script):
|
||||
"""`artifact:sample` is operator-supplied by design, so this verdict is stable on any host."""
|
||||
caps = load_script("capabilities.py")
|
||||
result = caps.resolve("commandline_debug", probe(caps))
|
||||
assert result["state"] == "blocked"
|
||||
assert "artifact:sample" in [atom for atom, _detail in result["missing"]]
|
||||
|
||||
|
||||
def test_resolve_reports_a_blocked_dependency_by_name(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
result = caps.resolve("hardware_breakpoint", probe(caps))
|
||||
assert result["state"] == "blocked"
|
||||
assert "commandline_debug" in [dep for dep, _state in result["blocked_deps"]]
|
||||
|
||||
|
||||
def test_resolve_on_a_file_atom_follows_the_disk(load_script):
|
||||
"""A shipped script is present, and the verdict says which path was looked at."""
|
||||
caps = load_script("capabilities.py")
|
||||
result = caps.resolve("pe_structure_read", probe(caps))
|
||||
assert result["state"] == "ok"
|
||||
assert result["missing"] == []
|
||||
file_atom = probe(caps).atom("file:peutil.py")
|
||||
assert file_atom["ok"] is True
|
||||
assert file_atom["detail"] == os.path.join(caps.HERE, "peutil.py")
|
||||
|
||||
|
||||
def test_resolve_on_a_fabricated_file_atom_is_missing_and_names_its_path(load_script,
|
||||
monkeypatch):
|
||||
caps = load_script("capabilities.py")
|
||||
monkeypatch.setitem(caps.ATOMS, "file:no_such_script_xyz.py",
|
||||
{"name": "Fabricated", "kind": "file", "basis": "observed",
|
||||
"script": "no_such_script_xyz.py", "est_minutes": 0})
|
||||
synthetic_capability(monkeypatch, caps, "synthetic_file",
|
||||
required=["file:no_such_script_xyz.py"])
|
||||
result = caps.resolve("synthetic_file", probe(caps))
|
||||
assert result["state"] == "blocked"
|
||||
atom, detail = result["missing"][0]
|
||||
assert atom == "file:no_such_script_xyz.py"
|
||||
assert detail.endswith("no_such_script_xyz.py")
|
||||
|
||||
|
||||
def test_resolve_reports_the_alternatives_it_found(load_script, monkeypatch):
|
||||
caps = load_script("capabilities.py")
|
||||
synthetic_capability(monkeypatch, caps, "synthetic_any",
|
||||
required=[], any_of=["file:peutil.py", "artifact:sample"])
|
||||
result = caps.resolve("synthetic_any", probe(caps))
|
||||
assert result["alternatives_present"] == ["file:peutil.py"]
|
||||
assert result["state"] == "ok"
|
||||
assert result["missing"] == []
|
||||
|
||||
|
||||
def test_resolve_of_an_unknown_capability_is_none(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.resolve("no_such_capability", probe(caps)) is None
|
||||
|
||||
|
||||
def test_resolve_all_covers_every_capability_and_agrees_with_resolve(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
p = probe(caps)
|
||||
rows = caps.resolve_all(p)
|
||||
assert [row["id"] for row in rows] == list(caps.CAPABILITIES)
|
||||
for row in rows:
|
||||
assert row == caps.resolve(row["id"], p)
|
||||
|
||||
|
||||
def test_verdict_is_ok_only_when_every_row_is_ok(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.verdict([]) == ("ok", 0)
|
||||
assert caps.verdict([{"state": "ok"}, {"state": "ok"}]) == ("ok", 0)
|
||||
assert caps.verdict([{"state": "ok"}, {"state": "blocked"}]) == ("blocked", 3)
|
||||
|
||||
|
||||
def test_human_report_marks_every_row_and_explains_a_blocked_one(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
rows = [
|
||||
{"id": "ok_one", "name": "Okay", "state": "ok", "missing": [], "blocked_deps": []},
|
||||
{"id": "blocked_one", "name": "Blocked", "state": "blocked",
|
||||
"missing": [("artifact:sample", "operator supplied; not detectable from here")],
|
||||
"blocked_deps": [("other_cap", "blocked")]},
|
||||
]
|
||||
quiet = caps.human_report(rows, verbose=False)
|
||||
assert any("ok_one" in line and line.startswith(" ok ") for line in quiet)
|
||||
assert any("blocked_one" in line and line.startswith(" BLK") for line in quiet)
|
||||
loud = caps.human_report(rows, verbose=True)
|
||||
assert any("missing atom : artifact:sample" in line for line in loud)
|
||||
assert any("blocked by : other_cap (blocked)" in line for line in loud)
|
||||
|
||||
|
||||
# --- declaration parsing -----------------------------------------------------
|
||||
|
||||
def write_script(tmp_path, name, body):
|
||||
path = tmp_path / name
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_declared_capability_reads_a_single_id(load_script, tmp_path):
|
||||
caps = load_script("capabilities.py")
|
||||
path = write_script(tmp_path, "one.py",
|
||||
'"""capability: byte_patch\n\nA script that declares its capability.\n"""\n')
|
||||
assert caps.declared_capability(str(path)) == ["byte_patch"]
|
||||
|
||||
|
||||
def test_declared_capability_reads_a_comma_separated_list(load_script, tmp_path):
|
||||
caps = load_script("capabilities.py")
|
||||
path = write_script(tmp_path, "two.py",
|
||||
'"""capability: byte_patch, pe_structure_read\n\nTwo of them.\n"""\n')
|
||||
assert caps.declared_capability(str(path)) == ["byte_patch", "pe_structure_read"]
|
||||
|
||||
|
||||
def test_declared_capability_rejects_the_documentation_template(load_script, tmp_path):
|
||||
"""The exact line this registry's own docstring uses to describe the convention.
|
||||
|
||||
It was once read as a declaration, which reported the registry as declaring a capability named
|
||||
`<capability_id>`. A template is not a declaration, and the pin is on that exact string.
|
||||
"""
|
||||
caps = load_script("capabilities.py")
|
||||
template = " capability: <capability_id>[, <capability_id>]"
|
||||
path = write_script(tmp_path, "template.py", '"""%s\n\nProse.\n"""\n' % template)
|
||||
assert caps.declared_capability(str(path)) == []
|
||||
|
||||
|
||||
def test_the_template_really_does_live_in_the_registrys_own_docstring(load_script):
|
||||
"""The pin above is only meaningful if the string it names is really in this file."""
|
||||
caps = load_script("capabilities.py")
|
||||
source = open(os.path.join(caps.HERE, "capabilities.py"), encoding="utf-8").read()
|
||||
assert "capability: <capability_id>[, <capability_id>]" in source
|
||||
assert caps.declared_capability(os.path.join(caps.HERE, "capabilities.py")) == []
|
||||
|
||||
|
||||
def test_declared_capability_reads_a_real_declaring_script(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.declared_capability(os.path.join(caps.HERE, "dart_snapshot_strings.py")) == \
|
||||
["static_disassembly"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,body,expected", [
|
||||
("prose.py", '"""No declaration here, only prose.\n\nMore prose.\n"""\n', []),
|
||||
("no-docstring.py", "import os\n", []),
|
||||
("template-only.py", '"""\ncapability: <capability_id>\n"""\n', []),
|
||||
("not-a-list.py", '"""capability: one two\n"""\n', []),
|
||||
("trailing-junk.py", '"""capability: one # comment\n"""\n', []),
|
||||
("hyphenated-id.py", '"""capability: byte-patch\n"""\n', []),
|
||||
("space-before-colon.py", '"""capability : byte_patch\n"""\n', []),
|
||||
("list-with-a-hyphen.py", '"""capability: byte_patch, pe-structure\n"""\n', []),
|
||||
("trailing-space-and-comma.py", '"""capability: byte_patch,\n"""\n', []),
|
||||
])
|
||||
def test_declared_capability_refuses_a_shape_that_is_not_a_declaration(load_script, tmp_path,
|
||||
name, body, expected):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.declared_capability(str(write_script(tmp_path, name, body))) == expected
|
||||
|
||||
|
||||
def test_declared_capability_matches_case_insensitively(load_script, tmp_path):
|
||||
"""Pinned, because it is wider than the ids it can name: the pattern is compiled `(?i)`, so a
|
||||
mixed-case declaration is returned verbatim and then resolves to nothing -- every id in both
|
||||
tables is lowercase. Recorded as an observation rather than asserted as desirable."""
|
||||
caps = load_script("capabilities.py")
|
||||
path = write_script(tmp_path, "mixed-case.py", '"""capability: BytePatch\n"""\n')
|
||||
assert caps.declared_capability(str(path)) == ["BytePatch"]
|
||||
assert "BytePatch" not in caps.CAPABILITIES
|
||||
|
||||
|
||||
def test_declared_capability_is_silent_about_a_file_it_cannot_read(load_script, tmp_path):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.declared_capability(str(tmp_path / "absent.py")) == []
|
||||
broken = write_script(tmp_path, "broken.py", '"""capability: byte_patch\n"""\nif True print)\n')
|
||||
assert caps.declared_capability(str(broken)) == []
|
||||
|
||||
|
||||
# --- script -> capability ----------------------------------------------------
|
||||
|
||||
def test_capabilities_for_script_reports_a_declaration(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
path = os.path.join(caps.HERE, "dart_snapshot_strings.py")
|
||||
assert caps.capabilities_for_script("dart_snapshot_strings.py", path) == \
|
||||
(["static_disassembly"], "declared")
|
||||
|
||||
|
||||
def test_capabilities_for_script_falls_back_to_the_map(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
assert caps.capabilities_for_script("aob_scan.py") == (["aob_locate"], "mapped")
|
||||
|
||||
|
||||
def test_capabilities_for_script_prefers_the_declaration_over_the_map(load_script, tmp_path):
|
||||
"""A script may not be silently reclassified by the map once it declares its own capability."""
|
||||
caps = load_script("capabilities.py")
|
||||
path = write_script(tmp_path, "aob_scan.py",
|
||||
'"""capability: pe_structure_read\n\nDeclares what it is.\n"""\n')
|
||||
assert caps.capabilities_for_script("aob_scan.py", str(path)) == \
|
||||
(["pe_structure_read"], "declared")
|
||||
|
||||
|
||||
def test_a_script_in_neither_table_is_unknown_rather_than_empty_and_claimed(load_script, tmp_path):
|
||||
caps = load_script("capabilities.py")
|
||||
path = write_script(tmp_path, "stranger.py", '"""Nothing is claimed here.\n"""\n')
|
||||
ids, source = caps.capabilities_for_script("stranger.py", str(path))
|
||||
assert (ids, source) == ([], "unknown")
|
||||
assert source != "mapped", "an unknown script must not be reported as mapped to nothing"
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/dart_snapshot_strings.py, the Dart AOT string reader.
|
||||
|
||||
The input is an ELF shared object and no sample may be committed, so the container is generated by
|
||||
`conftest.build_synthetic_elf`: a valid ELF64 header, a `.text` of sixteen bytes, a `.rodata` with
|
||||
one one-byte (ASCII) literal and one two-byte (UTF-16LE) literal at known offsets, and a
|
||||
section-name string table. Offsets are read from the fixture's own record of where it put each
|
||||
literal, never hardcoded here.
|
||||
|
||||
What the synthetic image cannot do is be a real snapshot. Literal recovery is what these tests hold:
|
||||
a one-byte and a two-byte literal at known offsets, a case-insensitive filter, and an offset probe
|
||||
that resolves anywhere a literal occupies. The instruction-level observations this file once carried
|
||||
are **withdrawn** -- a byte scan cannot read this format, and the reference records why -- so nothing
|
||||
here asserts anything about how a reference is encoded.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
ONE_BYTE = "one-byte"
|
||||
TWO_BYTE = "two-byte"
|
||||
|
||||
# The two idioms the observer counts, assembled here so the positive cases are built rather than
|
||||
# asserted from a table. `49 8B /r` is a load through r15 -- Dart's pool pointer; `48 8B 05` is a
|
||||
# RIP-relative load, which the docstring records as near-absent in a real image.
|
||||
POOL_LOAD = bytes((0x49, 0x8B, 0x87, 0x10, 0x00, 0x00, 0x00))
|
||||
FILLER = bytes((0x90,)) * 8
|
||||
|
||||
|
||||
def text_section(data, vaddr=0x1000, offset=0):
|
||||
return {"offset": offset, "vaddr": vaddr, "size": len(data)}
|
||||
|
||||
|
||||
def hex_bytes(*values):
|
||||
return bytes(values)
|
||||
|
||||
|
||||
# --- the container -----------------------------------------------------------
|
||||
|
||||
def test_the_fixture_is_an_elf_shared_object_the_reader_accepts(load_script, synthetic_elf):
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
elf = dss.Elf(synthetic_elf.data, str(synthetic_elf.path))
|
||||
assert elf.machine == 0x3E
|
||||
assert elf.etype == 3
|
||||
described = elf.describe()
|
||||
assert described["machine"] == "x86-64"
|
||||
assert described["type"] == "shared-object"
|
||||
assert [s["name"] for s in described["sections"]] == [".text", ".rodata", ".shstrtab"]
|
||||
|
||||
|
||||
def test_the_fixture_is_the_size_the_builder_promised(synthetic_elf):
|
||||
assert len(synthetic_elf.data) == synthetic_elf.size
|
||||
assert synthetic_elf.size < 0x1000, "a virtual address above this cannot be a file offset"
|
||||
|
||||
|
||||
def test_sections_are_found_by_name(load_script, synthetic_elf):
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
elf = dss.Elf(synthetic_elf.data)
|
||||
text = elf.section(".text")
|
||||
rodata = elf.section(".rodata")
|
||||
assert text["offset"] == synthetic_elf.text["offset"]
|
||||
assert text["vaddr"] == synthetic_elf.text["vaddr"]
|
||||
assert text["size"] == synthetic_elf.text["size"]
|
||||
assert rodata["offset"] == synthetic_elf.rodata["offset"]
|
||||
assert rodata["vaddr"] == synthetic_elf.rodata["vaddr"]
|
||||
assert rodata["size"] == synthetic_elf.rodata["size"]
|
||||
assert elf.section(".absent") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mutation,message", [
|
||||
(lambda d: bytearray(b"not an elf image at all" + bytes(64)), "not an ELF image"),
|
||||
(lambda d: bytearray(b"\x7fELF" + d[4:]), None), # a valid image
|
||||
(lambda d: bytearray(d[:4] + bytes((1,)) + d[5:]), "not a 64-bit ELF"),
|
||||
(lambda d: bytearray(d[:5] + bytes((2,)) + d[6:]), "not little-endian"),
|
||||
(lambda d: bytearray(d[:0x30]), "not an ELF image"), # truncated header
|
||||
])
|
||||
def test_the_reader_refuses_a_container_it_cannot_read(load_script, synthetic_elf, mutation,
|
||||
message):
|
||||
"""A refusal is a ValueError with a reason; the CLI turns it into the documented exit code."""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
data = mutation(bytearray(synthetic_elf.data))
|
||||
if message is None:
|
||||
assert dss.Elf(bytes(data)).etype == 3
|
||||
return
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
dss.Elf(bytes(data))
|
||||
assert message in str(excinfo.value)
|
||||
|
||||
|
||||
def test_the_address_maps_round_trip(load_script, synthetic_elf):
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
elf = dss.Elf(synthetic_elf.data)
|
||||
for entry in synthetic_elf.literals:
|
||||
assert elf.vaddr_to_offset(entry["vaddr"]) == entry["offset"]
|
||||
assert elf.offset_to_vaddr(entry["offset"]) == entry["vaddr"]
|
||||
assert elf.vaddr_to_offset(0xDEADBEEF) is None
|
||||
assert elf.offset_to_vaddr(0xFFFFFF) is None
|
||||
|
||||
|
||||
# --- literal recovery --------------------------------------------------------
|
||||
|
||||
def test_every_literal_in_the_fixture_is_recovered_at_the_offset_it_was_placed(
|
||||
load_script, synthetic_elf):
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
elf = dss.Elf(synthetic_elf.data)
|
||||
recovered = dss.recover_strings(synthetic_elf.data, elf.section(".rodata"))
|
||||
assert [(off, va, text, enc) for off, va, text, enc in recovered] == [
|
||||
(entry["offset"], entry["vaddr"], entry["text"], entry["encoding"])
|
||||
for entry in synthetic_elf.literals]
|
||||
|
||||
|
||||
def test_both_encodings_are_recovered(load_script, synthetic_elf):
|
||||
"""A keyword search that only reads ASCII misses half of a localised application's literals."""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
elf = dss.Elf(synthetic_elf.data)
|
||||
recovered = dss.recover_strings(synthetic_elf.data, elf.section(".rodata"))
|
||||
by_encoding = {}
|
||||
for _off, _va, text, encoding in recovered:
|
||||
by_encoding.setdefault(encoding, set()).add(text)
|
||||
assert by_encoding[ONE_BYTE] == {"premium_license", "trial_mode"}
|
||||
assert by_encoding[TWO_BYTE] == {"vip_pass"}
|
||||
|
||||
|
||||
def test_a_two_byte_literal_reports_byte_offsets_and_character_counts(load_script, synthetic_elf):
|
||||
"""Pinned, because the two units differ by a factor of two for this encoding.
|
||||
|
||||
The offsets are bytes; the text is characters. `--at` filters on `offset <= at < offset +
|
||||
len(text)`, so for a two-byte literal it accepts only the first half of the bytes the literal
|
||||
occupies. Measured here as a unit fact; the CLI layer pins the effect.
|
||||
"""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
entry = synthetic_elf.literal("vip_pass")
|
||||
recovered = dss.recover_strings(synthetic_elf.data,
|
||||
dss.Elf(synthetic_elf.data).section(".rodata"))
|
||||
offset, vaddr, text, encoding = [row for row in recovered if row[0] == entry["offset"]][0]
|
||||
assert (offset, vaddr, encoding) == (entry["offset"], entry["vaddr"], TWO_BYTE)
|
||||
assert text == "vip_pass"
|
||||
assert len(text) * 2 == 16, "sixteen bytes on disk, eight characters reported"
|
||||
|
||||
|
||||
def test_a_two_byte_run_does_not_glue_the_tail_of_one_literal_to_the_next(load_script):
|
||||
"""A run is reported from its own first byte, never from the tail of the literal before it.
|
||||
|
||||
This used to be a false positive. The scan accepted *any* printable byte followed by a NUL --
|
||||
which is exactly the shape of the last character of an ASCII literal plus its alignment byte --
|
||||
so a two-byte literal beginning right after one was reported as a literal that does not exist,
|
||||
at an offset inside a different one. Both directions are asserted here, because the cheap fix
|
||||
for the glue error (skip anything preceded by a character-shaped pair) reintroduces the opposite
|
||||
one: the real run then starts with its own bytes looking like an interior position.
|
||||
"""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
ascii_part = b"odd_phase_literal\x00"
|
||||
assert len(ascii_part) % 2 == 0, "the two-byte run needs to start at an even offset"
|
||||
data = ascii_part + "vip_pass".encode("utf-16-le") + b"\x00\x00"
|
||||
recovered = dss.recover_strings(data, {"offset": 0, "vaddr": 0x1000, "size": len(data)})
|
||||
texts = [row[2] for row in recovered]
|
||||
assert "odd_phase_literal" in texts
|
||||
assert "vip_pass" in texts, texts
|
||||
assert "lvip_pass" not in texts, "the tail of the ASCII literal was glued onto the next run"
|
||||
assert texts.count("vip_pass") == 1
|
||||
|
||||
|
||||
def test_a_two_byte_literal_landing_at_an_odd_offset_is_recovered(load_script):
|
||||
"""A two-byte run at an odd offset is found, and the run before it does not manufacture one.
|
||||
|
||||
Two errors lived here. The scan stepped through the two-byte form from the section base, so a
|
||||
run shifted to an odd offset -- which one odd-length literal is enough to do -- was never
|
||||
examined and was missed whole. Advancing one byte at a time fixes that, and the second half of
|
||||
this test guards the error it introduces: the tail of an even-length one-byte literal is a
|
||||
printable byte followed by alignment, which reads as the start of a two-byte run and yields a
|
||||
literal that does not exist.
|
||||
"""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
ascii_part = b"even_phase_liter\x00"
|
||||
assert len(ascii_part) % 2 == 1, "the two-byte run needs to start at an odd offset"
|
||||
data = ascii_part + "vip_pass".encode("utf-16-le") + b"\x00\x00"
|
||||
recovered = dss.recover_strings(data, {"offset": 0, "vaddr": 0x1000, "size": len(data)})
|
||||
texts = [row[2] for row in recovered]
|
||||
assert "vip_pass" in texts, texts
|
||||
assert "even_phase_liter" in texts, texts
|
||||
|
||||
# No phantom literal: a run is reported once, from its own first byte.
|
||||
assert texts.count("vip_pass") == 1
|
||||
two_byte = [row for row in recovered if row[3] == "two-byte"]
|
||||
assert len(two_byte) == 1, two_byte
|
||||
|
||||
# An even-length one-byte literal before a two-byte run must not start a phantom run.
|
||||
data2 = b"even_phase_lite\x00" + "vip_pass".encode("utf-16-le") + b"\x00\x00"
|
||||
got2 = [row[2] for row in dss.recover_strings(
|
||||
data2, {"offset": 0, "vaddr": 0x1000, "size": len(data2)})]
|
||||
assert "vip_pass" in got2, got2
|
||||
assert "vip_pass"[0] not in [t for t in got2 if t != "vip_pass"], got2
|
||||
|
||||
|
||||
def test_min_length_drops_the_shorter_literals(load_script, synthetic_elf):
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
rodata = dss.Elf(synthetic_elf.data).section(".rodata")
|
||||
long_only = dss.recover_strings(synthetic_elf.data, rodata, min_length=12)
|
||||
assert [row[2] for row in long_only] == ["premium_license"]
|
||||
everything = dss.recover_strings(synthetic_elf.data, rodata, min_length=6)
|
||||
assert {row[2] for row in everything} == {"premium_license", "trial_mode", "vip_pass"}
|
||||
|
||||
|
||||
def test_the_limit_caps_how_many_literals_come_back(load_script, synthetic_elf):
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
rodata = dss.Elf(synthetic_elf.data).section(".rodata")
|
||||
assert len(dss.recover_strings(synthetic_elf.data, rodata, limit=1)) == 1
|
||||
assert len(dss.recover_strings(synthetic_elf.data, rodata, limit=2)) == 2
|
||||
|
||||
|
||||
def test_a_section_claiming_more_bytes_than_the_file_holds_is_clamped(load_script, synthetic_elf):
|
||||
"""A truncated image must not read past the end; the reader clamps to the data it has."""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
truncated = synthetic_elf.data[:synthetic_elf.rodata["offset"] + 0x10]
|
||||
rodata = dict(synthetic_elf.rodata, size=synthetic_elf.rodata["size"] + 0x1000)
|
||||
recovered = dss.recover_strings(truncated, rodata)
|
||||
assert [row[2] for row in recovered] == ["premium_license"]
|
||||
|
||||
|
||||
def test_the_code_section_yields_no_literals(load_script, synthetic_elf):
|
||||
"""The fixture's `.text` is sixteen bytes of instructions, so nothing survives the length gate."""
|
||||
dss = load_script("dart_snapshot_strings.py")
|
||||
text = dss.Elf(synthetic_elf.data).section(".text")
|
||||
assert dss.recover_strings(synthetic_elf.data, text) == []
|
||||
|
||||
|
||||
# --- pool observation --------------------------------------------------------
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/doctor.py, the environment doctor.
|
||||
|
||||
Three properties of the doctor are what make its report a measurement, and each gets a test rather
|
||||
than a description:
|
||||
|
||||
* its per-script dependency pass is static, so a file whose *execution* would raise is inspected
|
||||
without ever being imported -- proved with a script that writes a sentinel and imports a module
|
||||
that does not exist;
|
||||
* `discover_scripts` scans the directory instead of holding a list, proved by pointing the doctor at
|
||||
a synthetic directory and checking that what it reports is what is there;
|
||||
* a script with no capability is reported `unknown` and *counted*, because a denominator that
|
||||
shrinks quietly is how a report stops being a measurement.
|
||||
|
||||
The host facts come from a real probe, so a host where the probe cannot answer skips the fact tests
|
||||
with a printed reason instead of failing them.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def write(root, name, body):
|
||||
path = root / name
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def synthetic_scripts_dir(tmp_path, sentinel=None):
|
||||
"""A directory of the six shapes `scan_scripts` has to classify."""
|
||||
root = tmp_path / "scriptdir"
|
||||
root.mkdir()
|
||||
write(root, "declaring.py",
|
||||
'"""capability: pe_structure_read\n\nDeclares its own capability.\n"""\n'
|
||||
"import json\n\nprint('usage: declaring')\n")
|
||||
write(root, "aob_scan.py",
|
||||
'"""A script the map knows and that declares nothing.\n"""\n'
|
||||
"import os\n\nprint('usage: aob_scan')\n")
|
||||
write(root, "needs-tool.py",
|
||||
'"""Imports a module that is not installed, safely.\n"""\n'
|
||||
"try:\n import zebra_kit\nexcept ImportError:\n zebra_kit = None\n\n"
|
||||
"print('usage: needs-tool')\n")
|
||||
write(root, "broken.py",
|
||||
'"""Raises as soon as it is executed.\n"""\n'
|
||||
"raise RuntimeError('this script cannot start')\n")
|
||||
write(root, "stranger.py",
|
||||
'"""Neither declares nor is mapped.\n"""\n'
|
||||
"import sibling_helper\n\nprint('usage: stranger')\n")
|
||||
write(root, "helper.js", "// not a python script\n")
|
||||
write(root, "sibling_helper.py",
|
||||
'"""A file this directory ships, so it is not a third-party dependency.\n"""\n')
|
||||
if sentinel is not None:
|
||||
write(root, "sentinel_target.py",
|
||||
'"""Would write a sentinel the moment it were executed.\n"""\n'
|
||||
"import pathlib\n\n"
|
||||
"pathlib.Path(%r).write_text('executed')\n"
|
||||
"import zebra_kit_that_does_not_exist\n" % str(sentinel))
|
||||
return root
|
||||
|
||||
|
||||
# --- static dependency pass --------------------------------------------------
|
||||
|
||||
def test_static_deps_separates_stdlib_siblings_and_third_party(load_script, tmp_path, monkeypatch):
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
assert doctor.static_python_deps(str(root / "declaring.py")) == (set(), None)
|
||||
assert doctor.static_python_deps(str(root / "aob_scan.py")) == (set(), None)
|
||||
assert doctor.static_python_deps(str(root / "stranger.py")) == (set(), None)
|
||||
deps, reason = doctor.static_python_deps(str(root / "needs-tool.py"))
|
||||
assert (deps, reason) == ({"zebra_kit"}, None)
|
||||
|
||||
|
||||
def test_static_deps_sees_an_import_inside_a_try_block(load_script, tmp_path, monkeypatch):
|
||||
"""The pass reads the AST, so an import that is guarded at run time is still a dependency."""
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
deps, _reason = doctor.static_python_deps(str(root / "needs-tool.py"))
|
||||
assert deps == {"zebra_kit"}
|
||||
|
||||
|
||||
def test_static_deps_does_not_import_or_execute_the_file_it_inspects(load_script, tmp_path,
|
||||
monkeypatch):
|
||||
"""The sentinel file is the proof: if the file were imported, it would exist -- or the import of
|
||||
the deliberately missing module would raise out of `static_python_deps` entirely."""
|
||||
doctor = load_script("doctor.py")
|
||||
sentinel = tmp_path / "sentinel.txt"
|
||||
root = synthetic_scripts_dir(tmp_path, sentinel=sentinel)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
deps, reason = doctor.static_python_deps(str(root / "sentinel_target.py"))
|
||||
assert deps == {"zebra_kit_that_does_not_exist"}
|
||||
assert reason is None
|
||||
assert not sentinel.exists(), "the inspected script was executed"
|
||||
assert "zebra_kit_that_does_not_exist" not in sys.modules
|
||||
|
||||
|
||||
def test_static_deps_reports_a_syntax_error_instead_of_raising(load_script, tmp_path):
|
||||
doctor = load_script("doctor.py")
|
||||
write(tmp_path, "broken.py", "def f(:\n")
|
||||
deps, reason = doctor.static_python_deps(str(tmp_path / "broken.py"))
|
||||
assert deps == set()
|
||||
assert reason
|
||||
|
||||
|
||||
def test_static_deps_reports_an_unreadable_path_instead_of_raising(load_script, tmp_path):
|
||||
doctor = load_script("doctor.py")
|
||||
deps, reason = doctor.static_python_deps(str(tmp_path / "absent.py"))
|
||||
assert deps == set()
|
||||
assert "absent.py" in reason
|
||||
|
||||
|
||||
def test_every_shipped_script_is_standard_library_only(load_script):
|
||||
"""The claim the skill's own README makes, measured on the scripts in the tree."""
|
||||
doctor = load_script("doctor.py")
|
||||
for name in doctor.discover_scripts():
|
||||
if not name.endswith(".py"):
|
||||
continue
|
||||
deps, reason = doctor.static_python_deps(os.path.join(doctor.HERE, name))
|
||||
assert reason is None, "%s could not be read: %s" % (name, reason)
|
||||
assert deps == set(), "%s needs %r, which the kit does not ship" % (name, sorted(deps))
|
||||
|
||||
|
||||
# --- discovery ---------------------------------------------------------------
|
||||
|
||||
def test_discover_scripts_reports_whether_files_are_really_there(load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
present = sorted(n for n in os.listdir(doctor.HERE) if n.endswith((".py", ".js")))
|
||||
assert doctor.discover_scripts() == present
|
||||
for name in ("capabilities.py", "doctor.py", "dart_snapshot_strings.py"):
|
||||
assert name in present
|
||||
|
||||
|
||||
def test_discover_scripts_scans_a_directory_rather_than_a_fixed_list(load_script, tmp_path,
|
||||
monkeypatch):
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
found = doctor.discover_scripts()
|
||||
assert found == sorted(n for n in os.listdir(str(root)) if n.endswith((".py", ".js")))
|
||||
assert set(found) >= {"declaring.py", "aob_scan.py", "needs-tool.py", "broken.py",
|
||||
"stranger.py", "helper.js", "sibling_helper.py"}
|
||||
assert "notes.txt" not in found
|
||||
write(root, "late_arrival.py", '"""Added after the scan.\n"""\n')
|
||||
assert "late_arrival.py" in doctor.discover_scripts()
|
||||
|
||||
|
||||
def test_discover_scripts_ignores_files_that_are_not_scripts(load_script, tmp_path, monkeypatch):
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
write(root, "notes.txt", "not a script\n")
|
||||
write(root, "Makefile", "all:\n\ttrue\n")
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
found = doctor.discover_scripts()
|
||||
assert "notes.txt" not in found
|
||||
assert "Makefile" not in found
|
||||
|
||||
|
||||
# --- per-script table --------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def capability_results(load_script):
|
||||
caps = load_script("capabilities.py")
|
||||
return caps.resolve_all(caps.Probe(tool_dirs=[]))
|
||||
|
||||
|
||||
def test_scan_scripts_classifies_every_script_it_finds(load_script, tmp_path, monkeypatch,
|
||||
capability_results):
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
rows = doctor.scan_scripts(capability_results)
|
||||
by_name = {row["script"]: row for row in rows}
|
||||
|
||||
assert len(rows) == len(doctor.discover_scripts()) == 7
|
||||
assert by_name["declaring.py"]["capability_source"] == "declared"
|
||||
assert by_name["declaring.py"]["capabilities"] == ["pe_structure_read"]
|
||||
assert by_name["aob_scan.py"]["capability_source"] == "mapped"
|
||||
assert by_name["aob_scan.py"]["capabilities"] == ["aob_locate"]
|
||||
assert by_name["needs-tool.py"]["capability_source"] == "unknown"
|
||||
assert by_name["needs-tool.py"]["third_party"] == ["zebra_kit"]
|
||||
assert by_name["helper.js"]["runnable"] is True
|
||||
assert "not a python script" in by_name["helper.js"]["detail"]
|
||||
|
||||
|
||||
def test_a_script_with_no_capability_is_unknown_counted_and_never_dropped(
|
||||
load_script, tmp_path, monkeypatch, capability_results):
|
||||
"""The denominator is the number of scripts in the directory, unknown ones included."""
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
rows = doctor.scan_scripts(capability_results)
|
||||
|
||||
unknown = [row for row in rows if row["capability_source"] == "unknown"]
|
||||
assert len(rows) == len(doctor.discover_scripts())
|
||||
assert {row["script"] for row in unknown} == {"needs-tool.py", "broken.py", "stranger.py",
|
||||
"helper.js", "sibling_helper.py"}
|
||||
assert len(unknown) == 5, "the unknown rows stay in the table; only two scripts claim one"
|
||||
assert {row["script"] for row in rows if row["capability_source"] != "unknown"} == \
|
||||
{"declaring.py", "aob_scan.py"}
|
||||
for row in unknown:
|
||||
assert row["capabilities"] == []
|
||||
assert row["capability_state"] == "unknown"
|
||||
assert row["script"] in {r["script"] for r in rows}
|
||||
|
||||
|
||||
def test_scan_scripts_marks_a_script_that_cannot_start(load_script, tmp_path, monkeypatch,
|
||||
capability_results):
|
||||
doctor = load_script("doctor.py")
|
||||
root = synthetic_scripts_dir(tmp_path)
|
||||
monkeypatch.setattr(doctor, "HERE", str(root))
|
||||
row = {r["script"]: r for r in doctor.scan_scripts(capability_results)}["broken.py"]
|
||||
assert row["runnable"] is False
|
||||
assert "RuntimeError" in row["detail"]
|
||||
assert "Traceback" not in row["detail"]
|
||||
|
||||
|
||||
def test_scan_scripts_agrees_with_the_registry_about_every_real_script(load_script,
|
||||
capability_results):
|
||||
"""On the shipped directory, every script is classified and the states come from the registry."""
|
||||
doctor = load_script("doctor.py")
|
||||
caps = load_script("capabilities.py")
|
||||
rows = doctor.scan_scripts(capability_results)
|
||||
assert [row["script"] for row in rows] == doctor.discover_scripts()
|
||||
states = {result["id"]: result["state"] for result in capability_results}
|
||||
for row in rows:
|
||||
declared, source = caps.capabilities_for_script(row["script"],
|
||||
os.path.join(doctor.HERE, row["script"]))
|
||||
assert row["capabilities"] == declared
|
||||
assert row["capability_source"] == source
|
||||
if not declared:
|
||||
assert row["capability_state"] == "unknown"
|
||||
continue
|
||||
assert row["capability_state"] == ("ok" if all(states[c] == "ok" for c in declared)
|
||||
else "blocked")
|
||||
|
||||
|
||||
# --- environment facts -------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def host_facts(load_script):
|
||||
"""One environment report for the module: it runs a probe and shells out to logman."""
|
||||
doctor = load_script("doctor.py")
|
||||
report = doctor.environment_report()
|
||||
if len(report) == 1 and report[0].get("fact") == "host probe":
|
||||
pytest.skip("the host probe could not answer here (%s), so the fact table is inert"
|
||||
% report[0].get("effect"))
|
||||
return report
|
||||
|
||||
|
||||
def test_environment_report_is_a_list_of_facts_with_the_documented_keys(host_facts):
|
||||
assert isinstance(host_facts, list) and host_facts
|
||||
for fact in host_facts:
|
||||
assert set(fact) == {"fact", "value", "effect", "severity"}, sorted(fact)
|
||||
assert fact["fact"] and fact["value"] is not None and fact["effect"]
|
||||
|
||||
|
||||
def test_every_environment_fact_is_labelled_note_or_warn(host_facts):
|
||||
"""The label is the whole point: `warn` means it can change a result, and the doctor's exit
|
||||
token turns on that count."""
|
||||
for fact in host_facts:
|
||||
assert fact["severity"] in ("note", "warn"), fact
|
||||
|
||||
|
||||
def test_environment_report_always_states_the_working_directory(host_facts):
|
||||
"""Unconditional, so the fact table can never be empty on a host where the probe answers."""
|
||||
facts = {fact["fact"]: fact for fact in host_facts}
|
||||
assert "working directory" in facts
|
||||
assert facts["working directory"]["value"] == os.getcwd()
|
||||
assert facts["working directory"]["severity"] == "note"
|
||||
|
||||
|
||||
def test_severity_summary_counts_only_warnings(load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
assert doctor._severity_summary([]) == 0
|
||||
assert doctor._severity_summary([{"severity": "note"}, {"severity": "note"}]) == 0
|
||||
assert doctor._severity_summary([{"severity": "note"}, {"severity": "warn"},
|
||||
{"severity": "warn"}]) == 2
|
||||
|
||||
|
||||
# --- primitives --------------------------------------------------------------
|
||||
|
||||
def test_run_reports_a_failure_instead_of_raising(load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
code, output = doctor.run(["definitely-not-a-real-command-xyz"])
|
||||
assert code == -1
|
||||
assert output
|
||||
|
||||
|
||||
def test_run_returns_the_code_and_combined_output(load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
code, output = doctor.run([sys.executable, "-B", "-c", "print('probe')"])
|
||||
assert code == 0
|
||||
assert "probe" in output
|
||||
|
||||
|
||||
def test_the_documented_exit_codes_and_tokens_are_the_ones_used(load_script):
|
||||
doctor = load_script("doctor.py")
|
||||
assert (doctor.EXIT_OK, doctor.EXIT_USE, doctor.EXIT_ENV, doctor.EXIT_INTERNAL) == (0, 2, 3, 4)
|
||||
assert doctor.TOKENS["ok"] == "env_ok"
|
||||
assert doctor.TOKENS["partial"] == "env_partial"
|
||||
assert doctor.TOKENS["blocked"] == "env_blocked"
|
||||
assert doctor.TOKENS["use"] == "usage_error"
|
||||
assert doctor.TOKENS["internal"] == "internal_error"
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/fold.py, the bounded summariser.
|
||||
|
||||
What these tests hold is the promise the tool makes about itself: the summary is bounded, it says
|
||||
what it withheld, and the numbers it prints are true of the file's actual shape.
|
||||
|
||||
The shape tests exist because the first version read a pretty-printed 32 MB JSON document line by
|
||||
line -- its shape decision was the first byte plus a size cutoff, and both a document and a record
|
||||
file begin with `{` -- and summarised it as 16,278 records with 1.7 million unparseable lines. Every
|
||||
count in that summary was honestly computed and the summary described a file that did not exist.
|
||||
The depth scan that replaced it is what the first three tests hold, including the case that breaks
|
||||
naive counters: braces and escaped quotes inside string values.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def run_fold(load_script, monkeypatch, capsys, tmp_path, name, content, extra=()):
|
||||
"""Write the input, run main() as if from the command line, and return (code, stdout)."""
|
||||
path = tmp_path / name
|
||||
if isinstance(content, bytes):
|
||||
path.write_bytes(content)
|
||||
else:
|
||||
path.write_text(content, encoding="utf-8")
|
||||
fold = load_script("fold.py")
|
||||
monkeypatch.setattr(sys, "argv", ["fold.py", str(path), *extra])
|
||||
code = fold.main()
|
||||
out = capsys.readouterr().out
|
||||
return code, out
|
||||
|
||||
|
||||
# --- the shape is decided by the bytes, not the name or the size ----------------
|
||||
|
||||
|
||||
def test_two_records_on_two_lines_are_records(load_script, monkeypatch, capsys, tmp_path):
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "a.jsonl",
|
||||
'{"a": 1}\n{"a": 2}\n')
|
||||
assert code == 0
|
||||
assert "records : 2" in out
|
||||
assert "RESULT=fold_ok" in out
|
||||
|
||||
|
||||
def test_a_pretty_printed_document_is_one_value_not_many(load_script, monkeypatch, capsys,
|
||||
tmp_path):
|
||||
# The string value carries braces, brackets and escaped quotes of its own: a naive depth count
|
||||
# is fooled by exactly this, the scanner must not be.
|
||||
doc = {"outer": {"s": 'a "{[quoted]}" body', "n": 3}, "arr": [1, 2, 3]}
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "graph.json",
|
||||
json.dumps(doc, indent=2))
|
||||
assert code == 0
|
||||
assert "one JSON document" in out
|
||||
assert "records" not in out
|
||||
|
||||
|
||||
def test_content_after_the_first_value_closes_means_records(load_script, monkeypatch, capsys,
|
||||
tmp_path):
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "b.jsonl",
|
||||
'{"a": 1}\n{"a": 2}\n{"a": 3}\n')
|
||||
assert code == 0
|
||||
assert "records : 3" in out
|
||||
|
||||
|
||||
def test_a_single_document_past_the_walk_limit_is_left_unwalked(load_script, monkeypatch, capsys,
|
||||
tmp_path):
|
||||
fold = load_script("fold.py")
|
||||
path = tmp_path / "huge.json"
|
||||
path.write_text('{"a": "%s"}' % ("x" * 40), encoding="utf-8")
|
||||
monkeypatch.setattr(fold, "JSON_WALK_LIMIT", 8)
|
||||
monkeypatch.setattr(sys, "argv", ["fold.py", str(path)])
|
||||
assert fold.main() == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "was not loaded" in out
|
||||
assert "RESULT=fold_ok" in out
|
||||
|
||||
|
||||
# --- the summary admits its bounds ----------------------------------------------
|
||||
|
||||
|
||||
def test_a_key_with_few_values_counts_them_exactly(load_script, monkeypatch, capsys, tmp_path):
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "c.jsonl",
|
||||
'{"kind": "call"}\n{"kind": "call"}\n{"kind": "ret"}\n',
|
||||
("--key", "kind", "--limit", "4"))
|
||||
assert code == 0
|
||||
assert 'key "kind": 2 distinct value(s) seen (showing 2)' in out
|
||||
assert "x2" in out
|
||||
|
||||
|
||||
def test_a_key_at_the_tracking_cap_is_reported_as_a_floor(load_script, monkeypatch, capsys,
|
||||
tmp_path):
|
||||
fold = load_script("fold.py")
|
||||
body = "".join(json.dumps({"v": i}) + "\n" for i in range(fold.VALUE_TRACK_CAP + 5))
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "wide.jsonl", body,
|
||||
("--key", "v", "--limit", "3"))
|
||||
assert code == 0
|
||||
assert "at least" in out
|
||||
assert "floor" in out
|
||||
# The cap must not leak as an exact count: "at least" only ever prefixes the capped number.
|
||||
assert "2005 distinct" not in out
|
||||
|
||||
|
||||
def test_grep_reports_how_many_matches_were_withheld(load_script, monkeypatch, capsys, tmp_path):
|
||||
text = "\n".join("hit %d premium" % i if i % 2 else "miss %d plain" % i
|
||||
for i in range(10)) + "\n"
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "log.txt", text,
|
||||
("--grep", "premium", "--limit", "1"))
|
||||
assert code == 0
|
||||
assert "matches: 5 (showing 1)" in out
|
||||
assert "4 more match(es) withheld" in out
|
||||
|
||||
|
||||
def test_text_input_never_touches_the_json_paths(load_script, monkeypatch, capsys, tmp_path):
|
||||
code, out = run_fold(load_script, monkeypatch, capsys, tmp_path, "notes.txt",
|
||||
"first\nsecond\nthird\n", ("--limit", "2"))
|
||||
assert code == 0
|
||||
assert "shape : text" in out
|
||||
assert "lines : 3" in out
|
||||
|
||||
|
||||
# --- the record itself is never the cost ----------------------------------------
|
||||
|
||||
|
||||
def test_the_input_is_not_modified(load_script, monkeypatch, capsys, tmp_path):
|
||||
body = '{"a": 1}\n{"a": 2}\n'
|
||||
path = tmp_path / "keep.jsonl"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
run_fold(load_script, monkeypatch, capsys, tmp_path, "keep.jsonl", body)
|
||||
assert path.read_text(encoding="utf-8") == body
|
||||
|
||||
|
||||
def test_a_missing_file_is_a_usage_error_not_a_summary(load_script, monkeypatch, capsys,
|
||||
tmp_path):
|
||||
fold = load_script("fold.py")
|
||||
monkeypatch.setattr(sys, "argv", ["fold.py", str(tmp_path / "absent.jsonl")])
|
||||
assert fold.main() == 2
|
||||
assert "RESULT=usage_error" in capsys.readouterr().out
|
||||
@@ -0,0 +1,377 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/pepatch.py.
|
||||
|
||||
The refusals matter more than the success path: a patcher that silently picks the wrong
|
||||
occurrence, or accepts a length change, destroys the baseline it was supposed to preserve. Each
|
||||
refusal below is asserted against the real exception text, and the CLI-level tests drive
|
||||
``pepatch.main()`` in-process so no subprocess is required to prove that a dry run writes
|
||||
nothing and that a real run leaves its input byte-identical.
|
||||
"""
|
||||
import hashlib
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pillow(tmp_path, fixtures):
|
||||
"""A privately owned copy of the fixture, so a test can never disturb another test."""
|
||||
dest = tmp_path / "input.exe"
|
||||
shutil.copyfile(str(fixtures.pe64.path), str(dest))
|
||||
return dest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plant(fixtures):
|
||||
"""The byte sequence the fixture plants twice, as a spaced hex string."""
|
||||
return fixtures.pe64.plant
|
||||
|
||||
|
||||
def sha256(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run_main(module, monkeypatch, argv):
|
||||
monkeypatch.setattr(sys, "argv", [getattr(module, "__file__", "pepatch.py")] + list(argv))
|
||||
return module.main()
|
||||
|
||||
|
||||
# --- parse_bytes -------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("48 8B 05", [0x48, 0x8B, 0x05]),
|
||||
("488B05", [0x48, 0x8B, 0x05]),
|
||||
("48,8B,05", [0x48, 0x8B, 0x05]),
|
||||
("0x48 0x8b", [0x48, 0x8B]),
|
||||
("48 8B ??", [0x48, 0x8B, None]),
|
||||
("?", [None]),
|
||||
("**", [None]),
|
||||
("xx", [None]),
|
||||
(" 48 8B ", [0x48, 0x8B]),
|
||||
("48", [0x48]),
|
||||
])
|
||||
def test_parse_bytes_accepts_the_documented_forms(load_script, text, expected):
|
||||
body, tokens = load_script("pepatch.py").parse_bytes(text)
|
||||
assert body == expected
|
||||
assert tokens
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", None, " "])
|
||||
def test_parse_bytes_of_nothing_is_empty(load_script, text):
|
||||
assert load_script("pepatch.py").parse_bytes(text) == ([], [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", ["ZZ", "4", "ABC", "GG HH", "0x1", "48 8G"])
|
||||
def test_parse_bytes_rejects_a_bad_token(load_script, token):
|
||||
with pytest.raises(ValueError, match="not a byte"):
|
||||
load_script("pepatch.py").parse_bytes(token)
|
||||
|
||||
|
||||
def test_parse_bytes_returns_the_original_tokens(load_script):
|
||||
body, tokens = load_script("pepatch.py").parse_bytes("48 8B ??")
|
||||
assert tokens == ["48", "8B", "??"]
|
||||
assert body == [0x48, 0x8B, None]
|
||||
|
||||
|
||||
# --- find_pattern ------------------------------------------------------------
|
||||
|
||||
def test_find_pattern_returns_every_occurrence(load_script, fixtures):
|
||||
"""The fixture plants the same sequence twice, on purpose."""
|
||||
pepatch = load_script("pepatch.py")
|
||||
data = fixtures.pe64.data
|
||||
pattern = pepatch.parse_bytes(fixtures.pe64.plant)[0]
|
||||
first, second = fixtures.pe64.plant_offsets
|
||||
assert pepatch.find_pattern(data, pattern) == [first, second]
|
||||
assert data[first:first + len(pattern)] == bytes(pattern)
|
||||
assert data[second:second + len(pattern)] == bytes(pattern)
|
||||
|
||||
|
||||
def test_find_pattern_honours_wildcards(load_script):
|
||||
pepatch = load_script("pepatch.py")
|
||||
data = bytes([0x00, 0x10, 0x99, 0x10, 0x00])
|
||||
assert pepatch.find_pattern(data, [None, 0x10]) == [0, 2]
|
||||
assert pepatch.find_pattern(data, [0x10, None]) == [1, 3]
|
||||
assert pepatch.find_pattern(data, [None, 0x10, None]) == [0, 2]
|
||||
|
||||
|
||||
def test_find_pattern_start_and_limit(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
data = fixtures.pe64.data
|
||||
pattern = pepatch.parse_bytes(fixtures.pe64.plant)[0]
|
||||
first, second = fixtures.pe64.plant_offsets
|
||||
assert pepatch.find_pattern(data, pattern, start=first + 1) == [second]
|
||||
assert pepatch.find_pattern(data, pattern, limit=1) == [first]
|
||||
|
||||
|
||||
def test_find_pattern_reports_no_hits_rather_than_guessing(load_script, fixtures):
|
||||
"""A negative outcome: a pattern that is not there must not match somewhere else."""
|
||||
pepatch = load_script("pepatch.py")
|
||||
assert pepatch.find_pattern(fixtures.pe64.data, [0xDE, 0xAD, 0xBE, 0xEF]) == []
|
||||
assert pepatch.find_pattern(fixtures.pe64.data,
|
||||
pepatch.parse_bytes("63 6F 6D 6D 6F 6E")[0]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern", [[], [None], [None, None]])
|
||||
def test_find_pattern_refuses_an_empty_or_all_wildcard_pattern(load_script, pattern):
|
||||
with pytest.raises(ValueError, match="empty or entirely wildcards"):
|
||||
load_script("pepatch.py").find_pattern(b"\x00\x01\x02", pattern)
|
||||
|
||||
|
||||
# --- length_mark -------------------------------------------------------------
|
||||
|
||||
def test_length_mark_distinguishes_three_states(load_script):
|
||||
pepatch = load_script("pepatch.py")
|
||||
assert pepatch.length_mark({"equal_length": True}) == ""
|
||||
assert "LENGTH CHANGE" in pepatch.length_mark({"equal_length": False})
|
||||
assert "length unproven" in pepatch.length_mark({"equal_length": None})
|
||||
|
||||
|
||||
# --- apply_one ---------------------------------------------------------------
|
||||
|
||||
def test_ambiguous_pattern_is_refused(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
spec = {"name": "gate", "pattern": fixtures.pe64.plant, "bytes": "90 " * 12}
|
||||
with pytest.raises(pepatch.PatchError, match="matched 2 sites"):
|
||||
pepatch.apply_one(spec, pe, fixtures.pe64.data)
|
||||
|
||||
|
||||
def test_explicit_index_selects_one_site(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
first, second = fixtures.pe64.plant_offsets
|
||||
spec = {"name": "gate", "pattern": fixtures.pe64.plant, "index": 1,
|
||||
"bytes": "90 " * 12, "expect": fixtures.pe64.plant}
|
||||
applied = pepatch.apply_one(spec, pe, fixtures.pe64.data)
|
||||
assert applied["offset"] == second
|
||||
assert applied["offset"] != first
|
||||
assert applied["equal_length"] is True
|
||||
assert applied["length_basis"] == "expect"
|
||||
|
||||
|
||||
def test_allow_many_takes_the_first_hit(load_script, fixtures):
|
||||
"""The documented escape hatch: it is not silent, and it still reports one site."""
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
spec = {"name": "gate", "pattern": fixtures.pe64.plant, "allow-many": True,
|
||||
"bytes": "90 " * 12, "expect": fixtures.pe64.plant}
|
||||
applied = pepatch.apply_one(spec, pe, fixtures.pe64.data)
|
||||
assert applied["offset"] == fixtures.pe64.plant_offsets[0]
|
||||
|
||||
|
||||
def test_expect_mismatch_is_refused(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
with pytest.raises(pepatch.PatchError, match="the site moved or the wrong binary is open"):
|
||||
pepatch.apply_one({"name": "gate", "offset": "0x410", "expect": "90 90",
|
||||
"bytes": "CC CC"}, pe, fixtures.pe64.data)
|
||||
|
||||
|
||||
def test_length_is_unproven_without_expect_or_original_length(load_script, fixtures):
|
||||
"""No disassembler, no honest claim: the marker must say the length is unknown."""
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
applied = pepatch.apply_one({"name": "site", "offset": "0x410", "bytes": "CC"},
|
||||
pe, fixtures.pe64.data)
|
||||
assert applied["equal_length"] is None
|
||||
assert applied["length_basis"] == "unknown (no expect/original-length given)"
|
||||
assert "length unproven" in pepatch.length_mark(applied)
|
||||
|
||||
|
||||
def test_length_change_is_refused_without_allow_resize(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
spec = {"name": "gate", "offset": "0x410", "expect": "48 8B", "bytes": "CC CC CC"}
|
||||
with pytest.raises(pepatch.PatchError, match="--allow-resize"):
|
||||
pepatch.apply_one(spec, pe, fixtures.pe64.data)
|
||||
|
||||
|
||||
def test_length_change_is_marked_structural_with_allow_resize(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
spec = {"name": "gate", "offset": "0x410", "expect": "48 8B", "bytes": "CC CC CC"}
|
||||
applied = pepatch.apply_one(spec, pe, fixtures.pe64.data, allow_resize=True)
|
||||
assert applied["equal_length"] is False
|
||||
assert "LENGTH CHANGE" in pepatch.length_mark(applied)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec,expected", [
|
||||
({"name": "wild", "offset": "0x410", "bytes": "CC ??"}, "no concrete replacement bytes"),
|
||||
({"name": "tail", "rva": "0x2200", "bytes": "CC"}, "maps to no file offset"),
|
||||
({"name": "eof", "offset": "0xFF8", "original-length": 16, "bytes": "CC"},
|
||||
"runs past end of file"),
|
||||
({"name": "neg", "offset": -1, "bytes": "CC"}, "must not be negative"),
|
||||
({"name": "nowhere", "bytes": "CC"}, "none of pattern/rva/offset"),
|
||||
({"name": "oob", "pattern": "48 8B 05 11 22 33 00 48 85 C0 74 05", "index": 5,
|
||||
"bytes": "CC"}, "index 5 out of range"),
|
||||
({"name": "none", "pattern": "DE AD BE EF", "bytes": "CC"}, "matched nothing"),
|
||||
])
|
||||
def test_resolve_refuses_specs_it_cannot_honour(load_script, fixtures, spec, expected):
|
||||
pepatch = load_script("pepatch.py")
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
with pytest.raises(pepatch.PatchError, match=expected):
|
||||
pepatch.resolve(spec, pe, fixtures.pe64.data)
|
||||
|
||||
|
||||
# --- verify ------------------------------------------------------------------
|
||||
|
||||
def test_verify_reports_bytes_that_are_not_there(load_script, fixtures):
|
||||
"""A negative outcome: the re-read step must fail when the patch did not land."""
|
||||
pepatch = load_script("pepatch.py")
|
||||
_pe, problems = pepatch.verify(str(fixtures.pe64.path),
|
||||
[{"name": "ghost", "offset": 0x410, "after": "CC"}])
|
||||
assert len(problems) == 1
|
||||
assert "ghost" in problems[0] and "expected CC" in problems[0]
|
||||
|
||||
|
||||
def test_verify_is_satisfied_when_the_bytes_are_present(load_script, fixtures):
|
||||
pepatch = load_script("pepatch.py")
|
||||
first, _second = fixtures.pe64.plant_offsets
|
||||
present = pepatch.fmt_bytes(fixtures.pe64.data[first:first + 4])
|
||||
_pe, problems = pepatch.verify(str(fixtures.pe64.path),
|
||||
[{"name": "real", "offset": first, "after": present}])
|
||||
assert problems == []
|
||||
|
||||
|
||||
# --- main() ------------------------------------------------------------------
|
||||
|
||||
def test_dry_run_writes_nothing(load_script, pillow, monkeypatch, capsys):
|
||||
pepatch = load_script("pepatch.py")
|
||||
before = sha256(pillow)
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--at-offset", "0x410", "--bytes", "CC", "--dry-run"])
|
||||
out = capsys.readouterr()
|
||||
assert code == 0
|
||||
assert "dry run" in out.out
|
||||
assert sha256(pillow) == before
|
||||
assert list(pillow.parent.iterdir()) == [pillow], "a dry run created a file"
|
||||
|
||||
|
||||
def test_dry_run_reports_a_refusal_without_writing(load_script, pillow, monkeypatch, capsys):
|
||||
pepatch = load_script("pepatch.py")
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--at-offset", "0x410", "--expect", "90 90",
|
||||
"--bytes", "CC CC", "--dry-run"])
|
||||
out = capsys.readouterr()
|
||||
assert code == 1
|
||||
assert "FAIL" in out.out
|
||||
assert list(pillow.parent.iterdir()) == [pillow]
|
||||
|
||||
|
||||
def test_out_is_required_to_write_a_patched_file(load_script, pillow, monkeypatch, capsys):
|
||||
pepatch = load_script("pepatch.py")
|
||||
before = sha256(pillow)
|
||||
code = run_main(pepatch, monkeypatch, [str(pillow), "--at-offset", "0x410",
|
||||
"--bytes", "CC"])
|
||||
err = capsys.readouterr().err
|
||||
assert code == 2
|
||||
assert "--out is required" in err
|
||||
assert sha256(pillow) == before
|
||||
assert list(pillow.parent.iterdir()) == [pillow]
|
||||
|
||||
|
||||
def test_real_run_preserves_the_input_and_verifies_the_output(load_script, pillow, fixtures,
|
||||
monkeypatch, capsys):
|
||||
pepatch = load_script("pepatch.py")
|
||||
peutil = load_script("peutil.py")
|
||||
before = sha256(pillow)
|
||||
first, _second = fixtures.pe64.plant_offsets
|
||||
replacement = "48 8B 05 11 22 33 00 48 85 C0 EB 05"
|
||||
out = pillow.parent / "patched.exe"
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--pattern", fixtures.pe64.plant, "--at-hit", "0",
|
||||
"--expect", fixtures.pe64.plant, "--bytes", replacement, "--out", str(out)])
|
||||
text = capsys.readouterr().out
|
||||
assert code == 0, text
|
||||
assert "verified" in text
|
||||
assert sha256(pillow) == before, "the input file was modified"
|
||||
assert out.exists()
|
||||
|
||||
pe_out = peutil.PE(out.read_bytes(), str(out))
|
||||
assert pe_out.file_size == fixtures.pe64.size
|
||||
assert out.read_bytes()[first:first + 12] == bytes(pepatch.parse_bytes(replacement)[0])
|
||||
# The second planted copy must be untouched: only the selected site moved.
|
||||
assert out.read_bytes()[_second:_second + 12] == bytes(
|
||||
pepatch.parse_bytes(fixtures.pe64.plant)[0])
|
||||
assert pe_out.checksum_ok() is True
|
||||
assert pe_out.checksum != 0
|
||||
|
||||
|
||||
def test_input_and_output_differ_only_where_expected(load_script, pillow, fixtures,
|
||||
monkeypatch, capsys):
|
||||
pepatch = load_script("pepatch.py")
|
||||
first, _second = fixtures.pe64.plant_offsets
|
||||
checksum_field = int(fixtures.pe64.meta["checksum_offset"], 16)
|
||||
out = pillow.parent / "one-byte.exe"
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--at-offset", hex(first), "--expect", "48 8B",
|
||||
"--bytes", "CC 8B", "--out", str(out)])
|
||||
capsys.readouterr()
|
||||
assert code == 0
|
||||
diff = [i for i, (a, b) in enumerate(zip(pillow.read_bytes(), out.read_bytes())) if a != b]
|
||||
# The patched byte plus at most the four checksum bytes.
|
||||
assert first in diff
|
||||
assert len(diff) <= 5
|
||||
assert set(diff) - {first} <= set(range(checksum_field, checksum_field + 4))
|
||||
|
||||
|
||||
def test_no_checksum_leaves_the_declared_field_stale(load_script, pillow, monkeypatch, capsys):
|
||||
pepatch = load_script("pepatch.py")
|
||||
peutil = load_script("peutil.py")
|
||||
out = pillow.parent / "stale.exe"
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--at-offset", "0x410", "--expect", "48 8B",
|
||||
"--bytes", "90 90", "--no-checksum", "--out", str(out), "--json"])
|
||||
captured = capsys.readouterr()
|
||||
assert code == 0, captured.err
|
||||
import json
|
||||
payload = json.loads(captured.out)
|
||||
assert payload["checksum_recomputed"] is False
|
||||
assert payload["checksum_after"]["matches"] is False
|
||||
assert peutil.PE(out.read_bytes(), None).checksum_ok() is False
|
||||
|
||||
|
||||
def test_allow_resize_shifts_the_following_bytes(load_script, pillow, fixtures,
|
||||
monkeypatch, capsys):
|
||||
"""A resize changes the file's length, and everything after the site moves with it.
|
||||
|
||||
This is the behaviour the flag promises. An earlier version wrote exactly as many bytes as
|
||||
it read, so the file never grew while the documented contract said it would -- the flag's
|
||||
only real effect was to defeat the equal-length guard. The assertions below pin the genuine
|
||||
consequence, shift included, because a shifted file is a structural edit and the caller is
|
||||
entitled to see that stated rather than inferred.
|
||||
"""
|
||||
pepatch = load_script("pepatch.py")
|
||||
first, _second = fixtures.pe64.plant_offsets
|
||||
original = pillow.read_bytes()
|
||||
out = pillow.parent / "resized.exe"
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--at-offset", hex(first), "--expect", "48",
|
||||
"--bytes", "CC CC CC", "--allow-resize", "--out", str(out), "--json"])
|
||||
import json
|
||||
payload = json.loads(capsys.readouterr().out)
|
||||
assert code == 0
|
||||
assert payload["structural"] == ["cli"]
|
||||
assert payload["output_size"] == payload["input_size"] + 2
|
||||
written = out.read_bytes()
|
||||
assert len(written) == len(original) + 2
|
||||
assert written[first:first + 3] == b"\xCC\xCC\xCC"
|
||||
# One byte was replaced by three, so the tail moved two bytes along.
|
||||
assert written[first + 3:first + 14] == original[first + 1:first + 12]
|
||||
|
||||
|
||||
def test_failed_patches_stop_the_write(load_script, pillow, plant, monkeypatch, capsys):
|
||||
"""An ambiguous pattern must fail the run instead of patching the first match."""
|
||||
pepatch = load_script("pepatch.py")
|
||||
before = sha256(pillow)
|
||||
out = pillow.parent / "never.exe"
|
||||
code = run_main(pepatch, monkeypatch,
|
||||
[str(pillow), "--pattern", plant, "--bytes", "90 " * 12, "--out", str(out)])
|
||||
err = capsys.readouterr().err
|
||||
assert code == 1
|
||||
assert "refusing to write an output with failed patches" in err
|
||||
assert "matched 2 sites" in err
|
||||
assert not out.exists()
|
||||
assert sha256(pillow) == before
|
||||
@@ -0,0 +1,417 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/peutil.py.
|
||||
|
||||
Everything here runs against the generated fixtures, whose declared metadata (section RVAs,
|
||||
plant offsets, checksum) is asserted against the parser instead of being re-derived from it, so
|
||||
a parser change cannot make its own test pass.
|
||||
|
||||
The checksum check is deliberately a second implementation: if the test called the parser's own
|
||||
function to produce the expected value, a wrong algorithm would agree with itself.
|
||||
"""
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def ms_checksum(data, field_offset):
|
||||
"""The published Microsoft PE checksum, written independently of peutil.py."""
|
||||
buf = bytearray(data)
|
||||
buf[field_offset:field_offset + 4] = b"\x00\x00\x00\x00"
|
||||
total = 0
|
||||
for i in range(0, len(buf) - (len(buf) % 2), 2):
|
||||
total += buf[i] | (buf[i + 1] << 8)
|
||||
total = (total & 0xFFFF) + (total >> 16)
|
||||
if len(buf) % 2:
|
||||
total += buf[-1]
|
||||
total = (total & 0xFFFF) + (total >> 16)
|
||||
total = (total & 0xFFFF) + (total >> 16)
|
||||
return (total + len(buf)) & 0xFFFFFFFF
|
||||
|
||||
|
||||
# --- header fields -----------------------------------------------------------
|
||||
|
||||
def test_pe32plus_fixture_header_fields_match_the_recipe(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, str(fixtures.pe64.path))
|
||||
meta = fixtures.pe64.meta
|
||||
assert pe.is_pe32plus is True
|
||||
assert pe.magic == 0x20B
|
||||
assert pe.machine == 0x8664
|
||||
assert pe.arch == "amd64"
|
||||
assert pe.number_of_sections == 5
|
||||
assert pe.number_of_rva_and_sizes == 16
|
||||
assert [s.name for s in pe.sections] == [".text", ".rdata", ".data", ".rsrc", ".reloc"]
|
||||
assert pe.address_of_entrypoint == int(meta["entry_rva"], 16) == 0x1000
|
||||
assert pe.image_base == int(meta["image_base"], 16) == 0x140000000
|
||||
assert pe.size_of_image == meta["size_of_image"]
|
||||
assert pe.size_of_headers == meta["size_of_headers"]
|
||||
assert pe.section_alignment == 0x1000
|
||||
assert pe.file_alignment == 0x200
|
||||
assert pe.subsystem == 3
|
||||
assert pe.timestamp == 0x5F5E1000
|
||||
assert pe.file_size == meta["size"] == 4096
|
||||
assert pe.e_lfanew == 0x80
|
||||
|
||||
|
||||
def test_pe32_fixture_reports_the_32bit_layout(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe32.data, str(fixtures.pe32.path))
|
||||
assert pe.is_pe32plus is False
|
||||
assert pe.magic == 0x10B
|
||||
assert pe.machine == 0x014C
|
||||
assert pe.arch == "i386"
|
||||
assert pe.image_base == 0x400000
|
||||
assert pe.number_of_sections == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,plus", [("pe64", True), ("pe32", False)])
|
||||
def test_optional_header_field_offsets_follow_the_magic(load_script, fixtures, name, plus):
|
||||
"""The PE32 and PE32+ optional headers are different layouts, not different sizes."""
|
||||
pe = load_script("peutil.py").PE(fixtures[name].data, None)
|
||||
oh = pe.optional_offset
|
||||
assert pe._num_dir_off - oh == (108 if plus else 92)
|
||||
assert pe._dir_off - oh == (112 if plus else 96)
|
||||
assert pe.checksum == struct.unpack_from("<I", pe.d, oh + 64)[0]
|
||||
if plus:
|
||||
assert pe.image_base == struct.unpack_from("<Q", pe.d, oh + 24)[0]
|
||||
else:
|
||||
assert pe.image_base == struct.unpack_from("<I", pe.d, oh + 28)[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["pe64", "pe32"])
|
||||
def test_reading_the_other_layout_yields_a_wrong_directory_count(load_script, fixtures, name):
|
||||
"""The trap the fixture exists to expose: the wrong layout parses without error."""
|
||||
pe = load_script("peutil.py").PE(fixtures[name].data, None)
|
||||
wrong = pe._num_dir_off - pe.optional_offset
|
||||
other = 92 if wrong == 108 else 108
|
||||
misread = struct.unpack_from("<I", pe.d, pe.optional_offset + other)[0]
|
||||
assert pe.number_of_rva_and_sizes == 16
|
||||
assert misread != 16, "the two layouts are not distinguishable on this fixture"
|
||||
|
||||
|
||||
def test_characteristics_and_kind_derivations(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
assert pe.is_dll is False
|
||||
assert pe.is_driver is False
|
||||
assert pe.is_dotnet is False
|
||||
assert pe.is_signed is False
|
||||
assert pe.security_directory() is None
|
||||
assert "executable_image" in pe.summary()["file_characteristics"]
|
||||
assert "large_address_aware" in pe.summary()["file_characteristics"]
|
||||
|
||||
|
||||
def test_fixture_is_well_formed_so_no_warnings_are_raised(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
assert pe.warnings == []
|
||||
|
||||
|
||||
# --- RVA <-> file offset -----------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("section", [".text", ".rdata", ".data", ".rsrc", ".reloc"])
|
||||
def test_rva_to_file_offset_round_trips_every_section(load_script, fixtures, section):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
decl = fixtures.pe64.section(section)
|
||||
rva = int(decl["rva"], 16)
|
||||
raw = int(decl["raw_pointer"], 16)
|
||||
assert pe.rva_to_offset(rva) == raw
|
||||
assert pe.offset_to_rva(raw) == rva
|
||||
for delta in (1, decl["raw_size"] // 2, decl["raw_size"] - 1):
|
||||
assert pe.rva_to_offset(rva + delta) == raw + delta
|
||||
assert pe.offset_to_rva(raw + delta) == rva + delta
|
||||
assert pe.section_of_rva(rva).name == section
|
||||
|
||||
|
||||
def test_header_rvas_map_identity_below_size_of_headers(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
for rva in (0, 0x40, 0x98, pe.size_of_headers - 1):
|
||||
assert pe.rva_to_offset(rva) == rva
|
||||
assert pe.offset_to_rva(rva) == rva
|
||||
|
||||
|
||||
def test_virtual_only_tail_has_no_bytes_on_disk(load_script, fixtures):
|
||||
"""The .rdata tail is allocated in memory and zero-filled at load, so it has no file bytes."""
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
decl = fixtures.pe64.section(".rdata")
|
||||
rva = int(decl["rva"], 16)
|
||||
raw_size = decl["raw_size"]
|
||||
# Measured shape of this fixture: a 384-byte payload aligned up to a 512-byte raw span,
|
||||
# plus the recipe's 1024-byte virtual pad, gives 1408 virtual bytes -- so 896 of them have
|
||||
# no file image at all.
|
||||
assert decl["virtual_size"] - raw_size == 896
|
||||
assert fixtures.pe64.meta["virtual_only_tail_bytes"] == 1024
|
||||
# Last byte with a file image, then the first byte without one.
|
||||
assert pe.rva_to_offset(rva + raw_size - 1) == int(decl["raw_pointer"], 16) + raw_size - 1
|
||||
assert pe.rva_to_offset(rva + raw_size) is None
|
||||
assert pe.rva_to_offset(rva + decl["virtual_size"] - 1) is None
|
||||
assert pe.rva_bytes(rva + raw_size, 4) == b""
|
||||
assert pe.section_of_rva(rva + raw_size).name == ".rdata"
|
||||
# The RVA still exists, it just does not resolve to a file offset.
|
||||
assert pe.rva_to_offset(rva) is not None
|
||||
|
||||
|
||||
def test_recipe_without_a_virtual_tail_has_none(load_script, fixtures):
|
||||
"""A 4 KiB file alignment is large enough that every virtual byte has a file image."""
|
||||
pe = load_script("peutil.py").PE(fixtures.fatalign.data, None)
|
||||
decl = fixtures.fatalign.section(".rdata")
|
||||
rva = int(decl["rva"], 16)
|
||||
assert decl["virtual_size"] <= decl["raw_size"]
|
||||
assert pe.rva_to_offset(rva + decl["virtual_size"] - 1) is not None
|
||||
assert pe.rva_to_offset(rva + decl["raw_size"]) is None
|
||||
|
||||
|
||||
def test_offset_to_rva_returns_none_outside_every_mapping(load_script, fixtures):
|
||||
"""A negative outcome: an offset with no mapping must not be reported as RVA 0."""
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
assert pe.offset_to_rva(pe.file_size) is None
|
||||
assert pe.offset_to_rva(pe.file_size + 0x1000) is None
|
||||
|
||||
|
||||
def test_rva_to_offset_rejects_unmapped_rvas(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
assert pe.rva_to_offset(0x900000) is None
|
||||
assert pe.section_of_rva(0x900000) is None
|
||||
assert pe.read_cstring(None) == ""
|
||||
assert pe.read_cstring(pe.file_size + 1) == ""
|
||||
|
||||
|
||||
# --- checksum ----------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("name", ["pe64", "pe32", "bare", "fatalign"])
|
||||
def test_checksum_reproduces_the_declared_value(load_script, fixtures, name):
|
||||
built = fixtures[name]
|
||||
pe = load_script("peutil.py").PE(built.data, str(built.path))
|
||||
declared = int(built.meta["checksum"], 16)
|
||||
assert pe.checksum == declared
|
||||
assert pe.computed_checksum() == declared
|
||||
assert pe.checksum_ok() is True
|
||||
# Independent implementation of the same published algorithm.
|
||||
assert ms_checksum(built.data, int(built.meta["checksum_offset"], 16)) == declared
|
||||
|
||||
|
||||
def test_checksum_mismatch_is_reported_not_hidden(load_script, fixtures):
|
||||
data = bytearray(fixtures.pe64.data)
|
||||
data[int(fixtures.pe64.meta["checksum_offset"], 16)] ^= 0xFF # corrupt one byte
|
||||
pe = load_script("peutil.py").PE(bytes(data), None)
|
||||
assert pe.checksum_ok() is False
|
||||
assert pe.computed_checksum() != pe.checksum
|
||||
|
||||
|
||||
# --- structures --------------------------------------------------------------
|
||||
|
||||
def test_resource_walk_returns_exactly_the_declared_entries(load_script, fixtures):
|
||||
"""type(16=VERSION) -> name(1) -> language(1033) -> one data entry, and nothing else.
|
||||
|
||||
A 32-bit read of either directory count would swallow the next entry and inflate this to a
|
||||
much larger number, which is why the exact count is asserted rather than "at least one".
|
||||
"""
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
entries = pe.resources()
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry["path"] == ["VERSION", "1", "1033"]
|
||||
assert entry["size"] == 4
|
||||
assert entry["codepage"] == 1200
|
||||
assert int(entry["data_rva"], 16) == int(fixtures.pe64.section(".rsrc")["rva"], 16) + 0x90
|
||||
assert pe.rva_bytes(int(entry["data_rva"], 16), 4) == b"FIXT"
|
||||
|
||||
|
||||
def test_recipe_without_resources_reports_an_empty_tree(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.bare.data, None)
|
||||
assert pe.resources() == []
|
||||
|
||||
|
||||
def test_imports_include_one_named_import_and_one_by_ordinal(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
imports = pe.imports()
|
||||
assert len(imports) == 1
|
||||
desc = imports[0]
|
||||
assert desc["dll"] == fixtures.pe64.meta["import_dll"] == "fixture-import.dll"
|
||||
assert desc["descriptor_rva"] == int(fixtures.pe64.meta["import_descriptor_rva"], 16)
|
||||
funcs = desc["functions"]
|
||||
assert len(funcs) == 2, "the descriptor terminator must stop the walk"
|
||||
named = funcs[0]
|
||||
assert named["name"] == fixtures.pe64.meta["import_named"] == "ImportedFunction"
|
||||
assert named["hint"] == 7
|
||||
assert "ordinal" not in named
|
||||
by_ordinal = funcs[1]
|
||||
assert by_ordinal["ordinal"] == int(fixtures.pe64.meta["import_ordinal"], 16) == 0x2A
|
||||
assert "name" not in by_ordinal
|
||||
assert funcs[1]["iat_rva"] > funcs[0]["iat_rva"]
|
||||
assert pe.delay_imports() == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["pe64", "pe32"])
|
||||
def test_export_directory_has_one_named_function(load_script, fixtures, name):
|
||||
pe = load_script("peutil.py").PE(fixtures[name].data, None)
|
||||
exports = pe.exports()
|
||||
assert exports["dll_name"] == "fixture-export.dll"
|
||||
assert exports["ordinal_base"] == 1
|
||||
assert exports["number_of_functions"] == 1
|
||||
assert exports["number_of_names"] == 1
|
||||
funcs = exports["functions"]
|
||||
assert len(funcs) == 1
|
||||
assert funcs[0]["name"] == "ExportedFunction"
|
||||
assert funcs[0]["ordinal"] == 1
|
||||
assert funcs[0]["rva"] == "0x1000"
|
||||
assert "forwarder" not in funcs[0]
|
||||
|
||||
|
||||
def test_recipe_without_exports_returns_none(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.bare.data, None)
|
||||
assert pe.exports() is None
|
||||
|
||||
|
||||
def test_tls_callback_rva_is_reachable(load_script, fixtures):
|
||||
"""The callback pointer is stored as a VA, so the reported RVA needs image-base subtraction."""
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
tls = pe.tls()
|
||||
assert tls is not None
|
||||
assert tls["callback_rvas"] == [fixtures.pe64.meta["entry_rva"]] == ["0x1000"]
|
||||
assert tls["callbacks_rva"] == fixtures.pe64.meta["tls_callback_rva"]
|
||||
assert tls["note"], "a callback that runs before the entry point must be flagged"
|
||||
assert pe.imports() and tls["callbacks_rva"] != fixtures.pe64.meta["import_descriptor_rva"]
|
||||
|
||||
|
||||
def test_recipe_without_tls_reports_none(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.bare.data, None)
|
||||
assert pe.tls() is None
|
||||
|
||||
|
||||
def test_debug_record_carries_the_rsds_pdb_path(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
records = pe.debug_directory()
|
||||
assert len(records) == 1
|
||||
rec = records[0]
|
||||
assert rec["type"] == "codeview" and rec["type_id"] == 2
|
||||
assert rec["format"] == "RSDS"
|
||||
assert rec["pdb"] == fixtures.pe64.meta["pdb_path"] == "C:\\build\\fixture.pdb"
|
||||
assert rec["age"] == 1
|
||||
assert rec["guid"] == hashlib.md5(bytes(range(16))).hexdigest()
|
||||
# PointerToRawData is a file offset while AddressOfRawData is an RVA: prove the pair agrees.
|
||||
rsds_rva = int(fixtures.pe64.meta["rdata_structure_rvas"]["dbg_rsds"], 16)
|
||||
assert pe.rva_to_offset(rsds_rva) == int(rec["pointer_to_raw_data"], 16)
|
||||
assert pe.d[pe.rva_to_offset(rsds_rva):pe.rva_to_offset(rsds_rva) + 4] == b"RSDS"
|
||||
|
||||
|
||||
def test_recipe_without_debug_directory_reports_no_records(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.bare.data, None)
|
||||
assert pe.debug_directory() == []
|
||||
|
||||
|
||||
def test_base_relocations_block_is_parsed_by_type(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
relocs = pe.base_relocations()
|
||||
assert relocs["blocks"] == 1
|
||||
assert relocs["entries"] == 2
|
||||
assert relocs["truncated"] is False
|
||||
assert relocs["types"] == {"3": 1, "10": 1}
|
||||
|
||||
|
||||
def test_absent_load_config_is_reported_as_none(load_script, fixtures):
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, None)
|
||||
assert pe.load_config() is None
|
||||
assert pe.rich_header() is None, "the fixture has no Rich header; none may be invented"
|
||||
|
||||
|
||||
def test_summary_is_json_serialisable_and_names_the_documented_keys(load_script, fixtures):
|
||||
import json
|
||||
pe = load_script("peutil.py").PE(fixtures.pe64.data, str(fixtures.pe64.path))
|
||||
summary = pe.summary()
|
||||
text = json.dumps(summary)
|
||||
assert '"checksum_matches": true' in text
|
||||
for key in ("path", "file_size", "sha256", "arch", "kind", "sections", "directories",
|
||||
"checksum_declared", "checksum_computed", "checksum_matches", "warnings"):
|
||||
assert key in summary
|
||||
assert summary["sha256"] == hashlib.sha256(fixtures.pe64.data).hexdigest()
|
||||
|
||||
|
||||
# --- pure helpers ------------------------------------------------------------
|
||||
|
||||
def test_entropy_bounds(load_script):
|
||||
peutil = load_script("peutil.py")
|
||||
assert peutil.entropy(b"") == 0.0
|
||||
assert peutil.entropy(b"\x00" * 256) == 0.0
|
||||
assert peutil.entropy(bytes(range(256))) == pytest.approx(8.0, abs=1e-9)
|
||||
assert 0.0 <= peutil.entropy(bytes(range(128)) * 2) <= 8.0
|
||||
|
||||
|
||||
def test_section_align_is_an_index_not_a_set_of_bits(load_script):
|
||||
"""Bits [23:20] hold an alignment index, so a normal image reports no alignment name."""
|
||||
peutil = load_script("peutil.py")
|
||||
assert peutil.section_align(0x00600000) == "align_32"
|
||||
assert peutil.section_align(0xE0000020) is None # the fixture's .text
|
||||
assert peutil.section_align(0x60000020) is None # a normal compiler's .text
|
||||
|
||||
|
||||
def test_flag_decoding_is_ordered_and_complete(load_script):
|
||||
peutil = load_script("peutil.py")
|
||||
assert peutil.bits(0x0022, peutil.FILE_CHARACTERISTICS) == ["executable_image",
|
||||
"large_address_aware"]
|
||||
assert peutil.bits(0, peutil.FILE_CHARACTERISTICS) == []
|
||||
assert "mem_execute" in peutil.bits(0xE0000020, peutil.SECTION_CHARACTERISTICS)
|
||||
assert "mem_write" in peutil.bits(0xE0000020, peutil.SECTION_CHARACTERISTICS)
|
||||
|
||||
|
||||
def test_integer_readers_are_little_endian(load_script):
|
||||
peutil = load_script("peutil.py")
|
||||
blob = b"\x01\x02\x03\x04\x05\x06\x07\x08"
|
||||
assert peutil.u8(blob, 0) == 0x01
|
||||
assert peutil.u16(blob, 0) == 0x0201
|
||||
assert peutil.u32(blob, 0) == 0x04030201
|
||||
assert peutil.u64(blob, 0) == 0x0807060504030201
|
||||
assert peutil.s32(b"\xFF\xFF\xFF\xFF", 0) == -1
|
||||
|
||||
|
||||
# --- rejection ---------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("blob,expected", [
|
||||
(b"", "no MZ signature"),
|
||||
(b"MZ", "no MZ signature"),
|
||||
(b"this is not a PE file at all, just text" * 4, "no MZ signature"),
|
||||
])
|
||||
def test_parse_error_on_non_pe_input(load_script, blob, expected):
|
||||
peutil = load_script("peutil.py")
|
||||
with pytest.raises(peutil.ParseError, match=expected):
|
||||
peutil.PE(blob, None)
|
||||
|
||||
|
||||
def test_parse_error_when_e_lfanew_points_past_end_of_file(load_script):
|
||||
peutil = load_script("peutil.py")
|
||||
blob = b"MZ" + b"\x00" * 0x3A + struct.pack("<I", 0xFFFFFF) + b"\x00" * 8
|
||||
with pytest.raises(peutil.ParseError, match="points past end of file"):
|
||||
peutil.PE(blob, None)
|
||||
|
||||
|
||||
def test_parse_error_without_the_pe_signature(load_script):
|
||||
peutil = load_script("peutil.py")
|
||||
blob = b"MZ" + b"\x00" * 0x3A + struct.pack("<I", 0x40) + b"\x00" * 0x40
|
||||
with pytest.raises(peutil.ParseError, match=r"no PE\\0\\0 signature"):
|
||||
peutil.PE(blob, None)
|
||||
|
||||
|
||||
def test_parse_error_on_an_unexpected_optional_header_magic(load_script, fixtures):
|
||||
peutil = load_script("peutil.py")
|
||||
data = bytearray(fixtures.pe64.data)
|
||||
struct.pack_into("<H", data, 0x98, 0x999)
|
||||
with pytest.raises(peutil.ParseError, match="unexpected optional-header magic 0x0999"):
|
||||
peutil.PE(bytes(data), None)
|
||||
|
||||
|
||||
def test_parse_error_on_an_object_file_with_no_optional_header(load_script, fixtures):
|
||||
peutil = load_script("peutil.py")
|
||||
data = bytearray(fixtures.pe64.data)
|
||||
struct.pack_into("<H", data, 0x80 + 4 + 16, 0)
|
||||
with pytest.raises(peutil.ParseError, match="object file, not an image"):
|
||||
peutil.PE(bytes(data), None)
|
||||
|
||||
|
||||
def test_load_rejects_a_missing_path(tmp_path, load_script):
|
||||
peutil = load_script("peutil.py")
|
||||
with pytest.raises(OSError):
|
||||
peutil.load(str(tmp_path / "absent.exe"))
|
||||
|
||||
|
||||
def test_load_sets_the_path_on_the_parsed_image(load_script, fixtures):
|
||||
pe = load_script("peutil.py").load(str(fixtures.pe64.path))
|
||||
assert pe.path == str(fixtures.pe64.path)
|
||||
assert pe.file_size == fixtures.pe64.size
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/scan_leaks.py.
|
||||
|
||||
Two failure modes are tested at once, because only one of them is obvious. A scanner that misses
|
||||
a pasted key is dangerous; a scanner that flags `x64dbg`, `PE32+` or a reserved documentation
|
||||
address gets switched off, which is equally dangerous and much easier to do. So every rule is
|
||||
asserted to fire on a shaped sample, and every exemption class is asserted to stay quiet.
|
||||
|
||||
Three measured observations are pinned here rather than hidden, because each is a false negative
|
||||
or a dead branch in the rule table:
|
||||
|
||||
* the bare-GUID exemption matches inside the machine-guid rule's own match text, so that rule
|
||||
cannot produce a finding;
|
||||
* the `.NET` entry in the platform list matches any `.net` TLD, so a hit inside such a URL or
|
||||
address is suppressed as a platform term;
|
||||
* the ``<[a-z_]+>`` placeholder alternative is wrapped in ``\\b`` on both sides, so it cannot
|
||||
match bracketed prose.
|
||||
|
||||
The shaped samples come from the corpus in conftest.py, which assembles them from fragments so
|
||||
that this suite does not itself trip the scan it tests.
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
# The rules the exemption list structurally suppresses: a hit exists, and an exemption claims
|
||||
# it, so the rule cannot produce a finding. Measured, and re-asserted below so a fix shows up as
|
||||
# a test change rather than as a quietly gained finding.
|
||||
SUPPRESSED_RULES = ("machine-guid",)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def findings_in(scan_leaks, text):
|
||||
"""What main() would report: hits that no exemption suppresses."""
|
||||
out = []
|
||||
for hit in scan_leaks.scan_text(text):
|
||||
if scan_leaks.exempt_reason(hit["match"]) or scan_leaks.exempt_reason(hit["context"]):
|
||||
continue
|
||||
out.append(hit)
|
||||
return out
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def corpus(leak_lines):
|
||||
return dict(leak_lines)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exemption_corpus(exempt_lines):
|
||||
return dict(exempt_lines)
|
||||
|
||||
|
||||
# --- every rule fires --------------------------------------------------------
|
||||
|
||||
def test_every_rule_fires_on_its_shaped_sample(load_script, leak_lines):
|
||||
scan = load_script("scan_leaks.py")
|
||||
missing = []
|
||||
for rule, line in leak_lines:
|
||||
fired = {hit["rule"] for hit in scan.scan_text(line)}
|
||||
if rule not in fired:
|
||||
missing.append((rule, sorted(fired)))
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_every_rule_except_the_suppressed_ones_is_reportable(load_script, leak_lines):
|
||||
scan = load_script("scan_leaks.py")
|
||||
suppressed = set(SUPPRESSED_RULES)
|
||||
unexpected = []
|
||||
for rule, line in leak_lines:
|
||||
reported = {hit["rule"] for hit in findings_in(scan, line)}
|
||||
if rule in suppressed:
|
||||
if rule in reported:
|
||||
unexpected.append((rule, "expected to be suppressed by the exemption list"))
|
||||
elif rule not in reported:
|
||||
unexpected.append((rule, "not reported"))
|
||||
assert unexpected == []
|
||||
assert suppressed == {"machine-guid"}
|
||||
|
||||
|
||||
def test_the_rule_table_is_well_formed(load_script):
|
||||
scan = load_script("scan_leaks.py")
|
||||
names = [name for _c, _s, name, _p, _w in scan.RULES]
|
||||
assert len(names) == len(set(names)) == len(scan.RULES) == 18
|
||||
assert {strength for _c, strength, _n, _p, _w in scan.RULES} == {"strong", "weak"}
|
||||
for category, strength, name, pattern, why in scan.RULES:
|
||||
assert category and name and why, (category, name)
|
||||
assert strength in ("strong", "weak")
|
||||
assert pattern.pattern, name
|
||||
|
||||
|
||||
# --- the three measured deviations ------------------------------------------
|
||||
|
||||
def test_machine_guid_rule_fires(load_script, corpus):
|
||||
scan = load_script("scan_leaks.py")
|
||||
hits = list(scan.scan_text(corpus["machine-guid"]))
|
||||
assert [h["rule"] for h in hits] == ["machine-guid"]
|
||||
|
||||
|
||||
def test_machine_guid_finding_is_suppressed_by_the_bare_guid_exemption(load_script, corpus):
|
||||
"""Measured: the bare-GUID exemption matches inside this rule's own match text.
|
||||
|
||||
Recorded here so that a change to either list shows up as a test change instead of as a
|
||||
silently gained or lost finding.
|
||||
"""
|
||||
scan = load_script("scan_leaks.py")
|
||||
line = corpus["machine-guid"]
|
||||
hit = list(scan.scan_text(line))[0]
|
||||
assert "bare GUID is not machine identity" in scan.exempt_reason(hit["match"])
|
||||
assert findings_in(scan, line) == []
|
||||
|
||||
|
||||
def test_a_url_finding_is_not_swallowed_by_an_exemption(load_script, corpus):
|
||||
"""A concrete host must still be reported, and only as the weak finding the rule declares.
|
||||
|
||||
Measured, twice: while this suite was being written, a standard-name exemption alternative
|
||||
matched the `HTTP` inside every `https?://` URL and swallowed the rule's every hit. The
|
||||
assertion below is the state after that was corrected -- a URL hit is reported, at weak
|
||||
strength, with no exemption claiming it. `benchmark.md` records both states.
|
||||
"""
|
||||
scan = load_script("scan_leaks.py")
|
||||
line = corpus["literal-host-with-path"]
|
||||
hits = list(scan.scan_text(line))
|
||||
assert [h["rule"] for h in hits] == ["literal-host-with-path"]
|
||||
assert hits[0]["strength"] == "weak"
|
||||
assert scan.exempt_reason(hits[0]["match"]) is None
|
||||
reported = findings_in(scan, line)
|
||||
assert [hit["rule"] for hit in reported] == ["literal-host-with-path"]
|
||||
|
||||
|
||||
def test_a_net_domain_hides_a_hit_behind_the_platform_exemption(load_script, corpus,
|
||||
exemption_corpus):
|
||||
"""Measured deviation: `.NET` in the platform list matches any `.net` TLD.
|
||||
|
||||
A bare domain under `.net` is therefore read as a platform term while the same domain under
|
||||
another TLD is not, which is a false negative rather than a rule. Pinned, not endorsed.
|
||||
"""
|
||||
scan = load_script("scan_leaks.py")
|
||||
net_url = exemption_corpus["net tld"]
|
||||
org_url = corpus["literal-host-with-path"]
|
||||
assert net_url.endswith(".net/v2/activate") and org_url.endswith(".org/v2/activate")
|
||||
assert "platform or vendor" in scan.exempt_reason(net_url)
|
||||
# Isolate the cause to the TLD: the same names under another TLD claim no platform exemption.
|
||||
assert scan.exempt_reason("licensing.hexweld.net") is not None
|
||||
assert scan.exempt_reason("licensing.hexweld.org") is None
|
||||
|
||||
|
||||
def test_the_bracketed_placeholder_form_is_unreachable_in_prose(load_script):
|
||||
"""Measured: `<[a-z_]+>` sits inside `\\b...\\b`, so bracketed prose never matches."""
|
||||
scan = load_script("scan_leaks.py")
|
||||
assert scan.exempt_reason("<user>") is None
|
||||
assert scan.exempt_reason("a <user> b") is None
|
||||
assert scan.PLACEHOLDER_HINT.search("a <user> b") is None
|
||||
# Only word-character adjacency reaches it, which is why a bare TARGET is the form that works.
|
||||
assert scan.PLACEHOLDER_HINT.search("x<user>x") is not None
|
||||
assert scan.exempt_reason("TARGET") == "placeholder token"
|
||||
|
||||
|
||||
# --- exemptions --------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("sample,reason", [
|
||||
("x64dbg", "tool or library name"),
|
||||
("WinDbg", "tool or library name"),
|
||||
("IDA", "tool or library name"),
|
||||
("CFF Explorer", "tool or library name"),
|
||||
("the copy is packed with UPX", "public protector/packer/managed tooling name"),
|
||||
("Themida", "public protector/packer/managed tooling name"),
|
||||
("IMAGE_NT_HEADERS", "format, standard or platform term"),
|
||||
("CWE-119", "format, standard or platform term"),
|
||||
("Authenticode", "format, standard or platform term"),
|
||||
("Windows", "platform or vendor-of-the-platform term"),
|
||||
("PowerShell", "platform or vendor-of-the-platform term"),
|
||||
("TARGET", "placeholder token"),
|
||||
("YOURAPP", "placeholder token"),
|
||||
("REDACTED", "placeholder token"),
|
||||
("x<user>x", "placeholder token"),
|
||||
("192.0.2.44", "reserved documentation address"),
|
||||
("198.51.100.7", "reserved documentation address"),
|
||||
("203.0.113.9", "reserved documentation address"),
|
||||
("127.0.0.1", "reserved documentation address"),
|
||||
("S-1-5-18", "well-known SID"),
|
||||
("3f2a1b4c-5d6e-7f80-9a1b-2c3d4e5f6071", "bare GUID is not machine identity"),
|
||||
])
|
||||
def test_exempt_reason_classifies_the_classes_that_must_stay(load_script, sample, reason):
|
||||
"""The reason is asserted by the class it names, not by its exact prose.
|
||||
|
||||
The wording of a reason is a message, and messages get reworded; which exemption class
|
||||
claims a sample is the behaviour under test.
|
||||
"""
|
||||
assert reason in load_script("scan_leaks.py").exempt_reason(sample)
|
||||
|
||||
|
||||
def test_exempt_reason_does_not_excuse_a_real_finding(load_script, corpus):
|
||||
"""A negative outcome: the exemption list must not swallow target identity."""
|
||||
scan = load_script("scan_leaks.py")
|
||||
for rule in ("private-key-block", "github-token", "windows-user-profile", "posix-home",
|
||||
"windows-sid-literal", "volume-serial-literal", "product-name-with-version"):
|
||||
assert scan.exempt_reason(corpus[rule]) is None, rule
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", [
|
||||
"tool name", "protector name", "format term", "platform term", "placeholder token",
|
||||
"documentation address", "well-known SID", "loopback and example", "machine guid context",
|
||||
"net tld",
|
||||
])
|
||||
def test_exemption_classes_are_never_reported(load_script, exemption_corpus, label):
|
||||
assert findings_in(load_script("scan_leaks.py"), exemption_corpus[label]) == [], label
|
||||
|
||||
|
||||
# --- scan_text ---------------------------------------------------------------
|
||||
|
||||
def test_scan_text_reports_position_and_strength(load_script, corpus):
|
||||
scan = load_script("scan_leaks.py")
|
||||
text = "line one is clean\nsecond line: " + corpus["private-key-block"] + "\n"
|
||||
hits = list(scan.scan_text(text))
|
||||
assert len(hits) == 1
|
||||
hit = hits[0]
|
||||
assert hit["line"] == 2
|
||||
assert hit["col"] == 14
|
||||
assert hit["strength"] == "strong"
|
||||
assert hit["category"] == "token"
|
||||
assert hit["rule"] == "private-key-block"
|
||||
assert hit["why"]
|
||||
assert corpus["private-key-block"] in hit["match"]
|
||||
assert hit["context"].startswith("second line:")
|
||||
|
||||
|
||||
def test_clean_text_produces_no_hits_at_all(load_script):
|
||||
"""A negative outcome: a clean note must not produce a single hit of any strength."""
|
||||
scan = load_script("scan_leaks.py")
|
||||
text = ("## Triage\n\nRead the section table with peutil.py and printed the entropy.\n"
|
||||
"The checksum matched the declared value, so the image was not hand-edited.\n")
|
||||
assert list(scan.scan_text(text)) == []
|
||||
|
||||
|
||||
def test_scan_text_does_not_report_the_same_line_as_a_different_rule(load_script, corpus):
|
||||
scan = load_script("scan_leaks.py")
|
||||
hits = scan.scan_text(corpus["generic-secret-assignment"])
|
||||
assert [h["rule"] for h in hits] == ["generic-secret-assignment"]
|
||||
|
||||
|
||||
# --- file selection ----------------------------------------------------------
|
||||
|
||||
def test_iter_files_skips_binaries_and_cache_directories(load_script, tmp_path, corpus):
|
||||
"""A negative outcome: the scanner must not walk binaries, caches or VCS metadata."""
|
||||
scan = load_script("scan_leaks.py")
|
||||
root = tmp_path / "repo"
|
||||
(root / "__pycache__").mkdir(parents=True)
|
||||
(root / ".git").mkdir()
|
||||
(root / "notes.md").write_text("clean\n", encoding="utf-8")
|
||||
(root / "trap.exe").write_bytes(corpus["private-key-block"].encode("ascii") + b"\n")
|
||||
(root / "trap.png").write_bytes(corpus["github-token"].encode("ascii"))
|
||||
(root / "__pycache__" / "cached.md").write_text(corpus["private-key-block"] + "\n",
|
||||
encoding="utf-8")
|
||||
(root / ".git" / "config.md").write_text(corpus["private-key-block"] + "\n",
|
||||
encoding="utf-8")
|
||||
found = sorted(pathlib.Path(p).relative_to(root).as_posix()
|
||||
for p in scan.iter_files(str(root)))
|
||||
assert found == ["notes.md"]
|
||||
|
||||
|
||||
def test_iter_files_honours_an_explicit_file_list(load_script, tmp_path):
|
||||
scan = load_script("scan_leaks.py")
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
(root / "a.md").write_text("a\n", encoding="utf-8")
|
||||
(root / "b.md").write_text("b\n", encoding="utf-8")
|
||||
listing = tmp_path / "listed.txt"
|
||||
listing.write_text("a.md\nmissing.md\n\n", encoding="utf-8")
|
||||
found = sorted(pathlib.Path(p).relative_to(root).as_posix()
|
||||
for p in scan.iter_files(str(root), str(listing)))
|
||||
assert found == ["a.md"]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Unit tests for skills/exe-reverse/scripts/trace_reduce.py, the bit-vector reducer.
|
||||
|
||||
Three layers, each held separately because they make different promises:
|
||||
|
||||
* the rewriting core -- the classic mixed-boolean-arithmetic substitutions reduce to `x + y` and
|
||||
survive a sampling check, and a genuinely different pair is caught with a counterexample;
|
||||
* the SMT emitter -- pure text, checked exactly, because the solver will execute it verbatim and a
|
||||
proof about wrongly-encoded semantics is worse than no proof. The shift-count mask is the case
|
||||
that matters: `evaluate()` masks a count to six bits, and the encoding must do the same or the
|
||||
solver proves a cleaner function than the one the samples ran on;
|
||||
* the proof path -- driven against a stub solver (one line of output) and against a missing path,
|
||||
never against a real solver: CI has none, and a test that needs one is a test that skips.
|
||||
"""
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def parse(tr, text):
|
||||
return tr.Parser(tr.tokenize(text)).parse()
|
||||
|
||||
|
||||
# --- the rewriting core ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_two_classic_substitutions_reduce_to_add(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
for obfuscated in ("(x | y) + (x & y)", "(x ^ y) + 2 * (x & y)"):
|
||||
tree = parse(tr, obfuscated)
|
||||
reduced, steps = tr.reduce_all(tree, tr.MASK[32])
|
||||
assert reduced.text() == "x + y"
|
||||
assert steps, "a reduction with no recorded steps changed nothing"
|
||||
equal, counter, _ = tr.verify(tree, reduced, tr.MASK[32], 64)
|
||||
assert equal, "counterexample: %r" % (counter,)
|
||||
|
||||
|
||||
def test_a_genuinely_different_pair_is_refuted_with_a_witness(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
original, other = parse(tr, "x + y"), parse(tr, "x | y")
|
||||
equal, counter, checked = tr.verify(original, other, tr.MASK[32], 64)
|
||||
assert checked > 0
|
||||
assert not equal
|
||||
# The witness is re-checked here rather than trusted: the point of a counterexample is that it
|
||||
# can be substituted by hand.
|
||||
env = counter["env"]
|
||||
assert tr.evaluate(original, env, tr.MASK[32]) != tr.evaluate(other, env, tr.MASK[32])
|
||||
|
||||
|
||||
# --- the SMT emitter ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_constant_is_emitted_at_the_full_word_width(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
term = parse(tr, "0x2a")
|
||||
assert tr.smt2_term(term, 32) == "#x0000002a"
|
||||
assert tr.smt2_term(term, 8) == "#x2a"
|
||||
|
||||
|
||||
def test_every_operator_maps_to_its_bit_vector_counterpart(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
assert tr.smt2_term(parse(tr, "x + y"), 32) == "(bvadd x y)"
|
||||
assert tr.smt2_term(parse(tr, "x - y"), 32) == "(bvsub x y)"
|
||||
assert tr.smt2_term(parse(tr, "x * y"), 32) == "(bvmul x y)"
|
||||
assert tr.smt2_term(parse(tr, "x & y"), 32) == "(bvand x y)"
|
||||
assert tr.smt2_term(parse(tr, "x | y"), 32) == "(bvor x y)"
|
||||
assert tr.smt2_term(parse(tr, "x ^ y"), 32) == "(bvxor x y)"
|
||||
assert tr.smt2_term(parse(tr, "~x"), 32) == "(bvnot x)"
|
||||
assert tr.smt2_term(parse(tr, "-x"), 32) == "(bvneg x)"
|
||||
|
||||
|
||||
def test_a_shift_count_is_masked_exactly_as_evaluate_masks_it(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
# evaluate() shifts by `count & 63`; the encoding must assert the same thing, or the solver
|
||||
# proves a different function than the one the samples ran on.
|
||||
assert tr.smt2_term(parse(tr, "x << y"), 32) == "(bvshl x (bvand y #x0000003f))"
|
||||
assert tr.smt2_term(parse(tr, "x >> y"), 64) == "(bvlshr x (bvand y #x000000000000003f))"
|
||||
|
||||
|
||||
def test_the_script_declares_variables_asserts_a_difference_and_asks(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
script = tr.smt2_for(parse(tr, "y + x"), parse(tr, "x + y"), 32)
|
||||
lines = script.strip().splitlines()
|
||||
assert lines[0] == "(set-logic QF_BV)"
|
||||
assert "(declare-fun x () (_ BitVec 32))" in lines
|
||||
assert "(declare-fun y () (_ BitVec 32))" in lines
|
||||
assert any(line.startswith("(assert (distinct") for line in lines)
|
||||
assert "(check-sat)" in lines
|
||||
assert "(get-model)" in lines
|
||||
|
||||
|
||||
def test_a_model_is_read_into_an_environment_and_masked(load_script):
|
||||
tr = load_script("trace_reduce.py")
|
||||
model = """
|
||||
sat
|
||||
(
|
||||
(define-fun x () (_ BitVec 32) #x00000001)
|
||||
(define-fun y () (_ BitVec 32) #x00000003)
|
||||
(define-fun ignored () Int 7)
|
||||
)
|
||||
"""
|
||||
assert tr.model_env(model, tr.MASK[32]) == {"x": 1, "y": 3}
|
||||
assert tr.model_env("unsat", tr.MASK[32]) == {}
|
||||
|
||||
|
||||
# --- the proof path, against stubs ----------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsat_solver(tmp_path):
|
||||
"""A stand-in solver that answers `unsat`: one line, no matter the question."""
|
||||
if sys.platform == "win32":
|
||||
path = tmp_path / "unsat.bat"
|
||||
path.write_text("@echo unsat\r\n", encoding="ascii")
|
||||
else:
|
||||
path = tmp_path / "unsat.sh"
|
||||
path.write_text("#!/bin/sh\necho unsat\n", encoding="ascii")
|
||||
path.chmod(0o755)
|
||||
return str(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lying_sat_solver(tmp_path):
|
||||
"""A stand-in that says `sat` and shows a witness that does not distinguish the forms.
|
||||
|
||||
The reducer's own rules are identities, so a real sat never appears in a run of this tool; a
|
||||
lying one is the case that must not be trusted on arrival, and this stub is how that is held.
|
||||
"""
|
||||
body = ("sat\n(\n(define-fun x () (_ BitVec 32) #x00000000)\n"
|
||||
"(define-fun y () (_ BitVec 32) #x00000000)\n)\n")
|
||||
if sys.platform == "win32":
|
||||
path = tmp_path / "sat.bat"
|
||||
path.write_text("@echo sat\r\n@echo (\r\n@echo (define-fun x () (_ BitVec 32) "
|
||||
"#x00000000)\r\n@echo (define-fun y () (_ BitVec 32) #x00000000)\r\n"
|
||||
"@echo )\r\n", encoding="ascii")
|
||||
else:
|
||||
path = tmp_path / "sat.sh"
|
||||
path.write_text("#!/bin/sh\ncat <<'EOF'\n%s\nEOF\n" % body, encoding="ascii")
|
||||
path.chmod(0o755)
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_an_unsat_answer_upgrades_the_verdict_to_proved(load_script, unsat_solver):
|
||||
tr = load_script("trace_reduce.py")
|
||||
original, reduced = parse(tr, "(x ^ y) + 2 * (x & y)"), parse(tr, "x + y")
|
||||
verdict, info = tr.prove(original, reduced, tr.MASK[32], 32, unsat_solver, 10)
|
||||
assert verdict == "proved"
|
||||
|
||||
|
||||
def test_a_witness_that_does_not_distinguish_is_not_believed(load_script, lying_sat_solver):
|
||||
tr = load_script("trace_reduce.py")
|
||||
original, reduced = parse(tr, "(x ^ y) + 2 * (x & y)"), parse(tr, "x + y")
|
||||
verdict, info = tr.prove(original, reduced, tr.MASK[32], 32, lying_sat_solver, 10)
|
||||
assert verdict == "no-verdict"
|
||||
assert "does not distinguish" in info["reason"]
|
||||
|
||||
|
||||
def test_a_missing_solver_path_is_reported_not_crashed(load_script, tmp_path):
|
||||
tr = load_script("trace_reduce.py")
|
||||
original, reduced = parse(tr, "x + y"), parse(tr, "x + y")
|
||||
verdict, info = tr.prove(original, reduced, tr.MASK[32], 32,
|
||||
str(tmp_path / "not-there.exe"), 10)
|
||||
assert verdict == "no-verdict"
|
||||
assert "does not exist" in info["reason"]
|
||||
|
||||
|
||||
def test_prove_with_a_missing_path_is_a_usage_error_at_the_cli(load_script, monkeypatch, capsys,
|
||||
tmp_path):
|
||||
tr = load_script("trace_reduce.py")
|
||||
monkeypatch.setattr(sys, "argv", ["trace_reduce.py", "x + 0",
|
||||
"--prove", str(tmp_path / "absent.exe")])
|
||||
assert tr.main() == 2
|
||||
out = capsys.readouterr()
|
||||
assert "RESULT=usage_error" in out.out
|
||||
|
||||
|
||||
def test_a_proved_run_reports_the_proved_token(load_script, monkeypatch, capsys, unsat_solver):
|
||||
tr = load_script("trace_reduce.py")
|
||||
monkeypatch.setattr(sys, "argv", ["trace_reduce.py", "(x ^ y) + 2 * (x & y)",
|
||||
"--prove", unsat_solver])
|
||||
assert tr.main() == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "RESULT=reduce_proved" in out
|
||||
assert "every input" in out
|
||||
Reference in New Issue
Block a user