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.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. 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 []