mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 12:39:28 +00:00
search-with-proof: declarative demos, brain/executor split, kube-ax constraints
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
721c4b0997
commit
85f4f0b3d4
+372
-120
@@ -1,47 +1,44 @@
|
||||
"""Offline differential verification through an isolated, batch CLI interface.
|
||||
"""Verifier-owned execution of bounded JSON demonstrations and owned oracles.
|
||||
|
||||
Side.command and readiness_args are trusted integration configuration, not demo
|
||||
contents. TARGET is an executable CLI bridge: each invocation queues arguments
|
||||
for the app. It deliberately has no synchronous response channel. Alternatively
|
||||
request.json contains one argument vector. HTTP apps need a trusted CLI adapter;
|
||||
this lane never treats an artefact's stdout, exit status, or claimed result as
|
||||
proof. Build scripts validate the immutable checkout and may write scratch only;
|
||||
only Side.build_command (trusted integration config) can produce /build runtime outputs.
|
||||
|
||||
Run: python -m openultrasast.search.verify vulnerable fixed demo path --entry app.py
|
||||
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
|
||||
from typing import Any, cast
|
||||
|
||||
from ..sandbox import SandboxJob
|
||||
from . import _sandbox
|
||||
from .oracles import BrowserExecutor, Canary, oracle_for
|
||||
|
||||
_BRIDGE = """#!/usr/bin/python3 -I
|
||||
import json, sys
|
||||
with open('/scratch/requests.jsonl', 'a') as stream:
|
||||
stream.write(json.dumps(sys.argv[1:]) + '\\n')
|
||||
"""
|
||||
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, ...]
|
||||
command: tuple[str, ...] = ()
|
||||
readiness_args: tuple[str, ...] = ("--ready",)
|
||||
dependencies: tuple[Path, ...] = ()
|
||||
build_command: tuple[str, ...] = ()
|
||||
products: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -75,42 +72,165 @@ class VerificationRecord:
|
||||
chromium_no_sandbox: bool = False
|
||||
|
||||
|
||||
def _tree(root: Path) -> str:
|
||||
"""Read every file; hash names, modes and bytes, refusing links/devices."""
|
||||
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 not root.is_dir() or root.is_symlink():
|
||||
raise ValueError("input must be a real directory")
|
||||
for path in [root, *sorted(root.rglob("*"))]:
|
||||
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()
|
||||
|
||||
|
||||
def _requests(scratch: Path, demo: Path) -> list[list[str]]:
|
||||
path = demo / "request.json"
|
||||
if path.is_file():
|
||||
values = [json.loads(path.read_text())]
|
||||
else:
|
||||
queue = scratch / "requests.jsonl"
|
||||
if not queue.exists():
|
||||
return []
|
||||
if queue.is_symlink() or not queue.is_file() or queue.stat().st_size > 65536:
|
||||
raise ValueError("invalid request queue")
|
||||
values = [json.loads(line) for line in queue.read_text().splitlines()]
|
||||
if len(values) > 32 or any(
|
||||
not isinstance(v, list) or not all(isinstance(a, str) and "\0" not in a for a in v) or sum(map(len, v)) > 16384 for v in values
|
||||
):
|
||||
raise ValueError("invalid or excessive CLI requests")
|
||||
return values
|
||||
# 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 _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExecutor | None) -> SideRecord:
|
||||
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)
|
||||
|
||||
@@ -137,20 +257,21 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
|
||||
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:
|
||||
originals = (side.checkout, *side.dependencies, demo)
|
||||
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(3):
|
||||
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")
|
||||
artefact = shutil.copytree(demo, root / "demo")
|
||||
dependencies = [shutil.copytree(p, root / f"dep-{i}") for i, p in enumerate(side.dependencies)]
|
||||
immutable = (checkout, *dependencies, artefact)
|
||||
immutable = (*dependencies,)
|
||||
before = tuple(_tree(p) for p in immutable)
|
||||
mounts = {f"/deps/{i}": p for i, p in enumerate(dependencies)}
|
||||
|
||||
@@ -158,104 +279,216 @@ def _side(side: Side, demo: Path, family: str, timeout: int, browser: BrowserExe
|
||||
return SandboxJob("", command, checkout, {}, timeout, 256, 128)
|
||||
|
||||
products = root / "products"
|
||||
products.mkdir()
|
||||
if side.build_command:
|
||||
compiled = timed("build", _sandbox.run, job(side.build_command), scratch=products, mounts=mounts)
|
||||
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", "trusted build failed: " + compiled.stderr[:300])
|
||||
product_hash = _tree(products)
|
||||
mounts["/build"] = products
|
||||
build_scratch = root / "build"
|
||||
build_scratch.mkdir()
|
||||
built = timed(
|
||||
"build", _sandbox.run, job(("/bin/sh", "/demo/build.sh")), scratch=build_scratch, mounts={**mounts, "/demo": artefact}
|
||||
)
|
||||
if built.exit_code or built.timed_out:
|
||||
return record("could_not_build", "build failed: " + built.stderr[:300])
|
||||
if tuple(_tree(p) for p in immutable) != before or _tree(products) != product_hash:
|
||||
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 _sandbox.isolation_mode() == "root-no-userns" and family != "command":
|
||||
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}
|
||||
ready = timed(
|
||||
"ready",
|
||||
_sandbox.run,
|
||||
job((*side.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[:300])
|
||||
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")
|
||||
attack_scratch = root / "attack"
|
||||
attack_scratch.mkdir()
|
||||
if (artefact / "attack.sh").is_file():
|
||||
bridge = root / "target"
|
||||
bridge.write_text(_BRIDGE)
|
||||
bridge.chmod(0o555)
|
||||
attacker_input = root / "attacker-input"
|
||||
attacker_input.mkdir()
|
||||
attack = timed(
|
||||
"run",
|
||||
_sandbox.run,
|
||||
job(("/bin/sh", "/demo/attack.sh"), checkout=attacker_input),
|
||||
scratch=attack_scratch,
|
||||
mounts={"/demo": artefact, "/target": bridge},
|
||||
env={"TARGET": "/target", "SCRATCH": "/scratch"},
|
||||
)
|
||||
# Output/status are never evidence. Timeout and explicit filesystem
|
||||
# denials veto verification; read-only mounts enforce the boundary.
|
||||
if attack.timed_out:
|
||||
return record("inconclusive", "attack timed out")
|
||||
if "Read-only file system" in attack.stderr or "Permission denied" in attack.stderr:
|
||||
return record("inconclusive", "attacker boundary refused")
|
||||
for arguments in _requests(attack_scratch, artefact):
|
||||
result = timed(
|
||||
"run",
|
||||
_sandbox.run,
|
||||
job((*side.command, *arguments)),
|
||||
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 result.timed_out:
|
||||
return record("inconclusive", "app attack timed out")
|
||||
timed("run", oracle.capture, result.stdout)
|
||||
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) != product_hash
|
||||
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", "three fresh runs completed")
|
||||
except (OSError, ValueError, AssertionError) as exc:
|
||||
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,
|
||||
@@ -264,6 +497,7 @@ def verify(
|
||||
*,
|
||||
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()
|
||||
@@ -271,9 +505,31 @@ def verify(
|
||||
return VerificationRecord("no_oracle", (), time.monotonic() - started, "no configured owned oracle for " + family)
|
||||
if timeout_seconds < 1:
|
||||
raise ValueError("timeout must be positive")
|
||||
_sandbox.isolation_check(timeout_seconds=timeout_seconds)
|
||||
sides = tuple(_side(s, artefact, family, timeout_seconds, browser) for s in (affected, safe))
|
||||
if sides[1].outcome != "observed":
|
||||
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
|
||||
@@ -286,6 +542,7 @@ def verify(
|
||||
sides,
|
||||
time.monotonic() - started,
|
||||
reason,
|
||||
isolation_mode=mode,
|
||||
chromium_no_sandbox=bool(getattr(browser, "no_sandbox", False)),
|
||||
)
|
||||
|
||||
@@ -298,13 +555,8 @@ def main() -> None:
|
||||
parser.add_argument("safe", type=Path)
|
||||
parser.add_argument("artefact", type=Path)
|
||||
parser.add_argument("family")
|
||||
parser.add_argument("--entry", required=True, help="trusted Python CLI entry relative to checkout")
|
||||
args = parser.parse_args()
|
||||
entry = Path(args.entry)
|
||||
if entry.is_absolute() or ".." in entry.parts:
|
||||
parser.error("entry must stay inside checkout")
|
||||
command = ("/usr/bin/python3", "-I", "/workspace/" + str(entry))
|
||||
result = verify(Side(args.affected, command), Side(args.safe, command), args.artefact, args.family)
|
||||
result = verify(Side(args.affected), Side(args.safe), args.artefact, args.family)
|
||||
print(json.dumps(asdict(result), indent=2))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user