@hoanghainh/komagu 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to `@hoanghainh/komagu` (published as `komainu-guard` up to v1.7.0) are documented here. From v1.2.2 on, entries are generated by [release-please](https://github.com/googleapis/release-please) from [Conventional Commits](https://www.conventionalcommits.org/). Earlier entries are copied from the [GitHub Releases](https://github.com/hoanghainh1188/komainu/releases).
4
4
 
5
+ ## [2.1.1](https://github.com/hoanghainh1188/komagu/compare/v2.1.0...v2.1.1) (2026-11-01)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **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))
11
+
5
12
  ## [2.1.0](https://github.com/hoanghainh1188/komagu/compare/v2.0.1...v2.1.0) (2026-11-01)
6
13
 
7
14
 
@@ -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`. If the route mapper
55
- sets `needs_bfla_audit: true` on a route that is in no privilege batch, give
56
- it to `35-privilege-auditor` as a one-route batch. `merge_findings.py`
57
- reports BFLA routes and mass candidates without a verdict as coverage gaps.
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", ".py": "python", ".rb": "ruby", ".kt": "java"}
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
 
@@ -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()
@@ -2,4 +2,4 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "2.1.0" # x-release-please-version
5
+ __version__ = "2.1.1" # x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hoanghainh/komagu",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
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",