Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions dexmachina/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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"
)


Expand Down
18 changes: 5 additions & 13 deletions dexmachina/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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")


Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion dexmachina/fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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")]

Expand Down
19 changes: 16 additions & 3 deletions dexmachina/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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}."
Expand Down Expand Up @@ -660,15 +671,17 @@ 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:
release = get_latest_github_release(tool.github_repo)
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)

Expand Down
14 changes: 14 additions & 0 deletions tests/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,27 @@

from dexmachina.device import (
FridaServerStatus,
adb_path,
device_has_su,
get_local_frida_version,
frida_server_user,
restart_frida_server,
)


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"):
Expand Down
34 changes: 33 additions & 1 deletion tests/test_doctor_perf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"}
19 changes: 19 additions & 0 deletions tests/test_install_direct.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down