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
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue