Skip to main content

rsigma_runtime/io/webhook/
sink.rs

1//! `WebhookSink`: render a templated HTTP request per result and classify the
2//! response. The queue, retry/backoff, and DLQ routing belong to the shared
3//! `crate::io::delivery` layer; this type owns only the webhook-specific
4//! request behavior (render, rate limit, retryable-vs-permanent classification).
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
10use rsigma_eval::{EvaluationResult, ProcessResult};
11
12use crate::enrichment::{HttpEnricherClient, render_template, render_template_json};
13use crate::error::RuntimeError;
14use crate::metrics::MetricsHook;
15
16use super::config::WebhookKind;
17
18/// Cap on the response bytes drained (and discarded) per request. Webhook
19/// responses are never parsed; draining a bounded prefix lets reqwest reuse
20/// the connection without letting a hostile endpoint stream an unbounded body.
21const DRAIN_CAP: usize = 64 * 1024;
22
23/// Ceiling applied to a `Retry-After` header so a hostile or misconfigured
24/// endpoint cannot pin a worker for an arbitrarily long sleep.
25const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
26
27/// A per-entry token bucket: `capacity` tokens, refilled at `refill_per_sec`.
28///
29/// Driven from `WebhookSink::deliver_one` under `&mut self`, so the worker
30/// processes one request at a time and the bucket needs no interior locking.
31/// Uses [`tokio::time::Instant`] so it stays consistent under paused time in
32/// tests.
33pub(crate) struct TokenBucket {
34    tokens: f64,
35    capacity: f64,
36    refill_per_sec: f64,
37    last: tokio::time::Instant,
38}
39
40impl TokenBucket {
41    /// `requests` tokens per `per` window; starts full (burst = `requests`).
42    /// Callers must ensure `requests >= 1` and `per > 0`.
43    pub(crate) fn new(requests: u32, per: Duration) -> Self {
44        let capacity = f64::from(requests);
45        let refill_per_sec = capacity / per.as_secs_f64();
46        TokenBucket {
47            tokens: capacity,
48            capacity,
49            refill_per_sec,
50            last: tokio::time::Instant::now(),
51        }
52    }
53
54    fn refill(&mut self) {
55        let now = tokio::time::Instant::now();
56        let elapsed = now.saturating_duration_since(self.last).as_secs_f64();
57        if elapsed > 0.0 {
58            self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity);
59            self.last = now;
60        }
61    }
62
63    /// Acquire one token, sleeping until the bucket refills if empty. Returns
64    /// `true` if it had to wait (so the caller can record the rate-limit metric).
65    async fn acquire(&mut self) -> bool {
66        self.refill();
67        if self.tokens >= 1.0 {
68            self.tokens -= 1.0;
69            return false;
70        }
71        let needed = 1.0 - self.tokens;
72        let wait = Duration::from_secs_f64(needed / self.refill_per_sec);
73        tokio::time::sleep(wait).await;
74        self.refill();
75        self.tokens = (self.tokens - 1.0).max(0.0);
76        true
77    }
78}
79
80/// One configured webhook. Filters each result by kind and scope, then renders
81/// and posts a templated request, classifying the outcome for the delivery
82/// layer.
83pub struct WebhookSink {
84    id: String,
85    /// `id` leaked to `&'static str` so it can serve as the shared per-sink
86    /// delivery label (queue depth, retries, DLQ), giving a one-to-one mapping
87    /// to the webhook-specific `rsigma_webhook_*` series. Bounded by the
88    /// configured webhook count, leaked once at startup.
89    label: &'static str,
90    kind: WebhookKind,
91    method: reqwest::Method,
92    url: String,
93    headers: Vec<(String, String)>,
94    body: Option<String>,
95    timeout: Duration,
96    scope: crate::enrichment::Scope,
97    limiter: Option<TokenBucket>,
98    client: HttpEnricherClient,
99    metrics: Arc<dyn MetricsHook>,
100}
101
102impl WebhookSink {
103    #[allow(clippy::too_many_arguments)]
104    pub(crate) fn new(
105        id: String,
106        kind: WebhookKind,
107        method: reqwest::Method,
108        url: String,
109        headers: Vec<(String, String)>,
110        body: Option<String>,
111        timeout: Duration,
112        scope: crate::enrichment::Scope,
113        limiter: Option<TokenBucket>,
114        client: HttpEnricherClient,
115        metrics: Arc<dyn MetricsHook>,
116    ) -> Self {
117        let label: &'static str = Box::leak(id.clone().into_boxed_str());
118        WebhookSink {
119            id,
120            label,
121            kind,
122            method,
123            url,
124            headers,
125            body,
126            timeout,
127            scope,
128            limiter,
129            client,
130            metrics,
131        }
132    }
133
134    /// The webhook id, used as the webhook-specific metric label.
135    pub fn id(&self) -> &str {
136        &self.id
137    }
138
139    /// The webhook id as a `&'static str`, used as the shared per-sink
140    /// delivery label so its queue/retry/DLQ series map one-to-one to the
141    /// `rsigma_webhook_*` series.
142    pub fn label(&self) -> &'static str {
143        self.label
144    }
145
146    /// Deliver every matching result in `result`.
147    ///
148    /// Results that do not match this webhook's `kind` or `scope` are skipped
149    /// (a no-op success). On the first delivery error the call short-circuits
150    /// with that error so the shared worker can apply retry/backoff (for a
151    /// retryable error) or route to the DLQ (for a [`RuntimeError::Permanent`]
152    /// or after the retry budget is spent).
153    pub async fn send(&mut self, result: &ProcessResult) -> Result<(), RuntimeError> {
154        for eval in result.iter() {
155            if !self.kind.matches(&eval.body) || !self.scope.matches(eval) {
156                continue;
157            }
158            self.deliver_one(eval).await?;
159        }
160        Ok(())
161    }
162
163    async fn deliver_one(&mut self, eval: &EvaluationResult) -> Result<(), RuntimeError> {
164        let waited = match &mut self.limiter {
165            Some(limiter) => limiter.acquire().await,
166            None => false,
167        };
168        if waited {
169            self.metrics.on_webhook_rate_limited(&self.id);
170        }
171
172        let url = render_template(&self.url, eval);
173        let mut header_map = HeaderMap::with_capacity(self.headers.len());
174        for (name, value_template) in &self.headers {
175            let rendered = render_template(value_template, eval);
176            let header_name = HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
177                RuntimeError::Permanent(format!(
178                    "webhook {}: invalid header name '{name}': {e}",
179                    self.id
180                ))
181            })?;
182            let header_value = HeaderValue::from_str(&rendered).map_err(|e| {
183                RuntimeError::Permanent(format!(
184                    "webhook {}: invalid header value for '{name}': {e}",
185                    self.id
186                ))
187            })?;
188            header_map.insert(header_name, header_value);
189        }
190        let body = self.body.as_ref().map(|b| render_template_json(b, eval));
191
192        let mut req = self
193            .client
194            .inner()
195            .request(self.method.clone(), &url)
196            .headers(header_map)
197            .timeout(self.timeout);
198        if let Some(b) = &body {
199            req = req.body(b.clone());
200        }
201
202        let started = std::time::Instant::now();
203        let resp = match req.send().await {
204            Ok(r) => r,
205            // Transport-level failures (connect, DNS/egress block, timeout)
206            // heal on retry, so they are retryable, not permanent.
207            Err(e) => {
208                return Err(RuntimeError::Io(std::io::Error::other(format!(
209                    "webhook {}: request error: {e}",
210                    self.id
211                ))));
212            }
213        };
214
215        let status = resp.status();
216        let elapsed = started.elapsed().as_secs_f64();
217
218        if status.is_success() {
219            drain_body(resp).await;
220            self.metrics
221                .on_webhook_request(&self.id, "success", elapsed);
222            return Ok(());
223        }
224
225        let retry_after = parse_retry_after(&resp);
226        drain_body(resp).await;
227
228        if status.as_u16() == 429 || status.is_server_error() {
229            // Retryable: honor Retry-After (capped) before yielding to the
230            // shared worker's own backoff schedule.
231            if let Some(wait) = retry_after {
232                tokio::time::sleep(wait.min(MAX_RETRY_AFTER)).await;
233            }
234            return Err(RuntimeError::Io(std::io::Error::other(format!(
235                "webhook {}: HTTP {status}",
236                self.id
237            ))));
238        }
239
240        // Other 4xx (and any non-2xx, non-429, non-5xx): a misrendered payload
241        // or bad endpoint will not heal on retry.
242        self.metrics
243            .on_webhook_request(&self.id, "permanent_failure", elapsed);
244        Err(RuntimeError::Permanent(format!(
245            "webhook {}: HTTP {status}",
246            self.id
247        )))
248    }
249}
250
251/// Read and discard up to [`DRAIN_CAP`] bytes of the response body.
252async fn drain_body(mut resp: reqwest::Response) {
253    let mut read = 0usize;
254    while read < DRAIN_CAP {
255        match resp.chunk().await {
256            Ok(Some(chunk)) => read += chunk.len(),
257            _ => break,
258        }
259    }
260}
261
262/// Parse a numeric `Retry-After` header (delay in seconds). The HTTP-date form
263/// is intentionally not supported; chat/paging endpoints use delay-seconds.
264fn parse_retry_after(resp: &reqwest::Response) -> Option<Duration> {
265    resp.headers()
266        .get(reqwest::header::RETRY_AFTER)?
267        .to_str()
268        .ok()?
269        .trim()
270        .parse::<u64>()
271        .ok()
272        .map(Duration::from_secs)
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278    use std::collections::HashMap;
279
280    use rsigma_eval::result::{DetectionBody, ResultBody, RuleHeader};
281    use rsigma_parser::Level;
282    use wiremock::matchers::{method, path};
283    use wiremock::{Mock, MockServer, ResponseTemplate};
284
285    use crate::metrics::NoopMetrics;
286
287    fn detection(title: &str) -> EvaluationResult {
288        EvaluationResult {
289            header: RuleHeader {
290                rule_title: title.to_string(),
291                rule_id: Some("rule-1".to_string()),
292                level: Some(Level::High),
293                tags: vec![],
294                custom_attributes: Arc::new(HashMap::new()),
295                enrichments: None,
296            },
297            body: ResultBody::Detection(DetectionBody {
298                matched_selections: vec!["sel".to_string()],
299                matched_fields: vec![],
300                event: None,
301            }),
302        }
303    }
304
305    fn sink_to(url: String) -> WebhookSink {
306        WebhookSink::new(
307            "test".to_string(),
308            WebhookKind::Detection,
309            reqwest::Method::POST,
310            url,
311            vec![("Content-Type".to_string(), "application/json".to_string())],
312            Some(r#"{"text":"${detection.rule.title}"}"#.to_string()),
313            Duration::from_secs(5),
314            crate::enrichment::Scope::default(),
315            None,
316            crate::enrichment::build_default_http_client().unwrap(),
317            Arc::new(NoopMetrics),
318        )
319    }
320
321    #[tokio::test]
322    async fn success_2xx_is_ok() {
323        let server = MockServer::start().await;
324        Mock::given(method("POST"))
325            .and(path("/hook"))
326            .respond_with(ResponseTemplate::new(204))
327            .mount(&server)
328            .await;
329        let mut sink = sink_to(format!("{}/hook", server.uri()));
330        let result: ProcessResult = vec![detection("hi")];
331        assert!(sink.send(&result).await.is_ok());
332    }
333
334    #[tokio::test]
335    async fn server_error_is_retryable() {
336        let server = MockServer::start().await;
337        Mock::given(method("POST"))
338            .respond_with(ResponseTemplate::new(500))
339            .mount(&server)
340            .await;
341        let mut sink = sink_to(format!("{}/hook", server.uri()));
342        let result: ProcessResult = vec![detection("hi")];
343        match sink.send(&result).await {
344            Err(RuntimeError::Io(_)) => {}
345            other => panic!("expected retryable Io error, got {other:?}"),
346        }
347    }
348
349    #[tokio::test]
350    async fn client_error_is_permanent() {
351        let server = MockServer::start().await;
352        Mock::given(method("POST"))
353            .respond_with(ResponseTemplate::new(400))
354            .mount(&server)
355            .await;
356        let mut sink = sink_to(format!("{}/hook", server.uri()));
357        let result: ProcessResult = vec![detection("hi")];
358        match sink.send(&result).await {
359            Err(RuntimeError::Permanent(_)) => {}
360            other => panic!("expected permanent error, got {other:?}"),
361        }
362    }
363
364    #[tokio::test]
365    async fn non_matching_kind_is_skipped_without_request() {
366        // No mock mounted: any request would 404 and fail. A correlation-only
367        // result must be skipped by a detection webhook, so send() is a no-op.
368        let server = MockServer::start().await;
369        let mut sink = sink_to(format!("{}/hook", server.uri()));
370        let correlation = EvaluationResult {
371            header: RuleHeader {
372                rule_title: "corr".to_string(),
373                rule_id: None,
374                level: None,
375                tags: vec![],
376                custom_attributes: Arc::new(HashMap::new()),
377                enrichments: None,
378            },
379            body: ResultBody::Correlation(rsigma_eval::result::CorrelationBody {
380                correlation_type: rsigma_parser::CorrelationType::EventCount,
381                aggregated_value: 1.0,
382                timespan_secs: 60,
383                group_key: vec![],
384                events: None,
385                event_refs: None,
386            }),
387        };
388        let result: ProcessResult = vec![correlation];
389        assert!(sink.send(&result).await.is_ok());
390    }
391
392    #[test]
393    fn slack_recipe_body_renders_to_pinned_json() {
394        // Pin the template-engine-plus-JSON-escaping contract for a realistic
395        // Slack-style body: the matched CommandLine carries embedded quotes
396        // that must be escaped so the rendered body stays valid JSON.
397        let body = r#"{"text":":rotating_light: ${detection.rule.title} (${detection.rule.level}) cmd=${detection.fields.CommandLine}"}"#;
398        let mut r = detection("Encoded PowerShell");
399        if let ResultBody::Detection(d) = &mut r.body {
400            d.matched_fields.push(rsigma_eval::result::FieldMatch::new(
401                "CommandLine",
402                serde_json::json!(r#"powershell -enc "AAA""#),
403            ));
404        }
405        let rendered = crate::enrichment::render_template_json(body, &r);
406        assert_eq!(
407            rendered,
408            r#"{"text":":rotating_light: Encoded PowerShell (high) cmd=powershell -enc \"AAA\""}"#,
409        );
410        // The escaped body must parse as JSON despite the embedded quotes.
411        let _: serde_json::Value = serde_json::from_str(&rendered).expect("valid JSON");
412    }
413
414    #[tokio::test]
415    async fn token_bucket_waits_when_empty() {
416        // 2 tokens per 100ms => one token refills in ~50ms.
417        let mut tb = TokenBucket::new(2, Duration::from_millis(100));
418        assert!(!tb.acquire().await, "first token is free");
419        assert!(!tb.acquire().await, "second token is free");
420        let start = std::time::Instant::now();
421        assert!(tb.acquire().await, "third token must wait");
422        assert!(start.elapsed() >= Duration::from_millis(40));
423    }
424}