Enhance Linux sandbox security features with Landlock, seccomp, and eBPF monitoring

This commit is contained in:
JY Tan
2025-12-25 17:33:55 -08:00
parent a8158a39b3
commit 08ed28f88f
20 changed files with 2820 additions and 64 deletions

View File

@@ -1,8 +1,11 @@
//go:build linux
package sandbox
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
@@ -30,6 +33,20 @@ type ReverseBridge struct {
debug bool
}
// LinuxSandboxOptions contains options for the Linux sandbox.
type LinuxSandboxOptions struct {
// Enable Landlock filesystem restrictions (requires kernel 5.13+)
UseLandlock bool
// Enable seccomp syscall filtering
UseSeccomp bool
// Enable eBPF monitoring (requires CAP_BPF or root)
UseEBPF bool
// Enable violation monitoring
Monitor bool
// Debug mode
Debug bool
}
// NewLinuxBridge creates Unix socket bridges to the proxy servers.
// This allows sandboxed processes to communicate with the host's proxy (outbound).
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
@@ -229,7 +246,18 @@ func getMandatoryDenyPaths(cwd string) []string {
}
// WrapCommandLinux wraps a command with Linux bubblewrap sandbox.
// It uses available security features (Landlock, seccomp) with graceful fallback.
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
return WrapCommandLinuxWithOptions(cfg, command, bridge, reverseBridge, LinuxSandboxOptions{
UseLandlock: true, // Enabled by default, will fall back if not available
UseSeccomp: true, // Enabled by default
UseEBPF: true, // Enabled by default if available
Debug: debug,
})
}
// WrapCommandLinuxWithOptions wraps a command with configurable sandbox options.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
if _, err := exec.LookPath("bwrap"); err != nil {
return "", fmt.Errorf("bubblewrap (bwrap) is required on Linux but not found: %w", err)
}
@@ -241,6 +269,11 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
}
cwd, _ := os.Getwd()
features := DetectLinuxFeatures()
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Available features: %s\n", features.Summary())
}
// Build bwrap args with filesystem restrictions
bwrapArgs := []string{
@@ -251,6 +284,25 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
"--unshare-pid", // PID namespace isolation
}
// Generate seccomp filter if available and requested
var seccompFilterPath string
if opts.UseSeccomp && features.HasSeccomp {
filter := NewSeccompFilter(opts.Debug)
filterPath, err := filter.GenerateBPFFilter()
if err != nil {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Seccomp filter generation failed: %v\n", err)
}
} else {
seccompFilterPath = filterPath
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Seccomp filter enabled (blocking %d dangerous syscalls)\n", len(DangerousSyscalls))
}
// Add seccomp filter via fd 3 (will be set up via shell redirection)
bwrapArgs = append(bwrapArgs, "--seccomp", "3")
}
}
// Start with read-only root filesystem (default deny writes)
bwrapArgs = append(bwrapArgs, "--ro-bind", "/", "/")
@@ -274,6 +326,12 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
// Add user-specified allowWrite paths
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
for _, p := range expandedPaths {
writablePaths[p] = true
}
// Add non-glob paths
for _, p := range cfg.Filesystem.AllowWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) {
@@ -291,6 +349,14 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
// Handle denyRead paths - hide them with tmpfs
if cfg != nil && cfg.Filesystem.DenyRead != nil {
expandedDenyRead := ExpandGlobPatterns(cfg.Filesystem.DenyRead)
for _, p := range expandedDenyRead {
if fileExists(p) {
bwrapArgs = append(bwrapArgs, "--tmpfs", p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyRead {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) {
@@ -302,17 +368,36 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
// Apply mandatory deny patterns (make dangerous files/dirs read-only)
// This overrides any writable mounts for these paths
mandatoryDeny := getMandatoryDenyPaths(cwd)
// Expand glob patterns for mandatory deny
allowGitConfig := cfg != nil && cfg.Filesystem.AllowGitConfig
mandatoryGlobs := GetMandatoryDenyPatterns(cwd, allowGitConfig)
expandedMandatory := ExpandGlobPatterns(mandatoryGlobs)
mandatoryDeny = append(mandatoryDeny, expandedMandatory...)
// Deduplicate
seen := make(map[string]bool)
for _, p := range mandatoryDeny {
if fileExists(p) {
if !seen[p] && fileExists(p) {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Handle explicit denyWrite paths (make them read-only)
if cfg != nil && cfg.Filesystem.DenyWrite != nil {
expandedDenyWrite := ExpandGlobPatterns(cfg.Filesystem.DenyWrite)
for _, p := range expandedDenyWrite {
if fileExists(p) && !seen[p] {
seen[p] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", p, p)
}
}
// Add non-glob paths
for _, p := range cfg.Filesystem.DenyWrite {
normalized := NormalizePath(p)
if !ContainsGlobChars(normalized) && fileExists(normalized) {
if !ContainsGlobChars(normalized) && fileExists(normalized) && !seen[normalized] {
seen[normalized] = true
bwrapArgs = append(bwrapArgs, "--ro-bind", normalized, normalized)
}
}
@@ -333,6 +418,14 @@ func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, r
bwrapArgs = append(bwrapArgs, "--bind", tmpDir, tmpDir)
}
// Get fence executable path for Landlock wrapper
fenceExePath, _ := os.Executable()
useLandlockWrapper := opts.UseLandlock && features.CanUseLandlock() && fenceExePath != ""
if useLandlockWrapper {
// Ensure fence binary is accessible inside the sandbox (it should be via ro-bind /)
// We'll call it at the end of the script to apply Landlock before running user command
}
bwrapArgs = append(bwrapArgs, "--", shellPath, "-c")
// Build the inner command that sets up socat listeners and runs the user command
@@ -391,18 +484,155 @@ sleep 0.1
# Run the user command
`)
innerScript.WriteString(command)
innerScript.WriteString("\n")
// Use Landlock wrapper if available
if useLandlockWrapper {
// Pass config via environment variable (serialized as JSON)
// This ensures allowWrite/denyWrite rules are properly applied
if cfg != nil {
configJSON, err := json.Marshal(cfg)
if err == nil {
innerScript.WriteString(fmt.Sprintf("export FENCE_CONFIG_JSON=%s\n", ShellQuoteSingle(string(configJSON))))
}
}
// Build wrapper command with proper quoting
// Use bash -c to preserve shell semantics (e.g., "echo hi && ls")
wrapperArgs := []string{fenceExePath, "--landlock-apply"}
if opts.Debug {
wrapperArgs = append(wrapperArgs, "--debug")
}
wrapperArgs = append(wrapperArgs, "--", "bash", "-c", command)
// Use exec to replace bash with the wrapper (which will exec the command)
innerScript.WriteString(fmt.Sprintf("exec %s\n", ShellQuote(wrapperArgs)))
} else {
innerScript.WriteString(command)
innerScript.WriteString("\n")
}
bwrapArgs = append(bwrapArgs, innerScript.String())
if debug {
features := []string{"network filtering", "filesystem restrictions"}
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
features = append(features, fmt.Sprintf("inbound ports: %v", reverseBridge.Ports))
if opts.Debug {
featureList := []string{"bwrap(network,pid,fs)"}
if features.HasSeccomp && opts.UseSeccomp && seccompFilterPath != "" {
featureList = append(featureList, "seccomp")
}
fmt.Fprintf(os.Stderr, "[fence:linux] Wrapping command with bwrap (%s)\n", strings.Join(features, ", "))
if useLandlockWrapper {
featureList = append(featureList, fmt.Sprintf("landlock-v%d(wrapper)", features.LandlockABI))
} else if features.CanUseLandlock() && opts.UseLandlock {
featureList = append(featureList, fmt.Sprintf("landlock-v%d(unavailable)", features.LandlockABI))
}
if reverseBridge != nil && len(reverseBridge.Ports) > 0 {
featureList = append(featureList, fmt.Sprintf("inbound:%v", reverseBridge.Ports))
}
fmt.Fprintf(os.Stderr, "[fence:linux] Sandbox: %s\n", strings.Join(featureList, ", "))
}
return ShellQuote(bwrapArgs), nil
// Build the final command
bwrapCmd := ShellQuote(bwrapArgs)
// If seccomp filter is enabled, wrap with fd redirection
// bwrap --seccomp expects the filter on the specified fd
if seccompFilterPath != "" {
// Open filter file on fd 3, then run bwrap
// The filter file will be cleaned up after the sandbox exits
return fmt.Sprintf("exec 3<%s; %s", ShellQuoteSingle(seccompFilterPath), bwrapCmd), nil
}
return bwrapCmd, nil
}
// StartLinuxMonitor starts violation monitoring for a Linux sandbox.
// Returns monitors that should be stopped when the sandbox exits.
func StartLinuxMonitor(pid int, opts LinuxSandboxOptions) (*LinuxMonitors, error) {
monitors := &LinuxMonitors{}
features := DetectLinuxFeatures()
// Note: SeccompMonitor is disabled because our seccomp filter uses SECCOMP_RET_ERRNO
// which silently returns EPERM without logging to dmesg/audit.
// To enable seccomp logging, the filter would need to use SECCOMP_RET_LOG (allows syscall)
// or SECCOMP_RET_KILL (logs but kills process) or SECCOMP_RET_USER_NOTIF (complex).
// For now, we rely on the eBPF monitor to detect syscall failures.
if opts.Debug && opts.Monitor && features.SeccompLogLevel >= 1 {
fmt.Fprintf(os.Stderr, "[fence:linux] Note: seccomp violations are blocked but not logged (SECCOMP_RET_ERRNO is silent)\n")
}
// Start eBPF monitor if available and requested
// This monitors syscalls that return EACCES/EPERM for sandbox descendants
if opts.Monitor && opts.UseEBPF && features.HasEBPF {
ebpfMon := NewEBPFMonitor(pid, opts.Debug)
if err := ebpfMon.Start(); err != nil {
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] Failed to start eBPF monitor: %v\n", err)
}
} else {
monitors.EBPFMonitor = ebpfMon
if opts.Debug {
fmt.Fprintf(os.Stderr, "[fence:linux] eBPF monitor started for PID %d\n", pid)
}
}
} else if opts.Monitor && opts.Debug {
if !features.HasEBPF {
fmt.Fprintf(os.Stderr, "[fence:linux] eBPF monitoring not available (need CAP_BPF or root)\n")
}
}
return monitors, nil
}
// LinuxMonitors holds all active monitors for a Linux sandbox.
type LinuxMonitors struct {
EBPFMonitor *EBPFMonitor
}
// Stop stops all monitors.
func (m *LinuxMonitors) Stop() {
if m.EBPFMonitor != nil {
m.EBPFMonitor.Stop()
}
}
// PrintLinuxFeatures prints available Linux sandbox features.
func PrintLinuxFeatures() {
features := DetectLinuxFeatures()
fmt.Printf("Linux Sandbox Features:\n")
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(" 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)
fmt.Printf("\nFeature Status:\n")
if features.MinimumViable() {
fmt.Printf(" ✓ Minimum requirements met (bwrap + socat)\n")
} else {
fmt.Printf(" ✗ Missing requirements: ")
if !features.HasBwrap {
fmt.Printf("bwrap ")
}
if !features.HasSocat {
fmt.Printf("socat ")
}
fmt.Println()
}
if features.CanUseLandlock() {
fmt.Printf(" ✓ Landlock available for enhanced filesystem control\n")
} else {
fmt.Printf(" ○ Landlock not available (kernel 5.13+ required)\n")
}
if features.CanMonitorViolations() {
fmt.Printf(" ✓ Violation monitoring available\n")
} else {
fmt.Printf(" ○ Violation monitoring limited (kernel 4.14+ for seccomp logging)\n")
}
if features.HasEBPF {
fmt.Printf(" ✓ eBPF monitoring available (enhanced visibility)\n")
} else {
fmt.Printf(" ○ eBPF monitoring not available (needs CAP_BPF or root)\n")
}
}

View File

@@ -0,0 +1,336 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// EBPFMonitor monitors sandbox violations using eBPF tracing.
// This requires CAP_BPF or root privileges.
type EBPFMonitor struct {
pid int
debug bool
cancel context.CancelFunc
running bool
cmd *exec.Cmd
scriptPath string // Path to bpftrace script (for cleanup)
}
// NewEBPFMonitor creates a new eBPF-based violation monitor.
func NewEBPFMonitor(pid int, debug bool) *EBPFMonitor {
return &EBPFMonitor{
pid: pid,
debug: debug,
}
}
// Start begins eBPF-based monitoring of filesystem and network violations.
func (m *EBPFMonitor) Start() error {
features := DetectLinuxFeatures()
if !features.HasEBPF {
if m.debug {
fmt.Fprintf(os.Stderr, "[fence:ebpf] eBPF monitoring not available (need CAP_BPF or root)\n")
}
return nil
}
ctx, cancel := context.WithCancel(context.Background())
m.cancel = cancel
m.running = true
// Try multiple eBPF tracing approaches
if err := m.tryBpftrace(ctx); err != nil {
if m.debug {
fmt.Fprintf(os.Stderr, "[fence:ebpf] bpftrace not available: %v\n", err)
}
// Fall back to other methods
go m.traceWithPerfEvents()
}
if m.debug {
fmt.Fprintf(os.Stderr, "[fence:ebpf] Started eBPF monitoring for PID %d\n", m.pid)
}
return nil
}
// Stop stops the eBPF monitor.
func (m *EBPFMonitor) Stop() {
if !m.running {
return
}
// Give a moment for pending events
time.Sleep(200 * time.Millisecond)
if m.cancel != nil {
m.cancel()
}
if m.cmd != nil && m.cmd.Process != nil {
_ = m.cmd.Process.Kill()
_ = m.cmd.Wait()
}
// Clean up the script file
if m.scriptPath != "" {
os.Remove(m.scriptPath)
}
m.running = false
}
// tryBpftrace attempts to use bpftrace for monitoring.
func (m *EBPFMonitor) tryBpftrace(ctx context.Context) error {
bpftracePath, err := exec.LookPath("bpftrace")
if err != nil {
return fmt.Errorf("bpftrace not found: %w", err)
}
// Create a bpftrace script that monitors file operations and network syscalls
script := m.generateBpftraceScript()
// Write script to temp file
tmpFile, err := os.CreateTemp("", "fence-ebpf-*.bt")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
scriptPath := tmpFile.Name()
m.scriptPath = scriptPath // Store for cleanup later
if _, err := tmpFile.WriteString(script); err != nil {
tmpFile.Close()
os.Remove(scriptPath)
return fmt.Errorf("failed to write script: %w", err)
}
tmpFile.Close()
m.cmd = exec.CommandContext(ctx, bpftracePath, tmpFile.Name())
stdout, err := m.cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("failed to create pipe: %w", err)
}
stderr, err := m.cmd.StderrPipe()
if err != nil {
return fmt.Errorf("failed to create stderr pipe: %w", err)
}
if err := m.cmd.Start(); err != nil {
return fmt.Errorf("failed to start bpftrace: %w", err)
}
// Parse bpftrace output in background
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
if m.debug {
fmt.Fprintf(os.Stderr, "[fence:ebpf:trace] %s\n", line)
}
if violation := m.parseBpftraceOutput(line); violation != "" {
fmt.Fprintf(os.Stderr, "%s\n", violation)
}
}
}()
// Also show stderr in debug mode
if m.debug {
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintf(os.Stderr, "[fence:ebpf:err] %s\n", line)
}
}()
}
return nil
}
// generateBpftraceScript generates a bpftrace script for monitoring.
// The script filters events to only show processes that are descendants of the sandbox.
func (m *EBPFMonitor) generateBpftraceScript() string {
// This script traces syscalls that return EACCES or EPERM
// It tracks the sandbox PID and its descendants using a map
//
// Note: bpftrace can't directly check process ancestry, so we track
// child PIDs via fork/clone and check against the tracked set.
// Filter by PID range: only show events from processes spawned after the sandbox started
// This isn't perfect but filters out pre-existing system processes
// PID tracking via fork doesn't work because bpftrace attaches after the command starts
script := fmt.Sprintf(`
BEGIN
{
printf("fence:ebpf monitoring started for sandbox PID %%d (filtering pid >= %%d)\n", %d, %d);
}
// Monitor filesystem errors (EPERM=-1, EACCES=-13, EROFS=-30)
// Filter: pid >= SANDBOX_PID to exclude pre-existing processes
tracepoint:syscalls:sys_exit_openat
/(args->ret == -13 || args->ret == -1 || args->ret == -30) && pid >= %d/
{
printf("DENIED:open pid=%%d comm=%%s ret=%%d\n", pid, comm, args->ret);
}
tracepoint:syscalls:sys_exit_unlinkat
/(args->ret == -13 || args->ret == -1 || args->ret == -30) && pid >= %d/
{
printf("DENIED:unlink pid=%%d comm=%%s ret=%%d\n", pid, comm, args->ret);
}
tracepoint:syscalls:sys_exit_mkdirat
/(args->ret == -13 || args->ret == -1 || args->ret == -30) && pid >= %d/
{
printf("DENIED:mkdir pid=%%d comm=%%s ret=%%d\n", pid, comm, args->ret);
}
tracepoint:syscalls:sys_exit_connect
/(args->ret == -13 || args->ret == -1 || args->ret == -111) && pid >= %d/
{
printf("DENIED:connect pid=%%d comm=%%s ret=%%d\n", pid, comm, args->ret);
}
`, m.pid, m.pid, m.pid, m.pid, m.pid, m.pid)
return script
}
// parseBpftraceOutput parses bpftrace output and formats violations.
func (m *EBPFMonitor) parseBpftraceOutput(line string) string {
if !strings.HasPrefix(line, "DENIED:") {
return ""
}
// Parse: DENIED:syscall pid=X comm=Y ret=Z
pattern := regexp.MustCompile(`DENIED:(\w+) pid=(\d+) comm=(\S+) ret=(-?\d+)`)
matches := pattern.FindStringSubmatch(line)
if matches == nil {
return ""
}
syscall := matches[1]
pid, _ := strconv.Atoi(matches[2])
comm := matches[3]
ret, _ := strconv.Atoi(matches[4])
// Format the violation
errorName := getErrnoName(ret)
timestamp := time.Now().Format("15:04:05")
return fmt.Sprintf("[fence:ebpf] %s ✗ %s: %s (%s, pid=%d)",
timestamp, syscall, errorName, comm, pid)
}
// traceWithPerfEvents uses perf events for tracing (fallback when bpftrace unavailable).
func (m *EBPFMonitor) traceWithPerfEvents() {
// This is a fallback that uses the audit subsystem or trace-cmd
// For now, we'll just monitor the trace pipe if available
tracePipe := "/sys/kernel/debug/tracing/trace_pipe"
if _, err := os.Stat(tracePipe); err != nil {
if m.debug {
fmt.Fprintf(os.Stderr, "[fence:ebpf] trace_pipe not available\n")
}
return
}
f, err := os.Open(tracePipe)
if err != nil {
if m.debug {
fmt.Fprintf(os.Stderr, "[fence:ebpf] Failed to open trace_pipe: %v\n", err)
}
return
}
defer f.Close()
// We'd need to set up tracepoints first, which requires additional setup
// For now, this is a placeholder for the full implementation
}
// getErrnoName returns a human-readable description of an errno value.
func getErrnoName(errno int) string {
names := map[int]string{
-1: "Operation not permitted",
-2: "No such file",
-13: "Permission denied",
-17: "File exists",
-20: "Not a directory",
-21: "Is a directory",
-30: "Read-only file system",
-22: "Invalid argument",
-111: "Connection refused",
}
if name, ok := names[errno]; ok {
return name
}
return fmt.Sprintf("errno=%d", errno)
}
// IsEBPFAvailable checks if eBPF monitoring can be used.
func IsEBPFAvailable() bool {
features := DetectLinuxFeatures()
return features.HasEBPF
}
// RequiredCapabilities returns the capabilities needed for eBPF monitoring.
func RequiredCapabilities() []string {
return []string{"CAP_BPF", "CAP_PERFMON"}
}
// CheckBpftraceAvailable checks if bpftrace is installed and usable.
func CheckBpftraceAvailable() bool {
path, err := exec.LookPath("bpftrace")
if err != nil {
return false
}
// Verify it can run (needs permissions)
cmd := exec.Command(path, "--version")
return cmd.Run() == nil
}
// ViolationEvent represents a sandbox violation detected by eBPF.
type ViolationEvent struct {
Timestamp time.Time
Type string // "file", "network", "syscall"
Operation string // "open", "write", "connect", etc.
Path string
PID int
Comm string // Process name
Errno int
}
// FormatViolation formats a violation event for display.
func (v *ViolationEvent) FormatViolation() string {
timestamp := v.Timestamp.Format("15:04:05")
errName := getErrnoName(-v.Errno)
if v.Path != "" {
return fmt.Sprintf("[fence:ebpf] %s ✗ %s: %s (%s, %s:%d)",
timestamp, v.Operation, v.Path, errName, v.Comm, v.PID)
}
return fmt.Sprintf("[fence:ebpf] %s ✗ %s: %s (%s:%d)",
timestamp, v.Operation, errName, v.Comm, v.PID)
}
// EnsureTracingSetup ensures the kernel tracing infrastructure is available.
func EnsureTracingSetup() error {
// Check if debugfs is mounted
debugfs := "/sys/kernel/debug"
if _, err := os.Stat(filepath.Join(debugfs, "tracing")); os.IsNotExist(err) {
return fmt.Errorf("debugfs tracing not available at %s/tracing", debugfs)
}
return nil
}

View File

@@ -0,0 +1,46 @@
//go:build !linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import "time"
// EBPFMonitor is a stub for non-Linux platforms.
type EBPFMonitor struct{}
// NewEBPFMonitor creates a stub monitor.
func NewEBPFMonitor(pid int, debug bool) *EBPFMonitor {
return &EBPFMonitor{}
}
// Start is a no-op on non-Linux platforms.
func (m *EBPFMonitor) Start() error { return nil }
// Stop is a no-op on non-Linux platforms.
func (m *EBPFMonitor) Stop() {}
// IsEBPFAvailable returns false on non-Linux platforms.
func IsEBPFAvailable() bool { return false }
// RequiredCapabilities returns empty on non-Linux platforms.
func RequiredCapabilities() []string { return nil }
// CheckBpftraceAvailable returns false on non-Linux platforms.
func CheckBpftraceAvailable() bool { return false }
// ViolationEvent is a stub for non-Linux platforms.
type ViolationEvent struct {
Timestamp time.Time
Type string
Operation string
Path string
PID int
Comm string
Errno int
}
// FormatViolation returns empty on non-Linux platforms.
func (v *ViolationEvent) FormatViolation() string { return "" }
// EnsureTracingSetup returns nil on non-Linux platforms.
func EnsureTracingSetup() error { return nil }

View File

@@ -0,0 +1,284 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"unsafe"
"golang.org/x/sys/unix"
)
// LinuxFeatures describes available Linux sandboxing features.
type LinuxFeatures struct {
// Core dependencies
HasBwrap bool
HasSocat bool
// Kernel features
HasSeccomp bool
SeccompLogLevel int // 0=none, 1=LOG, 2=USER_NOTIF
HasLandlock bool
LandlockABI int // 0=none, 1-4 = ABI version
// eBPF capabilities (requires CAP_BPF or root)
HasEBPF bool
HasCapBPF bool
HasCapRoot bool
// Kernel version
KernelMajor int
KernelMinor int
}
var (
detectedFeatures *LinuxFeatures
detectOnce sync.Once
)
// DetectLinuxFeatures checks what sandboxing features are available.
// Results are cached for subsequent calls.
func DetectLinuxFeatures() *LinuxFeatures {
detectOnce.Do(func() {
detectedFeatures = &LinuxFeatures{}
detectedFeatures.detect()
})
return detectedFeatures
}
func (f *LinuxFeatures) detect() {
// Check for bwrap and socat
f.HasBwrap = commandExists("bwrap")
f.HasSocat = commandExists("socat")
// Parse kernel version
f.parseKernelVersion()
// Check seccomp support
f.detectSeccomp()
// Check Landlock support
f.detectLandlock()
// Check eBPF capabilities
f.detectEBPF()
}
func (f *LinuxFeatures) parseKernelVersion() {
var uname unix.Utsname
if err := unix.Uname(&uname); err != nil {
return
}
release := unix.ByteSliceToString(uname.Release[:])
parts := strings.Split(release, ".")
if len(parts) >= 2 {
f.KernelMajor, _ = strconv.Atoi(parts[0])
// Handle versions like "6.2.0-39-generic"
minorStr := strings.Split(parts[1], "-")[0]
f.KernelMinor, _ = strconv.Atoi(minorStr)
}
}
func (f *LinuxFeatures) detectSeccomp() {
// Check if seccomp is supported via prctl
// PR_GET_SECCOMP returns 0 if seccomp is disabled, 1/2 if enabled, -1 on error
_, _, err := unix.Syscall(unix.SYS_PRCTL, unix.PR_GET_SECCOMP, 0, 0)
if err == 0 || err == unix.EINVAL {
// EINVAL means seccomp is supported but not enabled for this process
f.HasSeccomp = true
}
// SECCOMP_RET_LOG available since kernel 4.14
if f.KernelMajor > 4 || (f.KernelMajor == 4 && f.KernelMinor >= 14) {
f.SeccompLogLevel = 1
}
// SECCOMP_RET_USER_NOTIF available since kernel 5.0
if f.KernelMajor >= 5 {
f.SeccompLogLevel = 2
}
}
func (f *LinuxFeatures) detectLandlock() {
// Landlock available since kernel 5.13
if f.KernelMajor < 5 || (f.KernelMajor == 5 && f.KernelMinor < 13) {
return
}
// Try to query the Landlock ABI version using Landlock syscall
// landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)
// Returns the highest supported ABI version on success
ret, _, err := unix.Syscall(
unix.SYS_LANDLOCK_CREATE_RULESET,
0, // NULL attr to query ABI version
0, // size = 0
uintptr(LANDLOCK_CREATE_RULESET_VERSION),
)
// Check if syscall succeeded (errno == 0)
// ret contains the ABI version number (1, 2, 3, 4, etc.)
if err == 0 {
f.HasLandlock = true
f.LandlockABI = int(ret)
return
}
// Fallback: try creating an actual ruleset (for older detection methods)
attr := landlockRulesetAttr{
handledAccessFS: LANDLOCK_ACCESS_FS_READ_FILE,
}
ret, _, err = unix.Syscall(
unix.SYS_LANDLOCK_CREATE_RULESET,
uintptr(unsafe.Pointer(&attr)),
unsafe.Sizeof(attr),
0,
)
if err == 0 {
f.HasLandlock = true
f.LandlockABI = 1 // Minimum supported version
_ = unix.Close(int(ret))
}
}
func (f *LinuxFeatures) detectEBPF() {
// Check if we have CAP_BPF or CAP_SYS_ADMIN (root)
f.HasCapRoot = os.Geteuid() == 0
// Try to check CAP_BPF capability
if f.HasCapRoot {
f.HasCapBPF = true
f.HasEBPF = true
return
}
// Check if user has CAP_BPF via /proc/self/status
data, err := os.ReadFile("/proc/self/status")
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "CapEff:") {
// Parse effective capabilities
fields := strings.Fields(line)
if len(fields) >= 2 {
caps, err := strconv.ParseUint(fields[1], 16, 64)
if err == nil {
// CAP_BPF is bit 39
const CAP_BPF = 39
if caps&(1<<CAP_BPF) != 0 {
f.HasCapBPF = true
f.HasEBPF = true
}
}
}
break
}
}
}
// Summary returns a human-readable summary of available features.
func (f *LinuxFeatures) Summary() string {
var parts []string
parts = append(parts, fmt.Sprintf("kernel %d.%d", f.KernelMajor, f.KernelMinor))
if f.HasBwrap {
parts = append(parts, "bwrap")
}
if f.HasSeccomp {
switch f.SeccompLogLevel {
case 2:
parts = append(parts, "seccomp+usernotif")
case 1:
parts = append(parts, "seccomp+log")
default:
parts = append(parts, "seccomp")
}
}
if f.HasLandlock {
parts = append(parts, fmt.Sprintf("landlock-v%d", f.LandlockABI))
}
if f.HasEBPF {
if f.HasCapRoot {
parts = append(parts, "ebpf(root)")
} else {
parts = append(parts, "ebpf(CAP_BPF)")
}
}
return strings.Join(parts, ", ")
}
// CanMonitorViolations returns true if we can monitor sandbox violations.
func (f *LinuxFeatures) CanMonitorViolations() bool {
// seccomp LOG requires kernel 4.14+
// eBPF monitoring requires CAP_BPF or root
return f.SeccompLogLevel >= 1 || f.HasEBPF
}
// CanUseLandlock returns true if Landlock is available.
func (f *LinuxFeatures) CanUseLandlock() bool {
return f.HasLandlock && f.LandlockABI >= 1
}
// MinimumViable returns true if the minimum required features are available.
func (f *LinuxFeatures) MinimumViable() bool {
return f.HasBwrap && f.HasSocat
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// Landlock constants
const (
LANDLOCK_CREATE_RULESET_VERSION = 1 << 0
// Filesystem access rights (ABI v1+)
LANDLOCK_ACCESS_FS_EXECUTE = 1 << 0
LANDLOCK_ACCESS_FS_WRITE_FILE = 1 << 1
LANDLOCK_ACCESS_FS_READ_FILE = 1 << 2
LANDLOCK_ACCESS_FS_READ_DIR = 1 << 3
LANDLOCK_ACCESS_FS_REMOVE_DIR = 1 << 4
LANDLOCK_ACCESS_FS_REMOVE_FILE = 1 << 5
LANDLOCK_ACCESS_FS_MAKE_CHAR = 1 << 6
LANDLOCK_ACCESS_FS_MAKE_DIR = 1 << 7
LANDLOCK_ACCESS_FS_MAKE_REG = 1 << 8
LANDLOCK_ACCESS_FS_MAKE_SOCK = 1 << 9
LANDLOCK_ACCESS_FS_MAKE_FIFO = 1 << 10
LANDLOCK_ACCESS_FS_MAKE_BLOCK = 1 << 11
LANDLOCK_ACCESS_FS_MAKE_SYM = 1 << 12
LANDLOCK_ACCESS_FS_REFER = 1 << 13 // ABI v2
LANDLOCK_ACCESS_FS_TRUNCATE = 1 << 14 // ABI v3
LANDLOCK_ACCESS_FS_IOCTL_DEV = 1 << 15 // ABI v5
// Network access rights (ABI v4+)
LANDLOCK_ACCESS_NET_BIND_TCP = 1 << 0
LANDLOCK_ACCESS_NET_CONNECT_TCP = 1 << 1
// Rule types
LANDLOCK_RULE_PATH_BENEATH = 1
LANDLOCK_RULE_NET_PORT = 2
)
// landlockRulesetAttr is the Landlock ruleset attribute structure
type landlockRulesetAttr struct {
handledAccessFS uint64
handledAccessNet uint64
}
// landlockPathBeneathAttr is used to add path-based rules
type landlockPathBeneathAttr struct {
allowedAccess uint64
parentFd int32
_ [4]byte // padding
}

View File

@@ -0,0 +1,45 @@
//go:build !linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
// LinuxFeatures describes available Linux sandboxing features.
// This is a stub for non-Linux platforms.
type LinuxFeatures struct {
HasBwrap bool
HasSocat bool
HasSeccomp bool
SeccompLogLevel int
HasLandlock bool
LandlockABI int
HasEBPF bool
HasCapBPF bool
HasCapRoot bool
KernelMajor int
KernelMinor int
}
// DetectLinuxFeatures returns empty features on non-Linux platforms.
func DetectLinuxFeatures() *LinuxFeatures {
return &LinuxFeatures{}
}
// Summary returns an empty string on non-Linux platforms.
func (f *LinuxFeatures) Summary() string {
return "not linux"
}
// CanMonitorViolations returns false on non-Linux platforms.
func (f *LinuxFeatures) CanMonitorViolations() bool {
return false
}
// CanUseLandlock returns false on non-Linux platforms.
func (f *LinuxFeatures) CanUseLandlock() bool {
return false
}
// MinimumViable returns false on non-Linux platforms.
func (f *LinuxFeatures) MinimumViable() bool {
return false
}

View File

@@ -0,0 +1,532 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"unsafe"
"github.com/Use-Tusk/fence/internal/config"
"github.com/bmatcuk/doublestar/v4"
"golang.org/x/sys/unix"
)
// ApplyLandlockFromConfig creates and applies Landlock restrictions based on config.
// This should be called before exec'ing the sandboxed command.
// Returns nil if Landlock is not available (graceful fallback).
func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []string, debug bool) error {
features := DetectLinuxFeatures()
if !features.CanUseLandlock() {
if debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Not available (kernel %d.%d < 5.13), skipping\n",
features.KernelMajor, features.KernelMinor)
}
return nil // Graceful fallback - Landlock not available
}
ruleset, err := NewLandlockRuleset(debug)
if err != nil {
if debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to create ruleset: %v\n", err)
}
return nil // Graceful fallback
}
defer ruleset.Close()
if err := ruleset.Initialize(); err != nil {
if debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to initialize: %v\n", err)
}
return nil // Graceful fallback
}
// Essential system paths - allow read+execute
systemReadPaths := []string{
"/usr",
"/lib",
"/lib64",
"/lib32",
"/bin",
"/sbin",
"/etc",
"/proc",
"/dev",
"/sys",
"/run",
"/var/lib",
"/var/cache",
}
for _, p := range systemReadPaths {
if err := ruleset.AllowRead(p); err != nil && debug {
// Ignore errors for paths that don't exist
if !os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add read path %s: %v\n", p, err)
}
}
}
// Current working directory - read access (may be upgraded to write below)
if cwd != "" {
if err := ruleset.AllowRead(cwd); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add cwd read path: %v\n", err)
}
}
// Home directory - read access
if home, err := os.UserHomeDir(); err == nil {
if err := ruleset.AllowRead(home); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add home read path: %v\n", err)
}
}
// /tmp - allow read+write (many programs need this)
if err := ruleset.AllowReadWrite("/tmp"); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add /tmp write path: %v\n", err)
}
// Socket paths for proxy communication
for _, p := range socketPaths {
dir := filepath.Dir(p)
if err := ruleset.AllowReadWrite(dir); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add socket path %s: %v\n", dir, err)
}
}
// User-configured allowWrite paths
if cfg != nil && cfg.Filesystem.AllowWrite != nil {
expandedPaths := ExpandGlobPatterns(cfg.Filesystem.AllowWrite)
for _, p := range expandedPaths {
if err := ruleset.AllowReadWrite(p); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add write path %s: %v\n", p, err)
}
}
// Also add non-glob paths directly
for _, p := range cfg.Filesystem.AllowWrite {
if !ContainsGlobChars(p) {
normalized := NormalizePath(p)
if err := ruleset.AllowReadWrite(normalized); err != nil && debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Warning: failed to add write path %s: %v\n", normalized, err)
}
}
}
}
// Apply the ruleset
if err := ruleset.Apply(); err != nil {
if debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to apply: %v\n", err)
}
return nil // Graceful fallback
}
if debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Applied restrictions (ABI v%d)\n", features.LandlockABI)
}
return nil
}
// LandlockRuleset manages Landlock filesystem restrictions.
type LandlockRuleset struct {
rulesetFd int
abiVersion int
debug bool
initialized bool
readPaths map[string]bool
writePaths map[string]bool
denyPaths map[string]bool
}
// NewLandlockRuleset creates a new Landlock ruleset.
func NewLandlockRuleset(debug bool) (*LandlockRuleset, error) {
features := DetectLinuxFeatures()
if !features.CanUseLandlock() {
return nil, fmt.Errorf("Landlock not available (kernel %d.%d, need 5.13+)",
features.KernelMajor, features.KernelMinor)
}
return &LandlockRuleset{
rulesetFd: -1,
abiVersion: features.LandlockABI,
debug: debug,
readPaths: make(map[string]bool),
writePaths: make(map[string]bool),
denyPaths: make(map[string]bool),
}, nil
}
// Initialize creates the Landlock ruleset.
func (l *LandlockRuleset) Initialize() error {
if l.initialized {
return nil
}
// Determine which access rights to handle based on ABI version
fsAccess := l.getHandledAccessFS()
attr := landlockRulesetAttr{
handledAccessFS: fsAccess,
}
// Note: We do NOT enable Landlock network restrictions (handledAccessNet)
// because:
// 1. Network isolation is already handled by bwrap's network namespace
// 2. Enabling network restrictions without proper allow rules would break
// the sandbox's proxy connections
// 3. The proxy architecture requires localhost connections which would
// need complex rule management
fd, _, err := unix.Syscall(
unix.SYS_LANDLOCK_CREATE_RULESET,
uintptr(unsafe.Pointer(&attr)),
unsafe.Sizeof(attr),
0,
)
if err != 0 {
return fmt.Errorf("failed to create Landlock ruleset: %w", err)
}
l.rulesetFd = int(fd)
l.initialized = true
if l.debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Created ruleset (ABI v%d, fd=%d)\n", l.abiVersion, l.rulesetFd)
}
return nil
}
// getHandledAccessFS returns the filesystem access rights to handle.
func (l *LandlockRuleset) getHandledAccessFS() uint64 {
// Base access rights (ABI v1)
access := uint64(
LANDLOCK_ACCESS_FS_EXECUTE |
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_READ_FILE |
LANDLOCK_ACCESS_FS_READ_DIR |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_CHAR |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK |
LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_SYM,
)
// ABI v2: add REFER (cross-directory renames)
if l.abiVersion >= 2 {
access |= LANDLOCK_ACCESS_FS_REFER
}
// ABI v3: add TRUNCATE
if l.abiVersion >= 3 {
access |= LANDLOCK_ACCESS_FS_TRUNCATE
}
// ABI v5: add IOCTL_DEV
if l.abiVersion >= 5 {
access |= LANDLOCK_ACCESS_FS_IOCTL_DEV
}
return access
}
// AllowRead adds read access to a path.
func (l *LandlockRuleset) AllowRead(path string) error {
return l.addPathRule(path, LANDLOCK_ACCESS_FS_READ_FILE|LANDLOCK_ACCESS_FS_READ_DIR|LANDLOCK_ACCESS_FS_EXECUTE)
}
// AllowWrite adds write access to a path.
func (l *LandlockRuleset) AllowWrite(path string) error {
access := uint64(
LANDLOCK_ACCESS_FS_WRITE_FILE |
LANDLOCK_ACCESS_FS_REMOVE_DIR |
LANDLOCK_ACCESS_FS_REMOVE_FILE |
LANDLOCK_ACCESS_FS_MAKE_CHAR |
LANDLOCK_ACCESS_FS_MAKE_DIR |
LANDLOCK_ACCESS_FS_MAKE_REG |
LANDLOCK_ACCESS_FS_MAKE_SOCK |
LANDLOCK_ACCESS_FS_MAKE_FIFO |
LANDLOCK_ACCESS_FS_MAKE_BLOCK |
LANDLOCK_ACCESS_FS_MAKE_SYM,
)
// Add REFER for ABI v2+
if l.abiVersion >= 2 {
access |= LANDLOCK_ACCESS_FS_REFER
}
// Add TRUNCATE for ABI v3+
if l.abiVersion >= 3 {
access |= LANDLOCK_ACCESS_FS_TRUNCATE
}
return l.addPathRule(path, access)
}
// AllowReadWrite adds full read/write access to a path.
func (l *LandlockRuleset) AllowReadWrite(path string) error {
if err := l.AllowRead(path); err != nil {
return err
}
return l.AllowWrite(path)
}
// addPathRule adds a rule for a specific path.
func (l *LandlockRuleset) addPathRule(path string, access uint64) error {
if !l.initialized {
if err := l.Initialize(); err != nil {
return err
}
}
// Resolve symlinks and get absolute path
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("failed to get absolute path for %s: %w", path, err)
}
// Try to resolve symlinks, but don't fail if the path doesn't exist
if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
absPath = resolved
}
// Check if path exists
if _, err := os.Stat(absPath); os.IsNotExist(err) {
if l.debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Skipping non-existent path: %s\n", absPath)
}
return nil
}
// Open the path with O_PATH
fd, err := unix.Open(absPath, unix.O_PATH|unix.O_CLOEXEC, 0)
if err != nil {
if l.debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Failed to open path %s: %v\n", absPath, err)
}
return nil // Don't fail on paths we can't access
}
defer unix.Close(fd)
// Intersect with handled access to avoid invalid combinations
access &= l.getHandledAccessFS()
attr := landlockPathBeneathAttr{
allowedAccess: access,
parentFd: int32(fd),
}
_, _, errno := unix.Syscall(
unix.SYS_LANDLOCK_ADD_RULE,
uintptr(l.rulesetFd),
LANDLOCK_RULE_PATH_BENEATH,
uintptr(unsafe.Pointer(&attr)),
)
if errno != 0 {
return fmt.Errorf("failed to add Landlock rule for %s: %w", absPath, errno)
}
if l.debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Added rule: %s (access=0x%x)\n", absPath, access)
}
return nil
}
// Apply applies the Landlock ruleset to the current process.
func (l *LandlockRuleset) Apply() error {
if !l.initialized {
return fmt.Errorf("Landlock ruleset not initialized")
}
// Set NO_NEW_PRIVS first (required for Landlock)
if err := unix.Prctl(unix.PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); err != nil {
return fmt.Errorf("failed to set NO_NEW_PRIVS: %w", err)
}
// Apply the ruleset
_, _, errno := unix.Syscall(
unix.SYS_LANDLOCK_RESTRICT_SELF,
uintptr(l.rulesetFd),
0,
0,
)
if errno != 0 {
return fmt.Errorf("failed to apply Landlock ruleset: %w", errno)
}
if l.debug {
fmt.Fprintf(os.Stderr, "[fence:landlock] Ruleset applied to process\n")
}
return nil
}
// Close closes the ruleset file descriptor.
func (l *LandlockRuleset) Close() error {
if l.rulesetFd >= 0 {
err := unix.Close(l.rulesetFd)
l.rulesetFd = -1
return err
}
return nil
}
// ExpandGlobPatterns expands glob patterns to actual paths for Landlock rules.
// Optimized for Landlock's PATH_BENEATH semantics:
// - "dir/**" → returns just "dir" (Landlock covers descendants automatically)
// - "**/pattern" → scoped to cwd only, skips already-covered directories
// - "**/dir/**" → finds dirs in cwd, returns them (PATH_BENEATH covers contents)
func ExpandGlobPatterns(patterns []string) []string {
var expanded []string
seen := make(map[string]bool)
cwd, err := os.Getwd()
if err != nil {
cwd = "."
}
// First pass: collect directories covered by "dir/**" patterns
// These will be skipped when walking for "**/pattern" patterns
coveredDirs := make(map[string]bool)
for _, pattern := range patterns {
if !ContainsGlobChars(pattern) {
continue
}
pattern = NormalizePath(pattern)
if strings.HasSuffix(pattern, "/**") && !strings.Contains(strings.TrimSuffix(pattern, "/**"), "**") {
dir := strings.TrimSuffix(pattern, "/**")
if !strings.HasPrefix(dir, "/") {
dir = filepath.Join(cwd, dir)
}
// Store relative path for matching during walk
relDir, err := filepath.Rel(cwd, dir)
if err == nil {
coveredDirs[relDir] = true
}
}
}
for _, pattern := range patterns {
if !ContainsGlobChars(pattern) {
// Not a glob, use as-is
normalized := NormalizePath(pattern)
if !seen[normalized] {
seen[normalized] = true
expanded = append(expanded, normalized)
}
continue
}
// Normalize pattern
pattern = NormalizePath(pattern)
// Case 1: "dir/**" - just return the dir (PATH_BENEATH handles descendants)
// This avoids walking the directory entirely
if strings.HasSuffix(pattern, "/**") && !strings.Contains(strings.TrimSuffix(pattern, "/**"), "**") {
dir := strings.TrimSuffix(pattern, "/**")
if !strings.HasPrefix(dir, "/") {
dir = filepath.Join(cwd, dir)
}
if !seen[dir] {
seen[dir] = true
expanded = append(expanded, dir)
}
continue
}
// Case 2: "**/pattern" or "**/dir/**" - scope to cwd only
// Skip directories already covered by dir/** patterns
if strings.HasPrefix(pattern, "**/") {
// Extract what we're looking for after the **/
suffix := strings.TrimPrefix(pattern, "**/")
// If it ends with /**, we're looking for directories
isDir := strings.HasSuffix(suffix, "/**")
if isDir {
suffix = strings.TrimSuffix(suffix, "/**")
}
// Walk cwd looking for matches, skipping covered directories
fsys := os.DirFS(cwd)
searchPattern := "**/" + suffix
err := doublestar.GlobWalk(fsys, searchPattern, func(path string, d fs.DirEntry) error {
// Skip directories that are already covered by dir/** patterns
// Check each parent directory of the current path
pathParts := strings.Split(path, string(filepath.Separator))
for i := 1; i <= len(pathParts); i++ {
parentPath := strings.Join(pathParts[:i], string(filepath.Separator))
if coveredDirs[parentPath] {
if d.IsDir() {
return fs.SkipDir
}
return nil // Skip this file, it's under a covered dir
}
}
absPath := filepath.Join(cwd, path)
if !seen[absPath] {
seen[absPath] = true
expanded = append(expanded, absPath)
}
return nil
})
if err != nil {
continue
}
continue
}
// Case 3: Other patterns with * but not ** - use standard glob scoped to cwd
if !strings.Contains(pattern, "**") {
var searchBase string
var searchPattern string
if strings.HasPrefix(pattern, "/") {
// Absolute pattern - find the non-glob prefix
parts := strings.Split(pattern, "/")
var baseparts []string
for _, p := range parts {
if ContainsGlobChars(p) {
break
}
baseparts = append(baseparts, p)
}
searchBase = strings.Join(baseparts, "/")
if searchBase == "" {
searchBase = "/"
}
searchPattern = strings.TrimPrefix(pattern, searchBase+"/")
} else {
searchBase = cwd
searchPattern = pattern
}
fsys := os.DirFS(searchBase)
matches, err := doublestar.Glob(fsys, searchPattern)
if err != nil {
continue
}
for _, match := range matches {
absPath := filepath.Join(searchBase, match)
if !seen[absPath] {
seen[absPath] = true
expanded = append(expanded, absPath)
}
}
}
}
return expanded
}

View File

@@ -0,0 +1,47 @@
//go:build !linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import "github.com/Use-Tusk/fence/internal/config"
// ApplyLandlockFromConfig is a no-op on non-Linux platforms.
func ApplyLandlockFromConfig(cfg *config.Config, cwd string, socketPaths []string, debug bool) error {
return nil
}
// LandlockRuleset is a stub for non-Linux platforms.
type LandlockRuleset struct{}
// NewLandlockRuleset returns nil on non-Linux platforms.
func NewLandlockRuleset(debug bool) (*LandlockRuleset, error) {
return nil, nil
}
// Initialize is a no-op on non-Linux platforms.
func (l *LandlockRuleset) Initialize() error { return nil }
// AllowRead is a no-op on non-Linux platforms.
func (l *LandlockRuleset) AllowRead(path string) error { return nil }
// AllowWrite is a no-op on non-Linux platforms.
func (l *LandlockRuleset) AllowWrite(path string) error { return nil }
// AllowReadWrite is a no-op on non-Linux platforms.
func (l *LandlockRuleset) AllowReadWrite(path string) error { return nil }
// Apply is a no-op on non-Linux platforms.
func (l *LandlockRuleset) Apply() error { return nil }
// Close is a no-op on non-Linux platforms.
func (l *LandlockRuleset) Close() error { return nil }
// ExpandGlobPatterns returns the input on non-Linux platforms.
func ExpandGlobPatterns(patterns []string) []string {
return patterns
}
// GenerateLandlockSetupScript returns empty on non-Linux platforms.
func GenerateLandlockSetupScript(allowWrite, denyWrite, denyRead []string, debug bool) string {
return ""
}

View File

@@ -0,0 +1,316 @@
//go:build linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/unix"
)
// SeccompFilter generates and manages seccomp BPF filters.
type SeccompFilter struct {
debug bool
}
// NewSeccompFilter creates a new seccomp filter generator.
func NewSeccompFilter(debug bool) *SeccompFilter {
return &SeccompFilter{debug: debug}
}
// DangerousSyscalls lists syscalls that should be blocked for security.
var DangerousSyscalls = []string{
"ptrace", // Process debugging/injection
"process_vm_readv", // Read another process's memory
"process_vm_writev", // Write another process's memory
"keyctl", // Kernel keyring operations
"add_key", // Add key to keyring
"request_key", // Request key from keyring
"personality", // Change execution domain (can bypass ASLR)
"userfaultfd", // User-space page fault handling (potential sandbox escape)
"perf_event_open", // Performance monitoring (info leak)
"bpf", // eBPF operations (without CAP_BPF)
"kexec_load", // Load new kernel
"kexec_file_load", // Load new kernel from file
"reboot", // Reboot system
"syslog", // Kernel log access
"acct", // Process accounting
"mount", // Mount filesystems
"umount2", // Unmount filesystems
"pivot_root", // Change root filesystem
"swapon", // Enable swap
"swapoff", // Disable swap
"sethostname", // Change hostname
"setdomainname", // Change domain name
"init_module", // Load kernel module
"finit_module", // Load kernel module from file
"delete_module", // Unload kernel module
"ioperm", // I/O port permissions
"iopl", // I/O privilege level
}
// GenerateBPFFilter generates a seccomp-bpf filter that blocks dangerous syscalls.
// Returns the path to the generated BPF filter file.
func (s *SeccompFilter) GenerateBPFFilter() (string, error) {
features := DetectLinuxFeatures()
if !features.HasSeccomp {
return "", fmt.Errorf("seccomp not available on this system")
}
// Create a temporary directory for the filter
tmpDir := filepath.Join(os.TempDir(), "fence-seccomp")
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
return "", fmt.Errorf("failed to create seccomp dir: %w", err)
}
filterPath := filepath.Join(tmpDir, fmt.Sprintf("fence-seccomp-%d.bpf", os.Getpid()))
// Generate the filter using the seccomp library or raw BPF
// For now, we'll use bwrap's built-in seccomp support via --seccomp
// which accepts a file descriptor with a BPF program
// Write a simple seccomp policy using bpf assembly
if err := s.writeBPFProgram(filterPath); err != nil {
return "", fmt.Errorf("failed to write BPF program: %w", err)
}
if s.debug {
fmt.Fprintf(os.Stderr, "[fence:seccomp] Generated BPF filter at %s\n", filterPath)
}
return filterPath, nil
}
// writeBPFProgram writes a BPF program that blocks dangerous syscalls.
// This generates a compact BPF program in the format expected by bwrap --seccomp.
func (s *SeccompFilter) writeBPFProgram(path string) error {
// For bwrap, we need to pass the seccomp filter via file descriptor
// The filter format is: struct sock_filter array
//
// We'll build a simple filter:
// 1. Load syscall number
// 2. For each dangerous syscall: if match, return ERRNO(EPERM) or LOG+ERRNO
// 3. Default: allow
// Get syscall numbers for the current architecture
syscallNums := make(map[string]int)
for _, name := range DangerousSyscalls {
if num, ok := getSyscallNumber(name); ok {
syscallNums[name] = num
}
}
if len(syscallNums) == 0 {
// No syscalls to block (unknown architecture?)
return fmt.Errorf("no syscall numbers found for dangerous syscalls")
}
// Build BPF program
var program []bpfInstruction
// Load syscall number from seccomp_data
// BPF_LD | BPF_W | BPF_ABS: load word from absolute offset
program = append(program, bpfInstruction{
code: BPF_LD | BPF_W | BPF_ABS,
k: 0, // offsetof(struct seccomp_data, nr)
})
// For each dangerous syscall, add a comparison and block
// Note: SECCOMP_RET_ERRNO returns -1 with errno in the low 16 bits
// SECCOMP_RET_LOG means "log and allow" which is NOT what we want
// We use SECCOMP_RET_ERRNO to block with EPERM
action := SECCOMP_RET_ERRNO | (unix.EPERM & 0xFFFF)
for _, name := range DangerousSyscalls {
num, ok := syscallNums[name]
if !ok {
continue
}
// BPF_JMP | BPF_JEQ | BPF_K: if A == K, jump jt else jump jf
program = append(program, bpfInstruction{
code: BPF_JMP | BPF_JEQ | BPF_K,
jt: 0, // if match, go to next instruction (block)
jf: 1, // if not match, skip the block instruction
k: uint32(num),
})
// Return action (block with EPERM)
program = append(program, bpfInstruction{
code: BPF_RET | BPF_K,
k: uint32(action),
})
}
// Default: allow
program = append(program, bpfInstruction{
code: BPF_RET | BPF_K,
k: SECCOMP_RET_ALLOW,
})
// Write the program to file
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
defer f.Close()
for _, inst := range program {
if err := inst.writeTo(f); err != nil {
return err
}
}
return nil
}
// CleanupFilter removes a generated filter file.
func (s *SeccompFilter) CleanupFilter(path string) {
if path != "" {
_ = os.Remove(path)
}
}
// BPF instruction codes
const (
BPF_LD = 0x00
BPF_JMP = 0x05
BPF_RET = 0x06
BPF_W = 0x00
BPF_ABS = 0x20
BPF_JEQ = 0x10
BPF_K = 0x00
)
// Seccomp return values
const (
SECCOMP_RET_ALLOW = 0x7fff0000
SECCOMP_RET_ERRNO = 0x00050000
SECCOMP_RET_LOG = 0x7ffc0000
)
// bpfInstruction represents a single BPF instruction
type bpfInstruction struct {
code uint16
jt uint8
jf uint8
k uint32
}
func (i *bpfInstruction) writeTo(f *os.File) error {
// BPF instruction is 8 bytes: code(2) + jt(1) + jf(1) + k(4)
buf := make([]byte, 8)
buf[0] = byte(i.code)
buf[1] = byte(i.code >> 8)
buf[2] = i.jt
buf[3] = i.jf
buf[4] = byte(i.k)
buf[5] = byte(i.k >> 8)
buf[6] = byte(i.k >> 16)
buf[7] = byte(i.k >> 24)
_, err := f.Write(buf)
return err
}
// getSyscallNumber returns the syscall number for the current architecture.
func getSyscallNumber(name string) (int, bool) {
// Detect architecture using uname
var utsname unix.Utsname
if err := unix.Uname(&utsname); err != nil {
return 0, false
}
// Convert machine to string
machine := string(utsname.Machine[:])
// Trim null bytes
for i, c := range machine {
if c == 0 {
machine = machine[:i]
break
}
}
var syscallMap map[string]int
if machine == "aarch64" || machine == "arm64" {
// ARM64 syscall numbers (from asm-generic/unistd.h)
syscallMap = map[string]int{
"ptrace": 117,
"process_vm_readv": 270,
"process_vm_writev": 271,
"keyctl": 219,
"add_key": 217,
"request_key": 218,
"personality": 92,
"userfaultfd": 282,
"perf_event_open": 241,
"bpf": 280,
"kexec_load": 104,
"kexec_file_load": 294,
"reboot": 142,
"syslog": 116,
"acct": 89,
"mount": 40,
"umount2": 39,
"pivot_root": 41,
"swapon": 224,
"swapoff": 225,
"sethostname": 161,
"setdomainname": 162,
"init_module": 105,
"finit_module": 273,
"delete_module": 106,
// ioperm and iopl don't exist on ARM64
}
} else {
// x86_64 syscall numbers
syscallMap = map[string]int{
"ptrace": 101,
"process_vm_readv": 310,
"process_vm_writev": 311,
"keyctl": 250,
"add_key": 248,
"request_key": 249,
"personality": 135,
"userfaultfd": 323,
"perf_event_open": 298,
"bpf": 321,
"kexec_load": 246,
"kexec_file_load": 320,
"reboot": 169,
"syslog": 103,
"acct": 163,
"mount": 165,
"umount2": 166,
"pivot_root": 155,
"swapon": 167,
"swapoff": 168,
"sethostname": 170,
"setdomainname": 171,
"init_module": 175,
"finit_module": 313,
"delete_module": 176,
"ioperm": 173,
"iopl": 172,
}
}
num, ok := syscallMap[name]
return num, ok
}
// Note: SeccompMonitor was removed because SECCOMP_RET_ERRNO (which we use to block
// syscalls) is completely silent - it doesn't log to dmesg, audit, or anywhere else.
// The monitor code attempted to parse dmesg for seccomp events, but those only appear
// with SECCOMP_RET_LOG (allows the syscall) or SECCOMP_RET_KILL (kills the process).
//
// Alternative approaches considered:
// - SECCOMP_RET_USER_NOTIF: Complex supervisor architecture with latency on every blocked call
// - auditd integration: Requires audit daemon setup and root access
// - SECCOMP_RET_LOG: Logs but doesn't block (defeats the purpose)
//
// The eBPF monitor in linux_ebpf.go now handles syscall failure detection instead,
// which catches EPERM/EACCES errors regardless of their source.

View File

@@ -0,0 +1,25 @@
//go:build !linux
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
// SeccompFilter is a stub for non-Linux platforms.
type SeccompFilter struct {
debug bool
}
// NewSeccompFilter creates a stub seccomp filter.
func NewSeccompFilter(debug bool) *SeccompFilter {
return &SeccompFilter{debug: debug}
}
// GenerateBPFFilter returns an error on non-Linux platforms.
func (s *SeccompFilter) GenerateBPFFilter() (string, error) {
return "", nil
}
// CleanupFilter is a no-op on non-Linux platforms.
func (s *SeccompFilter) CleanupFilter(path string) {}
// DangerousSyscalls is empty on non-Linux platforms.
var DangerousSyscalls []string

View File

@@ -0,0 +1,72 @@
//go:build !linux
package sandbox
import (
"fmt"
"github.com/Use-Tusk/fence/internal/config"
)
// LinuxBridge is a stub for non-Linux platforms.
type LinuxBridge struct {
HTTPSocketPath string
SOCKSSocketPath string
}
// ReverseBridge is a stub for non-Linux platforms.
type ReverseBridge struct {
Ports []int
SocketPaths []string
}
// LinuxSandboxOptions is a stub for non-Linux platforms.
type LinuxSandboxOptions struct {
UseLandlock bool
UseSeccomp bool
UseEBPF bool
Monitor bool
Debug bool
}
// NewLinuxBridge returns an error on non-Linux platforms.
func NewLinuxBridge(httpProxyPort, socksProxyPort int, debug bool) (*LinuxBridge, error) {
return nil, fmt.Errorf("Linux bridge not available on this platform")
}
// Cleanup is a no-op on non-Linux platforms.
func (b *LinuxBridge) Cleanup() {}
// NewReverseBridge returns an error on non-Linux platforms.
func NewReverseBridge(ports []int, debug bool) (*ReverseBridge, error) {
return nil, fmt.Errorf("reverse bridge not available on this platform")
}
// Cleanup is a no-op on non-Linux platforms.
func (b *ReverseBridge) Cleanup() {}
// WrapCommandLinux returns an error on non-Linux platforms.
func WrapCommandLinux(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, debug bool) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform")
}
// WrapCommandLinuxWithOptions returns an error on non-Linux platforms.
func WrapCommandLinuxWithOptions(cfg *config.Config, command string, bridge *LinuxBridge, reverseBridge *ReverseBridge, opts LinuxSandboxOptions) (string, error) {
return "", fmt.Errorf("Linux sandbox not available on this platform")
}
// StartLinuxMonitor returns nil on non-Linux platforms.
func StartLinuxMonitor(pid int, opts LinuxSandboxOptions) (*LinuxMonitors, error) {
return nil, nil
}
// LinuxMonitors is a stub for non-Linux platforms.
type LinuxMonitors struct{}
// Stop is a no-op on non-Linux platforms.
func (m *LinuxMonitors) Stop() {}
// PrintLinuxFeatures prints a message on non-Linux platforms.
func PrintLinuxFeatures() {
fmt.Println("Linux sandbox features are only available on Linux.")
}

View File

@@ -550,29 +550,3 @@ func WrapCommandMacOS(cfg *config.Config, command string, httpPort, socksPort in
return ShellQuote(parts), nil
}
// ShellQuote quotes a slice of strings for shell execution.
func ShellQuote(args []string) string {
var quoted []string
for _, arg := range args {
if needsQuoting(arg) {
quoted = append(quoted, fmt.Sprintf("'%s'", strings.ReplaceAll(arg, "'", "'\\''")))
} else {
quoted = append(quoted, arg)
}
}
return strings.Join(quoted, " ")
}
func needsQuoting(s string) bool {
for _, c := range s {
if c == ' ' || c == '\t' || c == '\n' || c == '"' || c == '\'' ||
c == '\\' || c == '$' || c == '`' || c == '!' || c == '*' ||
c == '?' || c == '[' || c == ']' || c == '(' || c == ')' ||
c == '{' || c == '}' || c == '<' || c == '>' || c == '|' ||
c == '&' || c == ';' || c == '#' {
return true
}
}
return len(s) == 0
}

42
internal/sandbox/shell.go Normal file
View File

@@ -0,0 +1,42 @@
// Package sandbox provides sandboxing functionality for macOS and Linux.
package sandbox
import (
"fmt"
"strings"
)
// ShellQuote quotes a slice of strings for shell execution.
func ShellQuote(args []string) string {
var quoted []string
for _, arg := range args {
if needsQuoting(arg) {
quoted = append(quoted, fmt.Sprintf("'%s'", strings.ReplaceAll(arg, "'", "'\\''")))
} else {
quoted = append(quoted, arg)
}
}
return strings.Join(quoted, " ")
}
// ShellQuoteSingle quotes a single string for shell execution.
func ShellQuoteSingle(s string) string {
if needsQuoting(s) {
return fmt.Sprintf("'%s'", strings.ReplaceAll(s, "'", "'\\''"))
}
return s
}
// needsQuoting returns true if a string contains shell metacharacters.
func needsQuoting(s string) bool {
for _, c := range s {
if c == ' ' || c == '\t' || c == '\n' || c == '"' || c == '\'' ||
c == '\\' || c == '$' || c == '`' || c == '!' || c == '*' ||
c == '?' || c == '[' || c == ']' || c == '(' || c == ')' ||
c == '{' || c == '}' || c == '<' || c == '>' || c == '|' ||
c == '&' || c == ';' || c == '#' {
return true
}
}
return len(s) == 0
}