Skip to main content

elasticctl_api/compose/
alert_context.rs

1//! Triage context for one alert: the alert, the rule that fired it, the
2//! rule's exception lists, the cases that contain the alert, and the rule's
3//! alert volume in a window.
4use super::{
5    AlertActivity, ExceptionCoverage, MAX_RELATED_CASES, Part, RuleSnapshot, SkipReason,
6    alert_activity, degrade, exception_coverage, is_fatal, is_path_segment, snapshot_by_id,
7    validate_since,
8};
9use crate::{
10    alerts::AlertHit,
11    alerts_ops,
12    cases::{self, Case},
13};
14use elasticctl_core::{Error, ErrorKind, Result, Transport};
15use serde_json::Value;
16
17/// One case that contains the alert. `detail` is the case read by id.
18#[derive(Debug)]
19pub struct RelatedCase {
20    pub id: String,
21    pub title: Option<String>,
22    pub detail: std::result::Result<Case, Error>,
23}
24
25/// The cases route answer: `total` rows, of which the first five are read.
26#[derive(Debug)]
27pub struct RelatedCases {
28    pub total: usize,
29    pub truncated: bool,
30    pub cases: Vec<RelatedCase>,
31}
32
33#[derive(Debug)]
34pub struct AlertContext {
35    pub alert: AlertHit,
36    pub rule: Part<RuleSnapshot>,
37    pub exceptions: Part<ExceptionCoverage>,
38    pub cases: Part<RelatedCases>,
39    pub activity: Part<AlertActivity>,
40}
41
42/// Static findings, in the order a report lists them.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum AlertFinding {
45    AlertNotOpen,
46    RuleMissing,
47    RuleDisabled,
48    RuleLastExecutionFailed,
49    ExceptionListMissing,
50    HasRelatedCase,
51}
52
53/// A source field read flat first, then as a nested object path.
54fn source_str<'a>(source: &'a Value, key: &str) -> Option<&'a str> {
55    let pointer = format!("/{}", key.replace('.', "/"));
56    match source.get(key) {
57        Some(v) => v.as_str(),
58        None => source.pointer(&pointer).and_then(Value::as_str),
59    }
60}
61
62fn unavailable<T>(part: &Part<T>) -> bool {
63    matches!(part, Part::Unavailable(_))
64}
65
66impl AlertContext {
67    pub fn findings(&self) -> Vec<AlertFinding> {
68        let mut out = Vec::new();
69        if source_str(&self.alert.source, "kibana.alert.workflow_status")
70            .is_some_and(|s| s != "open")
71        {
72            out.push(AlertFinding::AlertNotOpen);
73        }
74        if matches!(&self.rule, Part::Unavailable(e) if e.kind == ErrorKind::NotFound) {
75            out.push(AlertFinding::RuleMissing);
76        }
77        if let Part::Ok(snapshot) = &self.rule {
78            if snapshot
79                .rule
80                .as_map()
81                .get("enabled")
82                .and_then(Value::as_bool)
83                == Some(false)
84            {
85                out.push(AlertFinding::RuleDisabled);
86            }
87            if snapshot
88                .execution
89                .as_ref()
90                .is_some_and(|e| e.status == "failed")
91            {
92                out.push(AlertFinding::RuleLastExecutionFailed);
93            }
94        }
95        if let Part::Ok(coverage) = &self.exceptions
96            && coverage
97                .lists
98                .iter()
99                .any(|l| matches!(&l.facts, Err(e) if e.kind == ErrorKind::NotFound))
100        {
101            out.push(AlertFinding::ExceptionListMissing);
102        }
103        if matches!(&self.cases, Part::Ok(c) if c.total > 0) {
104            out.push(AlertFinding::HasRelatedCase);
105        }
106        out
107    }
108
109    /// True when any part or row is unavailable.
110    pub fn partial(&self) -> bool {
111        let list_rows =
112            matches!(&self.exceptions, Part::Ok(c) if c.lists.iter().any(|l| l.facts.is_err()));
113        let case_rows =
114            matches!(&self.cases, Part::Ok(c) if c.cases.iter().any(|r| r.detail.is_err()));
115        unavailable(&self.rule)
116            || unavailable(&self.exceptions)
117            || unavailable(&self.cases)
118            || unavailable(&self.activity)
119            || list_rows
120            || case_rows
121    }
122}
123
124async fn related_cases(t: &Transport, alert_id: &str) -> Result<RelatedCases> {
125    let refs = cases::by_alert(t, alert_id).await?;
126    let total = refs.len();
127    let mut rows = Vec::new();
128    for r in refs.into_iter().take(MAX_RELATED_CASES) {
129        let detail = cases::get(t, &r.id).await;
130        if let Err(e) = &detail
131            && is_fatal(e.kind)
132        {
133            return Err(detail.unwrap_err());
134        }
135        rows.push(RelatedCase {
136            id: r.id,
137            title: r.title,
138            detail,
139        });
140    }
141    Ok(RelatedCases {
142        total,
143        truncated: total > MAX_RELATED_CASES,
144        cases: rows,
145    })
146}
147
148fn validate_rule_context(snapshot: RuleSnapshot) -> Result<RuleSnapshot> {
149    let invalid = || Error::new(ErrorKind::Http, "invalid rule context field");
150    fn optional<T: serde::de::DeserializeOwned>(
151        map: &serde_json::Map<String, Value>,
152        key: &str,
153    ) -> Result<()> {
154        if let Some(value) = map.get(key).filter(|v| !v.is_null()) {
155            serde_json::from_value::<T>(value.clone())
156                .map_err(|_| Error::new(ErrorKind::Http, "invalid rule context field"))?;
157        }
158        Ok(())
159    }
160    let map = snapshot.rule.as_map();
161    if !map.get("rule_id").is_some_and(Value::is_string) {
162        return Err(invalid());
163    }
164    for key in [
165        "name",
166        "type",
167        "severity",
168        "description",
169        "language",
170        "query",
171        "from",
172        "interval",
173    ] {
174        optional::<String>(map, key)?;
175    }
176    optional::<bool>(map, "enabled")?;
177    optional::<serde_json::Number>(map, "risk_score")?;
178    for key in ["tags", "index"] {
179        optional::<Vec<String>>(map, key)?;
180    }
181    if let Some(value) = map.get("exceptions_list").filter(|v| !v.is_null()) {
182        for entry in value.as_array().ok_or_else(invalid)? {
183            let entry = entry.as_object().ok_or_else(invalid)?;
184            for key in ["list_id", "namespace_type", "type"] {
185                optional::<String>(entry, key)?;
186            }
187        }
188    }
189    Ok(snapshot)
190}
191
192/// Read the alert, then its rule, the rule's exception coverage, the cases
193/// that contain the alert, and the rule's alert activity, one request at a
194/// time. The alert read is primary; every later read is a part.
195pub async fn alert_context(t: &Transport, alert_id: &str, since: &str) -> Result<AlertContext> {
196    validate_since(since)?;
197    if !is_path_segment(alert_id) {
198        return Err(Error::new(ErrorKind::Error, "invalid alert id"));
199    }
200    let alert = alerts_ops::get_one(t, alert_id).await?;
201    let rule_id = source_str(&alert.source, "kibana.alert.rule.rule_id").map(str::to_owned);
202    let rule = match &rule_id {
203        Some(id) => degrade(snapshot_by_id(t, id).await.and_then(validate_rule_context))?,
204        None => Part::Skipped(SkipReason::NotApplicable),
205    };
206    let exceptions = match &rule {
207        Part::Ok(snapshot) => degrade(exception_coverage(t, &snapshot.rule).await)?,
208        _ => Part::Skipped(SkipReason::DependencyUnavailable),
209    };
210    let cases = degrade(related_cases(t, alert_id).await)?;
211    let activity = match (&rule, &rule_id) {
212        (Part::Ok(_), Some(id)) => degrade(alert_activity(t, id, since, 0).await)?,
213        _ => Part::Skipped(SkipReason::DependencyUnavailable),
214    };
215    Ok(AlertContext {
216        alert,
217        rule,
218        exceptions,
219        cases,
220        activity,
221    })
222}