Expand rootkit monitoring and Claude docs

This commit is contained in:
Luna 2026-06-12 04:57:11 -07:00
parent a7129e5666
commit 3e5f8fc3f7
16 changed files with 524 additions and 32 deletions

103
CLAUDE.md Normal file
View file

@ -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 <incident-id> --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.

View file

@ -101,11 +101,11 @@ enodia_sentinel/
├── config.py dataclass config (TOML + env overrides) ├── config.py dataclass config (TOML + env overrides)
├── netutil.py public-IP / CIDR logic (stdlib ipaddress) ├── netutil.py public-IP / CIDR logic (stdlib ipaddress)
├── 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 HTTPS read-only management console + JSON API + auth
├── static/ the self-contained dashboard SPA ├── static/ the self-contained dashboard SPA
├── fim.py file integrity monitoring (hash baseline + pacman verify) ├── fim.py file integrity monitoring (hash baseline + pacman verify)
├── pkgdb.py package-DB integrity + signed-package verification ├── 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 ├── selfprotect.py self-integrity footprint + dead-man's-switch heartbeat
├── triage.py false-positive classification ├── triage.py false-positive classification
├── provenance.py package-ownership lookups (pacman/dpkg/rpm) ├── 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 - **Bearer-token auth** (constant-time check); the token is auto-generated and
saved on first run and printed in the startup line. Open saved on first run and printed in the startup line. Open
`https://<tailscale-ip>:8787/?token=…`. `https://<tailscale-ip>:8787/?token=…`.
- **Read-only management**: incidents, timelines, alert inventory, event tail, - **Read-only management**: incidents, timelines, alert inventory, posture
and dry-run response plans. No commands are executed from the browser. JSON 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/<id>`, API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
`/api/alerts`, `/api/alerts/<id>`, `/api/events`. `/api/posture`, `/api/alerts`, `/api/alerts/<id>`, `/api/events`.
## Phone push notifications ## 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 | | `/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 | | `/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 | | `/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 ```bash
enodia-sentinel rootcheck # one-shot cross-view scan enodia-sentinel rootcheck # one-shot cross-view scan
``` ```
The daemon runs it on a slow background cadence (`rootcheck_interval`) and routes 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 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 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; 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 signed-package verification (compares on-disk files to the `.MTREE` in the signed
cache package, surviving a rewritten checksum DB) plus a `SigLevel`-downgrade cache package, surviving a rewritten checksum DB) plus a `SigLevel`-downgrade
check, and an **anti-rootkit cross-view** layer (hidden processes, modules, check, and an **anti-rootkit cross-view** layer (hidden processes, modules,
ports, and promiscuous interfaces — each caught by asking the same question two TCP/UDP ports, promiscuous interfaces, known LKM rootkit names, and
ways and diffing the answers). kernel/module taint — each caught by asking independent views and diffing the
answers).
v0.6 — adds **tamper-evidence**: out-of-band package-DB integrity v0.6 — adds **tamper-evidence**: out-of-band package-DB integrity
(catches rewritten checksums), self-integrity of Sentinel's own footprint, and a (catches rewritten checksums), self-integrity of Sentinel's own footprint, and a

View file

@ -87,10 +87,14 @@ pkgdb_pkgverify_sample = 40 # packages verified per pass (rotates)
# --- anti-rootkit cross-view -------------------------------------------- # --- anti-rootkit cross-view --------------------------------------------
# Ask the same question two ways and diff the answers: kill(0) vs /proc for # 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 # 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_enabled = true
rootcheck_interval = 300 # seconds between cross-view sweeps rootcheck_interval = 300 # seconds between cross-view sweeps
rootcheck_pid_cap = 65536 # upper PID to brute-force via kill(0) 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 --------------------------------------------------- # --- incident grouping ---------------------------------------------------
# Collapse related alerts into one incident: process-lineage first (shared PID # Collapse related alerts into one incident: process-lineage first (shared PID

View file

@ -76,7 +76,11 @@ Runs anti-rootkit cross-view checks once:
- PIDs alive via `kill(pid, 0)` but missing from `/proc`. - PIDs alive via `kill(pid, 0)` but missing from `/proc`.
- Live modules in `/sys/module` but missing from `/proc/modules`. - Live modules in `/sys/module` but missing from `/proc/modules`.
- Listening TCP ports in `/proc/net/tcp*` but missing from `ss`. - 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. - 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: 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 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. 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`. Expected use: `enodia-sentinel-web.service`.
### `triage` ### `triage`

