@hoanghainh/komagu 2.0.0 → 2.1.0

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.
Files changed (143) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -1
  3. package/assess/lsa/examples/eval-run-4/ground-truth.json +823 -0
  4. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/ai-run-stats.json +58 -0
  5. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/coverage.json +85 -0
  6. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-001.json +822 -0
  7. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-002.json +393 -0
  8. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings.json +4477 -0
  9. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/mass-assignment.candidates.json +232 -0
  10. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-001.json +425 -0
  11. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-002.json +333 -0
  12. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-003.json +400 -0
  13. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-004.json +403 -0
  14. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-005.json +398 -0
  15. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-006.json +63 -0
  16. package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/score.json +120 -0
  17. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/ai-run-stats.json +55 -0
  18. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/coverage.json +50 -0
  19. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-001.json +613 -0
  20. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-002.json +287 -0
  21. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings.json +4870 -0
  22. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/mass-assignment.candidates.json +232 -0
  23. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-001.json +425 -0
  24. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-002.json +333 -0
  25. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-003.json +400 -0
  26. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-004.json +403 -0
  27. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-005.json +398 -0
  28. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-006.json +63 -0
  29. package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/score.json +107 -0
  30. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/ai-run-stats.json +58 -0
  31. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/coverage.json +92 -0
  32. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-001.json +645 -0
  33. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-002.json +222 -0
  34. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings.json +4859 -0
  35. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/mass-assignment.candidates.json +232 -0
  36. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-001.json +425 -0
  37. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-002.json +333 -0
  38. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-003.json +400 -0
  39. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-004.json +403 -0
  40. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-005.json +398 -0
  41. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-006.json +63 -0
  42. package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/score.json +121 -0
  43. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/coverage.json +111 -0
  44. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-001.json +6 -0
  45. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-002.json +344 -0
  46. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings.json +3544 -0
  47. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/mass-assignment.candidates.json +232 -0
  48. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-001.json +425 -0
  49. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-002.json +333 -0
  50. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-003.json +400 -0
  51. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-004.json +403 -0
  52. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-005.json +398 -0
  53. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-006.json +63 -0
  54. package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/score.json +123 -0
  55. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/ai-run-stats.json +50 -0
  56. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/coverage.json +42 -0
  57. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-001.json +540 -0
  58. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-002.json +238 -0
  59. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.json +4383 -0
  60. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.sarif +8729 -0
  61. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/mass-assignment.candidates.json +232 -0
  62. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.ja.md +1911 -0
  63. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.md +1911 -0
  64. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-001.json +425 -0
  65. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-002.json +333 -0
  66. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-003.json +400 -0
  67. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-004.json +403 -0
  68. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-005.json +398 -0
  69. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-006.json +63 -0
  70. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/score.json +101 -0
  71. package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/stack-profile.json +226 -0
  72. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/ai-run-stats.json +54 -0
  73. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/coverage.json +66 -0
  74. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-001.json +440 -0
  75. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-002.json +229 -0
  76. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings.json +3424 -0
  77. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/mass-assignment.candidates.json +232 -0
  78. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-001.json +425 -0
  79. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-002.json +333 -0
  80. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-003.json +400 -0
  81. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-004.json +403 -0
  82. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-005.json +398 -0
  83. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-006.json +63 -0
  84. package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/score.json +120 -0
  85. package/assess/lsa/examples/eval-run-5/gptoss-a/ai-run-stats.json +88 -0
  86. package/assess/lsa/examples/eval-run-5/gptoss-a/coverage.json +36 -0
  87. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-001.json +530 -0
  88. package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-002.json +248 -0
  89. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.json +4766 -0
  90. package/assess/lsa/examples/eval-run-5/gptoss-a/findings.sarif +9326 -0
  91. package/assess/lsa/examples/eval-run-5/gptoss-a/mass-assignment.candidates.json +232 -0
  92. package/assess/lsa/examples/eval-run-5/gptoss-a/report.ja.md +2011 -0
  93. package/assess/lsa/examples/eval-run-5/gptoss-a/report.md +2011 -0
  94. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-001.json +425 -0
  95. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-002.json +333 -0
  96. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-003.json +400 -0
  97. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-004.json +403 -0
  98. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-005.json +398 -0
  99. package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-006.json +63 -0
  100. package/assess/lsa/examples/eval-run-5/gptoss-a/score.json +80 -0
  101. package/assess/lsa/examples/eval-run-5/gptoss-a/stack-profile.json +226 -0
  102. package/assess/lsa/examples/eval-run-5/gptoss-b/ai-run-stats.json +83 -0
  103. package/assess/lsa/examples/eval-run-5/gptoss-b/coverage.json +44 -0
  104. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-001.json +579 -0
  105. package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-002.json +268 -0
  106. package/assess/lsa/examples/eval-run-5/gptoss-b/findings.json +3913 -0
  107. package/assess/lsa/examples/eval-run-5/gptoss-b/mass-assignment.candidates.json +232 -0
  108. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-001.json +425 -0
  109. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-002.json +333 -0
  110. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-003.json +400 -0
  111. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-004.json +403 -0
  112. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-005.json +398 -0
  113. package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-006.json +63 -0
  114. package/assess/lsa/examples/eval-run-5/gptoss-b/score.json +94 -0
  115. package/assess/lsa/examples/eval-run-5/ground-truth.json +823 -0
  116. package/assess/lsa/knowledge/injection-patterns.md +8 -1
  117. package/assess/lsa/knowledge/stacks/nextjs.md +62 -0
  118. package/assess/lsa/prompts/35-privilege-auditor.md +3 -1
  119. package/assess/lsa/prompts/_shared-rules.md +1 -0
  120. package/assess/lsa/scripts/detect_stack.py +5 -1
  121. package/assess/lsa/scripts/find_injection.py +34 -5
  122. package/assess/lsa/scripts/lsa_common.py +9 -2
  123. package/assess/lsa/scripts/make_batches.py +2 -1
  124. package/assess/lsa/scripts/map_routes.py +10 -2
  125. package/assess/lsa/scripts/nextjs_routes.py +236 -0
  126. package/assess/lsa/templates/audit-config.json +1 -1
  127. package/assess/lsa/tests/EVAL-RESULTS.md +156 -0
  128. package/assess/lsa/tests/score_findings.py +58 -0
  129. package/assess/lsa/tests/test_default_scope.py +86 -0
  130. package/assess/lsa/tests/test_eval_run_4.py +73 -0
  131. package/assess/lsa/tests/test_eval_run_5.py +73 -0
  132. package/assess/lsa/tests/test_js_tagged_sql.py +112 -0
  133. package/assess/lsa/tests/test_nextjs.py +198 -0
  134. package/assess/lsa/tests/test_privilege_tier2.py +30 -0
  135. package/operate/ai/anchoring.py +130 -0
  136. package/operate/ai/client.py +13 -0
  137. package/operate/ai/context.py +105 -0
  138. package/operate/ai/runner.py +197 -69
  139. package/operate/cli/__init__.py +1 -1
  140. package/operate/cli/commands/scan.py +1 -1
  141. package/operate/tests/test_ai_anchoring.py +184 -0
  142. package/operate/tests/test_headless_ai.py +148 -1
  143. package/package.json +1 -1
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env python3
2
+ """Next.js stack detection and route inventory (Issue #324).
3
+
4
+ Covers App Router route handlers (route groups, dynamic and catch-all segments, `export const`
5
+ handlers), Pages Router API routes, server actions (file-level and inline "use server"), dynamic
6
+ server-component pages, and middleware / proxy notes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ import tempfile
13
+ import unittest
14
+ from pathlib import Path
15
+ from typing import Dict, List
16
+
17
+ SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
18
+ if str(SCRIPTS_DIR) not in sys.path:
19
+ sys.path.insert(0, str(SCRIPTS_DIR))
20
+
21
+ from detect_stack import detect # noqa: E402
22
+ from lsa_common import load_config # noqa: E402
23
+ from map_routes import map_routes # noqa: E402
24
+
25
+ FILES: Dict[str, str] = {
26
+ "next.config.ts": "const nextConfig = {};\nexport default nextConfig;\n",
27
+ "src/proxy.ts": (
28
+ 'import { NextResponse } from "next/server";\n'
29
+ "export async function proxy(request) { return NextResponse.next(); }\n"
30
+ 'export const config = { matcher: ["/((?!_next|login).*)"] };\n'
31
+ ),
32
+ # unguarded BOLA: id from the path, no owner filter
33
+ "src/app/api/orders/[id]/route.ts": (
34
+ 'import { db } from "@/db";\n'
35
+ "export async function GET(request, { params }) {\n"
36
+ " const { id } = await params;\n"
37
+ " return Response.json(await db.query.orders.findFirst({ where: eq(orders.id, id) }));\n"
38
+ "}\n"
39
+ "\n"
40
+ "export async function DELETE(request, { params }) {\n"
41
+ " const session = await auth();\n"
42
+ " await db.delete(orders).where(eq(orders.id, params.id));\n"
43
+ " return new Response(null, { status: 204 });\n"
44
+ "}\n"
45
+ ),
46
+ # route group, export const with a guard wrapper, query + JSON body inputs
47
+ "src/app/(admin)/api/users/[userId]/route.ts": (
48
+ 'import { withAuth } from "@/lib/auth/guard";\n'
49
+ "export const PATCH = withAuth(async (request, { actor, params }) => {\n"
50
+ " const { role, tenantId } = await request.json();\n"
51
+ ' const dry = request.nextUrl.searchParams.get("dryRun");\n'
52
+ " return changeRole(actor, params.userId, role);\n"
53
+ '}, { permission: "users.manage" });\n'
54
+ ),
55
+ "app/api/files/[...path]/route.js": "export async function GET(req, ctx) { return serve(ctx.params.path); }\n",
56
+ "app/api/health/route.ts": "export function GET() { return Response.json({ ok: true }); }\n",
57
+ # private folder: not routable
58
+ "app/_lib/route.ts": "export function GET() {}\n",
59
+ "pages/api/invoices/[invoiceId].ts": (
60
+ "export default async function handler(req, res) {\n"
61
+ ' if (req.method === "DELETE") { await removeInvoice(req.query.invoiceId); }\n'
62
+ ' if (req.method === "GET") { res.json(await getInvoice(req.query.invoiceId)); }\n'
63
+ "}\n"
64
+ ),
65
+ "pages/api/index.ts": "export default function handler(req, res) { res.json({}); }\n",
66
+ "src/app/orders/actions.ts": (
67
+ '"use server";\n'
68
+ 'import { auth } from "@/auth";\n'
69
+ "export async function cancelOrder(orderId: number, reason: string) {\n"
70
+ " const session = await auth();\n"
71
+ " await db.update(orders).set({ status: 'cancelled' }).where(eq(orders.id, orderId));\n"
72
+ "}\n"
73
+ "export const archiveOrder = async (formData: FormData) => {\n"
74
+ ' const id = formData.get("orderId");\n'
75
+ " await db.delete(orders).where(eq(orders.id, id));\n"
76
+ "};\n"
77
+ "function helper() {}\n"
78
+ ),
79
+ "src/app/orders/[code]/page.tsx": (
80
+ "export default async function OrderPage({ params }) {\n"
81
+ " const { code } = await params;\n"
82
+ " const order = await getOrderByCode(code);\n"
83
+ " async function approve() {\n"
84
+ ' "use server";\n'
85
+ " await approveOrder(order.id);\n"
86
+ " }\n"
87
+ " return <form action={approve}><p>{order.code}</p></form>;\n"
88
+ "}\n"
89
+ ),
90
+ "src/app/about/page.tsx": "export default function About() { return <p>about</p>; }\n",
91
+ }
92
+
93
+
94
+ def _write(root: Path) -> None:
95
+ for rel, text in FILES.items():
96
+ p = root / rel
97
+ p.parent.mkdir(parents=True, exist_ok=True)
98
+ p.write_text(text, encoding="utf-8")
99
+
100
+
101
+ def _routes(root: Path) -> List[Dict]:
102
+ cfg = load_config(None)
103
+ stacks = {s["id"] for s in detect(root, cfg)["stacks"]}
104
+ return map_routes(root, cfg, stacks)["candidates"]
105
+
106
+
107
+ def _find(routes: List[Dict], method: str, path: str) -> Dict:
108
+ hits = [r for r in routes if r["path"] == path and method in r["method"]]
109
+ if len(hits) != 1:
110
+ raise AssertionError(f"{method} {path}: {len(hits)} hits in {[(r['method'], r['path']) for r in routes]}")
111
+ return hits[0]
112
+
113
+
114
+ def _names(route: Dict) -> Dict[str, str]:
115
+ return {p["name"]: p["source"] for p in route["id_params"]}
116
+
117
+
118
+ class TestNextjs(unittest.TestCase):
119
+ @classmethod
120
+ def setUpClass(cls) -> None:
121
+ cls._td = tempfile.TemporaryDirectory()
122
+ cls.root = Path(cls._td.name)
123
+ _write(cls.root)
124
+ cls.routes = _routes(cls.root)
125
+
126
+ @classmethod
127
+ def tearDownClass(cls) -> None:
128
+ cls._td.cleanup()
129
+
130
+ def test_stack_detected(self) -> None:
131
+ prof = detect(self.root, load_config(None))
132
+ self.assertIn("node-nextjs", {s["id"] for s in prof["stacks"]})
133
+ self.assertIn("knowledge/stacks/nextjs.md", prof["knowledge_files"])
134
+
135
+ def test_all_routes_are_nextjs(self) -> None:
136
+ self.assertEqual({r["stack"] for r in self.routes}, {"node-nextjs"})
137
+
138
+ def test_route_handlers_per_method(self) -> None:
139
+ get = _find(self.routes, "GET", "/api/orders/{id}")
140
+ self.assertEqual(get["kind"], "declared_route")
141
+ self.assertEqual(get["handler"]["file"], "src/app/api/orders/[id]/route.ts")
142
+ self.assertEqual(get["handler"]["line"], 2)
143
+ self.assertEqual(_names(get), {"id": "path"})
144
+ self.assertTrue(get["needs_audit_hint"])
145
+ self.assertEqual(get["guard_hints"], [])
146
+ delete = _find(self.routes, "DELETE", "/api/orders/{id}")
147
+ self.assertTrue(any("auth()" in g["hint"] for g in delete["guard_hints"]))
148
+
149
+ def test_route_group_and_export_const(self) -> None:
150
+ patch = _find(self.routes, "PATCH", "/api/users/{userId}")
151
+ self.assertEqual(_names(patch), {"userId": "path", "role": "body", "tenantId": "body", "dryRun": "query"})
152
+ hints = " ".join(g["hint"] for g in patch["guard_hints"])
153
+ self.assertIn("withAuth", hints)
154
+ self.assertIn("users.manage", hints)
155
+
156
+ def test_catch_all_and_static(self) -> None:
157
+ _find(self.routes, "GET", "/api/files/{path*}")
158
+ self.assertEqual(_find(self.routes, "GET", "/api/health")["id_params"], [])
159
+
160
+ def test_private_folder_skipped(self) -> None:
161
+ self.assertFalse(any(r["handler"]["file"] == "app/_lib/route.ts" for r in self.routes))
162
+
163
+ def test_pages_api(self) -> None:
164
+ inv = _find(self.routes, "DELETE", "/api/invoices/{invoiceId}")
165
+ self.assertEqual(sorted(inv["method"]), ["DELETE", "GET"])
166
+ self.assertEqual(_names(inv)["invoiceId"], "path")
167
+ self.assertEqual(_find(self.routes, "ANY", "/api")["handler"]["file"], "pages/api/index.ts")
168
+
169
+ def test_server_actions(self) -> None:
170
+ cancel = _find(self.routes, "POST", "action:src/app/orders/actions.ts#cancelOrder")
171
+ self.assertEqual(_names(cancel), {"orderId": "body", "reason": "body"})
172
+ self.assertTrue(any("auth" in g["hint"] for g in cancel["guard_hints"]))
173
+ archive = _find(self.routes, "POST", "action:src/app/orders/actions.ts#archiveOrder")
174
+ self.assertEqual(_names(archive), {"orderId": "form"})
175
+ self.assertFalse(any(r["path"].endswith("#helper") for r in self.routes))
176
+
177
+ def test_inline_server_action_and_page(self) -> None:
178
+ _find(self.routes, "POST", "action:src/app/orders/[code]/page.tsx#approve")
179
+ page = _find(self.routes, "GET", "/orders/{code}")
180
+ self.assertEqual(page["kind"], "file_endpoint")
181
+ self.assertEqual(_names(page), {"code": "path"})
182
+ self.assertFalse(any(r["path"] == "/about" for r in self.routes))
183
+
184
+ def test_use_server_after_comments(self) -> None:
185
+ from nextjs_routes import _starts_with_use_server
186
+
187
+ self.assertTrue(_starts_with_use_server('// x\n/* y */\n "use server";\nexport async function a() {}'))
188
+ self.assertFalse(_starts_with_use_server('import x from "y";\n"use server";'))
189
+ self.assertFalse(_starts_with_use_server("/*" + "*//*" * 5000))
190
+
191
+ def test_proxy_noted(self) -> None:
192
+ get = _find(self.routes, "GET", "/api/orders/{id}")
193
+ self.assertIn("src/proxy.ts", get["notes"])
194
+ self.assertIn("matcher", get["notes"])
195
+
196
+
197
+ if __name__ == "__main__":
198
+ unittest.main()
@@ -242,6 +242,36 @@ class TestScoring(unittest.TestCase):
242
242
  self.assertEqual(report["per_category"]["mass_assignment"]["recall"], 0.0)
243
243
  self.assertNotIn("bola", report["per_category"])
244
244
 
245
+ def test_verdict_level_ignores_misplaced_findings(self) -> None:
246
+ """--verdicts credits the auditor's route / candidate verdict even when the finding has a wrong path (#286)."""
247
+ with tempfile.TemporaryDirectory() as tmp:
248
+ truth = Path(tmp) / "gt.json"
249
+ write_json(truth, {"items": [
250
+ {"file": "admin.js", "line": 9, "category": "bfla", "vulnerable": True, "label": "BFLA"},
251
+ {"file": "admin.js", "line": 13, "category": "bfla", "vulnerable": False, "label": "SAFE-BFLA"},
252
+ {"file": "p.php", "line": 6, "category": "mass_assignment", "vulnerable": True, "label": "MASS"},
253
+ {"file": "p.php", "line": 9, "category": "mass_assignment", "vulnerable": False, "label": "SAFE-MASS"},
254
+ ]})
255
+ out = Path(tmp) / "out"
256
+ write_json(out / "findings.json", {"findings": [
257
+ {"finding_id": "BFLA-R-1-1", "category": "bfla", "status": "confirmed",
258
+ "location": {"file": "routes.js", "start_line": 1}, "evidence": []}]})
259
+ write_json(out / "routes" / "routes-001.json", {"routes": [
260
+ {"route_id": "R-1", "handler": {"file": "admin.js", "line": 8}},
261
+ {"route_id": "R-2", "handler": {"file": "admin.js", "line": 12}}]})
262
+ write_json(out / "mass-assignment.candidates.json", {"candidates": [
263
+ {"candidate_id": "M-1", "file": "p.php", "line": 6}]})
264
+ write_json(out / "findings" / "priv-privilege-001.json", {
265
+ "privilege_route_verdicts": [{"route_id": "R-1", "verdict": "vulnerable"}],
266
+ "candidate_verdicts": [{"candidate_id": "M-1", "verdict": "needs_review"}], "findings": []})
267
+ res = subprocess.run([sys.executable, str(SCORE), "--verdicts", str(truth), str(out)], capture_output=True, text=True)
268
+ report = json.loads(res.stdout)
269
+ self.assertEqual(report["per_category"]["bfla"]["found"], 0)
270
+ self.assertEqual(report["verdict_level"]["bfla"], {"truth_vulnerable": 1, "found": 1, "recall": 1.0,
271
+ "flagged_safe": [], "missed": []})
272
+ self.assertEqual(report["verdict_level"]["mass_assignment"]["found"], 1)
273
+ self.assertEqual(report["verdict_level"]["mass_assignment"]["flagged_safe"], [])
274
+
245
275
 
246
276
  if __name__ == "__main__":
247
277
  unittest.main()
@@ -0,0 +1,130 @@
1
+ """Anchor model-written finding locations to the Tier 1 routes and candidates they refer to (#319).
2
+
3
+ Small local models judge the right route or candidate but often write a wrong or non-existent path
4
+ into the finding (Evaluation Run 4: `routes.js`, `app.py`, `dotnet-mvc/...`). Tier 1 already knows
5
+ where every route handler and query / binding candidate is, so the location comes from there.
6
+
7
+ Rules:
8
+ - route findings (`route_id`): the file is the route handler's file; the model's line is kept when
9
+ it falls inside the handler span (handler line up to the next route in the same file, or the end
10
+ of the file), otherwise the handler line is used.
11
+ - candidate findings (`candidate_id`): file and line are the candidate's.
12
+ - evidence entries are re-pointed the same way; entries whose file does not exist and cannot be
13
+ re-pointed are dropped.
14
+ - findings with an unknown `route_id` / `candidate_id` keep their location, get `confidence: low`
15
+ and are reported as unanchored.
16
+
17
+ Standard-library only (Python 3.8+ compatible).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+
27
+ CANDIDATE_FILES = ("injection.candidates.json", "mass-assignment.candidates.json")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Anchor:
32
+ file: str
33
+ line: int
34
+ end_line: Optional[int] # last line of the handler span; None for an exact candidate site
35
+
36
+
37
+ @dataclass
38
+ class AnchorReport:
39
+ moved: List[str] = field(default_factory=list)
40
+ unanchored: List[str] = field(default_factory=list)
41
+
42
+
43
+ def _read(path: Path) -> Dict[str, Any]:
44
+ try:
45
+ doc = json.loads(path.read_text(encoding="utf-8"))
46
+ return doc if isinstance(doc, dict) else {}
47
+ except (OSError, ValueError):
48
+ return {}
49
+
50
+
51
+ def _line_count(target_dir: Path, rel_file: str) -> int:
52
+ try:
53
+ return len((target_dir / rel_file).read_text(encoding="utf-8", errors="replace").splitlines())
54
+ except OSError:
55
+ return 0
56
+
57
+
58
+ def load_anchors(out_dir: Path, target_dir: Path) -> Dict[str, Anchor]:
59
+ """route_id / candidate_id -> Anchor, from OUT/routes/*.json and the Tier 1 candidate files."""
60
+ handlers: List[Tuple[str, int, str]] = []
61
+ for path in sorted((out_dir / "routes").glob("*.json")):
62
+ for route in _read(path).get("routes") or []:
63
+ if not isinstance(route, dict):
64
+ continue
65
+ handler = route.get("handler") or {}
66
+ if route.get("route_id") and handler.get("file") and isinstance(handler.get("line"), int):
67
+ handlers.append((handler["file"], handler["line"], route["route_id"]))
68
+
69
+ anchors: Dict[str, Anchor] = {}
70
+ for rel_file, line, route_id in handlers:
71
+ later = sorted(ln for f, ln, _ in handlers if f == rel_file and ln > line)
72
+ end = later[0] - 1 if later else max(_line_count(target_dir, rel_file), line)
73
+ anchors[route_id] = Anchor(rel_file, line, end)
74
+
75
+ for name in CANDIDATE_FILES:
76
+ for cand in _read(out_dir / name).get("candidates") or []:
77
+ if isinstance(cand, dict) and cand.get("candidate_id") and cand.get("file") and isinstance(cand.get("line"), int):
78
+ anchors[cand["candidate_id"]] = Anchor(cand["file"], cand["line"], None)
79
+ return anchors
80
+
81
+
82
+ def _anchored_line(anchor: Anchor, line: Any) -> int:
83
+ if anchor.end_line is None or not isinstance(line, int):
84
+ return anchor.line
85
+ return line if anchor.line <= line <= anchor.end_line else anchor.line
86
+
87
+
88
+ def _repoint_evidence(evidence: Any, anchor: Anchor, target_dir: Path) -> List[Dict[str, Any]]:
89
+ kept = []
90
+ for ev in evidence if isinstance(evidence, list) else []:
91
+ if not isinstance(ev, dict):
92
+ continue
93
+ rel = str(ev.get("file") or "")
94
+ if rel == anchor.file or (rel and (target_dir / rel).is_file()):
95
+ kept.append(ev)
96
+ elif anchor.end_line is not None and isinstance(ev.get("line"), int) and anchor.line <= ev["line"] <= anchor.end_line:
97
+ kept.append({**ev, "file": anchor.file})
98
+ return kept or [{"file": anchor.file, "line": anchor.line, "why": "Location taken from the Tier 1 inventory"}]
99
+
100
+
101
+ def anchor_finding(finding: Dict[str, Any], anchors: Dict[str, Anchor], target_dir: Path) -> Tuple[Dict[str, Any], Optional[bool]]:
102
+ """Return (finding with Tier 1 location, moved?); moved is None when the finding has no known anchor."""
103
+ key = finding.get("route_id") or finding.get("candidate_id")
104
+ anchor = anchors.get(key) if key else None
105
+ if anchor is None:
106
+ return {**finding, "confidence": "low"}, None
107
+ loc = finding.get("location") if isinstance(finding.get("location"), dict) else {}
108
+ # a wrong path with a line inside the handler span is most likely the right line in the wrong file
109
+ new_loc = {**loc, "file": anchor.file, "start_line": _anchored_line(anchor, loc.get("start_line"))}
110
+ if anchor.end_line is None:
111
+ new_loc.pop("end_line", None)
112
+ moved = (loc.get("file"), loc.get("start_line")) != (new_loc["file"], new_loc["start_line"])
113
+ evidence = _repoint_evidence(finding.get("evidence"), anchor, target_dir)
114
+ return {**finding, "location": new_loc, "evidence": evidence}, moved
115
+
116
+
117
+ def anchor_findings(findings: List[Dict[str, Any]], anchors: Dict[str, Anchor], target_dir: Path,
118
+ report: AnchorReport) -> List[Dict[str, Any]]:
119
+ out = []
120
+ for f in findings:
121
+ if not isinstance(f, dict):
122
+ out.append(f)
123
+ continue
124
+ anchored, moved = anchor_finding(f, anchors, target_dir)
125
+ if moved is None:
126
+ report.unanchored.append(str(f.get("finding_id")))
127
+ elif moved:
128
+ report.moved.append(str(f.get("finding_id")))
129
+ out.append(anchored)
130
+ return out
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import json
9
9
  import re
10
+ import time
10
11
  import urllib.error
11
12
  import urllib.request
12
13
  from typing import Any, Dict, List, Optional
@@ -30,6 +31,8 @@ class LLMClient:
30
31
  self.model = model
31
32
  self.api_key = api_key or "ollama"
32
33
  self.timeout = timeout
34
+ # Running totals across chat() calls, read by the runner for ai-run-stats.json
35
+ self.usage: Dict[str, float] = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "seconds": 0.0}
33
36
 
34
37
  def chat(
35
38
  self,
@@ -54,10 +57,12 @@ class LLMClient:
54
57
  }
55
58
 
56
59
  req = urllib.request.Request(self.endpoint, data=data, headers=headers, method="POST")
60
+ started = time.monotonic()
57
61
  try:
58
62
  with urllib.request.urlopen(req, timeout=self.timeout) as resp:
59
63
  body = resp.read().decode("utf-8")
60
64
  res_json = json.loads(body)
65
+ self._record_usage(res_json.get("usage") or {}, time.monotonic() - started)
61
66
  choices = res_json.get("choices", [])
62
67
  if not choices:
63
68
  raise RuntimeError(f"LLM API returned no choices: {body}")
@@ -69,6 +74,14 @@ class LLMClient:
69
74
  except urllib.error.URLError as e:
70
75
  raise RuntimeError(f"Failed to connect to LLM endpoint at {self.endpoint}: {e.reason}") from e
71
76
 
77
+ def _record_usage(self, usage: Dict[str, Any], seconds: float) -> None:
78
+ self.usage = {
79
+ "calls": self.usage["calls"] + 1,
80
+ "prompt_tokens": self.usage["prompt_tokens"] + int(usage.get("prompt_tokens") or 0),
81
+ "completion_tokens": self.usage["completion_tokens"] + int(usage.get("completion_tokens") or 0),
82
+ "seconds": round(self.usage["seconds"] + seconds, 3),
83
+ }
84
+
72
85
  @staticmethod
73
86
  def extract_json(content: str) -> Any:
74
87
  """Extract and parse JSON from Markdown code blocks or raw text string."""
@@ -0,0 +1,105 @@
1
+ """Code context for the headless agents: snippets, server-side includes and bound model classes.
2
+
3
+ Standard-library only (Python 3.8+ compatible).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+
13
+ def _read_snippet(target_dir: Path, rel_file: str, line: int, radius: int = 15) -> str:
14
+ """Read a window of code around target line from the audited repository."""
15
+ full_path = target_dir / rel_file
16
+ if not full_path.is_file():
17
+ return f"[File not found: {rel_file}]"
18
+ try:
19
+ lines = full_path.read_text(encoding="utf-8", errors="replace").splitlines()
20
+ start = max(0, line - radius - 1)
21
+ end = min(len(lines), line + radius)
22
+ numbered = [f"{i + 1:4d} | {lines[i]}" for i in range(start, end)]
23
+ return "\n".join(numbered)
24
+ except Exception as e:
25
+ return f"[Error reading {rel_file}: {e}]"
26
+
27
+
28
+ # Server-side includes whose content a verifier must see to judge a control (PHP, Classic ASP, ColdFusion)
29
+ _INCLUDE_RE = re.compile(
30
+ r"(?:require|include)(?:_once)?\s*\(?\s*['\"]([^'\"]+)['\"]"
31
+ r"|<!--\s*#include\s+(?:file|virtual)\s*=\s*\"([^\"]+)\""
32
+ r"|<cfinclude\s+template\s*=\s*\"([^\"]+)\"",
33
+ re.IGNORECASE,
34
+ )
35
+
36
+
37
+ def _included_files(target_dir: Path, rel_file: str) -> List[str]:
38
+ """Repository-relative paths of files that rel_file includes, if they exist."""
39
+ target_dir = target_dir.resolve()
40
+ path = target_dir / rel_file
41
+ if not path.is_file():
42
+ return []
43
+ text = path.read_text(encoding="utf-8", errors="replace")
44
+ found: List[str] = []
45
+ for m in _INCLUDE_RE.finditer(text):
46
+ ref = next(g for g in m.groups() if g)
47
+ candidate = (path.parent / ref).resolve()
48
+ try:
49
+ rel = str(candidate.relative_to(target_dir))
50
+ except ValueError:
51
+ continue
52
+ if candidate.is_file() and rel not in found:
53
+ found.append(rel)
54
+ return found
55
+
56
+
57
+ _MODEL_EXTENSIONS = (".java", ".cs", ".vb", ".php", ".py", ".js", ".ts", ".rb", ".cfc")
58
+ _NEW_OBJECT_RE = re.compile(r"\bnew\s+([A-Z][A-Za-z0-9_]*)\s*\(")
59
+
60
+
61
+ def _model_source(target_dir: Path, name: Optional[str], near_file: str = "", near_line: int = 1) -> Optional[Dict[str, Any]]:
62
+ """Definition of the bound model class, so the privilege auditor can see its fields (#319).
63
+
64
+ name is the Tier 1 binding target; when it is unknown, the first `new Foo(` near the binding is used.
65
+ """
66
+ if not name and near_file:
67
+ m = _NEW_OBJECT_RE.search(_read_snippet(target_dir, near_file, near_line, radius=4))
68
+ name = m.group(1) if m else None
69
+ if not name or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
70
+ return None
71
+ class_re = re.compile(rf"\bclass\s+{name}\b")
72
+ # same application and language as the binding: a Node route must not pick up a PHP model of the same name
73
+ suffix = Path(near_file).suffix.lower()
74
+ suffixes = (suffix,) if suffix in _MODEL_EXTENSIONS else _MODEL_EXTENSIONS
75
+ parts = Path(near_file).parts
76
+ root = target_dir / parts[0] if len(parts) > 1 and (target_dir / parts[0]).is_dir() else target_dir
77
+ for path in sorted(root.rglob("*")):
78
+ if path.suffix.lower() not in suffixes or not path.is_file():
79
+ continue
80
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
81
+ for i, text in enumerate(lines):
82
+ if class_re.search(text):
83
+ rel = str(path.relative_to(target_dir))
84
+ return {"class": name, "file": rel, "code": _read_snippet(target_dir, rel, i + 16, radius=15)}
85
+ return {"class": name, "file": None, "code": "[definition not found in the repository]"}
86
+
87
+
88
+ def _verification_context(target_dir: Path, finding: Dict[str, Any]) -> Dict[str, Any]:
89
+ """Code the verifier needs: the finding's site, its file header, evidence lines and included files."""
90
+ loc = finding.get("location") or {}
91
+ rel = str(loc.get("file") or "")
92
+ line = int(loc.get("start_line") or 1)
93
+ ctx: Dict[str, Any] = {"location": _read_snippet(target_dir, rel, line, radius=20)}
94
+ if line > 40:
95
+ ctx["file_header"] = _read_snippet(target_dir, rel, 13, radius=12)
96
+ others = []
97
+ for ev in finding.get("evidence") or []:
98
+ if isinstance(ev, dict) and ev.get("file") and ev.get("file") != rel:
99
+ others.append({"file": ev["file"], "code": _read_snippet(target_dir, str(ev["file"]), int(ev.get("line") or 1), radius=6)})
100
+ if others:
101
+ ctx["evidence"] = others[:3]
102
+ includes = [{"file": inc, "code": _read_snippet(target_dir, inc, 30, radius=30)} for inc in _included_files(target_dir, rel)[:3]]
103
+ if includes:
104
+ ctx["included_files"] = includes
105
+ return ctx