From 53778f7263d9a7efe683453b2c8e4ca477e84e1d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:36:23 +0000 Subject: [PATCH] Scrub the guest environment for the process backend The process backend launched its guest with subprocess.Popen and no env=, so the child inherited the supervisor's entire os.environ. Any credential held by the host process -- AWS keys, API tokens, session secrets -- was readable by guest code with a plain os.environ.get(), which defeats the point of the boundary regardless of how well seccomp and Landlock confine the rest of the process. Build the child environment from a fixed allow-list instead: the variables the interpreter needs to boot and to resolve pyisolate (PYTHONPATH, PYTHONHOME, LD_LIBRARY_PATH), plus locale and TMPDIR. PATH is set to a fixed minimal value rather than forwarded, since the child is launched by absolute path and the host's PATH only leaks machine layout. LD_LIBRARY_PATH is forwarded deliberately: conda and custom CPython builds need it to find libpython, and dropping it stops the child from starting. Callers that need to hand the guest real configuration can pass env=, which is merged last so an explicit value always wins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ebvMQ3vLxdK3joymz6Feg --- SECURITY.md | 5 ++ pyisolate/runtime/process_backend.py | 63 +++++++++++++++++++++++- tests/test_process_backend.py | 72 ++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 4131a87..58e33c7 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -64,6 +64,11 @@ silently. frames are rejected. 7. **Crash isolation** — A crash in a guest process cannot bring down the supervisor. +8. **Environment scrubbing** — The guest starts from a fixed allow-list of + environment variables (interpreter/module resolution and locale only), never + a copy of the supervisor's `os.environ`. Cloud credentials, API tokens, and + ambient configuration held by the host process are not visible to the guest. + Callers pass anything the guest legitimately needs via `env=`. Resource quotas (CPU/RAM/I/O) are enforced by `rlimit` and cgroup v2 controls where available. Anything not listed above is *not* guaranteed. diff --git a/pyisolate/runtime/process_backend.py b/pyisolate/runtime/process_backend.py index b1e1c49..aedf397 100644 --- a/pyisolate/runtime/process_backend.py +++ b/pyisolate/runtime/process_backend.py @@ -13,18 +13,22 @@ corrupt supervisor memory. Kernel-level confinement of that process (no-new-privs, seccomp, rlimits, Landlock, cgroups) is layered on in follow-up work; this module establishes the process boundary and transport. + +The guest also starts from a scrubbed environment rather than inheriting the +supervisor's ``os.environ`` -- see :func:`build_child_env`. """ from __future__ import annotations import json +import os import queue import socket import struct import subprocess import sys import threading -from typing import Any, Optional +from typing import Any, Mapping, Optional from .. import errors from ..policy.model import RuntimePolicy @@ -37,6 +41,61 @@ # produced by untrusted guest code in the supervisor process. _CHILD_MODULE = "pyisolate.runtime.child" +# The guest process starts from a scrubbed environment, not the supervisor's. +# ``os.environ`` routinely carries cloud credentials, API tokens, and session +# secrets; a guest that can read them has exfiltrated them regardless of how +# well seccomp and Landlock confine the rest of the process. Only variables the +# child interpreter genuinely needs to boot and to resolve ``pyisolate`` itself +# are forwarded. +ENV_PASSTHROUGH: tuple[str, ...] = ( + # Module resolution: a source checkout or a non-standard layout reaches + # ``pyisolate.runtime.child`` through these. + "PYTHONPATH", + "PYTHONHOME", + # Shared-library resolution for the interpreter itself. conda and custom + # CPython builds need this to find libpython; dropping it stops the child + # from starting at all. It names library directories, not secrets. + "LD_LIBRARY_PATH", + # Text encoding: dropping these silently changes the guest's default + # encoding relative to the supervisor. + "LANG", + "LC_ALL", + "LC_CTYPE", + "PYTHONIOENCODING", + "PYTHONUTF8", + # Temporary files: the guest should land in the same place the host chose, + # which may be the only writable directory its policy grants. + "TMPDIR", +) + +# The child is launched by absolute path (``sys.executable``), so ``PATH`` is +# never consulted to start it. Forwarding the supervisor's ``PATH`` would leak +# host layout (home directories, usernames, toolchain locations) for no benefit, +# so the guest gets a fixed minimal value instead. +DEFAULT_CHILD_PATH = "/usr/local/bin:/usr/bin:/bin" + + +def build_child_env( + extra: Optional[Mapping[str, str]] = None, + *, + source: Optional[Mapping[str, str]] = None, +) -> dict[str, str]: + """Build the guest process environment: allow-list, never the host's copy. + + ``extra`` is merged last so a caller can hand the guest configuration it + actually needs. Anything the caller passes is deliberate, so it overrides + the allow-listed values. + """ + env_source = os.environ if source is None else source + env = {"PATH": DEFAULT_CHILD_PATH} + for key in ENV_PASSTHROUGH: + value = env_source.get(key) + if value is not None: + env[key] = value + if extra: + env.update({str(k): str(v) for k, v in extra.items()}) + return env + def _extract_fs_tcp(policy: Any) -> tuple[Optional[list[str]], Optional[list[str]]]: """Best-effort extraction of filesystem/TCP allow-lists from a policy. @@ -136,6 +195,7 @@ def __init__( confine: bool = True, require_seccomp: bool = False, require_landlock: bool = False, + env: Optional[Mapping[str, str]] = None, ) -> None: self.name = name self._backend = backend @@ -173,6 +233,7 @@ def __init__( [sys.executable, "-m", _CHILD_MODULE, str(child_sock.fileno())], pass_fds=(child_sock.fileno(),), close_fds=True, + env=build_child_env(env), ) except Exception: parent_sock.close() diff --git a/tests/test_process_backend.py b/tests/test_process_backend.py index 98d9bb9..84904f5 100644 --- a/tests/test_process_backend.py +++ b/tests/test_process_backend.py @@ -10,6 +10,7 @@ import pytest import pyisolate as iso +from pyisolate.runtime import process_backend # The object-graph escape that fully defeats the sub-interpreter backend: # recover the *real* __import__ from a stdlib module's globals, bypassing the @@ -156,3 +157,74 @@ def test_microvm_backend_remains_unimplemented(): # rather than ever returning a working guest. with pytest.raises((iso.SandboxError, NotImplementedError)): iso.spawn("proc-vm", backend="microvm") + + +# -- guest environment scrubbing ------------------------------------------ + + +def test_guest_does_not_inherit_supervisor_environment(monkeypatch): + # os.environ routinely holds cloud credentials and API tokens. A guest that + # can read them has exfiltrated them no matter how well the kernel confines + # the rest of the process. + monkeypatch.setenv("PYISOLATE_TEST_SECRET", "hunter2-supersecret") + with iso.spawn("proc-env", allowed_imports=["os"], backend="process") as sb: + sb.exec("import os; post(os.environ.get('PYISOLATE_TEST_SECRET'))") + assert sb.recv(timeout=5) is None + + +def test_guest_environment_is_an_allowlist_not_a_denylist(monkeypatch): + # A newly-invented variable must not reach the guest: the child env is built + # from a fixed allow-list, so anything unanticipated is withheld by default. + monkeypatch.setenv("SOME_FUTURE_CREDENTIAL", "leaked") + with iso.spawn("proc-env-allow", allowed_imports=["os"], backend="process") as sb: + sb.exec("import os; post(sorted(os.environ))") + names = sb.recv(timeout=5) + assert "SOME_FUTURE_CREDENTIAL" not in names + assert set(names) <= {"PATH", *process_backend.ENV_PASSTHROUGH} + + +def test_guest_path_is_fixed_not_the_supervisors(monkeypatch): + monkeypatch.setenv("PATH", "/home/someuser/.secret-toolchain/bin") + with iso.spawn("proc-env-path", allowed_imports=["os"], backend="process") as sb: + sb.exec("import os; post(os.environ.get('PATH'))") + assert sb.recv(timeout=5) == process_backend.DEFAULT_CHILD_PATH + + +def test_build_child_env_forwards_only_allowlisted_variables(): + source = { + "AWS_SECRET_ACCESS_KEY": "nope", + "GITHUB_TOKEN": "nope", + "LANG": "en_US.UTF-8", + "PYTHONPATH": "/opt/src", + } + env = process_backend.build_child_env(source=source) + assert env["LANG"] == "en_US.UTF-8" + assert env["PYTHONPATH"] == "/opt/src" + assert env["PATH"] == process_backend.DEFAULT_CHILD_PATH + assert "AWS_SECRET_ACCESS_KEY" not in env + assert "GITHUB_TOKEN" not in env + + +def test_build_child_env_extra_is_deliberate_and_wins(): + # Anything the caller passes explicitly is intentional configuration, so it + # overrides the allow-listed value rather than being dropped. + source = {"LANG": "C"} + env = process_backend.build_child_env( + {"LANG": "en_GB.UTF-8", "APP_MODE": "test"}, source=source + ) + assert env["LANG"] == "en_GB.UTF-8" + assert env["APP_MODE"] == "test" + + +def test_explicit_env_reaches_the_guest(): + proc = process_backend.ProcessSandbox( + "proc-env-extra", + allowed_imports=["os"], + env={"APP_MODE": "production"}, + ) + try: + proc.wait_confined(timeout=5) + proc.exec("import os; post(os.environ.get('APP_MODE'))") + assert proc.recv(timeout=5) == "production" + finally: + proc.stop()