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
245 lines
6.6 KiB
Go
245 lines
6.6 KiB
Go
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package fim
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"syscall"
|
|
"testing"
|
|
)
|
|
|
|
func writeFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestHashFileStreamsSHA256(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "f")
|
|
writeFile(t, path, "sentinel")
|
|
sum := sha256.Sum256([]byte("sentinel"))
|
|
|
|
got, ok := HashFile(path)
|
|
|
|
if !ok {
|
|
t.Fatal("HashFile reported failure for a readable file")
|
|
}
|
|
if want := hex.EncodeToString(sum[:]); got != want {
|
|
t.Errorf("hash = %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHashFileReportsFailureForMissingPath(t *testing.T) {
|
|
if _, ok := HashFile(filepath.Join(t.TempDir(), "absent")); ok {
|
|
t.Error("HashFile reported success for a missing file")
|
|
}
|
|
}
|
|
|
|
func TestScanPathsRecordsRegularFileWithHashAndMetadata(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "f")
|
|
writeFile(t, path, "x")
|
|
|
|
entries := ScanPaths([]string{path})
|
|
|
|
entry, ok := entries[path]
|
|
if !ok {
|
|
t.Fatalf("no entry for %s, got %v", path, entries)
|
|
}
|
|
if entry.SHA256 == nil {
|
|
t.Error("regular file entry has no hash")
|
|
}
|
|
if entry.Link != nil {
|
|
t.Error("regular file entry must not carry a link target")
|
|
}
|
|
if entry.Size != 1 {
|
|
t.Errorf("size = %d, want 1", entry.Size)
|
|
}
|
|
if entry.Mode != 0o644 {
|
|
t.Errorf("mode = %o, want 644", entry.Mode)
|
|
}
|
|
}
|
|
|
|
// Permission bits alone are not enough: losing the setuid bit here would mean
|
|
// a binary silently gaining setuid never trips the baseline.
|
|
func TestScanPathsPreservesSetuidBit(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "suid")
|
|
writeFile(t, path, "x")
|
|
// syscall.Chmod takes the raw mode; os.Chmod would need os.ModeSetuid and
|
|
// silently drops a bare 0o4000.
|
|
if err := syscall.Chmod(path, 0o4755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entry := ScanPaths([]string{path})[path]
|
|
|
|
if entry.Mode != 0o4755 {
|
|
t.Errorf("mode = %o, want 4755", entry.Mode)
|
|
}
|
|
}
|
|
|
|
func TestScanPathsRecordsSymlinkTargetWithoutHashing(t *testing.T) {
|
|
dir := t.TempDir()
|
|
target := filepath.Join(dir, "target")
|
|
link := filepath.Join(dir, "link")
|
|
writeFile(t, target, "x")
|
|
if err := os.Symlink(target, link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entry, ok := ScanPaths([]string{link})[link]
|
|
|
|
if !ok {
|
|
t.Fatalf("no entry for symlink %s", link)
|
|
}
|
|
if entry.Link == nil || *entry.Link != target {
|
|
t.Errorf("link = %v, want %s", entry.Link, target)
|
|
}
|
|
if entry.SHA256 != nil {
|
|
t.Error("symlink entry must not be hashed")
|
|
}
|
|
}
|
|
|
|
func TestScanPathsRecordsBrokenSymlink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
link := filepath.Join(dir, "broken")
|
|
if err := os.Symlink(filepath.Join(dir, "nowhere"), link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, ok := ScanPaths([]string{link})[link]; !ok {
|
|
t.Error("broken symlink was not recorded")
|
|
}
|
|
}
|
|
|
|
func TestScanPathsWalksDirectoriesRecursively(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "top"), "a")
|
|
writeFile(t, filepath.Join(dir, "sub", "nested"), "b")
|
|
|
|
entries := ScanPaths([]string{dir})
|
|
|
|
for _, want := range []string{filepath.Join(dir, "top"), filepath.Join(dir, "sub", "nested")} {
|
|
if _, ok := entries[want]; !ok {
|
|
t.Errorf("missing %s, got %v", want, entries)
|
|
}
|
|
}
|
|
if _, ok := entries[filepath.Join(dir, "sub")]; ok {
|
|
t.Error("directories themselves must not be recorded")
|
|
}
|
|
}
|
|
|
|
// Python's os.walk classifies with is_dir(), which follows symlinks, so a
|
|
// symlink to a directory lands in dirs and is never scanned as a file.
|
|
func TestScanPathsSkipsSymlinkToDirectoryDuringWalk(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, filepath.Join(dir, "real", "f"), "a")
|
|
if err := os.Symlink(filepath.Join(dir, "real"), filepath.Join(dir, "dirlink")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entries := ScanPaths([]string{dir})
|
|
|
|
if _, ok := entries[filepath.Join(dir, "dirlink")]; ok {
|
|
t.Error("symlink to a directory must not be recorded during a walk")
|
|
}
|
|
if _, ok := entries[filepath.Join(dir, "dirlink", "f")]; ok {
|
|
t.Error("symlinks to directories must not be followed")
|
|
}
|
|
}
|
|
|
|
// At the top level Python checks islink() before isdir(), so the same symlink
|
|
// passed directly as a root IS recorded.
|
|
func TestScanPathsRecordsSymlinkToDirectoryGivenAsRoot(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "real"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
link := filepath.Join(dir, "dirlink")
|
|
if err := os.Symlink(filepath.Join(dir, "real"), link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entry, ok := ScanPaths([]string{link})[link]
|
|
|
|
if !ok {
|
|
t.Fatal("symlink root was not recorded")
|
|
}
|
|
if entry.Link == nil {
|
|
t.Error("symlink root recorded without a link target")
|
|
}
|
|
}
|
|
|
|
func TestScanPathsSkipsFifosAndOtherSpecialFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fifo := filepath.Join(dir, "pipe")
|
|
if err := syscall.Mkfifo(fifo, 0o644); err != nil {
|
|
t.Skipf("cannot create fifo: %v", err)
|
|
}
|
|
|
|
if _, ok := ScanPaths([]string{dir})[fifo]; ok {
|
|
t.Error("fifo must be skipped")
|
|
}
|
|
}
|
|
|
|
func TestScanPathsIgnoresMissingRoots(t *testing.T) {
|
|
entries := ScanPaths([]string{filepath.Join(t.TempDir(), "absent")})
|
|
|
|
if len(entries) != 0 {
|
|
t.Errorf("entries = %v, want empty", entries)
|
|
}
|
|
}
|
|
|
|
// A single unreadable file must not abort the sweep — the rest of the tree
|
|
// still has to be recorded.
|
|
func TestScanPathsContinuesPastUnreadableFile(t *testing.T) {
|
|
if os.Geteuid() == 0 {
|
|
t.Skip("root can read mode 000 files")
|
|
}
|
|
dir := t.TempDir()
|
|
secret := filepath.Join(dir, "secret")
|
|
writeFile(t, secret, "x")
|
|
writeFile(t, filepath.Join(dir, "readable"), "y")
|
|
if err := os.Chmod(secret, 0o000); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
entries := ScanPaths([]string{dir})
|
|
|
|
if _, ok := entries[filepath.Join(dir, "readable")]; !ok {
|
|
t.Error("readable sibling was dropped")
|
|
}
|
|
entry, ok := entries[secret]
|
|
if !ok {
|
|
t.Fatal("unreadable file must still be recorded via lstat")
|
|
}
|
|
if entry.SHA256 != nil {
|
|
t.Error("unreadable file must record a nil hash, not a bogus one")
|
|
}
|
|
}
|
|
|
|
func TestScanPathsReturnsEmptyMapForNoPaths(t *testing.T) {
|
|
if entries := ScanPaths(nil); entries == nil || len(entries) != 0 {
|
|
t.Errorf("entries = %v, want empty non-nil map", entries)
|
|
}
|
|
}
|
|
|
|
func TestDefaultPathsCoverCriticalUnpackagedLocations(t *testing.T) {
|
|
required := []string{"/etc/ld.so.preload", "/root/.ssh/authorized_keys", "/etc/sudoers", "/usr/local/bin"}
|
|
present := make(map[string]bool, len(DefaultPaths))
|
|
for _, path := range DefaultPaths {
|
|
present[path] = true
|
|
}
|
|
for _, path := range required {
|
|
if !present[path] {
|
|
t.Errorf("DefaultPaths missing %s", path)
|
|
}
|
|
}
|
|
}
|