Add host correlation and assurance coverage

This commit is contained in:
Luna 2026-07-10 03:07:00 -07:00
parent 8194d13734
commit b40ac4252c
No known key found for this signature in database
36 changed files with 944 additions and 23 deletions

View file

@ -0,0 +1,91 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Append-only hash-chain records for local forensic artifacts."""
from __future__ import annotations
import hashlib
import json
import time
from pathlib import Path
from . import schemas
CHAIN_NAME = "hash-chain.jsonl"
def chain_path(cfg) -> Path:
return cfg.log_dir / CHAIN_NAME
def _sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def file_digest(path: Path) -> str:
return _sha256_bytes(path.read_bytes())
def last_chain_hash(cfg) -> str:
try:
lines = chain_path(cfg).read_text().splitlines()
except OSError:
return ""
for line in reversed(lines):
try:
record = json.loads(line)
except ValueError:
continue
value = record.get("chain_hash")
if isinstance(value, str):
return value
return ""
def append_artifact(cfg, kind: str, path: Path) -> dict:
"""Append a tamper-evident hash-chain record for ``path``."""
cfg.log_dir.mkdir(parents=True, exist_ok=True)
prev = last_chain_hash(cfg)
try:
digest = file_digest(path)
size = path.stat().st_size
except OSError:
digest = ""
size = 0
record = {
"schema": schemas.HASH_CHAIN_V1,
"time": time.time(),
"kind": kind,
"path": str(path),
"size": size,
"sha256": digest,
"prev_hash": prev,
}
payload = json.dumps(record, sort_keys=True, separators=(",", ":")).encode()
record["chain_hash"] = _sha256_bytes(payload + prev.encode())
with chain_path(cfg).open("a") as fh:
fh.write(json.dumps(record, sort_keys=True) + "\n")
try:
chain_path(cfg).chmod(0o640)
except OSError:
pass
return record
def summary(cfg) -> dict:
path = chain_path(cfg)
try:
lines = path.read_text().splitlines()
except OSError:
return {"path": str(path), "exists": False, "count": 0, "last_hash": ""}
last = ""
for line in reversed(lines):
try:
last = json.loads(line).get("chain_hash", "")
break
except ValueError:
continue
return {
"path": str(path),
"exists": True,
"count": len(lines),
"last_hash": last,
}

View file

@ -27,7 +27,7 @@ _DEFAULT_WATCH = (
_ALL_DETECTORS = (
"reverse_shell", "ld_preload", "deleted_exe",
"input_snooper", "credential_access", "stealth_network",
"memory_obfuscation",
"memory_obfuscation", "first_seen",
"new_listener", "new_suid", "persistence", "egress",
)
@ -98,6 +98,10 @@ class Config:
# memory-map shapes (for local JIT runtimes or known instrumentation).
memory_obfuscation_allow_paths: tuple[str, ...] = ()
# First-seen tracking stores an operator-visible baseline under log_dir and
# alerts on new public destinations or listener ports after the first pass.
first_seen_enabled: bool = True
# file integrity monitoring (FIM)
fim_enabled: bool = True
fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS

View file

@ -0,0 +1,66 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Read-only incident correlation rules.
Correlation does not hide the raw alerts or mutate the host. It adds a small
``correlations`` list to an incident when the incident's existing evidence
matches a higher-confidence story.
"""
from __future__ import annotations
from dataclasses import dataclass
from .alert import Severity
SID_MULTI_STAGE_INTRUSION = 100080
@dataclass(frozen=True)
class CorrelationRule:
sid: int
signature: str
classtype: str
severity: Severity
summary: str
required_any: tuple[set[str], ...]
max_window: int
DEFAULT_RULES: tuple[CorrelationRule, ...] = (
CorrelationRule(
sid=SID_MULTI_STAGE_INTRUSION,
signature="correlation.multi_stage_intrusion",
classtype="multi-stage-intrusion",
severity=Severity.CRITICAL,
summary=(
"Web/database service spawned a shell and the same incident showed "
"suspicious egress or an unusual listener within the correlation window"
),
required_any=(
{"exec_rule.web-rce"},
{"host_rule.suspicious-egress", "host_rule.suspicious-listener"},
),
max_window=600,
),
)
def correlate(incident: dict, rules: tuple[CorrelationRule, ...] = DEFAULT_RULES) -> list[dict]:
"""Return correlation records matched by an incident index entry."""
signatures = set(incident.get("signatures") or [])
duration = float(incident.get("last_ts", 0.0)) - float(incident.get("first_ts", 0.0))
out: list[dict] = []
for rule in rules:
if duration > rule.max_window:
continue
if not all(signatures & choices for choices in rule.required_any):
continue
out.append({
"sid": rule.sid,
"signature": rule.signature,
"classtype": rule.classtype,
"severity": str(rule.severity),
"summary": rule.summary,
"window_seconds": rule.max_window,
"matched_signatures": sorted(signatures),
})
return out

