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    if !snapshot.rules.auto_ban_overrides_valid() {
196        return Err(SnapshotError(String::from(
197            "snapshot decode: auto_ban_threshold and auto_ban_duration must be >= 1",
198        )));
199    }
200    Ok(snapshot.rules)
201}
202
203impl DynamicRules {
204    /// The pydantic `ge=1` constraints on the auto-ban overrides: the
205    /// reference rejects the whole delivery when either is zero.
206    #[must_use]
207    pub const fn auto_ban_overrides_valid(&self) -> bool {
208        !matches!(self.auto_ban_threshold, Some(0)) && !matches!(self.auto_ban_duration, Some(0))
209    }
210}
211
212/// `_has_rule_expired`: past `expires_at` at `now` (an absent expiry
213/// never expires).
214#[must_use]
215pub fn has_expired(rules: &DynamicRules, now: DateTime<Utc>) -> bool {
216    rules.expires_at.is_some_and(|expires_at| now > expires_at)
217}
218
219/// `_should_update_rules`: the first rule always updates; a same-id rule
220/// only when its version advances (a stale replay never re-applies).
221#[must_use]
222pub fn should_update(current: Option<&DynamicRules>, incoming: &DynamicRules) -> bool {
223    current.is_none_or(|current| {
224        !(incoming.rule_id == current.rule_id && incoming.version <= current.version)
225    })
226}
227
228/// `_reject_if_already_expired`'s de-duplication key: the same expired
229/// rule logs its warning once.
230#[must_use]
231pub fn expired_identity(
232    last_skipped: &mut Option<(String, u64)>,
233    rules: &DynamicRules,
234    now: DateTime<Utc>,
235) -> bool {
236    if !has_expired(rules, now) {
237        return false;
238    }
239    let key = (rules.rule_id.clone(), rules.version);
240    if last_skipped.as_ref() != Some(&key) {
241        *last_skipped = Some(key);
242    }
243    true
244}
245
246/// `match_event`: the enrichment correlation.
247///
248/// The rule answers `(rule_id, version)` when the event's IP sits on the
249/// rule's ban or allow lists, the event's country is one of the rule's
250/// blocked countries, or the event type names a rule surface that is in
251/// force (`rate_limited` with rate tiers, `cloud_blocked` with
252/// providers, `user_agent_blocked` with agents).
253#[must_use]
254pub fn matches_event(
255    rules: &DynamicRules,
256    ip: Option<&str>,
257    country: Option<&str>,
258    event_type: &str,
259) -> Option<(String, u64)> {
260    if let Some(ip) = ip
261        && (rules.ip_blacklist.iter().any(|banned| banned == ip)
262            || rules.ip_whitelist.iter().any(|allowed| allowed == ip))
263    {
264        return Some((rules.rule_id.clone(), rules.version));
265    }
266    if let Some(country) = country
267        && rules
268            .blocked_countries
269            .iter()
270            .any(|blocked| blocked.eq_ignore_ascii_case(country))
271    {
272        return Some((rules.rule_id.clone(), rules.version));
273    }
274    let type_matches = match event_type {
275        "rate_limited" => {
276            rules.global_rate_limit.is_some() || !rules.endpoint_rate_limits.is_empty()
277        }
278        "cloud_blocked" => !rules.blocked_cloud_providers.is_empty(),
279        "user_agent_blocked" => !rules.blocked_user_agents.is_empty(),
280        _ => false,
281    };
282    if type_matches {
283        return Some((rules.rule_id.clone(), rules.version));
284    }
285    None
286}
287
288/// What applying one rule produced.
289///
290/// The stateful lands for the caller's seams (the reference logs each of
291/// these; the outcome carries them so hosts log the returns).
292#[derive(Debug, Clone, Default, PartialEq, Eq)]
293pub struct AppliedDynamicRules {
294    /// `ip_blacklist` as `(ip, duration)` ban pairs that parsed.
295    pub bans: Vec<(IpAddr, u64)>,
296    /// `ip_blacklist` entries that failed to parse (reported, skipped).
297    pub unparseable_bans: Vec<String>,
298    /// `ip_whitelist` as unban addresses that parsed.
299    pub unbans: Vec<IpAddr>,
300    /// `ip_whitelist` entries that failed to parse (reported, skipped).
301    pub unparseable_unbans: Vec<String>,
302    /// `suspicious_patterns` additions for the pattern store.
303    pub patterns: Vec<String>,
304    /// `blocked_user_agents` patterns rejected by the ReDoS validator
305    /// with the validator's reason.
306    pub rejected_user_agents: Vec<(String, String)>,
307    /// Whether the emergency arm activated (threshold halved).
308    pub emergency_activated: bool,
309}
310
311/// `_apply_user_agent_rules`: the ReDoS-validated lane, the rejected
312/// patterns surfaced. The empirical cost verdict consults the optional
313/// disk-backed cache (the cheap layers re-run inside the validator).
314fn validate_user_agent_rules(
315    patterns: &[String],
316    cache: Option<&crate::redos::validation_cache::ValidationCache>,
317) -> (Vec<String>, Vec<(String, String)>) {
318    let mut valid = Vec::new();
319    let mut rejected = Vec::new();
320    for pattern in patterns {
321        let (is_safe, reason) = cache.map_or_else(
322            || validate_pattern_safety(pattern),
323            |cache| cache.validate_compat(pattern),
324        );
325        if is_safe {
326            valid.push(pattern.clone());
327        } else {
328            rejected.push((pattern.clone(), reason));
329        }
330    }
331    (valid, rejected)
332}
333
334/// The config-mutating half of `_apply_rules` +
335/// `DynamicRuleApplicationMixin`.
336///
337/// Every config landing the reference makes, in reference order, with
338/// the outcome carrying the stateful lands (bans, unbans, patterns) and
339/// the rejection reports.
340pub fn apply_to_config(config: &mut SecurityConfig, rules: &DynamicRules) -> AppliedDynamicRules {
341    apply_to_config_with_validation_cache(config, rules, None)
342}
343
344/// [`apply_to_config`] with the optional disk-backed validation cache.
345///
346/// The user-agent lane's ReDoS validation consults the cache for the
347/// empirical cost verdict, so a poll or a boot reuses prior
348/// certifications. (The cheap deterministic layers re-run inside the
349/// cached validator; only the timed cost verdict is cached.) The knob
350/// is `detection_pattern_validation_cache_path`.
351pub fn apply_to_config_with_validation_cache(
352    config: &mut SecurityConfig,
353    rules: &DynamicRules,
354    cache: Option<&crate::redos::validation_cache::ValidationCache>,
355) -> AppliedDynamicRules {
356    let mut outcome = AppliedDynamicRules::default();
357
358    // `_apply_ip_rules`: the bans and unbans surface for the caller's
359    // ban manager (the reference drives `ip_ban_manager` directly).
360    for ip in &rules.ip_blacklist {
361        match ip.parse::<IpAddr>() {
362            Ok(parsed) => outcome.bans.push((parsed, rules.ip_ban_duration)),
363            Err(_) => outcome.unparseable_bans.push(ip.clone()),
364        }
365    }
366    for ip in &rules.ip_whitelist {
367        match ip.parse::<IpAddr>() {
368            Ok(parsed) => outcome.unbans.push(parsed),
369            Err(_) => outcome.unparseable_unbans.push(ip.clone()),
370        }
371    }
372
373    // `_apply_country_rules`: the uppercased sets. Without a geo handler
374    // or an ipinfo token nothing can resolve an IP to a country, so the
375    // reference skips the rules (it warns) instead of installing them.
376    let countries_resolvable = config.geo_ip_handler.is_some()
377        || config
378            .ipinfo_token
379            .as_deref()
380            .is_some_and(|token| !token.is_empty());
381    if countries_resolvable && !rules.blocked_countries.is_empty() {
382        config.blocked_countries = rules
383            .blocked_countries
384            .iter()
385            .map(|c| c.to_uppercase())
386            .collect::<BTreeSet<_>>();
387    }
388    if countries_resolvable && !rules.whitelist_countries.is_empty() {
389        config.whitelist_countries = rules
390            .whitelist_countries
391            .iter()
392            .map(|c| c.to_uppercase())
393            .collect::<BTreeSet<_>>();
394    }
395
396    // `_apply_rate_limit_rules`: the global tier (the window only moves
397    // with a limit) and the endpoint tiers (clamped to the config's u32
398    // limit width).
399    // Zero is falsy in the reference (`if rules.global_rate_limit:`), so a
400    // zero limit or window leaves the config alone.
401    if let Some(limit) = rules.global_rate_limit.filter(|limit| *limit > 0) {
402        config.rate_limit = u32::try_from(limit).unwrap_or(u32::MAX);
403        if let Some(window) = rules.global_rate_window.filter(|window| *window > 0) {
404            config.rate_limit_window = window;
405        }
406    }
407    if !rules.endpoint_rate_limits.is_empty() {
408        config.endpoint_rate_limits = rules
409            .endpoint_rate_limits
410            .iter()
411            .map(|(endpoint, (limit, window))| {
412                (
413                    endpoint.clone(),
414                    (u32::try_from(*limit).unwrap_or(u32::MAX), *window),
415                )
416            })
417            .collect();
418    }
419
420    // `_apply_cloud_provider_rules`: the `VALID_CLOUD_PROVIDERS`
421    // vocabulary over the `:!`-stripped names; unknowns land in the
422    // outcome as ignored (the reference warns with the sorted set).
423    if !rules.blocked_cloud_providers.is_empty() {
424        let valid: BTreeSet<String> = rules
425            .blocked_cloud_providers
426            .iter()
427            .filter(|provider| {
428                let base = provider.split(":!").next().unwrap_or(provider);
429                VALID_CLOUD_PROVIDERS.contains(&base)
430            })
431            .cloned()
432            .collect();
433        config.block_cloud_providers = Some(valid);
434    }
435
436    // `_apply_user_agent_rules`: the ReDoS-validated lane, the rejected
437    // patterns surfaced.
438    if !rules.blocked_user_agents.is_empty() {
439        let (valid, rejected) = validate_user_agent_rules(&rules.blocked_user_agents, cache);
440        outcome.rejected_user_agents = rejected;
441        config.blocked_user_agents = valid;
442    }
443
444    // `_apply_feature_toggles`: the present-only overrides.
445    if let Some(flag) = rules.enable_penetration_detection {
446        config.enable_penetration_detection = flag;
447    }
448    if let Some(flag) = rules.enable_ip_banning {
449        config.enable_ip_banning = flag;
450    }
451    if let Some(flag) = rules.enable_rate_limiting {
452        config.enable_rate_limiting = flag;
453    }
454    if let Some(threshold) = rules.auto_ban_threshold {
455        config.auto_ban_threshold = u32::try_from(threshold).unwrap_or(u32::MAX);
456    }
457    if let Some(duration) = rules.auto_ban_duration {
458        config.auto_ban_duration = duration;
459    }
460    if let Some(flag) = rules.enable_rate_limit_auto_ban {
461        config.enable_rate_limit_auto_ban = flag;
462    }
463
464    // `_activate_emergency_mode`: the flag, the whitelist, and the
465    // halved threshold (`max(1, threshold / 2)`).
466    if rules.emergency_mode {
467        config.emergency_mode = true;
468        config
469            .emergency_whitelist
470            .clone_from(&rules.emergency_whitelist);
471        let original = config.auto_ban_threshold;
472        config.auto_ban_threshold = (original / 2).max(1);
473        outcome.emergency_activated = true;
474    }
475
476    // `_apply_pattern_rules`: the additions surface for the pattern
477    // store (the store's own validation decides acceptance).
478    outcome.patterns.clone_from(&rules.suspicious_patterns);
479    outcome
480}
481
482#[cfg(test)]
483mod dynamic_rules_tests {
484    use super::*;
485    use chrono::TimeZone;
486
487    fn rules() -> DynamicRules {
488        DynamicRules {
489            rule_id: String::from("rule-1"),
490            version: 2,
491            ..DynamicRules::default()
492        }
493    }
494
495    fn at(secs: i64) -> DateTime<Utc> {
496        Utc.timestamp_opt(secs, 0).single().expect("valid time")
497    }
498
499    #[test]
500    fn the_model_defaults_carry_the_reference_values() {
501        let base = DynamicRules::default();
502        assert_eq!(base.ttl, 300);
503        assert_eq!(base.ip_ban_duration, 3600);
504        assert!(!base.emergency_mode);
505        assert!(base.ip_blacklist.is_empty());
506        assert_eq!(base.identity(), ("", 0));
507    }
508
509    #[test]
510    fn the_snapshot_envelope_round_trips_and_guards_the_version() {
511        let rules = rules();
512        let payload = dump_last_known_rules_snapshot(&rules).expect("dumps");
513        assert!(payload.contains("\"schema_version\":1"));
514        let loaded = load_last_known_rules_snapshot(&payload).expect("loads");
515        assert_eq!(loaded, rules);
516
517        let wrong_version = payload.replace("\"schema_version\":1", "\"schema_version\":9");
518        let error = load_last_known_rules_snapshot(&wrong_version).expect_err("rejects");
519        assert!(error.to_string().contains("Unsupported last-known"));
520
521        let decode_error = load_last_known_rules_snapshot("not json").expect_err("rejects");
522        assert!(decode_error.to_string().contains("snapshot decode"));
523        let error_shape = SnapshotError(String::from("boom"));
524        assert_eq!(
525            error_shape.to_string(),
526            "last-known rules snapshot error: boom"
527        );
528    }
529
530    #[test]
531    fn the_expiry_gate_reads_the_deadline() {
532        let rules = DynamicRules {
533            expires_at: Some(at(500)),
534            ..rules()
535        };
536        assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
537        assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
538        assert!(has_expired(&rules, at(501)));
539    }
540
541    #[test]
542    fn the_update_gate_advances_versions_never_replays() {
543        assert!(should_update(None, &rules()));
544        let incoming = rules();
545        assert!(!should_update(Some(&incoming), &incoming));
546
547        let mut newer = rules();
548        newer.version = 3;
549        assert!(should_update(Some(&incoming), &newer));
550
551        let mut stale = rules();
552        stale.version = 1;
553        assert!(!should_update(Some(&incoming), &stale));
554
555        let mut other_id = rules();
556        other_id.rule_id = String::from("rule-2");
557        other_id.version = 1;
558        assert!(
559            should_update(Some(&incoming), &other_id),
560            "a new id updates"
561        );
562    }
563
564    #[test]
565    fn the_expired_identity_deduplicates_its_warning() {
566        let mut last_skipped: Option<(String, u64)> = None;
567        let mut expiring = rules();
568        expiring.expires_at = Some(at(500));
569
570        assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
571        let first = last_skipped.clone();
572        assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
573        assert_eq!(first, last_skipped, "the same rule logs once");
574
575        let mut fresh = rules();
576        fresh.rule_id = String::from("rule-2");
577        fresh.version = 7;
578        fresh.expires_at = Some(at(100));
579        assert!(expired_identity(&mut last_skipped, &fresh, at(501)));
580        assert_ne!(first, last_skipped, "a new identity re-warns");
581
582        assert!(!expired_identity(&mut last_skipped, &rules(), at(501)));
583    }
584
585    #[test]
586    fn the_event_matcher_answers_the_three_arms() {
587        let mut r = DynamicRules {
588            ip_blacklist: vec![String::from("192.0.2.1")],
589            ip_whitelist: vec![String::from("192.0.2.2")],
590            blocked_countries: vec![String::from("CN")],
591            ..rules()
592        };
593
594        assert_eq!(
595            matches_event(&r, Some("192.0.2.1"), None, "other"),
596            Some((String::from("rule-1"), 2))
597        );
598        assert_eq!(
599            matches_event(&r, Some("192.0.2.2"), None, "other"),
600            Some((String::from("rule-1"), 2)),
601            "the whitelist arm correlates too"
602        );
603        assert_eq!(
604            matches_event(&r, None, Some("cn"), "other"),
605            Some((String::from("rule-1"), 2)),
606            "the country arm is case-insensitive"
607        );
608        assert_eq!(
609            matches_event(&r, Some("192.0.2.9"), Some("US"), "other"),
610            None
611        );
612        assert_eq!(matches_event(&r, None, None, "rate_limited"), None);
613
614        r.global_rate_limit = Some(10);
615        assert!(matches_event(&r, None, None, "rate_limited").is_some());
616        r.blocked_cloud_providers.insert(String::from("AWS"));
617        assert!(matches_event(&r, None, None, "cloud_blocked").is_some());
618        r.blocked_user_agents = vec![String::from("curl")];
619        assert!(matches_event(&r, None, None, "user_agent_blocked").is_some());
620    }
621
622    #[test]
623    fn country_rules_need_a_resolver_and_zero_rate_limits_are_ignored() {
624        let mut config = SecurityConfig {
625            rate_limit: 100,
626            rate_limit_window: 60,
627            ..SecurityConfig::default()
628        };
629        let mut r = rules();
630        r.blocked_countries = vec![String::from("cn")];
631        r.whitelist_countries = vec![String::from("us")];
632        r.global_rate_limit = Some(0);
633        apply_to_config(&mut config, &r);
634        assert!(config.blocked_countries.is_empty());
635        assert!(config.whitelist_countries.is_empty());
636        assert_eq!(config.rate_limit, 100);
637
638        config.ipinfo_token = Some(String::new());
639        apply_to_config(&mut config, &r);
640        assert!(
641            config.blocked_countries.is_empty(),
642            "an empty token is falsy"
643        );
644
645        config.ipinfo_token = Some(String::from("token"));
646        r.global_rate_limit = Some(5);
647        r.global_rate_window = Some(0);
648        apply_to_config(&mut config, &r);
649        assert_eq!(
650            config.blocked_countries,
651            BTreeSet::from([String::from("CN")])
652        );
653        assert_eq!((config.rate_limit, config.rate_limit_window), (5, 60));
654    }
655
656    #[test]
657    fn a_zero_auto_ban_override_is_an_invalid_delivery() {
658        let mut r = rules();
659        assert!(r.auto_ban_overrides_valid());
660        r.auto_ban_threshold = Some(0);
661        assert!(!r.auto_ban_overrides_valid());
662        r.auto_ban_threshold = None;
663        r.auto_ban_duration = Some(0);
664        assert!(!r.auto_ban_overrides_valid());
665        let payload = dump_last_known_rules_snapshot(&r).expect("dumps");
666        let error = load_last_known_rules_snapshot(&payload).expect_err("rejects");
667        assert!(error.to_string().contains(">= 1"));
668    }
669
670    #[test]
671    fn the_application_lands_every_config_surface() {
672        let mut config = SecurityConfig {
673            auto_ban_threshold: 10,
674            ipinfo_token: Some(String::from("token")),
675            ..SecurityConfig::default()
676        };
677
678        let mut r = rules();
679        r.ip_blacklist = vec![String::from("192.0.2.1"), String::from("not-an-ip")];
680        r.ip_whitelist = vec![String::from("192.0.2.2"), String::from("also-bad")];
681        r.blocked_countries = vec![String::from("cn"), String::from("ru")];
682        r.whitelist_countries = vec![String::from("us")];
683        r.global_rate_limit = Some(25);
684        r.global_rate_window = Some(45);
685        r.endpoint_rate_limits.insert(String::from("/api"), (7, 30));
686        r.blocked_cloud_providers = BTreeSet::from([
687            String::from("AWS"),
688            String::from("AWS:!prod"),
689            String::from("GCP"),
690            String::from("Mystery"),
691        ]);
692        r.blocked_user_agents = vec![String::from("evil-agent"), String::from("(")];
693        r.enable_penetration_detection = Some(false);
694        r.enable_ip_banning = Some(false);
695        r.enable_rate_limiting = Some(true);
696        r.auto_ban_threshold = Some(20);
697        r.auto_ban_duration = Some(9_000);
698        r.enable_rate_limit_auto_ban = Some(false);
699        r.emergency_mode = true;
700        r.emergency_whitelist = vec![String::from("10.0.0.1")];
701        r.suspicious_patterns = vec![String::from("union.*select")];
702
703        let outcome = apply_to_config(&mut config, &r);
704
705        assert_eq!(outcome.bans, vec![(ip("192.0.2.1"), 3_600)]);
706        assert_eq!(outcome.unparseable_bans, vec![String::from("not-an-ip")]);
707        assert_eq!(outcome.unbans, vec![ip("192.0.2.2")]);
708        assert_eq!(outcome.unparseable_unbans, vec![String::from("also-bad")]);
709        assert_eq!(
710            config.blocked_countries,
711            BTreeSet::from([String::from("CN"), String::from("RU")])
712        );
713        assert_eq!(
714            config.whitelist_countries,
715            BTreeSet::from([String::from("US")])
716        );
717        assert_eq!(config.rate_limit, 25);
718        assert_eq!(config.rate_limit_window, 45);
719        assert_eq!(config.endpoint_rate_limits["/api"], (7, 30));
720        assert_eq!(
721            config.block_cloud_providers,
722            Some(BTreeSet::from([
723                String::from("AWS"),
724                String::from("AWS:!prod"),
725                String::from("GCP"),
726            ]))
727        );
728        assert_eq!(config.blocked_user_agents, vec![String::from("evil-agent")]);
729        assert_eq!(
730            outcome.rejected_user_agents.len(),
731            1,
732            "the unsafe pattern rejects with its reason"
733        );
734        assert!(!config.enable_penetration_detection);
735        assert!(!config.enable_ip_banning);
736        assert!(config.enable_rate_limiting);
737        assert_eq!(
738            config.auto_ban_threshold, 10,
739            "the emergency halves 20 to 10"
740        );
741        assert_eq!(config.auto_ban_duration, 9_000);
742        assert!(!config.enable_rate_limit_auto_ban);
743        assert!(config.emergency_mode);
744        assert_eq!(config.emergency_whitelist, vec![String::from("10.0.0.1")]);
745        assert!(outcome.emergency_activated);
746        assert_eq!(outcome.patterns, vec![String::from("union.*select")]);
747    }
748
749    #[test]
750    fn the_application_without_rule_content_changes_nothing() {
751        let mut config = SecurityConfig::default();
752        let before = config.clone();
753        let outcome = apply_to_config(&mut config, &rules());
754        assert_eq!(config.blocked_countries, before.blocked_countries);
755        assert_eq!(config.rate_limit, before.rate_limit);
756        assert_eq!(config.emergency_mode, before.emergency_mode);
757        assert_eq!(config.auto_ban_threshold, before.auto_ban_threshold);
758        assert!(outcome.bans.is_empty());
759        assert!(outcome.unbans.is_empty());
760        assert!(outcome.patterns.is_empty());
761        assert!(outcome.rejected_user_agents.is_empty());
762        assert!(!outcome.emergency_activated);
763    }
764
765    #[test]
766    fn the_emergency_threshold_never_reaches_zero() {
767        let mut config = SecurityConfig {
768            auto_ban_threshold: 1,
769            ..SecurityConfig::default()
770        };
771        let mut r = rules();
772        r.emergency_mode = true;
773        let outcome = apply_to_config(&mut config, &r);
774        assert_eq!(config.auto_ban_threshold, 1, "max(1, 1/2)");
775        assert!(outcome.emergency_activated);
776    }
777
778    fn ip(text: &str) -> IpAddr {
779        text.parse().expect("valid ip")
780    }
781
782    #[test]
783    fn debug_roundtrip() {
784        let mut r = DynamicRules {
785            rule_id: String::from("rule-1"),
786            version: 2,
787            ..DynamicRules::default()
788        };
789        r.ip_blacklist = vec![String::from("192.0.2.1")];
790        let v = serde_json::to_value(&r).expect("serializes");
791        let back: DynamicRules = serde_json::from_value(v).expect("decodes");
792        assert_eq!(back.rule_id, String::from("rule-1"));
793        assert_eq!(back.ip_blacklist, vec![String::from("192.0.2.1")]);
794    }
795
796    #[test]
797    fn a_wrong_typed_field_fails_the_decode() {
798        let payload = serde_json::json!({ "version": "not-a-number" });
799        let back: Result<DynamicRules, _> = serde_json::from_value(payload);
800        assert!(back.is_err(), "a wrong-typed field is a decode error");
801    }
802}