feat(go): port socket detectors
This commit is contained in:
parent
fc9b5f0448
commit
c6f7219de8
23 changed files with 693 additions and 28 deletions
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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue