Compare commits
5 Commits
master
...
work/secur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d9f83aee8 | ||
|
|
8f9ce15e33 | ||
|
|
13a933f6a6 | ||
|
|
421671b796 | ||
|
|
835214e23f |
@ -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<T: traits::ChannelConfig>(channel: Option<&T>) -> (&'static str, bool) {
|
||||
|
||||
@ -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<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
@ -144,6 +144,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
|
||||
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(),
|
||||
|
||||
@ -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)]
|
||||
|
||||
459
src/security/playbook.rs
Normal file
459
src/security/playbook.rs
Normal file
@ -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<PlaybookStep>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
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<Playbook> {
|
||||
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::<Playbook>(&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<Playbook> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
397
src/security/vulnerability.rs
Normal file
397
src/security/vulnerability.rs
Normal file
@ -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<Utc>,
|
||||
/// Scanner that produced the results (e.g. "nessus", "qualys", "generic").
|
||||
pub scanner: String,
|
||||
/// Individual findings from the scan.
|
||||
pub findings: Vec<Finding>,
|
||||
}
|
||||
|
||||
/// 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<VulnerabilityReport> {
|
||||
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<Finding> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@ -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())));
|
||||
|
||||
|
||||
659
src/tools/security_ops.rs
Normal file
659
src/tools/security_ops.rs
Normal file
@ -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<Playbook>,
|
||||
}
|
||||
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
if self.playbooks.is_empty() {
|
||||
return Ok(ToolResult {
|
||||
success: true,
|
||||
output: "No playbooks available.".into(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
|
||||
let playbook_list: Vec<serde_json::Value> = 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<ToolResult> {
|
||||
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<ToolResult> {
|
||||
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::Value> = 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user