Skip to main content

elasticctl_api/compose/
alert_context.rs

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