@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,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
+ )
@@ -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,