@hoanghainh/komagu 2.1.1 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
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.1.2](https://github.com/hoanghainh1188/komagu/compare/v2.1.1...v2.1.2) (2026-11-01)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **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))
11
+
5
12
  ## [2.1.1](https://github.com/hoanghainh1188/komagu/compare/v2.1.0...v2.1.1) (2026-11-01)
6
13
 
7
14
 
@@ -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.1.2" # x-release-please-version
@@ -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.1.2",
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",