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