mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(harnessx): Phase 3 task 6 — hunter sub-harness + llm-judge verifier + fallback
Adopt HarnessX at the hunter loop and verifier, capability-gated so the default
zero-dependency scan is byte-identical and CI (no extra) exercises the fallback.
- 6.1/6.2 build_sast() (single, lazy HarnessX composition site in harness_ext.py,
mirrors DocuHarnessX make_docgen) + HxScanOrchestrator (hunter_harness.py): a
drop-in for run_hunter_pool that runs one bounded HarnessX agent task per ranked
target (ModelConfig(main=AnthropicProvider).agentic(build_sast()).run(BaseTask)),
parses findings from a JSON structured-output contract, returns the existing
HunterPoolResult, maps scan_id->session_id and emits per-target exit_reason/cost/
steps to the outer trace.
- 6.4 verify_judge.verify_findings_dispatch: a drop-in superset of
verification.verify_findings — structural verifier verbatim by default (rejects
unknown reachability), HarnessX llm-judge (build_judge_prompt/parse_judge_response)
when the extra + a verifier model are present; verdict mapped to VerificationResult
evidence-gated (plausible>=0.6 accepted; unsupported/refused rejected; infra
failure never auto-rejects).
- 6.5 cli capability gate (hx_hunter/hx_verify): selects HxScanOrchestrator /
judge only in standard mode with a configured model AND has_harnessx(); otherwise
falls back and records a {stage,reason:harnessx_extra_unavailable} degradation in
the manifest (new append-only write_manifest degradations kwarg). With no model
configured the manifest stays byte-identical.
- pyproject: mypy override ignore_missing_imports for harnessx.* (absent in the
type-check env).
Verified the HarnessX path constructs against the real extra in a throwaway venv
(build_sast -> HarnessConfig; orchestrator -> Harness; build_judge_prompt/
parse_judge_response/verdict mapping) — only the live agent.run()/provider.complete()
LLM calls are offline-untestable and stay gated. 109 tests pass (2 construction
smokes skip without the extra), ruff + mypy clean, detection gate 93.6%/0% intact.
Tasks 6.1/6.2/6.4/6.5 done; 6.3 (deterministic stages as HarnessX processors) deferred.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5e206df166
commit
ef59c0ae3d
@@ -0,0 +1,56 @@
|
||||
"""CLI capability-gated dispatch + graceful degradation (Phase 3 task 6.5).
|
||||
|
||||
These run in the default (extra-absent) environment: standard mode with models
|
||||
configured must fall back to the deterministic hunter/structural verifier and
|
||||
record a degradation; with no models configured the manifest stays byte-identical.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast import cli
|
||||
from openultrasast.cli import main
|
||||
|
||||
_VULN = "@app.route('/admin')\ndef admin():\n return eval(request.data)\n"
|
||||
|
||||
|
||||
def _run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, config_body: str | None) -> dict:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text(_VULN)
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
argv = ["scan", str(repo), "--mode", "standard"]
|
||||
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]
|
||||
return json.loads((run_dir / "manifest.json").read_text())
|
||||
|
||||
|
||||
def test_degradation_recorded_when_models_set_but_extra_absent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert not cli.has_harnessx() # this CI env has no harnessx extra
|
||||
manifest = _run(tmp_path, monkeypatch, '[models]\nhunter = "anthropic/claude"\nverifier = "anthropic/claude"\n')
|
||||
|
||||
degradations = manifest.get("degradations", [])
|
||||
stages = {entry["stage"] for entry in degradations}
|
||||
assert stages == {"hunter_pool", "verify"}
|
||||
assert all(entry["reason"] == "harnessx_extra_unavailable" for entry in degradations)
|
||||
# Fallback still produced findings (deterministic hunter ran).
|
||||
assert manifest["findings"]
|
||||
|
||||
|
||||
def test_manifest_has_no_degradations_without_models(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
manifest = _run(tmp_path, monkeypatch, config_body=None)
|
||||
assert "degradations" not in manifest
|
||||
|
||||
|
||||
def test_standard_scan_falls_back_to_run_hunter_pool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[str] = []
|
||||
real = cli.run_hunter_pool
|
||||
monkeypatch.setattr(cli, "run_hunter_pool", lambda *a, **k: calls.append("fallback") or real(*a, **k))
|
||||
_run(tmp_path, monkeypatch, '[models]\nhunter = "anthropic/claude"\n')
|
||||
assert calls == ["fallback"]
|
||||
@@ -0,0 +1,48 @@
|
||||
"""HarnessX hunter sub-harness: guarding, findings parsing, construction smoke (task 6.1)."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.harness_ext import HarnessXUnavailableError, build_sast, has_harnessx
|
||||
from openultrasast.hunter_harness import HxScanOrchestrator, _extract_findings
|
||||
|
||||
|
||||
def test_module_import_does_not_pull_harnessx() -> None:
|
||||
import openultrasast.hunter_harness # noqa: F401
|
||||
|
||||
assert "harnessx" not in sys.modules
|
||||
|
||||
|
||||
def test_extract_findings_parses_json_array_and_tolerates_garbage() -> None:
|
||||
text = 'Here are the findings:\n[{"line": 9, "title": "SQLi", "cwe": "CWE-89", "rationale": "concat"}]\nDone.'
|
||||
items = _extract_findings(text)
|
||||
assert items == [{"line": 9, "title": "SQLi", "cwe": "CWE-89", "rationale": "concat"}]
|
||||
assert _extract_findings("no findings here") == []
|
||||
assert _extract_findings("[not, valid, json]") == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(has_harnessx(), reason="exercises the extra-absent guard")
|
||||
def test_build_sast_raises_without_extra() -> None:
|
||||
with pytest.raises(HarnessXUnavailableError):
|
||||
build_sast()
|
||||
|
||||
|
||||
@pytest.mark.skipif(has_harnessx(), reason="exercises the extra-absent guard")
|
||||
def test_orchestrator_construction_raises_without_extra() -> None:
|
||||
with pytest.raises(HarnessXUnavailableError):
|
||||
HxScanOrchestrator(provider_model="claude-sonnet-4-6")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not has_harnessx(), reason="construction smoke; requires the harnessx extra")
|
||||
def test_build_sast_returns_harness_config_offline() -> None:
|
||||
from harnessx.core.harness import HarnessConfig
|
||||
|
||||
assert isinstance(build_sast(), HarnessConfig)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not has_harnessx(), reason="construction smoke; requires the harnessx extra")
|
||||
def test_orchestrator_builds_agent_offline() -> None:
|
||||
# Constructs the model + harness without any LLM call (provider client is lazy).
|
||||
agent = HxScanOrchestrator(provider_model="claude-sonnet-4-6")._build_agent()
|
||||
assert type(agent).__name__ == "Harness"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Verifier dispatcher: structural fallback (default) + judge verdict mapping (Phase 3 task 6.4)."""
|
||||
|
||||
import sys
|
||||
|
||||
from openultrasast.findings import StaticFinding
|
||||
from openultrasast.verification import VerificationStatus, verify_findings
|
||||
from openultrasast.verify_judge import _verdict_to_verification, verify_findings_dispatch
|
||||
|
||||
|
||||
def _finding(reachability: str = "reachable") -> StaticFinding:
|
||||
return StaticFinding(
|
||||
finding_id="python-unsafe-eval:app.py:3",
|
||||
path="app.py",
|
||||
title="Dynamic Python execution needs review",
|
||||
severity="critical",
|
||||
confidence="medium",
|
||||
evidence_level="static_corroboration",
|
||||
rationale="Static pattern matched.",
|
||||
line=3,
|
||||
function_name="admin" if reachability == "reachable" else None,
|
||||
reachability_status=reachability,
|
||||
reachability_evidence=[{"kind": "route", "access_level": "public", "line": 1, "end_line": 3}]
|
||||
if reachability == "reachable"
|
||||
else [],
|
||||
reachability_conditions=[],
|
||||
tags=["syscall_entry"],
|
||||
ranking_priority=3.0,
|
||||
)
|
||||
|
||||
|
||||
def test_dispatch_falls_back_to_structural_by_default() -> None:
|
||||
findings = [_finding("reachable"), _finding("unknown")]
|
||||
assert verify_findings_dispatch(findings) == verify_findings(findings)
|
||||
|
||||
|
||||
def test_dispatch_stays_structural_when_model_unset_or_extra_absent() -> None:
|
||||
findings = [_finding("reachable")]
|
||||
# use_harnessx requested but no model -> structural; and no model -> structural.
|
||||
assert verify_findings_dispatch(findings, verifier_model=None, use_harnessx=True) == verify_findings(findings)
|
||||
assert verify_findings_dispatch(findings, verifier_model="claude-x", use_harnessx=False) == verify_findings(findings)
|
||||
|
||||
|
||||
def test_dispatch_does_not_import_harnessx_on_fallback() -> None:
|
||||
verify_findings_dispatch([_finding()], verifier_model="claude-x", use_harnessx=True)
|
||||
assert "harnessx" not in sys.modules
|
||||
|
||||
|
||||
def test_verdict_mapping_is_evidence_gated() -> None:
|
||||
finding = _finding("reachable")
|
||||
accepted = _verdict_to_verification(finding, {"verdict": "plausible", "confidence": 0.9, "cause": "tainted sink reached"})
|
||||
low_conf = _verdict_to_verification(finding, {"verdict": "plausible", "confidence": 0.3})
|
||||
rejected = _verdict_to_verification(finding, {"verdict": "unsupported", "cause": "input is constant"})
|
||||
hedging = _verdict_to_verification(finding, {"verdict": "hedging"})
|
||||
infra = _verdict_to_verification(finding, {"verdict": "no_answer"})
|
||||
|
||||
assert accepted.status == VerificationStatus.ACCEPTED and accepted.verified
|
||||
assert accepted.pro_case == "tainted sink reached"
|
||||
assert low_conf.status == VerificationStatus.NEEDS_EVIDENCE
|
||||
assert rejected.status == VerificationStatus.REJECTED and rejected.counter_case == "input is constant"
|
||||
assert hedging.status == VerificationStatus.NEEDS_EVIDENCE
|
||||
assert infra.status == VerificationStatus.NEEDS_EVIDENCE # infra failure never auto-rejects
|
||||
Reference in New Issue
Block a user