devloop 0.2.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.
- devloop/__init__.py +3 -0
- devloop/agents/__init__.py +33 -0
- devloop/agents/agent_health_monitor.py +105 -0
- devloop/agents/ci_monitor.py +237 -0
- devloop/agents/code_rabbit.py +248 -0
- devloop/agents/doc_lifecycle.py +374 -0
- devloop/agents/echo.py +24 -0
- devloop/agents/file_logger.py +46 -0
- devloop/agents/formatter.py +511 -0
- devloop/agents/git_commit_assistant.py +421 -0
- devloop/agents/linter.py +399 -0
- devloop/agents/performance_profiler.py +284 -0
- devloop/agents/security_scanner.py +322 -0
- devloop/agents/snyk.py +292 -0
- devloop/agents/test_runner.py +484 -0
- devloop/agents/type_checker.py +242 -0
- devloop/cli/__init__.py +1 -0
- devloop/cli/commands/__init__.py +1 -0
- devloop/cli/commands/custom_agents.py +144 -0
- devloop/cli/commands/feedback.py +161 -0
- devloop/cli/commands/summary.py +50 -0
- devloop/cli/main.py +430 -0
- devloop/cli/main_v1.py +144 -0
- devloop/collectors/__init__.py +17 -0
- devloop/collectors/base.py +55 -0
- devloop/collectors/filesystem.py +126 -0
- devloop/collectors/git.py +171 -0
- devloop/collectors/manager.py +159 -0
- devloop/collectors/process.py +221 -0
- devloop/collectors/system.py +195 -0
- devloop/core/__init__.py +21 -0
- devloop/core/agent.py +206 -0
- devloop/core/agent_template.py +498 -0
- devloop/core/amp_integration.py +166 -0
- devloop/core/auto_fix.py +224 -0
- devloop/core/config.py +272 -0
- devloop/core/context.py +0 -0
- devloop/core/context_store.py +530 -0
- devloop/core/contextual_feedback.py +311 -0
- devloop/core/custom_agent.py +439 -0
- devloop/core/debug_trace.py +289 -0
- devloop/core/event.py +105 -0
- devloop/core/event_store.py +316 -0
- devloop/core/feedback.py +311 -0
- devloop/core/learning.py +351 -0
- devloop/core/manager.py +219 -0
- devloop/core/performance.py +433 -0
- devloop/core/proactive_feedback.py +302 -0
- devloop/core/summary_formatter.py +159 -0
- devloop/core/summary_generator.py +275 -0
- devloop-0.2.0.dist-info/METADATA +705 -0
- devloop-0.2.0.dist-info/RECORD +55 -0
- devloop-0.2.0.dist-info/WHEEL +4 -0
- devloop-0.2.0.dist-info/entry_points.txt +3 -0
- devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Security Scanner Agent - Detects security vulnerabilities in code."""
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from datetime import datetime, UTC
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, Any, List, Optional
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..core.agent import Agent, AgentResult
|
|
14
|
+
from ..core.context_store import (
|
|
15
|
+
context_store,
|
|
16
|
+
Finding,
|
|
17
|
+
Severity,
|
|
18
|
+
ScopeType,
|
|
19
|
+
)
|
|
20
|
+
from ..core.event import Event
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SecurityConfig:
|
|
25
|
+
"""Configuration for security scanning."""
|
|
26
|
+
|
|
27
|
+
enabled_tools: Optional[List[str]] = None # ["bandit", "safety", "trivy"]
|
|
28
|
+
severity_threshold: str = "medium" # low, medium, high
|
|
29
|
+
confidence_threshold: str = "medium" # low, medium, high
|
|
30
|
+
exclude_patterns: Optional[List[str]] = None
|
|
31
|
+
max_issues: int = 50
|
|
32
|
+
|
|
33
|
+
def __post_init__(self):
|
|
34
|
+
if self.enabled_tools is None:
|
|
35
|
+
self.enabled_tools = ["bandit"]
|
|
36
|
+
if self.exclude_patterns is None:
|
|
37
|
+
self.exclude_patterns = ["test*", "*_test.py", "*/tests/*"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SecurityResult:
|
|
41
|
+
"""Security scan result."""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self, tool: str, issues: List[Dict[str, Any]], errors: List[str] = None
|
|
45
|
+
):
|
|
46
|
+
self.tool = tool
|
|
47
|
+
self.issues = issues
|
|
48
|
+
self.errors = errors or []
|
|
49
|
+
self.timestamp = None
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
52
|
+
return {
|
|
53
|
+
"tool": self.tool,
|
|
54
|
+
"issues_found": len(self.issues),
|
|
55
|
+
"issues": self.issues,
|
|
56
|
+
"errors": self.errors,
|
|
57
|
+
"severity_breakdown": self._get_severity_breakdown(),
|
|
58
|
+
"confidence_breakdown": self._get_confidence_breakdown(),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def _get_severity_breakdown(self) -> Dict[str, int]:
|
|
62
|
+
breakdown = {"low": 0, "medium": 0, "high": 0, "unknown": 0}
|
|
63
|
+
for issue in self.issues:
|
|
64
|
+
severity = issue.get("severity", "unknown").lower()
|
|
65
|
+
breakdown[severity] = breakdown.get(severity, 0) + 1
|
|
66
|
+
return breakdown
|
|
67
|
+
|
|
68
|
+
def _get_confidence_breakdown(self) -> Dict[str, int]:
|
|
69
|
+
breakdown = {"low": 0, "medium": 0, "high": 0, "unknown": 0}
|
|
70
|
+
for issue in self.issues:
|
|
71
|
+
confidence = issue.get("confidence", "unknown").lower()
|
|
72
|
+
breakdown[confidence] = breakdown.get(confidence, 0) + 1
|
|
73
|
+
return breakdown
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SecurityScannerAgent(Agent):
|
|
77
|
+
"""Agent for scanning code for security vulnerabilities."""
|
|
78
|
+
|
|
79
|
+
def __init__(self, config: Dict[str, Any], event_bus):
|
|
80
|
+
super().__init__(
|
|
81
|
+
"security-scanner", ["file:modified", "file:created"], event_bus
|
|
82
|
+
)
|
|
83
|
+
self.config = SecurityConfig(**config)
|
|
84
|
+
self.logger = logging.getLogger(f"agent.{self.name}")
|
|
85
|
+
|
|
86
|
+
async def handle(self, event: Event) -> AgentResult:
|
|
87
|
+
"""Handle file change events by scanning for security issues."""
|
|
88
|
+
try:
|
|
89
|
+
file_path = event.payload.get("path")
|
|
90
|
+
if not file_path:
|
|
91
|
+
return AgentResult(
|
|
92
|
+
agent_name=self.name,
|
|
93
|
+
success=False,
|
|
94
|
+
duration=0.0,
|
|
95
|
+
message="No file path in event",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
path = Path(file_path)
|
|
99
|
+
if not path.exists():
|
|
100
|
+
return AgentResult(
|
|
101
|
+
agent_name=self.name,
|
|
102
|
+
success=False,
|
|
103
|
+
duration=0.0,
|
|
104
|
+
message=f"File does not exist: {file_path}",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Only scan Python files for now
|
|
108
|
+
if path.suffix != ".py":
|
|
109
|
+
return AgentResult(
|
|
110
|
+
agent_name=self.name,
|
|
111
|
+
success=True,
|
|
112
|
+
duration=0.0,
|
|
113
|
+
message=f"Skipped non-Python file: {file_path}",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Check if file matches exclude patterns
|
|
117
|
+
if self._should_exclude_file(str(path)):
|
|
118
|
+
return AgentResult(
|
|
119
|
+
agent_name=self.name,
|
|
120
|
+
success=True,
|
|
121
|
+
duration=0.0,
|
|
122
|
+
message=f"Excluded file: {file_path}",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Run security scan
|
|
126
|
+
results = await self._run_security_scan(path)
|
|
127
|
+
|
|
128
|
+
agent_result = AgentResult(
|
|
129
|
+
agent_name=self.name,
|
|
130
|
+
success=True,
|
|
131
|
+
duration=0.0, # Would be calculated in real implementation
|
|
132
|
+
message=f"Scanned {file_path} with {results.tool}",
|
|
133
|
+
data={
|
|
134
|
+
"file": str(path),
|
|
135
|
+
"tool": results.tool,
|
|
136
|
+
"issues_found": len(results.issues),
|
|
137
|
+
"issues": results.issues,
|
|
138
|
+
"severity_breakdown": results._get_severity_breakdown(),
|
|
139
|
+
"confidence_breakdown": results._get_confidence_breakdown(),
|
|
140
|
+
"errors": results.errors,
|
|
141
|
+
},
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Write to context store for Claude Code integration
|
|
145
|
+
await self._write_findings_to_context(path, results.issues)
|
|
146
|
+
|
|
147
|
+
return agent_result
|
|
148
|
+
except Exception as e:
|
|
149
|
+
self.logger.error(
|
|
150
|
+
f"Error handling security scan for {event.payload.get('path', 'unknown')}: {e}",
|
|
151
|
+
exc_info=True,
|
|
152
|
+
)
|
|
153
|
+
return AgentResult(
|
|
154
|
+
agent_name=self.name,
|
|
155
|
+
success=False,
|
|
156
|
+
duration=0.0,
|
|
157
|
+
message=f"Security scan failed: {str(e)}",
|
|
158
|
+
error=str(e),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def _write_findings_to_context(
|
|
162
|
+
self, path: Path, issues: List[Dict[str, Any]]
|
|
163
|
+
) -> None:
|
|
164
|
+
"""Write security issues to the context store."""
|
|
165
|
+
# Map bandit severity to our Severity enum
|
|
166
|
+
severity_map = {
|
|
167
|
+
"high": Severity.ERROR,
|
|
168
|
+
"medium": Severity.WARNING,
|
|
169
|
+
"low": Severity.INFO,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for idx, issue in enumerate(issues):
|
|
173
|
+
issue_severity = issue.get("severity", "medium").lower()
|
|
174
|
+
severity = severity_map.get(issue_severity, Severity.WARNING)
|
|
175
|
+
|
|
176
|
+
# Security issues are always blocking if high severity
|
|
177
|
+
blocking = issue_severity == "high"
|
|
178
|
+
|
|
179
|
+
finding = Finding(
|
|
180
|
+
id=f"security_{path.name}_{issue.get('line_number', 0)}_{idx}",
|
|
181
|
+
agent="security-scanner",
|
|
182
|
+
timestamp=datetime.now(UTC).isoformat() + "Z",
|
|
183
|
+
file=str(path),
|
|
184
|
+
line=issue.get("line_number"),
|
|
185
|
+
severity=severity,
|
|
186
|
+
blocking=blocking,
|
|
187
|
+
category=f"security_{issue.get('test_id', 'unknown')}",
|
|
188
|
+
message=issue.get("text", "Security issue detected"),
|
|
189
|
+
scope_type=ScopeType.CURRENT_FILE,
|
|
190
|
+
caused_by_recent_change=True,
|
|
191
|
+
is_new=True,
|
|
192
|
+
)
|
|
193
|
+
await context_store.add_finding(finding)
|
|
194
|
+
|
|
195
|
+
def _should_exclude_file(self, file_path: str) -> bool:
|
|
196
|
+
"""Check if file should be excluded from scanning."""
|
|
197
|
+
if not self.config.exclude_patterns:
|
|
198
|
+
return False
|
|
199
|
+
for pattern in self.config.exclude_patterns:
|
|
200
|
+
if pattern.startswith("*") and pattern.endswith("*"):
|
|
201
|
+
if pattern[1:-1] in file_path:
|
|
202
|
+
return True
|
|
203
|
+
elif pattern.startswith("*"):
|
|
204
|
+
if file_path.endswith(pattern[1:]):
|
|
205
|
+
return True
|
|
206
|
+
elif pattern.endswith("*"):
|
|
207
|
+
if file_path.startswith(pattern[:-1]):
|
|
208
|
+
return True
|
|
209
|
+
elif pattern == file_path:
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
async def _run_security_scan(self, file_path: Path) -> SecurityResult:
|
|
214
|
+
"""Run security scanning tools."""
|
|
215
|
+
try:
|
|
216
|
+
results = []
|
|
217
|
+
|
|
218
|
+
# Try bandit first (most common Python security scanner)
|
|
219
|
+
if self.config.enabled_tools and "bandit" in self.config.enabled_tools:
|
|
220
|
+
bandit_result = await self._run_bandit(file_path)
|
|
221
|
+
if bandit_result:
|
|
222
|
+
results.append(bandit_result)
|
|
223
|
+
|
|
224
|
+
# If no results from primary tools, return empty
|
|
225
|
+
if results:
|
|
226
|
+
return results[0] # Return first successful result
|
|
227
|
+
|
|
228
|
+
return SecurityResult("none", [], ["No security scanning tools available"])
|
|
229
|
+
except Exception as e:
|
|
230
|
+
self.logger.error(
|
|
231
|
+
f"Error running security scan on {file_path}: {e}", exc_info=True
|
|
232
|
+
)
|
|
233
|
+
return SecurityResult("error", [], [f"Security scan error: {str(e)}"])
|
|
234
|
+
|
|
235
|
+
async def _run_bandit(self, file_path: Path) -> Optional[SecurityResult]:
|
|
236
|
+
"""Run Bandit security scanner."""
|
|
237
|
+
try:
|
|
238
|
+
# Check if bandit is available
|
|
239
|
+
import subprocess # nosec B404 - Required for running security analysis tools
|
|
240
|
+
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
[sys.executable, "-c", "import bandit"], capture_output=True, text=True
|
|
243
|
+
) # nosec B603 - Running trusted system Python with safe arguments
|
|
244
|
+
if result.returncode != 0:
|
|
245
|
+
return SecurityResult(
|
|
246
|
+
"bandit", [], ["Bandit not installed - run: pip install bandit"]
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
cmd = [
|
|
250
|
+
sys.executable,
|
|
251
|
+
"-m",
|
|
252
|
+
"bandit",
|
|
253
|
+
"-f",
|
|
254
|
+
"json",
|
|
255
|
+
"-r",
|
|
256
|
+
str(file_path),
|
|
257
|
+
"--severity-level",
|
|
258
|
+
self.config.severity_threshold,
|
|
259
|
+
"--confidence-level",
|
|
260
|
+
self.config.confidence_threshold,
|
|
261
|
+
]
|
|
262
|
+
if self.config.exclude_patterns:
|
|
263
|
+
cmd.extend(["-x", ",".join(self.config.exclude_patterns)])
|
|
264
|
+
|
|
265
|
+
# Run bandit in subprocess
|
|
266
|
+
process = await asyncio.create_subprocess_exec(
|
|
267
|
+
*cmd,
|
|
268
|
+
stdout=asyncio.subprocess.PIPE,
|
|
269
|
+
stderr=asyncio.subprocess.PIPE,
|
|
270
|
+
cwd=file_path.parent,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
stdout, stderr = await process.communicate()
|
|
274
|
+
|
|
275
|
+
if process.returncode == 0:
|
|
276
|
+
# Parse JSON output
|
|
277
|
+
try:
|
|
278
|
+
data = json.loads(stdout.decode())
|
|
279
|
+
issues = []
|
|
280
|
+
|
|
281
|
+
# Extract issues from bandit output
|
|
282
|
+
for result in data.get("results", []):
|
|
283
|
+
filename = result.get("filename", "")
|
|
284
|
+
if str(file_path) in filename or filename.endswith(
|
|
285
|
+
str(file_path)
|
|
286
|
+
):
|
|
287
|
+
for issue in result.get("issues", []):
|
|
288
|
+
issues.append(
|
|
289
|
+
{
|
|
290
|
+
"code": issue.get("code", ""),
|
|
291
|
+
"filename": issue.get("filename", ""),
|
|
292
|
+
"line_number": issue.get("line_number", 0),
|
|
293
|
+
"line_range": issue.get("line_range", []),
|
|
294
|
+
"test_id": issue.get("test_id", ""),
|
|
295
|
+
"test_name": issue.get("test_name", ""),
|
|
296
|
+
"severity": issue.get(
|
|
297
|
+
"issue_severity", "unknown"
|
|
298
|
+
),
|
|
299
|
+
"confidence": issue.get(
|
|
300
|
+
"issue_confidence", "unknown"
|
|
301
|
+
),
|
|
302
|
+
"text": issue.get("issue_text", ""),
|
|
303
|
+
"cwe": issue.get("cwe", {}),
|
|
304
|
+
"more_info": issue.get("more_info", ""),
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return SecurityResult("bandit", issues[: self.config.max_issues])
|
|
309
|
+
|
|
310
|
+
except json.JSONDecodeError:
|
|
311
|
+
return SecurityResult(
|
|
312
|
+
"bandit",
|
|
313
|
+
[],
|
|
314
|
+
[f"Failed to parse bandit output: {stdout.decode()[:200]}"],
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
else:
|
|
318
|
+
error_msg = stderr.decode().strip()
|
|
319
|
+
return SecurityResult("bandit", [], [f"Bandit failed: {error_msg}"])
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
return SecurityResult("bandit", [], [f"Bandit execution error: {str(e)}"])
|
devloop/agents/snyk.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Snyk agent - integrates Snyk CLI for vulnerability scanning."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from devloop.core.agent import Agent, AgentResult
|
|
10
|
+
from devloop.core.context_store import Finding, Severity
|
|
11
|
+
from devloop.core.event import Event
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SnykConfig:
|
|
15
|
+
"""Configuration for SnykAgent."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: Dict[str, Any]):
|
|
18
|
+
self.enabled = config.get("enabled", True)
|
|
19
|
+
self.api_token = config.get("apiToken")
|
|
20
|
+
self.severity = config.get("severity", "high")
|
|
21
|
+
self.file_patterns = config.get(
|
|
22
|
+
"filePatterns",
|
|
23
|
+
["**/package.json", "**/requirements.txt", "**/Gemfile", "**/pom.xml"],
|
|
24
|
+
)
|
|
25
|
+
self.debounce = config.get("debounce", 500) # ms
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SnykResult:
|
|
29
|
+
"""Result from running Snyk scan."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
success: bool,
|
|
34
|
+
vulnerabilities: List[Dict[str, Any]] | None = None,
|
|
35
|
+
error: str | None = None,
|
|
36
|
+
):
|
|
37
|
+
self.success = success
|
|
38
|
+
self.vulnerabilities = vulnerabilities or []
|
|
39
|
+
self.error = error
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def has_vulnerabilities(self) -> bool:
|
|
43
|
+
"""Check if there are any vulnerabilities."""
|
|
44
|
+
return len(self.vulnerabilities) > 0
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def vulnerability_count(self) -> int:
|
|
48
|
+
"""Get number of vulnerabilities."""
|
|
49
|
+
return len(self.vulnerabilities)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def critical_count(self) -> int:
|
|
53
|
+
"""Get count of critical vulnerabilities."""
|
|
54
|
+
return sum(
|
|
55
|
+
1
|
|
56
|
+
for v in self.vulnerabilities
|
|
57
|
+
if v.get("severity", "").lower() == "critical"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def high_count(self) -> int:
|
|
62
|
+
"""Get count of high severity vulnerabilities."""
|
|
63
|
+
return sum(
|
|
64
|
+
1 for v in self.vulnerabilities if v.get("severity", "").lower() == "high"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SnykAgent(Agent):
|
|
69
|
+
"""Agent that runs Snyk security scanning on dependency files."""
|
|
70
|
+
|
|
71
|
+
def __init__(
|
|
72
|
+
self,
|
|
73
|
+
name: str,
|
|
74
|
+
triggers: List[str],
|
|
75
|
+
event_bus,
|
|
76
|
+
config: Dict[str, Any] | None = None,
|
|
77
|
+
feedback_api=None,
|
|
78
|
+
performance_monitor=None,
|
|
79
|
+
):
|
|
80
|
+
super().__init__(
|
|
81
|
+
name,
|
|
82
|
+
triggers,
|
|
83
|
+
event_bus,
|
|
84
|
+
feedback_api=feedback_api,
|
|
85
|
+
performance_monitor=performance_monitor,
|
|
86
|
+
)
|
|
87
|
+
self.config = SnykConfig(config or {})
|
|
88
|
+
self._last_run: Dict[str, float] = {} # path -> timestamp for debouncing
|
|
89
|
+
|
|
90
|
+
async def handle(self, event: Event) -> AgentResult:
|
|
91
|
+
"""Handle file change event by running Snyk scan."""
|
|
92
|
+
# Extract file path
|
|
93
|
+
file_path = event.payload.get("path")
|
|
94
|
+
if not file_path:
|
|
95
|
+
return AgentResult(
|
|
96
|
+
agent_name=self.name,
|
|
97
|
+
success=True,
|
|
98
|
+
duration=0,
|
|
99
|
+
message="No file path in event",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
path = Path(file_path)
|
|
103
|
+
|
|
104
|
+
# Check if file should be scanned
|
|
105
|
+
if not self._should_scan(path):
|
|
106
|
+
return AgentResult(
|
|
107
|
+
agent_name=self.name,
|
|
108
|
+
success=True,
|
|
109
|
+
duration=0,
|
|
110
|
+
message=f"Skipped {path.name} (not a dependency file)",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Run Snyk scan
|
|
114
|
+
result = await self._run_snyk(path)
|
|
115
|
+
|
|
116
|
+
# Build result message
|
|
117
|
+
if result.error:
|
|
118
|
+
message = f"Snyk error on {path.name}: {result.error}"
|
|
119
|
+
success = False
|
|
120
|
+
elif result.has_vulnerabilities:
|
|
121
|
+
critical = result.critical_count
|
|
122
|
+
high = result.high_count
|
|
123
|
+
message = (
|
|
124
|
+
f"Found {result.vulnerability_count} vulnerability(ies) in {path.name}"
|
|
125
|
+
)
|
|
126
|
+
if critical > 0:
|
|
127
|
+
message += f" ({critical} critical, {high} high)"
|
|
128
|
+
success = True
|
|
129
|
+
else:
|
|
130
|
+
message = f"No vulnerabilities in {path.name}"
|
|
131
|
+
success = True
|
|
132
|
+
|
|
133
|
+
agent_result = AgentResult(
|
|
134
|
+
agent_name=self.name,
|
|
135
|
+
success=success,
|
|
136
|
+
duration=0,
|
|
137
|
+
message=message,
|
|
138
|
+
data={
|
|
139
|
+
"file": str(path),
|
|
140
|
+
"tool": "snyk",
|
|
141
|
+
"vulnerabilities": result.vulnerabilities,
|
|
142
|
+
"vulnerability_count": result.vulnerability_count,
|
|
143
|
+
"critical_count": result.critical_count,
|
|
144
|
+
"high_count": result.high_count,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Write findings to context store
|
|
149
|
+
await self._write_findings_to_context(path, result)
|
|
150
|
+
|
|
151
|
+
return agent_result
|
|
152
|
+
|
|
153
|
+
def _should_scan(self, path: Path) -> bool:
|
|
154
|
+
"""Check if file should be scanned based on patterns."""
|
|
155
|
+
# Skip if file doesn't exist
|
|
156
|
+
if not path.exists():
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
# Only scan dependency files
|
|
160
|
+
file_name = path.name
|
|
161
|
+
for pattern in self.config.file_patterns:
|
|
162
|
+
# Simple pattern matching for common dependency files
|
|
163
|
+
if pattern.startswith("**/"):
|
|
164
|
+
pattern = pattern[3:]
|
|
165
|
+
if file_name == pattern:
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
async def _run_snyk(self, path: Path) -> SnykResult:
|
|
171
|
+
"""Run Snyk scan on a dependency file."""
|
|
172
|
+
try:
|
|
173
|
+
# Check if snyk CLI is installed
|
|
174
|
+
check = await asyncio.create_subprocess_exec(
|
|
175
|
+
"snyk",
|
|
176
|
+
"--version",
|
|
177
|
+
stdout=asyncio.subprocess.PIPE,
|
|
178
|
+
stderr=asyncio.subprocess.PIPE,
|
|
179
|
+
)
|
|
180
|
+
await check.communicate()
|
|
181
|
+
|
|
182
|
+
if check.returncode != 0:
|
|
183
|
+
return SnykResult(
|
|
184
|
+
success=False, error="snyk CLI not installed or not authenticated"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# Run Snyk test with JSON output
|
|
188
|
+
proc = await asyncio.create_subprocess_exec(
|
|
189
|
+
"snyk",
|
|
190
|
+
"test",
|
|
191
|
+
str(path.parent), # Scan from directory containing dependency file
|
|
192
|
+
"--json",
|
|
193
|
+
stdout=asyncio.subprocess.PIPE,
|
|
194
|
+
stderr=asyncio.subprocess.PIPE,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
stdout, stderr = await proc.communicate()
|
|
198
|
+
|
|
199
|
+
# Snyk returns non-zero if vulnerabilities found, but that's expected
|
|
200
|
+
if stdout:
|
|
201
|
+
try:
|
|
202
|
+
data = json.loads(stdout.decode())
|
|
203
|
+
|
|
204
|
+
# Handle Snyk JSON response format
|
|
205
|
+
if isinstance(data, dict):
|
|
206
|
+
vulnerabilities = data.get("vulnerabilities", [])
|
|
207
|
+
error = data.get("error")
|
|
208
|
+
if error:
|
|
209
|
+
error_msg = error.get("message", "Unknown Snyk error")
|
|
210
|
+
return SnykResult(
|
|
211
|
+
success=False,
|
|
212
|
+
error=error_msg,
|
|
213
|
+
)
|
|
214
|
+
return SnykResult(success=True, vulnerabilities=vulnerabilities)
|
|
215
|
+
else:
|
|
216
|
+
# If it's a list, use it directly
|
|
217
|
+
return SnykResult(success=True, vulnerabilities=data)
|
|
218
|
+
|
|
219
|
+
except json.JSONDecodeError as e:
|
|
220
|
+
return SnykResult(
|
|
221
|
+
success=False, error=f"Failed to parse Snyk output: {e}"
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return SnykResult(success=True, vulnerabilities=[])
|
|
225
|
+
|
|
226
|
+
except FileNotFoundError:
|
|
227
|
+
return SnykResult(success=False, error="snyk command not found")
|
|
228
|
+
except Exception as e:
|
|
229
|
+
self.logger.error(f"Error running Snyk: {e}")
|
|
230
|
+
return SnykResult(success=False, error=str(e))
|
|
231
|
+
|
|
232
|
+
async def _write_findings_to_context(self, path: Path, result: SnykResult) -> None:
|
|
233
|
+
"""Write Snyk findings to context store."""
|
|
234
|
+
if not result.success or not result.has_vulnerabilities:
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
from devloop.core.context_store import context_store
|
|
238
|
+
|
|
239
|
+
# Convert each vulnerability to a Finding
|
|
240
|
+
for idx, vuln in enumerate(result.vulnerabilities):
|
|
241
|
+
# Extract vulnerability details
|
|
242
|
+
vuln_id = vuln.get("id", f"snyk-{idx}")
|
|
243
|
+
title = vuln.get("title", "Unknown vulnerability")
|
|
244
|
+
severity_str = vuln.get("severity", "medium").lower()
|
|
245
|
+
cvss_score = vuln.get("cvssScore")
|
|
246
|
+
package = vuln.get("package", "unknown")
|
|
247
|
+
from_package = vuln.get("from", [])
|
|
248
|
+
fix_available = vuln.get("fixAvailable", False)
|
|
249
|
+
upgradePath = vuln.get("upgradePath", [])
|
|
250
|
+
|
|
251
|
+
# Map severity values
|
|
252
|
+
severity_map = {
|
|
253
|
+
"critical": Severity.ERROR,
|
|
254
|
+
"high": Severity.ERROR,
|
|
255
|
+
"medium": Severity.WARNING,
|
|
256
|
+
"low": Severity.INFO,
|
|
257
|
+
}
|
|
258
|
+
severity = severity_map.get(severity_str, Severity.WARNING)
|
|
259
|
+
|
|
260
|
+
# Build suggestion
|
|
261
|
+
suggestion = ""
|
|
262
|
+
if fix_available and upgradePath:
|
|
263
|
+
suggestion = f"Upgrade path available: {' -> '.join(upgradePath)}"
|
|
264
|
+
elif fix_available:
|
|
265
|
+
suggestion = "Fix available - run 'snyk fix' to apply"
|
|
266
|
+
|
|
267
|
+
# Create Finding
|
|
268
|
+
finding = Finding(
|
|
269
|
+
id=f"{self.name}-{path.name}-{vuln_id}",
|
|
270
|
+
agent=self.name,
|
|
271
|
+
timestamp=str(datetime.now()),
|
|
272
|
+
file=str(path),
|
|
273
|
+
line=None,
|
|
274
|
+
column=None,
|
|
275
|
+
severity=severity,
|
|
276
|
+
message=title,
|
|
277
|
+
category=f"vulnerability-{severity_str}",
|
|
278
|
+
suggestion=suggestion,
|
|
279
|
+
context={
|
|
280
|
+
"tool": "snyk",
|
|
281
|
+
"vulnerability_id": vuln_id,
|
|
282
|
+
"package": package,
|
|
283
|
+
"cvss_score": cvss_score,
|
|
284
|
+
"fix_available": fix_available,
|
|
285
|
+
"from": from_package,
|
|
286
|
+
},
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
await context_store.add_finding(finding)
|
|
291
|
+
except Exception as e:
|
|
292
|
+
self.logger.error(f"Failed to write finding to context: {e}")
|