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,31 @@
|
|
|
1
|
+
"""CODEVANTA analysis core: loader, disassembler, strings, functions."""
|
|
2
|
+
|
|
3
|
+
from .disassembler import Disassembler, DisassemblyError, DisassemblyResult
|
|
4
|
+
from .functions import FunctionCandidate, discover_functions
|
|
5
|
+
from .loader import (
|
|
6
|
+
BinaryInfo,
|
|
7
|
+
BinaryLoader,
|
|
8
|
+
BinaryLoadError,
|
|
9
|
+
ExportInfo,
|
|
10
|
+
ImportInfo,
|
|
11
|
+
SectionInfo,
|
|
12
|
+
SymbolInfo,
|
|
13
|
+
)
|
|
14
|
+
from .strings import StringExtractor, StringResult
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BinaryInfo",
|
|
18
|
+
"BinaryLoader",
|
|
19
|
+
"BinaryLoadError",
|
|
20
|
+
"Disassembler",
|
|
21
|
+
"DisassemblyError",
|
|
22
|
+
"DisassemblyResult",
|
|
23
|
+
"ExportInfo",
|
|
24
|
+
"FunctionCandidate",
|
|
25
|
+
"ImportInfo",
|
|
26
|
+
"SectionInfo",
|
|
27
|
+
"StringExtractor",
|
|
28
|
+
"StringResult",
|
|
29
|
+
"SymbolInfo",
|
|
30
|
+
"discover_functions",
|
|
31
|
+
]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Capstone-backed disassembly for supported architectures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import capstone
|
|
9
|
+
|
|
10
|
+
from codevanta.evidence.confidence import Confidence
|
|
11
|
+
from codevanta.evidence.models import Evidence
|
|
12
|
+
from codevanta.ir.instruction import IRInstruction
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DisassemblyError(Exception):
|
|
16
|
+
"""Raised when disassembly cannot be performed."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class DisassemblyResult:
|
|
21
|
+
"""Outcome of a single disassembly request."""
|
|
22
|
+
|
|
23
|
+
instructions: list[IRInstruction]
|
|
24
|
+
base_address: int
|
|
25
|
+
consumed_bytes: int
|
|
26
|
+
evidence: Evidence
|
|
27
|
+
truncated: bool = False
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> dict[str, Any]:
|
|
30
|
+
return {
|
|
31
|
+
"base_address": f"0x{self.base_address:x}",
|
|
32
|
+
"consumed_bytes": self.consumed_bytes,
|
|
33
|
+
"truncated": self.truncated,
|
|
34
|
+
"instruction_count": len(self.instructions),
|
|
35
|
+
"instructions": [i.to_dict() for i in self.instructions],
|
|
36
|
+
"evidence": self.evidence.to_dict(),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_ARCH_MAP: dict[tuple[str, int], tuple[int, int]] = {
|
|
41
|
+
("x86", 32): (capstone.CS_ARCH_X86, capstone.CS_MODE_32),
|
|
42
|
+
("x86", 64): (capstone.CS_ARCH_X86, capstone.CS_MODE_64),
|
|
43
|
+
("x86_64", 64): (capstone.CS_ARCH_X86, capstone.CS_MODE_64),
|
|
44
|
+
("arm", 32): (capstone.CS_ARCH_ARM, capstone.CS_MODE_ARM),
|
|
45
|
+
("aarch64", 64): (capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM),
|
|
46
|
+
("arm64", 64): (capstone.CS_ARCH_ARM64, capstone.CS_MODE_ARM),
|
|
47
|
+
("mips", 32): (capstone.CS_ARCH_MIPS, capstone.CS_MODE_MIPS32),
|
|
48
|
+
("mips", 64): (capstone.CS_ARCH_MIPS, capstone.CS_MODE_MIPS64),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Disassembler:
|
|
53
|
+
"""Disassembles machine code with Capstone."""
|
|
54
|
+
|
|
55
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
56
|
+
MAX_INSTRUCTIONS = 1_000_000
|
|
57
|
+
|
|
58
|
+
def __init__(self, architecture: str, bitness: int, endianness: str = "little") -> None:
|
|
59
|
+
self.architecture = architecture
|
|
60
|
+
self.bitness = bitness
|
|
61
|
+
self.endianness = endianness
|
|
62
|
+
arch_mode = _ARCH_MAP.get((architecture.lower(), bitness))
|
|
63
|
+
if arch_mode is None:
|
|
64
|
+
raise DisassemblyError(f"Unsupported architecture: {architecture} ({bitness}-bit)")
|
|
65
|
+
cs_arch, cs_mode = arch_mode
|
|
66
|
+
if endianness.lower() == "big":
|
|
67
|
+
if cs_arch in (capstone.CS_ARCH_ARM, capstone.CS_ARCH_MIPS):
|
|
68
|
+
cs_mode |= capstone.CS_MODE_BIG_ENDIAN
|
|
69
|
+
elif cs_arch == capstone.CS_ARCH_X86:
|
|
70
|
+
raise DisassemblyError("x86 is little-endian only")
|
|
71
|
+
elif cs_arch == capstone.CS_ARCH_ARM64:
|
|
72
|
+
raise DisassemblyError("big-endian AArch64 is not supported")
|
|
73
|
+
self._cs = capstone.Cs(cs_arch, cs_mode)
|
|
74
|
+
self._cs.detail = False
|
|
75
|
+
|
|
76
|
+
def disassemble(
|
|
77
|
+
self,
|
|
78
|
+
data: bytes,
|
|
79
|
+
base_address: int,
|
|
80
|
+
max_instructions: int = 0,
|
|
81
|
+
max_bytes: int = 0,
|
|
82
|
+
) -> DisassemblyResult:
|
|
83
|
+
"""Decode ``data`` starting at ``base_address``."""
|
|
84
|
+
if not data:
|
|
85
|
+
raise DisassemblyError("No bytes supplied for disassembly")
|
|
86
|
+
if base_address < 0:
|
|
87
|
+
raise DisassemblyError("base_address must be non-negative")
|
|
88
|
+
byte_limit = min(len(data), max_bytes if max_bytes > 0 else self.MAX_BYTES)
|
|
89
|
+
instruction_limit = max_instructions if max_instructions > 0 else self.MAX_INSTRUCTIONS
|
|
90
|
+
window = data[:byte_limit]
|
|
91
|
+
instructions: list[IRInstruction] = []
|
|
92
|
+
consumed = 0
|
|
93
|
+
truncated = byte_limit < len(data)
|
|
94
|
+
for insn in self._cs.disasm(window, base_address):
|
|
95
|
+
instructions.append(
|
|
96
|
+
IRInstruction(
|
|
97
|
+
address=insn.address,
|
|
98
|
+
size=insn.size,
|
|
99
|
+
mnemonic=insn.mnemonic,
|
|
100
|
+
operands=insn.op_str,
|
|
101
|
+
raw_bytes=bytes(insn.bytes),
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
consumed += insn.size
|
|
105
|
+
if len(instructions) >= instruction_limit:
|
|
106
|
+
truncated = truncated or consumed < len(window)
|
|
107
|
+
break
|
|
108
|
+
evidence = Evidence(
|
|
109
|
+
source="capstone",
|
|
110
|
+
method="linear_disassembly",
|
|
111
|
+
confidence=Confidence.CERTAIN,
|
|
112
|
+
address=base_address,
|
|
113
|
+
detail=f"{len(instructions)} instruction(s) decoded",
|
|
114
|
+
)
|
|
115
|
+
return DisassemblyResult(
|
|
116
|
+
instructions=instructions,
|
|
117
|
+
base_address=base_address,
|
|
118
|
+
consumed_bytes=consumed,
|
|
119
|
+
evidence=evidence,
|
|
120
|
+
truncated=truncated,
|
|
121
|
+
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Per-section Shannon entropy for packing triage.
|
|
2
|
+
|
|
3
|
+
Entropy itself is a plain measurement over file bytes (confidence ``certain``);
|
|
4
|
+
the *packed/compressed* interpretation is a heuristic and is labelled as such
|
|
5
|
+
(``medium`` confidence, threshold stated in the evidence detail). Sections
|
|
6
|
+
without file bytes (e.g. ``S_ZEROFILL``) are reported with ``entropy=None``
|
|
7
|
+
rather than a fabricated value. Sampling is capped per section because entropy
|
|
8
|
+
converges quickly; the sample size actually used is part of every result.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from codevanta.core.loader import BinaryInfo
|
|
19
|
+
from codevanta.core.report import AnalysisError, read_code
|
|
20
|
+
from codevanta.evidence.confidence import Confidence
|
|
21
|
+
from codevanta.evidence.models import Evidence
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_MAX_SAMPLE_BYTES",
|
|
25
|
+
"DEFAULT_MIN_SAMPLE_BYTES",
|
|
26
|
+
"PACKED_HINT_THRESHOLD",
|
|
27
|
+
"EntropyReport",
|
|
28
|
+
"SectionEntropy",
|
|
29
|
+
"analyze_entropy",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
#: Entropy (bits/byte) at or above which a section is flagged as possibly
|
|
33
|
+
#: packed or compressed. 7.2 is a widely used rule of thumb, not a law.
|
|
34
|
+
PACKED_HINT_THRESHOLD = 7.2
|
|
35
|
+
|
|
36
|
+
#: Default cap on bytes read per section; entropy converges well before this.
|
|
37
|
+
DEFAULT_MAX_SAMPLE_BYTES = 65_536
|
|
38
|
+
|
|
39
|
+
#: Below this sample size the entropy is still reported but too noisy to mean
|
|
40
|
+
#: much, so the packed hint is suppressed and a caveat is attached instead.
|
|
41
|
+
DEFAULT_MIN_SAMPLE_BYTES = 256
|
|
42
|
+
|
|
43
|
+
#: Hard ceiling accepted for ``max_sample_bytes`` (bounded-resource rule).
|
|
44
|
+
MAX_SAMPLE_LIMIT = 4 * 1024 * 1024
|
|
45
|
+
|
|
46
|
+
_MAX_ENTROPY = 8.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class SectionEntropy:
|
|
51
|
+
"""Entropy of one section, sampled from its file bytes."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
virtual_address: int
|
|
55
|
+
size: int
|
|
56
|
+
entropy: float | None
|
|
57
|
+
sampled_bytes: int
|
|
58
|
+
is_executable: bool
|
|
59
|
+
packed_hint: bool
|
|
60
|
+
evidence: Evidence
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
data: dict[str, Any] = {
|
|
64
|
+
"name": self.name,
|
|
65
|
+
"virtual_address": f"0x{self.virtual_address:x}",
|
|
66
|
+
"size": self.size,
|
|
67
|
+
"entropy": round(self.entropy, 4) if self.entropy is not None else None,
|
|
68
|
+
"sampled_bytes": self.sampled_bytes,
|
|
69
|
+
"executable": self.is_executable,
|
|
70
|
+
"packed_hint": self.packed_hint,
|
|
71
|
+
"evidence": self.evidence.to_dict(),
|
|
72
|
+
}
|
|
73
|
+
return data
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class EntropyReport:
|
|
78
|
+
"""Entropy triage across every section of one binary."""
|
|
79
|
+
|
|
80
|
+
sections: list[SectionEntropy]
|
|
81
|
+
threshold: float
|
|
82
|
+
warnings: tuple[str, ...]
|
|
83
|
+
evidence: Evidence
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def packed_candidates(self) -> list[SectionEntropy]:
|
|
87
|
+
"""Sections whose entropy reached the packed-compression threshold."""
|
|
88
|
+
return [section for section in self.sections if section.packed_hint]
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
packed = self.packed_candidates
|
|
92
|
+
return {
|
|
93
|
+
"section_count": len(self.sections),
|
|
94
|
+
"threshold": self.threshold,
|
|
95
|
+
"packed_candidate_count": len(packed),
|
|
96
|
+
"packed_candidates": [section.name for section in packed],
|
|
97
|
+
"sections": [section.to_dict() for section in self.sections],
|
|
98
|
+
"warnings": list(self.warnings),
|
|
99
|
+
"evidence": self.evidence.to_dict(),
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def shannon_entropy(data: bytes) -> float:
|
|
104
|
+
"""Return the Shannon entropy of ``data`` in bits per byte (0..8)."""
|
|
105
|
+
if not data:
|
|
106
|
+
return 0.0
|
|
107
|
+
counts = [0] * 256
|
|
108
|
+
for byte in data:
|
|
109
|
+
counts[byte] += 1
|
|
110
|
+
total = len(data)
|
|
111
|
+
entropy = 0.0
|
|
112
|
+
for count in counts:
|
|
113
|
+
if count:
|
|
114
|
+
probability = count / total
|
|
115
|
+
entropy -= probability * math.log2(probability)
|
|
116
|
+
return min(entropy, _MAX_ENTROPY)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def analyze_entropy(
|
|
120
|
+
path: str | Path,
|
|
121
|
+
info: BinaryInfo,
|
|
122
|
+
*,
|
|
123
|
+
threshold: float = PACKED_HINT_THRESHOLD,
|
|
124
|
+
max_sample_bytes: int = DEFAULT_MAX_SAMPLE_BYTES,
|
|
125
|
+
min_sample_bytes: int = DEFAULT_MIN_SAMPLE_BYTES,
|
|
126
|
+
) -> EntropyReport:
|
|
127
|
+
"""Compute per-section entropy and flag possible packing.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
path: File the metadata in ``info`` was loaded from (read-only).
|
|
131
|
+
info: Normalized binary metadata.
|
|
132
|
+
threshold: Entropy at or above which ``packed_hint`` is set (0 < t <= 8).
|
|
133
|
+
max_sample_bytes: Bytes read per section at most (1 .. 4 MiB).
|
|
134
|
+
min_sample_bytes: Sample size below which the packed hint is suppressed.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
An :class:`EntropyReport` with one entry per section, sorted by
|
|
138
|
+
entropy (descending), sections without file bytes carrying ``None``.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
AnalysisError: If any limit is out of range.
|
|
142
|
+
"""
|
|
143
|
+
if not 0.0 < threshold <= _MAX_ENTROPY:
|
|
144
|
+
raise AnalysisError(f"threshold must be in (0, {_MAX_ENTROPY}]")
|
|
145
|
+
if max_sample_bytes < 1:
|
|
146
|
+
raise AnalysisError("max_sample_bytes must be >= 1")
|
|
147
|
+
if max_sample_bytes > MAX_SAMPLE_LIMIT:
|
|
148
|
+
raise AnalysisError(f"max_sample_bytes too large (limit {MAX_SAMPLE_LIMIT})")
|
|
149
|
+
if min_sample_bytes < 1:
|
|
150
|
+
raise AnalysisError("min_sample_bytes must be >= 1")
|
|
151
|
+
|
|
152
|
+
warnings: list[str] = []
|
|
153
|
+
results: list[SectionEntropy] = []
|
|
154
|
+
for section in info.sections:
|
|
155
|
+
entropy: float | None = None
|
|
156
|
+
sampled = 0
|
|
157
|
+
# File-backed means: PE/Mach-O raw bytes, or an ELF section with a real
|
|
158
|
+
# file offset (ELF loader metadata carries raw_size=0 by design).
|
|
159
|
+
if section.raw_size > 0 or section.offset > 0:
|
|
160
|
+
try:
|
|
161
|
+
data = read_code(path, info, section.virtual_address, max_sample_bytes)
|
|
162
|
+
except (AnalysisError, OSError) as exc:
|
|
163
|
+
warnings.append(
|
|
164
|
+
f"section {section.name!r}: cannot read bytes ({exc}); entropy unknown"
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
sampled = len(data)
|
|
168
|
+
if sampled:
|
|
169
|
+
entropy = shannon_entropy(data)
|
|
170
|
+
else:
|
|
171
|
+
warnings.append(
|
|
172
|
+
f"section {section.name!r}: no file bytes; entropy unknown"
|
|
173
|
+
)
|
|
174
|
+
else:
|
|
175
|
+
warnings.append(
|
|
176
|
+
f"section {section.name!r}: not file-backed (size {section.size}); "
|
|
177
|
+
"entropy unknown"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
packed_hint = entropy is not None and entropy >= threshold
|
|
181
|
+
detail_suffix = ""
|
|
182
|
+
if entropy is None:
|
|
183
|
+
detail = "entropy not computable: no file bytes"
|
|
184
|
+
confidence = Confidence.LOW
|
|
185
|
+
else:
|
|
186
|
+
confidence = Confidence.CERTAIN
|
|
187
|
+
if sampled < min_sample_bytes:
|
|
188
|
+
packed_hint = False
|
|
189
|
+
detail_suffix = "; sample too small for a packing judgment"
|
|
190
|
+
detail = (
|
|
191
|
+
f"entropy {entropy:.3f} bits/byte from {sampled} sampled byte(s)"
|
|
192
|
+
+ detail_suffix
|
|
193
|
+
)
|
|
194
|
+
else:
|
|
195
|
+
detail = (
|
|
196
|
+
f"entropy {entropy:.3f} bits/byte from {sampled} sampled byte(s); "
|
|
197
|
+
f"packed hint at >={threshold} is a heuristic, not proof"
|
|
198
|
+
if packed_hint
|
|
199
|
+
else f"entropy {entropy:.3f} bits/byte from {sampled} sampled byte(s)"
|
|
200
|
+
)
|
|
201
|
+
results.append(
|
|
202
|
+
SectionEntropy(
|
|
203
|
+
name=section.name,
|
|
204
|
+
virtual_address=section.virtual_address,
|
|
205
|
+
size=section.size,
|
|
206
|
+
entropy=entropy,
|
|
207
|
+
sampled_bytes=sampled,
|
|
208
|
+
is_executable=section.is_executable,
|
|
209
|
+
packed_hint=packed_hint,
|
|
210
|
+
evidence=Evidence(
|
|
211
|
+
source="entropy.section",
|
|
212
|
+
method="shannon_entropy",
|
|
213
|
+
confidence=confidence,
|
|
214
|
+
address=section.virtual_address,
|
|
215
|
+
detail=detail,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
results.sort(key=lambda item: (-1 if item.entropy is None else -item.entropy, item.name))
|
|
221
|
+
packed_names = [item.name for item in results if item.packed_hint]
|
|
222
|
+
report_evidence = Evidence(
|
|
223
|
+
source="entropy.triage",
|
|
224
|
+
method="per_section_shannon_entropy",
|
|
225
|
+
confidence=Confidence.CERTAIN,
|
|
226
|
+
detail=(
|
|
227
|
+
f"{len(results)} section(s) measured; threshold {threshold}; "
|
|
228
|
+
f"{len(packed_names)} packed candidate(s)"
|
|
229
|
+
+ (f" ({', '.join(packed_names)})" if packed_names else "")
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
return EntropyReport(
|
|
233
|
+
sections=results,
|
|
234
|
+
threshold=threshold,
|
|
235
|
+
warnings=tuple(warnings),
|
|
236
|
+
evidence=report_evidence,
|
|
237
|
+
)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Function candidate discovery with explicit provenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from codevanta.evidence.confidence import Confidence
|
|
9
|
+
from codevanta.evidence.models import Evidence
|
|
10
|
+
|
|
11
|
+
from .loader import BinaryInfo
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class FunctionCandidate:
|
|
16
|
+
"""A function candidate and the evidence that identified it."""
|
|
17
|
+
|
|
18
|
+
address: int
|
|
19
|
+
name: str | None
|
|
20
|
+
size: int | None
|
|
21
|
+
source: str
|
|
22
|
+
confidence: Confidence
|
|
23
|
+
evidence: Evidence
|
|
24
|
+
aliases: tuple[str, ...] = field(default_factory=tuple)
|
|
25
|
+
|
|
26
|
+
def to_dict(self) -> dict[str, Any]:
|
|
27
|
+
data: dict[str, Any] = {
|
|
28
|
+
"address": f"0x{self.address:x}",
|
|
29
|
+
"source": self.source,
|
|
30
|
+
"confidence": self.confidence.value,
|
|
31
|
+
"evidence": self.evidence.to_dict(),
|
|
32
|
+
}
|
|
33
|
+
if self.name:
|
|
34
|
+
data["name"] = self.name
|
|
35
|
+
if self.size is not None:
|
|
36
|
+
data["size"] = self.size
|
|
37
|
+
if self.aliases:
|
|
38
|
+
data["aliases"] = list(self.aliases)
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class _Draft:
|
|
44
|
+
address: int
|
|
45
|
+
name: str | None
|
|
46
|
+
size: int | None
|
|
47
|
+
source: str
|
|
48
|
+
confidence: Confidence
|
|
49
|
+
evidence: Evidence
|
|
50
|
+
aliases: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: Evidence source for exported functions, by binary format.
|
|
54
|
+
_EXPORT_SOURCES: dict[str, str] = {
|
|
55
|
+
"PE": "pe.export_table",
|
|
56
|
+
"ELF": "elf.dynsym",
|
|
57
|
+
"MachO": "macho.exported_symbols",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#: Evidence source for symbol-table functions, by binary format.
|
|
61
|
+
_SYMBOL_SOURCES: dict[str, str] = {
|
|
62
|
+
"PE": "pe.coff_symbols",
|
|
63
|
+
"ELF": "elf.symtab",
|
|
64
|
+
"MachO": "macho.symbol_table",
|
|
65
|
+
"DEX": "dex.method_ids",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#: Discovery confidence for symbol-table candidates, by binary format. Mach-O
|
|
69
|
+
#: symbol entries do not encode kind, so function-ness is inferred from
|
|
70
|
+
#: executable-section membership and deserves slightly lower confidence.
|
|
71
|
+
_SYMBOL_CONFIDENCE: dict[str, Confidence] = {
|
|
72
|
+
"PE": Confidence.HIGH,
|
|
73
|
+
"ELF": Confidence.HIGH,
|
|
74
|
+
"MachO": Confidence.MEDIUM,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def discover_functions(info: BinaryInfo) -> list[FunctionCandidate]:
|
|
79
|
+
"""Discover function candidates from structural evidence only.
|
|
80
|
+
|
|
81
|
+
Phase 1 reports exported functions, function symbols, and the entry point.
|
|
82
|
+
Speculative heuristics are intentionally excluded; every candidate states
|
|
83
|
+
where it came from and how much confidence it deserves.
|
|
84
|
+
"""
|
|
85
|
+
drafts: dict[int, _Draft] = {}
|
|
86
|
+
|
|
87
|
+
def add(
|
|
88
|
+
address: int | None,
|
|
89
|
+
name: str | None,
|
|
90
|
+
size: int | None,
|
|
91
|
+
source: str,
|
|
92
|
+
confidence: Confidence,
|
|
93
|
+
method: str,
|
|
94
|
+
detail: str,
|
|
95
|
+
) -> None:
|
|
96
|
+
if not address or address <= 0:
|
|
97
|
+
return
|
|
98
|
+
clean_name = name or None
|
|
99
|
+
evidence = Evidence(
|
|
100
|
+
source=source,
|
|
101
|
+
method=method,
|
|
102
|
+
confidence=confidence,
|
|
103
|
+
address=address,
|
|
104
|
+
detail=detail,
|
|
105
|
+
)
|
|
106
|
+
existing = drafts.get(address)
|
|
107
|
+
if existing is None:
|
|
108
|
+
drafts[address] = _Draft(
|
|
109
|
+
address=address,
|
|
110
|
+
name=clean_name,
|
|
111
|
+
size=size,
|
|
112
|
+
source=source,
|
|
113
|
+
confidence=confidence,
|
|
114
|
+
evidence=evidence,
|
|
115
|
+
)
|
|
116
|
+
return
|
|
117
|
+
if confidence.score > existing.confidence.score:
|
|
118
|
+
if existing.name and existing.name != clean_name:
|
|
119
|
+
existing.aliases.append(existing.name)
|
|
120
|
+
existing.name = clean_name
|
|
121
|
+
existing.size = size if size is not None else existing.size
|
|
122
|
+
existing.source = source
|
|
123
|
+
existing.confidence = confidence
|
|
124
|
+
existing.evidence = evidence
|
|
125
|
+
elif clean_name and clean_name != existing.name and clean_name not in existing.aliases:
|
|
126
|
+
existing.aliases.append(clean_name)
|
|
127
|
+
|
|
128
|
+
for export in info.exports:
|
|
129
|
+
add(
|
|
130
|
+
export.address,
|
|
131
|
+
export.name,
|
|
132
|
+
None,
|
|
133
|
+
_EXPORT_SOURCES.get(info.format, "binary.exports"),
|
|
134
|
+
Confidence.HIGH,
|
|
135
|
+
"exported_function",
|
|
136
|
+
"exported function entry",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for symbol in info.symbols:
|
|
140
|
+
if not symbol.is_function:
|
|
141
|
+
continue
|
|
142
|
+
add(
|
|
143
|
+
symbol.address,
|
|
144
|
+
symbol.name,
|
|
145
|
+
symbol.size or None,
|
|
146
|
+
_SYMBOL_SOURCES.get(info.format, "binary.symbol_table"),
|
|
147
|
+
_SYMBOL_CONFIDENCE.get(info.format, Confidence.HIGH),
|
|
148
|
+
"function_symbol",
|
|
149
|
+
"function symbol from the symbol table",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
if info.entry_point:
|
|
153
|
+
add(
|
|
154
|
+
info.entry_point,
|
|
155
|
+
None,
|
|
156
|
+
None,
|
|
157
|
+
"binary.entry_point",
|
|
158
|
+
Confidence.MEDIUM,
|
|
159
|
+
"entry_point",
|
|
160
|
+
"program entry point; a code candidate, not a proven function boundary",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
candidates = [
|
|
164
|
+
FunctionCandidate(
|
|
165
|
+
address=draft.address,
|
|
166
|
+
name=draft.name,
|
|
167
|
+
size=draft.size,
|
|
168
|
+
source=draft.source,
|
|
169
|
+
confidence=draft.confidence,
|
|
170
|
+
evidence=draft.evidence,
|
|
171
|
+
aliases=tuple(dict.fromkeys(draft.aliases)),
|
|
172
|
+
)
|
|
173
|
+
for draft in drafts.values()
|
|
174
|
+
]
|
|
175
|
+
candidates.sort(key=lambda c: c.address)
|
|
176
|
+
return candidates
|