enodia-sentinal/go-agent/internal/system/state.go

254 lines
7.1 KiB
Go

// 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
}