Add host correlation and assurance coverage
This commit is contained in:
parent
8194d13734
commit
b40ac4252c
36 changed files with 944 additions and 23 deletions
12
README.md
12
README.md
|
|
@ -26,9 +26,9 @@ behind reviewed response workflows rather than silent remediation.
|
||||||
|
|
||||||
| Function | Current capability | Direction |
|
| 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 |
|
| 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 |
|
| Investigate | Text/JSON snapshots, incidents, dashboard, triage | Richer timelines and evidence export |
|
||||||
| Respond | Persisted dry-run response plans | Audited containment and recovery execution |
|
| Respond | Persisted dry-run response plans | Audited containment and recovery execution |
|
||||||
| Assure | Heartbeat, watchdog, self-integrity, rootcheck | Fleet health and attestation-ready anchors |
|
| 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
|
- [Packaging guide](docs/PACKAGING.md) — Arch package layout plus Debian/RPM
|
||||||
source-install paths.
|
source-install paths.
|
||||||
- [JSON schemas](docs/SCHEMAS.md) — stable v1 contracts for alerts, incidents,
|
- [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
|
- [IR runbooks](docs/RUNBOOKS.md) — confirm/preserve/contain/recover playbooks
|
||||||
per alert: reverse shells, persistence, trojaned binaries, rootkits, tampering.
|
per alert: reverse shells, persistence, trojaned binaries, rootkits, tampering.
|
||||||
- [Rule reference](docs/RULES.md) — generated event-rule documentation: SID,
|
- [Rule reference](docs/RULES.md) — generated event-rule documentation: SID,
|
||||||
signature, classtype, match fields, expected false positives, and drills.
|
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
|
> **Two implementations, on purpose.** The project began as a bash prototype
|
||||||
> (`src/sentinel.sh`, kept as the regression **oracle**) and was re-architected
|
> (`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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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
|
Every detection carries a stable **`sid`** and a **`classtype`** (à la
|
||||||
Snort/Suricata), so it can be referenced, tuned, and tracked across revisions.
|
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)
|
## Event-driven detection (eBPF + a Snort-style rule engine)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,8 @@ enodia-sentinel list-detectors
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints the poll detectors and whether each is enabled by config.
|
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`
|
### `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
|
from), with a **time-window fallback** for alerts that carry no live PID (FIM
|
||||||
drift, package tamper, hidden modules). Each captured snapshot records an
|
drift, package tamper, hidden modules). Each captured snapshot records an
|
||||||
`incident_id`; the index lives in `incidents.json` under `log_dir`.
|
`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
|
- `list` — incidents newest-first: id, last seen, severity, alert count, and the
|
||||||
signatures involved.
|
signatures involved.
|
||||||
|
|
@ -383,7 +388,7 @@ endpoint, for cron/monitoring.
|
||||||
|
|
||||||
Stable JSON contracts are documented in [SCHEMAS.md](SCHEMAS.md). Current v1
|
Stable JSON contracts are documented in [SCHEMAS.md](SCHEMAS.md). Current v1
|
||||||
IDs cover alert objects/snapshots, incident records/views/exports, status,
|
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:
|
Exit code:
|
||||||
|
|
||||||
|
|
|
||||||
96
docs/FLEET_DESIGN.md
Normal file
96
docs/FLEET_DESIGN.md
Normal 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.
|
||||||
|
|
@ -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. |
|
| [SPECIFICATION.md](SPECIFICATION.md) | Product model, current scope, data model, and acceptance criteria. |
|
||||||
| [ROADMAP.md](ROADMAP.md) | Release tracks and planned sequencing. |
|
| [ROADMAP.md](ROADMAP.md) | Release tracks and planned sequencing. |
|
||||||
| [THREAT_MODEL.md](THREAT_MODEL.md) | Trust boundaries, attacker assumptions, and non-goals. |
|
| [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
|
## Versioning Rules
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
- ✅ Add a second typed host-event rule family for `listen` events: SID `100068`
|
||||||
flags interpreters opening listeners on unusual local ports, with local-port
|
flags interpreters opening listeners on unusual local ports, with local-port
|
||||||
match conditions and a replayable fixture.
|
match conditions and a replayable fixture.
|
||||||
- Continue generalizing the rule engine across more typed host events:
|
- ✅ Continue generalizing the rule engine across more typed host events:
|
||||||
`bind`, `accept`, `file_write`, `chmod/chown`, `setuid/setgid`,
|
`file_write`, `chmod/chown`, and `setuid/setgid` now have built-in rules,
|
||||||
`capset`, and module-load events.
|
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:
|
- Keep rules declarative and Snort-like:
|
||||||
`sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as
|
`sid`, `msg`, `severity`, `classtype`, `event`, and match fields such as
|
||||||
process name, parent process, argv regex, path prefix, peer IP/port, UID/GID,
|
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
|
`tcp_connect`, `bind`, `accept`, module load, privilege transitions, and
|
||||||
writes to watched persistence paths.
|
writes to watched persistence paths.
|
||||||
- Build process lineage across polling and event sources.
|
- 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,
|
web service spawns shell, shell downloads payload, payload adds persistence,
|
||||||
listener appears, and FIM changes.
|
listener appears, and FIM changes.
|
||||||
- Add configurable correlation windows and severity escalation rules, including:
|
- 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,
|
`curl|wget | sh`, Python/perl/bash reverse-shell argv variants,
|
||||||
`systemctl enable` from unusual ancestry, `chmod +s` outside package
|
`systemctl enable` from unusual ancestry, `chmod +s` outside package
|
||||||
transactions, and execution from writable directories.
|
transactions, and execution from writable directories.
|
||||||
- Add first-seen and rarity tracking for local network behavior:
|
- ✅ Add first-seen and rarity tracking for local network behavior:
|
||||||
first public destination per process name, first listening port per binary,
|
first public destination per process name and first listening port per
|
||||||
interpreter connections to non-standard ports, and long-lived low-byte
|
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.
|
connections when socket sampling can support it.
|
||||||
- Keep polling as the oracle and fallback.
|
- Keep polling as the oracle and fallback.
|
||||||
|
|
||||||
|
|
@ -208,6 +217,9 @@ Exit criteria:
|
||||||
Purpose: support several personal or small-business Linux hosts without turning
|
Purpose: support several personal or small-business Linux hosts without turning
|
||||||
the agent into a heavy enterprise platform.
|
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.
|
- Add an optional collector service.
|
||||||
- Agents push signed JSON events and heartbeats over HTTPS or a tailnet.
|
- Agents push signed JSON events and heartbeats over HTTPS or a tailnet.
|
||||||
- Collector stores host status, alerts, incidents, and package/integrity state.
|
- 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:
|
- External baseline anchor:
|
||||||
mirror FIM and package DB fingerprints off-box with append-only semantics.
|
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.
|
- Optional snapshot signing with a host key.
|
||||||
- Investigate Linux IMA/EVM and TPM-backed attestation for supported machines.
|
- 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
|
- Replace bcc with a libbpf + CO-RE agent if the event layer becomes core to
|
||||||
|
|
|
||||||
|
|
@ -242,3 +242,78 @@ Expected false positives:
|
||||||
- Administrative troubleshooting that temporarily listens on a high port.
|
- 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.
|
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`.
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Runbook 1 — Reverse shell / suspicious egress
|
||||||
|
|
||||||
**Triggers:** `reverse_shell` (sid 100010), `egress` (sid 100016),
|
**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
|
- Keep the evidence bundle; do not delete snapshots while an investigation is open
|
||||||
(retention is bounded by `max_snapshots` / `max_snapshot_age_days`).
|
(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 —
|
- Re-baseline (`baseline`, `fim-update`) only once the host is confirmed clean —
|
||||||
baselining a compromised host blesses the compromise.
|
baselining a compromised host blesses the compromise.
|
||||||
- Re-run `enodia-sentinel posture check` to close the hygiene gaps that enabled
|
- Re-run `enodia-sentinel posture check` to close the hygiene gaps that enabled
|
||||||
the intrusion.
|
the intrusion.
|
||||||
- The incident-grouping commands (`incident show/export`) on the
|
- Use `enodia-sentinel incident show/export` and the saved response plan as the
|
||||||
[roadmap](ROADMAP.md) will eventually automate the evidence-bundle steps above.
|
durable handoff record for later review.
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,11 @@ Required fields:
|
||||||
| `lineage` | array | Process-lineage IDs used for grouping. |
|
| `lineage` | array | Process-lineage IDs used for grouping. |
|
||||||
| `snapshots` | array | Member snapshot filenames. |
|
| `snapshots` | array | Member snapshot filenames. |
|
||||||
| `alert_count` | integer | Total alerts grouped into the incident. |
|
| `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 show --json` returns `enodia.incident.view.v1` with `schema`,
|
||||||
`incident`, and `timeline`. The dashboard incident API returns the same 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. |
|
| `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. |
|
| `anchors` | object | FIM baseline, package DB anchor, pacman keyring, and SigLevel summary. |
|
||||||
| `sentinel_footprint` | object | Configured Sentinel self-integrity paths and present/missing counts. |
|
| `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. |
|
| `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`
|
## `enodia.response.plan.v1`
|
||||||
|
|
||||||
Dry-run response plan from `respond plan <incident-id>`.
|
Dry-run response plan from `respond plan <incident-id>`.
|
||||||
|
|
|
||||||
91
enodia_sentinel/assurance.py
Normal file
91
enodia_sentinel/assurance.py
Normal 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,
|
||||||
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ _DEFAULT_WATCH = (
|
||||||
_ALL_DETECTORS = (
|
_ALL_DETECTORS = (
|
||||||
"reverse_shell", "ld_preload", "deleted_exe",
|
"reverse_shell", "ld_preload", "deleted_exe",
|
||||||
"input_snooper", "credential_access", "stealth_network",
|
"input_snooper", "credential_access", "stealth_network",
|
||||||
"memory_obfuscation",
|
"memory_obfuscation", "first_seen",
|
||||||
"new_listener", "new_suid", "persistence", "egress",
|
"new_listener", "new_suid", "persistence", "egress",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -98,6 +98,10 @@ class Config:
|
||||||
# memory-map shapes (for local JIT runtimes or known instrumentation).
|
# memory-map shapes (for local JIT runtimes or known instrumentation).
|
||||||
memory_obfuscation_allow_paths: tuple[str, ...] = ()
|
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)
|
# file integrity monitoring (FIM)
|
||||||
fim_enabled: bool = True
|
fim_enabled: bool = True
|
||||||
fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS
|
fim_paths: tuple[str, ...] = () # populated from fim.DEFAULT_FIM_PATHS
|
||||||
|
|
|
||||||
66
enodia_sentinel/correlation.py
Normal file
66
enodia_sentinel/correlation.py
Normal 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
|
||||||
|
|
@ -18,6 +18,7 @@ from . import (
|
||||||
credential_access,
|
credential_access,
|
||||||
deleted_exe,
|
deleted_exe,
|
||||||
egress,
|
egress,
|
||||||
|
first_seen,
|
||||||
input_snooper,
|
input_snooper,
|
||||||
ld_preload,
|
ld_preload,
|
||||||
memory_obfuscation,
|
memory_obfuscation,
|
||||||
|
|
@ -49,6 +50,7 @@ REGISTRY: tuple[Detector, ...] = (
|
||||||
Detector("stealth_network", stealth_network.detect),
|
Detector("stealth_network", stealth_network.detect),
|
||||||
Detector("memory_obfuscation", memory_obfuscation.detect),
|
Detector("memory_obfuscation", memory_obfuscation.detect),
|
||||||
Detector("egress", egress.detect),
|
Detector("egress", egress.detect),
|
||||||
|
Detector("first_seen", first_seen.detect, needs_baseline=True),
|
||||||
Detector("new_listener", new_listener.detect, needs_baseline=True),
|
Detector("new_listener", new_listener.detect, needs_baseline=True),
|
||||||
Detector("persistence", persistence.detect, needs_baseline=True),
|
Detector("persistence", persistence.detect, needs_baseline=True),
|
||||||
Detector("new_suid", new_suid.detect, needs_baseline=True, expensive=True),
|
Detector("new_suid", new_suid.detect, needs_baseline=True, expensive=True),
|
||||||
|
|
@ -68,4 +70,6 @@ def run_all(
|
||||||
continue
|
continue
|
||||||
if wanted is not None and det.name not in wanted:
|
if wanted is not None and det.name not in wanted:
|
||||||
continue
|
continue
|
||||||
|
if det.needs_baseline and state.listener_baseline is None:
|
||||||
|
continue
|
||||||
yield from det.detect(state, cfg)
|
yield from det.detect(state, cfg)
|
||||||
|
|
|
||||||
108
enodia_sentinel/detectors/first_seen.py
Normal file
108
enodia_sentinel/detectors/first_seen.py
Normal 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
|
||||||
|
|
@ -19,6 +19,9 @@ class HostEvent:
|
||||||
peer_port: int = 0
|
peer_port: int = 0
|
||||||
local_ip: str = ""
|
local_ip: str = ""
|
||||||
local_port: int = 0
|
local_port: int = 0
|
||||||
|
target_uid: int = -1
|
||||||
|
target_gid: int = -1
|
||||||
|
mode: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def argv_str(self) -> str:
|
def argv_str(self) -> str:
|
||||||
|
|
@ -38,4 +41,7 @@ class HostEvent:
|
||||||
"peer_port": self.peer_port,
|
"peer_port": self.peer_port,
|
||||||
"local_ip": self.local_ip,
|
"local_ip": self.local_ip,
|
||||||
"local_port": self.local_port,
|
"local_port": self.local_port,
|
||||||
|
"target_uid": self.target_uid,
|
||||||
|
"target_gid": self.target_gid,
|
||||||
|
"mode": self.mode,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,11 @@ _INTERPRETERS = frozenset(
|
||||||
"node nodejs nc ncat netcat socat curl wget fetch".split()
|
"node nodejs nc ncat netcat socat curl wget fetch".split()
|
||||||
)
|
)
|
||||||
_COMMON_PUBLIC_PORTS = frozenset({22, 53, 80, 123, 443, 853})
|
_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)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -32,6 +37,9 @@ class HostRule:
|
||||||
peer_port_exclude: frozenset[int] = frozenset()
|
peer_port_exclude: frozenset[int] = frozenset()
|
||||||
local_ports: frozenset[int] = frozenset()
|
local_ports: frozenset[int] = frozenset()
|
||||||
local_port_exclude: 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
|
argv_regex: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
|
@ -40,7 +48,8 @@ class HostRule:
|
||||||
if not any((
|
if not any((
|
||||||
self.comm, self.parent_comm, self.peer_public is not None,
|
self.comm, self.parent_comm, self.peer_public is not None,
|
||||||
self.peer_ports, self.peer_port_exclude, self.local_ports,
|
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")
|
raise ValueError(f"rule sid={self.sid} has no match conditions")
|
||||||
if self.argv_regex is not None:
|
if self.argv_regex is not None:
|
||||||
|
|
@ -66,6 +75,15 @@ class HostRule:
|
||||||
return False
|
return False
|
||||||
if self.local_port_exclude and ev.local_port in self.local_port_exclude:
|
if self.local_port_exclude and ev.local_port in self.local_port_exclude:
|
||||||
return False
|
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):
|
if self._argv_re is not None and not self._argv_re.search(ev.argv_str):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
@ -74,11 +92,17 @@ class HostRule:
|
||||||
return Alert(
|
return Alert(
|
||||||
severity=self.severity,
|
severity=self.severity,
|
||||||
signature=f"host_rule.{self.classtype}",
|
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=(
|
detail=(
|
||||||
f"sid={self.sid} {self.msg} - pid={ev.pid} ppid={ev.ppid} "
|
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"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,),
|
pids=(ev.pid,),
|
||||||
sid=self.sid,
|
sid=self.sid,
|
||||||
|
|
@ -106,6 +130,41 @@ DEFAULT_HOST_RULES: tuple[HostRule, ...] = (
|
||||||
comm=_INTERPRETERS,
|
comm=_INTERPRETERS,
|
||||||
local_port_exclude=_COMMON_PUBLIC_PORTS,
|
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}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ def load_index(cfg: Config) -> dict:
|
||||||
for inc in data.values():
|
for inc in data.values():
|
||||||
if isinstance(inc, dict):
|
if isinstance(inc, dict):
|
||||||
inc.setdefault("schema", schemas.INCIDENT_V1)
|
inc.setdefault("schema", schemas.INCIDENT_V1)
|
||||||
|
inc.setdefault("correlations", [])
|
||||||
return data
|
return data
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -174,6 +175,7 @@ def record(cfg: Config, snapshot_name: str, alerts: list[Alert],
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
"signatures": [], "sids": [], "pids": [],
|
"signatures": [], "sids": [], "pids": [],
|
||||||
"lineage": [], "snapshots": [], "alert_count": 0,
|
"lineage": [], "snapshots": [], "alert_count": 0,
|
||||||
|
"correlations": [],
|
||||||
}
|
}
|
||||||
inc = index[iid]
|
inc = index[iid]
|
||||||
inc.setdefault("schema", schemas.INCIDENT_V1)
|
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["lineage"] = sorted(set(inc["lineage"]) | lineage)
|
||||||
inc["snapshots"].append(snapshot_name)
|
inc["snapshots"].append(snapshot_name)
|
||||||
inc["alert_count"] += len(alerts)
|
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)
|
save_index(cfg, index)
|
||||||
return iid
|
return iid
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,12 @@ def _host_rule_record(rule: HostRule, origin: str) -> dict[str, Any]:
|
||||||
conditions["local_ports"] = sorted(rule.local_ports)
|
conditions["local_ports"] = sorted(rule.local_ports)
|
||||||
if rule.local_port_exclude:
|
if rule.local_port_exclude:
|
||||||
conditions["local_port_exclude"] = sorted(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:
|
if rule.argv_regex:
|
||||||
conditions["argv_regex"] = rule.argv_regex
|
conditions["argv_regex"] = rule.argv_regex
|
||||||
return {
|
return {
|
||||||
|
|
@ -359,6 +365,9 @@ def _host_event(event: dict[str, Any]) -> HostEvent:
|
||||||
event.get("listen_ip", "")))),
|
event.get("listen_ip", "")))),
|
||||||
local_port=_to_int(event.get("local_port", event.get("bind_port",
|
local_port=_to_int(event.get("local_port", event.get("bind_port",
|
||||||
event.get("listen_port", 0)))),
|
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)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ INCIDENT_VIEW_V1 = "enodia.incident.view.v1"
|
||||||
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
|
INCIDENT_BUNDLE_V1 = "enodia.incident.bundle.v1"
|
||||||
STATUS_V1 = "enodia.status.v1"
|
STATUS_V1 = "enodia.status.v1"
|
||||||
INTEGRITY_V1 = "enodia.integrity.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_PLAN_V1 = "enodia.response.plan.v1"
|
||||||
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
|
RESPONSE_AUDIT_V1 = "enodia.response.audit.v1"
|
||||||
RECONCILE_V1 = "enodia.reconcile.v1"
|
RECONCILE_V1 = "enodia.reconcile.v1"
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from . import fim, pkgdb, posture, rootcheck
|
from . import correlation, fim, pkgdb, posture, rootcheck
|
||||||
from .detectors import (
|
from .detectors import (
|
||||||
credential_access,
|
credential_access,
|
||||||
|
first_seen,
|
||||||
input_snooper,
|
input_snooper,
|
||||||
memory_obfuscation,
|
memory_obfuscation,
|
||||||
stealth_network,
|
stealth_network,
|
||||||
|
|
@ -66,6 +67,15 @@ BUILTIN_SIDS: tuple[SidInfo, ...] = (
|
||||||
SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY,
|
SidInfo(memory_obfuscation.SID_PROCESS_INJECTION_LIBRARY,
|
||||||
"process_injection_library", "detector",
|
"process_injection_library", "detector",
|
||||||
f"{_DETECTORS}.memory_obfuscation"),
|
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_MODIFIED, "fim_modified", "fim", "enodia_sentinel.fim"),
|
||||||
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
|
SidInfo(fim.SID_ADDED, "fim_added", "fim", "enodia_sentinel.fim"),
|
||||||
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),
|
SidInfo(fim.SID_REMOVED, "fim_removed", "fim", "enodia_sentinel.fim"),
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,11 @@ def capture(alerts: list[Alert], state: SystemState, cfg: Config) -> Path:
|
||||||
fh.write(f"{now.isoformat()} [{severity}] captured "
|
fh.write(f"{now.isoformat()} [{severity}] captured "
|
||||||
f"{base.with_suffix('.log')} — signatures: {sigs}\n")
|
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}")
|
_notify(cfg, f"{severity}: {sigs}")
|
||||||
|
|
||||||
# Phone push (ntfy / Pushover / webhook), gated by notify_min_severity.
|
# Phone push (ntfy / Pushover / webhook), gated by notify_min_severity.
|
||||||
|
|
|
||||||
|
|
@ -510,6 +510,15 @@ def _render_timelines(views: list[dict], width: int) -> list[str]:
|
||||||
sigs = ", ".join(inc.get("signatures") or [])
|
sigs = ", ".join(inc.get("signatures") or [])
|
||||||
if sigs:
|
if sigs:
|
||||||
lines.append(f" signatures: {_clip(sigs, max(20, width - 15))}")
|
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 []
|
timeline = view.get("timeline") or []
|
||||||
if not timeline:
|
if not timeline:
|
||||||
lines.append(" No timeline rows.")
|
lines.append(" No timeline rows.")
|
||||||
|
|
@ -566,6 +575,7 @@ def _render_integrity(report: dict, width: int) -> list[str]:
|
||||||
pacman = anchors.get("pacman") or {}
|
pacman = anchors.get("pacman") or {}
|
||||||
footprint = report.get("sentinel_footprint") or {}
|
footprint = report.get("sentinel_footprint") or {}
|
||||||
reconciliation = report.get("reconciliation") or {}
|
reconciliation = report.get("reconciliation") or {}
|
||||||
|
hash_chain = report.get("hash_chain") or {}
|
||||||
lines = [
|
lines = [
|
||||||
f"Overall: {report.get('status', 'unknown')}",
|
f"Overall: {report.get('status', 'unknown')}",
|
||||||
f"Generated: {report.get('generated_at', '?')}",
|
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" Package DB: {pkgdb.get('status', 'unknown')} prefix={pkgdb.get('fingerprint_prefix', '')}",
|
||||||
f" Pacman keyring:{' present' if pacman.get('keyring_present') else ' missing'}",
|
f" Pacman keyring:{' present' if pacman.get('keyring_present') else ' missing'}",
|
||||||
f" SigLevel: {pacman.get('siglevel_status', 'unknown')}",
|
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:",
|
"Sentinel footprint:",
|
||||||
f" Present: {footprint.get('present', 0)}/{footprint.get('configured', 0)}",
|
f" Present: {footprint.get('present', 0)}/{footprint.get('configured', 0)}",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
not run live FIM or package verification work from the web request path.
|
||||||
"""
|
"""
|
||||||
from . import pkgdb
|
from . import pkgdb
|
||||||
|
from . import assurance
|
||||||
from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict
|
from .selfprotect import SELF_PATHS, heartbeat_path, watchdog_verdict
|
||||||
|
|
||||||
ts = time.time() if now is None else now
|
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()
|
keyring_present = pkgdb.keyring_present()
|
||||||
reconciliation = _reconciliation_summary(cfg)
|
reconciliation = _reconciliation_summary(cfg)
|
||||||
|
hash_chain = assurance.summary(cfg)
|
||||||
checks = {
|
checks = {
|
||||||
"watchdog": "ok" if watchdog_ok else "review",
|
"watchdog": "ok" if watchdog_ok else "review",
|
||||||
"fim_baseline": fim_state["status"],
|
"fim_baseline": fim_state["status"],
|
||||||
"pkgdb_anchor": pkg_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_siglevel": "review" if sig_alert else "ok",
|
||||||
"pacman_keyring": "ok" if keyring_present else "missing",
|
"pacman_keyring": "ok" if keyring_present else "missing",
|
||||||
"reconciliation": "review" if reconciliation["stale"] else "ok",
|
"reconciliation": "review" if reconciliation["stale"] else "ok",
|
||||||
|
|
@ -416,6 +419,7 @@ def integrity_report(cfg: Config, status: dict | None = None,
|
||||||
"paths": watched,
|
"paths": watched,
|
||||||
},
|
},
|
||||||
"reconciliation": reconciliation,
|
"reconciliation": reconciliation,
|
||||||
|
"hash_chain": hash_chain,
|
||||||
"read_only": True,
|
"read_only": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
10
tests/fixtures/sids/100069-interpreter-persistence-write.json
vendored
Normal file
10
tests/fixtures/sids/100069-interpreter-persistence-write.json
vendored
Normal 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"]
|
||||||
|
}
|
||||||
10
tests/fixtures/sids/100070-persistence-permission-change.json
vendored
Normal file
10
tests/fixtures/sids/100070-persistence-permission-change.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100071-interpreter-setuid-root.json
vendored
Normal file
9
tests/fixtures/sids/100071-interpreter-setuid-root.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "setuid",
|
||||||
|
"pid": 4244,
|
||||||
|
"ppid": 1,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "python3",
|
||||||
|
"parent_comm": "bash",
|
||||||
|
"target_uid": 0
|
||||||
|
}
|
||||||
9
tests/fixtures/sids/100072-interpreter-setgid-root.json
vendored
Normal file
9
tests/fixtures/sids/100072-interpreter-setgid-root.json
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"event": "setgid",
|
||||||
|
"pid": 4245,
|
||||||
|
"ppid": 1,
|
||||||
|
"uid": 1000,
|
||||||
|
"comm": "python3",
|
||||||
|
"parent_comm": "bash",
|
||||||
|
"target_gid": 0
|
||||||
|
}
|
||||||
|
|
@ -8,13 +8,14 @@ from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from enodia_sentinel import fim, pkgdb, posture, rootcheck
|
from enodia_sentinel import correlation, fim, pkgdb, posture, rootcheck
|
||||||
from enodia_sentinel.alert import Alert
|
from enodia_sentinel.alert import Alert, Severity
|
||||||
from enodia_sentinel.config import Config
|
from enodia_sentinel.config import Config
|
||||||
from enodia_sentinel.detectors import (
|
from enodia_sentinel.detectors import (
|
||||||
credential_access,
|
credential_access,
|
||||||
deleted_exe,
|
deleted_exe,
|
||||||
egress,
|
egress,
|
||||||
|
first_seen,
|
||||||
input_snooper,
|
input_snooper,
|
||||||
ld_preload,
|
ld_preload,
|
||||||
memory_obfuscation,
|
memory_obfuscation,
|
||||||
|
|
@ -131,6 +132,43 @@ def _pkgverify() -> list[Alert]:
|
||||||
pkgdb.siglevel_alert = saved
|
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]:
|
def _fim_pkg() -> list[Alert]:
|
||||||
saved = fim.pacman_verify
|
saved = fim.pacman_verify
|
||||||
fim.pacman_verify = lambda timeout=600: [
|
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",
|
posture.UnitFact("evil.service", "/etc/systemd/system/evil.service",
|
||||||
0o100644, True, ("/tmp/payload",)),
|
0o100644, True, ("/tmp/payload",)),
|
||||||
])),
|
])),
|
||||||
|
100074: _first_seen_public_destination,
|
||||||
|
100075: _first_seen_listener_port,
|
||||||
|
100080: _correlation_alert,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
tests/test_assurance.py
Normal file
28
tests/test_assurance.py
Normal 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
45
tests/test_correlation.py
Normal 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
42
tests/test_first_seen.py
Normal 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()
|
||||||
|
|
@ -95,6 +95,35 @@ class TestRuleEventCompatibility(unittest.TestCase):
|
||||||
}
|
}
|
||||||
self.assertIn(100068, self._sids(event))
|
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):
|
def test_unknown_event_shape_still_errors(self):
|
||||||
with self.assertRaisesRegex(
|
with self.assertRaisesRegex(
|
||||||
ValueError, "event JSON must be an exec, syscall, or host event"
|
ValueError, "event JSON must be an exec, syscall, or host event"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ class TestRuleOps(unittest.TestCase):
|
||||||
self.assertIn(100060, sids)
|
self.assertIn(100060, sids)
|
||||||
self.assertIn(100067, sids)
|
self.assertIn(100067, sids)
|
||||||
self.assertIn(100068, 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)
|
exec_rule = next(r for r in rules if r["sid"] == 100002)
|
||||||
self.assertEqual(exec_rule["event"], "exec")
|
self.assertEqual(exec_rule["event"], "exec")
|
||||||
self.assertIn("argv_regex", exec_rule["conditions"])
|
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)
|
listener_rule = next(r for r in rules if r["sid"] == 100068)
|
||||||
self.assertEqual(listener_rule["event"], "listen")
|
self.assertEqual(listener_rule["event"], "listen")
|
||||||
self.assertIn("local_port_exclude", listener_rule["conditions"])
|
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):
|
def test_configured_exec_rule_is_listed(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
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.alert import Alert, Severity
|
||||||
from enodia_sentinel.cli import main
|
from enodia_sentinel.cli import main
|
||||||
from enodia_sentinel.config import Config
|
from enodia_sentinel.config import Config
|
||||||
|
|
@ -134,6 +134,7 @@ class TestSchemaContracts(unittest.TestCase):
|
||||||
"lineage": list,
|
"lineage": list,
|
||||||
"snapshots": list,
|
"snapshots": list,
|
||||||
"alert_count": int,
|
"alert_count": int,
|
||||||
|
"correlations": list,
|
||||||
})
|
})
|
||||||
|
|
||||||
view = web.get_incident(self.cfg, iid)
|
view = web.get_incident(self.cfg, iid)
|
||||||
|
|
@ -215,9 +216,26 @@ class TestSchemaContracts(unittest.TestCase):
|
||||||
"watchdog": dict,
|
"watchdog": dict,
|
||||||
"anchors": dict,
|
"anchors": dict,
|
||||||
"sentinel_footprint": dict,
|
"sentinel_footprint": dict,
|
||||||
|
"hash_chain": dict,
|
||||||
"read_only": bool,
|
"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):
|
def test_response_plan_and_audit_contracts(self):
|
||||||
iid = self._record_incident_with_snapshot()
|
iid = self._record_incident_with_snapshot()
|
||||||
bundle = respond.load_bundle(self.cfg, iid)
|
bundle = respond.load_bundle(self.cfg, iid)
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ class TestRegistry(unittest.TestCase):
|
||||||
self.assertEqual(len(nums), len(set(nums)))
|
self.assertEqual(len(nums), len(set(nums)))
|
||||||
|
|
||||||
def test_count_and_helpers(self):
|
def test_count_and_helpers(self):
|
||||||
self.assertEqual(len(sids.BUILTIN_SIDS), 55)
|
self.assertEqual(len(sids.BUILTIN_SIDS), 62)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
|
sids.all_sids(), frozenset(s.sid for s in sids.BUILTIN_SIDS)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,12 @@ class TestTuiCore(unittest.TestCase):
|
||||||
"severity": "CRITICAL",
|
"severity": "CRITICAL",
|
||||||
"alert_count": 1,
|
"alert_count": 1,
|
||||||
"signatures": ["reverse_shell"],
|
"signatures": ["reverse_shell"],
|
||||||
|
"correlations": [{
|
||||||
|
"sid": 100080,
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"signature": "correlation.multi_stage_intrusion",
|
||||||
|
"summary": "web RCE plus suspicious egress",
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
"timeline": [{
|
"timeline": [{
|
||||||
"time": "2026-07-09T01:02:03-07:00",
|
"time": "2026-07-09T01:02:03-07:00",
|
||||||
|
|
@ -138,6 +144,12 @@ class TestTuiCore(unittest.TestCase):
|
||||||
"pacman": {"keyring_present": True, "siglevel_status": "ok"},
|
"pacman": {"keyring_present": True, "siglevel_status": "ok"},
|
||||||
},
|
},
|
||||||
"sentinel_footprint": {"present": 2, "configured": 3, "missing": 1},
|
"sentinel_footprint": {"present": 2, "configured": 3, "missing": 1},
|
||||||
|
"hash_chain": {
|
||||||
|
"path": "/tmp/hash-chain.jsonl",
|
||||||
|
"exists": True,
|
||||||
|
"count": 2,
|
||||||
|
"last_hash": "abc123",
|
||||||
|
},
|
||||||
"reconciliation": {
|
"reconciliation": {
|
||||||
"total": 1,
|
"total": 1,
|
||||||
"stale": 1,
|
"stale": 1,
|
||||||
|
|
@ -172,8 +184,10 @@ class TestTuiCore(unittest.TestCase):
|
||||||
timeline = render_lines(TuiState(view="timeline", model=model), width=100)
|
timeline = render_lines(TuiState(view="timeline", model=model), width=100)
|
||||||
self.assertTrue(any("inc-1" in line for line in timeline))
|
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("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)
|
integrity = render_lines(TuiState(view="integrity", model=model), width=100)
|
||||||
self.assertTrue(any("Overall:" in line for line in integrity))
|
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))
|
self.assertTrue(any("ttl expired" in line for line in integrity))
|
||||||
events = render_lines(TuiState(view="events", model=model), width=100)
|
events = render_lines(TuiState(view="events", model=model), width=100)
|
||||||
self.assertTrue(any("technical logs" in line for line in events))
|
self.assertTrue(any("technical logs" in line for line in events))
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,9 @@ class TestDataLayer(unittest.TestCase):
|
||||||
self.assertEqual(report["anchors"]["pkgdb"]["fingerprint_prefix"], "0123456789abcdef")
|
self.assertEqual(report["anchors"]["pkgdb"]["fingerprint_prefix"], "0123456789abcdef")
|
||||||
self.assertTrue(report["read_only"])
|
self.assertTrue(report["read_only"])
|
||||||
self.assertIn("sentinel_footprint", report)
|
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):
|
def test_dashboard_has_persistent_theme_controls(self):
|
||||||
html = (web._STATIC / "dashboard.html").read_text()
|
html = (web._STATIC / "dashboard.html").read_text()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue