rsigma_runtime/enrichment/mod.rs
1//! Post-evaluation enrichment for the rsigma daemon.
2//!
3//! Enrichment runs in the daemon's sink task, after `Engine::evaluate()` has
4//! produced a [`ProcessResult`](rsigma_eval::ProcessResult) (a flat
5//! `Vec<EvaluationResult>`) and before that result is serialized to a sink.
6//! Each enricher inspects an [`EvaluationResult`], optionally fetches
7//! context (HTTP, command, source cache, pure template), and writes the
8//! result into `result.header.enrichments` under a configured
9//! `inject_field`.
10//!
11//! # Architecture
12//!
13//! A single [`Enricher`] trait covers every primitive (`template`, `lookup`,
14//! `http`, `command`) and any bespoke Rust-coded enrichers. Each enricher
15//! declares an [`EnricherKind`] at config time; the [`EnrichmentPipeline`]
16//! filters results by that declared kind against the
17//! [`EvaluationResult::body`] variant before invoking `enrich()`. There are no
18//! parallel `DetectionEnricher` / `CorrelationEnricher` traits and no separate
19//! context types; enrichers consume `&mut EvaluationResult` directly and
20//! match on `result.body` for kind-specific fields.
21//!
22//! # Concurrency
23//!
24//! Results within a single sink batch are enriched concurrently with bounded
25//! concurrency via a single [`tokio::sync::Semaphore`] owned by the pipeline.
26//! Within a single result the enricher chain runs sequentially (so later
27//! enrichers can depend on earlier ones via `${detection.enrichments.*}` in a
28//! follow-up implementation; for this initial cut the chain is linear and
29//! enrichers are independent).
30//!
31//! # Errors
32//!
33//! Failures are scoped per enricher and do not abort the chain by default;
34//! the per-enricher `on_error` policy ([`OnError`]) decides whether to
35//! `skip` the field, inject a JSON `null` for it, or `drop` the entire
36//! result before serialization. Timeouts are enforced via
37//! [`tokio::time::timeout`] using each enricher's [`Enricher::timeout`].
38
39use std::sync::Arc;
40use std::time::Duration;
41
42use async_trait::async_trait;
43use rsigma_eval::{EvaluationResult, ResultBody};
44use tokio::sync::Semaphore;
45
46use crate::metrics::{MetricsHook, NoopMetrics};
47
48mod command;
49pub mod config;
50mod http;
51pub mod http_cache;
52mod lookup;
53mod scope;
54mod template;
55#[cfg(test)]
56mod tests;
57
58pub use command::{CommandEnricher, OutputFormat};
59pub use http::{
60 DEFAULT_ENRICHER_MAX_RESPONSE_BYTES, HttpEnricher, HttpEnricherClient,
61 build_default_http_client,
62};
63pub use http_cache::{CacheKey, CacheOutcome, HttpResponseCache};
64pub use lookup::LookupEnricher;
65pub use scope::Scope;
66pub use template::{
67 TemplateEnricher, TemplateError, render_template, render_template_json,
68 validate_template_namespace,
69};
70
71/// The kind of [`EvaluationResult`] an [`Enricher`] applies to.
72///
73/// Fixed at config load. Used for two things:
74/// 1. **Template-namespace validation at config load**: a `Detection` enricher
75/// may only reference `${detection.*}`; a `Correlation` enricher may only
76/// reference `${correlation.*}`. Cross-namespace references fail fast at
77/// startup.
78/// 2. **Runtime gating in [`EnrichmentPipeline::run`]**: enrichers whose
79/// declared kind does not match `result.body`'s variant are skipped before
80/// [`Enricher::enrich`] is invoked.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum EnricherKind {
83 /// Applies to detection results ([`ResultBody::Detection`]).
84 Detection,
85 /// Applies to correlation results ([`ResultBody::Correlation`]).
86 Correlation,
87}
88
89impl EnricherKind {
90 /// String label used in metrics, logs, and config errors.
91 pub fn as_str(&self) -> &'static str {
92 match self {
93 EnricherKind::Detection => "detection",
94 EnricherKind::Correlation => "correlation",
95 }
96 }
97
98 /// Returns true if this kind matches the given result body variant.
99 pub fn matches(&self, body: &ResultBody) -> bool {
100 matches!(
101 (self, body),
102 (EnricherKind::Detection, ResultBody::Detection(_))
103 | (EnricherKind::Correlation, ResultBody::Correlation(_))
104 )
105 }
106}
107
108/// Behavior when an enricher fails (timeout, fetch error, parse error, …).
109///
110/// Applied per enricher. Defaults to [`OnError::Skip`] so a single failed
111/// enrichment never breaks the result stream.
112#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
113pub enum OnError {
114 /// Deliver the result unenriched for this field. Default. Logs a warning.
115 #[default]
116 Skip,
117 /// Inject `null` under `inject_field` so downstream consumers see a
118 /// "we tried" marker rather than missing-field ambiguity.
119 Null,
120 /// Suppress the result entirely before serialization. Useful for
121 /// dedup / pre-filter style enrichers that intentionally drop matches
122 /// based on external context.
123 Drop,
124}
125
126/// A typed enrichment failure attributed to a specific enricher.
127#[derive(Debug, Clone)]
128pub struct EnrichError {
129 /// Stable ID of the enricher that produced the error.
130 pub enricher_id: String,
131 /// Categorized failure kind.
132 pub kind: EnrichErrorKind,
133}
134
135impl std::fmt::Display for EnrichError {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 write!(f, "enricher '{}': {}", self.enricher_id, self.kind)
138 }
139}
140
141impl std::error::Error for EnrichError {}
142
143/// Categorized enrichment failure.
144#[derive(Debug, Clone)]
145pub enum EnrichErrorKind {
146 /// The per-enricher timeout elapsed before [`Enricher::enrich`] returned.
147 Timeout,
148 /// External fetch failed (HTTP non-2xx, connection refused, command exit
149 /// status non-zero, etc.).
150 Fetch(String),
151 /// External response could not be parsed (e.g. invalid JSON).
152 Parse(String),
153 /// Extract expression (jq / jsonpath / cel) failed during evaluation.
154 Extract(String),
155 /// Template rendering failed at runtime (missing variable in a strict
156 /// resolver, invalid expansion, …). The config-load-time validator
157 /// catches namespace violations earlier.
158 TemplateRender(String),
159}
160
161impl std::fmt::Display for EnrichErrorKind {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self {
164 EnrichErrorKind::Timeout => write!(f, "timeout"),
165 EnrichErrorKind::Fetch(m) => write!(f, "fetch failed: {m}"),
166 EnrichErrorKind::Parse(m) => write!(f, "parse failed: {m}"),
167 EnrichErrorKind::Extract(m) => write!(f, "extract failed: {m}"),
168 EnrichErrorKind::TemplateRender(m) => write!(f, "template render failed: {m}"),
169 }
170 }
171}
172
173/// Trait implemented by every enrichment primitive (`template`, `lookup`,
174/// `http`, `command`) and by any bespoke Rust-coded named enricher
175/// registered via [`register_builtin`].
176///
177/// Implementations read shared rule metadata from [`EvaluationResult::header`]
178/// and dispatch on `result.body` for kind-specific fields. The pipeline
179/// guarantees that `result.body` matches `self.kind()` before calling
180/// `enrich()`, so implementations may rely on the matching variant
181/// (e.g. via [`EvaluationResult::as_detection`] /
182/// [`EvaluationResult::as_correlation`]).
183///
184/// `enrich` is async to accommodate I/O-bound primitives (HTTP, command).
185/// Pure transformations (`template`) still implement `enrich` as `async fn`
186/// even though they perform no I/O.
187#[async_trait]
188pub trait Enricher: Send + Sync {
189 /// The kind of result this enricher applies to. Fixed at config load.
190 fn kind(&self) -> EnricherKind;
191
192 /// Stable identifier for this enricher instance. Used as a metric label
193 /// and in structured log fields. Conventionally something like
194 /// `asset_lookup_det` or `enrich_hash_virustotal`.
195 fn id(&self) -> &str;
196
197 /// Field name under [`RuleHeader::enrichments`](rsigma_eval::RuleHeader::enrichments)
198 /// that this enricher writes to.
199 fn inject_field(&self) -> &str;
200
201 /// Per-enricher timeout. The pipeline wraps each `enrich()` call in
202 /// [`tokio::time::timeout`] using this value. Defaults to 5 seconds.
203 fn timeout(&self) -> Duration {
204 Duration::from_secs(5)
205 }
206
207 /// Optional scope filter. Applied after the kind-vs-body filter and
208 /// before `enrich()` runs. Default is [`Scope::default`] (always fires).
209 fn scope(&self) -> &Scope;
210
211 /// Behavior when this enricher fails (timeout, fetch error, …).
212 /// Defaults to [`OnError::Skip`].
213 fn on_error(&self) -> OnError {
214 OnError::Skip
215 }
216
217 /// Run the enrichment.
218 ///
219 /// Implementations write into [`RuleHeader::enrichments`](rsigma_eval::RuleHeader::enrichments)
220 /// under the configured [`Self::inject_field`]. The pipeline initializes
221 /// the map (`None` → `Some(empty)`) before invoking the first enricher
222 /// for a given result, so implementations can `unwrap` the map safely.
223 async fn enrich(&self, result: &mut EvaluationResult) -> Result<(), EnrichError>;
224}
225
226/// Outcome of running a single enricher against a single result.
227///
228/// Returned internally by [`EnrichmentPipeline::run_one`] so the pipeline
229/// driver can decide whether to drop the entire result, log, or continue.
230enum EnrichOutcome {
231 /// Enricher ran and (possibly) wrote into `enrichments`.
232 Ok,
233 /// Enricher errored or timed out and `on_error: skip` applied.
234 Skip,
235 /// Enricher errored or timed out and `on_error: null` applied; the
236 /// pipeline injected `null` under `inject_field`.
237 Null,
238 /// Enricher errored or timed out and `on_error: drop` applied; the
239 /// pipeline must remove this result from the output vec.
240 Drop,
241 /// Enricher was filtered out (kind or scope mismatch) before running.
242 Filtered,
243}
244
245/// Execution surface for a configured set of enrichers.
246///
247/// One pipeline owns one `Vec<Box<dyn Enricher>>` plus a shared
248/// [`Semaphore`] that bounds the number of in-flight enrichments across
249/// all results in a batch.
250///
251/// The pipeline is constructed by the daemon config layer
252/// (`crates/rsigma-cli/src/daemon/enrichment/config.rs`) and held inside
253/// the daemon's sink task. Each [`ProcessResult`](rsigma_eval::ProcessResult)
254/// (a `Vec<EvaluationResult>`) flows through [`EnrichmentPipeline::run`]
255/// before it is serialized.
256pub struct EnrichmentPipeline {
257 enrichers: Vec<Box<dyn Enricher>>,
258 semaphore: Arc<Semaphore>,
259 metrics: Arc<dyn MetricsHook>,
260}
261
262impl std::fmt::Debug for EnrichmentPipeline {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 f.debug_struct("EnrichmentPipeline")
265 .field("enrichers", &self.enrichers.len())
266 .field("permits", &self.semaphore.available_permits())
267 .finish()
268 }
269}
270
271impl EnrichmentPipeline {
272 /// Build a pipeline from a list of configured enrichers.
273 ///
274 /// `max_concurrent_enrichments` bounds the number of results that can
275 /// be enriched in parallel; defaults to 16 if zero is passed.
276 /// Metrics default to a no-op sink; call [`Self::with_metrics`] to
277 /// route counters and latency histograms into a real
278 /// [`MetricsHook`] implementation.
279 pub fn new(enrichers: Vec<Box<dyn Enricher>>, max_concurrent_enrichments: usize) -> Self {
280 let permits = if max_concurrent_enrichments == 0 {
281 16
282 } else {
283 max_concurrent_enrichments
284 };
285 Self {
286 enrichers,
287 semaphore: Arc::new(Semaphore::new(permits)),
288 metrics: Arc::new(NoopMetrics),
289 }
290 }
291
292 /// Replace the metrics hook this pipeline reports into. The daemon
293 /// passes its Prometheus-backed `Metrics` here; library consumers
294 /// can pass any [`MetricsHook`] implementation.
295 ///
296 /// Pre-registers `(enricher_id, kind)` for every configured
297 /// enricher so `rsigma_enrichment_total{...}` and
298 /// `rsigma_enrichment_duration_seconds{...}` are emitted on
299 /// `/metrics` from the first scrape, even before any enricher has
300 /// fired.
301 pub fn with_metrics(mut self, metrics: Arc<dyn MetricsHook>) -> Self {
302 for enricher in &self.enrichers {
303 metrics.register_enricher(enricher.id(), enricher.kind().as_str());
304 }
305 self.metrics = metrics;
306 self
307 }
308
309 /// Returns true if no enrichers are configured.
310 ///
311 /// The daemon sink task uses this to skip enrichment work entirely
312 /// (no permit acquisition, no per-result loop) when no enrichers are
313 /// configured.
314 pub fn is_empty(&self) -> bool {
315 self.enrichers.is_empty()
316 }
317
318 /// Number of configured enrichers (across both kinds).
319 pub fn len(&self) -> usize {
320 self.enrichers.len()
321 }
322
323 /// Iterate over the configured enrichers (read-only view, used for
324 /// reload-diff logging and tests).
325 pub fn enrichers(&self) -> impl Iterator<Item = &dyn Enricher> {
326 self.enrichers.iter().map(|e| &**e)
327 }
328
329 /// Run every applicable enricher against each result in `results`.
330 ///
331 /// For each result the pipeline:
332 /// 1. Acquires a global semaphore permit (bounded concurrency).
333 /// 2. Iterates the configured enricher list.
334 /// 3. Skips enrichers whose [`EnricherKind`] does not match the
335 /// result's body variant.
336 /// 4. Skips enrichers whose [`Scope`] excludes this result.
337 /// 5. Wraps each remaining `enrich()` call in
338 /// [`tokio::time::timeout`] using the enricher's timeout.
339 /// 6. On error, applies the enricher's [`OnError`] policy.
340 /// 7. If any enricher in the chain returns the internal `Drop`
341 /// outcome (via an enricher whose [`OnError`] policy is set
342 /// to [`OnError::Drop`]), the result is removed from the
343 /// output.
344 ///
345 /// The pipeline initializes `result.header.enrichments` to
346 /// `Some(empty)` lazily on first successful injection, so the
347 /// `skip_serializing_if = "Option::is_none"` contract on
348 /// `RuleHeader::enrichments` is preserved when no enricher writes.
349 ///
350 /// Currently sequential per result; concurrent across results would
351 /// require splitting `results` into chunks across futures and is left
352 /// for a follow-up tuning pass once we have realistic throughput
353 /// numbers from the integration tests.
354 pub async fn run(&self, results: &mut Vec<EvaluationResult>) {
355 if self.enrichers.is_empty() || results.is_empty() {
356 return;
357 }
358
359 // Single-pass enrichment with drop bookkeeping.
360 //
361 // We cannot use `Vec::retain_mut` together with `.await` (the
362 // closure must be sync), so we collect drop indices first and
363 // apply them in a single linear pass at the end.
364 let mut drop_indices: Vec<usize> = Vec::new();
365
366 for (idx, result) in results.iter_mut().enumerate() {
367 let permit = self.semaphore.clone().acquire_owned().await.ok();
368 if permit.is_none() {
369 // Semaphore closed (only happens at shutdown). Drain
370 // remaining results unenriched rather than blocking.
371 tracing::debug!("Enrichment semaphore closed, draining remaining results");
372 return;
373 }
374 let _permit = permit.unwrap();
375
376 let mut should_drop = false;
377 for enricher in &self.enrichers {
378 match Self::run_one(enricher.as_ref(), result, self.metrics.as_ref()).await {
379 EnrichOutcome::Drop => {
380 should_drop = true;
381 break;
382 }
383 EnrichOutcome::Ok
384 | EnrichOutcome::Skip
385 | EnrichOutcome::Null
386 | EnrichOutcome::Filtered => {}
387 }
388 }
389 if should_drop {
390 drop_indices.push(idx);
391 }
392 }
393
394 if !drop_indices.is_empty() {
395 // Remove from the back so earlier indices stay valid.
396 for idx in drop_indices.into_iter().rev() {
397 results.swap_remove(idx);
398 }
399 }
400 }
401
402 /// Run a single enricher against a single result, applying the
403 /// kind-vs-body filter, scope filter, timeout, and on_error policy.
404 /// Records `rsigma_enrichment_total{enricher_id, kind, status}` and
405 /// `rsigma_enrichment_duration_seconds{enricher_id, kind}` via the
406 /// configured `MetricsHook` for every non-filtered call.
407 async fn run_one(
408 enricher: &dyn Enricher,
409 result: &mut EvaluationResult,
410 metrics: &dyn MetricsHook,
411 ) -> EnrichOutcome {
412 if !enricher.kind().matches(&result.body) {
413 return EnrichOutcome::Filtered;
414 }
415 if !enricher.scope().matches(result) {
416 return EnrichOutcome::Filtered;
417 }
418
419 let inject_field = enricher.inject_field().to_string();
420 let timeout = enricher.timeout();
421 let id = enricher.id().to_string();
422 let kind_label = enricher.kind().as_str();
423 let on_error = enricher.on_error();
424
425 metrics.on_enrichment_queue_depth_change(1);
426 let started = std::time::Instant::now();
427 let outcome = tokio::time::timeout(timeout, enricher.enrich(result)).await;
428 let elapsed = started.elapsed().as_secs_f64();
429 metrics.on_enrichment_queue_depth_change(-1);
430
431 let err = match outcome {
432 Ok(Ok(())) => {
433 metrics.on_enrichment_completed(&id, kind_label, "success", elapsed);
434 return EnrichOutcome::Ok;
435 }
436 Ok(Err(e)) => e,
437 Err(_) => EnrichError {
438 enricher_id: id.clone(),
439 kind: EnrichErrorKind::Timeout,
440 },
441 };
442
443 let is_timeout = matches!(err.kind, EnrichErrorKind::Timeout);
444 match on_error {
445 OnError::Skip => {
446 tracing::warn!(
447 enricher_id = %id,
448 kind = %kind_label,
449 error = %err,
450 "Enricher failed, skipping"
451 );
452 metrics.on_enrichment_completed(
453 &id,
454 kind_label,
455 if is_timeout { "timeout" } else { "skip" },
456 elapsed,
457 );
458 EnrichOutcome::Skip
459 }
460 OnError::Null => {
461 tracing::warn!(
462 enricher_id = %id,
463 kind = %kind_label,
464 error = %err,
465 "Enricher failed, injecting null"
466 );
467 let map = result
468 .header
469 .enrichments
470 .get_or_insert_with(serde_json::Map::new);
471 map.insert(inject_field, serde_json::Value::Null);
472 metrics.on_enrichment_completed(
473 &id,
474 kind_label,
475 if is_timeout { "timeout" } else { "error" },
476 elapsed,
477 );
478 EnrichOutcome::Null
479 }
480 OnError::Drop => {
481 tracing::warn!(
482 enricher_id = %id,
483 kind = %kind_label,
484 error = %err,
485 "Enricher failed, dropping result"
486 );
487 metrics.on_enrichment_completed(&id, kind_label, "drop", elapsed);
488 EnrichOutcome::Drop
489 }
490 }
491 }
492}
493
494impl Default for EnrichmentPipeline {
495 fn default() -> Self {
496 Self::new(Vec::new(), 16)
497 }
498}
499
500impl Clone for EnrichmentPipeline {
501 fn clone(&self) -> Self {
502 // Boxes of `dyn Enricher` are not `Clone`, so a true deep clone
503 // is not possible here. The hot-reload path always rebuilds the
504 // pipeline from config rather than cloning, but `Clone` is
505 // useful for tests and for `ArcSwap` adapter code that wants a
506 // throwaway snapshot. Returning an empty pipeline that shares
507 // the metrics hook is the safest behaviour: a misuse degrades
508 // to "no enrichment" rather than panicking or silently
509 // double-counting.
510 Self {
511 enrichers: Vec::new(),
512 semaphore: Arc::clone(&self.semaphore),
513 metrics: Arc::clone(&self.metrics),
514 }
515 }
516}
517
518/// Helper for [`Enricher::enrich`] implementations: write `value` into
519/// `result.header.enrichments` under `inject_field`, allocating the map
520/// if it was previously `None`.
521pub fn inject_enrichment(
522 result: &mut EvaluationResult,
523 inject_field: &str,
524 value: serde_json::Value,
525) {
526 let map = result
527 .header
528 .enrichments
529 .get_or_insert_with(serde_json::Map::new);
530 map.insert(inject_field.to_string(), value);
531}
532
533// ---------------------------------------------------------------------------
534// Bespoke enricher registration
535// ---------------------------------------------------------------------------
536
537/// Factory function signature used by [`register_builtin`].
538///
539/// External crates that ship a bespoke enricher type register a
540/// `Box<dyn Fn(&serde_json::Value) -> Result<Box<dyn Enricher>, String>>`
541/// so the daemon config layer can construct the enricher from its YAML
542/// config block at startup. The `serde_json::Value` argument is the raw
543/// enricher-config block (after `kind` / `id` / `type` fields are
544/// extracted by the loader).
545pub type EnricherFactory =
546 Arc<dyn Fn(&serde_json::Value) -> Result<Box<dyn Enricher>, String> + Send + Sync>;
547
548/// Process-wide registry of bespoke enricher factories keyed by `type`.
549///
550/// External crates call [`register_builtin`] once at startup (typically
551/// in their `lib.rs` via `ctor` or an explicit init function) to wire a
552/// new `type: <name>` value into the daemon's config loader. Generic
553/// primitives (`template`, `lookup`, `http`, `command`) are not in this
554/// registry; they are constructed directly by the loader.
555///
556/// The registry is global and append-only — registering the same name
557/// twice is an error. Concurrent reads are lock-free via [`std::sync::OnceLock`]
558/// at the outer level and a [`std::sync::RwLock`] at the inner level for the
559/// (rare) `register_builtin` writes.
560fn registry() -> &'static std::sync::RwLock<std::collections::HashMap<String, EnricherFactory>> {
561 use std::sync::OnceLock;
562 static REGISTRY: OnceLock<
563 std::sync::RwLock<std::collections::HashMap<String, EnricherFactory>>,
564 > = OnceLock::new();
565 REGISTRY.get_or_init(|| std::sync::RwLock::new(std::collections::HashMap::new()))
566}
567
568/// Register a bespoke enricher factory under `type: <name>`.
569///
570/// Returns an error if `name` is already registered (registration is
571/// process-global and append-only) or if `name` collides with a built-in
572/// primitive type (`template`, `lookup`, `http`, `command`).
573///
574/// External crates call this once at startup before the daemon loads its
575/// config. After config load, the registry is read-only in practice.
576pub fn register_builtin(name: &str, factory: EnricherFactory) -> Result<(), String> {
577 if matches!(name, "template" | "lookup" | "http" | "command") {
578 return Err(format!(
579 "cannot register '{name}': name is reserved for a built-in primitive"
580 ));
581 }
582 let reg = registry();
583 let mut guard = reg
584 .write()
585 .map_err(|_| "enricher registry poisoned".to_string())?;
586 if guard.contains_key(name) {
587 return Err(format!("enricher type '{name}' is already registered"));
588 }
589 guard.insert(name.to_string(), factory);
590 Ok(())
591}
592
593/// Look up a registered bespoke enricher factory by `type` name.
594///
595/// Returns `None` if `name` is not registered. The daemon config loader
596/// uses this to construct bespoke enrichers; missing names are surfaced
597/// to the operator as a clear startup error.
598pub fn lookup_builtin(name: &str) -> Option<EnricherFactory> {
599 let reg = registry();
600 let guard = reg.read().ok()?;
601 guard.get(name).cloned()
602}
603
604/// Clear the bespoke enricher registry. **Test-only**: used by unit tests
605/// that need to register / re-register the same name. Not exposed to
606/// downstream crates.
607#[cfg(test)]
608pub(crate) fn clear_builtin_registry() {
609 if let Ok(mut guard) = registry().write() {
610 guard.clear();
611 }
612}