//go:build linux package sandbox import ( "fmt" "io/fs" "os" "path/filepath" "strings" "unsafe" "gitea.app.monadical.io/monadical/greywall/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, "[greywall: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, "[greywall:landlock] Failed to create ruleset: %v\n", err) } return nil // Graceful fallback } defer func() { _ = ruleset.Close() }() if err := ruleset.Initialize(); err != nil { if debug { fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to initialize: %v\n", err) } return nil // Graceful fallback } // Essential system paths - allow read+execute // Note: /dev is handled separately with read+write for /dev/null, /dev/zero, etc. systemReadPaths := []string{ "/usr", "/lib", "/lib64", "/lib32", "/bin", "/sbin", "/etc", "/proc", "/sys", "/run", "/var/lib", "/var/cache", "/opt", } 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, "[greywall:landlock] Warning: failed to add read path %s: %v\n", p, err) } } } // If /etc/resolv.conf is a cross-mount symlink (e.g., -> /mnt/wsl/resolv.conf // on WSL), Landlock needs a read rule for the resolved target's parent dir, // otherwise following the symlink hits EACCES. if target, err := filepath.EvalSymlinks("/etc/resolv.conf"); err == nil && target != "/etc/resolv.conf" { targetDir := filepath.Dir(target) if err := ruleset.AllowRead(targetDir); err != nil && debug { fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add resolv.conf target dir %s: %v\n", targetDir, 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, "[greywall: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, "[greywall: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, "[greywall:landlock] Warning: failed to add /tmp write path: %v\n", err) } // /dev needs read+write for /dev/null, /dev/zero, /dev/tty, etc. // Landlock doesn't support rules on device files directly, so we allow the whole /dev if err := ruleset.AllowReadWrite("/dev"); err != nil && debug { fmt.Fprintf(os.Stderr, "[greywall:landlock] Warning: failed to add /dev 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, "[greywall: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, "[greywall: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, "[greywall: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, "[greywall:landlock] Failed to apply: %v\n", err) } return nil // Graceful fallback } if debug { fmt.Fprintf(os.Stderr, "[greywall: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)), //nolint:gosec // required for syscall 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, "[greywall: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, "[greywall: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, "[greywall:landlock] Failed to open path %s: %v\n", absPath, err) } return nil // Don't fail on paths we can't access } defer func() { _ = unix.Close(fd) }() // Use fstat on the fd to avoid TOCTOU race between stat and open var stat unix.Stat_t if err := unix.Fstat(fd, &stat); err != nil { if l.debug { fmt.Fprintf(os.Stderr, "[greywall:landlock] Failed to fstat path %s: %v\n", absPath, err) } return nil } isDir := (stat.Mode & unix.S_IFMT) == unix.S_IFDIR // Intersect with handled access to avoid invalid combinations access &= l.getHandledAccessFS() // Filter out directory-only access rights for non-directory paths (files, sockets, devices, etc.). // Landlock returns EINVAL if you try to add MAKE_*, REMOVE_*, READ_DIR, or REFER rights to a non-directory. // See: https://docs.kernel.org/userspace-api/landlock.html // File-compatible rights: EXECUTE, WRITE_FILE, READ_FILE, TRUNCATE // Directory-only rights: READ_DIR, REMOVE_*, MAKE_*, REFER if !isDir { dirOnlyRights := uint64( 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 | LANDLOCK_ACCESS_FS_REFER, ) access &^= dirOnlyRights } if access == 0 { if l.debug { fmt.Fprintf(os.Stderr, "[greywall:landlock] Skipping %s: no applicable access rights\n", absPath) } return nil } attr := landlockPathBeneathAttr{ allowedAccess: access, parentFd: int32(fd), //nolint:gosec // fd from unix.Open fits in int32 } _, _, errno := unix.Syscall( unix.SYS_LANDLOCK_ADD_RULE, uintptr(l.rulesetFd), LANDLOCK_RULE_PATH_BENEATH, uintptr(unsafe.Pointer(&attr)), //nolint:gosec // required for syscall ) if errno != 0 { return fmt.Errorf("failed to add Landlock rule for %s: %w", absPath, errno) } if l.debug { fmt.Fprintf(os.Stderr, "[greywall: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, "[greywall: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 }