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,139 @@
"""Canonical domain model — entities, enums, schemas, errors, and selectors.
All public symbols are re-exported for convenient imports:
from binary_analysis.domain import Address, Project, Function, ...
"""
from __future__ import annotations
from binary_analysis.domain.entities import (
Address,
AuditEvent,
BasicBlock,
Binary,
CallGraph,
Capability,
Diagnostic,
EntryPoint,
Export,
Function,
Heuristic,
Import,
Inference,
Instruction,
Observation,
Project,
Reference,
Report,
Section,
String,
Symbol,
Unknown,
)
from binary_analysis.domain.enums import (
AuditResult,
Confidence,
DiagnosticSeverity,
Endianness,
ExitCode,
FunctionNameSource,
ImportResolution,
ProjectState,
ReferenceKind,
ReportType,
)
from binary_analysis.domain.errors import (
AmbiguousSelectorError,
AnalysisFailedError,
BackendFailureError,
BinaryAnalysisError,
BinaryNotFoundError,
DependencyMissingError,
EntityNotFoundError,
ImportFailedError,
InvalidArgsError,
InvalidConfigError,
OperationTimeoutError,
ProjectNotFoundError,
UnsupportedFormatError,
error_type_for,
fail,
)
from binary_analysis.domain.schemas import (
canonical_address,
deserialize_address,
entity_to_dict,
safe_json_dumps,
serialize_address,
serialize_enum,
)
from binary_analysis.domain.selectors import (
ParsedSelector,
ResolvedEntity,
format_candidates,
parse_selector,
resolve_function,
resolve_functions,
)
__all__ = [
"Address",
"AmbiguousSelectorError",
"AnalysisFailedError",
"AuditEvent",
"AuditResult",
"BackendFailureError",
"BasicBlock",
"Binary",
"BinaryAnalysisError",
"BinaryNotFoundError",
"CallGraph",
"Capability",
"Confidence",
"DependencyMissingError",
"Diagnostic",
"DiagnosticSeverity",
"Endianness",
"EntityNotFoundError",
"EntryPoint",
"ExitCode",
"Export",
"Function",
"FunctionNameSource",
"Heuristic",
"Import",
"ImportFailedError",
"ImportResolution",
"Inference",
"Instruction",
"InvalidArgsError",
"InvalidConfigError",
"Observation",
"OperationTimeoutError",
"ParsedSelector",
"Project",
"ProjectNotFoundError",
"ProjectState",
"Reference",
"ReferenceKind",
"Report",
"ReportType",
"ResolvedEntity",
"Section",
"String",
"Symbol",
"Unknown",
"UnsupportedFormatError",
"canonical_address",
"deserialize_address",
"entity_to_dict",
"error_type_for",
"fail",
"format_candidates",
"parse_selector",
"resolve_function",
"resolve_functions",
"safe_json_dumps",
"serialize_address",
"serialize_enum",
]
@@ -0,0 +1,440 @@
"""Canonical domain entities as dataclasses.
All entities use typed fields with proper defaults. Every entity can be
serialized to a JSON-compatible dict via asdict() or the schema helpers.
Address objects use the canonical structured format with space, offset,
display, and optional file_offset.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID, uuid4
from binary_analysis.domain.enums import (
AuditResult,
Confidence,
DiagnosticSeverity,
Endianness,
FunctionNameSource,
ImportResolution,
ProjectState,
ReferenceKind,
ReportType,
)
# ---------------------------------------------------------------------------
# Canonical Address
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Address:
"""Canonical structured address.
Attributes:
space: Address space name (e.g., "ram", "register", "const").
offset: Hex-prefixed offset string (e.g., "0x4018d0").
display: Human-readable display form (e.g., "0x4018d0").
file_offset: Optional byte offset within the file on disk.
"""
space: str
offset: str
display: str
file_offset: int | None = None
def __post_init__(self) -> None:
"""Validate offset format."""
if not self.offset.startswith("0x"):
raise ValueError(f"Address offset must start with '0x', got: {self.offset!r}")
def to_dict(self) -> dict[str, Any]:
"""Serialize to a canonical dict for JSON output."""
result: dict[str, Any] = {
"space": self.space,
"offset": self.offset,
"display": self.display,
}
if self.file_offset is not None:
result["file_offset"] = self.file_offset
return result
@classmethod
def from_dict(cls, data: dict[str, Any]) -> Address:
"""Deserialize from a canonical dict."""
return cls(
space=data["space"],
offset=data["offset"],
display=data["display"],
file_offset=data.get("file_offset"),
)
# ---------------------------------------------------------------------------
# Domain Entities
# ---------------------------------------------------------------------------
@dataclass
class Project:
"""Persistent analysis workspace.
Identity: UUID.
"""
id: UUID = field(default_factory=uuid4)
name: str = ""
state: ProjectState = ProjectState.CREATED
created_at: str = ""
updated_at: str = ""
workspace_version: str = "1"
binary_count: int = 0
is_stale: bool = False
lock: dict[str, Any] | None = None
description: str | None = None
max_binary_size_bytes: int | None = None
@dataclass
class Binary:
"""Imported artifact identified by SHA-256.
Identity: UUID + SHA-256.
"""
id: UUID = field(default_factory=uuid4)
sha256: str = ""
path: str = ""
format: str = ""
import_mode: str = "copy"
size_bytes: int = 0
architecture: str | None = None
endianness: Endianness | None = None
entry_point: Address | None = None
compiler: str | None = None
source_language: str | None = None
imported_at: str | None = None
analyzed_at: str | None = None
analysis_profile: str | None = None
is_stale: bool = False
@dataclass
class Section:
"""Mapped code or data region within a binary.
Identity: Name + binary ID.
"""
name: str = ""
binary_id: UUID | None = None
address: Address | None = None
virtual_size: int = 0
raw_size: int = 0
flags: list[str] = field(default_factory=list)
entropy: float | None = None
content_hash: str | None = None
@dataclass
class EntryPoint:
"""Process, library, boot, or firmware entry point.
Identity: Address within binary.
"""
address: Address | None = None
kind: str = "unknown"
confidence: Confidence = Confidence.UNKNOWN
name: str | None = None
binary_id: UUID | None = None
@dataclass
class Import:
"""External dependency symbol.
Identity: Address within binary.
"""
module: str = ""
symbol: str = ""
address: Address | None = None
resolution: ImportResolution = ImportResolution.UNRESOLVED
ordinal: int | None = None
binary_id: UUID | None = None
@dataclass
class Export:
"""Public symbol or forwarder.
Identity: Address or ordinal.
"""
name: str = ""
address: Address | None = None
ordinal: int | None = None
forwarder: str | None = None
kind: str = "function"
binary_id: UUID | None = None
@dataclass
class Symbol:
"""Named entity with source and scope.
Identity: Address within binary.
"""
name: str = ""
address: Address | None = None
source: FunctionNameSource = FunctionNameSource.UNKNOWN
scope: str = "unknown"
binary_id: UUID | None = None
@dataclass
class String:
"""Decoded string at a specific address.
Identity: Address + encoding + length.
"""
text: str = ""
encoding: str = "ASCII"
address: Address | None = None
length: int = 0
binary_id: UUID | None = None
@dataclass
class Function:
"""Callable code region.
Identity: Binary ID + entry address.
"""
name: str = ""
address: Address | None = None
size_bytes: int = 0
confidence: Confidence = Confidence.UNKNOWN
name_source: FunctionNameSource = FunctionNameSource.UNKNOWN
binary_id: UUID | None = None
is_external: bool = False
is_thunk: bool = False
signature: str | None = None
source_language: str | None = None
basic_block_count: int | None = None
instruction_count: int | None = None
cyclomatic_complexity: int | None = None
@dataclass
class Instruction:
"""Canonical machine instruction.
Identity: Address within function.
"""
mnemonic: str = ""
operands: str = ""
bytes_hex: str = ""
address: Address | None = None
size_bytes: int = 0
function_id: str | None = None
@dataclass
class BasicBlock:
"""Control-flow node within a function.
Identity: Start address within function.
"""
start_address: Address | None = None
end_address: Address | None = None
instruction_count: int = 0
function_id: str | None = None
is_entry: bool = False
is_exit: bool = False
@dataclass
class Reference:
"""Directed call, jump, read, write, or data relation.
Identity: Address pair + kind.
"""
from_addr: Address | None = None
to_addr: Address | None = None
kind: ReferenceKind = ReferenceKind.UNKNOWN
confidence: Confidence = Confidence.UNKNOWN
binary_id: UUID | None = None
@dataclass
class CallGraph:
"""Bounded call graph rooted at a function.
Identity: Derived from function references.
"""
root_address: Address | None = None
nodes: list[dict[str, Any]] = field(default_factory=list)
edges: list[dict[str, Any]] = field(default_factory=list)
max_depth: int = 3
total_nodes: int = 0
total_edges: int = 0
truncated: bool = False
binary_id: UUID | None = None
@dataclass
class Diagnostic:
"""Warning or limitation from an analysis run.
Identity: Unique within analysis run.
"""
severity: DiagnosticSeverity = DiagnosticSeverity.INFO
category: str = ""
message: str = ""
component: str | None = None
remediation: str | None = None
recoverable: bool = True
@dataclass
class Capability:
"""Rule-derived functional indicator.
Identity: Name within binary.
"""
name: str = ""
confidence: Confidence = Confidence.UNKNOWN
evidence: list[dict[str, Any]] = field(default_factory=list)
binary_id: UUID | None = None
@dataclass
class Observation:
"""Direct deterministic fact from analysis.
Identity: Unique within analysis run.
"""
category: str = ""
description: str = ""
source: str = ""
address: Address | None = None
evidence: Any | None = None
binary_id: UUID | None = None
@dataclass
class Heuristic:
"""Rule-derived interpretation with confidence.
Identity: Name within analysis run.
"""
name: str = ""
description: str = ""
confidence: Confidence = Confidence.UNKNOWN
rule_id: str | None = None
evidence: list[dict[str, Any]] = field(default_factory=list)
binary_id: UUID | None = None
@dataclass
class Inference:
"""Agent-generated interpretation.
Identity: Unique within analysis run.
"""
description: str = ""
confidence: Confidence = Confidence.UNKNOWN
basis: list[str] = field(default_factory=list)
binary_id: UUID | None = None
@dataclass
class Unknown:
"""Explicit unresolved question.
Identity: Address within binary.
"""
address: Address | None = None
question: str = ""
category: str | None = None
binary_id: UUID | None = None
@dataclass
class Report:
"""Durable handoff artifact.
Identity: UUID.
"""
id: UUID = field(default_factory=uuid4)
report_type: ReportType = ReportType.TRIAGE
project_id: UUID | None = None
binary_id: UUID | None = None
created_at: str = ""
format: str = "json"
summary: str | None = None
sections: list[dict[str, Any]] = field(default_factory=list)
@dataclass
class AuditEvent:
"""Append-only provenance event.
Identity: Timestamp sequence.
"""
timestamp: str = ""
event_type: str = ""
result: AuditResult = AuditResult.SUCCESS
project_id: UUID | None = None
binary_id: UUID | None = None
user: str | None = None
details: dict[str, Any] = field(default_factory=dict)
@dataclass
class TriageResult:
"""Aggregate result of a triage analysis.
Contains observations (facts), heuristics (interpretations),
unknowns (open questions), and engine diagnostics.
"""
observations: list[Observation] = field(default_factory=list)
heuristics: list[Heuristic] = field(default_factory=list)
unknowns: list[Unknown] = field(default_factory=list)
engine_diagnostics: list[dict[str, Any]] = field(default_factory=list)
partial: bool = False
@dataclass
class DiagnosticsResult:
"""Cumulative diagnostics across project lifecycle.
Contains all persistent diagnostics from analysis, triage,
and other commands, plus any current engine diagnostics.
"""
diagnostics: list[dict[str, Any]] = field(default_factory=list)
total: int = 0
by_severity: dict[str, int] = field(
default_factory=lambda: {"INFO": 0, "WARNING": 0, "ERROR": 0}
)
@@ -0,0 +1,120 @@
"""Canonical enumerations for the binary analysis domain model.
All enums serialize as UPPER_CASE strings in JSON output. Integer ordinals and
lowercase representations are never used.
"""
from __future__ import annotations
from enum import Enum
class ExitCode(int, Enum):
"""Standard exit codes for the binary CLI.
Every error that terminates the CLI maps to one of these codes.
"""
SUCCESS = 0
GENERIC_ERROR = 1
INVALID_ARGS = 2
DEPENDENCY_MISSING = 3
INVALID_CONFIG = 4
UNSUPPORTED_FORMAT = 5
PROJECT_NOT_FOUND = 6
BINARY_NOT_FOUND = 7
AMBIGUOUS_SELECTOR = 8
ENTITY_NOT_FOUND = 9
IMPORT_FAILED = 10
ANALYSIS_FAILED = 11
OPERATION_TIMEOUT = 12
BACKEND_FAILURE = 13
class ProjectState(str, Enum):
"""Lifecycle states for a binary analysis project."""
CREATED = "CREATED"
IMPORTED = "IMPORTED"
ANALYZING = "ANALYZING"
READY = "READY"
STALE = "STALE"
FAILED = "FAILED"
class Confidence(str, Enum):
"""Confidence levels for observations, heuristics, and inferences."""
HIGH = "HIGH"
MEDIUM = "MEDIUM"
LOW = "LOW"
UNKNOWN = "UNKNOWN"
class DiagnosticSeverity(str, Enum):
"""Severity levels for diagnostic entries."""
INFO = "INFO"
WARNING = "WARNING"
ERROR = "ERROR"
class ReferenceKind(str, Enum):
"""Types of cross-references between entities."""
CALL = "CALL"
JUMP = "JUMP"
READ = "READ"
WRITE = "WRITE"
DATA = "DATA"
IMPORT = "IMPORT"
EXPORT = "EXPORT"
INDIRECT = "INDIRECT"
UNKNOWN = "UNKNOWN"
class Endianness(str, Enum):
"""Byte ordering of the target architecture."""
LITTLE = "LITTLE"
BIG = "BIG"
MIXED = "MIXED"
UNKNOWN = "UNKNOWN"
class FunctionNameSource(str, Enum):
"""Provenance of a function name."""
ORIGINAL = "ORIGINAL"
IMPORTED = "IMPORTED"
DEBUG = "DEBUG"
BACKEND_GENERATED = "BACKEND_GENERATED"
USER_ANNOTATION = "USER_ANNOTATION"
AGENT_SUGGESTION = "AGENT_SUGGESTION"
UNKNOWN = "UNKNOWN"
class ImportResolution(str, Enum):
"""Resolution status of an imported symbol."""
RESOLVED = "RESOLVED"
PARTIAL = "PARTIAL"
UNRESOLVED = "UNRESOLVED"
class ReportType(str, Enum):
"""Types of analysis reports that can be generated."""
TRIAGE = "TRIAGE"
FOCUSED = "FOCUSED"
PROJECT = "PROJECT"
class AuditResult(str, Enum):
"""Outcome of an audited operation."""
SUCCESS = "SUCCESS"
PARTIAL = "PARTIAL"
FAILED = "FAILED"
CANCELLED = "CANCELLED"
REFUSED = "REFUSED"
@@ -0,0 +1,186 @@
"""Canonical error types and exit codes for the binary CLI.
Each error type maps to a specific exit code from ExitCode enum (0-13).
The error hierarchy allows callers to catch specific error types while
the base class provides a fallback for GENERIC_ERROR.
"""
from __future__ import annotations
import sys
from typing import Any
from binary_analysis.domain.enums import ExitCode
class BinaryAnalysisError(Exception):
"""Base exception for all binary analysis errors.
Every BinaryAnalysisError carries an exit code and can produce
a JSON-serializable representation for the envelope's diagnostics.
"""
def __init__(self, message: str, exit_code: ExitCode = ExitCode.GENERIC_ERROR) -> None:
super().__init__(message)
self.message = message
self.exit_code = exit_code
def to_diagnostic(self) -> dict[str, Any]:
"""Return a diagnostic entry suitable for the envelope."""
return {
"severity": "ERROR",
"message": self.message,
}
class InvalidArgsError(BinaryAnalysisError):
"""Raised when CLI arguments are invalid. Exit code 2."""
def __init__(self, message: str) -> None:
super().__init__(message, ExitCode.INVALID_ARGS)
class DependencyMissingError(BinaryAnalysisError):
"""Raised when a required external dependency is missing. Exit code 3."""
def __init__(self, message: str) -> None:
super().__init__(message, ExitCode.DEPENDENCY_MISSING)
class InvalidConfigError(BinaryAnalysisError):
"""Raised when configuration is invalid or corrupted. Exit code 4."""
def __init__(self, message: str) -> None:
super().__init__(message, ExitCode.INVALID_CONFIG)
def to_diagnostic(self) -> dict[str, Any]:
return {
"severity": "ERROR",
"message": self.message,
"category": "config",
}
class UnsupportedFormatError(BinaryAnalysisError):
"""Raised when the binary format is not supported. Exit code 5."""
def __init__(self, message: str) -> None:
super().__init__(message, ExitCode.UNSUPPORTED_FORMAT)
class ProjectNotFoundError(BinaryAnalysisError):
"""Raised when a project does not exist. Exit code 6."""
def __init__(self, project: str) -> None:
super().__init__(f"Project not found: {project}", ExitCode.PROJECT_NOT_FOUND)
class BinaryNotFoundError(BinaryAnalysisError):
"""Raised when a binary is not found in a project. Exit code 7."""
def __init__(self, message: str = "No binary has been imported into this project") -> None:
super().__init__(message, ExitCode.BINARY_NOT_FOUND)
class AmbiguousSelectorError(BinaryAnalysisError):
"""Raised when an entity selector matches multiple entities. Exit code 8."""
def __init__(self, message: str, candidates: list[dict[str, Any]] | None = None) -> None:
super().__init__(message, ExitCode.AMBIGUOUS_SELECTOR)
self.candidates = candidates or []
def to_diagnostic(self) -> dict[str, Any]:
diag = super().to_diagnostic()
if self.candidates:
diag["candidates"] = self.candidates
return diag
class EntityNotFoundError(BinaryAnalysisError):
"""Raised when a referenced entity does not exist. Exit code 9."""
def __init__(self, entity_type: str, selector: str) -> None:
super().__init__(
f"{entity_type} not found: {selector}",
ExitCode.ENTITY_NOT_FOUND,
)
self.entity_type = entity_type
self.selector = selector
class ImportFailedError(BinaryAnalysisError):
"""Raised when binary import fails. Exit code 10."""
def __init__(self, message: str, binary_path: str | None = None) -> None:
super().__init__(message, ExitCode.IMPORT_FAILED)
self.binary_path = binary_path
class AnalysisFailedError(BinaryAnalysisError):
"""Raised when analysis fails completely (not partial). Exit code 11."""
def __init__(self, message: str, project: str | None = None) -> None:
super().__init__(message, ExitCode.ANALYSIS_FAILED)
self.project = project
class OperationTimeoutError(BinaryAnalysisError):
"""Raised when an operation exceeds its timeout. Exit code 12."""
def __init__(self, message: str = "Operation timed out") -> None:
super().__init__(message, ExitCode.OPERATION_TIMEOUT)
def to_diagnostic(self) -> dict[str, Any]:
return {
"severity": "ERROR",
"message": self.message,
"category": "timeout",
"recoverable": True,
}
class BackendFailureError(BinaryAnalysisError):
"""Raised when the backend encounters an internal failure. Exit code 13."""
def __init__(self, message: str, original_error: str | None = None) -> None:
super().__init__(message, ExitCode.BACKEND_FAILURE)
self.original_error = original_error
def to_diagnostic(self) -> dict[str, Any]:
diag = super().to_diagnostic()
if self.original_error:
diag["backend_error"] = self.original_error
return diag
# ---------------------------------------------------------------------------
# Exit code to error type lookup
# ---------------------------------------------------------------------------
_EXIT_CODE_TO_ERROR: dict[ExitCode, type[BinaryAnalysisError]] = {
ExitCode.SUCCESS: BinaryAnalysisError,
ExitCode.GENERIC_ERROR: BinaryAnalysisError,
ExitCode.INVALID_ARGS: InvalidArgsError,
ExitCode.DEPENDENCY_MISSING: DependencyMissingError,
ExitCode.INVALID_CONFIG: InvalidConfigError,
ExitCode.UNSUPPORTED_FORMAT: UnsupportedFormatError,
ExitCode.PROJECT_NOT_FOUND: ProjectNotFoundError,
ExitCode.BINARY_NOT_FOUND: BinaryNotFoundError,
ExitCode.AMBIGUOUS_SELECTOR: AmbiguousSelectorError,
ExitCode.ENTITY_NOT_FOUND: EntityNotFoundError,
ExitCode.IMPORT_FAILED: ImportFailedError,
ExitCode.ANALYSIS_FAILED: AnalysisFailedError,
ExitCode.OPERATION_TIMEOUT: OperationTimeoutError,
ExitCode.BACKEND_FAILURE: BackendFailureError,
}
def error_type_for(code: ExitCode) -> type[BinaryAnalysisError]:
"""Get the error class for a given exit code."""
return _EXIT_CODE_TO_ERROR.get(code, BinaryAnalysisError)
def fail(error: BinaryAnalysisError) -> None:
"""Print the error to stderr and exit with the appropriate code."""
print(f"Error: {error.message}", file=sys.stderr)
sys.exit(error.exit_code)
@@ -0,0 +1,453 @@
"""JSON serialization helpers for the canonical domain model.
Key serialization rules:
- Addresses: structured objects (space, offset, display, optional file_offset)
- Sizes: integer bytes (JSON number), never strings
- Unknown/null fields: serialize as JSON null, not "" or 0
- Enum values: UPPER_CASE strings matching documented enum members
- Entity objects: only canonical fields; no backend-specific keys
- Strings: correct JSON escaping of embedded quotes, backslashes, control chars
"""
from __future__ import annotations
import dataclasses
import json
from enum import Enum
from typing import Any
from binary_analysis.domain.entities import Address
# ---------------------------------------------------------------------------
# Address serialization
# ---------------------------------------------------------------------------
def serialize_address(addr: Address | None) -> dict[str, Any] | None:
"""Serialize an Address to its canonical dict form, or null."""
if addr is None:
return None
return addr.to_dict()
def deserialize_address(data: dict[str, Any] | None) -> Address | None:
"""Deserialize a canonical dict back to an Address, or null."""
if data is None:
return None
return Address.from_dict(data)
def canonical_address(
space: str, offset: str, display: str | None = None, file_offset: int | None = None
) -> Address:
"""Factory for creating canonical addresses with validated format.
Args:
space: Address space name (e.g., "ram", "register").
offset: Hex-prefixed offset string (e.g., "0x401000").
display: Display string. Defaults to offset if not provided.
file_offset: Optional byte offset within the file.
"""
if not offset.startswith("0x"):
offset = f"0x{offset}"
if display is None:
display = offset
return Address(space=space, offset=offset, display=display, file_offset=file_offset)
# ---------------------------------------------------------------------------
# Enum serialization
# ---------------------------------------------------------------------------
def serialize_enum(value: Enum | None) -> str | None:
"""Serialize an enum member to its UPPER_CASE string name, or null."""
if value is None:
return None
if isinstance(value, str):
return value.upper()
return str(value.value)
# ---------------------------------------------------------------------------
# Entity serialization (generic)
# ---------------------------------------------------------------------------
def entity_to_dict(
entity: Any,
canonical_fields: set[str] | None = None,
) -> dict[str, Any]:
"""Convert a dataclass entity to a dict using only canonical fields.
Args:
entity: The dataclass entity to serialize.
canonical_fields: Optional whitelist of field names to include.
If not provided, all dataclass fields are serialized.
Returns:
A dict with only canonical fields, with proper serialization:
- Addresses become structured dicts or null
- Enums become UPPER_CASE strings or null
- UUIDs become strings
- None values remain as null
- Sizes remain as integers (never converted to strings)
"""
result: dict[str, Any] = {}
fields_dict = {f.name: f for f in dataclasses.fields(entity)}
for field_name in fields_dict:
# Skip non-canonical fields if a whitelist is provided
if canonical_fields is not None and field_name not in canonical_fields:
continue
value = getattr(entity, field_name)
# Serialize based on type
serialized = _serialize_value(value)
# Only include optional fields if they have a non-None value,
# to keep the JSON minimal
result[field_name] = serialized
return result
def _serialize_value(value: Any) -> Any:
"""Serialize a single value to its JSON-compatible form.
Rules:
- None → None (JSON null)
- Address → structured dict or None
- Enum → UPPER_CASE string or None
- UUID → string
- list → list of serialized values
- dict → dict of serialized values
- booleans → remain booleans
- integers → remain integers (never strings)
- floats → remain floats
- strings → remain strings
"""
if value is None:
return None
if isinstance(value, Address):
return value.to_dict()
if isinstance(value, Enum):
return value.value
if isinstance(value, list):
return [_serialize_value(item) for item in value]
if isinstance(value, dict):
return {k: _serialize_value(v) for k, v in value.items()}
# Primitives pass through as-is
return value
# ---------------------------------------------------------------------------
# Canonical field whitelists per entity type
# These ensure no backend-specific keys leak into entity objects.
# ---------------------------------------------------------------------------
PROJECT_CANONICAL_FIELDS = frozenset(
{
"id",
"name",
"state",
"created_at",
"updated_at",
"workspace_version",
"binary_count",
"is_stale",
"lock",
"description",
"max_binary_size_bytes",
}
)
BINARY_CANONICAL_FIELDS = frozenset(
{
"id",
"sha256",
"path",
"format",
"import_mode",
"size_bytes",
"architecture",
"endianness",
"entry_point",
"compiler",
"source_language",
"imported_at",
"analyzed_at",
"analysis_profile",
"is_stale",
}
)
SECTION_CANONICAL_FIELDS = frozenset(
{
"name",
"binary_id",
"address",
"virtual_size",
"raw_size",
"flags",
"entropy",
"content_hash",
}
)
ENTRYPOINT_CANONICAL_FIELDS = frozenset(
{
"address",
"kind",
"confidence",
"name",
"binary_id",
}
)
IMPORT_CANONICAL_FIELDS = frozenset(
{
"module",
"symbol",
"address",
"resolution",
"ordinal",
"binary_id",
}
)
EXPORT_CANONICAL_FIELDS = frozenset(
{
"name",
"address",
"ordinal",
"forwarder",
"kind",
"binary_id",
}
)
SYMBOL_CANONICAL_FIELDS = frozenset(
{
"name",
"address",
"source",
"scope",
"binary_id",
}
)
STRING_CANONICAL_FIELDS = frozenset(
{
"text",
"encoding",
"address",
"length",
"binary_id",
}
)
FUNCTION_CANONICAL_FIELDS = frozenset(
{
"name",
"address",
"size_bytes",
"confidence",
"name_source",
"binary_id",
"is_external",
"is_thunk",
"signature",
"source_language",
"basic_block_count",
"instruction_count",
"cyclomatic_complexity",
}
)
INSTRUCTION_CANONICAL_FIELDS = frozenset(
{
"mnemonic",
"operands",
"bytes_hex",
"address",
"size_bytes",
"function_id",
}
)
BASIC_BLOCK_CANONICAL_FIELDS = frozenset(
{
"start_address",
"end_address",
"instruction_count",
"function_id",
"is_entry",
"is_exit",
}
)
REFERENCE_CANONICAL_FIELDS = frozenset(
{
"from_addr",
"to_addr",
"kind",
"confidence",
"binary_id",
}
)
CALLGRAPH_CANONICAL_FIELDS = frozenset(
{
"root_address",
"nodes",
"edges",
"max_depth",
"total_nodes",
"total_edges",
"truncated",
"binary_id",
}
)
DIAGNOSTIC_CANONICAL_FIELDS = frozenset(
{
"severity",
"category",
"message",
"component",
"remediation",
"recoverable",
}
)
CAPABILITY_CANONICAL_FIELDS = frozenset(
{
"name",
"confidence",
"evidence",
"binary_id",
}
)
OBSERVATION_CANONICAL_FIELDS = frozenset(
{
"category",
"description",
"source",
"address",
"evidence",
"binary_id",
}
)
HEURISTIC_CANONICAL_FIELDS = frozenset(
{
"name",
"description",
"confidence",
"rule_id",
"evidence",
"binary_id",
}
)
INFERENCE_CANONICAL_FIELDS = frozenset(
{
"description",
"confidence",
"basis",
"binary_id",
}
)
UNKNOWN_CANONICAL_FIELDS = frozenset(
{
"address",
"question",
"category",
"binary_id",
}
)
REPORT_CANONICAL_FIELDS = frozenset(
{
"id",
"report_type",
"project_id",
"binary_id",
"created_at",
"format",
"summary",
"sections",
}
)
AUDIT_EVENT_CANONICAL_FIELDS = frozenset(
{
"timestamp",
"event_type",
"result",
"project_id",
"binary_id",
"user",
"details",
}
)
# ---------------------------------------------------------------------------
# JSON encoding with correct string escaping
# ---------------------------------------------------------------------------
def safe_json_dumps(obj: Any, indent: int = 2, ensure_ascii: bool = False) -> str:
"""Serialize to JSON with correct escaping of embedded quotes, backslashes,
and control characters.
Uses json.dumps with ensure_ascii=False (preserving Unicode) unless
ensure_ascii is explicitly True. The standard library json module
correctly escapes ", \\, and control characters by default, but we
document the expected behavior here.
Args:
obj: The object to serialize.
indent: Indentation level (default 2 spaces).
ensure_ascii: Whether to escape non-ASCII characters.
Returns:
A valid JSON string.
Serialization rules enforced:
- Double quotes in strings → \\"
- Backslashes in strings → \\\\
- Control characters → \\uXXXX
- Unicode preserved by default (ensure_ascii=False)
"""
return json.dumps(obj, indent=indent, ensure_ascii=ensure_ascii)
# ---------------------------------------------------------------------------
# Serializable entity mixin
# ---------------------------------------------------------------------------
class SerializableEntity:
"""Mixin for entities that need JSON serialization.
Subclasses must implement to_dict() and can override _canonical_fields
to restrict which fields are serialized.
"""
_canonical_fields: frozenset[str] | None = None
def to_dict(self) -> dict[str, Any]:
"""Convert to a JSON-compatible dict."""
if self._canonical_fields is not None:
return entity_to_dict(self, set(self._canonical_fields))
return entity_to_dict(self)
def to_json(self, indent: int = 2) -> str:
"""Serialize to JSON string with correct escaping."""
return safe_json_dumps(self.to_dict(), indent=indent)
@@ -0,0 +1,264 @@
"""Entity selectors for resolving function, address, and entity references.
Selectors are human-readable strings that resolve to specific entities.
Supported selector formats:
- function:<name> — Resolve a function by name (exact or fuzzy match)
- function:<address> — Resolve a function by address
- address:<addr-range> — Resolve an address range (e.g., 0x1000..0x2000)
- name:<entity-name> — Generic entity lookup by name
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from binary_analysis.domain.entities import Function
from binary_analysis.domain.errors import AmbiguousSelectorError, EntityNotFoundError
# ---------------------------------------------------------------------------
# Selector types
# ---------------------------------------------------------------------------
class SelectorKind:
"""Selector kind constants."""
FUNCTION = "function"
ADDRESS = "address"
NAME = "name"
# ---------------------------------------------------------------------------
# Parsed selector
# ---------------------------------------------------------------------------
@dataclass
class ParsedSelector:
"""Result of parsing an entity selector string.
Attributes:
kind: The selector kind (function, address, name).
value: The parsed selector value.
raw: The original selector string.
is_address: Whether the value represents an address.
address_value: Parsed address offset (hex string without 0x prefix) if applicable.
is_range: Whether the selector specifies a range.
range_start: Start of range if is_range is True.
range_end: End of range if is_range is True.
"""
kind: str = ""
value: str = ""
raw: str = ""
is_address: bool = False
address_value: str | None = None
is_range: bool = False
range_start: str | None = None
range_end: str | None = None
def __str__(self) -> str:
return self.raw
# ---------------------------------------------------------------------------
# Selector parser
# ---------------------------------------------------------------------------
_ADDRESS_PATTERN = re.compile(r"^(0x)?[0-9a-fA-F]+$")
_RANGE_PATTERN = re.compile(r"^(0x)?[0-9a-fA-F]+\.\.(0x)?[0-9a-fA-F]+$")
_SELECTOR_PATTERN = re.compile(r"^(function|address|name):(.+)$", re.IGNORECASE)
def parse_selector(raw: str) -> ParsedSelector:
"""Parse a selector string into its components.
Supported formats:
"function:main" → function selector by name
"function:0x401000" → function selector by address
"address:0x1000..0x2000" → address range selector
"name:entrypoint" → generic name selector
"main" → implicit function selector (shorthand)
"0x401000" → implicit address selector (shorthand)
Args:
raw: The raw selector string.
Returns:
A ParsedSelector with kind, value, and parsed components.
"""
result = ParsedSelector(raw=raw, kind=SelectorKind.NAME, value=raw)
# Try explicit selector format: kind:value
match = _SELECTOR_PATTERN.match(raw)
if match:
kind = match.group(1).lower()
value = match.group(2)
if kind == SelectorKind.FUNCTION:
result.kind = SelectorKind.FUNCTION
result.value = value
elif kind == SelectorKind.ADDRESS:
result.kind = SelectorKind.ADDRESS
result.value = value
else:
result.kind = SelectorKind.NAME
result.value = value
else:
# Implicit: check if it looks like an address
if _ADDRESS_PATTERN.match(raw):
result.kind = SelectorKind.ADDRESS
result.value = raw
else:
result.kind = SelectorKind.FUNCTION
result.value = raw
# Check if it's an address value
if _ADDRESS_PATTERN.match(result.value):
result.is_address = True
addr = result.value
if addr.startswith("0x") or addr.startswith("0X"):
result.address_value = addr[2:].lower()
else:
result.address_value = addr.lower()
# Check if it's a range
if _RANGE_PATTERN.match(result.value):
result.is_range = True
parts = result.value.split("..")
result.range_start = parts[0]
result.range_end = parts[1]
return result
# ---------------------------------------------------------------------------
# Entity resolver
# ---------------------------------------------------------------------------
@dataclass
class ResolvedEntity:
"""Result of resolving a selector to one or more entities.
Attributes:
selector: The parsed selector that was resolved.
entity_type: The type of entity resolved (e.g., "Function", "Address").
exact_match: The single entity if resolution was unambiguous.
candidates: List of candidates if multiple matches were found.
is_ambiguous: Whether resolution produced multiple candidates.
"""
selector: ParsedSelector
entity_type: str = ""
exact_match: Any | None = None
candidates: list[Any] = field(default_factory=list)
is_ambiguous: bool = False
def resolve_function(
parsed: ParsedSelector,
functions: list[Function],
require_unique: bool = True,
) -> Function:
"""Resolve a function selector to a single Function entity.
Args:
parsed: The parsed function selector.
functions: List of functions to search.
require_unique: If True, raise AmbiguousSelectorError when multiple
functions match.
Returns:
The matching Function entity.
Raises:
EntityNotFoundError: If no function matches the selector.
AmbiguousSelectorError: If multiple functions match and require_unique is True.
"""
if parsed.is_address:
# Lookup by address
addr_val = parsed.address_value or ""
matches = [
f
for f in functions
if f.address is not None and f.address.offset.lower() == f"0x{addr_val}"
]
if not matches:
matches = [
f
for f in functions
if f.address is not None and addr_val in f.address.offset.lower()
]
else:
# Lookup by name
search_name = parsed.value.lower()
exact_matches = [f for f in functions if f.name.lower() == search_name]
matches = exact_matches or [f for f in functions if search_name in f.name.lower()]
if not matches:
raise EntityNotFoundError("Function", parsed.raw)
if len(matches) > 1 and require_unique:
candidates_info = [
{
"name": f.name,
"address": f.address.to_dict() if f.address else None,
"size_bytes": f.size_bytes,
}
for f in matches
]
raise AmbiguousSelectorError(
f"Function selector '{parsed.raw}' matches {len(matches)} functions",
candidates=candidates_info,
)
return matches[0]
def resolve_functions(
parsed: ParsedSelector,
functions: list[Function],
) -> list[Function]:
"""Resolve a function selector to all matching Function entities.
Args:
parsed: The parsed function selector.
functions: List of functions to search.
Returns:
List of matching Function entities (may be empty).
"""
if parsed.is_address:
addr_val = parsed.address_value or ""
matches = [
f for f in functions if f.address is not None and addr_val in f.address.offset.lower()
]
else:
search_name = parsed.value.lower()
matches = [f for f in functions if search_name in f.name.lower()]
return matches
def format_candidates(candidates: list[dict[str, Any]]) -> str:
"""Format candidate entities for display in ambiguity errors.
Args:
candidates: List of candidate dicts with name, address, and optional info.
Returns:
A human-readable string listing candidates.
"""
lines = ["Ambiguous selector matches multiple entities:"]
for i, candidate in enumerate(candidates, start=1):
name = candidate.get("name", "unknown")
addr = candidate.get("address", {})
if isinstance(addr, dict):
display = addr.get("display", addr.get("offset", "?"))
else:
display = str(addr)
lines.append(f" {i}. {name} @ {display}")
return "\n".join(lines)