codevanta 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. codevanta/__init__.py +1 -0
  2. codevanta/analysis/__init__.py +1 -0
  3. codevanta/analysis/binary_diff.py +1168 -0
  4. codevanta/analysis/callgraph.py +542 -0
  5. codevanta/analysis/cfg.py +1201 -0
  6. codevanta/analysis/dataflow.py +762 -0
  7. codevanta/analysis/debugger.py +289 -0
  8. codevanta/analysis/debuginfo.py +304 -0
  9. codevanta/analysis/decompile.py +1094 -0
  10. codevanta/analysis/decompilers/__init__.py +250 -0
  11. codevanta/analysis/decompilers/angr_backend.py +251 -0
  12. codevanta/analysis/decompilers/base.py +278 -0
  13. codevanta/analysis/decompilers/ghidra_backend.py +316 -0
  14. codevanta/analysis/dynamic.py +756 -0
  15. codevanta/analysis/emulate.py +1349 -0
  16. codevanta/analysis/evidence_bundle.py +736 -0
  17. codevanta/analysis/function_hash.py +562 -0
  18. codevanta/analysis/graph_export.py +441 -0
  19. codevanta/analysis/html_view.py +695 -0
  20. codevanta/analysis/jump_tables.py +546 -0
  21. codevanta/analysis/solver.py +715 -0
  22. codevanta/analysis/symbolic.py +1809 -0
  23. codevanta/analysis/trace.py +646 -0
  24. codevanta/analysis/type_propagation.py +1055 -0
  25. codevanta/analysis/type_recovery.py +1214 -0
  26. codevanta/analysis/vtables.py +572 -0
  27. codevanta/analysis/xrefs.py +434 -0
  28. codevanta/api/__init__.py +1 -0
  29. codevanta/cli/__init__.py +1 -0
  30. codevanta/cli/main.py +3038 -0
  31. codevanta/core/__init__.py +31 -0
  32. codevanta/core/disassembler.py +121 -0
  33. codevanta/core/entropy.py +237 -0
  34. codevanta/core/functions.py +176 -0
  35. codevanta/core/loader.py +1351 -0
  36. codevanta/core/report.py +306 -0
  37. codevanta/core/strings.py +109 -0
  38. codevanta/evidence/__init__.py +7 -0
  39. codevanta/evidence/confidence.py +36 -0
  40. codevanta/evidence/models.py +43 -0
  41. codevanta/evidence/provenance.py +37 -0
  42. codevanta/gui/__init__.py +46 -0
  43. codevanta/gui/layout.py +1384 -0
  44. codevanta/gui/server.py +1305 -0
  45. codevanta/ir/__init__.py +6 -0
  46. codevanta/ir/adapters/__init__.py +31 -0
  47. codevanta/ir/base.py +31 -0
  48. codevanta/ir/instruction.py +42 -0
  49. codevanta/mcp_server/__init__.py +5 -0
  50. codevanta/mcp_server/server.py +710 -0
  51. codevanta/mcp_server/tools.py +1364 -0
  52. codevanta/plugins/__init__.py +748 -0
  53. codevanta/setup/__init__.py +28 -0
  54. codevanta/setup/apply.py +22 -0
  55. codevanta/setup/clients.py +285 -0
  56. codevanta/setup/plan.py +79 -0
  57. codevanta/setup/verify.py +43 -0
  58. codevanta/storage/__init__.py +1 -0
  59. codevanta/storage/session.py +514 -0
  60. codevanta-0.1.0.dist-info/METADATA +688 -0
  61. codevanta-0.1.0.dist-info/RECORD +65 -0
  62. codevanta-0.1.0.dist-info/WHEEL +5 -0
  63. codevanta-0.1.0.dist-info/entry_points.txt +2 -0
  64. codevanta-0.1.0.dist-info/licenses/LICENSE +21 -0
  65. codevanta-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1305 @@
