1use 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
59pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum LogLevel {
88 Info,
90 Debug,
92 Warning,
94 Error,
96 Critical,
98}
99
100impl LogLevel {
101 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum LogFormat {
117 Text,
119 Json,
121}
122
123impl LogFormat {
124 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum BufferOverflowPolicy {
139 Drop,
141 Block,
143 Raise,
145}
146
147impl BufferOverflowPolicy {
148 #[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#[allow(clippy::struct_excessive_bools)]
167#[derive(Clone)]
168pub struct SecurityConfig {
169 pub trusted_proxies: Vec<String>,
173 pub trusted_proxy_depth: u32,
176 pub trust_x_forwarded_proto: bool,
178 pub passive_mode: bool,
180 pub geo_ip_handler: Option<Arc<dyn GeoIpHandler>>,
182 pub enable_redis: bool,
184 pub redis_url: Option<String>,
186 pub redis_prefix: String,
188 pub redis_socket_connect_timeout: Option<f64>,
190 pub redis_socket_timeout: Option<f64>,
192 pub redis_health_check_interval: u64,
194 pub redis_max_connections: Option<u64>,
196 pub redis_retries: u32,
198 pub whitelist: Option<Vec<String>>,
200 pub blacklist: Vec<String>,
202 pub exempt_ips: Vec<String>,
204 pub whitelist_countries: BTreeSet<String>,
206 pub blocked_countries: BTreeSet<String>,
208 pub blocked_user_agents: Vec<String>,
210 pub auto_ban_threshold: u32,
212 pub auto_ban_duration: u64,
214 pub threat_ban_config: BTreeMap<String, ThreatBanEntry>,
216 pub enable_rate_limit_auto_ban: bool,
218 pub global_behavior_rules: Vec<BehaviorRule>,
220 pub behavior_scan_response_body: bool,
222 pub behavior_max_response_body_inspect_bytes: usize,
224 pub body_read_timeout: f64,
226 pub sync_body_read_max_concurrent: usize,
228 pub custom_log_file: Option<String>,
230 pub log_suspicious_level: Option<LogLevel>,
232 pub log_request_level: Option<LogLevel>,
234 pub log_country_check_level: Option<LogLevel>,
236 pub log_format: LogFormat,
238 pub custom_error_responses: BTreeMap<u16, String>,
240 pub rate_limit: u32,
242 pub rate_limit_window: u64,
244 pub enforce_https: bool,
246 pub security_headers: SecurityHeadersConfig,
248 pub custom_request_check: Option<CustomRequestFn>,
250 pub auth_verifier: Option<AuthVerifier>,
252 pub custom_response_modifier: Option<ResponseModifierFn>,
254 pub on_block: Option<OnBlockHook>,
256 pub on_error: Option<OnErrorFn>,
258 pub enable_cors: bool,
260 pub cors_allow_origins: Vec<String>,
262 pub cors_allow_methods: Vec<String>,
264 pub cors_allow_headers: Vec<String>,
266 pub cors_allow_credentials: bool,
268 pub cors_expose_headers: Vec<String>,
270 pub cors_max_age: u64,
272 pub block_cloud_providers: Option<BTreeSet<String>>,
274 pub cloud_ip_refresh_interval: u64,
276 pub lazy_init: bool,
278 pub geo_ip_db_max_age: u64,
280 pub cloud_ip_store: Option<Arc<dyn CloudRangeStore>>,
282 pub exclude_paths: Vec<String>,
284 pub enable_ip_banning: bool,
286 pub enable_rate_limiting: bool,
288 pub enable_penetration_detection: bool,
290 pub fail_secure: bool,
292 pub redis_fail_open: bool,
294 pub route_resolution_strict: bool,
296 pub ipinfo_token: Option<String>,
298 pub ipinfo_db_path: Option<PathBuf>,
300 pub enable_agent: bool,
302 pub agent_api_key: Option<String>,
304 pub agent_strict: bool,
306 pub agent_endpoint: String,
308 pub agent_project_id: Option<String>,
310 pub agent_buffer_size: usize,
312 pub agent_flush_interval: u64,
314 pub agent_enable_events: bool,
316 pub agent_enable_metrics: bool,
318 pub agent_timeout: u64,
320 pub agent_retry_attempts: u32,
322 pub agent_project_encryption_key: Option<String>,
324 pub agent_guard_version: Option<String>,
326 pub agent_high_watermark_ratio: Option<f64>,
328 pub agent_max_concurrent_flushes: Option<u64>,
330 pub agent_buffer_overflow_policy: Option<BufferOverflowPolicy>,
332 pub agent_backoff_factor: Option<f64>,
334 pub agent_sensitive_headers: Option<Vec<String>>,
336 pub agent_max_payload_size: Option<u64>,
338 pub agent_compression_enabled: Option<bool>,
340 pub agent_compression_threshold: Option<u64>,
342 pub agent_install_id: Option<String>,
344 pub agent_payload_signing_secret: Option<String>,
346 pub enable_dynamic_rules: bool,
358 pub dynamic_rule_interval: u64,
360 pub dynamic_rules_cache_path: Option<PathBuf>,
362 pub agent_status_interval: u64,
364 pub emergency_mode: bool,
366 pub emergency_whitelist: Vec<String>,
368 pub endpoint_rate_limits: BTreeMap<String, (u32, u64)>,
370 pub detection_compiler_timeout: f64,
372 pub detection_max_scan_seconds: f64,
378 pub detection_pattern_validation_cache_path: Option<String>,
380 pub detection_max_content_length: usize,
382 pub detection_max_body_inspect_bytes: usize,
384 pub detection_max_scan_values: usize,
386 pub detection_max_scan_chars: usize,
388 pub detection_max_json_depth: usize,
390 pub detection_binary_min_run_length: usize,
392 pub detection_preserve_attack_patterns: bool,
394 pub detection_semantic_threshold: f64,
396 pub detection_anomaly_threshold: f64,
398 pub detection_slow_pattern_threshold: f64,
400 pub detection_monitor_history_size: usize,
402 pub detection_max_tracked_patterns: usize,
404 pub detection_anomaly_emission_cooldown: f64,
406 pub detection_min_samples_for_anomaly: usize,
408 pub detection_threat_score_threshold: f64,
410 pub muted_event_types: BTreeSet<String>,
412 pub muted_metric_types: BTreeSet<String>,
414 pub muted_check_logs: BTreeSet<String>,
416 pub log_sensitive_headers: BTreeSet<String>,
418 pub log_sensitive_params: BTreeSet<String>,
420 pub log_sensitive_body_fields: BTreeSet<String>,
422 pub enable_otel: bool,
424 pub otel_service_name: String,
426 pub otel_exporter_endpoint: Option<String>,
428 pub otel_resource_attributes: BTreeMap<String, String>,
430 pub enable_logfire: bool,
432 pub logfire_service_name: String,
434 pub enable_enrichment: bool,
436 pub excluded_detection_headers: BTreeSet<String>,
438 pub excluded_semantic_detection_headers: BTreeSet<String>,
444 pub excluded_detection_params: BTreeSet<String>,
446 pub excluded_detection_body_fields: BTreeSet<String>,
448 pub detection_scan_body: bool,
450 pub enabled_detection_categories: BTreeSet<String>,
453}
454
455impl Default for SecurityConfig {
456 #[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 #[must_use]
618 pub fn new() -> Self {
619 Self::default()
620 }
621
622 #[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 #[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
804fn 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#[derive(Debug, Clone, PartialEq, Eq)]
824pub struct SecurityConfigError {
825 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#[allow(clippy::field_reassign_with_default)]
845mod tests {
846 use super::*;
847
848 #[test]
849 fn defaults_satisfy_every_constraint() {
850 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 #[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 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}