1use std::collections::{BTreeMap, BTreeSet};
6
7use crate::Rule;
8use crate::rules::{self, RuleFilter};
9use elasticctl_core::{Error, ErrorKind, Result, Transport};
10use serde_json::{Map, Value};
11
12const MITRE: &str = "MITRE ATT&CK";
14
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum CoverageGroup {
18 Technique,
19 Tactic,
20 IndexPattern,
21 Integration,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
27pub struct CoverageRow {
28 pub key: String,
29 pub name: Option<String>,
30 pub tactics: Vec<String>,
31 pub enabled_rules: u64,
32 pub disabled_rules: u64,
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
38pub struct CoverageReport {
39 pub group_by: CoverageGroup,
40 pub rules: u64,
41 pub enabled_rules: u64,
42 pub disabled_rules: u64,
43 pub unmapped_rules: u64,
44 pub rows: Vec<CoverageRow>,
45}
46
47#[derive(Default)]
50struct Seen {
51 name: Option<String>,
52 tactics: BTreeSet<String>,
53}
54
55pub fn coverage_from_rules(rules: &[Rule], group_by: CoverageGroup) -> Result<CoverageReport> {
62 let mut groups: BTreeMap<String, (Seen, u64, u64)> = BTreeMap::new();
63 let mut report = CoverageReport {
64 group_by,
65 rules: rules.len() as u64,
66 enabled_rules: 0,
67 disabled_rules: 0,
68 unmapped_rules: 0,
69 rows: Vec::new(),
70 };
71 for rule in rules {
72 let enabled = rule.as_map().get("enabled") == Some(&Value::Bool(true));
73 if enabled {
74 report.enabled_rules += 1;
75 } else {
76 report.disabled_rules += 1;
77 }
78 let keys = rule_keys(rule.as_map(), group_by)?;
79 if keys.is_empty() {
80 report.unmapped_rules += 1;
81 }
82 for (key, seen) in keys {
83 let (group, enabled_rules, disabled_rules) = groups.entry(key).or_default();
84 keep_smallest(&mut group.name, seen.name.as_deref());
85 group.tactics.extend(seen.tactics);
86 if enabled {
87 *enabled_rules += 1;
88 } else {
89 *disabled_rules += 1;
90 }
91 }
92 }
93 report.rows = groups
94 .into_iter()
95 .map(|(key, (seen, enabled_rules, disabled_rules))| CoverageRow {
96 key,
97 name: seen.name,
98 tactics: seen.tactics.into_iter().collect(),
99 enabled_rules,
100 disabled_rules,
101 })
102 .collect();
103 report.rows.sort_by(|a, b| {
104 b.enabled_rules
105 .cmp(&a.enabled_rules)
106 .then(b.disabled_rules.cmp(&a.disabled_rules))
107 .then_with(|| a.key.cmp(&b.key))
108 });
109 Ok(report)
110}
111
112pub async fn detection_coverage(
115 t: &Transport,
116 filter: &RuleFilter,
117 group_by: CoverageGroup,
118) -> Result<CoverageReport> {
119 let rules = rules::find_window(t, filter).await?;
120 coverage_from_rules(&rules, group_by)
121}
122
123fn rule_keys(rule: &Map<String, Value>, group_by: CoverageGroup) -> Result<BTreeMap<String, Seen>> {
125 let mut keys: BTreeMap<String, Seen> = BTreeMap::new();
126 match group_by {
127 CoverageGroup::Technique | CoverageGroup::Tactic => {
128 for entry in array(rule, "threat")? {
129 let entry = object(entry, "threat")?;
130 if string(entry, "framework")? != Some(MITRE) {
131 continue;
132 }
133 let tactic = field(entry, "tactic")
134 .map(|tactic| object(tactic, "threat.tactic"))
135 .transpose()?;
136 let tactic_id = tactic
137 .map(|tactic| string(tactic, "id"))
138 .transpose()?
139 .flatten();
140 if group_by == CoverageGroup::Tactic {
141 if let (Some(tactic), Some(id)) = (tactic, tactic_id) {
142 let seen = keys.entry(id.to_owned()).or_default();
143 keep_smallest(&mut seen.name, string(tactic, "name")?);
144 }
145 continue;
146 }
147 for technique in array(entry, "technique")? {
149 let technique = object(technique, "threat.technique")?;
150 if let Some(id) = string(technique, "id")? {
151 let seen = keys.entry(id.to_owned()).or_default();
152 keep_smallest(&mut seen.name, string(technique, "name")?);
153 seen.tactics.extend(tactic_id.map(str::to_owned));
154 }
155 }
156 }
157 }
158 CoverageGroup::IndexPattern => {
159 let patterns = array(rule, "index")?;
160 if patterns.is_empty()
161 && let Some(id) = string(rule, "data_view_id")?
162 {
163 keys.entry(format!("data_view:{id}")).or_default();
164 }
165 for pattern in patterns {
166 let pattern = pattern.as_str().ok_or_else(|| wrong_type("index"))?;
167 keys.entry(pattern.to_owned()).or_default();
168 }
169 }
170 CoverageGroup::Integration => {
171 for entry in array(rule, "related_integrations")? {
172 let entry = object(entry, "related_integrations")?;
173 if let Some(package) = string(entry, "package")? {
174 keys.entry(package.to_owned()).or_default();
175 }
176 }
177 }
178 }
179 Ok(keys)
180}
181
182fn keep_smallest(current: &mut Option<String>, name: Option<&str>) {
184 if let Some(name) = name
185 && current.as_deref().is_none_or(|current| name < current)
186 {
187 *current = Some(name.to_owned());
188 }
189}
190
191fn field<'a>(map: &'a Map<String, Value>, key: &str) -> Option<&'a Value> {
193 map.get(key).filter(|value| !value.is_null())
194}
195
196fn array<'a>(map: &'a Map<String, Value>, key: &'static str) -> Result<&'a [Value]> {
197 match field(map, key) {
198 None => Ok(&[]),
199 Some(Value::Array(values)) => Ok(values),
200 Some(_) => Err(wrong_type(key)),
201 }
202}
203
204fn string<'a>(map: &'a Map<String, Value>, key: &'static str) -> Result<Option<&'a str>> {
205 match field(map, key) {
206 None => Ok(None),
207 Some(Value::String(value)) => Ok(Some(value)),
208 Some(_) => Err(wrong_type(key)),
209 }
210}
211
212fn object<'a>(value: &'a Value, what: &'static str) -> Result<&'a Map<String, Value>> {
213 value.as_object().ok_or_else(|| wrong_type(what))
214}
215
216fn wrong_type(field: &str) -> Error {
217 Error::new(
218 ErrorKind::Http,
219 format!("a rule field {field} has an unexpected type"),
220 )
221}