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
|
|
@ -15,7 +15,10 @@ import (
|
|||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/baseline"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/detectors"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/fim"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/pkgdb"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/rootcheck"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/schema"
|
||||
)
|
||||
|
||||
|
|
@ -37,6 +40,18 @@ type Agent struct {
|
|||
// baseline manager so startup grace and durable first-seen state match the
|
||||
// Python oracle without contaminating parity inputs.
|
||||
Lifecycle *baseline.Manager
|
||||
// FIM is optional even when the sidecar is stateful. It owns slow hashing
|
||||
// and package verification and hands completed alerts back to Sweep, which
|
||||
// keeps the existing cooldown and snapshot pipeline authoritative.
|
||||
FIM *fim.Manager
|
||||
// PackageDB watches the mutable package checksum database itself. It is kept
|
||||
// separate from FIM because a package-manager database edit can otherwise
|
||||
// make pacman-based file verification appear clean.
|
||||
PackageDB *pkgdb.Manager
|
||||
// Rootcheck is likewise a sidecar-only asynchronous integrity engine. It
|
||||
// uses the captured socket state but collects independent proc/sys/tool views
|
||||
// outside this sweep's detector and event-output locks.
|
||||
Rootcheck *rootcheck.Manager
|
||||
|
||||
cooldownMu sync.Mutex
|
||||
cooldowns map[string]time.Time
|
||||
|
|
@ -76,6 +91,21 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
if a.FIM != nil {
|
||||
// Scheduling is deliberately before detector evaluation, but FIM itself
|
||||
// runs asynchronously so a large hash tree never delays this sweep.
|
||||
a.FIM.MaybeStart(now)
|
||||
}
|
||||
if a.PackageDB != nil {
|
||||
// Like FIM, DB fingerprinting happens away from detector evaluation. A
|
||||
// completed tamper result still returns through the shared alert pipeline.
|
||||
a.PackageDB.MaybeStart(now)
|
||||
}
|
||||
if a.Rootcheck != nil {
|
||||
// Capture provides the socket view rootcheck compares with procfs and ps;
|
||||
// pass this immutable sweep snapshot to avoid a second full capture.
|
||||
a.Rootcheck.MaybeStart(now, state)
|
||||
}
|
||||
host, err := a.Host()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -131,6 +161,17 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
|
|||
return nil, err
|
||||
}
|
||||
}
|
||||
if a.FIM != nil {
|
||||
// Background engines return results here instead of emitting directly, so
|
||||
// they use this Agent's shared cooldown, snapshot, and event ordering.
|
||||
alerts = append(alerts, a.FIM.Drain()...)
|
||||
}
|
||||
if a.PackageDB != nil {
|
||||
alerts = append(alerts, a.PackageDB.Drain()...)
|
||||
}
|
||||
if a.Rootcheck != nil {
|
||||
alerts = append(alerts, a.Rootcheck.Drain()...)
|
||||
}
|
||||
events, err := a.alertEvents(alerts, now, host, timestamp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -223,6 +264,22 @@ func (a *Agent) Initialize() error {
|
|||
// otherwise fixed-clock tests could arm against wall time by accident.
|
||||
a.Lifecycle.Now = a.Now
|
||||
a.initializeErr = a.Lifecycle.Initialize(baseline.CaptureFunc(a.Capture))
|
||||
if a.initializeErr != nil {
|
||||
return a.initializeErr
|
||||
}
|
||||
}
|
||||
if a.FIM != nil {
|
||||
// FIM must establish its immutable baseline before its first scheduled
|
||||
// scan. The manager writes only the caller-selected sidecar LogDir.
|
||||
a.initializeErr = a.FIM.Initialize()
|
||||
if a.initializeErr != nil {
|
||||
return a.initializeErr
|
||||
}
|
||||
}
|
||||
if a.PackageDB != nil {
|
||||
// Establishing the anchor precedes monitoring, just as the Python daemon
|
||||
// does. Later unexplained changes deliberately do not refresh it.
|
||||
a.initializeErr = a.PackageDB.Initialize()
|
||||
}
|
||||
return a.initializeErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,25 @@ var defaultMemoryObfuscationAllowComms = []string{
|
|||
// Config mirrors the Phase 1 settings consumed by the Go sweep loop. More
|
||||
// fields are added as their detectors are ported.
|
||||
type Config struct {
|
||||
SampleInterval time.Duration
|
||||
Cooldown time.Duration
|
||||
BaselineGrace time.Duration
|
||||
SUIDRefresh time.Duration
|
||||
SUIDScanInterval time.Duration
|
||||
SampleInterval time.Duration
|
||||
Cooldown time.Duration
|
||||
BaselineGrace time.Duration
|
||||
SUIDRefresh time.Duration
|
||||
SUIDScanInterval time.Duration
|
||||
// Integrity engines are parsed with the shared configuration for parity,
|
||||
// but main attaches them only to an explicitly stateful Go sidecar. That
|
||||
// preserves Python's production baselines and management authority.
|
||||
FIMEnabled bool
|
||||
FIMPaths []string
|
||||
FIMScanInterval time.Duration
|
||||
FIMPackageVerify bool
|
||||
FIMPackageVerifyInterval time.Duration
|
||||
PackageDBVerify bool
|
||||
PackageDBInterval time.Duration
|
||||
RootcheckEnabled bool
|
||||
RootcheckInterval time.Duration
|
||||
RootcheckPIDCap int
|
||||
RootcheckModuleAllow map[string]bool
|
||||
HeartbeatMaxAge int
|
||||
MaxSnapshots int
|
||||
MaxSnapshotAgeDays int
|
||||
|
|
@ -97,6 +111,17 @@ func Default() Config {
|
|||
BaselineGrace: 10 * time.Second,
|
||||
SUIDRefresh: time.Hour,
|
||||
SUIDScanInterval: time.Minute,
|
||||
FIMEnabled: true,
|
||||
FIMPaths: []string{},
|
||||
FIMScanInterval: 5 * time.Minute,
|
||||
FIMPackageVerify: false,
|
||||
FIMPackageVerifyInterval: 6 * time.Hour,
|
||||
PackageDBVerify: true,
|
||||
PackageDBInterval: 10 * time.Minute,
|
||||
RootcheckEnabled: true,
|
||||
RootcheckInterval: 5 * time.Minute,
|
||||
RootcheckPIDCap: 65536,
|
||||
RootcheckModuleAllow: map[string]bool{},
|
||||
HeartbeatMaxAge: 120,
|
||||
MaxSnapshots: 300,
|
||||
MaxSnapshotAgeDays: 60,
|
||||
|
|
@ -192,6 +217,72 @@ func Load(path string) (Config, error) {
|
|||
return Config{}, fmt.Errorf("suid_scan_interval: %w", err)
|
||||
}
|
||||
cfg.SUIDScanInterval = duration
|
||||
case "fim_enabled":
|
||||
enabled, err := boolean(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("fim_enabled: %w", err)
|
||||
}
|
||||
cfg.FIMEnabled = enabled
|
||||
case "fim_paths":
|
||||
paths, err := stringArray(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("fim_paths: %w", err)
|
||||
}
|
||||
cfg.FIMPaths = paths
|
||||
case "fim_scan_interval":
|
||||
duration, err := positiveSeconds(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("fim_scan_interval: %w", err)
|
||||
}
|
||||
cfg.FIMScanInterval = duration
|
||||
case "fim_pkg_verify":
|
||||
enabled, err := boolean(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("fim_pkg_verify: %w", err)
|
||||
}
|
||||
cfg.FIMPackageVerify = enabled
|
||||
case "fim_pkg_verify_interval":
|
||||
duration, err := positiveSeconds(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("fim_pkg_verify_interval: %w", err)
|
||||
}
|
||||
cfg.FIMPackageVerifyInterval = duration
|
||||
case "pkgdb_verify":
|
||||
enabled, err := boolean(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("pkgdb_verify: %w", err)
|
||||
}
|
||||
cfg.PackageDBVerify = enabled
|
||||
case "pkgdb_interval":
|
||||
duration, err := positiveSeconds(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("pkgdb_interval: %w", err)
|
||||
}
|
||||
cfg.PackageDBInterval = duration
|
||||
case "rootcheck_enabled":
|
||||
enabled, err := boolean(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("rootcheck_enabled: %w", err)
|
||||
}
|
||||
cfg.RootcheckEnabled = enabled
|
||||
case "rootcheck_interval":
|
||||
duration, err := positiveSeconds(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("rootcheck_interval: %w", err)
|
||||
}
|
||||
cfg.RootcheckInterval = duration
|
||||
case "rootcheck_pid_cap":
|
||||
cap, err := nonNegativeInt(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("rootcheck_pid_cap: %w", err)
|
||||
}
|
||||
cfg.RootcheckPIDCap = cap
|
||||
case "rootcheck_module_allow":
|
||||
modules, err := stringArray(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("rootcheck_module_allow: %w", err)
|
||||
}
|
||||
cfg.RootcheckModuleAllow = stringSet(modules)
|
||||
case "heartbeat_max_age":
|
||||
age, err := strconv.Atoi(value)
|
||||
if err != nil || age < 0 {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,11 @@ func TestDefaultsMatchPython(t *testing.T) {
|
|||
if cfg.SampleInterval != 4*time.Second || cfg.Cooldown != 60*time.Second ||
|
||||
cfg.BaselineGrace != 10*time.Second ||
|
||||
cfg.SUIDRefresh != time.Hour || cfg.SUIDScanInterval != time.Minute ||
|
||||
!cfg.FIMEnabled || cfg.FIMScanInterval != 5*time.Minute ||
|
||||
cfg.FIMPackageVerify || cfg.FIMPackageVerifyInterval != 6*time.Hour ||
|
||||
!cfg.PackageDBVerify || cfg.PackageDBInterval != 10*time.Minute ||
|
||||
!cfg.RootcheckEnabled || cfg.RootcheckInterval != 5*time.Minute ||
|
||||
cfg.RootcheckPIDCap != 65536 || len(cfg.RootcheckModuleAllow) != 0 ||
|
||||
cfg.HeartbeatMaxAge != 120 || cfg.MaxSnapshots != 300 ||
|
||||
cfg.MaxSnapshotAgeDays != 60 || !cfg.IncidentTracking ||
|
||||
cfg.IncidentWindow != 1800 || cfg.IncidentLineageDepth != 8 ||
|
||||
|
|
@ -33,6 +38,17 @@ cooldown = 30
|
|||
baseline_grace = 2.5
|
||||
suid_refresh = 120
|
||||
suid_scan_interval = 15
|
||||
fim_enabled = false
|
||||
fim_paths = ["/srv/critical"]
|
||||
fim_scan_interval = 25
|
||||
fim_pkg_verify = true
|
||||
fim_pkg_verify_interval = 300
|
||||
pkgdb_verify = false
|
||||
pkgdb_interval = 75
|
||||
rootcheck_enabled = false
|
||||
rootcheck_interval = 40
|
||||
rootcheck_pid_cap = 1234
|
||||
rootcheck_module_allow = ["vendor_gpu"]
|
||||
heartbeat_max_age = 45
|
||||
max_snapshots = 25
|
||||
max_snapshot_age_days = 7
|
||||
|
|
@ -72,6 +88,12 @@ unknown_future_key = true
|
|||
if cfg.SampleInterval != 1500*time.Millisecond || cfg.Cooldown != 30*time.Second ||
|
||||
cfg.BaselineGrace != 2500*time.Millisecond ||
|
||||
cfg.SUIDRefresh != 2*time.Minute || cfg.SUIDScanInterval != 15*time.Second ||
|
||||
cfg.FIMEnabled || len(cfg.FIMPaths) != 1 || cfg.FIMPaths[0] != "/srv/critical" ||
|
||||
cfg.FIMScanInterval != 25*time.Second || !cfg.FIMPackageVerify ||
|
||||
cfg.FIMPackageVerifyInterval != 5*time.Minute ||
|
||||
cfg.PackageDBVerify || cfg.PackageDBInterval != 75*time.Second ||
|
||||
cfg.RootcheckEnabled || cfg.RootcheckInterval != 40*time.Second ||
|
||||
cfg.RootcheckPIDCap != 1234 || !cfg.RootcheckModuleAllow["vendor_gpu"] ||
|
||||
cfg.HeartbeatMaxAge != 45 || cfg.MaxSnapshots != 25 || cfg.MaxSnapshotAgeDays != 7 ||
|
||||
cfg.IncidentTracking || cfg.IncidentWindow != 90 || cfg.IncidentLineageDepth != 3 {
|
||||
t.Fatalf("unexpected parsed config: %+v", cfg)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,22 @@ type SlowResult struct {
|
|||
Integrity map[string]any
|
||||
}
|
||||
|
||||
// IntegritySettings describes engines that are already configured by the
|
||||
// sidecar. It is metadata only: CollectSlow must never start a hash scan,
|
||||
// package-manager process, or rootcheck while it is enriching an alert.
|
||||
//
|
||||
// Durations are carried as durations here and converted to integer seconds at
|
||||
// the snapshot boundary so the retained JSON stays easy to consume alongside
|
||||
// Python's enrichment records.
|
||||
type IntegritySettings struct {
|
||||
FIMEnabled bool
|
||||
FIMScanInterval time.Duration
|
||||
PackageVerifyEnabled bool
|
||||
PackageVerifyInterval time.Duration
|
||||
RootcheckEnabled bool
|
||||
RootcheckInterval time.Duration
|
||||
}
|
||||
|
||||
func CollectSlow(processes []Process, paths []string, owner ...func(string) string) SlowResult {
|
||||
result := SlowResult{Processes: map[int]map[string]any{}, Files: map[string]map[string]any{}}
|
||||
packageOwner := func(string) string { return "" }
|
||||
|
|
@ -71,17 +87,37 @@ func CollectSlow(processes []Process, paths []string, owner ...func(string) stri
|
|||
return result
|
||||
}
|
||||
|
||||
// IntegrityAnchors reports durable state presence without running FIM, package
|
||||
// verification, or rootcheck on the alert path. Those engines stay disabled
|
||||
// until their own Go parity tranches land.
|
||||
func IntegrityAnchors(directory string) map[string]any {
|
||||
return map[string]any{
|
||||
"fim_baseline": pathStatus(filepath.Join(directory, "fim-baseline.json")),
|
||||
"pkgdb_anchor": pathStatus(filepath.Join(directory, "pkgdb-anchor.json")),
|
||||
"package_verify": map[string]any{"enabled": false, "sample": 0},
|
||||
"rootcheck": map[string]any{"enabled": false, "interval": 0},
|
||||
"go_assurance": pathStatus(filepath.Join(directory, "hash-chain.jsonl")),
|
||||
// IntegrityAnchors reports durable state and configured engine cadence without
|
||||
// running FIM, package verification, or rootcheck on the alert path. A caller
|
||||
// can omit settings for stateless/parity uses; explicit sidecar mode supplies
|
||||
// them after it has attached the bounded background managers.
|
||||
func IntegrityAnchors(directory string, configured ...IntegritySettings) map[string]any {
|
||||
settings := IntegritySettings{}
|
||||
if len(configured) > 0 {
|
||||
settings = configured[0]
|
||||
}
|
||||
return map[string]any{
|
||||
"fim_baseline": pathStatus(filepath.Join(directory, "fim-baseline.json")),
|
||||
"pkgdb_anchor": pathStatus(filepath.Join(directory, "pkgdb-anchor.json")),
|
||||
// Go uses pacman -Qkk rather than Python's sampled signed-cache verifier.
|
||||
// Keep sample at zero to make that distinction explicit to consumers.
|
||||
"package_verify": map[string]any{
|
||||
"enabled": settings.PackageVerifyEnabled, "engine": "pacman-qkk",
|
||||
"interval": durationSeconds(settings.PackageVerifyInterval), "sample": 0,
|
||||
},
|
||||
"rootcheck": map[string]any{
|
||||
"enabled": settings.RootcheckEnabled,
|
||||
"interval": durationSeconds(settings.RootcheckInterval),
|
||||
},
|
||||
"go_assurance": pathStatus(filepath.Join(directory, "hash-chain.jsonl")),
|
||||
}
|
||||
}
|
||||
|
||||
func durationSeconds(value time.Duration) int64 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(value / time.Second)
|
||||
}
|
||||
|
||||
func pathStatus(path string) map[string]any {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
|
@ -77,9 +78,16 @@ func TestCollectSlowAddsInjectedPackageOwnership(t *testing.T) {
|
|||
|
||||
func TestIntegrityAnchorsAreTruthfulAndReadOnly(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
anchors := IntegrityAnchors(directory)
|
||||
anchors := IntegrityAnchors(directory, IntegritySettings{
|
||||
FIMEnabled: true, FIMScanInterval: 5 * time.Minute,
|
||||
PackageVerifyEnabled: true, PackageVerifyInterval: 6 * time.Hour,
|
||||
RootcheckEnabled: true, RootcheckInterval: 5 * time.Minute,
|
||||
})
|
||||
if anchors["fim_baseline"].(map[string]any)["status"] != "missing" ||
|
||||
anchors["rootcheck"].(map[string]any)["enabled"] != false {
|
||||
anchors["rootcheck"].(map[string]any)["enabled"] != true ||
|
||||
anchors["rootcheck"].(map[string]any)["interval"] != int64(300) ||
|
||||
anchors["package_verify"].(map[string]any)["engine"] != "pacman-qkk" ||
|
||||
anchors["package_verify"].(map[string]any)["interval"] != int64(21600) {
|
||||
t.Fatalf("anchors=%#v", anchors)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "hash-chain.jsonl"), []byte("{}\n"), 0o600); err != nil {
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
121
go-agent/internal/pkgdb/manager.go
Normal file
121
go-agent/internal/pkgdb/manager.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package pkgdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
// Manager owns the slow package-database fingerprint outside the polling path.
|
||||
// One check runs at a time; an unavailable pacman database or log fails open
|
||||
// rather than turning an environmental read error into a tamper claim.
|
||||
type Manager struct {
|
||||
Config config.Config
|
||||
Now func() time.Time
|
||||
Fingerprint func(string) (string, error)
|
||||
LastTransaction func(string) (time.Time, bool)
|
||||
DBDir string
|
||||
PacmanLog string
|
||||
|
||||
mu sync.Mutex
|
||||
initialized bool
|
||||
lastRun time.Time
|
||||
running bool
|
||||
pendingAlerts []model.Alert
|
||||
}
|
||||
|
||||
func New(cfg config.Config) *Manager {
|
||||
return &Manager{
|
||||
Config: cfg, Now: time.Now, Fingerprint: Fingerprint,
|
||||
LastTransaction: LastTransactionTime, DBDir: defaultDBDir, PacmanLog: defaultPacmanLog,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) anchorPath() string {
|
||||
return filepath.Join(m.Config.LogDir, anchorFilename)
|
||||
}
|
||||
|
||||
// Initialize records the first available fingerprint synchronously. This is a
|
||||
// one-time baseline action before events are emitted; subsequent checks stay
|
||||
// asynchronous and never rewrite unexplained drift.
|
||||
func (m *Manager) Initialize() error {
|
||||
if !m.Config.PackageDBVerify {
|
||||
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
|
||||
}
|
||||
if err := m.evaluate(m.Now()); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.initialized = true
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// MaybeStart starts a due fingerprint in the background. Setting lastRun
|
||||
// before launching makes a stalled disk scan self-throttling instead of
|
||||
// spawning one goroutine per polling interval.
|
||||
func (m *Manager) MaybeStart(now time.Time) {
|
||||
if !m.Config.PackageDBVerify {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.initialized || m.running || (!m.lastRun.IsZero() && now.Sub(m.lastRun) < m.Config.PackageDBInterval) {
|
||||
return
|
||||
}
|
||||
m.running, m.lastRun = true, now
|
||||
go m.run(now)
|
||||
}
|
||||
|
||||
func (m *Manager) run(now time.Time) {
|
||||
_ = m.evaluate(now) // Collection errors are intentionally fail-open.
|
||||
m.mu.Lock()
|
||||
m.running = false
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) evaluate(now time.Time) error {
|
||||
current, err := m.Fingerprint(m.DBDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
anchor, hasAnchor := loadAnchor(m.anchorPath())
|
||||
transaction, hasTransaction := m.LastTransaction(m.PacmanLog)
|
||||
alert, replacement := check(current, anchor, hasAnchor, transaction, hasTransaction, now)
|
||||
if replacement != nil {
|
||||
if err := saveAnchor(m.anchorPath(), *replacement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if alert != nil {
|
||||
m.mu.Lock()
|
||||
m.pendingAlerts = append(m.pendingAlerts, *alert)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Drain hands completed findings to Agent, which applies the shared cooldown,
|
||||
// snapshot, and event-stream ordering before any finding becomes observable.
|
||||
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
|
||||
}
|
||||
152
go-agent/internal/pkgdb/pkgdb.go
Normal file
152
go-agent/internal/pkgdb/pkgdb.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package pkgdb ports Sentinel's layer-one pacman database tamper evidence.
|
||||
// It does not treat the mutable local database as a root of trust: it records
|
||||
// a baseline fingerprint, permits only transaction-correlated change, and
|
||||
// retains the old anchor when a change is unexplained.
|
||||
package pkgdb
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
SIDTamper = 100021
|
||||
defaultDBDir = "/var/lib/pacman/local"
|
||||
defaultPacmanLog = "/var/log/pacman.log"
|
||||
anchorFilename = "pkgdb-anchor.json"
|
||||
transactionSlop = 120 * time.Second
|
||||
)
|
||||
|
||||
// Anchor is deliberately compatible with Python's small JSON record. Time is
|
||||
// an epoch value so both implementations can compare it with pacman's log
|
||||
// timestamp without formatting or local-time ambiguity.
|
||||
type Anchor struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Time float64 `json:"time"`
|
||||
}
|
||||
|
||||
// Fingerprint computes the deterministic SHA-256 used for the local package
|
||||
// database. It reads each non-directory entry in lexical walk order; a read
|
||||
// failure makes the whole check unavailable rather than producing a partial
|
||||
// fingerprint that could become an unsafe new baseline.
|
||||
func Fingerprint(directory string) (string, error) {
|
||||
hash := sha256.New()
|
||||
err := filepath.WalkDir(directory, func(path string, entry os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if _, err := io.WriteString(hash, path+"\x00"); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(hash, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
return closeErr
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// LastTransactionTime returns the final pacman transaction marker. A missing
|
||||
// or unparseable log is intentionally inconclusive; it is never treated as a
|
||||
// successful transaction that could authorize a changed database.
|
||||
func LastTransactionTime(path string) (time.Time, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
var latest time.Time
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if !strings.Contains(line, "transaction completed") && !strings.Contains(line, "transaction started") {
|
||||
continue
|
||||
}
|
||||
end := strings.IndexByte(line, ']')
|
||||
if !strings.HasPrefix(line, "[") || end < 2 {
|
||||
continue
|
||||
}
|
||||
stamp := line[1:end]
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05-0700"} {
|
||||
if parsed, err := time.Parse(layout, stamp); err == nil {
|
||||
latest = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return latest, !latest.IsZero()
|
||||
}
|
||||
|
||||
func tamperAlert() model.Alert {
|
||||
return model.Alert{
|
||||
SID: SIDTamper, Severity: "CRITICAL", Signature: "pkgdb_tamper",
|
||||
Classtype: "anti-tamper", Key: "pkgdb:tamper", PIDs: []int{},
|
||||
Detail: "pacman local DB changed with no corresponding transaction — the package checksum database may have been altered to mask file tampering",
|
||||
}
|
||||
}
|
||||
|
||||
func loadAnchor(path string) (Anchor, bool) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Anchor{}, false
|
||||
}
|
||||
var anchor Anchor
|
||||
if err := json.Unmarshal(raw, &anchor); err != nil || anchor.Fingerprint == "" || anchor.Time <= 0 {
|
||||
return Anchor{}, false
|
||||
}
|
||||
return anchor, true
|
||||
}
|
||||
|
||||
func saveAnchor(path string, anchor Anchor) error {
|
||||
raw, err := json.Marshal(anchor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw = append(raw, '\n')
|
||||
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)
|
||||
}
|
||||
|
||||
// check returns an alert only for changed state that no later pacman
|
||||
// transaction can explain. It deliberately returns a replacement anchor only
|
||||
// for first observation or a transaction-correlated change.
|
||||
func check(current string, anchor Anchor, hasAnchor bool, transaction time.Time, hasTransaction bool, now time.Time) (*model.Alert, *Anchor) {
|
||||
if !hasAnchor {
|
||||
fresh := Anchor{Fingerprint: current, Time: float64(now.Unix())}
|
||||
return nil, &fresh
|
||||
}
|
||||
if current == anchor.Fingerprint {
|
||||
return nil, nil
|
||||
}
|
||||
if hasTransaction && !transaction.Before(time.Unix(int64(anchor.Time), 0).Add(-transactionSlop)) {
|
||||
fresh := Anchor{Fingerprint: current, Time: float64(now.Unix())}
|
||||
return nil, &fresh
|
||||
}
|
||||
alert := tamperAlert()
|
||||
return &alert, nil
|
||||
}
|
||||
90
go-agent/internal/pkgdb/pkgdb_test.go
Normal file
90
go-agent/internal/pkgdb/pkgdb_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package pkgdb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
)
|
||||
|
||||
func write(t *testing.T, path, contents string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(contents), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFingerprintIsDeterministicAndChangesWithContent(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write(t, filepath.Join(directory, "b", "desc"), "second")
|
||||
write(t, filepath.Join(directory, "a", "desc"), "first")
|
||||
first, err := Fingerprint(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Fingerprint(directory)
|
||||
if err != nil || first != again {
|
||||
t.Fatalf("fingerprints %q %q err=%v", first, again, err)
|
||||
}
|
||||
write(t, filepath.Join(directory, "a", "desc"), "changed")
|
||||
changed, err := Fingerprint(directory)
|
||||
if err != nil || changed == first {
|
||||
t.Fatalf("changed fingerprint %q original %q err=%v", changed, first, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLastTransactionTimeAcceptsPacmanTimestampLayouts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pacman.log")
|
||||
write(t, path, "[2026-07-24T10:00:00-0700] [ALPM] transaction started\n[2026-07-24T10:01:00-07:00] [ALPM] transaction completed\n")
|
||||
got, ok := LastTransactionTime(path)
|
||||
if !ok || got.Format(time.RFC3339) != "2026-07-24T10:01:00-07:00" {
|
||||
t.Fatalf("transaction=%v ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPreservesUnexplainedChangedAnchorAndAcceptsTransaction(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
database := filepath.Join(directory, "pacman-db")
|
||||
write(t, filepath.Join(database, "pkg", "desc"), "trusted")
|
||||
cfg := config.Default()
|
||||
cfg.LogDir = filepath.Join(directory, "state")
|
||||
manager := New(cfg)
|
||||
manager.DBDir = database
|
||||
now := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC)
|
||||
manager.Now = func() time.Time { return now }
|
||||
manager.LastTransaction = func(string) (time.Time, bool) { return time.Time{}, false }
|
||||
if err := manager.Initialize(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial, ok := loadAnchor(manager.anchorPath())
|
||||
if !ok {
|
||||
t.Fatal("missing initial anchor")
|
||||
}
|
||||
write(t, filepath.Join(database, "pkg", "desc"), "tampered")
|
||||
if err := manager.evaluate(now.Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alerts := manager.Drain()
|
||||
if len(alerts) != 1 || alerts[0].SID != SIDTamper || alerts[0].Severity != "CRITICAL" {
|
||||
t.Fatalf("alerts=%+v", alerts)
|
||||
}
|
||||
afterTamper, _ := loadAnchor(manager.anchorPath())
|
||||
if afterTamper.Fingerprint != initial.Fingerprint {
|
||||
t.Fatal("unexplained change silently replaced anchor")
|
||||
}
|
||||
manager.LastTransaction = func(string) (time.Time, bool) { return now.Add(time.Minute), true }
|
||||
if err := manager.evaluate(now.Add(2 * time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterTransaction, _ := loadAnchor(manager.anchorPath())
|
||||
if afterTransaction.Fingerprint == initial.Fingerprint {
|
||||
t.Fatal("transaction-correlated change did not refresh anchor")
|
||||
}
|
||||
}
|
||||
351
go-agent/internal/rootcheck/collect.go
Normal file
351
go-agent/internal/rootcheck/collect.go
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
procRoot = "/proc"
|
||||
sysRoot = "/sys"
|
||||
)
|
||||
|
||||
// Collect obtains the independent local views used by Check. Every source is
|
||||
// best-effort: unavailable procfs/sysfs files or a missing ps binary remove
|
||||
// only that comparison, never turn a collection error into a rootkit alert.
|
||||
func Collect(ctx context.Context, state model.State, pidCap int) Views {
|
||||
visible := procPIDs()
|
||||
ps, psAvailable := psPIDs(ctx)
|
||||
return Views{
|
||||
VisiblePIDs: visible,
|
||||
AlivePIDs: alivePIDs(ctx, pidCap),
|
||||
PSPIDs: ps,
|
||||
PSAvailable: psAvailable,
|
||||
PIDAlive: pidAlive,
|
||||
ProcPIDExists: procPIDExists,
|
||||
ProcModules: procModules(),
|
||||
SysLiveModules: sysLiveModules(),
|
||||
ModuleTaints: moduleTaints(),
|
||||
KernelTaint: kernelTaint(),
|
||||
ProcTCPPorts: procTCPPorts(),
|
||||
SSTCPPorts: ssTCPPorts(state),
|
||||
ProcUDPPorts: procUDPPorts(),
|
||||
SSUDPPorts: ssUDPPorts(state),
|
||||
ProcRawProtocols: procRawProtocols(),
|
||||
SSRawProtocols: ssRawProtocols(state),
|
||||
ProcProtocolKinds: procProtocolKinds(),
|
||||
SSProtocolKinds: ssProtocolKinds(state),
|
||||
PromiscuousIfaces: promiscuousIfaces(),
|
||||
}
|
||||
}
|
||||
|
||||
// Run collects a bounded live view then evaluates it against operator policy.
|
||||
func Run(ctx context.Context, state model.State, pidCap int, allowed map[string]bool) []model.Alert {
|
||||
return Check(Collect(ctx, state, pidCap), Options{AllowedModules: allowed})
|
||||
}
|
||||
|
||||
func procPIDs() map[int]bool {
|
||||
result := map[int]bool{}
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if pid, err := strconv.Atoi(entry.Name()); err == nil && pid >= 0 {
|
||||
result[pid] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func alivePIDs(ctx context.Context, cap int) map[int]bool {
|
||||
// kill(pid, 0) asks the kernel whether a PID exists without signalling it;
|
||||
// that is intentionally independent of the procfs directory view. Limit
|
||||
// the sweep because pid_max may be very large, and return partial results on
|
||||
// cancellation rather than delaying every other integrity comparison.
|
||||
result := map[int]bool{}
|
||||
if cap <= 0 {
|
||||
return result
|
||||
}
|
||||
maximum := cap
|
||||
if raw, err := os.ReadFile(filepath.Join(procRoot, "sys/kernel/pid_max")); err == nil {
|
||||
if value, err := strconv.Atoi(strings.TrimSpace(string(raw))); err == nil && value < maximum {
|
||||
maximum = value
|
||||
}
|
||||
}
|
||||
for pid := 1; pid <= maximum; pid++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return result
|
||||
default:
|
||||
}
|
||||
err := syscall.Kill(pid, 0)
|
||||
if err == nil || errors.Is(err, syscall.EPERM) {
|
||||
result[pid] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func pidAlive(pid int) bool {
|
||||
// EPERM still proves that the kernel knows about the PID; it only means the
|
||||
// sidecar lacks permission to inspect or signal that process.
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
func procPIDExists(pid int) bool {
|
||||
// Keep this separate from pidAlive: rootcheck alerts on disagreement between
|
||||
// kernel process lookup and the procfs namespace it is expected to expose.
|
||||
_, err := os.Stat(filepath.Join(procRoot, strconv.Itoa(pid)))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func psPIDs(ctx context.Context) (map[int]bool, bool) {
|
||||
// ps provides a third, userspace view. A missing or failing command disables
|
||||
// only this comparison because an unavailable tool is not evidence of a
|
||||
// hidden process.
|
||||
command := exec.CommandContext(ctx, "ps", "-e", "-o", "pid=")
|
||||
raw, err := command.Output()
|
||||
if err != nil {
|
||||
return map[int]bool{}, false
|
||||
}
|
||||
result := map[int]bool{}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil {
|
||||
result[pid] = true
|
||||
}
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func procModules() map[string]bool {
|
||||
result := map[string]bool{}
|
||||
raw, err := os.ReadFile(filepath.Join(procRoot, "modules"))
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if fields := strings.Fields(line); len(fields) > 0 {
|
||||
result[fields[0]] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sysLiveModules() map[string]bool {
|
||||
result := map[string]bool{}
|
||||
entries, err := os.ReadDir(filepath.Join(sysRoot, "module"))
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, entry := range entries {
|
||||
raw, err := os.ReadFile(filepath.Join(sysRoot, "module", entry.Name(), "initstate"))
|
||||
if err == nil && strings.TrimSpace(string(raw)) == "live" {
|
||||
result[entry.Name()] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func moduleTaints() map[string]string {
|
||||
result := map[string]string{}
|
||||
entries, err := os.ReadDir(filepath.Join(sysRoot, "module"))
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, entry := range entries {
|
||||
raw, err := os.ReadFile(filepath.Join(sysRoot, "module", entry.Name(), "taint"))
|
||||
taint := strings.TrimSpace(string(raw))
|
||||
if err == nil && taint != "" && taint != "0" {
|
||||
result[entry.Name()] = taint
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func kernelTaint() int {
|
||||
raw, err := os.ReadFile(filepath.Join(procRoot, "sys/kernel/tainted"))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func procTCPPorts() map[int]bool {
|
||||
return unionInts(readSocketPorts("net/tcp", true), readSocketPorts("net/tcp6", true))
|
||||
}
|
||||
func procUDPPorts() map[int]bool {
|
||||
return unionInts(readSocketPorts("net/udp", false), readSocketPorts("net/udp6", false))
|
||||
}
|
||||
func procRawProtocols() map[int]bool {
|
||||
return unionInts(readSocketPorts("net/raw", false), readSocketPorts("net/raw6", false))
|
||||
}
|
||||
|
||||
func readSocketPorts(relative string, listenOnly bool) map[int]bool {
|
||||
// /proc/net/{tcp,udp,raw} encodes the local endpoint as hex ADDRESS:PORT.
|
||||
// TCP's 0A state is LISTEN; UDP and raw entries have no equivalent listener
|
||||
// state, so callers retain every bound protocol/port for comparison.
|
||||
result := map[int]bool{}
|
||||
raw, err := os.ReadFile(filepath.Join(procRoot, relative))
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for _, line := range lines[1:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 || (listenOnly && fields[3] != "0A") {
|
||||
continue
|
||||
}
|
||||
_, portText, ok := strings.Cut(fields[1], ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.ParseInt(portText, 16, 32)
|
||||
if err == nil && port != 0 {
|
||||
result[int(port)] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func procProtocolKinds() map[string]bool {
|
||||
checks := map[string][]string{
|
||||
"sctp": {"net/sctp/eps", "net/sctp/assocs", "net/sctp/remaddr"},
|
||||
"dccp": {"net/dccp", "net/dccp6"}, "packet": {"net/packet"},
|
||||
"tipc": {"net/tipc/socket"}, "xdp": {"net/xdp"},
|
||||
}
|
||||
result := map[string]bool{}
|
||||
for kind, paths := range checks {
|
||||
for _, path := range paths {
|
||||
if socketRows(path) {
|
||||
result[kind] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func socketRows(relative string) bool {
|
||||
raw, err := os.ReadFile(filepath.Join(procRoot, relative))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
for _, line := range lines[1:] {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ssTCPPorts(state model.State) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
for _, socket := range state.Sockets {
|
||||
if socket.State == "LISTEN" {
|
||||
if port, ok := endpointPort(socket.Local); ok {
|
||||
result[port] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func ssUDPPorts(state model.State) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
for _, socket := range state.Sockets {
|
||||
if socket.State == "UNCONN" || socket.State == "ESTAB" {
|
||||
if port, ok := endpointPort(socket.Local); ok {
|
||||
result[port] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func ssRawProtocols(state model.State) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
for _, socket := range state.Sockets {
|
||||
if socket.Kind == "raw" {
|
||||
if protocol, ok := endpointProtocol(socket.Local); ok {
|
||||
result[protocol] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func ssProtocolKinds(state model.State) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
for _, socket := range state.Sockets {
|
||||
if socket.Kind != "" {
|
||||
result[socket.Kind] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func endpointPort(value string) (int, bool) {
|
||||
index := strings.LastIndex(value, ":")
|
||||
if index < 0 {
|
||||
return 0, false
|
||||
}
|
||||
tail := value[index+1:]
|
||||
port, err := strconv.Atoi(tail)
|
||||
return port, err == nil
|
||||
}
|
||||
func endpointProtocol(value string) (int, bool) {
|
||||
index := strings.LastIndex(value, ":")
|
||||
if index < 0 {
|
||||
return 0, false
|
||||
}
|
||||
tail := value[index+1:]
|
||||
tail = strings.ToLower(strings.Trim(tail, "[]"))
|
||||
if value, err := strconv.Atoi(tail); err == nil {
|
||||
return value, true
|
||||
}
|
||||
protocol, ok := rawProtocolNames[tail]
|
||||
return protocol, ok
|
||||
}
|
||||
|
||||
func promiscuousIfaces() []string {
|
||||
result := make([]string, 0)
|
||||
entries, err := os.ReadDir(filepath.Join(sysRoot, "class/net"))
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, entry := range entries {
|
||||
raw, err := os.ReadFile(filepath.Join(sysRoot, "class/net", entry.Name(), "flags"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
flags, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 0, 64)
|
||||
if err == nil && flags&0x100 != 0 {
|
||||
result = append(result, entry.Name())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func unionInts(left, right map[int]bool) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
for value := range left {
|
||||
result[value] = true
|
||||
}
|
||||
for value := range right {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
72
go-agent/internal/rootcheck/manager.go
Normal file
72
go-agent/internal/rootcheck/manager.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const runTimeout = 30 * time.Second
|
||||
|
||||
// Manager schedules the expensive cross-view collection independently from
|
||||
// polling. One bounded check may run at a time; completed alerts return to the
|
||||
// Agent's existing cooldown, incident, and snapshot path on the next sweep.
|
||||
type Manager struct {
|
||||
Config config.Config
|
||||
Now func() time.Time
|
||||
Run func(context.Context, model.State) []model.Alert
|
||||
|
||||
mu sync.Mutex
|
||||
lastRun time.Time
|
||||
running bool
|
||||
pending []model.Alert
|
||||
}
|
||||
|
||||
func New(cfg config.Config) *Manager {
|
||||
manager := &Manager{Config: cfg, Now: time.Now}
|
||||
manager.Run = func(ctx context.Context, state model.State) []model.Alert {
|
||||
return Run(ctx, state, cfg.RootcheckPIDCap, cfg.RootcheckModuleAllow)
|
||||
}
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *Manager) MaybeStart(now time.Time, state model.State) {
|
||||
if !m.Config.RootcheckEnabled {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.running || (!m.lastRun.IsZero() && now.Sub(m.lastRun) < m.Config.RootcheckInterval) {
|
||||
return
|
||||
}
|
||||
// Record the start before launching the goroutine. This prevents a fast
|
||||
// polling loop from scheduling duplicates while collection is still running
|
||||
// or while a timed-out external command unwinds.
|
||||
m.running, m.lastRun = true, now
|
||||
go m.run(state)
|
||||
}
|
||||
|
||||
func (m *Manager) run(state model.State) {
|
||||
// Rootcheck compares several host views and may invoke ps; its deadline
|
||||
// makes that diagnostic work bounded even on a degraded host.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), runTimeout)
|
||||
defer cancel()
|
||||
alerts := m.Run(ctx, state)
|
||||
m.mu.Lock()
|
||||
m.pending = append(m.pending, alerts...)
|
||||
m.running = false
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *Manager) Drain() []model.Alert {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
alerts := append([]model.Alert(nil), m.pending...)
|
||||
m.pending = nil
|
||||
return alerts
|
||||
}
|
||||
64
go-agent/internal/rootcheck/manager_test.go
Normal file
64
go-agent/internal/rootcheck/manager_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
func TestManagerRunsAtMostOneBoundedCheckAndDrainsResults(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.RootcheckInterval = time.Hour
|
||||
manager := New(cfg)
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
var sawDeadline atomic.Bool
|
||||
manager.Run = func(ctx context.Context, state model.State) []model.Alert {
|
||||
_, ok := ctx.Deadline()
|
||||
sawDeadline.Store(ok)
|
||||
calls.Add(1)
|
||||
close(started)
|
||||
<-release
|
||||
return []model.Alert{{SID: SIDHiddenModule, Key: "rk:test", PIDs: []int{}}}
|
||||
}
|
||||
now := time.Date(2026, 7, 22, 12, 0, 0, 0, time.UTC)
|
||||
manager.MaybeStart(now, model.State{})
|
||||
<-started
|
||||
manager.MaybeStart(now.Add(2*time.Hour), model.State{})
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("concurrent checks = %d, want 1", got)
|
||||
}
|
||||
close(release)
|
||||
for deadline := time.Now().Add(time.Second); time.Now().Before(deadline); {
|
||||
if alerts := manager.Drain(); len(alerts) == 1 {
|
||||
if !sawDeadline.Load() {
|
||||
t.Fatal("rootcheck did not receive a bounded context")
|
||||
}
|
||||
if alerts[0].SID != SIDHiddenModule {
|
||||
t.Fatalf("alerts = %+v", alerts)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatal("completed rootcheck result was not drained")
|
||||
}
|
||||
|
||||
func TestDisabledManagerNeverSchedules(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.RootcheckEnabled = false
|
||||
manager := New(cfg)
|
||||
var calls atomic.Int32
|
||||
manager.Run = func(context.Context, model.State) []model.Alert { calls.Add(1); return nil }
|
||||
manager.MaybeStart(time.Now(), model.State{})
|
||||
if calls.Load() != 0 {
|
||||
t.Fatal("disabled rootcheck scheduled work")
|
||||
}
|
||||
}
|
||||
287
go-agent/internal/rootcheck/rootcheck.go
Normal file
287
go-agent/internal/rootcheck/rootcheck.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
// Package rootcheck ports Sentinel's user-space anti-rootkit cross-view
|
||||
// checks. It cannot prove a kernel free of a rootkit: a sufficiently capable
|
||||
// implant can lie consistently to every local view. It does catch common
|
||||
// hiding faults by comparing independently collected kernel, procfs, sysfs,
|
||||
// socket-tool, and interface views.
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
SIDHiddenProcess = 100022
|
||||
SIDHiddenModule = 100023
|
||||
SIDHiddenPort = 100024
|
||||
SIDPromiscuous = 100025
|
||||
SIDKnownRootkitModule = 100028
|
||||
SIDTaintedModule = 100029
|
||||
SIDKernelTainted = 100030
|
||||
SIDHiddenUDPPort = 100031
|
||||
SIDHiddenRawSocket = 100034
|
||||
SIDRawICMPSocket = 100035
|
||||
SIDHiddenProtocolSocket = 100037
|
||||
SIDPSHiddenProcess = 100038
|
||||
)
|
||||
|
||||
var knownModules = map[string]bool{
|
||||
"adore": true, "adore_ng": true, "azazel": true, "diamorphine": true,
|
||||
"ipsecs_kbeast": true, "kbeast": true, "knark": true, "reptile": true,
|
||||
"suterusu": true, "syslogk": true,
|
||||
}
|
||||
|
||||
var taintFlags = map[int]string{
|
||||
0: "proprietary-module", 1: "forced-module-load", 2: "unsafe-smp",
|
||||
3: "forced-module-unload", 4: "machine-check", 5: "bad-page",
|
||||
6: "user-tainted", 7: "kernel-died-recently", 8: "acpi-override",
|
||||
9: "kernel-warning", 10: "staging-driver", 11: "firmware-workaround",
|
||||
12: "out-of-tree-module", 13: "unsigned-module", 14: "soft-lockup",
|
||||
15: "live-patched", 16: "auxiliary-taint", 17: "randstruct-plugin",
|
||||
}
|
||||
|
||||
var highRiskTaintBits = map[int]bool{1: true, 3: true, 7: true, 12: true, 13: true}
|
||||
var rawProtocolNames = map[string]int{"icmp": 1, "ipv6-icmp": 58, "icmp6": 58, "sctp": 132}
|
||||
|
||||
// Views contains independently collected host observations. The two PID
|
||||
// callbacks preserve Python's race checks: a hidden-process candidate must
|
||||
// still be alive through the kernel while remaining absent from procfs; a
|
||||
// ps-only candidate must still be present in procfs.
|
||||
type Views struct {
|
||||
VisiblePIDs map[int]bool
|
||||
AlivePIDs map[int]bool
|
||||
PSPIDs map[int]bool
|
||||
PSAvailable bool
|
||||
PIDAlive func(int) bool
|
||||
ProcPIDExists func(int) bool
|
||||
ProcModules map[string]bool
|
||||
SysLiveModules map[string]bool
|
||||
ModuleTaints map[string]string
|
||||
KernelTaint int
|
||||
ProcTCPPorts map[int]bool
|
||||
SSTCPPorts map[int]bool
|
||||
ProcUDPPorts map[int]bool
|
||||
SSUDPPorts map[int]bool
|
||||
ProcRawProtocols map[int]bool
|
||||
SSRawProtocols map[int]bool
|
||||
ProcProtocolKinds map[string]bool
|
||||
SSProtocolKinds map[string]bool
|
||||
PromiscuousIfaces []string
|
||||
}
|
||||
|
||||
// Options supplies explicit operator policy. The PID cap is enforced by the
|
||||
// collector; Check is deliberately pure so parity tests do not touch a host.
|
||||
type Options struct {
|
||||
AllowedModules map[string]bool
|
||||
}
|
||||
|
||||
// Check turns a captured set of cross-views into Python-compatible alerts.
|
||||
func Check(views Views, options Options) []model.Alert {
|
||||
alerts := make([]model.Alert, 0)
|
||||
confirmed := make([]int, 0)
|
||||
for _, pid := range sortedIntDifference(views.AlivePIDs, views.VisiblePIDs) {
|
||||
alive := views.PIDAlive == nil || views.PIDAlive(pid)
|
||||
hidden := views.ProcPIDExists == nil || !views.ProcPIDExists(pid)
|
||||
if alive && hidden {
|
||||
confirmed = append(confirmed, pid)
|
||||
}
|
||||
}
|
||||
if len(confirmed) > 0 {
|
||||
shown := confirmed
|
||||
if len(shown) > 20 {
|
||||
shown = shown[:20]
|
||||
}
|
||||
alerts = append(alerts, model.Alert{SID: SIDHiddenProcess, Severity: "CRITICAL",
|
||||
Signature: "rootkit_hidden_process", Classtype: "rootkit-hidden-process",
|
||||
Key: "rk:hidproc:" + itoa(shown[0]),
|
||||
Detail: "process(es) alive but hidden from /proc: " + joinInts(shown), PIDs: shown})
|
||||
}
|
||||
|
||||
if views.PSAvailable {
|
||||
missing := make([]int, 0)
|
||||
for _, pid := range sortedIntDifference(views.VisiblePIDs, views.PSPIDs) {
|
||||
if views.ProcPIDExists == nil || views.ProcPIDExists(pid) {
|
||||
missing = append(missing, pid)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
shown := missing
|
||||
if len(shown) > 20 {
|
||||
shown = shown[:20]
|
||||
}
|
||||
alerts = append(alerts, model.Alert{SID: SIDPSHiddenProcess, Severity: "HIGH",
|
||||
Signature: "rootkit_ps_hidden_process", Classtype: "rootkit-hidden-process",
|
||||
Key: "rk:pshidproc:" + itoa(shown[0]),
|
||||
Detail: "process(es) visible in /proc but missing from ps output (process tool may be hooked): " + joinInts(shown), PIDs: shown})
|
||||
}
|
||||
}
|
||||
|
||||
for _, module := range sortedStringDifference(views.SysLiveModules, views.ProcModules) {
|
||||
alerts = append(alerts, alert(SIDHiddenModule, "CRITICAL", "rootkit_hidden_module", "rootkit-hidden-module", "rk:hidmod:"+module,
|
||||
"kernel module live in /sys/module but hidden from /proc/modules: "+module))
|
||||
}
|
||||
allModules := unionStrings(views.ProcModules, views.SysLiveModules)
|
||||
known := map[string]bool{}
|
||||
for module := range allModules {
|
||||
if knownModules[normalizeModule(module)] {
|
||||
known[module] = true
|
||||
alerts = append(alerts, alert(SIDKnownRootkitModule, "CRITICAL", "rootkit_known_module", "rootkit-known-module", "rk:knownmod:"+module,
|
||||
"known LKM rootkit module name loaded or visible: "+module))
|
||||
}
|
||||
}
|
||||
for _, module := range sortedKeysString(views.ModuleTaints) {
|
||||
if moduleAllowed(options.AllowedModules, module) || known[module] {
|
||||
continue
|
||||
}
|
||||
taint := views.ModuleTaints[module]
|
||||
alerts = append(alerts, alert(SIDTaintedModule, "HIGH", "rootkit_tainted_module", "rootkit-tainted-module", "rk:taintmod:"+module,
|
||||
"kernel module has taint marker '"+taint+"' (out-of-tree/proprietary/unsigned module): "+module))
|
||||
}
|
||||
if views.KernelTaint != 0 {
|
||||
severity := "MEDIUM"
|
||||
for bit := range highRiskTaintBits {
|
||||
if views.KernelTaint&(1<<bit) != 0 {
|
||||
severity = "HIGH"
|
||||
break
|
||||
}
|
||||
}
|
||||
flags := decodeTaint(views.KernelTaint)
|
||||
label := strings.Join(flags, ", ")
|
||||
if label == "" {
|
||||
label = "unknown"
|
||||
}
|
||||
alerts = append(alerts, alert(SIDKernelTainted, severity, "kernel_tainted", "kernel-integrity", "rk:ktaint:"+itoa(views.KernelTaint),
|
||||
"kernel taint value "+itoa(views.KernelTaint)+" ("+label+") — may indicate unsigned/out-of-tree modules, forced loads, or kernel faults"))
|
||||
}
|
||||
|
||||
alerts = appendPortAlert(alerts, sortedIntDifference(views.ProcTCPPorts, views.SSTCPPorts), SIDHiddenPort, "rootkit_hidden_port", "rk:hidport:",
|
||||
"listening port(s) in /proc/net/tcp not reported by ss (tool may be hooked): ", "rootkit-hidden-port")
|
||||
alerts = appendPortAlert(alerts, sortedIntDifference(views.ProcUDPPorts, views.SSUDPPorts), SIDHiddenUDPPort, "rootkit_hidden_udp_port", "rk:hidudp:",
|
||||
"UDP port(s) in /proc/net/udp not reported by ss (tool may be hooked): ", "rootkit-hidden-port")
|
||||
raw := sortedIntDifference(views.ProcRawProtocols, views.SSRawProtocols)
|
||||
alerts = appendPortAlert(alerts, raw, SIDHiddenRawSocket, "rootkit_hidden_raw_socket", "rk:hidraw:",
|
||||
"raw socket protocol(s) in /proc/net/raw not reported by ss (tool may be hooked): ", "rootkit-hidden-socket")
|
||||
icmp := make([]int, 0)
|
||||
for _, protocol := range sortedKeys(views.ProcRawProtocols) {
|
||||
if protocol == 1 || protocol == 58 {
|
||||
icmp = append(icmp, protocol)
|
||||
}
|
||||
}
|
||||
if len(icmp) > 0 {
|
||||
alerts = append(alerts, alert(SIDRawICMPSocket, "HIGH", "raw_icmp_socket", "raw-socket-backdoor", "rk:rawicmp:"+itoa(icmp[0]),
|
||||
"persistent raw ICMP socket protocol(s) visible in /proc/net/raw: "+joinInts(icmp)+" — validate authorized ping/monitoring tools or ICMP knockers"))
|
||||
}
|
||||
protocols := sortedStringDifference(views.ProcProtocolKinds, views.SSProtocolKinds)
|
||||
if len(protocols) > 0 {
|
||||
alerts = append(alerts, alert(SIDHiddenProtocolSocket, "HIGH", "rootkit_hidden_protocol_socket", "rootkit-hidden-socket", "rk:hidproto:"+protocols[0],
|
||||
"special protocol socket family present in /proc/net but absent from ss output (tool may be hooked): "+strings.Join(protocols, ", ")))
|
||||
}
|
||||
for _, iface := range sortedStrings(views.PromiscuousIfaces) {
|
||||
alerts = append(alerts, alert(SIDPromiscuous, "MEDIUM", "promiscuous_interface", "network-sniffer", "rk:promisc:"+iface,
|
||||
"interface in promiscuous mode (possible sniffer): "+iface))
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
func appendPortAlert(alerts []model.Alert, values []int, sid int, signature, keyPrefix, detailPrefix, class string) []model.Alert {
|
||||
if len(values) == 0 {
|
||||
return alerts
|
||||
}
|
||||
return append(alerts, alert(sid, "HIGH", signature, class, keyPrefix+itoa(values[0]), detailPrefix+joinInts(values)))
|
||||
}
|
||||
|
||||
func alert(sid int, severity, signature, class, key, detail string) model.Alert {
|
||||
return model.Alert{SID: sid, Severity: severity, Signature: signature, Classtype: class, Key: key, Detail: detail, PIDs: []int{}}
|
||||
}
|
||||
|
||||
func normalizeModule(value string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(value), "-", "_")
|
||||
}
|
||||
|
||||
func moduleAllowed(allowed map[string]bool, module string) bool {
|
||||
for name := range allowed {
|
||||
if normalizeModule(name) == normalizeModule(module) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func itoa(value int) string { return strconv.Itoa(value) }
|
||||
|
||||
func sortedIntDifference(left, right map[int]bool) []int {
|
||||
values := make([]int, 0)
|
||||
for value := range left {
|
||||
if !right[value] {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
sort.Ints(values)
|
||||
return values
|
||||
}
|
||||
func sortedStringDifference(left, right map[string]bool) []string {
|
||||
values := make([]string, 0)
|
||||
for value := range left {
|
||||
if !right[value] {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values
|
||||
}
|
||||
func sortedKeys[V any](source map[int]V) []int {
|
||||
values := make([]int, 0, len(source))
|
||||
for value := range source {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Ints(values)
|
||||
return values
|
||||
}
|
||||
func sortedStrings(values []string) []string {
|
||||
result := append([]string(nil), values...)
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
func sortedKeysString[V any](source map[string]V) []string {
|
||||
values := make([]string, 0, len(source))
|
||||
for value := range source {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values
|
||||
}
|
||||
func unionStrings(left, right map[string]bool) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
for value := range left {
|
||||
result[value] = true
|
||||
}
|
||||
for value := range right {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
func decodeTaint(value int) []string {
|
||||
keys := make([]int, 0, len(taintFlags))
|
||||
for bit := range taintFlags {
|
||||
keys = append(keys, bit)
|
||||
}
|
||||
sort.Ints(keys)
|
||||
values := make([]string, 0)
|
||||
for _, bit := range keys {
|
||||
if value&(1<<bit) != 0 {
|
||||
values = append(values, taintFlags[bit])
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
func joinInts(values []int) string {
|
||||
rendered := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
rendered[i] = itoa(value)
|
||||
}
|
||||
return strings.Join(rendered, ", ")
|
||||
}
|
||||
89
go-agent/internal/rootcheck/rootcheck_test.go
Normal file
89
go-agent/internal/rootcheck/rootcheck_test.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package rootcheck
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func setInts(values ...int) map[int]bool {
|
||||
result := map[int]bool{}
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func setStrings(values ...string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
for _, value := range values {
|
||||
result[value] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestCheckIsQuietWhenCrossViewsAgree(t *testing.T) {
|
||||
alerts := Check(Views{
|
||||
VisiblePIDs: setInts(1, 2), AlivePIDs: setInts(1, 2),
|
||||
PSPIDs: setInts(1, 2), PSAvailable: true,
|
||||
PIDAlive: func(int) bool { return true }, ProcPIDExists: func(int) bool { return true },
|
||||
ProcModules: setStrings("ext4"), SysLiveModules: setStrings("ext4"),
|
||||
}, Options{})
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("alerts = %+v, want none", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReportsRootkitCrossViewSignalsInPythonOrder(t *testing.T) {
|
||||
alerts := Check(Views{
|
||||
VisiblePIDs: setInts(1), AlivePIDs: setInts(1), PSPIDs: setInts(1), PSAvailable: true,
|
||||
PIDAlive: func(int) bool { return true }, ProcPIDExists: func(int) bool { return true },
|
||||
ProcModules: setStrings("ext4"), SysLiveModules: setStrings("ext4", "diamorphine"),
|
||||
ModuleTaints: map[string]string{"vendor_gpu": "OE"}, KernelTaint: (1 << 12) | (1 << 13),
|
||||
ProcTCPPorts: setInts(22, 31337), SSTCPPorts: setInts(22),
|
||||
ProcUDPPorts: setInts(53, 4444), SSUDPPorts: setInts(53),
|
||||
ProcRawProtocols: setInts(1, 58), SSRawProtocols: setInts(58),
|
||||
ProcProtocolKinds: setStrings("sctp", "packet"), SSProtocolKinds: setStrings("packet"),
|
||||
PromiscuousIfaces: []string{"eth0"},
|
||||
}, Options{})
|
||||
got := make([]int, len(alerts))
|
||||
for index, alert := range alerts {
|
||||
got[index] = alert.SID
|
||||
}
|
||||
want := []int{SIDHiddenModule, SIDKnownRootkitModule, SIDTaintedModule, SIDKernelTainted,
|
||||
SIDHiddenPort, SIDHiddenUDPPort, SIDHiddenRawSocket, SIDRawICMPSocket,
|
||||
SIDHiddenProtocolSocket, SIDPromiscuous}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("SIDs = %v, want %v", got, want)
|
||||
}
|
||||
if alerts[0].Severity != "CRITICAL" || alerts[1].Signature != "rootkit_known_module" ||
|
||||
alerts[3].Severity != "HIGH" || alerts[7].Key != "rk:rawicmp:1" {
|
||||
t.Fatalf("unexpected alert detail: %+v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRechecksProcessCandidatesAndLimitsRenderedPIDs(t *testing.T) {
|
||||
alive := map[int]bool{}
|
||||
for pid := 1; pid <= 25; pid++ {
|
||||
alive[pid] = true
|
||||
}
|
||||
alerts := Check(Views{AlivePIDs: alive, VisiblePIDs: map[int]bool{},
|
||||
PIDAlive: func(pid int) bool { return pid != 1 }, ProcPIDExists: func(int) bool { return false },
|
||||
}, Options{})
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("alerts = %+v, want one", alerts)
|
||||
}
|
||||
if len(alerts[0].PIDs) != 20 || alerts[0].PIDs[0] != 2 || alerts[0].PIDs[19] != 21 {
|
||||
t.Fatalf("pids = %v, want [2..21]", alerts[0].PIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHonorsNormalizedModuleAllowlist(t *testing.T) {
|
||||
alerts := Check(Views{ModuleTaints: map[string]string{"vendor-gpu": "OE"}}, Options{
|
||||
AllowedModules: setStrings("vendor_gpu"),
|
||||
})
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("alerts = %+v, want none", alerts)
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/correlation"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/enrich"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/incident"
|
||||
|
|
@ -83,6 +84,7 @@ type Store struct {
|
|||
Incidents *incident.Store
|
||||
LineageDepth int
|
||||
Assurance *assurance.Chain
|
||||
Integrity enrich.IntegritySettings
|
||||
|
||||
slowJobs chan slowJob
|
||||
slowClosed bool
|
||||
|
|
@ -152,6 +154,21 @@ func (s *Store) ConfigureIncidents(enabled bool, window, lineageDepth int) error
|
|||
return nil
|
||||
}
|
||||
|
||||
// ConfigureIntegrity copies settings for engines main actually attached into
|
||||
// retained snapshot metadata. This does not attach an engine or perform
|
||||
// collection: `--snapshot-dir` alone must not make default config values look
|
||||
// like active FIM/rootcheck managers.
|
||||
func (s *Store) ConfigureIntegrity(cfg config.Config, fimActive, rootcheckActive bool) {
|
||||
s.Integrity = enrich.IntegritySettings{
|
||||
FIMEnabled: fimActive,
|
||||
FIMScanInterval: cfg.FIMScanInterval,
|
||||
PackageVerifyEnabled: fimActive && cfg.FIMPackageVerify,
|
||||
PackageVerifyInterval: cfg.FIMPackageVerifyInterval,
|
||||
RootcheckEnabled: rootcheckActive,
|
||||
RootcheckInterval: cfg.RootcheckInterval,
|
||||
}
|
||||
}
|
||||
|
||||
// EnableSlowEnrichment starts one bounded worker. It deliberately never runs
|
||||
// package-manager commands: queue saturation or collector failure only leaves
|
||||
// fields unknown and can never delay or reject an alert event.
|
||||
|
|
@ -291,7 +308,7 @@ func (s *Store) applySlowEnrichment(job slowJob) {
|
|||
processes := enrichmentProcesses(report.Processes)
|
||||
paths := enrichmentPaths(report.Enrichment)
|
||||
updates := enrich.CollectSlow(processes, paths, provenance.PackageOwner)
|
||||
updates.Integrity = enrich.IntegrityAnchors(s.Dir)
|
||||
updates.Integrity = enrich.IntegrityAnchors(s.Dir, s.Integrity)
|
||||
|
||||
// All slow I/O has completed. Take the lock only to merge into the newest
|
||||
// same-second report, so a concurrent alert capture never loses evidence.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"time"
|
||||
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/assurance"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
|
||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||
)
|
||||
|
||||
|
|
@ -182,6 +183,50 @@ func TestSlowEnrichmentAddsBoundedHashAndFileMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSlowEnrichmentReportsConfiguredIntegrityWithoutRunningIt(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 10, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.FIMPackageVerify = true
|
||||
store.ConfigureIntegrity(cfg, true, true)
|
||||
store.EnableSlowEnrichment()
|
||||
defer store.Close()
|
||||
store.Collect = func(pid int) ProcessDetail { return ProcessDetail{PID: pid, FDs: map[string]string{}} }
|
||||
if _, err := store.CaptureEvent(alertEvent("2026-07-22T10:11:12Z", model.Alert{
|
||||
SID: 1, Severity: "HIGH", Signature: "test", Classtype: "test", Key: "integrity", Detail: "test", PIDs: []int{42},
|
||||
})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store.FlushSlow()
|
||||
report, err := LoadReport(filepath.Join(store.Dir, "alert-20260722-101112.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
integrity, ok := report.Enrichment["integrity"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("integrity=%#v", report.Enrichment["integrity"])
|
||||
}
|
||||
if integrity["package_verify"].(map[string]any)["enabled"] != true ||
|
||||
integrity["rootcheck"].(map[string]any)["enabled"] != true {
|
||||
t.Fatalf("integrity=%#v", integrity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigureIntegrityDoesNotReportUnattachedManagers(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 10, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
cfg.FIMPackageVerify = true
|
||||
store.ConfigureIntegrity(cfg, false, false)
|
||||
if store.Integrity.FIMEnabled || store.Integrity.PackageVerifyEnabled || store.Integrity.RootcheckEnabled {
|
||||
t.Fatalf("inactive managers reported as configured: %#v", store.Integrity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetentionPrunesCountAndAgeAsPairs(t *testing.T) {
|
||||
store, err := New(t.TempDir(), "/unused", 2, 1)
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue