From b40ac4252c0d72b64e82cf825eb9b6dcc69574d3 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 10 Jul 2026 03:07:00 -0700 Subject: [PATCH] Add host correlation and assurance coverage --- README.md | 12 +- docs/COMMAND_REFERENCE.md | 7 +- docs/FLEET_DESIGN.md | 96 ++++++++++++++++ docs/INDEX.md | 1 + docs/ROADMAP.md | 29 +++-- docs/RULES.md | 75 ++++++++++++ docs/RUNBOOKS.md | 31 ++++- docs/SCHEMAS.md | 40 +++++++ enodia_sentinel/assurance.py | 91 +++++++++++++++ enodia_sentinel/config.py | 6 +- enodia_sentinel/correlation.py | 66 +++++++++++ enodia_sentinel/detectors/__init__.py | 4 + enodia_sentinel/detectors/first_seen.py | 108 ++++++++++++++++++ enodia_sentinel/events/host_event.py | 6 + enodia_sentinel/events/host_rules.py | 65 ++++++++++- enodia_sentinel/incident.py | 12 ++ enodia_sentinel/ruleops.py | 9 ++ enodia_sentinel/schemas.py | 2 + enodia_sentinel/sids.py | 12 +- enodia_sentinel/snapshot.py | 5 + enodia_sentinel/tui.py | 13 +++ enodia_sentinel/web.py | 4 + .../100069-interpreter-persistence-write.json | 10 ++ .../100070-persistence-permission-change.json | 10 ++ .../sids/100071-interpreter-setuid-root.json | 9 ++ .../sids/100072-interpreter-setgid-root.json | 9 ++ tests/sid_drills.py | 45 +++++++- tests/test_assurance.py | 28 +++++ tests/test_correlation.py | 45 ++++++++ tests/test_first_seen.py | 42 +++++++ tests/test_rule_compat.py | 29 +++++ tests/test_ruleops.py | 7 ++ tests/test_schema_contracts.py | 20 +++- tests/test_sids.py | 2 +- tests/test_tui.py | 14 +++ tests/test_web.py | 3 + 36 files changed, 944 insertions(+), 23 deletions(-) create mode 100644 docs/FLEET_DESIGN.md create mode 100644 enodia_sentinel/assurance.py create mode 100644 enodia_sentinel/correlation.py create mode 100644 enodia_sentinel/detectors/first_seen.py create mode 100644 tests/fixtures/sids/100069-interpreter-persistence-write.json create mode 100644 tests/fixtures/sids/100070-persistence-permission-change.json create mode 100644 tests/fixtures/sids/100071-interpreter-setuid-root.json create mode 100644 tests/fixtures/sids/100072-interpreter-setgid-root.json create mode 100644 tests/test_assurance.py create mode 100644 tests/test_correlation.py create mode 100644 tests/test_first_seen.py diff --git a/README.md b/README.md index 96f2479..b7ed81a 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ behind reviewed response workflows rather than silent remediation. | Function | Current capability | Direction | |---|---|---| -| Detect | Poll detectors + eBPF exec/syscall rules + typed host-event fixtures | More live event sources and correlation | +| Detect | Poll detectors + eBPF exec/syscall/host-event rules, first-seen rarity, and incident correlation | More live event sources and correlation rules | | Prevent | Posture checks + dry-run containment plans | Audited, explicit `--apply` workflows | -| Verify | FIM, package DB anchor, signed-package checks | External anchors and signed evidence | +| Verify | FIM, package DB anchor, signed-package checks, local hash-chain records | External anchors and signed evidence | | Investigate | Text/JSON snapshots, incidents, dashboard, triage | Richer timelines and evidence export | | Respond | Persisted dry-run response plans | Audited containment and recovery execution | | Assure | Heartbeat, watchdog, self-integrity, rootcheck | Fleet health and attestation-ready anchors | @@ -46,11 +46,14 @@ Project docs: - [Packaging guide](docs/PACKAGING.md) — Arch package layout plus Debian/RPM source-install paths. - [JSON schemas](docs/SCHEMAS.md) — stable v1 contracts for alerts, incidents, - status, response plans, response audit records, and reconciliation acks. + status, integrity/hash-chain state, response plans, response audit records, + and reconciliation acks. - [IR runbooks](docs/RUNBOOKS.md) — confirm/preserve/contain/recover playbooks per alert: reverse shells, persistence, trojaned binaries, rootkits, tampering. - [Rule reference](docs/RULES.md) — generated event-rule documentation: SID, signature, classtype, match fields, expected false positives, and drills. +- [Fleet collector design](docs/FLEET_DESIGN.md) — optional v1.2 collector + boundaries for small multi-host deployments. > **Two implementations, on purpose.** The project began as a bash prototype > (`src/sentinel.sh`, kept as the regression **oracle**) and was re-architected @@ -74,12 +77,15 @@ EDRs are built on: | `stealth_network` | Raw, SCTP, DCCP, packet, MPTCP, TIPC, XDP, or vsock activity | Covert channels often avoid ordinary TCP/UDP paths; expected network managers/sniffers are tunable | | `memory_obfuscation` | Executable anonymous/memfd/deleted mappings, RWX pages, mapped process-hiding libraries, or executable `.so` mappings from writable runtime paths | Encrypted/packed payloads still need executable memory after decrypting; hide/injection libraries must be mapped to hook tools | | `new_listener` | A listening port absent from the startup baseline | Bind shells/backdoors have to listen somewhere | +| `first_public_destination` / `first_listener_port` | New per-process network destinations and listening ports after the initial rarity baseline | C2 and backdoors often introduce network edges that were not normal for that process name | | `new_suid` | A new SUID/SGID binary (critical in a writable dir) | A SUID `/tmp` binary is a textbook privesc trick | | `persistence` | Changes to cron, systemd units, `authorized_keys`, rc files | Persistence has to write somewhere that survives reboot | | `egress` | An interpreter with an established connection to a public IP | C2 beacons and exfil have to phone home | Every detection carries a stable **`sid`** and a **`classtype`** (à la Snort/Suricata), so it can be referenced, tuned, and tracked across revisions. +Incident correlation adds higher-confidence SIDs, such as multi-stage web-RCE +behavior, without hiding the underlying raw alerts. ## Event-driven detection (eBPF + a Snort-style rule engine) diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 1ce995d..1235d47 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -103,6 +103,8 @@ enodia-sentinel list-detectors ``` Prints the poll detectors and whether each is enabled by config. +The list includes rarity state such as `first_seen`; that detector keeps its +local baseline under `log_dir/first-seen.json`. ### `rules` @@ -282,6 +284,9 @@ sharing a PID or a common ancestor — the web server or SSH session they descen from), with a **time-window fallback** for alerts that carry no live PID (FIM drift, package tamper, hidden modules). Each captured snapshot records an `incident_id`; the index lives in `incidents.json` under `log_dir`. +If a correlation rule matches the incident's raw alert set, the incident record +also carries a `correlations` list and may escalate severity without hiding the +underlying snapshots. - `list` — incidents newest-first: id, last seen, severity, alert count, and the signatures involved. @@ -383,7 +388,7 @@ endpoint, for cron/monitoring. Stable JSON contracts are documented in [SCHEMAS.md](SCHEMAS.md). Current v1 IDs cover alert objects/snapshots, incident records/views/exports, status, -response plans, and response audit JSONL records. +integrity/hash-chain state, response plans, and response audit JSONL records. Exit code: diff --git a/docs/FLEET_DESIGN.md b/docs/FLEET_DESIGN.md new file mode 100644 index 0000000..a5e69f0 --- /dev/null +++ b/docs/FLEET_DESIGN.md @@ -0,0 +1,96 @@ +# Fleet Collector Design + +This note sketches the v1.2 fleet layer. It is intentionally optional: a local +Sentinel agent must keep detecting, snapshotting, and serving its local console +even when no collector exists or the collector is down. + +## Goals + +- Give a small fleet one view of host health, stale sensors, repeated + signatures, incidents, and integrity drift. +- Preserve local autonomy. Detection, alert capture, response-plan generation, + and local evidence retention stay on each host. +- Use append-only, auditable ingest. The collector stores what hosts report; it + does not execute response actions on agents. +- Keep transport simple: HTTPS with pinned/self-signed deployments, or a + tailnet address where the operator already has one. + +## Non-Goals + +- No remote shell, remote command execution, or automatic containment in the + first fleet version. +- No dependency on the collector for alerting or local dashboard availability. +- No central trust reset. If a host is suspected compromised, the collector can + show evidence and staleness, but recovery remains an operator-reviewed local + workflow. + +## Agent-to-Collector Payloads + +Agents should push compact JSON records on a cadence and after important local +events: + +| Payload | Source | Minimum contents | +|---|---|---| +| Heartbeat | `status --json` | host id, time, version, daemon state, heartbeat age, sensor states | +| Alert summary | alert snapshot | snapshot name, incident id, severity, SIDs, signatures, counts | +| Incident summary | `incidents.json` | id, host, timestamps, severity, SIDs, signatures, correlations | +| Integrity summary | `/api/integrity` | watchdog, FIM/package anchors, reconciliation, hash-chain summary | +| Hash-chain anchor | `hash-chain.jsonl` | latest `enodia.hash_chain.v1` record or last hash | + +Large evidence bundles stay local at first. The collector stores paths and +digests so an operator can pull evidence deliberately. + +## Enrollment + +Each host gets: + +- a stable host id, +- a per-host bearer token or mTLS client certificate, +- an optional display name and tags, +- a collector URL. + +Enrollment should write local config only after explicit operator approval. A +lost token can be revoked collector-side without changing local detection. + +## Ingest API Shape + +Initial endpoints can stay narrow: + +- `POST /api/v1/ingest/heartbeat` +- `POST /api/v1/ingest/alert` +- `POST /api/v1/ingest/incident` +- `POST /api/v1/ingest/integrity` +- `POST /api/v1/ingest/hash-chain` + +Every request includes host id, timestamp, schema id, monotonic sequence number, +and payload digest. The collector rejects unknown hosts, stale timestamps beyond +policy, replayed sequence numbers, and schema ids it cannot parse. + +## Collector Storage + +A small SQLite database is enough for the first version: + +- `hosts`: identity, tags, token/cert fingerprint, last seen, enrollment state. +- `heartbeats`: append-only health samples. +- `alerts`: compact alert summaries keyed by host and snapshot name. +- `incidents`: latest incident summary plus history rows for changes. +- `integrity`: latest integrity state plus historical samples. +- `hash_anchors`: host hash-chain tips over time. + +The collector dashboard should answer: + +- Which hosts are silent? +- Which hosts have critical incidents? +- Which hosts report repeated SIDs/signatures? +- Which hosts have missing/stale integrity anchors? +- Did a host hash-chain tip change as expected over time? + +## Security Notes + +- Treat collector data as sensitive forensic metadata; require TLS and auth. +- Store per-host credentials hashed or certificate fingerprints only. +- Do not let the collector mutate agent config in v1.2. +- Keep host-side evidence and response actions local until a separate remote + write-path design is reviewed. +- Consider mirroring `enodia.hash_chain.v1` records off-box before adding any + higher-risk fleet command surface. diff --git a/docs/INDEX.md b/docs/INDEX.md index 52a4e30..3c6626c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -26,6 +26,7 @@ same patch as any command, schema, or behavior documentation changes. | [SPECIFICATION.md](SPECIFICATION.md) | Product model, current scope, data model, and acceptance criteria. | | [ROADMAP.md](ROADMAP.md) | Release tracks and planned sequencing. | | [THREAT_MODEL.md](THREAT_MODEL.md) | Trust boundaries, attacker assumptions, and non-goals. | +| [FLEET_DESIGN.md](FLEET_DESIGN.md) | Optional v1.2 collector design: enrollment, ingest, storage, and safety boundaries. | ## Versioning Rules diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c2b8acb..28d65c0 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -135,9 +135,11 @@ Purpose: reduce blind spots and connect related signals. - ✅ Add a second typed host-event rule family for `listen` events: SID `100068` flags interpreters opening listeners on unusual local ports, with local-port match conditions and a replayable fixture. -- Continue generalizing the rule engine across more typed host events: - `bind`, `accept`, `file_write`, `chmod/chown`, `setuid/setgid`, - `capset`, and module-load events. +- ✅ Continue generalizing the rule engine across more typed host events: + `file_write`, `chmod/chown`, and `setuid/setgid` now have built-in rules, + typed event parsing, fixtures, and rule-operation tests. +- Continue extending typed host-event coverage to: + `bind`, `accept`, `capset`, and module-load events. - Keep rules declarative and Snort-like: `sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as process name, parent process, argv regex, path prefix, peer IP/port, UID/GID, @@ -146,7 +148,11 @@ Purpose: reduce blind spots and connect related signals. `tcp_connect`, `bind`, `accept`, module load, privilege transitions, and writes to watched persistence paths. - Build process lineage across polling and event sources. -- Correlate multi-stage behavior: +- ✅ Start correlating multi-stage behavior with a read-only incident + correlation engine: `exec_rule.web-rce` plus suspicious egress or an unusual + listener in a 10-minute incident window raises SID `100080` + `correlation.multi_stage_intrusion` while preserving the raw alerts. +- Continue expanding multi-stage behavior: web service spawns shell, shell downloads payload, payload adds persistence, listener appears, and FIM changes. - Add configurable correlation windows and severity escalation rules, including: @@ -162,9 +168,12 @@ Purpose: reduce blind spots and connect related signals. `curl|wget | sh`, Python/perl/bash reverse-shell argv variants, `systemctl enable` from unusual ancestry, `chmod +s` outside package transactions, and execution from writable directories. -- Add first-seen and rarity tracking for local network behavior: - first public destination per process name, first listening port per binary, - interpreter connections to non-standard ports, and long-lived low-byte +- ✅ Add first-seen and rarity tracking for local network behavior: + first public destination per process name and first listening port per + process name are locally baselined in `first-seen.json` and alert after the + initial silent pass. +- Continue rarity coverage for: + interpreter connections to non-standard ports and long-lived low-byte connections when socket sampling can support it. - Keep polling as the oracle and fallback. @@ -208,6 +217,9 @@ Exit criteria: Purpose: support several personal or small-business Linux hosts without turning the agent into a heavy enterprise platform. +- ✅ Add a fleet collector design note covering optional collector boundaries, + per-host enrollment, signed JSON payloads, HTTPS/tailnet transport, and + standalone-agent failure semantics ([FLEET_DESIGN.md](FLEET_DESIGN.md)). - Add an optional collector service. - Agents push signed JSON events and heartbeats over HTTPS or a tailnet. - Collector stores host status, alerts, incidents, and package/integrity state. @@ -230,7 +242,8 @@ Purpose: make tampering harder to hide from an attacker with high privileges. - External baseline anchor: mirror FIM and package DB fingerprints off-box with append-only semantics. -- Hash-chain `events.log` and snapshots. +- ✅ Hash-chain `events.log` and snapshots locally with append-only + `enodia.hash_chain.v1` JSONL records, exposed through the integrity report. - Optional snapshot signing with a host key. - Investigate Linux IMA/EVM and TPM-backed attestation for supported machines. - Replace bcc with a libbpf + CO-RE agent if the event layer becomes core to diff --git a/docs/RULES.md b/docs/RULES.md index 0513eab..1806f01 100644 --- a/docs/RULES.md +++ b/docs/RULES.md @@ -242,3 +242,78 @@ Expected false positives: - Administrative troubleshooting that temporarily listens on a high port. Drill or fixture: Use `rules test` with a `listen` event whose `comm` is an interpreter and whose `local_port` is not a common service port. + +## SID 100069: Interpreter wrote to a persistence path + +- Event: `file_write` +- Signature: `host_rule.persistence-write` +- Classtype: `persistence-write` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `file_write` +- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh` +- `path_prefixes`: `/etc/cron.d/`, `/etc/crontab`, `/etc/systemd/system/`, `/etc/ld.so.preload`, `/root/.ssh/authorized_keys`, `/etc/sudoers`, `/etc/sudoers.d/` + +Expected false positives: +- Installer, configuration-management, or recovery scripts that legitimately write persistence paths from an interpreter. +- Administrative SSH-key or service-unit maintenance performed by a reviewed script. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100069-interpreter-persistence-write.json`. + +## SID 100070: Permissions or ownership changed on a persistence path + +- Event: `chmod,chown` +- Signature: `host_rule.persistence-permission-change` +- Classtype: `persistence-permission-change` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `chmod`, `chown` +- `path_prefixes`: `/etc/cron.d/`, `/etc/crontab`, `/etc/systemd/system/`, `/etc/ld.so.preload`, `/root/.ssh/authorized_keys`, `/etc/sudoers`, `/etc/sudoers.d/` + +Expected false positives: +- Package post-install scripts or configuration-management agents adjusting mode/owner on system service, sudoers, cron, or SSH key files. +- Manual recovery work that repairs permissions after a known-good change. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100070-persistence-permission-change.json`. + +## SID 100071: Interpreter requested a root UID transition + +- Event: `setuid` +- Signature: `host_rule.privilege-transition` +- Classtype: `privilege-transition` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `setuid` +- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh` +- `target_uids`: `0` + +Expected false positives: +- Reviewed administrative wrappers that intentionally run interpreters across a root UID transition. +- Privileged installer or service-management scripts during maintenance windows. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100071-interpreter-setuid-root.json`. + +## SID 100072: Interpreter requested a root GID transition + +- Event: `setgid` +- Signature: `host_rule.privilege-transition` +- Classtype: `privilege-transition` +- Severity: `HIGH` +- Origin: `builtin` + +Match fields: +- `events`: `setgid` +- `comm`: `ash`, `bash`, `curl`, `dash`, `fetch`, `ksh`, `lua`, `nc`, `ncat`, `netcat`, `node`, `nodejs`, `perl`, `php`, `python`, `python2`, `python3`, `ruby`, `sh`, `socat`, `wget`, `zsh` +- `target_gids`: `0` + +Expected false positives: +- Reviewed administrative wrappers that intentionally run interpreters across a root GID transition. +- Privileged installer or service-management scripts during maintenance windows. + +Drill or fixture: Use `rules test` with `tests/fixtures/sids/100072-interpreter-setgid-root.json`. diff --git a/docs/RUNBOOKS.md b/docs/RUNBOOKS.md index 186a0b1..f2bf931 100644 --- a/docs/RUNBOOKS.md +++ b/docs/RUNBOOKS.md @@ -44,6 +44,29 @@ Then pick the runbook below that matches the signature. --- +## Correlation Alerts + +**Triggers:** `correlation.multi_stage_intrusion` (sid 100080). + +Correlation alerts are not separate raw telemetry. They mean one incident now +contains multiple lower-level signals that form a stronger story, such as a +web/database service spawning an interpreter and the same incident showing +suspicious egress or an unusual listener inside the correlation window. + +- Treat the correlated incident severity as the investigation priority. +- Keep reviewing the underlying raw alerts; they remain in the incident + timeline and snapshots. +- Export the incident before containment: + + ```bash + enodia-sentinel incident export > /tmp/.json + ``` + +- If the correlation involves a web/database parent, preserve service logs, + access logs, and application deployment state before restarting the service. + +--- + ## Runbook 1 — Reverse shell / suspicious egress **Triggers:** `reverse_shell` (sid 100010), `egress` (sid 100016), @@ -359,9 +382,13 @@ suspicious case. - Keep the evidence bundle; do not delete snapshots while an investigation is open (retention is bounded by `max_snapshots` / `max_snapshot_age_days`). +- Preserve `hash-chain.jsonl` with the snapshots. The chain records the local + digest sequence for snapshot logs, snapshot JSON, and `events.log`; a missing + or broken chain is not proof of compromise by itself, but it is a reason to + distrust local evidence and compare with off-box copies. - Re-baseline (`baseline`, `fim-update`) only once the host is confirmed clean — baselining a compromised host blesses the compromise. - Re-run `enodia-sentinel posture check` to close the hygiene gaps that enabled the intrusion. -- The incident-grouping commands (`incident show/export`) on the - [roadmap](ROADMAP.md) will eventually automate the evidence-bundle steps above. +- Use `enodia-sentinel incident show/export` and the saved response plan as the + durable handoff record for later review. diff --git a/docs/SCHEMAS.md b/docs/SCHEMAS.md index d098f4a..5c2d31a 100644 --- a/docs/SCHEMAS.md +++ b/docs/SCHEMAS.md @@ -66,6 +66,11 @@ Required fields: | `lineage` | array | Process-lineage IDs used for grouping. | | `snapshots` | array | Member snapshot filenames. | | `alert_count` | integer | Total alerts grouped into the incident. | +| `correlations` | array | Higher-confidence correlation records derived from the incident's raw alerts. Empty when no correlation rule matched. | + +Each correlation record is additive evidence on the incident and keeps the raw +alerts visible. Current fields are `sid`, `signature`, `classtype`, `severity`, +`summary`, `window_seconds`, and `matched_signatures`. `incident show --json` returns `enodia.incident.view.v1` with `schema`, `incident`, and `timeline`. The dashboard incident API returns the same schema @@ -108,8 +113,43 @@ Required fields: | `watchdog` | object | Daemon running state, heartbeat age, stale flag, max age, and verdict message. | | `anchors` | object | FIM baseline, package DB anchor, pacman keyring, and SigLevel summary. | | `sentinel_footprint` | object | Configured Sentinel self-integrity paths and present/missing counts. | +| `hash_chain` | object | Local append-only hash-chain summary for snapshots and event logs: path, exists flag, record count, and last chain hash. | | `read_only` | boolean | Always true for this dashboard API. | +## `enodia.hash_chain.v1` + +JSONL record appended to `hash-chain.jsonl` when Sentinel captures forensic +artifacts. This is a local tamper-evidence chain; future fleet/external anchors +can mirror the same records off-box. + +Required fields: + +| Field | Type | Meaning | +|---|---|---| +| `schema` | string | `enodia.hash_chain.v1`. | +| `time` | number | Unix timestamp when the chain record was appended. | +| `kind` | string | Artifact kind, such as `snapshot-log`, `snapshot-json`, or `events-log`. | +| `path` | string | Local artifact path that was hashed. | +| `size` | integer | Artifact size in bytes, or `0` if unavailable. | +| `sha256` | string | SHA-256 of the artifact bytes, empty if unavailable. | +| `prev_hash` | string | Previous chain hash, empty for the first usable record. | +| `chain_hash` | string | SHA-256 over this record and `prev_hash`. | + +## `enodia.first_seen.v1` + +Local detector state stored in `first-seen.json`. The first detector pass builds +a baseline silently; later passes alert on new public destinations per process +name and new listener ports per process name. + +Required fields: + +| Field | Type | Meaning | +|---|---|---| +| `schema` | string | `enodia.first_seen.v1`. | +| `initialized` | boolean | Whether the initial silent baseline pass has completed. | +| `public_destinations` | object | Process name to sorted `host:port` destinations. | +| `listener_ports` | object | Process name to sorted listener port strings. | + ## `enodia.response.plan.v1` Dry-run response plan from `respond plan `. diff --git a/enodia_sentinel/assurance.py b/enodia_sentinel/assurance.py new file mode 100644 index 0000000..0aa26aa --- /dev/null +++ b/enodia_sentinel/assurance.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Append-only hash-chain records for local forensic artifacts.""" +from __future__ import annotations + +import hashlib +import json +import time +from pathlib import Path + +from . import schemas + +CHAIN_NAME = "hash-chain.jsonl" + + +def chain_path(cfg) -> Path: + return cfg.log_dir / CHAIN_NAME + + +def _sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def file_digest(path: Path) -> str: + return _sha256_bytes(path.read_bytes()) + + +def last_chain_hash(cfg) -> str: + try: + lines = chain_path(cfg).read_text().splitlines() + except OSError: + return "" + for line in reversed(lines): + try: + record = json.loads(line) + except ValueError: + continue + value = record.get("chain_hash") + if isinstance(value, str): + return value + return "" + + +def append_artifact(cfg, kind: str, path: Path) -> dict: + """Append a tamper-evident hash-chain record for ``path``.""" + cfg.log_dir.mkdir(parents=True, exist_ok=True) + prev = last_chain_hash(cfg) + try: + digest = file_digest(path) + size = path.stat().st_size + except OSError: + digest = "" + size = 0 + record = { + "schema": schemas.HASH_CHAIN_V1, + "time": time.time(), + "kind": kind, + "path": str(path), + "size": size, + "sha256": digest, + "prev_hash": prev, + } + payload = json.dumps(record, sort_keys=True, separators=(",", ":")).encode() + record["chain_hash"] = _sha256_bytes(payload + prev.encode()) + with chain_path(cfg).open("a") as fh: + fh.write(json.dumps(record, sort_keys=True) + "\n") + try: + chain_path(cfg).chmod(0o640) + except OSError: + pass + return record + + +def summary(cfg) -> dict: + path = chain_path(cfg) + try: + lines = path.read_text().splitlines() + except OSError: + return {"path": str(path), "exists": False, "count": 0, "last_hash": ""} + last = "" + for line in reversed(lines): + try: + last = json.loads(line).get("chain_hash", "") + break + except ValueError: + continue + return { + "path": str(path), + "exists": True, + "count": len(lines), + "last_hash": last, + } diff --git a/enodia_sentinel/config.py b/enodia_sentinel/config.py index 03db823..9c525f6 100644 --- a/enodia_sentinel/config.py +++ b/enodia_sentinel/config.py @@ -27,7 +27,7 @@ _DEFAULT_WATCH = ( _ALL_DETECTORS = ( "reverse_shell", "ld_preload", "deleted_exe", "input_snooper", "credential_access", "stealth_network", - "memory_obfuscation", + "memory_obfuscation", "first_seen", "new_listener", "new_suid", "persistence", "egress", ) @@ -98,6 +98,10 @@ class Config: # memory-map shapes (for local JIT runtimes or known instrumentation). memory_obfuscation_allow_paths: tuple[str, ...] = () + # First-seen tracking stores an operator-visible baseline under log_dir and + # alerts on new public destinations or listener ports after the first pass. + first_seen_enabled: bool = True + # file integrity monitoring (FIM) fim_enabled: bool = True fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS diff --git a/enodia_sentinel/correlation.py b/enodia_sentinel/correlation.py new file mode 100644 index 0000000..4e38e64 --- /dev/null +++ b/enodia_sentinel/correlation.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""Read-only incident correlation rules. + +Correlation does not hide the raw alerts or mutate the host. It adds a small +``correlations`` list to an incident when the incident's existing evidence +matches a higher-confidence story. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from .alert import Severity + +SID_MULTI_STAGE_INTRUSION = 100080 + + +@dataclass(frozen=True) +class CorrelationRule: + sid: int + signature: str + classtype: str + severity: Severity + summary: str + required_any: tuple[set[str], ...] + max_window: int + + +DEFAULT_RULES: tuple[CorrelationRule, ...] = ( + CorrelationRule( + sid=SID_MULTI_STAGE_INTRUSION, + signature="correlation.multi_stage_intrusion", + classtype="multi-stage-intrusion", + severity=Severity.CRITICAL, + summary=( + "Web/database service spawned a shell and the same incident showed " + "suspicious egress or an unusual listener within the correlation window" + ), + required_any=( + {"exec_rule.web-rce"}, + {"host_rule.suspicious-egress", "host_rule.suspicious-listener"}, + ), + max_window=600, + ), +) + + +def correlate(incident: dict, rules: tuple[CorrelationRule, ...] = DEFAULT_RULES) -> list[dict]: + """Return correlation records matched by an incident index entry.""" + signatures = set(incident.get("signatures") or []) + duration = float(incident.get("last_ts", 0.0)) - float(incident.get("first_ts", 0.0)) + out: list[dict] = [] + for rule in rules: + if duration > rule.max_window: + continue + if not all(signatures & choices for choices in rule.required_any): + continue + out.append({ + "sid": rule.sid, + "signature": rule.signature, + "classtype": rule.classtype, + "severity": str(rule.severity), + "summary": rule.summary, + "window_seconds": rule.max_window, + "matched_signatures": sorted(signatures), + }) + return out diff --git a/enodia_sentinel/detectors/__init__.py b/enodia_sentinel/detectors/__init__.py index 992b6b9..fa25d16 100644 --- a/enodia_sentinel/detectors/__init__.py +++ b/enodia_sentinel/detectors/__init__.py @@ -18,6 +18,7 @@ from . import ( credential_access, deleted_exe, egress, + first_seen, input_snooper, ld_preload, memory_obfuscation, @@ -49,6 +50,7 @@ REGISTRY: tuple[Detector, ...] = ( Detector("stealth_network", stealth_network.detect), Detector("memory_obfuscation", memory_obfuscation.detect), Detector("egress", egress.detect), + Detector("first_seen", first_seen.detect, needs_baseline=True), Detector("new_listener", new_listener.detect, needs_baseline=True), Detector("persistence", persistence.detect, needs_baseline=True), Detector("new_suid", new_suid.detect, needs_baseline=True, expensive=True), @@ -68,4 +70,6 @@ def run_all( continue if wanted is not None and det.name not in wanted: continue + if det.needs_baseline and state.listener_baseline is None: + continue yield from det.detect(state, cfg) diff --git a/enodia_sentinel/detectors/first_seen.py b/enodia_sentinel/detectors/first_seen.py new file mode 100644 index 0000000..7e0a524 --- /dev/null +++ b/enodia_sentinel/detectors/first_seen.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +"""first_seen — rarity tracking for network behavior. + +The first pass creates a local baseline without alerting. Later passes alert +when a process name reaches a new public destination or opens a new listener +port. This complements signature detections without flooding on daemon startup. +""" +from __future__ import annotations + +import json +import os +from collections.abc import Iterator + +from .. import schemas +from ..alert import Alert, Severity +from ..config import Config +from ..netutil import is_public_ip, split_host_port +from ..system import SystemState + +SID_FIRST_PUBLIC_DESTINATION = 100074 +SID_FIRST_LISTENER_PORT = 100075 +STORE_NAME = "first-seen.json" + + +def store_path(cfg: Config): + return cfg.log_dir / STORE_NAME + + +def _load(cfg: Config) -> dict: + try: + data = json.loads(store_path(cfg).read_text()) + if isinstance(data, dict): + data.setdefault("schema", schemas.FIRST_SEEN_V1) + data.setdefault("public_destinations", {}) + data.setdefault("listener_ports", {}) + data.setdefault("initialized", False) + return data + except (OSError, ValueError): + pass + return { + "schema": schemas.FIRST_SEEN_V1, + "initialized": False, + "public_destinations": {}, + "listener_ports": {}, + } + + +def _save(cfg: Config, data: dict) -> None: + cfg.log_dir.mkdir(parents=True, exist_ok=True) + tmp = store_path(cfg).with_suffix(".json.tmp") + tmp.write_text(json.dumps(data, indent=2, sort_keys=True)) + os.replace(tmp, store_path(cfg)) + try: + store_path(cfg).chmod(0o640) + except OSError: + pass + + +def detect(state: SystemState, cfg: Config) -> Iterator[Alert]: + if not cfg.first_seen_enabled: + return + data = _load(cfg) + initialized = bool(data.get("initialized")) + public = data.setdefault("public_destinations", {}) + listeners = data.setdefault("listener_ports", {}) + alerts: list[Alert] = [] + + for sock in state.sockets: + comm = sock.comm or "?" + if sock.state == "ESTAB": + host, port = split_host_port(sock.peer) + if is_public_ip(host) and port: + seen = set(public.get(comm, [])) + target = f"{host}:{port}" + if target not in seen: + seen.add(target) + public[comm] = sorted(seen) + if initialized: + alerts.append(Alert( + Severity.MEDIUM, + "first_public_destination", + f"firstdest:{comm}:{target}", + f"comm={comm} first public destination {target}", + (sock.pid,) if sock.pid else (), + sid=SID_FIRST_PUBLIC_DESTINATION, + classtype="network-rarity", + )) + if sock.state in {"LISTEN", "UNCONN"} and sock.kind in {"tcp", "udp", ""}: + _host, port = split_host_port(sock.local) + if port and port.isdigit() and int(port): + seen_ports = set(listeners.get(comm, [])) + if port not in seen_ports: + seen_ports.add(port) + listeners[comm] = sorted(seen_ports, key=lambda p: int(p)) + if initialized: + alerts.append(Alert( + Severity.MEDIUM, + "first_listener_port", + f"firstlisten:{comm}:{port}", + f"comm={comm} first listening port {port}", + (sock.pid,) if sock.pid else (), + sid=SID_FIRST_LISTENER_PORT, + classtype="network-rarity", + )) + + data["initialized"] = True + _save(cfg, data) + yield from alerts diff --git a/enodia_sentinel/events/host_event.py b/enodia_sentinel/events/host_event.py index 1319636..2796fc4 100644 --- a/enodia_sentinel/events/host_event.py +++ b/enodia_sentinel/events/host_event.py @@ -19,6 +19,9 @@ class HostEvent: peer_port: int = 0 local_ip: str = "" local_port: int = 0 + target_uid: int = -1 + target_gid: int = -1 + mode: int = 0 @property def argv_str(self) -> str: @@ -38,4 +41,7 @@ class HostEvent: "peer_port": self.peer_port, "local_ip": self.local_ip, "local_port": self.local_port, + "target_uid": self.target_uid, + "target_gid": self.target_gid, + "mode": self.mode, } diff --git a/enodia_sentinel/events/host_rules.py b/enodia_sentinel/events/host_rules.py index 4adcbff..5f08437 100644 --- a/enodia_sentinel/events/host_rules.py +++ b/enodia_sentinel/events/host_rules.py @@ -16,6 +16,11 @@ _INTERPRETERS = frozenset( "node nodejs nc ncat netcat socat curl wget fetch".split() ) _COMMON_PUBLIC_PORTS = frozenset({22, 53, 80, 123, 443, 853}) +_PERSISTENCE_PREFIXES = ( + "/etc/cron.d/", "/etc/crontab", "/etc/systemd/system/", + "/etc/ld.so.preload", "/root/.ssh/authorized_keys", + "/etc/sudoers", "/etc/sudoers.d/", +) @dataclass(frozen=True) @@ -32,6 +37,9 @@ class HostRule: peer_port_exclude: frozenset[int] = frozenset() local_ports: frozenset[int] = frozenset() local_port_exclude: frozenset[int] = frozenset() + path_prefixes: tuple[str, ...] = () + target_uids: frozenset[int] = frozenset() + target_gids: frozenset[int] = frozenset() argv_regex: str | None = None def __post_init__(self) -> None: @@ -40,7 +48,8 @@ class HostRule: if not any(( self.comm, self.parent_comm, self.peer_public is not None, self.peer_ports, self.peer_port_exclude, self.local_ports, - self.local_port_exclude, self.argv_regex, + self.local_port_exclude, self.path_prefixes, self.target_uids, + self.target_gids, self.argv_regex, )): raise ValueError(f"rule sid={self.sid} has no match conditions") if self.argv_regex is not None: @@ -66,6 +75,15 @@ class HostRule: return False if self.local_port_exclude and ev.local_port in self.local_port_exclude: return False + if self.path_prefixes and not any( + ev.path == prefix or ev.path.startswith(prefix) + for prefix in self.path_prefixes + ): + return False + if self.target_uids and ev.target_uid not in self.target_uids: + return False + if self.target_gids and ev.target_gid not in self.target_gids: + return False if self._argv_re is not None and not self._argv_re.search(ev.argv_str): return False return True @@ -74,11 +92,17 @@ class HostRule: return Alert( severity=self.severity, signature=f"host_rule.{self.classtype}", - key=f"host:{self.sid}:{ev.event}:{ev.pid}:{ev.peer_ip}:{ev.peer_port}", + key=( + f"host:{self.sid}:{ev.event}:{ev.pid}:" + f"{ev.peer_ip}:{ev.peer_port}:{ev.local_ip}:{ev.local_port}:" + f"{ev.path}:{ev.target_uid}:{ev.target_gid}:{ev.mode}" + ), detail=( f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} " f"comm={ev.comm} event={ev.event} peer={ev.peer_ip}:{ev.peer_port} " - f"local={ev.local_ip}:{ev.local_port}" + f"local={ev.local_ip}:{ev.local_port} path={ev.path} " + f"target_uid={ev.target_uid} target_gid={ev.target_gid} " + f"mode={oct(ev.mode) if ev.mode else '-'}" ), pids=(ev.pid,), sid=self.sid, @@ -106,6 +130,41 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = ( comm=_INTERPRETERS, local_port_exclude=_COMMON_PUBLIC_PORTS, ), + HostRule( + sid=100069, + msg="Interpreter wrote to a persistence path", + severity=Severity.HIGH, + classtype="persistence-write", + events=frozenset({"file_write"}), + comm=_INTERPRETERS, + path_prefixes=_PERSISTENCE_PREFIXES, + ), + HostRule( + sid=100070, + msg="Permissions or ownership changed on a persistence path", + severity=Severity.HIGH, + classtype="persistence-permission-change", + events=frozenset({"chmod", "chown"}), + path_prefixes=_PERSISTENCE_PREFIXES, + ), + HostRule( + sid=100071, + msg="Interpreter requested a root UID transition", + severity=Severity.HIGH, + classtype="privilege-transition", + events=frozenset({"setuid"}), + comm=_INTERPRETERS, + target_uids=frozenset({0}), + ), + HostRule( + sid=100072, + msg="Interpreter requested a root GID transition", + severity=Severity.HIGH, + classtype="privilege-transition", + events=frozenset({"setgid"}), + comm=_INTERPRETERS, + target_gids=frozenset({0}), + ), ) diff --git a/enodia_sentinel/incident.py b/enodia_sentinel/incident.py index 80a08fc..00ebba2 100644 --- a/enodia_sentinel/incident.py +++ b/enodia_sentinel/incident.py @@ -118,6 +118,7 @@ def load_index(cfg: Config) -> dict: for inc in data.values(): if isinstance(inc, dict): inc.setdefault("schema", schemas.INCIDENT_V1) + inc.setdefault("correlations", []) return data except (OSError, ValueError): return {} @@ -174,6 +175,7 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert], "severity": severity, "signatures": [], "sids": [], "pids": [], "lineage": [], "snapshots": [], "alert_count": 0, + "correlations": [], } inc = index[iid] inc.setdefault("schema", schemas.INCIDENT_V1) @@ -186,6 +188,16 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert], inc["lineage"] = sorted(set(inc["lineage"]) | lineage) inc["snapshots"].append(snapshot_name) inc["alert_count"] += len(alerts) + from . import correlation + inc["correlations"] = correlation.correlate(inc) + for corr in inc["correlations"]: + if corr["sid"] not in inc["sids"]: + inc["sids"].append(corr["sid"]) + inc["severity"] = max_severity( + inc.get("severity", severity), + max((c.get("severity", severity) for c in inc["correlations"]), + default=severity), + ) save_index(cfg, index) return iid except Exception: diff --git a/enodia_sentinel/ruleops.py b/enodia_sentinel/ruleops.py index 95afbb1..5b825a9 100644 --- a/enodia_sentinel/ruleops.py +++ b/enodia_sentinel/ruleops.py @@ -168,6 +168,12 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]: conditions["local_ports"] = sorted(rule.local_ports) if rule.local_port_exclude: conditions["local_port_exclude"] = sorted(rule.local_port_exclude) + if rule.path_prefixes: + conditions["path_prefixes"] = list(rule.path_prefixes) + if rule.target_uids: + conditions["target_uids"] = sorted(rule.target_uids) + if rule.target_gids: + conditions["target_gids"] = sorted(rule.target_gids) if rule.argv_regex: conditions["argv_regex"] = rule.argv_regex return { @@ -359,6 +365,9 @@ def _host_event(event: dict[str, Any]) -> HostEvent: event.get("listen_ip", "")))), local_port=_to_int(event.get("local_port", event.get("bind_port", event.get("listen_port", 0)))), + target_uid=_to_int(event.get("target_uid", event.get("uid_target", -1))), + target_gid=_to_int(event.get("target_gid", event.get("gid_target", -1))), + mode=_to_int(event.get("mode", 0)), ) diff --git a/enodia_sentinel/schemas.py b/enodia_sentinel/schemas.py index 3cfc674..da82cb7 100644 --- a/enodia_sentinel/schemas.py +++ b/enodia_sentinel/schemas.py @@ -13,6 +13,8 @@ INCIDENT_VIEW_V1 = "enodia.incident.view.v1" INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1" STATUS_V1 = "enodia.status.v1" INTEGRITY_V1 = "enodia.integrity.v1" +HASH_CHAIN_V1 = "enodia.hash_chain.v1" +FIRST_SEEN_V1 = "enodia.first_seen.v1" RESPONSE_PLAN_V1 = "enodia.response.plan.v1" RESPONSE_AUDIT_V1 = "enodia.response.audit.v1" RECONCILE_V1 = "enodia.reconcile.v1" diff --git a/enodia_sentinel/sids.py b/enodia_sentinel/sids.py index 3aec141..ffd4aca 100644 --- a/enodia_sentinel/sids.py +++ b/enodia_sentinel/sids.py @@ -9,9 +9,10 @@ from __future__ import annotations from dataclasses import dataclass -from . import fim, pkgdb, posture, rootcheck +from . import correlation, fim, pkgdb, posture, rootcheck from .detectors import ( credential_access, + first_seen, input_snooper, memory_obfuscation, stealth_network, @@ -66,6 +67,15 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = ( SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY, "process_injection_library", "detector", f"{_DETECTORS}.memory_obfuscation"), + SidInfo(first_seen.SID_FIRST_PUBLIC_DESTINATION, + "first_public_destination", "detector", + f"{_DETECTORS}.first_seen"), + SidInfo(first_seen.SID_FIRST_LISTENER_PORT, + "first_listener_port", "detector", + f"{_DETECTORS}.first_seen"), + SidInfo(correlation.SID_MULTI_STAGE_INTRUSION, + "multi_stage_intrusion", "correlation", + "enodia_sentinel.correlation"), 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"), diff --git a/enodia_sentinel/snapshot.py b/enodia_sentinel/snapshot.py index c2f3335..16889cc 100644 --- a/enodia_sentinel/snapshot.py +++ b/enodia_sentinel/snapshot.py @@ -237,6 +237,11 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path: fh.write(f"{now.isoformat()} [{severity}] captured " f"{base.with_suffix('.log')} — signatures: {sigs}\n") + from . import assurance + assurance.append_artifact(cfg, "snapshot-log", base.with_suffix(".log")) + assurance.append_artifact(cfg, "snapshot-json", base.with_suffix(".json")) + assurance.append_artifact(cfg, "events-log", cfg.events_log) + _notify(cfg, f"{severity}: {sigs}") # Phone push (ntfy / Pushover / webhook), gated by notify_min_severity. diff --git a/enodia_sentinel/tui.py b/enodia_sentinel/tui.py index 64f71ba..26b9af8 100644 --- a/enodia_sentinel/tui.py +++ b/enodia_sentinel/tui.py @@ -510,6 +510,15 @@ def _render_timelines(views: list[dict], width: int) -> list[str]: sigs = ", ".join(inc.get("signatures") or []) if sigs: lines.append(f" signatures: {_clip(sigs, max(20, width - 15))}") + correlations = inc.get("correlations") or [] + for corr in correlations: + lines.append( + f" correlation sid={corr.get('sid', '?')} " + f"{corr.get('severity', '?')} {corr.get('signature', '?')}" + ) + lines.append( + f" {_clip(corr.get('summary', ''), max(20, width - 4))}" + ) timeline = view.get("timeline") or [] if not timeline: lines.append(" No timeline rows.") @@ -566,6 +575,7 @@ def _render_integrity(report: dict, width: int) -> list[str]: pacman = anchors.get("pacman") or {} footprint = report.get("sentinel_footprint") or {} reconciliation = report.get("reconciliation") or {} + hash_chain = report.get("hash_chain") or {} lines = [ f"Overall: {report.get('status', 'unknown')}", f"Generated: {report.get('generated_at', '?')}", @@ -588,6 +598,9 @@ def _render_integrity(report: dict, width: int) -> list[str]: f" Package DB: {pkgdb.get('status', 'unknown')} prefix={pkgdb.get('fingerprint_prefix', '')}", f" Pacman keyring:{' present' if pacman.get('keyring_present') else ' missing'}", f" SigLevel: {pacman.get('siglevel_status', 'unknown')}", + f" Hash chain: {'present' if hash_chain.get('exists') else 'missing'} " + f"count={hash_chain.get('count', 0)}", + f" {_clip(hash_chain.get('path', '?'), max(20, width - 17))}", "", "Sentinel footprint:", f" Present: {footprint.get('present', 0)}/{footprint.get('configured', 0)}", diff --git a/enodia_sentinel/web.py b/enodia_sentinel/web.py index fbac22e..61b297a 100644 --- a/enodia_sentinel/web.py +++ b/enodia_sentinel/web.py @@ -345,6 +345,7 @@ def integrity_report(cfg: Config, status: dict | None = None, not run live FIM or package verification work from the web request path. """ from . import pkgdb + from . import assurance from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict ts = time.time() if now is None else now @@ -373,10 +374,12 @@ def integrity_report(cfg: Config, status: dict | None = None, } keyring_present = pkgdb.keyring_present() reconciliation = _reconciliation_summary(cfg) + hash_chain = assurance.summary(cfg) checks = { "watchdog": "ok" if watchdog_ok else "review", "fim_baseline": fim_state["status"], "pkgdb_anchor": pkg_state["status"], + "hash_chain": "ok" if hash_chain["exists"] else "missing", "pacman_siglevel": "review" if sig_alert else "ok", "pacman_keyring": "ok" if keyring_present else "missing", "reconciliation": "review" if reconciliation["stale"] else "ok", @@ -416,6 +419,7 @@ def integrity_report(cfg: Config, status: dict | None = None, "paths": watched, }, "reconciliation": reconciliation, + "hash_chain": hash_chain, "read_only": True, } diff --git a/tests/fixtures/sids/100069-interpreter-persistence-write.json b/tests/fixtures/sids/100069-interpreter-persistence-write.json new file mode 100644 index 0000000..3a5a2ad --- /dev/null +++ b/tests/fixtures/sids/100069-interpreter-persistence-write.json @@ -0,0 +1,10 @@ +{ + "event": "file_write", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "parent_comm": "bash", + "path": "/etc/systemd/system/evil.service", + "argv": ["-c", "write unit"] +} diff --git a/tests/fixtures/sids/100070-persistence-permission-change.json b/tests/fixtures/sids/100070-persistence-permission-change.json new file mode 100644 index 0000000..1fa4bf6 --- /dev/null +++ b/tests/fixtures/sids/100070-persistence-permission-change.json @@ -0,0 +1,10 @@ +{ + "event": "chmod", + "pid": 4243, + "ppid": 1, + "uid": 0, + "comm": "chmod", + "parent_comm": "bash", + "path": "/etc/cron.d/backup", + "mode": "0o777" +} diff --git a/tests/fixtures/sids/100071-interpreter-setuid-root.json b/tests/fixtures/sids/100071-interpreter-setuid-root.json new file mode 100644 index 0000000..af67f0a --- /dev/null +++ b/tests/fixtures/sids/100071-interpreter-setuid-root.json @@ -0,0 +1,9 @@ +{ + "event": "setuid", + "pid": 4244, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "parent_comm": "bash", + "target_uid": 0 +} diff --git a/tests/fixtures/sids/100072-interpreter-setgid-root.json b/tests/fixtures/sids/100072-interpreter-setgid-root.json new file mode 100644 index 0000000..ab27923 --- /dev/null +++ b/tests/fixtures/sids/100072-interpreter-setgid-root.json @@ -0,0 +1,9 @@ +{ + "event": "setgid", + "pid": 4245, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "parent_comm": "bash", + "target_gid": 0 +} diff --git a/tests/sid_drills.py b/tests/sid_drills.py index 1fe2ec8..bb76e00 100644 --- a/tests/sid_drills.py +++ b/tests/sid_drills.py @@ -8,13 +8,14 @@ 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 import correlation, fim, pkgdb, posture, rootcheck +from enodia_sentinel.alert import Alert, Severity from enodia_sentinel.config import Config from enodia_sentinel.detectors import ( credential_access, deleted_exe, egress, + first_seen, input_snooper, ld_preload, memory_obfuscation, @@ -131,6 +132,43 @@ def _pkgverify() -> list[Alert]: pkgdb.siglevel_alert = saved +def _first_seen_public_destination() -> list[Alert]: + with tempfile.TemporaryDirectory() as d: + cfg = _cfg() + cfg.log_dir = Path(d) + list(first_seen.detect(SystemState(sockets=[ + Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443", 1, "curl", 400, "tcp"), + ]), cfg)) + return list(first_seen.detect(SystemState(sockets=[ + Socket("ESTAB", "10.0.0.2:5001", "1.1.1.1:8443", 2, "curl", 400, "tcp"), + ]), cfg)) + + +def _first_seen_listener_port() -> list[Alert]: + with tempfile.TemporaryDirectory() as d: + cfg = _cfg() + cfg.log_dir = Path(d) + list(first_seen.detect(SystemState(sockets=[ + Socket("LISTEN", "0.0.0.0:8000", "*:*", 1, "python3", 401, "tcp"), + ]), cfg)) + return list(first_seen.detect(SystemState(sockets=[ + Socket("LISTEN", "0.0.0.0:9000", "*:*", 2, "python3", 401, "tcp"), + ]), cfg)) + + +def _correlation_alert() -> list[Alert]: + inc = { + "first_ts": 1000.0, + "last_ts": 1050.0, + "signatures": ["exec_rule.web-rce", "host_rule.suspicious-egress"], + } + return [ + Alert(Severity.CRITICAL, c["signature"], f"corr:{c['sid']}", + c["summary"], sid=c["sid"], classtype=c["classtype"]) + for c in correlation.correlate(inc) + ] + + def _fim_pkg() -> list[Alert]: saved = fim.pacman_verify fim.pacman_verify = lambda timeout=600: [ @@ -321,4 +359,7 @@ DRILLS: dict[int, Callable[[], list[Alert]]] = { posture.UnitFact("evil.service", "/etc/systemd/system/evil.service", 0o100644, True, ("/tmp/payload",)), ])), + 100074: _first_seen_public_destination, + 100075: _first_seen_listener_port, + 100080: _correlation_alert, } diff --git a/tests/test_assurance.py b/tests/test_assurance.py new file mode 100644 index 0000000..abbbbb2 --- /dev/null +++ b/tests/test_assurance.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import tempfile +import unittest +from pathlib import Path + +from enodia_sentinel import assurance +from enodia_sentinel.config import Config + + +class TestHashChain(unittest.TestCase): + def test_artifact_records_chain_to_previous_hash(self): + with tempfile.TemporaryDirectory() as d: + cfg = Config() + cfg.log_dir = Path(d) + one = cfg.log_dir / "one.txt" + two = cfg.log_dir / "two.txt" + one.write_text("one") + two.write_text("two") + r1 = assurance.append_artifact(cfg, "test", one) + r2 = assurance.append_artifact(cfg, "test", two) + self.assertEqual(r2["prev_hash"], r1["chain_hash"]) + summary = assurance.summary(cfg) + self.assertEqual(summary["count"], 2) + self.assertEqual(summary["last_hash"], r2["chain_hash"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_correlation.py b/tests/test_correlation.py new file mode 100644 index 0000000..9653c44 --- /dev/null +++ b/tests/test_correlation.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import json +import tempfile +import unittest +from pathlib import Path + +from enodia_sentinel import correlation, incident +from enodia_sentinel.alert import Alert, Severity +from enodia_sentinel.config import Config + + +def _alert(sig: str, sid: int) -> Alert: + return Alert(Severity.HIGH, sig, sig, sig, (4242,), sid=sid, + classtype=sig.rsplit(".", 1)[-1]) + + +class TestCorrelation(unittest.TestCase): + def test_web_rce_plus_suspicious_egress_correlates(self): + inc = { + "first_ts": 1000.0, + "last_ts": 1050.0, + "signatures": ["exec_rule.web-rce", "host_rule.suspicious-egress"], + } + out = correlation.correlate(inc) + self.assertEqual(out[0]["sid"], correlation.SID_MULTI_STAGE_INTRUSION) + + def test_incident_record_persists_correlation(self): + with tempfile.TemporaryDirectory() as d: + cfg = Config() + cfg.log_dir = Path(d) + iid = incident.record( + cfg, "alert-1.log", [_alert("exec_rule.web-rce", 100003)], + {4242}, 1000.0, "h") + incident.record( + cfg, "alert-2.log", [_alert("host_rule.suspicious-egress", 100067)], + {4242}, 1050.0, "h") + data = json.loads((Path(d) / "incidents.json").read_text())[iid] + self.assertEqual(data["severity"], "CRITICAL") + self.assertIn(correlation.SID_MULTI_STAGE_INTRUSION, data["sids"]) + self.assertEqual(data["correlations"][0]["signature"], + "correlation.multi_stage_intrusion") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_first_seen.py b/tests/test_first_seen.py new file mode 100644 index 0000000..948241c --- /dev/null +++ b/tests/test_first_seen.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +import tempfile +import unittest +from pathlib import Path + +from enodia_sentinel.config import Config +from enodia_sentinel.detectors import first_seen +from enodia_sentinel.system import Socket, SystemState + + +class TestFirstSeen(unittest.TestCase): + def setUp(self): + self.dir = tempfile.TemporaryDirectory() + self.cfg = Config() + self.cfg.log_dir = Path(self.dir.name) + + def tearDown(self): + self.dir.cleanup() + + def test_first_pass_baselines_without_alerting_then_new_dest_alerts(self): + first = SystemState(sockets=[ + Socket("ESTAB", "10.0.0.2:5000", "8.8.8.8:443", 1, "curl", 400, "tcp"), + ]) + self.assertEqual(list(first_seen.detect(first, self.cfg)), []) + second = SystemState(sockets=[ + Socket("ESTAB", "10.0.0.2:5001", "1.1.1.1:8443", 2, "curl", 400, "tcp"), + ]) + alerts = list(first_seen.detect(second, self.cfg)) + self.assertEqual(alerts[0].sid, first_seen.SID_FIRST_PUBLIC_DESTINATION) + + def test_new_listener_port_alerts_after_baseline(self): + list(first_seen.detect(SystemState(sockets=[ + Socket("LISTEN", "0.0.0.0:8000", "*:*", 1, "python3", 401, "tcp"), + ]), self.cfg)) + alerts = list(first_seen.detect(SystemState(sockets=[ + Socket("LISTEN", "0.0.0.0:9000", "*:*", 2, "python3", 401, "tcp"), + ]), self.cfg)) + self.assertEqual(alerts[0].sid, first_seen.SID_FIRST_LISTENER_PORT) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rule_compat.py b/tests/test_rule_compat.py index 12bfe0f..0a40d31 100644 --- a/tests/test_rule_compat.py +++ b/tests/test_rule_compat.py @@ -95,6 +95,35 @@ class TestRuleEventCompatibility(unittest.TestCase): } self.assertIn(100068, self._sids(event)) + def test_host_event_accepts_file_write_shape(self): + self.assertIn(100069, self._sids({ + "event": "file_write", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "path": "/etc/systemd/system/evil.service", + })) + + def test_host_event_accepts_chmod_and_setuid_shapes(self): + self.assertIn(100070, self._sids({ + "event": "chmod", + "pid": 4242, + "ppid": 1, + "uid": 0, + "comm": "chmod", + "path": "/etc/cron.d/evil", + "mode": "0o777", + })) + self.assertIn(100071, self._sids({ + "event": "setuid", + "pid": 4242, + "ppid": 1, + "uid": 1000, + "comm": "python3", + "target_uid": "0", + })) + def test_unknown_event_shape_still_errors(self): with self.assertRaisesRegex( ValueError, "event JSON must be an exec, syscall, or host event" diff --git a/tests/test_ruleops.py b/tests/test_ruleops.py index 00503c1..f52912f 100644 --- a/tests/test_ruleops.py +++ b/tests/test_ruleops.py @@ -20,6 +20,10 @@ class TestRuleOps(unittest.TestCase): self.assertIn(100060, sids) self.assertIn(100067, sids) self.assertIn(100068, sids) + self.assertIn(100069, sids) + self.assertIn(100070, sids) + self.assertIn(100071, sids) + self.assertIn(100072, sids) exec_rule = next(r for r in rules if r["sid"] == 100002) self.assertEqual(exec_rule["event"], "exec") self.assertIn("argv_regex", exec_rule["conditions"]) @@ -29,6 +33,9 @@ class TestRuleOps(unittest.TestCase): listener_rule = next(r for r in rules if r["sid"] == 100068) self.assertEqual(listener_rule["event"], "listen") self.assertIn("local_port_exclude", listener_rule["conditions"]) + write_rule = next(r for r in rules if r["sid"] == 100069) + self.assertEqual(write_rule["event"], "file_write") + self.assertIn("path_prefixes", write_rule["conditions"]) def test_configured_exec_rule_is_listed(self): with tempfile.TemporaryDirectory() as d: diff --git a/tests/test_schema_contracts.py b/tests/test_schema_contracts.py index 3d0e128..e6b8c32 100644 --- a/tests/test_schema_contracts.py +++ b/tests/test_schema_contracts.py @@ -9,7 +9,7 @@ from io import StringIO from pathlib import Path from unittest.mock import patch -from enodia_sentinel import incident, reconcile, respond, schemas, snapshot, web +from enodia_sentinel import assurance, incident, reconcile, respond, schemas, snapshot, web from enodia_sentinel.alert import Alert, Severity from enodia_sentinel.cli import main from enodia_sentinel.config import Config @@ -134,6 +134,7 @@ class TestSchemaContracts(unittest.TestCase): "lineage": list, "snapshots": list, "alert_count": int, + "correlations": list, }) view = web.get_incident(self.cfg, iid) @@ -215,9 +216,26 @@ class TestSchemaContracts(unittest.TestCase): "watchdog": dict, "anchors": dict, "sentinel_footprint": dict, + "hash_chain": dict, "read_only": bool, }) + def test_hash_chain_v1_contract(self): + artifact = self.tmp / "artifact.json" + artifact.write_text("{}") + record = assurance.append_artifact(self.cfg, "test-artifact", artifact) + self.assertEqual(record["schema"], schemas.HASH_CHAIN_V1) + _assert_types(self, record, { + "schema": str, + "time": float, + "kind": str, + "path": str, + "size": int, + "sha256": str, + "prev_hash": str, + "chain_hash": str, + }) + def test_response_plan_and_audit_contracts(self): iid = self._record_incident_with_snapshot() bundle = respond.load_bundle(self.cfg, iid) diff --git a/tests/test_sids.py b/tests/test_sids.py index 78d8f58..c1ea729 100644 --- a/tests/test_sids.py +++ b/tests/test_sids.py @@ -20,7 +20,7 @@ class TestRegistry(unittest.TestCase): self.assertEqual(len(nums), len(set(nums))) def test_count_and_helpers(self): - self.assertEqual(len(sids.BUILTIN_SIDS), 55) + self.assertEqual(len(sids.BUILTIN_SIDS), 62) self.assertEqual( sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS) ) diff --git a/tests/test_tui.py b/tests/test_tui.py index caedff7..530b3f3 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -113,6 +113,12 @@ class TestTuiCore(unittest.TestCase): "severity": "CRITICAL", "alert_count": 1, "signatures": ["reverse_shell"], + "correlations": [{ + "sid": 100080, + "severity": "CRITICAL", + "signature": "correlation.multi_stage_intrusion", + "summary": "web RCE plus suspicious egress", + }], }, "timeline": [{ "time": "2026-07-09T01:02:03-07:00", @@ -138,6 +144,12 @@ class TestTuiCore(unittest.TestCase): "pacman": {"keyring_present": True, "siglevel_status": "ok"}, }, "sentinel_footprint": {"present": 2, "configured": 3, "missing": 1}, + "hash_chain": { + "path": "/tmp/hash-chain.jsonl", + "exists": True, + "count": 2, + "last_hash": "abc123", + }, "reconciliation": { "total": 1, "stale": 1, @@ -172,8 +184,10 @@ class TestTuiCore(unittest.TestCase): timeline = render_lines(TuiState(view="timeline", model=model), width=100) self.assertTrue(any("inc-1" in line for line in timeline)) self.assertTrue(any("pids=4242" in line for line in timeline)) + self.assertTrue(any("correlation sid=100080" in line for line in timeline)) integrity = render_lines(TuiState(view="integrity", model=model), width=100) self.assertTrue(any("Overall:" in line for line in integrity)) + self.assertTrue(any("Hash chain:" in line for line in integrity)) self.assertTrue(any("ttl expired" in line for line in integrity)) events = render_lines(TuiState(view="events", model=model), width=100) self.assertTrue(any("technical logs" in line for line in events)) diff --git a/tests/test_web.py b/tests/test_web.py index 423da59..59084d9 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -193,6 +193,9 @@ class TestDataLayer(unittest.TestCase): self.assertEqual(report["anchors"]["pkgdb"]["fingerprint_prefix"], "0123456789abcdef") self.assertTrue(report["read_only"]) self.assertIn("sentinel_footprint", report) + self.assertIn("hash_chain", report) + self.assertEqual(report["checks"]["hash_chain"], "missing") + self.assertFalse(report["hash_chain"]["exists"]) def test_dashboard_has_persistent_theme_controls(self): html = (web._STATIC / "dashboard.html").read_text()