feat(go): port process access detectors

This commit is contained in:
Luna 2026-07-10 05:19:56 -07:00
parent f02509aab5
commit fc9b5f0448
22 changed files with 554 additions and 40 deletions

View 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
}

View 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])
}
}

View 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
}

View 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
}

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

View 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
}

View 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])
}
}