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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ See [docs/execution-model.md](docs/execution-model.md). We keep this model small
* `PR_SET_NO_NEW_PRIVS` + a seccomp deny-list that kills the process on
dangerous syscalls (`execve`, `ptrace`, mount/namespace ops, `bpf`, module
load, `process_vm_*`, ...) - x86-64 Linux;
* **Landlock** filesystem rules from policy, where the kernel supports it;
* **Landlock** filesystem rules from policy, plus **Landlock TCP-egress**
rules (ABI ≥ 4) that deny `connect()` to any port outside the policy's
allow-list, where the kernel supports it;
* a coarse per-cgroup **eBPF/LSM** `deny_mask`, where BPF-LSM is available;
* `rlimit` and cgroup resource caps.
Each kernel layer is best-effort and recorded in the sandbox's confinement
Expand Down
14 changes: 10 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,21 @@ silently.
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.
4. **Coarse capability gating** — A per-cgroup eBPF/LSM `deny_mask` denies whole
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
is a coarse kernel backstop beneath the userspace host:port guard; it is
applied only when every allow-listed destination carries a parseable port.
5. **Coarse capability gating** — A per-cgroup eBPF/LSM `deny_mask` denies whole
capability classes (process creation, ptrace/mount/bpf, and filesystem or
network when the policy grants nothing in that class), on kernels with
BPF-LSM. This is coarse: it cannot express per-path allow-lists — that is
Landlock's job (§2.3) and the broker's.
5. **Broker integrity & replay protection** — Control-plane frames are AEAD-sealed
6. **Broker integrity & replay protection** — Control-plane frames are AEAD-sealed
(X25519 → HKDF-SHA-256 → ChaCha20-Poly1305, optional Kyber-768 hybrid) with a
strictly increasing per-direction counter; forged, reordered, or replayed
frames are rejected.
6. **Crash isolation** — A crash in a guest process cannot bring down the
7. **Crash isolation** — A crash in a guest process cannot bring down the
supervisor.

Resource quotas (CPU/RAM/I/O) are enforced by `rlimit` and cgroup v2 controls
Expand All @@ -79,7 +84,8 @@ code. Confinement is applied before any guest code runs, in order:

* **seccomp** deny-list — kills the process on high-risk syscalls; inherited
across `fork`/`clone` and irremovable once `no_new_privs` is set.
* **Landlock** — filesystem access restricted to the policy's paths (kernels
* **Landlock** — filesystem access restricted to the policy's paths, and (ABI
>= 4) TCP `connect()` restricted to the policy's allow-listed ports (kernels
with Landlock support).
* **eBPF/LSM** — a per-cgroup `deny_mask` on `file_open`, `socket_connect`,
`task_alloc`, `bprm_check_security`, `ptrace`, `sb_mount`, and `bpf` hooks,
Expand Down
11 changes: 10 additions & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ 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;
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);
- a **coarse per-cgroup eBPF/LSM deny-mask** (deny whole capability classes),
where BPF-LSM is available;
- `rlimit` and cgroup resource caps.
Expand Down Expand Up @@ -60,6 +61,9 @@ high-assurance multitenancy, run one sandbox per process inside a VM or microVM.
- process isolation from the supervisor's address space,
- a seccomp deny-list that kills the process on dangerous syscalls,
- Landlock filesystem rules from policy (where supported),
- Landlock TCP-egress rules that deny connect() to any port the policy did
not allow-list (ABI >= 4; port-granular, a coarse backstop beneath the
userspace host:port guard),
- a coarse per-cgroup eBPF/LSM deny-mask (where BPF-LSM is available),
- broker-mediated privileged operations and per-sandbox resource limits.

Expand Down Expand Up @@ -196,6 +200,11 @@ Any semantic change to defended/not-defended status requires:

### History

- **2026-07-21** — Added a Landlock TCP-egress layer to `backend="process"`:
where the kernel's Landlock ABI is >= 4, the policy's TCP allow-list is mapped
to allowed connect ports and the kernel denies egress to every other port,
backstopping the userspace host:port guard. Recorded in the confinement report
(`landlock_net` / `landlock_net_ports`).
- **2026-07-20** — Made the hostile-Python answer backend-conditional. Landed a
real `backend="process"` boundary: separate-process isolation, a seccomp
deny-list with `no_new_privs`, Landlock filesystem enforcement from policy,
Expand Down
22 changes: 22 additions & 0 deletions pyisolate/runtime/child.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
from typing import Any

from .. import errors
from . import landlock as _landlock
from .confine import apply_confinement
from .thread import _SAFE_BUILTINS, _blocked_open, _make_importer, _thread_local

Expand Down Expand Up @@ -110,6 +111,24 @@ def request(self, capability: str, action: str, payload: Any = None) -> None:
)


def _net_connect_ports(tcp: list[str] | None) -> list[int] | None:
"""Derive the Landlock TCP-egress allow-list from the policy destinations.

Returns the list of allowed connect ports, or ``None`` to leave network
egress unrestricted by Landlock. Egress is restricted only when the policy
names a TCP allow-list *and* every destination carries a parseable port:
Landlock keys on port and is default-deny for ports it does not name, so an
allow-list we cannot represent exactly is left to the userspace host:port
guard rather than silently blocking a permitted destination.
"""
if not tcp:
return None
ports, exact = _landlock.connect_ports_from_destinations(tcp)
if exact and ports:
return ports
return None


def _install_guest_context(
*,
allowed_imports: list[str] | None,
Expand Down Expand Up @@ -196,6 +215,7 @@ def _serve(sock: socket.socket) -> None:
cpu_seconds=bootstrap.get("cpu_seconds"),
fs_read=bootstrap.get("fs_read"),
fs_write=bootstrap.get("fs_write"),
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)),
)
Expand All @@ -208,6 +228,8 @@ def _serve(sock: socket.socket) -> None:
"rlimits": report.rlimits,
"landlock": report.landlock,
"landlock_rules": report.landlock_rules,
"landlock_net": report.landlock_net,
"landlock_net_ports": report.landlock_net_ports,
"skipped": report.skipped,
},
)
Expand Down
29 changes: 22 additions & 7 deletions pyisolate/runtime/confine.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ class ConfinementReport:
rlimits: list[str] = field(default_factory=list)
landlock: bool = False
landlock_rules: int = 0
landlock_net: bool = False
landlock_net_ports: int = 0
skipped: list[str] = field(default_factory=list)


Expand Down Expand Up @@ -220,20 +222,31 @@ def _apply_landlock(
*,
fs_read: list[str] | None,
fs_write: list[str] | None,
net_connect_ports: list[int] | None,
require_landlock: bool,
) -> None:
# Only restrict the filesystem when the policy actually names paths; without
# an allow-list a default-deny ruleset would just break the interpreter.
if not fs_read and not fs_write:
# 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:
return
landlock_report = _landlock.apply_landlock(
fs_read, fs_write, require=require_landlock
fs_read,
fs_write,
connect_ports=net_connect_ports,
require=require_landlock,
)
if landlock_report.applied:
report.landlock = True
report.landlock_rules = landlock_report.rules
else:
elif fs_read or fs_write:
report.skipped.append(f"landlock:{landlock_report.skipped}")
if landlock_report.net_applied:
report.landlock_net = True
report.landlock_net_ports = landlock_report.net_rules
elif net_connect_ports:
reason = landlock_report.net_skipped or landlock_report.skipped
report.skipped.append(f"landlock_net:{reason}")


def apply_confinement(
Expand All @@ -242,15 +255,16 @@ def apply_confinement(
cpu_seconds: int | None = None,
fs_read: list[str] | None = None,
fs_write: list[str] | None = None,
net_connect_ports: list[int] | None = None,
seccomp: bool = True,
require_seccomp: bool = False,
require_landlock: bool = False,
) -> ConfinementReport:
"""Confine the *current* process before it runs guest code.

Order matters: ``no_new_privs`` is set first (required by both Landlock and
seccomp), then resource limits, then the Landlock filesystem ruleset, and
finally the seccomp filter as the last lockdown step. ``require_*`` turns an
seccomp), then resource limits, then the Landlock filesystem/TCP-egress
ruleset, and finally the seccomp filter as the last lockdown step. ``require_*`` turns an
unsupported platform or a failed install into a raised error instead of a
best-effort skip recorded in the report.
"""
Expand All @@ -262,6 +276,7 @@ def apply_confinement(
report,
fs_read=fs_read,
fs_write=fs_write,
net_connect_ports=net_connect_ports,
require_landlock=require_landlock,
)

Expand Down
Loading
Loading