@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 +7 -0
- package/assess/lsa/tests/EVAL-RESULTS.md +25 -0
- package/assess/lsa/tests/fixtures/dotnet-legacy/Web/Controllers/CustomerController.cs +1 -1
- package/assess/lsa/tests/fixtures/java-legacy/src/com/acme/web/AccountController.java +1 -1
- package/assess/lsa/tests/fixtures/php-plain/register.php +3 -3
- package/assess/lsa/tests/fixtures/python-flask/profile.py +2 -2
- package/operate/ai/client.py +69 -1
- package/operate/ai/runner.py +61 -15
- package/operate/ai/verdicts.py +113 -0
- package/operate/cli/__init__.py +1 -1
- package/operate/tests/test_ai_verdicts.py +200 -0
- package/package.json +1 -1
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()
|
package/operate/ai/client.py
CHANGED
|
@@ -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)
|
package/operate/ai/runner.py
CHANGED
|
@@ -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 = {
|
|
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.
|
|
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
|
-
|
|
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":
|
|
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.
|
|
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":
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
package/operate/cli/__init__.py
CHANGED
|
@@ -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.
|
|
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",
|