mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
672 lines
21 KiB
Python
672 lines
21 KiB
Python
"""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
|