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