@hoanghainh/komagu 2.1.1 → 2.2.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/assess/lsa/knowledge/bfla-patterns.md +8 -0
  3. package/assess/lsa/knowledge/mass-assignment-patterns.md +7 -0
  4. package/assess/lsa/tests/EVAL-RESULTS.md +25 -0
  5. package/assess/lsa/tests/fixtures/dotnet-legacy/Web/Controllers/CustomerController.cs +1 -1
  6. package/assess/lsa/tests/fixtures/java-legacy/src/com/acme/web/AccountController.java +1 -1
  7. package/assess/lsa/tests/fixtures/php-plain/register.php +3 -3
  8. package/assess/lsa/tests/fixtures/python-flask/profile.py +2 -2
  9. package/operate/ai/client.py +69 -1
  10. package/operate/ai/runner.py +61 -15
  11. package/operate/ai/verdicts.py +113 -0
  12. package/operate/cli/__init__.py +1 -1
  13. package/operate/tests/test_ai_verdicts.py +200 -0
  14. package/package.json +1 -1
  15. package/remediate/authz/README.md +122 -1
  16. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/KomaguRoleOptions.cs +20 -0
  17. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RequireRoleAttribute.cs +34 -0
  18. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleAuthorizationFilter.cs +114 -0
  19. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/RoleDenyResponse.cs +17 -0
  20. package/remediate/authz/dotnet/src/Komagu.Security.AspNetCore/ServiceCollectionExtensions.cs +16 -0
  21. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/MassAssignmentDtoTests.cs +76 -0
  22. package/remediate/authz/dotnet/tests/Komagu.Security.Authorization.Tests/RoleAuthorizationFilterTests.cs +233 -0
  23. package/remediate/authz/golang/massassign.go +16 -0
  24. package/remediate/authz/golang/massassign_test.go +135 -0
  25. package/remediate/authz/golang/roles.go +168 -0
  26. package/remediate/authz/golang/roles_test.go +158 -0
  27. package/remediate/authz/nodejs/src/express/role-middleware.d.ts +14 -0
  28. package/remediate/authz/nodejs/src/express/role-middleware.js +52 -0
  29. package/remediate/authz/nodejs/src/index.d.ts +2 -0
  30. package/remediate/authz/nodejs/src/index.js +17 -2
  31. package/remediate/authz/nodejs/src/interfaces.d.ts +19 -4
  32. package/remediate/authz/nodejs/src/interfaces.js +10 -0
  33. package/remediate/authz/nodejs/src/mass-assignment.d.ts +8 -0
  34. package/remediate/authz/nodejs/src/mass-assignment.js +40 -0
  35. package/remediate/authz/nodejs/src/nestjs/decorators.d.ts +16 -0
  36. package/remediate/authz/nodejs/src/nestjs/decorators.js +27 -0
  37. package/remediate/authz/nodejs/src/nestjs/guards.d.ts +5 -0
  38. package/remediate/authz/nodejs/src/nestjs/guards.js +69 -2
  39. package/remediate/authz/nodejs/src/roles.js +90 -0
  40. package/remediate/authz/nodejs/src/service.d.ts +6 -0
  41. package/remediate/authz/nodejs/src/service.js +30 -0
  42. package/remediate/authz/nodejs/tests/test-authz.js +4 -0
  43. package/remediate/authz/nodejs/tests/test-roles.js +278 -0
  44. package/remediate/authz/spring/build.gradle.kts +5 -0
  45. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/authorization/SecurityAuditEvent.kt +10 -2
  46. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/data/MassAssignment.kt +18 -0
  47. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/AuthenticationRequiredException.kt +14 -0
  48. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/KomaguSecurityAutoConfiguration.kt +20 -0
  49. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RequireRole.kt +34 -0
  50. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleAspect.kt +97 -0
  51. package/remediate/authz/spring/src/main/kotlin/io/komagu/security/spring/RoleDeniedException.kt +14 -0
  52. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/KomaguSecurityAutoConfigurationTest.kt +34 -0
  53. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/MassAssignmentTest.kt +81 -0
  54. package/remediate/authz/spring/src/test/kotlin/io/komagu/security/RoleAspectTest.kt +196 -0
@@ -0,0 +1,200 @@
1
+ """Issue #323: replies with stray quotes in code fields parse, and flagged verdicts without a finding
2
+ become low-confidence anchored findings (offline replay of Evaluation Run 5, run A)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import unittest
12
+ from pathlib import Path
13
+
14
+ from operate.ai.anchoring import AnchorReport, anchor_findings, load_anchors
15
+ from operate.ai.client import LLMClient, repair_json_quotes
16
+ from operate.ai.runner import HeadlessAIRunner
17
+ from operate.ai.verdicts import verdict_findings
18
+ from operate.tests.test_ai_anchoring import _ScriptedClient
19
+
20
+ REPO_ROOT = Path(__file__).resolve().parents[2]
21
+ EVAL_RUN_5 = REPO_ROOT / "assess" / "lsa" / "examples" / "eval-run-5"
22
+ SCORER = REPO_ROOT / "assess" / "lsa" / "tests" / "score_findings.py"
23
+
24
+ # The slip that cost batch routes-001 in Run 4 and Run 5, run B (raw reply was not archived)
25
+ SLIPPED_REPLY = """Here is the audit:
26
+ ```json
27
+ {
28
+ "batch_id": "routes-001",
29
+ "findings": [
30
+ {
31
+ "finding_id": "BOLA-R-0001",
32
+ "category": "bola",
33
+ "subtype": "missing_ownership_check",
34
+ "title": "BOLA in GET /orders/{id}",
35
+ "route_id": "R-0001",
36
+ "location": {"file": "app/orders.php", "start_line": 3},
37
+ "evidence": [{"file": "app/orders.php", "start_line": 3, "code": "$id = $_GET["id"];", "why": "id from request"}],
38
+ "severity": "high",
39
+ "confidence": "high",
40
+ "cwe": ["CWE-639"],
41
+ "recommendation": {"summary": "Bind to the session user", "code_before": "SELECT * FROM orders WHERE id = " + id", "code_after": "... AND user_id = ?"},
42
+ "status": "unverified"
43
+ }
44
+ ],
45
+ "route_verdicts": [{"route_id": "R-0001", "verdict": "vulnerable"}]
46
+ }
47
+ ```"""
48
+
49
+
50
+ class TestRepairJsonQuotes(unittest.TestCase):
51
+ def test_run_5_slip_parses_after_repair(self) -> None:
52
+ with self.assertRaises((RuntimeError, ValueError)):
53
+ LLMClient.extract_json(SLIPPED_REPLY)
54
+ parsed = json.loads(repair_json_quotes(SLIPPED_REPLY))
55
+ rec = parsed["findings"][0]["recommendation"]
56
+ self.assertEqual(rec["code_before"], 'SELECT * FROM orders WHERE id = " + id')
57
+ self.assertEqual(parsed["findings"][0]["evidence"][0]["code"], '$id = $_GET["id"];')
58
+ self.assertEqual(parsed["route_verdicts"], [{"route_id": "R-0001", "verdict": "vulnerable"}])
59
+
60
+ def test_valid_json_is_unchanged(self) -> None:
61
+ doc = {"a": 'say "hi"', "b": ["x", {"c": "back\\slash"}], "d": 1}
62
+ text = json.dumps(doc, indent=2)
63
+ self.assertEqual(repair_json_quotes(text), text)
64
+ self.assertEqual(json.loads(repair_json_quotes("Answer: " + json.dumps(doc) + " done")), doc)
65
+
66
+
67
+ class TestRunnerParsesRepairedReply(unittest.TestCase):
68
+ def setUp(self) -> None:
69
+ self.tmp = Path(tempfile.mkdtemp(prefix="komagu_repair_"))
70
+ self.target, self.out = self.tmp / "app", self.tmp / "out"
71
+ (self.target / "app").mkdir(parents=True)
72
+ (self.target / "app" / "orders.php").write_text("<?php\nsession_start();\n$id = $_GET['id'];\n", encoding="utf-8")
73
+ (self.out / "routes").mkdir(parents=True)
74
+ route = {"route_id": "R-0001", "method": ["GET"], "path": "/orders/{id}",
75
+ "handler": {"file": "app/orders.php", "line": 3}, "needs_bola_audit": True}
76
+ (self.out / "routes" / "routes-001.json").write_text(json.dumps({"routes": [route]}), encoding="utf-8")
77
+
78
+ def tearDown(self) -> None:
79
+ shutil.rmtree(self.tmp, ignore_errors=True)
80
+
81
+ def test_slipped_bola_reply_keeps_its_findings_without_retry(self) -> None:
82
+ runner = HeadlessAIRunner(self.target, self.out, _ScriptedClient([SLIPPED_REPLY]))
83
+ runner.audit_bola_routes()
84
+ doc = json.loads((self.out / "findings" / "bola-routes-001.json").read_text(encoding="utf-8"))
85
+ self.assertEqual([f["finding_id"] for f in doc["findings"]], ["BOLA-R-0001"])
86
+ self.assertEqual(doc["findings"][0]["recommendation"], "Bind to the session user")
87
+ self.assertEqual((runner.retries, runner.parse_failures), ({}, {}))
88
+ self.assertEqual(runner.repaired, {"4a-bola-auditor": ["routes-001"]})
89
+ self.assertTrue((self.out / "ai-raw" / "4a-bola-auditor-routes-001.txt").is_file())
90
+ stats = json.loads(runner.write_run_stats().read_text(encoding="utf-8"))
91
+ self.assertEqual(stats["repaired"], {"4a-bola-auditor": ["routes-001"]})
92
+
93
+ def test_vulnerable_verdict_without_finding_becomes_a_finding(self) -> None:
94
+ reply = json.dumps({"batch_id": "routes-001", "findings": [],
95
+ "route_verdicts": [{"route_id": "R-0001", "verdict": "vulnerable"}]})
96
+ runner = HeadlessAIRunner(self.target, self.out, _ScriptedClient([reply]))
97
+ runner.audit_bola_routes()
98
+ doc = json.loads((self.out / "findings" / "bola-routes-001.json").read_text(encoding="utf-8"))
99
+ (f,) = doc["findings"]
100
+ self.assertEqual((f["category"], f["subtype"], f["confidence"], f["status"]),
101
+ ("bola", "missing_ownership_check", "low", "unverified"))
102
+ self.assertEqual(f["location"], {"file": "app/orders.php", "start_line": 3})
103
+ self.assertEqual(f["endpoint"], {"method": "GET", "path": "/orders/{id}"})
104
+ self.assertEqual(runner.findings_from_verdicts, {"4a-bola-auditor": [f["finding_id"]]})
105
+
106
+ def test_the_4a_template_no_longer_asks_for_code_fields(self) -> None:
107
+ client = _ScriptedClient(['{"findings": [], "route_verdicts": []}'])
108
+ HeadlessAIRunner(self.target, self.out, client).audit_bola_routes()
109
+ prompt = client.calls[0][-1]["content"]
110
+ self.assertNotIn("code_before", prompt)
111
+ self.assertNotIn("code_after", prompt)
112
+
113
+
114
+ class TestVerdictFindings(unittest.TestCase):
115
+ RECORDS = {
116
+ "M-1": {"candidate_id": "M-1", "file": "a.php", "line": 6, "subtype": "privileged_field_writable"},
117
+ "I-1": {"candidate_id": "I-1", "file": "q.js", "line": 9, "category_hint": "nosql_injection",
118
+ "subtype_hint": "operator_injection|dynamic_identifier"},
119
+ }
120
+
121
+ def test_only_flagged_uncovered_known_ids(self) -> None:
122
+ verdicts = [{"candidate_id": "M-1", "verdict": "needs_review"}, {"candidate_id": "I-1", "verdict": "safe"},
123
+ {"candidate_id": "M-9", "verdict": "vulnerable"}, "noise"]
124
+ (f,) = verdict_findings(verdicts, [], "candidate_id", self.RECORDS, "mass_assignment")
125
+ self.assertEqual((f["finding_id"], f["subtype"], f["cwe"]), ("MASS-M-1-verdict", "privileged_field_writable", ["CWE-915"]))
126
+ self.assertIn("needs_review", f["impact"])
127
+ covered = [{"candidate_id": "M-1"}]
128
+ self.assertEqual(verdict_findings(verdicts, covered, "candidate_id", self.RECORDS, "mass_assignment"), [])
129
+
130
+ def test_finding_linked_by_finding_ids_or_finding_id_covers_the_verdict(self) -> None:
131
+ findings = [{"finding_id": "MASS-M-1-1"}]
132
+ self.assertEqual(verdict_findings([{"candidate_id": "M-1", "verdict": "vulnerable"}], findings,
133
+ "candidate_id", self.RECORDS, "mass_assignment"), [])
134
+ listed = [{"candidate_id": "M-1", "verdict": "vulnerable", "finding_ids": ["F-9"]}]
135
+ self.assertEqual(verdict_findings(listed, [{"finding_id": "F-9"}], "candidate_id", self.RECORDS, "mass_assignment"), [])
136
+ # M-1 must not be covered by a finding for M-10
137
+ (f,) = verdict_findings([{"candidate_id": "M-1", "verdict": "vulnerable"}], [{"finding_id": "MASS-M-10-1"}],
138
+ "candidate_id", self.RECORDS, "mass_assignment")
139
+ self.assertEqual(f["candidate_id"], "M-1")
140
+
141
+ def test_category_and_subtype_hints_from_the_record(self) -> None:
142
+ (f,) = verdict_findings([{"candidate_id": "I-1", "verdict": "vulnerable"}], [], "candidate_id",
143
+ self.RECORDS, "sql_injection")
144
+ self.assertEqual((f["category"], f["subtype"], f["cwe"]), ("nosql_injection", "operator_injection", ["CWE-943"]))
145
+
146
+ def test_generated_findings_match_the_finding_schema(self) -> None:
147
+ sys.path.insert(0, str(REPO_ROOT / "assess" / "lsa" / "scripts"))
148
+ from merge_findings import load_validator
149
+ validate = load_validator("finding.schema.json")
150
+ records = {**self.RECORDS, "R-1": {"route_id": "R-1", "method": ["POST"], "path": "/users/{id}/role",
151
+ "handler": {"file": "admin.php", "line": 4}}}
152
+ for key, record_id, category in (("route_id", "R-1", "bola"), ("route_id", "R-1", "bfla"),
153
+ ("candidate_id", "M-1", "mass_assignment"), ("candidate_id", "I-1", "sql_injection")):
154
+ with self.subTest(category=category):
155
+ (f,) = verdict_findings([{key: record_id, "verdict": "vulnerable"}], [], key, records, category)
156
+ self.assertEqual(validate(f), [])
157
+
158
+
159
+ class TestReplayRun5RunA(unittest.TestCase):
160
+ """The four flagged mass assignment candidates of run A become located findings; no SAFE marker is hit."""
161
+
162
+ def setUp(self) -> None:
163
+ self.tmp = Path(tempfile.mkdtemp(prefix="komagu_replay_"))
164
+ self.out = self.tmp / "gptoss-a"
165
+ shutil.copytree(EVAL_RUN_5 / "gptoss-a", self.out)
166
+
167
+ def tearDown(self) -> None:
168
+ shutil.rmtree(self.tmp, ignore_errors=True)
169
+
170
+ def score(self) -> dict:
171
+ proc = subprocess.run([sys.executable, str(SCORER), "--verdicts", str(EVAL_RUN_5 / "ground-truth.json"), str(self.out)],
172
+ capture_output=True, text=True, check=True)
173
+ return json.loads(proc.stdout)
174
+
175
+ def test_replay_recovers_the_four_mass_assignment_items(self) -> None:
176
+ cands = json.loads((self.out / "mass-assignment.candidates.json").read_text(encoding="utf-8"))["candidates"]
177
+ records = {c["candidate_id"]: c for c in cands}
178
+ # the fixtures are not in the archive; anchoring only needs the Tier 1 records
179
+ anchors, report = load_anchors(self.out, self.tmp), AnchorReport()
180
+ generated = []
181
+ for path in sorted((self.out / "findings").glob("priv-*.json")):
182
+ doc = json.loads(path.read_text(encoding="utf-8"))
183
+ extra = verdict_findings(doc["candidate_verdicts"], doc["findings"], "candidate_id", records, "mass_assignment")
184
+ generated += anchor_findings(extra, anchors, self.tmp, report)
185
+ generated += verdict_findings(doc["privilege_route_verdicts"], doc["findings"], "route_id", {}, "bfla")
186
+ self.assertEqual(sorted(f["candidate_id"] for f in generated), ["M-0001", "M-0002", "M-0011", "M-0012"])
187
+ self.assertEqual(report.unanchored, [])
188
+
189
+ merged = json.loads((self.out / "findings.json").read_text(encoding="utf-8"))
190
+ merged["findings"] += generated
191
+ (self.out / "findings.json").write_text(json.dumps(merged), encoding="utf-8")
192
+ mass = self.score()["per_category"]["mass_assignment"]
193
+ # before: 10 / 15; register.php:4 stays missed (rejected by the verifier, fixture under review)
194
+ self.assertEqual((mass["found"], mass["missed"]), (14, ["php-plain/register.php:4"]))
195
+ for cat, row in self.score()["per_category"].items():
196
+ self.assertEqual(row["false_positive_on_safe_code"], [], cat)
197
+
198
+
199
+ if __name__ == "__main__":
200
+ unittest.main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoanghainh/komagu",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "Security-First Legacy Modernization Platform CLI (LSA Kit, IAM Perimeter, Cross-User Testing)",
5
5
  "main": "remediate/authz/nodejs/src/index.js",
6
6
  "types": "remediate/authz/nodejs/src/index.d.ts",
@@ -276,7 +276,128 @@ func RegisterOrderRoutes(mux *http.ServeMux, authorizer *authz.Authorizer) {
276
276
 
277
277
  ---
278
278
 
279
- ## 7. Test Verification & D5 Runner
279
+ ## 7. Function-Level Authorization (BFLA) and Mass Assignment
280
+
281
+ These helpers fix the two privilege findings of the audit kit: **BFLA** (CWE-285, see
282
+ [`bfla-patterns.md`](../../assess/lsa/knowledge/bfla-patterns.md)) and **mass assignment** (CWE-915, see
283
+ [`mass-assignment-patterns.md`](../../assess/lsa/knowledge/mass-assignment-patterns.md)).
284
+
285
+ The role guard behaves the same in every module:
286
+
287
+ | Rule | Behaviour |
288
+ |---|---|
289
+ | Role source | The authenticated principal only (claims, granted authorities, `req.user`, request context). Never route values, query, form, body or headers |
290
+ | Not authenticated | 401 |
291
+ | Match | The caller needs at least one of the listed roles (exact, case-sensitive) |
292
+ | Denied | 403 by default; a module setting answers 404 to hide the endpoint |
293
+ | Audit | Each denial emits `Komagu.Authorization.RoleDenied` with actor ids and the required roles |
294
+ | Bad configuration | Empty or blank role lists and unsupported deny statuses fail fast |
295
+
296
+ For mass assignment, bind a DTO that lists only the fields the caller may change and copy them onto the
297
+ entity explicitly. Never bind the entity itself or rely on `[Bind(Include = "...")]`. Where the input is a
298
+ map, `allowFields` keeps only the listed keys and never mutates its input.
299
+
300
+ ### .NET 8
301
+
302
+ ```csharp
303
+ // Program.cs
304
+ builder.Services.AddKomaguAuthorization();
305
+ builder.Services.ConfigureKomaguRoles(o =>
306
+ {
307
+ o.RoleClaimType = "role"; // default: ClaimTypes.Role
308
+ o.DenyWith = RoleDenyResponse.NotFound; // default: Forbidden (403)
309
+ });
310
+
311
+ [HttpDelete("admin/users/{id}")]
312
+ [RequireRole("admin", "support")] // any one role; several attributes must all pass
313
+ public IActionResult DeleteUser(int id) => NoContent();
314
+
315
+ public sealed record ProfileUpdateDto(string Name, string Email)
316
+ {
317
+ public void ApplyTo(Customer customer)
318
+ {
319
+ customer.Name = Name;
320
+ customer.Email = Email; // Role / IsAdmin / Balance cannot be set from the request
321
+ }
322
+ }
323
+ ```
324
+
325
+ `[RequireRole]` is an authorization filter, so it runs before model binding.
326
+
327
+ ### Spring Boot 3
328
+
329
+ ```kotlin
330
+ @RestController
331
+ @RequestMapping("/admin")
332
+ @RequireRole("admin") // class-level default
333
+ class AdminController(private val users: UserService) {
334
+ @DeleteMapping("/users/{id}")
335
+ fun delete(@PathVariable id: String) = users.delete(id) // ROLE_admin or admin passes
336
+
337
+ @GetMapping("/audit")
338
+ @RequireRole("admin", "auditor", hideAsNotFound = true) // method-level overrides class-level
339
+ fun audit() = users.auditTrail()
340
+ }
341
+
342
+ data class ProfileUpdate(val name: String, val email: String) {
343
+ fun applyTo(account: UserAccount) = account.copy(name = name, email = email)
344
+ }
345
+ val safe = allowFields(body, "name", "email") // io.komagu.security.data
346
+ ```
347
+
348
+ Roles are the granted authorities of the authentication in `SecurityContextHolder`; one leading `ROLE_` is
349
+ ignored on both sides. Map JWT scopes or role claims to authorities in your `JwtAuthenticationConverter`.
350
+ Set `komagu.security.role.hide-denied-as-not-found=true` to answer every denial with 404. Spring Boot's
351
+ default `ObjectMapper` silently drops unknown fields such as `role`; the explicit copy is what keeps them out.
352
+
353
+ ### Node.js (Express & NestJS)
354
+
355
+ ```js
356
+ const { requireRole, allowFields } = require('@hoanghainh/komagu-authz');
357
+
358
+ app.delete('/admin/users/:id', requireRole('admin'), deleteUser);
359
+ app.post('/ops/rotate-keys', requireRole(['ops', 'admin'], { denyStatus: 404 }), rotateKeys);
360
+
361
+ app.patch('/me', async (req, res) => {
362
+ const changes = allowFields(req.body, ['name', 'email']); // role / isAdmin are dropped
363
+ res.json(await users.update(req.user.userId, changes));
364
+ });
365
+ ```
366
+
367
+ ```ts
368
+ @Controller('admin')
369
+ @UseGuards(KomaguRoleGuard)
370
+ export class AdminController {
371
+ @Delete('users/:id')
372
+ @RequireRole('admin')
373
+ deleteUser(@Param('id') id: string) { /* ... */ }
374
+ }
375
+ ```
376
+
377
+ Roles come from `req.user.roles` and `req.user.role`. `allowFields` ignores `__proto__`, `constructor` and
378
+ `prototype`, and copies values by reference.
379
+
380
+ ### Go
381
+
382
+ ```go
383
+ // After verifying the token, attach the identity in your authentication middleware.
384
+ ctx := authz.WithIdentity(r.Context(), authz.Identity{UserID: claims.Sub, TenantID: claims.TenantID, Roles: claims.Roles})
385
+
386
+ auth := authz.NewAuthorizer(nil)
387
+ mux.Handle("/admin/users", authenticate(auth.RequireRole([]string{"admin"})(adminUsers)))
388
+ mux.Handle("/internal/reindex", authenticate(auth.RequireRole(
389
+ []string{"admin", "ops"}, authz.WithDenyStatus(http.StatusNotFound))(reindex)))
390
+
391
+ patch := authz.AllowFields(body, "name", "email") // or decode into a narrow struct:
392
+ dec := json.NewDecoder(r.Body)
393
+ dec.DisallowUnknownFields() // {"role":"admin"} -> 400
394
+ ```
395
+
396
+ `RequireRole` panics at route registration on an empty role list or a deny status other than 403 / 404.
397
+
398
+ ---
399
+
400
+ ## 8. Test Verification & D5 Runner
280
401
 
281
402
  All multi-language adapters verify identical BOLA defense invariants:
282
403
 
@@ -0,0 +1,20 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ using System.Security.Claims;
4
+
5
+ /// <summary>
6
+ /// Options for <see cref="RequireRoleAttribute"/> / <see cref="RoleAuthorizationFilter"/>.
7
+ /// </summary>
8
+ public sealed class KomaguRoleOptions
9
+ {
10
+ /// <summary>
11
+ /// Claim type read from the authenticated <see cref="ClaimsPrincipal"/> to obtain the caller's roles.
12
+ /// Defaults to <see cref="ClaimTypes.Role"/>; set to <c>"role"</c> or <c>"roles"</c> for raw JWT claims.
13
+ /// </summary>
14
+ public string RoleClaimType { get; set; } = ClaimTypes.Role;
15
+
16
+ /// <summary>
17
+ /// Response returned when an authenticated caller lacks every required role. Defaults to 403 Forbidden.
18
+ /// </summary>
19
+ public RoleDenyResponse DenyWith { get; set; } = RoleDenyResponse.Forbidden;
20
+ }
@@ -0,0 +1,34 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ using Microsoft.AspNetCore.Mvc;
4
+
5
+ /// <summary>
6
+ /// Declarative function-level authorization (BFLA defense): the authenticated caller must hold at least one of the
7
+ /// given roles. Roles are read only from verified <c>HttpContext.User</c> claims, never from the request.
8
+ /// Returns 401 when unauthenticated and 403 (or 404, see <see cref="KomaguRoleOptions.DenyWith"/>) when denied.
9
+ /// </summary>
10
+ [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = true, Inherited = true)]
11
+ public sealed class RequireRoleAttribute : TypeFilterAttribute
12
+ {
13
+ public RequireRoleAttribute(params string[] roles)
14
+ : base(typeof(RoleAuthorizationFilter))
15
+ {
16
+ if (roles == null || roles.Length == 0)
17
+ {
18
+ throw new ArgumentException("At least one required role must be specified.", nameof(roles));
19
+ }
20
+
21
+ if (roles.Any(string.IsNullOrWhiteSpace))
22
+ {
23
+ throw new ArgumentException("Required roles must not be null, empty or whitespace.", nameof(roles));
24
+ }
25
+
26
+ Roles = roles.ToArray();
27
+ Arguments = new object[] { Roles.ToArray() };
28
+ }
29
+
30
+ /// <summary>
31
+ /// Roles accepted by this guard; holding any one of them (exact, ordinal match) grants access.
32
+ /// </summary>
33
+ public IReadOnlyList<string> Roles { get; }
34
+ }
@@ -0,0 +1,114 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ using System.Diagnostics;
4
+ using Komagu.Security.Authorization;
5
+ using Microsoft.AspNetCore.Http;
6
+ using Microsoft.AspNetCore.Mvc;
7
+ using Microsoft.AspNetCore.Mvc.Filters;
8
+ using Microsoft.Extensions.Options;
9
+
10
+ /// <summary>
11
+ /// Authorization filter enforcing that the authenticated caller holds at least one required role.
12
+ /// Runs before model binding, so request-supplied values (route, query, form, body, headers) can never grant a role.
13
+ /// </summary>
14
+ public sealed class RoleAuthorizationFilter : IAsyncAuthorizationFilter
15
+ {
16
+ /// <summary>
17
+ /// Audit event name emitted on a role denial.
18
+ /// </summary>
19
+ public const string RoleDeniedEventName = "Komagu.Authorization.RoleDenied";
20
+
21
+ private readonly string[] _requiredRoles;
22
+ private readonly ISecurityAuditSink _auditSink;
23
+ private readonly IKomaguAuthorizationService _authorizationService;
24
+ private readonly KomaguRoleOptions _options;
25
+
26
+ public RoleAuthorizationFilter(
27
+ string[] requiredRoles,
28
+ ISecurityAuditSink auditSink,
29
+ IKomaguAuthorizationService authorizationService,
30
+ IOptions<KomaguRoleOptions> options)
31
+ {
32
+ ArgumentNullException.ThrowIfNull(requiredRoles);
33
+ if (requiredRoles.Length == 0 || requiredRoles.Any(string.IsNullOrWhiteSpace))
34
+ {
35
+ throw new ArgumentException("At least one non-empty required role must be specified.", nameof(requiredRoles));
36
+ }
37
+
38
+ _requiredRoles = requiredRoles.ToArray();
39
+ _auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink));
40
+ _authorizationService = authorizationService ?? throw new ArgumentNullException(nameof(authorizationService));
41
+ _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
42
+ }
43
+
44
+ public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
45
+ {
46
+ ArgumentNullException.ThrowIfNull(context);
47
+
48
+ var user = context.HttpContext.User;
49
+
50
+ // Invariant: Unauthenticated callers get 401 before any role evaluation
51
+ if (user?.Identity?.IsAuthenticated != true)
52
+ {
53
+ context.Result = new UnauthorizedResult();
54
+ return;
55
+ }
56
+
57
+ // Roles come only from the verified principal; exact, ordinal match against any required role
58
+ var roleClaimType = string.IsNullOrWhiteSpace(_options.RoleClaimType)
59
+ ? System.Security.Claims.ClaimTypes.Role
60
+ : _options.RoleClaimType;
61
+
62
+ var hasRole = user.FindAll(roleClaimType)
63
+ .Any(c => _requiredRoles.Contains(c.Value, StringComparer.Ordinal));
64
+
65
+ if (hasRole)
66
+ {
67
+ return;
68
+ }
69
+
70
+ context.Result = _options.DenyWith == RoleDenyResponse.NotFound
71
+ ? new NotFoundResult()
72
+ : new StatusCodeResult(StatusCodes.Status403Forbidden);
73
+
74
+ await EmitDenialAsync(context).ConfigureAwait(false);
75
+ }
76
+
77
+ private async ValueTask EmitDenialAsync(AuthorizationFilterContext context)
78
+ {
79
+ var httpContext = context.HttpContext;
80
+ try
81
+ {
82
+ var auditEvent = new SecurityAuditEvent
83
+ {
84
+ Timestamp = DateTimeOffset.UtcNow,
85
+ EventName = RoleDeniedEventName,
86
+ TraceId = Activity.Current?.Id ?? httpContext.TraceIdentifier,
87
+ ActorUserId = TryRead(() => _authorizationService.GetCurrentUserId(httpContext.User)),
88
+ ActorTenantId = TryRead(() => _authorizationService.GetCurrentTenantId(httpContext.User)),
89
+ ResourceType = "Endpoint",
90
+ ResourceId = context.ActionDescriptor?.DisplayName ?? httpContext.Request.Path.Value,
91
+ ClientIp = httpContext.Connection.RemoteIpAddress?.ToString(),
92
+ Reason = $"Missing required role: caller holds none of [{string.Join(", ", _requiredRoles)}]"
93
+ };
94
+
95
+ await _auditSink.EmitAsync(auditEvent, httpContext.RequestAborted).ConfigureAwait(false);
96
+ }
97
+ catch
98
+ {
99
+ // Auditing failures must not mask or alter the denial outcome
100
+ }
101
+ }
102
+
103
+ private static string? TryRead(Func<string> read)
104
+ {
105
+ try
106
+ {
107
+ return read();
108
+ }
109
+ catch (UnauthorizedAccessException)
110
+ {
111
+ return null;
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,17 @@
1
+ namespace Komagu.Security.AspNetCore;
2
+
3
+ /// <summary>
4
+ /// HTTP response returned by <see cref="RoleAuthorizationFilter"/> when an authenticated caller lacks every required role.
5
+ /// </summary>
6
+ public enum RoleDenyResponse
7
+ {
8
+ /// <summary>
9
+ /// HTTP 403 Forbidden (default).
10
+ /// </summary>
11
+ Forbidden,
12
+
13
+ /// <summary>
14
+ /// HTTP 404 Not Found, hiding the existence of the privileged endpoint.
15
+ /// </summary>
16
+ NotFound
17
+ }
@@ -16,6 +16,7 @@ public static class ServiceCollectionExtensions
16
16
  {
17
17
  services.TryAddSingleton<ISecurityAuditSink, LoggingSecurityAuditSink>();
18
18
  services.TryAddScoped<IKomaguAuthorizationService, DefaultKomaguAuthorizationService>();
19
+ services.AddOptions<KomaguRoleOptions>();
19
20
  return services;
20
21
  }
21
22
 
@@ -27,6 +28,21 @@ public static class ServiceCollectionExtensions
27
28
  {
28
29
  services.AddSingleton<ISecurityAuditSink, TAuditSink>();
29
30
  services.TryAddScoped<IKomaguAuthorizationService, DefaultKomaguAuthorizationService>();
31
+ services.AddOptions<KomaguRoleOptions>();
32
+ return services;
33
+ }
34
+
35
+ /// <summary>
36
+ /// Configures the <see cref="RequireRoleAttribute"/> guard (role claim type, 403 vs 404 on denial).
37
+ /// Call after <c>AddKomaguAuthorization</c>.
38
+ /// </summary>
39
+ public static IServiceCollection ConfigureKomaguRoles(
40
+ this IServiceCollection services,
41
+ Action<KomaguRoleOptions> configure)
42
+ {
43
+ ArgumentNullException.ThrowIfNull(services);
44
+ ArgumentNullException.ThrowIfNull(configure);
45
+ services.AddOptions<KomaguRoleOptions>().Configure(configure);
30
46
  return services;
31
47
  }
32
48
  }
@@ -0,0 +1,76 @@
1
+ namespace Komagu.Security.Authorization.Tests;
2
+
3
+ using System.Text.Json;
4
+ using Xunit;
5
+
6
+ /// <summary>
7
+ /// Mass-assignment remediation example: replace whole-entity binding (or <c>[Bind(Include = "...")]</c>) with a
8
+ /// request DTO that declares only the client-editable fields, then copy them onto the tracked entity explicitly.
9
+ /// </summary>
10
+ public class MassAssignmentDtoTests
11
+ {
12
+ /// <summary>Persisted entity: Role, IsAdmin and Balance must never be client-writable.</summary>
13
+ public sealed class Customer
14
+ {
15
+ public int Id { get; set; }
16
+ public string Name { get; set; } = string.Empty;
17
+ public string Email { get; set; } = string.Empty;
18
+ public string Role { get; set; } = "customer";
19
+ public bool IsAdmin { get; set; }
20
+ public decimal Balance { get; set; }
21
+ }
22
+
23
+ /// <summary>Allow-list DTO: only the fields a customer may edit on their own profile.</summary>
24
+ public sealed record ProfileUpdateDto(string Name, string Email)
25
+ {
26
+ /// <summary>Explicit copy: privileged fields are not reachable from the request.</summary>
27
+ public void ApplyTo(Customer customer)
28
+ {
29
+ ArgumentNullException.ThrowIfNull(customer);
30
+ customer.Name = Name;
31
+ customer.Email = Email;
32
+ }
33
+ }
34
+
35
+ private static readonly JsonSerializerOptions WebJson = new(JsonSerializerDefaults.Web);
36
+
37
+ [Fact]
38
+ public void ProfileUpdate_WithPrivilegedFieldsInJson_ChangesOnlyAllowListedFields()
39
+ {
40
+ var customer = new Customer { Id = 7, Name = "Old", Email = "old@example.com", Role = "customer", IsAdmin = false, Balance = 10m };
41
+ const string attackerJson = """
42
+ {"id": 1, "name": "New Name", "email": "new@example.com", "role": "admin", "isAdmin": true, "balance": 999999}
43
+ """;
44
+
45
+ var dto = JsonSerializer.Deserialize<ProfileUpdateDto>(attackerJson, WebJson);
46
+ Assert.NotNull(dto);
47
+ dto!.ApplyTo(customer);
48
+
49
+ Assert.Equal("New Name", customer.Name);
50
+ Assert.Equal("new@example.com", customer.Email);
51
+ Assert.Equal(7, customer.Id);
52
+ Assert.Equal("customer", customer.Role);
53
+ Assert.False(customer.IsAdmin);
54
+ Assert.Equal(10m, customer.Balance);
55
+ }
56
+
57
+ [Fact]
58
+ public void ProfileUpdateDto_ExposesOnlyNameAndEmail()
59
+ {
60
+ var writable = typeof(ProfileUpdateDto).GetProperties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal);
61
+ Assert.Equal(new[] { "Email", "Name" }, writable);
62
+ }
63
+
64
+ [Fact]
65
+ public void WholeEntityBinding_IsTheVulnerablePattern()
66
+ {
67
+ // Contrast: deserializing straight into the entity lets the client set Role/IsAdmin/Balance.
68
+ const string attackerJson = """{"name": "x", "email": "x@example.com", "role": "admin", "isAdmin": true, "balance": 999999}""";
69
+
70
+ var bound = JsonSerializer.Deserialize<Customer>(attackerJson, WebJson)!;
71
+
72
+ Assert.Equal("admin", bound.Role);
73
+ Assert.True(bound.IsAdmin);
74
+ Assert.Equal(999999m, bound.Balance);
75
+ }
76
+ }