Persist dry-run response plans
This commit is contained in:
parent
812cf0c836
commit
1a5b6f1d8d
11 changed files with 133 additions and 5 deletions
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue