enodia-sentinal/go-agent/cmd/enodia-sentinel-go/main_test.go

207 lines
6.7 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/agent"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/ebpfsource"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/snapshot"
)
type oneExecReader struct {
event events.ExecEvent
cancel context.CancelFunc
read bool
}
func (r *oneExecReader) Read() (events.ExecEvent, error) {
if !r.read {
r.read = true
return r.event, nil
}
r.cancel()
return events.ExecEvent{}, io.EOF
}
type oneSyscallReader struct {
event ebpfsource.SecurityEvent
cancel context.CancelFunc
read bool
}
func (r *oneSyscallReader) Read() (ebpfsource.SecurityEvent, error) {
if !r.read {
r.read = true
return r.event, nil
}
r.cancel()
return ebpfsource.SecurityEvent{}, io.EOF
}
func TestMonitorExecSourceUsesSharedAlertPipeline(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneExecReader{
event: events.ExecEvent{PID: 42, PPID: 7, UID: 1000, ParentComm: "bash", Filename: "/tmp/dropper"},
cancel: cancel,
}
var records []map[string]any
monitorExecSource(ctx, reader, events.DefaultExecRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 || records[0]["event_type"] != "alert" {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100001 || alert.Key != "exec:100001:42" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestMonitorSyscallSourceUsesSharedAlertPipeline(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneSyscallReader{
event: ebpfsource.SecurityEvent{Syscall: &events.SyscallEvent{
PID: 42, PPID: 7, UID: 1000, Comm: "dropper", Syscall: "mprotect",
Args: [6]uint64{0x1000, 0x2000, 0x6},
}},
cancel: cancel,
}
var records []map[string]any
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 || records[0]["event_type"] != "alert" {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100060 || alert.Key != "syscall:100060:42:mprotect:1000:6" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestMonitorSyscallSourceRoutesTypedHostEvent(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneSyscallReader{
event: ebpfsource.SecurityEvent{Host: &events.HostEvent{
Event: "setuid", PID: 42, PPID: 7, UID: 1000, Comm: "python3",
TargetUID: 0, TargetGID: -1,
}},
cancel: cancel,
}
var records []map[string]any
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100071 || alert.Signature != "host_rule.privilege-transition" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func TestMonitorSyscallSourceRoutesPermissionChange(t *testing.T) {
runner := monitorTestAgent()
ctx, cancel := context.WithCancel(context.Background())
reader := &oneSyscallReader{
event: ebpfsource.SecurityEvent{Host: &events.HostEvent{
Event: "chmod", PID: 42, PPID: 7, UID: 1000, Comm: "installer",
Path: "/etc/systemd/system/backdoor.service", Mode: 0o755,
TargetUID: -1, TargetGID: -1,
}},
cancel: cancel,
}
var records []map[string]any
monitorSyscallSource(ctx, reader, events.DefaultSyscallRuleEngine(), events.DefaultHostRuleEngine(), runner, func(record map[string]any) error {
records = append(records, record)
return nil
})
if len(records) != 1 {
t.Fatalf("unexpected records: %#v", records)
}
alert := records[0]["alert"].(model.Alert)
if alert.SID != 100070 || alert.Signature != "host_rule.persistence-permission-change" {
t.Fatalf("unexpected alert: %#v", alert)
}
}
func monitorTestAgent() *agent.Agent {
runner := agent.New(config.Default(), nil)
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)
}
return runner
}
func TestProbeFailureStatusIsConcise(t *testing.T) {
if got := probeFailureStatus(fmt.Errorf("load: %w", syscall.EPERM)); got != "permission denied" {
t.Fatalf("permission status=%q", got)
}
got := probeFailureStatus(fmt.Errorf("%s\nverifier detail", strings.Repeat("x", 200)))
if len([]rune(got)) != 160 || !strings.HasSuffix(got, "…") {
t.Fatalf("unexpected truncated status: %q", got)
}
}
func TestIncidentReadOnlyCommandsUseIsolatedSnapshotState(t *testing.T) {
directory := t.TempDir()
store, err := incident.New(directory, true, 1800, 8)
if err != nil {
t.Fatal(err)
}
when := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC)
id, err := store.RecordAlert("alert-1.log", model.Alert{SID: 1, Severity: "HIGH", Signature: "test", PIDs: []int{42}}, map[int]bool{42: true}, when, "host-a", "")
if err != nil {
t.Fatal(err)
}
report := snapshot.Report{Schema: snapshot.Schema, Time: when.Format(time.RFC3339), Host: "host-a", Severity: "HIGH", Alerts: []model.Alert{{SID: 1, Severity: "HIGH", Signature: "test"}}, Processes: []snapshot.ProcessDetail{{PID: 42}}, Enrichment: map[string]any{}}
raw, err := json.Marshal(report)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "alert-1.json"), raw, 0o600); err != nil {
t.Fatal(err)
}
var list bytes.Buffer
if err := writeIncidentList(directory, &list); err != nil {
t.Fatal(err)
}
if !strings.Contains(list.String(), id) {
t.Fatalf("list=%s", list.String())
}
var view bytes.Buffer
if err := writeIncidentView(directory, id, &view); err != nil {
t.Fatal(err)
}
var decoded map[string]any
if err := json.Unmarshal(view.Bytes(), &decoded); err != nil || decoded["schema"] != "enodia.incident.view.v1" {
t.Fatalf("view=%s err=%v", view.String(), err)
}
if len(decoded["timeline"].([]any)) != 1 || len(decoded["snapshots"].([]any)) != 1 {
t.Fatalf("view=%#v", decoded)
}
}