mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-04 08:28:19 +08:00
BREAKING CHANGE: Policy YAML and public policy APIs now use the protobuf-shaped openshell.policy.v1.PolicyDocument contract. Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
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, policy_pb2
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Callable
|
|
|
|
from openshell import Sandbox
|
|
|
|
|
|
# =============================================================================
|
|
# Policy helpers
|
|
# =============================================================================
|
|
|
|
_LANDLOCK_FILESYSTEM = policy_pb2.FilesystemPolicy(
|
|
include_workdir=True,
|
|
read_only=["/usr", "/lib", "/etc", "/proc", "/dev/urandom"],
|
|
read_write=["/sandbox", "/tmp"],
|
|
)
|
|
_LANDLOCK_BEST_EFFORT = policy_pb2.LandlockPolicy(compatibility="best_effort")
|
|
_LANDLOCK_PROCESS = policy_pb2.ProcessPolicy(
|
|
run_as_user="sandbox", run_as_group="sandbox"
|
|
)
|
|
|
|
|
|
def _landlock_policy(
|
|
*,
|
|
filesystem: policy_pb2.FilesystemPolicy | None = None,
|
|
landlock: policy_pb2.LandlockPolicy | None = None,
|
|
) -> policy_pb2.PolicyDocument:
|
|
return policy_pb2.PolicyDocument(
|
|
version=1,
|
|
filesystem_policy=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()}"
|
|
)
|