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};
10mod window;
11pub use window::{WINDOW_MAX_RULES, WINDOW_PAGE, find_window};
12
13use crate::codec::{self, Bundle};
14use crate::model::Rule;
15use crate::normalize;
16use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20const BASE: &str = "/api/detection_engine/rules";
21
22const RESULT_WINDOW: u32 = 10_000;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum RuleSource {
39 Custom,
40 Customized,
41 Prebuilt,
42 #[default]
43 All,
44}
45
46impl RuleSource {
47 pub fn clause(&self) -> Option<&'static str> {
50 match self {
51 RuleSource::Custom => Some("alert.attributes.params.immutable: false"),
52 RuleSource::Prebuilt => Some("alert.attributes.params.immutable: true"),
53 RuleSource::Customized => Some("alert.attributes.params.ruleSource.isCustomized: true"),
54 RuleSource::All => None,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Default)]
60pub struct RuleFilter {
61 pub source: RuleSource,
62 pub enabled: Option<bool>,
63 pub rule_type: Option<String>,
64 pub severity: Option<String>,
65 pub tag: Option<String>,
66 pub name: Option<String>,
69 pub query: Option<String>,
71 pub search: Option<String>,
74 pub technique: Option<String>,
76 pub tactic: Option<String>,
78}
79
80impl RuleFilter {
81 pub fn to_kql(&self) -> Option<String> {
83 let mut parts: Vec<String> = Vec::new();
84 if let Some(clause) = self.source.clause() {
85 parts.push(clause.to_string());
86 }
87 if let Some(v) = self.enabled {
88 parts.push(format!("alert.attributes.enabled: {v}"));
89 }
90 if let Some(v) = &self.name {
91 parts.push(format!("alert.attributes.name: \"{}\"", kql_escape(v)));
92 }
93 if let Some(v) = &self.rule_type {
94 parts.push(format!(
95 "alert.attributes.params.type: \"{}\"",
96 kql_escape(v)
97 ));
98 }
99 if let Some(v) = &self.severity {
100 parts.push(format!(
101 "alert.attributes.params.severity: \"{}\"",
102 kql_escape(v)
103 ));
104 }
105 if let Some(v) = &self.tag {
106 parts.push(format!("alert.attributes.tags: \"{}\"", kql_escape(v)));
107 }
108 if let Some(v) = &self.search {
109 parts.push(format!(
110 "(alert.attributes.name: \"*{}*\" OR alert.attributes.tags: \"{}\")",
111 kql_escape_wildcard(v),
112 kql_escape(v)
113 ));
114 }
115 if let Some(v) = &self.technique {
116 let sub = if v.contains('.') { ".subtechnique" } else { "" };
118 parts.push(format!(
119 "alert.attributes.params.threat.technique{sub}.id: \"{}\"",
120 kql_escape(v)
121 ));
122 }
123 if let Some(v) = &self.tactic {
124 parts.push(format!(
125 "alert.attributes.params.threat.tactic.id: \"{}\"",
126 kql_escape(v)
127 ));
128 }
129 if let Some(v) = &self.query {
130 parts.push(v.clone());
131 }
132 (!parts.is_empty()).then(|| parts.join(" AND "))
133 }
134
135 pub fn validate_threat(&self) -> Result<()> {
139 let technique = |v: &str| has_shape(v, "T0000") || has_shape(v, "T0000.000");
140 let tactic = |v: &str| has_shape(v, "TA0000");
141 let message = if !self.technique.as_deref().is_none_or(technique) {
142 "technique must be T and four digits, optionally a period and three digits"
143 } else if !self.tactic.as_deref().is_none_or(tactic) {
144 "tactic must be TA and four digits"
145 } else {
146 return Ok(());
147 };
148 Err(Error::new(ErrorKind::Error, message))
149 }
150}
151
152fn has_shape(value: &str, shape: &str) -> bool {
155 let same = |(v, s): (u8, u8)| v == s || (s == b'0' && v.is_ascii_digit());
156 value.len() == shape.len() && value.bytes().zip(shape.bytes()).all(same)
157}
158
159pub(crate) fn kql_escape(value: &str) -> String {
167 value.replace('\\', "\\\\").replace('"', "\\\"")
168}
169
170pub(crate) fn kql_escape_wildcard(value: &str) -> String {
177 kql_escape(value).replace('*', "\\*").replace('?', "\\?")
178}
179
180fn rule_id_query(rule_ids: &[String]) -> String {
182 rule_ids
183 .iter()
184 .map(|id| format!("alert.attributes.params.ruleId: \"{}\"", kql_escape(id)))
185 .collect::<Vec<_>>()
186 .join(" OR ")
187}
188
189pub async fn find_page(
190 t: &Transport,
191 filter: &RuleFilter,
192 page: u32,
193 per_page: u32,
194) -> Result<(Vec<Rule>, u64)> {
195 find(t, filter, page, per_page, "").await
196}
197
198pub async fn find_first_by_name(
201 t: &Transport,
202 filter: &RuleFilter,
203 per_page: u32,
204) -> Result<(Vec<Rule>, u64)> {
205 let (rules, total) = find(t, filter, 1, per_page, "&sort_field=name&sort_order=asc").await?;
206 if (rules.len() as u64) < total.min(u64::from(per_page)) {
207 return Err(short_read(total, rules.len()));
208 }
209 Ok((rules, total))
210}
211
212async fn find(
213 t: &Transport,
214 filter: &RuleFilter,
215 page: u32,
216 per_page: u32,
217 sort: &str,
218) -> Result<(Vec<Rule>, u64)> {
219 filter.validate_threat()?;
220 if filter.source != RuleSource::All {
221 t.require_feature(Feature::RuleSourceScoping).await?;
222 }
223 let mut path = format!("{BASE}/_find?page={page}&per_page={per_page}{sort}");
224 if let Some(kql) = filter.to_kql() {
225 path.push_str(&format!("&filter={}", urlencode(&kql)));
226 }
227
228 let body = t.get(&path).await?;
229 decode_find(&body)
230}
231
232pub fn decode_find(body: &Value) -> Result<(Vec<Rule>, u64)> {
235 let map = body
236 .as_object()
237 .ok_or_else(|| find_error("response", "must be a JSON object"))?;
238 let data = map
239 .get("data")
240 .and_then(Value::as_array)
241 .ok_or_else(|| find_error("data", "must be an array"))?;
242 let total = required_u64(map, "total")?;
243 let page = required_u64(map, "page")?;
244 if page == 0 {
245 return Err(find_error("page", "must be greater than zero"));
246 }
247 let per_page = required_u64(map, "perPage")?;
248 if per_page == 0 {
249 return Err(find_error("perPage", "must be greater than zero"));
250 }
251
252 let data_len = data.len() as u64;
253 if data_len > total {
254 return Err(find_error(
255 "data.len() > total",
256 format!("{data_len} returned records exceed total {total}"),
257 ));
258 }
259 if data_len > per_page {
260 return Err(find_error(
261 "data.len() > perPage",
262 format!("{data_len} returned records exceed perPage {per_page}"),
263 ));
264 }
265
266 let rules = data
267 .iter()
268 .cloned()
269 .map(Rule::from_value)
270 .collect::<Result<Vec<_>>>()?;
271 Ok((rules, total))
272}
273
274fn find_error(field: &str, detail: impl std::fmt::Display) -> Error {
275 Error::new(
276 ErrorKind::Http,
277 format!("decoding rule _find response field {field}: {detail}"),
278 )
279}
280
281fn required_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
282 map.get(field)
283 .and_then(Value::as_u64)
284 .ok_or_else(|| find_error(field, "must be an unsigned integer"))
285}
286
287pub(crate) struct SourceTotals {
289 pub custom: u64,
290 pub prebuilt: u64,
291 pub all: u64,
292}
293
294impl SourceTotals {
295 fn is_exhaustive(&self) -> bool {
296 self.custom.checked_add(self.prebuilt) == Some(self.all)
297 }
298}
299
300pub(crate) async fn verify_source_partition(t: &Transport) -> Result<SourceTotals> {
307 let (_, custom) = find_page(
308 t,
309 &RuleFilter {
310 source: RuleSource::Custom,
311 ..Default::default()
312 },
313 1,
314 1,
315 )
316 .await?;
317 let (_, prebuilt) = find_page(
318 t,
319 &RuleFilter {
320 source: RuleSource::Prebuilt,
321 ..Default::default()
322 },
323 1,
324 1,
325 )
326 .await?;
327 let (_, all) = find_page(t, &RuleFilter::default(), 1, 1).await?;
328
329 let totals = SourceTotals {
330 custom,
331 prebuilt,
332 all,
333 };
334 if !totals.is_exhaustive() {
335 return Err(Error::new(
336 ErrorKind::Unsupported,
337 format!(
338 "the immutable source partition is not exhaustive: custom={}, \
339 prebuilt={}, all={}. The field may be absent on this stack; \
340 re-run with --source all to read the corpus.",
341 totals.custom, totals.prebuilt, totals.all
342 ),
343 ));
344 }
345
346 Ok(totals)
347}
348
349const RULE_TYPES: [&str; 7] = [
354 "query",
355 "eql",
356 "esql",
357 "threshold",
358 "threat_match",
359 "machine_learning",
360 "new_terms",
361];
362
363pub async fn find_all(t: &Transport, filter: &RuleFilter) -> Result<Vec<Rule>> {
372 let (rules, total) = find_page(t, filter, 1, RESULT_WINDOW).await?;
373
374 if total <= u64::from(RESULT_WINDOW) {
375 if (rules.len() as u64) < total {
376 return Err(short_read(total, rules.len()));
377 }
378 return Ok(rules);
379 }
380
381 let types: Vec<&str> = match &filter.rule_type {
384 Some(t) => vec![t.as_str()],
385 None => RULE_TYPES.to_vec(),
386 };
387
388 let mut collected: Vec<Rule> = Vec::new();
389 let mut summed: u64 = 0;
390
391 for rule_type in types {
392 let mut type_filter = filter.clone();
393 type_filter.rule_type = Some(rule_type.to_string());
394
395 let slice_total = if filter.rule_type.is_some() {
398 total
399 } else {
400 let (slice_rules, slice_total) = find_page(t, &type_filter, 1, RESULT_WINDOW).await?;
401
402 if slice_total <= u64::from(RESULT_WINDOW) {
403 if (slice_rules.len() as u64) < slice_total {
404 return Err(short_read(slice_total, slice_rules.len()));
405 }
406 summed += slice_total;
407 collected.extend(slice_rules);
408 continue;
409 }
410 slice_total
411 };
412
413 if filter.enabled.is_some() {
416 return Err(oversized(slice_total));
417 }
418
419 for enabled in [true, false] {
420 let mut enabled_filter = type_filter.clone();
421 enabled_filter.enabled = Some(enabled);
422 let (enabled_rules, enabled_total) =
423 find_page(t, &enabled_filter, 1, RESULT_WINDOW).await?;
424 if enabled_total > u64::from(RESULT_WINDOW) {
425 return Err(oversized(enabled_total));
426 }
427 if (enabled_rules.len() as u64) < enabled_total {
428 return Err(short_read(enabled_total, enabled_rules.len()));
429 }
430 summed += enabled_total;
431 collected.extend(enabled_rules);
432 }
433 }
434
435 if summed != total {
438 return Err(Error::new(
439 ErrorKind::Http,
440 format!(
441 "the server counted {total} rules across the corpus but the type slices \
442 sum to {summed}. Refusing a partial corpus: a rule type added by a newer \
443 stack version would otherwise read as zero in every pull and diff."
444 ),
445 ));
446 }
447
448 Ok(collected)
449}
450
451fn short_read(counted: u64, returned: usize) -> Error {
454 Error::new(
455 ErrorKind::Http,
456 format!(
457 "the server counted {counted} rules and returned {returned}. Refusing a partial \
458 corpus: a short read is indistinguishable from rules having been deleted."
459 ),
460 )
461}
462
463fn oversized(count: u64) -> Error {
466 Error::new(
467 ErrorKind::Unsupported,
468 format!(
469 "{count} rules match, more than the {RESULT_WINDOW} a single search can return \
470 even after partitioning by type and enabled. Narrow the selection with a \
471 filter or a tag."
472 ),
473 )
474}
475
476const ID_CHUNK: usize = 50;
481
482pub async fn find_by_rule_ids(t: &Transport, rule_ids: &[String]) -> Result<Vec<Rule>> {
487 let mut found = Vec::with_capacity(rule_ids.len());
488 for chunk in rule_ids.chunks(ID_CHUNK) {
489 let filter = RuleFilter {
490 query: Some(rule_id_query(chunk)),
491 ..Default::default()
492 };
493 let (rules, _) = find_page(t, &filter, 1, RESULT_WINDOW).await?;
494 found.extend(rules);
495 }
496 Ok(found)
497}
498
499pub async fn get(t: &Transport, rule_id: &str) -> Result<Rule> {
500 let body = t
501 .get(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
502 .await?;
503 Rule::from_value(body)
504}
505
506pub async fn create(t: &Transport, rule: &Rule) -> Result<Rule> {
507 let mut payload = rule.clone();
508 normalize::strip_volatile(&mut payload);
509 let response = t
510 .post(BASE, Some(&Value::Object(payload.as_map().clone())))
511 .await?;
512 Rule::from_value(response)
513}
514
515pub async fn update(t: &Transport, rule: &Rule) -> Result<Rule> {
516 let mut payload = rule.clone();
517 normalize::strip_volatile(&mut payload);
518 let response = t
519 .put(BASE, &Value::Object(payload.as_map().clone()))
520 .await?;
521 Rule::from_value(response)
522}
523
524pub async fn patch(t: &Transport, rule_id: &str, patch: &Value) -> Result<Rule> {
525 let mut body = patch.as_object().cloned().unwrap_or_default();
526 body.insert("rule_id".into(), json!(rule_id));
527 let response = t.patch(BASE, &Value::Object(body)).await?;
529 Rule::from_value(response)
530}
531
532pub async fn delete(t: &Transport, rule_id: &str) -> Result<Rule> {
533 let body = t
534 .delete(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
535 .await?;
536 Rule::from_value(body)
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum BulkAction {
541 Enable,
542 Disable,
543 Delete,
544}
545
546impl BulkAction {
547 pub fn as_str(&self) -> &'static str {
548 match self {
549 Self::Enable => "enable",
550 Self::Disable => "disable",
551 Self::Delete => "delete",
552 }
553 }
554}
555
556#[derive(Debug, Clone, PartialEq, Eq, Default)]
557pub struct BulkOutcome {
558 pub succeeded: u64,
559 pub failed: u64,
560 pub skipped: u64,
561 pub total: u64,
562}
563
564pub fn decode_bulk_outcome(body: &Value) -> Result<BulkOutcome> {
569 let map = body
570 .as_object()
571 .ok_or_else(|| bulk_error("response", "must be a JSON object"))?;
572 let summary = map
573 .get("attributes")
574 .and_then(|attributes| attributes.get("summary"))
575 .and_then(Value::as_object)
576 .ok_or_else(|| bulk_error("attributes.summary", "must be an object"))?;
577 let succeeded = bulk_u64(summary, "succeeded")?;
578 let failed = bulk_u64(summary, "failed")?;
579 let skipped = bulk_u64(summary, "skipped")?;
580 let total = bulk_u64(summary, "total")?;
581 let applied = succeeded
582 .checked_add(failed)
583 .and_then(|count| count.checked_add(skipped))
584 .ok_or_else(|| bulk_error("total", "counter sum overflows u64"))?;
585 if applied != total {
586 return Err(bulk_error(
587 "total",
588 format!(
589 "{total} does not equal succeeded {succeeded} + failed {failed} + skipped {skipped}"
590 ),
591 ));
592 }
593 Ok(BulkOutcome {
594 succeeded,
595 failed,
596 skipped,
597 total,
598 })
599}
600
601fn bulk_error(field: &str, detail: impl std::fmt::Display) -> Error {
602 Error::new(
603 ErrorKind::Http,
604 format!("decoding rule _bulk_action response field {field}: {detail}"),
605 )
606}
607
608fn bulk_u64(map: &serde_json::Map<String, Value>, field: &str) -> Result<u64> {
609 map.get(field)
610 .and_then(Value::as_u64)
611 .ok_or_else(|| bulk_error(field, "must be an unsigned integer"))
612}
613
614pub async fn bulk_by_rule_ids(
615 t: &Transport,
616 action: BulkAction,
617 rule_ids: &[String],
618 dry_run: bool,
619) -> Result<BulkOutcome> {
620 if rule_ids.is_empty() {
622 return Ok(BulkOutcome::default());
623 }
624
625 let path = if dry_run {
626 format!("{BASE}/_bulk_action?dry_run=true")
627 } else {
628 format!("{BASE}/_bulk_action")
629 };
630 let body = json!({ "action": action.as_str(), "query": rule_id_query(rule_ids) });
631
632 let response = t.post(&path, Some(&body)).await?;
633 decode_bulk_outcome(&response)
634}
635
636pub async fn export(t: &Transport, rule_ids: Option<&[String]>) -> Result<Bundle> {
645 let body = rule_ids.map(|ids| {
646 json!({
647 "objects": ids
648 .iter()
649 .map(|id| json!({"rule_id": id}))
650 .collect::<Vec<_>>()
651 })
652 });
653 let text = t
654 .post_text(&format!("{BASE}/_export"), body.as_ref())
655 .await?;
656 codec::decode_bundle(&text)
657}
658
659const EXISTENCE_CHUNK: usize = 50;
662
663pub async fn existing_rule_ids(
668 t: &Transport,
669 rule_ids: &[String],
670) -> Result<std::collections::BTreeSet<String>> {
671 let mut found = std::collections::BTreeSet::new();
673 if rule_ids.is_empty() {
674 return Ok(found);
675 }
676
677 for chunk in rule_ids.chunks(EXISTENCE_CHUNK) {
678 let path = format!(
679 "{BASE}/_find?page=1&per_page={}&filter={}",
680 chunk.len(),
681 urlencode(&rule_id_query(chunk))
682 );
683 let (rules, _) = decode_find(&t.get(&path).await?)?;
684 for r in rules {
685 if let Ok(id) = r.rule_id() {
686 found.insert(id.to_string());
687 }
688 }
689 }
690
691 Ok(found)
692}
693
694pub async fn import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
695 t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
696 .await
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
705 fn each_source_maps_to_its_measured_filter() {
706 assert_eq!(
707 RuleSource::Custom.clause(),
708 Some("alert.attributes.params.immutable: false")
709 );
710 assert_eq!(
711 RuleSource::Customized.clause(),
712 Some("alert.attributes.params.ruleSource.isCustomized: true")
713 );
714 assert_eq!(RuleSource::All.clause(), None, "all adds no clause");
715 }
716
717 #[test]
718 fn a_source_clause_combines_with_other_filters() {
719 let f = RuleFilter {
720 source: RuleSource::Custom,
721 tag: Some("prod".into()),
722 ..Default::default()
723 };
724 let kql = f.to_kql().unwrap();
725 assert!(kql.contains("immutable: false"), "{kql}");
726 assert!(kql.contains("prod"), "{kql}");
727 assert!(
728 kql.contains(" AND "),
729 "clauses combine, they do not replace: {kql}"
730 );
731 }
732
733 #[test]
734 fn kql_escape_doubles_a_lone_backslash() {
735 assert_eq!(kql_escape("a\\b"), "a\\\\b");
736 }
737
738 #[test]
739 fn kql_escape_escapes_a_lone_quote() {
740 let mut expected = String::from("a");
741 expected.push('\\');
742 expected.push('"');
743 expected.push('b');
744 assert_eq!(kql_escape("a\"b"), expected);
745 }
746
747 #[test]
750 fn kql_escape_wildcard_escapes_kql_wildcards_after_kql_escape() {
751 assert_eq!(kql_escape_wildcard("*"), "\\*");
752 assert_eq!(kql_escape_wildcard("?"), "\\?");
753 assert_eq!(kql_escape_wildcard("a\\b\"c"), kql_escape("a\\b\"c"));
754 }
755
756 #[test]
759 fn kql_escape_orders_backslash_before_quote() {
760 let mut input = String::new();
761 input.push('\\');
762 input.push('"');
763
764 let escaped = kql_escape(&input);
765
766 let mut expected = "\\".repeat(3);
769 expected.push('"');
770 assert_eq!(escaped, expected);
771 }
772
773 #[test]
774 fn rule_id_query_escapes_a_quote_in_the_id() {
775 let mut id = String::from("x");
776 id.push('"');
777 id.push('y');
778
779 let q = rule_id_query(&[id]);
780
781 let mut expected = String::from("alert.attributes.params.ruleId: \"x");
782 expected.push('\\');
783 expected.push('"');
784 expected.push_str("y\"");
785 assert_eq!(q, expected);
786 assert!(
787 !q.contains(" OR "),
788 "a single id must produce exactly one clause: {q}"
789 );
790 }
791
792 #[test]
793 fn rule_id_query_neutralizes_a_kql_injection_payload() {
794 let payload = "x\" or alert.attributes.enabled: true or \"";
795 let q = rule_id_query(&[payload.to_string()]);
796 assert!(
797 !q.contains("\" or alert.attributes.enabled: true or \""),
798 "the injected quote must not close the literal: {q}"
799 );
800 }
801
802 #[test]
803 fn to_kql_escapes_a_quote_in_the_tag() {
804 let f = RuleFilter {
805 tag: Some("a\"b".into()),
806 ..Default::default()
807 };
808 let kql = f.to_kql().unwrap();
809
810 let mut expected = String::from("alert.attributes.tags: \"a");
811 expected.push('\\');
812 expected.push('"');
813 expected.push_str("b\"");
814 assert_eq!(kql, expected);
815 }
816}