1
+ """Loopback-only HTTP dashboard in front of the CODEVANTA analysis engines.
2
+
3
+ This module is the server half of the local GUI: :func:`create_server` returns a
4
+ :class:`~http.server.ThreadingHTTPServer` bound to a loopback address, and
5
+ :func:`serve` runs it until ``Ctrl+C``. Every route is ``GET``, returns
6
+ ``application/json`` (except ``/``, which returns the dashboard page) and is a
7
+ thin adapter over an existing analysis entry point -- no analysis is
8
+ reimplemented here:
9
+
10
+ * ``/`` -- :func:`codevanta.gui.layout.render_dashboard_html`
11
+ * ``/api/health`` -- static status payload
12
+ * ``/api/info`` -- :func:`codevanta.core.report.build_report`
13
+ * ``/api/functions`` -- :func:`codevanta.core.functions.discover_functions`
14
+ * ``/api/strings`` -- :class:`codevanta.core.strings.StringExtractor`
15
+ * ``/api/xrefs`` -- :func:`codevanta.analysis.xrefs.find_xrefs`
16
+ * ``/api/cfg`` -- :func:`codevanta.analysis.cfg.build_cfg` and
17
+ :func:`codevanta.analysis.cfg.build_recursive_cfg`
18
+ * ``/api/struct`` -- :func:`codevanta.analysis.type_recovery.suggest_struct`
19
+ * ``/api/callgraph`` -- :func:`codevanta.analysis.callgraph.build_call_graph`
20
+ * ``/api/diff`` -- :func:`codevanta.analysis.binary_diff.diff_binaries`
21
+ * ``/api/decompile`` -- :func:`codevanta.analysis.decompilers.decompile_function`
22
+ * ``/api/emulate`` -- :func:`codevanta.analysis.emulate.emulate_function`
23
+ * ``/api/symbolic`` -- :func:`codevanta.analysis.symbolic.explore_paths`
24
+ * ``/api/session_save`` -- :meth:`codevanta.storage.session.SessionStore.save`
25
+ * ``/api/session_list`` -- :meth:`codevanta.storage.session.SessionStore.list`
26
+
27
+ Contract:
28
+
29
+ * **Loopback only.** :func:`create_server` refuses any host that is not
30
+ ``localhost``, ``::1`` or inside ``127.0.0.0/8`` with a :class:`ValueError`,
31
+ so the tool can never be exposed on a LAN interface by accident.
32
+ * **Bounds are the analysis bounds.** Every numeric query parameter is checked
33
+ against the ceiling of the engine that consumes it (string, xref, call-graph,
34
+ struct and disassembly limits), so a request cannot ask for more work than the
35
+ engines already allow.
36
+ * **Read-only analysis.** Bytes are parsed, never executed and never written.
37
+ The dashboard never mutates the analysed file. The one thing a request may
38
+ write is a *session* record -- the optional ``/api/session_save`` route
39
+ appends one row to the session database (see :data:`SESSION_DB_ENV`) and
40
+ touches nothing else; every other route only reads.
41
+ * **Origin checked.** Every request must carry a ``Host`` header naming the
42
+ dashboard itself -- ``127.0.0.1``, ``localhost`` or ``[::1]``, with either the
43
+ bound port or no port at all -- otherwise it is answered with ``421`` before
44
+ any route runs, so a DNS-rebinding page cannot drive the API or read local
45
+ binaries. Every response, including error responses, also carries
46
+ ``X-Frame-Options: DENY``, ``X-Content-Type-Options: nosniff`` and a
47
+ restrictive ``Content-Security-Policy``.
48
+ * **Bounded responses.** Each payload is capped at :data:`MAX_RESPONSE_BYTES`.
49
+ Top-level lists are halved first, then oversized strings and lists nested
50
+ deeper are replaced with ``"<cut: N chars>"``-style placeholders, and a payload
51
+ that still does not fit is replaced by a minimal ``response too large``
52
+ document. The emitted body therefore never exceeds the cap, whatever the shape
53
+ of the payload, and a truncated payload is flagged with
54
+ ``"response_truncated": true``.
55
+
56
+ Status codes: ``400`` with ``{"error": ...}`` for bad input, a missing file or a
57
+ rejected analysis request, ``404`` for an unknown route, ``405`` for a non-``GET``
58
+ method, ``421`` with ``{"error": ...}`` for a foreign ``Host`` header and ``500``
59
+ with ``{"error": ...}`` for an unexpected failure.
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import json
65
+ import math
66
+ import os
67
+ import socket
68
+ import sys
69
+ import webbrowser
70
+ from collections.abc import Callable
71
+ from http import HTTPStatus
72
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
73
+ from pathlib import Path
74
+ from typing import Any
75
+ from urllib.parse import parse_qs, urlsplit
76
+
77
+ from codevanta.analysis.binary_diff import (
78
+ DEFAULT_SEMANTIC_THRESHOLD,
79
+ MAX_SEMANTIC_THRESHOLD,
80
+ MIN_SEMANTIC_THRESHOLD,
81
+ diff_binaries,
82
+ )
83
+ from codevanta.analysis.callgraph import (
84
+ DEFAULT_MAX_EDGES,
85
+ DEFAULT_MAX_FUNCTIONS,
86
+ MAX_LIMIT,
87
+ build_call_graph,
88
+ )
89
+ from codevanta.analysis.cfg import build_cfg, build_recursive_cfg
90
+ from codevanta.analysis.decompile import (
91
+ DEFAULT_MAX_BLOCKS as DEFAULT_DECOMPILE_MAX_BLOCKS,
92
+ )
93
+ from codevanta.analysis.decompile import (
94
+ DEFAULT_MAX_INSTRUCTIONS as DEFAULT_DECOMPILE_MAX_INSTRUCTIONS,
95
+ )
96
+ from codevanta.analysis.decompile import MAX_MAX_BLOCKS as MAX_DECOMPILE_MAX_BLOCKS
97
+ from codevanta.analysis.decompile import (
98
+ MAX_MAX_INSTRUCTIONS as MAX_DECOMPILE_MAX_INSTRUCTIONS,
99
+ )
100
+ from codevanta.analysis.decompilers import BackendResult, supported_backends
101
+ from codevanta.analysis.decompilers import (
102
+ decompile_function as decompile_with_backend,
103
+ )
104
+ from codevanta.analysis.emulate import (
105
+ DEFAULT_MAX_INSTRUCTIONS as DEFAULT_EMULATE_MAX_INSTRUCTIONS,
106
+ )
107
+ from codevanta.analysis.emulate import (
108
+ EMULATOR_AVAILABLE,
109
+ emulate_function,
110
+ emulator_status,
111
+ )
112
+ from codevanta.analysis.emulate import (
113
+ MAX_MAX_INSTRUCTIONS as MAX_EMULATE_MAX_INSTRUCTIONS,
114
+ )
115
+ from codevanta.analysis.symbolic import (
116
+ DEFAULT_MAX_PATHS as DEFAULT_SYMBOLIC_MAX_PATHS,
117
+ )
118
+ from codevanta.analysis.symbolic import (
119
+ DEFAULT_MAX_STEPS as DEFAULT_SYMBOLIC_MAX_STEPS,
120
+ )
121
+ from codevanta.analysis.symbolic import (
122
+ MAX_MAX_PATHS as MAX_SYMBOLIC_MAX_PATHS,
123
+ )
124
+ from codevanta.analysis.symbolic import (
125
+ MAX_MAX_STEPS as MAX_SYMBOLIC_MAX_STEPS,
126
+ )
127
+ from codevanta.analysis.symbolic import (
128
+ explore_paths,
129
+ )
130
+ from codevanta.analysis.type_recovery import (
131
+ DEFAULT_MAX_FIELDS,
132
+ MAX_MAX_FIELDS,
133
+ MIN_MAX_FIELDS,
134
+ suggest_struct,
135
+ )
136
+ from codevanta.analysis.xrefs import DEFAULT_MAX_BYTES, find_xrefs
137
+ from codevanta.core.functions import discover_functions
138
+ from codevanta.core.loader import BinaryInfo, BinaryLoader, BinaryLoadError
139
+ from codevanta.core.report import (
140
+ DEFAULT_XREF_LIMIT,
141
+ MAX_DISASSEMBLY_COUNT,
142
+ MAX_STRING_LIMIT,
143
+ MAX_XREF_LIMIT,
144
+ AnalysisError,
145
+ build_report,
146
+ )
147
+ from codevanta.core.strings import StringExtractor
148
+ from codevanta.storage.session import DEFAULT_LIMIT as DEFAULT_SESSION_LIMIT
149
+ from codevanta.storage.session import MAX_LIMIT as MAX_SESSION_LIMIT
150
+ from codevanta.storage.session import MAX_NOTE_LENGTH as MAX_SESSION_NOTE_LENGTH
151
+ from codevanta.storage.session import SessionError, SessionRecord, SessionStore
152
+
153
+ from .layout import render_dashboard_html
154
+
155
+ __all__ = [
156
+ "DEFAULT_SESSION_DB",
157
+ "MAX_RESPONSE_BYTES",
158
+ "ROUTES",
159
+ "SECURITY_HEADERS",
160
+ "SESSION_DB_ENV",
161
+ "VERSION",
162
+ "create_handler",
163
+ "create_server",
164
+ "is_allowed_host",
165
+ "normalize_host",
166
+ "serve",
167
+ "session_db_path",
168
+ ]
169
+
170
+ #: Version reported by ``/api/health`` when the package metadata is unavailable.
171
+ _VERSION_FALLBACK = "0.1.0"
172
+
173
+ #: Default cap on a rendered JSON response (the analysis limits keep payloads
174
+ #: far below it; the cap only guards against pathological input).
175
+ MAX_RESPONSE_BYTES = 4 * 1024 * 1024
176
+
177
+ #: Defaults for the routes whose engine default is tuned for a full report.
178
+ DEFAULT_INFO_FUNCTIONS = 50
179
+ DEFAULT_FUNCTIONS_LIMIT = 200
180
+ DEFAULT_STRINGS_LIMIT = 200
181
+ DEFAULT_MIN_STRING_LENGTH = 4
182
+ DEFAULT_CFG_INSTRUCTIONS = 2_000
183
+ DEFAULT_CFG_BLOCKS = 500
184
+
185
+ #: Backend ``/api/decompile`` uses when the query parameter is absent.
186
+ DEFAULT_DECOMPILE_BACKEND = "auto"
187
+
188
+ #: Backends ``/api/decompile`` accepts: ``auto`` plus every registered provider.
189
+ DECOMPILE_BACKENDS: frozenset[str] = frozenset(
190
+ {DEFAULT_DECOMPILE_BACKEND, *supported_backends()}
191
+ )
192
+
193
+ #: Prefix of the block-coverage line the native renderer writes in its header.
194
+ _HEADER_BLOCKS_PREFIX = " * blocks: "
195
+
196
+ #: Environment variable that overrides the session database location. It is the
197
+ #: same variable the CLI and the MCP tools read, so a dashboard, a ``codevanta
198
+ #: session list`` shell and an agent all share one database.
199
+ SESSION_DB_ENV = "CODEVANTA_DB"
200
+
201
+ #: Session database used when :data:`SESSION_DB_ENV` is unset.
202
+ DEFAULT_SESSION_DB = Path.home() / ".codevanta" / "sessions.db"
203
+
204
+ #: Minimum ``/api/session_list`` limit: ``SessionStore.list`` rejects ``<= 0``.
205
+ MIN_SESSION_LIMIT = 1
206
+
207
+ #: Content types used by the two response kinds.
208
+ _JSON_CONTENT_TYPE = "application/json; charset=utf-8"
209
+ _HTML_CONTENT_TYPE = "text/html; charset=utf-8"
210
+
211
+ #: Headers attached to every response, whatever the route or the status code.
212
+ #:
213
+ #: ``X-Frame-Options`` blocks framing the dashboard from another page,
214
+ #: ``X-Content-Type-Options`` stops content sniffing and the policy allows exactly
215
+ #: what the page needs -- its own inline script and style, XHR to the same origin
216
+ #: -- and nothing else (no images, fonts, frames or remote origins).
217
+ SECURITY_HEADERS: tuple[tuple[str, str], ...] = (
218
+ ("X-Frame-Options", "DENY"),
219
+ ("X-Content-Type-Options", "nosniff"),
220
+ (
221
+ "Content-Security-Policy",
222
+ "default-src 'none'; script-src 'unsafe-inline'; "
223
+ "style-src 'unsafe-inline'; connect-src 'self'",
224
+ ),
225
+ )
226
+
227
+ #: Host header names the dashboard answers to; the port, when present, must also
228
+ #: match the port the server is listening on.
229
+ _ALLOWED_HOST_NAMES = frozenset({"127.0.0.1", "localhost", "::1"})
230
+
231
+ #: Longest string kept inside a payload that has to be cut down to the cap.
232
+ _MAX_KEPT_STRING = 256
233
+
234
+ #: Longest list kept inside a payload that has to be cut down to the cap.
235
+ _MAX_KEPT_ITEMS = 32
236
+
237
+
238
+ def _package_version() -> str:
239
+ """Return the installed ``codevanta`` version, or the fallback constant."""
240
+ try:
241
+ from importlib.metadata import version
242
+
243
+ return version("codevanta")
244
+ except Exception: # pragma: no cover - metadata is present once installed
245
+ return _VERSION_FALLBACK
246
+
247
+
248
+ #: Version string exposed by ``/api/health``.
249
+ VERSION = _package_version()
250
+
251
+
252
+ def session_db_path() -> Path:
253
+ """Return the session database the two session routes read and write.
254
+
255
+ Resolution matches the CLI (``codevanta session ...``) and the MCP tools:
256
+ ``$CODEVANTA_DB`` when it is set to a non-blank value, otherwise
257
+ :data:`DEFAULT_SESSION_DB`. A blank variable counts as unset, so exporting
258
+ ``CODEVANTA_DB=`` never resolves to the current directory.
259
+
260
+ There is deliberately **no** ``db`` query parameter: the route table takes
261
+ the location from the server's own environment, so a request can never
262
+ point the dashboard at an arbitrary file to create or overwrite.
263
+
264
+ Returns:
265
+ The database path (it need not exist yet; the store creates it).
266
+ """
267
+ configured = os.environ.get(SESSION_DB_ENV)
268
+ if configured and configured.strip():
269
+ return Path(configured.strip()).expanduser()
270
+ return DEFAULT_SESSION_DB
271
+
272
+
273
+ class _RouteError(ValueError):
274
+ """Bad request input; reported as HTTP 400 with the message as ``error``."""
275
+
276
+
277
+ def normalize_host(host: str) -> str:
278
+ """Validate ``host`` as a loopback address and return the bind host.
279
+
280
+ Accepted inputs are ``127.0.0.1``, ``localhost``, ``::1`` and any address in
281
+ ``127.0.0.0/8``; ``localhost`` normalizes to ``127.0.0.1``.
282
+
283
+ Args:
284
+ host: Requested bind host.
285
+
286
+ Returns:
287
+ The address to bind.
288
+
289
+ Raises:
290
+ ValueError: If ``host`` is not a string or not a loopback address.
291
+ """
292
+ if not isinstance(host, str):
293
+ raise ValueError(f"host must be a string, got {type(host).__name__}")
294
+ candidate = host.strip().lower()
295
+ if not candidate:
296
+ raise ValueError(
297
+ "host must be a loopback address (127.0.0.1, localhost or ::1); "
298
+ "an empty host would listen on every interface"
299
+ )
300
+ if candidate == "localhost":
301
+ return "127.0.0.1"
302
+ if candidate.startswith("[") and candidate.endswith("]"):
303
+ candidate = candidate[1:-1]
304
+ if candidate == "::1":
305
+ return "::1"
306
+ octets = candidate.split(".")
307
+ if len(octets) == 4 and all(octet.isdigit() for octet in octets):
308
+ values = [int(octet) for octet in octets]
309
+ if all(0 <= value <= 255 for value in values) and values[0] == 127:
310
+ return candidate
311
+ raise ValueError(
312
+ f"refusing to bind {host!r}: the dashboard is loopback-only "
313
+ "(127.0.0.1, localhost, ::1 or 127.x.y.z)"
314
+ )
315
+
316
+
317
+ def _split_host_header(value: str) -> tuple[str, str | None] | None:
318
+ """Split a ``Host`` header into ``(name, port-or-None)``, or ``None``.
319
+
320
+ Both the bracketed IPv6 form (``[::1]``, ``[::1]:8765``) and the plain
321
+ ``name:port`` form are understood; anything structurally ambiguous (an
322
+ unterminated bracket, a trailing colon-suffix, an unbracketed IPv6 literal
323
+ other than a bare loopback) is reported as malformed.
324
+ """
325
+ text = value.strip().lower()
326
+ if not text:
327
+ return None
328
+ if text.startswith("["):
329
+ closing = text.find("]")
330
+ if closing < 0:
331
+ return None
332
+ rest = text[closing + 1:]
333
+ if rest and not rest.startswith(":"):
334
+ return None
335
+ return text[1:closing], (rest[1:] or None)
336
+ if text.count(":") > 1:
337
+ # An unbracketed IPv6 literal cannot carry a port; only the bare
338
+ # loopback address is meaningful here.
339
+ return (text, None) if text in _ALLOWED_HOST_NAMES else None
340
+ if ":" in text:
341
+ name, _, raw_port = text.partition(":")
342
+ return name, (raw_port or None)
343
+ return text, None
344
+
345
+
346
+ def is_allowed_host(value: str | None, port: int) -> bool:
347
+ """Return whether ``value`` is an acceptable ``Host`` header for ``port``.
348
+
349
+ The dashboard is only ever reached through its own loopback URL, so the rule
350
+ is deliberately narrow: the name must be ``127.0.0.1``, ``localhost`` or
351
+ ``[::1]`` and, when the header carries a port, that port must be the port this
352
+ server is listening on. A missing header, a foreign name (the DNS-rebinding
353
+ case) or a mismatched port is rejected.
354
+
355
+ Args:
356
+ value: The raw ``Host`` header, or ``None`` when it was not sent.
357
+ port: The TCP port the server accepted the request on.
358
+
359
+ Returns:
360
+ ``True`` when the request may be served, ``False`` when it must get a
361
+ ``421`` instead.
362
+ """
363
+ if not isinstance(value, str):
364
+ return False
365
+ split = _split_host_header(value)
366
+ if split is None:
367
+ return False
368
+ name, raw_port = split
369
+ if name not in _ALLOWED_HOST_NAMES:
370
+ return False
371
+ if raw_port is None:
372
+ return True
373
+ if not raw_port.isdigit():
374
+ return False
375
+ return int(raw_port) == port
376
+
377
+
378
+ def _first(params: dict[str, str], name: str) -> str | None:
379
+ """Return the stripped first value of ``name``, or ``None`` when absent."""
380
+ value = params.get(name)
381
+ if value is None:
382
+ return None
383
+ text = value.strip()
384
+ return text or None
385
+
386
+
387
+ def _query_int(
388
+ params: dict[str, str],
389
+ name: str,
390
+ default: int,
391
+ minimum: int,
392
+ maximum: int,
393
+ ) -> int:
394
+ """Parse an integer query parameter and enforce ``minimum..maximum``."""
395
+ raw = _first(params, name)
396
+ if raw is None:
397
+ return default
398
+ try:
399
+ value = int(raw, 10)
400
+ except ValueError:
401
+ raise _RouteError(f"{name} must be an integer, got {raw!r}") from None
402
+ if value < minimum or value > maximum:
403
+ raise _RouteError(f"{name} must be within {minimum}..{maximum}, got {value}")
404
+ return value
405
+
406
+
407
+ def _reject_unknown(params: dict[str, str], allowed: frozenset[str]) -> None:
408
+ """Reject every query parameter outside ``allowed``.
409
+
410
+ The analysis routes tolerate extra parameters (each one reads only the names
411
+ it understands, so an older page keeps working against a newer server). The
412
+ session routes are different: they *write*, so a typo such as ``notse=`` must
413
+ be reported instead of silently saving a session with no note. Unknown names
414
+ are listed in the error, because the caller needs to see which one was wrong.
415
+ """
416
+ unknown = sorted(set(params) - allowed)
417
+ if unknown:
418
+ shown = ", ".join(repr(name) for name in unknown)
419
+ raise _RouteError(
420
+ f"unknown query parameter(s) {shown}; this route accepts "
421
+ f"{', '.join(sorted(allowed)) or 'no parameters'}"
422
+ )
423
+
424
+
425
+ def _query_notes(params: dict[str, str], name: str) -> str | None:
426
+ """Parse the optional free-text ``notes`` parameter.
427
+
428
+ Notes are stored verbatim (newlines and non-ASCII characters included) but
429
+ are bounded: the store itself only checks the type, so the route reuses the
430
+ per-note ceiling the storage layer already declares, keeping one request
431
+ from parking an unbounded blob in the database. A blank value means "no
432
+ note" rather than an empty one, so the listing does not have to tell the two
433
+ spellings apart.
434
+ """
435
+ raw = _first(params, name)
436
+ if raw is None:
437
+ return None
438
+ if len(raw) > MAX_SESSION_NOTE_LENGTH:
439
+ raise _RouteError(
440
+ f"{name} must be at most {MAX_SESSION_NOTE_LENGTH} characters, "
441
+ f"got {len(raw)}"
442
+ )
443
+ return raw
444
+
445
+
446
+ def _query_float(
447
+ params: dict[str, str],
448
+ name: str,
449
+ default: float,
450
+ minimum: float,
451
+ maximum: float,
452
+ ) -> float:
453
+ """Parse a finite float query parameter and enforce ``minimum..maximum``."""
454
+ raw = _first(params, name)
455
+ if raw is None:
456
+ return default
457
+ try:
458
+ value = float(raw)
459
+ except ValueError:
460
+ raise _RouteError(f"{name} must be a number, got {raw!r}") from None
461
+ if not math.isfinite(value):
462
+ raise _RouteError(f"{name} must be a finite number, got {raw!r}")
463
+ if value < minimum or value > maximum:
464
+ raise _RouteError(f"{name} must be within {minimum}..{maximum}, got {value}")
465
+ return value
466
+
467
+
468
+ def _query_flag(params: dict[str, str], name: str, default: bool) -> bool:
469
+ """Parse a ``0|1`` (or ``false|true``) query parameter as a boolean."""
470
+ raw = _first(params, name)
471
+ if raw is None:
472
+ return default
473
+ lowered = raw.lower()
474
+ if lowered in {"0", "false", "no", "off"}:
475
+ return False
476
+ if lowered in {"1", "true", "yes", "on"}:
477
+ return True
478
+ raise _RouteError(f"{name} must be 0 or 1, got {raw!r}")
479
+
480
+
481
+ def _query_address(params: dict[str, str], name: str) -> int | None:
482
+ """Parse a hex (``0x...``) or decimal address query parameter."""
483
+ raw = _first(params, name)
484
+ if raw is None:
485
+ return None
486
+ try:
487
+ value = int(raw, 16) if raw.lower().startswith("0x") else int(raw, 10)
488
+ except ValueError:
489
+ raise _RouteError(
490
+ f"{name} must be a hexadecimal (0x...) or decimal address, got {raw!r}"
491
+ ) from None
492
+ if value < 0:
493
+ raise _RouteError(f"{name} must be a non-negative address, got {raw}")
494
+ return value
495
+
496
+
497
+ def _query_path(params: dict[str, str], name: str, allow_paths: bool) -> Path:
498
+ """Return the file path named by ``name`` as a :class:`~pathlib.Path`."""
499
+ raw = _first(params, name)
500
+ if raw is None:
501
+ raise _RouteError(f"{name} is required")
502
+ if not allow_paths:
503
+ raise _RouteError(
504
+ f"{name} is not accepted by this server (path parameters are disabled)"
505
+ )
506
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
507
+ # Pasted paths regularly arrive with the shell or Explorer quotes attached.
508
+ raw = raw[1:-1].strip()
509
+ if not raw:
510
+ raise _RouteError(f"{name} is required")
511
+ try:
512
+ return Path(raw)
513
+ except (ValueError, OSError) as exc:
514
+ raise _RouteError(f"{name} is not a usable path: {exc}") from None
515
+
516
+
517
+ def _load(path: Path) -> BinaryInfo:
518
+ """Load ``path`` through the production loader (failures become HTTP 400)."""
519
+ return BinaryLoader().load(path)
520
+
521
+
522
+ def _api_health(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
523
+ """Return a static liveness payload."""
524
+ return {
525
+ "status": "ok",
526
+ "version": VERSION,
527
+ "routes": sorted(ROUTES),
528
+ "paths_allowed": allow_paths,
529
+ }
530
+
531
+
532
+ def _api_info(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
533
+ """Report binary metadata, provenance and the bounded function list."""
534
+ path = _query_path(params, "path", allow_paths)
535
+ max_functions = _query_int(params, "max_functions", DEFAULT_INFO_FUNCTIONS, 0, MAX_LIMIT)
536
+ report = build_report(path, string_limit=0, xref_limit=0)
537
+ functions = report["functions"]
538
+ kept = functions[:max_functions]
539
+ return {
540
+ "binary": report["binary"],
541
+ "provenance": report["provenance"],
542
+ "function_count": len(functions),
543
+ "functions_returned": len(kept),
544
+ "functions_truncated": len(kept) < len(functions),
545
+ "functions": kept,
546
+ "string_count": report["strings"]["count"],
547
+ }
548
+
549
+
550
+ def _api_functions(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
551
+ """Return the function candidates reported by structural discovery."""
552
+ path = _query_path(params, "path", allow_paths)
553
+ limit = _query_int(params, "limit", DEFAULT_FUNCTIONS_LIMIT, 0, MAX_LIMIT)
554
+ info = _load(path)
555
+ candidates = discover_functions(info)
556
+ kept = candidates[:limit]
557
+ return {
558
+ "path": str(info.path),
559
+ "format": info.format,
560
+ "count": len(candidates),
561
+ "returned": len(kept),
562
+ "truncated": len(kept) < len(candidates),
563
+ "functions": [candidate.to_dict() for candidate in kept],
564
+ }
565
+
566
+
567
+ def _api_strings(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
568
+ """Return ASCII/UTF-16 strings found by the production string scanner."""
569
+ path = _query_path(params, "path", allow_paths)
570
+ min_length = _query_int(
571
+ params, "min_length", DEFAULT_MIN_STRING_LENGTH, 1, MAX_STRING_LIMIT
572
+ )
573
+ limit = _query_int(params, "limit", DEFAULT_STRINGS_LIMIT, 0, MAX_STRING_LIMIT)
574
+ info = _load(path)
575
+ extractor = StringExtractor(min_length=min_length)
576
+ # ``limit=0`` means "none"; a limit of N is probed with N+1 so truncation is
577
+ # known without scanning the file twice.
578
+ found = extractor.extract(path, info=info, limit=limit + 1) if limit else []
579
+ kept = found[:limit]
580
+ return {
581
+ "path": str(info.path),
582
+ "format": info.format,
583
+ "min_length": min_length,
584
+ "limit": limit,
585
+ "returned": len(kept),
586
+ "truncated": len(found) > limit,
587
+ "strings": [item.to_dict() for item in kept],
588
+ }
589
+
590
+
591
+ def _api_xrefs(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
592
+ """Return cross references from the bounded executable-byte scan."""
593
+ path = _query_path(params, "path", allow_paths)
594
+ limit = _query_int(params, "limit", DEFAULT_XREF_LIMIT, 0, MAX_XREF_LIMIT)
595
+ info = _load(path)
596
+ scan = find_xrefs(path, info, max_bytes=DEFAULT_MAX_BYTES)
597
+ kept = scan.xrefs[:limit]
598
+ payload = scan.to_dict()
599
+ payload["path"] = str(info.path)
600
+ payload["format"] = info.format
601
+ payload["limit"] = limit
602
+ payload["returned"] = len(kept)
603
+ payload["scan_truncated"] = scan.truncated
604
+ payload["truncated"] = len(kept) < len(scan.xrefs)
605
+ payload["xrefs"] = [xref.to_dict() for xref in kept]
606
+ return payload
607
+
608
+
609
+ def _api_cfg(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
610
+ """Return a control-flow graph, linear or recursively traversed."""
611
+ path = _query_path(params, "path", allow_paths)
612
+ address = _query_address(params, "address")
613
+ traversal = (_first(params, "traversal") or "linear").lower()
614
+ if traversal not in {"linear", "recursive"}:
615
+ raise _RouteError(f"traversal must be 'linear' or 'recursive', got {traversal!r}")
616
+ max_instructions = _query_int(
617
+ params, "max_instructions", DEFAULT_CFG_INSTRUCTIONS, 1, MAX_DISASSEMBLY_COUNT
618
+ )
619
+ max_blocks = _query_int(params, "max_blocks", DEFAULT_CFG_BLOCKS, 1, MAX_DISASSEMBLY_COUNT)
620
+ info = _load(path)
621
+ start = address if address is not None else info.entry_point
622
+ if start is None:
623
+ raise _RouteError("address is required: the binary has no entry point")
624
+ builder = build_recursive_cfg if traversal == "recursive" else build_cfg
625
+ graph = builder(
626
+ path,
627
+ info,
628
+ start,
629
+ max_instructions=max_instructions,
630
+ max_blocks=max_blocks,
631
+ )
632
+ payload = graph.to_dict()
633
+ payload["traversal"] = traversal
634
+ payload["path"] = str(info.path)
635
+ return payload
636
+
637
+
638
+ def _api_struct(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
639
+ """Return the struct-layout suggestion for one code window."""
640
+ path = _query_path(params, "path", allow_paths)
641
+ address = _query_address(params, "address")
642
+ include_stack = _query_flag(params, "include_stack", False)
643
+ max_fields = _query_int(
644
+ params, "max_fields", DEFAULT_MAX_FIELDS, MIN_MAX_FIELDS, MAX_MAX_FIELDS
645
+ )
646
+ info = _load(path)
647
+ start = address if address is not None else info.entry_point
648
+ if start is None:
649
+ raise _RouteError("address is required: the binary has no entry point")
650
+ suggestion = suggest_struct(
651
+ path,
652
+ info,
653
+ start,
654
+ max_fields=max_fields,
655
+ include_stack=include_stack,
656
+ )
657
+ payload = suggestion.to_dict()
658
+ payload["path"] = str(info.path)
659
+ payload["include_stack"] = include_stack
660
+ return payload
661
+
662
+
663
+ def _api_callgraph(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
664
+ """Return the direct-call graph with both engine limits honoured."""
665
+ path = _query_path(params, "path", allow_paths)
666
+ max_functions = _query_int(
667
+ params, "limit_functions", DEFAULT_MAX_FUNCTIONS, 1, MAX_LIMIT
668
+ )
669
+ max_edges = _query_int(params, "limit_edges", DEFAULT_MAX_EDGES, 1, MAX_LIMIT)
670
+ info = _load(path)
671
+ graph = build_call_graph(
672
+ path,
673
+ info,
674
+ max_functions=max_functions,
675
+ max_edges=max_edges,
676
+ )
677
+ payload = graph.to_dict()
678
+ payload["path"] = str(info.path)
679
+ payload["limit_functions"] = max_functions
680
+ payload["limit_edges"] = max_edges
681
+ return payload
682
+
683
+
684
+ def _api_diff(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
685
+ """Return the structural (or semantic) difference between two binaries."""
686
+ left = _query_path(params, "left", allow_paths)
687
+ right = _query_path(params, "right", allow_paths)
688
+ semantic = _query_flag(params, "semantic", False)
689
+ threshold = _query_float(
690
+ params,
691
+ "threshold",
692
+ DEFAULT_SEMANTIC_THRESHOLD,
693
+ MIN_SEMANTIC_THRESHOLD,
694
+ MAX_SEMANTIC_THRESHOLD,
695
+ )
696
+ result = diff_binaries(left, right, semantic=semantic, threshold=threshold)
697
+ payload = result.to_dict()
698
+ payload["semantic"] = semantic
699
+ payload["threshold"] = threshold
700
+ return payload
701
+
702
+
703
+ def _decompiled_block_count(result: BackendResult) -> int | None:
704
+ """Return how many basic blocks the rendering covered, when it says so.
705
+
706
+ Mirrors :func:`codevanta.mcp_server.tools.decompile`'s helper so the API and
707
+ the MCP tool publish the same JSON: the external backends record their block
708
+ count in ``evidence.extra``, while the native renderer publishes it in the
709
+ pseudo-C header comment (`` * blocks: N instructions: M``). ``None`` means
710
+ the backend reported no coverage.
711
+ """
712
+ recorded = result.evidence.extra.get("block_count")
713
+ if isinstance(recorded, int) and not isinstance(recorded, bool):
714
+ return recorded
715
+ for line in result.text.splitlines():
716
+ if line == " */":
717
+ break
718
+ if not line.startswith(_HEADER_BLOCKS_PREFIX):
719
+ continue
720
+ field = line[len(_HEADER_BLOCKS_PREFIX) :].split(" ", 1)[0]
721
+ if field.isdigit():
722
+ return int(field)
723
+ return None
724
+
725
+
726
+ def _api_decompile(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
727
+ """Return one function rendered as C-like text by the chosen backend.
728
+
729
+ ``backend=auto`` (the default) prefers an installed external decompiler and
730
+ falls back to the built-in renderer, listing every skipped backend in
731
+ ``warnings``; ``angr`` and ``ghidra`` are strict and answer ``400`` with a
732
+ message naming the backend when it is unavailable, so a missing optional
733
+ dependency never becomes a ``500`` or an ``ImportError``.
734
+ """
735
+ path = _query_path(params, "path", allow_paths)
736
+ address = _query_address(params, "address")
737
+ backend = (_first(params, "backend") or DEFAULT_DECOMPILE_BACKEND).lower()
738
+ if backend not in DECOMPILE_BACKENDS:
739
+ supported = ", ".join(sorted(DECOMPILE_BACKENDS))
740
+ raise _RouteError(f"backend must be one of {supported}, got {backend!r}")
741
+ max_instructions = _query_int(
742
+ params,
743
+ "max_instructions",
744
+ DEFAULT_DECOMPILE_MAX_INSTRUCTIONS,
745
+ 1,
746
+ MAX_DECOMPILE_MAX_INSTRUCTIONS,
747
+ )
748
+ max_blocks = _query_int(
749
+ params,
750
+ "max_blocks",
751
+ DEFAULT_DECOMPILE_MAX_BLOCKS,
752
+ 1,
753
+ MAX_DECOMPILE_MAX_BLOCKS,
754
+ )
755
+ info = _load(path)
756
+ start = address if address is not None else info.entry_point
757
+ if start is None:
758
+ raise _RouteError("address is required: the binary has no entry point")
759
+ result = decompile_with_backend(
760
+ path,
761
+ info,
762
+ start,
763
+ backend=backend,
764
+ max_instructions=max_instructions,
765
+ max_blocks=max_blocks,
766
+ )
767
+ payload = result.to_dict()
768
+ payload["path"] = str(info.path)
769
+ payload["address"] = f"0x{start:x}"
770
+ payload["blocks"] = _decompiled_block_count(result)
771
+ return payload
772
+
773
+
774
+ def _query_registers(params: dict[str, str], name: str) -> dict[str, int] | None:
775
+ """Parse repeatable ``set=REG=VAL`` pairs into an initial-register map.
776
+
777
+ ``set`` may be repeated (``set=eax=1&set=ebx=2``) -- :func:`_parse_query`
778
+ hands repeated occurrences over joined with a comma -- or carry
779
+ comma-separated pairs in one value. Values are decimal or ``0x``-hex
780
+ integers; register names and widths are validated by the engine itself, so
781
+ an unknown name surfaces as the engine's error, not a parser guess.
782
+ """
783
+ raw = _first(params, name)
784
+ if raw is None:
785
+ return None
786
+ seeds: dict[str, int] = {}
787
+ for pair in raw.split(","):
788
+ text = pair.strip()
789
+ reg, sep, value = text.partition("=")
790
+ reg = reg.strip().lower()
791
+ value = value.strip()
792
+ if not sep or not reg or not value:
793
+ raise _RouteError(
794
+ f"{name} must be REG=VAL pairs (e.g. set=eax=0x2a), got {text!r}"
795
+ )
796
+ try:
797
+ seeds[reg] = int(value, 16) if value.lower().startswith("0x") else int(value, 10)
798
+ except ValueError:
799
+ raise _RouteError(
800
+ f"{name} value must be a hexadecimal (0x...) or decimal integer, "
801
+ f"got {value!r}"
802
+ ) from None
803
+ return seeds
804
+
805
+
806
+ def _api_emulate(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
807
+ """Emulate one function on the sandboxed CPU and return the run trace."""
808
+ if not EMULATOR_AVAILABLE:
809
+ raise _RouteError(emulator_status())
810
+ path = _query_path(params, "path", allow_paths)
811
+ address = _query_address(params, "address")
812
+ max_instructions = _query_int(
813
+ params,
814
+ "max_instructions",
815
+ DEFAULT_EMULATE_MAX_INSTRUCTIONS,
816
+ 1,
817
+ MAX_EMULATE_MAX_INSTRUCTIONS,
818
+ )
819
+ seeds = _query_registers(params, "set")
820
+ info = _load(path)
821
+ start = address if address is not None else info.entry_point
822
+ if start is None:
823
+ raise _RouteError("address is required: the binary has no entry point")
824
+ result = emulate_function(
825
+ path,
826
+ info,
827
+ start,
828
+ initial_registers=seeds,
829
+ max_instructions=max_instructions,
830
+ )
831
+ payload = result.to_dict()
832
+ payload["path"] = str(info.path)
833
+ payload["address"] = f"0x{start:x}"
834
+ return payload
835
+
836
+
837
+ def _api_symbolic(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
838
+ """Explore the execution paths through one function, both limits honoured."""
839
+ path = _query_path(params, "path", allow_paths)
840
+ address = _query_address(params, "address")
841
+ max_paths = _query_int(
842
+ params, "max_paths", DEFAULT_SYMBOLIC_MAX_PATHS, 1, MAX_SYMBOLIC_MAX_PATHS
843
+ )
844
+ max_steps = _query_int(
845
+ params, "max_steps", DEFAULT_SYMBOLIC_MAX_STEPS, 1, MAX_SYMBOLIC_MAX_STEPS
846
+ )
847
+ info = _load(path)
848
+ start = address if address is not None else info.entry_point
849
+ if start is None:
850
+ raise _RouteError("address is required: the binary has no entry point")
851
+ exploration = explore_paths(path, info, start, max_paths=max_paths, max_steps=max_steps)
852
+ payload = exploration.to_dict()
853
+ payload["path"] = str(info.path)
854
+ payload["address"] = f"0x{start:x}"
855
+ return payload
856
+
857
+
858
+ def _session_item(record: SessionRecord) -> dict[str, Any]:
859
+ """Project a stored session onto the fields the listing exposes.
860
+
861
+ The full record also carries the SHA-256, bitness, entry point and the two
862
+ counts; the dashboard panel shows one row per session and needs the
863
+ path/format/architecture identity plus the note, so only those are returned.
864
+ """
865
+ return {
866
+ "session_id": record.session_id,
867
+ "path": record.binary_path,
868
+ "format": record.format,
869
+ "architecture": record.architecture,
870
+ "created_at": record.created_at,
871
+ "notes": record.notes,
872
+ }
873
+
874
+
875
+ def _api_session_save(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
876
+ """Analyze a binary and persist the run as a session row.
877
+
878
+ This is the only route with an effect: it appends one row to the database
879
+ named by :func:`session_db_path` and returns the identifiers of that row so
880
+ the page can show it without a second request. Re-saving the same binary is
881
+ legal and appends another row -- the store keeps an audit trail of repeated
882
+ analyses rather than overwriting.
883
+ """
884
+ _reject_unknown(params, frozenset({"path", "notes"}))
885
+ path = _query_path(params, "path", allow_paths)
886
+ notes = _query_notes(params, "notes")
887
+ info = _load(path)
888
+ # The counts are the two numbers the store persists next to the fingerprint;
889
+ # they are computed by the same engines ``/api/functions`` and ``/api/strings``
890
+ # serve, so the saved row can never disagree with what the page displayed.
891
+ functions_count = len(discover_functions(info))
892
+ strings_count = len(StringExtractor().extract(path, info=info))
893
+ with SessionStore(session_db_path()) as store:
894
+ record = store.save(
895
+ info,
896
+ functions_count,
897
+ strings_count,
898
+ notes=notes,
899
+ )
900
+ return {"session_id": record.session_id, "created_at": record.created_at}
901
+
902
+
903
+ def _api_session_list(params: dict[str, str], allow_paths: bool) -> dict[str, Any]:
904
+ """Return the stored sessions, newest first, bounded by ``limit``.
905
+
906
+ ``limit`` is validated against the storage bounds -- ``1`` up to
907
+ :data:`~codevanta.storage.session.MAX_LIMIT` -- and defaults to the store's
908
+ own :data:`~codevanta.storage.session.DEFAULT_LIMIT`, so the route never
909
+ asks the store for a window it would reject or silently shrink.
910
+ """
911
+ _reject_unknown(params, frozenset({"limit"}))
912
+ limit = _query_int(
913
+ params,
914
+ "limit",
915
+ DEFAULT_SESSION_LIMIT,
916
+ MIN_SESSION_LIMIT,
917
+ MAX_SESSION_LIMIT,
918
+ )
919
+ with SessionStore(session_db_path()) as store:
920
+ records = store.list(limit=limit)
921
+ return {
922
+ "limit": limit,
923
+ "count": len(records),
924
+ "sessions": [_session_item(record) for record in records],
925
+ }
926
+
927
+
928
+ #: Route table: request path -> payload builder. Builders take the query
929
+ #: parameters and the handler's ``paths_allowed`` flag.
930
+ ROUTES: dict[str, Callable[[dict[str, str], bool], dict[str, Any]]] = {
931
+ "/api/health": _api_health,
932
+ "/api/info": _api_info,
933
+ "/api/functions": _api_functions,
934
+ "/api/strings": _api_strings,
935
+ "/api/xrefs": _api_xrefs,
936
+ "/api/cfg": _api_cfg,
937
+ "/api/struct": _api_struct,
938
+ "/api/callgraph": _api_callgraph,
939
+ "/api/diff": _api_diff,
940
+ "/api/decompile": _api_decompile,
941
+ "/api/emulate": _api_emulate,
942
+ "/api/symbolic": _api_symbolic,
943
+ "/api/session_save": _api_session_save,
944
+ "/api/session_list": _api_session_list,
945
+ }
946
+
947
+ def _json_default(value: Any) -> Any:
948
+ """Last-resort encoder for values the analysis payloads should not contain."""
949
+ if isinstance(value, Path):
950
+ return str(value)
951
+ if isinstance(value, (bytes, bytearray)):
952
+ return f"<{len(value)} bytes>"
953
+ if isinstance(value, (set, frozenset)):
954
+ return sorted(value, key=str)
955
+ return str(value)
956
+
957
+
958
+ def _dump(payload: Any) -> bytes:
959
+ """Serialize ``payload`` to UTF-8 JSON using the payload encoder rules."""
960
+ return json.dumps(payload, ensure_ascii=False, default=_json_default).encode("utf-8")
961
+
962
+
963
+ def _cut_value(value: Any) -> Any:
964
+ """Replace oversized values with bounded placeholders, recursively.
965
+
966
+ Strings longer than :data:`_MAX_KEPT_STRING` become ``"<cut: N chars>"`` and
967
+ lists longer than :data:`_MAX_KEPT_ITEMS` keep their head plus a
968
+ ``"<cut: N items>"`` marker, so a payload whose size comes from one huge
969
+ scalar -- or from one long list nested below the top level -- shrinks to
970
+ something that fits, instead of staying oversized.
971
+ """
972
+ if isinstance(value, str):
973
+ if len(value) > _MAX_KEPT_STRING:
974
+ return f"<cut: {len(value)} chars>"
975
+ return value
976
+ if isinstance(value, (bytes, bytearray)):
977
+ return f"<{len(value)} bytes>"
978
+ if isinstance(value, dict):
979
+ return {key: _cut_value(item) for key, item in value.items()}
980
+ if isinstance(value, (set, frozenset)):
981
+ return _cut_value(sorted(value, key=str))
982
+ if isinstance(value, (list, tuple)):
983
+ items = [_cut_value(item) for item in value]
984
+ if len(items) > _MAX_KEPT_ITEMS:
985
+ dropped = len(items) - _MAX_KEPT_ITEMS
986
+ items = [*items[:_MAX_KEPT_ITEMS], f"<cut: {dropped} items>"]
987
+ return items
988
+ return value
989
+
990
+
991
+ def _encode_payload(payload: dict[str, Any]) -> bytes:
992
+ """Render ``payload`` as JSON bytes, always within :data:`MAX_RESPONSE_BYTES`.
993
+
994
+ The shrink is staged, cheapest loss first: top-level lists are halved, then
995
+ every oversized string and nested list is replaced by a placeholder, and only
996
+ if the payload still does not fit is it replaced by a minimal error document.
997
+ The returned body is therefore never larger than the cap, whatever the shape
998
+ of the payload, and every truncated form is self-describing
999
+ (``"response_truncated": true``).
1000
+ """
1001
+ body = _dump(payload)
1002
+ if len(body) <= MAX_RESPONSE_BYTES:
1003
+ return body
1004
+ capped = dict(payload)
1005
+ capped["response_truncated"] = True
1006
+ for _ in range(16):
1007
+ body = _dump(capped)
1008
+ if len(body) <= MAX_RESPONSE_BYTES:
1009
+ return body
1010
+ shrunk = False
1011
+ for key, value in capped.items():
1012
+ if isinstance(value, list) and len(value) > 1:
1013
+ capped[key] = value[: max(1, len(value) // 2)]
1014
+ shrunk = True
1015
+ if not shrunk:
1016
+ break
1017
+ body = _dump(_cut_value(capped))
1018
+ if len(body) <= MAX_RESPONSE_BYTES:
1019
+ return body
1020
+ return _dump(
1021
+ {
1022
+ "error": "response too large",
1023
+ "response_truncated": True,
1024
+ "limit": MAX_RESPONSE_BYTES,
1025
+ }
1026
+ )
1027
+
1028
+
1029
+
1030
+ def _parse_query(query: str) -> dict[str, str]:
1031
+ """Flatten a query string into ``{name: value}``.
1032
+
1033
+ A repeated parameter (``set=eax=1&set=ebx=2``) arrives as one comma-joined
1034
+ value, so the repeatable ``set=REG=VAL`` form of ``/api/emulate`` survives
1035
+ the flattening. Routes that accept a parameter only once reject the joined
1036
+ value as malformed input instead of silently picking the first occurrence.
1037
+ """
1038
+ parsed = parse_qs(query, keep_blank_values=True)
1039
+ return {key: ",".join(values) if values else "" for key, values in parsed.items()}
1040
+
1041
+
1042
+ class _GuiRequestHandler(BaseHTTPRequestHandler):
1043
+ """Request handler implementing the dashboard route table.
1044
+
1045
+ :func:`create_handler` subclasses this to bind the ``paths_allowed`` policy;
1046
+ the class itself defaults to accepting path parameters.
1047
+ """
1048
+
1049
+ server_version = f"CODEVANTA-gui/{VERSION}"
1050
+ protocol_version = "HTTP/1.1"
1051
+ allow_paths = True
1052
+
1053
+ def log_message(self, format: str, *args: Any) -> None:
1054
+ """Silence per-request logging; the CLI prints the URL only."""
1055
+
1056
+ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler naming
1057
+ self._dispatch()
1058
+
1059
+ def do_HEAD(self) -> None: # noqa: N802 - BaseHTTPRequestHandler naming
1060
+ self._method_not_allowed()
1061
+
1062
+ def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler naming
1063
+ self._method_not_allowed()
1064
+
1065
+ def do_PUT(self) -> None: # noqa: N802 - BaseHTTPRequestHandler naming
1066
+ self._method_not_allowed()
1067
+
1068
+ def do_PATCH(self) -> None: # noqa: N802 - BaseHTTPRequestHandler naming
1069
+ self._method_not_allowed()
1070
+
1071
+ def do_DELETE(self) -> None: # noqa: N802 - BaseHTTPRequestHandler naming
1072
+ self._method_not_allowed()
1073
+
1074
+ def _method_not_allowed(self) -> None:
1075
+ if not self._accepts_request_host():
1076
+ return
1077
+ self._send_json(HTTPStatus.METHOD_NOT_ALLOWED, {"error": "only GET is supported"})
1078
+
1079
+ def end_headers(self) -> None:
1080
+ """Close the header block, adding the headers every reply must carry.
1081
+
1082
+ This is the single choke point all responses share -- route bodies,
1083
+ ``421``/``404``/``405`` rejections and the errors
1084
+ :class:`~http.server.BaseHTTPRequestHandler` raises itself -- so the
1085
+ security headers cannot be forgotten on one path.
1086
+ """
1087
+ for name, value in SECURITY_HEADERS:
1088
+ self.send_header(name, value)
1089
+ super().end_headers()
1090
+
1091
+ def _bound_port(self) -> int:
1092
+ """Return the TCP port the server accepted this request on."""
1093
+ # ``self.server`` is typed as a plain ``BaseServer``, which does not
1094
+ # declare ``server_address``; every server we build is a ``TCPServer``.
1095
+ address = getattr(self.server, "server_address", None)
1096
+ if not isinstance(address, tuple) or len(address) < 2: # pragma: no cover
1097
+ return 0
1098
+ return int(address[1])
1099
+
1100
+ def _accepts_request_host(self) -> bool:
1101
+ """Check the ``Host`` header against the loopback allowlist.
1102
+
1103
+ Returns:
1104
+ ``True`` when the request may proceed, ``False`` when a ``421``
1105
+ response has already been written.
1106
+ """
1107
+ host = self.headers.get("Host")
1108
+ port = self._bound_port()
1109
+ if is_allowed_host(host, port):
1110
+ return True
1111
+ shown = (host or "").strip() or "<missing>"
1112
+ self._send_json(
1113
+ HTTPStatus.MISDIRECTED_REQUEST,
1114
+ {
1115
+ "error": (
1116
+ f"refusing Host {shown!r}: only 127.0.0.1, localhost and [::1] "
1117
+ f"on port {port} may drive the dashboard"
1118
+ )
1119
+ },
1120
+ )
1121
+ return False
1122
+
1123
+ def _dispatch(self) -> None:
1124
+ """Route one GET request, mapping failures onto the documented codes."""
1125
+ if not self._accepts_request_host():
1126
+ return
1127
+ try:
1128
+ split = urlsplit(self.path)
1129
+ except ValueError as exc:
1130
+ self._send_json(HTTPStatus.BAD_REQUEST, {"error": f"malformed URL: {exc}"})
1131
+ return
1132
+ route = split.path or "/"
1133
+ if len(route) > 1:
1134
+ route = route.rstrip("/") or "/"
1135
+ if route == "/":
1136
+ self._send_body(
1137
+ HTTPStatus.OK,
1138
+ render_dashboard_html().encode("utf-8"),
1139
+ _HTML_CONTENT_TYPE,
1140
+ )
1141
+ return
1142
+ builder = ROUTES.get(route)
1143
+ if builder is None:
1144
+ self._send_json(HTTPStatus.NOT_FOUND, {"error": f"unknown route {route!r}"})
1145
+ return
1146
+ params = _parse_query(split.query)
1147
+ try:
1148
+ payload = builder(params, self.allow_paths)
1149
+ except (ValueError, AnalysisError, BinaryLoadError, SessionError) as exc:
1150
+ self._send_json(
1151
+ HTTPStatus.BAD_REQUEST,
1152
+ {"error": str(exc) or type(exc).__name__},
1153
+ )
1154
+ return
1155
+ except Exception as exc: # noqa: BLE001 - last-resort mapping to HTTP 500
1156
+ self._send_json(
1157
+ HTTPStatus.INTERNAL_SERVER_ERROR,
1158
+ {"error": f"{type(exc).__name__}: {exc}"},
1159
+ )
1160
+ return
1161
+ self._send_json(HTTPStatus.OK, payload)
1162
+
1163
+ def _send_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None:
1164
+ self._send_body(status, _encode_payload(payload), _JSON_CONTENT_TYPE)
1165
+
1166
+ def _send_body(self, status: HTTPStatus, body: bytes, content_type: str) -> None:
1167
+ try:
1168
+ self.send_response(status)
1169
+ self.send_header("Content-Type", content_type)
1170
+ # A HEAD response carries no body, so it must not advertise one:
1171
+ # a non-zero length would desynchronize a keep-alive connection.
1172
+ self.send_header("Content-Length", "0" if self.command == "HEAD" else str(len(body)))
1173
+ self.send_header("Cache-Control", "no-store")
1174
+ # The security headers are added by ``end_headers`` so that the paths
1175
+ # which never reach here (421/404/405 handle it, but also the errors
1176
+ # BaseHTTPRequestHandler raises itself) carry them too.
1177
+ self.end_headers()
1178
+ if self.command != "HEAD" and body:
1179
+ self.wfile.write(body)
1180
+ except (BrokenPipeError, ConnectionResetError): # pragma: no cover - client gone
1181
+ self.close_connection = True
1182
+
1183
+
1184
+ def create_handler(paths_allowed: bool = True) -> type[BaseHTTPRequestHandler]:
1185
+ """Build the request-handler class for one dashboard server.
1186
+
1187
+ Args:
1188
+ paths_allowed: When false, the routes that take a file parameter reject
1189
+ it with HTTP 400, so a handler can be created for a context where
1190
+ filesystem access must not be reachable at all.
1191
+
1192
+ Returns:
1193
+ A :class:`~http.server.BaseHTTPRequestHandler` subclass ready to be
1194
+ passed to a server.
1195
+
1196
+ Raises:
1197
+ TypeError: If ``paths_allowed`` is not a :class:`bool`.
1198
+ """
1199
+ if not isinstance(paths_allowed, bool):
1200
+ raise TypeError(
1201
+ f"paths_allowed must be a bool, got {type(paths_allowed).__name__}"
1202
+ )
1203
+
1204
+ class GuiRequestHandler(_GuiRequestHandler):
1205
+ """Request handler bound to one ``paths_allowed`` policy."""
1206
+
1207
+ GuiRequestHandler.allow_paths = paths_allowed
1208
+ return GuiRequestHandler
1209
+
1210
+
1211
+ class _GuiHTTPServer(ThreadingHTTPServer):
1212
+ """Threaded HTTP server with daemon threads and a selectable family.
1213
+
1214
+ ``ThreadingHTTPServer`` already sets ``daemon_threads``; it is spelled out
1215
+ here together with ``allow_reuse_address`` so a restarted dashboard can
1216
+ rebind ``127.0.0.1:8765`` immediately.
1217
+ """
1218
+
1219
+ daemon_threads = True
1220
+ allow_reuse_address = True
1221
+
1222
+ def __init__(
1223
+ self,
1224
+ server_address: tuple[str, int],
1225
+ handler_class: type[BaseHTTPRequestHandler],
1226
+ address_family: socket.AddressFamily = socket.AF_INET,
1227
+ ) -> None:
1228
+ # BaseServer.__init__ creates the listening socket, so the family has to
1229
+ # be in place before the superclass runs.
1230
+ self.address_family = address_family
1231
+ super().__init__(server_address, handler_class)
1232
+
1233
+
1234
+ def create_server(host: str = "127.0.0.1", port: int = 8765) -> ThreadingHTTPServer:
1235
+ """Create (but do not start) a loopback-bound dashboard server.
1236
+
1237
+ Args:
1238
+ host: Bind host; must be loopback (see :func:`normalize_host`).
1239
+ port: TCP port, ``0`` asks the OS for a free ephemeral port.
1240
+
1241
+ Returns:
1242
+ A :class:`~http.server.ThreadingHTTPServer` ready for
1243
+ :meth:`~http.server.BaseServer.serve_forever`.
1244
+
1245
+ Raises:
1246
+ ValueError: If ``host`` is not loopback or ``port`` is out of range.
1247
+ """
1248
+ bind_host = normalize_host(host)
1249
+ if isinstance(port, bool) or not isinstance(port, int):
1250
+ raise ValueError(f"port must be an integer, got {type(port).__name__}")
1251
+ if not 0 <= port <= 65535:
1252
+ raise ValueError(f"port must be within 0..65535, got {port}")
1253
+ family = socket.AF_INET6 if bind_host == "::1" else socket.AF_INET
1254
+ return _GuiHTTPServer((bind_host, port), create_handler(), address_family=family)
1255
+
1256
+
1257
+ def _url_for(server: ThreadingHTTPServer) -> str:
1258
+ """Return the browser URL of a bound server."""
1259
+ host, port = server.server_address[0], server.server_address[1]
1260
+ text = str(host)
1261
+ if ":" in text:
1262
+ return f"http://[{text}]:{port}/"
1263
+ return f"http://{text}:{port}/"
1264
+
1265
+
1266
+ def serve(host: str = "127.0.0.1", port: int = 8765, open_browser: bool = True) -> None:
1267
+ """Print the dashboard URL and serve until ``Ctrl+C``.
1268
+
1269
+ Args:
1270
+ host: Bind host; must be loopback.
1271
+ port: TCP port, ``0`` asks the OS for a free ephemeral port.
1272
+ open_browser: When true, hand the URL to :mod:`webbrowser` before
1273
+ serving. A failure to launch a browser is ignored, because the URL
1274
+ is always printed.
1275
+
1276
+ Raises:
1277
+ ValueError: If ``host`` is not loopback or ``port`` is out of range.
1278
+ OSError: If the address cannot be bound (e.g. the port is taken).
1279
+
1280
+ Note:
1281
+ This function blocks inside
1282
+ :meth:`~http.server.BaseServer.serve_forever`; tests exercise
1283
+ :func:`create_server` plus a background thread instead.
1284
+ """
1285
+ server = create_server(host, port)
1286
+ url = _url_for(server)
1287
+ print(f"CODEVANTA dashboard: {url}")
1288
+ print("Loopback only, read-only analysis. Press Ctrl+C to stop.")
1289
+ sys.stdout.flush()
1290
+ if open_browser:
1291
+ try:
1292
+ webbrowser.open(url)
1293
+ except Exception: # pragma: no cover - headless session without a browser
1294
+ pass
1295
+ try:
1296
+ server.serve_forever()
1297
+ except KeyboardInterrupt:
1298
+ print("\nCODEVANTA dashboard stopped.")
1299
+ finally:
1300
+ server.server_close()
1301
+
1302
+
1303
+
1304
+
1305
+