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