From fc9b5f044837c3e711dfe1f0c3d8b05c365d409d Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 05:19:56 -0700 Subject: [PATCH] feat(go): port process access detectors --- CLAUDE.md | 3 +- README.md | 9 +- docs/ROADMAP.md | 7 +- docs/SURICATA_ASSIMILATION.md | 3 +- go-agent/README.md | 4 +- go-agent/internal/agent/agent.go | 9 ++ go-agent/internal/agent/agent_test.go | 28 +++++ go-agent/internal/config/config.go | 55 ++++++++- go-agent/internal/config/config_test.go | 10 ++ .../internal/detectors/credential_access.go | 104 ++++++++++++++++++ .../detectors/credential_access_test.go | 32 ++++++ go-agent/internal/detectors/fd.go | 24 ++++ go-agent/internal/detectors/input_snooper.go | 47 ++++++++ .../internal/detectors/input_snooper_test.go | 21 ++++ go-agent/internal/detectors/ld_preload.go | 57 ++++++++++ .../internal/detectors/ld_preload_test.go | 29 +++++ go-agent/internal/model/model.go | 11 +- go-agent/internal/system/state.go | 63 ++++++++++- go-agent/internal/system/state_test.go | 22 +++- scripts/check-go-parity.py | 32 +++++- tests/fixtures/go/deleted-exe.json | 8 -- tests/fixtures/go/process-detectors.json | 16 +++ 22 files changed, 554 insertions(+), 40 deletions(-) create mode 100644 go-agent/internal/detectors/credential_access.go create mode 100644 go-agent/internal/detectors/credential_access_test.go create mode 100644 go-agent/internal/detectors/fd.go create mode 100644 go-agent/internal/detectors/input_snooper.go create mode 100644 go-agent/internal/detectors/input_snooper_test.go create mode 100644 go-agent/internal/detectors/ld_preload.go create mode 100644 go-agent/internal/detectors/ld_preload_test.go delete mode 100644 tests/fixtures/go/deleted-exe.json create mode 100644 tests/fixtures/go/process-detectors.json diff --git a/CLAUDE.md b/CLAUDE.md index 11223ea..64bf5c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,8 @@ bcc/eBPF telemetry. An experimental, non-installing Phase 1 Go sidecar now lives under `go-agent/`. It is stdlib-only, emits `enodia.event.v1` JSONL, captures an injectable process -view, and ports only `deleted_exe` so far. Python remains the production agent +and file-descriptor view, and currently ports `ld_preload`, `deleted_exe`, +`input_snooper`, and `credential_access`. Python remains the production agent and parity oracle; do not describe the Go path as a replacement yet. Implemented detection coverage: diff --git a/README.md b/README.md index 1fcbb8d..04281d7 100644 --- a/README.md +++ b/README.md @@ -65,10 +65,11 @@ Project docs: > red-team harness, so every signature is exercised against both. Phase 1 now also has an **experimental Go sidecar** under `go-agent/`. It emits -the same `enodia.event.v1` envelope, captures an injectable `/proc` process -view, and ports `deleted_exe` as the first parity-checked poll detector. It is -stdout-only and does not replace, install over, or write state for the Python -daemon; Python remains authoritative until subsystem parity is complete. +the same `enodia.event.v1` envelope and captures an injectable `/proc` process +view. The first parity-checked cohort is `ld_preload`, `deleted_exe`, +`input_snooper`, and `credential_access`. It is stdout-only and does not +replace, install over, or write state for the Python daemon; Python remains +authoritative until subsystem parity is complete. ## Why these detectors diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5ef833e..6ed0603 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -260,9 +260,10 @@ Exit criteria: - Suricata model assimilation design is complete, and Phase 0's additive `enodia.event.v1` Python reference contract is implemented. - Phase 1 is in progress under `go-agent/`: a stdlib-only parallel sweep loop, - injectable process state, and the first poll-detector port (`deleted_exe`) - are checked against the Python oracle with a shared fixture. The Go sidecar - remains stdout-only and non-production until broader detector parity lands. + injectable process/file-descriptor state, and the first poll-detector cohort + (`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`) are + checked against the Python oracle with a shared fixture. The Go sidecar + remains stdout-only and non-production until broader parity lands. ## Backlog diff --git a/docs/SURICATA_ASSIMILATION.md b/docs/SURICATA_ASSIMILATION.md index dc228ae..9c2f7aa 100644 --- a/docs/SURICATA_ASSIMILATION.md +++ b/docs/SURICATA_ASSIMILATION.md @@ -228,7 +228,8 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs. equivalent, poll detectors ported, JSON output matching schemas. Runs alongside Python; harness compares both. *In progress:* the isolated `go-agent/` sidecar now has stdlib config loading, an injectable `/proc` - process view, the JSONL sweep loop, and fixture parity for `deleted_exe`; + process/file-descriptor view, the JSONL sweep loop, and fixture parity for + `ld_preload`, `deleted_exe`, `input_snooper`, and `credential_access`; Python remains the production agent. - **Phase 2 — eBPF via `cilium/ebpf`.** Port exec/syscall rules; drop the bcc/Python runtime dependency; single static binary. diff --git a/go-agent/README.md b/go-agent/README.md index 739de8f..a6d7a72 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -9,7 +9,9 @@ Current scope: - standard-library-only flat TOML loading for the settings used so far; - an injectable `/proc` process snapshot boundary; - a cancellable sweep loop that emits JSONL `enodia.event.v1` records; -- exact `enodia.alert.v1` parity for the `deleted_exe` detector (SID `100012`); +- exact `enodia.alert.v1` parity for `ld_preload` (SID `100011`), + `deleted_exe` (`100012`), `input_snooper` (`100032`), and + `credential_access` (`100033`); - `enodia.status.v1` heartbeat records with no persistence or retained-alert claims; - a shared fixture checked against the Python detector by diff --git a/go-agent/internal/agent/agent.go b/go-agent/internal/agent/agent.go index c877a3a..0a1b437 100644 --- a/go-agent/internal/agent/agent.go +++ b/go-agent/internal/agent/agent.go @@ -57,9 +57,18 @@ func (a *Agent) Sweep() ([]map[string]any, error) { timestamp := a.Now().Local().Format(time.RFC3339Nano) alerts := make([]model.Alert, 0) + if a.Config.Enabled("ld_preload") { + alerts = append(alerts, detectors.LDPreload(state)...) + } if a.Config.Enabled("deleted_exe") { alerts = append(alerts, detectors.DeletedExe(state)...) } + if a.Config.Enabled("input_snooper") { + alerts = append(alerts, detectors.InputSnooper(state, a.Config)...) + } + if a.Config.Enabled("credential_access") { + alerts = append(alerts, detectors.CredentialAccess(state, a.Config)...) + } events := make([]map[string]any, 0, len(alerts)+1) for _, alert := range alerts { record, err := schema.Build("alert", alert, host, timestamp) diff --git a/go-agent/internal/agent/agent_test.go b/go-agent/internal/agent/agent_test.go index 2daf6c6..1388c01 100644 --- a/go-agent/internal/agent/agent_test.go +++ b/go-agent/internal/agent/agent_test.go @@ -52,3 +52,31 @@ func TestDisabledDetectorEmitsStatusOnly(t *testing.T) { t.Fatalf("unexpected events: %#v", events) } } + +func TestSweepPreservesPythonDetectorOrder(t *testing.T) { + agent := New(config.Default(), func() (model.State, error) { + return model.State{ + LDPreload: "/tmp/global.so", + Processes: []model.Process{{ + PID: 42, + Comm: "dropper", + Exe: "/tmp/dropper (deleted)", + FDTargets: map[string]string{"4": "/etc/shadow", "7": "/dev/input/event3"}, + }}, + }, nil + }) + events, err := agent.Sweep() + if err != nil { + t.Fatal(err) + } + want := []string{"ld_preload", "deleted_exe", "input_snooper", "credential_access"} + if len(events) != len(want)+1 { + t.Fatalf("unexpected events: %#v", events) + } + for index, signature := range want { + alert := events[index]["alert"].(model.Alert) + if alert.Signature != signature { + t.Fatalf("event %d: got %s, want %s", index, alert.Signature, signature) + } + } +} diff --git a/go-agent/internal/config/config.go b/go-agent/internal/config/config.go index 280c08e..0bae112 100644 --- a/go-agent/internal/config/config.go +++ b/go-agent/internal/config/config.go @@ -19,12 +19,26 @@ var defaultDetectors = []string{ "first_seen", "new_listener", "new_suid", "persistence", "egress", } +var defaultInputSnooperAllowComms = []string{ + "Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway", "Hyprland", + "systemd-logind", "input-remapper", "keyd", "kanata", +} + +var defaultCredentialAccessAllowComms = []string{ + "sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd", + "gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox", "chromium", + "chrome", "google-chrome", "brave", "brave-browser", +} + // 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 - HeartbeatMaxAge int - Detectors map[string]bool + SampleInterval time.Duration + HeartbeatMaxAge int + Detectors map[string]bool + InputSnooperAllowComms map[string]bool + CredentialAccessAllowComms map[string]bool + CredentialAccessExtraPaths []string } // Default returns Python-compatible defaults for the fields currently used. @@ -34,9 +48,12 @@ func Default() Config { detectors[name] = true } return Config{ - SampleInterval: 4 * time.Second, - HeartbeatMaxAge: 120, - Detectors: detectors, + SampleInterval: 4 * time.Second, + HeartbeatMaxAge: 120, + Detectors: detectors, + InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms), + CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms), + CredentialAccessExtraPaths: []string{}, } } @@ -90,11 +107,37 @@ func Load(path string) (Config, error) { for _, name := range names { cfg.Detectors[name] = true } + case "input_snooper_allow_comms": + names, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("input_snooper_allow_comms: %w", err) + } + cfg.InputSnooperAllowComms = stringSet(names) + case "credential_access_allow_comms": + names, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("credential_access_allow_comms: %w", err) + } + cfg.CredentialAccessAllowComms = stringSet(names) + case "credential_access_extra_paths": + paths, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("credential_access_extra_paths: %w", err) + } + cfg.CredentialAccessExtraPaths = paths } } return cfg, nil } +func stringSet(values []string) map[string]bool { + result := make(map[string]bool, len(values)) + for _, value := range values { + result[value] = true + } + return result +} + func assignments(text string) []string { var out []string var current strings.Builder diff --git a/go-agent/internal/config/config_test.go b/go-agent/internal/config/config_test.go index de02fff..a9e38e5 100644 --- a/go-agent/internal/config/config_test.go +++ b/go-agent/internal/config/config_test.go @@ -28,6 +28,9 @@ detectors = [ "deleted_exe", "egress", ] +input_snooper_allow_comms = ["Xorg"] +credential_access_allow_comms = ["firefox"] +credential_access_extra_paths = ["/srv/secrets/"] unknown_future_key = true `) if err := os.WriteFile(path, raw, 0o600); err != nil { @@ -43,6 +46,13 @@ unknown_future_key = true if !cfg.Enabled("deleted_exe") || !cfg.Enabled("egress") || cfg.Enabled("reverse_shell") { t.Fatalf("unexpected detector set: %#v", cfg.Detectors) } + if !cfg.InputSnooperAllowComms["Xorg"] || cfg.InputSnooperAllowComms["sway"] { + t.Fatalf("unexpected input allowlist: %#v", cfg.InputSnooperAllowComms) + } + if !cfg.CredentialAccessAllowComms["firefox"] || len(cfg.CredentialAccessExtraPaths) != 1 { + t.Fatalf("unexpected credential tuning: %#v %#v", + cfg.CredentialAccessAllowComms, cfg.CredentialAccessExtraPaths) + } } func TestMissingFileKeepsDefaults(t *testing.T) { diff --git a/go-agent/internal/detectors/credential_access.go b/go-agent/internal/detectors/credential_access.go new file mode 100644 index 0000000..3b8c008 --- /dev/null +++ b/go-agent/internal/detectors/credential_access.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "fmt" + "path/filepath" + "strings" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +var exactCredentialFiles = map[string]bool{ + "/etc/shadow": true, "/etc/gshadow": true, "/etc/security/opasswd": true, +} + +var browserSecretNames = map[string]bool{ + "logins.json": true, "key4.db": true, "Login Data": true, "Cookies": true, +} + +var browserPathMarkers = []string{ + "/.mozilla/", "/firefox/", "/chromium/", "/google-chrome/", + "/chrome/", "/brave", "/vivaldi/", "/edge/", +} + +// CredentialAccess ports the Python credential_access detector (SID 100033). +func CredentialAccess(state model.State, cfg config.Config) []model.Alert { + alerts := make([]model.Alert, 0) + for _, process := range state.Processes { + comm := process.Comm + if comm == "" { + comm = "?" + } + if cfg.CredentialAccessAllowComms[comm] { + continue + } + for _, fd := range sortedFDs(process.FDTargets) { + target := strings.TrimSuffix(process.FDTargets[fd], " (deleted)") + kind := sensitiveCredentialKind(target, cfg.CredentialAccessExtraPaths) + if kind == "" { + continue + } + alerts = append(alerts, model.Alert{ + SID: 100033, + Severity: "CRITICAL", + Signature: "credential_access", + Classtype: "credential-theft", + Key: fmt.Sprintf("cred:%d:%s", process.PID, target), + Detail: fmt.Sprintf( + "pid=%d comm=%s fd=%s has %s open: %s", + process.PID, comm, fd, kind, target), + PIDs: []int{process.PID}, + }) + break + } + } + return alerts +} + +func sensitiveCredentialKind(path string, extraPaths []string) string { + if !strings.HasPrefix(path, "/") { + return "" + } + if exactCredentialFiles[path] { + return "system credential database" + } + if matchesExtraPath(path, extraPaths) { + return "configured credential path" + } + if strings.Contains(path, "/.ssh/") { + name := filepath.Base(path) + if strings.HasPrefix(name, "id_") || strings.HasSuffix(name, ".pem") || strings.HasSuffix(name, ".key") { + return "private SSH key" + } + } + if strings.HasPrefix(path, "/etc/NetworkManager/system-connections/") { + return "NetworkManager secret profile" + } + if browserSecretNames[filepath.Base(path)] { + lowered := strings.ToLower(path) + for _, marker := range browserPathMarkers { + if strings.Contains(lowered, marker) { + return "browser credential store" + } + } + } + return "" +} + +func matchesExtraPath(path string, entries []string) bool { + for _, entry := range entries { + if entry == "" { + continue + } + if strings.HasSuffix(entry, "/") && strings.HasPrefix(path, entry) { + return true + } + if path == entry || strings.HasPrefix(path, strings.TrimRight(entry, "/")+"/") { + return true + } + } + return false +} diff --git a/go-agent/internal/detectors/credential_access_test.go b/go-agent/internal/detectors/credential_access_test.go new file mode 100644 index 0000000..6b3e8c8 --- /dev/null +++ b/go-agent/internal/detectors/credential_access_test.go @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "strings" + "testing" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +func TestCredentialAccessMatchesKindsAndAllowlist(t *testing.T) { + cfg := config.Default() + cfg.CredentialAccessExtraPaths = []string{"/srv/secrets/"} + state := model.State{Processes: []model.Process{ + {PID: 330, Comm: "hoxha", FDTargets: map[string]string{"4": "/etc/shadow"}}, + {PID: 331, Comm: "collector", FDTargets: map[string]string{"5": "/home/luna/.ssh/id_ed25519"}}, + {PID: 332, Comm: "firefox", FDTargets: map[string]string{"8": "/home/luna/.mozilla/firefox/a/key4.db"}}, + {PID: 333, Comm: "backup", FDTargets: map[string]string{"9": "/srv/secrets/token (deleted)"}}, + }} + alerts := CredentialAccess(state, cfg) + if len(alerts) != 3 { + t.Fatalf("expected three alerts, got %#v", alerts) + } + if alerts[0].Key != "cred:330:/etc/shadow" || !strings.Contains(alerts[1].Detail, "private SSH key") { + t.Fatalf("unexpected alerts: %#v", alerts) + } + if !strings.Contains(alerts[2].Detail, "configured credential path") || strings.Contains(alerts[2].Key, "(deleted)") { + t.Fatalf("deleted suffix or extra path mismatch: %#v", alerts[2]) + } +} diff --git a/go-agent/internal/detectors/fd.go b/go-agent/internal/detectors/fd.go new file mode 100644 index 0000000..89aa3ff --- /dev/null +++ b/go-agent/internal/detectors/fd.go @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "sort" + "strconv" +) + +func sortedFDs(targets map[string]string) []string { + fds := make([]string, 0, len(targets)) + for fd := range targets { + fds = append(fds, fd) + } + sort.Slice(fds, func(i, j int) bool { + left, leftErr := strconv.Atoi(fds[i]) + right, rightErr := strconv.Atoi(fds[j]) + if leftErr == nil && rightErr == nil { + return left < right + } + return fds[i] < fds[j] + }) + return fds +} diff --git a/go-agent/internal/detectors/input_snooper.go b/go-agent/internal/detectors/input_snooper.go new file mode 100644 index 0000000..6359f6e --- /dev/null +++ b/go-agent/internal/detectors/input_snooper.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "fmt" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +var inputDevicePrefixes = []string{ + "/dev/input/event", "/dev/uinput", "/dev/hidraw", +} + +// InputSnooper ports the Python input_snooper detector (SID 100032). +func InputSnooper(state model.State, cfg config.Config) []model.Alert { + alerts := make([]model.Alert, 0) + for _, process := range state.Processes { + comm := process.Comm + if comm == "" { + comm = "?" + } + if cfg.InputSnooperAllowComms[comm] { + continue + } + for _, fd := range sortedFDs(process.FDTargets) { + target := process.FDTargets[fd] + if !hasAnyPrefix(target, inputDevicePrefixes) { + continue + } + alerts = append(alerts, model.Alert{ + SID: 100032, + Severity: "HIGH", + Signature: "input_snooper", + Classtype: "credential-keylogging", + Key: fmt.Sprintf("input:%d:%s", process.PID, target), + Detail: fmt.Sprintf( + "pid=%d comm=%s fd=%s has input device open: %s", + process.PID, comm, fd, target), + PIDs: []int{process.PID}, + }) + break + } + } + return alerts +} diff --git a/go-agent/internal/detectors/input_snooper_test.go b/go-agent/internal/detectors/input_snooper_test.go new file mode 100644 index 0000000..8560888 --- /dev/null +++ b/go-agent/internal/detectors/input_snooper_test.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "testing" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +func TestInputSnooperAlertsAndHonorsAllowlist(t *testing.T) { + state := model.State{Processes: []model.Process{ + {PID: 320, Comm: "hoxha", FDTargets: map[string]string{"7": "/dev/input/event3"}}, + {PID: 321, Comm: "Xorg", FDTargets: map[string]string{"8": "/dev/hidraw0"}}, + }} + alerts := InputSnooper(state, config.Default()) + if len(alerts) != 1 || alerts[0].SID != 100032 || alerts[0].Key != "input:320:/dev/input/event3" { + t.Fatalf("unexpected alerts: %#v", alerts) + } +} diff --git a/go-agent/internal/detectors/ld_preload.go b/go-agent/internal/detectors/ld_preload.go new file mode 100644 index 0000000..962af83 --- /dev/null +++ b/go-agent/internal/detectors/ld_preload.go @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "fmt" + "strings" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +var suspiciousPreloadPrefixes = []string{ + "/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/", "./", +} + +// LDPreload ports the global and per-process Python ld_preload detector. +func LDPreload(state model.State) []model.Alert { + alerts := make([]model.Alert, 0) + if state.LDPreload != "" { + alerts = append(alerts, model.Alert{ + SID: 100011, + Severity: "CRITICAL", + Signature: "ld_preload", + Classtype: "rootkit-preload", + Key: "ldp:global", + Detail: fmt.Sprintf("/etc/ld.so.preload is non-empty: [%s]", + strings.ReplaceAll(state.LDPreload, "\n", " ")), + PIDs: []int{}, + }) + } + for _, process := range state.Processes { + preload := process.Environ["LD_PRELOAD"] + if preload == "" || !hasAnyPrefix(preload, suspiciousPreloadPrefixes) { + continue + } + alerts = append(alerts, model.Alert{ + SID: 100011, + Severity: "CRITICAL", + Signature: "ld_preload", + Classtype: "rootkit-preload", + Key: fmt.Sprintf("ldp:%d", process.PID), + Detail: fmt.Sprintf( + "pid=%d comm=%s LD_PRELOAD=[%s]", process.PID, process.Comm, preload), + PIDs: []int{process.PID}, + }) + } + return alerts +} + +func hasAnyPrefix(value string, prefixes []string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(value, prefix) { + return true + } + } + return false +} diff --git a/go-agent/internal/detectors/ld_preload_test.go b/go-agent/internal/detectors/ld_preload_test.go new file mode 100644 index 0000000..187e853 --- /dev/null +++ b/go-agent/internal/detectors/ld_preload_test.go @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "testing" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +func TestLDPreloadMatchesGlobalAndWritableProcess(t *testing.T) { + state := model.State{ + LDPreload: "/tmp/global.so\n/opt/second.so", + Processes: []model.Process{ + {PID: 200, Comm: "sleep", Environ: map[string]string{"LD_PRELOAD": "/tmp/evil.so"}}, + {PID: 201, Comm: "safe", Environ: map[string]string{"LD_PRELOAD": "/usr/lib/libfoo.so"}}, + }, + } + alerts := LDPreload(state) + if len(alerts) != 2 { + t.Fatalf("expected two alerts, got %#v", alerts) + } + if alerts[0].Key != "ldp:global" || alerts[0].Detail != "/etc/ld.so.preload is non-empty: [/tmp/global.so /opt/second.so]" { + t.Fatalf("unexpected global alert: %#v", alerts[0]) + } + if alerts[1].Key != "ldp:200" || alerts[1].PIDs[0] != 200 { + t.Fatalf("unexpected process alert: %#v", alerts[1]) + } +} diff --git a/go-agent/internal/model/model.go b/go-agent/internal/model/model.go index 57827d9..22dac2c 100644 --- a/go-agent/internal/model/model.go +++ b/go-agent/internal/model/model.go @@ -7,16 +7,19 @@ package model // Process is the subset of /proc process state needed by the first ported // poll detectors. Fields are added as more Python detectors move across. type Process struct { - PID int `json:"pid"` - Comm string `json:"comm"` - Cmdline string `json:"cmdline"` - Exe string `json:"exe"` + PID int `json:"pid"` + Comm string `json:"comm"` + Cmdline string `json:"cmdline"` + Exe string `json:"exe"` + Environ map[string]string `json:"environ"` + FDTargets map[string]string `json:"fd_targets"` } // State is one injectable detector sweep, equivalent to the Python // SystemState boundary. type State struct { Processes []Process `json:"processes"` + LDPreload string `json:"ld_preload"` } // Alert matches enodia.alert.v1. diff --git a/go-agent/internal/system/state.go b/go-agent/internal/system/state.go index e42b080..95813d1 100644 --- a/go-agent/internal/system/state.go +++ b/go-agent/internal/system/state.go @@ -13,9 +13,24 @@ import ( "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) +// Paths makes host filesystem inputs injectable for parity and unit tests. +type Paths struct { + ProcRoot string + LDPreloadPath string +} + // Capture reads process state once from procRoot. Transient per-process read // failures degrade to empty fields, matching Python's fail-open Process view. func Capture(procRoot string) (model.State, error) { + return CaptureWithPaths(Paths{ + ProcRoot: procRoot, + LDPreloadPath: "/etc/ld.so.preload", + }) +} + +// CaptureWithPaths reads one process view and the global preload file. +func CaptureWithPaths(paths Paths) (model.State, error) { + procRoot := paths.ProcRoot if procRoot == "" { procRoot = "/proc" } @@ -31,14 +46,52 @@ func Capture(procRoot string) (model.State, error) { } base := filepath.Join(procRoot, entry.Name()) processes = append(processes, model.Process{ - PID: pid, - Comm: strings.TrimSpace(readText(filepath.Join(base, "comm"))), - Cmdline: strings.TrimSpace(strings.ReplaceAll(readText(filepath.Join(base, "cmdline")), "\x00", " ")), - Exe: readLink(filepath.Join(base, "exe")), + PID: pid, + Comm: strings.TrimSpace(readText(filepath.Join(base, "comm"))), + Cmdline: strings.TrimSpace(strings.ReplaceAll(readText(filepath.Join(base, "cmdline")), "\x00", " ")), + Exe: readLink(filepath.Join(base, "exe")), + Environ: parseEnviron(readText(filepath.Join(base, "environ"))), + FDTargets: readFDTargets(filepath.Join(base, "fd")), }) } sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID }) - return model.State{Processes: processes}, nil + return model.State{ + Processes: processes, + LDPreload: strings.TrimSpace(readText(paths.LDPreloadPath)), + }, nil +} + +func parseEnviron(raw string) map[string]string { + result := make(map[string]string) + for _, item := range strings.Split(raw, "\x00") { + key, value, ok := strings.Cut(item, "=") + if ok { + result[key] = value + } + } + return result +} + +func readFDTargets(path string) map[string]string { + result := make(map[string]string) + entries, err := os.ReadDir(path) + if err != nil { + return result + } + sort.Slice(entries, func(i, j int) bool { + left, leftErr := strconv.Atoi(entries[i].Name()) + right, rightErr := strconv.Atoi(entries[j].Name()) + if leftErr == nil && rightErr == nil { + return left < right + } + return entries[i].Name() < entries[j].Name() + }) + for _, entry := range entries { + if target := readLink(filepath.Join(path, entry.Name())); target != "" { + result[entry.Name()] = target + } + } + return result } func readText(path string) string { diff --git a/go-agent/internal/system/state_test.go b/go-agent/internal/system/state_test.go index 036009a..6c4b832 100644 --- a/go-agent/internal/system/state_test.go +++ b/go-agent/internal/system/state_test.go @@ -20,10 +20,24 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) { if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte("bash\x00-i\x00"), 0o600); err != nil { t.Fatal(err) } + if err := os.WriteFile(filepath.Join(proc, "environ"), []byte("LD_PRELOAD=/tmp/x.so\x00A=B\x00"), 0o600); err != nil { + t.Fatal(err) + } if err := os.Symlink("/tmp/x (deleted)", filepath.Join(proc, "exe")); err != nil { t.Fatal(err) } - state, err := Capture(root) + fdDir := filepath.Join(proc, "fd") + if err := os.Mkdir(fdDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/dev/input/event3", filepath.Join(fdDir, "7")); err != nil { + t.Fatal(err) + } + preload := filepath.Join(root, "ld.so.preload") + if err := os.WriteFile(preload, []byte("/tmp/global.so\n"), 0o600); err != nil { + t.Fatal(err) + } + state, err := CaptureWithPaths(Paths{ProcRoot: root, LDPreloadPath: preload}) if err != nil { t.Fatal(err) } @@ -34,4 +48,10 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) { if got.PID != 42 || got.Comm != "bash" || got.Cmdline != "bash -i" || got.Exe != "/tmp/x (deleted)" { t.Fatalf("unexpected process: %#v", got) } + if got.Environ["LD_PRELOAD"] != "/tmp/x.so" || got.FDTargets["7"] != "/dev/input/event3" { + t.Fatalf("missing environment or fd state: %#v", got) + } + if state.LDPreload != "/tmp/global.so" { + t.Fatalf("unexpected global preload: %q", state.LDPreload) + } } diff --git a/scripts/check-go-parity.py b/scripts/check-go-parity.py index c0bfdda..a5f453e 100755 --- a/scripts/check-go-parity.py +++ b/scripts/check-go-parity.py @@ -9,13 +9,19 @@ import subprocess import sys from pathlib import Path from types import SimpleNamespace +from unittest.mock import patch ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from enodia_sentinel import __version__, event # noqa: E402 from enodia_sentinel.config import Config # noqa: E402 -from enodia_sentinel.detectors import deleted_exe # noqa: E402 +from enodia_sentinel.detectors import ( # noqa: E402 + credential_access, + deleted_exe, + input_snooper, + ld_preload, +) from enodia_sentinel.system import SystemState # noqa: E402 HOST = "parity-host" @@ -23,13 +29,14 @@ TIMESTAMP = "2026-07-10T00:00:00-07:00" def main() -> int: - fixture = ROOT / "tests/fixtures/go/deleted-exe.json" + fixture = ROOT / "tests/fixtures/go/process-detectors.json" fixture_data = json.loads(fixture.read_text()) env = os.environ.copy() env.pop("ENODIA_CONFIG", None) env["GOCACHE"] = "/tmp/enodia-go-cache" command = [ "go", "run", "./cmd/enodia-sentinel-go", + "--config", str(ROOT / "tests/fixtures/go/no-such-config.toml"), "--once", "--fixture", str(fixture), "--host", HOST, "--timestamp", TIMESTAMP, ] @@ -43,13 +50,25 @@ def main() -> int: go_events = [json.loads(line) for line in result.stdout.splitlines() if line] go_alerts = [record for record in go_events if record["event_type"] == "alert"] - processes = [SimpleNamespace(**item) for item in fixture_data["processes"]] + processes = [] + for raw_process in fixture_data["processes"]: + process = dict(raw_process) + process.setdefault("environ", {}) + process.setdefault("fd_targets", {}) + processes.append(SimpleNamespace(**process)) cfg = Config() - cfg.detectors = frozenset({"deleted_exe"}) state = SystemState(processes=processes, sockets=[]) + with patch.object(ld_preload, "Path") as path_class: + preload = path_class.return_value + preload.is_file.return_value = bool(fixture_data.get("ld_preload")) + preload.read_text.return_value = fixture_data.get("ld_preload", "") + alerts = list(ld_preload.detect(state, cfg)) + alerts.extend(deleted_exe.detect(state, cfg)) + alerts.extend(input_snooper.detect(state, cfg)) + alerts.extend(credential_access.detect(state, cfg)) python_alerts = [ event.from_alert(alert, host=HOST, timestamp=TIMESTAMP) - for alert in deleted_exe.detect(state, cfg) + for alert in alerts ] if go_alerts != python_alerts: print("Go/Python alert parity mismatch", file=sys.stderr) @@ -64,7 +83,8 @@ def main() -> int: if status.get("schema") != "enodia.status.v1" or status.get("version") != __version__: print("Go status schema/version drifted from Python", file=sys.stderr) return 1 - print(f"parity ok: {len(go_alerts)} deleted_exe alert events") + signatures = sorted({record["alert"]["signature"] for record in go_alerts}) + print(f"parity ok: {len(go_alerts)} alerts across {', '.join(signatures)}") return 0 diff --git a/tests/fixtures/go/deleted-exe.json b/tests/fixtures/go/deleted-exe.json deleted file mode 100644 index 35c6dee..0000000 --- a/tests/fixtures/go/deleted-exe.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "processes": [ - {"pid": 300, "comm": "tmpdrop", "cmdline": "tmpdrop", "exe": "/tmp/x (deleted)"}, - {"pid": 301, "comm": "memdrop", "cmdline": "memdrop", "exe": "/memfd:foo (deleted)"}, - {"pid": 302, "comm": "upgrade", "cmdline": "upgrade", "exe": "/usr/bin/x (deleted)"}, - {"pid": 303, "comm": "normal", "cmdline": "normal", "exe": "/usr/bin/x"} - ] -} diff --git a/tests/fixtures/go/process-detectors.json b/tests/fixtures/go/process-detectors.json new file mode 100644 index 0000000..09e76e9 --- /dev/null +++ b/tests/fixtures/go/process-detectors.json @@ -0,0 +1,16 @@ +{ + "ld_preload": "/tmp/global.so\n/opt/second.so", + "processes": [ + {"pid": 200, "comm": "sleep", "cmdline": "sleep", "exe": "/usr/bin/sleep", "environ": {"LD_PRELOAD": "/tmp/evil.so"}}, + {"pid": 201, "comm": "safe", "cmdline": "safe", "exe": "/usr/bin/safe", "environ": {"LD_PRELOAD": "/usr/lib/libfoo.so"}}, + {"pid": 300, "comm": "tmpdrop", "cmdline": "tmpdrop", "exe": "/tmp/x (deleted)"}, + {"pid": 301, "comm": "memdrop", "cmdline": "memdrop", "exe": "/memfd:foo (deleted)"}, + {"pid": 302, "comm": "upgrade", "cmdline": "upgrade", "exe": "/usr/bin/x (deleted)"}, + {"pid": 303, "comm": "normal", "cmdline": "normal", "exe": "/usr/bin/x"}, + {"pid": 320, "comm": "hoxha", "cmdline": "hoxha", "exe": "/tmp/hoxha", "fd_targets": {"7": "/dev/input/event3"}}, + {"pid": 321, "comm": "Xorg", "cmdline": "Xorg", "exe": "/usr/bin/Xorg", "fd_targets": {"8": "/dev/hidraw0"}}, + {"pid": 330, "comm": "hoxha", "cmdline": "hoxha", "exe": "/tmp/hoxha", "fd_targets": {"4": "/etc/shadow"}}, + {"pid": 331, "comm": "collector", "cmdline": "collector", "exe": "/tmp/collector", "fd_targets": {"5": "/home/luna/.ssh/id_ed25519"}}, + {"pid": 332, "comm": "firefox", "cmdline": "firefox", "exe": "/usr/lib/firefox/firefox", "fd_targets": {"8": "/home/luna/.mozilla/firefox/a/key4.db"}} + ] +}