enodia-sentinal/go-agent/internal/detectors/first_seen.go

95 lines
3 KiB
Go

// 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
}