// SPDX-License-Identifier: GPL-3.0-or-later package rootcheck import ( "context" "errors" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) const ( procRoot = "/proc" sysRoot = "/sys" ) // Collect obtains the independent local views used by Check. Every source is // best-effort: unavailable procfs/sysfs files or a missing ps binary remove // only that comparison, never turn a collection error into a rootkit alert. func Collect(ctx context.Context, state model.State, pidCap int) Views { visible := procPIDs() ps, psAvailable := psPIDs(ctx) return Views{ VisiblePIDs: visible, AlivePIDs: alivePIDs(ctx, pidCap), PSPIDs: ps, PSAvailable: psAvailable, PIDAlive: pidAlive, ProcPIDExists: procPIDExists, ProcModules: procModules(), SysLiveModules: sysLiveModules(), ModuleTaints: moduleTaints(), KernelTaint: kernelTaint(), ProcTCPPorts: procTCPPorts(), SSTCPPorts: ssTCPPorts(state), ProcUDPPorts: procUDPPorts(), SSUDPPorts: ssUDPPorts(state), ProcRawProtocols: procRawProtocols(), SSRawProtocols: ssRawProtocols(state), ProcProtocolKinds: procProtocolKinds(), SSProtocolKinds: ssProtocolKinds(state), PromiscuousIfaces: promiscuousIfaces(), } } // Run collects a bounded live view then evaluates it against operator policy. func Run(ctx context.Context, state model.State, pidCap int, allowed map[string]bool) []model.Alert { return Check(Collect(ctx, state, pidCap), Options{AllowedModules: allowed}) } func procPIDs() map[int]bool { result := map[int]bool{} entries, err := os.ReadDir(procRoot) if err != nil { return result } for _, entry := range entries { if pid, err := strconv.Atoi(entry.Name()); err == nil && pid >= 0 { result[pid] = true } } return result } func alivePIDs(ctx context.Context, cap int) map[int]bool { // kill(pid, 0) asks the kernel whether a PID exists without signalling it; // that is intentionally independent of the procfs directory view. Limit // the sweep because pid_max may be very large, and return partial results on // cancellation rather than delaying every other integrity comparison. result := map[int]bool{} if cap <= 0 { return result } maximum := cap if raw, err := os.ReadFile(filepath.Join(procRoot, "sys/kernel/pid_max")); err == nil { if value, err := strconv.Atoi(strings.TrimSpace(string(raw))); err == nil && value < maximum { maximum = value } } for pid := 1; pid <= maximum; pid++ { select { case <-ctx.Done(): return result default: } err := syscall.Kill(pid, 0) if err == nil || errors.Is(err, syscall.EPERM) { result[pid] = true } } return result } func pidAlive(pid int) bool { // EPERM still proves that the kernel knows about the PID; it only means the // sidecar lacks permission to inspect or signal that process. err := syscall.Kill(pid, 0) return err == nil || errors.Is(err, syscall.EPERM) } func procPIDExists(pid int) bool { // Keep this separate from pidAlive: rootcheck alerts on disagreement between // kernel process lookup and the procfs namespace it is expected to expose. _, err := os.Stat(filepath.Join(procRoot, strconv.Itoa(pid))) return err == nil } func psPIDs(ctx context.Context) (map[int]bool, bool) { // ps provides a third, userspace view. A missing or failing command disables // only this comparison because an unavailable tool is not evidence of a // hidden process. command := exec.CommandContext(ctx, "ps", "-e", "-o", "pid=") raw, err := command.Output() if err != nil { return map[int]bool{}, false } result := map[int]bool{} for _, line := range strings.Split(string(raw), "\n") { if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { result[pid] = true } } return result, true } func procModules() map[string]bool { result := map[string]bool{} raw, err := os.ReadFile(filepath.Join(procRoot, "modules")) if err != nil { return result } for _, line := range strings.Split(string(raw), "\n") { if fields := strings.Fields(line); len(fields) > 0 { result[fields[0]] = true } } return result } func sysLiveModules() map[string]bool { result := map[string]bool{} entries, err := os.ReadDir(filepath.Join(sysRoot, "module")) if err != nil { return result } for _, entry := range entries { raw, err := os.ReadFile(filepath.Join(sysRoot, "module", entry.Name(), "initstate")) if err == nil && strings.TrimSpace(string(raw)) == "live" { result[entry.Name()] = true } } return result } func moduleTaints() map[string]string { result := map[string]string{} entries, err := os.ReadDir(filepath.Join(sysRoot, "module")) if err != nil { return result } for _, entry := range entries { raw, err := os.ReadFile(filepath.Join(sysRoot, "module", entry.Name(), "taint")) taint := strings.TrimSpace(string(raw)) if err == nil && taint != "" && taint != "0" { result[entry.Name()] = taint } } return result } func kernelTaint() int { raw, err := os.ReadFile(filepath.Join(procRoot, "sys/kernel/tainted")) if err != nil { return 0 } value, err := strconv.Atoi(strings.TrimSpace(string(raw))) if err != nil { return 0 } return value } func procTCPPorts() map[int]bool { return unionInts(readSocketPorts("net/tcp", true), readSocketPorts("net/tcp6", true)) } func procUDPPorts() map[int]bool { return unionInts(readSocketPorts("net/udp", false), readSocketPorts("net/udp6", false)) } func procRawProtocols() map[int]bool { return unionInts(readSocketPorts("net/raw", false), readSocketPorts("net/raw6", false)) } func readSocketPorts(relative string, listenOnly bool) map[int]bool { // /proc/net/{tcp,udp,raw} encodes the local endpoint as hex ADDRESS:PORT. // TCP's 0A state is LISTEN; UDP and raw entries have no equivalent listener // state, so callers retain every bound protocol/port for comparison. result := map[int]bool{} raw, err := os.ReadFile(filepath.Join(procRoot, relative)) if err != nil { return result } lines := strings.Split(string(raw), "\n") for _, line := range lines[1:] { fields := strings.Fields(line) if len(fields) < 4 || (listenOnly && fields[3] != "0A") { continue } _, portText, ok := strings.Cut(fields[1], ":") if !ok { continue } port, err := strconv.ParseInt(portText, 16, 32) if err == nil && port != 0 { result[int(port)] = true } } return result } func procProtocolKinds() map[string]bool { checks := map[string][]string{ "sctp": {"net/sctp/eps", "net/sctp/assocs", "net/sctp/remaddr"}, "dccp": {"net/dccp", "net/dccp6"}, "packet": {"net/packet"}, "tipc": {"net/tipc/socket"}, "xdp": {"net/xdp"}, } result := map[string]bool{} for kind, paths := range checks { for _, path := range paths { if socketRows(path) { result[kind] = true break } } } return result } func socketRows(relative string) bool { raw, err := os.ReadFile(filepath.Join(procRoot, relative)) if err != nil { return false } lines := strings.Split(string(raw), "\n") for _, line := range lines[1:] { if strings.TrimSpace(line) != "" { return true } } return false } func ssTCPPorts(state model.State) map[int]bool { result := map[int]bool{} for _, socket := range state.Sockets { if socket.State == "LISTEN" { if port, ok := endpointPort(socket.Local); ok { result[port] = true } } } return result } func ssUDPPorts(state model.State) map[int]bool { result := map[int]bool{} for _, socket := range state.Sockets { if socket.State == "UNCONN" || socket.State == "ESTAB" { if port, ok := endpointPort(socket.Local); ok { result[port] = true } } } return result } func ssRawProtocols(state model.State) map[int]bool { result := map[int]bool{} for _, socket := range state.Sockets { if socket.Kind == "raw" { if protocol, ok := endpointProtocol(socket.Local); ok { result[protocol] = true } } } return result } func ssProtocolKinds(state model.State) map[string]bool { result := map[string]bool{} for _, socket := range state.Sockets { if socket.Kind != "" { result[socket.Kind] = true } } return result } func endpointPort(value string) (int, bool) { index := strings.LastIndex(value, ":") if index < 0 { return 0, false } tail := value[index+1:] port, err := strconv.Atoi(tail) return port, err == nil } func endpointProtocol(value string) (int, bool) { index := strings.LastIndex(value, ":") if index < 0 { return 0, false } tail := value[index+1:] tail = strings.ToLower(strings.Trim(tail, "[]")) if value, err := strconv.Atoi(tail); err == nil { return value, true } protocol, ok := rawProtocolNames[tail] return protocol, ok } func promiscuousIfaces() []string { result := make([]string, 0) entries, err := os.ReadDir(filepath.Join(sysRoot, "class/net")) if err != nil { return result } for _, entry := range entries { raw, err := os.ReadFile(filepath.Join(sysRoot, "class/net", entry.Name(), "flags")) if err != nil { continue } flags, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 0, 64) if err == nil && flags&0x100 != 0 { result = append(result, entry.Name()) } } return result } func unionInts(left, right map[int]bool) map[int]bool { result := map[int]bool{} for value := range left { result[value] = true } for value := range right { result[value] = true } return result }