View file

@ -18,6 +18,7 @@ from . import (
credential_access,
deleted_exe,
egress,
first_seen,
input_snooper,
ld_preload,
memory_obfuscation,
@ -49,6 +50,7 @@ REGISTRY: tuple[Detector, ...] = (
Detector("stealth_network", stealth_network.detect),
Detector("memory_obfuscation", memory_obfuscation.detect),
Detector("egress", egress.detect),
Detector("first_seen", first_seen.detect, needs_baseline=True),
Detector("new_listener", new_listener.detect, needs_baseline=True),
Detector("persistence", persistence.detect, needs_baseline=True),
Detector("new_suid", new_suid.detect, needs_baseline=True, expensive=True),
@ -68,4 +70,6 @@ def run_all(
continue
if wanted is not None and det.name not in wanted:
continue
if det.needs_baseline and state.listener_baseline is None:
continue
yield from det.detect(state, cfg)

View 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

View file

@ -19,6 +19,9 @@ class HostEvent:
peer_port: int = 0
local_ip: str = ""
local_port: int = 0
target_uid: int = -1
target_gid: int = -1
mode: int = 0
@property
def argv_str(self) -> str:
@ -38,4 +41,7 @@ class HostEvent:
"peer_port": self.peer_port,
"local_ip": self.local_ip,
"local_port": self.local_port,
"target_uid": self.target_uid,
"target_gid": self.target_gid,
"mode": self.mode,
}

View file

@ -16,6 +16,11 @@ _INTERPRETERS = frozenset(
"node nodejs nc ncat netcat socat curl wget fetch".split()
)
_COMMON_PUBLIC_PORTS = frozenset({22, 53, 80, 123, 443, 853})
_PERSISTENCE_PREFIXES = (
"/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/",
"/etc/ld.so.preload", "/root/.ssh/authorized_keys",
"/etc/sudoers", "/etc/sudoers.d/",
)
@dataclass(frozen=True)
@ -32,6 +37,9 @@ class HostRule:
peer_port_exclude: frozenset[int] = frozenset()
local_ports: frozenset[int] = frozenset()
local_port_exclude: frozenset[int] = frozenset()
path_prefixes: tuple[str, ...] = ()
target_uids: frozenset[int] = frozenset()
target_gids: frozenset[int] = frozenset()
argv_regex: str | None = None
def __post_init__(self) -> None:
@ -40,7 +48,8 @@ class HostRule:
if not any((
self.comm, self.parent_comm, self.peer_public is not None,
self.peer_ports, self.peer_port_exclude, self.local_ports,
self.local_port_exclude, self.argv_regex,
self.local_port_exclude, self.path_prefixes, self.target_uids,
self.target_gids, self.argv_regex,
)):
raise ValueError(f"rule sid={self.sid} has no match conditions")
if self.argv_regex is not None:
@ -66,6 +75,15 @@ class HostRule:
return False
if self.local_port_exclude and ev.local_port in self.local_port_exclude:
return False
if self.path_prefixes and not any(
ev.path == prefix or ev.path.startswith(prefix)
for prefix in self.path_prefixes
):
return False
if self.target_uids and ev.target_uid not in self.target_uids:
return False
if self.target_gids and ev.target_gid not in self.target_gids:
return False
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
return False
return True
@ -74,11 +92,17 @@ class HostRule:
return Alert(
severity=self.severity,
signature=f"host_rule.{self.classtype}",
key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}",
key=(
f"host:{self.sid}:{ev.event}:{ev.pid}:"
f"{ev.peer_ip}:{ev.peer_port}:{ev.local_ip}:{ev.local_port}:"
f"{ev.path}:{ev.target_uid}:{ev.target_gid}:{ev.mode}"
),
detail=(
f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} "
f"comm={ev.comm} event={ev.event} peer={ev.peer_ip}:{ev.peer_port} "
f"local={ev.local_ip}:{ev.local_port}"
f"local={ev.local_ip}:{ev.local_port} path={ev.path} "
f"target_uid={ev.target_uid} target_gid={ev.target_gid} "
f"mode={oct(ev.mode) if ev.mode else '-'}"
),
pids=(ev.pid,),
sid=self.sid,
@ -106,6 +130,41 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = (
comm=_INTERPRETERS,
local_port_exclude=_COMMON_PUBLIC_PORTS,
),
HostRule(
sid=100069,
msg="Interpreter wrote to a persistence path",
severity=Severity.HIGH,
classtype="persistence-write",
events=frozenset({"file_write"}),
comm=_INTERPRETERS,
path_prefixes=_PERSISTENCE_PREFIXES,
),
HostRule(
sid=100070,
msg="Permissions or ownership changed on a persistence path",
severity=Severity.HIGH,
classtype="persistence-permission-change",
events=frozenset({"chmod", "chown"}),
path_prefixes=_PERSISTENCE_PREFIXES,
),
HostRule(
sid=100071,
msg="Interpreter requested a root UID transition",
severity=Severity.HIGH,
classtype="privilege-transition",
events=frozenset({"setuid"}),
comm=_INTERPRETERS,
target_uids=frozenset({0}),
),
HostRule(
sid=100072,
msg="Interpreter requested a root GID transition",
severity=Severity.HIGH,
classtype="privilege-transition",
events=frozenset({"setgid"}),
comm=_INTERPRETERS,
target_gids=frozenset({0}),
),
)

View file

@ -118,6 +118,7 @@ def load_index(cfg: Config) -> dict:
for inc in data.values():
if isinstance(inc, dict):
inc.setdefault("schema", schemas.INCIDENT_V1)
inc.setdefault("correlations", [])
return data
except (OSError, ValueError):
return {}
@ -174,6 +175,7 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
"severity": severity,
"signatures": [], "sids": [], "pids": [],
"lineage": [], "snapshots": [], "alert_count": 0,
"correlations": [],
}
inc = index[iid]
inc.setdefault("schema", schemas.INCIDENT_V1)
@ -186,6 +188,16 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
inc["lineage"] = sorted(set(inc["lineage"]) | lineage)
inc["snapshots"].append(snapshot_name)
inc["alert_count"] += len(alerts)
from . import correlation
inc["correlations"] = correlation.correlate(inc)
for corr in inc["correlations"]:
if corr["sid"] not in inc["sids"]:
inc["sids"].append(corr["sid"])
inc["severity"] = max_severity(
inc.get("severity", severity),
max((c.get("severity", severity) for c in inc["correlations"]),
default=severity),
)
save_index(cfg, index)
return iid
except Exception:

View file

@ -168,6 +168,12 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]:
conditions["local_ports"] = sorted(rule.local_ports)
if rule.local_port_exclude:
conditions["local_port_exclude"] = sorted(rule.local_port_exclude)
if rule.path_prefixes:
conditions["path_prefixes"] = list(rule.path_prefixes)
if rule.target_uids:
conditions["target_uids"] = sorted(rule.target_uids)
if rule.target_gids:
conditions["target_gids"] = sorted(rule.target_gids)
if rule.argv_regex:
conditions["argv_regex"] = rule.argv_regex
return {
@ -359,6 +365,9 @@ def _host_event(event: dict[str, Any]) -> HostEvent:
event.get("listen_ip", "")))),
local_port=_to_int(event.get("local_port", event.get("bind_port",
event.get("listen_port", 0)))),
target_uid=_to_int(event.get("target_uid", event.get("uid_target", -1))),
target_gid=_to_int(event.get("target_gid", event.get("gid_target", -1))),
mode=_to_int(event.get("mode", 0)),
)

View file

@ -13,6 +13,8 @@ INCIDENT_VIEW_V1 = "enodia.incident.view.v1"
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
STATUS_V1 = "enodia.status.v1"
INTEGRITY_V1 = "enodia.integrity.v1"
HASH_CHAIN_V1 = "enodia.hash_chain.v1"
FIRST_SEEN_V1 = "enodia.first_seen.v1"
RESPONSE_PLAN_V1 = "enodia.response.plan.v1"
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
RECONCILE_V1 = "enodia.reconcile.v1"

View file

@ -9,9 +9,10 @@ from __future__ import annotations
from dataclasses import dataclass
from . import fim, pkgdb, posture, rootcheck
from . import correlation, fim, pkgdb, posture, rootcheck
from .detectors import (
credential_access,
first_seen,
input_snooper,
memory_obfuscation,
stealth_network,
@ -66,6 +67,15 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = (
SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY,
"process_injection_library", "detector",
f"{_DETECTORS}.memory_obfuscation"),
SidInfo(first_seen.SID_FIRST_PUBLIC_DESTINATION,
"first_public_destination", "detector",
f"{_DETECTORS}.first_seen"),
SidInfo(first_seen.SID_FIRST_LISTENER_PORT,
"first_listener_port", "detector",
f"{_DETECTORS}.first_seen"),
SidInfo(correlation.SID_MULTI_STAGE_INTRUSION,
"multi_stage_intrusion", "correlation",
"enodia_sentinel.correlation"),
SidInfo(fim.SID_MODIFIED, "fim_modified", "fim", "enodia_sentinel.fim"),
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),

View file

@ -237,6 +237,11 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
fh.write(f"{now.isoformat()} [{severity}] captured "
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
from . import assurance
assurance.append_artifact(cfg, "snapshot-log", base.with_suffix(".log"))
assurance.append_artifact(cfg, "snapshot-json", base.with_suffix(".json"))
assurance.append_artifact(cfg, "events-log", cfg.events_log)
_notify(cfg, f"{severity}: {sigs}")
# Phone push (ntfy / Pushover / webhook), gated by notify_min_severity.

View file

@ -510,6 +510,15 @@ def _render_timelines(views: list[dict], width: int) -> list[str]:
sigs = ", ".join(inc.get("signatures") or [])
if sigs:
lines.append(f" signatures: {_clip(sigs, max(20, width - 15))}")
correlations = inc.get("correlations") or []
for corr in correlations:
lines.append(
f" correlation sid={corr.get('sid', '?')} "
f"{corr.get('severity', '?')} {corr.get('signature', '?')}"
)
lines.append(
f" {_clip(corr.get('summary', ''), max(20, width - 4))}"
)
timeline = view.get("timeline") or []
if not timeline:
lines.append(" No timeline rows.")
@ -566,6 +575,7 @@ def _render_integrity(report: dict, width: int) -> list[str]:
pacman = anchors.get("pacman") or {}
footprint = report.get("sentinel_footprint") or {}
reconciliation = report.get("reconciliation") or {}
hash_chain = report.get("hash_chain") or {}
lines = [
f"Overall: {report.get('status', 'unknown')}",
f"Generated: {report.get('generated_at', '?')}",
@ -588,6 +598,9 @@ def _render_integrity(report: dict, width: int) -> list[str]:
f" Package DB: {pkgdb.get('status', 'unknown')} prefix={pkgdb.get('fingerprint_prefix', '')}",
f" Pacman keyring:{' present' if pacman.get('keyring_present') else ' missing'}",
f" SigLevel: {pacman.get('siglevel_status', 'unknown')}",
f" Hash chain: {'present' if hash_chain.get('exists') else 'missing'} "
f"count={hash_chain.get('count', 0)}",
f" {_clip(hash_chain.get('path', '?'), max(20, width - 17))}",
"",
"Sentinel footprint:",
f" Present: {footprint.get('present', 0)}/{footprint.get('configured', 0)}",

View file

@ -345,6 +345,7 @@ def integrity_report(cfg: Config, status: dict | None = None,
not run live FIM or package verification work from the web request path.
"""
from . import pkgdb
from . import assurance
from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict
ts = time.time() if now is None else now
@ -373,10 +374,12 @@ def integrity_report(cfg: Config, status: dict | None = None,
}
keyring_present = pkgdb.keyring_present()
reconciliation = _reconciliation_summary(cfg)
hash_chain = assurance.summary(cfg)
checks = {
"watchdog": "ok" if watchdog_ok else "review",
"fim_baseline": fim_state["status"],
"pkgdb_anchor": pkg_state["status"],
"hash_chain": "ok" if hash_chain["exists"] else "missing",
"pacman_siglevel": "review" if sig_alert else "ok",
"pacman_keyring": "ok" if keyring_present else "missing",
"reconciliation": "review" if reconciliation["stale"] else "ok",
@ -416,6 +419,7 @@ def integrity_report(cfg: Config, status: dict | None = None,
"paths": watched,
},
"reconciliation": reconciliation,
"hash_chain": hash_chain,
"read_only": True,
}