feat(go): advance validation sidecar toward production
This commit is contained in:
parent
6a06eba255
commit
f85c2e831a
92 changed files with 8881 additions and 91 deletions
95
go-agent/internal/detectors/first_seen.go
Normal file
95
go-agent/internal/detectors/first_seen.go
Normal 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
|
||||
}
|
||||
45
go-agent/internal/detectors/first_seen_test.go
Normal file
45
go-agent/internal/detectors/first_seen_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
69
go-agent/internal/detectors/new_listener.go
Normal file
69
go-agent/internal/detectors/new_listener.go
Normal 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
|
||||
}
|
||||
91
go-agent/internal/detectors/new_listener_test.go
Normal file
91
go-agent/internal/detectors/new_listener_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
49
go-agent/internal/detectors/new_suid.go
Normal file
49
go-agent/internal/detectors/new_suid.go
Normal 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
|
||||
}
|
||||
29
go-agent/internal/detectors/new_suid_test.go
Normal file
29
go-agent/internal/detectors/new_suid_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
50
go-agent/internal/detectors/persistence.go
Normal file
50
go-agent/internal/detectors/persistence.go
Normal 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
|
||||
}
|
||||
33
go-agent/internal/detectors/persistence_test.go
Normal file
33
go-agent/internal/detectors/persistence_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue