mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
Eight of nine group-3 tasks were rejected. Each defect was hiding a number, and the numbers are why the loop was not learning: - The fixed side was judged against the *vulnerable* side's function ranges. 191 of 227 pairs with a resolvable labeled span (84%) have different spans on the two sides, so a leak was recorded as silence, always in the direction that flatters Youden. - Round zero measured every family with the generalist. `family:unknown` earns no credit against any real family, so every family but `unknown` baselined at a structural zero and every noise floor with it. - The cross-family sweep counted absolute failures instead of negative flips. On a corpus where most pairs fail at round zero that rejects every round for damage it did not do. Unscorable rows voted too. - K runs was declared and never threaded; `pairs --k-runs` was parsed and discarded, and its only test grepped `--help`. - The meta-agent proposer was a stub, so `learning round` could never propose anything. It now composes MetaAgent.evolve against a scratch copy of the family directory, and `--proposals FILE` runs a recorded sequence offline. - `refused_holdout` was a vocabulary word no code path could produce, and the covering test accepted either outcome. - Cost was metered once, after everything had been paid for; a stage that raised left no record at all. - The corrected lever number was published beside the earlier one rather than in place of it, and the test named for that property never checked. - Refusals were `continue` statements: `Refusal.degradation()` existed and nothing called it. A train-split identical twin was reported as `split:train`, which is untrue. Test repairs that each changed a number: two split tests measured an `x = 1` fixture that teaches nothing whatever the rule says; two CLI scoreboard tests asserted against all-zero output produced by a stub that answers twice and puts its finding outside the labeled function. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
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
|