feat(go): port process access detectors
This commit is contained in:
parent
f02509aab5
commit
fc9b5f0448
22 changed files with 554 additions and 40 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
104
go-agent/internal/detectors/credential_access.go
Normal file
104
go-agent/internal/detectors/credential_access.go
Normal file
|
|
@ -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
|
||||
}
|
||||
32
go-agent/internal/detectors/credential_access_test.go
Normal file
32
go-agent/internal/detectors/credential_access_test.go
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
24
go-agent/internal/detectors/fd.go
Normal file
24
go-agent/internal/detectors/fd.go
Normal file
|
|
@ -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
|
||||
}
|
||||
47
go-agent/internal/detectors/input_snooper.go
Normal file
47
go-agent/internal/detectors/input_snooper.go
Normal file
|
|
@ -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
|
||||
}
|
||||
21
go-agent/internal/detectors/input_snooper_test.go
Normal file
21
go-agent/internal/detectors/input_snooper_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
57
go-agent/internal/detectors/ld_preload.go
Normal file
57
go-agent/internal/detectors/ld_preload.go
Normal file
|
|
@ -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
|
||||
}
|
||||
29
go-agent/internal/detectors/ld_preload_test.go
Normal file
29
go-agent/internal/detectors/ld_preload_test.go
Normal file
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
8
tests/fixtures/go/deleted-exe.json
vendored
8
tests/fixtures/go/deleted-exe.json
vendored
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
16
tests/fixtures/go/process-detectors.json
vendored
Normal file
16
tests/fixtures/go/process-detectors.json
vendored
Normal file
|
|
@ -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"}}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue