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/core/report.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""Shared analysis helpers used by the CLI and the MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from codevanta.evidence.confidence import Confidence
|
|
9
|
+
from codevanta.evidence.models import Evidence
|
|
10
|
+
from codevanta.evidence.provenance import Provenance
|
|
11
|
+
|
|
12
|
+
from .disassembler import Disassembler, DisassemblyError, DisassemblyResult
|
|
13
|
+
from .functions import discover_functions
|
|
14
|
+
from .loader import BinaryInfo, BinaryLoader
|
|
15
|
+
from .strings import StringExtractor
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AnalysisError(Exception):
|
|
19
|
+
"""Raised when an analysis request cannot be satisfied."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MAX_DISASSEMBLY_COUNT = 100_000
|
|
23
|
+
MAX_STRING_LIMIT = 200_000
|
|
24
|
+
#: Hard ceiling for ``xref_limit`` (mirrors ``MAX_STRING_LIMIT``).
|
|
25
|
+
MAX_XREF_LIMIT = 200_000
|
|
26
|
+
#: Cross references kept in the report by default (the scan budget stays at
|
|
27
|
+
#: :data:`codevanta.analysis.xrefs.DEFAULT_MAX_BYTES`).
|
|
28
|
+
DEFAULT_XREF_LIMIT = 2_000
|
|
29
|
+
|
|
30
|
+
#: Ceiling on call-graph nodes embedded in a report (bounded work).
|
|
31
|
+
REPORT_MAX_CALLGRAPH_FUNCTIONS = 1000
|
|
32
|
+
|
|
33
|
+
#: Ceiling on call-graph edges embedded in a report (bounded work).
|
|
34
|
+
REPORT_MAX_CALLGRAPH_EDGES = 5000
|
|
35
|
+
|
|
36
|
+
#: Instruction budget for the entry-point CFG embedded in a report (bounded
|
|
37
|
+
#: work; the :func:`codevanta.analysis.cfg.build_cfg` default, made explicit so
|
|
38
|
+
#: the report cap is visible next to the other section caps).
|
|
39
|
+
REPORT_MAX_CFG_INSTRUCTIONS = 2000
|
|
40
|
+
|
|
41
|
+
#: Block budget for the entry-point CFG embedded in a report (bounded work;
|
|
42
|
+
#: likewise the analyzer default).
|
|
43
|
+
REPORT_MAX_CFG_BLOCKS = 500
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_binary(path: str | Path) -> BinaryInfo:
|
|
47
|
+
"""Load a binary and return normalized metadata."""
|
|
48
|
+
return BinaryLoader().load(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_code(path: str | Path, info: BinaryInfo, address: int, want_bytes: int) -> bytes:
|
|
52
|
+
"""Read bytes for ``address``; raise when the address is unmapped."""
|
|
53
|
+
offset = info.va_to_offset(address)
|
|
54
|
+
section = info.section_for_va(address)
|
|
55
|
+
if offset is None or section is None:
|
|
56
|
+
raise AnalysisError(f"address 0x{address:x} is not mapped to file bytes")
|
|
57
|
+
remaining = (section.offset + (section.raw_size or section.size)) - offset
|
|
58
|
+
if remaining <= 0:
|
|
59
|
+
raise AnalysisError(f"address 0x{address:x} has no file bytes")
|
|
60
|
+
with open(path, "rb") as handle:
|
|
61
|
+
handle.seek(offset)
|
|
62
|
+
return handle.read(min(want_bytes, remaining))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve_target(info: BinaryInfo, address: int | None, function: str | None) -> int:
|
|
66
|
+
"""Resolve the disassembly start from an address, a function name, or the entry point."""
|
|
67
|
+
if address is not None:
|
|
68
|
+
return address
|
|
69
|
+
if function:
|
|
70
|
+
match = next((c for c in discover_functions(info) if c.name == function), None)
|
|
71
|
+
if match is None:
|
|
72
|
+
raise AnalysisError(f"function {function!r} not found")
|
|
73
|
+
return match.address
|
|
74
|
+
if info.entry_point is not None:
|
|
75
|
+
return info.entry_point
|
|
76
|
+
raise AnalysisError("no address supplied and binary has no entry point")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def disassemble_region(
|
|
80
|
+
path: str | Path,
|
|
81
|
+
info: BinaryInfo,
|
|
82
|
+
start: int,
|
|
83
|
+
count: int = 50,
|
|
84
|
+
) -> DisassemblyResult:
|
|
85
|
+
"""Disassemble ``count`` instructions at ``start`` inside an executable section."""
|
|
86
|
+
if count < 1:
|
|
87
|
+
raise AnalysisError("count must be >= 1")
|
|
88
|
+
if count > MAX_DISASSEMBLY_COUNT:
|
|
89
|
+
raise AnalysisError(f"count too large (limit {MAX_DISASSEMBLY_COUNT})")
|
|
90
|
+
section = info.section_for_va(start)
|
|
91
|
+
if section is None or not section.is_executable:
|
|
92
|
+
raise AnalysisError(f"address 0x{start:x} is not inside an executable section")
|
|
93
|
+
code = read_code(path, info, start, count * 16)
|
|
94
|
+
disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
|
|
95
|
+
return disassembler.disassemble(code, base_address=start, max_instructions=count)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _empty_cfg(address: int | None, warnings: list[str]) -> dict[str, Any]:
|
|
99
|
+
"""Build the degraded ``cfg`` payload used when no graph could be produced.
|
|
100
|
+
|
|
101
|
+
Mirrors :meth:`codevanta.analysis.cfg.FunctionCFG.to_dict` key for key so
|
|
102
|
+
consumers of the report can rely on one shape; ``warnings`` carries the
|
|
103
|
+
reason the sweep produced nothing.
|
|
104
|
+
"""
|
|
105
|
+
evidence = Evidence(
|
|
106
|
+
source="cfg.linear_sweep",
|
|
107
|
+
method="linear_sweep",
|
|
108
|
+
confidence=Confidence.UNKNOWN,
|
|
109
|
+
address=address,
|
|
110
|
+
detail="no blocks; the control-flow graph was not built",
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
"function_address": f"0x{address:x}" if address is not None else None,
|
|
114
|
+
"function_name": None,
|
|
115
|
+
"block_count": 0,
|
|
116
|
+
"instruction_count": 0,
|
|
117
|
+
"truncated": False,
|
|
118
|
+
"warnings": list(warnings),
|
|
119
|
+
"blocks": [],
|
|
120
|
+
"evidence": evidence.to_dict(),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_report(
|
|
125
|
+
path: str | Path,
|
|
126
|
+
string_limit: int = 5000,
|
|
127
|
+
xref_limit: int = DEFAULT_XREF_LIMIT,
|
|
128
|
+
include_callgraph: bool = False,
|
|
129
|
+
include_xrefs: bool = False,
|
|
130
|
+
include_cfg: bool = False,
|
|
131
|
+
) -> dict[str, Any]:
|
|
132
|
+
"""Build the phase 1 JSON report for ``path``.
|
|
133
|
+
|
|
134
|
+
The report always contains ``provenance``, ``binary``, ``functions``,
|
|
135
|
+
``strings`` and ``disassembly``. Nothing more than the file bytes is read and
|
|
136
|
+
nothing is executed.
|
|
137
|
+
|
|
138
|
+
The phase 2 engines are additive and opt-in, one section each:
|
|
139
|
+
``include_xrefs`` adds ``report["xrefs"]``, ``include_cfg`` adds
|
|
140
|
+
``report["cfg"]`` and ``include_callgraph`` adds ``report["callgraph"]``.
|
|
141
|
+
Without those arguments the payload is exactly the phase 1 report, so no
|
|
142
|
+
existing consumer sees a new key. A requested section whose engine has
|
|
143
|
+
nothing to decode still appears, as a warning-carrying empty result: the
|
|
144
|
+
phase 2 sections never fail the report.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
path: Binary to analyse.
|
|
148
|
+
string_limit: Maximum strings kept under ``report["strings"]``
|
|
149
|
+
(``0 .. MAX_STRING_LIMIT``).
|
|
150
|
+
xref_limit: Maximum cross references kept under
|
|
151
|
+
``report["xrefs"]["items"]`` (``0 .. MAX_XREF_LIMIT``); ``count``
|
|
152
|
+
still reports the true total found by the scan.
|
|
153
|
+
include_callgraph: When true, a ``"callgraph"`` section holding
|
|
154
|
+
:meth:`~codevanta.analysis.callgraph.CallGraph.to_dict` is added.
|
|
155
|
+
When false the key is omitted entirely, so the payload is unchanged
|
|
156
|
+
for callers that do not ask for a graph.
|
|
157
|
+
include_xrefs: When true, an ``"xrefs"`` section with the scan result and
|
|
158
|
+
its evidence is added.
|
|
159
|
+
include_cfg: When true, a ``"cfg"`` section with the entry-point
|
|
160
|
+
control-flow graph is added.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
The JSON-serializable report.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
AnalysisError: If ``string_limit`` or ``xref_limit`` is out of range, or
|
|
167
|
+
-- when ``include_callgraph`` is true -- if the graph cannot be
|
|
168
|
+
built. The phase 2 sections never raise: an engine failure degrades
|
|
169
|
+
to a warning inside that section instead of failing the report.
|
|
170
|
+
|
|
171
|
+
Note:
|
|
172
|
+
**Layering.** :mod:`codevanta.core.report` is a *core* module and must not
|
|
173
|
+
import :mod:`codevanta.analysis` at import time: that package imports
|
|
174
|
+
:class:`AnalysisError` from here, so a module-level import would create a
|
|
175
|
+
cycle and would also drag the analysis layer into every core consumer.
|
|
176
|
+
The dependency is therefore resolved with a **lazy import inside this
|
|
177
|
+
function**, executed only when the corresponding section is requested:
|
|
178
|
+
:func:`~codevanta.analysis.callgraph.build_call_graph` for
|
|
179
|
+
``include_callgraph``, :func:`~codevanta.analysis.xrefs.find_xrefs` for
|
|
180
|
+
``include_xrefs`` and :func:`~codevanta.analysis.cfg.build_cfg` for
|
|
181
|
+
``include_cfg``.
|
|
182
|
+
|
|
183
|
+
Note:
|
|
184
|
+
**Bounded work.** The embedded graph is capped at
|
|
185
|
+
``REPORT_MAX_CALLGRAPH_FUNCTIONS`` (1000) nodes and
|
|
186
|
+
``REPORT_MAX_CALLGRAPH_EDGES`` (5000) edges, which are well below the
|
|
187
|
+
call-graph defaults (:data:`~codevanta.analysis.callgraph.DEFAULT_MAX_FUNCTIONS`
|
|
188
|
+
/ ``DEFAULT_MAX_EDGES``) so a report stays a bounded payload. When either
|
|
189
|
+
cap cuts the result short, ``report["callgraph"]["truncated"]`` is true.
|
|
190
|
+
The pre-computed function candidates are reused instead of rediscovering
|
|
191
|
+
them, so the report never scans the binary twice for candidates.
|
|
192
|
+
|
|
193
|
+
The requested phase 2 sections are bounded too, and say so in their own
|
|
194
|
+
payloads. The xref scan decodes at most
|
|
195
|
+
:data:`~codevanta.analysis.xrefs.DEFAULT_MAX_BYTES` (4 MB) of executable
|
|
196
|
+
bytes -- a budget cut sets ``report["xrefs"]["scan_truncated"]`` -- and
|
|
197
|
+
only ``xref_limit`` items are embedded, which sets
|
|
198
|
+
``report["xrefs"]["truncated"]`` the same way the strings section
|
|
199
|
+
reports truncation. The CFG runs with
|
|
200
|
+
``REPORT_MAX_CFG_INSTRUCTIONS`` (2000) instructions and
|
|
201
|
+
``REPORT_MAX_CFG_BLOCKS`` (500) blocks, the analyzer's own defaults made
|
|
202
|
+
explicit, and marks ``report["cfg"]["truncated"]`` when either limit cut
|
|
203
|
+
the sweep.
|
|
204
|
+
"""
|
|
205
|
+
if string_limit < 0:
|
|
206
|
+
raise AnalysisError("string_limit must be >= 0")
|
|
207
|
+
if string_limit > MAX_STRING_LIMIT:
|
|
208
|
+
raise AnalysisError(f"string_limit too large (limit {MAX_STRING_LIMIT})")
|
|
209
|
+
if xref_limit < 0:
|
|
210
|
+
raise AnalysisError("xref_limit must be >= 0")
|
|
211
|
+
if xref_limit > MAX_XREF_LIMIT:
|
|
212
|
+
raise AnalysisError(f"xref_limit too large (limit {MAX_XREF_LIMIT})")
|
|
213
|
+
|
|
214
|
+
info = load_binary(path)
|
|
215
|
+
provenance = (
|
|
216
|
+
Provenance()
|
|
217
|
+
.add_step("load_binary")
|
|
218
|
+
.add_step("discover_functions")
|
|
219
|
+
.add_step("extract_strings")
|
|
220
|
+
)
|
|
221
|
+
candidates = discover_functions(info)
|
|
222
|
+
all_strings = StringExtractor(min_length=4).extract(path, info=info)
|
|
223
|
+
kept = all_strings[:string_limit]
|
|
224
|
+
disassembly: dict[str, Any] | None = None
|
|
225
|
+
if info.entry_point is not None:
|
|
226
|
+
try:
|
|
227
|
+
disassembly = disassemble_region(path, info, info.entry_point, count=32).to_dict()
|
|
228
|
+
except (AnalysisError, DisassemblyError):
|
|
229
|
+
disassembly = None
|
|
230
|
+
xrefs_payload: dict[str, Any] | None = None
|
|
231
|
+
cfg_payload: dict[str, Any] | None = None
|
|
232
|
+
if include_xrefs:
|
|
233
|
+
# Lazy import on purpose: core must not import the analysis package at
|
|
234
|
+
# module import time (analysis imports AnalysisError from this module),
|
|
235
|
+
# and a caller that does not want the section pays nothing for it.
|
|
236
|
+
from codevanta.analysis.xrefs import DEFAULT_MAX_BYTES, find_xrefs
|
|
237
|
+
|
|
238
|
+
provenance.add_step("find_xrefs")
|
|
239
|
+
# find_xrefs never raises for undecodable input: it reports degraded
|
|
240
|
+
# coverage through warnings (no executable sections, unsupported
|
|
241
|
+
# architecture, unreadable sections) that are passed straight through.
|
|
242
|
+
xref_scan = find_xrefs(path, info, functions=candidates, max_bytes=DEFAULT_MAX_BYTES)
|
|
243
|
+
kept_xrefs = xref_scan.xrefs[:xref_limit]
|
|
244
|
+
xrefs_payload = {
|
|
245
|
+
"count": len(xref_scan.xrefs),
|
|
246
|
+
"returned": len(kept_xrefs),
|
|
247
|
+
"truncated": len(kept_xrefs) < len(xref_scan.xrefs),
|
|
248
|
+
"items": [xref.to_dict() for xref in kept_xrefs],
|
|
249
|
+
"scanned_sections": list(xref_scan.scanned_sections),
|
|
250
|
+
"scanned_bytes": xref_scan.scanned_bytes,
|
|
251
|
+
"scan_truncated": xref_scan.truncated,
|
|
252
|
+
"indirect_skipped": xref_scan.indirect_skipped,
|
|
253
|
+
"warnings": list(xref_scan.warnings),
|
|
254
|
+
"evidence": xref_scan.evidence.to_dict(),
|
|
255
|
+
}
|
|
256
|
+
if include_cfg:
|
|
257
|
+
# Lazy import, same reason as find_xrefs above.
|
|
258
|
+
from codevanta.analysis.cfg import build_cfg
|
|
259
|
+
|
|
260
|
+
provenance.add_step("build_cfg")
|
|
261
|
+
if info.entry_point is None:
|
|
262
|
+
cfg_payload = _empty_cfg(None, ["no entry point; control-flow graph not built"])
|
|
263
|
+
else:
|
|
264
|
+
try:
|
|
265
|
+
cfg_payload = build_cfg(
|
|
266
|
+
path,
|
|
267
|
+
info,
|
|
268
|
+
info.entry_point,
|
|
269
|
+
max_instructions=REPORT_MAX_CFG_INSTRUCTIONS,
|
|
270
|
+
max_blocks=REPORT_MAX_CFG_BLOCKS,
|
|
271
|
+
).to_dict()
|
|
272
|
+
except (AnalysisError, DisassemblyError) as exc:
|
|
273
|
+
cfg_payload = _empty_cfg(
|
|
274
|
+
info.entry_point, [f"control-flow graph not built: {exc}"]
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
report: dict[str, Any] = {
|
|
278
|
+
"provenance": provenance.to_dict(),
|
|
279
|
+
"binary": info.to_dict(),
|
|
280
|
+
"functions": [c.to_dict() for c in candidates],
|
|
281
|
+
"strings": {
|
|
282
|
+
"count": len(all_strings),
|
|
283
|
+
"returned": len(kept),
|
|
284
|
+
"truncated": len(kept) < len(all_strings),
|
|
285
|
+
"items": [s.to_dict() for s in kept],
|
|
286
|
+
},
|
|
287
|
+
"disassembly": disassembly,
|
|
288
|
+
}
|
|
289
|
+
if include_callgraph:
|
|
290
|
+
# Lazy import on purpose: core must not import the analysis package at
|
|
291
|
+
# module import time (analysis imports AnalysisError from this module).
|
|
292
|
+
from codevanta.analysis.callgraph import build_call_graph
|
|
293
|
+
|
|
294
|
+
graph = build_call_graph(
|
|
295
|
+
path,
|
|
296
|
+
info,
|
|
297
|
+
functions=candidates,
|
|
298
|
+
max_functions=REPORT_MAX_CALLGRAPH_FUNCTIONS,
|
|
299
|
+
max_edges=REPORT_MAX_CALLGRAPH_EDGES,
|
|
300
|
+
)
|
|
301
|
+
report["callgraph"] = graph.to_dict()
|
|
302
|
+
if xrefs_payload is not None:
|
|
303
|
+
report["xrefs"] = xrefs_payload
|
|
304
|
+
if cfg_payload is not None:
|
|
305
|
+
report["cfg"] = cfg_payload
|
|
306
|
+
return report
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""ASCII and UTF-16 string extraction with location mapping.
|
|
2
|
+
|
|
3
|
+
The UTF-16 byte order follows the binary's endianness when metadata is
|
|
4
|
+
available: little-endian targets are scanned as UTF-16LE, big-endian targets
|
|
5
|
+
as UTF-16BE. Without metadata the scan defaults to UTF-16LE and never guesses
|
|
6
|
+
a byte swap, so little-endian results cannot be fabricated from shifted
|
|
7
|
+
matches inside big-endian data (and vice versa).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from codevanta.evidence.confidence import Confidence
|
|
18
|
+
from codevanta.evidence.models import Evidence
|
|
19
|
+
|
|
20
|
+
from .loader import BinaryInfo
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class StringResult:
|
|
25
|
+
"""A string found in the file with its offset and mapped address."""
|
|
26
|
+
|
|
27
|
+
offset: int
|
|
28
|
+
address: int | None
|
|
29
|
+
text: str
|
|
30
|
+
encoding: str
|
|
31
|
+
length: int
|
|
32
|
+
evidence: Evidence
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict[str, Any]:
|
|
35
|
+
data: dict[str, Any] = {
|
|
36
|
+
"offset": f"0x{self.offset:x}",
|
|
37
|
+
"text": self.text,
|
|
38
|
+
"encoding": self.encoding,
|
|
39
|
+
"length": self.length,
|
|
40
|
+
}
|
|
41
|
+
if self.address is not None:
|
|
42
|
+
data["address"] = f"0x{self.address:x}"
|
|
43
|
+
return data
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class StringExtractor:
|
|
47
|
+
"""Extracts printable ASCII and UTF-16 strings from a file.
|
|
48
|
+
|
|
49
|
+
UTF-16 output uses the byte order of ``BinaryInfo.endianness`` when
|
|
50
|
+
metadata is supplied; see the module docstring for the no-metadata rule.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(self, min_length: int = 4) -> None:
|
|
54
|
+
if min_length < 1:
|
|
55
|
+
raise ValueError("min_length must be >= 1")
|
|
56
|
+
self.min_length = min_length
|
|
57
|
+
|
|
58
|
+
def extract(
|
|
59
|
+
self,
|
|
60
|
+
path: str | Path,
|
|
61
|
+
info: BinaryInfo | None = None,
|
|
62
|
+
limit: int = 0,
|
|
63
|
+
) -> list[StringResult]:
|
|
64
|
+
"""Extract strings from ``path``; ``limit`` caps the result count.
|
|
65
|
+
|
|
66
|
+
ASCII strings are always scanned. UTF-16 strings are scanned in the
|
|
67
|
+
byte order of the binary when ``info`` is given (little-endian targets
|
|
68
|
+
as UTF-16LE, big-endian targets as UTF-16BE); without ``info`` the
|
|
69
|
+
scan stays at UTF-16LE rather than guessing a byte swap.
|
|
70
|
+
"""
|
|
71
|
+
data = Path(path).read_bytes()
|
|
72
|
+
results: list[StringResult] = []
|
|
73
|
+
ascii_pattern = re.compile(rb"[\x20-\x7e]{%d,}" % self.min_length)
|
|
74
|
+
for match in ascii_pattern.finditer(data):
|
|
75
|
+
text = match.group().decode("ascii", errors="replace")
|
|
76
|
+
results.append(self._result(match.start(), text, "ascii", info))
|
|
77
|
+
if info is not None and info.endianness == "big":
|
|
78
|
+
utf16_pattern = re.compile(rb"(?:\x00[\x20-\x7e]){%d,}" % self.min_length)
|
|
79
|
+
codec, label = "utf-16-be", "utf-16be"
|
|
80
|
+
else:
|
|
81
|
+
utf16_pattern = re.compile(rb"(?:[\x20-\x7e]\x00){%d,}" % self.min_length)
|
|
82
|
+
codec, label = "utf-16-le", "utf-16le"
|
|
83
|
+
results.extend(
|
|
84
|
+
self._result(match.start(), match.group().decode(codec, errors="replace"), label, info)
|
|
85
|
+
for match in utf16_pattern.finditer(data)
|
|
86
|
+
)
|
|
87
|
+
results.sort(key=lambda r: (r.offset, r.encoding))
|
|
88
|
+
if limit and len(results) > limit:
|
|
89
|
+
return results[:limit]
|
|
90
|
+
return results
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def _result(offset: int, text: str, encoding: str, info: BinaryInfo | None) -> StringResult:
|
|
94
|
+
address = info.offset_to_va(offset) if info is not None else None
|
|
95
|
+
evidence = Evidence(
|
|
96
|
+
source="string_scanner",
|
|
97
|
+
method=f"{encoding}_scan",
|
|
98
|
+
confidence=Confidence.HIGH if address is not None else Confidence.MEDIUM,
|
|
99
|
+
address=address,
|
|
100
|
+
detail=f"{encoding} string, {len(text)} chars",
|
|
101
|
+
)
|
|
102
|
+
return StringResult(
|
|
103
|
+
offset=offset,
|
|
104
|
+
address=address,
|
|
105
|
+
text=text,
|
|
106
|
+
encoding=encoding,
|
|
107
|
+
length=len(text),
|
|
108
|
+
evidence=evidence,
|
|
109
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Confidence levels for analysis findings.
|
|
2
|
+
|
|
3
|
+
The engine never presents a guess as a certainty. Every finding carries one of
|
|
4
|
+
these levels and, where useful, the numeric ``score`` for ordering.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Confidence(str, Enum):
|
|
13
|
+
"""How strongly a finding is supported by evidence."""
|
|
14
|
+
|
|
15
|
+
CERTAIN = "certain" # direct, unambiguous data from the file format
|
|
16
|
+
HIGH = "high" # strong structural evidence (e.g. export table)
|
|
17
|
+
MEDIUM = "medium" # indirect evidence (e.g. entry point)
|
|
18
|
+
LOW = "low" # weak or inferred evidence
|
|
19
|
+
UNKNOWN = "unknown" # no confidence can be established
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
return self.value
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def score(self) -> float:
|
|
26
|
+
"""Normalized weight in [0.0, 1.0] used for ordering and merging."""
|
|
27
|
+
return _SCORES[self]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
_SCORES: dict[Confidence, float] = {
|
|
31
|
+
Confidence.CERTAIN: 1.0,
|
|
32
|
+
Confidence.HIGH: 0.8,
|
|
33
|
+
Confidence.MEDIUM: 0.5,
|
|
34
|
+
Confidence.LOW: 0.2,
|
|
35
|
+
Confidence.UNKNOWN: 0.0,
|
|
36
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Evidence records that justify every analysis finding."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .confidence import Confidence
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Evidence:
|
|
13
|
+
"""A traceable justification for a single analysis finding.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
source: Where the finding came from, e.g. ``"pe.export_table"``.
|
|
17
|
+
method: How it was obtained, e.g. ``"export_directory"``.
|
|
18
|
+
confidence: How reliable the finding is.
|
|
19
|
+
address: Optional virtual address the evidence refers to.
|
|
20
|
+
detail: Optional human-readable note.
|
|
21
|
+
extra: Optional structured metadata for the report.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
source: str
|
|
25
|
+
method: str
|
|
26
|
+
confidence: Confidence = Confidence.UNKNOWN
|
|
27
|
+
address: int | None = None
|
|
28
|
+
detail: str | None = None
|
|
29
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
data: dict[str, Any] = {
|
|
33
|
+
"source": self.source,
|
|
34
|
+
"method": self.method,
|
|
35
|
+
"confidence": self.confidence.value,
|
|
36
|
+
}
|
|
37
|
+
if self.address is not None:
|
|
38
|
+
data["address"] = f"0x{self.address:x}"
|
|
39
|
+
if self.detail:
|
|
40
|
+
data["detail"] = self.detail
|
|
41
|
+
if self.extra:
|
|
42
|
+
data["extra"] = dict(self.extra)
|
|
43
|
+
return data
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Provenance tracking for analysis runs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def utc_now_iso() -> str:
|
|
11
|
+
"""Return the current UTC time as an ISO-8601 string."""
|
|
12
|
+
return datetime.now(timezone.utc).isoformat()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Provenance:
|
|
17
|
+
"""Records how a report or finding was produced."""
|
|
18
|
+
|
|
19
|
+
tool: str = "codevanta"
|
|
20
|
+
tool_version: str = "0.1.0"
|
|
21
|
+
created_at: str = field(default_factory=utc_now_iso)
|
|
22
|
+
steps: list[str] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
def add_step(self, step: str) -> Provenance:
|
|
25
|
+
"""Append a processing step and return ``self`` for chaining."""
|
|
26
|
+
if not step:
|
|
27
|
+
raise ValueError("step must not be empty")
|
|
28
|
+
self.steps.append(step)
|
|
29
|
+
return self
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"tool": self.tool,
|
|
34
|
+
"tool_version": self.tool_version,
|
|
35
|
+
"created_at": self.created_at,
|
|
36
|
+
"steps": list(self.steps),
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Local, dependency-free web dashboard for CODEVANTA.
|
|
2
|
+
|
|
3
|
+
The GUI is a standard-library-only HTTP server bound to loopback. It exposes the
|
|
4
|
+
existing analysis engines over a small JSON API and serves one self-contained
|
|
5
|
+
HTML page, so a single command is enough to try CODEVANTA in a browser::
|
|
6
|
+
|
|
7
|
+
from codevanta.gui import serve
|
|
8
|
+
|
|
9
|
+
serve() # http://127.0.0.1:8765/
|
|
10
|
+
|
|
11
|
+
Nothing here reimplements analysis: every route delegates to the CLI/MCP engine
|
|
12
|
+
of the same name, keeps its bounds and returns its evidence payload. The server
|
|
13
|
+
is read-only (it parses bytes, never executes or writes them) and refuses to bind
|
|
14
|
+
anything but a loopback address.
|
|
15
|
+
|
|
16
|
+
Public API:
|
|
17
|
+
|
|
18
|
+
* :func:`~codevanta.gui.layout.render_dashboard_html` -- the dashboard page.
|
|
19
|
+
* :func:`~codevanta.gui.server.create_server` -- a bound, unstarted server.
|
|
20
|
+
* :func:`~codevanta.gui.server.create_handler` -- the request-handler class.
|
|
21
|
+
* :func:`~codevanta.gui.server.serve` -- bind, print the URL, serve until Ctrl+C.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from .layout import render_dashboard_html
|
|
27
|
+
from .server import (
|
|
28
|
+
MAX_RESPONSE_BYTES,
|
|
29
|
+
ROUTES,
|
|
30
|
+
VERSION,
|
|
31
|
+
create_handler,
|
|
32
|
+
create_server,
|
|
33
|
+
normalize_host,
|
|
34
|
+
serve,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"MAX_RESPONSE_BYTES",
|
|
39
|
+
"ROUTES",
|
|
40
|
+
"VERSION",
|
|
41
|
+
"create_handler",
|
|
42
|
+
"create_server",
|
|
43
|
+
"normalize_host",
|
|
44
|
+
"render_dashboard_html",
|
|
45
|
+
"serve",
|
|
46
|
+
]
|