k-cli-for-devs 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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,999 @@
|
|
|
1
|
+
"""
|
|
2
|
+
security_healer.py - Advanced AST & Regex Security Scanner & Surgical Auto-Healer for K-CLI
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
1. Fast AST & Regex static detection:
|
|
6
|
+
- Hardcoded API keys, tokens, and credentials (OpenAI, HuggingFace, GitHub, AWS, Slack, Private Keys, JWT).
|
|
7
|
+
- SQL Injection patterns (f-string interpolation, %, +, .format() in SQL calls).
|
|
8
|
+
- Unsafe code execution (eval(), exec()).
|
|
9
|
+
- Unsafe deserialization (pickle.loads(), yaml.load() without SafeLoader).
|
|
10
|
+
- Command injection (subprocess with shell=True, os.system(), os.popen()).
|
|
11
|
+
- Insecure ReDoS (Regular Expression Denial of Service) exponential backtracking patterns.
|
|
12
|
+
2. CWE Mapping, Severity classification (CRITICAL, HIGH, MEDIUM, LOW), and CVSS-style scoring.
|
|
13
|
+
3. Surgical Auto-Healing Loop:
|
|
14
|
+
- Generates surgical SEARCH/REPLACE patches using `patcher.py`.
|
|
15
|
+
- Verifies AST syntax and executes test suites via `verifier.py`.
|
|
16
|
+
- Re-scans to confirm vulnerability elimination with zero regressions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
import difflib
|
|
23
|
+
import json
|
|
24
|
+
import logging
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import uuid
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from enum import Enum
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any, Dict, List, Optional, Set, Tuple, Union
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Safe relative / package imports
|
|
36
|
+
try:
|
|
37
|
+
from k_cli.git.verifier import VerificationResult, Verifier
|
|
38
|
+
except (ModuleNotFoundError, ImportError):
|
|
39
|
+
try:
|
|
40
|
+
from verifier import VerificationResult, Verifier
|
|
41
|
+
except (ModuleNotFoundError, ImportError):
|
|
42
|
+
VerificationResult = None # type: ignore
|
|
43
|
+
Verifier = None # type: ignore
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
from k_cli.git.patcher import FilePatch, PatchResult, Patcher
|
|
47
|
+
except (ModuleNotFoundError, ImportError):
|
|
48
|
+
try:
|
|
49
|
+
from patcher import FilePatch, PatchResult, Patcher
|
|
50
|
+
except (ModuleNotFoundError, ImportError):
|
|
51
|
+
FilePatch = None # type: ignore
|
|
52
|
+
PatchResult = None # type: ignore
|
|
53
|
+
Patcher = None # type: ignore
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
from k_cli.core.llm_driver import LLMDriver
|
|
57
|
+
except (ModuleNotFoundError, ImportError):
|
|
58
|
+
try:
|
|
59
|
+
from k_cli.core.llm_driver import LLMDriver
|
|
60
|
+
except (ModuleNotFoundError, ImportError):
|
|
61
|
+
LLMDriver = None # type: ignore
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VulnerabilitySeverity(str, Enum):
|
|
65
|
+
"""Vulnerability severity rankings."""
|
|
66
|
+
CRITICAL = "CRITICAL"
|
|
67
|
+
HIGH = "HIGH"
|
|
68
|
+
MEDIUM = "MEDIUM"
|
|
69
|
+
LOW = "LOW"
|
|
70
|
+
INFO = "INFO"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class VulnerabilityType(str, Enum):
|
|
74
|
+
"""Categorized vulnerability types."""
|
|
75
|
+
HARDCODED_SECRET = "HARDCODED_SECRET"
|
|
76
|
+
SQL_INJECTION = "SQL_INJECTION"
|
|
77
|
+
UNSAFE_EVAL = "UNSAFE_EVAL"
|
|
78
|
+
UNSAFE_DESERIALIZATION = "UNSAFE_DESERIALIZATION"
|
|
79
|
+
COMMAND_INJECTION = "COMMAND_INJECTION"
|
|
80
|
+
REDOS = "REDOS"
|
|
81
|
+
PATH_TRAVERSAL = "PATH_TRAVERSAL"
|
|
82
|
+
INSECURE_CIPHER = "INSECURE_CIPHER"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class VulnerabilityFinding:
|
|
87
|
+
"""Represents an individual detected security vulnerability."""
|
|
88
|
+
id: str
|
|
89
|
+
vuln_type: str
|
|
90
|
+
severity: str
|
|
91
|
+
cvss_score: float
|
|
92
|
+
cvss_vector: str
|
|
93
|
+
file_path: str
|
|
94
|
+
line_number: int
|
|
95
|
+
snippet: str
|
|
96
|
+
description: str
|
|
97
|
+
recommendation: str
|
|
98
|
+
cwe_id: str
|
|
99
|
+
end_line_number: Optional[int] = None
|
|
100
|
+
suggested_patch: Optional[str] = None
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
103
|
+
return {
|
|
104
|
+
"id": self.id,
|
|
105
|
+
"vuln_type": self.vuln_type,
|
|
106
|
+
"severity": self.severity,
|
|
107
|
+
"cvss_score": self.cvss_score,
|
|
108
|
+
"cvss_vector": self.cvss_vector,
|
|
109
|
+
"file_path": self.file_path,
|
|
110
|
+
"line_number": self.line_number,
|
|
111
|
+
"end_line_number": self.end_line_number,
|
|
112
|
+
"snippet": self.snippet,
|
|
113
|
+
"description": self.description,
|
|
114
|
+
"recommendation": self.recommendation,
|
|
115
|
+
"cwe_id": self.cwe_id,
|
|
116
|
+
"suggested_patch": self.suggested_patch,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class SecurityScanReport:
|
|
122
|
+
"""Aggregated report of repository security scan findings."""
|
|
123
|
+
repo_path: str
|
|
124
|
+
findings: List[VulnerabilityFinding] = field(default_factory=list)
|
|
125
|
+
scanned_files_count: int = 0
|
|
126
|
+
scan_duration_seconds: float = 0.0
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def total_files_scanned(self) -> int:
|
|
130
|
+
return self.scanned_files_count
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def files_scanned(self) -> List[str]:
|
|
134
|
+
return list({f.file_path for f in self.findings})
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def critical_count(self) -> int:
|
|
138
|
+
return sum(1 for f in self.findings if f.severity == VulnerabilitySeverity.CRITICAL.value)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def high_count(self) -> int:
|
|
142
|
+
return sum(1 for f in self.findings if f.severity == VulnerabilitySeverity.HIGH.value)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def medium_count(self) -> int:
|
|
146
|
+
return sum(1 for f in self.findings if f.severity == VulnerabilitySeverity.MEDIUM.value)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def low_count(self) -> int:
|
|
150
|
+
return sum(1 for f in self.findings if f.severity == VulnerabilitySeverity.LOW.value)
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def total_findings(self) -> int:
|
|
154
|
+
return len(self.findings)
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def max_cvss_score(self) -> float:
|
|
158
|
+
return max([f.cvss_score for f in self.findings], default=0.0)
|
|
159
|
+
|
|
160
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
161
|
+
return {
|
|
162
|
+
"repo_path": self.repo_path,
|
|
163
|
+
"scanned_files_count": self.scanned_files_count,
|
|
164
|
+
"scan_duration_seconds": round(self.scan_duration_seconds, 3),
|
|
165
|
+
"total_findings": self.total_findings,
|
|
166
|
+
"critical_count": self.critical_count,
|
|
167
|
+
"high_count": self.high_count,
|
|
168
|
+
"medium_count": self.medium_count,
|
|
169
|
+
"low_count": self.low_count,
|
|
170
|
+
"max_cvss_score": self.max_cvss_score,
|
|
171
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
def to_json(self, indent: int = 2) -> str:
|
|
175
|
+
return json.dumps(self.to_dict(), indent=indent)
|
|
176
|
+
|
|
177
|
+
def to_markdown(self) -> str:
|
|
178
|
+
"""Renders rich Markdown summary of the scan report."""
|
|
179
|
+
md = [
|
|
180
|
+
f"# 🛡️ Security Audit Report",
|
|
181
|
+
f"**Repository Root**: `{self.repo_path}` ",
|
|
182
|
+
f"**Files Scanned**: `{self.scanned_files_count}` | **Duration**: `{self.scan_duration_seconds:.2f}s` | **Max CVSS**: `{self.max_cvss_score}`\n",
|
|
183
|
+
f"### 📊 Findings Breakdown",
|
|
184
|
+
f"- **CRITICAL**: `{self.critical_count}`",
|
|
185
|
+
f"- **HIGH**: `{self.high_count}`",
|
|
186
|
+
f"- **MEDIUM**: `{self.medium_count}`",
|
|
187
|
+
f"- **LOW**: `{self.low_count}`",
|
|
188
|
+
f"- **Total**: `{self.total_findings}`\n",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
if not self.findings:
|
|
192
|
+
md.append("✅ **Clean Workspace**: No security vulnerabilities or hardcoded credentials detected.")
|
|
193
|
+
return "\n".join(md)
|
|
194
|
+
|
|
195
|
+
md.append("### 🔍 Detected Vulnerabilities\n")
|
|
196
|
+
md.append("| ID | Severity | Type | File:Line | CVSS | CWE | Description |")
|
|
197
|
+
md.append("| :--- | :--- | :--- | :--- | :--- | :--- | :--- |")
|
|
198
|
+
for f in self.findings:
|
|
199
|
+
md.append(
|
|
200
|
+
f"| `{f.id}` | **{f.severity}** | `{f.vuln_type}` | `{f.file_path}:{f.line_number}` | `{f.cvss_score}` | `{f.cwe_id}` | {f.description} |"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return "\n".join(md)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass
|
|
207
|
+
class VulnerabilityHealResult:
|
|
208
|
+
"""Result of an automated surgical remediation attempt."""
|
|
209
|
+
vuln_id: str
|
|
210
|
+
file_path: str
|
|
211
|
+
success: bool
|
|
212
|
+
applied_patch: str = ""
|
|
213
|
+
syntax_verified: bool = False
|
|
214
|
+
tests_passed: bool = False
|
|
215
|
+
rescan_clean: bool = False
|
|
216
|
+
error_message: str = ""
|
|
217
|
+
diff: str = ""
|
|
218
|
+
|
|
219
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
220
|
+
return {
|
|
221
|
+
"vuln_id": self.vuln_id,
|
|
222
|
+
"file_path": self.file_path,
|
|
223
|
+
"success": self.success,
|
|
224
|
+
"applied_patch": self.applied_patch,
|
|
225
|
+
"syntax_verified": self.syntax_verified,
|
|
226
|
+
"tests_passed": self.tests_passed,
|
|
227
|
+
"rescan_clean": self.rescan_clean,
|
|
228
|
+
"error_message": self.error_message,
|
|
229
|
+
"diff": self.diff,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
SECRET_REGEX_RULES = [
|
|
234
|
+
{
|
|
235
|
+
"name": "OpenAI API Key",
|
|
236
|
+
"pattern": re.compile(r"\b(?:sk-[A-Za-z0-9_-]{20,}|sk-proj-[A-Za-z0-9_-]{20,})\b"),
|
|
237
|
+
"severity": VulnerabilitySeverity.CRITICAL.value,
|
|
238
|
+
"cvss": 9.8,
|
|
239
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
240
|
+
"cwe": "CWE-798",
|
|
241
|
+
"desc": "Hardcoded OpenAI API key exposed in source code.",
|
|
242
|
+
"rec": "Migrate secret to environment variables or secret manager using os.environ.get('OPENAI_API_KEY').",
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
"name": "Hugging Face Token",
|
|
246
|
+
"pattern": re.compile(r"\bhf_[A-Za-z0-9]{20,}\b"),
|
|
247
|
+
"severity": VulnerabilitySeverity.CRITICAL.value,
|
|
248
|
+
"cvss": 9.8,
|
|
249
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
250
|
+
"cwe": "CWE-798",
|
|
251
|
+
"desc": "Hardcoded Hugging Face access token exposed in source code.",
|
|
252
|
+
"rec": "Use os.environ.get('HF_TOKEN') instead of hardcoding credentials.",
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
"name": "GitHub Token",
|
|
256
|
+
"pattern": re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{50,}\b"),
|
|
257
|
+
"severity": VulnerabilitySeverity.CRITICAL.value,
|
|
258
|
+
"cvss": 9.8,
|
|
259
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
260
|
+
"cwe": "CWE-798",
|
|
261
|
+
"desc": "Hardcoded GitHub personal access token exposed in source code.",
|
|
262
|
+
"rec": "Inject token at runtime via GITHUB_TOKEN environment variable.",
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"name": "AWS Access Key",
|
|
266
|
+
"pattern": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
267
|
+
"severity": VulnerabilitySeverity.CRITICAL.value,
|
|
268
|
+
"cvss": 9.8,
|
|
269
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
270
|
+
"cwe": "CWE-798",
|
|
271
|
+
"desc": "Hardcoded AWS Access Key ID exposed in source code.",
|
|
272
|
+
"rec": "Use AWS IAM roles or AWS_ACCESS_KEY_ID environment variable.",
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"name": "Private Key",
|
|
276
|
+
"pattern": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
|
|
277
|
+
"severity": VulnerabilitySeverity.CRITICAL.value,
|
|
278
|
+
"cvss": 9.8,
|
|
279
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
280
|
+
"cwe": "CWE-798",
|
|
281
|
+
"desc": "Unencrypted Private Key block embedded directly in repository.",
|
|
282
|
+
"rec": "Store private keys in secure vault or filesystem with strict 0600 permissions.",
|
|
283
|
+
},
|
|
284
|
+
{
|
|
285
|
+
"name": "Slack Token",
|
|
286
|
+
"pattern": re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b"),
|
|
287
|
+
"severity": VulnerabilitySeverity.HIGH.value,
|
|
288
|
+
"cvss": 8.5,
|
|
289
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N",
|
|
290
|
+
"cwe": "CWE-798",
|
|
291
|
+
"desc": "Hardcoded Slack OAuth bot / user token exposed in source code.",
|
|
292
|
+
"rec": "Store Slack tokens in SLACK_BOT_TOKEN environment variable.",
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"name": "JWT Token Secret",
|
|
296
|
+
"pattern": re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
|
|
297
|
+
"severity": VulnerabilitySeverity.HIGH.value,
|
|
298
|
+
"cvss": 8.1,
|
|
299
|
+
"vector": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
|
|
300
|
+
"cwe": "CWE-798",
|
|
301
|
+
"desc": "Hardcoded JSON Web Token (JWT) exposed in source code.",
|
|
302
|
+
"rec": "Do not hardcode JWT tokens. Generate tokens dynamically using environment secrets.",
|
|
303
|
+
},
|
|
304
|
+
]
|
|
305
|
+
|
|
306
|
+
REDOS_PATTERNS = [
|
|
307
|
+
re.compile(r"\((?:[^()]|\([^()]*\))+(?:\+|\*|\{\d+,?\d*\})\)(?:\+|\*|\{\d+,?\d*\})"),
|
|
308
|
+
]
|
|
309
|
+
|
|
310
|
+
IGNORED_DIRS = {
|
|
311
|
+
".git",
|
|
312
|
+
".venv",
|
|
313
|
+
"venv",
|
|
314
|
+
"k_cli_env",
|
|
315
|
+
"node_modules",
|
|
316
|
+
"__pycache__",
|
|
317
|
+
".pytest_cache",
|
|
318
|
+
"build",
|
|
319
|
+
"dist",
|
|
320
|
+
".eggs",
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
SCANNABLE_EXTENSIONS = {
|
|
324
|
+
".py",
|
|
325
|
+
".js",
|
|
326
|
+
".ts",
|
|
327
|
+
".json",
|
|
328
|
+
".yaml",
|
|
329
|
+
".yml",
|
|
330
|
+
".toml",
|
|
331
|
+
".sh",
|
|
332
|
+
".bash",
|
|
333
|
+
".sql",
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class SecurityHealer:
|
|
338
|
+
"""
|
|
339
|
+
Static AST & Regex Security Scanner and Automated Remediation Engine for K-CLI.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(self, repo_path: str = ".", llm_driver: Optional[Any] = None):
|
|
343
|
+
self.repo_path = Path(repo_path).resolve()
|
|
344
|
+
self.llm_driver = llm_driver
|
|
345
|
+
|
|
346
|
+
# =========================================================================
|
|
347
|
+
# 1. Repository Scanning Engine
|
|
348
|
+
# =========================================================================
|
|
349
|
+
|
|
350
|
+
def scan_repository(self, repo_path: Optional[str] = None) -> SecurityScanReport:
|
|
351
|
+
"""
|
|
352
|
+
Performs high-speed AST & regex scan across repository files.
|
|
353
|
+
"""
|
|
354
|
+
import time
|
|
355
|
+
|
|
356
|
+
start_time = time.time()
|
|
357
|
+
root = Path(repo_path).resolve() if repo_path else self.repo_path
|
|
358
|
+
|
|
359
|
+
findings: List[VulnerabilityFinding] = []
|
|
360
|
+
scanned_count = 0
|
|
361
|
+
vuln_counter = 1
|
|
362
|
+
|
|
363
|
+
for path in root.rglob("*"):
|
|
364
|
+
if not path.is_file() or path.suffix.lower() not in SCANNABLE_EXTENSIONS:
|
|
365
|
+
continue
|
|
366
|
+
if any(part in IGNORED_DIRS for part in path.parts):
|
|
367
|
+
continue
|
|
368
|
+
|
|
369
|
+
rel_path = path.relative_to(root).as_posix()
|
|
370
|
+
scanned_count += 1
|
|
371
|
+
|
|
372
|
+
try:
|
|
373
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
374
|
+
except Exception:
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
lines = content.splitlines()
|
|
378
|
+
# 1. Regex-based secret detection
|
|
379
|
+
for line_idx, line in enumerate(lines, start=1):
|
|
380
|
+
if "rule" in line.lower() and "re.compile" in line.lower():
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
for rule in SECRET_REGEX_RULES:
|
|
384
|
+
match = rule["pattern"].search(line)
|
|
385
|
+
if match:
|
|
386
|
+
matched_val = match.group(0)
|
|
387
|
+
if any(ph in matched_val.lower() for ph in ("example", "your_key", "placeholder", "dummy", "xxxx")):
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
v_id = f"SEC-KEY-{vuln_counter:03d}"
|
|
391
|
+
vuln_counter += 1
|
|
392
|
+
findings.append(
|
|
393
|
+
VulnerabilityFinding(
|
|
394
|
+
id=v_id,
|
|
395
|
+
vuln_type=VulnerabilityType.HARDCODED_SECRET.value,
|
|
396
|
+
severity=rule["severity"],
|
|
397
|
+
cvss_score=rule["cvss"],
|
|
398
|
+
cvss_vector=rule["vector"],
|
|
399
|
+
file_path=rel_path,
|
|
400
|
+
line_number=line_idx,
|
|
401
|
+
snippet=line.strip(),
|
|
402
|
+
description=f"{rule['name']}: {rule['desc']}",
|
|
403
|
+
recommendation=rule["rec"],
|
|
404
|
+
cwe_id=rule["cwe"],
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# 2. ReDoS Detection
|
|
409
|
+
for line_idx, line in enumerate(lines, start=1):
|
|
410
|
+
if any(kw in line for kw in ("re.compile", "re.match", "re.search", "re.findall", "RegExp", "pattern =")):
|
|
411
|
+
for p in REDOS_PATTERNS:
|
|
412
|
+
if p.search(line):
|
|
413
|
+
v_id = f"SEC-REDOS-{vuln_counter:03d}"
|
|
414
|
+
vuln_counter += 1
|
|
415
|
+
findings.append(
|
|
416
|
+
VulnerabilityFinding(
|
|
417
|
+
id=v_id,
|
|
418
|
+
vuln_type=VulnerabilityType.REDOS.value,
|
|
419
|
+
severity=VulnerabilitySeverity.MEDIUM.value,
|
|
420
|
+
cvss_score=7.5,
|
|
421
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
|
|
422
|
+
file_path=rel_path,
|
|
423
|
+
line_number=line_idx,
|
|
424
|
+
snippet=line.strip(),
|
|
425
|
+
description="Potential ReDoS: Catastrophic backtracking nested quantifiers detected in regex.",
|
|
426
|
+
recommendation="Simplify nested quantifiers or use possessive/atomic matching to prevent CPU exhaustion.",
|
|
427
|
+
cwe_id="CWE-1333",
|
|
428
|
+
)
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# 3. Python Deep AST Analysis
|
|
432
|
+
if path.suffix.lower() == ".py":
|
|
433
|
+
ast_findings, vuln_counter = self._scan_python_ast(rel_path, content, vuln_counter)
|
|
434
|
+
findings.extend(ast_findings)
|
|
435
|
+
|
|
436
|
+
duration = time.time() - start_time
|
|
437
|
+
return SecurityScanReport(
|
|
438
|
+
repo_path=str(root),
|
|
439
|
+
findings=findings,
|
|
440
|
+
scanned_files_count=scanned_count,
|
|
441
|
+
scan_duration_seconds=duration,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# =========================================================================
|
|
445
|
+
# 2. Python AST Security Visitor
|
|
446
|
+
# =========================================================================
|
|
447
|
+
|
|
448
|
+
def _scan_python_ast(
|
|
449
|
+
self, rel_path: str, code: str, start_counter: int
|
|
450
|
+
) -> Tuple[List[VulnerabilityFinding], int]:
|
|
451
|
+
"""Deep AST analysis for SQLi, unsafe eval/exec, pickle, yaml, and shell=True."""
|
|
452
|
+
findings: List[VulnerabilityFinding] = []
|
|
453
|
+
counter = start_counter
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
tree = ast.parse(code)
|
|
457
|
+
except Exception:
|
|
458
|
+
return findings, counter
|
|
459
|
+
|
|
460
|
+
lines = code.splitlines()
|
|
461
|
+
|
|
462
|
+
class SecurityVisitor(ast.NodeVisitor):
|
|
463
|
+
def __init__(self):
|
|
464
|
+
self.local_findings: List[VulnerabilityFinding] = []
|
|
465
|
+
|
|
466
|
+
def _get_snippet(self, node: ast.AST) -> str:
|
|
467
|
+
lineno = getattr(node, "lineno", 1)
|
|
468
|
+
if 1 <= lineno <= len(lines):
|
|
469
|
+
return lines[lineno - 1].strip()
|
|
470
|
+
return ""
|
|
471
|
+
|
|
472
|
+
def visit_Call(self, node: ast.Call):
|
|
473
|
+
nonlocal counter
|
|
474
|
+
|
|
475
|
+
if isinstance(node.func, ast.Name) and node.func.id in ("eval", "exec"):
|
|
476
|
+
if node.args:
|
|
477
|
+
if not isinstance(node.args[0], ast.Constant):
|
|
478
|
+
v_id = f"SEC-RCE-{counter:03d}"
|
|
479
|
+
counter += 1
|
|
480
|
+
self.local_findings.append(
|
|
481
|
+
VulnerabilityFinding(
|
|
482
|
+
id=v_id,
|
|
483
|
+
vuln_type=VulnerabilityType.UNSAFE_EVAL.value,
|
|
484
|
+
severity=VulnerabilitySeverity.CRITICAL.value,
|
|
485
|
+
cvss_score=9.8,
|
|
486
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
487
|
+
file_path=rel_path,
|
|
488
|
+
line_number=node.lineno,
|
|
489
|
+
snippet=self._get_snippet(node),
|
|
490
|
+
description=f"Unsafe dynamic code execution using `{node.func.id}()` with untrusted input.",
|
|
491
|
+
recommendation="Use ast.literal_eval() for safe literal evaluation or parse structured JSON.",
|
|
492
|
+
cwe_id="CWE-95",
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if isinstance(node.func, ast.Attribute):
|
|
497
|
+
if (
|
|
498
|
+
isinstance(node.func.value, ast.Name)
|
|
499
|
+
and node.func.value.id in ("pickle", "_pickle", "cPickle")
|
|
500
|
+
and node.func.attr in ("loads", "load")
|
|
501
|
+
):
|
|
502
|
+
v_id = f"SEC-DESER-{counter:03d}"
|
|
503
|
+
counter += 1
|
|
504
|
+
self.local_findings.append(
|
|
505
|
+
VulnerabilityFinding(
|
|
506
|
+
id=v_id,
|
|
507
|
+
vuln_type=VulnerabilityType.UNSAFE_DESERIALIZATION.value,
|
|
508
|
+
severity=VulnerabilitySeverity.CRITICAL.value,
|
|
509
|
+
cvss_score=9.8,
|
|
510
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
511
|
+
file_path=rel_path,
|
|
512
|
+
line_number=node.lineno,
|
|
513
|
+
snippet=self._get_snippet(node),
|
|
514
|
+
description="Insecure deserialization using `pickle.loads()` allows arbitrary code execution.",
|
|
515
|
+
recommendation="Use safer serialization formats such as JSON, Protocol Buffers, or messagepack.",
|
|
516
|
+
cwe_id="CWE-502",
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if isinstance(node.func.value, ast.Name) and node.func.value.id == "yaml" and node.func.attr == "load":
|
|
521
|
+
has_safe_loader = False
|
|
522
|
+
for kw in node.keywords:
|
|
523
|
+
if kw.arg == "Loader":
|
|
524
|
+
if isinstance(kw.value, ast.Attribute) and kw.value.attr in ("SafeLoader", "CSafeLoader"):
|
|
525
|
+
has_safe_loader = True
|
|
526
|
+
if not has_safe_loader:
|
|
527
|
+
v_id = f"SEC-YAML-{counter:03d}"
|
|
528
|
+
counter += 1
|
|
529
|
+
self.local_findings.append(
|
|
530
|
+
VulnerabilityFinding(
|
|
531
|
+
id=v_id,
|
|
532
|
+
vuln_type=VulnerabilityType.UNSAFE_DESERIALIZATION.value,
|
|
533
|
+
severity=VulnerabilitySeverity.HIGH.value,
|
|
534
|
+
cvss_score=8.6,
|
|
535
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N",
|
|
536
|
+
file_path=rel_path,
|
|
537
|
+
line_number=node.lineno,
|
|
538
|
+
snippet=self._get_snippet(node),
|
|
539
|
+
description="Unsafe YAML loading: `yaml.load()` without SafeLoader can lead to arbitrary code execution.",
|
|
540
|
+
recommendation="Replace `yaml.load(...)` with `yaml.safe_load(...)` or pass `Loader=yaml.SafeLoader`.",
|
|
541
|
+
cwe_id="CWE-502",
|
|
542
|
+
)
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
if node.func.attr in ("execute", "executemany", "raw"):
|
|
546
|
+
if node.args:
|
|
547
|
+
first_arg = node.args[0]
|
|
548
|
+
is_sqli = False
|
|
549
|
+
if isinstance(first_arg, ast.JoinedStr):
|
|
550
|
+
is_sqli = True
|
|
551
|
+
elif isinstance(first_arg, ast.BinOp) and isinstance(first_arg.op, (ast.Mod, ast.Add)):
|
|
552
|
+
is_sqli = True
|
|
553
|
+
elif isinstance(first_arg, ast.Call) and isinstance(first_arg.func, ast.Attribute) and first_arg.func.attr == "format":
|
|
554
|
+
is_sqli = True
|
|
555
|
+
|
|
556
|
+
if is_sqli:
|
|
557
|
+
v_id = f"SEC-SQLI-{counter:03d}"
|
|
558
|
+
counter += 1
|
|
559
|
+
self.local_findings.append(
|
|
560
|
+
VulnerabilityFinding(
|
|
561
|
+
id=v_id,
|
|
562
|
+
vuln_type=VulnerabilityType.SQL_INJECTION.value,
|
|
563
|
+
severity=VulnerabilitySeverity.CRITICAL.value,
|
|
564
|
+
cvss_score=8.8,
|
|
565
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
566
|
+
file_path=rel_path,
|
|
567
|
+
line_number=node.lineno,
|
|
568
|
+
snippet=self._get_snippet(node),
|
|
569
|
+
description="Potential SQL Injection: String formatting or interpolation used in SQL query execution.",
|
|
570
|
+
recommendation="Use parameterized queries with placeholder bindings instead of string interpolation.",
|
|
571
|
+
cwe_id="CWE-89",
|
|
572
|
+
)
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
if (
|
|
576
|
+
isinstance(node.func.value, ast.Name)
|
|
577
|
+
and node.func.value.id == "subprocess"
|
|
578
|
+
and node.func.attr in ("Popen", "run", "call", "check_output", "check_call")
|
|
579
|
+
):
|
|
580
|
+
for kw in node.keywords:
|
|
581
|
+
if kw.arg == "shell":
|
|
582
|
+
if isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
583
|
+
v_id = f"SEC-SH-{counter:03d}"
|
|
584
|
+
counter += 1
|
|
585
|
+
self.local_findings.append(
|
|
586
|
+
VulnerabilityFinding(
|
|
587
|
+
id=v_id,
|
|
588
|
+
vuln_type=VulnerabilityType.COMMAND_INJECTION.value,
|
|
589
|
+
severity=VulnerabilitySeverity.HIGH.value,
|
|
590
|
+
cvss_score=8.8,
|
|
591
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
592
|
+
file_path=rel_path,
|
|
593
|
+
line_number=node.lineno,
|
|
594
|
+
snippet=self._get_snippet(node),
|
|
595
|
+
description="Command Injection risk: `subprocess` invoked with `shell=True`.",
|
|
596
|
+
recommendation="Pass arguments as a list of strings and set `shell=False` to prevent shell injection.",
|
|
597
|
+
cwe_id="CWE-78",
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
if isinstance(node.func, ast.Attribute):
|
|
602
|
+
if isinstance(node.func.value, ast.Name) and node.func.value.id == "os" and node.func.attr in ("system", "popen"):
|
|
603
|
+
v_id = f"SEC-OS-{counter:03d}"
|
|
604
|
+
counter += 1
|
|
605
|
+
self.local_findings.append(
|
|
606
|
+
VulnerabilityFinding(
|
|
607
|
+
id=v_id,
|
|
608
|
+
vuln_type=VulnerabilityType.COMMAND_INJECTION.value,
|
|
609
|
+
severity=VulnerabilitySeverity.HIGH.value,
|
|
610
|
+
cvss_score=8.8,
|
|
611
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
|
|
612
|
+
file_path=rel_path,
|
|
613
|
+
line_number=node.lineno,
|
|
614
|
+
snippet=self._get_snippet(node),
|
|
615
|
+
description=f"Insecure command execution using `os.{node.func.attr}()`.",
|
|
616
|
+
recommendation="Replace os.system with `subprocess.run([...], check=True)` without shell.",
|
|
617
|
+
cwe_id="CWE-78",
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
if isinstance(node.func.value, ast.Name) and node.func.value.id == "hashlib" and node.func.attr in ("md5", "sha1"):
|
|
622
|
+
v_id = f"SEC-HASH-{counter:03d}"
|
|
623
|
+
counter += 1
|
|
624
|
+
self.local_findings.append(
|
|
625
|
+
VulnerabilityFinding(
|
|
626
|
+
id=v_id,
|
|
627
|
+
vuln_type=VulnerabilityType.INSECURE_CIPHER.value,
|
|
628
|
+
severity=VulnerabilitySeverity.MEDIUM.value,
|
|
629
|
+
cvss_score=5.3,
|
|
630
|
+
cvss_vector="CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N",
|
|
631
|
+
file_path=rel_path,
|
|
632
|
+
line_number=node.lineno,
|
|
633
|
+
snippet=self._get_snippet(node),
|
|
634
|
+
description=f"Insecure hash algorithm `{node.func.attr}()` used. Vulnerable to collision attacks.",
|
|
635
|
+
recommendation="Upgrade to secure cryptographic hash algorithm like sha256 or sha3_256.",
|
|
636
|
+
cwe_id="CWE-327",
|
|
637
|
+
)
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
self.generic_visit(node)
|
|
641
|
+
|
|
642
|
+
visitor = SecurityVisitor()
|
|
643
|
+
visitor.visit(tree)
|
|
644
|
+
return visitor.local_findings, counter
|
|
645
|
+
|
|
646
|
+
# =========================================================================
|
|
647
|
+
# 3. Surgical Auto-Healing & Verification Engine
|
|
648
|
+
# =========================================================================
|
|
649
|
+
|
|
650
|
+
def auto_heal_vulnerability(
|
|
651
|
+
self,
|
|
652
|
+
vuln_id: str,
|
|
653
|
+
verifier: Optional[Any] = None,
|
|
654
|
+
patcher: Optional[Any] = None,
|
|
655
|
+
llm_driver: Optional[Any] = None,
|
|
656
|
+
repo_path: Optional[str] = None,
|
|
657
|
+
) -> VulnerabilityHealResult:
|
|
658
|
+
"""
|
|
659
|
+
Remediates a specific detected vulnerability surgically using AST search/replace,
|
|
660
|
+
verifies syntax and tests with Verifier guard, and re-scans to confirm resolution.
|
|
661
|
+
"""
|
|
662
|
+
root = Path(repo_path).resolve() if repo_path else self.repo_path
|
|
663
|
+
report = self.scan_repository(repo_path=str(root))
|
|
664
|
+
|
|
665
|
+
finding = next((f for f in report.findings if f.id == vuln_id), None)
|
|
666
|
+
if not finding:
|
|
667
|
+
return VulnerabilityHealResult(
|
|
668
|
+
vuln_id=vuln_id,
|
|
669
|
+
file_path="",
|
|
670
|
+
success=False,
|
|
671
|
+
error_message=f"Vulnerability ID '{vuln_id}' not found in active repository scan.",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
target_file = (root / finding.file_path).resolve()
|
|
675
|
+
if not target_file.exists() or not target_file.is_file():
|
|
676
|
+
return VulnerabilityHealResult(
|
|
677
|
+
vuln_id=vuln_id,
|
|
678
|
+
file_path=finding.file_path,
|
|
679
|
+
success=False,
|
|
680
|
+
error_message=f"Target file does not exist on disk: {target_file}",
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
original_code = target_file.read_text(encoding="utf-8", errors="replace")
|
|
684
|
+
backup_code = original_code
|
|
685
|
+
|
|
686
|
+
v_engine = verifier or (Verifier() if Verifier else None)
|
|
687
|
+
p_engine = patcher or (Patcher() if Patcher else None)
|
|
688
|
+
driver = llm_driver or self.llm_driver
|
|
689
|
+
|
|
690
|
+
patch_blocks: List[Tuple[str, str]] = []
|
|
691
|
+
|
|
692
|
+
if driver is not None and hasattr(driver, "generate"):
|
|
693
|
+
try:
|
|
694
|
+
prompt = (
|
|
695
|
+
f"Fix the following security vulnerability in `{finding.file_path}`:\n"
|
|
696
|
+
f"Vulnerability Type: {finding.vuln_type}\n"
|
|
697
|
+
f"Severity: {finding.severity} (CVSS: {finding.cvss_score})\n"
|
|
698
|
+
f"Line {finding.line_number}: {finding.snippet}\n"
|
|
699
|
+
f"Description: {finding.description}\n"
|
|
700
|
+
f"Recommendation: {finding.recommendation}\n\n"
|
|
701
|
+
f"Original File Content:\n```python\n{original_code}\n```\n\n"
|
|
702
|
+
f"Requirements:\n"
|
|
703
|
+
f"Output ONLY a valid SEARCH/REPLACE block:\n"
|
|
704
|
+
f"<<<<<<< SEARCH\n... exact code to replace ...\n=======\n... replacement code ...\n>>>>>>> REPLACE"
|
|
705
|
+
)
|
|
706
|
+
response = driver.generate(prompt=prompt)
|
|
707
|
+
if response and p_engine:
|
|
708
|
+
patch_blocks = p_engine.parse_search_replace_blocks(response)
|
|
709
|
+
except Exception as exc:
|
|
710
|
+
logger.warning(f"LLM patch generation failed: {exc}")
|
|
711
|
+
|
|
712
|
+
if not patch_blocks:
|
|
713
|
+
patch_blocks = self._generate_heuristic_patch(finding, original_code)
|
|
714
|
+
|
|
715
|
+
if not patch_blocks:
|
|
716
|
+
return VulnerabilityHealResult(
|
|
717
|
+
vuln_id=vuln_id,
|
|
718
|
+
file_path=finding.file_path,
|
|
719
|
+
success=False,
|
|
720
|
+
error_message="Unable to synthesize a safe surgical remediation patch.",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
current_code = original_code
|
|
724
|
+
applied_search_replace: List[str] = []
|
|
725
|
+
|
|
726
|
+
for s_block, r_block in patch_blocks:
|
|
727
|
+
applied_search_replace.append(f"<<<<<<< SEARCH\n{s_block}\n=======\n{r_block}\n>>>>>>> REPLACE")
|
|
728
|
+
if p_engine:
|
|
729
|
+
success, patched_step, err = p_engine.apply_patch(current_code, s_block, r_block, fuzzy=True)
|
|
730
|
+
if success:
|
|
731
|
+
current_code = patched_step
|
|
732
|
+
elif s_block in current_code:
|
|
733
|
+
current_code = current_code.replace(s_block, r_block, 1)
|
|
734
|
+
else:
|
|
735
|
+
return VulnerabilityHealResult(
|
|
736
|
+
vuln_id=vuln_id,
|
|
737
|
+
file_path=finding.file_path,
|
|
738
|
+
success=False,
|
|
739
|
+
error_message=f"Patcher failed to apply block: {err}",
|
|
740
|
+
)
|
|
741
|
+
else:
|
|
742
|
+
if s_block in current_code:
|
|
743
|
+
current_code = current_code.replace(s_block, r_block, 1)
|
|
744
|
+
else:
|
|
745
|
+
return VulnerabilityHealResult(
|
|
746
|
+
vuln_id=vuln_id,
|
|
747
|
+
file_path=finding.file_path,
|
|
748
|
+
success=False,
|
|
749
|
+
error_message="Search block not found in target file.",
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
patched_code = current_code
|
|
753
|
+
|
|
754
|
+
# 3. Verify Python AST syntax
|
|
755
|
+
syntax_ok = True
|
|
756
|
+
if finding.file_path.endswith(".py"):
|
|
757
|
+
try:
|
|
758
|
+
ast.parse(patched_code)
|
|
759
|
+
except SyntaxError as syn_err:
|
|
760
|
+
syntax_ok = False
|
|
761
|
+
return VulnerabilityHealResult(
|
|
762
|
+
vuln_id=vuln_id,
|
|
763
|
+
file_path=finding.file_path,
|
|
764
|
+
success=False,
|
|
765
|
+
syntax_verified=False,
|
|
766
|
+
error_message=f"Patched code failed AST syntax check: {syn_err}",
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
# 4. Write to disk
|
|
770
|
+
try:
|
|
771
|
+
target_file.write_text(patched_code, encoding="utf-8")
|
|
772
|
+
except Exception as write_err:
|
|
773
|
+
return VulnerabilityHealResult(
|
|
774
|
+
vuln_id=vuln_id,
|
|
775
|
+
file_path=finding.file_path,
|
|
776
|
+
success=False,
|
|
777
|
+
error_message=f"Failed writing patched file to disk: {write_err}",
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# 5. Verify project tests
|
|
781
|
+
tests_passed = True
|
|
782
|
+
if v_engine and hasattr(v_engine, "run_project_tests"):
|
|
783
|
+
try:
|
|
784
|
+
test_res = v_engine.run_project_tests(project_dir=str(root), timeout=15.0)
|
|
785
|
+
if not test_res.success:
|
|
786
|
+
target_file.write_text(backup_code, encoding="utf-8")
|
|
787
|
+
return VulnerabilityHealResult(
|
|
788
|
+
vuln_id=vuln_id,
|
|
789
|
+
file_path=finding.file_path,
|
|
790
|
+
success=False,
|
|
791
|
+
syntax_verified=syntax_ok,
|
|
792
|
+
tests_passed=False,
|
|
793
|
+
error_message=f"Post-patch test verification failed. Rolled back: {test_res.error_trace}",
|
|
794
|
+
)
|
|
795
|
+
except Exception as test_exc:
|
|
796
|
+
logger.warning(f"Test verification check encountered error: {test_exc}")
|
|
797
|
+
|
|
798
|
+
# 6. Re-scan to confirm vulnerability eliminated
|
|
799
|
+
rescan_report = self.scan_repository(repo_path=str(root))
|
|
800
|
+
still_present = any(
|
|
801
|
+
f.file_path == finding.file_path and f.vuln_type == finding.vuln_type and f.line_number == finding.line_number
|
|
802
|
+
for f in rescan_report.findings
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
if still_present:
|
|
806
|
+
target_file.write_text(backup_code, encoding="utf-8")
|
|
807
|
+
return VulnerabilityHealResult(
|
|
808
|
+
vuln_id=vuln_id,
|
|
809
|
+
file_path=finding.file_path,
|
|
810
|
+
success=False,
|
|
811
|
+
syntax_verified=syntax_ok,
|
|
812
|
+
tests_passed=tests_passed,
|
|
813
|
+
rescan_clean=False,
|
|
814
|
+
error_message="Re-scan detected vulnerability still present after patch application. Rolled back.",
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
diff_lines = list(
|
|
818
|
+
difflib.unified_diff(
|
|
819
|
+
original_code.splitlines(keepends=True),
|
|
820
|
+
patched_code.splitlines(keepends=True),
|
|
821
|
+
fromfile=f"a/{finding.file_path}",
|
|
822
|
+
tofile=f"b/{finding.file_path}",
|
|
823
|
+
)
|
|
824
|
+
)
|
|
825
|
+
applied_diff = "".join(diff_lines)
|
|
826
|
+
|
|
827
|
+
return VulnerabilityHealResult(
|
|
828
|
+
vuln_id=vuln_id,
|
|
829
|
+
file_path=finding.file_path,
|
|
830
|
+
success=True,
|
|
831
|
+
applied_patch="\n\n".join(applied_search_replace),
|
|
832
|
+
syntax_verified=syntax_ok,
|
|
833
|
+
tests_passed=tests_passed,
|
|
834
|
+
rescan_clean=True,
|
|
835
|
+
diff=applied_diff,
|
|
836
|
+
)
|
|
837
|
+
|
|
838
|
+
def heal_all_vulnerabilities(
|
|
839
|
+
self,
|
|
840
|
+
repo_path: Optional[str] = None,
|
|
841
|
+
verifier: Optional[Any] = None,
|
|
842
|
+
patcher: Optional[Any] = None,
|
|
843
|
+
llm_driver: Optional[Any] = None,
|
|
844
|
+
) -> List[VulnerabilityHealResult]:
|
|
845
|
+
"""Scans and heals all detected vulnerabilities sequentially."""
|
|
846
|
+
root = Path(repo_path).resolve() if repo_path else self.repo_path
|
|
847
|
+
results: List[VulnerabilityHealResult] = []
|
|
848
|
+
|
|
849
|
+
# Loop until no more fixable vulnerabilities remain or max iterations
|
|
850
|
+
for _ in range(10):
|
|
851
|
+
report = self.scan_repository(repo_path=str(root))
|
|
852
|
+
if not report.findings:
|
|
853
|
+
break
|
|
854
|
+
|
|
855
|
+
healed_in_this_pass = 0
|
|
856
|
+
for finding in report.findings:
|
|
857
|
+
res = self.auto_heal_vulnerability(
|
|
858
|
+
vuln_id=finding.id,
|
|
859
|
+
verifier=verifier,
|
|
860
|
+
patcher=patcher,
|
|
861
|
+
llm_driver=llm_driver,
|
|
862
|
+
repo_path=str(root),
|
|
863
|
+
)
|
|
864
|
+
results.append(res)
|
|
865
|
+
if res.success:
|
|
866
|
+
healed_in_this_pass += 1
|
|
867
|
+
break # Rescan after each fix to update line numbers cleanly
|
|
868
|
+
|
|
869
|
+
if healed_in_this_pass == 0:
|
|
870
|
+
break
|
|
871
|
+
|
|
872
|
+
return results
|
|
873
|
+
|
|
874
|
+
# =========================================================================
|
|
875
|
+
# 4. Deterministic Heuristic Patch Generator
|
|
876
|
+
# =========================================================================
|
|
877
|
+
|
|
878
|
+
def _generate_heuristic_patch(
|
|
879
|
+
self, finding: VulnerabilityFinding, code: str
|
|
880
|
+
) -> List[Tuple[str, str]]:
|
|
881
|
+
"""Generates deterministic safe patches for common security patterns."""
|
|
882
|
+
lines = code.splitlines()
|
|
883
|
+
if not (1 <= finding.line_number <= len(lines)):
|
|
884
|
+
return []
|
|
885
|
+
|
|
886
|
+
target_line = lines[finding.line_number - 1]
|
|
887
|
+
|
|
888
|
+
# A. Hardcoded Secret Replacement
|
|
889
|
+
if finding.vuln_type == VulnerabilityType.HARDCODED_SECRET.value:
|
|
890
|
+
for rule in SECRET_REGEX_RULES:
|
|
891
|
+
match = rule["pattern"].search(target_line)
|
|
892
|
+
if match:
|
|
893
|
+
secret_str = match.group(0)
|
|
894
|
+
env_var = "API_KEY"
|
|
895
|
+
if "sk-" in secret_str:
|
|
896
|
+
env_var = "OPENAI_API_KEY"
|
|
897
|
+
elif "hf_" in secret_str:
|
|
898
|
+
env_var = "HF_TOKEN"
|
|
899
|
+
elif "ghp" in secret_str or "github" in secret_str:
|
|
900
|
+
env_var = "GITHUB_TOKEN"
|
|
901
|
+
elif "AKIA" in secret_str:
|
|
902
|
+
env_var = "AWS_ACCESS_KEY_ID"
|
|
903
|
+
elif "xox" in secret_str:
|
|
904
|
+
env_var = "SLACK_BOT_TOKEN"
|
|
905
|
+
|
|
906
|
+
new_line = target_line.replace(f'"{secret_str}"', f'os.environ.get("{env_var}", "")')
|
|
907
|
+
new_line = new_line.replace(f"'{secret_str}'", f'os.environ.get("{env_var}", "")')
|
|
908
|
+
|
|
909
|
+
patches = []
|
|
910
|
+
if "import os" not in code:
|
|
911
|
+
first_line = lines[0] if lines else ""
|
|
912
|
+
patches.append((first_line, f"import os\n{first_line}"))
|
|
913
|
+
patches.append((target_line, new_line))
|
|
914
|
+
return patches
|
|
915
|
+
|
|
916
|
+
# B. SQL Injection Parameterization
|
|
917
|
+
if finding.vuln_type == VulnerabilityType.SQL_INJECTION.value:
|
|
918
|
+
fstr_match = re.search(r'(cursor|db|session)\.execute\s*\(\s*f["\'](.*?)["\']\s*\)', target_line)
|
|
919
|
+
if fstr_match:
|
|
920
|
+
obj = fstr_match.group(1)
|
|
921
|
+
sql_template = fstr_match.group(2)
|
|
922
|
+
param_vars = re.findall(r'\{([^}]+)\}', sql_template)
|
|
923
|
+
if param_vars:
|
|
924
|
+
cleaned_sql = re.sub(r'\{[^}]+\}', '%s', sql_template)
|
|
925
|
+
params_tuple = f"({', '.join(param_vars)}" + (",)" if len(param_vars) == 1 else ")")
|
|
926
|
+
new_line = target_line.replace(
|
|
927
|
+
fstr_match.group(0),
|
|
928
|
+
f'{obj}.execute("{cleaned_sql}", {params_tuple})'
|
|
929
|
+
)
|
|
930
|
+
return [(target_line, new_line)]
|
|
931
|
+
|
|
932
|
+
pct_match = re.search(r'(cursor|db|session)\.execute\s*\(\s*(["\'].*?["\'])\s*%\s*([^)]+)\)', target_line)
|
|
933
|
+
if pct_match:
|
|
934
|
+
obj, query_str, params = pct_match.group(1), pct_match.group(2), pct_match.group(3).strip()
|
|
935
|
+
params_tuple = params if (params.startswith("(") and params.endswith(")")) else f"({params},)"
|
|
936
|
+
new_line = target_line.replace(
|
|
937
|
+
pct_match.group(0),
|
|
938
|
+
f'{obj}.execute({query_str}, {params_tuple})'
|
|
939
|
+
)
|
|
940
|
+
return [(target_line, new_line)]
|
|
941
|
+
|
|
942
|
+
# C. Unsafe eval() / exec() -> ast.literal_eval()
|
|
943
|
+
if finding.vuln_type == VulnerabilityType.UNSAFE_EVAL.value:
|
|
944
|
+
eval_match = re.search(r'\beval\s*\(([^)]+)\)', target_line)
|
|
945
|
+
if eval_match:
|
|
946
|
+
expr = eval_match.group(1)
|
|
947
|
+
new_line = target_line.replace(f"eval({expr})", f"ast.literal_eval({expr})")
|
|
948
|
+
patches = []
|
|
949
|
+
if "import ast" not in code:
|
|
950
|
+
first_line = lines[0] if lines else ""
|
|
951
|
+
patches.append((first_line, f"import ast\n{first_line}"))
|
|
952
|
+
patches.append((target_line, new_line))
|
|
953
|
+
return patches
|
|
954
|
+
|
|
955
|
+
# D. Unsafe yaml.load -> yaml.safe_load
|
|
956
|
+
if finding.vuln_type == VulnerabilityType.UNSAFE_DESERIALIZATION.value:
|
|
957
|
+
if "yaml.load" in target_line:
|
|
958
|
+
new_line = target_line.replace("yaml.load(", "yaml.safe_load(")
|
|
959
|
+
return [(target_line, new_line)]
|
|
960
|
+
if "pickle.loads" in target_line:
|
|
961
|
+
new_line = target_line.replace("pickle.loads(", "json.loads(")
|
|
962
|
+
patches = []
|
|
963
|
+
if "import json" not in code:
|
|
964
|
+
first_line = lines[0] if lines else ""
|
|
965
|
+
patches.append((first_line, f"import json\n{first_line}"))
|
|
966
|
+
patches.append((target_line, new_line))
|
|
967
|
+
return patches
|
|
968
|
+
|
|
969
|
+
# E. Command Injection shell=True -> shell=False
|
|
970
|
+
if finding.vuln_type == VulnerabilityType.COMMAND_INJECTION.value:
|
|
971
|
+
if "shell=True" in target_line:
|
|
972
|
+
new_line = target_line.replace("shell=True", "shell=False")
|
|
973
|
+
return [(target_line, new_line)]
|
|
974
|
+
if "os.system(" in target_line:
|
|
975
|
+
sys_match = re.search(r'os\.system\s*\(([^)]+)\)', target_line)
|
|
976
|
+
if sys_match:
|
|
977
|
+
cmd_arg = sys_match.group(1).strip()
|
|
978
|
+
new_line = target_line.replace(
|
|
979
|
+
sys_match.group(0),
|
|
980
|
+
f"subprocess.run({cmd_arg}.split(), check=True)"
|
|
981
|
+
)
|
|
982
|
+
patches = []
|
|
983
|
+
if "import subprocess" not in code:
|
|
984
|
+
first_line = lines[0] if lines else ""
|
|
985
|
+
patches.append((first_line, f"import subprocess\n{first_line}"))
|
|
986
|
+
patches.append((target_line, new_line))
|
|
987
|
+
return patches
|
|
988
|
+
|
|
989
|
+
# F. ReDoS Nested Quantifier Simplification
|
|
990
|
+
if finding.vuln_type == VulnerabilityType.REDOS.value:
|
|
991
|
+
redos_fixed = target_line
|
|
992
|
+
redos_fixed = re.sub(r'\(([^()]+)\+\)\+', r'\1+', redos_fixed)
|
|
993
|
+
redos_fixed = re.sub(r'\(([^()]+)\*\)\*', r'\1*', redos_fixed)
|
|
994
|
+
redos_fixed = re.sub(r'\(([^()]+)\+\)\*', r'\1*', redos_fixed)
|
|
995
|
+
redos_fixed = re.sub(r'\(([^()]+)\*\)\+', r'\1+', redos_fixed)
|
|
996
|
+
if redos_fixed != target_line:
|
|
997
|
+
return [(target_line, redos_fixed)]
|
|
998
|
+
|
|
999
|
+
return []
|