View file

@ -66,11 +66,15 @@ Expected healthy output:
acknowledged. acknowledged.
- `pkgdb-check`: package DB consistent with anchor. - `pkgdb-check`: package DB consistent with anchor.
- `pkgdb-verify`: sampled files match signed cache packages. - `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 - `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 only ones you have consciously accepted (e.g. password auth on a host that
needs it). needs it).
The HTTPS dashboard shows the same posture findings under the Posture tab for
quick remote review.
## Alert Workflow ## Alert Workflow
When Sentinel fires: When Sentinel fires:
@ -125,7 +129,7 @@ When Sentinel fires:
| `fim_modified` | Verify package ownership, package checksum, and whether the change followed an upgrade. | | `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. | | `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. | | `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 ## Baseline Hygiene

View file

@ -95,6 +95,13 @@ Exit criteria:
Purpose: reduce blind spots and connect related signals. 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: - Add eBPF event sources:
`tcp_connect`, `bind`, `accept`, module load, privilege transitions, and `tcp_connect`, `bind`, `accept`, module load, privilege transitions, and
writes to watched persistence paths. writes to watched persistence paths.
@ -102,13 +109,60 @@ Purpose: reduce blind spots and connect related signals.
- Correlate multi-stage behavior: - Correlate multi-stage behavior:
web service spawns shell, shell downloads payload, payload adds persistence, web service spawns shell, shell downloads payload, payload adds persistence,
listener appears, and FIM changes. 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. - Keep polling as the oracle and fallback.
Exit criteria: Exit criteria:
- Short-lived network and persistence actions are visible. - Short-lived network and persistence actions are visible.
- Related alerts collapse into one incident with a readable timeline. - 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 <sid>` for built-in and
configured rules.
- Add `enodia-sentinel rules test <event-json>` 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 ## v1.2: Fleet Mode

View file

@ -165,7 +165,9 @@ the transaction completes; a persistent mismatch is the real finding.
## Runbook 4 — Hidden listener / rootkit ## Runbook 4 — Hidden listener / rootkit
**Triggers:** `rootkit_hidden_process` (100022), `rootkit_hidden_module` (100023), **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. `new_listener` (100013) for an unexplained open port.
These come from cross-view checks: the same question asked two ways, answered 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 - `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 package-owned. A hidden port that `rootcheck` sees but `ss` does not is far more
serious than a forgotten service. 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 - A `rootkit_hidden_module` names a live kernel module absent from
`/proc/modules` — strong evidence of an LKM rootkit. `/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** **Contain**
@ -197,8 +207,10 @@ differently, because something is hiding.
reliably clean a host whose kernel is hooked. Preserve the disk image first. 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 **False positives:** PID exit races (Sentinel re-verifies hidden PIDs to reduce
these) and a legitimately promiscuous interface (a sniffer/monitoring tool you these), a legitimately promiscuous interface (a sniffer/monitoring tool you
run). Confirm the owning process before escalating. 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.
--- ---

View file

@ -76,7 +76,7 @@ Sentinel includes several integrity layers:
| Package DB anchor | Detects out-of-band edits to `/var/lib/pacman/local`. | | 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. | | 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. | | 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 ### Evidence Capture

View file

@ -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. | | 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. | | 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. | | 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. | | Sensor tampering | Stop daemon, edit config, remove hook, modify baseline | Detect self-integrity changes and stale heartbeat via external watchdog. |
## Trust Boundaries ## Trust Boundaries

View file

@ -74,7 +74,7 @@ def main(argv: list[str] | None = None) -> int:
pv.add_argument("--sample", type=int, default=0, pv.add_argument("--sample", type=int, default=0,
help="packages to verify (0 = config default; rotates)") help="packages to verify (0 = config default; rotates)")
sub.add_parser("rootcheck", 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 = sub.add_parser("status", help="print daemon health/alert summary")
st.add_argument("--json", action="store_true", help="emit status as JSON") st.add_argument("--json", action="store_true", help="emit status as JSON")
inc = sub.add_parser("incident", inc = sub.add_parser("incident",
@ -197,7 +197,8 @@ def _cmd_rootcheck(cfg: Config) -> int:
from . import rootcheck from . import rootcheck
alerts = list(rootcheck.run(cfg)) alerts = list(rootcheck.run(cfg))
if not alerts: 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 return 0
for a in sorted(alerts, key=lambda x: -x.severity): for a in sorted(alerts, key=lambda x: -x.severity):
print(f"[{a.severity}] {a.signature:<22} {a.detail}") print(f"[{a.severity}] {a.signature:<22} {a.detail}")

View file

@ -82,6 +82,9 @@ class Config:
rootcheck_enabled: bool = True rootcheck_enabled: bool = True
rootcheck_interval: int = 300 # seconds between cross-view sweeps rootcheck_interval: int = 300 # seconds between cross-view sweeps
rootcheck_pid_cap: int = 65536 # upper PID to brute-force via kill(0) 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 grouping (collapse related alerts by process lineage, then time)
incident_tracking: bool = True incident_tracking: bool = True

View file

@ -28,9 +28,41 @@ SID_HIDDEN_PROC = 100022
SID_HIDDEN_MODULE = 100023 SID_HIDDEN_MODULE = 100023
SID_HIDDEN_PORT = 100024 SID_HIDDEN_PORT = 100024
SID_PROMISC = 100025 SID_PROMISC = 100025
SID_KNOWN_ROOTKIT_MODULE = 100028
SID_TAINTED_MODULE = 100029
SID_KERNEL_TAINTED = 100030
SID_HIDDEN_UDP_PORT = 100031
IFF_PROMISC = 0x100 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) ------------------ # --- 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 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 --------------------------------------------------------- # --- system views ---------------------------------------------------------
def proc_pids() -> set[int]: def proc_pids() -> set[int]:
@ -110,6 +166,36 @@ def sys_live_modules() -> set[str]:
return out 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]: def _procnet_listen_ports(path: str) -> set[int]:
ports: set[int] = set() ports: set[int] = set()
try: try:
@ -130,6 +216,28 @@ def procnet_listen_ports() -> set[int]:
return _procnet_listen_ports("/proc/net/tcp") | _procnet_listen_ports("/proc/net/tcp6") 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]: def ss_listen_ports(state: SystemState) -> set[int]:
ports: set[int] = set() ports: set[int] = set()
for s in state.listening_sockets(): for s in state.listening_sockets():
@ -139,6 +247,17 @@ def ss_listen_ports(state: SystemState) -> set[int]:
return ports 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]: def promiscuous_interfaces() -> list[str]:
out: list[str] = [] out: list[str] = []
base = "/sys/class/net" base = "/sys/class/net"
@ -184,7 +303,9 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]:
pids=tuple(sorted(confirmed)[:20]), pids=tuple(sorted(confirmed)[:20]),
sid=SID_HIDDEN_PROC, classtype="rootkit-hidden-process") 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): for m in sorted(hidden_mods):
yield Alert( yield Alert(
severity=Severity.CRITICAL, signature="rootkit_hidden_module", 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}", detail=f"kernel module live in /sys/module but hidden from /proc/modules: {m}",
sid=SID_HIDDEN_MODULE, classtype="rootkit-hidden-module") 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)) hidden_ports = find_hidden_ports(procnet_listen_ports(), ss_listen_ports(state))
if hidden_ports: if hidden_ports:
yield Alert( 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)))), "(tool may be hooked): " + ", ".join(map(str, sorted(hidden_ports)))),
sid=SID_HIDDEN_PORT, classtype="rootkit-hidden-port") 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(): for iface in promiscuous_interfaces():
yield Alert( yield Alert(
severity=Severity.MEDIUM, signature="promiscuous_interface", severity=Severity.MEDIUM, signature="promiscuous_interface",

View file

@ -40,11 +40,12 @@
.section-title{display:flex;align-items:center;justify-content:space-between; .section-title{display:flex;align-items:center;justify-content:space-between;
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em; padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
text-transform:uppercase} 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{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 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 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.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} .list{padding:0 8px 14px}
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px; .item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
cursor:pointer;border-radius:7px;margin-bottom:4px} cursor:pointer;border-radius:7px;margin-bottom:4px}
@ -118,10 +119,12 @@
<div class="tabs"> <div class="tabs">
<button class="active" data-tab="incident">Incident</button> <button class="active" data-tab="incident">Incident</button>
<button data-tab="alerts">Alerts</button> <button data-tab="alerts">Alerts</button>
<button data-tab="posture">Posture</button>
<button data-tab="events">Events</button> <button data-tab="events">Events</button>
</div> </div>
<section id="incidentTab"></section> <section id="incidentTab"></section>
<section id="alertsTab" hidden></section> <section id="alertsTab" hidden></section>
<section id="postureTab" hidden></section>
<section id="eventsTab" hidden></section> <section id="eventsTab" hidden></section>
</main> </main>
@ -134,7 +137,7 @@
<script> <script>
const TOKEN = new URLSearchParams(location.search).get("token") || ""; const TOKEN = new URLSearchParams(location.search).get("token") || "";
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {}; const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
const state = {incidents:[], alerts:[], selected:null, incident:null, plan:null, tab:"incident"}; const state = {incidents:[], alerts:[], posture:null, selected:null, incident:null, plan:null, tab:"incident"};
async function api(path){ async function api(path){
const r = await fetch(path,{headers:HDR}); const r = await fetch(path,{headers:HDR});
@ -150,10 +153,10 @@ function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
async function refresh(){ async function refresh(){
try{ try{
const [status, incidents, alerts, events] = await Promise.all([ const [status, incidents, alerts, posture, events] = await Promise.all([
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/events") api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/posture"), api("/api/events")
]); ]);
state.incidents = incidents; state.alerts = alerts; state.events = events.events || []; state.incidents = incidents; state.alerts = alerts; state.posture = posture; state.events = events.events || [];
renderStatus(status); renderLists(); renderStatus(status); renderLists();
if(!state.selected && incidents.length) await openIncident(incidents[0].id); if(!state.selected && incidents.length) await openIncident(incidents[0].id);
else if(state.selected) await openIncident(state.selected, false); else if(state.selected) await openIncident(state.selected, false);
@ -169,9 +172,11 @@ function renderStatus(st){
document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown"); document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown");
const counts = st.counts || {}; const counts = st.counts || {};
const metrics = document.getElementById("metrics"); metrics.innerHTML = ""; const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""]].forEach(([k,c])=>{ [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""],["POSTURE","posture"]].forEach(([k,c])=>{
const m=el("div","metric "+c); m.append(el("b",null,String(k==="TOTAL" ? st.total_alerts||0 : counts[k]||0))); const value = k==="TOTAL" ? st.total_alerts||0 : k==="POSTURE" ? state.posture?.count||0 : counts[k]||0;
m.append(el("span",null,k==="TOTAL" ? "alerts" : k)); metrics.append(m); const label = k==="TOTAL" ? "alerts" : k==="POSTURE" ? "findings" : k;
const m=el("div","metric "+c); m.append(el("b",null,String(value)));
m.append(el("span",null,label)); metrics.append(m);
}); });
} }
@ -212,7 +217,8 @@ async function openIncident(id, mark=true){
async function openAlert(name){ async function openAlert(name){
state.tab = "alerts"; setTabButtons(); state.tab = "alerts"; setTabButtons();
const box = document.getElementById("alertsTab"); const box = document.getElementById("alertsTab");
box.hidden = false; document.getElementById("incidentTab").hidden = true; document.getElementById("eventsTab").hidden = true; box.hidden = false; document.getElementById("incidentTab").hidden = true;
document.getElementById("postureTab").hidden = true; document.getElementById("eventsTab").hidden = true;
box.innerHTML='<div class="empty">loading snapshot</div>'; box.innerHTML='<div class="empty">loading snapshot</div>';
try{ try{
const a = await api("/api/alerts/"+encodeURIComponent(name)); const a = await api("/api/alerts/"+encodeURIComponent(name));
@ -261,8 +267,10 @@ function renderTab(){
setTabButtons(); setTabButtons();
document.getElementById("incidentTab").hidden = state.tab !== "incident"; document.getElementById("incidentTab").hidden = state.tab !== "incident";
document.getElementById("alertsTab").hidden = state.tab !== "alerts"; document.getElementById("alertsTab").hidden = state.tab !== "alerts";
document.getElementById("postureTab").hidden = state.tab !== "posture";
document.getElementById("eventsTab").hidden = state.tab !== "events"; document.getElementById("eventsTab").hidden = state.tab !== "events";
if(state.tab === "incident") renderIncident(); if(state.tab === "incident") renderIncident();
if(state.tab === "posture") renderPosture();
if(state.tab === "events") renderEvents(); if(state.tab === "events") renderEvents();
} }
function setTabButtons(){ function setTabButtons(){
@ -273,8 +281,37 @@ function renderEvents(){
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>'; box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events."; box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
} }
function renderPosture(){
const box = document.getElementById("postureTab");
const report = state.posture || {findings:[], counts:{}};
box.innerHTML = "";
const summary = el("div","card");
summary.append(el("h2",null,"Host posture"));
const counts = report.counts || {};
summary.append(tags([
"critical "+(counts.CRITICAL||0),
"high "+(counts.HIGH||0),
"medium "+(counts.MEDIUM||0)
]));
if(!report.findings || !report.findings.length){
summary.append(el("div","fine","No SSH, sudo, PATH, sensitive-file, or package-signature posture findings."));
box.append(summary);
return;
}
summary.append(el("div","fine",String(report.count)+" advisory finding(s). These are read-only hygiene checks, not live containment actions."));
box.append(summary);
report.findings.forEach(f=>{
const row = el("div","card");
const top = el("div","top"); top.append(sev(f.severity)); top.append(el("span","id",f.signature));
row.append(top);
row.append(tags(["sid "+(f.sid||0), f.classtype||"host-posture"]));
row.append(el("code","cmd",f.detail || ""));
box.append(row);
});
}
function showError(msg){ function showError(msg){
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("postureTab").innerHTML = '<div class="err">'+msg+'</div>';
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>'; document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
} }
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();}); document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();});

View file

@ -252,6 +252,23 @@ def response_plan(cfg: Config, incident_id: str) -> dict | None:
return respond.build_plan(bundle, cfg) return respond.build_plan(bundle, cfg)
def posture_report(cfg: Config, runner=None) -> dict:
"""Return advisory host posture findings for the management console."""
if runner is None:
from . import posture
runner = posture.run
findings = sorted(runner(cfg), key=lambda a: (-a.severity, a.signature))
counts: dict[str, int] = {}
for f in findings:
sev = str(f.severity)
counts[sev] = counts.get(sev, 0) + 1
return {
"count": len(findings),
"counts": counts,
"findings": [f.to_dict() for f in findings],
}
# --- HTTP layer ------------------------------------------------------------ # --- HTTP layer ------------------------------------------------------------
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
@ -299,6 +316,8 @@ class _Handler(BaseHTTPRequestHandler):
200 if alert else 404) 200 if alert else 404)
elif path == "/api/events": elif path == "/api/events":
self._json({"events": tail_events(cfg)}) self._json({"events": tail_events(cfg)})
elif path == "/api/posture":
self._json(posture_report(cfg))
elif path == "/api/incidents": elif path == "/api/incidents":
self._json(list_incidents(cfg)) self._json(list_incidents(cfg))
elif path.startswith("/api/incidents/"): elif path.startswith("/api/incidents/"):

