feat(go): advance validation sidecar toward production

This commit is contained in:
Luna 2026-07-22 01:52:19 -07:00
parent 6a06eba255
commit f85c2e831a
No known key found for this signature in database
92 changed files with 8881 additions and 91 deletions

View file

@ -0,0 +1,201 @@
// 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
}