mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
feat(three-stage-scan): attach tool hunter to map hotspots
This commit is contained in:
+182
-21
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from openultrasast.complexity.map import Hotspot
|
||||
from openultrasast.tool_hunter import ChatResponse, ToolCall, run_tool_hunter
|
||||
from openultrasast.tool_hunter import ChatResponse, OpenRouterHunterClient, ScriptedChatClient, ToolCall, run_tool_hunter
|
||||
|
||||
SPLIT_SINK = """\
|
||||
term = request.args["q"]
|
||||
@@ -21,26 +23,6 @@ _DUMP_FINDINGS = [
|
||||
]
|
||||
|
||||
|
||||
class ScriptedChatClient:
|
||||
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]
|
||||
|
||||
|
||||
def _repo_with_split_sink(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "repo"
|
||||
root.mkdir()
|
||||
@@ -164,3 +146,182 @@ def test_max_steps_stops_before_ungrounded_json(tmp_path: Path) -> None:
|
||||
|
||||
assert findings == []
|
||||
assert len(client.calls) == 1
|
||||
|
||||
|
||||
_UNSAFE_SNIPPET = 'client = docker.DockerClient(base_url="unix://var/run/docker.sock")\n'
|
||||
_VULN = "@app.route('/admin')\ndef admin():\n return eval(request.data)\n"
|
||||
|
||||
|
||||
def test_hunter_json_snippet_stays_suspicion_and_is_not_static_corroboration(tmp_path: Path) -> None:
|
||||
root = _repo_with_split_sink(tmp_path)
|
||||
payload = [{**_DUMP_FINDINGS[0], "snippet": _UNSAFE_SNIPPET, "evidence_level": "static_corroboration"}]
|
||||
client = ScriptedChatClient(
|
||||
[
|
||||
ChatResponse(tool_calls=(ToolCall(id="call_grep", name="grep_repo", arguments={"pattern": "query"}),)),
|
||||
ChatResponse(content=json.dumps(payload)),
|
||||
]
|
||||
)
|
||||
|
||||
findings = run_tool_hunter(root, [_hotspot()], client=client, model="test-hunter", max_steps=4)
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].evidence_level == "suspicion"
|
||||
assert findings[0].finding_id.startswith("tool-hunter:")
|
||||
assert findings[0].proposed_snippet == _UNSAFE_SNIPPET
|
||||
|
||||
|
||||
def _write_hunter_config(tmp_path: Path, body: str = '[models]\nhunter = "test-hunter"\n') -> Path:
|
||||
config = tmp_path / "openultrasast.toml"
|
||||
config.write_text(body)
|
||||
return config
|
||||
|
||||
|
||||
def _standard_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "app.py").write_text(_VULN)
|
||||
return repo
|
||||
|
||||
|
||||
def _latest_run(repo: Path) -> Path:
|
||||
return sorted((repo / ".runs").iterdir())[-1]
|
||||
|
||||
|
||||
def _guard_docker(monkeypatch: pytest.MonkeyPatch) -> list[list[str]]:
|
||||
import subprocess
|
||||
|
||||
from openultrasast.sandbox import runner as sandbox_runner
|
||||
|
||||
docker_argv: list[list[str]] = []
|
||||
original_run = subprocess.run
|
||||
|
||||
def guarded_run(command: object, *args: object, **kwargs: object) -> object:
|
||||
argv = list(command) if isinstance(command, list | tuple) else [command]
|
||||
if argv and Path(str(argv[0])).name == "docker":
|
||||
docker_argv.append([str(item) for item in argv])
|
||||
raise AssertionError(f"hunter scan test must not start docker: {argv}")
|
||||
return original_run(command, *args, **kwargs)
|
||||
|
||||
def fail(*args: object, **kwargs: object) -> object:
|
||||
raise AssertionError("hunter scan test must not use DockerCliRunner")
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", guarded_run)
|
||||
monkeypatch.setattr(sandbox_runner.DockerCliRunner, "run", fail)
|
||||
return docker_argv
|
||||
|
||||
|
||||
def test_standard_scripted_hunter_emits_tool_hunter_suspicion(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from openultrasast import cli, tool_hunter
|
||||
from openultrasast.cli import main
|
||||
|
||||
repo = _standard_repo(tmp_path)
|
||||
config = _write_hunter_config(tmp_path)
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
monkeypatch.setenv("OPENULTRASAST_HUNTER_CLIENT", "scripted")
|
||||
monkeypatch.setattr(cli, "has_harnessx", lambda: False)
|
||||
docker_argv = _guard_docker(monkeypatch)
|
||||
hunter_calls: list[int] = []
|
||||
real_hunter = tool_hunter.run_tool_hunter
|
||||
|
||||
def wrapped(*args: object, **kwargs: object) -> object:
|
||||
hunter_calls.append(1)
|
||||
return real_hunter(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(tool_hunter, "run_tool_hunter", wrapped)
|
||||
|
||||
assert main(["scan", str(repo), "--mode", "standard", "--config", str(config)]) == 0
|
||||
|
||||
run_dir = _latest_run(repo)
|
||||
findings = json.loads((run_dir / "findings.json").read_text())["findings"]
|
||||
complexity_map = json.loads((run_dir / "complexity_map.json").read_text())
|
||||
manifest = json.loads((run_dir / "manifest.json").read_text())
|
||||
hunter_findings = [item for item in findings if str(item["finding_id"]).startswith("tool-hunter:")]
|
||||
|
||||
assert hunter_calls
|
||||
assert docker_argv == []
|
||||
assert hunter_findings
|
||||
assert all(item["evidence_level"] == "suspicion" for item in hunter_findings)
|
||||
assert all(item["evidence_level"] != "static_corroboration" for item in hunter_findings)
|
||||
assert complexity_map["heuristic_only"] is False
|
||||
assert manifest["complexity"]["heuristic_only"] is False
|
||||
assert not any(entry.get("reason") == "hunter_model_unavailable" for entry in manifest.get("degradations", []))
|
||||
|
||||
|
||||
def test_dump_only_hunter_client_emits_no_hunter_findings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from openultrasast import cli, tool_hunter
|
||||
from openultrasast.cli import main
|
||||
|
||||
repo = _standard_repo(tmp_path)
|
||||
config = _write_hunter_config(tmp_path)
|
||||
monkeypatch.setenv("OPENULTRASAST_RUNS_DIR", ".runs")
|
||||
monkeypatch.setenv("OPENULTRASAST_HUNTER_CLIENT", "dump-only")
|
||||
monkeypatch.setattr(cli, "has_harnessx", lambda: False)
|
||||
docker_argv = _guard_docker(monkeypatch)
|
||||
hunter_calls: list[int] = []
|
||||
real_hunter = tool_hunter.run_tool_hunter
|
||||
|
||||
def wrapped(*args: object, **kwargs: object) -> object:
|
||||
hunter_calls.append(1)
|
||||
return real_hunter(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(tool_hunter, "run_tool_hunter", wrapped)
|
||||
|
||||
assert main(["scan", str(repo), "--mode", "standard", "--config", str(config)]) == 0
|
||||
|
||||
run_dir = _latest_run(repo)
|
||||
findings = json.loads((run_dir / "findings.json").read_text())["findings"]
|
||||
complexity_map = json.loads((run_dir / "complexity_map.json").read_text())
|
||||
hunter_findings = [item for item in findings if str(item["finding_id"]).startswith("tool-hunter:")]
|
||||
|
||||
assert hunter_calls, "dump-only still runs the tool hunter loop"
|
||||
assert docker_argv == []
|
||||
assert hunter_findings == []
|
||||
assert complexity_map["heuristic_only"] is False
|
||||
|
||||
|
||||
def test_resolve_hunter_client_scripted_and_dump_only_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from openultrasast import tool_hunter
|
||||
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENULTRASAST_HUNTER_CLIENT", "scripted")
|
||||
scripted = tool_hunter.resolve_hunter_client()
|
||||
assert scripted is not None
|
||||
assert type(scripted).__name__ == "ScriptedChatClient"
|
||||
|
||||
monkeypatch.setenv("OPENULTRASAST_HUNTER_CLIENT", "dump-only")
|
||||
dump_only = tool_hunter.resolve_hunter_client()
|
||||
assert dump_only is not None
|
||||
assert type(dump_only).__name__ == "ScriptedChatClient"
|
||||
assert dump_only is not scripted
|
||||
|
||||
monkeypatch.delenv("OPENULTRASAST_HUNTER_CLIENT", raising=False)
|
||||
assert tool_hunter.resolve_hunter_client() is None
|
||||
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "test-key")
|
||||
live = tool_hunter.resolve_hunter_client()
|
||||
assert isinstance(live, OpenRouterHunterClient)
|
||||
|
||||
|
||||
def test_openrouter_hunter_client_maps_tool_calls_without_network() -> None:
|
||||
class _Inner:
|
||||
def complete_chat(self, **kwargs: object) -> dict[str, object]:
|
||||
assert kwargs["model"] == "test-hunter"
|
||||
assert kwargs["tools"]
|
||||
return {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_grep",
|
||||
"type": "function",
|
||||
"function": {"name": "grep_repo", "arguments": '{"pattern": "eval", "max_matches": 4}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
client = OpenRouterHunterClient(_Inner()) # type: ignore[arg-type]
|
||||
response = client.complete(model="test-hunter", messages=[], tools=[{"type": "function"}], timeout_seconds=5)
|
||||
|
||||
assert response.content is None
|
||||
assert len(response.tool_calls) == 1
|
||||
assert response.tool_calls[0].name == "grep_repo"
|
||||
assert response.tool_calls[0].arguments == {"pattern": "eval", "max_matches": 4}
|
||||
|
||||
Reference in New Issue
Block a user