320 lines
11 KiB
Go
320 lines
11 KiB
Go
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package snapshot
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance"
|
|
"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())
|
|
}
|
|
}
|
|
records, err := assurance.ReadAll(filepath.Join(store.Dir, assurance.ChainName))
|
|
if err != nil || len(records) != 4 || records[0].Kind != "snapshot-log" ||
|
|
records[1].Kind != "snapshot-json" || records[1].PrevHash != records[0].ChainHash {
|
|
t.Fatalf("assurance records=%#v err=%v", records, err)
|
|
}
|
|
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 TestCaptureEventLinksMergedSnapshotToOneIncident(t *testing.T) {
|
|
store, err := New(t.TempDir(), "/unused", 10, 30)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := store.ConfigureIncidents(true, 1800, 8); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
parents := map[int]int{42: 7, 7: 1, 99: 1}
|
|
store.ParentOf = func(pid int) int { return parents[pid] }
|
|
store.Collect = func(pid int) ProcessDetail {
|
|
return ProcessDetail{PID: pid, PPID: parents[pid], FDs: map[string]string{}}
|
|
}
|
|
first := alertEvent("2026-07-22T10:11:12.100-07:00", model.Alert{
|
|
SID: 100003, Severity: "HIGH", Signature: "exec_rule.web-rce", Classtype: "execution", Key: "one", Detail: "first", PIDs: []int{42},
|
|
})
|
|
second := alertEvent("2026-07-22T10:11:12.900-07:00", model.Alert{
|
|
SID: 100067, Severity: "HIGH", Signature: "host_rule.suspicious-egress", Classtype: "c2", Key: "two", Detail: "second", PIDs: []int{99},
|
|
})
|
|
if _, err := store.CaptureEvent(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := store.CaptureEvent(second); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(store.Dir, "alert-20260722-101112.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var report Report
|
|
if err := json.Unmarshal(raw, &report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.IncidentID == nil || *report.IncidentID == "" {
|
|
t.Fatalf("missing incident id: %#v", report)
|
|
}
|
|
indexRaw, err := os.ReadFile(filepath.Join(store.Dir, "incidents.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var index map[string]map[string]any
|
|
if err := json.Unmarshal(indexRaw, &index); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := index[*report.IncidentID]
|
|
if item["schema"] != "enodia.incident.v1" || item["alert_count"] != float64(2) || len(item["snapshots"].([]any)) != 1 {
|
|
t.Fatalf("incident=%#v", item)
|
|
}
|
|
textRaw, err := os.ReadFile(filepath.Join(store.Dir, "alert-20260722-101112.log"))
|
|
if err != nil || !strings.Contains(string(textRaw), "Incident: "+*report.IncidentID) {
|
|
t.Fatalf("text incident missing err=%v text=%q", err, textRaw)
|
|
}
|
|
}
|
|
|
|
func TestSlowEnrichmentAddsBoundedHashAndFileMetadata(t *testing.T) {
|
|
store, err := New(t.TempDir(), "/unused", 10, 30)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.EnableSlowEnrichment()
|
|
defer store.Close()
|
|
exe := filepath.Join(store.Dir, "payload")
|
|
watch := filepath.Join(store.Dir, "persistence.service")
|
|
if err := os.WriteFile(exe, []byte("stage-one"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(watch, []byte("[Service]"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.Collect = func(pid int) ProcessDetail {
|
|
return ProcessDetail{PID: pid, Comm: "payload", Exe: exe, FDs: map[string]string{}}
|
|
}
|
|
event := alertEvent("2026-07-22T10:11:12Z", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "file", Detail: "modified " + watch, PIDs: []int{42}})
|
|
if _, err := store.CaptureEvent(event); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store.FlushSlow()
|
|
raw, err := os.ReadFile(filepath.Join(store.Dir, "alert-20260722-101112.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var report Report
|
|
if err := json.Unmarshal(raw, &report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
processes := report.Enrichment["processes"].([]any)
|
|
process := processes[0].(map[string]any)
|
|
if digest, ok := process["exe_sha256"].(string); !ok || len(digest) != 64 {
|
|
t.Fatalf("process enrichment=%#v", process)
|
|
}
|
|
files := report.Enrichment["files"].([]any)
|
|
file := files[0].(map[string]any)
|
|
if file["exists"] != true || file["mode"] != float64(0o600) {
|
|
t.Fatalf("file enrichment=%#v", file)
|
|
}
|
|
integrity := report.Enrichment["integrity"].(map[string]any)
|
|
if integrity["go_assurance"].(map[string]any)["status"] != "present" ||
|
|
integrity["rootcheck"].(map[string]any)["enabled"] != false {
|
|
t.Fatalf("integrity enrichment=%#v", integrity)
|
|
}
|
|
logRaw, err := os.ReadFile(filepath.Join(store.Dir, "alert-20260722-101112.log"))
|
|
if err != nil || !strings.Contains(string(logRaw), "ENODIA SENTINEL ALERT") {
|
|
t.Fatalf("slow log err=%v text=%q", err, logRaw)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|