@hoanghainh/komagu 2.6.1 → 2.7.0

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.
@@ -0,0 +1,285 @@
1
+ """Tier 2 AI-assisted multi-line remediation with mandatory verification gate (#394).
2
+
3
+ Used as an opt-in fallback (--ai) when Tier 1 deterministic patchers cannot
4
+ rewrite a vulnerability (e.g., Java legacy Statement -> PreparedStatement,
5
+ PHP mysql_query -> PDO, or multi-line authorization checks).
6
+
7
+ Every candidate patch produced by the LLM is:
8
+ 1. Redacted on input via assess/lsa/scripts/redaction.py before leaving the host.
9
+ 2. Constrained to a bounded line window around the finding.
10
+ 3. Verified via syntax/compiler checks (when available) and Tier 1 sink re-scan.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+ import tempfile
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any, Dict, List, Optional, Tuple
24
+
25
+ from operate.ai.client import LLMClient, repair_json_quotes
26
+
27
+ _LSA_SCRIPTS_DIR = Path(__file__).resolve().parents[2] / "assess" / "lsa" / "scripts"
28
+ if str(_LSA_SCRIPTS_DIR) not in sys.path:
29
+ sys.path.insert(0, str(_LSA_SCRIPTS_DIR))
30
+
31
+ from find_injection import find_injection # noqa: E402
32
+ from lsa_common import load_config # noqa: E402
33
+ from redaction import redact_lines # noqa: E402
34
+
35
+ _SYSTEM_PROMPT = (
36
+ "You are a senior application security engineer fixing a verified vulnerability in legacy code.\n"
37
+ "Rules:\n"
38
+ "1. Fix ONLY the security vulnerability at the indicated lines without changing unrelated behavior.\n"
39
+ "2. Never emit comment-only fixes or TODO placeholders.\n"
40
+ "3. Use standard, idiomatic APIs for the file's language and existing imports/stack.\n"
41
+ "4. Return STRICT JSON only with keys: start_line (int), end_line (int), "
42
+ "patched_snippet (string), explanation (string).\n"
43
+ "5. start_line and end_line are 1-based inclusive line numbers in the original file."
44
+ )
45
+
46
+ _COMMENT_ONLY_LINE = re.compile(r"^\s*(?://|#|--|/\*|\*|\*/|$)")
47
+
48
+
49
+ @dataclass
50
+ class AIFixResult:
51
+ """Outcome of Tier 2 AI-assisted patch generation and verification."""
52
+
53
+ changed: bool
54
+ patched_content: str
55
+ explanation: str
56
+ verified: bool = False
57
+ syntax_status: str = "not_run"
58
+
59
+
60
+ def _strip_comments_and_blank(lines: List[str]) -> List[str]:
61
+ """Return non-empty, non-comment lines to detect comment-only fake fixes."""
62
+ return [ln.strip() for ln in lines if not _COMMENT_ONLY_LINE.match(ln)]
63
+
64
+
65
+ def _check_syntax(file_path: Path, patched_content: str) -> Tuple[bool, str]:
66
+ """Run language-appropriate syntax/compile verification on patched_content."""
67
+ ext = file_path.suffix.lower()
68
+
69
+ if ext == ".py":
70
+ try:
71
+ compile(patched_content, str(file_path), "exec")
72
+ return True, "py_compile:ok"
73
+ except SyntaxError as exc:
74
+ return False, f"py_compile failed: {exc.msg} (line {exc.lineno})"
75
+
76
+ tool_commands: Dict[str, List[str]] = {
77
+ ".js": ["node", "--check"],
78
+ ".mjs": ["node", "--check"],
79
+ ".php": ["php", "-l"],
80
+ ".go": ["gofmt", "-e"],
81
+ }
82
+
83
+ cmd_prefix = tool_commands.get(ext)
84
+ if cmd_prefix is None:
85
+ return True, f"skipped (no syntax checker configured for {ext or 'unknown'})"
86
+
87
+ tool_bin = shutil.which(cmd_prefix[0])
88
+ if not tool_bin:
89
+ return True, f"skipped ({cmd_prefix[0]} not installed)"
90
+
91
+ with tempfile.TemporaryDirectory(prefix="komagu_ai_syntax_") as tmpdir:
92
+ tmp_file = Path(tmpdir) / file_path.name
93
+ tmp_file.write_text(patched_content, encoding="utf-8")
94
+ proc = subprocess.run(
95
+ [tool_bin, *cmd_prefix[1:], str(tmp_file)],
96
+ capture_output=True,
97
+ text=True,
98
+ timeout=15,
99
+ )
100
+ if proc.returncode != 0:
101
+ err = (proc.stderr or proc.stdout).strip().splitlines()[:3]
102
+ return False, f"{cmd_prefix[0]} failed: {' | '.join(err)}"
103
+ return True, f"{cmd_prefix[0]}:ok"
104
+
105
+
106
+ def _check_injection_sink_removed(
107
+ file_path: Path,
108
+ patched_content: str,
109
+ start_line: int,
110
+ new_end_line: int,
111
+ ) -> Tuple[bool, str]:
112
+ """Re-run Tier 1 injection detector on patched lines to confirm sink removal."""
113
+ with tempfile.TemporaryDirectory(prefix="komagu_ai_rescan_") as tmpdir:
114
+ tmp_dir = Path(tmpdir)
115
+ tmp_file = tmp_dir / file_path.name
116
+ tmp_file.write_text(patched_content, encoding="utf-8")
117
+ result = find_injection(tmp_dir, load_config(None))
118
+ candidates = result.get("candidates", [])
119
+ for cand in candidates:
120
+ line_no = int(cand.get("line", 0))
121
+ if start_line <= line_no <= new_end_line:
122
+ return False, (
123
+ f"Tier 1 re-scan still flags injection sink at line {line_no}: "
124
+ f"{cand.get('sink', cand.get('pattern', 'dynamic query'))}"
125
+ )
126
+ return True, "tier1_rescan:clean"
127
+
128
+
129
+ class AITier2Fixer:
130
+ """Generates and verifies multi-line security patches using a BYO-LLM endpoint."""
131
+
132
+ def __init__(
133
+ self,
134
+ endpoint: str = "http://localhost:11434/v1",
135
+ model: str = "llama3.1",
136
+ api_key: Optional[str] = None,
137
+ timeout: int = 120,
138
+ reasoning_effort: Optional[str] = None,
139
+ context_radius: int = 30,
140
+ max_changed_lines: int = 40,
141
+ client: Optional[LLMClient] = None,
142
+ ) -> None:
143
+ self.client = client or LLMClient(
144
+ endpoint=endpoint,
145
+ model=model,
146
+ api_key=api_key,
147
+ timeout=timeout,
148
+ reasoning_effort=reasoning_effort,
149
+ )
150
+ self.context_radius = context_radius
151
+ self.max_changed_lines = max_changed_lines
152
+
153
+ def _build_prompt(
154
+ self,
155
+ target_file: Path,
156
+ content: str,
157
+ finding: Dict[str, Any],
158
+ target_line: int,
159
+ ) -> str:
160
+ """Build a redacted, bounded code context prompt for the LLM."""
161
+ lines = content.splitlines()
162
+ total = len(lines)
163
+
164
+ header_end = min(20, total)
165
+ ctx_start = max(1, target_line - self.context_radius)
166
+ ctx_end = min(total, target_line + self.context_radius)
167
+
168
+ header_lines = redact_lines(lines[:header_end])
169
+ window_lines = redact_lines(lines[ctx_start - 1 : ctx_end])
170
+
171
+ numbered_header = "\n".join(f"{i + 1}: {ln}" for i, ln in enumerate(header_lines))
172
+ numbered_window = "\n".join(f"{ctx_start + i}: {ln}" for i, ln in enumerate(window_lines))
173
+
174
+ category = finding.get("category", "vulnerability")
175
+ cwe = finding.get("cwe", "")
176
+ title = finding.get("title", "")
177
+
178
+ return (
179
+ f"File: {target_file.name}\n"
180
+ f"Category: {category} ({cwe})\n"
181
+ f"Finding: {title}\n"
182
+ f"Target line: {target_line}\n\n"
183
+ f"--- File Header (lines 1..{header_end}) ---\n{numbered_header}\n\n"
184
+ f"--- Vulnerable Context (lines {ctx_start}..{ctx_end}) ---\n{numbered_window}\n\n"
185
+ "Provide a minimal, secure multi-line replacement in strict JSON."
186
+ )
187
+
188
+ def _parse_response(self, raw_reply: str) -> Dict[str, Any]:
189
+ """Parse and repair JSON returned by the LLM."""
190
+ repaired = repair_json_quotes(raw_reply)
191
+ data = json.loads(repaired)
192
+ if not isinstance(data, dict):
193
+ raise ValueError("LLM reply must be a JSON object")
194
+ return data
195
+
196
+ def generate_verified_patch(
197
+ self,
198
+ target_file: Path,
199
+ content: str,
200
+ finding: Dict[str, Any],
201
+ ) -> AIFixResult:
202
+ """Generate a multi-line patch with the LLM and run all verification gates."""
203
+ lines = content.splitlines(keepends=True)
204
+ total_lines = len(lines)
205
+ loc = finding.get("location") or {}
206
+ finding_line = int(loc.get("start_line", 1))
207
+
208
+ if finding_line < 1 or finding_line > total_lines:
209
+ return AIFixResult(False, content, "AI fix skipped: target line out of bounds.")
210
+
211
+ prompt = self._build_prompt(target_file, content, finding, finding_line)
212
+ try:
213
+ raw_reply = self.client.chat(
214
+ [
215
+ {"role": "system", "content": _SYSTEM_PROMPT},
216
+ {"role": "user", "content": prompt},
217
+ ],
218
+ temperature=0.1,
219
+ )
220
+ payload = self._parse_response(raw_reply)
221
+ except Exception as exc:
222
+ return AIFixResult(False, content, f"AI fix failed: {exc}")
223
+
224
+ try:
225
+ start_line = int(payload["start_line"])
226
+ end_line = int(payload["end_line"])
227
+ patched_snippet = str(payload["patched_snippet"])
228
+ explanation = str(payload.get("explanation", "Applied AI-assisted security fix.")).strip()
229
+ except (KeyError, TypeError, ValueError) as exc:
230
+ return AIFixResult(False, content, f"AI fix rejected: invalid JSON schema ({exc}).")
231
+
232
+ if start_line < 1 or end_line < start_line or end_line > total_lines:
233
+ return AIFixResult(False, content, f"AI fix rejected: invalid line span {start_line}..{end_line}.")
234
+
235
+ if abs(start_line - finding_line) > 15 and not (start_line <= finding_line <= end_line):
236
+ return AIFixResult(
237
+ False,
238
+ content,
239
+ f"AI fix rejected: span {start_line}..{end_line} is too far from finding line {finding_line}.",
240
+ )
241
+
242
+ if not patched_snippet.strip():
243
+ return AIFixResult(False, content, "AI fix rejected: empty patched_snippet.")
244
+
245
+ new_snippet_lines = patched_snippet.splitlines(keepends=True)
246
+ if not patched_snippet.endswith(("\n", "\r")):
247
+ newline = "\r\n" if "\r\n" in content else "\n"
248
+ new_snippet_lines[-1] += newline
249
+
250
+ if (end_line - start_line + 1) > self.max_changed_lines or len(new_snippet_lines) > self.max_changed_lines:
251
+ return AIFixResult(
252
+ False,
253
+ content,
254
+ f"AI fix rejected: patch exceeds bounded limit of {self.max_changed_lines} lines.",
255
+ )
256
+
257
+ old_slice = lines[start_line - 1 : end_line]
258
+ if _strip_comments_and_blank(old_slice) == _strip_comments_and_blank(new_snippet_lines):
259
+ return AIFixResult(False, content, "AI fix rejected: comment-only or no-op change.")
260
+
261
+ patched_lines = lines[: start_line - 1] + new_snippet_lines + lines[end_line:]
262
+ patched_content = "".join(patched_lines)
263
+ if patched_content == content:
264
+ return AIFixResult(False, content, "AI fix rejected: produced identical content.")
265
+
266
+ syntax_ok, syntax_status = _check_syntax(target_file, patched_content)
267
+ if not syntax_ok:
268
+ return AIFixResult(False, content, f"AI fix rejected ({syntax_status}).", syntax_status=syntax_status)
269
+
270
+ category = str(finding.get("category", "")).lower()
271
+ new_end_line = start_line + len(new_snippet_lines) - 1
272
+ if category in ("sql_injection", "nosql_injection"):
273
+ rescan_ok, rescan_msg = _check_injection_sink_removed(
274
+ target_file, patched_content, start_line, new_end_line
275
+ )
276
+ if not rescan_ok:
277
+ return AIFixResult(False, content, f"AI fix rejected: {rescan_msg}", syntax_status=syntax_status)
278
+
279
+ return AIFixResult(
280
+ changed=True,
281
+ patched_content=patched_content,
282
+ explanation=f"[AI-GENERATED — REQUIRES PERSON B REVIEW] {explanation} (verified: {syntax_status})",
283
+ verified=True,
284
+ syntax_status=syntax_status,
285
+ )
@@ -8,6 +8,7 @@ import os
8
8
  from pathlib import Path
9
9
  from typing import Any, Dict, List, Optional, Tuple
10
10
 
11
+ from operate.remediator.ai_fixer import AITier2Fixer
11
12
  from operate.remediator.models import RemediationPlan, RemediationReport
12
13
  from operate.remediator.strategies import BaseRemediationStrategy, StrategyRegistry
13
14
  from remediate.deps.patcher import CVEAdvisoryCatalog
@@ -177,6 +178,7 @@ class RemediationRunner:
177
178
  scan_sca: bool = False,
178
179
  query_osv: bool = False,
179
180
  sync_lockfiles: bool = True,
181
+ ai_fixer: Optional[AITier2Fixer] = None,
180
182
  ) -> None:
181
183
  self.target_dir = Path(target_dir).resolve()
182
184
  self.findings_path = Path(findings_path).resolve() if findings_path else None
@@ -186,6 +188,7 @@ class RemediationRunner:
186
188
  self.scan_sca = scan_sca
187
189
  self.query_osv = query_osv
188
190
  self.sync_lockfiles = sync_lockfiles
191
+ self.ai_fixer = ai_fixer
189
192
  self.registry = StrategyRegistry()
190
193
  self._virtual_contents: Dict[Path, str] = {}
191
194
  self._pre_patch_contents: Dict[Path, str] = {}
@@ -382,6 +385,14 @@ class RemediationRunner:
382
385
  located_finding = {**finding, "location": {**loc, "start_line": current_line}}
383
386
  patched_content, explanation = strategy.generate_patch(abs_file, original_content, located_finding)
384
387
 
388
+ if patched_content == original_content and self.ai_fixer is not None and category != "dependency":
389
+ ai_res = self.ai_fixer.generate_verified_patch(abs_file, original_content, located_finding)
390
+ if ai_res.changed:
391
+ patched_content = ai_res.patched_content
392
+ explanation = ai_res.explanation
393
+ elif ai_res.explanation:
394
+ explanation = f"{explanation} ({ai_res.explanation})" if explanation else ai_res.explanation
395
+
385
396
  if patched_content == original_content:
386
397
  return RemediationPlan(
387
398
  finding_id=finding_id,
@@ -7,6 +7,8 @@ import re
7
7
  from pathlib import Path
8
8
  from typing import Any, Dict, List, Optional, Tuple
9
9
 
10
+ from remediate.authz.bfla_patcher import BFLARolePatcher
11
+ from remediate.authz.mass_assignment_patcher import MassAssignmentPatcher
10
12
  from remediate.authz.patcher import BOLAOwnershipPatcher
11
13
  from remediate.deps.patcher import DependencyManifestPatcher
12
14
  from remediate.query.patcher import ParameterizedQueryPatcher
@@ -158,6 +160,66 @@ class DependencyVulnerabilityRemediationStrategy(BaseRemediationStrategy):
158
160
  return content, result.explanation or "No automated dependency version upgrade applicable; requires manual remediation."
159
161
 
160
162
 
163
+ class BFLARemediationStrategy(BaseRemediationStrategy):
164
+ """Strategy for remediating Broken Function Level Authorization (OWASP API5:2023, CWE-285/862)."""
165
+
166
+ name: str = "bfla"
167
+
168
+ def can_handle(self, finding: Dict[str, Any]) -> bool:
169
+ category = str(finding.get("category", "")).lower()
170
+ cwe = str(finding.get("cwe", "")).upper()
171
+ return category == "bfla" or "CWE-285" in cwe or "CWE-862" in cwe or "CWE-269" in cwe
172
+
173
+ def generate_patch(
174
+ self,
175
+ target_file: Path,
176
+ content: str,
177
+ finding: Dict[str, Any],
178
+ ) -> Tuple[str, str]:
179
+ lines = content.splitlines(keepends=True)
180
+ loc = finding.get("location", {})
181
+ start_line = loc.get("start_line", 1) - 1
182
+
183
+ if start_line < 0 or start_line >= len(lines):
184
+ return content, "Target line number is out of bounds."
185
+
186
+ patcher = BFLARolePatcher()
187
+ patched_content, result = patcher.patch_file(target_file, start_line + 1, finding, content=content)
188
+ if result.changed:
189
+ return patched_content, result.explanation
190
+ return content, result.explanation or "No automated BFLA role guard rewrite applicable; requires manual remediation."
191
+
192
+
193
+ class MassAssignmentRemediationStrategy(BaseRemediationStrategy):
194
+ """Strategy for remediating Mass Assignment (OWASP API3:2023, CWE-915)."""
195
+
196
+ name: str = "mass_assignment"
197
+
198
+ def can_handle(self, finding: Dict[str, Any]) -> bool:
199
+ category = str(finding.get("category", "")).lower()
200
+ cwe = str(finding.get("cwe", "")).upper()
201
+ return category == "mass_assignment" or "CWE-915" in cwe
202
+
203
+ def generate_patch(
204
+ self,
205
+ target_file: Path,
206
+ content: str,
207
+ finding: Dict[str, Any],
208
+ ) -> Tuple[str, str]:
209
+ lines = content.splitlines(keepends=True)
210
+ loc = finding.get("location", {})
211
+ start_line = loc.get("start_line", 1) - 1
212
+
213
+ if start_line < 0 or start_line >= len(lines):
214
+ return content, "Target line number is out of bounds."
215
+
216
+ patcher = MassAssignmentPatcher()
217
+ patched_content, result = patcher.patch_file(target_file, start_line + 1, finding, content=content)
218
+ if result.changed:
219
+ return patched_content, result.explanation
220
+ return content, result.explanation or "No automated mass-assignment allow-list rewrite applicable; requires manual remediation."
221
+
222
+
161
223
  class StrategyRegistry:
162
224
  """Registry coordinating available remediation strategies."""
163
225
 
@@ -166,6 +228,8 @@ class StrategyRegistry:
166
228
  DependencyVulnerabilityRemediationStrategy(),
167
229
  SQLInjectionRemediationStrategy(),
168
230
  BOLARemediationStrategy(),
231
+ BFLARemediationStrategy(),
232
+ MassAssignmentRemediationStrategy(),
169
233
  ]
170
234
 
171
235
  def register(self, strategy: BaseRemediationStrategy) -> None: