Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 62 additions & 1 deletion pyisolate/runtime/process_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
72 changes: 72 additions & 0 deletions tests/test_process_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Loading