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,153 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Package fim ports the Python file-integrity engine: a SHA-256 baseline over
// security-critical paths the package manager does not track, and the diff that
// turns baseline drift into alerts.
//
// Content hashing is the upgrade over mtime checks — it catches a file swapped
// for a malicious copy even when the timestamp is preserved.
package fim
import (
"sort"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// SIDs 100017+ are FIM signatures, matching Python.
const (
SIDModified = 100017
SIDAdded = 100018
SIDRemoved = 100019
SIDPackage = 100020
)
// Entry is one path's integrity record. SHA256 and Link are pointers so that
// "absent" and "present but empty" stay distinguishable: Python builds these
// dicts with optional keys and compares them with dict.get, so a regular file
// carries no link key at all while a symlink may legitimately carry "".
type Entry struct {
Mode int `json:"mode"`
UID int `json:"uid"`
GID int `json:"gid"`
Size int64 `json:"size"`
SHA256 *string `json:"sha256,omitempty"`
Link *string `json:"link,omitempty"`
}
// Change is one modified path and the fields that differ, in Python's order.
type Change struct {
Path string `json:"path"`
Fields []string `json:"fields"`
}
// Result is one baseline-versus-live comparison.
type Result struct {
Added []string `json:"added"`
Removed []string `json:"removed"`
Modified []Change `json:"modified"`
}
// comparedFields is Python's tuple order and drives the detail string. Size is
// deliberately absent: a content change already surfaces as a sha256 change.
var comparedFields = []string{"sha256", "mode", "uid", "gid", "link"}
func sameOptional(a, b *string) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func changedFields(old, current Entry) []string {
changed := make([]string, 0, len(comparedFields))
for _, field := range comparedFields {
differs := false
switch field {
case "sha256":
differs = !sameOptional(old.SHA256, current.SHA256)
case "mode":
differs = old.Mode != current.Mode
case "uid":
differs = old.UID != current.UID
case "gid":
differs = old.GID != current.GID
case "link":
differs = !sameOptional(old.Link, current.Link)
}
if differs {
changed = append(changed, field)
}
}
return changed
}
// Diff compares a baseline against a live scan. Every category is sorted so
// alert order is stable across runs and matches the Python oracle.
func Diff(old, current map[string]Entry) Result {
added := make([]string, 0)
removed := make([]string, 0)
shared := make([]string, 0)
for path := range current {
if _, ok := old[path]; !ok {
added = append(added, path)
}
}
for path := range old {
if _, ok := current[path]; ok {
shared = append(shared, path)
} else {
removed = append(removed, path)
}
}
sort.Strings(added)
sort.Strings(removed)
sort.Strings(shared)
modified := make([]Change, 0)
for _, path := range shared {
if fields := changedFields(old[path], current[path]); len(fields) > 0 {
modified = append(modified, Change{Path: path, Fields: fields})
}
}
return Result{Added: added, Removed: removed, Modified: modified}
}
// DiffAlerts turns a comparison into alerts, emitting modified, then added,
// then removed, to match Python's generator order.
func DiffAlerts(result Result) []model.Alert {
alerts := make([]model.Alert, 0, len(result.Modified)+len(result.Added)+len(result.Removed))
for _, change := range result.Modified {
// Content or permission changes are the ones that let an attacker
// substitute or escalate; ownership alone is weaker evidence.
severity := "MEDIUM"
for _, field := range change.Fields {
if field == "sha256" || field == "mode" {
severity = "HIGH"
break
}
}
alerts = append(alerts, model.Alert{
SID: SIDModified, Severity: severity, Signature: "fim_modified",
Classtype: "integrity-violation", Key: "fim:mod:" + change.Path,
Detail: "integrity change (" + strings.Join(change.Fields, ", ") + "): " + change.Path,
PIDs: []int{},
})
}
for _, path := range result.Added {
alerts = append(alerts, model.Alert{
SID: SIDAdded, Severity: "MEDIUM", Signature: "fim_added",
Classtype: "integrity-violation", Key: "fim:add:" + path,
Detail: "new file in monitored path: " + path, PIDs: []int{},
})
}
for _, path := range result.Removed {
alerts = append(alerts, model.Alert{
SID: SIDRemoved, Severity: "MEDIUM", Signature: "fim_removed",
Classtype: "integrity-violation", Key: "fim:del:" + path,
Detail: "monitored file removed: " + path, PIDs: []int{},
})
}
return alerts
}

View file

@ -0,0 +1,168 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"reflect"
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func ptr(value string) *string { return &value }
func TestDiffClassifiesAddedRemovedAndModified(t *testing.T) {
old := map[string]Entry{
"/etc/passwd": {Mode: 0o644, UID: 0, GID: 0, Size: 10, SHA256: ptr("aaa")},
"/etc/removed": {Mode: 0o644, SHA256: ptr("bbb")},
}
current := map[string]Entry{
"/etc/passwd": {Mode: 0o644, UID: 0, GID: 0, Size: 12, SHA256: ptr("ccc")},
"/etc/added": {Mode: 0o600, SHA256: ptr("ddd")},
}
result := Diff(old, current)
if !reflect.DeepEqual(result.Added, []string{"/etc/added"}) {
t.Errorf("added = %v, want [/etc/added]", result.Added)
}
if !reflect.DeepEqual(result.Removed, []string{"/etc/removed"}) {
t.Errorf("removed = %v, want [/etc/removed]", result.Removed)
}
want := []Change{{Path: "/etc/passwd", Fields: []string{"sha256"}}}
if !reflect.DeepEqual(result.Modified, want) {
t.Errorf("modified = %v, want %v", result.Modified, want)
}
}
// Python reports changed fields in a fixed order and size is deliberately not
// one of them: a content change already shows up as a sha256 change.
func TestDiffReportsFieldsInPythonOrderAndIgnoresSize(t *testing.T) {
old := map[string]Entry{"/f": {Mode: 0o644, UID: 0, GID: 0, Size: 1, SHA256: ptr("a")}}
current := map[string]Entry{"/f": {Mode: 0o755, UID: 1, GID: 2, Size: 999, SHA256: ptr("b")}}
result := Diff(old, current)
want := []Change{{Path: "/f", Fields: []string{"sha256", "mode", "uid", "gid"}}}
if !reflect.DeepEqual(result.Modified, want) {
t.Errorf("modified = %v, want %v", result.Modified, want)
}
}
// Python compares with dict.get, so an absent hash and a null hash are equal.
// Getting this wrong would alert on every unreadable file, every scan.
func TestDiffTreatsAbsentAndNullHashAsEqual(t *testing.T) {
old := map[string]Entry{"/f": {Mode: 0o644, SHA256: nil}}
current := map[string]Entry{"/f": {Mode: 0o644, SHA256: nil}}
if got := Diff(old, current).Modified; len(got) != 0 {
t.Errorf("modified = %v, want none", got)
}
}
// An empty symlink target is not the same as no symlink target at all.
func TestDiffDistinguishesEmptyLinkFromAbsentLink(t *testing.T) {
old := map[string]Entry{"/l": {Mode: 0o777, Link: ptr("")}}
current := map[string]Entry{"/l": {Mode: 0o777, Link: nil}}
want := []Change{{Path: "/l", Fields: []string{"link"}}}
if got := Diff(old, current).Modified; !reflect.DeepEqual(got, want) {
t.Errorf("modified = %v, want %v", got, want)
}
}
func TestDiffDetectsSymlinkRetarget(t *testing.T) {
old := map[string]Entry{"/l": {Mode: 0o777, Link: ptr("/bin/sh")}}
current := map[string]Entry{"/l": {Mode: 0o777, Link: ptr("/tmp/evil")}}
want := []Change{{Path: "/l", Fields: []string{"link"}}}
if got := Diff(old, current).Modified; !reflect.DeepEqual(got, want) {
t.Errorf("modified = %v, want %v", got, want)
}
}
func TestDiffSortsEachCategory(t *testing.T) {
old := map[string]Entry{"/z": {Size: 1}, "/a": {Size: 1}}
current := map[string]Entry{"/y": {Size: 1}, "/b": {Size: 1}}
result := Diff(old, current)
if !reflect.DeepEqual(result.Added, []string{"/b", "/y"}) {
t.Errorf("added = %v, want sorted [/b /y]", result.Added)
}
if !reflect.DeepEqual(result.Removed, []string{"/a", "/z"}) {
t.Errorf("removed = %v, want sorted [/a /z]", result.Removed)
}
}
func TestDiffEmitsEmptyNotNilSlices(t *testing.T) {
result := Diff(map[string]Entry{}, map[string]Entry{})
if result.Added == nil || result.Removed == nil || result.Modified == nil {
t.Fatalf("Diff must return empty, non-nil slices, got %+v", result)
}
}
func TestDiffAlertsOrdersModifiedThenAddedThenRemoved(t *testing.T) {
result := Result{
Added: []string{"/new"},
Removed: []string{"/gone"},
Modified: []Change{{Path: "/changed", Fields: []string{"uid"}}},
}
alerts := DiffAlerts(result)
want := []model.Alert{
{
SID: 100017, Severity: "MEDIUM", Signature: "fim_modified",
Classtype: "integrity-violation", Key: "fim:mod:/changed",
Detail: "integrity change (uid): /changed", PIDs: []int{},
},
{
SID: 100018, Severity: "MEDIUM", Signature: "fim_added",
Classtype: "integrity-violation", Key: "fim:add:/new",
Detail: "new file in monitored path: /new", PIDs: []int{},
},
{
SID: 100019, Severity: "MEDIUM", Signature: "fim_removed",
Classtype: "integrity-violation", Key: "fim:del:/gone",
Detail: "monitored file removed: /gone", PIDs: []int{},
},
}
if !reflect.DeepEqual(alerts, want) {
t.Errorf("alerts = %+v, want %+v", alerts, want)
}
}
// Content and permission changes are HIGH; ownership-only changes are MEDIUM.
func TestDiffAlertsEscalatesContentAndModeChanges(t *testing.T) {
cases := []struct {
fields []string
severity string
detail string
}{
{[]string{"sha256"}, "HIGH", "integrity change (sha256): /f"},
{[]string{"mode"}, "HIGH", "integrity change (mode): /f"},
{[]string{"sha256", "mode"}, "HIGH", "integrity change (sha256, mode): /f"},
{[]string{"uid", "gid"}, "MEDIUM", "integrity change (uid, gid): /f"},
{[]string{"link"}, "MEDIUM", "integrity change (link): /f"},
}
for _, testCase := range cases {
alerts := DiffAlerts(Result{Modified: []Change{{Path: "/f", Fields: testCase.fields}}})
if len(alerts) != 1 {
t.Fatalf("%v: got %d alerts, want 1", testCase.fields, len(alerts))
}
if alerts[0].Severity != testCase.severity {
t.Errorf("%v: severity = %s, want %s", testCase.fields, alerts[0].Severity, testCase.severity)
}
if alerts[0].Detail != testCase.detail {
t.Errorf("%v: detail = %q, want %q", testCase.fields, alerts[0].Detail, testCase.detail)
}
}
}
func TestDiffAlertsReturnsEmptySliceForCleanScan(t *testing.T) {
if alerts := DiffAlerts(Diff(nil, nil)); len(alerts) != 0 || alerts == nil {
t.Errorf("alerts = %v, want empty non-nil slice", alerts)
}
}

View file

@ -0,0 +1,188 @@
// 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
}

View file

@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
)
func TestManagerBuildsBaselineAndReportsDriftWithoutReplacingIt(t *testing.T) {
directory := t.TempDir()
watched := filepath.Join(directory, "watched")
writeFile(t, watched, "trusted")
state := filepath.Join(directory, "state")
cfg := config.Default()
cfg.LogDir = state
cfg.FIMPaths = []string{watched}
manager := New(cfg)
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
baselineHash := *manager.baseline[watched].SHA256
if _, err := os.Stat(filepath.Join(state, baselineFilename)); err != nil {
t.Fatalf("baseline not persisted: %v", err)
}
writeFile(t, watched, "tampered")
manager.runScan(cloneEntries(manager.baseline), PathList(cfg.FIMPaths))
alerts := manager.Drain()
if len(alerts) != 1 || alerts[0].SID != SIDModified || alerts[0].Severity != "HIGH" {
t.Fatalf("alerts = %+v, want one high modified alert", alerts)
}
if got := *manager.baseline[watched].SHA256; got != baselineHash {
t.Fatalf("scan silently replaced baseline %q with %q", baselineHash, got)
}
}
func TestManagerPackageVerificationIsBoundedAndDrained(t *testing.T) {
cfg := config.Default()
cfg.LogDir = t.TempDir()
cfg.FIMPaths = []string{filepath.Join(t.TempDir(), "absent")}
manager := New(cfg)
manager.Verify = func(ctx context.Context) (string, error) {
if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > pacmanTimeout {
t.Fatal("package verification did not receive a bounded context")
}
return "openssh: /usr/bin/ssh (SHA256 checksum mismatch)", nil
}
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
manager.runVerify()
alerts := manager.Drain()
if len(alerts) != 1 || alerts[0].SID != SIDPackage || alerts[0].Severity != "CRITICAL" {
t.Fatalf("alerts = %+v, want one critical package alert", alerts)
}
}
func TestDisabledManagerDoesNotCreateAStateDirectory(t *testing.T) {
cfg := config.Default()
cfg.FIMEnabled = false
cfg.LogDir = filepath.Join(t.TempDir(), "state")
manager := New(cfg)
if err := manager.Initialize(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cfg.LogDir); !os.IsNotExist(err) {
t.Fatalf("disabled FIM created state directory: %v", err)
}
}

