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}