enodia-sentinal/enodia_sentinel/detectors/first_seen.py

108 lines
3.8 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
"""first_seen — rarity tracking for network behavior.
The first pass creates a local baseline without alerting. Later passes alert
when a process name reaches a new public destination or opens a new listener
port. This complements signature detections without flooding on daemon startup.
"""
from __future__ import annotations
import json
import os
from collections.abc import Iterator
from .. import schemas
from ..alert import Alert, Severity
from ..config import Config
from ..netutil import is_public_ip, split_host_port
from ..system import SystemState
SID_FIRST_PUBLIC_DESTINATION = 100074
SID_FIRST_LISTENER_PORT = 100075
STORE_NAME = "first-seen.json"
def store_path(cfg: Config):
return cfg.log_dir / STORE_NAME
def _load(cfg: Config) -> dict:
try:
data = json.loads(store_path(cfg).read_text())
if isinstance(data, dict):
data.setdefault("schema", schemas.FIRST_SEEN_V1)
data.setdefault("public_destinations", {})
data.setdefault("listener_ports", {})
data.setdefault("initialized", False)
return data
except (OSError, ValueError):
pass
return {
"schema": schemas.FIRST_SEEN_V1,
"initialized": False,
"public_destinations": {},
"listener_ports": {},
}
def _save(cfg: Config, data: dict) -> None:
cfg.log_dir.mkdir(parents=True, exist_ok=True)
tmp = store_path(cfg).with_suffix(".json.tmp")
tmp.write_text(json.dumps(data, indent=2, sort_keys=True))
os.replace(tmp, store_path(cfg))
try:
store_path(cfg).chmod(0o640)
except OSError:
pass
def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
if not cfg.first_seen_enabled:
return
data = _load(cfg)
initialized = bool(data.get("initialized"))
public = data.setdefault("public_destinations", {})
listeners = data.setdefault("listener_ports", {})
alerts: list[Alert] = []
for sock in state.sockets:
comm = sock.comm or "?"
if sock.state == "ESTAB":
host, port = split_host_port(sock.peer)
if is_public_ip(host) and port:
seen = set(public.get(comm, []))
target = f"{host}:{port}"
if target not in seen:
seen.add(target)
public[comm] = sorted(seen)
if initialized:
alerts.append(Alert(
Severity.MEDIUM,
"first_public_destination",
f"firstdest:{comm}:{target}",
f"comm={comm} first public destination {target}",
(sock.pid,) if sock.pid else (),
sid=SID_FIRST_PUBLIC_DESTINATION,
classtype="network-rarity",
))
if sock.state in {"LISTEN", "UNCONN"} and sock.kind in {"tcp", "udp", ""}:
_host, port = split_host_port(sock.local)
if port and port.isdigit() and int(port):
seen_ports = set(listeners.get(comm, []))
if port not in seen_ports:
seen_ports.add(port)
listeners[comm] = sorted(seen_ports, key=lambda p: int(p))
if initialized:
alerts.append(Alert(
Severity.MEDIUM,
"first_listener_port",
f"firstlisten:{comm}:{port}",
f"comm={comm} first listening port {port}",
(sock.pid,) if sock.pid else (),
sid=SID_FIRST_LISTENER_PORT,
classtype="network-rarity",
))
data["initialized"] = True
_save(cfg, data)
yield from alerts