254 lines
7.1 KiB
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
|
|
}
|