@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
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
4
4
 
5
+ ## [2.2.0](https://github.com/hoanghainh1188/komagu/compare/v2.1.2...v2.2.0) (2026-11-01)
6
+
7
+
8
+ ### Features
9
+
10
+ * **remediate:** add role guards and DTO allow-list helpers for BFLA and mass assignment ([#337](https://github.com/hoanghainh1188/komagu/issues/337)) ([e8c0463](https://github.com/hoanghainh1188/komagu/commit/e8c046322fa10c3c29a5d896cdbb41de65282c34))
11
+
12
+ ## [2.1.2](https://github.com/hoanghainh1188/komagu/compare/v2.1.1...v2.1.2) (2026-11-01)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **ai:** stop code fields breaking JSON and write findings for flagged verdicts ([#334](https://github.com/hoanghainh1188/komagu/issues/334)) ([b1aab60](https://github.com/hoanghainh1188/komagu/commit/b1aab60ced26d3776bf5d12af2bffb1300cd7668))
18
+
5
19
  ## [2.1.1](https://github.com/hoanghainh1188/komagu/compare/v2.1.0...v2.1.1) (2026-11-01)
6
20
 
7
21
 
@@ -89,3 +89,11 @@ route named `/orders/close-month` can still be an admin function.
89
89
 
90
90
  Adjust with `severity-and-cwe.md` (multi-tenant impact +1, compensating
91
91
  control outside code −1).
92
+
93
+ ## 7. Recommended fix
94
+
95
+ Check the role on the server, from the authenticated principal only, before the privileged action.
96
+ Komagu ships a drop-in guard per stack (`[RequireRole]` for .NET, `@RequireRole` for Spring Boot,
97
+ `requireRole` / `@RequireRole` for Express and NestJS, `RequireRole` middleware for Go): see
98
+ `remediate/authz/README.md`, section 7. Use it in the finding's `recommendation` when the target stack
99
+ matches; never suggest reading the role from the request.
@@ -75,3 +75,10 @@ Bindings to DTO or form classes that are not entities are not reported.
75
75
  | Only harmless fields reachable | low (or no finding) |
76
76
 
77
77
  Adjust with `severity-and-cwe.md`.
78
+
79
+ ## 6. Recommended fix
80
+
81
+ Bind a DTO with only the editable fields and copy them onto the entity explicitly, or filter map-shaped
82
+ input with an allow-list. Komagu has a tested example per stack and an `allowFields` helper for Spring
83
+ Boot, Express and Go: see `remediate/authz/README.md`, section 7. Use it in the finding's `recommendation`
84
+ when the target stack matches.
@@ -308,3 +308,28 @@ Fixture markers were left unchanged so Run 4 and Run 5 share the same ground tru
308
308
  3. **Fixtures:** review the `register.php` MASS marker; add BOLA markers for the routes judged real above.
309
309
  4. **qwen3.5:9b** still unmeasured: 32K context does not fit in 24 GB next to the OS; needs 16K context or
310
310
  a larger machine.
311
+
312
+ Harness changes for gaps 1 and 2 (#323), not yet measured in a new run:
313
+ - The 4a / 4b templates ask for `recommendation` as one string (no `code_before` / `code_after`). A reply
314
+ that is still invalid is parsed again after escaping quotes that cannot end their string; such batches
315
+ are listed under `repaired` in `ai-run-stats.json` and their raw reply is kept in `ai-raw/`.
316
+ - Every `vulnerable` / `needs_review` verdict without a finding gets a `confidence: low` finding anchored
317
+ to its Tier 1 route or candidate (`operate/ai/verdicts.py`), listed under `findings_from_verdicts`. An
318
+ offline replay of run A turns the four flagged candidates into located findings (mass assignment 14 / 15
319
+ before verification) with no SAFE marker hit (`operate/tests/test_ai_verdicts.py`).
320
+
321
+ ### Ground truth change after Run 5 (#323)
322
+
323
+ The fixtures were changed after Run 5, so runs from Run 6 on are scored against a new ground truth
324
+ (117 → 121 items). The archived `eval-run-4/` and `eval-run-5/` ground-truth files are kept as they were,
325
+ and their tests still score against them.
326
+
327
+ | Change | Reason |
328
+ |---|---|
329
+ | `php-plain/register.php`: `$role` defaults to `customer`, `extract($_POST)` can overwrite it, and it is inserted (MASS marker kept on line 4) | As written, the INSERT used only `email` and `name`, so the verifier's rejection was sound |
330
+ | New `BOLA` markers: `AccountController.java:21` (`/account/rename`), `CustomerController.cs:29` (`Refresh`), `python-flask/profile.py:18` (`/users/<uid>/name`) and `:26` (`PATCH /users/<uid>`) | The record id comes from the request and is not bound to the caller; checked by hand |
331
+ | No marker for Laravel `account/rename` / `account/profile` or .NET `Customer/EditName` | They act on `$request->user()`, or touch no record, so the Run 5 triage was wrong about them |
332
+
333
+ Run 5 rescored against the new ground truth (for comparison only; findings unchanged): BOLA 28 / 29
334
+ (run A) and 16 / 29 (run B), precision 0.859 and 0.826, still no false positive on SAFE markers. Every
335
+ new marker was already reported by both runs; Tier 1 lists all of them as BOLA route candidates.
@@ -26,7 +26,7 @@ namespace Web.Controllers
26
26
  [HttpPost]
27
27
  public ActionResult Refresh(int id)
28
28
  {
29
- var customer = db.Customers.Find(id);
29
+ var customer = db.Customers.Find(id); // LSA-EXPECT:BOLA
30
30
  TryUpdateModel(customer); // LSA-EXPECT:MASS
31
31
  db.SaveChanges();
32
32
  return RedirectToAction("Index");
@@ -18,7 +18,7 @@ public class AccountController {
18
18
 
19
19
  @RequestMapping(value = "/rename", method = RequestMethod.POST)
20
20
  public String rename(@ModelAttribute RenameForm form) { // LSA-EXPECT:SAFE-MASS
21
- accountDao.rename(form.getId(), form.getName());
21
+ accountDao.rename(form.getId(), form.getName()); // LSA-EXPECT:BOLA
22
22
  return "redirect:/account";
23
23
  }
24
24
 
@@ -1,6 +1,6 @@
1
1
  <?php
2
2
  require_once 'inc/db.php';
3
-
3
+ $role = 'customer';
4
4
  extract($_POST); // LSA-EXPECT:MASS
5
- $stmt = $db->prepare("INSERT INTO users (email, name) VALUES (?, ?)");
6
- $stmt->execute(array($email, $name));
5
+ $stmt = $db->prepare("INSERT INTO users (email, name, role) VALUES (?, ?, ?)");
6
+ $stmt->execute(array($email, $name, $role));
@@ -15,7 +15,7 @@ def create_user():
15
15
 
16
16
  @app.route("/users/<int:uid>/name", methods=["POST"])
17
17
  def rename(uid):
18
- user = User.query.get(uid)
18
+ user = User.query.get(uid) # LSA-EXPECT:BOLA
19
19
  user.name = request.json["name"] # LSA-EXPECT:SAFE-MASS
20
20
  db.session.commit()
21
21
  return jsonify(ok=True)
@@ -23,7 +23,7 @@ def rename(uid):
23
23
 
24
24
  @app.route("/users/<int:uid>", methods=["PATCH"])
25
25
  def patch(uid):
26
- user = User.query.get(uid)
26
+ user = User.query.get(uid) # LSA-EXPECT:BOLA
27
27
  for key, value in request.json.items(): # LSA-EXPECT:MASS
28
28
  setattr(user, key, value)
29
29
  db.session.commit()
@@ -10,7 +10,7 @@ import re
10
10
  import time
11
11
  import urllib.error
12
12
  import urllib.request
13
- from typing import Any, Dict, List, Optional
13
+ from typing import Any, Dict, List, Optional, Tuple
14
14
 
15
15
 
16
16
  class LLMClient:
@@ -107,3 +107,71 @@ class LLMClient:
107
107
  if end_bracket != -1:
108
108
  return json.loads(text[start_bracket : end_bracket + 1])
109
109
  raise RuntimeError(f"Could not parse valid JSON from LLM response:\n{content}") from exc
110
+
111
+
112
+ def _next_char(text: str, i: int) -> Tuple[str, int]:
113
+ """First non-space character at or after i, and its index ("" at the end)."""
114
+ while i < len(text) and text[i].isspace():
115
+ i += 1
116
+ return (text[i], i) if i < len(text) else ("", i)
117
+
118
+
119
+ def _closes_string(text: str, i: int, container: str, is_key: bool) -> bool:
120
+ """Whether the quote at text[i] can close the current string, judged by what follows it."""
121
+ ch, j = _next_char(text, i + 1)
122
+ if is_key:
123
+ return ch == ":"
124
+ if container == "{":
125
+ return ch == "}" or (ch == "," and _next_char(text, j + 1)[0] == '"')
126
+ if container == "[":
127
+ return ch in ",]"
128
+ return ch == ""
129
+
130
+
131
+ def repair_json_quotes(content: str) -> str:
132
+ """Escape `"` characters that cannot close the string they appear in (#323).
133
+
134
+ Small models copy code into string values without escaping it, for example
135
+ `"code_before": "SELECT * FROM orders WHERE id = " + id",`. A quote inside a string is kept as
136
+ the closing quote only when what follows fits the enclosing object or array: `:` after a key;
137
+ `}` or `, "` in an object; `,` or `]` in an array.
138
+ Only the JSON part of the reply (fenced block, or first `{` to last `}`) is repaired and returned.
139
+ """
140
+ text = content.strip()
141
+ match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
142
+ if match:
143
+ text = match.group(1).strip()
144
+ start, end = text.find("{"), text.rfind("}")
145
+ if start != -1 and end > start:
146
+ text = text[start : end + 1]
147
+
148
+ out: List[str] = []
149
+ stack: List[str] = []
150
+ in_string = escaped = is_key = expecting_key = False
151
+ for i, ch in enumerate(text):
152
+ if in_string:
153
+ if escaped:
154
+ escaped = False
155
+ elif ch == "\\":
156
+ escaped = True
157
+ elif ch == '"':
158
+ if _closes_string(text, i, stack[-1] if stack else "", is_key):
159
+ in_string = False
160
+ else:
161
+ out.append('\\"')
162
+ continue
163
+ elif ch == '"':
164
+ in_string, is_key = True, bool(stack) and stack[-1] == "{" and expecting_key
165
+ elif ch in "{[":
166
+ stack.append(ch)
167
+ expecting_key = ch == "{"
168
+ elif ch in "}]":
169
+ if stack:
170
+ stack.pop()
171
+ expecting_key = False
172
+ elif ch == ",":
173
+ expecting_key = bool(stack) and stack[-1] == "{"
174
+ elif ch == ":":
175
+ expecting_key = False
176
+ out.append(ch)
177
+ return "".join(out)
@@ -22,17 +22,15 @@ import time
22
22
  from typing import Any, Callable, Dict, List, Optional
23
23
 
24
24
  from operate.ai.anchoring import Anchor, AnchorReport, anchor_findings, load_anchors
25
- from operate.ai.client import LLMClient
25
+ from operate.ai.client import LLMClient, repair_json_quotes
26
26
  from operate.ai.context import _included_files, _model_source, _read_snippet, _verification_context # noqa: F401
27
+ from operate.ai.verdicts import DEFAULT_CWE, SUBTYPES, verdict_findings
27
28
 
28
29
  REPO_ROOT = Path(__file__).resolve().parents[2]
29
30
  LSA_PROMPTS = REPO_ROOT / "assess" / "lsa" / "prompts"
30
- DEFAULT_CWE = {"bola": ["CWE-284"], "bfla": ["CWE-285"], "mass_assignment": ["CWE-915"],
31
- "sql_injection": ["CWE-89"], "nosql_injection": ["CWE-943"]}
32
31
  LSA_SCRIPTS = REPO_ROOT / "assess" / "lsa" / "scripts"
33
32
  # Valid subtypes per category (finding.schema.json); the first one is used when a model writes an invalid value
34
- CATEGORY_SUBTYPES = {"bfla": ["missing_role_check", "role_from_request", "client_side_role_check"],
35
- "mass_assignment": ["whole_object_binding", "privileged_field_writable"]}
33
+ CATEGORY_SUBTYPES = {c: SUBTYPES[c] for c in ("bfla", "mass_assignment")}
36
34
  FINDING_STATUSES = {"unverified", "confirmed", "likely", "rejected", "needs_review"}
37
35
 
38
36
 
@@ -62,6 +60,10 @@ class HeadlessAIRunner:
62
60
  self.call_errors: Dict[str, Dict[str, str]] = {}
63
61
  # Per-phase batches whose first reply was empty or unparseable and were asked again
64
62
  self.retries: Dict[str, List[str]] = {}
63
+ # Per-phase batches that parsed only after escaping stray quotes in string values (#323)
64
+ self.repaired: Dict[str, List[str]] = {}
65
+ # Per-phase finding ids generated from flagged verdicts the model wrote no finding for (#323)
66
+ self.findings_from_verdicts: Dict[str, List[str]] = {}
65
67
  # Findings whose location was taken from the Tier 1 inventory (#319)
66
68
  self.anchor_report = AnchorReport()
67
69
  self._anchors: Optional[Dict[str, Anchor]] = None
@@ -103,19 +105,47 @@ class HeadlessAIRunner:
103
105
  if not (reply or "").strip():
104
106
  return False
105
107
  try:
106
- self.client.extract_json(reply)
108
+ self._extract(reply)
107
109
  return True
108
110
  except Exception:
109
111
  return False
110
112
 
113
+ def _extract(self, reply: Optional[str], phase: Optional[str] = None, batch_id: Optional[str] = None) -> Any:
114
+ """Parse a reply; if that fails, escape stray quotes in string values and parse again (#323).
115
+
116
+ With phase and batch_id, a repaired batch is recorded and its raw reply kept in OUT/ai-raw/.
117
+ """
118
+ try:
119
+ return self.client.extract_json(reply)
120
+ except Exception:
121
+ parsed = self.client.extract_json(repair_json_quotes(reply or ""))
122
+ if phase and batch_id:
123
+ self.repaired.setdefault(phase, []).append(batch_id)
124
+ self._save_raw(phase, batch_id, reply or "")
125
+ self._log("warn", f"{phase}: reply for {batch_id} parsed after escaping stray quotes")
126
+ return parsed
127
+
128
+ def _save_raw(self, phase: str, batch_id: str, reply: str) -> None:
129
+ raw_dir = self.out_dir / "ai-raw"
130
+ raw_dir.mkdir(parents=True, exist_ok=True)
131
+ (raw_dir / f"{phase}-{batch_id}.txt").write_text(reply, encoding="utf-8")
132
+
133
+ def _with_verdict_findings(self, phase: str, findings: List[Dict[str, Any]], verdicts: Any, key: str,
134
+ records: Dict[str, Dict[str, Any]], category: str) -> List[Dict[str, Any]]:
135
+ """Add an anchored low-confidence finding for each flagged verdict without one (#323)."""
136
+ extra = verdict_findings(verdicts, findings, key, records, category)
137
+ if not extra:
138
+ return findings
139
+ self.findings_from_verdicts.setdefault(phase, []).extend(f["finding_id"] for f in extra)
140
+ self._log("warn", f"{phase}: {len(extra)} flagged verdict(s) had no finding; generated low-confidence findings")
141
+ return findings + self._anchor(extra)
142
+
111
143
  def _parse_failed(self, phase: str, batch_id: str, reply: Optional[str] = None) -> None:
112
144
  if batch_id in self.call_errors.get(phase, {}):
113
145
  return
114
146
  self.parse_failures.setdefault(phase, []).append(batch_id)
115
147
  if reply is not None:
116
- raw_dir = self.out_dir / "ai-raw"
117
- raw_dir.mkdir(parents=True, exist_ok=True)
118
- (raw_dir / f"{phase}-{batch_id}.txt").write_text(reply, encoding="utf-8")
148
+ self._save_raw(phase, batch_id, reply)
119
149
  self._log("warn", f"{phase}: could not parse the model reply for {batch_id}; batch left without verdicts")
120
150
 
121
151
  def _usage(self) -> Dict[str, Any]:
@@ -140,6 +170,8 @@ class HeadlessAIRunner:
140
170
  "parse_failures": self.parse_failures,
141
171
  "call_errors": self.call_errors,
142
172
  "retries": self.retries,
173
+ "repaired": self.repaired,
174
+ "findings_from_verdicts": self.findings_from_verdicts,
143
175
  "anchoring": {"moved": self.anchor_report.moved, "unanchored": self.anchor_report.unanchored},
144
176
  }
145
177
  path = self.out_dir / "ai-run-stats.json"
@@ -408,7 +440,7 @@ Respond STRICTLY with a valid JSON object matching:
408
440
  "severity": "high",
409
441
  "confidence": "high",
410
442
  "cwe": ["CWE-284", "CWE-639"],
411
- "recommendation": {{"summary": "Bind query to current session user", "code_before": "...", "code_after": "..."}},
443
+ "recommendation": "<one string: the fix in words, no code>",
412
444
  "status": "unverified"
413
445
  }}
414
446
  ],
@@ -416,6 +448,7 @@ Respond STRICTLY with a valid JSON object matching:
416
448
  {{"route_id": "<route_id>", "verdict": "vulnerable|safe"}}
417
449
  ]
418
450
  }}
451
+ Write one finding for every "vulnerable" verdict.
419
452
  """
420
453
  messages = [
421
454
  {"role": "system", "content": auditor_prompt},
@@ -424,10 +457,13 @@ Respond STRICTLY with a valid JSON object matching:
424
457
 
425
458
  reply = self._chat("4a-bola-auditor", r_file.stem, messages)
426
459
  try:
427
- parsed = self.client.extract_json(reply)
460
+ parsed = self._extract(reply, "4a-bola-auditor", r_file.stem)
428
461
  raw_findings = parsed.get("findings", [])
429
462
  findings = self._anchor([self._sanitize_finding(f) for f in raw_findings])
430
463
  route_verdicts = parsed.get("route_verdicts", [])
464
+ findings = self._with_verdict_findings(
465
+ "4a-bola-auditor", findings, route_verdicts, "route_id",
466
+ {r.get("route_id"): r for r in routes_to_audit}, "bola")
431
467
  except Exception:
432
468
  self._parse_failed("4a-bola-auditor", r_file.stem, reply)
433
469
  findings = []
@@ -495,7 +531,7 @@ Respond STRICTLY with a valid JSON object matching:
495
531
  "severity": "critical",
496
532
  "confidence": "high",
497
533
  "cwe": ["CWE-89"],
498
- "recommendation": {{"summary": "Use parameterized queries", "code_before": "...", "code_after": "..."}},
534
+ "recommendation": "<one string: the fix in words, no code>",
499
535
  "status": "unverified"
500
536
  }}
501
537
  ],
@@ -503,6 +539,7 @@ Respond STRICTLY with a valid JSON object matching:
503
539
  {{"candidate_id": "<candidate_id>", "verdict": "vulnerable|safe"}}
504
540
  ]
505
541
  }}
542
+ Write one finding for every "vulnerable" verdict.
506
543
  """
507
544
  messages = [
508
545
  {"role": "system", "content": auditor_prompt},
@@ -511,10 +548,13 @@ Respond STRICTLY with a valid JSON object matching:
511
548
 
512
549
  reply = self._chat("4b-injection-auditor", b_file.stem, messages)
513
550
  try:
514
- parsed = self.client.extract_json(reply)
551
+ parsed = self._extract(reply, "4b-injection-auditor", b_file.stem)
515
552
  raw_findings = parsed.get("findings", [])
516
553
  findings = self._anchor([self._sanitize_finding(f) for f in raw_findings])
517
554
  candidate_verdicts = parsed.get("candidate_verdicts", [])
555
+ findings = self._with_verdict_findings(
556
+ "4b-injection-auditor", findings, candidate_verdicts, "candidate_id",
557
+ {c.get("candidate_id"): c for c in candidates}, "sql_injection")
518
558
  except Exception:
519
559
  self._parse_failed("4b-injection-auditor", b_file.stem, reply)
520
560
  findings = []
@@ -621,10 +661,16 @@ For mass assignment, judge the fields from "model_definition". If it says the de
621
661
  ]
622
662
  reply = self._chat("4c-privilege-auditor", b_file.stem, messages)
623
663
  try:
624
- parsed = self.client.extract_json(reply)
664
+ parsed = self._extract(reply, "4c-privilege-auditor", b_file.stem)
625
665
  findings = self._anchor([self._sanitize_finding(f) for f in parsed.get("findings", [])])
