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