feat(go): port FIM/pkgdb/rootcheck integrity engines and add go-sidecar dashboard consumer
Adds sidecar-only, --state-dir-gated FIM, package-DB-anchor, and rootcheck
engines to the Go migration sidecar, wired into agent.Sweep/Initialize
behind the existing baseline lifecycle. Adds a read-only, schema-checked
Python dashboard consumer (go_sidecar_state_dir, /api/go-sidecar/*, Sidecar
tab) that never starts, stops, or mutates the Go sidecar's state.
Also fixes drift found while reconciling this work: enodia_sentinel/web.py
had reinvented local schema constants instead of using the canonical
enodia_sentinel/schemas.py catalog (now registers enodia.go.sidecar.v1
there and reuses ALERT_SNAPSHOT_V1/INCIDENT_V1); docs/RULES.md had drifted
from what `rules docs` actually generates for SIDs 100069-100078 (ruleops.py
was missing metadata for four SIDs and had stale drill text for four more),
now back in sync with a regression test pinning them together.
Updates CLAUDE.md, README.md, go-agent/README.md, and docs/{ROADMAP,
GO_PORT_HANDOFF,SURICATA_ASSIMILATION,THREAT_MODEL,OPERATIONS,SCHEMAS,
COMMAND_REFERENCE}.md to reflect the landed work.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQivSKBqQJsayz1xcYqWzZ
This commit is contained in:
parent
1d1dee7f6e
commit
d835386381
43 changed files with 3419 additions and 72 deletions
351
go-agent/internal/rootcheck/collect.go
Normal file
351
go-agent/internal/rootcheck/collect.go
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue