mod.rs 333 lignes · 11420 octets
//! Sliding-window detector chain for the fediverse guard.
//!
//! Each detector watches one [`GuardEventKind`] and triggers an
//! auto-ban via [`BanManager`] when the count of qualifying events
//! within the sliding window strictly exceeds the configured
//! threshold. Detectors are stateless: they query the
//! [`crate::store::GuardStore`] for the count at evaluation time, so
//! they tolerate process restarts and concurrent producers without
//! coordination.
//!
//! The five canonical detectors map 1-to-1 to the five
//! [`GuardEventKind`] variants emitted by the federation layer:
//!
//! | Detector            | Source event             |
//! |---------------------|--------------------------|
//! | `signature_failure` | `SignatureFailure`       |
//! | `replay_attempt`    | `ReplayAttempt`          |
//! | `fetch_ssrf`        | `FetchSsrf`              |
//! | `inbox_abuse`       | `InboxAbuse`             |
//! | `auth_spam`         | `AuthSpam`               |
//!
//! Build the canonical set with [`default_detectors`] and run them
//! against a fresh event's domain with [`run_all`].

use std::sync::Arc;

use async_trait::async_trait;
use chrono::Utc;
use rustwarden_core::error::AppError;

use crate::ban::BanManager;
use crate::config::{DetectorThreshold, GuardConfig};
use crate::events::GuardEventKind;

/// Sliding-window detector contract.
#[async_trait]
pub trait Detector: Send + Sync {
    /// Static identifier captured in `BanEntry.triggered_by`
    /// as `"auto:<name>"`. Must be stable across releases — admins
    /// will see it in audit logs.
    fn name(&self) -> &'static str;

    /// Event kind this detector counts.
    fn kind(&self) -> GuardEventKind;

    /// Inspect the rolling window for `domain` and return `true` if
    /// the threshold was exceeded *and* a new ban was inserted.
    /// Returns `false` when the count is below threshold, when an
    /// active ban already exists, when the guard is in dry-run, or
    /// when the domain is on the allow ACL.
    async fn check(&self, manager: &BanManager, domain: &str) -> Result<bool, AppError>;
}

/// Generic threshold-based detector. Five instances cover the
/// canonical chain (see [`default_detectors`]).
#[derive(Clone)]
pub struct ThresholdDetector {
    name: &'static str,
    kind: GuardEventKind,
    threshold: DetectorThreshold,
}

impl ThresholdDetector {
    /// Build a detector watching `kind` against `threshold`.
    #[must_use]
    pub const fn new(
        name: &'static str,
        kind: GuardEventKind,
        threshold: DetectorThreshold,
    ) -> Self {
        Self {
            name,
            kind,
            threshold,
        }
    }

    /// Returns the threshold (count + window) carried by this detector.
    #[must_use]
    pub fn threshold(&self) -> DetectorThreshold {
        self.threshold
    }
}

#[async_trait]
impl Detector for ThresholdDetector {
    fn name(&self) -> &'static str {
        self.name
    }

    fn kind(&self) -> GuardEventKind {
        self.kind
    }

    async fn check(&self, manager: &BanManager, domain: &str) -> Result<bool, AppError> {
        if !manager.config().enabled {
            return Ok(false);
        }
        let window = chrono::Duration::from_std(self.threshold.window)
            .unwrap_or_else(|_| chrono::Duration::seconds(300));
        let since = Utc::now() - window;
        let count = manager
            .store()
            .count_events_since(domain, self.kind, since)
            .await?;
        if count > u64::from(self.threshold.count) {
            let reason = format!(
                "threshold exceeded: {count} events of {kind:?} within {window_secs}s",
                kind = self.kind,
                window_secs = self.threshold.window.as_secs(),
            );
            let ban = manager.auto_ban(domain, self.name, reason).await?;
            return Ok(ban.is_some());
        }
        Ok(false)
    }
}

/// Build the canonical detector chain aligned with the supplied
/// [`GuardConfig`].
#[must_use]
pub fn default_detectors(config: &GuardConfig) -> Vec<Arc<dyn Detector>> {
    vec![
        Arc::new(ThresholdDetector::new(
            "signature_failure",
            GuardEventKind::SignatureFailure,
            config.signature_failures,
        )),
        Arc::new(ThresholdDetector::new(
            "replay_attempt",
            GuardEventKind::ReplayAttempt,
            config.replay_attempts,
        )),
        Arc::new(ThresholdDetector::new(
            "fetch_ssrf",
            GuardEventKind::FetchSsrf,
            config.fetch_ssrf,
        )),
        Arc::new(ThresholdDetector::new(
            "inbox_abuse",
            GuardEventKind::InboxAbuse,
            config.inbox_abuse,
        )),
        Arc::new(ThresholdDetector::new(
            "auth_spam",
            GuardEventKind::AuthSpam,
            config.auth_spam,
        )),
    ]
}

