# 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