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,474 @@
|
|
|
1
|
+
"""Agent-native tool interface for PyBinaryGuard.
|
|
2
|
+
|
|
3
|
+
Every function returns a structured, JSON-serializable dataclass that agents
|
|
4
|
+
can parse without interpreting human-formatted text. Each result includes
|
|
5
|
+
recommended actions classified by safety level.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from pybinaryguard.agent import scan, check, simulate_install, doctor
|
|
10
|
+
|
|
11
|
+
report = scan() # Full environment scan
|
|
12
|
+
result = check("torch") # Single package
|
|
13
|
+
sim = simulate_install("torch==2.4.0+cu124") # Pre-install prediction
|
|
14
|
+
dx = doctor("GLIBC_2.34 not found") # Error diagnosis
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from pybinaryguard.agent.recommender import ActionRecommender, RecommendedAction
|
|
25
|
+
from pybinaryguard.models.enums import ScanMode, Severity
|
|
26
|
+
from pybinaryguard.models.finding import Finding
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Result dataclasses
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ActionableReport:
|
|
35
|
+
"""Structured scan result for agent consumption.
|
|
36
|
+
|
|
37
|
+
Every field is JSON-serializable. Agents should check ``risk_level``
|
|
38
|
+
and iterate ``safe_actions`` for auto-executable fixes.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
health_score: int
|
|
42
|
+
risk_level: str # "low", "medium", "high", "critical"
|
|
43
|
+
total_packages: int
|
|
44
|
+
packages_scanned: int
|
|
45
|
+
scan_duration_ms: float
|
|
46
|
+
issues: List[Dict[str, object]] = field(default_factory=list)
|
|
47
|
+
safe_actions: List[Dict[str, object]] = field(default_factory=list)
|
|
48
|
+
review_actions: List[Dict[str, object]] = field(default_factory=list)
|
|
49
|
+
dangerous_actions: List[Dict[str, object]] = field(default_factory=list)
|
|
50
|
+
score_breakdown: Optional[Dict[str, object]] = None
|
|
51
|
+
detected_board: Optional[str] = None
|
|
52
|
+
profile_summary: Dict[str, str] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, object]:
|
|
55
|
+
result: Dict[str, object] = {
|
|
56
|
+
"health_score": self.health_score,
|
|
57
|
+
"risk_level": self.risk_level,
|
|
58
|
+
"total_packages": self.total_packages,
|
|
59
|
+
"packages_scanned": self.packages_scanned,
|
|
60
|
+
"scan_duration_ms": round(self.scan_duration_ms, 1),
|
|
61
|
+
"issue_count": len(self.issues),
|
|
62
|
+
"issues": self.issues,
|
|
63
|
+
"safe_actions": self.safe_actions,
|
|
64
|
+
"review_actions": self.review_actions,
|
|
65
|
+
"dangerous_actions": self.dangerous_actions,
|
|
66
|
+
}
|
|
67
|
+
if self.score_breakdown:
|
|
68
|
+
result["score_breakdown"] = self.score_breakdown
|
|
69
|
+
if self.detected_board:
|
|
70
|
+
result["detected_board"] = self.detected_board
|
|
71
|
+
if self.profile_summary:
|
|
72
|
+
result["profile"] = self.profile_summary
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
def to_json(self) -> str:
|
|
76
|
+
import json
|
|
77
|
+
return json.dumps(self.to_dict(), indent=2, default=str)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class AgentCheckResult:
|
|
82
|
+
"""Structured result from checking a single package."""
|
|
83
|
+
|
|
84
|
+
package: str
|
|
85
|
+
compatible: bool
|
|
86
|
+
risk_level: str
|
|
87
|
+
issues: List[Dict[str, object]] = field(default_factory=list)
|
|
88
|
+
safe_actions: List[Dict[str, object]] = field(default_factory=list)
|
|
89
|
+
review_actions: List[Dict[str, object]] = field(default_factory=list)
|
|
90
|
+
dangerous_actions: List[Dict[str, object]] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
def to_dict(self) -> Dict[str, object]:
|
|
93
|
+
return {
|
|
94
|
+
"package": self.package,
|
|
95
|
+
"compatible": self.compatible,
|
|
96
|
+
"risk_level": self.risk_level,
|
|
97
|
+
"issue_count": len(self.issues),
|
|
98
|
+
"issues": self.issues,
|
|
99
|
+
"safe_actions": self.safe_actions,
|
|
100
|
+
"review_actions": self.review_actions,
|
|
101
|
+
"dangerous_actions": self.dangerous_actions,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
def to_json(self) -> str:
|
|
105
|
+
import json
|
|
106
|
+
return json.dumps(self.to_dict(), indent=2, default=str)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class AgentSimulateResult:
|
|
111
|
+
"""Structured result from simulating a package installation."""
|
|
112
|
+
|
|
113
|
+
package_spec: str
|
|
114
|
+
predicted_compatible: bool
|
|
115
|
+
confidence: float
|
|
116
|
+
risk_level: str
|
|
117
|
+
warnings: List[Dict[str, object]] = field(default_factory=list)
|
|
118
|
+
blockers: List[Dict[str, object]] = field(default_factory=list)
|
|
119
|
+
parsed_tags: Optional[Dict[str, str]] = None
|
|
120
|
+
|
|
121
|
+
def to_dict(self) -> Dict[str, object]:
|
|
122
|
+
result: Dict[str, object] = {
|
|
123
|
+
"package_spec": self.package_spec,
|
|
124
|
+
"predicted_compatible": self.predicted_compatible,
|
|
125
|
+
"confidence": round(self.confidence, 2),
|
|
126
|
+
"risk_level": self.risk_level,
|
|
127
|
+
"blocker_count": len(self.blockers),
|
|
128
|
+
"blockers": self.blockers,
|
|
129
|
+
"warning_count": len(self.warnings),
|
|
130
|
+
"warnings": self.warnings,
|
|
131
|
+
}
|
|
132
|
+
if self.parsed_tags:
|
|
133
|
+
result["parsed_tags"] = self.parsed_tags
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def to_json(self) -> str:
|
|
137
|
+
import json
|
|
138
|
+
return json.dumps(self.to_dict(), indent=2, default=str)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class AgentDoctorResult:
|
|
143
|
+
"""Structured result from diagnosing an error."""
|
|
144
|
+
|
|
145
|
+
error_message: str
|
|
146
|
+
diagnosis: str
|
|
147
|
+
fix_plan: List[Dict[str, object]] = field(default_factory=list)
|
|
148
|
+
auto_fix_safe: bool = False
|
|
149
|
+
related_findings: List[Dict[str, object]] = field(default_factory=list)
|
|
150
|
+
|
|
151
|
+
def to_dict(self) -> Dict[str, object]:
|
|
152
|
+
return {
|
|
153
|
+
"error_message": self.error_message,
|
|
154
|
+
"diagnosis": self.diagnosis,
|
|
155
|
+
"fix_plan": self.fix_plan,
|
|
156
|
+
"auto_fix_safe": self.auto_fix_safe,
|
|
157
|
+
"related_findings": self.related_findings,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
def to_json(self) -> str:
|
|
161
|
+
import json
|
|
162
|
+
return json.dumps(self.to_dict(), indent=2, default=str)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ---------------------------------------------------------------------------
|
|
166
|
+
# Helper functions
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _risk_level(findings: List[Finding]) -> str:
|
|
170
|
+
"""Compute risk level from findings."""
|
|
171
|
+
critical = sum(1 for f in findings if f.severity == Severity.CRITICAL)
|
|
172
|
+
warning = sum(1 for f in findings if f.severity == Severity.WARNING)
|
|
173
|
+
if critical >= 3:
|
|
174
|
+
return "critical"
|
|
175
|
+
if critical >= 1:
|
|
176
|
+
return "high"
|
|
177
|
+
if warning >= 3:
|
|
178
|
+
return "medium"
|
|
179
|
+
if warning >= 1:
|
|
180
|
+
return "low"
|
|
181
|
+
return "none"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _findings_to_issues(findings: List[Finding]) -> List[Dict[str, object]]:
|
|
185
|
+
"""Convert findings to agent-friendly issue dicts."""
|
|
186
|
+
issues: List[Dict[str, object]] = []
|
|
187
|
+
for f in findings:
|
|
188
|
+
if f.severity == Severity.PASSED:
|
|
189
|
+
continue
|
|
190
|
+
issue: Dict[str, object] = {
|
|
191
|
+
"type": f.rule_id,
|
|
192
|
+
"severity": f.severity.value,
|
|
193
|
+
"title": f.title,
|
|
194
|
+
"explanation": f.explanation,
|
|
195
|
+
}
|
|
196
|
+
if f.package:
|
|
197
|
+
issue["package"] = f.package
|
|
198
|
+
if f.suggestion:
|
|
199
|
+
issue["fix_hint"] = f.suggestion
|
|
200
|
+
if f.confidence < 1.0:
|
|
201
|
+
issue["confidence"] = round(f.confidence, 2)
|
|
202
|
+
issues.append(issue)
|
|
203
|
+
return issues
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _classify_actions(
|
|
207
|
+
actions: List[RecommendedAction],
|
|
208
|
+
) -> Tuple[
|
|
209
|
+
List[Dict[str, object]],
|
|
210
|
+
List[Dict[str, object]],
|
|
211
|
+
List[Dict[str, object]],
|
|
212
|
+
]:
|
|
213
|
+
"""Split actions into safe, review, dangerous buckets."""
|
|
214
|
+
safe: List[Dict[str, object]] = []
|
|
215
|
+
review: List[Dict[str, object]] = []
|
|
216
|
+
dangerous: List[Dict[str, object]] = []
|
|
217
|
+
for action in actions:
|
|
218
|
+
d = action.to_dict()
|
|
219
|
+
if action.safety == "safe":
|
|
220
|
+
safe.append(d)
|
|
221
|
+
elif action.safety == "dangerous":
|
|
222
|
+
dangerous.append(d)
|
|
223
|
+
else:
|
|
224
|
+
review.append(d)
|
|
225
|
+
return safe, review, dangerous
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# Core agent API functions
|
|
230
|
+
# ---------------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
_recommender = ActionRecommender()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def scan(
|
|
236
|
+
scan_mode: str = "standard",
|
|
237
|
+
packages: Optional[List[str]] = None,
|
|
238
|
+
severity_threshold: str = "all",
|
|
239
|
+
timeout: float = 30.0,
|
|
240
|
+
) -> ActionableReport:
|
|
241
|
+
"""Full environment scan returning structured, agent-consumable results.
|
|
242
|
+
|
|
243
|
+
Parameters
|
|
244
|
+
----------
|
|
245
|
+
scan_mode:
|
|
246
|
+
"fast", "standard", or "deep".
|
|
247
|
+
packages:
|
|
248
|
+
Restrict scan to these package names. ``None`` = scan all.
|
|
249
|
+
severity_threshold:
|
|
250
|
+
Minimum severity: "critical", "warning", "info", "all".
|
|
251
|
+
timeout:
|
|
252
|
+
Max probe time in seconds.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
ActionableReport
|
|
257
|
+
Structured report with health score, issues, and classified actions.
|
|
258
|
+
"""
|
|
259
|
+
from pybinaryguard.scanner import Scanner
|
|
260
|
+
|
|
261
|
+
mode = ScanMode(scan_mode)
|
|
262
|
+
severity_map = {
|
|
263
|
+
"critical": Severity.CRITICAL,
|
|
264
|
+
"warning": Severity.WARNING,
|
|
265
|
+
"info": Severity.INFO,
|
|
266
|
+
"all": Severity.INFO,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
scanner = Scanner(
|
|
270
|
+
packages=packages,
|
|
271
|
+
severity_threshold=severity_map.get(severity_threshold, Severity.INFO),
|
|
272
|
+
timeout=timeout,
|
|
273
|
+
scan_mode=mode,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
report = scanner.run()
|
|
277
|
+
profile = scanner.get_profile()
|
|
278
|
+
|
|
279
|
+
# Generate actions
|
|
280
|
+
actions = _recommender.recommend(report.findings)
|
|
281
|
+
safe, review, dangerous = _classify_actions(actions)
|
|
282
|
+
|
|
283
|
+
# Score breakdown
|
|
284
|
+
breakdown_dict = None
|
|
285
|
+
if report.score_breakdown is not None:
|
|
286
|
+
breakdown_dict = report.score_breakdown.as_dict()
|
|
287
|
+
|
|
288
|
+
return ActionableReport(
|
|
289
|
+
health_score=report.health_score,
|
|
290
|
+
risk_level=_risk_level(report.findings),
|
|
291
|
+
total_packages=report.total_packages,
|
|
292
|
+
packages_scanned=report.packages_scanned,
|
|
293
|
+
scan_duration_ms=report.scan_duration_ms,
|
|
294
|
+
issues=_findings_to_issues(report.findings),
|
|
295
|
+
safe_actions=safe,
|
|
296
|
+
review_actions=review,
|
|
297
|
+
dangerous_actions=dangerous,
|
|
298
|
+
score_breakdown=breakdown_dict,
|
|
299
|
+
detected_board=report.detected_board,
|
|
300
|
+
profile_summary=profile.summary(),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def check(package: str, timeout: float = 30.0) -> AgentCheckResult:
|
|
305
|
+
"""Check a single installed package.
|
|
306
|
+
|
|
307
|
+
Parameters
|
|
308
|
+
----------
|
|
309
|
+
package:
|
|
310
|
+
Package name (e.g. "torch", "numpy").
|
|
311
|
+
timeout:
|
|
312
|
+
Max probe time in seconds.
|
|
313
|
+
|
|
314
|
+
Returns
|
|
315
|
+
-------
|
|
316
|
+
AgentCheckResult
|
|
317
|
+
Structured compatibility result with actions.
|
|
318
|
+
"""
|
|
319
|
+
from pybinaryguard.scanner import Scanner
|
|
320
|
+
|
|
321
|
+
scanner = Scanner(packages=[package], timeout=timeout)
|
|
322
|
+
findings = scanner.check_package(package)
|
|
323
|
+
|
|
324
|
+
actions = _recommender.recommend(findings)
|
|
325
|
+
safe, review, dangerous = _classify_actions(actions)
|
|
326
|
+
|
|
327
|
+
critical = sum(1 for f in findings if f.severity == Severity.CRITICAL)
|
|
328
|
+
|
|
329
|
+
return AgentCheckResult(
|
|
330
|
+
package=package,
|
|
331
|
+
compatible=critical == 0,
|
|
332
|
+
risk_level=_risk_level(findings),
|
|
333
|
+
issues=_findings_to_issues(findings),
|
|
334
|
+
safe_actions=safe,
|
|
335
|
+
review_actions=review,
|
|
336
|
+
dangerous_actions=dangerous,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def simulate_install(package_spec: str) -> AgentSimulateResult:
|
|
341
|
+
"""Predict compatibility BEFORE installing a package.
|
|
342
|
+
|
|
343
|
+
Analyses the package specifier (name, version pin, or wheel filename)
|
|
344
|
+
against the current system profile to predict whether installation
|
|
345
|
+
will produce a working binary.
|
|
346
|
+
|
|
347
|
+
Parameters
|
|
348
|
+
----------
|
|
349
|
+
package_spec:
|
|
350
|
+
Package specifier. Examples:
|
|
351
|
+
- ``"torch"`` — name only, limited prediction
|
|
352
|
+
- ``"torch==2.4.0+cu124"`` — version with CUDA variant
|
|
353
|
+
- ``"torch-2.4.0+cu124-cp312-cp312-manylinux_2_17_x86_64.whl"``
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
AgentSimulateResult
|
|
358
|
+
Prediction with blockers, warnings, and confidence.
|
|
359
|
+
"""
|
|
360
|
+
from pybinaryguard.agent.simulator import simulate
|
|
361
|
+
return simulate(package_spec)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def doctor(
|
|
365
|
+
error_message: str,
|
|
366
|
+
package: Optional[str] = None,
|
|
367
|
+
timeout: float = 30.0,
|
|
368
|
+
) -> AgentDoctorResult:
|
|
369
|
+
"""Diagnose an error message and produce a structured fix plan.
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
error_message:
|
|
374
|
+
The error string or traceback to diagnose.
|
|
375
|
+
package:
|
|
376
|
+
Optional package name related to the error.
|
|
377
|
+
timeout:
|
|
378
|
+
Max probe time in seconds.
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
AgentDoctorResult
|
|
383
|
+
Diagnosis with fix plan and auto-fix safety indicator.
|
|
384
|
+
"""
|
|
385
|
+
from pybinaryguard.diagnostics.explainer import diagnose_error
|
|
386
|
+
|
|
387
|
+
# Diagnose the error text
|
|
388
|
+
diagnosis_text = ""
|
|
389
|
+
fix_plan: List[Dict[str, object]] = []
|
|
390
|
+
related_findings: List[Dict[str, object]] = []
|
|
391
|
+
|
|
392
|
+
result = diagnose_error(error_message)
|
|
393
|
+
if result:
|
|
394
|
+
diagnosis_text = result.get("explanation", "Unknown error")
|
|
395
|
+
if result.get("fix_hint"):
|
|
396
|
+
fix_plan.append({
|
|
397
|
+
"step": 1,
|
|
398
|
+
"action": result["fix_hint"],
|
|
399
|
+
"safety": "review",
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
# If package specified, also run a check
|
|
403
|
+
if package:
|
|
404
|
+
from pybinaryguard.scanner import Scanner
|
|
405
|
+
|
|
406
|
+
scanner = Scanner(packages=[package], timeout=timeout)
|
|
407
|
+
findings = scanner.check_package(package)
|
|
408
|
+
|
|
409
|
+
related_findings = _findings_to_issues(findings)
|
|
410
|
+
|
|
411
|
+
# Generate fix actions from findings
|
|
412
|
+
actions = _recommender.recommend(findings)
|
|
413
|
+
for i, action in enumerate(actions):
|
|
414
|
+
fix_plan.append({
|
|
415
|
+
"step": len(fix_plan) + 1,
|
|
416
|
+
"action": action.command,
|
|
417
|
+
"target": action.target,
|
|
418
|
+
"reason": action.reason,
|
|
419
|
+
"safety": action.safety,
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
if not diagnosis_text:
|
|
423
|
+
diagnosis_text = "Could not match this error to a known pattern."
|
|
424
|
+
|
|
425
|
+
# Auto-fix is safe only if ALL fix plan items are "safe"
|
|
426
|
+
auto_fix_safe = (
|
|
427
|
+
len(fix_plan) > 0
|
|
428
|
+
and all(step.get("safety") == "safe" for step in fix_plan)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return AgentDoctorResult(
|
|
432
|
+
error_message=error_message,
|
|
433
|
+
diagnosis=diagnosis_text,
|
|
434
|
+
fix_plan=fix_plan,
|
|
435
|
+
auto_fix_safe=auto_fix_safe,
|
|
436
|
+
related_findings=related_findings,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ---------------------------------------------------------------------------
|
|
441
|
+
# Framework integration helper
|
|
442
|
+
# ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
def as_agent_tool() -> Dict[str, Any]:
|
|
445
|
+
"""Return a tool descriptor with bound handler functions.
|
|
446
|
+
|
|
447
|
+
Returns a dict that agent frameworks can register directly::
|
|
448
|
+
|
|
449
|
+
tool = pybinaryguard.agent.as_agent_tool()
|
|
450
|
+
# tool["scan"]["handler"](scan_mode="fast")
|
|
451
|
+
|
|
452
|
+
Returns
|
|
453
|
+
-------
|
|
454
|
+
Dict[str, Any]
|
|
455
|
+
Tool descriptors with ``schema`` and ``handler`` keys.
|
|
456
|
+
"""
|
|
457
|
+
from pybinaryguard.agent.schema import get_tool_descriptors
|
|
458
|
+
|
|
459
|
+
schemas = get_tool_descriptors(format="json_schema")
|
|
460
|
+
handlers = {
|
|
461
|
+
"pybinaryguard_scan": scan,
|
|
462
|
+
"pybinaryguard_check": check,
|
|
463
|
+
"pybinaryguard_simulate_install": simulate_install,
|
|
464
|
+
"pybinaryguard_doctor": doctor,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
tools: Dict[str, Any] = {}
|
|
468
|
+
for schema in schemas:
|
|
469
|
+
name = schema["name"]
|
|
470
|
+
tools[name] = {
|
|
471
|
+
"schema": schema,
|
|
472
|
+
"handler": handlers.get(name),
|
|
473
|
+
}
|
|
474
|
+
return tools
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Binary analyzers for PyBinaryGuard.
|
|
2
|
+
|
|
3
|
+
This module provides the analysis pipeline that inspects installed Python
|
|
4
|
+
packages for binary compatibility issues. Each analyzer is responsible
|
|
5
|
+
for a specific aspect of the analysis:
|
|
6
|
+
|
|
7
|
+
- **ELFAnalyzer** -- discovers and parses ELF shared objects.
|
|
8
|
+
- **WheelAnalyzer** -- reads wheel / dist-info metadata.
|
|
9
|
+
- **SymbolAnalyzer** -- extracts GLIBC version requirements and CPython ABI.
|
|
10
|
+
- **DependencyAnalyzer** -- resolves DT_NEEDED chains statically.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from typing import List, Optional, Sequence
|
|
18
|
+
|
|
19
|
+
from pybinaryguard.analyzers.base import AnalyzerBase
|
|
20
|
+
from pybinaryguard.analyzers.dependency_analyzer import DependencyAnalyzer
|
|
21
|
+
from pybinaryguard.analyzers.elf_analyzer import ELFAnalyzer, ELFParseError, MinimalELFParser
|
|
22
|
+
from pybinaryguard.analyzers.symbol_analyzer import (
|
|
23
|
+
SymbolAnalyzer,
|
|
24
|
+
compute_max_glibc,
|
|
25
|
+
parse_glibc_version,
|
|
26
|
+
)
|
|
27
|
+
from pybinaryguard.analyzers.wheel_analyzer import (
|
|
28
|
+
WheelAnalyzer,
|
|
29
|
+
find_dist_info_dirs,
|
|
30
|
+
)
|
|
31
|
+
from pybinaryguard.models.package import PackageBinaryInfo
|
|
32
|
+
from pybinaryguard.models.system import SystemProfile
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"AnalyzerBase",
|
|
38
|
+
"DependencyAnalyzer",
|
|
39
|
+
"ELFAnalyzer",
|
|
40
|
+
"ELFParseError",
|
|
41
|
+
"MinimalELFParser",
|
|
42
|
+
"SymbolAnalyzer",
|
|
43
|
+
"WheelAnalyzer",
|
|
44
|
+
"analyze_packages",
|
|
45
|
+
"compute_max_glibc",
|
|
46
|
+
"find_dist_info_dirs",
|
|
47
|
+
"get_all_analyzers",
|
|
48
|
+
"parse_glibc_version",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_all_analyzers(
|
|
53
|
+
system_profile: Optional[SystemProfile] = None,
|
|
54
|
+
) -> List[AnalyzerBase]:
|
|
55
|
+
"""Return an ordered list of all built-in analyzers.
|
|
56
|
+
|
|
57
|
+
The order matters: the ELF analyzer must run first (it discovers
|
|
58
|
+
``.so`` files), followed by the wheel analyzer (metadata), then the
|
|
59
|
+
symbol analyzer (refines GLIBC data), and finally the dependency
|
|
60
|
+
analyzer (resolves library paths).
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
system_profile:
|
|
65
|
+
Optional system profile. Passed to analyzers that need system
|
|
66
|
+
information (e.g. ``DependencyAnalyzer``).
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
list of AnalyzerBase
|
|
71
|
+
"""
|
|
72
|
+
return [
|
|
73
|
+
ELFAnalyzer(),
|
|
74
|
+
WheelAnalyzer(),
|
|
75
|
+
SymbolAnalyzer(),
|
|
76
|
+
DependencyAnalyzer(system_profile=system_profile),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def analyze_packages(
|
|
81
|
+
site_packages_paths: List[str],
|
|
82
|
+
system_profile: Optional[SystemProfile] = None,
|
|
83
|
+
analyzers: Optional[Sequence[AnalyzerBase]] = None,
|
|
84
|
+
) -> List[PackageBinaryInfo]:
|
|
85
|
+
"""Discover and analyze all installed packages with binary components.
|
|
86
|
+
|
|
87
|
+
Walks the given ``site-packages`` directories, creates a
|
|
88
|
+
``PackageBinaryInfo`` for each discovered distribution, runs the
|
|
89
|
+
full analyzer pipeline, and returns only packages that contain
|
|
90
|
+
compiled extensions.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
site_packages_paths:
|
|
95
|
+
Paths to ``site-packages`` directories to scan.
|
|
96
|
+
system_profile:
|
|
97
|
+
Optional system profile for dependency resolution.
|
|
98
|
+
analyzers:
|
|
99
|
+
Optional list of analyzers to run. If ``None``, the default
|
|
100
|
+
set from ``get_all_analyzers()`` is used.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
list of PackageBinaryInfo
|
|
105
|
+
Only packages that have at least one ``.so`` file.
|
|
106
|
+
"""
|
|
107
|
+
if analyzers is None:
|
|
108
|
+
analyzers = get_all_analyzers(system_profile=system_profile)
|
|
109
|
+
|
|
110
|
+
results: List[PackageBinaryInfo] = []
|
|
111
|
+
|
|
112
|
+
for site_path in site_packages_paths:
|
|
113
|
+
if not os.path.isdir(site_path):
|
|
114
|
+
logger.debug("Skipping non-existent site-packages: %s", site_path)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
dist_infos = find_dist_info_dirs(site_path)
|
|
118
|
+
logger.debug("Found %d distributions in %s", len(dist_infos), site_path)
|
|
119
|
+
|
|
120
|
+
for dist_info_path, pkg_name, pkg_version in dist_infos:
|
|
121
|
+
# Determine the package's install directory. This is
|
|
122
|
+
# typically a directory under site-packages with the same
|
|
123
|
+
# name as the package (lowercased), but we also check
|
|
124
|
+
# top_level.txt for accuracy.
|
|
125
|
+
install_path = _guess_install_path(site_path, dist_info_path, pkg_name)
|
|
126
|
+
if install_path is None:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
pkg_info = PackageBinaryInfo(
|
|
130
|
+
package_name=pkg_name,
|
|
131
|
+
package_version=pkg_version,
|
|
132
|
+
install_path=install_path,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
for analyzer in analyzers:
|
|
136
|
+
try:
|
|
137
|
+
analyzer.analyze(pkg_info)
|
|
138
|
+
except Exception:
|
|
139
|
+
logger.debug(
|
|
140
|
+
"Analyzer %s failed on %s",
|
|
141
|
+
getattr(analyzer, "name", type(analyzer).__name__),
|
|
142
|
+
pkg_name,
|
|
143
|
+
exc_info=True,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if pkg_info.has_binaries:
|
|
147
|
+
results.append(pkg_info)
|
|
148
|
+
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _guess_install_path(
|
|
153
|
+
site_path: str, dist_info_path: str, pkg_name: str
|
|
154
|
+
) -> Optional[str]:
|
|
155
|
+
"""Determine the package's install directory under *site_path*.
|
|
156
|
+
|
|
157
|
+
Checks (in order):
|
|
158
|
+
1. ``top_level.txt`` in the dist-info directory
|
|
159
|
+
2. A directory matching the package name (lowercased, with hyphens
|
|
160
|
+
replaced by underscores)
|
|
161
|
+
3. The site-packages directory itself (for namespace packages or
|
|
162
|
+
single-file installs)
|
|
163
|
+
|
|
164
|
+
Returns
|
|
165
|
+
-------
|
|
166
|
+
str or None
|
|
167
|
+
The install path, or ``None`` if the package directory cannot
|
|
168
|
+
be determined.
|
|
169
|
+
"""
|
|
170
|
+
# 1. Check top_level.txt
|
|
171
|
+
top_level_path = os.path.join(dist_info_path, "top_level.txt")
|
|
172
|
+
try:
|
|
173
|
+
if os.path.isfile(top_level_path):
|
|
174
|
+
with open(top_level_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
175
|
+
for line in fh:
|
|
176
|
+
top_pkg = line.strip()
|
|
177
|
+
if not top_pkg:
|
|
178
|
+
continue
|
|
179
|
+
candidate = os.path.join(site_path, top_pkg)
|
|
180
|
+
if os.path.isdir(candidate):
|
|
181
|
+
return candidate
|
|
182
|
+
except OSError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
# 2. Guess from package name
|
|
186
|
+
normalized = pkg_name.lower().replace("-", "_").replace(".", "_")
|
|
187
|
+
candidate = os.path.join(site_path, normalized)
|
|
188
|
+
if os.path.isdir(candidate):
|
|
189
|
+
return candidate
|
|
190
|
+
|
|
191
|
+
# 3. Try the RECORD file to find actual installed files
|
|
192
|
+
record_path = os.path.join(dist_info_path, "RECORD")
|
|
193
|
+
try:
|
|
194
|
+
if os.path.isfile(record_path):
|
|
195
|
+
with open(record_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
196
|
+
for line in fh:
|
|
197
|
+
file_path = line.split(",", 1)[0].strip()
|
|
198
|
+
if file_path.endswith((".so", ".pyd")):
|
|
199
|
+
# The first path component is the package directory
|
|
200
|
+
parts = file_path.split("/", 1)
|
|
201
|
+
if len(parts) >= 1:
|
|
202
|
+
pkg_dir = os.path.join(site_path, parts[0])
|
|
203
|
+
if os.path.isdir(pkg_dir):
|
|
204
|
+
return pkg_dir
|
|
205
|
+
except OSError:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
# 4. Fall back to site-packages itself (namespace packages, etc.)
|
|
209
|
+
return site_path
|