/// Run every detector against `domain` and return the names of those
/// that triggered a ban this call. In practice the list contains 0
/// or 1 entries because once `BanManager::auto_ban` inserts a ban,
/// any subsequent detector finds the existing active row and skips.
///
/// # Errors
///
/// Propagates errors from the underlying store.
pub async fn run_all(
    detectors: &[Arc<dyn Detector>],
    manager: &BanManager,
    domain: &str,
) -> Result<Vec<&'static str>, AppError> {
    let mut triggered = Vec::new();
    for det in detectors {
        if det.check(manager, domain).await? {
            triggered.push(det.name());
        }
    }
    Ok(triggered)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::events::{GuardEvent, Severity};
    use crate::store::{AclEntry, AclKind, GuardStore, MemoryStore};
    use uuid::Uuid;

    fn manager() -> BanManager {
        BanManager::new(Arc::new(MemoryStore::new()), GuardConfig::for_tests())
    }

    fn manager_with(cfg: GuardConfig) -> BanManager {
        BanManager::new(Arc::new(MemoryStore::new()), cfg)
    }

    async fn push_events(store: &Arc<dyn GuardStore>, kind: GuardEventKind, domain: &str, n: u32) {
        for _ in 0..n {
            let evt = GuardEvent::new(kind, domain, Severity::Medium);
            store.insert_event(&evt).await.unwrap();
        }
    }

    fn signature_detector() -> ThresholdDetector {
        ThresholdDetector::new(
            "signature_failure",
            GuardEventKind::SignatureFailure,
            DetectorThreshold::new(3, 60),
        )
    }

    #[tokio::test]
    async fn check_does_not_trigger_at_threshold_boundary() {
        let m = manager();
        let det = signature_detector();
        // Threshold 3 means "strictly more than 3" → 3 events do not trigger.
        push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 3).await;
        assert!(!det.check(&m, "x.tld").await.unwrap());
    }

    #[tokio::test]
    async fn check_triggers_when_threshold_strictly_exceeded() {
        let m = manager();
        let det = signature_detector();
        push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 4).await;
        assert!(det.check(&m, "x.tld").await.unwrap());
        // Ban actually persisted.
        let active = m
            .store()
            .active_bans_for_domain("x.tld", Utc::now())
            .await
            .unwrap();
        assert_eq!(active.len(), 1);
        assert!(active[0].triggered_by.starts_with("auto:signature_failure"));
    }

    #[tokio::test]
    async fn check_is_idempotent_once_banned() {
        let m = manager();
        let det = signature_detector();
        push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await;
        assert!(det.check(&m, "x.tld").await.unwrap());
        // Second run should not insert a new ban (already active).
        assert!(!det.check(&m, "x.tld").await.unwrap());
    }

    #[tokio::test]
    async fn check_ignores_other_event_kinds() {
        let m = manager();
        let det = signature_detector();
        // Inject 10 ReplayAttempt events — irrelevant for sig detector.
        push_events(m.store(), GuardEventKind::ReplayAttempt, "x.tld", 10).await;
        assert!(!det.check(&m, "x.tld").await.unwrap());
    }

    #[tokio::test]
    async fn allow_acl_prevents_trigger() {
        let m = manager();
        m.upsert_acl(&AclEntry {
            id: Uuid::new_v4(),
            kind: AclKind::Allow,
            domain: "trusted.tld".into(),
            reason: None,
            created_by: Uuid::new_v4(),
            created_at: Utc::now(),
            updated_at: Utc::now(),
        })
        .await
        .unwrap();
        let det = signature_detector();
        push_events(
            m.store(),
            GuardEventKind::SignatureFailure,
            "trusted.tld",
            10,
        )
        .await;
        assert!(!det.check(&m, "trusted.tld").await.unwrap());
    }

    #[tokio::test]
    async fn dry_run_inhibits_ban_insertion() {
        let mut cfg = GuardConfig::for_tests();
        cfg.dry_run = true;
        let m = manager_with(cfg);
        let det = signature_detector();
        push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await;
        assert!(!det.check(&m, "x.tld").await.unwrap());
    }

    #[tokio::test]
    async fn disabled_guard_short_circuits_check() {
        let mut cfg = GuardConfig::for_tests();
        cfg.enabled = false;
        let m = manager_with(cfg);
        let det = signature_detector();
        push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await;
        assert!(!det.check(&m, "x.tld").await.unwrap());
    }

    #[tokio::test]
    async fn sliding_window_excludes_old_events() {
        let m = manager();
        // Threshold 3 within 60s. Inject 4 events but all dated 10 min
        // in the past — the detector must see zero in the current window.
        let store = m.store().clone();
        let old = Utc::now() - chrono::Duration::minutes(10);
        for _ in 0..4 {
            let mut evt =
                GuardEvent::new(GuardEventKind::SignatureFailure, "x.tld", Severity::Medium);
            evt.created_at = old;
            store.insert_event(&evt).await.unwrap();
        }
        let det = signature_detector();
        assert!(!det.check(&m, "x.tld").await.unwrap());
    }

    #[tokio::test]
    async fn default_detectors_covers_five_kinds() {
        let cfg = GuardConfig::for_tests();
        let dets = default_detectors(&cfg);
        assert_eq!(dets.len(), 5);
        let kinds: Vec<GuardEventKind> = dets.iter().map(|d| d.kind()).collect();
        assert!(kinds.contains(&GuardEventKind::SignatureFailure));
        assert!(kinds.contains(&GuardEventKind::ReplayAttempt));
        assert!(kinds.contains(&GuardEventKind::FetchSsrf));
        assert!(kinds.contains(&GuardEventKind::InboxAbuse));
        assert!(kinds.contains(&GuardEventKind::AuthSpam));
    }

    #[tokio::test]
    async fn run_all_returns_triggered_names_only() {
        let m = manager();
        let cfg = GuardConfig::for_tests();
        let dets = default_detectors(&cfg);
        // Push enough signature failures to trip the first detector.
        push_events(m.store(), GuardEventKind::SignatureFailure, "x.tld", 10).await;
        let fired = run_all(&dets, &m, "x.tld").await.unwrap();
        assert_eq!(fired, vec!["signature_failure"]);
    }
}