@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,213 @@
1
+ #!/usr/bin/env python3
2
+ """Tests for BFLA & Mass Assignment patchers and remediation strategies (Issue #392)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import shutil
8
+ import sys
9
+ import tempfile
10
+ import unittest
11
+ from pathlib import Path
12
+
13
+ REPO_ROOT = Path(__file__).resolve().parents[3]
14
+ sys.path.insert(0, str(REPO_ROOT))
15
+
16
+ from operate.remediator.runner import RemediationRunner
17
+ from operate.remediator.strategies import StrategyRegistry
18
+ from remediate.authz.bfla_patcher import BFLARolePatcher
19
+ from remediate.authz.mass_assignment_patcher import MassAssignmentPatcher
20
+
21
+
22
+ class TestBFLARolePatcher(unittest.TestCase):
23
+ """Verify fail-closed role guard injection across Node.js, Go, C#, and Java."""
24
+
25
+ def setUp(self) -> None:
26
+ self.patcher = BFLARolePatcher()
27
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="komagu_bfla_test_")).resolve()
28
+
29
+ def tearDown(self) -> None:
30
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
31
+
32
+ def test_nestjs_decorator_and_merge_import(self) -> None:
33
+ ts_file = self.temp_dir / "admin.controller.ts"
34
+ ts_file.write_text(
35
+ "import { OwnershipGuard } from '@hoanghainh/komagu';\n\n"
36
+ "export class AdminController {\n"
37
+ " @Post('users')\n"
38
+ " createUser() {\n"
39
+ " return {};\n"
40
+ " }\n"
41
+ "}\n",
42
+ encoding="utf-8",
43
+ )
44
+ finding = {"finding_id": "BFLA-01", "category": "bfla", "endpoint": {"method": "POST", "path": "/admin/users"}}
45
+ patched, res = self.patcher.patch_file(ts_file, 4, finding)
46
+ self.assertTrue(res.changed)
47
+ self.assertIn("import { OwnershipGuard, RequireRole, KomaguRoleGuard } from '@hoanghainh/komagu';", patched)
48
+ self.assertIn(" @UseGuards(KomaguRoleGuard)\n @RequireRole('admin')\n @Post('users')", patched)
49
+
50
+ def test_express_route_middleware_injection(self) -> None:
51
+ js_file = self.temp_dir / "routes.js"
52
+ js_file.write_text(
53
+ "const express = require('express');\n"
54
+ "app.post('/internal/rebuild', triggerRebuild);\n",
55
+ encoding="utf-8",
56
+ )
57
+ finding = {"finding_id": "BFLA-02", "category": "bfla", "endpoint": {"method": "POST", "path": "/internal/rebuild"}}
58
+ patched, res = self.patcher.patch_file(js_file, 2, finding)
59
+ self.assertTrue(res.changed)
60
+ self.assertIn("import { requireRole } from '@hoanghainh/komagu';", patched)
61
+ self.assertIn("app.post('/internal/rebuild', requireRole('internal'), triggerRebuild);", patched)
62
+
63
+ def test_go_mux_handlefunc_wrapping(self) -> None:
64
+ go_file = self.temp_dir / "routes.go"
65
+ go_file.write_text(
66
+ 'package main\n\nimport "net/http"\n\n'
67
+ "func register(mux *http.ServeMux) {\n"
68
+ '\tmux.HandleFunc("/admin/audit", handleAudit)\n'
69
+ "}\n",
70
+ encoding="utf-8",
71
+ )
72
+ finding = {"finding_id": "BFLA-03", "category": "bfla", "endpoint": {"method": "GET", "path": "/admin/audit"}}
73
+ patched, res = self.patcher.patch_file(go_file, 6, finding)
74
+ self.assertTrue(res.changed)
75
+ self.assertIn('komaguauthz "github.com/hoanghainh1188/komagu/remediate/authz/golang"', patched)
76
+ self.assertIn('mux.Handle("/admin/audit", komaguauthz.RequireRole([]string{"admin"})(http.HandlerFunc(handleAudit)))', patched)
77
+
78
+ def test_csharp_and_spring_annotations(self) -> None:
79
+ cs_file = self.temp_dir / "StaffController.cs"
80
+ cs_file.write_text("public class StaffController {\n public IActionResult List() => Ok();\n}\n", encoding="utf-8")
81
+ finding = {"finding_id": "BFLA-04", "category": "bfla", "endpoint": {"method": "GET", "path": "/staff/list"}}
82
+ patched_cs, res_cs = self.patcher.patch_file(cs_file, 2, finding)
83
+ self.assertTrue(res_cs.changed)
84
+ self.assertIn("using Komagu.Security.Authorization;", patched_cs)
85
+ self.assertIn(' [RequireRole("staff")]\n public IActionResult List()', patched_cs)
86
+
87
+ java_file = self.temp_dir / "AdminController.java"
88
+ java_file.write_text(
89
+ "package com.example;\n\n@RestController\npublic class AdminController {\n"
90
+ " @GetMapping(\"/admin/metrics\")\n"
91
+ " public String metrics() { return \"ok\"; }\n"
92
+ "}\n",
93
+ encoding="utf-8",
94
+ )
95
+ patched_java, res_java = self.patcher.patch_file(java_file, 5, finding)
96
+ self.assertTrue(res_java.changed)
97
+ self.assertIn("import io.komagu.security.spring.RequireRole;", patched_java)
98
+ self.assertIn(' @RequireRole("staff")\n @GetMapping("/admin/metrics")', patched_java)
99
+
100
+ def test_idempotent_when_guard_already_present(self) -> None:
101
+ cs_file = self.temp_dir / "AdminController.cs"
102
+ cs_file.write_text(
103
+ 'using Komagu.Security.Authorization;\n[RequireRole("admin")]\npublic IActionResult Delete() => Ok();\n',
104
+ encoding="utf-8",
105
+ )
106
+ _, res = self.patcher.patch_file(cs_file, 3, {"category": "bfla"})
107
+ self.assertFalse(res.changed)
108
+
109
+
110
+ class TestMassAssignmentPatcher(unittest.TestCase):
111
+ """Verify allow-list rewrites for Mass Assignment findings."""
112
+
113
+ def setUp(self) -> None:
114
+ self.patcher = MassAssignmentPatcher()
115
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="komagu_mass_test_")).resolve()
116
+
117
+ def tearDown(self) -> None:
118
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
119
+
120
+ def test_refuses_without_explicit_allowed_fields(self) -> None:
121
+ ts_file = self.temp_dir / "user.ts"
122
+ ts_file.write_text("Object.assign(user, req.body);\n", encoding="utf-8")
123
+ _, res = self.patcher.patch_file(ts_file, 1, {"category": "mass_assignment"})
124
+ self.assertFalse(res.changed)
125
+ self.assertIn("No allowed_fields", res.explanation)
126
+
127
+ def test_typescript_req_body_wrapped_with_allow_fields(self) -> None:
128
+ ts_file = self.temp_dir / "user.ts"
129
+ ts_file.write_text("Object.assign(user, req.body);\n", encoding="utf-8")
130
+ finding = {"category": "mass_assignment", "allowed_fields": ["name", "email"]}
131
+ patched, res = self.patcher.patch_file(ts_file, 1, finding)
132
+ self.assertTrue(res.changed)
133
+ self.assertIn("import { allowFields } from '@hoanghainh/komagu';", patched)
134
+ self.assertIn("Object.assign(user, allowFields(req.body, ['name', 'email']));", patched)
135
+
136
+ def test_python_pydantic_model_dump_include(self) -> None:
137
+ py_file = self.temp_dir / "routes.py"
138
+ py_file.write_text("user.update(**payload.model_dump())\n", encoding="utf-8")
139
+ finding = {"category": "mass_assignment", "allowed_fields": ["display_name", "bio"]}
140
+ patched, res = self.patcher.patch_file(py_file, 1, finding)
141
+ self.assertTrue(res.changed)
142
+ self.assertEqual(patched, 'user.update(**payload.model_dump(include={"display_name", "bio"}))\n')
143
+
144
+ def test_go_allow_fields_wrapping(self) -> None:
145
+ go_file = self.temp_dir / "handler.go"
146
+ go_file.write_text(
147
+ 'package main\n\nfunc update(repo Repo, id string, body map[string]any) {\n'
148
+ "\trepo.Update(id, body)\n"
149
+ "}\n",
150
+ encoding="utf-8",
151
+ )
152
+ finding = {"category": "mass_assignment", "allowed_fields": ["name", "email"]}
153
+ patched, res = self.patcher.patch_file(go_file, 4, finding)
154
+ self.assertTrue(res.changed)
155
+ self.assertIn('komaguauthz "github.com/hoanghainh1188/komagu/remediate/authz/golang"', patched)
156
+ self.assertIn('repo.Update(id, komaguauthz.AllowFields(body, "name", "email"))', patched)
157
+
158
+
159
+ class TestStrategyRegistryAndRunner(unittest.TestCase):
160
+ """Verify BFLA and Mass Assignment strategies integrate end-to-end with RemediationRunner."""
161
+
162
+ def setUp(self) -> None:
163
+ self.temp_dir = Path(tempfile.mkdtemp(prefix="komagu_strat_test_")).resolve()
164
+
165
+ def tearDown(self) -> None:
166
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
167
+
168
+ def test_registry_resolves_bfla_and_mass_assignment(self) -> None:
169
+ registry = StrategyRegistry()
170
+ self.assertEqual(registry.find_strategy({"category": "bfla"}).name, "bfla")
171
+ self.assertEqual(registry.find_strategy({"category": "mass_assignment"}).name, "mass_assignment")
172
+
173
+ def test_runner_dry_run_generates_diff_for_bfla_and_mass_assignment(self) -> None:
174
+ app_file = self.temp_dir / "routes.js"
175
+ app_file.write_text(
176
+ "app.delete('/admin/users/:id', deleteUser);\n"
177
+ "Object.assign(profile, req.body);\n",
178
+ encoding="utf-8",
179
+ )
180
+ findings_path = self.temp_dir / "findings.json"
181
+ findings_path.write_text(
182
+ json.dumps(
183
+ {
184
+ "findings": [
185
+ {
186
+ "finding_id": "BFLA-01",
187
+ "category": "bfla",
188
+ "cwe": "CWE-285",
189
+ "endpoint": {"method": "DELETE", "path": "/admin/users/{id}"},
190
+ "location": {"file": "routes.js", "start_line": 1},
191
+ },
192
+ {
193
+ "finding_id": "MASS-01",
194
+ "category": "mass_assignment",
195
+ "cwe": "CWE-915",
196
+ "allowed_fields": ["bio"],
197
+ "location": {"file": "routes.js", "start_line": 2},
198
+ },
199
+ ]
200
+ }
201
+ ),
202
+ encoding="utf-8",
203
+ )
204
+ runner = RemediationRunner(target_dir=self.temp_dir, findings_path=findings_path, dry_run=True)
205
+ report = runner.run()
206
+ self.assertEqual(report.planned_count, 2)
207
+ combined_diff = "\n".join(p.diff for p in report.plans)
208
+ self.assertIn("requireRole('admin')", combined_diff)
209
+ self.assertIn("allowFields(req.body, ['bio'])", combined_diff)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ unittest.main()