diff --git a/README.md b/README.md index ebca21e..f240d75 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,16 @@ It is not a replacement for MobSF, Corellium, Burp, or manual reverse-engineerin `pipx` installs DexMachina in an isolated environment and exposes the `dexmachina` command on your shell `PATH`. +```bash +# Kali/Debian/Ubuntu +sudo apt update +sudo apt install -y pipx +pipx ensurepath +pipx install dexmachina +``` + +On other Python installs where `pipx` is not packaged by the OS: + ```bash python -m pip install --user pipx python -m pipx ensurepath @@ -66,6 +76,20 @@ dexmachina --help python -m pip install dexmachina ``` +On Kali, Debian 12+, Ubuntu 23.04+, and other PEP 668 distributions, system +Python may reject this with `externally-managed-environment`. Use `pipx` +instead, or install inside a virtual environment: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install dexmachina +dexmachina --help +``` + +Avoid `--break-system-packages` unless you intentionally want to modify the OS +Python environment. + If the install succeeds but `dexmachina` is not found, your Python scripts directory is not on `PATH`. You can still run DexMachina as a module: @@ -144,6 +168,16 @@ dexmachina shell # …or a raw subshell with every tool on PATH Inside `dexmachina shell`, tools like `jadx`, `apktool`, `objection`, and `frida` are directly on your `PATH`. Type `exit` to leave. +If `doctor` or `fix` reports that a managed tool is installed but not on your +shell `PATH`, use DexMachina's managed environment instead of editing global +shell startup files: + +```bash +dexmachina shell +# or: +eval "$(dexmachina env)" +``` + ## Pentest a rooted emulator — full walkthrough Assuming a rooted emulator/device is running with USB debugging (e.g. a @@ -434,6 +468,15 @@ DexMachina aims to become a batteries-included Android pentest environment: Release versions are fetched from the GitHub Releases API with a 1-hour cache in `~/.dexmachina/cache/`. Set `GITHUB_TOKEN` to avoid rate limits. +On Kali or shared networks you may hit unauthenticated GitHub API limits during +`dexmachina up`, `install`, or `fix --bootstrap`. Create a fine-grained GitHub +token with read-only public repository access and export it before retrying: + +```bash +export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +dexmachina fix --bootstrap +``` + ## Development ```bash diff --git a/dexmachina/device.py b/dexmachina/device.py index cc2d6f7..661320a 100644 --- a/dexmachina/device.py +++ b/dexmachina/device.py @@ -13,9 +13,9 @@ from rich.console import Console from rich.progress import Progress -from dexmachina.config import get_setting +from dexmachina.config import get_setting, install_dir from dexmachina.progress import work_spinner -from dexmachina.utils import PROBE_CMD_TIMEOUT, github_headers, parse_version, run_cmd, which +from dexmachina.utils import PROBE_CMD_TIMEOUT, detect_platform, github_headers, parse_version, run_cmd, which console = Console() @@ -45,14 +45,30 @@ def runs_as_root(self) -> bool: return self.user == "root" +def _managed_binary(config: dict, tool_name: str, binary_name: str) -> str | None: + bin_dir = install_dir(config) / tool_name / "bin" + candidates = [binary_name] + if detect_platform() == "windows": + candidates.extend([f"{binary_name}.exe", f"{binary_name}.bat", f"{binary_name}.cmd"]) + for candidate in candidates: + path = bin_dir / candidate + if path.is_file(): + return str(path) + return None + + def adb_path(config: dict) -> str: custom = get_setting(config, "adb_path", "adb") if which(custom): return custom if which("adb"): return "adb" + managed = _managed_binary(config, "adb", "adb") + if managed: + return managed raise DeviceError( - "adb not found. Install platform-tools or set adb_path in dexmachina.toml" + "adb not found. Install platform-tools, run `dexmachina shell`, " + "or set adb_path in dexmachina.toml" ) diff --git a/dexmachina/doctor.py b/dexmachina/doctor.py index 9816826..94e6321 100644 --- a/dexmachina/doctor.py +++ b/dexmachina/doctor.py @@ -12,11 +12,11 @@ from dexmachina.banner import doctor_table from dexmachina.config import get_pinned_version from dexmachina.progress import work_progress -from dexmachina.device import check_frida_server_running, frida_server_status, get_local_frida_version, list_devices +from dexmachina.device import DeviceError, check_frida_server_running, frida_server_status, get_local_frida_version, list_devices from dexmachina.config import install_dir from dexmachina.installer import get_tool_version, warm_version_cache from dexmachina.registry import FRIDA_PIN_GROUP, TOOLS, get_tool -from dexmachina.runtime import _find_binary_in_dir, collect_tool_bin_paths +from dexmachina.runtime import _find_binary_in_dir, build_run_env, collect_tool_bin_paths from dexmachina.utils import ( PROBE_CMD_TIMEOUT, normalize_pkg_name, @@ -68,14 +68,6 @@ def check_java() -> CheckResult: def check_adb(config: dict) -> CheckResult: - adb = config.get("settings", {}).get("adb_path", "adb") - if not which(adb) and not which("adb"): - return CheckResult( - "ADB", - "fail", - "Not found in PATH", - "dexmachina install adb", - ) try: devices = list_devices(config) if devices: @@ -87,7 +79,7 @@ def check_adb(config: dict) -> CheckResult: "Connect a device with USB debugging enabled", automated=False, ) - except Exception as e: + except DeviceError as e: return CheckResult("ADB", "fail", str(e), "dexmachina install adb") @@ -181,7 +173,7 @@ def check_frida_server_match(config: dict) -> CheckResult: ) try: - local = get_local_frida_version() + local = get_local_frida_version(config) except Exception as e: return CheckResult("Frida server", "warn", f"Local frida not installed: {e}") @@ -211,7 +203,7 @@ def check_frida_server_match(config: dict) -> CheckResult: ) # Best-effort: if frida-ps works, versions likely match - result = run_cmd("frida-ps -U", timeout=PROBE_CMD_TIMEOUT) + result = run_cmd("frida-ps -U", env=build_run_env(config), timeout=PROBE_CMD_TIMEOUT) if result.returncode == 0: return CheckResult( "Frida server", diff --git a/dexmachina/fix.py b/dexmachina/fix.py index 9ebc349..36b082a 100644 --- a/dexmachina/fix.py +++ b/dexmachina/fix.py @@ -10,7 +10,7 @@ from rich.panel import Panel from rich.table import Table -from dexmachina.config import get_pinned_version, is_ignored +from dexmachina.config import config_path_of, get_pinned_version, is_ignored, load_config from dexmachina.device import push_frida_server from dexmachina.doctor import CheckResult, run_doctor from dexmachina.installer import ( @@ -512,6 +512,7 @@ def run_fix( console.print(f" [yellow]▸[/] {line}") console.print("\n[bold cyan]Phase 3:[/] Re-checking environment…") + config = load_config(config_path_of(config)) post_checks = run_doctor(config) remaining = [c for c in post_checks if c.status in ("fail", "warn")] diff --git a/dexmachina/installer.py b/dexmachina/installer.py index a32c105..4eb3bcb 100644 --- a/dexmachina/installer.py +++ b/dexmachina/installer.py @@ -57,6 +57,17 @@ class DownloadVerificationError(InstallError): _latest_version_cache: dict[str, str | None] = {} +def _request_error_message(error: requests.RequestException) -> str: + message = str(error) + response = getattr(error, "response", None) + if response is not None and response.status_code == 403 and "rate limit" in message.lower(): + message += ( + "\nGitHub API rate limit exceeded. Set GITHUB_TOKEN to a GitHub token " + "with public_repo/read-only access, then retry." + ) + return message + + def _merged_pip_versions(config: dict | None) -> dict[str, str]: versions = load_pip_package_versions() if not config: @@ -462,7 +473,7 @@ def _download_file( if progress and task is not None: progress.update(task, advance=len(chunk)) - if total and written != total: + if total and written < total: partial.unlink(missing_ok=True) raise DownloadVerificationError( f"Incomplete download for {dest.name}: expected {total} bytes, got {written}." @@ -660,7 +671,9 @@ def _install_github_release( url = f"https://api.github.com/repos/{tool.github_repo}/releases/tags/{version}" release = requests.get(url, headers=github_headers(), timeout=30).json() except requests.RequestException as e: - raise InstallError(f"Failed to fetch release {version} for {tool.name}: {e}") from e + raise InstallError( + f"Failed to fetch release {version} for {tool.name}: {_request_error_message(e)}" + ) from e ver = version.lstrip("v") else: try: @@ -668,7 +681,7 @@ def _install_github_release( except requests.RequestException as e: raise InstallError( f"Could not fetch latest release for {tool.display_name} " - f"(github.com/{tool.github_repo}): {e}" + f"(github.com/{tool.github_repo}): {_request_error_message(e)}" ) from e ver = extract_release_version(release) diff --git a/tests/test_device.py b/tests/test_device.py index 49a1ec9..0ce9ec9 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -4,6 +4,7 @@ from dexmachina.device import ( FridaServerStatus, + adb_path, device_has_su, get_local_frida_version, frida_server_user, @@ -11,6 +12,19 @@ ) +def test_adb_path_finds_managed_platform_tools(tmp_path, monkeypatch): + tools = tmp_path / ".dexmachina" / "tools" + adb = tools / "adb" / "bin" / "adb" + adb.parent.mkdir(parents=True) + adb.write_text("#!/bin/sh\n", encoding="utf-8") + cfg = {"settings": {"adb_path": "adb", "install_dir": str(tools)}} + + monkeypatch.setattr("dexmachina.device.which", lambda _name: None) + monkeypatch.setattr("dexmachina.device.detect_platform", lambda: "linux") + + assert adb_path(cfg) == str(adb) + + def test_device_has_su_true(): cfg = {"settings": {"adb_path": "adb"}} with patch("dexmachina.device.adb_path", return_value="adb"): diff --git a/tests/test_doctor_perf.py b/tests/test_doctor_perf.py index 8e37f2d..be572ff 100644 --- a/tests/test_doctor_perf.py +++ b/tests/test_doctor_perf.py @@ -2,7 +2,7 @@ from unittest.mock import patch -from dexmachina.doctor import check_broken_installs +from dexmachina.doctor import check_broken_installs, check_frida_server_match from dexmachina.registry import get_tool @@ -36,3 +36,35 @@ def test_check_broken_installs_no_issue_when_binary_resolvable(): results = check_broken_installs(config) assert results == [] + + +def test_frida_server_check_uses_managed_run_env(): + config = {"settings": {"install_dir": "~/.dexmachina/tools"}, "active": {"frida": "17.0.0"}} + captured = {} + + def fake_run(cmd, *, env=None, timeout=None, **kwargs): + captured["cmd"] = cmd + captured["env"] = env + + class Result: + returncode = 0 + stdout = "ok" + stderr = "" + + return Result() + + with patch("dexmachina.doctor.list_devices", return_value=["emulator-5554"]): + with patch("dexmachina.doctor.get_local_frida_version", return_value="17.0.0") as local: + with patch("dexmachina.doctor.check_frida_server_running", return_value=True): + with patch( + "dexmachina.doctor.frida_server_status", + return_value=type("Status", (), {"device_rooted": True, "runs_as_root": True})(), + ): + with patch("dexmachina.doctor.build_run_env", return_value={"PATH": "/tmp/frida"}): + with patch("dexmachina.doctor.run_cmd", side_effect=fake_run): + result = check_frida_server_match(config) + + assert result.status == "ok" + local.assert_called_once_with(config) + assert captured["cmd"] == "frida-ps -U" + assert captured["env"] == {"PATH": "/tmp/frida"} diff --git a/tests/test_install_direct.py b/tests/test_install_direct.py index 220187f..d9910dd 100644 --- a/tests/test_install_direct.py +++ b/tests/test_install_direct.py @@ -169,6 +169,25 @@ def iter_content(self, chunk_size): assert not (tmp_path / "artifact.zip.part").exists() +def test_download_file_allows_decoded_body_larger_than_content_length(tmp_path, monkeypatch): + class Response: + headers = {"content-length": "3"} + + def raise_for_status(self): + return None + + def iter_content(self, chunk_size): + yield b"abcd" + + monkeypatch.setattr(installer.requests, "get", lambda *args, **kwargs: Response()) + + dest = tmp_path / "artifact.zip" + digest = installer._download_file("https://example.invalid/a.zip", dest) + + assert dest.read_bytes() == b"abcd" + assert digest == "88d4266fd4e6338d13b845fcf289579d209c897823b9217da3e161936f031589" + + def test_download_file_returns_digest_without_expected_hash(tmp_path, monkeypatch): class Response: headers = {"content-length": "3"}