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,1168 @@
|
|
|
1
|
+
"""Structural diffing of two loaded binaries.
|
|
2
|
+
|
|
3
|
+
:func:`diff_infos` compares two :class:`~codevanta.core.loader.BinaryInfo`
|
|
4
|
+
snapshots together with their discovered function candidates and reports what
|
|
5
|
+
was added, removed or changed. :func:`diff_binaries` is the convenience entry
|
|
6
|
+
point that loads both files first.
|
|
7
|
+
|
|
8
|
+
The comparison is deliberately *structural* by default: it only reads metadata
|
|
9
|
+
the loader already extracted (sections, imports, exports) plus the candidate list
|
|
10
|
+
produced by :func:`~codevanta.core.functions.discover_functions`. No target file
|
|
11
|
+
is executed and no instruction bytes are compared, so a ``changed`` entry means
|
|
12
|
+
"the observed metadata differs", never "the machine code differs". Every delta
|
|
13
|
+
carries that caveat in its evidence record at ``Confidence.MEDIUM``.
|
|
14
|
+
|
|
15
|
+
``deep=True`` adds a *content* signal on top of that metadata, without changing
|
|
16
|
+
any structural rule. For every function that matches by address, the normalized
|
|
17
|
+
instruction stream of both sides is fingerprinted with
|
|
18
|
+
:func:`~codevanta.analysis.function_hash.function_fingerprint`; when the digests
|
|
19
|
+
differ the entry becomes ``changed`` (with both digests and a content note), and
|
|
20
|
+
when they match although the metadata changed, the note says so. Fingerprints are
|
|
21
|
+
shape based -- numeric literals are masked to ``#`` -- so an equal digest is not
|
|
22
|
+
byte equality and a different digest is not a semantic difference. Deep mode
|
|
23
|
+
still only reads file bytes, never executes them, and is bounded by
|
|
24
|
+
``max_functions`` per side, :data:`DEEP_MAX_INSTRUCTIONS` instructions per
|
|
25
|
+
fingerprint, and :data:`MAX_DEEP_FINGERPRINTS` fingerprinted pairs per side;
|
|
26
|
+
every bound that cuts work short is reported through ``warnings``. Functions
|
|
27
|
+
matched by name after moving, and one-sided functions, are fingerprinted by
|
|
28
|
+
neither side of deep mode and keep their structural verdict.
|
|
29
|
+
|
|
30
|
+
``semantic=True`` adds a *pairing* signal rather than a new structural rule.
|
|
31
|
+
After address and name matching, the candidates still unpaired on both sides are
|
|
32
|
+
scored with :func:`~codevanta.analysis.function_hash.function_similarity` -- the
|
|
33
|
+
multiset overlap of their normalized instruction shapes -- and the
|
|
34
|
+
highest-scoring pairs at or above ``threshold`` (default
|
|
35
|
+
:data:`DEFAULT_SEMANTIC_THRESHOLD`) are reported as one ``changed`` entry each,
|
|
36
|
+
carrying that ``similarity`` (rounded to four decimals). Leftovers that are not
|
|
37
|
+
paired keep their structural ``added``/``removed`` verdict, so semantic mode
|
|
38
|
+
never hides a one-sided function; it can only turn a remove-plus-add pair into
|
|
39
|
+
one change.
|
|
40
|
+
A score decodes both sides with one instruction set, so the pass first requires
|
|
41
|
+
both sides to target the same architecture: when the loader identified two
|
|
42
|
+
different ones (``BinaryInfo.architecture``, the token it folds ELF ``e_machine``
|
|
43
|
+
and PE ``Machine`` into), nothing is decoded or scored and a warning names both,
|
|
44
|
+
because shapes from different instruction sets are not comparable. Two identical
|
|
45
|
+
tokens behave exactly as described above, and an architecture the loader could
|
|
46
|
+
not identify (``"unknown"``) never triggers the guard: it means "not determined",
|
|
47
|
+
not "a different target".
|
|
48
|
+
Because every score decodes both regions, the pass is bounded twice: at most
|
|
49
|
+
:data:`MAX_SEMANTIC_PAIRS` candidate pairs are scored -- the pass is skipped,
|
|
50
|
+
with a warning, when the leftover cross product is larger -- and one score
|
|
51
|
+
decodes at most :data:`SEMANTIC_MAX_INSTRUCTIONS` instructions per side. A score
|
|
52
|
+
is a *shape* overlap, never proof that two functions are the same code.
|
|
53
|
+
|
|
54
|
+
Matching rules, in order:
|
|
55
|
+
|
|
56
|
+
1. **By address.** Candidates that share a virtual address are the same
|
|
57
|
+
function; if their metadata (name, size) differs the entry is ``changed``.
|
|
58
|
+
Discovery provenance (``source``/``confidence``) is *not* compared, because
|
|
59
|
+
it describes how a candidate was found, not the binary itself.
|
|
60
|
+
2. **By name.** Candidates left over on both sides are matched by name, so a
|
|
61
|
+
function that was relocated is reported as one ``changed`` entry (with the
|
|
62
|
+
old and new address) instead of one removal plus one addition.
|
|
63
|
+
3. **By content similarity** (only with ``semantic=True``, and only when both
|
|
64
|
+
sides report the same identified architecture). The candidates still
|
|
65
|
+
left over on both sides are paired greedily, highest score first, while the
|
|
66
|
+
score is at least ``threshold`` and neither side has been used yet. Each pair
|
|
67
|
+
is reported as one ``changed`` entry carrying ``similarity``.
|
|
68
|
+
4. **Everything left over** on the left is ``removed`` and on the right is
|
|
69
|
+
``added``.
|
|
70
|
+
|
|
71
|
+
Sections are compared by name, imports by ``(library, name)`` and exports by
|
|
72
|
+
name. Consequently a section that changed size/flags at the same name, or an
|
|
73
|
+
export that kept its name but moved, is not a delta.
|
|
74
|
+
|
|
75
|
+
``identical`` therefore means "no structural delta was observed". It does not
|
|
76
|
+
imply equal bytes: two different files that expose the same metadata compare as
|
|
77
|
+
identical. Sorting and truncation are deterministic (``max_functions``) so the
|
|
78
|
+
same inputs always produce the same output.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
import math
|
|
84
|
+
from collections import Counter
|
|
85
|
+
from collections.abc import Mapping, Sequence
|
|
86
|
+
from dataclasses import dataclass
|
|
87
|
+
from pathlib import Path
|
|
88
|
+
from typing import Any, Literal
|
|
89
|
+
|
|
90
|
+
from codevanta.analysis.function_hash import (
|
|
91
|
+
DEFAULT_MAX_INSTRUCTIONS,
|
|
92
|
+
function_fingerprint,
|
|
93
|
+
normalized_tokens,
|
|
94
|
+
)
|
|
95
|
+
from codevanta.core.functions import FunctionCandidate, discover_functions
|
|
96
|
+
from codevanta.core.loader import BinaryInfo, BinaryLoader
|
|
97
|
+
from codevanta.core.report import AnalysisError
|
|
98
|
+
from codevanta.evidence.confidence import Confidence
|
|
99
|
+
from codevanta.evidence.models import Evidence
|
|
100
|
+
|
|
101
|
+
__all__ = [
|
|
102
|
+
"DEFAULT_MAX_FUNCTIONS",
|
|
103
|
+
"DEFAULT_SEMANTIC_THRESHOLD",
|
|
104
|
+
"DEEP_MAX_INSTRUCTIONS",
|
|
105
|
+
"MAX_DEEP_FINGERPRINTS",
|
|
106
|
+
"MAX_MAX_FUNCTIONS",
|
|
107
|
+
"MAX_SEMANTIC_PAIRS",
|
|
108
|
+
"MAX_SEMANTIC_THRESHOLD",
|
|
109
|
+
"MIN_MAX_FUNCTIONS",
|
|
110
|
+
"MIN_SEMANTIC_THRESHOLD",
|
|
111
|
+
"SEMANTIC_MAX_INSTRUCTIONS",
|
|
112
|
+
"BinaryDiff",
|
|
113
|
+
"FunctionDelta",
|
|
114
|
+
"diff_binaries",
|
|
115
|
+
"diff_infos",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
#: Default ceiling on how many function candidates per side are compared.
|
|
119
|
+
DEFAULT_MAX_FUNCTIONS = 5_000
|
|
120
|
+
|
|
121
|
+
#: Smallest accepted ``max_functions``; smaller requests are clamped up.
|
|
122
|
+
MIN_MAX_FUNCTIONS = 1
|
|
123
|
+
|
|
124
|
+
#: Largest accepted ``max_functions``; larger requests are clamped down.
|
|
125
|
+
MAX_MAX_FUNCTIONS = 20_000
|
|
126
|
+
|
|
127
|
+
#: Largest number of address-matched pairs fingerprinted per side in deep mode.
|
|
128
|
+
#: Deep mode decodes real instructions, so it needs its own, tighter bound than
|
|
129
|
+
#: :data:`DEFAULT_MAX_FUNCTIONS`; pairs above the ceiling keep their structural
|
|
130
|
+
#: verdict and are reported through ``warnings``.
|
|
131
|
+
MAX_DEEP_FINGERPRINTS = 1_000
|
|
132
|
+
|
|
133
|
+
#: Instruction ceiling for one deep-mode fingerprint. Equal to
|
|
134
|
+
#: :data:`~codevanta.analysis.function_hash.DEFAULT_MAX_INSTRUCTIONS`, so a
|
|
135
|
+
#: fingerprint taken during a diff matches one taken on its own.
|
|
136
|
+
DEEP_MAX_INSTRUCTIONS = DEFAULT_MAX_INSTRUCTIONS
|
|
137
|
+
|
|
138
|
+
#: Default minimum shape-similarity score for semantic pairing.
|
|
139
|
+
DEFAULT_SEMANTIC_THRESHOLD = 0.9
|
|
140
|
+
|
|
141
|
+
#: Smallest accepted ``threshold``; anything lower is rejected.
|
|
142
|
+
MIN_SEMANTIC_THRESHOLD = 0.0
|
|
143
|
+
|
|
144
|
+
#: Largest accepted ``threshold``; anything higher is rejected.
|
|
145
|
+
MAX_SEMANTIC_THRESHOLD = 1.0
|
|
146
|
+
|
|
147
|
+
#: Largest number of leftover function pairs scored in semantic mode. Scoring a
|
|
148
|
+
#: pair is scored from tokens decoded once per leftover, so the pass needs a bound
|
|
149
|
+
#: of its own: when the leftover cross product is larger, semantic pairing is
|
|
150
|
+
#: skipped (with a warning)
|
|
151
|
+
#: and every leftover keeps its structural ``added``/``removed`` verdict.
|
|
152
|
+
MAX_SEMANTIC_PAIRS = 4_096
|
|
153
|
+
|
|
154
|
+
#: Instruction ceiling per side for one semantic similarity score. Equal to
|
|
155
|
+
#: :data:`~codevanta.analysis.function_hash.DEFAULT_MAX_INSTRUCTIONS`, so a score
|
|
156
|
+
#: taken during a diff matches one taken on its own.
|
|
157
|
+
SEMANTIC_MAX_INSTRUCTIONS = DEFAULT_MAX_INSTRUCTIONS
|
|
158
|
+
|
|
159
|
+
#: Decimals kept in :attr:`FunctionDelta.similarity`, matching the rounding
|
|
160
|
+
#: ``SimilarityResult.to_dict()`` applies, so the field, the evidence note and the
|
|
161
|
+
#: JSON report cannot disagree about a score.
|
|
162
|
+
_SIMILARITY_DECIMALS = 4
|
|
163
|
+
|
|
164
|
+
#: ``"added"`` and ``"removed"`` are one-sided; ``"changed"`` covers a metadata
|
|
165
|
+
#: edit at the same address, a differing content fingerprint at the same address
|
|
166
|
+
#: (deep mode), a relocation matched by name, and a pair matched by shape
|
|
167
|
+
#: similarity (semantic mode).
|
|
168
|
+
ChangeKind = Literal["added", "removed", "changed"]
|
|
169
|
+
|
|
170
|
+
_EVIDENCE_SOURCE = "binary_diff.structural"
|
|
171
|
+
_EVIDENCE_METHOD = "metadata_compare"
|
|
172
|
+
_CAVEAT = (
|
|
173
|
+
"structural metadata only: no instruction bytes were read, compared or executed, "
|
|
174
|
+
"so this is not a semantic equivalence claim"
|
|
175
|
+
)
|
|
176
|
+
_CONTENT_CAVEAT = (
|
|
177
|
+
"structural metadata plus normalized instruction shapes: numeric literals were masked to "
|
|
178
|
+
"'#', so equal digests are not byte equality and different digests are not a semantic "
|
|
179
|
+
"difference; nothing was executed"
|
|
180
|
+
)
|
|
181
|
+
_SIMILARITY_CAVEAT = (
|
|
182
|
+
"structural metadata plus normalized instruction-shape similarity: numeric literals were "
|
|
183
|
+
"masked to '#', so a pairing is a shape overlap of decoded instructions and not proof that "
|
|
184
|
+
"two functions are the same code; nothing was executed"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class FunctionDelta:
|
|
190
|
+
"""One function-level difference between the two binaries.
|
|
191
|
+
|
|
192
|
+
Attributes:
|
|
193
|
+
address: Virtual address the delta is reported at. The right-hand
|
|
194
|
+
address when the candidate exists on the right, otherwise the
|
|
195
|
+
left-hand address.
|
|
196
|
+
name: Best-known name, taken from the right-hand side when it has one.
|
|
197
|
+
change: ``"added"``, ``"removed"`` or ``"changed"``. A function matched
|
|
198
|
+
by name after moving is reported as ``"changed"``.
|
|
199
|
+
left: Metadata snapshot of the left-hand candidate, or ``None`` for an
|
|
200
|
+
``"added"`` entry.
|
|
201
|
+
right: Metadata snapshot of the right-hand candidate, or ``None`` for a
|
|
202
|
+
``"removed"`` entry.
|
|
203
|
+
evidence: Why this delta is claimed, including the applicable caveat and
|
|
204
|
+
the specific reason (metadata edit, differing fingerprint, or
|
|
205
|
+
relocation).
|
|
206
|
+
left_digest: Normalized-content fingerprint of the left-hand function,
|
|
207
|
+
set only by deep mode and only when the pair was fingerprinted.
|
|
208
|
+
right_digest: Same for the right-hand function. ``left_digest`` and
|
|
209
|
+
``right_digest`` are always both set or both ``None``.
|
|
210
|
+
similarity: Shape-similarity score (``0.0 .. 1.0``) of the pair, set only
|
|
211
|
+
by semantic mode and only for a pair that was matched by that score.
|
|
212
|
+
Rounded to four decimals; ``None`` on every structurally matched
|
|
213
|
+
delta, including a name-matched relocation, because those were not
|
|
214
|
+
scored.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
address: int
|
|
218
|
+
name: str | None
|
|
219
|
+
change: ChangeKind
|
|
220
|
+
left: dict[str, Any] | None
|
|
221
|
+
right: dict[str, Any] | None
|
|
222
|
+
evidence: Evidence
|
|
223
|
+
left_digest: str | None = None
|
|
224
|
+
right_digest: str | None = None
|
|
225
|
+
similarity: float | None = None
|
|
226
|
+
|
|
227
|
+
def to_dict(self) -> dict[str, Any]:
|
|
228
|
+
data: dict[str, Any] = {"address": f"0x{self.address:x}", "change": self.change}
|
|
229
|
+
if self.name:
|
|
230
|
+
data["name"] = self.name
|
|
231
|
+
if self.left is not None:
|
|
232
|
+
data["left"] = dict(self.left)
|
|
233
|
+
if self.right is not None:
|
|
234
|
+
data["right"] = dict(self.right)
|
|
235
|
+
if self.left_digest is not None:
|
|
236
|
+
data["left_digest"] = self.left_digest
|
|
237
|
+
if self.right_digest is not None:
|
|
238
|
+
data["right_digest"] = self.right_digest
|
|
239
|
+
if self.similarity is not None:
|
|
240
|
+
data["similarity"] = self.similarity
|
|
241
|
+
data["evidence"] = self.evidence.to_dict()
|
|
242
|
+
return data
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True)
|
|
246
|
+
class BinaryDiff:
|
|
247
|
+
"""Structural difference between two binaries.
|
|
248
|
+
|
|
249
|
+
Attributes:
|
|
250
|
+
left_path: Path of the left-hand input as loaded.
|
|
251
|
+
right_path: Path of the right-hand input as loaded.
|
|
252
|
+
identical: ``True`` only when no function, section, import or export
|
|
253
|
+
delta was observed. Equal file hashes are neither required nor
|
|
254
|
+
implied.
|
|
255
|
+
functions_added: Candidates present only on the right.
|
|
256
|
+
functions_removed: Candidates present only on the left.
|
|
257
|
+
functions_changed: Candidates matched by address with different metadata
|
|
258
|
+
or -- in deep mode -- a different normalized content fingerprint,
|
|
259
|
+
matched by name after moving, or -- in semantic mode -- paired by
|
|
260
|
+
instruction-shape similarity.
|
|
261
|
+
sections_added: Section names present only on the right.
|
|
262
|
+
sections_removed: Section names present only on the left.
|
|
263
|
+
imports_added: Imports present only on the right, as ``"lib!name"`` when
|
|
264
|
+
a library is known and ``"name"`` otherwise.
|
|
265
|
+
imports_removed: Imports present only on the left, same formatting.
|
|
266
|
+
exports_added: Export names present only on the right.
|
|
267
|
+
exports_removed: Export names present only on the left.
|
|
268
|
+
evidence: Evidence describing the comparison as a whole, with the
|
|
269
|
+
applicable caveat (structural, or structural plus normalized
|
|
270
|
+
content in deep mode, or structural plus shape similarity in semantic
|
|
271
|
+
mode), the effective ``max_functions`` bound, and the deep-mode or
|
|
272
|
+
semantic-mode bounds when that mode ran.
|
|
273
|
+
warnings: Non-fatal problems, including truncation notices and a semantic
|
|
274
|
+
pass that was skipped (architecture mismatch or pair cap).
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
left_path: str
|
|
278
|
+
right_path: str
|
|
279
|
+
identical: bool
|
|
280
|
+
functions_added: tuple[FunctionDelta, ...]
|
|
281
|
+
functions_removed: tuple[FunctionDelta, ...]
|
|
282
|
+
functions_changed: tuple[FunctionDelta, ...]
|
|
283
|
+
sections_added: tuple[str, ...]
|
|
284
|
+
sections_removed: tuple[str, ...]
|
|
285
|
+
imports_added: tuple[str, ...]
|
|
286
|
+
imports_removed: tuple[str, ...]
|
|
287
|
+
exports_added: tuple[str, ...]
|
|
288
|
+
exports_removed: tuple[str, ...]
|
|
289
|
+
evidence: Evidence
|
|
290
|
+
warnings: tuple[str, ...] = ()
|
|
291
|
+
|
|
292
|
+
def to_dict(self) -> dict[str, Any]:
|
|
293
|
+
return {
|
|
294
|
+
"left_path": self.left_path,
|
|
295
|
+
"right_path": self.right_path,
|
|
296
|
+
"identical": self.identical,
|
|
297
|
+
"functions_added": [delta.to_dict() for delta in self.functions_added],
|
|
298
|
+
"functions_removed": [delta.to_dict() for delta in self.functions_removed],
|
|
299
|
+
"functions_changed": [delta.to_dict() for delta in self.functions_changed],
|
|
300
|
+
"sections_added": list(self.sections_added),
|
|
301
|
+
"sections_removed": list(self.sections_removed),
|
|
302
|
+
"imports_added": list(self.imports_added),
|
|
303
|
+
"imports_removed": list(self.imports_removed),
|
|
304
|
+
"exports_added": list(self.exports_added),
|
|
305
|
+
"exports_removed": list(self.exports_removed),
|
|
306
|
+
"evidence": self.evidence.to_dict(),
|
|
307
|
+
"warnings": list(self.warnings),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _sort_key(candidate: FunctionCandidate) -> tuple[int, str]:
|
|
312
|
+
"""Return a stable ordering key for a function candidate."""
|
|
313
|
+
return (candidate.address, candidate.name or "")
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _snapshot(candidate: FunctionCandidate | None) -> dict[str, Any] | None:
|
|
317
|
+
"""Return a JSON-safe metadata snapshot of ``candidate``."""
|
|
318
|
+
if candidate is None:
|
|
319
|
+
return None
|
|
320
|
+
data: dict[str, Any] = {
|
|
321
|
+
"address": f"0x{candidate.address:x}",
|
|
322
|
+
"source": candidate.source,
|
|
323
|
+
"confidence": candidate.confidence.value,
|
|
324
|
+
}
|
|
325
|
+
if candidate.name:
|
|
326
|
+
data["name"] = candidate.name
|
|
327
|
+
if candidate.size is not None:
|
|
328
|
+
data["size"] = candidate.size
|
|
329
|
+
return data
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _metadata_differences(left: FunctionCandidate, right: FunctionCandidate) -> tuple[str, ...]:
|
|
333
|
+
"""Return the metadata field names that differ between two candidates.
|
|
334
|
+
|
|
335
|
+
Only fields describing the function itself are compared. ``source``,
|
|
336
|
+
``confidence`` and ``evidence`` describe how the candidate was discovered
|
|
337
|
+
and may legitimately differ between two loads of the same file.
|
|
338
|
+
"""
|
|
339
|
+
differences: list[str] = []
|
|
340
|
+
if (left.name or None) != (right.name or None):
|
|
341
|
+
differences.append("name")
|
|
342
|
+
if left.size != right.size:
|
|
343
|
+
differences.append("size")
|
|
344
|
+
return tuple(differences)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _clamp_limit(requested: int, warnings: list[str]) -> int:
|
|
348
|
+
"""Clamp ``requested`` into ``[MIN_MAX_FUNCTIONS, MAX_MAX_FUNCTIONS]``.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
requested: Caller-supplied ``max_functions`` value.
|
|
352
|
+
warnings: Warning sink; every adjustment is recorded here.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
The effective comparison limit.
|
|
356
|
+
"""
|
|
357
|
+
if isinstance(requested, bool) or not isinstance(requested, int):
|
|
358
|
+
warnings.append(
|
|
359
|
+
f"max_functions={requested!r} is not an integer; using {DEFAULT_MAX_FUNCTIONS}"
|
|
360
|
+
)
|
|
361
|
+
return DEFAULT_MAX_FUNCTIONS
|
|
362
|
+
if requested < MIN_MAX_FUNCTIONS:
|
|
363
|
+
warnings.append(
|
|
364
|
+
f"max_functions={requested} is below the supported minimum; "
|
|
365
|
+
f"clamped to {MIN_MAX_FUNCTIONS}"
|
|
366
|
+
)
|
|
367
|
+
return MIN_MAX_FUNCTIONS
|
|
368
|
+
if requested > MAX_MAX_FUNCTIONS:
|
|
369
|
+
warnings.append(
|
|
370
|
+
f"max_functions={requested} exceeds the supported maximum; "
|
|
371
|
+
f"clamped to {MAX_MAX_FUNCTIONS}"
|
|
372
|
+
)
|
|
373
|
+
return MAX_MAX_FUNCTIONS
|
|
374
|
+
return requested
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _validate_threshold(threshold: float) -> float:
|
|
378
|
+
"""Return ``threshold`` as a float, rejecting values outside ``0.0 .. 1.0``.
|
|
379
|
+
|
|
380
|
+
The threshold is validated in every mode, not only with ``semantic=True``, so
|
|
381
|
+
a caller never gets a silently ignored out-of-range value that would behave
|
|
382
|
+
differently the moment semantic matching is switched on.
|
|
383
|
+
|
|
384
|
+
Args:
|
|
385
|
+
threshold: Smallest similarity score that may pair two functions.
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
The threshold as a ``float``.
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
AnalysisError: When ``threshold`` is not a finite number in
|
|
392
|
+
``MIN_SEMANTIC_THRESHOLD .. MAX_SEMANTIC_THRESHOLD``.
|
|
393
|
+
"""
|
|
394
|
+
if isinstance(threshold, bool) or not isinstance(threshold, (int, float)):
|
|
395
|
+
raise AnalysisError(f"threshold must be a number, got {threshold!r}")
|
|
396
|
+
value = float(threshold)
|
|
397
|
+
if not math.isfinite(value) or not (MIN_SEMANTIC_THRESHOLD <= value <= MAX_SEMANTIC_THRESHOLD):
|
|
398
|
+
raise AnalysisError(
|
|
399
|
+
f"threshold must be within {MIN_SEMANTIC_THRESHOLD} .. "
|
|
400
|
+
f"{MAX_SEMANTIC_THRESHOLD}, got {threshold!r}"
|
|
401
|
+
)
|
|
402
|
+
return value
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _bounded(
|
|
406
|
+
functions: Sequence[FunctionCandidate],
|
|
407
|
+
limit: int,
|
|
408
|
+
side: str,
|
|
409
|
+
warnings: list[str],
|
|
410
|
+
) -> list[FunctionCandidate]:
|
|
411
|
+
"""Return ``functions`` sorted by address, truncated to ``limit`` entries.
|
|
412
|
+
|
|
413
|
+
Truncation keeps the lowest addresses so the result stays deterministic and
|
|
414
|
+
records a warning: the added/removed lists may then be incomplete.
|
|
415
|
+
"""
|
|
416
|
+
ordered = sorted(functions, key=_sort_key)
|
|
417
|
+
if len(ordered) <= limit:
|
|
418
|
+
return ordered
|
|
419
|
+
warnings.append(
|
|
420
|
+
f"{side} side has {len(ordered)} function candidates, more than "
|
|
421
|
+
f"max_functions={limit}; only the lowest addresses were compared, so "
|
|
422
|
+
"added/removed lists may be incomplete"
|
|
423
|
+
)
|
|
424
|
+
return ordered[:limit]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _first_by_address(functions: Sequence[FunctionCandidate]) -> dict[int, FunctionCandidate]:
|
|
428
|
+
"""Index candidates by address, keeping the first one per address.
|
|
429
|
+
|
|
430
|
+
Candidates are expected to be unique per address (as produced by
|
|
431
|
+
:func:`~codevanta.core.functions.discover_functions`); if a duplicate slips
|
|
432
|
+
in, the lowest sorted occurrence wins so the output stays deterministic.
|
|
433
|
+
"""
|
|
434
|
+
by_address: dict[int, FunctionCandidate] = {}
|
|
435
|
+
for candidate in sorted(functions, key=_sort_key):
|
|
436
|
+
by_address.setdefault(candidate.address, candidate)
|
|
437
|
+
return by_address
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _group_by_name(candidates: Sequence[FunctionCandidate]) -> dict[str, list[FunctionCandidate]]:
|
|
441
|
+
"""Group candidates by name, dropping unnamed ones and preserving order."""
|
|
442
|
+
groups: dict[str, list[FunctionCandidate]] = {}
|
|
443
|
+
for candidate in candidates:
|
|
444
|
+
if not candidate.name:
|
|
445
|
+
continue
|
|
446
|
+
groups.setdefault(candidate.name, []).append(candidate)
|
|
447
|
+
return groups
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _delta(
|
|
451
|
+
change: ChangeKind,
|
|
452
|
+
address: int,
|
|
453
|
+
left: FunctionCandidate | None,
|
|
454
|
+
right: FunctionCandidate | None,
|
|
455
|
+
detail: str,
|
|
456
|
+
*,
|
|
457
|
+
caveat: str = _CAVEAT,
|
|
458
|
+
left_digest: str | None = None,
|
|
459
|
+
right_digest: str | None = None,
|
|
460
|
+
similarity: float | None = None,
|
|
461
|
+
) -> FunctionDelta:
|
|
462
|
+
"""Assemble one :class:`FunctionDelta` with its caveated evidence record."""
|
|
463
|
+
primary = right if right is not None else left
|
|
464
|
+
return FunctionDelta(
|
|
465
|
+
address=address,
|
|
466
|
+
name=primary.name if primary is not None else None,
|
|
467
|
+
change=change,
|
|
468
|
+
left=_snapshot(left),
|
|
469
|
+
right=_snapshot(right),
|
|
470
|
+
evidence=Evidence(
|
|
471
|
+
source=_EVIDENCE_SOURCE,
|
|
472
|
+
method=_EVIDENCE_METHOD,
|
|
473
|
+
confidence=Confidence.MEDIUM,
|
|
474
|
+
address=address,
|
|
475
|
+
detail=f"{caveat}; {detail}",
|
|
476
|
+
),
|
|
477
|
+
left_digest=left_digest,
|
|
478
|
+
right_digest=right_digest,
|
|
479
|
+
similarity=similarity,
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _address_digests(
|
|
484
|
+
info: BinaryInfo,
|
|
485
|
+
candidates: Sequence[FunctionCandidate],
|
|
486
|
+
addresses: set[int],
|
|
487
|
+
side: str,
|
|
488
|
+
warnings: list[str],
|
|
489
|
+
) -> dict[int, str]:
|
|
490
|
+
"""Fingerprint ``candidates`` whose address is in ``addresses``.
|
|
491
|
+
|
|
492
|
+
Bounded by construction: ``candidates`` is already truncated to
|
|
493
|
+
``max_functions`` and ``addresses`` is capped by the caller. Every failure is
|
|
494
|
+
counted rather than raised, so one unreadable function never loses the whole
|
|
495
|
+
comparison; the caller keeps the structural verdict for it.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
info: Metadata of the side being fingerprinted.
|
|
499
|
+
candidates: Bounded candidates of that side.
|
|
500
|
+
addresses: Addresses that were matched on both sides.
|
|
501
|
+
side: ``"left"`` or ``"right"``, used in warnings.
|
|
502
|
+
warnings: Warning sink for truncation and failure summaries.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Mapping of address to normalized-content digest, covering the candidates
|
|
506
|
+
that could be fingerprinted.
|
|
507
|
+
"""
|
|
508
|
+
digests: dict[int, str] = {}
|
|
509
|
+
truncated = 0
|
|
510
|
+
first_error: str | None = None
|
|
511
|
+
failed = 0
|
|
512
|
+
for candidate in candidates:
|
|
513
|
+
if candidate.address not in addresses:
|
|
514
|
+
continue
|
|
515
|
+
try:
|
|
516
|
+
fingerprint = function_fingerprint(
|
|
517
|
+
info.path,
|
|
518
|
+
info,
|
|
519
|
+
candidate.address,
|
|
520
|
+
max_instructions=DEEP_MAX_INSTRUCTIONS,
|
|
521
|
+
)
|
|
522
|
+
except (AnalysisError, OSError) as exc:
|
|
523
|
+
failed += 1
|
|
524
|
+
if first_error is None:
|
|
525
|
+
first_error = str(exc)
|
|
526
|
+
continue
|
|
527
|
+
digests[candidate.address] = fingerprint.digest
|
|
528
|
+
if fingerprint.truncated:
|
|
529
|
+
truncated += 1
|
|
530
|
+
if truncated:
|
|
531
|
+
warnings.append(
|
|
532
|
+
f"{side} side: {truncated} fingerprint(s) stopped at "
|
|
533
|
+
f"DEEP_MAX_INSTRUCTIONS={DEEP_MAX_INSTRUCTIONS}; the content comparison covers "
|
|
534
|
+
"only the decoded prefix of those functions"
|
|
535
|
+
)
|
|
536
|
+
if failed:
|
|
537
|
+
warnings.append(
|
|
538
|
+
f"{side} side: {failed} function(s) could not be fingerprinted and were "
|
|
539
|
+
f"compared structurally only (first error: {first_error})"
|
|
540
|
+
)
|
|
541
|
+
return digests
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
#: Value :class:`~codevanta.core.loader.BinaryLoader` reports when it could not
|
|
545
|
+
#: map the file's machine type onto an architecture it knows. It means "not
|
|
546
|
+
#: determined", so it never counts as a *different* target architecture.
|
|
547
|
+
_UNKNOWN_ARCHITECTURE = "unknown"
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _comparable_architecture(info: BinaryInfo) -> str:
|
|
551
|
+
"""Return the architecture of ``info`` in a form two sides can be compared by.
|
|
552
|
+
|
|
553
|
+
:attr:`~codevanta.core.loader.BinaryInfo.architecture` is the field for this
|
|
554
|
+
job: the loader folds the ELF ``e_machine`` and the PE ``Machine`` header into
|
|
555
|
+
the same small set of canonical tokens (``"x86"``, ``"x86_64"``, ``"arm"``,
|
|
556
|
+
``"aarch64"``, ``"ia64"``, ``"mips"``), so two sides target the same
|
|
557
|
+
instruction set exactly when those tokens match. Case and surrounding
|
|
558
|
+
whitespace are folded too, so metadata assembled by hand compares by token as
|
|
559
|
+
well.
|
|
560
|
+
|
|
561
|
+
An unidentified architecture is reported as ``""``: the loader's ``"unknown"``
|
|
562
|
+
placeholder says "not determined", not "a different target", so it must never
|
|
563
|
+
be the reason a pairing pass is refused. An empty result therefore means "no
|
|
564
|
+
architecture claim" and is compatible with any other value.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
info: Metadata of one side of the comparison.
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
The lower-cased architecture token, or ``""`` when it is unknown.
|
|
571
|
+
"""
|
|
572
|
+
architecture = str(info.architecture or "").strip().lower()
|
|
573
|
+
if not architecture or architecture == _UNKNOWN_ARCHITECTURE:
|
|
574
|
+
return ""
|
|
575
|
+
return architecture
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
@dataclass(frozen=True)
|
|
579
|
+
class _SemanticRequest:
|
|
580
|
+
"""Inputs needed to pair leftover functions by shape similarity.
|
|
581
|
+
|
|
582
|
+
Attributes:
|
|
583
|
+
left_info: Metadata of the left-hand binary; its ``path`` is read for
|
|
584
|
+
the left-hand scores and its ``architecture`` gates the pass
|
|
585
|
+
(read-only, never executed).
|
|
586
|
+
right_info: Metadata of the right-hand binary, same contract.
|
|
587
|
+
threshold: Smallest score that may pair two candidates.
|
|
588
|
+
warnings: Warning sink shared with the enclosing diff, so every bound
|
|
589
|
+
that cuts the pass short reaches the caller.
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
left_info: BinaryInfo
|
|
593
|
+
right_info: BinaryInfo
|
|
594
|
+
threshold: float
|
|
595
|
+
warnings: list[str]
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _semantic_pairs(
|
|
599
|
+
left_leftovers: Sequence[FunctionCandidate],
|
|
600
|
+
right_leftovers: Sequence[FunctionCandidate],
|
|
601
|
+
request: _SemanticRequest,
|
|
602
|
+
) -> list[tuple[FunctionCandidate, FunctionCandidate, float]]:
|
|
603
|
+
"""Pair leftover candidates greedily by normalized instruction similarity.
|
|
604
|
+
|
|
605
|
+
Each leftover is decoded once with
|
|
606
|
+
:func:`~codevanta.analysis.function_hash.normalized_tokens` and its token
|
|
607
|
+
multiset is kept, so scoring the cross product costs one multiset overlap per
|
|
608
|
+
pair instead of a fresh decode per pair. Pairs are then considered from the
|
|
609
|
+
highest score down and taken while the score rounds to at least the threshold
|
|
610
|
+
and neither candidate has been paired already. A greedy pass keeps the result
|
|
611
|
+
deterministic and mutually exclusive: one candidate never appears in two
|
|
612
|
+
deltas.
|
|
613
|
+
|
|
614
|
+
Work is bounded before it starts: the pass refuses to run when the two sides
|
|
615
|
+
target different, identified architectures (see
|
|
616
|
+
:func:`_comparable_architecture`) -- one instruction set cannot describe the
|
|
617
|
+
other's shapes -- and when the leftover cross product does not fit in
|
|
618
|
+
:data:`MAX_SEMANTIC_PAIRS`; either way nothing is decoded or scored, and a
|
|
619
|
+
single warning explains the skip. Each decode is bounded by
|
|
620
|
+
:data:`SEMANTIC_MAX_INSTRUCTIONS` instructions.
|
|
621
|
+
A leftover that cannot be decoded (unreadable file, address outside an
|
|
622
|
+
executable section, undecodable architecture) is skipped rather than raised,
|
|
623
|
+
so one bad candidate never loses the whole comparison.
|
|
624
|
+
|
|
625
|
+
Args:
|
|
626
|
+
left_leftovers: Left-hand candidates no structural rule matched.
|
|
627
|
+
right_leftovers: Right-hand candidates no structural rule matched.
|
|
628
|
+
request: Bounds, metadata and warning sink for the pass.
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
``(left, right, score)`` triples in descending score order, each pair
|
|
632
|
+
reported at the right-hand candidate's address by the caller. ``score`` is
|
|
633
|
+
rounded to ``_SIMILARITY_DECIMALS`` decimals and the threshold is compared
|
|
634
|
+
at the same precision, so a reported score never falls below the
|
|
635
|
+
threshold that authorised it; the ordering uses the unrounded value. A
|
|
636
|
+
pair is never accepted at a rounded score of ``0.0``, so leftovers that
|
|
637
|
+
share no token stay ``added``/``removed`` even with ``threshold=0.0``.
|
|
638
|
+
"""
|
|
639
|
+
if not left_leftovers or not right_leftovers:
|
|
640
|
+
return []
|
|
641
|
+
# Architecture gate first: a cross-architecture score would compare shapes
|
|
642
|
+
# from two instruction sets, so it is noise dressed up as a likeness. Checking
|
|
643
|
+
# it before anything else also keeps the report to one explanation -- the
|
|
644
|
+
# warning below -- instead of stacking a pair-cap notice on top of it.
|
|
645
|
+
left_architecture = _comparable_architecture(request.left_info)
|
|
646
|
+
right_architecture = _comparable_architecture(request.right_info)
|
|
647
|
+
if (
|
|
648
|
+
left_architecture
|
|
649
|
+
and right_architecture
|
|
650
|
+
and left_architecture != right_architecture
|
|
651
|
+
):
|
|
652
|
+
request.warnings.append(
|
|
653
|
+
"semantic matching skipped: the two sides target different architectures "
|
|
654
|
+
f"(left {left_architecture}, right {right_architecture}), so their instruction "
|
|
655
|
+
"shapes are not comparable; every leftover keeps its added/removed verdict"
|
|
656
|
+
)
|
|
657
|
+
return []
|
|
658
|
+
left_sorted = sorted(left_leftovers, key=_sort_key)
|
|
659
|
+
right_sorted = sorted(right_leftovers, key=_sort_key)
|
|
660
|
+
total_pairs = len(left_sorted) * len(right_sorted)
|
|
661
|
+
if total_pairs > MAX_SEMANTIC_PAIRS:
|
|
662
|
+
request.warnings.append(
|
|
663
|
+
f"semantic matching skipped: {len(left_sorted)} left and {len(right_sorted)} "
|
|
664
|
+
f"right leftover function(s) need {total_pairs} pair comparison(s), more than "
|
|
665
|
+
f"MAX_SEMANTIC_PAIRS={MAX_SEMANTIC_PAIRS}; every leftover keeps its "
|
|
666
|
+
"added/removed verdict"
|
|
667
|
+
)
|
|
668
|
+
return []
|
|
669
|
+
|
|
670
|
+
left_counts: dict[int, Counter[str]] = {}
|
|
671
|
+
right_counts: dict[int, Counter[str]] = {}
|
|
672
|
+
failed = 0
|
|
673
|
+
first_error: str | None = None
|
|
674
|
+
for candidate in left_sorted:
|
|
675
|
+
try:
|
|
676
|
+
left_counts[candidate.address] = Counter(
|
|
677
|
+
normalized_tokens(
|
|
678
|
+
request.left_info.path,
|
|
679
|
+
request.left_info,
|
|
680
|
+
candidate.address,
|
|
681
|
+
SEMANTIC_MAX_INSTRUCTIONS,
|
|
682
|
+
)
|
|
683
|
+
)
|
|
684
|
+
except (AnalysisError, OSError) as exc:
|
|
685
|
+
failed += 1
|
|
686
|
+
if first_error is None:
|
|
687
|
+
first_error = str(exc)
|
|
688
|
+
for candidate in right_sorted:
|
|
689
|
+
try:
|
|
690
|
+
right_counts[candidate.address] = Counter(
|
|
691
|
+
normalized_tokens(
|
|
692
|
+
request.right_info.path,
|
|
693
|
+
request.right_info,
|
|
694
|
+
candidate.address,
|
|
695
|
+
SEMANTIC_MAX_INSTRUCTIONS,
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
except (AnalysisError, OSError) as exc:
|
|
699
|
+
failed += 1
|
|
700
|
+
if first_error is None:
|
|
701
|
+
first_error = str(exc)
|
|
702
|
+
|
|
703
|
+
if failed:
|
|
704
|
+
request.warnings.append(
|
|
705
|
+
f"semantic matching: {failed} function(s) could not be scored and were left "
|
|
706
|
+
f"unmatched (first error: {first_error})"
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
# One decode per leftover: the cross product only overlaps the kept multisets,
|
|
710
|
+
# so the pass is quadratic in comparisons but linear in decoding.
|
|
711
|
+
truncated_sides = sum(
|
|
712
|
+
1
|
|
713
|
+
for counts in (*left_counts.values(), *right_counts.values())
|
|
714
|
+
if sum(counts.values()) >= SEMANTIC_MAX_INSTRUCTIONS
|
|
715
|
+
)
|
|
716
|
+
if truncated_sides:
|
|
717
|
+
request.warnings.append(
|
|
718
|
+
f"semantic matching: {truncated_sides} function(s) were decoded only up to "
|
|
719
|
+
f"SEMANTIC_MAX_INSTRUCTIONS={SEMANTIC_MAX_INSTRUCTIONS} instructions, so their "
|
|
720
|
+
"scores compare prefixes"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
scored: list[tuple[float, FunctionCandidate, FunctionCandidate]] = []
|
|
724
|
+
for left_candidate in left_sorted:
|
|
725
|
+
left_count = left_counts.get(left_candidate.address)
|
|
726
|
+
if left_count is None:
|
|
727
|
+
continue
|
|
728
|
+
for right_candidate in right_sorted:
|
|
729
|
+
right_count = right_counts.get(right_candidate.address)
|
|
730
|
+
if right_count is None:
|
|
731
|
+
continue
|
|
732
|
+
union = sum((left_count | right_count).values())
|
|
733
|
+
if not union:
|
|
734
|
+
# Defensive: a decoded region always carries tokens, but a union of
|
|
735
|
+
# zero would turn every score into 0/0.
|
|
736
|
+
continue
|
|
737
|
+
shared = sum((left_count & right_count).values())
|
|
738
|
+
scored.append((shared / union, left_candidate, right_candidate))
|
|
739
|
+
|
|
740
|
+
scored.sort(key=lambda item: (-item[0], item[1].address, item[2].address))
|
|
741
|
+
rounded_threshold = round(request.threshold, _SIMILARITY_DECIMALS)
|
|
742
|
+
used_left: set[int] = set()
|
|
743
|
+
used_right: set[int] = set()
|
|
744
|
+
pairs: list[tuple[FunctionCandidate, FunctionCandidate, float]] = []
|
|
745
|
+
for score, left_candidate, right_candidate in scored:
|
|
746
|
+
rounded_score = round(score, _SIMILARITY_DECIMALS)
|
|
747
|
+
if rounded_score <= 0.0 or rounded_score < rounded_threshold:
|
|
748
|
+
# Scores are sorted descending, so no later pair can qualify either.
|
|
749
|
+
# A rounded zero means the pair shares no token: pairing it would claim
|
|
750
|
+
# a likeness the score denies.
|
|
751
|
+
break
|
|
752
|
+
if left_candidate.address in used_left or right_candidate.address in used_right:
|
|
753
|
+
continue
|
|
754
|
+
used_left.add(left_candidate.address)
|
|
755
|
+
used_right.add(right_candidate.address)
|
|
756
|
+
pairs.append((left_candidate, right_candidate, rounded_score))
|
|
757
|
+
return pairs
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _diff_functions(
|
|
761
|
+
left: Sequence[FunctionCandidate],
|
|
762
|
+
right: Sequence[FunctionCandidate],
|
|
763
|
+
left_digests: Mapping[int, str] | None = None,
|
|
764
|
+
right_digests: Mapping[int, str] | None = None,
|
|
765
|
+
semantic: _SemanticRequest | None = None,
|
|
766
|
+
) -> tuple[tuple[FunctionDelta, ...], tuple[FunctionDelta, ...], tuple[FunctionDelta, ...]]:
|
|
767
|
+
"""Diff two bounded candidate lists.
|
|
768
|
+
|
|
769
|
+
Args:
|
|
770
|
+
left: Bounded, sorted left-hand candidates.
|
|
771
|
+
right: Bounded, sorted right-hand candidates.
|
|
772
|
+
left_digests: Optional address-to-digest map for the left side (deep
|
|
773
|
+
mode). Addresses missing from the map keep their structural verdict.
|
|
774
|
+
right_digests: Same for the right side.
|
|
775
|
+
semantic: Optional request enabling the similarity pass over candidates
|
|
776
|
+
that address and name matching left unpaired. ``None`` keeps the
|
|
777
|
+
structural verdict for those candidates.
|
|
778
|
+
|
|
779
|
+
Returns:
|
|
780
|
+
``(added, removed, changed)`` tuples, each sorted by address.
|
|
781
|
+
"""
|
|
782
|
+
left_by_address = _first_by_address(left)
|
|
783
|
+
right_by_address = _first_by_address(right)
|
|
784
|
+
left_digests = left_digests or {}
|
|
785
|
+
right_digests = right_digests or {}
|
|
786
|
+
|
|
787
|
+
added: list[FunctionDelta] = []
|
|
788
|
+
removed: list[FunctionDelta] = []
|
|
789
|
+
changed: list[FunctionDelta] = []
|
|
790
|
+
|
|
791
|
+
# Pass 1: same address means the same function; compare its metadata and, in
|
|
792
|
+
# deep mode, its normalized content.
|
|
793
|
+
for address in sorted(set(left_by_address) & set(right_by_address)):
|
|
794
|
+
left_candidate = left_by_address[address]
|
|
795
|
+
right_candidate = right_by_address[address]
|
|
796
|
+
differences = _metadata_differences(left_candidate, right_candidate)
|
|
797
|
+
left_digest = left_digests.get(address)
|
|
798
|
+
right_digest = right_digests.get(address)
|
|
799
|
+
if left_digest is not None and right_digest is not None:
|
|
800
|
+
delta_left_digest, delta_right_digest = left_digest, right_digest
|
|
801
|
+
else:
|
|
802
|
+
# Attach a digest pair only when both sides were fingerprinted, so a
|
|
803
|
+
# delta never carries a misleading half pair.
|
|
804
|
+
delta_left_digest, delta_right_digest = None, None
|
|
805
|
+
content_changed = (
|
|
806
|
+
delta_left_digest is not None
|
|
807
|
+
and delta_right_digest is not None
|
|
808
|
+
and delta_left_digest != delta_right_digest
|
|
809
|
+
)
|
|
810
|
+
if not differences and not content_changed:
|
|
811
|
+
continue
|
|
812
|
+
|
|
813
|
+
notes: list[str] = []
|
|
814
|
+
if differences:
|
|
815
|
+
notes.append(
|
|
816
|
+
f"metadata changed at the same address 0x{address:x}: {', '.join(differences)}"
|
|
817
|
+
)
|
|
818
|
+
if content_changed:
|
|
819
|
+
notes.append(
|
|
820
|
+
f"content changed at 0x{address:x}: normalized fingerprints differ "
|
|
821
|
+
f"(left {left_digest}, right {right_digest})"
|
|
822
|
+
)
|
|
823
|
+
elif differences and left_digest is not None and right_digest is not None:
|
|
824
|
+
notes.append(
|
|
825
|
+
"normalized fingerprints match, so this is a metadata-only change"
|
|
826
|
+
)
|
|
827
|
+
changed.append(
|
|
828
|
+
_delta(
|
|
829
|
+
"changed",
|
|
830
|
+
address,
|
|
831
|
+
left_candidate,
|
|
832
|
+
right_candidate,
|
|
833
|
+
"; ".join(notes),
|
|
834
|
+
caveat=_CONTENT_CAVEAT if delta_left_digest is not None else _CAVEAT,
|
|
835
|
+
left_digest=delta_left_digest,
|
|
836
|
+
right_digest=delta_right_digest,
|
|
837
|
+
)
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
# Pass 2: pair up the leftovers by name so a move is one change, not two.
|
|
841
|
+
left_unmatched = [
|
|
842
|
+
candidate
|
|
843
|
+
for address, candidate in left_by_address.items()
|
|
844
|
+
if address not in right_by_address
|
|
845
|
+
]
|
|
846
|
+
right_unmatched = [
|
|
847
|
+
candidate
|
|
848
|
+
for address, candidate in right_by_address.items()
|
|
849
|
+
if address not in left_by_address
|
|
850
|
+
]
|
|
851
|
+
right_by_name = _group_by_name(sorted(right_unmatched, key=_sort_key))
|
|
852
|
+
|
|
853
|
+
still_left: list[FunctionCandidate] = []
|
|
854
|
+
matched_right: set[int] = set()
|
|
855
|
+
for candidate in sorted(left_unmatched, key=_sort_key):
|
|
856
|
+
queue = right_by_name.get(candidate.name) if candidate.name else None
|
|
857
|
+
if not queue:
|
|
858
|
+
still_left.append(candidate)
|
|
859
|
+
continue
|
|
860
|
+
partner = queue.pop(0)
|
|
861
|
+
matched_right.add(partner.address)
|
|
862
|
+
changed.append(
|
|
863
|
+
_delta(
|
|
864
|
+
"changed",
|
|
865
|
+
partner.address,
|
|
866
|
+
candidate,
|
|
867
|
+
partner,
|
|
868
|
+
f"matched by name {candidate.name!r} but moved from "
|
|
869
|
+
f"0x{candidate.address:x} to 0x{partner.address:x}",
|
|
870
|
+
)
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
# Pass 3 (semantic mode only): pair what is left by shape similarity. Only
|
|
874
|
+
# candidates no structural rule matched are eligible, so the pass can turn a
|
|
875
|
+
# remove-plus-add pair into one change but never overrides an address or name
|
|
876
|
+
# match.
|
|
877
|
+
if semantic is not None:
|
|
878
|
+
right_leftover = [
|
|
879
|
+
candidate
|
|
880
|
+
for candidate in sorted(right_unmatched, key=_sort_key)
|
|
881
|
+
if candidate.address not in matched_right
|
|
882
|
+
]
|
|
883
|
+
pairs = _semantic_pairs(still_left, right_leftover, semantic)
|
|
884
|
+
if pairs:
|
|
885
|
+
paired_left = {candidate.address for candidate, _partner, _score in pairs}
|
|
886
|
+
matched_right.update(partner.address for _candidate, partner, _score in pairs)
|
|
887
|
+
still_left = [
|
|
888
|
+
candidate for candidate in still_left if candidate.address not in paired_left
|
|
889
|
+
]
|
|
890
|
+
for left_candidate, right_candidate, score in pairs:
|
|
891
|
+
changed.append(
|
|
892
|
+
_delta(
|
|
893
|
+
"changed",
|
|
894
|
+
right_candidate.address,
|
|
895
|
+
left_candidate,
|
|
896
|
+
right_candidate,
|
|
897
|
+
f"paired by instruction-shape similarity {score:.4f} >= "
|
|
898
|
+
f"threshold {semantic.threshold:.4f}, moving from "
|
|
899
|
+
f"0x{left_candidate.address:x} to 0x{right_candidate.address:x}; "
|
|
900
|
+
"the score compares normalized shapes, it is not proof that the two "
|
|
901
|
+
"functions are the same code",
|
|
902
|
+
caveat=_SIMILARITY_CAVEAT,
|
|
903
|
+
similarity=score,
|
|
904
|
+
)
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
# Pass 4: whatever is left is one-sided.
|
|
908
|
+
for candidate in still_left:
|
|
909
|
+
removed.append(
|
|
910
|
+
_delta(
|
|
911
|
+
"removed",
|
|
912
|
+
candidate.address,
|
|
913
|
+
candidate,
|
|
914
|
+
None,
|
|
915
|
+
f"present only in the left binary at 0x{candidate.address:x}",
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
for candidate in sorted(right_unmatched, key=_sort_key):
|
|
919
|
+
if candidate.address in matched_right:
|
|
920
|
+
continue
|
|
921
|
+
added.append(
|
|
922
|
+
_delta(
|
|
923
|
+
"added",
|
|
924
|
+
candidate.address,
|
|
925
|
+
None,
|
|
926
|
+
candidate,
|
|
927
|
+
f"present only in the right binary at 0x{candidate.address:x}",
|
|
928
|
+
)
|
|
929
|
+
)
|
|
930
|
+
|
|
931
|
+
changed.sort(key=lambda delta: delta.address)
|
|
932
|
+
removed.sort(key=lambda delta: delta.address)
|
|
933
|
+
added.sort(key=lambda delta: delta.address)
|
|
934
|
+
return tuple(added), tuple(removed), tuple(changed)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _section_names(info: BinaryInfo) -> set[str]:
|
|
938
|
+
"""Return the set of section names exposed by ``info``."""
|
|
939
|
+
return {section.name for section in info.sections}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _import_keys(info: BinaryInfo) -> set[str]:
|
|
943
|
+
"""Return imports as ``"library!name"`` keys, or ``"name"`` without a library.
|
|
944
|
+
|
|
945
|
+
The library is part of the key because ``CreateFileA`` from ``kernel32.dll``
|
|
946
|
+
and from another module are different imports.
|
|
947
|
+
"""
|
|
948
|
+
keys: set[str] = set()
|
|
949
|
+
for item in info.imports:
|
|
950
|
+
if not item.name:
|
|
951
|
+
continue
|
|
952
|
+
keys.add(f"{item.library}!{item.name}" if item.library else item.name)
|
|
953
|
+
return keys
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
def _export_names(info: BinaryInfo) -> set[str]:
|
|
957
|
+
"""Return the set of export names exposed by ``info``."""
|
|
958
|
+
return {export.name for export in info.exports if export.name}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
def diff_infos(
|
|
962
|
+
left_info: BinaryInfo,
|
|
963
|
+
right_info: BinaryInfo,
|
|
964
|
+
left_functions: Sequence[FunctionCandidate],
|
|
965
|
+
right_functions: Sequence[FunctionCandidate],
|
|
966
|
+
max_functions: int = DEFAULT_MAX_FUNCTIONS,
|
|
967
|
+
deep: bool = False,
|
|
968
|
+
semantic: bool = False,
|
|
969
|
+
threshold: float = DEFAULT_SEMANTIC_THRESHOLD,
|
|
970
|
+
) -> BinaryDiff:
|
|
971
|
+
"""Compare two loaded binaries and their function candidates.
|
|
972
|
+
|
|
973
|
+
Structural and pure by default: with ``deep=False`` and ``semantic=False``
|
|
974
|
+
nothing is read from disk or executed, and the input sequences are never
|
|
975
|
+
mutated. With ``deep=True`` the function bytes of both sides are read (never
|
|
976
|
+
executed) to fingerprint the address-matched functions; with ``semantic=True``
|
|
977
|
+
they are read to score the functions that address and name matching left
|
|
978
|
+
unpaired. See the module docstring for what each mode adds and what it does
|
|
979
|
+
not.
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
left_info: Metadata of the left-hand (baseline) binary.
|
|
983
|
+
right_info: Metadata of the right-hand (candidate) binary.
|
|
984
|
+
left_functions: Function candidates discovered in ``left_info``.
|
|
985
|
+
right_functions: Function candidates discovered in ``right_info``.
|
|
986
|
+
max_functions: Per-side ceiling on compared candidates. Values outside
|
|
987
|
+
``[MIN_MAX_FUNCTIONS, MAX_MAX_FUNCTIONS]`` are clamped and recorded
|
|
988
|
+
in ``warnings``; candidates above the ceiling are dropped (lowest
|
|
989
|
+
addresses are kept) with a truncation warning.
|
|
990
|
+
deep: When true, also fingerprint address-matched functions and report a
|
|
991
|
+
differing normalized fingerprint as ``"changed"`` with both digests
|
|
992
|
+
and a content note.
|
|
993
|
+
semantic: When true, also pair the candidates that address and name
|
|
994
|
+
matching left unpaired when their normalized instruction shapes score
|
|
995
|
+
at least ``threshold``. A pair becomes one ``"changed"`` delta
|
|
996
|
+
carrying ``similarity``; every candidate that is not paired keeps its
|
|
997
|
+
``"added"``/``"removed"`` verdict. The pass is skipped, with a warning
|
|
998
|
+
naming both, when the two sides report different architectures, since
|
|
999
|
+
shapes from different instruction sets are not comparable.
|
|
1000
|
+
threshold: Smallest similarity score that may pair two leftovers, in
|
|
1001
|
+
``[MIN_SEMANTIC_THRESHOLD, MAX_SEMANTIC_THRESHOLD]``. Validated in
|
|
1002
|
+
every mode, so an out-of-range value raises even with
|
|
1003
|
+
``semantic=False``.
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
A :class:`BinaryDiff` whose ``identical`` flag is ``True`` only when no
|
|
1007
|
+
function, section, import or export delta was observed.
|
|
1008
|
+
|
|
1009
|
+
Raises:
|
|
1010
|
+
AnalysisError: When ``threshold`` is not a finite number within
|
|
1011
|
+
``0.0 .. 1.0``.
|
|
1012
|
+
"""
|
|
1013
|
+
effective_threshold = _validate_threshold(threshold)
|
|
1014
|
+
warnings: list[str] = []
|
|
1015
|
+
limit = _clamp_limit(max_functions, warnings)
|
|
1016
|
+
left_bounded = _bounded(left_functions, limit, "left", warnings)
|
|
1017
|
+
right_bounded = _bounded(right_functions, limit, "right", warnings)
|
|
1018
|
+
|
|
1019
|
+
left_digests: dict[int, str] = {}
|
|
1020
|
+
right_digests: dict[int, str] = {}
|
|
1021
|
+
if deep:
|
|
1022
|
+
matched = {candidate.address for candidate in left_bounded} & {
|
|
1023
|
+
candidate.address for candidate in right_bounded
|
|
1024
|
+
}
|
|
1025
|
+
if len(matched) > MAX_DEEP_FINGERPRINTS:
|
|
1026
|
+
warnings.append(
|
|
1027
|
+
f"{len(matched)} address-matched function(s) exceed "
|
|
1028
|
+
f"MAX_DEEP_FINGERPRINTS={MAX_DEEP_FINGERPRINTS}; only the "
|
|
1029
|
+
f"{MAX_DEEP_FINGERPRINTS} lowest addresses were fingerprinted and the rest "
|
|
1030
|
+
"were compared structurally"
|
|
1031
|
+
)
|
|
1032
|
+
matched = set(sorted(matched)[:MAX_DEEP_FINGERPRINTS])
|
|
1033
|
+
left_digests = _address_digests(left_info, left_bounded, matched, "left", warnings)
|
|
1034
|
+
right_digests = _address_digests(right_info, right_bounded, matched, "right", warnings)
|
|
1035
|
+
|
|
1036
|
+
functions_added, functions_removed, functions_changed = _diff_functions(
|
|
1037
|
+
left_bounded,
|
|
1038
|
+
right_bounded,
|
|
1039
|
+
left_digests,
|
|
1040
|
+
right_digests,
|
|
1041
|
+
_SemanticRequest(
|
|
1042
|
+
left_info=left_info,
|
|
1043
|
+
right_info=right_info,
|
|
1044
|
+
threshold=effective_threshold,
|
|
1045
|
+
warnings=warnings,
|
|
1046
|
+
)
|
|
1047
|
+
if semantic
|
|
1048
|
+
else None,
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
left_sections = _section_names(left_info)
|
|
1052
|
+
right_sections = _section_names(right_info)
|
|
1053
|
+
sections_added = tuple(sorted(right_sections - left_sections))
|
|
1054
|
+
sections_removed = tuple(sorted(left_sections - right_sections))
|
|
1055
|
+
|
|
1056
|
+
left_imports = _import_keys(left_info)
|
|
1057
|
+
right_imports = _import_keys(right_info)
|
|
1058
|
+
imports_added = tuple(sorted(right_imports - left_imports))
|
|
1059
|
+
imports_removed = tuple(sorted(left_imports - right_imports))
|
|
1060
|
+
|
|
1061
|
+
left_exports = _export_names(left_info)
|
|
1062
|
+
right_exports = _export_names(right_info)
|
|
1063
|
+
exports_added = tuple(sorted(right_exports - left_exports))
|
|
1064
|
+
exports_removed = tuple(sorted(left_exports - right_exports))
|
|
1065
|
+
|
|
1066
|
+
identical = not any(
|
|
1067
|
+
(
|
|
1068
|
+
functions_added,
|
|
1069
|
+
functions_removed,
|
|
1070
|
+
functions_changed,
|
|
1071
|
+
sections_added,
|
|
1072
|
+
sections_removed,
|
|
1073
|
+
imports_added,
|
|
1074
|
+
imports_removed,
|
|
1075
|
+
exports_added,
|
|
1076
|
+
exports_removed,
|
|
1077
|
+
)
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
extra: dict[str, Any] = {"max_functions": limit}
|
|
1081
|
+
caveat = _CAVEAT
|
|
1082
|
+
if deep:
|
|
1083
|
+
extra["deep"] = True
|
|
1084
|
+
extra["deep_instructions"] = DEEP_MAX_INSTRUCTIONS
|
|
1085
|
+
extra["max_deep_fingerprints"] = MAX_DEEP_FINGERPRINTS
|
|
1086
|
+
caveat = _CONTENT_CAVEAT
|
|
1087
|
+
if semantic:
|
|
1088
|
+
extra["semantic"] = True
|
|
1089
|
+
extra["semantic_threshold"] = effective_threshold
|
|
1090
|
+
extra["semantic_instructions"] = SEMANTIC_MAX_INSTRUCTIONS
|
|
1091
|
+
extra["max_semantic_pairs"] = MAX_SEMANTIC_PAIRS
|
|
1092
|
+
# Both modes read instruction shapes, so the record must keep saying that
|
|
1093
|
+
# the content comparison ran instead of dropping its caveat.
|
|
1094
|
+
caveat = f"{_CONTENT_CAVEAT}; {_SIMILARITY_CAVEAT}" if deep else _SIMILARITY_CAVEAT
|
|
1095
|
+
return BinaryDiff(
|
|
1096
|
+
left_path=str(left_info.path),
|
|
1097
|
+
right_path=str(right_info.path),
|
|
1098
|
+
identical=identical,
|
|
1099
|
+
functions_added=functions_added,
|
|
1100
|
+
functions_removed=functions_removed,
|
|
1101
|
+
functions_changed=functions_changed,
|
|
1102
|
+
sections_added=sections_added,
|
|
1103
|
+
sections_removed=sections_removed,
|
|
1104
|
+
imports_added=imports_added,
|
|
1105
|
+
imports_removed=imports_removed,
|
|
1106
|
+
exports_added=exports_added,
|
|
1107
|
+
exports_removed=exports_removed,
|
|
1108
|
+
evidence=Evidence(
|
|
1109
|
+
source=_EVIDENCE_SOURCE,
|
|
1110
|
+
method=_EVIDENCE_METHOD,
|
|
1111
|
+
confidence=Confidence.MEDIUM,
|
|
1112
|
+
detail=caveat,
|
|
1113
|
+
extra=extra,
|
|
1114
|
+
),
|
|
1115
|
+
warnings=tuple(warnings),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def diff_binaries(
|
|
1120
|
+
left_path: str | Path,
|
|
1121
|
+
right_path: str | Path,
|
|
1122
|
+
max_functions: int = DEFAULT_MAX_FUNCTIONS,
|
|
1123
|
+
deep: bool = False,
|
|
1124
|
+
semantic: bool = False,
|
|
1125
|
+
threshold: float = DEFAULT_SEMANTIC_THRESHOLD,
|
|
1126
|
+
) -> BinaryDiff:
|
|
1127
|
+
"""Load two binaries with LIEF and return their structural difference.
|
|
1128
|
+
|
|
1129
|
+
Both files are parsed only; nothing is mapped or executed. Loading errors
|
|
1130
|
+
propagate as :class:`~codevanta.core.loader.BinaryLoadError`.
|
|
1131
|
+
|
|
1132
|
+
Args:
|
|
1133
|
+
left_path: Path of the left-hand (baseline) binary.
|
|
1134
|
+
right_path: Path of the right-hand (candidate) binary.
|
|
1135
|
+
max_functions: Per-side ceiling on compared function candidates; see
|
|
1136
|
+
:func:`diff_infos`.
|
|
1137
|
+
deep: When true, also compare the normalized content of address-matched
|
|
1138
|
+
functions (their bytes are read, never executed); see
|
|
1139
|
+
:func:`diff_infos`.
|
|
1140
|
+
semantic: When true, also pair the functions that address and name
|
|
1141
|
+
matching left unpaired when their normalized instruction shapes score
|
|
1142
|
+
at least ``threshold``; see :func:`diff_infos`.
|
|
1143
|
+
threshold: Smallest similarity score that may pair two leftovers, in
|
|
1144
|
+
``0.0 .. 1.0``; see :func:`diff_infos`.
|
|
1145
|
+
|
|
1146
|
+
Returns:
|
|
1147
|
+
The :class:`BinaryDiff` produced by :func:`diff_infos`.
|
|
1148
|
+
|
|
1149
|
+
Raises:
|
|
1150
|
+
AnalysisError: When ``threshold`` is not a finite number within
|
|
1151
|
+
``0.0 .. 1.0``.
|
|
1152
|
+
"""
|
|
1153
|
+
# Validate before parsing: a bad threshold must not cost two full loads, and a
|
|
1154
|
+
# missing or unreadable file must not mask the threshold error.
|
|
1155
|
+
effective_threshold = _validate_threshold(threshold)
|
|
1156
|
+
loader = BinaryLoader()
|
|
1157
|
+
left_info = loader.load(left_path)
|
|
1158
|
+
right_info = loader.load(right_path)
|
|
1159
|
+
return diff_infos(
|
|
1160
|
+
left_info,
|
|
1161
|
+
right_info,
|
|
1162
|
+
discover_functions(left_info),
|
|
1163
|
+
discover_functions(right_info),
|
|
1164
|
+
max_functions=max_functions,
|
|
1165
|
+
deep=deep,
|
|
1166
|
+
semantic=semantic,
|
|
1167
|
+
threshold=effective_threshold,
|
|
1168
|
+
)
|