Add enrichment and SID coverage gates

This commit is contained in:
Luna 2026-07-08 17:05:17 -07:00
parent 93ac47dd82
commit 66472134d8
32 changed files with 1623 additions and 31 deletions

View file

@ -683,9 +683,11 @@ def _cmd_triage(cfg: Config) -> int:
if v.suggest:
suggestions.add(v.suggest)
if suggestions:
print("\nTo suppress the false positives, add to your config:")
print("\nSuggested TOML after review (Sentinel does not edit config):")
for s in sorted(suggestions):
print(f" # {s}")
for line in s.splitlines():
print(f" {line}")
print()
return 0

210
enodia_sentinel/enrich.py Normal file
View file

@ -0,0 +1,210 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Post-alert enrichment for captured snapshots.
This module stays cheap: it annotates already-captured process, file, network,
and integrity context without running heavyweight package verification or
rootcheck sweeps during snapshot capture.
"""
from __future__ import annotations
import ipaddress
import os
import re
import stat
import time
from pathlib import Path
from typing import Callable
from .config import Config
from .fim import hash_file
from .netutil import is_public_ip
from .provenance import package_owner
from .system import SystemState
_IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
def build(
report: dict,
state: SystemState,
cfg: Config,
*,
lineage: set[int] | None = None,
recent_files: list[str] | None = None,
owner: Callable[[str], str | None] = package_owner,
) -> dict:
alerts = report.get("alerts", [])
return {
"processes": [_process_context(state, proc, cfg, owner)
for proc in report.get("processes", [])],
"remotes": [_remote_context(ip) for ip in sorted(_remote_ips(alerts))],
"files": [_file_context(path, owner) for path in sorted(_paths(alerts))],
"recent_writes": sorted(set(recent_files or [])),
"lineage": _lineage_context(state, sorted(lineage or set())),
"integrity": _integrity_context(cfg),
}
def format_text(enrichment: dict) -> str:
lines: list[str] = []
for proc in enrichment.get("processes", []):
lines.append(
"process pid={pid} comm={comm} package={pkg} sha256={sha}".format(
pid=proc.get("pid"),
comm=proc.get("comm") or "?",
pkg=proc.get("package_owner") or "unowned/unknown",
sha=(proc.get("exe_sha256") or "-")[:16],
)
)
chain = " <- ".join(
f"{p.get('pid')}:{p.get('comm') or '?'}"
for p in proc.get("parent_chain", [])
)
if chain:
lines.append(f" parent chain: {chain}")
for remote in enrichment.get("remotes", []):
lines.append(f"remote {remote['ip']}: {remote['classification']}")
for f in enrichment.get("files", []):
mode = f"0{f['mode']:o}" if f.get("mode") is not None else "-"
lines.append(
f"file {f['path']}: exists={f['exists']} mode={mode} "
f"owner={f.get('package_owner') or 'unowned/unknown'}"
)
integrity = enrichment.get("integrity", {})
if integrity:
lines.append(
"integrity: fim_baseline={fim} pkgdb_anchor={pkg} "
"rootcheck={root}".format(
fim=integrity.get("fim_baseline", {}).get("status", "unknown"),
pkg=integrity.get("pkgdb_anchor", {}).get("status", "unknown"),
root="enabled" if integrity.get("rootcheck", {}).get("enabled") else "disabled",
)
)
return "\n".join(lines) if lines else " (no enrichment available)"
def _process_context(
state: SystemState,
proc: dict,
cfg: Config,
owner: Callable[[str], str | None],
) -> dict:
exe = _clean_path(proc.get("exe", ""))
return {
"pid": proc.get("pid"),
"comm": proc.get("comm", ""),
"exe": exe,
"package_owner": owner(exe) if exe else None,
"exe_sha256": hash_file(exe) if exe and os.path.isfile(exe) else None,
"parent_chain": _parent_chain(state, proc.get("ppid", 0),
cfg.incident_lineage_depth),
}
def _parent_chain(state: SystemState, ppid: int, depth: int) -> list[dict]:
chain: list[dict] = []
seen: set[int] = set()
cur = ppid
for _ in range(max(depth, 0)):
if not cur or cur in seen:
break
seen.add(cur)
proc = state.process(cur)
if proc is None:
chain.append({"pid": cur, "comm": "", "ppid": 0})
break
chain.append({"pid": cur, "comm": proc.comm, "ppid": proc.ppid})
cur = proc.ppid
return chain
def _lineage_context(state: SystemState, lineage: list[int]) -> list[dict]:
out = []
for pid in lineage:
proc = state.process(pid)
out.append({
"pid": pid,
"comm": proc.comm if proc else "",
"present": proc is not None,
})
return out
def _remote_ips(alerts: list[dict]) -> set[str]:
out: set[str] = set()
for alert in alerts:
for ip in _IP_RE.findall(alert.get("detail", "")):
out.add(ip)
return out
def _remote_context(ip: str) -> dict:
try:
parsed = ipaddress.ip_address(ip)
except ValueError:
return {"ip": ip, "classification": "invalid"}
if is_public_ip(ip):
classification = "public"
elif parsed.is_loopback:
classification = "loopback"
elif parsed.is_private:
classification = "private"
elif parsed.is_link_local:
classification = "link-local"
else:
classification = "reserved"
return {"ip": ip, "classification": classification}
def _paths(alerts: list[dict]) -> set[str]:
paths: set[str] = set()
for alert in alerts:
for text in (alert.get("detail", ""), alert.get("key", "")):
for token in re.findall(r"(?<![\w.])/[^\s,\])]+", text):
paths.add(_clean_path(token.rstrip(":")))
return {p for p in paths if p}
def _file_context(path: str, owner: Callable[[str], str | None]) -> dict:
p = Path(path)
try:
st = p.lstat()
except OSError:
return {"path": path, "exists": False, "mode": None, "uid": None,
"gid": None, "package_owner": None}
return {
"path": path,
"exists": True,
"mode": stat.S_IMODE(st.st_mode),
"uid": st.st_uid,
"gid": st.st_gid,
"package_owner": owner(path),
}
def _integrity_context(cfg: Config) -> dict:
return {
"fim_baseline": _path_status(cfg.fim_baseline),
"pkgdb_anchor": _path_status(cfg.log_dir / "pkgdb-anchor.json"),
"package_verify": {
"enabled": bool(cfg.pkgdb_pkgverify),
"sample": cfg.pkgdb_pkgverify_sample,
},
"rootcheck": {
"enabled": bool(cfg.rootcheck_enabled),
"interval": cfg.rootcheck_interval,
},
}
def _path_status(path: Path) -> dict:
try:
st = path.stat()
except OSError:
return {"path": str(path), "status": "missing", "age": None}
return {"path": str(path), "status": "present",
"age": max(0, int(time.time() - st.st_mtime))}
def _clean_path(path: str) -> str:
return path.replace(" (deleted)", "")

View file

@ -172,6 +172,15 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
reason="Confirm package-owned and FIM-monitored files match expected state.",
risk="low",
))
if {"fim_pkg_modified", "pkg_signature_mismatch", "pkgdb_tamper"} & sigs:
add(ResponseAction(
id="",
category="verification",
title="Verify package-owned files against signed cache packages",
command=("enodia-sentinel", "pkgdb-verify"),
reason="Check on-disk package files against signed package metadata instead of the mutable local DB.",
risk="low",
))
if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"}
for s in sigs):
add(ResponseAction(
@ -182,6 +191,27 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
reason="Confirm cross-view hidden process/module/port findings are gone.",
risk="low",
))
if {"persistence", "new_suid", "fim_added", "fim_modified", "fim_removed"} & sigs:
add(ResponseAction(
id="",
category="verification",
title="Confirm no persistence diff remains",
command=("enodia-sentinel", "check", "--json"),
reason="Re-run the detector set after containment to confirm persistence-related alerts are gone.",
risk="low",
))
watchdog_url = _watchdog_url(cfg)
add(ResponseAction(
id="",
category="verification",
title="Confirm heartbeat and dashboard visibility",
command=("enodia-sentinel", "watchdog", "--url", watchdog_url,
"--token", "<token>", "--insecure-tls"),
reason="Run from another host or tailnet peer to prove the sensor heartbeat and HTTPS dashboard are reachable.",
risk="low",
targets={"url": watchdog_url},
))
numbered = []
for i, action in enumerate(actions, 1):
@ -336,3 +366,12 @@ def _is_quarantine_candidate(path: str, sig: str) -> bool:
if path == "/etc/ld.so.preload":
return False
return False
def _watchdog_url(cfg: Config) -> str:
if cfg.dashboard_url:
return cfg.dashboard_url.rstrip("/")
host = cfg.web_bind or "<dashboard-host>"
if ":" in host and not host.startswith("[") and host != "<dashboard-host>":
host = f"[{host}]"
return f"https://{host}:{cfg.web_port}"

125
enodia_sentinel/sids.py Normal file
View file

@ -0,0 +1,125 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Canonical registry of every built-in SID.
Aggregates the constants where they are defined; nothing is renumbered or
moved. The core poll detectors carry some SIDs inline in the Alert they build,
so those rows are tabulated here and cross-checked by tests.
"""
from __future__ import annotations
from dataclasses import dataclass
from . import fim, pkgdb, posture, rootcheck
from .detectors import (
credential_access,
input_snooper,
memory_obfuscation,
stealth_network,
)
from .events.rules import DEFAULT_EXEC_RULES
from .events.syscall_rules import DEFAULT_SYSCALL_RULES
@dataclass(frozen=True)
class SidInfo:
sid: int
name: str
engine: str
module: str
def _rule_rows(rules, engine: str, module: str) -> tuple[SidInfo, ...]:
return tuple(
SidInfo(r.sid, f"{engine}.{r.classtype}", engine, module)
for r in rules
)
_DETECTORS = "enodia_sentinel.detectors"
BUILTIN_SIDS: tuple[SidInfo, ...] = (
*_rule_rows(DEFAULT_EXEC_RULES, "exec_rule",
"enodia_sentinel.events.rules"),
*_rule_rows(DEFAULT_SYSCALL_RULES, "syscall_rule",
"enodia_sentinel.events.syscall_rules"),
SidInfo(100010, "reverse_shell", "detector", f"{_DETECTORS}.reverse_shell"),
SidInfo(100011, "ld_preload", "detector", f"{_DETECTORS}.ld_preload"),
SidInfo(100012, "deleted_exe", "detector", f"{_DETECTORS}.deleted_exe"),
SidInfo(100013, "new_listener", "detector", f"{_DETECTORS}.new_listener"),
SidInfo(100014, "new_suid", "detector", f"{_DETECTORS}.new_suid"),
SidInfo(100015, "persistence", "detector", f"{_DETECTORS}.persistence"),
SidInfo(100016, "egress", "detector", f"{_DETECTORS}.egress"),
SidInfo(input_snooper.SID_INPUT_SNOOPER, "input_snooper", "detector",
f"{_DETECTORS}.input_snooper"),
SidInfo(credential_access.SID_CREDENTIAL_ACCESS, "credential_access",
"detector", f"{_DETECTORS}.credential_access"),
SidInfo(stealth_network.SID_STEALTH_NETWORK, "stealth_network",
"detector", f"{_DETECTORS}.stealth_network"),
SidInfo(memory_obfuscation.SID_MEMORY_OBFUSCATION, "memory_obfuscation",
"detector", f"{_DETECTORS}.memory_obfuscation"),
SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY,
"process_hiding_library", "detector",
f"{_DETECTORS}.memory_obfuscation"),
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"),
SidInfo(fim.SID_PKG, "pkg_verify_mismatch", "fim", "enodia_sentinel.fim"),
SidInfo(pkgdb.SID_PKGDB, "pkgdb_tamper", "pkgdb", "enodia_sentinel.pkgdb"),
SidInfo(pkgdb.SID_SIGLEVEL, "pacman_siglevel_disabled", "pkgdb",
"enodia_sentinel.pkgdb"),
SidInfo(pkgdb.SID_PKGVERIFY, "pkg_signature_mismatch", "pkgdb",
"enodia_sentinel.pkgdb"),
SidInfo(rootcheck.SID_HIDDEN_PROC, "rootkit_hidden_process", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_MODULE, "rootkit_hidden_module", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_PORT, "rootkit_hidden_port", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_PROMISC, "promiscuous_interface", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_KNOWN_ROOTKIT_MODULE, "rootkit_known_module",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_TAINTED_MODULE, "rootkit_tainted_module",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_KERNEL_TAINTED, "kernel_tainted", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_UDP_PORT, "rootkit_hidden_udp_port",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_RAW_SOCKET, "rootkit_hidden_raw_socket",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_RAW_ICMP_SOCKET, "raw_icmp_socket", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_PROTOCOL_SOCKET,
"rootkit_hidden_protocol_socket", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_PS_HIDDEN_PROCESS, "rootkit_ps_hidden_process",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(posture.SID_SSH_ROOT_LOGIN, "ssh_root_login", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SSH_PASSWORD_AUTH, "ssh_password_auth", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SSH_EMPTY_PASSWORD, "ssh_empty_password", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SUDO_NOPASSWD, "sudo_nopasswd", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SUDO_NOAUTH, "sudo_no_authenticate", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SUDOERS_WRITABLE, "sudoers_writable", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_PATH_WRITABLE, "path_writable", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SENSITIVE_FILE_PERM, "sensitive_file_perm", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SYSTEMD_UNIT_WRITABLE, "systemd_unit_writable",
"posture", "enodia_sentinel.posture"),
SidInfo(posture.SID_SYSTEMD_EXEC_UNSAFE, "systemd_exec_unsafe", "posture",
"enodia_sentinel.posture"),
)
def all_sids() -> frozenset[int]:
return frozenset(s.sid for s in BUILTIN_SIDS)
def engine_sids(engine: str) -> frozenset[int]:
return frozenset(s.sid for s in BUILTIN_SIDS if s.engine == engine)

