Files
Open_Ultra_SAST/tests/test_scan_pipeline_integrity.py
Marius CiepluchandClaude Opus 5.5 cbadc38d5e refactor(harnessx-removal): retire the HarnessX scan paths (task 5)
The standard scan now runs run_hunter_pool, verify_findings and the
deterministic fuse_findings directly; the HarnessX hunter orchestrator,
the LLM judge dispatcher (verify_judge.py, deleted with its test) and the
LLM fusion panels are gone, and so are their extra-absent degradations.
host_under_harnessx is dropped from stage_processors.

config: [models] verifier and [fusion] panel_model/decider_model raise
RetiredConfigError naming the plane replacement (the CLI prints it and
exits 2); a [harnessx] section loads with one warning and is otherwise
ignored. harness.json keeps "verifier": null. Tests that asserted the
fallback are renamed to the behaviour they assert; the module-audit
manifest drops verify_judge. Nothing outside the two modules deleted in
task 6 imports harness_ext or hunter_harness.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
2026-09-30 12:13:26 +02:00

163 lines
6.3 KiB
Python

"""Scan-pipeline integrity & serialization (Phase 4 task 9).
9.1 — quick and standard scans run through the sync driver with the deterministic
hunter, verifier and fusion; a configured hunter model never changes the
deterministic findings.
9.2 — every persisted scan-state slot serializes and restores without loss (guards
against a non-serializable slot silently restoring as empty).
"""
import json
from dataclasses import asdict
from pathlib import Path
import pytest
from openultrasast.benchmark import load_findings
from openultrasast.cli import main
from openultrasast.findings import StaticFinding, write_findings
from openultrasast.preprocess import FileTarget
from openultrasast.rank import RankingScore
from openultrasast.scoring import build_score_artifact
from openultrasast.verification import EvidenceLevel, VerificationResult, VerificationStatus
_VULN = "@app.route('/admin')\ndef admin():\n return eval(request.data)\n"
# ---- 9.1 deterministic parity + degradation --------------------------------------
def _scan(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, mode: str, config_body: str | None) -> tuple[list, dict]:
repo = tmp_path / "repo"
repo.mkdir(parents=True)
(repo / "app.py").write_text(_VULN)
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENULTRASAST_HUNTER_CLIENT", raising=False)
argv = ["scan", str(repo), "--mode", mode]
if config_body is not None:
cfg = tmp_path / "openultrasast.toml"
cfg.write_text(config_body)
argv += ["--config", str(cfg)]
assert main(argv) == 0
run_dir = sorted((repo / ".runs").iterdir())[-1]
findings = json.loads((run_dir / "findings.json").read_text())["findings"]
manifest = json.loads((run_dir / "manifest.json").read_text())
return findings, manifest
def test_standard_scan_with_a_hunter_model_keeps_deterministic_findings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# A configured hunter model (no client reachable here) must leave the deterministic findings byte-identical.
requested, requested_manifest = _scan(tmp_path / "a", monkeypatch, mode="standard", config_body='[models]\nhunter = "openai/gpt-4o"\n')
baseline, baseline_manifest = _scan(tmp_path / "b", monkeypatch, mode="standard", config_body=None)
assert requested == baseline
# The hunter model runs the tool hunter (its client is absent here, so it contributes nothing) and removes the
# `map` skip; `model` records that no CPG engine was available (contributor-scan Req 1.3).
assert requested_manifest["degradations"] == [{"reason": "cpg_unavailable", "stage": "model"}]
assert {(e["stage"], e["reason"]) for e in baseline_manifest["degradations"]} == {
("model", "cpg_unavailable"),
("map", "hunter_model_unavailable"),
}
def test_quick_scan_records_no_degradation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Quick mode never touches a model: findings are produced and no degradation is recorded.
findings, manifest = _scan(tmp_path, monkeypatch, mode="quick", config_body=None)
assert findings
assert "degradations" not in manifest
# ---- 9.2 per-slot serialization round-trip ----------------------------------
def _finding() -> StaticFinding:
return StaticFinding(
finding_id="python-unsafe-eval:app.py:3",
path="app.py",
title="Dynamic Python execution",
severity="critical",
confidence="medium",
evidence_level="static_corroboration",
rationale="eval on request data",
line=3,
function_name="admin",
reachability_status="reachable",
reachability_evidence=[{"kind": "route", "access_level": "public", "line": 1, "end_line": 3}],
reachability_conditions=["public route"],
tags=["syscall_entry", "injection"],
ranking_priority=3.0,
)
def _ranking() -> RankingScore:
return RankingScore(
path="app.py",
surface=4,
influence=3,
reachability=4,
priority=3.5,
rationale="entry",
model_id=None,
static_boosts=["input_parser"],
)
def _file_target() -> FileTarget:
return FileTarget(
path="app.py",
absolute_path="/repo/app.py",
language="python",
loc=42,
tags=["network_entry"],
has_fuzz_entry_point=False,
static_hints=[{"rule": "x", "line": 1}],
reachability_hints=[{"kind": "route", "line": 1}],
)
def _verification() -> VerificationResult:
return VerificationResult(
finding_id="python-unsafe-eval:app.py:3",
status=VerificationStatus.ACCEPTED,
evidence_level=EvidenceLevel.STATIC_CORROBORATION,
verified=True,
pro_case="reachable tainted sink",
counter_case="needs dynamic confirmation",
tie_breaker="confirm attacker control",
required_next_step="dynamic repro",
context_sources=["app.py", "routes"],
)
@pytest.mark.parametrize(
"obj", [_finding(), _ranking(), _file_target(), _verification()], ids=["finding", "ranking", "file_target", "verification"]
)
def test_scan_slot_json_round_trips_without_loss(obj: object) -> None:
restored = type(obj)(**json.loads(json.dumps(asdict(obj))))
assert restored == obj
def test_score_artifact_round_trips() -> None:
from openultrasast.policy import load_policy
finding = _finding()
artifact = build_score_artifact([finding], {finding.finding_id: "CWE-95"}, load_policy())
payload = json.loads(json.dumps(artifact.to_dict()))
assert payload == artifact.to_dict() # serialization is total and stable
assert payload["project_score"] == artifact.project_score
def test_findings_reload_preserves_nested_slots(tmp_path: Path) -> None:
# The real resume path (benchmark reloads findings.json); nested collection
# slots must survive rather than restore as empty.
path = tmp_path / "findings.json"
write_findings([_finding()], path)
(restored,) = load_findings(path)
assert restored == _finding()
assert restored.reachability_evidence == [{"kind": "route", "access_level": "public", "line": 1, "end_line": 3}]
assert restored.reachability_conditions == ["public route"]
assert restored.tags == ["syscall_entry", "injection"]