Skip to main content

elasticctl_api/compose/
coverage.rs

1//! Detection coverage: the rules matching a filter, grouped by MITRE ATT&CK
2//! technique, tactic, index pattern, or integration package, with enabled and
3//! disabled counts per group.
4
5use 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
12/// The only threat framework whose entries are read.
13const MITRE: &str = "MITRE ATT&CK";
14
15/// What a coverage row counts rules by.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum CoverageGroup {
18    Technique,
19    Tactic,
20    IndexPattern,
21    Integration,
22}
23
24/// One group. `name` is set only for techniques and tactics, and `tactics`
25/// is filled only for techniques.
26#[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/// Every group of the corpus read, sorted by `enabled_rules` descending, then
36/// `disabled_rules` descending, then `key` ascending in byte order.
37#[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/// What one rule contributes to one key: the smallest name and the tactics
48/// it was seen with.
49#[derive(Default)]
50struct Seen {
51    name: Option<String>,
52    tactics: BTreeSet<String>,
53}
54
55/// Group the rules by `group_by`.
56///
57/// A rule counts as enabled only when `enabled` is `true`. Each rule adds at
58/// most one to a group, and a rule with no key adds one to `unmapped_rules`.
59/// A missing or null field yields no key; a present field of the wrong type is
60/// kind `http`. Only the fields the grouping reads are checked.
61pub 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
112/// Read every rule matching `filter` with `rules::find_window` and group it.
113/// The corpus limit and the read conflict reach the caller unchanged.
114pub 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
123/// The distinct keys of one rule for the grouping.
124fn 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                // Subtechniques sit under their parent and are not read.
148                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
182/// Keep the smaller of the current name and `name` in byte order.
183fn 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
191/// A present, non-null field.
192fn 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}