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
151 lines
4.8 KiB
Go
151 lines
4.8 KiB
Go
// 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)
|
|
}
|
|
}
|