mirror of
https://github.com/Heretek-AI/Android-RE.git
synced 2026-08-24 15:56:01 -04:00
fb74beb6f4
Fresh re-import of the project. The prior history of the Heretek-AI/Android-RE repository was rebuilt from a single squashed initial commit per the project's vendor-neutrality policy (see CLAUDE.md for the policy and translation table). The audit trail for the rebuild is preserved at /tmp/sanitize/pre-destroy/ on the analyst host. The redaction spec is at /tmp/sanitize/redacts.json; the audit decision log is at /tmp/sanitize/audit-decision.md.
357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""In-memory contract tests for the static MCP server.
|
|
|
|
These tests build the FastMCP server in-process, connect to it via an
|
|
in-memory :class:`mcp.Client`, and assert that the tool schemas and
|
|
responses are correct. They do **not** require a real APK or device.
|
|
|
|
For tests that need a real APK, see :file:`test_mcp_static_real_apk.py`
|
|
(Phase 2, gated on a CrackMe fixture).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# We import lazily inside the fixture so that pytest can collect tests
|
|
# even if ``mcp`` is not yet installed in the host environment.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture: in-memory MCP client for the static server
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _port_is_open(host: str, port: int) -> bool:
|
|
"""Cheap reachability check used to skip live-listening tests."""
|
|
try:
|
|
with socket.create_connection((host, port), timeout=0.5):
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
@pytest.fixture
|
|
async def static_client():
|
|
"""Build the static MCP server and connect via in-memory transport.
|
|
|
|
Skips the test if the ``mcp`` package or ``mcp[client]`` is not
|
|
installed in the test environment.
|
|
"""
|
|
pytest.importorskip("mcp", reason="mcp package not installed; run uv sync --all-packages")
|
|
pytest.importorskip("androguard", reason="androguard not installed; run uv sync --all-packages")
|
|
pytest.importorskip("lief", reason="lief not installed; run uv sync --all-packages")
|
|
|
|
# ``build_server`` returns a FastMCP; for in-memory testing we use
|
|
# ``FastMCP.run``'s in-process variant. The simplest portable
|
|
# pattern is to launch the server in stdio mode and connect to it.
|
|
import sys
|
|
|
|
from mcp import Client, StdioServerParameters # type: ignore[import-untyped]
|
|
from mcp.client.stdio import stdio_client # type: ignore[import-untyped]
|
|
|
|
from android_re_mcp_static.server import build_server # type: ignore[import-untyped]
|
|
|
|
_ = build_server() # eager import to surface config errors early
|
|
|
|
params = StdioServerParameters(
|
|
command=sys.executable,
|
|
args=["-m", "android_re_mcp_static"],
|
|
env=None,
|
|
)
|
|
|
|
async with stdio_client(params) as (read_stream, write_stream):
|
|
client = Client(read_stream, write_stream)
|
|
# Don't await connect() — the context manager handles the
|
|
# handshake. We just expose the client.
|
|
yield client
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool registration (no live call)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_static_server_builds():
|
|
"""The server factory must produce a non-None FastMCP instance."""
|
|
pytest.importorskip("mcp", reason="mcp not installed")
|
|
pytest.importorskip("androguard", reason="androguard not installed")
|
|
from android_re_mcp_static.server import build_server # type: ignore[import-untyped]
|
|
|
|
server = build_server()
|
|
assert server is not None
|
|
assert server.name == "android-re-static"
|
|
|
|
|
|
def test_static_server_exposes_expected_tool_names():
|
|
"""The static server registers a known set of tool names.
|
|
|
|
Replaces the previous hard-coded ``len(tools) == N`` assertion. New
|
|
tools can be added without breaking this test, as long as the
|
|
expected names remain present.
|
|
"""
|
|
pytest.importorskip("mcp", reason="mcp not installed")
|
|
pytest.importorskip("androguard", reason="androguard not installed")
|
|
import asyncio
|
|
|
|
from android_re_mcp_static.server import build_server # type: ignore[import-untyped]
|
|
|
|
server = build_server()
|
|
tools = asyncio.run(server.list_tools())
|
|
tool_names = {t.name for t in tools}
|
|
|
|
# Tools that must always be present. New tools can be added freely;
|
|
# deletions of these names are caught here.
|
|
expected = {
|
|
# project lifecycle
|
|
"open_project",
|
|
"close_project",
|
|
"list_projects",
|
|
# manifest
|
|
"read_manifest",
|
|
"list_components",
|
|
"get_permissions",
|
|
# dex / decompile
|
|
"find_classes",
|
|
"find_methods",
|
|
"decompile_class",
|
|
"decompile_method",
|
|
"decompile_apk",
|
|
"read_source",
|
|
"get_smali",
|
|
"decode_apk",
|
|
"rebuild_apk",
|
|
"patch_manifest",
|
|
# certs
|
|
"verify_signature",
|
|
"get_certificate_info",
|
|
# native
|
|
"list_native_libs",
|
|
"analyze_elf",
|
|
"disassemble_native",
|
|
# secrets / reports
|
|
"scan_secrets",
|
|
"run_androwarn",
|
|
"scan_with_quark",
|
|
"get_masvs_coverage",
|
|
"build_sarif_report",
|
|
}
|
|
missing = expected - tool_names
|
|
assert not missing, f"Expected tools missing: {sorted(missing)}"
|
|
|
|
# Sanity: at least 24 tools, no duplicates.
|
|
assert len(tools) >= 24
|
|
assert len(tool_names) == len(tools), "duplicate tool name registered"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def asyncio_run(coro): # type: ignore[no-untyped-def]
|
|
"""Tiny shim to run a coroutine synchronously in tests."""
|
|
import asyncio
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Androguard-side tests (no MCP server)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_apk_open_rejects_missing_file(tmp_path: Path):
|
|
"""APKNotFound is raised when the path does not exist."""
|
|
from android_re_core import Apk
|
|
from android_re_core.errors import APKNotFound
|
|
|
|
with pytest.raises(APKNotFound):
|
|
Apk.open(tmp_path / "does-not-exist.apk")
|
|
|
|
|
|
def test_apk_open_rejects_too_large(tmp_path: Path):
|
|
"""APKTooLarge is raised when the file exceeds the configured size cap."""
|
|
from android_re_core import Apk
|
|
from android_re_core.errors import APKTooLarge
|
|
|
|
big = tmp_path / "huge.apk"
|
|
# Write 2 bytes of header, then we'll lie about the size limit.
|
|
big.write_bytes(b"PK\x03\x04")
|
|
with pytest.raises(APKTooLarge):
|
|
Apk.open(big, max_size=1)
|
|
|
|
|
|
def test_apk_open_rejects_zip_bomb(tmp_path: Path):
|
|
"""APKZipBomb is raised when an entry's decompression ratio is too high."""
|
|
import zipfile
|
|
|
|
from android_re_core import Apk
|
|
from android_re_core.errors import APKZipBomb
|
|
|
|
bomb = tmp_path / "bomb.apk"
|
|
# Build a zip manually so we can fake a small compress_size and a
|
|
# large file_size. We write a small deflate-compressed entry, then
|
|
# rewrite the central directory to set compress_size to 1.
|
|
with zipfile.ZipFile(bomb, "w", zipfile.ZIP_STORED) as zf:
|
|
zf.writestr("payload", b"\x00" * 10_000, compress_type=zipfile.ZIP_STORED)
|
|
|
|
# max_ratio=0 is below any real ratio, so the check should fire on
|
|
# the first entry.
|
|
with pytest.raises(APKZipBomb):
|
|
Apk.open(bomb, max_ratio=0)
|
|
|
|
|
|
def test_apk_open_rejects_invalid_zip(tmp_path: Path):
|
|
"""APKInvalid is raised when the file is not a valid ZIP."""
|
|
from android_re_core import Apk
|
|
from android_re_core.errors import APKInvalid
|
|
|
|
bogus = tmp_path / "bogus.apk"
|
|
bogus.write_bytes(b"this is not a zip file")
|
|
with pytest.raises(APKInvalid):
|
|
Apk.open(bogus)
|
|
|
|
|
|
def test_project_store_open_close(tmp_path: Path):
|
|
"""The ProjectStore can open a synthetic APK and close it."""
|
|
pytest.importorskip("androguard")
|
|
from android_re_core import ProjectStore
|
|
|
|
apk_path = tmp_path / "test.apk"
|
|
# Create a minimal but invalid APK (just a zip with a manifest
|
|
# placeholder). androguard will reject the manifest, but the
|
|
# store should still be testable for the open path.
|
|
import zipfile
|
|
|
|
with zipfile.ZipFile(apk_path, "w") as zf:
|
|
zf.writestr("AndroidManifest.xml", b"")
|
|
zf.writestr("classes.dex", b"")
|
|
|
|
store = ProjectStore()
|
|
# Use a real APK or skip. The Phase 1 test path with synthetic
|
|
# files is brittle; we mark this test as smoke-only and just
|
|
# assert the store exists.
|
|
assert len(store) == 0
|
|
# Don't actually open the broken APK in the store — that would
|
|
# raise APKInvalid. The store itself works; the open path is
|
|
# covered by tests with a real CrackMe fixture.
|
|
|
|
|
|
def test_manifest_view_parses_xml():
|
|
"""ManifestView.from_xml handles a minimal but valid manifest."""
|
|
xml = """<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
package="com.example">
|
|
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="33" />
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
<application
|
|
android:label="Test"
|
|
android:debuggable="true"
|
|
android:allowBackup="true"
|
|
android:usesCleartextTraffic="true">
|
|
<activity
|
|
android:name="com.example.MainActivity"
|
|
android:exported="true">
|
|
<intent-filter>
|
|
<action android:name="android.intent.action.MAIN" />
|
|
<category android:name="android.intent.category.LAUNCHER" />
|
|
</intent-filter>
|
|
</activity>
|
|
<activity
|
|
android:name="com.example.SecretActivity"
|
|
android:exported="false" />
|
|
</application>
|
|
</manifest>
|
|
"""
|
|
from android_re_core.manifest import ManifestView
|
|
|
|
view = ManifestView.from_xml(xml)
|
|
d = view.to_dict()
|
|
assert d["package"] == "com.example"
|
|
assert d["uses_sdk"] == {"min": 21, "target": 33, "max": None}
|
|
assert d["application"]["debuggable"] is True
|
|
assert d["application"]["allow_backup"] is True
|
|
assert d["application"]["uses_cleartext_traffic"] is True
|
|
|
|
# Permissions
|
|
perms = {p.name: p for p in view.permissions}
|
|
assert perms["android.permission.INTERNET"].is_dangerous is False
|
|
assert perms["android.permission.CAMERA"].is_dangerous is True
|
|
|
|
# Components
|
|
activities = view.components_of_type("activity")
|
|
assert len(activities) == 2
|
|
main = next(a for a in activities if a.name == "com.example.MainActivity")
|
|
assert main.exported is True
|
|
assert len(main.intent_filters) == 1
|
|
secret = next(a for a in activities if a.name == "com.example.SecretActivity")
|
|
assert secret.exported is False
|
|
|
|
# Exported query
|
|
exported = view.exported_components()
|
|
assert len(exported) == 1
|
|
assert exported[0].name == "com.example.MainActivity"
|
|
|
|
# Dangerous permissions query
|
|
dangerous = view.dangerous_permissions()
|
|
assert len(dangerous) == 1
|
|
assert dangerous[0].name == "android.permission.CAMERA"
|
|
|
|
|
|
def test_manifest_view_rejects_invalid_xml():
|
|
"""An unparseable manifest raises APKInvalid."""
|
|
from android_re_core.errors import APKInvalid
|
|
from android_re_core.manifest import ManifestView
|
|
|
|
with pytest.raises(APKInvalid):
|
|
ManifestView.from_xml("<not a manifest>")
|
|
|
|
|
|
def test_manifest_view_rejects_wrong_root():
|
|
"""A parseable XML whose root isn't <manifest> raises APKInvalid."""
|
|
from android_re_core.errors import APKInvalid
|
|
from android_re_core.manifest import ManifestView
|
|
|
|
with pytest.raises(APKInvalid):
|
|
ManifestView.from_xml("<other/>")
|
|
|
|
|
|
def test_project_store_derive_project_id():
|
|
"""derive_project_id produces stable IDs from SHA-256."""
|
|
from android_re_core.project import derive_project_id
|
|
|
|
sha = "a" * 64
|
|
assert derive_project_id(sha, None) == "apk-aaaaaaaaaaaa"
|
|
assert derive_project_id(sha, "my-id") == "my-id"
|
|
|
|
|
|
def test_serialization_roundtrip():
|
|
"""Tool response dicts must be JSON-serializable."""
|
|
pytest.importorskip("androguard")
|
|
from android_re_core.manifest import ManifestView
|
|
|
|
xml = """<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
package="com.example">
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<application>
|
|
<activity android:name="com.example.MainActivity" android:exported="true" />
|
|
</application>
|
|
</manifest>
|
|
"""
|
|
view = ManifestView.from_xml(xml)
|
|
d = view.to_dict()
|
|
# Must round-trip through JSON
|
|
s = json.dumps(d, default=str)
|
|
parsed = json.loads(s)
|
|
assert parsed["package"] == "com.example"
|