observer.rs 260 lignes · 8459 octets
//! Bridge between `rustwarden-fediverse` runtime events and the
//! guard's detection + ban pipeline.
//!
//! [`GuardObserver`] implements
//! [`rustwarden_fediverse::FediverseEventObserver`] and translates
//! each federation hook call into a [`GuardEvent`]:
//!
//! 1. Persist the event via [`crate::GuardStore::insert_event`].
//! 2. Optionally emit it through a [`GuardEventSink`] (file, OTel, …).
//! 3. Run every detector in the chain; a triggered detector calls
//!    [`crate::BanManager::auto_ban`] which inserts the ban row.
//!
//! Hot-path safety: every step swallows errors via `tracing::warn!`
//! rather than propagating, so a downstream hiccup never breaks the
//! federation request path.

use std::sync::Arc;

use async_trait::async_trait;
use rustwarden_fediverse::observer::FediverseEventObserver;
use serde_json::Value;

use crate::ban::BanManager;
use crate::detector::{Detector, run_all};
use crate::events::{GuardEvent, GuardEventKind, GuardEventSink, Severity};
use crate::store::normalise_domain;

/// Connect federation events to the guard pipeline.
pub struct GuardObserver {
    manager: BanManager,
    detectors: Vec<Arc<dyn Detector>>,
    sink: Option<Arc<dyn GuardEventSink>>,
}

impl GuardObserver {
    /// Build a new observer wrapping the supplied ban manager and
    /// detector chain. Pass `None` for `sink` when events only need
    /// to land in the store (the typical production setup).
    #[must_use]
    pub fn new(
        manager: BanManager,
        detectors: Vec<Arc<dyn Detector>>,
        sink: Option<Arc<dyn GuardEventSink>>,
    ) -> Self {
        Self {
            manager,
            detectors,
            sink,
        }
    }

    /// Borrow the underlying [`BanManager`] (useful for admin
    /// endpoints that read or mutate state directly).
    #[must_use]
    pub fn manager(&self) -> &BanManager {
        &self.manager
    }

    async fn record(
        &self,
        kind: GuardEventKind,
        domain: &str,
        actor_uri: Option<&str>,
        severity: Severity,
        details: Value,
    ) {
        if !self.manager.config().enabled {
            return;
        }
        let domain = normalise_domain(domain);
        if domain.is_empty() {
            return;
        }
        let mut evt = GuardEvent::new(kind, &domain, severity);
        evt.actor_uri = actor_uri.map(str::to_string);
        evt.details = details;
        if let Err(e) = self.manager.store().insert_event(&evt).await {
            tracing::warn!(error = %e, "guard observer: failed to persist event");
            return;
        }
        if let Some(sink) = &self.sink {
            sink.emit(&evt).await;
        }
        if let Err(e) = run_all(&self.detectors, &self.manager, &domain).await {
            tracing::warn!(error = %e, "guard observer: detector chain failed");
        }
    }
}

#[async_trait]
impl FediverseEventObserver for GuardObserver {
    async fn on_signature_failure(&self, domain: &str, actor_uri: Option<&str>, reason: &str) {
        self.record(
            GuardEventKind::SignatureFailure,
            domain,
            actor_uri,
            Severity::High,
            serde_json::json!({"reason": reason}),
        )
        .await;
    }

    async fn on_replay_attempt(&self, domain: &str, actor_uri: Option<&str>, details: Value) {
        self.record(
            GuardEventKind::ReplayAttempt,
            domain,
            actor_uri,
            Severity::High,
            details,
        )
        .await;
    }

    async fn on_fetch_ssrf(&self, target_host: &str, attempted_ip: Option<&str>) {
        let details = serde_json::json!({"attempted_ip": attempted_ip});
        self.record(
            GuardEventKind::FetchSsrf,
            target_host,
            None,
            Severity::Critical,
            details,
        )
        .await;
    }

    async fn on_inbox_abuse(&self, domain: &str, actor_uri: Option<&str>, reason: &str) {
        self.record(
            GuardEventKind::InboxAbuse,
            domain,
            actor_uri,
            Severity::Medium,
            serde_json::json!({"reason": reason}),
        )
        .await;
    }

