From 3e5f8fc3f702c1bebfd60e0332818d1b3ad0670b Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 12 Jun 2026 04:57:11 -0700 Subject: [PATCH] Expand rootkit monitoring and Claude docs --- CLAUDE.md | 103 ++++++++++++++++ README.md | 26 +++-- config/enodia-sentinel.toml | 6 +- docs/COMMAND_REFERENCE.md | 8 ++ docs/OPERATIONS.md | 8 +- docs/ROADMAP.md | 56 ++++++++- docs/RUNBOOKS.md | 18 ++- docs/SPECIFICATION.md | 2 +- docs/THREAT_MODEL.md | 2 +- enodia_sentinel/cli.py | 5 +- enodia_sentinel/config.py | 3 + enodia_sentinel/rootcheck.py | 162 +++++++++++++++++++++++++- enodia_sentinel/static/dashboard.html | 55 +++++++-- enodia_sentinel/web.py | 19 +++ tests/test_rootcheck.py | 63 +++++++++- tests/test_web.py | 20 ++++ 16 files changed, 524 insertions(+), 32 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..05a8b45 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,103 @@ +# CLAUDE.md + +Guidance for Claude or Claude Code agents working on Enodia Sentinel. + +## Project Shape + +Enodia Sentinel is a zero-runtime-dependency Linux host security platform. The +core agent is pure Python standard library and should stay that way unless a +dependency has clear security/operator value that cannot reasonably be achieved +with stdlib. + +Primary functions: + +- Detect: poll detectors plus optional eBPF exec rules. +- Verify: FIM, package DB anchor, signed-package verification, rootcheck. +- Investigate: text/JSON snapshots, incidents, HTTPS management console. +- Respond: dry-run plans first; any state-changing action must be explicit, + logged, and tested. +- Assure: heartbeat, watchdog, self-integrity, future external anchors. + +Start with: + +- `README.md` for product overview and architecture. +- `docs/SPECIFICATION.md` for the product model and acceptance criteria. +- `docs/ROADMAP.md` for sequenced work. +- `docs/COMMAND_REFERENCE.md` for CLI contracts. +- `docs/OPERATIONS.md` and `docs/RUNBOOKS.md` for operator workflows. +- `docs/THREAT_MODEL.md` for security boundaries and non-goals. + +## Engineering Rules + +- Keep the daemon stdlib-only and useful without a dashboard, collector, or + network. +- Prefer read-only detection and dry-run response until schemas and audit logs + are tested and documented. +- Every new detector, rootcheck, response action, or event rule needs a stable + `sid`, `signature`, `classtype`, tests, and operator documentation. +- Do not add stealth, self-hiding, persistence tricks, or automatic destructive + remediation. +- Preserve the bash prototype/red-team harness as the behavioral oracle for core + signatures. +- If you add config keys, update `config/enodia-sentinel.toml`, README, and the + relevant docs. + +## Current Architecture + +- `enodia_sentinel/cli.py` is the command entry point. +- `daemon.py` runs sweep/cooldown/background tasks. +- `system.py` builds one cached injectable view of processes and sockets. +- `detectors/` contains pure poll detectors. +- `events/` contains optional eBPF exec monitoring and declarative rules. +- `snapshot.py` writes forensic `.log` and `.json` evidence. +- `incident.py` groups snapshots by process lineage and time window. +- `respond.py` builds read-only response plans from incident evidence. +- `web.py` serves the HTTPS-only management console and JSON APIs. +- `rootcheck.py` performs anti-rootkit cross-view checks: + hidden processes, hidden modules, hidden TCP/UDP ports, promiscuous + interfaces, known LKM rootkit module names, and kernel/module taint. +- `posture.py` performs advisory host hygiene checks. + +## Development Commands + +```bash +python3 -m unittest discover -s tests -v +python3 -m enodia_sentinel.cli check +python3 -m enodia_sentinel.cli rootcheck +python3 -m enodia_sentinel.cli posture check --json +python3 -m enodia_sentinel.cli incident list +python3 -m enodia_sentinel.cli respond plan --json +python3 -m enodia_sentinel.cli web +``` + +The web dashboard is HTTPS-only. In development it auto-generates a self-signed +certificate under `log_dir`; tests use an unverified TLS context for that local +certificate. + +## Testing Notes + +- Unit tests are stdlib `unittest`; `pytest` is optional, not required. +- Most detectors are pure functions over injectable `SystemState`. +- Rootcheck tests monkeypatch system views instead of depending on live `/proc`, + `/sys`, or `ss`. +- Web tests bind a local HTTPS socket and may require permission in sandboxed + environments. +- Run the full suite before committing: + +```bash +python3 -m unittest discover -s tests -v +``` + +## Documentation Checklist + +When changing behavior, update the relevant set: + +- `README.md` for visible product/architecture behavior. +- `docs/COMMAND_REFERENCE.md` for CLI/API-visible commands. +- `docs/OPERATIONS.md` for routine operator workflow. +- `docs/RUNBOOKS.md` for alert response guidance. +- `docs/SPECIFICATION.md` for product/data-model changes. +- `docs/THREAT_MODEL.md` for trust-boundary/security-model changes. +- `docs/ROADMAP.md` for planned or completed roadmap movement. +- `config/enodia-sentinel.toml` for config knobs. + diff --git a/README.md b/README.md index 976604c..3274210 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,11 @@ enodia_sentinel/ ├── config.py dataclass config (TOML + env overrides) ├── netutil.py public-IP / CIDR logic (stdlib ipaddress) ├── alert.py Alert / Severity (with Snort-style sid + classtype) -├── web.py read-only dashboard: stdlib http server + JSON API + auth +├── web.py HTTPS read-only management console + JSON API + auth ├── static/ the self-contained dashboard SPA ├── fim.py file integrity monitoring (hash baseline + pacman verify) ├── pkgdb.py package-DB integrity + signed-package verification -├── rootcheck.py anti-rootkit cross-view (hidden procs/modules/ports) +├── rootcheck.py anti-rootkit cross-view, module names, kernel/module taint ├── selfprotect.py self-integrity footprint + dead-man's-switch heartbeat ├── triage.py false-positive classification ├── provenance.py package-ownership lookups (pacman/dpkg/rpm) @@ -251,10 +251,11 @@ sudo systemctl enable --now enodia-sentinel-web - **Bearer-token auth** (constant-time check); the token is auto-generated and saved on first run and printed in the startup line. Open `https://:8787/?token=…`. -- **Read-only management**: incidents, timelines, alert inventory, event tail, - and dry-run response plans. No commands are executed from the browser. JSON +- **Read-only management**: incidents, timelines, alert inventory, posture + findings, event tail, and dry-run response plans. No commands are executed + from the browser. JSON API at `/api/status`, `/api/incidents`, `/api/respond/plan/`, - `/api/alerts`, `/api/alerts/`, `/api/events`. + `/api/posture`, `/api/alerts`, `/api/alerts/`, `/api/events`. ## Phone push notifications @@ -366,13 +367,21 @@ compare the answers.** A discrepancy is the hiding artifact. | `/sys/module` (initstate=live) vs `/proc/modules` | a loaded LKM hidden from the module list | 100023 | | `/proc/net/tcp` vs `ss` | a listening port a hooked `ss` won't report | 100024 | | `/sys/class/net/*/flags` | an interface in promiscuous mode (a sniffer) | 100025 | +| known LKM rootkit module names | Diamorphine/Reptile/Adore-style module artifacts | 100028 | +| `/sys/module/*/taint` | out-of-tree/proprietary/unsigned loaded modules needing review | 100029 | +| `/proc/sys/kernel/tainted` | global kernel taint: forced loads/unloads, unsigned modules, warnings | 100030 | +| `/proc/net/udp` vs `ss -u` | a UDP socket a hooked `ss` won't report | 100031 | ```bash enodia-sentinel rootcheck # one-shot cross-view scan ``` The daemon runs it on a slow background cadence (`rootcheck_interval`) and routes -any finding into the normal alert/snapshot/push pipeline. **Honest limits:** this +any finding into the normal alert/snapshot/push pipeline. If you intentionally +run tainted vendor/DKMS modules, add exact names to `rootcheck_module_allow`. +Known rootkit module names are never suppressed by that allowlist. + +**Honest limits:** this runs in user space, so it reliably catches userland (`LD_PRELOAD`) rootkits and the common `/proc`-hiding LKMs, but a kernel rootkit that hooks *every* path consistently can still evade it. It raises the bar and catches the common cases; @@ -488,8 +497,9 @@ v0.7 — closes the tamper-evidence loop with the **independent anchor**: signed-package verification (compares on-disk files to the `.MTREE` in the signed cache package, surviving a rewritten checksum DB) plus a `SigLevel`-downgrade check, and an **anti-rootkit cross-view** layer (hidden processes, modules, -ports, and promiscuous interfaces — each caught by asking the same question two -ways and diffing the answers). +TCP/UDP ports, promiscuous interfaces, known LKM rootkit names, and +kernel/module taint — each caught by asking independent views and diffing the +answers). v0.6 — adds **tamper-evidence**: out-of-band package-DB integrity (catches rewritten checksums), self-integrity of Sentinel's own footprint, and a diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 5d957d2..3f26c2e 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -87,10 +87,14 @@ pkgdb_pkgverify_sample = 40 # packages verified per pass (rotates) # --- anti-rootkit cross-view -------------------------------------------- # Ask the same question two ways and diff the answers: kill(0) vs /proc for # hidden processes, /sys/module vs /proc/modules for hidden LKMs, /proc/net/tcp -# vs ss for hidden listeners, and interface flags for promiscuous sniffing. +# vs ss for hidden TCP/UDP listeners, interface flags for promiscuous sniffing, +# known rootkit module names, per-module taint, and global kernel taint. rootcheck_enabled = true rootcheck_interval = 300 # seconds between cross-view sweeps rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0) +# Legitimate tainted modules you accept (exact module names), e.g. a vendor GPU +# or virtualization driver. Known rootkit module names are never suppressed here. +rootcheck_module_allow = [] # --- incident grouping --------------------------------------------------- # Collapse related alerts into one incident: process-lineage first (shared PID diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 04b3892..77bd489 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -76,7 +76,11 @@ Runs anti-rootkit cross-view checks once: - PIDs alive via `kill(pid, 0)` but missing from `/proc`. - Live modules in `/sys/module` but missing from `/proc/modules`. - Listening TCP ports in `/proc/net/tcp*` but missing from `ss`. +- UDP ports in `/proc/net/udp*` but missing from `ss -u`. - Network interfaces in promiscuous mode. +- Known rootkit module names. +- Tainted loaded modules (`/sys/module/*/taint`) and global kernel taint + (`/proc/sys/kernel/tainted`). Exit code: @@ -261,6 +265,10 @@ HTTPS is mandatory. If `web_tls_cert` / `web_tls_key` are not configured, Sentinel creates a self-signed pair under `log_dir`; add a browser exception for that certificate or replace it with a private/local CA certificate. +The console exposes incidents, alert snapshots, posture findings, event tail, +and dry-run response plans. It remains read-only; response commands are displayed +for review but not executed from the browser. + Expected use: `enodia-sentinel-web.service`. ### `triage` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 8430805..f910c43 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -66,11 +66,15 @@ Expected healthy output: acknowledged. - `pkgdb-check`: package DB consistent with anchor. - `pkgdb-verify`: sampled files match signed cache packages. -- `rootcheck`: no hidden processes, modules, ports, or sniffers. +- `rootcheck`: no hidden processes/modules/ports, sniffers, known rootkit + modules, or unexplained kernel/module taint. - `posture check`: no SSH/sudo/PATH/permission/signature hygiene findings, or only ones you have consciously accepted (e.g. password auth on a host that needs it). +The HTTPS dashboard shows the same posture findings under the Posture tab for +quick remote review. + ## Alert Workflow When Sentinel fires: @@ -125,7 +129,7 @@ When Sentinel fires: | `fim_modified` | Verify package ownership, package checksum, and whether the change followed an upgrade. | | `pkgdb_tamper` | Treat as high-confidence tampering until a legitimate package transaction explains it. | | `pkg_signature_mismatch` | Treat as a potentially trojaned package-owned file. | -| `hidden_*` rootcheck alerts | Compare live tools, preserve evidence, and consider offline analysis. | +| `rootkit_*` / `kernel_tainted` alerts | Compare live tools, review module provenance, preserve evidence, and consider offline analysis. | ## Baseline Hygiene diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7e06a5b..cdde6ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -95,6 +95,13 @@ Exit criteria: Purpose: reduce blind spots and connect related signals. +- Generalize the exec-rule engine into a typed host-event rule engine: + `exec`, `tcp_connect`, `bind`, `listen`, `accept`, `file_write`, + `chmod/chown`, `setuid/setgid`, `capset`, and module-load events. +- Keep rules declarative and Snort-like: + `sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as + process name, parent process, argv regex, path prefix, peer IP/port, UID/GID, + package ownership, and lineage. - Add eBPF event sources: `tcp_connect`, `bind`, `accept`, module load, privilege transitions, and writes to watched persistence paths. @@ -102,13 +109,60 @@ Purpose: reduce blind spots and connect related signals. - Correlate multi-stage behavior: web service spawns shell, shell downloads payload, payload adds persistence, listener appears, and FIM changes. -- Add configurable correlation windows and severity escalation rules. +- Add configurable correlation windows and severity escalation rules, including: + - `exec_rule.web-rce` + suspicious egress + persistence within 10 minutes → + CRITICAL multi-stage intrusion. + - `new_listener` + `new_suid` in the same lineage/window → CRITICAL backdoor + with privilege-escalation artifact. + - `fim_modified` or `pkg_signature_mismatch` + `pkgdb_tamper` → CRITICAL + suspected trojaned binary with checksum cover-up. + - web/database service spawning shell + tool transfer (`curl|wget|sh`) → + CRITICAL likely RCE payload staging. +- Add high-signal living-off-the-land detections: + `curl|wget | sh`, Python/perl/bash reverse-shell argv variants, + `systemctl enable` from unusual ancestry, `chmod +s` outside package + transactions, interpreter egress to unusual public ports, and execution from + writable directories. +- Add first-seen and rarity tracking for local network behavior: + first public destination per process name, first listening port per binary, + interpreter connections to non-standard ports, and long-lived low-byte + connections when socket sampling can support it. - Keep polling as the oracle and fallback. Exit criteria: - Short-lived network and persistence actions are visible. - Related alerts collapse into one incident with a readable timeline. +- Multi-stage correlation raises one higher-confidence incident without hiding + the underlying raw alerts. + +## v1.1.1: IDS Quality and Rule Operations + +Purpose: make detection coverage easier to audit, tune, and extend without +turning Sentinel into a noisy rules dump. + +- Add `enodia-sentinel rules list` and `rules show ` for built-in and + configured rules. +- Add `enodia-sentinel rules test ` so operators can validate custom + event rules against captured or fixture events. +- Generate rule documentation from source defaults: SID, signature, classtype, + event type, match fields, expected false positives, and drill coverage. +- Require a fixture or safe red-team drill for every built-in SID, including + event-only and correlation SIDs. +- Add compatibility tests for rule JSON/event schema evolution. +- Extend triage so false-positive suggestions are tied to SID/classtype and can + emit TOML snippets for allowlists, correlation windows, or severity overrides. +- Add post-alert enrichment consistently across detections: + package owner, executable hash, parent/ancestor chain, remote IP + classification, file mode/owner, recent writes by same lineage, and current + FIM/package/rootcheck status. + +Exit criteria: + +- Operators can inspect, test, document, and tune detection rules without + reading Python source. +- Every shipped IDS rule has reproducible fixture/drill coverage and stable + operator documentation. ## v1.2: Fleet Mode diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index 1483a77..86f5fc3 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -165,7 +165,9 @@ the transaction completes; a persistent mismatch is the real finding. ## Runbook 4 — Hidden listener / rootkit **Triggers:** `rootkit_hidden_process` (100022), `rootkit_hidden_module` (100023), -`rootkit_hidden_port` (100024), `promiscuous_interface` (100025), and +`rootkit_hidden_port` (100024), `promiscuous_interface` (100025), +`rootkit_known_module` (100028), `rootkit_tainted_module` (100029), +`kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031), and `new_listener` (100013) for an unexplained open port. These come from cross-view checks: the same question asked two ways, answered @@ -182,8 +184,16 @@ differently, because something is hiding. - `new_listener`: identify the owning process and whether its binary is package-owned. A hidden port that `rootcheck` sees but `ss` does not is far more serious than a forgotten service. +- `rootkit_hidden_udp_port`: check whether a legitimate UDP service owns the + port; a port present in `/proc/net/udp*` but absent from `ss -u` suggests tool + output may be hooked. - A `rootkit_hidden_module` names a live kernel module absent from `/proc/modules` — strong evidence of an LKM rootkit. +- A `rootkit_known_module` finding names a module associated with public LKM + rootkits; treat it as a high-confidence compromise until proven otherwise. +- A `rootkit_tainted_module` or `kernel_tainted` finding may be legitimate + vendor/DKMS software, but unsigned/out-of-tree or force-loaded modules should + be explained and allowlisted only after review. **Contain** @@ -197,8 +207,10 @@ differently, because something is hiding. reliably clean a host whose kernel is hooked. Preserve the disk image first. **False positives:** PID exit races (Sentinel re-verifies hidden PIDs to reduce -these) and a legitimately promiscuous interface (a sniffer/monitoring tool you -run). Confirm the owning process before escalating. +these), a legitimately promiscuous interface (a sniffer/monitoring tool you +run), and expected tainted vendor/DKMS modules. Confirm the owning process or +module provenance before escalating; use `rootcheck_module_allow` only for +modules you intentionally trust. --- diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index f815f24..85b8fd1 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -76,7 +76,7 @@ Sentinel includes several integrity layers: | Package DB anchor | Detects out-of-band edits to `/var/lib/pacman/local`. | | Signed-package anchor | Compares on-disk files to `.MTREE` hashes from cached signed packages. | | Heartbeat | Writes daemon liveness for local dashboard and external watchdog use. | -| Rootcheck | Cross-view checks for hidden processes, hidden modules, hidden ports, and promiscuous interfaces. | +| Rootcheck | Cross-view checks for hidden processes, hidden modules, hidden TCP/UDP ports, promiscuous interfaces, known rootkit modules, and kernel/module taint. | ### Evidence Capture diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 071d22b..69a5cb0 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -27,7 +27,7 @@ Sentinel is designed to help against: | Local privilege escalation | New SUID helper, dropped setuid shell, writable path abuse | Detect new SUID/SGID and critical writable-directory placement. | | Fileless or short-lived execution | Deleted executable, memfd payload, fast `curl|sh` | Detect deleted executables and eBPF exec rules where available. | | Package/file tampering | Trojaned binary, rewritten package DB checksums | Detect FIM drift, package DB tamper, and signed-package mismatches. | -| Common rootkit hiding | LD_PRELOAD tricks, `/proc` hiding, module-list hiding, hidden listener | Detect LD_PRELOAD and cross-view inconsistencies. | +| Common rootkit hiding | LD_PRELOAD tricks, `/proc` hiding, module-list hiding, hidden TCP/UDP sockets, tainted modules | Detect LD_PRELOAD, cross-view inconsistencies, known LKM names, and kernel/module taint. | | Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. | ## Trust Boundaries diff --git a/enodia_sentinel/cli.py b/enodia_sentinel/cli.py index 2bc1da2..5b4c7f1 100644 --- a/enodia_sentinel/cli.py +++ b/enodia_sentinel/cli.py @@ -74,7 +74,7 @@ def main(argv: list[str] | None = None) -> int: pv.add_argument("--sample", type=int, default=0, help="packages to verify (0 = config default; rotates)") sub.add_parser("rootcheck", - help="anti-rootkit cross-view: hidden procs/modules/ports") + help="anti-rootkit cross-view and kernel/module taint checks") st = sub.add_parser("status", help="print daemon health/alert summary") st.add_argument("--json", action="store_true", help="emit status as JSON") inc = sub.add_parser("incident", @@ -197,7 +197,8 @@ def _cmd_rootcheck(cfg: Config) -> int: from . import rootcheck alerts = list(rootcheck.run(cfg)) if not alerts: - print("Rootcheck: no hidden processes, modules, ports, or sniffers found.") + print("Rootcheck: no hidden processes/modules/ports, sniffers, " + "known rootkit modules, or kernel taint found.") return 0 for a in sorted(alerts, key=lambda x: -x.severity): print(f"[{a.severity}] {a.signature:<22} {a.detail}") diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 1e4a622..6a7300a 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -82,6 +82,9 @@ class Config: rootcheck_enabled: bool = True rootcheck_interval: int = 300 # seconds between cross-view sweeps rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0) + # Legitimate out-of-tree/proprietary/unsigned modules accepted by operator + # policy (e.g. vendor GPU or virtualization drivers). + rootcheck_module_allow: tuple[str, ...] = () # incident grouping (collapse related alerts by process lineage, then time) incident_tracking: bool = True diff --git a/enodia_sentinel/rootcheck.py b/enodia_sentinel/rootcheck.py index e78059b..43eb6c7 100644 --- a/enodia_sentinel/rootcheck.py +++ b/enodia_sentinel/rootcheck.py @@ -28,9 +28,41 @@ SID_HIDDEN_PROC = 100022 SID_HIDDEN_MODULE = 100023 SID_HIDDEN_PORT = 100024 SID_PROMISC = 100025 +SID_KNOWN_ROOTKIT_MODULE = 100028 +SID_TAINTED_MODULE = 100029 +SID_KERNEL_TAINTED = 100030 +SID_HIDDEN_UDP_PORT = 100031 IFF_PROMISC = 0x100 +KNOWN_ROOTKIT_MODULES = frozenset({ + "adore", "adore-ng", "azazel", "diamorphine", "ipsecs_kbeast", + "kbeast", "knark", "reptile", "suterusu", "syslogk", +}) + +TAINT_FLAGS = { + 0: "proprietary-module", + 1: "forced-module-load", + 2: "unsafe-smp", + 3: "forced-module-unload", + 4: "machine-check", + 5: "bad-page", + 6: "user-tainted", + 7: "kernel-died-recently", + 8: "acpi-override", + 9: "kernel-warning", + 10: "staging-driver", + 11: "firmware-workaround", + 12: "out-of-tree-module", + 13: "unsigned-module", + 14: "soft-lockup", + 15: "live-patched", + 16: "auxiliary-taint", + 17: "randstruct-plugin", +} + +HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13}) + # --- pure diff cores (unit-tested without a live system) ------------------ @@ -49,6 +81,30 @@ def find_hidden_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]: return procnet_ports - ss_ports +def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[int]: + """UDP ports in /proc/net/udp that ss does not report.""" + return procnet_ports - ss_ports + + +def _norm_module(name: str) -> str: + return name.lower().replace("-", "_") + + +def find_known_rootkit_modules(modules: set[str]) -> set[str]: + known = {_norm_module(m) for m in KNOWN_ROOTKIT_MODULES} + return {m for m in modules if _norm_module(m) in known} + + +def decode_kernel_taint(value: int) -> list[str]: + return [label for bit, label in sorted(TAINT_FLAGS.items()) + if value & (1 << bit)] + + +def taint_severity(value: int) -> Severity: + risky = any(value & (1 << bit) for bit in HIGH_RISK_TAINT_BITS) + return Severity.HIGH if risky else Severity.MEDIUM + + # --- system views --------------------------------------------------------- def proc_pids() -> set[int]: @@ -110,6 +166,36 @@ def sys_live_modules() -> set[str]: return out +def module_taints() -> dict[str, str]: + """Read per-module taint markers from /sys/module/*/taint. + + Values are kernel-provided letters such as P/O/E for proprietary, + out-of-tree, or unsigned modules. Built-ins often do not expose this file. + """ + out: dict[str, str] = {} + base = "/sys/module" + try: + for m in os.listdir(base): + try: + with open(os.path.join(base, m, "taint")) as fh: + taint = fh.read().strip() + except OSError: + continue + if taint and taint != "0": + out[m] = taint + except OSError: + pass + return out + + +def kernel_taint() -> int: + try: + with open("/proc/sys/kernel/tainted") as fh: + return int(fh.read().strip()) + except (OSError, ValueError): + return 0 + + def _procnet_listen_ports(path: str) -> set[int]: ports: set[int] = set() try: @@ -130,6 +216,28 @@ def procnet_listen_ports() -> set[int]: return _procnet_listen_ports("/proc/net/tcp") | _procnet_listen_ports("/proc/net/tcp6") +def _procnet_udp_ports(path: str) -> set[int]: + ports: set[int] = set() + try: + with open(path) as fh: + next(fh, None) # header + for line in fh: + parts = line.split() + if len(parts) < 2: + continue + local = parts[1] # HEX_ADDR:HEX_PORT + port = int(local.rsplit(":", 1)[1], 16) + if port: + ports.add(port) + except (OSError, ValueError): + pass + return ports + + +def procnet_udp_ports() -> set[int]: + return _procnet_udp_ports("/proc/net/udp") | _procnet_udp_ports("/proc/net/udp6") + + def ss_listen_ports(state: SystemState) -> set[int]: ports: set[int] = set() for s in state.listening_sockets(): @@ -139,6 +247,17 @@ def ss_listen_ports(state: SystemState) -> set[int]: return ports +def ss_udp_ports(state: SystemState) -> set[int]: + ports: set[int] = set() + for s in state.sockets: + if s.state not in {"UNCONN", "ESTAB"}: + continue + tail = s.local.rsplit(":", 1) + if len(tail) == 2 and tail[1].isdigit(): + ports.add(int(tail[1])) + return ports + + def promiscuous_interfaces() -> list[str]: out: list[str] = [] base = "/sys/class/net" @@ -184,7 +303,9 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: pids=tuple(sorted(confirmed)[:20]), sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process") - hidden_mods = find_hidden_modules(proc_modules(), sys_live_modules()) + proc_mods = proc_modules() + sys_mods = sys_live_modules() + hidden_mods = find_hidden_modules(proc_mods, sys_mods) for m in sorted(hidden_mods): yield Alert( severity=Severity.CRITICAL, signature="rootkit_hidden_module", @@ -192,6 +313,36 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: detail=f"kernel module live in /sys/module but hidden from /proc/modules: {m}", sid=SID_HIDDEN_MODULE, classtype="rootkit-hidden-module") + all_modules = proc_mods | sys_mods + known = find_known_rootkit_modules(all_modules) + for m in sorted(known): + yield Alert( + severity=Severity.CRITICAL, signature="rootkit_known_module", + key=f"rk:knownmod:{m}", + detail=f"known LKM rootkit module name loaded or visible: {m}", + sid=SID_KNOWN_ROOTKIT_MODULE, classtype="rootkit-known-module") + + allowed = {_norm_module(m) for m in getattr(cfg, "rootcheck_module_allow", ())} + for m, taint in sorted(module_taints().items()): + if _norm_module(m) in allowed or m in known: + continue + yield Alert( + severity=Severity.HIGH, signature="rootkit_tainted_module", + key=f"rk:taintmod:{m}", + detail=(f"kernel module has taint marker '{taint}' " + f"(out-of-tree/proprietary/unsigned module): {m}"), + sid=SID_TAINTED_MODULE, classtype="rootkit-tainted-module") + + kt = kernel_taint() + if kt: + flags = decode_kernel_taint(kt) + yield Alert( + severity=taint_severity(kt), signature="kernel_tainted", + key=f"rk:ktaint:{kt}", + detail=(f"kernel taint value {kt} ({', '.join(flags) or 'unknown'}) — " + "may indicate unsigned/out-of-tree modules, forced loads, or kernel faults"), + sid=SID_KERNEL_TAINTED, classtype="kernel-integrity") + hidden_ports = find_hidden_ports(procnet_listen_ports(), ss_listen_ports(state)) if hidden_ports: yield Alert( @@ -201,6 +352,15 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: "(tool may be hooked): " + ", ".join(map(str, sorted(hidden_ports)))), sid=SID_HIDDEN_PORT, classtype="rootkit-hidden-port") + hidden_udp = find_hidden_udp_ports(procnet_udp_ports(), ss_udp_ports(state)) + if hidden_udp: + yield Alert( + severity=Severity.HIGH, signature="rootkit_hidden_udp_port", + key=f"rk:hidudp:{min(hidden_udp)}", + detail=("UDP port(s) in /proc/net/udp not reported by ss " + "(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))), + sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port") + for iface in promiscuous_interfaces(): yield Alert( severity=Severity.MEDIUM, signature="promiscuous_interface", diff --git a/enodia_sentinel/static/dashboard.html b/enodia_sentinel/static/dashboard.html index ffe4afc..72a7b56 100644 --- a/enodia_sentinel/static/dashboard.html +++ b/enodia_sentinel/static/dashboard.html @@ -40,11 +40,12 @@ .section-title{display:flex;align-items:center;justify-content:space-between; padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em; text-transform:uppercase} - .metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px} + .metrics{display:grid;grid-template-columns:repeat(5,1fr);gap:10px;margin-bottom:16px} .metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px} .metric b{display:block;font-size:24px;line-height:1;color:#fff} .metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase} .metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)} + .metric.posture b{color:var(--accent)} .list{padding:0 8px 14px} .item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px; cursor:pointer;border-radius:7px;margin-bottom:4px} @@ -118,10 +119,12 @@
+
+ @@ -134,7 +137,7 @@