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
@@ -0,0 +1,1364 @@
1
+ """MCP tool implementations (plain functions, no MCP imports)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from codevanta.analysis.binary_diff import (
12
+ DEFAULT_SEMANTIC_THRESHOLD,
13
+ MAX_SEMANTIC_THRESHOLD,
14
+ MIN_SEMANTIC_THRESHOLD,
15
+ diff_binaries,
16
+ )
17
+ from codevanta.analysis.callgraph import MAX_LIMIT as MAX_CALLGRAPH_LIMIT
18
+ from codevanta.analysis.callgraph import build_call_graph
19
+ from codevanta.analysis.cfg import build_cfg
20
+ from codevanta.analysis.dataflow import (
21
+ DEFAULT_MAX_BLOCKS as DEFAULT_DATAFLOW_MAX_BLOCKS,
22
+ )
23
+ from codevanta.analysis.dataflow import (
24
+ DEFAULT_MAX_INSTRUCTIONS as DEFAULT_DATAFLOW_MAX_INSTRUCTIONS,
25
+ )
26
+ from codevanta.analysis.dataflow import (
27
+ MAX_MAX_BLOCKS as MAX_DATAFLOW_MAX_BLOCKS,
28
+ )
29
+ from codevanta.analysis.dataflow import (
30
+ MAX_MAX_INSTRUCTIONS as MAX_DATAFLOW_MAX_INSTRUCTIONS,
31
+ )
32
+ from codevanta.analysis.dataflow import analyze_dataflow
33
+ from codevanta.analysis.debuginfo import DEFAULT_LIMIT as DEFAULT_DWARF_LIMIT
34
+ from codevanta.analysis.debuginfo import MAX_LIMIT as MAX_DWARF_LIMIT
35
+ from codevanta.analysis.debuginfo import extract_debug_info
36
+ from codevanta.analysis.decompile import (
37
+ DEFAULT_MAX_BLOCKS as DEFAULT_DECOMPILE_MAX_BLOCKS,
38
+ )
39
+ from codevanta.analysis.decompile import (
40
+ DEFAULT_MAX_INSTRUCTIONS as DEFAULT_DECOMPILE_MAX_INSTRUCTIONS,
41
+ )
42
+ from codevanta.analysis.decompile import MAX_MAX_BLOCKS as MAX_DECOMPILE_MAX_BLOCKS
43
+ from codevanta.analysis.decompile import (
44
+ MAX_MAX_INSTRUCTIONS as MAX_DECOMPILE_MAX_INSTRUCTIONS,
45
+ )
46
+ from codevanta.analysis.decompilers import BackendResult, supported_backends
47
+ from codevanta.analysis.decompilers import (
48
+ decompile_function as decompile_with_backend,
49
+ )
50
+ from codevanta.analysis.function_hash import function_fingerprint, function_similarity
51
+ from codevanta.analysis.jump_tables import MAX_ENTRIES as MAX_JUMP_TABLE_ENTRIES
52
+ from codevanta.analysis.jump_tables import MODE_RELATIVE, resolve_jump_table
53
+ from codevanta.analysis.jump_tables import MODES as JUMP_TABLE_MODES
54
+ from codevanta.analysis.symbolic import (
55
+ DEFAULT_MAX_PATHS as DEFAULT_SYMBOLIC_MAX_PATHS,
56
+ )
57
+ from codevanta.analysis.symbolic import (
58
+ DEFAULT_MAX_STEPS as DEFAULT_SYMBOLIC_MAX_STEPS,
59
+ )
60
+ from codevanta.analysis.symbolic import (
61
+ MAX_MAX_PATHS as MAX_SYMBOLIC_MAX_PATHS,
62
+ )
63
+ from codevanta.analysis.symbolic import (
64
+ MAX_MAX_STEPS as MAX_SYMBOLIC_MAX_STEPS,
65
+ )
66
+ from codevanta.analysis.symbolic import explore_paths
67
+ from codevanta.analysis.trace import (
68
+ MAX_TRACE_HITS,
69
+ MAX_TRACE_LINES,
70
+ parse_frida_trace,
71
+ parse_gdb_trace,
72
+ )
73
+ from codevanta.analysis.type_recovery import (
74
+ DEFAULT_MAX_FIELDS,
75
+ DEFAULT_MAX_INSTRUCTIONS,
76
+ MAX_MAX_FIELDS,
77
+ MAX_MAX_INSTRUCTIONS,
78
+ suggest_struct,
79
+ )
80
+ from codevanta.analysis.vtables import (
81
+ DEFAULT_MAX_SLOTS,
82
+ DEFAULT_MAX_TABLES,
83
+ DEFAULT_MIN_SLOTS,
84
+ find_vtables,
85
+ )
86
+ from codevanta.analysis.xrefs import DEFAULT_MAX_BYTES, find_xrefs
87
+ from codevanta.core.entropy import (
88
+ DEFAULT_MAX_SAMPLE_BYTES,
89
+ MAX_SAMPLE_LIMIT,
90
+ PACKED_HINT_THRESHOLD,
91
+ analyze_entropy,
92
+ )
93
+ from codevanta.core.functions import discover_functions
94
+ from codevanta.core.report import (
95
+ MAX_DISASSEMBLY_COUNT,
96
+ MAX_STRING_LIMIT,
97
+ AnalysisError,
98
+ build_report,
99
+ disassemble_region,
100
+ load_binary,
101
+ resolve_target,
102
+ )
103
+ from codevanta.core.strings import StringExtractor
104
+ from codevanta.storage.session import MAX_LIMIT as MAX_SESSION_LIMIT
105
+ from codevanta.storage.session import SessionError, SessionStore
106
+
107
+ #: Environment variable that overrides the default session database location.
108
+ SESSION_DB_ENV = "CODEVANTA_DB"
109
+
110
+ #: Hard ceiling on reportable cross references so one call cannot exhaust memory.
111
+ MAX_XREF_LIMIT = 200_000
112
+
113
+ #: Hard ceiling on executable bytes decoded by one cross-reference scan (64 MiB).
114
+ MAX_XREF_BYTES = 64_000_000
115
+
116
+ #: Hard ceiling on the numeric limits of the comparison, jump-table and
117
+ #: fingerprint tools, so no single call can be asked for unbounded work.
118
+ MAX_TOOL_LIMIT = 20_000
119
+
120
+ #: Trace formats :func:`trace` accepts; ``auto`` reads the provider off the file.
121
+ AUTO_TRACE_PROVIDER = "auto"
122
+ TRACE_PROVIDERS: tuple[str, ...] = (AUTO_TRACE_PROVIDER, "gdb", "frida")
123
+
124
+ #: Hard ceiling on the bytes one call reads from a saved trace (8 MiB).
125
+ MAX_TRACE_BYTES = 8_000_000
126
+
127
+ #: Lines ``auto`` inspects before it decides it cannot identify the provider.
128
+ TRACE_DETECT_LINES = 200
129
+
130
+ #: Backend requested when the caller does not name one: pick the best available.
131
+ AUTO_BACKEND = "auto"
132
+
133
+ #: Decompiler backends :func:`decompile` accepts: ``auto`` plus every registered
134
+ #: provider (``ghidra``, ``angr``, ``native``). Read from the provider registry so
135
+ #: adding a backend never drifts from the accepted set.
136
+ DECOMPILE_BACKENDS: tuple[str, ...] = (AUTO_BACKEND, *supported_backends())
137
+
138
+ #: Prefix of the block-coverage line the native renderer writes in its header.
139
+ _HEADER_BLOCKS_PREFIX = " * blocks: "
140
+
141
+ #: Default session database file used when no explicit path is supplied.
142
+ DEFAULT_SESSION_DB = Path.home() / ".codevanta" / "sessions.db"
143
+
144
+
145
+ def _validate_path(path: str) -> Path:
146
+ candidate = Path(path).expanduser()
147
+ if not candidate.exists():
148
+ raise AnalysisError(f"file not found: {path}")
149
+ if not candidate.is_file():
150
+ raise AnalysisError(f"not a regular file: {path}")
151
+ return candidate
152
+
153
+
154
+ def _validate_trace_path(trace_path: str) -> Path:
155
+ """Return ``trace_path`` as an existing regular file, or raise naming it.
156
+
157
+ The binary has :func:`_validate_path`; a saved trace needs its own wording
158
+ because ``file not found: x`` would not say which of the two inputs was
159
+ wrong, and a caller that mixed them up would chase the wrong argument.
160
+
161
+ Args:
162
+ trace_path: Path to the saved session capture.
163
+
164
+ Returns:
165
+ The path, confirmed to exist and to be a regular file.
166
+
167
+ Raises:
168
+ AnalysisError: If ``trace_path`` is not a non-empty string, does not
169
+ exist, or is not a regular file.
170
+ """
171
+ if not isinstance(trace_path, str):
172
+ raise AnalysisError(
173
+ f"trace_path must be a string, got {type(trace_path).__name__}"
174
+ )
175
+ text = trace_path.strip()
176
+ if not text:
177
+ raise AnalysisError("trace_path must not be empty")
178
+ candidate = Path(text).expanduser()
179
+ if not candidate.exists():
180
+ raise AnalysisError(f"trace file not found: {trace_path}")
181
+ if not candidate.is_file():
182
+ raise AnalysisError(f"not a regular trace file: {trace_path}")
183
+ return candidate
184
+
185
+
186
+ def _read_trace_text(source: Path) -> tuple[str, bool]:
187
+ """Read a saved trace, bounded to :data:`MAX_TRACE_BYTES`.
188
+
189
+ The cut deliberately never lands mid-line: when the byte budget ends the
190
+ read, everything after the last newline is dropped, so a bounded read cannot
191
+ invent a malformed ``HIT`` line or a half JSON message that would then show
192
+ up as a bogus warning.
193
+
194
+ Args:
195
+ source: Trace file already validated by :func:`_validate_trace_path`.
196
+
197
+ Returns:
198
+ ``(text, byte_cut)``: the decoded capture (invalid UTF-8 replaced, never
199
+ raised) and whether the byte budget stopped the read early.
200
+
201
+ Raises:
202
+ AnalysisError: If the file cannot be opened or read.
203
+ """
204
+ try:
205
+ with source.open("rb") as handle:
206
+ raw = handle.read(MAX_TRACE_BYTES + 1)
207
+ except OSError as exc:
208
+ raise AnalysisError(f"cannot read trace file {str(source)!r}: {exc}") from exc
209
+ byte_cut = len(raw) > MAX_TRACE_BYTES
210
+ if byte_cut:
211
+ raw = raw[:MAX_TRACE_BYTES]
212
+ newline = raw.rfind(b"\n")
213
+ if newline >= 0:
214
+ raw = raw[: newline + 1]
215
+ return raw.decode("utf-8", errors="replace"), byte_cut
216
+
217
+
218
+ def _detect_trace_provider(text: str) -> str | None:
219
+ """Return which provider wrote ``text``, or ``None`` when nothing matches.
220
+
221
+ Detection reads the file shape, not a flag: a line that parses as a JSON
222
+ object is one of the messages the Frida hook sends, and a line opening with
223
+ ``HIT``/``BACKTRACE``/``LIMIT``/``DONE`` is one the gdb script prints. Only
224
+ the first :data:`TRACE_DETECT_LINES` lines are inspected, so a large capture
225
+ costs no more than a small one, and an unrecognisable file returns ``None``
226
+ instead of being guessed onto whichever provider has more markers.
227
+
228
+ This is the same rule ``codevanta trace --provider auto`` applies, restated
229
+ here rather than imported because the MCP layer must not depend on the CLI.
230
+
231
+ Args:
232
+ text: The captured trace.
233
+
234
+ Returns:
235
+ ``"gdb"``, ``"frida"``, or ``None`` when neither shape is recognised.
236
+ """
237
+ markers = ("HIT ", "BACKTRACE", "LIMIT ", "DONE ")
238
+ for line in text.splitlines()[:TRACE_DETECT_LINES]:
239
+ stripped = line.strip()
240
+ if not stripped:
241
+ continue
242
+ if stripped.startswith("{"):
243
+ try:
244
+ message = json.loads(stripped)
245
+ except ValueError:
246
+ continue
247
+ if isinstance(message, dict):
248
+ return "frida"
249
+ continue
250
+ if stripped.startswith(markers):
251
+ return "gdb"
252
+ return None
253
+
254
+
255
+ def _resolve_session_db(db: str | Path | None) -> Path:
256
+ """Return ``db``, else ``$CODEVANTA_DB``, else ``~/.codevanta/sessions.db``."""
257
+ if db is not None:
258
+ if isinstance(db, Path):
259
+ return db
260
+ if not isinstance(db, str):
261
+ raise SessionError(
262
+ f"db must be a string or Path, not {type(db).__name__}"
263
+ )
264
+ text = db.strip()
265
+ if not text:
266
+ raise SessionError("db must not be empty")
267
+ return Path(text).expanduser()
268
+ configured = os.environ.get(SESSION_DB_ENV)
269
+ if configured and configured.strip():
270
+ return Path(configured.strip()).expanduser()
271
+ return DEFAULT_SESSION_DB
272
+
273
+
274
+ def binary_info(path: str) -> dict[str, Any]:
275
+ """Return format, architecture, sections, imports, exports, and symbols."""
276
+ return load_binary(_validate_path(path)).to_dict()
277
+
278
+
279
+ def functions(path: str, limit: int = 200) -> dict[str, Any]:
280
+ """Return function candidates with detection source and confidence."""
281
+ if limit < 1 or limit > 100_000:
282
+ raise AnalysisError("limit must be between 1 and 100000")
283
+ info = load_binary(_validate_path(path))
284
+ candidates = discover_functions(info)
285
+ return {
286
+ "count": len(candidates),
287
+ "returned": min(limit, len(candidates)),
288
+ "truncated": len(candidates) > limit,
289
+ "items": [candidate.to_dict() for candidate in candidates[:limit]],
290
+ }
291
+
292
+
293
+ def strings(path: str, min_length: int = 4, limit: int = 200) -> dict[str, Any]:
294
+ """Return ASCII and UTF-16LE strings with file offset and mapped VA."""
295
+ if min_length < 1:
296
+ raise AnalysisError("min_length must be >= 1")
297
+ if limit < 1 or limit > MAX_STRING_LIMIT:
298
+ raise AnalysisError(f"limit must be between 1 and {MAX_STRING_LIMIT}")
299
+ target = _validate_path(path)
300
+ info = load_binary(target)
301
+ results = StringExtractor(min_length=min_length).extract(target, info=info, limit=limit)
302
+ return {
303
+ "count": len(results),
304
+ "limit": limit,
305
+ "items": [item.to_dict() for item in results],
306
+ }
307
+
308
+
309
+ def disasm(
310
+ path: str,
311
+ address: int | None = None,
312
+ function: str | None = None,
313
+ count: int = 50,
314
+ ) -> dict[str, Any]:
315
+ """Disassemble the entry point, a virtual address, or a named function."""
316
+ if count < 1 or count > MAX_DISASSEMBLY_COUNT:
317
+ raise AnalysisError(f"count must be between 1 and {MAX_DISASSEMBLY_COUNT}")
318
+ target = _validate_path(path)
319
+ info = load_binary(target)
320
+ start = resolve_target(info, address, function)
321
+ result = disassemble_region(target, info, start, count=count)
322
+ return {
323
+ "start": f"0x{start:x}",
324
+ "count": len(result.instructions),
325
+ "truncated": result.truncated,
326
+ "instructions": [instruction.to_dict() for instruction in result.instructions],
327
+ "evidence": result.evidence.to_dict(),
328
+ }
329
+
330
+
331
+ def analyze(
332
+ path: str,
333
+ string_limit: int = 5000,
334
+ include_xrefs: bool = False,
335
+ include_cfg: bool = False,
336
+ include_callgraph: bool = False,
337
+ ) -> dict[str, Any]:
338
+ """Return the analysis report; phase 2 sections are opt-in per flag.
339
+
340
+ Mirrors the CLI ``analyze`` flags: the base report always carries binary
341
+ metadata, functions, strings, entry-point disassembly, and provenance;
342
+ ``include_xrefs`` / ``include_cfg`` / ``include_callgraph`` add the phase 2
343
+ sections agents would otherwise fetch through the dedicated tools.
344
+ """
345
+ return build_report(
346
+ _validate_path(path),
347
+ string_limit=string_limit,
348
+ include_xrefs=include_xrefs,
349
+ include_cfg=include_cfg,
350
+ include_callgraph=include_callgraph,
351
+ )
352
+
353
+
354
+ def entropy(
355
+ path: str,
356
+ threshold: float = PACKED_HINT_THRESHOLD,
357
+ max_sample_bytes: int = DEFAULT_MAX_SAMPLE_BYTES,
358
+ ) -> dict[str, Any]:
359
+ """Return per-section Shannon entropy with packing triage hints."""
360
+ if not 0.0 < threshold <= 8.0:
361
+ raise AnalysisError("threshold must be in (0, 8]")
362
+ if max_sample_bytes < 1 or max_sample_bytes > MAX_SAMPLE_LIMIT:
363
+ raise AnalysisError(f"max_sample_bytes must be between 1 and {MAX_SAMPLE_LIMIT}")
364
+ target = _validate_path(path)
365
+ info = load_binary(target)
366
+ return analyze_entropy(
367
+ target, info, threshold=threshold, max_sample_bytes=max_sample_bytes
368
+ ).to_dict()
369
+
370
+
371
+ def xrefs(
372
+ path: str,
373
+ limit: int = 200,
374
+ max_bytes: int = DEFAULT_MAX_BYTES,
375
+ ) -> dict[str, Any]:
376
+ """Return direct call, jump, and branch cross references with evidence."""
377
+ if limit < 1 or limit > MAX_XREF_LIMIT:
378
+ raise AnalysisError(f"limit must be between 1 and {MAX_XREF_LIMIT}")
379
+ if max_bytes < 1 or max_bytes > MAX_XREF_BYTES:
380
+ raise AnalysisError(f"max_bytes must be between 1 and {MAX_XREF_BYTES}")
381
+ target = _validate_path(path)
382
+ info = load_binary(target)
383
+ result = find_xrefs(target, info, max_bytes=max_bytes)
384
+ total = len(result.xrefs)
385
+ items = [xref.to_dict() for xref in result.xrefs[:limit]]
386
+ return {
387
+ "count": total,
388
+ "returned": len(items),
389
+ "truncated": result.truncated or total > limit,
390
+ "items": items,
391
+ "scanned_bytes": result.scanned_bytes,
392
+ "warnings": list(result.warnings),
393
+ }
394
+
395
+
396
+ def cfg(
397
+ path: str,
398
+ address: int | None = None,
399
+ function: str | None = None,
400
+ max_instructions: int = 2000,
401
+ max_blocks: int = 500,
402
+ ) -> dict[str, Any]:
403
+ """Build a linear-sweep control-flow graph for an address or named function."""
404
+ if max_instructions < 1 or max_instructions > MAX_DISASSEMBLY_COUNT:
405
+ raise AnalysisError(
406
+ f"max_instructions must be between 1 and {MAX_DISASSEMBLY_COUNT}"
407
+ )
408
+ if max_blocks < 1 or max_blocks > MAX_DISASSEMBLY_COUNT:
409
+ raise AnalysisError(f"max_blocks must be between 1 and {MAX_DISASSEMBLY_COUNT}")
410
+ target = _validate_path(path)
411
+ info = load_binary(target)
412
+ start = resolve_target(info, address, function)
413
+ graph = build_cfg(
414
+ target,
415
+ info,
416
+ start,
417
+ max_instructions=max_instructions,
418
+ max_blocks=max_blocks,
419
+ )
420
+ return {
421
+ "start": f"0x{start:x}",
422
+ "blocks": [block.to_dict() for block in graph.blocks],
423
+ "instruction_count": graph.instruction_count,
424
+ "truncated": graph.truncated,
425
+ "warnings": list(graph.warnings),
426
+ }
427
+
428
+
429
+ def _rendered_block_count(result: BackendResult) -> int | None:
430
+ """Return how many basic blocks the rendering covered, when it says so.
431
+
432
+ The external backends record their block count in ``evidence.extra`` because
433
+ their C output has no header. The native renderer instead publishes it in the
434
+ pseudo-C header comment (`` * blocks: N instructions: M``), the same comment
435
+ block :mod:`codevanta.analysis.decompilers.base` scans for warnings, so the
436
+ count is read from there. ``None`` means the backend reported no coverage.
437
+ """
438
+ recorded = result.evidence.extra.get("block_count")
439
+ if isinstance(recorded, int) and not isinstance(recorded, bool):
440
+ return recorded
441
+ for line in result.text.splitlines():
442
+ if line == " */":
443
+ break
444
+ if not line.startswith(_HEADER_BLOCKS_PREFIX):
445
+ continue
446
+ field = line[len(_HEADER_BLOCKS_PREFIX) :].split(" ", 1)[0]
447
+ if field.isdigit():
448
+ return int(field)
449
+ return None
450
+
451
+
452
+ def decompile(
453
+ path: str,
454
+ address: int,
455
+ backend: str = AUTO_BACKEND,
456
+ max_instructions: int = DEFAULT_DECOMPILE_MAX_INSTRUCTIONS,
457
+ max_blocks: int = DEFAULT_DECOMPILE_MAX_BLOCKS,
458
+ ) -> dict[str, Any]:
459
+ """Render one function as C-like text with the requested decompiler backend.
460
+
461
+ ``backend="auto"`` (the default) prefers a real decompiler -- Ghidra, then
462
+ angr -- when one is installed and falls back to the built-in mnemonic-level
463
+ pseudo-C renderer, recording every skipped backend in ``warnings``, so a
464
+ fallback is never silent. ``backend="native"`` always runs; ``"angr"`` and
465
+ ``"ghidra"`` are strict: when that backend is unavailable the call raises
466
+ instead of substituting another provider. A missing optional dependency never
467
+ surfaces as ``ImportError``: it becomes an :class:`AnalysisError` naming the
468
+ backend, the probe's reason and the install hint.
469
+
470
+ Control flow, argument count and types are inferred from mnemonics; nothing
471
+ is mapped or executed, so the text is LOW-confidence evidence rather than a
472
+ recovered source file.
473
+
474
+ Args:
475
+ path: PE or ELF file to read; it is read, never executed.
476
+ address: Virtual address of the function's first instruction, inside an
477
+ executable section.
478
+ backend: ``"auto"`` (default), ``"native"``, ``"angr"`` or ``"ghidra"``;
479
+ matched case-insensitively, and blank means ``"auto"``.
480
+ max_instructions: Instruction budget (1 ..
481
+ :data:`codevanta.analysis.decompile.MAX_MAX_INSTRUCTIONS`).
482
+ max_blocks: Block budget (1 ..
483
+ :data:`codevanta.analysis.decompile.MAX_MAX_BLOCKS`).
484
+
485
+ Returns:
486
+ The backend result -- ``text``, ``backend`` (the provider that actually
487
+ rendered it, which differs from the request under ``"auto"``),
488
+ ``language``, ``warnings`` and ``evidence`` -- plus the resolved
489
+ ``path``, the resolved ``address`` as ``"0x..."``, and ``blocks``, the
490
+ number of basic blocks the text covers.
491
+
492
+ Raises:
493
+ AnalysisError: If the file is missing, ``address`` is not a non-negative
494
+ integer, ``backend`` is not one of :data:`DECOMPILE_BACKENDS`, a
495
+ budget is out of range, the strictly requested backend is
496
+ unavailable (the message names it), or the resolved address is not
497
+ executable code.
498
+ """
499
+ start = _check_address(address)
500
+ if not isinstance(backend, str):
501
+ raise AnalysisError(f"backend must be a string, got {type(backend).__name__}")
502
+ selected = backend.strip().lower() or AUTO_BACKEND
503
+ if selected not in DECOMPILE_BACKENDS:
504
+ raise AnalysisError(
505
+ f"backend must be one of {', '.join(DECOMPILE_BACKENDS)}, got {backend!r}"
506
+ )
507
+ instruction_budget = _check_bounded_int(
508
+ "max_instructions", max_instructions, MAX_DECOMPILE_MAX_INSTRUCTIONS
509
+ )
510
+ block_budget = _check_bounded_int(
511
+ "max_blocks", max_blocks, MAX_DECOMPILE_MAX_BLOCKS
512
+ )
513
+ target = _validate_path(path)
514
+ info = load_binary(target)
515
+ result = decompile_with_backend(
516
+ target,
517
+ info,
518
+ start,
519
+ backend=selected,
520
+ max_instructions=instruction_budget,
521
+ max_blocks=block_budget,
522
+ )
523
+ payload = result.to_dict()
524
+ payload["path"] = str(info.path)
525
+ payload["address"] = f"0x{start:x}"
526
+ payload["blocks"] = _rendered_block_count(result)
527
+ return payload
528
+
529
+
530
+ def _check_callgraph_limit(name: str, value: int) -> None:
531
+ """Raise :class:`AnalysisError` unless ``value`` is a limit in 1..MAX_LIMIT."""
532
+ if isinstance(value, bool) or not isinstance(value, int):
533
+ raise AnalysisError(f"{name} must be an integer")
534
+ if value < 1 or value > MAX_CALLGRAPH_LIMIT:
535
+ raise AnalysisError(f"{name} must be between 1 and {MAX_CALLGRAPH_LIMIT}")
536
+
537
+
538
+ def callgraph(
539
+ path: str,
540
+ limit_functions: int = 1000,
541
+ limit_edges: int = 5000,
542
+ ) -> dict[str, Any]:
543
+ """Build an inter-function call graph from resolved call cross references.
544
+
545
+ Edges come from direct calls only, plus calls through a pointer slot that
546
+ resolves to a known import (the node is labelled with the import name) or
547
+ to a statically known slot with an unknown callee. Jumps, conditional
548
+ branches, and indirect calls whose slot is not known statically produce no
549
+ edges. The caller of a call site is attributed heuristically to the nearest
550
+ function candidate at or below the call address in the same section, so
551
+ caller attribution is not proven.
552
+
553
+ Args:
554
+ path: PE or ELF file to analyse; it is read, never executed.
555
+ limit_functions: Maximum number of function candidates kept as nodes
556
+ (1 .. 200000). Dropping candidates marks the graph truncated.
557
+ limit_edges: Maximum number of deduplicated call edges reported
558
+ (1 .. 200000).
559
+
560
+ Returns:
561
+ The serialized graph with ``nodes``, ``edges``, ``truncated``,
562
+ ``evidence``, and ``warnings``.
563
+
564
+ Raises:
565
+ AnalysisError: If the file is missing or a limit is out of range.
566
+ """
567
+ _check_callgraph_limit("limit_functions", limit_functions)
568
+ _check_callgraph_limit("limit_edges", limit_edges)
569
+ target = _validate_path(path)
570
+ info = load_binary(target)
571
+ graph = build_call_graph(
572
+ target,
573
+ info,
574
+ functions=discover_functions(info),
575
+ max_functions=limit_functions,
576
+ max_edges=limit_edges,
577
+ )
578
+ return graph.to_dict()
579
+
580
+
581
+ def _check_bounded_int(
582
+ name: str,
583
+ value: int,
584
+ maximum: int = MAX_TOOL_LIMIT,
585
+ ) -> int:
586
+ """Return ``value`` when it is a plain integer in ``1 .. maximum``, else raise.
587
+
588
+ Args:
589
+ name: Argument name used in the error message.
590
+ value: Caller-supplied value; booleans are rejected even though ``bool``
591
+ is an ``int`` subclass, because ``True`` is never a valid limit.
592
+ maximum: Inclusive upper bound for ``value``.
593
+
594
+ Returns:
595
+ ``value`` unchanged.
596
+
597
+ Raises:
598
+ AnalysisError: If ``value`` is not a non-boolean ``int``, or if it falls
599
+ outside ``1 .. maximum``.
600
+ """
601
+ if isinstance(value, bool) or not isinstance(value, int):
602
+ raise AnalysisError(f"{name} must be an integer")
603
+ if value < 1 or value > maximum:
604
+ raise AnalysisError(f"{name} must be between 1 and {maximum}")
605
+ return value
606
+
607
+
608
+ def _check_bool(name: str, value: bool) -> bool:
609
+ """Return ``value`` when it is a real ``bool``, else raise.
610
+
611
+ Args:
612
+ name: Argument name used in the error message.
613
+ value: Caller-supplied flag; strings, integers and ``None`` are rejected
614
+ so a truthy value can never silently switch a mode on.
615
+
616
+ Returns:
617
+ ``value`` unchanged.
618
+
619
+ Raises:
620
+ AnalysisError: If ``value`` is not a ``bool``.
621
+ """
622
+ if not isinstance(value, bool):
623
+ raise AnalysisError(f"{name} must be a boolean")
624
+ return value
625
+
626
+
627
+ def _check_threshold(name: str, value: float) -> float:
628
+ """Return ``value`` as a float when it is a finite number in ``0.0 .. 1.0``.
629
+
630
+ Args:
631
+ name: Argument name used in the error message.
632
+ value: Caller-supplied similarity floor; booleans are rejected even
633
+ though ``bool`` is an ``int`` subclass, because ``True`` is not a
634
+ score, and ``NaN``/``inf`` are rejected because they can never be
635
+ compared meaningfully.
636
+
637
+ Returns:
638
+ ``value`` converted to ``float``, unchanged in magnitude.
639
+
640
+ Raises:
641
+ AnalysisError: If ``value`` is not a non-boolean real number, or if it
642
+ is non-finite or outside ``0.0 .. 1.0``.
643
+ """
644
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
645
+ raise AnalysisError(
646
+ f"{name} must be a number between {MIN_SEMANTIC_THRESHOLD} and "
647
+ f"{MAX_SEMANTIC_THRESHOLD}, got {type(value).__name__}"
648
+ )
649
+ number = float(value)
650
+ if not math.isfinite(number) or not (
651
+ MIN_SEMANTIC_THRESHOLD <= number <= MAX_SEMANTIC_THRESHOLD
652
+ ):
653
+ raise AnalysisError(
654
+ f"{name} must be between {MIN_SEMANTIC_THRESHOLD} and "
655
+ f"{MAX_SEMANTIC_THRESHOLD}, got {value!r}"
656
+ )
657
+ return number
658
+
659
+
660
+ def _check_address(address: int) -> int:
661
+ """Return ``address`` when it is a non-negative plain integer, else raise.
662
+
663
+ Args:
664
+ address: Virtual address supplied by the caller.
665
+
666
+ Returns:
667
+ ``address`` unchanged.
668
+
669
+ Raises:
670
+ AnalysisError: If ``address`` is not a non-boolean ``int`` or is negative.
671
+ """
672
+ if isinstance(address, bool) or not isinstance(address, int):
673
+ raise AnalysisError(f"address must be an integer, got {address!r}")
674
+ if address < 0:
675
+ raise AnalysisError(f"address must be non-negative, got {address}")
676
+ return address
677
+
678
+
679
+ def diff(
680
+ left: str,
681
+ right: str,
682
+ max_functions: int = 5000,
683
+ deep: bool = False,
684
+ semantic: bool = False,
685
+ threshold: float = DEFAULT_SEMANTIC_THRESHOLD,
686
+ ) -> dict[str, Any]:
687
+ """Compare two binaries structurally and report per-side deltas.
688
+
689
+ Both files are parsed only; nothing is mapped or executed. Functions are
690
+ matched by virtual address first and, for the leftovers, by name after
691
+ moving. ``identical`` means no function, section, import or export delta was
692
+ observed -- equal file hashes are neither required nor implied.
693
+
694
+ Args:
695
+ left: Path of the left-hand binary; it is read, never executed.
696
+ right: Path of the right-hand binary; it is read, never executed.
697
+ max_functions: Per-side ceiling on the compared function candidates
698
+ (1 .. 20000). Any truncation is reported in ``warnings``.
699
+ deep: When true, additionally fingerprint the normalized instruction
700
+ shape of address-matched functions and mark differing digests as
701
+ ``changed``. Digests are shape fingerprints: equal is not byte
702
+ equality and different is not a semantic difference.
703
+ semantic: When true, additionally score the functions that address and
704
+ name matching left unpaired. The best pairs whose normalized
705
+ instruction shapes overlap at or above ``threshold`` become one
706
+ ``changed`` entry carrying ``similarity``, so a moved function can
707
+ be reported as moved instead of removed-plus-added. A score is shape
708
+ evidence, not proof that the two functions are the same code.
709
+ threshold: Smallest similarity score in ``0.0 .. 1.0`` that may pair two
710
+ leftovers (default ``0.9``). It is validated in every mode, so an
711
+ out-of-range value raises even when ``semantic`` is false.
712
+
713
+ Returns:
714
+ The serialized comparison with ``left_path``, ``right_path``,
715
+ ``identical``, the added/removed/changed function and section lists, the
716
+ import and export deltas, ``evidence``, and ``warnings``. With
717
+ ``semantic`` true the evidence ``extra`` record also carries the
718
+ effective ``semantic_threshold``.
719
+
720
+ Raises:
721
+ AnalysisError: If either file is missing, ``max_functions`` is out of
722
+ range, ``deep`` or ``semantic`` is not a boolean, or ``threshold``
723
+ is not a finite number within ``0.0 .. 1.0``.
724
+ """
725
+ left_path = _validate_path(left)
726
+ right_path = _validate_path(right)
727
+ limit = _check_bounded_int("max_functions", max_functions)
728
+ deep_flag = _check_bool("deep", deep)
729
+ semantic_flag = _check_bool("semantic", semantic)
730
+ # Validate the floor even in structural mode so the argument contract does
731
+ # not depend on which mode was selected, and so a bad score cannot cost two
732
+ # full loads.
733
+ score_floor = _check_threshold("threshold", threshold)
734
+ comparison = diff_binaries(
735
+ left_path,
736
+ right_path,
737
+ max_functions=limit,
738
+ deep=deep_flag,
739
+ semantic=semantic_flag,
740
+ threshold=score_floor,
741
+ )
742
+ return comparison.to_dict()
743
+
744
+
745
+ def jump_table(
746
+ path: str,
747
+ address: int,
748
+ mode: str = "absolute",
749
+ base: int | None = None,
750
+ entries: int = 256,
751
+ executable_only: bool = True,
752
+ ) -> dict[str, Any]:
753
+ """Decode a jump table at an absolute virtual address.
754
+
755
+ ``mode="absolute"`` reads consecutive pointer-sized virtual addresses;
756
+ ``mode="relative"`` reads consecutive signed 32-bit offsets and resolves each
757
+ one as ``base + offset``. In both layouts the scan stops at the first slot
758
+ that cannot be a usable target and records why in ``warnings``: a target no
759
+ section maps, a target outside an executable section when
760
+ ``executable_only`` is true, or -- in absolute mode only -- a zero pointer,
761
+ the conventional terminator. A zero offset in relative mode is a real delta,
762
+ so it terminates nothing.
763
+
764
+ Entries carry medium confidence: a run of same-width slots found by shape is
765
+ evidence of a dispatch table, not proof that a dispatcher indexes it.
766
+
767
+ Args:
768
+ path: PE or ELF file to read; it is read, never executed.
769
+ address: Absolute virtual address of the first slot, which must be
770
+ backed by file bytes of a mapped section.
771
+ mode: ``"absolute"`` (default) or ``"relative"``, case-insensitive.
772
+ base: Origin the signed offsets are added to. Required for
773
+ ``"relative"`` and ignored for ``"absolute"``.
774
+ entries: Maximum number of slots to decode (1 .. 20000, further capped by
775
+ the resolver ceiling
776
+ :data:`codevanta.analysis.jump_tables.MAX_ENTRIES`).
777
+ executable_only: When true (default), a target inside a non-executable
778
+ section ends the scan with a warning.
779
+
780
+ Returns:
781
+ The serialized table with ``table_address``, ``entry_count``,
782
+ ``entries``, ``pointer_size``, ``truncated``, ``warnings``, and
783
+ ``evidence``.
784
+
785
+ Raises:
786
+ AnalysisError: If the file is missing, ``address`` or ``base`` is not a
787
+ non-negative integer, ``mode`` is unknown, ``entries`` is out of
788
+ range, or ``executable_only`` is not a boolean.
789
+ """
790
+ table_address = _check_address(address)
791
+ if not isinstance(mode, str):
792
+ raise AnalysisError(f"mode must be a string, got {type(mode).__name__}")
793
+ normalized = mode.strip().lower()
794
+ if normalized not in JUMP_TABLE_MODES:
795
+ raise AnalysisError(
796
+ f"mode must be one of {', '.join(JUMP_TABLE_MODES)}, got {mode!r}"
797
+ )
798
+ base_address: int | None = None
799
+ if normalized == MODE_RELATIVE:
800
+ if base is None:
801
+ raise AnalysisError("base is required when mode is 'relative'")
802
+ base_address = _check_address(base)
803
+ limit = _check_bounded_int("entries", entries)
804
+ if limit > MAX_JUMP_TABLE_ENTRIES:
805
+ raise AnalysisError(f"entries too large (limit {MAX_JUMP_TABLE_ENTRIES})")
806
+ flag = _check_bool("executable_only", executable_only)
807
+ target = _validate_path(path)
808
+ info = load_binary(target)
809
+ result = resolve_jump_table(
810
+ target,
811
+ info,
812
+ table_address,
813
+ max_entries=limit,
814
+ executable_only=flag,
815
+ mode=normalized,
816
+ base_address=base_address,
817
+ )
818
+ return result.to_dict()
819
+
820
+
821
+ def function_hash(
822
+ path: str,
823
+ address: int,
824
+ max_instructions: int = 2000,
825
+ ) -> dict[str, Any]:
826
+ """Fingerprint the normalized instruction shape of a function region.
827
+
828
+ The region is decoded with a bounded linear sweep from ``address`` and each
829
+ instruction is rendered as ``"<mnemonic> <operands>"`` with every numeric
830
+ literal masked to ``#``. The masked stream is hashed with SHA-256, so two
831
+ functions with the same mnemonic/operand structure share a digest even when
832
+ their immediates, branch targets or frame offsets differ.
833
+
834
+ Equal digests are therefore **not** byte equality, and different digests are
835
+ **not** a semantic difference. ``truncated`` marks a digest that covers only
836
+ a prefix of the region.
837
+
838
+ Args:
839
+ path: PE or ELF file to read; it is read, never executed.
840
+ address: Virtual address of the first instruction, inside an executable
841
+ section.
842
+ max_instructions: Instruction budget (1 .. 20000); the sweep reads at
843
+ most ``max_instructions * 16`` file bytes.
844
+
845
+ Returns:
846
+ The serialized fingerprint with ``address``, ``algorithm``, ``digest``,
847
+ ``instruction_count``, ``truncated``, and ``evidence``.
848
+
849
+ Raises:
850
+ AnalysisError: If the file is missing, ``address`` is not a non-negative
851
+ integer or is not inside an executable section, or
852
+ ``max_instructions`` is out of range.
853
+ """
854
+ start = _check_address(address)
855
+ limit = _check_bounded_int("max_instructions", max_instructions)
856
+ target = _validate_path(path)
857
+ info = load_binary(target)
858
+ fingerprint = function_fingerprint(
859
+ target,
860
+ info,
861
+ start,
862
+ max_instructions=limit,
863
+ )
864
+ return fingerprint.to_dict()
865
+
866
+
867
+ def _check_side_address(side: str, address: int) -> int:
868
+ """Return ``address`` when it is a non-negative plain integer, else raise.
869
+
870
+ Args:
871
+ side: Argument name (``address_a`` or ``address_b``) used in the error
872
+ message, so a two-address tool names the offending side.
873
+ address: Virtual address supplied by the caller.
874
+
875
+ Returns:
876
+ ``address`` unchanged.
877
+
878
+ Raises:
879
+ AnalysisError: If ``address`` is not a non-boolean ``int`` or is negative.
880
+ """
881
+ if isinstance(address, bool) or not isinstance(address, int):
882
+ raise AnalysisError(f"{side} must be an integer, got {address!r}")
883
+ if address < 0:
884
+ raise AnalysisError(f"{side} must be non-negative, got {address}")
885
+ return address
886
+
887
+
888
+ def similarity(
889
+ path_a: str,
890
+ address_a: int,
891
+ path_b: str,
892
+ address_b: int,
893
+ max_instructions: int = 2000,
894
+ ) -> dict[str, Any]:
895
+ """Compare two function regions by normalized instruction *shape*.
896
+
897
+ Each region is decoded with the same bounded linear sweep as
898
+ :func:`codevanta.mcp_server.tools.function_hash` and its instructions are
899
+ normalized to ``"<mnemonic> <masked operands>"``, with every numeric literal
900
+ masked to ``#``. The two token streams are compared as multisets::
901
+
902
+ shared = sum((left & right).values()) # sum(min), shared occurrences
903
+ union = sum((left | right).values()) # sum(max), union size
904
+ score = shared / union
905
+
906
+ The score is therefore in ``[0, 1]``: ``1.0`` means both sides share every
907
+ token with the same multiplicity and ``0.0`` means they share none.
908
+ Multiplicity matters (``nop; nop; ret`` is only partially similar to
909
+ ``nop; ret``). Because immediates, frame offsets and branch targets are
910
+ masked, a relocated or recompiled copy of a function usually scores
911
+ ``1.0`` -- and structurally similar but semantically unrelated code can too.
912
+
913
+ The score is **shape evidence at medium confidence, never semantic
914
+ equivalence**: it says nothing about what the code does, and a genuine
915
+ rewrite scores low.
916
+
917
+ Args:
918
+ path_a: Left-hand PE or ELF file; it is read, never executed.
919
+ address_a: Virtual address of the left region's first instruction,
920
+ inside an executable section.
921
+ path_b: Right-hand PE or ELF file; it is read, never executed.
922
+ address_b: Virtual address of the right region's first instruction,
923
+ inside an executable section.
924
+ max_instructions: Per-side instruction budget (1 .. 20000); each sweep
925
+ reads at most ``max_instructions * 16`` file bytes.
926
+
927
+ Returns:
928
+ The serialized comparison with ``score``, ``shared``, ``left_total``,
929
+ ``right_total``, ``union``, ``warnings``, and ``evidence``. A warning
930
+ means that side was truncated, so only prefixes were compared.
931
+
932
+ Raises:
933
+ AnalysisError: If either file is missing, either address is not a
934
+ non-negative integer or is not inside an executable section, or
935
+ ``max_instructions`` is out of range.
936
+ """
937
+ start_a = _check_side_address("address_a", address_a)
938
+ start_b = _check_side_address("address_b", address_b)
939
+ limit = _check_bounded_int("max_instructions", max_instructions)
940
+ target_a = _validate_path(path_a)
941
+ target_b = _validate_path(path_b)
942
+ info_a = load_binary(target_a)
943
+ info_b = load_binary(target_b)
944
+ comparison = function_similarity(
945
+ target_a,
946
+ info_a,
947
+ start_a,
948
+ target_b,
949
+ info_b,
950
+ start_b,
951
+ max_instructions=limit,
952
+ )
953
+ return comparison.to_dict()
954
+
955
+
956
+ def dwarf(
957
+ path: str,
958
+ limit: int = DEFAULT_DWARF_LIMIT,
959
+ ) -> dict[str, Any]:
960
+ """Extract DWARF debug-info functions and named types with evidence."""
961
+ if limit < 1 or limit > MAX_DWARF_LIMIT:
962
+ raise AnalysisError(f"limit must be between 1 and {MAX_DWARF_LIMIT}")
963
+ target = _validate_path(path)
964
+ info = load_binary(target)
965
+ return extract_debug_info(target, info, limit=limit).to_dict()
966
+
967
+
968
+ def vtable(
969
+ path: str,
970
+ address: int | None = None,
971
+ scan: bool = False,
972
+ min_slots: int = DEFAULT_MIN_SLOTS,
973
+ max_slots: int = DEFAULT_MAX_SLOTS,
974
+ max_tables: int = DEFAULT_MAX_TABLES,
975
+ resolve_thunks: bool = True,
976
+ ) -> dict[str, Any]:
977
+ """Discover vtable candidates and resolve thunk chains (indirect xrefs).
978
+
979
+ ``address`` decodes one candidate at an exact vtable address (the optional
980
+ two-slot Itanium header is skipped automatically); ``scan`` sweeps every
981
+ non-executable data section for runs of consecutive code pointers. Slot
982
+ targets that are one-instruction tail-call thunks report their final
983
+ callee as ``resolved_target``. Every candidate is a heuristic with
984
+ ``vtable.candidate`` / ``vtable.slot_ref`` evidence at medium confidence.
985
+ """
986
+ if scan and address is not None:
987
+ raise AnalysisError("use either address or scan, not both")
988
+ if address is None and not scan:
989
+ raise AnalysisError("give address or scan=True")
990
+ if min_slots < 1 or min_slots > 64:
991
+ raise AnalysisError("min_slots must be between 1 and 64")
992
+ if max_slots < min_slots or max_slots > 4_096:
993
+ raise AnalysisError("max_slots must be between min_slots and 4096")
994
+ if max_tables < 1 or max_tables > 1024:
995
+ raise AnalysisError("max_tables must be between 1 and 1024")
996
+ target = _validate_path(path)
997
+ info = load_binary(target)
998
+ return find_vtables(
999
+ target,
1000
+ info,
1001
+ address=address,
1002
+ min_slots=min_slots,
1003
+ max_slots=max_slots,
1004
+ max_tables=max_tables,
1005
+ resolve_thunks=resolve_thunks,
1006
+ ).to_dict()
1007
+
1008
+
1009
+ def struct(
1010
+ path: str,
1011
+ address: int | None = None,
1012
+ function: str | None = None,
1013
+ max_instructions: int = DEFAULT_MAX_INSTRUCTIONS,
1014
+ max_fields: int = DEFAULT_MAX_FIELDS,
1015
+ include_stack: bool = False,
1016
+ ) -> dict[str, Any]:
1017
+ """Suggest struct fields from the memory operands of a function region.
1018
+
1019
+ The region is decoded with a bounded linear sweep from the resolved start and
1020
+ every memory operand of the form ``[reg]``, ``[reg + 0xNN]`` or
1021
+ ``[reg - 0xNN]`` is read: the displacement becomes a field offset, the Intel
1022
+ size keyword on the operand (falling back to the pointer width) becomes the
1023
+ field width, and the operand position decides read versus write -- the first
1024
+ operand is a write, except for ``cmp``/``test``/``cmps*``, which are always
1025
+ reads. Operands with an index register, a scale or a segment prefix are
1026
+ skipped rather than guessed, and ``lea``/``nop`` carry no memory access.
1027
+
1028
+ Accesses are aggregated per offset: the dominant width is the most frequent
1029
+ one (ties go to the wider access) and the read/write counts are totals for
1030
+ the offset. Fields are returned sorted by ascending offset, so the result is
1031
+ deterministic for a given input. ``alignment`` is the gcd of the absolute
1032
+ non-zero offsets (``None`` with fewer than two distinct non-zero offsets) and
1033
+ ``array_groups`` lists maximal runs of at least three equally wide, equally
1034
+ spaced fields, computed from the fields actually reported.
1035
+
1036
+ Every field is a **heuristic** at ``Confidence.MEDIUM``: a hypothesis about
1037
+ how one function treats memory around one base register, never a verified
1038
+ type, and it says nothing about signedness, purpose or lifetime. Fields are
1039
+ not proven to belong to a single struct when several base registers are in
1040
+ play; each entry names its own ``base_register``.
1041
+
1042
+ Args:
1043
+ path: PE or ELF file to read; it is read, never executed.
1044
+ address: Virtual address of the first instruction, inside an executable
1045
+ section. Takes precedence over ``function``.
1046
+ function: Name of a detected function candidate to start from; used only
1047
+ when ``address`` is ``None``.
1048
+ max_instructions: Instruction budget (1 ..
1049
+ :data:`codevanta.analysis.type_recovery.MAX_MAX_INSTRUCTIONS`); the
1050
+ sweep reads at most ``max_instructions * 16`` file bytes.
1051
+ max_fields: Field cap (1 ..
1052
+ :data:`codevanta.analysis.type_recovery.MAX_MAX_FIELDS`). Dropped
1053
+ offsets set ``truncated``.
1054
+ include_stack: When true, ``rbp``/``rsp``-relative operands are reported
1055
+ alongside register-based ones; when false (default) they are ignored
1056
+ as stack slots.
1057
+
1058
+ Returns:
1059
+ The serialized suggestion with ``address``, ``instruction_count``,
1060
+ ``field_count``, ``max_fields``, ``truncated``, ``include_stack``,
1061
+ ``fields``, ``alignment``, ``array_groups``, and ``evidence``.
1062
+
1063
+ Raises:
1064
+ AnalysisError: If the file is missing, ``address`` is not a non-negative
1065
+ integer, ``function`` is not a string or names no candidate, a limit
1066
+ is out of range, ``include_stack`` is not a boolean, or the resolved
1067
+ start is not inside an executable section.
1068
+ """
1069
+ start_address: int | None = None
1070
+ if address is not None:
1071
+ start_address = _check_address(address)
1072
+ if function is not None and not isinstance(function, str):
1073
+ raise AnalysisError(
1074
+ f"function must be a string, got {type(function).__name__}"
1075
+ )
1076
+ instruction_budget = _check_bounded_int(
1077
+ "max_instructions", max_instructions, MAX_MAX_INSTRUCTIONS
1078
+ )
1079
+ field_cap = _check_bounded_int("max_fields", max_fields, MAX_MAX_FIELDS)
1080
+ stack_flag = _check_bool("include_stack", include_stack)
1081
+ target = _validate_path(path)
1082
+ info = load_binary(target)
1083
+ start = resolve_target(info, start_address, function)
1084
+ suggestion = suggest_struct(
1085
+ target,
1086
+ info,
1087
+ start,
1088
+ max_instructions=instruction_budget,
1089
+ max_fields=field_cap,
1090
+ include_stack=stack_flag,
1091
+ )
1092
+ return suggestion.to_dict()
1093
+
1094
+
1095
+ def trace(
1096
+ path: str,
1097
+ trace_path: str,
1098
+ provider: str = AUTO_TRACE_PROVIDER,
1099
+ max_hits: int | None = None,
1100
+ ) -> dict[str, Any]:
1101
+ """Replay a saved dynamic-session trace and map its hits onto functions.
1102
+
1103
+ Nothing is launched: ``trace_path`` is what a generated gdb or Frida session
1104
+ script already printed, so one capture can be re-read offline as often as
1105
+ needed and a session never has to be repeated to re-analyse it. The binary and
1106
+ the capture are read, never executed.
1107
+
1108
+ ``provider`` selects the format. ``auto`` (the default) reads it off the file
1109
+ shape -- a JSON object line is a Frida message, a ``HIT``/``BACKTRACE``/
1110
+ ``LIMIT``/``DONE`` line is gdb -- and the choice comes back as
1111
+ ``provider_resolved``. A capture that cannot be placed is an error rather
1112
+ than a guess, and so is an explicit ``provider`` that contradicts the file.
1113
+
1114
+ Every hit is attributed to the :func:`discover_functions` candidate that
1115
+ encloses its address; a hit outside every candidate stays unattributed
1116
+ instead of being guessed onto the nearest symbol. Hits are evidence of one
1117
+ run, not proof that the branch executes in production.
1118
+
1119
+ Work is bounded: at most :data:`MAX_TRACE_BYTES` bytes are read,
1120
+ :data:`codevanta.analysis.trace.MAX_TRACE_LINES` lines are parsed, and at
1121
+ most ``max_hits`` hits are kept. A cut of any kind sets ``truncated`` and is
1122
+ explained in ``warnings``; malformed lines become warnings too and never
1123
+ fail the call.
1124
+
1125
+ Args:
1126
+ path: PE or ELF file the session ran against.
1127
+ trace_path: Saved gdb output or Frida JSON-lines capture.
1128
+ provider: ``"auto"``, ``"gdb"`` or ``"frida"``.
1129
+ max_hits: Hit budget (1 ..
1130
+ :data:`codevanta.analysis.trace.MAX_TRACE_HITS`) or ``None`` for the
1131
+ parser default. Hits past it are counted in ``total_hits`` but not
1132
+ kept, and ``truncated`` is set.
1133
+
1134
+ Returns:
1135
+ ``TraceReport.to_dict()`` -- ``provider``, ``hits``, ``total_hits``,
1136
+ ``truncated``, ``unique_addresses``, ``function_coverage``, ``warnings``
1137
+ and ``evidence`` -- plus ``path``, ``trace_path`` and
1138
+ ``provider_resolved``.
1139
+
1140
+ Raises:
1141
+ AnalysisError: If a path is missing or unreadable, the trace file is not
1142
+ a regular file, ``provider`` is not one of :data:`TRACE_PROVIDERS`,
1143
+ the provider cannot be resolved, the capture contradicts the
1144
+ requested provider, or ``max_hits`` is out of range.
1145
+ """
1146
+ if provider not in TRACE_PROVIDERS:
1147
+ raise AnalysisError(
1148
+ f"provider must be one of {', '.join(TRACE_PROVIDERS)}, got {provider!r}"
1149
+ )
1150
+ hit_limit = (
1151
+ None
1152
+ if max_hits is None
1153
+ else _check_bounded_int("max_hits", max_hits, MAX_TRACE_HITS)
1154
+ )
1155
+ target = _validate_path(path)
1156
+ source = _validate_trace_path(trace_path)
1157
+ text, byte_cut = _read_trace_text(source)
1158
+
1159
+ detected = _detect_trace_provider(text)
1160
+ if provider == AUTO_TRACE_PROVIDER:
1161
+ if detected is None:
1162
+ raise AnalysisError(
1163
+ f"cannot tell whether {str(source)!r} is a gdb or a frida trace; "
1164
+ "pass provider='gdb' or provider='frida'"
1165
+ )
1166
+ resolved = detected
1167
+ elif detected is not None and detected != provider:
1168
+ raise AnalysisError(
1169
+ f"provider mismatch: {str(source)!r} is a {detected} trace, not {provider}"
1170
+ )
1171
+ else:
1172
+ resolved = provider
1173
+
1174
+ info = load_binary(target)
1175
+ functions = discover_functions(info)
1176
+ if resolved == "gdb":
1177
+ report = parse_gdb_trace(
1178
+ text,
1179
+ info=info,
1180
+ functions=functions,
1181
+ max_lines=MAX_TRACE_LINES,
1182
+ max_hits=hit_limit,
1183
+ )
1184
+ else:
1185
+ report = parse_frida_trace(
1186
+ text,
1187
+ info=info,
1188
+ functions=functions,
1189
+ max_lines=MAX_TRACE_LINES,
1190
+ max_hits=hit_limit,
1191
+ )
1192
+
1193
+ payload = report.to_dict()
1194
+ if byte_cut:
1195
+ payload["truncated"] = True
1196
+ payload["warnings"] = [
1197
+ *payload["warnings"],
1198
+ f"trace truncated to the first {MAX_TRACE_BYTES} byte(s)",
1199
+ ]
1200
+ payload["path"] = str(target)
1201
+ payload["trace_path"] = str(source)
1202
+ payload["provider_resolved"] = resolved
1203
+ return payload
1204
+
1205
+
1206
+ def session_save(
1207
+ path: str,
1208
+ db: str | None = None,
1209
+ notes: str | None = None,
1210
+ ) -> dict[str, Any]:
1211
+ """Analyze a binary and persist its fingerprint and counts as a session."""
1212
+ target = _validate_path(path)
1213
+ db_path = _resolve_session_db(db)
1214
+ info = load_binary(target)
1215
+ functions_count = len(discover_functions(info))
1216
+ strings_count = len(StringExtractor(min_length=4).extract(target, info=info))
1217
+ with SessionStore(db_path) as store:
1218
+ record = store.save(info, functions_count, strings_count, notes=notes)
1219
+ return {"session": record.to_dict(), "db": str(db_path)}
1220
+
1221
+
1222
+ def session_list(db: str | None = None, limit: int = 20) -> dict[str, Any]:
1223
+ """List stored sessions newest first from the session database."""
1224
+ if isinstance(limit, bool) or not isinstance(limit, int):
1225
+ raise SessionError("limit must be an integer")
1226
+ if limit < 1 or limit > MAX_SESSION_LIMIT:
1227
+ raise SessionError(f"limit must be between 1 and {MAX_SESSION_LIMIT}")
1228
+ db_path = _resolve_session_db(db)
1229
+ with SessionStore(db_path) as store:
1230
+ records = store.list(limit=limit)
1231
+ return {
1232
+ "count": len(records),
1233
+ "items": [record.to_dict() for record in records],
1234
+ "db": str(db_path),
1235
+ }
1236
+
1237
+
1238
+ def symbolic(
1239
+ path: str,
1240
+ address: int,
1241
+ max_paths: int = DEFAULT_SYMBOLIC_MAX_PATHS,
1242
+ max_steps: int = DEFAULT_SYMBOLIC_MAX_STEPS,
1243
+ ) -> dict[str, Any]:
1244
+ """Explore the paths through a function with the unicorn explorer.
1245
+
1246
+ This is a **unicorn exploration**, not a symbolic executor: every path is a
1247
+ sequence of branch decisions replayed on a freshly initialised unicorn CPU,
1248
+ and only the file's own image bytes ever run -- the host never executes the
1249
+ target. Only x86 and x86-64 are emulated, and the optional unicorn extra
1250
+ must be installed. What each direction is worth, stated honestly: the side
1251
+ of a conditional the real emulation takes comes back with
1252
+ ``basis: "observed"``; the other side is produced by rewriting the flag or
1253
+ counter that guards the branch and comes back ``basis: "seeded"`` -- it is
1254
+ assumed feasible, never proven, because no path predicate is built, no
1255
+ constraint is solved and there is no symbolic variable anywhere in the
1256
+ engine. All values are concrete, so a path is evidence about one concrete
1257
+ run of the code, never proof of reachability for arbitrary inputs. Every
1258
+ seeded direction is named in the path's ``warnings``, counted in
1259
+ ``seeded_branches``, listed in the evidence ``assumptions``, and the whole
1260
+ summary drops to ``low`` confidence as soon as any direction was seeded.
1261
+
1262
+ Args:
1263
+ path: PE or ELF file to read; its bytes are only ever run inside the
1264
+ emulator, never on the host.
1265
+ address: Virtual address of the first instruction, inside an
1266
+ executable section with file bytes.
1267
+ max_paths: Path budget (1 ..
1268
+ :data:`codevanta.analysis.symbolic.MAX_MAX_PATHS`).
1269
+ max_steps: Instruction budget for the whole exploration, replays
1270
+ included (1 .. :data:`codevanta.analysis.symbolic.MAX_MAX_STEPS`).
1271
+
1272
+ Returns:
1273
+ ``PathExploration.to_dict()`` -- ``address``, ``explorer``,
1274
+ ``architecture``, ``bitness``, ``max_paths``, ``max_steps``,
1275
+ ``steps``, ``complete``, ``paths``, ``unresolved``, ``return_values``,
1276
+ ``entries``, ``warnings`` and ``evidence`` -- plus ``path`` and the
1277
+ resolved start as ``address_resolved``.
1278
+
1279
+ Raises:
1280
+ AnalysisError: If the file is missing, ``address`` is not a
1281
+ non-negative integer or not inside an executable, mapped, readable
1282
+ section, a budget is out of range or not an integer, unicorn is
1283
+ not installed, or the architecture is not x86/x86-64.
1284
+ """
1285
+ start = _check_address(address)
1286
+ path_limit = _check_bounded_int("max_paths", max_paths, MAX_SYMBOLIC_MAX_PATHS)
1287
+ step_limit = _check_bounded_int("max_steps", max_steps, MAX_SYMBOLIC_MAX_STEPS)
1288
+ target = _validate_path(path)
1289
+ info = load_binary(target)
1290
+ exploration = explore_paths(
1291
+ target,
1292
+ info,
1293
+ start,
1294
+ max_paths=path_limit,
1295
+ max_steps=step_limit,
1296
+ )
1297
+ payload = exploration.to_dict()
1298
+ payload["path"] = str(target)
1299
+ payload["address_resolved"] = f"0x{start:x}"
1300
+ return payload
1301
+
1302
+
1303
+ def dataflow(
1304
+ path: str,
1305
+ address: int,
1306
+ max_instructions: int = DEFAULT_DATAFLOW_MAX_INSTRUCTIONS,
1307
+ max_blocks: int = DEFAULT_DATAFLOW_MAX_BLOCKS,
1308
+ ) -> dict[str, Any]:
1309
+ """Recover definition-use chains, constants and reaching definitions.
1310
+
1311
+ The recursive CFG is built from ``address`` and walked once, forward: each
1312
+ instruction contributes its read uses, resolved against the definitions
1313
+ seen so far, and each register write becomes one definition that kills
1314
+ whatever the register held before. Constants are folded only from shapes
1315
+ the pass itself proves -- immediates, RIP-relative ``lea``/``mov`` targets,
1316
+ copies of known values and ``add``/``sub``/``xor`` over two known values --
1317
+ so ``constant_registers`` never guesses. Reads with no reaching definition
1318
+ in the region are absent from ``reaching_definition`` and reported in
1319
+ ``warnings`` instead.
1320
+
1321
+ Honest limits: the pass is a linearization of the CFG, not a fixpoint --
1322
+ state flows into a block from its highest-addressed predecessor, so a join
1323
+ is approximated rather than merged. What is reported stays sound one
1324
+ definition at a time (a value is only folded where the summary proves it,
1325
+ and only the last definition of a register survives), but the summary is
1326
+ not a dataflow proof over the whole function. Two calls with the same
1327
+ arguments always produce the same result.
1328
+
1329
+ Args:
1330
+ path: PE or ELF file to read; it is read, never executed.
1331
+ address: Virtual address of the first instruction, inside an
1332
+ executable section.
1333
+ max_instructions: Instruction budget (1 ..
1334
+ :data:`codevanta.analysis.dataflow.MAX_MAX_INSTRUCTIONS`).
1335
+ max_blocks: Block budget (1 ..
1336
+ :data:`codevanta.analysis.dataflow.MAX_MAX_BLOCKS`).
1337
+
1338
+ Returns:
1339
+ ``DataFlowSummary.to_dict()`` -- ``address``, ``definition_count``,
1340
+ ``use_count``, ``definitions``, ``uses``, ``constant_registers``,
1341
+ ``reaching_definition``, ``warnings`` and ``evidence``.
1342
+
1343
+ Raises:
1344
+ AnalysisError: If the file is missing, ``address`` is not a
1345
+ non-negative integer or not inside an executable section, or a
1346
+ budget is out of range or not an integer.
1347
+ """
1348
+ start = _check_address(address)
1349
+ instruction_budget = _check_bounded_int(
1350
+ "max_instructions", max_instructions, MAX_DATAFLOW_MAX_INSTRUCTIONS
1351
+ )
1352
+ block_budget = _check_bounded_int(
1353
+ "max_blocks", max_blocks, MAX_DATAFLOW_MAX_BLOCKS
1354
+ )
1355
+ target = _validate_path(path)
1356
+ info = load_binary(target)
1357
+ summary = analyze_dataflow(
1358
+ target,
1359
+ info,
1360
+ start,
1361
+ max_instructions=instruction_budget,
1362
+ max_blocks=block_budget,
1363
+ )
1364
+ return summary.to_dict()