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,542 @@
1
+ """Inter-function call graph assembled from resolved call cross references (phase 2).
2
+
3
+ The graph is an *aggregate* view over the cross references produced by
4
+ :mod:`codevanta.analysis.xrefs`. Nothing is disassembled here; this module only
5
+ filters, attributes, and deduplicates what the xref scan already proved.
6
+
7
+ Two rules keep the result honest:
8
+
9
+ 1. **Resolved transfers only.** Exactly three cross-reference kinds become
10
+ edges:
11
+
12
+ * ``"call"`` -- a direct call to a literal address. The target is the callee.
13
+ * ``"call_import"`` -- a call through a pointer slot that matches a known
14
+ import. The target is the *slot* address, so the node is an ``import``
15
+ node labelled with the import name and marked ``high`` confidence.
16
+ * ``"call_indirect"`` -- a call through a pointer slot that matches no
17
+ import. The slot address is certain, the callee it holds is not, so the
18
+ target is an ``external`` node of ``unknown`` confidence.
19
+
20
+ ``jump`` and ``branch`` references, and indirect calls whose slot address is
21
+ not statically known (``call rax``, ``call qword ptr [rax + 8]``), emit no
22
+ edge -- the xref layer does not report them -- so ``to_dict()`` never
23
+ implies a completeness it does not have.
24
+ 2. **Caller attribution is a heuristic.** The xref layer records the address of
25
+ the calling *instruction*, not the enclosing function (no function boundary
26
+ data is trusted here). A call site is therefore attributed to the **nearest
27
+ function candidate whose address is at or below the call address inside the
28
+ same section** -- i.e. the candidate that most plausibly owns that address.
29
+ If no candidate exists at or below the call site in that section, the call
30
+ address itself is used as the edge source and a single warning is recorded,
31
+ because the real caller is genuinely unknown. The evidence record of the
32
+ resulting graph states this limitation explicitly.
33
+
34
+ Addresses are virtual addresses (VA). The analysed file is only ever read from
35
+ disk; nothing is loaded or executed, and every loop is bounded by
36
+ ``max_functions`` / ``max_edges``.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import bisect
42
+ from dataclasses import dataclass
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ from codevanta.analysis.xrefs import Xref, XrefResult, find_xrefs
47
+ from codevanta.core.functions import FunctionCandidate, discover_functions
48
+ from codevanta.core.loader import BinaryInfo
49
+ from codevanta.core.report import AnalysisError
50
+ from codevanta.evidence.confidence import Confidence
51
+ from codevanta.evidence.models import Evidence
52
+
53
+ __all__ = [
54
+ "DEFAULT_MAX_EDGES",
55
+ "DEFAULT_MAX_FUNCTIONS",
56
+ "MAX_LIMIT",
57
+ "CallGraph",
58
+ "CallGraphEdge",
59
+ "CallGraphNode",
60
+ "build_call_graph",
61
+ ]
62
+
63
+ #: Largest value accepted for either build limit.
64
+ MAX_LIMIT = 200_000
65
+
66
+ #: Default ceiling on how many function candidates become nodes.
67
+ DEFAULT_MAX_FUNCTIONS = 2_000
68
+
69
+ #: Default ceiling on how many call edges are reported.
70
+ DEFAULT_MAX_EDGES = 20_000
71
+
72
+ #: Xref kind of a direct call to a literal address.
73
+ _XREF_CALL = "call"
74
+
75
+ #: Xref kind of a call through a pointer slot that matched a known import.
76
+ _XREF_CALL_IMPORT = "call_import"
77
+
78
+ #: Xref kind of a call through a resolvable pointer slot with no matching import.
79
+ _XREF_CALL_INDIRECT = "call_indirect"
80
+
81
+ #: Node kinds. ``external`` marks a call target that is not a known candidate
82
+ #: and whose callee is unknown; ``import`` marks a pointer slot that matched a
83
+ #: known import.
84
+ _KIND_ENTRY_POINT = "entry_point"
85
+ _KIND_FUNCTION = "function"
86
+ _KIND_EXTERNAL = "external"
87
+ _KIND_IMPORT = "import"
88
+ _KIND_CALL_SITE = "call_site"
89
+
90
+ #: Cross-reference kinds that become graph edges.
91
+ _INCLUDED_XREF_KINDS = frozenset({_XREF_CALL, _XREF_CALL_IMPORT, _XREF_CALL_INDIRECT})
92
+
93
+ #: Prefix used by :meth:`CallGraph.to_graph_model` node ids for external nodes.
94
+ _EXTERNAL_PREFIX = "external:"
95
+
96
+ #: Prefix used by :meth:`CallGraph.to_graph_model` node ids for import nodes.
97
+ _IMPORT_PREFIX = "import:"
98
+
99
+ #: Fallback id for an address that could not be resolved to any node.
100
+ _UNRESOLVED_ID = "unresolved"
101
+
102
+ #: Evidence describing how the graph as a whole was produced. The label keeps
103
+ #: its historical value (``callgraph.direct_calls``) so existing report consumers
104
+ #: and snapshots stay stable; the detail text states the extra coverage.
105
+ _EVIDENCE_SOURCE = "callgraph.direct_calls"
106
+ _EVIDENCE_METHOD = "xref_aggregation"
107
+
108
+ #: Default label applied to every edge produced by this module.
109
+ _EDGE_LABEL = "call"
110
+
111
+
112
+ def _hex(address: int | None) -> str | None:
113
+ """Render ``address`` as lowercase hex, or ``None`` when it is unknown."""
114
+ return None if address is None else f"0x{address:x}"
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class CallGraphNode:
119
+ """One vertex of the call graph.
120
+
121
+ Attributes:
122
+ address: Virtual address of the node, or ``None`` when unresolved. For
123
+ an ``import`` node this is the address of the import stub / IAT slot
124
+ the call goes through, not the address of the imported code.
125
+ label: Display name -- the candidate name, the import name, or a
126
+ synthesized ``sub_*`` / ``external_*`` placeholder when no name is
127
+ known.
128
+ kind: ``"entry_point"``, ``"function"``, ``"external"`` or ``"import"``.
129
+ confidence: Confidence value as a plain string, e.g. ``"high"``. Nodes
130
+ for unknown targets always report ``"unknown"``; nodes for resolved
131
+ imports report ``"high"``.
132
+ """
133
+
134
+ address: int | None
135
+ label: str
136
+ kind: str
137
+ confidence: str
138
+
139
+ def to_dict(self) -> dict[str, Any]:
140
+ """Serialize the node for the JSON report."""
141
+ return {
142
+ "address": _hex(self.address),
143
+ "label": self.label,
144
+ "kind": self.kind,
145
+ "confidence": self.confidence,
146
+ }
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class CallGraphEdge:
151
+ """A single directed call edge.
152
+
153
+ Attributes:
154
+ source: Attributed caller address, or the call address itself when no
155
+ candidate covers the call site.
156
+ target: Address the call refers to: the callee for a direct call, or the
157
+ pointer slot / import slot for an import or indirect call.
158
+ from_address: Virtual address of the calling instruction.
159
+ label: Edge kind label; ``"call"`` for every edge produced here. Import
160
+ naming is carried by the target node's ``label``, not by the edge.
161
+ """
162
+
163
+ source: int | None
164
+ target: int | None
165
+ from_address: int
166
+ label: str = _EDGE_LABEL
167
+
168
+ def to_dict(self) -> dict[str, Any]:
169
+ """Serialize the edge for the JSON report."""
170
+ return {
171
+ "source": _hex(self.source),
172
+ "target": _hex(self.target),
173
+ "from_address": _hex(self.from_address),
174
+ "label": self.label,
175
+ }
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class CallGraph:
180
+ """Nodes and call edges for one binary.
181
+
182
+ Attributes:
183
+ nodes: Nodes sorted by address (unknown addresses last).
184
+ edges: Edges deduplicated by ``(source, target, from_address)`` and
185
+ sorted by ``(source, from_address)``.
186
+ truncated: ``True`` when a limit or the underlying xref scan cut the
187
+ result short, so the graph may be incomplete.
188
+ evidence: How the graph as a whole was derived.
189
+ warnings: Human-readable caveats; caller-attribution failures are
190
+ reported here exactly once.
191
+ """
192
+
193
+ nodes: list[CallGraphNode]
194
+ edges: list[CallGraphEdge]
195
+ truncated: bool
196
+ evidence: Evidence
197
+ warnings: tuple[str, ...] = ()
198
+
199
+ def _nodes_with_endpoints(self) -> list[CallGraphNode]:
200
+ """Return the nodes plus synthetic nodes for every edge endpoint.
201
+
202
+ Caller attribution is heuristic, so an edge can start at a call site that
203
+ is not a candidate, and an indirect target need not be one either. Adding
204
+ a ``call_site`` / ``external`` node for each missing endpoint keeps both
205
+ ``to_dict()`` and ``to_graph_model()`` internally consistent.
206
+ """
207
+ nodes = list(self.nodes)
208
+ known_addresses = {node.address for node in nodes}
209
+ for edge in self.edges:
210
+ for address, kind in (
211
+ (edge.source, _KIND_CALL_SITE),
212
+ (edge.target, _KIND_EXTERNAL),
213
+ ):
214
+ if address in known_addresses:
215
+ continue
216
+ known_addresses.add(address)
217
+ nodes.append(
218
+ CallGraphNode(
219
+ address=address,
220
+ label=_hex(address) or "unresolved",
221
+ kind=kind,
222
+ confidence="unknown",
223
+ )
224
+ )
225
+ return nodes
226
+
227
+ def to_dict(self) -> dict[str, Any]:
228
+ """Serialize the graph for the JSON report."""
229
+ nodes = self._nodes_with_endpoints()
230
+ return {
231
+ "node_count": len(nodes),
232
+ "edge_count": len(self.edges),
233
+ "nodes": [node.to_dict() for node in nodes],
234
+ "edges": [edge.to_dict() for edge in self.edges],
235
+ "truncated": self.truncated,
236
+ "warnings": list(self.warnings),
237
+ "evidence": self.evidence.to_dict(),
238
+ }
239
+
240
+ def to_graph_model(self) -> dict[str, Any]:
241
+ """Serialize the graph as a generic ``{nodes, edges}`` graph model.
242
+
243
+ Node ids are strings: ``"0x<addr>"`` for a candidate node,
244
+ ``"import:0x<addr>"`` for a resolved import node and
245
+ ``"external:0x<addr>"`` for a node that only exists because some call
246
+ targeted an address that is not a known candidate. Edge endpoints reuse
247
+ those ids so consumers can join them to the node list.
248
+
249
+ Caller attribution is heuristic, so an edge can start at a call site that
250
+ is not a known candidate. Every endpoint without a node gets a synthetic
251
+ node (``call_site`` for a source, ``external`` for a target), so DOT and
252
+ GraphML consumers always see edges that reference existing node ids.
253
+ """
254
+ base_nodes = self._nodes_with_endpoints()
255
+ ids_by_address: dict[int | None, str] = {
256
+ node.address: _node_id(node) for node in base_nodes if node.address is not None
257
+ }
258
+ nodes: list[dict[str, Any]] = [
259
+ {
260
+ "id": _node_id(node),
261
+ "address": _hex(node.address),
262
+ "label": node.label,
263
+ "kind": node.kind,
264
+ "confidence": node.confidence,
265
+ }
266
+ for node in base_nodes
267
+ ]
268
+ known_ids = {node["id"] for node in nodes}
269
+
270
+ def ensure_node(endpoint_id: str, address: int | None, kind: str) -> None:
271
+ if endpoint_id in known_ids:
272
+ return
273
+ known_ids.add(endpoint_id)
274
+ nodes.append(
275
+ {
276
+ "id": endpoint_id,
277
+ "address": _hex(address),
278
+ "label": _hex(address) if address is not None else "unresolved",
279
+ "kind": kind,
280
+ "confidence": "unknown",
281
+ }
282
+ )
283
+
284
+ edges: list[dict[str, Any]] = []
285
+ for edge in self.edges:
286
+ source_id = ids_by_address.get(edge.source, _endpoint_id(edge.source))
287
+ target_id = ids_by_address.get(edge.target, _endpoint_id(edge.target))
288
+ ensure_node(source_id, edge.source, _KIND_CALL_SITE)
289
+ ensure_node(target_id, edge.target, _KIND_EXTERNAL)
290
+ edges.append(
291
+ {
292
+ "source": source_id,
293
+ "target": target_id,
294
+ "from_address": _hex(edge.from_address),
295
+ "label": edge.label,
296
+ }
297
+ )
298
+
299
+ nodes.sort(key=lambda node: node["id"])
300
+ edges.sort(key=lambda edge: (edge["source"], edge["target"], edge["from_address"]))
301
+ return {"nodes": nodes, "edges": edges, "truncated": self.truncated}
302
+
303
+
304
+ def _node_id(node: CallGraphNode) -> str:
305
+ """Return the stable graph-model id of ``node``.
306
+
307
+ Import nodes are namespaced so a slot address can never be confused with a
308
+ candidate or external node that happens to share the same address.
309
+ """
310
+ if node.address is None:
311
+ return _UNRESOLVED_ID
312
+ if node.kind == _KIND_IMPORT:
313
+ return f"{_IMPORT_PREFIX}0x{node.address:x}"
314
+ if node.kind == _KIND_EXTERNAL:
315
+ return f"{_EXTERNAL_PREFIX}0x{node.address:x}"
316
+ return f"0x{node.address:x}"
317
+
318
+
319
+ def _endpoint_id(address: int | None) -> str:
320
+ """Return a best-effort id for an edge endpoint that has no node."""
321
+ return _UNRESOLVED_ID if address is None else f"0x{address:x}"
322
+
323
+
324
+ def _node_sort_key(node: CallGraphNode) -> tuple[int, int]:
325
+ """Sort nodes by address, pushing unresolved addresses to the end."""
326
+ return (1, 0) if node.address is None else (0, node.address)
327
+
328
+
329
+ def _edge_sort_key(edge: CallGraphEdge) -> tuple[int, int]:
330
+ """Sort edges by attributed caller, then by call site."""
331
+ return (edge.source if edge.source is not None else -1, edge.from_address)
332
+
333
+
334
+ def _validate_limit(name: str, value: int) -> None:
335
+ """Reject limits outside ``1 .. MAX_LIMIT``."""
336
+ if value < 1:
337
+ raise AnalysisError(f"{name} must be >= 1")
338
+ if value > MAX_LIMIT:
339
+ raise AnalysisError(f"{name} too large (limit {MAX_LIMIT})")
340
+
341
+
342
+ def _dedupe_candidates(candidates: list[FunctionCandidate]) -> list[FunctionCandidate]:
343
+ """Keep one candidate per address, preferring the higher confidence.
344
+
345
+ Injected candidate lists need not be unique; discovery output usually is. The
346
+ winner is the highest-confidence candidate for an address, with the first
347
+ occurrence breaking ties. The result is sorted by address.
348
+ """
349
+ best: dict[int, FunctionCandidate] = {}
350
+ for candidate in candidates:
351
+ existing = best.get(candidate.address)
352
+ if existing is None or candidate.confidence.score > existing.confidence.score:
353
+ best[candidate.address] = candidate
354
+ return sorted(best.values(), key=lambda candidate: candidate.address)
355
+
356
+
357
+ def _section_key(info: BinaryInfo, address: int) -> tuple[str, int] | None:
358
+ """Identify the section containing ``address`` (``None`` when unmapped)."""
359
+ section = info.section_for_va(address)
360
+ if section is None:
361
+ return None
362
+ return (section.name, section.virtual_address)
363
+
364
+
365
+ def _group_by_section(
366
+ info: BinaryInfo, candidates: list[FunctionCandidate]
367
+ ) -> dict[tuple[str, int] | None, list[int]]:
368
+ """Map a section key to the ascending candidate addresses inside it."""
369
+ grouped: dict[tuple[str, int] | None, list[int]] = {}
370
+ for candidate in candidates: # already sorted by address
371
+ grouped.setdefault(_section_key(info, candidate.address), []).append(candidate.address)
372
+ return grouped
373
+
374
+
375
+ def _caller_for(
376
+ grouped: dict[tuple[str, int] | None, list[int]], info: BinaryInfo, address: int
377
+ ) -> int | None:
378
+ """Return the nearest candidate address at or below ``address``.
379
+
380
+ The candidate must live in the same section as ``address`` so that a call is
381
+ never attributed across a section boundary; see the module docstring for why
382
+ this heuristic is used and what it guarantees.
383
+ """
384
+ addresses = grouped.get(_section_key(info, address))
385
+ if not addresses:
386
+ return None
387
+ position = bisect.bisect_right(addresses, address)
388
+ return addresses[position - 1] if position else None
389
+
390
+
391
+ def _target_node(xref: Xref) -> CallGraphNode:
392
+ """Synthesize the node for a call target that is not a known candidate.
393
+
394
+ ``call_import`` references name a pointer slot that matched an import, so the
395
+ node is an ``import`` node labelled with the import name (or the slot hex when
396
+ the name is missing) and marked ``high`` confidence: the slot address comes
397
+ straight from the decoded operand and the import table corroborates it.
398
+
399
+ Every other included kind (``call`` to an unknown address, ``call_indirect``)
400
+ keeps the historical ``external`` node with ``unknown`` confidence, because
401
+ the callee behind the target is genuinely unknown.
402
+ """
403
+ if xref.kind == _XREF_CALL_IMPORT:
404
+ return CallGraphNode(
405
+ address=xref.to_address,
406
+ label=xref.target_name or f"0x{xref.to_address:x}",
407
+ kind=_KIND_IMPORT,
408
+ confidence=Confidence.HIGH.value,
409
+ )
410
+ return CallGraphNode(
411
+ address=xref.to_address,
412
+ label=f"external_{xref.to_address:x}",
413
+ kind=_KIND_EXTERNAL,
414
+ confidence=Confidence.UNKNOWN.value,
415
+ )
416
+
417
+
418
+ def build_call_graph(
419
+ path: str | Path,
420
+ info: BinaryInfo,
421
+ functions: list[FunctionCandidate] | None = None,
422
+ max_functions: int = DEFAULT_MAX_FUNCTIONS,
423
+ max_edges: int = DEFAULT_MAX_EDGES,
424
+ ) -> CallGraph:
425
+ """Build the call graph for ``info``.
426
+
427
+ Edges come from ``call``, ``call_import`` and ``call_indirect`` cross
428
+ references; ``jump``/``branch`` references and unresolved indirect calls are
429
+ ignored (see the module docstring).
430
+
431
+ Args:
432
+ path: File the bytes are read from (read-only; never executed).
433
+ info: Loaded binary metadata used for section lookups, scanning and
434
+ import matching.
435
+ functions: Pre-computed function candidates. When omitted,
436
+ :func:`~codevanta.core.functions.discover_functions` is used.
437
+ max_functions: Candidate budget (1 .. :data:`MAX_LIMIT`). When more
438
+ candidates exist the lowest-address ones are kept, the rest are
439
+ dropped, and the graph is marked ``truncated``; calls to a dropped
440
+ candidate degrade to ``external`` nodes.
441
+ max_edges: Edge budget (1 .. :data:`MAX_LIMIT`). Edges are deduplicated
442
+ and sorted before the cut, so the kept edges are the first ones in
443
+ ``(source, from_address)`` order.
444
+
445
+ Returns:
446
+ The assembled :class:`CallGraph`.
447
+
448
+ Raises:
449
+ AnalysisError: If either limit falls outside ``1 .. MAX_LIMIT``.
450
+ """
451
+ _validate_limit("max_functions", max_functions)
452
+ _validate_limit("max_edges", max_edges)
453
+
454
+ warnings: list[str] = []
455
+ candidates = _dedupe_candidates(
456
+ list(functions) if functions is not None else discover_functions(info)
457
+ )
458
+ truncated = False
459
+ if len(candidates) > max_functions:
460
+ candidates = candidates[:max_functions]
461
+ truncated = True
462
+ warnings.append(
463
+ f"function limit reached (max_functions={max_functions}); only the "
464
+ "lowest-address candidates are represented"
465
+ )
466
+
467
+ nodes: dict[int, CallGraphNode] = {}
468
+ for candidate in candidates:
469
+ is_entry = info.entry_point is not None and candidate.address == info.entry_point
470
+ nodes[candidate.address] = CallGraphNode(
471
+ address=candidate.address,
472
+ label=candidate.name or f"sub_{candidate.address:x}",
473
+ kind=_KIND_ENTRY_POINT if is_entry else _KIND_FUNCTION,
474
+ confidence=candidate.confidence.value,
475
+ )
476
+
477
+ grouped = _group_by_section(info, candidates)
478
+ xrefs: XrefResult = find_xrefs(path, info, functions=candidates)
479
+ if xrefs.truncated:
480
+ truncated = True
481
+ warnings.extend(f"xrefs: {warning}" for warning in xrefs.warnings)
482
+
483
+ edges: dict[tuple[int | None, int | None, int], tuple[CallGraphEdge, str]] = {}
484
+ unattributed = 0
485
+ for xref in xrefs.xrefs:
486
+ if xref.kind not in _INCLUDED_XREF_KINDS:
487
+ continue # jumps and conditional branches are never call edges
488
+ target = xref.to_address
489
+ source = _caller_for(grouped, info, xref.from_address)
490
+ if source is None:
491
+ source = xref.from_address
492
+ unattributed += 1
493
+ # A candidate at the target address wins over a synthesized node: the
494
+ # xref scan proves the address, the candidate proves it is code.
495
+ if target not in nodes:
496
+ nodes[target] = _target_node(xref)
497
+ key = (source, target, xref.from_address)
498
+ if key not in edges:
499
+ edges[key] = (
500
+ CallGraphEdge(
501
+ source=source,
502
+ target=target,
503
+ from_address=xref.from_address,
504
+ label=_EDGE_LABEL,
505
+ ),
506
+ xref.kind,
507
+ )
508
+
509
+ ordered = sorted(edges.values(), key=lambda item: _edge_sort_key(item[0]))
510
+ if len(ordered) > max_edges:
511
+ ordered = ordered[:max_edges]
512
+ truncated = True
513
+ warnings.append(f"edge limit reached (max_edges={max_edges}); later edges were dropped")
514
+ ordered_edges = [edge for edge, _kind in ordered]
515
+ import_edges = sum(1 for _edge, kind in ordered if kind == _XREF_CALL_IMPORT)
516
+ indirect_edges = sum(1 for _edge, kind in ordered if kind == _XREF_CALL_INDIRECT)
517
+ if unattributed:
518
+ warnings.append(
519
+ f"caller attribution failed for {unattributed} call site(s); the call "
520
+ "address itself is used as the edge source for those edges"
521
+ )
522
+
523
+ ordered_nodes = sorted(nodes.values(), key=_node_sort_key)
524
+ evidence = Evidence(
525
+ source=_EVIDENCE_SOURCE,
526
+ method=_EVIDENCE_METHOD,
527
+ confidence=Confidence.MEDIUM,
528
+ detail=(
529
+ f"{len(ordered_nodes)} nodes and {len(ordered_edges)} edges from direct "
530
+ f"calls plus resolved import and indirect memory calls "
531
+ f"({import_edges} import, {indirect_edges} indirect); caller attribution "
532
+ "is heuristic (nearest candidate at or below the call site) and unresolved "
533
+ "indirect calls are not represented"
534
+ ),
535
+ )
536
+ return CallGraph(
537
+ nodes=ordered_nodes,
538
+ edges=ordered_edges,
539
+ truncated=truncated,
540
+ evidence=evidence,
541
+ warnings=tuple(warnings),
542
+ )