@hoanghainh/komagu 2.1.0 → 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 +14 -0
- package/assess/lsa/prompts/00-orchestrator.md +8 -5
- package/assess/lsa/scripts/make_privilege_batches.py +81 -0
- package/assess/lsa/scripts/make_verify_batches.py +3 -2
- package/assess/lsa/scripts/privilege.py +4 -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/assess/lsa/tests/test_privilege_followup.py +138 -0
- 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,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.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
|
+
|
|
12
|
+
## [2.1.1](https://github.com/hoanghainh1188/komagu/compare/v2.1.0...v2.1.1) (2026-11-01)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **assess:** build privilege batches after route mapping and verify privilege findings ([#332](https://github.com/hoanghainh1188/komagu/issues/332)) ([b81f15b](https://github.com/hoanghainh1188/komagu/commit/b81f15b45c1c76c4be55beab48fb637516f3ff46))
|
|
18
|
+
|
|
5
19
|
## [2.1.0](https://github.com/hoanghainh1188/komagu/compare/v2.0.1...v2.1.0) (2026-11-01)
|
|
6
20
|
|
|
7
21
|
|
|
@@ -26,7 +26,7 @@ Skip nothing. If a phase fails, stop and report the failure.
|
|
|
26
26
|
|---|---|---|---|
|
|
27
27
|
| 1 | Pre-scan | script `python3 KIT/scripts/run_prescan.py TARGET OUT [--config CONFIG]` | `stack-profile.json`, `routes.candidates.json`, `injection.candidates.json`, `mass-assignment.candidates.json`, `batches/*.json` |
|
|
28
28
|
| 2 | Stack & auth model | agent `10-stack-detector` | `stack-profile.json` (confirmed), `auth-model.md` |
|
|
29
|
-
| 3 | Route inventory | agent `20-route-mapper` (one run per route batch) | `routes/<batch>.json` → merged `routes.json` |
|
|
29
|
+
| 3 | Route inventory | agent `20-route-mapper` (one run per route batch), then scripts `merge_findings.py OUT` and `make_privilege_batches.py OUT` | `routes/<batch>.json` → merged `routes.json`; extra `batches/privilege-<n>.json` |
|
|
30
30
|
| 4a | BOLA audit | agent `30-bola-auditor` (parallel, one per route batch) | `findings/bola-<batch>.json` |
|
|
31
31
|
| 4b | Injection audit | agent `40-injection-auditor` (parallel, one per injection batch) | `findings/inj-<batch>.json` |
|
|
32
32
|
| 4c | Privilege audit (BFLA, mass assignment) | agent `35-privilege-auditor` (parallel, one per `batches/privilege-<n>.json`) | `findings/priv-<batch>.json` |
|
|
@@ -51,10 +51,13 @@ Skip nothing. If a phase fails, stop and report the failure.
|
|
|
51
51
|
`routes.candidates.json`. Every candidate must be either in `routes.json` or
|
|
52
52
|
listed in `routes.json → rejected[]` with a reason. Missing items = rerun.
|
|
53
53
|
- **Privilege batches.** The pre-scan puts BFLA-hinted routes and mass
|
|
54
|
-
assignment candidates into `batches/privilege-<n>.json`.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
assignment candidates into `batches/privilege-<n>.json`. The route mapper
|
|
55
|
+
usually flags more routes (`needs_bfla_audit: true`) than the pre-scan hints.
|
|
56
|
+
After phase 3, run `merge_findings.py OUT` (writes `routes.json`), then
|
|
57
|
+
`make_privilege_batches.py OUT`: it adds `privilege-<n>.json` batches for the
|
|
58
|
+
flagged routes that are in no batch yet. Run phase 4c on every privilege batch.
|
|
59
|
+
`merge_findings.py` reports BFLA routes and mass candidates without a verdict
|
|
60
|
+
as coverage gaps; if any remain, run `make_privilege_batches.py` again.
|
|
58
61
|
- **Verifier new issues.** `merge_findings.py` lists verifier `new_issues`
|
|
59
62
|
in `coverage.json`. Send each one back to the matching auditor (BOLA,
|
|
60
63
|
injection or privilege) as a one-item batch, then verify the result. Repeat once at most.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Add privilege batches for routes the route mapper flagged for a BFLA audit.
|
|
3
|
+
|
|
4
|
+
Usage: make_privilege_batches.py OUT [--config audit-config.json]
|
|
5
|
+
Run after phase 3, once merge_findings.py has written OUT/routes.json.
|
|
6
|
+
Reads OUT/routes.json, OUT/batches/privilege-*.json, OUT/findings/priv-*.json, OUT/stack-profile.json
|
|
7
|
+
Writes OUT/batches/privilege-NNN.json for routes with needs_bfla_audit: true that are in no
|
|
8
|
+
privilege batch yet and have no privilege verdict (numbering continues after the existing
|
|
9
|
+
batches). Running it again writes nothing new. Give each new batch to the privilege auditor.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Dict, List, Set
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
20
|
+
from lsa_common import load_config, read_json, write_json # noqa: E402
|
|
21
|
+
from make_batches import PRIVILEGE_KNOWLEDGE, group_by_file, pack # noqa: E402
|
|
22
|
+
from merge_findings import extract_json # noqa: E402
|
|
23
|
+
|
|
24
|
+
BATCH_RE = re.compile(r"^privilege-(\d+)\.json$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _covered(out: Path) -> Set[str]:
|
|
28
|
+
"""Route ids already in a privilege batch or with a privilege verdict."""
|
|
29
|
+
ids: Set[str] = set()
|
|
30
|
+
for p in (out / "batches").glob("privilege-*.json"):
|
|
31
|
+
ids |= {r.get("route_id") for r in read_json(p).get("routes", []) if isinstance(r, dict)}
|
|
32
|
+
for p in (out / "findings").glob("priv-*.json"):
|
|
33
|
+
doc = extract_json(p)
|
|
34
|
+
verdicts = doc.get("privilege_route_verdicts", []) if isinstance(doc, dict) else []
|
|
35
|
+
ids |= {v.get("route_id") for v in verdicts if isinstance(v, dict)}
|
|
36
|
+
return ids
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _stack_knowledge(out: Path) -> List[str]:
|
|
40
|
+
prof = out / "stack-profile.json"
|
|
41
|
+
files = read_json(prof).get("knowledge_files", []) if prof.is_file() else []
|
|
42
|
+
return [f for f in files if isinstance(f, str) and f.startswith("knowledge/stacks/")]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def make_privilege_batches(out: Path, config: Dict) -> List[str]:
|
|
46
|
+
"""Write privilege batches for uncovered BFLA routes; return the new batch ids."""
|
|
47
|
+
if not (out / "routes.json").is_file():
|
|
48
|
+
return []
|
|
49
|
+
covered = _covered(out)
|
|
50
|
+
todo = [r for r in read_json(out / "routes.json").get("routes", [])
|
|
51
|
+
if isinstance(r, dict) and r.get("needs_bfla_audit") and r.get("route_id") not in covered]
|
|
52
|
+
if not todo:
|
|
53
|
+
return []
|
|
54
|
+
bdir = out / "batches"
|
|
55
|
+
numbers = [int(m.group(1)) for p in bdir.glob("privilege-*.json") for m in [BATCH_RE.match(p.name)] if m]
|
|
56
|
+
n = max(numbers, default=0)
|
|
57
|
+
knowledge = PRIVILEGE_KNOWLEDGE + _stack_knowledge(out)
|
|
58
|
+
limit = int(config["batch"].get("max_privilege_items", 20))
|
|
59
|
+
written = []
|
|
60
|
+
for chunk in pack(group_by_file(todo, lambda r: r["handler"]["file"]), limit):
|
|
61
|
+
n += 1
|
|
62
|
+
bid = f"privilege-{n:03d}"
|
|
63
|
+
write_json(bdir / f"{bid}.json", {"batch_id": bid, "files": sorted({r["handler"]["file"] for r in chunk}),
|
|
64
|
+
"knowledge_files": knowledge, "routes": chunk, "mass_candidates": [],
|
|
65
|
+
"source": "routes.json (needs_bfla_audit set by the route mapper)"})
|
|
66
|
+
written.append(bid)
|
|
67
|
+
return written
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> int:
|
|
71
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
72
|
+
ap.add_argument("out")
|
|
73
|
+
ap.add_argument("--config")
|
|
74
|
+
a = ap.parse_args()
|
|
75
|
+
written = make_privilege_batches(Path(a.out), load_config(a.config))
|
|
76
|
+
print(f"new privilege batches: {len(written)}" + (f" ({', '.join(written)})" if written else ""))
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
sys.exit(main())
|
|
@@ -20,7 +20,8 @@ from merge_findings import extract_json # noqa: E402
|
|
|
20
20
|
|
|
21
21
|
EXT_LANG = {".php": "php", ".inc": "php", ".java": "java", ".jsp": "java", ".xml": "java", ".cs": "cs", ".vb": "vb",
|
|
22
22
|
".aspx": "aspx", ".ashx": "cs", ".asmx": "cs", ".asp": "vb", ".pl": "perl", ".cgi": "perl", ".pm": "perl",
|
|
23
|
-
".cfm": "cfml", ".cfc": "cfml", ".js": "js", ".
|
|
23
|
+
".cfm": "cfml", ".cfc": "cfml", ".js": "js", ".mjs": "js", ".cjs": "js", ".ts": "js", ".tsx": "js",
|
|
24
|
+
".jsx": "js", ".py": "python", ".rb": "ruby", ".kt": "java"}
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def ext_lang(path: str) -> str:
|
|
@@ -35,7 +36,7 @@ def main() -> int:
|
|
|
35
36
|
out = Path(a.out)
|
|
36
37
|
findings = []
|
|
37
38
|
for p in sorted((out / "findings").glob("*.json")):
|
|
38
|
-
if p.name.startswith(("bola-", "inj-")):
|
|
39
|
+
if p.name.startswith(("bola-", "inj-", "priv-")):
|
|
39
40
|
findings += extract_json(p).get("findings", [])
|
|
40
41
|
# keep findings on the same file together, highest severity first
|
|
41
42
|
order = {"critical": 0, "high": 1, "medium": 2, "low": 3, "info": 4}
|
|
@@ -42,6 +42,9 @@ SENSITIVE_WRITE_RES = [
|
|
|
42
42
|
re.compile(r"(?i)\bupdate\s+[`\"\[]?(?:\w+\.)?(users?|accounts?|members?)[`\"\]]?\s+set\b[^;\n]{0,120}?"
|
|
43
43
|
r"\b(?:role|is_?admin|admin|permissions?|privileges?|level|user_?type)\s*="),
|
|
44
44
|
re.compile(r"(?i)\bupdate\s+[`\"\[]?(?:\w+\.)?(roles?|permissions?|settings|config)\b"),
|
|
45
|
+
# ORM writes: Drizzle db.update(users) / db.delete(roles) / db.insert(userRoles), Prisma prisma.user.update(...)
|
|
46
|
+
re.compile(r"\.(?:update|delete|insert)\s*\(\s*(?:\w+\.)?(users?|accounts?|members?|roles?|permissions?|user_?[Rr]oles?)\s*\)"),
|
|
47
|
+
re.compile(r"\bprisma\.(users?|accounts?|members?|roles?|permissions?|userRoles?)\.(?:update|updateMany|upsert|delete|deleteMany|create|createMany)\s*\("),
|
|
45
48
|
]
|
|
46
49
|
|
|
47
50
|
ROLE_CHECK_RE = re.compile("|".join([
|
|
@@ -55,6 +58,7 @@ ROLE_CHECK_RE = re.compile("|".join([
|
|
|
55
58
|
r"\$_SESSION\s*\[\s*['\"](?:role|is_?admin|admin|user_?role|level|privilege)['\"]\s*\]",
|
|
56
59
|
r"Session\s*[\(\[]\s*\"(?:role|isadmin|is_admin|admin|level)\"|session\.(?:role|isadmin|is_admin|admin)\b",
|
|
57
60
|
r"\$session->param\s*\(\s*['\"](?:role|admin)",
|
|
61
|
+
r"\bpermission\s*:\s*['\"][\w.:-]+['\"]|\bcan\s*\(\s*[\w.]+\s*,\s*['\"]|\b(?:require|has)(?:Permission|Role)\s*\(",
|
|
58
62
|
r"(?:include|require)(?:_once)?\s*\(?\s*['\"](?:[^'\"]*/)?[^/'\"]*(?:admin|role|perm|acl)[^/'\"]*['\"]",
|
|
59
63
|
]), re.I)
|
|
60
64
|
|
|
@@ -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()
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Privilege work after route mapping, verification of privilege findings, modern BFLA hints (Issue #331)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
HERE = Path(__file__).resolve().parent
|
|
13
|
+
SCRIPTS = HERE.parent / "scripts"
|
|
14
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
15
|
+
|
|
16
|
+
from lsa_common import load_config, read_json, write_json # noqa: E402
|
|
17
|
+
from make_privilege_batches import make_privilege_batches # noqa: E402
|
|
18
|
+
from privilege import annotate_privilege # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _mapped(rid: str, rel: str, bfla: bool) -> dict:
|
|
22
|
+
return {"route_id": rid, "method": ["POST"], "path": "/api/" + rid.lower(),
|
|
23
|
+
"handler": {"file": rel, "line": 3, "symbol": "POST"}, "id_inputs": [], "guards": [], "data_ops": [],
|
|
24
|
+
"needs_bola_audit": False, "needs_bfla_audit": bfla, "privilege": "privileged" if bfla else "user", "notes": ""}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _finding(fid: str, rel: str, category: str) -> dict:
|
|
28
|
+
return {"id": fid, "category": category, "severity": "high", "location": {"file": rel, "start_line": 3}}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestMakePrivilegeBatches(unittest.TestCase):
|
|
32
|
+
def setUp(self) -> None:
|
|
33
|
+
tmp = tempfile.TemporaryDirectory()
|
|
34
|
+
self.addCleanup(tmp.cleanup)
|
|
35
|
+
self.out = Path(tmp.name)
|
|
36
|
+
self.config = load_config(None)
|
|
37
|
+
write_json(self.out / "stack-profile.json", {"stacks": [{"id": "node-nextjs"}],
|
|
38
|
+
"knowledge_files": ["knowledge/stacks/nextjs.md"]})
|
|
39
|
+
write_json(self.out / "routes.json", {"routes": [
|
|
40
|
+
_mapped("R-0001", "src/app/api/a/route.ts", True),
|
|
41
|
+
_mapped("R-0002", "src/app/api/b/route.ts", True),
|
|
42
|
+
_mapped("R-0003", "src/app/api/c/route.ts", True),
|
|
43
|
+
_mapped("R-0004", "src/app/api/d/route.ts", False),
|
|
44
|
+
_mapped("R-0005", "src/app/api/e/route.ts", True),
|
|
45
|
+
]})
|
|
46
|
+
write_json(self.out / "batches" / "privilege-001.json",
|
|
47
|
+
{"batch_id": "privilege-001", "files": [], "routes": [{"route_id": "R-0001"}], "mass_candidates": []})
|
|
48
|
+
(self.out / "findings").mkdir()
|
|
49
|
+
write_json(self.out / "findings" / "priv-privilege-001.json",
|
|
50
|
+
{"batch_id": "privilege-001", "privilege_route_verdicts": [{"route_id": "R-0005", "verdict": "safe"}]})
|
|
51
|
+
|
|
52
|
+
def test_new_batch_for_uncovered_routes_only(self) -> None:
|
|
53
|
+
self.assertEqual(make_privilege_batches(self.out, self.config), ["privilege-002"])
|
|
54
|
+
b = read_json(self.out / "batches" / "privilege-002.json")
|
|
55
|
+
self.assertEqual([r["route_id"] for r in b["routes"]], ["R-0002", "R-0003"])
|
|
56
|
+
self.assertEqual(b["files"], ["src/app/api/b/route.ts", "src/app/api/c/route.ts"])
|
|
57
|
+
self.assertIn("knowledge/bfla-patterns.md", b["knowledge_files"])
|
|
58
|
+
self.assertIn("knowledge/stacks/nextjs.md", b["knowledge_files"])
|
|
59
|
+
self.assertEqual(b["mass_candidates"], [])
|
|
60
|
+
|
|
61
|
+
def test_second_run_writes_nothing(self) -> None:
|
|
62
|
+
make_privilege_batches(self.out, self.config)
|
|
63
|
+
self.assertEqual(make_privilege_batches(self.out, self.config), [])
|
|
64
|
+
self.assertFalse((self.out / "batches" / "privilege-003.json").exists())
|
|
65
|
+
|
|
66
|
+
def test_batch_size_limit(self) -> None:
|
|
67
|
+
cfg = load_config(None)
|
|
68
|
+
cfg["batch"]["max_privilege_items"] = 1
|
|
69
|
+
self.assertEqual(make_privilege_batches(self.out, cfg), ["privilege-002", "privilege-003"])
|
|
70
|
+
|
|
71
|
+
def test_no_routes_json(self) -> None:
|
|
72
|
+
(self.out / "routes.json").unlink()
|
|
73
|
+
self.assertEqual(make_privilege_batches(self.out, self.config), [])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestVerifyBatchesIncludePrivilege(unittest.TestCase):
|
|
77
|
+
def test_priv_findings_are_batched_with_stack_knowledge(self) -> None:
|
|
78
|
+
with tempfile.TemporaryDirectory() as td:
|
|
79
|
+
out = Path(td)
|
|
80
|
+
write_json(out / "findings" / "bola-routes-001.json",
|
|
81
|
+
{"findings": [_finding("F-1", "src/app/api/x/route.ts", "bola")]})
|
|
82
|
+
write_json(out / "findings" / "priv-privilege-001.json",
|
|
83
|
+
{"findings": [_finding("F-2", "src/app/api/users/route.ts", "bfla")]})
|
|
84
|
+
res = subprocess.run([sys.executable, str(SCRIPTS / "make_verify_batches.py"), str(out)],
|
|
85
|
+
capture_output=True, text=True)
|
|
86
|
+
self.assertEqual(res.returncode, 0, res.stderr)
|
|
87
|
+
b = read_json(out / "batches" / "verify-001.json")
|
|
88
|
+
self.assertEqual(sorted(f["id"] for f in b["findings"]), ["F-1", "F-2"])
|
|
89
|
+
self.assertIn("knowledge/stacks/node-python-ruby.md", b["knowledge_files"])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestModernRoleChecks(unittest.TestCase):
|
|
93
|
+
def _annotate(self, rel: str, code: str, path: str, line: int = 2) -> dict:
|
|
94
|
+
with tempfile.TemporaryDirectory() as td:
|
|
95
|
+
root = Path(td)
|
|
96
|
+
(root / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
(root / rel).write_text(code, encoding="utf-8")
|
|
98
|
+
route = {"route_id": "R-0001", "path": path, "method": ["GET"],
|
|
99
|
+
"handler": {"file": rel, "line": line, "symbol": "GET"}}
|
|
100
|
+
return annotate_privilege([route], root, load_config(None))[0]
|
|
101
|
+
|
|
102
|
+
def test_permission_option_is_a_role_check(self) -> None:
|
|
103
|
+
r = self._annotate("src/app/api/activity/export/route.ts",
|
|
104
|
+
'import { withAuth } from "@/lib/auth/guard";\n'
|
|
105
|
+
"export const GET = withAuth(\n"
|
|
106
|
+
" async () => exportCsv(),\n"
|
|
107
|
+
' { permission: "activity.view" },\n'
|
|
108
|
+
");\n", "/api/activity/export")
|
|
109
|
+
self.assertEqual(r["privilege_hint"], "privileged")
|
|
110
|
+
self.assertTrue(r["role_checks"])
|
|
111
|
+
self.assertFalse(any(s["signal"] == "no_role_check" for s in r["bfla_signals"]))
|
|
112
|
+
|
|
113
|
+
def test_can_call_is_a_role_check(self) -> None:
|
|
114
|
+
r = self._annotate("src/app/api/export/route.ts",
|
|
115
|
+
"export async function GET() {\n"
|
|
116
|
+
' if (!can(actor, "activity.view")) return forbidden();\n'
|
|
117
|
+
"}\n", "/api/export", line=1)
|
|
118
|
+
self.assertTrue(r["role_checks"])
|
|
119
|
+
|
|
120
|
+
def test_orm_write_to_users_is_privileged(self) -> None:
|
|
121
|
+
r = self._annotate("src/app/api/x/route.ts",
|
|
122
|
+
"export async function PATCH(req) {\n"
|
|
123
|
+
" await db.update(users).set({ role: body.role }).where(eq(users.id, id));\n"
|
|
124
|
+
"}\n", "/api/x", line=1)
|
|
125
|
+
self.assertEqual(r["privilege_hint"], "privileged")
|
|
126
|
+
self.assertIn("write:users", r["privilege_reasons"])
|
|
127
|
+
self.assertTrue(any(s["signal"] == "no_role_check" for s in r["bfla_signals"]))
|
|
128
|
+
|
|
129
|
+
def test_prisma_write_to_role_is_privileged(self) -> None:
|
|
130
|
+
r = self._annotate("api/role.js",
|
|
131
|
+
"async function handler(req, res) {\n"
|
|
132
|
+
" await prisma.role.update({ where: { id }, data });\n"
|
|
133
|
+
"}\n", "/api/x", line=1)
|
|
134
|
+
self.assertIn("write:role", r["privilege_reasons"])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
unittest.main()
|
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",
|