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,748 @@
|
|
|
1
|
+
"""Plugin SDK: discover, load and run analysers without touching the core.
|
|
2
|
+
|
|
3
|
+
An analyser that ships outside the package must not require an edit inside it.
|
|
4
|
+
Three functions make that possible:
|
|
5
|
+
|
|
6
|
+
* :func:`discover` reads the ``codevanta.analyzers`` entry point group through
|
|
7
|
+
:mod:`importlib.metadata` and returns :class:`PluginInfo` records. It
|
|
8
|
+
**never imports** a plugin: discovery only touches distribution metadata, so
|
|
9
|
+
listing what is installed cannot execute third-party code.
|
|
10
|
+
* :func:`load` imports one plugin lazily, by name, and validates the object
|
|
11
|
+
before handing it back. A broken import or a wrong interface becomes an
|
|
12
|
+
:class:`~codevanta.core.report.AnalysisError` that names the plugin, so one
|
|
13
|
+
bad plugin cannot take the caller down with an arbitrary traceback.
|
|
14
|
+
* :func:`run_plugin` is the only place a plugin actually runs, and it is the
|
|
15
|
+
chokepoint for the two limits that matter: wall-clock time and payload size.
|
|
16
|
+
|
|
17
|
+
The contract is deliberately tiny -- one dataclass in, one mapping out::
|
|
18
|
+
|
|
19
|
+
class MyAnalyzer:
|
|
20
|
+
name = "my-analyzer"
|
|
21
|
+
version = "1.0"
|
|
22
|
+
description = "Counts something interesting."
|
|
23
|
+
|
|
24
|
+
def analyze(self, context: AnalysisContext) -> Mapping[str, Any]:
|
|
25
|
+
return {"count": len(context.functions)}
|
|
26
|
+
|
|
27
|
+
That object satisfies :class:`AnalyzerPlugin` structurally; it does not have to
|
|
28
|
+
import this module or subclass anything.
|
|
29
|
+
|
|
30
|
+
Safety limits
|
|
31
|
+
-------------
|
|
32
|
+
|
|
33
|
+
* **Time.** ``run_plugin`` measures the call with ``perf_counter`` and rejects
|
|
34
|
+
a run over ``max_seconds`` (default :data:`DEFAULT_MAX_SECONDS`, 30 s).
|
|
35
|
+
Python cannot kill a thread that is stuck inside third-party code, so the
|
|
36
|
+
budget is **checked on return, not enforced during the call**: a plugin that
|
|
37
|
+
never returns still hangs the caller. Run untrusted plugins in a subprocess
|
|
38
|
+
if that matters; for the pure-Python, read-only analysers this SDK targets,
|
|
39
|
+
a post-return check still stops a merely slow plugin from bloating a sweep.
|
|
40
|
+
* **Size.** The payload is reduced to canonical JSON and rejected above
|
|
41
|
+
``max_bytes`` (default :data:`DEFAULT_MAX_BYTES`, 4 MiB). The cap is checked
|
|
42
|
+
*before* the time budget, so an oversized payload is always reported as
|
|
43
|
+
oversized rather than as a timeout. Serialising first also rejects payloads
|
|
44
|
+
that are not JSON-safe (``NaN``, arbitrary objects), which keeps every
|
|
45
|
+
:class:`PluginResult` exportable.
|
|
46
|
+
* **Shape.** Only a ``Mapping`` is accepted as a payload; anything else is an
|
|
47
|
+
:class:`AnalysisError` naming the plugin and the type it returned.
|
|
48
|
+
|
|
49
|
+
Built-in registry
|
|
50
|
+
-----------------
|
|
51
|
+
|
|
52
|
+
Analysers wired inside the package live in a process-local, lock-guarded dict
|
|
53
|
+
(:func:`register` / :func:`unregister`). :func:`load` consults it before entry
|
|
54
|
+
points, so an internal analyser is reachable by name without being installable,
|
|
55
|
+
and tests can register a throw-away analyser and drive it through the same code
|
|
56
|
+
path as an installed plugin. :func:`builtin_plugins` describes whatever is
|
|
57
|
+
registered; no plugin code runs at import time.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import json
|
|
63
|
+
import threading
|
|
64
|
+
import time
|
|
65
|
+
from collections.abc import Iterable, Mapping
|
|
66
|
+
from dataclasses import dataclass, field
|
|
67
|
+
from importlib import metadata
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from types import MappingProxyType
|
|
70
|
+
from typing import Any, Protocol, cast, runtime_checkable
|
|
71
|
+
|
|
72
|
+
from codevanta.core.functions import FunctionCandidate, discover_functions
|
|
73
|
+
from codevanta.core.loader import BinaryInfo
|
|
74
|
+
from codevanta.core.report import MAX_STRING_LIMIT, AnalysisError, load_binary
|
|
75
|
+
from codevanta.core.strings import StringExtractor
|
|
76
|
+
from codevanta.evidence.confidence import Confidence
|
|
77
|
+
from codevanta.evidence.models import Evidence
|
|
78
|
+
|
|
79
|
+
#: Entry point group scanned for analysers unless the caller says otherwise.
|
|
80
|
+
DEFAULT_ENTRY_POINT_GROUP = "codevanta.analyzers"
|
|
81
|
+
|
|
82
|
+
KIND_ANALYZER = "analyzer"
|
|
83
|
+
KIND_EXPORTER = "exporter"
|
|
84
|
+
KIND_PROVIDER = "provider"
|
|
85
|
+
#: The three plugin kinds this SDK knows how to describe.
|
|
86
|
+
PLUGIN_KINDS = frozenset({KIND_ANALYZER, KIND_EXPORTER, KIND_PROVIDER})
|
|
87
|
+
|
|
88
|
+
#: Wall-clock budget for one plugin run, in seconds.
|
|
89
|
+
DEFAULT_MAX_SECONDS = 30.0
|
|
90
|
+
#: Ceiling on the canonical-JSON size of one plugin payload (4 MiB).
|
|
91
|
+
DEFAULT_MAX_BYTES = 4 * 1024 * 1024
|
|
92
|
+
#: Strings the built-in metrics analyser reads per run.
|
|
93
|
+
DEFAULT_MAX_STRINGS = 5000
|
|
94
|
+
|
|
95
|
+
#: Version reported when a distribution does not declare one.
|
|
96
|
+
UNKNOWN_VERSION = "unknown"
|
|
97
|
+
|
|
98
|
+
#: Plugin kinds keyed by entry point group suffix.
|
|
99
|
+
_KIND_BY_GROUP_SUFFIX: dict[str, str] = {
|
|
100
|
+
"analyzer": KIND_ANALYZER,
|
|
101
|
+
"analyzers": KIND_ANALYZER,
|
|
102
|
+
"exporter": KIND_EXPORTER,
|
|
103
|
+
"exporters": KIND_EXPORTER,
|
|
104
|
+
"provider": KIND_PROVIDER,
|
|
105
|
+
"providers": KIND_PROVIDER,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class PluginInfo:
|
|
110
|
+
"""What is known about a plugin without importing it.
|
|
111
|
+
|
|
112
|
+
Attributes:
|
|
113
|
+
name: Entry point name / registry key; unique per group.
|
|
114
|
+
version: Declaring distribution's version, or ``"unknown"``.
|
|
115
|
+
kind: One of :data:`PLUGIN_KINDS`.
|
|
116
|
+
entry_point: The ``module:attribute`` reference as declared.
|
|
117
|
+
description: One-line summary from the distribution metadata, if any.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
ValueError: When the name is blank or the kind is not a known kind.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
name: str
|
|
124
|
+
version: str
|
|
125
|
+
kind: str
|
|
126
|
+
entry_point: str
|
|
127
|
+
description: str = ""
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
name = self.name.strip()
|
|
131
|
+
if not name:
|
|
132
|
+
raise ValueError("plugin name must not be empty")
|
|
133
|
+
kind = self.kind.strip().lower()
|
|
134
|
+
if kind not in PLUGIN_KINDS:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"plugin {name!r} has unknown kind {self.kind!r}; "
|
|
137
|
+
f"expected one of {sorted(PLUGIN_KINDS)}"
|
|
138
|
+
)
|
|
139
|
+
object.__setattr__(self, "name", name)
|
|
140
|
+
object.__setattr__(self, "kind", kind)
|
|
141
|
+
object.__setattr__(self, "version", self.version.strip() or UNKNOWN_VERSION)
|
|
142
|
+
object.__setattr__(self, "entry_point", self.entry_point.strip())
|
|
143
|
+
object.__setattr__(self, "description", self.description.strip())
|
|
144
|
+
|
|
145
|
+
def to_dict(self) -> dict[str, Any]:
|
|
146
|
+
"""Return a JSON-safe description of the plugin."""
|
|
147
|
+
return {
|
|
148
|
+
"name": self.name,
|
|
149
|
+
"version": self.version,
|
|
150
|
+
"kind": self.kind,
|
|
151
|
+
"entry_point": self.entry_point,
|
|
152
|
+
"description": self.description,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class AnalysisContext:
|
|
158
|
+
"""Everything an analyser is given: the subject, and a budget.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
path: The file under analysis (bytes, not a parsed copy).
|
|
162
|
+
info: Normalized binary metadata from the loader.
|
|
163
|
+
address: Focus address for address-scoped analysers; :func:`build_context`
|
|
164
|
+
defaults it to the entry point.
|
|
165
|
+
functions: Pre-discovered function candidates, so a plugin does not
|
|
166
|
+
repeat work the core already did.
|
|
167
|
+
budget: Plugin-specific integer limits (e.g. ``{"max_strings": 5000}``),
|
|
168
|
+
read-only for the plugin.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
path: Path
|
|
172
|
+
info: BinaryInfo
|
|
173
|
+
address: int | None = None
|
|
174
|
+
functions: tuple[FunctionCandidate, ...] = ()
|
|
175
|
+
budget: Mapping[str, int] = field(default_factory=dict)
|
|
176
|
+
|
|
177
|
+
def __post_init__(self) -> None:
|
|
178
|
+
object.__setattr__(self, "path", Path(self.path))
|
|
179
|
+
object.__setattr__(self, "functions", tuple(self.functions))
|
|
180
|
+
object.__setattr__(self, "budget", MappingProxyType(dict(self.budget)))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@runtime_checkable
|
|
184
|
+
class AnalyzerPlugin(Protocol):
|
|
185
|
+
"""Structural contract for an analyser plugin.
|
|
186
|
+
|
|
187
|
+
No base class is required: any object with these three attributes and an
|
|
188
|
+
``analyze`` method works, which keeps plugins independent of CODEVANTA's
|
|
189
|
+
release cadence.
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
name: str
|
|
193
|
+
version: str
|
|
194
|
+
description: str
|
|
195
|
+
|
|
196
|
+
def analyze(self, context: AnalysisContext) -> Mapping[str, Any]:
|
|
197
|
+
"""Analyse ``context`` and return a JSON-serialisable mapping."""
|
|
198
|
+
...
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class PluginResult:
|
|
203
|
+
"""The bounded outcome of one :func:`run_plugin` call.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
plugin: Description of the plugin that produced the payload.
|
|
207
|
+
payload: The mapping the plugin returned (a shallow copy).
|
|
208
|
+
elapsed_ms: Measured run time in milliseconds.
|
|
209
|
+
warnings: Warnings lifted out of ``payload["warnings"]``.
|
|
210
|
+
evidence: Provenance for the run, including the payload size.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
plugin: PluginInfo
|
|
214
|
+
payload: Mapping[str, Any]
|
|
215
|
+
elapsed_ms: float
|
|
216
|
+
warnings: tuple[str, ...] = ()
|
|
217
|
+
evidence: Evidence | None = None
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> dict[str, Any]:
|
|
220
|
+
"""Return a JSON-safe representation, ready for a report or a bundle."""
|
|
221
|
+
data: dict[str, Any] = {
|
|
222
|
+
"plugin": self.plugin.to_dict(),
|
|
223
|
+
"payload": dict(self.payload),
|
|
224
|
+
"elapsed_ms": round(float(self.elapsed_ms), 3),
|
|
225
|
+
"warnings": list(self.warnings),
|
|
226
|
+
}
|
|
227
|
+
if self.evidence is not None:
|
|
228
|
+
data["evidence"] = self.evidence.to_dict()
|
|
229
|
+
return data
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---- built-in registry ------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
#: Process-local registry of plugins wired inside the package (and, in tests,
|
|
235
|
+
#: registered by hand). Guarded by ``_REGISTRY_LOCK``: built-ins are registered
|
|
236
|
+
#: once at import and read from any thread thereafter.
|
|
237
|
+
_REGISTRY: dict[str, AnalyzerPlugin] = {}
|
|
238
|
+
_REGISTRY_LOCK = threading.RLock()
|
|
239
|
+
|
|
240
|
+
#: Warnings produced by the most recent :func:`discover` call.
|
|
241
|
+
_DISCOVERY_WARNINGS: tuple[str, ...] = ()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def register(plugin: AnalyzerPlugin) -> PluginInfo:
|
|
245
|
+
"""Register ``plugin`` under its own name and return its description.
|
|
246
|
+
|
|
247
|
+
Registering the same name twice replaces the previous object, which is how
|
|
248
|
+
an internal analyser gets swapped out during development.
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
AnalysisError: When the object does not satisfy :class:`AnalyzerPlugin`
|
|
252
|
+
(no usable ``name``, no callable ``analyze``).
|
|
253
|
+
"""
|
|
254
|
+
validated = _validate_plugin(plugin, source="plugin")
|
|
255
|
+
info = _plugin_info(validated)
|
|
256
|
+
with _REGISTRY_LOCK:
|
|
257
|
+
_REGISTRY[info.name] = validated
|
|
258
|
+
return info
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def unregister(name: str) -> bool:
|
|
262
|
+
"""Remove ``name`` from the registry; return whether it was there.
|
|
263
|
+
|
|
264
|
+
Idempotent on purpose, so a test can clean up in a ``finally`` block
|
|
265
|
+
without first checking what it managed to register.
|
|
266
|
+
"""
|
|
267
|
+
key = (name or "").strip()
|
|
268
|
+
with _REGISTRY_LOCK:
|
|
269
|
+
return _REGISTRY.pop(key, None) is not None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def registry_snapshot() -> dict[str, AnalyzerPlugin]:
|
|
273
|
+
"""Return a shallow copy of the registry, safe to iterate while mutating."""
|
|
274
|
+
with _REGISTRY_LOCK:
|
|
275
|
+
return dict(_REGISTRY)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def builtin_plugins() -> tuple[PluginInfo, ...]:
|
|
279
|
+
"""Describe the plugins currently wired into this process, sorted by name.
|
|
280
|
+
|
|
281
|
+
This is the live registry, not a static list: a plugin registered by a
|
|
282
|
+
caller (a test, say) shows up here too, which is what makes the built-in
|
|
283
|
+
path testable end to end.
|
|
284
|
+
"""
|
|
285
|
+
with _REGISTRY_LOCK:
|
|
286
|
+
plugins = list(_REGISTRY.values())
|
|
287
|
+
return tuple(sorted((_plugin_info(plugin) for plugin in plugins), key=lambda i: i.name))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def discovery_warnings() -> tuple[str, ...]:
|
|
291
|
+
"""Return the warnings collected by the last :func:`discover` call."""
|
|
292
|
+
with _REGISTRY_LOCK:
|
|
293
|
+
return _DISCOVERY_WARNINGS
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---- discovery and loading --------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def discover(entry_point_group: str = DEFAULT_ENTRY_POINT_GROUP) -> tuple[PluginInfo, ...]:
|
|
300
|
+
"""List the plugins declared in ``entry_point_group``, sorted by name.
|
|
301
|
+
|
|
302
|
+
Only distribution metadata is read -- no entry point is loaded -- so
|
|
303
|
+
enumerating what is installed never executes plugin code. Broken metadata
|
|
304
|
+
degrades the result instead of raising it: an entry point without a name or
|
|
305
|
+
without a target is skipped, an unreadable version falls back to
|
|
306
|
+
``"unknown"``, a name declared twice is kept once, and each of those
|
|
307
|
+
records a warning retrievable through :func:`discovery_warnings`.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
entry_point_group: Entry point group to scan.
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
One :class:`PluginInfo` per declared plugin, sorted by name. An empty
|
|
314
|
+
tuple when nothing is installed for the group -- never an exception.
|
|
315
|
+
"""
|
|
316
|
+
group = _group_name(entry_point_group)
|
|
317
|
+
warnings: list[str] = []
|
|
318
|
+
try:
|
|
319
|
+
declared = list(metadata.entry_points(group=group))
|
|
320
|
+
except Exception as exc: # noqa: BLE001 - distribution metadata is external data
|
|
321
|
+
_store_discovery_warnings(
|
|
322
|
+
(f"{group}: cannot read entry points ({exc}); no plugins discovered",)
|
|
323
|
+
)
|
|
324
|
+
return ()
|
|
325
|
+
|
|
326
|
+
found: dict[str, PluginInfo] = {}
|
|
327
|
+
for entry_point in sorted(declared, key=_ep_name):
|
|
328
|
+
label = _ep_name(entry_point) or "?"
|
|
329
|
+
try:
|
|
330
|
+
info, warning = _info_from_entry_point(entry_point, group)
|
|
331
|
+
except Exception as exc: # noqa: BLE001 - one bad entry point must not win
|
|
332
|
+
warnings.append(f"{group}: skipped entry point {label!r} ({exc})")
|
|
333
|
+
continue
|
|
334
|
+
if warning is not None:
|
|
335
|
+
warnings.append(warning)
|
|
336
|
+
if info.name in found:
|
|
337
|
+
warnings.append(
|
|
338
|
+
f"{group}: duplicate entry point {info.name!r} ignored "
|
|
339
|
+
f"({found[info.name].entry_point!r} already declared)"
|
|
340
|
+
)
|
|
341
|
+
continue
|
|
342
|
+
found[info.name] = info
|
|
343
|
+
|
|
344
|
+
_store_discovery_warnings(tuple(warnings))
|
|
345
|
+
return tuple(found[name] for name in sorted(found))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def load(
|
|
349
|
+
entry_point_group: str = DEFAULT_ENTRY_POINT_GROUP,
|
|
350
|
+
name: str = "",
|
|
351
|
+
) -> AnalyzerPlugin:
|
|
352
|
+
"""Import and return the plugin called ``name``, validating its interface.
|
|
353
|
+
|
|
354
|
+
The built-in registry is consulted first, so an internal analyser is
|
|
355
|
+
reachable by name whether or not anything is installed. Otherwise the
|
|
356
|
+
entry point is imported now -- lazily, and only for the one plugin asked
|
|
357
|
+
for.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
entry_point_group: Entry point group to search.
|
|
361
|
+
name: Plugin name, as declared by the entry point or the registry.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
The plugin object, validated against :class:`AnalyzerPlugin`.
|
|
365
|
+
|
|
366
|
+
Raises:
|
|
367
|
+
AnalysisError: When the name is empty or unknown, when the import
|
|
368
|
+
fails, or when the imported object does not implement the
|
|
369
|
+
interface. The message always names the plugin.
|
|
370
|
+
"""
|
|
371
|
+
key = (name or "").strip()
|
|
372
|
+
if not key:
|
|
373
|
+
raise AnalysisError("plugin name must not be empty")
|
|
374
|
+
group = _group_name(entry_point_group)
|
|
375
|
+
|
|
376
|
+
with _REGISTRY_LOCK:
|
|
377
|
+
builtin = _REGISTRY.get(key)
|
|
378
|
+
if builtin is not None:
|
|
379
|
+
return _validate_plugin(builtin, source=f"plugin {key!r} (built-in)")
|
|
380
|
+
|
|
381
|
+
try:
|
|
382
|
+
declared = [ep for ep in metadata.entry_points(group=group) if _ep_name(ep) == key]
|
|
383
|
+
except Exception as exc: # noqa: BLE001 - distribution metadata is external data
|
|
384
|
+
raise AnalysisError(f"cannot read entry points for {group!r}: {exc}") from exc
|
|
385
|
+
|
|
386
|
+
if not declared:
|
|
387
|
+
raise AnalysisError(f"no plugin {key!r} in entry point group {group!r}")
|
|
388
|
+
if len(declared) > 1:
|
|
389
|
+
raise AnalysisError(
|
|
390
|
+
f"plugin {key!r} is declared {len(declared)} times in {group!r}; "
|
|
391
|
+
"refusing to guess which one to load"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
loaded = declared[0].load()
|
|
396
|
+
except Exception as exc: # noqa: BLE001 - arbitrary import-time failure
|
|
397
|
+
raise AnalysisError(f"plugin {key!r} failed to import: {exc}") from exc
|
|
398
|
+
if isinstance(loaded, type) and not callable(getattr(loaded, "analyze", None)):
|
|
399
|
+
# An entry point can point at a class rather than an instance; say so
|
|
400
|
+
# instead of reporting a bare "no analyze()".
|
|
401
|
+
raise AnalysisError(
|
|
402
|
+
f"plugin {key!r} resolves to the class {loaded.__name__}; "
|
|
403
|
+
"point the entry point at an instance instead"
|
|
404
|
+
)
|
|
405
|
+
return _validate_plugin(loaded, source=f"plugin {key!r}")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ---- running ----------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def canonical_json(payload: Mapping[str, Any]) -> bytes:
|
|
412
|
+
"""Serialise ``payload`` as canonical JSON bytes.
|
|
413
|
+
|
|
414
|
+
Canonical means ``sort_keys``, ``separators=(",", ":")``, ASCII-escaped and
|
|
415
|
+
NaN-free, matching the rest of the project, so two runs of the same plugin
|
|
416
|
+
on the same input serialise to identical bytes.
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
AnalysisError: When the payload is not JSON-serialisable.
|
|
420
|
+
"""
|
|
421
|
+
try:
|
|
422
|
+
text = json.dumps(
|
|
423
|
+
dict(payload),
|
|
424
|
+
sort_keys=True,
|
|
425
|
+
separators=(",", ":"),
|
|
426
|
+
ensure_ascii=True,
|
|
427
|
+
allow_nan=False,
|
|
428
|
+
)
|
|
429
|
+
except (TypeError, ValueError) as exc:
|
|
430
|
+
raise AnalysisError(f"payload is not JSON-serialisable: {exc}") from exc
|
|
431
|
+
return text.encode("utf-8")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def run_plugin(
|
|
435
|
+
plugin: AnalyzerPlugin,
|
|
436
|
+
context: AnalysisContext,
|
|
437
|
+
*,
|
|
438
|
+
max_seconds: float = DEFAULT_MAX_SECONDS,
|
|
439
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
440
|
+
) -> PluginResult:
|
|
441
|
+
"""Run ``plugin`` against ``context`` under a time and size budget.
|
|
442
|
+
|
|
443
|
+
Checks are applied in this order: the plugin interface, the payload shape,
|
|
444
|
+
the canonical-JSON byte cap, then the time budget. The time budget is
|
|
445
|
+
measured with ``perf_counter`` and therefore checked *after* the call
|
|
446
|
+
returned -- the call is not interruptible (see the module docstring), so a
|
|
447
|
+
hung plugin still hangs the caller, and the byte cap is deliberately
|
|
448
|
+
enforced first so an oversized payload is never masked by a slow run.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
plugin: The plugin object, not its name (:func:`load` resolves names).
|
|
452
|
+
context: Subject and budget handed to ``analyze``.
|
|
453
|
+
max_seconds: Wall-clock budget for the call, in seconds.
|
|
454
|
+
max_bytes: Ceiling on the canonical-JSON size of the payload.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
A :class:`PluginResult` carrying the payload, the measured time, any
|
|
458
|
+
warnings lifted out of ``payload["warnings"]`` and the run's evidence.
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
AnalysisError: When the plugin is malformed, returns a non-mapping,
|
|
462
|
+
exceeds either budget, or when a budget argument is invalid.
|
|
463
|
+
"""
|
|
464
|
+
if isinstance(plugin, str):
|
|
465
|
+
raise AnalysisError("run_plugin takes a plugin object, not a name; call load() first")
|
|
466
|
+
validated = _validate_plugin(plugin, source="plugin")
|
|
467
|
+
if not isinstance(context, AnalysisContext):
|
|
468
|
+
raise AnalysisError(f"run_plugin needs an AnalysisContext, got {type(context).__name__}")
|
|
469
|
+
seconds = _budget_seconds(max_seconds)
|
|
470
|
+
limit = _budget_bytes(max_bytes)
|
|
471
|
+
|
|
472
|
+
label = _plugin_label(validated)
|
|
473
|
+
started = time.perf_counter()
|
|
474
|
+
payload = validated.analyze(context)
|
|
475
|
+
elapsed_ms = (time.perf_counter() - started) * 1000.0
|
|
476
|
+
|
|
477
|
+
if not isinstance(payload, Mapping):
|
|
478
|
+
raise AnalysisError(
|
|
479
|
+
f"plugin {label} returned {type(payload).__name__}, expected a mapping"
|
|
480
|
+
)
|
|
481
|
+
try:
|
|
482
|
+
data = canonical_json(payload)
|
|
483
|
+
except AnalysisError as exc:
|
|
484
|
+
raise AnalysisError(f"plugin {label}: {exc}") from exc
|
|
485
|
+
if len(data) > limit:
|
|
486
|
+
raise AnalysisError(
|
|
487
|
+
f"plugin {label} produced {len(data)} bytes of JSON, over the {limit}-byte limit"
|
|
488
|
+
)
|
|
489
|
+
if elapsed_ms > seconds * 1000.0:
|
|
490
|
+
raise AnalysisError(
|
|
491
|
+
f"plugin {label} took {elapsed_ms:.1f} ms, over its {seconds:.3f} s budget "
|
|
492
|
+
"(measured after return; the call cannot be interrupted)"
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
info = _plugin_info(validated)
|
|
496
|
+
return PluginResult(
|
|
497
|
+
plugin=info,
|
|
498
|
+
payload=dict(payload),
|
|
499
|
+
elapsed_ms=round(elapsed_ms, 3),
|
|
500
|
+
warnings=_payload_warnings(payload),
|
|
501
|
+
evidence=Evidence(
|
|
502
|
+
source=f"plugin.{info.name}",
|
|
503
|
+
method="plugin.analyze",
|
|
504
|
+
confidence=Confidence.MEDIUM,
|
|
505
|
+
detail=(
|
|
506
|
+
f"ran to completion, {len(data)} bytes of JSON in {elapsed_ms:.1f} ms; "
|
|
507
|
+
"the harness vouches for the run, not for the plugin's method"
|
|
508
|
+
),
|
|
509
|
+
extra={
|
|
510
|
+
"plugin": info.name,
|
|
511
|
+
"version": info.version,
|
|
512
|
+
"payload_bytes": len(data),
|
|
513
|
+
"elapsed_ms": round(elapsed_ms, 3),
|
|
514
|
+
},
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def build_context(
|
|
520
|
+
path: str | Path,
|
|
521
|
+
*,
|
|
522
|
+
address: int | None = None,
|
|
523
|
+
functions: Iterable[FunctionCandidate] | None = None,
|
|
524
|
+
budget: Mapping[str, int] | None = None,
|
|
525
|
+
) -> AnalysisContext:
|
|
526
|
+
"""Load ``path`` and assemble the :class:`AnalysisContext` for a run.
|
|
527
|
+
|
|
528
|
+
Convenience for callers (and tests) that want one obvious way from a file
|
|
529
|
+
to a runnable context: the binary is loaded, functions are discovered
|
|
530
|
+
unless supplied, and ``address`` falls back to the entry point.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
path: Binary to analyse.
|
|
534
|
+
address: Focus address; defaults to the entry point (may stay ``None``).
|
|
535
|
+
functions: Pre-computed candidates, skipping discovery when given.
|
|
536
|
+
budget: Plugin-specific integer limits.
|
|
537
|
+
|
|
538
|
+
Raises:
|
|
539
|
+
BinaryLoadError: When the file cannot be loaded (propagated from the
|
|
540
|
+
loader, which already names the offending path).
|
|
541
|
+
"""
|
|
542
|
+
binary_path = Path(path)
|
|
543
|
+
info = load_binary(binary_path)
|
|
544
|
+
discovered: tuple[FunctionCandidate, ...]
|
|
545
|
+
if functions is None:
|
|
546
|
+
discovered = tuple(discover_functions(info))
|
|
547
|
+
else:
|
|
548
|
+
discovered = tuple(functions)
|
|
549
|
+
return AnalysisContext(
|
|
550
|
+
path=binary_path,
|
|
551
|
+
info=info,
|
|
552
|
+
address=info.entry_point if address is None else address,
|
|
553
|
+
functions=discovered,
|
|
554
|
+
budget=dict(budget or {}),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
class MetricsAnalyzer:
|
|
559
|
+
"""Built-in analyser: counts functions and strings via the public API.
|
|
560
|
+
|
|
561
|
+
Deliberately unambitious. It exists to prove the plugin path end to end
|
|
562
|
+
(registry -> :func:`load` -> :func:`run_plugin` -> bounded JSON) with an
|
|
563
|
+
analyser whose numbers the core can independently check, and to show how a
|
|
564
|
+
plugin honours a budget from :class:`AnalysisContext`.
|
|
565
|
+
"""
|
|
566
|
+
|
|
567
|
+
name = "metrics"
|
|
568
|
+
version = "1.0"
|
|
569
|
+
description = "Function and string counts from the public CODEVANTA analysis API."
|
|
570
|
+
|
|
571
|
+
def analyze(self, context: AnalysisContext) -> dict[str, Any]:
|
|
572
|
+
"""Return counts plus the shape of the subject, bounded by the budget."""
|
|
573
|
+
max_strings = _budget_within(context.budget.get("max_strings", DEFAULT_MAX_STRINGS))
|
|
574
|
+
strings = StringExtractor().extract(context.path, context.info, limit=max_strings)
|
|
575
|
+
functions = context.functions
|
|
576
|
+
named = sum(1 for candidate in functions if candidate.name)
|
|
577
|
+
warnings: list[str] = []
|
|
578
|
+
if len(strings) >= max_strings:
|
|
579
|
+
warnings.append(
|
|
580
|
+
f"string scan stopped at the {max_strings}-string budget; "
|
|
581
|
+
"string_count is a lower bound"
|
|
582
|
+
)
|
|
583
|
+
entry_point = context.info.entry_point
|
|
584
|
+
return {
|
|
585
|
+
"format": context.info.format,
|
|
586
|
+
"architecture": context.info.architecture,
|
|
587
|
+
"bitness": context.info.bitness,
|
|
588
|
+
"endianness": context.info.endianness,
|
|
589
|
+
"entry_point": f"0x{entry_point:x}" if entry_point is not None else None,
|
|
590
|
+
"function_count": len(functions),
|
|
591
|
+
"named_function_count": named,
|
|
592
|
+
"string_count": len(strings),
|
|
593
|
+
"distinct_string_count": len({result.text for result in strings}),
|
|
594
|
+
"budget": {"max_strings": max_strings},
|
|
595
|
+
"warnings": warnings,
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
#: The built-in analyser instance, registered by ``_wire_builtins`` below.
|
|
600
|
+
METRICS_PLUGIN = MetricsAnalyzer()
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
# ---- internals --------------------------------------------------------------
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _group_name(entry_point_group: str) -> str:
|
|
607
|
+
"""Return a validated entry point group name."""
|
|
608
|
+
group = (entry_point_group or "").strip()
|
|
609
|
+
if not group:
|
|
610
|
+
raise AnalysisError("entry point group must not be empty")
|
|
611
|
+
return group
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _kind_for_group(entry_point_group: str) -> str:
|
|
615
|
+
"""Map an entry point group (``...analyzers``) to a plugin kind."""
|
|
616
|
+
suffix = _group_name(entry_point_group).rpartition(".")[2].lower()
|
|
617
|
+
return _KIND_BY_GROUP_SUFFIX.get(suffix, KIND_ANALYZER)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _ep_name(entry_point: Any) -> str:
|
|
621
|
+
"""Return an entry point's name, stripped, or ``""`` when it has none."""
|
|
622
|
+
return str(getattr(entry_point, "name", "") or "").strip()
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _plugin_label(plugin: object) -> str:
|
|
626
|
+
"""A message-safe identifier for ``plugin``, using its name when it has one."""
|
|
627
|
+
name = getattr(plugin, "name", None)
|
|
628
|
+
if isinstance(name, str) and name.strip():
|
|
629
|
+
return repr(name.strip())
|
|
630
|
+
return f"<{type(plugin).__name__} at 0x{id(plugin):x}>"
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _validate_plugin(plugin: object, *, source: str) -> AnalyzerPlugin:
|
|
634
|
+
"""Check the plugin contract, raising :class:`AnalysisError` naming it."""
|
|
635
|
+
label = _plugin_label(plugin)
|
|
636
|
+
name = getattr(plugin, "name", None)
|
|
637
|
+
if not isinstance(name, str) or not name.strip():
|
|
638
|
+
raise AnalysisError(f"{source} has no usable 'name' attribute (got {name!r})")
|
|
639
|
+
if not callable(getattr(plugin, "analyze", None)):
|
|
640
|
+
raise AnalysisError(f"{source} has no callable 'analyze' method (plugin {label})")
|
|
641
|
+
return cast(AnalyzerPlugin, plugin)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _plugin_info(plugin: AnalyzerPlugin) -> PluginInfo:
|
|
645
|
+
"""Describe a plugin object that already passed validation."""
|
|
646
|
+
version = getattr(plugin, "version", "") or ""
|
|
647
|
+
description = getattr(plugin, "description", "") or ""
|
|
648
|
+
return PluginInfo(
|
|
649
|
+
name=str(plugin.name),
|
|
650
|
+
version=str(version),
|
|
651
|
+
kind=KIND_ANALYZER,
|
|
652
|
+
entry_point=f"{type(plugin).__module__}:{type(plugin).__qualname__}",
|
|
653
|
+
description=str(description),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _info_from_entry_point(entry_point: Any, group: str) -> tuple[PluginInfo, str | None]:
|
|
658
|
+
"""Build :class:`PluginInfo` from one entry point without importing it.
|
|
659
|
+
|
|
660
|
+
Returns the description and, when the metadata was partially broken but
|
|
661
|
+
recoverable, a warning string instead of raising.
|
|
662
|
+
"""
|
|
663
|
+
name = _ep_name(entry_point)
|
|
664
|
+
if not name:
|
|
665
|
+
raise ValueError("entry point declares no name")
|
|
666
|
+
value = str(getattr(entry_point, "value", "") or "").strip()
|
|
667
|
+
if not value:
|
|
668
|
+
raise ValueError(f"entry point {name!r} declares no importable target")
|
|
669
|
+
|
|
670
|
+
version, description, warning = UNKNOWN_VERSION, "", None
|
|
671
|
+
dist = getattr(entry_point, "dist", None)
|
|
672
|
+
if dist is not None:
|
|
673
|
+
try:
|
|
674
|
+
version = str(dist.version or UNKNOWN_VERSION)
|
|
675
|
+
except Exception as exc: # noqa: BLE001 - broken distribution metadata
|
|
676
|
+
warning = (
|
|
677
|
+
f"{group}: {name!r} has unreadable version metadata ({exc}); "
|
|
678
|
+
f"reporting {UNKNOWN_VERSION!r}"
|
|
679
|
+
)
|
|
680
|
+
try:
|
|
681
|
+
summary = dist.metadata["Summary"]
|
|
682
|
+
except Exception: # noqa: BLE001 - the summary is optional metadata
|
|
683
|
+
summary = None
|
|
684
|
+
if isinstance(summary, str):
|
|
685
|
+
description = summary
|
|
686
|
+
|
|
687
|
+
info = PluginInfo(
|
|
688
|
+
name=name,
|
|
689
|
+
version=version,
|
|
690
|
+
kind=_kind_for_group(group),
|
|
691
|
+
entry_point=value,
|
|
692
|
+
description=description,
|
|
693
|
+
)
|
|
694
|
+
return info, warning
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def _store_discovery_warnings(warnings: tuple[str, ...]) -> None:
|
|
698
|
+
"""Record the warnings from one discovery pass."""
|
|
699
|
+
global _DISCOVERY_WARNINGS
|
|
700
|
+
with _REGISTRY_LOCK:
|
|
701
|
+
_DISCOVERY_WARNINGS = warnings
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def _payload_warnings(payload: Mapping[str, Any]) -> tuple[str, ...]:
|
|
705
|
+
"""Lift string warnings out of ``payload["warnings"]``, ignoring the rest."""
|
|
706
|
+
raw = payload.get("warnings")
|
|
707
|
+
if isinstance(raw, (str, bytes)) or not isinstance(raw, Iterable):
|
|
708
|
+
return ()
|
|
709
|
+
return tuple(item for item in raw if isinstance(item, str))
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _budget_seconds(value: float) -> float:
|
|
713
|
+
"""Validate ``max_seconds``: a positive, finite number."""
|
|
714
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
715
|
+
raise AnalysisError(f"max_seconds must be a number, got {value!r}")
|
|
716
|
+
seconds = float(value)
|
|
717
|
+
if not seconds > 0.0 or seconds == float("inf") or seconds != seconds:
|
|
718
|
+
raise AnalysisError(f"max_seconds must be finite and > 0, got {value!r}")
|
|
719
|
+
return seconds
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _budget_bytes(value: int) -> int:
|
|
723
|
+
"""Validate ``max_bytes``: a positive integer."""
|
|
724
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
725
|
+
raise AnalysisError(f"max_bytes must be an integer, got {value!r}")
|
|
726
|
+
if value < 1:
|
|
727
|
+
raise AnalysisError(f"max_bytes must be >= 1, got {value}")
|
|
728
|
+
return value
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _budget_within(value: Any) -> int:
|
|
732
|
+
"""Clamp a plugin-side integer budget into ``1 .. MAX_STRING_LIMIT``.
|
|
733
|
+
|
|
734
|
+
A plugin budget comes from a caller, so it is treated as untrusted input:
|
|
735
|
+
a bad value falls back to the default rather than failing the analysis, and
|
|
736
|
+
a huge one is capped by the core's string limit.
|
|
737
|
+
"""
|
|
738
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
739
|
+
return DEFAULT_MAX_STRINGS
|
|
740
|
+
return max(1, min(value, MAX_STRING_LIMIT))
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _wire_builtins() -> None:
|
|
744
|
+
"""Register the analysers that ship inside the package."""
|
|
745
|
+
register(METRICS_PLUGIN)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
_wire_builtins()
|