diff --git a/README.md b/README.md index d61b3bb..b45d7e3 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,10 @@ Use default runtime discovery only for trusted applications. The bounded host subprocess currently requires POSIX; on other platforms use `--secure-ast` for execution-free discovery or `--vm` for the hardened untrusted-code boundary. -`--vm` requires a prebuilt immutable image digest and a configured gVisor/Kata -runtime. It never builds an image or imports application code on the host; see -the [runtime sandbox policy](docs/runtime-sandbox.md). +`--vm` requires a prebuilt immutable image digest, dependency/snapshot/SBOM +attestations, and a configured gVisor/Kata runtime. It never pulls or builds an +image or imports application code on the host; see the +[runtime sandbox policy](docs/runtime-sandbox.md). `--scip` requires pinned external tools on `PATH`: `scip-query` 0.16.0, `@sourcegraph/scip-python` 0.6.6, and SCIP CLI. Install Node tooling outside the diff --git a/benchmarks/results/runtime-sandbox-v1/README.md b/benchmarks/results/runtime-sandbox-v1/README.md index 69a2b9b..6123c11 100644 --- a/benchmarks/results/runtime-sandbox-v1/README.md +++ b/benchmarks/results/runtime-sandbox-v1/README.md @@ -10,15 +10,20 @@ execution. It deliberately publishes no secure-vs-runtime quality numbers. The command builder and lifecycle tests attest: - immutable repository-digest resolution with ambiguity rejection; -- mandatory dependency-lock and SBOM SHA-256 attestations; -- gVisor/Kata requirement (`runc` is rejected); -- no network, host environment inheritance, devices, privileges, capabilities, - sockets, or writable checkout/root; -- non-root execution, read-only non-recursive mounts, deny-by-default seccomp, - and bounded noexec tmpfs; +- mandatory dependency-lock, source-snapshot-lock, SBOM, and seccomp SHA-256 + attestations; +- an explicit gVisor/Kata runtime allowlist (`runc` and unknown runtime names are + rejected); +- no image pull, network, host environment inheritance, IPC sharing, retained + logs, devices, privileges, capabilities, sockets, or writable checkout/root; +- non-root execution, Docker 29+ `bind-recursive=disabled` read-only mounts, + image `Config.Volumes` rejection, deny-by-default seccomp, and bounded noexec + tmpfs; - memory/swap, CPU, PID, nofile, nproc, fsize, core, timeout, and output limits; -- unique name/CID cleanup with post-removal inspection; -- deterministic content-addressed policy provenance; +- unique name/CID cleanup with successful bounded absence queries; +- deterministic content-addressed policy provenance and rejection of mutated + policy bytes, mount-grammar metacharacters, broad/symlinked/unreadable mounts, + special files, and malformed CID values; - malformed output and endpoint schema rejection. The wheel build contains diff --git a/docs/runtime-sandbox.md b/docs/runtime-sandbox.md index e350ee3..97fea14 100644 --- a/docs/runtime-sandbox.md +++ b/docs/runtime-sandbox.md @@ -14,31 +14,46 @@ Set the immutable image explicitly: ```bash export FASTAPI_ENDPOINT_DETECTOR_VM_IMAGE='registry.example/detector@sha256:<64-hex>' export FASTAPI_ENDPOINT_DETECTOR_VM_LOCK_SHA256='sha256:<64-hex>' +export FASTAPI_ENDPOINT_DETECTOR_VM_SNAPSHOT_SHA256='sha256:<64-hex>' export FASTAPI_ENDPOINT_DETECTOR_VM_SBOM_SHA256='sha256:<64-hex>' fastapi-endpoint-detector list --vm --app ./application ``` -The image must already contain all snapshot-pinned dependencies. The CLI no -longer builds a mutable image automatically. A content-addressed dependency lock -and SBOM attestation are mandatory launch inputs. Their production remains a -release-pipeline responsibility; benchmark manifests record both hashes, the -image digest, and `VMExecutor.policy_provenance()`. +The image must already contain all snapshot-pinned dependencies, declare no +`Config.Volumes`, and is never pulled during a comparator launch. The CLI no +longer builds a mutable image automatically. Content-addressed dependency-lock, +source-snapshot-lock, and SBOM attestations are mandatory launch inputs. Their +production remains a +release-pipeline responsibility; benchmark manifests record all three hashes, +the image digest, and `VMExecutor.policy_provenance()`. The packaged seccomp +profile has a pinned digest in the executor; a custom profile requires +`FASTAPI_ENDPOINT_DETECTOR_VM_SECCOMP_SHA256` and must match it exactly. ## Policy v1 +Docker Engine and CLI 29.0 or newer (API 1.52+) are the documented minimum. +Bind mounts use Docker 29's `bind-recursive=disabled`; the removed +`bind-nonrecursive` spelling is intentionally not emitted. + Every launch uses argv without a shell and enforces: -- gVisor/Kata runtime rather than the default OCI runtime; -- no network, devices, host sockets, privileges, or added capabilities; +- an explicit allowlist of gVisor/Kata runtime names rather than arbitrary or + default OCI runtimes; +- no daemon image pull, network, IPC sharing, retained container logs, devices, + host sockets, privileges, or added capabilities; - non-root UID/GID `65532:65532` and `no-new-privileges`; -- a read-only root and exact, read-only, non-recursive app/diff mounts; +- a read-only root and exact, read-only, non-recursive app/diff mounts; mount + grammar metacharacters, symlinked or unreadable trees, special files, + filesystem-root, home-root, and other broad system mounts fail before Docker + starts; - a 64 MiB `noexec,nosuid,nodev` tmpfs; - memory=swap, CPU, PID, `nofile`, `nproc`, `fsize`, core, timeout, and combined stdout/stderr limits; - a clean explicit process environment; - the versioned deny-by-default seccomp profile at `executor/policies/runtime-seccomp-v1.json`; -- a unique container name plus CID file, followed by kill and forced removal on +- a unique container name plus CID file, followed by kill, forced removal, and + successful bounded inventory queries proving both identifiers absent on success, failure, timeout, or output overflow. The policy and seccomp bytes are content-addressed in provenance. Missing diff --git a/src/fastapi_endpoint_detector/executor/vm_executor.py b/src/fastapi_endpoint_detector/executor/vm_executor.py index b9a7d9d..1f82146 100644 --- a/src/fastapi_endpoint_detector/executor/vm_executor.py +++ b/src/fastapi_endpoint_detector/executor/vm_executor.py @@ -9,7 +9,9 @@ import hashlib import json import os +import re import selectors +import stat import subprocess import tempfile import time @@ -29,6 +31,10 @@ class VMExecutorError(Exception): class SandboxPolicy: """Versioned fail-closed runtime policy and bounded resource limits.""" + SAFE_RUNTIMES: ClassVar[frozenset[str]] = frozenset( + {"runsc", "io.containerd.runsc.v1", "kata-runtime", "io.containerd.kata.v2"} + ) + version: int = 1 runtime: str = "runsc" user: str = "65532:65532" @@ -46,8 +52,15 @@ class SandboxPolicy: def __post_init__(self) -> None: if self.version != 1: raise ValueError("unsupported sandbox policy version") - if self.runtime in {"", "runc", "io.containerd.runc.v2"}: - raise ValueError("runtime sandbox requires gVisor/Kata, not the default OCI runtime") + if self.runtime not in self.SAFE_RUNTIMES: + raise ValueError("runtime sandbox requires an explicitly supported gVisor/Kata runtime") + if re.fullmatch(r"[1-9][0-9]*[bkmg]?", self.memory_limit) is None: + raise ValueError("memory limit must be a positive Docker byte value") + if self.memory_swap != self.memory_limit: + raise ValueError("memory and swap limits must be identical") + user_match = re.fullmatch(r"([0-9]+):([0-9]+)", self.user) + if user_match is None or any(int(value) == 0 for value in user_match.groups()): + raise ValueError("sandbox user must be a numeric non-root uid:gid") positive = ( self.cpu_quota, self.cpu_period, @@ -65,6 +78,13 @@ class VMExecutor: """Execute an explicit runtime comparator under a hardened container policy.""" DOCKER_IMAGE = "fastapi-endpoint-detector:vm" + PACKAGED_SECCOMP_SHA256 = ( + "sha256:96dbac26aac6041de88eaf99f653d606933469dd104157b877190d998ab68d4a" + ) + _IMAGE_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]*(?:@sha256:[0-9a-f]{64})?") + _CONTAINER_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") + _CID_PATTERN = re.compile(r"[0-9a-f]{12,64}") + _MOUNT_SCAN_LIMIT = 100_000 CLEAN_ENV: ClassVar[dict[str, str]] = { "HOME": "/tmp/home", "LANG": "C.UTF-8", @@ -88,7 +108,9 @@ def __init__( seccomp_profile: Path | None = None, output_limit_bytes: int = 4 * 1024 * 1024, dependency_lock_hash: str | None = None, + snapshot_lock_hash: str | None = None, sbom_hash: str | None = None, + seccomp_hash: str | None = None, ) -> None: if not network_disabled: raise ValueError("runtime comparator network access cannot be enabled") @@ -99,10 +121,20 @@ def __init__( self.image = ( image or os.environ.get("FASTAPI_ENDPOINT_DETECTOR_VM_IMAGE") or self.DOCKER_IMAGE ) - self.seccomp_profile = ( - seccomp_profile - or Path(__file__).resolve().parent / "policies" / "runtime-seccomp-v1.json" + self._validate_image_reference(self.image) + packaged_seccomp = ( + Path(__file__).resolve().parent / "policies" / "runtime-seccomp-v1.json" ).resolve() + self.seccomp_profile = self._validated_mount_source( + seccomp_profile or packaged_seccomp, + "seccomp profile", + allow_directory=False, + ) + self.seccomp_hash = seccomp_hash or os.environ.get( + "FASTAPI_ENDPOINT_DETECTOR_VM_SECCOMP_SHA256" + ) + if self.seccomp_hash is None and self.seccomp_profile == packaged_seccomp: + self.seccomp_hash = self.PACKAGED_SECCOMP_SHA256 self.policy = SandboxPolicy( runtime=runtime, memory_limit=memory_limit, @@ -115,9 +147,25 @@ def __init__( self.dependency_lock_hash = dependency_lock_hash or os.environ.get( "FASTAPI_ENDPOINT_DETECTOR_VM_LOCK_SHA256" ) + self.snapshot_lock_hash = snapshot_lock_hash or os.environ.get( + "FASTAPI_ENDPOINT_DETECTOR_VM_SNAPSHOT_SHA256" + ) self.sbom_hash = sbom_hash or os.environ.get("FASTAPI_ENDPOINT_DETECTOR_VM_SBOM_SHA256") self._resolved_image: str | None = None + @classmethod + def _validate_image_reference(cls, value: str, *, immutable: bool = False) -> str: + if ( + len(value) > 512 + or cls._IMAGE_PATTERN.fullmatch(value) is None + or value.startswith("-") + or ".." in value + ): + raise VMExecutorError("runtime image has an invalid Docker reference") + if immutable and "@sha256:" not in value: + raise VMExecutorError("runtime image must use an immutable sha256 digest") + return value + @staticmethod def _validated_hash(value: str | None, label: str) -> str: if value is None or not value.startswith("sha256:"): @@ -150,12 +198,31 @@ def build_image(self, dockerfile_path: Path | None = None) -> None: raise VMExecutorError("Docker image build timed out") from exc self._resolved_image = None + @staticmethod + def _validated_image_inspection(stdout: str) -> dict[str, Any]: + try: + payload = json.loads(stdout) + except (json.JSONDecodeError, TypeError) as exc: + raise VMExecutorError("image inspect did not return image configuration") from exc + if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], dict): + raise VMExecutorError("image inspect did not return exactly one image configuration") + inspected = payload[0] + config = inspected.get("Config") + if not isinstance(config, dict) or "Volumes" not in config: + raise VMExecutorError("image inspect did not return Config.Volumes") + volumes = config["Volumes"] + if volumes is not None and not isinstance(volumes, dict): + raise VMExecutorError("image inspect returned malformed Config.Volumes") + if volumes: + raise VMExecutorError("runtime image must not declare writable volumes") + return inspected + def _resolve_image(self) -> str: if self._resolved_image is not None: return self._resolved_image try: result = subprocess.run( - ["docker", "image", "inspect", "--format", "{{json .RepoDigests}}", self.image], + ["docker", "image", "inspect", self.image], capture_output=True, check=False, text=True, @@ -165,19 +232,20 @@ def _resolve_image(self) -> str: raise VMExecutorError("Docker or the configured image is unavailable") from exc if result.returncode != 0: raise VMExecutorError(f"Docker image {self.image!r} is unavailable") + inspected = self._validated_image_inspection(result.stdout) if "@sha256:" in self.image: resolved = self.image else: - try: - digests = json.loads(result.stdout) - except (json.JSONDecodeError, TypeError) as exc: - raise VMExecutorError("image inspect did not return repository digests") from exc + digests = inspected.get("RepoDigests") + if not isinstance(digests, list): + raise VMExecutorError("image inspect did not return repository digests") candidates = sorted( - item for item in digests or [] if isinstance(item, str) and "@sha256:" in item + item for item in digests if isinstance(item, str) and "@sha256:" in item ) if len(candidates) != 1: raise VMExecutorError("runtime image must resolve to exactly one repository digest") resolved = candidates[0] + self._validate_image_reference(resolved, immutable=True) digest = resolved.rsplit("@sha256:", maxsplit=1)[-1] if len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest): raise VMExecutorError("runtime image has an invalid sha256 digest") @@ -192,27 +260,133 @@ def check_image_exists(self) -> bool: return False return True + def _verified_seccomp_hash(self) -> str: + expected = self._validated_hash(self.seccomp_hash, "seccomp profile") + try: + mode = self.seccomp_profile.stat(follow_symlinks=False).st_mode + if not stat.S_ISREG(mode): + raise VMExecutorError("seccomp profile must be a regular file") + content = self.seccomp_profile.read_bytes() + except OSError as exc: + raise VMExecutorError( + f"seccomp profile is unavailable: {self.seccomp_profile}" + ) from exc + actual = f"sha256:{hashlib.sha256(content).hexdigest()}" + if actual != expected: + raise VMExecutorError("seccomp profile does not match its immutable attestation") + try: + payload = json.loads(content) + except (json.JSONDecodeError, UnicodeError) as exc: + raise VMExecutorError("seccomp profile is not valid JSON") from exc + if not isinstance(payload, dict) or payload.get("defaultAction") != "SCMP_ACT_ERRNO": + raise VMExecutorError("seccomp profile must be deny-by-default") + return actual + def policy_provenance(self) -> dict[str, Any]: """Return deterministic policy/image provenance suitable for benchmark manifests.""" - if not self.seccomp_profile.is_file(): - raise VMExecutorError(f"seccomp profile not found: {self.seccomp_profile}") - seccomp_hash = hashlib.sha256(self.seccomp_profile.read_bytes()).hexdigest() + seccomp_hash = self._verified_seccomp_hash() payload = { "policy": asdict(self.policy), "image": self._resolve_image(), - "seccomp_sha256": f"sha256:{seccomp_hash}", + "seccomp_sha256": seccomp_hash, "environment": dict(sorted(self.CLEAN_ENV.items())), "dependency_lock_hash": self._validated_hash( self.dependency_lock_hash, "dependency lock", ), + "snapshot_lock_hash": self._validated_hash( + self.snapshot_lock_hash, + "snapshot lock", + ), "sbom_hash": self._validated_hash(self.sbom_hash, "SBOM"), } canonical = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() return {**payload, "policy_sha256": f"sha256:{hashlib.sha256(canonical).hexdigest()}"} + @classmethod + def _validate_directory_entries(cls, root: Path, label: str) -> None: + inspected = 0 + + def raise_walk_error(exc: OSError) -> None: + raise VMExecutorError(f"cannot inspect {label} directory {root}: {exc}") from exc + + try: + for directory, names, files in os.walk( + root, + followlinks=False, + onerror=raise_walk_error, + ): + parent = Path(directory) + for name in (*names, *files): + inspected += 1 + if inspected > cls._MOUNT_SCAN_LIMIT: + raise VMExecutorError(f"{label} exceeds the bounded mount scan limit") + mode = (parent / name).lstat().st_mode + if not (stat.S_ISREG(mode) or stat.S_ISDIR(mode) or stat.S_ISLNK(mode)): + raise VMExecutorError( + f"{label} contains a socket, device, fifo, or other special file" + ) + except OSError as exc: + raise VMExecutorError(f"cannot inspect {label} directory {root}: {exc}") from exc + + @staticmethod + def _validated_mount_field(value: str, label: str) -> str: + if any(character in value for character in ",\r\n"): + raise VMExecutorError(f"{label} contains a Docker mount grammar metacharacter") + return value + + @classmethod + def _validated_mount_source( + cls, + path: Path, + label: str, + *, + allow_directory: bool, + ) -> Path: + # Resolving here would follow the symlink components rejected below. + absolute = Path(os.path.abspath(path.expanduser())) # noqa: PTH100 + cls._validated_mount_field(str(absolute), f"{label} path") + current = Path(absolute.anchor) + try: + for component in absolute.parts[1:]: + current /= component + if stat.S_ISLNK(os.lstat(current).st_mode): + raise VMExecutorError(f"{label} path cannot contain symlinks") + resolved = absolute.resolve(strict=True) + mode = resolved.stat(follow_symlinks=False).st_mode + except FileNotFoundError as exc: + raise VMExecutorError(f"{label} path does not exist: {path}") from exc + except OSError as exc: + raise VMExecutorError(f"cannot inspect {label} path {path}: {exc}") from exc + if stat.S_ISDIR(mode): + broad_roots = { + Path(resolved.anchor), + Path.home().resolve(), + Path(tempfile.gettempdir()).resolve(), + Path("/dev"), + Path("/etc"), + Path("/home"), + Path("/proc"), + Path("/run"), + Path("/sys"), + Path("/usr"), + Path("/var"), + } + if not allow_directory: + raise VMExecutorError(f"{label} must be a regular file") + if resolved in broad_roots: + raise VMExecutorError(f"refusing broad {label} directory mount: {resolved}") + cls._validate_directory_entries(resolved, label) + elif not stat.S_ISREG(mode): + raise VMExecutorError(f"{label} must be a regular file or directory") + return resolved + def _mount(self, source: Path, target: str) -> str: - return f"type=bind,src={source},dst={target},readonly,bind-nonrecursive" + source_field = self._validated_mount_field(str(source), "mount source") + target_field = self._validated_mount_field(target, "mount target") + # Policy v1 requires Docker 29 / API 1.52, where bind-nonrecursive was + # removed in favor of this bind-recursive=disabled spelling. + return f"type=bind,src={source_field},dst={target_field},readonly,bind-recursive=disabled" def _container_command( self, @@ -223,15 +397,17 @@ def _container_command( cidfile: Path, name: str, ) -> list[str]: - if not self.seccomp_profile.is_file(): - raise VMExecutorError(f"seccomp profile not found: {self.seccomp_profile}") + self._verified_seccomp_hash() self._validated_hash(self.dependency_lock_hash, "dependency lock") + self._validated_hash(self.snapshot_lock_hash, "snapshot lock") self._validated_hash(self.sbom_hash, "SBOM") - app = app_path.resolve(strict=True) + app = self._validated_mount_source(app_path, "application", allow_directory=True) app_target = "/workspace/app" if app.is_dir() else f"/workspace/{app.name}" command = [ "docker", "run", + "--pull", + "never", "--name", name, "--cidfile", @@ -240,6 +416,12 @@ def _container_command( self.policy.runtime, "--network", "none", + "--ipc", + "none", + "--pid", + "private", + "--log-driver", + "none", "--read-only", "--user", self.policy.user, @@ -276,7 +458,7 @@ def _container_command( ] cli = ["list", "--app", app_target, "--format", output_format, "--app-var", app_variable] if diff_path is not None: - diff = diff_path.resolve(strict=True) + diff = self._validated_mount_source(diff_path, "diff", allow_directory=False) command.extend(["--mount", self._mount(diff, "/workspace/change.diff")]) cli = [ "analyze", @@ -297,16 +479,52 @@ def _container_command( return command @staticmethod - def _cleanup_container(cidfile: Path, name: str) -> None: + def _absence_query_failure(container_filter: str, label: str) -> str | None: + command = [ + "docker", + "container", + "ls", + "--all", + "--no-trunc", + "--quiet", + "--filter", + container_filter, + ] + try: + remaining = subprocess.run( + command, + capture_output=True, + check=False, + text=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired, UnicodeError) as exc: + return f"cleanup verification query for {label}: {exc}" + if remaining.returncode != 0: + detail = remaining.stderr.strip() or f"exit status {remaining.returncode}" + return f"cleanup verification query for {label} failed: {detail}" + if remaining.stdout.strip(): + return f"container still exists after forced removal ({label})" + return None + + @classmethod + def _cleanup_container(cls, cidfile: Path, name: str) -> None: + if cls._CONTAINER_NAME_PATTERN.fullmatch(name) is None or name.startswith("-"): + raise VMExecutorError("sandbox container name is invalid") target = name + cid: str | None = None + failures: list[str] = [] try: if cidfile.is_file(): candidate = cidfile.read_text(encoding="utf-8").strip() if candidate: - target = candidate - except OSError: - pass - failures: list[str] = [] + if cls._CID_PATTERN.fullmatch(candidate) is None: + failures.append("CID file contained an invalid container identifier") + else: + target = candidate + cid = candidate + except (OSError, UnicodeError) as exc: + failures.append(f"cannot read CID file: {exc}") for command in ( ["docker", "kill", target], ["docker", "rm", "--force", target], @@ -320,18 +538,14 @@ def _cleanup_container(cidfile: Path, name: str) -> None: ) except (OSError, subprocess.TimeoutExpired) as exc: failures.append(f"{' '.join(command[:2])}: {exc}") - try: - remaining = subprocess.run( - ["docker", "container", "inspect", target], - capture_output=True, - check=False, - timeout=10, - ) - except (OSError, subprocess.TimeoutExpired) as exc: - failures.append(f"cleanup verification: {exc}") - else: - if remaining.returncode == 0: - failures.append("container still exists after forced removal") + exact_name_filter = name.replace(".", "[.]") + verification_filters = [("generated name", f"name=^/{exact_name_filter}$")] + if cid is not None: + verification_filters.append(("CID", f"id={cid}")) + for label, container_filter in verification_filters: + query_failure = cls._absence_query_failure(container_filter, label) + if query_failure is not None: + failures.append(query_failure) if failures: raise VMExecutorError("sandbox cleanup failed: " + "; ".join(failures)) @@ -392,8 +606,11 @@ def _execute_bounded( # noqa: PLR0912, PLR0915 finally: streams.close() self._cleanup_container(cidfile, name) - stdout = buffers["stdout"].decode("utf-8", errors="replace") - stderr = buffers["stderr"].decode("utf-8", errors="replace") + try: + stdout = buffers["stdout"].decode("utf-8", errors="strict") + stderr = buffers["stderr"].decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise VMExecutorError("Container output is not valid UTF-8") from exc if failure is not None: raise VMExecutorError(failure) if return_code != 0: diff --git a/tests/unit/test_vm_executor.py b/tests/unit/test_vm_executor.py index da5673e..c6bf1eb 100644 --- a/tests/unit/test_vm_executor.py +++ b/tests/unit/test_vm_executor.py @@ -1,7 +1,10 @@ """Fail-closed policy tests for the isolated runtime comparator.""" +import hashlib import json +import os from pathlib import Path +from typing import Any from unittest.mock import Mock, patch import pytest @@ -15,25 +18,52 @@ _DIGEST = "registry.example/detector@sha256:" + "a" * 64 +@pytest.fixture(autouse=True) +def _clear_vm_policy_environment(monkeypatch: pytest.MonkeyPatch) -> None: + for name in tuple(os.environ): + if name.startswith("FASTAPI_ENDPOINT_DETECTOR_VM_"): + monkeypatch.delenv(name) + + +def _image_inspect(*, digests: object, volumes: object = None) -> str: + return json.dumps([{"RepoDigests": digests, "Config": {"Volumes": volumes}}]) + + def _executor(tmp_path: Path, **kwargs: object) -> VMExecutor: profile = tmp_path / "seccomp.json" profile.write_text('{"defaultAction":"SCMP_ACT_ERRNO"}', encoding="utf-8") + profile_hash = "sha256:" + hashlib.sha256(profile.read_bytes()).hexdigest() return VMExecutor( image=_DIGEST, seccomp_profile=profile, + seccomp_hash=profile_hash, dependency_lock_hash="sha256:" + "b" * 64, + snapshot_lock_hash="sha256:" + "d" * 64, sbom_hash="sha256:" + "c" * 64, **kwargs, # type: ignore[arg-type] ) -def test_policy_rejects_default_runtime_and_network() -> None: - with pytest.raises(ValueError, match="gVisor/Kata"): - SandboxPolicy(runtime="runc") +def test_policy_rejects_unsupported_runtime_unbounded_limits_root_and_network() -> None: + for runtime in ("runc", "custom-runtime", ""): + with pytest.raises(ValueError, match="gVisor/Kata"): + SandboxPolicy(runtime=runtime) + with pytest.raises(ValueError, match="memory limit"): + SandboxPolicy(memory_limit="unlimited", memory_swap="unlimited") + with pytest.raises(ValueError, match="non-root"): + SandboxPolicy(user="0:0") with pytest.raises(ValueError, match="network"): VMExecutor(network_disabled=False) +def test_image_reference_rejects_docker_option_injection_and_mutable_attestation() -> None: + for image in ("--privileged@sha256:" + "a" * 64, "detector name:tag", "../detector:tag"): + with pytest.raises(VMExecutorError, match="invalid Docker reference"): + VMExecutor(image=image) + with pytest.raises(VMExecutorError, match="immutable"): + VMExecutor._validate_image_reference("detector:tag", immutable=True) + + def test_default_policy_is_bounded() -> None: executor = VMExecutor() @@ -47,25 +77,59 @@ def test_default_policy_is_bounded() -> None: @patch("subprocess.run") def test_mutable_image_resolves_to_one_repository_digest(mock_run: Mock) -> None: - mock_run.return_value = Mock(returncode=0, stdout=json.dumps([_DIGEST]), stderr="") + mock_run.return_value = Mock( + returncode=0, + stdout=_image_inspect(digests=[_DIGEST]), + stderr="", + ) executor = VMExecutor(image="registry.example/detector:comparison") assert executor.check_image_exists() is True assert executor._resolve_image() == _DIGEST assert mock_run.call_count == 1 + assert mock_run.call_args.args[0] == [ + "docker", + "image", + "inspect", + "registry.example/detector:comparison", + ] + + +@patch("subprocess.run") +def test_inspected_image_digest_cannot_become_a_docker_option(mock_run: Mock) -> None: + mock_run.return_value = Mock( + returncode=0, + stdout=_image_inspect(digests=["--privileged@sha256:" + "a" * 64]), + stderr="", + ) + + with pytest.raises(VMExecutorError, match="invalid Docker reference"): + VMExecutor(image="detector:tag")._resolve_image() @patch("subprocess.run") def test_ambiguous_or_missing_image_digest_fails_closed(mock_run: Mock) -> None: mock_run.return_value = Mock( returncode=0, - stdout=json.dumps([_DIGEST, "other.example/detector@sha256:" + "b" * 64]), + stdout=_image_inspect(digests=[_DIGEST, "other.example/detector@sha256:" + "b" * 64]), stderr="", ) assert VMExecutor(image="detector:tag").check_image_exists() is False +@patch("subprocess.run") +def test_image_declared_volumes_are_rejected(mock_run: Mock) -> None: + mock_run.return_value = Mock( + returncode=0, + stdout=_image_inspect(digests=[_DIGEST], volumes={"/host-consuming-data": {}}), + stderr="", + ) + + with pytest.raises(VMExecutorError, match="must not declare writable volumes"): + VMExecutor(image=_DIGEST)._resolve_image() + + @patch("subprocess.run") def test_build_image_uses_argv_and_disables_base_pull( mock_run: Mock, @@ -82,7 +146,11 @@ def test_build_image_uses_argv_and_disables_base_pull( assert isinstance(command, list) -def test_container_command_has_complete_hardening_and_narrow_mounts(tmp_path: Path) -> None: +def test_container_command_has_complete_hardening_and_narrow_mounts( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "HOST-SECRET") app = tmp_path / "app" app.mkdir() diff = tmp_path / "change.diff" @@ -100,36 +168,198 @@ def test_container_command_has_complete_hardening_and_narrow_mounts(tmp_path: Pa "comparison-id", ) - assert command[0:2] == ["docker", "run"] - for pair in ( - ("--runtime", "runsc"), - ("--network", "none"), - ("--user", "65532:65532"), - ("--cap-drop", "ALL"), - ("--pids-limit", "128"), - ("--memory", "512m"), - ("--memory-swap", "512m"), - ): - index = command.index(pair[0]) - assert command[index + 1] == pair[1] - assert "--read-only" in command - assert "no-new-privileges=true" in command - assert any(item.startswith("seccomp=") for item in command) - assert "/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777" in command - mounts = [command[index + 1] for index, item in enumerate(command) if item == "--mount"] - assert mounts == [ - f"type=bind,src={app},dst=/workspace/app,readonly,bind-nonrecursive", - f"type=bind,src={diff},dst=/workspace/change.diff,readonly,bind-nonrecursive", + assert command == [ + "docker", + "run", + "--pull", + "never", + "--name", + "comparison-id", + "--cidfile", + str(cidfile), + "--runtime", + "runsc", + "--network", + "none", + "--ipc", + "none", + "--pid", + "private", + "--log-driver", + "none", + "--read-only", + "--user", + "65532:65532", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges=true", + "--security-opt", + f"seccomp={tmp_path / 'seccomp.json'}", + "--memory", + "512m", + "--memory-swap", + "512m", + "--cpu-period", + "100000", + "--cpu-quota", + "50000", + "--pids-limit", + "128", + "--ulimit", + "nofile=256:256", + "--ulimit", + "nproc=128:128", + "--ulimit", + "fsize=16384:16384", + "--ulimit", + "core=0:0", + "--tmpfs", + "/tmp:rw,noexec,nosuid,nodev,size=64m,mode=1777", + "--mount", + f"type=bind,src={app},dst=/workspace/app,readonly,bind-recursive=disabled", + "--entrypoint", + "/usr/bin/env", + "--mount", + f"type=bind,src={diff},dst=/workspace/change.diff,readonly,bind-recursive=disabled", + _DIGEST, + "-i", + "HOME=/tmp/home", + "LANG=C.UTF-8", + "LC_ALL=C.UTF-8", + "PATH=/usr/local/bin:/usr/bin:/bin", + "PYTHONDONTWRITEBYTECODE=1", + "PYTHONHASHSEED=0", + "PYTHONNOUSERSITE=1", + "TMPDIR=/tmp", + "fastapi-endpoint-detector", + "analyze", + "--app", + "/workspace/app", + "--diff", + "/workspace/change.diff", + "--format", + "json", + "--app-var", + "app", ] - assert _DIGEST in command - assert "--device" not in command - assert "--privileged" not in command - assert "--env" not in command - entrypoint = command.index("--entrypoint") - assert command[entrypoint + 1] == "/usr/bin/env" - image = command.index(_DIGEST) - assert command[image + 1] == "-i" - assert "fastapi-endpoint-detector" in command[image + 2 :] + assert all("HOST-SECRET" not in item for item in command) + + +def test_runtime_launch_rejects_mutated_seccomp_and_missing_snapshot_attestation( + tmp_path: Path, +) -> None: + app = tmp_path / "app" + app.mkdir() + executor = _executor(tmp_path) + executor._resolved_image = _DIGEST + executor.seccomp_profile.write_text('{"defaultAction":"SCMP_ACT_ALLOW"}', encoding="utf-8") + + with pytest.raises(VMExecutorError, match="immutable attestation"): + executor._container_command(app, None, "app", "json", tmp_path / "cid", "name") + + profile = tmp_path / "other-seccomp.json" + profile.write_text('{"defaultAction":"SCMP_ACT_ERRNO"}', encoding="utf-8") + profile_hash = "sha256:" + hashlib.sha256(profile.read_bytes()).hexdigest() + missing_snapshot = VMExecutor( + image=_DIGEST, + seccomp_profile=profile, + seccomp_hash=profile_hash, + dependency_lock_hash="sha256:" + "b" * 64, + sbom_hash="sha256:" + "c" * 64, + ) + missing_snapshot._resolved_image = _DIGEST + with pytest.raises(VMExecutorError, match="snapshot lock"): + missing_snapshot._container_command(app, None, "app", "json", tmp_path / "cid", "name") + + +def test_runtime_mounts_reject_symlinks_broad_roots_and_special_files(tmp_path: Path) -> None: + app = tmp_path / "app" + app.mkdir() + alias = tmp_path / "alias" + alias.symlink_to(app, target_is_directory=True) + fifo = tmp_path / "change.diff" + os.mkfifo(fifo) + executor = _executor(tmp_path) + executor._resolved_image = _DIGEST + + with pytest.raises(VMExecutorError, match="symlinks"): + executor._container_command(alias, None, "app", "json", tmp_path / "cid", "name") + with pytest.raises(VMExecutorError, match="broad"): + executor._container_command(Path("/"), None, "app", "json", tmp_path / "cid", "name") + nested_fifo = app / "host.pipe" + os.mkfifo(nested_fifo) + with pytest.raises(VMExecutorError, match="special file"): + executor._container_command(app, None, "app", "json", tmp_path / "cid", "name") + nested_fifo.unlink() + with pytest.raises(VMExecutorError, match="regular file"): + executor._container_command(app, fifo, "app", "json", tmp_path / "cid", "name") + + +def test_runtime_mounts_reject_docker_grammar_metacharacters(tmp_path: Path) -> None: + executor = _executor(tmp_path) + executor._resolved_image = _DIGEST + + for unsafe_name in ("source,src=", "source\rfield", "source\nfield"): + app = tmp_path / unsafe_name + app.mkdir() + with pytest.raises(VMExecutorError, match="mount grammar metacharacter"): + executor._container_command(app, None, "app", "json", tmp_path / "cid", "name") + + diff = tmp_path / f"{unsafe_name}.diff" + diff.write_text("", encoding="utf-8") + safe_app = tmp_path / f"safe-{len(unsafe_name)}" + safe_app.mkdir(exist_ok=True) + with pytest.raises(VMExecutorError, match="mount grammar metacharacter"): + executor._container_command( + safe_app, + diff, + "app", + "json", + tmp_path / "cid", + "name", + ) + + with pytest.raises(VMExecutorError, match="mount grammar metacharacter"): + executor._mount(tmp_path, "/workspace/app,dst=/host") + + +def test_runtime_mount_scan_fails_closed_on_unreadable_subtree( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = tmp_path / "app" + blocked = app / "blocked" + blocked.mkdir(parents=True) + os.mkfifo(blocked / "host.pipe") + executor = _executor(tmp_path) + executor._resolved_image = _DIGEST + real_scandir = os.scandir + + def guarded_scandir(path: os.PathLike[str] | str) -> Any: + if Path(path) == blocked: + raise PermissionError("mocked unreadable subtree") + return real_scandir(path) + + monkeypatch.setattr(os, "scandir", guarded_scandir) + + with pytest.raises(VMExecutorError, match="mocked unreadable subtree"): + executor._container_command(app, None, "app", "json", tmp_path / "cid", "name") + + +def test_runtime_mount_tree_scan_is_bounded( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + app = tmp_path / "app" + app.mkdir() + (app / "main.py").write_text("app = None\n", encoding="utf-8") + executor = _executor(tmp_path) + executor._resolved_image = _DIGEST + monkeypatch.setattr(VMExecutor, "_MOUNT_SCAN_LIMIT", 0) + + with pytest.raises(VMExecutorError, match="bounded mount scan"): + executor._container_command(app, None, "app", "json", tmp_path / "cid", "name") def test_runtime_launch_requires_lock_and_sbom_attestations(tmp_path: Path) -> None: @@ -137,9 +367,13 @@ def test_runtime_launch_requires_lock_and_sbom_attestations(tmp_path: Path) -> N profile.write_text('{"defaultAction":"SCMP_ACT_ERRNO"}', encoding="utf-8") app = tmp_path / "app" app.mkdir() + profile_hash = "sha256:" + hashlib.sha256(profile.read_bytes()).hexdigest() executor = VMExecutor( image=_DIGEST, seccomp_profile=profile, + seccomp_hash=profile_hash, + snapshot_lock_hash="sha256:" + "d" * 64, + sbom_hash="sha256:" + "c" * 64, ) executor._resolved_image = _DIGEST @@ -181,6 +415,7 @@ def test_policy_provenance_attests_digest_seccomp_and_environment(tmp_path: Path assert provenance["seccomp_sha256"].startswith("sha256:") assert provenance["policy_sha256"].startswith("sha256:") assert provenance["dependency_lock_hash"] == "sha256:" + "b" * 64 + assert provenance["snapshot_lock_hash"] == "sha256:" + "d" * 64 assert provenance["sbom_hash"] == "sha256:" + "c" * 64 assert set(provenance["environment"]) == set(VMExecutor.CLEAN_ENV) @@ -230,44 +465,113 @@ def test_invalid_json_and_endpoint_payload_fail_closed(tmp_path: Path) -> None: @patch("subprocess.run") -def test_cleanup_prefers_cid_and_always_kills_then_removes( +def test_cleanup_proves_generated_name_and_cid_are_absent( mock_run: Mock, tmp_path: Path, ) -> None: cidfile = tmp_path / "cid" - cidfile.write_text("abc123\n", encoding="utf-8") - + cid = "a" * 64 + cidfile.write_text(cid + "\n", encoding="utf-8") mock_run.side_effect = [ Mock(returncode=0), Mock(returncode=0), - Mock(returncode=1), + Mock(returncode=0, stdout="", stderr=""), + Mock(returncode=0, stdout="\n", stderr=""), ] VMExecutor._cleanup_container(cidfile, "fallback-name") - commands = [call.args[0] for call in mock_run.call_args_list] - assert commands == [ - ["docker", "kill", "abc123"], - ["docker", "rm", "--force", "abc123"], - ["docker", "container", "inspect", "abc123"], + assert [call.args[0] for call in mock_run.call_args_list] == [ + ["docker", "kill", cid], + ["docker", "rm", "--force", cid], + [ + "docker", + "container", + "ls", + "--all", + "--no-trunc", + "--quiet", + "--filter", + "name=^/fallback-name$", + ], + [ + "docker", + "container", + "ls", + "--all", + "--no-trunc", + "--quiet", + "--filter", + f"id={cid}", + ], ] + for call in mock_run.call_args_list[2:]: + assert call.kwargs["timeout"] == 10 + assert call.kwargs["text"] is True @patch("subprocess.run") -def test_cleanup_verification_fails_if_container_remains( +def test_cleanup_rejects_invalid_cid_but_removes_by_safe_name( mock_run: Mock, tmp_path: Path, ) -> None: + cidfile = tmp_path / "cid" + cidfile.write_text("--all\n", encoding="utf-8") mock_run.side_effect = [ Mock(returncode=0), + Mock(returncode=0), + Mock(returncode=0, stdout="", stderr=""), + ] + + with pytest.raises(VMExecutorError, match="invalid container identifier"): + VMExecutor._cleanup_container(cidfile, "safe-container") + + assert [call.args[0] for call in mock_run.call_args_list] == [ + ["docker", "kill", "safe-container"], + ["docker", "rm", "--force", "safe-container"], + [ + "docker", + "container", + "ls", + "--all", + "--no-trunc", + "--quiet", + "--filter", + "name=^/safe-container$", + ], + ] + + +@patch("subprocess.run") +def test_cleanup_verification_fails_if_container_remains( + mock_run: Mock, + tmp_path: Path, +) -> None: + mock_run.side_effect = [ Mock(returncode=0), Mock(returncode=0), + Mock(returncode=0, stdout="a" * 64 + "\n", stderr=""), ] with pytest.raises(VMExecutorError, match="still exists"): VMExecutor._cleanup_container(tmp_path / "missing-cid", "container-name") +@patch("subprocess.run") +def test_cleanup_verification_treats_daemon_errors_as_failures( + mock_run: Mock, + tmp_path: Path, +) -> None: + mock_run.side_effect = [ + Mock(returncode=0), + Mock(returncode=0), + Mock(returncode=1, stdout="", stderr="permission denied by daemon"), + ] + + with pytest.raises(VMExecutorError, match=r"query.*failed.*permission denied"): + VMExecutor._cleanup_container(tmp_path / "missing-cid", "container-name") + + @patch("subprocess.Popen") @patch.object(VMExecutor, "_cleanup_container") def test_launch_failure_is_structured( @@ -288,6 +592,7 @@ def test_seccomp_profile_is_packaged_and_deny_by_default() -> None: executor = VMExecutor() payload = json.loads(executor.seccomp_profile.read_text(encoding="utf-8")) + assert executor._verified_seccomp_hash() == VMExecutor.PACKAGED_SECCOMP_SHA256 assert payload["defaultAction"] == "SCMP_ACT_ERRNO" assert payload["syscalls"][0]["action"] == "SCMP_ACT_ALLOW" assert "mount" not in payload["syscalls"][0]["names"]