codevanta 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevanta-0.1.0.dist-info/top_level.txt +1 -0
codevanta/cli/main.py
ADDED
|
@@ -0,0 +1,3038 @@
|
|
|
1
|
+
"""CODEVANTA command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Annotated, Any, Literal, NoReturn
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
from rich.panel import Panel
|
|
16
|
+
from rich.table import Table
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
|
|
19
|
+
from codevanta.analysis import dynamic
|
|
20
|
+
from codevanta.analysis import emulate as emulator
|
|
21
|
+
from codevanta.analysis.binary_diff import (
|
|
22
|
+
DEFAULT_MAX_FUNCTIONS as DIFF_MAX_FUNCTIONS,
|
|
23
|
+
)
|
|
24
|
+
from codevanta.analysis.binary_diff import (
|
|
25
|
+
DEFAULT_SEMANTIC_THRESHOLD as DIFF_SEMANTIC_THRESHOLD,
|
|
26
|
+
)
|
|
27
|
+
from codevanta.analysis.binary_diff import (
|
|
28
|
+
MAX_SEMANTIC_THRESHOLD as DIFF_MAX_THRESHOLD,
|
|
29
|
+
)
|
|
30
|
+
from codevanta.analysis.binary_diff import (
|
|
31
|
+
MIN_SEMANTIC_THRESHOLD as DIFF_MIN_THRESHOLD,
|
|
32
|
+
)
|
|
33
|
+
from codevanta.analysis.binary_diff import BinaryDiff, FunctionDelta, diff_binaries
|
|
34
|
+
from codevanta.analysis.callgraph import (
|
|
35
|
+
DEFAULT_MAX_EDGES,
|
|
36
|
+
DEFAULT_MAX_FUNCTIONS,
|
|
37
|
+
CallGraph,
|
|
38
|
+
build_call_graph,
|
|
39
|
+
)
|
|
40
|
+
from codevanta.analysis.cfg import FunctionCFG, build_cfg, build_recursive_cfg
|
|
41
|
+
from codevanta.analysis.dataflow import (
|
|
42
|
+
DEFAULT_MAX_BLOCKS as DATAFLOW_MAX_BLOCKS,
|
|
43
|
+
)
|
|
44
|
+
from codevanta.analysis.dataflow import (
|
|
45
|
+
DEFAULT_MAX_INSTRUCTIONS as DATAFLOW_MAX_INSTRUCTIONS,
|
|
46
|
+
)
|
|
47
|
+
from codevanta.analysis.dataflow import DataFlowSummary, analyze_dataflow
|
|
48
|
+
from codevanta.analysis.debuginfo import extract_debug_info
|
|
49
|
+
from codevanta.analysis.decompile import (
|
|
50
|
+
DEFAULT_MAX_BLOCKS as DECOMPILE_MAX_BLOCKS,
|
|
51
|
+
)
|
|
52
|
+
from codevanta.analysis.decompile import (
|
|
53
|
+
DEFAULT_MAX_INSTRUCTIONS as DECOMPILE_MAX_INSTRUCTIONS,
|
|
54
|
+
)
|
|
55
|
+
from codevanta.analysis.decompilers import BackendResult, decompile_function
|
|
56
|
+
from codevanta.analysis.evidence_bundle import build_bundle, dumps_bundle, write_bundle
|
|
57
|
+
from codevanta.analysis.graph_export import (
|
|
58
|
+
DEFAULT_MAX_EDGES as EXPORT_MAX_EDGES,
|
|
59
|
+
)
|
|
60
|
+
from codevanta.analysis.graph_export import (
|
|
61
|
+
DEFAULT_MAX_NODES as EXPORT_MAX_NODES,
|
|
62
|
+
)
|
|
63
|
+
from codevanta.analysis.graph_export import (
|
|
64
|
+
GraphExportError,
|
|
65
|
+
to_dot,
|
|
66
|
+
to_graphml,
|
|
67
|
+
)
|
|
68
|
+
from codevanta.analysis.html_view import to_html
|
|
69
|
+
from codevanta.analysis.jump_tables import (
|
|
70
|
+
DEFAULT_MAX_ENTRIES as JUMP_TABLE_MAX_ENTRIES,
|
|
71
|
+
)
|
|
72
|
+
from codevanta.analysis.jump_tables import (
|
|
73
|
+
MODE_ABSOLUTE,
|
|
74
|
+
MODE_RELATIVE,
|
|
75
|
+
MODES,
|
|
76
|
+
JumpTableResult,
|
|
77
|
+
resolve_jump_table,
|
|
78
|
+
)
|
|
79
|
+
from codevanta.analysis.symbolic import (
|
|
80
|
+
DEFAULT_MAX_PATHS as SYMBOLIC_MAX_PATHS,
|
|
81
|
+
)
|
|
82
|
+
from codevanta.analysis.symbolic import (
|
|
83
|
+
DEFAULT_MAX_STEPS as SYMBOLIC_MAX_STEPS,
|
|
84
|
+
)
|
|
85
|
+
from codevanta.analysis.symbolic import (
|
|
86
|
+
EMULATOR_AVAILABLE as SYMBOLIC_EMULATOR_AVAILABLE,
|
|
87
|
+
)
|
|
88
|
+
from codevanta.analysis.symbolic import PathExploration, explore_paths
|
|
89
|
+
from codevanta.analysis.trace import TraceReport, parse_frida_trace, parse_gdb_trace
|
|
90
|
+
from codevanta.analysis.type_recovery import (
|
|
91
|
+
DEFAULT_MAX_FIELDS as STRUCT_MAX_FIELDS,
|
|
92
|
+
)
|
|
93
|
+
from codevanta.analysis.type_recovery import (
|
|
94
|
+
DEFAULT_MAX_INSTRUCTIONS as STRUCT_MAX_INSTRUCTIONS,
|
|
95
|
+
)
|
|
96
|
+
from codevanta.analysis.type_recovery import (
|
|
97
|
+
MAX_MERGED_WINDOWS as STRUCT_MAX_WINDOWS,
|
|
98
|
+
)
|
|
99
|
+
from codevanta.analysis.type_recovery import (
|
|
100
|
+
ArrayGroup,
|
|
101
|
+
StructSuggestion,
|
|
102
|
+
parse_memory_operands,
|
|
103
|
+
suggest_struct,
|
|
104
|
+
suggest_structs,
|
|
105
|
+
)
|
|
106
|
+
from codevanta.analysis.vtables import find_vtables
|
|
107
|
+
from codevanta.analysis.xrefs import DEFAULT_MAX_BYTES, find_xrefs
|
|
108
|
+
from codevanta.core.disassembler import DisassemblyError
|
|
109
|
+
from codevanta.core.entropy import analyze_entropy
|
|
110
|
+
from codevanta.core.functions import discover_functions
|
|
111
|
+
from codevanta.core.loader import BinaryInfo, BinaryLoader, BinaryLoadError
|
|
112
|
+
from codevanta.core.report import (
|
|
113
|
+
DEFAULT_XREF_LIMIT,
|
|
114
|
+
AnalysisError,
|
|
115
|
+
build_report,
|
|
116
|
+
disassemble_region,
|
|
117
|
+
resolve_target,
|
|
118
|
+
)
|
|
119
|
+
from codevanta.core.strings import StringExtractor
|
|
120
|
+
from codevanta.plugins import AnalysisContext, builtin_plugins, run_plugin
|
|
121
|
+
from codevanta.plugins import load as load_plugin
|
|
122
|
+
from codevanta.storage.session import SessionError, SessionRecord, SessionStore
|
|
123
|
+
|
|
124
|
+
app = typer.Typer(
|
|
125
|
+
name="codevanta",
|
|
126
|
+
help="CODEVANTA — Advanced Code Intelligence. Decode. Analyze. Understand.",
|
|
127
|
+
no_args_is_help=True,
|
|
128
|
+
add_completion=False,
|
|
129
|
+
)
|
|
130
|
+
console = Console()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load(path: Path) -> BinaryInfo:
|
|
134
|
+
try:
|
|
135
|
+
return BinaryLoader().load(path)
|
|
136
|
+
except BinaryLoadError as exc:
|
|
137
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
138
|
+
raise typer.Exit(code=2) from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _session_db(db: Path | None) -> Path:
|
|
142
|
+
"""Return ``db``, else ``$CODEVANTA_DB``, else ``~/.codevanta/sessions.db``."""
|
|
143
|
+
if db is not None:
|
|
144
|
+
return db
|
|
145
|
+
configured = os.environ.get("CODEVANTA_DB")
|
|
146
|
+
if configured and configured.strip():
|
|
147
|
+
return Path(configured)
|
|
148
|
+
return Path.home() / ".codevanta" / "sessions.db"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fail(message: str, code: int) -> NoReturn:
|
|
152
|
+
"""Print an error and exit with ``code``."""
|
|
153
|
+
console.print(f"[bold red]error:[/] {escape(message)}")
|
|
154
|
+
raise typer.Exit(code=code)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _system_exit_status(exc: SystemExit) -> int:
|
|
158
|
+
"""Return the status a :class:`SystemExit` asks for, as CPython would.
|
|
159
|
+
|
|
160
|
+
``SystemExit`` carries ``None`` (meaning success), an ``int`` or any other
|
|
161
|
+
object (printed, then exit 1, which is the documented CPython behaviour).
|
|
162
|
+
"""
|
|
163
|
+
code = exc.code
|
|
164
|
+
if code is None:
|
|
165
|
+
return 0
|
|
166
|
+
if isinstance(code, bool) or not isinstance(code, int):
|
|
167
|
+
return 1
|
|
168
|
+
return code
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _print_sections(info: BinaryInfo) -> None:
|
|
172
|
+
table = Table(title="Sections")
|
|
173
|
+
table.add_column("Name")
|
|
174
|
+
table.add_column("VA", justify="right")
|
|
175
|
+
table.add_column("Size", justify="right")
|
|
176
|
+
table.add_column("Offset", justify="right")
|
|
177
|
+
table.add_column("Flags")
|
|
178
|
+
for section in info.sections:
|
|
179
|
+
flags = ("X" if section.is_executable else "") + ("W" if section.is_writable else "")
|
|
180
|
+
table.add_row(
|
|
181
|
+
escape(section.name or "<unnamed>"),
|
|
182
|
+
f"0x{section.virtual_address:x}",
|
|
183
|
+
str(section.size),
|
|
184
|
+
f"0x{section.offset:x}",
|
|
185
|
+
flags or "-",
|
|
186
|
+
)
|
|
187
|
+
console.print(table)
|
|
188
|
+
@app.command()
|
|
189
|
+
def info(path: Annotated[Path, typer.Argument(help="Path to a PE or ELF binary")]) -> None:
|
|
190
|
+
"""Show binary metadata (format, arch, sections, imports, exports)."""
|
|
191
|
+
loaded = _load(path)
|
|
192
|
+
grid = Table.grid(padding=(0, 2))
|
|
193
|
+
grid.add_column(style="bold cyan")
|
|
194
|
+
grid.add_column()
|
|
195
|
+
rows = [
|
|
196
|
+
("File", str(loaded.path)),
|
|
197
|
+
("Format", loaded.format),
|
|
198
|
+
("Architecture", loaded.architecture),
|
|
199
|
+
("Bitness", str(loaded.bitness)),
|
|
200
|
+
("Endianness", loaded.endianness),
|
|
201
|
+
("Entry point", f"0x{loaded.entry_point:x}" if loaded.entry_point is not None else "-"),
|
|
202
|
+
("Image base", f"0x{loaded.image_base:x}"),
|
|
203
|
+
("SHA-256", loaded.sha256),
|
|
204
|
+
("Stripped", "yes" if loaded.is_stripped else "no"),
|
|
205
|
+
("Sections", str(len(loaded.sections))),
|
|
206
|
+
("Imports", str(len(loaded.imports))),
|
|
207
|
+
("Exports", str(len(loaded.exports))),
|
|
208
|
+
("Symbols", str(len(loaded.symbols))),
|
|
209
|
+
]
|
|
210
|
+
for key, value in rows:
|
|
211
|
+
grid.add_row(key, escape(value))
|
|
212
|
+
console.print(
|
|
213
|
+
Panel(grid, title="CODEVANTA · binary info", subtitle="Decode. Analyze. Understand.")
|
|
214
|
+
)
|
|
215
|
+
_print_sections(loaded)
|
|
216
|
+
for warning in loaded.warnings:
|
|
217
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def entropy(
|
|
222
|
+
path: Path,
|
|
223
|
+
threshold: Annotated[
|
|
224
|
+
float,
|
|
225
|
+
typer.Option(
|
|
226
|
+
help="Entropy (bits/byte) at or above which a section is flagged as possibly packed"
|
|
227
|
+
),
|
|
228
|
+
] = 7.2,
|
|
229
|
+
max_sample_bytes: Annotated[
|
|
230
|
+
int, typer.Option(help="Maximum bytes sampled per section")
|
|
231
|
+
] = 65_536,
|
|
232
|
+
json_output: Annotated[
|
|
233
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of a table")
|
|
234
|
+
] = False,
|
|
235
|
+
) -> None:
|
|
236
|
+
"""Show per-section Shannon entropy and packing triage hints."""
|
|
237
|
+
loaded = _load(path)
|
|
238
|
+
try:
|
|
239
|
+
report = analyze_entropy(
|
|
240
|
+
path, loaded, threshold=threshold, max_sample_bytes=max_sample_bytes
|
|
241
|
+
)
|
|
242
|
+
except AnalysisError as exc:
|
|
243
|
+
_fail(str(exc), 4)
|
|
244
|
+
if json_output:
|
|
245
|
+
console.print_json(json.dumps(report.to_dict()))
|
|
246
|
+
return
|
|
247
|
+
table = Table(title="Section entropy (bits/byte)")
|
|
248
|
+
table.add_column("Section")
|
|
249
|
+
table.add_column("Entropy", justify="right")
|
|
250
|
+
table.add_column("Sampled", justify="right")
|
|
251
|
+
table.add_column("Exec")
|
|
252
|
+
table.add_column("Packed hint")
|
|
253
|
+
for section in report.sections:
|
|
254
|
+
entropy_text = f"{section.entropy:.3f}" if section.entropy is not None else "-"
|
|
255
|
+
packed_text = "yes" if section.packed_hint else "-"
|
|
256
|
+
style = "yellow" if section.packed_hint else ""
|
|
257
|
+
table.add_row(
|
|
258
|
+
escape(section.name),
|
|
259
|
+
entropy_text,
|
|
260
|
+
str(section.sampled_bytes),
|
|
261
|
+
"yes" if section.is_executable else "-",
|
|
262
|
+
f"[{style}]{packed_text}[/]" if style else packed_text,
|
|
263
|
+
)
|
|
264
|
+
console.print(table)
|
|
265
|
+
for warning in report.warnings:
|
|
266
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
267
|
+
if report.packed_candidates:
|
|
268
|
+
console.print(
|
|
269
|
+
"[dim]high entropy suggests packing or compression — a heuristic, "
|
|
270
|
+
"not proof; check imports and entry-point stubs before concluding.[/]"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@app.command()
|
|
275
|
+
def dwarf(
|
|
276
|
+
path: Annotated[Path, typer.Argument(help="Path to an ELF binary with DWARF debug info")],
|
|
277
|
+
limit: Annotated[
|
|
278
|
+
int, typer.Option(help="Maximum functions and types displayed")
|
|
279
|
+
] = 500,
|
|
280
|
+
json_output: Annotated[
|
|
281
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of tables")
|
|
282
|
+
] = False,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""Extract function names, extents and type layouts from DWARF debug info."""
|
|
285
|
+
loaded = _load(path)
|
|
286
|
+
try:
|
|
287
|
+
result = extract_debug_info(path, loaded, limit=limit)
|
|
288
|
+
except AnalysisError as exc:
|
|
289
|
+
_fail(str(exc), 4)
|
|
290
|
+
if json_output:
|
|
291
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
292
|
+
return
|
|
293
|
+
functions_table = Table(title="DWARF functions (producer metadata)")
|
|
294
|
+
functions_table.add_column("Address", justify="right")
|
|
295
|
+
functions_table.add_column("Size", justify="right")
|
|
296
|
+
functions_table.add_column("Name")
|
|
297
|
+
functions_table.add_column("Linkage name")
|
|
298
|
+
functions_table.add_column("External")
|
|
299
|
+
for function in result.functions:
|
|
300
|
+
functions_table.add_row(
|
|
301
|
+
f"0x{function.address:x}",
|
|
302
|
+
str(function.size),
|
|
303
|
+
escape(function.name),
|
|
304
|
+
escape(function.linkage_name or "-"),
|
|
305
|
+
"yes" if function.is_external else "-",
|
|
306
|
+
)
|
|
307
|
+
console.print(functions_table)
|
|
308
|
+
if result.types:
|
|
309
|
+
types_table = Table(title="Named types")
|
|
310
|
+
types_table.add_column("Kind")
|
|
311
|
+
types_table.add_column("Name")
|
|
312
|
+
types_table.add_column("Byte size", justify="right")
|
|
313
|
+
for debug_type in result.types:
|
|
314
|
+
types_table.add_row(
|
|
315
|
+
debug_type.kind,
|
|
316
|
+
escape(debug_type.name),
|
|
317
|
+
str(debug_type.byte_size),
|
|
318
|
+
)
|
|
319
|
+
console.print(types_table)
|
|
320
|
+
for warning in result.warnings:
|
|
321
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@app.command()
|
|
325
|
+
def vtable(
|
|
326
|
+
path: Annotated[Path, typer.Argument(help="Path to a PE, ELF, Mach-O or DEX binary")],
|
|
327
|
+
address: Annotated[
|
|
328
|
+
str | None,
|
|
329
|
+
typer.Option("--address", "-a", help="Vtable address (decimal or 0x hex); header slots are skipped automatically"),
|
|
330
|
+
] = None,
|
|
331
|
+
scan: Annotated[
|
|
332
|
+
bool, typer.Option("--scan", help="Sweep every data section for candidate runs")
|
|
333
|
+
] = False,
|
|
334
|
+
min_slots: Annotated[
|
|
335
|
+
int, typer.Option("--min-slots", help="Fewest consecutive code pointers that count as a table")
|
|
336
|
+
] = 3,
|
|
337
|
+
max_slots: Annotated[
|
|
338
|
+
int, typer.Option("--max-slots", help="Cap on slots decoded per candidate")
|
|
339
|
+
] = 256,
|
|
340
|
+
max_tables: Annotated[
|
|
341
|
+
int, typer.Option("--max-tables", help="Cap on candidates in --scan mode")
|
|
342
|
+
] = 64,
|
|
343
|
+
no_thunks: Annotated[
|
|
344
|
+
bool, typer.Option("--no-thunks", help="Do not follow one-instruction tail-call chains")
|
|
345
|
+
] = False,
|
|
346
|
+
json_output: Annotated[
|
|
347
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of tables")
|
|
348
|
+
] = False,
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Discover vtable candidates and resolve thunk chains (indirect xrefs)."""
|
|
351
|
+
if address is None and not scan:
|
|
352
|
+
_fail("give --address ADDR or --scan", 2)
|
|
353
|
+
loaded = _load(path)
|
|
354
|
+
try:
|
|
355
|
+
parsed_address = None if address is None else _parse_address(address)
|
|
356
|
+
result = find_vtables(
|
|
357
|
+
path,
|
|
358
|
+
loaded,
|
|
359
|
+
address=parsed_address,
|
|
360
|
+
min_slots=min_slots,
|
|
361
|
+
max_slots=max_slots,
|
|
362
|
+
max_tables=max_tables,
|
|
363
|
+
resolve_thunks=not no_thunks,
|
|
364
|
+
)
|
|
365
|
+
except AnalysisError as exc:
|
|
366
|
+
_fail(str(exc), 4)
|
|
367
|
+
if json_output:
|
|
368
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
369
|
+
return
|
|
370
|
+
if not result.tables:
|
|
371
|
+
console.print("[yellow]no vtable candidates found[/]")
|
|
372
|
+
for warning in result.warnings:
|
|
373
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
374
|
+
return
|
|
375
|
+
for index, candidate in enumerate(result.tables):
|
|
376
|
+
title = (
|
|
377
|
+
f"Vtable candidate {index + 1}/{len(result.tables)} at 0x{candidate.address:x} "
|
|
378
|
+
f"({candidate.section_name}, {len(candidate.slots)} slots"
|
|
379
|
+
+ (f", {candidate.header_slots} header skipped)" if candidate.header_slots else ")")
|
|
380
|
+
)
|
|
381
|
+
slots_table = Table(title=escape(title))
|
|
382
|
+
slots_table.add_column("Slot", justify="right")
|
|
383
|
+
slots_table.add_column("Slot address", justify="right")
|
|
384
|
+
slots_table.add_column("Target", justify="right")
|
|
385
|
+
slots_table.add_column("Via thunk", justify="right")
|
|
386
|
+
for slot in candidate.slots:
|
|
387
|
+
slots_table.add_row(
|
|
388
|
+
str(slot.index),
|
|
389
|
+
f"0x{slot.slot_address:x}",
|
|
390
|
+
f"0x{slot.target:x}",
|
|
391
|
+
f"0x{slot.resolved_target:x}" if slot.resolved_target is not None else "-",
|
|
392
|
+
)
|
|
393
|
+
console.print(slots_table)
|
|
394
|
+
for warning in result.warnings:
|
|
395
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
396
|
+
console.print(
|
|
397
|
+
"[dim]candidates are runs of code pointers in data — a heuristic, "
|
|
398
|
+
"not proof of a vtable.[/]"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.command()
|
|
403
|
+
def functions(
|
|
404
|
+
path: Path,
|
|
405
|
+
limit: Annotated[int, typer.Option(help="Maximum candidates to display")] = 200,
|
|
406
|
+
) -> None:
|
|
407
|
+
"""List function candidates with detection source and confidence."""
|
|
408
|
+
loaded = _load(path)
|
|
409
|
+
candidates = discover_functions(loaded)
|
|
410
|
+
table = Table(title=f"Function candidates ({len(candidates)})")
|
|
411
|
+
table.add_column("Address", justify="right")
|
|
412
|
+
table.add_column("Name")
|
|
413
|
+
table.add_column("Size", justify="right")
|
|
414
|
+
table.add_column("Source")
|
|
415
|
+
table.add_column("Confidence")
|
|
416
|
+
for candidate in candidates[:limit]:
|
|
417
|
+
table.add_row(
|
|
418
|
+
f"0x{candidate.address:x}",
|
|
419
|
+
candidate.name or "<unknown>",
|
|
420
|
+
str(candidate.size) if candidate.size is not None else "-",
|
|
421
|
+
escape(candidate.source),
|
|
422
|
+
candidate.confidence.value,
|
|
423
|
+
)
|
|
424
|
+
console.print(table)
|
|
425
|
+
if len(candidates) > limit:
|
|
426
|
+
console.print(f"[dim]Showing {limit} of {len(candidates)}; raise --limit to see more.[/]")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@app.command()
|
|
430
|
+
def strings(
|
|
431
|
+
path: Path,
|
|
432
|
+
min_length: Annotated[int, typer.Option(help="Minimum string length")] = 4,
|
|
433
|
+
limit: Annotated[int, typer.Option(help="Maximum strings to display")] = 200,
|
|
434
|
+
) -> None:
|
|
435
|
+
"""Extract ASCII and UTF-16LE strings with their locations."""
|
|
436
|
+
loaded = _load(path)
|
|
437
|
+
results = StringExtractor(min_length=min_length).extract(path, info=loaded, limit=limit)
|
|
438
|
+
table = Table(title=f"Strings (min {min_length} chars, first {len(results)})")
|
|
439
|
+
table.add_column("Offset", justify="right")
|
|
440
|
+
table.add_column("VA", justify="right")
|
|
441
|
+
table.add_column("Enc")
|
|
442
|
+
table.add_column("Text")
|
|
443
|
+
for item in results:
|
|
444
|
+
text = item.text if len(item.text) <= 80 else item.text[:77] + "..."
|
|
445
|
+
table.add_row(
|
|
446
|
+
f"0x{item.offset:x}",
|
|
447
|
+
f"0x{item.address:x}" if item.address is not None else "-",
|
|
448
|
+
item.encoding,
|
|
449
|
+
escape(text),
|
|
450
|
+
)
|
|
451
|
+
console.print(table)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.command()
|
|
455
|
+
def disasm(
|
|
456
|
+
path: Path,
|
|
457
|
+
address: Annotated[
|
|
458
|
+
int | None, typer.Option("--address", "-a", help="Virtual address to disassemble")
|
|
459
|
+
] = None,
|
|
460
|
+
count: Annotated[int, typer.Option(help="Maximum instructions to decode")] = 50,
|
|
461
|
+
function: Annotated[
|
|
462
|
+
str | None, typer.Option("--function", "-f", help="Function name to disassemble")
|
|
463
|
+
] = None,
|
|
464
|
+
) -> None:
|
|
465
|
+
"""Disassemble the entry point, an address, or a named function."""
|
|
466
|
+
loaded = _load(path)
|
|
467
|
+
try:
|
|
468
|
+
start = resolve_target(loaded, address, function)
|
|
469
|
+
result = disassemble_region(path, loaded, start, count=count)
|
|
470
|
+
except AnalysisError as exc:
|
|
471
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
472
|
+
raise typer.Exit(code=4) from exc
|
|
473
|
+
table = Table(title=f"Disassembly at 0x{start:x}")
|
|
474
|
+
table.add_column("Address", justify="right")
|
|
475
|
+
table.add_column("Bytes")
|
|
476
|
+
table.add_column("Instruction")
|
|
477
|
+
for insn in result.instructions:
|
|
478
|
+
table.add_row(f"0x{insn.address:x}", insn.raw_bytes.hex(), escape(insn.text))
|
|
479
|
+
console.print(table)
|
|
480
|
+
if result.truncated:
|
|
481
|
+
console.print("[dim]Output truncated; raise --count to decode more.[/]")
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@app.command()
|
|
485
|
+
def analyze(
|
|
486
|
+
path: Path,
|
|
487
|
+
output: Annotated[
|
|
488
|
+
Path | None, typer.Option("--output", "-o", help="Write the JSON report to this path")
|
|
489
|
+
] = None,
|
|
490
|
+
string_limit: Annotated[int, typer.Option(help="Maximum strings kept in the report")] = 5000,
|
|
491
|
+
callgraph: Annotated[
|
|
492
|
+
bool,
|
|
493
|
+
typer.Option("--callgraph", help="Embed the direct-call call graph in the report"),
|
|
494
|
+
] = False,
|
|
495
|
+
xrefs: Annotated[
|
|
496
|
+
bool,
|
|
497
|
+
typer.Option("--xrefs", help="Embed the cross-reference section in the report"),
|
|
498
|
+
] = False,
|
|
499
|
+
cfg: Annotated[
|
|
500
|
+
bool,
|
|
501
|
+
typer.Option("--cfg", help="Embed the entry-point control-flow graph in the report"),
|
|
502
|
+
] = False,
|
|
503
|
+
xref_limit: Annotated[
|
|
504
|
+
int, typer.Option("--xref-limit", help="Maximum cross references kept in the report")
|
|
505
|
+
] = DEFAULT_XREF_LIMIT,
|
|
506
|
+
json_output: Annotated[
|
|
507
|
+
bool,
|
|
508
|
+
typer.Option("--json", help="Echo the JSON report on stdout (default without --output)"),
|
|
509
|
+
] = False,
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Run the phase 1 analysis and emit a JSON report.
|
|
512
|
+
|
|
513
|
+
Each phase 2 section is opt-in and maps straight to its ``build_report``
|
|
514
|
+
argument: ``--xrefs`` adds ``xrefs`` (bounded by ``--xref-limit``, default
|
|
515
|
+
2000, capped at 200000) and ``--cfg`` adds the entry-point ``cfg`` (bounded
|
|
516
|
+
to 2000 instructions and 500 blocks), each with its own evidence and
|
|
517
|
+
warnings, and the provenance step chain gains ``find_xrefs``/``build_cfg``.
|
|
518
|
+
``--callgraph`` adds the ``callgraph`` section (bounded to 1000 nodes and
|
|
519
|
+
5000 edges). Without the flags the report is the phase 1 payload.
|
|
520
|
+
|
|
521
|
+
When ``--output`` is used a dim one-line summary per embedded section
|
|
522
|
+
(nodes/edges, xrefs kept, CFG blocks; counts and truncation) is printed
|
|
523
|
+
after the file is written, and ``--json`` additionally echoes the payload on
|
|
524
|
+
stdout.
|
|
525
|
+
"""
|
|
526
|
+
try:
|
|
527
|
+
report = build_report(
|
|
528
|
+
path,
|
|
529
|
+
string_limit=string_limit,
|
|
530
|
+
xref_limit=xref_limit,
|
|
531
|
+
include_callgraph=callgraph,
|
|
532
|
+
include_xrefs=xrefs,
|
|
533
|
+
include_cfg=cfg,
|
|
534
|
+
)
|
|
535
|
+
except (AnalysisError, BinaryLoadError) as exc:
|
|
536
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
537
|
+
raise typer.Exit(code=2) from exc
|
|
538
|
+
payload = json.dumps(report, indent=2)
|
|
539
|
+
if output is not None:
|
|
540
|
+
output.write_text(payload, encoding="utf-8")
|
|
541
|
+
console.print(f"[green]report written:[/] {output}")
|
|
542
|
+
_print_report_summary(report)
|
|
543
|
+
if json_output:
|
|
544
|
+
console.print_json(payload)
|
|
545
|
+
return
|
|
546
|
+
console.print_json(payload)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _print_report_summary(report: dict[str, Any]) -> None:
|
|
550
|
+
"""Print dim one-line summaries for the sections embedded in a report."""
|
|
551
|
+
graph = report.get("callgraph")
|
|
552
|
+
if isinstance(graph, dict):
|
|
553
|
+
console.print(
|
|
554
|
+
f"[dim]callgraph: {graph.get('node_count', 0)} nodes"
|
|
555
|
+
f" · {graph.get('edge_count', 0)} edges"
|
|
556
|
+
f" · truncated: {'yes' if graph.get('truncated') else 'no'}[/]"
|
|
557
|
+
)
|
|
558
|
+
refs = report.get("xrefs")
|
|
559
|
+
if isinstance(refs, dict):
|
|
560
|
+
console.print(
|
|
561
|
+
f"[dim]xrefs: {refs.get('returned', 0)} of {refs.get('count', 0)} kept"
|
|
562
|
+
f" · truncated: {'yes' if refs.get('truncated') else 'no'}[/]"
|
|
563
|
+
)
|
|
564
|
+
flow = report.get("cfg")
|
|
565
|
+
if isinstance(flow, dict):
|
|
566
|
+
console.print(
|
|
567
|
+
f"[dim]cfg: {flow.get('block_count', 0)} blocks"
|
|
568
|
+
f" · {flow.get('instruction_count', 0)} instructions"
|
|
569
|
+
f" · truncated: {'yes' if flow.get('truncated') else 'no'}[/]"
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@app.command()
|
|
574
|
+
def xrefs(
|
|
575
|
+
path: Path,
|
|
576
|
+
limit: Annotated[int, typer.Option(help="Maximum cross references to display")] = 100,
|
|
577
|
+
max_bytes: Annotated[
|
|
578
|
+
int, typer.Option("--max-bytes", help="Ceiling on executable bytes scanned")
|
|
579
|
+
] = DEFAULT_MAX_BYTES,
|
|
580
|
+
json_output: Annotated[
|
|
581
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of a table")
|
|
582
|
+
] = False,
|
|
583
|
+
) -> None:
|
|
584
|
+
"""List direct-branch cross references found in executable sections."""
|
|
585
|
+
if limit < 1:
|
|
586
|
+
_fail("--limit must be >= 1", 2)
|
|
587
|
+
if max_bytes < 1:
|
|
588
|
+
_fail("--max-bytes must be >= 1", 2)
|
|
589
|
+
loaded = _load(path)
|
|
590
|
+
try:
|
|
591
|
+
result = find_xrefs(path, loaded, max_bytes=max_bytes)
|
|
592
|
+
except AnalysisError as exc:
|
|
593
|
+
_fail(str(exc), 2)
|
|
594
|
+
if json_output:
|
|
595
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
596
|
+
return
|
|
597
|
+
table = Table(title=f"Cross references ({len(result.xrefs)})")
|
|
598
|
+
table.add_column("From", justify="right")
|
|
599
|
+
table.add_column("To", justify="right")
|
|
600
|
+
table.add_column("Kind")
|
|
601
|
+
table.add_column("Function")
|
|
602
|
+
table.add_column("Instruction")
|
|
603
|
+
for item in result.xrefs[:limit]:
|
|
604
|
+
table.add_row(
|
|
605
|
+
f"0x{item.from_address:x}",
|
|
606
|
+
f"0x{item.to_address:x}",
|
|
607
|
+
escape(item.kind),
|
|
608
|
+
escape(item.function or "-"),
|
|
609
|
+
escape(item.instruction),
|
|
610
|
+
)
|
|
611
|
+
console.print(table)
|
|
612
|
+
if len(result.xrefs) > limit:
|
|
613
|
+
console.print(f"[dim]Showing {limit} of {len(result.xrefs)}; raise --limit for more.[/]")
|
|
614
|
+
shown = ", ".join(result.scanned_sections) or "-"
|
|
615
|
+
console.print(
|
|
616
|
+
f"[dim]scanned {result.scanned_bytes} bytes in {escape(shown)}"
|
|
617
|
+
f" · truncated: {'yes' if result.truncated else 'no'}[/]"
|
|
618
|
+
)
|
|
619
|
+
for warning in result.warnings:
|
|
620
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _print_cfg(graph: FunctionCFG) -> None:
|
|
624
|
+
title = f"Basic blocks ({len(graph.blocks)}) at 0x{graph.function_address:x}"
|
|
625
|
+
if graph.function_name:
|
|
626
|
+
title += f" · {graph.function_name}"
|
|
627
|
+
table = Table(title=escape(title))
|
|
628
|
+
table.add_column("Start", justify="right")
|
|
629
|
+
table.add_column("End", justify="right")
|
|
630
|
+
table.add_column("Instructions", justify="right")
|
|
631
|
+
table.add_column("Terminator")
|
|
632
|
+
table.add_column("Successors")
|
|
633
|
+
for block in graph.blocks:
|
|
634
|
+
successors = ", ".join(f"0x{address:x}" for address in block.successors) or "-"
|
|
635
|
+
table.add_row(
|
|
636
|
+
f"0x{block.start:x}",
|
|
637
|
+
f"0x{block.end:x}",
|
|
638
|
+
str(block.instruction_count),
|
|
639
|
+
escape(block.terminator or "-"),
|
|
640
|
+
escape(successors),
|
|
641
|
+
)
|
|
642
|
+
console.print(table)
|
|
643
|
+
for warning in graph.warnings:
|
|
644
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
645
|
+
if graph.truncated:
|
|
646
|
+
console.print("[dim]graph truncated; raise --max-instructions/--max-blocks for more.[/]")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
#: Traversal strategies accepted by ``codevanta cfg``.
|
|
650
|
+
CfgTraversal = Literal["linear", "recursive"]
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.command()
|
|
654
|
+
def cfg(
|
|
655
|
+
path: Path,
|
|
656
|
+
address: Annotated[
|
|
657
|
+
int | None, typer.Option("--address", "-a", help="Virtual address of the function")
|
|
658
|
+
] = None,
|
|
659
|
+
function: Annotated[
|
|
660
|
+
str | None, typer.Option("--function", "-f", help="Function name to graph")
|
|
661
|
+
] = None,
|
|
662
|
+
max_instructions: Annotated[
|
|
663
|
+
int, typer.Option("--max-instructions", help="Instruction budget for the traversal")
|
|
664
|
+
] = 2000,
|
|
665
|
+
max_blocks: Annotated[
|
|
666
|
+
int, typer.Option("--max-blocks", help="Maximum basic blocks reported")
|
|
667
|
+
] = 500,
|
|
668
|
+
traversal: Annotated[
|
|
669
|
+
CfgTraversal,
|
|
670
|
+
typer.Option(
|
|
671
|
+
"--traversal",
|
|
672
|
+
help=(
|
|
673
|
+
"Traversal strategy: linear (contiguous linear sweep, default) or "
|
|
674
|
+
"recursive (follow jump targets from the entry block only)"
|
|
675
|
+
),
|
|
676
|
+
),
|
|
677
|
+
] = "linear",
|
|
678
|
+
resolve_indirect: Annotated[
|
|
679
|
+
bool,
|
|
680
|
+
typer.Option(
|
|
681
|
+
"--resolve-indirect",
|
|
682
|
+
help=(
|
|
683
|
+
"Resolve indirect jumps through pointer jump tables (recursive "
|
|
684
|
+
"traversal only)"
|
|
685
|
+
),
|
|
686
|
+
),
|
|
687
|
+
] = False,
|
|
688
|
+
json_output: Annotated[
|
|
689
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of a table")
|
|
690
|
+
] = False,
|
|
691
|
+
) -> None:
|
|
692
|
+
"""Build a control-flow graph for the entry point, an address, or a function.
|
|
693
|
+
|
|
694
|
+
The --traversal option selects linear (default: contiguous window sweep) or
|
|
695
|
+
recursive (control-flow driven: only code reachable from the start address is
|
|
696
|
+
graphed). Both return the same FunctionCFG structure (blocks, successors,
|
|
697
|
+
evidence) and therefore the same table, --json payload and warnings; linear
|
|
698
|
+
blocks partition the decoded window while recursive blocks are disjoint but
|
|
699
|
+
limited to reachable code.
|
|
700
|
+
|
|
701
|
+
--resolve-indirect only means something to the recursive traversal: it turns
|
|
702
|
+
an indirect ``jmp`` through a pointer-sized memory operand into a jump-table
|
|
703
|
+
lookup, so the dispatch targets become successors and are followed like any
|
|
704
|
+
other block. The linear sweep has no notion of a dispatch edge, so asking for
|
|
705
|
+
it there is rejected (exit code 2) instead of silently ignored.
|
|
706
|
+
"""
|
|
707
|
+
if resolve_indirect and traversal != "recursive":
|
|
708
|
+
_fail(
|
|
709
|
+
"--resolve-indirect needs --traversal recursive: the linear sweep does not "
|
|
710
|
+
"follow control flow, so indirect jumps cannot be resolved",
|
|
711
|
+
2,
|
|
712
|
+
)
|
|
713
|
+
loaded = _load(path)
|
|
714
|
+
try:
|
|
715
|
+
start = resolve_target(loaded, address, function)
|
|
716
|
+
except AnalysisError as exc:
|
|
717
|
+
_fail(str(exc), 4)
|
|
718
|
+
try:
|
|
719
|
+
if traversal == "recursive":
|
|
720
|
+
graph = build_recursive_cfg(
|
|
721
|
+
path,
|
|
722
|
+
loaded,
|
|
723
|
+
start,
|
|
724
|
+
max_instructions=max_instructions,
|
|
725
|
+
max_blocks=max_blocks,
|
|
726
|
+
resolve_indirect=resolve_indirect,
|
|
727
|
+
)
|
|
728
|
+
else:
|
|
729
|
+
graph = build_cfg(
|
|
730
|
+
path,
|
|
731
|
+
loaded,
|
|
732
|
+
start,
|
|
733
|
+
max_instructions=max_instructions,
|
|
734
|
+
max_blocks=max_blocks,
|
|
735
|
+
)
|
|
736
|
+
except AnalysisError as exc:
|
|
737
|
+
_fail(str(exc), 5)
|
|
738
|
+
if json_output:
|
|
739
|
+
console.print_json(json.dumps(graph.to_dict()))
|
|
740
|
+
return
|
|
741
|
+
_print_cfg(graph)
|
|
742
|
+
if json_output:
|
|
743
|
+
console.print_json(json.dumps(graph.to_dict()))
|
|
744
|
+
return
|
|
745
|
+
_print_cfg(graph)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
#: Number of leading hex characters of a content fingerprint shown in a table.
|
|
749
|
+
_DIGEST_PREVIEW = 12
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _digest_cell(delta: FunctionDelta) -> str:
|
|
753
|
+
"""Return the fingerprint cell for one function delta.
|
|
754
|
+
|
|
755
|
+
Deep mode fingerprints only address-matched pairs, so ``added``/``removed``
|
|
756
|
+
deltas -- and matched pairs beyond the deep-mode ceiling -- carry no digest
|
|
757
|
+
and render as ``-`` rather than as an empty cell.
|
|
758
|
+
"""
|
|
759
|
+
if delta.left_digest is None or delta.right_digest is None:
|
|
760
|
+
return "-"
|
|
761
|
+
return f"{delta.left_digest[:_DIGEST_PREVIEW]} -> {delta.right_digest[:_DIGEST_PREVIEW]}"
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _similarity_line(delta: FunctionDelta) -> str:
|
|
765
|
+
"""Render one semantic pairing as ``0x401000 -> 0x401010 ... similarity 1.0000``.
|
|
766
|
+
|
|
767
|
+
Only deltas carrying a ``similarity`` reach this renderer, so both metadata
|
|
768
|
+
snapshots are set and the two addresses are the move the score paired. The
|
|
769
|
+
score keeps four decimals, matching :attr:`FunctionDelta.similarity` and the
|
|
770
|
+
JSON report, so the text and the payload cannot disagree.
|
|
771
|
+
"""
|
|
772
|
+
left = "-" if delta.left is None else str(delta.left.get("address", "-"))
|
|
773
|
+
right = "-" if delta.right is None else str(delta.right.get("address", "-"))
|
|
774
|
+
score = 0.0 if delta.similarity is None else delta.similarity
|
|
775
|
+
return (
|
|
776
|
+
f"semantic pair {left} -> {right} "
|
|
777
|
+
f"({delta.name or '<unknown>'}): similarity {score:.4f}"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _print_binary_diff(result: BinaryDiff) -> None:
|
|
782
|
+
"""Print a :class:`BinaryDiff` as an identity panel plus two tables.
|
|
783
|
+
|
|
784
|
+
The first table is the added/removed/changed count matrix for functions,
|
|
785
|
+
sections, imports and exports; the second lists the individual name deltas.
|
|
786
|
+
The function delta table then carries the address, name and change of every
|
|
787
|
+
candidate the comparison could not match, plus -- when deep mode
|
|
788
|
+
fingerprinted the pair -- the truncated content digests of both sides. A
|
|
789
|
+
pairing made by semantic mode adds no column: its score is printed as one
|
|
790
|
+
dim detail line per pair, so the table keeps its shape.
|
|
791
|
+
"""
|
|
792
|
+
header = Table.grid(padding=(0, 2))
|
|
793
|
+
header.add_column(style="bold cyan")
|
|
794
|
+
header.add_column()
|
|
795
|
+
header.add_row("Left", escape(result.left_path))
|
|
796
|
+
header.add_row("Right", escape(result.right_path))
|
|
797
|
+
header.add_row("Identical", "yes" if result.identical else "no")
|
|
798
|
+
console.print(Panel(header, title="CODEVANTA · binary diff"))
|
|
799
|
+
|
|
800
|
+
counts = Table(title="Deltas")
|
|
801
|
+
counts.add_column("Kind")
|
|
802
|
+
counts.add_column("Added", justify="right")
|
|
803
|
+
counts.add_column("Removed", justify="right")
|
|
804
|
+
counts.add_column("Changed", justify="right")
|
|
805
|
+
for kind, added, removed, changed in (
|
|
806
|
+
(
|
|
807
|
+
"Functions",
|
|
808
|
+
result.functions_added,
|
|
809
|
+
result.functions_removed,
|
|
810
|
+
result.functions_changed,
|
|
811
|
+
),
|
|
812
|
+
("Sections", result.sections_added, result.sections_removed, ()),
|
|
813
|
+
("Imports", result.imports_added, result.imports_removed, ()),
|
|
814
|
+
("Exports", result.exports_added, result.exports_removed, ()),
|
|
815
|
+
):
|
|
816
|
+
counts.add_row(kind, str(len(added)), str(len(removed)), str(len(changed)))
|
|
817
|
+
console.print(counts)
|
|
818
|
+
|
|
819
|
+
named = (
|
|
820
|
+
[("Section", "added", name) for name in result.sections_added]
|
|
821
|
+
+ [("Section", "removed", name) for name in result.sections_removed]
|
|
822
|
+
+ [("Import", "added", name) for name in result.imports_added]
|
|
823
|
+
+ [("Import", "removed", name) for name in result.imports_removed]
|
|
824
|
+
+ [("Export", "added", name) for name in result.exports_added]
|
|
825
|
+
+ [("Export", "removed", name) for name in result.exports_removed]
|
|
826
|
+
)
|
|
827
|
+
if named:
|
|
828
|
+
names = Table(title=f"Section, import and export deltas ({len(named)})")
|
|
829
|
+
names.add_column("Kind")
|
|
830
|
+
names.add_column("Change")
|
|
831
|
+
names.add_column("Name")
|
|
832
|
+
for kind, change, name in named:
|
|
833
|
+
names.add_row(escape(kind), escape(change), escape(name))
|
|
834
|
+
console.print(names)
|
|
835
|
+
|
|
836
|
+
deltas = sorted(
|
|
837
|
+
(*result.functions_added, *result.functions_removed, *result.functions_changed),
|
|
838
|
+
key=lambda delta: (delta.address, delta.change),
|
|
839
|
+
)
|
|
840
|
+
if deltas:
|
|
841
|
+
show_digests = any(
|
|
842
|
+
delta.left_digest is not None or delta.right_digest is not None for delta in deltas
|
|
843
|
+
)
|
|
844
|
+
scored = [delta for delta in deltas if delta.similarity is not None]
|
|
845
|
+
functions = Table(title=f"Function deltas ({len(deltas)})")
|
|
846
|
+
functions.add_column("Address", justify="right")
|
|
847
|
+
functions.add_column("Name")
|
|
848
|
+
functions.add_column("Change")
|
|
849
|
+
if show_digests:
|
|
850
|
+
functions.add_column("Digest (left -> right)")
|
|
851
|
+
for delta in deltas:
|
|
852
|
+
row = [
|
|
853
|
+
f"0x{delta.address:x}",
|
|
854
|
+
escape(delta.name or "<unknown>"),
|
|
855
|
+
escape(delta.change),
|
|
856
|
+
]
|
|
857
|
+
if show_digests:
|
|
858
|
+
row.append(escape(_digest_cell(delta)))
|
|
859
|
+
functions.add_row(*row)
|
|
860
|
+
console.print(functions)
|
|
861
|
+
if show_digests:
|
|
862
|
+
console.print(
|
|
863
|
+
"[dim]digests are the first "
|
|
864
|
+
f"{_DIGEST_PREVIEW} hex characters of the normalized shape SHA-256; "
|
|
865
|
+
"equal digests are not byte equality[/]"
|
|
866
|
+
)
|
|
867
|
+
if scored:
|
|
868
|
+
for delta in scored:
|
|
869
|
+
console.print(f"[dim]{escape(_similarity_line(delta))}[/]")
|
|
870
|
+
console.print(
|
|
871
|
+
"[dim]pairs are decoded instruction-shape overlaps, "
|
|
872
|
+
"not proof the functions are the same code[/]"
|
|
873
|
+
)
|
|
874
|
+
else:
|
|
875
|
+
console.print("[dim]no function deltas[/]")
|
|
876
|
+
for warning in result.warnings:
|
|
877
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _diff_threshold(value: float) -> float:
|
|
881
|
+
"""Validate ``--threshold`` and return it as a float, or exit 2.
|
|
882
|
+
|
|
883
|
+
The bound is checked before either binary is loaded, so an out-of-range
|
|
884
|
+
value fails as an input error instead of after a potentially expensive
|
|
885
|
+
parse -- and it is checked even when ``--semantic`` is off, because a
|
|
886
|
+
silently ignored value would change behaviour the moment semantic matching
|
|
887
|
+
is switched on. ``nan``/``inf`` are rejected alongside the finite range
|
|
888
|
+
because the comparison would otherwise be silently always-false.
|
|
889
|
+
"""
|
|
890
|
+
if not math.isfinite(value) or not (DIFF_MIN_THRESHOLD <= value <= DIFF_MAX_THRESHOLD):
|
|
891
|
+
_fail(
|
|
892
|
+
f"invalid threshold {value!r}; expected a number within "
|
|
893
|
+
f"{DIFF_MIN_THRESHOLD} .. {DIFF_MAX_THRESHOLD}",
|
|
894
|
+
2,
|
|
895
|
+
)
|
|
896
|
+
return float(value)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@app.command()
|
|
900
|
+
def diff(
|
|
901
|
+
left: Annotated[Path, typer.Argument(help="Baseline (left-hand) binary")],
|
|
902
|
+
right: Annotated[Path, typer.Argument(help="Candidate (right-hand) binary")],
|
|
903
|
+
max_functions: Annotated[
|
|
904
|
+
int,
|
|
905
|
+
typer.Option(
|
|
906
|
+
"--max-functions", help="Per-side ceiling on compared function candidates"
|
|
907
|
+
),
|
|
908
|
+
] = DIFF_MAX_FUNCTIONS,
|
|
909
|
+
deep: Annotated[
|
|
910
|
+
bool,
|
|
911
|
+
typer.Option(
|
|
912
|
+
"--deep",
|
|
913
|
+
help="Also compare the normalized content of address-matched functions",
|
|
914
|
+
),
|
|
915
|
+
] = False,
|
|
916
|
+
semantic: Annotated[
|
|
917
|
+
bool,
|
|
918
|
+
typer.Option(
|
|
919
|
+
"--semantic",
|
|
920
|
+
help=(
|
|
921
|
+
"Also pair the functions left unpaired by address and name when "
|
|
922
|
+
"their normalized instruction shapes score at least --threshold"
|
|
923
|
+
),
|
|
924
|
+
),
|
|
925
|
+
] = False,
|
|
926
|
+
threshold: Annotated[
|
|
927
|
+
float,
|
|
928
|
+
typer.Option(
|
|
929
|
+
"--threshold",
|
|
930
|
+
help=(
|
|
931
|
+
"Smallest shape-similarity score that may pair two leftovers "
|
|
932
|
+
f"({DIFF_MIN_THRESHOLD} .. {DIFF_MAX_THRESHOLD}, "
|
|
933
|
+
f"default {DIFF_SEMANTIC_THRESHOLD})"
|
|
934
|
+
),
|
|
935
|
+
),
|
|
936
|
+
] = DIFF_SEMANTIC_THRESHOLD,
|
|
937
|
+
json_output: Annotated[
|
|
938
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of tables")
|
|
939
|
+
] = False,
|
|
940
|
+
) -> None:
|
|
941
|
+
"""Structurally diff two binaries (sections, imports, exports, functions).
|
|
942
|
+
|
|
943
|
+
The comparison is metadata only: no instruction bytes are read, compared or
|
|
944
|
+
executed, so the identical flag means "no structural delta was observed",
|
|
945
|
+
never "the machine code is equivalent". A difference is the successful
|
|
946
|
+
outcome, so the command exits 0 whether or not the binaries differ; only load
|
|
947
|
+
and input errors exit non-zero (2).
|
|
948
|
+
|
|
949
|
+
``--deep`` adds a shape fingerprint of every address-matched function: its
|
|
950
|
+
bytes are read (never executed) and their normalized instructions are hashed
|
|
951
|
+
with SHA-256, so immediates and branch targets are masked out. A differing
|
|
952
|
+
digest turns the pair into ``changed`` and both digests are shown;
|
|
953
|
+
``added``/``removed`` pairs are not fingerprinted and keep ``-``. Equal
|
|
954
|
+
digests are not byte equality and different digests are not a semantic
|
|
955
|
+
difference, so the metadata verdict is never upgraded to "equivalent".
|
|
956
|
+
|
|
957
|
+
``--semantic`` reads the decoded instruction shapes of the functions that
|
|
958
|
+
address and name matching left unpaired and pairs them when their score
|
|
959
|
+
reaches ``--threshold`` (default 0.9, valid range 0.0 .. 1.0; anything else
|
|
960
|
+
exits 2 before a file is loaded). A paired move becomes one ``changed``
|
|
961
|
+
entry and its score is printed as a dim ``semantic pair`` detail line under
|
|
962
|
+
the unchanged function table. Every unpaired candidate keeps its structural
|
|
963
|
+
verdict, so semantic mode can only merge a removal with an addition, never
|
|
964
|
+
hide one. An overlap is evidence of similar decoded shapes, not proof that
|
|
965
|
+
two functions are the same code.
|
|
966
|
+
"""
|
|
967
|
+
effective_threshold = _diff_threshold(threshold)
|
|
968
|
+
try:
|
|
969
|
+
result = diff_binaries(
|
|
970
|
+
left,
|
|
971
|
+
right,
|
|
972
|
+
max_functions=max_functions,
|
|
973
|
+
deep=deep,
|
|
974
|
+
semantic=semantic,
|
|
975
|
+
threshold=effective_threshold,
|
|
976
|
+
)
|
|
977
|
+
except BinaryLoadError as exc:
|
|
978
|
+
_fail(str(exc), 2)
|
|
979
|
+
except AnalysisError as exc:
|
|
980
|
+
_fail(str(exc), 2)
|
|
981
|
+
if json_output:
|
|
982
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
983
|
+
return
|
|
984
|
+
_print_binary_diff(result)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _jump_table_mode(mode: str | None) -> str | None:
|
|
988
|
+
"""Validate ``--mode`` and return it normalised, or ``None`` when omitted.
|
|
989
|
+
|
|
990
|
+
Case and surrounding whitespace are folded exactly like
|
|
991
|
+
:func:`codevanta.analysis.jump_tables.resolve_jump_table`, so
|
|
992
|
+
``--mode Relative`` works; an unknown layout exits 2 before any file is
|
|
993
|
+
loaded, and ``None`` (flag omitted) keeps the historical absolute default
|
|
994
|
+
without changing the printed footer.
|
|
995
|
+
"""
|
|
996
|
+
if mode is None:
|
|
997
|
+
return None
|
|
998
|
+
text = mode.strip().lower()
|
|
999
|
+
if text not in MODES:
|
|
1000
|
+
_fail(f"invalid mode {mode!r}; expected one of {', '.join(MODES)}", 2)
|
|
1001
|
+
return text
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _print_jump_table(
|
|
1005
|
+
result: JumpTableResult,
|
|
1006
|
+
*,
|
|
1007
|
+
mode: str | None = None,
|
|
1008
|
+
base_address: int | None = None,
|
|
1009
|
+
) -> None:
|
|
1010
|
+
"""Print one :class:`JumpTableResult` as an index/target table plus a footer.
|
|
1011
|
+
|
|
1012
|
+
The first footer line is identical to what earlier releases printed. When
|
|
1013
|
+
the caller asked for a layout explicitly, a second dim line names the mode
|
|
1014
|
+
and -- in relative mode -- the base: every target of such a table is
|
|
1015
|
+
``base + signed offset`` and is meaningless without it. Keeping that on its
|
|
1016
|
+
own line also stops a narrow console from wrapping the base away from its
|
|
1017
|
+
label.
|
|
1018
|
+
"""
|
|
1019
|
+
table = Table(title=escape(f"Jump table at 0x{result.table_address:x}"))
|
|
1020
|
+
table.add_column("Index", justify="right")
|
|
1021
|
+
table.add_column("Target", justify="right")
|
|
1022
|
+
for entry in result.entries:
|
|
1023
|
+
table.add_row(str(entry.index), f"0x{entry.target:x}")
|
|
1024
|
+
console.print(table)
|
|
1025
|
+
if not result.entries:
|
|
1026
|
+
console.print("[yellow]no pointer slots decoded at this address[/]")
|
|
1027
|
+
console.print(
|
|
1028
|
+
f"[dim]pointer size: {result.pointer_size} bytes"
|
|
1029
|
+
f" · entries: {len(result.entries)}"
|
|
1030
|
+
f" · truncated: {'yes' if result.truncated else 'no'}[/]"
|
|
1031
|
+
)
|
|
1032
|
+
layout: list[str] = []
|
|
1033
|
+
if mode is not None:
|
|
1034
|
+
layout.append(f"mode: {mode}")
|
|
1035
|
+
if base_address is not None:
|
|
1036
|
+
layout.append(f"base: 0x{base_address:x}")
|
|
1037
|
+
if layout:
|
|
1038
|
+
console.print(f"[dim]{' · '.join(layout)}[/]")
|
|
1039
|
+
for warning in result.warnings:
|
|
1040
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
@app.command("jump-table")
|
|
1044
|
+
def jump_table(
|
|
1045
|
+
path: Path,
|
|
1046
|
+
address: Annotated[
|
|
1047
|
+
str,
|
|
1048
|
+
typer.Option(
|
|
1049
|
+
"--address",
|
|
1050
|
+
"-a",
|
|
1051
|
+
help="Virtual address of the first pointer slot (decimal or 0x hex)",
|
|
1052
|
+
),
|
|
1053
|
+
],
|
|
1054
|
+
entries: Annotated[
|
|
1055
|
+
int, typer.Option("--entries", help="Maximum pointer slots to decode")
|
|
1056
|
+
] = JUMP_TABLE_MAX_ENTRIES,
|
|
1057
|
+
executable_only: Annotated[
|
|
1058
|
+
bool,
|
|
1059
|
+
typer.Option(
|
|
1060
|
+
"--executable-only/--no-executable-only",
|
|
1061
|
+
help="Stop the scan at pointers into non-executable sections",
|
|
1062
|
+
),
|
|
1063
|
+
] = True,
|
|
1064
|
+
mode: Annotated[
|
|
1065
|
+
str | None,
|
|
1066
|
+
typer.Option(
|
|
1067
|
+
"--mode",
|
|
1068
|
+
help="Table layout: 'absolute' pointer slots (default) or 'relative' "
|
|
1069
|
+
"signed 32-bit offsets from --base",
|
|
1070
|
+
),
|
|
1071
|
+
] = None,
|
|
1072
|
+
base: Annotated[
|
|
1073
|
+
str | None,
|
|
1074
|
+
typer.Option(
|
|
1075
|
+
"--base",
|
|
1076
|
+
help="Origin that relative offsets are added to (decimal or 0x hex); "
|
|
1077
|
+
"required with --mode relative",
|
|
1078
|
+
),
|
|
1079
|
+
] = None,
|
|
1080
|
+
json_output: Annotated[
|
|
1081
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of a table")
|
|
1082
|
+
] = False,
|
|
1083
|
+
) -> None:
|
|
1084
|
+
"""Decode a jump table starting at ADDRESS.
|
|
1085
|
+
|
|
1086
|
+
With ``--mode absolute`` (the default) each slot is a pointer-sized absolute
|
|
1087
|
+
virtual address; with ``--mode relative`` each slot is a signed 32-bit offset
|
|
1088
|
+
and the target is ``--base + offset``, which is the shape position-independent
|
|
1089
|
+
switch tables use. Relative mode without a usable ``--base`` exits 2 instead
|
|
1090
|
+
of inventing an origin, because every target would otherwise be wrong.
|
|
1091
|
+
|
|
1092
|
+
Slots are read exclusively from the containing section's file bytes and
|
|
1093
|
+
decoded with the binary's endianness and pointer width (8 bytes for a 64-bit
|
|
1094
|
+
binary, 4 for a 32-bit one; always 4 in relative mode). The scan stops at the
|
|
1095
|
+
first slot that cannot be a target, namely an address no section maps, or a
|
|
1096
|
+
pointer into a non-executable section while --executable-only is in effect,
|
|
1097
|
+
and every such stop is reported as a warning. In absolute mode a zero pointer
|
|
1098
|
+
also terminates the table. Decoded pointers are evidence of a dispatch table,
|
|
1099
|
+
not proof that a dispatcher indexes it.
|
|
1100
|
+
"""
|
|
1101
|
+
table_address = _parse_address(address)
|
|
1102
|
+
explicit_mode = _jump_table_mode(mode)
|
|
1103
|
+
resolved_mode = explicit_mode or MODE_ABSOLUTE
|
|
1104
|
+
base_address = None if base is None else _parse_address(base, label="base")
|
|
1105
|
+
if resolved_mode == MODE_RELATIVE and base_address is None:
|
|
1106
|
+
_fail("--mode relative requires --base (e.g. --base 0x401000)", 2)
|
|
1107
|
+
loaded = _load(path)
|
|
1108
|
+
try:
|
|
1109
|
+
result = resolve_jump_table(
|
|
1110
|
+
path,
|
|
1111
|
+
loaded,
|
|
1112
|
+
table_address,
|
|
1113
|
+
max_entries=entries,
|
|
1114
|
+
executable_only=executable_only,
|
|
1115
|
+
mode=resolved_mode,
|
|
1116
|
+
base_address=base_address if resolved_mode == MODE_RELATIVE else None,
|
|
1117
|
+
)
|
|
1118
|
+
except AnalysisError as exc:
|
|
1119
|
+
_fail(str(exc), 2)
|
|
1120
|
+
if json_output:
|
|
1121
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
1122
|
+
return
|
|
1123
|
+
_print_jump_table(
|
|
1124
|
+
result,
|
|
1125
|
+
mode=explicit_mode,
|
|
1126
|
+
base_address=base_address if resolved_mode == MODE_RELATIVE else None,
|
|
1127
|
+
)
|
|
1128
|
+
|
|
1129
|
+
|
|
1130
|
+
#: Pointer width in bytes per bitness, used when a memory operand carries no
|
|
1131
|
+
#: Intel size keyword. Mirrors the fallback used by the type-recovery layer.
|
|
1132
|
+
_STRUCT_POINTER_WIDTHS: dict[int, int] = {16: 2, 32: 4, 64: 8}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _struct_offset_cell(offset: int) -> str:
|
|
1136
|
+
"""Render a field offset in hex, keeping the sign in front (``-0x8``)."""
|
|
1137
|
+
return f"-0x{-offset:x}" if offset < 0 else f"0x{offset:x}"
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _struct_base_registers(
|
|
1141
|
+
path: Path,
|
|
1142
|
+
info: BinaryInfo,
|
|
1143
|
+
start: int,
|
|
1144
|
+
*,
|
|
1145
|
+
max_instructions: int,
|
|
1146
|
+
include_stack: bool,
|
|
1147
|
+
) -> tuple[str, ...]:
|
|
1148
|
+
"""Return the base registers a *single* struct window saw, sorted and unique.
|
|
1149
|
+
|
|
1150
|
+
:func:`codevanta.analysis.type_recovery.suggest_struct` aggregates accesses
|
|
1151
|
+
per offset and therefore drops the base register of each access, while the
|
|
1152
|
+
footer still has to name the registers used as struct pointers. The same
|
|
1153
|
+
bounded window is decoded once more and every operand goes through the
|
|
1154
|
+
production parser (:func:`parse_memory_operands`), so both passes agree on
|
|
1155
|
+
which operands count. A failure here never fails the command: the suggestion
|
|
1156
|
+
is already computed, so the footer simply reports no base register.
|
|
1157
|
+
|
|
1158
|
+
Only the single-window path pays for this second decode -- one extra window,
|
|
1159
|
+
so the single-address output stays exactly as it was. A merge must not
|
|
1160
|
+
multiply that cost by its window count and reads the registers out of the
|
|
1161
|
+
evidence instead (:func:`_struct_evidence_base_registers`).
|
|
1162
|
+
"""
|
|
1163
|
+
pointer_width = _STRUCT_POINTER_WIDTHS.get(info.bitness, 8)
|
|
1164
|
+
try:
|
|
1165
|
+
region = disassemble_region(path, info, start, count=max_instructions)
|
|
1166
|
+
except (AnalysisError, DisassemblyError):
|
|
1167
|
+
return ()
|
|
1168
|
+
registers: set[str] = set()
|
|
1169
|
+
for instruction in region.instructions:
|
|
1170
|
+
for access in parse_memory_operands(
|
|
1171
|
+
instruction, include_stack=include_stack, pointer_width=pointer_width
|
|
1172
|
+
):
|
|
1173
|
+
registers.add(access.base_register)
|
|
1174
|
+
return tuple(sorted(registers))
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
#: Evidence key a suggestion would carry the base registers it saw under.
|
|
1178
|
+
_STRUCT_BASE_REGISTER_KEY = "base_registers"
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _struct_evidence_base_registers(result: StructSuggestion) -> tuple[str, ...]:
|
|
1182
|
+
"""Return the base registers the sweep behind ``result`` recorded itself.
|
|
1183
|
+
|
|
1184
|
+
A merge aggregates accesses per offset across windows, so the base register
|
|
1185
|
+
of each access is not a field of its own. Re-sweeping every window just for
|
|
1186
|
+
the footer would decode up to ``max_windows * max_instructions``
|
|
1187
|
+
instructions a second time, on top of the budget the merge is bounded by, so
|
|
1188
|
+
the union is read out of the evidence the merge already produced instead --
|
|
1189
|
+
from the merged suggestion and from every merged field, because either level
|
|
1190
|
+
can carry it. The values are de-duplicated and sorted, which keeps the footer
|
|
1191
|
+
independent of the order the windows were passed in.
|
|
1192
|
+
|
|
1193
|
+
The phase 2k analysis layer records no base register in that evidence, so
|
|
1194
|
+
this returns none today and the footer says so rather than paying for a
|
|
1195
|
+
second decode. Carrying the registers out of the sweep is an analysis-layer
|
|
1196
|
+
change; until it lands, nothing here decodes a window again.
|
|
1197
|
+
"""
|
|
1198
|
+
registers: set[str] = set()
|
|
1199
|
+
evidence_records = [result.evidence, *(field.evidence for field in result.fields)]
|
|
1200
|
+
for evidence in evidence_records:
|
|
1201
|
+
recorded = evidence.extra.get(_STRUCT_BASE_REGISTER_KEY)
|
|
1202
|
+
if not isinstance(recorded, (list, tuple, set, frozenset)):
|
|
1203
|
+
continue
|
|
1204
|
+
registers.update(name for name in recorded if isinstance(name, str))
|
|
1205
|
+
return tuple(sorted(registers))
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _struct_window_text(windows: tuple[int, ...]) -> str:
|
|
1209
|
+
"""Render merged windows as ``2 (0x401000, 0x401004)``."""
|
|
1210
|
+
addresses = ", ".join(f"0x{start:x}" for start in windows)
|
|
1211
|
+
return f"{len(windows)} ({addresses})"
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _struct_warnings(result: StructSuggestion, *, merged: bool = False) -> list[str]:
|
|
1215
|
+
"""Return the CLI-level warnings for one struct suggestion.
|
|
1216
|
+
|
|
1217
|
+
``merged`` selects the field-cap hint. A single window can keep the dropped
|
|
1218
|
+
offsets by raising ``--max-fields``, but the same command exits 2 on a
|
|
1219
|
+
non-default ``--max-fields`` next to a merge, so a merge must not be told to
|
|
1220
|
+
raise a flag it rejects: it is told the cap the merged sweep applies instead
|
|
1221
|
+
and that the windows have to be split to see the remaining offsets.
|
|
1222
|
+
"""
|
|
1223
|
+
warnings: list[str] = []
|
|
1224
|
+
if result.truncated:
|
|
1225
|
+
if merged:
|
|
1226
|
+
warnings.append(
|
|
1227
|
+
f"field cap reached: the merged sweep keeps at most {STRUCT_MAX_FIELDS} "
|
|
1228
|
+
"offsets, so split the windows into separate runs to see the remaining "
|
|
1229
|
+
"offsets"
|
|
1230
|
+
)
|
|
1231
|
+
else:
|
|
1232
|
+
warnings.append(
|
|
1233
|
+
f"field cap of {result.max_fields} reached; raise --max-fields for the "
|
|
1234
|
+
"remaining offsets"
|
|
1235
|
+
)
|
|
1236
|
+
if not result.fields:
|
|
1237
|
+
hint = (
|
|
1238
|
+
""
|
|
1239
|
+
if result.include_stack
|
|
1240
|
+
else "; stack-based operands are excluded unless --include-stack is given"
|
|
1241
|
+
)
|
|
1242
|
+
warnings.append(f"no field-shaped memory operand was decoded in the window{hint}")
|
|
1243
|
+
return warnings
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _struct_alignment_text(result: StructSuggestion) -> str:
|
|
1247
|
+
"""Render the inferred alignment, or ``unknown`` when none could be derived."""
|
|
1248
|
+
return "unknown" if result.alignment is None else str(result.alignment)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def _struct_array_line(group: ArrayGroup) -> str:
|
|
1252
|
+
"""Render one array candidate as ``array at 0x10: 3 x 8-byte, stride 8``."""
|
|
1253
|
+
return (
|
|
1254
|
+
f"array at {_struct_offset_cell(group.offset)}: "
|
|
1255
|
+
f"{group.count} x {group.width}-byte, stride {group.stride}"
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def _struct_signed_text(result: StructSuggestion) -> str:
|
|
1260
|
+
"""Render the signedness hints of ``result`` as one footer value.
|
|
1261
|
+
|
|
1262
|
+
Only fields with evidence contribute, so a field whose offset was never
|
|
1263
|
+
touched by a sign- or zero-extending mnemonic stays out of the line, and a
|
|
1264
|
+
suggestion without any hint reports ``none`` -- meaning "no signedness
|
|
1265
|
+
evidence", never "safe". Contradictory offsets are already collapsed to
|
|
1266
|
+
``signed=None`` (with a warning) by the analysis layer, so they read as
|
|
1267
|
+
unknown here too. Offsets are named in hex, order preserved from the table.
|
|
1268
|
+
"""
|
|
1269
|
+
hints = [
|
|
1270
|
+
f"{_struct_offset_cell(field.offset)} "
|
|
1271
|
+
f"{'signed' if field.signed else 'unsigned'}"
|
|
1272
|
+
for field in result.fields
|
|
1273
|
+
if field.signed is not None
|
|
1274
|
+
]
|
|
1275
|
+
return ", ".join(hints) or "none"
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def _print_struct_layout(result: StructSuggestion) -> None:
|
|
1279
|
+
"""Print the alignment line and one line per array candidate.
|
|
1280
|
+
|
|
1281
|
+
``arrays: none`` stands in for the absent group lines, so the footer always
|
|
1282
|
+
says whether a run was found. Both texts go through :func:`escape` because
|
|
1283
|
+
they are assembled from analysis data. The closing ``signed:`` line follows
|
|
1284
|
+
the same rule: it lists the offsets that carry a signedness hint (or
|
|
1285
|
+
``none``), keeping the hint out of the table columns.
|
|
1286
|
+
"""
|
|
1287
|
+
console.print(f"[dim]alignment: {escape(_struct_alignment_text(result))}[/]")
|
|
1288
|
+
if result.array_groups:
|
|
1289
|
+
for group in result.array_groups:
|
|
1290
|
+
console.print(f"[dim]{escape(_struct_array_line(group))}[/]")
|
|
1291
|
+
else:
|
|
1292
|
+
console.print("[dim]arrays: none[/]")
|
|
1293
|
+
console.print(f"[dim]signed: {escape(_struct_signed_text(result))}[/]")
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _struct_register_footer(base_registers: tuple[str, ...], *, merged: bool) -> str:
|
|
1297
|
+
"""Render the ``base registers:`` footer value of a struct run.
|
|
1298
|
+
|
|
1299
|
+
A single window derives its registers from its own decode, so the only empty
|
|
1300
|
+
case is a window without a field-shaped operand and ``-`` states that. A
|
|
1301
|
+
merge reads the union from the evidence the sweep already produced; when that
|
|
1302
|
+
evidence carries none, ``-`` would claim the windows never used a base
|
|
1303
|
+
register, so the footer says the merge did not record them instead of
|
|
1304
|
+
decoding every window a second time to find out.
|
|
1305
|
+
"""
|
|
1306
|
+
if base_registers:
|
|
1307
|
+
return ", ".join(base_registers)
|
|
1308
|
+
return "not recorded by the merge" if merged else "-"
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _print_struct_suggestion(
|
|
1312
|
+
result: StructSuggestion,
|
|
1313
|
+
*,
|
|
1314
|
+
base_registers: tuple[str, ...],
|
|
1315
|
+
windows: tuple[int, ...] | None = None,
|
|
1316
|
+
) -> None:
|
|
1317
|
+
"""Print one :class:`StructSuggestion` as an offset table plus a footer.
|
|
1318
|
+
|
|
1319
|
+
``windows`` is only passed by the merged (repeated ``--address``) path, where
|
|
1320
|
+
it adds one ``windows:`` footer line naming every merged address and selects
|
|
1321
|
+
the merge wording of the field-cap hint. The single-window output stays
|
|
1322
|
+
unchanged because both only apply when ``windows`` is given.
|
|
1323
|
+
"""
|
|
1324
|
+
table = Table(title=escape(f"Struct at 0x{result.address:x}"))
|
|
1325
|
+
table.add_column("Offset", justify="right")
|
|
1326
|
+
table.add_column("Width", justify="right")
|
|
1327
|
+
table.add_column("Reads", justify="right")
|
|
1328
|
+
table.add_column("Writes", justify="right")
|
|
1329
|
+
for field in result.fields:
|
|
1330
|
+
table.add_row(
|
|
1331
|
+
_struct_offset_cell(field.offset),
|
|
1332
|
+
str(field.width),
|
|
1333
|
+
str(field.read_count),
|
|
1334
|
+
str(field.write_count),
|
|
1335
|
+
)
|
|
1336
|
+
console.print(table)
|
|
1337
|
+
merged = windows is not None
|
|
1338
|
+
registers = _struct_register_footer(base_registers, merged=merged)
|
|
1339
|
+
console.print(f"[dim]base registers: {escape(registers)} - fields: {result.field_count}[/]")
|
|
1340
|
+
console.print(
|
|
1341
|
+
f"[dim]instructions: {result.instruction_count}"
|
|
1342
|
+
f" - max fields: {result.max_fields}"
|
|
1343
|
+
f" - truncated: {'yes' if result.truncated else 'no'}[/]"
|
|
1344
|
+
)
|
|
1345
|
+
if windows is not None:
|
|
1346
|
+
console.print(f"[dim]windows: {escape(_struct_window_text(windows))}[/]")
|
|
1347
|
+
_print_struct_layout(result)
|
|
1348
|
+
for warning in _struct_warnings(result, merged=merged):
|
|
1349
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
@app.command()
|
|
1353
|
+
def struct(
|
|
1354
|
+
path: Path,
|
|
1355
|
+
address: Annotated[
|
|
1356
|
+
list[str] | None,
|
|
1357
|
+
typer.Option(
|
|
1358
|
+
"--address",
|
|
1359
|
+
"-a",
|
|
1360
|
+
help=(
|
|
1361
|
+
"Virtual address of the first instruction (decimal or 0x hex); repeat "
|
|
1362
|
+
f"up to {STRUCT_MAX_WINDOWS} times to merge the windows"
|
|
1363
|
+
),
|
|
1364
|
+
),
|
|
1365
|
+
] = None,
|
|
1366
|
+
function: Annotated[
|
|
1367
|
+
str | None, typer.Option("--function", "-f", help="Function to start the sweep at")
|
|
1368
|
+
] = None,
|
|
1369
|
+
max_instructions: Annotated[
|
|
1370
|
+
int,
|
|
1371
|
+
typer.Option("--max-instructions", help="Maximum instructions to decode"),
|
|
1372
|
+
] = STRUCT_MAX_INSTRUCTIONS,
|
|
1373
|
+
max_fields: Annotated[
|
|
1374
|
+
int,
|
|
1375
|
+
typer.Option("--max-fields", help="Maximum distinct field offsets reported"),
|
|
1376
|
+
] = STRUCT_MAX_FIELDS,
|
|
1377
|
+
include_stack: Annotated[
|
|
1378
|
+
bool,
|
|
1379
|
+
typer.Option(
|
|
1380
|
+
"--include-stack",
|
|
1381
|
+
help=(
|
|
1382
|
+
"Keep \\[rsp]/\\[rbp]-relative operands instead of treating them as "
|
|
1383
|
+
"stack slots"
|
|
1384
|
+
),
|
|
1385
|
+
),
|
|
1386
|
+
] = False,
|
|
1387
|
+
json_output: Annotated[
|
|
1388
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of a table")
|
|
1389
|
+
] = False,
|
|
1390
|
+
) -> None:
|
|
1391
|
+
"""Suggest the struct fields a function accesses through memory operands.
|
|
1392
|
+
|
|
1393
|
+
The start address comes from ``--address``, else ``--function``, else the
|
|
1394
|
+
entry point. A bounded window (``--max-instructions``) is disassembled and
|
|
1395
|
+
every ``\\[reg]`` / ``\\[reg + 0xNN]`` / ``\\[reg - 0xNN]`` operand is
|
|
1396
|
+
aggregated per offset: the dominant access width, the read count and the
|
|
1397
|
+
write count. Only file bytes are read; nothing is mapped or executed.
|
|
1398
|
+
|
|
1399
|
+
``--address`` is repeatable: one address is the single-window sweep above,
|
|
1400
|
+
while two or more addresses are merged by ``suggest_structs`` (type
|
|
1401
|
+
recovery) into one field per offset -- counters summed, widest width
|
|
1402
|
+
winning, earliest access kept -- and the ``alignment``/``array_groups``/
|
|
1403
|
+
``truncated`` facts are recomputed across windows. A merge accepts at most
|
|
1404
|
+
``MAX_MERGED_WINDOWS`` (16) windows, sorts the addresses before decoding,
|
|
1405
|
+
and rejects duplicated ones, so the output is the same for any order of the
|
|
1406
|
+
same address set. ``--function`` names one start, so it is rejected (exit 2)
|
|
1407
|
+
next to *any* ``--address`` -- one address would silently win over it --
|
|
1408
|
+
and ``--max-fields`` is rejected next to a merge, which always keeps its own
|
|
1409
|
+
``DEFAULT_MAX_FIELDS`` (64) cap.
|
|
1410
|
+
|
|
1411
|
+
Fields are a heuristic about how the code reaches memory through one base
|
|
1412
|
+
register, not verified types: signedness, purpose and lifetime stay unknown.
|
|
1413
|
+
``\\[rsp]``/``\\[rbp]``-based operands usually describe stack slots, so they
|
|
1414
|
+
are dropped unless ``--include-stack`` is given, and ``--max-fields`` caps the
|
|
1415
|
+
number of offsets kept (the rest are dropped and reported as truncated).
|
|
1416
|
+
|
|
1417
|
+
``--json`` emits ``StructSuggestion.to_dict()`` unchanged, which already
|
|
1418
|
+
carries the derived ``alignment``, ``array_groups`` and per-field ``signed``
|
|
1419
|
+
hint, plus the merged ``window_count``/``windows`` in the evidence ``extra``.
|
|
1420
|
+
The table adds a footer naming the base registers seen, the field and
|
|
1421
|
+
instruction counts, the truncation flag, the inferred ``alignment``
|
|
1422
|
+
(``unknown`` when fewer than two distinct non-zero offsets were seen), one
|
|
1423
|
+
``array at 0x..`` line per array candidate -- ``arrays: none`` when no run
|
|
1424
|
+
was found -- and one ``signed:`` line listing the offsets whose mnemonics
|
|
1425
|
+
hinted a signed or unsigned access (``none`` when nothing hinted), followed
|
|
1426
|
+
by one ``warning:`` line per limitation. A merged run adds one
|
|
1427
|
+
``windows: N (0x.., 0x..)`` line naming the merged windows, and reads its
|
|
1428
|
+
base registers out of the evidence the merge already produced instead of
|
|
1429
|
+
decoding every window again -- that evidence records none today, so the
|
|
1430
|
+
footer says so. A truncated merge is told to split the windows, because the
|
|
1431
|
+
merged sweep always keeps at most its own field cap. Load and input
|
|
1432
|
+
errors -- unreadable file, bad address, unknown function, address outside an
|
|
1433
|
+
executable section, duplicated address, too many windows, conflicting
|
|
1434
|
+
options -- exit 2.
|
|
1435
|
+
"""
|
|
1436
|
+
starts = tuple(_parse_address(text) for text in address or ())
|
|
1437
|
+
if starts and function is not None:
|
|
1438
|
+
_fail(
|
|
1439
|
+
"--function names one start; give either --function or --address, not both",
|
|
1440
|
+
2,
|
|
1441
|
+
)
|
|
1442
|
+
if len(starts) > 1 and max_fields != STRUCT_MAX_FIELDS:
|
|
1443
|
+
_fail(
|
|
1444
|
+
"--max-fields applies to a single --address; the merged sweep keeps "
|
|
1445
|
+
f"at most {STRUCT_MAX_FIELDS} fields",
|
|
1446
|
+
2,
|
|
1447
|
+
)
|
|
1448
|
+
loaded = _load(path)
|
|
1449
|
+
try:
|
|
1450
|
+
if len(starts) > 1:
|
|
1451
|
+
result = suggest_structs(
|
|
1452
|
+
path,
|
|
1453
|
+
loaded,
|
|
1454
|
+
starts,
|
|
1455
|
+
max_instructions=max_instructions,
|
|
1456
|
+
include_stack=include_stack,
|
|
1457
|
+
)
|
|
1458
|
+
else:
|
|
1459
|
+
start = resolve_target(loaded, starts[0] if starts else None, function)
|
|
1460
|
+
result = suggest_struct(
|
|
1461
|
+
path,
|
|
1462
|
+
loaded,
|
|
1463
|
+
start,
|
|
1464
|
+
max_instructions=max_instructions,
|
|
1465
|
+
max_fields=max_fields,
|
|
1466
|
+
include_stack=include_stack,
|
|
1467
|
+
)
|
|
1468
|
+
except AnalysisError as exc:
|
|
1469
|
+
_fail(str(exc), 2)
|
|
1470
|
+
if json_output:
|
|
1471
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
1472
|
+
return
|
|
1473
|
+
if len(starts) > 1:
|
|
1474
|
+
windows = tuple(sorted(starts))
|
|
1475
|
+
_print_struct_suggestion(
|
|
1476
|
+
result,
|
|
1477
|
+
base_registers=_struct_evidence_base_registers(result),
|
|
1478
|
+
windows=windows,
|
|
1479
|
+
)
|
|
1480
|
+
return
|
|
1481
|
+
_print_struct_suggestion(
|
|
1482
|
+
result,
|
|
1483
|
+
base_registers=_struct_base_registers(
|
|
1484
|
+
path,
|
|
1485
|
+
loaded,
|
|
1486
|
+
start,
|
|
1487
|
+
max_instructions=max_instructions,
|
|
1488
|
+
include_stack=include_stack,
|
|
1489
|
+
),
|
|
1490
|
+
)
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
def _header_fact(line: str, label: str) -> str | None:
|
|
1494
|
+
"""Return the value behind ``label:`` on one header comment line.
|
|
1495
|
+
|
|
1496
|
+
The native renderer states its own block/instruction counts and truncation
|
|
1497
|
+
flag only inside the ``* blocks: 1 instructions: 2 truncated: false`` line
|
|
1498
|
+
of the header comment it writes at the top of the pseudo-C, so the footer
|
|
1499
|
+
reads that line back instead of decoding the function a second time.
|
|
1500
|
+
"""
|
|
1501
|
+
_, separator, rest = line.partition(f"{label}:")
|
|
1502
|
+
if not separator:
|
|
1503
|
+
return None
|
|
1504
|
+
value = rest.split()[0] if rest.split() else ""
|
|
1505
|
+
return value or None
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def _fact_text(value: Any) -> str:
|
|
1509
|
+
"""Render one count fact from a rendering, or ``unknown`` when it stated none."""
|
|
1510
|
+
return "unknown" if value is None else str(value)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _truncation_text(value: Any) -> str:
|
|
1514
|
+
"""Render a truncation flag as ``yes``/``no``, or ``unknown`` when unstated."""
|
|
1515
|
+
if isinstance(value, bool):
|
|
1516
|
+
return "yes" if value else "no"
|
|
1517
|
+
text = "" if value is None else str(value).strip().lower()
|
|
1518
|
+
if text in {"true", "yes", "1"}:
|
|
1519
|
+
return "yes"
|
|
1520
|
+
if text in {"false", "no", "0"}:
|
|
1521
|
+
return "no"
|
|
1522
|
+
return "unknown"
|
|
1523
|
+
|
|
1524
|
+
|
|
1525
|
+
def _rendering_facts(result: BackendResult) -> tuple[str, str, str]:
|
|
1526
|
+
"""Return the ``(blocks, instructions, truncated)`` footer facts of a rendering.
|
|
1527
|
+
|
|
1528
|
+
The backends report these differently: ``angr`` and ``ghidra`` record a block
|
|
1529
|
+
count (and the requested budgets) in the evidence ``extra`` map, while the
|
|
1530
|
+
native renderer only prints them in its header comment, which
|
|
1531
|
+
:func:`_header_fact` reads. A fact no backend stated stays ``unknown`` rather
|
|
1532
|
+
than guessed, and a truncated rendering is never reported as whole.
|
|
1533
|
+
"""
|
|
1534
|
+
extra = result.evidence.extra
|
|
1535
|
+
blocks = extra.get("blocks", extra.get("block_count"))
|
|
1536
|
+
instructions = extra.get("instructions")
|
|
1537
|
+
truncated = extra.get("truncated")
|
|
1538
|
+
if blocks is None or instructions is None or truncated is None:
|
|
1539
|
+
header = next(
|
|
1540
|
+
(
|
|
1541
|
+
line
|
|
1542
|
+
for line in result.text.splitlines()
|
|
1543
|
+
if "blocks:" in line and "instructions:" in line and "truncated:" in line
|
|
1544
|
+
),
|
|
1545
|
+
"",
|
|
1546
|
+
)
|
|
1547
|
+
if blocks is None:
|
|
1548
|
+
blocks = _header_fact(header, "blocks")
|
|
1549
|
+
if instructions is None:
|
|
1550
|
+
instructions = _header_fact(header, "instructions")
|
|
1551
|
+
if truncated is None:
|
|
1552
|
+
truncated = _header_fact(header, "truncated")
|
|
1553
|
+
return _fact_text(blocks), _fact_text(instructions), _truncation_text(truncated)
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def _print_decompilation(result: BackendResult, start: int) -> None:
|
|
1557
|
+
"""Print one rendering as a Panel, then a dim footer and one line per warning.
|
|
1558
|
+
|
|
1559
|
+
The body is wrapped in a :class:`~rich.text.Text` instead of being printed as
|
|
1560
|
+
markup-bearing text, so the pseudo-C is shown byte for byte: a ``[`` or a
|
|
1561
|
+
``/* ... */`` comment in the rendering is never interpreted by Rich. The
|
|
1562
|
+
footer repeats the counts the backend stated, and every warning keeps its own
|
|
1563
|
+
line so a fallback or an unresolved indirect branch is never hidden.
|
|
1564
|
+
"""
|
|
1565
|
+
console.print(
|
|
1566
|
+
Panel(
|
|
1567
|
+
Text(result.text),
|
|
1568
|
+
title=escape(f"Decompilation at 0x{start:x}"),
|
|
1569
|
+
subtitle=escape(f"backend: {result.backend} · language: {result.language}"),
|
|
1570
|
+
)
|
|
1571
|
+
)
|
|
1572
|
+
blocks, instructions, truncated = _rendering_facts(result)
|
|
1573
|
+
console.print(
|
|
1574
|
+
f"[dim]blocks: {blocks} · instructions: {instructions} · truncated: {truncated}[/]"
|
|
1575
|
+
)
|
|
1576
|
+
for warning in result.warnings:
|
|
1577
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
@app.command()
|
|
1581
|
+
def decompile(
|
|
1582
|
+
path: Path,
|
|
1583
|
+
address: Annotated[
|
|
1584
|
+
str | None,
|
|
1585
|
+
typer.Argument(
|
|
1586
|
+
help="Virtual address of the function (decimal or 0x hex); defaults to the entry point"
|
|
1587
|
+
),
|
|
1588
|
+
] = None,
|
|
1589
|
+
backend: Annotated[
|
|
1590
|
+
str,
|
|
1591
|
+
typer.Option(
|
|
1592
|
+
"--backend",
|
|
1593
|
+
help=(
|
|
1594
|
+
"Decompiler backend: 'auto' (default: first available external provider, "
|
|
1595
|
+
"else native), 'native', 'angr' or 'ghidra'"
|
|
1596
|
+
),
|
|
1597
|
+
),
|
|
1598
|
+
] = "auto",
|
|
1599
|
+
function: Annotated[
|
|
1600
|
+
str | None, typer.Option("--function", "-f", help="Function name to decompile")
|
|
1601
|
+
] = None,
|
|
1602
|
+
max_instructions: Annotated[
|
|
1603
|
+
int, typer.Option("--max-instructions", help="Maximum instructions to render")
|
|
1604
|
+
] = DECOMPILE_MAX_INSTRUCTIONS,
|
|
1605
|
+
max_blocks: Annotated[
|
|
1606
|
+
int, typer.Option("--max-blocks", help="Maximum basic blocks to render")
|
|
1607
|
+
] = DECOMPILE_MAX_BLOCKS,
|
|
1608
|
+
json_output: Annotated[
|
|
1609
|
+
bool, typer.Option("--json", help="Emit the JSON rendering instead of a panel")
|
|
1610
|
+
] = False,
|
|
1611
|
+
output: Annotated[
|
|
1612
|
+
Path | None,
|
|
1613
|
+
typer.Option(
|
|
1614
|
+
"--output", "-o", help="Write the rendering here (the JSON payload with --json)"
|
|
1615
|
+
),
|
|
1616
|
+
] = None,
|
|
1617
|
+
) -> None:
|
|
1618
|
+
"""Decompile one function to readable pseudo-C.
|
|
1619
|
+
|
|
1620
|
+
The function comes from ADDRESS, else ``--function``, else the entry point,
|
|
1621
|
+
resolved exactly like ``codevanta disasm`` does -- an address therefore wins
|
|
1622
|
+
over ``--function`` when both are given. The target binary is only ever read:
|
|
1623
|
+
the rendering is static text, and nothing is mapped or executed.
|
|
1624
|
+
|
|
1625
|
+
``--backend`` selects the provider. ``auto`` (the default) prefers the first
|
|
1626
|
+
available external backend (``ghidra``, then ``angr``) and falls back to the
|
|
1627
|
+
built-in heuristic renderer, recording one warning per skipped backend so a
|
|
1628
|
+
fallback is never silent. ``native`` always works; ``angr`` and ``ghidra``
|
|
1629
|
+
are strict and exit 2 with the probe's reason -- install hint included --
|
|
1630
|
+
instead of substituting another provider. A missing optional dependency never
|
|
1631
|
+
surfaces as ``ImportError``.
|
|
1632
|
+
|
|
1633
|
+
``--max-instructions`` and ``--max-blocks`` bound the work; an out-of-range
|
|
1634
|
+
budget is rejected before any backend runs.
|
|
1635
|
+
|
|
1636
|
+
The panel shows the pseudo-C body verbatim (no markup is interpreted) with the
|
|
1637
|
+
backend that produced it and the source language, followed by a dim
|
|
1638
|
+
``blocks``/``instructions``/``truncated`` footer and one ``warning:`` line per
|
|
1639
|
+
limitation: budget cuts, unresolved indirect branches, skipped backends.
|
|
1640
|
+
``--json`` prints ``BackendResult.to_dict()`` unchanged instead, and
|
|
1641
|
+
``--output`` writes the rendering -- the JSON payload when ``--json`` is given
|
|
1642
|
+
-- to a file. Load errors, a bad address, an unknown function, an unknown
|
|
1643
|
+
backend and an unavailable backend all exit 2.
|
|
1644
|
+
"""
|
|
1645
|
+
requested = None if address is None else _parse_address(address)
|
|
1646
|
+
loaded = _load(path)
|
|
1647
|
+
try:
|
|
1648
|
+
start = resolve_target(loaded, requested, function)
|
|
1649
|
+
result = decompile_function(
|
|
1650
|
+
path,
|
|
1651
|
+
loaded,
|
|
1652
|
+
start,
|
|
1653
|
+
backend=backend,
|
|
1654
|
+
max_instructions=max_instructions,
|
|
1655
|
+
max_blocks=max_blocks,
|
|
1656
|
+
)
|
|
1657
|
+
except AnalysisError as exc:
|
|
1658
|
+
_fail(str(exc), 2)
|
|
1659
|
+
payload = result.to_dict()
|
|
1660
|
+
if output is not None:
|
|
1661
|
+
written = json.dumps(payload) if json_output else result.text
|
|
1662
|
+
output.write_text(written if written.endswith("\n") else f"{written}\n", encoding="utf-8")
|
|
1663
|
+
console.print(f"[green]rendering written:[/] {escape(str(output))}")
|
|
1664
|
+
if json_output:
|
|
1665
|
+
console.print_json(json.dumps(payload))
|
|
1666
|
+
return
|
|
1667
|
+
_print_decompilation(result, start)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _parse_register_assignment(text: str) -> tuple[str, int]:
|
|
1671
|
+
"""Parse one ``REG=VALUE`` register seed given through ``--set``.
|
|
1672
|
+
|
|
1673
|
+
The name is passed through untouched -- the emulation layer owns the valid
|
|
1674
|
+
register set, so a typo is reported by its own message -- while the value
|
|
1675
|
+
accepts decimal and ``0x``-prefixed hex. A missing ``=``, an empty name or
|
|
1676
|
+
value, and a negative value all exit 2 rather than seeding a wrong state.
|
|
1677
|
+
"""
|
|
1678
|
+
name, separator, value = text.partition("=")
|
|
1679
|
+
name = name.strip()
|
|
1680
|
+
value = value.strip()
|
|
1681
|
+
if not separator or not name or not value:
|
|
1682
|
+
_fail(f"invalid --set {text!r}; use REG=VALUE, e.g. --set rdi=2", 2)
|
|
1683
|
+
try:
|
|
1684
|
+
parsed = int(value, 0)
|
|
1685
|
+
except ValueError:
|
|
1686
|
+
_fail(f"invalid --set value {value!r}; use decimal or 0x-prefixed hex", 2)
|
|
1687
|
+
if parsed < 0:
|
|
1688
|
+
_fail(f"invalid --set value {value!r}; register values must not be negative", 2)
|
|
1689
|
+
return name, parsed
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def _parse_memory_block(text: str) -> tuple[int, bytes]:
|
|
1693
|
+
"""Parse one ``ADDRESS=HEXBYTES`` memory block given through ``--mem``.
|
|
1694
|
+
|
|
1695
|
+
The address side accepts decimal and ``0x``-prefixed hex, the byte side an
|
|
1696
|
+
even number of hex digits (``--mem 0x600000=48454c4c4f``). The block is
|
|
1697
|
+
placed in emulator memory before the run, which is how a decrypted or
|
|
1698
|
+
synthetic input reaches the emulated code without touching the host.
|
|
1699
|
+
"""
|
|
1700
|
+
address_text, separator, hex_text = text.partition("=")
|
|
1701
|
+
if not separator:
|
|
1702
|
+
_fail(f"invalid --mem {text!r}; use ADDRESS=HEXBYTES, e.g. --mem 0x600000=deadbeef", 2)
|
|
1703
|
+
block_address = _parse_address(address_text.strip(), label="--mem address")
|
|
1704
|
+
digits = "".join(hex_text.split())
|
|
1705
|
+
if not digits:
|
|
1706
|
+
_fail(f"invalid --mem {text!r}; the byte side is empty", 2)
|
|
1707
|
+
try:
|
|
1708
|
+
data = bytes.fromhex(digits)
|
|
1709
|
+
except ValueError as exc:
|
|
1710
|
+
_fail(f"invalid --mem bytes {digits!r}; use an even number of hex digits ({exc})", 2)
|
|
1711
|
+
return block_address, data
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def _register_line(registers: Mapping[str, int]) -> str:
|
|
1715
|
+
"""Render the non-zero registers as ``rax=0x2a rbp=0x7fff...``.
|
|
1716
|
+
|
|
1717
|
+
Zeroed registers are dropped because the ABI defaults zero most of them, so
|
|
1718
|
+
the ones that survived the run are the interesting ones. ``none`` is printed
|
|
1719
|
+
when every register is zero, so the line never disappears.
|
|
1720
|
+
"""
|
|
1721
|
+
live = [f"{name}=0x{value:x}" for name, value in sorted(registers.items()) if value]
|
|
1722
|
+
return " ".join(live) if live else "none"
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def _value_text(value: int | None) -> str:
|
|
1726
|
+
"""Render a machine value as ``0x2a (42)``, or ``none`` when absent."""
|
|
1727
|
+
return "none" if value is None else f"0x{value:x} ({value})"
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def _print_emulation(result: emulator.EmulationResult) -> None:
|
|
1731
|
+
"""Print one run as a fact table, then the live registers and the warnings.
|
|
1732
|
+
|
|
1733
|
+
The table carries exactly what a caller has to read off a bounded run: why it
|
|
1734
|
+
stopped, how many instructions completed, the return value (``none`` unless
|
|
1735
|
+
the stop reason is ``return``, because a value left in ``rax`` by an
|
|
1736
|
+
interrupted run is not a return value) and the resolved indirect targets. The
|
|
1737
|
+
dim line under it lists the non-zero registers, and every warning keeps its
|
|
1738
|
+
own line so a budget cut or a faulting address is never hidden.
|
|
1739
|
+
"""
|
|
1740
|
+
table = Table(title=escape(f"Emulation at 0x{result.address:x}"))
|
|
1741
|
+
table.add_column("Fact")
|
|
1742
|
+
table.add_column("Value")
|
|
1743
|
+
targets = ", ".join(f"0x{target:x}" for target in result.indirect_targets) or "none"
|
|
1744
|
+
rows = (
|
|
1745
|
+
("stopped reason", result.stopped_reason),
|
|
1746
|
+
("instructions", str(result.instructions)),
|
|
1747
|
+
("return value", _value_text(result.return_value)),
|
|
1748
|
+
("indirect targets", targets),
|
|
1749
|
+
)
|
|
1750
|
+
for label, value in rows:
|
|
1751
|
+
table.add_row(label, escape(str(value)))
|
|
1752
|
+
console.print(table)
|
|
1753
|
+
console.print(f"[dim]registers: {escape(_register_line(result.registers))}[/]")
|
|
1754
|
+
for warning in result.warnings:
|
|
1755
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
@app.command()
|
|
1759
|
+
def emulate(
|
|
1760
|
+
path: Path,
|
|
1761
|
+
address: Annotated[
|
|
1762
|
+
str | None,
|
|
1763
|
+
typer.Argument(
|
|
1764
|
+
help="Virtual address to start at (decimal or 0x hex); defaults to the entry point"
|
|
1765
|
+
),
|
|
1766
|
+
] = None,
|
|
1767
|
+
max_instructions: Annotated[
|
|
1768
|
+
int, typer.Option("--max-instructions", help="Instruction budget for one run")
|
|
1769
|
+
] = emulator.DEFAULT_MAX_INSTRUCTIONS,
|
|
1770
|
+
stack_size: Annotated[
|
|
1771
|
+
int, typer.Option("--stack-size", help="Bytes of stack to map for the run")
|
|
1772
|
+
] = emulator.DEFAULT_STACK_SIZE,
|
|
1773
|
+
set_values: Annotated[
|
|
1774
|
+
list[str] | None,
|
|
1775
|
+
typer.Option(
|
|
1776
|
+
"--set", help="Seed a register, e.g. --set rdi=2 (decimal or 0x hex); repeatable"
|
|
1777
|
+
),
|
|
1778
|
+
] = None,
|
|
1779
|
+
memory: Annotated[
|
|
1780
|
+
list[str] | None,
|
|
1781
|
+
typer.Option(
|
|
1782
|
+
"--mem", help="Place a memory block, e.g. --mem 0x600000=deadbeef; repeatable"
|
|
1783
|
+
),
|
|
1784
|
+
] = None,
|
|
1785
|
+
stop_at_indirect: Annotated[
|
|
1786
|
+
bool,
|
|
1787
|
+
typer.Option(
|
|
1788
|
+
"--resolve-target",
|
|
1789
|
+
help="Stop at the first indirect branch and report its target instead",
|
|
1790
|
+
),
|
|
1791
|
+
] = False,
|
|
1792
|
+
json_output: Annotated[
|
|
1793
|
+
bool, typer.Option("--json", help="Emit the JSON result instead of a table")
|
|
1794
|
+
] = False,
|
|
1795
|
+
) -> None:
|
|
1796
|
+
"""Emulate one function offline and report what it returns.
|
|
1797
|
+
|
|
1798
|
+
The run starts at ADDRESS, else the entry point, on a *private* virtual CPU:
|
|
1799
|
+
only the file's bytes are copied into emulator memory, so nothing is ever
|
|
1800
|
+
executed on the host, and ``--max-instructions`` bounds the work. Registers
|
|
1801
|
+
start at the ABI defaults (instruction and stack pointers seeded, argument
|
|
1802
|
+
registers left alone), which is why inputs are seeded explicitly with
|
|
1803
|
+
``--set REG=VALUE`` (repeatable, decimal or ``0x`` hex) and
|
|
1804
|
+
``--mem ADDRESS=HEXBYTES`` (repeatable blocks placed before the run, which is
|
|
1805
|
+
how an encrypted or synthetic input reaches the emulated code).
|
|
1806
|
+
|
|
1807
|
+
The table reports why the run stopped, how many instructions completed, the
|
|
1808
|
+
return value and the indirect targets it resolved; the dim line under it
|
|
1809
|
+
lists the non-zero registers, and every degradation (budget exhausted,
|
|
1810
|
+
unmapped access, unresolved indirect branch) keeps its own ``warning:`` line.
|
|
1811
|
+
A partial answer is never dressed up as a complete one: a value read from
|
|
1812
|
+
``rax`` after a budget stop is reported as a register, not as a return value.
|
|
1813
|
+
``--json`` prints ``EmulationResult.to_dict()`` unchanged, which swaps the
|
|
1814
|
+
table for the full register snapshot, the recorded memory writes and the
|
|
1815
|
+
evidence.
|
|
1816
|
+
|
|
1817
|
+
``--resolve-target`` answers the static-analysis question instead: it runs
|
|
1818
|
+
until the first indirect jump/call and reports that target, so the payload is
|
|
1819
|
+
``address``/``mode``/``resolved``/``indirect_target`` plus the budget rather
|
|
1820
|
+
than an emulation result (there is none). ``--stack-size`` only affects a full
|
|
1821
|
+
emulation, so a non-default value is called out when ``--resolve-target`` is
|
|
1822
|
+
used.
|
|
1823
|
+
|
|
1824
|
+
Emulation needs the optional ``unicorn`` engine; when it is missing this exits
|
|
1825
|
+
2 with :func:`codevanta.analysis.emulate.emulator_status` as the reason and
|
|
1826
|
+
never raises ``ImportError``. Malformed seeds, an unknown register, an address
|
|
1827
|
+
outside an executable section and out-of-range limits also exit 2.
|
|
1828
|
+
"""
|
|
1829
|
+
if not emulator.EMULATOR_AVAILABLE:
|
|
1830
|
+
_fail(
|
|
1831
|
+
"emulation is unavailable: "
|
|
1832
|
+
f"{emulator.emulator_status() or 'the unicorn engine could not be imported'}; "
|
|
1833
|
+
"codevanta.analysis.emulate.emulator_status() reports the exact probe result -- "
|
|
1834
|
+
"install it with 'pip install unicorn' and retry",
|
|
1835
|
+
2,
|
|
1836
|
+
)
|
|
1837
|
+
registers = dict(_parse_register_assignment(text) for text in set_values or ())
|
|
1838
|
+
blocks: dict[int, bytes] = {}
|
|
1839
|
+
for text in memory or ():
|
|
1840
|
+
block_address, data = _parse_memory_block(text)
|
|
1841
|
+
blocks[block_address] = data
|
|
1842
|
+
requested = None if address is None else _parse_address(address)
|
|
1843
|
+
loaded = _load(path)
|
|
1844
|
+
try:
|
|
1845
|
+
start = resolve_target(loaded, requested, None)
|
|
1846
|
+
if stop_at_indirect:
|
|
1847
|
+
target = emulator.resolve_indirect_target(
|
|
1848
|
+
path,
|
|
1849
|
+
loaded,
|
|
1850
|
+
start,
|
|
1851
|
+
registers,
|
|
1852
|
+
max_instructions=max_instructions,
|
|
1853
|
+
initial_memory=blocks,
|
|
1854
|
+
)
|
|
1855
|
+
else:
|
|
1856
|
+
result = emulator.emulate_function(
|
|
1857
|
+
path,
|
|
1858
|
+
loaded,
|
|
1859
|
+
start,
|
|
1860
|
+
initial_registers=registers,
|
|
1861
|
+
initial_memory=blocks,
|
|
1862
|
+
max_instructions=max_instructions,
|
|
1863
|
+
stack_size=stack_size,
|
|
1864
|
+
)
|
|
1865
|
+
except AnalysisError as exc:
|
|
1866
|
+
_fail(str(exc), 2)
|
|
1867
|
+
if stop_at_indirect:
|
|
1868
|
+
if json_output:
|
|
1869
|
+
console.print_json(
|
|
1870
|
+
json.dumps(
|
|
1871
|
+
{
|
|
1872
|
+
"address": f"0x{start:x}",
|
|
1873
|
+
"mode": "resolve_indirect_target",
|
|
1874
|
+
"resolved": target is not None,
|
|
1875
|
+
"indirect_target": None if target is None else f"0x{target:x}",
|
|
1876
|
+
"max_instructions": max_instructions,
|
|
1877
|
+
}
|
|
1878
|
+
)
|
|
1879
|
+
)
|
|
1880
|
+
return
|
|
1881
|
+
if target is None:
|
|
1882
|
+
console.print("[yellow]indirect target: unresolved[/]")
|
|
1883
|
+
console.print(
|
|
1884
|
+
"[dim]no indirect branch was reached before the run stopped, or its operand "
|
|
1885
|
+
"could not be evaluated; that means unresolved, never 'no target'[/]"
|
|
1886
|
+
)
|
|
1887
|
+
else:
|
|
1888
|
+
console.print(f"[green]indirect target:[/] {_value_text(target)}")
|
|
1889
|
+
if stack_size != emulator.DEFAULT_STACK_SIZE:
|
|
1890
|
+
console.print(
|
|
1891
|
+
"[dim]note: --stack-size applies to a full emulation, not to --resolve-target[/]"
|
|
1892
|
+
)
|
|
1893
|
+
return
|
|
1894
|
+
if json_output:
|
|
1895
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
1896
|
+
return
|
|
1897
|
+
_print_emulation(result)
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
#: Output formats accepted by ``codevanta callgraph``.
|
|
1901
|
+
CallGraphFormat = Literal["json", "dot", "graphml", "html"]
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
def _render_call_graph(graph: CallGraph, output_format: CallGraphFormat, path: Path) -> str:
|
|
1905
|
+
"""Render ``graph`` as JSON, DOT, GraphML or HTML text.
|
|
1906
|
+
|
|
1907
|
+
``json`` serializes the analysis payload itself (:meth:`CallGraph.to_dict`);
|
|
1908
|
+
``dot``, ``graphml`` and ``html`` render the generic graph model so the shared
|
|
1909
|
+
exporters in :mod:`codevanta.analysis.graph_export` and the viewer in
|
|
1910
|
+
:mod:`codevanta.analysis.html_view` stay the only serializers.
|
|
1911
|
+
|
|
1912
|
+
Every serializer is given size-derived caps (``max(DEFAULT, len(...))``) so a
|
|
1913
|
+
graph the analysis already accepted is never rejected downstream for being
|
|
1914
|
+
larger than the exporter default.
|
|
1915
|
+
"""
|
|
1916
|
+
if output_format in ("dot", "graphml", "html"):
|
|
1917
|
+
model = graph.to_graph_model()
|
|
1918
|
+
title = f"callgraph {path.name}"
|
|
1919
|
+
max_nodes = max(EXPORT_MAX_NODES, len(model["nodes"]))
|
|
1920
|
+
max_edges = max(EXPORT_MAX_EDGES, len(model["edges"]))
|
|
1921
|
+
if output_format == "dot":
|
|
1922
|
+
return to_dot(model, title=title, max_nodes=max_nodes, max_edges=max_edges)
|
|
1923
|
+
if output_format == "graphml":
|
|
1924
|
+
return to_graphml(model, title=title, max_nodes=max_nodes, max_edges=max_edges)
|
|
1925
|
+
return to_html(model, title=title, max_nodes=max_nodes, max_edges=max_edges)
|
|
1926
|
+
return json.dumps(graph.to_dict(), indent=2)
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
@app.command()
|
|
1930
|
+
def callgraph(
|
|
1931
|
+
path: Path,
|
|
1932
|
+
limit_functions: Annotated[
|
|
1933
|
+
int,
|
|
1934
|
+
typer.Option("--limit-functions", help="Maximum function candidates kept as nodes"),
|
|
1935
|
+
] = DEFAULT_MAX_FUNCTIONS,
|
|
1936
|
+
limit_edges: Annotated[
|
|
1937
|
+
int, typer.Option("--limit-edges", help="Maximum call edges reported")
|
|
1938
|
+
] = DEFAULT_MAX_EDGES,
|
|
1939
|
+
output_format: Annotated[
|
|
1940
|
+
CallGraphFormat,
|
|
1941
|
+
typer.Option(
|
|
1942
|
+
"--format",
|
|
1943
|
+
help=(
|
|
1944
|
+
"Output format: json (analysis payload), dot, graphml or html "
|
|
1945
|
+
"(self-contained SVG viewer)"
|
|
1946
|
+
),
|
|
1947
|
+
),
|
|
1948
|
+
] = "json",
|
|
1949
|
+
output: Annotated[
|
|
1950
|
+
Path | None,
|
|
1951
|
+
typer.Option("--output", "-o", help="Write the graph to this path instead of stdout"),
|
|
1952
|
+
] = None,
|
|
1953
|
+
) -> None:
|
|
1954
|
+
"""Build the direct-call call graph and emit it as JSON, DOT, GraphML or HTML."""
|
|
1955
|
+
loaded = _load(path)
|
|
1956
|
+
try:
|
|
1957
|
+
graph = build_call_graph(
|
|
1958
|
+
path,
|
|
1959
|
+
loaded,
|
|
1960
|
+
functions=discover_functions(loaded),
|
|
1961
|
+
max_functions=limit_functions,
|
|
1962
|
+
max_edges=limit_edges,
|
|
1963
|
+
)
|
|
1964
|
+
payload = _render_call_graph(graph, output_format, path)
|
|
1965
|
+
except (AnalysisError, GraphExportError) as exc:
|
|
1966
|
+
_fail(str(exc), 2)
|
|
1967
|
+
if output is not None:
|
|
1968
|
+
try:
|
|
1969
|
+
output.write_text(payload, encoding="utf-8", newline="\n")
|
|
1970
|
+
except (OSError, UnicodeError) as exc:
|
|
1971
|
+
_fail(f"cannot write {output}: {exc}", 2)
|
|
1972
|
+
console.print(f"[green]graph written:[/] {escape(str(output))}")
|
|
1973
|
+
else:
|
|
1974
|
+
# markup=False keeps DOT/GraphML brackets intact; soft_wrap avoids
|
|
1975
|
+
# inserting line breaks that would corrupt the serialized payload.
|
|
1976
|
+
console.print(payload, markup=False, soft_wrap=True)
|
|
1977
|
+
console.print(
|
|
1978
|
+
f"[dim]nodes: {len(graph.nodes)} · edges: {len(graph.edges)}"
|
|
1979
|
+
f" · truncated: {'yes' if graph.truncated else 'no'}"
|
|
1980
|
+
f" · format: {escape(output_format)}[/]"
|
|
1981
|
+
)
|
|
1982
|
+
for warning in graph.warnings:
|
|
1983
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
1984
|
+
|
|
1985
|
+
|
|
1986
|
+
# --------------------------------------------------------------------------- #
|
|
1987
|
+
# Dynamic sessions: replay a saved trace, generate an attach script
|
|
1988
|
+
# --------------------------------------------------------------------------- #
|
|
1989
|
+
|
|
1990
|
+
#: Providers ``codevanta trace`` accepts; ``auto`` reads the provider off the file.
|
|
1991
|
+
TraceProvider = Literal["gdb", "frida", "auto"]
|
|
1992
|
+
|
|
1993
|
+
#: Providers ``codevanta attach`` generates a session script for.
|
|
1994
|
+
AttachProvider = Literal["gdb", "frida"]
|
|
1995
|
+
|
|
1996
|
+
#: Lines ``--provider auto`` reads before it gives up on detecting the provider.
|
|
1997
|
+
_DETECT_LINES = 200
|
|
1998
|
+
|
|
1999
|
+
#: First-line markers the generated gdb script prints, in the order they appear.
|
|
2000
|
+
_GDB_TRACE_MARKERS = ("HIT ", "BACKTRACE", "LIMIT ", "DONE ")
|
|
2001
|
+
|
|
2002
|
+
|
|
2003
|
+
def _detect_trace_provider(text: str) -> str | None:
|
|
2004
|
+
"""Return which provider wrote ``text``, or ``None`` when nothing matches.
|
|
2005
|
+
|
|
2006
|
+
Detection reads the file shape, not a flag: a line that parses as a JSON
|
|
2007
|
+
object is one of the messages the Frida hook sends, and a line opening with
|
|
2008
|
+
``HIT``/``BACKTRACE``/``LIMIT``/``DONE`` is one the gdb script prints. Only
|
|
2009
|
+
the first :data:`_DETECT_LINES` non-blank lines are inspected, so a large
|
|
2010
|
+
trace costs no more than a small one, and an unrecognisable file returns
|
|
2011
|
+
``None`` instead of being guessed onto whichever provider has more markers.
|
|
2012
|
+
"""
|
|
2013
|
+
for line in text.splitlines()[:_DETECT_LINES]:
|
|
2014
|
+
stripped = line.strip()
|
|
2015
|
+
if not stripped:
|
|
2016
|
+
continue
|
|
2017
|
+
if stripped.startswith("{"):
|
|
2018
|
+
try:
|
|
2019
|
+
message = json.loads(stripped)
|
|
2020
|
+
except ValueError:
|
|
2021
|
+
continue
|
|
2022
|
+
if isinstance(message, dict):
|
|
2023
|
+
return "frida"
|
|
2024
|
+
continue
|
|
2025
|
+
if stripped.startswith(_GDB_TRACE_MARKERS):
|
|
2026
|
+
return "gdb"
|
|
2027
|
+
return None
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
def _print_trace_report(report: TraceReport, *, auto_detected: bool) -> None:
|
|
2031
|
+
"""Render a parsed trace as a per-function table, its totals and its warnings."""
|
|
2032
|
+
names: dict[int, str] = {}
|
|
2033
|
+
for hit in report.hits:
|
|
2034
|
+
if hit.function is not None:
|
|
2035
|
+
names.setdefault(hit.function, hit.function_name or "-")
|
|
2036
|
+
unattributed = sum(1 for hit in report.hits if hit.function is None)
|
|
2037
|
+
provider = f"{report.provider} (auto-detected)" if auto_detected else report.provider
|
|
2038
|
+
if report.function_coverage:
|
|
2039
|
+
table = Table(title=f"Trace hits by function ({len(report.function_coverage)})")
|
|
2040
|
+
table.add_column("Function")
|
|
2041
|
+
table.add_column("Address", justify="right")
|
|
2042
|
+
table.add_column("Hits", justify="right")
|
|
2043
|
+
for address, count in sorted(report.function_coverage.items()):
|
|
2044
|
+
table.add_row(escape(names.get(address, "-")), f"0x{address:x}", str(count))
|
|
2045
|
+
console.print(table)
|
|
2046
|
+
else:
|
|
2047
|
+
console.print("[yellow]no hit landed inside a discovered function[/]")
|
|
2048
|
+
console.print(
|
|
2049
|
+
f"[dim]provider: {escape(provider)} · hits: {report.total_hits}"
|
|
2050
|
+
f" · kept: {len(report.hits)} · unique addresses: {len(report.unique_addresses)}"
|
|
2051
|
+
f" · functions hit: {len(report.function_coverage)} · unattributed: {unattributed}"
|
|
2052
|
+
f" · truncated: {'yes' if report.truncated else 'no'}[/]"
|
|
2053
|
+
)
|
|
2054
|
+
if report.provider == "frida":
|
|
2055
|
+
console.print(
|
|
2056
|
+
"[dim]frida reports runtime addresses, so a hit is attributed to a function "
|
|
2057
|
+
"only when the image was not relocated[/]"
|
|
2058
|
+
)
|
|
2059
|
+
for warning in report.warnings:
|
|
2060
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
2061
|
+
|
|
2062
|
+
|
|
2063
|
+
@app.command()
|
|
2064
|
+
def trace(
|
|
2065
|
+
path: Annotated[Path, typer.Argument(help="Path to the binary the session ran against")],
|
|
2066
|
+
trace_file: Annotated[
|
|
2067
|
+
Path,
|
|
2068
|
+
typer.Option(
|
|
2069
|
+
"--input", help="Saved session trace to parse (gdb output or frida JSON lines)"
|
|
2070
|
+
),
|
|
2071
|
+
],
|
|
2072
|
+
provider: Annotated[
|
|
2073
|
+
TraceProvider,
|
|
2074
|
+
typer.Option(
|
|
2075
|
+
"--provider", help="Trace format: gdb, frida, or auto to detect it from the file"
|
|
2076
|
+
),
|
|
2077
|
+
] = "auto",
|
|
2078
|
+
json_output: Annotated[
|
|
2079
|
+
bool, typer.Option("--json", help="Emit TraceReport.to_dict() instead of a table")
|
|
2080
|
+
] = False,
|
|
2081
|
+
) -> None:
|
|
2082
|
+
"""Replay a saved session trace and map its hits onto function candidates.
|
|
2083
|
+
|
|
2084
|
+
Nothing here starts a debugger: ``--input`` is what a generated session
|
|
2085
|
+
script already printed, so a run can be re-read offline as often as needed.
|
|
2086
|
+
``--provider auto`` reads the provider off the file shape -- a JSON object
|
|
2087
|
+
line is a Frida message, a ``HIT``/``BACKTRACE``/``LIMIT``/``DONE`` line is
|
|
2088
|
+
gdb -- and the render states which one it used; a file it cannot place exits
|
|
2089
|
+
2 instead of being guessed onto a provider.
|
|
2090
|
+
|
|
2091
|
+
The table has one row per function that was hit: its name (``-`` when the
|
|
2092
|
+
candidate is unnamed), its start address, and how many hits landed inside it.
|
|
2093
|
+
The dim line under the table carries the totals -- hits seen, hits kept,
|
|
2094
|
+
unique addresses, functions hit, hits that fell outside every candidate, and
|
|
2095
|
+
whether the parse was cut -- and every dropped or malformed line keeps its
|
|
2096
|
+
own ``warning:`` line. ``--json`` prints ``TraceReport.to_dict()`` unchanged.
|
|
2097
|
+
"""
|
|
2098
|
+
try:
|
|
2099
|
+
text = trace_file.read_text(encoding="utf-8", errors="replace")
|
|
2100
|
+
except OSError as exc:
|
|
2101
|
+
_fail(f"cannot read {trace_file}: {exc}", 2)
|
|
2102
|
+
auto_detected = provider == "auto"
|
|
2103
|
+
chosen = _detect_trace_provider(text) if auto_detected else provider
|
|
2104
|
+
if chosen is None:
|
|
2105
|
+
_fail(
|
|
2106
|
+
f"cannot tell whether {str(trace_file)!r} is a gdb or a frida trace; "
|
|
2107
|
+
"pass --provider gdb or --provider frida",
|
|
2108
|
+
2,
|
|
2109
|
+
)
|
|
2110
|
+
loaded = _load(path)
|
|
2111
|
+
functions = discover_functions(loaded)
|
|
2112
|
+
try:
|
|
2113
|
+
if chosen == "gdb":
|
|
2114
|
+
report = parse_gdb_trace(text, info=loaded, functions=functions)
|
|
2115
|
+
else:
|
|
2116
|
+
report = parse_frida_trace(text, info=loaded, functions=functions)
|
|
2117
|
+
except AnalysisError as exc:
|
|
2118
|
+
_fail(str(exc), 2)
|
|
2119
|
+
if json_output:
|
|
2120
|
+
console.print_json(json.dumps(report.to_dict()))
|
|
2121
|
+
return
|
|
2122
|
+
_print_trace_report(report, auto_detected=auto_detected)
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
@app.command()
|
|
2126
|
+
def attach(
|
|
2127
|
+
path: Annotated[Path, typer.Argument(help="Path to the binary to instrument")],
|
|
2128
|
+
addresses: Annotated[
|
|
2129
|
+
list[str] | None,
|
|
2130
|
+
typer.Argument(
|
|
2131
|
+
help="Breakpoint/hook addresses (decimal or 0x hex); defaults to the entry point"
|
|
2132
|
+
),
|
|
2133
|
+
] = None,
|
|
2134
|
+
provider: Annotated[
|
|
2135
|
+
AttachProvider,
|
|
2136
|
+
typer.Option("--provider", help="Provider to generate a session script for: gdb or frida"),
|
|
2137
|
+
] = "gdb",
|
|
2138
|
+
max_hits: Annotated[
|
|
2139
|
+
int, typer.Option("--max-hits", help="Stop the session after this many hits")
|
|
2140
|
+
] = dynamic.DEFAULT_MAX_HITS,
|
|
2141
|
+
write: Annotated[
|
|
2142
|
+
Path | None,
|
|
2143
|
+
typer.Option("--write", help="Write the script into this directory (created if missing)"),
|
|
2144
|
+
] = None,
|
|
2145
|
+
json_output: Annotated[
|
|
2146
|
+
bool, typer.Option("--json", help="Emit the script, its path and its command as JSON")
|
|
2147
|
+
] = False,
|
|
2148
|
+
) -> None:
|
|
2149
|
+
"""Generate a session script and the exact command that runs it -- never run it.
|
|
2150
|
+
|
|
2151
|
+
The script comes from :func:`codevanta.analysis.dynamic.build_gdb_script` or
|
|
2152
|
+
:func:`codevanta.analysis.dynamic.build_frida_script`, and the command from
|
|
2153
|
+
:func:`codevanta.analysis.dynamic.attach_command`, so neither is spelled out
|
|
2154
|
+
a second time here. ADDRESS is repeatable and defaults to the entry point;
|
|
2155
|
+
``--max-hits`` bounds the session and the script states that bound.
|
|
2156
|
+
|
|
2157
|
+
``--write DIR`` creates the directory, saves the script under its fixed name
|
|
2158
|
+
(:data:`codevanta.analysis.dynamic.SCRIPT_FILE_NAMES`) and prints that path;
|
|
2159
|
+
without it the script text goes to stdout and nothing is written. In both
|
|
2160
|
+
cases the exact ``argv`` follows, and the dim line under it says what has to
|
|
2161
|
+
happen before that command is run. ``--json`` prints the script text, its
|
|
2162
|
+
path (``null`` when not written), the argv as a list and the probe.
|
|
2163
|
+
|
|
2164
|
+
The provider is never launched: this command writes a file and prints a
|
|
2165
|
+
command line. A provider that cannot run here -- :func:`available_backends`
|
|
2166
|
+
decides that without executing anything -- exits 2 with its ``reason``
|
|
2167
|
+
rather than handing over a command line that would fail.
|
|
2168
|
+
"""
|
|
2169
|
+
status = dynamic.provider_status(provider)
|
|
2170
|
+
if not status.available:
|
|
2171
|
+
_fail(f"{provider} is unavailable: {status.reason}", 2)
|
|
2172
|
+
loaded = _load(path)
|
|
2173
|
+
points = [_parse_address(text) for text in addresses or ()]
|
|
2174
|
+
try:
|
|
2175
|
+
if not points:
|
|
2176
|
+
points = [resolve_target(loaded, None, None)]
|
|
2177
|
+
if provider == "gdb":
|
|
2178
|
+
script = dynamic.build_gdb_script(path, loaded, points, max_hits=max_hits)
|
|
2179
|
+
else:
|
|
2180
|
+
script = dynamic.build_frida_script(path, loaded, points, max_hits=max_hits)
|
|
2181
|
+
argv = dynamic.attach_command(provider, path, script)
|
|
2182
|
+
except AnalysisError as exc:
|
|
2183
|
+
_fail(str(exc), 2)
|
|
2184
|
+
script_name = dynamic.script_file_name(provider)
|
|
2185
|
+
script_path: Path | None = None
|
|
2186
|
+
if write is not None:
|
|
2187
|
+
script_path = write / script_name
|
|
2188
|
+
try:
|
|
2189
|
+
write.mkdir(parents=True, exist_ok=True)
|
|
2190
|
+
script_path.write_text(script.text, encoding="utf-8", newline="\n")
|
|
2191
|
+
except OSError as exc:
|
|
2192
|
+
_fail(f"cannot write {script_path}: {exc}", 2)
|
|
2193
|
+
if json_output:
|
|
2194
|
+
console.print_json(
|
|
2195
|
+
json.dumps(
|
|
2196
|
+
{
|
|
2197
|
+
"provider": provider,
|
|
2198
|
+
"availability": status.to_dict(),
|
|
2199
|
+
"script": script.to_dict(),
|
|
2200
|
+
"script_path": None if script_path is None else str(script_path),
|
|
2201
|
+
"attach_command": list(argv),
|
|
2202
|
+
"addresses": [f"0x{address:x}" for address in points],
|
|
2203
|
+
"max_hits": max_hits,
|
|
2204
|
+
}
|
|
2205
|
+
)
|
|
2206
|
+
)
|
|
2207
|
+
return
|
|
2208
|
+
if script_path is not None:
|
|
2209
|
+
console.print(f"[green]script written:[/] {escape(str(script_path))}")
|
|
2210
|
+
else:
|
|
2211
|
+
console.print(script.text, markup=False, soft_wrap=True)
|
|
2212
|
+
console.print(f"[bold]attach:[/] {escape(' '.join(argv))}")
|
|
2213
|
+
console.print(
|
|
2214
|
+
f"[dim]provider: {escape(provider)} · targets: {len(points)} · max hits: {max_hits}"
|
|
2215
|
+
f" · script: {escape(script_name)} · availability: {escape(status.reason)}[/]"
|
|
2216
|
+
)
|
|
2217
|
+
if script_path is not None:
|
|
2218
|
+
console.print(
|
|
2219
|
+
f"[dim]run the command above from {escape(str(script_path.parent))}, because the "
|
|
2220
|
+
"argv names the script by its fixed file name[/]"
|
|
2221
|
+
)
|
|
2222
|
+
else:
|
|
2223
|
+
console.print(
|
|
2224
|
+
"[dim]nothing was written: save the text above under that file name in the "
|
|
2225
|
+
"working directory, or pass --write DIR, before running the command[/]"
|
|
2226
|
+
)
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
# ---- phase 7: path exploration and data flow --------------------------------
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def _print_symbolic_exploration(result: PathExploration) -> None:
|
|
2233
|
+
"""Print one :class:`PathExploration` as a path table plus its warnings."""
|
|
2234
|
+
table = Table(title=escape(f"Paths through 0x{result.address:x} ({result.paths})"))
|
|
2235
|
+
table.add_column("#", justify="right")
|
|
2236
|
+
table.add_column("Decisions")
|
|
2237
|
+
table.add_column("Return", justify="right")
|
|
2238
|
+
table.add_column("Stopped")
|
|
2239
|
+
table.add_column("Steps", justify="right")
|
|
2240
|
+
for entry in result.entries:
|
|
2241
|
+
decisions = " ".join(
|
|
2242
|
+
f"{branch.mnemonic}{'+' if branch.taken else '-'}" for branch in entry.decisions
|
|
2243
|
+
)
|
|
2244
|
+
if entry.seeded:
|
|
2245
|
+
decisions = f"{decisions} ({entry.seeded} seeded)"
|
|
2246
|
+
table.add_row(
|
|
2247
|
+
str(entry.index),
|
|
2248
|
+
decisions or "-",
|
|
2249
|
+
f"0x{entry.return_value:x}" if entry.return_value is not None else "-",
|
|
2250
|
+
entry.stopped_reason,
|
|
2251
|
+
str(entry.instructions),
|
|
2252
|
+
)
|
|
2253
|
+
console.print(table)
|
|
2254
|
+
console.print(
|
|
2255
|
+
f"[dim]explorer: {escape(result.explorer)} · arch: {escape(result.architecture)} "
|
|
2256
|
+
f"({result.bitness}-bit) · paths: {result.paths} · unresolved: {result.unresolved} · "
|
|
2257
|
+
f"steps: {result.steps} · complete: {'yes' if result.complete else 'no'}[/]"
|
|
2258
|
+
)
|
|
2259
|
+
for warning in result.warnings:
|
|
2260
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
2261
|
+
for entry in result.entries:
|
|
2262
|
+
for warning in entry.warnings:
|
|
2263
|
+
console.print(f"[yellow]warning:[/] [path {entry.index}] {escape(warning)}")
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
@app.command()
|
|
2267
|
+
def symbolic(
|
|
2268
|
+
path: Annotated[Path, typer.Argument(help="Path to the binary to explore")],
|
|
2269
|
+
address: Annotated[
|
|
2270
|
+
str | None,
|
|
2271
|
+
typer.Argument(
|
|
2272
|
+
help="Virtual address to start at (decimal or 0x hex); defaults to the entry point"
|
|
2273
|
+
),
|
|
2274
|
+
] = None,
|
|
2275
|
+
max_paths: Annotated[
|
|
2276
|
+
int, typer.Option("--max-paths", help="Maximum paths to report")
|
|
2277
|
+
] = SYMBOLIC_MAX_PATHS,
|
|
2278
|
+
max_steps: Annotated[
|
|
2279
|
+
int,
|
|
2280
|
+
typer.Option(
|
|
2281
|
+
"--max-steps", help="Instruction budget for the whole exploration, replays included"
|
|
2282
|
+
),
|
|
2283
|
+
] = SYMBOLIC_MAX_STEPS,
|
|
2284
|
+
set_values: Annotated[
|
|
2285
|
+
list[str] | None,
|
|
2286
|
+
typer.Option(
|
|
2287
|
+
"--set", help="Seed a register for every path, e.g. --set eax=42; repeatable"
|
|
2288
|
+
),
|
|
2289
|
+
] = None,
|
|
2290
|
+
json_output: Annotated[
|
|
2291
|
+
bool, typer.Option("--json", help="Emit PathExploration.to_dict() instead of a table")
|
|
2292
|
+
] = False,
|
|
2293
|
+
) -> None:
|
|
2294
|
+
"""Enumerate the paths through one function by replaying branch decisions.
|
|
2295
|
+
|
|
2296
|
+
Exploration starts at ADDRESS, else the entry point, and walks the decision
|
|
2297
|
+
tree breadth first: every conditional branch met on the way is followed in
|
|
2298
|
+
the direction the emulated flags produced (``observed``) and the opposite
|
|
2299
|
+
direction is enqueued with its flags rewritten (``seeded`` -- an assumption
|
|
2300
|
+
about feasibility, not a solved constraint, and each such branch says so in
|
|
2301
|
+
a ``warning:`` line). Nothing is executed on the host: the file's bytes are
|
|
2302
|
+
copied onto a private CPU, exactly like ``codevanta emulate``.
|
|
2303
|
+
|
|
2304
|
+
The table has one row per path: its number, the decisions as
|
|
2305
|
+
``mnemonic+`` (taken) or ``mnemonic-`` (fell through) with a ``seeded``
|
|
2306
|
+
count when directions were forced, the value in ``rax``/``eax`` when the
|
|
2307
|
+
path reached a ``ret`` that left the function (``-`` otherwise), why it
|
|
2308
|
+
stopped (``return``, ``budget``, ``blocked`` or ``unmapped``) and how many
|
|
2309
|
+
instructions it executed. The dim line under it carries the explorer, the
|
|
2310
|
+
architecture, the totals and whether the exploration was complete; every
|
|
2311
|
+
degradation keeps its own ``warning:`` line, per path where applicable.
|
|
2312
|
+
``--json`` prints ``PathExploration.to_dict()`` unchanged instead.
|
|
2313
|
+
|
|
2314
|
+
``--set REG=VALUE`` (repeatable, decimal or ``0x`` hex) seeds a register for
|
|
2315
|
+
every path before it runs, which is how an input reaches the code. A
|
|
2316
|
+
partial answer is never dressed up as a complete one: paths that ended
|
|
2317
|
+
without a return are counted in the ``unresolved`` total and explained.
|
|
2318
|
+
``--max-paths`` and ``--max-steps`` bound the work; an out-of-range budget
|
|
2319
|
+
is rejected before anything runs. Exploration needs the optional ``unicorn``
|
|
2320
|
+
engine; when it is missing this exits 2 and never raises ``ImportError``.
|
|
2321
|
+
A bad address, a bad seed and an address outside an executable section all
|
|
2322
|
+
exit 2 as well.
|
|
2323
|
+
"""
|
|
2324
|
+
if not SYMBOLIC_EMULATOR_AVAILABLE:
|
|
2325
|
+
_fail(
|
|
2326
|
+
"path exploration is unavailable: "
|
|
2327
|
+
f"{emulator.emulator_status() or 'the unicorn engine could not be imported'}; "
|
|
2328
|
+
"install it with 'pip install unicorn' and retry",
|
|
2329
|
+
2,
|
|
2330
|
+
)
|
|
2331
|
+
registers = dict(_parse_register_assignment(text) for text in set_values or ())
|
|
2332
|
+
requested = None if address is None else _parse_address(address)
|
|
2333
|
+
loaded = _load(path)
|
|
2334
|
+
try:
|
|
2335
|
+
start = resolve_target(loaded, requested, None)
|
|
2336
|
+
result = explore_paths(
|
|
2337
|
+
path,
|
|
2338
|
+
loaded,
|
|
2339
|
+
start,
|
|
2340
|
+
max_paths=max_paths,
|
|
2341
|
+
max_steps=max_steps,
|
|
2342
|
+
initial_registers=registers,
|
|
2343
|
+
)
|
|
2344
|
+
except AnalysisError as exc:
|
|
2345
|
+
_fail(str(exc), 2)
|
|
2346
|
+
if json_output:
|
|
2347
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
2348
|
+
return
|
|
2349
|
+
_print_symbolic_exploration(result)
|
|
2350
|
+
|
|
2351
|
+
|
|
2352
|
+
def _print_dataflow_summary(result: DataFlowSummary) -> None:
|
|
2353
|
+
"""Print one :class:`DataFlowSummary` as a definition table plus footers."""
|
|
2354
|
+
table = Table(title=escape(f"Data flow at 0x{result.address:x}"))
|
|
2355
|
+
table.add_column("Address", justify="right")
|
|
2356
|
+
table.add_column("Register")
|
|
2357
|
+
table.add_column("Kind")
|
|
2358
|
+
table.add_column("Value", justify="right")
|
|
2359
|
+
table.add_column("Source")
|
|
2360
|
+
for definition in result.definitions:
|
|
2361
|
+
table.add_row(
|
|
2362
|
+
f"0x{definition.address:x}",
|
|
2363
|
+
escape(definition.register),
|
|
2364
|
+
escape(definition.kind),
|
|
2365
|
+
f"0x{definition.value:x}" if definition.value is not None else "-",
|
|
2366
|
+
escape(definition.source_register or "-"),
|
|
2367
|
+
)
|
|
2368
|
+
console.print(table)
|
|
2369
|
+
constants = " ".join(
|
|
2370
|
+
f"{name}=0x{value:x}" for name, value in sorted(result.constant_registers.items())
|
|
2371
|
+
)
|
|
2372
|
+
console.print(f"[dim]constant registers: {escape(constants or 'none')}[/]")
|
|
2373
|
+
for warning in result.warnings:
|
|
2374
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
2375
|
+
|
|
2376
|
+
|
|
2377
|
+
@app.command()
|
|
2378
|
+
def dataflow(
|
|
2379
|
+
path: Annotated[Path, typer.Argument(help="Path to the binary to analyse")],
|
|
2380
|
+
address: Annotated[
|
|
2381
|
+
str | None,
|
|
2382
|
+
typer.Argument(
|
|
2383
|
+
help="Virtual address to start at (decimal or 0x hex); defaults to the entry point"
|
|
2384
|
+
),
|
|
2385
|
+
] = None,
|
|
2386
|
+
max_instructions: Annotated[
|
|
2387
|
+
int,
|
|
2388
|
+
typer.Option("--max-instructions", help="Maximum instructions to decode"),
|
|
2389
|
+
] = DATAFLOW_MAX_INSTRUCTIONS,
|
|
2390
|
+
max_blocks: Annotated[
|
|
2391
|
+
int, typer.Option("--max-blocks", help="Maximum basic blocks to walk")
|
|
2392
|
+
] = DATAFLOW_MAX_BLOCKS,
|
|
2393
|
+
json_output: Annotated[
|
|
2394
|
+
bool, typer.Option("--json", help="Emit DataFlowSummary.to_dict() instead of a table")
|
|
2395
|
+
] = False,
|
|
2396
|
+
) -> None:
|
|
2397
|
+
"""Recover register definitions, uses and provable constants of one function.
|
|
2398
|
+
|
|
2399
|
+
The region starting at ADDRESS, else the entry point, is decoded and walked
|
|
2400
|
+
forward once, block by block in ascending address order: every read is
|
|
2401
|
+
resolved against the definitions seen so far and every register write
|
|
2402
|
+
replaces (kills) what the register held. A few shapes are folded -- the
|
|
2403
|
+
values a ``mov`` immediate, a register copy or an ``add``/``sub`` of known
|
|
2404
|
+
values leave behind -- and anything else keeps the register unknown rather
|
|
2405
|
+
than guessing.
|
|
2406
|
+
|
|
2407
|
+
The table has one row per definition: its address, the canonical register
|
|
2408
|
+
(aliases such as ``eax`` are normalised), the shape (``const``, ``copy``,
|
|
2409
|
+
``arithmetic`` or ``unknown``), the provable value (``-`` when none) and
|
|
2410
|
+
the source register of a copy. The dim line under it lists the constants
|
|
2411
|
+
still known after the pass (``none`` when nothing is provable), and every
|
|
2412
|
+
caveat -- undefined reads, a truncated CFG -- keeps its own ``warning:``
|
|
2413
|
+
line. ``--json`` prints ``DataFlowSummary.to_dict()`` unchanged instead,
|
|
2414
|
+
which adds the uses, the reaching definitions and the evidence.
|
|
2415
|
+
|
|
2416
|
+
``--max-instructions`` and ``--max-blocks`` bound the work; an out-of-range
|
|
2417
|
+
budget, a bad address and an address outside an executable section all
|
|
2418
|
+
exit 2. The target binary is only ever read: this is a static pass.
|
|
2419
|
+
"""
|
|
2420
|
+
requested = None if address is None else _parse_address(address)
|
|
2421
|
+
loaded = _load(path)
|
|
2422
|
+
try:
|
|
2423
|
+
start = resolve_target(loaded, requested, None)
|
|
2424
|
+
result = analyze_dataflow(
|
|
2425
|
+
path,
|
|
2426
|
+
loaded,
|
|
2427
|
+
start,
|
|
2428
|
+
max_instructions=max_instructions,
|
|
2429
|
+
max_blocks=max_blocks,
|
|
2430
|
+
)
|
|
2431
|
+
except AnalysisError as exc:
|
|
2432
|
+
_fail(str(exc), 2)
|
|
2433
|
+
if json_output:
|
|
2434
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
2435
|
+
return
|
|
2436
|
+
_print_dataflow_summary(result)
|
|
2437
|
+
|
|
2438
|
+
|
|
2439
|
+
# ---- phase 7: analysis plugins ----------------------------------------------
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
plugins_app = typer.Typer(help="Analysis plugin commands")
|
|
2443
|
+
app.add_typer(plugins_app, name="plugins")
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
@plugins_app.command("list")
|
|
2447
|
+
def plugins_list(
|
|
2448
|
+
json_output: Annotated[
|
|
2449
|
+
bool, typer.Option("--json", help="Emit the plugin descriptions as JSON")
|
|
2450
|
+
] = False,
|
|
2451
|
+
) -> None:
|
|
2452
|
+
"""List the analysis plugins this process can run with ``plugins run``.
|
|
2453
|
+
|
|
2454
|
+
The list is the live built-in registry, sorted by name. ``--json`` prints
|
|
2455
|
+
one ``PluginInfo.to_dict()`` per plugin instead of a table.
|
|
2456
|
+
"""
|
|
2457
|
+
infos = builtin_plugins()
|
|
2458
|
+
if json_output:
|
|
2459
|
+
console.print_json(json.dumps([info.to_dict() for info in infos]))
|
|
2460
|
+
return
|
|
2461
|
+
table = Table(title=escape(f"Analysis plugins ({len(infos)})"))
|
|
2462
|
+
table.add_column("Name")
|
|
2463
|
+
table.add_column("Version")
|
|
2464
|
+
table.add_column("Kind")
|
|
2465
|
+
table.add_column("Entry point")
|
|
2466
|
+
table.add_column("Description")
|
|
2467
|
+
for info in infos:
|
|
2468
|
+
table.add_row(
|
|
2469
|
+
escape(info.name),
|
|
2470
|
+
escape(info.version),
|
|
2471
|
+
escape(info.kind),
|
|
2472
|
+
escape(info.entry_point or "-"),
|
|
2473
|
+
escape(info.description or "-"),
|
|
2474
|
+
)
|
|
2475
|
+
console.print(table)
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
@plugins_app.command("run")
|
|
2479
|
+
def plugins_run(
|
|
2480
|
+
name: Annotated[
|
|
2481
|
+
str, typer.Argument(help="Plugin name, as shown by 'codevanta plugins list'")
|
|
2482
|
+
],
|
|
2483
|
+
path: Annotated[Path, typer.Argument(help="Path to the binary to analyse")],
|
|
2484
|
+
address: Annotated[
|
|
2485
|
+
str | None,
|
|
2486
|
+
typer.Argument(
|
|
2487
|
+
help="Focus address for the run (decimal or 0x hex); defaults to the entry point"
|
|
2488
|
+
),
|
|
2489
|
+
] = None,
|
|
2490
|
+
json_output: Annotated[
|
|
2491
|
+
bool, typer.Option("--json", help="Emit PluginResult.to_dict() instead of a table")
|
|
2492
|
+
] = False,
|
|
2493
|
+
) -> None:
|
|
2494
|
+
"""Run one analysis plugin over a binary under the plugin harness budget.
|
|
2495
|
+
|
|
2496
|
+
The plugin is resolved from the built-in registry first; a name no registry
|
|
2497
|
+
or entry point declares exits 2 with an error that names it. The run gets
|
|
2498
|
+
an :class:`~codevanta.plugins.AnalysisContext` built from the loaded binary
|
|
2499
|
+
and its discovered function candidates, focused at ADDRESS, else the entry
|
|
2500
|
+
point, and the harness bounds the run by wall-clock time and payload size
|
|
2501
|
+
-- a plugin that overruns either budget exits 2.
|
|
2502
|
+
|
|
2503
|
+
The table prints the returned payload as ``key = value`` rows (nested
|
|
2504
|
+
values as JSON), the dim line under it names the plugin, its version, its
|
|
2505
|
+
kind and the measured run time, and warnings the payload declares keep
|
|
2506
|
+
their own ``warning:`` lines. ``--json`` prints
|
|
2507
|
+
``PluginResult.to_dict()`` unchanged instead.
|
|
2508
|
+
"""
|
|
2509
|
+
requested = None if address is None else _parse_address(address)
|
|
2510
|
+
loaded = _load(path)
|
|
2511
|
+
try:
|
|
2512
|
+
plugin = load_plugin(name=name)
|
|
2513
|
+
start = resolve_target(loaded, requested, None)
|
|
2514
|
+
context = AnalysisContext(
|
|
2515
|
+
path=path,
|
|
2516
|
+
info=loaded,
|
|
2517
|
+
address=start,
|
|
2518
|
+
functions=tuple(discover_functions(loaded)),
|
|
2519
|
+
)
|
|
2520
|
+
result = run_plugin(plugin, context)
|
|
2521
|
+
except AnalysisError as exc:
|
|
2522
|
+
_fail(str(exc), 2)
|
|
2523
|
+
if json_output:
|
|
2524
|
+
console.print_json(json.dumps(result.to_dict()))
|
|
2525
|
+
return
|
|
2526
|
+
table = Table(title=escape(f"Plugin {result.plugin.name}"))
|
|
2527
|
+
table.add_column("Key")
|
|
2528
|
+
table.add_column("Value")
|
|
2529
|
+
for key, value in sorted(result.payload.items()):
|
|
2530
|
+
text = value if isinstance(value, str) else json.dumps(value, sort_keys=True)
|
|
2531
|
+
table.add_row(escape(str(key)), escape(text))
|
|
2532
|
+
console.print(table)
|
|
2533
|
+
console.print(
|
|
2534
|
+
f"[dim]plugin: {escape(result.plugin.name)} · version: "
|
|
2535
|
+
f"{escape(result.plugin.version)} · kind: {escape(result.plugin.kind)} · "
|
|
2536
|
+
f"{result.elapsed_ms:.1f} ms[/]"
|
|
2537
|
+
)
|
|
2538
|
+
for warning in result.warnings:
|
|
2539
|
+
console.print(f"[yellow]warning:[/] {escape(warning)}")
|
|
2540
|
+
|
|
2541
|
+
|
|
2542
|
+
# ---- phase 7: evidence bundles ----------------------------------------------
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
def _parse_entry_assignment(text: str) -> tuple[str, str]:
|
|
2546
|
+
"""Parse one ``NAME=FILEPATH`` payload given through ``--entry``.
|
|
2547
|
+
|
|
2548
|
+
The name and the file side must both be non-empty; a missing ``=`` or a
|
|
2549
|
+
blank half exits with code 2 instead of bundling a wrong payload.
|
|
2550
|
+
"""
|
|
2551
|
+
name, separator, file_text = text.partition("=")
|
|
2552
|
+
name = name.strip()
|
|
2553
|
+
file_text = file_text.strip()
|
|
2554
|
+
if not separator or not name or not file_text:
|
|
2555
|
+
_fail(f"invalid --entry {text!r}; use NAME=FILEPATH, e.g. --entry cfg=report.json", 2)
|
|
2556
|
+
return name, file_text
|
|
2557
|
+
|
|
2558
|
+
|
|
2559
|
+
def _read_entry_payload(name: str, file_text: str) -> Any:
|
|
2560
|
+
"""Read one ``--entry`` payload file and parse it as JSON."""
|
|
2561
|
+
payload_path = Path(file_text)
|
|
2562
|
+
try:
|
|
2563
|
+
text = payload_path.read_text(encoding="utf-8")
|
|
2564
|
+
except OSError as exc:
|
|
2565
|
+
_fail(f"cannot read entry {name!r} from {payload_path}: {exc}", 2)
|
|
2566
|
+
except UnicodeDecodeError as exc:
|
|
2567
|
+
_fail(f"entry {name!r} is not UTF-8 text ({exc})", 2)
|
|
2568
|
+
try:
|
|
2569
|
+
return json.loads(text)
|
|
2570
|
+
except ValueError as exc:
|
|
2571
|
+
_fail(f"entry {name!r} is not valid JSON ({exc})", 2)
|
|
2572
|
+
|
|
2573
|
+
|
|
2574
|
+
@app.command("evidence-bundle")
|
|
2575
|
+
def evidence_bundle(
|
|
2576
|
+
path: Annotated[Path, typer.Argument(help="Path to the analysed binary")],
|
|
2577
|
+
entries: Annotated[
|
|
2578
|
+
list[str],
|
|
2579
|
+
typer.Option(
|
|
2580
|
+
"--entry",
|
|
2581
|
+
help="Bundle one payload, NAME=FILEPATH (a JSON file); repeatable, at least once",
|
|
2582
|
+
),
|
|
2583
|
+
],
|
|
2584
|
+
output: Annotated[Path, typer.Option("--output", "-o", help="Write the bundle document here")],
|
|
2585
|
+
json_output: Annotated[
|
|
2586
|
+
bool, typer.Option("--json", help="Also print the bundle document to stdout")
|
|
2587
|
+
] = False,
|
|
2588
|
+
) -> None:
|
|
2589
|
+
"""Hash one binary and its analysis payloads into a tamper-evident bundle.
|
|
2590
|
+
|
|
2591
|
+
Every ``--entry`` names one payload: ``NAME`` is the entry name in the
|
|
2592
|
+
bundle (at least one entry is required; a repeated name is refused) and
|
|
2593
|
+
``FILEPATH`` is a JSON file whose parsed document becomes the payload. The
|
|
2594
|
+
subject binary is streamed through SHA-256 and every payload is reduced to
|
|
2595
|
+
canonical JSON before it is hashed, so a verifier can recompute both
|
|
2596
|
+
digests from the document alone -- ``verify_bundle`` reports any later
|
|
2597
|
+
edit as a mismatch.
|
|
2598
|
+
|
|
2599
|
+
The bundle is written to ``--output`` as canonical JSON. Writing it *into*
|
|
2600
|
+
the analysed path is refused: ``--output`` must not name the analysed
|
|
2601
|
+
binary itself (nor anything under it, when the subject is a directory),
|
|
2602
|
+
because a bundle that overwrites its own subject destroys the evidence it
|
|
2603
|
+
pins. Missing or unreadable entry files, non-JSON payloads and unwritable
|
|
2604
|
+
outputs all exit 2, and a bundle is never written partially.
|
|
2605
|
+
|
|
2606
|
+
The success line names the written path, the entry count and the subject
|
|
2607
|
+
digest. ``--json`` additionally prints the exact document to stdout.
|
|
2608
|
+
"""
|
|
2609
|
+
payloads: dict[str, Any] = {}
|
|
2610
|
+
for text in entries:
|
|
2611
|
+
name, file_text = _parse_entry_assignment(text)
|
|
2612
|
+
if name in payloads:
|
|
2613
|
+
_fail(f"duplicate --entry name {name!r}; entry names must be unique", 2)
|
|
2614
|
+
payloads[name] = _read_entry_payload(name, file_text)
|
|
2615
|
+
subject = path.resolve()
|
|
2616
|
+
target = output.resolve()
|
|
2617
|
+
if subject.is_dir():
|
|
2618
|
+
if target == subject or subject in target.parents:
|
|
2619
|
+
_fail(
|
|
2620
|
+
f"--output {str(output)!r} is inside the analysed directory {str(path)!r}; "
|
|
2621
|
+
"write the bundle somewhere else",
|
|
2622
|
+
2,
|
|
2623
|
+
)
|
|
2624
|
+
elif target == subject:
|
|
2625
|
+
_fail(f"--output {str(output)!r} would overwrite the analysed binary {str(path)!r}", 2)
|
|
2626
|
+
try:
|
|
2627
|
+
bundle = build_bundle(path, payloads)
|
|
2628
|
+
written = write_bundle(bundle, output)
|
|
2629
|
+
except AnalysisError as exc:
|
|
2630
|
+
_fail(str(exc), 2)
|
|
2631
|
+
console.print(
|
|
2632
|
+
f"[green]bundle written:[/] {escape(str(written))} · entries: {len(payloads)} "
|
|
2633
|
+
f"· subject sha256: {escape(bundle.manifest.subject_sha256)}"
|
|
2634
|
+
)
|
|
2635
|
+
if json_output:
|
|
2636
|
+
console.print(dumps_bundle(bundle), markup=False, soft_wrap=True)
|
|
2637
|
+
|
|
2638
|
+
|
|
2639
|
+
mcp_app = typer.Typer(help="Model Context Protocol server commands")
|
|
2640
|
+
app.add_typer(mcp_app, name="mcp")
|
|
2641
|
+
|
|
2642
|
+
|
|
2643
|
+
@mcp_app.command("serve")
|
|
2644
|
+
def mcp_serve(
|
|
2645
|
+
transport: Annotated[
|
|
2646
|
+
str,
|
|
2647
|
+
typer.Option(
|
|
2648
|
+
"--transport",
|
|
2649
|
+
help="stdio (default), sse or streamable-http",
|
|
2650
|
+
),
|
|
2651
|
+
] = "stdio",
|
|
2652
|
+
host: Annotated[
|
|
2653
|
+
str,
|
|
2654
|
+
typer.Option("--host", help="Bind address for the HTTP transports"),
|
|
2655
|
+
] = "127.0.0.1",
|
|
2656
|
+
port: Annotated[
|
|
2657
|
+
int,
|
|
2658
|
+
typer.Option("--port", help="Bind port for the HTTP transports; 0 picks a free one"),
|
|
2659
|
+
] = 8000,
|
|
2660
|
+
allow_remote: Annotated[
|
|
2661
|
+
bool,
|
|
2662
|
+
typer.Option("--allow-remote", help="Permit a non-loopback bind address"),
|
|
2663
|
+
] = False,
|
|
2664
|
+
) -> None:
|
|
2665
|
+
"""Serve CODEVANTA tools to coding agents over MCP (stdio or HTTP)."""
|
|
2666
|
+
try:
|
|
2667
|
+
from codevanta.mcp_server.server import run as run_server
|
|
2668
|
+
except ImportError as exc: # pragma: no cover - dependency guard
|
|
2669
|
+
console.print(f"[bold red]error:[/] MCP support needs the 'mcp' package: {exc}")
|
|
2670
|
+
raise typer.Exit(code=2) from exc
|
|
2671
|
+
try:
|
|
2672
|
+
run_server(transport=transport, host=host, port=port, allow_remote=allow_remote)
|
|
2673
|
+
except (AnalysisError, ValueError) as exc:
|
|
2674
|
+
_fail(str(exc), 2)
|
|
2675
|
+
except OSError as exc:
|
|
2676
|
+
_fail(f"could not bind {host}:{port}: {exc}", 2)
|
|
2677
|
+
except SystemExit as exc:
|
|
2678
|
+
# uvicorn reports an unusable address by calling ``sys.exit`` from inside
|
|
2679
|
+
# the SDK's event loop, so a taken port arrives here as ``SystemExit``
|
|
2680
|
+
# (verified with the pinned mcp/uvicorn: code 3) and would otherwise
|
|
2681
|
+
# leave the process with exit 3 and raw uvicorn output. Exit 0 is a
|
|
2682
|
+
# clean shutdown and is re-raised untouched.
|
|
2683
|
+
if _system_exit_status(exc) == 0:
|
|
2684
|
+
raise
|
|
2685
|
+
_fail(f"could not bind {host}:{port}", 2)
|
|
2686
|
+
|
|
2687
|
+
|
|
2688
|
+
@app.command()
|
|
2689
|
+
def gui(
|
|
2690
|
+
host: Annotated[
|
|
2691
|
+
str,
|
|
2692
|
+
typer.Option("--host", help="Loopback address to bind (127.0.0.1, localhost or ::1)"),
|
|
2693
|
+
] = "127.0.0.1",
|
|
2694
|
+
port: Annotated[
|
|
2695
|
+
int,
|
|
2696
|
+
typer.Option("--port", help="TCP port to listen on; 0 picks a free one"),
|
|
2697
|
+
] = 8765,
|
|
2698
|
+
no_browser: Annotated[
|
|
2699
|
+
bool,
|
|
2700
|
+
typer.Option("--no-browser", help="Do not open a browser window"),
|
|
2701
|
+
] = False,
|
|
2702
|
+
) -> None:
|
|
2703
|
+
"""Serve the local web dashboard (read-only, loopback only)."""
|
|
2704
|
+
if port < 0 or port > 65535:
|
|
2705
|
+
_fail(f"port must be within 0 .. 65535, got {port}", 2)
|
|
2706
|
+
try:
|
|
2707
|
+
from codevanta.gui import serve as serve_gui
|
|
2708
|
+
except ImportError as exc: # pragma: no cover - packaging guard
|
|
2709
|
+
console.print(f"[bold red]error:[/] the web dashboard is unavailable: {exc}")
|
|
2710
|
+
raise typer.Exit(code=2) from exc
|
|
2711
|
+
try:
|
|
2712
|
+
serve_gui(host=host, port=port, open_browser=not no_browser)
|
|
2713
|
+
except OSError as exc:
|
|
2714
|
+
# ``http.server`` raises ``OSError`` (``PermissionError`` for WSAEACCES on
|
|
2715
|
+
# Windows) for a taken port, never ``SystemExit``, so this is the bind
|
|
2716
|
+
# failure path for the dashboard; the message names the address.
|
|
2717
|
+
_fail(f"could not bind {host}:{port}: {exc}", 2)
|
|
2718
|
+
except ValueError as exc:
|
|
2719
|
+
_fail(str(exc), 2)
|
|
2720
|
+
|
|
2721
|
+
|
|
2722
|
+
def _render_plan(plan) -> None:
|
|
2723
|
+
lines = list(plan.notes)
|
|
2724
|
+
for change in plan.changes:
|
|
2725
|
+
lines.append(f"{change.action}: {change.path}")
|
|
2726
|
+
if change.diff:
|
|
2727
|
+
lines.append(change.diff)
|
|
2728
|
+
title = f"setup plan · {plan.client} · {plan.scope}"
|
|
2729
|
+
console.print(Panel("\n".join(lines), title=escape(title)))
|
|
2730
|
+
|
|
2731
|
+
|
|
2732
|
+
def _adapters_for(ctx, names: list[str] | None):
|
|
2733
|
+
from codevanta.setup.clients import SetupError, detected_clients, get_client
|
|
2734
|
+
|
|
2735
|
+
try:
|
|
2736
|
+
if names:
|
|
2737
|
+
return [get_client(name) for name in names]
|
|
2738
|
+
return detected_clients(ctx)
|
|
2739
|
+
except SetupError as exc:
|
|
2740
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
2741
|
+
raise typer.Exit(code=2) from exc
|
|
2742
|
+
|
|
2743
|
+
|
|
2744
|
+
def _print_client_status(ctx) -> None:
|
|
2745
|
+
from codevanta.setup.clients import describe_clients
|
|
2746
|
+
|
|
2747
|
+
table = Table(title="Coding agent MCP status")
|
|
2748
|
+
table.add_column("Client")
|
|
2749
|
+
table.add_column("Scope")
|
|
2750
|
+
table.add_column("Status")
|
|
2751
|
+
table.add_column("Config path")
|
|
2752
|
+
for row in describe_clients(ctx):
|
|
2753
|
+
status_style = "green" if row["status"] == "registered" else "yellow"
|
|
2754
|
+
table.add_row(
|
|
2755
|
+
escape(row["client"]),
|
|
2756
|
+
row["scope"],
|
|
2757
|
+
f"[{status_style}]{escape(row['status'])}[/]",
|
|
2758
|
+
escape(row["path"]),
|
|
2759
|
+
)
|
|
2760
|
+
console.print(table)
|
|
2761
|
+
|
|
2762
|
+
|
|
2763
|
+
@app.command()
|
|
2764
|
+
def setup(
|
|
2765
|
+
client: Annotated[
|
|
2766
|
+
list[str] | None,
|
|
2767
|
+
typer.Option("--client", "-c", help="claude, cursor, cline, vscode (default: detected)"),
|
|
2768
|
+
] = None,
|
|
2769
|
+
scope: Annotated[str, typer.Option(help="user or project")] = "user",
|
|
2770
|
+
project: Annotated[
|
|
2771
|
+
Path | None, typer.Option(help="Project directory for project scope")
|
|
2772
|
+
] = None,
|
|
2773
|
+
dry_run: Annotated[bool, typer.Option("--dry-run", help="Show the plan only")] = False,
|
|
2774
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Apply without confirmation")] = False,
|
|
2775
|
+
verify: Annotated[bool, typer.Option("--verify", help="Verify after applying")] = False,
|
|
2776
|
+
list_clients: Annotated[
|
|
2777
|
+
bool, typer.Option("--list", help="Show configuration status for every client and exit")
|
|
2778
|
+
] = False,
|
|
2779
|
+
) -> None:
|
|
2780
|
+
"""Register CODEVANTA as an MCP server for coding agents."""
|
|
2781
|
+
from codevanta.setup.apply import apply_plan
|
|
2782
|
+
from codevanta.setup.clients import SetupContext, SetupError
|
|
2783
|
+
from codevanta.setup.verify import verify_entry, verify_tools
|
|
2784
|
+
|
|
2785
|
+
ctx = SetupContext.from_environment(project)
|
|
2786
|
+
if list_clients:
|
|
2787
|
+
_print_client_status(ctx)
|
|
2788
|
+
return
|
|
2789
|
+
adapters = _adapters_for(ctx, client)
|
|
2790
|
+
if not adapters:
|
|
2791
|
+
console.print("[yellow]no coding agents detected[/]; pass --client claude|cursor|cline|vscode")
|
|
2792
|
+
raise typer.Exit(code=3)
|
|
2793
|
+
try:
|
|
2794
|
+
plans = [adapter.plan(ctx, scope) for adapter in adapters]
|
|
2795
|
+
except SetupError as exc:
|
|
2796
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
2797
|
+
raise typer.Exit(code=2) from exc
|
|
2798
|
+
for plan in plans:
|
|
2799
|
+
_render_plan(plan)
|
|
2800
|
+
if dry_run:
|
|
2801
|
+
console.print("[dim]dry run: nothing was written[/]")
|
|
2802
|
+
return
|
|
2803
|
+
if any(plan.has_changes for plan in plans):
|
|
2804
|
+
if not yes and not typer.confirm("Apply this setup plan?", default=False):
|
|
2805
|
+
console.print("[yellow]cancelled[/]")
|
|
2806
|
+
return
|
|
2807
|
+
for plan in plans:
|
|
2808
|
+
for path in apply_plan(plan):
|
|
2809
|
+
console.print(f"[green]updated[/] {path}")
|
|
2810
|
+
else:
|
|
2811
|
+
console.print("[green]already configured[/]")
|
|
2812
|
+
if verify:
|
|
2813
|
+
results = [verify_entry(adapter, ctx, scope) for adapter in adapters]
|
|
2814
|
+
results.append(verify_tools())
|
|
2815
|
+
for result in results:
|
|
2816
|
+
marker = "[green]ok[/]" if result["ok"] else "[red]fail[/]"
|
|
2817
|
+
console.print(f"{marker} {escape(str(result))}")
|
|
2818
|
+
if not all(result["ok"] for result in results):
|
|
2819
|
+
raise typer.Exit(code=4)
|
|
2820
|
+
|
|
2821
|
+
|
|
2822
|
+
@app.command()
|
|
2823
|
+
def uninstall(
|
|
2824
|
+
client: Annotated[
|
|
2825
|
+
list[str] | None, typer.Option("--client", "-c", help="claude, cursor, cline, vscode")
|
|
2826
|
+
] = None,
|
|
2827
|
+
scope: Annotated[str, typer.Option(help="user or project")] = "user",
|
|
2828
|
+
project: Annotated[
|
|
2829
|
+
Path | None, typer.Option(help="Project directory for project scope")
|
|
2830
|
+
] = None,
|
|
2831
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Apply without confirmation")] = False,
|
|
2832
|
+
) -> None:
|
|
2833
|
+
"""Remove CODEVANTA from coding-agent MCP settings."""
|
|
2834
|
+
from codevanta.setup.apply import apply_plan
|
|
2835
|
+
from codevanta.setup.clients import SetupContext, SetupError
|
|
2836
|
+
|
|
2837
|
+
ctx = SetupContext.from_environment(project)
|
|
2838
|
+
adapters = _adapters_for(ctx, client)
|
|
2839
|
+
if not adapters:
|
|
2840
|
+
console.print("[yellow]no coding agents detected[/]; pass --client claude|cursor|cline|vscode")
|
|
2841
|
+
raise typer.Exit(code=3)
|
|
2842
|
+
try:
|
|
2843
|
+
plans = [adapter.removal_plan(ctx, scope) for adapter in adapters]
|
|
2844
|
+
except SetupError as exc:
|
|
2845
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
2846
|
+
raise typer.Exit(code=2) from exc
|
|
2847
|
+
for plan in plans:
|
|
2848
|
+
_render_plan(plan)
|
|
2849
|
+
if not any(plan.has_changes for plan in plans):
|
|
2850
|
+
console.print("[green]nothing to remove[/]")
|
|
2851
|
+
return
|
|
2852
|
+
if not yes and not typer.confirm("Apply this removal plan?", default=False):
|
|
2853
|
+
console.print("[yellow]cancelled[/]")
|
|
2854
|
+
return
|
|
2855
|
+
for plan in plans:
|
|
2856
|
+
for path in apply_plan(plan):
|
|
2857
|
+
console.print(f"[green]updated[/] {path}")
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
session_app = typer.Typer(help="Persisted analysis sessions (SQLite)")
|
|
2861
|
+
app.add_typer(session_app, name="session")
|
|
2862
|
+
|
|
2863
|
+
|
|
2864
|
+
@session_app.command("save")
|
|
2865
|
+
def session_save(
|
|
2866
|
+
path: Path,
|
|
2867
|
+
db: Annotated[Path | None, typer.Option("--db", help="Session database file")] = None,
|
|
2868
|
+
notes: Annotated[str | None, typer.Option("--notes", help="Note stored with the session")] = None,
|
|
2869
|
+
) -> None:
|
|
2870
|
+
"""Analyze PATH and persist the counts as a new session."""
|
|
2871
|
+
loaded = _load(path)
|
|
2872
|
+
try:
|
|
2873
|
+
functions_count = len(discover_functions(loaded))
|
|
2874
|
+
strings_count = len(StringExtractor(min_length=4).extract(path, info=loaded))
|
|
2875
|
+
with SessionStore(_session_db(db)) as store:
|
|
2876
|
+
record = store.save(loaded, functions_count, strings_count, notes=notes)
|
|
2877
|
+
except SessionError as exc:
|
|
2878
|
+
_fail(str(exc), 2)
|
|
2879
|
+
console.print(f"[green]session saved:[/] {escape(record.session_id)}")
|
|
2880
|
+
console.print(f"[dim]{escape(str(record.binary_path))}[/]")
|
|
2881
|
+
console.print(
|
|
2882
|
+
f"[dim]functions: {record.functions} · strings: {record.strings}"
|
|
2883
|
+
f" · db: {escape(str(_session_db(db)))}[/]"
|
|
2884
|
+
)
|
|
2885
|
+
|
|
2886
|
+
|
|
2887
|
+
def _print_sessions(records: list[SessionRecord]) -> None:
|
|
2888
|
+
table = Table(title=f"Sessions ({len(records)})")
|
|
2889
|
+
table.add_column("Id")
|
|
2890
|
+
table.add_column("Created")
|
|
2891
|
+
table.add_column("Format")
|
|
2892
|
+
table.add_column("Arch")
|
|
2893
|
+
table.add_column("Functions", justify="right")
|
|
2894
|
+
table.add_column("Strings", justify="right")
|
|
2895
|
+
table.add_column("Path")
|
|
2896
|
+
for record in records:
|
|
2897
|
+
table.add_row(
|
|
2898
|
+
escape(record.session_id),
|
|
2899
|
+
escape(record.created_at),
|
|
2900
|
+
escape(record.format),
|
|
2901
|
+
escape(record.architecture),
|
|
2902
|
+
str(record.functions),
|
|
2903
|
+
str(record.strings),
|
|
2904
|
+
escape(record.binary_path),
|
|
2905
|
+
)
|
|
2906
|
+
console.print(table)
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
@session_app.command("list")
|
|
2910
|
+
def session_list(
|
|
2911
|
+
db: Annotated[Path | None, typer.Option("--db", help="Session database file")] = None,
|
|
2912
|
+
limit: Annotated[int, typer.Option(help="Maximum sessions to display")] = 20,
|
|
2913
|
+
) -> None:
|
|
2914
|
+
"""List stored sessions, newest first."""
|
|
2915
|
+
if limit < 1:
|
|
2916
|
+
_fail("--limit must be >= 1", 2)
|
|
2917
|
+
try:
|
|
2918
|
+
with SessionStore(_session_db(db)) as store:
|
|
2919
|
+
records = store.list(limit=limit)
|
|
2920
|
+
except SessionError as exc:
|
|
2921
|
+
_fail(str(exc), 2)
|
|
2922
|
+
if not records:
|
|
2923
|
+
console.print("[yellow]no sessions stored[/]")
|
|
2924
|
+
return
|
|
2925
|
+
_print_sessions(records)
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
@session_app.command("show")
|
|
2929
|
+
def session_show(
|
|
2930
|
+
session_id: str,
|
|
2931
|
+
db: Annotated[Path | None, typer.Option("--db", help="Session database file")] = None,
|
|
2932
|
+
) -> None:
|
|
2933
|
+
"""Show one stored session as a key/value panel."""
|
|
2934
|
+
try:
|
|
2935
|
+
with SessionStore(_session_db(db)) as store:
|
|
2936
|
+
record = store.get(session_id)
|
|
2937
|
+
except SessionError as exc:
|
|
2938
|
+
_fail(str(exc), 2)
|
|
2939
|
+
if record is None:
|
|
2940
|
+
_fail(f"session {session_id!r} not found", 2)
|
|
2941
|
+
grid = Table.grid(padding=(0, 2))
|
|
2942
|
+
grid.add_column(style="bold cyan")
|
|
2943
|
+
grid.add_column()
|
|
2944
|
+
rows = [
|
|
2945
|
+
("Session", record.session_id),
|
|
2946
|
+
("Created", record.created_at),
|
|
2947
|
+
("Path", record.binary_path),
|
|
2948
|
+
("Format", record.format),
|
|
2949
|
+
("Architecture", record.architecture),
|
|
2950
|
+
("Bitness", str(record.bitness)),
|
|
2951
|
+
(
|
|
2952
|
+
"Entry point",
|
|
2953
|
+
f"0x{record.entry_point:x}" if record.entry_point is not None else "-",
|
|
2954
|
+
),
|
|
2955
|
+
("SHA-256", record.sha256),
|
|
2956
|
+
("Functions", str(record.functions)),
|
|
2957
|
+
("Strings", str(record.strings)),
|
|
2958
|
+
("Notes", record.notes or "-"),
|
|
2959
|
+
]
|
|
2960
|
+
for key, value in rows:
|
|
2961
|
+
grid.add_row(key, escape(value))
|
|
2962
|
+
console.print(Panel(grid, title="CODEVANTA · session", subtitle=escape(record.session_id)))
|
|
2963
|
+
|
|
2964
|
+
|
|
2965
|
+
def _parse_address(text: str, *, label: str = "address") -> int:
|
|
2966
|
+
"""Parse an address given on the command line.
|
|
2967
|
+
|
|
2968
|
+
``int(text, 0)`` is used so decimal (``4198400``), hexadecimal (``0x401000``),
|
|
2969
|
+
octal (``0o...``) and binary (``0b...``) literals are all accepted. Anything
|
|
2970
|
+
that does not parse, or parses to a negative value, exits with code 2.
|
|
2971
|
+
|
|
2972
|
+
``label`` only names the value in those error messages, so ``--base`` can
|
|
2973
|
+
report ``invalid base ...`` while every other address keeps its own wording.
|
|
2974
|
+
"""
|
|
2975
|
+
try:
|
|
2976
|
+
address = int(text, 0)
|
|
2977
|
+
except ValueError:
|
|
2978
|
+
_fail(f"invalid {label} {text!r}; use decimal or 0x-prefixed hex", 2)
|
|
2979
|
+
if address < 0:
|
|
2980
|
+
_fail(f"invalid {label} {text!r}; addresses must not be negative", 2)
|
|
2981
|
+
return address
|
|
2982
|
+
|
|
2983
|
+
|
|
2984
|
+
@session_app.command("annotate")
|
|
2985
|
+
def session_annotate(
|
|
2986
|
+
session_id: str,
|
|
2987
|
+
address: str,
|
|
2988
|
+
note: str,
|
|
2989
|
+
db: Annotated[Path | None, typer.Option("--db", help="Session database file")] = None,
|
|
2990
|
+
) -> None:
|
|
2991
|
+
"""Attach NOTE to ADDRESS (decimal or 0x hex) inside a stored session."""
|
|
2992
|
+
parsed = _parse_address(address)
|
|
2993
|
+
try:
|
|
2994
|
+
with SessionStore(_session_db(db)) as store:
|
|
2995
|
+
annotation = store.annotate(session_id, parsed, note)
|
|
2996
|
+
except SessionError as exc:
|
|
2997
|
+
_fail(str(exc), 2)
|
|
2998
|
+
console.print(
|
|
2999
|
+
f"[green]annotation saved:[/] {escape(annotation.session_id)}"
|
|
3000
|
+
f" · 0x{annotation.address:x}"
|
|
3001
|
+
)
|
|
3002
|
+
console.print(f"[dim]{escape(annotation.note)} · {escape(annotation.created_at)}[/]")
|
|
3003
|
+
|
|
3004
|
+
|
|
3005
|
+
@session_app.command("notes")
|
|
3006
|
+
def session_notes(
|
|
3007
|
+
session_id: str,
|
|
3008
|
+
db: Annotated[Path | None, typer.Option("--db", help="Session database file")] = None,
|
|
3009
|
+
) -> None:
|
|
3010
|
+
"""List the annotations stored for one session, lowest address first."""
|
|
3011
|
+
try:
|
|
3012
|
+
with SessionStore(_session_db(db)) as store:
|
|
3013
|
+
annotations = store.annotations(session_id)
|
|
3014
|
+
except SessionError as exc:
|
|
3015
|
+
_fail(str(exc), 2)
|
|
3016
|
+
if not annotations:
|
|
3017
|
+
console.print(f"[yellow]no notes for session {escape(session_id)}[/]")
|
|
3018
|
+
return
|
|
3019
|
+
table = Table(title=escape(f"Notes ({len(annotations)})"))
|
|
3020
|
+
table.add_column("Address", justify="right")
|
|
3021
|
+
table.add_column("Note")
|
|
3022
|
+
table.add_column("Created")
|
|
3023
|
+
for annotation in annotations:
|
|
3024
|
+
table.add_row(
|
|
3025
|
+
f"0x{annotation.address:x}",
|
|
3026
|
+
escape(annotation.note),
|
|
3027
|
+
escape(annotation.created_at),
|
|
3028
|
+
)
|
|
3029
|
+
console.print(table)
|
|
3030
|
+
|
|
3031
|
+
|
|
3032
|
+
def main() -> None:
|
|
3033
|
+
"""Console-script entry point."""
|
|
3034
|
+
app()
|
|
3035
|
+
|
|
3036
|
+
|
|
3037
|
+
if __name__ == "__main__":
|
|
3038
|
+
main()
|