feat(go): advance validation sidecar toward production

This commit is contained in:
Luna 2026-07-22 01:52:19 -07:00
parent 6a06eba255
commit f85c2e831a
No known key found for this signature in database
92 changed files with 8881 additions and 91 deletions

View file

@ -0,0 +1,298 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package baseline owns the state that makes baseline-diff detectors useful
// outside the deterministic parity fixture. It deliberately stops short of
// forensic alert snapshots; bounded envelope retention belongs to eventlog.
package baseline
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
const firstSeenSchema = "enodia.first_seen.v1"
// CaptureFunc and ScanSUIDFunc keep host collection injectable. Unit tests can
// exercise the full lifecycle without reading the real /proc or filesystem.
type CaptureFunc func() (model.State, error)
type ScanSUIDFunc func() []string
type firstSeenStore struct {
Schema string `json:"schema"`
Initialized bool `json:"initialized"`
PublicDestinations map[string][]string `json:"public_destinations"`
ListenerPorts map[string][]string `json:"listener_ports"`
}
// Manager decorates each captured State with the previous-sweep and durable
// baseline views expected by Python's four baseline detectors.
type Manager struct {
Config config.Config
Now func() time.Time
ScanSUID ScanSUIDFunc
mu sync.Mutex
initialized bool
started time.Time
lastPersistScan time.Time
lastSUIDScan time.Time
lastSUIDRefresh time.Time
suidScanRunning bool
listenerBaseline []string
suidBaseline []string
suidCurrent []string
firstSeen firstSeenStore
}
func New(cfg config.Config, scanSUID ScanSUIDFunc) *Manager {
return &Manager{Config: cfg, Now: time.Now, ScanSUID: scanSUID}
}
// Initialize snapshots startup listeners and privileged files. As in Python,
// startup state becomes the baseline; stored listener/SUID files are operator
// artifacts, not inputs reloaded on daemon restart.
func (m *Manager) Initialize(capture CaptureFunc) error {
if capture == nil {
return fmt.Errorf("baseline capture is required")
}
started := m.Now()
state, err := capture()
if err != nil {
return err
}
listeners := listenerKeys(state.Sockets)
var suid []string
if m.ScanSUID != nil {
suid = sortedUnique(m.ScanSUID())
}
if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil {
return err
}
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "listener-baseline.json"), listeners); err != nil {
return err
}
if err := writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), suid); err != nil {
return err
}
firstSeen := loadFirstSeen(filepath.Join(m.Config.LogDir, "first-seen.json"))
m.mu.Lock()
defer m.mu.Unlock()
m.initialized = true
m.started = started
m.lastPersistScan = started
m.lastSUIDRefresh = started
m.listenerBaseline = listeners
m.suidBaseline = suid
m.suidCurrent = nil
m.firstSeen = firstSeen
return nil
}
// Prepare injects baseline state immediately before detectors run. During the
// grace window every baseline detector stays inert, matching the registry-wide
// listener_baseline gate in Python.
func (m *Manager) Prepare(state *model.State, now time.Time) error {
if state == nil {
return fmt.Errorf("baseline state is required")
}
m.mu.Lock()
if !m.initialized {
m.mu.Unlock()
return fmt.Errorf("baseline manager is not initialized")
}
armed := now.Sub(m.started) >= m.Config.BaselineGrace
if !armed {
state.ListenerBaseline = nil
state.SUIDBaseline = nil
state.SUIDBinaries = nil
state.PersistSince = nil
state.PersistenceFiles = nil
state.FirstSeenPublicDestinations = nil
state.FirstSeenListenerPorts = nil
m.mu.Unlock()
return nil
}
state.ListenerBaseline = append([]string(nil), m.listenerBaseline...)
state.SUIDBaseline = append([]string(nil), m.suidBaseline...)
if m.suidCurrent != nil {
state.SUIDBinaries = append([]string(nil), m.suidCurrent...)
}
since := float64(m.lastPersistScan.UnixNano()) / float64(time.Second)
state.PersistSince = &since
state.FirstSeenInitialized = m.firstSeen.Initialized
state.FirstSeenPublicDestinations = cloneStringMap(m.firstSeen.PublicDestinations)
state.FirstSeenListenerPorts = cloneStringMap(m.firstSeen.ListenerPorts)
m.maybeStartSUIDScanLocked(now)
m.mu.Unlock()
// Filesystem walking can be slower than copying the small durable stores,
// but it is bounded to explicitly configured persistence roots and remains
// fail-open per entry.
state.PersistenceFiles = collectPersistenceFiles(m.Config.WatchPersistence)
return nil
}
// Commit advances the previous-sweep timestamp only after detection and saves
// first-seen additions atomically. This ordering prevents changes observed in
// the current sweep from being skipped by the next one.
func (m *Manager) Commit(state model.State, now time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.initialized {
return fmt.Errorf("baseline manager is not initialized")
}
m.lastPersistScan = now
if now.Sub(m.started) < m.Config.BaselineGrace || !m.Config.Enabled("first_seen") {
return nil
}
m.firstSeen = firstSeenStore{
Schema: firstSeenSchema,
Initialized: true,
PublicDestinations: cloneStringMap(state.FirstSeenPublicDestinations),
ListenerPorts: cloneStringMap(state.FirstSeenListenerPorts),
}
return writeJSONAtomic(filepath.Join(m.Config.LogDir, "first-seen.json"), m.firstSeen)
}
func (m *Manager) maybeStartSUIDScanLocked(now time.Time) {
if m.ScanSUID == nil || m.suidScanRunning ||
(!m.lastSUIDScan.IsZero() && now.Sub(m.lastSUIDScan) < m.Config.SUIDScanInterval) {
return
}
m.lastSUIDScan = now
m.suidScanRunning = true
go func() {
result := sortedUnique(m.ScanSUID())
finished := m.Now()
m.mu.Lock()
defer m.mu.Unlock()
m.suidCurrent = result
m.suidScanRunning = false
if finished.Sub(m.lastSUIDRefresh) < m.Config.SUIDRefresh {
return
}
// Periodic folding matches Python: a legitimately installed privileged
// binary eventually becomes baseline instead of alerting forever.
m.suidBaseline = append([]string(nil), result...)
m.lastSUIDRefresh = finished
_ = writeJSONAtomic(filepath.Join(m.Config.LogDir, "suid-baseline.json"), result)
}()
}
func listenerKeys(sockets []model.Socket) []string {
keys := make([]string, 0)
for _, socket := range sockets {
if socket.State != "LISTEN" {
continue
}
port := socket.Local
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
port = socket.Local[index+1:]
}
comm := socket.Comm
if comm == "" {
comm = "?"
}
keys = append(keys, port+"/"+comm)
}
return sortedUnique(keys)
}
func collectPersistenceFiles(roots []string) map[string]float64 {
result := make(map[string]float64)
for _, root := range roots {
info, err := os.Lstat(root)
if err != nil {
continue
}
if !info.IsDir() {
result[root] = float64(info.ModTime().UnixNano()) / float64(time.Second)
continue
}
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() {
return nil
}
info, err := os.Lstat(path)
if err == nil {
result[path] = float64(info.ModTime().UnixNano()) / float64(time.Second)
}
return nil
})
}
return result
}
func loadFirstSeen(path string) firstSeenStore {
store := firstSeenStore{
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
ListenerPorts: map[string][]string{},
}
raw, err := os.ReadFile(path)
if err != nil || json.Unmarshal(raw, &store) != nil {
return firstSeenStore{
Schema: firstSeenSchema, PublicDestinations: map[string][]string{},
ListenerPorts: map[string][]string{},
}
}
store.Schema = firstSeenSchema
if store.PublicDestinations == nil {
store.PublicDestinations = map[string][]string{}
}
if store.ListenerPorts == nil {
store.ListenerPorts = map[string][]string{}
}
return store
}
func writeJSONAtomic(path string, value any) error {
raw, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
raw = append(raw, '\n')
temporary := path + ".tmp"
if err := os.WriteFile(temporary, raw, 0o640); err != nil {
return err
}
if err := os.Rename(temporary, path); err != nil {
return err
}
return os.Chmod(path, 0o640)
}
func cloneStringMap(source map[string][]string) map[string][]string {
result := make(map[string][]string, len(source))
for key, values := range source {
result[key] = append([]string(nil), values...)
}
return result
}
func sortedUnique(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
sort.Strings(result)
return result
}