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
codevanta/gui/server.py
ADDED
|
@@ -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
|
+
|