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
150 lines
4.5 KiB
Go
150 lines
4.5 KiB
Go
// 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
|
|
})
|
|
}
|