1use std::collections::{BTreeMap, BTreeSet};
34use std::net::IpAddr;
35
36use chrono::{DateTime, Utc};
37use serde::{Deserialize, Serialize};
38
39use crate::compiler::validate_pattern_safety;
40use crate::decorators::VALID_CLOUD_PROVIDERS;
41use crate::security_config::SecurityConfig;
42
43pub const LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION: u64 = 1;
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(default)]
53#[allow(clippy::derive_partial_eq_without_eq)]
54pub struct DynamicRules {
55 pub rule_id: String,
57 pub version: u64,
59 pub timestamp: Option<DateTime<Utc>>,
61 pub expires_at: Option<DateTime<Utc>>,
63 pub ttl: u64,
65 pub ip_blacklist: Vec<String>,
67 pub ip_whitelist: Vec<String>,
69 pub ip_ban_duration: u64,
71 pub blocked_countries: Vec<String>,
73 pub whitelist_countries: Vec<String>,
75 pub global_rate_limit: Option<u64>,
77 pub global_rate_window: Option<u64>,
79 pub endpoint_rate_limits: BTreeMap<String, (u64, u64)>,
81 pub blocked_cloud_providers: BTreeSet<String>,
83 pub blocked_user_agents: Vec<String>,
85 pub suspicious_patterns: Vec<String>,
87 pub enable_penetration_detection: Option<bool>,
89 pub enable_ip_banning: Option<bool>,
91 pub enable_rate_limiting: Option<bool>,
93 pub auto_ban_threshold: Option<u64>,
95 pub auto_ban_duration: Option<u64>,
97 pub enable_rate_limit_auto_ban: Option<bool>,
99 pub emergency_mode: bool,
101 pub emergency_whitelist: Vec<String>,
103}
104
105impl Default for DynamicRules {
106 fn default() -> Self {
107 Self {
108 rule_id: String::new(),
109 version: 0,
110 timestamp: None,
111 expires_at: None,
112 ttl: 300,
113 ip_blacklist: Vec::new(),
114 ip_whitelist: Vec::new(),
115 ip_ban_duration: 3600,
116 blocked_countries: Vec::new(),
117 whitelist_countries: Vec::new(),
118 global_rate_limit: None,
119 global_rate_window: None,
120 endpoint_rate_limits: BTreeMap::new(),
121 blocked_cloud_providers: BTreeSet::new(),
122 blocked_user_agents: Vec::new(),
123 suspicious_patterns: Vec::new(),
124 enable_penetration_detection: None,
125 enable_ip_banning: None,
126 enable_rate_limiting: None,
127 auto_ban_threshold: None,
128 auto_ban_duration: None,
129 enable_rate_limit_auto_ban: None,
130 emergency_mode: false,
131 emergency_whitelist: Vec::new(),
132 }
133 }
134}
135
136impl DynamicRules {
137 #[must_use]
139 pub fn identity(&self) -> (&str, u64) {
140 (&self.rule_id, self.version)
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct LastKnownRulesSnapshot {
148 pub schema_version: u64,
150 pub rules: DynamicRules,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct SnapshotError(pub String);
157
158impl core::fmt::Display for SnapshotError {
159 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160 write!(f, "last-known rules snapshot error: {}", self.0)
161 }
162}
163
164impl std::error::Error for SnapshotError {}
165
166pub fn dump_last_known_rules_snapshot(rules: &DynamicRules) -> Result<String, SnapshotError> {
172 let snapshot = LastKnownRulesSnapshot {
173 schema_version: LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION,
174 rules: rules.clone(),
175 };
176 serde_json::to_string(&snapshot)
177 .map_err(|error| SnapshotError(format!("snapshot serialization: {error}")))
178}
179
180pub fn load_last_known_rules_snapshot(payload: &str) -> Result<DynamicRules, SnapshotError> {
187 let snapshot: LastKnownRulesSnapshot = serde_json::from_str(payload)
188 .map_err(|error| SnapshotError(format!("snapshot decode: {error}")))?;
189 if snapshot.schema_version != LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION {
190 return Err(SnapshotError(format!(
191 "Unsupported last-known dynamic rules snapshot schema version: {}",
192 snapshot.schema_version
193 )));
194 }
195 Ok(snapshot.rules)
196}
197
198#[must_use]
201pub fn has_expired(rules: &DynamicRules, now: DateTime<Utc>) -> bool {
202 rules.expires_at.is_some_and(|expires_at| now > expires_at)
203}
204
205#[must_use]
208pub fn should_update(current: Option<&DynamicRules>, incoming: &DynamicRules) -> bool {
209 current.is_none_or(|current| {
210 !(incoming.rule_id == current.rule_id && incoming.version <= current.version)
211 })
212}
213
214#[must_use]
217pub fn expired_identity(
218 last_skipped: &mut Option<(String, u64)>,
219 rules: &DynamicRules,
220 now: DateTime<Utc>,
221) -> bool {
222 if !has_expired(rules, now) {
223 return false;
224 }
225 let key = (rules.rule_id.clone(), rules.version);
226 if last_skipped.as_ref() != Some(&key) {
227 *last_skipped = Some(key);
228 }
229 true
230}
231
232#[must_use]
240pub fn matches_event(
241 rules: &DynamicRules,
242 ip: Option<&str>,
243 country: Option<&str>,
244 event_type: &str,
245) -> Option<(String, u64)> {
246 if let Some(ip) = ip
247 && (rules.ip_blacklist.iter().any(|banned| banned == ip)
248 || rules.ip_whitelist.iter().any(|allowed| allowed == ip))
249 {
250 return Some((rules.rule_id.clone(), rules.version));
251 }
252 if let Some(country) = country
253 && rules
254 .blocked_countries
255 .iter()
256 .any(|blocked| blocked.eq_ignore_ascii_case(country))
257 {
258 return Some((rules.rule_id.clone(), rules.version));
259 }
260 let type_matches = match event_type {
261 "rate_limited" => {
262 rules.global_rate_limit.is_some() || !rules.endpoint_rate_limits.is_empty()
263 }
264 "cloud_blocked" => !rules.blocked_cloud_providers.is_empty(),
265 "user_agent_blocked" => !rules.blocked_user_agents.is_empty(),
266 _ => false,
267 };
268 if type_matches {
269 return Some((rules.rule_id.clone(), rules.version));
270 }
271 None
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Eq)]
279pub struct AppliedDynamicRules {
280 pub bans: Vec<(IpAddr, u64)>,
282 pub unparseable_bans: Vec<String>,
284 pub unbans: Vec<IpAddr>,
286 pub unparseable_unbans: Vec<String>,
288 pub patterns: Vec<String>,
290 pub rejected_user_agents: Vec<(String, String)>,
293 pub emergency_activated: bool,
295}
296
297fn validate_user_agent_rules(
301 patterns: &[String],
302 cache: Option<&crate::redos::validation_cache::ValidationCache>,
303) -> (Vec<String>, Vec<(String, String)>) {
304 let mut valid = Vec::new();
305 let mut rejected = Vec::new();
306 for pattern in patterns {
307 let (is_safe, reason) = cache.map_or_else(
308 || validate_pattern_safety(pattern),
309 |cache| cache.validate_compat(pattern),
310 );
311 if is_safe {
312 valid.push(pattern.clone());
313 } else {
314 rejected.push((pattern.clone(), reason));
315 }
316 }
317 (valid, rejected)
318}
319
320pub fn apply_to_config(config: &mut SecurityConfig, rules: &DynamicRules) -> AppliedDynamicRules {
327 apply_to_config_with_validation_cache(config, rules, None)
328}
329
330pub fn apply_to_config_with_validation_cache(
338 config: &mut SecurityConfig,
339 rules: &DynamicRules,
340 cache: Option<&crate::redos::validation_cache::ValidationCache>,
341) -> AppliedDynamicRules {
342 let mut outcome = AppliedDynamicRules::default();
343
344 for ip in &rules.ip_blacklist {
347 match ip.parse::<IpAddr>() {
348 Ok(parsed) => outcome.bans.push((parsed, rules.ip_ban_duration)),
349 Err(_) => outcome.unparseable_bans.push(ip.clone()),
350 }
351 }
352 for ip in &rules.ip_whitelist {
353 match ip.parse::<IpAddr>() {
354 Ok(parsed) => outcome.unbans.push(parsed),
355 Err(_) => outcome.unparseable_unbans.push(ip.clone()),
356 }
357 }
358
359 if !rules.blocked_countries.is_empty() {
361 config.blocked_countries = rules
362 .blocked_countries
363 .iter()
364 .map(|c| c.to_uppercase())
365 .collect::<BTreeSet<_>>();
366 }
367 if !rules.whitelist_countries.is_empty() {
368 config.whitelist_countries = rules
369 .whitelist_countries
370 .iter()
371 .map(|c| c.to_uppercase())
372 .collect::<BTreeSet<_>>();
373 }
374
375 if let Some(limit) = rules.global_rate_limit {
379 config.rate_limit = u32::try_from(limit).unwrap_or(u32::MAX);
380 if let Some(window) = rules.global_rate_window {
381 config.rate_limit_window = window;
382 }
383 }
384 if !rules.endpoint_rate_limits.is_empty() {
385 config.endpoint_rate_limits = rules
386 .endpoint_rate_limits
387 .iter()
388 .map(|(endpoint, (limit, window))| {
389 (
390 endpoint.clone(),
391 (u32::try_from(*limit).unwrap_or(u32::MAX), *window),
392 )
393 })
394 .collect();
395 }
396
397 if !rules.blocked_cloud_providers.is_empty() {
401 let valid: BTreeSet<String> = rules
402 .blocked_cloud_providers
403 .iter()
404 .filter(|provider| {
405 let base = provider.split(":!").next().unwrap_or(provider);
406 VALID_CLOUD_PROVIDERS.contains(&base)
407 })
408 .cloned()
409 .collect();
410 config.block_cloud_providers = Some(valid);
411 }
412
413 if !rules.blocked_user_agents.is_empty() {
416 let (valid, rejected) = validate_user_agent_rules(&rules.blocked_user_agents, cache);
417 outcome.rejected_user_agents = rejected;
418 config.blocked_user_agents = valid;
419 }
420
421 if let Some(flag) = rules.enable_penetration_detection {
423 config.enable_penetration_detection = flag;
424 }
425 if let Some(flag) = rules.enable_ip_banning {
426 config.enable_ip_banning = flag;
427 }
428 if let Some(flag) = rules.enable_rate_limiting {
429 config.enable_rate_limiting = flag;
430 }
431 if let Some(threshold) = rules.auto_ban_threshold {
432 config.auto_ban_threshold = u32::try_from(threshold).unwrap_or(u32::MAX);
433 }
434 if let Some(duration) = rules.auto_ban_duration {
435 config.auto_ban_duration = duration;
436 }
437 if let Some(flag) = rules.enable_rate_limit_auto_ban {
438 config.enable_rate_limit_auto_ban = flag;
439 }
440
441 if rules.emergency_mode {
444 config.emergency_mode = true;
445 config
446 .emergency_whitelist
447 .clone_from(&rules.emergency_whitelist);
448 let original = config.auto_ban_threshold;
449 config.auto_ban_threshold = (original / 2).max(1);
450 outcome.emergency_activated = true;
451 }
452
453 outcome.patterns.clone_from(&rules.suspicious_patterns);
456 outcome
457}
458
459#[cfg(test)]
460mod dynamic_rules_tests {
461 use super::*;
462 use chrono::TimeZone;
463
464 fn rules() -> DynamicRules {
465 DynamicRules {
466 rule_id: String::from("rule-1"),
467 version: 2,
468 ..DynamicRules::default()
469 }
470 }
471
472 fn at(secs: i64) -> DateTime<Utc> {
473 Utc.timestamp_opt(secs, 0).single().expect("valid time")
474 }
475
476 #[test]
477 fn the_model_defaults_carry_the_reference_values() {
478 let base = DynamicRules::default();
479 assert_eq!(base.ttl, 300);
480 assert_eq!(base.ip_ban_duration, 3600);
481 assert!(!base.emergency_mode);
482 assert!(base.ip_blacklist.is_empty());
483 assert_eq!(base.identity(), ("", 0));
484 }
485
486 #[test]
487 fn the_snapshot_envelope_round_trips_and_guards_the_version() {
488 let rules = rules();
489 let payload = dump_last_known_rules_snapshot(&rules).expect("dumps");
490 assert!(payload.contains("\"schema_version\":1"));
491 let loaded = load_last_known_rules_snapshot(&payload).expect("loads");
492 assert_eq!(loaded, rules);
493
494 let wrong_version = payload.replace("\"schema_version\":1", "\"schema_version\":9");
495 let error = load_last_known_rules_snapshot(&wrong_version).expect_err("rejects");
496 assert!(error.to_string().contains("Unsupported last-known"));
497
498 let decode_error = load_last_known_rules_snapshot("not json").expect_err("rejects");
499 assert!(decode_error.to_string().contains("snapshot decode"));
500 let error_shape = SnapshotError(String::from("boom"));
501 assert_eq!(
502 error_shape.to_string(),
503 "last-known rules snapshot error: boom"
504 );
505 }
506
507 #[test]
508 fn the_expiry_gate_reads_the_deadline() {
509 let rules = DynamicRules {
510 expires_at: Some(at(500)),
511 ..rules()
512 };
513 assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
514 assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
515 assert!(has_expired(&rules, at(501)));
516 }
517
518 #[test]
519 fn the_update_gate_advances_versions_never_replays() {
520 assert!(should_update(None, &rules()));
521 let incoming = rules();
522 assert!(!should_update(Some(&incoming), &incoming));
523
524 let mut newer = rules();
525 newer.version = 3;
526 assert!(should_update(Some(&incoming), &newer));
527
528 let mut stale = rules();
529 stale.version = 1;
530 assert!(!should_update(Some(&incoming), &stale));
531
532 let mut other_id = rules();
533 other_id.rule_id = String::from("rule-2");
534 other_id.version = 1;
535 assert!(
536 should_update(Some(&incoming), &other_id),
537 "a new id updates"
538 );
539 }
540
541 #[test]
542 fn the_expired_identity_deduplicates_its_warning() {
543 let mut last_skipped: Option<(String, u64)> = None;
544 let mut expiring = rules();
545 expiring.expires_at = Some(at(500));
546
547 assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
548 let first = last_skipped.clone();
549 assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
550 assert_eq!(first, last_skipped, "the same rule logs once");
551
552 let mut fresh = rules();
553 fresh.rule_id = String::from("rule-2");
554 fresh.version = 7;
555 fresh.expires_at = Some(at(100));
556 assert!(expired_identity(&mut last_skipped, &fresh, at(501)));
557 assert_ne!(first, last_skipped, "a new identity re-warns");
558
559 assert!(!expired_identity(&mut last_skipped, &rules(), at(501)));
560 }
561
562 #[test]
563 fn the_event_matcher_answers_the_three_arms() {
564 let mut r = DynamicRules {
565 ip_blacklist: vec![String::from("192.0.2.1")],
566 ip_whitelist: vec![String::from("192.0.2.2")],
567 blocked_countries: vec![String::from("CN")],
568 ..rules()
569 };
570
571 assert_eq!(
572 matches_event(&r, Some("192.0.2.1"), None, "other"),
573 Some((String::from("rule-1"), 2))
574 );
575 assert_eq!(
576 matches_event(&r, Some("192.0.2.2"), None, "other"),
577 Some((String::from("rule-1"), 2)),
578 "the whitelist arm correlates too"
579 );
580 assert_eq!(
581 matches_event(&r, None, Some("cn"), "other"),
582 Some((String::from("rule-1"), 2)),
583 "the country arm is case-insensitive"
584 );
585 assert_eq!(
586 matches_event(&r, Some("192.0.2.9"), Some("US"), "other"),
587 None
588 );
589 assert_eq!(matches_event(&r, None, None, "rate_limited"), None);
590
591 r.global_rate_limit = Some(10);
592 assert!(matches_event(&r, None, None, "rate_limited").is_some());
593 r.blocked_cloud_providers.insert(String::from("AWS"));
594 assert!(matches_event(&r, None, None, "cloud_blocked").is_some());
595 r.blocked_user_agents = vec![String::from("curl")];
596 assert!(matches_event(&r, None, None, "user_agent_blocked").is_some());
597 }
598
599 #[test]
600 fn the_application_lands_every_config_surface() {
601 let mut config = SecurityConfig {
602 auto_ban_threshold: 10,
603 ..SecurityConfig::default()
604 };
605
606 let mut r = rules();
607 r.ip_blacklist = vec![String::from("192.0.2.1"), String::from("not-an-ip")];
608 r.ip_whitelist = vec![String::from("192.0.2.2"), String::from("also-bad")];
609 r.blocked_countries = vec![String::from("cn"), String::from("ru")];
610 r.whitelist_countries = vec![String::from("us")];
611 r.global_rate_limit = Some(25);
612 r.global_rate_window = Some(45);
613 r.endpoint_rate_limits.insert(String::from("/api"), (7, 30));
614 r.blocked_cloud_providers = BTreeSet::from([
615 String::from("AWS"),
616 String::from("AWS:!prod"),
617 String::from("GCP"),
618 String::from("Mystery"),
619 ]);
620 r.blocked_user_agents = vec![String::from("evil-agent"), String::from("(")];
621 r.enable_penetration_detection = Some(false);
622 r.enable_ip_banning = Some(false);
623 r.enable_rate_limiting = Some(true);
624 r.auto_ban_threshold = Some(20);
625 r.auto_ban_duration = Some(9_000);
626 r.enable_rate_limit_auto_ban = Some(false);
627 r.emergency_mode = true;
628 r.emergency_whitelist = vec![String::from("10.0.0.1")];
629 r.suspicious_patterns = vec![String::from("union.*select")];
630
631 let outcome = apply_to_config(&mut config, &r);
632
633 assert_eq!(outcome.bans, vec![(ip("192.0.2.1"), 3_600)]);
634 assert_eq!(outcome.unparseable_bans, vec![String::from("not-an-ip")]);
635 assert_eq!(outcome.unbans, vec![ip("192.0.2.2")]);
636 assert_eq!(outcome.unparseable_unbans, vec![String::from("also-bad")]);
637 assert_eq!(
638 config.blocked_countries,
639 BTreeSet::from([String::from("CN"), String::from("RU")])
640 );
641 assert_eq!(
642 config.whitelist_countries,
643 BTreeSet::from([String::from("US")])
644 );
645 assert_eq!(config.rate_limit, 25);
646 assert_eq!(config.rate_limit_window, 45);
647 assert_eq!(config.endpoint_rate_limits["/api"], (7, 30));
648 assert_eq!(
649 config.block_cloud_providers,
650 Some(BTreeSet::from([
651 String::from("AWS"),
652 String::from("AWS:!prod"),
653 String::from("GCP"),
654 ]))
655 );
656 assert_eq!(config.blocked_user_agents, vec![String::from("evil-agent")]);
657 assert_eq!(
658 outcome.rejected_user_agents.len(),
659 1,
660 "the unsafe pattern rejects with its reason"
661 );
662 assert!(!config.enable_penetration_detection);
663 assert!(!config.enable_ip_banning);
664 assert!(config.enable_rate_limiting);
665 assert_eq!(
666 config.auto_ban_threshold, 10,
667 "the emergency halves 20 to 10"
668 );
669 assert_eq!(config.auto_ban_duration, 9_000);
670 assert!(!config.enable_rate_limit_auto_ban);
671 assert!(config.emergency_mode);
672 assert_eq!(config.emergency_whitelist, vec![String::from("10.0.0.1")]);
673 assert!(outcome.emergency_activated);
674 assert_eq!(outcome.patterns, vec![String::from("union.*select")]);
675 }
676
677 #[test]
678 fn the_application_without_rule_content_changes_nothing() {
679 let mut config = SecurityConfig::default();
680 let before = config.clone();
681 let outcome = apply_to_config(&mut config, &rules());
682 assert_eq!(config.blocked_countries, before.blocked_countries);
683 assert_eq!(config.rate_limit, before.rate_limit);
684 assert_eq!(config.emergency_mode, before.emergency_mode);
685 assert_eq!(config.auto_ban_threshold, before.auto_ban_threshold);
686 assert!(outcome.bans.is_empty());
687 assert!(outcome.unbans.is_empty());
688 assert!(outcome.patterns.is_empty());
689 assert!(outcome.rejected_user_agents.is_empty());
690 assert!(!outcome.emergency_activated);
691 }
692
693 #[test]
694 fn the_emergency_threshold_never_reaches_zero() {
695 let mut config = SecurityConfig {
696 auto_ban_threshold: 1,
697 ..SecurityConfig::default()
698 };
699 let mut r = rules();
700 r.emergency_mode = true;
701 let outcome = apply_to_config(&mut config, &r);
702 assert_eq!(config.auto_ban_threshold, 1, "max(1, 1/2)");
703 assert!(outcome.emergency_activated);
704 }
705
706 fn ip(text: &str) -> IpAddr {
707 text.parse().expect("valid ip")
708 }
709
710 #[test]
711 fn debug_roundtrip() {
712 let mut r = DynamicRules {
713 rule_id: String::from("rule-1"),
714 version: 2,
715 ..DynamicRules::default()
716 };
717 r.ip_blacklist = vec![String::from("192.0.2.1")];
718 let v = serde_json::to_value(&r).expect("serializes");
719 let back: DynamicRules = serde_json::from_value(v).expect("decodes");
720 assert_eq!(back.rule_id, String::from("rule-1"));
721 assert_eq!(back.ip_blacklist, vec![String::from("192.0.2.1")]);
722 }
723
724 #[test]
725 fn a_wrong_typed_field_fails_the_decode() {
726 let payload = serde_json::json!({ "version": "not-a-number" });
727 let back: Result<DynamicRules, _> = serde_json::from_value(payload);
728 assert!(back.is_err(), "a wrong-typed field is a decode error");
729 }
730}