View file

@ -28,6 +28,21 @@ class TestDiffCores(unittest.TestCase):
self.assertEqual( self.assertEqual(
rootcheck.find_hidden_ports({22, 80, 31337}, {22, 80}), {31337}) rootcheck.find_hidden_ports({22, 80, 31337}, {22, 80}), {31337})
def test_hidden_udp_ports_in_procnet_not_in_ss(self):
self.assertEqual(
rootcheck.find_hidden_udp_ports({53, 5353, 4444}, {53, 5353}), {4444})
def test_known_rootkit_module_names_normalized(self):
self.assertEqual(
rootcheck.find_known_rootkit_modules({"ext4", "diamorphine", "adore-ng"}),
{"diamorphine", "adore-ng"})
def test_kernel_taint_decoding_and_severity(self):
value = (1 << 12) | (1 << 13)
self.assertEqual(rootcheck.decode_kernel_taint(value),
["out-of-tree-module", "unsigned-module"])
self.assertEqual(rootcheck.taint_severity(value), Severity.HIGH)
class TestSsPortsFromState(unittest.TestCase): class TestSsPortsFromState(unittest.TestCase):
def test_extracts_listening_ports_from_injected_state(self): def test_extracts_listening_ports_from_injected_state(self):
@ -38,6 +53,15 @@ class TestSsPortsFromState(unittest.TestCase):
state = SystemState(sockets=socks) state = SystemState(sockets=socks)
self.assertEqual(rootcheck.ss_listen_ports(state), {22, 631}) self.assertEqual(rootcheck.ss_listen_ports(state), {22, 631})
def test_extracts_udp_ports_from_injected_state(self):
socks = [
Socket("UNCONN", "0.0.0.0:53", "0.0.0.0:0", 10, "dnsmasq", 1),
Socket("ESTAB", "127.0.0.1:5353", "127.0.0.1:1", 11, "mdns", 2),
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 12, "sshd", 3),
]
state = SystemState(sockets=socks)
self.assertEqual(rootcheck.ss_udp_ports(state), {53, 5353})
class TestRunIntegration(unittest.TestCase): class TestRunIntegration(unittest.TestCase):
"""Drive run() with every system view monkeypatched to a known state.""" """Drive run() with every system view monkeypatched to a known state."""
@ -47,7 +71,8 @@ class TestRunIntegration(unittest.TestCase):
self._saved = {} self._saved = {}
for name in ("proc_pids", "alive_pids", "proc_modules", for name in ("proc_pids", "alive_pids", "proc_modules",
"sys_live_modules", "procnet_listen_ports", "sys_live_modules", "procnet_listen_ports",
"promiscuous_interfaces"): "procnet_udp_ports", "promiscuous_interfaces", "module_taints",
"kernel_taint"):
self._saved[name] = getattr(rootcheck, name) self._saved[name] = getattr(rootcheck, name)
def tearDown(self): def tearDown(self):
@ -60,7 +85,10 @@ class TestRunIntegration(unittest.TestCase):
rootcheck.proc_modules = lambda: views.get("proc_modules", set()) rootcheck.proc_modules = lambda: views.get("proc_modules", set())
rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", set()) rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", set())
rootcheck.procnet_listen_ports = lambda: views.get("procnet_ports", set()) rootcheck.procnet_listen_ports = lambda: views.get("procnet_ports", set())
rootcheck.procnet_udp_ports = lambda: views.get("procnet_udp_ports", set())
rootcheck.promiscuous_interfaces = lambda: views.get("promisc", []) rootcheck.promiscuous_interfaces = lambda: views.get("promisc", [])
rootcheck.module_taints = lambda: views.get("module_taints", {})
rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0)
def test_clean_system_yields_nothing(self): def test_clean_system_yields_nothing(self):
self._patch(proc_pids={1, 2}, alive_pids={1, 2}, self._patch(proc_pids={1, 2}, alive_pids={1, 2},
@ -72,18 +100,47 @@ class TestRunIntegration(unittest.TestCase):
self._patch( self._patch(
proc_pids={1}, alive_pids={1}, proc_pids={1}, alive_pids={1},
proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"}, proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"},
procnet_ports={22, 31337}, promisc=["eth0"]) procnet_ports={22, 31337}, procnet_udp_ports={53, 4444},
promisc=["eth0"])
state = SystemState(sockets=[ state = SystemState(sockets=[
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 10, "sshd", 1)]) Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 10, "sshd", 1),
Socket("UNCONN", "0.0.0.0:53", "0.0.0.0:0", 11, "dnsmasq", 2),
])
alerts = {a.signature: a for a in rootcheck.run(self.cfg, state)} alerts = {a.signature: a for a in rootcheck.run(self.cfg, state)}
self.assertIn("rootkit_hidden_module", alerts) self.assertIn("rootkit_hidden_module", alerts)
self.assertEqual(alerts["rootkit_hidden_module"].severity, Severity.CRITICAL) self.assertEqual(alerts["rootkit_hidden_module"].severity, Severity.CRITICAL)
self.assertIn("diamorphine", alerts["rootkit_hidden_module"].detail) self.assertIn("diamorphine", alerts["rootkit_hidden_module"].detail)
self.assertIn("rootkit_known_module", alerts)
self.assertIn("rootkit_hidden_port", alerts) self.assertIn("rootkit_hidden_port", alerts)
self.assertIn("31337", alerts["rootkit_hidden_port"].detail) self.assertIn("31337", alerts["rootkit_hidden_port"].detail)
self.assertIn("rootkit_hidden_udp_port", alerts)
self.assertIn("4444", alerts["rootkit_hidden_udp_port"].detail)
self.assertIn("promiscuous_interface", alerts) self.assertIn("promiscuous_interface", alerts)
self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC) self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC)
def test_tainted_module_and_kernel_taint(self):
self._patch(
proc_pids={1}, alive_pids={1},
proc_modules={"ext4", "vendor_gpu"},
sys_live_modules={"ext4", "vendor_gpu"},
module_taints={"vendor_gpu": "OE"},
kernel_taint=(1 << 12) | (1 << 13))
alerts = {a.signature: a for a in rootcheck.run(self.cfg, SystemState(sockets=[]))}
self.assertIn("rootkit_tainted_module", alerts)
self.assertEqual(alerts["rootkit_tainted_module"].sid,
rootcheck.SID_TAINTED_MODULE)
self.assertIn("kernel_tainted", alerts)
self.assertEqual(alerts["kernel_tainted"].severity, Severity.HIGH)
def test_module_allowlist_suppresses_tainted_module(self):
self.cfg.rootcheck_module_allow = ("vendor_gpu",)
self._patch(
proc_pids={1}, alive_pids={1},
proc_modules={"vendor_gpu"}, sys_live_modules={"vendor_gpu"},
module_taints={"vendor_gpu": "OE"})
alerts = {a.signature: a for a in rootcheck.run(self.cfg, SystemState(sockets=[]))}
self.assertNotIn("rootkit_tainted_module", alerts)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -82,6 +82,20 @@ class TestDataLayer(unittest.TestCase):
self.assertEqual(plan["incident_id"], iid) self.assertEqual(plan["incident_id"], iid)
self.assertEqual(plan["mode"], "dry-run") self.assertEqual(plan["mode"], "dry-run")
def test_posture_report_shape(self):
def runner(_cfg):
yield Alert(Severity.HIGH, "ssh_root_login", "k",
"root SSH login enabled", sid=100040,
classtype="host-posture")
yield Alert(Severity.MEDIUM, "sudo_nopasswd", "k2",
"passwordless sudo", sid=100043,
classtype="host-posture")
report = web.posture_report(self.cfg, runner=runner)
self.assertEqual(report["count"], 2)
self.assertEqual(report["counts"], {"HIGH": 1, "MEDIUM": 1})
self.assertEqual(report["findings"][0]["signature"], "ssh_root_login")
class TestNetworkHelpers(unittest.TestCase): class TestNetworkHelpers(unittest.TestCase):
def test_is_loopback(self): def test_is_loopback(self):
@ -153,6 +167,12 @@ class TestAuth(unittest.TestCase):
resp = self._get("/api/alerts?token=secret-token") resp = self._get("/api/alerts?token=secret-token")
self.assertEqual(resp.status, 200) self.assertEqual(resp.status, 200)
def test_posture_endpoint(self):
resp = self._get("/api/posture", token="secret-token")
data = json.loads(resp.read())
self.assertIn("findings", data)
self.assertIn("count", data)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()