Files
OpenShell/e2e/python/test_sandbox_landlock.py
John T. Myers 29a3b1cacd fix(sandbox): two-phase Landlock to fix privilege ordering and add enforcement tests (#810)
* fix(sandbox): add parent-side Landlock availability probe logging

Landlock status was only logged from inside the pre_exec child process
where the tracing/OCSF pipeline is non-functional after fork. This made
Landlock failures completely invisible in sandbox logs.

Add a probe_availability() function that issues the raw
landlock_create_ruleset syscall to check kernel support, and call it
from the parent process before fork in all three spawn paths (entrypoint,
SSH PTY, SSH pipe). Uses std::sync::Once to emit exactly once per
sandbox lifetime.

WIP - addresses logging gap from #803.

* test(e2e): add Landlock filesystem enforcement tests

Verify Landlock availability logging and enforcement in e2e:
- OCSF probe event appears in sandbox logs
- Read-only paths block writes, allow reads
- Read-write paths allow both
- Paths outside policy are denied entirely
- User-owned paths outside policy are still blocked (proves
  Landlock enforces independently of Unix DAC permissions)

Requires Linux host with Landlock support (GitHub Actions runners,
Docker Desktop linuxkit). Related to #803.

* test(e2e): add xfail tests proving #803 privilege ordering bug

Two strict xfail tests that demonstrate the root cause of #803:
- PathFd::new() runs as uid 998 after drop_privileges, so root-only
  paths (mode 700) silently fail and Landlock degrades
- When ALL paths fail, best_effort silently drops Landlock entirely

These tests will pass after the two-phase Landlock fix (open PathFds
as root before drop_privileges, restrict_self after).

* fix(test): fix Landlock e2e tests based on test run results

- Remove log-reading tests: the Landlock probe logs to the
  supervisor's container stdout, not the in-sandbox file appender
  at /var/log/openshell*.log*
- Replace broken xfail test (checked for child-process log messages
  that never reach the file appender) with a stat()-based test that
  verifies /root is actually in the Landlock allowlist
- Keep enforcement tests (all passing) and the root-only policy
  xfail test (correctly proves #803 bug)

* fix(test): remove mixed-policy xfail test

Landlock doesn't restrict stat(), so the test passed unexpectedly.
In the mixed policy case where /root is silently skipped, Landlock
still applies (using other paths) and blocks /root even harder
(not in allowlist = denied). The observable security degradation
only occurs when ALL paths fail, which the existing xfail test
already covers.

* fix(sandbox): two-phase Landlock to fix privilege ordering (#803)

Split Landlock apply into prepare() and enforce():
- prepare() runs as root before drop_privileges: opens PathFds,
  creates ruleset, adds rules. Root-only paths (mode 700) now
  succeed instead of silently failing as uid 998.
- enforce() runs after drop_privileges: calls restrict_self()
  which does not require root.

This fixes the root cause of #803 where drop_privileges() ran
before sandbox::apply(), causing PathFd::new() to fail on
root-only paths. In best_effort mode this silently dropped all
Landlock restrictions.

The fix applies to all three spawn paths: entrypoint (process.rs),
SSH PTY (ssh.rs), and SSH pipe exec (ssh.rs).

Removes xfail marker from e2e test that now passes.

* fix(sandbox): address PR review feedback

- enforce() now respects best_effort: if restrict_self() fails and
  policy is best_effort, log and degrade instead of aborting startup
- log_sandbox_readiness distinguishes best_effort (degraded) from
  hard_requirement (will fail) in OCSF messages
2026-04-13 10:55:34 -07:00

208 lines
7.3 KiB
Python

# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Tests for Landlock filesystem sandboxing.
Verifies that:
- Landlock availability is logged via OCSF in sandbox logs
- Read-only paths block writes but allow reads
- Read-write paths allow both reads and writes
- Paths outside the policy are blocked entirely
- Paths the sandbox user owns but are not in the policy are still blocked
- best_effort mode skips inaccessible paths without crashing
These tests require a Linux host with Landlock support (kernel 5.13+).
GitHub Actions Linux runners satisfy this requirement. Docker Desktop
linuxkit kernels also support Landlock (ABI v5+).
Related: https://github.com/NVIDIA/OpenShell/issues/803
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from openshell._proto import datamodel_pb2, sandbox_pb2
if TYPE_CHECKING:
from collections.abc import Callable
from openshell import Sandbox
# =============================================================================
# Policy helpers
# =============================================================================
_LANDLOCK_FILESYSTEM = sandbox_pb2.FilesystemPolicy(
include_workdir=True,
read_only=["/usr", "/lib", "/etc", "/proc", "/dev/urandom"],
read_write=["/sandbox", "/tmp"],
)
_LANDLOCK_BEST_EFFORT = sandbox_pb2.LandlockPolicy(compatibility="best_effort")
_LANDLOCK_PROCESS = sandbox_pb2.ProcessPolicy(
run_as_user="sandbox", run_as_group="sandbox"
)
def _landlock_policy(
*,
filesystem: sandbox_pb2.FilesystemPolicy | None = None,
landlock: sandbox_pb2.LandlockPolicy | None = None,
) -> sandbox_pb2.SandboxPolicy:
return sandbox_pb2.SandboxPolicy(
version=1,
filesystem=filesystem or _LANDLOCK_FILESYSTEM,
landlock=landlock or _LANDLOCK_BEST_EFFORT,
process=_LANDLOCK_PROCESS,
network_policies={},
)
# =============================================================================
# Closures for exec_python (serialized into the sandbox by cloudpickle)
# =============================================================================
def _try_write():
"""Return a closure that attempts to write a file and returns the result."""
def fn(path):
import os
try:
with open(os.path.join(path, ".landlock-test"), "w") as f:
f.write("test")
return "OK"
except PermissionError:
return "EPERM"
except OSError as e:
return f"ERROR:{e.errno}"
return fn
def _try_read():
"""Return a closure that attempts to read a directory listing."""
def fn(path):
import os
try:
entries = os.listdir(path)
return f"OK:{len(entries)}"
except PermissionError:
return "EPERM"
except OSError as e:
return f"ERROR:{e.errno}"
return fn
def _check_user_owns_path():
"""Return a closure that checks if the current user owns a path."""
def fn(path):
import os
try:
st = os.stat(path)
uid = os.getuid()
return f"owner:{st.st_uid} me:{uid} match:{st.st_uid == uid}"
except OSError as e:
return f"ERROR:{e}"
return fn
# =============================================================================
# Landlock enforcement tests
# =============================================================================
def test_landlock_blocks_write_to_read_only_path(
sandbox: Callable[..., Sandbox],
) -> None:
"""Writes to read-only paths (/usr) are blocked by Landlock."""
spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
with sandbox(spec=spec, delete_on_exit=True) as sb:
result = sb.exec_python(_try_write(), args=("/usr",))
assert result.exit_code == 0, result.stderr
assert result.stdout.strip() == "EPERM", (
f"Expected write to /usr to be denied, got: {result.stdout.strip()}"
)
def test_landlock_allows_write_to_read_write_path(
sandbox: Callable[..., Sandbox],
) -> None:
"""Writes to read-write paths (/tmp, /sandbox) are allowed."""
spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
with sandbox(spec=spec, delete_on_exit=True) as sb:
for path in ["/tmp", "/sandbox"]:
result = sb.exec_python(_try_write(), args=(path,))
assert result.exit_code == 0, result.stderr
assert result.stdout.strip() == "OK", (
f"Expected write to {path} to succeed, got: {result.stdout.strip()}"
)
def test_landlock_allows_read_on_read_only_path(
sandbox: Callable[..., Sandbox],
) -> None:
"""Reads from read-only paths (/usr, /etc) are allowed."""
spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
with sandbox(spec=spec, delete_on_exit=True) as sb:
for path in ["/usr", "/etc"]:
result = sb.exec_python(_try_read(), args=(path,))
assert result.exit_code == 0, result.stderr
assert result.stdout.strip().startswith("OK:"), (
f"Expected read from {path} to succeed, got: {result.stdout.strip()}"
)
def test_landlock_blocks_access_outside_policy(
sandbox: Callable[..., Sandbox],
) -> None:
"""Paths not listed in the policy (/opt, /root) are blocked entirely.
When Landlock is enforced, any path not covered by a rule is denied
by default. This is the fundamental allowlist property.
"""
spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
with sandbox(spec=spec, delete_on_exit=True) as sb:
for path in ["/opt", "/root"]:
result = sb.exec_python(_try_read(), args=(path,))
assert result.exit_code == 0, result.stderr
assert (
"EPERM" in result.stdout.strip() or "ERROR:" in result.stdout.strip()
), (
f"Expected access to {path} (outside policy) to be denied, "
f"got: {result.stdout.strip()}"
)
def test_landlock_blocks_user_owned_path_outside_policy(
sandbox: Callable[..., Sandbox],
) -> None:
"""Landlock blocks access to /home/sandbox even though the sandbox user owns it.
This is the key distinction between Landlock and Unix DAC permissions:
the sandbox user has filesystem ownership of /home/sandbox, but because
/home is not in the Landlock policy, access is denied. This confirms
Landlock is enforcing independently of Unix permissions.
"""
spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
with sandbox(spec=spec, delete_on_exit=True) as sb:
# Verify the sandbox user owns /home/sandbox
own_result = sb.exec_python(_check_user_owns_path(), args=("/home/sandbox",))
# The path might not exist in all images, so only assert Landlock
# enforcement if the path is present and owned by us.
if own_result.exit_code == 0 and "match:True" in own_result.stdout:
write_result = sb.exec_python(_try_write(), args=("/home/sandbox",))
assert write_result.exit_code == 0, write_result.stderr
assert write_result.stdout.strip() == "EPERM", (
"Expected Landlock to block write to /home/sandbox despite user ownership. "
f"Got: {write_result.stdout.strip()}"
)