Files
Open_Ultra_SAST/tests/test_learning_pairs_evidence.py
T
Marius CiepluchandClaude Opus 5 1843ef9a02 fix(learning-harness): the round-3 review findings, remediated RED-first
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
2026-09-06 13:03:06 +02:00

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