2026-09-06 09:31:04 +02:00
|
|
|
"""learning-harness task 2.3: per-pair family scoring (offline)."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
from openultrasast.benchmark import ExpectedFinding
|
|
|
|
|
from openultrasast.findings import StaticFinding
|
|
|
|
|
from openultrasast.learning.families import load_families
|
|
|
|
|
from openultrasast.pairs import PairCase
|
|
|
|
|
|
|
|
|
|
RANGES = {"app.py": (("leaky", 10, 13), ("guarded", 4, 7))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _finding(family: str, line: int, path: str = "app.py") -> StaticFinding:
|
|
|
|
|
return StaticFinding(
|
|
|
|
|
finding_id=f"detector:{path}:{line}",
|
|
|
|
|
path=path,
|
|
|
|
|
title="t",
|
|
|
|
|
severity="medium",
|
|
|
|
|
confidence="low",
|
|
|
|
|
evidence_level="suspicion",
|
|
|
|
|
rationale="r",
|
|
|
|
|
line=line,
|
|
|
|
|
function_name=None,
|
|
|
|
|
reachability_status="unknown",
|
|
|
|
|
reachability_evidence=[],
|
|
|
|
|
reachability_conditions=[],
|
|
|
|
|
tags=[f"family:{family}", "detector:x@1"],
|
|
|
|
|
ranking_priority=0.0,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _case(tmp_path: Path, *, family: str = "access_control", unscorable: str | None = None) -> PairCase:
|
|
|
|
|
(tmp_path / "v.py").write_text("x = 1\n")
|
|
|
|
|
(tmp_path / "f.py").write_text("x = 2\n")
|
|
|
|
|
return PairCase(
|
|
|
|
|
name="p",
|
|
|
|
|
slice="vibe-py",
|
|
|
|
|
language="python",
|
|
|
|
|
origin="test",
|
|
|
|
|
vuln_file=tmp_path / "v.py",
|
|
|
|
|
fixed_file=tmp_path / "f.py",
|
|
|
|
|
relpath="app.py",
|
|
|
|
|
expected=(
|
|
|
|
|
ExpectedFinding(
|
|
|
|
|
cwe="CWE-639", vulnerability_class="x", path="app.py", evidence="", function="leaky", mechanism="other", family=family
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
min_recall=1.0,
|
|
|
|
|
fix_policy="silent",
|
|
|
|
|
unscorable=unscorable,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detection_needs_the_labeled_family_inside_the_labeled_function(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
case = _case(tmp_path)
|
|
|
|
|
hit = score_pair_family(case, "access_control", [[_finding("access_control", 12)]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert hit.outcome == "pair_correct" and hit.runs == ("pair_correct",)
|
|
|
|
|
outside = score_pair_family(case, "access_control", [[_finding("access_control", 5)]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert outside.outcome == "both_silent" # the right family in the wrong function is not a detection
|
|
|
|
|
other_file = score_pair_family(
|
|
|
|
|
case, "access_control", [[_finding("access_control", 12, "other.py")]], [[]], ranges=RANGES, taxonomy=taxonomy
|
|
|
|
|
)
|
|
|
|
|
assert other_file.outcome == "both_silent"
|
|
|
|
|
wrong_family = score_pair_family(case, "access_control", [[_finding("injection", 12)]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert wrong_family.outcome == "both_silent" and wrong_family.other_findings_vuln == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_fabricated_family_never_counts_and_is_recorded(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
score = score_pair_family(_case(tmp_path), "access_control", [[_finding("sql_injection", 12)]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert score.outcome == "both_silent" and score.fabricated_vuln == 1
|
|
|
|
|
assert score.other_findings_vuln == 0 # a fabricated id is not another family, it is noise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_silence_ignores_findings_of_other_families_and_never_calls_them_leaks(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
case = _case(tmp_path)
|
|
|
|
|
quiet = score_pair_family(
|
|
|
|
|
case,
|
|
|
|
|
"access_control",
|
|
|
|
|
[[_finding("access_control", 12)]],
|
|
|
|
|
[[_finding("injection", 12), _finding("access_control", 5)]],
|
|
|
|
|
ranges=RANGES,
|
|
|
|
|
taxonomy=taxonomy,
|
|
|
|
|
)
|
|
|
|
|
assert quiet.outcome == "pair_correct" and quiet.other_findings_fixed == 2
|
|
|
|
|
leaked = score_pair_family(
|
|
|
|
|
case, "access_control", [[_finding("access_control", 12)]], [[_finding("access_control", 13)]], ranges=RANGES, taxonomy=taxonomy
|
|
|
|
|
)
|
|
|
|
|
assert leaked.outcome == "both_flagged"
|
|
|
|
|
reversed_pair = score_pair_family(case, "access_control", [[]], [[_finding("access_control", 12)]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert reversed_pair.outcome == "reversed"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_outcome_of_several_runs_is_their_majority(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
case = _case(tmp_path)
|
|
|
|
|
hit = [_finding("access_control", 12)]
|
|
|
|
|
score = score_pair_family(case, "access_control", [hit, [], hit], [[], [], []], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert score.runs == ("pair_correct", "both_silent", "pair_correct") and score.outcome == "pair_correct"
|
|
|
|
|
flaky = score_pair_family(case, "access_control", [hit, [], []], [[], [], []], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert flaky.outcome == "both_silent" and flaky.flips == 1 # a run that disagrees with the majority is a flip
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unscorable_pair_is_never_run_and_keeps_its_reason(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
case = _case(tmp_path, unscorable="identical_twin")
|
|
|
|
|
score = score_pair_family(case, "access_control", [[_finding("access_control", 12)]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert score.outcome == "unscorable" and score.unscorable_reason == "identical_twin" and score.runs == ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_pair_with_no_parsed_function_range_is_unscorable_not_a_miss(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
score = score_pair_family(_case(tmp_path), "access_control", [[]], [[]], ranges={}, taxonomy=taxonomy)
|
|
|
|
|
assert score.outcome == "unscorable" and score.unscorable_reason == "unresolved_label"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_scoring_never_reads_the_text_of_a_finding(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
shouting = _finding("injection", 12)
|
|
|
|
|
loud = StaticFinding(**{**shouting.__dict__, "title": "CWE-639 broken object level authorization", "rationale": "IDOR in leaky"})
|
|
|
|
|
score = score_pair_family(_case(tmp_path), "access_control", [[loud]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert score.outcome == "both_silent" # the words say access control; the family tag says injection, and only that counts
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_rung_is_the_best_verifier_outcome_among_the_detections(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
corroborated = _finding("access_control", 12)
|
|
|
|
|
corroborated.tags.append("verifier:static_corroboration")
|
|
|
|
|
score = score_pair_family(_case(tmp_path), "access_control", [[corroborated]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert score.outcome == "pair_correct" and score.rung == "static_corroboration"
|
|
|
|
|
plain = score_pair_family(_case(tmp_path), "access_control", [[_finding("access_control", 12)]], [[]], ranges=RANGES, taxonomy=taxonomy)
|
|
|
|
|
assert plain.rung == "suspicion"
|
2026-09-06 10:32:02 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_unscorable_reason_tells_a_tooling_gap_from_a_corpus_defect(tmp_path: Path) -> None:
|
|
|
|
|
"""Req 4.1: an unparsable row is a tooling gap; a row whose labeled function no range names is a corpus defect."""
|
|
|
|
|
from openultrasast.learning.scoring import unscorable_reason
|
|
|
|
|
|
|
|
|
|
case = _case(tmp_path)
|
|
|
|
|
assert unscorable_reason(case, parse_ok=True, ranges=RANGES) is None
|
|
|
|
|
assert unscorable_reason(case, parse_ok=False, ranges={}) == "unsupported_language"
|
|
|
|
|
assert unscorable_reason(case, parse_ok=False, ranges=RANGES) == "unsupported_language" # the parse decides, not the ranges
|
|
|
|
|
assert unscorable_reason(case, parse_ok=True, ranges={}) == "unresolved_label"
|
|
|
|
|
assert unscorable_reason(_case(tmp_path, unscorable="identical_twin"), parse_ok=True, ranges=RANGES) == "identical_twin"
|
|
|
|
|
twin = _case(tmp_path, unscorable="known_limit:java hash")
|
|
|
|
|
assert unscorable_reason(twin, parse_ok=False, ranges={}) == "known_limit:java hash" # a declared limit wins
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_row_whose_registration_context_is_missing_is_named_as_such(tmp_path: Path) -> None:
|
|
|
|
|
"""Req 4.1: an access-control row whose handler arrived without its registration cannot be scored for that reason."""
|
|
|
|
|
from openultrasast.learning.scoring import unscorable_reason
|
|
|
|
|
|
|
|
|
|
case = _case(tmp_path, family="access_control")
|
|
|
|
|
assert unscorable_reason(case, parse_ok=True, ranges=RANGES, entry_points=("leaky",)) is None
|
|
|
|
|
assert unscorable_reason(case, parse_ok=True, ranges=RANGES, entry_points=()) == "missing_context"
|
|
|
|
|
# only families whose bug shape needs the registration are judged on it
|
|
|
|
|
assert unscorable_reason(_case(tmp_path, family="injection"), parse_ok=True, ranges=RANGES, entry_points=()) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unparsable_side_is_scored_unscorable_rather_than_missed(tmp_path: Path) -> None:
|
|
|
|
|
from openultrasast.learning.scoring import score_pair_family
|
|
|
|
|
|
|
|
|
|
taxonomy = load_families()
|
|
|
|
|
score = score_pair_family(_case(tmp_path), "access_control", [[]], [[]], ranges={}, taxonomy=taxonomy, parse_ok=False)
|
|
|
|
|
assert score.outcome == "unscorable" and score.unscorable_reason == "unsupported_language"
|