// SPDX-License-Identifier: GPL-3.0-or-later package events import ( "fmt" "regexp" "strings" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil" ) type HostRule struct { SID int Message string Severity string Classtype string Events map[string]bool Comms map[string]bool ParentComms map[string]bool PeerPublic *bool PeerPorts map[int]bool PeerPortExclude map[int]bool LocalPorts map[int]bool LocalPortExclude map[int]bool PathPrefixes []string TargetUIDs map[int]bool TargetGIDs map[int]bool CapabilitiesAny map[string]bool ModuleNames map[string]bool ArgvRegex string compiled *regexp.Regexp } func NewHostRule(rule HostRule) (HostRule, error) { if len(rule.Events) == 0 { return HostRule{}, fmt.Errorf("rule sid=%d has no event types", rule.SID) } if len(rule.Comms) == 0 && len(rule.ParentComms) == 0 && rule.PeerPublic == nil && len(rule.PeerPorts) == 0 && len(rule.PeerPortExclude) == 0 && len(rule.LocalPorts) == 0 && len(rule.LocalPortExclude) == 0 && len(rule.PathPrefixes) == 0 && len(rule.TargetUIDs) == 0 && len(rule.TargetGIDs) == 0 && len(rule.CapabilitiesAny) == 0 && len(rule.ModuleNames) == 0 && rule.ArgvRegex == "" { return HostRule{}, fmt.Errorf("rule sid=%d has no match conditions", rule.SID) } if rule.ArgvRegex != "" { compiled, err := regexp.Compile("(?i)" + rule.ArgvRegex) if err != nil { return HostRule{}, fmt.Errorf("rule sid=%d argv_regex: %w", rule.SID, err) } rule.compiled = compiled } return rule, nil } func (r HostRule) Matches(event HostEvent) bool { if !r.Events[event.Event] || len(r.Comms) > 0 && !r.Comms[event.Comm] || len(r.ParentComms) > 0 && !r.ParentComms[event.ParentComm] { return false } if r.PeerPublic != nil && netutil.IsPublicIP(event.PeerIP) != *r.PeerPublic { return false } if len(r.PeerPorts) > 0 && !r.PeerPorts[event.PeerPort] || r.PeerPortExclude[event.PeerPort] || len(r.LocalPorts) > 0 && !r.LocalPorts[event.LocalPort] || r.LocalPortExclude[event.LocalPort] { return false } if len(r.PathPrefixes) > 0 && !pathMatches(event.Path, r.PathPrefixes) { return false } if len(r.TargetUIDs) > 0 && !r.TargetUIDs[event.TargetUID] || len(r.TargetGIDs) > 0 && !r.TargetGIDs[event.TargetGID] || len(r.ModuleNames) > 0 && !r.ModuleNames[event.ModuleName] { return false } if len(r.CapabilitiesAny) > 0 { matched := false for _, capability := range event.Capabilities { if r.CapabilitiesAny[strings.ToUpper(capability)] { matched = true break } } if !matched { return false } } return r.compiled == nil || r.compiled.MatchString(event.ArgvString()) } func (r HostRule) Alert(event HostEvent) model.Alert { capabilities := strings.Join(event.Capabilities, ",") mode := "-" if event.Mode != 0 { mode = fmt.Sprintf("0o%o", event.Mode) } module := event.ModuleName if module == "" { module = "-" } displayCaps := capabilities if displayCaps == "" { displayCaps = "-" } return model.Alert{ SID: r.SID, Severity: r.Severity, Signature: "host_rule." + r.Classtype, Classtype: r.Classtype, Key: fmt.Sprintf( "host:%d:%s:%d:%s:%d:%s:%d:%s:%d:%d:%d:%s:%s", r.SID, event.Event, event.PID, event.PeerIP, event.PeerPort, event.LocalIP, event.LocalPort, event.Path, event.TargetUID, event.TargetGID, event.Mode, capabilities, event.ModuleName), Detail: fmt.Sprintf( "sid=%d %s - pid=%d ppid=%d comm=%s event=%s peer=%s:%d local=%s:%d path=%s target_uid=%d target_gid=%d mode=%s capabilities=%s module=%s", r.SID, r.Message, event.PID, event.PPID, event.Comm, event.Event, event.PeerIP, event.PeerPort, event.LocalIP, event.LocalPort, event.Path, event.TargetUID, event.TargetGID, mode, displayCaps, module), PIDs: []int{event.PID}, } } type HostRuleEngine struct{ Rules []HostRule } func DefaultHostRuleEngine() HostRuleEngine { public := true specifications := []HostRule{ {SID: 100067, Message: "Interpreter connected to an unusual public port", Severity: "HIGH", Classtype: "suspicious-egress", Events: stringSet([]string{"tcp_connect"}), Comms: hostInterpreters, PeerPublic: &public, PeerPortExclude: commonPublicPorts}, {SID: 100068, Message: "Interpreter opened a listener on an unusual local port", Severity: "HIGH", Classtype: "suspicious-listener", Events: stringSet([]string{"listen"}), Comms: hostInterpreters, LocalPortExclude: commonPublicPorts}, {SID: 100073, Message: "Interpreter bound an unusual local port", Severity: "HIGH", Classtype: "suspicious-bind", Events: stringSet([]string{"bind"}), Comms: hostInterpreters, LocalPortExclude: commonPublicPorts}, {SID: 100069, Message: "Interpreter wrote to a persistence path", Severity: "HIGH", Classtype: "persistence-write", Events: stringSet([]string{"file_write"}), Comms: hostInterpreters, PathPrefixes: persistencePrefixes}, {SID: 100070, Message: "Permissions or ownership changed on a persistence path", Severity: "HIGH", Classtype: "persistence-permission-change", Events: stringSet([]string{"chmod", "chown"}), PathPrefixes: persistencePrefixes}, {SID: 100071, Message: "Interpreter requested a root UID transition", Severity: "HIGH", Classtype: "privilege-transition", Events: stringSet([]string{"setuid"}), Comms: hostInterpreters, TargetUIDs: intSet([]int{0})}, {SID: 100072, Message: "Interpreter requested a root GID transition", Severity: "HIGH", Classtype: "privilege-transition", Events: stringSet([]string{"setgid"}), Comms: hostInterpreters, TargetGIDs: intSet([]int{0})}, {SID: 100076, Message: "Interpreter accepted inbound traffic on an unusual local port", Severity: "HIGH", Classtype: "suspicious-accept", Events: stringSet([]string{"accept"}), Comms: hostInterpreters, PeerPublic: &public, LocalPortExclude: commonPublicPorts}, {SID: 100077, Message: "Interpreter requested sensitive Linux capabilities", Severity: "HIGH", Classtype: "capability-escalation", Events: stringSet([]string{"capset"}), Comms: hostInterpreters, CapabilitiesAny: sensitiveCapabilities}, {SID: 100078, Message: "Kernel module load requested from a writable runtime path", Severity: "CRITICAL", Classtype: "suspicious-module-load", Events: stringSet([]string{"module_load"}), PathPrefixes: writableRuntimePrefixes}, } rules := make([]HostRule, 0, len(specifications)) for _, specification := range specifications { rule, err := NewHostRule(specification) if err != nil { panic(err) } rules = append(rules, rule) } return HostRuleEngine{Rules: rules} } func (e HostRuleEngine) Match(event HostEvent) []model.Alert { alerts := make([]model.Alert, 0) for _, rule := range e.Rules { if rule.Matches(event) { alerts = append(alerts, rule.Alert(event)) } } return alerts } var hostInterpreterNames = strings.Fields( "sh bash dash zsh ksh ash python python2 python3 perl ruby php lua " + "node nodejs nc ncat netcat socat curl wget fetch") var hostInterpreters = stringSet(hostInterpreterNames) // HostInterpreterComms returns the process names used by typed host rules. // Kernel sources use the same list for in-BPF prefiltering. func HostInterpreterComms() []string { return append([]string(nil), hostInterpreterNames...) } var commonPublicPorts = intSet([]int{22, 53, 80, 123, 443, 853}) var persistencePrefixes = []string{ "/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/", "/etc/ld.so.preload", "/root/.ssh/authorized_keys", "/etc/sudoers", "/etc/sudoers.d/", } var writableRuntimePrefixes = []string{"/tmp/", "/var/tmp/", "/dev/shm/", "/run/user/"} var sensitiveCapabilities = stringSet([]string{ "CAP_SYS_ADMIN", "CAP_SYS_MODULE", "CAP_SYS_PTRACE", "CAP_DAC_READ_SEARCH", "CAP_NET_ADMIN", "CAP_NET_RAW", }) func intSet(values []int) map[int]bool { result := make(map[int]bool, len(values)) for _, value := range values { result[value] = true } return result } func pathMatches(path string, prefixes []string) bool { for _, prefix := range prefixes { if path == prefix || strings.HasPrefix(path, prefix) { return true } } return false }