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.
Files changed (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. 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()