enodia-sentinal/go-agent/internal/snapshot/store.go

612 lines
18 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/assurance"
"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
Assurance *assurance.Chain
slowJobs chan slowJob
slowClosed bool
slowWG sync.WaitGroup
mu sync.Mutex
}
type slowJob struct {
jsonPath string
logPath string
}
// 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) }
store.Assurance, err = assurance.New(dir)
if err != nil {
return nil, err
}
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
}
// EnableSlowEnrichment starts one bounded worker. It deliberately never runs
// package-manager commands: queue saturation or collector failure only leaves
// fields unknown and can never delay or reject an alert event.
func (s *Store) EnableSlowEnrichment() {
s.mu.Lock()
defer s.mu.Unlock()
if s.slowJobs != nil || s.slowClosed {
return
}
s.slowJobs = make(chan slowJob, 16)
go s.runSlowEnrichment(s.slowJobs)
}
// Close drains accepted slow-enrichment work during a clean service shutdown.
func (s *Store) Close() {
s.mu.Lock()
if s.slowJobs == nil || s.slowClosed {
s.mu.Unlock()
return
}
close(s.slowJobs)
s.slowClosed = true
s.mu.Unlock()
s.slowWG.Wait()
}
// FlushSlow waits for work accepted before the call. It is used by clean
// shutdown/tests; alert emission never waits for this collector.
func (s *Store) FlushSlow() { s.slowWG.Wait() }
// 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 s.Assurance != nil {
if _, err := s.Assurance.Append("snapshot-log", base+".log", captured); err != nil {
return "", err
}
if _, err := s.Assurance.Append("snapshot-json", jsonPath, captured); err != nil {
return "", err
}
}
if err := s.pruneLocked(); err != nil {
return "", err
}
s.enqueueSlow(slowJob{jsonPath: jsonPath, logPath: base + ".log"})
return filepath.Base(base + ".log"), nil
}
func (s *Store) enqueueSlow(job slowJob) {
if s.slowJobs == nil || s.slowClosed {
return
}
s.slowWG.Add(1)
select {
case s.slowJobs <- job:
default:
s.slowWG.Done()
}
}
func (s *Store) runSlowEnrichment(jobs <-chan slowJob) {
for job := range jobs {
s.applySlowEnrichment(job)
s.slowWG.Done()
}
}
func (s *Store) applySlowEnrichment(job slowJob) {
raw, err := os.ReadFile(job.jsonPath)
if err != nil {
return
}
var report Report
if json.Unmarshal(raw, &report) != nil || report.Schema != Schema {
return
}
processes := enrichmentProcesses(report.Processes)
paths := enrichmentPaths(report.Enrichment)
updates := enrich.CollectSlow(processes, paths)
// All slow I/O has completed. Take the lock only to merge into the newest
// same-second report, so a concurrent alert capture never loses evidence.
s.mu.Lock()
defer s.mu.Unlock()
raw, err = os.ReadFile(job.jsonPath)
if err != nil || json.Unmarshal(raw, &report) != nil || report.Schema != Schema {
return
}
mergeSlow(&report, updates)
if err := writeJSONAtomic(job.jsonPath, report); err != nil {
return
}
if err := writeAtomic(job.logPath, []byte(formatText(report))); err != nil {
return
}
if s.Assurance != nil {
captured, err := time.Parse(time.RFC3339Nano, report.Time)
if err != nil {
return
}
_, _ = s.Assurance.Append("snapshot-log", job.logPath, captured)
_, _ = s.Assurance.Append("snapshot-json", job.jsonPath, captured)
}
}
func enrichmentPaths(value map[string]any) []string {
rows, _ := value["files"].([]any)
paths := make([]string, 0, len(rows))
for _, row := range rows {
if item, ok := row.(map[string]any); ok {
if path, ok := item["path"].(string); ok && path != "" {
paths = append(paths, path)
}
}
}
return paths
}
func mergeSlow(report *Report, updates enrich.SlowResult) {
if report.Enrichment == nil {
return
}
if rows, ok := report.Enrichment["processes"].([]any); ok {
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
continue
}
pid, _ := item["pid"].(float64)
if update := updates.Processes[int(pid)]; update != nil {
item["exe_sha256"] = update["exe_sha256"]
}
}
}
if rows, ok := report.Enrichment["files"].([]any); ok {
for _, row := range rows {
item, ok := row.(map[string]any)
if !ok {
continue
}
path, _ := item["path"].(string)
if update := updates.Files[path]; update != nil {
for key, value := range update {
item[key] = value
}
}
}
}
}
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
}