diff --git a/src/config/mod.rs b/src/config/mod.rs index b49edfda8..45fbd6ff7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,10 +18,10 @@ pub use schema::{ PeripheralBoardConfig, PeripheralsConfig, ProjectIntelConfig, ProxyConfig, ProxyScope, QdrantConfig, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, - SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, StorageProviderConfig, - StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy, TelegramConfig, - ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, TunnelConfig, - WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig, + SecurityOpsConfig, SkillsConfig, SkillsPromptInjectionMode, SlackConfig, StorageConfig, + StorageProviderConfig, StorageProviderSection, StreamMode, SwarmConfig, SwarmStrategy, + TelegramConfig, ToolFilterGroup, ToolFilterGroupMode, TranscriptionConfig, TtsConfig, + TunnelConfig, WebFetchConfig, WebSearchConfig, WebhookConfig, WorkspaceConfig, }; pub fn name_and_presence(channel: Option<&T>) -> (&'static str, bool) { diff --git a/src/config/schema.rs b/src/config/schema.rs index 462fabe97..9ca82e547 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -124,6 +124,9 @@ pub struct Config { #[serde(default)] pub security: SecurityConfig, + /// Managed cybersecurity service configuration (`[security_ops]`). + pub security_ops: SecurityOpsConfig, + /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution. #[serde(default)] pub runtime: RuntimeConfig, @@ -4714,6 +4717,65 @@ impl Default for NotionConfig { } } +// ── Security ops config ───────────────────────────────────────── + +/// Managed Cybersecurity Service (MCSS) dashboard agent configuration (`[security_ops]`). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SecurityOpsConfig { + /// Enable security operations tools. + #[serde(default)] + pub enabled: bool, + /// Directory containing incident response playbook definitions (JSON). + #[serde(default = "default_playbooks_dir")] + pub playbooks_dir: String, + /// Automatically triage incoming alerts without user prompt. + #[serde(default)] + pub auto_triage: bool, + /// Require human approval before executing playbook actions. + #[serde(default = "default_require_approval")] + pub require_approval_for_actions: bool, + /// Maximum severity level that can be auto-remediated without approval. + /// One of: "low", "medium", "high", "critical". Default: "low". + #[serde(default = "default_max_auto_severity")] + pub max_auto_severity: String, + /// Directory for generated security reports. + #[serde(default = "default_report_output_dir")] + pub report_output_dir: String, + /// Optional SIEM webhook URL for alert ingestion. + #[serde(default)] + pub siem_integration: Option, +} + +fn default_playbooks_dir() -> String { + "~/.zeroclaw/playbooks".into() +} + +fn default_require_approval() -> bool { + true +} + +fn default_max_auto_severity() -> String { + "low".into() +} + +fn default_report_output_dir() -> String { + "~/.zeroclaw/security-reports".into() +} + +impl Default for SecurityOpsConfig { + fn default() -> Self { + Self { + enabled: false, + playbooks_dir: default_playbooks_dir(), + auto_triage: false, + require_approval_for_actions: true, + max_auto_severity: default_max_auto_severity(), + report_output_dir: default_report_output_dir(), + siem_integration: None, + } + } +} + // ── Config impl ────────────────────────────────────────────────── impl Default for Config { @@ -4737,6 +4799,7 @@ impl Default for Config { observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), security: SecurityConfig::default(), + security_ops: SecurityOpsConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), @@ -6984,6 +7047,7 @@ default_temperature = 0.7 non_cli_excluded_tools: vec![], }, security: SecurityConfig::default(), + security_ops: SecurityOpsConfig::default(), runtime: RuntimeConfig { kind: "docker".into(), ..RuntimeConfig::default() @@ -7326,6 +7390,7 @@ tool_dispatcher = "xml" observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), security: SecurityConfig::default(), + security_ops: SecurityOpsConfig::default(), runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 2fe8fef28..210114954 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -144,6 +144,7 @@ pub async fn run_wizard(force: bool) -> Result { observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), security: crate::config::SecurityConfig::default(), + security_ops: crate::config::SecurityOpsConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), @@ -507,6 +508,7 @@ async fn run_quick_setup_with_home( observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), security: crate::config::SecurityConfig::default(), + security_ops: crate::config::SecurityOpsConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), diff --git a/src/security/mod.rs b/src/security/mod.rs index f80268427..433e7046f 100644 --- a/src/security/mod.rs +++ b/src/security/mod.rs @@ -36,10 +36,12 @@ pub mod leak_detector; pub mod nevis; pub mod otp; pub mod pairing; +pub mod playbook; pub mod policy; pub mod prompt_guard; pub mod secrets; pub mod traits; +pub mod vulnerability; pub mod workspace_boundary; #[allow(unused_imports)] diff --git a/src/security/playbook.rs b/src/security/playbook.rs new file mode 100644 index 000000000..cce5a27ff --- /dev/null +++ b/src/security/playbook.rs @@ -0,0 +1,459 @@ +//! Incident response playbook definitions and execution engine. +//! +//! Playbooks define structured response procedures for security incidents. +//! Each playbook has named steps, some of which require human approval before +//! execution. Playbooks are loaded from JSON files in the configured directory. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// A single step in an incident response playbook. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PlaybookStep { + /// Machine-readable action identifier (e.g. "isolate_host", "block_ip"). + pub action: String, + /// Human-readable description of what this step does. + pub description: String, + /// Whether this step requires explicit human approval before execution. + #[serde(default)] + pub requires_approval: bool, + /// Timeout in seconds for this step. Default: 300 (5 minutes). + #[serde(default = "default_timeout_secs")] + pub timeout_secs: u64, +} + +fn default_timeout_secs() -> u64 { + 300 +} + +/// An incident response playbook. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Playbook { + /// Unique playbook name (e.g. "suspicious_login"). + pub name: String, + /// Human-readable description. + pub description: String, + /// Ordered list of response steps. + pub steps: Vec, + /// Minimum alert severity that triggers this playbook (low/medium/high/critical). + #[serde(default = "default_severity_filter")] + pub severity_filter: String, + /// Step indices (0-based) that can be auto-approved when below max_auto_severity. + #[serde(default)] + pub auto_approve_steps: Vec, +} + +fn default_severity_filter() -> String { + "medium".into() +} + +/// Result of executing a single playbook step. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepExecutionResult { + pub step_index: usize, + pub action: String, + pub status: StepStatus, + pub message: String, +} + +/// Status of a playbook step. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum StepStatus { + /// Step completed successfully. + Completed, + /// Step is waiting for human approval. + PendingApproval, + /// Step was skipped (e.g. not applicable). + Skipped, + /// Step failed with an error. + Failed, +} + +impl std::fmt::Display for StepStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Completed => write!(f, "completed"), + Self::PendingApproval => write!(f, "pending_approval"), + Self::Skipped => write!(f, "skipped"), + Self::Failed => write!(f, "failed"), + } + } +} + +/// Load all playbook definitions from a directory of JSON files. +pub fn load_playbooks(dir: &Path) -> Vec { + let mut playbooks = Vec::new(); + + if !dir.exists() || !dir.is_dir() { + return builtin_playbooks(); + } + + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "json") { + match std::fs::read_to_string(&path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(pb) => playbooks.push(pb), + Err(e) => { + tracing::warn!("Failed to parse playbook {}: {e}", path.display()); + } + }, + Err(e) => { + tracing::warn!("Failed to read playbook {}: {e}", path.display()); + } + } + } + } + } + + // Merge built-in playbooks that aren't overridden by user-defined ones + for builtin in builtin_playbooks() { + if !playbooks.iter().any(|p| p.name == builtin.name) { + playbooks.push(builtin); + } + } + + playbooks +} + +/// Severity ordering for comparison: low < medium < high < critical. +pub fn severity_level(severity: &str) -> u8 { + match severity.to_lowercase().as_str() { + "low" => 1, + "medium" => 2, + "high" => 3, + "critical" => 4, + // Deny-by-default: unknown severities get the highest level to prevent + // auto-approval of unrecognized severity labels. + _ => u8::MAX, + } +} + +/// Check whether a step can be auto-approved given config constraints. +pub fn can_auto_approve( + playbook: &Playbook, + step_index: usize, + alert_severity: &str, + max_auto_severity: &str, +) -> bool { + // Never auto-approve if alert severity exceeds the configured max + if severity_level(alert_severity) > severity_level(max_auto_severity) { + return false; + } + + // Only auto-approve steps explicitly listed in auto_approve_steps + playbook.auto_approve_steps.contains(&step_index) +} + +/// Evaluate a playbook step. Returns the result with approval gating. +/// +/// Steps that require approval and cannot be auto-approved will return +/// `StepStatus::PendingApproval` without executing. +pub fn evaluate_step( + playbook: &Playbook, + step_index: usize, + alert_severity: &str, + max_auto_severity: &str, + require_approval: bool, +) -> StepExecutionResult { + let step = match playbook.steps.get(step_index) { + Some(s) => s, + None => { + return StepExecutionResult { + step_index, + action: "unknown".into(), + status: StepStatus::Failed, + message: format!("Step index {step_index} out of range"), + }; + } + }; + + // Enforce approval gates: steps that require approval must either be + // auto-approved or wait for human approval. Never mark an unexecuted + // approval-gated step as Completed. + if step.requires_approval + && (!require_approval + || !can_auto_approve(playbook, step_index, alert_severity, max_auto_severity)) + { + return StepExecutionResult { + step_index, + action: step.action.clone(), + status: StepStatus::PendingApproval, + message: format!( + "Step '{}' requires human approval (severity: {alert_severity})", + step.description + ), + }; + } + + // Step is approved (either doesn't require approval, or was auto-approved) + // Actual execution would be delegated to the appropriate tool/system + StepExecutionResult { + step_index, + action: step.action.clone(), + status: StepStatus::Completed, + message: format!("Executed: {}", step.description), + } +} + +/// Built-in playbook definitions for common incident types. +pub fn builtin_playbooks() -> Vec { + vec![ + Playbook { + name: "suspicious_login".into(), + description: "Respond to suspicious login activity detected by SIEM".into(), + steps: vec![ + PlaybookStep { + action: "gather_login_context".into(), + description: "Collect login metadata: IP, geo, device fingerprint, time".into(), + requires_approval: false, + timeout_secs: 60, + }, + PlaybookStep { + action: "check_threat_intel".into(), + description: "Query threat intelligence for source IP reputation".into(), + requires_approval: false, + timeout_secs: 30, + }, + PlaybookStep { + action: "notify_user".into(), + description: "Send verification notification to account owner".into(), + requires_approval: true, + timeout_secs: 300, + }, + PlaybookStep { + action: "force_password_reset".into(), + description: "Force password reset if login confirmed unauthorized".into(), + requires_approval: true, + timeout_secs: 120, + }, + ], + severity_filter: "medium".into(), + auto_approve_steps: vec![0, 1], + }, + Playbook { + name: "malware_detected".into(), + description: "Respond to malware detection on endpoint".into(), + steps: vec![ + PlaybookStep { + action: "isolate_endpoint".into(), + description: "Network-isolate the affected endpoint".into(), + requires_approval: true, + timeout_secs: 60, + }, + PlaybookStep { + action: "collect_forensics".into(), + description: "Capture memory dump and disk image for analysis".into(), + requires_approval: false, + timeout_secs: 600, + }, + PlaybookStep { + action: "scan_lateral_movement".into(), + description: "Check for lateral movement indicators on adjacent hosts".into(), + requires_approval: false, + timeout_secs: 300, + }, + PlaybookStep { + action: "remediate_endpoint".into(), + description: "Remove malware and restore endpoint to clean state".into(), + requires_approval: true, + timeout_secs: 600, + }, + ], + severity_filter: "high".into(), + auto_approve_steps: vec![1, 2], + }, + Playbook { + name: "data_exfiltration_attempt".into(), + description: "Respond to suspected data exfiltration".into(), + steps: vec![ + PlaybookStep { + action: "block_egress".into(), + description: "Block suspicious outbound connections".into(), + requires_approval: true, + timeout_secs: 30, + }, + PlaybookStep { + action: "identify_data_scope".into(), + description: "Determine what data may have been accessed or transferred".into(), + requires_approval: false, + timeout_secs: 300, + }, + PlaybookStep { + action: "preserve_evidence".into(), + description: "Preserve network logs and access records".into(), + requires_approval: false, + timeout_secs: 120, + }, + PlaybookStep { + action: "escalate_to_legal".into(), + description: "Notify legal and compliance teams".into(), + requires_approval: true, + timeout_secs: 60, + }, + ], + severity_filter: "critical".into(), + auto_approve_steps: vec![1, 2], + }, + Playbook { + name: "brute_force".into(), + description: "Respond to brute force authentication attempts".into(), + steps: vec![ + PlaybookStep { + action: "block_source_ip".into(), + description: "Block the attacking source IP at firewall".into(), + requires_approval: true, + timeout_secs: 30, + }, + PlaybookStep { + action: "check_compromised_accounts".into(), + description: "Check if any accounts were successfully compromised".into(), + requires_approval: false, + timeout_secs: 120, + }, + PlaybookStep { + action: "enable_rate_limiting".into(), + description: "Enable enhanced rate limiting on auth endpoints".into(), + requires_approval: true, + timeout_secs: 60, + }, + ], + severity_filter: "medium".into(), + auto_approve_steps: vec![1], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_playbooks_are_valid() { + let playbooks = builtin_playbooks(); + assert_eq!(playbooks.len(), 4); + + let names: Vec<&str> = playbooks.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"suspicious_login")); + assert!(names.contains(&"malware_detected")); + assert!(names.contains(&"data_exfiltration_attempt")); + assert!(names.contains(&"brute_force")); + + for pb in &playbooks { + assert!(!pb.steps.is_empty(), "Playbook {} has no steps", pb.name); + assert!(!pb.description.is_empty()); + } + } + + #[test] + fn severity_level_ordering() { + assert!(severity_level("low") < severity_level("medium")); + assert!(severity_level("medium") < severity_level("high")); + assert!(severity_level("high") < severity_level("critical")); + assert_eq!(severity_level("unknown"), u8::MAX); + } + + #[test] + fn auto_approve_respects_severity_cap() { + let pb = &builtin_playbooks()[0]; // suspicious_login + + // Step 0 is in auto_approve_steps + assert!(can_auto_approve(pb, 0, "low", "low")); + assert!(can_auto_approve(pb, 0, "low", "medium")); + + // Alert severity exceeds max -> cannot auto-approve + assert!(!can_auto_approve(pb, 0, "high", "low")); + assert!(!can_auto_approve(pb, 0, "critical", "medium")); + + // Step 2 is NOT in auto_approve_steps + assert!(!can_auto_approve(pb, 2, "low", "critical")); + } + + #[test] + fn evaluate_step_requires_approval() { + let pb = &builtin_playbooks()[0]; // suspicious_login + + // Step 2 (notify_user) requires approval, high severity, max=low -> pending + let result = evaluate_step(pb, 2, "high", "low", true); + assert_eq!(result.status, StepStatus::PendingApproval); + assert_eq!(result.action, "notify_user"); + + // Step 0 (gather_login_context) does NOT require approval -> completed + let result = evaluate_step(pb, 0, "high", "low", true); + assert_eq!(result.status, StepStatus::Completed); + } + + #[test] + fn evaluate_step_out_of_range() { + let pb = &builtin_playbooks()[0]; + let result = evaluate_step(pb, 99, "low", "low", true); + assert_eq!(result.status, StepStatus::Failed); + } + + #[test] + fn playbook_json_roundtrip() { + let pb = &builtin_playbooks()[0]; + let json = serde_json::to_string(pb).unwrap(); + let parsed: Playbook = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, *pb); + } + + #[test] + fn load_playbooks_from_nonexistent_dir_returns_builtins() { + let playbooks = load_playbooks(Path::new("/nonexistent/dir")); + assert_eq!(playbooks.len(), 4); + } + + #[test] + fn load_playbooks_merges_custom_and_builtin() { + let dir = tempfile::tempdir().unwrap(); + let custom = Playbook { + name: "custom_playbook".into(), + description: "A custom playbook".into(), + steps: vec![PlaybookStep { + action: "custom_action".into(), + description: "Do something custom".into(), + requires_approval: true, + timeout_secs: 60, + }], + severity_filter: "low".into(), + auto_approve_steps: vec![], + }; + let json = serde_json::to_string(&custom).unwrap(); + std::fs::write(dir.path().join("custom.json"), json).unwrap(); + + let playbooks = load_playbooks(dir.path()); + // 4 builtins + 1 custom + assert_eq!(playbooks.len(), 5); + assert!(playbooks.iter().any(|p| p.name == "custom_playbook")); + } + + #[test] + fn load_playbooks_custom_overrides_builtin() { + let dir = tempfile::tempdir().unwrap(); + let override_pb = Playbook { + name: "suspicious_login".into(), + description: "Custom override".into(), + steps: vec![PlaybookStep { + action: "custom_step".into(), + description: "Overridden step".into(), + requires_approval: false, + timeout_secs: 30, + }], + severity_filter: "low".into(), + auto_approve_steps: vec![0], + }; + let json = serde_json::to_string(&override_pb).unwrap(); + std::fs::write(dir.path().join("suspicious_login.json"), json).unwrap(); + + let playbooks = load_playbooks(dir.path()); + // 3 remaining builtins + 1 overridden = 4 + assert_eq!(playbooks.len(), 4); + let sl = playbooks + .iter() + .find(|p| p.name == "suspicious_login") + .unwrap(); + assert_eq!(sl.description, "Custom override"); + } +} diff --git a/src/security/vulnerability.rs b/src/security/vulnerability.rs new file mode 100644 index 000000000..0b8e30535 --- /dev/null +++ b/src/security/vulnerability.rs @@ -0,0 +1,397 @@ +//! Vulnerability scan result parsing and management. +//! +//! Parses vulnerability scan outputs from common scanners (Nessus, Qualys, generic +//! CVSS JSON) and provides priority scoring with business context adjustments. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; + +/// A single vulnerability finding. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Finding { + /// CVE identifier (e.g. "CVE-2024-1234"). May be empty for non-CVE findings. + #[serde(default)] + pub cve_id: String, + /// CVSS base score (0.0 - 10.0). + pub cvss_score: f64, + /// Severity label: "low", "medium", "high", "critical". + pub severity: String, + /// Affected asset identifier (hostname, IP, or service name). + pub affected_asset: String, + /// Description of the vulnerability. + pub description: String, + /// Recommended remediation steps. + #[serde(default)] + pub remediation: String, + /// Whether the asset is internet-facing (increases effective priority). + #[serde(default)] + pub internet_facing: bool, + /// Whether the asset is in a production environment. + #[serde(default = "default_true")] + pub production: bool, +} + +fn default_true() -> bool { + true +} + +/// A parsed vulnerability scan report. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VulnerabilityReport { + /// When the scan was performed. + pub scan_date: DateTime, + /// Scanner that produced the results (e.g. "nessus", "qualys", "generic"). + pub scanner: String, + /// Individual findings from the scan. + pub findings: Vec, +} + +/// Compute effective priority score for a finding. +/// +/// Base: CVSS score (0-10). Adjustments: +/// - Internet-facing: +2.0 (capped at 10.0) +/// - Production: +1.0 (capped at 10.0) +pub fn effective_priority(finding: &Finding) -> f64 { + let mut score = finding.cvss_score; + if finding.internet_facing { + score += 2.0; + } + if finding.production { + score += 1.0; + } + score.min(10.0) +} + +/// Classify CVSS score into severity label. +pub fn cvss_to_severity(cvss: f64) -> &'static str { + match cvss { + s if s >= 9.0 => "critical", + s if s >= 7.0 => "high", + s if s >= 4.0 => "medium", + s if s > 0.0 => "low", + _ => "informational", + } +} + +/// Parse a generic CVSS JSON vulnerability report. +/// +/// Expects a JSON object with: +/// - `scan_date`: ISO 8601 date string +/// - `scanner`: string +/// - `findings`: array of Finding objects +pub fn parse_vulnerability_json(json_str: &str) -> anyhow::Result { + let report: VulnerabilityReport = serde_json::from_str(json_str) + .map_err(|e| anyhow::anyhow!("Failed to parse vulnerability report: {e}"))?; + + for (i, finding) in report.findings.iter().enumerate() { + if !(0.0..=10.0).contains(&finding.cvss_score) { + anyhow::bail!( + "findings[{}].cvss_score must be between 0.0 and 10.0, got {}", + i, + finding.cvss_score + ); + } + } + + Ok(report) +} + +/// Generate a summary of the vulnerability report. +pub fn generate_summary(report: &VulnerabilityReport) -> String { + if report.findings.is_empty() { + return format!( + "Vulnerability scan by {} on {}: No findings.", + report.scanner, + report.scan_date.format("%Y-%m-%d") + ); + } + + let total = report.findings.len(); + let critical = report + .findings + .iter() + .filter(|f| f.severity.eq_ignore_ascii_case("critical")) + .count(); + let high = report + .findings + .iter() + .filter(|f| f.severity.eq_ignore_ascii_case("high")) + .count(); + let medium = report + .findings + .iter() + .filter(|f| f.severity.eq_ignore_ascii_case("medium")) + .count(); + let low = report + .findings + .iter() + .filter(|f| f.severity.eq_ignore_ascii_case("low")) + .count(); + let informational = report + .findings + .iter() + .filter(|f| f.severity.eq_ignore_ascii_case("informational")) + .count(); + + // Sort by effective priority descending + let mut sorted: Vec<&Finding> = report.findings.iter().collect(); + sorted.sort_by(|a, b| { + effective_priority(b) + .partial_cmp(&effective_priority(a)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + let mut summary = format!( + "## Vulnerability Scan Summary\n\ + **Scanner:** {} | **Date:** {}\n\ + **Total findings:** {} (Critical: {}, High: {}, Medium: {}, Low: {}, Informational: {})\n\n", + report.scanner, + report.scan_date.format("%Y-%m-%d"), + total, + critical, + high, + medium, + low, + informational + ); + + // Top 10 by effective priority + summary.push_str("### Top Findings by Priority\n\n"); + for (i, finding) in sorted.iter().take(10).enumerate() { + let priority = effective_priority(finding); + let context = match (finding.internet_facing, finding.production) { + (true, true) => " [internet-facing, production]", + (true, false) => " [internet-facing]", + (false, true) => " [production]", + (false, false) => "", + }; + let _ = writeln!( + summary, + "{}. **{}** (CVSS: {:.1}, Priority: {:.1}){}\n Asset: {} | {}", + i + 1, + if finding.cve_id.is_empty() { + "No CVE" + } else { + &finding.cve_id + }, + finding.cvss_score, + priority, + context, + finding.affected_asset, + finding.description + ); + if !finding.remediation.is_empty() { + let _ = writeln!(summary, " Remediation: {}", finding.remediation); + } + summary.push('\n'); + } + + // Remediation recommendations + if critical > 0 || high > 0 { + summary.push_str("### Remediation Recommendations\n\n"); + if critical > 0 { + let _ = writeln!( + summary, + "- **URGENT:** {} critical findings require immediate remediation", + critical + ); + } + if high > 0 { + let _ = writeln!( + summary, + "- **HIGH:** {} high-severity findings should be addressed within 7 days", + high + ); + } + let internet_facing_critical = sorted + .iter() + .filter(|f| f.internet_facing && (f.severity == "critical" || f.severity == "high")) + .count(); + if internet_facing_critical > 0 { + let _ = writeln!( + summary, + "- **PRIORITY:** {} critical/high findings on internet-facing assets", + internet_facing_critical + ); + } + } + + summary +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_findings() -> Vec { + vec![ + Finding { + cve_id: "CVE-2024-0001".into(), + cvss_score: 9.8, + severity: "critical".into(), + affected_asset: "web-server-01".into(), + description: "Remote code execution in web framework".into(), + remediation: "Upgrade to version 2.1.0".into(), + internet_facing: true, + production: true, + }, + Finding { + cve_id: "CVE-2024-0002".into(), + cvss_score: 7.5, + severity: "high".into(), + affected_asset: "db-server-01".into(), + description: "SQL injection in query parser".into(), + remediation: "Apply patch KB-12345".into(), + internet_facing: false, + production: true, + }, + Finding { + cve_id: "CVE-2024-0003".into(), + cvss_score: 4.3, + severity: "medium".into(), + affected_asset: "staging-app-01".into(), + description: "Information disclosure via debug endpoint".into(), + remediation: "Disable debug endpoint in config".into(), + internet_facing: false, + production: false, + }, + ] + } + + #[test] + fn effective_priority_adds_context_bonuses() { + let mut f = Finding { + cve_id: String::new(), + cvss_score: 7.0, + severity: "high".into(), + affected_asset: "host".into(), + description: "test".into(), + remediation: String::new(), + internet_facing: false, + production: false, + }; + + assert!((effective_priority(&f) - 7.0).abs() < f64::EPSILON); + + f.internet_facing = true; + assert!((effective_priority(&f) - 9.0).abs() < f64::EPSILON); + + f.production = true; + assert!((effective_priority(&f) - 10.0).abs() < f64::EPSILON); // capped + + // High CVSS + both bonuses still caps at 10.0 + f.cvss_score = 9.5; + assert!((effective_priority(&f) - 10.0).abs() < f64::EPSILON); + } + + #[test] + fn cvss_to_severity_classification() { + assert_eq!(cvss_to_severity(9.8), "critical"); + assert_eq!(cvss_to_severity(9.0), "critical"); + assert_eq!(cvss_to_severity(8.5), "high"); + assert_eq!(cvss_to_severity(7.0), "high"); + assert_eq!(cvss_to_severity(5.0), "medium"); + assert_eq!(cvss_to_severity(4.0), "medium"); + assert_eq!(cvss_to_severity(3.9), "low"); + assert_eq!(cvss_to_severity(0.1), "low"); + assert_eq!(cvss_to_severity(0.0), "informational"); + } + + #[test] + fn parse_vulnerability_json_roundtrip() { + let report = VulnerabilityReport { + scan_date: Utc::now(), + scanner: "nessus".into(), + findings: sample_findings(), + }; + + let json = serde_json::to_string(&report).unwrap(); + let parsed = parse_vulnerability_json(&json).unwrap(); + + assert_eq!(parsed.scanner, "nessus"); + assert_eq!(parsed.findings.len(), 3); + assert_eq!(parsed.findings[0].cve_id, "CVE-2024-0001"); + } + + #[test] + fn parse_vulnerability_json_rejects_invalid() { + let result = parse_vulnerability_json("not json"); + assert!(result.is_err()); + } + + #[test] + fn generate_summary_includes_key_sections() { + let report = VulnerabilityReport { + scan_date: Utc::now(), + scanner: "qualys".into(), + findings: sample_findings(), + }; + + let summary = generate_summary(&report); + + assert!(summary.contains("qualys")); + assert!(summary.contains("Total findings:** 3")); + assert!(summary.contains("Critical: 1")); + assert!(summary.contains("High: 1")); + assert!(summary.contains("CVE-2024-0001")); + assert!(summary.contains("URGENT")); + assert!(summary.contains("internet-facing")); + } + + #[test] + fn parse_vulnerability_json_rejects_out_of_range_cvss() { + let report = VulnerabilityReport { + scan_date: Utc::now(), + scanner: "test".into(), + findings: vec![Finding { + cve_id: "CVE-2024-9999".into(), + cvss_score: 11.0, + severity: "critical".into(), + affected_asset: "host".into(), + description: "bad score".into(), + remediation: String::new(), + internet_facing: false, + production: false, + }], + }; + let json = serde_json::to_string(&report).unwrap(); + let result = parse_vulnerability_json(&json); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("cvss_score must be between 0.0 and 10.0")); + } + + #[test] + fn parse_vulnerability_json_rejects_negative_cvss() { + let report = VulnerabilityReport { + scan_date: Utc::now(), + scanner: "test".into(), + findings: vec![Finding { + cve_id: "CVE-2024-9998".into(), + cvss_score: -1.0, + severity: "low".into(), + affected_asset: "host".into(), + description: "negative score".into(), + remediation: String::new(), + internet_facing: false, + production: false, + }], + }; + let json = serde_json::to_string(&report).unwrap(); + let result = parse_vulnerability_json(&json); + assert!(result.is_err()); + } + + #[test] + fn generate_summary_empty_findings() { + let report = VulnerabilityReport { + scan_date: Utc::now(), + scanner: "nessus".into(), + findings: vec![], + }; + + let summary = generate_summary(&report); + assert!(summary.contains("No findings")); + } +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 75cfccbc1..89761a5f0 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -60,6 +60,7 @@ pub mod report_templates; pub mod schedule; pub mod schema; pub mod screenshot; +pub mod security_ops; pub mod shell; pub mod swarm; pub mod tool_search; @@ -111,6 +112,7 @@ pub use schedule::ScheduleTool; #[allow(unused_imports)] pub use schema::{CleaningStrategy, SchemaCleanr}; pub use screenshot::ScreenshotTool; +pub use security_ops::SecurityOpsTool; pub use shell::ShellTool; pub use swarm::SwarmTool; pub use tool_search::ToolSearchTool; @@ -375,6 +377,13 @@ pub fn all_tools_with_runtime( ))); } + // MCSS Security Operations + if root_config.security_ops.enabled { + tool_arcs.push(Arc::new(SecurityOpsTool::new( + root_config.security_ops.clone(), + ))); + } + // PDF extraction (feature-gated at compile time via rag-pdf) tool_arcs.push(Arc::new(PdfReadTool::new(security.clone()))); diff --git a/src/tools/security_ops.rs b/src/tools/security_ops.rs new file mode 100644 index 000000000..92ce18d06 --- /dev/null +++ b/src/tools/security_ops.rs @@ -0,0 +1,659 @@ +//! Security operations tool for managed cybersecurity service (MCSS) workflows. +//! +//! Provides alert triage, incident response playbook execution, vulnerability +//! scan parsing, and security report generation. All actions that modify state +//! enforce human approval gates unless explicitly configured otherwise. + +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; + +use super::traits::{Tool, ToolResult}; +use crate::config::SecurityOpsConfig; +use crate::security::playbook::{ + evaluate_step, load_playbooks, severity_level, Playbook, StepStatus, +}; +use crate::security::vulnerability::{generate_summary, parse_vulnerability_json}; + +/// Security operations tool — triage alerts, run playbooks, parse vulns, generate reports. +pub struct SecurityOpsTool { + config: SecurityOpsConfig, + playbooks: Vec, +} + +impl SecurityOpsTool { + pub fn new(config: SecurityOpsConfig) -> Self { + let playbooks_dir = expand_tilde(&config.playbooks_dir); + let playbooks = load_playbooks(&playbooks_dir); + Self { config, playbooks } + } + + /// Triage an alert: classify severity and recommend response. + fn triage_alert(&self, args: &serde_json::Value) -> anyhow::Result { + let alert = args + .get("alert") + .ok_or_else(|| anyhow::anyhow!("Missing required 'alert' parameter"))?; + + // Extract key fields for classification + let alert_type = alert + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let source = alert + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let severity = alert + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("medium"); + let description = alert + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + // Classify and find matching playbooks + let matching_playbooks: Vec<&Playbook> = self + .playbooks + .iter() + .filter(|pb| { + severity_level(severity) >= severity_level(&pb.severity_filter) + && (pb.name.contains(alert_type) + || alert_type.contains(&pb.name) + || description + .to_lowercase() + .contains(&pb.name.replace('_', " "))) + }) + .collect(); + + let playbook_names: Vec<&str> = + matching_playbooks.iter().map(|p| p.name.as_str()).collect(); + + let output = json!({ + "classification": { + "alert_type": alert_type, + "source": source, + "severity": severity, + "severity_level": severity_level(severity), + "priority": if severity_level(severity) >= 3 { "immediate" } else { "standard" }, + }, + "recommended_playbooks": playbook_names, + "recommended_action": if matching_playbooks.is_empty() { + "Manual investigation required — no matching playbook found" + } else { + "Execute recommended playbook(s)" + }, + "auto_triage": self.config.auto_triage, + }); + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&output)?, + error: None, + }) + } + + /// Execute a playbook step with approval gating. + fn run_playbook(&self, args: &serde_json::Value) -> anyhow::Result { + let playbook_name = args + .get("playbook") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required 'playbook' parameter"))?; + + let step_index = + usize::try_from(args.get("step").and_then(|v| v.as_u64()).ok_or_else(|| { + anyhow::anyhow!("Missing required 'step' parameter (0-based index)") + })?) + .map_err(|_| anyhow::anyhow!("'step' parameter value too large for this platform"))?; + + let alert_severity = args + .get("alert_severity") + .and_then(|v| v.as_str()) + .unwrap_or("medium"); + + let playbook = self + .playbooks + .iter() + .find(|p| p.name == playbook_name) + .ok_or_else(|| anyhow::anyhow!("Playbook '{}' not found", playbook_name))?; + + let result = evaluate_step( + playbook, + step_index, + alert_severity, + &self.config.max_auto_severity, + self.config.require_approval_for_actions, + ); + + let output = json!({ + "playbook": playbook_name, + "step_index": result.step_index, + "action": result.action, + "status": result.status.to_string(), + "message": result.message, + "requires_manual_approval": result.status == StepStatus::PendingApproval, + }); + + Ok(ToolResult { + success: result.status != StepStatus::Failed, + output: serde_json::to_string_pretty(&output)?, + error: if result.status == StepStatus::Failed { + Some(result.message) + } else { + None + }, + }) + } + + /// Parse vulnerability scan results. + fn parse_vulnerability(&self, args: &serde_json::Value) -> anyhow::Result { + let scan_data = args + .get("scan_data") + .ok_or_else(|| anyhow::anyhow!("Missing required 'scan_data' parameter"))?; + + let json_str = if scan_data.is_string() { + scan_data.as_str().unwrap().to_string() + } else { + serde_json::to_string(scan_data)? + }; + + let report = parse_vulnerability_json(&json_str)?; + let summary = generate_summary(&report); + + let output = json!({ + "scanner": report.scanner, + "scan_date": report.scan_date.to_rfc3339(), + "total_findings": report.findings.len(), + "by_severity": { + "critical": report.findings.iter().filter(|f| f.severity == "critical").count(), + "high": report.findings.iter().filter(|f| f.severity == "high").count(), + "medium": report.findings.iter().filter(|f| f.severity == "medium").count(), + "low": report.findings.iter().filter(|f| f.severity == "low").count(), + }, + "summary": summary, + }); + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&output)?, + error: None, + }) + } + + /// Generate a client-facing security posture report. + fn generate_report(&self, args: &serde_json::Value) -> anyhow::Result { + let client_name = args + .get("client_name") + .and_then(|v| v.as_str()) + .unwrap_or("Client"); + let period = args + .get("period") + .and_then(|v| v.as_str()) + .unwrap_or("current"); + let alert_stats = args.get("alert_stats"); + let vuln_summary = args + .get("vuln_summary") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let report = format!( + "# Security Posture Report — {client_name}\n\ + **Period:** {period}\n\ + **Generated:** {}\n\n\ + ## Executive Summary\n\n\ + This report provides an overview of the security posture for {client_name} \ + during the {period} period.\n\n\ + ## Alert Summary\n\n\ + {}\n\n\ + ## Vulnerability Assessment\n\n\ + {}\n\n\ + ## Recommendations\n\n\ + 1. Address all critical and high-severity findings immediately\n\ + 2. Review and update incident response playbooks quarterly\n\ + 3. Conduct regular vulnerability scans on all internet-facing assets\n\ + 4. Ensure all endpoints have current security patches\n\n\ + ---\n\ + *Report generated by ZeroClaw MCSS Agent*\n", + chrono::Utc::now().format("%Y-%m-%d %H:%M UTC"), + alert_stats + .map(|s| serde_json::to_string_pretty(s).unwrap_or_default()) + .unwrap_or_else(|| "No alert statistics provided.".into()), + if vuln_summary.is_empty() { + "No vulnerability data provided." + } else { + vuln_summary + }, + ); + + Ok(ToolResult { + success: true, + output: report, + error: None, + }) + } + + /// List available playbooks. + fn list_playbooks(&self) -> anyhow::Result { + if self.playbooks.is_empty() { + return Ok(ToolResult { + success: true, + output: "No playbooks available.".into(), + error: None, + }); + } + + let playbook_list: Vec = self + .playbooks + .iter() + .map(|pb| { + json!({ + "name": pb.name, + "description": pb.description, + "steps": pb.steps.len(), + "severity_filter": pb.severity_filter, + "auto_approve_steps": pb.auto_approve_steps, + }) + }) + .collect(); + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&playbook_list)?, + error: None, + }) + } + + /// Summarize alert volume, categories, and resolution times. + fn alert_stats(&self, args: &serde_json::Value) -> anyhow::Result { + let alerts = args + .get("alerts") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing required 'alerts' array parameter"))?; + + let total = alerts.len(); + let mut by_severity = std::collections::HashMap::new(); + let mut by_category = std::collections::HashMap::new(); + let mut resolved_count = 0u64; + let mut total_resolution_secs = 0u64; + + for alert in alerts { + let severity = alert + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + *by_severity.entry(severity.to_string()).or_insert(0u64) += 1; + + let category = alert + .get("category") + .and_then(|v| v.as_str()) + .unwrap_or("uncategorized"); + *by_category.entry(category.to_string()).or_insert(0u64) += 1; + + if let Some(resolution_secs) = alert.get("resolution_secs").and_then(|v| v.as_u64()) { + resolved_count += 1; + total_resolution_secs += resolution_secs; + } + } + + let avg_resolution = if resolved_count > 0 { + total_resolution_secs as f64 / resolved_count as f64 + } else { + 0.0 + }; + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let avg_resolution_secs_u64 = avg_resolution.max(0.0) as u64; + + let output = json!({ + "total_alerts": total, + "resolved": resolved_count, + "unresolved": total as u64 - resolved_count, + "by_severity": by_severity, + "by_category": by_category, + "avg_resolution_secs": avg_resolution, + "avg_resolution_human": format_duration_secs(avg_resolution_secs_u64), + }); + + Ok(ToolResult { + success: true, + output: serde_json::to_string_pretty(&output)?, + error: None, + }) + } +} + +fn format_duration_secs(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + +/// Expand ~ to home directory. +fn expand_tilde(path: &str) -> PathBuf { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(user_dirs) = directories::UserDirs::new() { + return user_dirs.home_dir().join(rest); + } + } + PathBuf::from(path) +} + +#[async_trait] +impl Tool for SecurityOpsTool { + fn name(&self) -> &str { + "security_ops" + } + + fn description(&self) -> &str { + "Security operations tool for managed cybersecurity services. Actions: \ + triage_alert (classify/prioritize alerts), run_playbook (execute incident response steps), \ + parse_vulnerability (parse scan results), generate_report (create security posture reports), \ + list_playbooks (list available playbooks), alert_stats (summarize alert metrics)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": ["triage_alert", "run_playbook", "parse_vulnerability", "generate_report", "list_playbooks", "alert_stats"], + "description": "The security operation to perform" + }, + "alert": { + "type": "object", + "description": "Alert JSON for triage_alert (requires: type, severity; optional: source, description)" + }, + "playbook": { + "type": "string", + "description": "Playbook name for run_playbook" + }, + "step": { + "type": "integer", + "description": "0-based step index for run_playbook" + }, + "alert_severity": { + "type": "string", + "description": "Alert severity context for run_playbook" + }, + "scan_data": { + "description": "Vulnerability scan data (JSON string or object) for parse_vulnerability" + }, + "client_name": { + "type": "string", + "description": "Client name for generate_report" + }, + "period": { + "type": "string", + "description": "Reporting period for generate_report" + }, + "alert_stats": { + "type": "object", + "description": "Alert statistics to include in generate_report" + }, + "vuln_summary": { + "type": "string", + "description": "Vulnerability summary to include in generate_report" + }, + "alerts": { + "type": "array", + "description": "Array of alert objects for alert_stats" + } + } + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing required 'action' parameter"))?; + + match action { + "triage_alert" => self.triage_alert(&args), + "run_playbook" => self.run_playbook(&args), + "parse_vulnerability" => self.parse_vulnerability(&args), + "generate_report" => self.generate_report(&args), + "list_playbooks" => self.list_playbooks(), + "alert_stats" => self.alert_stats(&args), + _ => Ok(ToolResult { + success: false, + output: String::new(), + error: Some(format!( + "Unknown action '{action}'. Valid: triage_alert, run_playbook, \ + parse_vulnerability, generate_report, list_playbooks, alert_stats" + )), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_config() -> SecurityOpsConfig { + SecurityOpsConfig { + enabled: true, + playbooks_dir: "/nonexistent".into(), + auto_triage: false, + require_approval_for_actions: true, + max_auto_severity: "low".into(), + report_output_dir: "/tmp/reports".into(), + siem_integration: None, + } + } + + fn test_tool() -> SecurityOpsTool { + SecurityOpsTool::new(test_config()) + } + + #[test] + fn tool_name_and_schema() { + let tool = test_tool(); + assert_eq!(tool.name(), "security_ops"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["action"].is_object()); + assert!(schema["required"] + .as_array() + .unwrap() + .contains(&json!("action"))); + } + + #[tokio::test] + async fn triage_alert_classifies_severity() { + let tool = test_tool(); + let result = tool + .execute(json!({ + "action": "triage_alert", + "alert": { + "type": "suspicious_login", + "source": "siem", + "severity": "high", + "description": "Multiple failed login attempts followed by successful login" + } + })) + .await + .unwrap(); + + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["classification"]["severity"], "high"); + assert_eq!(output["classification"]["priority"], "immediate"); + // Should match suspicious_login playbook + let playbooks = output["recommended_playbooks"].as_array().unwrap(); + assert!(playbooks.iter().any(|p| p == "suspicious_login")); + } + + #[tokio::test] + async fn triage_alert_missing_alert_param() { + let tool = test_tool(); + let result = tool.execute(json!({"action": "triage_alert"})).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn run_playbook_requires_approval() { + let tool = test_tool(); + let result = tool + .execute(json!({ + "action": "run_playbook", + "playbook": "suspicious_login", + "step": 2, + "alert_severity": "high" + })) + .await + .unwrap(); + + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["status"], "pending_approval"); + assert_eq!(output["requires_manual_approval"], true); + } + + #[tokio::test] + async fn run_playbook_executes_safe_step() { + let tool = test_tool(); + let result = tool + .execute(json!({ + "action": "run_playbook", + "playbook": "suspicious_login", + "step": 0, + "alert_severity": "medium" + })) + .await + .unwrap(); + + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["status"], "completed"); + } + + #[tokio::test] + async fn run_playbook_not_found() { + let tool = test_tool(); + let result = tool + .execute(json!({ + "action": "run_playbook", + "playbook": "nonexistent", + "step": 0 + })) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn parse_vulnerability_valid_report() { + let tool = test_tool(); + let scan_data = json!({ + "scan_date": "2025-01-15T10:00:00Z", + "scanner": "nessus", + "findings": [ + { + "cve_id": "CVE-2024-0001", + "cvss_score": 9.8, + "severity": "critical", + "affected_asset": "web-01", + "description": "RCE in web framework", + "remediation": "Upgrade", + "internet_facing": true, + "production": true + } + ] + }); + + let result = tool + .execute(json!({ + "action": "parse_vulnerability", + "scan_data": scan_data + })) + .await + .unwrap(); + + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["total_findings"], 1); + assert_eq!(output["by_severity"]["critical"], 1); + } + + #[tokio::test] + async fn generate_report_produces_markdown() { + let tool = test_tool(); + let result = tool + .execute(json!({ + "action": "generate_report", + "client_name": "ZeroClaw Corp", + "period": "Q1 2025" + })) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("ZeroClaw Corp")); + assert!(result.output.contains("Q1 2025")); + assert!(result.output.contains("Security Posture Report")); + } + + #[tokio::test] + async fn list_playbooks_returns_builtins() { + let tool = test_tool(); + let result = tool + .execute(json!({"action": "list_playbooks"})) + .await + .unwrap(); + + assert!(result.success); + let output: Vec = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output.len(), 4); + let names: Vec<&str> = output.iter().map(|p| p["name"].as_str().unwrap()).collect(); + assert!(names.contains(&"suspicious_login")); + assert!(names.contains(&"malware_detected")); + } + + #[tokio::test] + async fn alert_stats_computes_summary() { + let tool = test_tool(); + let result = tool + .execute(json!({ + "action": "alert_stats", + "alerts": [ + {"severity": "critical", "category": "malware", "resolution_secs": 3600}, + {"severity": "high", "category": "phishing", "resolution_secs": 1800}, + {"severity": "medium", "category": "malware"}, + {"severity": "low", "category": "policy_violation", "resolution_secs": 600} + ] + })) + .await + .unwrap(); + + assert!(result.success); + let output: serde_json::Value = serde_json::from_str(&result.output).unwrap(); + assert_eq!(output["total_alerts"], 4); + assert_eq!(output["resolved"], 3); + assert_eq!(output["unresolved"], 1); + assert_eq!(output["by_severity"]["critical"], 1); + assert_eq!(output["by_category"]["malware"], 2); + } + + #[tokio::test] + async fn unknown_action_returns_error() { + let tool = test_tool(); + let result = tool.execute(json!({"action": "bad_action"})).await.unwrap(); + + assert!(!result.success); + assert!(result.error.unwrap().contains("Unknown action")); + } + + #[test] + fn format_duration_secs_readable() { + assert_eq!(format_duration_secs(45), "45s"); + assert_eq!(format_duration_secs(125), "2m 5s"); + assert_eq!(format_duration_secs(3665), "1h 1m"); + } +}