1mod 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
25const EXCEPTION_POINTER_PLACEHOLDER: &str = "00000000-0000-0000-0000-000000000000";
29
30#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct RuleListReport {
33 pub total: usize,
34 pub rules: Vec<Rule>,
35}
36
37#[derive(Debug, Clone)]
39pub struct RulePage {
40 pub total: u64,
41 pub rules: Vec<Rule>,
42}
43
44#[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#[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#[derive(Debug, Clone, PartialEq, Serialize)]
68pub struct ValidateReport {
69 pub valid: bool,
70 pub count: usize,
71 pub rules: Vec<RuleValidation>,
72}
73
74#[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
96pub 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
106fn 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
121pub 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
127pub 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 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 Err(e) => failures.push(format!("rule at index {i}: {}", e.message)),
164 }
165 }
166
167 if !failures.is_empty() {
168 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
180async 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
231pub 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
250pub 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 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 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 Format::Ndjson => codec::encode_bundle(&bundle)?,
322 };
323
324 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
339pub 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 Format::Yaml => codec::Bundle {
360 rules: codec::decode_yaml(&body)?,
361 ..Default::default()
362 },
363 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 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
449fn 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
478fn 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
500pub async fn apply_import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
502 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 crate::ops::decode_import_report(&response, "rules")
515}
516
517async 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
536pub 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 let timeframe_end = now_rfc3339();
567 let result = rules::preview(t, &rule, invocations, &timeframe_end).await?;
568
569 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 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
613fn 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 #[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 (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 #[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}