Files
Open_Ultra_SAST/benchmarks/measurements/2026-10-12-valid-benchmark-pilot/NOTES.md
T
Marius CiepluchandClaude Fable 5.1 ad20405fc2 docs(benchmark): where the hunter's false alarms come from
Sweep over the pilot's hunter records: most 'vulnerable' suspicions on ordinary
pushes sit far from the diff. Scope to the change, then confidence, then the
config_secrets family; 5/12 recall kept at 8.8 false alarms per 100 (in-sample).

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-13 08:35:04 +02:00

14 KiB

Valid detection benchmark, pilot (2026-10-12)

First build and first run of the benchmark defined in .kiro/specs/valid-detection-benchmark/. Labels come from the fix, not SZZ. The frozen manifest is at benchmarks/valid/pilot/ (pool-valid.toml, freeze-valid.json). This directory keeps the provenance and the numbers.

Numbers (read 2026-10-13 morning)

Two arms over the same 252 changes (12 verified positives, 240 ordinary pushes from the same 12 repos), as R6 of the requirements asks:

  • (a) push hook as shipped: quick rules + engine, 300 s deadline, the Docker replay lane, no model key in the container. Still running when this was written: 163 of 252 records done (all 12 positives, 151 ordinary). The final numbers land in MORNING.txt outside the repo and will replace the *_interim_163.json files here.
  • (b) hunter arm: the LLM tool hunter (scan --mode deep's search) run off-cluster over the changed files at head, every finding classified into the taxonomy by the decision-engine classifier. Finished: 252 of 252. Reported twice: raw (every hunter suspicion counts) and filtered (only suspicions the classifier judged vulnerable).
Metric (a) push hook, 163 done (b) hunter, raw (b') hunter, classifier-filtered
Recall (12 positives) 0 / 12 = 0% (Wilson 0 to 24.3) 6 / 12 = 50% (25.4 to 74.6) 5 / 12 = 41.7% (19.3 to 68.0)
False alarms per 100 ordinary pushes 1 / 149 = 0.7 (0.1 to 3.7) 221 / 239 = 92.5 (88.4 to 95.2) 130 / 239 = 54.4 (48.1 to 60.6)
Precision (flagged pushes that were positives) 0 / 1 6 / 232 = 2.6% 5 / 139 = 3.6%
Exploit-verified 0 (no proof run) 0 (no proof run) 0 (no proof run)
Instrument failures 2 / 163 1 / 252 1 / 252

Neither arm meets the gate (recall at least 90%, false alarms under 10 per 100 pushes). The hook as shipped is quiet and blind on this set. The hunter finds about half and alarms on more than half of ordinary pushes even after the classifier. The whole-tool number the brief holds the tool to is (a) plus (b'); since (a) caught nothing, it equals (b') on recall and adds one false alarm.

Files: scored_a_interim_163.json, metrics_a_interim_163.json, scored_b_raw.json, metrics_b_raw.json, scored_b_filtered.json, metrics_b_filtered.json. Scorer: benchmarks/unseen/score.py (a catch needs the labelled family and a location inside the labelled function or within 10 lines of it). Metrics: benchmarks/valid/metrics.py.

Per positive

Repo Label (a) hook (b) raw (b') filtered What the hunter did
bytebase/dbhub access_control miss (engine ran) miss miss 8 suspicions in the right files, all not_vulnerable or unsure, none typed access_control
contentful/contentful-mcp-server untrusted_destination miss miss miss 2 vulnerable hits in the labelled file, typed path; family mismatch, not credited
fastapi-users/fastapi-users access_control miss miss miss 0 suspicions
i18next/i18next-http-middleware output_encoding miss hit miss hit at lib/index.js:96, classifier said unsure, dropped by the filter
imzbf/md-editor-v3 output_encoding miss (engine ran) hit hit 9 vulnerable html-injection suspicions at the labelled sites
jupyter-lsp/jupyterlab-lsp path miss hit hit 3 vulnerable path hits in the labelled file
matrix-org/matrix-appservice-bridge access_control miss (engine ran) miss miss 5 "complexity hotspot" suspicions in the right file, all not_vulnerable
nvbn/thefuck path miss (engine ran) hit hit both labelled functions hit, vulnerable 0.85 to 0.9
ondata/ckan-mcp-server untrusted_destination miss hit hit 2 vulnerable ssrf hits in http.ts
webpack/webpack-bundle-analyzer output_encoding miss miss miss vulnerable output_encoding hit in viewer.js:185, outside the labelled function
zenitram/malla output_encoding miss hit hit 7 vulnerable dom-xss hits
zereight/gitlab-mcp path miss miss miss 5 suspicions, none typed path

Two misses are scorer strictness rather than blindness: contentful (right file, family typed path instead of untrusted_destination) and webpack (right file and family, other function). Keep the strict scorer; note them.

Arm (a) detail

  • Engine finished inside 300 s on 100 of 163 changes (61%); 61 fell back to quick rules only; 2 instrument failures (contentful 9e706114, matrix 7018468e, nothing readable at the hotspots). On the positives: engine ran on 4 of 12, quick only on 8.
  • Every record proves the instrument: hook exit 0, bytes read per changed file, base and head verified.
  • The one false alarm: bytebase/dbhub 5bd45460, quick rule js-command-exec (CWE-78) at src/connectors/sqlite/index.ts:132, "Node child_process command execution needs review".

Arm (b) detail

  • 1398 suspicions over 251 pushes: mean 5.6 per ordinary push, median 5, max 23.
  • Classifier verdicts: 377 vulnerable, 968 not_vulnerable, 53 unsure, 0 errors. Families among the vulnerable ones: config_secrets 103, injection 74, output_encoding 58, access_control 54, path 39, untrusted_destination 33, prototype 10, other 6.
  • The hunter's own family: tag is free text (sql-parser, complexity-hotspot, dom-xss), so without the classifier every finding scored unknown. Product follow-up: constrain the hunter prompt to the taxonomy (tool_hunter.py) so the push path emits taxonomy families natively.
  • The instrument failure: matrix 7018468e, no readable hotspot (hook_bytes_read: 0).

Where the hunter's false alarms come from (sweep over the same records, no new model calls)

Of the 343 ordinary-push suspicions the classifier called vulnerable, only 40 sit on a line the push changed and only 93 within 10 lines of one. The hunter searches the whole changed file, so on an ordinary push it re-reports the repo's pre-existing code. Scoping to the change is the biggest lever, confidence the second, the config_secrets family the third. In-sample on 12 positives, so treat the table as direction, not a tuned threshold:

Filter on hunter suspicions Recall False alarms per 100
raw 6/12 92.5
classifier vulnerable 5/12 54.4
vulnerable, confidence >= 0.8 5/12 31.8
vulnerable, within 10 lines of the diff 5/12 22.6
vulnerable, confidence >= 0.8, within 10 lines 5/12 11.3
vulnerable, confidence >= 0.8, within 10 lines, not config_secrets 5/12 8.8

The exact-line scope (0 lines) drops recall to 1/12 because the benchmark's "head" is the code before the fix, where most fixes add lines rather than change them; a developer's own push has the vulnerable lines in the diff by construction. Proof stays the last step, on what survives this.

What to do with this

  1. The push hook needs the LLM tier (owned by pre-push-safety-net): the hook as shipped caught none of the 12. That is the product gap R6 names.
  2. The hunter needs a precision step before it can sit on pre-push: 54 alarms per 100 pushes after the classifier is far from 10. Candidates: require vulnerable with confidence at or above 0.7, drop config_secrets noise in ordinary pushes, restrict to changed lines, or run the proof arm on the survivors.
  3. Twelve positives give intervals 50 points wide. Grow to the ~40 positives score.py:required_n asks for before quoting a single recall number.

What was built

Step Result
Sourcing 32 held-out Python/JS advisories taken from the public part of pool-p1 (pip 12, npm 20). CWE and advisory text fetched from OSV by GHSA id. No fresh GitHub harvest.
Fix-site catalog 133 candidate locations across 31 repos, labelled at fix^1 (the commit just before the fix). 125 after dropping 7 test files and 1 minified file. method = "fix_site" on every row; the SZZ methods were never called.
LLM verification 125 DeepSeek calls (deepseek-flash). 31 locations passed all three checks (vulnerable before the fix, fix addresses it, reachable from an in-repo entry point) across 12 repos. 94 rejected.
Human spot-check All 12 repos accepted by the maintainer (reviewer mc, 2026-10-12), recorded on the 31 locations.
Negatives 20 ordinary first-parent commits per positive change, drawn from the same repo, fix excluded. 240 in total.
Held-out guard 0 dropped against the training-only used set (1777 names: pairs, pointers, harvest, units, sweep).
Freeze 252 changes: 12 vulnerability + 240 ordinary. Digest in freeze-valid.json.
Replay, arm (a) benchmarks/unseen/replay.py --arm long_deadline, Docker lane, one container at a time on the local host, about 14 records per hour. Started 2026-10-12 19:51.
Hunter, arm (b) benchmarks/valid/hunter_arm.py run, positives 20:23 to 20:51, ordinary 20:51 to 22:00.

The 12 positives

Repo Lang Family Advisory Vulnerable function
bytebase/dbhub ts access_control GHSA-mwwr-p57h-56pf src/utils/sql-parser.ts::scanTokenMySQL
contentful/contentful-mcp-server ts untrusted_destination GHSA-2xhg-73j7-rrgx .../exportSpace.ts::tool (+1)
fastapi-users/fastapi-users py access_control GHSA-5j53-63w8-8625 fastapi_users/router/oauth.py::get_oauth_router (+2)
i18next/i18next-http-middleware js output_encoding GHSA-c3h8-g69v-pjrg lib/index.js::extendOptionsWithDefaults
imzbf/md-editor-v3 ts output_encoding GHSA-3rm2-h79c-8qw6 .../useMarkdownIt.ts::useMarkdownIt (+1)
jupyter-lsp/jupyterlab-lsp py path GHSA-4qhp-652w-c22x .../virtual_documents_shadow.py::shadow_virtual_documents
matrix-org/matrix-appservice-bridge ts access_control GHSA-35g4-qx3c-vjhx src/components/room-upgrade-handler.ts::onJoinedNewRoom
nvbn/thefuck py path GHSA-8wwf-2644-f8x4 thefuck/rules/dirty_untar.py::side_effect (+1)
ondata/ckan-mcp-server ts untrusted_destination GHSA-798p-78g2-v556 src/utils/http.ts::validateServerUrl (+1)
webpack/webpack-bundle-analyzer js output_encoding GHSA-pgr8-jg6h-8gw6 src/viewer.js::chartData
zenitram/malla js output_encoding GHSA-ch57-39q2-4crm src/malla/static/js/chat.js::refreshPop (+12)
zereight/gitlab-mcp ts path GHSA-cv3r-c5h8-f4g5 index.ts::startSSEServer (+1)

"(+N)" means the fix touched N more functions the verifier also accepted. They collapse into one positive change per fix commit in the manifest; the extra functions are listed as targets on that change.

Decisions made while building

  • Parent is fix^1, read from git, not the parent field stored in pool-p1. That field did not always equal the fix commit's real first parent. "The code just before the fix" is fix^1.
  • Candidates are every function the fix touched. fix_sites() does not know which function holds the vulnerability, so the catalog over-generates and the LLM check plus the human spot-check select the real location. One repo (zenitram/malla) produced 40 candidates from one fix.
  • The negative ratio is per positive change, not per accepted function. assemble.py was corrected; the old count would have drawn 260 negatives for malla alone.
  • The held-out guard runs against training sources only. build_used() also scans measurements/ and populations/, which contain the SZZ pool and analysis of it, so it flagged all 12 repos as "used" by my own earlier measurements. That is circular. None of the 12 appear in any training source (pairs, pointers, harvest, units, sweep).
  • Catalog enumeration skips a site it cannot read (fix renamed or removed the function) instead of aborting the whole run. Verification stays strict.
  • The hook has no LLM detection tier. push/assistance.py only picks witnesses for defects the engine already admitted. R6 was amended to measure the hook as shipped and the hook plus the hunter arm; the second is the number the tool is held to.
  • Hunter findings are classified, never relabelled. The classifier gets the finding's excerpt and the taxonomy list; the change's own label is never shown to it. The raw hunter family, title and rationale stay on the defect for audit.
  • Replay output kept every result twice (top level and attempt-*/), 30 MB a record, and the host hit 1.1 GB free at record 162. Byte-identical attempt-*/ copies of finished records were removed; a guard repeats that while the replay runs. Nothing in Docker was touched.

Not done yet

  • Final arm (a) numbers on all 252 changes (replay finishing 2026-10-13 afternoon), then the combined (a)+(b') score with hunter_arm combine --keep-verdicts vulnerable.
  • Exploit-verified rate: the proof arm has not run on any of the 26 flagged positive findings.
  • Growing from 12 to the ~40 positives score.py:required_n asks for.
  • The 252-change replay at the engine's ~200 s fixed cost is about 17 hours one container at a time on the local host; that scale belongs on the kube ax lane, still blocked on the gVisor checkpoint path.

Reproduce

# clones: fetch each repo single-branch with history, then fetch <fix> <parent>
python -m benchmarks.valid.catalog  sources.json --repos repos.json --out catalog.toml
DEEPSEEK_API_KEY=... python -m benchmarks.valid.verify run catalog.toml --repos repos.json --out verification.json
python -m benchmarks.valid.verify spot-check verification.json <id> accept --reviewer mc --date 2026-10-12
python - <<'PY'   # assemble with the training-only used set
from benchmarks.valid import assemble
...  # see this directory's provenance files; used = build_used(root) minus {measurements, populations}
PY
# arm (a): replay.py --arm long_deadline, docker lane, --docker-image-bytes 3608729816, output outside the repo
# arm (b): DEEPSEEK_API_KEY=... python -m benchmarks.valid.hunter_arm run benchmarks/valid/pilot/pool-valid.toml --slice 1 --repos repos_hist.json --out <hunter_out>
python -m benchmarks.unseen.score <records_dir> --out scored.json --slice-id 1
python -m benchmarks.valid.metrics <records_dir> --scored scored.json --out metrics.json