Enhance Linux sandbox security features with Landlock, seccomp, and eBPF monitoring
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
336
internal/sandbox/linux_ebpf.go
Normal file
336
internal/sandbox/linux_ebpf.go
Normal 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
|
||||
}
|
||||
46
internal/sandbox/linux_ebpf_stub.go
Normal file
46
internal/sandbox/linux_ebpf_stub.go
Normal 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 }
|
||||
284
internal/sandbox/linux_features.go
Normal file
284
internal/sandbox/linux_features.go
Normal 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
|
||||
}
|
||||
45
internal/sandbox/linux_features_stub.go
Normal file
45
internal/sandbox/linux_features_stub.go
Normal 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
|
||||
}
|
||||
532
internal/sandbox/linux_landlock.go
Normal file
532
internal/sandbox/linux_landlock.go
Normal 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
|
||||
}
|
||||
47
internal/sandbox/linux_landlock_stub.go
Normal file
47
internal/sandbox/linux_landlock_stub.go
Normal 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 ""
|
||||
}
|
||||
316
internal/sandbox/linux_seccomp.go
Normal file
316
internal/sandbox/linux_seccomp.go
Normal 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.
|
||||
25
internal/sandbox/linux_seccomp_stub.go
Normal file
25
internal/sandbox/linux_seccomp_stub.go
Normal 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
|
||||
72
internal/sandbox/linux_stub.go
Normal file
72
internal/sandbox/linux_stub.go
Normal 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.")
|
||||
}
|
||||
@@ -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
42
internal/sandbox/shell.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user