# 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:` | | 100011 | `ld_preload` | CRITICAL | `rootkit-preload` | `ldp:global`, `ldp:` | | 100012 | `deleted_exe` | CRITICAL | `fileless-execution` | `del:` | | 100013 | `new_listener` | HIGH | `backdoor-listener` | `lis:/` | | 100014 | `new_suid` | CRITICAL / HIGH | `privilege-escalation` | `suid:` | | 100015 | `persistence` | HIGH | `persistence` | `persist:` | | 100016 | `egress` | HIGH | `c2-exfil` | `egr::` | | 100032 | `input_snooper` | HIGH | `credential-keylogging` | `input::` | | 100033 | `credential_access` | CRITICAL | `credential-theft` | `cred::` | | 100036 | `stealth_network` | MEDIUM / HIGH | `covert-channel` | `stealthnet::::` | | 100039 | `memory_obfuscation` | HIGH / CRITICAL | `memory-obfuscation` | `mem:memory_obfuscation::-` | | 100048 | `process_hiding_library` | CRITICAL | `process-hiding` | `mem:process_hiding_library::-` | | 100049 | `process_injection_library` | HIGH | `process-injection` | `mem:process_injection_library::-` | | 100074 | `first_public_destination` | MEDIUM | `network-rarity` | `firstdest::` | | 100075 | `first_listener_port` | MEDIUM | `network-rarity` | `firstlisten::` | `-` 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:`, `pids=(pid,)`, detail `pid=… comm=… stdio=net-socket peer=[local -> peer] cmd=[]`. - 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:`, `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:`, `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::`, `pids=(pid,)`, detail `pid=… comm=… fd=… has input device open: `. - 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::`, `pids=(pid,)`, detail `pid=… comm=… fd=… has open: `. - 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::::`, `pids=(pid,)` only when pid is known, detail ` socket state=… local=… peer=… comm= pid=`. ## 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::`, `pids=(pid,)` when pid known, detail `pid=… comm=… -> : (interpreter to public IP)`. ## 2.10 `memory_obfuscation` — SIDs 100039 / 100048 / 100049 Inspects `/proc//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:::-`, `pids=(pid,)`, detail `pid=… comm=… range=-`. ## 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:/`, `pids=(pid,)` when known, detail `new listening socket by 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:`, no pids, detail `SUID/SGID binary in writable dir: ` (hot) or `new SUID/SGID binary: `. ## 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:`, no pids, detail `persistence file modified: `. ## 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::`, `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::`. 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.