// 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. package snapshot import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) const Schema = "enodia.alert.snapshot.v1" type ProcessDetail struct { PID int `json:"pid"` Comm string `json:"comm"` Exe string `json:"exe"` CWD string `json:"cwd"` Cmdline string `json:"cmdline"` PPID int `json:"ppid"` PPIDComm string `json:"ppid_comm"` UID int `json:"uid"` LDPreload string `json:"ld_preload"` FDs map[string]string `json:"fds"` } type Report struct { Schema string `json:"schema"` Time string `json:"time"` Host string `json:"host"` Severity string `json:"severity"` IncidentID *string `json:"incident_id"` Alerts []model.Alert `json:"alerts"` Processes []ProcessDetail `json:"processes"` Enrichment map[string]any `json:"enrichment"` } type Stats struct { Total int Counts map[string]int LastAlert *string } type Store struct { Dir string ProcRoot string MaxCount int MaxAgeDays int Now func() time.Time Collect func(int) ProcessDetail mu sync.Mutex } // New preflights the isolated snapshot directory before service readiness. func New(dir, procRoot string, maxCount, maxAgeDays int) (*Store, error) { if dir == "" { return nil, fmt.Errorf("snapshot directory is required") } if maxCount < 0 || maxAgeDays < 0 { return nil, fmt.Errorf("snapshot retention values must be non-negative") } if procRoot == "" { procRoot = "/proc" } if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create snapshot directory: %w", err) } probe, err := os.CreateTemp(dir, ".snapshot-preflight-*") if err != nil { return nil, fmt.Errorf("preflight snapshot directory: %w", err) } probePath := probe.Name() if err := probe.Chmod(0o600); err != nil { probe.Close() os.Remove(probePath) return nil, fmt.Errorf("protect snapshot preflight: %w", err) } if err := probe.Close(); err != nil { os.Remove(probePath) return nil, fmt.Errorf("close snapshot preflight: %w", err) } if err := os.Remove(probePath); err != nil { return nil, fmt.Errorf("remove snapshot preflight: %w", err) } store := &Store{Dir: dir, ProcRoot: procRoot, MaxCount: maxCount, MaxAgeDays: maxAgeDays, Now: time.Now} store.Collect = func(pid int) ProcessDetail { return collectProcess(procRoot, pid) } if err := store.Prune(); err != nil { return nil, err } return store, 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. func (s *Store) CaptureEvent(event map[string]any) (string, error) { if event["event_type"] != "alert" { return "", fmt.Errorf("snapshot requires an alert event") } alert, err := decodeAlert(event["alert"]) if err != nil { return "", err } timestamp, ok := event["timestamp"].(string) if !ok || timestamp == "" { return "", fmt.Errorf("snapshot event timestamp is required") } captured, err := time.Parse(time.RFC3339Nano, timestamp) if err != nil { return "", fmt.Errorf("parse snapshot timestamp: %w", err) } host, _ := event["host"].(string) base := filepath.Join(s.Dir, "alert-"+captured.Format("20060102-150405")) s.mu.Lock() defer s.mu.Unlock() report := Report{ Schema: Schema, Time: timestamp, Host: host, Severity: alert.Severity, Alerts: []model.Alert{}, Processes: []ProcessDetail{}, Enrichment: map[string]any{"source": "enodia-sentinel-go"}, } jsonPath := base + ".json" if raw, err := os.ReadFile(jsonPath); err == nil { if err := json.Unmarshal(raw, &report); err != nil { return "", fmt.Errorf("decode existing snapshot: %w", err) } if report.Schema != Schema { return "", fmt.Errorf("existing snapshot has schema %q", report.Schema) } } else if !os.IsNotExist(err) { return "", fmt.Errorf("read existing snapshot: %w", err) } report.Alerts = append(report.Alerts, alert) report.Severity = maxSeverity(report.Severity, alert.Severity) report.Processes = s.collectProcesses(report.Alerts) if report.Enrichment == nil { report.Enrichment = map[string]any{"source": "enodia-sentinel-go"} } if err := writeJSONAtomic(jsonPath, report); err != nil { return "", err } if err := writeAtomic(base+".log", []byte(formatText(report))); err != nil { return "", err } if err := s.pruneLocked(); err != nil { return "", err } return filepath.Base(base + ".log"), nil } func (s *Store) collectProcesses(alerts []model.Alert) []ProcessDetail { seen := map[int]bool{} var pids []int for _, alert := range alerts { for _, pid := range alert.PIDs { if pid > 0 && !seen[pid] { seen[pid] = true pids = append(pids, pid) } } } sort.Ints(pids) result := make([]ProcessDetail, 0, len(pids)) for _, pid := range pids { result = append(result, s.Collect(pid)) } return result } // SnapshotStats prunes first, then derives status claims from retained, // parseable snapshot files. Calling it for every status emission keeps the age // bound active even when a quiet host produces no new alert snapshots. func (s *Store) SnapshotStats() (Stats, error) { s.mu.Lock() defer s.mu.Unlock() if err := s.pruneLocked(); err != nil { return Stats{}, err } stats := Stats{Counts: map[string]int{}} paths, _ := filepath.Glob(filepath.Join(s.Dir, "alert-*.json")) for _, path := range paths { raw, err := os.ReadFile(path) if err != nil { continue } var report Report if json.Unmarshal(raw, &report) != nil || report.Schema != Schema { continue } stats.Total++ stats.Counts[report.Severity]++ if stats.LastAlert == nil || report.Time > *stats.LastAlert { value := report.Time stats.LastAlert = &value } } return stats, nil } // Prune applies count and age limits to JSON/text pairs. func (s *Store) Prune() error { s.mu.Lock() defer s.mu.Unlock() return s.pruneLocked() } func (s *Store) pruneLocked() error { paths, err := filepath.Glob(filepath.Join(s.Dir, "alert-*.json")) if err != nil { return fmt.Errorf("list snapshots: %w", err) } type candidate struct { path string mod time.Time } items := make([]candidate, 0, len(paths)) for _, path := range paths { info, err := os.Stat(path) if err == nil { items = append(items, candidate{path: path, mod: info.ModTime()}) } } sort.Slice(items, func(i, j int) bool { return items[i].mod.After(items[j].mod) }) cutoff := time.Time{} if s.MaxAgeDays > 0 { cutoff = s.Now().Add(-time.Duration(s.MaxAgeDays) * 24 * time.Hour) } kept := 0 for _, item := range items { remove := (!cutoff.IsZero() && item.mod.Before(cutoff)) || (s.MaxCount > 0 && kept >= s.MaxCount) if remove { if err := removePair(item.path); err != nil { return err } continue } kept++ } return nil } func decodeAlert(value any) (model.Alert, error) { raw, err := json.Marshal(value) if err != nil { return model.Alert{}, fmt.Errorf("encode snapshot alert: %w", err) } var alert model.Alert if err := json.Unmarshal(raw, &alert); err != nil { return model.Alert{}, fmt.Errorf("decode snapshot alert: %w", err) } if alert.SID == 0 || alert.Signature == "" || alert.Severity == "" { return model.Alert{}, fmt.Errorf("snapshot alert is incomplete") } return alert, nil } func maxSeverity(left, right string) string { rank := map[string]int{"MEDIUM": 1, "HIGH": 2, "CRITICAL": 3} if rank[right] > rank[left] { return right } return left } func writeJSONAtomic(path string, value any) error { raw, err := json.MarshalIndent(value, "", " ") if err != nil { return fmt.Errorf("encode snapshot: %w", err) } return writeAtomic(path, append(raw, '\n')) } func writeAtomic(path string, raw []byte) error { temporary := path + ".tmp" if err := os.WriteFile(temporary, raw, 0o600); err != nil { return fmt.Errorf("write snapshot: %w", err) } if err := os.Rename(temporary, path); err != nil { return fmt.Errorf("replace snapshot: %w", err) } if err := os.Chmod(path, 0o600); err != nil { return fmt.Errorf("protect snapshot: %w", err) } return nil } func removePair(jsonPath string) error { base := strings.TrimSuffix(jsonPath, ".json") for _, path := range []string{base + ".json", base + ".log"} { if err := os.Remove(path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove expired snapshot: %w", err) } } return nil } func formatText(report Report) string { var output strings.Builder fmt.Fprintln(&output, "=== ENODIA SENTINEL ALERT ===") fmt.Fprintln(&output, "Time: ", report.Time) fmt.Fprintln(&output, "Host: ", report.Host) fmt.Fprintln(&output, "Severity:", report.Severity) 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) } fmt.Fprintln(&output, "\n## Flagged process detail") for _, process := range report.Processes { fmt.Fprintf(&output, " pid:%d comm:%s exe:%s ppid:%d uid:%d cmdline:%s\n", process.PID, process.Comm, process.Exe, process.PPID, process.UID, process.Cmdline) } if len(report.Processes) == 0 { fmt.Fprintln(&output, " (no specific pid in alerts)") } return output.String() } func collectProcess(procRoot string, pid int) ProcessDetail { base := filepath.Join(procRoot, strconv.Itoa(pid)) detail := ProcessDetail{PID: pid, FDs: map[string]string{}} detail.Comm = readText(filepath.Join(base, "comm")) detail.Cmdline = strings.TrimSpace(strings.ReplaceAll(readRaw(filepath.Join(base, "cmdline")), "\x00", " ")) detail.Exe = readLink(filepath.Join(base, "exe")) detail.CWD = readLink(filepath.Join(base, "cwd")) status := readRaw(filepath.Join(base, "status")) for _, line := range strings.Split(status, "\n") { fields := strings.Fields(line) if len(fields) < 2 { continue } switch fields[0] { case "PPid:": detail.PPID, _ = strconv.Atoi(fields[1]) case "Uid:": detail.UID, _ = strconv.Atoi(fields[1]) } } detail.PPIDComm = readText(filepath.Join(procRoot, strconv.Itoa(detail.PPID), "comm")) for _, item := range strings.Split(readRaw(filepath.Join(base, "environ")), "\x00") { if strings.HasPrefix(item, "LD_PRELOAD=") { detail.LDPreload = strings.TrimPrefix(item, "LD_PRELOAD=") } } entries, _ := os.ReadDir(filepath.Join(base, "fd")) sort.Slice(entries, func(i, j int) bool { left, leftErr := strconv.Atoi(entries[i].Name()) right, rightErr := strconv.Atoi(entries[j].Name()) if leftErr == nil && rightErr == nil { return left < right } return entries[i].Name() < entries[j].Name() }) for _, entry := range entries { if len(detail.FDs) >= 40 { break } if target := readLink(filepath.Join(base, "fd", entry.Name())); target != "" { detail.FDs[entry.Name()] = target } } return detail } func readRaw(path string) string { raw, _ := os.ReadFile(path) return string(raw) } func readText(path string) string { return strings.TrimSpace(readRaw(path)) } func readLink(path string) string { target, _ := os.Readlink(path) return target }