From 6a27e6e770a5513a82192627f5d8986b8994464c Mon Sep 17 00:00:00 2001 From: Luna Date: Sat, 11 Jul 2026 01:19:56 -0700 Subject: [PATCH] feat(go): port memory-map detector tranche --- README.md | 5 +- docs/SURICATA_ASSIMILATION.md | 7 +- go-agent/README.md | 7 +- go-agent/internal/agent/agent.go | 3 + go-agent/internal/config/config.go | 62 ++++--- go-agent/internal/config/config_test.go | 7 + .../internal/detectors/memory_obfuscation.go | 153 ++++++++++++++++++ .../detectors/memory_obfuscation_test.go | 69 ++++++++ go-agent/internal/model/model.go | 23 ++- go-agent/internal/system/state.go | 56 ++++++- go-agent/internal/system/state_test.go | 15 ++ scripts/check-go-parity.py | 5 +- tests/fixtures/go/process-detectors.json | 6 + 13 files changed, 377 insertions(+), 41 deletions(-) create mode 100644 go-agent/internal/detectors/memory_obfuscation.go create mode 100644 go-agent/internal/detectors/memory_obfuscation_test.go diff --git a/README.md b/README.md index bd09560..237b832 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,10 @@ Project docs: Phase 1 now also has an **experimental Go sidecar** under `go-agent/`. It emits the same `enodia.event.v1` envelope and captures an injectable `/proc` process -and `ss` socket view. The parity-checked cohort is `reverse_shell`, +and memory-map view plus `ss` sockets. The parity-checked cohort is `reverse_shell`, `ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`, -`stealth_network`, and `egress`. It is stdout-only and does not replace, +`stealth_network`, `memory_obfuscation` (including mapped hide/injection +libraries), and `egress`. 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. diff --git a/docs/SURICATA_ASSIMILATION.md b/docs/SURICATA_ASSIMILATION.md index a8ba19c..d9fc090 100644 --- a/docs/SURICATA_ASSIMILATION.md +++ b/docs/SURICATA_ASSIMILATION.md @@ -228,9 +228,10 @@ 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/file-descriptor and `ss` socket view, the JSONL sweep loop, and - fixture parity for `reverse_shell`, `ld_preload`, `deleted_exe`, - `input_snooper`, `credential_access`, `stealth_network`, and `egress`; + process/file-descriptor/memory-map and `ss` socket view, the JSONL sweep + loop, and fixture parity for `reverse_shell`, `ld_preload`, `deleted_exe`, + `input_snooper`, `credential_access`, `stealth_network`, the + `memory_obfuscation` cohort, and `egress`; 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 8cedda5..4ddf27b 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -7,13 +7,14 @@ behavioral oracle until every subsystem reaches fixture and red-team parity. Current scope: - standard-library-only flat TOML loading for the settings used so far; -- an injectable `/proc` process/file-descriptor snapshot plus fail-open `ss` - socket collection; +- an injectable `/proc` process/file-descriptor/memory-map snapshot plus + fail-open `ss` socket collection; - a cancellable sweep loop that emits JSONL `enodia.event.v1` records; - exact `enodia.alert.v1` parity for `ld_preload` (SID `100011`), `deleted_exe` (`100012`), `input_snooper` (`100032`), and `credential_access` (`100033`), plus `reverse_shell` (`100010`), `egress` - (`100016`), and `stealth_network` (`100036`); + (`100016`), `stealth_network` (`100036`), and the `/proc//maps` + `memory_obfuscation` cohort (`100039`, `100048`, `100049`); - `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 93806a0..feaa540 100644 --- a/go-agent/internal/agent/agent.go +++ b/go-agent/internal/agent/agent.go @@ -78,6 +78,9 @@ func (a *Agent) Sweep() ([]map[string]any, error) { if a.Config.Enabled("stealth_network") { alerts = append(alerts, detectors.StealthNetwork(state, a.Config)...) } + if a.Config.Enabled("memory_obfuscation") { + alerts = append(alerts, detectors.MemoryObfuscation(state, a.Config)...) + } if a.Config.Enabled("egress") { alerts = append(alerts, detectors.Egress(state, a.Config)...) } diff --git a/go-agent/internal/config/config.go b/go-agent/internal/config/config.go index c70654f..76531e7 100644 --- a/go-agent/internal/config/config.go +++ b/go-agent/internal/config/config.go @@ -44,19 +44,27 @@ var defaultStealthNetworkAllowComms = []string{ "tcpdump", "dumpcap", "wireshark", "suricata", "zeek", } +var defaultMemoryObfuscationAllowComms = []string{ + "java", "node", "firefox", "chromium", "chrome", "google-chrome", + "brave", "brave-browser", "WebKitWebProcess", "dotnet", "qemu-system-x86", + "qemu-system-x86_64", "wine", "wasmtime", +} + // 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 - InputSnooperAllowComms map[string]bool - CredentialAccessAllowComms map[string]bool - CredentialAccessExtraPaths []string - Interpreters map[string]bool - EgressAllowCIDRs []string - StealthNetworkAllowComms map[string]bool - StealthNetworkAllowKinds map[string]bool + SampleInterval time.Duration + HeartbeatMaxAge int + Detectors map[string]bool + InputSnooperAllowComms map[string]bool + CredentialAccessAllowComms map[string]bool + CredentialAccessExtraPaths []string + Interpreters map[string]bool + EgressAllowCIDRs []string + StealthNetworkAllowComms map[string]bool + StealthNetworkAllowKinds map[string]bool + MemoryObfuscationAllowComms map[string]bool + MemoryObfuscationAllowPaths []string } // Default returns Python-compatible defaults for the fields currently used. @@ -66,16 +74,18 @@ func Default() Config { detectors[name] = true } return Config{ - SampleInterval: 4 * time.Second, - HeartbeatMaxAge: 120, - Detectors: detectors, - InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms), - CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms), - CredentialAccessExtraPaths: []string{}, - Interpreters: stringSet(defaultInterpreters), - EgressAllowCIDRs: []string{}, - StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms), - StealthNetworkAllowKinds: map[string]bool{}, + SampleInterval: 4 * time.Second, + HeartbeatMaxAge: 120, + Detectors: detectors, + InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms), + CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms), + CredentialAccessExtraPaths: []string{}, + Interpreters: stringSet(defaultInterpreters), + EgressAllowCIDRs: []string{}, + StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms), + StealthNetworkAllowKinds: map[string]bool{}, + MemoryObfuscationAllowComms: stringSet(defaultMemoryObfuscationAllowComms), + MemoryObfuscationAllowPaths: []string{}, } } @@ -173,6 +183,18 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("stealth_network_allow_kinds: %w", err) } cfg.StealthNetworkAllowKinds = stringSet(kinds) + case "memory_obfuscation_allow_comms": + comms, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("memory_obfuscation_allow_comms: %w", err) + } + cfg.MemoryObfuscationAllowComms = stringSet(comms) + case "memory_obfuscation_allow_paths": + paths, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("memory_obfuscation_allow_paths: %w", err) + } + cfg.MemoryObfuscationAllowPaths = paths } } return cfg, nil diff --git a/go-agent/internal/config/config_test.go b/go-agent/internal/config/config_test.go index 982287e..aee8248 100644 --- a/go-agent/internal/config/config_test.go +++ b/go-agent/internal/config/config_test.go @@ -35,6 +35,8 @@ interpreters = ["bash", "python3"] egress_allow_cidrs = ["203.0.113.0/24"] stealth_network_allow_comms = ["tcpdump"] stealth_network_allow_kinds = ["mptcp"] +memory_obfuscation_allow_comms = ["jit-runtime"] +memory_obfuscation_allow_paths = ["/tmp/known-profiler/"] unknown_future_key = true `) if err := os.WriteFile(path, raw, 0o600); err != nil { @@ -64,6 +66,11 @@ unknown_future_key = true t.Fatalf("unexpected stealth tuning: %#v %#v", cfg.StealthNetworkAllowComms, cfg.StealthNetworkAllowKinds) } + if !cfg.MemoryObfuscationAllowComms["jit-runtime"] || len(cfg.MemoryObfuscationAllowPaths) != 1 || + cfg.MemoryObfuscationAllowPaths[0] != "/tmp/known-profiler/" { + t.Fatalf("unexpected memory tuning: %#v %#v", + cfg.MemoryObfuscationAllowComms, cfg.MemoryObfuscationAllowPaths) + } } func TestMissingFileKeepsDefaults(t *testing.T) { diff --git a/go-agent/internal/detectors/memory_obfuscation.go b/go-agent/internal/detectors/memory_obfuscation.go new file mode 100644 index 0000000..3b13829 --- /dev/null +++ b/go-agent/internal/detectors/memory_obfuscation.go @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "fmt" + "strings" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +const ( + SIDMemoryObfuscation = 100039 + SIDProcessHidingLibrary = 100048 + SIDProcessInjectionLibrary = 100049 +) + +var memoryWritablePrefixes = []string{"/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/"} +var memoryLibraryExtensions = []string{".so", ".so.", ".dylib"} +var memoryHideHints = []string{ + "libhide", "libprocesshide", "process_hide", "proc_hide", "rootkit_hide", +} + +// MemoryObfuscation ports the /proc//maps detector. It reports one alert +// per signature and process, preserving Python's useful signal compression +// when a process has many suspicious mappings of the same kind. +func MemoryObfuscation(state model.State, cfg config.Config) []model.Alert { + alerts := make([]model.Alert, 0) + for _, process := range state.Processes { + comm := process.Comm + if comm == "" { + comm = "?" + } + seen := map[string]bool{} + for _, memory := range process.MemoryMaps { + signature, severity, detail := memoryIndicator(memory, cfg) + if signature == "" || seen[signature] { + continue + } + // JIT allowlisting applies only to generic executable memory. A + // library whose name suggests hiding or injection stays reviewable. + if signature == "memory_obfuscation" && cfg.MemoryObfuscationAllowComms[comm] { + continue + } + seen[signature] = true + sid, classtype := SIDMemoryObfuscation, "memory-obfuscation" + if signature == "process_hiding_library" { + sid, classtype = SIDProcessHidingLibrary, "process-hiding" + } else if signature == "process_injection_library" { + sid, classtype = SIDProcessInjectionLibrary, "process-injection" + } + alerts = append(alerts, model.Alert{ + SID: sid, Severity: severity, Signature: signature, + Classtype: classtype, + Key: fmt.Sprintf("mem:%s:%d:%x-%x", signature, process.PID, memory.Start, memory.End), + Detail: fmt.Sprintf("pid=%d comm=%s %s range=%x-%x", + process.PID, comm, detail, memory.Start, memory.End), + PIDs: []int{process.PID}, + }) + } + } + return alerts +} + +func memoryIndicator(memory model.MemoryMap, cfg config.Config) (string, string, string) { + path := memory.Path + if pathAllowed(path, cfg.MemoryObfuscationAllowPaths) { + return "", "MEDIUM", "" + } + if path != "" && hasMemoryHint(path) { + return "process_hiding_library", "CRITICAL", + fmt.Sprintf("mapped library name suggests process hiding: %s", path) + } + executable := strings.Contains(memory.Perms, "x") + writable := strings.Contains(memory.Perms, "w") + if executable && path != "" && writableLibrary(path) { + return "process_injection_library", "HIGH", + fmt.Sprintf("executable library mapped from writable/runtime path: %s", path) + } + if executable && (strings.Contains(path, "memfd:") || strings.Contains(path, "(deleted)")) { + return "memory_obfuscation", "CRITICAL", + fmt.Sprintf("executable transient mapping perms=%s path=%s", memory.Perms, displayPath(path)) + } + if executable && writable { + return "memory_obfuscation", "HIGH", + fmt.Sprintf("writable+executable memory mapping perms=%s path=%s", memory.Perms, displayPath(path)) + } + if executable && anonymousPath(path) { + return "memory_obfuscation", "HIGH", + fmt.Sprintf("executable anonymous memory mapping perms=%s path=%s", memory.Perms, displayPath(path)) + } + return "", "MEDIUM", "" +} + +func pathAllowed(path string, prefixes []string) bool { + for _, prefix := range prefixes { + if prefix != "" && strings.HasPrefix(path, prefix) { + return true + } + } + return false +} + +func hasMemoryHint(path string) bool { + name := path + if index := strings.LastIndex(name, "/"); index >= 0 { + name = name[index+1:] + } + name = strings.ToLower(name) + for _, hint := range memoryHideHints { + if strings.Contains(name, hint) { + return true + } + } + return false +} + +func writableLibrary(path string) bool { + lower := strings.ToLower(path) + allowedPrefix := false + for _, prefix := range memoryWritablePrefixes { + if strings.HasPrefix(lower, prefix) { + allowedPrefix = true + break + } + } + if !allowedPrefix { + return false + } + name := lower + if index := strings.LastIndex(name, "/"); index >= 0 { + name = name[index+1:] + } + for _, extension := range memoryLibraryExtensions { + if strings.Contains(name, extension) { + return true + } + } + return false +} + +func anonymousPath(path string) bool { + return path == "" || path == "[heap]" || path == "[stack]" || + strings.HasPrefix(path, "[anon") || strings.HasPrefix(path, "[stack:") +} + +func displayPath(path string) string { + if path != "" { + return path + } + return "" +} diff --git a/go-agent/internal/detectors/memory_obfuscation_test.go b/go-agent/internal/detectors/memory_obfuscation_test.go new file mode 100644 index 0000000..d59fd2b --- /dev/null +++ b/go-agent/internal/detectors/memory_obfuscation_test.go @@ -0,0 +1,69 @@ +// 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 TestMemoryObfuscationMatchesPythonShapes(t *testing.T) { + cases := []struct { + name string + process model.Process + signature string + severity string + sid int + }{ + {"memfd", model.Process{PID: 350, Comm: "hoxha", MemoryMaps: []model.MemoryMap{{ + Start: 0x1000, End: 0x2000, Perms: "r-xp", Path: "/memfd:stage (deleted)", + }}}, "memory_obfuscation", "CRITICAL", SIDMemoryObfuscation}, + {"anonymous-rwx", model.Process{PID: 351, Comm: "packer", MemoryMaps: []model.MemoryMap{{ + Start: 0x3000, End: 0x4000, Perms: "rwxp", + }}}, "memory_obfuscation", "HIGH", SIDMemoryObfuscation}, + {"hide-library", model.Process{PID: 352, Comm: "bash", MemoryMaps: []model.MemoryMap{{ + Start: 0x5000, End: 0x6000, Perms: "r-xp", Path: "/usr/lib/libhide.so", + }}}, "process_hiding_library", "CRITICAL", SIDProcessHidingLibrary}, + {"injection-library", model.Process{PID: 355, Comm: "sshd", MemoryMaps: []model.MemoryMap{{ + Start: 0xb000, End: 0xc000, Perms: "r-xp", Path: "/tmp/libinject.so", + }}}, "process_injection_library", "HIGH", SIDProcessInjectionLibrary}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + alerts := MemoryObfuscation(model.State{Processes: []model.Process{tc.process}}, config.Default()) + if len(alerts) != 1 { + t.Fatalf("expected one alert, got %#v", alerts) + } + if alerts[0].Signature != tc.signature || alerts[0].Severity != tc.severity || alerts[0].SID != tc.sid { + t.Fatalf("unexpected alert: %#v", alerts[0]) + } + }) + } +} + +func TestMemoryObfuscationAllowlistsMatchPython(t *testing.T) { + cfg := config.Default() + if len(MemoryObfuscation(model.State{Processes: []model.Process{{ + PID: 353, Comm: "node", MemoryMaps: []model.MemoryMap{{Perms: "rwxp"}}, + }}}, cfg)) != 0 { + t.Fatal("generic executable mapping should honor the runtime allowlist") + } + cfg.MemoryObfuscationAllowPaths = []string{"/tmp/known-profiler/"} + if len(MemoryObfuscation(model.State{Processes: []model.Process{{ + PID: 356, Comm: "python3", MemoryMaps: []model.MemoryMap{{ + Perms: "r-xp", Path: "/tmp/known-profiler/libhook.so", + }}, + }}}, cfg)) != 0 { + t.Fatal("configured mapping path should be silent") + } + alerts := MemoryObfuscation(model.State{Processes: []model.Process{{ + PID: 354, Comm: "node", MemoryMaps: []model.MemoryMap{{ + Perms: "r-xp", Path: "/tmp/libhide.so", + }}, + }}}, config.Default()) + if len(alerts) != 1 || alerts[0].Signature != "process_hiding_library" { + t.Fatalf("hide-library evidence must survive comm allowlist: %#v", alerts) + } +} diff --git a/go-agent/internal/model/model.go b/go-agent/internal/model/model.go index 758cf75..1197707 100644 --- a/go-agent/internal/model/model.go +++ b/go-agent/internal/model/model.go @@ -7,12 +7,23 @@ 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"` - Environ map[string]string `json:"environ"` - FDTargets map[string]string `json:"fd_targets"` + 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"` + MemoryMaps []MemoryMap `json:"memory_maps"` +} + +// MemoryMap is one parsed /proc//maps row. The detector only needs the +// address range, permission bits, and optional pathname; retaining those raw +// fields keeps fixture input and alert explanations close to Python. +type MemoryMap struct { + Start uint64 `json:"start"` + End uint64 `json:"end"` + Perms string `json:"perms"` + Path string `json:"path"` } // State is one injectable detector sweep, equivalent to the Python diff --git a/go-agent/internal/system/state.go b/go-agent/internal/system/state.go index 88c64f1..ba4541b 100644 --- a/go-agent/internal/system/state.go +++ b/go-agent/internal/system/state.go @@ -78,12 +78,13 @@ func CaptureWithPaths(paths Paths) (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")), - Environ: parseEnviron(readText(filepath.Join(base, "environ"))), - FDTargets: readFDTargets(filepath.Join(base, "fd")), + 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")), + MemoryMaps: parseMemoryMaps(readText(filepath.Join(base, "maps"))), }) } sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID }) @@ -193,6 +194,49 @@ func readFDTargets(path string) map[string]string { return result } +// parseMemoryMaps mirrors Python's split(maxsplit=5) parser. A process may +// disappear or deny maps access between directory enumeration and this read; +// returning an empty slice preserves the detector's fail-open behavior. +func parseMemoryMaps(text string) []model.MemoryMap { + result := make([]model.MemoryMap, 0) + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + bounds := strings.SplitN(fields[0], "-", 2) + if len(bounds) != 2 { + continue + } + start, err := strconv.ParseUint(bounds[0], 16, 64) + if err != nil { + continue + } + end, err := strconv.ParseUint(bounds[1], 16, 64) + if err != nil { + continue + } + path := "" + if len(fields) > 5 { + // Keep pathname spaces intact, just as Python's maxsplit=5 does. + remaining := strings.TrimSpace(line) + for index := 0; index < 5; index++ { + space := strings.IndexAny(remaining, " \t") + if space < 0 { + remaining = "" + break + } + remaining = strings.TrimSpace(remaining[space:]) + } + path = remaining + } + result = append(result, model.MemoryMap{ + Start: start, End: end, Perms: fields[1], Path: path, + }) + } + return result +} + func readText(path string) string { raw, err := os.ReadFile(path) if err != nil { diff --git a/go-agent/internal/system/state_test.go b/go-agent/internal/system/state_test.go index 5d24d9d..ac78d7c 100644 --- a/go-agent/internal/system/state_test.go +++ b/go-agent/internal/system/state_test.go @@ -22,6 +22,9 @@ 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, "maps"), []byte("7f00-8000 rwxp 00000000 00:00 0 /memfd:stage (deleted)\n"), 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) } @@ -56,11 +59,23 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) { if got.Environ["LD_PRELOAD"] != "/tmp/x.so" || got.FDTargets["7"] != "/dev/input/event3" { t.Fatalf("missing environment or fd state: %#v", got) } + if len(got.MemoryMaps) != 1 || got.MemoryMaps[0].Start != 0x7f00 || + got.MemoryMaps[0].Path != "/memfd:stage (deleted)" { + t.Fatalf("missing memory-map state: %#v", got.MemoryMaps) + } if state.LDPreload != "/tmp/global.so" { t.Fatalf("unexpected global preload: %q", state.LDPreload) } } +func TestParseMemoryMapsPreservesPathSpaces(t *testing.T) { + maps := parseMemoryMaps("7f00-8000 r-xp 00000000 00:00 0 /tmp/a path.so\n") + if len(maps) != 1 || maps[0].Start != 0x7f00 || maps[0].End != 0x8000 || + maps[0].Perms != "r-xp" || maps[0].Path != "/tmp/a path.so" { + t.Fatalf("unexpected maps: %#v", maps) + } +} + func TestParseSSMatchesPythonShapes(t *testing.T) { text := "tcp ESTAB 0 0 10.0.0.2:5555 8.8.8.8:443 users:((\"bash\",pid=42,fd=3)) ino:999\n" sockets := parseSS(text, "tcp") diff --git a/scripts/check-go-parity.py b/scripts/check-go-parity.py index 6755dbc..c2d5871 100755 --- a/scripts/check-go-parity.py +++ b/scripts/check-go-parity.py @@ -22,10 +22,11 @@ from enodia_sentinel.detectors import ( # noqa: E402 egress, input_snooper, ld_preload, + memory_obfuscation, reverse_shell, stealth_network, ) -from enodia_sentinel.system import Socket, SystemState # noqa: E402 +from enodia_sentinel.system import MemoryMap, Socket, SystemState # noqa: E402 HOST = "parity-host" TIMESTAMP = "2026-07-10T00:00:00-07:00" @@ -73,6 +74,7 @@ def main() -> int: process = dict(raw_process) process.setdefault("environ", {}) process.setdefault("fd_targets", {}) + process["memory_maps"] = [MemoryMap(**mapping) for mapping in process.get("memory_maps", [])] processes.append(FixtureProcess(**process)) sockets = [Socket(**item) for item in fixture_data.get("sockets", [])] cfg = Config() @@ -87,6 +89,7 @@ def main() -> int: alerts.extend(input_snooper.detect(state, cfg)) alerts.extend(credential_access.detect(state, cfg)) alerts.extend(stealth_network.detect(state, cfg)) + alerts.extend(memory_obfuscation.detect(state, cfg)) alerts.extend(egress.detect(state, cfg)) python_alerts = [ event.from_alert(alert, host=HOST, timestamp=TIMESTAMP) diff --git a/tests/fixtures/go/process-detectors.json b/tests/fixtures/go/process-detectors.json index a942240..e4ee991 100644 --- a/tests/fixtures/go/process-detectors.json +++ b/tests/fixtures/go/process-detectors.json @@ -8,6 +8,12 @@ {"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": 350, "comm": "hoxha", "cmdline": "hoxha", "exe": "/usr/bin/hoxha", "memory_maps": [{"start": 4096, "end": 8192, "perms": "r-xp", "path": "/memfd:stage (deleted)"}]}, + {"pid": 351, "comm": "packer", "cmdline": "packer", "exe": "/usr/bin/packer", "memory_maps": [{"start": 12288, "end": 16384, "perms": "rwxp", "path": ""}]}, + {"pid": 352, "comm": "bash", "cmdline": "bash", "exe": "/usr/bin/bash", "memory_maps": [{"start": 20480, "end": 24576, "perms": "r-xp", "path": "/usr/lib/libhide.so"}]}, + {"pid": 353, "comm": "node", "cmdline": "node", "exe": "/usr/bin/node", "memory_maps": [{"start": 28672, "end": 32768, "perms": "rwxp", "path": ""}]}, + {"pid": 354, "comm": "node", "cmdline": "node", "exe": "/usr/bin/node", "memory_maps": [{"start": 36864, "end": 40960, "perms": "r-xp", "path": "/tmp/libhide.so"}]}, + {"pid": 355, "comm": "sshd", "cmdline": "sshd", "exe": "/usr/sbin/sshd", "memory_maps": [{"start": 45056, "end": 49152, "perms": "r-xp", "path": "/tmp/libinject.so"}]}, {"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"}},