diff --git a/net-guardia/src/adapter/http/byo.rs b/net-guardia/src/adapter/http/byo.rs new file mode 100644 index 0000000..7fa09bd --- /dev/null +++ b/net-guardia/src/adapter/http/byo.rs @@ -0,0 +1,26 @@ +//! HTTP surface for the BYO (bring-your-own-model) Quickstart flow. +//! Exposes read-only metadata that helps an administrator author a +//! valid `manifest.yaml` — principally the `FEATURE_REGISTRY` list, +//! which is the authoritative set of feature names the system will +//! extract and feed to a user-supplied ONNX model. + +use actix_web::{HttpResponse, Scope, web}; + +use crate::core::auth::extractor::AuthClaims; +use crate::core::ml::feature_extractor::feature_registry_names; + +pub fn initialize() -> Scope { + web::scope("/byo").route("/feature-registry", web::get().to(get_feature_registry)) +} + +/// `GET /api/byo/feature-registry` — list every feature name the +/// manifest validator accepts. Returning this over HTTP lets the +/// BYO Quickstart panel show the authoritative set without shipping +/// duplicated documentation that would drift from the Rust constants. +async fn get_feature_registry(_auth: AuthClaims) -> HttpResponse { + let names = feature_registry_names(); + HttpResponse::Ok().json(serde_json::json!({ + "count": names.len(), + "features": names, + })) +} diff --git a/net-guardia/src/adapter/http/mod.rs b/net-guardia/src/adapter/http/mod.rs index 45fed2f..11813d8 100644 --- a/net-guardia/src/adapter/http/mod.rs +++ b/net-guardia/src/adapter/http/mod.rs @@ -2,6 +2,7 @@ pub mod acl; pub mod api_keys; pub mod audit; pub mod auth; +pub mod byo; pub mod default; pub mod filter; pub mod flow_trace; diff --git a/net-guardia/src/adapter/http/soar.rs b/net-guardia/src/adapter/http/soar.rs index c98e709..b73b8e4 100644 --- a/net-guardia/src/adapter/http/soar.rs +++ b/net-guardia/src/adapter/http/soar.rs @@ -1,8 +1,12 @@ +use std::str::FromStr; + use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; use crate::core::playbook_service::PlaybookService; +use crate::core::soar::engine::SoarEngine; +use crate::model::event::{DetectionSource, ThreatDetectedEvent}; use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput}; #[derive(Deserialize)] @@ -44,6 +48,7 @@ pub fn initialize() -> Scope { .route("/whitelist", web::get().to(list_whitelist)) .route("/whitelist", web::post().to(add_whitelist)) .route("/whitelist/{ip}", web::delete().to(remove_whitelist)) + .route("/dry-run", web::post().to(dry_run)) } async fn list_playbooks(_auth: AuthClaims, svc: web::Data) -> HttpResponse { @@ -317,3 +322,80 @@ async fn remove_whitelist(_auth: AuthClaims, svc: web::Data, pa Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } + +/// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR +/// matcher actually reads are carried — `dest_ip`, `protocol`, +/// `packet_rate`, `flow_count` participate in neither trigger-matching +/// nor condition evaluation, so accepting them would just invite +/// confusion. Sensible defaults fill in the rest of the synthetic +/// `ThreatDetectedEvent` body. +#[derive(Deserialize)] +struct DryRunRequest { + attack_type: String, + confidence: f32, + source_ip: String, + #[serde(default)] + sources: Option>, + #[serde(default)] + active_source_count: Option, + #[serde(default)] + fused_confidence: Option, + #[serde(default)] + geoip_country: Option, + #[serde(default)] + is_repeat_offender: Option, +} + +/// `POST /api/soar/dry-run` — simulate every enabled playbook against +/// a synthetic event. No actions execute, no cooldown or frequency +/// state gets recorded. Useful for an admin who just edited a +/// playbook's conditions and wants to sanity-check the match logic +/// before enabling it. +async fn dry_run(_auth: AuthClaims, engine: web::Data, body: web::Json) -> HttpResponse { + let event = match build_event(body.into_inner()) { + Ok(e) => e, + Err(msg) => { + return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })); + } + }; + let matches = engine.dry_run(&event); + HttpResponse::Ok().json(serde_json::json!({ + "match_count": matches.iter().filter(|m| m.would_fire).count(), + "playbooks_evaluated": matches.len(), + "results": matches, + })) +} + +/// Translate a wire `DryRunRequest` into a synthetic `ThreatDetectedEvent`. +/// Errors on typo'd `DetectionSource` names so an admin dry-running a +/// `SingleSourceHigh` condition doesn't silently get an empty sources +/// vector and a "doesn't match" result they misread as the playbook +/// being broken. +fn build_event(req: DryRunRequest) -> Result { + let sources: Vec = match req.sources { + Some(names) => names + .iter() + .map(|n| DetectionSource::from_str(n).map_err(|_| format!("unknown DetectionSource: {n}"))) + .collect::, _>>()?, + None => vec![DetectionSource::ML], + }; + let active_source_count = req.active_source_count.unwrap_or(sources.len()).max(1); + let fused_confidence = req.fused_confidence.unwrap_or(req.confidence); + Ok(ThreatDetectedEvent { + attack_type: req.attack_type, + confidence: req.confidence, + source_ip: req.source_ip, + dest_ip: "0.0.0.0".to_string(), + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: req.geoip_country, + is_repeat_offender: req.is_repeat_offender.unwrap_or(false), + sources, + active_source_count, + fused_confidence, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + }) +} diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs index ad8b90d..3b497be 100644 --- a/net-guardia/src/core/auth/middleware.rs +++ b/net-guardia/src/core/auth/middleware.rs @@ -47,7 +47,7 @@ fn required_permission(path: &str, method: &Method) -> Option { return Some("users:admin".to_string()); } else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") { "dashboard" - } else if path.starts_with("/api/ml/") { + } else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") { "ai_detection" } else if path.starts_with("/api/fusion/") { "fusion" diff --git a/net-guardia/src/core/ml/feature_extractor.rs b/net-guardia/src/core/ml/feature_extractor.rs index 1290681..c334ffe 100644 --- a/net-guardia/src/core/ml/feature_extractor.rs +++ b/net-guardia/src/core/ml/feature_extractor.rs @@ -18,6 +18,18 @@ pub fn feature_is_known(name: &str) -> bool { FEATURE_REGISTRY.contains_key(name) } +/// Every name (canonical or alias) the system accepts inside a +/// `manifest.features` list, sorted alphabetically so the BYO +/// Quickstart endpoint returns a deterministic ordering. Callers +/// treat this as an opaque string list; aliases for the same +/// underlying feature appear next to each other after sort only by +/// coincidence, not as a structural guarantee. +pub fn feature_registry_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = FEATURE_REGISTRY.keys().copied().collect(); + names.sort_unstable(); + names +} + impl FlowFeatures { pub fn extract(flow: &FlowData, feature_names: &[String]) -> Self { let precomputed = PrecomputedStats::compute(flow); @@ -763,6 +775,28 @@ mod tests { } } + #[test] + fn feature_registry_names_returns_sorted_unique_list() { + let names = feature_registry_names(); + assert!( + names.len() > 30, + "FEATURE_REGISTRY should carry at least the v10 feature set plus aliases" + ); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted, "feature_registry_names must be sorted"); + let mut dedup = names.clone(); + dedup.dedup(); + assert_eq!( + names.len(), + dedup.len(), + "feature_registry_names must have no duplicates" + ); + // Spot-check a canonical + alias pair both surface. + assert!(names.contains(&"Flow Duration")); + assert!(names.contains(&"flow_duration")); + } + #[test] fn bowley_skewness_known_output() { // Symmetric distribution: [1, 2, 3, 4, 5, 6, 7, 8] (n=8) diff --git a/net-guardia/src/core/soar/matcher.rs b/net-guardia/src/core/soar/matcher.rs index fc6209d..05f9d9e 100644 --- a/net-guardia/src/core/soar/matcher.rs +++ b/net-guardia/src/core/soar/matcher.rs @@ -16,6 +16,7 @@ use crate::core::soar::engine::SoarEngine; use crate::model::event::{DetectionSource, ThreatDetectedEvent}; use crate::model::log::soar::SoarLog; use crate::model::soar::condition::{ConditionType, PlaybookCondition}; +use crate::model::soar::dry_run::{DryRunAction, DryRunConditionResult, DryRunMatch}; use crate::model::soar::playbook::Playbook; /// Default frequency-condition window when the playbook omits `value2`. @@ -297,6 +298,22 @@ impl SoarEngine { } } + /// Simulate how each enabled playbook would react to `event` without + /// executing any actions or recording cooldown / frequency state. + /// Used by the dry-run endpoint so an admin can preview a rule + /// change before committing to it. + /// + /// Frequency conditions are reported as met-with-note rather than + /// evaluated, because a real evaluation requires runtime history + /// the synthetic event doesn't carry. The `has_frequency_condition` + /// flag on each `DryRunMatch` lets the UI flag that caveat to the + /// admin so they don't assume a `would_fire=true` playbook will + /// definitely fire on the next matching real event. + pub fn dry_run(&self, event: &ThreatDetectedEvent) -> Vec { + let playbooks = self.playbooks.read(); + playbooks.iter().map(|pb| simulate_playbook(pb, event)).collect() + } + /// Check if an IP address is private/loopback/link-local (SSRF protection). pub(super) fn is_private_ip(ip: &IpAddr) -> bool { match ip { @@ -318,3 +335,331 @@ impl SoarEngine { } } } + +/// Build a `DryRunMatch` for one playbook. Pure — no engine state +/// touched, no logs emitted, no cooldown recording. `would_fire` is +/// true when `trigger_matches` holds and every condition (including +/// the pass-through frequency branch) reports met. Any frequency +/// condition is evaluated as "passes + flagged for admin review" so +/// the preview stays conservative rather than a hard no against a +/// rule that only fails because dry-run has no history to count. +fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent) -> DryRunMatch { + let trigger_matches = pb.trigger_event == event.attack_type; + let mut has_frequency_condition = false; + let conditions: Vec = pb + .conditions + .iter() + .map(|c| { + if c.condition_type == ConditionType::Frequency { + has_frequency_condition = true; + } + simulate_condition(c, event) + }) + .collect(); + let all_conditions_met = conditions.iter().all(|r| r.met); + let would_fire = pb.enabled && trigger_matches && all_conditions_met; + let actions: Vec = pb + .actions + .iter() + .map(|a| DryRunAction { + action_order: a.action_order, + action_type: a.action_type.clone(), + params: a.params.clone(), + }) + .collect(); + + DryRunMatch { + playbook_id: pb.id, + playbook_name: pb.name.clone(), + enabled: pb.enabled, + trigger_event: pb.trigger_event.clone(), + trigger_matches, + has_frequency_condition, + would_fire, + conditions, + actions, + } +} + +/// Evaluate one condition against a synthetic event without touching +/// shared state. Every branch mirrors the live `evaluate_single_condition` +/// logic except `Frequency`, which is reported as "passes-with-note" +/// because a real evaluation would both need historical events and +/// record a new one. +fn simulate_condition(cond: &PlaybookCondition, event: &ThreatDetectedEvent) -> DryRunConditionResult { + let base = |met: bool, note: Option| DryRunConditionResult { + condition_type: cond.condition_type.to_string(), + operator: cond.operator.clone(), + value: cond.value.clone(), + value2: cond.value2.clone(), + met, + note, + }; + + match cond.condition_type { + ConditionType::Threshold => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return base(false, Some(format!("invalid threshold value: {}", cond.value))), + }; + let confidence = event.confidence as f64; + let met = if cond.operator == "<=" { + confidence <= threshold + } else { + confidence >= threshold + }; + base(met, Some(format!("event.confidence={confidence:.3}"))) + } + ConditionType::SourceCountry => { + let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); + let matches = event + .geoip_country + .as_ref() + .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); + let met = if cond.operator == "not_in" { !matches } else { matches }; + base( + met, + Some(format!( + "event.geoip_country={}", + event.geoip_country.as_deref().unwrap_or("none") + )), + ) + } + ConditionType::IpPattern => { + let net = match cond.value.parse::() { + Ok(n) => n, + Err(_) => return base(false, Some(format!("invalid CIDR: {}", cond.value))), + }; + let ip = match event.source_ip.parse::() { + Ok(a) => a, + Err(_) => return base(false, Some(format!("invalid source_ip: {}", event.source_ip))), + }; + let matches = net.contains(ip); + let met = if cond.operator == "not_in" { !matches } else { matches }; + base(met, Some(format!("event.source_ip={}", event.source_ip))) + } + ConditionType::RepeatOffender => { + let expected = cond.value.eq_ignore_ascii_case("true"); + let met = event.is_repeat_offender == expected; + base( + met, + Some(format!("event.is_repeat_offender={}", event.is_repeat_offender)), + ) + } + ConditionType::Frequency => base( + true, + Some("frequency condition not evaluated in dry-run — requires runtime history".to_string()), + ), + ConditionType::MultiSourceMin => { + let required = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return base(false, Some(format!("invalid required count: {}", cond.value))), + }; + let met = event.active_source_count >= required; + base( + met, + Some(format!( + "event.active_source_count={} required={required}", + event.active_source_count + )), + ) + } + ConditionType::SingleSourceHigh => { + let target_source = match DetectionSource::from_str(&cond.value) { + Ok(s) => s, + Err(_) => return base(false, Some(format!("unknown DetectionSource: {}", cond.value))), + }; + let min_conf = cond + .value2 + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE); + let solo_match = + event.active_source_count == 1 && event.sources.len() == 1 && event.sources[0] == target_source; + let conf_met = event.confidence >= min_conf; + let met = solo_match && conf_met; + base( + met, + Some(format!( + "sources={:?} count={} conf={:.3} target={} need_conf>={:.3}", + event.sources, event.active_source_count, event.confidence, cond.value, min_conf + )), + ) + } + ConditionType::FusedConfidenceAbove => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return base(false, Some(format!("invalid threshold: {}", cond.value))), + }; + let met = if cond.operator == "<=" { + event.fused_confidence <= threshold + } else { + event.fused_confidence >= threshold + }; + base( + met, + Some(format!("event.fused_confidence={:.3}", event.fused_confidence)), + ) + } + } +} + +#[cfg(test)] +mod dry_run_tests { + use super::*; + use crate::model::soar::playbook::PlaybookAction; + + fn event(attack_type: &str, confidence: f32, sources: Vec) -> ThreatDetectedEvent { + let count = sources.len().max(1); + ThreatDetectedEvent { + attack_type: attack_type.to_string(), + confidence, + source_ip: "1.2.3.4".to_string(), + dest_ip: "10.0.0.1".to_string(), + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources, + active_source_count: count, + fused_confidence: confidence, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + } + } + + fn playbook(name: &str, trigger: &str, conditions: Vec) -> Playbook { + Playbook { + id: 1, + name: name.to_string(), + enabled: true, + trigger_event: trigger.to_string(), + cooldown_secs: 60, + actions: vec![PlaybookAction { + action_order: 1, + action_type: "block_ip".to_string(), + params: serde_json::json!({"ttl_secs": 300}), + }], + conditions, + } + } + + #[test] + fn simulate_playbook_fires_when_trigger_matches_and_no_conditions() { + let pb = playbook("trivial", "brute_force", vec![]); + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(result.trigger_matches); + assert!(result.would_fire); + assert_eq!(result.actions.len(), 1); + assert_eq!(result.actions[0].action_type, "block_ip"); + } + + #[test] + fn simulate_playbook_does_not_fire_on_mismatched_trigger() { + let pb = playbook("brute-match", "brute_force", vec![]); + let ev = event("c2_beacon", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(!result.trigger_matches); + assert!(!result.would_fire); + } + + #[test] + fn simulate_playbook_does_not_fire_when_disabled() { + let mut pb = playbook("off", "brute_force", vec![]); + pb.enabled = false; + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(result.trigger_matches); + assert!(!result.enabled); + assert!(!result.would_fire); + } + + #[test] + fn simulate_condition_frequency_passes_with_note() { + let cond = PlaybookCondition { + condition_type: ConditionType::Frequency, + operator: ">=".to_string(), + value: "5".to_string(), + value2: Some("60".to_string()), + }; + let ev = event("brute_force", 0.8, vec![DetectionSource::ML]); + let result = simulate_condition(&cond, &ev); + assert!(result.met, "frequency must pass in dry-run"); + assert!( + result.note.as_deref().unwrap_or("").contains("frequency"), + "note must explain why frequency wasn't evaluated" + ); + } + + #[test] + fn simulate_condition_threshold_respects_operator() { + let gte = PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: ">=".to_string(), + value: "0.85".to_string(), + value2: None, + }; + let lte = PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: "<=".to_string(), + value: "0.85".to_string(), + value2: None, + }; + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + assert!(simulate_condition(>e, &ev).met); + assert!(!simulate_condition(<e, &ev).met); + } + + #[test] + fn simulate_condition_multi_source_min_counts_sources() { + let cond = PlaybookCondition { + condition_type: ConditionType::MultiSourceMin, + operator: ">=".to_string(), + value: "2".to_string(), + value2: None, + }; + let ev_two = event("c2_beacon", 0.9, vec![DetectionSource::Suricata, DetectionSource::ML]); + let ev_one = event("c2_beacon", 0.9, vec![DetectionSource::ML]); + assert!(simulate_condition(&cond, &ev_two).met); + assert!(!simulate_condition(&cond, &ev_one).met); + } + + #[test] + fn simulate_condition_single_source_high_requires_solo_and_confidence() { + let cond = PlaybookCondition { + condition_type: ConditionType::SingleSourceHigh, + operator: ">=".to_string(), + value: "Suricata".to_string(), + value2: Some("0.95".to_string()), + }; + let solo_high = event("c2_beacon", 0.96, vec![DetectionSource::Suricata]); + let solo_low = event("c2_beacon", 0.90, vec![DetectionSource::Suricata]); + let multi = event("c2_beacon", 0.96, vec![DetectionSource::Suricata, DetectionSource::ML]); + let wrong_source = event("c2_beacon", 0.96, vec![DetectionSource::ML]); + assert!(simulate_condition(&cond, &solo_high).met); + assert!(!simulate_condition(&cond, &solo_low).met); + assert!(!simulate_condition(&cond, &multi).met); + assert!(!simulate_condition(&cond, &wrong_source).met); + } + + #[test] + fn simulate_playbook_flags_frequency_condition_presence() { + let cond = PlaybookCondition { + condition_type: ConditionType::Frequency, + operator: ">=".to_string(), + value: "5".to_string(), + value2: Some("60".to_string()), + }; + let pb = playbook("brute_force_block", "brute_force", vec![cond]); + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(result.has_frequency_condition); + assert!( + result.would_fire, + "frequency alone must not block would_fire in dry-run" + ); + } +} diff --git a/net-guardia/src/infrastructure/http_server.rs b/net-guardia/src/infrastructure/http_server.rs index 2f7121a..c42df53 100644 --- a/net-guardia/src/infrastructure/http_server.rs +++ b/net-guardia/src/infrastructure/http_server.rs @@ -11,7 +11,7 @@ use macros::log; use crate::adapter::ebpf::EbpfServices; use crate::adapter::http::model_upload::PromoteLock; use crate::adapter::http::{ - acl, api_keys, audit as audit_api, auth, default, filter, flow_trace, fusion, health as health_api, + acl, api_keys, audit as audit_api, auth, byo, default, filter, flow_trace, fusion, health as health_api, logs as logs_api, ml, model_upload, notification as notification_api, rate_limit as rate_limit_api, report as report_api, setup as setup_api, soar, stats, system as system_api, }; @@ -28,6 +28,7 @@ use crate::core::dns_filter_service::DnsFilterService; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; +use crate::core::soar::engine::SoarEngine; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; @@ -69,6 +70,7 @@ pub struct HttpServerParams { pub force_https: ForceHttpsFlag, pub shutdown_handle: Arc, pub suricata_manager: Arc, + pub soar_engine: Arc, } /// CORS configuration shared by both full and setup servers. @@ -234,6 +236,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { let force_https = params.force_https; let shutdown_handle = params.shutdown_handle; let suricata_manager = params.suricata_manager; + let soar_engine = params.soar_engine; let port = app_config.http.http_server_bind_port; // Shared across every actix worker so concurrent model uploads @@ -279,6 +282,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .app_data(web::Data::from(playbook_service.clone())) .app_data(web::Data::from(rate_limit_service.clone())) .app_data(web::Data::from(suricata_manager.clone())) + .app_data(web::Data::from(soar_engine.clone())) .app_data(web::Data::from(promote_lock.clone())); app.wrap(SetupGuard) .service( @@ -293,6 +297,7 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .service(health_api::initialize()) .service(ml::initialize()) .service(model_upload::initialize()) + .service(byo::initialize()) .service(fusion::initialize()) .service(flow_trace::initialize()) .service(system_api::initialize()) diff --git a/net-guardia/src/infrastructure/system.rs b/net-guardia/src/infrastructure/system.rs index 5a49da3..ccea331 100644 --- a/net-guardia/src/infrastructure/system.rs +++ b/net-guardia/src/infrastructure/system.rs @@ -344,6 +344,7 @@ impl System { force_https, shutdown_handle: shutdown_handle.clone(), suricata_manager: self.suricata_manager.clone(), + soar_engine: self.soar_engine.clone(), }; let ready_for_http = ready_flag_for_set.clone(); actix::spawn(async move { diff --git a/net-guardia/src/model/soar/dry_run.rs b/net-guardia/src/model/soar/dry_run.rs new file mode 100644 index 0000000..649e79d --- /dev/null +++ b/net-guardia/src/model/soar/dry_run.rs @@ -0,0 +1,50 @@ +//! DTOs for the SOAR dry-run endpoint. Returned to the HTTP layer +//! verbatim, so any rename here is a wire-format change. + +use serde::Serialize; + +/// One playbook's simulated outcome against a synthetic event. Reports +/// what would fire, what conditions passed, and what actions would +/// execute — without actually invoking any of them. +#[derive(Debug, Clone, Serialize)] +pub struct DryRunMatch { + pub playbook_id: i64, + pub playbook_name: String, + pub enabled: bool, + pub trigger_event: String, + /// The playbook's `trigger_event` matched the event's `attack_type`. + pub trigger_matches: bool, + /// The playbook has at least one `frequency` condition whose outcome + /// depends on runtime history — dry-run cannot accurately evaluate + /// it, so the UI should warn the admin that real firing may differ. + pub has_frequency_condition: bool, + /// `trigger_matches` and every non-frequency condition reported met. + /// `has_frequency_condition=true` does NOT force this false — the + /// frequency branch is treated as "passes in dry-run" and flagged + /// for the admin to interpret. + pub would_fire: bool, + pub conditions: Vec, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DryRunConditionResult { + pub condition_type: String, + pub operator: String, + pub value: String, + pub value2: Option, + pub met: bool, + /// Human-readable explanation for the frontend to surface, populated + /// when the answer is non-obvious (e.g. "skipped — requires history" + /// for Frequency, or "sources=[ML] confidence=0.90 target=Suricata" + /// for a mismatching SingleSourceHigh). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DryRunAction { + pub action_order: i64, + pub action_type: String, + pub params: serde_json::Value, +} diff --git a/net-guardia/src/model/soar/mod.rs b/net-guardia/src/model/soar/mod.rs index 25d41b9..6e1bf40 100644 --- a/net-guardia/src/model/soar/mod.rs +++ b/net-guardia/src/model/soar/mod.rs @@ -1,3 +1,4 @@ pub mod condition; +pub mod dry_run; pub mod playbook; pub mod playbook_data;