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,710 @@
|
|
|
1
|
+
"""CODEVANTA MCP server (Model Context Protocol).
|
|
2
|
+
|
|
3
|
+
``stdio`` is the default transport, so existing client configurations keep
|
|
4
|
+
working. ``sse`` and ``streamable-http`` are also available for clients that
|
|
5
|
+
speak HTTP; they bind a local TCP port and are opt-in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ipaddress
|
|
11
|
+
from typing import Annotated, Any, Literal, cast
|
|
12
|
+
|
|
13
|
+
from mcp.server.mcpserver import MCPServer
|
|
14
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
15
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
|
|
18
|
+
from codevanta.core.report import AnalysisError
|
|
19
|
+
|
|
20
|
+
from . import tools
|
|
21
|
+
|
|
22
|
+
SERVER_NAME = "CODEVANTA"
|
|
23
|
+
SERVER_VERSION = "0.1.0"
|
|
24
|
+
|
|
25
|
+
#: Transports the pinned ``mcp`` SDK can serve. ``MCPServer.run`` declares
|
|
26
|
+
#: ``Literal["stdio", "sse", "streamable-http"]`` and raises ``ValueError`` for
|
|
27
|
+
#: anything else, so this tuple is the SDK's list - not a CODEVANTA invention.
|
|
28
|
+
#: ``stdio`` is first because it is the default.
|
|
29
|
+
SUPPORTED_TRANSPORTS: tuple[str, ...] = ("stdio", "sse", "streamable-http")
|
|
30
|
+
|
|
31
|
+
#: Transports that bind a TCP socket, i.e. that can be reached from off-box.
|
|
32
|
+
HTTP_TRANSPORTS: frozenset[str] = frozenset({"sse", "streamable-http"})
|
|
33
|
+
|
|
34
|
+
#: Default bind address for the HTTP transports; loopback keeps the tools local.
|
|
35
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
36
|
+
DEFAULT_PORT = 8000
|
|
37
|
+
|
|
38
|
+
#: The hosts the pinned MCP SDK hardens by itself. ``MCPServer.sse_app`` and
|
|
39
|
+
#: ``MCPServer.streamable_http_app`` compare their ``host`` argument with exactly
|
|
40
|
+
#: these strings before enabling DNS-rebinding protection, so ``127.0.0.2`` used
|
|
41
|
+
#: to bind without protection while ``"[::1]"`` did not bind at all. CODEVANTA
|
|
42
|
+
#: therefore never relies on that detection: it normalises the host once and
|
|
43
|
+
#: passes explicit :class:`TransportSecuritySettings` for a loopback bind (see
|
|
44
|
+
#: :func:`transport_security`).
|
|
45
|
+
SDK_HARDENED_HOSTS: tuple[str, ...] = ("127.0.0.1", "localhost", "::1")
|
|
46
|
+
|
|
47
|
+
HttpTransport = Literal["sse", "streamable-http"]
|
|
48
|
+
|
|
49
|
+
INSTRUCTIONS = (
|
|
50
|
+
"CODEVANTA performs static analysis of PE and ELF binaries and never executes "
|
|
51
|
+
"targets. Every result carries evidence: check 'source', 'method', and "
|
|
52
|
+
"'confidence' before relying on a finding. Exports and function symbols are "
|
|
53
|
+
"high confidence; the entry point is a medium-confidence code candidate, not a "
|
|
54
|
+
"proven function boundary. All addresses are absolute virtual addresses (VA)."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: ``codevanta_diff`` flags are forwarded to :func:`codevanta.mcp_server.tools.diff`
|
|
59
|
+
#: exactly as the client sent them. ``Any`` stops the MCP argument model from
|
|
60
|
+
#: rewriting the JSON first (``deep=1`` became ``True``, ``threshold=true`` became
|
|
61
|
+
#: ``1.0`` and ``threshold="0.9"`` became ``0.9``), so ``_check_bool`` and
|
|
62
|
+
#: ``_check_threshold`` make the decision and name the offending argument, while
|
|
63
|
+
#: ``Annotated`` keeps the published input schema advertising the documented type.
|
|
64
|
+
RawBooleanFlag = Annotated[Any, Field(json_schema_extra={"type": "boolean"})]
|
|
65
|
+
RawNumberFlag = Annotated[Any, Field(json_schema_extra={"type": "number"})]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_server() -> MCPServer:
|
|
69
|
+
"""Create the MCP server and register the analysis tools."""
|
|
70
|
+
server = MCPServer(
|
|
71
|
+
name=SERVER_NAME,
|
|
72
|
+
version=SERVER_VERSION,
|
|
73
|
+
instructions=INSTRUCTIONS,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@server.tool(
|
|
77
|
+
description=(
|
|
78
|
+
"Load a PE or ELF binary and return format, architecture, sections, "
|
|
79
|
+
"imports, exports, and symbols."
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
def codevanta_info(path: str) -> dict:
|
|
83
|
+
return tools.binary_info(path)
|
|
84
|
+
|
|
85
|
+
@server.tool(
|
|
86
|
+
description="List function candidates with detection source and confidence."
|
|
87
|
+
)
|
|
88
|
+
def codevanta_functions(path: str, limit: int = 200) -> dict:
|
|
89
|
+
return tools.functions(path, limit=limit)
|
|
90
|
+
|
|
91
|
+
@server.tool(
|
|
92
|
+
description=(
|
|
93
|
+
"Extract ASCII and UTF-16LE strings with file offset and mapped "
|
|
94
|
+
"virtual address."
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
def codevanta_strings(path: str, min_length: int = 4, limit: int = 200) -> dict:
|
|
98
|
+
return tools.strings(path, min_length=min_length, limit=limit)
|
|
99
|
+
|
|
100
|
+
@server.tool(
|
|
101
|
+
description="Disassemble the entry point, a virtual address, or a named function."
|
|
102
|
+
)
|
|
103
|
+
def codevanta_disasm(
|
|
104
|
+
path: str,
|
|
105
|
+
address: int | None = None,
|
|
106
|
+
function: str | None = None,
|
|
107
|
+
count: int = 50,
|
|
108
|
+
) -> dict:
|
|
109
|
+
return tools.disasm(path, address=address, function=function, count=count)
|
|
110
|
+
|
|
111
|
+
@server.tool(
|
|
112
|
+
description="Produce the full phase 1 JSON report with provenance and evidence."
|
|
113
|
+
)
|
|
114
|
+
def codevanta_analyze(
|
|
115
|
+
path: str,
|
|
116
|
+
string_limit: int = 5000,
|
|
117
|
+
include_xrefs: bool = False,
|
|
118
|
+
include_cfg: bool = False,
|
|
119
|
+
include_callgraph: bool = False,
|
|
120
|
+
) -> dict:
|
|
121
|
+
return tools.analyze(
|
|
122
|
+
path,
|
|
123
|
+
string_limit=string_limit,
|
|
124
|
+
include_xrefs=include_xrefs,
|
|
125
|
+
include_cfg=include_cfg,
|
|
126
|
+
include_callgraph=include_callgraph,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@server.tool(
|
|
130
|
+
description=(
|
|
131
|
+
"Per-section Shannon entropy sampled from file bytes with a packing "
|
|
132
|
+
"triage hint: sections at or above the threshold (default 7.2 bits/byte) "
|
|
133
|
+
"are flagged as possibly packed — a heuristic, not proof. Sections "
|
|
134
|
+
"without file bytes carry entropy null with a warning."
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
def codevanta_entropy(
|
|
138
|
+
path: str,
|
|
139
|
+
threshold: float = 7.2,
|
|
140
|
+
max_sample_bytes: int = 65_536,
|
|
141
|
+
) -> dict:
|
|
142
|
+
return tools.entropy(path, threshold=threshold, max_sample_bytes=max_sample_bytes)
|
|
143
|
+
|
|
144
|
+
@server.tool(
|
|
145
|
+
description=(
|
|
146
|
+
"List direct call, jump, and conditional-branch cross references from "
|
|
147
|
+
"executable sections. Each reference carries evidence; indirect "
|
|
148
|
+
"transfers are not resolved."
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
def codevanta_xrefs(
|
|
152
|
+
path: str,
|
|
153
|
+
limit: int = 200,
|
|
154
|
+
max_bytes: int = 4_000_000,
|
|
155
|
+
) -> dict:
|
|
156
|
+
return tools.xrefs(path, limit=limit, max_bytes=max_bytes)
|
|
157
|
+
|
|
158
|
+
@server.tool(
|
|
159
|
+
description=(
|
|
160
|
+
"Build a heuristic linear-sweep control-flow graph (basic blocks, "
|
|
161
|
+
"successors, terminators) for the entry point, an address, or a named "
|
|
162
|
+
"function. Block boundaries are heuristic, not proven."
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
def codevanta_cfg(
|
|
166
|
+
path: str,
|
|
167
|
+
address: int | None = None,
|
|
168
|
+
function: str | None = None,
|
|
169
|
+
max_instructions: int = 2000,
|
|
170
|
+
max_blocks: int = 500,
|
|
171
|
+
) -> dict:
|
|
172
|
+
return tools.cfg(
|
|
173
|
+
path,
|
|
174
|
+
address=address,
|
|
175
|
+
function=function,
|
|
176
|
+
max_instructions=max_instructions,
|
|
177
|
+
max_blocks=max_blocks,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
@server.tool(
|
|
181
|
+
description=(
|
|
182
|
+
"Decompile one function to C-like text with a pluggable backend. "
|
|
183
|
+
"backend='auto' (default) prefers a real decompiler -- Ghidra, then "
|
|
184
|
+
"angr -- when one is installed and falls back to the built-in "
|
|
185
|
+
"mnemonic-level pseudo-C renderer, listing every skipped backend in "
|
|
186
|
+
"'warnings'; backend='native' always works; backend='angr' and "
|
|
187
|
+
"backend='ghidra' are strict and fail with a clear error naming the "
|
|
188
|
+
"backend and its install hint (never an ImportError) when that "
|
|
189
|
+
"backend is not available. 'backend' reports the provider that "
|
|
190
|
+
"actually produced 'text', which differs from the request under "
|
|
191
|
+
"'auto', and 'blocks' is how many basic blocks the text covers. "
|
|
192
|
+
"Control flow, argument count and types are inferred from "
|
|
193
|
+
"mnemonics, so the text is low-confidence evidence and nothing is "
|
|
194
|
+
"executed. Check 'warnings' before relying on a fallback rendering."
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
def codevanta_decompile(
|
|
198
|
+
path: str,
|
|
199
|
+
address: int,
|
|
200
|
+
backend: str = "auto",
|
|
201
|
+
max_instructions: int = 2000,
|
|
202
|
+
max_blocks: int = 500,
|
|
203
|
+
) -> dict:
|
|
204
|
+
try:
|
|
205
|
+
return tools.decompile(
|
|
206
|
+
path,
|
|
207
|
+
address,
|
|
208
|
+
backend=backend,
|
|
209
|
+
max_instructions=max_instructions,
|
|
210
|
+
max_blocks=max_blocks,
|
|
211
|
+
)
|
|
212
|
+
except AnalysisError as exc:
|
|
213
|
+
# An unavailable backend or a bad address is caller-correctable, but
|
|
214
|
+
# the framework would otherwise replace the text with a generic
|
|
215
|
+
# "Error executing tool" message that hides which argument failed.
|
|
216
|
+
raise ToolError(str(exc)) from exc
|
|
217
|
+
|
|
218
|
+
@server.tool(
|
|
219
|
+
description=(
|
|
220
|
+
"Build an inter-function call graph. Edges come from direct calls "
|
|
221
|
+
"only, plus calls through a pointer slot that resolves to a known "
|
|
222
|
+
"import (the target node is labelled with the import name) or to a "
|
|
223
|
+
"statically known slot whose callee is unknown. Jumps, conditional "
|
|
224
|
+
"branches, and unresolved indirect calls produce no edges. The "
|
|
225
|
+
"caller of a call site is attributed heuristically to the nearest "
|
|
226
|
+
"function candidate at or below the call address in the same "
|
|
227
|
+
"section, so caller attribution is not proven. Check 'truncated' "
|
|
228
|
+
"and 'warnings' before assuming the graph is complete."
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
def codevanta_callgraph(
|
|
232
|
+
path: str,
|
|
233
|
+
limit_functions: int = 1000,
|
|
234
|
+
limit_edges: int = 5000,
|
|
235
|
+
) -> dict:
|
|
236
|
+
return tools.callgraph(
|
|
237
|
+
path,
|
|
238
|
+
limit_functions=limit_functions,
|
|
239
|
+
limit_edges=limit_edges,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
@server.tool(
|
|
243
|
+
description=(
|
|
244
|
+
"Compare two binaries structurally: functions (matched by address "
|
|
245
|
+
"first, then by name after moving), sections, imports and exports. "
|
|
246
|
+
"Nothing is executed. With deep=true, address-matched functions are "
|
|
247
|
+
"also compared by a normalized-instruction fingerprint; equal "
|
|
248
|
+
"digests are shape equality, not byte equality. With semantic=true "
|
|
249
|
+
"the functions that address and name matching left unpaired are "
|
|
250
|
+
"scored by instruction-shape overlap, and the best pairs reaching "
|
|
251
|
+
"threshold (0.0..1.0, default 0.9) are reported as one 'changed' "
|
|
252
|
+
"entry carrying 'similarity', so a moved function is no longer a "
|
|
253
|
+
"removal plus an addition; that score is shape evidence, not proof "
|
|
254
|
+
"of equivalence. The threshold is validated even when semantic is "
|
|
255
|
+
"false. Arguments are never coerced: deep and semantic must be real "
|
|
256
|
+
"booleans and threshold a real number, so deep=1, semantic=1, "
|
|
257
|
+
"threshold=true and threshold='0.9' are rejected and the error names "
|
|
258
|
+
"the offending argument. Check 'warnings' before assuming the "
|
|
259
|
+
"comparison is complete."
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
def codevanta_diff(
|
|
263
|
+
left: str,
|
|
264
|
+
right: str,
|
|
265
|
+
max_functions: int = 5000,
|
|
266
|
+
deep: RawBooleanFlag = False,
|
|
267
|
+
semantic: RawBooleanFlag = False,
|
|
268
|
+
threshold: RawNumberFlag = 0.9,
|
|
269
|
+
) -> dict:
|
|
270
|
+
try:
|
|
271
|
+
return tools.diff(
|
|
272
|
+
left,
|
|
273
|
+
right,
|
|
274
|
+
max_functions=max_functions,
|
|
275
|
+
deep=deep,
|
|
276
|
+
semantic=semantic,
|
|
277
|
+
threshold=threshold,
|
|
278
|
+
)
|
|
279
|
+
except AnalysisError as exc:
|
|
280
|
+
# AnalysisError is an anticipated, caller-correctable failure, but the
|
|
281
|
+
# framework would collapse it into a generic "Error executing tool"
|
|
282
|
+
# crash message that hides which argument is wrong. Re-raising it as a
|
|
283
|
+
# ToolError keeps the text (e.g. "deep must be a boolean") in the
|
|
284
|
+
# is_error result the client receives.
|
|
285
|
+
raise ToolError(str(exc)) from exc
|
|
286
|
+
|
|
287
|
+
@server.tool(
|
|
288
|
+
description=(
|
|
289
|
+
"Decode a jump table at an absolute virtual address. mode='absolute' "
|
|
290
|
+
"reads consecutive pointer-sized virtual addresses; mode='relative' "
|
|
291
|
+
"reads signed 32-bit offsets added to 'base' (required in that "
|
|
292
|
+
"mode). Scanning stops at the first slot that is not a usable code "
|
|
293
|
+
"target and explains why in 'warnings'; a zero pointer also ends an "
|
|
294
|
+
"absolute table. Entries are shape evidence, not proof that a "
|
|
295
|
+
"dispatcher indexes the table."
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
def codevanta_jump_table(
|
|
299
|
+
path: str,
|
|
300
|
+
address: int,
|
|
301
|
+
mode: str = "absolute",
|
|
302
|
+
base: int | None = None,
|
|
303
|
+
entries: int = 256,
|
|
304
|
+
executable_only: bool = True,
|
|
305
|
+
) -> dict:
|
|
306
|
+
return tools.jump_table(
|
|
307
|
+
path,
|
|
308
|
+
address,
|
|
309
|
+
mode=mode,
|
|
310
|
+
base=base,
|
|
311
|
+
entries=entries,
|
|
312
|
+
executable_only=executable_only,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
@server.tool(
|
|
316
|
+
description=(
|
|
317
|
+
"Return a SHA-256 fingerprint of the normalized instruction shape of "
|
|
318
|
+
"the function starting at an address. Numeric literals are masked, "
|
|
319
|
+
"so equal digests mean equal mnemonic/operand structure, not byte "
|
|
320
|
+
"equality, and different digests are not a semantic difference. "
|
|
321
|
+
"'truncated' means the digest covers only a prefix of the region."
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
def codevanta_function_hash(
|
|
325
|
+
path: str,
|
|
326
|
+
address: int,
|
|
327
|
+
max_instructions: int = 2000,
|
|
328
|
+
) -> dict:
|
|
329
|
+
return tools.function_hash(
|
|
330
|
+
path,
|
|
331
|
+
address,
|
|
332
|
+
max_instructions=max_instructions,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
@server.tool(
|
|
336
|
+
description=(
|
|
337
|
+
"Compare two function regions as multisets of normalized instruction "
|
|
338
|
+
"shapes and return a 'score' in 0..1 (shared / union token "
|
|
339
|
+
"occurrences). Numeric literals, frame offsets and branch targets are "
|
|
340
|
+
"masked, so a relocated or recompiled copy usually scores 1.0, while "
|
|
341
|
+
"two structurally similar functions can score 1.0 without sharing "
|
|
342
|
+
"meaning. The score is shape evidence at MEDIUM confidence, not "
|
|
343
|
+
"semantic equivalence: different code can score high and a rewrite can "
|
|
344
|
+
"score low. 'warnings' means a side was truncated to the instruction "
|
|
345
|
+
"budget, so only prefixes were compared."
|
|
346
|
+
)
|
|
347
|
+
)
|
|
348
|
+
def codevanta_similarity(
|
|
349
|
+
path_a: str,
|
|
350
|
+
address_a: int,
|
|
351
|
+
path_b: str,
|
|
352
|
+
address_b: int,
|
|
353
|
+
max_instructions: int = 2000,
|
|
354
|
+
) -> dict:
|
|
355
|
+
return tools.similarity(
|
|
356
|
+
path_a,
|
|
357
|
+
address_a,
|
|
358
|
+
path_b,
|
|
359
|
+
address_b,
|
|
360
|
+
max_instructions=max_instructions,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@server.tool(
|
|
364
|
+
description=(
|
|
365
|
+
"Suggest struct fields for the memory a function accesses around a "
|
|
366
|
+
"base register, starting at an address, a named function, or the "
|
|
367
|
+
"entry point. Only '[reg]', '[reg + 0xNN]' and '[reg - 0xNN]' "
|
|
368
|
+
"operands are read; index/scale/segment forms are skipped. Width "
|
|
369
|
+
"comes from the Intel size keyword, direction from the operand "
|
|
370
|
+
"position (cmp/test/call/jmp/push are always reads). 'alignment' is "
|
|
371
|
+
"the gcd of the absolute non-zero offsets and 'array_groups' lists "
|
|
372
|
+
"runs of at least three "
|
|
373
|
+
"equally spaced fields; 'truncated' means max_fields dropped "
|
|
374
|
+
"offsets. Fields are medium-confidence heuristics from operand "
|
|
375
|
+
"shapes, not verified types, and carry no signedness or purpose."
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
def codevanta_struct(
|
|
379
|
+
path: str,
|
|
380
|
+
address: int | None = None,
|
|
381
|
+
function: str | None = None,
|
|
382
|
+
max_instructions: int = 2000,
|
|
383
|
+
max_fields: int = 64,
|
|
384
|
+
include_stack: bool = False,
|
|
385
|
+
) -> dict:
|
|
386
|
+
return tools.struct(
|
|
387
|
+
path,
|
|
388
|
+
address=address,
|
|
389
|
+
function=function,
|
|
390
|
+
max_instructions=max_instructions,
|
|
391
|
+
max_fields=max_fields,
|
|
392
|
+
include_stack=include_stack,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
@server.tool(
|
|
396
|
+
description=(
|
|
397
|
+
"Replay a saved dynamic-session trace against a binary and map every "
|
|
398
|
+
"recorded hit onto the discovered function candidates. 'trace_path' "
|
|
399
|
+
"is a capture the generated gdb or Frida script already printed: "
|
|
400
|
+
"nothing is launched and no target is executed. 'provider' picks the "
|
|
401
|
+
"format; 'auto' detects it from the file and 'provider_resolved' "
|
|
402
|
+
"reports the choice, while a capture that cannot be placed, or that "
|
|
403
|
+
"contradicts an explicit provider, is an error instead of a guess. "
|
|
404
|
+
"The work is bounded (8 MB and 100000 lines read, plus 'max_hits'): "
|
|
405
|
+
"'truncated' means a bound or the session's own limit cut the parse, "
|
|
406
|
+
"and malformed lines land in 'warnings' rather than failing the "
|
|
407
|
+
"call. Hits outside every candidate stay unattributed. A hit is "
|
|
408
|
+
"evidence of one run, not proof that the code always executes."
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
def codevanta_trace(
|
|
412
|
+
path: str,
|
|
413
|
+
trace_path: str,
|
|
414
|
+
provider: Literal["auto", "gdb", "frida"] = "auto",
|
|
415
|
+
max_hits: int | None = None,
|
|
416
|
+
) -> dict:
|
|
417
|
+
try:
|
|
418
|
+
return tools.trace(path, trace_path, provider=provider, max_hits=max_hits)
|
|
419
|
+
except AnalysisError as exc:
|
|
420
|
+
# A missing capture, an unreadable one, an unresolvable provider, a
|
|
421
|
+
# provider mismatch or a bad max_hits is the caller's to fix, but the
|
|
422
|
+
# framework would otherwise replace the reason with a generic
|
|
423
|
+
# "Error executing tool" message that hides which argument failed.
|
|
424
|
+
raise ToolError(str(exc)) from exc
|
|
425
|
+
|
|
426
|
+
@server.tool(
|
|
427
|
+
description=(
|
|
428
|
+
"Extract DWARF debug-info functions and named type layouts from an ELF "
|
|
429
|
+
"with producer metadata (highest-grade symbol evidence). Requires the "
|
|
430
|
+
"optional pyelftools extra: pip install -e \".[dwarf]\"."
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
def codevanta_dwarf(path: str, limit: int = 500) -> dict:
|
|
434
|
+
return tools.dwarf(path, limit=limit)
|
|
435
|
+
|
|
436
|
+
@server.tool(
|
|
437
|
+
description=(
|
|
438
|
+
"Discover C++ vtable candidates (runs of consecutive code pointers in "
|
|
439
|
+
"data sections) and resolve thunk chains from slot targets — indirect "
|
|
440
|
+
"cross-references at medium confidence, clearly labelled as heuristic."
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
def codevanta_vtable(
|
|
444
|
+
path: str,
|
|
445
|
+
address: int | None = None,
|
|
446
|
+
scan: bool = False,
|
|
447
|
+
min_slots: int = 3,
|
|
448
|
+
max_slots: int = 256,
|
|
449
|
+
max_tables: int = 64,
|
|
450
|
+
resolve_thunks: bool = True,
|
|
451
|
+
) -> dict:
|
|
452
|
+
return tools.vtable(
|
|
453
|
+
path,
|
|
454
|
+
address=address,
|
|
455
|
+
scan=scan,
|
|
456
|
+
min_slots=min_slots,
|
|
457
|
+
max_slots=max_slots,
|
|
458
|
+
max_tables=max_tables,
|
|
459
|
+
resolve_thunks=resolve_thunks,
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
@server.tool(
|
|
463
|
+
description=(
|
|
464
|
+
"Analyze a binary and persist its fingerprint, function count, and "
|
|
465
|
+
"string count as a new session in the local SQLite session database."
|
|
466
|
+
)
|
|
467
|
+
)
|
|
468
|
+
def codevanta_session_save(
|
|
469
|
+
path: str,
|
|
470
|
+
db: str | None = None,
|
|
471
|
+
notes: str | None = None,
|
|
472
|
+
) -> dict:
|
|
473
|
+
return tools.session_save(path, db=db, notes=notes)
|
|
474
|
+
|
|
475
|
+
@server.tool(
|
|
476
|
+
description="List persisted analysis sessions, newest first."
|
|
477
|
+
)
|
|
478
|
+
def codevanta_session_list(db: str | None = None, limit: int = 20) -> dict:
|
|
479
|
+
return tools.session_list(db=db, limit=limit)
|
|
480
|
+
|
|
481
|
+
@server.tool(
|
|
482
|
+
description=(
|
|
483
|
+
"Explore the paths through a function with the unicorn branch-decision "
|
|
484
|
+
"replay. Honest limits: this is a unicorn exploration, not a symbolic "
|
|
485
|
+
"executor -- the observed side of a branch is the real emulation "
|
|
486
|
+
"result, the other side is forced by rewriting the guarding flag or "
|
|
487
|
+
"counter ('seeded') and is assumed feasible, never proven: no path "
|
|
488
|
+
"predicate, no constraint solving, no symbolic variables. Values are "
|
|
489
|
+
"concrete, x86/x86-64 only, and the optional unicorn extra is "
|
|
490
|
+
"required (pip install 'codevanta[emulation]'). The file is read and "
|
|
491
|
+
"only ever executed inside the emulator, never on the host. Seeded "
|
|
492
|
+
"directions are flagged per path and drop the summary to low "
|
|
493
|
+
"confidence; a path is evidence about one concrete run, not a "
|
|
494
|
+
"reachability proof."
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
def codevanta_symbolic(
|
|
498
|
+
path: str,
|
|
499
|
+
address: int,
|
|
500
|
+
max_paths: int = 64,
|
|
501
|
+
max_steps: int = 2000,
|
|
502
|
+
) -> dict:
|
|
503
|
+
return tools.symbolic(
|
|
504
|
+
path,
|
|
505
|
+
address,
|
|
506
|
+
max_paths=max_paths,
|
|
507
|
+
max_steps=max_steps,
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
@server.tool(
|
|
511
|
+
description=(
|
|
512
|
+
"Recover definition-use chains, constants and reaching definitions "
|
|
513
|
+
"for the function at an address: one forward pass over the recursive "
|
|
514
|
+
"CFG in which register writes become definitions that kill earlier "
|
|
515
|
+
"ones and reads resolve against what is still known. Only provable "
|
|
516
|
+
"shapes fold into 'constant_registers' (immediates, rip-relative "
|
|
517
|
+
"loads, copies, add/sub/xor of two known values). Honest limits: "
|
|
518
|
+
"the pass is a linearization, not a fixpoint -- joins take the "
|
|
519
|
+
"highest-addressed predecessor -- so the summary is sound one "
|
|
520
|
+
"definition at a time, not a dataflow proof. Undefined reads are "
|
|
521
|
+
"reported in 'warnings'. The binary is read, never executed."
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
def codevanta_dataflow(
|
|
525
|
+
path: str,
|
|
526
|
+
address: int,
|
|
527
|
+
max_instructions: int = 2000,
|
|
528
|
+
max_blocks: int = 500,
|
|
529
|
+
) -> dict:
|
|
530
|
+
return tools.dataflow(
|
|
531
|
+
path,
|
|
532
|
+
address,
|
|
533
|
+
max_instructions=max_instructions,
|
|
534
|
+
max_blocks=max_blocks,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
return server
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def normalise_host(host: str) -> str:
|
|
541
|
+
"""Return ``host`` as the bare bind address uvicorn and the SDK expect.
|
|
542
|
+
|
|
543
|
+
The rule, applied in this order:
|
|
544
|
+
|
|
545
|
+
1. surrounding whitespace is stripped;
|
|
546
|
+
2. one pair of surrounding IPv6 brackets is dropped, because ``[::1]`` is
|
|
547
|
+
``Host``-header/URL notation rather than a bindable address - uvicorn
|
|
548
|
+
refused ``--host "[::1]"`` with "invalid IP address";
|
|
549
|
+
3. ``localhost`` (in any case) becomes ``127.0.0.1``, the literal the SDK
|
|
550
|
+
recognises as loopback, so neither the bind nor the ``Host`` allow-list
|
|
551
|
+
depends on what a resolver returns for ``localhost``.
|
|
552
|
+
|
|
553
|
+
Anything else is returned unchanged. Normalising once means the loopback
|
|
554
|
+
guard, the ``Host``/``Origin`` allow-list and the ``bind()`` call all see the
|
|
555
|
+
same string. This function does not validate: a blank or remote result is
|
|
556
|
+
returned as-is and rejected by :func:`run`.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
host: Requested bind host, possibly bracketed or ``localhost``.
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
The bare address to bind, or ``""`` when ``host`` is blank.
|
|
563
|
+
"""
|
|
564
|
+
candidate = host.strip()
|
|
565
|
+
if len(candidate) > 2 and candidate.startswith("[") and candidate.endswith("]"):
|
|
566
|
+
candidate = candidate[1:-1]
|
|
567
|
+
if candidate.lower() == "localhost":
|
|
568
|
+
return "127.0.0.1"
|
|
569
|
+
return candidate
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def is_loopback_host(host: str) -> bool:
|
|
573
|
+
"""Return True when ``host`` is an address only reachable from this machine.
|
|
574
|
+
|
|
575
|
+
The check runs on :func:`normalise_host` output, so ``[::1]``, `` 127.0.0.1 ``
|
|
576
|
+
and ``LOCALHOST`` behave like their bare lower-case forms, and ``127.0.0.2``
|
|
577
|
+
is loopback because the whole of ``127.0.0.0/8`` is. Everything else -
|
|
578
|
+
including the wildcards ``0.0.0.0`` and ``::``, and any hostname such as
|
|
579
|
+
``example.com`` or ``localhost.evil.com`` - is treated as remote: a wildcard
|
|
580
|
+
listens on every interface and a hostname may resolve to a routable address.
|
|
581
|
+
|
|
582
|
+
Accepting ``127.0.0.2`` is only safe because :func:`run` also passes explicit
|
|
583
|
+
:class:`TransportSecuritySettings`; the SDK's own hardening knows three
|
|
584
|
+
literal hosts (:data:`SDK_HARDENED_HOSTS`), not the whole loopback range.
|
|
585
|
+
"""
|
|
586
|
+
if not isinstance(host, str):
|
|
587
|
+
return False
|
|
588
|
+
candidate = normalise_host(host)
|
|
589
|
+
if not candidate:
|
|
590
|
+
return False
|
|
591
|
+
try:
|
|
592
|
+
return ipaddress.ip_address(candidate).is_loopback
|
|
593
|
+
except ValueError:
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def transport_security(host: str) -> TransportSecuritySettings | None:
|
|
598
|
+
"""Return DNS-rebinding protection for the loopback bind ``host``.
|
|
599
|
+
|
|
600
|
+
The SDK builds its own settings only when its ``host`` argument is literally
|
|
601
|
+
one of :data:`SDK_HARDENED_HOSTS`, which left ``127.0.0.2`` - accepted by
|
|
602
|
+
:func:`is_loopback_host` - serving without any ``Host``/``Origin`` check.
|
|
603
|
+
Passing the settings explicitly closes that gap: ``MCPServer.sse_app`` and
|
|
604
|
+
``MCPServer.streamable_http_app`` only fall back to their own detection when
|
|
605
|
+
``transport_security is None``, and the pinned SDK's ``run`` forwards the
|
|
606
|
+
keyword to both HTTP transports.
|
|
607
|
+
|
|
608
|
+
The lists describe the *actual* bind address in the SDK's own ``:<port>``
|
|
609
|
+
wildcard shape, so ``Host: 127.0.0.2:8000`` and ``Origin:
|
|
610
|
+
http://127.0.0.2:8000`` pass while ``Host: attacker.example`` and ``Origin:
|
|
611
|
+
http://attacker.example`` get the SDK's ``421``/``403``. Only ``http://``
|
|
612
|
+
origins are listed, matching the SDK's defaults for its own three hosts, and
|
|
613
|
+
a request without an ``Origin`` header - every non-browser MCP client - is
|
|
614
|
+
always accepted.
|
|
615
|
+
|
|
616
|
+
A non-loopback bind returns ``None``: the SDK then leaves protection off,
|
|
617
|
+
which is exactly what ``allow_remote=True`` promises - front a remote bind
|
|
618
|
+
with your own authentication and ``Host``/``Origin`` validation.
|
|
619
|
+
|
|
620
|
+
Args:
|
|
621
|
+
host: Bind host, normalised or not.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
Settings for :meth:`MCPServer.run`, or ``None`` for a remote bind.
|
|
625
|
+
"""
|
|
626
|
+
if not is_loopback_host(host):
|
|
627
|
+
return None
|
|
628
|
+
bind_host = normalise_host(host)
|
|
629
|
+
authority = f"[{bind_host}]" if ":" in bind_host else bind_host
|
|
630
|
+
return TransportSecuritySettings(
|
|
631
|
+
enable_dns_rebinding_protection=True,
|
|
632
|
+
allowed_hosts=[authority, f"{authority}:*"],
|
|
633
|
+
allowed_origins=[f"http://{authority}", f"http://{authority}:*"],
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def run(
|
|
638
|
+
transport: str = "stdio",
|
|
639
|
+
host: str = DEFAULT_HOST,
|
|
640
|
+
port: int = DEFAULT_PORT,
|
|
641
|
+
*,
|
|
642
|
+
allow_remote: bool = False,
|
|
643
|
+
) -> None:
|
|
644
|
+
"""Serve the CODEVANTA tools over MCP.
|
|
645
|
+
|
|
646
|
+
``transport="stdio"`` is the default and behaves exactly as before: the
|
|
647
|
+
server speaks JSON-RPC over stdin/stdout and ``host``/``port`` are unused,
|
|
648
|
+
so no existing client configuration changes meaning. ``"sse"`` and
|
|
649
|
+
``"streamable-http"`` bind ``host:port`` instead.
|
|
650
|
+
|
|
651
|
+
The HTTP transports are network-exposed and these tools read arbitrary
|
|
652
|
+
local files, so a non-loopback ``host`` is refused unless ``allow_remote``
|
|
653
|
+
is set.
|
|
654
|
+
|
|
655
|
+
``host`` is normalised once through :func:`normalise_host` - whitespace
|
|
656
|
+
stripped, surrounding IPv6 brackets dropped, ``localhost`` mapped to
|
|
657
|
+
``127.0.0.1`` - and that single value is used for the loopback guard, the
|
|
658
|
+
``Host``/``Origin`` allow-list and the bind. Two consequences of the SDK's
|
|
659
|
+
own behaviour are worth spelling out, because the pinned SDK hardens only
|
|
660
|
+
the three literal strings in :data:`SDK_HARDENED_HOSTS`:
|
|
661
|
+
|
|
662
|
+
* ``127.0.0.2`` (and the rest of ``127.0.0.0/8``) is accepted *and* hardened:
|
|
663
|
+
:func:`transport_security` passes explicit
|
|
664
|
+
:class:`TransportSecuritySettings` naming the real bind address, instead
|
|
665
|
+
of relying on the SDK's detection, which does not fire for it.
|
|
666
|
+
* ``"[::1]"`` is un-bracketed to ``::1`` before binding, so it binds and is
|
|
667
|
+
hardened; the bracketed form used to fail inside uvicorn.
|
|
668
|
+
|
|
669
|
+
A non-loopback bind (``allow_remote=True``) gets no automatic hardening -
|
|
670
|
+
front it with your own authentication and ``Host``/``Origin`` validation.
|
|
671
|
+
|
|
672
|
+
Args:
|
|
673
|
+
transport: One of :data:`SUPPORTED_TRANSPORTS`.
|
|
674
|
+
host: Bind address for the HTTP transports (default loopback).
|
|
675
|
+
port: Bind port for the HTTP transports; ``0`` picks a free port.
|
|
676
|
+
allow_remote: Permit a non-loopback ``host``.
|
|
677
|
+
|
|
678
|
+
Raises:
|
|
679
|
+
AnalysisError: Unknown transport, malformed host/port, or a
|
|
680
|
+
non-loopback host without ``allow_remote``.
|
|
681
|
+
"""
|
|
682
|
+
if transport not in SUPPORTED_TRANSPORTS:
|
|
683
|
+
supported = ", ".join(SUPPORTED_TRANSPORTS)
|
|
684
|
+
raise AnalysisError(
|
|
685
|
+
f"unknown MCP transport {transport!r}; supported transports: {supported}"
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
if transport == "stdio":
|
|
689
|
+
# Identical to the previous no-argument behaviour.
|
|
690
|
+
build_server().run(transport="stdio")
|
|
691
|
+
return
|
|
692
|
+
|
|
693
|
+
if not isinstance(host, str) or not host.strip():
|
|
694
|
+
raise AnalysisError(f"invalid MCP bind host {host!r}; expected a hostname or IP address")
|
|
695
|
+
bind_host = normalise_host(host)
|
|
696
|
+
if not is_loopback_host(bind_host) and not allow_remote:
|
|
697
|
+
raise AnalysisError(
|
|
698
|
+
f"refusing to bind the MCP server to non-loopback host {host!r}: these tools "
|
|
699
|
+
"read local files. Pass allow_remote=True to override."
|
|
700
|
+
)
|
|
701
|
+
if isinstance(port, bool) or not isinstance(port, int) or not 0 <= port <= 65535:
|
|
702
|
+
raise AnalysisError(f"invalid MCP port {port!r}; expected an integer in 0..65535")
|
|
703
|
+
|
|
704
|
+
build_server().run(
|
|
705
|
+
transport=cast("HttpTransport", transport),
|
|
706
|
+
host=bind_host,
|
|
707
|
+
port=port,
|
|
708
|
+
transport_security=transport_security(bind_host),
|
|
709
|
+
)
|
|
710
|
+
|