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,259 @@
1
+ """Python ABI and version compatibility rules.
2
+
3
+ These rules detect mismatches between the Python interpreter running on the
4
+ host and the Python ABI / version that installed binary packages were built
5
+ for. Such mismatches typically result in ``ImportError`` or segfaults at
6
+ import time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import List, Optional, Tuple
13
+
14
+ from pybinaryguard.models.enums import Severity
15
+ from pybinaryguard.models.finding import Finding
16
+ from pybinaryguard.models.package import PackageBinaryInfo, WheelTag
17
+ from pybinaryguard.models.system import SystemProfile
18
+ from pybinaryguard.rules.base import Rule
19
+
20
+
21
+ def _extract_interpreter_version(tag: str) -> Optional[Tuple[int, int]]:
22
+ """Extract (major, minor) from an interpreter tag like ``'cp312'``.
23
+
24
+ Returns ``None`` if the tag cannot be parsed.
25
+ """
26
+ match = re.match(r"^(?:cp|pp|ip|jy)(\d)(\d+)$", tag)
27
+ if match:
28
+ return (int(match.group(1)), int(match.group(2)))
29
+ return None
30
+
31
+
32
+ def _system_interpreter_tag(profile: SystemProfile) -> str:
33
+ """Derive the expected CPython interpreter tag from the system profile.
34
+
35
+ For CPython 3.12 this is ``'cp312'``.
36
+ """
37
+ major, minor = profile.python_version[0], profile.python_version[1]
38
+ return f"cp{major}{minor}"
39
+
40
+
41
+ class PythonABIMismatchRule(Rule):
42
+ """Detects packages built for a different CPython ABI tag.
43
+
44
+ A CPython extension module is compiled against a specific C API and
45
+ ABI, identified by a tag like ``cp312``. Loading an extension built
46
+ for ``cp310`` into a ``cp312`` interpreter may segfault or raise an
47
+ ``ImportError`` because the internal struct layouts differ.
48
+ """
49
+
50
+ rule_id = "PYTHON_ABI_MISMATCH"
51
+ description = (
52
+ "Check that each package's wheel ABI tag matches the running "
53
+ "Python interpreter's ABI."
54
+ )
55
+
56
+ def evaluate(
57
+ self,
58
+ profile: SystemProfile,
59
+ packages: List[PackageBinaryInfo],
60
+ ) -> List[Finding]:
61
+ findings: List[Finding] = []
62
+ if not profile.python_abi_tag:
63
+ return findings
64
+
65
+ sys_abi = profile.python_abi_tag # e.g. "cp312"
66
+
67
+ for pkg in packages:
68
+ if pkg.is_pure_python or not pkg.wheel_tags:
69
+ continue
70
+ for tag in pkg.wheel_tags:
71
+ # Skip universal tags that work on any interpreter.
72
+ if tag.abi in ("none", "abi3"):
73
+ continue
74
+ if tag.abi != sys_abi:
75
+ findings.append(
76
+ Finding(
77
+ rule_id=self.rule_id,
78
+ severity=Severity.CRITICAL,
79
+ title=(
80
+ f"{pkg.package_name} was built for a "
81
+ f"different Python ABI"
82
+ ),
83
+ explanation=(
84
+ f"Package {pkg.package_name} "
85
+ f"{pkg.package_version} was compiled for "
86
+ f"the '{tag.abi}' ABI but your Python "
87
+ f"interpreter uses '{sys_abi}'. Compiled "
88
+ f"extensions are not interchangeable between "
89
+ f"Python ABI versions because internal data "
90
+ f"structures change. Importing this package "
91
+ f"will raise an ImportError or cause a "
92
+ f"segmentation fault."
93
+ ),
94
+ technical_detail=(
95
+ f"Wheel ABI tag: {tag.abi}, "
96
+ f"System ABI tag: {sys_abi}"
97
+ ),
98
+ suggestion=(
99
+ f"Reinstall the package so pip fetches the "
100
+ f"correct wheel for your interpreter:\n"
101
+ f" pip install --force-reinstall "
102
+ f"{pkg.package_name}=={pkg.package_version}"
103
+ ),
104
+ package=pkg.package_name,
105
+ package_version=pkg.package_version,
106
+ )
107
+ )
108
+ # One finding per package is enough.
109
+ break
110
+ return findings
111
+
112
+
113
+ class PythonVersionMismatchRule(Rule):
114
+ """Detects packages whose interpreter tag targets a different Python.
115
+
116
+ Even without a strict ABI mismatch, a wheel tagged ``cp310`` that is
117
+ loaded into Python 3.12 is suspect. This rule checks the interpreter
118
+ field of wheel tags against the running Python version.
119
+ """
120
+
121
+ rule_id = "PYTHON_VERSION_MISMATCH"
122
+ description = (
123
+ "Check that each package's wheel interpreter tag matches the "
124
+ "running Python version."
125
+ )
126
+
127
+ def evaluate(
128
+ self,
129
+ profile: SystemProfile,
130
+ packages: List[PackageBinaryInfo],
131
+ ) -> List[Finding]:
132
+ findings: List[Finding] = []
133
+ sys_tag = _system_interpreter_tag(profile)
134
+ sys_ver = (profile.python_version[0], profile.python_version[1])
135
+
136
+ for pkg in packages:
137
+ if pkg.is_pure_python or not pkg.wheel_tags:
138
+ continue
139
+ for tag in pkg.wheel_tags:
140
+ # Universal / stable-ABI wheels work everywhere.
141
+ if tag.interpreter in ("py3", "py2.py3"):
142
+ continue
143
+ if tag.abi == "abi3":
144
+ continue
145
+ wheel_ver = _extract_interpreter_version(tag.interpreter)
146
+ if wheel_ver is None:
147
+ continue
148
+ if wheel_ver != sys_ver:
149
+ findings.append(
150
+ Finding(
151
+ rule_id=self.rule_id,
152
+ severity=Severity.CRITICAL,
153
+ title=(
154
+ f"{pkg.package_name} targets Python "
155
+ f"{wheel_ver[0]}.{wheel_ver[1]}"
156
+ ),
157
+ explanation=(
158
+ f"Package {pkg.package_name} "
159
+ f"{pkg.package_version} was built for "
160
+ f"Python {wheel_ver[0]}.{wheel_ver[1]} "
161
+ f"(tag '{tag.interpreter}') but you are "
162
+ f"running Python {sys_ver[0]}.{sys_ver[1]}. "
163
+ f"CPython compiled extensions are version-"
164
+ f"specific and cannot be shared across "
165
+ f"different Python minor versions."
166
+ ),
167
+ technical_detail=(
168
+ f"Wheel interpreter tag: {tag.interpreter}, "
169
+ f"System Python: {sys_tag}"
170
+ ),
171
+ suggestion=(
172
+ f"Reinstall with the correct Python version:\n"
173
+ f" pip install --force-reinstall "
174
+ f"{pkg.package_name}=={pkg.package_version}\n\n"
175
+ f"If you need a specific Python version, "
176
+ f"create a virtual environment:\n"
177
+ f" python{wheel_ver[0]}.{wheel_ver[1]} -m "
178
+ f"venv .venv && source .venv/bin/activate"
179
+ ),
180
+ package=pkg.package_name,
181
+ package_version=pkg.package_version,
182
+ )
183
+ )
184
+ break
185
+ return findings
186
+
187
+
188
+ class DebugReleaseMixRule(Rule):
189
+ """Detects mixing debug and release Python builds.
190
+
191
+ CPython can be compiled with ``--with-pydebug``, which changes the
192
+ sizes of internal objects (adding ref-count fields, etc.). Loading a
193
+ release-mode extension into a debug interpreter -- or vice versa --
194
+ will corrupt memory and is likely to segfault.
195
+ """
196
+
197
+ rule_id = "DEBUG_RELEASE_MIX"
198
+ description = (
199
+ "Warn when the Python build type (debug/release) does not match "
200
+ "the installed extensions."
201
+ )
202
+
203
+ def evaluate(
204
+ self,
205
+ profile: SystemProfile,
206
+ packages: List[PackageBinaryInfo],
207
+ ) -> List[Finding]:
208
+ findings: List[Finding] = []
209
+ if not profile.python_debug_build:
210
+ # Release builds are the common case; extensions are almost
211
+ # always built in release mode so there is nothing to flag.
212
+ return findings
213
+
214
+ for pkg in packages:
215
+ if pkg.is_pure_python or not pkg.wheel_tags:
216
+ continue
217
+ for tag in pkg.wheel_tags:
218
+ # A debug-built Python uses an ABI tag ending with 'd'
219
+ # (e.g. "cp312d"). If the wheel's abi tag does NOT end
220
+ # with 'd', it is a release wheel.
221
+ if tag.abi in ("none", "abi3"):
222
+ continue
223
+ if not tag.abi.endswith("d"):
224
+ findings.append(
225
+ Finding(
226
+ rule_id=self.rule_id,
227
+ severity=Severity.WARNING,
228
+ title=(
229
+ f"{pkg.package_name} is a release build "
230
+ f"on a debug interpreter"
231
+ ),
232
+ explanation=(
233
+ f"Your Python interpreter was compiled "
234
+ f"with --with-pydebug (debug build), but "
235
+ f"{pkg.package_name} {pkg.package_version} "
236
+ f"is a release-mode extension (ABI tag "
237
+ f"'{tag.abi}'). Debug and release builds "
238
+ f"use different internal struct layouts, so "
239
+ f"mixing them can cause crashes or "
240
+ f"memory corruption."
241
+ ),
242
+ technical_detail=(
243
+ f"System: debug build, "
244
+ f"Wheel ABI: {tag.abi} (release)"
245
+ ),
246
+ suggestion=(
247
+ f"Either rebuild the package from source "
248
+ f"under your debug Python:\n"
249
+ f" pip install --no-binary :all: "
250
+ f"{pkg.package_name}\n\n"
251
+ f"Or switch to a release build of Python for "
252
+ f"production workloads."
253
+ ),
254
+ package=pkg.package_name,
255
+ package_version=pkg.package_version,
256
+ )
257
+ )
258
+ break
259
+ return findings
@@ -0,0 +1,150 @@
1
+ """Source build detection rules.
2
+
3
+ Detects packages built from source (sdist) rather than prebuilt wheels,
4
+ and validates compiler compatibility. This addresses the blind spot that
5
+ neither pip nor Poetry catches — build-time ABI issues.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import List
11
+
12
+ from pybinaryguard.models.enums import Severity
13
+ from pybinaryguard.models.finding import Finding
14
+ from pybinaryguard.models.package import PackageBinaryInfo
15
+ from pybinaryguard.models.system import SystemProfile
16
+ from pybinaryguard.rules.base import Rule
17
+
18
+
19
+ class SourceBuildDetectionRule(Rule):
20
+ """Detect packages that were compiled from source on this machine.
21
+
22
+ Packages built from sdist may have been compiled with a different
23
+ compiler version or flags than expected, leading to ABI issues.
24
+ """
25
+
26
+ rule_id = "SOURCE_BUILD_DETECTED"
27
+ description = "Detect packages built from source distribution"
28
+
29
+ def evaluate(
30
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
31
+ ) -> List[Finding]:
32
+ findings: List[Finding] = []
33
+
34
+ for pkg in packages:
35
+ if pkg.is_pure_python:
36
+ continue
37
+ if not pkg.has_binaries:
38
+ continue
39
+
40
+ # If package has .so files but NO wheel tags, it was built from source
41
+ if not pkg.wheel_tags:
42
+ findings.append(Finding(
43
+ rule_id=self.rule_id,
44
+ severity=Severity.INFO,
45
+ title=f"{pkg.package_name} was built from source",
46
+ explanation=(
47
+ f"{pkg.package_name}=={pkg.package_version} has compiled "
48
+ f"extensions but no wheel tags, indicating it was installed "
49
+ f"from an sdist (source distribution) rather than a prebuilt "
50
+ f"wheel. Source builds may have ABI issues if the compiler "
51
+ f"or flags differ from what the package authors tested."
52
+ ),
53
+ package=pkg.package_name,
54
+ package_version=pkg.package_version,
55
+ suggestion=(
56
+ f"Consider installing a prebuilt wheel: "
57
+ f"pip install --only-binary :all: {pkg.package_name}"
58
+ ),
59
+ confidence=0.8,
60
+ ))
61
+
62
+ return findings
63
+
64
+
65
+ class SourceBuildNoCompilerRule(Rule):
66
+ """Warn if source-built packages exist but no C compiler is available.
67
+
68
+ If a package was built from source but gcc/clang are not currently
69
+ present, rebuilds or updates will fail.
70
+ """
71
+
72
+ rule_id = "SOURCE_BUILD_NO_COMPILER"
73
+ description = "Source-built packages with no compiler available"
74
+
75
+ def is_applicable(self, profile: SystemProfile) -> bool:
76
+ return not profile.has_build_tools
77
+
78
+ def evaluate(
79
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
80
+ ) -> List[Finding]:
81
+ findings: List[Finding] = []
82
+
83
+ source_built = [
84
+ pkg for pkg in packages
85
+ if not pkg.is_pure_python and pkg.has_binaries and not pkg.wheel_tags
86
+ ]
87
+
88
+ if source_built:
89
+ names = ", ".join(p.package_name for p in source_built[:5])
90
+ remaining = len(source_built) - 5
91
+ suffix = f" (+{remaining} more)" if remaining > 0 else ""
92
+
93
+ findings.append(Finding(
94
+ rule_id=self.rule_id,
95
+ severity=Severity.WARNING,
96
+ title="Source-built packages but no C compiler found",
97
+ explanation=(
98
+ f"Found {len(source_built)} package(s) built from source "
99
+ f"({names}{suffix}) but no C/C++ compiler (gcc, g++, clang) "
100
+ f"is available on this system. Upgrading or reinstalling these "
101
+ f"packages will fail without a compiler."
102
+ ),
103
+ suggestion=(
104
+ "Install build tools: apt install build-essential "
105
+ "(Debian/Ubuntu) or yum groupinstall 'Development Tools' (RHEL)"
106
+ ),
107
+ confidence=0.9,
108
+ ))
109
+
110
+ return findings
111
+
112
+
113
+ class MissingPythonHeadersRule(Rule):
114
+ """Warn if Python dev headers are missing for source builds."""
115
+
116
+ rule_id = "SOURCE_BUILD_NO_PYTHON_HEADERS"
117
+ description = "Python development headers missing for source builds"
118
+
119
+ def is_applicable(self, profile: SystemProfile) -> bool:
120
+ return not profile.has_python_dev_headers and profile.has_build_tools
121
+
122
+ def evaluate(
123
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
124
+ ) -> List[Finding]:
125
+ findings: List[Finding] = []
126
+
127
+ source_built = [
128
+ pkg for pkg in packages
129
+ if not pkg.is_pure_python and pkg.has_binaries and not pkg.wheel_tags
130
+ ]
131
+
132
+ if source_built:
133
+ py_ver = ".".join(str(v) for v in profile.python_version[:2])
134
+ findings.append(Finding(
135
+ rule_id=self.rule_id,
136
+ severity=Severity.WARNING,
137
+ title="Python development headers not found",
138
+ explanation=(
139
+ f"Found {len(source_built)} source-built package(s) but "
140
+ f"Python.h was not found. Building C extensions requires "
141
+ f"Python development headers."
142
+ ),
143
+ suggestion=(
144
+ f"Install Python headers: apt install python{py_ver}-dev "
145
+ f"(Debian/Ubuntu) or yum install python3-devel (RHEL)"
146
+ ),
147
+ confidence=0.85,
148
+ ))
149
+
150
+ return findings
@@ -0,0 +1,159 @@
1
+ """Virtual environment misconfiguration rules.
2
+
3
+ Detects common venv issues that cause packages to misbehave:
4
+ system/venv package mixing, pip user-site leaks, stale environments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import List
10
+
11
+ from pybinaryguard.models.enums import Severity
12
+ from pybinaryguard.models.finding import Finding
13
+ from pybinaryguard.models.package import PackageBinaryInfo
14
+ from pybinaryguard.models.system import SystemProfile
15
+ from pybinaryguard.rules.base import Rule
16
+
17
+
18
+ class SystemPythonWarningRule(Rule):
19
+ """Warn when running on system Python without a virtual environment."""
20
+
21
+ rule_id = "VENV_SYSTEM_PYTHON"
22
+ description = "Detect use of system Python without isolation"
23
+
24
+ def is_applicable(self, profile: SystemProfile) -> bool:
25
+ return profile.is_system_python
26
+
27
+ def evaluate(
28
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
29
+ ) -> List[Finding]:
30
+ findings: List[Finding] = []
31
+
32
+ # Only warn if there are a significant number of packages
33
+ if len(packages) > 10:
34
+ findings.append(Finding(
35
+ rule_id=self.rule_id,
36
+ severity=Severity.INFO,
37
+ title="Running on system Python without a virtual environment",
38
+ explanation=(
39
+ f"Found {len(packages)} packages installed on system Python. "
40
+ f"Installing packages system-wide can cause conflicts with "
41
+ f"OS-managed packages and may break system tools. This is "
42
+ f"especially risky on Debian/Ubuntu where Python is externally "
43
+ f"managed (PEP 668)."
44
+ ),
45
+ suggestion=(
46
+ "Create a virtual environment: python3 -m venv .venv && "
47
+ "source .venv/bin/activate"
48
+ ),
49
+ confidence=0.7,
50
+ ))
51
+
52
+ return findings
53
+
54
+
55
+ class MixedEnvironmentRule(Rule):
56
+ """Detect packages from multiple environments leaking into sys.path."""
57
+
58
+ rule_id = "VENV_MIXED_ENVIRONMENT"
59
+ description = "Detect mixed package sources on sys.path"
60
+
61
+ def is_applicable(self, profile: SystemProfile) -> bool:
62
+ return profile.mixed_env_risk
63
+
64
+ def evaluate(
65
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
66
+ ) -> List[Finding]:
67
+ return [Finding(
68
+ rule_id=self.rule_id,
69
+ severity=Severity.WARNING,
70
+ title="Mixed package environments detected",
71
+ explanation=(
72
+ "Packages from multiple environment roots are on sys.path. "
73
+ "This happens when system packages leak into a virtual environment "
74
+ "or when multiple venvs overlap. Mixed environments cause "
75
+ "unpredictable import behavior and version conflicts."
76
+ ),
77
+ suggestion=(
78
+ "Create a clean virtual environment: python3 -m venv --clear .venv"
79
+ ),
80
+ confidence=0.85,
81
+ )]
82
+
83
+
84
+ class UserSiteLeakRule(Rule):
85
+ """Detect pip user-site packages leaking into a virtual environment."""
86
+
87
+ rule_id = "VENV_USER_SITE_LEAK"
88
+ description = "Detect user-site packages leaking into venv"
89
+
90
+ def is_applicable(self, profile: SystemProfile) -> bool:
91
+ return profile.is_virtual_env and profile.pip_user_site_enabled
92
+
93
+ def evaluate(
94
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
95
+ ) -> List[Finding]:
96
+ return [Finding(
97
+ rule_id=self.rule_id,
98
+ severity=Severity.WARNING,
99
+ title="User site-packages leaking into virtual environment",
100
+ explanation=(
101
+ f"Running in a {profile.venv_type} environment but pip user "
102
+ f"site-packages directory is on sys.path. Packages installed "
103
+ f"with 'pip install --user' outside the venv will shadow "
104
+ f"venv-installed packages, causing version confusion."
105
+ ),
106
+ suggestion=(
107
+ "Set PYTHONNOUSERSITE=1 or create venv with: "
108
+ "python3 -m venv --system-site-packages=off .venv"
109
+ ),
110
+ confidence=0.9,
111
+ )]
112
+
113
+
114
+ class CondaPipMixingRule(Rule):
115
+ """Detect pip-installed packages in a conda environment."""
116
+
117
+ rule_id = "VENV_CONDA_PIP_MIXING"
118
+ description = "Detect pip packages in conda environment"
119
+
120
+ def is_applicable(self, profile: SystemProfile) -> bool:
121
+ return profile.venv_type == "conda"
122
+
123
+ def evaluate(
124
+ self, profile: SystemProfile, packages: List[PackageBinaryInfo]
125
+ ) -> List[Finding]:
126
+ findings: List[Finding] = []
127
+
128
+ # In conda envs, packages without wheel tags that have binaries
129
+ # were likely pip-installed (conda packages have different metadata)
130
+ pip_binary_pkgs = [
131
+ pkg for pkg in packages
132
+ if pkg.has_binaries and pkg.wheel_tags
133
+ ]
134
+
135
+ if len(pip_binary_pkgs) > 5:
136
+ names = ", ".join(p.package_name for p in pip_binary_pkgs[:5])
137
+ remaining = len(pip_binary_pkgs) - 5
138
+ suffix = f" (+{remaining} more)" if remaining > 0 else ""
139
+
140
+ findings.append(Finding(
141
+ rule_id=self.rule_id,
142
+ severity=Severity.INFO,
143
+ title=f"Pip-installed binary packages in conda environment",
144
+ explanation=(
145
+ f"Found {len(pip_binary_pkgs)} pip-installed package(s) with "
146
+ f"compiled extensions in a conda environment ({names}{suffix}). "
147
+ f"Mixing pip and conda can cause library conflicts when both "
148
+ f"provide different versions of shared libraries (e.g., libstdc++, "
149
+ f"MKL, OpenSSL)."
150
+ ),
151
+ suggestion=(
152
+ "Prefer conda install for packages with C extensions: "
153
+ "conda install <package>. Use pip only for packages not "
154
+ "available via conda."
155
+ ),
156
+ confidence=0.7,
157
+ ))
158
+
159
+ return findings
@@ -0,0 +1,123 @@
1
+ """Rule engine -- orchestrates evaluation of all registered rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import List, Set
6
+
7
+ from pybinaryguard.models.system import SystemProfile
8
+ from pybinaryguard.models.package import PackageBinaryInfo
9
+ from pybinaryguard.models.finding import Finding
10
+ from pybinaryguard.rules.base import Rule
11
+
12
+
13
+ class RuleEngine:
14
+ """Evaluates registered rules against a system profile and package list.
15
+
16
+ The engine holds a list of :class:`Rule` instances, runs each applicable
17
+ rule, collects findings, and returns them sorted by severity (critical
18
+ first).
19
+
20
+ Example::
21
+
22
+ engine = RuleEngine.with_builtin_rules()
23
+ findings = engine.evaluate(profile, packages)
24
+ """
25
+
26
+ def __init__(self, ignored_rules: Set[str] | None = None) -> None:
27
+ self._rules: List[Rule] = []
28
+ self._ignored_rules: Set[str] = ignored_rules or set()
29
+
30
+ # -- Registration -------------------------------------------------------
31
+
32
+ def register(self, rule: Rule) -> None:
33
+ """Add a rule to the engine.
34
+
35
+ Args:
36
+ rule: An instance of a :class:`Rule` subclass.
37
+
38
+ Raises:
39
+ TypeError: If *rule* is not a :class:`Rule` instance.
40
+ """
41
+ if not isinstance(rule, Rule):
42
+ raise TypeError(
43
+ f"Expected a Rule instance, got {type(rule).__name__}"
44
+ )
45
+ self._rules.append(rule)
46
+
47
+ @property
48
+ def rules(self) -> List[Rule]:
49
+ """All currently registered rules."""
50
+ return list(self._rules)
51
+
52
+ @property
53
+ def ignored_rules(self) -> Set[str]:
54
+ """Rule IDs that will be skipped during evaluation."""
55
+ return set(self._ignored_rules)
56
+
57
+ @ignored_rules.setter
58
+ def ignored_rules(self, value: Set[str]) -> None:
59
+ self._ignored_rules = set(value)
60
+
61
+ # -- Evaluation ---------------------------------------------------------
62
+
63
+ def evaluate(
64
+ self,
65
+ profile: SystemProfile,
66
+ packages: List[PackageBinaryInfo],
67
+ ) -> List[Finding]:
68
+ """Run all applicable rules and return sorted findings.
69
+
70
+ Rules whose ``rule_id`` is in :attr:`ignored_rules` are skipped.
71
+ Rules whose :meth:`~Rule.is_applicable` returns ``False`` for
72
+ *profile* are also skipped.
73
+
74
+ Args:
75
+ profile: The current system profile.
76
+ packages: List of packages with binary analysis data.
77
+
78
+ Returns:
79
+ Findings sorted by severity (CRITICAL first, then WARNING,
80
+ INFO, PASSED).
81
+ """
82
+ findings: List[Finding] = []
83
+ for rule in self._rules:
84
+ if rule.rule_id in self._ignored_rules:
85
+ continue
86
+ if not rule.is_applicable(profile):
87
+ continue
88
+ try:
89
+ rule_findings = rule.evaluate(profile, packages)
90
+ findings.extend(rule_findings)
91
+ except Exception:
92
+ # Individual rule failures must not crash the whole scan.
93
+ # In production this would be logged; for now we silently
94
+ # skip the broken rule.
95
+ pass
96
+ findings.sort(key=lambda f: f.severity)
97
+ return findings
98
+
99
+ # -- Factory ------------------------------------------------------------
100
+
101
+ @classmethod
102
+ def with_builtin_rules(
103
+ cls,
104
+ ignored_rules: Set[str] | None = None,
105
+ ) -> "RuleEngine":
106
+ """Create an engine pre-loaded with all built-in rules.
107
+
108
+ Args:
109
+ ignored_rules: Optional set of rule IDs to skip.
110
+
111
+ Returns:
112
+ A fully-configured :class:`RuleEngine`.
113
+ """
114
+ engine = cls(ignored_rules=ignored_rules)
115
+ engine.load_builtin_rules()
116
+ return engine
117
+
118
+ def load_builtin_rules(self) -> None:
119
+ """Instantiate and register all built-in rules."""
120
+ from pybinaryguard.rules.builtin import get_all_builtin_rules
121
+
122
+ for rule in get_all_builtin_rules():
123
+ self.register(rule)