Add audited response apply rehearsal

This commit is contained in:
Luna 2026-07-09 05:56:25 -07:00
parent 51d52b5229
commit 0b010df514
No known key found for this signature in database
11 changed files with 198 additions and 28 deletions

View file

@ -355,6 +355,11 @@ not create artifacts. Plans also include pacman package-restore guidance
plus read-only recovery checks for package verification, rootcheck, persistence plus read-only recovery checks for package verification, rootcheck, persistence
re-checks, and off-host watchdog visibility. 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 Alert snapshots include a best-effort enrichment block that annotates flagged
processes and paths with package ownership, executable hashes, parent chains, processes and paths with package ownership, executable hashes, parent chains,
remote IP classification, file metadata, recent watched writes, and local remote IP classification, file metadata, recent watched writes, and local

View file

@ -319,6 +319,8 @@ Exit code:
```bash ```bash
enodia-sentinel respond plan <incident-id> enodia-sentinel respond plan <incident-id>
enodia-sentinel respond plan <incident-id> --json 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 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 `apply_supported: false` until an audited apply workflow exists. Dashboard/API
plan previews remain read-only and do not create new artifacts. 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: Exit code:
- `0`: plan generated. - `0`: plan generated, or apply rehearsal recorded.
- `1`: unknown incident id. - `1`: unknown incident id or saved plan reference.
- `2`: missing incident id. - `2`: missing id/reference, or `respond apply` requested without `--dry-run`.
### `status` ### `status`
@ -498,11 +506,6 @@ does not require pip or a virtualenv.
## Future Command Contracts ## 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, 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.

View file

@ -109,6 +109,12 @@ When Sentinel fires:
commands. CLI-generated plans are saved under commands. CLI-generated plans are saved under
`/var/log/enodia-sentinel/response-plans/` and indexed in `/var/log/enodia-sentinel/response-plans/` and indexed in
`/var/log/enodia-sentinel/response-audit.log` for later review. `/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 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 actions in the response plan and run the restore sequence only after the
owning package and package source are trusted: owning package and package source are trusted:

View file

@ -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 identify the owning package, verify signed package-cache metadata, reinstall
from trusted package media, re-run integrity checks, and re-anchor FIM only from trusted package media, re-run integrity checks, and re-anchor FIM only
after operator review. Debian/RPM support remains future work. after operator review. Debian/RPM support remains future work.
- Require explicit `--apply` for changes; default to read-only plans. - ✅ Add an explicit apply boundary: `respond apply <plan-ref> --dry-run`
- Extend response audit logs to any future state-changing `--apply` workflow. 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 - ✅ Add baseline reconciliation: accept legitimate FIM/package/listener/SUID
drift with a mandatory reason via `baseline accept/revoke/list`. Acks are drift with a mandatory reason via `baseline accept/revoke/list`. Acks are
fingerprint-bound (content kinds re-alert on further change), support an fingerprint-bound (content kinds re-alert on further change), support an

View file

@ -132,7 +132,8 @@ CLI-generated plans may also include `artifacts` with saved plan/audit paths.
## `enodia.response.audit.v1` ## `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: Required fields:
@ -140,7 +141,7 @@ Required fields:
|---|---|---| |---|---|---|
| `schema` | string | `enodia.response.audit.v1`. | | `schema` | string | `enodia.response.audit.v1`. |
| `time` | string | ISO-8601 audit time. | | `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. | | `actor` | string | Caller identity supplied by the CLI/workflow. |
| `plan_id` | string | Persisted plan id. | | `plan_id` | string | Persisted plan id. |
| `incident_id` | string | Source incident id. | | `incident_id` | string | Source incident id. |
@ -149,6 +150,8 @@ Required fields:
| `action_count` | integer | Number of proposed actions. | | `action_count` | integer | Number of proposed actions. |
| `plan_path` | string | Persisted plan artifact path. | | `plan_path` | string | Persisted plan artifact path. |
Apply rehearsal records also include `executed: false` and a short `reason`.
## `enodia.reconcile.v1` ## `enodia.reconcile.v1`
Acknowledged-drift store written by `baseline accept/revoke/list` Acknowledged-drift store written by `baseline accept/revoke/list`

View file

@ -109,7 +109,9 @@ When a fresh alert survives cooldown, Sentinel writes:
CLI-generated response plans are durable artifacts: `respond plan` writes the CLI-generated response plans are durable artifacts: `respond plan` writes the
reviewed dry-run plan under `response-plans/` and appends a JSONL reviewed dry-run plan under `response-plans/` and appends a JSONL
`response-audit.log` record. Dashboard/API previews remain read-only and do not `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 Current IPS scope is explicit prevention workflow, not transparent inline
enforcement. Sentinel can recommend evidence preservation, process enforcement. Sentinel can recommend evidence preservation, process

View file

@ -237,11 +237,14 @@ def main(argv: list[str] | None = None) -> int:
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", rsp = sub.add_parser("respond",
help="build dry-run response plans for incidents") help="build and rehearse response plans for incidents")
rsp.add_argument("action", nargs="?", default="plan", choices=["plan"], rsp.add_argument("action", nargs="?", default="plan", choices=["plan", "apply"],
help="response action (default: plan)") help="response action (default: plan)")
rsp.add_argument("id", nargs="?", help="incident id") rsp.add_argument("id", nargs="?",
rsp.add_argument("--json", action="store_true", help="emit plan as JSON") 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", 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")
@ -305,7 +308,7 @@ def main(argv: list[str] | None = None) -> int:
if args.cmd == "posture": if args.cmd == "posture":
return _cmd_posture(cfg, args.json) return _cmd_posture(cfg, args.json)
if args.cmd == "respond": 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": 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) not args.insecure_tls)
@ -581,14 +584,14 @@ 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: def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool,
dry_run: bool = False) -> int:
import json import json
from . import respond from . import respond
if action != "plan": if action == "apply":
print(f"error: unsupported respond action: {action}", file=sys.stderr) return _cmd_respond_apply(cfg, iid, as_json, dry_run)
return 2
if not iid: if not iid:
print("error: 'respond plan' needs an incident id " print("error: 'respond plan' needs an incident id "
"(see 'incident list')", file=sys.stderr) "(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 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: def _cmd_fim_check(cfg: Config, packages: bool) -> int:
from . import fim, reconcile from . import fim, reconcile
sentinel = Sentinel(cfg) sentinel = Sentinel(cfg)

View file

@ -13,7 +13,7 @@ RULE_ACTIONS = ("list", "show", "test", "docs")
BASELINE_ACTIONS = ("build", "accept", "revoke", "list") BASELINE_ACTIONS = ("build", "accept", "revoke", "list")
INCIDENT_ACTIONS = ("list", "show", "export") INCIDENT_ACTIONS = ("list", "show", "export")
POSTURE_ACTIONS = ("check",) POSTURE_ACTIONS = ("check",)
RESPOND_ACTIONS = ("plan",) RESPOND_ACTIONS = ("plan", "apply")
COMPLETION_SHELLS = ("bash", "zsh") COMPLETION_SHELLS = ("bash", "zsh")
@ -47,7 +47,7 @@ _{prog.replace('-', '_')}_complete() {{
baseline) COMPREPLY=( $(compgen -W "{baseline_actions} --reason --expires --force --stale --json" -- "$cur") ) ;; baseline) COMPREPLY=( $(compgen -W "{baseline_actions} --reason --expires --force --stale --json" -- "$cur") ) ;;
incident) COMPREPLY=( $(compgen -W "{incident_actions} --json" -- "$cur") ) ;; incident) COMPREPLY=( $(compgen -W "{incident_actions} --json" -- "$cur") ) ;;
posture) COMPREPLY=( $(compgen -W "{posture_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") ) ;; completion) COMPREPLY=( $(compgen -W "{shells}" -- "$cur") ) ;;
check|fim-check|status) COMPREPLY=( $(compgen -W "--json --packages" -- "$cur") ) ;; check|fim-check|status) COMPREPLY=( $(compgen -W "--json --packages" -- "$cur") ) ;;
pkgdb-verify) COMPREPLY=( $(compgen -W "--sample" -- "$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]' ;; 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]' ;; incident) _arguments '1:action:((list show export))' '--json[emit JSON]' ;;
posture) _arguments '1:action:((check))' '--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))' ;; completion) _arguments '1:shell:((bash zsh))' ;;
check|status) _arguments '--json[emit JSON]' ;; check|status) _arguments '--json[emit JSON]' ;;
fim-check) _arguments '--packages[verify package-owned files]' ;; fim-check) _arguments '--packages[verify package-owned files]' ;;

View file

@ -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)} 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: def response_audit_path(cfg: Config) -> Path:
return cfg.log_dir / "response-audit.log" return cfg.log_dir / "response-audit.log"

View file

@ -4,7 +4,7 @@ import json
import os import os
import tempfile import tempfile
import unittest import unittest
from contextlib import redirect_stdout from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path from pathlib import Path
from enodia_sentinel import incident, respond from enodia_sentinel import incident, respond
@ -155,6 +155,34 @@ class TestRespondPlan(unittest.TestCase):
finally: finally:
os.environ.pop("ENODIA_LOG_DIR", None) 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): def test_cli_requires_incident_id(self):
os.environ["ENODIA_LOG_DIR"] = str(self.tmp) os.environ["ENODIA_LOG_DIR"] = str(self.tmp)
try: try:

View file

@ -175,6 +175,8 @@ class TestTuiCore(unittest.TestCase):
self.assertEqual(code, 0) self.assertEqual(code, 0)
self.assertIn("enodia-sentinel", buf.getvalue()) self.assertIn("enodia-sentinel", buf.getvalue())
self.assertIn("tui", 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): def test_help_lists_all_commands(self):
help_lines = render_lines(TuiState(view="help"), width=100) help_lines = render_lines(TuiState(view="help"), width=100)