@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.
- package/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/assess/lsa/examples/eval-run-4/ground-truth.json +823 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/ai-run-stats.json +58 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/coverage.json +85 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-001.json +822 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings/priv-privilege-002.json +393 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/findings.json +4477 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-4/iter0-gptoss-a/score.json +120 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/ai-run-stats.json +55 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/coverage.json +50 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-001.json +613 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings/priv-privilege-002.json +287 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/findings.json +4870 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-a/score.json +107 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/ai-run-stats.json +58 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/coverage.json +92 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-001.json +645 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings/priv-privilege-002.json +222 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/findings.json +4859 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-4/iter1-gptoss-b/score.json +121 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/coverage.json +111 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-001.json +6 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings/priv-privilege-002.json +344 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/findings.json +3544 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-4/iter1-qwen-a-incomplete/score.json +123 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/ai-run-stats.json +50 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/coverage.json +42 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-001.json +540 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings/priv-privilege-002.json +238 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.json +4383 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/findings.sarif +8729 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.ja.md +1911 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/report.md +1911 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/score.json +101 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-a/stack-profile.json +226 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/ai-run-stats.json +54 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/coverage.json +66 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-001.json +440 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings/priv-privilege-002.json +229 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/findings.json +3424 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-4/iter2-gptoss-b/score.json +120 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/ai-run-stats.json +88 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/coverage.json +36 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-001.json +530 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/findings/priv-privilege-002.json +248 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/findings.json +4766 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/findings.sarif +9326 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/report.ja.md +2011 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/report.md +2011 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/score.json +80 -0
- package/assess/lsa/examples/eval-run-5/gptoss-a/stack-profile.json +226 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/ai-run-stats.json +83 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/coverage.json +44 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-001.json +579 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/findings/priv-privilege-002.json +268 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/findings.json +3913 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/mass-assignment.candidates.json +232 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-001.json +425 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-002.json +333 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-003.json +400 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-004.json +403 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-005.json +398 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/routes/routes-006.json +63 -0
- package/assess/lsa/examples/eval-run-5/gptoss-b/score.json +94 -0
- package/assess/lsa/examples/eval-run-5/ground-truth.json +823 -0
- package/assess/lsa/knowledge/injection-patterns.md +8 -1
- package/assess/lsa/knowledge/stacks/nextjs.md +62 -0
- package/assess/lsa/prompts/35-privilege-auditor.md +3 -1
- package/assess/lsa/prompts/_shared-rules.md +1 -0
- package/assess/lsa/scripts/detect_stack.py +5 -1
- package/assess/lsa/scripts/find_injection.py +34 -5
- package/assess/lsa/scripts/lsa_common.py +9 -2
- package/assess/lsa/scripts/make_batches.py +2 -1
- package/assess/lsa/scripts/map_routes.py +10 -2
- package/assess/lsa/scripts/nextjs_routes.py +236 -0
- package/assess/lsa/templates/audit-config.json +1 -1
- package/assess/lsa/tests/EVAL-RESULTS.md +156 -0
- package/assess/lsa/tests/score_findings.py +58 -0
- package/assess/lsa/tests/test_default_scope.py +86 -0
- package/assess/lsa/tests/test_eval_run_4.py +73 -0
- package/assess/lsa/tests/test_eval_run_5.py +73 -0
- package/assess/lsa/tests/test_js_tagged_sql.py +112 -0
- package/assess/lsa/tests/test_nextjs.py +198 -0
- package/assess/lsa/tests/test_privilege_tier2.py +30 -0
- package/operate/ai/anchoring.py +130 -0
- package/operate/ai/client.py +13 -0
- package/operate/ai/context.py +105 -0
- package/operate/ai/runner.py +197 -69
- package/operate/cli/__init__.py +1 -1
- package/operate/cli/commands/scan.py +1 -1
- package/operate/tests/test_ai_anchoring.py +184 -0
- package/operate/tests/test_headless_ai.py +148 -1
- 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
|
package/operate/ai/client.py
CHANGED
|
@@ -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
|