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,646 @@
1
+ """Parse what a dynamic session produced into a bounded, attributable report.
2
+
3
+ :mod:`codevanta.analysis.dynamic` writes the script; this module reads what the
4
+ script printed. It never runs anything, so it is the half of dynamic analysis
5
+ that can be tested deterministically and replayed from a saved log.
6
+
7
+ Two providers, two formats
8
+ --------------------------
9
+ ``gdb``
10
+ :func:`parse_gdb_trace` reads the ``HIT <address> <reg>=<value> ...`` lines the
11
+ generated script prints, the ``BACKTRACE`` marker that follows each one, and
12
+ the ``#N 0x... in ...`` frames ``bt`` emits. Everything else gdb says
13
+ (breakpoint notices, ``Inferior 1 exited``, ``DONE n hits``) is noise and is
14
+ skipped. A ``LIMIT`` line means the session stopped at its own hit ceiling, so
15
+ the report is marked ``truncated``.
16
+ ``frida``
17
+ :func:`parse_frida_trace` reads the JSON-lines messages the generated hook
18
+ sends (``{"hit": N, "address": "0x...", "tid": ..., "args": [...]}``). A
19
+ malformed line is skipped with a warning instead of aborting the parse;
20
+ ``{"event": "limit"}`` marks the report truncated and ``{"event": "error"}``
21
+ becomes a warning.
22
+
23
+ Mapping hits onto functions
24
+ ---------------------------
25
+ When a function list (usually :func:`codevanta.core.functions.discover_functions`)
26
+ is supplied, every hit is attributed to the candidate that encloses its address:
27
+ the greatest candidate start ``<= address`` that either contains the address
28
+ within its known ``size`` or, when the size is unknown, still starts before the
29
+ next candidate. Addresses outside every candidate stay unattributed rather than
30
+ being guessed onto the nearest symbol. ``function_coverage`` counts hits per
31
+ attributed function, so a caller can see where a session actually went.
32
+
33
+ Bounds
34
+ ------
35
+ A parse never reads more than :data:`MAX_TRACE_LINES` lines, keeps at most
36
+ :data:`MAX_TRACE_HITS` hits and at most :data:`MAX_BACKTRACE_FRAMES` frames per
37
+ hit; a cut is reported through ``truncated`` plus a warning, never silently.
38
+ Warnings are capped too, so a corrupt log cannot turn into unbounded memory. The
39
+ only exception raised is :class:`~codevanta.core.report.AnalysisError`, and only
40
+ for a caller mistake (an out-of-range ``max_lines``/``max_hits``).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import json
46
+ import re
47
+ from bisect import bisect_right
48
+ from collections.abc import Mapping, Sequence
49
+ from dataclasses import dataclass, field
50
+ from typing import Any
51
+
52
+ from codevanta.analysis.dynamic import MAX_FRIDA_ARGS
53
+ from codevanta.core.functions import FunctionCandidate
54
+ from codevanta.core.loader import BinaryInfo, SectionInfo
55
+ from codevanta.core.report import AnalysisError
56
+ from codevanta.evidence.confidence import Confidence
57
+ from codevanta.evidence.models import Evidence
58
+
59
+ __all__ = [
60
+ "MAX_BACKTRACE_FRAMES",
61
+ "MAX_TRACE_HITS",
62
+ "MAX_TRACE_LINES",
63
+ "MAX_WARNINGS",
64
+ "TraceHit",
65
+ "TraceReport",
66
+ "parse_frida_trace",
67
+ "parse_gdb_trace",
68
+ ]
69
+
70
+ #: Hard ceiling on the lines one parse will read.
71
+ MAX_TRACE_LINES = 100_000
72
+
73
+ #: Hard ceiling on the hits one parse will keep.
74
+ MAX_TRACE_HITS = 100_000
75
+
76
+ #: Frames kept per hit, whatever the session printed.
77
+ MAX_BACKTRACE_FRAMES = 64
78
+
79
+ #: Warnings kept per report before the rest are summarised away.
80
+ MAX_WARNINGS = 50
81
+
82
+ #: Longest input snippet echoed back in a warning.
83
+ _SNIPPET = 120
84
+
85
+ #: ``HIT 0x401000 pc=0x401000 sp=0x7ffd...`` -- the line the gdb script prints.
86
+ _HIT_LINE = re.compile(
87
+ r"^HIT\s+(?P<address>0x[0-9a-fA-F]+)"
88
+ r"(?P<registers>(?:\s+[A-Za-z][A-Za-z0-9_]*=(?:0x[0-9a-fA-F]+|\d+))*)\s*$"
89
+ )
90
+
91
+ #: ``name=value`` inside a ``HIT`` line's register snapshot.
92
+ _REGISTER = re.compile(r"([A-Za-z][A-Za-z0-9_]*)=(0x[0-9a-fA-F]+|\d+)")
93
+
94
+ #: A line that starts like a hit but does not parse as one.
95
+ _HIT_PREFIX = re.compile(r"^HIT\b")
96
+
97
+ #: The marker the gdb script prints before its ``bt`` frames.
98
+ _BACKTRACE_MARKER = re.compile(r"^BACKTRACE\b")
99
+
100
+ #: ``#3 0x0000000000401000 in ?? ()`` -- one gdb backtrace frame.
101
+ _FRAME_LINE = re.compile(r"^#\d+\s+0x(?P<address>[0-9a-fA-F]+)")
102
+
103
+ #: The line the gdb script prints when it stops at its hit ceiling.
104
+ _LIMIT_MARKER = re.compile(r"^LIMIT\b")
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class TraceHit:
109
+ """One recorded visit to one address.
110
+
111
+ Attributes:
112
+ index: Position of the hit in the session, as the provider numbered it
113
+ (gdb hits are numbered in parse order; Frida reports its own counter
114
+ and the parser falls back to parse order when that counter is absent).
115
+ kind: ``"breakpoint"`` for a gdb hit, ``"message"`` for a Frida message.
116
+ address: The address the session reported: a file virtual address for gdb
117
+ (its breakpoints are absolute) and a runtime address for Frida.
118
+ function: Start address of the function candidate that encloses
119
+ ``address``, or ``None`` when no candidate does.
120
+ function_name: Name of that candidate, when the candidate has one.
121
+ registers: Register snapshot. gdb supplies the registers its script
122
+ printed; Frida supplies ``tid`` plus ``arg0``..``arg3`` from the
123
+ message, because its messages carry no register dump.
124
+ backtrace: Frame addresses in call order, innermost first; empty for
125
+ Frida, whose messages carry no stack.
126
+ """
127
+
128
+ index: int
129
+ kind: str
130
+ address: int
131
+ function: int | None = None
132
+ function_name: str | None = None
133
+ registers: Mapping[str, int] = field(default_factory=dict)
134
+ backtrace: tuple[int, ...] = ()
135
+
136
+ def to_dict(self) -> dict[str, Any]:
137
+ """Serialize the hit for the JSON report (hex addresses, int registers)."""
138
+ return {
139
+ "index": self.index,
140
+ "kind": self.kind,
141
+ "address": f"0x{self.address:x}",
142
+ "function": f"0x{self.function:x}" if self.function is not None else None,
143
+ "function_name": self.function_name,
144
+ "registers": dict(self.registers),
145
+ "backtrace": [f"0x{address:x}" for address in self.backtrace],
146
+ }
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class TraceReport:
151
+ """Everything one session produced, honestly bounded.
152
+
153
+ Attributes:
154
+ provider: ``"gdb"`` or ``"frida"``.
155
+ hits: The hits that were kept, in session order.
156
+ total_hits: Hits the parser saw. It exceeds ``len(hits)`` only when a
157
+ ``max_hits`` bound stopped the parser from keeping the tail.
158
+ truncated: ``True`` when the input was cut -- by ``max_lines``, by
159
+ ``max_hits``, or by the session's own ``LIMIT``/``limit`` marker.
160
+ unique_addresses: Distinct hit addresses, ascending.
161
+ function_coverage: Hits per attributed function start address. Functions
162
+ with no hits are absent, and unattributed hits are counted nowhere.
163
+ warnings: Everything that went wrong: malformed lines, foreign messages,
164
+ limits that cut the session.
165
+ evidence: How the report was produced, including the budgets that were in
166
+ effect and, when ``info`` was supplied, the binary it belongs to.
167
+ """
168
+
169
+ provider: str
170
+ hits: tuple[TraceHit, ...]
171
+ total_hits: int
172
+ truncated: bool
173
+ unique_addresses: tuple[int, ...]
174
+ function_coverage: Mapping[int, int]
175
+ warnings: tuple[str, ...]
176
+ evidence: Evidence
177
+
178
+ def to_dict(self) -> dict[str, Any]:
179
+ """Serialize the report for the JSON report (JSON-safe values only)."""
180
+ return {
181
+ "provider": self.provider,
182
+ "hits": [hit.to_dict() for hit in self.hits],
183
+ "total_hits": self.total_hits,
184
+ "truncated": self.truncated,
185
+ "unique_addresses": [f"0x{address:x}" for address in self.unique_addresses],
186
+ "function_coverage": {
187
+ f"0x{address:x}": count for address, count in self.function_coverage.items()
188
+ },
189
+ "warnings": list(self.warnings),
190
+ "evidence": self.evidence.to_dict(),
191
+ }
192
+
193
+
194
+ # --------------------------------------------------------------------------- #
195
+ # Shared helpers
196
+ # --------------------------------------------------------------------------- #
197
+
198
+
199
+ class _FunctionIndex:
200
+ """Bisect-based lookup from an address to the candidate that encloses it.
201
+
202
+ A candidate's extent comes from its symbol size when the loader knows it, and
203
+ otherwise from the executable section that contains it: a function cannot run
204
+ past the end of its own section. When neither is available the extent is
205
+ unknown, and an address above the start is reported as unattributed instead of
206
+ being guessed onto the function.
207
+ """
208
+
209
+ def __init__(
210
+ self,
211
+ functions: Sequence[FunctionCandidate],
212
+ info: BinaryInfo | None = None,
213
+ ) -> None:
214
+ self._starts: list[int] = []
215
+ self._ends: list[int | None] = []
216
+ self._names: list[str | None] = []
217
+ sections = (
218
+ [section for section in getattr(info, "sections", ()) if section.is_executable]
219
+ if info is not None
220
+ else []
221
+ )
222
+ for candidate in sorted(functions, key=lambda item: item.address):
223
+ if candidate.address <= 0:
224
+ continue
225
+ if self._starts and self._starts[-1] == candidate.address:
226
+ continue
227
+ self._starts.append(candidate.address)
228
+ self._ends.append(_candidate_end(candidate, sections))
229
+ self._names.append(candidate.name)
230
+
231
+ def locate(self, address: int) -> tuple[int, str | None] | None:
232
+ """Return ``(start, name)`` for the enclosing candidate, or ``None``."""
233
+ position = bisect_right(self._starts, address) - 1
234
+ if position < 0:
235
+ return None
236
+ start = self._starts[position]
237
+ end = self._ends[position]
238
+ if end is not None:
239
+ inside = address < end
240
+ elif position + 1 < len(self._starts):
241
+ # No size and no section: the next candidate's start is the boundary.
242
+ inside = address < self._starts[position + 1]
243
+ else:
244
+ inside = False
245
+ if not inside:
246
+ return None
247
+ return start, self._names[position]
248
+
249
+
250
+ def _candidate_end(candidate: FunctionCandidate, sections: Sequence[SectionInfo]) -> int | None:
251
+ """Return the last address a candidate can occupy, or ``None`` when unknown."""
252
+ size = getattr(candidate, "size", None)
253
+ if isinstance(size, int) and size > 0:
254
+ return candidate.address + size
255
+ for section in sections:
256
+ start = section.virtual_address
257
+ end = start + section.size
258
+ if start <= candidate.address < end:
259
+ return end
260
+ return None
261
+
262
+
263
+ def _snippet(line: str) -> str:
264
+ """Return a quoted, length-bounded excerpt of ``line`` for a warning."""
265
+ text = line if len(line) <= _SNIPPET else line[: _SNIPPET - 3] + "..."
266
+ return repr(text)
267
+
268
+
269
+ def _warn(warnings: list[str], message: str) -> None:
270
+ """Append ``message`` to ``warnings``, capping the list at :data:`MAX_WARNINGS`."""
271
+ if len(warnings) < MAX_WARNINGS:
272
+ warnings.append(message)
273
+ elif len(warnings) == MAX_WARNINGS:
274
+ warnings.append(f"further warnings suppressed after {MAX_WARNINGS}")
275
+
276
+
277
+ def _as_int(value: Any) -> int | None:
278
+ """Return ``value`` as an int, accepting ``int`` and ``"0x1f"``-style text."""
279
+ if isinstance(value, bool):
280
+ return None
281
+ if isinstance(value, int):
282
+ return value
283
+ if isinstance(value, str):
284
+ text = value.strip()
285
+ if not text:
286
+ return None
287
+ try:
288
+ return int(text, 0)
289
+ except ValueError:
290
+ return None
291
+ return None
292
+
293
+
294
+ def _require_max_lines(max_lines: int) -> int:
295
+ """Return ``max_lines`` after checking it is a sane positive bound."""
296
+ if isinstance(max_lines, bool) or not isinstance(max_lines, int):
297
+ raise AnalysisError(
298
+ f"max_lines must be an int, got {type(max_lines).__name__}: {max_lines!r}"
299
+ )
300
+ if max_lines < 1:
301
+ raise AnalysisError(f"max_lines must be >= 1, got {max_lines}")
302
+ if max_lines > MAX_TRACE_LINES:
303
+ raise AnalysisError(f"max_lines must be <= {MAX_TRACE_LINES}, got {max_lines}")
304
+ return max_lines
305
+
306
+
307
+ def _require_max_hits(max_hits: int | None) -> int | None:
308
+ """Return ``max_hits`` after checking it is ``None`` or a sane positive bound."""
309
+ if max_hits is None:
310
+ return None
311
+ if isinstance(max_hits, bool) or not isinstance(max_hits, int):
312
+ raise AnalysisError(
313
+ f"max_hits must be an int or None, got {type(max_hits).__name__}: {max_hits!r}"
314
+ )
315
+ if max_hits < 1:
316
+ raise AnalysisError(f"max_hits must be >= 1, got {max_hits}")
317
+ if max_hits > MAX_TRACE_HITS:
318
+ raise AnalysisError(f"max_hits must be <= {MAX_TRACE_HITS}, got {max_hits}")
319
+ return max_hits
320
+
321
+
322
+ def _make_hit(draft: dict[str, Any], functions: _FunctionIndex, kind: str) -> TraceHit:
323
+ """Freeze one parsed draft into a :class:`TraceHit`, attributing its address."""
324
+ address: int = draft["address"]
325
+ located = functions.locate(address)
326
+ start, name = located if located is not None else (None, None)
327
+ return TraceHit(
328
+ index=int(draft["index"]),
329
+ kind=kind,
330
+ address=address,
331
+ function=start,
332
+ function_name=name,
333
+ registers=dict(draft["registers"]),
334
+ backtrace=tuple(draft["backtrace"]),
335
+ )
336
+
337
+
338
+ def _build_report(
339
+ provider: str,
340
+ hits: list[TraceHit],
341
+ *,
342
+ total_hits: int,
343
+ truncated: bool,
344
+ warnings: list[str],
345
+ info: BinaryInfo | None,
346
+ lines_scanned: int,
347
+ max_lines: int,
348
+ ) -> TraceReport:
349
+ """Assemble the report, counting coverage and building the evidence record."""
350
+ coverage: dict[int, int] = {}
351
+ for hit in hits:
352
+ if hit.function is None:
353
+ continue
354
+ coverage[hit.function] = coverage.get(hit.function, 0) + 1
355
+ extra: dict[str, Any] = {
356
+ "provider": provider,
357
+ "lines_scanned": lines_scanned,
358
+ "line_limit": max_lines,
359
+ "truncated": truncated,
360
+ "hits": total_hits,
361
+ "kept_hits": len(hits),
362
+ "warning_count": len(warnings),
363
+ }
364
+ if info is not None:
365
+ extra["format"] = info.format
366
+ extra["architecture"] = info.architecture
367
+ extra["image_base"] = f"0x{info.image_base:x}"
368
+ evidence = Evidence(
369
+ source=f"{provider}.trace",
370
+ method=f"parse_{provider}_trace",
371
+ confidence=Confidence.HIGH if warnings else Confidence.CERTAIN,
372
+ detail=f"parsed {total_hits} hit(s) from {lines_scanned} line(s)",
373
+ extra=extra,
374
+ )
375
+ return TraceReport(
376
+ provider=provider,
377
+ hits=tuple(hits),
378
+ total_hits=total_hits,
379
+ truncated=truncated,
380
+ unique_addresses=tuple(sorted({hit.address for hit in hits})),
381
+ function_coverage=coverage,
382
+ warnings=tuple(warnings),
383
+ evidence=evidence,
384
+ )
385
+
386
+
387
+ # --------------------------------------------------------------------------- #
388
+ # gdb traces
389
+ # --------------------------------------------------------------------------- #
390
+
391
+
392
+ def parse_gdb_trace(
393
+ text: str,
394
+ *,
395
+ info: BinaryInfo | None = None,
396
+ functions: Sequence[FunctionCandidate] = (),
397
+ max_lines: int = MAX_TRACE_LINES,
398
+ max_hits: int | None = None,
399
+ ) -> TraceReport:
400
+ """Parse the output of a generated gdb session script.
401
+
402
+ One ``HIT 0x... name=0x...`` line becomes one :class:`TraceHit` of kind
403
+ ``"breakpoint"``; the frames printed after the following ``BACKTRACE`` marker
404
+ become its ``backtrace``. Everything else is ignored, except a ``LIMIT`` line,
405
+ which marks the report ``truncated`` because the session stopped at its own
406
+ ceiling. A line that starts with ``HIT`` but does not parse produces a warning
407
+ and never raises.
408
+
409
+ Args:
410
+ text: The captured gdb output.
411
+ info: Binary metadata for context. It never changes which hits are
412
+ reported; it is recorded in the evidence so the report can be tied to
413
+ the file the session ran against.
414
+ functions: Candidates used to attribute each hit to a function.
415
+ max_lines: Line budget, ``1 <= max_lines <=`` :data:`MAX_TRACE_LINES`.
416
+ max_hits: Hit budget, or ``None``. Hits beyond it are counted in
417
+ ``total_hits`` but not kept, and the report is ``truncated``.
418
+
419
+ Raises:
420
+ AnalysisError: ``max_lines`` or ``max_hits`` is out of range.
421
+ """
422
+ line_limit = _require_max_lines(max_lines)
423
+ hit_limit = _require_max_hits(max_hits)
424
+ index = _FunctionIndex(functions, info)
425
+
426
+ lines = text.splitlines()
427
+ warnings: list[str] = []
428
+ truncated = False
429
+ if len(lines) > line_limit:
430
+ lines = lines[:line_limit]
431
+ truncated = True
432
+ _warn(warnings, f"input truncated to the first {line_limit} line(s)")
433
+
434
+ hits: list[TraceHit] = []
435
+ total_hits = 0
436
+ draft: dict[str, Any] | None = None
437
+ in_backtrace = False
438
+ hit_limit_reported = False
439
+
440
+ for number, line in enumerate(lines, start=1):
441
+ stripped = line.strip()
442
+ if not stripped:
443
+ continue
444
+ match = _HIT_LINE.match(stripped)
445
+ if match is not None:
446
+ if draft is not None:
447
+ hits.append(_make_hit(draft, index, "breakpoint"))
448
+ draft = None
449
+ total_hits += 1
450
+ if hit_limit is not None and len(hits) >= hit_limit:
451
+ if not hit_limit_reported:
452
+ truncated = True
453
+ hit_limit_reported = True
454
+ _warn(
455
+ warnings,
456
+ f"hit limit {hit_limit} reached; further hits are counted but "
457
+ "not kept",
458
+ )
459
+ continue
460
+ draft = {
461
+ "index": total_hits,
462
+ "address": int(match.group("address"), 16),
463
+ "registers": {
464
+ name: int(value, 0)
465
+ for name, value in _REGISTER.findall(match.group("registers"))
466
+ },
467
+ "backtrace": [],
468
+ }
469
+ in_backtrace = False
470
+ continue
471
+ if _HIT_PREFIX.match(stripped):
472
+ _warn(warnings, f"line {number}: unparseable HIT line {_snippet(stripped)}")
473
+ continue
474
+ if _LIMIT_MARKER.match(stripped):
475
+ truncated = True
476
+ _warn(
477
+ warnings,
478
+ f"line {number}: the session stopped at its hit limit ({_snippet(stripped)})",
479
+ )
480
+ continue
481
+ if _BACKTRACE_MARKER.match(stripped):
482
+ in_backtrace = True
483
+ continue
484
+ if in_backtrace:
485
+ frame = _FRAME_LINE.match(stripped)
486
+ if frame is not None:
487
+ if draft is not None and len(draft["backtrace"]) < MAX_BACKTRACE_FRAMES:
488
+ draft["backtrace"].append(int(frame.group("address"), 16))
489
+ continue
490
+ # Anything else is gdb noise: breakpoint notices, exit messages, "DONE".
491
+
492
+ if draft is not None:
493
+ hits.append(_make_hit(draft, index, "breakpoint"))
494
+
495
+ return _build_report(
496
+ "gdb",
497
+ hits,
498
+ total_hits=total_hits,
499
+ truncated=truncated,
500
+ warnings=warnings,
501
+ info=info,
502
+ lines_scanned=len(lines),
503
+ max_lines=line_limit,
504
+ )
505
+
506
+
507
+ # --------------------------------------------------------------------------- #
508
+ # frida traces
509
+ # --------------------------------------------------------------------------- #
510
+
511
+
512
+ def parse_frida_trace(
513
+ text: str,
514
+ *,
515
+ info: BinaryInfo | None = None,
516
+ functions: Sequence[FunctionCandidate] = (),
517
+ max_lines: int = MAX_TRACE_LINES,
518
+ max_hits: int | None = None,
519
+ ) -> TraceReport:
520
+ """Parse the JSON-lines messages a generated Frida script sends.
521
+
522
+ One ``{"hit": N, "address": "0x...", "tid": ..., "args": [...]}`` message
523
+ becomes one :class:`TraceHit` of kind ``"message"``, with ``tid`` and
524
+ ``arg0``..``arg3`` in ``registers`` (Frida messages carry no register dump and
525
+ no stack). ``{"event": "limit"}`` marks the report ``truncated``;
526
+ ``{"event": "error"}`` and any other unrecognised message become warnings. A
527
+ malformed line is skipped with a warning instead of aborting the parse.
528
+
529
+ Args:
530
+ text: The captured frida output, one JSON message per line.
531
+ info: Binary metadata for context; recorded in the evidence only.
532
+ functions: Candidates used to attribute each hit to a function.
533
+ max_lines: Line budget, ``1 <= max_lines <=`` :data:`MAX_TRACE_LINES`.
534
+ max_hits: Hit budget, or ``None``. Hits beyond it are counted in
535
+ ``total_hits`` but not kept, and the report is ``truncated``.
536
+
537
+ Raises:
538
+ AnalysisError: ``max_lines`` or ``max_hits`` is out of range.
539
+ """
540
+ line_limit = _require_max_lines(max_lines)
541
+ hit_limit = _require_max_hits(max_hits)
542
+ index = _FunctionIndex(functions, info)
543
+
544
+ lines = text.splitlines()
545
+ warnings: list[str] = []
546
+ truncated = False
547
+ if len(lines) > line_limit:
548
+ lines = lines[:line_limit]
549
+ truncated = True
550
+ _warn(warnings, f"input truncated to the first {line_limit} line(s)")
551
+
552
+ hits: list[TraceHit] = []
553
+ total_hits = 0
554
+ hit_limit_reported = False
555
+
556
+ for number, line in enumerate(lines, start=1):
557
+ stripped = line.strip()
558
+ if not stripped:
559
+ continue
560
+ try:
561
+ payload = json.loads(stripped)
562
+ except ValueError:
563
+ _warn(warnings, f"line {number}: malformed JSON message {_snippet(stripped)}")
564
+ continue
565
+ if not isinstance(payload, dict):
566
+ _warn(
567
+ warnings,
568
+ f"line {number}: message is not a JSON object {_snippet(stripped)}",
569
+ )
570
+ continue
571
+ if "hit" not in payload:
572
+ event = payload.get("event")
573
+ if event == "limit":
574
+ truncated = True
575
+ _warn(warnings, f"line {number}: the session detached its hooks at the hit limit")
576
+ elif event == "error":
577
+ message = payload.get("message") or "no message"
578
+ _warn(warnings, f"line {number}: provider error: {message}")
579
+ else:
580
+ _warn(
581
+ warnings,
582
+ f"line {number}: message without a hit or a known event "
583
+ f"{_snippet(stripped)}",
584
+ )
585
+ continue
586
+ address = _as_int(payload.get("address"))
587
+ if address is None:
588
+ _warn(
589
+ warnings,
590
+ f"line {number}: hit without a usable address {_snippet(stripped)}",
591
+ )
592
+ continue
593
+ total_hits += 1
594
+ if hit_limit is not None and len(hits) >= hit_limit:
595
+ if not hit_limit_reported:
596
+ truncated = True
597
+ hit_limit_reported = True
598
+ _warn(
599
+ warnings,
600
+ f"hit limit {hit_limit} reached; further hits are counted but not kept",
601
+ )
602
+ continue
603
+
604
+ registers: dict[str, int] = {}
605
+ tid = _as_int(payload.get("tid"))
606
+ if tid is not None:
607
+ registers["tid"] = tid
608
+ arguments = payload.get("args")
609
+ if isinstance(arguments, list):
610
+ for position, value in enumerate(arguments[:MAX_FRIDA_ARGS]):
611
+ parsed = _as_int(value)
612
+ if parsed is None:
613
+ _warn(
614
+ warnings,
615
+ f"line {number}: argument {position} is not a number "
616
+ f"({value!r}); skipped",
617
+ )
618
+ continue
619
+ registers[f"arg{position}"] = parsed
620
+ elif arguments is not None:
621
+ _warn(warnings, f"line {number}: 'args' is not a list; ignored")
622
+
623
+ reported = _as_int(payload.get("hit"))
624
+ hits.append(
625
+ _make_hit(
626
+ {
627
+ "index": reported if reported is not None else total_hits,
628
+ "address": address,
629
+ "registers": registers,
630
+ "backtrace": [],
631
+ },
632
+ index,
633
+ "message",
634
+ )
635
+ )
636
+
637
+ return _build_report(
638
+ "frida",
639
+ hits,
640
+ total_hits=total_hits,
641
+ truncated=truncated,
642
+ warnings=warnings,
643
+ info=info,
644
+ lines_scanned=len(lines),
645
+ max_lines=line_limit,
646
+ )