reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
186
vendor-new/apk-reverse/tests/unit/test_blob_decode.py
Normal file
186
vendor-new/apk-reverse/tests/unit/test_blob_decode.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""Unit tests for skills/apk-reverse/scripts/blob_decode.py.
|
||||
|
||||
The encoded-blob search is the part worth pinning: the whole method exists
|
||||
because a single un-rotated inflate attempt fails and the failure looks like
|
||||
"this is encrypted". The tests build blobs with known parameters and require the
|
||||
search to recover them.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_script, run_script
|
||||
|
||||
blob_decode = load_script("blob_decode.py")
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
PAYLOAD = json.dumps({
|
||||
"flags": {"newCheckout": True, "minVersion": 4210},
|
||||
"endpoints": ["https://api.example.invalid/v3/config"],
|
||||
"notes": "synthetic payload for the rotation search regression" * 3,
|
||||
}).encode("utf-8")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# outer encodings
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestOuterEncodings:
|
||||
@pytest.mark.parametrize("which", ["base64", "base64url", "hex"])
|
||||
def test_round_trip(self, which):
|
||||
data = bytes(range(64))
|
||||
encoded = blob_decode.outer_encode(data, which)
|
||||
assert blob_decode.outer_decode(encoded, which) == data
|
||||
|
||||
def test_unknown_encoding_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
blob_decode.outer_encode(b"x", "rot13")
|
||||
|
||||
def test_undecodable_hex_returns_empty_not_an_exception(self):
|
||||
"""The search calls this on candidate encodings; a raise would abort it."""
|
||||
assert blob_decode.outer_decode("zzzz", "hex") == b""
|
||||
|
||||
def test_whitespace_and_newlines_are_stripped_before_decoding(self):
|
||||
encoded = blob_decode.outer_encode(b"hello world", "base64")
|
||||
wrapped = "\n".join([encoded[:4], encoded[4:]])
|
||||
assert blob_decode.outer_decode(wrapped, "base64") == b"hello world"
|
||||
|
||||
def test_sniff_offers_all_three_encodings(self):
|
||||
guesses = blob_decode.sniff_outer("aGVsbG8gd29ybGQgdGhpcyBpcyBhIHRlc3Q=")
|
||||
assert set(guesses) >= {"base64", "base64url", "hex"}
|
||||
|
||||
def test_sniff_puts_hex_first_for_hex_shaped_text(self):
|
||||
assert blob_decode.sniff_outer("deadbeef" * 8)[0] == "hex"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# inner transforms
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestInnerTransforms:
|
||||
def test_inflate_raw_round_trips_through_raw_deflate(self):
|
||||
body = blob_decode.inflate_raw(PAYLOAD)
|
||||
assert zlib.decompress(body, -15) == PAYLOAD
|
||||
|
||||
def test_inflate_raw_output_has_no_zlib_header(self):
|
||||
"""A raw stream is what the rotation search needs: no header to mis-align."""
|
||||
body = blob_decode.inflate_raw(b"x" * 200)
|
||||
with pytest.raises(zlib.error):
|
||||
zlib.decompress(body) # default wbits expects a zlib header
|
||||
|
||||
@pytest.mark.parametrize("name,blob", [
|
||||
("raw", zlib.compressobj(9, zlib.DEFLATED, -15)),
|
||||
("zlib", zlib.compressobj(9, zlib.DEFLATED, 15)),
|
||||
("gzip", zlib.compressobj(9, zlib.DEFLATED, 31)),
|
||||
])
|
||||
def test_try_inflate_names_each_inner_transform(self, name, blob):
|
||||
body = blob.compress(PAYLOAD) + blob.flush()
|
||||
got = blob_decode.try_inflate(body)
|
||||
assert got is not None
|
||||
assert got[0] == name
|
||||
assert got[1] == PAYLOAD
|
||||
|
||||
def test_try_inflate_returns_none_for_noise(self):
|
||||
assert blob_decode.try_inflate(bytes(range(256))) is None
|
||||
|
||||
def test_gzip_is_accepted_via_the_stdlib_helper(self):
|
||||
got = blob_decode.try_inflate(gzip.compress(PAYLOAD))
|
||||
assert got is not None and got[0] == "gzip" and got[1] == PAYLOAD
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# scoring
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestScoring:
|
||||
def test_structured_text_scores_above_random_bytes(self):
|
||||
structured = blob_decode.score(PAYLOAD)
|
||||
noise = blob_decode.score(bytes(range(256)) * 4)
|
||||
assert structured > noise
|
||||
|
||||
def test_a_json_header_is_rewarded(self):
|
||||
assert blob_decode.score(b'{"a": 1}') > blob_decode.score(b"aaaaaaaa")
|
||||
|
||||
def test_empty_output_scores_negative(self):
|
||||
assert blob_decode.score(b"") < 0
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the rotation search
|
||||
# --------------------------------------------------------------------------- #
|
||||
def rotate(body, cut):
|
||||
return body[-cut:] + body[:-cut] if cut else body
|
||||
|
||||
|
||||
class TestRotationSearch:
|
||||
def _hits(self, blob, max_skip=0):
|
||||
return blob_decode.search(blob, max_skip, lambda _msg: None)
|
||||
|
||||
def test_search_recovers_a_rotated_raw_stream(self):
|
||||
body = blob_decode.inflate_raw(PAYLOAD)
|
||||
cut = 7
|
||||
hits = self._hits(rotate(body, cut))
|
||||
matching = [h for h in hits if h["data"] == PAYLOAD]
|
||||
assert matching, "the rotation with cut=%d was not recovered" % cut
|
||||
assert matching[0]["cut"] == cut
|
||||
assert matching[0]["inner"] == "raw"
|
||||
assert matching[0]["size"] == len(PAYLOAD)
|
||||
|
||||
def test_search_recovers_a_prefixed_and_rotated_stream(self):
|
||||
"""The `skip` arm: a short header in front of the compressed stream."""
|
||||
body = blob_decode.inflate_raw(PAYLOAD)
|
||||
blob = b"\x01\x02" + rotate(body, 5)
|
||||
hits = self._hits(blob, max_skip=4)
|
||||
matching = [h for h in hits if h["data"] == PAYLOAD]
|
||||
assert matching, "skip=2 + cut=5 was not recovered"
|
||||
assert matching[0]["skip"] == 2
|
||||
|
||||
def test_search_on_noise_returns_no_hits(self):
|
||||
assert self._hits(bytes(range(64))) == []
|
||||
|
||||
def test_scores_are_reported_rounded(self):
|
||||
body = blob_decode.inflate_raw(PAYLOAD)
|
||||
hits = self._hits(body)
|
||||
assert hits
|
||||
assert all(isinstance(h["score"], float) for h in hits)
|
||||
|
||||
def test_the_shortest_accepted_body_is_eight_bytes(self):
|
||||
"""Documented floor: `m < 8` breaks the loop, so tiny blobs yield nothing."""
|
||||
assert self._hits(b"\x01\x02\x03") == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the CLI-level write-back contract
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestEncodePath:
|
||||
def test_encode_then_decode_recovers_the_payload(self, tmp_path):
|
||||
"""The write-back line the tool prints must actually round-trip."""
|
||||
payload_file = tmp_path / "payload.bin"
|
||||
payload_file.write_bytes(PAYLOAD)
|
||||
|
||||
encoded_file = tmp_path / "value.txt"
|
||||
enc = run_script("blob_decode.py",
|
||||
["--encode", "--file", str(payload_file), "--inner", "raw",
|
||||
"--outer", "base64", "--cut", "0", "--out", str(encoded_file)])
|
||||
assert enc.returncode == 0, enc.output
|
||||
|
||||
back = run_script("blob_decode.py",
|
||||
["--file", str(encoded_file), "--outer", "base64",
|
||||
"--min-size", "1", "--out", str(tmp_path / "back.bin")])
|
||||
assert back.returncode == 0, back.output
|
||||
assert (tmp_path / "back.bin").read_bytes() == PAYLOAD
|
||||
|
||||
def test_an_undecodable_blob_exits_one(self, tmp_path):
|
||||
value = tmp_path / "value.txt"
|
||||
value.write_text("not-a-blob-at-all-just-prose-text\n", encoding="utf-8")
|
||||
result = run_script("blob_decode.py", ["--file", str(value), "--outer", "hex"])
|
||||
assert result.returncode == 1, result.output
|
||||
assert not result.has_traceback
|
||||
|
||||
def test_empty_stdin_is_reported_as_invalid_input(self):
|
||||
"""Exit code 2, not 1: an empty input is invalid, not a failed decode."""
|
||||
result = run_script("blob_decode.py", [], stdin=" ")
|
||||
assert result.returncode == 2, result.output
|
||||
assert "empty input" in result.output
|
||||
90
vendor-new/apk-reverse/tests/unit/test_budget_measurement.py
Normal file
90
vendor-new/apk-reverse/tests/unit/test_budget_measurement.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""The budget gate must reach the same verdict whether or not `tiktoken` is installed.
|
||||
|
||||
This is a small test for a defect that cost two red CI runs. `check_budget.py` measures the
|
||||
always-loaded body in tokens, preferring `tiktoken` and falling back to `characters / N` when it is
|
||||
absent. The first version's `N` was guessed (3.6) instead of calibrated, so a machine with tiktoken
|
||||
saw ~10,900 tokens and passed while a machine without it saw ~13,200 and warned -- and this
|
||||
repository's gate runner (CI's 3.11 job, and any contributor's box) is the one without it.
|
||||
|
||||
A gate whose answer depends on which packages the runner happens to have installed is not a gate, so
|
||||
both paths are forced here and compared.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import REPO_ROOT
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def budget():
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
return importlib.import_module("check_budget")
|
||||
|
||||
|
||||
def _has_tiktoken():
|
||||
"""Whether a real tokenizer is available, reported rather than assumed.
|
||||
|
||||
The comparison needs *two* implementations; where there is only one, the honest outcome is a
|
||||
skip with the install command, not a failure and not a silent pass. CI installs it, so the
|
||||
property is verified where it matters; a contributor without it has both gates agreeing anyway,
|
||||
because the fallback is calibrated to the same constant.
|
||||
"""
|
||||
try:
|
||||
import tiktoken # noqa: F401
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_the_fallback_estimate_agrees_with_the_real_tokenizer(budget):
|
||||
"""Both measurement paths must land on the same side of the soft budget."""
|
||||
if not _has_tiktoken():
|
||||
pytest.skip("tiktoken is not installed here, so there is no measurement to compare against; "
|
||||
"install it with `python -m pip install tiktoken`")
|
||||
text = (REPO_ROOT / "skills" / "apk-reverse" / "SKILL.md").read_text(encoding="utf-8")
|
||||
|
||||
real, how_real = budget.token_estimate(text)
|
||||
assert "tiktoken" in how_real, how_real
|
||||
|
||||
# Force the fallback path by handing the module a tokenizer that cannot be imported.
|
||||
saved = sys.modules.get("tiktoken")
|
||||
sys.modules["tiktoken"] = None # `import tiktoken` raises here
|
||||
try:
|
||||
fallback, how_fallback = budget.token_estimate(text)
|
||||
finally:
|
||||
if saved is not None:
|
||||
sys.modules["tiktoken"] = saved
|
||||
else:
|
||||
sys.modules.pop("tiktoken", None)
|
||||
|
||||
assert "estimate" in how_fallback, how_fallback
|
||||
drift = abs(real - fallback) / max(1, real)
|
||||
assert drift < 0.05, (
|
||||
"the fallback is %.1f%% away from tiktoken (%d vs %d): recalibrate TOKENS_PER_CHAR "
|
||||
"against a fresh measurement rather than a guess" % (100 * drift, fallback, real))
|
||||
assert (real <= budget.TOKEN_SOFT) == (fallback <= budget.TOKEN_SOFT), (
|
||||
"the two paths straddle the soft budget (%d real / %d fallback vs %d): the gate would "
|
||||
"answer differently on CI than on a developer machine"
|
||||
% (real, fallback, budget.TOKEN_SOFT))
|
||||
|
||||
|
||||
def test_the_fallback_constant_is_the_calibrated_one(budget):
|
||||
"""Pin the constant to a measurement: `chars / tokens` on SKILL.md, recomputed here."""
|
||||
text = (REPO_ROOT / "skills" / "apk-reverse" / "SKILL.md").read_text(encoding="utf-8")
|
||||
real, how = budget.token_estimate(text)
|
||||
if "tiktoken" not in how:
|
||||
pytest.skip("no tiktoken available, so there is no measurement to calibrate against")
|
||||
expected = len(text) / real
|
||||
assert abs(budget.TOKENS_PER_CHAR - expected) < 0.25, (
|
||||
"TOKENS_PER_CHAR is %.3f but this SKILL.md measures %.3f characters per token -- the "
|
||||
"document changed, so recalibrate" % (budget.TOKENS_PER_CHAR, expected))
|
||||
@@ -0,0 +1,216 @@
|
||||
"""The established regression for scripts/dex_patch_bytes.py.
|
||||
|
||||
This is the repository's pre-existing red line, not a test invented here: the
|
||||
l1l3 benchmark row recorded that patching the first root check in
|
||||
`MainActivity.onCreate` produces a specific file, and any refactor of the dex
|
||||
reader, the spec matcher, the header fixer or the writer must reproduce it
|
||||
byte for byte.
|
||||
|
||||
`tools/_work/bench/**` is git-ignored, so these tests skip with a printed reason
|
||||
on a fresh checkout. That is deliberate: a regression marker that turns red
|
||||
because a sample is absent is a marker people learn to ignore.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import L1L3_PATCHED_SHA256, load_script, require_path, run_script
|
||||
|
||||
dexutil = load_script("dexutil.py")
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
SPEC_NAME = "l1_root.spec.json"
|
||||
SITE_OFF = 0x9D0
|
||||
OLD_BYTES = "39000e00"
|
||||
NEW_BYTES = "00000000"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample(bench_l1l3):
|
||||
"""Paths to the recorded input, spec and expected output."""
|
||||
dex = require_path(bench_l1l3 / "dex" / "classes.dex",
|
||||
"git-ignored bench tree")
|
||||
spec = require_path(bench_l1l3 / SPEC_NAME, "git-ignored bench tree")
|
||||
expected = require_path(bench_l1l3 / "dex" / "classes.patched.dex",
|
||||
"git-ignored bench tree")
|
||||
return {"dex": dex, "spec": spec, "expected": expected}
|
||||
|
||||
|
||||
def patch(sample, tmp_path, extra=()):
|
||||
out = tmp_path / "out.patched.dex"
|
||||
report = tmp_path / "report.json"
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(sample["dex"]), "--spec", str(sample["spec"]),
|
||||
"-o", str(out), "--report", str(report)] + list(extra))
|
||||
return result, out, report
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the hash marker
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestRecordedRegression:
|
||||
def test_the_patch_runs_and_exits_zero(self, sample, tmp_path):
|
||||
result, _out, _report = patch(sample, tmp_path)
|
||||
assert not result.timed_out, repr(result)
|
||||
assert not result.has_traceback, result.output
|
||||
assert result.returncode == 0, result.output
|
||||
|
||||
def test_output_matches_the_recorded_sha256(self, sample, tmp_path):
|
||||
_result, out, _report = patch(sample, tmp_path)
|
||||
digest = hashlib.sha256(out.read_bytes()).hexdigest()
|
||||
assert digest.startswith(L1L3_PATCHED_SHA256), (
|
||||
"the recorded marker moved: got %s, expected prefix %s"
|
||||
% (digest, L1L3_PATCHED_SHA256))
|
||||
|
||||
def test_output_is_byte_identical_to_the_stored_expectation(self, sample, tmp_path):
|
||||
_result, out, _report = patch(sample, tmp_path)
|
||||
assert out.read_bytes() == sample["expected"].read_bytes()
|
||||
|
||||
def test_only_the_four_patch_bytes_and_the_header_change(self, sample, tmp_path):
|
||||
"""Pin *where* the difference is, so a stray write cannot pass as a hash match.
|
||||
|
||||
The allowed window is bytes 8..32 (checksum + signature, recomputed) plus
|
||||
the four bytes of the patched instruction. Two of those four keep their
|
||||
original value (`0e00` -> `0000` clears one byte and leaves the zero
|
||||
byte), and a recomputed checksum may keep a byte by coincidence, so the
|
||||
assertion is containment plus the changes that must occur.
|
||||
"""
|
||||
_result, out, _report = patch(sample, tmp_path)
|
||||
before = sample["dex"].read_bytes()
|
||||
after = out.read_bytes()
|
||||
assert len(before) == len(after), "an equal-length patch must not resize the file"
|
||||
|
||||
diffs = {i for i, (a, b) in enumerate(zip(before, after)) if a != b}
|
||||
window = set(range(8, 32)) | {SITE_OFF, SITE_OFF + 1, SITE_OFF + 2, SITE_OFF + 3}
|
||||
|
||||
assert diffs <= window, "bytes changed outside the owned window: %r" % sorted(
|
||||
diffs - window)
|
||||
# The two bytes that differ between 39000e00 and 00000000 must have moved.
|
||||
assert {SITE_OFF, SITE_OFF + 2} <= diffs
|
||||
# Both header fields are recomputed; each must have changed somewhere.
|
||||
assert diffs & set(range(8, 12)), "no checksum byte changed"
|
||||
assert diffs & set(range(12, 32)), "no signature byte changed"
|
||||
|
||||
def test_the_written_dex_self_verifies(self, sample, tmp_path):
|
||||
_result, out, _report = patch(sample, tmp_path)
|
||||
data = bytearray(out.read_bytes())
|
||||
assert dexutil.verify_dex_header(data) == (True, True)
|
||||
|
||||
def test_the_result_still_parses_and_decodes_cleanly(self, sample, tmp_path):
|
||||
_result, out, _report = patch(sample, tmp_path)
|
||||
dex, _entry = dexutil.load_dex(str(out))
|
||||
assert dex.check() == []
|
||||
found = dex.find_method("Lsg/vantagepoint/uncrackable1/MainActivity;",
|
||||
"onCreate", "(Landroid/os/Bundle;)V")
|
||||
assert found
|
||||
_section, _idx, code_off = found
|
||||
_insns, clean, expected = dex.decode_all(code_off)
|
||||
assert clean, "the patched method must still decode to exactly insns_size units"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the report the tool writes
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestPatchReport:
|
||||
def test_report_records_the_site_and_the_polarity_check(self, sample, tmp_path):
|
||||
_result, _out, report = patch(sample, tmp_path)
|
||||
reports = json.loads(report.read_text(encoding="utf-8"))
|
||||
assert len(reports) == 1
|
||||
entry = reports[0]
|
||||
assert entry["ok"] is True
|
||||
assert entry["site_off"] == SITE_OFF
|
||||
assert entry["old_bytes"] == OLD_BYTES
|
||||
assert entry["new_bytes"] == NEW_BYTES
|
||||
assert entry["expect_next_ok"] is True
|
||||
assert entry["size_delta"] == 0
|
||||
|
||||
def test_report_confirms_the_post_edit_instruction_boundary(self, sample, tmp_path):
|
||||
_result, _out, report = patch(sample, tmp_path)
|
||||
entry = json.loads(report.read_text(encoding="utf-8"))[0]
|
||||
assert entry["post_decode_clean"] is True
|
||||
assert entry["verifier_edges"] == 0
|
||||
|
||||
def test_stdout_states_the_polarity_check_passed(self, sample, tmp_path):
|
||||
result, _out, _report = patch(sample, tmp_path)
|
||||
assert "polarity: expect_next satisfied" in result.stdout
|
||||
|
||||
def test_dry_run_reports_without_writing(self, sample, tmp_path):
|
||||
out = tmp_path / "should-not-exist.dex"
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(sample["dex"]), "--spec", str(sample["spec"]),
|
||||
"-o", str(out), "--dry-run"])
|
||||
assert result.returncode == 0, result.output
|
||||
assert "dry run: nothing written" in result.stdout
|
||||
assert not out.exists()
|
||||
|
||||
def test_default_output_name_is_derived_from_the_input(self, sample, tmp_path):
|
||||
"""Run on a copy: the bench tree is evidence and is never written by this suite."""
|
||||
local = tmp_path / "classes.dex"
|
||||
local.write_bytes(sample["dex"].read_bytes())
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(local), "--spec", str(sample["spec"])])
|
||||
assert result.returncode == 0, result.output
|
||||
derived = tmp_path / "classes.patched.dex"
|
||||
assert derived.exists(), result.output
|
||||
assert hashlib.sha256(derived.read_bytes()).hexdigest() == \
|
||||
hashlib.sha256(sample["expected"].read_bytes()).hexdigest()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# refusal paths: a wrong spec must fail loudly, not write a plausible file
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestRefusals:
|
||||
def test_a_dry_run_against_a_missing_match_fails(self, sample, tmp_path):
|
||||
spec = tmp_path / "bad.spec.json"
|
||||
spec.write_text(json.dumps([{
|
||||
"name": "no such site",
|
||||
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
|
||||
"method": "onCreate",
|
||||
"desc": "(Landroid/os/Bundle;)V",
|
||||
"match": {"kind": "if-testz", "bytes": "deadbeef"},
|
||||
"replace": {"kind": "nops"},
|
||||
}]), encoding="utf-8")
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(sample["dex"]), "--spec", str(spec), "--dry-run"])
|
||||
assert result.returncode == 1, result.output
|
||||
assert "no instruction matched" in result.output
|
||||
|
||||
def test_a_wrong_polarity_pin_is_refused(self, sample, tmp_path):
|
||||
"""expect_next exists to make the wrong side of a branch impossible to patch."""
|
||||
spec = tmp_path / "bad-polarity.spec.json"
|
||||
spec.write_text(json.dumps([{
|
||||
"name": "wrong expected neighbour",
|
||||
"class": "Lsg/vantagepoint/uncrackable1/MainActivity;",
|
||||
"method": "onCreate",
|
||||
"desc": "(Landroid/os/Bundle;)V",
|
||||
"match": {"kind": "if-testz", "bytes": OLD_BYTES},
|
||||
"expect_next": {"kind": "return"},
|
||||
"replace": {"kind": "nops"},
|
||||
}]), encoding="utf-8")
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(sample["dex"]), "--spec", str(spec), "--dry-run"])
|
||||
assert result.returncode == 1, result.output
|
||||
assert "expect_next not satisfied" in result.output
|
||||
|
||||
def test_a_missing_dex_is_a_usage_error(self, tmp_path, sample):
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(tmp_path / "nope.dex"), "--spec", str(sample["spec"])])
|
||||
assert result.returncode == 2, result.output
|
||||
assert "no such file" in result.output
|
||||
|
||||
def test_a_missing_spec_argument_is_a_usage_error(self, sample):
|
||||
result = run_script("dex_patch_bytes.py", [str(sample["dex"])])
|
||||
assert result.returncode == 2, result.output
|
||||
|
||||
def test_a_non_dex_input_is_refused_with_code_1(self, tmp_path, sample):
|
||||
"""Structurally invalid input is a target-side failure (1), not a usage error (2)."""
|
||||
bogus = tmp_path / "bogus.dex"
|
||||
bogus.write_bytes(b"not a dex at all" * 8)
|
||||
result = run_script("dex_patch_bytes.py",
|
||||
[str(bogus), "--spec", str(sample["spec"]), "--dry-run"])
|
||||
assert result.returncode == 1, result.output
|
||||
assert "not a dex" in result.output
|
||||
393
vendor-new/apk-reverse/tests/unit/test_dexutil.py
Normal file
393
vendor-new/apk-reverse/tests/unit/test_dexutil.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""Unit tests for skills/apk-reverse/scripts/dexutil.py.
|
||||
|
||||
The dex format table is the one place where a wrong entry silently shifts every
|
||||
offset after it, so widths are asserted opcode by opcode against the cases the
|
||||
script's own docstring calls out. No device and no sample file are needed for
|
||||
the table itself; the sample-backed assertions skip when the git-ignored bench
|
||||
tree is absent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_script, require_path
|
||||
|
||||
dexutil = load_script("dexutil.py")
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# little-endian scalar readers
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestScalarReaders:
|
||||
def test_u16_and_u32_are_little_endian(self):
|
||||
assert dexutil.u16(b"\x01\x02", 0) == 0x0201
|
||||
assert dexutil.u32(b"\x01\x02\x03\x04", 0) == 0x04030201
|
||||
|
||||
def test_u16_and_u32_read_from_an_offset(self):
|
||||
buf = b"\xff\xff\x34\x12\x78\x56\x34\x12"
|
||||
assert dexutil.u16(buf, 2) == 0x1234
|
||||
assert dexutil.u32(buf, 4) == 0x12345678
|
||||
|
||||
@pytest.mark.parametrize("value,expected", [
|
||||
(0x0000, 0),
|
||||
(0x7FFF, 0x7FFF),
|
||||
(0x8000, -0x8000),
|
||||
(0xFFFF, -1),
|
||||
])
|
||||
def test_s16_boundaries(self, value, expected):
|
||||
assert dexutil.s16(value) == expected
|
||||
|
||||
def test_read_uleb_single_byte(self):
|
||||
assert dexutil.read_uleb(b"\x7f", 0) == (0x7F, 1)
|
||||
|
||||
def test_read_uleb_two_bytes_and_offset(self):
|
||||
value, off = dexutil.read_uleb(b"\x00\x80\x01", 1)
|
||||
assert value == 128
|
||||
assert off == 3
|
||||
|
||||
def test_read_uleb_multi_byte(self):
|
||||
# 0xE5 0x8E 0x26 -> 624485, the example from the format specification
|
||||
value, off = dexutil.read_uleb(b"\xe5\x8e\x26", 0)
|
||||
assert value == 624485
|
||||
assert off == 3
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# instruction width table
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Each case is a trap the module docstring names explicitly.
|
||||
WIDTH_CASES = [
|
||||
(0x00, 1, "plain nop is one unit"),
|
||||
(0x02, 2, "move/from16"),
|
||||
(0x03, 3, "move/16"),
|
||||
(0x13, 2, "const/16"),
|
||||
(0x14, 3, "const"),
|
||||
(0x16, 2, "const-wide/16"),
|
||||
(0x17, 3, "const-wide/32"),
|
||||
(0x18, 5, "const-wide"),
|
||||
(0x1A, 2, "const-string (21c) carries a 16-bit index"),
|
||||
(0x1B, 3, "const-string/jumbo (31c) carries a 32-bit index"),
|
||||
(0x1C, 2, "const-class"),
|
||||
(0x1E, 1, "monitor-exit breaks the same-family reading"),
|
||||
(0x20, 2, "instance-of"),
|
||||
(0x21, 1, "array-length breaks the same-family reading"),
|
||||
(0x27, 1, "throw"),
|
||||
(0x28, 1, "goto (10t) is one unit"),
|
||||
(0x29, 2, "goto/16 (20t)"),
|
||||
(0x2A, 3, "goto/32 (30t)"),
|
||||
(0x32, 2, "if-test (22t) is two units, not one"),
|
||||
(0x37, 2, "last if-test"),
|
||||
(0x38, 2, "if-testz (21t) is two units, not one"),
|
||||
(0x3D, 2, "last if-testz"),
|
||||
(0x6E, 3, "invoke-kind (35c)"),
|
||||
(0x74, 3, "invoke-kind/range (3rc)"),
|
||||
(0x7B, 1, "neg-int"),
|
||||
(0x90, 2, "add-int (23x)"),
|
||||
(0xD0, 2, "add-int/lit16 (22s)"),
|
||||
(0xFA, 4, "invoke-polymorphic (45cc)"),
|
||||
(0xFC, 3, "invoke-custom (35c)"),
|
||||
]
|
||||
|
||||
|
||||
class TestInstructionWidths:
|
||||
@pytest.mark.parametrize("op,units,why", WIDTH_CASES,
|
||||
ids=["%02x" % c[0] for c in WIDTH_CASES])
|
||||
def test_width(self, op, units, why):
|
||||
assert dexutil.insn_units(op, b"\x00" * 32, 0, 32) == units, why
|
||||
|
||||
def test_table_and_accessor_agree_for_every_listed_opcode(self):
|
||||
"""insn_units must not diverge from OP_UNITS for any allocated opcode."""
|
||||
buf = b"\x00" * 32
|
||||
drifted = []
|
||||
for op, units in sorted(dexutil.OP_UNITS.items()):
|
||||
got = dexutil.insn_units(op, buf, 0, len(buf))
|
||||
if got != units:
|
||||
drifted.append((hex(op), units, got))
|
||||
assert drifted == []
|
||||
|
||||
@pytest.mark.parametrize("op", [0x3E, 0x43, 0x73, 0x79, 0x7A, 0xE3, 0xF9])
|
||||
def test_unallocated_opcodes_still_terminate(self, op):
|
||||
"""An unlisted opcode must advance by one unit, not run off the end."""
|
||||
assert dexutil.insn_units(op, b"\x00" * 8, 0, 8) == 1
|
||||
|
||||
def test_packed_switch_payload_width(self):
|
||||
# 00 01 | size=3 -> 4 + 3*2 = 10 units
|
||||
buf = bytes([0x00, 0x01]) + (3).to_bytes(2, "little") + b"\x00" * 12
|
||||
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 10
|
||||
|
||||
def test_sparse_switch_payload_width(self):
|
||||
# 00 02 | size=3 -> 2 + 3*4 = 14 units
|
||||
buf = bytes([0x00, 0x02]) + (3).to_bytes(2, "little") + b"\x00" * 24
|
||||
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 14
|
||||
|
||||
def test_fill_array_data_payload_width(self):
|
||||
# 00 03 | element_width=4 | size=30 -> 4 + ceil(30*4/2) = 64 units.
|
||||
# The measured case named in the docstring: counting this as 5 units is
|
||||
# what desynchronises the rest of the walk.
|
||||
buf = bytes([0x00, 0x03]) + (4).to_bytes(2, "little") + (30).to_bytes(4, "little")
|
||||
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 64
|
||||
|
||||
def test_fill_array_data_payload_pads_to_an_even_byte_count(self):
|
||||
# element_width=1, size=3 -> 3 bytes pads to 4 -> 4 + 2 = 6 units
|
||||
buf = bytes([0x00, 0x03]) + (1).to_bytes(2, "little") + (3).to_bytes(4, "little")
|
||||
assert dexutil.insn_units(0x00, buf, 0, len(buf)) == 6
|
||||
|
||||
def test_plain_nop_is_not_treated_as_a_payload(self):
|
||||
assert dexutil.insn_units(0x00, b"\x00\x00\x00\x00", 0, 4) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# header parsing and the structural check
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Verified dex header offsets, as declared by dexutil.OFFSETS. Asserted rather
|
||||
# than assumed: these are the values a hand-written reader most often gets wrong.
|
||||
HEADER_OFFSETS = {
|
||||
"file_size": 0x20, "header_size": 0x24, "endian_tag": 0x28,
|
||||
"map_off": 0x34, "string_ids_off": 0x3C, "type_ids_off": 0x44,
|
||||
"proto_ids_off": 0x4C, "field_ids_off": 0x54, "method_ids_off": 0x5C,
|
||||
"class_defs_off": 0x64,
|
||||
}
|
||||
|
||||
# The six table pointers dex.check() requires to be inside the file.
|
||||
TABLE_POINTERS = ("string_ids_off", "type_ids_off", "proto_ids_off",
|
||||
"field_ids_off", "method_ids_off", "class_defs_off")
|
||||
|
||||
|
||||
def synthetic_dex(size=512):
|
||||
"""A dex-shaped buffer whose header is internally consistent.
|
||||
|
||||
Enough for the structural reader: magic, a matching file_size, and the six
|
||||
table pointers inside the file. It is deliberately not a runnable dex, so
|
||||
nothing here claims parse-level validity of a real image.
|
||||
"""
|
||||
buf = bytearray(size)
|
||||
|
||||
def put(offset, value, width=4):
|
||||
buf[offset:offset + width] = value.to_bytes(width, "little")
|
||||
|
||||
buf[0:8] = b"dex\n035\x00"
|
||||
put(HEADER_OFFSETS["file_size"], size)
|
||||
put(HEADER_OFFSETS["header_size"], 0x70)
|
||||
put(HEADER_OFFSETS["endian_tag"], 0x12345678)
|
||||
put(HEADER_OFFSETS["map_off"], 0x70)
|
||||
for key in TABLE_POINTERS:
|
||||
# size 0, pointer 0x70: every pointer lands inside the file.
|
||||
put(HEADER_OFFSETS[key] - 4, 0)
|
||||
put(HEADER_OFFSETS[key], 0x70)
|
||||
return buf
|
||||
|
||||
|
||||
class TestHeaderParsing:
|
||||
def test_the_offset_table_matches_the_format_specification(self):
|
||||
for key, expected in HEADER_OFFSETS.items():
|
||||
assert dexutil.OFFSETS[key] == expected, key
|
||||
|
||||
def test_header_fields_are_parsed_out_of_the_buffer(self):
|
||||
dex = dexutil.Dex(bytes(synthetic_dex(512)))
|
||||
assert dex.header["file_size"] == 512
|
||||
assert dex.header["header_size"] == 0x70
|
||||
assert dex.header["string_ids_off"] == 0x70
|
||||
|
||||
def test_magic_is_parsed(self):
|
||||
dex = dexutil.Dex(bytes(synthetic_dex(256)))
|
||||
assert dex.d[:4] == b"dex\n"
|
||||
|
||||
def test_consistent_header_reports_no_problems(self):
|
||||
dex = dexutil.Dex(bytes(synthetic_dex(512)))
|
||||
assert dex.check() == []
|
||||
|
||||
def test_bad_magic_is_reported(self):
|
||||
buf = synthetic_dex(512)
|
||||
buf[0:4] = b"zip\n"
|
||||
assert any("not a dex" in p for p in dexutil.Dex(bytes(buf)).check())
|
||||
|
||||
def test_file_size_mismatch_is_reported(self):
|
||||
buf = synthetic_dex(512)
|
||||
buf[HEADER_OFFSETS["file_size"]:HEADER_OFFSETS["file_size"] + 4] = \
|
||||
(999).to_bytes(4, "little")
|
||||
assert any("file_size" in p for p in dexutil.Dex(bytes(buf)).check())
|
||||
|
||||
def test_out_of_range_table_pointer_is_reported(self):
|
||||
buf = synthetic_dex(512)
|
||||
at = HEADER_OFFSETS["string_ids_off"]
|
||||
buf[at:at + 4] = (0).to_bytes(4, "little")
|
||||
assert any("string_ids_off" in p for p in dexutil.Dex(bytes(buf)).check())
|
||||
|
||||
def test_string_safe_never_raises_on_a_junk_index(self):
|
||||
"""A desynced decode passes junk indices; the reader must not throw."""
|
||||
dex = dexutil.Dex(bytes(synthetic_dex(512)))
|
||||
assert dex.string_safe(10**6).startswith("<string_idx")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# branch targets
|
||||
# --------------------------------------------------------------------------- #
|
||||
def fake_dex(buf):
|
||||
"""A Dex instance over raw bytes, without header parsing.
|
||||
|
||||
branch_target needs only self.d plus the instruction dict, so this keeps the
|
||||
displacement arithmetic testable without a whole valid dex image.
|
||||
"""
|
||||
dex = dexutil.Dex.__new__(dexutil.Dex)
|
||||
dex.d = bytes(buf)
|
||||
dex.name = "<synthetic>"
|
||||
return dex
|
||||
|
||||
|
||||
def insn_at_zero(buf, op, raw_len):
|
||||
return {"off": 0, "op": op, "raw": bytes(buf[:raw_len]),
|
||||
"units": raw_len // 2, "name": "synthetic"}
|
||||
|
||||
|
||||
class TestBranchTargets:
|
||||
def test_if_testz_negative_displacement(self):
|
||||
buf = bytes([0x38, 0x00]) + (-1).to_bytes(2, "little", signed=True)
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x38, 4)) == -2
|
||||
|
||||
def test_if_testz_positive_displacement(self):
|
||||
buf = bytes([0x3D, 0x01]) + (2).to_bytes(2, "little", signed=True)
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x3D, 4)) == 4
|
||||
|
||||
def test_if_test_two_registers(self):
|
||||
buf = bytes([0x32, 0x10]) + (-4).to_bytes(2, "little", signed=True)
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x32, 4)) == -8
|
||||
|
||||
def test_goto_signed_byte(self):
|
||||
# 10t: op, int8. 0xFE -> -2 units -> -4 bytes from the instruction.
|
||||
buf = bytes([0x28, 0xFE])
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x28, 2)) == -4
|
||||
|
||||
def test_goto16(self):
|
||||
buf = bytes([0x29, 0x00]) + (3).to_bytes(2, "little", signed=True)
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x29, 4)) == 6
|
||||
|
||||
def test_goto32_negative(self):
|
||||
buf = bytes([0x2A, 0x00]) + (-1).to_bytes(4, "little", signed=True)
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x2A, 6)) == -2
|
||||
|
||||
def test_non_branch_returns_none(self):
|
||||
buf = bytes([0x0E, 0x00])
|
||||
assert fake_dex(buf).branch_target(insn_at_zero(buf, 0x0E, 2)) is None
|
||||
|
||||
def test_branch_regs_if_testz_reads_one_register(self):
|
||||
buf = bytes([0x38, 0x07]) + (-1).to_bytes(2, "little", signed=True)
|
||||
regs, kind = fake_dex(buf).branch_regs(insn_at_zero(buf, 0x38, 4))
|
||||
assert regs == [7]
|
||||
assert kind == "if-testz"
|
||||
|
||||
def test_branch_regs_if_test_reads_two_registers(self):
|
||||
buf = bytes([0x32, 0x21]) + (-1).to_bytes(2, "little", signed=True)
|
||||
regs, kind = fake_dex(buf).branch_regs(insn_at_zero(buf, 0x32, 4))
|
||||
assert regs == [2, 1]
|
||||
assert kind == "if-test"
|
||||
|
||||
def test_branch_regs_is_empty_for_a_non_branch(self):
|
||||
buf = bytes([0x0E, 0x00])
|
||||
assert fake_dex(buf).branch_regs(insn_at_zero(buf, 0x0E, 2)) == ([], None)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# header integrity: signature before checksum
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestHeaderIntegrity:
|
||||
def test_fix_then_verify_round_trips(self):
|
||||
data = bytearray(synthetic_dex(512))
|
||||
dexutil.fix_dex_header(data)
|
||||
assert dexutil.verify_dex_header(data) == (True, True)
|
||||
|
||||
def test_signature_covers_the_bytes_after_the_signature_field(self):
|
||||
data = bytearray(synthetic_dex(512))
|
||||
dexutil.fix_dex_header(data)
|
||||
assert bytes(data[12:32]) == hashlib.sha1(bytes(data[32:])).digest()
|
||||
|
||||
def test_checksum_covers_the_signature_field(self):
|
||||
"""The order requirement: adler32 over d[12:] includes the written signature."""
|
||||
data = bytearray(synthetic_dex(512))
|
||||
dexutil.fix_dex_header(data)
|
||||
stored = int.from_bytes(data[8:12], "little")
|
||||
assert stored == zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF
|
||||
|
||||
def test_checksum_taken_over_a_zeroed_signature_does_not_verify(self):
|
||||
"""The failure mode the module docstring names: the header never verifies."""
|
||||
data = bytearray(synthetic_dex(512))
|
||||
data[12:32] = b"\x00" * 20
|
||||
data[8:12] = (zlib.adler32(bytes(data[12:])) & 0xFFFFFFFF).to_bytes(4, "little")
|
||||
data[12:32] = hashlib.sha1(bytes(data[32:])).digest()
|
||||
checksum_ok, _signature_ok = dexutil.verify_dex_header(data)
|
||||
assert checksum_ok is False
|
||||
|
||||
def test_a_tampered_body_fails_verification(self):
|
||||
data = bytearray(synthetic_dex(512))
|
||||
dexutil.fix_dex_header(data)
|
||||
data[-1] ^= 0xFF
|
||||
assert dexutil.verify_dex_header(data) == (False, False)
|
||||
|
||||
def test_fix_reports_before_and_after_values(self):
|
||||
data = bytearray(synthetic_dex(512))
|
||||
report = dexutil.fix_dex_header(data)
|
||||
assert set(report) == {"before_checksum", "after_checksum",
|
||||
"before_signature", "after_signature"}
|
||||
assert report["before_checksum"] != report["after_checksum"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sample-backed assertions (skip when the git-ignored bench tree is absent)
|
||||
# --------------------------------------------------------------------------- #
|
||||
SAMPLE_CLASS = "Lsg/vantagepoint/uncrackable1/MainActivity;"
|
||||
SAMPLE_METHOD = ("onCreate", "(Landroid/os/Bundle;)V")
|
||||
SAMPLE_SITE = 0x9D0
|
||||
SAMPLE_SITE_BYTES = "39000e00"
|
||||
|
||||
|
||||
class TestAgainstTheRecordedSample:
|
||||
"""The l1l3 fixture is git-ignored, so these skip in CI by design."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _need_sample(self, bench_l1l3):
|
||||
self.dex_path = require_path(
|
||||
bench_l1l3 / "dex" / "classes.dex",
|
||||
"the l1l3 sample is a download into the git-ignored bench tree")
|
||||
|
||||
def _method(self):
|
||||
dex, _entry = dexutil.load_dex(str(self.dex_path))
|
||||
found = dex.find_method(SAMPLE_CLASS, *SAMPLE_METHOD)
|
||||
assert found, "the recorded fixture must still contain MainActivity.onCreate"
|
||||
_section, _idx, code_off = found
|
||||
return dex, code_off
|
||||
|
||||
def test_sample_dex_parses_cleanly(self):
|
||||
dex, _entry = dexutil.load_dex(str(self.dex_path))
|
||||
assert dex.check() == []
|
||||
|
||||
def test_decode_walks_exactly_insns_size_units(self):
|
||||
dex, code_off = self._method()
|
||||
insns, clean, expected = dex.decode_all(code_off)
|
||||
assert clean, "decode did not land on 0x%x" % expected
|
||||
assert insns
|
||||
|
||||
def test_the_recorded_root_check_branch_is_at_0x9d0(self):
|
||||
dex, code_off = self._method()
|
||||
target = dex.insn_at(code_off, SAMPLE_SITE)
|
||||
assert target is not None, "instruction boundary at 0x%x disappeared" % SAMPLE_SITE
|
||||
assert target["raw"].hex() == SAMPLE_SITE_BYTES
|
||||
assert target["op"] in dexutil.IF_TESTZ
|
||||
|
||||
def test_branch_target_resolves_to_an_instruction_boundary(self):
|
||||
dex, code_off = self._method()
|
||||
target = dex.insn_at(code_off, SAMPLE_SITE)
|
||||
dest = dex.branch_target(target)
|
||||
assert dest is not None
|
||||
assert dex.insn_at(code_off, dest) is not None, (
|
||||
"branch target 0x%x is not an instruction boundary" % dest)
|
||||
|
||||
def test_every_branch_target_is_an_instruction_boundary(self):
|
||||
"""A decode desync shows up here first: a branch into the middle of an insn."""
|
||||
dex, code_off = self._method()
|
||||
boundaries = {i["off"] for i in dex.decode(code_off)}
|
||||
stray = sorted(t for t in dex.all_targets(code_off) if t not in boundaries)
|
||||
assert stray == [], "branch targets outside the instruction stream: %r" % stray
|
||||
192
vendor-new/apk-reverse/tests/unit/test_protobuf_decode_raw.py
Normal file
192
vendor-new/apk-reverse/tests/unit/test_protobuf_decode_raw.py
Normal file
@@ -0,0 +1,192 @@
|
||||
"""Unit tests for skills/apk-reverse/scripts/protobuf_decode_raw.py.
|
||||
|
||||
Two properties are pinned here, because both are claims the reference material
|
||||
makes and neither is visible from reading the decoder:
|
||||
|
||||
* the built-in known-answer fixture set passes in full (26/26), and
|
||||
* a decode -> re-encode round trip is byte-identical for every fixture except
|
||||
the one fixture that is deliberately non-canonical, which must instead
|
||||
report *why* it differs rather than silently normalising.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_script, run_script
|
||||
|
||||
pb = load_script("protobuf_decode_raw.py")
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
# 12 fixtures x (fixture check + round trip) + 2 wire-format trap checks.
|
||||
EXPECTED_FIXTURE_COUNT = 12
|
||||
EXPECTED_TOTAL_CHECKS = 26
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def fixtures():
|
||||
return pb._fixtures()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# varint primitives
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestVarintPrimitives:
|
||||
def test_canonical_example(self):
|
||||
assert pb.encode_varint(150) == bytes.fromhex("9601")
|
||||
|
||||
def test_read_varint_returns_info_next_index_and_no_error(self):
|
||||
info, nxt, err = pb.read_varint(bytes.fromhex("9601"), 0, 2)
|
||||
assert err is None
|
||||
assert info["value"] == 150
|
||||
assert info["bytes"] == 2
|
||||
assert nxt == 2
|
||||
|
||||
def test_a_truncated_varint_is_an_error_not_a_value(self):
|
||||
info, nxt, err = pb.read_varint(bytes.fromhex("96"), 0, 1)
|
||||
assert info is None
|
||||
assert err and "truncated" in err
|
||||
assert nxt == 0
|
||||
|
||||
def test_a_non_canonical_encoding_is_flagged(self):
|
||||
"""A padded varint must be reported: it is why re-encode cannot be assumed equal."""
|
||||
info, _nxt, _err = pb.read_varint(bytes.fromhex("8000"), 0, 2)
|
||||
assert info["value"] == 0
|
||||
assert info["non_canonical"] is True
|
||||
assert info["canonical_bytes"] == 1
|
||||
|
||||
def test_varint_len_matches_the_encoding(self):
|
||||
for value in (0, 1, 127, 128, 300, 2 ** 31, 2 ** 63 - 1):
|
||||
assert pb.varint_len(value) == len(pb.encode_varint(value))
|
||||
|
||||
def test_zigzag(self):
|
||||
assert pb.zigzag_decode(0) == 0
|
||||
assert pb.zigzag_decode(1) == -1
|
||||
assert pb.zigzag_decode(2) == 1
|
||||
assert pb.zigzag_decode(4294967294) == 2147483647
|
||||
|
||||
def test_int_views_wrap(self):
|
||||
assert pb.int32_view(0xFFFFFFFF) == -1
|
||||
assert pb.int64_view(0xFFFFFFFFFFFFFFFF) == -1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the built-in fixture set
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestFixtureSet:
|
||||
def test_the_selftest_function_passes_end_to_end(self, capsys):
|
||||
assert pb.selftest(None) == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "selftest: %d/%d PASS" % (EXPECTED_TOTAL_CHECKS, EXPECTED_TOTAL_CHECKS) in out
|
||||
assert "FAIL" not in out
|
||||
|
||||
def test_fixture_count_is_stable(self, fixtures):
|
||||
"""The 26/26 headline is 12 fixtures x 2 + 2; a silent drop would hide."""
|
||||
assert len(fixtures) == EXPECTED_FIXTURE_COUNT
|
||||
|
||||
def test_every_fixture_has_a_name_payload_and_expectations(self, fixtures):
|
||||
for name, payload, expects, _rt in fixtures:
|
||||
assert isinstance(name, str) and name
|
||||
assert isinstance(payload, (bytes, bytearray)) and payload
|
||||
assert expects, "fixture %s has no assertions" % name
|
||||
|
||||
def test_each_declared_expectation_holds(self, fixtures):
|
||||
failures = []
|
||||
for name, payload, expects, _rt in fixtures:
|
||||
ctx = pb.Ctx(max_depth=6)
|
||||
frames = pb._fixture_frames(name, payload)
|
||||
fields = []
|
||||
for start, end, _p in frames:
|
||||
found, _n, _s = pb.walk(payload, start, end, 0, ctx, "f", analyse=True)
|
||||
fields.extend(found)
|
||||
for label, num, expected in expects:
|
||||
got = pb._selftest_lookup(fields, frames, payload, label, num)
|
||||
if got != expected:
|
||||
failures.append((name, label, expected, got))
|
||||
assert failures == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# round trip
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestRoundTrip:
|
||||
def _reencode(self, name, payload):
|
||||
frames = pb._fixture_frames(name, payload)
|
||||
tree = {"frames": [{"fields": pb.walk(payload, s, e, 0, pb.Ctx(6), "f",
|
||||
analyse=True)[0]}
|
||||
for s, e, _p in frames]}
|
||||
report = {"fields": 0, "identical": 0, "mismatched": []}
|
||||
out = pb.reencode_tree(tree, report)
|
||||
expected = b"".join(payload[s:e] for s, e, _p in frames)
|
||||
return out, expected, report
|
||||
|
||||
def test_canonical_fixtures_reencode_byte_identically(self, fixtures):
|
||||
differing = []
|
||||
for name, payload, _expects, rt in fixtures:
|
||||
if rt == "non-canonical":
|
||||
continue
|
||||
out, expected, _report = self._reencode(name, payload)
|
||||
if out != expected:
|
||||
differing.append((name, expected.hex(), out.hex()))
|
||||
assert differing == []
|
||||
|
||||
def test_the_non_canonical_fixture_reports_why_it_differs(self, fixtures):
|
||||
"""A normalising re-encode must say so, not return different bytes quietly."""
|
||||
targets = [f for f in fixtures if f[3] == "non-canonical"]
|
||||
assert targets, "the non-canonical fixture disappeared from the set"
|
||||
for name, payload, _expects, _rt in targets:
|
||||
out, expected, report = self._reencode(name, payload)
|
||||
assert out != expected
|
||||
assert out.hex() == "089601"
|
||||
assert report["mismatched"], "the mismatch must be recorded"
|
||||
assert "non-canonical" in report["mismatched"][0]["reason"]
|
||||
|
||||
def test_reencode_accounts_for_every_field(self, fixtures):
|
||||
for name, payload, _expects, rt in fixtures:
|
||||
_out, _expected, report = self._reencode(name, payload)
|
||||
assert report["fields"] > 0, name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the two wire-format traps, asserted rather than narrated
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestWireFormatTraps:
|
||||
def test_explicit_zero_is_not_the_same_bytes_as_absent(self):
|
||||
zero = pb.tag_0(5, 0) + pb.encode_varint(0)
|
||||
assert zero != b""
|
||||
|
||||
def test_explicit_zero_decodes_to_value_zero(self):
|
||||
zero = pb.tag_0(5, 0) + pb.encode_varint(0)
|
||||
fields = pb.walk(zero, 0, len(zero), 0, pb.Ctx(6), "f")[0]
|
||||
assert fields[0]["value"] == 0
|
||||
|
||||
def test_absent_field_decodes_to_no_fields(self):
|
||||
assert pb.walk(b"", 0, 0, 0, pb.Ctx(6), "f")[0] == []
|
||||
|
||||
def test_a_packed_payload_offers_both_readings(self):
|
||||
"""Three varints are also a valid nested message; only a schema decides."""
|
||||
packed = b"".join(pb.encode_varint(v) for v in (3, 270, 86942))
|
||||
cands, view = pb.analyse_payload(packed, 0, pb.Ctx(6), "f", 0)
|
||||
live = sorted(c["kind"] for c in cands if c["confidence"] > 0.0)
|
||||
all_kinds = sorted(c["kind"] for c in cands)
|
||||
assert "packed_varint" in live
|
||||
assert "nested_message" in all_kinds
|
||||
assert view == "packed_varint"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the subprocess contract
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestSelftestCli:
|
||||
def test_selftest_subcommand_exits_zero_and_prints_the_tally(self):
|
||||
result = run_script("protobuf_decode_raw.py", ["--selftest"], timeout=180)
|
||||
assert not result.timed_out
|
||||
assert not result.has_traceback, result.output
|
||||
assert result.returncode == 0, result.output
|
||||
assert "selftest: %d/%d PASS" % (EXPECTED_TOTAL_CHECKS, EXPECTED_TOTAL_CHECKS) \
|
||||
in result.stdout
|
||||
|
||||
def test_help_lists_the_selftest_flag(self):
|
||||
result = run_script("protobuf_decode_raw.py", ["--help"])
|
||||
assert result.returncode == 0
|
||||
assert "--selftest" in result.stdout
|
||||
198
vendor-new/apk-reverse/tests/unit/test_scan_leaks.py
Normal file
198
vendor-new/apk-reverse/tests/unit/test_scan_leaks.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Unit tests for skills/apk-reverse/scripts/scan_leaks.py.
|
||||
|
||||
Decoys are built in a temporary directory at run time. No real target identity
|
||||
is committed here, and the token-shaped decoys are assembled from fragments so
|
||||
that this test file itself cannot become a finding if the scanner is ever
|
||||
pointed at the tests tree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import load_script, run_script
|
||||
|
||||
scan_leaks = load_script("scan_leaks.py")
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# Synthetic identifiers: reverse-domain-shaped, not derived from any real target.
|
||||
DECOY_PACKAGE = "com." + "acme" + ".targetapp"
|
||||
DECOY_TOKEN = "AbCdEf" + "0123456789" + "XyZ"
|
||||
DECOY_USER = "mkessler"
|
||||
|
||||
|
||||
def write(root, name, body):
|
||||
path = root / name
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def scan(root, *extra, timeout=120):
|
||||
"""Run the scanner over a temporary root and parse its JSON report."""
|
||||
result = run_script("scan_leaks.py",
|
||||
["--root", str(root), "--format", "json"] + list(extra),
|
||||
timeout=timeout)
|
||||
assert not result.timed_out, "scan_leaks.py did not finish: %r" % result
|
||||
assert not result.has_traceback, result.output
|
||||
# The JSON document is printed first, then the RESULT token line.
|
||||
lines = result.stdout.splitlines()
|
||||
token = lines[-1] if lines else ""
|
||||
doc = json.loads(result.stdout[:result.stdout.rindex("}") + 1])
|
||||
return result, doc, token
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the exemption list as data
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestExemptions:
|
||||
@pytest.mark.parametrize("value", [
|
||||
"com.android.internal",
|
||||
"androidx.core.app",
|
||||
"com.google.firebase",
|
||||
"org.example.demo",
|
||||
"<pkg>",
|
||||
"${PKG}",
|
||||
"sg.vantagepoint.uncrackable1",
|
||||
])
|
||||
def test_benign_values_carry_an_exemption_reason(self, value):
|
||||
assert scan_leaks._benign_package(value) != "", value
|
||||
|
||||
@pytest.mark.parametrize("value", [
|
||||
DECOY_PACKAGE,
|
||||
"com.unknownvendor.someapp",
|
||||
])
|
||||
def test_unknown_reverse_domain_values_are_reportable(self, value):
|
||||
assert scan_leaks._benign_package(value) == "", value
|
||||
|
||||
def test_loopback_and_documentation_addresses_are_distinguished(self):
|
||||
"""Loopback is exempt; an RFC 5737 documentation address is reported on purpose."""
|
||||
assert scan_leaks._benign_ip("127.0.0.1:8080") != ""
|
||||
assert scan_leaks._benign_ip("192.0.2.10:8080") == ""
|
||||
|
||||
def test_placeholder_user_paths_are_exempt(self):
|
||||
assert scan_leaks._benign_path("C:\\Users\\<user>\\work") != ""
|
||||
assert scan_leaks._benign_path("/home/" + DECOY_USER + "/work") == ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# decoys that must be reported
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestDecoyDetection:
|
||||
def test_strong_decoys_are_reported(self, tmp_path):
|
||||
write(tmp_path, "notes.md", "\n".join([
|
||||
'package="%s"' % DECOY_PACKAGE,
|
||||
"Authorization: Bearer " + DECOY_TOKEN,
|
||||
"/home/%s/work/build/classes.dex" % DECOY_USER,
|
||||
]) + "\n")
|
||||
|
||||
result, doc, token = scan(tmp_path)
|
||||
|
||||
rules = sorted({f["rule"] for f in doc["findings"]})
|
||||
assert "bundle_pkg_attr" in rules
|
||||
assert "token_authorization_header" in rules
|
||||
assert "user_path_posix" in rules
|
||||
assert all(f["strength"] in ("strong", "certain") for f in doc["findings"])
|
||||
assert token == "RESULT=leaks_found"
|
||||
assert result.returncode == 1, result.output
|
||||
|
||||
def test_findings_carry_context_not_just_a_line_number(self, tmp_path):
|
||||
write(tmp_path, "notes.md", 'package="%s"\n' % DECOY_PACKAGE)
|
||||
_result, doc, _token = scan(tmp_path)
|
||||
finding = doc["findings"][0]
|
||||
assert finding["context"].strip()
|
||||
assert finding["match"] == DECOY_PACKAGE
|
||||
assert finding["line"] == 1
|
||||
assert finding["file"] == "notes.md"
|
||||
|
||||
def test_device_serial_shaped_token_is_reported(self, tmp_path):
|
||||
# 16 uppercase alphanumerics with at least one letter, next to a device word.
|
||||
serial = "R58M" + "12ABCDEF" + "9K7Q"
|
||||
assert len(serial) == 16
|
||||
write(tmp_path, "adb.txt", "device %s\n" % serial)
|
||||
_result, doc, token = scan(tmp_path)
|
||||
assert any(f["rule"].startswith("device_serial") for f in doc["findings"]), doc
|
||||
assert token == "RESULT=leaks_found"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# decoys that must not be reported
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestExemptDecoysStaySilent:
|
||||
def test_benign_identifiers_produce_no_findings(self, tmp_path):
|
||||
write(tmp_path, "notes.md", "\n".join([
|
||||
'package="com.android.internal"',
|
||||
'package="com.google.firebase.messaging"',
|
||||
'package="<pkg>"',
|
||||
"endpoint 127.0.0.1:8080",
|
||||
"path C:\\Users\\<user>\\work\\x.dex",
|
||||
"public crackme sg.vantagepoint.uncrackable1",
|
||||
]) + "\n")
|
||||
|
||||
result, doc, token = scan(tmp_path)
|
||||
|
||||
assert doc["findings"] == [], doc["findings"]
|
||||
assert token == "RESULT=clean"
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_exempted_hits_are_kept_and_auditable_with_show_exempt(self, tmp_path):
|
||||
"""The exemption list must be inspectable, not just silent."""
|
||||
write(tmp_path, "notes.md", 'package="com.android.internal"\n')
|
||||
_result, doc, _token = scan(tmp_path, "--show-exempt")
|
||||
assert doc["findings"] == []
|
||||
assert doc["exempted"], "an exempted hit must still be recorded"
|
||||
assert doc["exempted"][0]["exempt_reason"]
|
||||
|
||||
def test_a_weak_only_hit_keeps_the_default_gate_green(self, tmp_path):
|
||||
"""Documented behaviour: an RFC 5737 address is reported but does not fail --fail-on strong.
|
||||
|
||||
Note the measured detail: `ip:port` satisfies both endpoint rules, so one
|
||||
address is reported twice (once as the `ip:port` form, once as the bare
|
||||
literal). Recorded here rather than smoothed over -- it is a report-volume
|
||||
question, not a missed or invented finding.
|
||||
"""
|
||||
write(tmp_path, "notes.md", "endpoint 192.0.2.10:8080\n")
|
||||
|
||||
result, doc, token = scan(tmp_path)
|
||||
assert {f["strength"] for f in doc["findings"]} == {"weak"}
|
||||
assert {f["rule"] for f in doc["findings"]} == {"plain_addr", "plain_addr_ipliteral"}
|
||||
assert token == "RESULT=leaks_found_strong_only"
|
||||
assert result.returncode == 0
|
||||
|
||||
strict, _doc2, strict_token = scan(tmp_path, "--fail-on", "any")
|
||||
assert strict_token == "RESULT=leaks_found"
|
||||
assert strict.returncode == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the machine-readable contract
|
||||
# --------------------------------------------------------------------------- #
|
||||
class TestReportContract:
|
||||
def test_json_report_has_the_documented_keys(self, tmp_path):
|
||||
write(tmp_path, "notes.md", 'package="%s"\n' % DECOY_PACKAGE)
|
||||
_result, doc, _token = scan(tmp_path)
|
||||
assert set(doc) >= {"root", "files_scanned", "findings", "exempted",
|
||||
"errors", "result", "fail_on"}
|
||||
assert doc["result"] == "leaks_found"
|
||||
assert doc["fail_on"] == "strong"
|
||||
|
||||
def test_result_token_is_always_the_last_line(self, tmp_path):
|
||||
write(tmp_path, "notes.md", 'package="%s"\n' % DECOY_PACKAGE)
|
||||
result, _doc, token = scan(tmp_path)
|
||||
assert result.stdout.splitlines()[-1] == token
|
||||
assert token.startswith("RESULT=")
|
||||
|
||||
def test_list_rules_prints_the_table_and_exits_zero(self):
|
||||
result = run_script("scan_leaks.py", ["--list-rules"])
|
||||
assert result.returncode == 0, result.output
|
||||
assert not result.has_traceback
|
||||
for rule_id in ("bundle_pkg_attr", "device_serial", "token_github_pat",
|
||||
"appkey_assignment", "user_path_posix"):
|
||||
assert rule_id in result.stdout
|
||||
|
||||
def test_unknown_category_is_a_usage_error(self, tmp_path):
|
||||
result = run_script("scan_leaks.py",
|
||||
["--root", str(tmp_path), "--only", "nosuchcategory"])
|
||||
assert result.returncode == 2, result.output
|
||||
Reference in New Issue
Block a user