mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 20:49:28 +00:00
262 lines
11 KiB
Python
262 lines
11 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, replace
|
|
from importlib.resources import files
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from . import _sandbox
|
|
from .demo import FAMILY_ORACLES, load_demo
|
|
from .oracles import BrowserExecutor
|
|
from .task_storage import exception_reason
|
|
from .verify import Side, VerificationRecord, verify, verify_side_task
|
|
|
|
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] = {
|
|
"oracle": family,
|
|
"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 _verify_task_pair(sides: tuple[Side, Side], demo: Path, family: str) -> VerificationRecord:
|
|
"""Exercise the task executor on packaged toys only, not arbitrary checkouts.
|
|
|
|
Repetitions get fresh scratch, but share this probe task's outer boundary.
|
|
This is an instrument self-test, not a fresh-task dispatcher for evidence.
|
|
"""
|
|
started = time.monotonic()
|
|
records = []
|
|
try:
|
|
load_demo(demo)
|
|
except (OSError, ValueError) as exc:
|
|
return VerificationRecord(
|
|
"inconclusive",
|
|
(),
|
|
time.monotonic() - started,
|
|
"invalid declarative demo: " + exception_reason(exc),
|
|
isolation_mode="task-boundary",
|
|
)
|
|
search_family = next(f for f, kinds in FAMILY_ORACLES.items() if family in kinds)
|
|
for side in sides:
|
|
runs = [verify_side_task(side, demo, search_family) for _ in range(3)]
|
|
first = next((run for run in runs if run.outcome != "observed"), runs[0])
|
|
records.append(
|
|
replace(
|
|
first,
|
|
runs=tuple(observation for run in runs for observation in run.runs),
|
|
elapsed_seconds=sum(run.elapsed_seconds for run in runs),
|
|
build_seconds=sum(run.build_seconds for run in runs),
|
|
ready_seconds=sum(run.ready_seconds for run in runs),
|
|
run_seconds=sum(run.run_seconds for run in runs),
|
|
scratch_peak_bytes=max(run.scratch_peak_bytes for run in runs),
|
|
reason=first.reason if first.outcome != "observed" else "three probe task executions completed",
|
|
)
|
|
)
|
|
demonstrated = (
|
|
all(side.outcome == "observed" and len(side.runs) == 3 for side in records)
|
|
and all(run.observed for run in records[0].runs)
|
|
and not any(run.observed for run in records[1].runs)
|
|
)
|
|
return VerificationRecord(
|
|
"demonstrated" if demonstrated else "inconclusive",
|
|
tuple(records),
|
|
time.monotonic() - started,
|
|
"owned effect in 3/3 affected runs and 0/3 safe runs" if demonstrated else "no consistent differential effect",
|
|
isolation_mode="task-boundary",
|
|
chromium_no_sandbox=any(side.chromium_no_sandbox for side in records),
|
|
)
|
|
|
|
|
|
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(),
|
|
}
|
|
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"
|
|
task_boundary = record["isolation_mode"] == "task-boundary"
|
|
if task_boundary:
|
|
# Real verification still needs fresh side tasks; only these packaged
|
|
# self-test applications share the probe task's boundary.
|
|
record["requires_fresh_side_tasks"] = True
|
|
start = time.monotonic()
|
|
try:
|
|
if task_boundary:
|
|
record["isolation_check"] = {"status": "skipped", "reason": "task-boundary"}
|
|
else:
|
|
_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():
|
|
search_family = next(f for f, kinds in FAMILY_ORACLES.items() if family in kinds)
|
|
if task_boundary:
|
|
tick = time.monotonic()
|
|
try:
|
|
result = _verify_task_pair(sides, demo, family)
|
|
except Exception as exc:
|
|
result = VerificationRecord(
|
|
"inconclusive", (), time.monotonic() - tick, exception_reason(exc), isolation_mode="task-boundary"
|
|
)
|
|
else:
|
|
result = verify(
|
|
*sides,
|
|
demo,
|
|
search_family,
|
|
oracle=family,
|
|
browser=BrowserExecutor() if family == "xss" else None,
|
|
)
|
|
results[name] = asdict(result)
|
|
if task_boundary and (result.outcome == "demonstrated") != (name == "real") and record["status"] == "ok":
|
|
expected = "demonstrated" if name == "real" else "not demonstrated"
|
|
record.update(
|
|
status="instrument_failure",
|
|
exception=f"{family}/{name}: expected {expected}, got {result.outcome}",
|
|
)
|
|
# SSRF/browser need additional namespace capabilities beyond the
|
|
# generic preflight. These failures must not look like oracle misses.
|
|
for side in result.sides:
|
|
if not task_boundary and 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())
|