enodia-sentinal/enodia_sentinel/provenance.py
Luna 07f5261d59 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>
2026-05-31 22:05:00 -07:00

63 lines
2.2 KiB
Python

# 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