95 lines
3 KiB
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
|
|
}
|