    async fn on_auth_challenge(&self, domain: &str, handle: &str) {
        self.record(
            GuardEventKind::AuthSpam,
            domain,
            None,
            Severity::Low,
            serde_json::json!({"handle": handle}),
        )
        .await;
    }

    async fn on_invite_action(&self, _domain: &str, _decision: &str) {
        // Invites are not a security event by themselves and already
        // emit an `admin_id` tracing log from `invite_routes.rs`
        // (ANSSI FUI-AUTH-1). Skip to avoid polluting the guard with
        // benign traffic. A future detector specific to invite churn
        // can reuse this hook.
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::GuardConfig;
    use crate::detector::default_detectors;
    use crate::events::GuardEventKind;
    use crate::store::MemoryStore;
    use chrono::Utc;

    fn observer() -> GuardObserver {
        let cfg = GuardConfig::for_tests();
        let store: Arc<dyn crate::GuardStore> = Arc::new(MemoryStore::new());
        let manager = BanManager::new(store, cfg.clone());
        let detectors = default_detectors(&cfg);
        GuardObserver::new(manager, detectors, None)
    }

    #[tokio::test]
    async fn on_signature_failure_persists_event() {
        let obs = observer();
        obs.on_signature_failure("evil.tld", Some("https://evil.tld/users/x"), "bad sig")
            .await;
        let since = Utc::now() - chrono::Duration::seconds(60);
        let count = obs
            .manager()
            .store()
            .count_events_since("evil.tld", GuardEventKind::SignatureFailure, since)
            .await
            .unwrap();
        assert_eq!(count, 1);
    }

    #[tokio::test]
    async fn repeated_signature_failures_trigger_auto_ban() {
        let obs = observer();
        // for_tests threshold is 3 → 4 failures must trip the ban.
        for _ in 0..4 {
            obs.on_signature_failure("evil.tld", None, "bad sig").await;
        }
        let active = obs
            .manager()
            .store()
            .active_bans_for_domain("evil.tld", Utc::now())
            .await
            .unwrap();
        assert_eq!(active.len(), 1);
        assert!(active[0].triggered_by.starts_with("auto:signature_failure"));
    }

    #[tokio::test]
    async fn disabled_guard_swallows_calls_without_storing() {
        let mut cfg = GuardConfig::for_tests();
        cfg.enabled = false;
        let store: Arc<dyn crate::GuardStore> = Arc::new(MemoryStore::new());
        let manager = BanManager::new(store, cfg.clone());
        let detectors = default_detectors(&cfg);
        let obs = GuardObserver::new(manager, detectors, None);
        obs.on_signature_failure("evil.tld", None, "x").await;
        let since = Utc::now() - chrono::Duration::seconds(60);
        let count = obs
            .manager()
            .store()
            .count_events_since("evil.tld", GuardEventKind::SignatureFailure, since)
            .await
            .unwrap();
        assert_eq!(count, 0);
    }

    #[tokio::test]
    async fn normalises_domain_before_recording() {
        let obs = observer();
        obs.on_signature_failure("  HTTPS://Evil.TLD/  ", None, "x")
            .await;
        let since = Utc::now() - chrono::Duration::seconds(60);
        // The event must be stored under the canonical lower-cased form.
        let count = obs
            .manager()
            .store()
            .count_events_since("evil.tld", GuardEventKind::SignatureFailure, since)
            .await
            .unwrap();
        assert_eq!(count, 1);
    }

    #[tokio::test]
    async fn invite_action_is_no_op() {
        let obs = observer();
        obs.on_invite_action("a.tld", "accept").await;
        let since = Utc::now() - chrono::Duration::seconds(60);
        for kind in [
            GuardEventKind::AuthSpam,
            GuardEventKind::InboxAbuse,
            GuardEventKind::SignatureFailure,
        ] {
            let count = obs
                .manager()
                .store()
                .count_events_since("a.tld", kind, since)
                .await
                .unwrap();
            assert_eq!(count, 0, "kind {kind:?} should not be recorded");
        }
    }
}