Add false-positive triage via package-ownership provenance
A noisy IDS gets ignored. Adds explicit FP-handling built on provenance — a binary owned by the package manager is almost certainly legitimate (the OSSEC rootcheck / AIDE principle). - provenance.py: package_owner()/is_package_owned() via pacman/dpkg/rpm, cached; framed as confidence-raising, never proof-of-safety - triage.py: triage_alert() labels each detection likely-FP vs review with a reason (package-owned binary, loopback-only listener, allowlisted comm, …); reverse_shell/egress/exec rules are ALWAYS review (provenance can't clear a network shell); unattributable listeners are reviewed, not cleared - cli: `enodia-sentinel triage` summarizes captured alerts and suggests allowlist entries - new_listener: listener_allow_comms + optional suppress_package_owned_listeners gate (the best single knob for a desktop/seedbox running P2P apps) - tests: +10 (provenance injected); README + config documented. 65/65 pass Verified on a live seedbox: 12 detections, 11 auto-cleared as FP (qbittorrent / nicotine / kdeconnectd / the dashboard itself), 1 correctly held for review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c00fff224c
commit
07f5261d59
8 changed files with 332 additions and 0 deletions
91
enodia_sentinel/triage.py
Normal file
91
enodia_sentinel/triage.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Alert triage — label a detection likely-false-positive vs needs-review.
|
||||
|
||||
Applies cheap, explainable heuristics (provenance, loopback binds, allowlists,
|
||||
drill artifacts) so a human isn't drowned in benign noise. Verdicts are advice,
|
||||
never auto-deletion. ``is_owned`` is injectable for testing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .config import Config
|
||||
from .provenance import is_package_owned
|
||||
|
||||
LIKELY_FP = "likely-fp"
|
||||
REVIEW = "review"
|
||||
|
||||
_LOOPBACK = ("127.", "::1", "[::1]")
|
||||
_WRITABLE = ("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/")
|
||||
# Signatures that provenance can never clear — a network shell or C2 channel is
|
||||
# worth a look regardless of which binary is involved.
|
||||
_ALWAYS_REVIEW = {"reverse_shell", "egress"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Verdict:
|
||||
label: str # LIKELY_FP or REVIEW
|
||||
reason: str
|
||||
suggest: str = "" # optional config line to suppress future copies
|
||||
|
||||
|
||||
def _exe_for(alert: dict, processes: list[dict]) -> str:
|
||||
pids = set(alert.get("pids", []))
|
||||
for p in processes:
|
||||
if p.get("pid") in pids:
|
||||
return p.get("exe", "")
|
||||
return ""
|
||||
|
||||
|
||||
def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
||||
is_owned: Callable[[str], bool] = is_package_owned) -> Verdict:
|
||||
sig = alert.get("signature", "")
|
||||
detail = alert.get("detail", "")
|
||||
exe = _exe_for(alert, processes)
|
||||
|
||||
if sig.startswith("exec_rule.") or sig in _ALWAYS_REVIEW:
|
||||
return Verdict(REVIEW, "network/exec signature — provenance can't clear it")
|
||||
|
||||
if sig == "ld_preload":
|
||||
m = re.search(r"LD_PRELOAD=\[([^\]]+)\]", detail)
|
||||
lib = m.group(1) if m else ""
|
||||
if lib.startswith(_WRITABLE):
|
||||
return Verdict(REVIEW, f"LD_PRELOAD from writable path: {lib}")
|
||||
if lib and is_owned(lib):
|
||||
return Verdict(LIKELY_FP, f"preload library is package-owned: {lib}")
|
||||
return Verdict(REVIEW, "library injection — verify the .so by hand")
|
||||
|
||||
if sig == "deleted_exe":
|
||||
if "memfd:" in detail:
|
||||
return Verdict(REVIEW, "memfd-backed (fileless) execution")
|
||||
if exe and is_owned(exe):
|
||||
return Verdict(LIKELY_FP, "deleted exe is package-owned (post-upgrade daemon)")
|
||||
return Verdict(REVIEW, "process running from a deleted binary")
|
||||
|
||||
if sig == "new_listener":
|
||||
addr = detail.split("socket ", 1)[-1].split(" by ", 1)[0]
|
||||
comm = detail.rsplit("comm=", 1)[-1].strip()
|
||||
port = addr.rsplit(":", 1)[-1]
|
||||
if addr.startswith(_LOOPBACK):
|
||||
return Verdict(LIKELY_FP, "loopback-only listener (not externally reachable)")
|
||||
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})")
|
||||
|
||||
if sig == "new_suid":
|
||||
path = detail.rsplit(": ", 1)[-1]
|
||||
if is_owned(path):
|
||||
return Verdict(LIKELY_FP, "SUID binary is package-owned")
|
||||
return Verdict(REVIEW, f"unowned SUID binary: {path}")
|
||||
|
||||
if sig == "persistence":
|
||||
return Verdict(REVIEW, "a persistence-relevant file changed — confirm intent")
|
||||
|
||||
return Verdict(REVIEW, "no triage rule matched")
|
||||
Loading…
Add table
Add a link
Reference in a new issue