//go:build linux package platform import ( "encoding/binary" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "runtime" "strings" "sync" "sync/atomic" "unsafe" "github.com/safedep/dry/log" "golang.org/x/sys/unix" ) // ioctl constants for seccomp-notify, from Linux kernel UAPI include/uapi/linux/seccomp.h. // These are _IOWR('!', N, struct) values. const ( _SECCOMP_IOCTL_NOTIF_RECV = 0xc0502100 _SECCOMP_IOCTL_NOTIF_SEND = 0xc0182101 ) // seccomp constants available in golang.org/x/sys/unix, aliased here for clarity. // unix.SECCOMP_FILTER_FLAG_NEW_LISTENER = 0x8 // unix.SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV = 0x20 // unix.SECCOMP_USER_NOTIF_FLAG_CONTINUE = 0x1 // unix.SECCOMP_RET_USER_NOTIF = 0x7fc00000 // unix.SECCOMP_RET_ALLOW = 0x7fff0000 // C-layout structs matching kernel seccomp notification structures exactly. type seccompData struct { Nr int32 Arch uint32 InstructionPointer uint64 Args [6]uint64 } type seccompNotification struct { ID uint64 PID uint32 Flags uint32 Data seccompData } type seccompNotifResp struct { ID uint64 Val int64 Error int32 Flags uint32 } // Compile-time size assertions to ensure struct layout matches kernel expectations. var ( _ [unsafe.Sizeof(seccompData{}) - 64]byte _ [unsafe.Sizeof(seccompNotification{}) - 80]byte _ [unsafe.Sizeof(seccompNotifResp{}) - 24]byte ) // denyMode specifies what kind of access should be denied for a path. type denyMode int const ( denyRead denyMode = iota denyWrite denyBoth ) // denyPathEntry pairs a filesystem path with the access mode to deny. type denyPathEntry struct { Path string Mode denyMode } // auditEventType categorizes security audit events. type auditEventType string const ( auditSeccompDeny auditEventType = "seccomp_deny" auditNamespaceUnavailable auditEventType = "namespace_isolation_unavailable" auditMemFdOpenFailed auditEventType = "memfd_open_failed" ) // auditEvent represents a single security audit log entry. type auditEvent struct { Type auditEventType `json:"type"` Syscall string `json:"syscall,omitempty"` Path string `json:"path,omitempty"` PID int `json:"pid,omitempty"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` Ts int64 `json:"ts"` } // writeAuditEvent JSON-encodes an audit event and writes it as a single line to w. func landlockWriteAuditEvent(w io.Writer, evt auditEvent) error { data, err := json.Marshal(evt) if err != nil { return fmt.Errorf("marshal audit event: %w", err) } data = append(data, '\n') _, err = w.Write(data) if err != nil { return fmt.Errorf("write audit event: %w", err) } return nil } // buildSeccompBPFFilter builds a classic BPF program that intercepts openat, openat2, // execve, and execveat syscalls, returning SECCOMP_RET_USER_NOTIF for these and // SECCOMP_RET_ALLOW for everything else. func landlockBuildBPFFilter() (*unix.SockFprog, error) { filter := []unix.SockFilter{ // [0] Load syscall number: BPF_LD | BPF_W | BPF_ABS, offset 0 (nr field in seccomp_data) {Code: unix.BPF_LD | unix.BPF_W | unix.BPF_ABS, K: 0}, // [1] JEQ SYS_OPENAT -> notify (jump to instruction 5) {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 4, Jf: 0, K: uint32(unix.SYS_OPENAT)}, // [2] JEQ SYS_OPENAT2 -> notify (jump to instruction 5) {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 3, Jf: 0, K: uint32(unix.SYS_OPENAT2)}, // [3] JEQ SYS_EXECVE -> notify (jump to instruction 5) {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 2, Jf: 0, K: uint32(unix.SYS_EXECVE)}, // [4] JEQ SYS_EXECVEAT -> notify (jump to instruction 5) {Code: unix.BPF_JMP | unix.BPF_JEQ | unix.BPF_K, Jt: 1, Jf: 0, K: uint32(unix.SYS_EXECVEAT)}, // [5] RET SECCOMP_RET_ALLOW {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_ALLOW}, // [6] notify: RET SECCOMP_RET_USER_NOTIF {Code: unix.BPF_RET | unix.BPF_K, K: unix.SECCOMP_RET_USER_NOTIF}, } return &unix.SockFprog{ Len: uint16(len(filter)), Filter: &filter[0], }, nil } // isPathDenied checks if a path should be denied based on the deny list and open flags. // flags uses O_ACCMODE constants (O_RDONLY, O_WRONLY, O_RDWR). // Matching rules: // - Exact match: /home/user/.env matches deny /home/user/.env // - Directory subtree: /home/user/.ssh/id_rsa matches deny /home/user/.ssh // or deny /home/user/.ssh/ (either with or without trailing slash — a // deny entry without slash is treated as "this path OR anything beneath it") // - Must NOT match partial names: /home/.envrc does NOT match deny /home/.env func isPathDenied(path string, flags int, denyPaths []denyPathEntry) bool { accessMode := flags & unix.O_ACCMODE for _, entry := range denyPaths { matched := false if strings.HasSuffix(entry.Path, "/") { // Directory prefix match: path must start with the deny prefix. matched = strings.HasPrefix(path, entry.Path) } else { // Exact match OR any path under this entry as a directory. matched = path == entry.Path || strings.HasPrefix(path, entry.Path+"/") } if !matched { continue } switch entry.Mode { case denyRead: if accessMode == unix.O_RDONLY || accessMode == unix.O_RDWR { return true } case denyWrite: if accessMode == unix.O_WRONLY || accessMode == unix.O_RDWR { return true } case denyBoth: return true } } return false } // isExecDenied checks if a path matches the deny exec list. // Same matching rules as isPathDenied but no flag check. func isExecDenied(path string, denyExec []string) bool { for _, entry := range denyExec { if strings.HasSuffix(entry, "/") { if strings.HasPrefix(path, entry) { return true } } else { if path == entry || strings.HasPrefix(path, entry+"/") { return true } } } return false } // readPathFromMem reads a null-terminated path string from a process's memory // via a pre-opened /proc//mem file descriptor. Uses ReadAt (pread syscall) // which is NOT intercepted by the seccomp filter. Max 4096 bytes. func readPathFromMem(memFd *os.File, addr uintptr) (string, error) { buf := make([]byte, 4096) n, err := memFd.ReadAt(buf, int64(addr)) if err != nil && n == 0 { return "", fmt.Errorf("read process memory at 0x%x: %w", addr, err) } // Find the null terminator. idx := 0 for idx < n { if buf[idx] == 0 { break } idx++ } if idx == 0 { return "", fmt.Errorf("empty path at 0x%x", addr) } return string(buf[:idx]), nil } // _AT_FDCWD is the Linux AT_FDCWD constant (-100). When stored as uint64 in // seccomp args it may appear as 0xFFFFFF9C (32-bit sign-extended) or // 0xFFFFFFFFFFFFFF9C (64-bit). const ( _AT_FDCWD_32 = 0xFFFFFF9C _AT_FDCWD_64 = 0xFFFFFFFFFFFFFF9C ) // resolveNotifPath resolves a path from seccomp notification arguments. // Handles AT_FDCWD and dirfd-relative paths via os.Readlink on /proc//cwd // and /proc//fd/. readlinkat syscall is NOT intercepted. func resolveNotifPath(pid uint32, dirfd int, rawPath string) (string, error) { // Absolute path: return as-is. if filepath.IsAbs(rawPath) { return filepath.Clean(rawPath), nil } var base string // Check for AT_FDCWD (which is -100, but may be sign-extended in uint64). if dirfd == -100 { cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid)) if err != nil { return "", fmt.Errorf("readlink /proc/%d/cwd: %w", pid, err) } base = cwd } else { fdPath, err := os.Readlink(fmt.Sprintf("/proc/%d/fd/%d", pid, dirfd)) if err != nil { return "", fmt.Errorf("readlink /proc/%d/fd/%d: %w", pid, dirfd, err) } base = fdPath } return filepath.Clean(filepath.Join(base, rawPath)), nil } // classifyOpenFlags extracts O_ACCMODE from openat flags. // For openat(2): flags are in args[2] directly. // For openat2(2): args[2] is a pointer to an open_how struct where the first // uint64 field is the flags. We read those from process memory. func classifyOpenFlags(nr int32, args [6]uint64, memFd *os.File) int { if nr == int32(unix.SYS_OPENAT) { return int(args[2]) & unix.O_ACCMODE } // openat2: args[2] is a pointer to struct open_how { u64 flags; u64 mode; u64 resolve; } if nr == int32(unix.SYS_OPENAT2) && memFd != nil { buf := make([]byte, 8) _, err := memFd.ReadAt(buf, int64(args[2])) if err != nil { // Cannot read open_how struct; default to read-only (conservative). return unix.O_RDONLY } flags := binary.LittleEndian.Uint64(buf) return int(flags) & unix.O_ACCMODE } return unix.O_RDONLY } // dirfdFromArgs extracts the dirfd from seccomp args, handling AT_FDCWD // sign-extension from uint64. func dirfdFromArgs(val uint64) int { if val == _AT_FDCWD_32 || val == _AT_FDCWD_64 { return -100 } return int(int32(val)) } // seccompPhase holds the enforcement state for the seccomp supervisor. type seccompPhase struct { enforcing bool childPID uint32 // memFd is the pre-opened /proc//mem fd for the direct child. // Descendants (grandchildren spawned via fork/exec) have their own PIDs; // use memFdFor(pid) to resolve the right fd for any notification. memFd *os.File denyPaths []denyPathEntry denyExec []string auditWriter io.Writer // memFdCache maps descendant PID -> /proc//mem fd. Entries live for // the duration of the enforce phase; fds are closed in (*seccompSupervisor).Stop. memFdMu sync.Mutex memFdCache map[uint32]*os.File } // seccompSupervisor manages the seccomp notification loop. type seccompSupervisor struct { notifyFd int // stopFd is an eventfd written to by Stop() to wake the recv loop. // Closing notifyFd does NOT wake a goroutine blocked in ioctl(NOTIF_RECV), // so we poll on both fds and use stopFd as an interrupt. stopFd int phase atomic.Pointer[seccompPhase] loopDone chan struct{} } // newLandlockSupervisorFromFd wraps an already-created seccomp notify fd // (obtained from the shim over a socketpair) in a supervisor. It does NOT // install a filter — the shim did that inside its user namespace so the // helper stays unfiltered. func newLandlockSupervisorFromFd(notifyFd int) (*seccompSupervisor, error) { stopFd, err := unix.Eventfd(0, unix.EFD_CLOEXEC|unix.EFD_NONBLOCK) if err != nil { _ = unix.Close(notifyFd) return nil, fmt.Errorf("eventfd: %w", err) } s := &seccompSupervisor{ notifyFd: notifyFd, stopFd: stopFd, loopDone: make(chan struct{}), } go s.loop() return s, nil } // Enforce transitions the supervisor to enforcement mode. From this point on, // syscalls from childPID and its descendants are checked against the deny lists. func (s *seccompSupervisor) Enforce(childPID int, memFd *os.File, denyPaths []denyPathEntry, denyExec []string, auditWriter io.Writer) error { p := &seccompPhase{ enforcing: true, childPID: uint32(childPID), memFd: memFd, denyPaths: denyPaths, denyExec: denyExec, auditWriter: auditWriter, memFdCache: map[uint32]*os.File{uint32(childPID): memFd}, } s.phase.Store(p) return nil } // memFdFor returns an open /proc//mem fd for the given PID, caching it. // Returns nil if the fd cannot be opened (e.g., dumpable=0 from an execve // inside the sandboxed process tree, or the process already exited). func (p *seccompPhase) memFdFor(pid uint32) *os.File { p.memFdMu.Lock() defer p.memFdMu.Unlock() if fd, ok := p.memFdCache[pid]; ok { return fd } fd, err := os.Open(fmt.Sprintf("/proc/%d/mem", pid)) if err != nil { return nil } p.memFdCache[pid] = fd return fd } // invalidateMemFd drops the cached /proc//mem fd. Call this after an // execve on `pid`: execve can change the process's address space layout and // (crucially) its dumpable / PTRACE_MODE_ATTACH state, which invalidates // reads through the existing mem fd with EIO/EOF. Callers will reopen on // the next lookup. func (p *seccompPhase) invalidateMemFd(pid uint32) { p.memFdMu.Lock() defer p.memFdMu.Unlock() if fd, ok := p.memFdCache[pid]; ok { _ = fd.Close() delete(p.memFdCache, pid) } } // closeDescendantMemFds closes all cached memfd entries EXCEPT the direct // child's. Called on Stop; the direct child's memfd is owned by the helper // caller and closed separately. func (p *seccompPhase) closeDescendantMemFds() { p.memFdMu.Lock() defer p.memFdMu.Unlock() for pid, fd := range p.memFdCache { if pid == p.childPID { continue } _ = fd.Close() delete(p.memFdCache, pid) } } // Stop signals the recv loop to exit via the eventfd, waits for it, then // closes the notification fd. Closing notifyFd alone does NOT wake a // goroutine blocked in ioctl(SECCOMP_IOCTL_NOTIF_RECV). func (s *seccompSupervisor) Stop() error { var one = [8]byte{1} _, _ = unix.Write(s.stopFd, one[:]) <-s.loopDone if phase := s.phase.Load(); phase != nil { phase.closeDescendantMemFds() } if err := unix.Close(s.notifyFd); err != nil { log.Warnf("close seccomp notify fd: %v", err) } if err := unix.Close(s.stopFd); err != nil { log.Warnf("close seccomp stop fd: %v", err) } return nil } // loop is the main notification processing goroutine. func (s *seccompSupervisor) loop() { defer close(s.loopDone) runtime.LockOSThread() defer runtime.UnlockOSThread() for { ready, err := waitForNotif(s.notifyFd, s.stopFd) if err != nil || !ready { // stop signalled or fatal poll error — exit loop. return } notif, err := recvNotification(s.notifyFd) if err != nil { // ENOENT: notif expired (process exited between poll and recv). // Retry the loop rather than exit — the listener is still valid. if errors.Is(err, unix.ENOENT) { continue } return } phase := s.phase.Load() if phase == nil || !phase.enforcing { _ = respondContinue(s.notifyFd, notif.ID) continue } // Enforce for the direct child AND all descendants. A memfd per // notifying PID is resolved lazily in handleOpen/handleExec — we do // NOT skip descendants here, because npm-style flows spawn real work // (node, python, etc.) as grandchildren and the deny list must apply // to them too. switch notif.Data.Nr { case int32(unix.SYS_EXECVE), int32(unix.SYS_EXECVEAT): s.handleExec(notif, phase) // execve reshapes the process's memory layout and may drop // PTRACE-read permission (if the new binary is setuid or // changes dumpable). Drop the cached memfd so the next // openat re-opens /proc//mem fresh. phase.invalidateMemFd(notif.PID) case int32(unix.SYS_OPENAT), int32(unix.SYS_OPENAT2): s.handleOpen(notif, phase) default: _ = respondContinue(s.notifyFd, notif.ID) } } } func (s *seccompSupervisor) handleExec(notif *seccompNotification, phase *seccompPhase) { // For execve: args[0] is filename pointer. // For execveat: args[0] is dirfd, args[1] is filename pointer. var pathAddr uintptr var dirfd int if notif.Data.Nr == int32(unix.SYS_EXECVE) { pathAddr = uintptr(notif.Data.Args[0]) dirfd = -100 // AT_FDCWD } else { dirfd = dirfdFromArgs(notif.Data.Args[0]) pathAddr = uintptr(notif.Data.Args[1]) } memFd := phase.memFdFor(notif.PID) if memFd == nil { // Process gone or /proc//mem unreadable — fail-closed would // kill the process; fail-open to avoid breaking legit flows. _ = respondContinue(s.notifyFd, notif.ID) return } rawPath, err := readPathFromMem(memFd, pathAddr) if err != nil { // Cannot read memory (EIO, ESRCH) — process may have died. Continue. _ = respondContinue(s.notifyFd, notif.ID) return } resolved, err := resolveNotifPath(notif.PID, dirfd, rawPath) if err != nil { _ = respondContinue(s.notifyFd, notif.ID) return } if isExecDenied(resolved, phase.denyExec) { if phase.auditWriter != nil { _ = landlockWriteAuditEvent(phase.auditWriter, auditEvent{ Type: auditSeccompDeny, Syscall: syscallName(notif.Data.Nr), Path: resolved, PID: int(notif.PID), }) } _ = respondDeny(s.notifyFd, notif.ID) return } _ = respondContinue(s.notifyFd, notif.ID) } func (s *seccompSupervisor) handleOpen(notif *seccompNotification, phase *seccompPhase) { dirfd := dirfdFromArgs(notif.Data.Args[0]) pathAddr := uintptr(notif.Data.Args[1]) memFd := phase.memFdFor(notif.PID) if memFd == nil { // Can't read the target's memory — typically because an execve in the // process chain with NO_NEW_PRIVS set makes /proc//mem owner-RW // only via CAP_SYS_PTRACE (dumpable=0). Fail open rather than deny // every openat from the process, but this is a real enforcement gap // for grandchild processes. See docs/sandbox.md. _ = respondContinue(s.notifyFd, notif.ID) return } rawPath, err := readPathFromMem(memFd, pathAddr) if err != nil { // Same fail-open path as above; memfd exists but read returned EIO // or similar (stale fd after execve). _ = respondContinue(s.notifyFd, notif.ID) return } resolved, err := resolveNotifPath(notif.PID, dirfd, rawPath) if err != nil { _ = respondContinue(s.notifyFd, notif.ID) return } flags := classifyOpenFlags(notif.Data.Nr, notif.Data.Args, memFd) if isPathDenied(resolved, flags, phase.denyPaths) { if phase.auditWriter != nil { _ = landlockWriteAuditEvent(phase.auditWriter, auditEvent{ Type: auditSeccompDeny, Syscall: syscallName(notif.Data.Nr), Path: resolved, PID: int(notif.PID), }) } _ = respondDeny(s.notifyFd, notif.ID) return } _ = respondContinue(s.notifyFd, notif.ID) } // syscallName returns a human-readable name for known intercepted syscalls. func syscallName(nr int32) string { switch nr { case int32(unix.SYS_OPENAT): return "openat" case int32(unix.SYS_OPENAT2): return "openat2" case int32(unix.SYS_EXECVE): return "execve" case int32(unix.SYS_EXECVEAT): return "execveat" default: return fmt.Sprintf("syscall_%d", nr) } } // waitForNotif blocks until notifyFd has a notification to read or stopFd is // signalled. Returns (true, nil) when a notification is ready, (false, nil) // when stop was signalled, and (false, err) on fatal errors. func waitForNotif(notifyFd, stopFd int) (bool, error) { pfds := []unix.PollFd{ {Fd: int32(notifyFd), Events: unix.POLLIN}, {Fd: int32(stopFd), Events: unix.POLLIN}, } for { _, err := unix.Ppoll(pfds, nil, nil) if err == unix.EINTR { continue } if err != nil { return false, fmt.Errorf("ppoll: %w", err) } if pfds[1].Revents&unix.POLLIN != 0 { return false, nil } if pfds[0].Revents&(unix.POLLIN|unix.POLLERR|unix.POLLHUP) != 0 { // POLLERR/POLLHUP on notifyFd means the child died and the // listener is no longer useful — caller will see EINVAL on recv. return true, nil } } } // recvNotification receives a seccomp notification from the notification fd. // Retries on EINTR which can happen due to Go runtime signals. func recvNotification(fd int) (*seccompNotification, error) { var notif seccompNotification for { _, _, errno := unix.Syscall( unix.SYS_IOCTL, uintptr(fd), _SECCOMP_IOCTL_NOTIF_RECV, uintptr(unsafe.Pointer(¬if)), ) if errno == 0 { return ¬if, nil } if errno == unix.EINTR { continue } return nil, fmt.Errorf("ioctl SECCOMP_IOCTL_NOTIF_RECV: %w", errno) } } // respondContinue tells the kernel to continue the syscall as if the filter // was not installed (SECCOMP_USER_NOTIF_FLAG_CONTINUE). func respondContinue(fd int, id uint64) error { resp := seccompNotifResp{ ID: id, Flags: unix.SECCOMP_USER_NOTIF_FLAG_CONTINUE, } for { _, _, errno := unix.Syscall( unix.SYS_IOCTL, uintptr(fd), _SECCOMP_IOCTL_NOTIF_SEND, uintptr(unsafe.Pointer(&resp)), ) if errno == 0 { return nil } if errno == unix.EINTR { continue } return fmt.Errorf("ioctl SECCOMP_IOCTL_NOTIF_SEND (continue): %w", errno) } } // respondDeny tells the kernel to fail the syscall with EACCES. func respondDeny(fd int, id uint64) error { resp := seccompNotifResp{ ID: id, Error: -int32(unix.EACCES), } for { _, _, errno := unix.Syscall( unix.SYS_IOCTL, uintptr(fd), _SECCOMP_IOCTL_NOTIF_SEND, uintptr(unsafe.Pointer(&resp)), ) if errno == 0 { return nil } if errno == unix.EINTR { continue } return fmt.Errorf("ioctl SECCOMP_IOCTL_NOTIF_SEND (deny): %w", errno) } }