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
|
@@ -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)
|