Skip to main content

zeroclaw_runtime/security/
playbook.rs

1//! Incident response playbook definitions and execution engine.
2//!
3//! Playbooks define structured response procedures for security incidents.
4//! Each playbook has named steps, some of which require human approval before
5//! execution. Playbooks are loaded from JSON files in the configured directory.
6
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// A single step in an incident response playbook.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct PlaybookStep {
13    /// Machine-readable action identifier (e.g. "isolate_host", "block_ip").
14    pub action: String,
15    /// Human-readable description of what this step does.
16    pub description: String,
17    /// Whether this step requires explicit human approval before execution.
18    #[serde(default)]
19    pub requires_approval: bool,
20    /// Timeout in seconds for this step. Default: 300 (5 minutes).
21    #[serde(default = "default_timeout_secs")]
22    pub timeout_secs: u64,
23}
24
25fn default_timeout_secs() -> u64 {
26    300
27}
28
29/// An incident response playbook.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Playbook {
32    /// Unique playbook name (e.g. "suspicious_login").
33    pub name: String,
34    /// Human-readable description.
35    pub description: String,
36    /// Ordered list of response steps.
37    pub steps: Vec<PlaybookStep>,
38    /// Minimum alert severity that triggers this playbook (low/medium/high/critical).
39    #[serde(default = "default_severity_filter")]
40    pub severity_filter: String,
41    /// Step indices (0-based) that can be auto-approved when below max_auto_severity.
42    #[serde(default)]
43    pub auto_approve_steps: Vec<usize>,
44}
45
46fn default_severity_filter() -> String {
47    "medium".into()
48}
49
50/// Result of executing a single playbook step.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct StepExecutionResult {
53    pub step_index: usize,
54    pub action: String,
55    pub status: StepStatus,
56    pub message: String,
57}
58
59/// Status of a playbook step.
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub enum StepStatus {
62    /// Step completed successfully.
63    Completed,
64    /// Step is waiting for human approval.
65    PendingApproval,
66    /// Step was skipped (e.g. not applicable).
67    Skipped,
68    /// Step failed with an error.
69    Failed,
70}
71
72impl std::fmt::Display for StepStatus {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Self::Completed => write!(f, "completed"),
76            Self::PendingApproval => write!(f, "pending_approval"),
77            Self::Skipped => write!(f, "skipped"),
78            Self::Failed => write!(f, "failed"),
79        }
80    }
81}
82
83/// Load all playbook definitions from a directory of JSON files.
84pub fn load_playbooks(dir: &Path) -> Vec<Playbook> {
85    let mut playbooks = Vec::new();
86
87    if !dir.exists() || !dir.is_dir() {
88        return builtin_playbooks();
89    }
90
91    if let Ok(entries) = std::fs::read_dir(dir) {
92        for entry in entries.flatten() {
93            let path = entry.path();
94            if path.extension().is_some_and(|ext| ext == "json") {
95                match std::fs::read_to_string(&path) {
96                    Ok(contents) => match serde_json::from_str::<Playbook>(&contents) {
97                        Ok(pb) => playbooks.push(pb),
98                        Err(e) => {
99                            ::zeroclaw_log::record!(
100                                WARN,
101                                ::zeroclaw_log::Event::new(
102                                    module_path!(),
103                                    ::zeroclaw_log::Action::Note
104                                )
105                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
106                                .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
107                                &format!("Failed to parse playbook {}", path.display().to_string())
108                            );
109                        }
110                    },
111                    Err(e) => {
112                        ::zeroclaw_log::record!(
113                            WARN,
114                            ::zeroclaw_log::Event::new(
115                                module_path!(),
116                                ::zeroclaw_log::Action::Note
117                            )
118                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
119                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
120                            &format!("Failed to read playbook {}", path.display().to_string())
121                        );
122                    }
123                }
124            }
125        }
126    }
127
128    // Merge built-in playbooks that aren't overridden by user-defined ones
129    for builtin in builtin_playbooks() {
130        if !playbooks.iter().any(|p| p.name == builtin.name) {
131            playbooks.push(builtin);
132        }
133    }
134
135    playbooks
136}
137
138/// Severity ordering for comparison: low < medium < high < critical.
139pub fn severity_level(severity: &str) -> u8 {
140    match severity.to_lowercase().as_str() {
141        "low" => 1,
142        "medium" => 2,
143        "high" => 3,
144        "critical" => 4,
145        // Deny-by-default: unknown severities get the highest level to prevent
146        // auto-approval of unrecognized severity labels.
147        _ => u8::MAX,
148    }
149}
150
151/// Check whether a step can be auto-approved given config constraints.
152pub fn can_auto_approve(
153    playbook: &Playbook,
154    step_index: usize,
155    alert_severity: &str,
156    max_auto_severity: &str,
157) -> bool {
158    // Never auto-approve if alert severity exceeds the configured max
159    if severity_level(alert_severity) > severity_level(max_auto_severity) {
160        return false;
161    }
162
163    // Only auto-approve steps explicitly listed in auto_approve_steps
164    playbook.auto_approve_steps.contains(&step_index)
165}
166
167/// Evaluate a playbook step. Returns the result with approval gating.
168///
169/// Steps that require approval and cannot be auto-approved will return
170/// `StepStatus::PendingApproval` without executing.
171pub fn evaluate_step(
172    playbook: &Playbook,
173    step_index: usize,
174    alert_severity: &str,
175    max_auto_severity: &str,
176    require_approval: bool,
177) -> StepExecutionResult {
178    let step = match playbook.steps.get(step_index) {
179        Some(s) => s,
180        None => {
181            return StepExecutionResult {
182                step_index,
183                action: "unknown".into(),
184                status: StepStatus::Failed,
185                message: format!("Step index {step_index} out of range"),
186            };
187        }
188    };
189
190    // Enforce approval gates: steps that require approval must either be
191    // auto-approved or wait for human approval. Never mark an unexecuted
192    // approval-gated step as Completed.
193    if step.requires_approval
194        && (!require_approval
195            || !can_auto_approve(playbook, step_index, alert_severity, max_auto_severity))
196    {
197        return StepExecutionResult {
198            step_index,
199            action: step.action.clone(),
200            status: StepStatus::PendingApproval,
201            message: format!(
202                "Step '{}' requires human approval (severity: {alert_severity})",
203                step.description
204            ),
205        };
206    }
207
208    // Step is approved (either doesn't require approval, or was auto-approved)
209    // Actual execution would be delegated to the appropriate tool/system
210    StepExecutionResult {
211        step_index,
212        action: step.action.clone(),
213        status: StepStatus::Completed,
214        message: format!("Executed: {}", step.description),
215    }
216}
217
218/// Built-in playbook definitions for common incident types.
219pub fn builtin_playbooks() -> Vec<Playbook> {
220    vec![
221        Playbook {
222            name: "suspicious_login".into(),
223            description: "Respond to suspicious login activity detected by SIEM".into(),
224            steps: vec![
225                PlaybookStep {
226                    action: "gather_login_context".into(),
227                    description: "Collect login metadata: IP, geo, device fingerprint, time".into(),
228                    requires_approval: false,
229                    timeout_secs: 60,
230                },
231                PlaybookStep {
232                    action: "check_threat_intel".into(),
233                    description: "Query threat intelligence for source IP reputation".into(),
234                    requires_approval: false,
235                    timeout_secs: 30,
236                },
237                PlaybookStep {
238                    action: "notify_user".into(),
239                    description: "Send verification notification to account owner".into(),
240                    requires_approval: true,
241                    timeout_secs: 300,
242                },
243                PlaybookStep {
244                    action: "force_password_reset".into(),
245                    description: "Force password reset if login confirmed unauthorized".into(),
246                    requires_approval: true,
247                    timeout_secs: 120,
248                },
249            ],
250            severity_filter: "medium".into(),
251            auto_approve_steps: vec![0, 1],
252        },
253        Playbook {
254            name: "malware_detected".into(),
255            description: "Respond to malware detection on endpoint".into(),
256            steps: vec![
257                PlaybookStep {
258                    action: "isolate_endpoint".into(),
259                    description: "Network-isolate the affected endpoint".into(),
260                    requires_approval: true,
261                    timeout_secs: 60,
262                },
263                PlaybookStep {
264                    action: "collect_forensics".into(),
265                    description: "Capture memory dump and disk image for analysis".into(),
266                    requires_approval: false,
267                    timeout_secs: 600,
268                },
269                PlaybookStep {
270                    action: "scan_lateral_movement".into(),
271                    description: "Check for lateral movement indicators on adjacent hosts".into(),
272                    requires_approval: false,
273                    timeout_secs: 300,
274                },
275                PlaybookStep {
276                    action: "remediate_endpoint".into(),
277                    description: "Remove malware and restore endpoint to clean state".into(),
278                    requires_approval: true,
279                    timeout_secs: 600,
280                },
281            ],
282            severity_filter: "high".into(),
283            auto_approve_steps: vec![1, 2],
284        },
285        Playbook {
286            name: "data_exfiltration_attempt".into(),
287            description: "Respond to suspected data exfiltration".into(),
288            steps: vec![
289                PlaybookStep {
290                    action: "block_egress".into(),
291                    description: "Block suspicious outbound connections".into(),
292                    requires_approval: true,
293                    timeout_secs: 30,
294                },
295                PlaybookStep {
296                    action: "identify_data_scope".into(),
297                    description: "Determine what data may have been accessed or transferred".into(),
298                    requires_approval: false,
299                    timeout_secs: 300,
300                },
301                PlaybookStep {
302                    action: "preserve_evidence".into(),
303                    description: "Preserve network logs and access records".into(),
304                    requires_approval: false,
305                    timeout_secs: 120,
306                },
307                PlaybookStep {
308                    action: "escalate_to_legal".into(),
309                    description: "Notify legal and compliance teams".into(),
310                    requires_approval: true,
311                    timeout_secs: 60,
312                },
313            ],
314            severity_filter: "critical".into(),
315            auto_approve_steps: vec![1, 2],
316        },
317        Playbook {
318            name: "brute_force".into(),
319            description: "Respond to brute force authentication attempts".into(),
320            steps: vec![
321                PlaybookStep {
322                    action: "block_source_ip".into(),
323                    description: "Block the attacking source IP at firewall".into(),
324                    requires_approval: true,
325                    timeout_secs: 30,
326                },
327                PlaybookStep {
328                    action: "check_compromised_accounts".into(),
329                    description: "Check if any accounts were successfully compromised".into(),
330                    requires_approval: false,
331                    timeout_secs: 120,
332                },
333                PlaybookStep {
334                    action: "enable_rate_limiting".into(),
335                    description: "Enable enhanced rate limiting on auth endpoints".into(),
336                    requires_approval: true,
337                    timeout_secs: 60,
338                },
339            ],
340            severity_filter: "medium".into(),
341            auto_approve_steps: vec![1],
342        },
343    ]
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn builtin_playbooks_are_valid() {
352        let playbooks = builtin_playbooks();
353        assert_eq!(playbooks.len(), 4);
354
355        let names: Vec<&str> = playbooks.iter().map(|p| p.name.as_str()).collect();
356        assert!(names.contains(&"suspicious_login"));
357        assert!(names.contains(&"malware_detected"));
358        assert!(names.contains(&"data_exfiltration_attempt"));
359        assert!(names.contains(&"brute_force"));
360
361        for pb in &playbooks {
362            assert!(!pb.steps.is_empty(), "Playbook {} has no steps", pb.name);
363            assert!(!pb.description.is_empty());
364        }
365    }
366
367    #[test]
368    fn severity_level_ordering() {
369        assert!(severity_level("low") < severity_level("medium"));
370        assert!(severity_level("medium") < severity_level("high"));
371        assert!(severity_level("high") < severity_level("critical"));
372        assert_eq!(severity_level("unknown"), u8::MAX);
373    }
374
375    #[test]
376    fn auto_approve_respects_severity_cap() {
377        let pb = &builtin_playbooks()[0]; // suspicious_login
378
379        // Step 0 is in auto_approve_steps
380        assert!(can_auto_approve(pb, 0, "low", "low"));
381        assert!(can_auto_approve(pb, 0, "low", "medium"));
382
383        // Alert severity exceeds max -> cannot auto-approve
384        assert!(!can_auto_approve(pb, 0, "high", "low"));
385        assert!(!can_auto_approve(pb, 0, "critical", "medium"));
386
387        // Step 2 is NOT in auto_approve_steps
388        assert!(!can_auto_approve(pb, 2, "low", "critical"));
389    }
390
391    #[test]
392    fn evaluate_step_requires_approval() {
393        let pb = &builtin_playbooks()[0]; // suspicious_login
394
395        // Step 2 (notify_user) requires approval, high severity, max=low -> pending
396        let result = evaluate_step(pb, 2, "high", "low", true);
397        assert_eq!(result.status, StepStatus::PendingApproval);
398        assert_eq!(result.action, "notify_user");
399
400        // Step 0 (gather_login_context) does NOT require approval -> completed
401        let result = evaluate_step(pb, 0, "high", "low", true);
402        assert_eq!(result.status, StepStatus::Completed);
403    }
404
405    #[test]
406    fn evaluate_step_out_of_range() {
407        let pb = &builtin_playbooks()[0];
408        let result = evaluate_step(pb, 99, "low", "low", true);
409        assert_eq!(result.status, StepStatus::Failed);
410    }
411
412    #[test]
413    fn playbook_json_roundtrip() {
414        let pb = &builtin_playbooks()[0];
415        let json = serde_json::to_string(pb).unwrap();
416        let parsed: Playbook = serde_json::from_str(&json).unwrap();
417        assert_eq!(parsed, *pb);
418    }
419
420    #[test]
421    fn load_playbooks_from_nonexistent_dir_returns_builtins() {
422        let playbooks = load_playbooks(Path::new("/nonexistent/dir"));
423        assert_eq!(playbooks.len(), 4);
424    }
425
426    #[test]
427    fn load_playbooks_merges_custom_and_builtin() {
428        let dir = tempfile::tempdir().unwrap();
429        let custom = Playbook {
430            name: "custom_playbook".into(),
431            description: "A custom playbook".into(),
432            steps: vec![PlaybookStep {
433                action: "custom_action".into(),
434                description: "Do something custom".into(),
435                requires_approval: true,
436                timeout_secs: 60,
437            }],
438            severity_filter: "low".into(),
439            auto_approve_steps: vec![],
440        };
441        let json = serde_json::to_string(&custom).unwrap();
442        std::fs::write(dir.path().join("custom.json"), json).unwrap();
443
444        let playbooks = load_playbooks(dir.path());
445        // 4 builtins + 1 custom
446        assert_eq!(playbooks.len(), 5);
447        assert!(playbooks.iter().any(|p| p.name == "custom_playbook"));
448    }
449
450    #[test]
451    fn load_playbooks_custom_overrides_builtin() {
452        let dir = tempfile::tempdir().unwrap();
453        let override_pb = Playbook {
454            name: "suspicious_login".into(),
455            description: "Custom override".into(),
456            steps: vec![PlaybookStep {
457                action: "custom_step".into(),
458                description: "Overridden step".into(),
459                requires_approval: false,
460                timeout_secs: 30,
461            }],
462            severity_filter: "low".into(),
463            auto_approve_steps: vec![0],
464        };
465        let json = serde_json::to_string(&override_pb).unwrap();
466        std::fs::write(dir.path().join("suspicious_login.json"), json).unwrap();
467
468        let playbooks = load_playbooks(dir.path());
469        // 3 remaining builtins + 1 overridden = 4
470        assert_eq!(playbooks.len(), 4);
471        let sl = playbooks
472            .iter()
473            .find(|p| p.name == "suspicious_login")
474            .unwrap();
475        assert_eq!(sl.description, "Custom override");
476    }
477}