// SPDX-License-Identifier: GPL-3.0-or-later // Package incident persists Python-compatible incident grouping for retained // Go alert snapshots. Grouping is process-lineage first and time-window second; // it adds evidence without hiding or replacing the raw alerts. package incident import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" "sort" "sync" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) const ( Schema = "enodia.incident.v1" IndexName = "incidents.json" MaxIncidents = 1000 ) type Record struct { Schema string `json:"schema"` ID string `json:"id"` Host string `json:"host"` FirstTS float64 `json:"first_ts"` LastTS float64 `json:"last_ts"` FirstSeen string `json:"first_seen"` LastSeen string `json:"last_seen"` Severity string `json:"severity"` Signatures []string `json:"signatures"` SIDs []int `json:"sids"` PIDs []int `json:"pids"` Lineage []int `json:"lineage"` Snapshots []string `json:"snapshots"` AlertCount int `json:"alert_count"` Correlations []correlation.Record `json:"correlations"` } type Store struct { Dir string Window int Depth int Enabled bool mu sync.Mutex } // New validates incident settings and preflights the isolated state directory. func New(dir string, enabled bool, window, depth int) (*Store, error) { if dir == "" { return nil, fmt.Errorf("incident directory is required") } if window < 0 || depth < 0 { return nil, fmt.Errorf("incident window and lineage depth must be non-negative") } if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create incident directory: %w", err) } return &Store{Dir: dir, Window: window, Depth: depth, Enabled: enabled}, nil } // RecordAlert attaches one newly appended alert to a snapshot's incident. // preferredID keeps all alerts merged into the same snapshot in one incident. // The snapshot name itself is appended idempotently. func (s *Store) RecordAlert(snapshotName string, alert model.Alert, lineage map[int]bool, when time.Time, host, preferredID string) (string, error) { if !s.Enabled { return "", nil } s.mu.Lock() defer s.mu.Unlock() index, err := load(filepath.Join(s.Dir, IndexName)) if err != nil { return "", err } id := "" if preferredID != "" && index[preferredID] != nil { id = preferredID } if id == "" { candidates := make([]correlation.IndexIncident, 0, len(index)) for _, item := range index { candidates = append(candidates, correlation.IndexIncident{ ID: item.ID, Host: item.Host, LastTimestamp: item.LastTS, Lineage: item.Lineage, }) } id = correlation.Assign(candidates, lineage, float64(when.UnixNano())/1e9, host, s.Window) } whenFloat := float64(when.UnixNano()) / 1e9 whenISO := when.Format(time.RFC3339Nano) if id == "" { id, err = newID(when) if err != nil { return "", err } index[id] = &Record{ Schema: Schema, ID: id, Host: host, FirstTS: whenFloat, LastTS: whenFloat, FirstSeen: whenISO, LastSeen: whenISO, Severity: alert.Severity, Signatures: []string{}, SIDs: []int{}, PIDs: []int{}, Lineage: []int{}, Snapshots: []string{}, Correlations: []correlation.Record{}, } } item := index[id] item.Schema = Schema item.LastTS = whenFloat item.LastSeen = whenISO item.Severity = correlation.MaxSeverity(item.Severity, alert.Severity) item.Signatures = appendUniqueString(item.Signatures, alert.Signature) if alert.SID != 0 { item.SIDs = appendUniqueInt(item.SIDs, alert.SID) } item.PIDs = sortedUnion(item.PIDs, alert.PIDs) item.Lineage = sortedUnion(item.Lineage, mapKeys(lineage)) item.Snapshots = appendUniqueString(item.Snapshots, snapshotName) item.AlertCount++ item.Correlations = correlation.Correlate(correlation.Incident{ FirstTimestamp: item.FirstTS, LastTimestamp: item.LastTS, Signatures: item.Signatures, }, nil) for _, match := range item.Correlations { item.SIDs = appendUniqueInt(item.SIDs, match.SID) item.Severity = correlation.MaxSeverity(item.Severity, match.Severity) } if err := save(filepath.Join(s.Dir, IndexName), index); err != nil { return "", err } return id, nil } // LoadIndex reads the durable index for compatibility tests and consumers. func LoadIndex(dir string) (map[string]*Record, error) { return load(filepath.Join(dir, IndexName)) } func load(path string) (map[string]*Record, error) { raw, err := os.ReadFile(path) if os.IsNotExist(err) { return map[string]*Record{}, nil } if err != nil { return nil, fmt.Errorf("read incident index: %w", err) } index := map[string]*Record{} if json.Unmarshal(raw, &index) != nil { // Python treats malformed legacy state as an empty index. return map[string]*Record{}, nil } for id, item := range index { if item == nil { delete(index, id) continue } if item.Schema == "" { item.Schema = Schema } if item.Correlations == nil { item.Correlations = []correlation.Record{} } } return index, nil } func save(path string, index map[string]*Record) error { if len(index) > MaxIncidents { items := make([]*Record, 0, len(index)) for _, item := range index { items = append(items, item) } sort.Slice(items, func(i, j int) bool { if items[i].LastTS == items[j].LastTS { return items[i].ID < items[j].ID } return items[i].LastTS > items[j].LastTS }) trimmed := make(map[string]*Record, MaxIncidents) for _, item := range items[:MaxIncidents] { trimmed[item.ID] = item } index = trimmed } raw, err := json.MarshalIndent(index, "", " ") if err != nil { return fmt.Errorf("encode incident index: %w", err) } temporary := path + ".tmp" if err := os.WriteFile(temporary, append(raw, '\n'), 0o600); err != nil { return fmt.Errorf("write incident index: %w", err) } if err := os.Rename(temporary, path); err != nil { return fmt.Errorf("replace incident index: %w", err) } if err := os.Chmod(path, 0o600); err != nil { return fmt.Errorf("protect incident index: %w", err) } return nil } func newID(when time.Time) (string, error) { suffix := make([]byte, 2) if _, err := rand.Read(suffix); err != nil { return "", fmt.Errorf("generate incident id: %w", err) } return "inc-" + when.Local().Format("20060102-150405") + "-" + hex.EncodeToString(suffix), nil } func appendUniqueString(values []string, value string) []string { for _, current := range values { if current == value { return values } } return append(values, value) } func appendUniqueInt(values []int, value int) []int { for _, current := range values { if current == value { return values } } return append(values, value) } func sortedUnion(left, right []int) []int { values := make(map[int]bool, len(left)+len(right)) for _, value := range left { values[value] = true } for _, value := range right { values[value] = true } result := make([]int, 0, len(values)) for value := range values { result = append(result, value) } sort.Ints(result) return result } func mapKeys(values map[int]bool) []int { result := make([]int, 0, len(values)) for value, enabled := range values { if enabled { result = append(result, value) } } return result }