feat(go): persist Python-compatible incidents

This commit is contained in:
Luna 2026-07-22 01:59:49 -07:00
parent f85c2e831a
commit 538d1d954a
No known key found for this signature in database
13 changed files with 555 additions and 46 deletions

View file

@ -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