Skip to main content

guard_core_engine/
security_config.rs

1//! The unified configuration surface: the reference `SecurityConfig`
2//! (`guard_core/_security_config_fields.py`, 129 fields) fieldized for the
3//! Rust family.
4//!
5//! Every field carries the reference default in [`SecurityConfig::default`]
6//! and every reference constraint in [`SecurityConfig::validate`] (the
7//! pydantic `gt`/`ge`/`le` bounds, the IP-or-CIDR list parsing, and the
8//! cross-field agent rules). Validation collects every violation into one
9//! [`SecurityConfigError`] (the reference raises per-field pydantic
10//! errors; the engine answers with the full problem list, fail-closed).
11//!
12//! The four seams the reference types as callables/handlers map onto
13//! engine-native types:
14//!
15//! | Python field | Rust type |
16//! |---|---|
17//! | `geo_ip_handler: GeoIPHandler` | `Arc<dyn GeoIpHandler>` ([`crate::geo`]) |
18//! | `custom_request_check` | `CustomRequestFn` ([`crate::custom_checks`]) |
19//! | `auth_verifier` | `AuthVerifier` ([`crate::headers_auth`]) |
20//! | `cloud_ip_store: CloudIpStoreProtocol` | `Arc<dyn CloudRangeStore>` ([`crate::cloud_fetch`]) |
21//! | `on_block` | `OnBlockHook` ([`crate::payload`]) |
22//! | `on_error` | `OnErrorFn` ([`crate::payload`]) |
23//! | `custom_response_modifier` | `ResponseModifierFn` ([`crate::payload`]) |
24//!
25//! The `agent_*` group is carried as data (the fields exist here for
26//! parity; the wiring into `guard-agent-rs`'s `AgentConfig` is the
27//! adapter lane's R5 seam).
28//!
29//! # Example
30//!
31//! ```
32//! use guard_core_engine::security_config::SecurityConfig;
33//!
34//! let mut config = SecurityConfig::default();
35//! assert!(config.validate().is_ok());
36//!
37//! config.auto_ban_threshold = 0;
38//! let error = config.validate().unwrap_err();
39//! assert!(error
40//!     .problems
41//!     .iter()
42//!     .any(|problem| problem.contains("auto_ban_threshold")));
43//! ```
44
45use std::collections::{BTreeMap, BTreeSet};
46use std::net::IpAddr;
47use std::path::PathBuf;
48use std::sync::Arc;
49
50use crate::behavior::BehaviorRule;
51use crate::cloud_fetch::CloudRangeStore;
52use crate::custom_checks::CustomRequestFn;
53use crate::geo::GeoIpHandler;
54use crate::headers_auth::AuthVerifier;
55use crate::ip_ban::{IpBanConfig, ThreatBanEntry};
56use crate::payload::{OnBlockHook, OnErrorFn, ResponseModifierFn};
57use crate::security_headers::SecurityHeadersConfig;
58
59/// The reference detection-category set
60/// (`ALL_DETECTION_CATEGORIES`, 19 categories) -
61/// [`SecurityConfig::enabled_detection_categories`]'s default.
62pub const ALL_DETECTION_CATEGORIES: [&str; 19] = [
63    "xss",
64    "sqli",
65    "dir_traversal",
66    "path_traversal",
67    "cmd_injection",
68    "file_inclusion",
69    "ldap",
70    "xml",
71    "ssrf",
72    "nosql",
73    "file_upload",
74    "template",
75    "http_split",
76    "sensitive_file",
77    "cms_probing",
78    "recon",
79    "proto_pollution",
80    "code_injection",
81    "deserialization",
82];
83
84/// A log-severity knob value (the reference `Literal['INFO', 'DEBUG',
85/// 'WARNING', 'ERROR', 'CRITICAL']`).
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum LogLevel {
88    /// `INFO`.
89    Info,
90    /// `DEBUG`.
91    Debug,
92    /// `WARNING`.
93    Warning,
94    /// `ERROR`.
95    Error,
96    /// `CRITICAL`.
97    Critical,
98}
99
100impl LogLevel {
101    /// The reference literal.
102    #[must_use]
103    pub const fn as_str(self) -> &'static str {
104        match self {
105            Self::Info => "INFO",
106            Self::Debug => "DEBUG",
107            Self::Warning => "WARNING",
108            Self::Error => "ERROR",
109            Self::Critical => "CRITICAL",
110        }
111    }
112}
113
114/// The log-line format knob (the reference `Literal['text', 'json']`).
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum LogFormat {
117    /// `text` (the default fixed-format lines).
118    Text,
119    /// `json` (the structured record formatters).
120    Json,
121}
122
123impl LogFormat {
124    /// The reference literal.
125    #[must_use]
126    pub const fn as_str(self) -> &'static str {
127        match self {
128            Self::Text => "text",
129            Self::Json => "json",
130        }
131    }
132}
133
134/// The agent buffer overflow posture (the reference
135/// `Literal['drop', 'block', 'raise']`), `None` defers to the agent's own
136/// default.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum BufferOverflowPolicy {
139    /// `drop`: discard the oldest entry.
140    Drop,
141    /// `block`: backpressure the caller.
142    Block,
143    /// `raise`: surface the overflow.
144    Raise,
145}
146
147impl BufferOverflowPolicy {
148    /// The reference literal.
149    #[must_use]
150    pub const fn as_str(self) -> &'static str {
151        match self {
152            Self::Drop => "drop",
153            Self::Block => "block",
154            Self::Raise => "raise",
155        }
156    }
157}
158
159/// The unified configuration surface (the reference `SecurityConfig`).
160///
161/// Construct with [`SecurityConfig::default`] (every field at the
162/// reference default) or `..Default::default()` struct-update, then
163/// [`SecurityConfig::validate`] before use.
164// The boolean count is the reference fieldization, not a design smell:
165// the 129-field pydantic model carries exactly these toggles.
166#[allow(clippy::struct_excessive_bools)]
167#[derive(Clone)]
168pub struct SecurityConfig {
169    /// `trusted_proxies`: proxy IPs or CIDR ranges for X-Forwarded-For
170    /// (the literal `unix` is additionally accepted, the reference's
171    /// `allow_unix`).
172    pub trusted_proxies: Vec<String>,
173    /// `trusted_proxy_depth`: how many proxies to expect in the
174    /// X-Forwarded-For chain (at least 1).
175    pub trusted_proxy_depth: u32,
176    /// `trust_x_forwarded_proto`.
177    pub trust_x_forwarded_proto: bool,
178    /// `passive_mode`: log-only, nothing blocks.
179    pub passive_mode: bool,
180    /// `geo_ip_handler`.
181    pub geo_ip_handler: Option<Arc<dyn GeoIpHandler>>,
182    /// `enable_redis`.
183    pub enable_redis: bool,
184    /// `redis_url`.
185    pub redis_url: Option<String>,
186    /// `redis_prefix`.
187    pub redis_prefix: String,
188    /// `redis_socket_connect_timeout` (seconds; `Some` must be positive).
189    pub redis_socket_connect_timeout: Option<f64>,
190    /// `redis_socket_timeout` (seconds; `Some` must be positive).
191    pub redis_socket_timeout: Option<f64>,
192    /// `redis_health_check_interval` (seconds; 0 disables).
193    pub redis_health_check_interval: u64,
194    /// `redis_max_connections` (`Some` must be at least 1).
195    pub redis_max_connections: Option<u64>,
196    /// `redis_retries` (the type carries the at-least-0 bound).
197    pub redis_retries: u32,
198    /// `whitelist`: restrictive when non-empty (IPs or CIDR ranges).
199    pub whitelist: Option<Vec<String>>,
200    /// `blacklist`.
201    pub blacklist: Vec<String>,
202    /// `exempt_ips`.
203    pub exempt_ips: Vec<String>,
204    /// `whitelist_countries`.
205    pub whitelist_countries: BTreeSet<String>,
206    /// `blocked_countries`.
207    pub blocked_countries: BTreeSet<String>,
208    /// `blocked_user_agents`.
209    pub blocked_user_agents: Vec<String>,
210    /// `auto_ban_threshold` (at least 1).
211    pub auto_ban_threshold: u32,
212    /// `auto_ban_duration` (seconds, at least 1).
213    pub auto_ban_duration: u64,
214    /// `threat_ban_config`: per-category threshold/duration overrides.
215    pub threat_ban_config: BTreeMap<String, ThreatBanEntry>,
216    /// `enable_rate_limit_auto_ban`.
217    pub enable_rate_limit_auto_ban: bool,
218    /// `global_behavior_rules`.
219    pub global_behavior_rules: Vec<BehaviorRule>,
220    /// `behavior_scan_response_body`.
221    pub behavior_scan_response_body: bool,
222    /// `behavior_max_response_body_inspect_bytes` (1024..=10485760).
223    pub behavior_max_response_body_inspect_bytes: usize,
224    /// `body_read_timeout` (seconds, `0 < t <= 30`).
225    pub body_read_timeout: f64,
226    /// `sync_body_read_max_concurrent` (1..=10000).
227    pub sync_body_read_max_concurrent: usize,
228    /// `custom_log_file`.
229    pub custom_log_file: Option<String>,
230    /// `log_suspicious_level`.
231    pub log_suspicious_level: Option<LogLevel>,
232    /// `log_request_level`.
233    pub log_request_level: Option<LogLevel>,
234    /// `log_country_check_level`.
235    pub log_country_check_level: Option<LogLevel>,
236    /// `log_format`.
237    pub log_format: LogFormat,
238    /// `custom_error_responses`: status code to body override.
239    pub custom_error_responses: BTreeMap<u16, String>,
240    /// `rate_limit` (the default-window request count).
241    pub rate_limit: u32,
242    /// `rate_limit_window` (seconds).
243    pub rate_limit_window: u64,
244    /// `enforce_https`.
245    pub enforce_https: bool,
246    /// `security_headers`.
247    pub security_headers: SecurityHeadersConfig,
248    /// `custom_request_check`.
249    pub custom_request_check: Option<CustomRequestFn>,
250    /// `auth_verifier`.
251    pub auth_verifier: Option<AuthVerifier>,
252    /// `custom_response_modifier`.
253    pub custom_response_modifier: Option<ResponseModifierFn>,
254    /// `on_block`.
255    pub on_block: Option<OnBlockHook>,
256    /// `on_error`.
257    pub on_error: Option<OnErrorFn>,
258    /// `enable_cors`.
259    pub enable_cors: bool,
260    /// `cors_allow_origins`.
261    pub cors_allow_origins: Vec<String>,
262    /// `cors_allow_methods`.
263    pub cors_allow_methods: Vec<String>,
264    /// `cors_allow_headers`.
265    pub cors_allow_headers: Vec<String>,
266    /// `cors_allow_credentials`.
267    pub cors_allow_credentials: bool,
268    /// `cors_expose_headers`.
269    pub cors_expose_headers: Vec<String>,
270    /// `cors_max_age` (seconds).
271    pub cors_max_age: u64,
272    /// `block_cloud_providers`.
273    pub block_cloud_providers: Option<BTreeSet<String>>,
274    /// `cloud_ip_refresh_interval` (seconds, 60..=86400).
275    pub cloud_ip_refresh_interval: u64,
276    /// `lazy_init`.
277    pub lazy_init: bool,
278    /// `geo_ip_db_max_age` (seconds, 3600..=604800).
279    pub geo_ip_db_max_age: u64,
280    /// `cloud_ip_store`.
281    pub cloud_ip_store: Option<Arc<dyn CloudRangeStore>>,
282    /// `exclude_paths` (the framework's own docs/static paths).
283    pub exclude_paths: Vec<String>,
284    /// `enable_ip_banning`.
285    pub enable_ip_banning: bool,
286    /// `enable_rate_limiting`.
287    pub enable_rate_limiting: bool,
288    /// `enable_penetration_detection`.
289    pub enable_penetration_detection: bool,
290    /// `fail_secure`.
291    pub fail_secure: bool,
292    /// `redis_fail_open`.
293    pub redis_fail_open: bool,
294    /// `route_resolution_strict`.
295    pub route_resolution_strict: bool,
296    /// `ipinfo_token`.
297    pub ipinfo_token: Option<String>,
298    /// `ipinfo_db_path`.
299    pub ipinfo_db_path: Option<PathBuf>,
300    /// `enable_agent`.
301    pub enable_agent: bool,
302    /// `agent_api_key` (required when `enable_agent`).
303    pub agent_api_key: Option<String>,
304    /// `agent_strict`.
305    pub agent_strict: bool,
306    /// `agent_endpoint`.
307    pub agent_endpoint: String,
308    /// `agent_project_id`.
309    pub agent_project_id: Option<String>,
310    /// `agent_buffer_size`.
311    pub agent_buffer_size: usize,
312    /// `agent_flush_interval` (seconds).
313    pub agent_flush_interval: u64,
314    /// `agent_enable_events`.
315    pub agent_enable_events: bool,
316    /// `agent_enable_metrics`.
317    pub agent_enable_metrics: bool,
318    /// `agent_timeout` (seconds).
319    pub agent_timeout: u64,
320    /// `agent_retry_attempts`.
321    pub agent_retry_attempts: u32,
322    /// `agent_project_encryption_key`.
323    pub agent_project_encryption_key: Option<String>,
324    /// `agent_guard_version`.
325    pub agent_guard_version: Option<String>,
326    /// `agent_high_watermark_ratio` (`Some` must be in `(0, 1]`).
327    pub agent_high_watermark_ratio: Option<f64>,
328    /// `agent_max_concurrent_flushes` (`Some` must be at least 1).
329    pub agent_max_concurrent_flushes: Option<u64>,
330    /// `agent_buffer_overflow_policy`.
331    pub agent_buffer_overflow_policy: Option<BufferOverflowPolicy>,
332    /// `agent_backoff_factor` (`Some` must be positive).
333    pub agent_backoff_factor: Option<f64>,
334    /// `agent_sensitive_headers`.
335    pub agent_sensitive_headers: Option<Vec<String>>,
336    /// `agent_max_payload_size`.
337    pub agent_max_payload_size: Option<u64>,
338    /// `agent_compression_enabled`.
339    pub agent_compression_enabled: Option<bool>,
340    /// `agent_compression_threshold`.
341    pub agent_compression_threshold: Option<u64>,
342    /// `agent_install_id`.
343    pub agent_install_id: Option<String>,
344    /// `agent_payload_signing_secret`.
345    pub agent_payload_signing_secret: Option<String>,
346    /// `enable_dynamic_rules` (requires `enable_agent`).
347    pub enable_dynamic_rules: bool,
348    /// `dynamic_rule_interval` (seconds, at least 60).
349    pub dynamic_rule_interval: u64,
350    /// `dynamic_rules_cache_path`.
351    pub dynamic_rules_cache_path: Option<PathBuf>,
352    /// `agent_status_interval` (seconds, 60..=86400).
353    pub agent_status_interval: u64,
354    /// `emergency_mode`.
355    pub emergency_mode: bool,
356    /// `emergency_whitelist`.
357    pub emergency_whitelist: Vec<String>,
358    /// `endpoint_rate_limits`: path pattern to (count, window seconds).
359    pub endpoint_rate_limits: BTreeMap<String, (u32, u64)>,
360    /// `detection_compiler_timeout` (seconds, 0.1..=10.0).
361    pub detection_compiler_timeout: f64,
362    /// `detection_pattern_validation_cache_path`.
363    pub detection_pattern_validation_cache_path: Option<String>,
364    /// `detection_max_content_length` (1000..=100000).
365    pub detection_max_content_length: usize,
366    /// `detection_max_body_inspect_bytes` (1024..=10485760).
367    pub detection_max_body_inspect_bytes: usize,
368    /// `detection_max_scan_values` (2..=100000).
369    pub detection_max_scan_values: usize,
370    /// `detection_max_scan_chars` (1024..=262144).
371    pub detection_max_scan_chars: usize,
372    /// `detection_max_json_depth` (1..=1000).
373    pub detection_max_json_depth: usize,
374    /// `detection_binary_min_run_length` (4..=1024).
375    pub detection_binary_min_run_length: usize,
376    /// `detection_preserve_attack_patterns`.
377    pub detection_preserve_attack_patterns: bool,
378    /// `detection_semantic_threshold` (0.0..=1.0).
379    pub detection_semantic_threshold: f64,
380    /// `detection_anomaly_threshold` (1.0..=10.0).
381    pub detection_anomaly_threshold: f64,
382    /// `detection_slow_pattern_threshold` (0.01..=1.0).
383    pub detection_slow_pattern_threshold: f64,
384    /// `detection_monitor_history_size` (100..=10000).
385    pub detection_monitor_history_size: usize,
386    /// `detection_max_tracked_patterns` (100..=5000).
387    pub detection_max_tracked_patterns: usize,
388    /// `detection_anomaly_emission_cooldown` (seconds, 1.0..=3600.0).
389    pub detection_anomaly_emission_cooldown: f64,
390    /// `detection_min_samples_for_anomaly` (10..=1000).
391    pub detection_min_samples_for_anomaly: usize,
392    /// `detection_threat_score_threshold` (0.0..=10.0).
393    pub detection_threat_score_threshold: f64,
394    /// `muted_event_types`.
395    pub muted_event_types: BTreeSet<String>,
396    /// `muted_metric_types`.
397    pub muted_metric_types: BTreeSet<String>,
398    /// `muted_check_logs`.
399    pub muted_check_logs: BTreeSet<String>,
400    /// `log_sensitive_headers`.
401    pub log_sensitive_headers: BTreeSet<String>,
402    /// `log_sensitive_params`.
403    pub log_sensitive_params: BTreeSet<String>,
404    /// `log_sensitive_body_fields`.
405    pub log_sensitive_body_fields: BTreeSet<String>,
406    /// `enable_otel`.
407    pub enable_otel: bool,
408    /// `otel_service_name`.
409    pub otel_service_name: String,
410    /// `otel_exporter_endpoint`.
411    pub otel_exporter_endpoint: Option<String>,
412    /// `otel_resource_attributes`.
413    pub otel_resource_attributes: BTreeMap<String, String>,
414    /// `enable_logfire`.
415    pub enable_logfire: bool,
416    /// `logfire_service_name`.
417    pub logfire_service_name: String,
418    /// `enable_enrichment` (requires `enable_agent`).
419    pub enable_enrichment: bool,
420    /// `excluded_detection_headers`.
421    pub excluded_detection_headers: BTreeSet<String>,
422    /// `excluded_detection_params`.
423    pub excluded_detection_params: BTreeSet<String>,
424    /// `excluded_detection_body_fields`.
425    pub excluded_detection_body_fields: BTreeSet<String>,
426    /// `detection_scan_body`.
427    pub detection_scan_body: bool,
428    /// `enabled_detection_categories`
429    /// (defaults to [`ALL_DETECTION_CATEGORIES`]).
430    pub enabled_detection_categories: BTreeSet<String>,
431}
432
433impl Default for SecurityConfig {
434    /// Every field at the reference default
435    /// (`guard_core/_security_config_fields.py`); the length is the
436    /// 129-field default table itself.
437    #[allow(clippy::too_many_lines)]
438    fn default() -> Self {
439        Self {
440            trusted_proxies: Vec::new(),
441            trusted_proxy_depth: 1,
442            trust_x_forwarded_proto: false,
443            passive_mode: false,
444            geo_ip_handler: None,
445            enable_redis: true,
446            redis_url: Some(String::from("redis://localhost:6379")),
447            redis_prefix: String::from("guard_core:"),
448            redis_socket_connect_timeout: Some(2.0),
449            redis_socket_timeout: Some(2.0),
450            redis_health_check_interval: 30,
451            redis_max_connections: None,
452            redis_retries: 1,
453            whitelist: None,
454            blacklist: Vec::new(),
455            exempt_ips: Vec::new(),
456            whitelist_countries: BTreeSet::new(),
457            blocked_countries: BTreeSet::new(),
458            blocked_user_agents: Vec::new(),
459            auto_ban_threshold: 10,
460            auto_ban_duration: 3600,
461            threat_ban_config: BTreeMap::new(),
462            enable_rate_limit_auto_ban: false,
463            global_behavior_rules: Vec::new(),
464            behavior_scan_response_body: false,
465            behavior_max_response_body_inspect_bytes: 262_144,
466            body_read_timeout: 3.0,
467            sync_body_read_max_concurrent: 64,
468            custom_log_file: None,
469            log_suspicious_level: Some(LogLevel::Warning),
470            log_request_level: None,
471            log_country_check_level: Some(LogLevel::Info),
472            log_format: LogFormat::Text,
473            custom_error_responses: BTreeMap::new(),
474            rate_limit: 10,
475            rate_limit_window: 60,
476            enforce_https: false,
477            security_headers: SecurityHeadersConfig::reference_default(),
478            custom_request_check: None,
479            auth_verifier: None,
480            custom_response_modifier: None,
481            on_block: None,
482            on_error: None,
483            enable_cors: false,
484            cors_allow_origins: vec![String::from("*")],
485            cors_allow_methods: vec![
486                String::from("GET"),
487                String::from("POST"),
488                String::from("PUT"),
489                String::from("PATCH"),
490                String::from("DELETE"),
491                String::from("OPTIONS"),
492            ],
493            cors_allow_headers: vec![String::from("*")],
494            cors_allow_credentials: false,
495            cors_expose_headers: Vec::new(),
496            cors_max_age: 600,
497            block_cloud_providers: None,
498            cloud_ip_refresh_interval: 3600,
499            lazy_init: true,
500            geo_ip_db_max_age: 86_400,
501            cloud_ip_store: None,
502            exclude_paths: vec![
503                String::from("/docs"),
504                String::from("/redoc"),
505                String::from("/openapi.json"),
506                String::from("/openapi.yaml"),
507                String::from("/favicon.ico"),
508                String::from("/static"),
509            ],
510            enable_ip_banning: true,
511            enable_rate_limiting: true,
512            enable_penetration_detection: true,
513            fail_secure: true,
514            redis_fail_open: false,
515            route_resolution_strict: false,
516            ipinfo_token: None,
517            ipinfo_db_path: Some(PathBuf::from("data/ipinfo/country_asn.mmdb")),
518            enable_agent: false,
519            agent_api_key: None,
520            agent_strict: false,
521            agent_endpoint: String::from("https://api.guard-core.com"),
522            agent_project_id: None,
523            agent_buffer_size: 100,
524            agent_flush_interval: 30,
525            agent_enable_events: true,
526            agent_enable_metrics: true,
527            agent_timeout: 30,
528            agent_retry_attempts: 3,
529            agent_project_encryption_key: None,
530            agent_guard_version: None,
531            agent_high_watermark_ratio: None,
532            agent_max_concurrent_flushes: None,
533            agent_buffer_overflow_policy: None,
534            agent_backoff_factor: None,
535            agent_sensitive_headers: None,
536            agent_max_payload_size: None,
537            agent_compression_enabled: None,
538            agent_compression_threshold: None,
539            agent_install_id: None,
540            agent_payload_signing_secret: None,
541            enable_dynamic_rules: false,
542            dynamic_rule_interval: 300,
543            dynamic_rules_cache_path: None,
544            agent_status_interval: 300,
545            emergency_mode: false,
546            emergency_whitelist: Vec::new(),
547            endpoint_rate_limits: BTreeMap::new(),
548            detection_compiler_timeout: 2.0,
549            detection_pattern_validation_cache_path: None,
550            detection_max_content_length: 10_000,
551            detection_max_body_inspect_bytes: 262_144,
552            detection_max_scan_values: 512,
553            detection_max_scan_chars: 65_536,
554            detection_max_json_depth: 32,
555            detection_binary_min_run_length: 16,
556            detection_preserve_attack_patterns: true,
557            detection_semantic_threshold: 0.7,
558            detection_anomaly_threshold: 3.0,
559            detection_slow_pattern_threshold: 0.1,
560            detection_monitor_history_size: 1000,
561            detection_max_tracked_patterns: 1000,
562            detection_anomaly_emission_cooldown: 60.0,
563            detection_min_samples_for_anomaly: 30,
564            detection_threat_score_threshold: 1.0,
565            muted_event_types: BTreeSet::new(),
566            muted_metric_types: BTreeSet::new(),
567            muted_check_logs: BTreeSet::new(),
568            log_sensitive_headers: BTreeSet::new(),
569            log_sensitive_params: BTreeSet::new(),
570            log_sensitive_body_fields: BTreeSet::new(),
571            enable_otel: false,
572            otel_service_name: String::from("guard-core"),
573            otel_exporter_endpoint: None,
574            otel_resource_attributes: BTreeMap::new(),
575            enable_logfire: false,
576            logfire_service_name: String::from("guard-core"),
577            enable_enrichment: false,
578            excluded_detection_headers: BTreeSet::new(),
579            excluded_detection_params: BTreeSet::new(),
580            excluded_detection_body_fields: BTreeSet::new(),
581            detection_scan_body: true,
582            enabled_detection_categories: ALL_DETECTION_CATEGORIES
583                .iter()
584                .map(|category| (*category).to_owned())
585                .collect(),
586        }
587    }
588}
589
590impl SecurityConfig {
591    /// The reference-default configuration. Validation still runs before
592    /// use; the defaults satisfy every constraint.
593    #[must_use]
594    pub fn new() -> Self {
595        Self::default()
596    }
597
598    /// Validate every reference constraint, collecting all violations.
599    ///
600    /// The length is the reference constraint list, one check per
601    /// pydantic `gt`/`ge`/`le` bound plus the list parsers and the
602    /// cross-field agent rules; every violation is collected, none
603    /// short-circuits.
604    ///
605    /// # Errors
606    ///
607    /// [`SecurityConfigError`] carrying every problem, fail-closed.
608    #[allow(clippy::too_many_lines)]
609    pub fn validate(&self) -> Result<(), SecurityConfigError> {
610        let mut problems = Vec::new();
611
612        if self.trusted_proxy_depth < 1 {
613            problems.push("trusted_proxy_depth must be at least 1".to_owned());
614        }
615        if self
616            .redis_socket_connect_timeout
617            .is_some_and(|value| value <= 0.0)
618        {
619            problems.push("redis_socket_connect_timeout must be positive when set".to_owned());
620        }
621        if self.redis_socket_timeout.is_some_and(|value| value <= 0.0) {
622            problems.push("redis_socket_timeout must be positive when set".to_owned());
623        }
624        if self.redis_max_connections.is_some_and(|value| value < 1) {
625            problems.push("redis_max_connections must be at least 1 when set".to_owned());
626        }
627        if self.auto_ban_threshold < 1 {
628            problems.push("auto_ban_threshold must be at least 1".to_owned());
629        }
630        if self.auto_ban_duration < 1 {
631            problems.push("auto_ban_duration must be at least 1".to_owned());
632        }
633        if !(1024..=10_485_760).contains(&self.behavior_max_response_body_inspect_bytes) {
634            problems.push(
635                "behavior_max_response_body_inspect_bytes must be between 1024 and 10485760"
636                    .to_owned(),
637            );
638        }
639        if self.body_read_timeout <= 0.0 || self.body_read_timeout > 30.0 {
640            problems.push("body_read_timeout must be between 0 (exclusive) and 30".to_owned());
641        }
642        if !(1..=10_000).contains(&self.sync_body_read_max_concurrent) {
643            problems.push("sync_body_read_max_concurrent must be between 1 and 10000".to_owned());
644        }
645        if !(60..=86_400).contains(&self.cloud_ip_refresh_interval) {
646            problems.push("cloud_ip_refresh_interval must be between 60 and 86400".to_owned());
647        }
648        if !(3600..=604_800).contains(&self.geo_ip_db_max_age) {
649            problems.push("geo_ip_db_max_age must be between 3600 and 604800".to_owned());
650        }
651        if self.dynamic_rule_interval < 60 {
652            problems.push("dynamic_rule_interval must be at least 60".to_owned());
653        }
654        if !(60..=86_400).contains(&self.agent_status_interval) {
655            problems.push("agent_status_interval must be between 60 and 86400".to_owned());
656        }
657        if !(0.1..=10.0).contains(&self.detection_compiler_timeout) {
658            problems.push("detection_compiler_timeout must be between 0.1 and 10.0".to_owned());
659        }
660        if !(1000..=100_000).contains(&self.detection_max_content_length) {
661            problems
662                .push("detection_max_content_length must be between 1000 and 100000".to_owned());
663        }
664        if !(1024..=10_485_760).contains(&self.detection_max_body_inspect_bytes) {
665            problems.push(
666                "detection_max_body_inspect_bytes must be between 1024 and 10485760".to_owned(),
667            );
668        }
669        if !(2..=100_000).contains(&self.detection_max_scan_values) {
670            problems.push("detection_max_scan_values must be between 2 and 100000".to_owned());
671        }
672        if !(1024..=262_144).contains(&self.detection_max_scan_chars) {
673            problems.push("detection_max_scan_chars must be between 1024 and 262144".to_owned());
674        }
675        if !(1..=1000).contains(&self.detection_max_json_depth) {
676            problems.push("detection_max_json_depth must be between 1 and 1000".to_owned());
677        }
678        if !(4..=1024).contains(&self.detection_binary_min_run_length) {
679            problems.push("detection_binary_min_run_length must be between 4 and 1024".to_owned());
680        }
681        if !(0.0..=1.0).contains(&self.detection_semantic_threshold) {
682            problems.push("detection_semantic_threshold must be between 0.0 and 1.0".to_owned());
683        }
684        if !(1.0..=10.0).contains(&self.detection_anomaly_threshold) {
685            problems.push("detection_anomaly_threshold must be between 1.0 and 10.0".to_owned());
686        }
687        if !(0.01..=1.0).contains(&self.detection_slow_pattern_threshold) {
688            problems
689                .push("detection_slow_pattern_threshold must be between 0.01 and 1.0".to_owned());
690        }
691        if !(100..=10_000).contains(&self.detection_monitor_history_size) {
692            problems
693                .push("detection_monitor_history_size must be between 100 and 10000".to_owned());
694        }
695        if !(100..=5000).contains(&self.detection_max_tracked_patterns) {
696            problems.push("detection_max_tracked_patterns must be between 100 and 5000".to_owned());
697        }
698        if !(1.0..=3600.0).contains(&self.detection_anomaly_emission_cooldown) {
699            problems.push(
700                "detection_anomaly_emission_cooldown must be between 1.0 and 3600.0".to_owned(),
701            );
702        }
703        if !(10..=1000).contains(&self.detection_min_samples_for_anomaly) {
704            problems
705                .push("detection_min_samples_for_anomaly must be between 10 and 1000".to_owned());
706        }
707        if !(0.0..=10.0).contains(&self.detection_threat_score_threshold) {
708            problems
709                .push("detection_threat_score_threshold must be between 0.0 and 10.0".to_owned());
710        }
711        if self
712            .agent_high_watermark_ratio
713            .is_some_and(|ratio| ratio <= 0.0 || ratio > 1.0)
714        {
715            problems
716                .push("agent_high_watermark_ratio must be between 0 (exclusive) and 1".to_owned());
717        }
718        if self
719            .agent_max_concurrent_flushes
720            .is_some_and(|value| value < 1)
721        {
722            problems.push("agent_max_concurrent_flushes must be at least 1 when set".to_owned());
723        }
724        if self.agent_backoff_factor.is_some_and(|value| value <= 0.0) {
725            problems.push("agent_backoff_factor must be positive when set".to_owned());
726        }
727
728        for (field, entries) in [
729            ("whitelist", self.whitelist.as_deref().unwrap_or(&[])),
730            ("blacklist", &self.blacklist[..]),
731            ("exempt_ips", &self.exempt_ips[..]),
732        ] {
733            for entry in entries {
734                if parse_ip_or_cidr(entry).is_none() {
735                    problems.push(format!("{field}: invalid IP or CIDR range ({entry})"));
736                }
737            }
738        }
739        for entry in &self.trusted_proxies {
740            if entry != "unix" && parse_ip_or_cidr(entry).is_none() {
741                problems.push(format!(
742                    "trusted_proxies: invalid proxy IP or CIDR range ({entry})"
743                ));
744            }
745        }
746
747        if self.enable_agent && self.agent_api_key.is_none() {
748            problems.push("agent_api_key is required when enable_agent is true".to_owned());
749        }
750        if self.enable_dynamic_rules && !self.enable_agent {
751            problems.push("enable_agent must be true when enable_dynamic_rules is true".to_owned());
752        }
753        if self.enable_enrichment && !self.enable_agent {
754            problems.push("enable_enrichment requires enable_agent=true".to_owned());
755        }
756
757        if problems.is_empty() {
758            Ok(())
759        } else {
760            Err(SecurityConfigError { problems })
761        }
762    }
763
764    /// The `IpBanConfig` view of the ban group (`enable_ip_banning`,
765    /// `auto_ban_threshold`, `auto_ban_duration`, `threat_ban_config`).
766    #[must_use]
767    pub fn ip_ban_config(&self) -> IpBanConfig {
768        IpBanConfig {
769            enable_ip_banning: self.enable_ip_banning,
770            auto_ban_threshold: self.auto_ban_threshold,
771            auto_ban_duration: self.auto_ban_duration,
772            threat_ban_config: self.threat_ban_config.clone().into_iter().collect(),
773        }
774    }
775}
776
777/// Parse one list entry as an IP address or a CIDR range (the reference
778/// `_validate_ip_or_cidr_list` accepts both for every IP list; the prefix
779/// length is family-bounded, 32 for v4 and 128 for v6).
780fn parse_ip_or_cidr(entry: &str) -> Option<IpAddr> {
781    if let Ok(ip) = entry.parse::<IpAddr>() {
782        return Some(ip);
783    }
784    let (address, prefix) = entry.split_once('/')?;
785    let prefix = prefix.parse::<u8>().ok()?;
786    let address = address.parse::<IpAddr>().ok()?;
787    let max_prefix = match address {
788        IpAddr::V4(_) => 32,
789        IpAddr::V6(_) => 128,
790    };
791    (prefix <= max_prefix).then_some(address)
792}
793
794/// Every reference violation at once: the config error
795/// [`SecurityConfig::validate`] fails closed with.
796#[derive(Debug, Clone, PartialEq, Eq)]
797pub struct SecurityConfigError {
798    /// Every violation, field-named.
799    pub problems: Vec<String>,
800}
801
802impl std::fmt::Display for SecurityConfigError {
803    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
804        write!(
805            f,
806            "invalid SecurityConfig ({} problems)",
807            self.problems.len()
808        )
809    }
810}
811
812impl std::error::Error for SecurityConfigError {}
813
814#[cfg(test)]
815// The tests construct a default config and mutate one group at a time;
816// literal 129-field initializers would be unreadable.
817#[allow(clippy::field_reassign_with_default)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn defaults_satisfy_every_constraint() {
823        // Through `new` (the reference constructor shape) and through
824        // `Default`: both carry the reference defaults.
825        assert!(SecurityConfig::new().validate().is_ok());
826        assert!(SecurityConfig::default().validate().is_ok());
827    }
828
829    #[test]
830    #[allow(clippy::too_many_lines)]
831    fn every_reference_default_is_carried() {
832        let config = SecurityConfig::default();
833        assert!(config.trusted_proxies.is_empty());
834        assert_eq!(config.trusted_proxy_depth, 1);
835        assert!(!config.trust_x_forwarded_proto);
836        assert!(!config.passive_mode);
837        assert!(config.geo_ip_handler.is_none());
838        assert!(config.enable_redis);
839        assert_eq!(config.redis_url.as_deref(), Some("redis://localhost:6379"));
840        assert_eq!(config.redis_prefix, "guard_core:");
841        assert!(
842            config
843                .redis_socket_connect_timeout
844                .is_some_and(|value| (value - 2.0).abs() < f64::EPSILON)
845        );
846        assert!(
847            config
848                .redis_socket_timeout
849                .is_some_and(|value| (value - 2.0).abs() < f64::EPSILON)
850        );
851        assert_eq!(config.redis_health_check_interval, 30);
852        assert_eq!(config.redis_max_connections, None);
853        assert_eq!(config.redis_retries, 1);
854        assert_eq!(config.whitelist, None);
855        assert!(config.blacklist.is_empty());
856        assert!(config.exempt_ips.is_empty());
857        assert!(config.whitelist_countries.is_empty());
858        assert!(config.blocked_countries.is_empty());
859        assert!(config.blocked_user_agents.is_empty());
860        assert_eq!(config.auto_ban_threshold, 10);
861        assert_eq!(config.auto_ban_duration, 3600);
862        assert!(config.threat_ban_config.is_empty());
863        assert!(!config.enable_rate_limit_auto_ban);
864        assert!(config.global_behavior_rules.is_empty());
865        assert!(!config.behavior_scan_response_body);
866        assert_eq!(config.behavior_max_response_body_inspect_bytes, 262_144);
867        assert!((config.body_read_timeout - 3.0).abs() < f64::EPSILON);
868        assert_eq!(config.sync_body_read_max_concurrent, 64);
869        assert_eq!(config.custom_log_file, None);
870        assert_eq!(config.log_suspicious_level, Some(LogLevel::Warning));
871        assert_eq!(config.log_request_level, None);
872        assert_eq!(config.log_country_check_level, Some(LogLevel::Info));
873        assert_eq!(config.log_format, LogFormat::Text);
874        assert!(config.custom_error_responses.is_empty());
875        assert_eq!(config.rate_limit, 10);
876        assert_eq!(config.rate_limit_window, 60);
877        assert!(!config.enforce_https);
878        assert_eq!(
879            config.security_headers,
880            SecurityHeadersConfig::reference_default()
881        );
882        assert!(config.custom_request_check.is_none());
883        assert!(config.auth_verifier.is_none());
884        assert!(config.custom_response_modifier.is_none());
885        assert!(config.on_block.is_none());
886        assert!(config.on_error.is_none());
887        assert!(!config.enable_cors);
888        assert_eq!(config.cors_allow_origins, ["*"]);
889        assert_eq!(
890            config.cors_allow_methods,
891            ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
892        );
893        assert_eq!(config.cors_allow_headers, ["*"]);
894        assert!(!config.cors_allow_credentials);
895        assert!(config.cors_expose_headers.is_empty());
896        assert_eq!(config.cors_max_age, 600);
897        assert_eq!(config.block_cloud_providers, None);
898        assert_eq!(config.cloud_ip_refresh_interval, 3600);
899        assert!(config.lazy_init);
900        assert_eq!(config.geo_ip_db_max_age, 86_400);
901        assert!(config.cloud_ip_store.is_none());
902        assert_eq!(
903            config.exclude_paths,
904            [
905                "/docs",
906                "/redoc",
907                "/openapi.json",
908                "/openapi.yaml",
909                "/favicon.ico",
910                "/static"
911            ]
912        );
913        assert!(config.enable_ip_banning);
914        assert!(config.enable_rate_limiting);
915        assert!(config.enable_penetration_detection);
916        assert!(config.fail_secure);
917        assert!(!config.redis_fail_open);
918        assert!(!config.route_resolution_strict);
919        assert_eq!(config.ipinfo_token, None);
920        assert_eq!(
921            config.ipinfo_db_path.as_deref(),
922            Some(std::path::Path::new("data/ipinfo/country_asn.mmdb"))
923        );
924        assert!(!config.enable_agent);
925        assert_eq!(config.agent_api_key, None);
926        assert!(!config.agent_strict);
927        assert_eq!(config.agent_endpoint, "https://api.guard-core.com");
928        assert_eq!(config.agent_project_id, None);
929        assert_eq!(config.agent_buffer_size, 100);
930        assert_eq!(config.agent_flush_interval, 30);
931        assert!(config.agent_enable_events);
932        assert!(config.agent_enable_metrics);
933        assert_eq!(config.agent_timeout, 30);
934        assert_eq!(config.agent_retry_attempts, 3);
935        assert_eq!(config.agent_project_encryption_key, None);
936        assert_eq!(config.agent_guard_version, None);
937        assert_eq!(config.agent_high_watermark_ratio, None);
938        assert_eq!(config.agent_max_concurrent_flushes, None);
939        assert_eq!(config.agent_buffer_overflow_policy, None);
940        assert_eq!(config.agent_backoff_factor, None);
941        assert_eq!(config.agent_sensitive_headers, None);
942        assert_eq!(config.agent_max_payload_size, None);
943        assert_eq!(config.agent_compression_enabled, None);
944        assert_eq!(config.agent_compression_threshold, None);
945        assert_eq!(config.agent_install_id, None);
946        assert_eq!(config.agent_payload_signing_secret, None);
947        assert!(!config.enable_dynamic_rules);
948        assert_eq!(config.dynamic_rule_interval, 300);
949        assert_eq!(config.dynamic_rules_cache_path, None);
950        assert_eq!(config.agent_status_interval, 300);
951        assert!(!config.emergency_mode);
952        assert!(config.emergency_whitelist.is_empty());
953        assert!(config.endpoint_rate_limits.is_empty());
954        assert!((config.detection_compiler_timeout - 2.0).abs() < f64::EPSILON);
955        assert_eq!(config.detection_pattern_validation_cache_path, None);
956        assert_eq!(config.detection_max_content_length, 10_000);
957        assert_eq!(config.detection_max_body_inspect_bytes, 262_144);
958        assert_eq!(config.detection_max_scan_values, 512);
959        assert_eq!(config.detection_max_scan_chars, 65_536);
960        assert_eq!(config.detection_max_json_depth, 32);
961        assert_eq!(config.detection_binary_min_run_length, 16);
962        assert!(config.detection_preserve_attack_patterns);
963        assert!((config.detection_semantic_threshold - 0.7).abs() < f64::EPSILON);
964        assert!((config.detection_anomaly_threshold - 3.0).abs() < f64::EPSILON);
965        assert!((config.detection_slow_pattern_threshold - 0.1).abs() < f64::EPSILON);
966        assert_eq!(config.detection_monitor_history_size, 1000);
967        assert_eq!(config.detection_max_tracked_patterns, 1000);
968        assert!((config.detection_anomaly_emission_cooldown - 60.0).abs() < f64::EPSILON);
969        assert_eq!(config.detection_min_samples_for_anomaly, 30);
970        assert!((config.detection_threat_score_threshold - 1.0).abs() < f64::EPSILON);
971        assert!(config.muted_event_types.is_empty());
972        assert!(config.muted_metric_types.is_empty());
973        assert!(config.muted_check_logs.is_empty());
974        assert!(config.log_sensitive_headers.is_empty());
975        assert!(config.log_sensitive_params.is_empty());
976        assert!(config.log_sensitive_body_fields.is_empty());
977        assert!(!config.enable_otel);
978        assert_eq!(config.otel_service_name, "guard-core");
979        assert_eq!(config.otel_exporter_endpoint, None);
980        assert!(config.otel_resource_attributes.is_empty());
981        assert!(!config.enable_logfire);
982        assert_eq!(config.logfire_service_name, "guard-core");
983        assert!(!config.enable_enrichment);
984        assert!(config.excluded_detection_headers.is_empty());
985        assert!(config.excluded_detection_params.is_empty());
986        assert!(config.excluded_detection_body_fields.is_empty());
987        assert!(config.detection_scan_body);
988        assert_eq!(
989            config.enabled_detection_categories,
990            ALL_DETECTION_CATEGORIES
991                .iter()
992                .map(|category| (*category).to_owned())
993                .collect::<BTreeSet<String>>()
994        );
995    }
996
997    #[test]
998    fn the_category_table_is_the_reference_set() {
999        assert_eq!(ALL_DETECTION_CATEGORIES.len(), 19);
1000        assert!(ALL_DETECTION_CATEGORIES.contains(&"xss"));
1001        assert!(ALL_DETECTION_CATEGORIES.contains(&"deserialization"));
1002    }
1003
1004    #[test]
1005    fn the_log_level_round_trips_the_reference_literals() {
1006        assert_eq!(LogLevel::Info.as_str(), "INFO");
1007        assert_eq!(LogLevel::Debug.as_str(), "DEBUG");
1008        assert_eq!(LogLevel::Warning.as_str(), "WARNING");
1009        assert_eq!(LogLevel::Error.as_str(), "ERROR");
1010        assert_eq!(LogLevel::Critical.as_str(), "CRITICAL");
1011    }
1012
1013    #[test]
1014    fn the_log_format_round_trips_the_reference_literals() {
1015        assert_eq!(LogFormat::Text.as_str(), "text");
1016        assert_eq!(LogFormat::Json.as_str(), "json");
1017    }
1018
1019    #[test]
1020    fn the_overflow_policy_round_trips_the_reference_literals() {
1021        assert_eq!(BufferOverflowPolicy::Drop.as_str(), "drop");
1022        assert_eq!(BufferOverflowPolicy::Block.as_str(), "block");
1023        assert_eq!(BufferOverflowPolicy::Raise.as_str(), "raise");
1024    }
1025
1026    /// Every bound constraint, one mutating case per violation: the error
1027    /// names the field and validation keeps collecting the rest.
1028    #[test]
1029    #[allow(clippy::too_many_lines)]
1030    fn every_bound_constraint_names_its_field() {
1031        type Mutator = Box<dyn Fn(&mut SecurityConfig)>;
1032        let cases: Vec<(&str, Mutator)> = vec![
1033            (
1034                "trusted_proxy_depth",
1035                Box::new(|config: &mut SecurityConfig| config.trusted_proxy_depth = 0),
1036            ),
1037            (
1038                "redis_socket_connect_timeout",
1039                Box::new(|config: &mut SecurityConfig| {
1040                    config.redis_socket_connect_timeout = Some(0.0);
1041                }),
1042            ),
1043            (
1044                "redis_socket_timeout",
1045                Box::new(|config: &mut SecurityConfig| config.redis_socket_timeout = Some(-1.0)),
1046            ),
1047            (
1048                "redis_max_connections",
1049                Box::new(|config: &mut SecurityConfig| config.redis_max_connections = Some(0)),
1050            ),
1051            (
1052                "auto_ban_threshold",
1053                Box::new(|config: &mut SecurityConfig| config.auto_ban_threshold = 0),
1054            ),
1055            (
1056                "auto_ban_duration",
1057                Box::new(|config: &mut SecurityConfig| config.auto_ban_duration = 0),
1058            ),
1059            (
1060                "behavior_max_response_body_inspect_bytes",
1061                Box::new(|config: &mut SecurityConfig| {
1062                    config.behavior_max_response_body_inspect_bytes = 1023;
1063                }),
1064            ),
1065            (
1066                "body_read_timeout",
1067                Box::new(|config: &mut SecurityConfig| config.body_read_timeout = 0.0),
1068            ),
1069            (
1070                "body_read_timeout",
1071                Box::new(|config: &mut SecurityConfig| config.body_read_timeout = 30.1),
1072            ),
1073            (
1074                "sync_body_read_max_concurrent",
1075                Box::new(|config: &mut SecurityConfig| {
1076                    config.sync_body_read_max_concurrent = 0;
1077                }),
1078            ),
1079            (
1080                "cloud_ip_refresh_interval",
1081                Box::new(|config: &mut SecurityConfig| config.cloud_ip_refresh_interval = 59),
1082            ),
1083            (
1084                "geo_ip_db_max_age",
1085                Box::new(|config: &mut SecurityConfig| config.geo_ip_db_max_age = 3599),
1086            ),
1087            (
1088                "dynamic_rule_interval",
1089                Box::new(|config: &mut SecurityConfig| config.dynamic_rule_interval = 59),
1090            ),
1091            (
1092                "agent_status_interval",
1093                Box::new(|config: &mut SecurityConfig| config.agent_status_interval = 59),
1094            ),
1095            (
1096                "detection_compiler_timeout",
1097                Box::new(|config: &mut SecurityConfig| config.detection_compiler_timeout = 0.0),
1098            ),
1099            (
1100                "detection_max_content_length",
1101                Box::new(|config: &mut SecurityConfig| config.detection_max_content_length = 999),
1102            ),
1103            (
1104                "detection_max_body_inspect_bytes",
1105                Box::new(|config: &mut SecurityConfig| {
1106                    config.detection_max_body_inspect_bytes = 1023;
1107                }),
1108            ),
1109            (
1110                "detection_max_scan_values",
1111                Box::new(|config: &mut SecurityConfig| config.detection_max_scan_values = 1),
1112            ),
1113            (
1114                "detection_max_scan_chars",
1115                Box::new(|config: &mut SecurityConfig| config.detection_max_scan_chars = 1023),
1116            ),
1117            (
1118                "detection_max_json_depth",
1119                Box::new(|config: &mut SecurityConfig| config.detection_max_json_depth = 0),
1120            ),
1121            (
1122                "detection_binary_min_run_length",
1123                Box::new(|config: &mut SecurityConfig| {
1124                    config.detection_binary_min_run_length = 3;
1125                }),
1126            ),
1127            (
1128                "detection_semantic_threshold",
1129                Box::new(|config: &mut SecurityConfig| config.detection_semantic_threshold = 1.1),
1130            ),
1131            (
1132                "detection_anomaly_threshold",
1133                Box::new(|config: &mut SecurityConfig| config.detection_anomaly_threshold = 0.9),
1134            ),
1135            (
1136                "detection_slow_pattern_threshold",
1137                Box::new(|config: &mut SecurityConfig| {
1138                    config.detection_slow_pattern_threshold = 0.0;
1139                }),
1140            ),
1141            (
1142                "detection_monitor_history_size",
1143                Box::new(|config: &mut SecurityConfig| {
1144                    config.detection_monitor_history_size = 99;
1145                }),
1146            ),
1147            (
1148                "detection_max_tracked_patterns",
1149                Box::new(|config: &mut SecurityConfig| {
1150                    config.detection_max_tracked_patterns = 99;
1151                }),
1152            ),
1153            (
1154                "detection_anomaly_emission_cooldown",
1155                Box::new(|config: &mut SecurityConfig| {
1156                    config.detection_anomaly_emission_cooldown = 0.9;
1157                }),
1158            ),
1159            (
1160                "detection_min_samples_for_anomaly",
1161                Box::new(|config: &mut SecurityConfig| {
1162                    config.detection_min_samples_for_anomaly = 9;
1163                }),
1164            ),
1165            (
1166                "detection_threat_score_threshold",
1167                Box::new(|config: &mut SecurityConfig| {
1168                    config.detection_threat_score_threshold = 10.1;
1169                }),
1170            ),
1171            (
1172                "agent_high_watermark_ratio",
1173                Box::new(|config: &mut SecurityConfig| {
1174                    config.agent_high_watermark_ratio = Some(1.1);
1175                }),
1176            ),
1177            (
1178                "agent_max_concurrent_flushes",
1179                Box::new(|config: &mut SecurityConfig| {
1180                    config.agent_max_concurrent_flushes = Some(0);
1181                }),
1182            ),
1183            (
1184                "agent_backoff_factor",
1185                Box::new(|config: &mut SecurityConfig| config.agent_backoff_factor = Some(0.0)),
1186            ),
1187        ];
1188        for (field, mutate) in cases {
1189            let mut config = SecurityConfig::default();
1190            mutate(&mut config);
1191            let error = config.validate().unwrap_err();
1192            assert!(
1193                error.problems.iter().any(|problem| problem.contains(field)),
1194                "{field}: {:?}",
1195                error.problems
1196            );
1197        }
1198    }
1199
1200    #[test]
1201    fn the_ip_lists_reject_garbage_and_accept_ips_and_cidrs() {
1202        let mut config = SecurityConfig::default();
1203        config.whitelist = Some(vec![String::from("10.0.0.1"), String::from("10.0.0.0/8")]);
1204        config.blacklist = vec![String::from("2001:db8::1"), String::from("2001:db8::/32")];
1205        config.exempt_ips = vec![String::from("192.0.2.1")];
1206        config.trusted_proxies = vec![String::from("unix"), String::from("10.1.0.0/16")];
1207        assert!(config.validate().is_ok());
1208
1209        config.exempt_ips = vec![String::from("not-an-ip")];
1210        let error = config.validate().unwrap_err();
1211        assert!(
1212            error
1213                .problems
1214                .iter()
1215                .any(|problem| problem.contains("exempt_ips"))
1216        );
1217
1218        config.trusted_proxies = vec![String::from("also-bad")];
1219        config.whitelist = Some(vec![String::from("10.0.0.0/99")]);
1220        let error = config.validate().unwrap_err();
1221        assert!(
1222            error
1223                .problems
1224                .iter()
1225                .any(|problem| problem.contains("trusted_proxies"))
1226        );
1227        assert!(
1228            error
1229                .problems
1230                .iter()
1231                .any(|problem| problem.contains("whitelist"))
1232        );
1233    }
1234
1235    #[test]
1236    fn the_agent_cross_rules_hold() {
1237        let mut config = SecurityConfig::default();
1238        config.enable_agent = true;
1239        let error = config.validate().unwrap_err();
1240        assert!(
1241            error
1242                .problems
1243                .iter()
1244                .any(|problem| problem.contains("agent_api_key"))
1245        );
1246
1247        let mut config = SecurityConfig::default();
1248        config.enable_dynamic_rules = true;
1249        let error = config.validate().unwrap_err();
1250        assert!(
1251            error
1252                .problems
1253                .iter()
1254                .any(|problem| problem.contains("enable_agent"))
1255        );
1256
1257        let mut config = SecurityConfig::default();
1258        config.enable_enrichment = true;
1259        let error = config.validate().unwrap_err();
1260        assert!(
1261            error
1262                .problems
1263                .iter()
1264                .any(|problem| problem.contains("enable_enrichment"))
1265        );
1266
1267        // The satisfied shape: everything the toggles demand is present.
1268        let mut config = SecurityConfig::default();
1269        config.enable_agent = true;
1270        config.agent_api_key = Some(String::from("k"));
1271        config.enable_dynamic_rules = true;
1272        config.enable_enrichment = true;
1273        assert!(config.validate().is_ok());
1274    }
1275
1276    #[test]
1277    fn validation_collects_every_problem_at_once() {
1278        let mut config = SecurityConfig::default();
1279        config.auto_ban_threshold = 0;
1280        config.auto_ban_duration = 0;
1281        config.enable_dynamic_rules = true;
1282        let error = config.validate().unwrap_err();
1283        assert_eq!(error.problems.len(), 3);
1284        assert_eq!(error.to_string(), "invalid SecurityConfig (3 problems)");
1285    }
1286
1287    #[test]
1288    fn the_ban_group_maps_onto_the_engine_config() {
1289        let mut config = SecurityConfig::default();
1290        config.enable_ip_banning = false;
1291        config.auto_ban_threshold = 5;
1292        config.auto_ban_duration = 60;
1293        config.threat_ban_config.insert(
1294            String::from("rate_limit"),
1295            ThreatBanEntry {
1296                threshold: 3,
1297                duration: 120,
1298            },
1299        );
1300        let ban_config = config.ip_ban_config();
1301        assert!(!ban_config.enable_ip_banning);
1302        assert_eq!(ban_config.auto_ban_threshold, 5);
1303        assert_eq!(ban_config.auto_ban_duration, 60);
1304        assert_eq!(ban_config.threat_ban_config.len(), 1);
1305    }
1306}