feat(go): advance validation sidecar toward production

This commit is contained in:
Luna 2026-07-22 01:52:19 -07:00
parent 6a06eba255
commit f85c2e831a
No known key found for this signature in database
92 changed files with 8881 additions and 91 deletions

View file

@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"sort"
"strconv"
"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"
)
// FirstSeen ports the Python first_seen detector (SIDs 100074 and 100075).
// Baseline state is injected for now; durable local storage belongs to a later
// sidecar persistence tranche.
func FirstSeen(state model.State, cfg config.Config) []model.Alert {
if !cfg.Enabled("first_seen") ||
state.FirstSeenPublicDestinations == nil ||
state.FirstSeenListenerPorts == nil {
return []model.Alert{}
}
alerts := make([]model.Alert, 0)
for _, socket := range state.Sockets {
comm := socket.Comm
if comm == "" {
comm = "?"
}
if socket.State == "ESTAB" {
host, port := netutil.SplitHostPort(socket.Peer)
target := host + ":" + port
if netutil.IsPublicIP(host) && port != "" &&
!contains(state.FirstSeenPublicDestinations[comm], target) {
// Record before considering whether to alert. The first armed pass
// learns silently, and duplicate sockets in one sweep must not emit
// duplicate rarity alerts.
state.FirstSeenPublicDestinations[comm] = append(
state.FirstSeenPublicDestinations[comm], target)
sort.Strings(state.FirstSeenPublicDestinations[comm])
if state.FirstSeenInitialized {
alerts = append(alerts, model.Alert{
SID: 100074, Severity: "MEDIUM",
Signature: "first_public_destination", Classtype: "network-rarity",
Key: "firstdest:" + comm + ":" + target,
Detail: fmt.Sprintf("comm=%s first public destination %s", comm, target),
PIDs: alertPIDs(socket.PID),
})
}
}
}
if (socket.State == "LISTEN" || socket.State == "UNCONN") &&
(socket.Kind == "tcp" || socket.Kind == "udp" || socket.Kind == "") {
_, port := netutil.SplitHostPort(socket.Local)
if port != "" && port != "0" && isDigits(port) &&
!contains(state.FirstSeenListenerPorts[comm], port) {
state.FirstSeenListenerPorts[comm] = append(
state.FirstSeenListenerPorts[comm], port)
sort.Slice(state.FirstSeenListenerPorts[comm], func(i, j int) bool {
left, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][i])
right, _ := strconv.Atoi(state.FirstSeenListenerPorts[comm][j])
return left < right
})
if state.FirstSeenInitialized {
alerts = append(alerts, model.Alert{
SID: 100075, Severity: "MEDIUM",
Signature: "first_listener_port", Classtype: "network-rarity",
Key: "firstlisten:" + comm + ":" + port,
Detail: fmt.Sprintf("comm=%s first listening port %s", comm, port),
PIDs: alertPIDs(socket.PID),
})
}
}
}
}
return alerts
}
func contains(values []string, wanted string) bool {
for _, value := range values {
if value == wanted {
return true
}
}
return false
}
func isDigits(value string) bool {
for _, char := range value {
if char < '0' || char > '9' {
return false
}
}
return true
}

View file

