feat(http): SOAR dry-run + BYO feature-registry endpoints

Two admin-facing surfaces that close out the backend side of the v1
fusion + BYO plan.

POST /api/soar/dry-run — simulate every enabled playbook against a
synthetic ThreatDetectedEvent body without executing actions,
recording cooldowns, or touching frequency tracker state. Each
result carries trigger-match, per-condition pass/fail with a
human-readable note, and the action list that would run. Frequency
conditions are evaluated as pass-with-note because dry-run has no
runtime history to count; the response surfaces has_frequency_condition
so the UI can warn the admin. SoarEngine::dry_run is additive on the
engine; the heavy lifting lives in pure free functions that are unit-
testable without constructing an engine fixture.

GET /api/byo/feature-registry — read-only projection of the compile-
time FEATURE_REGISTRY list the manifest validator accepts. Lets the
BYO Quickstart panel render the authoritative feature name set
(canonical + alias) without duplicating documentation that would
drift. Sorted alphabetically for deterministic output.

Middleware maps /api/byo/ to the existing ai_detection permission
bundle so the admin/viewer split there also governs the BYO surface.
SoarEngine joins the web::Data pool so the dry-run handler can reach
it, threaded through HttpServerParams alongside the other services.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-04-18 19:15:28 +08:00
parent 56c531533b
commit 36cdf1a3d9
10 changed files with 547 additions and 2 deletions

View File

@ -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,
}))
}

View File

@ -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;

View File

@ -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<PlaybookService>) -> HttpResponse {
@ -317,3 +322,80 @@ async fn remove_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>, 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<Vec<String>>,
#[serde(default)]
active_source_count: Option<usize>,
#[serde(default)]
fused_confidence: Option<f32>,
#[serde(default)]
geoip_country: Option<String>,
#[serde(default)]
is_repeat_offender: Option<bool>,
}
/// `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<SoarEngine>, body: web::Json<DryRunRequest>) -> 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<ThreatDetectedEvent, String> {
let sources: Vec<DetectionSource> = match req.sources {
Some(names) => names
.iter()
.map(|n| DetectionSource::from_str(n).map_err(|_| format!("unknown DetectionSource: {n}")))
.collect::<Result<Vec<_>, _>>()?,
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,
})
}

View File

@ -47,7 +47,7 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
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"

View File

@ -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)

View File

@ -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<DryRunMatch> {
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<DryRunConditionResult> = 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<DryRunAction> = 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<String>| 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::<f64>() {
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::<ipnetwork::IpNetwork>() {
Ok(n) => n,
Err(_) => return base(false, Some(format!("invalid CIDR: {}", cond.value))),
};
let ip = match event.source_ip.parse::<IpAddr>() {
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::<usize>() {
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::<f32>().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::<f32>() {
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<DetectionSource>) -> 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<PlaybookCondition>) -> 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(&gte, &ev).met);
assert!(!simulate_condition(&lte, &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"
);
}
}

View File

@ -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<ShutdownHandle>,
pub suricata_manager: Arc<SuricataManager>,
pub soar_engine: Arc<SoarEngine>,
}
/// 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())

View File

@ -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 {

View File

@ -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<DryRunConditionResult>,
pub actions: Vec<DryRunAction>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DryRunConditionResult {
pub condition_type: String,
pub operator: String,
pub value: String,
pub value2: Option<String>,
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<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DryRunAction {
pub action_order: i64,
pub action_type: String,
pub params: serde_json::Value,
}

View File

@ -1,3 +1,4 @@
pub mod condition;
pub mod dry_run;
pub mod playbook;
pub mod playbook_data;