feat(go): advance validation sidecar toward production
This commit is contained in:
parent
6a06eba255
commit
f85c2e831a
92 changed files with 8881 additions and 91 deletions
414
go-agent/internal/events/exec_rules.go
Normal file
414
go-agent/internal/events/exec_rules.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue