1use std::collections::HashMap;
19use std::path::Path;
20use std::sync::Arc;
21use std::time::Duration;
22
23use base64::Engine as _;
24use base64::engine::general_purpose::STANDARD as BASE64;
25use rsigma_eval::ResultBody;
26use serde::Deserialize;
27use zeroize::Zeroizing;
28
29use crate::enrichment::{
30 EnricherKind, HttpEnricherClient, Scope, TemplateError, build_default_http_client,
31 validate_template_namespace,
32};
33use crate::io::DeliveryConfig;
34use crate::metrics::MetricsHook;
35
36use super::signing::{Algorithm, CustomScheme, Encoding, SigningScheme, WebhookSigner};
37use super::sink::{TokenBucket, WebhookSink};
38
39pub const DEFAULT_WEBHOOK_TIMEOUT: Duration = Duration::from_secs(10);
41pub const DEFAULT_WEBHOOK_ATTEMPTS: u32 = 3;
43pub const DEFAULT_WEBHOOK_BACKOFF: Duration = Duration::from_secs(1);
45pub const DEFAULT_WEBHOOK_MAX_BACKOFF: Duration = Duration::from_secs(30);
47pub const DEFAULT_WEBHOOK_QUEUE_SIZE: usize = 1024;
49
50#[derive(Debug, Clone, Deserialize)]
63pub struct WebhooksFile {
64 #[serde(default)]
67 pub webhooks: Vec<WebhookConfig>,
68}
69
70#[derive(Debug, Clone, Deserialize)]
72pub struct WebhookConfig {
73 pub id: String,
75 pub kind: String,
79 pub url: String,
81 #[serde(default)]
83 pub method: Option<String>,
84 #[serde(default)]
86 pub headers: HashMap<String, String>,
87 #[serde(default)]
90 pub body: Option<String>,
91 #[serde(default, with = "humantime_opt")]
93 pub timeout: Option<Duration>,
94 #[serde(default)]
96 pub retry: Option<RetryConfig>,
97 #[serde(default)]
99 pub rate_limit: Option<RateLimitConfig>,
100 #[serde(default)]
102 pub scope: Option<ScopeConfig>,
103 #[serde(default)]
105 pub queue_size: Option<usize>,
106 #[serde(default)]
110 pub tls: Option<WebhookTlsConfig>,
111 #[serde(default)]
114 pub signing: Option<SigningConfig>,
115}
116
117#[derive(Debug, Clone, Default, Deserialize)]
119pub struct WebhookTlsConfig {
120 #[serde(default)]
123 pub ca: Option<String>,
124 #[serde(default)]
127 pub client_cert: Option<String>,
128 #[serde(default)]
131 pub client_key: Option<String>,
132}
133
134#[derive(Debug, Clone, Deserialize)]
145pub struct SigningConfig {
146 pub secret_env: String,
148 #[serde(default)]
152 pub secret_encoding: Option<String>,
153 #[serde(default)]
155 pub scheme: Option<String>,
156 #[serde(default)]
159 pub rotate_secret_env: Option<String>,
160 #[serde(default)]
162 pub custom: Option<CustomSigningConfig>,
163}
164
165#[derive(Debug, Clone, Deserialize)]
167pub struct CustomSigningConfig {
168 #[serde(default)]
170 pub algorithm: Option<String>,
171 #[serde(default)]
173 pub encoding: Option<String>,
174 pub signature_header: String,
176 pub value_format: String,
179 pub signed_payload: String,
181 #[serde(default)]
183 pub timestamp_header: Option<String>,
184 #[serde(default)]
186 pub id_header: Option<String>,
187}
188
189#[derive(Debug, Clone, Default, Deserialize)]
191pub struct RetryConfig {
192 #[serde(default)]
195 pub attempts: Option<u32>,
196 #[serde(default, with = "humantime_opt")]
198 pub backoff: Option<Duration>,
199 #[serde(default, with = "humantime_opt")]
201 pub max_backoff: Option<Duration>,
202}
203
204#[derive(Debug, Clone, Deserialize)]
206pub struct RateLimitConfig {
207 pub requests: u32,
209 #[serde(with = "humantime_dur")]
211 pub per: Duration,
212}
213
214#[derive(Debug, Clone, Default, Deserialize)]
216pub struct ScopeConfig {
217 #[serde(default)]
218 pub rules: Vec<String>,
219 #[serde(default)]
220 pub tags: Vec<String>,
221 #[serde(default)]
222 pub levels: Vec<String>,
223}
224
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum WebhookKind {
231 Detection,
233 Correlation,
235}
236
237impl WebhookKind {
238 pub fn as_str(self) -> &'static str {
240 match self {
241 WebhookKind::Detection => "detection",
242 WebhookKind::Correlation => "correlation",
243 }
244 }
245
246 pub(crate) fn as_enricher_kind(self) -> EnricherKind {
249 match self {
250 WebhookKind::Detection => EnricherKind::Detection,
251 WebhookKind::Correlation => EnricherKind::Correlation,
252 }
253 }
254
255 pub fn matches(self, body: &ResultBody) -> bool {
257 self.as_enricher_kind().matches(body)
258 }
259
260 fn parse(s: &str) -> Option<Self> {
261 match s {
262 "detection" => Some(WebhookKind::Detection),
263 "correlation" => Some(WebhookKind::Correlation),
264 _ => None,
265 }
266 }
267}
268
269pub struct BuiltWebhook {
273 pub sink: WebhookSink,
274 pub delivery: DeliveryConfig,
275}
276
277#[derive(Debug)]
279pub enum WebhookConfigError {
280 Io(std::io::Error, std::path::PathBuf),
282 Yaml(yaml_serde::Error),
284 UnknownKind { webhook_id: String, kind: String },
286 MissingField {
288 webhook_id: String,
289 field: &'static str,
290 },
291 InvalidMethod { webhook_id: String, method: String },
293 CrossNamespace {
295 webhook_id: String,
296 kind: &'static str,
297 reference: String,
298 field: &'static str,
299 },
300 MalformedTemplate {
302 webhook_id: String,
303 reference: String,
304 field: &'static str,
305 },
306 InvalidRetry { webhook_id: String, message: String },
308 InvalidRateLimit { webhook_id: String, message: String },
310 Scope { webhook_id: String, message: String },
312 Tls { webhook_id: String, message: String },
314 MissingSecretEnv { webhook_id: String, var: String },
316 InvalidSigning { webhook_id: String, message: String },
319 Client { message: String },
321}
322
323impl std::fmt::Display for WebhookConfigError {
324 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325 match self {
326 WebhookConfigError::Io(e, p) => {
327 write!(f, "failed to read webhooks config '{}': {e}", p.display())
328 }
329 WebhookConfigError::Yaml(e) => write!(f, "invalid webhooks YAML: {e}"),
330 WebhookConfigError::UnknownKind { webhook_id, kind } => write!(
331 f,
332 "webhook '{webhook_id}': unknown kind '{kind}' (valid kinds: detection, correlation; incident arrives with roadmap item #48)"
333 ),
334 WebhookConfigError::MissingField { webhook_id, field } => {
335 write!(
336 f,
337 "webhook '{webhook_id}': missing required field '{field}'"
338 )
339 }
340 WebhookConfigError::InvalidMethod { webhook_id, method } => {
341 write!(f, "webhook '{webhook_id}': invalid HTTP method '{method}'")
342 }
343 WebhookConfigError::CrossNamespace {
344 webhook_id,
345 kind,
346 reference,
347 field,
348 } => write!(
349 f,
350 "webhook '{webhook_id}' (kind: {kind}): template reference '${{{reference}}}' in field '{field}' is the wrong namespace for a {kind} webhook"
351 ),
352 WebhookConfigError::MalformedTemplate {
353 webhook_id,
354 reference,
355 field,
356 } => write!(
357 f,
358 "webhook '{webhook_id}': malformed template reference '${{{reference}}}' in field '{field}'; expected ${{detection.*}}, ${{correlation.*}}, or ${{ENV_VAR}}"
359 ),
360 WebhookConfigError::InvalidRetry {
361 webhook_id,
362 message,
363 } => write!(f, "webhook '{webhook_id}': {message}"),
364 WebhookConfigError::InvalidRateLimit {
365 webhook_id,
366 message,
367 } => write!(f, "webhook '{webhook_id}': {message}"),
368 WebhookConfigError::Scope {
369 webhook_id,
370 message,
371 } => write!(f, "webhook '{webhook_id}': {message}"),
372 WebhookConfigError::Tls {
373 webhook_id,
374 message,
375 } => write!(f, "webhook '{webhook_id}': {message}"),
376 WebhookConfigError::MissingSecretEnv { webhook_id, var } => write!(
377 f,
378 "webhook '{webhook_id}': signing secret environment variable '{var}' is unset or empty"
379 ),
380 WebhookConfigError::InvalidSigning {
381 webhook_id,
382 message,
383 } => write!(f, "webhook '{webhook_id}': {message}"),
384 WebhookConfigError::Client { message } => {
385 write!(f, "webhook HTTP client build failed: {message}")
386 }
387 }
388 }
389}
390
391impl std::error::Error for WebhookConfigError {}
392
393pub fn load_webhooks_file(path: &Path) -> Result<WebhooksFile, WebhookConfigError> {
396 let text =
397 std::fs::read_to_string(path).map_err(|e| WebhookConfigError::Io(e, path.to_path_buf()))?;
398 let parsed: WebhooksFile = yaml_serde::from_str(&text).map_err(WebhookConfigError::Yaml)?;
399 Ok(parsed)
400}
401
402pub fn build_webhooks(
410 file: WebhooksFile,
411 metrics: Arc<dyn MetricsHook>,
412) -> Result<Vec<BuiltWebhook>, WebhookConfigError> {
413 let client =
414 build_default_http_client().map_err(|message| WebhookConfigError::Client { message })?;
415 let mut built = Vec::with_capacity(file.webhooks.len());
416 for cfg in file.webhooks {
417 built.push(build_one(cfg, client.clone(), metrics.clone())?);
418 }
419 Ok(built)
420}
421
422fn build_one(
423 cfg: WebhookConfig,
424 default_client: HttpEnricherClient,
425 metrics: Arc<dyn MetricsHook>,
426) -> Result<BuiltWebhook, WebhookConfigError> {
427 let kind = WebhookKind::parse(&cfg.kind).ok_or_else(|| WebhookConfigError::UnknownKind {
428 webhook_id: cfg.id.clone(),
429 kind: cfg.kind.clone(),
430 })?;
431 if cfg.url.trim().is_empty() {
432 return Err(WebhookConfigError::MissingField {
433 webhook_id: cfg.id.clone(),
434 field: "url",
435 });
436 }
437
438 let ek = kind.as_enricher_kind();
439 check_template(&cfg.url, ek, &cfg.id, "url")?;
440 for (name, value) in &cfg.headers {
441 let field: &'static str = Box::leak(format!("headers.{name}").into_boxed_str());
442 check_template(value, ek, &cfg.id, field)?;
443 }
444 if let Some(body) = &cfg.body {
445 check_template(body, ek, &cfg.id, "body")?;
446 }
447
448 let method = match &cfg.method {
449 Some(m) => {
450 reqwest::Method::from_bytes(m.to_ascii_uppercase().as_bytes()).map_err(|_| {
451 WebhookConfigError::InvalidMethod {
452 webhook_id: cfg.id.clone(),
453 method: m.clone(),
454 }
455 })?
456 }
457 None => reqwest::Method::POST,
458 };
459
460 let scope =
461 match &cfg.scope {
462 Some(s) => Scope::new(s.rules.clone(), s.tags.clone(), s.levels.clone()).map_err(
463 |message| WebhookConfigError::Scope {
464 webhook_id: cfg.id.clone(),
465 message,
466 },
467 )?,
468 None => Scope::default(),
469 };
470
471 let limiter = match &cfg.rate_limit {
472 Some(rl) => {
473 if rl.requests == 0 {
474 return Err(WebhookConfigError::InvalidRateLimit {
475 webhook_id: cfg.id.clone(),
476 message: "rate_limit.requests must be at least 1".to_string(),
477 });
478 }
479 if rl.per.is_zero() {
480 return Err(WebhookConfigError::InvalidRateLimit {
481 webhook_id: cfg.id.clone(),
482 message: "rate_limit.per must be greater than zero".to_string(),
483 });
484 }
485 Some(TokenBucket::new(rl.requests, rl.per))
486 }
487 None => None,
488 };
489
490 let retry = cfg.retry.clone().unwrap_or_default();
491 let attempts = retry.attempts.unwrap_or(DEFAULT_WEBHOOK_ATTEMPTS);
492 if attempts == 0 {
493 return Err(WebhookConfigError::InvalidRetry {
494 webhook_id: cfg.id.clone(),
495 message: "retry.attempts must be at least 1".to_string(),
496 });
497 }
498
499 let delivery = DeliveryConfig {
500 queue_depth: cfg.queue_size.unwrap_or(DEFAULT_WEBHOOK_QUEUE_SIZE),
501 batch_max: 1,
504 batch_flush: DeliveryConfig::default().batch_flush,
505 retry_max: attempts.saturating_sub(1),
506 backoff_base: retry.backoff.unwrap_or(DEFAULT_WEBHOOK_BACKOFF),
507 backoff_max: retry.max_backoff.unwrap_or(DEFAULT_WEBHOOK_MAX_BACKOFF),
508 };
509
510 let timeout = cfg.timeout.unwrap_or(DEFAULT_WEBHOOK_TIMEOUT);
511 let headers: Vec<(String, String)> = cfg
512 .headers
513 .iter()
514 .map(|(k, v)| (k.clone(), v.clone()))
515 .collect();
516
517 let client = match &cfg.tls {
520 Some(tls) => build_tls_client(&cfg.id, tls)?,
521 None => default_client,
522 };
523
524 let signer = match &cfg.signing {
525 Some(s) => Some(build_signer(&cfg.id, s, &cfg.headers)?),
526 None => None,
527 };
528
529 metrics.register_webhook(&cfg.id);
530 let sink = WebhookSink::new(
531 cfg.id, kind, method, cfg.url, headers, cfg.body, timeout, scope, limiter, client, metrics,
532 signer,
533 );
534 Ok(BuiltWebhook { sink, delivery })
535}
536
537fn build_tls_client(
543 id: &str,
544 tls: &WebhookTlsConfig,
545) -> Result<HttpEnricherClient, WebhookConfigError> {
546 let err = |message: String| WebhookConfigError::Tls {
547 webhook_id: id.to_string(),
548 message,
549 };
550 match (&tls.client_cert, &tls.client_key) {
551 (Some(_), Some(_)) | (None, None) => {}
552 _ => {
553 return Err(err(
554 "tls.client_cert and tls.client_key must be set together for mutual TLS"
555 .to_string(),
556 ));
557 }
558 }
559
560 ensure_crypto_provider();
561 let resolver =
562 crate::egress::EgressFilteredResolver::new(crate::egress::default_egress_policy())
563 .into_dns_resolver();
564 let mut builder = reqwest::Client::builder().dns_resolver(resolver);
565
566 if let Some(ca_path) = &tls.ca {
567 let pem = std::fs::read(ca_path)
568 .map_err(|e| err(format!("failed to read tls.ca '{ca_path}': {e}")))?;
569 let cert = reqwest::Certificate::from_pem(&pem)
570 .map_err(|e| err(format!("invalid tls.ca PEM: {e}")))?;
571 builder = builder.add_root_certificate(cert);
572 }
573
574 if let (Some(cert_path), Some(key_path)) = (&tls.client_cert, &tls.client_key) {
575 let cert = std::fs::read(cert_path)
576 .map_err(|e| err(format!("failed to read tls.client_cert '{cert_path}': {e}")))?;
577 let key = std::fs::read(key_path)
578 .map_err(|e| err(format!("failed to read tls.client_key '{key_path}': {e}")))?;
579 let mut pem = cert;
581 pem.push(b'\n');
582 pem.extend_from_slice(&key);
583 let identity = reqwest::Identity::from_pem(&pem)
584 .map_err(|e| err(format!("invalid client identity PEM: {e}")))?;
585 builder = builder.identity(identity);
586 }
587
588 builder
589 .build()
590 .map(|c| HttpEnricherClient::from_reqwest(std::sync::Arc::new(c)))
591 .map_err(|e| err(format!("TLS client build failed: {e}")))
592}
593
594fn ensure_crypto_provider() {
602 #[cfg(feature = "otlp")]
603 {
604 let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
605 }
606}
607
608fn check_template(
609 text: &str,
610 kind: EnricherKind,
611 id: &str,
612 field: &'static str,
613) -> Result<(), WebhookConfigError> {
614 validate_template_namespace(text, kind, id, field).map_err(|te| match te {
615 TemplateError::CrossNamespace {
616 reference, field, ..
617 } => WebhookConfigError::CrossNamespace {
618 webhook_id: id.to_string(),
619 kind: kind.as_str(),
620 reference,
621 field,
622 },
623 TemplateError::Malformed {
624 reference, field, ..
625 } => WebhookConfigError::MalformedTemplate {
626 webhook_id: id.to_string(),
627 reference,
628 field,
629 },
630 })
631}
632
633fn build_signer(
638 id: &str,
639 cfg: &SigningConfig,
640 headers: &HashMap<String, String>,
641) -> Result<WebhookSigner, WebhookConfigError> {
642 let err = |message: String| WebhookConfigError::InvalidSigning {
643 webhook_id: id.to_string(),
644 message,
645 };
646
647 let scheme_name = cfg.scheme.as_deref().unwrap_or("standard");
650 if cfg.rotate_secret_env.is_some() && scheme_name == "github" {
651 return Err(err(
652 "rotate_secret_env is not supported for the github scheme (it carries a single signature value)"
653 .to_string(),
654 ));
655 }
656
657 let scheme = match scheme_name {
658 "standard" => SigningScheme::Standard,
659 "github" => SigningScheme::Github,
660 "custom" => {
661 let custom = cfg
662 .custom
663 .as_ref()
664 .ok_or_else(|| err("scheme 'custom' requires a 'custom:' block".to_string()))?;
665 SigningScheme::Custom(build_custom_scheme(id, custom)?)
666 }
667 other => {
668 return Err(err(format!(
669 "unknown signing scheme '{other}' (valid: standard, github, custom)"
670 )));
671 }
672 };
673
674 for sig_name in scheme.header_names() {
675 if headers.keys().any(|h| h.eq_ignore_ascii_case(&sig_name)) {
676 return Err(err(format!(
677 "signing header '{sig_name}' collides with a configured header"
678 )));
679 }
680 }
681
682 let encoding = cfg.secret_encoding.as_deref();
684 let mut keys = vec![read_secret(id, &cfg.secret_env, encoding)?];
685 if let Some(rot_env) = &cfg.rotate_secret_env {
686 keys.push(read_secret(id, rot_env, encoding)?);
687 }
688
689 Ok(WebhookSigner::new(scheme, keys))
690}
691
692fn read_secret(
698 id: &str,
699 var: &str,
700 encoding: Option<&str>,
701) -> Result<Zeroizing<Vec<u8>>, WebhookConfigError> {
702 let raw = Zeroizing::new(
703 std::env::var(var)
704 .ok()
705 .filter(|v| !v.is_empty())
706 .ok_or_else(|| WebhookConfigError::MissingSecretEnv {
707 webhook_id: id.to_string(),
708 var: var.to_string(),
709 })?,
710 );
711 let key = match encoding.unwrap_or("utf8") {
712 "utf8" => Zeroizing::new(raw.as_bytes().to_vec()),
713 "base64" => {
714 let trimmed = raw.strip_prefix("whsec_").unwrap_or(raw.as_str());
715 Zeroizing::new(BASE64.decode(trimmed.as_bytes()).map_err(|e| {
716 WebhookConfigError::InvalidSigning {
717 webhook_id: id.to_string(),
718 message: format!("secret in '{var}' is not valid base64: {e}"),
719 }
720 })?)
721 }
722 other => {
723 return Err(WebhookConfigError::InvalidSigning {
724 webhook_id: id.to_string(),
725 message: format!("unknown secret_encoding '{other}' (valid: utf8, base64)"),
726 });
727 }
728 };
729 if key.is_empty() {
733 return Err(WebhookConfigError::InvalidSigning {
734 webhook_id: id.to_string(),
735 message: format!("signing secret in '{var}' decoded to an empty key"),
736 });
737 }
738 Ok(key)
739}
740
741fn build_custom_scheme(
743 id: &str,
744 cfg: &CustomSigningConfig,
745) -> Result<CustomScheme, WebhookConfigError> {
746 let err = |message: String| WebhookConfigError::InvalidSigning {
747 webhook_id: id.to_string(),
748 message,
749 };
750
751 let algorithm = match cfg.algorithm.as_deref().unwrap_or("sha256") {
752 "sha256" => Algorithm::Sha256,
753 "sha512" => Algorithm::Sha512,
754 other => {
755 return Err(err(format!(
756 "unknown custom.algorithm '{other}' (valid: sha256, sha512)"
757 )));
758 }
759 };
760 let encoding = match cfg.encoding.as_deref().unwrap_or("hex") {
761 "hex" => Encoding::Hex,
762 "base64" => Encoding::Base64,
763 other => {
764 return Err(err(format!(
765 "unknown custom.encoding '{other}' (valid: hex, base64)"
766 )));
767 }
768 };
769 if cfg.signature_header.trim().is_empty() {
770 return Err(err("custom.signature_header must not be empty".to_string()));
771 }
772 validate_signing_tokens(
773 id,
774 &cfg.value_format,
775 &["signature", "timestamp", "id"],
776 "custom.value_format",
777 )?;
778 if !cfg.value_format.contains("{signature}") {
779 return Err(err(
780 "custom.value_format must contain the {signature} token".to_string(),
781 ));
782 }
783 validate_signing_tokens(
784 id,
785 &cfg.signed_payload,
786 &["body", "timestamp", "id"],
787 "custom.signed_payload",
788 )?;
789
790 Ok(CustomScheme {
791 algorithm,
792 encoding,
793 signature_header: cfg.signature_header.clone(),
794 value_format: cfg.value_format.clone(),
795 signed_payload: cfg.signed_payload.clone(),
796 timestamp_header: cfg.timestamp_header.clone(),
797 id_header: cfg.id_header.clone(),
798 })
799}
800
801fn validate_signing_tokens(
803 id: &str,
804 template: &str,
805 allowed: &[&str],
806 field: &str,
807) -> Result<(), WebhookConfigError> {
808 let err = |message: String| WebhookConfigError::InvalidSigning {
809 webhook_id: id.to_string(),
810 message,
811 };
812 let mut rest = template;
813 while let Some(open) = rest.find('{') {
814 let after = &rest[open + 1..];
815 let close = after
816 .find('}')
817 .ok_or_else(|| err(format!("{field} has an unclosed '{{'")))?;
818 let token = &after[..close];
819 if !allowed.contains(&token) {
820 return Err(err(format!(
821 "{field} has unknown token '{{{token}}}' (allowed: {})",
822 allowed.join(", ")
823 )));
824 }
825 rest = &after[close + 1..];
826 }
827 Ok(())
828}
829
830mod humantime_opt {
832 use std::time::Duration;
833
834 use serde::{Deserialize, Deserializer};
835
836 pub fn deserialize<'de, D>(d: D) -> Result<Option<Duration>, D::Error>
837 where
838 D: Deserializer<'de>,
839 {
840 let raw: Option<String> = Option::deserialize(d)?;
841 match raw {
842 Some(s) => humantime::parse_duration(&s)
843 .map(Some)
844 .map_err(serde::de::Error::custom),
845 None => Ok(None),
846 }
847 }
848}
849
850mod humantime_dur {
852 use std::time::Duration;
853
854 use serde::{Deserialize, Deserializer};
855
856 pub fn deserialize<'de, D>(d: D) -> Result<Duration, D::Error>
857 where
858 D: Deserializer<'de>,
859 {
860 let s = String::deserialize(d)?;
861 humantime::parse_duration(&s).map_err(serde::de::Error::custom)
862 }
863}
864
865#[cfg(test)]
866mod tests {
867 use super::*;
868 use crate::metrics::NoopMetrics;
869
870 fn build(yaml: &str) -> Result<Vec<BuiltWebhook>, WebhookConfigError> {
871 let file: WebhooksFile = yaml_serde::from_str(yaml).expect("yaml parses");
872 build_webhooks(file, Arc::new(NoopMetrics))
873 }
874
875 fn build_err(yaml: &str) -> WebhookConfigError {
878 build(yaml).map(|_| ()).unwrap_err()
879 }
880
881 #[test]
882 fn minimal_detection_webhook_builds() {
883 let built = build(
884 r#"
885webhooks:
886 - id: slack
887 kind: detection
888 url: https://example.test/hook
889 body: '{"text":"${detection.rule.title}"}'
890"#,
891 )
892 .expect("valid config");
893 assert_eq!(built.len(), 1);
894 assert_eq!(built[0].delivery.retry_max, 2);
896 assert_eq!(built[0].delivery.batch_max, 1);
897 assert_eq!(built[0].delivery.queue_depth, 1024);
898 }
899
900 #[test]
901 fn unknown_kind_is_rejected_with_incident_hint() {
902 let err = build_err(
903 r#"
904webhooks:
905 - id: pd
906 kind: incident
907 url: https://example.test/hook
908"#,
909 );
910 let msg = err.to_string();
911 assert!(msg.contains("unknown kind 'incident'"), "{msg}");
912 assert!(msg.contains("roadmap item #48"), "{msg}");
913 }
914
915 #[test]
916 fn missing_url_is_rejected() {
917 let err = build_err(
918 r#"
919webhooks:
920 - id: x
921 kind: detection
922 url: " "
923"#,
924 );
925 assert!(err.to_string().contains("missing required field 'url'"));
926 }
927
928 #[test]
929 fn cross_namespace_template_points_at_the_field() {
930 let err = build_err(
931 r#"
932webhooks:
933 - id: x
934 kind: detection
935 url: https://example.test/hook
936 body: '{"t":"${correlation.rule.title}"}'
937"#,
938 );
939 let msg = err.to_string();
940 assert!(
941 msg.contains("wrong namespace for a detection webhook"),
942 "{msg}"
943 );
944 assert!(msg.contains("field 'body'"), "{msg}");
945 }
946
947 #[test]
948 fn zero_attempts_is_rejected() {
949 let err = build_err(
950 r#"
951webhooks:
952 - id: x
953 kind: detection
954 url: https://example.test/hook
955 retry:
956 attempts: 0
957"#,
958 );
959 assert!(
960 err.to_string()
961 .contains("retry.attempts must be at least 1")
962 );
963 }
964
965 #[test]
966 fn retry_and_queue_override_delivery_defaults() {
967 let built = build(
968 r#"
969webhooks:
970 - id: x
971 kind: detection
972 url: https://example.test/hook
973 retry:
974 attempts: 5
975 backoff: 2s
976 max_backoff: 45s
977 queue_size: 256
978"#,
979 )
980 .expect("valid config");
981 let d = &built[0].delivery;
982 assert_eq!(d.retry_max, 4);
983 assert_eq!(d.backoff_base, Duration::from_secs(2));
984 assert_eq!(d.backoff_max, Duration::from_secs(45));
985 assert_eq!(d.queue_depth, 256);
986 }
987
988 #[test]
989 fn malformed_duration_is_rejected() {
990 let file: Result<WebhooksFile, _> = yaml_serde::from_str(
991 r#"
992webhooks:
993 - id: x
994 kind: detection
995 url: https://example.test/hook
996 timeout: "not-a-duration"
997"#,
998 );
999 assert!(file.is_err(), "humantime parse should fail at deserialize");
1000 }
1001
1002 #[test]
1003 fn tls_webhook_with_ca_and_identity_builds() {
1004 use std::io::Write;
1005
1006 use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair};
1007
1008 let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
1009 ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
1010 let ca_key = KeyPair::generate().unwrap();
1011 let ca_pem = ca_params.self_signed(&ca_key).unwrap().pem();
1012
1013 let client_key = KeyPair::generate().unwrap();
1014 let client_pem = CertificateParams::new(vec!["client".to_string()])
1015 .unwrap()
1016 .self_signed(&client_key)
1017 .unwrap()
1018 .pem();
1019 let client_key_pem = client_key.serialize_pem();
1020
1021 let write = |contents: &str| {
1022 let mut f = tempfile::Builder::new().suffix(".pem").tempfile().unwrap();
1023 f.write_all(contents.as_bytes()).unwrap();
1024 f.flush().unwrap();
1025 f
1026 };
1027 let ca = write(&ca_pem);
1028 let cert = write(&client_pem);
1029 let key = write(&client_key_pem);
1030
1031 let yaml = format!(
1032 r#"
1033webhooks:
1034 - id: internal
1035 kind: detection
1036 url: https://relay.internal/hook
1037 tls:
1038 ca: {ca}
1039 client_cert: {cert}
1040 client_key: {key}
1041"#,
1042 ca = ca.path().display(),
1043 cert = cert.path().display(),
1044 key = key.path().display(),
1045 );
1046 let built = build(&yaml).expect("a webhook with a CA and client identity should build");
1047 assert_eq!(built.len(), 1);
1048 }
1049
1050 #[test]
1051 fn tls_client_cert_without_key_is_rejected() {
1052 let err = build_err(
1053 r#"
1054webhooks:
1055 - id: internal
1056 kind: detection
1057 url: https://relay.internal/hook
1058 tls:
1059 client_cert: /nonexistent/cert.pem
1060"#,
1061 );
1062 assert!(
1063 err.to_string()
1064 .contains("must be set together for mutual TLS"),
1065 "{err}"
1066 );
1067 }
1068
1069 #[test]
1070 fn tls_unreadable_ca_is_rejected() {
1071 let err = build_err(
1072 r#"
1073webhooks:
1074 - id: internal
1075 kind: detection
1076 url: https://relay.internal/hook
1077 tls:
1078 ca: /nonexistent/ca.pem
1079"#,
1080 );
1081 assert!(err.to_string().contains("failed to read tls.ca"), "{err}");
1082 }
1083
1084 #[test]
1085 fn rate_limit_requires_positive_budget() {
1086 let err = build_err(
1087 r#"
1088webhooks:
1089 - id: x
1090 kind: detection
1091 url: https://example.test/hook
1092 rate_limit:
1093 requests: 0
1094 per: 1m
1095"#,
1096 );
1097 assert!(
1098 err.to_string()
1099 .contains("rate_limit.requests must be at least 1")
1100 );
1101 }
1102
1103 #[test]
1108 fn signing_missing_secret_env_is_rejected() {
1109 let err = build_err(
1110 r#"
1111webhooks:
1112 - id: x
1113 kind: detection
1114 url: https://example.test/hook
1115 signing:
1116 secret_env: RSIGMA_DEFINITELY_UNSET_SIGNING_SECRET
1117"#,
1118 );
1119 assert!(
1120 err.to_string()
1121 .contains("environment variable 'RSIGMA_DEFINITELY_UNSET_SIGNING_SECRET' is unset"),
1122 "{err}"
1123 );
1124 }
1125
1126 #[test]
1127 fn signing_unknown_scheme_is_rejected() {
1128 let err = build_err(
1129 r#"
1130webhooks:
1131 - id: x
1132 kind: detection
1133 url: https://example.test/hook
1134 signing:
1135 secret_env: RSIGMA_UNUSED
1136 scheme: hocus-pocus
1137"#,
1138 );
1139 assert!(
1140 err.to_string()
1141 .contains("unknown signing scheme 'hocus-pocus'"),
1142 "{err}"
1143 );
1144 }
1145
1146 #[test]
1147 fn signing_github_with_rotation_is_rejected() {
1148 let err = build_err(
1149 r#"
1150webhooks:
1151 - id: x
1152 kind: detection
1153 url: https://example.test/hook
1154 signing:
1155 secret_env: RSIGMA_UNUSED
1156 scheme: github
1157 rotate_secret_env: RSIGMA_UNUSED_OLD
1158"#,
1159 );
1160 assert!(
1161 err.to_string()
1162 .contains("rotate_secret_env is not supported for the github scheme"),
1163 "{err}"
1164 );
1165 }
1166
1167 #[test]
1168 fn signing_custom_unknown_payload_token_is_rejected() {
1169 let err = build_err(
1170 r#"
1171webhooks:
1172 - id: x
1173 kind: detection
1174 url: https://example.test/hook
1175 signing:
1176 secret_env: RSIGMA_UNUSED
1177 scheme: custom
1178 custom:
1179 signature_header: X-Sig
1180 value_format: "v1={signature}"
1181 signed_payload: "{timestamp}.{nope}"
1182"#,
1183 );
1184 let msg = err.to_string();
1185 assert!(msg.contains("custom.signed_payload"), "{msg}");
1186 assert!(msg.contains("{nope}"), "{msg}");
1187 }
1188
1189 #[test]
1190 fn signing_custom_value_format_requires_signature_token() {
1191 let err = build_err(
1192 r#"
1193webhooks:
1194 - id: x
1195 kind: detection
1196 url: https://example.test/hook
1197 signing:
1198 secret_env: RSIGMA_UNUSED
1199 scheme: custom
1200 custom:
1201 signature_header: X-Sig
1202 value_format: "t={timestamp}"
1203 signed_payload: "{body}"
1204"#,
1205 );
1206 assert!(
1207 err.to_string()
1208 .contains("custom.value_format must contain the {signature} token"),
1209 "{err}"
1210 );
1211 }
1212
1213 #[test]
1214 fn signing_empty_base64_secret_is_rejected() {
1215 unsafe { std::env::set_var("RSIGMA_TEST_EMPTY_B64_SECRET", "whsec_") };
1219 let err = build_err(
1220 r#"
1221webhooks:
1222 - id: x
1223 kind: detection
1224 url: https://example.test/hook
1225 signing:
1226 secret_env: RSIGMA_TEST_EMPTY_B64_SECRET
1227 secret_encoding: base64
1228"#,
1229 );
1230 unsafe { std::env::remove_var("RSIGMA_TEST_EMPTY_B64_SECRET") };
1231 assert!(err.to_string().contains("decoded to an empty key"), "{err}");
1232 }
1233
1234 #[test]
1235 fn signing_header_collision_is_rejected() {
1236 let err = build_err(
1237 r#"
1238webhooks:
1239 - id: x
1240 kind: detection
1241 url: https://example.test/hook
1242 headers:
1243 Webhook-Signature: spoofed
1244 signing:
1245 secret_env: RSIGMA_UNUSED
1246"#,
1247 );
1248 assert!(
1249 err.to_string()
1250 .contains("signing header 'webhook-signature' collides"),
1251 "{err}"
1252 );
1253 }
1254}