feat(three-stage-scan): attach tool hunter to map hotspots

This commit is contained in:
Marius Ciepluch
2026-09-04 00:04:36 +02:00
parent 2efbbb8024
commit a916e2d173
10 changed files with 455 additions and 31 deletions
+66 -2
View File
@@ -3,10 +3,11 @@ from __future__ import annotations
import argparse
import json
import tempfile
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from time import perf_counter
from . import tool_hunter
from .benchmark import (
create_benchmark_run,
evaluate_benchmark,
@@ -48,7 +49,7 @@ from .run import ScanRun, create_scan_run
from .sandbox import resolve_sandbox_probe, resolve_sandbox_runner
from .scoring import build_score_artifact
from .stages import Stage, plan_for_mode, record_completed, record_skip, skip_as_degradation, stages_payload
from .verification import VerificationResult, write_verification_results
from .verification import VerificationResult, verify_findings, write_verification_results
from .verify_judge import verify_findings_dispatch
CALIBRATION_DIR = ".openultrasast/calibration"
@@ -320,6 +321,20 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
plan = record_completed(plan, Stage.MAP)
if not hunter_model:
runtime.state["degradations"].append(skip_as_degradation(Stage.MAP, "hunter_model_unavailable"))
else:
built_map, findings, verifications = _attach_tool_hunter(
root=run.target,
built_map=built_map,
findings=findings,
verifications=verifications,
hunter_model=hunter_model,
max_hotspots=config.complexity.max_hunter_hotspots,
max_findings=max_findings,
findings_path=findings_path,
verification_path=verification_path,
complexity_map_path=complexity_map_path,
runtime=runtime,
)
complexity_payload = {
"hotspot_count": len(built_map.hotspots),
"heuristic_only": built_map.heuristic_only,
@@ -396,6 +411,55 @@ def _run_scan(path: Path, config_path: Path, mode: str, fail_on: str) -> ScanOut
)
def _attach_tool_hunter(
*,
root: Path,
built_map: complexity_map.ComplexityMap,
findings: list[StaticFinding],
verifications: list[VerificationResult],
hunter_model: str,
max_hotspots: int,
max_findings: int,
findings_path: Path,
verification_path: Path,
complexity_map_path: Path,
runtime: HarnessRuntime,
) -> tuple[complexity_map.ComplexityMap, list[StaticFinding], list[VerificationResult]]:
client = tool_hunter.resolve_hunter_client()
if client is None:
return built_map, findings, verifications
hunter_hotspots = list(built_map.hotspots[: max(max_hotspots, 0)])
hunter_findings = runtime.run_stage(
"tool_hunter",
lambda: tool_hunter.run_tool_hunter(
root,
hunter_hotspots,
client=client,
model=hunter_model,
max_steps=tool_hunter.DEFAULT_MAX_STEPS,
),
)
seen = {finding.finding_id for finding in findings}
extra = [finding for finding in hunter_findings if finding.finding_id not in seen]
if extra:
findings = findings + extra
if max_findings and len(findings) > max_findings:
if not any(entry.get("reason") == "max_findings_exceeded" for entry in runtime.state["degradations"]):
runtime.state["degradations"].append(
{"stage": "budget", "reason": "max_findings_exceeded", "requested": max_findings, "actual": len(findings)}
)
findings = findings[:max_findings]
kept = {finding.finding_id for finding in findings}
extra = [finding for finding in extra if finding.finding_id in kept]
if extra:
verifications = verifications + verify_findings(extra)
write_verification_results(verifications, verification_path)
write_findings(findings, findings_path)
built_map = replace(built_map, heuristic_only=False)
complexity_map.write_complexity_map(built_map, complexity_map_path)
return built_map, findings, verifications
def _persist_calibration_feedback(
run: ScanRun,
ledger_path: Path,
+1
View File
@@ -28,6 +28,7 @@ class StaticFinding:
tags: list[str]
ranking_priority: float
status: str = "enabled"
proposed_snippet: str | None = None
SEVERITY_LABEL = {5: "critical", 4: "high", 3: "medium", 2: "low", 1: "low", 0: "info"}
+25 -6
View File
@@ -6,7 +6,7 @@ import re
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from typing import TypeVar, cast
@@ -61,8 +61,18 @@ class OpenRouterChatClient:
raise OpenRouterError("OPENROUTER_API_KEY is required for OpenRouter chat calls")
return cls(api_key=api_key, base_url=os.environ.get("OPENROUTER_BASE_URL", cls.base_url))
def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object:
payload = json.dumps({"model": model, "messages": messages, "temperature": 0}).encode()
def complete_chat(
self,
*,
model: str,
messages: Sequence[Mapping[str, object]],
tools: Sequence[Mapping[str, object]] | None = None,
timeout_seconds: int = 60,
) -> dict[str, object]:
body: dict[str, object] = {"model": model, "messages": list(messages), "temperature": 0}
if tools:
body["tools"] = list(tools)
payload = json.dumps(body).encode()
request = urllib.request.Request(
f"{self.base_url.rstrip('/')}/chat/completions",
data=payload,
@@ -81,8 +91,13 @@ class OpenRouterChatClient:
response_payload = call_with_retry(_do, attempts=self.max_attempts, base_delay=self.retry_base_delay)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
raise OpenRouterError(f"OpenRouter chat request failed: {exc}") from exc
return _extract_message(response_payload)
content = _extract_message_content(response_payload)
def complete_json(self, *, model: str, messages: list[dict[str, str]], timeout_seconds: int = 60) -> object:
message = self.complete_chat(model=model, messages=messages, timeout_seconds=timeout_seconds)
content = message.get("content")
if not isinstance(content, str) or not content.strip():
raise OpenRouterError("OpenRouter message content was empty")
return parse_json_content(content)
@@ -156,7 +171,7 @@ def parse_json_content(content: str) -> object:
raise OpenRouterError(f"model response was not valid JSON: {exc}") from exc
def _extract_message_content(payload: object) -> str:
def _extract_message(payload: object) -> dict[str, object]:
if not isinstance(payload, dict):
raise OpenRouterError("OpenRouter response must be a JSON object")
choices = payload.get("choices")
@@ -168,7 +183,11 @@ def _extract_message_content(payload: object) -> str:
message = first.get("message")
if not isinstance(message, dict):
raise OpenRouterError("OpenRouter choice did not include a message")
content = message.get("content")
return message
def _extract_message_content(payload: object) -> str:
content = _extract_message(payload).get("content")
if not isinstance(content, str) or not content.strip():
raise OpenRouterError("OpenRouter message content was empty")
return content
+11 -1
View File
@@ -196,7 +196,7 @@ def run_regression(
records: list[CandidateVerdict] = []
for hotspot in selected:
language = _language_for(hotspot.path, languages_by_path)
snippet = snippet_for(language, hotspot.path, hotspot.function_name)
snippet = _proposed_snippet(hotspot, findings) or snippet_for(language, hotspot.path, hotspot.function_name)
image = images.get(language) or DEFAULT_IMAGES.get(language, "ousast-missing-image")
mapped = runner.run_recipe(language, snippet, image, sandbox_limits, repo_root=repo_root)
reachability = _reachability_for(hotspot, findings_by_id)
@@ -220,6 +220,16 @@ def write_verdicts(verdicts: Sequence[CandidateVerdict], path: Path) -> None:
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
def _proposed_snippet(hotspot: Hotspot, findings: Sequence[StaticFinding]) -> str | None:
for finding in findings:
snippet = finding.proposed_snippet
if not snippet:
continue
if finding.path == hotspot.path or finding.finding_id in hotspot.inventory_finding_ids:
return snippet
return None
def _language_for(path: str, languages_by_path: Mapping[str, str]) -> str:
if path in languages_by_path:
return languages_by_path[path]
+132
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import json
import os
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
@@ -12,12 +13,29 @@ from typing import Protocol
from .complexity.map import Hotspot
from .findings import StaticFinding
from .hunter_tools import PathEscapesRepo, clamp_repo_path, find_refs, grep_repo, read_file
from .provider.openrouter import OpenRouterChatClient, OpenRouterError
_DEFAULT_MAX_CHARS = 4000
_DEFAULT_MAX_MATCHES = 50
DEFAULT_MAX_STEPS = 4
CLIENT_ENV = "OPENULTRASAST_HUNTER_CLIENT"
_FINDING_ID_PREFIX = "tool-hunter:"
_HUNTER_TOOL_NAMES = frozenset({"read_file", "grep_repo", "find_refs"})
_SEVERITIES = frozenset({"critical", "high", "medium", "low", "info"})
_SCRIPTED_FLAGS = frozenset({"scripted", "script"})
_DUMP_FLAGS = frozenset({"dump", "dump-only"})
_UNSAFE_FLAGS = frozenset({"unsafe", "unsafe-snippet"})
_OPENROUTER_FLAGS = frozenset({"openrouter", "live"})
_UNSAFE_SNIPPET = 'client = docker.DockerClient(base_url="unix://var/run/docker.sock")\n'
_SCRIPTED_FINDINGS = [
{
"path": "app.py",
"line": 3,
"title": "Dynamic execution of attacker-controlled data",
"rationale": "eval/execute follows request input across the hotspot.",
"evidence_level": "static_corroboration",
}
]
HUNTER_TOOLS: list[dict[str, object]] = [
{
@@ -97,6 +115,117 @@ class ChatClient(Protocol):
raise NotImplementedError
class ScriptedChatClient:
"""Deterministic ChatClient for tests and OPENULTRASAST_HUNTER_CLIENT injection."""
def __init__(self, turns: list[ChatResponse]) -> None:
self._turns = turns
self.calls: list[dict[str, object]] = []
def complete(
self,
*,
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]],
timeout_seconds: int = 60,
) -> ChatResponse:
self.calls.append({"model": model, "messages": list(messages), "tools": tools, "timeout_seconds": timeout_seconds})
index = len(self.calls) - 1
if index >= len(self._turns):
return ChatResponse(content="")
return self._turns[index]
class OpenRouterHunterClient:
"""Adapt OpenRouterChatClient to the tool-hunter ChatClient protocol."""
def __init__(self, client: OpenRouterChatClient) -> None:
self._client = client
@classmethod
def from_env(cls) -> OpenRouterHunterClient:
return cls(OpenRouterChatClient.from_env())
def complete(
self,
*,
model: str,
messages: list[dict[str, object]],
tools: list[dict[str, object]],
timeout_seconds: int = 60,
) -> ChatResponse:
message = self._client.complete_chat(model=model, messages=messages, tools=tools, timeout_seconds=timeout_seconds)
return chat_response_from_message(message)
def scripted_hunter_client(mode: str = "scripted") -> ScriptedChatClient:
grep_turn = ChatResponse(
tool_calls=(ToolCall(id="call_grep", name="grep_repo", arguments={"pattern": "eval|execute|query", "max_matches": 10}),)
)
if mode in _DUMP_FLAGS:
return ScriptedChatClient([ChatResponse(content=json.dumps(_SCRIPTED_FINDINGS))])
if mode in _UNSAFE_FLAGS:
payload = [{**_SCRIPTED_FINDINGS[0], "snippet": _UNSAFE_SNIPPET}]
return ScriptedChatClient([grep_turn, ChatResponse(content=json.dumps(payload))])
return ScriptedChatClient([grep_turn, ChatResponse(content=json.dumps(_SCRIPTED_FINDINGS))])
def resolve_hunter_client() -> ChatClient | None:
"""Return a hunter client from env, or OpenRouter when an API key is present."""
flag = os.environ.get(CLIENT_ENV, "").strip().lower()
if flag in _SCRIPTED_FLAGS:
return scripted_hunter_client("scripted")
if flag in _DUMP_FLAGS:
return scripted_hunter_client("dump-only")
if flag in _UNSAFE_FLAGS:
return scripted_hunter_client("unsafe-snippet")
if flag in _OPENROUTER_FLAGS or (not flag and os.environ.get("OPENROUTER_API_KEY")):
try:
return OpenRouterHunterClient.from_env()
except OpenRouterError:
return None
return None
def chat_response_from_message(message: Mapping[str, object]) -> ChatResponse:
content = message.get("content")
text = content if isinstance(content, str) else None
raw_calls = message.get("tool_calls") or ()
calls: list[ToolCall] = []
if isinstance(raw_calls, Sequence) and not isinstance(raw_calls, str | bytes):
for index, item in enumerate(raw_calls):
parsed = _tool_call_from_openrouter(item, index)
if parsed is not None:
calls.append(parsed)
return ChatResponse(content=text, tool_calls=tuple(calls))
def _tool_call_from_openrouter(item: object, index: int) -> ToolCall | None:
if not isinstance(item, Mapping):
return None
function = item.get("function")
if not isinstance(function, Mapping):
return None
name = function.get("name")
if not isinstance(name, str) or not name:
return None
call_id = item.get("id")
identifier = call_id if isinstance(call_id, str) and call_id else f"call_{index}"
arguments = function.get("arguments", {})
if isinstance(arguments, str):
try:
loaded = json.loads(arguments)
except json.JSONDecodeError:
loaded = {}
parsed_args = loaded if isinstance(loaded, dict) else {}
elif isinstance(arguments, Mapping):
parsed_args = dict(arguments)
else:
parsed_args = {}
return ToolCall(id=identifier, name=name, arguments=parsed_args)
def run_tool_hunter(
root: Path,
hotspots: list[Hotspot],
@@ -268,6 +397,8 @@ def _finding_from_item(root: Path, item: Mapping[str, object], scores: Mapping[s
rationale = item.get("rationale")
function_name = item.get("function_name")
severity = item.get("severity")
snippet = item.get("snippet")
proposed_snippet = snippet if isinstance(snippet, str) and snippet else None
return StaticFinding(
finding_id=f"{_FINDING_ID_PREFIX}{relative}:{line_no if line_no is not None else 0}",
path=relative,
@@ -283,6 +414,7 @@ def _finding_from_item(root: Path, item: Mapping[str, object], scores: Mapping[s
reachability_conditions=[],
tags=["tool-hunter"],
ranking_priority=float(scores.get(relative, 0.0)),
proposed_snippet=proposed_snippet,
)