iflow-mcp_anton-prosterity-documentation-search-enhanced 1.9.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.
- documentation_search_enhanced/__init__.py +14 -0
- documentation_search_enhanced/__main__.py +6 -0
- documentation_search_enhanced/config.json +1674 -0
- documentation_search_enhanced/config_manager.py +233 -0
- documentation_search_enhanced/config_validator.py +79 -0
- documentation_search_enhanced/content_enhancer.py +578 -0
- documentation_search_enhanced/docker_manager.py +87 -0
- documentation_search_enhanced/logger.py +179 -0
- documentation_search_enhanced/main.py +2170 -0
- documentation_search_enhanced/project_generator.py +260 -0
- documentation_search_enhanced/project_scanner.py +85 -0
- documentation_search_enhanced/reranker.py +230 -0
- documentation_search_enhanced/site_index_builder.py +274 -0
- documentation_search_enhanced/site_index_downloader.py +222 -0
- documentation_search_enhanced/site_search.py +1325 -0
- documentation_search_enhanced/smart_search.py +473 -0
- documentation_search_enhanced/snyk_integration.py +657 -0
- documentation_search_enhanced/vector_search.py +303 -0
- documentation_search_enhanced/version_resolver.py +189 -0
- documentation_search_enhanced/vulnerability_scanner.py +545 -0
- documentation_search_enhanced/web_scraper.py +117 -0
- iflow_mcp_anton_prosterity_documentation_search_enhanced-1.9.0.dist-info/METADATA +195 -0
- iflow_mcp_anton_prosterity_documentation_search_enhanced-1.9.0.dist-info/RECORD +26 -0
- iflow_mcp_anton_prosterity_documentation_search_enhanced-1.9.0.dist-info/WHEEL +4 -0
- iflow_mcp_anton_prosterity_documentation_search_enhanced-1.9.0.dist-info/entry_points.txt +2 -0
- iflow_mcp_anton_prosterity_documentation_search_enhanced-1.9.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Snyk integration for enhanced security scanning.
|
|
3
|
+
Provides comprehensive vulnerability analysis, license compliance, and security monitoring.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
import httpx
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
from .vulnerability_scanner import Vulnerability, SeverityLevel, SecurityReport
|
|
16
|
+
from .project_scanner import find_and_parse_dependencies
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SnykSeverity(Enum):
|
|
20
|
+
"""Snyk severity levels mapping"""
|
|
21
|
+
|
|
22
|
+
CRITICAL = "critical"
|
|
23
|
+
HIGH = "high"
|
|
24
|
+
MEDIUM = "medium"
|
|
25
|
+
LOW = "low"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SnykVulnerability:
|
|
30
|
+
"""Snyk-specific vulnerability data"""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
title: str
|
|
34
|
+
description: str
|
|
35
|
+
severity: SnykSeverity
|
|
36
|
+
cvss_score: Optional[float]
|
|
37
|
+
cve: List[str]
|
|
38
|
+
cwe: List[str]
|
|
39
|
+
exploit_maturity: Optional[str]
|
|
40
|
+
patches: List[str]
|
|
41
|
+
upgrade_path: List[str]
|
|
42
|
+
is_patchable: bool
|
|
43
|
+
is_pinnable: bool
|
|
44
|
+
published_date: str
|
|
45
|
+
disclosure_time: Optional[str]
|
|
46
|
+
|
|
47
|
+
def to_vulnerability(self) -> Vulnerability:
|
|
48
|
+
"""Convert to standard Vulnerability format"""
|
|
49
|
+
severity_map = {
|
|
50
|
+
SnykSeverity.CRITICAL: SeverityLevel.CRITICAL,
|
|
51
|
+
SnykSeverity.HIGH: SeverityLevel.HIGH,
|
|
52
|
+
SnykSeverity.MEDIUM: SeverityLevel.MEDIUM,
|
|
53
|
+
SnykSeverity.LOW: SeverityLevel.LOW,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return Vulnerability(
|
|
57
|
+
id=self.id,
|
|
58
|
+
title=self.title,
|
|
59
|
+
description=self.description,
|
|
60
|
+
severity=severity_map[self.severity],
|
|
61
|
+
cvss_score=self.cvss_score,
|
|
62
|
+
cve_id=self.cve[0] if self.cve else None,
|
|
63
|
+
affected_versions=["various"], # Snyk provides more complex version info
|
|
64
|
+
fixed_version=self.upgrade_path[-1] if self.upgrade_path else None,
|
|
65
|
+
published_date=self.published_date,
|
|
66
|
+
source="snyk",
|
|
67
|
+
references=[f"https://snyk.io/vuln/{self.id}"],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class SnykLicense:
|
|
73
|
+
"""License information from Snyk"""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
name: str
|
|
77
|
+
spdx_id: Optional[str]
|
|
78
|
+
type: str # "copyleft", "permissive", "proprietary", etc.
|
|
79
|
+
url: Optional[str]
|
|
80
|
+
is_deprecated: bool
|
|
81
|
+
instructions: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class SnykPackageInfo:
|
|
86
|
+
"""Package information with security details"""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
version: str
|
|
90
|
+
ecosystem: str
|
|
91
|
+
vulnerabilities: List[SnykVulnerability]
|
|
92
|
+
licenses: List[SnykLicense]
|
|
93
|
+
severity_counts: Dict[str, int]
|
|
94
|
+
dependency_paths: List[List[str]]
|
|
95
|
+
is_direct_dependency: bool
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SnykIntegration:
|
|
99
|
+
"""Snyk API integration for enterprise security scanning"""
|
|
100
|
+
|
|
101
|
+
def __init__(self):
|
|
102
|
+
self.api_key = os.getenv("SNYK_API_KEY")
|
|
103
|
+
self.org_id = os.getenv("SNYK_ORG_ID")
|
|
104
|
+
self.base_url = "https://api.snyk.io"
|
|
105
|
+
self.rest_api_url = "https://api.snyk.io/rest"
|
|
106
|
+
self.timeout = httpx.Timeout(60.0)
|
|
107
|
+
|
|
108
|
+
# Cache for API responses
|
|
109
|
+
self.cache = {}
|
|
110
|
+
self.cache_ttl = timedelta(hours=6)
|
|
111
|
+
|
|
112
|
+
def _get_headers(self) -> Dict[str, str]:
|
|
113
|
+
"""Get authentication headers for Snyk API"""
|
|
114
|
+
if not self.api_key:
|
|
115
|
+
raise ValueError("SNYK_API_KEY environment variable is required")
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"Authorization": f"token {self.api_key}",
|
|
119
|
+
"Content-Type": "application/json",
|
|
120
|
+
"User-Agent": "documentation-search-enhanced/1.3.0",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async def test_connection(self) -> Dict[str, Any]:
|
|
124
|
+
"""Test Snyk API connection and get organization info"""
|
|
125
|
+
try:
|
|
126
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
127
|
+
response = await client.get(
|
|
128
|
+
f"{self.base_url}/v1/user/me", headers=self._get_headers()
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if response.status_code == 200:
|
|
132
|
+
user_data = response.json()
|
|
133
|
+
|
|
134
|
+
# Get organizations
|
|
135
|
+
orgs_response = await client.get(
|
|
136
|
+
f"{self.base_url}/v1/user/me/orgs", headers=self._get_headers()
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
orgs_data = (
|
|
140
|
+
orgs_response.json()
|
|
141
|
+
if orgs_response.status_code == 200
|
|
142
|
+
else {"orgs": []}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"status": "connected",
|
|
147
|
+
"user": user_data.get("username"),
|
|
148
|
+
"organizations": [
|
|
149
|
+
{"id": org["id"], "name": org["name"]}
|
|
150
|
+
for org in orgs_data.get("orgs", [])
|
|
151
|
+
],
|
|
152
|
+
}
|
|
153
|
+
else:
|
|
154
|
+
return {
|
|
155
|
+
"status": "error",
|
|
156
|
+
"error": f"Authentication failed: {response.status_code}",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
except Exception as e:
|
|
160
|
+
return {"status": "error", "error": f"Connection failed: {str(e)}"}
|
|
161
|
+
|
|
162
|
+
async def scan_package(
|
|
163
|
+
self, package_name: str, version: str, ecosystem: str = "pypi"
|
|
164
|
+
) -> SnykPackageInfo:
|
|
165
|
+
"""Scan a single package for vulnerabilities"""
|
|
166
|
+
cache_key = f"package_{ecosystem}_{package_name}_{version}"
|
|
167
|
+
|
|
168
|
+
if self._is_cached(cache_key):
|
|
169
|
+
return self.cache[cache_key]["data"]
|
|
170
|
+
|
|
171
|
+
ecosystem_map = {
|
|
172
|
+
"pypi": "pip",
|
|
173
|
+
"npm": "npm",
|
|
174
|
+
"maven": "maven",
|
|
175
|
+
"gradle": "gradle",
|
|
176
|
+
"nuget": "nuget",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
snyk_ecosystem = ecosystem_map.get(ecosystem.lower(), "pip")
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
183
|
+
# Test endpoint for vulnerabilities
|
|
184
|
+
test_payload = {
|
|
185
|
+
"encoding": "plain",
|
|
186
|
+
"files": {
|
|
187
|
+
(
|
|
188
|
+
"requirements.txt"
|
|
189
|
+
if snyk_ecosystem == "pip"
|
|
190
|
+
else "package.json"
|
|
191
|
+
): {
|
|
192
|
+
"contents": (
|
|
193
|
+
f"{package_name}=={version}"
|
|
194
|
+
if snyk_ecosystem == "pip"
|
|
195
|
+
else json.dumps(
|
|
196
|
+
{"dependencies": {package_name: version}}
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
response = await client.post(
|
|
204
|
+
f"{self.base_url}/v1/test/{snyk_ecosystem}",
|
|
205
|
+
headers=self._get_headers(),
|
|
206
|
+
json=test_payload,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if response.status_code == 200:
|
|
210
|
+
data = response.json()
|
|
211
|
+
package_info = self._parse_package_scan_result(
|
|
212
|
+
data, package_name, version, ecosystem
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Cache the result
|
|
216
|
+
self._cache_result(cache_key, package_info)
|
|
217
|
+
|
|
218
|
+
return package_info
|
|
219
|
+
else:
|
|
220
|
+
# Return empty result for failed scans
|
|
221
|
+
return SnykPackageInfo(
|
|
222
|
+
name=package_name,
|
|
223
|
+
version=version,
|
|
224
|
+
ecosystem=ecosystem,
|
|
225
|
+
vulnerabilities=[],
|
|
226
|
+
licenses=[],
|
|
227
|
+
severity_counts={
|
|
228
|
+
"critical": 0,
|
|
229
|
+
"high": 0,
|
|
230
|
+
"medium": 0,
|
|
231
|
+
"low": 0,
|
|
232
|
+
},
|
|
233
|
+
dependency_paths=[],
|
|
234
|
+
is_direct_dependency=True,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
except Exception as e:
|
|
238
|
+
print(f"Snyk scan error for {package_name}: {e}", file=sys.stderr)
|
|
239
|
+
return SnykPackageInfo(
|
|
240
|
+
name=package_name,
|
|
241
|
+
version=version,
|
|
242
|
+
ecosystem=ecosystem,
|
|
243
|
+
vulnerabilities=[],
|
|
244
|
+
licenses=[],
|
|
245
|
+
severity_counts={"critical": 0, "high": 0, "medium": 0, "low": 0},
|
|
246
|
+
dependency_paths=[],
|
|
247
|
+
is_direct_dependency=True,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def scan_project_manifest(
|
|
251
|
+
self, manifest_path: str, ecosystem: str = None
|
|
252
|
+
) -> Dict[str, Any]:
|
|
253
|
+
"""Scan project manifest file (requirements.txt, package.json, etc.)"""
|
|
254
|
+
|
|
255
|
+
if not os.path.exists(manifest_path):
|
|
256
|
+
return {"error": f"Manifest file not found: {manifest_path}"}
|
|
257
|
+
|
|
258
|
+
# Auto-detect ecosystem if not provided
|
|
259
|
+
if not ecosystem:
|
|
260
|
+
if manifest_path.endswith(("requirements.txt", "pyproject.toml")):
|
|
261
|
+
ecosystem = "pip"
|
|
262
|
+
elif manifest_path.endswith("package.json"):
|
|
263
|
+
ecosystem = "npm"
|
|
264
|
+
else:
|
|
265
|
+
ecosystem = "pip" # Default
|
|
266
|
+
|
|
267
|
+
try:
|
|
268
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
269
|
+
file_contents = f.read()
|
|
270
|
+
|
|
271
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
272
|
+
test_payload = {
|
|
273
|
+
"encoding": "plain",
|
|
274
|
+
"files": {
|
|
275
|
+
os.path.basename(manifest_path): {"contents": file_contents}
|
|
276
|
+
},
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
response = await client.post(
|
|
280
|
+
f"{self.base_url}/v1/test/{ecosystem}",
|
|
281
|
+
headers=self._get_headers(),
|
|
282
|
+
json=test_payload,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if response.status_code == 200:
|
|
286
|
+
data = response.json()
|
|
287
|
+
return self._parse_project_scan_result(data, manifest_path)
|
|
288
|
+
else:
|
|
289
|
+
return {
|
|
290
|
+
"error": f"Snyk API error: {response.status_code}",
|
|
291
|
+
"details": response.text,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
return {"error": f"Failed to scan manifest: {str(e)}"}
|
|
296
|
+
|
|
297
|
+
async def get_license_compliance(
|
|
298
|
+
self, packages: List[Tuple[str, str]], ecosystem: str = "pypi"
|
|
299
|
+
) -> Dict[str, Any]:
|
|
300
|
+
"""Check license compliance for multiple packages"""
|
|
301
|
+
compliance_results = {
|
|
302
|
+
"total_packages": len(packages),
|
|
303
|
+
"compliant_packages": 0,
|
|
304
|
+
"non_compliant_packages": 0,
|
|
305
|
+
"unknown_licenses": 0,
|
|
306
|
+
"license_summary": {},
|
|
307
|
+
"compliance_details": [],
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
# Define license policies (configurable)
|
|
311
|
+
allowed_licenses = {
|
|
312
|
+
"MIT",
|
|
313
|
+
"Apache-2.0",
|
|
314
|
+
"BSD-2-Clause",
|
|
315
|
+
"BSD-3-Clause",
|
|
316
|
+
"ISC",
|
|
317
|
+
"Unlicense",
|
|
318
|
+
"WTFPL",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
restricted_licenses = {
|
|
322
|
+
"GPL-2.0",
|
|
323
|
+
"GPL-3.0",
|
|
324
|
+
"LGPL-2.1",
|
|
325
|
+
"LGPL-3.0",
|
|
326
|
+
"AGPL-3.0",
|
|
327
|
+
"SSPL-1.0",
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for package_name, version in packages:
|
|
331
|
+
try:
|
|
332
|
+
package_info = await self.scan_package(package_name, version, ecosystem)
|
|
333
|
+
|
|
334
|
+
package_compliance = {
|
|
335
|
+
"package": package_name,
|
|
336
|
+
"version": version,
|
|
337
|
+
"licenses": [license.name for license in package_info.licenses],
|
|
338
|
+
"compliance_status": "unknown",
|
|
339
|
+
"risk_level": "unknown",
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if package_info.licenses:
|
|
343
|
+
license_names = {license.name for license in package_info.licenses}
|
|
344
|
+
|
|
345
|
+
if license_names.intersection(restricted_licenses):
|
|
346
|
+
package_compliance["compliance_status"] = "non-compliant"
|
|
347
|
+
package_compliance["risk_level"] = "high"
|
|
348
|
+
compliance_results["non_compliant_packages"] += 1
|
|
349
|
+
elif license_names.intersection(allowed_licenses):
|
|
350
|
+
package_compliance["compliance_status"] = "compliant"
|
|
351
|
+
package_compliance["risk_level"] = "low"
|
|
352
|
+
compliance_results["compliant_packages"] += 1
|
|
353
|
+
else:
|
|
354
|
+
package_compliance["compliance_status"] = "review_required"
|
|
355
|
+
package_compliance["risk_level"] = "medium"
|
|
356
|
+
compliance_results["unknown_licenses"] += 1
|
|
357
|
+
else:
|
|
358
|
+
compliance_results["unknown_licenses"] += 1
|
|
359
|
+
|
|
360
|
+
compliance_results["compliance_details"].append(package_compliance)
|
|
361
|
+
|
|
362
|
+
# Update license summary
|
|
363
|
+
for license in package_info.licenses:
|
|
364
|
+
if license.name not in compliance_results["license_summary"]:
|
|
365
|
+
compliance_results["license_summary"][license.name] = 0
|
|
366
|
+
compliance_results["license_summary"][license.name] += 1
|
|
367
|
+
|
|
368
|
+
except Exception as e:
|
|
369
|
+
print(f"License check error for {package_name}: {e}", file=sys.stderr)
|
|
370
|
+
|
|
371
|
+
return compliance_results
|
|
372
|
+
|
|
373
|
+
async def monitor_project(self, project_path: str) -> Dict[str, Any]:
|
|
374
|
+
"""Set up continuous monitoring for a project"""
|
|
375
|
+
|
|
376
|
+
# Find project dependencies
|
|
377
|
+
dep_result = find_and_parse_dependencies(project_path)
|
|
378
|
+
if not dep_result:
|
|
379
|
+
return {"error": "No supported dependency files found"}
|
|
380
|
+
|
|
381
|
+
filename, ecosystem, dependencies = dep_result
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
385
|
+
# Import project for monitoring
|
|
386
|
+
import_payload = {
|
|
387
|
+
"target": {
|
|
388
|
+
"files": [
|
|
389
|
+
{
|
|
390
|
+
"path": filename,
|
|
391
|
+
"contents": self._generate_manifest_content(
|
|
392
|
+
dependencies, ecosystem
|
|
393
|
+
),
|
|
394
|
+
}
|
|
395
|
+
]
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if not self.org_id:
|
|
400
|
+
return {
|
|
401
|
+
"error": "SNYK_ORG_ID environment variable is required for monitoring"
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
response = await client.post(
|
|
405
|
+
f"{self.rest_api_url}/orgs/{self.org_id}/projects",
|
|
406
|
+
headers=self._get_headers(),
|
|
407
|
+
json=import_payload,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if response.status_code == 201:
|
|
411
|
+
project_data = response.json()
|
|
412
|
+
return {
|
|
413
|
+
"status": "monitoring_enabled",
|
|
414
|
+
"project_id": project_data.get("data", {}).get("id"),
|
|
415
|
+
"project_name": os.path.basename(project_path),
|
|
416
|
+
"dependencies_count": len(dependencies),
|
|
417
|
+
}
|
|
418
|
+
else:
|
|
419
|
+
return {
|
|
420
|
+
"error": f"Failed to enable monitoring: {response.status_code}",
|
|
421
|
+
"details": response.text,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
return {"error": f"Monitoring setup failed: {str(e)}"}
|
|
426
|
+
|
|
427
|
+
def _parse_package_scan_result(
|
|
428
|
+
self, scan_data: Dict[str, Any], package_name: str, version: str, ecosystem: str
|
|
429
|
+
) -> SnykPackageInfo:
|
|
430
|
+
"""Parse Snyk scan result into SnykPackageInfo"""
|
|
431
|
+
|
|
432
|
+
vulnerabilities = []
|
|
433
|
+
licenses = []
|
|
434
|
+
severity_counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
|
435
|
+
|
|
436
|
+
# Parse vulnerabilities
|
|
437
|
+
for issue in scan_data.get("issues", {}).get("vulnerabilities", []):
|
|
438
|
+
vuln = self._parse_vulnerability(issue)
|
|
439
|
+
vulnerabilities.append(vuln)
|
|
440
|
+
|
|
441
|
+
severity_key = vuln.severity.value
|
|
442
|
+
if severity_key in severity_counts:
|
|
443
|
+
severity_counts[severity_key] += 1
|
|
444
|
+
|
|
445
|
+
# Parse licenses
|
|
446
|
+
for license_data in scan_data.get("issues", {}).get("licenses", []):
|
|
447
|
+
license = self._parse_license(license_data)
|
|
448
|
+
licenses.append(license)
|
|
449
|
+
|
|
450
|
+
return SnykPackageInfo(
|
|
451
|
+
name=package_name,
|
|
452
|
+
version=version,
|
|
453
|
+
ecosystem=ecosystem,
|
|
454
|
+
vulnerabilities=vulnerabilities,
|
|
455
|
+
licenses=licenses,
|
|
456
|
+
severity_counts=severity_counts,
|
|
457
|
+
dependency_paths=[], # Would need more complex parsing
|
|
458
|
+
is_direct_dependency=True,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
def _parse_project_scan_result(
|
|
462
|
+
self, scan_data: Dict[str, Any], manifest_path: str
|
|
463
|
+
) -> Dict[str, Any]:
|
|
464
|
+
"""Parse project-level scan results"""
|
|
465
|
+
|
|
466
|
+
result = {
|
|
467
|
+
"manifest_file": manifest_path,
|
|
468
|
+
"scan_timestamp": datetime.now().isoformat(),
|
|
469
|
+
"summary": {
|
|
470
|
+
"total_dependencies": scan_data.get("dependencyCount", 0),
|
|
471
|
+
"vulnerability_count": len(
|
|
472
|
+
scan_data.get("issues", {}).get("vulnerabilities", [])
|
|
473
|
+
),
|
|
474
|
+
"license_issues": len(scan_data.get("issues", {}).get("licenses", [])),
|
|
475
|
+
},
|
|
476
|
+
"vulnerabilities": [],
|
|
477
|
+
"license_issues": [],
|
|
478
|
+
"remediation": scan_data.get("remediation", {}),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
# Process vulnerabilities
|
|
482
|
+
for vuln_data in scan_data.get("issues", {}).get("vulnerabilities", []):
|
|
483
|
+
vuln = self._parse_vulnerability(vuln_data)
|
|
484
|
+
result["vulnerabilities"].append(vuln.to_vulnerability().to_dict())
|
|
485
|
+
|
|
486
|
+
# Process license issues
|
|
487
|
+
for license_data in scan_data.get("issues", {}).get("licenses", []):
|
|
488
|
+
license = self._parse_license(license_data)
|
|
489
|
+
result["license_issues"].append(
|
|
490
|
+
{
|
|
491
|
+
"id": license.id,
|
|
492
|
+
"name": license.name,
|
|
493
|
+
"type": license.type,
|
|
494
|
+
"is_deprecated": license.is_deprecated,
|
|
495
|
+
}
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
return result
|
|
499
|
+
|
|
500
|
+
def _parse_vulnerability(self, vuln_data: Dict[str, Any]) -> SnykVulnerability:
|
|
501
|
+
"""Parse vulnerability data from Snyk response"""
|
|
502
|
+
|
|
503
|
+
severity_map = {
|
|
504
|
+
"critical": SnykSeverity.CRITICAL,
|
|
505
|
+
"high": SnykSeverity.HIGH,
|
|
506
|
+
"medium": SnykSeverity.MEDIUM,
|
|
507
|
+
"low": SnykSeverity.LOW,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return SnykVulnerability(
|
|
511
|
+
id=vuln_data.get("id", ""),
|
|
512
|
+
title=vuln_data.get("title", ""),
|
|
513
|
+
description=vuln_data.get("description", ""),
|
|
514
|
+
severity=severity_map.get(
|
|
515
|
+
vuln_data.get("severity", "medium"), SnykSeverity.MEDIUM
|
|
516
|
+
),
|
|
517
|
+
cvss_score=vuln_data.get("cvssScore"),
|
|
518
|
+
cve=vuln_data.get("identifiers", {}).get("CVE", []),
|
|
519
|
+
cwe=vuln_data.get("identifiers", {}).get("CWE", []),
|
|
520
|
+
exploit_maturity=vuln_data.get("exploitMaturity"),
|
|
521
|
+
patches=vuln_data.get("patches", []),
|
|
522
|
+
upgrade_path=vuln_data.get("upgradePath", []),
|
|
523
|
+
is_patchable=vuln_data.get("isPatchable", False),
|
|
524
|
+
is_pinnable=vuln_data.get("isPinnable", False),
|
|
525
|
+
published_date=vuln_data.get("publicationTime", ""),
|
|
526
|
+
disclosure_time=vuln_data.get("disclosureTime"),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
def _parse_license(self, license_data: Dict[str, Any]) -> SnykLicense:
|
|
530
|
+
"""Parse license data from Snyk response"""
|
|
531
|
+
|
|
532
|
+
return SnykLicense(
|
|
533
|
+
id=license_data.get("id", ""),
|
|
534
|
+
name=license_data.get("title", ""),
|
|
535
|
+
spdx_id=license_data.get("license"),
|
|
536
|
+
type=license_data.get("type", "unknown"),
|
|
537
|
+
url=license_data.get("url"),
|
|
538
|
+
is_deprecated=license_data.get("isDeprecated", False),
|
|
539
|
+
instructions=license_data.get("instructions", ""),
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _generate_manifest_content(
|
|
543
|
+
self, dependencies: Dict[str, str], ecosystem: str
|
|
544
|
+
) -> str:
|
|
545
|
+
"""Generate manifest file content for monitoring"""
|
|
546
|
+
|
|
547
|
+
if ecosystem.lower() == "pypi":
|
|
548
|
+
return "\n".join(
|
|
549
|
+
[f"{name}=={version}" for name, version in dependencies.items()]
|
|
550
|
+
)
|
|
551
|
+
elif ecosystem.lower() == "npm":
|
|
552
|
+
return json.dumps({"dependencies": dependencies}, indent=2)
|
|
553
|
+
else:
|
|
554
|
+
return str(dependencies)
|
|
555
|
+
|
|
556
|
+
def _is_cached(self, cache_key: str) -> bool:
|
|
557
|
+
"""Check if result is cached and still valid"""
|
|
558
|
+
if cache_key not in self.cache:
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
cached_time = self.cache[cache_key]["timestamp"]
|
|
562
|
+
return datetime.now() - cached_time < self.cache_ttl
|
|
563
|
+
|
|
564
|
+
def _cache_result(self, cache_key: str, result: Any) -> None:
|
|
565
|
+
"""Cache scan result"""
|
|
566
|
+
self.cache[cache_key] = {"data": result, "timestamp": datetime.now()}
|
|
567
|
+
|
|
568
|
+
# Simple cache cleanup
|
|
569
|
+
if len(self.cache) > 100:
|
|
570
|
+
oldest_key = min(
|
|
571
|
+
self.cache.keys(), key=lambda k: self.cache[k]["timestamp"]
|
|
572
|
+
)
|
|
573
|
+
del self.cache[oldest_key]
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
# Global instance
|
|
577
|
+
snyk_integration = SnykIntegration()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
async def get_snyk_security_report(
|
|
581
|
+
library_name: str, version: str = "latest", ecosystem: str = "pypi"
|
|
582
|
+
) -> SecurityReport:
|
|
583
|
+
"""Get security report using Snyk integration"""
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
package_info = await snyk_integration.scan_package(
|
|
587
|
+
library_name, version, ecosystem
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
# Convert Snyk vulnerabilities to standard format
|
|
591
|
+
vulnerabilities = [
|
|
592
|
+
vuln.to_vulnerability() for vuln in package_info.vulnerabilities
|
|
593
|
+
]
|
|
594
|
+
|
|
595
|
+
# Calculate security score based on Snyk data
|
|
596
|
+
critical_count = package_info.severity_counts.get("critical", 0)
|
|
597
|
+
high_count = package_info.severity_counts.get("high", 0)
|
|
598
|
+
medium_count = package_info.severity_counts.get("medium", 0)
|
|
599
|
+
low_count = package_info.severity_counts.get("low", 0)
|
|
600
|
+
|
|
601
|
+
security_score = max(
|
|
602
|
+
0.0,
|
|
603
|
+
100.0
|
|
604
|
+
- (
|
|
605
|
+
critical_count * 25 + high_count * 15 + medium_count * 5 + low_count * 1
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
# Generate recommendations
|
|
610
|
+
recommendations = []
|
|
611
|
+
if critical_count > 0:
|
|
612
|
+
recommendations.append(
|
|
613
|
+
"🚨 Critical vulnerabilities found - Update immediately"
|
|
614
|
+
)
|
|
615
|
+
if package_info.vulnerabilities and any(
|
|
616
|
+
vuln.upgrade_path for vuln in package_info.vulnerabilities
|
|
617
|
+
):
|
|
618
|
+
recommendations.append("📦 Security updates available - Consider upgrading")
|
|
619
|
+
if security_score >= 80:
|
|
620
|
+
recommendations.append("🛡️ Good security posture")
|
|
621
|
+
elif security_score >= 60:
|
|
622
|
+
recommendations.append("⚠️ Moderate risk - Monitor for updates")
|
|
623
|
+
else:
|
|
624
|
+
recommendations.append("🔴 High risk - Consider alternatives")
|
|
625
|
+
|
|
626
|
+
return SecurityReport(
|
|
627
|
+
library_name=library_name,
|
|
628
|
+
ecosystem=ecosystem,
|
|
629
|
+
scan_date=datetime.now().isoformat(),
|
|
630
|
+
total_vulnerabilities=len(vulnerabilities),
|
|
631
|
+
critical_count=critical_count,
|
|
632
|
+
high_count=high_count,
|
|
633
|
+
medium_count=medium_count,
|
|
634
|
+
low_count=low_count,
|
|
635
|
+
security_score=security_score,
|
|
636
|
+
recommendations=recommendations,
|
|
637
|
+
vulnerabilities=vulnerabilities,
|
|
638
|
+
latest_secure_version=None, # Would need additional API call
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
except Exception as e:
|
|
642
|
+
print(f"Snyk security report error: {e}", file=sys.stderr)
|
|
643
|
+
# Return empty report on error
|
|
644
|
+
return SecurityReport(
|
|
645
|
+
library_name=library_name,
|
|
646
|
+
ecosystem=ecosystem,
|
|
647
|
+
scan_date=datetime.now().isoformat(),
|
|
648
|
+
total_vulnerabilities=0,
|
|
649
|
+
critical_count=0,
|
|
650
|
+
high_count=0,
|
|
651
|
+
medium_count=0,
|
|
652
|
+
low_count=0,
|
|
653
|
+
security_score=50.0, # Neutral score
|
|
654
|
+
recommendations=["Unable to fetch security data"],
|
|
655
|
+
vulnerabilities=[],
|
|
656
|
+
latest_secure_version=None,
|
|
657
|
+
)
|