// SPDX-License-Identifier: GPL-3.0-or-later // Package system captures one cached, injectable view of live host state. package system import ( "context" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) // Paths makes host filesystem inputs injectable for parity and unit tests. type Paths struct { ProcRoot string LDPreloadPath string SocketSource func() []model.Socket } var ( inodePattern = regexp.MustCompile(`\bino:(\d+)`) userPattern = regexp.MustCompile(`users:\(\("([^"]+)",pid=(\d+),fd=\d+`) ) var socketSpecs = []struct { args []string kind string }{ {[]string{"-tanep"}, "tcp"}, {[]string{"-uanep"}, "udp"}, {[]string{"-wanep"}, "raw"}, {[]string{"-Sanep"}, "sctp"}, {[]string{"-danep"}, "dccp"}, {[]string{"-0anep"}, "packet"}, {[]string{"-Manep"}, "mptcp"}, {[]string{"--tipc", "-anep"}, "tipc"}, {[]string{"--xdp", "-anep"}, "xdp"}, {[]string{"--vsock", "-anep"}, "vsock"}, } var socketNetIDs = map[string]bool{ "tcp": true, "udp": true, "raw": true, "sctp": true, "dccp": true, "mptcp": true, "p_raw": true, "p_dgr": true, "packet": true, "tipc": true, "xdp": true, "vsock": true, } // Capture reads process state once from procRoot. Transient per-process read // failures degrade to empty fields, matching Python's fail-open Process view. func Capture(procRoot string) (model.State, error) { return CaptureWithPaths(Paths{ ProcRoot: procRoot, LDPreloadPath: "/etc/ld.so.preload", }) } // CaptureWithPaths reads one process view and the global preload file. func CaptureWithPaths(paths Paths) (model.State, error) { procRoot := paths.ProcRoot if procRoot == "" { procRoot = "/proc" } entries, err := os.ReadDir(procRoot) if err != nil { return model.State{}, err } processes := make([]model.Process, 0, len(entries)) for _, entry := range entries { pid, err := strconv.Atoi(entry.Name()) if err != nil || !entry.IsDir() { continue } base := filepath.Join(procRoot, entry.Name()) processes = append(processes, model.Process{ PID: pid, Comm: strings.TrimSpace(readText(filepath.Join(base, "comm"))), Cmdline: strings.TrimSpace(strings.ReplaceAll(readText(filepath.Join(base, "cmdline")), "\x00", " ")), Exe: readLink(filepath.Join(base, "exe")), Environ: parseEnviron(readText(filepath.Join(base, "environ"))), FDTargets: readFDTargets(filepath.Join(base, "fd")), MemoryMaps: parseMemoryMaps(readText(filepath.Join(base, "maps"))), }) } sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID }) var sockets []model.Socket if paths.SocketSource != nil { sockets = paths.SocketSource() } else { sockets = captureSockets() } return model.State{ Processes: processes, Sockets: sockets, LDPreload: strings.TrimSpace(readText(paths.LDPreloadPath)), }, nil } func captureSockets() []model.Socket { result := make([]model.Socket, 0) for _, spec := range socketSpecs { // Python asks ss once per protocol family because several families use // incompatible flags. Preserve that behavior instead of hiding gaps // behind a single TCP/UDP-only invocation. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) command := exec.CommandContext(ctx, "ss", append([]string{"-H"}, spec.args...)...) output, err := command.Output() cancel() if err != nil { // Collection is fail-open by design: a missing/unsupported ss flag // drops only that socket family and never stops process detection. continue } result = append(result, parseSS(string(output), spec.kind)...) } return result } func parseSS(text, kind string) []model.Socket { result := make([]model.Socket, 0) for _, line := range strings.Split(text, "\n") { fields := strings.Fields(line) if len(fields) < 5 { continue } var state, local, peer string // Some ss versions prefix each row with the protocol netid and some // begin directly with state. Python accepts both layouts; Go must too. if socketNetIDs[strings.ToLower(fields[0])] && len(fields) >= 6 { state, local, peer = fields[1], fields[4], fields[5] } else { state, local, peer = fields[0], fields[3], fields[4] } var inode, pid *int // Ownership is best-effort. Nil values are meaningful because Python // serializes an unknown PID differently in keys (None) and prose (?). if match := inodePattern.FindStringSubmatch(line); len(match) == 2 { value, err := strconv.Atoi(match[1]) if err == nil { inode = &value } } comm := "" if match := userPattern.FindStringSubmatch(line); len(match) == 3 { comm = match[1] value, err := strconv.Atoi(match[2]) if err == nil { pid = &value } } result = append(result, model.Socket{ State: state, Local: local, Peer: peer, Inode: inode, Comm: comm, PID: pid, Kind: kind, }) } return result } func parseEnviron(raw string) map[string]string { result := make(map[string]string) for _, item := range strings.Split(raw, "\x00") { key, value, ok := strings.Cut(item, "=") if ok { result[key] = value } } return result } func readFDTargets(path string) map[string]string { result := make(map[string]string) entries, err := os.ReadDir(path) if err != nil { return result } sort.Slice(entries, func(i, j int) bool { left, leftErr := strconv.Atoi(entries[i].Name()) right, rightErr := strconv.Atoi(entries[j].Name()) if leftErr == nil && rightErr == nil { return left < right } return entries[i].Name() < entries[j].Name() }) for _, entry := range entries { if target := readLink(filepath.Join(path, entry.Name())); target != "" { result[entry.Name()] = target } } return result } // parseMemoryMaps mirrors Python's split(maxsplit=5) parser. A process may // disappear or deny maps access between directory enumeration and this read; // returning an empty slice preserves the detector's fail-open behavior. func parseMemoryMaps(text string) []model.MemoryMap { result := make([]model.MemoryMap, 0) for _, line := range strings.Split(text, "\n") { fields := strings.Fields(line) if len(fields) < 5 { continue } bounds := strings.SplitN(fields[0], "-", 2) if len(bounds) != 2 { continue } start, err := strconv.ParseUint(bounds[0], 16, 64) if err != nil { continue } end, err := strconv.ParseUint(bounds[1], 16, 64) if err != nil { continue } path := "" if len(fields) > 5 { // Keep pathname spaces intact, just as Python's maxsplit=5 does. remaining := strings.TrimSpace(line) for index := 0; index < 5; index++ { space := strings.IndexAny(remaining, " \t") if space < 0 { remaining = "" break } remaining = strings.TrimSpace(remaining[space:]) } path = remaining } result = append(result, model.MemoryMap{ Start: start, End: end, Perms: fields[1], Path: path, }) } return result } func readText(path string) string { raw, err := os.ReadFile(path) if err != nil { return "" } return string(raw) } func readLink(path string) string { target, err := os.Readlink(path) if err != nil { return "" } return target }