enodia-sentinal/docs/behavior/02-poll-detectors.md

276 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# 2. Poll Detectors
Scope: the per-detector behavioral contract for the twelve pure detectors in
`detectors/`. Each is a `detect(state, cfg) -> Iterable[Alert]` over the
`SystemState` built in §1. This section documents, per detector, the inputs it
reads, the match algorithm, the exact alert it emits, and its allow-list /
fail-open rules. The registry order and the baseline-arming gate are in §1.3 /
§1.5; the alert fields and dedup keying are in §1.1.
Every detector is fail-open by inheritance: it reads already-captured
`SystemState` fields, and missing `/proc`/`ss` data is an empty value, never an
exception.
## 2.0 Signature summary
The stable contract. A port emitting a different SID, severity, classtype, or
key for the same condition is a parity break.
| SID | signature | severity | classtype | key |
|-----|-----------|----------|-----------|-----|
| 100010 | `reverse_shell` | CRITICAL | `c2-reverse-shell` | `rsh:<pid>` |
| 100011 | `ld_preload` | CRITICAL | `rootkit-preload` | `ldp:global`, `ldp:<pid>` |
| 100012 | `deleted_exe` | CRITICAL | `fileless-execution` | `del:<pid>` |
| 100013 | `new_listener` | HIGH | `backdoor-listener` | `lis:<port>/<comm>` |
| 100014 | `new_suid` | CRITICAL / HIGH | `privilege-escalation` | `suid:<path>` |
| 100015 | `persistence` | HIGH | `persistence` | `persist:<path>` |
| 100016 | `egress` | HIGH | `c2-exfil` | `egr:<pid>:<host>` |
| 100032 | `input_snooper` | HIGH | `credential-keylogging` | `input:<pid>:<target>` |
| 100033 | `credential_access` | CRITICAL | `credential-theft` | `cred:<pid>:<target>` |
| 100036 | `stealth_network` | MEDIUM / HIGH | `covert-channel` | `stealthnet:<kind>:<pid>:<local>:<peer>` |
| 100039 | `memory_obfuscation` | HIGH / CRITICAL | `memory-obfuscation` | `mem:memory_obfuscation:<pid>:<lo>-<hi>` |
| 100048 | `process_hiding_library` | CRITICAL | `process-hiding` | `mem:process_hiding_library:<pid>:<lo>-<hi>` |
| 100049 | `process_injection_library` | HIGH | `process-injection` | `mem:process_injection_library:<pid>:<lo>-<hi>` |
| 100074 | `first_public_destination` | MEDIUM | `network-rarity` | `firstdest:<comm>:<ip:port>` |
| 100075 | `first_listener_port` | MEDIUM | `network-rarity` | `firstlisten:<comm>:<port>` |
`<lo>-<hi>` are the map bounds formatted as lowercase hex (`{start:x}-{end:x}`).
Allow-list config keys and their defaults live in `config.py`; this section
names each key where used. The Go port mirrors the same defaults (see
`go-agent/internal/config`), so the values are asserted by config tests, not
repeated here.
---
## 2.1 Shared address helpers (`netutil.py`)
Used by `egress`, `stealth_network`, and `first_seen`.
- `split_host_port(endpoint)` — splits an `ss` endpoint. If it starts with `[`,
partition on `]` (IPv6, e.g. `[::1]:22``::1`, `22`); else `rpartition(":")`
(e.g. `10.0.0.2%eth0:443``10.0.0.2%eth0`, `443`). Returns `(host, port)`
strings.
- `parse_addr(addr)` — strips `[]`, drops an IPv6 `%zone`, returns an
`ipaddress` object or `None`.
- `is_public_ip(addr)``True` only if `parse_addr(...).is_global`: excludes
loopback, RFC1918, link-local, CGNAT (100.64/10), and other non-global
ranges. Unparseable → `False`.
- `ip_in_cidrs(addr, cidrs)``True` if `addr` is inside any CIDR (the operator
trust list); version-matched; bad CIDRs skipped.
## 2.2 Package provenance (`provenance.py`)
Used by `new_listener` (§2.11) as an optional suppressor.
`package_owner(path)` returns the owning package string or `None`, using the
first available of `pacman -Qo` / `dpkg -S` / `rpm -qf` (5s timeout each, cached
via `lru_cache`; `(deleted)` suffix stripped first). `is_package_owned(path) =
package_owner(path) is not None`. **Contract:** ownership *ranks/explains*, it
never proves safety — it may suppress a `new_listener` alert but is never used
to silently drop anything else.
---
## 2.3 `reverse_shell` — SID 100010, CRITICAL
Interpreter process with a **network** socket on its stdio.
- Input: `state.processes`, `state.net_peer_by_inode`.
- Match, per process: `proc.comm in cfg.interpreters`; `proc.stdio_socket_inode()`
is not `None` (fd 0/1/2 links to `socket:[…]`); and that inode appears in the
network socket table (`net_peer_by_inode`). The last condition is what
excludes benign unix sockets / pipes and keeps false positives near zero.
- Emit: key `rsh:<pid>`, `pids=(pid,)`, detail
`pid=… comm=… stdio=net-socket peer=[local -> peer] cmd=[<cmdline first 90>]`.
- Config: `interpreters` allow-*set of shell/scripting comms* (membership means
"watch", not "trust").
## 2.4 `ld_preload` — SID 100011, CRITICAL
Two independent signatures, same SID:
1. **Global**`/etc/ld.so.preload` exists and is non-empty (fail-open read).
Key `ldp:global`, no pids, detail includes the file contents (newlines →
spaces).
2. **Per-process** — a process whose `environ["LD_PRELOAD"]` starts with a
suspicious prefix: `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/", "./")`.
Key `ldp:<pid>`, `pids=(pid,)`.
Note the global check reads `/etc/ld.so.preload` directly (not the injected
`state`), so a port must read it live inside the detector or supply it on state.
## 2.5 `deleted_exe` — SID 100012, CRITICAL
Process running from a deleted or memfd-backed binary in an attacker-controlled
location.
- Match: `proc.exe` is fileless — `"memfd:" in exe`, **or** `"(deleted)" in exe`
**and** `exe` starts with `("/tmp/", "/dev/shm/", "/var/tmp/", "/run/")`.
Normal-path deleted exes (a daemon still running after a package upgrade) are
ignored on purpose.
- Emit: key `del:<pid>`, `pids=(pid,)`, detail `pid=… comm=… exe=[…]`.
## 2.6 `input_snooper` — SID 100032, HIGH
Process holding a keyboard/HID event device open (keylogger tell).
- Match, per process: skip if `comm in cfg.input_snooper_allow_comms`; then scan
`fd_targets` for a target starting with `("/dev/input/event", "/dev/uinput",
"/dev/hidraw")`. **First** matching fd emits and then `break` (at most one
alert per process).
- Emit: key `input:<pid>:<target>`, `pids=(pid,)`, detail
`pid=… comm=… fd=… has input device open: <target>`.
- Config: `input_snooper_allow_comms` (display servers, compositors, remappers).
## 2.7 `credential_access` — SID 100033, CRITICAL
Live process with a sensitive credential file open.
- Match, per process: skip if `comm in cfg.credential_access_allow_comms`; then
per fd target (with a trailing ` (deleted)` stripped), classify via
`_sensitive_kind`, first hit emits then `break`. Classification precedence:
1. exact file in `{/etc/shadow, /etc/gshadow, /etc/security/opasswd}`
"system credential database";
2. matches `cfg.credential_access_extra_paths` (entry ending `/` = prefix;
else exact or `entry/` prefix) → "configured credential path";
3. path contains `/.ssh/` and basename starts `id_` or ends `.pem`/`.key`
"private SSH key";
4. path under `/etc/NetworkManager/system-connections/` → "NetworkManager
secret profile";
5. basename in `{logins.json, key4.db, "Login Data", Cookies}` **and** path
(lowercased) contains a browser marker (`/.mozilla/`, `/firefox/`,
`/chromium/`, `/google-chrome/`, `/chrome/`, `/brave`, `/vivaldi/`,
`/edge/`) → "browser credential store".
Non-absolute paths are never sensitive.
- Emit: key `cred:<pid>:<target>`, `pids=(pid,)`, detail
`pid=… comm=… fd=… has <kind> open: <target>`.
- Config: `credential_access_allow_comms`, `credential_access_extra_paths`.
## 2.8 `stealth_network` — SID 100036, MEDIUM or HIGH
Sockets in families attackers use to slide around TCP/UDP checks.
- Watched kinds: `{raw, sctp, dccp, packet, mptcp, tipc, xdp, vsock}` (matched
on `sock.kind.lower()`).
- Skip if: kind in `cfg.stealth_network_allow_kinds`; or `sock.comm` in
`cfg.stealth_network_allow_comms`; or `sock.state` is non-empty and **not** in
`{ESTAB, LISTEN, UNCONN, CONNECTED, SYN-SENT, SYN-RECV}`.
- Severity: HIGH if kind in `{raw, packet, xdp}`; else HIGH if `state == LISTEN`
or the peer is a public IP outside the egress trust list; else MEDIUM.
- Emit: key `stealthnet:<kind>:<pid>:<local>:<peer>`, `pids=(pid,)` only when
pid is known, detail `<kind> socket state=… local=… peer=… comm=<comm or ?>
pid=<pid or ?>`.
## 2.9 `egress` — SID 100016, HIGH
Interpreter holding an outbound connection to a public IP.
- Match, per `established_sockets()`: `s.comm in cfg.interpreters`; peer host is
public (`is_public_ip`); peer not in `cfg.egress_allow_cidrs`.
- Emit: key `egr:<pid>:<host>`, `pids=(pid,)` when pid known, detail
`pid=… comm=… -> <host>:<port> (interpreter to public IP)`.
## 2.10 `memory_obfuscation` — SIDs 100039 / 100048 / 100049
Inspects `/proc/<pid>/maps` shapes (never reads process memory). Per process,
each map is classified by `_indicator`; **at most one alert per signature per
process** (a per-process `seen` set). Precedence within a single map:
1. If `path` matches `cfg.memory_obfuscation_allow_paths` (prefix) → ignore.
2. `path` basename contains a hide hint (`libhide`, `libprocesshide`,
`process_hide`, `proc_hide`, `rootkit_hide`) → **`process_hiding_library`**,
SID 100048, CRITICAL, classtype `process-hiding`.
3. executable **and** library from a writable/runtime path (`/tmp/`, `/dev/shm/`,
`/var/tmp/`, `/run/user/` with a `.so`/`.so.`/`.dylib` in the name) →
**`process_injection_library`**, SID 100049, HIGH, classtype
`process-injection`.
4. executable **and** path contains `memfd:` or `(deleted)`
**`memory_obfuscation`**, SID 100039, CRITICAL.
5. executable **and** writable (RWX) → `memory_obfuscation`, HIGH.
6. executable **and** anonymous (`""`, `[heap]`, `[stack]`, `[anon…`,
`[stack:…`) → `memory_obfuscation`, HIGH.
The `memory_obfuscation` signature (only) is additionally suppressed when
`comm in cfg.memory_obfuscation_allow_comms` (JITs: java, node, browsers,
dotnet, qemu, wine, wasmtime). The library signatures are **not** suppressed by
that allow-list.
- Emit: key `mem:<signature>:<pid>:<lo>-<hi>`, `pids=(pid,)`, detail
`pid=… comm=… <indicator detail> range=<lo>-<hi>`.
## 2.11 `new_listener` — SID 100013, HIGH
New listening socket absent from the startup baseline (see §1.5.1). No-op when
`state.listener_baseline is None` (unarmed).
- Match, per `LISTEN` socket: identity `key = port/comm` where
`port = local.rpartition(":")[2]` and `comm` defaults `?`. Skip if key in
`state.listener_baseline`, or `port in cfg.listener_allow_ports`, or
`comm in cfg.listener_allow_comms`.
- Optional provenance gate: if `cfg.suppress_package_owned_listeners` and the
owning process's `exe` `is_package_owned`, skip.
- Emit: key `lis:<port>/<comm>`, `pids=(pid,)` when known, detail
`new listening socket <local> by comm=<comm>`.
## 2.12 `new_suid` — SID 100014, HIGH or CRITICAL
SUID/SGID binary not in the baseline. No-op when `state.suid_binaries is None`
**or** `state.suid_baseline is None` (see §1.5.3 — no alerts until an async scan
result exists).
- Match, per path in `state.suid_binaries` not in `state.suid_baseline`:
`hot = any(path.startswith(d.rstrip("/") + "/") for d in cfg.suid_hot_dirs)`.
- Severity: CRITICAL if hot (writable dir), else HIGH.
- Emit: key `suid:<path>`, no pids, detail
`SUID/SGID binary in writable dir: <path>` (hot) or `new SUID/SGID binary:
<path>`.
## 2.13 `persistence` — SID 100015, HIGH
Watched persistence-relevant file modified since the previous sweep. No-op when
`state.persist_since is None` (unarmed).
- Input: walk `cfg.watch_persistence` (a dir → recurse `os.walk`, a file →
itself). For each existing file, `mtime = os.lstat(path).st_mtime`
(unreadable → skip).
- Match: `mtime > state.persist_since` (the *previous* sweep's timestamp; §1.5.4).
- Emit: key `persist:<path>`, no pids, detail `persistence file modified:
<path>`.
## 2.14 `first_seen` — SIDs 100074 / 100075, MEDIUM
Network-rarity tracking with a self-owned persistent store. The store format,
the `initialized` learn-once semantics, and the restart asymmetry are specified
in §1.5.5 — not repeated here. Match summary:
- `first_public_destination` (100074): a `comm` reaches an `ESTAB` peer that is
a public IP:port not previously recorded for it. Key
`firstdest:<comm>:<ip:port>`, `pids=(pid,)` when known.
- `first_listener_port` (100075): a `comm` opens a `LISTEN`/`UNCONN` tcp/udp
socket on a numeric non-zero port not previously recorded for it. Key
`firstlisten:<comm>:<port>`.
Both only alert when the store is already `initialized`; the recording of the
new value happens regardless (so the alert fires once).
---
## 2.15 Port parity notes (Go port)
- The Go poll detectors for §2.3§2.14 are **ported and pass the parity
fixture** (`scripts/check-go-parity.py`), covering exact SID/severity/key/
classtype/detail output. This document is the human-readable form of that
fixture's contract.
- `first_seen`/`new_listener`/`new_suid`/`persistence` receive injected baseline
inputs in parity mode and use the opt-in live lifecycle described in §1 when
`--state-dir` is supplied.
- Watch two easily-missed rules when reviewing a port: the **first-match-then-
break** behavior in `input_snooper`/`credential_access` (one alert per
process), and the **per-signature `seen` dedup** in `memory_obfuscation` (one
alert per signature per process, precedence-ordered).
- `ld_preload`'s global check reads `/etc/ld.so.preload` **inside the detector**
(or from an injected state field), not from the shared `SystemState` process
view.