View file

@ -0,0 +1,99 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"context"
"os/exec"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// PackageFinding is one meaningful package-manager integrity discrepancy.
// Package verification is intentionally separate from the FIM baseline: it
// relies on distribution-owned hashes for files that Sentinel should not
// silently re-baseline after an upgrade.
type PackageFinding struct {
Package string `json:"package"`
Path string `json:"path"`
Reason string `json:"reason"`
}
// ParsePacmanVerify mirrors Python's parse_pacman_verify. pacman emits both
// routine summaries and diagnostics to stdout/stderr; only content, mode, or
// ownership mismatches are security findings. A timestamp-only difference is
// deliberately ignored because a harmless touch must not page an operator.
func ParsePacmanVerify(output string) []PackageFinding {
findings := make([]PackageFinding, 0)
for _, raw := range strings.Split(output, "\n") {
line := strings.TrimSpace(raw)
open, close := strings.LastIndex(line, "("), strings.LastIndex(line, ")")
if open < 0 || close <= open {
continue
}
reason := strings.TrimSpace(line[open+1 : close])
lower := strings.ToLower(reason)
if !containsAny(lower, "checksum", "sha", "size", "permission", "ownership", "uid", "gid") {
continue
}
head := strings.TrimSpace(line[:open])
packageName, path, ok := strings.Cut(head, ": /")
if !ok {
continue
}
packageName = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(packageName), "warning:"))
path = "/" + strings.TrimSpace(path)
if packageName == "" || path == "/" {
continue
}
findings = append(findings, PackageFinding{Package: packageName, Path: path, Reason: reason})
}
return findings
}
func containsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
// PackageAlert converts a package-owned integrity discrepancy to the same
// critical Sentinel alert as Python. The path is the stable cooldown key, so
// multiple pacman diagnostics for one path do not cause repeated pages.
func PackageAlert(finding PackageFinding) model.Alert {
return model.Alert{
SID: SIDPackage, Severity: "CRITICAL", Signature: "fim_pkg_modified",
Classtype: "integrity-violation", Key: "fim:pkg:" + finding.Path,
Detail: "package file altered (" + finding.Package + ": " + finding.Reason + "): " + finding.Path,
PIDs: []int{},
}
}
// VerifyAlerts executes an injected verification command and turns any
// parseable output into alerts. A missing package manager or a timeout fails
// open; a non-zero exit with diagnostics is still parsed because pacman uses
// that exit status to report changed files.
func VerifyAlerts(run func() (string, error)) []model.Alert {
alerts := make([]model.Alert, 0)
if run == nil {
return alerts
}
output, _ := run()
for _, finding := range ParsePacmanVerify(output) {
alerts = append(alerts, PackageAlert(finding))
}
return alerts
}
// PacmanVerify runs pacman without a shell and joins stdout/stderr because the
// tool may use either stream for discrepancies. The caller supplies a bounded
// context; this function has no unbounded host operation of its own.
func PacmanVerify(ctx context.Context) (string, error) {
command := exec.CommandContext(ctx, "pacman", "-Qkk")
output, err := command.CombinedOutput()
return string(output), err
}

View file

@ -0,0 +1,151 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package fim
import (
"errors"
"reflect"
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestParsePacmanVerifyExtractsPackagePathAndReason(t *testing.T) {
output := "coreutils: /usr/bin/ls (Size mismatch)"
got := ParsePacmanVerify(output)
want := []PackageFinding{{Package: "coreutils", Path: "/usr/bin/ls", Reason: "Size mismatch"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("findings = %+v, want %+v", got, want)
}
}
func TestParsePacmanVerifyStripsWarningPrefix(t *testing.T) {
got := ParsePacmanVerify("warning: openssh: /usr/bin/ssh (SHA256 checksum mismatch)")
want := []PackageFinding{{Package: "openssh", Path: "/usr/bin/ssh", Reason: "SHA256 checksum mismatch"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("findings = %+v, want %+v", got, want)
}
}
// A `touch` does not alter content, so a modification-time-only mismatch is
// benign noise that must never reach an operator.
func TestParsePacmanVerifyIgnoresModificationTimeOnlyMismatches(t *testing.T) {
got := ParsePacmanVerify("coreutils: /usr/bin/ls (Modification time mismatch)")
if len(got) != 0 {
t.Errorf("findings = %+v, want none", got)
}
}
func TestParsePacmanVerifyKeepsEveryContentAndOwnershipReason(t *testing.T) {
reasons := []string{
"Size mismatch", "SHA256 checksum mismatch", "Permissions mismatch",
"UID mismatch", "GID mismatch", "Ownership mismatch",
}
for _, reason := range reasons {
got := ParsePacmanVerify("pkg: /usr/bin/x (" + reason + ")")
if len(got) != 1 {
t.Errorf("reason %q: got %d findings, want 1", reason, len(got))
}
}
}
func TestParsePacmanVerifyMatchesReasonCaseInsensitively(t *testing.T) {
if got := ParsePacmanVerify("pkg: /usr/bin/x (size MISMATCH)"); len(got) != 1 {
t.Errorf("findings = %+v, want 1", got)
}
}
func TestParsePacmanVerifyIgnoresSummaryAndUnownedLines(t *testing.T) {
output := `warning: No package owns /usr/local/bin/custom
coreutils: 468 total files, 0 altered files
pacman: /usr/bin/pacman (Size mismatch)`
got := ParsePacmanVerify(output)
want := []PackageFinding{{Package: "pacman", Path: "/usr/bin/pacman", Reason: "Size mismatch"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("findings = %+v, want %+v", got, want)
}
}
func TestParsePacmanVerifyIgnoresLinesWithoutAnAbsolutePath(t *testing.T) {
if got := ParsePacmanVerify("something odd (Size mismatch)"); len(got) != 0 {
t.Errorf("findings = %+v, want none", got)
}
}
func TestParsePacmanVerifyReadsTheLastParenthesisGroup(t *testing.T) {
got := ParsePacmanVerify("pkg: /usr/bin/x (1.0-1) (Size mismatch)")
if len(got) != 1 || got[0].Reason != "Size mismatch" {
t.Errorf("findings = %+v, want reason 'Size mismatch'", got)
}
}
func TestParsePacmanVerifyHandlesEmptyOutput(t *testing.T) {
if got := ParsePacmanVerify(""); got == nil || len(got) != 0 {
t.Errorf("findings = %+v, want empty non-nil slice", got)
}
}
func TestPackageAlertIsCriticalWithDistroReason(t *testing.T) {
got := PackageAlert(PackageFinding{Package: "openssh", Path: "/usr/bin/ssh", Reason: "SHA256 checksum mismatch"})
want := model.Alert{
SID: 100020, Severity: "CRITICAL", Signature: "fim_pkg_modified",
Classtype: "integrity-violation", Key: "fim:pkg:/usr/bin/ssh",
Detail: "package file altered (openssh: SHA256 checksum mismatch): /usr/bin/ssh",
PIDs: []int{},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("alert = %+v, want %+v", got, want)
}
}
func TestVerifyAlertsConvertsEveryFinding(t *testing.T) {
run := func() (string, error) {
return "a: /usr/bin/a (Size mismatch)\nb: /usr/bin/b (UID mismatch)", nil
}
alerts := VerifyAlerts(run)
if len(alerts) != 2 {
t.Fatalf("alerts = %+v, want 2", alerts)
}
if alerts[0].Key != "fim:pkg:/usr/bin/a" || alerts[1].Key != "fim:pkg:/usr/bin/b" {
t.Errorf("unexpected alert keys: %s, %s", alerts[0].Key, alerts[1].Key)
}
}
// A host without pacman, or a verification that times out, must degrade to no
// alerts rather than reporting a false integrity violation.
func TestVerifyAlertsFailsQuietWhenVerificationCannotRun(t *testing.T) {
run := func() (string, error) { return "", errors.New("pacman: not found") }
alerts := VerifyAlerts(run)
if len(alerts) != 0 || alerts == nil {
t.Errorf("alerts = %+v, want empty non-nil slice", alerts)
}
}
// pacman writes diagnostics to stderr, so a non-zero exit still carries
// findings that must be parsed rather than discarded.
func TestVerifyAlertsParsesOutputEvenWithFindingsPresentOnFailureExit(t *testing.T) {
run := func() (string, error) {
return "openssh: /usr/bin/ssh (Size mismatch)", errors.New("exit status 1")
}
alerts := VerifyAlerts(run)
if len(alerts) != 1 {
t.Fatalf("alerts = %+v, want 1", alerts)
}
if alerts[0].Severity != "CRITICAL" {
t.Errorf("severity = %s, want CRITICAL", alerts[0].Severity)
}
}

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
})
}

View file

@ -0,0 +1,245 @@
// 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)
}
}
}