feat(go): persist Python-compatible incidents
This commit is contained in:
parent
f85c2e831a
commit
538d1d954a
13 changed files with 555 additions and 46 deletions
|
|
@ -1,9 +1,8 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package snapshot retains Python-compatible alert snapshot pairs for the Go
|
||||
// migration service. It intentionally leaves incident grouping and rich
|
||||
// post-alert enrichment to later parity tranches while preserving the stable
|
||||
// v1 fields consumed by existing read-only tools.
|
||||
// migration service, including process-lineage incident linkage. Rich post-
|
||||
// alert enrichment remains a later parity tranche.
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
|
|
@ -17,6 +16,8 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
|
|
@ -59,6 +60,8 @@ type Store struct {
|
|||
MaxAgeDays int
|
||||
Now func() time.Time
|
||||
Collect func(int) ProcessDetail
|
||||
ParentOf func(int) int
|
||||
Incidents *incident.Store
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
|
@ -96,12 +99,24 @@ func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) {
|
|||
}
|
||||
store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now}
|
||||
store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) }
|
||||
store.ParentOf = func(pid int) int { return readParent(procRoot, pid) }
|
||||
if err := store.Prune(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// ConfigureIncidents preflights Python-compatible incident persistence before
|
||||
// service readiness. A disabled tracker leaves incident_id null.
|
||||
func (s *Store) ConfigureIncidents(enabled bool, window, lineageDepth int) error {
|
||||
tracker, err := incident.New(s.Dir, enabled, window, lineageDepth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Incidents = tracker
|
||||
return nil
|
||||
}
|
||||
|
||||
// CaptureEvent merges one alert envelope into its second-granularity snapshot.
|
||||
// Python filenames use second precision, so merging avoids overwriting sibling
|
||||
// alerts emitted by one poll sweep or concurrent kernel sources.
|
||||
|
|
@ -145,6 +160,19 @@ func (s *Store) CaptureEvent(event map[string]any) (string, error) {
|
|||
report.Alerts = append(report.Alerts, alert)
|
||||
report.Severity = maxSeverity(report.Severity, alert.Severity)
|
||||
report.Processes = s.collectProcesses(report.Alerts)
|
||||
if s.Incidents != nil {
|
||||
pids := alertPIDs(report.Alerts)
|
||||
lineage := correlation.LineageOf(pids, s.ParentOf, s.Incidents.Depth)
|
||||
preferred := ""
|
||||
if report.IncidentID != nil {
|
||||
preferred = *report.IncidentID
|
||||
}
|
||||
// Incident persistence is best-effort, matching Python: an index problem
|
||||
// must never discard the forensic snapshot itself.
|
||||
if id, err := s.Incidents.RecordAlert(filepath.Base(base+".log"), alert, lineage, captured, host, preferred); err == nil && id != "" {
|
||||
report.IncidentID = &id
|
||||
}
|
||||
}
|
||||
if report.Enrichment == nil {
|
||||
report.Enrichment = map[string]any{"source": "enodia-sentinel-go"}
|
||||
}
|
||||
|
|
@ -161,6 +189,15 @@ func (s *Store) CaptureEvent(event map[string]any) (string, error) {
|
|||
}
|
||||
|
||||
func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
|
||||
pids := alertPIDs(alerts)
|
||||
result := make([]ProcessDetail, 0, len(pids))
|
||||
for _, pid := range pids {
|
||||
result = append(result, s.Collect(pid))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func alertPIDs(alerts []model.Alert) []int {
|
||||
seen := map[int]bool{}
|
||||
var pids []int
|
||||
for _, alert := range alerts {
|
||||
|
|
@ -172,11 +209,7 @@ func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail {
|
|||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
result := make([]ProcessDetail, 0, len(pids))
|
||||
for _, pid := range pids {
|
||||
result = append(result, s.Collect(pid))
|
||||
}
|
||||
return result
|
||||
return pids
|
||||
}
|
||||
|
||||
// SnapshotStats prunes first, then derives status claims from retained,
|
||||
|
|
@ -312,6 +345,9 @@ func formatText(report Report) string {
|
|||
fmt.Fprintln(&output, "Time: ", report.Time)
|
||||
fmt.Fprintln(&output, "Host: ", report.Host)
|
||||
fmt.Fprintln(&output, "Severity:", report.Severity)
|
||||
if report.IncidentID != nil {
|
||||
fmt.Fprintln(&output, "Incident:", *report.IncidentID)
|
||||
}
|
||||
fmt.Fprintln(&output, "\n## Triggering detections")
|
||||
for _, alert := range report.Alerts {
|
||||
fmt.Fprintf(&output, " [%s] sid:%d %s (%s) — %s\n", alert.Severity, alert.SID, alert.Signature, alert.Classtype, alert.Detail)
|
||||
|
|
@ -379,6 +415,18 @@ func readRaw(path string) string {
|
|||
|
||||
func readText(path string) string { return strings.TrimSpace(readRaw(path)) }
|
||||
|
||||
func readParent(procRoot string, pid int) int {
|
||||
status := readRaw(filepath.Join(procRoot, strconv.Itoa(pid), "status"))
|
||||
for _, line := range strings.Split(status, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[0] == "PPid:" {
|
||||
parent, _ := strconv.Atoi(fields[1])
|
||||
return parent
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readLink(path string) string {
|
||||
target, _ := os.Readlink(path)
|
||||
return target
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -69,6 +70,60 @@ func TestCaptureEventWritesCompatiblePairAndMergesSameSecond(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 2, 1)
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue