Skip to main content

elasticctl_api/
rules.rs

1//! Typed wrappers for the detection-engine API.
2//!
3//! Functions use stable `rule_id` values, not volatile server-side `id` values.
4
5use crate::codec;
6use crate::model::{ExportSummary, Rule};
7use crate::normalize;
8use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
9use serde_json::{Value, json};
10
11const BASE: &str = "/api/detection_engine/rules";
12
13/// Elasticsearch's `_find` result window. `from + size` cannot exceed this;
14/// 10,001 returns a 400 error.
15///
16/// It bounds `per_page` and the largest `_find` result. Smaller pages cannot
17/// evade the `from + size` limit. A window-sized request read 2,066 rules in
18/// 2.4 seconds; 21 pages of 100 took 8.4–11 seconds.
19const RESULT_WINDOW: u32 = 10_000;
20
21#[derive(Debug, Clone, Default)]
22pub struct RuleFilter {
23    pub enabled: Option<bool>,
24    pub rule_type: Option<String>,
25    pub severity: Option<String>,
26    pub tag: Option<String>,
27    /// Exact display name, filtered server-side. This takes one request;
28    /// walking 2,066 rules took 8.8 seconds.
29    pub name: Option<String>,
30    /// A raw KQL fragment, combined with the structured filters above.
31    pub query: Option<String>,
32}
33
34impl RuleFilter {
35    /// Kibana filters saved objects with KQL over `alert.attributes.*`.
36    pub fn to_kql(&self) -> Option<String> {
37        let mut parts: Vec<String> = Vec::new();
38        if let Some(v) = self.enabled {
39            parts.push(format!("alert.attributes.enabled: {v}"));
40        }
41        if let Some(v) = &self.name {
42            parts.push(format!("alert.attributes.name: \"{}\"", kql_escape(v)));
43        }
44        if let Some(v) = &self.rule_type {
45            parts.push(format!(
46                "alert.attributes.params.type: \"{}\"",
47                kql_escape(v)
48            ));
49        }
50        if let Some(v) = &self.severity {
51            parts.push(format!(
52                "alert.attributes.params.severity: \"{}\"",
53                kql_escape(v)
54            ));
55        }
56        if let Some(v) = &self.tag {
57            parts.push(format!("alert.attributes.tags: \"{}\"", kql_escape(v)));
58        }
59        if let Some(v) = &self.query {
60            parts.push(v.clone());
61        }
62        (!parts.is_empty()).then(|| parts.join(" AND "))
63    }
64}
65
66/// Escape a value for use inside a double-quoted KQL literal.
67///
68/// A quote could otherwise close the literal and make the remaining value KQL,
69/// turning a scoped bulk action into an unscoped action. Escape backslashes
70/// first to avoid double-escaping inserted quote escapes.
71fn kql_escape(value: &str) -> String {
72    value.replace('\\', "\\\\").replace('"', "\\\"")
73}
74
75/// KQL selecting exactly the given stable rule ids.
76fn rule_id_query(rule_ids: &[String]) -> String {
77    rule_ids
78        .iter()
79        .map(|id| format!("alert.attributes.params.ruleId: \"{}\"", kql_escape(id)))
80        .collect::<Vec<_>>()
81        .join(" OR ")
82}
83
84pub async fn find_page(
85    t: &Transport,
86    filter: &RuleFilter,
87    page: u32,
88    per_page: u32,
89) -> Result<(Vec<Rule>, u64)> {
90    let mut path = format!("{BASE}/_find?page={page}&per_page={per_page}");
91    if let Some(kql) = filter.to_kql() {
92        path.push_str(&format!("&filter={}", urlencode(&kql)));
93    }
94
95    let body = t.get(&path).await?;
96    decode_find(&body)
97}
98
99/// Decode a `_find` response into rules and a total. Fixtures use this same
100/// path offline.
101pub fn decode_find(body: &Value) -> Result<(Vec<Rule>, u64)> {
102    let total = body["total"].as_u64().unwrap_or(0);
103    let data = body["data"].as_array().cloned().unwrap_or_default();
104    let rules = data
105        .into_iter()
106        .map(Rule::from_value)
107        .collect::<Result<Vec<_>>>()?;
108    Ok((rules, total))
109}
110
111/// Detection-rule types used to partition a corpus. Each rule has one
112/// `params.type`, so type slices are disjoint and exhaustive. Tags are not:
113/// a rule can have many tags or none. Measured against 2,066 rules, these seven
114/// type slices sum exactly to the corpus.
115const RULE_TYPES: [&str; 7] = [
116    "query",
117    "eql",
118    "esql",
119    "threshold",
120    "threat_match",
121    "machine_learning",
122    "new_terms",
123];
124
125/// Every rule matching the filter.
126///
127/// Read corpora within the result window in one request. For larger corpora,
128/// partition by rule type, then by `enabled` when needed. Verify the partition
129/// by summing slice totals to the corpus total.
130///
131/// Never return a partial corpus. `state diff` would report unread rules as
132/// locally added, and `state pull` would silently omit them.
133pub async fn find_all(t: &Transport, filter: &RuleFilter) -> Result<Vec<Rule>> {
134    let (rules, total) = find_page(t, filter, 1, RESULT_WINDOW).await?;
135
136    if total <= u64::from(RESULT_WINDOW) {
137        if (rules.len() as u64) < total {
138            return Err(short_read(total, rules.len()));
139        }
140        return Ok(rules);
141    }
142
143    // A caller that filtered by type has one requested slice. Only `enabled`
144    // can subdivide it further.
145    let types: Vec<&str> = match &filter.rule_type {
146        Some(t) => vec![t.as_str()],
147        None => RULE_TYPES.to_vec(),
148    };
149
150    let mut collected: Vec<Rule> = Vec::new();
151    let mut summed: u64 = 0;
152
153    for rule_type in types {
154        let mut type_filter = filter.clone();
155        type_filter.rule_type = Some(rule_type.to_string());
156
157        // The opening request already fetched this selected type. Repeating it
158        // would return the same oversized result before the `enabled` split.
159        let slice_total = if filter.rule_type.is_some() {
160            total
161        } else {
162            let (slice_rules, slice_total) = find_page(t, &type_filter, 1, RESULT_WINDOW).await?;
163
164            if slice_total <= u64::from(RESULT_WINDOW) {
165                if (slice_rules.len() as u64) < slice_total {
166                    return Err(short_read(slice_total, slice_rules.len()));
167                }
168                summed += slice_total;
169                collected.extend(slice_rules);
170                continue;
171            }
172            slice_total
173        };
174
175        // A caller that filtered by `enabled` leaves no further partition.
176        // Refuse the slice rather than truncating it to 10,000 rules.
177        if filter.enabled.is_some() {
178            return Err(oversized(slice_total));
179        }
180
181        for enabled in [true, false] {
182            let mut enabled_filter = type_filter.clone();
183            enabled_filter.enabled = Some(enabled);
184            let (enabled_rules, enabled_total) =
185                find_page(t, &enabled_filter, 1, RESULT_WINDOW).await?;
186            if enabled_total > u64::from(RESULT_WINDOW) {
187                return Err(oversized(enabled_total));
188            }
189            if (enabled_rules.len() as u64) < enabled_total {
190                return Err(short_read(enabled_total, enabled_rules.len()));
191            }
192            summed += enabled_total;
193            collected.extend(enabled_rules);
194        }
195    }
196
197    // The sum verifies exhaustiveness. A newer rule type would otherwise read
198    // as zero, disappear from pulls, and appear remote-only in diffs.
199    if summed != total {
200        return Err(Error::new(
201            ErrorKind::Http,
202            format!(
203                "the server counted {total} rules across the corpus but the type slices \
204                 sum to {summed}. Refusing a partial corpus: a rule type added by a newer \
205                 stack version would otherwise read as zero in every pull and diff."
206            ),
207        ));
208    }
209
210    Ok(collected)
211}
212
213/// A server that counts more rules than it serves contradicts itself. A short
214/// list is indistinguishable from rules deleted between count and read.
215fn short_read(counted: u64, returned: usize) -> Error {
216    Error::new(
217        ErrorKind::Http,
218        format!(
219            "the server counted {counted} rules and returned {returned}. Refusing a partial \
220             corpus: a short read is indistinguishable from rules having been deleted."
221        ),
222    )
223}
224
225/// A slice that exceeds the window after both partitions cannot be served.
226/// Returning its first 10,000 rules would make unread rules look remote-only.
227fn oversized(count: u64) -> Error {
228    Error::new(
229        ErrorKind::Unsupported,
230        format!(
231            "{count} rules match, more than the {RESULT_WINDOW} a single search can return \
232             even after partitioning by type and enabled. Narrow the selection with a \
233             filter or a tag."
234        ),
235    )
236}
237
238/// How many `rule_id`s go into one filtered `_find`.
239///
240/// A KQL disjunction grows with the selection, and `--tag` can select
241/// thousands of rules. Chunking keeps each URL below practical limits.
242const ID_CHUNK: usize = 50;
243
244/// The rules carrying exactly these `rule_id`s.
245///
246/// IDs absent from the stack do not return. That is expected for locally added
247/// rules that `state push` creates.
248pub async fn find_by_rule_ids(t: &Transport, rule_ids: &[String]) -> Result<Vec<Rule>> {
249    let mut found = Vec::with_capacity(rule_ids.len());
250    for chunk in rule_ids.chunks(ID_CHUNK) {
251        let filter = RuleFilter {
252            query: Some(rule_id_query(chunk)),
253            ..Default::default()
254        };
255        let (rules, _) = find_page(t, &filter, 1, RESULT_WINDOW).await?;
256        found.extend(rules);
257    }
258    Ok(found)
259}
260
261pub async fn get(t: &Transport, rule_id: &str) -> Result<Rule> {
262    let body = t
263        .get(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
264        .await?;
265    Rule::from_value(body)
266}
267
268pub async fn create(t: &Transport, rule: &Rule) -> Result<Rule> {
269    let mut payload = rule.clone();
270    normalize::strip_volatile(&mut payload);
271    let response = t
272        .post(BASE, Some(&Value::Object(payload.as_map().clone())))
273        .await?;
274    Rule::from_value(response)
275}
276
277pub async fn update(t: &Transport, rule: &Rule) -> Result<Rule> {
278    let mut payload = rule.clone();
279    normalize::strip_volatile(&mut payload);
280    let response = t
281        .put(BASE, &Value::Object(payload.as_map().clone()))
282        .await?;
283    Rule::from_value(response)
284}
285
286pub async fn patch(t: &Transport, rule_id: &str, patch: &Value) -> Result<Rule> {
287    let mut body = patch.as_object().cloned().unwrap_or_default();
288    body.insert("rule_id".into(), json!(rule_id));
289    // PATCH accepts `rule_id` directly, avoiding the volatile server `id`.
290    let response = t.patch(BASE, &Value::Object(body)).await?;
291    Rule::from_value(response)
292}
293
294pub async fn delete(t: &Transport, rule_id: &str) -> Result<Rule> {
295    let body = t
296        .delete(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
297        .await?;
298    Rule::from_value(body)
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum BulkAction {
303    Enable,
304    Disable,
305    Delete,
306}
307
308impl BulkAction {
309    pub fn as_str(&self) -> &'static str {
310        match self {
311            Self::Enable => "enable",
312            Self::Disable => "disable",
313            Self::Delete => "delete",
314        }
315    }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Default)]
319pub struct BulkOutcome {
320    pub succeeded: u64,
321    pub failed: u64,
322    pub skipped: u64,
323    pub total: u64,
324}
325
326pub async fn bulk_by_rule_ids(
327    t: &Transport,
328    action: BulkAction,
329    rule_ids: &[String],
330    dry_run: bool,
331) -> Result<BulkOutcome> {
332    // An empty selection must not become an unscoped query for every rule.
333    if rule_ids.is_empty() {
334        return Ok(BulkOutcome::default());
335    }
336
337    let path = if dry_run {
338        format!("{BASE}/_bulk_action?dry_run=true")
339    } else {
340        format!("{BASE}/_bulk_action")
341    };
342    let body = json!({ "action": action.as_str(), "query": rule_id_query(rule_ids) });
343
344    let response = t.post(&path, Some(&body)).await?;
345    let s = &response["attributes"]["summary"];
346    Ok(BulkOutcome {
347        succeeded: s["succeeded"].as_u64().unwrap_or(0),
348        failed: s["failed"].as_u64().unwrap_or(0),
349        skipped: s["skipped"].as_u64().unwrap_or(0),
350        total: s["total"].as_u64().unwrap_or(0),
351    })
352}
353
354/// Export every rule, or exactly the named ones.
355///
356/// `None` posts no body for a whole-space export. `Some(ids)` posts `objects`
357/// so a subset export transfers only the selected rules.
358pub async fn export(
359    t: &Transport,
360    rule_ids: Option<&[String]>,
361) -> Result<(Vec<Rule>, Option<ExportSummary>)> {
362    let body = rule_ids.map(|ids| {
363        json!({
364            "objects": ids
365                .iter()
366                .map(|id| json!({"rule_id": id}))
367                .collect::<Vec<_>>()
368        })
369    });
370    let text = t
371        .post_text(&format!("{BASE}/_export"), body.as_ref())
372        .await?;
373    codec::decode_ndjson(&text)
374}
375
376/// IDs to check in one `_find`. This keeps large requests below proxy URL
377/// limits while a 40-rule corpus still uses one request.
378const EXISTENCE_CHUNK: usize = 50;
379
380/// Which of these rule ids already exist on the stack.
381///
382/// Check file IDs before upload so existing rules can be skipped. This makes
383/// imports idempotent and lets dry runs distinguish skipped rules.
384pub async fn existing_rule_ids(
385    t: &Transport,
386    rule_ids: &[String],
387) -> Result<std::collections::BTreeSet<String>> {
388    // An empty list must not become an unscoped find for every rule.
389    let mut found = std::collections::BTreeSet::new();
390    if rule_ids.is_empty() {
391        return Ok(found);
392    }
393
394    for chunk in rule_ids.chunks(EXISTENCE_CHUNK) {
395        let path = format!(
396            "{BASE}/_find?page=1&per_page={}&filter={}",
397            chunk.len(),
398            urlencode(&rule_id_query(chunk))
399        );
400        let (rules, _) = decode_find(&t.get(&path).await?)?;
401        for r in rules {
402            if let Ok(id) = r.rule_id() {
403                found.insert(id.to_string());
404            }
405        }
406    }
407
408    Ok(found)
409}
410
411pub async fn import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
412    t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
413        .await
414}
415
416#[derive(Debug, Clone, Default, PartialEq)]
417pub struct PreviewResult {
418    pub preview_id: Option<String>,
419    pub errors: Vec<String>,
420    pub warnings: Vec<String>,
421}
422
423/// Run a rule against historical data without writing alerts.
424///
425/// The API requires `invocationCount` and `timeframeEnd`. The `logs` array has
426/// errors and warnings for each simulated invocation.
427pub async fn preview(
428    t: &Transport,
429    rule: &Rule,
430    invocation_count: u32,
431    timeframe_end: &str,
432) -> Result<PreviewResult> {
433    let mut body = rule.as_map().clone();
434    // The preview API rejects fields that identify a saved rule.
435    for k in [
436        "rule_id",
437        "id",
438        "immutable",
439        "rule_source",
440        "revision",
441        "version",
442    ] {
443        body.remove(k);
444    }
445    body.insert("invocationCount".into(), json!(invocation_count));
446    body.insert("timeframeEnd".into(), json!(timeframe_end));
447
448    let response = t
449        .post(&format!("{BASE}/preview"), Some(&Value::Object(body)))
450        .await?;
451
452    let collect = |key: &str| -> Vec<String> {
453        response["logs"]
454            .as_array()
455            .map(|logs| {
456                logs.iter()
457                    .filter_map(|l| l.get(key)?.as_array())
458                    .flatten()
459                    .filter_map(|v| v.as_str().map(str::to_owned))
460                    .collect()
461            })
462            .unwrap_or_default()
463    };
464
465    Ok(PreviewResult {
466        preview_id: response["previewId"].as_str().map(str::to_owned),
467        errors: collect("errors"),
468        warnings: collect("warnings"),
469    })
470}
471
472/// Where a preview's alerts land. Kibana names the alias per space.
473///
474/// The `rules_preview_hits` fixtures record this alias and its filter field.
475/// A response alone cannot distinguish an empty result from a wrong field.
476pub const PREVIEW_ALERTS_INDEX_PREFIX: &str = ".preview.alerts-security.alerts-";
477
478#[derive(Debug, Clone, Default, PartialEq)]
479pub struct PreviewHits {
480    pub total: u64,
481    /// One entry for each returned document: `{"_id": ..., "_source": {...}}`.
482    /// Contains the matching alert document, not a projected subset.
483    pub sample: Vec<Value>,
484}
485
486/// Read back what a preview matched.
487///
488/// `rules/preview` returns a `previewId` but no hit count, so search the alerts
489/// it wrote. `ignore_unavailable=true` reports no preview index as zero hits,
490/// not a 404 error.
491pub async fn preview_hits(
492    t: &Transport,
493    space: &str,
494    preview_id: &str,
495    sample: usize,
496) -> Result<PreviewHits> {
497    let space = if space.is_empty() { "default" } else { space };
498    let index = urlencode(&format!("{PREVIEW_ALERTS_INDEX_PREFIX}{space}"));
499    let body = json!({
500        "size": sample,
501        // Count all matches, not only the default 10,000.
502        "track_total_hits": true,
503        "query": {"term": {"kibana.alert.rule.uuid": preview_id}},
504        "sort": [{"@timestamp": {"order": "desc"}}]
505    });
506
507    let response = t
508        .post_absolute_es(&format!("/{index}/_search?ignore_unavailable=true"), &body)
509        .await?;
510
511    Ok(decode_preview_hits(&response))
512}
513
514/// Decode a preview-hits response. Fixtures use this same path offline.
515pub fn decode_preview_hits(response: &Value) -> PreviewHits {
516    let total = response["hits"]["total"]["value"].as_u64().unwrap_or(0);
517    let sample = response["hits"]["hits"]
518        .as_array()
519        .map(|hits| {
520            hits.iter()
521                .map(|h| {
522                    json!({
523                        "_id": h.get("_id").cloned().unwrap_or(Value::Null),
524                        "_source": h.get("_source").cloned().unwrap_or(Value::Null),
525                    })
526                })
527                .collect()
528        })
529        .unwrap_or_default();
530
531    PreviewHits { total, sample }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn kql_escape_doubles_a_lone_backslash() {
540        assert_eq!(kql_escape("a\\b"), "a\\\\b");
541    }
542
543    #[test]
544    fn kql_escape_escapes_a_lone_quote() {
545        let mut expected = String::from("a");
546        expected.push('\\');
547        expected.push('"');
548        expected.push('b');
549        assert_eq!(kql_escape("a\"b"), expected);
550    }
551
552    /// A backslash before a quote tests escape order. Escaping quotes first
553    /// would double the backslash inserted for the quote and corrupt the value.
554    #[test]
555    fn kql_escape_orders_backslash_before_quote() {
556        let mut input = String::new();
557        input.push('\\');
558        input.push('"');
559
560        let escaped = kql_escape(&input);
561
562        // Double the backslash, then escape the quote: three backslashes and a
563        // quote.
564        let mut expected = "\\".repeat(3);
565        expected.push('"');
566        assert_eq!(escaped, expected);
567    }
568
569    #[test]
570    fn rule_id_query_escapes_a_quote_in_the_id() {
571        let mut id = String::from("x");
572        id.push('"');
573        id.push('y');
574
575        let q = rule_id_query(&[id]);
576
577        let mut expected = String::from("alert.attributes.params.ruleId: \"x");
578        expected.push('\\');
579        expected.push('"');
580        expected.push_str("y\"");
581        assert_eq!(q, expected);
582        assert!(
583            !q.contains(" OR "),
584            "a single id must produce exactly one clause: {q}"
585        );
586    }
587
588    #[test]
589    fn rule_id_query_neutralizes_a_kql_injection_payload() {
590        let payload = "x\" or alert.attributes.enabled: true or \"";
591        let q = rule_id_query(&[payload.to_string()]);
592        assert!(
593            !q.contains("\" or alert.attributes.enabled: true or \""),
594            "the injected quote must not close the literal: {q}"
595        );
596    }
597
598    #[test]
599    fn to_kql_escapes_a_quote_in_the_tag() {
600        let f = RuleFilter {
601            tag: Some("a\"b".into()),
602            ..Default::default()
603        };
604        let kql = f.to_kql().unwrap();
605
606        let mut expected = String::from("alert.attributes.tags: \"a");
607        expected.push('\\');
608        expected.push('"');
609        expected.push_str("b\"");
610        assert_eq!(kql, expected);
611    }
612}