fix: relocate binary analysis skill

This commit is contained in:
username
2026-07-31 13:25:08 -04:00
parent 0d4a3652e4
commit f66ed48b8c
107 changed files with 17 additions and 2 deletions
@@ -0,0 +1,27 @@
"""Backend adapters — abstract interface, FakeAdapter for testing, Ghidra adapter."""
from __future__ import annotations
from binary_analysis.adapters.base import (
AnalysisProfile,
AnalysisResult,
BackendAdapter,
BinaryMetadata,
CallEdge,
ConcurrencyMode,
DecompilationResult,
)
from binary_analysis.adapters.fake import FakeAdapter
from binary_analysis.adapters.ghidra import GhidraAdapter
__all__ = [
"AnalysisProfile",
"AnalysisResult",
"BackendAdapter",
"BinaryMetadata",
"CallEdge",
"ConcurrencyMode",
"DecompilationResult",
"FakeAdapter",
"GhidraAdapter",
]
@@ -0,0 +1,671 @@
"""Abstract BackendAdapter interface.
Defines the typed behavioral contract that every backend must implement.
Public commands never branch on backend names; they interact exclusively
through this interface.
The interface is backend-neutral: all inputs and outputs use canonical
domain entities. Backend-native objects never cross this boundary.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from binary_analysis.domain.entities import (
Address,
Binary,
CallGraph,
EntryPoint,
Export,
Function,
Import,
Instruction,
Project,
Reference,
Section,
String,
Symbol,
TriageResult,
)
class ConcurrencyMode(str, Enum):
"""Declares how a backend handles concurrent access."""
PROJECT_SERIALIZED = "PROJECT_SERIALIZED"
"""Only one operation per project at a time."""
@dataclass
class AnalysisProfile:
"""An analysis profile specification.
Attributes:
name: Profile identifier (e.g., "standard", "quick", "deep").
description: Human-readable description.
analysers: List of analyser names included in this profile.
"""
name: str
description: str = ""
analysers: list[str] = field(default_factory=list)
@dataclass
class AnalysisResult:
"""Result of an analysis operation.
Attributes:
success: Whether the analysis completed without critical errors.
partial: Whether some analysers failed while others succeeded.
completed_analysers: List of analyser names that completed.
failed_analysers: List of analyser names that failed.
diagnostics: List of diagnostic entries describing failures.
"""
success: bool = True
partial: bool = False
completed_analysers: list[str] = field(default_factory=list)
failed_analysers: list[str] = field(default_factory=list)
diagnostics: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class DecompilationResult:
"""Result of decompiling a function.
Attributes:
pseudocode: The reconstructed pseudocode (never original source).
address_map: Maps source line numbers (1-indexed) to canonical address objects.
diagnostics: List of diagnostic entries.
language: The source language of the decompilation output (e.g., "c").
"""
pseudocode: str = ""
address_map: dict[int, dict[str, Any]] = field(default_factory=dict)
diagnostics: list[dict[str, Any]] = field(default_factory=list)
language: str = "c"
@dataclass
class CallEdge:
"""A directed call edge between two functions.
Attributes:
from_address: The caller function's entry address.
to_address: The callee function's entry address.
from_name: The caller function's name.
to_name: The callee function's name.
kind: The kind of call (direct, indirect, etc.).
"""
from_address: Address | None = None
to_address: Address | None = None
from_name: str = ""
to_name: str = ""
kind: str = "direct"
@dataclass
class BinaryMetadata:
"""Canonical metadata about a binary, backend-neutral.
This is a lightweight subset of the Binary entity focused on
metadata that does not require full analysis.
"""
format: str = ""
architecture: str | None = None
endianness: str | None = None
size_bytes: int = 0
entry_point: Address | None = None
compiler: str | None = None
source_language: str | None = None
class BackendAdapter(ABC):
"""Abstract interface for all backend adapters.
Every backend implementation must subclass this and implement
all abstract methods. The adapter translates backend-specific
data into canonical domain entities.
Concurrency is declared via the ``concurrency`` property.
"""
@property
@abstractmethod
def concurrency(self) -> ConcurrencyMode:
"""Declare how this backend handles concurrent access."""
...
@abstractmethod
def initialize(self) -> None:
"""Initialize the backend (start JVM, load libraries, etc.).
Must be safe to call multiple times (idempotent).
"""
...
@abstractmethod
def capabilities(self) -> dict[str, Any]:
"""Return the backend's capabilities.
Returns:
A dict describing supported formats, architectures, analyzers,
and limitations.
"""
...
@abstractmethod
def available_profiles(self) -> list[AnalysisProfile]:
"""Return the list of available analysis profiles."""
...
def validate_profile(self, profile_name: str) -> AnalysisProfile:
"""Validate that a profile name is known.
Args:
profile_name: The profile to validate.
Returns:
The matching AnalysisProfile.
Raises:
ValueError: If the profile is not available.
"""
profiles = self.available_profiles()
for profile in profiles:
if profile.name == profile_name:
return profile
available = [p.name for p in profiles]
raise ValueError(
f"Unknown analysis profile: {profile_name!r}. Available: {', '.join(available)}"
)
@abstractmethod
def import_binary(self, path: str, project: Project) -> Binary:
"""Import a binary into the backend.
Args:
path: Path to the binary file on disk.
project: The project this binary belongs to.
Returns:
A canonical Binary entity with format, architecture, and
SHA-256 populated.
Raises:
Various backend-specific errors that are normalized to
canonical error types by the caller.
"""
...
@abstractmethod
def analyze(self, binary: Binary, profile: AnalysisProfile) -> AnalysisResult:
"""Run analysis on an imported binary.
Args:
binary: The canonical Binary entity to analyze.
profile: The analysis profile to apply.
Returns:
An AnalysisResult with completed/failed analysers and diagnostics.
"""
...
@abstractmethod
def get_metadata(self, binary: Binary) -> BinaryMetadata:
"""Return canonical metadata for a binary.
Does not require full analysis. Should return whatever info is
available from the import step (format, architecture, etc.).
Args:
binary: The binary to query.
Returns:
Backend-neutral metadata.
"""
...
@abstractmethod
def get_sections(self, binary: Binary) -> list[Section]:
"""Return all sections in the binary.
Args:
binary: The binary to query.
Returns:
List of canonical Section entities.
"""
...
@abstractmethod
def get_entrypoints(self, binary: Binary) -> list[EntryPoint]:
"""Return all entry points in the binary.
Args:
binary: The binary to query.
Returns:
List of canonical EntryPoint entities.
"""
...
@abstractmethod
def get_imports(self, binary: Binary) -> list[Import]:
"""Return all imported symbols in the binary.
Args:
binary: The binary to query.
Returns:
List of canonical Import entities.
"""
...
@abstractmethod
def get_exports(self, binary: Binary) -> list[Export]:
"""Return all exported symbols in the binary.
Args:
binary: The binary to query.
Returns:
List of canonical Export entities.
"""
...
@abstractmethod
def get_symbols(self, binary: Binary) -> list[Symbol]:
"""Return all symbols in the binary.
Args:
binary: The binary to query.
Returns:
List of canonical Symbol entities.
"""
...
@abstractmethod
def get_strings(
self,
binary: Binary,
min_length: int = 4,
contains: str | None = None,
encoding_filter: str | None = None,
) -> list[String]:
"""Return all decoded strings in the binary.
Args:
binary: The binary to query.
min_length: Minimum string length to return (default 4).
contains: Optional substring filter (case-sensitive).
encoding_filter: Optional encoding filter (e.g., "ASCII", "UTF-16").
Returns:
List of canonical String entities.
"""
...
@abstractmethod
def get_functions(
self,
binary: Binary,
exclude_external: bool = True,
exclude_thunks: bool = True,
) -> list[Function]:
"""Return all functions in the binary.
Args:
binary: The binary to query.
exclude_external: If True, exclude externally defined functions.
exclude_thunks: If True, exclude thunk functions.
Returns:
List of canonical Function entities.
"""
...
@abstractmethod
def decompile(self, binary: Binary, function: Function) -> DecompilationResult:
"""Decompile a function to pseudocode.
Args:
binary: The binary containing the function.
function: The function to decompile.
Returns:
Reconstructed pseudocode with address map and diagnostics.
"""
...
@abstractmethod
def disassemble(
self, binary: Binary, start_address: Address, end_address: Address
) -> list[Instruction]:
"""Disassemble instructions in an address range.
Args:
binary: The binary to disassemble from.
start_address: Start of the address range (inclusive).
end_address: End of the address range (inclusive).
Returns:
List of canonical Instruction entities.
Raises:
ValueError: If the address range is entirely unmapped.
"""
...
@abstractmethod
def read_bytes(self, binary: Binary, address: Address, length: int) -> tuple[bytes, int]:
"""Read raw bytes from a binary at a given address.
Args:
binary: The binary to read from.
address: The starting address.
length: The number of bytes to read.
Returns:
A tuple of (bytes_read, actual_length). actual_length may be
less than length if the read crosses a segment boundary.
Raises:
ValueError: If the address is not mapped.
"""
...
@abstractmethod
def get_xrefs(self, binary: Binary, address: Address) -> list[Reference]:
"""Return cross-references to/from an address.
Args:
binary: The binary to query.
address: The address to find references for.
Returns:
List of canonical Reference entities.
"""
...
@abstractmethod
def get_callers(self, binary: Binary, function: Function) -> list[CallEdge]:
"""Return functions that call the given function.
Args:
binary: The binary to query.
function: The target function.
Returns:
List of CallEdge entities from callers to the target.
"""
...
@abstractmethod
def get_callees(self, binary: Binary, function: Function) -> list[CallEdge]:
"""Return functions called by the given function.
Args:
binary: The binary to query.
function: The target function.
Returns:
List of CallEdge entities from the target to callees.
"""
...
@abstractmethod
def get_callgraph(self, binary: Binary, function: Function, max_depth: int = 3) -> CallGraph:
"""Build a call graph rooted at a function.
Args:
binary: The binary to query.
function: The root function.
max_depth: Maximum depth to traverse (default 3, max 10).
Returns:
A bounded CallGraph entity.
"""
...
def register_binary(self, binary: Binary, fixture_name: str) -> None: # noqa: B027
"""Register a binary with a fixture name for fixture-based lookup.
This is a hook for fixture-based adapters (like FakeAdapter) that
need to map Binary entities to pre-defined test fixture data. Real
adapters (like GhidraAdapter) that use actual backend analysis
should leave this as a no-op.
Args:
binary: The canonical Binary entity to register.
fixture_name: The name of the fixture dataset to associate.
"""
pass # Default no-op for real adapters
def run_triage(self, binary: Binary, profile: AnalysisProfile | None = None) -> TriageResult:
"""Run the triage analysis pipeline on a binary.
Collects observations, evaluates heuristics, and identifies unknowns.
Returns a TriageResult with structured findings. The default
implementation uses the TriageEngine from the rules module.
Args:
binary: The binary to triage.
profile: Optional analysis profile for context.
Returns:
A TriageResult with observations, heuristics, and unknowns.
"""
from binary_analysis.rules.engine import TriageEngine
engine = TriageEngine(self, binary)
obs, heur, unk, diags = engine.run()
partial = len(diags) > 0
return TriageResult(
observations=obs,
heuristics=heur,
unknowns=unk,
engine_diagnostics=diags,
partial=partial,
)
def search(
self,
binary: Binary,
query: str,
search_type: str = "function",
) -> list[dict[str, Any]]:
"""Search for entities matching a query string.
Searches across functions, strings, symbols, imports, and exports
depending on the search type. Returns a list of result dicts with
entity type, name, address, and relevance.
Args:
binary: The binary to search within.
query: The search query string.
search_type: Type of entity to search ("function", "string", "symbol",
"import", "export", "all"; default "function").
Returns:
List of result dicts with keys: entity_type, name, address, and
optional match_detail.
This is a concrete method with a default implementation that searches
the basic fixtures. Backends may override for more sophisticated search.
"""
results: list[dict[str, Any]] = []
query_lower = query.lower()
def _match(name: str) -> bool:
"""Case-insensitive substring match."""
return query_lower in name.lower()
if search_type in ("function", "all"):
for fn in self.get_functions(binary, exclude_external=False, exclude_thunks=False):
if _match(fn.name):
results.append(
{
"entity_type": "function",
"name": fn.name,
"address": fn.address.to_dict() if fn.address else None,
"match_detail": f"Function name matches '{query}'",
"size_bytes": fn.size_bytes,
}
)
if search_type in ("string", "all"):
for s in self.get_strings(binary):
if _match(s.text):
results.append(
{
"entity_type": "string",
"name": s.text,
"address": s.address.to_dict() if s.address else None,
"match_detail": f"String contains '{query}'",
"encoding": s.encoding,
"length": s.length,
}
)
if search_type in ("symbol", "all"):
for sym in self.get_symbols(binary):
if _match(sym.name):
results.append(
{
"entity_type": "symbol",
"name": sym.name,
"address": sym.address.to_dict() if sym.address else None,
"match_detail": f"Symbol name matches '{query}'",
"scope": sym.scope,
}
)
if search_type in ("import", "all"):
for imp in self.get_imports(binary):
if _match(imp.symbol) or _match(imp.module):
results.append(
{
"entity_type": "import",
"name": imp.symbol,
"address": imp.address.to_dict() if imp.address else None,
"match_detail": f"Import matches '{query}' in module '{imp.module}'",
"module": imp.module,
}
)
if search_type in ("export", "all"):
for exp in self.get_exports(binary):
if _match(exp.name):
results.append(
{
"entity_type": "export",
"name": exp.name,
"address": exp.address.to_dict() if exp.address else None,
"match_detail": f"Export name matches '{query}'",
"kind": exp.kind,
}
)
return results
def trace(
self,
binary: Binary,
from_address: Address,
to_address: Address,
max_paths: int = 10,
max_depth: int = 10,
) -> tuple[list[list[dict[str, Any]]], bool]:
"""Find bounded paths between two entities.
Traces call paths from a source address to a target address within
the disclosed path count and depth limits.
Args:
binary: The binary to trace within.
from_address: The source entity address.
to_address: The destination entity address.
max_paths: Maximum number of paths to return (default 10).
max_depth: Maximum path depth to explore (default 10).
Returns:
A tuple of (paths, truncated) where paths is a list of paths,
each path is a list of entity dicts with name, address, and
depth, and truncated is True if paths were truncated at limits.
This is a concrete method with a default implementation that traces
through the call graph. Backends may override for more sophisticated
path finding.
"""
# Get all functions
functions = self.get_functions(binary, exclude_external=False, exclude_thunks=False)
# Build an adjacency map: function address -> list of callee addresses
adj: dict[str, list[str]] = {}
addr_to_name: dict[str, str] = {}
for fn in functions:
if fn.address is None:
continue
offset = fn.address.offset
addr_to_name[offset] = fn.name
callees = self.get_callees(binary, fn)
targets = []
for edge in callees:
if edge.to_address is not None:
targets.append(edge.to_address.offset)
adj[offset] = targets
from_offset = from_address.offset
to_offset = to_address.offset
paths: list[list[dict[str, Any]]] = []
truncated = False
# BFS/DFS with depth limiting
def _dfs(
current: str, target: str, visited: set[str], current_path: list[str], depth: int
) -> None:
nonlocal truncated
if len(paths) >= max_paths:
truncated = True
return
if depth > max_depth:
truncated = True
return
if current == target:
# Build the path
path_entities: list[dict[str, Any]] = []
for d, addr in enumerate([*current_path, current]):
path_entities.append(
{
"name": addr_to_name.get(addr, addr),
"address": {
"space": "ram",
"offset": addr,
"display": addr,
},
"depth": d,
}
)
paths.append(path_entities)
return
if current in visited:
return
visited.add(current)
for neighbor in adj.get(current, []):
if neighbor not in visited:
_dfs(neighbor, target, visited.copy(), [*current_path, current], depth + 1)
_dfs(from_offset, to_offset, set(), [], 1)
return paths, truncated
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
"""Ghidra backend adapter — PyGhidra bridge.
Provides the GhidraAdapter that bridges the canonical domain model to
PyGhidra/Ghidra. The adapter module contains the GhidraAdapter class and
the bridge module handles JVM startup and Ghidra API translation.
Exports:
GhidraAdapter: Backend adapter implementing the BackendAdapter interface
with PROJECT_SERIALIZED concurrency and capability detection.
"""
from __future__ import annotations
from binary_analysis.adapters.ghidra.adapter import GhidraAdapter
__all__ = ["GhidraAdapter"]
@@ -0,0 +1,409 @@
"""GhidraAdapter — bridges the canonical domain model to PyGhidra/Ghidra.
Implements the BackendAdapter interface using PyGhidra for JVM interaction
and Ghidra API calls. This is a skeleton implementation at this stage;
full analysis methods are deferred to subsequent features.
Key characteristics:
- PROJECT_SERIALIZED concurrency: only one operation per project at a time
- Error normalization: Ghidra/Java exceptions mapped to canonical error types
- Capability detection: reports available formats, analyzers, and limitations
- Idempotent initialization: safe to call initialize() multiple times
"""
from __future__ import annotations
import logging
from typing import Any, ClassVar
from binary_analysis.adapters.base import (
AnalysisProfile,
AnalysisResult,
BackendAdapter,
BinaryMetadata,
CallEdge,
ConcurrencyMode,
DecompilationResult,
)
from binary_analysis.adapters.ghidra.bridge import (
ensure_initialized,
get_ghidra_version,
is_pyghidra_available,
)
from binary_analysis.domain.entities import (
Address,
Binary,
CallGraph,
EntryPoint,
Export,
Function,
Import,
Instruction,
Project,
Reference,
Section,
String,
Symbol,
)
logger = logging.getLogger("binary_analysis.adapters.ghidra.adapter")
class GhidraAdapter(BackendAdapter):
"""Ghidra backend adapter via PyGhidra.
Concurrency: PROJECT_SERIALIZED.
Skeleton implementation — structural queries, decompile, disassemble,
and analysis methods raise NotImplementedError until fully implemented
in subsequent features. initialize(), capabilities(), and
available_profiles() are functional with capability detection.
"""
# ------------------------------------------------------------------
# Built-in analysis profiles
# ------------------------------------------------------------------
DEFAULT_PROFILES: ClassVar[list[AnalysisProfile]] = [
AnalysisProfile(
name="standard",
description=(
"Standard analysis: auto-analysis with function discovery, "
"reference analysis, decompiler parameter ID, and data type propagation"
),
analysers=[
"function_start",
"function_id",
"references",
"data_type_propagation",
"decompiler_parameter_id",
"stack_analysis",
],
),
AnalysisProfile(
name="quick",
description=("Quick analysis: function discovery and basic reference analysis only"),
analysers=[
"function_start",
"function_id",
"references",
],
),
AnalysisProfile(
name="deep",
description=(
"Deep analysis: full auto-analysis plus decompiler, callgraph, "
"and cross-reference analysis"
),
analysers=[
"function_start",
"function_id",
"references",
"data_type_propagation",
"decompiler_parameter_id",
"stack_analysis",
"decompiler",
"callgraph",
"xrefs",
"string_analysis",
"constant_propagation",
],
),
]
# ------------------------------------------------------------------
# Supported formats (reported by Ghidra)
# ------------------------------------------------------------------
_SUPPORTED_FORMATS: tuple[str, ...] = (
"PE",
"ELF",
"Mach-O",
"COFF",
"NES",
"RAW",
"MIPS",
"Intel Hex",
"Motorola SREC",
"DOS MZ",
)
_SUPPORTED_ARCHITECTURES: tuple[str, ...] = (
"x86",
"x86-64",
"ARM",
"ARM-64",
"MIPS",
"MIPS-64",
"PowerPC",
"PowerPC-64",
"SPARC",
"6502",
"Z80",
"Java Bytecode",
"Dalvik",
)
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def concurrency(self) -> ConcurrencyMode:
"""Ghidra requires project-level serialization.
Only one operation per Ghidra project at a time. This is because
Ghidra's ProgramDB is not thread-safe and Ghidra projects lock
at the program level.
"""
return ConcurrencyMode.PROJECT_SERIALIZED
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def initialize(self) -> None:
"""Initialize the Ghidra backend.
Starts the JVM and initializes Ghidra in headless mode.
Safe to call multiple times (idempotent).
Raises:
RuntimeError: If PyGhidra is not available or JVM startup fails.
"""
if not is_pyghidra_available():
raise RuntimeError(
"PyGhidra is not available. Run 'binary doctor' to diagnose "
"or 'binary bootstrap --apply' to install dependencies."
)
ensure_initialized()
logger.info("GhidraAdapter initialized")
# ------------------------------------------------------------------
# Capabilities
# ------------------------------------------------------------------
def capabilities(self) -> dict[str, Any]:
"""Return the Ghidra backend's capabilities.
Reports:
- Supported binary formats
- Supported architectures
- Available analyzers (by profile)
- Backend version
- Concurrency model
- PyGhidra status
- JVM status
Returns:
A dict describing capabilities, formats, and limitations.
"""
version = get_ghidra_version()
jvm_ready = ensure_initialized()
return {
"backend": "Ghidra",
"backend_version": version or "unknown",
"adapter": "GhidraAdapter",
"adapter_version": "0.1.0",
"concurrency": self.concurrency.value,
"pyghidra_available": is_pyghidra_available(),
"jvm_initialized": jvm_ready,
"formats": list(self._SUPPORTED_FORMATS),
"architectures": list(self._SUPPORTED_ARCHITECTURES),
"profiles": [
{
"name": p.name,
"description": p.description,
"analyser_count": len(p.analysers),
}
for p in self.DEFAULT_PROFILES
],
"limitations": [
"Skeleton implementation — structural queries and analysis "
"methods deferred to subsequent features",
"Single-project concurrency (PROJECT_SERIALIZED)",
"Headless mode only — no GUI interaction",
],
}
def available_profiles(self) -> list[AnalysisProfile]:
"""Return the list of available analysis profiles.
Returns:
List of built-in Ghidra analysis profiles.
"""
return list(self.DEFAULT_PROFILES)
# ------------------------------------------------------------------
# Import
# ------------------------------------------------------------------
def import_binary(self, path: str, project: Project) -> Binary:
"""Import a binary into Ghidra. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra binary import is deferred to subsequent features")
# ------------------------------------------------------------------
# Analysis
# ------------------------------------------------------------------
def analyze(self, binary: Binary, profile: AnalysisProfile) -> AnalysisResult:
"""Run analysis on an imported binary. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra analysis is deferred to subsequent features")
# ------------------------------------------------------------------
# Metadata
# ------------------------------------------------------------------
def get_metadata(self, binary: Binary) -> BinaryMetadata:
"""Return canonical metadata. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra metadata query is deferred to subsequent features")
# ------------------------------------------------------------------
# Structural queries
# ------------------------------------------------------------------
def get_sections(self, binary: Binary) -> list[Section]:
"""Return all sections in the binary. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra section query is deferred to subsequent features")
def get_entrypoints(self, binary: Binary) -> list[EntryPoint]:
"""Return all entry points. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra entrypoints query is deferred to subsequent features")
def get_imports(self, binary: Binary) -> list[Import]:
"""Return all imported symbols. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra imports query is deferred to subsequent features")
def get_exports(self, binary: Binary) -> list[Export]:
"""Return all exported symbols. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra exports query is deferred to subsequent features")
def get_symbols(self, binary: Binary) -> list[Symbol]:
"""Return all symbols. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra symbols query is deferred to subsequent features")
def get_strings(
self,
binary: Binary,
min_length: int = 4,
contains: str | None = None,
encoding_filter: str | None = None,
) -> list[String]:
"""Return decoded strings. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra strings query is deferred to subsequent features")
def get_functions(
self,
binary: Binary,
exclude_external: bool = True,
exclude_thunks: bool = True,
) -> list[Function]:
"""Return all functions. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra functions query is deferred to subsequent features")
# ------------------------------------------------------------------
# Focused analysis
# ------------------------------------------------------------------
def decompile(self, binary: Binary, function: Function) -> DecompilationResult:
"""Decompile a function. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra decompile is deferred to subsequent features")
def disassemble(
self, binary: Binary, start_address: Address, end_address: Address
) -> list[Instruction]:
"""Disassemble instructions in an address range. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra disassembly is deferred to subsequent features")
def read_bytes(self, binary: Binary, address: Address, length: int) -> tuple[bytes, int]:
"""Read raw bytes. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra byte reading is deferred to subsequent features")
# ------------------------------------------------------------------
# References
# ------------------------------------------------------------------
def get_xrefs(self, binary: Binary, address: Address) -> list[Reference]:
"""Return cross-references. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra xrefs query is deferred to subsequent features")
def get_callers(self, binary: Binary, function: Function) -> list[CallEdge]:
"""Return functions that call the given function. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra callers query is deferred to subsequent features")
def get_callees(self, binary: Binary, function: Function) -> list[CallEdge]:
"""Return functions called by the given function. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra callees query is deferred to subsequent features")
def get_callgraph(self, binary: Binary, function: Function, max_depth: int = 3) -> CallGraph:
"""Build a call graph. SKELETON — deferred.
Raises:
NotImplementedError: Full implementation deferred.
"""
raise NotImplementedError("Ghidra callgraph is deferred to subsequent features")
@@ -0,0 +1,286 @@
"""PyGhidra bridge layer — JVM startup and Ghidra API translation.
Provides safe, idempotent initialization of the Ghidra headless environment
and utilities for translating Ghidra exceptions to canonical error types.
This module is the only place in the codebase that imports PyGhidra.
All other modules interact with Ghidra through the adapter boundary.
"""
from __future__ import annotations
import logging
import os
from typing import Any
from binary_analysis.domain.enums import ExitCode
from binary_analysis.domain.errors import (
AnalysisFailedError,
BackendFailureError,
ImportFailedError,
OperationTimeoutError,
UnsupportedFormatError,
)
logger = logging.getLogger("binary_analysis.adapters.ghidra.bridge")
# ---------------------------------------------------------------------------
# State tracking
# ---------------------------------------------------------------------------
_initialized: bool = False
_pyghidra_available: bool | None = None
_ghidra_version: str | None = None
def is_pyghidra_available() -> bool:
"""Check whether PyGhidra can be imported.
Returns:
True if PyGhidra is importable and JAVA_HOME/GHIDRA_INSTALL_DIR
are configured.
"""
global _pyghidra_available
if _pyghidra_available is not None:
return _pyghidra_available
# Check environment variables
java_home = os.environ.get("JAVA_HOME")
ghidra_install = os.environ.get("GHIDRA_INSTALL_DIR")
if not java_home or not ghidra_install:
logger.debug("PyGhidra not available: JAVA_HOME and/or GHIDRA_INSTALL_DIR not set")
_pyghidra_available = False
return False
try:
import pyghidra # noqa: F401
_pyghidra_available = True
return True
except ImportError:
logger.debug("PyGhidra not available: import failed")
_pyghidra_available = False
return False
def get_ghidra_version() -> str | None:
"""Return the Ghidra version string if available.
The version is read from the Ghidra application.properties file
or set during initialization.
"""
global _ghidra_version
if _ghidra_version is not None:
return _ghidra_version
ghidra_install = os.environ.get("GHIDRA_INSTALL_DIR", "")
props_path = os.path.join(ghidra_install, "Ghidra", "application.properties")
if os.path.isfile(props_path):
try:
with open(props_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line.startswith("application.version="):
_ghidra_version = line.split("=", 1)[1].strip()
return _ghidra_version
except OSError:
logger.debug("Could not read Ghidra application.properties")
return None
def start_jvm(headless: bool = True) -> None:
"""Start the JVM and initialize Ghidra in headless mode.
This is the safe entry point for PyGhidra initialization. It handles:
- Verifying JAVA_HOME and GHIDRA_INSTALL_DIR
- Starting the JVM with appropriate memory settings
- Initializing Ghidra in headless mode
Args:
headless: If True, initialize Ghidra in headless mode (no GUI).
Raises:
RuntimeError: If PyGhidra is not available or JVM startup fails.
"""
global _initialized
if _initialized:
return
if not is_pyghidra_available():
raise RuntimeError(
"PyGhidra is not available. Ensure JAVA_HOME and GHIDRA_INSTALL_DIR "
"are set, and PyGhidra is installed."
)
try:
import pyghidra
pyghidra.start()
_initialized = True
_ghidra_version = get_ghidra_version()
logger.info("Ghidra JVM started successfully (version: %s)", _ghidra_version)
except Exception as e:
logger.error("Failed to start Ghidra JVM: %s", e)
raise RuntimeError(f"Failed to start Ghidra JVM: {e}") from e
def ensure_initialized() -> bool:
"""Ensure PyGhidra is initialized, starting the JVM if necessary.
Returns:
True if initialization succeeded or was already done,
False if PyGhidra is not available.
"""
global _initialized
if _initialized:
return True
try:
start_jvm(headless=True)
return True
except RuntimeError:
return False
def is_initialized() -> bool:
"""Return whether the Ghidra JVM has been started."""
return _initialized
# ---------------------------------------------------------------------------
# Ghidra error normalization
# ---------------------------------------------------------------------------
# Mapping of Ghidra exception class names to canonical error factories.
# Each entry is (exception_class_name_prefix, error_factory).
_GHIDRA_ERROR_MAP: list[tuple[str, Any]] = []
def _build_error_map() -> list[tuple[str, Any]]:
"""Build the Ghidra error-to-canonical mapping lazily."""
if _GHIDRA_ERROR_MAP:
return _GHIDRA_ERROR_MAP
_GHIDRA_ERROR_MAP.extend(
[
(
"CancelledException",
lambda msg, orig: OperationTimeoutError(f"Operation cancelled: {msg}"),
),
(
"TimeoutException",
lambda msg, orig: OperationTimeoutError(f"Operation timed out: {msg}"),
),
(
"UnsupportedLanguageException",
lambda msg, orig: UnsupportedFormatError(f"Unsupported language or format: {msg}"),
),
(
"DomainFileException",
lambda msg, orig: ImportFailedError(f"Domain file error: {msg}"),
),
(
"PortableExecutableException",
lambda msg, orig: ImportFailedError(f"PE import error: {msg}"),
),
(
"ELFException",
lambda msg, orig: ImportFailedError(f"ELF import error: {msg}"),
),
(
"MachException",
lambda msg, orig: ImportFailedError(f"Mach-O import error: {msg}"),
),
(
"AssertException",
lambda msg, orig: AnalysisFailedError(f"Ghidra assertion failed: {msg}"),
),
(
"IOException",
lambda msg, orig: BackendFailureError(
f"Ghidra I/O error: {msg}", original_error=str(orig)
),
),
(
"RuntimeException",
lambda msg, orig: BackendFailureError(
f"Ghidra runtime error: {msg}", original_error=str(orig)
),
),
]
)
return _GHIDRA_ERROR_MAP
def normalize_error(error: Exception) -> Any:
"""Map a Ghidra or Java exception to a canonical error type.
Uses class name matching against known Ghidra error types. Falls back
to BackendFailureError for unrecognized exceptions.
Args:
error: The exception raised by Ghidra/PyGhidra/JVM.
Returns:
A BinaryAnalysisError subclass instance with the appropriate
exit code and message.
"""
error_map = _build_error_map()
error_name = type(error).__name__
error_msg = str(error)
for prefix, factory in error_map:
if prefix in error_name:
return factory(error_msg, error)
# Fallback: generic backend failure
return BackendFailureError(
f"Unexpected Ghidra error ({error_name}): {error_msg}",
original_error=error_msg,
)
def map_exit_code_to_error(ghidra_exception: Exception) -> ExitCode:
"""Map a Ghidra exception to the appropriate canonical exit code.
Args:
ghidra_exception: The Ghidra/Java exception.
Returns:
The canonical ExitCode for this error class.
"""
error = normalize_error(ghidra_exception)
return ExitCode(error.exit_code)
# ---------------------------------------------------------------------------
# Ghidra API translation utilities (skeleton)
# ---------------------------------------------------------------------------
def translate_program_to_binary(program: Any) -> dict[str, Any]:
"""Translate a Ghidra Program object to a canonical binary dict.
Skeleton only — returns minimal metadata. Full translation deferred
to subsequent features.
Args:
program: A Ghidra Program object.
Returns:
A dict with basic binary identity fields.
"""
raise NotImplementedError("Full Ghidra API translation is deferred to subsequent features")
def translate_function_manager(program: Any) -> list[dict[str, Any]]:
"""Translate Ghidra's FunctionManager data to canonical function dicts.
Skeleton only — deferred to subsequent features.
"""
raise NotImplementedError("Full Ghidra API translation is deferred to subsequent features")