events.rs 141 lignes · 5116 octets
//! Stable on-the-wire format for guard events plus the [`GuardEventSink`]
//! trait that backends (tracing, file, fan-out) implement.

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

/// Discriminator describing what an observer reports.
///
/// Each variant corresponds to one of the five detectors. Variants are
/// serialised in `snake_case` to remain stable in JSON logs consumed
/// by external SIEMs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GuardEventKind {
    /// A request's HTTP signature failed cryptographic verification.
    SignatureFailure,
    /// Replay window violated: stale `Date` header or duplicate nonce.
    ReplayAttempt,
    /// Outbound fetcher attempted to reach a private/loopback/link-local
    /// IP (SSRF guard hit).
    FetchSsrf,
    /// Inbox payload failed size, schema, or type validation.
    InboxAbuse,
    /// AP-native auth challenge initiated but never consumed; high
    /// volume from one domain signals scripted abuse.
    AuthSpam,
    /// A domain or actor was banned (auto or manual). Reported by the
    /// `BanManager` itself, not by detectors.
    Banned,
    /// A domain or actor ban was lifted (TTL expiry or admin action).
    Unbanned,
}

/// Severity hint attached to each event. Mirrors the levels accepted
/// by `fediverse_guard_events.severity`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    /// Informational (e.g. ban lifted by TTL).
    Info,
    /// Low — anomaly worth recording but not actionable alone.
    Low,
    /// Medium — repeated occurrences should trigger a review.
    Medium,
    /// High — likely abuse; detector seuils probably reached soon.
    High,
    /// Critical — confirmed abuse; auto-ban just triggered.
    Critical,
}

/// One observable guard event.
///
/// Events are produced by the federation crate via the
/// `FediverseEventObserver` trait and consumed by the guard's detector
/// chain and sinks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GuardEvent {
    /// Surrogate UUID for the event row.
    pub id: Uuid,
    /// What kind of anomaly is being reported.
    pub kind: GuardEventKind,
    /// Severity hint.
    pub severity: Severity,
    /// Domain the event is attributed to (lowercased, no scheme).
    pub domain: String,
    /// Optional ActivityPub actor URI (e.g.
    /// `https://a.tld/users/alice`) when the event narrows to one actor.
    pub actor_uri: Option<String>,
    /// FK to `remote_identities.id` when the actor has been resolved.
    pub remote_identity_id: Option<Uuid>,
    /// Optional HTTP status returned by the offending request, when
    /// applicable.
    pub http_status: Option<i32>,
    /// Detector-specific metadata (e.g. `{"skew_secs": 3601}`).
    /// Always a JSON object; never `null`.
    pub details: serde_json::Value,
    /// Server-side timestamp.
    pub created_at: DateTime<Utc>,
}

impl GuardEvent {
    /// Convenience constructor with sensible defaults for fields
    /// rarely populated by detectors.
    #[must_use]
    pub fn new(kind: GuardEventKind, domain: impl Into<String>, severity: Severity) -> Self {
        Self {
            id: Uuid::new_v4(),
            kind,
            severity,
            domain: domain.into(),
            actor_uri: None,
            remote_identity_id: None,
            http_status: None,
            details: serde_json::Value::Object(serde_json::Map::new()),
            created_at: Utc::now(),
        }
    }
}

/// Pluggable sink for [`GuardEvent`].
///
/// Implementations must be non-blocking: a slow sink should buffer
/// internally rather than back-pressure the federation hot path.
#[async_trait]
pub trait GuardEventSink: Send + Sync {
    /// Emit one event. Errors are swallowed by the caller — sinks
    /// must not propagate failures into the request path.
    async fn emit(&self, event: &GuardEvent);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn guard_event_new_fills_defaults() {
        let evt = GuardEvent::new(GuardEventKind::SignatureFailure, "evil.tld", Severity::High);
        assert_eq!(evt.kind, GuardEventKind::SignatureFailure);
        assert_eq!(evt.domain, "evil.tld");
        assert!(evt.actor_uri.is_none());
        assert!(evt.http_status.is_none());
        assert!(evt.details.is_object());
    }

    #[test]
    fn guard_event_round_trips_through_json() {
        let evt = GuardEvent::new(GuardEventKind::ReplayAttempt, "a.tld", Severity::Medium);
        let json = serde_json::to_string(&evt).unwrap();
        let back: GuardEvent = serde_json::from_str(&json).unwrap();
        assert_eq!(back.kind, GuardEventKind::ReplayAttempt);
        assert_eq!(back.domain, "a.tld");
    }

    #[test]
    fn guard_event_kind_serialises_snake_case() {
        let s = serde_json::to_string(&GuardEventKind::SignatureFailure).unwrap();
        assert_eq!(s, "\"signature_failure\"");
    }
}