mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
FUXA failed every hunt with HTTP 400, and neither run could say why. The client discarded the response body. With the body shown, the reason was "This model's maximum context length...": FUXA ships minified bundles whose single lines run to megabytes, and grep_repo returned a matched line whole. - grep_repo and find_refs quote a matched line (240 characters), never the whole line. - Every tool result is capped at 16 x max_chars, with a truncation marker. - OpenRouterChatClient errors carry the provider's response body (first 500 characters). That would also have exposed the rejected model id in one call instead of eighteen days. After the fix, FUXA candidates verify normally at $0.004-0.013 each. A test covers the megabyte-line case. verify_sinks.py: skip futures cancelled at the spend ceiling instead of reading them (the run crashed with CancelledError). Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
157 lines
5.8 KiB
Python
157 lines
5.8 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openultrasast.hunter_tools import PathEscapesRepo, clamp_repo_path, find_refs, grep_repo, read_file
|
|
from openultrasast.mapping import EntryPointRecord
|
|
|
|
SPLIT_SINK = """\
|
|
term = request.args["q"]
|
|
query = "select * from items where title like '%" + term + "%'"
|
|
return db.execute(query)
|
|
"""
|
|
|
|
|
|
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 test_read_grep_and_reject_path_escape(tmp_path: Path) -> None:
|
|
root = _repo_with_split_sink(tmp_path)
|
|
|
|
source = read_file(root, "app.py", max_chars=4000)
|
|
assert "query =" in source
|
|
assert "db.execute(query)" in source
|
|
|
|
matches = grep_repo(root, r"query\s*=", max_matches=10)
|
|
assert matches
|
|
assert matches[0]["path"] == "app.py"
|
|
assert matches[0]["line"] == 2
|
|
assert "query =" in str(matches[0]["text"])
|
|
|
|
with pytest.raises(PathEscapesRepo):
|
|
read_file(root, "../etc/passwd", max_chars=100)
|
|
|
|
|
|
def test_clamp_repo_path_rejects_dotdot_and_absolute_escapes(tmp_path: Path) -> None:
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
(root / "app.py").write_text("ok\n")
|
|
|
|
assert clamp_repo_path(root, "app.py") == (root / "app.py").resolve()
|
|
|
|
with pytest.raises(PathEscapesRepo):
|
|
clamp_repo_path(root, "../etc/passwd")
|
|
with pytest.raises(PathEscapesRepo):
|
|
clamp_repo_path(root, "/etc/passwd")
|
|
with pytest.raises(PathEscapesRepo):
|
|
clamp_repo_path(root, "..")
|
|
|
|
|
|
def test_grep_repo_does_not_search_outside_root(tmp_path: Path) -> None:
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
(root / "app.py").write_text('query = "inside"\n')
|
|
(tmp_path / "outside.py").write_text('query = "outside"\n')
|
|
|
|
matches = grep_repo(root, r"query\s*=", max_matches=10)
|
|
|
|
assert [match["path"] for match in matches] == ["app.py"]
|
|
assert all("outside" not in str(match["text"]) for match in matches)
|
|
|
|
|
|
def test_grep_repo_honors_max_matches(tmp_path: Path) -> None:
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
(root / "app.py").write_text("sink = 1\nsink = 2\nsink = 3\n")
|
|
|
|
matches = grep_repo(root, r"sink\s*=", max_matches=2)
|
|
|
|
assert len(matches) == 2
|
|
assert [match["line"] for match in matches] == [1, 2]
|
|
|
|
|
|
def test_read_file_truncates_and_rejects_symlink_escape(tmp_path: Path) -> None:
|
|
root = tmp_path / "repo"
|
|
root.mkdir()
|
|
(root / "app.py").write_text("abcdef")
|
|
(root / "link.py").symlink_to("/etc/passwd")
|
|
|
|
assert read_file(root, "app.py", max_chars=3) == "abc"
|
|
with pytest.raises(PathEscapesRepo):
|
|
read_file(root, "link.py", max_chars=100)
|
|
|
|
|
|
def test_find_refs_uses_mapping_index_and_clamped_text_search(tmp_path: Path) -> None:
|
|
root = _repo_with_split_sink(tmp_path)
|
|
mapping_index = [
|
|
{"path": "app.py", "name": "query", "line": 2},
|
|
EntryPointRecord(
|
|
path="app.py",
|
|
line=3,
|
|
end_line=3,
|
|
function_name="search",
|
|
name="search",
|
|
kind="route",
|
|
access_level="public",
|
|
trust_boundary="http_request",
|
|
access_evidence=[],
|
|
conditions=[],
|
|
provenance="test",
|
|
rationale="fixture",
|
|
),
|
|
]
|
|
|
|
query_refs = find_refs(root, "query", mapping_index)
|
|
assert any(ref["path"] == "app.py" and ref["name"] == "query" for ref in query_refs)
|
|
assert any(ref.get("source") == "mapping" for ref in query_refs)
|
|
assert any(ref.get("source") == "text" and "query" in str(ref.get("text", ref.get("name", ""))) for ref in query_refs)
|
|
|
|
search_refs = find_refs(root, "search", mapping_index)
|
|
assert any(ref["path"] == "app.py" and ref["name"] == "search" for ref in search_refs)
|
|
|
|
|
|
def test_find_refs_rejects_mapping_path_escape(tmp_path: Path) -> None:
|
|
root = _repo_with_split_sink(tmp_path)
|
|
|
|
with pytest.raises(PathEscapesRepo):
|
|
find_refs(root, "passwd", [{"path": "../etc/passwd", "name": "passwd"}])
|
|
with pytest.raises(PathEscapesRepo):
|
|
find_refs(root, "passwd", [{"path": "/etc/passwd", "name": "passwd"}])
|
|
|
|
|
|
def test_grep_reads_the_tree_once_and_sees_an_edit(tmp_path, monkeypatch) -> None:
|
|
"""Every call re-enumerated and re-read the repository; now once per root state, re-validated per file."""
|
|
import os
|
|
|
|
from openultrasast import hunter_tools
|
|
|
|
sub = tmp_path / "pkg"
|
|
sub.mkdir()
|
|
source = sub / "app.py"
|
|
source.write_text("def run(q):\n return query(q)\n")
|
|
calls = []
|
|
real = hunter_tools._iter_clamped_source_files
|
|
monkeypatch.setattr(hunter_tools, "_iter_clamped_source_files", lambda root: calls.append(root) or real(root))
|
|
assert [m["line"] for m in hunter_tools.grep_repo(tmp_path, r"query\(", max_matches=5)] == [2]
|
|
assert [m["line"] for m in hunter_tools.grep_repo(tmp_path, r"def run", max_matches=5)] == [1]
|
|
assert len(calls) == 1, "the tree was enumerated again for an unchanged root"
|
|
source.write_text("def run(q):\n x = 1\n return query(q)\n")
|
|
stamp = source.stat().st_mtime_ns + 10_000_000
|
|
os.utime(source, ns=(stamp, stamp))
|
|
assert [m["line"] for m in hunter_tools.grep_repo(tmp_path, r"query\(", max_matches=5)] == [3], "a stale read survived an edit"
|
|
|
|
|
|
def test_a_match_in_a_minified_line_is_quoted_not_returned_whole(tmp_path) -> None:
|
|
"""FUXA's minified bundles have megabyte lines; one match overflowed the model's context for the whole hunt."""
|
|
from openultrasast import hunter_tools, tool_hunter
|
|
|
|
(tmp_path / "bundle.js").write_text("var a=1;" + "x" * 500_000 + ";eval(q);\n")
|
|
[match] = hunter_tools.grep_repo(tmp_path, r"var a", max_matches=5)
|
|
assert len(match["text"]) <= 240
|
|
call = tool_hunter.ToolCall(id="c", name="grep_repo", arguments={"pattern": "x+", "max_matches": 50})
|
|
assert len(tool_hunter._run_tool(tmp_path, call, 4000)) <= 16 * 4000 + 64
|