feat(go): expose retained incident views

This commit is contained in:
Luna 2026-07-22 03:12:34 -07:00
parent eff31b3a82
commit 6355fb403d
No known key found for this signature in database
6 changed files with 166 additions and 7 deletions

View file

@ -10,8 +10,11 @@ import (
"errors"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"sync"
"syscall"
@ -25,6 +28,7 @@ import (
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/eventlog"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/events"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/health"
"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/schema"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/sdnotify"
@ -57,6 +61,8 @@ func run() error {
rulesList := flag.Bool("rules-list", false, "emit the active event-rule catalog as JSON and exit")
rulesShow := flag.Int("rules-show", 0, "emit one event rule by SID as JSON and exit")
correlateFile := flag.String("correlate", "", "correlate a JSON array of incident summaries and exit")
incidentsList := flag.Bool("incidents-list", false, "list retained Go incidents as JSON and exit")
incidentShow := flag.String("incident-show", "", "show one retained Go incident timeline as JSON and exit")
ebpfExec := flag.Bool("ebpf-exec", false, "enable the native execve eBPF source (fails open to polling)")
ebpfSyscall := flag.Bool("ebpf-syscall", false, "enable the native security-syscall eBPF source (fails open to polling)")
checkHealth := flag.Bool("health", false, "check the state heartbeat as JSON and exit")
@ -91,6 +97,19 @@ func run() error {
}
return nil
}
if *incidentsList || *incidentShow != "" {
directory := *snapshotDir
if directory == "" {
directory = *stateDir
}
if directory == "" {
directory = "/var/lib/enodia-sentinel-go"
}
if *incidentsList {
return writeIncidentList(directory, os.Stdout)
}
return writeIncidentView(directory, *incidentShow, os.Stdout)
}
cfg, err := config.Load(*configPath)
if err != nil {
@ -307,6 +326,62 @@ func run() error {
return runner.Run(ctx, *once, emit)
}
func writeIncidentList(directory string, output io.Writer) error {
index, err := incident.LoadIndex(directory)
if err != nil {
return err
}
items := make([]*incident.Record, 0, len(index))
for _, item := range index {
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool { return items[i].LastTS > items[j].LastTS })
return json.NewEncoder(output).Encode(items)
}
func writeIncidentView(directory, id string, output io.Writer) error {
index, err := incident.LoadIndex(directory)
if err != nil {
return err
}
item := index[id]
if item == nil {
return fmt.Errorf("no such incident: %s", id)
}
timeline := make([]map[string]any, 0, len(item.Snapshots))
snapshots := make([]snapshot.Report, 0, len(item.Snapshots))
for _, name := range item.Snapshots {
path := filepath.Join(directory, strings.TrimSuffix(name, ".log")+".json")
report, err := snapshot.LoadReport(path)
if err != nil {
timeline = append(timeline, map[string]any{"snapshot": name, "time": "?", "missing": true})
continue
}
snapshots = append(snapshots, report)
signatures := make([]string, 0, len(report.Alerts))
pids := make([]int, 0, len(report.Processes))
seen := map[string]bool{}
for _, alert := range report.Alerts {
if !seen[alert.Signature] {
seen[alert.Signature] = true
signatures = append(signatures, alert.Signature)
}
}
for _, process := range report.Processes {
pids = append(pids, process.PID)
}
timeline = append(timeline, map[string]any{
"snapshot": name, "time": report.Time, "severity": report.Severity,
"signatures": signatures, "pids": pids,
})
}
sort.Slice(timeline, func(i, j int) bool { return timeline[i]["time"].(string) < timeline[j]["time"].(string) })
return json.NewEncoder(output).Encode(map[string]any{
"schema": "enodia.incident.view.v1", "incident": item,
"timeline": timeline, "snapshots": snapshots,
})
}
func monitorExecSource(
ctx context.Context,
source execEventReader,

View file

@ -3,9 +3,13 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
@ -15,7 +19,9 @@ import (
"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 {
@ -160,3 +166,42 @@ func TestProbeFailureStatusIsConcise(t *testing.T) {
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)
}
}