Persist dry-run response plans

This commit is contained in:
Luna 2026-06-13 05:53:48 -07:00
parent 812cf0c836
commit 1a5b6f1d8d
11 changed files with 133 additions and 5 deletions

View file

@ -359,6 +359,9 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in
print(f"error: no such incident: {iid}", file=sys.stderr)
return 1
plan = respond.build_plan(bundle, cfg)
artifacts = respond.persist_plan(cfg, plan)
plan = dict(plan)
plan["artifacts"] = artifacts
if as_json:
print(json.dumps(plan, indent=2))
return 0
@ -369,6 +372,8 @@ def _cmd_respond(cfg: Config, action: str, iid: str | None, as_json: bool) -> in
print(f" mode: {plan['mode']} (no commands executed)")
print(f" signatures: {', '.join(summary['signatures']) or ''}")
print(f" snapshots: {summary['snapshot_count']}")
print(f" saved: {artifacts['plan_path']}")
print(f" audit: {artifacts['audit_log']}")
print(" actions:")
for a in plan["actions"]:
cmd = " ".join(a["command"])

View file

@ -8,6 +8,7 @@ an explicit plan an operator can review. No action is executed here. Future
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
@ -209,6 +210,62 @@ def build_plan(bundle: dict, cfg: Config) -> dict:
}
def persist_plan(cfg: Config, plan: dict, actor: str = "cli") -> dict[str, str]:
"""Persist a generated dry-run plan and append an audit record.
This is intentionally separate from ``build_plan`` so dashboard/API reads can
render plans without creating new artifacts. The persisted file is the
operator-reviewed command proposal; it is not evidence that actions ran.
"""
cfg.log_dir.mkdir(parents=True, exist_ok=True)
plan_dir = cfg.log_dir / "response-plans"
plan_dir.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().astimezone().strftime("%Y%m%d-%H%M%S-%f")
incident_id = _safe_name(str(plan.get("incident_id", "unknown")))
plan_id = _safe_name(str(plan.get("plan_id", f"plan-{incident_id}")))
plan_path = plan_dir / f"{stamp}-{plan_id}.json"
stored = dict(plan)
artifacts = dict(stored.get("artifacts", {}))
artifacts["plan_path"] = str(plan_path)
artifacts["audit_log"] = str(response_audit_path(cfg))
stored["artifacts"] = artifacts
tmp = plan_path.with_suffix(".json.tmp")
tmp.write_text(json.dumps(stored, indent=2) + "\n")
os.replace(tmp, plan_path)
try:
plan_path.chmod(0o640)
except OSError:
pass
audit_path = response_audit_path(cfg)
record = {
"time": datetime.now().astimezone().isoformat(),
"event": "response_plan_generated",
"actor": actor,
"plan_id": stored.get("plan_id"),
"incident_id": stored.get("incident_id"),
"mode": stored.get("mode"),
"apply_supported": stored.get("apply_supported"),
"action_count": stored.get("summary", {}).get("action_count", 0),
"plan_path": str(plan_path),
}
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 {"plan_path": str(plan_path), "audit_log": str(audit_path)}
def response_audit_path(cfg: Config) -> Path:
return cfg.log_dir / "response-audit.log"
def _alerts(snapshots: list[dict]) -> list[dict]:
return [a for snap in snapshots for a in snap.get("alerts", [])]
@ -259,6 +316,13 @@ def _systemd_units(alerts: list[dict]) -> set[str]:
return units
_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9_.-]+")
def _safe_name(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value).strip(".-") or "unknown"
def _is_quarantine_candidate(path: str, sig: str) -> bool:
if sig == "new_suid":
return True