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.
Files changed (91) hide show
  1. pybinaryguard/__init__.py +78 -0
  2. pybinaryguard/__main__.py +7 -0
  3. pybinaryguard/_compat/__init__.py +0 -0
  4. pybinaryguard/agent/__init__.py +63 -0
  5. pybinaryguard/agent/guard.py +232 -0
  6. pybinaryguard/agent/recommender.py +283 -0
  7. pybinaryguard/agent/schema.py +200 -0
  8. pybinaryguard/agent/simulator.py +430 -0
  9. pybinaryguard/agent/tool_interface.py +474 -0
  10. pybinaryguard/analyzers/__init__.py +209 -0
  11. pybinaryguard/analyzers/base.py +40 -0
  12. pybinaryguard/analyzers/dependency_analyzer.py +336 -0
  13. pybinaryguard/analyzers/elf_analyzer.py +754 -0
  14. pybinaryguard/analyzers/symbol_analyzer.py +280 -0
  15. pybinaryguard/analyzers/wheel_analyzer.py +308 -0
  16. pybinaryguard/cli/__init__.py +5 -0
  17. pybinaryguard/cli/commands.py +414 -0
  18. pybinaryguard/cli/formatters.py +720 -0
  19. pybinaryguard/cli/main.py +250 -0
  20. pybinaryguard/diagnostics/__init__.py +13 -0
  21. pybinaryguard/diagnostics/explainer.py +356 -0
  22. pybinaryguard/diagnostics/findings.py +146 -0
  23. pybinaryguard/diagnostics/suggestions.py +508 -0
  24. pybinaryguard/frameworks/__init__.py +20 -0
  25. pybinaryguard/frameworks/onnxruntime.py +214 -0
  26. pybinaryguard/frameworks/pytorch.py +223 -0
  27. pybinaryguard/frameworks/tensorflow.py +266 -0
  28. pybinaryguard/frameworks/tensorrt.py +189 -0
  29. pybinaryguard/models/__init__.py +19 -0
  30. pybinaryguard/models/enums.py +102 -0
  31. pybinaryguard/models/finding.py +109 -0
  32. pybinaryguard/models/package.py +121 -0
  33. pybinaryguard/models/system.py +118 -0
  34. pybinaryguard/plugins/__init__.py +19 -0
  35. pybinaryguard/plugins/contrib/__init__.py +7 -0
  36. pybinaryguard/plugins/contrib/gstreamer.py +109 -0
  37. pybinaryguard/plugins/contrib/jetson.py +385 -0
  38. pybinaryguard/plugins/contrib/opencv.py +306 -0
  39. pybinaryguard/plugins/contrib/tensorrt.py +426 -0
  40. pybinaryguard/plugins/hooks.py +273 -0
  41. pybinaryguard/plugins/loader.py +190 -0
  42. pybinaryguard/predictor/__init__.py +23 -0
  43. pybinaryguard/predictor/dependency_graph.py +226 -0
  44. pybinaryguard/predictor/linker_simulator.py +161 -0
  45. pybinaryguard/predictor/predictor.py +144 -0
  46. pybinaryguard/predictor/resolver.py +282 -0
  47. pybinaryguard/probes/__init__.py +73 -0
  48. pybinaryguard/probes/base.py +45 -0
  49. pybinaryguard/probes/board_probe.py +248 -0
  50. pybinaryguard/probes/cpu_probe.py +176 -0
  51. pybinaryguard/probes/glibc_probe.py +215 -0
  52. pybinaryguard/probes/gpu_probe.py +430 -0
  53. pybinaryguard/probes/library_probe.py +132 -0
  54. pybinaryguard/probes/os_probe.py +227 -0
  55. pybinaryguard/probes/python_probe.py +135 -0
  56. pybinaryguard/probes/toolchain_probe.py +89 -0
  57. pybinaryguard/probes/venv_probe.py +111 -0
  58. pybinaryguard/profiles/__init__.py +12 -0
  59. pybinaryguard/profiles/engine.py +343 -0
  60. pybinaryguard/rules/__init__.py +24 -0
  61. pybinaryguard/rules/base.py +57 -0
  62. pybinaryguard/rules/builtin/__init__.py +136 -0
  63. pybinaryguard/rules/builtin/arch_rules.py +86 -0
  64. pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
  65. pybinaryguard/rules/builtin/container_rules.py +170 -0
  66. pybinaryguard/rules/builtin/cpu_rules.py +204 -0
  67. pybinaryguard/rules/builtin/cuda_rules.py +760 -0
  68. pybinaryguard/rules/builtin/dependency_rules.py +278 -0
  69. pybinaryguard/rules/builtin/framework_rules.py +252 -0
  70. pybinaryguard/rules/builtin/glibc_rules.py +320 -0
  71. pybinaryguard/rules/builtin/numpy_rules.py +110 -0
  72. pybinaryguard/rules/builtin/predictive_rules.py +165 -0
  73. pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
  74. pybinaryguard/rules/builtin/source_build_rules.py +150 -0
  75. pybinaryguard/rules/builtin/venv_rules.py +159 -0
  76. pybinaryguard/rules/engine.py +123 -0
  77. pybinaryguard/scanner.py +904 -0
  78. pybinaryguard/scoring/__init__.py +19 -0
  79. pybinaryguard/scoring/engine.py +416 -0
  80. pybinaryguard/snapshot/__init__.py +20 -0
  81. pybinaryguard/snapshot/generator.py +168 -0
  82. pybinaryguard/snapshot/lockfile.py +152 -0
  83. pybinaryguard/snapshot/verifier.py +315 -0
  84. pybinaryguard/validators/__init__.py +7 -0
  85. pybinaryguard/validators/import_validator.py +231 -0
  86. pybinaryguard-1.0.0.dist-info/METADATA +876 -0
  87. pybinaryguard-1.0.0.dist-info/RECORD +91 -0
  88. pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
  89. pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
  90. pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
  91. pybinaryguard-1.0.0.dist-info/top_level.txt +1 -0
@@ -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)