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,289 @@
|
|
|
1
|
+
"""Run a generated gdb session and return its parsed trace report.
|
|
2
|
+
|
|
3
|
+
:mod:`codevanta.analysis.dynamic` writes the session script and
|
|
4
|
+
:mod:`codevanta.analysis.trace` reads a saved log; this module is the narrow
|
|
5
|
+
bridge between them: it *actually runs* the generated script through gdb and
|
|
6
|
+
hands the captured stdout to :func:`codevanta.analysis.trace.parse_gdb_trace`.
|
|
7
|
+
On a machine without gdb -- a Windows workstation, a slim CI container --
|
|
8
|
+
nothing here could run against a real process, so the module is built to work
|
|
9
|
+
without one:
|
|
10
|
+
|
|
11
|
+
* :func:`gdb_available` probes ``PATH`` with ``shutil.which`` and nothing
|
|
12
|
+
else. It never imports ``frida`` and never executes gdb, so the probe cannot
|
|
13
|
+
fail.
|
|
14
|
+
* :func:`run_breakpoints` takes an injectable ``runner`` callable that stands
|
|
15
|
+
in for ``subprocess.run``. A test (or an embedding host) can supply any
|
|
16
|
+
callable with the same contract and drive the full
|
|
17
|
+
build -> write -> run -> parse -> cleanup pipeline against canned output,
|
|
18
|
+
byte for byte as gdb would have printed it.
|
|
19
|
+
|
|
20
|
+
The injection contract
|
|
21
|
+
----------------------
|
|
22
|
+
``runner`` is called as ``runner(command, **kwargs)``: ``command`` is the exact
|
|
23
|
+
``argv`` tuple (``("gdb", "-q", "-batch", "-x", <script>, "--args", <target>)``)
|
|
24
|
+
and ``kwargs`` carry ``capture_output=True``, ``text=True`` and
|
|
25
|
+
``timeout=<seconds>``. It must return an object exposing ``.returncode`` (an
|
|
26
|
+
``int``), ``.stdout`` and ``.stderr`` -- the ``subprocess.CompletedProcess``
|
|
27
|
+
that ``subprocess.run(..., capture_output=True, text=True)`` returns satisfies
|
|
28
|
+
this. ``runner=None`` (the default) means ``subprocess.run`` itself.
|
|
29
|
+
|
|
30
|
+
Every failure names what is missing or wrong
|
|
31
|
+
--------------------------------------------
|
|
32
|
+
A missing gdb executable, a session that exceeds ``timeout``, an empty or
|
|
33
|
+
non-``int`` address list, an out-of-range ``max_hits`` -- all raise
|
|
34
|
+
:class:`~codevanta.core.report.AnalysisError` naming the offending input, and
|
|
35
|
+
none of them leaves a script file behind. A non-zero gdb exit code is *not*
|
|
36
|
+
an error: the output is still parsed and the condition is reported as a
|
|
37
|
+
warning on the :class:`GdbRunResult` (the parser's own warnings live on
|
|
38
|
+
``report.warnings``). The script file is always removed once the run ends,
|
|
39
|
+
whether it succeeded, failed or raised; when ``script_dir`` is supplied, only
|
|
40
|
+
the file this module wrote is removed -- the directory itself and its other
|
|
41
|
+
contents are left alone.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import shutil
|
|
48
|
+
import subprocess
|
|
49
|
+
import tempfile
|
|
50
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Any, Protocol, TypeAlias
|
|
54
|
+
|
|
55
|
+
from codevanta.analysis.dynamic import DEFAULT_MAX_HITS, build_gdb_script, script_file_name
|
|
56
|
+
from codevanta.analysis.trace import TraceReport, parse_gdb_trace
|
|
57
|
+
from codevanta.core.functions import discover_functions
|
|
58
|
+
from codevanta.core.loader import BinaryInfo
|
|
59
|
+
from codevanta.core.report import AnalysisError
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"DEFAULT_TIMEOUT",
|
|
63
|
+
"GdbRunResult",
|
|
64
|
+
"Runner",
|
|
65
|
+
"RunnerOutcome",
|
|
66
|
+
"gdb_available",
|
|
67
|
+
"run_breakpoints",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
#: Default wall-clock budget for one gdb session, in seconds.
|
|
71
|
+
DEFAULT_TIMEOUT = 60.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class RunnerOutcome(Protocol):
|
|
75
|
+
"""What a runner must return: ``subprocess.CompletedProcess``-shaped.
|
|
76
|
+
|
|
77
|
+
Attributes:
|
|
78
|
+
returncode: The gdb process exit code (``0`` on success).
|
|
79
|
+
stdout: Everything gdb printed, decoded to text.
|
|
80
|
+
stderr: Everything gdb wrote to its error stream, decoded to text.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
returncode: int
|
|
84
|
+
stdout: str
|
|
85
|
+
stderr: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: A callable standing in for ``subprocess.run``; see :func:`run_breakpoints`.
|
|
89
|
+
Runner: TypeAlias = Callable[..., RunnerOutcome]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _default_runner(command: Sequence[str], **kwargs: Any) -> RunnerOutcome:
|
|
93
|
+
"""Run ``command`` through :func:`subprocess.run`; the production runner."""
|
|
94
|
+
return subprocess.run(command, **kwargs)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def gdb_available() -> bool:
|
|
98
|
+
"""Return ``True`` when the ``gdb`` executable can be found on ``PATH``.
|
|
99
|
+
|
|
100
|
+
The probe is ``shutil.which("gdb")`` and nothing else: no provider package
|
|
101
|
+
is imported (Frida in particular is never touched) and no process is
|
|
102
|
+
started, so asking cannot fail and cannot have side effects.
|
|
103
|
+
"""
|
|
104
|
+
return shutil.which("gdb") is not None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class GdbRunResult:
|
|
109
|
+
"""Everything one gdb session produced, plus how it was run.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
report: The parsed trace: hits, backtraces, function attribution and
|
|
113
|
+
the parser's own warnings.
|
|
114
|
+
exit_code: The exit code gdb itself returned. Non-zero is reported as
|
|
115
|
+
a warning, not an error: the output was still parsed.
|
|
116
|
+
script_path: Path of the session script that was executed. The file is
|
|
117
|
+
removed before this result is returned, so the path is a record,
|
|
118
|
+
not a handle.
|
|
119
|
+
command: The exact ``argv`` tuple that was (or would have been)
|
|
120
|
+
executed.
|
|
121
|
+
raw_output_path: Path where the raw gdb output was persisted, or
|
|
122
|
+
``None``. The default run parses stdout in memory and persists
|
|
123
|
+
nothing.
|
|
124
|
+
warnings: Run-level warnings: a non-zero gdb exit code, a script file
|
|
125
|
+
that could not be removed. Parse-level warnings are on
|
|
126
|
+
``report.warnings``.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
report: TraceReport
|
|
131
|
+
exit_code: int
|
|
132
|
+
script_path: str
|
|
133
|
+
command: tuple[str, ...]
|
|
134
|
+
raw_output_path: str | None
|
|
135
|
+
warnings: tuple[str, ...] = ()
|
|
136
|
+
|
|
137
|
+
def to_dict(self) -> dict[str, Any]:
|
|
138
|
+
"""Serialize the run for the JSON report (JSON-safe values only)."""
|
|
139
|
+
return {
|
|
140
|
+
"report": self.report.to_dict(),
|
|
141
|
+
"exit_code": self.exit_code,
|
|
142
|
+
"script_path": self.script_path,
|
|
143
|
+
"command": list(self.command),
|
|
144
|
+
"raw_output_path": self.raw_output_path,
|
|
145
|
+
"warnings": list(self.warnings),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _require_timeout(timeout: float) -> float:
|
|
150
|
+
"""Return ``timeout`` after checking it is a positive number of seconds."""
|
|
151
|
+
if isinstance(timeout, bool) or not isinstance(timeout, int | float):
|
|
152
|
+
raise AnalysisError(
|
|
153
|
+
f"timeout must be a number of seconds, got {type(timeout).__name__}: {timeout!r}"
|
|
154
|
+
)
|
|
155
|
+
if timeout <= 0:
|
|
156
|
+
raise AnalysisError(f"timeout must be positive, got {timeout}")
|
|
157
|
+
return float(timeout)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _as_text(value: Any) -> str:
|
|
161
|
+
"""Return ``value`` as text, tolerating ``None`` and bytes."""
|
|
162
|
+
if value is None:
|
|
163
|
+
return ""
|
|
164
|
+
if isinstance(value, bytes):
|
|
165
|
+
return value.decode("utf-8", "replace")
|
|
166
|
+
return str(value)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _outcome_parts(outcome: RunnerOutcome) -> tuple[int, str]:
|
|
170
|
+
"""Return ``(exit_code, stdout_text)`` from a runner outcome.
|
|
171
|
+
|
|
172
|
+
Raises:
|
|
173
|
+
AnalysisError: the outcome's ``returncode`` is not an ``int`` -- a
|
|
174
|
+
runner that does not honour the injection contract.
|
|
175
|
+
"""
|
|
176
|
+
returncode = outcome.returncode
|
|
177
|
+
if isinstance(returncode, bool) or not isinstance(returncode, int):
|
|
178
|
+
raise AnalysisError(
|
|
179
|
+
f"runner returned a non-int returncode, got {type(returncode).__name__}: "
|
|
180
|
+
f"{returncode!r}"
|
|
181
|
+
)
|
|
182
|
+
return returncode, _as_text(outcome.stdout)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def run_breakpoints(
|
|
186
|
+
path: str | Path,
|
|
187
|
+
info: BinaryInfo,
|
|
188
|
+
addresses: Iterable[int],
|
|
189
|
+
*,
|
|
190
|
+
max_hits: int = DEFAULT_MAX_HITS,
|
|
191
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
192
|
+
runner: Runner | None = None,
|
|
193
|
+
script_dir: Path | None = None,
|
|
194
|
+
) -> GdbRunResult:
|
|
195
|
+
"""Build the gdb script, run it, and return the parsed trace report.
|
|
196
|
+
|
|
197
|
+
The script is generated with
|
|
198
|
+
:func:`codevanta.analysis.dynamic.build_gdb_script` (which validates every
|
|
199
|
+
input and raises :class:`~codevanta.core.report.AnalysisError` on an empty
|
|
200
|
+
or non-``int`` address list, an address outside an executable section, or
|
|
201
|
+
an out-of-range ``max_hits``), written to ``script_dir`` or to a private
|
|
202
|
+
temporary directory, executed as
|
|
203
|
+
``("gdb", "-q", "-batch", "-x", <script>, "--args", str(path))`` through
|
|
204
|
+
``runner`` (``subprocess.run`` by default), and its captured stdout is
|
|
205
|
+
parsed with :func:`codevanta.analysis.trace.parse_gdb_trace`, attributing
|
|
206
|
+
every hit to a candidate from
|
|
207
|
+
:func:`codevanta.core.functions.discover_functions`.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
path: Target binary, spelled the way gdb should see it.
|
|
211
|
+
info: Binary metadata for the script's registers and section checks,
|
|
212
|
+
and for function attribution in the parsed report.
|
|
213
|
+
addresses: Breakpoint addresses, in file virtual-address space.
|
|
214
|
+
max_hits: Hit ceiling the *script* enforces; the parser keeps
|
|
215
|
+
everything the session printed.
|
|
216
|
+
timeout: Wall-clock budget for the session, in seconds. Exceeding it
|
|
217
|
+
raises :class:`~codevanta.core.report.AnalysisError`.
|
|
218
|
+
runner: Injectable replacement for ``subprocess.run``; see the module
|
|
219
|
+
docstring for the exact contract. ``None`` means
|
|
220
|
+
``subprocess.run``.
|
|
221
|
+
script_dir: Directory to write the session script into, instead of a
|
|
222
|
+
private temporary directory. The script file is removed after the
|
|
223
|
+
run either way; the directory itself and its other contents are
|
|
224
|
+
left alone.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
AnalysisError: A missing gdb executable, a session that exceeded
|
|
228
|
+
``timeout``, any script-validation failure (bad addresses, bad
|
|
229
|
+
``max_hits``), or a runner whose outcome violates the injection
|
|
230
|
+
contract -- each message names what is missing or wrong.
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
The parsed :class:`~codevanta.analysis.trace.TraceReport` wrapped in a
|
|
234
|
+
:class:`GdbRunResult` with the exit code, the executed ``argv`` and
|
|
235
|
+
any run-level warnings.
|
|
236
|
+
"""
|
|
237
|
+
_require_timeout(timeout)
|
|
238
|
+
script = build_gdb_script(path, info, addresses, max_hits=max_hits)
|
|
239
|
+
execute = runner if runner is not None else _default_runner
|
|
240
|
+
warnings: list[str] = []
|
|
241
|
+
temp_dir: tempfile.TemporaryDirectory[str] | None = None
|
|
242
|
+
script_path: Path | None = None
|
|
243
|
+
try:
|
|
244
|
+
if script_dir is None:
|
|
245
|
+
temp_dir = tempfile.TemporaryDirectory(prefix="codevanta-gdb-")
|
|
246
|
+
directory = Path(temp_dir.name)
|
|
247
|
+
else:
|
|
248
|
+
directory = Path(script_dir)
|
|
249
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
script_path = directory / script_file_name("gdb")
|
|
251
|
+
script_path.write_text(script.text, encoding="utf-8", newline="\n")
|
|
252
|
+
command = ("gdb", "-q", "-batch", "-x", str(script_path), "--args", str(path))
|
|
253
|
+
try:
|
|
254
|
+
outcome = execute(command, capture_output=True, text=True, timeout=timeout)
|
|
255
|
+
except subprocess.TimeoutExpired as exc:
|
|
256
|
+
raise AnalysisError(
|
|
257
|
+
f"gdb session exceeded its timeout of {timeout} seconds and was terminated; "
|
|
258
|
+
"raise timeout or reduce the breakpoint workload"
|
|
259
|
+
) from exc
|
|
260
|
+
except FileNotFoundError as exc:
|
|
261
|
+
raise AnalysisError(
|
|
262
|
+
"gdb was not found: the runner raised FileNotFoundError, so the gdb "
|
|
263
|
+
"executable is missing; install gdb and make sure it is on PATH "
|
|
264
|
+
"(gdb_available() reports whether it is)"
|
|
265
|
+
) from exc
|
|
266
|
+
exit_code, stdout = _outcome_parts(outcome)
|
|
267
|
+
if exit_code != 0:
|
|
268
|
+
warnings.append(
|
|
269
|
+
f"gdb exited with non-zero code {exit_code}; its output was still parsed"
|
|
270
|
+
)
|
|
271
|
+
finally:
|
|
272
|
+
if temp_dir is not None:
|
|
273
|
+
temp_dir.cleanup()
|
|
274
|
+
elif script_path is not None:
|
|
275
|
+
try:
|
|
276
|
+
script_path.unlink()
|
|
277
|
+
except OSError as exc:
|
|
278
|
+
warnings.append(f"the session script could not be removed: {exc}")
|
|
279
|
+
|
|
280
|
+
report = parse_gdb_trace(stdout, info=info, functions=discover_functions(info))
|
|
281
|
+
return GdbRunResult(
|
|
282
|
+
report=report,
|
|
283
|
+
exit_code=exit_code,
|
|
284
|
+
script_path=str(script_path),
|
|
285
|
+
command=command,
|
|
286
|
+
raw_output_path=None,
|
|
287
|
+
warnings=tuple(warnings),
|
|
288
|
+
)
|
|
289
|
+
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""DWARF debug-info extraction via the optional pyelftools backend.
|
|
2
|
+
|
|
3
|
+
Producer debug info is the strongest symbol evidence available for a build
|
|
4
|
+
that kept it: real function names, byte-exact extents, and data-type layouts
|
|
5
|
+
straight from the compiler. This module reads ``.debug_info`` through
|
|
6
|
+
:mod:`elftools` (pyelftools), an **optional** dependency:
|
|
7
|
+
|
|
8
|
+
pip install -e ".[dwarf]" # or ".[dev]" for CI
|
|
9
|
+
|
|
10
|
+
When the extra is missing, :func:`extract_debug_info` raises
|
|
11
|
+
:class:`~codevanta.core.report.AnalysisError` naming the extra — the same
|
|
12
|
+
graceful-refusal contract as the decompiler providers (#32).
|
|
13
|
+
|
|
14
|
+
Scope (stated honestly):
|
|
15
|
+
* ELF only. pyelftools reads the ELF container; PE/Mach-O are rejected with a
|
|
16
|
+
typed message rather than half-parsed.
|
|
17
|
+
* DWARF v2-5 via pyelftools; ``.debug_info`` DIEs only. No line tables, no
|
|
18
|
+
inline chains, no expression evaluation.
|
|
19
|
+
* PDB (PE) is out of scope here — the LIEF OSS build gates its PDB parser
|
|
20
|
+
behind the Extended distribution, so there is no OSS backend to wrap yet.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from codevanta.core.loader import BinaryInfo
|
|
30
|
+
from codevanta.core.report import AnalysisError
|
|
31
|
+
from codevanta.evidence.confidence import Confidence
|
|
32
|
+
from codevanta.evidence.models import Evidence
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DEFAULT_LIMIT",
|
|
36
|
+
"MAX_LIMIT",
|
|
37
|
+
"DebugFunction",
|
|
38
|
+
"DebugType",
|
|
39
|
+
"DebugInfoResult",
|
|
40
|
+
"extract_debug_info",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
#: Default cap on returned debug functions (mirrors ``functions`` defaults).
|
|
44
|
+
DEFAULT_LIMIT = 500
|
|
45
|
+
|
|
46
|
+
#: Hard ceiling accepted for ``limit`` (bounded-resource rule).
|
|
47
|
+
MAX_LIMIT = 100_000
|
|
48
|
+
|
|
49
|
+
#: DIE tags mapped to a short kind label for type entries.
|
|
50
|
+
_TYPE_KINDS = {
|
|
51
|
+
"DW_TAG_structure_type": "struct",
|
|
52
|
+
"DW_TAG_union_type": "union",
|
|
53
|
+
"DW_TAG_enumeration_type": "enum",
|
|
54
|
+
"DW_TAG_class_type": "class",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class DebugFunction:
|
|
60
|
+
"""One DWARF subprogram: producer-grade name and byte-exact extent."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
address: int
|
|
64
|
+
size: int
|
|
65
|
+
linkage_name: str | None
|
|
66
|
+
is_external: bool
|
|
67
|
+
evidence: Evidence
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
data: dict[str, Any] = {
|
|
71
|
+
"name": self.name,
|
|
72
|
+
"address": f"0x{self.address:x}",
|
|
73
|
+
"size": self.size,
|
|
74
|
+
"is_external": self.is_external,
|
|
75
|
+
"evidence": self.evidence.to_dict(),
|
|
76
|
+
}
|
|
77
|
+
if self.linkage_name is not None:
|
|
78
|
+
data["linkage_name"] = self.linkage_name
|
|
79
|
+
return data
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class DebugType:
|
|
84
|
+
"""One named data-type layout from the producer (struct/union/enum/class)."""
|
|
85
|
+
|
|
86
|
+
name: str
|
|
87
|
+
kind: str
|
|
88
|
+
byte_size: int
|
|
89
|
+
evidence: Evidence
|
|
90
|
+
|
|
91
|
+
def to_dict(self) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"name": self.name,
|
|
94
|
+
"kind": self.kind,
|
|
95
|
+
"byte_size": self.byte_size,
|
|
96
|
+
"evidence": self.evidence.to_dict(),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class DebugInfoResult:
|
|
102
|
+
"""Everything ``.debug_info`` yielded, with counts and truncation flags."""
|
|
103
|
+
|
|
104
|
+
functions: list[DebugFunction]
|
|
105
|
+
types: list[DebugType]
|
|
106
|
+
truncated_functions: bool
|
|
107
|
+
truncated_types: bool
|
|
108
|
+
evidence: Evidence
|
|
109
|
+
warnings: tuple[str, ...] = ()
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"function_count": len(self.functions),
|
|
114
|
+
"type_count": len(self.types),
|
|
115
|
+
"truncated_functions": self.truncated_functions,
|
|
116
|
+
"truncated_types": self.truncated_types,
|
|
117
|
+
"functions": [f.to_dict() for f in self.functions],
|
|
118
|
+
"types": [t.to_dict() for t in self.types],
|
|
119
|
+
"warnings": list(self.warnings),
|
|
120
|
+
"evidence": self.evidence.to_dict(),
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _require_elf(info: BinaryInfo) -> None:
|
|
125
|
+
if info.format != "ELF":
|
|
126
|
+
raise AnalysisError(
|
|
127
|
+
f"debug-info extraction supports ELF only, got {info.format!r}; "
|
|
128
|
+
"PE PDB parsing needs a backend that is not available yet"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _load_elftools(path: Path):
|
|
133
|
+
"""Import pyelftools lazily and open ``path``; refuse with a typed error."""
|
|
134
|
+
try:
|
|
135
|
+
from elftools.elf.elffile import ELFFile
|
|
136
|
+
except ImportError as exc:
|
|
137
|
+
raise AnalysisError(
|
|
138
|
+
"pyelftools is not installed: run pip install -e \".[dwarf]\" "
|
|
139
|
+
"(or .[dev]) to enable debug-info extraction"
|
|
140
|
+
) from exc
|
|
141
|
+
handle = path.open("rb")
|
|
142
|
+
try:
|
|
143
|
+
return ELFFile(handle), handle
|
|
144
|
+
except Exception:
|
|
145
|
+
handle.close()
|
|
146
|
+
raise
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def extract_debug_info(
|
|
150
|
+
path: str | Path,
|
|
151
|
+
info: BinaryInfo,
|
|
152
|
+
*,
|
|
153
|
+
limit: int = DEFAULT_LIMIT,
|
|
154
|
+
) -> DebugInfoResult:
|
|
155
|
+
"""Extract named functions and data-type layouts from ELF DWARF.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
path: File the metadata in ``info`` was loaded from (read-only).
|
|
159
|
+
info: Normalized binary metadata; must be an ELF.
|
|
160
|
+
limit: Cap on functions and types returned each (1..100000).
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
A :class:`DebugInfoResult`; empty lists with warnings when the binary
|
|
164
|
+
carries no ``.debug_info``.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
AnalysisError: If the binary is not an ELF, ``limit`` is out of range,
|
|
168
|
+
or pyelftools is not installed.
|
|
169
|
+
"""
|
|
170
|
+
if limit < 1:
|
|
171
|
+
raise AnalysisError("limit must be >= 1")
|
|
172
|
+
if limit > MAX_LIMIT:
|
|
173
|
+
raise AnalysisError(f"limit too large (limit {MAX_LIMIT})")
|
|
174
|
+
_require_elf(info)
|
|
175
|
+
|
|
176
|
+
elf_file, handle = _load_elftools(Path(path))
|
|
177
|
+
try:
|
|
178
|
+
if not elf_file.has_dwarf_info():
|
|
179
|
+
return DebugInfoResult(
|
|
180
|
+
functions=[],
|
|
181
|
+
types=[],
|
|
182
|
+
truncated_functions=False,
|
|
183
|
+
truncated_types=False,
|
|
184
|
+
evidence=Evidence(
|
|
185
|
+
source="dwarf.debug_info",
|
|
186
|
+
method="elf_scan",
|
|
187
|
+
confidence=Confidence.HIGH,
|
|
188
|
+
detail="binary carries no DWARF debug info",
|
|
189
|
+
),
|
|
190
|
+
warnings=("no .debug_info section in this ELF",),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
dwarf_info = elf_file.get_dwarf_info()
|
|
194
|
+
functions: list[DebugFunction] = []
|
|
195
|
+
types: list[DebugType] = []
|
|
196
|
+
warnings: list[str] = []
|
|
197
|
+
truncated_functions = False
|
|
198
|
+
truncated_types = False
|
|
199
|
+
|
|
200
|
+
for cu in dwarf_info.iter_CUs():
|
|
201
|
+
for die in cu.iter_DIEs():
|
|
202
|
+
if die.tag == "DW_TAG_subprogram":
|
|
203
|
+
if truncated_functions:
|
|
204
|
+
continue
|
|
205
|
+
low_attr = die.attributes.get("DW_AT_low_pc")
|
|
206
|
+
decl_attr = die.attributes.get("DW_AT_declaration")
|
|
207
|
+
if low_attr is None or decl_attr is not None:
|
|
208
|
+
continue # prototype or outlined without a body here
|
|
209
|
+
name_attr = die.attributes.get("DW_AT_name")
|
|
210
|
+
name = (
|
|
211
|
+
str(name_attr.value, "utf-8", "replace")
|
|
212
|
+
if name_attr is not None
|
|
213
|
+
else ""
|
|
214
|
+
)
|
|
215
|
+
if not name:
|
|
216
|
+
continue
|
|
217
|
+
high_attr = die.attributes.get("DW_AT_high_pc")
|
|
218
|
+
if high_attr is None:
|
|
219
|
+
size = 0
|
|
220
|
+
elif high_attr.form == "DW_FORM_addr":
|
|
221
|
+
size = max(0, int(high_attr.value) - int(low_attr.value))
|
|
222
|
+
else:
|
|
223
|
+
# data forms store an offset from low_pc
|
|
224
|
+
size = int(high_attr.value)
|
|
225
|
+
linkage = die.attributes.get("DW_AT_MIPS_linkage_name") or die.attributes.get(
|
|
226
|
+
"DW_AT_linkage_name"
|
|
227
|
+
)
|
|
228
|
+
functions.append(
|
|
229
|
+
DebugFunction(
|
|
230
|
+
name=name,
|
|
231
|
+
address=int(low_attr.value),
|
|
232
|
+
size=size,
|
|
233
|
+
linkage_name=(
|
|
234
|
+
str(linkage.value, "utf-8", "replace")
|
|
235
|
+
if linkage is not None
|
|
236
|
+
else None
|
|
237
|
+
),
|
|
238
|
+
is_external=bool(
|
|
239
|
+
die.attributes.get("DW_AT_external") is not None
|
|
240
|
+
),
|
|
241
|
+
evidence=Evidence(
|
|
242
|
+
source="dwarf.debug_info",
|
|
243
|
+
method="DIE_subprogram",
|
|
244
|
+
confidence=Confidence.HIGH,
|
|
245
|
+
address=int(low_attr.value),
|
|
246
|
+
detail=f"{name}: DWARF subprogram, {size} bytes",
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
if len(functions) >= limit:
|
|
251
|
+
truncated_functions = True
|
|
252
|
+
|
|
253
|
+
elif die.tag in _TYPE_KINDS:
|
|
254
|
+
if truncated_types:
|
|
255
|
+
continue
|
|
256
|
+
name_attr = die.attributes.get("DW_AT_name")
|
|
257
|
+
size_attr = die.attributes.get("DW_AT_byte_size")
|
|
258
|
+
if name_attr is None or size_attr is None:
|
|
259
|
+
continue # anonymous or declaration-only layout
|
|
260
|
+
types.append(
|
|
261
|
+
DebugType(
|
|
262
|
+
name=str(name_attr.value, "utf-8", "replace"),
|
|
263
|
+
kind=_TYPE_KINDS[die.tag],
|
|
264
|
+
byte_size=int(size_attr.value),
|
|
265
|
+
evidence=Evidence(
|
|
266
|
+
source="dwarf.debug_info",
|
|
267
|
+
method="DIE_structure_type"
|
|
268
|
+
if die.tag == "DW_TAG_structure_type"
|
|
269
|
+
else "DIE_type",
|
|
270
|
+
confidence=Confidence.HIGH,
|
|
271
|
+
detail=(
|
|
272
|
+
f"{str(name_attr.value, 'utf-8', 'replace')}: "
|
|
273
|
+
f"{_TYPE_KINDS[die.tag]}, {int(size_attr.value)} bytes"
|
|
274
|
+
),
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
if len(types) >= limit:
|
|
279
|
+
truncated_types = True
|
|
280
|
+
finally:
|
|
281
|
+
handle.close()
|
|
282
|
+
|
|
283
|
+
warnings = []
|
|
284
|
+
if truncated_functions:
|
|
285
|
+
warnings.append(f"function limit {limit} reached; more subprograms exist")
|
|
286
|
+
if truncated_types:
|
|
287
|
+
warnings.append(f"type limit {limit} reached; more named types exist")
|
|
288
|
+
|
|
289
|
+
return DebugInfoResult(
|
|
290
|
+
functions=functions,
|
|
291
|
+
types=types,
|
|
292
|
+
truncated_functions=truncated_functions,
|
|
293
|
+
truncated_types=truncated_types,
|
|
294
|
+
evidence=Evidence(
|
|
295
|
+
source="dwarf.debug_info",
|
|
296
|
+
method="debug_info_scan",
|
|
297
|
+
confidence=Confidence.HIGH,
|
|
298
|
+
detail=(
|
|
299
|
+
f"{len(functions)} DWARF function(s), {len(types)} named type(s); "
|
|
300
|
+
"producer metadata, highest-grade symbol evidence"
|
|
301
|
+
),
|
|
302
|
+
),
|
|
303
|
+
warnings=tuple(warnings),
|
|
304
|
+
)
|