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,572 @@
1
+ """Vtable discovery and thunk-chain resolution (indirect xref heuristics).
2
+
3
+ A C++ virtual table is a run of pointer-sized slots in a non-executable data
4
+ section where every slot holds the address of a function. Neither fact alone
5
+ proves a vtable, so the discovery here is an explicitly **medium**-confidence
6
+ heuristic (source ``vtable.candidate`` / ``vtable.slot_ref``), never merged
7
+ into the certain xref stream: a data run of code pointers is also what a
8
+ hand-built callback array looks like.
9
+
10
+ Thunk resolution rides on top. Many slot targets are one-instruction
11
+ tail-call stubs (``jmp``/``b <target>``) that forward to the real override.
12
+ Following the chain recovers the final callee; every hop is a direct operand
13
+ read, but the *judgment* that a one-instruction function is a thunk is
14
+ heuristic, so the resolved target is reported separately (``resolved_target``
15
+ plus ``vtable.thunk_chain`` evidence at medium confidence) instead of
16
+ rewriting the slot target.
17
+
18
+ The analysed file is only ever read from disk; nothing is executed.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import Any, Literal
26
+
27
+ from codevanta.core.disassembler import Disassembler, DisassemblyError
28
+ from codevanta.core.loader import BinaryInfo
29
+ from codevanta.core.report import AnalysisError, read_code
30
+ from codevanta.evidence.confidence import Confidence
31
+ from codevanta.evidence.models import Evidence
32
+
33
+ __all__ = [
34
+ "DEFAULT_MAX_SLOTS",
35
+ "DEFAULT_MAX_TABLES",
36
+ "DEFAULT_MIN_SLOTS",
37
+ "MAX_SLOTS_LIMIT",
38
+ "VtableCandidate",
39
+ "VtableScanResult",
40
+ "VtableSlot",
41
+ "find_vtables",
42
+ "resolve_thunk",
43
+ ]
44
+
45
+ #: Fewest consecutive code pointers for a run to count as a vtable candidate.
46
+ DEFAULT_MIN_SLOTS = 3
47
+
48
+ #: Default cap on slots decoded per candidate table.
49
+ DEFAULT_MAX_SLOTS = 256
50
+
51
+ #: Hard ceiling accepted for ``max_slots`` (bounded-resource rule).
52
+ MAX_SLOTS_LIMIT = 4_096
53
+
54
+ #: Default cap on candidate tables in scan mode.
55
+ DEFAULT_MAX_TABLES = 64
56
+
57
+ #: Default cap on bytes read per scanned section.
58
+ DEFAULT_MAX_SCAN_BYTES = 1_000_000
59
+
60
+ #: Hard ceiling accepted for ``max_scan_bytes`` per section.
61
+ MAX_SCAN_BYTES_LIMIT = 16_000_000
62
+
63
+ #: One-instruction mnemonics that mean "tail-call forwarding" to this layer.
64
+ _THUNK_MNEMONICS = frozenset({"jmp", "b"})
65
+
66
+ _SOURCE = "vtable.candidate"
67
+ _SLOT_SOURCE = "vtable.slot_ref"
68
+ _THUNK_SOURCE = "vtable.thunk_chain"
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class VtableSlot:
73
+ """One decoded slot: a data word that points into executable code."""
74
+
75
+ index: int
76
+ slot_address: int
77
+ target: int
78
+ resolved_target: int | None
79
+ evidence: Evidence
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ data: dict[str, Any] = {
83
+ "index": self.index,
84
+ "slot_address": f"0x{self.slot_address:x}",
85
+ "target": f"0x{self.target:x}",
86
+ "evidence": self.evidence.to_dict(),
87
+ }
88
+ if self.resolved_target is not None:
89
+ data["resolved_target"] = f"0x{self.resolved_target:x}"
90
+ return data
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class VtableCandidate:
95
+ """One discovered run of consecutive code pointers.
96
+
97
+ ``header_slots`` records how many leading non-code slots (the Itanium
98
+ layout's offset-to-top and RTTI pointer) were skipped before the run.
99
+ """
100
+
101
+ address: int
102
+ slots: list[VtableSlot]
103
+ slot_size: int
104
+ section_name: str
105
+ header_slots: int
106
+ truncated: bool
107
+ evidence: Evidence
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ return {
111
+ "address": f"0x{self.address:x}",
112
+ "slot_count": len(self.slots),
113
+ "slot_size": self.slot_size,
114
+ "section": self.section_name,
115
+ "header_slots": self.header_slots,
116
+ "truncated": self.truncated,
117
+ "slots": [slot.to_dict() for slot in self.slots],
118
+ "evidence": self.evidence.to_dict(),
119
+ }
120
+
121
+
122
+ @dataclass(frozen=True)
123
+ class VtableScanResult:
124
+ """Outcome of one discovery run (address mode or whole-binary scan)."""
125
+
126
+ tables: list[VtableCandidate]
127
+ scanned_sections: list[str]
128
+ scanned_bytes: int
129
+ truncated: bool
130
+ evidence: Evidence
131
+ warnings: tuple[str, ...] = ()
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {
135
+ "table_count": len(self.tables),
136
+ "tables": [table.to_dict() for table in self.tables],
137
+ "scanned_sections": list(self.scanned_sections),
138
+ "scanned_bytes": self.scanned_bytes,
139
+ "truncated": self.truncated,
140
+ "warnings": list(self.warnings),
141
+ "evidence": self.evidence.to_dict(),
142
+ }
143
+
144
+
145
+ def _byteorder(info: BinaryInfo) -> Literal["little", "big"]:
146
+ text = str(getattr(info, "endianness", "") or "").strip().lower()
147
+ return "big" if text.startswith(("big", "msb", "be")) else "little"
148
+
149
+
150
+ def _relocation_overlay(path: str | Path) -> dict[int, int]:
151
+ """Map slot VA -> value for position-independent pointer fills.
152
+
153
+ PIE/shared objects keep pointer slots zero in the file and fill them at
154
+ load time through dynamic relocations whose ``addend`` is the target VA.
155
+ LIEF's relocation table gives that mapping directly, so static analysis
156
+ can see the pointers a loader would create. Only symbol-less relocations
157
+ with a non-zero addend are used (``R_*_RELATIVE`` semantics across
158
+ architectures); anything else is not a pointer fill and stays untouched.
159
+ """
160
+ try:
161
+ import lief
162
+
163
+ binary = lief.parse(str(path))
164
+ except Exception:
165
+ return {}
166
+ if binary is None:
167
+ return {}
168
+ overlay: dict[int, int] = {}
169
+ for relocation in getattr(binary, "relocations", []) or []:
170
+ symbol = getattr(relocation, "symbol", None)
171
+ if symbol is not None and getattr(symbol, "name", None):
172
+ continue
173
+ addend = int(getattr(relocation, "addend", 0) or 0)
174
+ if addend <= 0:
175
+ continue
176
+ slot_address = int(getattr(relocation, "address", 0) or 0)
177
+ if slot_address > 0:
178
+ overlay[slot_address] = addend
179
+ return overlay
180
+
181
+
182
+ def _pointer_size(info: BinaryInfo) -> int:
183
+ return 8 if info.bitness >= 64 else 4
184
+
185
+
186
+ def _target_in_executable(info: BinaryInfo, target: int) -> bool:
187
+ section = info.section_for_va(target)
188
+ return section is not None and section.is_executable
189
+
190
+
191
+ def _new_disassembler(info: BinaryInfo) -> Disassembler | None:
192
+ try:
193
+ return Disassembler(info.architecture, info.bitness, info.endianness)
194
+ except DisassemblyError:
195
+ return None
196
+
197
+
198
+ def _read_slots(
199
+ path: str | Path,
200
+ info: BinaryInfo,
201
+ address: int,
202
+ *,
203
+ pointer_size: int,
204
+ byteorder: Literal["little", "big"],
205
+ min_slots: int,
206
+ max_slots: int,
207
+ resolve_thunks: bool,
208
+ max_thunk_depth: int,
209
+ disassembler: Disassembler | None,
210
+ section_name: str,
211
+ overlay: dict[int, int] | None = None,
212
+ skip_header: bool = False,
213
+ ) -> tuple[list[VtableSlot], int] | None:
214
+ """Read consecutive code-pointer slots from ``address``.
215
+
216
+ With ``skip_header`` (address mode), up to two leading non-code slots —
217
+ the Itanium layout's offset-to-top and RTTI pointer — are skipped before
218
+ the run must start. ``overlay`` supplies relocation-filled pointer values
219
+ for PIE files whose slots are zero on disk. Returns ``None`` when fewer
220
+ than ``min_slots`` valid slots were found, else the decoded slots and the
221
+ number of skipped header slots.
222
+ """
223
+ slots: list[VtableSlot] = []
224
+ cursor = address
225
+ header_slots = 0
226
+ while len(slots) < max_slots:
227
+ try:
228
+ raw = read_code(path, info, cursor, pointer_size)
229
+ except (AnalysisError, OSError):
230
+ break
231
+ if len(raw) < pointer_size:
232
+ break
233
+ value = int.from_bytes(raw, byteorder)
234
+ relocated = bool(overlay and cursor in overlay)
235
+ if relocated:
236
+ value = int(overlay[cursor]) # type: ignore[index]
237
+ is_code = value != 0 and _target_in_executable(info, value)
238
+ if not is_code and skip_header and not slots and header_slots < 2:
239
+ header_slots += 1
240
+ cursor += pointer_size
241
+ continue
242
+ if not is_code:
243
+ break
244
+ resolved: int | None = None
245
+ if resolve_thunks and disassembler is not None:
246
+ resolved = resolve_thunk(
247
+ path,
248
+ info,
249
+ value,
250
+ disassembler=disassembler,
251
+ max_depth=max_thunk_depth,
252
+ )
253
+ if resolved == value:
254
+ resolved = None # no forwarding hop: not a thunk
255
+ slots.append(
256
+ VtableSlot(
257
+ index=len(slots),
258
+ slot_address=cursor,
259
+ target=value,
260
+ resolved_target=resolved,
261
+ evidence=Evidence(
262
+ source=_SLOT_SOURCE,
263
+ method="pointer_array_run",
264
+ confidence=Confidence.MEDIUM,
265
+ address=cursor,
266
+ detail=(
267
+ f"slot {len(slots)} points at code 0x{value:x}"
268
+ + (" via dynamic relocation" if relocated else "")
269
+ + (f" via thunk chain to 0x{resolved:x}" if resolved else "")
270
+ + "; data-pointer run, not proof of a vtable"
271
+ ),
272
+ ),
273
+ )
274
+ )
275
+ cursor += pointer_size
276
+ if len(slots) < min_slots:
277
+ return None
278
+ return slots, header_slots
279
+
280
+
281
+ def resolve_thunk(
282
+ path: str | Path,
283
+ info: BinaryInfo,
284
+ address: int,
285
+ *,
286
+ disassembler: Disassembler | None = None,
287
+ max_depth: int = 4,
288
+ ) -> int | None:
289
+ """Follow a tail-call chain from ``address``.
290
+
291
+ A thunk here means the *first* instruction at ``address`` is a direct
292
+ ``jmp``/``b <target>`` — real binaries pack these stubs tightly, so no
293
+ claim is made about what follows. The final non-thunk address is returned,
294
+ or ``None`` when ``address`` does not start with a direct tail-call. The
295
+ heuristic judgment (one branch = thunk) lives in the caller's evidence,
296
+ not in this return value.
297
+ """
298
+ if max_depth < 1:
299
+ raise AnalysisError("max_depth must be >= 1")
300
+ disassembler = disassembler or _new_disassembler(info)
301
+ if disassembler is None:
302
+ return None
303
+ current = address
304
+ hops = 0
305
+ while hops < max_depth:
306
+ try:
307
+ code = read_code(path, info, current, 16)
308
+ except (AnalysisError, OSError):
309
+ break
310
+ if not code:
311
+ break
312
+ try:
313
+ result = disassembler.disassemble(code, base_address=current, max_instructions=1)
314
+ except DisassemblyError:
315
+ break
316
+ if not result.instructions:
317
+ break
318
+ only = result.instructions[0]
319
+ mnemonic = only.mnemonic.strip().lower()
320
+ operand = only.operands.strip().lstrip("#")
321
+ if (
322
+ mnemonic not in _THUNK_MNEMONICS
323
+ or not operand.startswith("0x")
324
+ or any(ch in operand for ch in "[]+:")
325
+ ):
326
+ break # not a forwarding hop: this address is the chain's end
327
+ try:
328
+ target = int(operand, 16)
329
+ except ValueError:
330
+ break
331
+ if not _target_in_executable(info, target) or target == current:
332
+ break
333
+ hops += 1
334
+ current = target
335
+ return current if hops > 0 else None
336
+
337
+
338
+ def find_vtables(
339
+ path: str | Path,
340
+ info: BinaryInfo,
341
+ *,
342
+ address: int | None = None,
343
+ min_slots: int = DEFAULT_MIN_SLOTS,
344
+ max_slots: int = DEFAULT_MAX_SLOTS,
345
+ max_tables: int = DEFAULT_MAX_TABLES,
346
+ max_scan_bytes: int = DEFAULT_MAX_SCAN_BYTES,
347
+ resolve_thunks: bool = True,
348
+ max_thunk_depth: int = 4,
349
+ ) -> VtableScanResult:
350
+ """Discover vtable candidates at ``address`` or across data sections.
351
+
352
+ Args:
353
+ path: File the metadata in ``info`` was loaded from (read-only).
354
+ info: Normalized binary metadata.
355
+ address: Exact vtable address to decode; ``None`` sweeps every
356
+ non-executable section with file bytes for candidate runs.
357
+ min_slots: Fewest consecutive code pointers that count as a table.
358
+ max_slots: Cap on slots decoded per candidate.
359
+ max_tables: Cap on candidates in scan mode.
360
+ max_scan_bytes: Cap on bytes read per scanned section.
361
+ resolve_thunks: Follow one-instruction tail-call chains from slot
362
+ targets and report the final callee as ``resolved_target``.
363
+ max_thunk_depth: Cap on thunk-chain hops.
364
+
365
+ Returns:
366
+ A :class:`VtableScanResult` with candidate tables in address order.
367
+
368
+ Raises:
369
+ AnalysisError: If any bound is out of range, or address mode found no
370
+ plausible table at ``address``.
371
+ """
372
+ if min_slots < 2:
373
+ raise AnalysisError("min_slots must be >= 2")
374
+ if max_slots < min_slots:
375
+ raise AnalysisError("max_slots must be >= min_slots")
376
+ if max_slots > MAX_SLOTS_LIMIT:
377
+ raise AnalysisError(f"max_slots too large (limit {MAX_SLOTS_LIMIT})")
378
+ if max_tables < 1 or max_tables > 1024:
379
+ raise AnalysisError("max_tables must be between 1 and 1024")
380
+ if max_scan_bytes < pointer_size_of(info):
381
+ raise AnalysisError("max_scan_bytes is smaller than one pointer")
382
+ if max_scan_bytes > MAX_SCAN_BYTES_LIMIT:
383
+ raise AnalysisError(f"max_scan_bytes too large (limit {MAX_SCAN_BYTES_LIMIT})")
384
+ if max_thunk_depth < 1 or max_thunk_depth > 16:
385
+ raise AnalysisError("max_thunk_depth must be between 1 and 16")
386
+
387
+ byteorder = _byteorder(info)
388
+ pointer_size = pointer_size_of(info)
389
+ overlay = _relocation_overlay(path)
390
+ disassembler: Disassembler | None = None
391
+ if resolve_thunks:
392
+ try:
393
+ disassembler = Disassembler(info.architecture, info.bitness, info.endianness)
394
+ except DisassemblyError as exc:
395
+ return VtableScanResult(
396
+ tables=[],
397
+ scanned_sections=[],
398
+ scanned_bytes=0,
399
+ truncated=False,
400
+ evidence=Evidence(
401
+ source=_SOURCE,
402
+ method="pointer_array_scan",
403
+ confidence=Confidence.MEDIUM,
404
+ detail=f"vtable scan unavailable: {exc}",
405
+ ),
406
+ warnings=(f"disassembly unavailable: {exc}",),
407
+ )
408
+
409
+ scanned_sections: list[str] = []
410
+ scanned_bytes = 0
411
+ truncated = False
412
+ warnings: list[str] = []
413
+ tables: list[VtableCandidate] = []
414
+
415
+ if address is not None:
416
+ section = info.section_for_va(address)
417
+ if section is None:
418
+ raise AnalysisError(f"address 0x{address:x} is not mapped")
419
+ read = _read_slots(
420
+ path,
421
+ info,
422
+ address,
423
+ pointer_size=pointer_size,
424
+ byteorder=byteorder,
425
+ min_slots=min_slots,
426
+ max_slots=max_slots,
427
+ resolve_thunks=resolve_thunks,
428
+ max_thunk_depth=max_thunk_depth,
429
+ disassembler=disassembler,
430
+ section_name=section.name,
431
+ overlay=overlay,
432
+ skip_header=True,
433
+ )
434
+ if read is None:
435
+ raise AnalysisError(
436
+ f"address 0x{address:x} does not start a plausible vtable "
437
+ f"(fewer than {min_slots} consecutive code pointers within the "
438
+ "2-slot Itanium header window)"
439
+ )
440
+ slots, header_slots = read
441
+ truncated = len(slots) >= max_slots
442
+ tables.append(
443
+ VtableCandidate(
444
+ address=address,
445
+ slots=slots,
446
+ slot_size=pointer_size,
447
+ section_name=section.name,
448
+ header_slots=header_slots,
449
+ truncated=truncated,
450
+ evidence=Evidence(
451
+ source=_SOURCE,
452
+ method="pointer_array_scan",
453
+ confidence=Confidence.MEDIUM,
454
+ address=address,
455
+ detail=(
456
+ f"{len(slots)} slot(s) at caller-supplied address "
457
+ f"({header_slots} header slot(s) skipped); "
458
+ "heuristic candidate, not proof"
459
+ ),
460
+ ),
461
+ )
462
+ )
463
+ scanned_sections.append(section.name)
464
+ scanned_bytes = (len(slots) + header_slots) * pointer_size
465
+ else:
466
+ data_sections = [
467
+ section
468
+ for section in info.sections
469
+ if not section.is_executable
470
+ and section.size > 0
471
+ and (section.raw_size > 0 or info.format == "ELF")
472
+ ]
473
+ for section in data_sections:
474
+ if len(tables) >= max_tables:
475
+ truncated = True
476
+ warnings.append(f"table limit {max_tables} reached; scan stopped early")
477
+ break
478
+ budget = min(max_scan_bytes, section.size)
479
+ try:
480
+ data = read_code(path, info, section.virtual_address, budget)
481
+ except (AnalysisError, OSError) as exc:
482
+ warnings.append(
483
+ f"section {section.name!r}: cannot read bytes ({exc})"
484
+ )
485
+ continue
486
+ if not data:
487
+ continue
488
+ scanned_sections.append(section.name)
489
+ scanned_bytes += len(data)
490
+ step = pointer_size
491
+ run_start: int | None = None
492
+ run_length = 0
493
+ for offset in range(0, len(data) - step + 1, step):
494
+ target_va = section.virtual_address + offset
495
+ value = int.from_bytes(data[offset : offset + step], byteorder)
496
+ if target_va in overlay:
497
+ value = overlay[target_va]
498
+ valid = value != 0 and _target_in_executable(info, value)
499
+ if valid:
500
+ if run_start is None:
501
+ run_start = target_va
502
+ run_length += 1
503
+ continue
504
+ if run_start is not None and run_length >= min_slots:
505
+ read = _read_slots(
506
+ path,
507
+ info,
508
+ run_start,
509
+ pointer_size=pointer_size,
510
+ byteorder=byteorder,
511
+ min_slots=min_slots,
512
+ max_slots=max_slots,
513
+ resolve_thunks=resolve_thunks,
514
+ max_thunk_depth=max_thunk_depth,
515
+ disassembler=disassembler,
516
+ section_name=section.name,
517
+ overlay=overlay,
518
+ )
519
+ if read is not None:
520
+ slots, _header = read
521
+ if len(tables) >= max_tables:
522
+ truncated = True
523
+ warnings.append(
524
+ f"table limit {max_tables} reached; scan stopped early"
525
+ )
526
+ break
527
+ truncated = truncated or len(slots) >= max_slots
528
+ tables.append(
529
+ VtableCandidate(
530
+ address=run_start,
531
+ slots=slots,
532
+ slot_size=pointer_size,
533
+ section_name=section.name,
534
+ header_slots=0,
535
+ truncated=truncated,
536
+ evidence=Evidence(
537
+ source=_SOURCE,
538
+ method="pointer_array_scan",
539
+ confidence=Confidence.MEDIUM,
540
+ address=run_start,
541
+ detail=(
542
+ f"{len(slots)} consecutive code pointer(s) in "
543
+ f"{section.name!r}; heuristic candidate, not proof"
544
+ ),
545
+ ),
546
+ )
547
+ )
548
+ run_start = None
549
+ run_length = 0
550
+
551
+ tables.sort(key=lambda table: table.address)
552
+ return VtableScanResult(
553
+ tables=tables,
554
+ scanned_sections=scanned_sections,
555
+ scanned_bytes=scanned_bytes,
556
+ truncated=truncated,
557
+ evidence=Evidence(
558
+ source=_SOURCE,
559
+ method="pointer_array_scan",
560
+ confidence=Confidence.MEDIUM,
561
+ detail=(
562
+ f"{len(tables)} vtable candidate(s) across {len(scanned_sections)} "
563
+ "data section(s); runs of code pointers are a heuristic, not proof"
564
+ ),
565
+ ),
566
+ warnings=tuple(warnings),
567
+ )
568
+
569
+
570
+ def pointer_size_of(info: BinaryInfo) -> int:
571
+ """Public alias for the pointer-size helper used by callers and tests."""
572
+ return 8 if info.bitness >= 64 else 4