mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
167 lines
5.7 KiB
Python
167 lines
5.7 KiB
Python
import json
|
|||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from openultrasast.complexity.map import Hotspot
|
||
|
|
from openultrasast.tool_hunter import ChatResponse, ToolCall, run_tool_hunter
|
||
|
|
|
||
|
|
SPLIT_SINK = """\
|
||
|
|
term = request.args["q"]
|
||
|
|
query = "select * from items where title like '%" + term + "%'"
|
||
|
|
return db.execute(query)
|
||
|
|
"""
|
||
|
|
|
||
|
|
_DUMP_FINDINGS = [
|
||
|
|
{
|
||
|
|
"path": "app.py",
|
||
|
|
"line": 3,
|
||
|
|
"title": "SQL injection",
|
||
|
|
"rationale": "query is built from request input then executed",
|
||
|
|
"evidence_level": "static_corroboration",
|
||
|
|
}
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
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()
|
||
|
|
(root / "app.py").write_text(SPLIT_SINK)
|
||
|
|
return root
|
||
|
|
|
||
|
|
|
||
|
|
def _hotspot(path: str = "app.py") -> Hotspot:
|
||
|
|
return Hotspot(
|
||
|
|
path=path,
|
||
|
|
function_name="search",
|
||
|
|
score=7.5,
|
||
|
|
band="high",
|
||
|
|
signals={"loc": 3},
|
||
|
|
rationale="reachable split-sink",
|
||
|
|
test_hint=None,
|
||
|
|
inventory_finding_ids=(),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _tool_names(tools: object) -> set[str]:
|
||
|
|
names: set[str] = set()
|
||
|
|
if not isinstance(tools, list):
|
||
|
|
return names
|
||
|
|
for item in tools:
|
||
|
|
if not isinstance(item, dict):
|
||
|
|
continue
|
||
|
|
function = item.get("function")
|
||
|
|
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
||
|
|
names.add(str(function["name"]))
|
||
|
|
elif isinstance(item.get("name"), str):
|
||
|
|
names.add(str(item["name"]))
|
||
|
|
return names
|
||
|
|
|
||
|
|
|
||
|
|
def test_json_dump_without_tool_call_emits_no_findings(tmp_path: Path) -> None:
|
||
|
|
root = _repo_with_split_sink(tmp_path)
|
||
|
|
client = ScriptedChatClient([ChatResponse(content=json.dumps(_DUMP_FINDINGS))])
|
||
|
|
|
||
|
|
findings = run_tool_hunter(root, [_hotspot()], client=client, model="test-hunter", max_steps=4)
|
||
|
|
|
||
|
|
assert client.calls, "the hunter loop must run when a model client is provided"
|
||
|
|
assert _tool_names(client.calls[0]["tools"]) >= {"read_file", "grep_repo", "find_refs"}
|
||
|
|
assert findings == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_grep_then_json_yields_suspicion_with_tool_hunter_prefix(tmp_path: Path) -> None:
|
||
|
|
root = _repo_with_split_sink(tmp_path)
|
||
|
|
client = ScriptedChatClient(
|
||
|
|
[
|
||
|
|
ChatResponse(tool_calls=(ToolCall(id="call_grep", name="grep_repo", arguments={"pattern": r"query\s*=", "max_matches": 10}),)),
|
||
|
|
ChatResponse(content=json.dumps(_DUMP_FINDINGS)),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
findings = run_tool_hunter(root, [_hotspot()], client=client, model="test-hunter", max_steps=4)
|
||
|
|
|
||
|
|
assert len(client.calls) >= 2
|
||
|
|
assert _tool_names(client.calls[0]["tools"]) >= {"read_file", "grep_repo", "find_refs"}
|
||
|
|
tool_trace = json.dumps(client.calls[1]["messages"])
|
||
|
|
assert "query =" in tool_trace
|
||
|
|
assert len(findings) == 1
|
||
|
|
assert findings[0].evidence_level == "suspicion"
|
||
|
|
assert findings[0].finding_id.startswith("tool-hunter:")
|
||
|
|
assert findings[0].path == "app.py"
|
||
|
|
assert findings[0].line == 3
|
||
|
|
assert findings[0].title == "SQL injection"
|
||
|
|
|
||
|
|
|
||
|
|
def test_loop_dispatches_read_file_grep_repo_and_find_refs(tmp_path: Path) -> None:
|
||
|
|
root = _repo_with_split_sink(tmp_path)
|
||
|
|
client = ScriptedChatClient(
|
||
|
|
[
|
||
|
|
ChatResponse(
|
||
|
|
tool_calls=(
|
||
|
|
ToolCall(id="call_read", name="read_file", arguments={"path": "app.py", "max_chars": 4000}),
|
||
|
|
ToolCall(id="call_grep", name="grep_repo", arguments={"pattern": r"db\.execute", "max_matches": 5}),
|
||
|
|
ToolCall(id="call_refs", name="find_refs", arguments={"symbol": "query"}),
|
||
|
|
)
|
||
|
|
),
|
||
|
|
ChatResponse(content=json.dumps(_DUMP_FINDINGS)),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
findings = run_tool_hunter(root, [_hotspot()], client=client, model="test-hunter", max_steps=4)
|
||
|
|
|
||
|
|
assert findings
|
||
|
|
assert findings[0].evidence_level == "suspicion"
|
||
|
|
second_messages = json.dumps(client.calls[1]["messages"])
|
||
|
|
assert "term = request.args" in second_messages
|
||
|
|
assert "db.execute" in second_messages
|
||
|
|
assert "query" in second_messages
|
||
|
|
|
||
|
|
|
||
|
|
def test_path_escape_stays_in_band_and_still_counts_as_tool_use(tmp_path: Path) -> None:
|
||
|
|
root = _repo_with_split_sink(tmp_path)
|
||
|
|
client = ScriptedChatClient(
|
||
|
|
[
|
||
|
|
ChatResponse(tool_calls=(ToolCall(id="call_escape", name="read_file", arguments={"path": "../etc/passwd"}),)),
|
||
|
|
ChatResponse(content=json.dumps(_DUMP_FINDINGS)),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
findings = run_tool_hunter(root, [_hotspot()], client=client, model="test-hunter", max_steps=4)
|
||
|
|
|
||
|
|
assert findings
|
||
|
|
assert findings[0].evidence_level == "suspicion"
|
||
|
|
assert "error" in json.dumps(client.calls[1]["messages"]).lower()
|
||
|
|
|
||
|
|
|
||
|
|
def test_max_steps_stops_before_ungrounded_json(tmp_path: Path) -> None:
|
||
|
|
root = _repo_with_split_sink(tmp_path)
|
||
|
|
client = ScriptedChatClient(
|
||
|
|
[
|
||
|
|
ChatResponse(tool_calls=(ToolCall(id="call_grep", name="grep_repo", arguments={"pattern": "query"}),)),
|
||
|
|
ChatResponse(content=json.dumps(_DUMP_FINDINGS)),
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
findings = run_tool_hunter(root, [_hotspot()], client=client, model="test-hunter", max_steps=1)
|
||
|
|
|
||
|
|
assert findings == []
|
||
|
|
assert len(client.calls) == 1
|