108 lines
3.8 KiB
Python
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
|