Skip to main content

elasticctl_api/compose/
coverage.rs

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}