mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 03:59:28 +00:00
152 lines
6.0 KiB
Python
152 lines
6.0 KiB
Python
"""What the pairs hunter path must prove (learning-harness review round 3, Req 3.2, 3.5).
|
|||
|
|
|
||
|
|
Three defects, one test each: K was declared and never threaded, so every stage scored one run; the fixed
|
||
|
|
side was judged against the *vulnerable* side's function ranges, which differ on most of the corpus; and
|
||
|
|
the generalist hunter emitted findings with no family tag, which the class-aware scorer can never credit,
|
||
|
|
so `ousast pairs --hunter` reported recall zero by construction.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from openultrasast.benchmark import ExpectedFinding
|
||
|
|
from openultrasast.findings import StaticFinding
|
||
|
|
from openultrasast.learning.families import load_families
|
||
|
|
from openultrasast.pairs import PairCase, evaluate_catalog
|
||
|
|
|
||
|
|
# The fix inserts two guard lines above the handler, so `run` starts on a different line on each side.
|
||
|
|
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 shlex\nimport os\n\n\n"
|
||
|
|
"def _safe(value):\n return shlex.quote(value or '')\n\n\n"
|
||
|
|
"def run():\n os.system(_safe(request.args.get('cmd')))\n"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _case(tmp_path: Path, name: str = "p") -> PairCase:
|
||
|
|
(tmp_path / f"{name}-v.py").write_text(VULN)
|
||
|
|
(tmp_path / f"{name}-f.py").write_text(FIXED)
|
||
|
|
return PairCase(
|
||
|
|
name=name,
|
||
|
|
slice="vibe-py",
|
||
|
|
language="python",
|
||
|
|
origin="test",
|
||
|
|
vuln_file=tmp_path / f"{name}-v.py",
|
||
|
|
fixed_file=tmp_path / f"{name}-f.py",
|
||
|
|
relpath="app.py",
|
||
|
|
expected=(
|
||
|
|
ExpectedFinding(
|
||
|
|
cwe="CWE-78",
|
||
|
|
vulnerability_class="x",
|
||
|
|
path="app.py",
|
||
|
|
evidence="",
|
||
|
|
function="run",
|
||
|
|
mechanism="other",
|
||
|
|
family="injection",
|
||
|
|
),
|
||
|
|
),
|
||
|
|
min_recall=1.0,
|
||
|
|
fix_policy="silent",
|
||
|
|
review_tier="seeded",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _finding(line: int, *, family: str | None = "injection") -> StaticFinding:
|
||
|
|
return StaticFinding(
|
||
|
|
finding_id=f"tool-hunter: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=["tool-hunter", *([f"family:{family}"] if family else [])],
|
||
|
|
ranking_priority=0.0,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_k_runs_flag_reaches_the_detector(tmp_path: Path) -> None:
|
||
|
|
"""Req 3.5: three runs per side, per pair. A parameter no caller can set is not a K-run floor."""
|
||
|
|
calls: list[str] = []
|
||
|
|
|
||
|
|
def scan(root: Path) -> list[StaticFinding]:
|
||
|
|
calls.append(root.name)
|
||
|
|
return [_finding(6)] if root.name == "vuln" else []
|
||
|
|
|
||
|
|
evaluate_catalog([_case(tmp_path)], hunter=scan, k_runs=3)
|
||
|
|
assert calls.count("vuln") == 3 and calls.count("fixed") == 3
|
||
|
|
|
||
|
|
|
||
|
|
def test_fewer_than_three_runs_is_refused(tmp_path: Path) -> None:
|
||
|
|
with pytest.raises(ValueError, match="three runs"):
|
||
|
|
evaluate_catalog([_case(tmp_path)], hunter=lambda root: [], k_runs=1)
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_fixed_side_is_judged_against_the_fixed_side_line_numbers(tmp_path: Path) -> None:
|
||
|
|
"""`run` sits at line 5 on the vulnerable side and line 10 on the fixed side.
|
||
|
|
|
||
|
|
Scoring the fixed side against the vulnerable side's span reads a finding inside the fixed handler as
|
||
|
|
outside it, so a detector that flags both sides is recorded as silent on the fix — a leak counted as a
|
||
|
|
clean pass, and always in the direction that flatters the number."""
|
||
|
|
|
||
|
|
def scan(root: Path) -> list[StaticFinding]:
|
||
|
|
return [_finding(6)] if root.name == "vuln" else [_finding(11)]
|
||
|
|
|
||
|
|
result = evaluate_catalog([_case(tmp_path)], hunter=scan, k_runs=3)
|
||
|
|
hunter = result.scorers["vibe-py"]["hunter"]
|
||
|
|
assert hunter.detected_vuln == 1
|
||
|
|
assert hunter.silent_fix == 0, "the finding is inside `run` on the fixed side; that is a leak"
|
||
|
|
assert hunter.pair_correct == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_a_generalist_finding_without_a_family_is_never_a_detection(tmp_path: Path) -> None:
|
||
|
|
"""The other half of the same defect: `pairs --hunter` used to feed the scorer untagged findings."""
|
||
|
|
|
||
|
|
def scan(root: Path) -> list[StaticFinding]:
|
||
|
|
return [_finding(6, family=None)] if root.name == "vuln" else []
|
||
|
|
|
||
|
|
result = evaluate_catalog([_case(tmp_path)], hunter=scan, k_runs=3)
|
||
|
|
assert result.scorers["vibe-py"]["hunter"].detected_vuln == 0
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_hunter_reports_the_family_it_believes_and_that_is_what_is_credited(tmp_path: Path) -> None:
|
||
|
|
"""A generalist that must name the family is answerable; one that names nothing cannot be scored at all."""
|
||
|
|
from openultrasast.tool_hunter import _findings_from_content
|
||
|
|
|
||
|
|
findings = _findings_from_content(
|
||
|
|
tmp_path,
|
||
|
|
[],
|
||
|
|
'[{"path": "app.py", "line": 6, "family": "injection", "title": "t", "rationale": "r"}]',
|
||
|
|
)
|
||
|
|
assert findings and "family:injection" in findings[0].tags
|
||
|
|
untagged = _findings_from_content(tmp_path, [], '[{"path": "app.py", "line": 6, "title": "t"}]')
|
||
|
|
assert untagged and not [tag for tag in untagged[0].tags if tag.startswith("family:")]
|
||
|
|
|
||
|
|
|
||
|
|
def test_the_pair_hunter_asks_for_a_family_from_the_closed_list(tmp_path: Path) -> None:
|
||
|
|
"""The prompt has to name the taxonomy, or the model invents family names and every finding is noise."""
|
||
|
|
from openultrasast.pairs import make_hunter_scan
|
||
|
|
|
||
|
|
seen: list[str] = []
|
||
|
|
|
||
|
|
class Client:
|
||
|
|
def complete(self, *, model: str, messages, tools=None, **options): # type: ignore[no-untyped-def]
|
||
|
|
seen.append("\n".join(str(message.get("content") or "") for message in messages))
|
||
|
|
from openultrasast.tool_hunter import ChatResponse
|
||
|
|
|
||
|
|
return ChatResponse(content="[]", tool_calls=())
|
||
|
|
|
||
|
|
(tmp_path / "app.py").write_text(VULN)
|
||
|
|
make_hunter_scan(Client(), "m", taxonomy=load_families())(tmp_path)
|
||
|
|
prompt = "\n".join(seen)
|
||
|
|
assert "injection" in prompt and "access_control" in prompt and "untrusted_destination" in prompt
|
||
|
|
assert '"family"' in prompt
|