use serde::{Deserialize, Serialize};
use rsigma_eval::EvaluationResult;
use super::matcher::{MatcherError, MatcherSet, MatcherSpec};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SilenceOrigin {
Static,
Api,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SilenceState {
Pending,
Active,
Expired,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SilenceSpec {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub matchers: Vec<MatcherSpec>,
#[serde(default)]
pub starts_at: Option<String>,
#[serde(default)]
pub ends_at: Option<String>,
#[serde(default)]
pub created_by: Option<String>,
#[serde(default)]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SilenceView {
pub id: String,
pub matchers: Vec<MatcherSpec>,
#[serde(skip_serializing_if = "Option::is_none")]
pub starts_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ends_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
pub origin: SilenceOrigin,
pub state: SilenceState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SilenceSnap {
pub id: String,
pub matchers: Vec<MatcherSpec>,
pub starts_at: Option<i64>,
pub ends_at: Option<i64>,
pub created_by: Option<String>,
pub comment: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SilenceError {
EmptyMatchers,
Matcher(MatcherError),
Time { field: &'static str, value: String },
Window,
}
impl std::fmt::Display for SilenceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SilenceError::EmptyMatchers => write!(f, "a silence requires at least one matcher"),
SilenceError::Matcher(e) => write!(f, "{e}"),
SilenceError::Time { field, value } => {
write!(
f,
"invalid {field} '{value}': expected an RFC 3339 timestamp"
)
}
SilenceError::Window => write!(f, "ends_at must be after starts_at"),
}
}
}
impl std::error::Error for SilenceError {}
#[derive(Debug, Clone)]
pub struct Silence {
id: String,
matchers: MatcherSet,
starts_at: Option<i64>,
ends_at: Option<i64>,
created_by: Option<String>,
comment: Option<String>,
origin: SilenceOrigin,
}
impl Silence {
pub fn build(spec: SilenceSpec, origin: SilenceOrigin) -> Result<Self, SilenceError> {
if spec.matchers.is_empty() {
return Err(SilenceError::EmptyMatchers);
}
let matchers = MatcherSet::compile(&spec.matchers).map_err(SilenceError::Matcher)?;
let starts_at = parse_time(spec.starts_at.as_deref(), "starts_at")?;
let ends_at = parse_time(spec.ends_at.as_deref(), "ends_at")?;
if let (Some(s), Some(e)) = (starts_at, ends_at)
&& e <= s
{
return Err(SilenceError::Window);
}
Ok(Silence {
id: spec.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
matchers,
starts_at,
ends_at,
created_by: spec.created_by,
comment: spec.comment,
origin,
})
}
pub fn id(&self) -> &str {
&self.id
}
pub fn state(&self, now: i64) -> SilenceState {
if self.starts_at.is_some_and(|s| now < s) {
SilenceState::Pending
} else if self.ends_at.is_some_and(|e| now >= e) {
SilenceState::Expired
} else {
SilenceState::Active
}
}
fn mutes(&self, result: &EvaluationResult, now: i64) -> bool {
self.state(now) == SilenceState::Active && self.matchers.matches(result)
}
fn to_snap(&self) -> SilenceSnap {
SilenceSnap {
id: self.id.clone(),
matchers: self.matchers.to_specs(),
starts_at: self.starts_at,
ends_at: self.ends_at,
created_by: self.created_by.clone(),
comment: self.comment.clone(),
}
}
fn from_snap(snap: SilenceSnap) -> Result<Self, SilenceError> {
if snap.matchers.is_empty() {
return Err(SilenceError::EmptyMatchers);
}
let matchers = MatcherSet::compile(&snap.matchers).map_err(SilenceError::Matcher)?;
Ok(Silence {
id: snap.id,
matchers,
starts_at: snap.starts_at,
ends_at: snap.ends_at,
created_by: snap.created_by,
comment: snap.comment,
origin: SilenceOrigin::Api,
})
}
fn view(&self, now: i64) -> SilenceView {
SilenceView {
id: self.id.clone(),
matchers: self.matchers.to_specs(),
starts_at: self.starts_at.map(unix_to_rfc3339),
ends_at: self.ends_at.map(unix_to_rfc3339),
created_by: self.created_by.clone(),
comment: self.comment.clone(),
origin: self.origin,
state: self.state(now),
}
}
}
#[derive(Debug, Default)]
pub struct SilenceStore {
silences: Vec<Silence>,
}
impl SilenceStore {
pub fn is_empty(&self) -> bool {
self.silences.is_empty()
}
pub fn add(&mut self, silence: Silence) {
self.silences.push(silence);
}
pub fn dynamic_count(&self) -> usize {
self.silences
.iter()
.filter(|s| s.origin == SilenceOrigin::Api)
.count()
}
pub fn try_add(&mut self, silence: Silence, max_dynamic: usize) -> bool {
if self.dynamic_count() >= max_dynamic {
return false;
}
self.silences.push(silence);
true
}
pub fn remove(&mut self, id: &str) -> bool {
let before = self.silences.len();
self.silences.retain(|s| s.id != id);
self.silences.len() != before
}
pub fn set_static(&mut self, statics: Vec<Silence>) {
self.silences.retain(|s| s.origin != SilenceOrigin::Static);
self.silences.extend(statics);
}
pub fn gc(&mut self, now: i64) {
self.silences
.retain(|s| s.state(now) != SilenceState::Expired);
}
pub fn active_match(&self, result: &EvaluationResult, now: i64) -> Option<&str> {
self.silences
.iter()
.find(|s| s.mutes(result, now))
.map(|s| s.id.as_str())
}
pub fn active_count(&self, now: i64) -> usize {
self.silences
.iter()
.filter(|s| s.state(now) == SilenceState::Active)
.count()
}
pub fn snapshot(&self, now: i64) -> Vec<SilenceView> {
self.silences.iter().map(|s| s.view(now)).collect()
}
pub(crate) fn api_snapshot(&self) -> Vec<SilenceSnap> {
self.silences
.iter()
.filter(|s| s.origin == SilenceOrigin::Api)
.map(|s| s.to_snap())
.collect()
}
pub(crate) fn restore_api(&mut self, snaps: Vec<SilenceSnap>, now: i64) {
for snap in snaps {
match Silence::from_snap(snap) {
Ok(silence) if silence.state(now) != SilenceState::Expired => {
self.silences.push(silence);
}
Ok(_) => {}
Err(e) => tracing::warn!(error = %e, "Dropping unrestorable silence"),
}
}
}
}
fn parse_time(raw: Option<&str>, field: &'static str) -> Result<Option<i64>, SilenceError> {
match raw {
None => Ok(None),
Some(s) => chrono::DateTime::parse_from_rfc3339(s)
.map(|dt| Some(dt.timestamp()))
.map_err(|_| SilenceError::Time {
field,
value: s.to_string(),
}),
}
}
fn unix_to_rfc3339(secs: i64) -> String {
chrono::DateTime::from_timestamp(secs, 0)
.unwrap_or_default()
.to_rfc3339()
}
#[cfg(test)]
mod tests {
use super::*;
use rsigma_eval::{DetectionBody, EvaluationResult, FieldMatch, ResultBody, RuleHeader};
use rsigma_parser::Level;
use std::collections::HashMap;
use std::sync::Arc;
use super::super::matcher::MatchOp;
fn detection(ip: &str) -> EvaluationResult {
EvaluationResult {
header: RuleHeader {
rule_title: "t".to_string(),
rule_id: Some("rule-1".to_string()),
level: Some(Level::High),
tags: vec![],
custom_attributes: Arc::new(HashMap::new()),
enrichments: None,
},
body: ResultBody::Detection(DetectionBody {
matched_selections: vec![],
matched_fields: vec![FieldMatch::new("SourceIp", serde_json::json!(ip))],
event: None,
}),
}
}
fn spec(ip: &str) -> SilenceSpec {
SilenceSpec {
matchers: vec![MatcherSpec {
selector: "match.SourceIp".to_string(),
op: MatchOp::Eq,
value: ip.to_string(),
}],
..Default::default()
}
}
#[test]
fn unbounded_silence_is_active_and_mutes() {
let mut store = SilenceStore::default();
store.add(Silence::build(spec("10.0.0.1"), SilenceOrigin::Api).unwrap());
assert!(store.active_match(&detection("10.0.0.1"), 100).is_some());
assert!(store.active_match(&detection("10.0.0.2"), 100).is_none());
assert_eq!(store.active_count(100), 1);
}
#[test]
fn time_window_pending_active_expired() {
let s = Silence::build(
SilenceSpec {
starts_at: Some("2026-01-01T00:00:00Z".to_string()),
ends_at: Some("2026-01-02T00:00:00Z".to_string()),
..spec("10.0.0.1")
},
SilenceOrigin::Api,
)
.unwrap();
let before = 1_767_139_200; let during = 1_767_283_200; let after = 1_767_312_000; assert_eq!(s.state(before), SilenceState::Pending);
assert_eq!(s.state(during), SilenceState::Active);
assert_eq!(s.state(after), SilenceState::Expired);
}
#[test]
fn gc_drops_expired() {
let mut store = SilenceStore::default();
store.add(
Silence::build(
SilenceSpec {
ends_at: Some("2026-01-01T00:00:00Z".to_string()),
..spec("10.0.0.1")
},
SilenceOrigin::Api,
)
.unwrap(),
);
store.gc(1_700_000_000); assert!(!store.is_empty());
store.gc(1_800_000_000); assert!(store.is_empty());
}
#[test]
fn set_static_replaces_static_keeps_api() {
let mut store = SilenceStore::default();
store.add(Silence::build(spec("10.0.0.9"), SilenceOrigin::Api).unwrap());
store.set_static(vec![
Silence::build(spec("10.0.0.1"), SilenceOrigin::Static).unwrap(),
]);
assert_eq!(store.snapshot(0).len(), 2);
store.set_static(vec![
Silence::build(spec("10.0.0.2"), SilenceOrigin::Static).unwrap(),
]);
let ips: Vec<String> = store
.snapshot(0)
.into_iter()
.flat_map(|v| v.matchers.into_iter().map(|m| m.value))
.collect();
assert!(ips.contains(&"10.0.0.9".to_string()), "api silence kept");
assert!(ips.contains(&"10.0.0.2".to_string()), "new static seeded");
assert!(
!ips.contains(&"10.0.0.1".to_string()),
"old static replaced"
);
}
#[test]
fn remove_by_id() {
let mut store = SilenceStore::default();
let s = Silence::build(spec("10.0.0.1"), SilenceOrigin::Api).unwrap();
let id = s.id().to_string();
store.add(s);
assert!(store.remove(&id));
assert!(!store.remove(&id));
assert!(store.is_empty());
}
#[test]
fn empty_matchers_rejected() {
let err = Silence::build(SilenceSpec::default(), SilenceOrigin::Api).unwrap_err();
assert!(matches!(err, SilenceError::EmptyMatchers));
}
#[test]
fn try_add_enforces_dynamic_cap() {
let mut store = SilenceStore::default();
store.add(Silence::build(spec("10.0.0.0"), SilenceOrigin::Static).unwrap());
assert!(store.try_add(
Silence::build(spec("10.0.0.1"), SilenceOrigin::Api).unwrap(),
2
));
assert!(store.try_add(
Silence::build(spec("10.0.0.2"), SilenceOrigin::Api).unwrap(),
2
));
assert!(!store.try_add(
Silence::build(spec("10.0.0.3"), SilenceOrigin::Api).unwrap(),
2
));
assert_eq!(store.dynamic_count(), 2);
}
}