Skip to content

v0.7.0 — Locked Down

Latest

Choose a tag to compare

@johnzfitch johnzfitch released this 02 Jun 06:34
· 35 commits to master since this release
724096f

pyghidra-lite v0.7.0 — Locked Down

"A setting you can change mid-session is a setting an attacker can change mid-session."

Security-hardening release · immutable-by-design config · adversarial red-team suite · real-Ghidra E2E CI

This release makes the server's security posture structural rather than incidental: the configuration is frozen once the server goes live, the run-time remote-code-execution path is gone, and every claim is backed by an adversarial test that performs the attack and asserts it fails closed.

Area Change
🔒 Immutable config ServerConfig is frozen, built once, and locked at go_live(). Changing a setting mid-session raises ConfigLockedError.
🔒 No remote code exec search(type="extract") no longer fetches+runs an npm package on every call.
🔒 Transport DNS-rebinding protection, required bearer auth off-loopback, ipaddress-based loopback detection.
Performance info/code/xrefs moved off the event loop — one decompile no longer freezes the shared HTTP server.
🧪 Tests Red-team gauntlet + holder-impenetrability checks + real-Ghidra E2E CI.

Configuration is immutable while serving SECURITY

ServerConfig is now a frozen value object, built exactly once at startup through a single writer (configure_server) and sealed at the serve boundary by go_live(). After that, any attempt to change a security setting — restrict paths, bind host, auth token, runtime home — raises ConfigLockedError.

Why this matters (the threat model)

The defended surface is the agent-driving-the-tools path and accidental/buggy mutation: no MCP tool can widen restrict_paths, flip shared, disable auth, or add an allowed Host while a connection is live. To change a setting you stop the process and re-run the CLI. A removed hidden writer (_init_backend used to mutate runtime_home in place, inside the lifespan, after go-live) is gone — the resolved runtime home is now persisted before the lock.

This is not a sandbox against arbitrary code execution in the server process; it makes the tool/automation surface have no expressible way to change a security setting mid-session.

No run-time remote code execution SECURITY

search(type="extract") previously ran bun x bun-extract-bundled, which resolved and executed an npm package from the network on every call. It now invokes a locally pre-installed, pinned extractor directly (fixed argv, no shell, no package-manager launcher), or returns an actionable error.

Transport & input hardening SECURITY

  • DNS-rebinding protection (Host/Origin validation) on all HTTP/SSE binds.
  • Constant-time bearer-token guard (--auth-token / PYGHIDRA_LITE_AUTH_TOKEN), required for non-loopback binds; --allowed-host for fronting.
  • ipaddress-based loopback detection (closes ::1, the :: wildcard, and hostnames that slipped past the old literal == "127.0.0.1"); fails closed on ambiguous hosts.
  • Import-path TOCTOU defense — canonical path re-validated immediately before import; errors report requested= and resolves_to=.
  • Trailing-newline id bypass fixed^[0-9a-f]{16}$ accepted "<id>\n" (Python's $ matches before a final newline); both validators now anchor with \Z.
  • Error redaction strips server-side absolute paths from outward-facing exceptions.
  • MCP tool annotations — every tool publishes ToolAnnotations (read-only / destructive / idempotent / open-world) for safe client auto-approve UX.

Performance

info, code, and xrefs (and the blocking bodies of functions/search) ran JVM work directly on the async event loop — a single decompile froze the whole shared HTTP server. They're now async via asyncio.to_thread.

Also
  • GhidraTools cached per binary handle (function-name index + list caches survive between calls).
  • load() caches the content hash by (path, mtime, size) — no re-hashing an already-analyzed binary.
  • xrefs(depth>1) call graphs bounded (max nodes/edges) and flagged truncated.
  • Fixed a dead depth = min(depth, 5) line in xrefs that would raise UnboundLocalError on depth>1.

Testing

Adversarial red-team gauntlet — tests/test_red_team.py

Each test performs an attack and asserts it fails closed: restrict-path escape + TOCTOU, id injection (incl. the newline bypass), a live trojan-on-PATH trap proving extraction never runs a package launcher (real processes), DNS-rebinding rejected 421 end-to-end through the real ASGI app, the auth-bypass matrix, loopback fail-closed, job-queue cap, error redaction, worker self-spawn, and holder-impenetrability checks — config cannot be mutated while live, plus a tripwire that fails if any @mcp.tool gains a settings-mutation surface.

Real-server E2E — tests/test_security_e2e.py + CI

Gated by PYGHIDRA_E2E, with a security-e2e workflow (JDK 21 + pinned Ghidra) that boots an actual pyghidra-lite serve and runs the attack matrix over real TCP sockets: missing/wrong token → 401, forged Host → 421 (repeatable — the allowed-host set can't be widened at runtime), legit request passes both guards.

uv run pytest317 passed, 6 skipped (the 6 are the real-Ghidra E2E).


Upgrade

pip install --upgrade pyghidra-lite