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

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

View file

@ -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]' ;;

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