mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(fusion): Phase 13 — two-panel fusion adjudication
OpenUltraCode-style deepening for findings that need more reasoning than the ranker→hunter→verifier→mapping loop provides. - fusion.py (zero-dep core + lazy LLM path): a deterministic trigger policy (critical/high severity, verifier disagreement, static-vs-semantic conflict, gates-risky-fix, high-assurance request); two panels that steel-man the vulnerability and false-positive cases; a deterministic, auditable decider that reconciles to one of five dispositions (accepted/rejected/mitigated/deferred/ blocked); full disclosure (votes, model IDs, degradations, warnings, decision source). fuse_findings_dispatch routes panels through the configured HarnessX provider when panel_model + extra are present, else deterministic + a degradation. - config [fusion]: enabled (default true), panel_model, decider_model, high_assurance. - cli: standard-mode fusion stage runs on triggered findings, writes fusion.json and a manifest `fusion` block; quick mode never fuses. reports.write_manifest gained the fusion summary param. - 8.4 zero-dep guard extended to cover fusion.py (lazy harnessx import only). - README fusion section marked implemented + documented; openrouter-sast-harness Phase 13 checked off. tests/test_fusion.py (16 tests) + config tests. 169 passed, 4 skipped; ruff + mypy clean; detection gate PASS. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b9caeeb835
commit
867bd536a2
@@ -55,6 +55,24 @@ def test_load_config_reads_harnessx_section(tmp_path: Path) -> None:
|
||||
assert config.harnessx.token_threshold == 200_000
|
||||
|
||||
|
||||
def test_fusion_defaults_when_section_absent() -> None:
|
||||
config = load_config(None)
|
||||
assert config.fusion.enabled is True
|
||||
assert config.fusion.panel_model is None
|
||||
assert config.fusion.high_assurance is False
|
||||
|
||||
|
||||
def test_load_config_reads_fusion_section(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "openultrasast.toml"
|
||||
config_path.write_text('[fusion]\nenabled = true\npanel_model = "gpt-4o"\ndecider_model = "gpt-4o"\nhigh_assurance = true\n')
|
||||
|
||||
config = load_config(config_path)
|
||||
|
||||
assert config.fusion.panel_model == "gpt-4o"
|
||||
assert config.fusion.decider_model == "gpt-4o"
|
||||
assert config.fusion.high_assurance is True
|
||||
|
||||
|
||||
def test_write_resolved_config_creates_json(tmp_path: Path) -> None:
|
||||
output = tmp_path / "run" / "resolved_config.json"
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Fusion adjudication: trigger policy, two-panel decider, dispositions (Phase 13)."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.findings import StaticFinding
|
||||
from openultrasast.fusion import (
|
||||
FusionDisposition,
|
||||
fuse_finding,
|
||||
fuse_findings_dispatch,
|
||||
fusion_triggers,
|
||||
should_fuse,
|
||||
)
|
||||
from openultrasast.harness_ext import has_harnessx
|
||||
from openultrasast.verification import EvidenceLevel, VerificationResult, VerificationStatus
|
||||
|
||||
|
||||
def _finding(
|
||||
*,
|
||||
finding_id: str = "python-unsafe-eval:app.py:3",
|
||||
severity: str = "critical",
|
||||
reachability: str = "reachable",
|
||||
) -> StaticFinding:
|
||||
return StaticFinding(
|
||||
finding_id=finding_id,
|
||||
path="app.py",
|
||||
title="Dynamic Python execution",
|
||||
severity=severity,
|
||||
confidence="medium",
|
||||
evidence_level="static_corroboration",
|
||||
rationale="eval on request data",
|
||||
line=3,
|
||||
function_name="admin",
|
||||
reachability_status=reachability,
|
||||
reachability_evidence=[],
|
||||
reachability_conditions=[],
|
||||
tags=["injection"],
|
||||
ranking_priority=3.0,
|
||||
)
|
||||
|
||||
|
||||
def _verification(status: VerificationStatus, *, verified: bool) -> VerificationResult:
|
||||
return VerificationResult(
|
||||
finding_id="python-unsafe-eval:app.py:3",
|
||||
status=status,
|
||||
evidence_level=EvidenceLevel.STATIC_CORROBORATION,
|
||||
verified=verified,
|
||||
pro_case="reachable tainted sink",
|
||||
counter_case="input may be constant",
|
||||
tie_breaker="confirm attacker control",
|
||||
required_next_step="dynamic repro",
|
||||
context_sources=["app.py"],
|
||||
)
|
||||
|
||||
|
||||
# ---- trigger policy ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_triggers_on_high_severity_and_reachable() -> None:
|
||||
triggers = fusion_triggers(_finding(severity="critical", reachability="reachable"))
|
||||
assert "severity:critical" in triggers
|
||||
assert "gates_risky_fix" in triggers
|
||||
|
||||
|
||||
def test_low_severity_unreachable_does_not_trigger() -> None:
|
||||
assert not should_fuse(_finding(severity="low", reachability="unknown"))
|
||||
|
||||
|
||||
def test_verifier_disagreement_triggers() -> None:
|
||||
triggers = fusion_triggers(
|
||||
_finding(severity="low", reachability="unknown"),
|
||||
_verification(VerificationStatus.NEEDS_EVIDENCE, verified=False),
|
||||
)
|
||||
assert triggers == ["verifier_disagreement"]
|
||||
|
||||
|
||||
def test_evidence_conflict_triggers_when_rejected_but_reachable() -> None:
|
||||
triggers = fusion_triggers(
|
||||
_finding(severity="low", reachability="reachable"),
|
||||
_verification(VerificationStatus.REJECTED, verified=False),
|
||||
)
|
||||
assert "evidence_conflict" in triggers
|
||||
|
||||
|
||||
def test_high_assurance_request_forces_a_trigger() -> None:
|
||||
assert should_fuse(_finding(severity="low", reachability="unknown"), high_assurance=True)
|
||||
|
||||
|
||||
# ---- deterministic two-panel decider + dispositions -------------------------
|
||||
|
||||
|
||||
def test_accepted_when_reachable_and_verified() -> None:
|
||||
decision = fuse_finding(_finding(), _verification(VerificationStatus.ACCEPTED, verified=True), ["severity:critical"])
|
||||
assert decision.disposition == FusionDisposition.ACCEPTED
|
||||
assert decision.decision_source == "deterministic-reconciler"
|
||||
assert {p.role for p in decision.panels} == {"panel_a", "panel_b"}
|
||||
assert sum(decision.votes.values()) == 2 # both panels voted
|
||||
|
||||
|
||||
def test_blocked_when_reachable_high_severity_but_unverified() -> None:
|
||||
decision = fuse_finding(_finding(), _verification(VerificationStatus.NEEDS_EVIDENCE, verified=False), ["gates_risky_fix"])
|
||||
assert decision.disposition == FusionDisposition.BLOCKED
|
||||
|
||||
|
||||
def test_rejected_when_verifier_rejects_unreachable() -> None:
|
||||
decision = fuse_finding(
|
||||
_finding(reachability="unknown"), _verification(VerificationStatus.REJECTED, verified=False), ["severity:critical"]
|
||||
)
|
||||
assert decision.disposition == FusionDisposition.REJECTED
|
||||
|
||||
|
||||
def test_deferred_when_evidence_conflicts() -> None:
|
||||
decision = fuse_finding(_finding(severity="low"), _verification(VerificationStatus.REJECTED, verified=False), ["evidence_conflict"])
|
||||
assert decision.disposition == FusionDisposition.DEFERRED
|
||||
|
||||
|
||||
def test_mitigated_when_finding_is_known_mitigated() -> None:
|
||||
finding = _finding()
|
||||
decision = fuse_finding(
|
||||
finding,
|
||||
_verification(VerificationStatus.ACCEPTED, verified=True),
|
||||
["severity:critical"],
|
||||
mitigated_ids=frozenset({finding.finding_id}),
|
||||
)
|
||||
assert decision.disposition == FusionDisposition.MITIGATED
|
||||
|
||||
|
||||
# ---- dispatch + disclosure + serialization ----------------------------------
|
||||
|
||||
|
||||
def test_dispatch_only_fuses_triggered_findings() -> None:
|
||||
findings = [
|
||||
_finding(finding_id="hot:app.py:3", severity="critical"),
|
||||
_finding(finding_id="cold:lib.py:9", severity="low", reachability="unknown"),
|
||||
]
|
||||
decisions = fuse_findings_dispatch(findings, [])
|
||||
assert [d.finding_id for d in decisions] == ["hot:app.py:3"]
|
||||
|
||||
|
||||
def test_dispatch_records_degradation_when_llm_requested_but_extra_absent() -> None:
|
||||
if has_harnessx():
|
||||
pytest.skip("HarnessX extra installed; exercises the extra-absent degradation path")
|
||||
decisions = fuse_findings_dispatch([_finding()], [], panel_model="gpt-4o", use_harnessx=True)
|
||||
assert decisions and decisions[0].decision_source == "deterministic-reconciler"
|
||||
assert any("fusion_llm_unavailable" in note for note in decisions[0].degradations)
|
||||
|
||||
|
||||
def test_decision_serializes_with_full_disclosure() -> None:
|
||||
decision = fuse_finding(_finding(), _verification(VerificationStatus.ACCEPTED, verified=True), ["severity:critical"])
|
||||
payload = json.loads(json.dumps(decision.to_dict()))
|
||||
assert payload["disposition"] == "accepted"
|
||||
assert payload["decision_source"] == "deterministic-reconciler"
|
||||
assert "votes" in payload and "model_ids" in payload and "degradations" in payload
|
||||
assert all("leaning" in panel and "disposition" in panel for panel in payload["panels"])
|
||||
|
||||
|
||||
# ---- CLI integration: standard-mode fusion stage ----------------------------
|
||||
|
||||
|
||||
def test_standard_scan_emits_fusion_artifact_and_manifest_block(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from openultrasast.cli import main
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text("@app.route('/admin')\ndef admin():\n return eval(request.data)\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
assert main(["scan", str(repo), "--mode", "standard"]) == 0
|
||||
|
||||
run_dir = sorted((repo / ".runs").iterdir())[-1]
|
||||
fusion = json.loads((run_dir / "fusion.json").read_text())
|
||||
assert fusion and fusion[0]["disposition"] in {"accepted", "blocked", "deferred", "rejected", "mitigated"}
|
||||
manifest = json.loads((run_dir / "manifest.json").read_text())
|
||||
assert manifest["fusion"][0]["finding_id"] == fusion[0]["finding_id"]
|
||||
assert "fusion" in manifest["artifacts"]
|
||||
|
||||
|
||||
def test_quick_scan_does_not_emit_fusion(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from openultrasast.cli import main
|
||||
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text("@app.route('/admin')\ndef admin():\n return eval(request.data)\n")
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
assert main(["scan", str(repo), "--mode", "quick"]) == 0
|
||||
|
||||
run_dir = sorted((repo / ".runs").iterdir())[-1]
|
||||
assert not (run_dir / "fusion.json").exists()
|
||||
assert "fusion" not in json.loads((run_dir / "manifest.json").read_text())
|
||||
@@ -146,6 +146,7 @@ GOVERNANCE_SCORING_BENCHMARK_PLANES = (
|
||||
"openultrasast.improve.evolve",
|
||||
"openultrasast.slot_contract",
|
||||
"openultrasast.stage_processors",
|
||||
"openultrasast.fusion",
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user