pybinaryguard 1.0.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.
- pybinaryguard/__init__.py +78 -0
- pybinaryguard/__main__.py +7 -0
- pybinaryguard/_compat/__init__.py +0 -0
- pybinaryguard/agent/__init__.py +63 -0
- pybinaryguard/agent/guard.py +232 -0
- pybinaryguard/agent/recommender.py +283 -0
- pybinaryguard/agent/schema.py +200 -0
- pybinaryguard/agent/simulator.py +430 -0
- pybinaryguard/agent/tool_interface.py +474 -0
- pybinaryguard/analyzers/__init__.py +209 -0
- pybinaryguard/analyzers/base.py +40 -0
- pybinaryguard/analyzers/dependency_analyzer.py +336 -0
- pybinaryguard/analyzers/elf_analyzer.py +754 -0
- pybinaryguard/analyzers/symbol_analyzer.py +280 -0
- pybinaryguard/analyzers/wheel_analyzer.py +308 -0
- pybinaryguard/cli/__init__.py +5 -0
- pybinaryguard/cli/commands.py +414 -0
- pybinaryguard/cli/formatters.py +720 -0
- pybinaryguard/cli/main.py +250 -0
- pybinaryguard/diagnostics/__init__.py +13 -0
- pybinaryguard/diagnostics/explainer.py +356 -0
- pybinaryguard/diagnostics/findings.py +146 -0
- pybinaryguard/diagnostics/suggestions.py +508 -0
- pybinaryguard/frameworks/__init__.py +20 -0
- pybinaryguard/frameworks/onnxruntime.py +214 -0
- pybinaryguard/frameworks/pytorch.py +223 -0
- pybinaryguard/frameworks/tensorflow.py +266 -0
- pybinaryguard/frameworks/tensorrt.py +189 -0
- pybinaryguard/models/__init__.py +19 -0
- pybinaryguard/models/enums.py +102 -0
- pybinaryguard/models/finding.py +109 -0
- pybinaryguard/models/package.py +121 -0
- pybinaryguard/models/system.py +118 -0
- pybinaryguard/plugins/__init__.py +19 -0
- pybinaryguard/plugins/contrib/__init__.py +7 -0
- pybinaryguard/plugins/contrib/gstreamer.py +109 -0
- pybinaryguard/plugins/contrib/jetson.py +385 -0
- pybinaryguard/plugins/contrib/opencv.py +306 -0
- pybinaryguard/plugins/contrib/tensorrt.py +426 -0
- pybinaryguard/plugins/hooks.py +273 -0
- pybinaryguard/plugins/loader.py +190 -0
- pybinaryguard/predictor/__init__.py +23 -0
- pybinaryguard/predictor/dependency_graph.py +226 -0
- pybinaryguard/predictor/linker_simulator.py +161 -0
- pybinaryguard/predictor/predictor.py +144 -0
- pybinaryguard/predictor/resolver.py +282 -0
- pybinaryguard/probes/__init__.py +73 -0
- pybinaryguard/probes/base.py +45 -0
- pybinaryguard/probes/board_probe.py +248 -0
- pybinaryguard/probes/cpu_probe.py +176 -0
- pybinaryguard/probes/glibc_probe.py +215 -0
- pybinaryguard/probes/gpu_probe.py +430 -0
- pybinaryguard/probes/library_probe.py +132 -0
- pybinaryguard/probes/os_probe.py +227 -0
- pybinaryguard/probes/python_probe.py +135 -0
- pybinaryguard/probes/toolchain_probe.py +89 -0
- pybinaryguard/probes/venv_probe.py +111 -0
- pybinaryguard/profiles/__init__.py +12 -0
- pybinaryguard/profiles/engine.py +343 -0
- pybinaryguard/rules/__init__.py +24 -0
- pybinaryguard/rules/base.py +57 -0
- pybinaryguard/rules/builtin/__init__.py +136 -0
- pybinaryguard/rules/builtin/arch_rules.py +86 -0
- pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
- pybinaryguard/rules/builtin/container_rules.py +170 -0
- pybinaryguard/rules/builtin/cpu_rules.py +204 -0
- pybinaryguard/rules/builtin/cuda_rules.py +760 -0
- pybinaryguard/rules/builtin/dependency_rules.py +278 -0
- pybinaryguard/rules/builtin/framework_rules.py +252 -0
- pybinaryguard/rules/builtin/glibc_rules.py +320 -0
- pybinaryguard/rules/builtin/numpy_rules.py +110 -0
- pybinaryguard/rules/builtin/predictive_rules.py +165 -0
- pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
- pybinaryguard/rules/builtin/source_build_rules.py +150 -0
- pybinaryguard/rules/builtin/venv_rules.py +159 -0
- pybinaryguard/rules/engine.py +123 -0
- pybinaryguard/scanner.py +904 -0
- pybinaryguard/scoring/__init__.py +19 -0
- pybinaryguard/scoring/engine.py +416 -0
- pybinaryguard/snapshot/__init__.py +20 -0
- pybinaryguard/snapshot/generator.py +168 -0
- pybinaryguard/snapshot/lockfile.py +152 -0
- pybinaryguard/snapshot/verifier.py +315 -0
- pybinaryguard/validators/__init__.py +7 -0
- pybinaryguard/validators/import_validator.py +231 -0
- pybinaryguard-1.0.0.dist-info/METADATA +876 -0
- pybinaryguard-1.0.0.dist-info/RECORD +91 -0
- pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
- pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
- pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
- pybinaryguard-1.0.0.dist-info/top_level.txt +1 -0
pybinaryguard/scanner.py
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
"""Scanner orchestrator -- runs the full probe -> analyze -> evaluate -> report pipeline.
|
|
2
|
+
|
|
3
|
+
This module is the heart of PyBinaryGuard. The :class:`Scanner` class ties
|
|
4
|
+
together probes, analyzers, rules, and plugins into a single cohesive scan
|
|
5
|
+
pipeline.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import time
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
16
|
+
from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
|
|
17
|
+
|
|
18
|
+
from pybinaryguard.models import (
|
|
19
|
+
Finding,
|
|
20
|
+
PackageBinaryInfo,
|
|
21
|
+
ScanMode,
|
|
22
|
+
ScanReport,
|
|
23
|
+
Severity,
|
|
24
|
+
SystemProfile,
|
|
25
|
+
WheelTag,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Scanner:
|
|
32
|
+
"""Orchestrates the full scan pipeline: probe -> analyze -> evaluate -> report.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
probes:
|
|
37
|
+
Override the default list of probes. When ``None``, all built-in
|
|
38
|
+
probes are used.
|
|
39
|
+
packages:
|
|
40
|
+
When set, restrict analysis to only these package names (case-
|
|
41
|
+
insensitive). ``None`` means scan every installed package.
|
|
42
|
+
severity_threshold:
|
|
43
|
+
Minimum severity to include in the final report. Findings below
|
|
44
|
+
this threshold are filtered out.
|
|
45
|
+
ignored_rules:
|
|
46
|
+
Set of rule IDs to skip during evaluation.
|
|
47
|
+
timeout:
|
|
48
|
+
Maximum wall-clock seconds for each probe's ``collect()`` call.
|
|
49
|
+
enable_plugins:
|
|
50
|
+
Whether to discover and load plugins via entry points and the
|
|
51
|
+
built-in contrib modules.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
probes: Optional[List[Any]] = None,
|
|
57
|
+
packages: Optional[List[str]] = None,
|
|
58
|
+
severity_threshold: Severity = Severity.INFO,
|
|
59
|
+
ignored_rules: Optional[Set[str]] = None,
|
|
60
|
+
timeout: float = 30.0,
|
|
61
|
+
enable_plugins: bool = True,
|
|
62
|
+
scan_mode: ScanMode = ScanMode.STANDARD,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._custom_probes = probes
|
|
65
|
+
self._packages = (
|
|
66
|
+
{p.lower() for p in packages} if packages else None
|
|
67
|
+
)
|
|
68
|
+
self._severity_threshold = severity_threshold
|
|
69
|
+
self._ignored_rules: Set[str] = ignored_rules or set()
|
|
70
|
+
self._timeout = timeout
|
|
71
|
+
self._enable_plugins = enable_plugins
|
|
72
|
+
self._scan_mode = scan_mode
|
|
73
|
+
|
|
74
|
+
# Lazily populated
|
|
75
|
+
self._profile: Optional[SystemProfile] = None
|
|
76
|
+
self._plugin_registry: Optional[Any] = None
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Public API
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def run(self) -> ScanReport:
|
|
83
|
+
"""Execute the full scan pipeline.
|
|
84
|
+
|
|
85
|
+
1. **PROBE** -- run all probes to build a :class:`SystemProfile`.
|
|
86
|
+
2. **ANALYZE** -- walk site-packages, discover packages, run ELF
|
|
87
|
+
analysis on shared objects.
|
|
88
|
+
3. **EVALUATE** -- apply compatibility rules and plugin framework
|
|
89
|
+
checkers to produce findings.
|
|
90
|
+
4. **REPORT** -- filter findings by severity, sort, and assemble
|
|
91
|
+
the :class:`ScanReport`.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
ScanReport
|
|
96
|
+
A complete scan report with all findings, package counts, and
|
|
97
|
+
timing metadata.
|
|
98
|
+
"""
|
|
99
|
+
start = time.monotonic()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# 1. PROBE PHASE
|
|
103
|
+
profile = self._run_probes()
|
|
104
|
+
self._profile = profile
|
|
105
|
+
|
|
106
|
+
# Fire pre-scan hooks
|
|
107
|
+
self._fire_pre_scan_hooks(profile)
|
|
108
|
+
|
|
109
|
+
# 2. ANALYZE PHASE
|
|
110
|
+
all_packages = self._discover_and_analyze_packages(profile)
|
|
111
|
+
|
|
112
|
+
# 3. EVALUATE PHASE
|
|
113
|
+
findings = self._evaluate_rules(profile, all_packages)
|
|
114
|
+
|
|
115
|
+
# 4. REPORT PHASE
|
|
116
|
+
report = self._build_report(
|
|
117
|
+
findings=findings,
|
|
118
|
+
packages_scanned=len(all_packages),
|
|
119
|
+
total_packages=self._count_total_packages(profile),
|
|
120
|
+
elapsed_ms=(time.monotonic() - start) * 1000.0,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
# Fire post-scan hooks
|
|
124
|
+
self._fire_post_scan_hooks(profile)
|
|
125
|
+
|
|
126
|
+
return report
|
|
127
|
+
except Exception as exc:
|
|
128
|
+
logger.error("Scan failed: %s", exc, exc_info=True)
|
|
129
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
130
|
+
return ScanReport(
|
|
131
|
+
findings=[
|
|
132
|
+
Finding(
|
|
133
|
+
rule_id="SCANNER_ERROR",
|
|
134
|
+
severity=Severity.CRITICAL,
|
|
135
|
+
title="Scanner encountered an error",
|
|
136
|
+
explanation=str(exc),
|
|
137
|
+
)
|
|
138
|
+
],
|
|
139
|
+
scan_duration_ms=elapsed_ms,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def check_package(self, package_name: str) -> List[Finding]:
|
|
143
|
+
"""Check a single package and return its findings.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
package_name:
|
|
148
|
+
The distribution name of the package to check.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
List[Finding]
|
|
153
|
+
All findings for the specified package, sorted by severity.
|
|
154
|
+
"""
|
|
155
|
+
profile = self._run_probes()
|
|
156
|
+
self._profile = profile
|
|
157
|
+
|
|
158
|
+
pkg_info = self._find_and_analyze_package(profile, package_name)
|
|
159
|
+
if pkg_info is None:
|
|
160
|
+
return [
|
|
161
|
+
Finding(
|
|
162
|
+
rule_id="PACKAGE_NOT_FOUND",
|
|
163
|
+
severity=Severity.INFO,
|
|
164
|
+
title=f"Package '{package_name}' not found",
|
|
165
|
+
explanation=(
|
|
166
|
+
f"Could not find '{package_name}' in any "
|
|
167
|
+
f"site-packages directory."
|
|
168
|
+
),
|
|
169
|
+
package=package_name,
|
|
170
|
+
)
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
findings = self._evaluate_rules(profile, [pkg_info])
|
|
174
|
+
return findings
|
|
175
|
+
|
|
176
|
+
def get_profile(self) -> SystemProfile:
|
|
177
|
+
"""Collect the system profile without running analysis or rules.
|
|
178
|
+
|
|
179
|
+
Returns
|
|
180
|
+
-------
|
|
181
|
+
SystemProfile
|
|
182
|
+
The current system's binary compatibility surface.
|
|
183
|
+
"""
|
|
184
|
+
profile = self._run_probes()
|
|
185
|
+
self._profile = profile
|
|
186
|
+
return profile
|
|
187
|
+
|
|
188
|
+
def inspect_file(self, file_path: str) -> List[Finding]:
|
|
189
|
+
"""Inspect a ``.whl`` or ``.so`` file against the current system.
|
|
190
|
+
|
|
191
|
+
Parameters
|
|
192
|
+
----------
|
|
193
|
+
file_path:
|
|
194
|
+
Absolute or relative path to a ``.whl`` or ``.so`` file.
|
|
195
|
+
|
|
196
|
+
Returns
|
|
197
|
+
-------
|
|
198
|
+
List[Finding]
|
|
199
|
+
Findings produced by inspecting the file.
|
|
200
|
+
|
|
201
|
+
Raises
|
|
202
|
+
------
|
|
203
|
+
FileNotFoundError
|
|
204
|
+
If *file_path* does not exist.
|
|
205
|
+
ValueError
|
|
206
|
+
If *file_path* is not a ``.whl`` or ``.so`` file.
|
|
207
|
+
"""
|
|
208
|
+
abs_path = os.path.abspath(file_path)
|
|
209
|
+
if not os.path.isfile(abs_path):
|
|
210
|
+
raise FileNotFoundError(f"File not found: {abs_path}")
|
|
211
|
+
|
|
212
|
+
profile = self._run_probes()
|
|
213
|
+
self._profile = profile
|
|
214
|
+
|
|
215
|
+
if abs_path.endswith(".whl"):
|
|
216
|
+
return self._inspect_wheel(abs_path, profile)
|
|
217
|
+
elif abs_path.endswith(".so") or ".so." in os.path.basename(abs_path):
|
|
218
|
+
return self._inspect_shared_object(abs_path, profile)
|
|
219
|
+
else:
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Unsupported file type: {abs_path!r}. "
|
|
222
|
+
f"Expected a .whl or .so file."
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# PROBE PHASE
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _get_all_probes(self) -> list:
|
|
230
|
+
"""Return the list of probes to execute."""
|
|
231
|
+
if self._custom_probes is not None:
|
|
232
|
+
return list(self._custom_probes)
|
|
233
|
+
|
|
234
|
+
from pybinaryguard.probes.board_probe import BoardProbe
|
|
235
|
+
from pybinaryguard.probes.cpu_probe import CpuProbe
|
|
236
|
+
from pybinaryguard.probes.glibc_probe import GlibcProbe
|
|
237
|
+
from pybinaryguard.probes.library_probe import LibraryProbe
|
|
238
|
+
from pybinaryguard.probes.os_probe import OsProbe
|
|
239
|
+
from pybinaryguard.probes.python_probe import PythonProbe
|
|
240
|
+
|
|
241
|
+
probes: list = [
|
|
242
|
+
PythonProbe(),
|
|
243
|
+
OsProbe(),
|
|
244
|
+
CpuProbe(),
|
|
245
|
+
GlibcProbe(),
|
|
246
|
+
LibraryProbe(),
|
|
247
|
+
BoardProbe(),
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
# Add plugin probes
|
|
251
|
+
registry = self._get_plugin_registry()
|
|
252
|
+
if registry is not None:
|
|
253
|
+
probes.extend(registry.probes)
|
|
254
|
+
|
|
255
|
+
return probes
|
|
256
|
+
|
|
257
|
+
def _run_probes(self) -> SystemProfile:
|
|
258
|
+
"""Execute all probes in parallel and merge results into a SystemProfile."""
|
|
259
|
+
if self._profile is not None:
|
|
260
|
+
return self._profile
|
|
261
|
+
|
|
262
|
+
probes = self._get_all_probes()
|
|
263
|
+
merged: Dict[str, Any] = {}
|
|
264
|
+
|
|
265
|
+
with ThreadPoolExecutor(max_workers=min(len(probes), 8)) as executor:
|
|
266
|
+
future_to_probe = {}
|
|
267
|
+
for probe in probes:
|
|
268
|
+
if not probe.is_applicable():
|
|
269
|
+
logger.debug("Skipping probe %s (not applicable)", probe.name)
|
|
270
|
+
continue
|
|
271
|
+
future = executor.submit(self._safe_collect, probe)
|
|
272
|
+
future_to_probe[future] = probe
|
|
273
|
+
|
|
274
|
+
for future in as_completed(future_to_probe):
|
|
275
|
+
probe = future_to_probe[future]
|
|
276
|
+
try:
|
|
277
|
+
result = future.result(timeout=self._timeout)
|
|
278
|
+
if result:
|
|
279
|
+
merged.update(result)
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
logger.debug(
|
|
282
|
+
"Probe %s failed: %s", probe.name, exc
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return self._build_profile(merged)
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _safe_collect(probe: Any) -> Dict[str, Any]:
|
|
289
|
+
"""Run a probe's collect() method, catching all exceptions."""
|
|
290
|
+
try:
|
|
291
|
+
return probe.collect()
|
|
292
|
+
except Exception as exc:
|
|
293
|
+
logger.debug("Probe %s raised: %s", probe.name, exc)
|
|
294
|
+
return {}
|
|
295
|
+
|
|
296
|
+
@staticmethod
|
|
297
|
+
def _build_profile(data: Dict[str, Any]) -> SystemProfile:
|
|
298
|
+
"""Construct a SystemProfile from the merged probe data.
|
|
299
|
+
|
|
300
|
+
Only fields that are valid SystemProfile attributes are passed through.
|
|
301
|
+
Unknown keys are silently ignored.
|
|
302
|
+
"""
|
|
303
|
+
valid_fields = {f.name for f in dataclasses.fields(SystemProfile)}
|
|
304
|
+
filtered = {}
|
|
305
|
+
for key, value in data.items():
|
|
306
|
+
if key in valid_fields and value is not None:
|
|
307
|
+
filtered[key] = value
|
|
308
|
+
return SystemProfile(**filtered)
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------
|
|
311
|
+
# ANALYZE PHASE
|
|
312
|
+
# ------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
def _discover_and_analyze_packages(
|
|
315
|
+
self, profile: SystemProfile
|
|
316
|
+
) -> List[PackageBinaryInfo]:
|
|
317
|
+
"""Walk site-packages directories and analyze each package's binaries.
|
|
318
|
+
|
|
319
|
+
The depth of analysis depends on ``self._scan_mode``:
|
|
320
|
+
|
|
321
|
+
- **FAST**: Only read WHEEL metadata and tags, skip ELF parsing.
|
|
322
|
+
- **STANDARD**: Parse ELF headers for arch, GLIBC, DT_NEEDED.
|
|
323
|
+
- **DEEP**: Full ELF analysis plus SHA256 hashing of all .so files.
|
|
324
|
+
"""
|
|
325
|
+
packages: List[PackageBinaryInfo] = []
|
|
326
|
+
seen_packages: Set[str] = set()
|
|
327
|
+
|
|
328
|
+
# Only import the ELF analyzer for STANDARD and DEEP modes
|
|
329
|
+
analyzer = None
|
|
330
|
+
if self._scan_mode != ScanMode.FAST:
|
|
331
|
+
from pybinaryguard.analyzers.elf_analyzer import ELFAnalyzer
|
|
332
|
+
analyzer = ELFAnalyzer()
|
|
333
|
+
|
|
334
|
+
for sp_path in profile.site_packages_paths:
|
|
335
|
+
if not os.path.isdir(sp_path):
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
entries = os.listdir(sp_path)
|
|
340
|
+
except OSError as exc:
|
|
341
|
+
logger.debug("Cannot list %s: %s", sp_path, exc)
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
for entry in entries:
|
|
345
|
+
if not entry.endswith(".dist-info"):
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
dist_info_path = os.path.join(sp_path, entry)
|
|
349
|
+
if not os.path.isdir(dist_info_path):
|
|
350
|
+
continue
|
|
351
|
+
|
|
352
|
+
pkg_name, pkg_version = self._parse_dist_info_name(entry)
|
|
353
|
+
if not pkg_name:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
# Deduplicate
|
|
357
|
+
pkg_key = pkg_name.lower()
|
|
358
|
+
if pkg_key in seen_packages:
|
|
359
|
+
continue
|
|
360
|
+
seen_packages.add(pkg_key)
|
|
361
|
+
|
|
362
|
+
# Filter if requested
|
|
363
|
+
if self._packages is not None and pkg_key not in self._packages:
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Determine the package's install path
|
|
367
|
+
install_path = self._find_package_install_path(
|
|
368
|
+
sp_path, dist_info_path, pkg_name
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
pkg_info = PackageBinaryInfo(
|
|
372
|
+
package_name=pkg_name,
|
|
373
|
+
package_version=pkg_version,
|
|
374
|
+
install_path=install_path,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
# Parse WHEEL file for tags (all modes)
|
|
378
|
+
wheel_tags = self._parse_wheel_file(dist_info_path)
|
|
379
|
+
if wheel_tags:
|
|
380
|
+
pkg_info.wheel_tags = wheel_tags
|
|
381
|
+
|
|
382
|
+
# FAST mode: detect .so presence without full parsing
|
|
383
|
+
if self._scan_mode == ScanMode.FAST:
|
|
384
|
+
has_so = self._has_shared_objects(install_path)
|
|
385
|
+
if has_so:
|
|
386
|
+
pkg_info.is_pure_python = False
|
|
387
|
+
elif self._packages is None:
|
|
388
|
+
continue
|
|
389
|
+
else:
|
|
390
|
+
# STANDARD and DEEP: run ELF analysis
|
|
391
|
+
pkg_info = analyzer.analyze(pkg_info)
|
|
392
|
+
|
|
393
|
+
# Skip pure-Python packages unless explicitly requested
|
|
394
|
+
if not pkg_info.has_binaries and self._packages is None:
|
|
395
|
+
continue
|
|
396
|
+
|
|
397
|
+
# DEEP mode: compute SHA256 hashes for all .so files
|
|
398
|
+
if self._scan_mode == ScanMode.DEEP and pkg_info.has_binaries:
|
|
399
|
+
self._compute_binary_hashes(pkg_info)
|
|
400
|
+
|
|
401
|
+
packages.append(pkg_info)
|
|
402
|
+
|
|
403
|
+
return packages
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _has_shared_objects(install_path: str) -> bool:
|
|
407
|
+
"""Quick check for .so files without parsing them (FAST mode)."""
|
|
408
|
+
if not os.path.isdir(install_path):
|
|
409
|
+
return False
|
|
410
|
+
try:
|
|
411
|
+
for root, _dirs, files in os.walk(install_path):
|
|
412
|
+
for fname in files:
|
|
413
|
+
if fname.endswith(".so") or ".so." in fname:
|
|
414
|
+
return True
|
|
415
|
+
except OSError:
|
|
416
|
+
pass
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
@staticmethod
|
|
420
|
+
def _compute_binary_hashes(pkg_info: PackageBinaryInfo) -> None:
|
|
421
|
+
"""Compute SHA256 hashes for all shared objects (DEEP mode)."""
|
|
422
|
+
import hashlib
|
|
423
|
+
|
|
424
|
+
for so in pkg_info.shared_objects:
|
|
425
|
+
if so.path and os.path.isfile(so.path):
|
|
426
|
+
sha256 = hashlib.sha256()
|
|
427
|
+
try:
|
|
428
|
+
with open(so.path, "rb") as f:
|
|
429
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
430
|
+
sha256.update(chunk)
|
|
431
|
+
so.sha256 = sha256.hexdigest()
|
|
432
|
+
except OSError:
|
|
433
|
+
pass
|
|
434
|
+
|
|
435
|
+
def _find_and_analyze_package(
|
|
436
|
+
self, profile: SystemProfile, package_name: str
|
|
437
|
+
) -> Optional[PackageBinaryInfo]:
|
|
438
|
+
"""Find and analyze a single named package."""
|
|
439
|
+
from pybinaryguard.analyzers.elf_analyzer import ELFAnalyzer
|
|
440
|
+
|
|
441
|
+
target = package_name.lower().replace("-", "_")
|
|
442
|
+
analyzer = ELFAnalyzer()
|
|
443
|
+
|
|
444
|
+
for sp_path in profile.site_packages_paths:
|
|
445
|
+
if not os.path.isdir(sp_path):
|
|
446
|
+
continue
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
entries = os.listdir(sp_path)
|
|
450
|
+
except OSError:
|
|
451
|
+
continue
|
|
452
|
+
|
|
453
|
+
for entry in entries:
|
|
454
|
+
if not entry.endswith(".dist-info"):
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
pkg_name, pkg_version = self._parse_dist_info_name(entry)
|
|
458
|
+
if not pkg_name:
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
if pkg_name.lower().replace("-", "_") != target:
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
dist_info_path = os.path.join(sp_path, entry)
|
|
465
|
+
install_path = self._find_package_install_path(
|
|
466
|
+
sp_path, dist_info_path, pkg_name
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
pkg_info = PackageBinaryInfo(
|
|
470
|
+
package_name=pkg_name,
|
|
471
|
+
package_version=pkg_version,
|
|
472
|
+
install_path=install_path,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
wheel_tags = self._parse_wheel_file(dist_info_path)
|
|
476
|
+
if wheel_tags:
|
|
477
|
+
pkg_info.wheel_tags = wheel_tags
|
|
478
|
+
|
|
479
|
+
pkg_info = analyzer.analyze(pkg_info)
|
|
480
|
+
return pkg_info
|
|
481
|
+
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
@staticmethod
|
|
485
|
+
def _parse_dist_info_name(dirname: str) -> Tuple[str, str]:
|
|
486
|
+
"""Parse a .dist-info directory name into (name, version).
|
|
487
|
+
|
|
488
|
+
Examples:
|
|
489
|
+
``"numpy-1.26.4.dist-info"`` -> ``("numpy", "1.26.4")``
|
|
490
|
+
``"Pillow-10.2.0.dist-info"`` -> ``("Pillow", "10.2.0")``
|
|
491
|
+
|
|
492
|
+
Returns ``("", "")`` if parsing fails.
|
|
493
|
+
"""
|
|
494
|
+
suffix = ".dist-info"
|
|
495
|
+
if not dirname.endswith(suffix):
|
|
496
|
+
return ("", "")
|
|
497
|
+
base = dirname[: -len(suffix)]
|
|
498
|
+
parts = base.split("-", 1)
|
|
499
|
+
if len(parts) != 2:
|
|
500
|
+
return ("", "")
|
|
501
|
+
return (parts[0], parts[1])
|
|
502
|
+
|
|
503
|
+
@staticmethod
|
|
504
|
+
def _find_package_install_path(
|
|
505
|
+
site_packages: str, dist_info_path: str, package_name: str
|
|
506
|
+
) -> str:
|
|
507
|
+
"""Determine the install path for a package.
|
|
508
|
+
|
|
509
|
+
Tries the RECORD file to find actual installed files, then falls back
|
|
510
|
+
to heuristic name matching.
|
|
511
|
+
"""
|
|
512
|
+
# Normalise: packages use underscores in directory names
|
|
513
|
+
normalised = package_name.lower().replace("-", "_")
|
|
514
|
+
|
|
515
|
+
# Check RECORD file for top-level package directory
|
|
516
|
+
record_path = os.path.join(dist_info_path, "RECORD")
|
|
517
|
+
if os.path.isfile(record_path):
|
|
518
|
+
try:
|
|
519
|
+
with open(record_path, "r") as fh:
|
|
520
|
+
for line in fh:
|
|
521
|
+
parts = line.strip().split(",", 1)
|
|
522
|
+
if not parts[0]:
|
|
523
|
+
continue
|
|
524
|
+
first_component = parts[0].split("/")[0]
|
|
525
|
+
candidate = os.path.join(site_packages, first_component)
|
|
526
|
+
if (
|
|
527
|
+
os.path.isdir(candidate)
|
|
528
|
+
and first_component.lower().replace("-", "_") == normalised
|
|
529
|
+
):
|
|
530
|
+
return candidate
|
|
531
|
+
except OSError:
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
# Heuristic: look for a directory matching the package name
|
|
535
|
+
candidate = os.path.join(site_packages, normalised)
|
|
536
|
+
if os.path.isdir(candidate):
|
|
537
|
+
return candidate
|
|
538
|
+
|
|
539
|
+
# Try the original casing
|
|
540
|
+
candidate = os.path.join(site_packages, package_name)
|
|
541
|
+
if os.path.isdir(candidate):
|
|
542
|
+
return candidate
|
|
543
|
+
|
|
544
|
+
# Fall back to the site-packages root itself
|
|
545
|
+
return site_packages
|
|
546
|
+
|
|
547
|
+
@staticmethod
|
|
548
|
+
def _parse_wheel_file(dist_info_path: str) -> List[WheelTag]:
|
|
549
|
+
"""Parse the WHEEL metadata file for compatibility tags."""
|
|
550
|
+
wheel_path = os.path.join(dist_info_path, "WHEEL")
|
|
551
|
+
if not os.path.isfile(wheel_path):
|
|
552
|
+
return []
|
|
553
|
+
|
|
554
|
+
tags: List[WheelTag] = []
|
|
555
|
+
try:
|
|
556
|
+
with open(wheel_path, "r") as fh:
|
|
557
|
+
for line in fh:
|
|
558
|
+
line = line.strip()
|
|
559
|
+
if line.startswith("Tag:"):
|
|
560
|
+
tag_str = line[4:].strip()
|
|
561
|
+
parts = tag_str.split("-")
|
|
562
|
+
if len(parts) == 3:
|
|
563
|
+
tags.append(WheelTag(
|
|
564
|
+
interpreter=parts[0],
|
|
565
|
+
abi=parts[1],
|
|
566
|
+
platform=parts[2],
|
|
567
|
+
))
|
|
568
|
+
except OSError:
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
return tags
|
|
572
|
+
|
|
573
|
+
def _count_total_packages(self, profile: SystemProfile) -> int:
|
|
574
|
+
"""Count total .dist-info directories across all site-packages."""
|
|
575
|
+
count = 0
|
|
576
|
+
seen: Set[str] = set()
|
|
577
|
+
for sp_path in profile.site_packages_paths:
|
|
578
|
+
if not os.path.isdir(sp_path):
|
|
579
|
+
continue
|
|
580
|
+
try:
|
|
581
|
+
for entry in os.listdir(sp_path):
|
|
582
|
+
if entry.endswith(".dist-info"):
|
|
583
|
+
pkg_name, _ = self._parse_dist_info_name(entry)
|
|
584
|
+
if pkg_name:
|
|
585
|
+
key = pkg_name.lower()
|
|
586
|
+
if key not in seen:
|
|
587
|
+
seen.add(key)
|
|
588
|
+
count += 1
|
|
589
|
+
except OSError:
|
|
590
|
+
continue
|
|
591
|
+
return count
|
|
592
|
+
|
|
593
|
+
# ------------------------------------------------------------------
|
|
594
|
+
# EVALUATE PHASE
|
|
595
|
+
# ------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
# Rule IDs that require ELF analysis (skip in FAST mode)
|
|
598
|
+
_ELF_DEPENDENT_RULES: FrozenSet[str] = frozenset({
|
|
599
|
+
"GLIBC_VERSION_MISMATCH",
|
|
600
|
+
"GLIBC_SYMBOL_MISSING",
|
|
601
|
+
"MUSL_GLIBC_CONFLICT",
|
|
602
|
+
"MANYLINUX_TAG_VIOLATION",
|
|
603
|
+
"MISSING_SHARED_LIB",
|
|
604
|
+
"LIBSTDCXX_TOO_OLD",
|
|
605
|
+
"NUMPY_ABI_MISMATCH",
|
|
606
|
+
"ILLEGAL_INSTRUCTION_RISK",
|
|
607
|
+
"AVX2_REQUIRED",
|
|
608
|
+
"AVX512_REQUIRED",
|
|
609
|
+
"PREDICTED_IMPORT_ERROR",
|
|
610
|
+
"UNRESOLVED_DEPENDENCY_CHAIN",
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
def _evaluate_rules(
|
|
614
|
+
self,
|
|
615
|
+
profile: SystemProfile,
|
|
616
|
+
packages: List[PackageBinaryInfo],
|
|
617
|
+
) -> List[Finding]:
|
|
618
|
+
"""Run all rules (built-in + plugin) and return findings.
|
|
619
|
+
|
|
620
|
+
In FAST mode, rules that depend on ELF analysis data are skipped.
|
|
621
|
+
"""
|
|
622
|
+
from pybinaryguard.rules.engine import RuleEngine
|
|
623
|
+
|
|
624
|
+
# In FAST mode, add ELF-dependent rules to the ignore list
|
|
625
|
+
ignored = set(self._ignored_rules)
|
|
626
|
+
if self._scan_mode == ScanMode.FAST:
|
|
627
|
+
ignored.update(self._ELF_DEPENDENT_RULES)
|
|
628
|
+
|
|
629
|
+
engine = RuleEngine.with_builtin_rules(ignored_rules=ignored)
|
|
630
|
+
|
|
631
|
+
# Add plugin rules
|
|
632
|
+
registry = self._get_plugin_registry()
|
|
633
|
+
if registry is not None:
|
|
634
|
+
for rule in registry.rules:
|
|
635
|
+
engine.register(rule)
|
|
636
|
+
|
|
637
|
+
findings = engine.evaluate(profile, packages)
|
|
638
|
+
|
|
639
|
+
# Run plugin framework checkers (skip in FAST mode)
|
|
640
|
+
if self._scan_mode != ScanMode.FAST and registry is not None:
|
|
641
|
+
for checker in registry.framework_checkers:
|
|
642
|
+
try:
|
|
643
|
+
extra = checker(profile, packages)
|
|
644
|
+
if extra:
|
|
645
|
+
findings.extend(extra)
|
|
646
|
+
except Exception as exc:
|
|
647
|
+
logger.debug("Framework checker failed: %s", exc)
|
|
648
|
+
|
|
649
|
+
return findings
|
|
650
|
+
|
|
651
|
+
# ------------------------------------------------------------------
|
|
652
|
+
# REPORT PHASE
|
|
653
|
+
# ------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
def _build_report(
|
|
656
|
+
self,
|
|
657
|
+
findings: List[Finding],
|
|
658
|
+
packages_scanned: int,
|
|
659
|
+
total_packages: int,
|
|
660
|
+
elapsed_ms: float,
|
|
661
|
+
) -> ScanReport:
|
|
662
|
+
"""Filter, sort, and assemble the final ScanReport."""
|
|
663
|
+
from pybinaryguard.diagnostics.findings import (
|
|
664
|
+
deduplicate_findings,
|
|
665
|
+
filter_findings,
|
|
666
|
+
sort_findings,
|
|
667
|
+
)
|
|
668
|
+
from pybinaryguard.profiles import match_board_profile
|
|
669
|
+
from pybinaryguard.scoring import compute_health_score
|
|
670
|
+
|
|
671
|
+
# Deduplicate
|
|
672
|
+
findings = deduplicate_findings(findings)
|
|
673
|
+
|
|
674
|
+
# Compute v2 health score (before filtering — uses all findings)
|
|
675
|
+
has_gpu = self._profile.gpu_available if self._profile else False
|
|
676
|
+
is_embedded = self._profile.is_embedded_board if self._profile else False
|
|
677
|
+
score_breakdown = compute_health_score(
|
|
678
|
+
findings, has_gpu=has_gpu, is_embedded=is_embedded
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
# Filter by severity threshold
|
|
682
|
+
findings = filter_findings(findings, self._severity_threshold)
|
|
683
|
+
|
|
684
|
+
# Sort: CRITICAL first
|
|
685
|
+
findings = sort_findings(findings)
|
|
686
|
+
|
|
687
|
+
# Detect board profile if available
|
|
688
|
+
detected_board = None
|
|
689
|
+
if self._profile:
|
|
690
|
+
board_profile = match_board_profile(self._profile)
|
|
691
|
+
if board_profile:
|
|
692
|
+
detected_board = board_profile.display_name
|
|
693
|
+
|
|
694
|
+
return ScanReport(
|
|
695
|
+
findings=findings,
|
|
696
|
+
packages_scanned=packages_scanned,
|
|
697
|
+
total_packages=total_packages,
|
|
698
|
+
scan_duration_ms=elapsed_ms,
|
|
699
|
+
detected_board=detected_board,
|
|
700
|
+
score_breakdown=score_breakdown,
|
|
701
|
+
)
|
|
702
|
+
|
|
703
|
+
# ------------------------------------------------------------------
|
|
704
|
+
# INSPECT helpers
|
|
705
|
+
# ------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
def _inspect_wheel(
|
|
708
|
+
self, wheel_path: str, profile: SystemProfile
|
|
709
|
+
) -> List[Finding]:
|
|
710
|
+
"""Inspect a .whl file by extracting and analyzing its contents."""
|
|
711
|
+
import tempfile
|
|
712
|
+
import zipfile
|
|
713
|
+
|
|
714
|
+
findings: List[Finding] = []
|
|
715
|
+
|
|
716
|
+
if not zipfile.is_zipfile(wheel_path):
|
|
717
|
+
findings.append(Finding(
|
|
718
|
+
rule_id="INVALID_WHEEL",
|
|
719
|
+
severity=Severity.CRITICAL,
|
|
720
|
+
title="Invalid wheel file",
|
|
721
|
+
explanation=f"The file '{wheel_path}' is not a valid ZIP/wheel archive.",
|
|
722
|
+
))
|
|
723
|
+
return findings
|
|
724
|
+
|
|
725
|
+
with tempfile.TemporaryDirectory(prefix="pybinaryguard_") as tmpdir:
|
|
726
|
+
try:
|
|
727
|
+
with zipfile.ZipFile(wheel_path, "r") as zf:
|
|
728
|
+
zf.extractall(tmpdir)
|
|
729
|
+
except zipfile.BadZipFile as exc:
|
|
730
|
+
findings.append(Finding(
|
|
731
|
+
rule_id="INVALID_WHEEL",
|
|
732
|
+
severity=Severity.CRITICAL,
|
|
733
|
+
title="Corrupt wheel file",
|
|
734
|
+
explanation=f"Cannot extract '{wheel_path}': {exc}",
|
|
735
|
+
))
|
|
736
|
+
return findings
|
|
737
|
+
|
|
738
|
+
# Parse wheel filename for package metadata
|
|
739
|
+
basename = os.path.basename(wheel_path)
|
|
740
|
+
name, version = self._parse_wheel_filename(basename)
|
|
741
|
+
|
|
742
|
+
pkg_info = PackageBinaryInfo(
|
|
743
|
+
package_name=name or basename,
|
|
744
|
+
package_version=version or "unknown",
|
|
745
|
+
install_path=tmpdir,
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
# Parse wheel tags from filename
|
|
749
|
+
wheel_tags = self._tags_from_wheel_filename(basename)
|
|
750
|
+
if wheel_tags:
|
|
751
|
+
pkg_info.wheel_tags = wheel_tags
|
|
752
|
+
|
|
753
|
+
# Run ELF analysis
|
|
754
|
+
from pybinaryguard.analyzers.elf_analyzer import ELFAnalyzer
|
|
755
|
+
|
|
756
|
+
analyzer = ELFAnalyzer()
|
|
757
|
+
pkg_info = analyzer.analyze(pkg_info)
|
|
758
|
+
|
|
759
|
+
if not pkg_info.has_binaries:
|
|
760
|
+
findings.append(Finding(
|
|
761
|
+
rule_id="PURE_PYTHON_WHEEL",
|
|
762
|
+
severity=Severity.PASSED,
|
|
763
|
+
title=f"{pkg_info.package_name} is pure Python",
|
|
764
|
+
explanation="No compiled extensions found in this wheel.",
|
|
765
|
+
package=pkg_info.package_name,
|
|
766
|
+
package_version=pkg_info.package_version,
|
|
767
|
+
))
|
|
768
|
+
return findings
|
|
769
|
+
|
|
770
|
+
# Evaluate rules against this package
|
|
771
|
+
findings.extend(self._evaluate_rules(profile, [pkg_info]))
|
|
772
|
+
|
|
773
|
+
return findings
|
|
774
|
+
|
|
775
|
+
def _inspect_shared_object(
|
|
776
|
+
self, so_path: str, profile: SystemProfile
|
|
777
|
+
) -> List[Finding]:
|
|
778
|
+
"""Inspect a single .so file against the current system."""
|
|
779
|
+
from pybinaryguard.analyzers.elf_analyzer import ELFAnalyzer, ELFParseError
|
|
780
|
+
|
|
781
|
+
findings: List[Finding] = []
|
|
782
|
+
basename = os.path.basename(so_path)
|
|
783
|
+
|
|
784
|
+
pkg_info = PackageBinaryInfo(
|
|
785
|
+
package_name=basename,
|
|
786
|
+
package_version="",
|
|
787
|
+
install_path=os.path.dirname(so_path),
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
analyzer = ELFAnalyzer()
|
|
791
|
+
try:
|
|
792
|
+
so_info = analyzer._parse_so(so_path)
|
|
793
|
+
except Exception as exc:
|
|
794
|
+
findings.append(Finding(
|
|
795
|
+
rule_id="ELF_PARSE_ERROR",
|
|
796
|
+
severity=Severity.CRITICAL,
|
|
797
|
+
title=f"Cannot parse '{basename}'",
|
|
798
|
+
explanation=str(exc),
|
|
799
|
+
))
|
|
800
|
+
return findings
|
|
801
|
+
|
|
802
|
+
if so_info is None:
|
|
803
|
+
findings.append(Finding(
|
|
804
|
+
rule_id="ELF_PARSE_ERROR",
|
|
805
|
+
severity=Severity.CRITICAL,
|
|
806
|
+
title=f"'{basename}' is not a valid ELF binary",
|
|
807
|
+
explanation="The file could not be parsed as an ELF shared object.",
|
|
808
|
+
))
|
|
809
|
+
return findings
|
|
810
|
+
|
|
811
|
+
pkg_info.shared_objects = [so_info]
|
|
812
|
+
pkg_info.is_pure_python = False
|
|
813
|
+
if so_info.required_glibc is not None:
|
|
814
|
+
pkg_info.required_glibc = so_info.required_glibc
|
|
815
|
+
if so_info.architecture is not None:
|
|
816
|
+
pkg_info.target_architecture = so_info.architecture
|
|
817
|
+
pkg_info.required_libraries = set(so_info.dt_needed)
|
|
818
|
+
|
|
819
|
+
findings.extend(self._evaluate_rules(profile, [pkg_info]))
|
|
820
|
+
return findings
|
|
821
|
+
|
|
822
|
+
@staticmethod
|
|
823
|
+
def _parse_wheel_filename(filename: str) -> Tuple[str, str]:
|
|
824
|
+
"""Parse a wheel filename to extract name and version.
|
|
825
|
+
|
|
826
|
+
Wheel filenames follow the pattern:
|
|
827
|
+
``{name}-{version}(-{build})?-{python}-{abi}-{platform}.whl``
|
|
828
|
+
|
|
829
|
+
Returns ``("", "")`` if parsing fails.
|
|
830
|
+
"""
|
|
831
|
+
if not filename.endswith(".whl"):
|
|
832
|
+
return ("", "")
|
|
833
|
+
base = filename[:-4]
|
|
834
|
+
parts = base.split("-")
|
|
835
|
+
if len(parts) < 5:
|
|
836
|
+
return ("", "")
|
|
837
|
+
return (parts[0], parts[1])
|
|
838
|
+
|
|
839
|
+
@staticmethod
|
|
840
|
+
def _tags_from_wheel_filename(filename: str) -> List[WheelTag]:
|
|
841
|
+
"""Extract compatibility tags from a wheel filename."""
|
|
842
|
+
if not filename.endswith(".whl"):
|
|
843
|
+
return []
|
|
844
|
+
base = filename[:-4]
|
|
845
|
+
parts = base.split("-")
|
|
846
|
+
if len(parts) < 5:
|
|
847
|
+
return []
|
|
848
|
+
# Last three components are python-abi-platform
|
|
849
|
+
python_tag = parts[-3]
|
|
850
|
+
abi_tag = parts[-2]
|
|
851
|
+
platform_tag = parts[-1]
|
|
852
|
+
# Platform may contain multiple tags separated by "."
|
|
853
|
+
tags: List[WheelTag] = []
|
|
854
|
+
for plat in platform_tag.split("."):
|
|
855
|
+
tags.append(WheelTag(
|
|
856
|
+
interpreter=python_tag,
|
|
857
|
+
abi=abi_tag,
|
|
858
|
+
platform=plat,
|
|
859
|
+
))
|
|
860
|
+
return tags
|
|
861
|
+
|
|
862
|
+
# ------------------------------------------------------------------
|
|
863
|
+
# Plugin helpers
|
|
864
|
+
# ------------------------------------------------------------------
|
|
865
|
+
|
|
866
|
+
def _get_plugin_registry(self) -> Optional[Any]:
|
|
867
|
+
"""Lazily discover and cache the plugin registry."""
|
|
868
|
+
if not self._enable_plugins:
|
|
869
|
+
return None
|
|
870
|
+
|
|
871
|
+
if self._plugin_registry is not None:
|
|
872
|
+
return self._plugin_registry
|
|
873
|
+
|
|
874
|
+
try:
|
|
875
|
+
from pybinaryguard.plugins.loader import discover_plugins
|
|
876
|
+
|
|
877
|
+
self._plugin_registry = discover_plugins()
|
|
878
|
+
except Exception as exc:
|
|
879
|
+
logger.debug("Plugin discovery failed: %s", exc)
|
|
880
|
+
self._plugin_registry = None
|
|
881
|
+
|
|
882
|
+
return self._plugin_registry
|
|
883
|
+
|
|
884
|
+
def _fire_pre_scan_hooks(self, profile: SystemProfile) -> None:
|
|
885
|
+
"""Execute all registered pre-scan hooks."""
|
|
886
|
+
registry = self._get_plugin_registry()
|
|
887
|
+
if registry is None:
|
|
888
|
+
return
|
|
889
|
+
for hook in registry.pre_scan_hooks:
|
|
890
|
+
try:
|
|
891
|
+
hook(profile)
|
|
892
|
+
except Exception as exc:
|
|
893
|
+
logger.debug("Pre-scan hook failed: %s", exc)
|
|
894
|
+
|
|
895
|
+
def _fire_post_scan_hooks(self, profile: SystemProfile) -> None:
|
|
896
|
+
"""Execute all registered post-scan hooks."""
|
|
897
|
+
registry = self._get_plugin_registry()
|
|
898
|
+
if registry is None:
|
|
899
|
+
return
|
|
900
|
+
for hook in registry.post_scan_hooks:
|
|
901
|
+
try:
|
|
902
|
+
hook(profile)
|
|
903
|
+
except Exception as exc:
|
|
904
|
+
logger.debug("Post-scan hook failed: %s", exc)
|