feat(go): port socket detectors
This commit is contained in:
parent
fc9b5f0448
commit
c6f7219de8
23 changed files with 693 additions and 28 deletions
|
|
@ -32,9 +32,10 @@ bcc/eBPF telemetry.
|
||||||
|
|
||||||
An experimental, non-installing Phase 1 Go sidecar now lives under `go-agent/`.
|
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
|
It is stdlib-only, emits `enodia.event.v1` JSONL, captures an injectable process
|
||||||
and file-descriptor view, and currently ports `ld_preload`, `deleted_exe`,
|
file-descriptor, and socket view, and currently ports `reverse_shell`,
|
||||||
`input_snooper`, and `credential_access`. Python remains the production agent
|
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`,
|
||||||
and parity oracle; do not describe the Go path as a replacement yet.
|
`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:
|
Implemented detection coverage:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,10 +66,11 @@ Project docs:
|
||||||
|
|
||||||
Phase 1 now also has an **experimental Go sidecar** under `go-agent/`. It emits
|
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
|
the same `enodia.event.v1` envelope and captures an injectable `/proc` process
|
||||||
view. The first parity-checked cohort is `ld_preload`, `deleted_exe`,
|
and `ss` socket view. The parity-checked cohort is `reverse_shell`,
|
||||||
`input_snooper`, and `credential_access`. It is stdout-only and does not
|
`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`,
|
||||||
replace, install over, or write state for the Python daemon; Python remains
|
`stealth_network`, and `egress`. It is stdout-only and does not replace,
|
||||||
authoritative until subsystem parity is complete.
|
install over, or write state for the Python daemon; Python remains authoritative
|
||||||
|
until subsystem parity is complete.
|
||||||
|
|
||||||
## Why these detectors
|
## Why these detectors
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,10 +260,11 @@ Exit criteria:
|
||||||
- Suricata model assimilation design is complete, and Phase 0's additive
|
- Suricata model assimilation design is complete, and Phase 0's additive
|
||||||
`enodia.event.v1` Python reference contract is implemented.
|
`enodia.event.v1` Python reference contract is implemented.
|
||||||
- Phase 1 is in progress under `go-agent/`: a stdlib-only parallel sweep loop,
|
- 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
|
injectable process/file-descriptor/socket state, and seven poll detectors
|
||||||
(`ld_preload`, `deleted_exe`, `input_snooper`, `credential_access`) are
|
(`reverse_shell`, `ld_preload`, `deleted_exe`, `input_snooper`,
|
||||||
checked against the Python oracle with a shared fixture. The Go sidecar
|
`credential_access`, `stealth_network`, `egress`) are checked against the
|
||||||
remains stdout-only and non-production until broader parity lands.
|
Python oracle with a shared fixture. The Go sidecar remains stdout-only and
|
||||||
|
non-production until broader parity lands.
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,8 +228,9 @@ signature keeps `sid` / `signature` / `classtype` / tests / docs.
|
||||||
equivalent, poll detectors ported, JSON output matching schemas. Runs
|
equivalent, poll detectors ported, JSON output matching schemas. Runs
|
||||||
alongside Python; harness compares both. *In progress:* the isolated
|
alongside Python; harness compares both. *In progress:* the isolated
|
||||||
`go-agent/` sidecar now has stdlib config loading, an injectable `/proc`
|
`go-agent/` sidecar now has stdlib config loading, an injectable `/proc`
|
||||||
process/file-descriptor view, the JSONL sweep loop, and fixture parity for
|
process/file-descriptor and `ss` socket view, the JSONL sweep loop, and
|
||||||
`ld_preload`, `deleted_exe`, `input_snooper`, and `credential_access`;
|
fixture parity for `reverse_shell`, `ld_preload`, `deleted_exe`,
|
||||||
|
`input_snooper`, `credential_access`, `stealth_network`, and `egress`;
|
||||||
Python remains the production agent.
|
Python remains the production agent.
|
||||||
- **Phase 2 — eBPF via `cilium/ebpf`.** Port exec/syscall rules; drop the
|
- **Phase 2 — eBPF via `cilium/ebpf`.** Port exec/syscall rules; drop the
|
||||||
bcc/Python runtime dependency; single static binary.
|
bcc/Python runtime dependency; single static binary.
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ behavioral oracle until every subsystem reaches fixture and red-team parity.
|
||||||
Current scope:
|
Current scope:
|
||||||
|
|
||||||
- standard-library-only flat TOML loading for the settings used so far;
|
- 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;
|
- a cancellable sweep loop that emits JSONL `enodia.event.v1` records;
|
||||||
- exact `enodia.alert.v1` parity for `ld_preload` (SID `100011`),
|
- exact `enodia.alert.v1` parity for `ld_preload` (SID `100011`),
|
||||||
`deleted_exe` (`100012`), `input_snooper` (`100032`), and
|
`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
|
- `enodia.status.v1` heartbeat records with no persistence or retained-alert
|
||||||
claims;
|
claims;
|
||||||
- a shared fixture checked against the Python detector by
|
- a shared fixture checked against the Python detector by
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,12 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
|
||||||
timestamp := a.Now().Local().Format(time.RFC3339Nano)
|
timestamp := a.Now().Local().Format(time.RFC3339Nano)
|
||||||
|
|
||||||
alerts := make([]model.Alert, 0)
|
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") {
|
if a.Config.Enabled("ld_preload") {
|
||||||
alerts = append(alerts, detectors.LDPreload(state)...)
|
alerts = append(alerts, detectors.LDPreload(state)...)
|
||||||
}
|
}
|
||||||
|
|
@ -69,6 +75,12 @@ func (a *Agent) Sweep() ([]map[string]any, error) {
|
||||||
if a.Config.Enabled("credential_access") {
|
if a.Config.Enabled("credential_access") {
|
||||||
alerts = append(alerts, detectors.CredentialAccess(state, a.Config)...)
|
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)
|
events := make([]map[string]any, 0, len(alerts)+1)
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
record, err := schema.Build("alert", alert, host, timestamp)
|
record, err := schema.Build("alert", alert, host, timestamp)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ import (
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func intPointer(value int) *int { return &value }
|
||||||
|
|
||||||
func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) {
|
func TestRunOnceEmitsAlertAndStatusEnvelopes(t *testing.T) {
|
||||||
agent := New(config.Default(), func() (model.State, error) {
|
agent := New(config.Default(), func() (model.State, error) {
|
||||||
return model.State{Processes: []model.Process{
|
return model.State{Processes: []model.Process{
|
||||||
|
|
@ -57,19 +59,34 @@ func TestSweepPreservesPythonDetectorOrder(t *testing.T) {
|
||||||
agent := New(config.Default(), func() (model.State, error) {
|
agent := New(config.Default(), func() (model.State, error) {
|
||||||
return model.State{
|
return model.State{
|
||||||
LDPreload: "/tmp/global.so",
|
LDPreload: "/tmp/global.so",
|
||||||
Processes: []model.Process{{
|
Processes: []model.Process{
|
||||||
PID: 42,
|
{
|
||||||
Comm: "dropper",
|
PID: 42,
|
||||||
Exe: "/tmp/dropper (deleted)",
|
Comm: "dropper",
|
||||||
FDTargets: map[string]string{"4": "/etc/shadow", "7": "/dev/input/event3"},
|
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
|
}, nil
|
||||||
})
|
})
|
||||||
events, err := agent.Sweep()
|
events, err := agent.Sweep()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if len(events) != len(want)+1 {
|
||||||
t.Fatalf("unexpected events: %#v", events)
|
t.Fatalf("unexpected events: %#v", events)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,20 @@ var defaultCredentialAccessAllowComms = []string{
|
||||||
"chrome", "google-chrome", "brave", "brave-browser",
|
"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
|
// Config mirrors the Phase 1 settings consumed by the Go sweep loop. More
|
||||||
// fields are added as their detectors are ported.
|
// fields are added as their detectors are ported.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
@ -39,6 +53,10 @@ type Config struct {
|
||||||
InputSnooperAllowComms map[string]bool
|
InputSnooperAllowComms map[string]bool
|
||||||
CredentialAccessAllowComms map[string]bool
|
CredentialAccessAllowComms map[string]bool
|
||||||
CredentialAccessExtraPaths []string
|
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.
|
// Default returns Python-compatible defaults for the fields currently used.
|
||||||
|
|
@ -54,6 +72,10 @@ func Default() Config {
|
||||||
InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms),
|
InputSnooperAllowComms: stringSet(defaultInputSnooperAllowComms),
|
||||||
CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms),
|
CredentialAccessAllowComms: stringSet(defaultCredentialAccessAllowComms),
|
||||||
CredentialAccessExtraPaths: []string{},
|
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)
|
return Config{}, fmt.Errorf("credential_access_extra_paths: %w", err)
|
||||||
}
|
}
|
||||||
cfg.CredentialAccessExtraPaths = paths
|
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
|
return cfg, nil
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ detectors = [
|
||||||
input_snooper_allow_comms = ["Xorg"]
|
input_snooper_allow_comms = ["Xorg"]
|
||||||
credential_access_allow_comms = ["firefox"]
|
credential_access_allow_comms = ["firefox"]
|
||||||
credential_access_extra_paths = ["/srv/secrets/"]
|
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
|
unknown_future_key = true
|
||||||
`)
|
`)
|
||||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||||
|
|
@ -53,6 +57,13 @@ unknown_future_key = true
|
||||||
t.Fatalf("unexpected credential tuning: %#v %#v",
|
t.Fatalf("unexpected credential tuning: %#v %#v",
|
||||||
cfg.CredentialAccessAllowComms, cfg.CredentialAccessExtraPaths)
|
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) {
|
func TestMissingFileKeepsDefaults(t *testing.T) {
|
||||||
|
|
|
||||||
41
go-agent/internal/detectors/egress.go
Normal file
41
go-agent/internal/detectors/egress.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
27
go-agent/internal/detectors/egress_test.go
Normal file
27
go-agent/internal/detectors/egress_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
go-agent/internal/detectors/reverse_shell.go
Normal file
47
go-agent/internal/detectors/reverse_shell.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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
34
go-agent/internal/detectors/reverse_shell_test.go
Normal file
34
go-agent/internal/detectors/reverse_shell_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
67
go-agent/internal/detectors/socket.go
Normal file
67
go-agent/internal/detectors/socket.go
Normal file
|
|
@ -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 <nil> 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)
|
||||||
|
}
|
||||||
67
go-agent/internal/detectors/stealth_network.go
Normal file
67
go-agent/internal/detectors/stealth_network.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
25
go-agent/internal/detectors/stealth_network_test.go
Normal file
25
go-agent/internal/detectors/stealth_network_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,9 +19,22 @@ type Process struct {
|
||||||
// SystemState boundary.
|
// SystemState boundary.
|
||||||
type State struct {
|
type State struct {
|
||||||
Processes []Process `json:"processes"`
|
Processes []Process `json:"processes"`
|
||||||
|
Sockets []Socket `json:"sockets"`
|
||||||
LDPreload string `json:"ld_preload"`
|
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.
|
// Alert matches enodia.alert.v1.
|
||||||
type Alert struct {
|
type Alert struct {
|
||||||
SID int `json:"sid"`
|
SID int `json:"sid"`
|
||||||
|
|
|
||||||
66
go-agent/internal/netutil/netutil.go
Normal file
66
go-agent/internal/netutil/netutil.go
Normal file
|
|
@ -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:]
|
||||||
|
}
|
||||||
36
go-agent/internal/netutil/netutil_test.go
Normal file
36
go-agent/internal/netutil/netutil_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,15 @@
|
||||||
package system
|
package system
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
)
|
)
|
||||||
|
|
@ -17,6 +21,34 @@ import (
|
||||||
type Paths struct {
|
type Paths struct {
|
||||||
ProcRoot string
|
ProcRoot string
|
||||||
LDPreloadPath 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
|
// 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 })
|
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{
|
return model.State{
|
||||||
Processes: processes,
|
Processes: processes,
|
||||||
|
Sockets: sockets,
|
||||||
LDPreload: strings.TrimSpace(readText(paths.LDPreloadPath)),
|
LDPreload: strings.TrimSpace(readText(paths.LDPreloadPath)),
|
||||||
}, nil
|
}, 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 {
|
func parseEnviron(raw string) map[string]string {
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
for _, item := range strings.Split(raw, "\x00") {
|
for _, item := range strings.Split(raw, "\x00") {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCaptureUsesInjectableProcRoot(t *testing.T) {
|
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 {
|
if err := os.WriteFile(preload, []byte("/tmp/global.so\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
@ -55,3 +60,18 @@ func TestCaptureUsesInjectableProcRoot(t *testing.T) {
|
||||||
t.Fatalf("unexpected global preload: %q", state.LDPreload)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -19,15 +19,33 @@ from enodia_sentinel.config import Config # noqa: E402
|
||||||
from enodia_sentinel.detectors import ( # noqa: E402
|
from enodia_sentinel.detectors import ( # noqa: E402
|
||||||
credential_access,
|
credential_access,
|
||||||
deleted_exe,
|
deleted_exe,
|
||||||
|
egress,
|
||||||
input_snooper,
|
input_snooper,
|
||||||
ld_preload,
|
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"
|
HOST = "parity-host"
|
||||||
TIMESTAMP = "2026-07-10T00:00:00-07:00"
|
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:
|
def main() -> int:
|
||||||
fixture = ROOT / "tests/fixtures/go/process-detectors.json"
|
fixture = ROOT / "tests/fixtures/go/process-detectors.json"
|
||||||
fixture_data = json.loads(fixture.read_text())
|
fixture_data = json.loads(fixture.read_text())
|
||||||
|
|
@ -55,17 +73,21 @@ def main() -> int:
|
||||||
process = dict(raw_process)
|
process = dict(raw_process)
|
||||||
process.setdefault("environ", {})
|
process.setdefault("environ", {})
|
||||||
process.setdefault("fd_targets", {})
|
process.setdefault("fd_targets", {})
|
||||||
processes.append(SimpleNamespace(**process))
|
processes.append(FixtureProcess(**process))
|
||||||
|
sockets = [Socket(**item) for item in fixture_data.get("sockets", [])]
|
||||||
cfg = Config()
|
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:
|
with patch.object(ld_preload, "Path") as path_class:
|
||||||
preload = path_class.return_value
|
preload = path_class.return_value
|
||||||
preload.is_file.return_value = bool(fixture_data.get("ld_preload"))
|
preload.is_file.return_value = bool(fixture_data.get("ld_preload"))
|
||||||
preload.read_text.return_value = 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(deleted_exe.detect(state, cfg))
|
||||||
alerts.extend(input_snooper.detect(state, cfg))
|
alerts.extend(input_snooper.detect(state, cfg))
|
||||||
alerts.extend(credential_access.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 = [
|
python_alerts = [
|
||||||
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
|
event.from_alert(alert, host=HOST, timestamp=TIMESTAMP)
|
||||||
for alert in alerts
|
for alert in alerts
|
||||||
|
|
|
||||||
7
tests/fixtures/go/process-detectors.json
vendored
7
tests/fixtures/go/process-detectors.json
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"ld_preload": "/tmp/global.so\n/opt/second.so",
|
"ld_preload": "/tmp/global.so\n/opt/second.so",
|
||||||
"processes": [
|
"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": 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": 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": 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": 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": 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"}}
|
{"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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue