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