Skip to main content

elasticctl_api/
rules_ops.rs

1//! Rules command orchestration, above the endpoint wrappers in `rules`.
2//!
3//! The `-cli` crate resolves context, applies the guard, and renders; this
4//! module does the work between them so a future MCP server can call the same
5//! functions and serialize the same structs.
6
7use crate::codec::{self, Format};
8use crate::model::{ListKey, Rule, exception_refs, server_defaults};
9use crate::normalize;
10use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
11use crate::rules::{self, BulkAction, RuleFilter, RuleSource};
12use crate::selection;
13use elasticctl_core::{Error, ErrorKind, Result, Transport};
14use serde::Serialize;
15use serde_json::{Value, json};
16use std::path::Path;
17
18/// Kibana validates an exception pointer as a string before it re-resolves the
19/// reference against the target stack. This deterministic placeholder is
20/// upload-only: the server replaces it with the live container id.
21const EXCEPTION_POINTER_PLACEHOLDER: &str = "00000000-0000-0000-0000-000000000000";
22
23/// The report `list` renders: every matching rule, in server order.
24#[derive(Debug, Clone, PartialEq, Serialize)]
25pub struct RuleListReport {
26    pub total: usize,
27    pub rules: Vec<Rule>,
28}
29
30/// One name-ordered page of rules and the server's match total.
31#[derive(Debug, Clone)]
32pub struct RulePage {
33    pub total: u64,
34    pub rules: Vec<Rule>,
35}
36
37/// The report an `enable`/`disable` apply renders. Field order is the
38/// serialized JSON key order and is contractual: the root `Cargo.toml` enables
39/// `serde_json`'s `preserve_order`.
40#[derive(Debug, Clone, PartialEq, Serialize)]
41pub struct SetEnabledOutcome {
42    pub applied: bool,
43    pub succeeded: u64,
44    pub failed: u64,
45    pub skipped: u64,
46    pub total: u64,
47}
48
49/// One rule's validation entry.
50#[derive(Debug, Clone, PartialEq, Serialize)]
51pub struct RuleValidation {
52    pub rule_id: String,
53    pub name: String,
54    #[serde(rename = "type")]
55    pub rule_type: String,
56    pub defaults_applied: Vec<String>,
57}
58
59/// The report `validate` renders.
60#[derive(Debug, Clone, PartialEq, Serialize)]
61pub struct ValidateReport {
62    pub valid: bool,
63    pub count: usize,
64    pub rules: Vec<RuleValidation>,
65}
66
67/// The report a preview renders.
68#[derive(Debug, Clone, PartialEq, Serialize)]
69pub struct PreviewReport {
70    pub rule: String,
71    pub preview_id: Option<String>,
72    pub invocations: u32,
73    pub hits: Option<u64>,
74    pub errors: Vec<String>,
75    pub warnings: Vec<String>,
76    pub hits_error: Option<String>,
77    pub sample: Vec<Value>,
78}
79
80pub async fn list(t: &Transport, filter: &RuleFilter) -> Result<RuleListReport> {
81    let rules = rules::find_all(t, filter).await?;
82    if rules.is_empty() && is_unselected_source_query(filter) {
83        rules::verify_source_partition(t).await?;
84    }
85    let total = rules.len();
86    Ok(RuleListReport { total, rules })
87}
88
89/// Read at most `limit` rules in one request, ordered by name. MCP uses this
90/// because a whole-corpus read can exceed the response body cap.
91pub async fn list_page(t: &Transport, filter: &RuleFilter, limit: u32) -> Result<RulePage> {
92    let (rules, total) = rules::find_first_by_name(t, filter, limit).await?;
93    if rules.is_empty() && is_unselected_source_query(filter) {
94        rules::verify_source_partition(t).await?;
95    }
96    Ok(RulePage { total, rules })
97}
98
99/// A `rules list` filter has no positional selectors. Any remaining clause
100/// deliberately narrows the result and must not trigger a corpus proof.
101fn is_unselected_source_query(filter: &RuleFilter) -> bool {
102    matches!(filter.source, RuleSource::Custom | RuleSource::Prebuilt)
103        && filter.enabled.is_none()
104        && filter.rule_type.is_none()
105        && filter.severity.is_none()
106        && filter.tag.is_none()
107        && filter.name.is_none()
108        && filter.query.is_none()
109        && filter.search.is_none()
110        && filter.technique.is_none()
111        && filter.tactic.is_none()
112}
113
114/// Resolve a selector and fetch the rule, canonicalized so output is stable.
115pub async fn get_one(t: &Transport, selector: &str) -> Result<Rule> {
116    let (_, rule) = selection::to_rule(t, selector).await?;
117    Ok(normalize::canonical(&rule))
118}
119
120/// Parse and validate a local file without contacting a server.
121///
122/// The codecs reject missing or non-string `rule_id` values. Keep the
123/// per-rule check because derived `Rule::Deserialize` bypasses that
124/// validation. Check every rule so one report names every invalid index.
125pub fn validate(path: &Path) -> Result<ValidateReport> {
126    let body = std::fs::read_to_string(path)
127        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
128
129    let rules = match Format::from_path(path) {
130        Format::Yaml => codec::decode_yaml(&body)?,
131        Format::Ndjson => codec::decode_ndjson(&body)?.0,
132    };
133
134    let defaults = server_defaults();
135    let mut reports = Vec::with_capacity(rules.len());
136    let mut failures = Vec::new();
137
138    for (i, r) in rules.iter().enumerate() {
139        match r.rule_id() {
140            Ok(rule_id) => {
141                // Show server defaults applied to sparse rules.
142                let mut applied: Vec<String> = defaults
143                    .keys()
144                    .filter(|k| !r.as_map().contains_key(*k))
145                    .cloned()
146                    .collect();
147                applied.sort();
148                reports.push(RuleValidation {
149                    rule_id: rule_id.to_string(),
150                    name: r.name().to_string(),
151                    rule_type: r.rule_type().to_string(),
152                    defaults_applied: applied,
153                });
154            }
155            // Do not report a blank rule ID as valid.
156            Err(e) => failures.push(format!("rule at index {i}: {}", e.message)),
157        }
158    }
159
160    if !failures.is_empty() {
161        // An invalid rule invalidates the file. Return a classified error,
162        // not a partial success payload.
163        return Err(Error::new(ErrorKind::Error, failures.join("; ")));
164    }
165
166    Ok(ValidateReport {
167        valid: true,
168        count: rules.len(),
169        rules: reports,
170    })
171}
172
173/// Resolve every selector to its rule ID and rule before previewing, so the
174/// preview is accurate and unresolved selectors fail before mutation.
175async fn resolve_targets(t: &Transport, selectors: &[String]) -> Result<Vec<(String, Rule)>> {
176    let mut out = Vec::with_capacity(selectors.len());
177    for s in selectors {
178        out.push(selection::to_rule(t, s).await?);
179    }
180    Ok(out)
181}
182
183pub async fn plan_set_enabled(
184    t: &Transport,
185    selectors: &[String],
186    enable: bool,
187) -> Result<MutationPlan> {
188    let resolved = resolve_targets(t, selectors).await?;
189    let preview_details = resolved
190        .iter()
191        .map(|(id, r)| {
192            let from = if r.enabled() { "enabled" } else { "disabled" };
193            let to = if enable { "enabled" } else { "disabled" };
194            format!("{id}  {}  {from} -> {to}", r.name())
195        })
196        .collect();
197    let verb = if enable { "Enable" } else { "Disable" };
198    Ok(MutationPlan {
199        preview_action: format!("{verb} {} rule(s)", resolved.len()),
200        preview_details,
201        targets: resolved.into_iter().map(|(id, _)| id).collect(),
202    })
203}
204
205pub async fn apply_set_enabled(
206    t: &Transport,
207    plan: &MutationPlan,
208    enable: bool,
209) -> Result<SetEnabledOutcome> {
210    let action = if enable {
211        BulkAction::Enable
212    } else {
213        BulkAction::Disable
214    };
215    let o = rules::bulk_by_rule_ids(t, action, &plan.targets, false).await?;
216    Ok(SetEnabledOutcome {
217        applied: true,
218        succeeded: o.succeeded,
219        failed: o.failed,
220        skipped: o.skipped,
221        total: o.total,
222    })
223}
224
225pub async fn plan_delete(t: &Transport, selectors: &[String]) -> Result<MutationPlan> {
226    let resolved = resolve_targets(t, selectors).await?;
227    let preview_details = resolved
228        .iter()
229        .map(|(id, r)| format!("{id}  {}", r.name()))
230        .collect();
231    Ok(MutationPlan {
232        preview_action: format!("Delete {} rule(s)", resolved.len()),
233        preview_details,
234        targets: resolved.into_iter().map(|(id, _)| id).collect(),
235    })
236}
237
238/// Continue after per-rule failures so the result records every deletion and
239/// every rule that remains.
240pub async fn apply_delete(t: &Transport, plan: &MutationPlan) -> Result<DeleteOutcome> {
241    let mut deleted = Vec::new();
242    let mut failed = Vec::new();
243    for id in &plan.targets {
244        match rules::delete(t, id).await {
245            Ok(_) => deleted.push(json!({"rule_id": id})),
246            Err(e) => failed.push(json!({"rule_id": id, "error": e.message})),
247        }
248    }
249    Ok(DeleteOutcome {
250        applied: true,
251        deleted,
252        failed,
253        total: plan.targets.len(),
254    })
255}
256
257/// Fetch, canonicalize, and sort selected rules by rule ID. Exports from an
258/// unchanged stack are byte-identical for version-control review. Scoped by
259/// `source`; the `all` default lives on the clap flag (spec 5.5).
260///
261/// A source scope without a selector or tag resolves to the matching rule IDs
262/// first, so the subset export transfers only the subset (spec 4.3). A selector
263/// or tag is an explicit narrowing and overrides the source default, matching
264/// the state commands (spec 5.3).
265pub async fn export_rules(
266    t: &Transport,
267    selectors: &[String],
268    tag: Option<&str>,
269    source: RuleSource,
270    format: Format,
271) -> Result<ExportOutcome> {
272    let selection: Option<Vec<String>> =
273        if selectors.is_empty() && tag.is_none() && source != RuleSource::All {
274            let scoped = rules::find_all(
275                t,
276                &RuleFilter {
277                    source,
278                    ..Default::default()
279                },
280            )
281            .await?;
282            if scoped.is_empty() {
283                if matches!(source, RuleSource::Custom | RuleSource::Prebuilt) {
284                    rules::verify_source_partition(t).await?;
285                }
286                return Ok(ExportOutcome {
287                    body: String::new(),
288                    exported: 0,
289                    missing: Vec::new(),
290                });
291            }
292            Some(
293                scoped
294                    .iter()
295                    .filter_map(|r| r.rule_id().ok().map(str::to_owned))
296                    .collect(),
297            )
298        } else {
299            // Export reads from the stack, so every selector names a server rule.
300            selection::resolve(t, selectors, tag, None, &[], "export").await?
301        };
302
303    let mut bundle = rules::export(t, selection.as_deref()).await?;
304    for r in &mut bundle.rules {
305        *r = normalize::canonical(r);
306    }
307    normalize::sort_rules(&mut bundle.rules);
308
309    let body = match format {
310        // YAML carries rules only. A bundle with exception objects has no YAML
311        // form, so refuse rather than silently drop them (spec 5.2).
312        Format::Yaml => {
313            if !bundle.lists.is_empty() || !bundle.items.is_empty() {
314                return Err(Error::new(
315                    ErrorKind::Unsupported,
316                    format!(
317                        "this export carries {} exception list(s) and {} item(s), which the \
318                         YAML format cannot represent; re-run with --format-file ndjson",
319                        bundle.lists.len(),
320                        bundle.items.len()
321                    ),
322                ));
323            }
324            codec::encode_yaml(&bundle.rules)?
325        }
326        // The bundle's lists and items must survive the export or importing
327        // the file elsewhere recreates a rule pointing at a missing list.
328        Format::Ndjson => codec::encode_bundle(&bundle)?,
329    };
330
331    // A requested but missing rule was deleted after selection. Report it in
332    // `missing` so a short export has a nonzero exit code.
333    let missing = bundle
334        .summary
335        .as_ref()
336        .map(|s| s.missing_rules.clone())
337        .unwrap_or_default();
338
339    Ok(ExportOutcome {
340        body,
341        exported: bundle.rules.len() as u64,
342        missing,
343    })
344}
345
346/// Compute the import preview and the NDJSON to upload.
347///
348/// With `--skip-existing`, check existing rule IDs before the preview so it
349/// shows only rules that would import. That query is a read, so it belongs
350/// here and does not violate the no-write rule. The transport is `None` unless
351/// `skip_existing` is set, so a dry run that only reads the file never needs
352/// one.
353pub async fn plan_import(
354    t: Option<&Transport>,
355    path: &Path,
356    overwrite: bool,
357    skip_existing: bool,
358) -> Result<ImportPlan> {
359    let body = std::fs::read_to_string(path)
360        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
361
362    let format = Format::from_path(path);
363    let mut bundle = match format {
364        // YAML has no exception-bundle representation. Preserve its existing
365        // rules-only behavior, then upload it as Kibana's required NDJSON.
366        Format::Yaml => codec::Bundle {
367            rules: codec::decode_yaml(&body)?,
368            ..Default::default()
369        },
370        // Rules export emits rules, exception containers, and exception items
371        // in one NDJSON bundle. Import must retain all three so Kibana can
372        // recreate containers and resolve their server-owned pointers.
373        Format::Ndjson => codec::decode_bundle(&body)?,
374    };
375    let total = bundle.rules.len();
376
377    let mut skipped: Vec<Value> = Vec::new();
378
379    if skip_existing {
380        let t = t.ok_or_else(|| {
381            Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
382        })?;
383        let ids: Vec<String> = bundle
384            .rules
385            .iter()
386            .filter_map(|r| r.rule_id().ok().map(str::to_owned))
387            .collect();
388        let existing = rules::existing_rule_ids(t, &ids).await?;
389
390        let mut keep = Vec::with_capacity(bundle.rules.len());
391        for rule in std::mem::take(&mut bundle.rules) {
392            match rule.rule_id() {
393                Ok(id) if existing.contains(id) => {
394                    skipped.push(json!({"rule_id": id, "reason": "exists"}));
395                }
396                _ => keep.push(rule),
397            }
398        }
399        bundle.rules = keep;
400    }
401
402    if format == Format::Ndjson {
403        retain_referenced_exception_objects(&mut bundle);
404    }
405    add_upload_pointer_placeholders(&mut bundle);
406
407    let mut details: Vec<String> = bundle
408        .rules
409        .iter()
410        .map(|r| format!("{}  {}  import", r.rule_id().unwrap_or(""), r.name()))
411        .collect();
412    details.extend(skipped.iter().map(|s| {
413        format!(
414            "{}  skip (already exists)",
415            s["rule_id"].as_str().unwrap_or("")
416        )
417    }));
418
419    let qualifier = if overwrite {
420        ", overwriting existing".to_string()
421    } else if skip_existing && !skipped.is_empty() {
422        format!(", skipping {} that already exist", skipped.len())
423    } else {
424        String::new()
425    };
426    let preview = MutationPlan {
427        preview_action: format!(
428            "Import {} rule(s) from {}{qualifier}",
429            bundle.rules.len(),
430            path.display()
431        ),
432        preview_details: details,
433        targets: bundle
434            .rules
435            .iter()
436            .filter_map(|r| r.rule_id().ok().map(str::to_owned))
437            .collect(),
438    };
439
440    // Kibana's import takes NDJSON regardless of the source file's format.
441    // A source NDJSON bundle keeps its exception members; YAML remains rules
442    // only because that format cannot represent containers or items.
443    let ndjson = match format {
444        Format::Yaml => codec::encode_ndjson(&bundle.rules)?,
445        Format::Ndjson => codec::encode_bundle(&bundle)?,
446    };
447
448    Ok(ImportPlan {
449        preview,
450        ndjson,
451        total,
452        skipped,
453    })
454}
455
456/// Keep only containers and items a rule scheduled for upload references.
457/// `rules import` is guarded as a rule mutation, so an exception-only or
458/// unreferenced object must never become an unpreviewed side effect. Namespace
459/// is part of stable list identity, so equal `list_id` values in `single` and
460/// `agnostic` remain disjoint.
461fn retain_referenced_exception_objects(bundle: &mut codec::Bundle) {
462    let wanted: std::collections::BTreeSet<ListKey> = bundle
463        .rules
464        .iter()
465        .flat_map(exception_refs)
466        .map(|reference| ListKey {
467            list_id: reference.list_id,
468            namespace_type: reference.namespace_type,
469        })
470        .collect();
471
472    bundle
473        .lists
474        .retain(|list| list.key().is_ok_and(|key| wanted.contains(&key)));
475    bundle.items.retain(|item| {
476        item.list_id().is_ok_and(|list_id| {
477            wanted.contains(&ListKey {
478                list_id: list_id.to_string(),
479                namespace_type: item.namespace_type().to_string(),
480            })
481        })
482    });
483}
484
485/// Make every readable exception reference acceptable to Kibana's import
486/// schema. This deliberately changes only the transient upload bundle; source
487/// files remain pointer-free and malformed/unreadable entries survive intact.
488fn add_upload_pointer_placeholders(bundle: &mut codec::Bundle) {
489    for rule in &mut bundle.rules {
490        let Some(Value::Array(references)) = rule.as_map_mut().get_mut("exceptions_list") else {
491            continue;
492        };
493        for reference in references {
494            let Value::Object(reference) = reference else {
495                continue;
496            };
497            if reference.get("list_id").is_some_and(Value::is_string) {
498                reference.insert(
499                    "id".to_string(),
500                    Value::String(EXCEPTION_POINTER_PLACEHOLDER.to_string()),
501                );
502            }
503        }
504    }
505}
506
507/// Upload the NDJSON `plan_import` prepared.
508pub async fn apply_import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
509    // Do not upload empty NDJSON when every rule already exists.
510    if ndjson.is_empty() {
511        return Ok(ImportReport {
512            succeeded: json!(0),
513            failed: json!([]),
514        });
515    }
516
517    let response = rules::import(t, ndjson, overwrite).await?;
518
519    // Normalize Kibana's response to the bulk-action shape so partial imports
520    // use the existing exit-code rule, refusing a malformed success body.
521    crate::ops::decode_import_report(&response, "rules")
522}
523
524/// Retry once only when the first search finds no hits.
525///
526/// The preview already completed each invocation. A newly written alert can
527/// miss the first search because of Elasticsearch's refresh interval. Retrying
528/// only zero hits avoids delay for matching rules and false zero results.
529async fn fetch_hits(
530    transport: &Transport,
531    space: &str,
532    preview_id: &str,
533    sample: usize,
534) -> Result<rules::PreviewHits> {
535    let first = rules::preview_hits(transport, space, preview_id, sample).await?;
536    if first.total > 0 {
537        return Ok(first);
538    }
539    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
540    rules::preview_hits(transport, space, preview_id, sample).await
541}
542
543/// Preview a rule. An existing file path wins; otherwise the source is a rule
544/// ID or name from the stack. This supports unpushed local rules.
545pub async fn preview_rule(
546    t: &Transport,
547    source: &str,
548    invocations: u32,
549    sample: u32,
550    space: &str,
551) -> Result<PreviewReport> {
552    let path = Path::new(source);
553
554    let rule = if path.exists() {
555        let body = std::fs::read_to_string(path).map_err(|e| {
556            Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
557        })?;
558        let rules = match Format::from_path(path) {
559            Format::Yaml => codec::decode_yaml(&body)?,
560            Format::Ndjson => codec::decode_ndjson(&body)?.0,
561        };
562        rules.into_iter().next().ok_or_else(|| {
563            Error::new(
564                ErrorKind::Error,
565                format!("{} contains no rules", path.display()),
566            )
567        })?
568    } else {
569        selection::to_rule(t, source).await?.1
570    };
571
572    // The API requires an explicit end of the window it simulates over.
573    let timeframe_end = now_rfc3339();
574    let result = rules::preview(t, &rule, invocations, &timeframe_end).await?;
575
576    // Preserve the preview when reading hits fails. Set `hits` to null and
577    // report the error in `hits_error`.
578    let (hits, hits_error, sample_hits) = match &result.preview_id {
579        None => (
580            None,
581            Some("the server returned no preview_id".to_string()),
582            Vec::new(),
583        ),
584        Some(preview_id) => match fetch_hits(t, space, preview_id, sample as usize).await {
585            Ok(h) => (Some(h.total), None, h.sample),
586            Err(e) => (None, Some(e.message), Vec::new()),
587        },
588    };
589
590    Ok(PreviewReport {
591        rule: rule.name().to_string(),
592        preview_id: result.preview_id,
593        invocations,
594        hits,
595        errors: result.errors,
596        warnings: result.warnings,
597        hits_error,
598        sample: sample_hits,
599    })
600}
601
602fn now_rfc3339() -> String {
603    use std::time::{SystemTime, UNIX_EPOCH};
604    let secs = SystemTime::now()
605        .duration_since(UNIX_EPOCH)
606        .unwrap_or_default()
607        .as_secs();
608    // Format directly to avoid a date dependency; the API accepts UTC ISO-8601.
609    let days = secs / 86_400;
610    let rem = secs % 86_400;
611    let (y, m, d) = civil_from_days(days as i64);
612    format!(
613        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
614        rem / 3600,
615        (rem % 3600) / 60,
616        rem % 60
617    )
618}
619
620/// Howard Hinnant's days-from-civil inverse, used without a date dependency.
621fn civil_from_days(z: i64) -> (i64, u32, u32) {
622    let z = z + 719_468;
623    let era = z.div_euclid(146_097);
624    let doe = z.rem_euclid(146_097);
625    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
626    let y = yoe + era * 400;
627    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
628    let mp = (5 * doy + 2) / 153;
629    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
630    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
631    (if m <= 2 { y + 1 } else { y }, m, d)
632}
633
634#[cfg(test)]
635mod date_tests {
636    use super::*;
637
638    /// Independently computed epoch days catch plausible arithmetic errors
639    /// that would shift a preview's `timeframeEnd`.
640    #[test]
641    fn civil_from_days_matches_independently_computed_epoch_days() {
642        let cases = [
643            (0, (1970, 1, 1), "epoch"),
644            (19782, (2024, 2, 29), "leap day"),
645            (11017, (2000, 3, 1), "century leap year (2000 % 400 == 0)"),
646            (20818, (2026, 12, 31), "year end"),
647            (20819, (2027, 1, 1), "year rollover"),
648            // 2100 is divisible by 100 but not 400, so it is not a leap year.
649            (47541, (2100, 3, 1), "century non-leap (2100 % 400 != 0)"),
650            (
651                47540,
652                (2100, 2, 28),
653                "day before the century non-leap rollover",
654            ),
655        ];
656
657        for (day, expected, label) in cases {
658            assert_eq!(civil_from_days(day), expected, "{label}: day {day}");
659        }
660    }
661
662    /// The API requires this exact `timeframeEnd` format.
663    #[test]
664    fn now_rfc3339_matches_the_shape_the_api_requires() {
665        let s = now_rfc3339();
666        let bytes = s.as_bytes();
667
668        assert_eq!(s.len(), 24, "{s}");
669        assert!(bytes[4] == b'-' && bytes[7] == b'-', "{s}");
670        assert_eq!(bytes[10], b'T', "{s}");
671        assert!(bytes[13] == b':' && bytes[16] == b':', "{s}");
672        assert_eq!(bytes[19], b'.', "{s}");
673        assert_eq!(&s[20..], "000Z", "{s}");
674        assert!(
675            s[..19]
676                .chars()
677                .enumerate()
678                .all(|(i, c)| { matches!(i, 4 | 7 | 10 | 13 | 16) || c.is_ascii_digit() }),
679            "every non-separator position must be a digit: {s}"
680        );
681    }
682}