// SPDX-License-Identifier: GPL-3.0-or-later package fim import ( "context" "encoding/json" "os" "path/filepath" "sync" "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) const ( baselineFilename = "fim-baseline.json" pacmanTimeout = 10 * time.Minute ) // Manager owns the deliberately separate, slow integrity work. Its scans run // in at most one goroutine per engine and only hand completed alerts back to // the polling loop. That keeps filesystem hashing and pacman from holding a // detector, snapshot, or event-output lock. type Manager struct { Config config.Config Now func() time.Time Scan func([]string) map[string]Entry Verify func(context.Context) (string, error) mu sync.Mutex baseline map[string]Entry initialized bool lastScan time.Time lastVerify time.Time scanRunning bool verifyRunning bool pendingAlerts []model.Alert } // New builds a manager with real host collection. Tests replace Scan or Verify // so they never read the host or execute a package manager. func New(cfg config.Config) *Manager { return &Manager{ Config: cfg, Now: time.Now, Scan: ScanPaths, Verify: PacmanVerify, } } // Initialize loads a valid non-empty baseline or builds it once. Like Python, // baseline creation is a local operator-state action: regular scans never // silently accept a changed file as the new truth. The caller supplies LogDir // only in explicit sidecar mode, so this baseline can never share or rewrite // the Python service's production state. func (m *Manager) Initialize() error { if !m.Config.FIMEnabled { return nil } m.mu.Lock() if m.initialized { m.mu.Unlock() return nil } m.mu.Unlock() if err := os.MkdirAll(m.Config.LogDir, 0o750); err != nil { return err } baseline := loadBaseline(filepath.Join(m.Config.LogDir, baselineFilename)) if len(baseline) == 0 { // A missing, unreadable, malformed, or empty baseline has no trustworthy // historical truth. Rebuilding it is safer than treating every file as a // change, and it happens only during initialization—not a later scan. baseline = m.Scan(PathList(m.Config.FIMPaths)) if err := writeBaseline(filepath.Join(m.Config.LogDir, baselineFilename), baseline); err != nil { return err } } m.mu.Lock() defer m.mu.Unlock() if !m.initialized { m.baseline = baseline m.initialized = true } return nil } // MaybeStart schedules due work and returns immediately. A short interval does // not start overlapping scans, which bounds both disk pressure and pacman // process count even if a scan takes longer than the configured cadence. func (m *Manager) MaybeStart(now time.Time) { if !m.Config.FIMEnabled { return } m.mu.Lock() if !m.initialized { m.mu.Unlock() return } if !m.scanRunning && (m.lastScan.IsZero() || now.Sub(m.lastScan) >= m.Config.FIMScanInterval) { m.scanRunning = true m.lastScan = now // Copy state while protected, then release the mutex before filesystem // I/O. Holding the lock through hashing would block Drain and could make // regular detector sweeps wait behind a large tree. baseline := cloneEntries(m.baseline) paths := append([]string(nil), PathList(m.Config.FIMPaths)...) go m.runScan(baseline, paths) } if m.Config.FIMPackageVerify && !m.verifyRunning && (m.lastVerify.IsZero() || now.Sub(m.lastVerify) >= m.Config.FIMPackageVerifyInterval) { m.verifyRunning = true m.lastVerify = now go m.runVerify() } m.mu.Unlock() } func (m *Manager) runScan(baseline map[string]Entry, paths []string) { // The baseline intentionally remains immutable after initialization. A // completed scan reports drift; it never turns observed drift into truth. alerts := DiffAlerts(Diff(baseline, m.Scan(paths))) m.mu.Lock() m.pendingAlerts = append(m.pendingAlerts, alerts...) m.scanRunning = false m.mu.Unlock() } func (m *Manager) runVerify() { // pacman can block on its database or a slow disk. Its own deadline keeps // the optional verification feature from accumulating background workers. ctx, cancel := context.WithTimeout(context.Background(), pacmanTimeout) defer cancel() alerts := VerifyAlerts(func() (string, error) { return m.Verify(ctx) }) m.mu.Lock() m.pendingAlerts = append(m.pendingAlerts, alerts...) m.verifyRunning = false m.mu.Unlock() } // Drain returns completed background results once. The caller must route them // through the shared Agent cooldown and snapshot paths before exposing them. func (m *Manager) Drain() []model.Alert { m.mu.Lock() defer m.mu.Unlock() alerts := append([]model.Alert(nil), m.pendingAlerts...) m.pendingAlerts = nil return alerts } func loadBaseline(path string) map[string]Entry { entries := make(map[string]Entry) raw, err := os.ReadFile(path) if err != nil || json.Unmarshal(raw, &entries) != nil { return map[string]Entry{} } return entries } func writeBaseline(path string, entries map[string]Entry) error { raw, err := json.Marshal(entries) if err != nil { return err } raw = append(raw, '\n') // Write and rename in the same directory so a crash cannot leave a partly // written JSON file at the baseline path. The restrictive mode keeps hashes // and file metadata from becoming world-readable state. 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 cloneEntries(source map[string]Entry) map[string]Entry { result := make(map[string]Entry, len(source)) for path, entry := range source { result[path] = entry } return result }