feat(go): advance validation sidecar toward production
This commit is contained in:
parent
6a06eba255
commit
f85c2e831a
92 changed files with 8881 additions and 91 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue