enodia-sentinal/go-agent/internal/agent/agent.go

283 lines
8.6 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
// 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 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"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
)
const Version = "0.7.0"
// CaptureFunc returns one injectable SystemState-equivalent sweep.
type CaptureFunc func() (model.State, error)
// EmitFunc receives one enodia.event.v1-compatible record.
type EmitFunc func(map[string]any) error
// 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,
ebpf: "unknown",
ebpfExec: "unknown",
ebpfSyscall: "unknown",
}
}
// Sweep captures state once and returns alert events followed by one status
// 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 := now.Local().Format(time.RFC3339Nano)
alerts := make([]model.Alert, 0)
// Preserve the relative order of Python detectors.REGISTRY. Snapshot and
// parity consumers rely on stable alert ordering even while unported slots
// are temporarily absent from the Go implementation.
if a.Config.Enabled("reverse_shell") {
alerts = append(alerts, detectors.ReverseShell(state, a.Config)...)
}
if a.Config.Enabled("ld_preload") {
alerts = append(alerts, detectors.LDPreload(state)...)
}
if a.Config.Enabled("deleted_exe") {
alerts = append(alerts, detectors.DeletedExe(state)...)
}
if a.Config.Enabled("input_snooper") {
alerts = append(alerts, detectors.InputSnooper(state, a.Config)...)
}
if a.Config.Enabled("credential_access") {
alerts = append(alerts, detectors.CredentialAccess(state, a.Config)...)
}
if a.Config.Enabled("stealth_network") {
alerts = append(alerts, detectors.StealthNetwork(state, a.Config)...)
}
if a.Config.Enabled("memory_obfuscation") {
alerts = append(alerts, detectors.MemoryObfuscation(state, a.Config)...)
}
if a.Config.Enabled("egress") {
alerts = append(alerts, detectors.Egress(state, a.Config)...)
}
// 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, 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,
Running: true,
TotalAlerts: 0,
Counts: map[string]int{},
LastAlert: nil,
EBPF: ebpfStatus,
EBPFExec: ebpfExecStatus,
EBPFSyscall: ebpfSyscallStatus,
Host: host,
}
record, err := schema.Build("status", status, host, timestamp)
if err != nil {
return nil, err
}
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 {
return err
}
for _, event := range events {
if err := emit(event); err != nil {
return err
}
}
if once {
return nil
}
timer := time.NewTimer(a.Config.SampleInterval)
select {
case <-ctx.Done():
if !timer.Stop() {
<-timer.C
}
return nil
case <-timer.C:
}
}
}