@hoanghainh/komagu 2.6.0 → 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 +23 -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/examples/member-portal/backend-dotnet/Auth/DemoAuthenticationHandler.cs +75 -0
- package/examples/member-portal/backend-dotnet/Program.cs +1 -72
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/Application.kt +4 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/controller/Controllers.kt +3 -0
- package/examples/member-portal/backend-spring/src/main/kotlin/io/komagu/portal/model/Entities.kt +4 -0
- package/examples/member-portal/tests/test_d6_demo_app.py +4 -4
- package/examples/member-portal/tests/test_d6_remediation_verification.py +6 -6
- package/operate/README.md +6 -1
- package/operate/ai/client.py +5 -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/cross_user_tester/runner.py +4 -1
- package/operate/devsecops/sbom.py +12 -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/simulate/scenarios.py +11 -2
- package/operate/tests/test_ai_remediation.py +288 -0
- package/operate/tests/test_audit_minimum_versions.py +89 -0
- package/operate/tests/test_cli.py +1 -1
- package/operate/tests/test_mcp_server.py +380 -0
- package/operate/tests/test_scheme_and_xml_guards.py +67 -0
- package/package.json +1 -1
- package/remediate/authz/bfla_patcher.py +319 -0
- package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs +19 -3
- package/remediate/authz/golang/authz_test.go +5 -2
- package/remediate/authz/mass_assignment_patcher.py +188 -0
- package/remediate/authz/patcher.py +8 -0
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/DefaultKomaguSecurityManager.kt +74 -78
- package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/OwnershipAspect.kt +129 -158
- package/remediate/authz/tests/test_bfla_and_mass_assignment_patcher.py +213 -0
- package/remediate/deps/patcher.py +2 -1
- package/remediate/edge/mock-backend/app.py +2 -1
- package/remediate/edge/tests/test_edge_security.py +5 -5
- package/remediate/ekyc/requirements.txt +1 -1
- package/remediate/identity/dotnet/src/Komagu.Security.Identity/OidcServiceCollectionExtensions.cs +19 -6
- package/remediate/identity/python/requirements.txt +2 -2
- package/remediate/identity/spring/src/main/kotlin/io/komagu/security/identity/KomaguIdentityAutoConfiguration.kt +31 -38
- package/remediate/legacy_shims/tests/test_legacy_shims.py +8 -8
- package/remediate/query/dotnet/src/Komagu.Security.Query/SafeSortAllowList.cs +5 -2
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/SafeSortAllowList.kt +10 -11
- package/remediate/query/spring/src/main/kotlin/io/komagu/security/query/detekt/StringBuiltSqlQueryRule.kt +19 -24
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Automated BFLA Role Guard Patcher (OWASP API5:2023, CWE-285 / CWE-862).
|
|
2
|
+
|
|
3
|
+
Injects fail-closed role authorization guards (@RequireRole, requireRole,
|
|
4
|
+
[RequireRole], komaguauthz.RequireRole) above or around privileged endpoints
|
|
5
|
+
across TypeScript/JavaScript (NestJS & Express), C#, Java (Spring), and Go.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from remediate.authz.patcher import (
|
|
16
|
+
_JAVA_PACKAGE_STATEMENT,
|
|
17
|
+
_SPRING_CONTROLLER,
|
|
18
|
+
_is_java_handler_line,
|
|
19
|
+
_newline_of,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_EXPRESS_ROUTE = re.compile(
|
|
23
|
+
r"^(?P<indent>[ \t]*)(?P<router>[A-Za-z_][A-Za-z0-9_$.]*)\."
|
|
24
|
+
r"(?P<method>get|post|put|patch|delete)\s*\(\s*"
|
|
25
|
+
r"(?P<path>['\"`][^'\"`]+['\"`])\s*,\s*"
|
|
26
|
+
r"(?P<handler>[A-Za-z_][A-Za-z0-9_$.]*)\s*\)(?P<tail>[ \t]*;?)[ \t]*(?P<eol>\r?\n?)$"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_GO_HANDLE = re.compile(
|
|
30
|
+
r"^(?P<indent>[ \t]*)(?P<router>[A-Za-z_][A-Za-z0-9_.]*)\."
|
|
31
|
+
r"(?P<fn>Handle|HandleFunc)\s*\(\s*"
|
|
32
|
+
r"(?P<path>\"[^\"]+\")\s*,\s*"
|
|
33
|
+
r"(?P<handler>[A-Za-z_][A-Za-z0-9_.]*)\s*\)[ \t]*(?P<eol>\r?\n?)$"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_GO_IMPORT_SINGLE = re.compile(r'^(?P<indent>[ \t]*)import\s+"(?P<pkg>[^"]+)"[ \t]*(?P<eol>\r?\n?)$', re.MULTILINE)
|
|
37
|
+
_GO_IMPORT_BLOCK = re.compile(r"^(?P<indent>[ \t]*)import\s*\((?P<body>.*?)\)", re.MULTILINE | re.DOTALL)
|
|
38
|
+
_GO_PACKAGE = re.compile(r"^[ \t]*package\s+\w+[ \t]*(?:\r\n|\r|\n)", re.MULTILINE)
|
|
39
|
+
|
|
40
|
+
_ROLE_GUARD_MARKERS = (
|
|
41
|
+
"RequireRole",
|
|
42
|
+
"KomaguRoleGuard",
|
|
43
|
+
"requireRole",
|
|
44
|
+
"@RolesAllowed",
|
|
45
|
+
"@PreAuthorize",
|
|
46
|
+
"[Authorize(Roles",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
_ROLE_PATH_HINTS: Tuple[Tuple[str, str], ...] = (
|
|
50
|
+
("/admin", "admin"),
|
|
51
|
+
("/staff", "staff"),
|
|
52
|
+
("/internal", "internal"),
|
|
53
|
+
("/operator", "operator"),
|
|
54
|
+
("/moderator", "moderator"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class BFLARewriteResult:
|
|
60
|
+
"""Represents the outcome of injecting a BFLA role guard."""
|
|
61
|
+
|
|
62
|
+
original_line: str
|
|
63
|
+
patched_line: str
|
|
64
|
+
role: str = "admin"
|
|
65
|
+
guard_type: str = "annotation"
|
|
66
|
+
imported: bool = False
|
|
67
|
+
changed: bool = False
|
|
68
|
+
explanation: str = ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ensure_go_import(content: str, alias: str, pkg_path: str) -> Tuple[str, bool]:
|
|
72
|
+
"""Ensure a Go import with alias is present in content."""
|
|
73
|
+
if pkg_path in content:
|
|
74
|
+
return content, False
|
|
75
|
+
newline = _newline_of(content)
|
|
76
|
+
import_line = f'\t{alias} "{pkg_path}"{newline}'
|
|
77
|
+
|
|
78
|
+
block = _GO_IMPORT_BLOCK.search(content)
|
|
79
|
+
if block:
|
|
80
|
+
body = block.group("body")
|
|
81
|
+
insert_at = block.end("body")
|
|
82
|
+
prefix = "" if body.endswith(("\n", "\r")) else newline
|
|
83
|
+
return content[:insert_at] + prefix + import_line + content[insert_at:], True
|
|
84
|
+
|
|
85
|
+
single = _GO_IMPORT_SINGLE.search(content)
|
|
86
|
+
if single:
|
|
87
|
+
existing = f'\t"{single.group("pkg")}"{newline}'
|
|
88
|
+
replacement = f"import ({newline}{existing}{import_line}){newline}"
|
|
89
|
+
return content[: single.start()] + replacement + content[single.end():], True
|
|
90
|
+
|
|
91
|
+
pkg = _GO_PACKAGE.search(content)
|
|
92
|
+
if pkg:
|
|
93
|
+
stmt = f'{newline}import {alias} "{pkg_path}"{newline}'
|
|
94
|
+
return content[: pkg.end()] + stmt + content[pkg.end():], True
|
|
95
|
+
|
|
96
|
+
return f'import {alias} "{pkg_path}"{newline}' + content, True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ensure_js_named_imports(content: str, names: List[str]) -> Tuple[str, bool]:
|
|
100
|
+
"""Ensure named imports from '@hoanghainh/komagu' are present in JS/TS content."""
|
|
101
|
+
newline = _newline_of(content)
|
|
102
|
+
match = re.search(
|
|
103
|
+
r"""import\s*\{(?P<names>[^}]*)\}\s*from\s*(['"])@hoanghainh/komagu\2\s*;?""",
|
|
104
|
+
content,
|
|
105
|
+
)
|
|
106
|
+
if match:
|
|
107
|
+
existing = [n.strip() for n in match.group("names").split(",") if n.strip()]
|
|
108
|
+
missing = [n for n in names if n not in existing]
|
|
109
|
+
if not missing:
|
|
110
|
+
return content, False
|
|
111
|
+
merged = ", ".join(existing + missing)
|
|
112
|
+
updated = f"import {{ {merged} }} from '@hoanghainh/komagu';"
|
|
113
|
+
return content[: match.start()] + updated + content[match.end():], True
|
|
114
|
+
|
|
115
|
+
stmt = f"import {{ {', '.join(names)} }} from '@hoanghainh/komagu';{newline}"
|
|
116
|
+
return stmt + content, True
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class BFLARolePatcher:
|
|
120
|
+
"""Detects unprotected privileged endpoints and injects fail-closed role guards."""
|
|
121
|
+
|
|
122
|
+
EXT_TO_LANG: Dict[str, str] = {
|
|
123
|
+
".ts": "typescript",
|
|
124
|
+
".js": "javascript",
|
|
125
|
+
".cs": "csharp",
|
|
126
|
+
".java": "java",
|
|
127
|
+
".kt": "java",
|
|
128
|
+
".go": "golang",
|
|
129
|
+
".py": "python",
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def detect_language(self, file_path: Path | str) -> str:
|
|
133
|
+
"""Infer language from file extension."""
|
|
134
|
+
return self.EXT_TO_LANG.get(Path(file_path).suffix.lower(), "generic")
|
|
135
|
+
|
|
136
|
+
def infer_role(self, finding: Dict[str, Any]) -> str:
|
|
137
|
+
"""Infer required role from finding metadata or endpoint path (defaults to 'admin')."""
|
|
138
|
+
explicit = finding.get("required_role") or (finding.get("endpoint") or {}).get("required_role")
|
|
139
|
+
if isinstance(explicit, str) and explicit.strip():
|
|
140
|
+
return explicit.strip()
|
|
141
|
+
|
|
142
|
+
path = str((finding.get("endpoint") or {}).get("path", "")).lower()
|
|
143
|
+
for prefix, role in _ROLE_PATH_HINTS:
|
|
144
|
+
if prefix in path:
|
|
145
|
+
return role
|
|
146
|
+
return "admin"
|
|
147
|
+
|
|
148
|
+
def ensure_imports(self, content: str, lang: str, names: Optional[List[str]] = None) -> Tuple[str, bool]:
|
|
149
|
+
"""Ensure required Komagu role-guard imports exist for the target language."""
|
|
150
|
+
newline = _newline_of(content)
|
|
151
|
+
if lang in ("typescript", "javascript"):
|
|
152
|
+
return _ensure_js_named_imports(content, names or ["RequireRole", "KomaguRoleGuard"])
|
|
153
|
+
if lang == "csharp":
|
|
154
|
+
if "using Komagu.Security.Authorization;" in content:
|
|
155
|
+
return content, False
|
|
156
|
+
return f"using Komagu.Security.Authorization;{newline}" + content, True
|
|
157
|
+
if lang == "java":
|
|
158
|
+
if "import io.komagu.security.spring.RequireRole;" in content:
|
|
159
|
+
return content, False
|
|
160
|
+
stmt = f"import io.komagu.security.spring.RequireRole;{newline}"
|
|
161
|
+
pkg = _JAVA_PACKAGE_STATEMENT.search(content)
|
|
162
|
+
if pkg:
|
|
163
|
+
return content[: pkg.end()] + newline + stmt + content[pkg.end():], True
|
|
164
|
+
return stmt + content, True
|
|
165
|
+
if lang == "golang":
|
|
166
|
+
return _ensure_go_import(content, "komaguauthz", "github.com/hoanghainh1188/komagu/remediate/authz/golang")
|
|
167
|
+
return content, False
|
|
168
|
+
|
|
169
|
+
def patch_file(
|
|
170
|
+
self,
|
|
171
|
+
target_file: Path,
|
|
172
|
+
line_number: int,
|
|
173
|
+
finding: Dict[str, Any],
|
|
174
|
+
content: Optional[str] = None,
|
|
175
|
+
) -> Tuple[str, BFLARewriteResult]:
|
|
176
|
+
"""Inject a role guard at line_number for a BFLA finding."""
|
|
177
|
+
if content is None:
|
|
178
|
+
content = Path(target_file).read_bytes().decode("utf-8")
|
|
179
|
+
lines = content.splitlines(keepends=True)
|
|
180
|
+
idx = line_number - 1
|
|
181
|
+
|
|
182
|
+
if idx < 0 or idx >= len(lines):
|
|
183
|
+
return content, BFLARewriteResult("", "", changed=False, explanation="Line number out of bounds.")
|
|
184
|
+
|
|
185
|
+
lang = self.detect_language(target_file)
|
|
186
|
+
role = self.infer_role(finding)
|
|
187
|
+
target_line = lines[idx]
|
|
188
|
+
indent = re.match(r"^(\s*)", target_line).group(1) if target_line else ""
|
|
189
|
+
newline = _newline_of(content)
|
|
190
|
+
|
|
191
|
+
surrounding = "".join(lines[max(0, idx - 3):min(len(lines), idx + 3)])
|
|
192
|
+
if any(marker in surrounding for marker in _ROLE_GUARD_MARKERS):
|
|
193
|
+
return content, BFLARewriteResult(
|
|
194
|
+
original_line=target_line,
|
|
195
|
+
patched_line=target_line,
|
|
196
|
+
role=role,
|
|
197
|
+
changed=False,
|
|
198
|
+
explanation="Role guard already present at target line.",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if lang in ("typescript", "javascript"):
|
|
202
|
+
express_match = _EXPRESS_ROUTE.match(target_line)
|
|
203
|
+
if express_match:
|
|
204
|
+
eol = express_match.group("eol") or newline
|
|
205
|
+
rewritten = (
|
|
206
|
+
f"{express_match.group('indent')}{express_match.group('router')}."
|
|
207
|
+
f"{express_match.group('method')}({express_match.group('path')}, "
|
|
208
|
+
f"requireRole('{role}'), {express_match.group('handler')}){express_match.group('tail')}{eol}"
|
|
209
|
+
)
|
|
210
|
+
lines[idx] = rewritten
|
|
211
|
+
final_content, imported = self.ensure_imports("".join(lines), lang, ["requireRole"])
|
|
212
|
+
return final_content, BFLARewriteResult(
|
|
213
|
+
original_line=target_line,
|
|
214
|
+
patched_line=rewritten,
|
|
215
|
+
role=role,
|
|
216
|
+
guard_type="express_middleware",
|
|
217
|
+
imported=imported,
|
|
218
|
+
changed=True,
|
|
219
|
+
explanation=f"Injected Express requireRole('{role}') middleware on route.",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if re.search(r"\b(app|router)\.(get|post|put|delete|patch)\s*\(", target_line):
|
|
223
|
+
return content, BFLARewriteResult(
|
|
224
|
+
original_line=target_line,
|
|
225
|
+
patched_line=target_line,
|
|
226
|
+
role=role,
|
|
227
|
+
changed=False,
|
|
228
|
+
explanation="Complex Express route needs requireRole middleware wired manually; requires manual remediation.",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
guard_snippet = (
|
|
232
|
+
f"{indent}@UseGuards(KomaguRoleGuard){newline}"
|
|
233
|
+
f"{indent}@RequireRole('{role}'){newline}"
|
|
234
|
+
)
|
|
235
|
+
lines.insert(idx, guard_snippet)
|
|
236
|
+
final_content, imported = self.ensure_imports("".join(lines), lang, ["RequireRole", "KomaguRoleGuard"])
|
|
237
|
+
return final_content, BFLARewriteResult(
|
|
238
|
+
original_line=target_line,
|
|
239
|
+
patched_line=guard_snippet + target_line,
|
|
240
|
+
role=role,
|
|
241
|
+
guard_type="nestjs_decorator",
|
|
242
|
+
imported=imported,
|
|
243
|
+
changed=True,
|
|
244
|
+
explanation=f"Injected NestJS @UseGuards(KomaguRoleGuard) and @RequireRole('{role}').",
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
if lang == "csharp":
|
|
248
|
+
guard_snippet = f'{indent}[RequireRole("{role}")]{newline}'
|
|
249
|
+
lines.insert(idx, guard_snippet)
|
|
250
|
+
final_content, imported = self.ensure_imports("".join(lines), lang)
|
|
251
|
+
return final_content, BFLARewriteResult(
|
|
252
|
+
original_line=target_line,
|
|
253
|
+
patched_line=guard_snippet + target_line,
|
|
254
|
+
role=role,
|
|
255
|
+
guard_type="csharp_attribute",
|
|
256
|
+
imported=imported,
|
|
257
|
+
changed=True,
|
|
258
|
+
explanation=f'Injected ASP.NET Core [RequireRole("{role}")] attribute.',
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
if lang == "java":
|
|
262
|
+
if not (_SPRING_CONTROLLER.search(content) and _is_java_handler_line(lines, idx)):
|
|
263
|
+
return content, BFLARewriteResult(
|
|
264
|
+
original_line=target_line,
|
|
265
|
+
patched_line=target_line,
|
|
266
|
+
role=role,
|
|
267
|
+
changed=False,
|
|
268
|
+
explanation="@RequireRole requires a Spring controller handler method; requires manual remediation.",
|
|
269
|
+
)
|
|
270
|
+
guard_snippet = f'{indent}@RequireRole("{role}"){newline}'
|
|
271
|
+
lines.insert(idx, guard_snippet)
|
|
272
|
+
final_content, imported = self.ensure_imports("".join(lines), lang)
|
|
273
|
+
return final_content, BFLARewriteResult(
|
|
274
|
+
original_line=target_line,
|
|
275
|
+
patched_line=guard_snippet + target_line,
|
|
276
|
+
role=role,
|
|
277
|
+
guard_type="spring_annotation",
|
|
278
|
+
imported=imported,
|
|
279
|
+
changed=True,
|
|
280
|
+
explanation=f'Injected Spring @RequireRole("{role}") annotation.',
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if lang == "golang":
|
|
284
|
+
go_match = _GO_HANDLE.match(target_line)
|
|
285
|
+
if not go_match:
|
|
286
|
+
return content, BFLARewriteResult(
|
|
287
|
+
original_line=target_line,
|
|
288
|
+
patched_line=target_line,
|
|
289
|
+
role=role,
|
|
290
|
+
changed=False,
|
|
291
|
+
explanation="Go BFLA guard requires a standard mux.Handle/HandleFunc registration; requires manual remediation.",
|
|
292
|
+
)
|
|
293
|
+
eol = go_match.group("eol") or newline
|
|
294
|
+
handler = go_match.group("handler")
|
|
295
|
+
wrapped = f"http.HandlerFunc({handler})" if go_match.group("fn") == "HandleFunc" else handler
|
|
296
|
+
rewritten = (
|
|
297
|
+
f"{go_match.group('indent')}{go_match.group('router')}.Handle("
|
|
298
|
+
f"{go_match.group('path')}, "
|
|
299
|
+
f'komaguauthz.RequireRole([]string{{"{role}"}})({wrapped})){eol}'
|
|
300
|
+
)
|
|
301
|
+
lines[idx] = rewritten
|
|
302
|
+
final_content, imported = self.ensure_imports("".join(lines), lang)
|
|
303
|
+
return final_content, BFLARewriteResult(
|
|
304
|
+
original_line=target_line,
|
|
305
|
+
patched_line=rewritten,
|
|
306
|
+
role=role,
|
|
307
|
+
guard_type="go_middleware",
|
|
308
|
+
imported=imported,
|
|
309
|
+
changed=True,
|
|
310
|
+
explanation=f'Wrapped Go handler with komaguauthz.RequireRole([]string{{"{role}"}}).',
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
return content, BFLARewriteResult(
|
|
314
|
+
original_line=target_line,
|
|
315
|
+
patched_line=target_line,
|
|
316
|
+
role=role,
|
|
317
|
+
changed=False,
|
|
318
|
+
explanation=f"Automated BFLA role guard not supported for language '{lang}'; requires manual remediation.",
|
|
319
|
+
)
|
package/remediate/authz/dotnet/src/Komagu.Security.Authorization/LoggingSecurityAuditSink.cs
CHANGED
|
@@ -5,7 +5,7 @@ using Microsoft.Extensions.Logging;
|
|
|
5
5
|
/// <summary>
|
|
6
6
|
/// Default security audit sink implementation logging structured events via ILogger.
|
|
7
7
|
/// </summary>
|
|
8
|
-
public sealed class LoggingSecurityAuditSink : ISecurityAuditSink
|
|
8
|
+
public sealed partial class LoggingSecurityAuditSink : ISecurityAuditSink
|
|
9
9
|
{
|
|
10
10
|
private readonly ILogger<LoggingSecurityAuditSink> _logger;
|
|
11
11
|
|
|
@@ -18,8 +18,8 @@ public sealed class LoggingSecurityAuditSink : ISecurityAuditSink
|
|
|
18
18
|
{
|
|
19
19
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
LogAuditEvent(
|
|
22
|
+
_logger,
|
|
23
23
|
auditEvent.EventName,
|
|
24
24
|
auditEvent.TraceId ?? "none",
|
|
25
25
|
auditEvent.ActorUserId ?? "anonymous",
|
|
@@ -32,4 +32,20 @@ public sealed class LoggingSecurityAuditSink : ISecurityAuditSink
|
|
|
32
32
|
|
|
33
33
|
return ValueTask.CompletedTask;
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
[LoggerMessage(
|
|
37
|
+
EventId = 1,
|
|
38
|
+
Level = LogLevel.Warning,
|
|
39
|
+
Message = "[SECURITY AUDIT] {EventName} | TraceId: {TraceId} | Actor: {ActorUserId} | Tenant: {ActorTenantId} | Target: {ResourceType}/{ResourceId} | Op: {Operation} | IP: {ClientIp} | Reason: {Reason}")]
|
|
40
|
+
private static partial void LogAuditEvent(
|
|
41
|
+
ILogger logger,
|
|
42
|
+
string eventName,
|
|
43
|
+
string traceId,
|
|
44
|
+
string actorUserId,
|
|
45
|
+
string actorTenantId,
|
|
46
|
+
string resourceType,
|
|
47
|
+
string resourceId,
|
|
48
|
+
ResourceOperation operation,
|
|
49
|
+
string clientIp,
|
|
50
|
+
string reason);
|
|
35
51
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package golang
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"errors"
|
|
4
5
|
"net/http"
|
|
5
6
|
"net/http/httptest"
|
|
6
7
|
"testing"
|
|
@@ -67,7 +68,7 @@ func TestAuthorizer_AttackerDeniedWith404AndAudit(t *testing.T) {
|
|
|
67
68
|
// Verify RequireOwnership returns ErrResourceNotFound
|
|
68
69
|
sink.Events = nil // Reset sink
|
|
69
70
|
err := auth.RequireOwnership(attackerClaims, order, OpRead)
|
|
70
|
-
if err
|
|
71
|
+
if !errors.Is(err, ErrResourceNotFound) {
|
|
71
72
|
t.Fatalf("expected ErrResourceNotFound, got: %v", err)
|
|
72
73
|
}
|
|
73
74
|
|
|
@@ -129,7 +130,9 @@ func TestAuthorizer_HTTPMiddleware(t *testing.T) {
|
|
|
129
130
|
|
|
130
131
|
handler := auth.Middleware(claimsExtractor, loader, OpRead)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
131
132
|
w.WriteHeader(http.StatusOK)
|
|
132
|
-
w.Write([]byte(`{"status":"ok"}`))
|
|
133
|
+
if _, err := w.Write([]byte(`{"status":"ok"}`)); err != nil {
|
|
134
|
+
t.Errorf("write response: %v", err)
|
|
135
|
+
}
|
|
133
136
|
}))
|
|
134
137
|
|
|
135
138
|
// 1. Owner request -> 200 OK
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Automated Mass Assignment Allow-List Patcher (OWASP API3:2023, CWE-915).
|
|
2
|
+
|
|
3
|
+
Rewrites whole-object request body bindings into explicit allow-list calls
|
|
4
|
+
using Komagu's allowFields / AllowFields helpers or framework-native include
|
|
5
|
+
filters when explicit allowed_fields are provided in the finding.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from remediate.authz.bfla_patcher import _ensure_go_import, _ensure_js_named_imports
|
|
16
|
+
from remediate.authz.patcher import _newline_of
|
|
17
|
+
|
|
18
|
+
_JS_REQ_BODY = re.compile(r"\breq\.body\b")
|
|
19
|
+
_PY_MODEL_DUMP = re.compile(r"\b(?P<var>[A-Za-z_][A-Za-z0-9_]*)\.(?P<method>model_dump|dict)\s*\(\s*\)")
|
|
20
|
+
_GO_MAP_ARG = re.compile(r"(?P<call>[A-Za-z_][A-Za-z0-9_.]*\([^)]*\b)(?P<arg>body|payload|input|updates|fields)\b")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class MassAssignmentRewriteResult:
|
|
25
|
+
"""Represents the outcome of rewriting a mass-assignment binding."""
|
|
26
|
+
|
|
27
|
+
original_line: str
|
|
28
|
+
patched_line: str
|
|
29
|
+
allowed_fields: Tuple[str, ...] = ()
|
|
30
|
+
imported: bool = False
|
|
31
|
+
changed: bool = False
|
|
32
|
+
explanation: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _extract_allowed_fields(finding: Dict[str, Any]) -> List[str]:
|
|
36
|
+
"""Extract validated allowed field names from finding metadata, if explicitly supplied."""
|
|
37
|
+
candidates: Any = finding.get("allowed_fields")
|
|
38
|
+
if candidates is None:
|
|
39
|
+
evidence = finding.get("evidence")
|
|
40
|
+
if isinstance(evidence, dict):
|
|
41
|
+
candidates = evidence.get("allowed_fields")
|
|
42
|
+
if not isinstance(candidates, list):
|
|
43
|
+
return []
|
|
44
|
+
cleaned: List[str] = []
|
|
45
|
+
for item in candidates:
|
|
46
|
+
if isinstance(item, str):
|
|
47
|
+
name = item.strip()
|
|
48
|
+
if name and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
|
|
49
|
+
cleaned.append(name)
|
|
50
|
+
return cleaned
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MassAssignmentPatcher:
|
|
54
|
+
"""Rewrites unsafe whole-object bindings into explicit allow-list bindings."""
|
|
55
|
+
|
|
56
|
+
EXT_TO_LANG: Dict[str, str] = {
|
|
57
|
+
".ts": "typescript",
|
|
58
|
+
".js": "javascript",
|
|
59
|
+
".py": "python",
|
|
60
|
+
".go": "golang",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
def detect_language(self, file_path: Path | str) -> str:
|
|
64
|
+
"""Infer language from file extension."""
|
|
65
|
+
return self.EXT_TO_LANG.get(Path(file_path).suffix.lower(), "generic")
|
|
66
|
+
|
|
67
|
+
def patch_file(
|
|
68
|
+
self,
|
|
69
|
+
target_file: Path,
|
|
70
|
+
line_number: int,
|
|
71
|
+
finding: Dict[str, Any],
|
|
72
|
+
content: Optional[str] = None,
|
|
73
|
+
) -> Tuple[str, MassAssignmentRewriteResult]:
|
|
74
|
+
"""Rewrite an unsafe mass-assignment line using explicit allowed_fields from finding."""
|
|
75
|
+
if content is None:
|
|
76
|
+
content = Path(target_file).read_bytes().decode("utf-8")
|
|
77
|
+
lines = content.splitlines(keepends=True)
|
|
78
|
+
idx = line_number - 1
|
|
79
|
+
|
|
80
|
+
if idx < 0 or idx >= len(lines):
|
|
81
|
+
return content, MassAssignmentRewriteResult("", "", changed=False, explanation="Line number out of bounds.")
|
|
82
|
+
|
|
83
|
+
target_line = lines[idx]
|
|
84
|
+
allowed = _extract_allowed_fields(finding)
|
|
85
|
+
if not allowed:
|
|
86
|
+
return content, MassAssignmentRewriteResult(
|
|
87
|
+
original_line=target_line,
|
|
88
|
+
patched_line=target_line,
|
|
89
|
+
changed=False,
|
|
90
|
+
explanation="No allowed_fields provided in finding; requires manual allow-list definition.",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
lang = self.detect_language(target_file)
|
|
94
|
+
|
|
95
|
+
if lang in ("typescript", "javascript"):
|
|
96
|
+
if "allowFields(" in target_line:
|
|
97
|
+
return content, MassAssignmentRewriteResult(
|
|
98
|
+
original_line=target_line,
|
|
99
|
+
patched_line=target_line,
|
|
100
|
+
allowed_fields=tuple(allowed),
|
|
101
|
+
changed=False,
|
|
102
|
+
explanation="allowFields() guard already applied on target line.",
|
|
103
|
+
)
|
|
104
|
+
if not _JS_REQ_BODY.search(target_line):
|
|
105
|
+
return content, MassAssignmentRewriteResult(
|
|
106
|
+
original_line=target_line,
|
|
107
|
+
patched_line=target_line,
|
|
108
|
+
allowed_fields=tuple(allowed),
|
|
109
|
+
changed=False,
|
|
110
|
+
explanation="No direct req.body binding found on target line; requires manual remediation.",
|
|
111
|
+
)
|
|
112
|
+
fields_js = ", ".join(f"'{f}'" for f in allowed)
|
|
113
|
+
rewritten = _JS_REQ_BODY.sub(f"allowFields(req.body, [{fields_js}])", target_line, count=1)
|
|
114
|
+
lines[idx] = rewritten
|
|
115
|
+
final_content, imported = _ensure_js_named_imports("".join(lines), ["allowFields"])
|
|
116
|
+
return final_content, MassAssignmentRewriteResult(
|
|
117
|
+
original_line=target_line,
|
|
118
|
+
patched_line=rewritten,
|
|
119
|
+
allowed_fields=tuple(allowed),
|
|
120
|
+
imported=imported,
|
|
121
|
+
changed=True,
|
|
122
|
+
explanation=f"Wrapped req.body with allowFields(..., [{fields_js}]).",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if lang == "python":
|
|
126
|
+
match = _PY_MODEL_DUMP.search(target_line)
|
|
127
|
+
if not match:
|
|
128
|
+
return content, MassAssignmentRewriteResult(
|
|
129
|
+
original_line=target_line,
|
|
130
|
+
patched_line=target_line,
|
|
131
|
+
allowed_fields=tuple(allowed),
|
|
132
|
+
changed=False,
|
|
133
|
+
explanation="No unguarded Pydantic .dict()/.model_dump() call on target line; requires manual remediation.",
|
|
134
|
+
)
|
|
135
|
+
fields_py = ", ".join(f'"{f}"' for f in allowed)
|
|
136
|
+
replacement = f"{match.group('var')}.{match.group('method')}(include={{{fields_py}}})"
|
|
137
|
+
rewritten = target_line[: match.start()] + replacement + target_line[match.end():]
|
|
138
|
+
lines[idx] = rewritten
|
|
139
|
+
return "".join(lines), MassAssignmentRewriteResult(
|
|
140
|
+
original_line=target_line,
|
|
141
|
+
patched_line=rewritten,
|
|
142
|
+
allowed_fields=tuple(allowed),
|
|
143
|
+
imported=False,
|
|
144
|
+
changed=True,
|
|
145
|
+
explanation=f"Restricted {match.group('method')}() with include={{{fields_py}}}.",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if lang == "golang":
|
|
149
|
+
if "AllowFields(" in target_line:
|
|
150
|
+
return content, MassAssignmentRewriteResult(
|
|
151
|
+
original_line=target_line,
|
|
152
|
+
patched_line=target_line,
|
|
153
|
+
allowed_fields=tuple(allowed),
|
|
154
|
+
changed=False,
|
|
155
|
+
explanation="AllowFields() guard already applied on target line.",
|
|
156
|
+
)
|
|
157
|
+
match = _GO_MAP_ARG.search(target_line)
|
|
158
|
+
if not match:
|
|
159
|
+
return content, MassAssignmentRewriteResult(
|
|
160
|
+
original_line=target_line,
|
|
161
|
+
patched_line=target_line,
|
|
162
|
+
allowed_fields=tuple(allowed),
|
|
163
|
+
changed=False,
|
|
164
|
+
explanation="No direct map payload binding found on target line; requires manual remediation.",
|
|
165
|
+
)
|
|
166
|
+
fields_go = ", ".join(f'"{f}"' for f in allowed)
|
|
167
|
+
replacement = f"{match.group('call')}komaguauthz.AllowFields({match.group('arg')}, {fields_go})"
|
|
168
|
+
rewritten = target_line[: match.start()] + replacement + target_line[match.end():]
|
|
169
|
+
lines[idx] = rewritten
|
|
170
|
+
final_content, imported = _ensure_go_import(
|
|
171
|
+
"".join(lines), "komaguauthz", "github.com/hoanghainh1188/komagu/remediate/authz/golang"
|
|
172
|
+
)
|
|
173
|
+
return final_content, MassAssignmentRewriteResult(
|
|
174
|
+
original_line=target_line,
|
|
175
|
+
patched_line=rewritten,
|
|
176
|
+
allowed_fields=tuple(allowed),
|
|
177
|
+
imported=imported,
|
|
178
|
+
changed=True,
|
|
179
|
+
explanation=f"Wrapped {match.group('arg')} with komaguauthz.AllowFields(..., {fields_go}).",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return content, MassAssignmentRewriteResult(
|
|
183
|
+
original_line=target_line,
|
|
184
|
+
patched_line=target_line,
|
|
185
|
+
allowed_fields=tuple(allowed),
|
|
186
|
+
changed=False,
|
|
187
|
+
explanation=f"Automated mass-assignment rewrite not supported for language '{lang}'; requires manual remediation.",
|
|
188
|
+
)
|
|
@@ -287,6 +287,14 @@ class BOLAOwnershipPatcher:
|
|
|
287
287
|
elif lang == "python":
|
|
288
288
|
guard_lines.append(f"{indent}@RequireOwnership(operation=ResourceOperation.{operation}, id_param=\"id\")\n")
|
|
289
289
|
|
|
290
|
+
elif lang == "golang":
|
|
291
|
+
return content, BOLARewriteResult(
|
|
292
|
+
original_line=target_line,
|
|
293
|
+
patched_line=target_line,
|
|
294
|
+
changed=False,
|
|
295
|
+
explanation="Go BOLA guard requires Authorizer.Middleware(resourceLoader, ...) with a domain resourceLoader; requires manual remediation.",
|
|
296
|
+
)
|
|
297
|
+
|
|
290
298
|
else:
|
|
291
299
|
return content, BOLARewriteResult(
|
|
292
300
|
original_line=target_line,
|