1mod 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};
10
11use crate::codec::{self, Bundle};
12use crate::model::Rule;
13use crate::normalize;
14use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17
18const BASE: &str = "/api/detection_engine/rules";
19
20const RESULT_WINDOW: u32 = 10_000;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum RuleSource {
37 Custom,
38 Customized,
39 Prebuilt,
40 #[default]
41 All,
42}
43
44impl RuleSource {
45 pub fn clause(&self) -> Option<&'static str> {
48 match self {
49 RuleSource::Custom => Some("alert.attributes.params.immutable: false"),
50 RuleSource::Prebuilt => Some("alert.attributes.params.immutable: true"),
51 RuleSource::Customized => Some("alert.attributes.params.ruleSource.isCustomized: true"),
52 RuleSource::All => None,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Default)]
58pub struct RuleFilter {
59 pub source: RuleSource,
60 pub enabled: Option<bool>,
61 pub rule_type: Option<String>,
62 pub severity: Option<String>,
63 pub tag: Option<String>,
64 pub name: Option<String>,
67 pub query: Option<String>,
69 pub search: Option<String>,
72}
73
74impl RuleFilter {
75 pub fn to_kql(&self) -> Option<String> {
77 let mut parts: Vec<String> = Vec::new();
78 if let Some(clause) = self.source.clause() {
79 parts.push(clause.to_string());
80 }
81 if let Some(v) = self.enabled {
82 parts.push(format!("alert.attributes.enabled: {v}"));
83 }
84 if let Some(v) = &self.name {
85 parts.push(format!("alert.attributes.name: \"{}\"", kql_escape(v)));
86 }
87 if let Some(v) = &self.rule_type {
88 parts.push(format!(
89 "alert.attributes.params.type: \"{}\"",
90 kql_escape(v)
91 ));
92 }
93 if let Some(v) = &self.severity {
94 parts.push(format!(
95 "alert.attributes.params.severity: \"{}\"",
96 kql_escape(v)
97 ));
98 }
99 if let Some(v) = &self.tag {
100 parts.push(format!("alert.attributes.tags: \"{}\"", kql_escape(v)));
101 }
102 if let Some(v) = &self.search {
103 parts.push(format!(
104 "(alert.attributes.name: \"*{}*\" OR alert.attributes.tags: \"{}\")",
105 kql_escape_wildcard(v),
106 kql_escape(v)
107 ));
108 }
109 if let Some(v) = &self.query {
110 parts.push(v.clone());
111 }
112 (!parts.is_empty()).then(|| parts.join(" AND "))
113 }
114}
115
116pub(crate) fn kql_escape(value: &str) -> String {
124 value.replace('\\', "\\\\").replace('"', "\\\"")
125}
126
127pub(crate) fn kql_escape_wildcard(value: &str) -> String {
134 kql_escape(value).replace('*', "\\*").replace('?', "\\?")
135}
136
137fn rule_id_query(rule_ids: &[String]) -> String {
139 rule_ids
140 .iter()
141 .map(|id| format!("alert.attributes.params.ruleId: \"{}\"", kql_escape(id)))
142 .collect::<Vec<_>>()
143 .join(" OR ")
144}
145
146pub async fn find_page(
147 t: &Transport,
148 filter: &RuleFilter,
149 page: u32,
150 per_page: u32,
151) -> Result<(Vec<Rule>, u64)> {
152 if filter.source != RuleSource::All {
153 t.require_feature(Feature::RuleSourceScoping).await?;
154 }
155 let mut path = format!("{BASE}/_find?page={page}&per_page={per_page}");
156 if let Some(kql) = filter.to_kql() {
157 path.push_str(&format!("&filter={}", urlencode(&kql)));
158 }
159
160 let body = t.get(&path).await?;
161 decode_find(&body)
162}
163
164pub fn decode_find(body: &Value) -> Result<(Vec<Rule>, u64)> {
167 let map = body
168 .as_object()
169 .ok_or_else(|| find_error("response", "must be a JSON object"))?;
170 let data = map
171 .get("data")
172 .and_then(Value::as_array)
173 .ok_or_else(|| find_error("data", "must be an array"))?;
174 let total = required_u64(map, "total")?;
175 let page = required_u64(map, "page")?;
176 if page == 0 {
177 return Err(find_error("page", "must be greater than zero"));
178 }
179 let per_page = required_u64(map, "perPage")?;
180 if per_page == 0 {
181 return Err(find_error("perPage", "must be greater than zero"));
182 }
183
184 let data_len = data.len() as u64;
185 if data_len > total {
186 return Err(find_error(
187 "data.len() > total",
188 format!("{data_len} returned records exceed total {total}"),
189 ));
190 }
191 if data_len > per_page {
192 return Err(find_error(
193 "data.len() > perPage",
194 format!("{data_len} returned records exceed perPage {per_page}"),
195 ));
196 }
197
198 let rules = data
199 .iter()
200 .cloned()
201 .map(Rule::from_value)
202 .collect::<Result<Vec<_>>>()?;
203 Ok((rules, total))
204}
205
206fn find_error(field: &str, detail: impl std::fmt::Display) -> Error {
207 Error::new(
208 ErrorKind::Http,
209 format!("decoding rule _find response field {field}: {detail}"),
210 )
211}
212
213fn required_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
214 map.get(field)
215 .and_then(Value::as_u64)
216 .ok_or_else(|| find_error(field, "must be an unsigned integer"))
217}
218
219pub(crate) struct SourceTotals {
221 pub custom: u64,
222 pub prebuilt: u64,
223 pub all: u64,
224}
225
226impl SourceTotals {
227 fn is_exhaustive(&self) -> bool {
228 self.custom.checked_add(self.prebuilt) == Some(self.all)
229 }
230}
231
232pub(crate) async fn verify_source_partition(t: &Transport) -> Result<SourceTotals> {
239 let (_, custom) = find_page(
240 t,
241 &RuleFilter {
242 source: RuleSource::Custom,
243 ..Default::default()
244 },
245 1,
246 1,
247 )
248 .await?;
249 let (_, prebuilt) = find_page(
250 t,
251 &RuleFilter {
252 source: RuleSource::Prebuilt,
253 ..Default::default()
254 },
255 1,
256 1,
257 )
258 .await?;
259 let (_, all) = find_page(t, &RuleFilter::default(), 1, 1).await?;
260
261 let totals = SourceTotals {
262 custom,
263 prebuilt,
264 all,
265 };
266 if !totals.is_exhaustive() {
267 return Err(Error::new(
268 ErrorKind::Unsupported,
269 format!(
270 "the immutable source partition is not exhaustive: custom={}, \
271 prebuilt={}, all={}. The field may be absent on this stack; \
272 re-run with --source all to read the corpus.",
273 totals.custom, totals.prebuilt, totals.all
274 ),
275 ));
276 }
277
278 Ok(totals)
279}
280
281const RULE_TYPES: [&str; 7] = [
286 "query",
287 "eql",
288 "esql",
289 "threshold",
290 "threat_match",
291 "machine_learning",
292 "new_terms",
293];
294
295pub async fn find_all(t: &Transport, filter: &RuleFilter) -> Result<Vec<Rule>> {
304 let (rules, total) = find_page(t, filter, 1, RESULT_WINDOW).await?;
305
306 if total <= u64::from(RESULT_WINDOW) {
307 if (rules.len() as u64) < total {
308 return Err(short_read(total, rules.len()));
309 }
310 return Ok(rules);
311 }
312
313 let types: Vec<&str> = match &filter.rule_type {
316 Some(t) => vec![t.as_str()],
317 None => RULE_TYPES.to_vec(),
318 };
319
320 let mut collected: Vec<Rule> = Vec::new();
321 let mut summed: u64 = 0;
322
323 for rule_type in types {
324 let mut type_filter = filter.clone();
325 type_filter.rule_type = Some(rule_type.to_string());
326
327 let slice_total = if filter.rule_type.is_some() {
330 total
331 } else {
332 let (slice_rules, slice_total) = find_page(t, &type_filter, 1, RESULT_WINDOW).await?;
333
334 if slice_total <= u64::from(RESULT_WINDOW) {
335 if (slice_rules.len() as u64) < slice_total {
336 return Err(short_read(slice_total, slice_rules.len()));
337 }
338 summed += slice_total;
339 collected.extend(slice_rules);
340 continue;
341 }
342 slice_total
343 };
344
345 if filter.enabled.is_some() {
348 return Err(oversized(slice_total));
349 }
350
351 for enabled in [true, false] {
352 let mut enabled_filter = type_filter.clone();
353 enabled_filter.enabled = Some(enabled);
354 let (enabled_rules, enabled_total) =
355 find_page(t, &enabled_filter, 1, RESULT_WINDOW).await?;
356 if enabled_total > u64::from(RESULT_WINDOW) {
357 return Err(oversized(enabled_total));
358 }
359 if (enabled_rules.len() as u64) < enabled_total {
360 return Err(short_read(enabled_total, enabled_rules.len()));
361 }
362 summed += enabled_total;
363 collected.extend(enabled_rules);
364 }
365 }
366
367 if summed != total {
370 return Err(Error::new(
371 ErrorKind::Http,
372 format!(
373 "the server counted {total} rules across the corpus but the type slices \
374 sum to {summed}. Refusing a partial corpus: a rule type added by a newer \
375 stack version would otherwise read as zero in every pull and diff."
376 ),
377 ));
378 }
379
380 Ok(collected)
381}
382
383fn short_read(counted: u64, returned: usize) -> Error {
386 Error::new(
387 ErrorKind::Http,
388 format!(
389 "the server counted {counted} rules and returned {returned}. Refusing a partial \
390 corpus: a short read is indistinguishable from rules having been deleted."
391 ),
392 )
393}
394
395fn oversized(count: u64) -> Error {
398 Error::new(
399 ErrorKind::Unsupported,
400 format!(
401 "{count} rules match, more than the {RESULT_WINDOW} a single search can return \
402 even after partitioning by type and enabled. Narrow the selection with a \
403 filter or a tag."
404 ),
405 )
406}
407
408const ID_CHUNK: usize = 50;
413
414pub async fn find_by_rule_ids(t: &Transport, rule_ids: &[String]) -> Result<Vec<Rule>> {
419 let mut found = Vec::with_capacity(rule_ids.len());
420 for chunk in rule_ids.chunks(ID_CHUNK) {
421 let filter = RuleFilter {
422 query: Some(rule_id_query(chunk)),
423 ..Default::default()
424 };
425 let (rules, _) = find_page(t, &filter, 1, RESULT_WINDOW).await?;
426 found.extend(rules);
427 }
428 Ok(found)
429}
430
431pub async fn get(t: &Transport, rule_id: &str) -> Result<Rule> {
432 let body = t
433 .get(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
434 .await?;
435 Rule::from_value(body)
436}
437
438pub async fn create(t: &Transport, rule: &Rule) -> Result<Rule> {
439 let mut payload = rule.clone();
440 normalize::strip_volatile(&mut payload);
441 let response = t
442 .post(BASE, Some(&Value::Object(payload.as_map().clone())))
443 .await?;
444 Rule::from_value(response)
445}
446
447pub async fn update(t: &Transport, rule: &Rule) -> Result<Rule> {
448 let mut payload = rule.clone();
449 normalize::strip_volatile(&mut payload);
450 let response = t
451 .put(BASE, &Value::Object(payload.as_map().clone()))
452 .await?;
453 Rule::from_value(response)
454}
455
456pub async fn patch(t: &Transport, rule_id: &str, patch: &Value) -> Result<Rule> {
457 let mut body = patch.as_object().cloned().unwrap_or_default();
458 body.insert("rule_id".into(), json!(rule_id));
459 let response = t.patch(BASE, &Value::Object(body)).await?;
461 Rule::from_value(response)
462}
463
464pub async fn delete(t: &Transport, rule_id: &str) -> Result<Rule> {
465 let body = t
466 .delete(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
467 .await?;
468 Rule::from_value(body)
469}
470
471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472pub enum BulkAction {
473 Enable,
474 Disable,
475 Delete,
476}
477
478impl BulkAction {
479 pub fn as_str(&self) -> &'static str {
480 match self {
481 Self::Enable => "enable",
482 Self::Disable => "disable",
483 Self::Delete => "delete",
484 }
485 }
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Default)]
489pub struct BulkOutcome {
490 pub succeeded: u64,
491 pub failed: u64,
492 pub skipped: u64,
493 pub total: u64,
494}
495
496pub fn decode_bulk_outcome(body: &Value) -> Result<BulkOutcome> {
501 let map = body
502 .as_object()
503 .ok_or_else(|| bulk_error("response", "must be a JSON object"))?;
504 let summary = map
505 .get("attributes")
506 .and_then(|attributes| attributes.get("summary"))
507 .and_then(Value::as_object)
508 .ok_or_else(|| bulk_error("attributes.summary", "must be an object"))?;
509 let succeeded = bulk_u64(summary, "succeeded")?;
510 let failed = bulk_u64(summary, "failed")?;
511 let skipped = bulk_u64(summary, "skipped")?;
512 let total = bulk_u64(summary, "total")?;
513 let applied = succeeded
514 .checked_add(failed)
515 .and_then(|count| count.checked_add(skipped))
516 .ok_or_else(|| bulk_error("total", "counter sum overflows u64"))?;
517 if applied != total {
518 return Err(bulk_error(
519 "total",
520 format!(
521 "{total} does not equal succeeded {succeeded} + failed {failed} + skipped {skipped}"
522 ),
523 ));
524 }
525 Ok(BulkOutcome {
526 succeeded,
527 failed,
528 skipped,
529 total,
530 })
531}
532
533fn bulk_error(field: &str, detail: impl std::fmt::Display) -> Error {
534 Error::new(
535 ErrorKind::Http,
536 format!("decoding rule _bulk_action response field {field}: {detail}"),
537 )
538}
539
540fn bulk_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
541 map.get(field)
542 .and_then(Value::as_u64)
543 .ok_or_else(|| bulk_error(field, "must be an unsigned integer"))
544}
545
546pub async fn bulk_by_rule_ids(
547 t: &Transport,
548 action: BulkAction,
549 rule_ids: &[String],
550 dry_run: bool,
551) -> Result<BulkOutcome> {
552 if rule_ids.is_empty() {
554 return Ok(BulkOutcome::default());
555 }
556
557 let path = if dry_run {
558 format!("{BASE}/_bulk_action?dry_run=true")
559 } else {
560 format!("{BASE}/_bulk_action")
561 };
562 let body = json!({ "action": action.as_str(), "query": rule_id_query(rule_ids) });
563
564 let response = t.post(&path, Some(&body)).await?;
565 decode_bulk_outcome(&response)
566}
567
568pub async fn export(t: &Transport, rule_ids: Option<&[String]>) -> Result<Bundle> {
577 let body = rule_ids.map(|ids| {
578 json!({
579 "objects": ids
580 .iter()
581 .map(|id| json!({"rule_id": id}))
582 .collect::<Vec<_>>()
583 })
584 });
585 let text = t
586 .post_text(&format!("{BASE}/_export"), body.as_ref())
587 .await?;
588 codec::decode_bundle(&text)
589}
590
591const EXISTENCE_CHUNK: usize = 50;
594
595pub async fn existing_rule_ids(
600 t: &Transport,
601 rule_ids: &[String],
602) -> Result<std::collections::BTreeSet<String>> {
603 let mut found = std::collections::BTreeSet::new();
605 if rule_ids.is_empty() {
606 return Ok(found);
607 }
608
609 for chunk in rule_ids.chunks(EXISTENCE_CHUNK) {
610 let path = format!(
611 "{BASE}/_find?page=1&per_page={}&filter={}",
612 chunk.len(),
613 urlencode(&rule_id_query(chunk))
614 );
615 let (rules, _) = decode_find(&t.get(&path).await?)?;
616 for r in rules {
617 if let Ok(id) = r.rule_id() {
618 found.insert(id.to_string());
619 }
620 }
621 }
622
623 Ok(found)
624}
625
626pub async fn import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
627 t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
628 .await
629}
630
631#[cfg(test)]
632mod tests {
633 use super::*;
634
635 #[test]
637 fn each_source_maps_to_its_measured_filter() {
638 assert_eq!(
639 RuleSource::Custom.clause(),
640 Some("alert.attributes.params.immutable: false")
641 );
642 assert_eq!(
643 RuleSource::Customized.clause(),
644 Some("alert.attributes.params.ruleSource.isCustomized: true")
645 );
646 assert_eq!(RuleSource::All.clause(), None, "all adds no clause");
647 }
648
649 #[test]
650 fn a_source_clause_combines_with_other_filters() {
651 let f = RuleFilter {
652 source: RuleSource::Custom,
653 tag: Some("prod".into()),
654 ..Default::default()
655 };
656 let kql = f.to_kql().unwrap();
657 assert!(kql.contains("immutable: false"), "{kql}");
658 assert!(kql.contains("prod"), "{kql}");
659 assert!(
660 kql.contains(" AND "),
661 "clauses combine, they do not replace: {kql}"
662 );
663 }
664
665 #[test]
666 fn kql_escape_doubles_a_lone_backslash() {
667 assert_eq!(kql_escape("a\\b"), "a\\\\b");
668 }
669
670 #[test]
671 fn kql_escape_escapes_a_lone_quote() {
672 let mut expected = String::from("a");
673 expected.push('\\');
674 expected.push('"');
675 expected.push('b');
676 assert_eq!(kql_escape("a\"b"), expected);
677 }
678
679 #[test]
682 fn kql_escape_wildcard_escapes_kql_wildcards_after_kql_escape() {
683 assert_eq!(kql_escape_wildcard("*"), "\\*");
684 assert_eq!(kql_escape_wildcard("?"), "\\?");
685 assert_eq!(kql_escape_wildcard("a\\b\"c"), kql_escape("a\\b\"c"));
686 }
687
688 #[test]
691 fn kql_escape_orders_backslash_before_quote() {
692 let mut input = String::new();
693 input.push('\\');
694 input.push('"');
695
696 let escaped = kql_escape(&input);
697
698 let mut expected = "\\".repeat(3);
701 expected.push('"');
702 assert_eq!(escaped, expected);
703 }
704
705 #[test]
706 fn rule_id_query_escapes_a_quote_in_the_id() {
707 let mut id = String::from("x");
708 id.push('"');
709 id.push('y');
710
711 let q = rule_id_query(&[id]);
712
713 let mut expected = String::from("alert.attributes.params.ruleId: \"x");
714 expected.push('\\');
715 expected.push('"');
716 expected.push_str("y\"");
717 assert_eq!(q, expected);
718 assert!(
719 !q.contains(" OR "),
720 "a single id must produce exactly one clause: {q}"
721 );
722 }
723
724 #[test]
725 fn rule_id_query_neutralizes_a_kql_injection_payload() {
726 let payload = "x\" or alert.attributes.enabled: true or \"";
727 let q = rule_id_query(&[payload.to_string()]);
728 assert!(
729 !q.contains("\" or alert.attributes.enabled: true or \""),
730 "the injected quote must not close the literal: {q}"
731 );
732 }
733
734 #[test]
735 fn to_kql_escapes_a_quote_in_the_tag() {
736 let f = RuleFilter {
737 tag: Some("a\"b".into()),
738 ..Default::default()
739 };
740 let kql = f.to_kql().unwrap();
741
742 let mut expected = String::from("alert.attributes.tags: \"a");
743 expected.push('\\');
744 expected.push('"');
745 expected.push_str("b\"");
746 assert_eq!(kql, expected);
747 }
748}