View file

@ -173,6 +173,15 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
incident_id = incident.record(
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
cutoff = int(time.time()) - 3600
recent_files = []
for root in cfg.watch_persistence:
try:
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
recent_files.append(root)
except OSError:
pass
report = {
"schema": schemas.ALERT_SNAPSHOT_V1,
"time": now.isoformat(),
@ -183,18 +192,14 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
"processes": [_pid_detail(state, p) for p in pids],
}
cutoff = int(time.time()) - 3600
recent_files = []
for root in cfg.watch_persistence:
try:
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
recent_files.append(root)
except OSError:
pass
from . import enrich
report["enrichment"] = enrich.build(
report, state, cfg, lineage=lineage, recent_files=recent_files)
ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm",
"--sort=-pcpu"])
extras = {
"Post-alert enrichment": enrich.format_text(report["enrichment"]),
"Process tree (top by CPU)": "\n".join(ps_out.splitlines()[:40]),
"Sockets (ss -tanp)": "\n".join(_run(["ss", "-tanp"]).splitlines()[:80]),
"/etc/ld.so.preload": (

View file

@ -8,6 +8,7 @@ never auto-deletion. ``is_owned`` is injectable for testing.
from __future__ import annotations
import re
import json
from collections.abc import Callable
from dataclasses import dataclass
@ -28,7 +29,7 @@ _ALWAYS_REVIEW = {"reverse_shell", "egress"}
class Verdict:
label: str # LIKELY_FP or REVIEW
reason: str
suggest: str = "" # optional config line to suppress future copies
suggest: str = "" # optional TOML snippet to suppress future copies
def _exe_for(alert: dict, processes: list[dict]) -> str:
@ -39,6 +40,32 @@ def _exe_for(alert: dict, processes: list[dict]) -> str:
return ""
def _comm_from_detail(detail: str) -> str:
m = re.search(r"\bcomm=([^\s]+)", detail)
return m.group(1) if m else ""
def _alert_context(alert: dict) -> str:
sid = alert.get("sid") or "unknown"
classtype = alert.get("classtype") or "unknown"
return f"sid={sid} classtype={classtype}"
def _toml_string_array(name: str, values) -> str:
items = ", ".join(json.dumps(v) for v in sorted(set(values)) if v)
return f"{name} = [{items}]"
def _allow_comm_suggestion(alert: dict, cfg: Config, key: str, comm: str,
why: str) -> str:
current = getattr(cfg, key)
return "\n".join((
f"# Triage suggestion for {_alert_context(alert)}.",
f"# Add only after verifying this {comm!r} activity is expected: {why}.",
_toml_string_array(key, set(current) | {comm}),
))
def triage_alert(alert: dict, processes: list[dict], cfg: Config,
is_owned: Callable[[str], bool] = is_package_owned) -> Verdict:
sig = alert.get("signature", "")
@ -73,11 +100,15 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
if comm in cfg.listener_allow_comms:
return Verdict(LIKELY_FP, f"comm '{comm}' is allow-listed")
if exe and is_owned(exe):
owner = comm
return Verdict(LIKELY_FP,
f"listener binary is package-owned ({comm})",
suggest=f'listener_allow_comms += "{comm}"')
return Verdict(REVIEW, f"unrecognized listener {addr} ({comm})")
suggest=_allow_comm_suggestion(
alert, cfg, "listener_allow_comms", comm,
"package-owned listener"))
return Verdict(REVIEW, f"unrecognized listener {addr} ({comm})",
suggest=_allow_comm_suggestion(
alert, cfg, "listener_allow_comms", comm,
"known service listener"))
if sig == "new_suid":
path = detail.rsplit(": ", 1)[-1]
@ -85,6 +116,42 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
return Verdict(LIKELY_FP, "SUID binary is package-owned")
return Verdict(REVIEW, f"unowned SUID binary: {path}")
if sig == "input_snooper":
comm = _comm_from_detail(detail)
if comm:
return Verdict(REVIEW, "input device access — verify this is local input stack",
suggest=_allow_comm_suggestion(
alert, cfg, "input_snooper_allow_comms", comm,
"expected input-device reader"))
return Verdict(REVIEW, "input device access — verify this is local input stack")
if sig == "credential_access":
comm = _comm_from_detail(detail)
if comm:
return Verdict(REVIEW, "credential file access — verify the process role",
suggest=_allow_comm_suggestion(
alert, cfg, "credential_access_allow_comms", comm,
"expected credential-store reader"))
return Verdict(REVIEW, "credential file access — verify the process role")
if sig == "stealth_network":
comm = _comm_from_detail(detail)
if comm and comm != "?":
return Verdict(REVIEW, "unusual socket family — verify the protocol use",
suggest=_allow_comm_suggestion(
alert, cfg, "stealth_network_allow_comms", comm,
"expected raw/special-protocol socket owner"))
return Verdict(REVIEW, "unusual socket family — verify the protocol use")
if sig == "memory_obfuscation":
comm = _comm_from_detail(detail)
if comm:
return Verdict(REVIEW, "executable or RWX memory mapping — verify runtime/JIT use",
suggest=_allow_comm_suggestion(
alert, cfg, "memory_obfuscation_allow_comms", comm,
"expected executable memory runtime"))
return Verdict(REVIEW, "executable or RWX memory mapping — verify runtime/JIT use")
if sig == "persistence":
return Verdict(REVIEW, "a persistence-relevant file changed — confirm intent")