227 lines
6.8 KiB
Go
227 lines
6.8 KiB
Go
// 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)
|
|
}
|
|
}
|
|
}
|