@ -0,0 +1,45 @@
// 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 TestFirstSeenAlertsNewDestinationAndListener(t *testing.T) {
pid := 42
cfg := config.Default()
alerts := FirstSeen(model.State{
FirstSeenInitialized: true,
FirstSeenPublicDestinations: map[string][]string{"bash": {"1.1.1.1:443"}},
FirstSeenListenerPorts: map[string][]string{"nc": {"22"}},
Sockets: []model.Socket{
{State: "ESTAB", Peer: "8.8.8.8:4443", Comm: "bash", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid, Kind: "tcp"},
},
}, cfg)
if len(alerts) != 2 || alerts[0].SID != 100074 || alerts[1].SID != 100075 {
t.Fatalf("got %#v", alerts)
}
}
func TestFirstSeenUninitializedIsSilent(t *testing.T) {
cfg := config.Default()
state := model.State{
FirstSeenPublicDestinations: map[string][]string{},
FirstSeenListenerPorts: map[string][]string{},
Sockets: []model.Socket{
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
{State: "ESTAB", Peer: "8.8.8.8:443", Comm: "bash"},
},
}
if alerts := FirstSeen(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
if got := state.FirstSeenPublicDestinations["bash"]; len(got) != 1 || got[0] != "8.8.8.8:443" {
t.Fatalf("silent learning did not update state once: %#v", got)
}
}

View file

@ -0,0 +1,69 @@
// 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/provenance"
)
// isPackageOwned is injectable so tests never shell out to a package manager.
var isPackageOwned = provenance.IsPackageOwned
// NewListener ports the Python new_listener detector (SID 100013).
// A nil baseline is represented by an empty fixture field and remains fail-open.
func NewListener(state model.State, cfg config.Config) []model.Alert {
if state.ListenerBaseline == nil {
return []model.Alert{}
}
baseline := make(map[string]bool, len(state.ListenerBaseline))
for _, key := range state.ListenerBaseline {
baseline[key] = true
}
processExe := make(map[int]string, len(state.Processes))
for _, process := range state.Processes {
processExe[process.PID] = process.Exe
}
alerts := make([]model.Alert, 0)
for _, socket := range state.Sockets {
if socket.State != "LISTEN" {
continue
}
// Python uses rpartition(":"): the port is everything after the last
// colon, or the whole endpoint when no colon exists. Splitting at the
// first colon would corrupt bracketed IPv6 locals such as "[::]:31337".
port := socket.Local
if index := strings.LastIndex(socket.Local, ":"); index >= 0 {
port = socket.Local[index+1:]
}
comm := socket.Comm
if comm == "" {
comm = "?"
}
key := port + "/" + comm
if baseline[key] || cfg.ListenerAllowPorts[port] || cfg.ListenerAllowComms[comm] {
continue
}
// Optional provenance gate, mirroring Python: a listener whose binary
// ships with an installed package is very likely legitimate.
if cfg.SuppressPackageOwnedListeners && socket.PID != nil {
if exe := processExe[*socket.PID]; exe != "" && isPackageOwned(exe) {
continue
}
}
alerts = append(alerts, model.Alert{
SID: 100013,
Severity: "HIGH",
Signature: "new_listener",
Classtype: "backdoor-listener",
Key: "lis:" + key,
Detail: fmt.Sprintf("new listening socket %s by comm=%s", socket.Local, comm),
PIDs: alertPIDs(socket.PID),
})
}
return alerts
}

View file

@ -0,0 +1,91 @@
// 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 TestNewListenerAlertsUnbaselinedListener(t *testing.T) {
pid := 500
alerts := NewListener(model.State{
Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid,
}},
ListenerBaseline: []string{},
}, config.Default())
if len(alerts) != 1 {
t.Fatalf("got %#v", alerts)
}
if alerts[0].SID != 100013 || alerts[0].Key != "lis:31337/nc" {
t.Fatalf("unexpected alert: %#v", alerts[0])
}
}
func TestNewListenerSkipsBaselineAndAllowLists(t *testing.T) {
pid := 501
cfg := config.Default()
cfg.ListenerAllowPorts["8080"] = true
cfg.ListenerAllowComms["sshd"] = true
state := model.State{
Sockets: []model.Socket{
{State: "LISTEN", Local: "0.0.0.0:22", Comm: "sshd", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:8080", Comm: "server", PID: &pid},
{State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc", PID: &pid},
},
ListenerBaseline: []string{"31337/nc"},
}
if alerts := NewListener(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerWithoutBaselineIsSilent(t *testing.T) {
state := model.State{Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:31337", Comm: "nc",
}}}
if alerts := NewListener(state, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerIPv6PortUsesLastColon(t *testing.T) {
pid := 502
alerts := NewListener(model.State{
Sockets: []model.Socket{{
State: "LISTEN", Local: "[::]:31337", Comm: "nc", PID: &pid,
}},
ListenerBaseline: []string{},
}, config.Default())
if len(alerts) != 1 || alerts[0].Key != "lis:31337/nc" {
t.Fatalf("got %#v", alerts)
}
}
func TestNewListenerPackageOwnedSuppression(t *testing.T) {
saved := isPackageOwned
defer func() { isPackageOwned = saved }()
isPackageOwned = func(path string) bool { return path == "/usr/bin/qbittorrent" }
pid := 503
cfg := config.Default()
cfg.SuppressPackageOwnedListeners = true
state := model.State{
Processes: []model.Process{{PID: pid, Exe: "/usr/bin/qbittorrent"}},
Sockets: []model.Socket{{
State: "LISTEN", Local: "0.0.0.0:6881", Comm: "qbittorrent", PID: &pid,
}},
ListenerBaseline: []string{},
}
if alerts := NewListener(state, cfg); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
// The gate only applies when explicitly enabled.
cfg.SuppressPackageOwnedListeners = false
if alerts := NewListener(state, cfg); len(alerts) != 1 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// NewSUID ports the Python new_suid detector (SID 100014).
// The filesystem scan and baseline are supplied by the caller; absent values
// remain fail-open until the sidecar gains its own slow-scan persistence.
func NewSUID(state model.State, cfg config.Config) []model.Alert {
if state.SUIDBinaries == nil || state.SUIDBaseline == nil {
return []model.Alert{}
}
baseline := make(map[string]bool, len(state.SUIDBaseline))
for _, path := range state.SUIDBaseline {
baseline[path] = true
}
alerts := make([]model.Alert, 0)
for _, path := range state.SUIDBinaries {
if baseline[path] {
continue
}
hot := false
for _, directory := range cfg.SUIDHotDirs {
directory = strings.TrimRight(directory, "/")
if directory != "" && strings.HasPrefix(path, directory+"/") {
hot = true
break
}
}
severity := "HIGH"
detail := "new SUID/SGID binary: " + path
if hot {
severity = "CRITICAL"
detail = "SUID/SGID binary in writable dir: " + path
}
alerts = append(alerts, model.Alert{
SID: 100014, Severity: severity, Signature: "new_suid",
Classtype: "privilege-escalation", Key: "suid:" + path,
Detail: detail, PIDs: []int{},
})
}
return alerts
}

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"testing"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
func TestNewSUIDSeverityAndBaseline(t *testing.T) {
alerts := NewSUID(model.State{
SUIDBinaries: []string{"/tmp/evil", "/usr/local/bin/newtool", "/usr/bin/sudo"},
SUIDBaseline: []string{"/usr/bin/sudo"},
}, config.Default())
if len(alerts) != 2 {
t.Fatalf("got %#v", alerts)
}
if alerts[0].Severity != "CRITICAL" || alerts[1].Severity != "HIGH" {
t.Fatalf("unexpected severities: %#v", alerts)
}
}
func TestNewSUIDWithoutScanIsSilent(t *testing.T) {
if alerts := NewSUID(model.State{SUIDBaseline: []string{"/usr/bin/sudo"}}, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}

View file

@ -0,0 +1,50 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package detectors
import (
"fmt"
"sort"
"strings"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/config"
"codeberg.org/anassaeneroi/enodia-sentinal/go-agent/internal/model"
)
// Persistence ports the Python persistence detector (SID 100015).
// PersistenceFiles is an injectable path-to-mtime view; live filesystem
// collection remains deferred until the sidecar gains configured slow scans.
func Persistence(state model.State, cfg config.Config) []model.Alert {
if state.PersistSince == nil {
return []model.Alert{}
}
alerts := make([]model.Alert, 0)
paths := make([]string, 0, len(state.PersistenceFiles))
for path := range state.PersistenceFiles {
paths = append(paths, path)
}
sort.Strings(paths)
for _, path := range paths {
mtime := state.PersistenceFiles[path]
if mtime <= *state.PersistSince || !watchedPath(path, cfg.WatchPersistence) {
continue
}
alerts = append(alerts, model.Alert{
SID: 100015, Severity: "HIGH", Signature: "persistence",
Classtype: "persistence", Key: "persist:" + path,
Detail: fmt.Sprintf("persistence file modified: %s", path),
PIDs: []int{},
})
}
return alerts
}
func watchedPath(path string, roots []string) bool {
for _, root := range roots {
root = strings.TrimRight(root, "/")
if path == root || (root != "" && strings.HasPrefix(path, root+"/")) {
return true
}
}
return false
}

View file

@ -0,0 +1,33 @@
// 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 TestPersistenceAlertsModifiedWatchedFiles(t *testing.T) {
since := 100.0
alerts := Persistence(model.State{
PersistSince: &since,
PersistenceFiles: map[string]float64{
"/etc/cron.d/evil": 101,
"/tmp/not-watched": 200,
"/etc/passwd": 99,
},
}, config.Default())
if len(alerts) != 1 || alerts[0].Key != "persist:/etc/cron.d/evil" {
t.Fatalf("got %#v", alerts)
}
}
func TestPersistenceWithoutScanIsSilent(t *testing.T) {
if alerts := Persistence(model.State{
PersistenceFiles: map[string]float64{"/etc/passwd": 200},
}, config.Default()); len(alerts) != 0 {
t.Fatalf("got %#v", alerts)
}
}