Skip to main content

elasticctl_api/
rules_ops.rs

1mod create;
2mod recheck;
3pub use create::{RuleCreateOutcome, RuleCreatePlan, apply_create, plan_create};
4pub use recheck::observe_rules;
5mod observed;
6pub use observed::plan_set_enabled_observed;
7
8use crate::codec::{self, Format};
9use crate::model::{ListKey, Rule, exception_refs, server_defaults};
10use crate::normalize;
11use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
12use crate::rules::{self, BulkAction, RuleFilter, RuleSource};
13use crate::selection;
14use elasticctl_core::{Error, ErrorKind, Result, Transport};
15use serde::Serialize;
16use serde_json::{Value, json};
17use std::path::Path;
18
19const EXCEPTION_POINTER_PLACEHOLDER: &str = "00000000-0000-0000-0000-000000000000";
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct RuleListReport {
23    pub total: usize,
24    pub rules: Vec<Rule>,
25}
26
27#[derive(Debug, Clone)]
28pub struct RulePage {
29    pub total: u64,
30    pub rules: Vec<Rule>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct SetEnabledOutcome {
35    pub applied: bool,
36    pub succeeded: u64,
37    pub failed: u64,
38    pub skipped: u64,
39    pub total: u64,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize)]
43pub struct RuleValidation {
44    pub rule_id: String,
45    pub name: String,
46    #[serde(rename = "type")]
47    pub rule_type: String,
48    pub defaults_applied: Vec<String>,
49}
50
51#[derive(Debug, Clone, PartialEq, Serialize)]
52pub struct ValidateReport {
53    pub valid: bool,
54    pub count: usize,
55    pub rules: Vec<RuleValidation>,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize)]
59pub struct PreviewReport {
60    pub rule: String,
61    pub preview_id: Option<String>,
62    pub invocations: u32,
63    pub hits: Option<u64>,
64    pub errors: Vec<String>,
65    pub warnings: Vec<String>,
66    pub hits_error: Option<String>,
67    pub sample: Vec<Value>,
68}
69
70pub async fn list(t: &Transport, filter: &RuleFilter) -> Result<RuleListReport> {
71    let rules = rules::find_all(t, filter).await?;
72    if rules.is_empty() && is_unselected_source_query(filter) {
73        rules::verify_source_partition(t).await?;
74    }
75    let total = rules.len();
76    Ok(RuleListReport { total, rules })
77}
78
79pub async fn list_page(t: &Transport, filter: &RuleFilter, limit: u32) -> Result<RulePage> {
80    let (rules, total) = rules::find_first_by_name(t, filter, limit).await?;
81    if rules.is_empty() && is_unselected_source_query(filter) {
82        rules::verify_source_partition(t).await?;
83    }
84    Ok(RulePage { total, rules })
85}
86
87fn is_unselected_source_query(filter: &RuleFilter) -> bool {
88    matches!(filter.source, RuleSource::Custom | RuleSource::Prebuilt)
89        && filter.enabled.is_none()
90        && filter.rule_type.is_none()
91        && filter.severity.is_none()
92        && filter.tag.is_none()
93        && filter.name.is_none()
94        && filter.query.is_none()
95        && filter.search.is_none()
96        && filter.technique.is_none()
97        && filter.tactic.is_none()
98}
99
100pub async fn get_one(t: &Transport, selector: &str) -> Result<Rule> {
101    let (_, rule) = selection::to_rule(t, selector).await?;
102    Ok(normalize::canonical(&rule))
103}
104
105pub fn validate(path: &Path) -> Result<ValidateReport> {
106    let body = std::fs::read_to_string(path)
107        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
108
109    let rules = match Format::from_path(path) {
110        Format::Yaml => codec::decode_yaml(&body)?,
111        Format::Ndjson => codec::decode_ndjson(&body)?.0,
112    };
113
114    let defaults = server_defaults();
115    let mut reports = Vec::with_capacity(rules.len());
116    let mut failures = Vec::new();
117
118    for (i, r) in rules.iter().enumerate() {
119        match r.rule_id() {
120            Ok(rule_id) => {
121                let mut applied: Vec<String> = defaults
122                    .keys()
123                    .filter(|k| !r.as_map().contains_key(*k))
124                    .cloned()
125                    .collect();
126                applied.sort();
127                reports.push(RuleValidation {
128                    rule_id: rule_id.to_string(),
129                    name: r.name().to_string(),
130                    rule_type: r.rule_type().to_string(),
131                    defaults_applied: applied,
132                });
133            }
134            Err(e) => failures.push(format!("rule at index {i}: {}", e.message)),
135        }
136    }
137
138    if !failures.is_empty() {
139        return Err(Error::new(ErrorKind::Error, failures.join("; ")));
140    }
141
142    Ok(ValidateReport {
143        valid: true,
144        count: rules.len(),
145        rules: reports,
146    })
147}
148
149async fn resolve_targets(t: &Transport, selectors: &[String]) -> Result<Vec<(String, Rule)>> {
150    let mut out = Vec::with_capacity(selectors.len());
151    for s in selectors {
152        out.push(selection::to_rule(t, s).await?);
153    }
154    Ok(out)
155}
156
157pub async fn plan_set_enabled(
158    t: &Transport,
159    selectors: &[String],
160    enable: bool,
161) -> Result<MutationPlan> {
162    Ok(plan_set_enabled_observed(t, selectors, enable).await?.0)
163}
164
165pub async fn apply_set_enabled(
166    t: &Transport,
167    plan: &MutationPlan,
168    enable: bool,
169) -> Result<SetEnabledOutcome> {
170    let action = if enable {
171        BulkAction::Enable
172    } else {
173        BulkAction::Disable
174    };
175    let o = rules::bulk_by_rule_ids(t, action, &plan.targets, false).await?;
176    Ok(SetEnabledOutcome {
177        applied: true,
178        succeeded: o.succeeded,
179        failed: o.failed,
180        skipped: o.skipped,
181        total: o.total,
182    })
183}
184
185pub async fn plan_delete(t: &Transport, selectors: &[String]) -> Result<MutationPlan> {
186    let resolved = resolve_targets(t, selectors).await?;
187    let preview_details = resolved
188        .iter()
189        .map(|(id, r)| format!("{id}  {}", r.name()))
190        .collect();
191    Ok(MutationPlan {
192        preview_action: format!("Delete {} rule(s)", resolved.len()),
193        preview_details,
194        targets: resolved.into_iter().map(|(id, _)| id).collect(),
195    })
196}
197
198pub async fn apply_delete(t: &Transport, plan: &MutationPlan) -> Result<DeleteOutcome> {
199    let mut deleted = Vec::new();
200    let mut failed = Vec::new();
201    for id in &plan.targets {
202        match rules::delete(t, id).await {
203            Ok(_) => deleted.push(json!({"rule_id": id})),
204            Err(e) => failed.push(json!({"rule_id": id, "error": e.message})),
205        }
206    }
207    Ok(DeleteOutcome {
208        applied: true,
209        deleted,
210        failed,
211        total: plan.targets.len(),
212    })
213}
214
215pub async fn export_rules(
216    t: &Transport,
217    selectors: &[String],
218    tag: Option<&str>,
219    source: RuleSource,
220    format: Format,
221) -> Result<ExportOutcome> {
222    let selection: Option<Vec<String>> =
223        if selectors.is_empty() && tag.is_none() && source != RuleSource::All {
224            let scoped = rules::find_all(
225                t,
226                &RuleFilter {
227                    source,
228                    ..Default::default()
229                },
230            )
231            .await?;
232            if scoped.is_empty() {
233                if matches!(source, RuleSource::Custom | RuleSource::Prebuilt) {
234                    rules::verify_source_partition(t).await?;
235                }
236                return Ok(ExportOutcome {
237                    body: String::new(),
238                    exported: 0,
239                    missing: Vec::new(),
240                });
241            }
242            Some(
243                scoped
244                    .iter()
245                    .filter_map(|r| r.rule_id().ok().map(str::to_owned))
246                    .collect(),
247            )
248        } else {
249            selection::resolve(t, selectors, tag, None, &[], "export").await?
250        };
251
252    let mut bundle = rules::export(t, selection.as_deref()).await?;
253    for r in &mut bundle.rules {
254        *r = normalize::canonical(r);
255    }
256    normalize::sort_rules(&mut bundle.rules);
257
258    let body = match format {
259        Format::Yaml => {
260            if !bundle.lists.is_empty() || !bundle.items.is_empty() {
261                return Err(Error::new(
262                    ErrorKind::Unsupported,
263                    format!(
264                        "this export carries {} exception list(s) and {} item(s), which the \
265                         YAML format cannot represent; re-run with --format-file ndjson",
266                        bundle.lists.len(),
267                        bundle.items.len()
268                    ),
269                ));
270            }
271            codec::encode_yaml(&bundle.rules)?
272        }
273        Format::Ndjson => codec::encode_bundle(&bundle)?,
274    };
275
276    let missing = bundle
277        .summary
278        .as_ref()
279        .map(|s| s.missing_rules.clone())
280        .unwrap_or_default();
281
282    Ok(ExportOutcome {
283        body,
284        exported: bundle.rules.len() as u64,
285        missing,
286    })
287}
288
289pub async fn plan_import(
290    t: Option<&Transport>,
291    path: &Path,
292    overwrite: bool,
293    skip_existing: bool,
294) -> Result<ImportPlan> {
295    let body = std::fs::read_to_string(path)
296        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
297
298    let format = Format::from_path(path);
299    let mut bundle = match format {
300        Format::Yaml => codec::Bundle {
301            rules: codec::decode_yaml(&body)?,
302            ..Default::default()
303        },
304        Format::Ndjson => codec::decode_bundle(&body)?,
305    };
306    let total = bundle.rules.len();
307
308    let mut skipped: Vec<Value> = Vec::new();
309
310    if skip_existing {
311        let t = t.ok_or_else(|| {
312            Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
313        })?;
314        let ids: Vec<String> = bundle
315            .rules
316            .iter()
317            .filter_map(|r| r.rule_id().ok().map(str::to_owned))
318            .collect();
319        let existing = rules::existing_rule_ids(t, &ids).await?;
320
321        let mut keep = Vec::with_capacity(bundle.rules.len());
322        for rule in std::mem::take(&mut bundle.rules) {
323            match rule.rule_id() {
324                Ok(id) if existing.contains(id) => {
325                    skipped.push(json!({"rule_id": id, "reason": "exists"}));
326                }
327                _ => keep.push(rule),
328            }
329        }
330        bundle.rules = keep;
331    }
332
333    if format == Format::Ndjson {
334        retain_referenced_exception_objects(&mut bundle);
335    }
336    add_upload_pointer_placeholders(&mut bundle);
337
338    let mut details: Vec<String> = bundle
339        .rules
340        .iter()
341        .map(|r| format!("{}  {}  import", r.rule_id().unwrap_or(""), r.name()))
342        .collect();
343    details.extend(skipped.iter().map(|s| {
344        format!(
345            "{}  skip (already exists)",
346            s["rule_id"].as_str().unwrap_or("")
347        )
348    }));
349
350    let qualifier = if overwrite {
351        ", overwriting existing".to_string()
352    } else if skip_existing && !skipped.is_empty() {
353        format!(", skipping {} that already exist", skipped.len())
354    } else {
355        String::new()
356    };
357    let preview = MutationPlan {
358        preview_action: format!(
359            "Import {} rule(s) from {}{qualifier}",
360            bundle.rules.len(),
361            path.display()
362        ),
363        preview_details: details,
364        targets: bundle
365            .rules
366            .iter()
367            .filter_map(|r| r.rule_id().ok().map(str::to_owned))
368            .collect(),
369    };
370
371    let ndjson = match format {
372        Format::Yaml => codec::encode_ndjson(&bundle.rules)?,
373        Format::Ndjson => codec::encode_bundle(&bundle)?,
374    };
375
376    Ok(ImportPlan {
377        preview,
378        ndjson,
379        total,
380        skipped,
381    })
382}
383
384fn retain_referenced_exception_objects(bundle: &mut codec::Bundle) {
385    let wanted: std::collections::BTreeSet<ListKey> = bundle
386        .rules
387        .iter()
388        .flat_map(exception_refs)
389        .map(|reference| ListKey {
390            list_id: reference.list_id,
391            namespace_type: reference.namespace_type,
392        })
393        .collect();
394
395    bundle
396        .lists
397        .retain(|list| list.key().is_ok_and(|key| wanted.contains(&key)));
398    bundle.items.retain(|item| {
399        item.list_id().is_ok_and(|list_id| {
400            wanted.contains(&ListKey {
401                list_id: list_id.to_string(),
402                namespace_type: item.namespace_type().to_string(),
403            })
404        })
405    });
406}
407
408fn add_upload_pointer_placeholders(bundle: &mut codec::Bundle) {
409    for rule in &mut bundle.rules {
410        let Some(Value::Array(references)) = rule.as_map_mut().get_mut("exceptions_list") else {
411            continue;
412        };
413        for reference in references {
414            let Value::Object(reference) = reference else {
415                continue;
416            };
417            if reference.get("list_id").is_some_and(Value::is_string) {
418                reference.insert(
419                    "id".to_string(),
420                    Value::String(EXCEPTION_POINTER_PLACEHOLDER.to_string()),
421                );
422            }
423        }
424    }
425}
426
427pub async fn apply_import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
428    if ndjson.is_empty() {
429        return Ok(ImportReport {
430            succeeded: json!(0),
431            failed: json!([]),
432        });
433    }
434
435    let response = rules::import(t, ndjson, overwrite).await?;
436
437    crate::ops::decode_import_report(&response, "rules")
438}
439
440async fn fetch_hits(
441    transport: &Transport,
442    space: &str,
443    preview_id: &str,
444    sample: usize,
445) -> Result<rules::PreviewHits> {
446    let first = rules::preview_hits(transport, space, preview_id, sample).await?;
447    if first.total > 0 {
448        return Ok(first);
449    }
450    tokio::time::sleep(std::time::Duration::from_secs(1)).await;
451    rules::preview_hits(transport, space, preview_id, sample).await
452}
453
454pub async fn preview_rule(
455    t: &Transport,
456    source: &str,
457    invocations: u32,
458    sample: u32,
459    space: &str,
460) -> Result<PreviewReport> {
461    let path = Path::new(source);
462
463    let rule = if path.exists() {
464        let body = std::fs::read_to_string(path).map_err(|e| {
465            Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display()))
466        })?;
467        let rules = match Format::from_path(path) {
468            Format::Yaml => codec::decode_yaml(&body)?,
469            Format::Ndjson => codec::decode_ndjson(&body)?.0,
470        };
471        rules.into_iter().next().ok_or_else(|| {
472            Error::new(
473                ErrorKind::Error,
474                format!("{} contains no rules", path.display()),
475            )
476        })?
477    } else {
478        selection::to_rule(t, source).await?.1
479    };
480
481    let timeframe_end = now_rfc3339();
482    let result = rules::preview(t, &rule, invocations, &timeframe_end).await?;
483
484    let (hits, hits_error, sample_hits) = match &result.preview_id {
485        None => (
486            None,
487            Some("the server returned no preview_id".to_string()),
488            Vec::new(),
489        ),
490        Some(preview_id) => match fetch_hits(t, space, preview_id, sample as usize).await {
491            Ok(h) => (Some(h.total), None, h.sample),
492            Err(e) => (None, Some(e.message), Vec::new()),
493        },
494    };
495
496    Ok(PreviewReport {
497        rule: rule.name().to_string(),
498        preview_id: result.preview_id,
499        invocations,
500        hits,
501        errors: result.errors,
502        warnings: result.warnings,
503        hits_error,
504        sample: sample_hits,
505    })
506}
507
508fn now_rfc3339() -> String {
509    use std::time::{SystemTime, UNIX_EPOCH};
510    let secs = SystemTime::now()
511        .duration_since(UNIX_EPOCH)
512        .unwrap_or_default()
513        .as_secs();
514    let days = secs / 86_400;
515    let rem = secs % 86_400;
516    let (y, m, d) = civil_from_days(days as i64);
517    format!(
518        "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.000Z",
519        rem / 3600,
520        (rem % 3600) / 60,
521        rem % 60
522    )
523}
524
525fn civil_from_days(z: i64) -> (i64, u32, u32) {
526    let z = z + 719_468;
527    let era = z.div_euclid(146_097);
528    let doe = z.rem_euclid(146_097);
529    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
530    let y = yoe + era * 400;
531    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
532    let mp = (5 * doy + 2) / 153;
533    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
534    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
535    (if m <= 2 { y + 1 } else { y }, m, d)
536}
537
538#[cfg(test)]
539mod date_tests {
540    use super::*;
541
542    #[test]
543    fn civil_from_days_matches_independently_computed_epoch_days() {
544        let cases = [
545            (0, (1970, 1, 1), "epoch"),
546            (19782, (2024, 2, 29), "leap day"),
547            (11017, (2000, 3, 1), "century leap year (2000 % 400 == 0)"),
548            (20818, (2026, 12, 31), "year end"),
549            (20819, (2027, 1, 1), "year rollover"),
550            (47541, (2100, 3, 1), "century non-leap (2100 % 400 != 0)"),
551            (
552                47540,
553                (2100, 2, 28),
554                "day before the century non-leap rollover",
555            ),
556        ];
557
558        for (day, expected, label) in cases {
559            assert_eq!(civil_from_days(day), expected, "{label}: day {day}");
560        }
561    }
562
563    #[test]
564    fn now_rfc3339_matches_the_shape_the_api_requires() {
565        let s = now_rfc3339();
566        let bytes = s.as_bytes();
567
568        assert_eq!(s.len(), 24, "{s}");
569        assert!(bytes[4] == b'-' && bytes[7] == b'-', "{s}");
570        assert_eq!(bytes[10], b'T', "{s}");
571        assert!(bytes[13] == b':' && bytes[16] == b':', "{s}");
572        assert_eq!(bytes[19], b'.', "{s}");
573        assert_eq!(&s[20..], "000Z", "{s}");
574        assert!(
575            s[..19]
576                .chars()
577                .enumerate()
578                .all(|(i, c)| { matches!(i, 4 | 7 | 10 | 13 | 16) || c.is_ascii_digit() }),
579            "every non-separator position must be a digit: {s}"
580        );
581    }
582}