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
5mod preview_execution;
6pub use preview_execution::{
7    PREVIEW_ALERTS_INDEX_PREFIX, PreviewHits, PreviewResult, decode_preview_hits,
8    decode_preview_hits_checked, preview, preview_hits,
9};
10
11use crate::codec::{self, Bundle};
12use crate::model::Rule;
13use crate::normalize;
14use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17
18const BASE: &str = "/api/detection_engine/rules";
19
20/// Elasticsearch's `_find` result window. `from + size` cannot exceed this;
21/// 10,001 returns a 400 error.
22///
23/// It bounds `per_page` and the largest `_find` result. Smaller pages cannot
24/// evade the `from + size` limit. A window-sized request read 2,066 rules in
25/// 2.4 seconds; 21 pages of 100 took 8.4–11 seconds.
26const RESULT_WINDOW: u32 = 10_000;
27
28/// Which rules an operation acts on, grouped by source.
29///
30/// The server-side split filters on `alert.attributes.params.immutable`. On
31/// Serverless 9.6.0, it agreed exactly with `params.ruleSource.type` (2,066
32/// prebuilt / 0 custom). Its presence on older versions is unmeasured.
33/// `customized` narrows the prebuilt set to rules edited on the stack.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum RuleSource {
37    Custom,
38    Customized,
39    Prebuilt,
40    #[default]
41    All,
42}
43
44impl RuleSource {
45    /// The measured server-side filter clause. `None` means no clause: the
46    /// whole corpus. Spec 5.5.
47    pub fn clause(&self) -> Option<&'static str> {
48        match self {
49            RuleSource::Custom => Some("alert.attributes.params.immutable: false"),
50            RuleSource::Prebuilt => Some("alert.attributes.params.immutable: true"),
51            RuleSource::Customized => Some("alert.attributes.params.ruleSource.isCustomized: true"),
52            RuleSource::All => None,
53        }
54    }
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct RuleFilter {
59    pub source: RuleSource,
60    pub enabled: Option<bool>,
61    pub rule_type: Option<String>,
62    pub severity: Option<String>,
63    pub tag: Option<String>,
64    /// Exact display name, filtered server-side. This takes one request;
65    /// walking 2,066 rules took 8.8 seconds.
66    pub name: Option<String>,
67    /// A raw KQL fragment, combined with the structured filters above.
68    pub query: Option<String>,
69    /// Friendly name-substring plus exact-tag search (spec 4.7). Distinct from
70    /// `name` (exact) and `query` (raw KQL).
71    pub search: Option<String>,
72}
73
74impl RuleFilter {
75    /// Kibana filters saved objects with KQL over `alert.attributes.*`.
76    pub fn to_kql(&self) -> Option<String> {
77        let mut parts: Vec<String> = Vec::new();
78        if let Some(clause) = self.source.clause() {
79            parts.push(clause.to_string());
80        }
81        if let Some(v) = self.enabled {
82            parts.push(format!("alert.attributes.enabled: {v}"));
83        }
84        if let Some(v) = &self.name {
85            parts.push(format!("alert.attributes.name: \"{}\"", kql_escape(v)));
86        }
87        if let Some(v) = &self.rule_type {
88            parts.push(format!(
89                "alert.attributes.params.type: \"{}\"",
90                kql_escape(v)
91            ));
92        }
93        if let Some(v) = &self.severity {
94            parts.push(format!(
95                "alert.attributes.params.severity: \"{}\"",
96                kql_escape(v)
97            ));
98        }
99        if let Some(v) = &self.tag {
100            parts.push(format!("alert.attributes.tags: \"{}\"", kql_escape(v)));
101        }
102        if let Some(v) = &self.search {
103            parts.push(format!(
104                "(alert.attributes.name: \"*{}*\" OR alert.attributes.tags: \"{}\")",
105                kql_escape_wildcard(v),
106                kql_escape(v)
107            ));
108        }
109        if let Some(v) = &self.query {
110            parts.push(v.clone());
111        }
112        (!parts.is_empty()).then(|| parts.join(" AND "))
113    }
114}
115
116/// Escape a value for use inside a double-quoted KQL literal.
117///
118/// A quote could otherwise close the literal and make the remaining value KQL,
119/// turning a scoped bulk action into an unscoped action. Escape backslashes
120/// first to avoid double-escaping inserted quote escapes. Shared with the
121/// exceptions vertical: the two filter builders must not diverge, because a
122/// divergence silently matches the wrong objects.
123pub(crate) fn kql_escape(value: &str) -> String {
124    value.replace('\\', "\\\\").replace('"', "\\\"")
125}
126
127/// Escape a value for a wildcard-wrapped KQL substring term (`*<value>*`).
128///
129/// Builds on `kql_escape`, then escapes the KQL wildcards `*` and `?` so a
130/// search text cannot inject its own wildcards and widen the match. Shared
131/// with the exceptions vertical: the two filter builders must not diverge,
132/// because a divergence silently matches the wrong objects.
133pub(crate) fn kql_escape_wildcard(value: &str) -> String {
134    kql_escape(value).replace('*', "\\*").replace('?', "\\?")
135}
136
137/// KQL selecting exactly the given stable rule ids.
138fn rule_id_query(rule_ids: &[String]) -> String {
139    rule_ids
140        .iter()
141        .map(|id| format!("alert.attributes.params.ruleId: \"{}\"", kql_escape(id)))
142        .collect::<Vec<_>>()
143        .join(" OR ")
144}
145
146pub async fn find_page(
147    t: &Transport,
148    filter: &RuleFilter,
149    page: u32,
150    per_page: u32,
151) -> Result<(Vec<Rule>, u64)> {
152    if filter.source != RuleSource::All {
153        t.require_feature(Feature::RuleSourceScoping).await?;
154    }
155    let mut path = format!("{BASE}/_find?page={page}&per_page={per_page}");
156    if let Some(kql) = filter.to_kql() {
157        path.push_str(&format!("&filter={}", urlencode(&kql)));
158    }
159
160    let body = t.get(&path).await?;
161    decode_find(&body)
162}
163
164/// Decode a `_find` response into rules and a total. Fixtures use this same
165/// path offline.
166pub fn decode_find(body: &Value) -> Result<(Vec<Rule>, u64)> {
167    let map = body
168        .as_object()
169        .ok_or_else(|| find_error("response", "must be a JSON object"))?;
170    let data = map
171        .get("data")
172        .and_then(Value::as_array)
173        .ok_or_else(|| find_error("data", "must be an array"))?;
174    let total = required_u64(map, "total")?;
175    let page = required_u64(map, "page")?;
176    if page == 0 {
177        return Err(find_error("page", "must be greater than zero"));
178    }
179    let per_page = required_u64(map, "perPage")?;
180    if per_page == 0 {
181        return Err(find_error("perPage", "must be greater than zero"));
182    }
183
184    let data_len = data.len() as u64;
185    if data_len > total {
186        return Err(find_error(
187            "data.len() > total",
188            format!("{data_len} returned records exceed total {total}"),
189        ));
190    }
191    if data_len > per_page {
192        return Err(find_error(
193            "data.len() > perPage",
194            format!("{data_len} returned records exceed perPage {per_page}"),
195        ));
196    }
197
198    let rules = data
199        .iter()
200        .cloned()
201        .map(Rule::from_value)
202        .collect::<Result<Vec<_>>>()?;
203    Ok((rules, total))
204}
205
206fn find_error(field: &str, detail: impl std::fmt::Display) -> Error {
207    Error::new(
208        ErrorKind::Http,
209        format!("decoding rule _find response field {field}: {detail}"),
210    )
211}
212
213fn required_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
214    map.get(field)
215        .and_then(Value::as_u64)
216        .ok_or_else(|| find_error(field, "must be an unsigned integer"))
217}
218
219/// The three totals that prove `immutable` divides the complete rule corpus.
220pub(crate) struct SourceTotals {
221    pub custom: u64,
222    pub prebuilt: u64,
223    pub all: u64,
224}
225
226impl SourceTotals {
227    fn is_exhaustive(&self) -> bool {
228        self.custom.checked_add(self.prebuilt) == Some(self.all)
229    }
230}
231
232/// Verify that the custom and prebuilt `immutable` filters are disjoint and
233/// exhaustive. `customized` is intentionally absent: it overlaps prebuilt.
234///
235/// This runs only after an otherwise unselected custom or prebuilt read found
236/// no rules. A valid empty slice must still account for the entire corpus with
237/// its opposite source slice.
238pub(crate) async fn verify_source_partition(t: &Transport) -> Result<SourceTotals> {
239    let (_, custom) = find_page(
240        t,
241        &RuleFilter {
242            source: RuleSource::Custom,
243            ..Default::default()
244        },
245        1,
246        1,
247    )
248    .await?;
249    let (_, prebuilt) = find_page(
250        t,
251        &RuleFilter {
252            source: RuleSource::Prebuilt,
253            ..Default::default()
254        },
255        1,
256        1,
257    )
258    .await?;
259    let (_, all) = find_page(t, &RuleFilter::default(), 1, 1).await?;
260
261    let totals = SourceTotals {
262        custom,
263        prebuilt,
264        all,
265    };
266    if !totals.is_exhaustive() {
267        return Err(Error::new(
268            ErrorKind::Unsupported,
269            format!(
270                "the immutable source partition is not exhaustive: custom={}, \
271                 prebuilt={}, all={}. The field may be absent on this stack; \
272                 re-run with --source all to read the corpus.",
273                totals.custom, totals.prebuilt, totals.all
274            ),
275        ));
276    }
277
278    Ok(totals)
279}
280
281/// Detection-rule types used to partition a corpus. Each rule has one
282/// `params.type`, so type slices are disjoint and exhaustive. Tags are not:
283/// a rule can have many tags or none. Measured against 2,066 rules, these seven
284/// type slices sum exactly to the corpus.
285const RULE_TYPES: [&str; 7] = [
286    "query",
287    "eql",
288    "esql",
289    "threshold",
290    "threat_match",
291    "machine_learning",
292    "new_terms",
293];
294
295/// Every rule matching the filter.
296///
297/// Read corpora within the result window in one request. For larger corpora,
298/// partition by rule type, then by `enabled` when needed. Verify the partition
299/// by summing slice totals to the corpus total.
300///
301/// Never return a partial corpus. `state diff` would report unread rules as
302/// locally added, and `state pull` would silently omit them.
303pub async fn find_all(t: &Transport, filter: &RuleFilter) -> Result<Vec<Rule>> {
304    let (rules, total) = find_page(t, filter, 1, RESULT_WINDOW).await?;
305
306    if total <= u64::from(RESULT_WINDOW) {
307        if (rules.len() as u64) < total {
308            return Err(short_read(total, rules.len()));
309        }
310        return Ok(rules);
311    }
312
313    // A caller that filtered by type has one requested slice. Only `enabled`
314    // can subdivide it further.
315    let types: Vec<&str> = match &filter.rule_type {
316        Some(t) => vec![t.as_str()],
317        None => RULE_TYPES.to_vec(),
318    };
319
320    let mut collected: Vec<Rule> = Vec::new();
321    let mut summed: u64 = 0;
322
323    for rule_type in types {
324        let mut type_filter = filter.clone();
325        type_filter.rule_type = Some(rule_type.to_string());
326
327        // The opening request already fetched this selected type. Repeating it
328        // would return the same oversized result before the `enabled` split.
329        let slice_total = if filter.rule_type.is_some() {
330            total
331        } else {
332            let (slice_rules, slice_total) = find_page(t, &type_filter, 1, RESULT_WINDOW).await?;
333
334            if slice_total <= u64::from(RESULT_WINDOW) {
335                if (slice_rules.len() as u64) < slice_total {
336                    return Err(short_read(slice_total, slice_rules.len()));
337                }
338                summed += slice_total;
339                collected.extend(slice_rules);
340                continue;
341            }
342            slice_total
343        };
344
345        // A caller that filtered by `enabled` leaves no further partition.
346        // Refuse the slice rather than truncating it to 10,000 rules.
347        if filter.enabled.is_some() {
348            return Err(oversized(slice_total));
349        }
350
351        for enabled in [true, false] {
352            let mut enabled_filter = type_filter.clone();
353            enabled_filter.enabled = Some(enabled);
354            let (enabled_rules, enabled_total) =
355                find_page(t, &enabled_filter, 1, RESULT_WINDOW).await?;
356            if enabled_total > u64::from(RESULT_WINDOW) {
357                return Err(oversized(enabled_total));
358            }
359            if (enabled_rules.len() as u64) < enabled_total {
360                return Err(short_read(enabled_total, enabled_rules.len()));
361            }
362            summed += enabled_total;
363            collected.extend(enabled_rules);
364        }
365    }
366
367    // The sum verifies exhaustiveness. A newer rule type would otherwise read
368    // as zero, disappear from pulls, and appear remote-only in diffs.
369    if summed != total {
370        return Err(Error::new(
371            ErrorKind::Http,
372            format!(
373                "the server counted {total} rules across the corpus but the type slices \
374                 sum to {summed}. Refusing a partial corpus: a rule type added by a newer \
375                 stack version would otherwise read as zero in every pull and diff."
376            ),
377        ));
378    }
379
380    Ok(collected)
381}
382
383/// A server that counts more rules than it serves contradicts itself. A short
384/// list is indistinguishable from rules deleted between count and read.
385fn short_read(counted: u64, returned: usize) -> Error {
386    Error::new(
387        ErrorKind::Http,
388        format!(
389            "the server counted {counted} rules and returned {returned}. Refusing a partial \
390             corpus: a short read is indistinguishable from rules having been deleted."
391        ),
392    )
393}
394
395/// A slice that exceeds the window after both partitions cannot be served.
396/// Returning its first 10,000 rules would make unread rules look remote-only.
397fn oversized(count: u64) -> Error {
398    Error::new(
399        ErrorKind::Unsupported,
400        format!(
401            "{count} rules match, more than the {RESULT_WINDOW} a single search can return \
402             even after partitioning by type and enabled. Narrow the selection with a \
403             filter or a tag."
404        ),
405    )
406}
407
408/// How many `rule_id`s go into one filtered `_find`.
409///
410/// A KQL disjunction grows with the selection, and `--tag` can select
411/// thousands of rules. Chunking keeps each URL below practical limits.
412const ID_CHUNK: usize = 50;
413
414/// The rules carrying exactly these `rule_id`s.
415///
416/// IDs absent from the stack do not return. That is expected for locally added
417/// rules that `state push` creates.
418pub async fn find_by_rule_ids(t: &Transport, rule_ids: &[String]) -> Result<Vec<Rule>> {
419    let mut found = Vec::with_capacity(rule_ids.len());
420    for chunk in rule_ids.chunks(ID_CHUNK) {
421        let filter = RuleFilter {
422            query: Some(rule_id_query(chunk)),
423            ..Default::default()
424        };
425        let (rules, _) = find_page(t, &filter, 1, RESULT_WINDOW).await?;
426        found.extend(rules);
427    }
428    Ok(found)
429}
430
431pub async fn get(t: &Transport, rule_id: &str) -> Result<Rule> {
432    let body = t
433        .get(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
434        .await?;
435    Rule::from_value(body)
436}
437
438pub async fn create(t: &Transport, rule: &Rule) -> Result<Rule> {
439    let mut payload = rule.clone();
440    normalize::strip_volatile(&mut payload);
441    let response = t
442        .post(BASE, Some(&Value::Object(payload.as_map().clone())))
443        .await?;
444    Rule::from_value(response)
445}
446
447pub async fn update(t: &Transport, rule: &Rule) -> Result<Rule> {
448    let mut payload = rule.clone();
449    normalize::strip_volatile(&mut payload);
450    let response = t
451        .put(BASE, &Value::Object(payload.as_map().clone()))
452        .await?;
453    Rule::from_value(response)
454}
455
456pub async fn patch(t: &Transport, rule_id: &str, patch: &Value) -> Result<Rule> {
457    let mut body = patch.as_object().cloned().unwrap_or_default();
458    body.insert("rule_id".into(), json!(rule_id));
459    // PATCH accepts `rule_id` directly, avoiding the volatile server `id`.
460    let response = t.patch(BASE, &Value::Object(body)).await?;
461    Rule::from_value(response)
462}
463
464pub async fn delete(t: &Transport, rule_id: &str) -> Result<Rule> {
465    let body = t
466        .delete(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
467        .await?;
468    Rule::from_value(body)
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub enum BulkAction {
473    Enable,
474    Disable,
475    Delete,
476}
477
478impl BulkAction {
479    pub fn as_str(&self) -> &'static str {
480        match self {
481            Self::Enable => "enable",
482            Self::Disable => "disable",
483            Self::Delete => "delete",
484        }
485    }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Default)]
489pub struct BulkOutcome {
490    pub succeeded: u64,
491    pub failed: u64,
492    pub skipped: u64,
493    pub total: u64,
494}
495
496/// Decode a `_bulk_action` response summary, refusing a malformed success body.
497///
498/// A missing or mistyped counter, or a total that contradicts the per-outcome
499/// counters, must fail rather than read as "nothing happened".
500pub fn decode_bulk_outcome(body: &Value) -> Result<BulkOutcome> {
501    let map = body
502        .as_object()
503        .ok_or_else(|| bulk_error("response", "must be a JSON object"))?;
504    let summary = map
505        .get("attributes")
506        .and_then(|attributes| attributes.get("summary"))
507        .and_then(Value::as_object)
508        .ok_or_else(|| bulk_error("attributes.summary", "must be an object"))?;
509    let succeeded = bulk_u64(summary, "succeeded")?;
510    let failed = bulk_u64(summary, "failed")?;
511    let skipped = bulk_u64(summary, "skipped")?;
512    let total = bulk_u64(summary, "total")?;
513    let applied = succeeded
514        .checked_add(failed)
515        .and_then(|count| count.checked_add(skipped))
516        .ok_or_else(|| bulk_error("total", "counter sum overflows u64"))?;
517    if applied != total {
518        return Err(bulk_error(
519            "total",
520            format!(
521                "{total} does not equal succeeded {succeeded} + failed {failed} + skipped {skipped}"
522            ),
523        ));
524    }
525    Ok(BulkOutcome {
526        succeeded,
527        failed,
528        skipped,
529        total,
530    })
531}
532
533fn bulk_error(field: &str, detail: impl std::fmt::Display) -> Error {
534    Error::new(
535        ErrorKind::Http,
536        format!("decoding rule _bulk_action response field {field}: {detail}"),
537    )
538}
539
540fn bulk_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
541    map.get(field)
542        .and_then(Value::as_u64)
543        .ok_or_else(|| bulk_error(field, "must be an unsigned integer"))
544}
545
546pub async fn bulk_by_rule_ids(
547    t: &Transport,
548    action: BulkAction,
549    rule_ids: &[String],
550    dry_run: bool,
551) -> Result<BulkOutcome> {
552    // An empty selection must not become an unscoped query for every rule.
553    if rule_ids.is_empty() {
554        return Ok(BulkOutcome::default());
555    }
556
557    let path = if dry_run {
558        format!("{BASE}/_bulk_action?dry_run=true")
559    } else {
560        format!("{BASE}/_bulk_action")
561    };
562    let body = json!({ "action": action.as_str(), "query": rule_id_query(rule_ids) });
563
564    let response = t.post(&path, Some(&body)).await?;
565    decode_bulk_outcome(&response)
566}
567
568/// Export every rule, or exactly the named ones.
569///
570/// `None` posts no body for a whole-space export. `Some(ids)` posts `objects`
571/// so a subset export transfers only the selected rules.
572///
573/// The response is a bundle: rules, exception-list containers, exception items,
574/// and a trailer. `_export` appends the exception objects a rule references, so
575/// decoding them to rules only would drop that content.
576pub async fn export(t: &Transport, rule_ids: Option<&[String]>) -> Result<Bundle> {
577    let body = rule_ids.map(|ids| {
578        json!({
579            "objects": ids
580                .iter()
581                .map(|id| json!({"rule_id": id}))
582                .collect::<Vec<_>>()
583        })
584    });
585    let text = t
586        .post_text(&format!("{BASE}/_export"), body.as_ref())
587        .await?;
588    codec::decode_bundle(&text)
589}
590
591/// IDs to check in one `_find`. This keeps large requests below proxy URL
592/// limits while a 40-rule corpus still uses one request.
593const EXISTENCE_CHUNK: usize = 50;
594
595/// Which of these rule ids already exist on the stack.
596///
597/// Check file IDs before upload so existing rules can be skipped. This makes
598/// imports idempotent and lets dry runs distinguish skipped rules.
599pub async fn existing_rule_ids(
600    t: &Transport,
601    rule_ids: &[String],
602) -> Result<std::collections::BTreeSet<String>> {
603    // An empty list must not become an unscoped find for every rule.
604    let mut found = std::collections::BTreeSet::new();
605    if rule_ids.is_empty() {
606        return Ok(found);
607    }
608
609    for chunk in rule_ids.chunks(EXISTENCE_CHUNK) {
610        let path = format!(
611            "{BASE}/_find?page=1&per_page={}&filter={}",
612            chunk.len(),
613            urlencode(&rule_id_query(chunk))
614        );
615        let (rules, _) = decode_find(&t.get(&path).await?)?;
616        for r in rules {
617            if let Ok(id) = r.rule_id() {
618                found.insert(id.to_string());
619            }
620        }
621    }
622
623    Ok(found)
624}
625
626pub async fn import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
627    t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
628        .await
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    /// Spec 5.5, measured 2026-08-14.
636    #[test]
637    fn each_source_maps_to_its_measured_filter() {
638        assert_eq!(
639            RuleSource::Custom.clause(),
640            Some("alert.attributes.params.immutable: false")
641        );
642        assert_eq!(
643            RuleSource::Customized.clause(),
644            Some("alert.attributes.params.ruleSource.isCustomized: true")
645        );
646        assert_eq!(RuleSource::All.clause(), None, "all adds no clause");
647    }
648
649    #[test]
650    fn a_source_clause_combines_with_other_filters() {
651        let f = RuleFilter {
652            source: RuleSource::Custom,
653            tag: Some("prod".into()),
654            ..Default::default()
655        };
656        let kql = f.to_kql().unwrap();
657        assert!(kql.contains("immutable: false"), "{kql}");
658        assert!(kql.contains("prod"), "{kql}");
659        assert!(
660            kql.contains(" AND "),
661            "clauses combine, they do not replace: {kql}"
662        );
663    }
664
665    #[test]
666    fn kql_escape_doubles_a_lone_backslash() {
667        assert_eq!(kql_escape("a\\b"), "a\\\\b");
668    }
669
670    #[test]
671    fn kql_escape_escapes_a_lone_quote() {
672        let mut expected = String::from("a");
673        expected.push('\\');
674        expected.push('"');
675        expected.push('b');
676        assert_eq!(kql_escape("a\"b"), expected);
677    }
678
679    /// The wildcard pass runs after `kql_escape`, escaping `*` and `?` without
680    /// undoing the backslash and quote escaping.
681    #[test]
682    fn kql_escape_wildcard_escapes_kql_wildcards_after_kql_escape() {
683        assert_eq!(kql_escape_wildcard("*"), "\\*");
684        assert_eq!(kql_escape_wildcard("?"), "\\?");
685        assert_eq!(kql_escape_wildcard("a\\b\"c"), kql_escape("a\\b\"c"));
686    }
687
688    /// A backslash before a quote tests escape order. Escaping quotes first
689    /// would double the backslash inserted for the quote and corrupt the value.
690    #[test]
691    fn kql_escape_orders_backslash_before_quote() {
692        let mut input = String::new();
693        input.push('\\');
694        input.push('"');
695
696        let escaped = kql_escape(&input);
697
698        // Double the backslash, then escape the quote: three backslashes and a
699        // quote.
700        let mut expected = "\\".repeat(3);
701        expected.push('"');
702        assert_eq!(escaped, expected);
703    }
704
705    #[test]
706    fn rule_id_query_escapes_a_quote_in_the_id() {
707        let mut id = String::from("x");
708        id.push('"');
709        id.push('y');
710
711        let q = rule_id_query(&[id]);
712
713        let mut expected = String::from("alert.attributes.params.ruleId: \"x");
714        expected.push('\\');
715        expected.push('"');
716        expected.push_str("y\"");
717        assert_eq!(q, expected);
718        assert!(
719            !q.contains(" OR "),
720            "a single id must produce exactly one clause: {q}"
721        );
722    }
723
724    #[test]
725    fn rule_id_query_neutralizes_a_kql_injection_payload() {
726        let payload = "x\" or alert.attributes.enabled: true or \"";
727        let q = rule_id_query(&[payload.to_string()]);
728        assert!(
729            !q.contains("\" or alert.attributes.enabled: true or \""),
730            "the injected quote must not close the literal: {q}"
731        );
732    }
733
734    #[test]
735    fn to_kql_escapes_a_quote_in_the_tag() {
736        let f = RuleFilter {
737            tag: Some("a\"b".into()),
738            ..Default::default()
739        };
740        let kql = f.to_kql().unwrap();
741
742        let mut expected = String::from("alert.attributes.tags: \"a");
743        expected.push('\\');
744        expected.push('"');
745        expected.push_str("b\"");
746        assert_eq!(kql, expected);
747    }
748}