mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-17 12:09:27 +00:00
Demos are data the verifier executes (fixed build recipes, runtime+path start, HTTP/CLI steps with captures; the canary is never referable). The agent loop with the key never runs repository code: executor tasks without a key exchange commands and results through presigned objects. AX capacity errors are back-pressure with at most 2 tasks in flight; caches and builds under /workspace with a 2 GiB guard; verifiers take one presigned archive per side. Launcher fix: private /dev in bwrap. 190 tests on the host; probe 5/5 demonstrated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
565 lines
26 KiB
Python
565 lines
26 KiB
Python
"""Verifier-owned execution of bounded JSON demonstrations and owned oracles.
|
|
|
|
When namespaces are unavailable, a trusted dispatcher must provide separate fresh
|
|
AX tasks for the two sides. There is deliberately no local unsandboxed fallback.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import resource
|
|
import secrets
|
|
import shutil
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import tempfile
|
|
import time
|
|
from collections.abc import Callable
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from contextlib import suppress
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
|
|
from ..sandbox import SandboxJob, SandboxResult
|
|
from . import _sandbox, task_storage
|
|
from .demo import BUILD_RECIPES, RUNTIMES, capture_output, load_demo, substitute
|
|
from .oracles import BrowserExecutor, Canary, Oracle, oracle_for
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Side:
|
|
checkout: Path
|
|
command: tuple[str, ...] = ()
|
|
readiness_args: tuple[str, ...] = ("--ready",)
|
|
dependencies: tuple[Path, ...] = ()
|
|
build_command: tuple[str, ...] = ()
|
|
products: Path | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class RunObservation:
|
|
observed: bool
|
|
evidence: str
|
|
elapsed_seconds: float
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SideRecord:
|
|
outcome: str
|
|
runs: tuple[RunObservation, ...]
|
|
elapsed_seconds: float
|
|
reason: str
|
|
build_seconds: float = 0.0
|
|
ready_seconds: float = 0.0
|
|
run_seconds: float = 0.0
|
|
scratch_peak_bytes: int = 0
|
|
isolation_mode: str = field(default_factory=_sandbox.isolation_mode)
|
|
chromium_no_sandbox: bool = False
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class VerificationRecord:
|
|
outcome: str
|
|
sides: tuple[SideRecord, ...]
|
|
elapsed_seconds: float
|
|
reason: str
|
|
isolation_mode: str = field(default_factory=_sandbox.isolation_mode)
|
|
chromium_no_sandbox: bool = False
|
|
|
|
|
|
def _tree(root: Path, *, generated_links: bool = False) -> str:
|
|
"""Hash every input byte; only post-build trees may link within themselves."""
|
|
digest = hashlib.sha256()
|
|
if root.is_symlink():
|
|
raise ValueError("input must not be a link")
|
|
if root.is_file():
|
|
digest.update(root.read_bytes())
|
|
return digest.hexdigest()
|
|
if not root.is_dir():
|
|
raise ValueError("input must be a real directory or JSON file")
|
|
paths = [root, *sorted(root.rglob("*"))]
|
|
directory_edges: dict[Path, list[Path]] = {}
|
|
for path in paths:
|
|
mode = path.lstat().st_mode
|
|
if stat.S_ISLNK(mode) and generated_links:
|
|
try:
|
|
target = path.resolve(strict=True)
|
|
except (OSError, RuntimeError) as exc:
|
|
raise ValueError("dangling or cyclic build link") from exc
|
|
if not target.is_relative_to(root.resolve()):
|
|
raise ValueError("build link escapes its tree")
|
|
if not (target.is_file() or target.is_dir()):
|
|
raise ValueError("build link targets a special file")
|
|
digest.update(str(path.relative_to(root)).encode())
|
|
digest.update(str(mode).encode())
|
|
digest.update(os.readlink(path).encode())
|
|
if target.is_dir():
|
|
directory_edges.setdefault(path.parent.resolve(), []).append(target)
|
|
continue
|
|
if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode)):
|
|
raise ValueError(f"links and special files refused: {path.name}")
|
|
if path != root and path.is_dir():
|
|
directory_edges.setdefault(path.parent.resolve(), []).append(path.resolve())
|
|
digest.update(str(path.relative_to(root)).encode())
|
|
digest.update(str(mode).encode())
|
|
if path.is_file():
|
|
digest.update(path.read_bytes())
|
|
if generated_links:
|
|
visiting: set[Path] = set()
|
|
visited: set[Path] = set()
|
|
|
|
def visit(directory: Path) -> None:
|
|
if directory in visiting:
|
|
raise ValueError("cyclic build directory link")
|
|
if directory in visited:
|
|
return
|
|
visiting.add(directory)
|
|
for target in directory_edges.get(directory, []):
|
|
visit(target)
|
|
visiting.remove(directory)
|
|
visited.add(directory)
|
|
|
|
visit(root.resolve())
|
|
return digest.hexdigest()
|
|
|
|
|
|
# Executed as verifier-owned code inside the same network sandbox as the app.
|
|
# Redirects are refused: only the configured loopback application is reachable.
|
|
_HTTP_DRIVER = r"""
|
|
import http.client, importlib.util, json, os, socket, subprocess, sys, time
|
|
spec = importlib.util.spec_from_file_location('demo', '/verifier-demo.py')
|
|
demo = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(demo)
|
|
config = json.load(sys.stdin)
|
|
schema = config['schema']
|
|
app = subprocess.Popen(config['command'], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
until = time.monotonic() + config['timeout'] / 2
|
|
while True:
|
|
if app.poll() is not None:
|
|
raise RuntimeError('HTTP application exited during readiness')
|
|
try:
|
|
connection = socket.create_connection(('127.0.0.1', schema['start']['port']), timeout=0.1)
|
|
connection.close()
|
|
break
|
|
except OSError:
|
|
if time.monotonic() >= until:
|
|
raise RuntimeError('HTTP readiness timed out')
|
|
time.sleep(0.03)
|
|
with open('/scratch/' + config['ready'], 'w') as ready:
|
|
ready.write('ready')
|
|
while not os.path.exists('/scratch/' + config['go']):
|
|
if time.monotonic() >= until + config['timeout'] / 2:
|
|
raise RuntimeError('verifier readiness acknowledgment timed out')
|
|
time.sleep(0.01)
|
|
captures, outputs = {}, []
|
|
for step in schema['steps']:
|
|
path = demo.substitute(step['path'], captures)
|
|
if not path.startswith('/') or path.startswith('//') or '\r' in path or '\n' in path:
|
|
raise ValueError('invalid expanded HTTP path')
|
|
headers = {key: demo.substitute(value, captures) for key, value in step.get('headers', {}).items()}
|
|
conn = http.client.HTTPConnection('127.0.0.1', schema['start']['port'], timeout=config['timeout'] / 2)
|
|
conn.request(step['method'], path, body=demo.substitute(step.get('body', ''), captures).encode(), headers=headers)
|
|
response = conn.getresponse()
|
|
body = response.read(1048577)
|
|
if len(body) > 1048576:
|
|
raise ValueError('HTTP response exceeds limit')
|
|
output = body.decode(errors='replace')
|
|
outputs.append(output)
|
|
demo.capture_output(step, output, captures, config['nonce'])
|
|
conn.close()
|
|
print(json.dumps(outputs))
|
|
finally:
|
|
app.terminate()
|
|
try:
|
|
app.wait(timeout=0.5)
|
|
except subprocess.TimeoutExpired:
|
|
app.kill()
|
|
app.wait()
|
|
"""
|
|
|
|
|
|
def _http_steps(
|
|
schema: dict[str, Any],
|
|
command: tuple[str, ...],
|
|
job: Callable[[tuple[str, ...]], SandboxJob],
|
|
scratch: Path,
|
|
mounts: dict[str, Path],
|
|
env: dict[str, str],
|
|
namespace_pid: int | None,
|
|
nonce: str,
|
|
runner: Callable[..., SandboxResult],
|
|
ready_check: Callable[[], None],
|
|
) -> list[str]:
|
|
from . import demo as demo_module
|
|
|
|
ready, go = ".ready-" + secrets.token_hex(12), ".go-" + secrets.token_hex(12)
|
|
request = {"schema": schema, "command": command, "timeout": job(command).timeout_seconds, "nonce": nonce, "ready": ready, "go": go}
|
|
acknowledged = False
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
future = pool.submit(
|
|
runner,
|
|
job(("/usr/bin/python3", "-I", "-c", _HTTP_DRIVER)),
|
|
scratch=scratch,
|
|
mounts={**mounts, "/verifier-demo.py": Path(demo_module.__file__)},
|
|
env=env,
|
|
namespace_pid=namespace_pid,
|
|
network=True,
|
|
input_text=json.dumps(request),
|
|
)
|
|
deadline = time.monotonic() + job(command).timeout_seconds
|
|
while not (scratch / ready).exists() and not future.done() and time.monotonic() < deadline:
|
|
time.sleep(0.01)
|
|
if (scratch / ready).exists():
|
|
ready_check()
|
|
(scratch / go).touch()
|
|
acknowledged = True
|
|
result = future.result()
|
|
if not acknowledged or result.exit_code or result.timed_out:
|
|
raise ValueError("HTTP application/steps failed or readiness not acknowledged: " + result.stderr[-2000:])
|
|
outputs = json.loads(result.stdout)
|
|
if not isinstance(outputs, list) or len(outputs) != len(schema["steps"]) or not all(isinstance(value, str) for value in outputs):
|
|
raise ValueError("invalid HTTP driver output")
|
|
return cast(list[str], outputs)
|
|
|
|
|
|
def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExecutor | None, *, task_boundary: bool = False) -> SideRecord:
|
|
started = time.monotonic()
|
|
runner = _run_task if task_boundary else _sandbox.run
|
|
observations: list[RunObservation] = []
|
|
timings = dict(build=0.0, ready=0.0, run=0.0)
|
|
|
|
disk_peak = 0
|
|
root: Path | None = None
|
|
|
|
def timed(stage: str, operation: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
|
|
nonlocal disk_peak
|
|
tick = time.monotonic()
|
|
try:
|
|
return operation(*args, **kwargs)
|
|
finally:
|
|
timings[stage] += time.monotonic() - tick
|
|
if root is not None:
|
|
disk_peak = max(disk_peak, sum(p.lstat().st_blocks * 512 for p in root.rglob("*")))
|
|
|
|
def record(outcome: str, reason: str) -> SideRecord:
|
|
return SideRecord(
|
|
outcome,
|
|
tuple(observations),
|
|
time.monotonic() - started,
|
|
reason,
|
|
timings["build"],
|
|
timings["ready"],
|
|
timings["run"],
|
|
disk_peak,
|
|
isolation_mode="task-boundary" if task_boundary else _sandbox.isolation_mode(),
|
|
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
|
|
)
|
|
|
|
try:
|
|
schema = load_demo(demo)
|
|
originals = (side.checkout, *side.dependencies, demo, *((side.products,) if side.products else ()))
|
|
initial = tuple(_tree(p) for p in originals)
|
|
for _ in range(1 if task_boundary else 3):
|
|
tick = time.monotonic()
|
|
with tempfile.TemporaryDirectory(prefix="ousast-proof-") as temporary:
|
|
root = Path(temporary)
|
|
checkout = shutil.copytree(side.checkout, root / "checkout")
|
|
dependencies = [shutil.copytree(p, root / f"dep-{i}") for i, p in enumerate(side.dependencies)]
|
|
immutable = (*dependencies,)
|
|
before = tuple(_tree(p) for p in immutable)
|
|
mounts = {f"/deps/{i}": p for i, p in enumerate(dependencies)}
|
|
|
|
def job(command: tuple[str, ...], checkout: Path = checkout) -> SandboxJob:
|
|
return SandboxJob("", command, checkout, {}, timeout, 256, 128)
|
|
|
|
products = root / "products"
|
|
if side.products is not None:
|
|
shutil.copytree(side.products, products)
|
|
else:
|
|
products.mkdir()
|
|
if task_boundary:
|
|
task_storage.check(task_storage.WORKSPACE if task_storage.WORKSPACE.is_dir() else root)
|
|
recipe = schema["build"]["recipe"]
|
|
build_args = schema["build"]["arguments"]
|
|
if task_boundary and recipe != "none":
|
|
return record("could_not_build", "verifier requires executor-built archive; builds are forbidden")
|
|
if recipe != "none":
|
|
build_input = checkout / build_args[0]
|
|
manifest = recipe in ("pip", "maven")
|
|
if not build_input.resolve().is_relative_to(checkout.resolve()) or not (
|
|
build_input.is_file() if manifest else build_input.is_dir()
|
|
):
|
|
return record("could_not_build", "recipe input missing or outside checkout")
|
|
command = (*BUILD_RECIPES[recipe], "/workspace/" + build_args[0])
|
|
if recipe == "maven":
|
|
command += ("package", "-DskipTests")
|
|
elif recipe == "gradle":
|
|
command += ("assemble",)
|
|
compiled = timed("build", runner, job(command), scratch=products, mounts=mounts, writable_checkout=True)
|
|
if compiled.exit_code or compiled.timed_out:
|
|
return record("could_not_build", "build recipe failed: " + compiled.stderr[-2000:])
|
|
if tuple(_tree(p) for p in immutable) != before:
|
|
return record("inconclusive", "build changed immutable inputs")
|
|
product_hash = _tree(products, generated_links=True)
|
|
checkout_hash = _tree(checkout, generated_links=True)
|
|
mounts["/build"] = products
|
|
start_spec = schema["start"]
|
|
entry = checkout / start_spec["path"]
|
|
if not entry.is_file() or not entry.resolve().is_relative_to(checkout.resolve()):
|
|
return record("could_not_build", "runtime entry missing from checkout")
|
|
command = (*RUNTIMES[start_spec["runtime"]], "/workspace/" + start_spec["path"], *start_spec["arguments"])
|
|
if start_spec["runtime"] == "python":
|
|
# Fixed bootstrap enables ordinary local imports and the pip
|
|
# recipe's offline packages without trusting PYTHONPATH.
|
|
bootstrap = (
|
|
"import os,runpy,sys; sys.dont_write_bytecode=True; p=sys.argv.pop(1); "
|
|
"sys.path[:0]=['/build/packages',os.path.dirname(p)]; sys.argv[0]=p; "
|
|
"runpy.run_path(p,run_name='__main__')"
|
|
)
|
|
command = ("/usr/bin/python3", "-I", "-c", bootstrap, "/workspace/" + start_spec["path"], *start_spec["arguments"])
|
|
|
|
oracle = oracle_for(family, browser)
|
|
assert oracle is not None
|
|
try:
|
|
canary = Canary.fresh(root / "fixture")
|
|
if task_boundary and hasattr(oracle, "task_boundary"):
|
|
oracle.task_boundary = True
|
|
app_env = oracle.prepare(canary)
|
|
app_scratch = canary.root if family == "command" else root / "app"
|
|
app_scratch.mkdir(exist_ok=True)
|
|
fixture = canary.root
|
|
if not task_boundary and _sandbox.isolation_mode() == "root-no-userns" and family != "command":
|
|
# Observer originals remain private. Only the app gets a
|
|
# read-only copy of the fixtures its interface requires.
|
|
fixture = shutil.copytree(canary.root, root / "app-fixture")
|
|
for path in [fixture, *fixture.rglob("*")]:
|
|
path.chmod(0o555 if path.is_dir() else 0o444)
|
|
app_mounts = {**mounts, "/fixture": fixture}
|
|
if schema["start"]["mode"] == "cli":
|
|
ready = timed(
|
|
"ready",
|
|
runner,
|
|
job((*command, *side.readiness_args)),
|
|
scratch=app_scratch,
|
|
mounts=app_mounts,
|
|
env=app_env,
|
|
namespace_pid=oracle.namespace_pid,
|
|
)
|
|
if ready.exit_code or ready.timed_out:
|
|
return record("could_not_run", "app readiness failed: " + ready.stderr[-2000:])
|
|
if family == "ssrf" and oracle.observe()[0]:
|
|
return record("inconclusive", "readiness triggered the SSRF oracle")
|
|
# Readiness cannot supply the attack's evidence or leave a command marker.
|
|
if family == "command" and any(canary.root.iterdir()):
|
|
return record("inconclusive", "readiness modified command fixture")
|
|
|
|
def http_ready_check(oracle: Oracle = oracle, canary: Canary = canary) -> None:
|
|
if family == "ssrf" and oracle.observe()[0]:
|
|
raise ValueError("HTTP readiness triggered the SSRF oracle")
|
|
if family == "command" and (canary.root / "marker").exists():
|
|
raise ValueError("HTTP readiness modified command fixture")
|
|
|
|
captures: dict[str, str] = {}
|
|
if schema["start"]["mode"] == "http":
|
|
outputs = timed(
|
|
"run",
|
|
_http_steps,
|
|
schema,
|
|
command,
|
|
job,
|
|
app_scratch,
|
|
app_mounts,
|
|
app_env,
|
|
oracle.namespace_pid,
|
|
canary.nonce,
|
|
runner,
|
|
http_ready_check,
|
|
)
|
|
for output in outputs:
|
|
timed("run", oracle.capture, output)
|
|
else:
|
|
for step in schema["steps"]:
|
|
arguments = [substitute(arg, captures) for arg in step["arguments"]]
|
|
result = timed(
|
|
"run",
|
|
runner,
|
|
job((*command, *arguments)),
|
|
scratch=app_scratch,
|
|
mounts=app_mounts,
|
|
env=app_env,
|
|
namespace_pid=oracle.namespace_pid,
|
|
input_text=substitute(step.get("stdin", ""), captures),
|
|
)
|
|
if result.timed_out:
|
|
return record("inconclusive", "app attack timed out")
|
|
if result.exit_code:
|
|
return record("could_not_run", "app invocation failed")
|
|
timed("run", oracle.capture, result.stdout)
|
|
capture_output(step, result.stdout, captures, canary.nonce)
|
|
observed, evidence = timed("run", oracle.observe)
|
|
if (
|
|
tuple(_tree(p) for p in immutable) != before
|
|
or tuple(_tree(p) for p in originals) != initial
|
|
or _tree(products, generated_links=True) != product_hash
|
|
or _tree(checkout, generated_links=True) != checkout_hash
|
|
):
|
|
return record("inconclusive", "immutable input or dependency changed")
|
|
observations.append(RunObservation(observed, evidence, time.monotonic() - tick))
|
|
finally:
|
|
oracle.close()
|
|
return record("observed", "fresh task run completed" if task_boundary else "three fresh runs completed")
|
|
except task_storage.ScratchLimit:
|
|
return record("could_not_build", "scratch limit")
|
|
except (OSError, ValueError, AssertionError, KeyError, IndexError, TypeError) as exc:
|
|
return record("could_not_run", f"verification unavailable or refused: {exc}")
|
|
|
|
|
|
def _run_task(
|
|
job: SandboxJob,
|
|
*,
|
|
scratch: Path,
|
|
mounts: dict[str, Path] | None = None,
|
|
env: dict[str, str] | None = None,
|
|
input_text: str = "",
|
|
**kwargs: Any,
|
|
) -> SandboxResult:
|
|
"""Only called in a fresh externally isolated task by verify_side_task.
|
|
|
|
The schema selects tracked programs, never agent scripts. The task itself is
|
|
the filesystem/network boundary. Its launch manifest must contain no keys.
|
|
"""
|
|
translations = {"/workspace": str(job.repo_root), "/scratch": str(scratch), **{k: str(v) for k, v in (mounts or {}).items()}}
|
|
pattern = re.compile("|".join(re.escape(key) for key in sorted(translations, key=len, reverse=True)))
|
|
|
|
def translate(value: str) -> str:
|
|
# One substitution pass: host temp paths may themselves contain /scratch.
|
|
return pattern.sub(lambda match: translations[match.group(0)], value)
|
|
|
|
command = tuple(translate(arg) for arg in job.command)
|
|
environment = {"PATH": "/usr/bin:/bin", "HOME": str(scratch), "TMPDIR": str(scratch), **task_storage.environment()}
|
|
environment.update({key: translate(value) for key, value in (env or {}).items()})
|
|
|
|
def limits() -> None:
|
|
resource.setrlimit(resource.RLIMIT_FSIZE, (1024 * 1024,) * 2)
|
|
resource.setrlimit(resource.RLIMIT_AS, (job.memory_mb * 1024**2,) * 2)
|
|
resource.setrlimit(resource.RLIMIT_CPU, (job.timeout_seconds + 1,) * 2)
|
|
|
|
# Driver JSON includes command paths, but arbitrary user input stays data.
|
|
if "-c" in command and _HTTP_DRIVER in job.command:
|
|
config = json.loads(input_text)
|
|
config["command"] = [translate(arg) for arg in config["command"]]
|
|
input_text = json.dumps(config)
|
|
with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err:
|
|
proc = subprocess.Popen(
|
|
command, cwd=scratch, env=environment, stdin=subprocess.PIPE, stdout=out, stderr=err, start_new_session=True, preexec_fn=limits
|
|
)
|
|
expired = False
|
|
try:
|
|
task_storage.communicate(proc, input_text.encode(), job.timeout_seconds, job.repo_root.parent, files=(out, err))
|
|
except subprocess.TimeoutExpired:
|
|
expired = True
|
|
finally:
|
|
with suppress(ProcessLookupError):
|
|
os.killpg(proc.pid, signal.SIGKILL)
|
|
proc.wait()
|
|
out.seek(0)
|
|
err.seek(0)
|
|
return SandboxResult(
|
|
proc.returncode, out.read(1048576).decode(errors="replace"), err.read(1048576).decode(errors="replace"), expired
|
|
)
|
|
|
|
|
|
def verify_side_task(side: Side, demo: Path, family: str, timeout_seconds: int = 5) -> SideRecord:
|
|
"""Fresh AX task entry: one repetition, grouped by the trusted dispatcher.
|
|
|
|
This explicit API is never selected as a fallback in the host verifier.
|
|
"""
|
|
load_demo(demo)
|
|
if timeout_seconds < 1:
|
|
raise ValueError("timeout must be positive")
|
|
browser = BrowserExecutor(task_boundary=True) if family == "xss" else None
|
|
if oracle_for(family, browser) is None:
|
|
return SideRecord("no_oracle", (), 0, "no owned oracle", isolation_mode="task-boundary")
|
|
return _side(side, demo, family, timeout_seconds, browser, task_boundary=True)
|
|
|
|
|
|
def verify(
|
|
affected: Side,
|
|
safe: Side,
|
|
artefact: Path,
|
|
family: str,
|
|
*,
|
|
timeout_seconds: int = 5,
|
|
browser: BrowserExecutor | None = None,
|
|
task_dispatcher: Callable[..., SideRecord] | None = None,
|
|
) -> VerificationRecord:
|
|
"""Affected is vulnerable/head; safe is fixed/base. No negative verdict exists."""
|
|
started = time.monotonic()
|
|
if oracle_for(family, browser) is None:
|
|
return VerificationRecord("no_oracle", (), time.monotonic() - started, "no configured owned oracle for " + family)
|
|
if timeout_seconds < 1:
|
|
raise ValueError("timeout must be positive")
|
|
try:
|
|
load_demo(artefact)
|
|
except (OSError, ValueError) as exc:
|
|
return VerificationRecord("inconclusive", (), time.monotonic() - started, "invalid declarative demo: " + str(exc))
|
|
mode = "task-boundary" if task_dispatcher is not None else _sandbox.isolation_mode()
|
|
if task_dispatcher is None:
|
|
try:
|
|
_sandbox.isolation_check(timeout_seconds=timeout_seconds)
|
|
except _sandbox.IsolationUnavailable:
|
|
raise _sandbox.IsolationUnavailable("task-boundary requires a trusted fresh-task dispatcher; no local fallback") from None
|
|
if mode == "task-boundary":
|
|
assert task_dispatcher is not None
|
|
# Only trusted infrastructure supplies this callback. One fresh task per
|
|
# side; task IDs and cleanup are the dispatcher's responsibility.
|
|
sides = tuple(
|
|
task_dispatcher(side=s, demo=load_demo(artefact), family=family, timeout_seconds=timeout_seconds, fresh_task=True)
|
|
for s in (affected, safe)
|
|
)
|
|
if any(s.isolation_mode != "task-boundary" for s in sides):
|
|
raise _sandbox.IsolationUnavailable("dispatcher did not attest task-boundary isolation")
|
|
else:
|
|
sides = tuple(_side(s, artefact, family, timeout_seconds, browser) for s in (affected, safe))
|
|
if any(s.outcome == "observed" and len(s.runs) != 3 for s in sides):
|
|
outcome, reason = "inconclusive", "each side requires exactly three fresh observations"
|
|
elif sides[1].outcome != "observed":
|
|
outcome, reason = "inconclusive", "safe side did not complete build/readiness/verification"
|
|
elif sides[0].outcome != "observed":
|
|
outcome, reason = sides[0].outcome, sides[0].reason
|
|
elif all(r.observed for r in sides[0].runs) and not any(r.observed for r in sides[1].runs):
|
|
outcome, reason = "demonstrated", "owned effect in 3/3 affected runs and 0/3 safe runs"
|
|
else:
|
|
outcome, reason = "inconclusive", "no consistent differential effect"
|
|
return VerificationRecord(
|
|
outcome,
|
|
sides,
|
|
time.monotonic() - started,
|
|
reason,
|
|
isolation_mode=mode,
|
|
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("affected", type=Path)
|
|
parser.add_argument("safe", type=Path)
|
|
parser.add_argument("artefact", type=Path)
|
|
parser.add_argument("family")
|
|
args = parser.parse_args()
|
|
result = verify(Side(args.affected), Side(args.safe), args.artefact, args.family)
|
|
print(json.dumps(asdict(result), indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|