mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
201 lines
6.2 KiB
Python
201 lines
6.2 KiB
Python
"""Docker CLI sandbox runner that never mounts the host Docker socket."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import tempfile
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from subprocess import CompletedProcess
|
|
from typing import Protocol
|
|
|
|
NONROOT_USER = "65534:65534"
|
|
WORKSPACE_MOUNT = "/workspace"
|
|
SCRATCH_MOUNT = "/scratch"
|
|
SCRATCH_SEED_MOUNT = "/scratch-in"
|
|
RUNNER_ENV = "OPENULTRASAST_SANDBOX_RUNNER"
|
|
_COPY_SEED_THEN_EXEC = 'cp -r /scratch-in/. /scratch/ && exec "$@"'
|
|
_SEED_ARGV0 = "ousast-sandbox"
|
|
_FAKE_RUNNER = frozenset({"fake"})
|
|
|
|
|
|
class SandboxRunner(Protocol):
|
|
def run(self, spec: SandboxJob) -> SandboxResult: ...
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SandboxJob:
|
|
image: str
|
|
command: tuple[str, ...]
|
|
repo_root: Path
|
|
scratch_files: dict[str, str]
|
|
timeout_seconds: int
|
|
memory_mb: int
|
|
pids_limit: int
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SandboxResult:
|
|
exit_code: int
|
|
stdout: str
|
|
stderr: str
|
|
timed_out: bool
|
|
|
|
|
|
class FakeSandboxRunner:
|
|
"""Record jobs and return a programmed result without a Docker daemon."""
|
|
|
|
def __init__(self, result: SandboxResult | None = None) -> None:
|
|
self.jobs: list[SandboxJob] = []
|
|
self.result = result if result is not None else SandboxResult(exit_code=0, stdout="", stderr="", timed_out=False)
|
|
|
|
def run(self, spec: SandboxJob) -> SandboxResult:
|
|
self.jobs.append(spec)
|
|
return self.result
|
|
|
|
|
|
def resolve_sandbox_runner() -> SandboxRunner:
|
|
"""Return the Docker CLI runner, or a fake when OPENULTRASAST_SANDBOX_RUNNER=fake."""
|
|
flag = os.environ.get(RUNNER_ENV, "").strip().lower()
|
|
if flag in _FAKE_RUNNER:
|
|
return FakeSandboxRunner()
|
|
return DockerCliRunner()
|
|
|
|
|
|
class DockerCliRunner:
|
|
"""Run a job with Docker CLI flags that match the threat model."""
|
|
|
|
def __init__(self, *, runner: Callable[..., CompletedProcess[str]] | None = None) -> None:
|
|
self._runner = runner
|
|
|
|
def run(self, spec: SandboxJob) -> SandboxResult:
|
|
if spec.scratch_files:
|
|
with tempfile.TemporaryDirectory(prefix="ousast-scratch-") as tmp:
|
|
seed = Path(tmp)
|
|
_write_scratch_files(seed, spec.scratch_files)
|
|
return self._invoke(_argv_with_scratch_seed(spec, seed), spec)
|
|
return self._invoke(build_docker_argv(spec), spec)
|
|
|
|
def _invoke(self, argv: list[str], spec: SandboxJob) -> SandboxResult:
|
|
_reject_unsafe_argv(argv)
|
|
run = self._runner if self._runner is not None else subprocess.run
|
|
try:
|
|
completed = run(
|
|
argv,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=spec.timeout_seconds,
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
return SandboxResult(
|
|
exit_code=-1,
|
|
stdout=_as_text(exc.stdout),
|
|
stderr=_as_text(exc.stderr),
|
|
timed_out=True,
|
|
)
|
|
return SandboxResult(
|
|
exit_code=completed.returncode,
|
|
stdout=completed.stdout or "",
|
|
stderr=completed.stderr or "",
|
|
timed_out=False,
|
|
)
|
|
|
|
|
|
def build_docker_argv(job: SandboxJob) -> list[str]:
|
|
argv = [*_isolation_argv(job), job.image, *job.command]
|
|
_reject_unsafe_argv(argv)
|
|
return argv
|
|
|
|
|
|
def _isolation_argv(job: SandboxJob) -> list[str]:
|
|
repo = str(job.repo_root.resolve())
|
|
return [
|
|
"docker",
|
|
"run",
|
|
"--rm",
|
|
"--init",
|
|
"--network",
|
|
"none",
|
|
"--read-only",
|
|
"--tmpfs",
|
|
f"{SCRATCH_MOUNT}:rw,exec",
|
|
"--memory",
|
|
f"{job.memory_mb}m",
|
|
"--pids-limit",
|
|
str(job.pids_limit),
|
|
"--cap-drop",
|
|
"ALL",
|
|
"--security-opt",
|
|
"no-new-privileges",
|
|
"--user",
|
|
NONROOT_USER,
|
|
"--mount",
|
|
f"type=bind,src={repo},dst={WORKSPACE_MOUNT},ro",
|
|
]
|
|
|
|
|
|
def _argv_with_scratch_seed(job: SandboxJob, seed: Path) -> list[str]:
|
|
argv = [
|
|
*_isolation_argv(job),
|
|
"--mount",
|
|
f"type=bind,src={seed.resolve()},dst={SCRATCH_SEED_MOUNT},ro",
|
|
job.image,
|
|
"/bin/sh",
|
|
"-c",
|
|
_COPY_SEED_THEN_EXEC,
|
|
_SEED_ARGV0,
|
|
*job.command,
|
|
]
|
|
_reject_unsafe_argv(argv)
|
|
return argv
|
|
|
|
|
|
def _write_scratch_files(root: Path, files: Mapping[str, str]) -> None:
|
|
resolved_root = root.resolve()
|
|
for relative, content in files.items():
|
|
candidate = Path(relative)
|
|
if candidate.is_absolute():
|
|
raise ValueError(f"scratch path {relative!r} must be relative to {SCRATCH_MOUNT}")
|
|
destination = (resolved_root / candidate).resolve()
|
|
try:
|
|
common = os.path.commonpath([str(resolved_root), str(destination)])
|
|
except ValueError as exc:
|
|
raise ValueError(f"scratch path {relative!r} escapes {SCRATCH_MOUNT}") from exc
|
|
if common != str(resolved_root):
|
|
raise ValueError(f"scratch path {relative!r} escapes {SCRATCH_MOUNT}")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
destination.write_text(content)
|
|
_chmod_seed_tree(resolved_root)
|
|
|
|
|
|
def _chmod_seed_tree(root: Path) -> None:
|
|
# Container user 65534 must be able to traverse and read the seed bind.
|
|
for dirpath, _dirnames, filenames in os.walk(root):
|
|
Path(dirpath).chmod(0o755)
|
|
for name in filenames:
|
|
(Path(dirpath) / name).chmod(0o644)
|
|
|
|
|
|
def _reject_unsafe_argv(argv: list[str]) -> None:
|
|
joined = " ".join(argv)
|
|
if "/var/run/docker.sock" in joined:
|
|
raise ValueError("sandbox argv must not mount the host Docker socket")
|
|
if "--privileged" in argv:
|
|
raise ValueError("sandbox argv must not be privileged")
|
|
for index, item in enumerate(argv):
|
|
if item in {"--network", "--net"} and index + 1 < len(argv) and argv[index + 1] == "host":
|
|
raise ValueError("sandbox argv must not use host network")
|
|
if item in {"--network=host", "--net=host"}:
|
|
raise ValueError("sandbox argv must not use host network")
|
|
|
|
|
|
def _as_text(value: str | bytes | None) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
return value.decode(errors="replace")
|
|
return value
|