626
666
  route_verdicts = parsed.get("privilege_route_verdicts", [])
627
667
  candidate_verdicts = parsed.get("candidate_verdicts", [])
668
+ findings = self._with_verdict_findings(
669
+ "4c-privilege-auditor", findings, route_verdicts, "route_id",
670
+ {r.get("route_id"): r for r in routes}, "bfla")
671
+ findings = self._with_verdict_findings(
672
+ "4c-privilege-auditor", findings, candidate_verdicts, "candidate_id",
673
+ {c.get("candidate_id"): c for c in mass}, "mass_assignment")
628
674
  except Exception:
629
675
  self._parse_failed("4c-privilege-auditor", b_file.stem, reply)
630
676
  findings, route_verdicts, candidate_verdicts = [], [], []
@@ -712,7 +758,7 @@ Respond STRICTLY with a valid JSON object matching verification.schema.json:
712
758
 
713
759
  reply = self._chat("5-finding-verifier", f"verified-{(idx // batch_size) + 1}", messages)
714
760
  try:
715
- parsed = self.client.extract_json(reply)
761
+ parsed = self._extract(reply, "5-finding-verifier", f"verified-{(idx // batch_size) + 1}")
716
762
  verifs = parsed.get("verifications", [])
717
763
  except Exception:
718
764
  # No verdicts: merge_findings.py lists these findings under
