201 lines
8 KiB
Go
201 lines
8 KiB
Go
// 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
|
|
}
|