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`). Lets the `SaaS`
347    /// dashboard change protection at runtime without a code change: a rule
348    /// bans the IPs in its `ip_blacklist` (for `ip_ban_duration` seconds),
349    /// unbans the IPs in its `ip_whitelist`, adds its `suspicious_patterns`
350    /// to detection, and replaces `blocked_countries`, `whitelist_countries`,
351    /// `rate_limit`, `rate_limit_window`, `endpoint_rate_limits`,
352    /// `block_cloud_providers`, `blocked_user_agents`,
353    /// `enable_penetration_detection`, `enable_ip_banning`,
354    /// `enable_rate_limiting`, `enable_rate_limit_auto_ban`,
355    /// `auto_ban_threshold`, `auto_ban_duration`, `emergency_mode` and
356    /// `emergency_whitelist`. A rule cannot change any other setting.
357    pub enable_dynamic_rules: bool,
358    /// `dynamic_rule_interval` (seconds, at least 60).
359    pub dynamic_rule_interval: u64,
360    /// `dynamic_rules_cache_path`.
361    pub dynamic_rules_cache_path: Option<PathBuf>,
362    /// `agent_status_interval` (seconds, 60..=86400).
363    pub agent_status_interval: u64,
364    /// `emergency_mode`.
365    pub emergency_mode: bool,
366    /// `emergency_whitelist`.
367    pub emergency_whitelist: Vec<String>,
368    /// `endpoint_rate_limits`: path pattern to (count, window seconds).
369    pub endpoint_rate_limits: BTreeMap<String, (u32, u64)>,
370    /// `detection_compiler_timeout` (seconds, 0.1..=10.0).
371    pub detection_compiler_timeout: f64,
372    /// `detection_max_scan_seconds` (seconds, 0.1..=60.0): the per-request
373    /// cap on the summed regex scan CPU time, not wall time. Once spent the
374    /// remaining patterns and values are skipped and a weighted
375    /// `pattern_timeout` threat blocks the request. See
376    /// [`crate::scan_budget`].
377    pub detection_max_scan_seconds: f64,
378    /// `detection_pattern_validation_cache_path`.
379    pub detection_pattern_validation_cache_path: Option<String>,
380    /// `detection_max_content_length` (1000..=100000).
381    pub detection_max_content_length: usize,
382    /// `detection_max_body_inspect_bytes` (1024..=10485760).
383    pub detection_max_body_inspect_bytes: usize,
384    /// `detection_max_scan_values` (2..=100000).
385    pub detection_max_scan_values: usize,
386    /// `detection_max_scan_chars` (1024..=262144).
387    pub detection_max_scan_chars: usize,
388    /// `detection_max_json_depth` (1..=1000).
389    pub detection_max_json_depth: usize,
390    /// `detection_binary_min_run_length` (4..=1024).
391    pub detection_binary_min_run_length: usize,
392    /// `detection_preserve_attack_patterns`.
393    pub detection_preserve_attack_patterns: bool,
394    /// `detection_semantic_threshold` (0.0..=1.0).
395    pub detection_semantic_threshold: f64,
396    /// `detection_anomaly_threshold` (1.0..=10.0).
397    pub detection_anomaly_threshold: f64,
398    /// `detection_slow_pattern_threshold` (0.01..=1.0).
399    pub detection_slow_pattern_threshold: f64,
400    /// `detection_monitor_history_size` (100..=10000).
401    pub detection_monitor_history_size: usize,
402    /// `detection_max_tracked_patterns` (100..=5000).
403    pub detection_max_tracked_patterns: usize,
404    /// `detection_anomaly_emission_cooldown` (seconds, 1.0..=3600.0).
405    pub detection_anomaly_emission_cooldown: f64,
406    /// `detection_min_samples_for_anomaly` (10..=1000).
407    pub detection_min_samples_for_anomaly: usize,
408    /// `detection_threat_score_threshold` (0.0..=10.0).
409    pub detection_threat_score_threshold: f64,
410    /// `muted_event_types`.
411    pub muted_event_types: BTreeSet<String>,
412    /// `muted_metric_types`.
413    pub muted_metric_types: BTreeSet<String>,
414    /// `muted_check_logs`.
415    pub muted_check_logs: BTreeSet<String>,
416    /// `log_sensitive_headers`.
417    pub log_sensitive_headers: BTreeSet<String>,
418    /// `log_sensitive_params`.
419    pub log_sensitive_params: BTreeSet<String>,
420    /// `log_sensitive_body_fields`.
421    pub log_sensitive_body_fields: BTreeSet<String>,
422    /// `enable_otel`.
423    pub enable_otel: bool,
424    /// `otel_service_name`.
425    pub otel_service_name: String,
426    /// `otel_exporter_endpoint`.
427    pub otel_exporter_endpoint: Option<String>,
428    /// `otel_resource_attributes`.
429    pub otel_resource_attributes: BTreeMap<String, String>,
430    /// `enable_logfire`.
431    pub enable_logfire: bool,
432    /// `logfire_service_name`.
433    pub logfire_service_name: String,
434    /// `enable_enrichment` (requires `enable_agent`).
435    pub enable_enrichment: bool,
436    /// `excluded_detection_headers`.
437    pub excluded_detection_headers: BTreeSet<String>,
438    /// `excluded_semantic_detection_headers` (opt-in, default empty):
439    /// header names whose values skip the semantic analyzer only, matched
440    /// case-insensitively; regex category scanning is unchanged. Useful for
441    /// apps whose JWT session cookies score near the semantic threshold
442    /// (for example `cookie`, `authorization`).
443    pub excluded_semantic_detection_headers: BTreeSet<String>,
444    /// `excluded_detection_params`.
445    pub excluded_detection_params: BTreeSet<String>,
446    /// `excluded_detection_body_fields`.
447    pub excluded_detection_body_fields: BTreeSet<String>,
448    /// `detection_scan_body`.
449    pub detection_scan_body: bool,
450    /// `enabled_detection_categories`
451    /// (defaults to [`ALL_DETECTION_CATEGORIES`]).
452    pub enabled_detection_categories: BTreeSet<String>,
453}
454
455impl Default for SecurityConfig {
456    /// Every field at the reference default
457    /// (`guard_core/_security_config_fields.py`); the length is the
458    /// 129-field default table itself.
459    #[allow(clippy::too_many_lines)]
460    fn default() -> Self {
461        Self {
462            trusted_proxies: Vec::new(),
463            trusted_proxy_depth: 1,
464            trust_x_forwarded_proto: false,
465            passive_mode: false,
466            geo_ip_handler: None,
467            enable_redis: true,
468            redis_url: Some(String::from("redis://localhost:6379")),
469            redis_prefix: String::from("guard_core:"),
470            redis_socket_connect_timeout: Some(2.0),
471            redis_socket_timeout: Some(2.0),
472            redis_health_check_interval: 30,
473            redis_max_connections: None,
474            redis_retries: 1,
475            whitelist: None,
476            blacklist: Vec::new(),
477            exempt_ips: Vec::new(),
478            whitelist_countries: BTreeSet::new(),
479            blocked_countries: BTreeSet::new(),
480            blocked_user_agents: Vec::new(),
481            auto_ban_threshold: 10,
482            auto_ban_duration: 3600,
483            threat_ban_config: BTreeMap::new(),
484            enable_rate_limit_auto_ban: false,
485            global_behavior_rules: Vec::new(),
486            behavior_scan_response_body: false,
487            behavior_max_response_body_inspect_bytes: 262_144,
488            body_read_timeout: 3.0,
489            sync_body_read_max_concurrent: 64,
490            custom_log_file: None,
491            log_suspicious_level: Some(LogLevel::Warning),
492            log_request_level: None,
493            log_country_check_level: Some(LogLevel::Info),
494            log_format: LogFormat::Text,
495            custom_error_responses: BTreeMap::new(),
496            rate_limit: 10,
497            rate_limit_window: 60,
498            enforce_https: false,
499            security_headers: SecurityHeadersConfig::reference_default(),
500            custom_request_check: None,
501            auth_verifier: None,
502            custom_response_modifier: None,
503            on_block: None,
504            on_error: None,
505            enable_cors: false,
506            cors_allow_origins: vec![String::from("*")],
507            cors_allow_methods: vec![
508                String::from("GET"),
509                String::from("POST"),
510                String::from("PUT"),
511                String::from("PATCH"),
512                String::from("DELETE"),
513                String::from("OPTIONS"),
514            ],
515            cors_allow_headers: vec![String::from("*")],
516            cors_allow_credentials: false,
517            cors_expose_headers: Vec::new(),
518            cors_max_age: 600,
519            block_cloud_providers: None,
520            cloud_ip_refresh_interval: 3600,
521            lazy_init: true,
522            geo_ip_db_max_age: 86_400,
523            cloud_ip_store: None,
524            exclude_paths: vec![
525                String::from("/docs"),
526                String::from("/redoc"),
527                String::from("/openapi.json"),
528                String::from("/openapi.yaml"),
529                String::from("/favicon.ico"),
530                String::from("/static"),
531            ],
532            enable_ip_banning: true,
533            enable_rate_limiting: true,
534            enable_penetration_detection: true,
535            fail_secure: true,
536            redis_fail_open: false,
537            route_resolution_strict: false,
538            ipinfo_token: None,
539            ipinfo_db_path: Some(PathBuf::from("data/ipinfo/country_asn.mmdb")),
540            enable_agent: false,
541            agent_api_key: None,
542            agent_strict: false,
543            agent_endpoint: String::from("https://api.guard-core.com"),
544            agent_project_id: None,
545            agent_buffer_size: 100,
546            agent_flush_interval: 30,
547            agent_enable_events: true,
548            agent_enable_metrics: true,
549            agent_timeout: 30,
550            agent_retry_attempts: 3,
551            agent_project_encryption_key: None,
552            agent_guard_version: None,
553            agent_high_watermark_ratio: None,
554            agent_max_concurrent_flushes: None,
555            agent_buffer_overflow_policy: None,
556            agent_backoff_factor: None,
557            agent_sensitive_headers: None,
558            agent_max_payload_size: None,
559            agent_compression_enabled: None,
560            agent_compression_threshold: None,
561            agent_install_id: None,
562            agent_payload_signing_secret: None,
563            enable_dynamic_rules: false,
564            dynamic_rule_interval: 300,
565            dynamic_rules_cache_path: None,
566            agent_status_interval: 300,
567            emergency_mode: false,
568            emergency_whitelist: Vec::new(),
569            endpoint_rate_limits: BTreeMap::new(),
570            detection_compiler_timeout: 2.0,
571            detection_max_scan_seconds: 10.0,
572            detection_pattern_validation_cache_path: None,
573            detection_max_content_length: 10_000,
574            detection_max_body_inspect_bytes: 262_144,
575            detection_max_scan_values: 512,
576            detection_max_scan_chars: 65_536,
577            detection_max_json_depth: 32,
578            detection_binary_min_run_length: 16,
579            detection_preserve_attack_patterns: true,
580            detection_semantic_threshold: 0.7,
581            detection_anomaly_threshold: 3.0,
582            detection_slow_pattern_threshold: 0.1,
583            detection_monitor_history_size: 1000,
584            detection_max_tracked_patterns: 1000,
585            detection_anomaly_emission_cooldown: 60.0,
586            detection_min_samples_for_anomaly: 30,
587            detection_threat_score_threshold: 1.0,
588            muted_event_types: BTreeSet::new(),
589            muted_metric_types: BTreeSet::new(),
590            muted_check_logs: BTreeSet::new(),
591            log_sensitive_headers: BTreeSet::new(),
592            log_sensitive_params: BTreeSet::new(),
593            log_sensitive_body_fields: BTreeSet::new(),
594            enable_otel: false,
595            otel_service_name: String::from("guard-core"),
596            otel_exporter_endpoint: None,
597            otel_resource_attributes: BTreeMap::new(),
598            enable_logfire: false,
599            logfire_service_name: String::from("guard-core"),
600            enable_enrichment: false,
601            excluded_detection_headers: BTreeSet::new(),
602            excluded_semantic_detection_headers: BTreeSet::new(),
603            excluded_detection_params: BTreeSet::new(),
604            excluded_detection_body_fields: BTreeSet::new(),
605            detection_scan_body: true,
606            enabled_detection_categories: ALL_DETECTION_CATEGORIES
607                .iter()
608                .map(|category| (*category).to_owned())
609                .collect(),
610        }
611    }
612}
613
614impl SecurityConfig {
615    /// The reference-default configuration. Validation still runs before
616    /// use; the defaults satisfy every constraint.
617    #[must_use]
618    pub fn new() -> Self {
619        Self::default()
620    }
621
622    /// Validate every reference constraint, collecting all violations.
623    ///
624    /// The length is the reference constraint list, one check per
625    /// pydantic `gt`/`ge`/`le` bound plus the list parsers and the
626    /// cross-field agent rules; every violation is collected, none
627    /// short-circuits.
628    ///
629    /// # Errors
630    ///
631    /// [`SecurityConfigError`] carrying every problem, fail-closed.
632    #[allow(clippy::too_many_lines)]
633    pub fn validate(&self) -> Result<(), SecurityConfigError> {
634        let mut problems = Vec::new();
635
636        if self.trusted_proxy_depth < 1 {
637            problems.push("trusted_proxy_depth must be at least 1".to_owned());
638        }
639        if self
640            .redis_socket_connect_timeout
641            .is_some_and(|value| value <= 0.0)
642        {
643            problems.push("redis_socket_connect_timeout must be positive when set".to_owned());
644        }
645        if self.redis_socket_timeout.is_some_and(|value| value <= 0.0) {
646            problems.push("redis_socket_timeout must be positive when set".to_owned());
647        }
648        if self.redis_max_connections.is_some_and(|value| value < 1) {
649            problems.push("redis_max_connections must be at least 1 when set".to_owned());
650        }
651        if self.auto_ban_threshold < 1 {
652            problems.push("auto_ban_threshold must be at least 1".to_owned());
653        }
654        if self.auto_ban_duration < 1 {
655            problems.push("auto_ban_duration must be at least 1".to_owned());
656        }
657        if !(1024..=10_485_760).contains(&self.behavior_max_response_body_inspect_bytes) {
658            problems.push(
659                "behavior_max_response_body_inspect_bytes must be between 1024 and 10485760"
660                    .to_owned(),
661            );
662        }
663        if self.body_read_timeout <= 0.0 || self.body_read_timeout > 30.0 {
664            problems.push("body_read_timeout must be between 0 (exclusive) and 30".to_owned());
665        }
666        if !(1..=10_000).contains(&self.sync_body_read_max_concurrent) {
667            problems.push("sync_body_read_max_concurrent must be between 1 and 10000".to_owned());
668        }
669        if !(60..=86_400).contains(&self.cloud_ip_refresh_interval) {
670            problems.push("cloud_ip_refresh_interval must be between 60 and 86400".to_owned());
671        }
672        if !(3600..=604_800).contains(&self.geo_ip_db_max_age) {
673            problems.push("geo_ip_db_max_age must be between 3600 and 604800".to_owned());
674        }
675        if self.dynamic_rule_interval < 60 {
676            problems.push("dynamic_rule_interval must be at least 60".to_owned());
677        }
678        if !(60..=86_400).contains(&self.agent_status_interval) {
679            problems.push("agent_status_interval must be between 60 and 86400".to_owned());
680        }
681        if !(0.1..=10.0).contains(&self.detection_compiler_timeout) {
682            problems.push("detection_compiler_timeout must be between 0.1 and 10.0".to_owned());
683        }
684        if !(1000..=100_000).contains(&self.detection_max_content_length) {
685            problems
686                .push("detection_max_content_length must be between 1000 and 100000".to_owned());
687        }
688        if !(1024..=10_485_760).contains(&self.detection_max_body_inspect_bytes) {
689            problems.push(
690                "detection_max_body_inspect_bytes must be between 1024 and 10485760".to_owned(),
691            );
692        }
693        if !(2..=100_000).contains(&self.detection_max_scan_values) {
694            problems.push("detection_max_scan_values must be between 2 and 100000".to_owned());
695        }
696        if !(0.1..=60.0).contains(&self.detection_max_scan_seconds) {
697            problems.push("detection_max_scan_seconds must be between 0.1 and 60.0".to_owned());
698        }
699        if !(1024..=262_144).contains(&self.detection_max_scan_chars) {
700            problems.push("detection_max_scan_chars must be between 1024 and 262144".to_owned());
701        }
702        if !(1..=1000).contains(&self.detection_max_json_depth) {
703            problems.push("detection_max_json_depth must be between 1 and 1000".to_owned());
704        }
705        if !(4..=1024).contains(&self.detection_binary_min_run_length) {
706            problems.push("detection_binary_min_run_length must be between 4 and 1024".to_owned());
707        }
708        if !(0.0..=1.0).contains(&self.detection_semantic_threshold) {
709            problems.push("detection_semantic_threshold must be between 0.0 and 1.0".to_owned());
710        }
711        if !(1.0..=10.0).contains(&self.detection_anomaly_threshold) {
712            problems.push("detection_anomaly_threshold must be between 1.0 and 10.0".to_owned());
713        }
714        if !(0.01..=1.0).contains(&self.detection_slow_pattern_threshold) {
715            problems
716                .push("detection_slow_pattern_threshold must be between 0.01 and 1.0".to_owned());
717        }
718        if !(100..=10_000).contains(&self.detection_monitor_history_size) {
719            problems
720                .push("detection_monitor_history_size must be between 100 and 10000".to_owned());
721        }
722        if !(100..=5000).contains(&self.detection_max_tracked_patterns) {
723            problems.push("detection_max_tracked_patterns must be between 100 and 5000".to_owned());
724        }
725        if !(1.0..=3600.0).contains(&self.detection_anomaly_emission_cooldown) {
726            problems.push(
727                "detection_anomaly_emission_cooldown must be between 1.0 and 3600.0".to_owned(),
728            );
729        }
730        if !(10..=1000).contains(&self.detection_min_samples_for_anomaly) {
731            problems
732                .push("detection_min_samples_for_anomaly must be between 10 and 1000".to_owned());
733        }
734        if !(0.0..=10.0).contains(&self.detection_threat_score_threshold) {
735            problems
736                .push("detection_threat_score_threshold must be between 0.0 and 10.0".to_owned());
737        }
738        if self
739            .agent_high_watermark_ratio
740            .is_some_and(|ratio| ratio <= 0.0 || ratio > 1.0)
741        {
742            problems
743                .push("agent_high_watermark_ratio must be between 0 (exclusive) and 1".to_owned());
744        }
745        if self
746            .agent_max_concurrent_flushes
747            .is_some_and(|value| value < 1)
748        {
749            problems.push("agent_max_concurrent_flushes must be at least 1 when set".to_owned());
750        }
751        if self.agent_backoff_factor.is_some_and(|value| value <= 0.0) {
752            problems.push("agent_backoff_factor must be positive when set".to_owned());
753        }
754
755        for (field, entries) in [
756            ("whitelist", self.whitelist.as_deref().unwrap_or(&[])),
757            ("blacklist", &self.blacklist[..]),
758            ("exempt_ips", &self.exempt_ips[..]),
759        ] {
760            for entry in entries {
761                if parse_ip_or_cidr(entry).is_none() {
762                    problems.push(format!("{field}: invalid IP or CIDR range ({entry})"));
763                }
764            }
765        }
766        for entry in &self.trusted_proxies {
767            if entry != "unix" && parse_ip_or_cidr(entry).is_none() {
768                problems.push(format!(
769                    "trusted_proxies: invalid proxy IP or CIDR range ({entry})"
770                ));
771            }
772        }
773
774        if self.enable_agent && self.agent_api_key.is_none() {
775            problems.push("agent_api_key is required when enable_agent is true".to_owned());
776        }
777        if self.enable_dynamic_rules && !self.enable_agent {
778            problems.push("enable_agent must be true when enable_dynamic_rules is true".to_owned());
779        }
780        if self.enable_enrichment && !self.enable_agent {
781            problems.push("enable_enrichment requires enable_agent=true".to_owned());
782        }
783
784        if problems.is_empty() {
785            Ok(())
786        } else {
787            Err(SecurityConfigError { problems })
788        }
789    }
790
791    /// The `IpBanConfig` view of the ban group (`enable_ip_banning`,
792    /// `auto_ban_threshold`, `auto_ban_duration`, `threat_ban_config`).
793    #[must_use]
794    pub fn ip_ban_config(&self) -> IpBanConfig {
795        IpBanConfig {
796            enable_ip_banning: self.enable_ip_banning,
797            auto_ban_threshold: self.auto_ban_threshold,
798            auto_ban_duration: self.auto_ban_duration,
799            threat_ban_config: self.threat_ban_config.clone().into_iter().collect(),
800        }
801    }
802}
803
804/// Parse one list entry as an IP address or a CIDR range (the reference
805/// `_validate_ip_or_cidr_list` accepts both for every IP list; the prefix
806/// length is family-bounded, 32 for v4 and 128 for v6).
807fn parse_ip_or_cidr(entry: &str) -> Option<IpAddr> {
808    if let Ok(ip) = entry.parse::<IpAddr>() {
809        return Some(ip);
810    }
811    let (address, prefix) = entry.split_once('/')?;
812    let prefix = prefix.parse::<u8>().ok()?;
813    let address = address.parse::<IpAddr>().ok()?;
814    let max_prefix = match address {
815        IpAddr::V4(_) => 32,
816        IpAddr::V6(_) => 128,
817    };
818    (prefix <= max_prefix).then_some(address)
819}
820
821/// Every reference violation at once: the config error
822/// [`SecurityConfig::validate`] fails closed with.
823#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct SecurityConfigError {
825    /// Every violation, field-named.
826    pub problems: Vec<String>,
827}
828
829impl std::fmt::Display for SecurityConfigError {
830    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
831        write!(
832            f,
833            "invalid SecurityConfig ({} problems)",
834            self.problems.len()
835        )
836    }
837}
838
839impl std::error::Error for SecurityConfigError {}
840
841#[cfg(test)]
842// The tests construct a default config and mutate one group at a time;
843// literal 129-field initializers would be unreadable.
844#[allow(clippy::field_reassign_with_default)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn defaults_satisfy_every_constraint() {
850        // Through `new` (the reference constructor shape) and through
851        // `Default`: both carry the reference defaults.
852        assert!(SecurityConfig::new().validate().is_ok());
853        assert!(SecurityConfig::default().validate().is_ok());
854    }
855
856    #[test]
857    #[allow(clippy::too_many_lines)]
858    fn every_reference_default_is_carried() {
859        let config = SecurityConfig::default();
860        assert!(config.trusted_proxies.is_empty());
861        assert_eq!(config.trusted_proxy_depth, 1);
862        assert!(!config.trust_x_forwarded_proto);
863        assert!(!config.passive_mode);
864        assert!(config.geo_ip_handler.is_none());
865        assert!(config.enable_redis);
866        assert_eq!(config.redis_url.as_deref(), Some("redis://localhost:6379"));
867        assert_eq!(config.redis_prefix, "guard_core:");
868        assert!(
869            config
870                .redis_socket_connect_timeout
871                .is_some_and(|value| (value - 2.0).abs() < f64::EPSILON)
872        );
873        assert!(
874            config
875                .redis_socket_timeout
876                .is_some_and(|value| (value - 2.0).abs() < f64::EPSILON)
877        );
878        assert_eq!(config.redis_health_check_interval, 30);
879        assert_eq!(config.redis_max_connections, None);
880        assert_eq!(config.redis_retries, 1);
881        assert_eq!(config.whitelist, None);
882        assert!(config.blacklist.is_empty());
883        assert!(config.exempt_ips.is_empty());
884        assert!(config.whitelist_countries.is_empty());
885        assert!(config.blocked_countries.is_empty());
886        assert!(config.blocked_user_agents.is_empty());
887        assert_eq!(config.auto_ban_threshold, 10);
888        assert_eq!(config.auto_ban_duration, 3600);
889        assert!(config.threat_ban_config.is_empty());
890        assert!(!config.enable_rate_limit_auto_ban);
891        assert!(config.global_behavior_rules.is_empty());
892        assert!(!config.behavior_scan_response_body);
893        assert_eq!(config.behavior_max_response_body_inspect_bytes, 262_144);
894        assert!((config.body_read_timeout - 3.0).abs() < f64::EPSILON);
895        assert_eq!(config.sync_body_read_max_concurrent, 64);
896        assert_eq!(config.custom_log_file, None);
897        assert_eq!(config.log_suspicious_level, Some(LogLevel::Warning));
898        assert_eq!(config.log_request_level, None);
899        assert_eq!(config.log_country_check_level, Some(LogLevel::Info));
900        assert_eq!(config.log_format, LogFormat::Text);
901        assert!(config.custom_error_responses.is_empty());
902        assert_eq!(config.rate_limit, 10);
903        assert_eq!(config.rate_limit_window, 60);
904        assert!(!config.enforce_https);
905        assert_eq!(
906            config.security_headers,
907            SecurityHeadersConfig::reference_default()
908        );
909        assert!(config.custom_request_check.is_none());
910        assert!(config.auth_verifier.is_none());
911        assert!(config.custom_response_modifier.is_none());
912        assert!(config.on_block.is_none());
913        assert!(config.on_error.is_none());
914        assert!(!config.enable_cors);
915        assert_eq!(config.cors_allow_origins, ["*"]);
916        assert_eq!(
917            config.cors_allow_methods,
918            ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
919        );
920        assert_eq!(config.cors_allow_headers, ["*"]);
921        assert!(!config.cors_allow_credentials);
922        assert!(config.cors_expose_headers.is_empty());
923        assert_eq!(config.cors_max_age, 600);
924        assert_eq!(config.block_cloud_providers, None);
925        assert_eq!(config.cloud_ip_refresh_interval, 3600);
926        assert!(config.lazy_init);
927        assert_eq!(config.geo_ip_db_max_age, 86_400);
928        assert!(config.cloud_ip_store.is_none());
929        assert_eq!(
930            config.exclude_paths,
931            [
932                "/docs",
933                "/redoc",
934                "/openapi.json",
935                "/openapi.yaml",
936                "/favicon.ico",
937                "/static"
938            ]
939        );
940        assert!(config.enable_ip_banning);
941        assert!(config.enable_rate_limiting);
942        assert!(config.enable_penetration_detection);
943        assert!(config.fail_secure);
944        assert!(!config.redis_fail_open);
945        assert!(!config.route_resolution_strict);
946        assert_eq!(config.ipinfo_token, None);
947        assert_eq!(
948            config.ipinfo_db_path.as_deref(),
949            Some(std::path::Path::new("data/ipinfo/country_asn.mmdb"))
950        );
951        assert!(!config.enable_agent);
952        assert_eq!(config.agent_api_key, None);
953        assert!(!config.agent_strict);
954        assert_eq!(config.agent_endpoint, "https://api.guard-core.com");
955        assert_eq!(config.agent_project_id, None);
956        assert_eq!(config.agent_buffer_size, 100);
957        assert_eq!(config.agent_flush_interval, 30);
958        assert!(config.agent_enable_events);
959        assert!(config.agent_enable_metrics);
960        assert_eq!(config.agent_timeout, 30);
961        assert_eq!(config.agent_retry_attempts, 3);
962        assert_eq!(config.agent_project_encryption_key, None);
963        assert_eq!(config.agent_guard_version, None);
964        assert_eq!(config.agent_high_watermark_ratio, None);
965        assert_eq!(config.agent_max_concurrent_flushes, None);
966        assert_eq!(config.agent_buffer_overflow_policy, None);
967        assert_eq!(config.agent_backoff_factor, None);
968        assert_eq!(config.agent_sensitive_headers, None);
969        assert_eq!(config.agent_max_payload_size, None);
970        assert_eq!(config.agent_compression_enabled, None);
971        assert_eq!(config.agent_compression_threshold, None);
972        assert_eq!(config.agent_install_id, None);
973        assert_eq!(config.agent_payload_signing_secret, None);
974        assert!(!config.enable_dynamic_rules);
975        assert_eq!(config.dynamic_rule_interval, 300);
976        assert_eq!(config.dynamic_rules_cache_path, None);
977        assert_eq!(config.agent_status_interval, 300);
978        assert!(!config.emergency_mode);
979        assert!(config.emergency_whitelist.is_empty());
980        assert!(config.endpoint_rate_limits.is_empty());
981        assert!((config.detection_compiler_timeout - 2.0).abs() < f64::EPSILON);
982        assert!((config.detection_max_scan_seconds - 10.0).abs() < f64::EPSILON);
983        assert_eq!(config.detection_pattern_validation_cache_path, None);
984        assert_eq!(config.detection_max_content_length, 10_000);
985        assert_eq!(config.detection_max_body_inspect_bytes, 262_144);
986        assert_eq!(config.detection_max_scan_values, 512);
987        assert_eq!(config.detection_max_scan_chars, 65_536);
988        assert_eq!(config.detection_max_json_depth, 32);
989        assert_eq!(config.detection_binary_min_run_length, 16);
990        assert!(config.detection_preserve_attack_patterns);
991        assert!((config.detection_semantic_threshold - 0.7).abs() < f64::EPSILON);
992        assert!((config.detection_anomaly_threshold - 3.0).abs() < f64::EPSILON);
993        assert!((config.detection_slow_pattern_threshold - 0.1).abs() < f64::EPSILON);
994        assert_eq!(config.detection_monitor_history_size, 1000);
995        assert_eq!(config.detection_max_tracked_patterns, 1000);
996        assert!((config.detection_anomaly_emission_cooldown - 60.0).abs() < f64::EPSILON);
997        assert_eq!(config.detection_min_samples_for_anomaly, 30);
998        assert!((config.detection_threat_score_threshold - 1.0).abs() < f64::EPSILON);
999        assert!(config.muted_event_types.is_empty());
1000        assert!(config.muted_metric_types.is_empty());
1001        assert!(config.muted_check_logs.is_empty());
1002        assert!(config.log_sensitive_headers.is_empty());
1003        assert!(config.log_sensitive_params.is_empty());
1004        assert!(config.log_sensitive_body_fields.is_empty());
1005        assert!(!config.enable_otel);
1006        assert_eq!(config.otel_service_name, "guard-core");
1007        assert_eq!(config.otel_exporter_endpoint, None);
1008        assert!(config.otel_resource_attributes.is_empty());
1009        assert!(!config.enable_logfire);
1010        assert_eq!(config.logfire_service_name, "guard-core");
1011        assert!(!config.enable_enrichment);
1012        assert!(config.excluded_detection_headers.is_empty());
1013        assert!(config.excluded_semantic_detection_headers.is_empty());
1014        assert!(config.excluded_detection_params.is_empty());
1015        assert!(config.excluded_detection_body_fields.is_empty());
1016        assert!(config.detection_scan_body);
1017        assert_eq!(
1018            config.enabled_detection_categories,
1019            ALL_DETECTION_CATEGORIES
1020                .iter()
1021                .map(|category| (*category).to_owned())
1022                .collect::<BTreeSet<String>>()
1023        );
1024    }
1025
1026    #[test]
1027    fn the_category_table_is_the_reference_set() {
1028        assert_eq!(ALL_DETECTION_CATEGORIES.len(), 19);
1029        assert!(ALL_DETECTION_CATEGORIES.contains(&"xss"));
1030        assert!(ALL_DETECTION_CATEGORIES.contains(&"deserialization"));
1031    }
1032
1033    #[test]
1034    fn the_log_level_round_trips_the_reference_literals() {
1035        assert_eq!(LogLevel::Info.as_str(), "INFO");
1036        assert_eq!(LogLevel::Debug.as_str(), "DEBUG");
1037        assert_eq!(LogLevel::Warning.as_str(), "WARNING");
1038        assert_eq!(LogLevel::Error.as_str(), "ERROR");
1039        assert_eq!(LogLevel::Critical.as_str(), "CRITICAL");
1040    }
1041
1042    #[test]
1043    fn the_log_format_round_trips_the_reference_literals() {
1044        assert_eq!(LogFormat::Text.as_str(), "text");
1045        assert_eq!(LogFormat::Json.as_str(), "json");
1046    }
1047
1048    #[test]
1049    fn the_overflow_policy_round_trips_the_reference_literals() {
1050        assert_eq!(BufferOverflowPolicy::Drop.as_str(), "drop");
1051        assert_eq!(BufferOverflowPolicy::Block.as_str(), "block");
1052        assert_eq!(BufferOverflowPolicy::Raise.as_str(), "raise");
1053    }
1054
1055    /// Every bound constraint, one mutating case per violation: the error
1056    /// names the field and validation keeps collecting the rest.
1057    #[test]
1058    #[allow(clippy::too_many_lines)]
1059    fn every_bound_constraint_names_its_field() {
1060        type Mutator = Box<dyn Fn(&mut SecurityConfig)>;
1061        let cases: Vec<(&str, Mutator)> = vec![
1062            (
1063                "trusted_proxy_depth",
1064                Box::new(|config: &mut SecurityConfig| config.trusted_proxy_depth = 0),
1065            ),
1066            (
1067                "redis_socket_connect_timeout",
1068                Box::new(|config: &mut SecurityConfig| {
1069                    config.redis_socket_connect_timeout = Some(0.0);
1070                }),
1071            ),
1072            (
1073                "redis_socket_timeout",
1074                Box::new(|config: &mut SecurityConfig| config.redis_socket_timeout = Some(-1.0)),
1075            ),
1076            (
1077                "redis_max_connections",
1078                Box::new(|config: &mut SecurityConfig| config.redis_max_connections = Some(0)),
1079            ),
1080            (
1081                "auto_ban_threshold",
1082                Box::new(|config: &mut SecurityConfig| config.auto_ban_threshold = 0),
1083            ),
1084            (
1085                "auto_ban_duration",
1086                Box::new(|config: &mut SecurityConfig| config.auto_ban_duration = 0),
1087            ),
1088            (
1089                "behavior_max_response_body_inspect_bytes",
1090                Box::new(|config: &mut SecurityConfig| {
1091                    config.behavior_max_response_body_inspect_bytes = 1023;
1092                }),
1093            ),
1094            (
1095                "body_read_timeout",
1096                Box::new(|config: &mut SecurityConfig| config.body_read_timeout = 0.0),
1097            ),
1098            (
1099                "body_read_timeout",
1100                Box::new(|config: &mut SecurityConfig| config.body_read_timeout = 30.1),
1101            ),
1102            (
1103                "sync_body_read_max_concurrent",
1104                Box::new(|config: &mut SecurityConfig| {
1105                    config.sync_body_read_max_concurrent = 0;
1106                }),
1107            ),
1108            (
1109                "cloud_ip_refresh_interval",
1110                Box::new(|config: &mut SecurityConfig| config.cloud_ip_refresh_interval = 59),
1111            ),
1112            (
1113                "geo_ip_db_max_age",
1114                Box::new(|config: &mut SecurityConfig| config.geo_ip_db_max_age = 3599),
1115            ),
1116            (
1117                "dynamic_rule_interval",
1118                Box::new(|config: &mut SecurityConfig| config.dynamic_rule_interval = 59),
1119            ),
1120            (
1121                "agent_status_interval",
1122                Box::new(|config: &mut SecurityConfig| config.agent_status_interval = 59),
1123            ),
1124            (
1125                "detection_compiler_timeout",
1126                Box::new(|config: &mut SecurityConfig| config.detection_compiler_timeout = 0.0),
1127            ),
1128            (
1129                "detection_max_content_length",
1130                Box::new(|config: &mut SecurityConfig| config.detection_max_content_length = 999),
1131            ),
1132            (
1133                "detection_max_body_inspect_bytes",
1134                Box::new(|config: &mut SecurityConfig| {
1135                    config.detection_max_body_inspect_bytes = 1023;
1136                }),
1137            ),
1138            (
1139                "detection_max_scan_seconds",
1140                Box::new(|config: &mut SecurityConfig| config.detection_max_scan_seconds = 0.09),
1141            ),
1142            (
1143                "detection_max_scan_seconds",
1144                Box::new(|config: &mut SecurityConfig| config.detection_max_scan_seconds = 60.1),
1145            ),
1146            (
1147                "detection_max_scan_values",
1148                Box::new(|config: &mut SecurityConfig| config.detection_max_scan_values = 1),
1149            ),
1150            (
1151                "detection_max_scan_chars",
1152                Box::new(|config: &mut SecurityConfig| config.detection_max_scan_chars = 1023),
1153            ),
1154            (
1155                "detection_max_json_depth",
1156                Box::new(|config: &mut SecurityConfig| config.detection_max_json_depth = 0),
1157            ),
1158            (
1159                "detection_binary_min_run_length",
1160                Box::new(|config: &mut SecurityConfig| {
1161                    config.detection_binary_min_run_length = 3;
1162                }),
1163            ),
1164            (
1165                "detection_semantic_threshold",
1166                Box::new(|config: &mut SecurityConfig| config.detection_semantic_threshold = 1.1),
1167            ),
1168            (
1169                "detection_anomaly_threshold",
1170                Box::new(|config: &mut SecurityConfig| config.detection_anomaly_threshold = 0.9),
1171            ),
1172            (
1173                "detection_slow_pattern_threshold",
1174                Box::new(|config: &mut SecurityConfig| {
1175                    config.detection_slow_pattern_threshold = 0.0;
1176                }),
1177            ),
1178            (
1179                "detection_monitor_history_size",
1180                Box::new(|config: &mut SecurityConfig| {
1181                    config.detection_monitor_history_size = 99;
1182                }),
1183            ),
1184            (
1185                "detection_max_tracked_patterns",
1186                Box::new(|config: &mut SecurityConfig| {
1187                    config.detection_max_tracked_patterns = 99;
1188                }),
1189            ),
1190            (
1191                "detection_anomaly_emission_cooldown",
1192                Box::new(|config: &mut SecurityConfig| {
1193                    config.detection_anomaly_emission_cooldown = 0.9;
1194                }),
1195            ),
1196            (
1197                "detection_min_samples_for_anomaly",
1198                Box::new(|config: &mut SecurityConfig| {
1199                    config.detection_min_samples_for_anomaly = 9;
1200                }),
1201            ),
1202            (
1203                "detection_threat_score_threshold",
1204                Box::new(|config: &mut SecurityConfig| {
1205                    config.detection_threat_score_threshold = 10.1;
1206                }),
1207            ),
1208            (
1209                "agent_high_watermark_ratio",
1210                Box::new(|config: &mut SecurityConfig| {
1211                    config.agent_high_watermark_ratio = Some(1.1);
1212                }),
1213            ),
1214            (
1215                "agent_max_concurrent_flushes",
1216                Box::new(|config: &mut SecurityConfig| {
1217                    config.agent_max_concurrent_flushes = Some(0);
1218                }),
1219            ),
1220            (
1221                "agent_backoff_factor",
1222                Box::new(|config: &mut SecurityConfig| config.agent_backoff_factor = Some(0.0)),
1223            ),
1224        ];
1225        for (field, mutate) in cases {
1226            let mut config = SecurityConfig::default();
1227            mutate(&mut config);
1228            let error = config.validate().unwrap_err();
1229            assert!(
1230                error.problems.iter().any(|problem| problem.contains(field)),
1231                "{field}: {:?}",
1232                error.problems
1233            );
1234        }
1235    }
1236
1237    #[test]
1238    fn detection_max_scan_seconds_accepts_its_inclusive_bounds() {
1239        for ok in [0.1, 10.0, 60.0] {
1240            let mut config = SecurityConfig::default();
1241            config.detection_max_scan_seconds = ok;
1242            assert!(config.validate().is_ok(), "{ok}");
1243        }
1244    }
1245
1246    #[test]
1247    fn the_ip_lists_reject_garbage_and_accept_ips_and_cidrs() {
1248        let mut config = SecurityConfig::default();
1249        config.whitelist = Some(vec![String::from("10.0.0.1"), String::from("10.0.0.0/8")]);
1250        config.blacklist = vec![String::from("2001:db8::1"), String::from("2001:db8::/32")];
1251        config.exempt_ips = vec![String::from("192.0.2.1")];
1252        config.trusted_proxies = vec![String::from("unix"), String::from("10.1.0.0/16")];
1253        assert!(config.validate().is_ok());
1254
1255        config.exempt_ips = vec![String::from("not-an-ip")];
1256        let error = config.validate().unwrap_err();
1257        assert!(
1258            error
1259                .problems
1260                .iter()
1261                .any(|problem| problem.contains("exempt_ips"))
1262        );
1263
1264        config.trusted_proxies = vec![String::from("also-bad")];
1265        config.whitelist = Some(vec![String::from("10.0.0.0/99")]);
1266        let error = config.validate().unwrap_err();
1267        assert!(
1268            error
1269                .problems
1270                .iter()
1271                .any(|problem| problem.contains("trusted_proxies"))
1272        );
1273        assert!(
1274            error
1275                .problems
1276                .iter()
1277                .any(|problem| problem.contains("whitelist"))
1278        );
1279    }
1280
1281    #[test]
1282    fn the_agent_cross_rules_hold() {
1283        let mut config = SecurityConfig::default();
1284        config.enable_agent = true;
1285        let error = config.validate().unwrap_err();
1286        assert!(
1287            error
1288                .problems
1289                .iter()
1290                .any(|problem| problem.contains("agent_api_key"))
1291        );
1292
1293        let mut config = SecurityConfig::default();
1294        config.enable_dynamic_rules = true;
1295        let error = config.validate().unwrap_err();
1296        assert!(
1297            error
1298                .problems
1299                .iter()
1300                .any(|problem| problem.contains("enable_agent"))
1301        );
1302
1303        let mut config = SecurityConfig::default();
1304        config.enable_enrichment = true;
1305        let error = config.validate().unwrap_err();
1306        assert!(
1307            error
1308                .problems
1309                .iter()
1310                .any(|problem| problem.contains("enable_enrichment"))
1311        );
1312
1313        // The satisfied shape: everything the toggles demand is present.
1314        let mut config = SecurityConfig::default();
1315        config.enable_agent = true;
1316        config.agent_api_key = Some(String::from("k"));
1317        config.enable_dynamic_rules = true;
1318        config.enable_enrichment = true;
1319        assert!(config.validate().is_ok());
1320    }
1321
1322    #[test]
1323    fn validation_collects_every_problem_at_once() {
1324        let mut config = SecurityConfig::default();
1325        config.auto_ban_threshold = 0;
1326        config.auto_ban_duration = 0;
1327        config.enable_dynamic_rules = true;
1328        let error = config.validate().unwrap_err();
1329        assert_eq!(error.problems.len(), 3);
1330        assert_eq!(error.to_string(), "invalid SecurityConfig (3 problems)");
1331    }
1332
1333    #[test]
1334    fn the_ban_group_maps_onto_the_engine_config() {
1335        let mut config = SecurityConfig::default();
1336        config.enable_ip_banning = false;
1337        config.auto_ban_threshold = 5;
1338        config.auto_ban_duration = 60;
1339        config.threat_ban_config.insert(
1340            String::from("rate_limit"),
1341            ThreatBanEntry {
1342                threshold: 3,
1343                duration: 120,
1344            },
1345        );
1346        let ban_config = config.ip_ban_config();
1347        assert!(!ban_config.enable_ip_banning);
1348        assert_eq!(ban_config.auto_ban_threshold, 5);
1349        assert_eq!(ban_config.auto_ban_duration, 60);
1350        assert_eq!(ban_config.threat_ban_config.len(), 1);
1351    }
1352}