enodia-sentinal/go-agent/internal/events/exec_rules.go

414 lines
11 KiB
Go

// 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
}