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