feat(learning-harness): family scoring on the pairs hunter path

Task 3.2. The model-driven hunter is now scored by family and function. A
detection is a finding of the labeled family inside the labeled function, a
finding of another family or outside it is counted and never called a leak, and
the words in a finding are never read. A pair that cannot be scored is reported
with its reason and the detector is never run on it. The scoreboard gains a
per-family block with its own denominator, taxonomy version and the note that
hierarchical partial credit could not apply.

The text-token matcher this path used is deleted rather than left dormant. It
was the reason the hunter's published number said zero of seventeen while the
model was finding the right bugs in the right functions, and leaving it in the
file would invite its return.

The test that pinned the old rule now pins the new one: the same finding with
the same words counts when it carries a family tag and does not when it carries
none. The overlay and inventory paths and all three gate outputs are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
This commit is contained in:
Marius Ciepluch
2026-09-06 10:41:06 +02:00
co-authored by Claude Opus 5
parent c14ac8344f
commit fcda1ee46b
3 changed files with 252 additions and 75 deletions
+31 -27
View File
@@ -373,7 +373,8 @@ def test_known_limit_pairs_are_evaluated_but_excluded_from_achievable(tmp_path:
assert result.degradations and result.degradations[0]["reason"] == "hunter_model_unavailable"
def test_hunter_scorer_uses_same_rules_with_a_scripted_client(tmp_path: Path) -> None:
def test_hunter_scorer_credits_a_family_tag_and_never_the_words(tmp_path: Path) -> None:
"""learning-harness Req 3.1/3.6 replaced this path's rule: the family tag decides, the rationale never does."""
from openultrasast.findings import StaticFinding
src = "from flask import request\n\ndef f():\n return eval(request.args.get('x'))\n"
@@ -387,34 +388,37 @@ def test_hunter_scorer_uses_same_rules_with_a_scripted_client(tmp_path: Path) ->
mechanism="source_reaches_sink",
)
def scripted(root: Path) -> list[StaticFinding]:
text = (root / "app.py").read_text()
if "eval(" not in text:
return []
return [
StaticFinding(
finding_id="tool-hunter:app.py:4",
path="app.py",
title="eval of request input",
severity="high",
confidence="medium",
evidence_level="suspicion",
rationale="CWE-95 eval executes attacker input",
line=4,
function_name=None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=[],
ranking_priority=1.0,
)
]
def scripted(tags: list[str]): # type: ignore[no-untyped-def]
def scan(root: Path) -> list[StaticFinding]:
if "eval(" not in (root / "app.py").read_text():
return []
return [
StaticFinding(
finding_id="tool-hunter:app.py:4",
path="app.py",
title="eval of request input",
severity="high",
confidence="medium",
evidence_level="suspicion",
rationale="CWE-95 eval executes attacker input",
line=4,
function_name=None,
reachability_status="unknown",
reachability_evidence=[],
reachability_conditions=[],
tags=tags,
ranking_priority=1.0,
)
]
return scan
case = _case(tmp_path, "hunt", src, "def f():\n return 1\n", row)
result = evaluate_catalog([case], hunter=scripted)
hunter = result.scorers["sast"]["hunter"]
assert hunter.pair_correct == 1 and hunter.detected_vuln == 1 and hunter.silent_fix == 1
assert not result.degradations
tagged = evaluate_catalog([case], hunter=scripted(["family:injection"])).scorers["sast"]["hunter"]
assert tagged.pair_correct == 1 and tagged.detected_vuln == 1 and tagged.silent_fix == 1
# the same finding, the same words, no family tag: not a detection, because the rationale is not evidence
untagged = evaluate_catalog([case], hunter=scripted([])).scorers["sast"]["hunter"]
assert untagged.detected_vuln == 0 and untagged.silent_fix == 1
def test_juliet_strcpy_pair_bodies_differ_and_java_hash_is_known_limit() -> None: