Add host correlation and assurance coverage
This commit is contained in:
parent
8194d13734
commit
b40ac4252c
36 changed files with 944 additions and 23 deletions
108
enodia_sentinel/detectors/first_seen.py
Normal file
108
enodia_sentinel/detectors/first_seen.py
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue