From 3d1fc81e3d422d8d49df26dbd13e0c2343c86edb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 20:49:57 +0000 Subject: [PATCH] Deny filesystem access by default in the process backend A process sandbox whose policy named no filesystem paths received no filesystem confinement at all. confine._apply_landlock returned early before building a ruleset, so the guest kept full read access to the host and could write anywhere: sb = iso.spawn("p", backend="process", allowed_imports=["os"]) sb.exec("post(open('/etc/passwd').read())") # -> contents sb.exec("open('/tmp/owned','w').write('x')") # -> succeeds while the same report said seccomp was active, so the sandbox looked confined. This contradicted the deny-by-default posture the import allow-list already takes (DEFAULT_ALLOWED_IMPORTS is deliberately empty "so missing import policy fails closed"). The early return also meant require_landlock was never consulted on that path, so hardened rollout mode silently produced an unconfined guest on a kernel without Landlock -- the one mode whose whole purpose is to refuse. Always handle the filesystem access class. Landlock is default-deny for every right its ruleset handles, and _runtime_read_paths() already grants the interpreter what it needs to keep running, so the fix is to stop skipping the ruleset rather than to build new machinery: the guest keeps its stdlib and shared libraries, and home directories, /root, /var and all writes are denied by the kernel. The network layer is deliberately left as it was. Landlock keys network rules on port and is default-deny for ports it does not name, so handling that class with no allow-list would sever egress the policy never meant to restrict. /etc stays readable: the loader cache, TLS trust store and locale data live there. Narrowing it to specific entries is worth doing separately. Reported as landlock_default_deny_fs in the confinement report, so "confined to policy" and "confined to the bare minimum" are distinguishable by callers and by CI. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012ebvMQ3vLxdK3joymz6Feg --- SECURITY.md | 8 +- docs/threat-model.md | 15 +- pyisolate/nogil.py | 8 +- pyisolate/runtime/child.py | 2 + pyisolate/runtime/confine.py | 24 +++- pyisolate/runtime/landlock.py | 27 +++- pyisolate/runtime/process_backend.py | 2 + tests/test_landlock.py | 203 ++++++++++++++++++++++++++- 8 files changed, 271 insertions(+), 18 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4131a87..a90b977 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,7 +47,13 @@ silently. `process_vm_readv`/`writev`, and others. x86-64 Linux. This is a robust deny-list, **not** a proof that only a fixed syscall allow-list is reachable. 3. **Filesystem policy** — Landlock confines the guest to the policy's read/write - paths, on kernels that support Landlock. + paths, on kernels that support Landlock. A policy that names **no** filesystem + paths is deny-by-default, not unrestricted: the guest is confined to the + interpreter's own runtime paths (so it can still import) and nothing else — + home directories, `/root`, `/var`, and all writes are denied by the kernel. + `/etc` remains readable, because the loader, TLS trust store, and locale data + live there. On a kernel without Landlock the layer is recorded as skipped, and + hardened rollout mode fails closed rather than running unconfined. 4. **Network-egress policy** — On Landlock ABI >= 4 (Linux 6.7+) the policy's TCP allow-list is mapped to allowed `connect()` ports and the kernel denies egress to every other port. Landlock keys network rules on port, not address, so this diff --git a/docs/threat-model.md b/docs/threat-model.md index cfac943..bddeb72 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -29,8 +29,10 @@ answer below as conditional on the backend you select. dangerous syscalls (`execve`, `ptrace`, mount/namespace ops, `bpf`, kernel module load, `process_vm_*`, …) — x86-64 Linux; - **Landlock** filesystem rules derived from policy, where the kernel supports - it, plus **Landlock TCP-egress** rules that allow-list the connect ports in - the policy (Landlock ABI >= 4 / Linux 6.7+; keyed on port, not address); + it — deny-by-default when the policy names no paths, confining the guest to + the interpreter's own runtime paths — plus **Landlock TCP-egress** rules that + allow-list the connect ports in the policy (Landlock ABI >= 4 / Linux 6.7+; + keyed on port, not address); - a **coarse per-cgroup eBPF/LSM deny-mask** (deny whole capability classes), where BPF-LSM is available; - `rlimit` and cgroup resource caps. @@ -206,6 +208,15 @@ Any semantic change to defended/not-defended status requires: ### History +- **2026-08-15** — Made the `backend="process"` filesystem layer deny-by-default. + A sandbox whose policy named no filesystem paths previously received no + Landlock confinement at all — the guest could read any host file and write + anywhere — and because that path returned before consulting `require_landlock`, + hardened rollout mode could not fail closed on it either. The filesystem access + class is now always handled: the interpreter's runtime paths are granted so the + guest still runs, everything else is denied, and a missing Landlock layer is + recorded (and fatal under hardened mode). Reported as + `landlock_default_deny_fs` in the confinement report. - **2026-07-21** — Wired capability-gated broker mediation into `backend="process"`: the guest's `request` cell op is denied unless the named capability was granted, and a permitted request crosses the boundary as a diff --git a/pyisolate/nogil.py b/pyisolate/nogil.py index 343dc5b..2bdf61a 100644 --- a/pyisolate/nogil.py +++ b/pyisolate/nogil.py @@ -89,9 +89,11 @@ def imported_native_extensions() -> list[dict[str, Any]]: ), "no_gil_safe": marked_safe, "status": "declared-safe" if marked_safe else "unknown", - "reason": "declared in PYISOLATE_NOGIL_SAFE_MODULES" - if marked_safe - else "native extension has no PyIsolate no-GIL safety declaration", + "reason": ( + "declared in PYISOLATE_NOGIL_SAFE_MODULES" + if marked_safe + else "native extension has no PyIsolate no-GIL safety declaration" + ), } ) return records diff --git a/pyisolate/runtime/child.py b/pyisolate/runtime/child.py index 51b4436..b11a4d4 100644 --- a/pyisolate/runtime/child.py +++ b/pyisolate/runtime/child.py @@ -232,6 +232,7 @@ def _serve(sock: socket.socket) -> None: net_connect_ports=_net_connect_ports(bootstrap.get("tcp")), require_seccomp=bool(bootstrap.get("require_seccomp", False)), require_landlock=bool(bootstrap.get("require_landlock", False)), + default_deny_fs=bool(bootstrap.get("default_deny_fs", True)), ) _send_frame( sock, @@ -242,6 +243,7 @@ def _serve(sock: socket.socket) -> None: "rlimits": report.rlimits, "landlock": report.landlock, "landlock_rules": report.landlock_rules, + "landlock_default_deny_fs": report.landlock_default_deny_fs, "landlock_net": report.landlock_net, "landlock_net_ports": report.landlock_net_ports, "skipped": report.skipped, diff --git a/pyisolate/runtime/confine.py b/pyisolate/runtime/confine.py index 47a0263..5b457ab 100644 --- a/pyisolate/runtime/confine.py +++ b/pyisolate/runtime/confine.py @@ -118,6 +118,9 @@ class ConfinementReport: rlimits: list[str] = field(default_factory=list) landlock: bool = False landlock_rules: int = 0 + # True when the guest is confined to the interpreter's runtime paths only, + # because the policy named no filesystem paths of its own. + landlock_default_deny_fs: bool = False landlock_net: bool = False landlock_net_ports: int = 0 skipped: list[str] = field(default_factory=list) @@ -224,22 +227,31 @@ def _apply_landlock( fs_write: list[str] | None, net_connect_ports: list[int] | None, require_landlock: bool, + default_deny_fs: bool = True, ) -> None: - # Only handle an access class when the policy actually names an allow-list; - # without one a default-deny ruleset would break the interpreter (FS) or - # sever egress the policy meant to permit (network). - if not fs_read and not fs_write and not net_connect_ports: + """Apply the Landlock filesystem and TCP-egress layers to this process. + + The filesystem layer is applied even when the policy names no paths: the + guest is then confined to the interpreter's own runtime paths, which is the + deny-by-default posture the import allow-list already takes. The network + layer is only handled when the policy names ports, because Landlock's + network rules are default-deny per port and handling that class with no + allow-list would sever egress the policy never meant to restrict. + """ + if not fs_read and not fs_write and not default_deny_fs and not net_connect_ports: return landlock_report = _landlock.apply_landlock( fs_read, fs_write, connect_ports=net_connect_ports, require=require_landlock, + default_deny_fs=default_deny_fs, ) if landlock_report.applied: report.landlock = True report.landlock_rules = landlock_report.rules - elif fs_read or fs_write: + report.landlock_default_deny_fs = landlock_report.default_deny_fs + elif fs_read or fs_write or default_deny_fs: report.skipped.append(f"landlock:{landlock_report.skipped}") if landlock_report.net_applied: report.landlock_net = True @@ -259,6 +271,7 @@ def apply_confinement( seccomp: bool = True, require_seccomp: bool = False, require_landlock: bool = False, + default_deny_fs: bool = True, ) -> ConfinementReport: """Confine the *current* process before it runs guest code. @@ -278,6 +291,7 @@ def apply_confinement( fs_write=fs_write, net_connect_ports=net_connect_ports, require_landlock=require_landlock, + default_deny_fs=default_deny_fs, ) if not seccomp: diff --git a/pyisolate/runtime/landlock.py b/pyisolate/runtime/landlock.py index cc4a390..6421d31 100644 --- a/pyisolate/runtime/landlock.py +++ b/pyisolate/runtime/landlock.py @@ -129,6 +129,10 @@ class LandlockReport: applied: bool = False abi: int = 0 rules: int = 0 + # True when the filesystem layer was applied without any policy paths, i.e. + # the guest is confined to the interpreter's own runtime paths and nothing + # else. Distinguishes "confined to policy" from "confined to bare minimum". + default_deny_fs: bool = False skipped: str | None = None denied_paths: list[str] = field(default_factory=list) net_applied: bool = False @@ -343,6 +347,7 @@ def apply_landlock( *, connect_ports: list[int] | None = None, require: bool = False, + default_deny_fs: bool = False, ) -> LandlockReport: """Restrict the current process's filesystem and TCP-egress access to policy. @@ -350,9 +355,18 @@ def apply_landlock( interpreter's runtime paths are granted read+execute so it can keep running. ``connect_ports`` (Landlock ABI >= 4, Linux 6.7+) allow-lists the TCP ports the guest may ``connect()`` to; every other port is denied by the kernel. - Both layers share a single ruleset. A layer whose allow-list is empty/None - is not handled at all, so a default-deny ruleset never breaks the - interpreter or blocks egress the policy did not mean to restrict. + Both layers share a single ruleset. + + ``default_deny_fs`` handles the filesystem access classes even when the + policy names no paths at all. The interpreter's own runtime paths are still + granted, so the guest keeps working, but everything else -- home + directories, ``/root``, ``/var``, and *all* writes -- is denied by the + kernel. Without it, a policy-free sandbox gets no filesystem confinement + whatsoever. + + The network layer is never defaulted on: Landlock keys network rules on + port and is default-deny for ports it does not name, so handling that class + with no allow-list would sever egress the policy never meant to restrict. On a kernel without Landlock this is a no-op unless ``require`` is set, in which case it raises. When network confinement is requested but the kernel's @@ -368,7 +382,7 @@ def apply_landlock( report.skipped = "unsupported" return report - handle_fs = bool(read_paths or write_paths) + handle_fs = bool(read_paths or write_paths or default_deny_fs) want_net = connect_ports is not None handle_net = want_net and abi >= _NET_ABI if want_net and not handle_net: @@ -380,8 +394,8 @@ def apply_landlock( report.net_skipped = f"net_unsupported_abi:{abi}" if not handle_fs and not handle_net: - # Nothing to restrict. A ruleset that handled an access class with no - # allow-list rules would be default-deny and break the guest. + # Nothing to restrict. Reachable only when the caller opted out of the + # filesystem default-deny and named no network ports. report.skipped = "no_rules" return report @@ -426,5 +440,6 @@ def apply_landlock( os.close(ruleset_fd) report.applied = handle_fs + report.default_deny_fs = handle_fs and not (read_paths or write_paths) report.net_applied = handle_net return report diff --git a/pyisolate/runtime/process_backend.py b/pyisolate/runtime/process_backend.py index b1e1c49..4089ebe 100644 --- a/pyisolate/runtime/process_backend.py +++ b/pyisolate/runtime/process_backend.py @@ -136,6 +136,7 @@ def __init__( confine: bool = True, require_seccomp: bool = False, require_landlock: bool = False, + default_deny_fs: bool = True, ) -> None: self.name = name self._backend = backend @@ -198,6 +199,7 @@ def __init__( "cpu_seconds": cpu_seconds, "require_seccomp": require_seccomp, "require_landlock": require_landlock, + "default_deny_fs": default_deny_fs, } ) diff --git a/tests/test_landlock.py b/tests/test_landlock.py index c4d99eb..a009844 100644 --- a/tests/test_landlock.py +++ b/tests/test_landlock.py @@ -17,7 +17,7 @@ import pytest import pyisolate as iso -from pyisolate.runtime import landlock +from pyisolate.runtime import confine, landlock from pyisolate.runtime.child import _net_connect_ports from pyisolate.runtime.process_backend import ( _extract_fs_read_write, @@ -286,3 +286,204 @@ def test_landlock_blocks_disallowed_reads_but_allows_permitted(tmp_path): sb.exec(_REAL_READ.format(path=str(secret))) with pytest.raises(iso.SandboxError): sb.recv(timeout=5) + + +# -- filesystem default-deny ------------------------------------------------ +# +# A sandbox whose policy names no filesystem paths used to get no filesystem +# confinement at all: confine._apply_landlock returned early, so the guest kept +# full read access to the host (/root, $HOME, /var) and could write anywhere. +# That contradicted the deny-by-default posture the import allow-list already +# takes, and it also meant hardened rollout mode could not fail closed, because +# require_landlock was never consulted on that path. + + +def test_default_deny_handles_the_fs_class_without_policy_paths(): + # The decision that matters: with no policy paths and default_deny_fs on, + # the filesystem access class is still handled, so Landlock denies + # everything outside the interpreter's own runtime paths. + if landlock.landlock_supported(): + pytest.skip("kernel supports Landlock; would confine the test process") + report = landlock.apply_landlock(None, None, default_deny_fs=True) + # Unsupported kernel, so nothing is applied -- but the attempt was made and + # recorded, rather than skipped as "no_rules" before reaching the kernel. + assert report.skipped == "unsupported" + + +def test_opting_out_of_default_deny_skips_the_fs_class(): + if landlock.landlock_supported(): + pytest.skip("kernel supports Landlock; would confine the test process") + report = landlock.apply_landlock(None, None, default_deny_fs=False) + assert report.skipped == "unsupported" + + +def test_no_rules_is_only_reachable_with_default_deny_off(monkeypatch): + # Force the "kernel supports Landlock" branch without touching the real + # kernel, to prove which combination reaches the no-op path. + monkeypatch.setattr(landlock, "abi_version", lambda: 1) + report = landlock.apply_landlock(None, None, default_deny_fs=False) + assert report.skipped == "no_rules" + assert report.applied is False + + +def test_hardened_mode_fails_closed_without_policy_paths(): + # The regression this guards: require_landlock=True with no policy paths + # used to return silently, leaving a "hardened" guest with unrestricted + # filesystem access on a kernel that cannot enforce Landlock. + if landlock.landlock_supported(): + pytest.skip("kernel supports Landlock; would confine the test process") + report = confine.ConfinementReport() + with pytest.raises((RuntimeError, OSError)): + confine._apply_landlock( + report, + fs_read=None, + fs_write=None, + net_connect_ports=None, + require_landlock=True, + ) + + +def test_missing_landlock_is_recorded_even_without_policy_paths(): + # Best-effort mode must still say the layer is absent. Previously the report + # was silent, so a no-policy sandbox looked confined when it was not. + if landlock.landlock_supported(): + pytest.skip("kernel supports Landlock; would confine the test process") + report = confine.ConfinementReport() + confine._apply_landlock( + report, + fs_read=None, + fs_write=None, + net_connect_ports=None, + require_landlock=False, + ) + assert any(item.startswith("landlock:") for item in report.skipped) + + +@requires_landlock +def test_policy_free_sandbox_reports_default_deny_filesystem(): + with iso.spawn("ll-default-deny", backend="process") as sb: + report = sb._thread.wait_confined(timeout=5) + assert report is not None + assert report["landlock"] is True + assert report["landlock_default_deny_fs"] is True + # The interpreter's own runtime paths are still granted, or the guest + # could not import anything. + assert report["landlock_rules"] >= 1 + + +@requires_landlock +def test_sandbox_with_policy_paths_is_not_flagged_default_deny(tmp_path): + allowed = tmp_path / "allowed" + allowed.mkdir() + policy = iso.policy.Policy().allow_fs(str(allowed)) + with iso.spawn("ll-policy-deny", policy=policy, backend="process") as sb: + report = sb._thread.wait_confined(timeout=5) + assert report["landlock"] is True + assert report["landlock_default_deny_fs"] is False + + +@live_landlock +def test_policy_free_sandbox_cannot_read_outside_the_interpreter(tmp_path): + # The actual hole: with no policy, guest code that bypasses the Python open + # guard used to read any host file. The interpreter's runtime paths stay + # readable so the guest still works; a file outside them does not. + secret = tmp_path / "secret.txt" + secret.write_text("secret", encoding="utf-8") + with iso.spawn("ll-default-read", backend="process") as sb: + sb.exec(_REAL_READ.format(path=str(secret))) + with pytest.raises(iso.SandboxError): + sb.recv(timeout=5) + + +@live_landlock +def test_policy_free_sandbox_can_still_import_the_stdlib(): + # Default-deny is only correct if it does not break the interpreter. + with iso.spawn( + "ll-default-import", allowed_imports=["json"], backend="process" + ) as sb: + sb.exec("import json; post(json.dumps({'ok': True}))") + assert sb.recv(timeout=5) == '{"ok": true}' + + +@live_landlock +def test_policy_free_sandbox_cannot_write_outside_the_interpreter(tmp_path): + target = tmp_path / "written.txt" + write_src = """ +def _real_open(path, mode): + for cls in ().__class__.__base__.__subclasses__(): + if cls.__name__ == "catch_warnings": + return cls()._module.__builtins__["open"](path, mode) + raise RuntimeError("no real open") +handle = _real_open({path!r}, "w") +handle.write("owned") +handle.close() +post("WROTE") +""" + with iso.spawn("ll-default-write", backend="process") as sb: + sb.exec(write_src.format(path=str(target))) + with pytest.raises(iso.SandboxError): + sb.recv(timeout=5) + assert not target.exists() + + +class _FakeLibc: + """Records the Landlock syscalls a real kernel would receive. + + Lets the default-deny ruleset construction be verified on hosts without + Landlock (including CI runners on older kernels), where the live tests + above can only skip. + """ + + def __init__(self): + self.created_attrs = [] + self.path_rules = 0 + self.restricted = False + + def syscall(self, number, *args): + if number == landlock._NR_LANDLOCK_CREATE_RULESET: + attr_ref = args[0] + self.created_attrs.append(attr_ref._obj.handled_access_fs) + # A real fd, because apply_landlock closes it. + return os.open(os.devnull, os.O_RDONLY) + if number == landlock._NR_LANDLOCK_ADD_RULE: + if args[1] == landlock._LANDLOCK_RULE_PATH_BENEATH: + self.path_rules += 1 + return 0 + if number == landlock._NR_LANDLOCK_RESTRICT_SELF: + self.restricted = True + return 0 + raise AssertionError(f"unexpected syscall {number}") + + +def test_default_deny_builds_a_ruleset_granting_only_runtime_paths(monkeypatch): + fake = _FakeLibc() + monkeypatch.setattr(landlock, "abi_version", lambda: 1) + monkeypatch.setattr(landlock, "_libc", lambda: fake) + + report = landlock.apply_landlock(None, None, default_deny_fs=True) + + # The filesystem access class is handled, which is what makes Landlock + # deny every path not added as a rule. + assert fake.created_attrs and fake.created_attrs[0] != 0 + # The interpreter's own runtime paths are granted, so the guest still runs. + assert fake.path_rules == len(landlock._runtime_read_paths()) + assert fake.restricted is True + assert report.applied is True + assert report.default_deny_fs is True + assert report.rules == fake.path_rules + + +def test_policy_paths_are_added_on_top_of_runtime_paths(monkeypatch, tmp_path): + readable = tmp_path / "r" + writable = tmp_path / "w" + readable.mkdir() + writable.mkdir() + fake = _FakeLibc() + monkeypatch.setattr(landlock, "abi_version", lambda: 1) + monkeypatch.setattr(landlock, "_libc", lambda: fake) + + report = landlock.apply_landlock([str(readable)], [str(writable)]) + + assert fake.path_rules == len(landlock._runtime_read_paths()) + 2 + # A policy was supplied, so this is policy confinement, not the bare default. + assert report.default_deny_fs is False