feat(go): port FIM/pkgdb/rootcheck integrity engines and add go-sidecar dashboard consumer

Adds sidecar-only, --state-dir-gated FIM, package-DB-anchor, and rootcheck
engines to the Go migration sidecar, wired into agent.Sweep/Initialize
behind the existing baseline lifecycle. Adds a read-only, schema-checked
Python dashboard consumer (go_sidecar_state_dir, /api/go-sidecar/*, Sidecar
tab) that never starts, stops, or mutates the Go sidecar's state.

Also fixes drift found while reconciling this work: enodia_sentinel/web.py
had reinvented local schema constants instead of using the canonical
enodia_sentinel/schemas.py catalog (now registers enodia.go.sidecar.v1
there and reuses ALERT_SNAPSHOT_V1/INCIDENT_V1); docs/RULES.md had drifted
from what `rules docs` actually generates for SIDs 100069-100078 (ruleops.py
was missing metadata for four SIDs and had stale drill text for four more),
now back in sync with a regression test pinning them together.

Updates CLAUDE.md, README.md, go-agent/README.md, and docs/{ROADMAP,
GO_PORT_HANDOFF,SURICATA_ASSIMILATION,THREAT_MODEL,OPERATIONS,SCHEMAS,
COMMAND_REFERENCE}.md to reflect the landed work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQivSKBqQJsayz1xcYqWzZ
This commit is contained in:
Luna 2026-07-24 09:59:09 -07:00
parent 1d1dee7f6e
commit d835386381
No known key found for this signature in database
43 changed files with 3419 additions and 72 deletions

View file

@ -0,0 +1,150 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"crypto/sha256"
"encoding/hex"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
)
// DefaultPaths mirrors Python's DEFAULT_FIM_PATHS: security-critical files the
// package manager does not track, so package verification cannot cover them.
var DefaultPaths = []string{
"/usr/local/bin", "/usr/local/sbin", "/usr/local/lib",
"/etc/systemd/system", "/etc/ld.so.preload", "/etc/ld.so.conf",
"/etc/ld.so.conf.d", "/etc/pam.d", "/etc/ssh/sshd_config",
"/etc/sudoers", "/etc/sudoers.d", "/etc/profile", "/etc/profile.d",
"/etc/bash.bashrc", "/etc/crontab", "/etc/cron.d", "/etc/cron.daily",
"/etc/cron.hourly", "/etc/passwd", "/etc/shadow", "/etc/group",
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
}
// SelfPaths mirrors Python's SELF_PATHS. Sentinel's own footprint is always
// monitored so tampering with the agent trips the agent.
var SelfPaths = []string{
"/usr/bin/enodia-sentinel", "/usr/local/bin/enodia-sentinel",
"/usr/bin/sentinel-redteam", "/usr/local/bin/sentinel-redteam",
"/usr/lib/enodia-sentinel", "/usr/local/lib/enodia-sentinel",
"/etc/enodia-sentinel.toml",
"/etc/systemd/system/enodia-sentinel.service",
"/etc/systemd/system/enodia-sentinel-web.service",
"/usr/lib/systemd/system/enodia-sentinel.service",
"/usr/lib/systemd/system/enodia-sentinel-web.service",
"/etc/pacman.d/hooks/enodia-sentinel-fim.hook",
"/usr/share/libalpm/hooks/enodia-sentinel-fim.hook",
}
const hashBufferSize = 1 << 16
// PathList returns the configured or default monitored paths plus Sentinel's
// own footprint, matching Python's Config.fim_path_list.
func PathList(configured []string) []string {
base := configured
if len(base) == 0 {
base = DefaultPaths
}
paths := make([]string, 0, len(base)+len(SelfPaths))
paths = append(paths, base...)
paths = append(paths, SelfPaths...)
return paths
}
// HashFile streams a file's SHA-256. The second return is false when the file
// cannot be read, which the caller records as an absent hash rather than an
// alert: an unreadable file is not evidence of tampering by itself.
func HashFile(path string) (string, bool) {
file, err := os.Open(path)
if err != nil {
return "", false
}
defer file.Close()
digest := sha256.New()
if _, err := io.CopyBuffer(digest, file, make([]byte, hashBufferSize)); err != nil {
return "", false
}
return hex.EncodeToString(digest.Sum(nil)), true
}
// entryFor builds one integrity record, or false for paths that carry no
// meaningful integrity state (sockets, devices, fifos).
func entryFor(path string) (Entry, bool) {
info, err := os.Lstat(path)
if err != nil {
return Entry{}, false
}
entry := Entry{Size: info.Size()}
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
// Permission bits alone would drop setuid/setgid/sticky, so take the
// same 0o7777 mask Python's stat.S_IMODE applies.
entry.Mode = int(stat.Mode & 0o7777)
entry.UID = int(stat.Uid)
entry.GID = int(stat.Gid)
}
switch {
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
target = ""
}
entry.Link = &target
case info.Mode().IsRegular():
if hash, ok := HashFile(path); ok {
entry.SHA256 = &hash
}
default:
return Entry{}, false
}
return entry, true
}
// ScanPaths maps path -> entry for every regular file and symlink under the
// given roots. Unreadable subtrees are skipped rather than failing the sweep.
func ScanPaths(paths []string) map[string]Entry {
entries := make(map[string]Entry)
for _, root := range paths {
info, err := os.Lstat(root)
if err != nil {
continue
}
// Python tests islink() before isdir(), so a symlink handed in
// directly is recorded as a link even when it points at a directory.
if !info.IsDir() {
if entry, ok := entryFor(root); ok {
entries[root] = entry
}
continue
}
walkRoot(root, entries)
}
return entries
}
func walkRoot(root string, entries map[string]Entry) {
_ = filepath.WalkDir(root, func(path string, dirEntry fs.DirEntry, err error) error {
if err != nil {
// Matches os.walk(onerror=...) swallowing permission errors.
return nil
}
if dirEntry.IsDir() {
return nil
}
// os.walk classifies with is_dir(), which follows symlinks: a symlink
// to a directory is listed as a directory and never scanned as a file.
if dirEntry.Type()&fs.ModeSymlink != 0 {
if target, err := os.Stat(path); err == nil && target.IsDir() {
return nil
}
}
if entry, ok := entryFor(path); ok {
entries[path] = entry
}
return nil
})
}