mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 11:49:28 +00:00
Task 2.3: two of the five unscorable reasons had no code path, so an unparsable row was reported as a corpus labelling defect when the true cause was a tooling gap. They have different owners and different fixes, and conflating them is the dishonesty this spec exists to remove. The design's unscorable_reason is reinstated with the parse result as an input, and a handler that arrived without its registration is now named as missing context for the families whose bug shape is a missing check. Task 2.6: a verification stamped its own word for proof onto the shared evidence level, which is not a value the project's ladder defines, and three existing helpers raised on it. Nothing in production called it yet, so the suite stayed green while the wiring in the next group would have broken a scan. A verification now writes only its tag, and a caller that wants to move a claim up the ladder goes through an explicit mapping that can only produce a level the ladder has. Task 2.7: the injection canary planted its row in a private in-memory database the target could never reach, so it could not fire; the fake sandbox handed the token back regardless of the snippet, which is why nothing noticed. It is now an execution canary whose marker only an interpreter could write, and each template is tested by running it for real against a vulnerable fixture and its fixed twin. That test immediately caught a second flaw: a handler that merely echoes its input printed the oracle token and satisfied the oracle without any vulnerability, so an injected payload now carries a value unrelated to the token the oracle looks for. Also from the review: a configuration with no tools is refused instead of producing a detector that silently reports nothing, the classifier abstains when its fallback call fails too, and the archive docstring says which version wins. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
168 lines
8.3 KiB
Python
168 lines
8.3 KiB
Python
"""learning-harness task 2.5: per-family detector configurations and the family runner (offline)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openultrasast.learning.families import load_families
|
|
from openultrasast.tool_hunter import ChatResponse, ScriptedChatClient, ToolCall
|
|
|
|
APP = (
|
|
"from flask import request\n\n\n"
|
|
"@app.route('/books/<title>')\n@login_required\ndef guarded(title):\n"
|
|
" return Book.query.filter_by(book_title=title).first()\n\n\n"
|
|
"@app.route('/books/any/<title>')\ndef leaky(title):\n"
|
|
" owner = request.args.get('owner')\n"
|
|
" return Book.query.filter_by(user_id=owner, book_title=title).first()\n"
|
|
)
|
|
REPLY = '[{"path": "app.py", "line": 12, "title": "unconstrained read", "rationale": "no owner check"}]'
|
|
|
|
|
|
@pytest.fixture
|
|
def repo(tmp_path: Path) -> Path:
|
|
(tmp_path / "app.py").write_text(APP)
|
|
return tmp_path
|
|
|
|
|
|
def _write_config(root: Path, family: str, **overrides: object) -> Path:
|
|
directory = root / family
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
(directory / "prompt.md").write_text(str(overrides.get("prompt", f"You hunt {family} only.")))
|
|
(directory / "checklist.md").write_text(str(overrides.get("checklist", "- did the handler check ownership?")))
|
|
tools = overrides.get("tools", ["read_definition", "obligations"])
|
|
(directory / "family.toml").write_text(
|
|
f'version = "{overrides.get("version", "1")}"\ntools = {json.dumps(tools)}\n'
|
|
f"max_steps = {overrides.get('max_steps', 3)}\nmax_cost_usd = {overrides.get('max_cost_usd', 0.5)}\n"
|
|
f"max_chars = {overrides.get('max_chars', 4000)}\n"
|
|
)
|
|
(directory / "hard_negatives.jsonl").write_text(json.dumps({"note": "a guarded handler is not a finding"}) + "\n")
|
|
(directory / "counterexamples.jsonl").write_text("")
|
|
return directory
|
|
|
|
|
|
def test_a_configuration_loads_every_part_it_needs(tmp_path: Path) -> None:
|
|
from openultrasast.learning.detectors import load_family_configs
|
|
|
|
_write_config(tmp_path, "access_control")
|
|
configs = load_family_configs(tmp_path, load_families())
|
|
config = configs["access_control"]
|
|
assert config.family == "access_control" and config.version == "1"
|
|
assert config.prompt.startswith("You hunt access_control") and "ownership" in config.checklist
|
|
assert config.tools == ("read_definition", "obligations")
|
|
assert config.max_steps == 3 and config.max_cost_usd == 0.5 and config.max_chars == 4000
|
|
assert config.hard_negatives[0]["note"].startswith("a guarded handler")
|
|
assert config.counterexamples == ()
|
|
assert load_family_configs(tmp_path, load_families())["access_control"] == config # loading twice is the same config
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("overrides", "needle"),
|
|
[
|
|
({"prompt": "x" * 9000}, "length"),
|
|
({"tools": ["read_file", "run_shell"]}, "run_shell"),
|
|
({"version": ""}, "version"),
|
|
],
|
|
ids=["over_cap_prompt", "unknown_tool", "no_version"],
|
|
)
|
|
def test_the_loader_refuses_a_broken_configuration_by_name(tmp_path: Path, overrides: dict[str, object], needle: str) -> None:
|
|
from openultrasast.learning.detectors import DetectorConfigError, load_family_configs
|
|
|
|
_write_config(tmp_path, "access_control", **overrides)
|
|
with pytest.raises(DetectorConfigError, match=needle):
|
|
load_family_configs(tmp_path, load_families())
|
|
|
|
|
|
def test_a_directory_that_is_not_a_family_is_refused_by_name(tmp_path: Path) -> None:
|
|
from openultrasast.learning.detectors import DetectorConfigError, load_family_configs
|
|
|
|
_write_config(tmp_path, "access_control")
|
|
(tmp_path / "sql_injection").mkdir()
|
|
(tmp_path / "sql_injection" / "prompt.md").write_text("x")
|
|
with pytest.raises(DetectorConfigError, match="sql_injection"):
|
|
load_family_configs(tmp_path, load_families())
|
|
|
|
|
|
def test_the_runner_uses_the_family_prompt_tools_and_tags(repo: Path, tmp_path: Path) -> None:
|
|
from openultrasast.learning.detectors import Region, load_family_configs, run_family_detector
|
|
|
|
configs_dir = tmp_path / "configs"
|
|
_write_config(configs_dir, "access_control")
|
|
config = load_family_configs(configs_dir, load_families())["access_control"]
|
|
client = ScriptedChatClient(
|
|
[
|
|
ChatResponse(tool_calls=(ToolCall(id="1", name="obligations", arguments={"path": "app.py", "function": "leaky"}),)),
|
|
ChatResponse(content=REPLY),
|
|
]
|
|
)
|
|
findings = run_family_detector(repo, Region(path="app.py", function="leaky"), config, client=client, model="m")
|
|
assert [finding.tags for finding in findings] == [["family:access_control", "detector:access_control@1"]]
|
|
assert findings[0].evidence_level == "suspicion"
|
|
call = client.calls[0]
|
|
assert call["messages"][0]["content"] == "You hunt access_control only."
|
|
assert [tool["function"]["name"] for tool in call["tools"]] == ["read_definition", "obligations"]
|
|
user = str(call["messages"][1]["content"])
|
|
assert "def leaky" in user and "def guarded" in user # the whole file, not an excerpt around a hotspot
|
|
assert "ownership" in user and "a guarded handler is not a finding" in user
|
|
|
|
|
|
def test_a_region_runs_every_admitted_family_plus_the_generalist(repo: Path, tmp_path: Path) -> None:
|
|
from openultrasast.learning.detectors import Region, load_family_configs, run_region_detectors
|
|
|
|
configs_dir = tmp_path / "configs"
|
|
for family in ("access_control", "injection", "unknown"):
|
|
_write_config(configs_dir, family, tools=["read_file"])
|
|
configs = load_family_configs(configs_dir, load_families())
|
|
client = ScriptedChatClient(
|
|
[
|
|
ChatResponse(tool_calls=(ToolCall(id="1", name="read_file", arguments={"path": "app.py"}),)),
|
|
ChatResponse(content=REPLY),
|
|
]
|
|
* 4
|
|
)
|
|
region = Region(path="app.py", function="leaky", families=("access_control",))
|
|
findings = run_region_detectors(repo, region, configs, client=client, model="m")
|
|
families = sorted({tag for finding in findings for tag in finding.tags if tag.startswith("family:")})
|
|
assert families == ["family:access_control", "family:unknown"] # the admitted family and the generalist, not injection
|
|
assert len(client.calls) == 4 # two turns each, and nothing else was run
|
|
|
|
|
|
def test_the_runner_never_offers_a_tool_outside_the_curated_set(repo: Path, tmp_path: Path) -> None:
|
|
from openultrasast.learning.detectors import Region, load_family_configs, run_family_detector
|
|
from openultrasast.tool_hunter import HUNTER_TOOLS
|
|
|
|
configs_dir = tmp_path / "configs"
|
|
_write_config(
|
|
configs_dir, "injection", tools=["read_file", "grep_repo", "find_refs", "read_definition", "entry_points", "flows", "obligations"]
|
|
)
|
|
config = load_family_configs(configs_dir, load_families())["injection"]
|
|
client = ScriptedChatClient([ChatResponse(content="")])
|
|
run_family_detector(repo, Region(path="app.py", function="leaky"), config, client=client, model="m")
|
|
offered = {tool["function"]["name"] for tool in client.calls[0]["tools"]}
|
|
assert offered == {str(tool["function"]["name"]) for tool in HUNTER_TOOLS}
|
|
assert "bash" not in offered and "run_shell" not in offered
|
|
|
|
|
|
def test_round_zero_can_write_a_configuration_for_every_family(tmp_path: Path) -> None:
|
|
from openultrasast.learning.detectors import load_family_configs, write_default_configs
|
|
|
|
taxonomy = load_families()
|
|
written = write_default_configs(tmp_path, taxonomy, prompt="You are a security hunter.", version="0")
|
|
assert set(written) == {family.id for family in taxonomy.families}
|
|
configs = load_family_configs(tmp_path, taxonomy)
|
|
assert set(configs) == {family.id for family in taxonomy.families}
|
|
assert all(config.version == "0" for config in configs.values())
|
|
assert all(config.prompt.startswith("You are a security hunter.") for config in configs.values())
|
|
assert configs["access_control"].prompt != configs["injection"].prompt # each carries its own family's definition
|
|
|
|
|
|
def test_a_configuration_with_no_tools_is_refused_rather_than_reporting_nothing(tmp_path: Path) -> None:
|
|
"""A detector offered no tools never calls one, and the hunter loop then returns nothing at all."""
|
|
from openultrasast.learning.detectors import DetectorConfigError, load_family_configs
|
|
|
|
_write_config(tmp_path, "injection", tools=[])
|
|
with pytest.raises(DetectorConfigError, match="tools"):
|
|
load_family_configs(tmp_path, load_families())
|