Add HTTPS management console and response planning
This commit is contained in:
parent
a56d72edd6
commit
a7129e5666
16 changed files with 927 additions and 160 deletions
22
README.md
22
README.md
|
|
@ -234,8 +234,8 @@ enodia-sentinel.service`. Every key is optional. Highlights:
|
||||||
|
|
||||||
## Web dashboard
|
## Web dashboard
|
||||||
|
|
||||||
A read-only console, served by the stdlib `http.server` (no Flask, no JS
|
A read-only HTTPS management console, served by the stdlib `http.server` (no
|
||||||
framework, no CDN — one self-contained page):
|
Flask, no JS framework, no CDN — one self-contained page):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enodia-sentinel web # serves on the Tailscale IP by default
|
enodia-sentinel web # serves on the Tailscale IP by default
|
||||||
|
|
@ -245,13 +245,16 @@ sudo systemctl enable --now enodia-sentinel-web
|
||||||
|
|
||||||
- **Bound to your Tailscale interface** by default (auto-detected), so it's
|
- **Bound to your Tailscale interface** by default (auto-detected), so it's
|
||||||
reachable from your phone/laptop on the tailnet but not the LAN or internet.
|
reachable from your phone/laptop on the tailnet but not the LAN or internet.
|
||||||
|
- **TLS is mandatory.** If `web_tls_cert` / `web_tls_key` are unset, Sentinel
|
||||||
|
auto-generates a self-signed certificate under `log_dir`. Add a browser
|
||||||
|
exception for now, or point config at a local/private CA certificate.
|
||||||
- **Bearer-token auth** (constant-time check); the token is auto-generated and
|
- **Bearer-token auth** (constant-time check); the token is auto-generated and
|
||||||
saved on first run and printed in the startup line. Open
|
saved on first run and printed in the startup line. Open
|
||||||
`http://<tailscale-ip>:8787/?token=…`.
|
`https://<tailscale-ip>:8787/?token=…`.
|
||||||
- **Read-only**: severity cards, a live alert list, and the full forensic
|
- **Read-only management**: incidents, timelines, alert inventory, event tail,
|
||||||
snapshot per alert. No actions, no writes — minimal attack surface for
|
and dry-run response plans. No commands are executed from the browser. JSON
|
||||||
sensitive data. JSON API at `/api/status`, `/api/alerts`, `/api/alerts/<id>`,
|
API at `/api/status`, `/api/incidents`, `/api/respond/plan/<id>`,
|
||||||
`/api/events`.
|
`/api/alerts`, `/api/alerts/<id>`, `/api/events`.
|
||||||
|
|
||||||
## Phone push notifications
|
## Phone push notifications
|
||||||
|
|
||||||
|
|
@ -301,7 +304,7 @@ exposes its age. Run an external watcher on another host (over Tailscale) and
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# on a SEPARATE machine — alerts you if the sensor or the whole box goes dark
|
# on a SEPARATE machine — alerts you if the sensor or the whole box goes dark
|
||||||
enodia-sentinel watchdog --url http://100.x.x.x:8787 --token <T> --max-age 120
|
enodia-sentinel watchdog --url https://100.x.x.x:8787 --token <T> --max-age 120 --insecure-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hardening (the layers beyond on-box detection):**
|
**Hardening (the layers beyond on-box detection):**
|
||||||
|
|
@ -497,7 +500,8 @@ verification, auto-refreshed by a pacman hook) and **false-positive triage**
|
||||||
via package-ownership provenance.
|
via package-ownership provenance.
|
||||||
|
|
||||||
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound,
|
v0.4 — adds a read-only **web dashboard** (stdlib server, Tailscale-bound,
|
||||||
token-auth) and **phone push** (ntfy / Pushover / webhook), both zero-dependency.
|
token-auth; now HTTPS-only) and **phone push** (ntfy / Pushover / webhook),
|
||||||
|
both zero-dependency.
|
||||||
|
|
||||||
v0.3 — adds the event-driven **eBPF layer**: a real `bcc` execve probe feeding a
|
v0.3 — adds the event-driven **eBPF layer**: a real `bcc` execve probe feeding a
|
||||||
Snort-style declarative rule engine (4 default rules), stable signature IDs +
|
Snort-style declarative rule engine (4 default rules), stable signature IDs +
|
||||||
|
|
|
||||||
|
|
@ -158,3 +158,8 @@ web_port = 8787
|
||||||
web_token = "" # bearer token; auto-generated + saved if empty on a
|
web_token = "" # bearer token; auto-generated + saved if empty on a
|
||||||
# non-loopback bind. Set explicitly to keep the unit
|
# non-loopback bind. Set explicitly to keep the unit
|
||||||
# fully read-only.
|
# fully read-only.
|
||||||
|
# HTTPS is mandatory. If these are blank, the dashboard auto-generates a
|
||||||
|
# self-signed cert/key under log_dir. Add a browser exception for that cert, or
|
||||||
|
# point these at your own local/private CA certificate.
|
||||||
|
web_tls_cert = ""
|
||||||
|
web_tls_key = ""
|
||||||
|
|
|
||||||
|
|
@ -210,6 +210,28 @@ Exit code:
|
||||||
|
|
||||||
## Evidence and Operator Commands
|
## Evidence and Operator Commands
|
||||||
|
|
||||||
|
### `respond`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel respond plan <incident-id>
|
||||||
|
enodia-sentinel respond plan <incident-id> --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Builds a dry-run response plan from an incident's retained JSON snapshots. The
|
||||||
|
plan may include evidence preservation, process freeze/terminate candidates,
|
||||||
|
outbound IP blocks, systemd unit disablement, suspicious-file quarantine,
|
||||||
|
package-restore lookup, and follow-up verification checks.
|
||||||
|
|
||||||
|
This command is intentionally read-only: it prints commands for operator review
|
||||||
|
and never executes them. The JSON schema is `enodia.response.plan.v1` and marks
|
||||||
|
`apply_supported: false` until an audited apply workflow exists.
|
||||||
|
|
||||||
|
Exit code:
|
||||||
|
|
||||||
|
- `0`: plan generated.
|
||||||
|
- `1`: unknown incident id.
|
||||||
|
- `2`: missing incident id.
|
||||||
|
|
||||||
### `status`
|
### `status`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -235,6 +257,9 @@ enodia-sentinel web
|
||||||
|
|
||||||
Starts the read-only dashboard. By default it binds to the Tailscale interface
|
Starts the read-only dashboard. By default it binds to the Tailscale interface
|
||||||
when available and uses bearer-token authentication for non-loopback binds.
|
when available and uses bearer-token authentication for non-loopback binds.
|
||||||
|
HTTPS is mandatory. If `web_tls_cert` / `web_tls_key` are not configured,
|
||||||
|
Sentinel creates a self-signed pair under `log_dir`; add a browser exception for
|
||||||
|
that certificate or replace it with a private/local CA certificate.
|
||||||
|
|
||||||
Expected use: `enodia-sentinel-web.service`.
|
Expected use: `enodia-sentinel-web.service`.
|
||||||
|
|
||||||
|
|
@ -251,12 +276,14 @@ noise but never edits config automatically.
|
||||||
### `watchdog`
|
### `watchdog`
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enodia-sentinel watchdog --url http://100.x.y.z:8787 --token <token> --max-age 120
|
enodia-sentinel watchdog --url https://100.x.y.z:8787 --token <token> --max-age 120
|
||||||
|
enodia-sentinel watchdog --url https://100.x.y.z:8787 --token <token> --insecure-tls
|
||||||
```
|
```
|
||||||
|
|
||||||
Polls a remote dashboard and alerts if the sensor is down, unreachable, or has a
|
Polls a remote dashboard and alerts if the sensor is down, unreachable, or has a
|
||||||
stale heartbeat. Run this from a separate host. A watchdog on the same machine
|
stale heartbeat. Run this from a separate host. Use `--insecure-tls` only for
|
||||||
does not prove the protected host is alive.
|
the built-in self-signed certificate while you have no local/private CA trust
|
||||||
|
path. A watchdog on the same machine does not prove the protected host is alive.
|
||||||
|
|
||||||
Exit code:
|
Exit code:
|
||||||
|
|
||||||
|
|
@ -283,9 +310,8 @@ does not require pip or a virtualenv.
|
||||||
The roadmap reserves these command shapes:
|
The roadmap reserves these command shapes:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enodia-sentinel respond plan <incident-id>
|
|
||||||
enodia-sentinel respond apply <plan-id>
|
enodia-sentinel respond apply <plan-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
These should be introduced with stable JSON schemas and tests before they are
|
State-changing response commands should be introduced with stable JSON schemas,
|
||||||
documented as supported.
|
audit logs, and tests before they are documented as supported.
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ deliberately practical: what to run, what to expect, and what to check next.
|
||||||
sudo systemctl enable --now enodia-sentinel-web
|
sudo systemctl enable --now enodia-sentinel-web
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open the `https://...` URL from the service log. The default certificate is
|
||||||
|
self-signed, so add a browser exception or configure `web_tls_cert` and
|
||||||
|
`web_tls_key` with your own local/private CA certificate.
|
||||||
|
|
||||||
6. Configure one off-box notification or watchdog path. A local-only alerting
|
6. Configure one off-box notification or watchdog path. A local-only alerting
|
||||||
path is not enough if the whole host goes silent.
|
path is not enough if the whole host goes silent.
|
||||||
|
|
||||||
|
|
@ -76,29 +80,38 @@ When Sentinel fires:
|
||||||
and parent process.
|
and parent process.
|
||||||
3. Run `enodia-sentinel triage` to separate known benign listener noise from
|
3. Run `enodia-sentinel triage` to separate known benign listener noise from
|
||||||
findings that need review.
|
findings that need review.
|
||||||
4. If the alert involves a binary, run:
|
4. Build a read-only response plan from the grouped incident:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel incident list
|
||||||
|
enodia-sentinel respond plan <incident-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
Review the plan before acting; Sentinel does not execute containment
|
||||||
|
commands.
|
||||||
|
5. If the alert involves a binary, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enodia-sentinel fim-check --packages
|
enodia-sentinel fim-check --packages
|
||||||
enodia-sentinel pkgdb-verify --sample 200
|
enodia-sentinel pkgdb-verify --sample 200
|
||||||
```
|
```
|
||||||
|
|
||||||
5. If the alert involves hiding or tampering, run:
|
6. If the alert involves hiding or tampering, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enodia-sentinel rootcheck
|
enodia-sentinel rootcheck
|
||||||
enodia-sentinel pkgdb-check
|
enodia-sentinel pkgdb-check
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Preserve evidence before changing state:
|
7. Preserve evidence before changing state:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -a /var/log/enodia-sentinel /tmp/enodia-sentinel-evidence
|
cp -a /var/log/enodia-sentinel /tmp/enodia-sentinel-evidence
|
||||||
```
|
```
|
||||||
|
|
||||||
7. Contain manually for now, following the matching playbook in
|
8. Contain manually for now, following the matching playbook in
|
||||||
[RUNBOOKS.md](RUNBOOKS.md). Future response commands should produce a dry-run
|
[RUNBOOKS.md](RUNBOOKS.md). The response plan gives reviewed commands, but
|
||||||
plan first.
|
applying them remains an explicit operator action.
|
||||||
|
|
||||||
## Common Findings
|
## Common Findings
|
||||||
|
|
||||||
|
|
@ -129,6 +142,9 @@ Baselines are useful only if they represent a known-good state.
|
||||||
|
|
||||||
- Keep the dashboard bound to Tailscale or loopback, not a public interface.
|
- Keep the dashboard bound to Tailscale or loopback, not a public interface.
|
||||||
- Set `web_token` explicitly for fully read-only service operation.
|
- Set `web_token` explicitly for fully read-only service operation.
|
||||||
|
- Keep HTTPS enabled. The built-in self-signed certificate is acceptable for a
|
||||||
|
single host after you add an exception; use a private/local CA for cleaner
|
||||||
|
browser trust.
|
||||||
- Enable at least one push backend.
|
- Enable at least one push backend.
|
||||||
- Run `watchdog` from another machine.
|
- Run `watchdog` from another machine.
|
||||||
- Consider `chattr +i` for Sentinel binaries, systemd units, config, baselines,
|
- Consider `chattr +i` for Sentinel binaries, systemd units, config, baselines,
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ Exit criteria:
|
||||||
|
|
||||||
Purpose: move from "tell me" to "help me act" without unsafe automation.
|
Purpose: move from "tell me" to "help me act" without unsafe automation.
|
||||||
|
|
||||||
- Add response plan generation:
|
- ✅ Add response plan generation:
|
||||||
`enodia-sentinel respond plan <incident-id>`.
|
`enodia-sentinel respond plan <incident-id>`.
|
||||||
- Add dry-run first actions:
|
- Add dry-run first actions:
|
||||||
kill process, stop/disable service, block remote IP, quarantine file, restore
|
kill process, stop/disable service, block remote IP, quarantine file, restore
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,15 @@ For any alert, before changing anything:
|
||||||
alerts, when was the last.
|
alerts, when was the last.
|
||||||
3. **Triage known noise.** `enodia-sentinel triage` separates likely false
|
3. **Triage known noise.** `enodia-sentinel triage` separates likely false
|
||||||
positives from findings that need a human.
|
positives from findings that need a human.
|
||||||
4. **Preserve evidence before you touch anything:**
|
4. **Build a dry-run response plan.** Find the incident and review the proposed
|
||||||
|
actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enodia-sentinel incident list
|
||||||
|
enodia-sentinel respond plan <incident-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Preserve evidence before you touch anything:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -C /var/log -czf /tmp/enodia-evidence-$(date +%s).tgz enodia-sentinel
|
tar -C /var/log -czf /tmp/enodia-evidence-$(date +%s).tgz enodia-sentinel
|
||||||
|
|
@ -208,7 +216,7 @@ edited Sentinel is itself an alert.
|
||||||
- From the **watchdog host** (never the protected host):
|
- From the **watchdog host** (never the protected host):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enodia-sentinel watchdog --url http://<host>:8787 --token <token> --max-age 120
|
enodia-sentinel watchdog --url https://<host>:8787 --token <token> --max-age 120
|
||||||
```
|
```
|
||||||
|
|
||||||
Exit 1 means down, unreachable, or stale.
|
Exit 1 means down, unreachable, or stale.
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ Sentinel assumes:
|
||||||
package signature policy.
|
package signature policy.
|
||||||
- `pacman -Qkk` trusts the local package database; it is useful but not an
|
- `pacman -Qkk` trusts the local package database; it is useful but not an
|
||||||
independent root of trust.
|
independent root of trust.
|
||||||
- The dashboard is read-only by design; it is not a remote response console.
|
- The dashboard is read-only by design; it can display dry-run response plans
|
||||||
|
but does not execute containment commands.
|
||||||
- The current product does not perform automatic containment.
|
- The current product does not perform automatic containment.
|
||||||
|
|
||||||
## Security Controls Already Present
|
## Security Controls Already Present
|
||||||
|
|
@ -90,7 +91,7 @@ Sentinel assumes:
|
||||||
| Signed-package verification | Checks files against package manifests independent of the local DB. |
|
| Signed-package verification | Checks files against package manifests independent of the local DB. |
|
||||||
| Rootcheck | Finds common hiding artifacts by comparing independent views. |
|
| Rootcheck | Finds common hiding artifacts by comparing independent views. |
|
||||||
| Heartbeat + watchdog | Makes a silent sensor observable from another machine. |
|
| Heartbeat + watchdog | Makes a silent sensor observable from another machine. |
|
||||||
| Read-only dashboard | Exposes evidence without adding a remote write path. |
|
| HTTPS read-only dashboard | Exposes evidence and dry-run plans without adding a remote write path. |
|
||||||
|
|
||||||
## Response Safety
|
## Response Safety
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,12 +89,20 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
po.add_argument("action", nargs="?", default="check", choices=["check"],
|
po.add_argument("action", nargs="?", default="check", choices=["check"],
|
||||||
help="posture action (default: check)")
|
help="posture action (default: check)")
|
||||||
po.add_argument("--json", action="store_true", help="emit findings as JSON")
|
po.add_argument("--json", action="store_true", help="emit findings as JSON")
|
||||||
|
rsp = sub.add_parser("respond",
|
||||||
|
help="build dry-run response plans for incidents")
|
||||||
|
rsp.add_argument("action", nargs="?", default="plan", choices=["plan"],
|
||||||
|
help="response action (default: plan)")
|
||||||
|
rsp.add_argument("id", nargs="?", help="incident id")
|
||||||
|
rsp.add_argument("--json", action="store_true", help="emit plan as JSON")
|
||||||
wd = sub.add_parser("watchdog",
|
wd = sub.add_parser("watchdog",
|
||||||
help="poll a remote dashboard and push if Sentinel is silent")
|
help="poll a remote dashboard and push if Sentinel is silent")
|
||||||
wd.add_argument("--url", required=True, help="dashboard base URL")
|
wd.add_argument("--url", required=True, help="dashboard base URL")
|
||||||
wd.add_argument("--token", default="", help="dashboard bearer token")
|
wd.add_argument("--token", default="", help="dashboard bearer token")
|
||||||
wd.add_argument("--max-age", type=int, default=120,
|
wd.add_argument("--max-age", type=int, default=120,
|
||||||
help="heartbeat staleness threshold (seconds)")
|
help="heartbeat staleness threshold (seconds)")
|
||||||
|
wd.add_argument("--insecure-tls", action="store_true",
|
||||||
|
help="allow self-signed/untrusted dashboard certificates")
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
cfg = Config.load(args.config)
|
cfg = Config.load(args.config)
|
||||||
|
|
@ -138,15 +146,20 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
return _cmd_incident(cfg, args.action, args.id, args.json)
|
return _cmd_incident(cfg, args.action, args.id, args.json)
|
||||||
if args.cmd == "posture":
|
if args.cmd == "posture":
|
||||||
return _cmd_posture(cfg, args.json)
|
return _cmd_posture(cfg, args.json)
|
||||||
|
if args.cmd == "respond":
|
||||||
|
return _cmd_respond(cfg, args.action, args.id, args.json)
|
||||||
if args.cmd == "watchdog":
|
if args.cmd == "watchdog":
|
||||||
return _cmd_watchdog(cfg, args.url, args.token, args.max_age)
|
return _cmd_watchdog(cfg, args.url, args.token, args.max_age,
|
||||||
|
not args.insecure_tls)
|
||||||
return _cmd_run(cfg)
|
return _cmd_run(cfg)
|
||||||
|
|
||||||
|
|
||||||
def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int) -> int:
|
def _cmd_watchdog(cfg: Config, url: str, token: str, max_age: int,
|
||||||
|
verify_tls: bool = True) -> int:
|
||||||
from . import notify
|
from . import notify
|
||||||
from .selfprotect import poll_status, watchdog_verdict
|
from .selfprotect import poll_status, watchdog_verdict
|
||||||
ok, msg = watchdog_verdict(poll_status(url, token), max_age)
|
ok, msg = watchdog_verdict(poll_status(url, token, verify_tls=verify_tls),
|
||||||
|
max_age)
|
||||||
print(("OK: " if ok else "ALERT: ") + msg)
|
print(("OK: " if ok else "ALERT: ") + msg)
|
||||||
if not ok:
|
if not ok:
|
||||||
n = notify.Notification(
|
n = notify.Notification(
|
||||||
|
|
@ -328,6 +341,42 @@ def _cmd_posture(cfg: Config, as_json: bool) -> int:
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> int:
|
||||||
|
import json
|
||||||
|
|
||||||
|
from . import respond
|
||||||
|
|
||||||
|
if action != "plan":
|
||||||
|
print(f"error: unsupported respond action: {action}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not iid:
|
||||||
|
print("error: 'respond plan' needs an incident id "
|
||||||
|
"(see 'incident list')", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
bundle = respond.load_bundle(cfg, iid)
|
||||||
|
if bundle is None:
|
||||||
|
print(f"error: no such incident: {iid}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
plan = respond.build_plan(bundle, cfg)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(plan, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
summary = plan["summary"]
|
||||||
|
print(f"Response plan {plan['plan_id']} [{summary['severity']}]")
|
||||||
|
print(f" incident: {plan['incident_id']}")
|
||||||
|
print(f" mode: {plan['mode']} (no commands executed)")
|
||||||
|
print(f" signatures: {', '.join(summary['signatures']) or '—'}")
|
||||||
|
print(f" snapshots: {summary['snapshot_count']}")
|
||||||
|
print(" actions:")
|
||||||
|
for a in plan["actions"]:
|
||||||
|
cmd = " ".join(a["command"])
|
||||||
|
print(f" {a['id']} [{a['risk']}] {a['title']}")
|
||||||
|
print(f" {cmd}")
|
||||||
|
print(f" reason: {a['reason']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
|
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
|
||||||
from . import fim
|
from . import fim
|
||||||
sentinel = Sentinel(cfg)
|
sentinel = Sentinel(cfg)
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ class Config:
|
||||||
web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit
|
web_bind: str = "" # "" = auto-detect Tailscale IP, else explicit
|
||||||
web_port: int = 8787
|
web_port: int = 8787
|
||||||
web_token: str = "" # bearer token; auto-generated if empty + non-local
|
web_token: str = "" # bearer token; auto-generated if empty + non-local
|
||||||
|
web_tls_cert: str = "" # "" = auto-generate self-signed cert under log_dir
|
||||||
|
web_tls_key: str = "" # "" = auto-generate private key under log_dir
|
||||||
|
|
||||||
# paths
|
# paths
|
||||||
log_dir: Path = Path("/var/log/enodia-sentinel")
|
log_dir: Path = Path("/var/log/enodia-sentinel")
|
||||||
|
|
|
||||||
272
enodia_sentinel/respond.py
Normal file
272
enodia_sentinel/respond.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
"""Dry-run response planning for incidents.
|
||||||
|
|
||||||
|
The response layer starts read-only: turn an incident's captured evidence into
|
||||||
|
an explicit plan an operator can review. No action is executed here. Future
|
||||||
|
``--apply`` support should consume this schema and write an audit log.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config import Config
|
||||||
|
from .netutil import is_public_ip
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResponseAction:
|
||||||
|
id: str
|
||||||
|
category: str
|
||||||
|
title: str
|
||||||
|
command: tuple[str, ...]
|
||||||
|
reason: str
|
||||||
|
risk: str = "medium"
|
||||||
|
reversible: bool = True
|
||||||
|
requires_review: bool = True
|
||||||
|
targets: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"category": self.category,
|
||||||
|
"title": self.title,
|
||||||
|
"command": list(self.command),
|
||||||
|
"reason": self.reason,
|
||||||
|
"risk": self.risk,
|
||||||
|
"reversible": self.reversible,
|
||||||
|
"requires_review": self.requires_review,
|
||||||
|
"targets": self.targets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_bundle(cfg: Config, incident_id: str) -> dict | None:
|
||||||
|
"""Load an incident record and its still-retained snapshot reports."""
|
||||||
|
from . import incident
|
||||||
|
|
||||||
|
inc = incident.load_index(cfg).get(incident_id)
|
||||||
|
if inc is None:
|
||||||
|
return None
|
||||||
|
snaps = []
|
||||||
|
for name in inc.get("snapshots", []):
|
||||||
|
path = (cfg.log_dir / name).with_suffix(".json")
|
||||||
|
try:
|
||||||
|
snaps.append(json.loads(path.read_text()))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
continue
|
||||||
|
return {"incident": inc, "snapshots": snaps}
|
||||||
|
|
||||||
|
|
||||||
|
def build_plan(bundle: dict, cfg: Config) -> dict:
|
||||||
|
inc = bundle["incident"]
|
||||||
|
snapshots = bundle.get("snapshots", [])
|
||||||
|
incident_id = inc["id"]
|
||||||
|
actions: list[ResponseAction] = []
|
||||||
|
|
||||||
|
def add(action: ResponseAction) -> None:
|
||||||
|
if (action.category, action.command, tuple(sorted(action.targets.items()))) in seen:
|
||||||
|
return
|
||||||
|
seen.add((action.category, action.command, tuple(sorted(action.targets.items()))))
|
||||||
|
actions.append(action)
|
||||||
|
|
||||||
|
seen: set[tuple] = set()
|
||||||
|
|
||||||
|
evidence_name = f"enodia-evidence-{incident_id}.tgz"
|
||||||
|
add(ResponseAction(
|
||||||
|
id="A001",
|
||||||
|
category="evidence",
|
||||||
|
title="Freeze Sentinel evidence bundle",
|
||||||
|
command=("tar", "-C", str(cfg.log_dir.parent), "-czf",
|
||||||
|
f"/tmp/{evidence_name}", cfg.log_dir.name),
|
||||||
|
reason="Preserve snapshots, event logs, baselines, and incident index before containment.",
|
||||||
|
risk="low",
|
||||||
|
reversible=False,
|
||||||
|
targets={"path": f"/tmp/{evidence_name}"},
|
||||||
|
))
|
||||||
|
|
||||||
|
alerts = _alerts(snapshots)
|
||||||
|
procs = _processes(snapshots)
|
||||||
|
sigs = {a.get("signature", "") for a in alerts}
|
||||||
|
|
||||||
|
for proc in procs.values():
|
||||||
|
pid = proc.get("pid")
|
||||||
|
if not isinstance(pid, int):
|
||||||
|
continue
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="process",
|
||||||
|
title=f"Freeze suspicious process {pid}",
|
||||||
|
command=("kill", "-STOP", str(pid)),
|
||||||
|
reason="Pause the process so /proc state can be inspected before termination.",
|
||||||
|
risk="medium",
|
||||||
|
targets={"pid": pid, "comm": proc.get("comm", "")},
|
||||||
|
))
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="process",
|
||||||
|
title=f"Terminate suspicious process {pid}",
|
||||||
|
command=("kill", "-TERM", str(pid)),
|
||||||
|
reason="Stop a process directly tied to the incident after evidence is captured.",
|
||||||
|
risk="high",
|
||||||
|
targets={"pid": pid, "comm": proc.get("comm", "")},
|
||||||
|
))
|
||||||
|
|
||||||
|
for ip in sorted(_public_ips(alerts)):
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="network",
|
||||||
|
title=f"Block outbound traffic to {ip}",
|
||||||
|
command=("nft", "add", "rule", "inet", "filter", "output",
|
||||||
|
"ip", "daddr", ip, "drop"),
|
||||||
|
reason="Contain suspicious C2/egress while the incident is investigated.",
|
||||||
|
risk="medium",
|
||||||
|
targets={"ip": ip},
|
||||||
|
))
|
||||||
|
|
||||||
|
for unit in sorted(_systemd_units(alerts)):
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="service",
|
||||||
|
title=f"Stop and disable {unit}",
|
||||||
|
command=("systemctl", "disable", "--now", unit),
|
||||||
|
reason="Disable persistence from a modified systemd unit.",
|
||||||
|
risk="high",
|
||||||
|
targets={"unit": unit},
|
||||||
|
))
|
||||||
|
|
||||||
|
for path, sig in sorted(_alert_paths(alerts)):
|
||||||
|
if _is_quarantine_candidate(path, sig):
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="file",
|
||||||
|
title=f"Quarantine suspicious file {path}",
|
||||||
|
command=("mv", "--", path, f"{path}.enodia-quarantine"),
|
||||||
|
reason="Move a suspicious added/SUID/preload artifact out of its execution path.",
|
||||||
|
risk="high",
|
||||||
|
targets={"path": path},
|
||||||
|
))
|
||||||
|
if sig in {"fim_pkg_modified", "pkg_signature_mismatch"}:
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="recovery",
|
||||||
|
title=f"Restore package-owned file {path}",
|
||||||
|
command=("pacman", "-Qo", path),
|
||||||
|
reason="Identify the owning package before reinstalling it from trusted media.",
|
||||||
|
risk="low",
|
||||||
|
targets={"path": path},
|
||||||
|
))
|
||||||
|
|
||||||
|
if {"fim_modified", "fim_pkg_modified", "pkg_signature_mismatch",
|
||||||
|
"pkgdb_tamper"} & sigs:
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="verification",
|
||||||
|
title="Re-run integrity checks after containment",
|
||||||
|
command=("enodia-sentinel", "fim-check", "--packages"),
|
||||||
|
reason="Confirm package-owned and FIM-monitored files match expected state.",
|
||||||
|
risk="low",
|
||||||
|
))
|
||||||
|
if any(s.startswith("rootkit_") or s in {"new_listener", "promiscuous_interface"}
|
||||||
|
for s in sigs):
|
||||||
|
add(ResponseAction(
|
||||||
|
id="",
|
||||||
|
category="verification",
|
||||||
|
title="Re-run rootcheck after containment",
|
||||||
|
command=("enodia-sentinel", "rootcheck"),
|
||||||
|
reason="Confirm cross-view hidden process/module/port findings are gone.",
|
||||||
|
risk="low",
|
||||||
|
))
|
||||||
|
|
||||||
|
numbered = []
|
||||||
|
for i, action in enumerate(actions, 1):
|
||||||
|
d = action.to_dict()
|
||||||
|
d["id"] = action.id or f"A{i:03d}"
|
||||||
|
numbered.append(d)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema": "enodia.response.plan.v1",
|
||||||
|
"plan_id": f"plan-{incident_id}",
|
||||||
|
"incident_id": incident_id,
|
||||||
|
"created_at": datetime.now().astimezone().isoformat(),
|
||||||
|
"mode": "dry-run",
|
||||||
|
"apply_supported": False,
|
||||||
|
"summary": {
|
||||||
|
"severity": inc.get("severity", "?"),
|
||||||
|
"signatures": inc.get("signatures", []),
|
||||||
|
"snapshot_count": len(snapshots),
|
||||||
|
"action_count": len(numbered),
|
||||||
|
},
|
||||||
|
"actions": numbered,
|
||||||
|
"notes": [
|
||||||
|
"This plan is read-only; Sentinel did not execute any command.",
|
||||||
|
"Preserve evidence before stopping processes, services, or moving files.",
|
||||||
|
"Review each target against live state because snapshots can be stale.",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _alerts(snapshots: list[dict]) -> list[dict]:
|
||||||
|
return [a for snap in snapshots for a in snap.get("alerts", [])]
|
||||||
|
|
||||||
|
|
||||||
|
def _processes(snapshots: list[dict]) -> dict[int, dict]:
|
||||||
|
out = {}
|
||||||
|
for snap in snapshots:
|
||||||
|
for proc in snap.get("processes", []):
|
||||||
|
pid = proc.get("pid")
|
||||||
|
if isinstance(pid, int):
|
||||||
|
out[pid] = proc
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_IP_RE = re.compile(r"(?<![\w.])(?:\d{1,3}\.){3}\d{1,3}(?![\w.])")
|
||||||
|
|
||||||
|
|
||||||
|
def _public_ips(alerts: list[dict]) -> set[str]:
|
||||||
|
ips = set()
|
||||||
|
for alert in alerts:
|
||||||
|
for ip in _IP_RE.findall(alert.get("detail", "")):
|
||||||
|
if is_public_ip(ip):
|
||||||
|
ips.add(ip)
|
||||||
|
return ips
|
||||||
|
|
||||||
|
|
||||||
|
def _paths(alerts: list[dict]) -> set[str]:
|
||||||
|
return {path for path, _sig in _alert_paths(alerts)}
|
||||||
|
|
||||||
|
|
||||||
|
def _alert_paths(alerts: list[dict]) -> set[tuple[str, str]]:
|
||||||
|
paths = set()
|
||||||
|
for alert in alerts:
|
||||||
|
detail = alert.get("detail", "")
|
||||||
|
key = alert.get("key", "")
|
||||||
|
for text in (detail, key):
|
||||||
|
for token in re.findall(r"(?<![\w.])/[^\s,\])]+", text):
|
||||||
|
paths.add((token.rstrip(":"), alert.get("signature", "")))
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_units(alerts: list[dict]) -> set[str]:
|
||||||
|
units = set()
|
||||||
|
for path in _paths(alerts):
|
||||||
|
p = Path(path)
|
||||||
|
if "/systemd/system/" in path and p.name.endswith((".service", ".timer", ".socket")):
|
||||||
|
units.add(p.name)
|
||||||
|
return units
|
||||||
|
|
||||||
|
|
||||||
|
def _is_quarantine_candidate(path: str, sig: str) -> bool:
|
||||||
|
if sig == "new_suid":
|
||||||
|
return True
|
||||||
|
if sig == "fim_added":
|
||||||
|
return True
|
||||||
|
if sig in {"ld_preload", "deleted_exe", "fim_added"} and path.startswith(
|
||||||
|
("/tmp/", "/dev/shm/", "/var/tmp/", "/run/user/")):
|
||||||
|
return True
|
||||||
|
if path == "/etc/ld.so.preload":
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
@ -64,9 +64,11 @@ def heartbeat_age(cfg: Config) -> float | None:
|
||||||
|
|
||||||
# --- external dead-man's-switch watcher -----------------------------------
|
# --- external dead-man's-switch watcher -----------------------------------
|
||||||
|
|
||||||
def poll_status(url: str, token: str = "", timeout: int = 8) -> dict | None:
|
def poll_status(url: str, token: str = "", timeout: int = 8,
|
||||||
|
verify_tls: bool = True) -> dict | None:
|
||||||
"""Fetch a remote dashboard's /api/status. None if unreachable."""
|
"""Fetch a remote dashboard's /api/status. None if unreachable."""
|
||||||
import json
|
import json
|
||||||
|
import ssl
|
||||||
import urllib.request
|
import urllib.request
|
||||||
base = url.rstrip("/")
|
base = url.rstrip("/")
|
||||||
api = base if base.endswith("/api/status") else base + "/api/status"
|
api = base if base.endswith("/api/status") else base + "/api/status"
|
||||||
|
|
@ -74,7 +76,8 @@ def poll_status(url: str, token: str = "", timeout: int = 8) -> dict | None:
|
||||||
if token:
|
if token:
|
||||||
req.add_header("Authorization", f"Bearer {token}")
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
ctx = None if verify_tls else ssl._create_unverified_context()
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
||||||
return json.loads(resp.read())
|
return json.loads(resp.read())
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -7,156 +7,278 @@
|
||||||
<title>Enodia Sentinel</title>
|
<title>Enodia Sentinel</title>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--bg:#0b0e14; --panel:#11151f; --panel2:#161b27; --line:#222a3a;
|
--bg:#0a0c0f; --ink:#d8dee7; --muted:#8590a3; --soft:#aeb7c7;
|
||||||
--fg:#cdd6e4; --dim:#7c8aa5; --accent:#5ad1c8;
|
--panel:#11151b; --panel2:#151b23; --rail:#0d1117; --line:#26313f;
|
||||||
--crit:#ff5370; --high:#ffb454; --med:#73d0ff;
|
--accent:#22c7a9; --amber:#f2b84b; --red:#f06476; --blue:#61a8ff;
|
||||||
|
--ok:#56d364; --shadow:0 18px 48px rgba(0,0,0,.28);
|
||||||
}
|
}
|
||||||
*{box-sizing:border-box}
|
*{box-sizing:border-box}
|
||||||
body{margin:0;background:var(--bg);color:var(--fg);
|
body{margin:0;background:var(--bg);color:var(--ink);
|
||||||
font:14px/1.5 ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace}
|
font:14px/1.45 "Aptos Mono","IBM Plex Mono",ui-monospace,Menlo,Consolas,monospace}
|
||||||
header{display:flex;align-items:center;gap:14px;padding:14px 20px;
|
body:before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.18;
|
||||||
border-bottom:1px solid var(--line);background:var(--panel)}
|
background-image:linear-gradient(rgba(255,255,255,.06) 1px,transparent 1px),
|
||||||
header h1{font-size:16px;margin:0;letter-spacing:.18em;color:var(--accent)}
|
linear-gradient(90deg,rgba(255,255,255,.05) 1px,transparent 1px);
|
||||||
header .host{color:var(--dim)}
|
background-size:28px 28px}
|
||||||
.dot{width:10px;height:10px;border-radius:50%;background:#444;display:inline-block}
|
button{font:inherit;color:inherit;background:var(--panel2);border:1px solid var(--line);
|
||||||
.dot.up{background:#46d160;box-shadow:0 0 8px #46d160}
|
border-radius:6px;padding:8px 10px;cursor:pointer}
|
||||||
.dot.down{background:var(--crit)}
|
button:hover{border-color:var(--accent);color:#fff}
|
||||||
|
header{height:58px;display:flex;align-items:center;gap:16px;padding:0 18px;
|
||||||
|
border-bottom:1px solid var(--line);background:rgba(13,17,23,.96);position:relative;z-index:2}
|
||||||
|
h1{font-size:15px;margin:0;color:#fff;letter-spacing:.16em;text-transform:uppercase}
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:7px;border:1px solid var(--line);
|
||||||
|
border-radius:999px;padding:4px 9px;color:var(--soft);font-size:12px}
|
||||||
|
.dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px var(--red)}
|
||||||
|
.dot.up{background:var(--ok);box-shadow:0 0 12px var(--ok)}
|
||||||
.spacer{flex:1}
|
.spacer{flex:1}
|
||||||
.meta{color:var(--dim);font-size:12px}
|
.tls{color:var(--accent)}
|
||||||
.cards{display:flex;gap:12px;padding:16px 20px;flex-wrap:wrap}
|
.layout{display:grid;grid-template-columns:330px minmax(420px,1fr) 390px;
|
||||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;
|
height:calc(100vh - 58px);position:relative;z-index:1}
|
||||||
padding:12px 16px;min-width:120px}
|
aside,.work,.plan{min-height:0;overflow:auto}
|
||||||
.card .n{font-size:26px;font-weight:700}
|
aside{background:rgba(13,17,23,.9);border-right:1px solid var(--line)}
|
||||||
.card .l{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.1em}
|
.work{background:linear-gradient(180deg,#0f1319,#0b0d11);padding:18px}
|
||||||
.card.crit .n{color:var(--crit)} .card.high .n{color:var(--high)}
|
.plan{background:rgba(17,21,27,.92);border-left:1px solid var(--line);padding:16px}
|
||||||
.card.med .n{color:var(--med)}
|
.section-title{display:flex;align-items:center;justify-content:space-between;
|
||||||
main{display:grid;grid-template-columns:minmax(340px,460px) 1fr;gap:0;
|
padding:13px 14px 8px;color:var(--muted);font-size:11px;letter-spacing:.12em;
|
||||||
border-top:1px solid var(--line);height:calc(100vh - 180px)}
|
text-transform:uppercase}
|
||||||
.list,.detail{overflow:auto;height:100%}
|
.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:16px}
|
||||||
.list{border-right:1px solid var(--line)}
|
.metric{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}
|
||||||
.row{padding:10px 16px;border-bottom:1px solid var(--line);cursor:pointer}
|
.metric b{display:block;font-size:24px;line-height:1;color:#fff}
|
||||||
.row:hover{background:var(--panel2)} .row.sel{background:var(--panel2);
|
.metric span{display:block;color:var(--muted);font-size:11px;margin-top:8px;text-transform:uppercase}
|
||||||
border-left:3px solid var(--accent);padding-left:13px}
|
.metric.crit b{color:var(--red)} .metric.high b{color:var(--amber)} .metric.med b{color:var(--blue)}
|
||||||
.row .top{display:flex;justify-content:space-between;gap:8px}
|
.list{padding:0 8px 14px}
|
||||||
.sev{font-weight:700;font-size:11px;padding:1px 7px;border-radius:4px}
|
.item{border:1px solid transparent;border-bottom-color:var(--line);padding:10px;
|
||||||
.sev.CRITICAL{color:var(--crit);border:1px solid var(--crit)}
|
cursor:pointer;border-radius:7px;margin-bottom:4px}
|
||||||
.sev.HIGH{color:var(--high);border:1px solid var(--high)}
|
.item:hover{background:var(--panel)}
|
||||||
.sev.MEDIUM{color:var(--med);border:1px solid var(--med)}
|
.item.sel{background:var(--panel2);border-color:var(--accent);box-shadow:inset 3px 0 0 var(--accent)}
|
||||||
.row .sigs{color:var(--fg);margin-top:4px;font-size:13px}
|
.item .top{display:flex;align-items:center;gap:8px;justify-content:space-between}
|
||||||
.row .t{color:var(--dim);font-size:11px}
|
.id{font-size:12px;color:#fff;word-break:break-word}
|
||||||
.row .sids{color:var(--dim);font-size:11px}
|
.time,.fine{color:var(--muted);font-size:11px}
|
||||||
.detail{padding:18px 22px}
|
.tags{margin-top:7px;display:flex;gap:6px;flex-wrap:wrap}
|
||||||
.detail pre{white-space:pre-wrap;word-break:break-word;margin:0;
|
.tag{font-size:11px;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--soft)}
|
||||||
color:#b9c4d8;font-size:12.5px}
|
.sev{font-weight:700;font-size:11px;border-radius:4px;padding:2px 7px;border:1px solid var(--line)}
|
||||||
.empty{color:var(--dim);padding:40px;text-align:center}
|
.sev.CRITICAL{color:var(--red);border-color:var(--red)}
|
||||||
.err{color:var(--crit);padding:30px;text-align:center}
|
.sev.HIGH{color:var(--amber);border-color:var(--amber)}
|
||||||
footer{border-top:1px solid var(--line);background:var(--panel);
|
.sev.MEDIUM{color:var(--blue);border-color:var(--blue)}
|
||||||
padding:8px 20px;color:var(--dim);font-size:11px;display:flex;gap:18px}
|
.tabs{display:flex;gap:6px;margin-bottom:14px}
|
||||||
a{color:var(--accent)}
|
.tabs button{padding:7px 11px}
|
||||||
|
.tabs button.active{background:var(--accent);border-color:var(--accent);color:#00110e}
|
||||||
|
.card{background:rgba(17,21,27,.9);border:1px solid var(--line);border-radius:8px;
|
||||||
|
padding:14px;margin-bottom:14px;box-shadow:var(--shadow)}
|
||||||
|
h2{font-size:18px;margin:0 0 8px;color:#fff;letter-spacing:0}
|
||||||
|
h3{font-size:12px;margin:0 0 10px;color:var(--muted);letter-spacing:.12em;text-transform:uppercase}
|
||||||
|
.kv{display:grid;grid-template-columns:120px 1fr;gap:8px;margin-top:10px}
|
||||||
|
.kv div:nth-child(odd){color:var(--muted)}
|
||||||
|
.timeline{display:grid;gap:8px}
|
||||||
|
.event{border-left:2px solid var(--accent);padding:8px 10px;background:rgba(255,255,255,.03)}
|
||||||
|
.event b{display:block;color:#fff}
|
||||||
|
.event code,.cmd{display:block;margin-top:6px;padding:8px;background:#080a0d;
|
||||||
|
border:1px solid var(--line);border-radius:6px;color:#cbd5e1;white-space:pre-wrap;word-break:break-word}
|
||||||
|
.action{border:1px solid var(--line);border-radius:8px;background:#0d1117;padding:11px;margin-bottom:10px}
|
||||||
|
.action .head{display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||||
|
.risk{font-size:10px;text-transform:uppercase;border:1px solid var(--line);border-radius:999px;padding:2px 7px;color:var(--muted)}
|
||||||
|
.risk.high{color:var(--red);border-color:var(--red)}
|
||||||
|
.risk.medium{color:var(--amber);border-color:var(--amber)}
|
||||||
|
.risk.low{color:var(--accent);border-color:var(--accent)}
|
||||||
|
pre{margin:0;white-space:pre-wrap;word-break:break-word;color:#c8d1df;font-size:12px}
|
||||||
|
.empty,.err{padding:30px;color:var(--muted);text-align:center}
|
||||||
|
.err{color:var(--red)}
|
||||||
|
@media (max-width:1100px){
|
||||||
|
.layout{grid-template-columns:300px 1fr}
|
||||||
|
.plan{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line);height:44vh}
|
||||||
|
}
|
||||||
|
@media (max-width:760px){
|
||||||
|
header{height:auto;min-height:58px;flex-wrap:wrap;padding:12px}
|
||||||
|
.layout{display:block;height:auto}
|
||||||
|
aside,.work,.plan{height:auto;max-height:none}
|
||||||
|
.metrics{grid-template-columns:repeat(2,1fr)}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>ENODIA SENTINEL</h1>
|
<h1>Enodia Sentinel</h1>
|
||||||
<span class="dot" id="dot"></span>
|
<span class="pill"><span class="dot" id="dot"></span><span id="daemon">connecting</span></span>
|
||||||
<span class="host" id="host">—</span>
|
<span class="pill tls">HTTPS self-signed</span>
|
||||||
|
<span class="pill" id="host">host unknown</span>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<span class="meta" id="ebpf"></span>
|
<span class="pill" id="ebpf">eBPF unknown</span>
|
||||||
<span class="meta" id="ver"></span>
|
<button id="refreshBtn" title="Refresh console">Refresh</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cards" id="cards"></div>
|
<div class="layout">
|
||||||
|
<aside>
|
||||||
|
<div class="section-title"><span>Incidents</span><span id="incidentCount">0</span></div>
|
||||||
|
<div class="list" id="incidents"><div class="empty">loading incidents</div></div>
|
||||||
|
<div class="section-title"><span>Recent Alerts</span><span id="alertCount">0</span></div>
|
||||||
|
<div class="list" id="alerts"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<main>
|
<main class="work">
|
||||||
<div class="list" id="list"><div class="empty">loading…</div></div>
|
<div class="metrics" id="metrics"></div>
|
||||||
<div class="detail" id="detail"><div class="empty">Select an alert to view its forensic snapshot.</div></div>
|
<div class="tabs">
|
||||||
</main>
|
<button class="active" data-tab="incident">Incident</button>
|
||||||
|
<button data-tab="alerts">Alerts</button>
|
||||||
|
<button data-tab="events">Events</button>
|
||||||
|
</div>
|
||||||
|
<section id="incidentTab"></section>
|
||||||
|
<section id="alertsTab" hidden></section>
|
||||||
|
<section id="eventsTab" hidden></section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<footer>
|
<section class="plan">
|
||||||
<span id="status">connecting…</span>
|
<div class="section-title"><span>Dry-run Response Plan</span><span id="planMode">read-only</span></div>
|
||||||
<span class="spacer" style="flex:1"></span>
|
<div id="plan"><div class="empty">Select an incident to generate a plan.</div></div>
|
||||||
<span id="refreshed"></span>
|
</section>
|
||||||
</footer>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
||||||
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
const HDR = TOKEN ? {Authorization:"Bearer "+TOKEN} : {};
|
||||||
let selected = null;
|
const state = {incidents:[], alerts:[], selected:null, incident:null, plan:null, tab:"incident"};
|
||||||
|
|
||||||
async function api(path){
|
async function api(path){
|
||||||
const r = await fetch(path, {headers:HDR});
|
const r = await fetch(path,{headers:HDR});
|
||||||
if(r.status===401) throw new Error("unauthorized — bad or missing ?token");
|
if(r.status===401) throw new Error("unauthorized - open with ?token=YOUR_TOKEN");
|
||||||
if(!r.ok) throw new Error("HTTP "+r.status);
|
if(!r.ok) throw new Error("HTTP "+r.status);
|
||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
function el(tag, cls, txt){const e=document.createElement(tag); if(cls)e.className=cls;
|
||||||
function el(tag, cls, txt){const e=document.createElement(tag);
|
if(txt!==undefined)e.textContent=txt; return e}
|
||||||
if(cls)e.className=cls; if(txt!=null)e.textContent=txt; return e;}
|
function sev(s){return el("span","sev "+(s||""),s||"?")}
|
||||||
|
function tags(xs){const box=el("div","tags"); (xs||[]).forEach(x=>box.append(el("span","tag",x))); return box}
|
||||||
|
function fmt(t){return (t||"?").replace("T"," ").slice(0,19)}
|
||||||
|
|
||||||
async function refresh(){
|
async function refresh(){
|
||||||
try{
|
try{
|
||||||
const st = await api("/api/status");
|
const [status, incidents, alerts, events] = await Promise.all([
|
||||||
document.getElementById("dot").className = "dot "+(st.running?"up":"down");
|
api("/api/status"), api("/api/incidents"), api("/api/alerts"), api("/api/events")
|
||||||
document.getElementById("host").textContent = st.host || "";
|
]);
|
||||||
document.getElementById("ver").textContent = "v"+st.version;
|
state.incidents = incidents; state.alerts = alerts; state.events = events.events || [];
|
||||||
document.getElementById("ebpf").textContent = "eBPF: "+(st.ebpf||"?");
|
renderStatus(status); renderLists();
|
||||||
let hb = "";
|
if(!state.selected && incidents.length) await openIncident(incidents[0].id);
|
||||||
if (st.heartbeat_age != null)
|
else if(state.selected) await openIncident(state.selected, false);
|
||||||
hb = st.heartbeat_stale
|
renderTab(); renderEvents();
|
||||||
? ` ⚠ heartbeat STALE (${Math.round(st.heartbeat_age)}s) — possible tampering`
|
}catch(e){ showError(e.message); }
|
||||||
: ` ♥ ${Math.round(st.heartbeat_age)}s ago`;
|
}
|
||||||
const st_el = document.getElementById("status");
|
|
||||||
st_el.textContent = (st.running ? "daemon running" : "daemon NOT running") + hb;
|
|
||||||
st_el.style.color = (st.running && !st.heartbeat_stale) ? "" : "var(--crit)";
|
|
||||||
const c = st.counts||{};
|
|
||||||
const cards = document.getElementById("cards"); cards.innerHTML="";
|
|
||||||
for(const [lbl,key] of [["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"]]){
|
|
||||||
const card=el("div","card "+key);
|
|
||||||
card.append(el("div","n",String(c[lbl]||0))); card.append(el("div","l",lbl));
|
|
||||||
cards.append(card);
|
|
||||||
}
|
|
||||||
const tot=el("div","card"); tot.append(el("div","n",String(st.total_alerts||0)));
|
|
||||||
tot.append(el("div","l","total alerts")); cards.append(tot);
|
|
||||||
|
|
||||||
const alerts = await api("/api/alerts");
|
function renderStatus(st){
|
||||||
const list = document.getElementById("list"); list.innerHTML="";
|
document.getElementById("dot").className = "dot "+(st.running && !st.heartbeat_stale ? "up" : "");
|
||||||
if(!alerts.length){ list.append(el("div","empty","No alerts captured.")); }
|
let hb = st.heartbeat_age == null ? "no heartbeat" : Math.round(st.heartbeat_age)+"s heartbeat";
|
||||||
for(const a of alerts){
|
document.getElementById("daemon").textContent = (st.running ? "running" : "not running")+" / "+hb;
|
||||||
const row=el("div","row"); row.dataset.name=a.name;
|
document.getElementById("host").textContent = st.host+" / v"+st.version;
|
||||||
if(a.name===selected) row.classList.add("sel");
|
document.getElementById("ebpf").textContent = "eBPF "+(st.ebpf || "unknown");
|
||||||
const top=el("div","top");
|
const counts = st.counts || {};
|
||||||
top.append(el("span","sev "+a.severity, a.severity));
|
const metrics = document.getElementById("metrics"); metrics.innerHTML = "";
|
||||||
top.append(el("span","t", (a.time||"").replace("T"," ").slice(0,19)));
|
[["CRITICAL","crit"],["HIGH","high"],["MEDIUM","med"],["TOTAL",""]].forEach(([k,c])=>{
|
||||||
row.append(top);
|
const m=el("div","metric "+c); m.append(el("b",null,String(k==="TOTAL" ? st.total_alerts||0 : counts[k]||0)));
|
||||||
row.append(el("div","sigs", a.signatures.join(", ")));
|
m.append(el("span",null,k==="TOTAL" ? "alerts" : k)); metrics.append(m);
|
||||||
if(a.sids.length) row.append(el("div","sids","sid: "+a.sids.join(", ")));
|
});
|
||||||
row.onclick=()=>openAlert(a.name);
|
}
|
||||||
list.append(row);
|
|
||||||
}
|
function renderLists(){
|
||||||
document.getElementById("refreshed").textContent =
|
document.getElementById("incidentCount").textContent = state.incidents.length;
|
||||||
"updated "+new Date().toLocaleTimeString();
|
document.getElementById("alertCount").textContent = state.alerts.length;
|
||||||
}catch(e){
|
const incBox = document.getElementById("incidents"); incBox.innerHTML = "";
|
||||||
document.getElementById("status").textContent = "error: "+e.message;
|
if(!state.incidents.length) incBox.append(el("div","empty","No incidents recorded."));
|
||||||
if(String(e.message).includes("unauthorized"))
|
state.incidents.forEach(i=>{
|
||||||
document.getElementById("detail").innerHTML =
|
const row=el("div","item"); row.classList.toggle("sel", i.id===state.selected);
|
||||||
'<div class="err">Unauthorized. Open this page with ?token=YOUR_TOKEN</div>';
|
row.onclick=()=>openIncident(i.id);
|
||||||
}
|
const top=el("div","top"); top.append(el("span","id",i.id)); top.append(sev(i.severity));
|
||||||
|
row.append(top); row.append(el("div","time",fmt(i.last_seen)));
|
||||||
|
row.append(tags(i.signatures)); incBox.append(row);
|
||||||
|
});
|
||||||
|
const alertBox = document.getElementById("alerts"); alertBox.innerHTML = "";
|
||||||
|
state.alerts.slice(0,12).forEach(a=>{
|
||||||
|
const row=el("div","item"); row.onclick=()=>openAlert(a.name);
|
||||||
|
const top=el("div","top"); top.append(el("span","id",a.name)); top.append(sev(a.severity));
|
||||||
|
row.append(top); row.append(el("div","time",fmt(a.time))); row.append(tags(a.signatures));
|
||||||
|
alertBox.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openIncident(id, mark=true){
|
||||||
|
state.selected = id;
|
||||||
|
if(mark) renderLists();
|
||||||
|
try{
|
||||||
|
const [inc, plan] = await Promise.all([
|
||||||
|
api("/api/incidents/"+encodeURIComponent(id)),
|
||||||
|
api("/api/respond/plan/"+encodeURIComponent(id))
|
||||||
|
]);
|
||||||
|
state.incident = inc; state.plan = plan;
|
||||||
|
renderIncident(); renderPlan();
|
||||||
|
}catch(e){ document.getElementById("incidentTab").innerHTML='<div class="err">'+e.message+'</div>'; }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openAlert(name){
|
async function openAlert(name){
|
||||||
selected = name;
|
state.tab = "alerts"; setTabButtons();
|
||||||
document.querySelectorAll(".row").forEach(r=>
|
const box = document.getElementById("alertsTab");
|
||||||
r.classList.toggle("sel", r.dataset.name===name));
|
box.hidden = false; document.getElementById("incidentTab").hidden = true; document.getElementById("eventsTab").hidden = true;
|
||||||
const d = document.getElementById("detail");
|
box.innerHTML='<div class="empty">loading snapshot</div>';
|
||||||
d.innerHTML='<div class="empty">loading…</div>';
|
|
||||||
try{
|
try{
|
||||||
const a = await api("/api/alerts/"+encodeURIComponent(name));
|
const a = await api("/api/alerts/"+encodeURIComponent(name));
|
||||||
d.innerHTML="";
|
box.innerHTML = '<div class="card"><h3>'+name+'</h3><pre></pre></div>';
|
||||||
const pre=el("pre",null,a.text || JSON.stringify(a.json,null,2));
|
box.querySelector("pre").textContent = a.text || JSON.stringify(a.json,null,2);
|
||||||
d.append(pre);
|
}catch(e){ box.innerHTML='<div class="err">'+e.message+'</div>'; }
|
||||||
}catch(e){ d.innerHTML='<div class="err">'+e.message+'</div>'; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderIncident(){
|
||||||
|
const box = document.getElementById("incidentTab"); const data = state.incident;
|
||||||
|
if(!data){ box.innerHTML='<div class="empty">No incident selected.</div>'; return; }
|
||||||
|
const i = data.incident;
|
||||||
|
box.innerHTML = "";
|
||||||
|
const card = el("div","card");
|
||||||
|
card.append(el("h2",null,i.id)); card.append(tags(i.signatures));
|
||||||
|
const kv = el("div","kv");
|
||||||
|
[["Severity",i.severity],["First seen",fmt(i.first_seen)],["Last seen",fmt(i.last_seen)],
|
||||||
|
["Alert count",String(i.alert_count||0)],["PIDs",(i.pids||[]).join(", ")||"-"],["SIDs",(i.sids||[]).join(", ")||"-"]]
|
||||||
|
.forEach(([k,v])=>{kv.append(el("div",null,k)); kv.append(el("div",null,v));});
|
||||||
|
card.append(kv); box.append(card);
|
||||||
|
const tl = el("div","card"); tl.append(el("h3",null,"Timeline"));
|
||||||
|
const list = el("div","timeline");
|
||||||
|
(data.timeline||[]).forEach(r=>{
|
||||||
|
const ev = el("div","event"); ev.append(el("b",null,fmt(r.time)+" ["+(r.severity||"?")+"]"));
|
||||||
|
ev.append(el("div","fine",(r.signatures||[]).join(", ") || r.snapshot));
|
||||||
|
ev.append(el("code",null,r.snapshot)); list.append(ev);
|
||||||
|
});
|
||||||
|
tl.append(list); box.append(tl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlan(){
|
||||||
|
const box = document.getElementById("plan"); const plan = state.plan;
|
||||||
|
if(!plan){ box.innerHTML='<div class="empty">No response plan available.</div>'; return; }
|
||||||
|
document.getElementById("planMode").textContent = plan.mode + " / no apply";
|
||||||
|
box.innerHTML = "";
|
||||||
|
(plan.actions||[]).forEach(a=>{
|
||||||
|
const row = el("div","action");
|
||||||
|
const head = el("div","head"); head.append(el("b",null,a.id+" "+a.title));
|
||||||
|
head.append(el("span","risk "+a.risk,a.risk)); row.append(head);
|
||||||
|
row.append(el("code","cmd",(a.command||[]).join(" ")));
|
||||||
|
row.append(el("div","fine",a.reason)); box.append(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTab(){
|
||||||
|
setTabButtons();
|
||||||
|
document.getElementById("incidentTab").hidden = state.tab !== "incident";
|
||||||
|
document.getElementById("alertsTab").hidden = state.tab !== "alerts";
|
||||||
|
document.getElementById("eventsTab").hidden = state.tab !== "events";
|
||||||
|
if(state.tab === "incident") renderIncident();
|
||||||
|
if(state.tab === "events") renderEvents();
|
||||||
|
}
|
||||||
|
function setTabButtons(){
|
||||||
|
document.querySelectorAll(".tabs button").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
|
||||||
|
}
|
||||||
|
function renderEvents(){
|
||||||
|
const box = document.getElementById("eventsTab");
|
||||||
|
box.innerHTML = '<div class="card"><h3>Event tail</h3><pre></pre></div>';
|
||||||
|
box.querySelector("pre").textContent = (state.events||[]).join("\n") || "No events.";
|
||||||
|
}
|
||||||
|
function showError(msg){
|
||||||
|
document.getElementById("incidentTab").innerHTML = '<div class="err">'+msg+'</div>';
|
||||||
|
document.getElementById("plan").innerHTML = '<div class="err">'+msg+'</div>';
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".tabs button").forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab; renderTab();});
|
||||||
|
document.getElementById("refreshBtn").onclick=refresh;
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 10000);
|
setInterval(refresh, 10000);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,27 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""Read-only web dashboard, served by the stdlib http.server (zero deps).
|
"""Read-only HTTPS web dashboard, served by the stdlib http.server (zero deps).
|
||||||
|
|
||||||
Exposes a small JSON API over the log directory plus a single self-contained
|
Exposes a small JSON API over the log directory plus a single self-contained
|
||||||
page. It is deliberately read-only — no actions, no writes — because it serves
|
page. It is deliberately read-only — no actions, no writes — because it serves
|
||||||
sensitive forensic data. By default it binds the host's Tailscale address and
|
sensitive forensic data. TLS is mandatory, using configured certificates or an
|
||||||
requires a bearer token; on loopback the token is optional.
|
auto-generated self-signed certificate under ``log_dir``. By default it binds
|
||||||
|
the host's Tailscale address and requires a bearer token; on loopback the token
|
||||||
|
is optional.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
import hmac
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import ssl
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
from . import __version__
|
from . import __version__
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
|
@ -79,6 +83,53 @@ def ensure_token(cfg: Config) -> str:
|
||||||
return tok
|
return tok
|
||||||
|
|
||||||
|
|
||||||
|
def tls_paths(cfg: Config) -> tuple[Path, Path]:
|
||||||
|
cert = Path(cfg.web_tls_cert) if cfg.web_tls_cert else cfg.log_dir / "web-selfsigned.crt"
|
||||||
|
key = Path(cfg.web_tls_key) if cfg.web_tls_key else cfg.log_dir / "web-selfsigned.key"
|
||||||
|
return cert, key
|
||||||
|
|
||||||
|
|
||||||
|
def _san_for(bind: str) -> str:
|
||||||
|
names = ["DNS:localhost", "IP:127.0.0.1"]
|
||||||
|
host = os.uname().nodename
|
||||||
|
if host:
|
||||||
|
names.append(f"DNS:{host}")
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(bind)
|
||||||
|
names.append(f"IP:{bind}")
|
||||||
|
except ValueError:
|
||||||
|
if bind and bind != "localhost":
|
||||||
|
names.append(f"DNS:{bind}")
|
||||||
|
return ",".join(dict.fromkeys(names))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tls_cert(cfg: Config, bind: str) -> tuple[Path, Path]:
|
||||||
|
"""Return usable cert/key paths, generating a self-signed pair if absent."""
|
||||||
|
cert, key = tls_paths(cfg)
|
||||||
|
if cert.is_file() and key.is_file():
|
||||||
|
return cert, key
|
||||||
|
cert.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
key.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subj = f"/CN={bind if bind else 'localhost'}"
|
||||||
|
cmd = [
|
||||||
|
"openssl", "req", "-x509", "-newkey", "rsa:3072", "-nodes",
|
||||||
|
"-sha256", "-days", "365",
|
||||||
|
"-keyout", str(key), "-out", str(cert),
|
||||||
|
"-subj", subj,
|
||||||
|
"-addext", f"subjectAltName={_san_for(bind)}",
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, capture_output=True, text=True, timeout=20, check=True)
|
||||||
|
key.chmod(0o600)
|
||||||
|
cert.chmod(0o644)
|
||||||
|
except (OSError, subprocess.SubprocessError) as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"TLS is mandatory for the web dashboard, but no certificate/key "
|
||||||
|
"were available and self-signed generation with openssl failed"
|
||||||
|
) from exc
|
||||||
|
return cert, key
|
||||||
|
|
||||||
|
|
||||||
# --- data layer (pure, testable) ------------------------------------------
|
# --- data layer (pure, testable) ------------------------------------------
|
||||||
|
|
||||||
def list_alerts(cfg: Config, limit: int = 200) -> list[dict]:
|
def list_alerts(cfg: Config, limit: int = 200) -> list[dict]:
|
||||||
|
|
@ -156,6 +207,51 @@ def daemon_status(cfg: Config) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_incidents(cfg: Config) -> list[dict]:
|
||||||
|
from . import incident
|
||||||
|
|
||||||
|
return sorted(incident.load_index(cfg).values(),
|
||||||
|
key=lambda i: i.get("last_ts", 0.0), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def get_incident(cfg: Config, incident_id: str) -> dict | None:
|
||||||
|
from . import incident
|
||||||
|
|
||||||
|
inc = incident.load_index(cfg).get(incident_id)
|
||||||
|
if inc is None:
|
||||||
|
return None
|
||||||
|
snapshots = []
|
||||||
|
timeline = []
|
||||||
|
for name in inc.get("snapshots", []):
|
||||||
|
path = (cfg.log_dir / name).with_suffix(".json")
|
||||||
|
try:
|
||||||
|
report = json.loads(path.read_text())
|
||||||
|
except (OSError, ValueError):
|
||||||
|
timeline.append({"snapshot": name, "time": "?", "missing": True})
|
||||||
|
continue
|
||||||
|
snapshots.append(report)
|
||||||
|
timeline.append({
|
||||||
|
"snapshot": name,
|
||||||
|
"time": report.get("time", "?"),
|
||||||
|
"severity": report.get("severity", "?"),
|
||||||
|
"signatures": list(dict.fromkeys(
|
||||||
|
a.get("signature", "") for a in report.get("alerts", []))),
|
||||||
|
"pids": [p.get("pid") for p in report.get("processes", [])
|
||||||
|
if isinstance(p.get("pid"), int)],
|
||||||
|
})
|
||||||
|
timeline.sort(key=lambda r: r.get("time", ""))
|
||||||
|
return {"incident": inc, "timeline": timeline, "snapshots": snapshots}
|
||||||
|
|
||||||
|
|
||||||
|
def response_plan(cfg: Config, incident_id: str) -> dict | None:
|
||||||
|
from . import respond
|
||||||
|
|
||||||
|
bundle = respond.load_bundle(cfg, incident_id)
|
||||||
|
if bundle is None:
|
||||||
|
return None
|
||||||
|
return respond.build_plan(bundle, cfg)
|
||||||
|
|
||||||
|
|
||||||
# --- HTTP layer ------------------------------------------------------------
|
# --- HTTP layer ------------------------------------------------------------
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
|
@ -203,6 +299,18 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
200 if alert else 404)
|
200 if alert else 404)
|
||||||
elif path == "/api/events":
|
elif path == "/api/events":
|
||||||
self._json({"events": tail_events(cfg)})
|
self._json({"events": tail_events(cfg)})
|
||||||
|
elif path == "/api/incidents":
|
||||||
|
self._json(list_incidents(cfg))
|
||||||
|
elif path.startswith("/api/incidents/"):
|
||||||
|
iid = unquote(path.rsplit("/", 1)[-1])
|
||||||
|
inc = get_incident(cfg, iid)
|
||||||
|
self._json(inc if inc else {"error": "not found"},
|
||||||
|
200 if inc else 404)
|
||||||
|
elif path.startswith("/api/respond/plan/"):
|
||||||
|
iid = unquote(path.rsplit("/", 1)[-1])
|
||||||
|
plan = response_plan(cfg, iid)
|
||||||
|
self._json(plan if plan else {"error": "not found"},
|
||||||
|
200 if plan else 404)
|
||||||
else:
|
else:
|
||||||
self._json({"error": "not found"}, 404)
|
self._json({"error": "not found"}, 404)
|
||||||
|
|
||||||
|
|
@ -220,22 +328,29 @@ class _Handler(BaseHTTPRequestHandler):
|
||||||
|
|
||||||
|
|
||||||
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
|
def build_server(cfg: Config) -> tuple[ThreadingHTTPServer, str, str]:
|
||||||
"""Construct the server (without serving). Returns (httpd, bind, token)."""
|
"""Construct the HTTPS server. Returns (httpd, bind, token)."""
|
||||||
bind = resolve_bind(cfg)
|
bind = resolve_bind(cfg)
|
||||||
token = cfg.web_token
|
token = cfg.web_token
|
||||||
if not is_loopback(bind) and not token:
|
if not is_loopback(bind) and not token:
|
||||||
token = ensure_token(cfg)
|
token = ensure_token(cfg)
|
||||||
|
cert, key = ensure_tls_cert(cfg, bind)
|
||||||
httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler)
|
httpd = ThreadingHTTPServer((bind, cfg.web_port), _Handler)
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
ctx.load_cert_chain(certfile=str(cert), keyfile=str(key))
|
||||||
|
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||||
httpd.cfg = cfg # type: ignore[attr-defined]
|
httpd.cfg = cfg # type: ignore[attr-defined]
|
||||||
httpd.token = token # type: ignore[attr-defined]
|
httpd.token = token # type: ignore[attr-defined]
|
||||||
|
httpd.tls_cert = cert # type: ignore[attr-defined]
|
||||||
return httpd, bind, token
|
return httpd, bind, token
|
||||||
|
|
||||||
|
|
||||||
def serve(cfg: Config) -> None:
|
def serve(cfg: Config) -> None:
|
||||||
httpd, bind, token = build_server(cfg)
|
httpd, bind, token = build_server(cfg)
|
||||||
suffix = f"/?token={token}" if token else "/"
|
suffix = f"/?token={token}" if token else "/"
|
||||||
url = f"http://{bind}:{cfg.web_port}{suffix}"
|
url = f"https://{bind}:{cfg.web_port}{suffix}"
|
||||||
msg = f"{time.strftime('%FT%T%z')} dashboard serving at {url}"
|
msg = (f"{time.strftime('%FT%T%z')} dashboard serving at {url} "
|
||||||
|
f"(TLS cert: {getattr(httpd, 'tls_cert', '')})")
|
||||||
print(msg, flush=True)
|
print(msg, flush=True)
|
||||||
try:
|
try:
|
||||||
with open(cfg.events_log, "a") as fh:
|
with open(cfg.events_log, "a") as fh:
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,9 @@ RestartSec=5
|
||||||
# lock it down hard. It does not need root, but reads root-owned snapshots, so
|
# lock it down hard. It does not need root, but reads root-owned snapshots, so
|
||||||
# it runs as root with an otherwise minimal surface and no capabilities.
|
# it runs as root with an otherwise minimal surface and no capabilities.
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
# Writable only so it can persist an auto-generated token; set web_token in the
|
# Writable so it can persist an auto-generated token and self-signed TLS
|
||||||
# config to keep this purely read-only.
|
# certificate/key. Set web_token, web_tls_cert, and web_tls_key in config to
|
||||||
|
# keep this service purely read-only after provisioning.
|
||||||
ReadWritePaths=/var/log/enodia-sentinel
|
ReadWritePaths=/var/log/enodia-sentinel
|
||||||
ProtectHome=yes
|
ProtectHome=yes
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|
|
||||||
111
tests/test_respond.py
Normal file
111
tests/test_respond.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from enodia_sentinel import incident, respond
|
||||||
|
from enodia_sentinel.alert import Alert, Severity
|
||||||
|
from enodia_sentinel.cli import main
|
||||||
|
from enodia_sentinel.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def _alert(sig, detail, pids=(), sid=1, sev=Severity.HIGH):
|
||||||
|
return Alert(severity=sev, signature=sig, key=f"k:{sig}",
|
||||||
|
detail=detail, pids=tuple(pids), sid=sid, classtype="test")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRespondPlan(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
self.tmp = Path(self.dir.name)
|
||||||
|
self.cfg = Config()
|
||||||
|
self.cfg.log_dir = self.tmp
|
||||||
|
self.iid = incident.record(self.cfg, "alert-1.log", [
|
||||||
|
_alert("reverse_shell",
|
||||||
|
"pid=4242 comm=bash stdio=net-socket peer=[8.8.8.8:4444]",
|
||||||
|
pids=[4242], sid=100010, sev=Severity.CRITICAL),
|
||||||
|
_alert("persistence",
|
||||||
|
"persistence file modified: /etc/systemd/system/bad.service",
|
||||||
|
sid=100015),
|
||||||
|
_alert("new_suid",
|
||||||
|
"SUID/SGID binary in writable dir: /tmp/suidsh",
|
||||||
|
sid=100014, sev=Severity.CRITICAL),
|
||||||
|
], lineage={4242, 100}, when=1000.0, host="h")
|
||||||
|
(self.tmp / "alert-1.json").write_text(json.dumps({
|
||||||
|
"time": "2026-06-10T10:00:00-07:00",
|
||||||
|
"host": "h",
|
||||||
|
"severity": "CRITICAL",
|
||||||
|
"incident_id": self.iid,
|
||||||
|
"alerts": [
|
||||||
|
{"signature": "reverse_shell", "sid": 100010,
|
||||||
|
"detail": "pid=4242 comm=bash stdio=net-socket peer=[8.8.8.8:4444]",
|
||||||
|
"pids": [4242]},
|
||||||
|
{"signature": "persistence", "sid": 100015,
|
||||||
|
"detail": "persistence file modified: /etc/systemd/system/bad.service",
|
||||||
|
"pids": []},
|
||||||
|
{"signature": "new_suid", "sid": 100014,
|
||||||
|
"detail": "SUID/SGID binary in writable dir: /tmp/suidsh",
|
||||||
|
"pids": []},
|
||||||
|
],
|
||||||
|
"processes": [{"pid": 4242, "comm": "bash"}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.dir.cleanup()
|
||||||
|
|
||||||
|
def test_builds_dry_run_plan_from_incident(self):
|
||||||
|
bundle = respond.load_bundle(self.cfg, self.iid)
|
||||||
|
plan = respond.build_plan(bundle, self.cfg)
|
||||||
|
self.assertEqual(plan["mode"], "dry-run")
|
||||||
|
self.assertFalse(plan["apply_supported"])
|
||||||
|
commands = [a["command"] for a in plan["actions"]]
|
||||||
|
self.assertIn(["kill", "-STOP", "4242"], commands)
|
||||||
|
self.assertIn(["kill", "-TERM", "4242"], commands)
|
||||||
|
self.assertIn(["nft", "add", "rule", "inet", "filter", "output",
|
||||||
|
"ip", "daddr", "8.8.8.8", "drop"], commands)
|
||||||
|
self.assertIn(["systemctl", "disable", "--now", "bad.service"], commands)
|
||||||
|
self.assertIn(["mv", "--", "/tmp/suidsh",
|
||||||
|
"/tmp/suidsh.enodia-quarantine"], commands)
|
||||||
|
self.assertNotIn(["mv", "--", "/etc/systemd/system/bad.service",
|
||||||
|
"/etc/systemd/system/bad.service.enodia-quarantine"],
|
||||||
|
commands)
|
||||||
|
self.assertEqual(plan["actions"][0]["category"], "evidence")
|
||||||
|
|
||||||
|
def test_cli_json(self):
|
||||||
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
||||||
|
try:
|
||||||
|
buf = io.StringIO()
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
code = main(["respond", "plan", self.iid, "--json"])
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
plan = json.loads(buf.getvalue())
|
||||||
|
self.assertEqual(plan["incident_id"], self.iid)
|
||||||
|
self.assertGreaterEqual(plan["summary"]["action_count"], 5)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||||
|
|
||||||
|
def test_cli_requires_incident_id(self):
|
||||||
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
||||||
|
try:
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
code = main(["respond", "plan"])
|
||||||
|
self.assertEqual(code, 2)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||||
|
|
||||||
|
def test_unknown_incident(self):
|
||||||
|
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
||||||
|
try:
|
||||||
|
with redirect_stdout(io.StringIO()):
|
||||||
|
code = main(["respond", "plan", "inc-nope"])
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
finally:
|
||||||
|
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
"""Tests for the read-only web dashboard: data layer + auth (real server)."""
|
"""Tests for the read-only web dashboard: data layer + auth (real server)."""
|
||||||
import json
|
import json
|
||||||
|
import ssl
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -9,6 +10,8 @@ import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from enodia_sentinel import web
|
from enodia_sentinel import web
|
||||||
|
from enodia_sentinel import incident
|
||||||
|
from enodia_sentinel.alert import Alert, Severity
|
||||||
from enodia_sentinel.config import Config
|
from enodia_sentinel.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,6 +30,11 @@ def _write_alert(tmp: Path, name: str, severity: str, sigs):
|
||||||
(tmp / f"{name}.log").write_text(f"=== ENODIA SENTINEL ALERT ===\n{severity}\n")
|
(tmp / f"{name}.log").write_text(f"=== ENODIA SENTINEL ALERT ===\n{severity}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _incident_alert(sig: str, pids=()):
|
||||||
|
return Alert(Severity.CRITICAL, sig, f"k:{sig}", f"{sig} detail",
|
||||||
|
tuple(pids), sid=100010, classtype="test")
|
||||||
|
|
||||||
|
|
||||||
class TestDataLayer(unittest.TestCase):
|
class TestDataLayer(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.dir = tempfile.TemporaryDirectory()
|
self.dir = tempfile.TemporaryDirectory()
|
||||||
|
|
@ -60,6 +68,20 @@ class TestDataLayer(unittest.TestCase):
|
||||||
(self.tmp / "events.log").write_text("l1\nl2\nl3\n")
|
(self.tmp / "events.log").write_text("l1\nl2\nl3\n")
|
||||||
self.assertEqual(web.tail_events(self.cfg, 2), ["l2", "l3"])
|
self.assertEqual(web.tail_events(self.cfg, 2), ["l2", "l3"])
|
||||||
|
|
||||||
|
def test_incident_and_response_plan_data(self):
|
||||||
|
_write_alert(self.tmp, "alert-20260531-000001", "CRITICAL", ["reverse_shell"])
|
||||||
|
iid = incident.record(self.cfg, "alert-20260531-000001.log",
|
||||||
|
[_incident_alert("reverse_shell", [4242])],
|
||||||
|
lineage={4242}, when=1000.0, host="h")
|
||||||
|
incs = web.list_incidents(self.cfg)
|
||||||
|
self.assertEqual(incs[0]["id"], iid)
|
||||||
|
inc = web.get_incident(self.cfg, iid)
|
||||||
|
self.assertEqual(inc["incident"]["id"], iid)
|
||||||
|
self.assertEqual(len(inc["timeline"]), 1)
|
||||||
|
plan = web.response_plan(self.cfg, iid)
|
||||||
|
self.assertEqual(plan["incident_id"], iid)
|
||||||
|
self.assertEqual(plan["mode"], "dry-run")
|
||||||
|
|
||||||
|
|
||||||
class TestNetworkHelpers(unittest.TestCase):
|
class TestNetworkHelpers(unittest.TestCase):
|
||||||
def test_is_loopback(self):
|
def test_is_loopback(self):
|
||||||
|
|
@ -79,6 +101,13 @@ class TestNetworkHelpers(unittest.TestCase):
|
||||||
self.assertTrue(t1)
|
self.assertTrue(t1)
|
||||||
self.assertEqual(t1, t2) # stable across calls
|
self.assertEqual(t1, t2) # stable across calls
|
||||||
|
|
||||||
|
def test_self_signed_tls_material_is_created(self):
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
c = _make_cfg(Path(d))
|
||||||
|
cert, key = web.ensure_tls_cert(c, "127.0.0.1")
|
||||||
|
self.assertTrue(cert.is_file())
|
||||||
|
self.assertTrue(key.is_file())
|
||||||
|
|
||||||
|
|
||||||
class TestAuth(unittest.TestCase):
|
class TestAuth(unittest.TestCase):
|
||||||
"""Spin up the real server on loopback and check token enforcement."""
|
"""Spin up the real server on loopback and check token enforcement."""
|
||||||
|
|
@ -92,20 +121,23 @@ class TestAuth(unittest.TestCase):
|
||||||
self.cfg.web_port = 0 # ephemeral
|
self.cfg.web_port = 0 # ephemeral
|
||||||
self.cfg.web_token = "secret-token"
|
self.cfg.web_token = "secret-token"
|
||||||
self.httpd, _bind, _tok = web.build_server(self.cfg)
|
self.httpd, _bind, _tok = web.build_server(self.cfg)
|
||||||
|
self.assertTrue(Path(self.httpd.tls_cert).is_file())
|
||||||
self.port = self.httpd.server_address[1]
|
self.port = self.httpd.server_address[1]
|
||||||
self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True)
|
||||||
self.t.start()
|
self.t.start()
|
||||||
|
self.ctx = ssl._create_unverified_context()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.httpd.shutdown()
|
self.httpd.shutdown()
|
||||||
|
self.httpd.server_close()
|
||||||
self.dir.cleanup()
|
self.dir.cleanup()
|
||||||
|
|
||||||
def _get(self, path, token=None):
|
def _get(self, path, token=None):
|
||||||
url = f"http://127.0.0.1:{self.port}{path}"
|
url = f"https://127.0.0.1:{self.port}{path}"
|
||||||
req = urllib.request.Request(url)
|
req = urllib.request.Request(url)
|
||||||
if token:
|
if token:
|
||||||
req.add_header("Authorization", f"Bearer {token}")
|
req.add_header("Authorization", f"Bearer {token}")
|
||||||
return urllib.request.urlopen(req, timeout=4)
|
return urllib.request.urlopen(req, timeout=4, context=self.ctx)
|
||||||
|
|
||||||
def test_unauthorized_without_token(self):
|
def test_unauthorized_without_token(self):
|
||||||
with self.assertRaises(urllib.error.HTTPError) as cm:
|
with self.assertRaises(urllib.error.HTTPError) as cm:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue