test: add integration and smoke tests (#4)

This commit is contained in:
JY Tan
2025-12-26 14:56:20 -08:00
committed by GitHub
parent f86d9a2c82
commit 6fdd1af057
14 changed files with 2171 additions and 18 deletions

View File

@@ -0,0 +1,488 @@
//go:build linux
package sandbox
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// ============================================================================
// Linux-Specific Integration Tests
// ============================================================================
// skipIfLandlockNotUsable skips tests that require the Landlock wrapper.
// The Landlock wrapper is disabled when the executable is in /tmp (test binaries),
// because --tmpfs /tmp hides the test binary from inside the sandbox.
func skipIfLandlockNotUsable(t *testing.T) {
t.Helper()
features := DetectLinuxFeatures()
if !features.CanUseLandlock() {
t.Skip("skipping: Landlock not available on this kernel")
}
exePath, _ := os.Executable()
if strings.HasPrefix(exePath, "/tmp/") {
t.Skip("skipping: Landlock wrapper disabled in test environment (executable in /tmp)")
}
}
// assertNetworkBlocked verifies that a network command was blocked.
// It checks for either a non-zero exit code OR the proxy's blocked message.
func assertNetworkBlocked(t *testing.T, result *SandboxTestResult) {
t.Helper()
blockedMessage := "Connection blocked by network allowlist"
if result.Failed() {
return // Command failed = blocked
}
if strings.Contains(result.Stdout, blockedMessage) || strings.Contains(result.Stderr, blockedMessage) {
return // Proxy blocked the request
}
t.Errorf("expected network request to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
result.Stdout, result.Stderr)
}
// TestLinux_LandlockBlocksWriteOutsideWorkspace verifies that Landlock prevents
// writes to locations outside the allowed workspace.
func TestLinux_LandlockBlocksWriteOutsideWorkspace(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t)
workspace := createTempWorkspace(t)
outsideFile := "/tmp/fence-test-outside-" + filepath.Base(workspace) + ".txt"
defer func() { _ = os.Remove(outsideFile) }()
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "touch "+outsideFile, workspace)
assertBlocked(t, result)
assertFileNotExists(t, outsideFile)
}
// TestLinux_LandlockAllowsWriteInWorkspace verifies writes within the workspace work.
func TestLinux_LandlockAllowsWriteInWorkspace(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "echo 'test content' > allowed.txt", workspace)
assertAllowed(t, result)
assertFileExists(t, filepath.Join(workspace, "allowed.txt"))
// Verify content was written
content, err := os.ReadFile(filepath.Join(workspace, "allowed.txt")) //nolint:gosec
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if !strings.Contains(string(content), "test content") {
t.Errorf("expected file to contain 'test content', got: %s", string(content))
}
}
// TestLinux_LandlockProtectsGitHooks verifies .git/hooks cannot be written to.
func TestLinux_LandlockProtectsGitHooks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t)
workspace := createTempWorkspace(t)
createGitRepo(t, workspace)
cfg := testConfigWithWorkspace(workspace)
hookPath := filepath.Join(workspace, ".git", "hooks", "pre-commit")
result := runUnderSandbox(t, cfg, "echo '#!/bin/sh\nmalicious' > "+hookPath, workspace)
assertBlocked(t, result)
// Hook file should not exist or should be empty
if content, err := os.ReadFile(hookPath); err == nil && strings.Contains(string(content), "malicious") { //nolint:gosec
t.Errorf("malicious content should not have been written to git hook")
}
}
// TestLinux_LandlockProtectsGitConfig verifies .git/config cannot be written to
// unless allowGitConfig is true.
func TestLinux_LandlockProtectsGitConfig(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t)
workspace := createTempWorkspace(t)
createGitRepo(t, workspace)
cfg := testConfigWithWorkspace(workspace)
cfg.Filesystem.AllowGitConfig = false
configPath := filepath.Join(workspace, ".git", "config")
originalContent, _ := os.ReadFile(configPath) //nolint:gosec
result := runUnderSandbox(t, cfg, "echo 'malicious=true' >> "+configPath, workspace)
assertBlocked(t, result)
// Verify content wasn't modified
newContent, _ := os.ReadFile(configPath) //nolint:gosec
if strings.Contains(string(newContent), "malicious") {
t.Errorf("git config should not have been modified")
}
if string(newContent) != string(originalContent) {
// Content was modified, which shouldn't happen
t.Logf("original: %s", originalContent)
t.Logf("new: %s", newContent)
}
}
// TestLinux_LandlockAllowsGitConfigWhenEnabled verifies .git/config can be written
// when allowGitConfig is true.
func TestLinux_LandlockAllowsGitConfigWhenEnabled(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
createGitRepo(t, workspace)
cfg := testConfigWithWorkspace(workspace)
cfg.Filesystem.AllowGitConfig = true
configPath := filepath.Join(workspace, ".git", "config")
// This may or may not work depending on the implementation
// The key is that hooks should ALWAYS be protected, but config might be allowed
result := runUnderSandbox(t, cfg, "echo '[test]' >> "+configPath, workspace)
// We just verify it doesn't crash; actual behavior depends on implementation
_ = result
}
// TestLinux_LandlockProtectsBashrc verifies shell config files are protected.
func TestLinux_LandlockProtectsBashrc(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t)
workspace := createTempWorkspace(t)
bashrcPath := filepath.Join(workspace, ".bashrc")
createTestFile(t, workspace, ".bashrc", "# original bashrc")
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "echo 'malicious' >> "+bashrcPath, workspace)
assertBlocked(t, result)
content, _ := os.ReadFile(bashrcPath) //nolint:gosec
if strings.Contains(string(content), "malicious") {
t.Errorf(".bashrc should be protected from writes")
}
}
// TestLinux_LandlockAllowsReadSystemFiles verifies system files can be read.
func TestLinux_LandlockAllowsReadSystemFiles(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Reading /etc/passwd should work
result := runUnderSandbox(t, cfg, "cat /etc/passwd | head -1", workspace)
assertAllowed(t, result)
if result.Stdout == "" {
t.Errorf("expected to read /etc/passwd content")
}
}
// TestLinux_LandlockBlocksWriteSystemFiles verifies system files cannot be written.
func TestLinux_LandlockBlocksWriteSystemFiles(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Attempting to write to /etc should fail
result := runUnderSandbox(t, cfg, "touch /etc/fence-test-file", workspace)
assertBlocked(t, result)
assertFileNotExists(t, "/etc/fence-test-file")
}
// TestLinux_LandlockAllowsTmpFence verifies /tmp/fence is writable.
func TestLinux_LandlockAllowsTmpFence(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Ensure /tmp/fence exists
_ = os.MkdirAll("/tmp/fence", 0o750)
testFile := "/tmp/fence/test-file-" + filepath.Base(workspace)
defer func() { _ = os.Remove(testFile) }()
result := runUnderSandbox(t, cfg, "echo 'test' > "+testFile, workspace)
assertAllowed(t, result)
assertFileExists(t, testFile)
}
// ============================================================================
// Network Blocking Tests
// ============================================================================
// TestLinux_NetworkBlocksCurl verifies that curl cannot reach the network.
func TestLinux_NetworkBlocksCurl(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// No domains allowed = all network blocked
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second)
assertNetworkBlocked(t, result)
}
// TestLinux_NetworkBlocksWget verifies that wget cannot reach the network.
func TestLinux_NetworkBlocksWget(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "wget")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, "wget -q --timeout=2 -O /dev/null http://example.com", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestLinux_NetworkBlocksPing verifies that ping cannot reach the network.
func TestLinux_NetworkBlocksPing(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "ping")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, "ping -c 1 -W 2 8.8.8.8", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestLinux_NetworkBlocksNetcat verifies that nc cannot make connections.
func TestLinux_NetworkBlocksNetcat(t *testing.T) {
skipIfAlreadySandboxed(t)
// Try both nc and netcat
ncCmd := "nc"
if _, err := lookPathLinux("nc"); err != nil {
if _, err := lookPathLinux("netcat"); err != nil {
t.Skip("skipping: nc/netcat not found")
}
ncCmd = "netcat"
}
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, ncCmd+" -z -w 2 127.0.0.1 80", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestLinux_NetworkBlocksSSH verifies that SSH cannot connect.
func TestLinux_NetworkBlocksSSH(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "ssh")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, "ssh -o BatchMode=yes -o ConnectTimeout=1 -o StrictHostKeyChecking=no github.com", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestLinux_NetworkBlocksDevTcp verifies /dev/tcp is blocked.
func TestLinux_NetworkBlocksDevTcp(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "bash")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, "bash -c 'echo hi > /dev/tcp/127.0.0.1/80'", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestLinux_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
func TestLinux_ProxyAllowsAllowedDomains(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t)
cfg := testConfigWithNetwork("httpbin.org")
cfg.Filesystem.AllowWrite = []string{workspace}
// This test requires actual network - skip in CI if network is unavailable
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
}
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
assertAllowed(t, result)
assertContains(t, result.Stdout, "httpbin")
}
// ============================================================================
// Seccomp Tests (if available)
// ============================================================================
// TestLinux_SeccompBlocksDangerousSyscalls tests that dangerous syscalls are blocked.
func TestLinux_SeccompBlocksDangerousSyscalls(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t) // Seccomp tests are unreliable in test environments
features := DetectLinuxFeatures()
if !features.HasSeccomp {
t.Skip("skipping: seccomp not available")
}
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Try to use ptrace (should be blocked by seccomp filter)
result := runUnderSandbox(t, cfg, `python3 -c "import ctypes; ctypes.CDLL(None).ptrace(0, 0, 0, 0)"`, workspace)
// ptrace should be blocked, causing an error
assertBlocked(t, result)
}
// ============================================================================
// Python Compatibility Tests
// ============================================================================
// TestLinux_PythonMultiprocessingWorks verifies Python multiprocessing works.
func TestLinux_PythonMultiprocessingWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "python3")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Python multiprocessing needs /dev/shm
cfg.Filesystem.AllowWrite = append(cfg.Filesystem.AllowWrite, "/dev/shm")
pythonCode := `
import multiprocessing
from multiprocessing import Lock, Process
def f(lock):
with lock:
print("Lock acquired in child process")
if __name__ == '__main__':
lock = Lock()
p = Process(target=f, args=(lock,))
p.start()
p.join()
print("SUCCESS")
`
// Write Python script to workspace
scriptPath := createTestFile(t, workspace, "test_mp.py", pythonCode)
result := runUnderSandboxWithTimeout(t, cfg, "python3 "+scriptPath, workspace, 30*time.Second)
assertAllowed(t, result)
assertContains(t, result.Stdout, "SUCCESS")
}
// TestLinux_PythonGetpwuidWorks verifies Python can look up user info.
func TestLinux_PythonGetpwuidWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "python3")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, `python3 -c "import pwd, os; print(pwd.getpwuid(os.getuid()).pw_name)"`, workspace)
assertAllowed(t, result)
if result.Stdout == "" {
t.Errorf("expected username output")
}
}
// ============================================================================
// Security Edge Case Tests
// ============================================================================
// TestLinux_SymlinkEscapeBlocked verifies symlink attacks are prevented.
func TestLinux_SymlinkEscapeBlocked(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Create a symlink pointing outside the workspace
symlinkPath := filepath.Join(workspace, "escape")
_ = os.Symlink("/etc", symlinkPath)
// Try to write through the symlink
result := runUnderSandbox(t, cfg, "echo 'test' > "+symlinkPath+"/fence-test", workspace)
assertBlocked(t, result)
assertFileNotExists(t, "/etc/fence-test")
}
// TestLinux_PathTraversalBlocked verifies path traversal attacks are prevented.
func TestLinux_PathTraversalBlocked(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfLandlockNotUsable(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Try to escape using ../../../
result := runUnderSandbox(t, cfg, "touch ../../../../tmp/fence-escape-test", workspace)
assertBlocked(t, result)
assertFileNotExists(t, "/tmp/fence-escape-test")
}
// TestLinux_DeviceAccessBlocked verifies device files cannot be accessed.
func TestLinux_DeviceAccessBlocked(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Try to read /dev/mem (requires root anyway, but should be blocked)
// Use a command that will exit non-zero if the file doesn't exist or can't be read
result := runUnderSandbox(t, cfg, "test -r /dev/mem && cat /dev/mem", workspace)
// Should fail (permission denied, blocked by sandbox, or device doesn't exist)
assertBlocked(t, result)
}
// TestLinux_ProcSelfEnvReadable verifies /proc/self can be read for basic operations.
func TestLinux_ProcSelfEnvReadable(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Reading /proc/self/cmdline should work
result := runUnderSandbox(t, cfg, "cat /proc/self/cmdline", workspace)
assertAllowed(t, result)
}
// ============================================================================
// Helper functions
// ============================================================================
func lookPathLinux(cmd string) (string, error) {
return exec.LookPath(cmd)
}

View File

@@ -0,0 +1,406 @@
//go:build darwin
package sandbox
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// ============================================================================
// macOS-Specific Integration Tests (Seatbelt)
// ============================================================================
// TestMacOS_SeatbeltBlocksWriteOutsideWorkspace verifies Seatbelt prevents writes
// outside the allowed workspace.
func TestMacOS_SeatbeltBlocksWriteOutsideWorkspace(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
outsideFile := "/tmp/fence-test-outside-" + filepath.Base(workspace) + ".txt"
defer func() { _ = os.Remove(outsideFile) }()
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "touch "+outsideFile, workspace)
assertBlocked(t, result)
assertFileNotExists(t, outsideFile)
}
// TestMacOS_SeatbeltAllowsWriteInWorkspace verifies writes within the workspace work.
func TestMacOS_SeatbeltAllowsWriteInWorkspace(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "echo 'test content' > allowed.txt", workspace)
assertAllowed(t, result)
assertFileExists(t, filepath.Join(workspace, "allowed.txt"))
content, err := os.ReadFile(filepath.Join(workspace, "allowed.txt")) //nolint:gosec
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
if !strings.Contains(string(content), "test content") {
t.Errorf("expected file to contain 'test content', got: %s", string(content))
}
}
// TestMacOS_SeatbeltProtectsGitHooks verifies .git/hooks cannot be written to.
func TestMacOS_SeatbeltProtectsGitHooks(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
createGitRepo(t, workspace)
cfg := testConfigWithWorkspace(workspace)
hookPath := filepath.Join(workspace, ".git", "hooks", "pre-commit")
result := runUnderSandbox(t, cfg, "echo '#!/bin/sh\nmalicious' > "+hookPath, workspace)
assertBlocked(t, result)
if content, err := os.ReadFile(hookPath); err == nil && strings.Contains(string(content), "malicious") { //nolint:gosec
t.Errorf("malicious content should not have been written to git hook")
}
}
// TestMacOS_SeatbeltProtectsGitConfig verifies .git/config is protected by default.
func TestMacOS_SeatbeltProtectsGitConfig(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
createGitRepo(t, workspace)
cfg := testConfigWithWorkspace(workspace)
cfg.Filesystem.AllowGitConfig = false
configPath := filepath.Join(workspace, ".git", "config")
originalContent, _ := os.ReadFile(configPath) //nolint:gosec
result := runUnderSandbox(t, cfg, "echo 'malicious=true' >> "+configPath, workspace)
assertBlocked(t, result)
// Verify content wasn't modified
newContent, _ := os.ReadFile(configPath) //nolint:gosec
if strings.Contains(string(newContent), "malicious") {
t.Errorf("git config should not have been modified")
}
_ = originalContent
}
// TestMacOS_SeatbeltProtectsShellConfig verifies shell config files are protected.
func TestMacOS_SeatbeltProtectsShellConfig(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
zshrcPath := filepath.Join(workspace, ".zshrc")
createTestFile(t, workspace, ".zshrc", "# original zshrc")
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "echo 'malicious' >> "+zshrcPath, workspace)
assertBlocked(t, result)
content, _ := os.ReadFile(zshrcPath) //nolint:gosec
if strings.Contains(string(content), "malicious") {
t.Errorf(".zshrc should be protected from writes")
}
}
// TestMacOS_SeatbeltAllowsReadSystemFiles verifies system files can be read.
func TestMacOS_SeatbeltAllowsReadSystemFiles(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Reading /etc/passwd should work on macOS
result := runUnderSandbox(t, cfg, "cat /etc/passwd | head -1", workspace)
assertAllowed(t, result)
if result.Stdout == "" {
t.Errorf("expected to read /etc/passwd content")
}
}
// TestMacOS_SeatbeltBlocksWriteSystemFiles verifies system files cannot be written.
func TestMacOS_SeatbeltBlocksWriteSystemFiles(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Attempting to write to /etc should fail
result := runUnderSandbox(t, cfg, "touch /etc/fence-test-file", workspace)
assertBlocked(t, result)
assertFileNotExists(t, "/etc/fence-test-file")
}
// TestMacOS_SeatbeltAllowsTmpFence verifies /tmp/fence is writable.
func TestMacOS_SeatbeltAllowsTmpFence(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Ensure /tmp/fence exists
_ = os.MkdirAll("/tmp/fence", 0o750)
testFile := "/tmp/fence/test-file-" + filepath.Base(workspace)
defer func() { _ = os.Remove(testFile) }()
result := runUnderSandbox(t, cfg, "echo 'test' > "+testFile, workspace)
assertAllowed(t, result)
assertFileExists(t, testFile)
}
// ============================================================================
// Network Blocking Tests
// ============================================================================
// TestMacOS_NetworkBlocksCurl verifies that curl cannot reach the network when blocked.
func TestMacOS_NetworkBlocksCurl(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// No domains allowed = all network blocked
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 2 --max-time 3 http://example.com", workspace, 10*time.Second)
// Network is blocked via proxy - curl may exit 0 but with "blocked" message,
// or it may fail with a connection error. Either is acceptable.
if result.Succeeded() && !strings.Contains(result.Stdout, "blocked") && !strings.Contains(result.Stdout, "Connection refused") {
t.Errorf("expected network to be blocked, but curl succeeded with: %s", result.Stdout)
}
}
// TestMacOS_NetworkBlocksSSH verifies that SSH cannot connect when blocked.
func TestMacOS_NetworkBlocksSSH(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "ssh")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, "ssh -o BatchMode=yes -o ConnectTimeout=1 -o StrictHostKeyChecking=no github.com", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestMacOS_NetworkBlocksNc verifies that nc cannot make connections.
func TestMacOS_NetworkBlocksNc(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "nc")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandboxWithTimeout(t, cfg, "nc -z -w 2 127.0.0.1 80", workspace, 10*time.Second)
assertBlocked(t, result)
}
// TestMacOS_ProxyAllowsAllowedDomains verifies the proxy allows configured domains.
func TestMacOS_ProxyAllowsAllowedDomains(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "curl")
workspace := createTempWorkspace(t)
cfg := testConfigWithNetwork("httpbin.org")
cfg.Filesystem.AllowWrite = []string{workspace}
// This test requires actual network - skip in CI if network is unavailable
if os.Getenv("FENCE_TEST_NETWORK") != "1" {
t.Skip("skipping: set FENCE_TEST_NETWORK=1 to run network tests")
}
result := runUnderSandboxWithTimeout(t, cfg, "curl -s --connect-timeout 5 --max-time 10 https://httpbin.org/get", workspace, 15*time.Second)
assertAllowed(t, result)
assertContains(t, result.Stdout, "httpbin")
}
// ============================================================================
// Python Compatibility Tests
// ============================================================================
// TestMacOS_PythonOpenptyWorks verifies Python can open a PTY under Seatbelt.
func TestMacOS_PythonOpenptyWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "python3")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
cfg.AllowPty = true
pythonCode := `import os
master, slave = os.openpty()
os.write(slave, b"ping")
assert os.read(master, 4) == b"ping"
print("SUCCESS")`
result := runUnderSandbox(t, cfg, `python3 -c '`+pythonCode+`'`, workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "SUCCESS")
}
// TestMacOS_PythonGetpwuidWorks verifies Python can look up user info.
func TestMacOS_PythonGetpwuidWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "python3")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, `python3 -c "import pwd, os; print(pwd.getpwuid(os.getuid()).pw_name)"`, workspace)
assertAllowed(t, result)
if result.Stdout == "" {
t.Errorf("expected username output")
}
}
// ============================================================================
// Security Edge Case Tests
// ============================================================================
// TestMacOS_SymlinkEscapeBlocked verifies symlink attacks are prevented.
func TestMacOS_SymlinkEscapeBlocked(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Create a symlink pointing outside the workspace
symlinkPath := filepath.Join(workspace, "escape")
if err := os.Symlink("/etc", symlinkPath); err != nil {
t.Fatalf("failed to create symlink: %v", err)
}
// Try to write through the symlink
result := runUnderSandbox(t, cfg, "echo 'test' > "+symlinkPath+"/fence-test", workspace)
assertBlocked(t, result)
assertFileNotExists(t, "/etc/fence-test")
}
// TestMacOS_PathTraversalBlocked verifies path traversal attacks are prevented.
func TestMacOS_PathTraversalBlocked(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "touch ../../../../tmp/fence-escape-test", workspace)
assertBlocked(t, result)
assertFileNotExists(t, "/tmp/fence-escape-test")
}
// TestMacOS_DeviceAccessBlocked verifies device files cannot be written.
func TestMacOS_DeviceAccessBlocked(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Try to write to /dev/disk0 (would need root anyway, but should be blocked by sandbox)
result := runUnderSandbox(t, cfg, "echo 'test' > /dev/disk0 2>&1", workspace)
// Should fail (permission denied or blocked by sandbox)
// The command may "succeed" if the write fails silently, so we check for error messages
if result.Succeeded() && !strings.Contains(result.Stderr, "denied") && !strings.Contains(result.Stderr, "Permission") {
// Even if shell exits 0, reading /dev/disk0 should produce errors or empty output
t.Logf("Note: device access test may not be reliable without root")
}
}
// ============================================================================
// Policy Tests
// ============================================================================
// TestMacOS_ReadOnlyPolicy verifies that files outside the allowed write paths cannot be written.
// Note: Fence always adds some default writable paths (/tmp/fence, /dev/null, etc.)
// so "read-only" here means "outside the workspace".
func TestMacOS_ReadOnlyPolicy(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
createTestFile(t, workspace, "existing.txt", "hello")
// Only allow writing to workspace - but NOT to a specific location outside
cfg := testConfigWithWorkspace(workspace)
// Reading should work
result := runUnderSandbox(t, cfg, "cat "+filepath.Join(workspace, "existing.txt"), workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "hello")
// Writing in workspace should work
result = runUnderSandbox(t, cfg, "echo 'test' > "+filepath.Join(workspace, "writeable.txt"), workspace)
assertAllowed(t, result)
// Writing outside workspace should fail
outsidePath := "/tmp/fence-test-readonly-" + filepath.Base(workspace) + ".txt"
defer func() { _ = os.Remove(outsidePath) }()
result = runUnderSandbox(t, cfg, "echo 'outside' > "+outsidePath, workspace)
assertBlocked(t, result)
assertFileNotExists(t, outsidePath)
}
// TestMacOS_WorkspaceWritePolicy verifies workspace-write sandbox works.
func TestMacOS_WorkspaceWritePolicy(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
// Writing in workspace should work
result := runUnderSandbox(t, cfg, "echo 'test' > test.txt", workspace)
assertAllowed(t, result)
assertFileExists(t, filepath.Join(workspace, "test.txt"))
// Writing outside workspace should fail
outsideFile := "/tmp/fence-test-outside.txt"
defer func() { _ = os.Remove(outsideFile) }()
result = runUnderSandbox(t, cfg, "echo 'test' > "+outsideFile, workspace)
assertBlocked(t, result)
assertFileNotExists(t, outsideFile)
}
// TestMacOS_MultipleWritableRoots verifies multiple writable roots work.
func TestMacOS_MultipleWritableRoots(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace1 := createTempWorkspace(t)
workspace2 := createTempWorkspace(t)
cfg := testConfig()
cfg.Filesystem.AllowWrite = []string{workspace1, workspace2}
// Writing in first workspace should work
result := runUnderSandbox(t, cfg, "echo 'test1' > "+filepath.Join(workspace1, "file1.txt"), workspace1)
assertAllowed(t, result)
// Writing in second workspace should work
result = runUnderSandbox(t, cfg, "echo 'test2' > "+filepath.Join(workspace2, "file2.txt"), workspace1)
assertAllowed(t, result)
// Writing outside both should fail
outsideFile := "/tmp/fence-test-outside-multi.txt"
defer func() { _ = os.Remove(outsideFile) }()
result = runUnderSandbox(t, cfg, "echo 'test' > "+outsideFile, workspace1)
assertBlocked(t, result)
}

View File

@@ -0,0 +1,485 @@
package sandbox
import (
"bytes"
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/Use-Tusk/fence/internal/config"
)
// ============================================================================
// Test Result Types
// ============================================================================
// SandboxTestResult captures the output of a sandboxed command.
type SandboxTestResult struct {
ExitCode int
Stdout string
Stderr string
Error error
}
// Succeeded returns true if the command exited with code 0.
func (r *SandboxTestResult) Succeeded() bool {
return r.ExitCode == 0 && r.Error == nil
}
// Failed returns true if the command exited with a non-zero code.
func (r *SandboxTestResult) Failed() bool {
return r.ExitCode != 0 || r.Error != nil
}
// ============================================================================
// Test Skip Helpers
// ============================================================================
// skipIfAlreadySandboxed skips the test if running inside a sandbox.
func skipIfAlreadySandboxed(t *testing.T) {
t.Helper()
if os.Getenv("FENCE_SANDBOX") == "1" {
t.Skip("skipping: already running inside Fence sandbox")
}
}
// skipIfCommandNotFound skips if a command is not available.
func skipIfCommandNotFound(t *testing.T, cmd string) {
t.Helper()
if _, err := exec.LookPath(cmd); err != nil {
t.Skipf("skipping: command %q not found", cmd)
}
}
// ============================================================================
// Test Assertions
// ============================================================================
// assertBlocked verifies that a command was blocked by the sandbox.
func assertBlocked(t *testing.T, result *SandboxTestResult) {
t.Helper()
if result.Succeeded() {
t.Errorf("expected command to be blocked, but it succeeded\nstdout: %s\nstderr: %s",
result.Stdout, result.Stderr)
}
}
// assertAllowed verifies that a command was allowed and succeeded.
func assertAllowed(t *testing.T, result *SandboxTestResult) {
t.Helper()
if result.Failed() {
t.Errorf("expected command to succeed, but it failed with exit code %d\nstdout: %s\nstderr: %s\nerror: %v",
result.ExitCode, result.Stdout, result.Stderr, result.Error)
}
}
// assertFileExists checks that a file exists.
func assertFileExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Errorf("expected file to exist: %s", path)
}
}
// assertFileNotExists checks that a file does not exist.
func assertFileNotExists(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected file to not exist: %s", path)
}
}
// assertContains checks that a string contains a substring.
func assertContains(t *testing.T, haystack, needle string) {
t.Helper()
if !strings.Contains(haystack, needle) {
t.Errorf("expected %q to contain %q", haystack, needle)
}
}
// ============================================================================
// Test Configuration Helpers
// ============================================================================
// testConfig creates a test configuration with sensible defaults.
func testConfig() *config.Config {
return &config.Config{
Network: config.NetworkConfig{
AllowedDomains: []string{},
DeniedDomains: []string{},
},
Filesystem: config.FilesystemConfig{
DenyRead: []string{},
AllowWrite: []string{},
DenyWrite: []string{},
},
Command: config.CommandConfig{
Deny: []string{},
Allow: []string{},
UseDefaults: boolPtr(false), // Disable defaults for predictable testing
},
}
}
// testConfigWithWorkspace creates a config that allows writing to a workspace.
func testConfigWithWorkspace(workspacePath string) *config.Config {
cfg := testConfig()
cfg.Filesystem.AllowWrite = []string{workspacePath}
return cfg
}
// testConfigWithNetwork creates a config that allows specific domains.
func testConfigWithNetwork(domains ...string) *config.Config {
cfg := testConfig()
cfg.Network.AllowedDomains = domains
return cfg
}
// ============================================================================
// Sandbox Execution Helpers
// ============================================================================
// runUnderSandbox executes a command under the fence sandbox.
// This uses the sandbox Manager directly for integration testing.
func runUnderSandbox(t *testing.T, cfg *config.Config, command string, workDir string) *SandboxTestResult {
t.Helper()
skipIfAlreadySandboxed(t)
if workDir == "" {
var err error
workDir, err = os.Getwd()
if err != nil {
return &SandboxTestResult{Error: err}
}
}
manager := NewManager(cfg, false, false)
defer manager.Cleanup()
if err := manager.Initialize(); err != nil {
return &SandboxTestResult{Error: err}
}
wrappedCmd, err := manager.WrapCommand(command)
if err != nil {
// Command was blocked before execution
return &SandboxTestResult{
ExitCode: 1,
Stderr: err.Error(),
Error: err,
}
}
return executeShellCommand(t, wrappedCmd, workDir)
}
// runUnderSandboxWithTimeout runs a command with a timeout.
func runUnderSandboxWithTimeout(t *testing.T, cfg *config.Config, command string, workDir string, timeout time.Duration) *SandboxTestResult {
t.Helper()
skipIfAlreadySandboxed(t)
if workDir == "" {
var err error
workDir, err = os.Getwd()
if err != nil {
return &SandboxTestResult{Error: err}
}
}
manager := NewManager(cfg, false, false)
defer manager.Cleanup()
if err := manager.Initialize(); err != nil {
return &SandboxTestResult{Error: err}
}
wrappedCmd, err := manager.WrapCommand(command)
if err != nil {
return &SandboxTestResult{
ExitCode: 1,
Stderr: err.Error(),
Error: err,
}
}
return executeShellCommandWithTimeout(t, wrappedCmd, workDir, timeout)
}
// executeShellCommand runs a command string via /bin/sh.
func executeShellCommand(t *testing.T, command string, workDir string) *SandboxTestResult {
t.Helper()
return executeShellCommandWithTimeout(t, command, workDir, 30*time.Second)
}
// executeShellCommandWithTimeout runs a command with a timeout.
func executeShellCommandWithTimeout(t *testing.T, command string, workDir string, timeout time.Duration) *SandboxTestResult {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
shell := "/bin/sh"
if runtime.GOOS == "darwin" {
shell = "/bin/bash"
}
cmd := exec.CommandContext(ctx, shell, "-c", command)
cmd.Dir = workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
result := &SandboxTestResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
}
if ctx.Err() == context.DeadlineExceeded {
result.Error = ctx.Err()
result.ExitCode = -1
return result
}
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
result.ExitCode = exitErr.ExitCode()
} else {
result.Error = err
result.ExitCode = -1
}
}
return result
}
// ============================================================================
// Workspace Helpers
// ============================================================================
// createTempWorkspace creates a temporary directory for testing.
func createTempWorkspace(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "fence-test-*")
if err != nil {
t.Fatalf("failed to create temp workspace: %v", err)
}
t.Cleanup(func() {
_ = os.RemoveAll(dir)
})
return dir
}
// createTestFile creates a file in the workspace with the given content.
func createTestFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatalf("failed to create directory: %v", err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
return path
}
// createGitRepo creates a minimal git repository structure.
func createGitRepo(t *testing.T, dir string) string {
t.Helper()
gitDir := filepath.Join(dir, ".git")
hooksDir := filepath.Join(gitDir, "hooks")
if err := os.MkdirAll(hooksDir, 0o750); err != nil {
t.Fatalf("failed to create .git/hooks: %v", err)
}
// Create a minimal config file
createTestFile(t, gitDir, "config", "[core]\n\trepositoryformatversion = 0\n")
return dir
}
// ============================================================================
// Common Integration Tests (run on all platforms)
// ============================================================================
func TestIntegration_BasicReadAllowed(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
createTestFile(t, workspace, "test.txt", "hello world")
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "cat test.txt", workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "hello world")
}
func TestIntegration_EchoWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "echo 'hello from sandbox'", workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "hello from sandbox")
}
func TestIntegration_CommandDenyList(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
cfg.Command.Deny = []string{"rm -rf"}
result := runUnderSandbox(t, cfg, "rm -rf /tmp/should-not-run", workspace)
assertBlocked(t, result)
assertContains(t, result.Stderr, "blocked")
}
func TestIntegration_CommandAllowOverridesDeny(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
cfg.Command.Deny = []string{"git push"}
cfg.Command.Allow = []string{"git push origin docs"}
// This should be blocked
result := runUnderSandbox(t, cfg, "git push origin main", workspace)
assertBlocked(t, result)
// This should be allowed (by the allow override)
// Note: it may still fail because git isn't configured, but it shouldn't be blocked
result2 := runUnderSandbox(t, cfg, "git push origin docs", workspace)
// We just check it wasn't blocked by command policy
if result2.Error != nil {
errStr := result2.Error.Error()
if strings.Contains(errStr, "blocked") {
t.Errorf("command should not have been blocked by policy")
}
}
}
func TestIntegration_ChainedCommandDeny(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
cfg.Command.Deny = []string{"rm -rf"}
// Chained command should be blocked
result := runUnderSandbox(t, cfg, "ls && rm -rf /tmp/test", workspace)
assertBlocked(t, result)
}
func TestIntegration_NestedShellCommandDeny(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
cfg.Command.Deny = []string{"rm -rf"}
// Nested shell invocation should be blocked
result := runUnderSandbox(t, cfg, `bash -c "rm -rf /tmp/test"`, workspace)
assertBlocked(t, result)
}
// ============================================================================
// Compatibility Tests
// ============================================================================
func TestIntegration_PythonWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "python3")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, `python3 -c "print('hello from python')"`, workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "hello from python")
}
func TestIntegration_NodeWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "node")
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, `node -e "console.log('hello from node')"`, workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "hello from node")
}
func TestIntegration_GitStatusWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
skipIfCommandNotFound(t, "git")
workspace := createTempWorkspace(t)
createGitRepo(t, workspace)
cfg := testConfigWithWorkspace(workspace)
// git status should work (read operation)
result := runUnderSandbox(t, cfg, "git status", workspace)
// May fail due to git config, but shouldn't crash
// The important thing is it runs, not that it succeeds perfectly
if result.Error != nil && strings.Contains(result.Error.Error(), "blocked") {
t.Errorf("git status should not be blocked")
}
}
func TestIntegration_LsWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
createTestFile(t, workspace, "file1.txt", "content1")
createTestFile(t, workspace, "file2.txt", "content2")
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "ls", workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "file1.txt")
assertContains(t, result.Stdout, "file2.txt")
}
func TestIntegration_PwdWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "pwd", workspace)
assertAllowed(t, result)
// Output should contain the workspace path (or its resolved symlink)
if !strings.Contains(result.Stdout, filepath.Base(workspace)) &&
result.Stdout == "" {
t.Errorf("pwd should output the current directory")
}
}
func TestIntegration_EnvWorks(t *testing.T) {
skipIfAlreadySandboxed(t)
workspace := createTempWorkspace(t)
cfg := testConfigWithWorkspace(workspace)
result := runUnderSandbox(t, cfg, "env | grep FENCE", workspace)
assertAllowed(t, result)
assertContains(t, result.Stdout, "FENCE_SANDBOX=1")
}