@@ -0,0 +1,113 @@
1
+ """Write a minimal finding for every flagged auditor verdict the model left without one (#323).
2
+
3
+ Small local models sometimes judge a route or candidate correctly ("vulnerable" / "needs_review")
4
+ but write no finding for it (Evaluation Run 5, run A: four mass assignment candidates). Those count
5
+ as missed although the judgment was right, so a finding is generated from the verdict and the
6
+ Tier 1 record it refers to:
7
+ - category and subtype come from the record (`category_hint` / `subtype_hint` / `subtype`), or the
8
+ phase's default;
9
+ - location is the record's handler or candidate site (the runner then anchors it like any finding);
10
+ - `confidence: low`, `status: unverified` (the verifier judges it like any finding), and `impact`
11
+ names the verdict it was generated from.
12
+
13
+ Standard-library only (Python 3.8+ compatible).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, List
19
+
20
+ FLAGGED = ("vulnerable", "needs_review")
21
+ # Valid subtypes per category (finding.schema.json); the first one is the default
22
+ SUBTYPES = {
23
+ "bola": ["missing_ownership_check", "ownership_check_bypassable", "id_from_request_used_as_identity",
24
+ "no_authentication", "parent_checked_child_not", "check_result_ignored"],
25
+ "sql_injection": ["string_concatenation", "string_interpolation", "format_string", "template_placeholder",
26
+ "dynamic_identifier", "second_order", "stored_procedure_dynamic_sql"],
27
+ "nosql_injection": ["operator_injection", "js_injection", "string_concatenation", "string_interpolation"],
28
+ "bfla": ["missing_role_check", "role_from_request", "client_side_role_check"],
29
+ "mass_assignment": ["whole_object_binding", "privileged_field_writable"],
30
+ }
31
+ DEFAULT_CWE = {"bola": ["CWE-284"], "bfla": ["CWE-285"], "mass_assignment": ["CWE-915"],
32
+ "sql_injection": ["CWE-89"], "nosql_injection": ["CWE-943"]}
33
+ PREFIX = {"bola": "BOLA", "sql_injection": "INJ", "nosql_injection": "INJ", "bfla": "BFLA", "mass_assignment": "MASS"}
34
+ LABEL = {"bola": "Missing ownership check", "sql_injection": "SQL injection", "nosql_injection": "NoSQL injection",
35
+ "bfla": "Missing role check", "mass_assignment": "Mass assignment"}
36
+ RECOMMENDATION = {
37
+ "bola": "Bind the object lookup to the current user or tenant, or check ownership before using it.",
38
+ "sql_injection": "Use parameterized queries; allow-list any dynamic identifier.",
39
+ "nosql_injection": "Pass typed values to the query; reject operator objects and server-side JavaScript.",
40
+ "bfla": "Check the caller's role on the server before the privileged action.",
41
+ "mass_assignment": "Bind only an explicit allow-list of fields; never bind role, owner or balance fields.",
42
+ }
43
+
44
+
45
+ def _category(record: Dict[str, Any], default: str) -> str:
46
+ hint = record.get("category_hint")
47
+ return hint if hint in SUBTYPES else default
48
+
49
+
50
+ def _subtype(record: Dict[str, Any], category: str) -> str:
51
+ hints = str(record.get("subtype_hint") or record.get("subtype") or "").split("|")
52
+ return next((h for h in hints if h in SUBTYPES[category]), SUBTYPES[category][0])
53
+
54
+
55
+ def _site(record: Dict[str, Any]) -> Dict[str, Any]:
56
+ """file / start_line of a route handler or a candidate."""
57
+ handler = record.get("handler") if isinstance(record.get("handler"), dict) else {}
58
+ loc = record.get("location") if isinstance(record.get("location"), dict) else {}
59
+ line = handler.get("line") or record.get("line") or loc.get("start_line")
60
+ return {"file": handler.get("file") or record.get("file") or loc.get("file") or "unknown",
61
+ "start_line": line if isinstance(line, int) else 1}
62
+
63
+
64
+ def _finding(key: str, record_id: str, verdict: str, record: Dict[str, Any], default_category: str) -> Dict[str, Any]:
65
+ category = _category(record, default_category)
66
+ site = _site(record)
67
+ finding = {
68
+ "finding_id": f"{PREFIX[category]}-{record_id}-verdict",
69
+ "category": category,
70
+ "subtype": _subtype(record, category),
71
+ "title": f"{LABEL[category]} at {site['file']}:{site['start_line']} (from auditor verdict)",
72
+ key: record_id,
73
+ "location": site,
74
+ "evidence": [{"file": site["file"], "line": site["start_line"],
75
+ "why": f"The auditor judged this {'route' if key == 'route_id' else 'candidate'} '{verdict}' but wrote no finding"}],
76
+ "impact": f"Generated from the auditor's '{verdict}' verdict; the model wrote no finding for it.",
77
+ "severity": "medium",
78
+ "confidence": "low",
79
+ "cwe": list(DEFAULT_CWE[category]),
80
+ "recommendation": RECOMMENDATION[category],
81
+ # the verifier (phase 5) decides; the original verdict is kept in impact
82
+ "status": "unverified",
83
+ }
84
+ if key == "route_id" and record.get("path"):
85
+ method = record.get("method")
86
+ finding["endpoint"] = {"method": method[0] if isinstance(method, list) and method else method or "GET",
87
+ "path": record["path"]}
88
+ return finding
89
+
90
+
91
+ def verdict_findings(verdicts: Any, findings: List[Dict[str, Any]], key: str,
92
+ records: Dict[str, Dict[str, Any]], default_category: str) -> List[Dict[str, Any]]:
93
+ """Findings for flagged verdicts (by `key`: route_id or candidate_id) that no finding covers.
94
+
95
+ A verdict is covered by a finding that carries its id in `key`, that the verdict lists in
96
+ `finding_ids`, or whose finding_id names the record (`BFLA-R-0001-1`). Verdicts whose id is not a
97
+ known Tier 1 record are skipped: they cannot be located.
98
+ """
99
+ dicts = [f for f in findings if isinstance(f, dict)]
100
+ covered = {f.get(key) for f in dicts}
101
+ finding_ids = {str(f.get("finding_id")) for f in dicts}
102
+ out: List[Dict[str, Any]] = []
103
+ for v in verdicts if isinstance(verdicts, list) else []:
104
+ if not isinstance(v, dict) or v.get("verdict") not in FLAGGED:
105
+ continue
106
+ record_id = v.get(key)
107
+ listed = v.get("finding_ids") if isinstance(v.get("finding_ids"), list) else []
108
+ if (record_id in covered or record_id not in records or finding_ids.intersection(map(str, listed))
109
+ or any(f"-{record_id}-" in f"{fid}-" for fid in finding_ids)):
110
+ continue
111
+ covered.add(record_id)
112
+ out.append(_finding(key, record_id, v["verdict"], records[record_id], default_category))
113
+ return out
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.1.1" # x-release-please-version
5
+ __version__ = "2.2.0" # x-release-please-version