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:
parent
1d1dee7f6e
commit
d835386381
43 changed files with 3419 additions and 72 deletions
153
go-agent/internal/fim/fim.go
Normal file
153
go-agent/internal/fim/fim.go
Normal 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
|
||||
}
|
||||
168
go-agent/internal/fim/fim_test.go
Normal file
168
go-agent/internal/fim/fim_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
188
go-agent/internal/fim/manager.go
Normal file
188
go-agent/internal/fim/manager.go
Normal 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
|
||||
}
|
||||
75
go-agent/internal/fim/manager_test.go
Normal file
75
go-agent/internal/fim/manager_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
99
go-agent/internal/fim/pkgverify.go
Normal file
99
go-agent/internal/fim/pkgverify.go
Normal 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
|
||||
}
|
||||
151
go-agent/internal/fim/pkgverify_test.go
Normal file
151
go-agent/internal/fim/pkgverify_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
150
go-agent/internal/fim/scan.go
Normal file
150
go-agent/internal/fim/scan.go
Normal 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
|
||||
})
|
||||
}
|
||||
245
go-agent/internal/fim/scan_test.go
Normal file
245
go-agent/internal/fim/scan_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue