diff --git a/CLAUDE.md b/CLAUDE.md index 64bf5c4..b51b434 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,9 +32,10 @@ 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 -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. +file-descriptor, and socket view, and currently ports `reverse_shell`, +`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`, +`stealth_network`, and `egress`. 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 04281d7..bd09560 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,11 @@ 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 -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. +and `ss` socket view. 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, +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 6ed0603..2a5e358 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -260,10 +260,11 @@ 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/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. + injectable process/file-descriptor/socket state, and seven poll detectors + (`reverse_shell`, `ld_preload`, `deleted_exe`, `input_snooper`, + `credential_access`, `stealth_network`, `egress`) 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 9c2f7aa..a8ba19c 100644 --- a/docs/SURICATA_ASSIMILATION.md +++ b/docs/SURICATA_ASSIMILATION.md @@ -228,8 +228,9 @@ 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 view, the JSONL sweep loop, and fixture parity for - `ld_preload`, `deleted_exe`, `input_snooper`, and `credential_access`; + 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`; 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 a6d7a72..8cedda5 100644 --- a/go-agent/README.md +++ b/go-agent/README.md @@ -7,11 +7,13 @@ 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 snapshot boundary; +- an injectable `/proc` process/file-descriptor 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`); + `credential_access` (`100033`), plus `reverse_shell` (`100010`), `egress` + (`100016`), and `stealth_network` (`100036`); - `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 0a1b437..93806a0 100644 --- a/go-agent/internal/agent/agent.go +++ b/go-agent/internal/agent/agent.go @@ -57,6 +57,12 @@ func (a *Agent) Sweep() ([]map[string]any, error) { timestamp := a.Now().Local().Format(time.RFC3339Nano) alerts := make([]model.Alert, 0) + // Preserve the relative order of Python detectors.REGISTRY. Snapshot and + // parity consumers rely on stable alert ordering even while unported slots + // are temporarily absent from the Go implementation. + if a.Config.Enabled("reverse_shell") { + alerts = append(alerts, detectors.ReverseShell(state, a.Config)...) + } if a.Config.Enabled("ld_preload") { alerts = append(alerts, detectors.LDPreload(state)...) } @@ -69,6 +75,12 @@ func (a *Agent) Sweep() ([]map[string]any, error) { if a.Config.Enabled("credential_access") { alerts = append(alerts, detectors.CredentialAccess(state, a.Config)...) } + if a.Config.Enabled("stealth_network") { + alerts = append(alerts, detectors.StealthNetwork(state, a.Config)...) + } + if a.Config.Enabled("egress") { + alerts = append(alerts, detectors.Egress(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 1388c01..8161e70 100644 --- a/go-agent/internal/agent/agent_test.go +++ b/go-agent/internal/agent/agent_test.go @@ -11,6 +11,8 @@ import ( "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) +func intPointer(value int) *int { return &value } + func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) { agent := New(config.Default(), func() (model.State, error) { return model.State{Processes: []model.Process{ @@ -57,19 +59,34 @@ 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"}, - }}, + Processes: []model.Process{ + { + PID: 42, + Comm: "dropper", + Exe: "/tmp/dropper (deleted)", + FDTargets: map[string]string{"4": "/etc/shadow", "7": "/dev/input/event3"}, + }, + { + PID: 100, + Comm: "bash", + Cmdline: "bash -i", + FDTargets: map[string]string{"0": "socket:[999]"}, + }, + }, + Sockets: []model.Socket{ + {State: "ESTAB", Local: "127.0.0.1:55", Peer: "9.9.9.9:443", Inode: intPointer(999), Comm: "bash", PID: intPointer(100), Kind: "tcp"}, + {State: "ESTAB", Local: "10.0.0.2:5000", Peer: "8.8.8.8:4443", Comm: "hoxha", PID: intPointer(340), Kind: "sctp"}, + }, }, nil }) events, err := agent.Sweep() if err != nil { t.Fatal(err) } - want := []string{"ld_preload", "deleted_exe", "input_snooper", "credential_access"} + want := []string{ + "reverse_shell", "ld_preload", "deleted_exe", "input_snooper", + "credential_access", "stealth_network", "egress", + } if len(events) != len(want)+1 { t.Fatalf("unexpected events: %#v", events) } diff --git a/go-agent/internal/config/config.go b/go-agent/internal/config/config.go index 0bae112..c70654f 100644 --- a/go-agent/internal/config/config.go +++ b/go-agent/internal/config/config.go @@ -30,6 +30,20 @@ var defaultCredentialAccessAllowComms = []string{ "chrome", "google-chrome", "brave", "brave-browser", } +var defaultInterpreters = []string{ + "bash", "sh", "dash", "zsh", "ksh", "ash", "nc", "ncat", "netcat", + "socat", "telnet", "python", "python2", "python3", "perl", "ruby", + "php", "lua", "awk", +} + +// These defaults intentionally duplicate the Python Config values. The parity +// harness catches detector output drift, while config tests catch tuning drift +// before a detector ever runs. +var defaultStealthNetworkAllowComms = []string{ + "NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd", + "tcpdump", "dumpcap", "wireshark", "suricata", "zeek", +} + // Config mirrors the Phase 1 settings consumed by the Go sweep loop. More // fields are added as their detectors are ported. type Config struct { @@ -39,6 +53,10 @@ type Config struct { InputSnooperAllowComms map[string]bool CredentialAccessAllowComms map[string]bool CredentialAccessExtraPaths []string + Interpreters map[string]bool + EgressAllowCIDRs []string + StealthNetworkAllowComms map[string]bool + StealthNetworkAllowKinds map[string]bool } // Default returns Python-compatible defaults for the fields currently used. @@ -54,6 +72,10 @@ func Default() Config { InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms), CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms), CredentialAccessExtraPaths: []string{}, + Interpreters: stringSet(defaultInterpreters), + EgressAllowCIDRs: []string{}, + StealthNetworkAllowComms: stringSet(defaultStealthNetworkAllowComms), + StealthNetworkAllowKinds: map[string]bool{}, } } @@ -125,6 +147,32 @@ func Load(path string) (Config, error) { return Config{}, fmt.Errorf("credential_access_extra_paths: %w", err) } cfg.CredentialAccessExtraPaths = paths + case "interpreters": + // Keep arrays as sets where membership is the runtime operation. TOML + // order has no behavioral meaning for allowlists. + names, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("interpreters: %w", err) + } + cfg.Interpreters = stringSet(names) + case "egress_allow_cidrs": + cidrs, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("egress_allow_cidrs: %w", err) + } + cfg.EgressAllowCIDRs = cidrs + case "stealth_network_allow_comms": + names, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("stealth_network_allow_comms: %w", err) + } + cfg.StealthNetworkAllowComms = stringSet(names) + case "stealth_network_allow_kinds": + kinds, err := stringArray(value) + if err != nil { + return Config{}, fmt.Errorf("stealth_network_allow_kinds: %w", err) + } + cfg.StealthNetworkAllowKinds = stringSet(kinds) } } return cfg, nil diff --git a/go-agent/internal/config/config_test.go b/go-agent/internal/config/config_test.go index a9e38e5..982287e 100644 --- a/go-agent/internal/config/config_test.go +++ b/go-agent/internal/config/config_test.go @@ -31,6 +31,10 @@ detectors = [ input_snooper_allow_comms = ["Xorg"] credential_access_allow_comms = ["firefox"] credential_access_extra_paths = ["/srv/secrets/"] +interpreters = ["bash", "python3"] +egress_allow_cidrs = ["203.0.113.0/24"] +stealth_network_allow_comms = ["tcpdump"] +stealth_network_allow_kinds = ["mptcp"] unknown_future_key = true `) if err := os.WriteFile(path, raw, 0o600); err != nil { @@ -53,6 +57,13 @@ unknown_future_key = true t.Fatalf("unexpected credential tuning: %#v %#v", cfg.CredentialAccessAllowComms, cfg.CredentialAccessExtraPaths) } + if !cfg.Interpreters["bash"] || len(cfg.EgressAllowCIDRs) != 1 { + t.Fatalf("unexpected network tuning: %#v %#v", cfg.Interpreters, cfg.EgressAllowCIDRs) + } + if !cfg.StealthNetworkAllowComms["tcpdump"] || !cfg.StealthNetworkAllowKinds["mptcp"] { + t.Fatalf("unexpected stealth tuning: %#v %#v", + cfg.StealthNetworkAllowComms, cfg.StealthNetworkAllowKinds) + } } func TestMissingFileKeepsDefaults(t *testing.T) { diff --git a/go-agent/internal/detectors/egress.go b/go-agent/internal/detectors/egress.go new file mode 100644 index 0000000..9116aa8 --- /dev/null +++ b/go-agent/internal/detectors/egress.go @@ -0,0 +1,41 @@ +// 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" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil" +) + +// Egress ports interpreter-to-public-IP detection (SID 100016). +func Egress(state model.State, cfg config.Config) []model.Alert { + alerts := make([]model.Alert, 0) + for _, socket := range state.Sockets { + // Only established interpreter-owned sockets qualify. Listeners and + // connection setup states are handled by other detector families. + if socket.State != "ESTAB" || !cfg.Interpreters[socket.Comm] { + continue + } + host, port := netutil.SplitHostPort(socket.Peer) + // Private/special addresses and explicit trusted CIDRs are intentionally + // silent so routine local services and operator infrastructure stay quiet. + if !netutil.IsPublicIP(host) || netutil.IPInCIDRs(host, cfg.EgressAllowCIDRs) { + continue + } + alerts = append(alerts, model.Alert{ + SID: 100016, + Severity: "HIGH", + Signature: "egress", + Classtype: "c2-exfil", + Key: fmt.Sprintf("egr:%s:%s", optionalPythonInt(socket.PID), host), + Detail: fmt.Sprintf( + "pid=%s comm=%s -> %s:%s (interpreter to public IP)", + optionalPythonInt(socket.PID), socket.Comm, host, port), + PIDs: alertPIDs(socket.PID), + }) + } + return alerts +} diff --git a/go-agent/internal/detectors/egress_test.go b/go-agent/internal/detectors/egress_test.go new file mode 100644 index 0000000..4ca4dd5 --- /dev/null +++ b/go-agent/internal/detectors/egress_test.go @@ -0,0 +1,27 @@ +// 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 TestEgressPublicPrivateAndAllowlist(t *testing.T) { + state := model.State{Sockets: []model.Socket{ + {State: "ESTAB", Peer: "8.8.8.8:443", Comm: "python3", PID: intPointer(500)}, + {State: "ESTAB", Peer: "10.0.0.5:443", Comm: "python3", PID: intPointer(501)}, + {State: "ESTAB", Peer: "9.9.9.9:443", Comm: "nginx", PID: intPointer(502)}, + }} + alerts := Egress(state, config.Default()) + if len(alerts) != 1 || alerts[0].SID != 100016 || alerts[0].Key != "egr:500:8.8.8.8" { + t.Fatalf("unexpected alerts: %#v", alerts) + } + cfg := config.Default() + cfg.EgressAllowCIDRs = []string{"8.8.8.0/24"} + if alerts := Egress(state, cfg); len(alerts) != 0 { + t.Fatalf("allowlisted egress alerted: %#v", alerts) + } +} diff --git a/go-agent/internal/detectors/reverse_shell.go b/go-agent/internal/detectors/reverse_shell.go new file mode 100644 index 0000000..2c2c849 --- /dev/null +++ b/go-agent/internal/detectors/reverse_shell.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" +) + +// ReverseShell ports the network-socket-on-stdio detector (SID 100010). +func ReverseShell(state model.State, cfg config.Config) []model.Alert { + // Build the inode correlation table once. A stdio socket alone could be a + // benign Unix socket; requiring the inode in the network socket view is the + // high-signal condition that distinguishes a likely reverse shell. + peerByInode := make(map[int]string) + for _, socket := range state.Sockets { + if socket.Inode != nil { + peerByInode[*socket.Inode] = fmt.Sprintf("%s -> %s", socket.Local, socket.Peer) + } + } + alerts := make([]model.Alert, 0) + for _, process := range state.Processes { + // Restrict to shells/interpreters exactly as Python does. Networked + // daemons commonly inherit sockets and must not become false positives. + if !cfg.Interpreters[process.Comm] { + continue + } + inode := stdioSocketInode(process) + if inode == nil || peerByInode[*inode] == "" { + continue + } + alerts = append(alerts, model.Alert{ + SID: 100010, + Severity: "CRITICAL", + Signature: "reverse_shell", + Classtype: "c2-reverse-shell", + Key: fmt.Sprintf("rsh:%d", process.PID), + Detail: fmt.Sprintf( + "pid=%d comm=%s stdio=net-socket peer=[%s] cmd=[%s]", + process.PID, process.Comm, peerByInode[*inode], truncateRunes(process.Cmdline, 90)), + PIDs: []int{process.PID}, + }) + } + return alerts +} diff --git a/go-agent/internal/detectors/reverse_shell_test.go b/go-agent/internal/detectors/reverse_shell_test.go new file mode 100644 index 0000000..83bdd06 --- /dev/null +++ b/go-agent/internal/detectors/reverse_shell_test.go @@ -0,0 +1,34 @@ +// 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 intPointer(value int) *int { return &value } + +func TestReverseShellRequiresInterpreterAndNetworkInode(t *testing.T) { + state := model.State{ + Processes: []model.Process{ + {PID: 100, Comm: "bash", Cmdline: "bash -i", FDTargets: map[string]string{"0": "socket:[999]"}}, + {PID: 101, Comm: "nginx", FDTargets: map[string]string{"0": "socket:[999]"}}, + {PID: 102, Comm: "bash", FDTargets: map[string]string{"0": "socket:[777]"}}, + }, + Sockets: []model.Socket{{ + State: "ESTAB", Local: "127.0.0.1:55", Peer: "9.9.9.9:443", + Inode: intPointer(999), Comm: "bash", PID: intPointer(100), Kind: "tcp", + }}, + } + alerts := ReverseShell(state, config.Default()) + if len(alerts) != 1 || alerts[0].SID != 100010 || alerts[0].Key != "rsh:100" { + t.Fatalf("unexpected alerts: %#v", alerts) + } + want := "pid=100 comm=bash stdio=net-socket peer=[127.0.0.1:55 -> 9.9.9.9:443] cmd=[bash -i]" + if alerts[0].Detail != want { + t.Fatalf("unexpected detail: %q", alerts[0].Detail) + } +} diff --git a/go-agent/internal/detectors/socket.go b/go-agent/internal/detectors/socket.go new file mode 100644 index 0000000..910065c --- /dev/null +++ b/go-agent/internal/detectors/socket.go @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package detectors + +import ( + "fmt" + "strconv" + "strings" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" +) + +func stdioSocketInode(process model.Process) *int { + // The Python oracle checks descriptors in 0,1,2 order and stops at the + // first socket. That ordering avoids a map-iteration-dependent alert. + for _, fd := range []string{"0", "1", "2"} { + target := process.FDTargets[fd] + if strings.HasPrefix(target, "socket:[") && strings.HasSuffix(target, "]") { + value, err := strconv.Atoi(target[8 : len(target)-1]) + if err == nil { + return &value + } + } + } + return nil +} + +func optionalPythonInt(value *int) string { + // Python f-strings render a missing optional integer as "None". Keys are + // stable machine contracts, so preserve that spelling rather than Go's + // default representation. + if value == nil { + return "None" + } + return strconv.Itoa(*value) +} + +func optionalDisplayInt(value *int) string { + // Human-facing stealth-network details use "?" for unknown ownership even + // though the corresponding dedup key uses Python's "None" spelling. + if value == nil || *value == 0 { + return "?" + } + return strconv.Itoa(*value) +} + +func alertPIDs(value *int) []int { + if value == nil || *value == 0 { + return []int{} + } + return []int{*value} +} + +func truncateRunes(value string, limit int) string { + // Python slices Unicode code points, not UTF-8 bytes. Rune truncation keeps + // command details identical for non-ASCII argv values. + runes := []rune(value) + if len(runes) <= limit { + return value + } + return string(runes[:limit]) +} + +func socketKey(kind string, socket model.Socket) string { + return fmt.Sprintf("stealthnet:%s:%s:%s:%s", + kind, optionalPythonInt(socket.PID), socket.Local, socket.Peer) +} diff --git a/go-agent/internal/detectors/stealth_network.go b/go-agent/internal/detectors/stealth_network.go new file mode 100644 index 0000000..b93a782 --- /dev/null +++ b/go-agent/internal/detectors/stealth_network.go @@ -0,0 +1,67 @@ +// 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" + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/netutil" +) + +var watchedSocketKinds = map[string]bool{ + "raw": true, "sctp": true, "dccp": true, "packet": true, + "mptcp": true, "tipc": true, "xdp": true, "vsock": true, +} + +var alwaysHighSocketKinds = map[string]bool{"raw": true, "packet": true, "xdp": true} + +var activeSocketStates = map[string]bool{ + "ESTAB": true, "LISTEN": true, "UNCONN": true, "CONNECTED": true, + "SYN-SENT": true, "SYN-RECV": true, +} + +// StealthNetwork ports unusual socket-family detection (SID 100036). +func StealthNetwork(state model.State, cfg config.Config) []model.Alert { + alerts := make([]model.Alert, 0) + for _, socket := range state.Sockets { + kind := strings.ToLower(socket.Kind) + // TCP/UDP belong to reverse-shell, egress, and listener detectors. This + // detector is deliberately limited to less-common covert-channel paths. + if !watchedSocketKinds[kind] || cfg.StealthNetworkAllowKinds[kind] { + continue + } + if socket.Comm != "" && cfg.StealthNetworkAllowComms[socket.Comm] { + continue + } + if socket.State != "" && !activeSocketStates[socket.State] { + continue + } + severity := "MEDIUM" + host, _ := netutil.SplitHostPort(socket.Peer) + publicPeer := netutil.IsPublicIP(host) && !netutil.IPInCIDRs(host, cfg.EgressAllowCIDRs) + // Raw/packet/XDP access, listeners, and public peers are immediately + // high severity; inactive or locally scoped unusual families stay medium. + if alwaysHighSocketKinds[kind] || socket.State == "LISTEN" || publicPeer { + severity = "HIGH" + } + comm := socket.Comm + if comm == "" { + comm = "?" + } + alerts = append(alerts, model.Alert{ + SID: 100036, + Severity: severity, + Signature: "stealth_network", + Classtype: "covert-channel", + Key: socketKey(kind, socket), + Detail: fmt.Sprintf( + "%s socket state=%s local=%s peer=%s comm=%s pid=%s", + kind, socket.State, socket.Local, socket.Peer, comm, optionalDisplayInt(socket.PID)), + PIDs: alertPIDs(socket.PID), + }) + } + return alerts +} diff --git a/go-agent/internal/detectors/stealth_network_test.go b/go-agent/internal/detectors/stealth_network_test.go new file mode 100644 index 0000000..c4c686f --- /dev/null +++ b/go-agent/internal/detectors/stealth_network_test.go @@ -0,0 +1,25 @@ +// 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 TestStealthNetworkSeverityAndAllowlist(t *testing.T) { + state := model.State{Sockets: []model.Socket{ + {State: "ESTAB", Local: "10.0.0.2:5000", Peer: "8.8.8.8:4443", Comm: "hoxha", PID: intPointer(340), Kind: "sctp"}, + {State: "UNCONN", Local: "*:eth0", Peer: "*", Comm: "tcpdump", PID: intPointer(341), Kind: "packet"}, + {State: "ESTAB", Local: "10.0.0.2:5", Peer: "8.8.8.8:443", Comm: "curl", PID: intPointer(342), Kind: "tcp"}, + }} + alerts := StealthNetwork(state, config.Default()) + if len(alerts) != 1 || alerts[0].SID != 100036 || alerts[0].Severity != "HIGH" { + t.Fatalf("unexpected alerts: %#v", alerts) + } + if alerts[0].Key != "stealthnet:sctp:340:10.0.0.2:5000:8.8.8.8:4443" { + t.Fatalf("unexpected key: %s", alerts[0].Key) + } +} diff --git a/go-agent/internal/model/model.go b/go-agent/internal/model/model.go index 22dac2c..758cf75 100644 --- a/go-agent/internal/model/model.go +++ b/go-agent/internal/model/model.go @@ -19,9 +19,22 @@ type Process struct { // SystemState boundary. type State struct { Processes []Process `json:"processes"` + Sockets []Socket `json:"sockets"` LDPreload string `json:"ld_preload"` } +// Socket matches the Python SystemState socket view. Nil inode/PID values +// preserve ss records where ownership metadata is unavailable. +type Socket struct { + State string `json:"state"` + Local string `json:"local"` + Peer string `json:"peer"` + Inode *int `json:"inode"` + Comm string `json:"comm"` + PID *int `json:"pid"` + Kind string `json:"kind"` +} + // Alert matches enodia.alert.v1. type Alert struct { SID int `json:"sid"` diff --git a/go-agent/internal/netutil/netutil.go b/go-agent/internal/netutil/netutil.go new file mode 100644 index 0000000..45bf823 --- /dev/null +++ b/go-agent/internal/netutil/netutil.go @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package netutil contains stdlib address helpers matching the Python oracle. +package netutil + +import ( + "net/netip" + "strings" +) + +var carrierGradeNAT = netip.MustParsePrefix("100.64.0.0/10") + +// ParseAddr accepts brackets and IPv6 zone suffixes used by ss. +func ParseAddr(value string) (netip.Addr, bool) { + value = strings.Trim(strings.TrimSpace(value), "[]") + if index := strings.IndexByte(value, '%'); index >= 0 { + value = value[:index] + } + address, err := netip.ParseAddr(value) + return address, err == nil +} + +// IsPublicIP reports globally routable addresses and excludes CGNAT. +func IsPublicIP(value string) bool { + address, ok := ParseAddr(value) + // netip's GlobalUnicast includes CGNAT, so explicitly exclude 100.64/10 + // to match Python ipaddress.is_global and avoid treating carrier-local + // traffic as public C2 egress. + if !ok || !address.IsGlobalUnicast() || address.IsPrivate() || + address.IsLoopback() || address.IsLinkLocalUnicast() { + return false + } + return !carrierGradeNAT.Contains(address) +} + +// IPInCIDRs checks an address against valid same-family trust prefixes. +func IPInCIDRs(value string, cidrs []string) bool { + address, ok := ParseAddr(value) + if !ok { + return false + } + for _, raw := range cidrs { + // Invalid operator entries are ignored just like Python Config. A typo + // must not crash or disable the detector sweep. + prefix, err := netip.ParsePrefix(strings.TrimSpace(raw)) + if err == nil && prefix.Addr().BitLen() == address.BitLen() && prefix.Contains(address) { + return true + } + } + return false +} + +// SplitHostPort splits ss-style IPv4, IPv6, and zone-qualified endpoints. +func SplitHostPort(endpoint string) (string, string) { + endpoint = strings.TrimSpace(endpoint) + if strings.HasPrefix(endpoint, "[") { + if end := strings.Index(endpoint, "]"); end >= 0 { + return endpoint[1:end], strings.TrimPrefix(endpoint[end+1:], ":") + } + } + index := strings.LastIndex(endpoint, ":") + if index < 0 { + return "", endpoint + } + return endpoint[:index], endpoint[index+1:] +} diff --git a/go-agent/internal/netutil/netutil_test.go b/go-agent/internal/netutil/netutil_test.go new file mode 100644 index 0000000..f72afde --- /dev/null +++ b/go-agent/internal/netutil/netutil_test.go @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package netutil + +import "testing" + +func TestPublicIPMatchesPythonCases(t *testing.T) { + for _, address := range []string{"8.8.8.8", "1.1.1.1"} { + if !IsPublicIP(address) { + t.Fatalf("expected public: %s", address) + } + } + for _, address := range []string{ + "10.0.0.5", "192.168.1.1", "172.16.0.1", "127.0.0.1", + "169.254.1.1", "100.64.0.1", "::1", "not-an-ip", "", + } { + if IsPublicIP(address) { + t.Fatalf("expected non-public: %s", address) + } + } +} + +func TestCIDRAndEndpointHelpers(t *testing.T) { + if !IPInCIDRs("203.0.113.55", []string{"203.0.113.0/24"}) || + IPInCIDRs("8.8.8.8", []string{"203.0.113.0/24"}) { + t.Fatal("CIDR matching failed") + } + host, port := SplitHostPort("[2001:db8::1]:22") + if host != "2001:db8::1" || port != "22" { + t.Fatalf("unexpected IPv6 split: %q %q", host, port) + } + host, port = SplitHostPort("10.2.0.2%proton0:61877") + if host != "10.2.0.2%proton0" || port != "61877" { + t.Fatalf("unexpected zone split: %q %q", host, port) + } +} diff --git a/go-agent/internal/system/state.go b/go-agent/internal/system/state.go index 95813d1..88c64f1 100644 --- a/go-agent/internal/system/state.go +++ b/go-agent/internal/system/state.go @@ -4,11 +4,15 @@ package system import ( + "context" "os" + "os/exec" "path/filepath" + "regexp" "sort" "strconv" "strings" + "time" "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) @@ -17,6 +21,34 @@ import ( type Paths struct { ProcRoot string LDPreloadPath string + SocketSource func() []model.Socket +} + +var ( + inodePattern = regexp.MustCompile(`\bino:(\d+)`) + userPattern = regexp.MustCompile(`users:\(\("([^"]+)",pid=(\d+),fd=\d+`) +) + +var socketSpecs = []struct { + args []string + kind string +}{ + {[]string{"-tanep"}, "tcp"}, + {[]string{"-uanep"}, "udp"}, + {[]string{"-wanep"}, "raw"}, + {[]string{"-Sanep"}, "sctp"}, + {[]string{"-danep"}, "dccp"}, + {[]string{"-0anep"}, "packet"}, + {[]string{"-Manep"}, "mptcp"}, + {[]string{"--tipc", "-anep"}, "tipc"}, + {[]string{"--xdp", "-anep"}, "xdp"}, + {[]string{"--vsock", "-anep"}, "vsock"}, +} + +var socketNetIDs = map[string]bool{ + "tcp": true, "udp": true, "raw": true, "sctp": true, "dccp": true, + "mptcp": true, "p_raw": true, "p_dgr": true, "packet": true, + "tipc": true, "xdp": true, "vsock": true, } // Capture reads process state once from procRoot. Transient per-process read @@ -55,12 +87,79 @@ func CaptureWithPaths(paths Paths) (model.State, error) { }) } sort.Slice(processes, func(i, j int) bool { return processes[i].PID < processes[j].PID }) + var sockets []model.Socket + if paths.SocketSource != nil { + sockets = paths.SocketSource() + } else { + sockets = captureSockets() + } return model.State{ Processes: processes, + Sockets: sockets, LDPreload: strings.TrimSpace(readText(paths.LDPreloadPath)), }, nil } +func captureSockets() []model.Socket { + result := make([]model.Socket, 0) + for _, spec := range socketSpecs { + // Python asks ss once per protocol family because several families use + // incompatible flags. Preserve that behavior instead of hiding gaps + // behind a single TCP/UDP-only invocation. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + command := exec.CommandContext(ctx, "ss", append([]string{"-H"}, spec.args...)...) + output, err := command.Output() + cancel() + if err != nil { + // Collection is fail-open by design: a missing/unsupported ss flag + // drops only that socket family and never stops process detection. + continue + } + result = append(result, parseSS(string(output), spec.kind)...) + } + return result +} + +func parseSS(text, kind string) []model.Socket { + result := make([]model.Socket, 0) + for _, line := range strings.Split(text, "\n") { + fields := strings.Fields(line) + if len(fields) < 5 { + continue + } + var state, local, peer string + // Some ss versions prefix each row with the protocol netid and some + // begin directly with state. Python accepts both layouts; Go must too. + if socketNetIDs[strings.ToLower(fields[0])] && len(fields) >= 6 { + state, local, peer = fields[1], fields[4], fields[5] + } else { + state, local, peer = fields[0], fields[3], fields[4] + } + var inode, pid *int + // Ownership is best-effort. Nil values are meaningful because Python + // serializes an unknown PID differently in keys (None) and prose (?). + if match := inodePattern.FindStringSubmatch(line); len(match) == 2 { + value, err := strconv.Atoi(match[1]) + if err == nil { + inode = &value + } + } + comm := "" + if match := userPattern.FindStringSubmatch(line); len(match) == 3 { + comm = match[1] + value, err := strconv.Atoi(match[2]) + if err == nil { + pid = &value + } + } + result = append(result, model.Socket{ + State: state, Local: local, Peer: peer, Inode: inode, + Comm: comm, PID: pid, Kind: kind, + }) + } + return result +} + func parseEnviron(raw string) map[string]string { result := make(map[string]string) for _, item := range strings.Split(raw, "\x00") { diff --git a/go-agent/internal/system/state_test.go b/go-agent/internal/system/state_test.go index 6c4b832..5d24d9d 100644 --- a/go-agent/internal/system/state_test.go +++ b/go-agent/internal/system/state_test.go @@ -6,6 +6,8 @@ import ( "os" "path/filepath" "testing" + + "codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model" ) func TestCaptureUsesInjectableProcRoot(t *testing.T) { @@ -37,7 +39,10 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) { if err := os.WriteFile(preload, []byte("/tmp/global.so\n"), 0o600); err != nil { t.Fatal(err) } - state, err := CaptureWithPaths(Paths{ProcRoot: root, LDPreloadPath: preload}) + state, err := CaptureWithPaths(Paths{ + ProcRoot: root, LDPreloadPath: preload, + SocketSource: func() []model.Socket { return []model.Socket{} }, + }) if err != nil { t.Fatal(err) } @@ -55,3 +60,18 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) { t.Fatalf("unexpected global preload: %q", state.LDPreload) } } + +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") + if len(sockets) != 1 { + t.Fatalf("unexpected sockets: %#v", sockets) + } + got := sockets[0] + if got.State != "ESTAB" || got.Local != "10.0.0.2:5555" || got.Peer != "8.8.8.8:443" { + t.Fatalf("unexpected endpoints: %#v", got) + } + if got.Inode == nil || *got.Inode != 999 || got.PID == nil || *got.PID != 42 || got.Comm != "bash" { + t.Fatalf("unexpected ownership: %#v", got) + } +} diff --git a/scripts/check-go-parity.py b/scripts/check-go-parity.py index a5f453e..6755dbc 100755 --- a/scripts/check-go-parity.py +++ b/scripts/check-go-parity.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later -"""Compare the first Go detector port with the Python reference oracle.""" +"""Compare ported Go poll detectors with the Python reference oracle.""" from __future__ import annotations import json @@ -19,15 +19,33 @@ from enodia_sentinel.config import Config # noqa: E402 from enodia_sentinel.detectors import ( # noqa: E402 credential_access, deleted_exe, + egress, input_snooper, ld_preload, + reverse_shell, + stealth_network, ) -from enodia_sentinel.system import SystemState # noqa: E402 +from enodia_sentinel.system import Socket, SystemState # noqa: E402 HOST = "parity-host" TIMESTAMP = "2026-07-10T00:00:00-07:00" +class FixtureProcess(SimpleNamespace): + """Duck-typed Python Process with Go-fixture stdio socket semantics.""" + + def stdio_socket_inode(self): + # Match Process.stdio_socket_inode: descriptors 0/1/2 only, in order. + for fd in ("0", "1", "2"): + target = self.fd_targets.get(fd, "") + if target.startswith("socket:[") and target.endswith("]"): + try: + return int(target[8:-1]) + except ValueError: + continue + return None + + def main() -> int: fixture = ROOT / "tests/fixtures/go/process-detectors.json" fixture_data = json.loads(fixture.read_text()) @@ -55,17 +73,21 @@ def main() -> int: process = dict(raw_process) process.setdefault("environ", {}) process.setdefault("fd_targets", {}) - processes.append(SimpleNamespace(**process)) + processes.append(FixtureProcess(**process)) + sockets = [Socket(**item) for item in fixture_data.get("sockets", [])] cfg = Config() - state = SystemState(processes=processes, sockets=[]) + state = SystemState(processes=processes, sockets=sockets) + alerts = list(reverse_shell.detect(state, cfg)) 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(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)) + alerts.extend(stealth_network.detect(state, cfg)) + alerts.extend(egress.detect(state, cfg)) python_alerts = [ event.from_alert(alert, host=HOST, timestamp=TIMESTAMP) for alert in alerts diff --git a/tests/fixtures/go/process-detectors.json b/tests/fixtures/go/process-detectors.json index 09e76e9..a942240 100644 --- a/tests/fixtures/go/process-detectors.json +++ b/tests/fixtures/go/process-detectors.json @@ -1,6 +1,7 @@ { "ld_preload": "/tmp/global.so\n/opt/second.so", "processes": [ + {"pid": 100, "comm": "bash", "cmdline": "bash -i", "exe": "/usr/bin/bash", "fd_targets": {"0": "socket:[999]"}}, {"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)"}, @@ -12,5 +13,11 @@ {"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"}} + ], + "sockets": [ + {"state": "ESTAB", "local": "127.0.0.1:55", "peer": "9.9.9.9:443", "inode": 999, "comm": "bash", "pid": 100, "kind": "tcp"}, + {"state": "ESTAB", "local": "10.0.0.2:5000", "peer": "8.8.8.8:4443", "inode": 1, "comm": "hoxha", "pid": 340, "kind": "sctp"}, + {"state": "UNCONN", "local": "*:eth0", "peer": "*", "inode": 2, "comm": "tcpdump", "pid": 341, "kind": "packet"}, + {"state": "ESTAB", "local": "10.0.0.2:6000", "peer": "10.0.0.5:443", "inode": 3, "comm": "python3", "pid": 342, "kind": "tcp"} ] }