Add file integrity monitoring (Tripwire-style), auto-refreshed via pacman hook
Detects binary/config tampering by content hash — catching a malicious swap even when mtime is preserved (the gap in mtime-based persistence checks). Two engines split by file ownership: - fim.py hash baseline: SHA-256 (+ mode/uid/gid/size) of security-critical files the package manager doesn't track (/usr/local, /etc configs, systemd units, SSH keys). The baseline refreshes ONLY via fim-update / the pacman hook, so a flagged change stays flagged until acknowledged (Tripwire semantics). Alerts: fim_modified 100017 / fim_added 100018 / fim_removed 100019. - package verification: `pacman -Qkk` checks package-owned binaries against the distro's own signed checksums — no baseline to maintain, implicitly current because the package DB updates on every upgrade. fim_pkg_modified 100020. Auto-update on system updates: a pacman PostTransaction hook runs `enodia-sentinel fim-update` after every install/upgrade/remove, so legitimate package changes never alert — no manual `tripwire --update`. - daemon: backgrounded FIM scan + optional pkg-verify on slow cadences, feeding the normal alert/snapshot/push pipeline; baseline loaded/built at startup - cli: fim-baseline / fim-update / fim-check [--packages] - config: fim_enabled, fim_paths, fim_scan_interval, fim_pkg_verify[_interval] - packaging: ship + install the pacman hook (Makefile + PKGBUILD) - tests: +7 (hashing, diff incl. mtime-preserving tamper, pacman -Qkk parse). 72/72 pass. Verified end-to-end: a content swap with preserved mtime is caught. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
07f5261d59
commit
5d577b624f
12 changed files with 467 additions and 3 deletions
2
Makefile
2
Makefile
|
|
@ -16,6 +16,7 @@ install:
|
||||||
install -Dm755 src/sentinel-redteam $(DESTDIR)$(BINDIR)/sentinel-redteam
|
install -Dm755 src/sentinel-redteam $(DESTDIR)$(BINDIR)/sentinel-redteam
|
||||||
install -Dm644 systemd/enodia-sentinel.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service
|
install -Dm644 systemd/enodia-sentinel.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service
|
||||||
install -Dm644 systemd/enodia-sentinel-web.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service
|
install -Dm644 systemd/enodia-sentinel-web.service $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service
|
||||||
|
install -Dm644 packaging/enodia-sentinel-fim.hook $(DESTDIR)/etc/pacman.d/hooks/enodia-sentinel-fim.hook
|
||||||
@if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \
|
@if [ ! -e "$(DESTDIR)$(CONFDIR)/enodia-sentinel.toml" ]; then \
|
||||||
install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \
|
install -Dm644 config/enodia-sentinel.toml $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml; \
|
||||||
echo "Installed default config at $(CONFDIR)/enodia-sentinel.toml"; \
|
echo "Installed default config at $(CONFDIR)/enodia-sentinel.toml"; \
|
||||||
|
|
@ -32,6 +33,7 @@ uninstall:
|
||||||
rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam
|
rm -f $(DESTDIR)$(BINDIR)/sentinel-redteam
|
||||||
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service
|
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel.service
|
||||||
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service
|
rm -f $(DESTDIR)$(SYSTEMDDIR)/enodia-sentinel-web.service
|
||||||
|
rm -f $(DESTDIR)/etc/pacman.d/hooks/enodia-sentinel-fim.hook
|
||||||
rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new
|
rm -f $(DESTDIR)$(CONFDIR)/enodia-sentinel.toml.new
|
||||||
@echo "Uninstalled. Config and logs preserved."
|
@echo "Uninstalled. Config and logs preserved."
|
||||||
|
|
||||||
|
|
|
||||||
34
README.md
34
README.md
|
|
@ -74,6 +74,9 @@ enodia_sentinel/
|
||||||
├── alert.py Alert / Severity (with Snort-style sid + classtype)
|
├── alert.py Alert / Severity (with Snort-style sid + classtype)
|
||||||
├── web.py read-only dashboard: stdlib http server + JSON API + auth
|
├── web.py read-only dashboard: stdlib http server + JSON API + auth
|
||||||
├── static/ the self-contained dashboard SPA
|
├── static/ the self-contained dashboard SPA
|
||||||
|
├── fim.py file integrity monitoring (hash baseline + pacman verify)
|
||||||
|
├── triage.py false-positive classification
|
||||||
|
├── provenance.py package-ownership lookups (pacman/dpkg/rpm)
|
||||||
├── detectors/ poll detectors — one module per signature, each a pure
|
├── detectors/ poll detectors — one module per signature, each a pure
|
||||||
│ function: detect(state, cfg) -> Iterable[Alert]
|
│ function: detect(state, cfg) -> Iterable[Alert]
|
||||||
├── notify/ outbound push — ntfy / Pushover / webhook backends
|
├── notify/ outbound push — ntfy / Pushover / webhook backends
|
||||||
|
|
@ -236,6 +239,33 @@ notify_ntfy_url = "https://ntfy.sh"
|
||||||
notify_ntfy_topic = "enodia-7Hq2x" # keep this secret — it's the access control
|
notify_ntfy_topic = "enodia-7Hq2x" # keep this secret — it's the access control
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File integrity monitoring (Tripwire-style)
|
||||||
|
|
||||||
|
Detects tampering with binaries and critical configs by **content hash**, so it
|
||||||
|
catches a malicious swap even when the timestamp is preserved (the weakness of
|
||||||
|
mtime-based checks). Two engines, split by who owns the file:
|
||||||
|
|
||||||
|
- **Package verification** — `pacman -Qkk` checks package-owned binaries against
|
||||||
|
the distro's *own signed checksums*. No baseline to maintain, and it's
|
||||||
|
implicitly current because the package DB updates on every `pacman -Syu`. A
|
||||||
|
trojaned `/usr/bin/ssh` surfaces as a checksum mismatch (CRITICAL, `sid 100020`).
|
||||||
|
- **Hash baseline** — a SHA-256 baseline of the files pacman *doesn't* track
|
||||||
|
(`/usr/local`, `/etc` configs, systemd units, SSH keys). A pacman
|
||||||
|
`PostTransaction` **hook** runs `fim-update` after every upgrade, so legitimate
|
||||||
|
package changes never alert — no manual `tripwire --update` ritual.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel fim-baseline # establish the baseline
|
||||||
|
enodia-sentinel fim-check # report changes vs baseline
|
||||||
|
enodia-sentinel fim-check --packages # also verify package-owned files
|
||||||
|
```
|
||||||
|
|
||||||
|
The baseline is **only** refreshed by `fim-update` / the pacman hook — a flagged
|
||||||
|
change stays flagged until you acknowledge it, exactly like Tripwire. The daemon
|
||||||
|
runs the scan on a slow background cadence (`fim_scan_interval`) and routes any
|
||||||
|
change into the normal alert/snapshot/push pipeline (`fim_modified` `sid 100017`,
|
||||||
|
`fim_added` `100018`, `fim_removed` `100019`).
|
||||||
|
|
||||||
## False positives & triage
|
## False positives & triage
|
||||||
|
|
||||||
A host IDS that cries wolf gets ignored, so Sentinel ships explicit tooling to
|
A host IDS that cries wolf gets ignored, so Sentinel ships explicit tooling to
|
||||||
|
|
@ -319,6 +349,10 @@ regression suite for both.
|
||||||
|
|
||||||
## Project status
|
## Project status
|
||||||
|
|
||||||
|
v0.5 — adds **file integrity monitoring** (SHA-256 baseline + `pacman -Qkk`
|
||||||
|
verification, auto-refreshed by a pacman hook) and **false-positive triage**
|
||||||
|
via package-ownership provenance.
|
||||||
|
|
||||||
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound,
|
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound,
|
||||||
token-auth) and **phone push** (ntfy / Pushover / webhook), both zero-dependency.
|
token-auth) and **phone push** (ntfy / Pushover / webhook), both zero-dependency.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,18 @@ watch_persistence = [
|
||||||
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
|
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# --- file integrity monitoring (FIM) ------------------------------------
|
||||||
|
# SHA-256 baseline of security-critical files the package manager doesn't track
|
||||||
|
# (configs, /usr/local, systemd units, keys). A pacman PostTransaction hook runs
|
||||||
|
# `enodia-sentinel fim-update` after every upgrade, so package changes never
|
||||||
|
# alert. Package-owned binaries are instead verified against the distro's own
|
||||||
|
# checksums via `pacman -Qkk` (fim_pkg_verify).
|
||||||
|
fim_enabled = true
|
||||||
|
fim_scan_interval = 300 # seconds between hash scans
|
||||||
|
fim_paths = [] # [] = built-in critical set; or your own list
|
||||||
|
fim_pkg_verify = false # run `pacman -Qkk` (thorough but slow)
|
||||||
|
fim_pkg_verify_interval = 21600 # seconds between package verifications
|
||||||
|
|
||||||
# --- eBPF event layer ----------------------------------------------------
|
# --- eBPF event layer ----------------------------------------------------
|
||||||
# Event-driven execve monitor: catches short-lived processes the poll loop
|
# Event-driven execve monitor: catches short-lived processes the poll loop
|
||||||
# misses, matched against the Snort-style rule engine. Requires python-bpfcc
|
# misses, matched against the Snort-style rule engine. Requires python-bpfcc
|
||||||
|
|
|
||||||
|
|
@ -6,4 +6,4 @@ captures a forensic snapshot with incident-response guidance whenever a known
|
||||||
attack signature appears. The Python re-architecture of the bash v0 prototype.
|
attack signature appears. The Python re-architecture of the bash v0 prototype.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.4.0"
|
__version__ = "0.5.0"
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,11 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
sub.add_parser("list-detectors", help="list available detectors")
|
sub.add_parser("list-detectors", help="list available detectors")
|
||||||
sub.add_parser("web", help="serve the read-only dashboard")
|
sub.add_parser("web", help="serve the read-only dashboard")
|
||||||
sub.add_parser("triage", help="classify captured alerts as likely-FP vs review")
|
sub.add_parser("triage", help="classify captured alerts as likely-FP vs review")
|
||||||
|
sub.add_parser("fim-baseline", help="build the file-integrity baseline")
|
||||||
|
sub.add_parser("fim-update", help="refresh the FIM baseline (run by the pacman hook)")
|
||||||
|
fc = sub.add_parser("fim-check", help="scan monitored files and report changes")
|
||||||
|
fc.add_argument("--packages", action="store_true",
|
||||||
|
help="also verify package-owned files via pacman -Qkk")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
cfg = Config.load(args.config)
|
cfg = Config.load(args.config)
|
||||||
|
|
@ -82,9 +87,40 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return 0
|
return 0
|
||||||
if args.cmd == "triage":
|
if args.cmd == "triage":
|
||||||
return _cmd_triage(cfg)
|
return _cmd_triage(cfg)
|
||||||
|
if args.cmd in ("fim-baseline", "fim-update"):
|
||||||
|
n = Sentinel(cfg).build_fim_baseline()
|
||||||
|
print(f"FIM baseline written: {n} files under {cfg.log_dir}")
|
||||||
|
return 0
|
||||||
|
if args.cmd == "fim-check":
|
||||||
|
return _cmd_fim_check(cfg, args.packages)
|
||||||
return _cmd_run(cfg)
|
return _cmd_run(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
|
||||||
|
from . import fim
|
||||||
|
sentinel = Sentinel(cfg)
|
||||||
|
sentinel.load_fim_baseline()
|
||||||
|
current = fim.scan_paths(cfg.fim_path_list())
|
||||||
|
d = fim.diff(sentinel.fim_baseline, current)
|
||||||
|
changed = len(d["added"]) + len(d["removed"]) + len(d["modified"])
|
||||||
|
for path, changes in d["modified"]:
|
||||||
|
print(f"[MODIFIED] {path} ({', '.join(changes)})")
|
||||||
|
for path in d["added"]:
|
||||||
|
print(f"[ADDED] {path}")
|
||||||
|
for path in d["removed"]:
|
||||||
|
print(f"[REMOVED] {path}")
|
||||||
|
if not changed:
|
||||||
|
print("FIM: no changes against baseline.")
|
||||||
|
if packages:
|
||||||
|
print("\nVerifying package-owned files (pacman -Qkk, may take a while)…")
|
||||||
|
hits = fim.pacman_verify()
|
||||||
|
for pkg, path, reason in hits:
|
||||||
|
print(f"[PKG] {path} ({pkg}: {reason})")
|
||||||
|
if not hits:
|
||||||
|
print("Packages: all verified files match the distro checksums.")
|
||||||
|
return 1 if changed else 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_triage(cfg: Config) -> int:
|
def _cmd_triage(cfg: Config) -> int:
|
||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,13 @@ class Config:
|
||||||
)
|
)
|
||||||
watch_persistence: tuple[str, ...] = _DEFAULT_WATCH
|
watch_persistence: tuple[str, ...] = _DEFAULT_WATCH
|
||||||
|
|
||||||
|
# file integrity monitoring (FIM)
|
||||||
|
fim_enabled: bool = True
|
||||||
|
fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS
|
||||||
|
fim_scan_interval: int = 300 # seconds between hash scans
|
||||||
|
fim_pkg_verify: bool = False # run `pacman -Qkk` (slow; opt-in)
|
||||||
|
fim_pkg_verify_interval: int = 21600 # seconds between package verifications
|
||||||
|
|
||||||
# eBPF on-ramp
|
# eBPF on-ramp
|
||||||
capture_execve_bpftrace: bool = False
|
capture_execve_bpftrace: bool = False
|
||||||
# Event-driven eBPF execve monitor (catches short-lived processes the poll
|
# Event-driven eBPF execve monitor (catches short-lived processes the poll
|
||||||
|
|
@ -107,6 +114,17 @@ class Config:
|
||||||
def suid_baseline(self) -> Path:
|
def suid_baseline(self) -> Path:
|
||||||
return self.log_dir / "suid-baseline.json"
|
return self.log_dir / "suid-baseline.json"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fim_baseline(self) -> Path:
|
||||||
|
return self.log_dir / "fim-baseline.json"
|
||||||
|
|
||||||
|
def fim_path_list(self) -> tuple[str, ...]:
|
||||||
|
"""Configured FIM paths, defaulting to the built-in critical set."""
|
||||||
|
if self.fim_paths:
|
||||||
|
return self.fim_paths
|
||||||
|
from .fim import DEFAULT_FIM_PATHS
|
||||||
|
return DEFAULT_FIM_PATHS
|
||||||
|
|
||||||
# ---- loading ---------------------------------------------------------
|
# ---- loading ---------------------------------------------------------
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, path: str | os.PathLike | None = None) -> "Config":
|
def load(cls, path: str | os.PathLike | None = None) -> "Config":
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@ class Sentinel:
|
||||||
self._suid_thread: threading.Thread | None = None
|
self._suid_thread: threading.Thread | None = None
|
||||||
self._last_suid_scan = 0.0
|
self._last_suid_scan = 0.0
|
||||||
self._last_suid_baseline_refresh = self.start_time
|
self._last_suid_baseline_refresh = self.start_time
|
||||||
|
# FIM: baseline + background scan state
|
||||||
|
self.fim_baseline: dict = {}
|
||||||
|
self._fim_thread: threading.Thread | None = None
|
||||||
|
self._last_fim_scan = 0.0
|
||||||
|
self._fim_pkg_thread: threading.Thread | None = None
|
||||||
|
self._last_fim_pkg = 0.0
|
||||||
self._stop = threading.Event()
|
self._stop = threading.Event()
|
||||||
|
|
||||||
# -- baselines ---------------------------------------------------------
|
# -- baselines ---------------------------------------------------------
|
||||||
|
|
@ -82,6 +88,57 @@ class Sentinel:
|
||||||
self._last_suid_baseline_refresh = time.time()
|
self._last_suid_baseline_refresh = time.time()
|
||||||
self._save(self.cfg.suid_baseline, sorted(self.suid_baseline))
|
self._save(self.cfg.suid_baseline, sorted(self.suid_baseline))
|
||||||
|
|
||||||
|
# -- FIM (off the loop thread) -----------------------------------------
|
||||||
|
def build_fim_baseline(self) -> int:
|
||||||
|
from .fim import scan_paths
|
||||||
|
self.fim_baseline = scan_paths(self.cfg.fim_path_list())
|
||||||
|
self.cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.cfg.fim_baseline.write_text(json.dumps(self.fim_baseline))
|
||||||
|
return len(self.fim_baseline)
|
||||||
|
|
||||||
|
def load_fim_baseline(self) -> None:
|
||||||
|
try:
|
||||||
|
self.fim_baseline = json.loads(self.cfg.fim_baseline.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self.fim_baseline = {}
|
||||||
|
if not self.fim_baseline:
|
||||||
|
self.build_fim_baseline()
|
||||||
|
|
||||||
|
def _maybe_scan_fim(self, now: float) -> None:
|
||||||
|
if not self.cfg.fim_enabled:
|
||||||
|
return
|
||||||
|
if self._fim_thread and self._fim_thread.is_alive():
|
||||||
|
return
|
||||||
|
if (now - self._last_fim_scan) < self.cfg.fim_scan_interval:
|
||||||
|
return
|
||||||
|
self._last_fim_scan = now
|
||||||
|
self._fim_thread = threading.Thread(target=self._scan_fim, daemon=True)
|
||||||
|
self._fim_thread.start()
|
||||||
|
|
||||||
|
def _scan_fim(self) -> None:
|
||||||
|
from .fim import diff, diff_alerts, scan_paths
|
||||||
|
current = scan_paths(self.cfg.fim_path_list())
|
||||||
|
# NB: the baseline is NOT updated here — only `fim-update` / the pacman
|
||||||
|
# hook refreshes it, so a change stays flagged until acknowledged.
|
||||||
|
for alert in diff_alerts(diff(self.fim_baseline, current)):
|
||||||
|
self._on_exec_alert(alert)
|
||||||
|
|
||||||
|
def _maybe_pkg_verify(self, now: float) -> None:
|
||||||
|
if not self.cfg.fim_pkg_verify:
|
||||||
|
return
|
||||||
|
if self._fim_pkg_thread and self._fim_pkg_thread.is_alive():
|
||||||
|
return
|
||||||
|
if (now - self._last_fim_pkg) < self.cfg.fim_pkg_verify_interval:
|
||||||
|
return
|
||||||
|
self._last_fim_pkg = now
|
||||||
|
self._fim_pkg_thread = threading.Thread(target=self._pkg_verify, daemon=True)
|
||||||
|
self._fim_pkg_thread.start()
|
||||||
|
|
||||||
|
def _pkg_verify(self) -> None:
|
||||||
|
from .fim import pacman_verify_alerts
|
||||||
|
for alert in pacman_verify_alerts():
|
||||||
|
self._on_exec_alert(alert)
|
||||||
|
|
||||||
# -- one sweep ---------------------------------------------------------
|
# -- one sweep ---------------------------------------------------------
|
||||||
def sweep(self, *, force_suid: bool = False) -> list[Alert]:
|
def sweep(self, *, force_suid: bool = False) -> list[Alert]:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
@ -132,6 +189,7 @@ class Sentinel:
|
||||||
with open(self.cfg.events_log, "a") as fh:
|
with open(self.cfg.events_log, "a") as fh:
|
||||||
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
|
fh.write(f"{time.strftime('%FT%T%z')} enodia-sentinel started\n")
|
||||||
self.build_baselines()
|
self.build_baselines()
|
||||||
|
self.load_fim_baseline()
|
||||||
snapshot.prune(self.cfg)
|
snapshot.prune(self.cfg)
|
||||||
self._start_exec_monitor()
|
self._start_exec_monitor()
|
||||||
sweeps = 0
|
sweeps = 0
|
||||||
|
|
@ -139,6 +197,8 @@ class Sentinel:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if (now - self.start_time) >= self.cfg.baseline_grace:
|
if (now - self.start_time) >= self.cfg.baseline_grace:
|
||||||
self._maybe_scan_suid(now)
|
self._maybe_scan_suid(now)
|
||||||
|
self._maybe_scan_fim(now)
|
||||||
|
self._maybe_pkg_verify(now)
|
||||||
alerts = self.sweep()
|
alerts = self.sweep()
|
||||||
fresh = self.fresh_alerts(alerts, now)
|
fresh = self.fresh_alerts(alerts, now)
|
||||||
if fresh:
|
if fresh:
|
||||||
|
|
|
||||||
181
enodia_sentinel/fim.py
Normal file
181
enodia_sentinel/fim.py
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""File integrity monitoring — the Tripwire/OSSEC-syscheck capability.
|
||||||
|
|
||||||
|
Two engines:
|
||||||
|
|
||||||
|
* **hash baseline** — SHA-256 (plus mode/uid/gid/size) of a curated set of
|
||||||
|
security-critical files that the package manager does *not* track
|
||||||
|
(``/usr/local``, ``/etc`` configs, systemd units, SSH keys). We own this
|
||||||
|
baseline and refresh it on demand (a pacman PostTransaction hook calls
|
||||||
|
``fim-update`` after every upgrade, so legitimate package changes never
|
||||||
|
alert).
|
||||||
|
|
||||||
|
* **package verification** — ``pacman -Qkk`` checks package-owned binaries
|
||||||
|
against the distro's own signed checksums. No baseline to maintain; it is
|
||||||
|
implicitly current because the package DB updates on every upgrade.
|
||||||
|
|
||||||
|
Content hashing is the upgrade over mtime checks: it catches a file swapped out
|
||||||
|
for a malicious copy even when the timestamp is preserved.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
|
|
||||||
|
from .alert import Alert, Severity
|
||||||
|
|
||||||
|
# SIDs 100017+ are FIM signatures.
|
||||||
|
SID_MODIFIED = 100017
|
||||||
|
SID_ADDED = 100018
|
||||||
|
SID_REMOVED = 100019
|
||||||
|
SID_PKG = 100020
|
||||||
|
|
||||||
|
DEFAULT_FIM_PATHS = (
|
||||||
|
"/usr/local/bin", "/usr/local/sbin", "/usr/local/lib",
|
||||||
|
"/etc/systemd/system", "/etc/ld.so.preload", "/etc/ld.so.conf",
|
||||||
|
"/etc/ld.so.conf.d", "/etc/pam.d", "/etc/ssh/sshd_config",
|
||||||
|
"/etc/sudoers", "/etc/sudoers.d", "/etc/profile", "/etc/profile.d",
|
||||||
|
"/etc/bash.bashrc", "/etc/crontab", "/etc/cron.d", "/etc/cron.daily",
|
||||||
|
"/etc/cron.hourly", "/etc/passwd", "/etc/shadow", "/etc/group",
|
||||||
|
"/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- hashing & scanning ---------------------------------------------------
|
||||||
|
|
||||||
|
def hash_file(path: str, _bufsize: int = 1 << 16) -> str | None:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while chunk := fh.read(_bufsize):
|
||||||
|
h.update(chunk)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _entry(path: str) -> dict | None:
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
e = {"mode": stat.S_IMODE(st.st_mode), "uid": st.st_uid,
|
||||||
|
"gid": st.st_gid, "size": st.st_size}
|
||||||
|
if stat.S_ISLNK(st.st_mode):
|
||||||
|
try:
|
||||||
|
e["link"] = os.readlink(path)
|
||||||
|
except OSError:
|
||||||
|
e["link"] = ""
|
||||||
|
elif stat.S_ISREG(st.st_mode):
|
||||||
|
e["sha256"] = hash_file(path)
|
||||||
|
else:
|
||||||
|
return None # skip sockets/devices/fifos
|
||||||
|
return e
|
||||||
|
|
||||||
|
|
||||||
|
def scan_paths(paths: Iterable[str]) -> dict[str, dict]:
|
||||||
|
"""Map path -> integrity entry for every regular file / symlink under paths."""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for root in paths:
|
||||||
|
if os.path.isfile(root) or os.path.islink(root):
|
||||||
|
e = _entry(root)
|
||||||
|
if e:
|
||||||
|
out[root] = e
|
||||||
|
elif os.path.isdir(root):
|
||||||
|
for dirpath, _dirs, files in os.walk(root, onerror=lambda e: None):
|
||||||
|
for f in files:
|
||||||
|
p = os.path.join(dirpath, f)
|
||||||
|
e = _entry(p)
|
||||||
|
if e:
|
||||||
|
out[p] = e
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- diffing --------------------------------------------------------------
|
||||||
|
|
||||||
|
def diff(old: dict[str, dict], new: dict[str, dict]) -> dict[str, list]:
|
||||||
|
"""Return {'added':[...], 'removed':[...], 'modified':[(path, changes)]}."""
|
||||||
|
added = sorted(set(new) - set(old))
|
||||||
|
removed = sorted(set(old) - set(new))
|
||||||
|
modified = []
|
||||||
|
for path in sorted(set(old) & set(new)):
|
||||||
|
o, n = old[path], new[path]
|
||||||
|
changes = [k for k in ("sha256", "mode", "uid", "gid", "link")
|
||||||
|
if o.get(k) != n.get(k)]
|
||||||
|
if changes:
|
||||||
|
modified.append((path, changes))
|
||||||
|
return {"added": added, "removed": removed, "modified": modified}
|
||||||
|
|
||||||
|
|
||||||
|
def diff_alerts(d: dict[str, list]) -> Iterator[Alert]:
|
||||||
|
for path, changes in d["modified"]:
|
||||||
|
crit = "sha256" in changes or "mode" in changes
|
||||||
|
yield Alert(
|
||||||
|
severity=Severity.HIGH if crit else Severity.MEDIUM,
|
||||||
|
signature="fim_modified",
|
||||||
|
key=f"fim:mod:{path}",
|
||||||
|
detail=f"integrity change ({', '.join(changes)}): {path}",
|
||||||
|
sid=SID_MODIFIED, classtype="integrity-violation",
|
||||||
|
)
|
||||||
|
for path in d["added"]:
|
||||||
|
yield Alert(Severity.MEDIUM, "fim_added", f"fim:add:{path}",
|
||||||
|
f"new file in monitored path: {path}",
|
||||||
|
sid=SID_ADDED, classtype="integrity-violation")
|
||||||
|
for path in d["removed"]:
|
||||||
|
yield Alert(Severity.MEDIUM, "fim_removed", f"fim:del:{path}",
|
||||||
|
f"monitored file removed: {path}",
|
||||||
|
sid=SID_REMOVED, classtype="integrity-violation")
|
||||||
|
|
||||||
|
|
||||||
|
# --- package verification (engine A) --------------------------------------
|
||||||
|
|
||||||
|
def parse_pacman_verify(output: str) -> list[tuple[str, str, str]]:
|
||||||
|
"""Parse ``pacman -Qkk`` diagnostics into (package, path, reason).
|
||||||
|
|
||||||
|
Only lines reporting an actual content/permission/ownership change are
|
||||||
|
returned; 'total files' summaries and 'No package owns' lines are ignored.
|
||||||
|
"""
|
||||||
|
out: list[tuple[str, str, str]] = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if "(" not in line or ")" not in line:
|
||||||
|
continue
|
||||||
|
reason = line[line.rfind("(") + 1:line.rfind(")")]
|
||||||
|
rl = reason.lower()
|
||||||
|
# Content/permission/ownership changes matter; a modification-time-only
|
||||||
|
# mismatch is benign noise (a `touch` doesn't alter the file).
|
||||||
|
if not any(k in rl for k in ("checksum", "sha", "size",
|
||||||
|
"permission", "ownership", "uid", "gid")):
|
||||||
|
continue
|
||||||
|
head = line[:line.rfind("(")].strip()
|
||||||
|
# head looks like "pkgname: /path/to/file"
|
||||||
|
if ": /" not in head:
|
||||||
|
continue
|
||||||
|
pkg, path = head.split(": /", 1)
|
||||||
|
pkg = pkg.replace("warning:", "").strip()
|
||||||
|
out.append((pkg, "/" + path.strip(), reason.strip()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def pacman_verify(timeout: int = 600) -> list[tuple[str, str, str]]:
|
||||||
|
"""Run ``pacman -Qkk`` and return altered package-owned files (best-effort)."""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(["pacman", "-Qkk"], capture_output=True,
|
||||||
|
text=True, timeout=timeout)
|
||||||
|
except (OSError, subprocess.SubprocessError):
|
||||||
|
return []
|
||||||
|
return parse_pacman_verify(r.stdout + "\n" + r.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def pacman_verify_alerts(timeout: int = 600) -> Iterator[Alert]:
|
||||||
|
for pkg, path, reason in pacman_verify(timeout):
|
||||||
|
yield Alert(
|
||||||
|
severity=Severity.CRITICAL,
|
||||||
|
signature="fim_pkg_modified",
|
||||||
|
key=f"fim:pkg:{path}",
|
||||||
|
detail=f"package file altered ({pkg}: {reason}): {path}",
|
||||||
|
sid=SID_PKG, classtype="integrity-violation",
|
||||||
|
)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Maintainer: Enodia
|
# Maintainer: Enodia
|
||||||
pkgname=enodia-sentinel
|
pkgname=enodia-sentinel
|
||||||
pkgver=0.4.0
|
pkgver=0.5.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
pkgdesc="Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|
@ -23,6 +23,7 @@ package() {
|
||||||
install -Dm755 src/sentinel-redteam "$pkgdir/usr/bin/sentinel-redteam"
|
install -Dm755 src/sentinel-redteam "$pkgdir/usr/bin/sentinel-redteam"
|
||||||
install -Dm644 systemd/enodia-sentinel.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel.service"
|
install -Dm644 systemd/enodia-sentinel.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel.service"
|
||||||
install -Dm644 systemd/enodia-sentinel-web.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel-web.service"
|
install -Dm644 systemd/enodia-sentinel-web.service "$pkgdir/usr/lib/systemd/system/enodia-sentinel-web.service"
|
||||||
|
install -Dm644 packaging/enodia-sentinel-fim.hook "$pkgdir/usr/share/libalpm/hooks/enodia-sentinel-fim.hook"
|
||||||
install -Dm644 config/enodia-sentinel.toml "$pkgdir/etc/enodia-sentinel.toml"
|
install -Dm644 config/enodia-sentinel.toml "$pkgdir/etc/enodia-sentinel.toml"
|
||||||
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||||
install -dm750 "$pkgdir/var/log/enodia-sentinel"
|
install -dm750 "$pkgdir/var/log/enodia-sentinel"
|
||||||
|
|
|
||||||
18
packaging/enodia-sentinel-fim.hook
Normal file
18
packaging/enodia-sentinel-fim.hook
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Refresh the Enodia Sentinel file-integrity baseline after every package
|
||||||
|
# transaction, so legitimate package changes never trigger a FIM alert.
|
||||||
|
# Install as: /etc/pacman.d/hooks/enodia-sentinel-fim.hook
|
||||||
|
[Trigger]
|
||||||
|
Operation = Install
|
||||||
|
Operation = Upgrade
|
||||||
|
Operation = Remove
|
||||||
|
Type = Path
|
||||||
|
Target = usr/*
|
||||||
|
Target = etc/*
|
||||||
|
Target = boot/*
|
||||||
|
Target = opt/*
|
||||||
|
|
||||||
|
[Action]
|
||||||
|
Description = Refreshing Enodia Sentinel file-integrity baseline...
|
||||||
|
When = PostTransaction
|
||||||
|
Exec = /usr/bin/env enodia-sentinel fim-update
|
||||||
|
Depends = python
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "enodia-sentinel"
|
name = "enodia-sentinel"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
description = "Host intrusion-detection daemon — signature-based detection of reverse shells, LD_PRELOAD rootkits, fileless malware, and persistence tampering"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
|
||||||
102
tests/test_fim.py
Normal file
102
tests/test_fim.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Tests for file integrity monitoring: hashing, diff, alerts, pacman parse."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from enodia_sentinel import fim
|
||||||
|
from enodia_sentinel.alert import Severity
|
||||||
|
|
||||||
|
|
||||||
|
class TestHashAndScan(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.d = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self.d.name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.d.cleanup()
|
||||||
|
|
||||||
|
def test_hash_stable_and_sensitive(self):
|
||||||
|
p = self.tmp / "f"
|
||||||
|
p.write_text("hello")
|
||||||
|
h1 = fim.hash_file(str(p))
|
||||||
|
self.assertEqual(h1, fim.hash_file(str(p)))
|
||||||
|
p.write_text("hell0")
|
||||||
|
self.assertNotEqual(h1, fim.hash_file(str(p)))
|
||||||
|
|
||||||
|
def test_scan_collects_entries(self):
|
||||||
|
(self.tmp / "a").write_text("x")
|
||||||
|
(self.tmp / "b").write_text("y")
|
||||||
|
scan = fim.scan_paths([str(self.tmp)])
|
||||||
|
self.assertEqual(len(scan), 2)
|
||||||
|
self.assertIn("sha256", next(iter(scan.values())))
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiff(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.d = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self.d.name)
|
||||||
|
(self.tmp / "keep").write_text("keep")
|
||||||
|
(self.tmp / "gone").write_text("gone")
|
||||||
|
self.base = fim.scan_paths([str(self.tmp)])
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.d.cleanup()
|
||||||
|
|
||||||
|
def test_modified_detected_with_preserved_mtime(self):
|
||||||
|
p = self.tmp / "keep"
|
||||||
|
st = p.stat()
|
||||||
|
p.write_text("TAMPERED")
|
||||||
|
os.utime(p, (st.st_atime, st.st_mtime)) # preserve mtime
|
||||||
|
new = fim.scan_paths([str(self.tmp)])
|
||||||
|
d = fim.diff(self.base, new)
|
||||||
|
self.assertIn((str(p), ["sha256"]), [(x, c) for x, c in d["modified"]])
|
||||||
|
|
||||||
|
def test_added_and_removed(self):
|
||||||
|
(self.tmp / "new").write_text("n")
|
||||||
|
(self.tmp / "gone").unlink()
|
||||||
|
d = fim.diff(self.base, fim.scan_paths([str(self.tmp)]))
|
||||||
|
self.assertIn(str(self.tmp / "new"), d["added"])
|
||||||
|
self.assertIn(str(self.tmp / "gone"), d["removed"])
|
||||||
|
|
||||||
|
def test_alerts_have_sids(self):
|
||||||
|
(self.tmp / "x").write_text("x")
|
||||||
|
d = fim.diff(self.base, fim.scan_paths([str(self.tmp)]))
|
||||||
|
alerts = list(fim.diff_alerts(d))
|
||||||
|
sids = {a.sid for a in alerts}
|
||||||
|
self.assertIn(fim.SID_ADDED, sids)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPacmanParse(unittest.TestCase):
|
||||||
|
SAMPLE = """\
|
||||||
|
warning: qbittorrent: /usr/bin/qbittorrent (SHA256 checksum mismatch)
|
||||||
|
openssh: /usr/bin/ssh (Permissions mismatch)
|
||||||
|
coreutils: 105 total files, 1 altered files
|
||||||
|
warning: No package owns /usr/local/bin/custom
|
||||||
|
bar: /etc/bar.conf (Modification time mismatch)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_parse(self):
|
||||||
|
hits = fim.parse_pacman_verify(self.SAMPLE)
|
||||||
|
paths = {p for _pkg, p, _r in hits}
|
||||||
|
self.assertIn("/usr/bin/qbittorrent", paths)
|
||||||
|
self.assertIn("/usr/bin/ssh", paths)
|
||||||
|
# mtime-only and summary/no-owner lines are ignored
|
||||||
|
self.assertNotIn("/etc/bar.conf", paths)
|
||||||
|
self.assertEqual(len(hits), 2)
|
||||||
|
|
||||||
|
def test_pkg_alert_is_critical(self):
|
||||||
|
# exercise the alert builder via a monkeypatched verify
|
||||||
|
orig = fim.pacman_verify
|
||||||
|
fim.pacman_verify = lambda timeout=600: [("openssh", "/usr/bin/ssh", "SHA256 checksum mismatch")]
|
||||||
|
try:
|
||||||
|
alerts = list(fim.pacman_verify_alerts())
|
||||||
|
finally:
|
||||||
|
fim.pacman_verify = orig
|
||||||
|
self.assertEqual(alerts[0].severity, Severity.CRITICAL)
|
||||||
|
self.assertEqual(alerts[0].sid, fim.SID_PKG)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue