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
|
@@ -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
|
+
)
|