diff --git a/CLAUDE.md b/CLAUDE.md index 05a8b45..9e9e68b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,10 +54,24 @@ Start with: - `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. + hidden processes, hidden modules, hidden TCP/UDP/raw/special-protocol sockets, + raw ICMP/SCTP-style channels, promiscuous interfaces, known LKM rootkit module + names, and kernel/module taint. - `posture.py` performs advisory host hygiene checks. +## Current Threat Mapping + +Recent local sample families used for defensive coverage: + +- Gonzalo-style implant/rootkit behavior: direct input-event keylogging, + credential harvesting, LD_PRELOAD/tool tampering, persistence writes, raw ICMP + and SCTP-style knock/listener paths, deleted/fileless execution, and hidden + sockets/modules. +- Peopleswar-style C2 behavior: TLS C2 listener/check-in traffic, command queue + responses, and API/listener ports. Sentinel should prefer behavior coverage + (`new_listener`, `egress`, `stealth_network`, rootcheck, credential/input + detectors) over brittle sample-name matching unless adding explicit IOCs. + ## Development Commands ```bash @@ -100,4 +114,3 @@ When changing behavior, update the relevant set: - `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 3274210..296a30b 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ EDRs are built on: | `reverse_shell` | An interpreter with a **network socket on fd 0/1/2** | Interactive shells get a pty and daemons get unix sockets — a *network* socket on stdio is `nc -e` / `bash -i >& /dev/tcp/...` | | `ld_preload` | Non-empty `/etc/ld.so.preload`, or `LD_PRELOAD` into a writable dir | Injecting into processes needs the library to exist somewhere | | `deleted_exe` | A process running from a **deleted / `memfd:`** binary | Fileless malware deletes its dropper; the kernel still names the inode `(deleted)` | +| `input_snooper` | A non-allowlisted process holding `/dev/input`, `/dev/uinput`, or HID raw devices open | Keyloggers have to read keystroke/event devices somewhere; expected compositors/remappers are tunable | +| `credential_access` | A non-allowlisted process with shadow files, private SSH keys, browser stores, or secret profiles open | Credential harvesters have to open the material they steal; legitimate auth/keyring/browser readers are tunable | +| `stealth_network` | Raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity | Covert channels often avoid ordinary TCP/UDP paths; expected network managers/sniffers are tunable | | `new_listener` | A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere | | `new_suid` | A new SUID/SGID binary (critical in a writable dir) | A SUID `/tmp` binary is a textbook privesc trick | | `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot | @@ -220,9 +223,14 @@ enodia-sentinel.service`. Every key is optional. Highlights: |---|---|---| | `sample_interval` | 4 | seconds between sweeps | | `cooldown` | 60 | min seconds before re-alerting a signature | -| `detectors` | all 7 | the enabled detector list | +| `detectors` | all 10 | the enabled detector list | | `interpreters` | bash sh … | process names treated as shells | | `egress_allow_cidrs` | [] | trusted public ranges (won't trip egress) | +| `input_snooper_allow_comms` | desktop input stack | comm names allowed to hold input devices | +| `credential_access_allow_comms` | auth/keyring/browsers | comm names allowed to read credential stores | +| `credential_access_extra_paths` | [] | extra exact paths or directory prefixes treated as secrets | +| `stealth_network_allow_comms` | network managers/sniffers | comm names allowed to own special protocol sockets | +| `stealth_network_allow_kinds` | [] | socket families to ignore entirely | | `suid_hot_dirs` | /tmp … | dirs where a SUID binary is CRITICAL | | `suid_scan_extra_dirs` | /tmp … | writable mounts always scanned (tmpfs-safe) | | `capture_execve_bpftrace` | false | add a bpftrace execve trace to snapshots | @@ -371,6 +379,9 @@ compare the answers.** A discrepancy is the hiding artifact. | `/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 | +| `/proc/net/raw` vs `ss -w` | a raw socket protocol a hooked `ss` won't report | 100034 | +| `/proc/net/raw` ICMP protocols | persistent raw ICMP sockets used by knockers/sniffers | 100035 | +| `/proc/net` special families vs `ss` | SCTP/DCCP/packet/TIPC/XDP sockets a hooked `ss` won't report | 100037 | ```bash enodia-sentinel rootcheck # one-shot cross-view scan @@ -493,11 +504,18 @@ regression suite for both. ## Project status +v0.8-dev — expands security monitoring for Gonzalo/Peopleswar-style samples: +`input_snooper` catches direct keyboard/HID event access, `credential_access` +catches live credential harvesting against shadow files, SSH keys, browser +stores, and secret profiles, `stealth_network` watches SCTP/DCCP/raw/packet and +other special protocol families, and rootcheck now covers raw ICMP plus hidden +SCTP/DCCP/packet-family sockets. + 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, -TCP/UDP ports, promiscuous interfaces, known LKM rootkit names, and +TCP/UDP sockets, promiscuous interfaces, known LKM rootkit names, and kernel/module taint — each caught by asking independent views and diffing the answers). diff --git a/config/enodia-sentinel.toml b/config/enodia-sentinel.toml index 3f26c2e..8e425af 100644 --- a/config/enodia-sentinel.toml +++ b/config/enodia-sentinel.toml @@ -14,6 +14,7 @@ suid_scan_interval = 60 # seconds between (backgrounded) SUID filesystem sca # --- detectors (omit one to disable it) ---------------------------------- detectors = [ "reverse_shell", "ld_preload", "deleted_exe", + "input_snooper", "credential_access", "stealth_network", "new_listener", "new_suid", "persistence", "egress", ] @@ -55,6 +56,30 @@ watch_persistence = [ "/root/.ssh/authorized_keys", "/root/.bashrc", "/root/.profile", ] +# Processes expected to keep keyboard/HID event devices open. +input_snooper_allow_comms = [ + "Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway", "Hyprland", + "systemd-logind", "input-remapper", "keyd", "kanata", +] + +# Processes expected to read credential stores during normal auth/user activity. +credential_access_allow_comms = [ + "sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd", + "gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox", "chromium", + "chrome", "google-chrome", "brave", "brave-browser", +] +# Extra exact paths or directory prefixes treated as credential material. +credential_access_extra_paths = [] + +# Expected owners of packet/raw/special protocol sockets. +stealth_network_allow_comms = [ + "NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd", + "tcpdump", "dumpcap", "wireshark", "suricata", "zeek", +] +# Socket family names to ignore entirely if they are normal in your environment: +# raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock. +stealth_network_allow_kinds = [] + # --- 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 @@ -87,8 +112,9 @@ 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 TCP/UDP listeners, interface flags for promiscuous sniffing, -# known rootkit module names, per-module taint, and global kernel taint. +# vs ss for hidden TCP/UDP/raw/special-protocol sockets, interface flags for +# promiscuous sniffing, known rootkit module names, raw ICMP/SCTP-style +# channels, 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) diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index f910c43..f5bc53d 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -66,8 +66,9 @@ 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, sniffers, known rootkit - modules, or unexplained kernel/module taint. +- `rootcheck`: no hidden processes/modules/ports/raw/special-protocol sockets, + sniffers, known rootkit modules, raw ICMP/SCTP-style channels, 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). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index cdde6ef..3698b76 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -37,6 +37,9 @@ work that is bigger than single alerts. expected `sid` (`sentinel-redteam --list`), with a PASS/MISS verification pass. - ✅ Document incident response runbooks for reverse shells, persistence, trojaned binaries, hidden listeners, and sensor tampering ([RUNBOOKS.md](RUNBOOKS.md)). +- ✅ Add Gonzalo/Peopleswar-style behavior coverage: input-device keylogging, + credential-store/private-key access, SCTP/DCCP/raw/packet-family traffic, and + raw ICMP/special-protocol rootcheck detection. Exit criteria: diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index 86f5fc3..e702aa6 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -41,10 +41,12 @@ Then pick the runbook below that matches the signature. ## Runbook 1 — Reverse shell / suspicious egress -**Triggers:** `reverse_shell` (sid 100010), `egress` (sid 100016). +**Triggers:** `reverse_shell` (sid 100010), `egress` (sid 100016), +`stealth_network` (sid 100036). -An interpreter wired to a socket, or an interpreter holding a connection to a -public IP — the classic interactive-C2 and beaconing shapes. +An interpreter wired to a socket, an interpreter holding a connection to a +public IP, or activity over raw/SCTP/DCCP/packet/XDP/TIPC/vsock families - the +classic interactive-C2, beaconing, and covert-channel shapes. **Confirm** @@ -60,6 +62,11 @@ public IP — the classic interactive-C2 and beaconing shapes. - Is the peer expected? Compare against `egress_allow_cidrs` in config. A package manager or browser to a public IP is normal; `bash`/`python`/`nc` is not. +- For `stealth_network`, check the socket family and owner. Packet/raw sockets + are normal for approved sniffers and some network managers; SCTP/DCCP/TIPC/XDP + on a desktop or ordinary server should have a clear service owner. Tune with + `stealth_network_allow_comms` or, for intentionally used families, + `stealth_network_allow_kinds`. **Contain** @@ -167,8 +174,10 @@ the transaction completes; a persistent mismatch is the real finding. **Triggers:** `rootkit_hidden_process` (100022), `rootkit_hidden_module` (100023), `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. +`kernel_tainted` (100030), `rootkit_hidden_udp_port` (100031), +`rootkit_hidden_raw_socket` (100034), `raw_icmp_socket` (100035), +`rootkit_hidden_protocol_socket` (100037), and `new_listener` (100013) for an +unexplained open port. These come from cross-view checks: the same question asked two ways, answered differently, because something is hiding. @@ -187,6 +196,15 @@ differently, because something is hiding. - `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. +- `rootkit_hidden_raw_socket` or `raw_icmp_socket`: look for authorized ping, + monitoring, traceroute, or packet tooling first. A persistent raw ICMP socket + with no operator explanation is consistent with ICMP knockers/backdoors and + should be investigated with process fd tables, package provenance, and memory + capture before killing the process. +- `rootkit_hidden_protocol_socket`: inspect SCTP, DCCP, packet, TIPC, or XDP + use. If `/proc/net` sees the family but `ss` does not, treat normal socket + tooling as suspect and pivot to offline evidence or the dashboard from another + host. - 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 @@ -214,7 +232,37 @@ modules you intentionally trust. --- -## Runbook 5 — Sensor tampering +## Runbook 5 — Credential or input capture + +**Triggers:** `credential_access` (100033), `input_snooper` (100032) + +These alerts mean a non-allowlisted process has credential material or keyboard +input devices open. They map to Gonzalo-style keylogging and local credential +harvesting, but are intentionally behavior-based. + +**Confirm** + +- Open the snapshot for the alert and inspect the captured `cmdline`, `exe`, + `cwd`, parent process, and fd target. +- For `input_snooper`, confirm whether the process is an expected compositor, + display server, input remapper, or accessibility tool. Add stable legitimate + process names to `input_snooper_allow_comms`. +- For `credential_access`, identify which secret was opened: shadow DB, private + SSH key, browser credential store, or NetworkManager secret profile. Add + only well-understood auth/keyring/browser readers to + `credential_access_allow_comms`. + +**Contain** + +- Preserve evidence first for unknown processes: copy the executable path if it + still exists, capture `/proc//maps`, and export the incident. +- Rotate exposed credentials and SSH keys after containment; assume browser + session cookies and saved passwords may be compromised if browser stores were + opened by an unknown process. + +--- + +## Runbook 6 — Sensor tampering **Triggers:** `fim_modified` on a Sentinel-owned path (self-integrity), a stale or missing heartbeat surfaced by the off-box `watchdog`, or the dashboard going diff --git a/docs/SPECIFICATION.md b/docs/SPECIFICATION.md index 85b8fd1..241e542 100644 --- a/docs/SPECIFICATION.md +++ b/docs/SPECIFICATION.md @@ -41,7 +41,7 @@ The long-term product should combine five functions: 6. **Bash prototype remains the oracle.** The original shell implementation and red-team harness preserve behavioral compatibility for core signatures. -## Current Scope: v0.7 +## Current Scope: v0.8-dev ### Poll Detectors @@ -53,6 +53,9 @@ against cached process and socket data: | `reverse_shell` | Interpreter with a network socket on fd 0/1/2. | | `ld_preload` | Non-empty `/etc/ld.so.preload` or writable-path `LD_PRELOAD`. | | `deleted_exe` | Process executing from a deleted file or `memfd:` image. | +| `input_snooper` | Non-allowlisted process holding keyboard/input/HID devices open. | +| `credential_access` | Non-allowlisted process reading credential databases, private keys, browser stores, or secret profiles. | +| `stealth_network` | Non-allowlisted raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity. | | `new_listener` | Listening socket absent from the startup baseline. | | `new_suid` | New SUID/SGID binary, critical in writable locations. | | `persistence` | Changes to cron, systemd units, SSH keys, shell rc files, and similar persistence locations. | @@ -76,7 +79,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 TCP/UDP ports, promiscuous interfaces, known rootkit modules, and kernel/module taint. | +| Rootcheck | Cross-view checks for hidden processes, hidden modules, hidden TCP/UDP/raw/special-protocol sockets, promiscuous interfaces, known rootkit modules, raw ICMP/SCTP-style channels, and kernel/module taint. | ### Evidence Capture diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 69a5cb0..ca6ee05 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -24,10 +24,11 @@ Sentinel is designed to help against: | Adversary | Examples | Expected Sentinel value | |---|---|---| | Opportunistic remote shell | Webshell, exposed service RCE, stolen SSH key | Detect reverse shell, suspicious egress, new listeners, persistence writes. | +| Credential harvesting | Shadow file reads, private SSH key theft, browser credential database access, keylogging | Detect credential file access and non-allowlisted input/HID event readers. | | 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 TCP/UDP sockets, tainted modules | Detect LD_PRELOAD, cross-view inconsistencies, known LKM names, and kernel/module taint. | +| Common rootkit hiding | LD_PRELOAD tricks, `/proc` hiding, module-list hiding, hidden TCP/UDP/raw/SCTP/packet sockets, tainted modules | Detect LD_PRELOAD, cross-view inconsistencies, known LKM names, raw ICMP/SCTP-style sockets, unusual protocol families, 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/config.py b/enodia_sentinel/config.py index 6a7300a..ee5feac 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -26,6 +26,7 @@ _DEFAULT_WATCH = ( _ALL_DETECTORS = ( "reverse_shell", "ld_preload", "deleted_exe", + "input_snooper", "credential_access", "stealth_network", "new_listener", "new_suid", "persistence", "egress", ) @@ -60,6 +61,31 @@ class Config: "/tmp", "/dev/shm", "/var/tmp", "/run/user", ) watch_persistence: tuple[str, ...] = _DEFAULT_WATCH + # Processes expected to hold keyboard/HID event devices open. + input_snooper_allow_comms: frozenset[str] = frozenset(( + "Xorg", "Xwayland", "gnome-shell", "kwin_wayland", "sway", + "Hyprland", "systemd-logind", "input-remapper", "keyd", "kanata", + )) + # Processes expected to read credential stores as part of normal auth or + # user interaction. Malware harvesting those same files under another comm + # still alerts. + credential_access_allow_comms: frozenset[str] = frozenset(( + "sshd", "sudo", "su", "login", "gdm-session-worker", "polkitd", + "gnome-keyring-daemon", "kwalletd5", "kwalletd6", "firefox", + "chromium", "chrome", "google-chrome", "brave", "brave-browser", + )) + # Extra exact paths or directory prefixes treated as credential material. + credential_access_extra_paths: tuple[str, ...] = () + # Expected owners of packet/raw/special protocol sockets. Keep tight: these + # families are common in sniffers, tunnels, and covert channels. + stealth_network_allow_comms: frozenset[str] = frozenset(( + "NetworkManager", "systemd-networkd", "wpa_supplicant", "dhcpcd", + "tcpdump", "dumpcap", "wireshark", "suricata", "zeek", + )) + # Socket family names to ignore entirely, e.g. ["mptcp"] if your host uses + # it legitimately. Valid observed kinds include raw, sctp, dccp, packet, + # mptcp, tipc, xdp, and vsock. + stealth_network_allow_kinds: frozenset[str] = frozenset() # file integrity monitoring (FIM) fim_enabled: bool = True diff --git a/enodia_sentinel/detectors/__init__.py b/enodia_sentinel/detectors/__init__.py index a6f4a7a..7af30c9 100644 --- a/enodia_sentinel/detectors/__init__.py +++ b/enodia_sentinel/detectors/__init__.py @@ -15,13 +15,16 @@ from ..alert import Alert from ..config import Config from ..system import SystemState from . import ( + credential_access, deleted_exe, egress, + input_snooper, ld_preload, new_listener, new_suid, persistence, reverse_shell, + stealth_network, ) DetectFn = Callable[[SystemState, Config], Iterable[Alert]] @@ -40,6 +43,9 @@ REGISTRY: tuple[Detector, ...] = ( Detector("reverse_shell", reverse_shell.detect), Detector("ld_preload", ld_preload.detect), Detector("deleted_exe", deleted_exe.detect), + Detector("input_snooper", input_snooper.detect), + Detector("credential_access", credential_access.detect), + Detector("stealth_network", stealth_network.detect), Detector("egress", egress.detect), Detector("new_listener", new_listener.detect, needs_baseline=True), Detector("persistence", persistence.detect, needs_baseline=True), diff --git a/enodia_sentinel/detectors/credential_access.py b/enodia_sentinel/detectors/credential_access.py new file mode 100644 index 0000000..7b8fdc1 --- /dev/null +++ b/enodia_sentinel/detectors/credential_access.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""credential_access — live process has sensitive credential files open. + +This catches credential harvesters that open shadow databases, private SSH +keys, browser credential stores, or NetworkManager secret material during a +poll sweep. Legitimate auth agents and browsers are configurable allow-list +entries so the rule can stay enabled on desktops. +""" +from __future__ import annotations + +from collections.abc import Iterator + +from ..alert import Alert, Severity +from ..config import Config +from ..system import SystemState + +SID_CREDENTIAL_ACCESS = 100033 + +_EXACT_FILES = frozenset({ + "/etc/shadow", + "/etc/gshadow", + "/etc/security/opasswd", +}) + +_BROWSER_SECRET_NAMES = frozenset({ + "logins.json", + "key4.db", + "Login Data", + "Cookies", +}) + + +def _fd_targets(proc) -> dict[str, str]: + targets = getattr(proc, "fd_targets", {}) + return targets() if callable(targets) else targets + + +def _clean_target(target: str) -> str: + return target.removesuffix(" (deleted)") + + +def _matches_extra(path: str, entries: tuple[str, ...]) -> bool: + for entry in entries: + if not entry: + continue + if entry.endswith("/") and path.startswith(entry): + return True + if path == entry or path.startswith(entry.rstrip("/") + "/"): + return True + return False + + +def _sensitive_kind(path: str, cfg: Config) -> str: + if not path.startswith("/"): + return "" + if path in _EXACT_FILES: + return "system credential database" + if _matches_extra(path, cfg.credential_access_extra_paths): + return "configured credential path" + if "/.ssh/" in path: + name = path.rsplit("/", 1)[-1] + if name.startswith("id_") or name.endswith(".pem") or name.endswith(".key"): + return "private SSH key" + if path.startswith("/etc/NetworkManager/system-connections/"): + return "NetworkManager secret profile" + name = path.rsplit("/", 1)[-1] + if name in _BROWSER_SECRET_NAMES: + lowered = path.lower() + if any(marker in lowered for marker in ( + "/.mozilla/", "/firefox/", "/chromium/", "/google-chrome/", + "/chrome/", "/brave", "/vivaldi/", "/edge/", + )): + return "browser credential store" + return "" + + +def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: + for proc in state.processes: + comm = proc.comm or "?" + if comm in cfg.credential_access_allow_comms: + continue + for fd, raw_target in _fd_targets(proc).items(): + target = _clean_target(raw_target) + kind = _sensitive_kind(target, cfg) + if not kind: + continue + yield Alert( + severity=Severity.CRITICAL, + signature="credential_access", + key=f"cred:{proc.pid}:{target}", + detail=( + f"pid={proc.pid} comm={comm} fd={fd} has {kind} open: " + f"{target}" + ), + pids=(proc.pid,), + sid=SID_CREDENTIAL_ACCESS, + classtype="credential-theft", + ) + break diff --git a/enodia_sentinel/detectors/input_snooper.py b/enodia_sentinel/detectors/input_snooper.py new file mode 100644 index 0000000..bb5d153 --- /dev/null +++ b/enodia_sentinel/detectors/input_snooper.py @@ -0,0 +1,54 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""input_snooper — processes reading keyboard/HID event devices. + +Keyloggers commonly open ``/dev/input/event*`` or HID raw devices directly. +Normal display servers, compositors, and input remappers can do this too, so +known local input stack processes are configurable allow-list entries. +""" +from __future__ import annotations + +from collections.abc import Iterator + +from ..alert import Alert, Severity +from ..config import Config +from ..system import SystemState + +SID_INPUT_SNOOPER = 100032 + +_INPUT_PREFIXES = ( + "/dev/input/event", + "/dev/uinput", + "/dev/hidraw", +) + + +def _fd_targets(proc) -> dict[str, str]: + targets = getattr(proc, "fd_targets", {}) + return targets() if callable(targets) else targets + + +def _is_input_device(target: str) -> bool: + return target.startswith(_INPUT_PREFIXES) + + +def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: + for proc in state.processes: + comm = proc.comm or "?" + if comm in cfg.input_snooper_allow_comms: + continue + for fd, target in _fd_targets(proc).items(): + if not _is_input_device(target): + continue + yield Alert( + severity=Severity.HIGH, + signature="input_snooper", + key=f"input:{proc.pid}:{target}", + detail=( + f"pid={proc.pid} comm={comm} fd={fd} has input device " + f"open: {target}" + ), + pids=(proc.pid,), + sid=SID_INPUT_SNOOPER, + classtype="credential-keylogging", + ) + break diff --git a/enodia_sentinel/detectors/stealth_network.py b/enodia_sentinel/detectors/stealth_network.py new file mode 100644 index 0000000..aa371a0 --- /dev/null +++ b/enodia_sentinel/detectors/stealth_network.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""stealth_network — unusual socket families used for covert channels. + +TCP and UDP are covered by reverse-shell, egress, and listener checks. This +detector watches families attackers use to slide around those paths: raw IP, +SCTP, DCCP, packet sockets, XDP, TIPC, vsock, and MPTCP. +""" +from __future__ import annotations + +from collections.abc import Iterator + +from ..alert import Alert, Severity +from ..config import Config +from ..netutil import ip_in_cidrs, is_public_ip, split_host_port +from ..system import SystemState + +SID_STEALTH_NETWORK = 100036 + +_WATCHED_KINDS = frozenset({ + "raw", "sctp", "dccp", "packet", "mptcp", "tipc", "xdp", "vsock", +}) +_ALWAYS_HIGH = frozenset({"raw", "packet", "xdp"}) +_ACTIVE_STATES = frozenset({ + "ESTAB", "LISTEN", "UNCONN", "CONNECTED", "SYN-SENT", "SYN-RECV", +}) + + +def _peer_is_public(peer: str, cfg: Config) -> bool: + host, _port = split_host_port(peer) + if not is_public_ip(host): + return False + return not ip_in_cidrs(host, cfg.egress_allow_cidrs) + + +def _severity(kind: str, state: str, peer: str, cfg: Config) -> Severity: + if kind in _ALWAYS_HIGH: + return Severity.HIGH + if state == "LISTEN" or _peer_is_public(peer, cfg): + return Severity.HIGH + return Severity.MEDIUM + + +def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: + allowed_kinds = set(cfg.stealth_network_allow_kinds) + for sock in state.sockets: + kind = sock.kind.lower() + if kind not in _WATCHED_KINDS: + continue + if kind in allowed_kinds: + continue + if sock.comm and sock.comm in cfg.stealth_network_allow_comms: + continue + if sock.state and sock.state not in _ACTIVE_STATES: + continue + yield Alert( + severity=_severity(kind, sock.state, sock.peer, cfg), + signature="stealth_network", + key=f"stealthnet:{kind}:{sock.pid}:{sock.local}:{sock.peer}", + detail=( + f"{kind} socket state={sock.state} local={sock.local} " + f"peer={sock.peer} comm={sock.comm or '?'} pid={sock.pid or '?'}" + ), + pids=(sock.pid,) if sock.pid else (), + sid=SID_STEALTH_NETWORK, + classtype="covert-channel", + ) diff --git a/enodia_sentinel/rootcheck.py b/enodia_sentinel/rootcheck.py index 43eb6c7..9c44e61 100644 --- a/enodia_sentinel/rootcheck.py +++ b/enodia_sentinel/rootcheck.py @@ -32,6 +32,9 @@ SID_KNOWN_ROOTKIT_MODULE = 100028 SID_TAINTED_MODULE = 100029 SID_KERNEL_TAINTED = 100030 SID_HIDDEN_UDP_PORT = 100031 +SID_HIDDEN_RAW_SOCKET = 100034 +SID_RAW_ICMP_SOCKET = 100035 +SID_HIDDEN_PROTOCOL_SOCKET = 100037 IFF_PROMISC = 0x100 @@ -62,6 +65,13 @@ TAINT_FLAGS = { } HIGH_RISK_TAINT_BITS = frozenset({1, 3, 7, 12, 13}) +ICMP_RAW_PROTOCOLS = frozenset({1, 58}) # ICMP, IPv6-ICMP +RAW_PROTOCOL_NAMES = { + "icmp": 1, + "ipv6-icmp": 58, + "icmp6": 58, + "sctp": 132, +} # --- pure diff cores (unit-tested without a live system) ------------------ @@ -86,6 +96,18 @@ def find_hidden_udp_ports(procnet_ports: set[int], ss_ports: set[int]) -> set[in return procnet_ports - ss_ports +def find_hidden_raw_protocols(procnet_protocols: set[int], + ss_protocols: set[int]) -> set[int]: + """Raw socket protocols in /proc/net/raw that ss does not report.""" + return procnet_protocols - ss_protocols + + +def find_hidden_protocol_kinds(procnet_kinds: set[str], + ss_kinds: set[str]) -> set[str]: + """Special socket families visible in /proc/net but absent from ss.""" + return procnet_kinds - ss_kinds + + def _norm_module(name: str) -> str: return name.lower().replace("-", "_") @@ -238,6 +260,60 @@ def procnet_udp_ports() -> set[int]: return _procnet_udp_ports("/proc/net/udp") | _procnet_udp_ports("/proc/net/udp6") +def _procnet_raw_protocols(path: str) -> set[int]: + protocols: 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_PROTO + proto = int(local.rsplit(":", 1)[1], 16) + if proto: + protocols.add(proto) + except (OSError, ValueError): + pass + return protocols + + +def procnet_raw_protocols() -> set[int]: + return (_procnet_raw_protocols("/proc/net/raw") + | _procnet_raw_protocols("/proc/net/raw6")) + + +def _procnet_has_rows(path: str) -> bool: + try: + with open(path) as fh: + next(fh, None) # header + for line in fh: + if line.strip(): + return True + except OSError: + pass + return False + + +def procnet_protocol_kinds() -> set[str]: + kinds: set[str] = set() + checks = { + "sctp": ( + "/proc/net/sctp/eps", + "/proc/net/sctp/assocs", + "/proc/net/sctp/remaddr", + ), + "dccp": ("/proc/net/dccp", "/proc/net/dccp6"), + "packet": ("/proc/net/packet",), + "tipc": ("/proc/net/tipc/socket",), + "xdp": ("/proc/net/xdp",), + } + for kind, paths in checks.items(): + if any(_procnet_has_rows(path) for path in paths): + kinds.add(kind) + return kinds + + def ss_listen_ports(state: SystemState) -> set[int]: ports: set[int] = set() for s in state.listening_sockets(): @@ -258,6 +334,31 @@ def ss_udp_ports(state: SystemState) -> set[int]: return ports +def _protocol_from_endpoint(endpoint: str) -> int | None: + tail = endpoint.rsplit(":", 1) + if len(tail) != 2: + return None + label = tail[1].strip("[]").lower() + if label.isdigit(): + return int(label) + return RAW_PROTOCOL_NAMES.get(label) + + +def ss_raw_protocols(state: SystemState) -> set[int]: + protocols: set[int] = set() + for s in state.sockets: + if s.kind != "raw": + continue + proto = _protocol_from_endpoint(s.local) + if proto is not None: + protocols.add(proto) + return protocols + + +def ss_protocol_kinds(state: SystemState) -> set[str]: + return {s.kind for s in state.sockets if s.kind} + + def promiscuous_interfaces() -> list[str]: out: list[str] = [] base = "/sys/class/net" @@ -361,6 +462,37 @@ def run(cfg: Config, state: SystemState | None = None) -> Iterator[Alert]: "(tool may be hooked): " + ", ".join(map(str, sorted(hidden_udp)))), sid=SID_HIDDEN_UDP_PORT, classtype="rootkit-hidden-port") + raw_protocols = procnet_raw_protocols() + hidden_raw = find_hidden_raw_protocols(raw_protocols, ss_raw_protocols(state)) + if hidden_raw: + yield Alert( + severity=Severity.HIGH, signature="rootkit_hidden_raw_socket", + key=f"rk:hidraw:{min(hidden_raw)}", + detail=("raw socket protocol(s) in /proc/net/raw not reported by ss " + "(tool may be hooked): " + ", ".join(map(str, sorted(hidden_raw)))), + sid=SID_HIDDEN_RAW_SOCKET, classtype="rootkit-hidden-socket") + + raw_icmp = raw_protocols & ICMP_RAW_PROTOCOLS + if raw_icmp: + yield Alert( + severity=Severity.HIGH, signature="raw_icmp_socket", + key=f"rk:rawicmp:{min(raw_icmp)}", + detail=("persistent raw ICMP socket protocol(s) visible in /proc/net/raw: " + + ", ".join(map(str, sorted(raw_icmp))) + + " — validate authorized ping/monitoring tools or ICMP knockers"), + sid=SID_RAW_ICMP_SOCKET, classtype="raw-socket-backdoor") + + hidden_protocols = find_hidden_protocol_kinds( + procnet_protocol_kinds(), ss_protocol_kinds(state)) + if hidden_protocols: + yield Alert( + severity=Severity.HIGH, signature="rootkit_hidden_protocol_socket", + key=f"rk:hidproto:{sorted(hidden_protocols)[0]}", + detail=("special protocol socket family present in /proc/net but " + "absent from ss output (tool may be hooked): " + + ", ".join(sorted(hidden_protocols))), + sid=SID_HIDDEN_PROTOCOL_SOCKET, classtype="rootkit-hidden-socket") + for iface in promiscuous_interfaces(): yield Alert( severity=Severity.MEDIUM, signature="promiscuous_interface", diff --git a/enodia_sentinel/system.py b/enodia_sentinel/system.py index 9f1988f..5740bb6 100644 --- a/enodia_sentinel/system.py +++ b/enodia_sentinel/system.py @@ -18,6 +18,10 @@ from pathlib import Path _INO_RE = re.compile(r"\bino:(\d+)") _USER_RE = re.compile(r'users:\(\("([^"]+)",pid=(\d+),fd=\d+') +_SS_NETIDS = frozenset({ + "tcp", "udp", "raw", "sctp", "dccp", "mptcp", + "p_raw", "p_dgr", "packet", "tipc", "xdp", "vsock", +}) @dataclass @@ -65,6 +69,23 @@ class Process: out[k] = v return out + @cached_property + def fd_targets(self) -> dict[str, str]: + """Open file descriptor targets, keyed by fd number as a string.""" + out: dict[str, str] = {} + fd_dir = f"/proc/{self.pid}/fd" + try: + names = sorted(os.listdir(fd_dir), + key=lambda x: int(x) if x.isdigit() else 0) + except OSError: + return out + for name in names: + try: + out[name] = os.readlink(os.path.join(fd_dir, name)) + except OSError: + continue + return out + @cached_property def status(self) -> dict[str, str]: out: dict[str, str] = {} @@ -105,6 +126,7 @@ class Socket: inode: int | None comm: str pid: int | None + kind: str = "" class SystemState: @@ -166,8 +188,19 @@ class SystemState: if self._injected_socks is not None: return self._injected_socks out: list[Socket] = [] - for args in (["-tanep"], ["-uanep"]): - out.extend(self._parse_ss(self._run_ss(args))) + for args, kind in ( + (["-tanep"], "tcp"), + (["-uanep"], "udp"), + (["-wanep"], "raw"), + (["-Sanep"], "sctp"), + (["-danep"], "dccp"), + (["-0anep"], "packet"), + (["-Manep"], "mptcp"), + (["--tipc", "-anep"], "tipc"), + (["--xdp", "-anep"], "xdp"), + (["--vsock", "-anep"], "vsock"), + ): + out.extend(self._parse_ss(self._run_ss(args), kind=kind)) return out @staticmethod @@ -182,13 +215,16 @@ class SystemState: return "" @staticmethod - def _parse_ss(text: str) -> list[Socket]: + def _parse_ss(text: str, *, kind: str = "") -> list[Socket]: socks: list[Socket] = [] for line in text.splitlines(): parts = line.split() if len(parts) < 5: continue - state, _rq, _sq, local, peer = parts[:5] + if parts[0].lower() in _SS_NETIDS and len(parts) >= 6: + state, local, peer = parts[1], parts[4], parts[5] + else: + state, local, peer = parts[0], parts[3], parts[4] rest = line ino_m = _INO_RE.search(rest) usr_m = _USER_RE.search(rest) @@ -199,6 +235,7 @@ class SystemState: inode=int(ino_m.group(1)) if ino_m else None, comm=usr_m.group(1) if usr_m else "", pid=int(usr_m.group(2)) if usr_m else None, + kind=kind, )) return socks diff --git a/tests/test_detectors.py b/tests/test_detectors.py index be76b75..9be118e 100755 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -6,7 +6,9 @@ from dataclasses import dataclass, field from enodia_sentinel.alert import Severity from enodia_sentinel.config import Config from enodia_sentinel.detectors import ( - deleted_exe, egress, ld_preload, new_listener, new_suid, reverse_shell, + credential_access, deleted_exe, egress, input_snooper, ld_preload, + new_listener, new_suid, reverse_shell, + stealth_network, ) from enodia_sentinel.system import Socket, SystemState @@ -23,6 +25,7 @@ class FakeProc: ppid: int = 1 uid: int = 0 cwd: str = "/" + fd_targets: dict = field(default_factory=dict) def stdio_socket_inode(self): return self._stdio_inode @@ -90,6 +93,71 @@ class TestDeletedExe(unittest.TestCase): self.assertEqual(list(deleted_exe.detect(state, cfg())), []) +class TestInputSnooper(unittest.TestCase): + def test_input_device_alerts(self): + proc = FakeProc(pid=320, comm="hoxha", + fd_targets={"7": "/dev/input/event3"}) + alerts = list(input_snooper.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "input_snooper") + self.assertEqual(alerts[0].severity, Severity.HIGH) + + def test_allowlisted_input_process_ignored(self): + proc = FakeProc(pid=321, comm="Xorg", + fd_targets={"7": "/dev/input/event3"}) + self.assertEqual(list(input_snooper.detect(SystemState(processes=[proc]), cfg())), []) + + +class TestCredentialAccess(unittest.TestCase): + def test_shadow_access_alerts(self): + proc = FakeProc(pid=330, comm="hoxha", + fd_targets={"4": "/etc/shadow"}) + alerts = list(credential_access.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "credential_access") + self.assertEqual(alerts[0].severity, Severity.CRITICAL) + + def test_private_key_access_alerts(self): + proc = FakeProc(pid=331, comm="collector", + fd_targets={"5": "/home/luna/.ssh/id_ed25519"}) + alerts = list(credential_access.detect(SystemState(processes=[proc]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertIn("private SSH key", alerts[0].detail) + + def test_browser_access_by_browser_ignored(self): + proc = FakeProc( + pid=332, comm="firefox", + fd_targets={"8": "/home/luna/.mozilla/firefox/a/key4.db"}, + ) + self.assertEqual(list(credential_access.detect(SystemState(processes=[proc]), cfg())), []) + + +class TestStealthNetwork(unittest.TestCase): + def test_sctp_public_peer_alerts(self): + sock = Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:4443", + 1, "hoxha", 340, "sctp") + alerts = list(stealth_network.detect(SystemState(sockets=[sock]), cfg())) + self.assertEqual(len(alerts), 1) + self.assertEqual(alerts[0].signature, "stealth_network") + self.assertEqual(alerts[0].severity, Severity.HIGH) + + def test_packet_socket_allowlisted_comm_ignored(self): + sock = Socket("UNCONN", "*:eth0", "*", 1, "tcpdump", 341, "packet") + self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), cfg())), []) + + def test_allowlisted_kind_ignored(self): + c = Config() + c.stealth_network_allow_kinds = frozenset({"mptcp"}) + sock = Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443", + 1, "curl", 342, "mptcp") + self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), c)), []) + + def test_tcp_ignored(self): + sock = Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443", + 1, "python3", 343, "tcp") + self.assertEqual(list(stealth_network.detect(SystemState(sockets=[sock]), cfg())), []) + + class TestNewSuid(unittest.TestCase): def test_new_in_writable_is_critical(self): state = SystemState( diff --git a/tests/test_rootcheck.py b/tests/test_rootcheck.py index 108986e..a5b2b68 100644 --- a/tests/test_rootcheck.py +++ b/tests/test_rootcheck.py @@ -32,6 +32,15 @@ class TestDiffCores(unittest.TestCase): self.assertEqual( rootcheck.find_hidden_udp_ports({53, 5353, 4444}, {53, 5353}), {4444}) + def test_hidden_raw_protocols_in_procnet_not_in_ss(self): + self.assertEqual( + rootcheck.find_hidden_raw_protocols({1, 58}, {58}), {1}) + + def test_hidden_protocol_kinds_in_procnet_not_in_ss(self): + self.assertEqual( + rootcheck.find_hidden_protocol_kinds({"sctp", "packet"}, {"packet"}), + {"sctp"}) + def test_known_rootkit_module_names_normalized(self): self.assertEqual( rootcheck.find_known_rootkit_modules({"ext4", "diamorphine", "adore-ng"}), @@ -62,6 +71,33 @@ class TestSsPortsFromState(unittest.TestCase): state = SystemState(sockets=socks) self.assertEqual(rootcheck.ss_udp_ports(state), {53, 5353}) + def test_extracts_raw_protocols_from_injected_state(self): + socks = [ + Socket("UNCONN", "0.0.0.0:icmp", "0.0.0.0:*", 10, "hoxha", 1, "raw"), + Socket("UNCONN", "[::]:ipv6-icmp", "[::]:*", 11, "monitor", 2, "raw"), + Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 12, "sshd", 3, "tcp"), + ] + state = SystemState(sockets=socks) + self.assertEqual(rootcheck.ss_raw_protocols(state), {1, 58}) + + def test_extracts_socket_kinds_from_injected_state(self): + state = SystemState(sockets=[ + Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:4443", 10, "h", 1, "sctp"), + Socket("UNCONN", "*:eth0", "*", 11, "tcpdump", 2, "packet"), + ]) + self.assertEqual(rootcheck.ss_protocol_kinds(state), {"sctp", "packet"}) + + def test_ss_parser_handles_netid_column(self): + text = 'p_raw UNCONN 0 0 *:eth0 * users:(("dhcpcd",pid=12,fd=7)) ino:123' + sock = SystemState._parse_ss(text, kind="packet")[0] + self.assertEqual(sock.state, "UNCONN") + self.assertEqual(sock.local, "*:eth0") + self.assertEqual(sock.peer, "*") + self.assertEqual(sock.comm, "dhcpcd") + self.assertEqual(sock.pid, 12) + self.assertEqual(sock.inode, 123) + self.assertEqual(sock.kind, "packet") + class TestRunIntegration(unittest.TestCase): """Drive run() with every system view monkeypatched to a known state.""" @@ -71,8 +107,9 @@ class TestRunIntegration(unittest.TestCase): self._saved = {} for name in ("proc_pids", "alive_pids", "proc_modules", "sys_live_modules", "procnet_listen_ports", - "procnet_udp_ports", "promiscuous_interfaces", "module_taints", - "kernel_taint"): + "procnet_udp_ports", "procnet_raw_protocols", + "procnet_protocol_kinds", + "promiscuous_interfaces", "module_taints", "kernel_taint"): self._saved[name] = getattr(rootcheck, name) def tearDown(self): @@ -86,6 +123,8 @@ class TestRunIntegration(unittest.TestCase): rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", set()) rootcheck.procnet_listen_ports = lambda: views.get("procnet_ports", set()) rootcheck.procnet_udp_ports = lambda: views.get("procnet_udp_ports", set()) + rootcheck.procnet_raw_protocols = lambda: views.get("procnet_raw_protocols", set()) + rootcheck.procnet_protocol_kinds = lambda: views.get("procnet_protocol_kinds", set()) rootcheck.promiscuous_interfaces = lambda: views.get("promisc", []) rootcheck.module_taints = lambda: views.get("module_taints", {}) rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0) @@ -101,10 +140,14 @@ class TestRunIntegration(unittest.TestCase): proc_pids={1}, alive_pids={1}, proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"}, procnet_ports={22, 31337}, procnet_udp_ports={53, 4444}, + procnet_raw_protocols={1, 58}, + procnet_protocol_kinds={"sctp", "packet"}, promisc=["eth0"]) state = SystemState(sockets=[ 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), + Socket("UNCONN", "0.0.0.0:ipv6-icmp", "0.0.0.0:*", 12, "monitor", 3, "raw"), + Socket("UNCONN", "*:eth0", "*", 13, "tcpdump", 4, "packet"), ]) alerts = {a.signature: a for a in rootcheck.run(self.cfg, state)} self.assertIn("rootkit_hidden_module", alerts) @@ -115,6 +158,12 @@ class TestRunIntegration(unittest.TestCase): 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("rootkit_hidden_raw_socket", alerts) + self.assertIn("1", alerts["rootkit_hidden_raw_socket"].detail) + self.assertIn("raw_icmp_socket", alerts) + self.assertEqual(alerts["raw_icmp_socket"].sid, rootcheck.SID_RAW_ICMP_SOCKET) + self.assertIn("rootkit_hidden_protocol_socket", alerts) + self.assertIn("sctp", alerts["rootkit_hidden_protocol_socket"].detail) self.assertIn("promiscuous_interface", alerts) self.assertEqual(alerts["promiscuous_interface"].sid, rootcheck.SID_PROMISC)