// SPDX-License-Identifier: GPL-3.0-or-later package snapshot import ( "encoding/json" "os" "path/filepath" "testing" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) func TestCaptureEventWritesCompatiblePairAndMergesSameSecond(t *testing.T) { store, err := New(t.TempDir(), "/unused", 10, 30) if err != nil { t.Fatal(err) } store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, Comm: "python3", PPID: 1, UID: 1000, FDs: map[string]string{"1": "socket:[9]"}} } first := alertEvent("2026-07-22T10:11:12.100-07:00", model.Alert{ SID: 100069, Severity: "HIGH", Signature: "host_rule.persistence-write", Classtype: "persistence-write", Key: "one", Detail: "first", PIDs: []int{42}, }) second := alertEvent("2026-07-22T10:11:12.900-07:00", model.Alert{ SID: 100001, Severity: "CRITICAL", Signature: "exec_rule.fileless", Classtype: "execution", Key: "two", Detail: "second", PIDs: []int{7, 42}, }) name, err := store.CaptureEvent(first) if err != nil { t.Fatal(err) } if name != "alert-20260722-101112.log" { t.Fatalf("name=%q", name) } if _, err := store.CaptureEvent(second); err != nil { t.Fatal(err) } base := filepath.Join(store.Dir, "alert-20260722-101112") raw, err := os.ReadFile(base + ".json") if err != nil { t.Fatal(err) } var report Report if err := json.Unmarshal(raw, &report); err != nil { t.Fatal(err) } if report.Schema != Schema || report.IncidentID != nil || report.Severity != "CRITICAL" || len(report.Alerts) != 2 || len(report.Processes) != 2 || report.Enrichment["source"] != "enodia-sentinel-go" { t.Fatalf("report=%#v", report) } for _, path := range []string{base + ".json", base + ".log"} { info, err := os.Stat(path) if err != nil { t.Fatal(err) } if info.Mode().Perm() != 0o600 { t.Fatalf("%s mode=%#o", path, info.Mode().Perm()) } } stats, err := store.SnapshotStats() if err != nil { t.Fatal(err) } if stats.Total != 1 || stats.Counts["CRITICAL"] != 1 || stats.LastAlert == nil || *stats.LastAlert != first["timestamp"] { t.Fatalf("stats=%#v", stats) } } func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) { store, err := New(t.TempDir(), "/unused", 2, 1) if err != nil { t.Fatal(err) } store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, FDs: map[string]string{}} } now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC) store.Now = func() time.Time { return now } for index, timestamp := range []string{ "2026-07-20T10:00:00Z", "2026-07-22T10:00:01Z", "2026-07-22T10:00:02Z", } { event := alertEvent(timestamp, model.Alert{SID: 100010 + index, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"}) if _, err := store.CaptureEvent(event); err != nil { t.Fatal(err) } base := filepath.Join(store.Dir, "alert-"+mustTime(t, timestamp).Format("20060102-150405")) mod := now.Add(time.Duration(index-2) * time.Hour) if index == 0 { mod = now.Add(-48 * time.Hour) } for _, suffix := range []string{".json", ".log"} { if err := os.Chtimes(base+suffix, mod, mod); err != nil { t.Fatal(err) } } } // A final capture runs pruning after the test-controlled mtimes are set. if _, err := store.CaptureEvent(alertEvent("2026-07-22T10:00:03Z", model.Alert{SID: 100099, Severity: "MEDIUM", Signature: "test", Classtype: "test", Key: "last", Detail: "last"})); err != nil { t.Fatal(err) } jsonFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.json")) logFiles, _ := filepath.Glob(filepath.Join(store.Dir, "alert-*.log")) if len(jsonFiles) != 2 || len(logFiles) != 2 { t.Fatalf("retained json=%v log=%v", jsonFiles, logFiles) } if _, err := os.Stat(filepath.Join(store.Dir, "alert-20260720-100000.json")); !os.IsNotExist(err) { t.Fatalf("old snapshot not pruned: %v", err) } } func TestNewPrunesExpiredPairBeforeReadiness(t *testing.T) { directory := t.TempDir() base := filepath.Join(directory, "alert-20200101-000000") old := time.Now().Add(-48 * time.Hour) for _, suffix := range []string{".json", ".log"} { if err := os.WriteFile(base+suffix, []byte("old"), 0o600); err != nil { t.Fatal(err) } if err := os.Chtimes(base+suffix, old, old); err != nil { t.Fatal(err) } } if _, err := New(directory, "/unused", 300, 1); err != nil { t.Fatal(err) } for _, suffix := range []string{".json", ".log"} { if _, err := os.Stat(base + suffix); !os.IsNotExist(err) { t.Fatalf("expired %s remains: %v", suffix, err) } } probes, _ := filepath.Glob(filepath.Join(directory, ".snapshot-preflight-*")) if len(probes) != 0 { t.Fatalf("preflight files remain: %v", probes) } } func TestCaptureRejectsCorruptExistingSnapshot(t *testing.T) { store, err := New(t.TempDir(), "/unused", 10, 30) if err != nil { t.Fatal(err) } path := filepath.Join(store.Dir, "alert-20260722-101112.json") if err := os.WriteFile(path, []byte("broken"), 0o600); err != nil { t.Fatal(err) } _, err = store.CaptureEvent(alertEvent("2026-07-22T10:11:12Z", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "key", Detail: "detail"})) if err == nil { t.Fatal("corrupt snapshot accepted") } } func TestCollectProcessBuildsPythonCompatibleContext(t *testing.T) { procRoot := t.TempDir() processDir := filepath.Join(procRoot, "42") parentDir := filepath.Join(procRoot, "7") if err := os.MkdirAll(filepath.Join(processDir, "fd"), 0o755); err != nil { t.Fatal(err) } if err := os.MkdirAll(parentDir, 0o755); err != nil { t.Fatal(err) } files := map[string]string{ filepath.Join(processDir, "comm"): "python3\n", filepath.Join(processDir, "cmdline"): "python3\x00/tmp/dropper.py\x00", filepath.Join(processDir, "status"): "Name:\tpython3\nPPid:\t7\nUid:\t1000\t1000\t1000\t1000\n", filepath.Join(processDir, "environ"): "HOME=/tmp\x00LD_PRELOAD=/tmp/hook.so\x00", filepath.Join(parentDir, "comm"): "nginx\n", } for path, content := range files { if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } } for path, target := range map[string]string{ filepath.Join(processDir, "exe"): "/usr/bin/python3", filepath.Join(processDir, "cwd"): "/tmp", filepath.Join(processDir, "fd", "1"): "socket:[99]", } { if err := os.Symlink(target, path); err != nil { t.Fatal(err) } } detail := collectProcess(procRoot, 42) if detail.PID != 42 || detail.Comm != "python3" || detail.Cmdline != "python3 /tmp/dropper.py" || detail.Exe != "/usr/bin/python3" || detail.CWD != "/tmp" || detail.PPID != 7 || detail.PPIDComm != "nginx" || detail.UID != 1000 || detail.LDPreload != "/tmp/hook.so" || detail.FDs["1"] != "socket:[99]" { t.Fatalf("detail=%#v", detail) } } func alertEvent(timestamp string, alert model.Alert) map[string]any { return map[string]any{ "schema": "enodia.event.v1", "event_type": "alert", "timestamp": timestamp, "host": "host-a", "alert": alert, } } func mustTime(t *testing.T, value string) time.Time { t.Helper() parsed, err := time.Parse(time.RFC3339Nano, value) if err != nil { t.Fatal(err) } return parsed }