mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
111 lines
4.8 KiB
Python
111 lines
4.8 KiB
Python
"""learning-harness task 3.2: family scoring on the pairs hunter path (offline)."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from openultrasast.benchmark import ExpectedFinding
|
||
|
|
from openultrasast.findings import StaticFinding
|
||
|
|
from openultrasast.pairs import PairCase, evaluate_pair
|
||
|
|
|
||
|
|
VULN = "from flask import request\nimport os\n\n\ndef run():\n os.system(request.args.get('cmd'))\n"
|
||
|
|
FIXED = "from flask import request\nimport subprocess\n\n\ndef run():\n subprocess.run(['echo', request.args.get('cmd')], check=False)\n"
|
||
|
|
|
||
|
|
|
||
|
|
def _case(tmp_path: Path, *, family: str | None = "injection", unscorable: str | None = None) -> PairCase:
|
||
|
|
(tmp_path / "v.py").write_text(VULN)
|
||
|
|
(tmp_path / "f.py").write_text(FIXED)
|
||
|
|
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-78", vulnerability_class="x", path="app.py", evidence="", function="run", mechanism="other", family=family
|
||
|
|
),
|
||
|
|
),
|
||
|
|
min_recall=1.0,
|
||
|
|
fix_policy="silent",
|
||
|
|
unscorable=unscorable,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _finding(family: str, line: int) -> StaticFinding:
|
||
|
|
return StaticFinding(
|
||
|
|
finding_id=f"detector:app.py:{line}",
|
||
|
|
path="app.py",
|
||
|
|
title="t",
|
||
|
|
severity="high",
|
||
|
|
confidence="low",
|
||
|
|
evidence_level="suspicion",
|
||
|
|
rationale="r",
|
||
|
|
line=line,
|
||
|
|
function_name="run",
|
||
|
|
reachability_status="unknown",
|
||
|
|
reachability_evidence=[],
|
||
|
|
reachability_conditions=[],
|
||
|
|
tags=[f"family:{family}", "detector:injection@1"],
|
||
|
|
ranking_priority=0.0,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _hunter(vuln: list[StaticFinding], fixed: list[StaticFinding]): # type: ignore[no-untyped-def]
|
||
|
|
seen: list[Path] = []
|
||
|
|
|
||
|
|
def scan(root: Path) -> list[StaticFinding]:
|
||
|
|
seen.append(root)
|
||
|
|
return vuln if root.name == "vuln" else fixed
|
||
|
|
|
||
|
|
scan.seen = seen # type: ignore[attr-defined]
|
||
|
|
return scan
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_hunter_path_scores_by_family_and_function_not_by_text(tmp_path: Path) -> None:
|
||
|
|
right = evaluate_pair(_case(tmp_path), hunter=_hunter([_finding("injection", 6)], []))
|
||
|
|
assert right.detected_vuln and right.silent_fix and right.pair_correct
|
||
|
|
assert right.detection_kinds == ("hunter",) and right.family == "injection"
|
||
|
|
wrong_family = evaluate_pair(_case(tmp_path), hunter=_hunter([_finding("access_control", 6)], []))
|
||
|
|
assert not wrong_family.detected_vuln # the right place, the wrong family
|
||
|
|
loud = StaticFinding(**{**_finding("access_control", 6).__dict__, "title": "CWE-78 command injection in run"})
|
||
|
|
assert not evaluate_pair(_case(tmp_path), hunter=_hunter([loud], [])).detected_vuln # the words never count
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_finding_of_another_family_on_the_fixed_side_is_not_a_leak(tmp_path: Path) -> None:
|
||
|
|
outcome = evaluate_pair(_case(tmp_path), hunter=_hunter([_finding("injection", 6)], [_finding("access_control", 6)]))
|
||
|
|
assert outcome.pair_correct and outcome.silent_fix
|
||
|
|
assert outcome.fix_findings == 1 and outcome.fix_leaks == 0 # counted, never charged
|
||
|
|
leaked = evaluate_pair(_case(tmp_path), hunter=_hunter([_finding("injection", 6)], [_finding("injection", 6)]))
|
||
|
|
assert leaked.detected_vuln and not leaked.silent_fix and leaked.fix_leaks == 1
|
||
|
|
|
||
|
|
|
||
|
|
def test_an_unscorable_pair_is_reported_as_such_and_never_run(tmp_path: Path) -> None:
|
||
|
|
hunter = _hunter([_finding("injection", 6)], [])
|
||
|
|
outcome = evaluate_pair(_case(tmp_path, unscorable="identical_twin"), hunter=hunter)
|
||
|
|
assert outcome.unscorable == "identical_twin" and not outcome.detected_vuln
|
||
|
|
assert hunter.seen == [] # type: ignore[attr-defined]
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_pair_with_no_family_label_falls_back_to_the_classifier(tmp_path: Path) -> None:
|
||
|
|
outcome = evaluate_pair(_case(tmp_path, family=None), hunter=_hunter([_finding("injection", 6)], []))
|
||
|
|
assert outcome.family == "injection" and outcome.pair_correct # CWE-78 classifies as injection
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_overlay_path_is_untouched_by_the_family_scorer(tmp_path: Path) -> None:
|
||
|
|
overlay = evaluate_pair(_case(tmp_path))
|
||
|
|
assert overlay.detection_kinds != ("hunter",) and overlay.family == ""
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_scoreboard_carries_the_family_block(tmp_path: Path) -> None:
|
||
|
|
from openultrasast.pairs import evaluate_catalog, result_payload
|
||
|
|
|
||
|
|
result = evaluate_catalog([_case(tmp_path)], hunter=_hunter([_finding("injection", 6)], []), inventory_sidecar=False)
|
||
|
|
payload = result_payload(result)
|
||
|
|
assert "per_family" in payload and payload["per_family"]["injection"]["scorable"] == 1
|
||
|
|
assert payload["per_family"]["injection"]["recall"] == 1.0
|
||
|
|
assert payload["per_family"]["injection"]["hierarchical_credit"] is False
|
||
|
|
assert payload["per_family"]["injection"]["taxonomy_version"]
|