mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
* 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
208 lines
7.3 KiB
Python
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()}"
|
|
)
|