// SPDX-License-Identifier: GPL-3.0-or-later package agent import ( "context" "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 } func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) { agent := New(config.Default(), func() (model.State, error) { return model.State{Processes: []model.Process{ {PID: 42, Comm: "dropper", Exe: "/tmp/dropper (deleted)"}, }}, nil }) agent.Host = func() (string, error) { return "host-a", nil } agent.Now = func() time.Time { return time.Date(2026, 7, 10, 0, 0, 0, 0, time.FixedZone("PDT", -7*3600)) } var events []map[string]any err := agent.Run(context.Background(), true, func(event map[string]any) error { events = append(events, event) return nil }) if err != nil { t.Fatal(err) } if len(events) != 2 || events[0]["event_type"] != "alert" || events[1]["event_type"] != "status" { t.Fatalf("unexpected events: %#v", events) } alert := events[0]["alert"].(model.Alert) if alert.SID != 100012 || alert.Detail != "pid=42 comm=dropper exe=[/tmp/dropper (deleted)]" { t.Fatalf("unexpected alert payload: %#v", alert) } } func TestRunAttachesLiveBaselineLifecycle(t *testing.T) { cfg := config.Default() cfg.LogDir = t.TempDir() cfg.BaselineGrace = 0 // This fixture exercises listener-baseline lifecycle only. Avoid inheriting // the host's live persistence paths (for example /etc/passwd), whose mtime // would otherwise add an unrelated environment-dependent alert. cfg.Detectors["persistence"] = false 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{} agent := New(cfg, func() (model.State, error) { return model.State{Processes: []model.Process{{PID: 42, Exe: "/tmp/x (deleted)"}}}, nil }) events, err := agent.Sweep() if err != nil { t.Fatal(err) } if len(events) != 1 || events[0]["event_type"] != "status" { t.Fatalf("unexpected events: %#v", events) } } 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{ LDPreload: "/tmp/global.so", Processes: []model.Process{ { PID: 42, Comm: "dropper", Exe: "/tmp/dropper (deleted)", FDTargets: map[string]string{"4": "/etc/shadow", "7": "/dev/input/event3"}, }, { PID: 100, Comm: "bash", Cmdline: "bash -i", FDTargets: map[string]string{"0": "socket:[999]"}, }, }, Sockets: []model.Socket{ {State: "ESTAB", Local: "127.0.0.1:55", Peer: "9.9.9.9:443", Inode: intPointer(999), Comm: "bash", PID: intPointer(100), Kind: "tcp"}, {State: "ESTAB", Local: "10.0.0.2:5000", Peer: "8.8.8.8:4443", Comm: "hoxha", PID: intPointer(340), Kind: "sctp"}, }, }, nil }) events, err := agent.Sweep() if err != nil { t.Fatal(err) } want := []string{ "reverse_shell", "ld_preload", "deleted_exe", "input_snooper", "credential_access", "stealth_network", "egress", } if len(events) != len(want)+1 { t.Fatalf("unexpected events: %#v", events) } for index, signature := range want { alert := events[index]["alert"].(model.Alert) if alert.Signature != signature { t.Fatalf("event %d: got %s, want %s", index, alert.Signature, signature) } } }