@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
|
@@ -152,3 +152,159 @@ Based on Qwen 3.5's findings, the following concrete improvements were implement
|
|
|
152
152
|
| **False Positives on Safe Code** | 0 | 0 | 0 on safe markers | **0** |
|
|
153
153
|
| **Precision (matched / active)** | 0.965 (55 / 57) | 0.965 (55 / 57) | 0.966 | **1.000 (57 / 57)** |
|
|
154
154
|
| **Legacy Stack Shims** | None | None | Recommended | **4 Stacks (`remediate/legacy_shims/`)** |
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Run 4 — 2026-10-31, kit 0.3.1 (BFLA & mass assignment Tier 2, local Ollama)
|
|
159
|
+
|
|
160
|
+
Issue #286. First end-to-end measurement of phase 4c (`35-privilege-auditor`) with real models, and a
|
|
161
|
+
re-check of BOLA / injection on the same harness. All runs are **local only**: fixture code never left
|
|
162
|
+
the machine, and no third-party LLM was used.
|
|
163
|
+
|
|
164
|
+
### Setup
|
|
165
|
+
|
|
166
|
+
| Item | Value |
|
|
167
|
+
|---|---|
|
|
168
|
+
| Eval repo | `tests/build_eval_repo.py` (10 fixture apps, markers stripped); ground truth 117 items: 25 BOLA, 34 SQL/NoSQL injection, 10 BFLA, 15 mass assignment, plus 33 SAFE markers |
|
|
169
|
+
| Pipeline | `komagu scan <repo> --no-cache --headless-ai http://localhost:11434/v1 --model <model> --ai-timeout 3600` (`operate/ai/runner.py`, Tier 1 pre-scan + phases 2–6) |
|
|
170
|
+
| Hosting | Ollama 0.40.13 on an Apple M4 Pro, 24 GB RAM, 100% GPU; context 32,768 tokens |
|
|
171
|
+
| Models | `gpt-oss:20b` (MXFP4, reasoning effort default `medium`), `qwen3.5:9b` |
|
|
172
|
+
| Scoring | `tests/score_findings.py --verdicts` (tolerance 3 lines); archived per run in [`assess/lsa/examples/eval-run-4/`](../examples/eval-run-4/) |
|
|
173
|
+
|
|
174
|
+
**This is a floor, not a ceiling.** A 9–20B model on a laptop is much weaker than the models used in
|
|
175
|
+
Runs 1–2. The numbers below say what a fully local deployment achieves today, not what the prompts can
|
|
176
|
+
do with a stronger model.
|
|
177
|
+
|
|
178
|
+
**Not comparable with Run 3.** Run 3 does not record its model, and the archived Run 1 / Run 2 / Qwen
|
|
179
|
+
numbers were produced through agent runtimes that can open files, not through the headless runner.
|
|
180
|
+
The BOLA / injection columns below are therefore a new baseline for the headless harness, not a
|
|
181
|
+
regression check against Run 3.
|
|
182
|
+
|
|
183
|
+
### Harness fixes found by this run
|
|
184
|
+
|
|
185
|
+
Measuring with real models exposed defects in the headless runner that inflated or lost results.
|
|
186
|
+
They were fixed before the numbers were trusted, in two iterations (the limit set in #286):
|
|
187
|
+
|
|
188
|
+
| Iteration | Fix | Effect seen |
|
|
189
|
+
|---|---|---|
|
|
190
|
+
| 0 → 1 | Verifier no longer marks a whole batch `confirmed` when its reply cannot be parsed (it used to, with a made-up "consensus" note); such findings stay `unverified` | Results stopped being inflated by broken replies |
|
|
191
|
+
| 0 → 1 | A failed call (timeout) is recorded per batch in `ai-run-stats.json` instead of aborting the run | A 25-candidate injection batch took > 15 min and used to kill the run |
|
|
192
|
+
| 0 → 1 | Phase 4c gets the same JSON output template as 4a / 4b; sanitizer joins list `impact` values and moves a verdict written into `subtype` to `status`; unparseable replies saved to `OUT/ai-raw/` | Mass assignment findings stopped failing schema validation |
|
|
193
|
+
| 1 → 2 | Verifier now receives the code it judges (finding site, file header, evidence lines, server-side includes) and the auth model, and may only cite controls that are visible; otherwise `likely` + `code_not_visible` | In iteration 1 it rejected 10 of 11 real BFLA findings by inventing an admin base class and a role check in a login-only `auth.php` |
|
|
194
|
+
| 1 → 2 | Verifier batches of 5 instead of 10; sanitizer fills a `null` evidence line and a missing `status` | No verifier parse failures in iteration 2 |
|
|
195
|
+
|
|
196
|
+
### Results after verification
|
|
197
|
+
|
|
198
|
+
"Marker" columns use the normal location match. "Verdict" columns (BFLA / mass assignment only) count a
|
|
199
|
+
truth item as found when the privilege auditor judged the route or candidate that contains it
|
|
200
|
+
`vulnerable` or `needs_review`, whatever path and line the model then wrote into the finding.
|
|
201
|
+
|
|
202
|
+
| Run | BOLA | Injection | BFLA marker | Mass marker | BFLA verdict | Mass verdict | FP on safe code | Precision | Rejected | Unverified | Parse fail | Min · calls · tokens |
|
|
203
|
+
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
204
|
+
| iter 0, gpt-oss A | 19/25 | 20/34 | 8/10 | 2/15 | 10/10 | 12/15 | 0 | 0.689 | 10 | 11 | 3 | 81 · 22 · 311K |
|
|
205
|
+
| iter 1, gpt-oss A | 21/25 | 34/34 | 1/10 | 2/15 | 10/10 | 14/15 | 0 | 0.765 | 19 | 17 | 2 | 75 · 21 · 288K |
|
|
206
|
+
| iter 1, gpt-oss B | 19/25 | 28/34 | 5/10 | 5/15 | 10/10 | 14/15 | 1 | 0.667 | 4 | 8 | 3 | 80 · 22 · 273K |
|
|
207
|
+
| iter 1, qwen3.5 (incomplete) | 23/25 | 33/34 | 0/10 | 0/15 | 0/10 | 8/15 | 1 | 0.778 | 0 | 52 | — | stopped after 2 h 50 min |
|
|
208
|
+
| **iter 2, gpt-oss A** | **24/25** | **34/34** | **8/10** | **6/15** | **10/10** | **10/15** | **0** | 0.729 | 1 | 0 | 0 | 57 · 32 · 309K |
|
|
209
|
+
| **iter 2, gpt-oss B** | **11/25** | **32/34** | **5/10** | **4/15** | **10/10** | **9/15** | **0** | 0.693 | 1 | 0 | 1 | 56 · 29 · 318K |
|
|
210
|
+
|
|
211
|
+
Final harness = iteration 2. Stated plainly for `gpt-oss:20b` on it:
|
|
212
|
+
|
|
213
|
+
- **BFLA:** the auditor flagged the right route for **10 / 10** BFLA markers in both runs, with no SAFE-BFLA
|
|
214
|
+
route flagged. Only **8 / 10 and 5 / 10** survive as correctly located findings, because the model
|
|
215
|
+
writes wrong or non-existent paths into the finding (`dotnet-mvc/Controllers/AdminController.cs`,
|
|
216
|
+
`routes.js`).
|
|
217
|
+
- **Mass assignment:** **10 / 15 and 9 / 15** at verdict level, **6 / 15 and 4 / 15** as located findings.
|
|
218
|
+
Five candidates in .NET (`CustomerController.cs`) and Java (`AccountController.java`,
|
|
219
|
+
`ProfileServlet.java`) were judged `safe` in both runs: real model misses.
|
|
220
|
+
- **False positives on SAFE markers:** **0** in both iteration-2 runs, for all four categories.
|
|
221
|
+
- **BOLA / injection:** 24 / 25 and 34 / 34 in run A. In run B, 11 of its 14 missed BOLA items sit in
|
|
222
|
+
one batch whose reply was empty (`routes-001`: `gpt-oss` returned no content, so 15 routes got no
|
|
223
|
+
verdict); the other 3 are real misses (`perl-cgi/cgi-bin/order.cgi:19` is missed in both runs).
|
|
224
|
+
- **Run-to-run variance is large.** Two runs with the same model, harness and temperature 0.1 differ by
|
|
225
|
+
up to 13 BOLA items and 3 BFLA findings. Single-run numbers from a local model should not be quoted
|
|
226
|
+
as product capability.
|
|
227
|
+
- **Precision ~0.7** is measured against markers only. Of the ~25 unmatched findings per run, most are BOLA
|
|
228
|
+
findings in files that carry BFLA / mass-assignment markers but no BOLA markers
|
|
229
|
+
(`CustomerController.cs`, `AccountController.java`, `AdminController.cs`), plus the misplaced BFLA /
|
|
230
|
+
mass findings above. They have not been triaged by a person and are not counted as false positives.
|
|
231
|
+
|
|
232
|
+
**qwen3.5:9b** could not be measured on this machine: with a 32K context the MLX runner used 22 GB of the
|
|
233
|
+
24 GB RAM, the system swapped (29.7 / 30 GB), and two calls hit the 1-hour timeout (the privilege batch
|
|
234
|
+
holding all BFLA routes, and a verifier batch). The run was stopped after 2 h 50 min with 52 findings
|
|
235
|
+
unverified. Its partial output is archived as `iter1-qwen-a-incomplete/` and says nothing about the
|
|
236
|
+
model's ability.
|
|
237
|
+
|
|
238
|
+
### Known gaps (follow-up: #319)
|
|
239
|
+
|
|
240
|
+
1. **Misplaced findings.** Take `file` / `line` of BFLA and mass assignment findings from the Tier 1 route
|
|
241
|
+
or candidate the auditor judged (`route_id` / `candidate_id`), not from the model's text. Expected to
|
|
242
|
+
close most of the gap between the marker and verdict columns.
|
|
243
|
+
2. **Empty replies.** Retry a batch once when the reply is empty or unparseable (seen with `gpt-oss`).
|
|
244
|
+
3. **Mass assignment on .NET / Java binding.** Review `knowledge/mass-assignment-patterns.md` for
|
|
245
|
+
`UpdateModel` / `TryUpdateModel` and Spring `@ModelAttribute` binding into entities.
|
|
246
|
+
4. **Re-run qwen3.5:9b** with 16K context or on a machine with more memory.
|
|
247
|
+
5. **Triage unmatched BOLA findings** and add markers to the fixtures where they are real.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Run 5 — 2026-10-31, kit 0.3.1 (Run 4 follow-up: anchored locations, retries, model context)
|
|
252
|
+
|
|
253
|
+
Issue #319. Same eval repo, ground truth, hosting and model as Run 4 (`gpt-oss:20b`, Ollama 0.40.13,
|
|
254
|
+
M4 Pro 24 GB, 32K context, local only). Outputs are archived in
|
|
255
|
+
[`assess/lsa/examples/eval-run-5/`](../examples/eval-run-5/).
|
|
256
|
+
|
|
257
|
+
### Changes since Run 4 iteration 2
|
|
258
|
+
|
|
259
|
+
| Change | Why (from Run 4) |
|
|
260
|
+
|---|---|
|
|
261
|
+
| Finding `file` / `line` come from the Tier 1 route handler span or candidate the finding references (`operate/ai/anchoring.py`); evidence is re-pointed or dropped; unknown references get `confidence: low` | The auditor judged the right route for 10 / 10 BFLA markers, but wrote paths such as `routes.js`, `app.py`, `dotnet-mvc/...` |
|
|
262
|
+
| An empty or unparseable batch reply is retried once with a JSON-only reminder | One empty reply cost 15 BOLA routes |
|
|
263
|
+
| Each mass assignment candidate carries the bound model's definition (same app and language); an unknown model means `needs_review`, never `safe` (prompt `35-privilege-auditor.md` and the headless 4c prompt) | Five .NET / Java candidates were judged `safe` in both runs because the auditor never saw `Account.java` (`role`, `balance`) |
|
|
264
|
+
|
|
265
|
+
### Results after verification
|
|
266
|
+
|
|
267
|
+
| Run | BOLA | Injection | BFLA marker | Mass marker | BFLA verdict | Mass verdict | FP on safe code | Precision | Rejected | Unverified | Retries · parse fail | Min · calls · tokens |
|
|
268
|
+
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
269
|
+
| Run 4 iter 2, A | 24/25 | 34/34 | 8/10 | 6/15 | 10/10 | 10/15 | 0 | 0.729 | 1 | 0 | — · 0 | 57 · 32 · 309K |
|
|
270
|
+
| Run 4 iter 2, B | 11/25 | 32/34 | 5/10 | 4/15 | 10/10 | 9/15 | 0 | 0.693 | 1 | 0 | — · 1 | 56 · 29 · 318K |
|
|
271
|
+
| **Run 5, A** | **24/25** | **34/34** | **10/10** | **10/15** | **10/10** | **15/15** | **0** | **0.818** | 2 | 0 | 0 · 0 | 83 · 32 · 328K |
|
|
272
|
+
| **Run 5, B** | **12/25** | **32/34** | **9/10** | **14/15** | **10/10** | **15/15** | **0** | **0.779** | 3 | 0 | 1 · 1 | 86 · 31 · 369K |
|
|
273
|
+
|
|
274
|
+
- **BFLA:** 10 / 10 and 9 / 10 as located findings (from 8 and 5). Every anchored finding pointed at a
|
|
275
|
+
known route or candidate (0 unanchored).
|
|
276
|
+
- **Mass assignment:** the auditor flagged **15 / 15** candidates in both runs (from 10 and 9), including the
|
|
277
|
+
five .NET / Java bindings. As located findings 10 / 15 and 14 / 15: in run A the model gave a
|
|
278
|
+
`vulnerable` verdict for four candidates (`php-plain/profile.php` ×2, `CustomerController.cs` ×2)
|
|
279
|
+
without writing a finding for them.
|
|
280
|
+
- **`php-plain/register.php:4` (MASS marker)** was rejected by the verifier in both runs: `extract($_POST)`
|
|
281
|
+
is followed by an INSERT of only `email` and `name`. The argument is sound for this fixture as written,
|
|
282
|
+
so the marker should be reviewed rather than counted as a verifier error.
|
|
283
|
+
- **BOLA in run B (12 / 25)** again lost batch `routes-001` (15 routes). This time the reply was not empty
|
|
284
|
+
but invalid JSON: the model put code with unescaped `"` into `recommendation.code_before`; the retry
|
|
285
|
+
failed the same way. The same batch failed in Run 4 run B.
|
|
286
|
+
- **No false positives** on SAFE markers in either run; precision rose to 0.82 / 0.78.
|
|
287
|
+
- Runs are ~45% slower than Run 4 iteration 2 (83–86 min): the privilege and verifier prompts are longer
|
|
288
|
+
and there are more findings to verify.
|
|
289
|
+
|
|
290
|
+
### Unmatched BOLA findings (triage, not yet reflected in fixtures)
|
|
291
|
+
|
|
292
|
+
About 20 BOLA findings per run sit in files with no BOLA marker. By route:
|
|
293
|
+
|
|
294
|
+
| Route(s) | Assessment |
|
|
295
|
+
|---|---|
|
|
296
|
+
| `Customer/EditName`, `account/rename`, `python-flask` `/users/<uid>/name`, `account/profile` | Likely **real BOLA** not marked: the record ID comes from the request and is not bound to the caller |
|
|
297
|
+
| `Customer/Edit`, `Customer/Refresh`, `account/save`, `account/api`, `/users/<uid>` | Mass-assignment fixtures that also take the ID from the request: plausibly BOLA as well |
|
|
298
|
+
| `Admin/DeleteUser`, `Admin/Export`, `admin/users/{id}/delete`, `delete_user.php`, `promote.php`, `manage_users.php` | **BFLA routes also reported as BOLA**: same flaw seen from a second category; duplicate rather than wrong |
|
|
299
|
+
|
|
300
|
+
Fixture markers were left unchanged so Run 4 and Run 5 share the same ground truth.
|
|
301
|
+
|
|
302
|
+
### Remaining gaps
|
|
303
|
+
|
|
304
|
+
1. **Invalid JSON from code fields:** drop `code_before` / `code_after` from the 4a / 4b output templates
|
|
305
|
+
(the sanitizer already reduces `recommendation` to a string), or repair unescaped quotes before parsing.
|
|
306
|
+
2. **Verdict without finding:** write a minimal finding for every `vulnerable` / `needs_review` verdict
|
|
307
|
+
the model did not turn into one.
|
|
308
|
+
3. **Fixtures:** review the `register.php` MASS marker; add BOLA markers for the routes judged real above.
|
|
309
|
+
4. **qwen3.5:9b** still unmeasured: 32K context does not fit in 24 GB next to the OS; needs 16K context or
|
|
310
|
+
a larger machine.
|
|
@@ -7,13 +7,20 @@ A vulnerable truth item is found (TP) when an active finding (status != rejected
|
|
|
7
7
|
same category (sql_injection and nosql_injection are treated as one group), and the truth
|
|
8
8
|
line is within --tolerance lines of the finding location or of any evidence line in the same file.
|
|
9
9
|
A safe truth item hit by an active finding counts as a false positive (FP-safe).
|
|
10
|
+
|
|
11
|
+
--verdicts adds a verdict-level score for bfla and mass_assignment: a truth item counts as found when
|
|
12
|
+
the privilege auditor judged the route or candidate that contains it "vulnerable" or "needs_review",
|
|
13
|
+
whatever file and line the model then wrote into the finding. Routes cover their handler line up to
|
|
14
|
+
the next inventoried route in the same file; mass assignment candidates match their exact line.
|
|
10
15
|
"""
|
|
11
16
|
from __future__ import annotations
|
|
12
17
|
|
|
13
18
|
import argparse
|
|
19
|
+
import glob
|
|
14
20
|
import json
|
|
15
21
|
import sys
|
|
16
22
|
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
17
24
|
|
|
18
25
|
|
|
19
26
|
def group(cat: str) -> str:
|
|
@@ -32,11 +39,60 @@ def hits(finding: dict, item: dict, tol: int) -> bool:
|
|
|
32
39
|
return False
|
|
33
40
|
|
|
34
41
|
|
|
42
|
+
FLAGGED = ("vulnerable", "needs_review")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _same_file(a: str, b: str) -> bool:
|
|
46
|
+
return bool(a) and bool(b) and (a.endswith(b) or b.endswith(a))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _route_for(item: dict, routes: List[Tuple[str, int, str]]) -> Optional[str]:
|
|
50
|
+
"""route_id whose handler span (handler line .. next route in the file) contains the truth line."""
|
|
51
|
+
in_file = sorted((line, rid) for f, line, rid in routes if _same_file(f, item["file"]) and line <= item["line"])
|
|
52
|
+
return in_file[-1][1] if in_file else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verdict_scores(out: Path, truth: List[dict]) -> Dict[str, dict]:
|
|
56
|
+
routes: List[Tuple[str, int, str]] = []
|
|
57
|
+
for p in glob.glob(str(out / "routes" / "*.json")):
|
|
58
|
+
for r in json.loads(Path(p).read_text()).get("routes", []):
|
|
59
|
+
h = r.get("handler") or {}
|
|
60
|
+
if h.get("file") and isinstance(h.get("line"), int):
|
|
61
|
+
routes.append((h["file"], h["line"], r.get("route_id")))
|
|
62
|
+
cands_path = out / "mass-assignment.candidates.json"
|
|
63
|
+
cands = json.loads(cands_path.read_text()).get("candidates", []) if cands_path.is_file() else []
|
|
64
|
+
route_v: Dict[str, str] = {}
|
|
65
|
+
cand_v: Dict[str, str] = {}
|
|
66
|
+
for p in glob.glob(str(out / "findings" / "priv-*.json")):
|
|
67
|
+
doc = json.loads(Path(p).read_text())
|
|
68
|
+
route_v.update({v.get("route_id"): v.get("verdict") for v in doc.get("privilege_route_verdicts", []) if isinstance(v, dict)})
|
|
69
|
+
cand_v.update({v.get("candidate_id"): v.get("verdict") for v in doc.get("candidate_verdicts", []) if isinstance(v, dict)})
|
|
70
|
+
|
|
71
|
+
def flagged(item: dict) -> bool:
|
|
72
|
+
if item["category"] == "bfla":
|
|
73
|
+
return route_v.get(_route_for(item, routes)) in FLAGGED
|
|
74
|
+
return any(_same_file(c.get("file", ""), item["file"]) and c.get("line") == item["line"]
|
|
75
|
+
and cand_v.get(c.get("candidate_id")) in FLAGGED for c in cands)
|
|
76
|
+
|
|
77
|
+
rows = {}
|
|
78
|
+
for cat in ("bfla", "mass_assignment"):
|
|
79
|
+
vul = [t for t in truth if t["category"] == cat and t["vulnerable"]]
|
|
80
|
+
safe = [t for t in truth if t["category"] == cat and not t["vulnerable"]]
|
|
81
|
+
if not vul:
|
|
82
|
+
continue
|
|
83
|
+
found = [t for t in vul if flagged(t)]
|
|
84
|
+
rows[cat] = {"truth_vulnerable": len(vul), "found": len(found), "recall": round(len(found) / len(vul), 3),
|
|
85
|
+
"flagged_safe": [f"{t['file']}:{t['line']}" for t in safe if flagged(t)],
|
|
86
|
+
"missed": [f"{t['file']}:{t['line']}" for t in vul if t not in found]}
|
|
87
|
+
return rows
|
|
88
|
+
|
|
89
|
+
|
|
35
90
|
def main() -> int:
|
|
36
91
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
37
92
|
ap.add_argument("ground_truth")
|
|
38
93
|
ap.add_argument("out")
|
|
39
94
|
ap.add_argument("--tolerance", type=int, default=3)
|
|
95
|
+
ap.add_argument("--verdicts", action="store_true", help="also score bfla / mass_assignment by auditor verdicts")
|
|
40
96
|
a = ap.parse_args()
|
|
41
97
|
truth = json.loads(Path(a.ground_truth).read_text())["items"]
|
|
42
98
|
# fixture paths are relative to tests/fixtures; eval repo keeps the same layout
|
|
@@ -69,6 +125,8 @@ def main() -> int:
|
|
|
69
125
|
report = {"per_category": rows, "active_findings": len(g_findings), "matched_findings": len(matched_findings),
|
|
70
126
|
"precision": precision, "unmatched_findings": unmatched,
|
|
71
127
|
"needs_review_not_scored": needs_review}
|
|
128
|
+
if a.verdicts:
|
|
129
|
+
report["verdict_level"] = verdict_scores(Path(a.out), truth)
|
|
72
130
|
print(json.dumps(report, indent=2))
|
|
73
131
|
return 0
|
|
74
132
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Default scan scope skips framework build output, caches and source maps (Issue #325)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
13
|
+
LSA_SCRIPTS = REPO_ROOT / "assess" / "lsa" / "scripts"
|
|
14
|
+
sys.path.insert(0, str(LSA_SCRIPTS))
|
|
15
|
+
|
|
16
|
+
from find_injection import find_injection # noqa: E402
|
|
17
|
+
from lsa_common import DEFAULT_CONFIG, deep_merge, iter_source_files, load_config # noqa: E402
|
|
18
|
+
|
|
19
|
+
TEMPLATE = REPO_ROOT / "assess" / "lsa" / "templates" / "audit-config.json"
|
|
20
|
+
|
|
21
|
+
VULN_JS = (
|
|
22
|
+
"const id = req.query.id;\n"
|
|
23
|
+
"db.query(`SELECT * FROM orders WHERE id = ${id}`);\n"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
GENERATED = [
|
|
27
|
+
".next/server/chunks/x.js",
|
|
28
|
+
".next/dev/server/chunks/y.js",
|
|
29
|
+
".nuxt/dist/server/a.js",
|
|
30
|
+
".output/server/index.mjs",
|
|
31
|
+
".svelte-kit/output/server/b.js",
|
|
32
|
+
".vercel/output/functions/c.js",
|
|
33
|
+
".turbo/cache/d.js",
|
|
34
|
+
"out/_next/e.js",
|
|
35
|
+
"coverage/lcov-report/f.js",
|
|
36
|
+
"test-results/g.js",
|
|
37
|
+
"playwright-report/h.js",
|
|
38
|
+
"storybook-static/i.js",
|
|
39
|
+
".venv/lib/site.py",
|
|
40
|
+
"__pycache__/m.py",
|
|
41
|
+
"src/app/page.js.map",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write(root: Path, rel: str, text: str) -> None:
|
|
46
|
+
p = root / rel
|
|
47
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
p.write_text(text, encoding="utf-8")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TestDefaultScope(unittest.TestCase):
|
|
52
|
+
def _repo(self, td: str) -> Path:
|
|
53
|
+
root = Path(td)
|
|
54
|
+
for rel in GENERATED:
|
|
55
|
+
_write(root, rel, VULN_JS)
|
|
56
|
+
_write(root, "src/lib/orders.js", VULN_JS)
|
|
57
|
+
return root
|
|
58
|
+
|
|
59
|
+
def test_generated_output_not_scanned(self) -> None:
|
|
60
|
+
with tempfile.TemporaryDirectory() as td:
|
|
61
|
+
root = self._repo(td)
|
|
62
|
+
rels = {rel for _, rel in iter_source_files(root, load_config(None))}
|
|
63
|
+
self.assertEqual(rels, {"src/lib/orders.js"})
|
|
64
|
+
|
|
65
|
+
def test_injection_candidates_only_from_source(self) -> None:
|
|
66
|
+
with tempfile.TemporaryDirectory() as td:
|
|
67
|
+
root = self._repo(td)
|
|
68
|
+
res = find_injection(root, load_config(None))
|
|
69
|
+
files = {c["file"] for c in res["candidates"]}
|
|
70
|
+
self.assertEqual(files, {"src/lib/orders.js"})
|
|
71
|
+
|
|
72
|
+
def test_user_exclude_override_brings_folder_back(self) -> None:
|
|
73
|
+
with tempfile.TemporaryDirectory() as td:
|
|
74
|
+
root = self._repo(td)
|
|
75
|
+
excludes = [e for e in DEFAULT_CONFIG["scope"]["exclude"] if e != ".next/"]
|
|
76
|
+
cfg = deep_merge(DEFAULT_CONFIG, {"scope": {"exclude": excludes}})
|
|
77
|
+
rels = {rel for _, rel in iter_source_files(root, cfg)}
|
|
78
|
+
self.assertIn(".next/server/chunks/x.js", rels)
|
|
79
|
+
|
|
80
|
+
def test_template_matches_default(self) -> None:
|
|
81
|
+
template = json.loads(TEMPLATE.read_text(encoding="utf-8"))
|
|
82
|
+
self.assertEqual(template["scope"]["exclude"], DEFAULT_CONFIG["scope"]["exclude"])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
unittest.main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Keeps the archived Evaluation Run 4 numbers (Issue #286) in step with the scorer and EVAL-RESULTS.md."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
TESTS_DIR = Path(__file__).resolve().parent
|
|
13
|
+
LSA_ROOT = TESTS_DIR.parent
|
|
14
|
+
EVAL_RUN_4_DIR = LSA_ROOT / "examples" / "eval-run-4"
|
|
15
|
+
GROUND_TRUTH = EVAL_RUN_4_DIR / "ground-truth.json"
|
|
16
|
+
RUNS = ("iter0-gptoss-a", "iter1-gptoss-a", "iter1-gptoss-b", "iter1-qwen-a-incomplete", "iter2-gptoss-a", "iter2-gptoss-b")
|
|
17
|
+
# (BOLA, injection, BFLA, mass assignment) found by location, then BFLA / mass found by auditor verdict
|
|
18
|
+
EXPECTED = {
|
|
19
|
+
"iter2-gptoss-a": {"marker": (24, 34, 8, 6), "verdict": (10, 10)},
|
|
20
|
+
"iter2-gptoss-b": {"marker": (11, 32, 5, 4), "verdict": (10, 9)},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def score(run: str) -> dict:
|
|
25
|
+
proc = subprocess.run(
|
|
26
|
+
[sys.executable, str(TESTS_DIR / "score_findings.py"), "--verdicts", str(GROUND_TRUTH), str(EVAL_RUN_4_DIR / run)],
|
|
27
|
+
capture_output=True, text=True, check=True,
|
|
28
|
+
)
|
|
29
|
+
return json.loads(proc.stdout)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestEvaluationRun4(unittest.TestCase):
|
|
33
|
+
def test_artifacts_exist(self) -> None:
|
|
34
|
+
self.assertTrue(GROUND_TRUTH.is_file())
|
|
35
|
+
for run in RUNS:
|
|
36
|
+
for name in ("findings.json", "coverage.json", "score.json"):
|
|
37
|
+
self.assertTrue((EVAL_RUN_4_DIR / run / name).is_file(), f"{run}/{name}")
|
|
38
|
+
|
|
39
|
+
def test_archived_scores_match_the_scorer(self) -> None:
|
|
40
|
+
for run in RUNS:
|
|
41
|
+
with self.subTest(run=run):
|
|
42
|
+
archived = json.loads((EVAL_RUN_4_DIR / run / "score.json").read_text(encoding="utf-8"))
|
|
43
|
+
self.assertEqual(score(run), archived)
|
|
44
|
+
|
|
45
|
+
def test_final_harness_numbers_match_eval_results(self) -> None:
|
|
46
|
+
for run, want in EXPECTED.items():
|
|
47
|
+
with self.subTest(run=run):
|
|
48
|
+
report = score(run)
|
|
49
|
+
pc, vl = report["per_category"], report["verdict_level"]
|
|
50
|
+
got = tuple(pc[c]["found"] for c in ("bola", "injection", "bfla", "mass_assignment"))
|
|
51
|
+
self.assertEqual(got, want["marker"])
|
|
52
|
+
self.assertEqual((vl["bfla"]["found"], vl["mass_assignment"]["found"]), want["verdict"])
|
|
53
|
+
|
|
54
|
+
def test_final_harness_has_no_false_positive_on_safe_code(self) -> None:
|
|
55
|
+
for run in EXPECTED:
|
|
56
|
+
with self.subTest(run=run):
|
|
57
|
+
report = score(run)
|
|
58
|
+
for cat, row in report["per_category"].items():
|
|
59
|
+
self.assertEqual(row["false_positive_on_safe_code"], [], cat)
|
|
60
|
+
for cat, row in report["verdict_level"].items():
|
|
61
|
+
self.assertEqual(row["flagged_safe"], [], cat)
|
|
62
|
+
|
|
63
|
+
def test_final_harness_records_run_stats(self) -> None:
|
|
64
|
+
for run in EXPECTED:
|
|
65
|
+
with self.subTest(run=run):
|
|
66
|
+
stats = json.loads((EVAL_RUN_4_DIR / run / "ai-run-stats.json").read_text(encoding="utf-8"))
|
|
67
|
+
self.assertEqual(stats["model"], "gpt-oss:20b")
|
|
68
|
+
self.assertGreater(stats["totals"]["prompt_tokens"], 0)
|
|
69
|
+
self.assertEqual(stats["call_errors"], {})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Keeps the archived Evaluation Run 5 numbers (Issue #319) in step with the scorer and EVAL-RESULTS.md."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
TESTS_DIR = Path(__file__).resolve().parent
|
|
13
|
+
LSA_ROOT = TESTS_DIR.parent
|
|
14
|
+
EVAL_RUN_5_DIR = LSA_ROOT / "examples" / "eval-run-5"
|
|
15
|
+
GROUND_TRUTH = EVAL_RUN_5_DIR / "ground-truth.json"
|
|
16
|
+
RUNS = ("gptoss-a", "gptoss-b")
|
|
17
|
+
# (BOLA, injection, BFLA, mass assignment) found by location, then BFLA / mass found by auditor verdict
|
|
18
|
+
EXPECTED = {
|
|
19
|
+
"gptoss-a": {"marker": (24, 34, 10, 10), "verdict": (10, 15)},
|
|
20
|
+
"gptoss-b": {"marker": (12, 32, 9, 14), "verdict": (10, 15)},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def score(run: str) -> dict:
|
|
24
|
+
proc = subprocess.run(
|
|
25
|
+
[sys.executable, str(TESTS_DIR / "score_findings.py"), "--verdicts", str(GROUND_TRUTH), str(EVAL_RUN_5_DIR / run)],
|
|
26
|
+
capture_output=True, text=True, check=True,
|
|
27
|
+
)
|
|
28
|
+
return json.loads(proc.stdout)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TestEvaluationRun5(unittest.TestCase):
|
|
32
|
+
def test_artifacts_exist(self) -> None:
|
|
33
|
+
self.assertTrue(GROUND_TRUTH.is_file())
|
|
34
|
+
for run in RUNS:
|
|
35
|
+
for name in ("findings.json", "coverage.json", "score.json"):
|
|
36
|
+
self.assertTrue((EVAL_RUN_5_DIR / run / name).is_file(), f"{run}/{name}")
|
|
37
|
+
|
|
38
|
+
def test_archived_scores_match_the_scorer(self) -> None:
|
|
39
|
+
for run in RUNS:
|
|
40
|
+
with self.subTest(run=run):
|
|
41
|
+
archived = json.loads((EVAL_RUN_5_DIR / run / "score.json").read_text(encoding="utf-8"))
|
|
42
|
+
self.assertEqual(score(run), archived)
|
|
43
|
+
|
|
44
|
+
def test_final_harness_numbers_match_eval_results(self) -> None:
|
|
45
|
+
for run, want in EXPECTED.items():
|
|
46
|
+
with self.subTest(run=run):
|
|
47
|
+
report = score(run)
|
|
48
|
+
pc, vl = report["per_category"], report["verdict_level"]
|
|
49
|
+
got = tuple(pc[c]["found"] for c in ("bola", "injection", "bfla", "mass_assignment"))
|
|
50
|
+
self.assertEqual(got, want["marker"])
|
|
51
|
+
self.assertEqual((vl["bfla"]["found"], vl["mass_assignment"]["found"]), want["verdict"])
|
|
52
|
+
|
|
53
|
+
def test_final_harness_has_no_false_positive_on_safe_code(self) -> None:
|
|
54
|
+
for run in EXPECTED:
|
|
55
|
+
with self.subTest(run=run):
|
|
56
|
+
report = score(run)
|
|
57
|
+
for cat, row in report["per_category"].items():
|
|
58
|
+
self.assertEqual(row["false_positive_on_safe_code"], [], cat)
|
|
59
|
+
for cat, row in report["verdict_level"].items():
|
|
60
|
+
self.assertEqual(row["flagged_safe"], [], cat)
|
|
61
|
+
|
|
62
|
+
def test_final_harness_records_run_stats(self) -> None:
|
|
63
|
+
for run in EXPECTED:
|
|
64
|
+
with self.subTest(run=run):
|
|
65
|
+
stats = json.loads((EVAL_RUN_5_DIR / run / "ai-run-stats.json").read_text(encoding="utf-8"))
|
|
66
|
+
self.assertEqual(stats["model"], "gpt-oss:20b")
|
|
67
|
+
self.assertGreater(stats["totals"]["prompt_tokens"], 0)
|
|
68
|
+
self.assertEqual(stats["call_errors"], {})
|
|
69
|
+
self.assertEqual(stats["anchoring"]["unanchored"], [])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
unittest.main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Parameterizing JS/TS sql tagged templates are not injection candidates (Issue #326).
|
|
3
|
+
|
|
4
|
+
Drizzle, postgres.js, @vercel/postgres, Slonik and Prisma ($queryRaw, Prisma.sql) send ${...}
|
|
5
|
+
inside a tagged template as a bound parameter. Raw-text escape hatches (sql.raw, Prisma.raw,
|
|
6
|
+
sql.unsafe, $queryRawUnsafe) and untagged templates are still flagged.
|
|
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 find_injection import find_injection # noqa: E402
|
|
23
|
+
from lsa_common import load_config # noqa: E402
|
|
24
|
+
|
|
25
|
+
SAFE_TAGGED = '''\
|
|
26
|
+
import { sql } from "drizzle-orm";
|
|
27
|
+
import { Prisma } from "@prisma/client";
|
|
28
|
+
|
|
29
|
+
export async function counts(db, prisma, orderId, req) {
|
|
30
|
+
const id = req.query.id;
|
|
31
|
+
const lineCount = sql<number>`(select count(*)::int from ${orderLine} where ${orderLine.orderId} = ${orderId})`;
|
|
32
|
+
const rows = await db.execute(sql`select * from orders where id = ${id}`);
|
|
33
|
+
const p = await prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}`;
|
|
34
|
+
await prisma.$executeRaw`UPDATE users SET name = ${req.body.name} WHERE id = ${id}`;
|
|
35
|
+
const frag = Prisma.sql`SELECT * FROM users WHERE email = ${req.body.email}`;
|
|
36
|
+
const pg = await pool.sql`select * from items where owner_id = ${id}`;
|
|
37
|
+
return [lineCount, rows, p, frag, pg];
|
|
38
|
+
}
|
|
39
|
+
'''
|
|
40
|
+
|
|
41
|
+
UNSAFE = '''\
|
|
42
|
+
import { sql } from "drizzle-orm";
|
|
43
|
+
|
|
44
|
+
export async function unsafe(db, prisma, req) {
|
|
45
|
+
const id = req.query.id;
|
|
46
|
+
const a = await db.execute(`SELECT * FROM orders WHERE id = ${id}`);
|
|
47
|
+
const b = await prisma.$queryRawUnsafe(`SELECT * FROM users WHERE id = ${id}`);
|
|
48
|
+
const c = await db.execute(sql`select * from orders order by ${sql.raw(req.query.sort)}`);
|
|
49
|
+
const d = await db.execute(sql`select nextval(${sql.raw(`'${customerSeq.seqName}'`)}) as value`);
|
|
50
|
+
const e = await db.execute(sql`select * from t order by ${sql.raw("created_at")}`);
|
|
51
|
+
const f = await prisma.$queryRaw`SELECT * FROM t ORDER BY ${Prisma.raw(req.query.sort)}`;
|
|
52
|
+
return [a, b, c, d, e, f];
|
|
53
|
+
}
|
|
54
|
+
'''
|
|
55
|
+
|
|
56
|
+
NO_INPUT = '''\
|
|
57
|
+
import { sql } from "drizzle-orm";
|
|
58
|
+
|
|
59
|
+
export async function nextCode(tx, sequence) {
|
|
60
|
+
return tx.execute(sql`select nextval(${sql.raw(`'${sequence.seqName}'`)}) as value`);
|
|
61
|
+
}
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _scan(files: Dict[str, str]) -> List[Dict]:
|
|
66
|
+
with tempfile.TemporaryDirectory() as td:
|
|
67
|
+
root = Path(td)
|
|
68
|
+
for rel, text in files.items():
|
|
69
|
+
p = root / rel
|
|
70
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
p.write_text(text, encoding="utf-8")
|
|
72
|
+
return find_injection(root, load_config(None))["candidates"]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _by_line(cands: List[Dict]) -> Dict[int, Dict]:
|
|
76
|
+
return {c["line"]: c for c in cands}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestJsTaggedSql(unittest.TestCase):
|
|
80
|
+
def test_parameterizing_tags_not_flagged(self) -> None:
|
|
81
|
+
self.assertEqual(_scan({"src/safe.ts": SAFE_TAGGED}), [])
|
|
82
|
+
|
|
83
|
+
def test_untagged_and_unsafe_calls_flagged(self) -> None:
|
|
84
|
+
got = _by_line(_scan({"src/unsafe.ts": UNSAFE}))
|
|
85
|
+
self.assertEqual(got[5]["rule"], "sql-interpolation") # untagged template to db.execute
|
|
86
|
+
self.assertEqual(got[6]["rule"], "sql-interpolation") # $queryRawUnsafe
|
|
87
|
+
|
|
88
|
+
def test_raw_fragments(self) -> None:
|
|
89
|
+
got = _by_line(_scan({"src/unsafe.ts": UNSAFE}))
|
|
90
|
+
self.assertEqual(got[7]["rule"], "sql-raw-fragment") # sql.raw(req.query.sort)
|
|
91
|
+
self.assertEqual(got[7]["priority"], "high")
|
|
92
|
+
self.assertEqual(got[8]["rule"], "sql-raw-fragment") # sql.raw of a schema property
|
|
93
|
+
self.assertNotIn(9, got) # sql.raw("literal") is a constant
|
|
94
|
+
self.assertEqual(got[10]["rule"], "sql-raw-fragment") # Prisma.raw(req.query.sort)
|
|
95
|
+
self.assertEqual(got[10]["priority"], "high")
|
|
96
|
+
|
|
97
|
+
def test_raw_fragment_without_request_input_is_low(self) -> None:
|
|
98
|
+
got = _scan({"src/seq.ts": NO_INPUT})
|
|
99
|
+
self.assertEqual([(c["rule"], c["priority"]) for c in got], [("sql-raw-fragment", "low")])
|
|
100
|
+
|
|
101
|
+
def test_data_access_names_library(self) -> None:
|
|
102
|
+
with tempfile.TemporaryDirectory() as td:
|
|
103
|
+
root = Path(td)
|
|
104
|
+
(root / "a.ts").write_text('import { sql } from "drizzle-orm";\n', encoding="utf-8")
|
|
105
|
+
(root / "b.ts").write_text('import postgres from "postgres";\n', encoding="utf-8")
|
|
106
|
+
da = detect(root, load_config(None))["data_access"]
|
|
107
|
+
self.assertIn("Drizzle ORM (Node/TS)", da)
|
|
108
|
+
self.assertIn("postgres.js (Node/TS)", da)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
unittest.main()
|