mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 12:39:28 +00:00
Reasons carry type plus a sanitised message tail; command failures carry phase, exit code and bounded stderr. Scratch walk tolerates directories pip removes mid-walk; writable /workspace/home and runtime paths. Capacity retries delete a possibly created task before resubmitting. Docker smoke checks the image first. Co-Authored-By: Claude Opus 5.5 <[email protected]>
178 lines
7.4 KiB
Python
178 lines
7.4 KiB
Python
"""No-model image probe: python -m openultrasast.search.probe.
|
|
|
|
The packaged app is data, deliberately never imported into the worker process.
|
|
RESULT_URL is an optional object-scoped PUT; no store credentials are used.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import resource
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
import urllib.request
|
|
from dataclasses import asdict
|
|
from importlib.resources import files
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import _sandbox
|
|
from .oracles import BrowserExecutor
|
|
from .task_storage import exception_reason
|
|
from .verify import Side, verify
|
|
|
|
FAMILIES = ("sql", "path", "command", "ssrf", "xss")
|
|
PAYLOADS = {
|
|
"sql": "' OR public=0 --",
|
|
"path": "../canary",
|
|
"command": "; touch marker",
|
|
"ssrf": "internal",
|
|
"xss": '<script>alert("ousast-xss")</script>',
|
|
}
|
|
|
|
|
|
def materialise(root: Path, family: str) -> tuple[tuple[Side, Side], dict[str, Path]]:
|
|
source = files("openultrasast.search").joinpath("probe_apps/app.py.txt").read_bytes()
|
|
if not source:
|
|
raise ValueError("empty packaged toy input")
|
|
sides = []
|
|
for name in ("vulnerable", "fixed"):
|
|
checkout = root / name
|
|
checkout.mkdir(parents=True)
|
|
(checkout / "app.py").write_bytes(source)
|
|
if name == "fixed":
|
|
(checkout / "fixed").touch()
|
|
sides.append(Side(checkout))
|
|
demos = {}
|
|
for name in ("real", "observer_access", "revision_patch"):
|
|
demo = root / name
|
|
demo.mkdir()
|
|
demos[name] = demo
|
|
schema: dict[str, Any] = {
|
|
"build": {"recipe": "none", "arguments": []},
|
|
"start": {"runtime": "python", "path": "app.py", "arguments": [], "mode": "cli"},
|
|
"steps": [{"type": "cli", "arguments": [family, PAYLOADS[family]]}],
|
|
}
|
|
(demos["real"] / "demo.json").write_text(json.dumps(schema))
|
|
schema["steps"][0]["arguments"] = ["/fixture/canary"]
|
|
(demos["observer_access"] / "demo.json").write_text(json.dumps(schema))
|
|
schema["start"]["path"] = "../fixed/app.py"
|
|
(demos["revision_patch"] / "demo.json").write_text(json.dumps(schema))
|
|
return (sides[0], sides[1]), demos
|
|
|
|
|
|
def image_facts() -> dict[str, Any]:
|
|
facts = {}
|
|
for name, argv in {
|
|
"python": ["python3", "--version"],
|
|
"node": ["node", "--version"],
|
|
"tsc": ["tsc", "--version"],
|
|
"php": ["php", "--version"],
|
|
"java": ["java", "-version"],
|
|
"sqlite": ["sqlite3", "--version"],
|
|
"bwrap": ["bwrap", "--version"],
|
|
"chromium": ["chromium", "--version"],
|
|
}.items():
|
|
try:
|
|
done = subprocess.run(argv, capture_output=True, text=True, timeout=10, env={"PATH": os.defpath + ":/opt/java/openjdk/bin"})
|
|
facts[name] = {"exit_code": done.returncode, "version": (done.stdout + done.stderr).strip()[:2000]}
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
facts[name] = {"error": str(exc)}
|
|
return facts
|
|
|
|
|
|
def namespace_facts() -> dict[str, Any]:
|
|
"""Try capabilities independently, even when the combined preflight fails."""
|
|
commands = {
|
|
name: ["bwrap", f"--unshare-{name}", "--bind", "/", "/", "--", "/bin/true"]
|
|
for name in ("user", "pid", "net", "ipc", "uts", "cgroup")
|
|
}
|
|
commands["mount"] = ["bwrap", "--bind", "/", "/", "--", "/bin/true"]
|
|
commands["setpriv"] = [*_sandbox.drop_privileges(), "/usr/bin/id"]
|
|
facts = {}
|
|
for name, argv in commands.items():
|
|
try:
|
|
done = subprocess.run(argv, capture_output=True, text=True, timeout=5, env={"PATH": os.defpath})
|
|
facts[name] = {"exit_code": done.returncode, "stderr": done.stderr[:2000], "stdout": done.stdout[:2000]}
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
facts[name] = {"exit_code": None, "stderr": str(exc)}
|
|
return facts
|
|
|
|
|
|
def probe(root: Path) -> dict[str, Any]:
|
|
record: dict[str, Any] = {
|
|
"status": "ok",
|
|
"oracles": {},
|
|
"image": image_facts(),
|
|
"namespaces": namespace_facts(),
|
|
"isolation_check": None,
|
|
"isolation_mode": _sandbox.isolation_mode(),
|
|
}
|
|
start = time.monotonic()
|
|
try:
|
|
_sandbox.isolation_check()
|
|
record["isolation_check"] = {"status": "ok", "input_bytes": len(b"isolation-ready\n")}
|
|
for family in FAMILIES:
|
|
sides, demos = materialise(root / family, family)
|
|
results: dict[str, Any] = {}
|
|
record["oracles"][family] = results
|
|
for name, demo in demos.items():
|
|
result = verify(*sides, demo, family, browser=BrowserExecutor() if family == "xss" else None)
|
|
results[name] = asdict(result)
|
|
# SSRF/browser need additional namespace capabilities beyond the
|
|
# generic preflight. These failures must not look like oracle misses.
|
|
for side in result.sides:
|
|
if side.reason.startswith("verification unavailable or refused:"):
|
|
raise _sandbox.IsolationUnavailable(f"{family}/{name}: {side.reason}")
|
|
except Exception as exc:
|
|
record.update(status="instrument_failure", exception=exception_reason(exc))
|
|
namespaces = record["namespaces"]
|
|
if namespaces and all(namespaces[name]["exit_code"] != 0 for name in ("user", "pid", "net", "ipc", "uts", "mount")):
|
|
record["isolation_mode"] = "task-boundary"
|
|
record["requires_fresh_side_tasks"] = True
|
|
if record["isolation_check"] is None:
|
|
record["isolation_check"] = {"status": "instrument_failure", "exception": exception_reason(exc)}
|
|
finally:
|
|
record["wall_seconds"] = time.monotonic() - start
|
|
record["peak_rss_kib"] = {
|
|
"self": resource.getrusage(resource.RUSAGE_SELF).ru_maxrss,
|
|
"children": resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss,
|
|
}
|
|
record["peak_rss_kib"]["sum"] = sum(record["peak_rss_kib"].values())
|
|
record["scratch_disk_bytes"] = sum(p.stat().st_blocks * 512 for p in root.rglob("*") if p.is_file())
|
|
record["verification_scratch_peak_bytes"] = max(
|
|
(s["scratch_peak_bytes"] for results in record["oracles"].values() for result in results.values() for s in result["sides"]),
|
|
default=0,
|
|
)
|
|
record["scratch_filesystem_used_bytes"] = shutil.disk_usage(root).used
|
|
return record
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--heap-profile", choices=("vm", "ax"), help=argparse.SUPPRESS)
|
|
parser.parse_args(argv)
|
|
with tempfile.TemporaryDirectory(prefix="ousast-search-probe-") as directory:
|
|
record = probe(Path(directory))
|
|
data = (json.dumps(record, sort_keys=True) + "\n").encode()
|
|
print(data.decode(), end="", flush=True)
|
|
if url := os.environ.get("RESULT_URL"):
|
|
request = urllib.request.Request(url, data=data, method="PUT", headers={"Content-Type": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
if not 200 <= response.status < 300:
|
|
return 1
|
|
except Exception as exc:
|
|
record.update(status="instrument_failure", phase="result upload", reason=exception_reason(exc))
|
|
print(json.dumps(record), flush=True)
|
|
return 1
|
|
return 0 if record["status"] == "ok" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|