Skip to main content

zeroclaw_runtime/tools/
security_ops.rs

1//! Security operations tool for managed cybersecurity service (MCSS) workflows.
2//!
3//! Provides alert triage, incident response playbook execution, vulnerability
4//! scan parsing, and security report generation. All actions that modify state
5//! enforce human approval gates unless explicitly configured otherwise.
6
7use async_trait::async_trait;
8use serde_json::json;
9use std::path::PathBuf;
10
11use crate::security::playbook::{
12    Playbook, StepStatus, evaluate_step, load_playbooks, severity_level,
13};
14use crate::security::vulnerability::{generate_summary, parse_vulnerability_json};
15use zeroclaw_api::tool::{Tool, ToolResult};
16use zeroclaw_config::schema::SecurityOpsConfig;
17
18/// Security operations tool — triage alerts, run playbooks, parse vulns, generate reports.
19pub struct SecurityOpsTool {
20    config: SecurityOpsConfig,
21    playbooks: Vec<Playbook>,
22}
23
24impl SecurityOpsTool {
25    pub fn new(config: SecurityOpsConfig) -> Self {
26        let playbooks_dir = expand_tilde(&config.playbooks_dir);
27        let playbooks = load_playbooks(&playbooks_dir);
28        Self { config, playbooks }
29    }
30
31    /// Triage an alert: classify severity and recommend response.
32    fn triage_alert(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
33        let alert = args.get("alert").ok_or_else(|| {
34            ::zeroclaw_log::record!(
35                WARN,
36                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
37                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
38                    .with_attrs(::serde_json::json!({"param": "alert"})),
39                "tool argument validation failed"
40            );
41
42            anyhow::Error::msg("Missing required 'alert' parameter")
43        })?;
44
45        // Extract key fields for classification
46        let alert_type = alert
47            .get("type")
48            .and_then(|v| v.as_str())
49            .unwrap_or("unknown");
50        let source = alert
51            .get("source")
52            .and_then(|v| v.as_str())
53            .unwrap_or("unknown");
54        let severity = alert
55            .get("severity")
56            .and_then(|v| v.as_str())
57            .unwrap_or("medium");
58        let description = alert
59            .get("description")
60            .and_then(|v| v.as_str())
61            .unwrap_or("");
62
63        // Classify and find matching playbooks
64        let matching_playbooks: Vec<&Playbook> = self
65            .playbooks
66            .iter()
67            .filter(|pb| {
68                severity_level(severity) >= severity_level(&pb.severity_filter)
69                    && (pb.name.contains(alert_type)
70                        || alert_type.contains(&pb.name)
71                        || description
72                            .to_lowercase()
73                            .contains(&pb.name.replace('_', " ")))
74            })
75            .collect();
76
77        let playbook_names: Vec<&str> =
78            matching_playbooks.iter().map(|p| p.name.as_str()).collect();
79
80        let output = json!({
81            "classification": {
82                "alert_type": alert_type,
83                "source": source,
84                "severity": severity,
85                "severity_level": severity_level(severity),
86                "priority": if severity_level(severity) >= 3 { "immediate" } else { "standard" },
87            },
88            "recommended_playbooks": playbook_names,
89            "recommended_action": if matching_playbooks.is_empty() {
90                "Manual investigation required — no matching playbook found"
91            } else {
92                "Execute recommended playbook(s)"
93            },
94            "auto_triage": self.config.auto_triage,
95        });
96
97        Ok(ToolResult {
98            success: true,
99            output: serde_json::to_string_pretty(&output)?,
100            error: None,
101        })
102    }
103
104    /// Execute a playbook step with approval gating.
105    fn run_playbook(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
106        let playbook_name = args
107            .get("playbook")
108            .and_then(|v| v.as_str())
109            .ok_or_else(|| {
110                ::zeroclaw_log::record!(
111                    WARN,
112                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
113                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
114                        .with_attrs(::serde_json::json!({"param": "playbook"})),
115                    "tool argument validation failed"
116                );
117
118                anyhow::Error::msg("Missing required 'playbook' parameter")
119            })?;
120
121        let step_index =
122            usize::try_from(args.get("step").and_then(|v| v.as_u64()).ok_or_else(|| {
123                ::zeroclaw_log::record!(
124                    WARN,
125                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
126                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
127                        .with_attrs(::serde_json::json!({"param": "step"})),
128                    "security_ops tool: missing 'step' parameter"
129                );
130                anyhow::Error::msg("Missing required 'step' parameter (0-based index)")
131            })?)
132            .map_err(|_| {
133                ::zeroclaw_log::record!(
134                    WARN,
135                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
136                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
137                        .with_attrs(::serde_json::json!({"param": "step"})),
138                    "security_ops tool: 'step' parameter too large for usize on this platform"
139                );
140                anyhow::Error::msg("'step' parameter value too large for this platform")
141            })?;
142
143        let alert_severity = args
144            .get("alert_severity")
145            .and_then(|v| v.as_str())
146            .unwrap_or("medium");
147
148        let playbook = self
149            .playbooks
150            .iter()
151            .find(|p| p.name == playbook_name)
152            .ok_or_else(|| {
153                ::zeroclaw_log::record!(
154                    WARN,
155                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
156                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
157                        .with_attrs(::serde_json::json!({"playbook": playbook_name})),
158                    "security_ops tool: playbook not found"
159                );
160                anyhow::Error::msg(format!("Playbook '{playbook_name}' not found"))
161            })?;
162
163        let result = evaluate_step(
164            playbook,
165            step_index,
166            alert_severity,
167            &self.config.max_auto_severity,
168            self.config.require_approval_for_actions,
169        );
170
171        let output = json!({
172            "playbook": playbook_name,
173            "step_index": result.step_index,
174            "action": result.action,
175            "status": result.status.to_string(),
176            "message": result.message,
177            "requires_manual_approval": result.status == StepStatus::PendingApproval,
178        });
179
180        Ok(ToolResult {
181            success: result.status != StepStatus::Failed,
182            output: serde_json::to_string_pretty(&output)?,
183            error: if result.status == StepStatus::Failed {
184                Some(result.message)
185            } else {
186                None
187            },
188        })
189    }
190
191    /// Parse vulnerability scan results.
192    fn parse_vulnerability(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
193        let scan_data = args.get("scan_data").ok_or_else(|| {
194            ::zeroclaw_log::record!(
195                WARN,
196                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
197                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
198                    .with_attrs(::serde_json::json!({"param": "scan_data"})),
199                "tool argument validation failed"
200            );
201
202            anyhow::Error::msg("Missing required 'scan_data' parameter")
203        })?;
204
205        let json_str = if scan_data.is_string() {
206            scan_data.as_str().unwrap().to_string()
207        } else {
208            serde_json::to_string(scan_data)?
209        };
210
211        let report = parse_vulnerability_json(&json_str)?;
212        let summary = generate_summary(&report);
213
214        let output = json!({
215            "scanner": report.scanner,
216            "scan_date": report.scan_date.to_rfc3339(),
217            "total_findings": report.findings.len(),
218            "by_severity": {
219                "critical": report.findings.iter().filter(|f| f.severity == "critical").count(),
220                "high": report.findings.iter().filter(|f| f.severity == "high").count(),
221                "medium": report.findings.iter().filter(|f| f.severity == "medium").count(),
222                "low": report.findings.iter().filter(|f| f.severity == "low").count(),
223            },
224            "summary": summary,
225        });
226
227        Ok(ToolResult {
228            success: true,
229            output: serde_json::to_string_pretty(&output)?,
230            error: None,
231        })
232    }
233
234    /// Generate a client-facing security posture report.
235    fn generate_report(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
236        let client_name = args
237            .get("client_name")
238            .and_then(|v| v.as_str())
239            .unwrap_or("Client");
240        let period = args
241            .get("period")
242            .and_then(|v| v.as_str())
243            .unwrap_or("current");
244        let alert_stats = args.get("alert_stats");
245        let vuln_summary = args
246            .get("vuln_summary")
247            .and_then(|v| v.as_str())
248            .unwrap_or("");
249
250        let report = format!(
251            "# Security Posture Report — {client_name}\n\
252             **Period:** {period}\n\
253             **Generated:** {}\n\n\
254             ## Executive Summary\n\n\
255             This report provides an overview of the security posture for {client_name} \
256             during the {period} period.\n\n\
257             ## Alert Summary\n\n\
258             {}\n\n\
259             ## Vulnerability Assessment\n\n\
260             {}\n\n\
261             ## Recommendations\n\n\
262             1. Address all critical and high-severity findings immediately\n\
263             2. Review and update incident response playbooks quarterly\n\
264             3. Conduct regular vulnerability scans on all internet-facing assets\n\
265             4. Ensure all endpoints have current security patches\n\n\
266             ---\n\
267             *Report generated by ZeroClaw MCSS Agent*\n",
268            chrono::Utc::now().format("%Y-%m-%d %H:%M UTC"),
269            alert_stats
270                .map(|s| serde_json::to_string_pretty(s).unwrap_or_default())
271                .unwrap_or_else(|| "No alert statistics provided.".into()),
272            if vuln_summary.is_empty() {
273                "No vulnerability data provided."
274            } else {
275                vuln_summary
276            },
277        );
278
279        Ok(ToolResult {
280            success: true,
281            output: report,
282            error: None,
283        })
284    }
285
286    /// List available playbooks.
287    fn list_playbooks(&self) -> anyhow::Result<ToolResult> {
288        if self.playbooks.is_empty() {
289            return Ok(ToolResult {
290                success: true,
291                output: "No playbooks available.".into(),
292                error: None,
293            });
294        }
295
296        let playbook_list: Vec<serde_json::Value> = self
297            .playbooks
298            .iter()
299            .map(|pb| {
300                json!({
301                    "name": pb.name,
302                    "description": pb.description,
303                    "steps": pb.steps.len(),
304                    "severity_filter": pb.severity_filter,
305                    "auto_approve_steps": pb.auto_approve_steps,
306                })
307            })
308            .collect();
309
310        Ok(ToolResult {
311            success: true,
312            output: serde_json::to_string_pretty(&playbook_list)?,
313            error: None,
314        })
315    }
316
317    /// Summarize alert volume, categories, and resolution times.
318    fn alert_stats(&self, args: &serde_json::Value) -> anyhow::Result<ToolResult> {
319        let alerts = args
320            .get("alerts")
321            .and_then(|v| v.as_array())
322            .ok_or_else(|| {
323                ::zeroclaw_log::record!(
324                    WARN,
325                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
326                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
327                        .with_attrs(::serde_json::json!({"param": "alerts"})),
328                    "tool argument validation failed"
329                );
330
331                anyhow::Error::msg("Missing required 'alerts' array parameter")
332            })?;
333
334        let total = alerts.len();
335        let mut by_severity = std::collections::HashMap::new();
336        let mut by_category = std::collections::HashMap::new();
337        let mut resolved_count = 0u64;
338        let mut total_resolution_secs = 0u64;
339
340        for alert in alerts {
341            let severity = alert
342                .get("severity")
343                .and_then(|v| v.as_str())
344                .unwrap_or("unknown");
345            *by_severity.entry(severity.to_string()).or_insert(0u64) += 1;
346
347            let category = alert
348                .get("category")
349                .and_then(|v| v.as_str())
350                .unwrap_or("uncategorized");
351            *by_category.entry(category.to_string()).or_insert(0u64) += 1;
352
353            if let Some(resolution_secs) = alert.get("resolution_secs").and_then(|v| v.as_u64()) {
354                resolved_count += 1;
355                total_resolution_secs += resolution_secs;
356            }
357        }
358
359        let avg_resolution = if resolved_count > 0 {
360            total_resolution_secs as f64 / resolved_count as f64
361        } else {
362            0.0
363        };
364
365        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
366        let avg_resolution_secs_u64 = avg_resolution.max(0.0) as u64;
367
368        let output = json!({
369            "total_alerts": total,
370            "resolved": resolved_count,
371            "unresolved": total as u64 - resolved_count,
372            "by_severity": by_severity,
373            "by_category": by_category,
374            "avg_resolution_secs": avg_resolution,
375            "avg_resolution_human": format_duration_secs(avg_resolution_secs_u64),
376        });
377
378        Ok(ToolResult {
379            success: true,
380            output: serde_json::to_string_pretty(&output)?,
381            error: None,
382        })
383    }
384}
385
386fn format_duration_secs(secs: u64) -> String {
387    if secs < 60 {
388        format!("{secs}s")
389    } else if secs < 3600 {
390        format!("{}m {}s", secs / 60, secs % 60)
391    } else {
392        format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
393    }
394}
395
396/// Expand ~ to home directory.
397fn expand_tilde(path: &str) -> PathBuf {
398    if let Some(rest) = path.strip_prefix("~/")
399        && let Some(user_dirs) = directories::UserDirs::new()
400    {
401        return user_dirs.home_dir().join(rest);
402    }
403    PathBuf::from(path)
404}
405
406#[async_trait]
407impl Tool for SecurityOpsTool {
408    fn name(&self) -> &str {
409        "security_ops"
410    }
411
412    fn description(&self) -> &str {
413        "Security operations tool for managed cybersecurity services. Actions: \
414         triage_alert (classify/prioritize alerts), run_playbook (execute incident response steps), \
415         parse_vulnerability (parse scan results), generate_report (create security posture reports), \
416         list_playbooks (list available playbooks), alert_stats (summarize alert metrics)."
417    }
418
419    fn parameters_schema(&self) -> serde_json::Value {
420        json!({
421            "type": "object",
422            "required": ["action"],
423            "properties": {
424                "action": {
425                    "type": "string",
426                    "enum": ["triage_alert", "run_playbook", "parse_vulnerability", "generate_report", "list_playbooks", "alert_stats"],
427                    "description": "The security operation to perform"
428                },
429                "alert": {
430                    "type": "object",
431                    "description": "Alert JSON for triage_alert (requires: type, severity; optional: source, description)"
432                },
433                "playbook": {
434                    "type": "string",
435                    "description": "Playbook name for run_playbook"
436                },
437                "step": {
438                    "type": "integer",
439                    "description": "0-based step index for run_playbook"
440                },
441                "alert_severity": {
442                    "type": "string",
443                    "description": "Alert severity context for run_playbook"
444                },
445                "scan_data": {
446                    "description": "Vulnerability scan data (JSON string or object) for parse_vulnerability"
447                },
448                "client_name": {
449                    "type": "string",
450                    "description": "Client name for generate_report"
451                },
452                "period": {
453                    "type": "string",
454                    "description": "Reporting period for generate_report"
455                },
456                "alert_stats": {
457                    "type": "object",
458                    "description": "Alert statistics to include in generate_report"
459                },
460                "vuln_summary": {
461                    "type": "string",
462                    "description": "Vulnerability summary to include in generate_report"
463                },
464                "alerts": {
465                    "type": "array",
466                    "description": "Array of alert objects for alert_stats"
467                }
468            }
469        })
470    }
471
472    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
473        let action = args.get("action").and_then(|v| v.as_str()).ok_or_else(|| {
474            ::zeroclaw_log::record!(
475                WARN,
476                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
477                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
478                    .with_attrs(::serde_json::json!({"param": "action"})),
479                "tool argument validation failed"
480            );
481
482            anyhow::Error::msg("Missing required 'action' parameter")
483        })?;
484
485        match action {
486            "triage_alert" => self.triage_alert(&args),
487            "run_playbook" => self.run_playbook(&args),
488            "parse_vulnerability" => self.parse_vulnerability(&args),
489            "generate_report" => self.generate_report(&args),
490            "list_playbooks" => self.list_playbooks(),
491            "alert_stats" => self.alert_stats(&args),
492            _ => Ok(ToolResult {
493                success: false,
494                output: String::new(),
495                error: Some(format!(
496                    "Unknown action '{action}'. Valid: triage_alert, run_playbook, \
497                     parse_vulnerability, generate_report, list_playbooks, alert_stats"
498                )),
499            }),
500        }
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507
508    fn test_config() -> SecurityOpsConfig {
509        SecurityOpsConfig {
510            enabled: true,
511            playbooks_dir: "/nonexistent".into(),
512            auto_triage: false,
513            require_approval_for_actions: true,
514            max_auto_severity: "low".into(),
515            report_output_dir: "/tmp/reports".into(),
516            siem_integration: None,
517        }
518    }
519
520    fn test_tool() -> SecurityOpsTool {
521        SecurityOpsTool::new(test_config())
522    }
523
524    #[test]
525    fn tool_name_and_schema() {
526        let tool = test_tool();
527        assert_eq!(tool.name(), "security_ops");
528        let schema = tool.parameters_schema();
529        assert!(schema["properties"]["action"].is_object());
530        assert!(
531            schema["required"]
532                .as_array()
533                .unwrap()
534                .contains(&json!("action"))
535        );
536    }
537
538    #[tokio::test]
539    async fn triage_alert_classifies_severity() {
540        let tool = test_tool();
541        let result = tool
542            .execute(json!({
543                "action": "triage_alert",
544                "alert": {
545                    "type": "suspicious_login",
546                    "source": "siem",
547                    "severity": "high",
548                    "description": "Multiple failed login attempts followed by successful login"
549                }
550            }))
551            .await
552            .unwrap();
553
554        assert!(result.success);
555        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
556        assert_eq!(output["classification"]["severity"], "high");
557        assert_eq!(output["classification"]["priority"], "immediate");
558        // Should match suspicious_login playbook
559        let playbooks = output["recommended_playbooks"].as_array().unwrap();
560        assert!(playbooks.iter().any(|p| p == "suspicious_login"));
561    }
562
563    #[tokio::test]
564    async fn triage_alert_missing_alert_param() {
565        let tool = test_tool();
566        let result = tool.execute(json!({"action": "triage_alert"})).await;
567        assert!(result.is_err());
568    }
569
570    #[tokio::test]
571    async fn run_playbook_requires_approval() {
572        let tool = test_tool();
573        let result = tool
574            .execute(json!({
575                "action": "run_playbook",
576                "playbook": "suspicious_login",
577                "step": 2,
578                "alert_severity": "high"
579            }))
580            .await
581            .unwrap();
582
583        assert!(result.success);
584        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
585        assert_eq!(output["status"], "pending_approval");
586        assert_eq!(output["requires_manual_approval"], true);
587    }
588
589    #[tokio::test]
590    async fn run_playbook_executes_safe_step() {
591        let tool = test_tool();
592        let result = tool
593            .execute(json!({
594                "action": "run_playbook",
595                "playbook": "suspicious_login",
596                "step": 0,
597                "alert_severity": "medium"
598            }))
599            .await
600            .unwrap();
601
602        assert!(result.success);
603        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
604        assert_eq!(output["status"], "completed");
605    }
606
607    #[tokio::test]
608    async fn run_playbook_not_found() {
609        let tool = test_tool();
610        let result = tool
611            .execute(json!({
612                "action": "run_playbook",
613                "playbook": "nonexistent",
614                "step": 0
615            }))
616            .await;
617
618        assert!(result.is_err());
619    }
620
621    #[tokio::test]
622    async fn parse_vulnerability_valid_report() {
623        let tool = test_tool();
624        let scan_data = json!({
625            "scan_date": "2025-01-15T10:00:00Z",
626            "scanner": "nessus",
627            "findings": [
628                {
629                    "cve_id": "CVE-2024-0001",
630                    "cvss_score": 9.8,
631                    "severity": "critical",
632                    "affected_asset": "web-01",
633                    "description": "RCE in web framework",
634                    "remediation": "Upgrade",
635                    "internet_facing": true,
636                    "production": true
637                }
638            ]
639        });
640
641        let result = tool
642            .execute(json!({
643                "action": "parse_vulnerability",
644                "scan_data": scan_data
645            }))
646            .await
647            .unwrap();
648
649        assert!(result.success);
650        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
651        assert_eq!(output["total_findings"], 1);
652        assert_eq!(output["by_severity"]["critical"], 1);
653    }
654
655    #[tokio::test]
656    async fn generate_report_produces_markdown() {
657        let tool = test_tool();
658        let result = tool
659            .execute(json!({
660                "action": "generate_report",
661                "client_name": "ZeroClaw Corp",
662                "period": "Q1 2025"
663            }))
664            .await
665            .unwrap();
666
667        assert!(result.success);
668        assert!(result.output.contains("ZeroClaw Corp"));
669        assert!(result.output.contains("Q1 2025"));
670        assert!(result.output.contains("Security Posture Report"));
671    }
672
673    #[tokio::test]
674    async fn list_playbooks_returns_builtins() {
675        let tool = test_tool();
676        let result = tool
677            .execute(json!({"action": "list_playbooks"}))
678            .await
679            .unwrap();
680
681        assert!(result.success);
682        let output: Vec<serde_json::Value> = serde_json::from_str(&result.output).unwrap();
683        assert_eq!(output.len(), 4);
684        let names: Vec<&str> = output.iter().map(|p| p["name"].as_str().unwrap()).collect();
685        assert!(names.contains(&"suspicious_login"));
686        assert!(names.contains(&"malware_detected"));
687    }
688
689    #[tokio::test]
690    async fn alert_stats_computes_summary() {
691        let tool = test_tool();
692        let result = tool
693            .execute(json!({
694                "action": "alert_stats",
695                "alerts": [
696                    {"severity": "critical", "category": "malware", "resolution_secs": 3600},
697                    {"severity": "high", "category": "phishing", "resolution_secs": 1800},
698                    {"severity": "medium", "category": "malware"},
699                    {"severity": "low", "category": "policy_violation", "resolution_secs": 600}
700                ]
701            }))
702            .await
703            .unwrap();
704
705        assert!(result.success);
706        let output: serde_json::Value = serde_json::from_str(&result.output).unwrap();
707        assert_eq!(output["total_alerts"], 4);
708        assert_eq!(output["resolved"], 3);
709        assert_eq!(output["unresolved"], 1);
710        assert_eq!(output["by_severity"]["critical"], 1);
711        assert_eq!(output["by_category"]["malware"], 2);
712    }
713
714    #[tokio::test]
715    async fn unknown_action_returns_error() {
716        let tool = test_tool();
717        let result = tool.execute(json!({"action": "bad_action"})).await.unwrap();
718
719        assert!(!result.success);
720        assert!(result.error.unwrap().contains("Unknown action"));
721    }
722
723    #[test]
724    fn format_duration_secs_readable() {
725        assert_eq!(format_duration_secs(45), "45s");
726        assert_eq!(format_duration_secs(125), "2m 5s");
727        assert_eq!(format_duration_secs(3665), "1h 1m");
728    }
729}