mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 20:19:29 +00:00
Add a [harnessx] config block so the agentic plane can be steered from
openultrasast.toml, and make the LLM provider selectable (the hunter and verifier
previously hardcoded AnthropicProvider).
- config.HarnessxConfig: provider ("anthropic"|"openai"|"litellm", default
anthropic), max_cost_usd (2.0), token_threshold (120000); parsed from [harnessx].
- harness_ext.build_provider: single lazy, capability-guarded provider factory.
The provider name is validated before any import, so an unknown choice fails with
a clear ValueError even when the extra is absent; each provider reads its own
standard key env var (openai -> OPENAI_API_KEY, anthropic -> ANTHROPIC_API_KEY).
- HxScanOrchestrator and verify_findings_dispatch take a provider and route through
build_provider; cli threads config.harnessx.{provider,max_cost_usd,token_threshold}
into both the hunter and the verifier.
- README: "Configuring the HarnessX agentic plane" section (install, models,
provider, budgets, fallback/degradation).
Test isolation: convert the "harnessx not in sys.modules" import-guards to a
hermetic subprocess fixture (tests/conftest.py assert_cold_of_harnessx) and
simulate extra-absent in the CLI dispatch tests, so the suite is deterministic
whether or not the optional extra is installed (it now is, locally). Verified the
OpenAI path constructs end-to-end (build_provider -> OpenAIProvider; orchestrator
-> Harness) with the budget threaded through.
137 passed, 3 skipped; ruff + mypy clean; detection gate PASS.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
131 lines
5.4 KiB
Python
131 lines
5.4 KiB
Python
"""Verifier dispatcher: HarnessX llm-judge when available, structural fallback otherwise.
|
|
|
|
The default path (no extra) is the existing :func:`verification.verify_findings`
|
|
verbatim — same ``list[VerificationResult]``, same rejection of unknown reachability —
|
|
so behaviour is byte-identical without HarnessX. The judge path is lazy/guarded.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from .findings import StaticFinding
|
|
from .harness_ext import build_provider, has_harnessx
|
|
from .verification import (
|
|
EvidenceLevel,
|
|
VerificationResult,
|
|
VerificationStatus,
|
|
build_independent_verifier_context,
|
|
is_report_verified,
|
|
verify_findings,
|
|
)
|
|
|
|
|
|
def verify_findings_dispatch(
|
|
findings: list[StaticFinding],
|
|
*,
|
|
verifier_model: str | None = None,
|
|
verifier_provider: str = "anthropic",
|
|
use_harnessx: bool = False,
|
|
) -> list[VerificationResult]:
|
|
"""Drop-in superset of :func:`verification.verify_findings`.
|
|
|
|
Falls back to the structural verifier unless the HarnessX extra is present, a
|
|
verifier model is configured, and ``use_harnessx`` is set.
|
|
"""
|
|
if not (use_harnessx and verifier_model and has_harnessx()):
|
|
return verify_findings(findings)
|
|
return _verify_with_judge(findings, verifier_model, verifier_provider)
|
|
|
|
|
|
def _verify_with_judge(findings: list[StaticFinding], verifier_model: str, verifier_provider: str) -> list[VerificationResult]:
|
|
import asyncio
|
|
|
|
return asyncio.run(_judge_all(findings, verifier_model, verifier_provider))
|
|
|
|
|
|
async def _judge_all(findings: list[StaticFinding], verifier_model: str, verifier_provider: str) -> list[VerificationResult]:
|
|
from harnessx.core.events import Message
|
|
from harnessx.processors.evaluation.llm_judge import build_judge_prompt, parse_judge_response
|
|
|
|
provider = build_provider(verifier_model, verifier_provider)
|
|
results: list[VerificationResult] = []
|
|
for finding in findings:
|
|
prompt = build_judge_prompt(
|
|
task_description=f"Is this a real, exploitable security issue: {finding.title}? Judge on evidence only.",
|
|
trajectory_summary=_finding_summary(finding),
|
|
extracted_answer=finding.rationale,
|
|
)
|
|
try:
|
|
response = await provider.complete(messages=[Message(role="user", content=prompt)], tools=[])
|
|
verdict = parse_judge_response(_text(response.content))
|
|
except Exception: # noqa: BLE001 — judge/provider failure must never auto-reject a finding
|
|
verdict = {"verdict": "no_answer", "confidence": 0.0}
|
|
results.append(_verdict_to_verification(finding, verdict))
|
|
return results
|
|
|
|
|
|
def _verdict_to_verification(finding: StaticFinding, verdict: dict[str, object]) -> VerificationResult:
|
|
"""Map an llm-judge verdict onto a VerificationResult (infra failure never auto-rejects)."""
|
|
label = str(verdict.get("verdict", "no_answer"))
|
|
raw_confidence = verdict.get("confidence", 0.0)
|
|
confidence = 0.0
|
|
if isinstance(raw_confidence, (int, float)):
|
|
confidence = float(raw_confidence)
|
|
elif isinstance(raw_confidence, str):
|
|
try:
|
|
confidence = float(raw_confidence)
|
|
except ValueError:
|
|
confidence = 0.0
|
|
cause = str(verdict.get("cause", "")).strip()
|
|
lesson = str(verdict.get("lesson", "")).strip()
|
|
missing = str(verdict.get("missing", "")).strip() or _missing_capability_summary(verdict)
|
|
level = EvidenceLevel(finding.evidence_level)
|
|
|
|
if label == "plausible" and confidence >= 0.6:
|
|
status = VerificationStatus.ACCEPTED
|
|
elif label in ("unsupported", "refused"):
|
|
status = VerificationStatus.REJECTED
|
|
else: # hedging / format_wrong / no_answer / low-confidence plausible
|
|
status = VerificationStatus.NEEDS_EVIDENCE
|
|
|
|
if status == VerificationStatus.ACCEPTED:
|
|
pro_case = cause or "The judge found the issue plausible on the available evidence."
|
|
counter_case = "Impact and exploitability still require deeper verifier or dynamic evidence."
|
|
else:
|
|
pro_case = "A candidate issue exists from the finding's evidence."
|
|
counter_case = cause or "The judge could not confirm the issue from the available evidence."
|
|
|
|
return VerificationResult(
|
|
finding_id=finding.finding_id,
|
|
status=status,
|
|
evidence_level=level,
|
|
verified=is_report_verified(level, status),
|
|
pro_case=pro_case,
|
|
counter_case=counter_case,
|
|
tie_breaker=lesson or "Confirm attacker control, sanitizer absence, and impact.",
|
|
required_next_step=missing or "Collect corroborating evidence before reporting as verified.",
|
|
context_sources=sorted(build_independent_verifier_context(finding).keys()),
|
|
)
|
|
|
|
|
|
def _finding_summary(finding: StaticFinding) -> str:
|
|
return (
|
|
f"{finding.title} at {finding.path}:{finding.line} (function={finding.function_name or 'unknown'}); "
|
|
f"reachability={finding.reachability_status}; tags={', '.join(finding.tags) or 'none'}"
|
|
)
|
|
|
|
|
|
def _missing_capability_summary(verdict: dict[str, object]) -> str:
|
|
capability = verdict.get("missing_capability")
|
|
if isinstance(capability, dict):
|
|
return str(capability.get("summary", "")).strip()
|
|
return ""
|
|
|
|
|
|
def _text(content: object) -> str:
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts = [str(block.get("text", "")) if isinstance(block, dict) else str(block) for block in content]
|
|
return "".join(parts)
|
|
return str(content)
|