codevanta 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codevanta/__init__.py +1 -0
- codevanta/analysis/__init__.py +1 -0
- codevanta/analysis/binary_diff.py +1168 -0
- codevanta/analysis/callgraph.py +542 -0
- codevanta/analysis/cfg.py +1201 -0
- codevanta/analysis/dataflow.py +762 -0
- codevanta/analysis/debugger.py +289 -0
- codevanta/analysis/debuginfo.py +304 -0
- codevanta/analysis/decompile.py +1094 -0
- codevanta/analysis/decompilers/__init__.py +250 -0
- codevanta/analysis/decompilers/angr_backend.py +251 -0
- codevanta/analysis/decompilers/base.py +278 -0
- codevanta/analysis/decompilers/ghidra_backend.py +316 -0
- codevanta/analysis/dynamic.py +756 -0
- codevanta/analysis/emulate.py +1349 -0
- codevanta/analysis/evidence_bundle.py +736 -0
- codevanta/analysis/function_hash.py +562 -0
- codevanta/analysis/graph_export.py +441 -0
- codevanta/analysis/html_view.py +695 -0
- codevanta/analysis/jump_tables.py +546 -0
- codevanta/analysis/solver.py +715 -0
- codevanta/analysis/symbolic.py +1809 -0
- codevanta/analysis/trace.py +646 -0
- codevanta/analysis/type_propagation.py +1055 -0
- codevanta/analysis/type_recovery.py +1214 -0
- codevanta/analysis/vtables.py +572 -0
- codevanta/analysis/xrefs.py +434 -0
- codevanta/api/__init__.py +1 -0
- codevanta/cli/__init__.py +1 -0
- codevanta/cli/main.py +3038 -0
- codevanta/core/__init__.py +31 -0
- codevanta/core/disassembler.py +121 -0
- codevanta/core/entropy.py +237 -0
- codevanta/core/functions.py +176 -0
- codevanta/core/loader.py +1351 -0
- codevanta/core/report.py +306 -0
- codevanta/core/strings.py +109 -0
- codevanta/evidence/__init__.py +7 -0
- codevanta/evidence/confidence.py +36 -0
- codevanta/evidence/models.py +43 -0
- codevanta/evidence/provenance.py +37 -0
- codevanta/gui/__init__.py +46 -0
- codevanta/gui/layout.py +1384 -0
- codevanta/gui/server.py +1305 -0
- codevanta/ir/__init__.py +6 -0
- codevanta/ir/adapters/__init__.py +31 -0
- codevanta/ir/base.py +31 -0
- codevanta/ir/instruction.py +42 -0
- codevanta/mcp_server/__init__.py +5 -0
- codevanta/mcp_server/server.py +710 -0
- codevanta/mcp_server/tools.py +1364 -0
- codevanta/plugins/__init__.py +748 -0
- codevanta/setup/__init__.py +28 -0
- codevanta/setup/apply.py +22 -0
- codevanta/setup/clients.py +285 -0
- codevanta/setup/plan.py +79 -0
- codevanta/setup/verify.py +43 -0
- codevanta/storage/__init__.py +1 -0
- codevanta/storage/session.py +514 -0
- codevanta-0.1.0.dist-info/METADATA +688 -0
- codevanta-0.1.0.dist-info/RECORD +65 -0
- codevanta-0.1.0.dist-info/WHEEL +5 -0
- codevanta-0.1.0.dist-info/entry_points.txt +2 -0
- codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
- codevanta-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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()
|