Files

1075 lines
37 KiB
Python

"""Binary operations — import, analyze, and metadata commands.
Implements the full import/analyze/metadata pipeline:
- Import: copy and reference modes, SHA-256 client-side, format validation,
size limits, project state transitions.
- Analyze: state transitions (IMPORTED/STALE -> ANALYZING -> READY),
profiles (standard/quick/deep), lock lifecycle, timeout with partial
results, staleness detection.
- Metadata: backend-neutral canonical fields, project_state in provenance.
All commands follow the standard JSON envelope pattern and respect the
project state machine, file locking, and error taxonomy (exit codes 0-13).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import threading
import time
from datetime import datetime, timezone
from typing import Any
from uuid import UUID, uuid4
from binary_analysis.domain.enums import AuditResult, ExitCode, ProjectState
from binary_analysis.domain.errors import (
AnalysisFailedError,
BackendFailureError,
BinaryAnalysisError,
BinaryNotFoundError,
ImportFailedError,
ProjectNotFoundError,
UnsupportedFormatError,
)
from binary_analysis.projects.diagnostics import persist_diagnostics
from binary_analysis.projects.lock import (
acquire_lock,
is_locked,
release_lock,
)
from binary_analysis.projects.manifest import (
load_manifest,
save_manifest,
)
from binary_analysis.projects.path_security import (
validate_binary_import_path,
)
from binary_analysis.projects.state_machine import (
can_analyze,
can_import,
transition_to_failed,
)
from binary_analysis.projects.workspace import (
get_project_path,
workspace_exists,
)
from binary_analysis.reporting.audit import write_audit_event
# ---------------------------------------------------------------------------
# Supported binary formats (magic bytes detection)
# ---------------------------------------------------------------------------
# Known magic bytes for supported formats
_SUPPORTED_MAGICS: dict[str, Any] = {
"PE": b"MZ", # MZ header (PE files also have PE\0\0 at offset after DOS stub)
"ELF": b"\x7fELF",
"Mach-O": (
b"\xcf\xfa\xed\xfe", # 32-bit little-endian
b"\xce\xfa\xed\xfe", # 32-bit big-endian
b"\xfe\xed\xfa\xcf", # 64-bit little-endian
b"\xfe\xed\xfa\xce", # 64-bit big-endian
),
}
# File extensions that map to known formats (for text/script fallback rejection)
_SUPPORTED_EXTENSIONS: set[str] = {
".exe",
".dll",
".sys",
".o",
".obj",
".so",
".dylib",
".bin",
".elf",
".macho",
".lib",
".a",
}
def _detect_format(file_path: str) -> str | None:
"""Detect binary format by magic bytes.
Reads the first 4 bytes of the file and checks against known
magic byte sequences. Returns the format name or None if unknown.
Args:
file_path: Path to the binary file.
Returns:
Format string ("PE", "ELF", "Mach-O") or None if unsupported.
Raises:
FileNotFoundError: If the file doesn't exist.
"""
if not os.path.isfile(file_path):
raise FileNotFoundError(f"Binary file not found: {file_path}")
try:
with open(file_path, "rb") as f:
header = f.read(4)
except OSError as e:
raise OSError(f"Cannot read binary file: {file_path}") from e
if len(header) < 2:
return None
# PE: starts with "MZ"
if header[:2] == b"MZ":
return "PE"
# ELF: starts with \x7fELF
if header[:4] == b"\x7fELF":
return "ELF"
# Mach-O: starts with specific magic sequences
macho_magics = (
b"\xcf\xfa\xed\xfe",
b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xcf",
b"\xfe\xed\xfa\xce",
)
if header in macho_magics:
return "Mach-O"
# Check for known extensions as fallback
ext = os.path.splitext(file_path)[1].lower()
if ext in _SUPPORTED_EXTENSIONS and ext in (".exe", ".dll", ".sys"):
return "PE" # Could be PE without complete header
return None
def _compute_sha256(file_path: str) -> str:
"""Compute SHA-256 hash of a file, client-side.
This is done before any backend interaction to ensure
the hash is always available, even on backend failure.
Args:
file_path: Path to the file.
Returns:
64-character lowercase hex digest.
"""
sha = hashlib.sha256()
with open(file_path, "rb") as f:
while True:
chunk = f.read(65536) # 64KB chunks
if not chunk:
break
sha.update(chunk)
return sha.hexdigest()
def _compute_file_sha256(file_path: str) -> str:
"""Alias for _compute_sha256 — used for staleness checks on sample files."""
return _compute_sha256(file_path)
# ---------------------------------------------------------------------------
# Project path resolution
# ---------------------------------------------------------------------------
def _resolve_project_path(project_name: str) -> str:
"""Resolve a project name or UUID to its workspace path.
Args:
project_name: Project name or UUID string.
Returns:
Absolute path to the project workspace directory.
Raises:
ProjectNotFoundError: If the project doesn't exist.
"""
from binary_analysis.projects.workspace import list_workspaces
# Try by name
if workspace_exists(project_name):
return str(get_project_path(project_name))
# Try by UUID
for ws_name in list_workspaces():
ws_path = str(get_project_path(ws_name))
try:
manifest = load_manifest(ws_path)
if manifest.get("id") == project_name:
return ws_path
except Exception:
continue
raise ProjectNotFoundError(project_name)
# ---------------------------------------------------------------------------
# Subparser registration
# ---------------------------------------------------------------------------
def add_subparser(subparsers: Any) -> None:
"""Register binary operation subcommands."""
# -- Import --
import_parser: argparse.ArgumentParser = subparsers.add_parser(
"import", help="Import a binary into a project."
)
import_parser.add_argument("path", help="Path to the binary file.")
import_parser.add_argument("--project", required=True, help="Project name or UUID.")
import_parser.add_argument(
"--reference",
action="store_true",
default=False,
help="Use reference mode (track source path, do not copy).",
)
# -- Analyze --
analyze_parser = subparsers.add_parser("analyze", help="Analyze an imported binary.")
analyze_parser.add_argument("--project", required=True, help="Project name or UUID.")
analyze_parser.add_argument(
"--profile",
default="standard",
help="Analysis profile: standard, quick, or deep (default: standard).",
)
# -- Metadata --
metadata_parser = subparsers.add_parser(
"metadata", help="Show canonical metadata for an imported binary."
)
metadata_parser.add_argument("--project", required=True, help="Project name or UUID.")
# ---------------------------------------------------------------------------
# Command dispatch
# ---------------------------------------------------------------------------
def execute_import(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'import' command.
Flow:
1. Resolve project, load manifest, validate state and lock
2. Validate binary format (magic bytes)
3. Validate file size against project max_binary_size_bytes
4. Compute SHA-256 client-side
5. Copy or reference the binary
6. Try backend import (may fail)
7. Store binary record, update manifest, transition to IMPORTED
8. Return result with binary identity
"""
t_start = time.perf_counter()
project_name = args.project
binary_path = args.path
reference_mode: bool = getattr(args, "reference", False)
# 1. Resolve project
project_path = _resolve_project_path(project_name)
manifest = load_manifest(project_path)
# 1.5. Validate binary path for safety (VAL-SAFE-003)
# Reject path traversal sequences, symlink escapes, and system-sensitive paths
try:
binary_path = validate_binary_import_path(binary_path, project_path)
except ValueError as e:
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": str(e),
"category": "path_security",
}
],
"data": None,
}
current_state_str = manifest.get("state", "")
try:
current_state = ProjectState(current_state_str)
except ValueError:
current_state = ProjectState.CREATED
# 2. Validate state: must allow import
if not can_import(current_state):
# Check if analyzing (locked)
if current_state == ProjectState.ANALYZING or is_locked(project_path):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Cannot import: project '{project_name}' is in {current_state.value} state "
"or is locked by an active operation. Wait for it to complete."
),
"category": "state_machine",
}
],
"data": None,
}
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Cannot import into project in {current_state.value} state. "
"Clean the project first ('binary project clean')."
),
"category": "state_machine",
}
],
"data": None,
}
# 3. Validate binary exists
if not os.path.isfile(binary_path):
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": f"Binary file not found: {binary_path}",
"category": "import",
}
],
"data": None,
}
# 4. Validate format (magic bytes)
detected_format = _detect_format(binary_path)
if detected_format is None:
raise UnsupportedFormatError(
f"Unsupported binary format: '{binary_path}'. "
"Supported formats: PE (MZ header), ELF, Mach-O. "
"The file must be a valid executable or object file with a recognized header."
)
# 5. Check max size
max_size = manifest.get("max_binary_size_bytes")
if max_size is not None:
file_size = os.path.getsize(binary_path)
if file_size > max_size:
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Binary size ({file_size} bytes) exceeds project maximum "
f"({max_size} bytes). Increase max_binary_size_bytes or use a smaller binary."
),
"category": "import",
}
],
"data": None,
}
# 6. Compute SHA-256 client-side (always, even if backend fails)
binary_sha256 = _compute_sha256(binary_path)
file_size = os.path.getsize(binary_path)
import_mode = "reference" if reference_mode else "copy"
# 6a. Check for duplicate binary (same SHA-256 already imported)
binaries_dir = os.path.join(project_path, "binaries")
if os.path.isdir(binaries_dir):
for fname in os.listdir(binaries_dir):
if fname.endswith(".json"):
existing_path = os.path.join(binaries_dir, fname)
try:
with open(existing_path) as f:
existing = json.load(f)
if existing.get("sha256") == binary_sha256:
existing_binary_id = existing.get("id", fname.replace(".json", ""))
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "INFO",
"message": (
f"Binary with SHA-256 {binary_sha256[:16]}... "
f"is already imported (binary_id: {existing_binary_id}). "
"Re-import is a no-op; returning the existing binary identity."
),
"category": "import",
"recoverable": True,
}
],
"data": {
"binary_id": existing_binary_id,
"binary_sha256": binary_sha256,
"binary_path": binary_path,
"format": existing.get("format", detected_format),
"import_mode": existing.get("import_mode", import_mode),
"size_bytes": existing.get("size_bytes", file_size),
},
}
except Exception:
continue
# 7. Handle copy vs reference mode
binary_id = str(uuid4())
stored_path = binary_path
if reference_mode:
# Reference mode: track external path, do not copy
pass
else:
# Copy mode: copy to samples/<binary-id>
samples_dir = os.path.join(project_path, "samples")
os.makedirs(samples_dir, exist_ok=True)
dest_path = os.path.join(samples_dir, binary_id)
try:
shutil.copy2(binary_path, dest_path)
stored_path = dest_path
except OSError as e:
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": f"Failed to copy binary to samples/: {e}",
"category": "import",
}
],
"data": None,
}
# 8. Try backend import (may raise ImportFailedError)
backend_format: str = detected_format
backend_architecture: str | None = None
try:
from binary_analysis.adapters.fake import FakeAdapter
from binary_analysis.domain.entities import Project as ProjectEntity
adapter = FakeAdapter()
adapter.set_fixture("pe-default", FakeAdapter.pe_fixture())
adapter.set_fixture("elf-default", FakeAdapter.elf_fixture())
adapter.set_fixture("macho-default", FakeAdapter.macho_fixture())
_proj_entity = ProjectEntity(
id=UUID(manifest["id"]),
name=manifest.get("name", project_name),
)
binary_entity = adapter.import_binary(stored_path, _proj_entity)
backend_format = binary_entity.format or detected_format
backend_architecture = binary_entity.architecture
except ImportFailedError:
# Import backend failure — exit code 10 but we still return SHA-256
raise
except BinaryAnalysisError as e:
raise ImportFailedError(
f"Backend import failed: {e.message}", binary_path=binary_path
) from e
except Exception as e:
raise ImportFailedError(f"Backend import failed: {e}", binary_path=binary_path) from e
# 9. Store binary record
binary_record: dict[str, Any] = {
"id": binary_id,
"sha256": binary_sha256,
"path": binary_path, # Original path
"format": backend_format,
"import_mode": import_mode,
"size_bytes": file_size,
"architecture": backend_architecture,
"imported_at": datetime.now(timezone.utc).isoformat(),
}
binaries_dir = os.path.join(project_path, "binaries")
os.makedirs(binaries_dir, exist_ok=True)
record_path = os.path.join(binaries_dir, f"{binary_id}.json")
with open(record_path, "w") as f:
json.dump(binary_record, f, indent=2)
# 10. Update manifest
manifest["state"] = ProjectState.IMPORTED.value
manifest["binary_count"] = manifest.get("binary_count", 0) + 1
manifest["current_binary"] = binary_record
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
# Record audit event
duration_ms = int((time.perf_counter() - t_start) * 1000)
write_audit_event(
project_path,
command="import",
result=AuditResult.SUCCESS,
duration_ms=duration_ms,
args={
"path": binary_path,
"mode": import_mode,
},
project_id=manifest.get("id"),
binary_id=binary_id,
details={
"sha256": binary_sha256,
"format": backend_format,
"size_bytes": file_size,
},
)
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": {
"binary_id": binary_id,
"binary_sha256": binary_sha256,
"binary_path": binary_path,
"format": backend_format,
"import_mode": import_mode,
"size_bytes": file_size,
},
}
def execute_analyze(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'analyze' command.
Flow:
1. Resolve project, load manifest
2. Check state allows analyze (IMPORTED, STALE)
3. Check staleness (SHA-256 mismatch, profile change)
4. Validate profile
5. Acquire lock, transition to ANALYZING
6. Run backend analysis (with timeout)
7. On success: transition to READY, release lock
8. On timeout: return partial results, exit code 12
9. On hard failure: transition to FAILED, exit code 11
"""
t_start = time.perf_counter()
project_name = args.project
profile_name: str = getattr(args, "profile", "standard")
timeout_seconds: int = getattr(args, "timeout", 300)
# 1. Resolve project
project_path = _resolve_project_path(project_name)
manifest = load_manifest(project_path)
current_state_str = manifest.get("state", "")
try:
current_state = ProjectState(current_state_str)
except ValueError:
current_state = ProjectState.CREATED
# 2. Check state allows analyze
if not can_analyze(current_state):
if current_state == ProjectState.CREATED:
raise BinaryNotFoundError(
"No binary has been imported into this project. "
"Use 'binary import' to add a binary before analyzing."
)
if current_state == ProjectState.ANALYZING:
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": "Project is already being analyzed. Wait for it to complete.",
"category": "state_machine",
}
],
"data": None,
"_provenance_project_state": current_state.value,
}
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Cannot analyze project in {current_state.value} state. "
"Import a binary first, or clean a FAILED project."
),
"category": "state_machine",
}
],
"data": None,
"_provenance_project_state": current_state.value,
}
current_binary = manifest.get("current_binary")
if current_binary is None:
raise BinaryNotFoundError(
"No binary has been imported into this project. "
"Use 'binary import' to add a binary before analyzing."
)
# 3. Check staleness
prev_profile = manifest.get("analysis_profile")
stored_sha256 = current_binary.get("sha256", "")
import_mode = current_binary.get("import_mode", "copy")
stored_path = current_binary.get("path", "")
# Profile change
if prev_profile and prev_profile != profile_name:
manifest["state"] = ProjectState.STALE.value
manifest["is_stale"] = True
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Analysis profile changed from '{prev_profile}' to '{profile_name}'. "
"Project is now STALE. Run analyze again with the new profile to re-analyze."
),
"category": "staleness",
}
],
"data": None,
"_provenance_project_state": ProjectState.STALE.value,
}
# Source change check
if import_mode == "copy":
# Check sample file
binary_id = current_binary.get("id", "")
sample_path = os.path.join(project_path, "samples", binary_id)
if os.path.exists(sample_path):
current_sha = _compute_file_sha256(sample_path)
if current_sha != stored_sha256:
manifest["state"] = ProjectState.STALE.value
manifest["is_stale"] = True
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Binary SHA-256 mismatch: stored={stored_sha256[:16]}..., "
f"current={current_sha[:16]}... "
"Project is now STALE. The source binary has changed."
),
"category": "staleness",
}
],
"data": None,
"_provenance_project_state": ProjectState.STALE.value,
}
else:
# Reference mode: check source file
if os.path.exists(stored_path):
current_sha = _compute_file_sha256(stored_path)
if current_sha != stored_sha256:
manifest["state"] = ProjectState.STALE.value
manifest["is_stale"] = True
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Source binary SHA-256 mismatch: stored={stored_sha256[:16]}..., "
f"current={current_sha[:16]}... "
"Project is now STALE. The source has been modified."
),
"category": "staleness",
}
],
"data": None,
"_provenance_project_state": ProjectState.STALE.value,
}
# 4. Validate analysis profile
available_profiles = {"standard", "quick", "deep"}
if profile_name not in available_profiles:
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": (
f"Unknown analysis profile: {profile_name!r}. "
f"Available: standard, quick, deep."
),
"category": "profile",
}
],
"data": None,
"_provenance_project_state": current_state.value,
}
# 5. Acquire lock
try:
_lock_info = acquire_lock(
project_path,
project_name=project_name,
holder_purpose="analysis",
)
except Exception as e:
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{
"severity": "ERROR",
"message": f"Cannot acquire project lock: {e}",
"category": "lock",
}
],
"data": None,
"_provenance_project_state": current_state.value,
}
# 6. Transition to ANALYZING
try:
manifest["state"] = ProjectState.ANALYZING.value
manifest["analysis_profile"] = profile_name
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
except Exception:
release_lock(project_path)
raise
# 7. Run backend analysis with timeout
error = None
completed_analysers: list[str] = []
failed_analysers: list[str] = []
diagnostics: list[dict[str, Any]] = []
timed_out = False
# Profile -> analyser mapping
profile_analysers: dict[str, list[str]] = {
"standard": [
"functions",
"sections",
"strings",
"symbols",
"imports",
"exports",
"entrypoints",
],
"quick": ["functions", "sections"],
"deep": [
"functions",
"sections",
"strings",
"symbols",
"imports",
"exports",
"entrypoints",
"decompiler",
"callgraph",
"xrefs",
],
}
analysers = profile_analysers.get(profile_name, [])
try:
from binary_analysis.adapters.fake import FakeAdapter
from binary_analysis.domain.entities import (
Binary as BinaryEntity,
)
from binary_analysis.domain.entities import (
Project as ProjectEntity,
)
adapter = FakeAdapter()
adapter.set_fixture("pe-default", FakeAdapter.pe_fixture())
adapter.set_fixture("elf-default", FakeAdapter.elf_fixture())
adapter.set_fixture("macho-default", FakeAdapter.macho_fixture())
_proj_entity = ProjectEntity(
id=UUID(manifest["id"]),
name=manifest.get("name", project_name),
)
binary_entity = BinaryEntity(
id=UUID(current_binary.get("id", str(uuid4()))),
sha256=current_binary.get("sha256", ""),
path=current_binary.get("path", ""),
format=current_binary.get("format", ""),
size_bytes=current_binary.get("size_bytes", 0),
architecture=current_binary.get("architecture"),
)
from binary_analysis.adapters.base import AnalysisProfile
profile = AnalysisProfile(
name=profile_name,
description=f"{profile_name} analysis",
analysers=analysers,
)
# Run with timeout
result_container: dict[str, Any] = {"result": None, "error": None}
def _run_analysis() -> None:
try:
result_container["result"] = adapter.analyze(binary_entity, profile)
except Exception as e:
result_container["error"] = e
thread = threading.Thread(target=_run_analysis, daemon=True)
thread.start()
thread.join(timeout=timeout_seconds)
if thread.is_alive():
# Timeout: partial results
timed_out = True
# Mark as many analysers as completed as we can
completed_analysers = analysers[:1] # At least first one
failed_analysers = analysers[1:] if len(analysers) > 1 else []
diagnostics.append(
{
"severity": "ERROR",
"message": (
f"Analysis timed out after {timeout_seconds}s. "
f"{len(completed_analysers)} of {len(analysers)} analysers completed."
),
"category": "timeout",
"recoverable": True,
}
)
elif result_container["error"] is not None:
# Hard failure
error = result_container["error"]
if isinstance(error, AnalysisFailedError):
raise error
raise AnalysisFailedError(
f"Analysis failed: {error}",
project=project_name,
) from error
else:
result = result_container["result"]
completed_analysers = result.completed_analysers
failed_analysers = result.failed_analysers
diagnostics = result.diagnostics
if result.partial:
timed_out = True # Treat partial as timed-out for exit code 12
except AnalysisFailedError as e:
# Transition to FAILED
manifest = load_manifest(project_path)
transition_to_failed(
manifest,
ProjectState.ANALYZING,
[e.to_diagnostic()],
release_lock_fn=lambda: release_lock(project_path),
)
save_manifest(project_path, manifest)
release_lock(project_path)
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
e.to_diagnostic(),
{"severity": "ERROR", "message": "Project state: FAILED", "category": "state"},
],
"data": None,
"_exit_code": int(ExitCode.ANALYSIS_FAILED),
"_provenance_project_state": ProjectState.FAILED.value,
}
except Exception as e:
# Unhandled backend error
manifest = load_manifest(project_path)
transition_to_failed(
manifest,
ProjectState.ANALYZING,
[{"severity": "ERROR", "message": str(e), "category": "backend"}],
release_lock_fn=lambda: release_lock(project_path),
)
save_manifest(project_path, manifest)
release_lock(project_path)
return {
"success": False,
"partial": False,
"warnings": [],
"diagnostics": [
{"severity": "ERROR", "message": f"Backend failure: {e}", "category": "backend"},
],
"data": None,
"_exit_code": int(ExitCode.BACKEND_FAILURE),
"_provenance_project_state": ProjectState.FAILED.value,
}
# 8. Handle timeout (partial results)
if timed_out:
# Save partial results but don't transition to READY
manifest = load_manifest(project_path)
manifest["state"] = ProjectState.ANALYZING.value
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
release_lock(project_path)
# Persist diagnostics for later retrieval
persist_diagnostics(project_path, diagnostics, command="analyze")
return {
"success": False,
"partial": True,
"warnings": [],
"diagnostics": diagnostics,
"data": {
"results": {
"completed_analysers": completed_analysers,
"failed_analysers": failed_analysers,
},
},
"_exit_code": int(ExitCode.OPERATION_TIMEOUT),
"_provenance_project_state": ProjectState.ANALYZING.value,
}
# 9. Success: transition to READY
manifest = load_manifest(project_path)
manifest["state"] = ProjectState.READY.value
manifest["is_stale"] = False
manifest["analysis_profile"] = profile_name
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
save_manifest(project_path, manifest)
release_lock(project_path)
# Persist any diagnostics (including warnings from partial analysis)
if diagnostics:
persist_diagnostics(project_path, diagnostics, command="analyze")
# Record audit event
result = AuditResult.PARTIAL if failed_analysers else AuditResult.SUCCESS
duration_ms = int((time.perf_counter() - t_start) * 1000)
write_audit_event(
project_path,
command="analyze",
result=result,
duration_ms=duration_ms,
args={
"profile": profile_name,
},
project_id=manifest.get("id"),
binary_id=current_binary.get("id"),
details={
"completed_analysers": completed_analysers,
"failed_analysers": failed_analysers,
},
)
return {
"success": True,
"partial": len(failed_analysers) > 0,
"warnings": [],
"diagnostics": diagnostics,
"data": {
"results": {
"completed_analysers": completed_analysers,
"failed_analysers": failed_analysers,
},
},
"_provenance_project_state": ProjectState.READY.value,
"_provenance_analysis_profile": profile_name,
}
def execute_metadata(args: argparse.Namespace) -> dict[str, Any]:
"""Execute the 'metadata' command.
Returns backend-neutral canonical metadata:
format, architecture, endianness, size_bytes, entry_point.
Reports project_state in provenance regardless of analysis state.
"""
project_name = args.project
# 1. Resolve project
project_path = _resolve_project_path(project_name)
manifest = load_manifest(project_path)
current_binary = manifest.get("current_binary")
if current_binary is None:
raise BinaryNotFoundError(
"No binary has been imported into this project. "
"Use 'binary import' to add a binary before viewing metadata."
)
current_state = manifest.get("state", "")
try:
from binary_analysis.adapters.fake import FakeAdapter
from binary_analysis.domain.entities import (
Binary as BinaryEntity,
)
adapter = FakeAdapter()
adapter.set_fixture("pe-default", FakeAdapter.pe_fixture())
adapter.set_fixture("elf-default", FakeAdapter.elf_fixture())
adapter.set_fixture("macho-default", FakeAdapter.macho_fixture())
binary_entity = BinaryEntity(
id=UUID(current_binary.get("id", str(uuid4()))),
sha256=current_binary.get("sha256", ""),
path=current_binary.get("path", ""),
format=current_binary.get("format", ""),
size_bytes=current_binary.get("size_bytes", 0),
architecture=current_binary.get("architecture"),
)
metadata = adapter.get_metadata(binary_entity)
data: dict[str, Any] = {
"format": metadata.format or current_binary.get("format", "unknown"),
"architecture": metadata.architecture or current_binary.get("architecture"),
"endianness": metadata.endianness,
"size_bytes": metadata.size_bytes or current_binary.get("size_bytes", 0),
"entry_point": (metadata.entry_point.to_dict() if metadata.entry_point else None),
}
# Add optional fields only if present
if metadata.compiler:
data["compiler"] = metadata.compiler
if metadata.source_language:
data["source_language"] = metadata.source_language
return {
"success": True,
"partial": False,
"warnings": [],
"diagnostics": [],
"data": data,
"_provenance_project_state": current_state,
}
except Exception as e:
raise BackendFailureError(f"Failed to retrieve metadata: {e}", original_error=str(e)) from e