From f78ea55e5d5a52dc2e47488b936d2963d0d27909 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:56:02 +0200 Subject: [PATCH] feat(three-stage-scan): add Docker CLI sandbox runner with isolation flags --- .kiro/specs/three-stage-scan/tasks.md | 2 +- src/openultrasast/sandbox/__init__.py | 19 +- src/openultrasast/sandbox/runner.py | 190 ++++++++++++++ tests/test_sandbox_runner.py | 346 ++++++++++++++++++++++++++ 4 files changed, 555 insertions(+), 2 deletions(-) create mode 100644 src/openultrasast/sandbox/runner.py create mode 100644 tests/test_sandbox_runner.py diff --git a/.kiro/specs/three-stage-scan/tasks.md b/.kiro/specs/three-stage-scan/tasks.md index a175299..0c9adcc 100644 --- a/.kiro/specs/three-stage-scan/tasks.md +++ b/.kiro/specs/three-stage-scan/tasks.md @@ -85,7 +85,7 @@ This plan ships the three-stage scan without rebuilding CWE policy or the regex - _Requirements: 9.3_ - _Boundary: SandboxProbe_ -- [ ] 4.2 (P) Run jobs through Docker CLI flags that match the threat model +- [x] 4.2 (P) Run jobs through Docker CLI flags that match the threat model - Network is disabled, source is read-only, scratch is writable, memory/pids/timeout apply, capabilities are dropped, source tree is not writable, and the host Docker socket is never mounted. - A fake runner records the job and returns a programmed result so default tests do not need a daemon. - Observable completion: the constructed command line includes network-none and read-only and excludes docker.sock; the fake runner is used by unit tests. diff --git a/src/openultrasast/sandbox/__init__.py b/src/openultrasast/sandbox/__init__.py index a855dfd..86d1361 100644 --- a/src/openultrasast/sandbox/__init__.py +++ b/src/openultrasast/sandbox/__init__.py @@ -1,5 +1,22 @@ """Isolated Docker CLI sandbox.""" from .probe import FakeSandboxProbe, SandboxProbe +from .runner import ( + DockerCliRunner, + FakeSandboxRunner, + SandboxJob, + SandboxResult, + SandboxRunner, + build_docker_argv, +) -__all__ = ["FakeSandboxProbe", "SandboxProbe"] +__all__ = [ + "DockerCliRunner", + "FakeSandboxProbe", + "FakeSandboxRunner", + "SandboxJob", + "SandboxProbe", + "SandboxResult", + "SandboxRunner", + "build_docker_argv", +] diff --git a/src/openultrasast/sandbox/runner.py b/src/openultrasast/sandbox/runner.py new file mode 100644 index 0000000..b000e76 --- /dev/null +++ b/src/openultrasast/sandbox/runner.py @@ -0,0 +1,190 @@ +"""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" +_COPY_SEED_THEN_EXEC = 'cp -r /scratch-in/. /scratch/ && exec "$@"' +_SEED_ARGV0 = "ousast-sandbox" + + +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 + + +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 diff --git a/tests/test_sandbox_runner.py b/tests/test_sandbox_runner.py new file mode 100644 index 0000000..6e0480c --- /dev/null +++ b/tests/test_sandbox_runner.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from openultrasast.sandbox import DockerCliRunner, FakeSandboxRunner, SandboxJob, SandboxResult, SandboxRunner, build_docker_argv +from openultrasast.sandbox import runner as runner_mod + + +def _job(tmp_path: Path, **overrides: object) -> SandboxJob: + repo = tmp_path / "repo" + repo.mkdir(exist_ok=True) + (repo / "app.py").write_text("print(1)\n") + values: dict[str, object] = { + "image": "python:3.12-alpine", + "command": ("python", "/scratch/case.py"), + "repo_root": repo, + "scratch_files": {"case.py": "print('ok')\n"}, + "timeout_seconds": 30, + "memory_mb": 256, + "pids_limit": 128, + } + values.update(overrides) + return SandboxJob(**values) # type: ignore[arg-type] + + +def _flag_value(argv: list[str], flag: str) -> str: + try: + return argv[argv.index(flag) + 1] + except (ValueError, IndexError) as exc: + raise AssertionError(f"{flag} missing from {argv}") from exc + + +def _mount_options(spec: str) -> dict[str, str]: + options: dict[str, str] = {} + for part in spec.split(","): + if "=" in part: + key, value = part.split("=", 1) + options[key] = value + else: + options[part] = "true" + return options + + +def _workspace_mount(argv: list[str]) -> dict[str, str]: + specs: list[str] = [] + for index, item in enumerate(argv): + if item in {"--mount", "-v", "--volume"} and index + 1 < len(argv): + specs.append(argv[index + 1]) + for spec in specs: + options = _mount_options(spec) + target = options.get("dst") or options.get("destination") or options.get("target") + if target == "/workspace" or spec.endswith(":/workspace") or ":/workspace:" in spec: + return options if target == "/workspace" else {"raw": spec} + if "/workspace" in spec: + return options if options else {"raw": spec} + raise AssertionError(f"no /workspace mount in {argv}") + + +def _seed_source(argv: list[str]) -> Path: + mounts = [argv[index + 1] for index, item in enumerate(argv) if item == "--mount" and index + 1 < len(argv)] + seed_mounts = [spec for spec in mounts if "/scratch-in" in spec] + assert seed_mounts, argv + options = _mount_options(seed_mounts[0]) + source = Path(options.get("src") or options.get("source") or "") + assert source.is_dir(), argv + return source + + +def _copy_script(argv: list[str]) -> str: + try: + return argv[argv.index("-c") + 1] + except (ValueError, IndexError) as exc: + raise AssertionError(f"no /bin/sh -c wrapper in {argv}") from exc + + +def _posix_mode(path: Path) -> int: + return path.stat().st_mode & 0o777 + + +def test_build_docker_argv_starts_with_docker_run_rm_init(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + assert argv[0] == "docker" + assert argv[1] == "run" + assert "--rm" in argv + assert "--init" in argv + + +def test_build_docker_argv_includes_network_none_and_read_only(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + joined = " ".join(argv) + assert "--read-only" in argv + assert "--network none" in joined or "--network=none" in argv + assert "--network host" not in joined + assert "--network=host" not in argv + + +def test_build_docker_argv_excludes_docker_sock(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + joined = " ".join(argv) + assert "docker.sock" not in joined + assert "/var/run/docker.sock" not in joined + assert "-v" not in argv + assert "--volume" not in argv + + +def test_build_docker_argv_forbids_privileged_and_host_network(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + assert "--privileged" not in argv + assert "--pid" not in argv or _flag_value(argv, "--pid") != "host" + assert "--ipc" not in argv or _flag_value(argv, "--ipc") != "host" + assert "--userns" not in argv or "host" not in _flag_value(argv, "--userns") + + +def test_build_docker_argv_scratch_is_writable_tmpfs(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + tmpfs = _flag_value(argv, "--tmpfs") + assert tmpfs.startswith("/scratch") + assert "rw" in tmpfs + assert "exec" in tmpfs + + +def test_build_docker_argv_applies_memory_and_pids_limits(tmp_path: Path) -> None: + job = _job(tmp_path, memory_mb=512, pids_limit=64) + argv = build_docker_argv(job) + memory = _flag_value(argv, "--memory") + assert "512" in memory + assert memory.endswith("m") or memory.endswith("M") or memory.endswith("b") + assert _flag_value(argv, "--pids-limit") == "64" + + +def test_build_docker_argv_drops_capabilities_and_new_privileges(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + assert _flag_value(argv, "--cap-drop") == "ALL" + security = _flag_value(argv, "--security-opt") + assert "no-new-privileges" in security + + +def test_build_docker_argv_uses_non_root_user(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + user = _flag_value(argv, "--user") + uid = user.split(":", 1)[0] + assert uid not in {"0", "root"} + assert int(uid) != 0 + + +def test_build_docker_argv_bind_mounts_repo_read_only_at_workspace(tmp_path: Path) -> None: + job = _job(tmp_path) + argv = build_docker_argv(job) + mount = _workspace_mount(argv) + source = mount.get("src") or mount.get("source") + assert source == str(job.repo_root.resolve()) + assert mount.get("dst") == "/workspace" or mount.get("destination") == "/workspace" or mount.get("target") == "/workspace" + assert mount.get("ro") == "true" or mount.get("readonly") == "true" + assert "rw" not in mount + + +def test_build_docker_argv_source_tree_is_not_writable(tmp_path: Path) -> None: + argv = build_docker_argv(_job(tmp_path)) + mount = _workspace_mount(argv) + assert mount.get("ro") == "true" or mount.get("readonly") == "true" + assert "--read-only" in argv + joined = " ".join(argv) + assert f"{_job(tmp_path).repo_root.resolve()}:/workspace:rw" not in joined + assert f"{_job(tmp_path).repo_root.resolve()}:/workspace " not in joined + " " + + +def test_build_docker_argv_ends_with_image_and_command(tmp_path: Path) -> None: + job = _job(tmp_path) + argv = build_docker_argv(job) + assert argv[-len(job.command) :] == list(job.command) + assert argv[-len(job.command) - 1] == job.image + + +def test_fake_sandbox_runner_records_jobs_and_returns_programmed_result(tmp_path: Path) -> None: + programmed = SandboxResult(exit_code=7, stdout="out", stderr="err", timed_out=False) + runner: SandboxRunner = FakeSandboxRunner(programmed) + job = _job(tmp_path) + result = runner.run(job) + assert result == programmed + assert isinstance(runner, FakeSandboxRunner) + assert runner.jobs == [job] + + +def test_fake_sandbox_runner_can_be_reprogrammed(tmp_path: Path) -> None: + runner = FakeSandboxRunner() + first = _job(tmp_path, command=("echo", "one")) + second = _job(tmp_path, command=("echo", "two")) + timeout_result = SandboxResult(exit_code=-1, stdout="", stderr="deadline", timed_out=True) + runner.result = timeout_result + assert runner.run(first).timed_out is True + runner.result = SandboxResult(exit_code=0, stdout="ok", stderr="", timed_out=False) + assert runner.run(second).stdout == "ok" + assert runner.jobs == [first, second] + + +def test_fake_sandbox_runner_does_not_need_docker_daemon(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise AssertionError("unit tests must not invoke the docker daemon") + + monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr(runner_mod.subprocess, "run", boom) + programmed = SandboxResult(exit_code=0, stdout="fake", stderr="", timed_out=False) + runner = FakeSandboxRunner(programmed) + assert runner.run(_job(tmp_path)) == programmed + + +def test_docker_cli_runner_uses_constructed_argv_and_timeout(tmp_path: Path) -> None: + seen: dict[str, Any] = {} + job = _job(tmp_path, scratch_files={}, timeout_seconds=19) + + def fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]: + seen["command"] = list(command) + seen["timeout"] = kwargs.get("timeout") + seen["check"] = kwargs.get("check") + seen["capture_output"] = kwargs.get("capture_output") + seen["text"] = kwargs.get("text") + return subprocess.CompletedProcess(command, 3, stdout="hello", stderr="warn") + + result = DockerCliRunner(runner=fake_run).run(job) + assert seen["command"] == build_docker_argv(job) + assert seen["timeout"] == 19 + assert seen["check"] is False + assert seen["capture_output"] is True + assert seen["text"] is True + assert result == SandboxResult(exit_code=3, stdout="hello", stderr="warn", timed_out=False) + assert "--network" in seen["command"] + assert "--read-only" in seen["command"] + assert "docker.sock" not in " ".join(seen["command"]) + + +def test_docker_cli_runner_maps_timeout_expired_to_timed_out(tmp_path: Path) -> None: + job = _job(tmp_path, scratch_files={}, timeout_seconds=5) + + def fake_run(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired(cmd=command, timeout=5, output="partial", stderr="slow") + + result = DockerCliRunner(runner=fake_run).run(job) + assert result.timed_out is True + assert result.stdout == "partial" + assert result.stderr == "slow" + + +def test_docker_cli_runner_does_not_invoke_daemon_when_runner_injected(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[str]: + raise AssertionError("unit tests must not invoke the docker daemon") + + monkeypatch.setattr(subprocess, "run", boom) + monkeypatch.setattr(runner_mod.subprocess, "run", boom) + + def fake_run(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + result = DockerCliRunner(runner=fake_run).run(_job(tmp_path, scratch_files={})) + assert result.timed_out is False + assert result.exit_code == 0 + + +def test_docker_cli_runner_does_not_write_the_source_tree(tmp_path: Path) -> None: + job = _job(tmp_path) + original = (job.repo_root / "app.py").read_text() + before = {path.relative_to(job.repo_root): path.read_text() for path in job.repo_root.rglob("*") if path.is_file()} + + def fake_run(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + joined = " ".join(command) + assert "docker.sock" not in joined + assert "--read-only" in command + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + DockerCliRunner(runner=fake_run).run(job) + assert (job.repo_root / "app.py").read_text() == original + after = {path.relative_to(job.repo_root): path.read_text() for path in job.repo_root.rglob("*") if path.is_file()} + assert after == before + assert "case.py" not in {path.name for path in job.repo_root.iterdir()} + + +def test_docker_cli_runner_seeds_scratch_files_outside_the_repo(tmp_path: Path) -> None: + job = _job(tmp_path, scratch_files={"nested/case.py": "print('seeded')\n"}) + seen: dict[str, Any] = {} + + def fake_run(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + seen["command"] = list(command) + mounts = [command[index + 1] for index, item in enumerate(command) if item == "--mount" and index + 1 < len(command)] + seed_mounts = [spec for spec in mounts if "/scratch-in" in spec] + assert seed_mounts, command + options = _mount_options(seed_mounts[0]) + source = Path(options.get("src") or options.get("source") or "") + assert source.is_dir() + assert job.repo_root.resolve() not in source.resolve().parents + assert source.resolve() != job.repo_root.resolve() + assert (source / "nested" / "case.py").read_text() == "print('seeded')\n" + assert options.get("ro") == "true" or options.get("readonly") == "true" + assert "--tmpfs" in command + assert _flag_value(command, "--tmpfs").startswith("/scratch") + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + result = DockerCliRunner(runner=fake_run).run(job) + assert result.exit_code == 0 + argv = seen["command"] + assert "docker.sock" not in " ".join(argv) + assert "--read-only" in argv + assert job.image in argv + + +def test_docker_cli_runner_scratch_seed_is_world_readable_for_container_user(tmp_path: Path) -> None: + job = _job(tmp_path, scratch_files={"nested/dir/case.py": "print('seeded')\n"}) + seen: dict[str, Path] = {} + + def fake_run(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + source = _seed_source(command) + seen["source"] = source + assert _posix_mode(source) == 0o755 + for dirpath, _dirnames, filenames in os.walk(source): + directory = Path(dirpath) + assert stat.S_ISDIR(directory.stat().st_mode) + assert _posix_mode(directory) == 0o755, directory + for name in filenames: + file_path = directory / name + assert _posix_mode(file_path) == 0o644, file_path + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + DockerCliRunner(runner=fake_run).run(job) + assert "source" in seen + + +def test_docker_cli_runner_scratch_copy_uses_cp_r_not_cp_a(tmp_path: Path) -> None: + job = _job(tmp_path, scratch_files={"nested/case.py": "print('seeded')\n"}) + seen: dict[str, str] = {} + + def fake_run(command: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + script = _copy_script(command) + seen["script"] = script + assert command[command.index(job.image) + 1] == "/bin/sh" + assert "cp -a" not in script + assert "cp -a" not in " ".join(command) + assert script.startswith("cp -r /scratch-in/. /scratch/") + assert 'exec "$@"' in script + return subprocess.CompletedProcess(command, 0, stdout="", stderr="") + + DockerCliRunner(runner=fake_run).run(job) + assert seen["script"] == 'cp -r /scratch-in/. /scratch/ && exec "$@"'