// 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 Phase 1. package agent import ( "context" "fmt" "os" "time" "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 Phase 1 sidecar sweep loop. type Agent struct { Config config.Config Capture CaptureFunc Host func() (string, error) Now func() time.Time } // 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, } } // Sweep captures state once and returns alert events followed by one status // event. Retained snapshot fields remain empty until persistence is ported. func (a *Agent) Sweep() ([]map[string]any, error) { state, err := a.Capture() if err != nil { return nil, err } host, err := a.Host() if err != nil { return nil, err } timestamp := a.Now().Local().Format(time.RFC3339Nano) alerts := make([]model.Alert, 0) 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)...) } events := make([]map[string]any, 0, len(alerts)+1) for _, alert := range alerts { record, err := schema.Build("alert", alert, host, timestamp) if err != nil { return nil, err } events = append(events, record) } status := schema.Status{ Schema: schema.StatusV1, Version: Version, Running: true, TotalAlerts: 0, Counts: map[string]int{}, LastAlert: nil, EBPF: "unknown", EBPFExec: "unknown", EBPFSyscall: "unknown", Host: host, } record, err := schema.Build("status", status, host, timestamp) if err != nil { return nil, err } return append(events, record), nil } // 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") } 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: } } }