Skip to main content

rsigma_runtime/io/webhook/
config.rs

1//! YAML schema, validation, and loader for a webhooks config file.
2//!
3//! Declared via `--webhook <PATH>` (repeatable; file or directory) and the
4//! layered `daemon.output.webhooks` key. Loaded once at daemon startup. The
5//! validator rejects startup with a clear, field-scoped error when:
6//!
7//! - `kind` is not `detection` or `correlation` (`incident` arrives with
8//!   roadmap item #48),
9//! - a templated field (`url`, a header value, `body`) references the wrong
10//!   namespace for the declared `kind`, or is malformed,
11//! - `url` is empty, the `method` is invalid, `retry.attempts` is zero, a
12//!   duration fails to parse, or `scope` fails to compile.
13//!
14//! Core logic (template render, classification, rate limiting) lives in
15//! [`super::sink`]; the queue, retry/backoff, and DLQ routing are the shared
16//! `crate::io::delivery` layer, not re-implemented here.
17
18use 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
39/// Default per-request timeout when `timeout:` is omitted.
40pub const DEFAULT_WEBHOOK_TIMEOUT: Duration = Duration::from_secs(10);
41/// Default total attempts (one initial try plus retries).
42pub const DEFAULT_WEBHOOK_ATTEMPTS: u32 = 3;
43/// Default exponential backoff base.
44pub const DEFAULT_WEBHOOK_BACKOFF: Duration = Duration::from_secs(1);
45/// Default backoff ceiling.
46pub const DEFAULT_WEBHOOK_MAX_BACKOFF: Duration = Duration::from_secs(30);
47/// Default bounded queue depth between the dispatcher and the worker.
48pub const DEFAULT_WEBHOOK_QUEUE_SIZE: usize = 1024;
49
50/// Top-level webhooks config file.
51///
52/// ```yaml
53/// webhooks:
54///   - id: slack-critical
55///     kind: detection
56///     url: https://hooks.slack.com/services/${SLACK_WEBHOOK_PATH}
57///     body: |
58///       {"text": "Sigma: ${detection.rule.title} (${detection.rule.level})"}
59///     scope:
60///       levels: [high, critical]
61/// ```
62#[derive(Debug, Clone, Deserialize)]
63pub struct WebhooksFile {
64    /// Per-webhook configurations. An empty list (or missing key) is allowed
65    /// so an operator can keep a webhooks file around during a rollout.
66    #[serde(default)]
67    pub webhooks: Vec<WebhookConfig>,
68}
69
70/// One webhook's YAML config block.
71#[derive(Debug, Clone, Deserialize)]
72pub struct WebhookConfig {
73    /// Stable identifier; used as the metric label and in config errors.
74    pub id: String,
75    /// `detection` or `correlation`. Deserialized as a free-form string so an
76    /// unknown value (e.g. `incident`) produces the forward-looking error
77    /// rather than a generic serde "unknown variant".
78    pub kind: String,
79    /// Target URL template (`${detection.*}` / `${correlation.*}` / `${ENV}`).
80    pub url: String,
81    /// HTTP method. Defaults to `POST`.
82    #[serde(default)]
83    pub method: Option<String>,
84    /// Header templates. Values are rendered per result (identity escaping).
85    #[serde(default)]
86    pub headers: HashMap<String, String>,
87    /// Request body template. Rendered with JSON-string escaping so
88    /// interpolated values cannot break the document.
89    #[serde(default)]
90    pub body: Option<String>,
91    /// Per-request timeout. Accepts humantime strings (`5s`, `200ms`).
92    #[serde(default, with = "humantime_opt")]
93    pub timeout: Option<Duration>,
94    /// Retry tuning. Overrides the daemon's `--sink-*` delivery defaults.
95    #[serde(default)]
96    pub retry: Option<RetryConfig>,
97    /// Optional per-entry rate limit (token bucket).
98    #[serde(default)]
99    pub rate_limit: Option<RateLimitConfig>,
100    /// Optional scope filter (same axes as enrichers: rules/tags/levels).
101    #[serde(default)]
102    pub scope: Option<ScopeConfig>,
103    /// Bounded queue depth. Defaults to [`DEFAULT_WEBHOOK_QUEUE_SIZE`].
104    #[serde(default)]
105    pub queue_size: Option<usize>,
106    /// Optional TLS material for the endpoint: a custom CA bundle and/or a
107    /// client identity for mutual TLS. Omit it to use the system roots (the
108    /// common case for public services like Slack).
109    #[serde(default)]
110    pub tls: Option<WebhookTlsConfig>,
111    /// Optional HMAC request signing. Adds signature headers a receiving
112    /// endpoint can verify; see [`SigningConfig`].
113    #[serde(default)]
114    pub signing: Option<SigningConfig>,
115}
116
117/// `tls:` block. PEM file paths read at startup.
118#[derive(Debug, Clone, Default, Deserialize)]
119pub struct WebhookTlsConfig {
120    /// Custom CA bundle (PEM file path) to trust in addition to the system
121    /// roots. Use for an internal relay served by a private CA.
122    #[serde(default)]
123    pub ca: Option<String>,
124    /// Client certificate chain (PEM file path) for mutual TLS. Requires
125    /// `client_key`.
126    #[serde(default)]
127    pub client_cert: Option<String>,
128    /// Client private key (PEM file path) for mutual TLS. Requires
129    /// `client_cert`.
130    #[serde(default)]
131    pub client_key: Option<String>,
132}
133
134/// `signing:` block. HMAC-signs each request so a receiver can verify it.
135///
136/// The secret never lives in the YAML: `secret_env` names an environment
137/// variable, resolved once at startup so a missing key fails fast.
138///
139/// ```yaml
140/// signing:
141///   secret_env: RSIGMA_WEBHOOK_SECRET
142///   scheme: standard            # standard (default) | github | custom
143/// ```
144#[derive(Debug, Clone, Deserialize)]
145pub struct SigningConfig {
146    /// Environment variable holding the HMAC key. Required.
147    pub secret_env: String,
148    /// How the env value is decoded into key bytes: `utf8` (default) treats it
149    /// as raw bytes; `base64` decodes it (stripping an optional `whsec_`
150    /// prefix) so a Standard Webhooks secret can be pasted verbatim.
151    #[serde(default)]
152    pub secret_encoding: Option<String>,
153    /// Signing convention: `standard` (default), `github`, or `custom`.
154    #[serde(default)]
155    pub scheme: Option<String>,
156    /// Optional second key (from another env var) emitted as a second
157    /// signature during a key rollover. Not supported by `github`.
158    #[serde(default)]
159    pub rotate_secret_env: Option<String>,
160    /// Knobs honored only when `scheme: custom`.
161    #[serde(default)]
162    pub custom: Option<CustomSigningConfig>,
163}
164
165/// `signing.custom:` block. Honored only when `scheme: custom`.
166#[derive(Debug, Clone, Deserialize)]
167pub struct CustomSigningConfig {
168    /// HMAC hash: `sha256` (default) or `sha512`.
169    #[serde(default)]
170    pub algorithm: Option<String>,
171    /// Signature encoding: `hex` (default) or `base64`.
172    #[serde(default)]
173    pub encoding: Option<String>,
174    /// Header name carrying the signature value.
175    pub signature_header: String,
176    /// Header value template; must contain `{signature}`. Also supports
177    /// `{timestamp}` and `{id}`.
178    pub value_format: String,
179    /// What gets HMAC'd. Supports `{body}`, `{timestamp}`, and `{id}`.
180    pub signed_payload: String,
181    /// Optional separate header carrying the unix-seconds timestamp.
182    #[serde(default)]
183    pub timestamp_header: Option<String>,
184    /// Optional separate header carrying the per-delivery id.
185    #[serde(default)]
186    pub id_header: Option<String>,
187}
188
189/// `retry:` block. Each field overrides a delivery-layer default.
190#[derive(Debug, Clone, Default, Deserialize)]
191pub struct RetryConfig {
192    /// Total tries (one initial plus retries). Defaults to
193    /// [`DEFAULT_WEBHOOK_ATTEMPTS`]; must be at least 1.
194    #[serde(default)]
195    pub attempts: Option<u32>,
196    /// Exponential backoff base. Defaults to [`DEFAULT_WEBHOOK_BACKOFF`].
197    #[serde(default, with = "humantime_opt")]
198    pub backoff: Option<Duration>,
199    /// Backoff ceiling. Defaults to [`DEFAULT_WEBHOOK_MAX_BACKOFF`].
200    #[serde(default, with = "humantime_opt")]
201    pub max_backoff: Option<Duration>,
202}
203
204/// `rate_limit:` block. `requests` per `per`, burst = `requests`.
205#[derive(Debug, Clone, Deserialize)]
206pub struct RateLimitConfig {
207    /// Sustained request budget per window.
208    pub requests: u32,
209    /// Window length. Accepts humantime strings (`1m`, `30s`).
210    #[serde(with = "humantime_dur")]
211    pub per: Duration,
212}
213
214/// `scope:` block. Mirrors the enrichment scope axes.
215#[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/// Parsed `kind:` discriminator.
226///
227/// Deliberately a closed enum so #48 can add `Incident` additively without any
228/// existing config key changing meaning.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum WebhookKind {
231    /// Fires on detection results ([`ResultBody::Detection`]).
232    Detection,
233    /// Fires on correlation results ([`ResultBody::Correlation`]).
234    Correlation,
235}
236
237impl WebhookKind {
238    /// String label used in config errors and logs.
239    pub fn as_str(self) -> &'static str {
240        match self {
241            WebhookKind::Detection => "detection",
242            WebhookKind::Correlation => "correlation",
243        }
244    }
245
246    /// Map onto the shared [`EnricherKind`] so the template-namespace validator
247    /// (which is kind-agnostic past detection/correlation) can be reused.
248    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    /// True when this kind matches the given result body variant.
256    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
269/// A webhook sink plus the per-sink delivery config the dispatcher drives it
270/// with. The full-queue policy is fixed to `Drop` by the caller (the lossy
271/// seam that keeps a third-party HTTP endpoint off the at-least-once path).
272pub struct BuiltWebhook {
273    pub sink: WebhookSink,
274    pub delivery: DeliveryConfig,
275}
276
277/// Errors produced while loading or validating a webhooks config.
278#[derive(Debug)]
279pub enum WebhookConfigError {
280    /// File could not be read.
281    Io(std::io::Error, std::path::PathBuf),
282    /// YAML failed to deserialize.
283    Yaml(yaml_serde::Error),
284    /// `kind` was not `detection` or `correlation`.
285    UnknownKind { webhook_id: String, kind: String },
286    /// A required field was empty or missing.
287    MissingField {
288        webhook_id: String,
289        field: &'static str,
290    },
291    /// `method` was not a valid HTTP method token.
292    InvalidMethod { webhook_id: String, method: String },
293    /// A templated field referenced the wrong namespace for the declared kind.
294    CrossNamespace {
295        webhook_id: String,
296        kind: &'static str,
297        reference: String,
298        field: &'static str,
299    },
300    /// A templated field had a malformed `${...}` reference.
301    MalformedTemplate {
302        webhook_id: String,
303        reference: String,
304        field: &'static str,
305    },
306    /// `retry` settings were invalid (e.g. zero attempts).
307    InvalidRetry { webhook_id: String, message: String },
308    /// `rate_limit` settings were invalid.
309    InvalidRateLimit { webhook_id: String, message: String },
310    /// `scope` failed to compile.
311    Scope { webhook_id: String, message: String },
312    /// `tls` material was invalid or unreadable.
313    Tls { webhook_id: String, message: String },
314    /// A signing `secret_env` was unset or empty in the environment.
315    MissingSecretEnv { webhook_id: String, var: String },
316    /// A `signing` block was otherwise invalid (scheme, encoding, tokens,
317    /// header collision).
318    InvalidSigning { webhook_id: String, message: String },
319    /// The shared HTTP client could not be built.
320    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
393/// Read and deserialize a webhooks config file (no validation; see
394/// [`build_webhooks`]).
395pub 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
402/// Validate and build every webhook in `file` into a [`BuiltWebhook`].
403///
404/// All webhooks share one process-level egress-filtered `reqwest::Client`
405/// (via [`build_default_http_client`]) so connection pooling and the SSRF
406/// defense are wired once. `metrics` receives the webhook-specific request /
407/// rate-limit events; its labels are pre-seeded here so panels render before
408/// traffic.
409pub 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        // One rendered body per result; multi-result digest posts are out of
502        // scope. The shared worker still owns the queue and retry schedule.
503        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    // A webhook with a `tls:` block gets a dedicated egress-filtered client
518    // carrying its CA and/or client identity; the rest share the default.
519    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
537/// Build an egress-filtered `reqwest` client carrying a webhook's TLS material.
538///
539/// The CA bundle is trusted in addition to the system roots; a client cert and
540/// key together enable mutual TLS. Egress filtering (SSRF defense) is preserved
541/// via the same DNS resolver the default client uses.
542fn 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        // reqwest's rustls identity wants a single PEM buffer of cert + key.
580        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
594/// Pin the process-default rustls `CryptoProvider` when more than one is in the
595/// dependency tree.
596///
597/// With the `otlp` feature, tonic pulls aws-lc-rs and reqwest pulls ring, so
598/// rustls cannot auto-select a default and a TLS client build would panic; pin
599/// aws-lc-rs to match the daemon's other TLS surfaces. Without `otlp` there is
600/// a single provider and reqwest self-configures, so this is a no-op.
601fn 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
633/// Validate a `signing:` block and build its [`WebhookSigner`].
634///
635/// Resolves the secret(s) from the environment, parses the scheme and its
636/// knobs, and rejects a signing header that would collide with a user header.
637fn 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    // Structural validation first, so a malformed block is reported even when
648    // the secret happens to be missing from the environment.
649    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    // Resolve secret material last.
683    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
692/// Read and decode a signing secret from the environment.
693///
694/// The returned key is wrapped in [`Zeroizing`] so the secret bytes are wiped
695/// from memory when they are dropped, including on the error path where a later
696/// key fails to resolve.
697fn 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    // A zero-length HMAC key is accepted by the primitive but is effectively no
730    // key at all; reject it so a misconfigured `base64` secret (e.g. a bare
731    // `whsec_` prefix) cannot silently weaken signing.
732    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
741/// Validate and resolve a `signing.custom:` block.
742fn 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
801/// Reject any `{token}` in `template` outside `allowed`.
802fn 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
830/// humantime serde adapter for `Option<Duration>`.
831mod 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
850/// humantime serde adapter for a required `Duration`.
851mod 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    // `BuiltWebhook` is not `Debug` (it holds a reqwest client and a `dyn`
876    // metrics hook), so error tests discard the Ok payload before `unwrap_err`.
877    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        // Defaults: 3 total tries -> retry_max 2, queue 1024.
895        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    // Signing validation. These cases are reached without the secret present in
1104    // the environment because structural checks run before secret resolution;
1105    // the one case that needs an unset secret uses a name that is never set.
1106
1107    #[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        // A bare `whsec_` prefix decodes to an empty key; reject it rather than
1216        // sign with effectively no key.
1217        // SAFETY: single-threaded test; the var is unique to this case.
1218        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}