Skip to main content

guard_core_engine/
dynamic_rules.rs

1//! The dynamic-rules carrier: the reference `DynamicRules` wire model
2//! and its manager-side logic (`handlers/_dynamic_rule_*.py` +
3//! `_dynamic_rules.py`).
4//!
5//! The carrier holds the wire model, the last-known snapshot envelope,
6//! the update gating, the event matcher, and the config application.
7//! Sources, piece by piece:
8//!
9//! - the model: `_dynamic_rules.py` `DynamicRules` (the same field set
10//!   the guard-agent wire model carries; the defaults land verbatim -
11//!   `ttl = 300`, `ip_ban_duration = 3600`, `emergency_mode = false`);
12//! - the snapshot envelope: `dump_last_known_rules_snapshot` /
13//!   `load_last_known_rules_snapshot` (the `schema_version` guard
14//!   rejects unsupported versions at load);
15//! - the gating: `DynamicRuleManager._should_update_rules` (a new rule
16//!   id always updates; a same-id rule only when its version advances),
17//!   `_has_rule_expired` / `_reject_if_already_expired`;
18//! - the correlation: `DynamicRuleManager.match_event` (the IP, country,
19//!   and event-type arms the enricher reads);
20//! - the application: `DynamicRuleApplicationMixin` landings on the
21//!   unified config - uppercased country sets, the global/window/endpoint
22//!   rate tiers, the `VALID_CLOUD_PROVIDERS`-validated cloud set (the
23//!   `AWS:!region` scoping survives verbatim), the ReDoS-validated user
24//!   agent list (rejected patterns surface in the outcome), the feature
25//!   toggles, and the emergency-mode activation with the halved
26//!   auto-ban threshold (`max(1, threshold / 2)`).
27//!
28//! The module is pure: the bans, unbans, and pattern additions come back
29//! in the [`AppliedDynamicRules`] outcome for the caller's stateful
30//! seams (the ban manager, the pattern store), while the config-mutating
31//! half lands directly on the [`SecurityConfig`] the caller owns.
32
33use 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
43/// `LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION` (the envelope guard).
44pub const LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION: u64 = 1;
45
46/// The reference `DynamicRules` wire model, field for field (the same
47/// shape the guard-agent models carry).
48///
49/// `Eq` is unreachable while the model carries a `DateTime` (chrono
50/// answers `PartialEq` only), hence the pinpointed allowance.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[serde(default)]
53#[allow(clippy::derive_partial_eq_without_eq)]
54pub struct DynamicRules {
55    /// Unique rule ID.
56    pub rule_id: String,
57    /// Rule version number.
58    pub version: u64,
59    /// Rule creation/update timestamp.
60    pub timestamp: Option<DateTime<Utc>>,
61    /// Rule expiration time.
62    pub expires_at: Option<DateTime<Utc>>,
63    /// Cache TTL in seconds (drives the client-side rules cache).
64    pub ttl: u64,
65    /// IPs to ban.
66    pub ip_blacklist: Vec<String>,
67    /// IPs to allow.
68    pub ip_whitelist: Vec<String>,
69    /// Ban duration in seconds.
70    pub ip_ban_duration: u64,
71    /// Countries to block.
72    pub blocked_countries: Vec<String>,
73    /// Countries to allow.
74    pub whitelist_countries: Vec<String>,
75    /// Global rate limit.
76    pub global_rate_limit: Option<u64>,
77    /// Global rate window in seconds.
78    pub global_rate_window: Option<u64>,
79    /// Per-endpoint rate limits `{endpoint: (requests, window)}`.
80    pub endpoint_rate_limits: BTreeMap<String, (u64, u64)>,
81    /// Cloud providers to block (the `AWS:!region` scoping survives).
82    pub blocked_cloud_providers: BTreeSet<String>,
83    /// User agents to block.
84    pub blocked_user_agents: Vec<String>,
85    /// Additional suspicious patterns.
86    pub suspicious_patterns: Vec<String>,
87    /// Override penetration detection setting.
88    pub enable_penetration_detection: Option<bool>,
89    /// Override IP banning setting.
90    pub enable_ip_banning: Option<bool>,
91    /// Override rate limiting setting.
92    pub enable_rate_limiting: Option<bool>,
93    /// Override auto-ban threshold setting.
94    pub auto_ban_threshold: Option<u64>,
95    /// Override auto-ban duration setting.
96    pub auto_ban_duration: Option<u64>,
97    /// Override rate-limit auto-ban setting.
98    pub enable_rate_limit_auto_ban: Option<bool>,
99    /// Emergency lockdown mode.
100    pub emergency_mode: bool,
101    /// Emergency whitelist IPs.
102    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    /// The rule identity the gates compare (`(rule_id, version)`).
138    #[must_use]
139    pub fn identity(&self) -> (&str, u64) {
140        (&self.rule_id, self.version)
141    }
142}
143
144/// The reference snapshot envelope (`LastKnownRulesSnapshot`): the
145/// schema version beside the mirrored rules.
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147pub struct LastKnownRulesSnapshot {
148    /// The envelope schema version.
149    pub schema_version: u64,
150    /// The mirrored rules.
151    pub rules: DynamicRules,
152}
153
154/// A snapshot load failure (the version guard and the JSON decode).
155#[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
166/// `dump_last_known_rules_snapshot`: the versioned JSON envelope.
167///
168/// # Errors
169///
170/// [`SnapshotError`] when the payload fails to serialize.
171pub 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
180/// `load_last_known_rules_snapshot`: the version-guarded decode.
181///
182/// # Errors
183///
184/// [`SnapshotError`] on a decode failure or an unsupported schema
185/// version.
186pub 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/// `_has_rule_expired`: past `expires_at` at `now` (an absent expiry
199/// never expires).
200#[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/// `_should_update_rules`: the first rule always updates; a same-id rule
206/// only when its version advances (a stale replay never re-applies).
207#[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/// `_reject_if_already_expired`'s de-duplication key: the same expired
215/// rule logs its warning once.
216#[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/// `match_event`: the enrichment correlation.
233///
234/// The rule answers `(rule_id, version)` when the event's IP sits on the
235/// rule's ban or allow lists, the event's country is one of the rule's
236/// blocked countries, or the event type names a rule surface that is in
237/// force (`rate_limited` with rate tiers, `cloud_blocked` with
238/// providers, `user_agent_blocked` with agents).
239#[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/// What applying one rule produced.
275///
276/// The stateful lands for the caller's seams (the reference logs each of
277/// these; the outcome carries them so hosts log the returns).
278#[derive(Debug, Clone, Default, PartialEq, Eq)]
279pub struct AppliedDynamicRules {
280    /// `ip_blacklist` as `(ip, duration)` ban pairs that parsed.
281    pub bans: Vec<(IpAddr, u64)>,
282    /// `ip_blacklist` entries that failed to parse (reported, skipped).
283    pub unparseable_bans: Vec<String>,
284    /// `ip_whitelist` as unban addresses that parsed.
285    pub unbans: Vec<IpAddr>,
286    /// `ip_whitelist` entries that failed to parse (reported, skipped).
287    pub unparseable_unbans: Vec<String>,
288    /// `suspicious_patterns` additions for the pattern store.
289    pub patterns: Vec<String>,
290    /// `blocked_user_agents` patterns rejected by the ReDoS validator
291    /// with the validator's reason.
292    pub rejected_user_agents: Vec<(String, String)>,
293    /// Whether the emergency arm activated (threshold halved).
294    pub emergency_activated: bool,
295}
296
297/// `_apply_user_agent_rules`: the ReDoS-validated lane, the rejected
298/// patterns surfaced. The empirical cost verdict consults the optional
299/// disk-backed cache (the cheap layers re-run inside the validator).
300fn 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
320/// The config-mutating half of `_apply_rules` +
321/// `DynamicRuleApplicationMixin`.
322///
323/// Every config landing the reference makes, in reference order, with
324/// the outcome carrying the stateful lands (bans, unbans, patterns) and
325/// the rejection reports.
326pub fn apply_to_config(config: &mut SecurityConfig, rules: &DynamicRules) -> AppliedDynamicRules {
327    apply_to_config_with_validation_cache(config, rules, None)
328}
329
330/// [`apply_to_config`] with the optional disk-backed validation cache.
331///
332/// The user-agent lane's ReDoS validation consults the cache for the
333/// empirical cost verdict, so a poll or a boot reuses prior
334/// certifications. (The cheap deterministic layers re-run inside the
335/// cached validator; only the timed cost verdict is cached.) The knob
336/// is `detection_pattern_validation_cache_path`.
337pub 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    // `_apply_ip_rules`: the bans and unbans surface for the caller's
345    // ban manager (the reference drives `ip_ban_manager` directly).
346    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    // `_apply_country_rules`: the uppercased sets.
360    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    // `_apply_rate_limit_rules`: the global tier (the window only moves
376    // with a limit) and the endpoint tiers (clamped to the config's u32
377    // limit width).
378    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    // `_apply_cloud_provider_rules`: the `VALID_CLOUD_PROVIDERS`
398    // vocabulary over the `:!`-stripped names; unknowns land in the
399    // outcome as ignored (the reference warns with the sorted set).
400    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    // `_apply_user_agent_rules`: the ReDoS-validated lane, the rejected
414    // patterns surfaced.
415    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    // `_apply_feature_toggles`: the present-only overrides.
422    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    // `_activate_emergency_mode`: the flag, the whitelist, and the
442    // halved threshold (`max(1, threshold / 2)`).
443    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    // `_apply_pattern_rules`: the additions surface for the pattern
454    // store (the store's own validation decides acceptance).
455    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}