Files
Open_Ultra_SAST/benchmarks/valid/hunter_arm.py
T
Marius CiepluchandClaude Fable 5.1 9f7ca132ff feat(benchmark): hunter arm runner and combiner (group 6)
benchmarks/valid/hunter_arm.py runs the shipped deep-mode tool hunter
(tool_hunter.run_tool_hunter via resolve_hunter_client) over each frozen
change off-cluster: detached worktree at head with git hooks disabled,
hotspots scoped to the changed files, instrument rule on every hotspot
(bytes read printed, empty or unreadable fails the change loudly), model id
from the config resolver, family from the taxonomy, never from the label.
Records land in the exact shape score.py reads (push.admission.defects with
locations), resumable per change.

`combine` joins arm (a) replay records with arm (b) hunter records per change
id, dedupes defects by (path, line, family), keeps (a)'s instrument, records
which arms were present, and writes a file score.py loads directly.

9 offline tests (stub hunter, real scorer), 53 green with the benchmark
tests. No network, no key handling, no live run.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
2026-10-12 20:29:03 +02:00

300 lines
13 KiB
Python

"""Run the shipped tool hunter on local frozen changes, then join replay records."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
import tempfile
from copy import deepcopy
from pathlib import Path
from openultrasast import tool_hunter
from openultrasast.complexity.map import Hotspot
from openultrasast.config import load_config
from openultrasast.hunter_tools import clamp_repo_path
from openultrasast.model.endpoint import resolve_models
from openultrasast.model.taxonomy import FAMILY_IDS, load_families
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from benchmarks.unseen.extract import SHA, InstrumentFailure
from benchmarks.unseen.replay import canonical, load_slice
from benchmarks.unseen.score import load_records
from benchmarks.valid.catalog import local_git
# These names describe findings, never benchmark labels. The taxonomy owns the
# family mapping; an ambiguous family alone does not establish a particular CWE.
_WEAKNESSES = (
(r"\bsql[ _-]injection\b", "CWE-89"),
(r"\b(?:os[ _-])?command[ _-]injection\b", "CWE-78"),
(r"\b(?:code[ _-]injection|dynamic execution|remote code execution)\b", "CWE-94"),
(r"\b(?:ssti|server[ -]side template injection)\b", "CWE-1336"),
(r"\bcypher[ _-]injection\b", "CWE-943"),
(r"\b(?:path[ _-]traversal|directory[ _-]traversal)\b", "CWE-22"),
(r"\b(?:unsafe[ _-]deserialization|insecure[ _-]deserialization)\b", "CWE-502"),
(r"\b(?:xss|cross[ -]site scripting)\b", "CWE-79"),
(r"\b(?:ssrf|server[ -]side request forgery)\b", "CWE-918"),
(r"\bopen[ _-]redirect\b", "CWE-601"),
(r"\bprototype[ _-]pollution\b", "CWE-1321"),
(r"\b(?:missing[ _-]authentication|missing[ _-]auth[ _-]guard)\b", "CWE-306"),
(r"\b(?:missing[ _-]authorization|broken[ _-]access[ _-]control)\b", "CWE-862"),
(r"\bhardcoded[ _-](?:secret|credential|password)s?\b", "CWE-798"),
)
def finding_defect(finding):
"""Translate the hunter's own CWE, family tag or weakness title to a defect."""
taxonomy = load_families()
raw_family = next((tag.split(":", 1)[1].strip() for tag in finding.tags if tag.startswith("family:")), "")
evidence = " ".join([*finding.tags, finding.title])
explicit = re.search(r"\bCWE-\d+\b", evidence, re.I)
cwe = explicit.group().upper() if explicit else next((cwe for pattern, cwe in _WEAKNESSES if re.search(pattern, evidence, re.I)), None)
mapped = taxonomy.family_of_cwe(cwe or "")
mechanism = taxonomy.family_of_mechanism(raw_family)
family = raw_family if raw_family in FAMILY_IDS else mechanism.id if mechanism else mapped.id if mapped else "unknown"
if mapped and mapped.id != family:
cwe = None
return {
"defect_id": finding.finding_id,
"family": family,
"cwe": cwe,
"function": finding.function_name,
"locations": [[finding.path, finding.line if finding.line is not None else 0]],
"source": "hunter",
}
def _write_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(".json.tmp")
temporary.write_text(json.dumps(value, sort_keys=True, indent=2) + "\n")
temporary.replace(path)
def _record_path(out, identity):
return out / (hashlib.sha256(identity.encode()).hexdigest() + ".json")
def _inputs(out):
path = out / "inputs.json"
entries = json.loads(path.read_text()) if path.exists() else []
for key in (lambda entry: entry["item_id"], lambda entry: entry["change"]["id"]):
if len({key(entry) for entry in entries}) != len(entries):
raise ValueError(f"duplicate ids in {path}")
return entries
def _hunt_change(change, repos, *, client, model, max_steps):
instrument = {
"hook_exit": 0,
"hook_bytes_read": 0,
"changed_bytes": {},
"head_verified": False,
"base_verified": False,
"failure": None,
"suspect_fast": False,
}
record = {
"id": change["id"],
"done": True,
"status": "hunter_covered",
"instrument": instrument,
"push": {"admission": {"defects": [], "dispositions": []}, "quick_tier": []},
"skipped": [],
}
try:
if not all(SHA.fullmatch(change[side]) for side in ("base", "head")):
raise InstrumentFailure("invalid_revision")
git = local_git(Path(repos[change["repo"]]).expanduser().resolve())
git.run("cat-file", "-e", change["base"] + "^{commit}")
instrument["base_verified"] = True
with tempfile.TemporaryDirectory(prefix="ousast-hunter-") as temporary:
root = Path(temporary) / "head"
added = False
try:
git.run("-c", "core.hooksPath=/dev/null", "worktree", "add", "--detach", str(root), change["head"])
added = True
if local_git(root).run("rev-parse", "HEAD").strip() != change["head"]:
raise InstrumentFailure("head_mismatch")
instrument["head_verified"] = True
hotspots = []
for relative in dict.fromkeys(file["path"] for file in change.get("files", [])):
path = clamp_repo_path(root, relative)
try:
path.stat()
except FileNotFoundError:
reason = {"path": relative, "reason": "missing_at_head"}
record["skipped"].append(reason)
print(f"{change['id']} {relative}: skipped (missing_at_head)", flush=True)
continue
try:
data = path.read_bytes()
except OSError:
print(f"{change['id']} {relative}: 0 bytes read", flush=True)
raise InstrumentFailure(f"unreadable_hotspot:{relative}") from None
size = len(data)
print(f"{change['id']} {relative}: {size} bytes read", flush=True)
instrument["changed_bytes"][relative] = size
instrument["hook_bytes_read"] += size
if not size:
raise InstrumentFailure(f"empty_hotspot:{relative}")
hotspots.append(
Hotspot(
path=relative,
function_name=None,
score=1.0,
band="low",
signals={"bytes_read": size},
rationale="Changed file at the frozen head",
test_hint=None,
inventory_finding_ids=(),
)
)
if not hotspots:
raise InstrumentFailure("no_readable_hotspots")
found = tool_hunter.run_tool_hunter(root, hotspots, client=client, model=model, max_steps=max_steps)
record["push"]["admission"]["defects"] = [finding_defect(finding) for finding in found]
finally:
if added:
git.run("worktree", "remove", "--force", str(root))
except Exception as error: # noqa: BLE001
# Provider exception messages can contain credentials. Store only the
# error type, except for our own bounded instrument reasons.
reason = str(error) if isinstance(error, InstrumentFailure) else type(error).__name__
instrument.update(hook_exit=2, failure=reason)
record["status"] = "instrument_failure"
record["push"]["admission"]["defects"] = []
print(f"{change['id']}: instrument_failure ({reason})", file=sys.stderr, flush=True)
return record
def run(manifest, repos_path, out, *, slice_id="1", max_steps=tool_hunter.DEFAULT_MAX_STEPS, only="all", client_mode=None):
if max_steps < 1:
raise ValueError("max_steps must be positive")
changes = load_slice(manifest, slice_id)
if len({change["id"] for change in changes}) != len(changes):
raise ValueError("duplicate change ids")
changes = [change for change in changes if only == "all" or change["kind"] == only]
entries = _inputs(out)
indexed = {entry["change"]["id"]: entry for entry in entries}
pending = []
for change in changes:
path = _record_path(out, change["id"])
previous = json.loads(path.read_text()) if path.exists() else {}
entry = indexed.get(change["id"])
if entry is None:
entry = {
"item_id": change["id"],
"change": change,
"input_digest": previous.get("input_digest") or hashlib.sha256(canonical(change)).hexdigest(),
}
entries.append(entry)
elif entry["change"] != change or entry["item_id"] != change["id"]:
raise ValueError("existing hunter inputs do not match frozen change")
if previous.get("done") is True:
print(f"{change['id']}: skipped (done)", flush=True)
continue
pending.append(entry)
if not pending:
_write_json(out / "inputs.json", entries)
return 0
config_path = Path("openultrasast.toml")
config = load_config(config_path if config_path.exists() else None, dotenv=client_mode != "scripted")
model = resolve_models(config)[0]
client = tool_hunter.scripted_hunter_client() if client_mode == "scripted" else tool_hunter.resolve_hunter_client()
if client is None:
print("No hunter client resolved. Configure a hunter provider or use --client scripted for offline tests.", file=sys.stderr)
return 2
repos = json.loads(repos_path.read_text())
_write_json(out / "inputs.json", entries)
failed = False
for entry in pending:
# The built-in script is a finite sequence, so each change needs a fresh
# sequence. Live clients remain shared and are resolved only once.
if client_mode == "scripted":
client = tool_hunter.scripted_hunter_client()
record = _hunt_change(entry["change"], repos, client=client, model=model, max_steps=max_steps)
record["input_digest"] = entry["input_digest"]
_write_json(_record_path(out, entry["item_id"]), record)
failed |= record["status"] == "instrument_failure"
return 2 if failed else 0
def _joined(out):
_inputs(out)
rows = load_records(out)
if len({row["id"] for row in rows}) != len(rows):
raise ValueError("duplicate joined change ids")
return {row["id"]: row for row in rows}
def _extend_defects(existing, extra):
seen = set()
merged = []
for defect in [*existing, *extra]:
locations = []
for path, line in defect.get("locations", []):
key = (path, line, defect.get("family"))
if key not in seen:
locations.append([path, line])
seen.add(key)
if locations:
merged.append({**defect, "locations": locations})
elif not defect.get("locations"):
merged.append(defect)
return merged
def combine(replay_out, hunter_out, out):
"""Preserve replay coverage while adding independently detected hunter defects."""
a = _joined(replay_out)
b = _joined(hunter_out) if (hunter_out / "inputs.json").exists() else {}
combined = []
for identity, left in a.items():
right = b.get(identity, {})
has_a, has_b = "push" in left, "push" in right
merged = deepcopy(left if has_a or not has_b else right)
if has_a and has_b:
admission = merged["push"].setdefault("admission", {})
admission["defects"] = _extend_defects(
admission.get("defects", []),
right["push"].get("admission", {}).get("defects", []),
)
merged["arms"] = {
"a": left.get("status", "missing") if has_a else "missing",
"b": right.get("status", "missing") if has_b else "missing",
}
combined.append(merged)
_write_json(out, combined)
return combined
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
hunt = commands.add_parser("run")
hunt.add_argument("manifest", type=Path)
hunt.add_argument("--slice", default="1")
hunt.add_argument("--repos", type=Path, required=True)
hunt.add_argument("--out", type=Path, required=True)
hunt.add_argument("--max-steps", type=int, default=tool_hunter.DEFAULT_MAX_STEPS)
hunt.add_argument("--only", choices=("vulnerability", "ordinary", "all"), default="all")
hunt.add_argument("--client", choices=("scripted",))
join = commands.add_parser("combine")
join.add_argument("replay_out", type=Path)
join.add_argument("hunter_out", type=Path)
join.add_argument("--out", type=Path, required=True)
args = parser.parse_args(argv)
if args.command == "combine":
combine(args.replay_out, args.hunter_out, args.out)
return 0
return run(args.manifest, args.repos, args.out, slice_id=args.slice, max_steps=args.max_steps, only=args.only, client_mode=args.client)
if __name__ == "__main__":
raise SystemExit(main())