Skip to main content

elasticctl_api/
rules.rs

1mod preview_execution;
2pub use preview_execution::{
3    PREVIEW_ALERTS_INDEX_PREFIX, PreviewHits, PreviewResult, decode_preview_hits,
4    decode_preview_hits_checked, preview, preview_hits,
5};
6mod window;
7pub use window::{WINDOW_MAX_RULES, WINDOW_PAGE, find_window};
8
9use crate::codec::{self, Bundle};
10use crate::model::Rule;
11use crate::normalize;
12use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15
16const BASE: &str = "/api/detection_engine/rules";
17
18const RESULT_WINDOW: u32 = 10_000;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum RuleSource {
23    Custom,
24    Customized,
25    Prebuilt,
26    #[default]
27    All,
28}
29
30impl RuleSource {
31    pub fn clause(&self) -> Option<&'static str> {
32        match self {
33            RuleSource::Custom => Some("alert.attributes.params.immutable: false"),
34            RuleSource::Prebuilt => Some("alert.attributes.params.immutable: true"),
35            RuleSource::Customized => Some("alert.attributes.params.ruleSource.isCustomized: true"),
36            RuleSource::All => None,
37        }
38    }
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct RuleFilter {
43    pub source: RuleSource,
44    pub enabled: Option<bool>,
45    pub rule_type: Option<String>,
46    pub severity: Option<String>,
47    pub tag: Option<String>,
48    pub name: Option<String>,
49    pub query: Option<String>,
50    pub search: Option<String>,
51    pub technique: Option<String>,
52    pub tactic: Option<String>,
53}
54
55impl RuleFilter {
56    pub fn to_kql(&self) -> Option<String> {
57        let mut parts: Vec<String> = Vec::new();
58        if let Some(clause) = self.source.clause() {
59            parts.push(clause.to_string());
60        }
61        if let Some(v) = self.enabled {
62            parts.push(format!("alert.attributes.enabled: {v}"));
63        }
64        if let Some(v) = &self.name {
65            parts.push(format!("alert.attributes.name: \"{}\"", kql_escape(v)));
66        }
67        if let Some(v) = &self.rule_type {
68            parts.push(format!(
69                "alert.attributes.params.type: \"{}\"",
70                kql_escape(v)
71            ));
72        }
73        if let Some(v) = &self.severity {
74            parts.push(format!(
75                "alert.attributes.params.severity: \"{}\"",
76                kql_escape(v)
77            ));
78        }
79        if let Some(v) = &self.tag {
80            parts.push(format!("alert.attributes.tags: \"{}\"", kql_escape(v)));
81        }
82        if let Some(v) = &self.search {
83            parts.push(format!(
84                "(alert.attributes.name: \"*{}*\" OR alert.attributes.tags: \"{}\")",
85                kql_escape_wildcard(v),
86                kql_escape(v)
87            ));
88        }
89        if let Some(v) = &self.technique {
90            let sub = if v.contains('.') { ".subtechnique" } else { "" };
91            parts.push(format!(
92                "alert.attributes.params.threat.technique{sub}.id: \"{}\"",
93                kql_escape(v)
94            ));
95        }
96        if let Some(v) = &self.tactic {
97            parts.push(format!(
98                "alert.attributes.params.threat.tactic.id: \"{}\"",
99                kql_escape(v)
100            ));
101        }
102        if let Some(v) = &self.query {
103            parts.push(v.clone());
104        }
105        (!parts.is_empty()).then(|| parts.join(" AND "))
106    }
107
108    pub fn validate_threat(&self) -> Result<()> {
109        let technique = |v: &str| has_shape(v, "T0000") || has_shape(v, "T0000.000");
110        let tactic = |v: &str| has_shape(v, "TA0000");
111        let message = if !self.technique.as_deref().is_none_or(technique) {
112            "technique must be T and four digits, optionally a period and three digits"
113        } else if !self.tactic.as_deref().is_none_or(tactic) {
114            "tactic must be TA and four digits"
115        } else {
116            return Ok(());
117        };
118        Err(Error::new(ErrorKind::Error, message))
119    }
120}
121
122fn has_shape(value: &str, shape: &str) -> bool {
123    let same = |(v, s): (u8, u8)| v == s || (s == b'0' && v.is_ascii_digit());
124    value.len() == shape.len() && value.bytes().zip(shape.bytes()).all(same)
125}
126
127pub(crate) fn kql_escape(value: &str) -> String {
128    value.replace('\\', "\\\\").replace('"', "\\\"")
129}
130
131pub(crate) fn kql_escape_wildcard(value: &str) -> String {
132    kql_escape(value).replace('*', "\\*").replace('?', "\\?")
133}
134
135fn rule_id_query(rule_ids: &[String]) -> String {
136    rule_ids
137        .iter()
138        .map(|id| format!("alert.attributes.params.ruleId: \"{}\"", kql_escape(id)))
139        .collect::<Vec<_>>()
140        .join(" OR ")
141}
142
143pub async fn find_page(
144    t: &Transport,
145    filter: &RuleFilter,
146    page: u32,
147    per_page: u32,
148) -> Result<(Vec<Rule>, u64)> {
149    find(t, filter, page, per_page, "").await
150}
151
152pub async fn find_first_by_name(
153    t: &Transport,
154    filter: &RuleFilter,
155    per_page: u32,
156) -> Result<(Vec<Rule>, u64)> {
157    let (rules, total) = find(t, filter, 1, per_page, "&sort_field=name&sort_order=asc").await?;
158    if (rules.len() as u64) < total.min(u64::from(per_page)) {
159        return Err(short_read(total, rules.len()));
160    }
161    Ok((rules, total))
162}
163
164async fn find(
165    t: &Transport,
166    filter: &RuleFilter,
167    page: u32,
168    per_page: u32,
169    sort: &str,
170) -> Result<(Vec<Rule>, u64)> {
171    filter.validate_threat()?;
172    if filter.source != RuleSource::All {
173        t.require_feature(Feature::RuleSourceScoping).await?;
174    }
175    let mut path = format!("{BASE}/_find?page={page}&per_page={per_page}{sort}");
176    if let Some(kql) = filter.to_kql() {
177        path.push_str(&format!("&filter={}", urlencode(&kql)));
178    }
179
180    let body = t.get(&path).await?;
181    decode_find(&body)
182}
183
184pub fn decode_find(body: &Value) -> Result<(Vec<Rule>, u64)> {
185    let map = body
186        .as_object()
187        .ok_or_else(|| find_error("response", "must be a JSON object"))?;
188    let data = map
189        .get("data")
190        .and_then(Value::as_array)
191        .ok_or_else(|| find_error("data", "must be an array"))?;
192    let total = required_u64(map, "total")?;
193    let page = required_u64(map, "page")?;
194    if page == 0 {
195        return Err(find_error("page", "must be greater than zero"));
196    }
197    let per_page = required_u64(map, "perPage")?;
198    if per_page == 0 {
199        return Err(find_error("perPage", "must be greater than zero"));
200    }
201
202    let data_len = data.len() as u64;
203    if data_len > total {
204        return Err(find_error(
205            "data.len() > total",
206            format!("{data_len} returned records exceed total {total}"),
207        ));
208    }
209    if data_len > per_page {
210        return Err(find_error(
211            "data.len() > perPage",
212            format!("{data_len} returned records exceed perPage {per_page}"),
213        ));
214    }
215
216    let rules = data
217        .iter()
218        .cloned()
219        .map(Rule::from_value)
220        .collect::<Result<Vec<_>>>()?;
221    Ok((rules, total))
222}
223
224fn find_error(field: &str, detail: impl std::fmt::Display) -> Error {
225    Error::new(
226        ErrorKind::Http,
227        format!("decoding rule _find response field {field}: {detail}"),
228    )
229}
230
231fn required_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
232    map.get(field)
233        .and_then(Value::as_u64)
234        .ok_or_else(|| find_error(field, "must be an unsigned integer"))
235}
236
237pub(crate) struct SourceTotals {
238    pub custom: u64,
239    pub prebuilt: u64,
240    pub all: u64,
241}
242
243impl SourceTotals {
244    fn is_exhaustive(&self) -> bool {
245        self.custom.checked_add(self.prebuilt) == Some(self.all)
246    }
247}
248
249pub(crate) async fn verify_source_partition(t: &Transport) -> Result<SourceTotals> {
250    let (_, custom) = find_page(
251        t,
252        &RuleFilter {
253            source: RuleSource::Custom,
254            ..Default::default()
255        },
256        1,
257        1,
258    )
259    .await?;
260    let (_, prebuilt) = find_page(
261        t,
262        &RuleFilter {
263            source: RuleSource::Prebuilt,
264            ..Default::default()
265        },
266        1,
267        1,
268    )
269    .await?;
270    let (_, all) = find_page(t, &RuleFilter::default(), 1, 1).await?;
271
272    let totals = SourceTotals {
273        custom,
274        prebuilt,
275        all,
276    };
277    if !totals.is_exhaustive() {
278        return Err(Error::new(
279            ErrorKind::Unsupported,
280            format!(
281                "the immutable source partition is not exhaustive: custom={}, \
282                 prebuilt={}, all={}. The field may be absent on this stack; \
283                 re-run with --source all to read the corpus.",
284                totals.custom, totals.prebuilt, totals.all
285            ),
286        ));
287    }
288
289    Ok(totals)
290}
291
292const RULE_TYPES: [&str; 7] = [
293    "query",
294    "eql",
295    "esql",
296    "threshold",
297    "threat_match",
298    "machine_learning",
299    "new_terms",
300];
301
302pub async fn find_all(t: &Transport, filter: &RuleFilter) -> Result<Vec<Rule>> {
303    let (rules, total) = find_page(t, filter, 1, RESULT_WINDOW).await?;
304
305    if total <= u64::from(RESULT_WINDOW) {
306        if (rules.len() as u64) < total {
307            return Err(short_read(total, rules.len()));
308        }
309        return Ok(rules);
310    }
311
312    let types: Vec<&str> = match &filter.rule_type {
313        Some(t) => vec![t.as_str()],
314        None => RULE_TYPES.to_vec(),
315    };
316
317    let mut collected: Vec<Rule> = Vec::new();
318    let mut summed: u64 = 0;
319
320    for rule_type in types {
321        let mut type_filter = filter.clone();
322        type_filter.rule_type = Some(rule_type.to_string());
323
324        let slice_total = if filter.rule_type.is_some() {
325            total
326        } else {
327            let (slice_rules, slice_total) = find_page(t, &type_filter, 1, RESULT_WINDOW).await?;
328
329            if slice_total <= u64::from(RESULT_WINDOW) {
330                if (slice_rules.len() as u64) < slice_total {
331                    return Err(short_read(slice_total, slice_rules.len()));
332                }
333                summed += slice_total;
334                collected.extend(slice_rules);
335                continue;
336            }
337            slice_total
338        };
339
340        if filter.enabled.is_some() {
341            return Err(oversized(slice_total));
342        }
343
344        for enabled in [true, false] {
345            let mut enabled_filter = type_filter.clone();
346            enabled_filter.enabled = Some(enabled);
347            let (enabled_rules, enabled_total) =
348                find_page(t, &enabled_filter, 1, RESULT_WINDOW).await?;
349            if enabled_total > u64::from(RESULT_WINDOW) {
350                return Err(oversized(enabled_total));
351            }
352            if (enabled_rules.len() as u64) < enabled_total {
353                return Err(short_read(enabled_total, enabled_rules.len()));
354            }
355            summed += enabled_total;
356            collected.extend(enabled_rules);
357        }
358    }
359
360    if summed != total {
361        return Err(Error::new(
362            ErrorKind::Http,
363            format!(
364                "the server counted {total} rules across the corpus but the type slices \
365                 sum to {summed}. Refusing a partial corpus: a rule type added by a newer \
366                 stack version would otherwise read as zero in every pull and diff."
367            ),
368        ));
369    }
370
371    Ok(collected)
372}
373
374fn short_read(counted: u64, returned: usize) -> Error {
375    Error::new(
376        ErrorKind::Http,
377        format!(
378            "the server counted {counted} rules and returned {returned}. Refusing a partial \
379             corpus: a short read is indistinguishable from rules having been deleted."
380        ),
381    )
382}
383
384fn oversized(count: u64) -> Error {
385    Error::new(
386        ErrorKind::Unsupported,
387        format!(
388            "{count} rules match, more than the {RESULT_WINDOW} a single search can return \
389             even after partitioning by type and enabled. Narrow the selection with a \
390             filter or a tag."
391        ),
392    )
393}
394
395const ID_CHUNK: usize = 50;
396
397pub async fn find_by_rule_ids(t: &Transport, rule_ids: &[String]) -> Result<Vec<Rule>> {
398    let mut found = Vec::with_capacity(rule_ids.len());
399    for chunk in rule_ids.chunks(ID_CHUNK) {
400        let filter = RuleFilter {
401            query: Some(rule_id_query(chunk)),
402            ..Default::default()
403        };
404        let (rules, _) = find_page(t, &filter, 1, RESULT_WINDOW).await?;
405        found.extend(rules);
406    }
407    Ok(found)
408}
409
410pub async fn get(t: &Transport, rule_id: &str) -> Result<Rule> {
411    let body = t
412        .get(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
413        .await?;
414    Rule::from_value(body)
415}
416
417pub async fn create(t: &Transport, rule: &Rule) -> Result<Rule> {
418    let mut payload = rule.clone();
419    normalize::strip_volatile(&mut payload);
420    let response = t
421        .post(BASE, Some(&Value::Object(payload.as_map().clone())))
422        .await?;
423    Rule::from_value(response)
424}
425
426pub async fn update(t: &Transport, rule: &Rule) -> Result<Rule> {
427    let mut payload = rule.clone();
428    normalize::strip_volatile(&mut payload);
429    let response = t
430        .put(BASE, &Value::Object(payload.as_map().clone()))
431        .await?;
432    Rule::from_value(response)
433}
434
435pub async fn patch(t: &Transport, rule_id: &str, patch: &Value) -> Result<Rule> {
436    let mut body = patch.as_object().cloned().unwrap_or_default();
437    body.insert("rule_id".into(), json!(rule_id));
438    let response = t.patch(BASE, &Value::Object(body)).await?;
439    Rule::from_value(response)
440}
441
442pub async fn delete(t: &Transport, rule_id: &str) -> Result<Rule> {
443    let body = t
444        .delete(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
445        .await?;
446    Rule::from_value(body)
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum BulkAction {
451    Enable,
452    Disable,
453    Delete,
454}
455
456impl BulkAction {
457    pub fn as_str(&self) -> &'static str {
458        match self {
459            Self::Enable => "enable",
460            Self::Disable => "disable",
461            Self::Delete => "delete",
462        }
463    }
464}
465
466#[derive(Debug, Clone, PartialEq, Eq, Default)]
467pub struct BulkOutcome {
468    pub succeeded: u64,
469    pub failed: u64,
470    pub skipped: u64,
471    pub total: u64,
472}
473
474pub fn decode_bulk_outcome(body: &Value) -> Result<BulkOutcome> {
475    let map = body
476        .as_object()
477        .ok_or_else(|| bulk_error("response", "must be a JSON object"))?;
478    let summary = map
479        .get("attributes")
480        .and_then(|attributes| attributes.get("summary"))
481        .and_then(Value::as_object)
482        .ok_or_else(|| bulk_error("attributes.summary", "must be an object"))?;
483    let succeeded = bulk_u64(summary, "succeeded")?;
484    let failed = bulk_u64(summary, "failed")?;
485    let skipped = bulk_u64(summary, "skipped")?;
486    let total = bulk_u64(summary, "total")?;
487    let applied = succeeded
488        .checked_add(failed)
489        .and_then(|count| count.checked_add(skipped))
490        .ok_or_else(|| bulk_error("total", "counter sum overflows u64"))?;
491    if applied != total {
492        return Err(bulk_error(
493            "total",
494            format!(
495                "{total} does not equal succeeded {succeeded} + failed {failed} + skipped {skipped}"
496            ),
497        ));
498    }
499    Ok(BulkOutcome {
500        succeeded,
501        failed,
502        skipped,
503        total,
504    })
505}
506
507fn bulk_error(field: &str, detail: impl std::fmt::Display) -> Error {
508    Error::new(
509        ErrorKind::Http,
510        format!("decoding rule _bulk_action response field {field}: {detail}"),
511    )
512}
513
514fn bulk_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
515    map.get(field)
516        .and_then(Value::as_u64)
517        .ok_or_else(|| bulk_error(field, "must be an unsigned integer"))
518}
519
520pub async fn bulk_by_rule_ids(
521    t: &Transport,
522    action: BulkAction,
523    rule_ids: &[String],
524    dry_run: bool,
525) -> Result<BulkOutcome> {
526    if rule_ids.is_empty() {
527        return Ok(BulkOutcome::default());
528    }
529
530    let path = if dry_run {
531        format!("{BASE}/_bulk_action?dry_run=true")
532    } else {
533        format!("{BASE}/_bulk_action")
534    };
535    let body = json!({ "action": action.as_str(), "query": rule_id_query(rule_ids) });
536
537    let response = t.post(&path, Some(&body)).await?;
538    decode_bulk_outcome(&response)
539}
540
541pub async fn export(t: &Transport, rule_ids: Option<&[String]>) -> Result<Bundle> {
542    let body = rule_ids.map(|ids| {
543        json!({
544            "objects": ids
545                .iter()
546                .map(|id| json!({"rule_id": id}))
547                .collect::<Vec<_>>()
548        })
549    });
550    let text = t
551        .post_text(&format!("{BASE}/_export"), body.as_ref())
552        .await?;
553    codec::decode_bundle(&text)
554}
555
556const EXISTENCE_CHUNK: usize = 50;
557
558pub async fn existing_rule_ids(
559    t: &Transport,
560    rule_ids: &[String],
561) -> Result<std::collections::BTreeSet<String>> {
562    let mut found = std::collections::BTreeSet::new();
563    if rule_ids.is_empty() {
564        return Ok(found);
565    }
566
567    for chunk in rule_ids.chunks(EXISTENCE_CHUNK) {
568        let path = format!(
569            "{BASE}/_find?page=1&per_page={}&filter={}",
570            chunk.len(),
571            urlencode(&rule_id_query(chunk))
572        );
573        let (rules, _) = decode_find(&t.get(&path).await?)?;
574        for r in rules {
575            if let Ok(id) = r.rule_id() {
576                found.insert(id.to_string());
577            }
578        }
579    }
580
581    Ok(found)
582}
583
584pub async fn import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
585    t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
586        .await
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn each_source_maps_to_its_measured_filter() {
595        assert_eq!(
596            RuleSource::Custom.clause(),
597            Some("alert.attributes.params.immutable: false")
598        );
599        assert_eq!(
600            RuleSource::Customized.clause(),
601            Some("alert.attributes.params.ruleSource.isCustomized: true")
602        );
603        assert_eq!(RuleSource::All.clause(), None, "all adds no clause");
604    }
605
606    #[test]
607    fn a_source_clause_combines_with_other_filters() {
608        let f = RuleFilter {
609            source: RuleSource::Custom,
610            tag: Some("prod".into()),
611            ..Default::default()
612        };
613        let kql = f.to_kql().unwrap();
614        assert!(kql.contains("immutable: false"), "{kql}");
615        assert!(kql.contains("prod"), "{kql}");
616        assert!(
617            kql.contains(" AND "),
618            "clauses combine, they do not replace: {kql}"
619        );
620    }
621
622    #[test]
623    fn kql_escape_doubles_a_lone_backslash() {
624        assert_eq!(kql_escape("a\\b"), "a\\\\b");
625    }
626
627    #[test]
628    fn kql_escape_escapes_a_lone_quote() {
629        let mut expected = String::from("a");
630        expected.push('\\');
631        expected.push('"');
632        expected.push('b');
633        assert_eq!(kql_escape("a\"b"), expected);
634    }
635
636    #[test]
637    fn kql_escape_wildcard_escapes_kql_wildcards_after_kql_escape() {
638        assert_eq!(kql_escape_wildcard("*"), "\\*");
639        assert_eq!(kql_escape_wildcard("?"), "\\?");
640        assert_eq!(kql_escape_wildcard("a\\b\"c"), kql_escape("a\\b\"c"));
641    }
642
643    #[test]
644    fn kql_escape_orders_backslash_before_quote() {
645        let mut input = String::new();
646        input.push('\\');
647        input.push('"');
648
649        let escaped = kql_escape(&input);
650
651        let mut expected = "\\".repeat(3);
652        expected.push('"');
653        assert_eq!(escaped, expected);
654    }
655
656    #[test]
657    fn rule_id_query_escapes_a_quote_in_the_id() {
658        let mut id = String::from("x");
659        id.push('"');
660        id.push('y');
661
662        let q = rule_id_query(&[id]);
663
664        let mut expected = String::from("alert.attributes.params.ruleId: \"x");
665        expected.push('\\');
666        expected.push('"');
667        expected.push_str("y\"");
668        assert_eq!(q, expected);
669        assert!(
670            !q.contains(" OR "),
671            "a single id must produce exactly one clause: {q}"
672        );
673    }
674
675    #[test]
676    fn rule_id_query_neutralizes_a_kql_injection_payload() {
677        let payload = "x\" or alert.attributes.enabled: true or \"";
678        let q = rule_id_query(&[payload.to_string()]);
679        assert!(
680            !q.contains("\" or alert.attributes.enabled: true or \""),
681            "the injected quote must not close the literal: {q}"
682        );
683    }
684
685    #[test]
686    fn to_kql_escapes_a_quote_in_the_tag() {
687        let f = RuleFilter {
688            tag: Some("a\"b".into()),
689            ..Default::default()
690        };
691        let kql = f.to_kql().unwrap();
692
693        let mut expected = String::from("alert.attributes.tags: \"a");
694        expected.push('\\');
695        expected.push('"');
696        expected.push_str("b\"");
697        assert_eq!(kql, expected);
698    }
699}