Add host correlation and assurance coverage

This commit is contained in:
Luna 2026-07-10 03:07:00 -07:00
parent 8194d13734
commit b40ac4252c
No known key found for this signature in database
36 changed files with 944 additions and 23 deletions

View file

@ -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)

View file

@ -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:

96
docs/FLEET_DESIGN.md Normal file
View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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`.

View file

@ -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 <incident-id> > /tmp/<incident-id>.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.

View file

@ -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 <incident-id>`.

View file

@ -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,
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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,
}

View file

@ -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}),
),
)

View file

@ -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:

View file

@ -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)),
)

View file

@ -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"

View file

@ -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"),

View file

@ -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.

View file

@ -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)}",

View file

@ -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,
}

View file

@ -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"]
}

View file

@ -0,0 +1,10 @@
{
"event": "chmod",
"pid": 4243,
"ppid": 1,
"uid": 0,
"comm": "chmod",
"parent_comm": "bash",
"path": "/etc/cron.d/backup",
"mode": "0o777"
}

View file

@ -0,0 +1,9 @@
{
"event": "setuid",
"pid": 4244,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"parent_comm": "bash",
"target_uid": 0
}

View file

@ -0,0 +1,9 @@
{
"event": "setgid",
"pid": 4245,
"ppid": 1,
"uid": 1000,
"comm": "python3",
"parent_comm": "bash",
"target_gid": 0
}

View file

@ -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,
}

28
tests/test_assurance.py Normal file
View file

@ -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()

45
tests/test_correlation.py Normal file
View file

@ -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()

42
tests/test_first_seen.py Normal file
View file

@ -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()

View file

@ -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"

View file

@ -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:

View file

@ -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)

View file

@ -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)
)

View file

@ -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))

View file

@ -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()