1use 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
18pub 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 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 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 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 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 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 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 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 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
396fn 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 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}