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:
Luna 2026-05-31 22:05:00 -07:00
parent c00fff224c
commit 07f5261d59
8 changed files with 332 additions and 0 deletions

View file

@ -62,6 +62,7 @@ def main(argv: list[str] | None = None) -> int:
sub.add_parser("baseline", help="rebuild listener/SUID baselines")
sub.add_parser("list-detectors", help="list available detectors")
sub.add_parser("web", help="serve the read-only dashboard")
sub.add_parser("triage", help="classify captured alerts as likely-FP vs review")
args = parser.parse_args(argv)
cfg = Config.load(args.config)
@ -79,8 +80,53 @@ def main(argv: list[str] | None = None) -> int:
from .web import serve
serve(cfg)
return 0
if args.cmd == "triage":
return _cmd_triage(cfg)
return _cmd_run(cfg)
def _cmd_triage(cfg: Config) -> int:
import json
from collections import OrderedDict
from .triage import LIKELY_FP, triage_alert
seen: OrderedDict[tuple, dict] = OrderedDict()
for p in sorted(cfg.log_dir.glob("alert-*.json")):
try:
d = json.loads(p.read_text())
except (OSError, ValueError):
continue
procs = d.get("processes", [])
for a in d.get("alerts", []):
key = (a.get("signature"), a.get("detail", "").split(" cmd=")[0])
if key in seen:
seen[key]["count"] += 1
continue
v = triage_alert(a, procs, cfg)
seen[key] = {"alert": a, "verdict": v, "count": 1}
if not seen:
print("No alerts to triage.")
return 0
fp = sum(1 for e in seen.values() if e["verdict"].label == LIKELY_FP)
print(f"{len(seen)} distinct detections — {fp} likely false-positive, "
f"{len(seen) - fp} to review.\n")
suggestions = set()
for e in sorted(seen.values(), key=lambda e: e["verdict"].label):
a, v = e["alert"], e["verdict"]
tag = "FP " if v.label == LIKELY_FP else "REVIEW"
print(f"[{tag}] {a.get('signature'):14} x{e['count']:<3} {v.reason}")
print(f" {a.get('detail', '')[:100]}")
if v.suggest:
suggestions.add(v.suggest)
if suggestions:
print("\nTo suppress the false positives, add to your config:")
for s in sorted(suggestions):
print(f" # {s}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -46,6 +46,11 @@ class Config:
interpreters: frozenset[str] = frozenset(_DEFAULT_INTERPRETERS)
egress_allow_cidrs: tuple[str, ...] = ()
listener_allow_ports: frozenset[str] = frozenset()
# Listeners owned by these process names never alert (e.g. P2P clients).
listener_allow_comms: frozenset[str] = frozenset()
# Suppress new_listener when the listening binary is package-owned (strong
# provenance signal; off by default so the detector stays honest).
suppress_package_owned_listeners: bool = False
suid_hot_dirs: tuple[str, ...] = (
"/tmp", "/dev/shm", "/var/tmp", "/home", "/run/user",
)

View file

@ -11,6 +11,7 @@ from collections.abc import Iterator
from ..alert import Alert, Severity
from ..config import Config
from ..provenance import is_package_owned
from ..system import SystemState
@ -25,6 +26,14 @@ def detect(state: SystemState, cfg: Config) -> Iterator[Alert]:
continue
if port in cfg.listener_allow_ports:
continue
if comm in cfg.listener_allow_comms:
continue
# Optional provenance gate: a listener whose binary ships with an
# installed package is very likely legitimate (P2P clients, servers).
if cfg.suppress_package_owned_listeners and s.pid:
proc = state.process(s.pid)
if proc and proc.exe and is_package_owned(proc.exe):
continue
yield Alert(
severity=Severity.HIGH,
signature="new_listener",

View file

@ -0,0 +1,63 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Provenance checks — is a file shipped by an installed package?
The single most effective false-positive filter on a managed Linux system is
*known-good provenance*: a binary owned by the package manager is overwhelmingly
likely to be legitimate (the same principle behind OSSEC rootcheck and AIDE).
We support pacman / dpkg / rpm and cache lookups.
Caveat by design: package ownership *raises confidence*, it does not prove
safety a compromised or hijacked package-owned binary is still possible. So
this is used to *rank* and *explain* alerts, never to silently delete them.
"""
from __future__ import annotations
import functools
import shutil
import subprocess
@functools.lru_cache(maxsize=1)
def _tool() -> str | None:
for name in ("pacman", "dpkg", "rpm"):
if shutil.which(name):
return name
return None
def _query(tool: str, path: str) -> str | None:
try:
if tool == "pacman":
r = subprocess.run(["pacman", "-Qo", path], capture_output=True,
text=True, timeout=5)
if r.returncode == 0 and "owned by" in r.stdout:
return r.stdout.split("owned by", 1)[1].strip()
elif tool == "dpkg":
r = subprocess.run(["dpkg", "-S", path], capture_output=True,
text=True, timeout=5)
if r.returncode == 0 and ":" in r.stdout:
return r.stdout.split(":", 1)[0].strip()
elif tool == "rpm":
r = subprocess.run(["rpm", "-qf", path], capture_output=True,
text=True, timeout=5)
if r.returncode == 0 and "not owned" not in r.stdout:
return r.stdout.strip().splitlines()[0]
except (OSError, subprocess.SubprocessError):
return None
return None
@functools.lru_cache(maxsize=2048)
def package_owner(path: str) -> str | None:
"""Return the owning package (e.g. ``qbittorrent 5.2.1-1``) or None."""
if not path or path == "(deleted)":
return None
path = path.replace(" (deleted)", "")
tool = _tool()
if not tool:
return None
return _query(tool, path)
def is_package_owned(path: str) -> bool:
return package_owner(path) is not None

91
enodia_sentinel/triage.py Normal file
View 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")