View File

@@ -280,10 +280,18 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
"bwrap",
"--new-session",
"--die-with-parent",
"--unshare-net", // Network namespace isolation
"--unshare-pid", // PID namespace isolation
}
// Only use --unshare-net if the environment supports it
// Containerized environments (Docker, CI) often lack CAP_NET_ADMIN
if features.CanUnshareNet {
bwrapArgs = append(bwrapArgs, "--unshare-net") // Network namespace isolation
} else if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping --unshare-net (network namespace unavailable in this environment)\n")
}
bwrapArgs = append(bwrapArgs, "--unshare-pid") // PID namespace isolation
// Generate seccomp filter if available and requested
var seccompFilterPath string
if opts.UseSeccomp && features.HasSeccomp {
@@ -307,7 +315,9 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
// Mount special filesystems
bwrapArgs = append(bwrapArgs, "--dev", "/dev")
// Use --dev-bind for /dev instead of --dev to preserve host device permissions
// (the --dev minimal devtmpfs has permission issues when bwrap is setuid)
bwrapArgs = append(bwrapArgs, "--dev-bind", "/dev", "/dev")
bwrapArgs = append(bwrapArgs, "--proc", "/proc")
// /tmp needs to be writable for many programs
@@ -420,7 +430,14 @@ func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *Lin
// Get fence executable path for Landlock wrapper
fenceExePath, _ := os.Executable()
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && fenceExePath != ""
// Skip Landlock wrapper if executable is in /tmp (test binaries are built there)
// The wrapper won't work because --tmpfs /tmp hides the test binary
executableInTmp := strings.HasPrefix(fenceExePath, "/tmp/")
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && fenceExePath != "" && !executableInTmp
if opts.Debug && executableInTmp {
fmt.Fprintf(os.Stderr, "[fence:linux] Skipping Landlock wrapper (executable in /tmp, likely a test)\n")
}
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
@@ -510,7 +527,12 @@ sleep 0.1
bwrapArgs = append(bwrapArgs, innerScript.String())
if opts.Debug {
featureList := []string{"bwrap(network,pid,fs)"}
var featureList []string
if features.CanUnshareNet {
featureList = append(featureList, "bwrap(network,pid,fs)")
} else {
featureList = append(featureList, "bwrap(pid,fs)")
}
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
featureList = append(featureList, "seccomp")
}
@@ -596,6 +618,7 @@ func PrintLinuxFeatures() {
fmt.Printf(" Kernel: %d.%d\n", features.KernelMajor, features.KernelMinor)
fmt.Printf(" Bubblewrap (bwrap): %v\n", features.HasBwrap)
fmt.Printf(" Socat: %v\n", features.HasSocat)
fmt.Printf(" Network namespace (--unshare-net): %v\n", features.CanUnshareNet)
fmt.Printf(" Seccomp: %v (log level: %d)\n", features.HasSeccomp, features.SeccompLogLevel)
fmt.Printf(" Landlock: %v (ABI v%d)\n", features.HasLandlock, features.LandlockABI)
fmt.Printf(" eBPF: %v (CAP_BPF: %v, root: %v)\n", features.HasEBPF, features.HasCapBPF, features.HasCapRoot)
@@ -614,6 +637,14 @@ func PrintLinuxFeatures() {
fmt.Println()
}
if features.CanUnshareNet {
fmt.Printf(" ✓ Network namespace isolation available\n")
} else if features.HasBwrap {
fmt.Printf(" ⚠ Network namespace unavailable (containerized environment?)\n")
fmt.Printf(" Sandbox will still work but with reduced network isolation.\n")
fmt.Printf(" This is common in Docker, GitHub Actions, and other CI systems.\n")
}
if features.CanUseLandlock() {
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
} else {

View File

@@ -31,6 +31,10 @@ type LinuxFeatures struct {
HasCapBPF bool
HasCapRoot bool
// Network namespace capability
// This can be false in containerized environments (Docker, CI) without CAP_NET_ADMIN
CanUnshareNet bool
// Kernel version
KernelMajor int
KernelMinor int
@@ -67,6 +71,9 @@ func (f *LinuxFeatures) detect() {
// Check eBPF capabilities
f.detectEBPF()
// Check if we can create network namespaces
f.detectNetworkNamespace()
}
func (f *LinuxFeatures) parseKernelVersion() {
@@ -183,6 +190,21 @@ func (f *LinuxFeatures) detectEBPF() {
}
}
// detectNetworkNamespace probes whether bwrap --unshare-net works.
// This can fail in containerized environments (Docker, GitHub Actions, etc.)
// that don't have CAP_NET_ADMIN capability needed to set up the loopback interface.
func (f *LinuxFeatures) detectNetworkNamespace() {
if !f.HasBwrap {
return
}
// Run a minimal bwrap command with --unshare-net to test if it works
// We use a very short timeout since this should either succeed or fail immediately
cmd := exec.Command("bwrap", "--unshare-net", "--", "/bin/true")
err := cmd.Run()
f.CanUnshareNet = err == nil
}
// Summary returns a human-readable summary of available features.
func (f *LinuxFeatures) Summary() string {
var parts []string
@@ -190,7 +212,11 @@ func (f *LinuxFeatures) Summary() string {
parts = append(parts, fmt.Sprintf("kernel %d.%d", f.KernelMajor, f.KernelMinor))
if f.HasBwrap {
parts = append(parts, "bwrap")
if f.CanUnshareNet {
parts = append(parts, "bwrap")
} else {
parts = append(parts, "bwrap(no-netns)")
}
}
if f.HasSeccomp {
switch f.SeccompLogLevel {

View File

@@ -45,6 +45,7 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
}
// Essential system paths - allow read+execute
// Note: /dev is handled separately with read+write for /dev/null, /dev/zero, etc.
systemReadPaths := []string{
"/usr",
"/lib",
@@ -54,11 +55,11 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
"/sbin",
"/etc",
"/proc",
"/dev",
"/sys",
"/run",
"/var/lib",
"/var/cache",
"/opt",
}
for _, p := range systemReadPaths {
@@ -89,6 +90,12 @@ func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []strin
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add /tmp write path: %v\n", err)
}
// /dev needs read+write for /dev/null, /dev/zero, /dev/tty, etc.
// Landlock doesn't support rules on device files directly, so we allow the whole /dev
if err := ruleset.AllowReadWrite("/dev"); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add /dev write path: %v\n", err)
}
// Socket paths for proxy communication
for _, p := range socketPaths {
dir := filepath.Dir(p)