@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 +7 -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/test_privilege_followup.py +138 -0
- package/operate/cli/__init__.py +1 -1
- 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.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`.
|
|
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
|
|
|
@@ -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/cli/__init__.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hoanghainh/komagu",
|
|
3
|
-
"version": "2.1.
|
|
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",
|