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
287 lines
10 KiB
Go
287 lines
10 KiB
Go
// 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, ", ")
|
|
}
|