Add audited response apply rehearsal
This commit is contained in:
parent
51d52b5229
commit
0b010df514
11 changed files with 198 additions and 28 deletions
|
|
@ -355,6 +355,11 @@ not create artifacts. Plans also include pacman package-restore guidance
|
|||
plus read-only recovery checks for package verification, rootcheck, persistence
|
||||
re-checks, and off-host watchdog visibility.
|
||||
|
||||
`enodia-sentinel respond apply <plan-ref> --dry-run` reloads a saved plan,
|
||||
prints the reviewed actions, and appends an audit record without executing
|
||||
commands. Apply execution remains unsupported until the state-changing workflow
|
||||
is separately designed and tested.
|
||||
|
||||
Alert snapshots include a best-effort enrichment block that annotates flagged
|
||||
processes and paths with package ownership, executable hashes, parent chains,
|
||||
remote IP classification, file metadata, recent watched writes, and local
|
||||
|
|
|
|||
|
|
@ -319,6 +319,8 @@ Exit code:
|
|||
```bash
|
||||
enodia-sentinel respond plan <incident-id>
|
||||
enodia-sentinel respond plan <incident-id> --json
|
||||
enodia-sentinel respond apply <plan-path-or-plan-id> --dry-run
|
||||
enodia-sentinel respond apply <plan-path-or-plan-id> --dry-run --json
|
||||
```
|
||||
|
||||
Builds a dry-run response plan from an incident's retained JSON snapshots. The
|
||||
|
|
@ -341,11 +343,17 @@ saved path. The JSON schema is `enodia.response.plan.v1` and marks
|
|||
`apply_supported: false` until an audited apply workflow exists. Dashboard/API
|
||||
plan previews remain read-only and do not create new artifacts.
|
||||
|
||||
`respond apply` currently supports only `--dry-run`. It reloads a saved plan by
|
||||
path, filename, or `plan_id`, prints the exact reviewed actions, and appends a
|
||||
`response_apply_rehearsed` audit record with `executed: false`. Running
|
||||
`respond apply` without `--dry-run` exits with an error; Sentinel still does not
|
||||
execute containment commands.
|
||||
|
||||
Exit code:
|
||||
|
||||
- `0`: plan generated.
|
||||
- `1`: unknown incident id.
|
||||
- `2`: missing incident id.
|
||||
- `0`: plan generated, or apply rehearsal recorded.
|
||||
- `1`: unknown incident id or saved plan reference.
|
||||
- `2`: missing id/reference, or `respond apply` requested without `--dry-run`.
|
||||
|
||||
### `status`
|
||||
|
||||
|
|
@ -498,11 +506,6 @@ does not require pip or a virtualenv.
|
|||
|
||||
## Future Command Contracts
|
||||
|
||||
The roadmap reserves these command shapes:
|
||||
|
||||
```bash
|
||||
enodia-sentinel respond apply <plan-id>
|
||||
```
|
||||
|
||||
State-changing response commands should be introduced with stable JSON schemas,
|
||||
audit logs, and tests before they are documented as supported.
|
||||
audit logs, and tests before they are documented as supported. The current
|
||||
`respond apply <plan-ref> --dry-run` command is only an audited rehearsal path.
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ When Sentinel fires:
|
|||
commands. CLI-generated plans are saved under
|
||||
`/var/log/enodia-sentinel/response-plans/` and indexed in
|
||||
`/var/log/enodia-sentinel/response-audit.log` for later review.
|
||||
To rehearse a saved plan and record that review without executing anything:
|
||||
|
||||
```bash
|
||||
enodia-sentinel respond apply <plan-path-or-plan-id> --dry-run
|
||||
```
|
||||
|
||||
5. If the alert involves a package-owned binary, review the package recovery
|
||||
actions in the response plan and run the restore sequence only after the
|
||||
owning package and package source are trusted:
|
||||
|
|
|
|||
|
|
@ -71,8 +71,13 @@ Purpose: move from "tell me" to "help me act" without unsafe automation.
|
|||
identify the owning package, verify signed package-cache metadata, reinstall
|
||||
from trusted package media, re-run integrity checks, and re-anchor FIM only
|
||||
after operator review. Debian/RPM support remains future work.
|
||||
- Require explicit `--apply` for changes; default to read-only plans.
|
||||
- Extend response audit logs to any future state-changing `--apply` workflow.
|
||||
- ✅ Add an explicit apply boundary: `respond apply <plan-ref> --dry-run`
|
||||
reloads a saved reviewed plan, prints the exact actions, and appends a
|
||||
`response_apply_rehearsed` audit record without executing commands.
|
||||
- Require a future explicit execution flag for state changes; default to
|
||||
read-only plans and audited apply rehearsals.
|
||||
- ✅ Extend response audit logs to the apply boundary with `executed: false`
|
||||
records. Future state-changing execution must append the same audit trail.
|
||||
- ✅ Add baseline reconciliation: accept legitimate FIM/package/listener/SUID
|
||||
drift with a mandatory reason via `baseline accept/revoke/list`. Acks are
|
||||
fingerprint-bound (content kinds re-alert on further change), support an
|
||||
|
|
|
|||
|
|
@ -132,7 +132,8 @@ CLI-generated plans may also include `artifacts` with saved plan/audit paths.
|
|||
|
||||
## `enodia.response.audit.v1`
|
||||
|
||||
JSONL record appended when the CLI persists a response plan.
|
||||
JSONL record appended when the CLI persists a response plan or rehearses a
|
||||
saved plan with `respond apply --dry-run`.
|
||||
|
||||
Required fields:
|
||||
|
||||
|
|
@ -140,7 +141,7 @@ Required fields:
|
|||
|---|---|---|
|
||||
| `schema` | string | `enodia.response.audit.v1`. |
|
||||
| `time` | string | ISO-8601 audit time. |
|
||||
| `event` | string | Currently `response_plan_generated`. |
|
||||
| `event` | string | `response_plan_generated` or `response_apply_rehearsed`. |
|
||||
| `actor` | string | Caller identity supplied by the CLI/workflow. |
|
||||
| `plan_id` | string | Persisted plan id. |
|
||||
| `incident_id` | string | Source incident id. |
|
||||
|
|
@ -149,6 +150,8 @@ Required fields:
|
|||
| `action_count` | integer | Number of proposed actions. |
|
||||
| `plan_path` | string | Persisted plan artifact path. |
|
||||
|
||||
Apply rehearsal records also include `executed: false` and a short `reason`.
|
||||
|
||||
## `enodia.reconcile.v1`
|
||||
|
||||
Acknowledged-drift store written by `baseline accept/revoke/list`
|
||||
|
|
|
|||
|
|
@ -109,7 +109,9 @@ When a fresh alert survives cooldown, Sentinel writes:
|
|||
CLI-generated response plans are durable artifacts: `respond plan` writes the
|
||||
reviewed dry-run plan under `response-plans/` and appends a JSONL
|
||||
`response-audit.log` record. Dashboard/API previews remain read-only and do not
|
||||
create artifacts.
|
||||
create artifacts. `respond apply <plan-ref> --dry-run` reloads a saved plan and
|
||||
appends an apply-rehearsal audit record with `executed: false`; it does not run
|
||||
commands.
|
||||
|
||||
Current IPS scope is explicit prevention workflow, not transparent inline
|
||||
enforcement. Sentinel can recommend evidence preservation, process
|
||||
|
|
|
|||
|
|
@ -237,11 +237,14 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="posture action (default: check)")
|
||||
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="build and rehearse response plans for incidents")
|
||||
rsp.add_argument("action", nargs="?", default="plan", choices=["plan", "apply"],
|
||||
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")
|
||||
rsp.add_argument("id", nargs="?",
|
||||
help="incident id for plan, or saved plan path/id for apply")
|
||||
rsp.add_argument("--json", action="store_true", help="emit JSON")
|
||||
rsp.add_argument("--dry-run", action="store_true",
|
||||
help="for respond apply, audit and print actions without executing")
|
||||
wd = sub.add_parser("watchdog",
|
||||
help="poll a remote dashboard and push if Sentinel is silent")
|
||||
wd.add_argument("--url", required=True, help="dashboard base URL")
|
||||
|
|
@ -305,7 +308,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if args.cmd == "posture":
|
||||
return _cmd_posture(cfg, args.json)
|
||||
if args.cmd == "respond":
|
||||
return _cmd_respond(cfg, args.action, args.id, args.json)
|
||||
return _cmd_respond(cfg, args.action, args.id, args.json, args.dry_run)
|
||||
if args.cmd == "watchdog":
|
||||
return _cmd_watchdog(cfg, args.url, args.token, args.max_age,
|
||||
not args.insecure_tls)
|
||||
|
|
@ -581,14 +584,14 @@ def _cmd_posture(cfg: Config, as_json: bool) -> int:
|
|||
return 1
|
||||
|
||||
|
||||
def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> int:
|
||||
def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool,
|
||||
dry_run: bool = False) -> int:
|
||||
import json
|
||||
|
||||
from . import respond
|
||||
|
||||
if action != "plan":
|
||||
print(f"error: unsupported respond action: {action}", file=sys.stderr)
|
||||
return 2
|
||||
if action == "apply":
|
||||
return _cmd_respond_apply(cfg, iid, as_json, dry_run)
|
||||
if not iid:
|
||||
print("error: 'respond plan' needs an incident id "
|
||||
"(see 'incident list')", file=sys.stderr)
|
||||
|
|
@ -622,6 +625,56 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in
|
|||
return 0
|
||||
|
||||
|
||||
def _cmd_respond_apply(cfg: Config, ref: str | None, as_json: bool,
|
||||
dry_run: bool) -> int:
|
||||
import json
|
||||
|
||||
from . import respond
|
||||
|
||||
if not ref:
|
||||
print("error: 'respond apply' needs a saved plan path or plan id",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
if not dry_run:
|
||||
print("error: response apply execution is not implemented; rerun with "
|
||||
"--dry-run to rehearse and audit the reviewed plan",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
plan, plan_path = respond.load_persisted_plan(cfg, ref)
|
||||
if plan is None or plan_path is None:
|
||||
print(f"error: no such response plan: {ref}", file=sys.stderr)
|
||||
return 1
|
||||
result = respond.rehearse_apply(cfg, plan, plan_path)
|
||||
output = {
|
||||
"schema": schemas.RESPONSE_AUDIT_V1,
|
||||
"plan": {
|
||||
"plan_id": plan.get("plan_id"),
|
||||
"incident_id": plan.get("incident_id"),
|
||||
"plan_path": str(plan_path),
|
||||
"action_count": len(plan.get("actions", [])),
|
||||
},
|
||||
"audit": result["record"],
|
||||
"audit_log": result["audit_log"],
|
||||
"executed": False,
|
||||
}
|
||||
if as_json:
|
||||
print(json.dumps(output, indent=2))
|
||||
return 0
|
||||
|
||||
print(f"Response apply rehearsal {plan.get('plan_id', ref)}")
|
||||
print(f" incident: {plan.get('incident_id', '?')}")
|
||||
print(f" plan: {plan_path}")
|
||||
print(f" audit: {result['audit_log']}")
|
||||
print(" executed: no")
|
||||
print(" actions:")
|
||||
for action in plan.get("actions", []):
|
||||
cmd = " ".join(action.get("command", []))
|
||||
print(f" {action.get('id', '?')} [{action.get('risk', '?')}] "
|
||||
f"{action.get('title', '')}")
|
||||
print(f" {cmd}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_fim_check(cfg: Config, packages: bool) -> int:
|
||||
from . import fim, reconcile
|
||||
sentinel = Sentinel(cfg)
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ RULE_ACTIONS = ("list", "show", "test", "docs")
|
|||
BASELINE_ACTIONS = ("build", "accept", "revoke", "list")
|
||||
INCIDENT_ACTIONS = ("list", "show", "export")
|
||||
POSTURE_ACTIONS = ("check",)
|
||||
RESPOND_ACTIONS = ("plan",)
|
||||
RESPOND_ACTIONS = ("plan", "apply")
|
||||
COMPLETION_SHELLS = ("bash", "zsh")
|
||||
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ _{prog.replace('-', '_')}_complete() {{
|
|||
baseline) COMPREPLY=( $(compgen -W "{baseline_actions} --reason --expires --force --stale --json" -- "$cur") ) ;;
|
||||
incident) COMPREPLY=( $(compgen -W "{incident_actions} --json" -- "$cur") ) ;;
|
||||
posture) COMPREPLY=( $(compgen -W "{posture_actions} --json" -- "$cur") ) ;;
|
||||
respond) COMPREPLY=( $(compgen -W "{respond_actions} --json" -- "$cur") ) ;;
|
||||
respond) COMPREPLY=( $(compgen -W "{respond_actions} --json --dry-run" -- "$cur") ) ;;
|
||||
completion) COMPREPLY=( $(compgen -W "{shells}" -- "$cur") ) ;;
|
||||
check|fim-check|status) COMPREPLY=( $(compgen -W "--json --packages" -- "$cur") ) ;;
|
||||
pkgdb-verify) COMPREPLY=( $(compgen -W "--sample" -- "$cur") ) ;;
|
||||
|
|
@ -82,7 +82,7 @@ case $state in
|
|||
baseline) _arguments '1:action:((build accept revoke list))' '2:kind:((fim pkgfile listener suid))' '--reason[reason]' '--expires[TTL]' '--force[force]' '--stale[stale]' '--json[emit JSON]' ;;
|
||||
incident) _arguments '1:action:((list show export))' '--json[emit JSON]' ;;
|
||||
posture) _arguments '1:action:((check))' '--json[emit JSON]' ;;
|
||||
respond) _arguments '1:action:((plan))' '--json[emit JSON]' ;;
|
||||
respond) _arguments '1:action:((plan apply))' '--json[emit JSON]' '--dry-run[rehearse apply without executing]' ;;
|
||||
completion) _arguments '1:shell:((bash zsh))' ;;
|
||||
check|status) _arguments '--json[emit JSON]' ;;
|
||||
fim-check) _arguments '--packages[verify package-owned files]' ;;
|
||||
|
|
|
|||
|
|
@ -312,6 +312,69 @@ def persist_plan(cfg: Config, plan: dict, actor: str = "cli") -> dict[str, str]:
|
|||
return {"plan_path": str(plan_path), "audit_log": str(audit_path)}
|
||||
|
||||
|
||||
def load_persisted_plan(cfg: Config, ref: str) -> tuple[dict, Path] | tuple[None, None]:
|
||||
"""Load a persisted response plan by path, filename, or plan_id.
|
||||
|
||||
``respond apply`` deliberately consumes a saved artifact instead of a freshly
|
||||
generated preview, so the operator is applying exactly what was reviewed.
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
raw = Path(ref)
|
||||
if raw.is_file():
|
||||
candidates.append(raw)
|
||||
plan_dir = cfg.log_dir / "response-plans"
|
||||
if not raw.is_absolute():
|
||||
candidates.extend([
|
||||
plan_dir / ref,
|
||||
plan_dir / f"{ref}.json",
|
||||
])
|
||||
try:
|
||||
candidates.extend(sorted(plan_dir.glob("*.json"), reverse=True))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
seen = set()
|
||||
for path in candidates:
|
||||
if path in seen or not path.is_file():
|
||||
continue
|
||||
seen.add(path)
|
||||
try:
|
||||
plan = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if path == raw or path.name == ref or plan.get("plan_id") == ref:
|
||||
return plan, path
|
||||
return None, None
|
||||
|
||||
|
||||
def rehearse_apply(cfg: Config, plan: dict, plan_path: Path,
|
||||
actor: str = "cli") -> dict:
|
||||
"""Audit an explicit apply rehearsal without executing plan commands."""
|
||||
record = {
|
||||
"schema": schemas.RESPONSE_AUDIT_V1,
|
||||
"time": datetime.now().astimezone().isoformat(),
|
||||
"event": "response_apply_rehearsed",
|
||||
"actor": actor,
|
||||
"plan_id": plan.get("plan_id"),
|
||||
"incident_id": plan.get("incident_id"),
|
||||
"mode": plan.get("mode"),
|
||||
"apply_supported": plan.get("apply_supported", False),
|
||||
"action_count": len(plan.get("actions", [])),
|
||||
"plan_path": str(plan_path),
|
||||
"executed": False,
|
||||
"reason": "dry-run rehearsal; no commands executed",
|
||||
}
|
||||
audit_path = response_audit_path(cfg)
|
||||
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
with audit_path.open("a") as fh:
|
||||
fh.write(json.dumps(record, sort_keys=True) + "\n")
|
||||
try:
|
||||
audit_path.chmod(0o640)
|
||||
except OSError:
|
||||
pass
|
||||
return {"audit_log": str(audit_path), "record": record}
|
||||
|
||||
|
||||
def response_audit_path(cfg: Config) -> Path:
|
||||
return cfg.log_dir / "response-audit.log"
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import json
|
|||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
from enodia_sentinel import incident, respond
|
||||
|
|
@ -155,6 +155,34 @@ class TestRespondPlan(unittest.TestCase):
|
|||
finally:
|
||||
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||
|
||||
def test_apply_requires_dry_run_and_audits_rehearsal(self):
|
||||
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
||||
try:
|
||||
with redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(main(["respond", "plan", self.iid]), 0)
|
||||
plan_path = next((self.tmp / "response-plans").glob("*.json"))
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
code = main(["respond", "apply", str(plan_path)])
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("not implemented", err.getvalue())
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
code = main(["respond", "apply", str(plan_path),
|
||||
"--dry-run", "--json"])
|
||||
self.assertEqual(code, 0)
|
||||
output = json.loads(buf.getvalue())
|
||||
self.assertFalse(output["executed"])
|
||||
self.assertEqual(output["audit"]["event"], "response_apply_rehearsed")
|
||||
self.assertEqual(output["plan"]["plan_path"], str(plan_path))
|
||||
audit = (self.tmp / "response-audit.log").read_text().splitlines()
|
||||
self.assertEqual(json.loads(audit[-1])["event"],
|
||||
"response_apply_rehearsed")
|
||||
finally:
|
||||
os.environ.pop("ENODIA_LOG_DIR", None)
|
||||
|
||||
def test_cli_requires_incident_id(self):
|
||||
os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -175,6 +175,8 @@ class TestTuiCore(unittest.TestCase):
|
|||
self.assertEqual(code, 0)
|
||||
self.assertIn("enodia-sentinel", buf.getvalue())
|
||||
self.assertIn("tui", buf.getvalue())
|
||||
self.assertIn("apply", buf.getvalue())
|
||||
self.assertIn("--dry-run", buf.getvalue())
|
||||
|
||||
def test_help_lists_all_commands(self):
|
||||
help_lines = render_lines(TuiState(view="help"), width=100)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue