2026-06-22 16:29:52 +02:00
|
|
|
"""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
|
2026-06-22 17:59:44 +02:00
|
|
|
from .harness_ext import build_provider, has_harnessx
|
2026-06-22 16:29:52 +02:00
|
|
|
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,
|
2026-06-22 17:59:44 +02:00
|
|
|
verifier_provider: str = "anthropic",
|
2026-06-22 16:29:52 +02:00
|
|
|
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)
|
2026-06-22 17:59:44 +02:00
|
|
|
return _verify_with_judge(findings, verifier_model, verifier_provider)
|
2026-06-22 16:29:52 +02:00
|
|
|
|
|
|
|
|
|
2026-06-22 17:59:44 +02:00
|
|
|
def _verify_with_judge(findings: list[StaticFinding], verifier_model: str, verifier_provider: str) -> list[VerificationResult]:
|
2026-06-22 16:29:52 +02:00
|
|
|
import asyncio
|
|
|
|
|
|
2026-06-22 17:59:44 +02:00
|
|
|
return asyncio.run(_judge_all(findings, verifier_model, verifier_provider))
|
2026-06-22 16:29:52 +02:00
|
|
|
|
|
|
|
|
|
2026-06-22 17:59:44 +02:00
|
|
|
async def _judge_all(findings: list[StaticFinding], verifier_model: str, verifier_provider: str) -> list[VerificationResult]:
|
2026-06-22 16:29:52 +02:00
|
|
|
from harnessx.core.events import Message
|
|
|
|
|
from harnessx.processors.evaluation.llm_judge import build_judge_prompt, parse_judge_response
|
|
|
|
|
|
2026-06-22 17:59:44 +02:00
|
|
|
provider = build_provider(verifier_model, verifier_provider)
|
2026-06-22 16:29:52 +02:00
|
|
|
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)
|