1use 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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum AlertFinding {
45 AlertNotOpen,
46 RuleMissing,
47 RuleDisabled,
48 RuleLastExecutionFailed,
49 ExceptionListMissing,
50 HasRelatedCase,
51}
52
53fn 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 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
192pub 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}