// SPDX-License-Identifier: GPL-3.0-or-later package events import ( "encoding/csv" "fmt" "os" "regexp" "sort" "strconv" "strings" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) // ExecRule is the Go representation of Python's declarative exec rule. Every // specified positive condition is ANDed; values inside a condition are ORed. type ExecRule struct { SID int Message string Severity string Classtype string PathPrefixes []string ExecComms map[string]bool ParentComms map[string]bool ArgvRegex string ParentExclude map[string]bool compiled *regexp.Regexp } func NewExecRule(rule ExecRule) (ExecRule, error) { if len(rule.PathPrefixes) == 0 && len(rule.ExecComms) == 0 && len(rule.ParentComms) == 0 && rule.ArgvRegex == "" { return ExecRule{}, fmt.Errorf("rule sid=%d has no match conditions", rule.SID) } if rule.Severity == "" { rule.Severity = "HIGH" } if rule.Classtype == "" { rule.Classtype = "uncategorized" } if rule.ArgvRegex != "" { compiled, err := regexp.Compile("(?i)" + rule.ArgvRegex) if err != nil { return ExecRule{}, fmt.Errorf("rule sid=%d argv_regex: %w", rule.SID, err) } rule.compiled = compiled } return rule, nil } func (r ExecRule) Matches(event ExecEvent) bool { if r.ParentExclude[event.ParentComm] { return false } if len(r.PathPrefixes) > 0 && !hasPrefix(event.Filename, r.PathPrefixes) { return false } if len(r.ExecComms) > 0 && !r.ExecComms[event.ExecComm()] { return false } if len(r.ParentComms) > 0 && !r.ParentComms[event.ParentComm] { return false } if r.compiled != nil && !r.compiled.MatchString(event.ArgvString()) { return false } return true } func (r ExecRule) Alert(event ExecEvent) model.Alert { argv := truncateRunes(event.ArgvString(), 120) return model.Alert{ SID: r.SID, Severity: r.Severity, Signature: "exec_rule." + r.Classtype, Classtype: r.Classtype, Key: fmt.Sprintf("exec:%d:%d", r.SID, event.PID), Detail: fmt.Sprintf( "sid=%d %s — pid=%d ppid=%d parent=%s exec=%s argv=[%s]", r.SID, r.Message, event.PID, event.PPID, event.ParentComm, event.Filename, argv), PIDs: []int{event.PID}, } } type ExecRuleEngine struct { Rules []ExecRule } func DefaultExecRuleEngine() ExecRuleEngine { // Constructors below use only compile-time-valid rules. Panic is preferable // to silently dropping a shipped detection if a future edit breaks one. rules := make([]ExecRule, 0, len(defaultExecRuleSpecs)) for _, specification := range defaultExecRuleSpecs { rule, err := NewExecRule(specification) if err != nil { panic(err) } rules = append(rules, rule) } return ExecRuleEngine{Rules: rules} } func LoadExecRuleEngine(path string) (ExecRuleEngine, error) { engine := DefaultExecRuleEngine() if path == "" { return engine, nil } if _, err := os.Stat(path); os.IsNotExist(err) { return engine, nil } else if err != nil { return ExecRuleEngine{}, err } rules, err := loadTOMLExecRules(path) if err != nil { return ExecRuleEngine{}, err } engine.Rules = append(engine.Rules, rules...) return engine, nil } func (e ExecRuleEngine) Match(event ExecEvent) []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 interpreters = stringSet(strings.Fields( "sh bash dash zsh ksh ash python python2 python3 perl ruby php lua " + "nc ncat netcat socat")) var webDBServers = stringSet(strings.Fields( "nginx apache apache2 httpd php-fpm php php7 php8 lighttpd caddy " + "node nodejs tomcat catalina mysqld mariadbd postgres redis-server")) var defaultExecRuleSpecs = []ExecRule{ { SID: 100001, Message: "Program executed from a world-writable directory", Severity: "CRITICAL", Classtype: "fileless-execution", PathPrefixes: []string{"/tmp/", "/dev/shm/", "/var/tmp/"}, }, { SID: 100002, Message: "Reverse-shell command pattern in execve arguments", Severity: "CRITICAL", Classtype: "c2-reverse-shell", ArgvRegex: `/dev/(tcp|udp)/|\b(ba|da|z)?sh\b[^|]*\s-i\b|\bn(c|cat|etcat)\b.*\s-e\b|\bsocat\b.*\bexec|python[0-9.]*\b.*(pty\.spawn|socket\.socket)|perl\b.*\bSocket\b`, }, { SID: 100003, Message: "Web/DB service spawned a shell or interpreter (possible RCE/webshell)", Severity: "CRITICAL", Classtype: "web-rce", ExecComms: interpreters, ParentComms: webDBServers, }, { SID: 100004, Message: "Download piped directly to a shell (ingress tool transfer)", Severity: "HIGH", Classtype: "ingress-tool-transfer", ArgvRegex: `\b(curl|wget|fetch)\b.*\|\s*(ba|da|z)?sh\b`, }, } type rawExecRule struct { values map[string]string } // loadTOMLExecRules implements the intentionally narrow [[exec_rules]] subset // consumed by Python. The sidecar stays stdlib-only; multiline string arrays, // quoted strings, comments, and arbitrary table ordering are supported. func loadTOMLExecRules(path string) ([]ExecRule, error) { raw, err := os.ReadFile(path) if err != nil { return nil, err } tables, err := parseExecRuleTables(string(raw)) if err != nil { return nil, err } rules := make([]ExecRule, 0, len(tables)) for _, table := range tables { sidText, ok := table.values["sid"] if !ok { return nil, fmt.Errorf("exec_rules entry missing sid") } sid, err := strconv.Atoi(strings.TrimSpace(sidText)) if err != nil { return nil, fmt.Errorf("exec_rules sid: %w", err) } message, err := optionalString(table.values, "msg", "") if err != nil { return nil, err } severity, err := optionalString(table.values, "severity", "HIGH") if err != nil { return nil, err } severity = strings.ToUpper(severity) if severity != "MEDIUM" && severity != "HIGH" && severity != "CRITICAL" { severity = "HIGH" } classtype, err := optionalString(table.values, "classtype", "uncategorized") if err != nil { return nil, err } argvRegex, err := optionalString(table.values, "argv_regex", "") if err != nil { return nil, err } pathPrefixes, err := optionalStringArray(table.values, "path_prefixes") if err != nil { return nil, err } execComms, err := optionalStringArray(table.values, "exec_comm") if err != nil { return nil, err } parentComms, err := optionalStringArray(table.values, "parent_comm") if err != nil { return nil, err } parentExclude, err := optionalStringArray(table.values, "parent_exclude") if err != nil { return nil, err } rule, err := NewExecRule(ExecRule{ SID: sid, Message: message, Severity: severity, Classtype: classtype, PathPrefixes: pathPrefixes, ExecComms: stringSet(execComms), ParentComms: stringSet(parentComms), ArgvRegex: argvRegex, ParentExclude: stringSet(parentExclude), }) if err != nil { return nil, err } rules = append(rules, rule) } return rules, nil } func parseExecRuleTables(text string) ([]rawExecRule, error) { tables := make([]rawExecRule, 0) var current *rawExecRule var assignment strings.Builder depth := 0 flush := func() error { if assignment.Len() == 0 || current == nil { assignment.Reset() return nil } key, value, ok := strings.Cut(assignment.String(), "=") if !ok { return fmt.Errorf("invalid exec rule assignment: %q", assignment.String()) } current.values[strings.TrimSpace(key)] = strings.TrimSpace(value) assignment.Reset() return nil } for _, rawLine := range strings.Split(text, "\n") { line := strings.TrimSpace(stripTOMLComment(rawLine)) if line == "" { continue } if line == "[[exec_rules]]" { if err := flush(); err != nil { return nil, err } tables = append(tables, rawExecRule{values: map[string]string{}}) current = &tables[len(tables)-1] depth = 0 continue } if current == nil { continue } if assignment.Len() > 0 { assignment.WriteByte(' ') } assignment.WriteString(line) depth += strings.Count(line, "[") - strings.Count(line, "]") if depth <= 0 { if err := flush(); err != nil { return nil, err } depth = 0 } } if err := flush(); err != nil { return nil, err } return tables, nil } func optionalString(values map[string]string, key, fallback string) (string, error) { raw, ok := values[key] if !ok { return fallback, nil } value, err := tomlString(raw) if err != nil { return "", fmt.Errorf("exec_rules %s: %w", key, err) } return value, nil } func optionalStringArray(values map[string]string, key string) ([]string, error) { raw, ok := values[key] if !ok { return []string{}, nil } raw = strings.TrimSpace(raw) if len(raw) < 2 || raw[0] != '[' || raw[len(raw)-1] != ']' { return nil, fmt.Errorf("exec_rules %s: expected string array", key) } body := strings.TrimSpace(raw[1 : len(raw)-1]) if body == "" { return []string{}, nil } body = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(body), ",")) reader := csv.NewReader(strings.NewReader(body)) reader.TrimLeadingSpace = true fields, err := reader.Read() if err != nil { return nil, fmt.Errorf("exec_rules %s: %w", key, err) } result := make([]string, 0, len(fields)) for _, field := range fields { field = strings.TrimSpace(field) if field == "" { continue } // encoding/csv already removes TOML's ordinary double quotes. Literal // single-quoted strings are not CSV syntax, so unwrap those explicitly. if len(field) >= 2 && field[0] == '\'' && field[len(field)-1] == '\'' { field = field[1 : len(field)-1] } result = append(result, field) } return result, nil } func tomlString(raw string) (string, error) { raw = strings.TrimSpace(raw) if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' { return raw[1 : len(raw)-1], nil } value, err := strconv.Unquote(raw) if err != nil { return "", fmt.Errorf("expected string, got %q", raw) } return value, nil } func stripTOMLComment(line string) string { inBasic, inLiteral, escaped := false, false, false for index, char := range line { if escaped { escaped = false continue } if char == '\\' && inBasic { escaped = true continue } if char == '"' && !inLiteral { inBasic = !inBasic continue } if char == '\'' && !inBasic { inLiteral = !inLiteral continue } if char == '#' && !inBasic && !inLiteral { return line[:index] } } return line } func hasPrefix(value string, prefixes []string) bool { for _, prefix := range prefixes { if strings.HasPrefix(value, prefix) { return true } } return false } func stringSet(values []string) map[string]bool { result := make(map[string]bool, len(values)) for _, value := range values { result[value] = true } return result } func truncateRunes(value string, limit int) string { runes := []rune(value) if len(runes) <= limit { return value } return string(runes[:limit]) } // RuleSIDs is kept sorted for operator catalogs and coverage assertions. func (e ExecRuleEngine) RuleSIDs() []int { result := make([]int, 0, len(e.Rules)) for _, rule := range e.Rules { result = append(result, rule.SID) } sort.Ints(result) return result }