mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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:
parent
56c531533b
commit
36cdf1a3d9
26
net-guardia/src/adapter/http/byo.rs
Normal file
26
net-guardia/src/adapter/http/byo.rs
Normal 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,
|
||||
}))
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(>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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 {
|
||||
|
||||
50
net-guardia/src/model/soar/dry_run.rs
Normal file
50
net-guardia/src/model/soar/dry_run.rs
Normal 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,
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod condition;
|
||||
pub mod dry_run;
|
||||
pub mod playbook;
|
||||
pub mod playbook_data;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user