1use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct PlaybookStep {
13 pub action: String,
15 pub description: String,
17 #[serde(default)]
19 pub requires_approval: bool,
20 #[serde(default = "default_timeout_secs")]
22 pub timeout_secs: u64,
23}
24
25fn default_timeout_secs() -> u64 {
26 300
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Playbook {
32 pub name: String,
34 pub description: String,
36 pub steps: Vec<PlaybookStep>,
38 #[serde(default = "default_severity_filter")]
40 pub severity_filter: String,
41 #[serde(default)]
43 pub auto_approve_steps: Vec<usize>,
44}
45
46fn default_severity_filter() -> String {
47 "medium".into()
48}
49
50#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
61pub enum StepStatus {
62 Completed,
64 PendingApproval,
66 Skipped,
68 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
83pub 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 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
138pub 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 _ => u8::MAX,
148 }
149}
150
151pub fn can_auto_approve(
153 playbook: &Playbook,
154 step_index: usize,
155 alert_severity: &str,
156 max_auto_severity: &str,
157) -> bool {
158 if severity_level(alert_severity) > severity_level(max_auto_severity) {
160 return false;
161 }
162
163 playbook.auto_approve_steps.contains(&step_index)
165}
166
167pub 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 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 StepExecutionResult {
211 step_index,
212 action: step.action.clone(),
213 status: StepStatus::Completed,
214 message: format!("Executed: {}", step.description),
215 }
216}
217
218pub 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]; assert!(can_auto_approve(pb, 0, "low", "low"));
381 assert!(can_auto_approve(pb, 0, "low", "medium"));
382
383 assert!(!can_auto_approve(pb, 0, "high", "low"));
385 assert!(!can_auto_approve(pb, 0, "critical", "medium"));
386
387 assert!(!can_auto_approve(pb, 2, "low", "critical"));
389 }
390
391 #[test]
392 fn evaluate_step_requires_approval() {
393 let pb = &builtin_playbooks()[0]; 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 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 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 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}