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
63
enodia_sentinel/provenance.py
Normal file
63
enodia_sentinel/provenance.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue