rsigma_runtime/io/webhook/
sink.rs1use 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
18const DRAIN_CAP: usize = 64 * 1024;
22
23const MAX_RETRY_AFTER: Duration = Duration::from_secs(60);
26
27pub(crate) struct TokenBucket {
34 tokens: f64,
35 capacity: f64,
36 refill_per_sec: f64,
37 last: tokio::time::Instant,
38}
39
40impl TokenBucket {
41 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 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
80pub struct WebhookSink {
84 id: String,
85 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 pub fn id(&self) -> &str {
136 &self.id
137 }
138
139 pub fn label(&self) -> &'static str {
143 self.label
144 }
145
146 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 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 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 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
251async 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
262fn 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 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 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 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 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}