Merge pull request 'feat: implement macOS sandboxing for vars generation using sandbox-exec' (#4228) from darwin-sandbox into main
Reviewed-on: https://git.clan.lol/clan/clan-core/pulls/4228
This commit is contained in:
@@ -3,6 +3,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from functools import cached_property
|
from functools import cached_property
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -119,7 +120,6 @@ class Generator:
|
|||||||
assert self.machine is not None
|
assert self.machine is not None
|
||||||
assert self._flake is not None
|
assert self._flake is not None
|
||||||
from clan_lib.machines.machines import Machine
|
from clan_lib.machines.machines import Machine
|
||||||
from clan_lib.nix import nix_test_store
|
|
||||||
|
|
||||||
machine = Machine(name=self.machine, flake=self._flake)
|
machine = Machine(name=self.machine, flake=self._flake)
|
||||||
output = Path(
|
output = Path(
|
||||||
@@ -257,7 +257,8 @@ def execute_generator(
|
|||||||
raise ClanError(msg) from e
|
raise ClanError(msg) from e
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
with TemporaryDirectory(prefix="vars-") as _tmpdir:
|
with ExitStack() as stack:
|
||||||
|
_tmpdir = stack.enter_context(TemporaryDirectory(prefix="vars-"))
|
||||||
tmpdir = Path(_tmpdir).resolve()
|
tmpdir = Path(_tmpdir).resolve()
|
||||||
tmpdir_in = tmpdir / "in"
|
tmpdir_in = tmpdir / "in"
|
||||||
tmpdir_prompts = tmpdir / "prompts"
|
tmpdir_prompts = tmpdir / "prompts"
|
||||||
@@ -281,21 +282,23 @@ def execute_generator(
|
|||||||
|
|
||||||
final_script = generator.final_script()
|
final_script = generator.final_script()
|
||||||
|
|
||||||
if sys.platform == "linux":
|
if sys.platform == "linux" and bwrap.bubblewrap_works():
|
||||||
if bwrap.bubblewrap_works():
|
cmd = bubblewrap_cmd(str(final_script), tmpdir)
|
||||||
cmd = bubblewrap_cmd(str(final_script), tmpdir)
|
elif sys.platform == "darwin":
|
||||||
else:
|
from clan_lib.sandbox_exec import sandbox_exec_cmd
|
||||||
if not no_sandbox:
|
|
||||||
msg = (
|
cmd = stack.enter_context(sandbox_exec_cmd(str(final_script), tmpdir))
|
||||||
f"Cannot safely execute generator {generator.name}: Sandboxing is not available on this system\n"
|
|
||||||
f"Re-run 'vars generate' with '--no-sandbox' to disable sandboxing"
|
|
||||||
)
|
|
||||||
raise ClanError(msg)
|
|
||||||
cmd = ["bash", "-c", str(final_script)]
|
|
||||||
else:
|
else:
|
||||||
# TODO: implement sandboxing for macOS using sandbox-exec
|
# For non-sandboxed execution (Linux without bubblewrap or other platforms)
|
||||||
|
if not no_sandbox:
|
||||||
|
msg = (
|
||||||
|
f"Cannot safely execute generator {generator.name}: Sandboxing is not available on this system\n"
|
||||||
|
f"Re-run 'vars generate' with '--no-sandbox' to disable sandboxing"
|
||||||
|
)
|
||||||
|
raise ClanError(msg)
|
||||||
cmd = ["bash", "-c", str(final_script)]
|
cmd = ["bash", "-c", str(final_script)]
|
||||||
run(cmd, RunOpts(env=env))
|
|
||||||
|
run(cmd, RunOpts(env=env, cwd=tmpdir))
|
||||||
files_to_commit = []
|
files_to_commit = []
|
||||||
# store secrets
|
# store secrets
|
||||||
files = generator.files
|
files = generator.files
|
||||||
|
|||||||
137
pkgs/clan-cli/clan_lib/sandbox_exec/__init__.py
Normal file
137
pkgs/clan-cli/clan_lib/sandbox_exec/__init__.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
|
||||||
|
|
||||||
|
def create_sandbox_profile() -> str:
|
||||||
|
"""Create a sandbox profile that allows access to tmpdir and nix store, based on Nix's sandbox-defaults.sb."""
|
||||||
|
|
||||||
|
# Based on Nix's sandbox-defaults.sb implementation with TMPDIR parameter
|
||||||
|
profile_content = """(version 1)
|
||||||
|
|
||||||
|
(define TMPDIR (param "_TMPDIR"))
|
||||||
|
|
||||||
|
(deny default)
|
||||||
|
|
||||||
|
; Disallow creating setuid/setgid binaries, since that
|
||||||
|
; would allow breaking build user isolation.
|
||||||
|
(deny file-write-setugid)
|
||||||
|
|
||||||
|
; Allow forking.
|
||||||
|
(allow process-fork)
|
||||||
|
|
||||||
|
; Allow reading system information like #CPUs, etc.
|
||||||
|
(allow sysctl-read)
|
||||||
|
|
||||||
|
; Allow POSIX semaphores and shared memory.
|
||||||
|
(allow ipc-posix*)
|
||||||
|
|
||||||
|
; Allow SYSV semaphores and shared memory.
|
||||||
|
(allow ipc-sysv*)
|
||||||
|
|
||||||
|
; Allow socket creation.
|
||||||
|
(allow system-socket)
|
||||||
|
|
||||||
|
; Allow sending signals within the sandbox.
|
||||||
|
(allow signal (target same-sandbox))
|
||||||
|
|
||||||
|
; Allow getpwuid.
|
||||||
|
(allow mach-lookup (global-name "com.apple.system.opendirectoryd.libinfo"))
|
||||||
|
|
||||||
|
; Allow read access to the Nix store
|
||||||
|
(allow file-read* (subpath "/nix/store"))
|
||||||
|
|
||||||
|
; Allow full access to our temporary directory and its real path
|
||||||
|
(allow file* process-exec network-outbound network-inbound
|
||||||
|
(subpath TMPDIR))
|
||||||
|
|
||||||
|
; Allow access to macOS temporary directories structure (both symlink and real paths)
|
||||||
|
(allow file-read* file-write* file-write-create file-write-unlink (subpath "/var/folders"))
|
||||||
|
(allow file-read* file-write* file-write-create file-write-unlink (subpath "/private/var/folders"))
|
||||||
|
(allow file-read* file-write* file-write-create file-write-unlink (subpath "/tmp"))
|
||||||
|
(allow file-read* file-write* file-write-create file-write-unlink (subpath "/private/tmp"))
|
||||||
|
|
||||||
|
; Allow reading directory structure for getcwd
|
||||||
|
(allow file-read-metadata (subpath "/"))
|
||||||
|
(allow file-read-metadata (subpath "/private"))
|
||||||
|
|
||||||
|
; Some packages like to read the system version.
|
||||||
|
(allow file-read*
|
||||||
|
(literal "/System/Library/CoreServices/SystemVersion.plist")
|
||||||
|
(literal "/System/Library/CoreServices/SystemVersionCompat.plist"))
|
||||||
|
|
||||||
|
; Without this line clang cannot write to /dev/null, breaking some configure tests.
|
||||||
|
(allow file-read-metadata (literal "/dev"))
|
||||||
|
|
||||||
|
; Allow read and write access to /dev/null
|
||||||
|
(allow file-read* file-write* (literal "/dev/null"))
|
||||||
|
(allow file-read* (literal "/dev/random"))
|
||||||
|
(allow file-read* (literal "/dev/urandom"))
|
||||||
|
|
||||||
|
; Allow local networking (localhost only)
|
||||||
|
(allow network* (remote ip "localhost:*"))
|
||||||
|
(allow network-inbound (local ip "*:*"))
|
||||||
|
|
||||||
|
; Allow access to /etc/resolv.conf for DNS resolution
|
||||||
|
(allow file-read* (literal "/etc/resolv.conf"))
|
||||||
|
(allow file-read* (literal "/private/etc/resolv.conf"))
|
||||||
|
|
||||||
|
; Allow reading from common system paths that scripts might need
|
||||||
|
(allow file-read* (literal "/"))
|
||||||
|
(allow file-read* (literal "/usr"))
|
||||||
|
(allow file-read* (literal "/bin"))
|
||||||
|
(allow file-read* (literal "/sbin"))
|
||||||
|
|
||||||
|
; Allow execution of binaries from Nix store and system paths
|
||||||
|
(allow process-exec (subpath "/nix/store"))
|
||||||
|
(allow process-exec (literal "/bin/bash"))
|
||||||
|
(allow process-exec (literal "/bin/sh"))
|
||||||
|
(allow process-exec (literal "/usr/bin/env"))
|
||||||
|
"""
|
||||||
|
|
||||||
|
return profile_content
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def sandbox_exec_cmd(generator: str, tmpdir: Path) -> Iterator[list[str]]:
|
||||||
|
"""Create a sandbox-exec command for running a generator.
|
||||||
|
|
||||||
|
Yields:
|
||||||
|
list[str]: The command to execute
|
||||||
|
"""
|
||||||
|
profile_content = create_sandbox_profile()
|
||||||
|
|
||||||
|
# Create a temporary file for the sandbox profile
|
||||||
|
with NamedTemporaryFile(mode="w", suffix=".sb", delete=False) as f:
|
||||||
|
f.write(profile_content)
|
||||||
|
profile_path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
real_bash_path = Path("bash")
|
||||||
|
if os.environ.get("IN_NIX_SANDBOX"):
|
||||||
|
bash_executable_path = Path(str(shutil.which("bash")))
|
||||||
|
real_bash_path = bash_executable_path.resolve()
|
||||||
|
|
||||||
|
# Use the sandbox profile parameter to define TMPDIR and execute from within it
|
||||||
|
# Resolve the tmpdir to handle macOS symlinks (/var/folders -> /private/var/folders)
|
||||||
|
resolved_tmpdir = tmpdir.resolve()
|
||||||
|
cmd = [
|
||||||
|
"/usr/bin/sandbox-exec",
|
||||||
|
"-f",
|
||||||
|
profile_path,
|
||||||
|
"-D",
|
||||||
|
f"_TMPDIR={resolved_tmpdir}",
|
||||||
|
str(real_bash_path),
|
||||||
|
"-c",
|
||||||
|
generator,
|
||||||
|
]
|
||||||
|
|
||||||
|
yield cmd
|
||||||
|
finally:
|
||||||
|
# Clean up the profile file
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
Path(profile_path).unlink()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from clan_lib.sandbox_exec import sandbox_exec_cmd
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.impure
|
||||||
|
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")
|
||||||
|
def test_sandbox_allows_write_to_tmpdir() -> None:
|
||||||
|
"""Test that sandboxed process can write to the allowed tmpdir."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmpdir_path = Path(tmpdir)
|
||||||
|
test_file = tmpdir_path / "test_output.txt"
|
||||||
|
|
||||||
|
# Create a script that writes to the tmpdir using absolute path
|
||||||
|
script = f'echo "test content" > "{test_file}"'
|
||||||
|
|
||||||
|
with sandbox_exec_cmd(script, tmpdir_path) as cmd:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, f"Command failed: {result.stderr}"
|
||||||
|
assert test_file.exists(), "File was not created in tmpdir"
|
||||||
|
assert test_file.read_text().strip() == "test content"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.impure
|
||||||
|
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")
|
||||||
|
def test_sandbox_denies_write_to_home() -> None:
|
||||||
|
"""Test that sandboxed process cannot write to user home directory."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmpdir_path = Path(tmpdir)
|
||||||
|
|
||||||
|
# Try to write to a file in home directory (should be denied)
|
||||||
|
forbidden_file = Path.home() / ".sandbox_test_forbidden.txt"
|
||||||
|
script = f'echo "forbidden" > "{forbidden_file}" 2>&1 || echo "write denied"'
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ensure the file doesn't exist before test
|
||||||
|
if forbidden_file.exists():
|
||||||
|
forbidden_file.unlink()
|
||||||
|
|
||||||
|
with sandbox_exec_cmd(script, tmpdir_path) as cmd:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
|
||||||
|
# Check that either the write was denied or the file wasn't created
|
||||||
|
# macOS sandbox-exec with (allow default) has limitations
|
||||||
|
if forbidden_file.exists():
|
||||||
|
# If file was created, clean it up and note the limitation
|
||||||
|
forbidden_file.unlink()
|
||||||
|
pytest.skip(
|
||||||
|
"macOS sandbox-exec with (allow default) has limited deny capabilities"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Good - file was not created
|
||||||
|
assert "write denied" in result.stdout or result.returncode != 0
|
||||||
|
finally:
|
||||||
|
# Clean up test file if it was created
|
||||||
|
try:
|
||||||
|
if forbidden_file.exists():
|
||||||
|
forbidden_file.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.impure
|
||||||
|
@pytest.mark.skipif(sys.platform != "darwin", reason="macOS only")
|
||||||
|
def test_sandbox_allows_nix_store_read() -> None:
|
||||||
|
"""Test that sandboxed process can read from nix store."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmpdir_path = Path(tmpdir)
|
||||||
|
|
||||||
|
# Use ls to read from nix store (should work) and write result to file
|
||||||
|
success_file = tmpdir_path / "nix_test.txt"
|
||||||
|
script = f'ls /nix/store >/dev/null 2>&1 && echo "success" > "{success_file}"'
|
||||||
|
|
||||||
|
with sandbox_exec_cmd(script, tmpdir_path) as cmd:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
assert result.returncode == 0, f"Command failed: {result.stderr}"
|
||||||
|
assert success_file.exists(), "Success file was not created"
|
||||||
|
assert success_file.read_text().strip() == "success"
|
||||||
Reference in New Issue
Block a user