use std::collections::{BTreeMap, BTreeSet};
use std::net::IpAddr;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::compiler::validate_pattern_safety;
use crate::decorators::VALID_CLOUD_PROVIDERS;
use crate::security_config::SecurityConfig;
pub const LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION: u64 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
#[allow(clippy::derive_partial_eq_without_eq)]
pub struct DynamicRules {
pub rule_id: String,
pub version: u64,
pub timestamp: Option<DateTime<Utc>>,
pub expires_at: Option<DateTime<Utc>>,
pub ttl: u64,
pub ip_blacklist: Vec<String>,
pub ip_whitelist: Vec<String>,
pub ip_ban_duration: u64,
pub blocked_countries: Vec<String>,
pub whitelist_countries: Vec<String>,
pub global_rate_limit: Option<u64>,
pub global_rate_window: Option<u64>,
pub endpoint_rate_limits: BTreeMap<String, (u64, u64)>,
pub blocked_cloud_providers: BTreeSet<String>,
pub blocked_user_agents: Vec<String>,
pub suspicious_patterns: Vec<String>,
pub enable_penetration_detection: Option<bool>,
pub enable_ip_banning: Option<bool>,
pub enable_rate_limiting: Option<bool>,
pub auto_ban_threshold: Option<u64>,
pub auto_ban_duration: Option<u64>,
pub enable_rate_limit_auto_ban: Option<bool>,
pub emergency_mode: bool,
pub emergency_whitelist: Vec<String>,
}
impl Default for DynamicRules {
fn default() -> Self {
Self {
rule_id: String::new(),
version: 0,
timestamp: None,
expires_at: None,
ttl: 300,
ip_blacklist: Vec::new(),
ip_whitelist: Vec::new(),
ip_ban_duration: 3600,
blocked_countries: Vec::new(),
whitelist_countries: Vec::new(),
global_rate_limit: None,
global_rate_window: None,
endpoint_rate_limits: BTreeMap::new(),
blocked_cloud_providers: BTreeSet::new(),
blocked_user_agents: Vec::new(),
suspicious_patterns: Vec::new(),
enable_penetration_detection: None,
enable_ip_banning: None,
enable_rate_limiting: None,
auto_ban_threshold: None,
auto_ban_duration: None,
enable_rate_limit_auto_ban: None,
emergency_mode: false,
emergency_whitelist: Vec::new(),
}
}
}
impl DynamicRules {
#[must_use]
pub fn identity(&self) -> (&str, u64) {
(&self.rule_id, self.version)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct LastKnownRulesSnapshot {
pub schema_version: u64,
pub rules: DynamicRules,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotError(pub String);
impl core::fmt::Display for SnapshotError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "last-known rules snapshot error: {}", self.0)
}
}
impl std::error::Error for SnapshotError {}
pub fn dump_last_known_rules_snapshot(rules: &DynamicRules) -> Result<String, SnapshotError> {
let snapshot = LastKnownRulesSnapshot {
schema_version: LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION,
rules: rules.clone(),
};
serde_json::to_string(&snapshot)
.map_err(|error| SnapshotError(format!("snapshot serialization: {error}")))
}
pub fn load_last_known_rules_snapshot(payload: &str) -> Result<DynamicRules, SnapshotError> {
let snapshot: LastKnownRulesSnapshot = serde_json::from_str(payload)
.map_err(|error| SnapshotError(format!("snapshot decode: {error}")))?;
if snapshot.schema_version != LAST_KNOWN_RULES_SNAPSHOT_SCHEMA_VERSION {
return Err(SnapshotError(format!(
"Unsupported last-known dynamic rules snapshot schema version: {}",
snapshot.schema_version
)));
}
Ok(snapshot.rules)
}
#[must_use]
pub fn has_expired(rules: &DynamicRules, now: DateTime<Utc>) -> bool {
rules.expires_at.is_some_and(|expires_at| now > expires_at)
}
#[must_use]
pub fn should_update(current: Option<&DynamicRules>, incoming: &DynamicRules) -> bool {
current.is_none_or(|current| {
!(incoming.rule_id == current.rule_id && incoming.version <= current.version)
})
}
#[must_use]
pub fn expired_identity(
last_skipped: &mut Option<(String, u64)>,
rules: &DynamicRules,
now: DateTime<Utc>,
) -> bool {
if !has_expired(rules, now) {
return false;
}
let key = (rules.rule_id.clone(), rules.version);
if last_skipped.as_ref() != Some(&key) {
*last_skipped = Some(key);
}
true
}
#[must_use]
pub fn matches_event(
rules: &DynamicRules,
ip: Option<&str>,
country: Option<&str>,
event_type: &str,
) -> Option<(String, u64)> {
if let Some(ip) = ip
&& (rules.ip_blacklist.iter().any(|banned| banned == ip)
|| rules.ip_whitelist.iter().any(|allowed| allowed == ip))
{
return Some((rules.rule_id.clone(), rules.version));
}
if let Some(country) = country
&& rules
.blocked_countries
.iter()
.any(|blocked| blocked.eq_ignore_ascii_case(country))
{
return Some((rules.rule_id.clone(), rules.version));
}
let type_matches = match event_type {
"rate_limited" => {
rules.global_rate_limit.is_some() || !rules.endpoint_rate_limits.is_empty()
}
"cloud_blocked" => !rules.blocked_cloud_providers.is_empty(),
"user_agent_blocked" => !rules.blocked_user_agents.is_empty(),
_ => false,
};
if type_matches {
return Some((rules.rule_id.clone(), rules.version));
}
None
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AppliedDynamicRules {
pub bans: Vec<(IpAddr, u64)>,
pub unparseable_bans: Vec<String>,
pub unbans: Vec<IpAddr>,
pub unparseable_unbans: Vec<String>,
pub patterns: Vec<String>,
pub rejected_user_agents: Vec<(String, String)>,
pub emergency_activated: bool,
}
fn validate_user_agent_rules(
patterns: &[String],
cache: Option<&crate::redos::validation_cache::ValidationCache>,
) -> (Vec<String>, Vec<(String, String)>) {
let mut valid = Vec::new();
let mut rejected = Vec::new();
for pattern in patterns {
let (is_safe, reason) = cache.map_or_else(
|| validate_pattern_safety(pattern),
|cache| cache.validate_compat(pattern),
);
if is_safe {
valid.push(pattern.clone());
} else {
rejected.push((pattern.clone(), reason));
}
}
(valid, rejected)
}
pub fn apply_to_config(config: &mut SecurityConfig, rules: &DynamicRules) -> AppliedDynamicRules {
apply_to_config_with_validation_cache(config, rules, None)
}
pub fn apply_to_config_with_validation_cache(
config: &mut SecurityConfig,
rules: &DynamicRules,
cache: Option<&crate::redos::validation_cache::ValidationCache>,
) -> AppliedDynamicRules {
let mut outcome = AppliedDynamicRules::default();
for ip in &rules.ip_blacklist {
match ip.parse::<IpAddr>() {
Ok(parsed) => outcome.bans.push((parsed, rules.ip_ban_duration)),
Err(_) => outcome.unparseable_bans.push(ip.clone()),
}
}
for ip in &rules.ip_whitelist {
match ip.parse::<IpAddr>() {
Ok(parsed) => outcome.unbans.push(parsed),
Err(_) => outcome.unparseable_unbans.push(ip.clone()),
}
}
if !rules.blocked_countries.is_empty() {
config.blocked_countries = rules
.blocked_countries
.iter()
.map(|c| c.to_uppercase())
.collect::<BTreeSet<_>>();
}
if !rules.whitelist_countries.is_empty() {
config.whitelist_countries = rules
.whitelist_countries
.iter()
.map(|c| c.to_uppercase())
.collect::<BTreeSet<_>>();
}
if let Some(limit) = rules.global_rate_limit {
config.rate_limit = u32::try_from(limit).unwrap_or(u32::MAX);
if let Some(window) = rules.global_rate_window {
config.rate_limit_window = window;
}
}
if !rules.endpoint_rate_limits.is_empty() {
config.endpoint_rate_limits = rules
.endpoint_rate_limits
.iter()
.map(|(endpoint, (limit, window))| {
(
endpoint.clone(),
(u32::try_from(*limit).unwrap_or(u32::MAX), *window),
)
})
.collect();
}
if !rules.blocked_cloud_providers.is_empty() {
let valid: BTreeSet<String> = rules
.blocked_cloud_providers
.iter()
.filter(|provider| {
let base = provider.split(":!").next().unwrap_or(provider);
VALID_CLOUD_PROVIDERS.contains(&base)
})
.cloned()
.collect();
config.block_cloud_providers = Some(valid);
}
if !rules.blocked_user_agents.is_empty() {
let (valid, rejected) = validate_user_agent_rules(&rules.blocked_user_agents, cache);
outcome.rejected_user_agents = rejected;
config.blocked_user_agents = valid;
}
if let Some(flag) = rules.enable_penetration_detection {
config.enable_penetration_detection = flag;
}
if let Some(flag) = rules.enable_ip_banning {
config.enable_ip_banning = flag;
}
if let Some(flag) = rules.enable_rate_limiting {
config.enable_rate_limiting = flag;
}
if let Some(threshold) = rules.auto_ban_threshold {
config.auto_ban_threshold = u32::try_from(threshold).unwrap_or(u32::MAX);
}
if let Some(duration) = rules.auto_ban_duration {
config.auto_ban_duration = duration;
}
if let Some(flag) = rules.enable_rate_limit_auto_ban {
config.enable_rate_limit_auto_ban = flag;
}
if rules.emergency_mode {
config.emergency_mode = true;
config
.emergency_whitelist
.clone_from(&rules.emergency_whitelist);
let original = config.auto_ban_threshold;
config.auto_ban_threshold = (original / 2).max(1);
outcome.emergency_activated = true;
}
outcome.patterns.clone_from(&rules.suspicious_patterns);
outcome
}
#[cfg(test)]
mod dynamic_rules_tests {
use super::*;
use chrono::TimeZone;
fn rules() -> DynamicRules {
DynamicRules {
rule_id: String::from("rule-1"),
version: 2,
..DynamicRules::default()
}
}
fn at(secs: i64) -> DateTime<Utc> {
Utc.timestamp_opt(secs, 0).single().expect("valid time")
}
#[test]
fn the_model_defaults_carry_the_reference_values() {
let base = DynamicRules::default();
assert_eq!(base.ttl, 300);
assert_eq!(base.ip_ban_duration, 3600);
assert!(!base.emergency_mode);
assert!(base.ip_blacklist.is_empty());
assert_eq!(base.identity(), ("", 0));
}
#[test]
fn the_snapshot_envelope_round_trips_and_guards_the_version() {
let rules = rules();
let payload = dump_last_known_rules_snapshot(&rules).expect("dumps");
assert!(payload.contains("\"schema_version\":1"));
let loaded = load_last_known_rules_snapshot(&payload).expect("loads");
assert_eq!(loaded, rules);
let wrong_version = payload.replace("\"schema_version\":1", "\"schema_version\":9");
let error = load_last_known_rules_snapshot(&wrong_version).expect_err("rejects");
assert!(error.to_string().contains("Unsupported last-known"));
let decode_error = load_last_known_rules_snapshot("not json").expect_err("rejects");
assert!(decode_error.to_string().contains("snapshot decode"));
let error_shape = SnapshotError(String::from("boom"));
assert_eq!(
error_shape.to_string(),
"last-known rules snapshot error: boom"
);
}
#[test]
fn the_expiry_gate_reads_the_deadline() {
let rules = DynamicRules {
expires_at: Some(at(500)),
..rules()
};
assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
assert!(!has_expired(&rules, at(500)), "the boundary is exclusive");
assert!(has_expired(&rules, at(501)));
}
#[test]
fn the_update_gate_advances_versions_never_replays() {
assert!(should_update(None, &rules()));
let incoming = rules();
assert!(!should_update(Some(&incoming), &incoming));
let mut newer = rules();
newer.version = 3;
assert!(should_update(Some(&incoming), &newer));
let mut stale = rules();
stale.version = 1;
assert!(!should_update(Some(&incoming), &stale));
let mut other_id = rules();
other_id.rule_id = String::from("rule-2");
other_id.version = 1;
assert!(
should_update(Some(&incoming), &other_id),
"a new id updates"
);
}
#[test]
fn the_expired_identity_deduplicates_its_warning() {
let mut last_skipped: Option<(String, u64)> = None;
let mut expiring = rules();
expiring.expires_at = Some(at(500));
assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
let first = last_skipped.clone();
assert!(expired_identity(&mut last_skipped, &expiring, at(501)));
assert_eq!(first, last_skipped, "the same rule logs once");
let mut fresh = rules();
fresh.rule_id = String::from("rule-2");
fresh.version = 7;
fresh.expires_at = Some(at(100));
assert!(expired_identity(&mut last_skipped, &fresh, at(501)));
assert_ne!(first, last_skipped, "a new identity re-warns");
assert!(!expired_identity(&mut last_skipped, &rules(), at(501)));
}
#[test]
fn the_event_matcher_answers_the_three_arms() {
let mut r = DynamicRules {
ip_blacklist: vec![String::from("192.0.2.1")],
ip_whitelist: vec![String::from("192.0.2.2")],
blocked_countries: vec![String::from("CN")],
..rules()
};
assert_eq!(
matches_event(&r, Some("192.0.2.1"), None, "other"),
Some((String::from("rule-1"), 2))
);
assert_eq!(
matches_event(&r, Some("192.0.2.2"), None, "other"),
Some((String::from("rule-1"), 2)),
"the whitelist arm correlates too"
);
assert_eq!(
matches_event(&r, None, Some("cn"), "other"),
Some((String::from("rule-1"), 2)),
"the country arm is case-insensitive"
);
assert_eq!(
matches_event(&r, Some("192.0.2.9"), Some("US"), "other"),
None
);
assert_eq!(matches_event(&r, None, None, "rate_limited"), None);
r.global_rate_limit = Some(10);
assert!(matches_event(&r, None, None, "rate_limited").is_some());
r.blocked_cloud_providers.insert(String::from("AWS"));
assert!(matches_event(&r, None, None, "cloud_blocked").is_some());
r.blocked_user_agents = vec![String::from("curl")];
assert!(matches_event(&r, None, None, "user_agent_blocked").is_some());
}
#[test]
fn the_application_lands_every_config_surface() {
let mut config = SecurityConfig {
auto_ban_threshold: 10,
..SecurityConfig::default()
};
let mut r = rules();
r.ip_blacklist = vec![String::from("192.0.2.1"), String::from("not-an-ip")];
r.ip_whitelist = vec![String::from("192.0.2.2"), String::from("also-bad")];
r.blocked_countries = vec![String::from("cn"), String::from("ru")];
r.whitelist_countries = vec![String::from("us")];
r.global_rate_limit = Some(25);
r.global_rate_window = Some(45);
r.endpoint_rate_limits.insert(String::from("/api"), (7, 30));
r.blocked_cloud_providers = BTreeSet::from([
String::from("AWS"),
String::from("AWS:!prod"),
String::from("GCP"),
String::from("Mystery"),
]);
r.blocked_user_agents = vec![String::from("evil-agent"), String::from("(")];
r.enable_penetration_detection = Some(false);
r.enable_ip_banning = Some(false);
r.enable_rate_limiting = Some(true);
r.auto_ban_threshold = Some(20);
r.auto_ban_duration = Some(9_000);
r.enable_rate_limit_auto_ban = Some(false);
r.emergency_mode = true;
r.emergency_whitelist = vec![String::from("10.0.0.1")];
r.suspicious_patterns = vec![String::from("union.*select")];
let outcome = apply_to_config(&mut config, &r);
assert_eq!(outcome.bans, vec![(ip("192.0.2.1"), 3_600)]);
assert_eq!(outcome.unparseable_bans, vec![String::from("not-an-ip")]);
assert_eq!(outcome.unbans, vec![ip("192.0.2.2")]);
assert_eq!(outcome.unparseable_unbans, vec![String::from("also-bad")]);
assert_eq!(
config.blocked_countries,
BTreeSet::from([String::from("CN"), String::from("RU")])
);
assert_eq!(
config.whitelist_countries,
BTreeSet::from([String::from("US")])
);
assert_eq!(config.rate_limit, 25);
assert_eq!(config.rate_limit_window, 45);
assert_eq!(config.endpoint_rate_limits["/api"], (7, 30));
assert_eq!(
config.block_cloud_providers,
Some(BTreeSet::from([
String::from("AWS"),
String::from("AWS:!prod"),
String::from("GCP"),
]))
);
assert_eq!(config.blocked_user_agents, vec![String::from("evil-agent")]);
assert_eq!(
outcome.rejected_user_agents.len(),
1,
"the unsafe pattern rejects with its reason"
);
assert!(!config.enable_penetration_detection);
assert!(!config.enable_ip_banning);
assert!(config.enable_rate_limiting);
assert_eq!(
config.auto_ban_threshold, 10,
"the emergency halves 20 to 10"
);
assert_eq!(config.auto_ban_duration, 9_000);
assert!(!config.enable_rate_limit_auto_ban);
assert!(config.emergency_mode);
assert_eq!(config.emergency_whitelist, vec![String::from("10.0.0.1")]);
assert!(outcome.emergency_activated);
assert_eq!(outcome.patterns, vec![String::from("union.*select")]);
}
#[test]
fn the_application_without_rule_content_changes_nothing() {
let mut config = SecurityConfig::default();
let before = config.clone();
let outcome = apply_to_config(&mut config, &rules());
assert_eq!(config.blocked_countries, before.blocked_countries);
assert_eq!(config.rate_limit, before.rate_limit);
assert_eq!(config.emergency_mode, before.emergency_mode);
assert_eq!(config.auto_ban_threshold, before.auto_ban_threshold);
assert!(outcome.bans.is_empty());
assert!(outcome.unbans.is_empty());
assert!(outcome.patterns.is_empty());
assert!(outcome.rejected_user_agents.is_empty());
assert!(!outcome.emergency_activated);
}
#[test]
fn the_emergency_threshold_never_reaches_zero() {
let mut config = SecurityConfig {
auto_ban_threshold: 1,
..SecurityConfig::default()
};
let mut r = rules();
r.emergency_mode = true;
let outcome = apply_to_config(&mut config, &r);
assert_eq!(config.auto_ban_threshold, 1, "max(1, 1/2)");
assert!(outcome.emergency_activated);
}
fn ip(text: &str) -> IpAddr {
text.parse().expect("valid ip")
}
#[test]
fn debug_roundtrip() {
let mut r = DynamicRules {
rule_id: String::from("rule-1"),
version: 2,
..DynamicRules::default()
};
r.ip_blacklist = vec![String::from("192.0.2.1")];
let v = serde_json::to_value(&r).expect("serializes");
let back: DynamicRules = serde_json::from_value(v).expect("decodes");
assert_eq!(back.rule_id, String::from("rule-1"));
assert_eq!(back.ip_blacklist, vec![String::from("192.0.2.1")]);
}
#[test]
fn a_wrong_typed_field_fails_the_decode() {
let payload = serde_json::json!({ "version": "not-a-number" });
let back: Result<DynamicRules, _> = serde_json::from_value(payload);
assert!(back.is_err(), "a wrong-typed field is a decode error");
}
}