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
|
||||
}
|
||||
72
go-agent/internal/rootcheck/manager.go
Normal file
72
go-agent/internal/rootcheck/manager.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const runTimeout = 30 * time.Second
|
||||
|
||||
// Manager schedules the expensive cross-view collection independently from
|
||||
// polling. One bounded check may run at a time; completed alerts return to the
|
||||
// Agent's existing cooldown, incident, and snapshot path on the next sweep.
|
||||
type Manager struct {
|
||||
Config config.Config
|
||||
Now func() time.Time
|
||||
Run func(context.Context, model.State) []model.Alert
|
||||
|
||||
mu sync.Mutex
|
||||
lastRun time.Time
|
||||
running bool
|
||||
pending []model.Alert
|
||||
}
|
||||
|
||||
func New(cfg config.Config) *Manager {
|
||||
manager := &Manager{Config: cfg, Now: time.Now}
|
||||
manager.Run = func(ctx context.Context, state model.State) []model.Alert {
|
||||
return Run(ctx, state, cfg.RootcheckPIDCap, cfg.RootcheckModuleAllow)
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *Manager) MaybeStart(now time.Time, state model.State) {
|
||||
if !m.Config.RootcheckEnabled {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.running || (!m.lastRun.IsZero() && now.Sub(m.lastRun) < m.Config.RootcheckInterval) {
|
||||
return
|
||||
}
|
||||
// Record the start before launching the goroutine. This prevents a fast
|
||||
// polling loop from scheduling duplicates while collection is still running
|
||||
// or while a timed-out external command unwinds.
|
||||
m.running, m.lastRun = true, now
|
||||
go m.run(state)
|
||||
}
|
||||
|
||||
func (m *Manager) run(state model.State) {
|
||||
// Rootcheck compares several host views and may invoke ps; its deadline
|
||||
// makes that diagnostic work bounded even on a degraded host.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
|
||||
defer cancel()
|
||||
alerts := m.Run(ctx, state)
|
||||
m.mu.Lock()
|
||||
m.pending = append(m.pending, alerts...)
|
||||
m.running = false
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) Drain() []model.Alert {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
alerts := append([]model.Alert(nil), m.pending...)
|
||||
m.pending = nil
|
||||
return alerts
|
||||
}
|
||||
64
go-agent/internal/rootcheck/manager_test.go
Normal file
64
go-agent/internal/rootcheck/manager_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestManagerRunsAtMostOneBoundedCheckAndDrainsResults(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.RootcheckInterval = time.Hour
|
||||
manager := New(cfg)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
var sawDeadline atomic.Bool
|
||||
manager.Run = func(ctx context.Context, state model.State) []model.Alert {
|
||||
_, ok := ctx.Deadline()
|
||||
sawDeadline.Store(ok)
|
||||
calls.Add(1)
|
||||
close(started)
|
||||
<-release
|
||||
return []model.Alert{{SID: SIDHiddenModule, Key: "rk:test", PIDs: []int{}}}
|
||||
}
|
||||
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||
manager.MaybeStart(now, model.State{})
|
||||
<-started
|
||||
manager.MaybeStart(now.Add(2*time.Hour), model.State{})
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("concurrent checks = %d, want 1", got)
|
||||
}
|
||||
close(release)
|
||||
for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
|
||||
if alerts := manager.Drain(); len(alerts) == 1 {
|
||||
if !sawDeadline.Load() {
|
||||
t.Fatal("rootcheck did not receive a bounded context")
|
||||
}
|
||||
if alerts[0].SID != SIDHiddenModule {
|
||||
t.Fatalf("alerts = %+v", alerts)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("completed rootcheck result was not drained")
|
||||
}
|
||||
|
||||
func TestDisabledManagerNeverSchedules(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.RootcheckEnabled = false
|
||||
manager := New(cfg)
|
||||
var calls atomic.Int32
|
||||
manager.Run = func(context.Context, model.State) []model.Alert { calls.Add(1); return nil }
|
||||
manager.MaybeStart(time.Now(), model.State{})
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("disabled rootcheck scheduled work")
|
||||
}
|
||||
}
|
||||
287
go-agent/internal/rootcheck/rootcheck.go
Normal file
287
go-agent/internal/rootcheck/rootcheck.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package rootcheck ports Sentinel's user-space anti-rootkit cross-view
|
||||
// checks. It cannot prove a kernel free of a rootkit: a sufficiently capable
|
||||
// implant can lie consistently to every local view. It does catch common
|
||||
// hiding faults by comparing independently collected kernel, procfs, sysfs,
|
||||
// socket-tool, and interface views.
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
SIDHiddenProcess = 100022
|
||||
SIDHiddenModule = 100023
|
||||
SIDHiddenPort = 100024
|
||||
SIDPromiscuous = 100025
|
||||
SIDKnownRootkitModule = 100028
|
||||
SIDTaintedModule = 100029
|
||||
SIDKernelTainted = 100030
|
||||
SIDHiddenUDPPort = 100031
|
||||
SIDHiddenRawSocket = 100034
|
||||
SIDRawICMPSocket = 100035
|
||||
SIDHiddenProtocolSocket = 100037
|
||||
SIDPSHiddenProcess = 100038
|
||||
)
|
||||
|
||||
var knownModules = map[string]bool{
|
||||
"adore": true, "adore_ng": true, "azazel": true, "diamorphine": true,
|
||||
"ipsecs_kbeast": true, "kbeast": true, "knark": true, "reptile": true,
|
||||
"suterusu": true, "syslogk": true,
|
||||
}
|
||||
|
||||
var taintFlags = map[int]string{
|
||||
0: "proprietary-module", 1: "forced-module-load", 2: "unsafe-smp",
|
||||
3: "forced-module-unload", 4: "machine-check", 5: "bad-page",
|
||||
6: "user-tainted", 7: "kernel-died-recently", 8: "acpi-override",
|
||||
9: "kernel-warning", 10: "staging-driver", 11: "firmware-workaround",
|
||||
12: "out-of-tree-module", 13: "unsigned-module", 14: "soft-lockup",
|
||||
15: "live-patched", 16: "auxiliary-taint", 17: "randstruct-plugin",
|
||||
}
|
||||
|
||||
var highRiskTaintBits = map[int]bool{1: true, 3: true, 7: true, 12: true, 13: true}
|
||||
var rawProtocolNames = map[string]int{"icmp": 1, "ipv6-icmp": 58, "icmp6": 58, "sctp": 132}
|
||||
|
||||
// Views contains independently collected host observations. The two PID
|
||||
// callbacks preserve Python's race checks: a hidden-process candidate must
|
||||
// still be alive through the kernel while remaining absent from procfs; a
|
||||
// ps-only candidate must still be present in procfs.
|
||||
type Views struct {
|
||||
VisiblePIDs map[int]bool
|
||||
AlivePIDs map[int]bool
|
||||
PSPIDs map[int]bool
|
||||
PSAvailable bool
|
||||
PIDAlive func(int) bool
|
||||
ProcPIDExists func(int) bool
|
||||
ProcModules map[string]bool
|
||||
SysLiveModules map[string]bool
|
||||
ModuleTaints map[string]string
|
||||
KernelTaint int
|
||||
ProcTCPPorts map[int]bool
|
||||
SSTCPPorts map[int]bool
|
||||
ProcUDPPorts map[int]bool
|
||||
SSUDPPorts map[int]bool
|
||||
ProcRawProtocols map[int]bool
|
||||
SSRawProtocols map[int]bool
|
||||
ProcProtocolKinds map[string]bool
|
||||
SSProtocolKinds map[string]bool
|
||||
PromiscuousIfaces []string
|
||||
}
|
||||
|
||||
// Options supplies explicit operator policy. The PID cap is enforced by the
|
||||
// collector; Check is deliberately pure so parity tests do not touch a host.
|
||||
type Options struct {
|
||||
AllowedModules map[string]bool
|
||||
}
|
||||
|
||||
// Check turns a captured set of cross-views into Python-compatible alerts.
|
||||
func Check(views Views, options Options) []model.Alert {
|
||||
alerts := make([]model.Alert, 0)
|
||||
confirmed := make([]int, 0)
|
||||
for _, pid := range sortedIntDifference(views.AlivePIDs, views.VisiblePIDs) {
|
||||
alive := views.PIDAlive == nil || views.PIDAlive(pid)
|
||||
hidden := views.ProcPIDExists == nil || !views.ProcPIDExists(pid)
|
||||
if alive && hidden {
|
||||
confirmed = append(confirmed, pid)
|
||||
}
|
||||
}
|
||||
if len(confirmed) > 0 {
|
||||
shown := confirmed
|
||||
if len(shown) > 20 {
|
||||
shown = shown[:20]
|
||||
}
|
||||
alerts = append(alerts, model.Alert{SID: SIDHiddenProcess, Severity: "CRITICAL",
|
||||
Signature: "rootkit_hidden_process", Classtype: "rootkit-hidden-process",
|
||||
Key: "rk:hidproc:" + itoa(shown[0]),
|
||||
Detail: "process(es) alive but hidden from /proc: " + joinInts(shown), PIDs: shown})
|
||||
}
|
||||
|
||||
if views.PSAvailable {
|
||||
missing := make([]int, 0)
|
||||
for _, pid := range sortedIntDifference(views.VisiblePIDs, views.PSPIDs) {
|
||||
if views.ProcPIDExists == nil || views.ProcPIDExists(pid) {
|
||||
missing = append(missing, pid)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
shown := missing
|
||||
if len(shown) > 20 {
|
||||
shown = shown[:20]
|
||||
}
|
||||
alerts = append(alerts, model.Alert{SID: SIDPSHiddenProcess, Severity: "HIGH",
|
||||
Signature: "rootkit_ps_hidden_process", Classtype: "rootkit-hidden-process",
|
||||
Key: "rk:pshidproc:" + itoa(shown[0]),
|
||||
Detail: "process(es) visible in /proc but missing from ps output (process tool may be hooked): " + joinInts(shown), PIDs: shown})
|
||||
}
|
||||
}
|
||||
|
||||
for _, module := range sortedStringDifference(views.SysLiveModules, views.ProcModules) {
|
||||
alerts = append(alerts, alert(SIDHiddenModule, "CRITICAL", "rootkit_hidden_module", "rootkit-hidden-module", "rk:hidmod:"+module,
|
||||
"kernel module live in /sys/module but hidden from /proc/modules: "+module))
|
||||
}
|
||||
allModules := unionStrings(views.ProcModules, views.SysLiveModules)
|
||||
known := map[string]bool{}
|
||||
for module := range allModules {
|
||||
if knownModules[normalizeModule(module)] {
|
||||
known[module] = true
|
||||
alerts = append(alerts, alert(SIDKnownRootkitModule, "CRITICAL", "rootkit_known_module", "rootkit-known-module", "rk:knownmod:"+module,
|
||||
"known LKM rootkit module name loaded or visible: "+module))
|
||||
}
|
||||
}
|
||||
for _, module := range sortedKeysString(views.ModuleTaints) {
|
||||
if moduleAllowed(options.AllowedModules, module) || known[module] {
|
||||
continue
|
||||
}
|
||||
taint := views.ModuleTaints[module]
|
||||
alerts = append(alerts, alert(SIDTaintedModule, "HIGH", "rootkit_tainted_module", "rootkit-tainted-module", "rk:taintmod:"+module,
|
||||
"kernel module has taint marker '"+taint+"' (out-of-tree/proprietary/unsigned module): "+module))
|
||||
}
|
||||
if views.KernelTaint != 0 {
|
||||
severity := "MEDIUM"
|
||||
for bit := range highRiskTaintBits {
|
||||
if views.KernelTaint&(1<<bit) != 0 {
|
||||
severity = "HIGH"
|
||||
break
|
||||
}
|
||||
}
|
||||
flags := decodeTaint(views.KernelTaint)
|
||||
label := strings.Join(flags, ", ")
|
||||
if label == "" {
|
||||
label = "unknown"
|
||||
}
|
||||
alerts = append(alerts, alert(SIDKernelTainted, severity, "kernel_tainted", "kernel-integrity", "rk:ktaint:"+itoa(views.KernelTaint),
|
||||
"kernel taint value "+itoa(views.KernelTaint)+" ("+label+") — may indicate unsigned/out-of-tree modules, forced loads, or kernel faults"))
|
||||
}
|
||||
|
||||
alerts = appendPortAlert(alerts, sortedIntDifference(views.ProcTCPPorts, views.SSTCPPorts), SIDHiddenPort, "rootkit_hidden_port", "rk:hidport:",
|
||||
"listening port(s) in /proc/net/tcp not reported by ss (tool may be hooked): ", "rootkit-hidden-port")
|
||||
alerts = appendPortAlert(alerts, sortedIntDifference(views.ProcUDPPorts, views.SSUDPPorts), SIDHiddenUDPPort, "rootkit_hidden_udp_port", "rk:hidudp:",
|
||||
"UDP port(s) in /proc/net/udp not reported by ss (tool may be hooked): ", "rootkit-hidden-port")
|
||||
raw := sortedIntDifference(views.ProcRawProtocols, views.SSRawProtocols)
|
||||
alerts = appendPortAlert(alerts, raw, SIDHiddenRawSocket, "rootkit_hidden_raw_socket", "rk:hidraw:",
|
||||
"raw socket protocol(s) in /proc/net/raw not reported by ss (tool may be hooked): ", "rootkit-hidden-socket")
|
||||
icmp := make([]int, 0)
|
||||
for _, protocol := range sortedKeys(views.ProcRawProtocols) {
|
||||
if protocol == 1 || protocol == 58 {
|
||||
icmp = append(icmp, protocol)
|
||||
}
|
||||
}
|
||||
if len(icmp) > 0 {
|
||||
alerts = append(alerts, alert(SIDRawICMPSocket, "HIGH", "raw_icmp_socket", "raw-socket-backdoor", "rk:rawicmp:"+itoa(icmp[0]),
|
||||
"persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "+joinInts(icmp)+" — validate authorized ping/monitoring tools or ICMP knockers"))
|
||||
}
|
||||
protocols := sortedStringDifference(views.ProcProtocolKinds, views.SSProtocolKinds)
|
||||
if len(protocols) > 0 {
|
||||
alerts = append(alerts, alert(SIDHiddenProtocolSocket, "HIGH", "rootkit_hidden_protocol_socket", "rootkit-hidden-socket", "rk:hidproto:"+protocols[0],
|
||||
"special protocol socket family present in /proc/net but absent from ss output (tool may be hooked): "+strings.Join(protocols, ", ")))
|
||||
}
|
||||
for _, iface := range sortedStrings(views.PromiscuousIfaces) {
|
||||
alerts = append(alerts, alert(SIDPromiscuous, "MEDIUM", "promiscuous_interface", "network-sniffer", "rk:promisc:"+iface,
|
||||
"interface in promiscuous mode (possible sniffer): "+iface))
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func appendPortAlert(alerts []model.Alert, values []int, sid int, signature, keyPrefix, detailPrefix, class string) []model.Alert {
|
||||
if len(values) == 0 {
|
||||
return alerts
|
||||
}
|
||||
return append(alerts, alert(sid, "HIGH", signature, class, keyPrefix+itoa(values[0]), detailPrefix+joinInts(values)))
|
||||
}
|
||||
|
||||
func alert(sid int, severity, signature, class, key, detail string) model.Alert {
|
||||
return model.Alert{SID: sid, Severity: severity, Signature: signature, Classtype: class, Key: key, Detail: detail, PIDs: []int{}}
|
||||
}
|
||||
|
||||
func normalizeModule(value string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(value), "-", "_")
|
||||
}
|
||||
|
||||
func moduleAllowed(allowed map[string]bool, module string) bool {
|
||||
for name := range allowed {
|
||||
if normalizeModule(name) == normalizeModule(module) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func itoa(value int) string { return strconv.Itoa(value) }
|
||||
|
||||
func sortedIntDifference(left, right map[int]bool) []int {
|
||||
values := make([]int, 0)
|
||||
for value := range left {
|
||||
if !right[value] {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
sort.Ints(values)
|
||||
return values
|
||||
}
|
||||
func sortedStringDifference(left, right map[string]bool) []string {
|
||||
values := make([]string, 0)
|
||||
for value := range left {
|
||||
if !right[value] {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values
|
||||
}
|
||||
func sortedKeys[V any](source map[int]V) []int {
|
||||
values := make([]int, 0, len(source))
|
||||
for value := range source {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Ints(values)
|
||||
return values
|
||||
}
|
||||
func sortedStrings(values []string) []string {
|
||||
result := append([]string(nil), values...)
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
func sortedKeysString[V any](source map[string]V) []string {
|
||||
values := make([]string, 0, len(source))
|
||||
for value := range source {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values
|
||||
}
|
||||
func unionStrings(left, right map[string]bool) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
for value := range left {
|
||||
result[value] = true
|
||||
}
|
||||
for value := range right {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
func decodeTaint(value int) []string {
|
||||
keys := make([]int, 0, len(taintFlags))
|
||||
for bit := range taintFlags {
|
||||
keys = append(keys, bit)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
values := make([]string, 0)
|
||||
for _, bit := range keys {
|
||||
if value&(1<<bit) != 0 {
|
||||
values = append(values, taintFlags[bit])
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
func joinInts(values []int) string {
|
||||
rendered := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
rendered[i] = itoa(value)
|
||||
}
|
||||
return strings.Join(rendered, ", ")
|
||||
}
|
||||
89
go-agent/internal/rootcheck/rootcheck_test.go
Normal file
89
go-agent/internal/rootcheck/rootcheck_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setInts(values ...int) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func setStrings(values ...string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestCheckIsQuietWhenCrossViewsAgree(t *testing.T) {
|
||||
alerts := Check(Views{
|
||||
VisiblePIDs: setInts(1, 2), AlivePIDs: setInts(1, 2),
|
||||
PSPIDs: setInts(1, 2), PSAvailable: true,
|
||||
PIDAlive: func(int) bool { return true }, ProcPIDExists: func(int) bool { return true },
|
||||
ProcModules: setStrings("ext4"), SysLiveModules: setStrings("ext4"),
|
||||
}, Options{})
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("alerts = %+v, want none", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsRootkitCrossViewSignalsInPythonOrder(t *testing.T) {
|
||||
alerts := Check(Views{
|
||||
VisiblePIDs: setInts(1), AlivePIDs: setInts(1), PSPIDs: setInts(1), PSAvailable: true,
|
||||
PIDAlive: func(int) bool { return true }, ProcPIDExists: func(int) bool { return true },
|
||||
ProcModules: setStrings("ext4"), SysLiveModules: setStrings("ext4", "diamorphine"),
|
||||
ModuleTaints: map[string]string{"vendor_gpu": "OE"}, KernelTaint: (1 << 12) | (1 << 13),
|
||||
ProcTCPPorts: setInts(22, 31337), SSTCPPorts: setInts(22),
|
||||
ProcUDPPorts: setInts(53, 4444), SSUDPPorts: setInts(53),
|
||||
ProcRawProtocols: setInts(1, 58), SSRawProtocols: setInts(58),
|
||||
ProcProtocolKinds: setStrings("sctp", "packet"), SSProtocolKinds: setStrings("packet"),
|
||||
PromiscuousIfaces: []string{"eth0"},
|
||||
}, Options{})
|
||||
got := make([]int, len(alerts))
|
||||
for index, alert := range alerts {
|
||||
got[index] = alert.SID
|
||||
}
|
||||
want := []int{SIDHiddenModule, SIDKnownRootkitModule, SIDTaintedModule, SIDKernelTainted,
|
||||
SIDHiddenPort, SIDHiddenUDPPort, SIDHiddenRawSocket, SIDRawICMPSocket,
|
||||
SIDHiddenProtocolSocket, SIDPromiscuous}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("SIDs = %v, want %v", got, want)
|
||||
}
|
||||
if alerts[0].Severity != "CRITICAL" || alerts[1].Signature != "rootkit_known_module" ||
|
||||
alerts[3].Severity != "HIGH" || alerts[7].Key != "rk:rawicmp:1" {
|
||||
t.Fatalf("unexpected alert detail: %+v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRechecksProcessCandidatesAndLimitsRenderedPIDs(t *testing.T) {
|
||||
alive := map[int]bool{}
|
||||
for pid := 1; pid <= 25; pid++ {
|
||||
alive[pid] = true
|
||||
}
|
||||
alerts := Check(Views{AlivePIDs: alive, VisiblePIDs: map[int]bool{},
|
||||
PIDAlive: func(pid int) bool { return pid != 1 }, ProcPIDExists: func(int) bool { return false },
|
||||
}, Options{})
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("alerts = %+v, want one", alerts)
|
||||
}
|
||||
if len(alerts[0].PIDs) != 20 || alerts[0].PIDs[0] != 2 || alerts[0].PIDs[19] != 21 {
|
||||
t.Fatalf("pids = %v, want [2..21]", alerts[0].PIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHonorsNormalizedModuleAllowlist(t *testing.T) {
|
||||
alerts := Check(Views{ModuleTaints: map[string]string{"vendor-gpu": "OE"}}, Options{
|
||||
AllowedModules: setStrings("vendor_gpu"),
|
||||
})
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("alerts = %+v, want none", alerts)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue