feat(go): port process access detectors
This commit is contained in:
parent
f02509aab5
commit
fc9b5f0448
22 changed files with 554 additions and 40 deletions
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])
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue