456 lines
14 KiB
Go
456 lines
14 KiB
Go
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package snapshot retains Python-compatible alert snapshot pairs for the Go
|
|
// migration service, including process-lineage incident linkage. Rich post-
|
|
// alert enrichment remains a later parity tranche.
|
|
package snapshot
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
|
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/enrich"
|
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
|
|
"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
|
|
ParentOf func(int) int
|
|
Incidents *incident.Store
|
|
LineageDepth int
|
|
|
|
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, LineageDepth: 8}
|
|
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
|
|
s.LineageDepth = lineageDepth
|
|
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.
|
|
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)
|
|
pids := alertPIDs(report.Alerts)
|
|
lineage := correlation.LineageOf(pids, s.ParentOf, s.LineageDepth)
|
|
if s.Incidents != nil {
|
|
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
|
|
}
|
|
}
|
|
report.Enrichment = enrich.Build(report.Alerts, enrichmentProcesses(report.Processes), lineageValues(lineage))
|
|
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 {
|
|
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 {
|
|
for _, pid := range alert.PIDs {
|
|
if pid > 0 && !seen[pid] {
|
|
seen[pid] = true
|
|
pids = append(pids, pid)
|
|
}
|
|
}
|
|
}
|
|
sort.Ints(pids)
|
|
return pids
|
|
}
|
|
|
|
func enrichmentProcesses(processes []ProcessDetail) []enrich.Process {
|
|
result := make([]enrich.Process, 0, len(processes))
|
|
for _, process := range processes {
|
|
result = append(result, enrich.Process{
|
|
PID: process.PID, Comm: process.Comm, Exe: process.Exe,
|
|
PPID: process.PPID, PPIDComm: process.PPIDComm,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func lineageValues(lineage map[int]bool) []int {
|
|
result := make([]int, 0, len(lineage))
|
|
for pid, included := range lineage {
|
|
if included {
|
|
result = append(result, pid)
|
|
}
|
|
}
|
|
sort.Ints(result)
|
|
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)
|
|
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)
|
|
}
|
|
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 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
|
|
}
|