@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.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -0
- package/assess/lsa/knowledge/path-ssrf-patterns.md +19 -0
- package/assess/lsa/knowledge/severity-and-cwe.md +3 -1
- package/assess/lsa/scripts/find_path_ssrf.py +176 -0
- package/assess/lsa/scripts/findings_to_sarif.py +29 -0
- package/assess/lsa/scripts/merge_findings.py +1 -1
- package/assess/lsa/scripts/run_prescan.py +4 -0
- package/assess/lsa/tests/run_tests.py +4 -0
- package/assess/lsa/tests/test_path_ssrf.py +94 -0
- package/contracts/schemas/finding.schema.json +4 -2
- package/operate/README.md +6 -1
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/fix.py +47 -0
- package/operate/cli/commands/mcp.py +54 -0
- package/operate/cli/main.py +42 -4
- package/operate/mcp/__init__.py +11 -0
- package/operate/mcp/server.py +206 -0
- package/operate/mcp/tools.py +464 -0
- package/operate/remediator/ai_fixer.py +285 -0
- package/operate/remediator/runner.py +11 -0
- package/operate/remediator/strategies.py +64 -0
- package/operate/tests/test_ai_remediation.py +288 -0
- package/operate/tests/test_mcp_server.py +380 -0
- package/package.json +1 -1
- package/remediate/authz/bfla_patcher.py +319 -0
- package/remediate/authz/mass_assignment_patcher.py +188 -0
- package/remediate/authz/patcher.py +8 -0
- package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
|
@@ -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:
|