1use crate::codec;
6use crate::model::{ExportSummary, Rule};
7use crate::normalize;
8use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
9use serde_json::{Value, json};
10
11const BASE: &str = "/api/detection_engine/rules";
12
13const RESULT_WINDOW: u32 = 10_000;
20
21#[derive(Debug, Clone, Default)]
22pub struct RuleFilter {
23 pub enabled: Option<bool>,
24 pub rule_type: Option<String>,
25 pub severity: Option<String>,
26 pub tag: Option<String>,
27 pub name: Option<String>,
30 pub query: Option<String>,
32}
33
34impl RuleFilter {
35 pub fn to_kql(&self) -> Option<String> {
37 let mut parts: Vec<String> = Vec::new();
38 if let Some(v) = self.enabled {
39 parts.push(format!("alert.attributes.enabled: {v}"));
40 }
41 if let Some(v) = &self.name {
42 parts.push(format!("alert.attributes.name: \"{}\"", kql_escape(v)));
43 }
44 if let Some(v) = &self.rule_type {
45 parts.push(format!(
46 "alert.attributes.params.type: \"{}\"",
47 kql_escape(v)
48 ));
49 }
50 if let Some(v) = &self.severity {
51 parts.push(format!(
52 "alert.attributes.params.severity: \"{}\"",
53 kql_escape(v)
54 ));
55 }
56 if let Some(v) = &self.tag {
57 parts.push(format!("alert.attributes.tags: \"{}\"", kql_escape(v)));
58 }
59 if let Some(v) = &self.query {
60 parts.push(v.clone());
61 }
62 (!parts.is_empty()).then(|| parts.join(" AND "))
63 }
64}
65
66fn kql_escape(value: &str) -> String {
72 value.replace('\\', "\\\\").replace('"', "\\\"")
73}
74
75fn rule_id_query(rule_ids: &[String]) -> String {
77 rule_ids
78 .iter()
79 .map(|id| format!("alert.attributes.params.ruleId: \"{}\"", kql_escape(id)))
80 .collect::<Vec<_>>()
81 .join(" OR ")
82}
83
84pub async fn find_page(
85 t: &Transport,
86 filter: &RuleFilter,
87 page: u32,
88 per_page: u32,
89) -> Result<(Vec<Rule>, u64)> {
90 let mut path = format!("{BASE}/_find?page={page}&per_page={per_page}");
91 if let Some(kql) = filter.to_kql() {
92 path.push_str(&format!("&filter={}", urlencode(&kql)));
93 }
94
95 let body = t.get(&path).await?;
96 decode_find(&body)
97}
98
99pub fn decode_find(body: &Value) -> Result<(Vec<Rule>, u64)> {
102 let total = body["total"].as_u64().unwrap_or(0);
103 let data = body["data"].as_array().cloned().unwrap_or_default();
104 let rules = data
105 .into_iter()
106 .map(Rule::from_value)
107 .collect::<Result<Vec<_>>>()?;
108 Ok((rules, total))
109}
110
111const RULE_TYPES: [&str; 7] = [
116 "query",
117 "eql",
118 "esql",
119 "threshold",
120 "threat_match",
121 "machine_learning",
122 "new_terms",
123];
124
125pub async fn find_all(t: &Transport, filter: &RuleFilter) -> Result<Vec<Rule>> {
134 let (rules, total) = find_page(t, filter, 1, RESULT_WINDOW).await?;
135
136 if total <= u64::from(RESULT_WINDOW) {
137 if (rules.len() as u64) < total {
138 return Err(short_read(total, rules.len()));
139 }
140 return Ok(rules);
141 }
142
143 let types: Vec<&str> = match &filter.rule_type {
146 Some(t) => vec![t.as_str()],
147 None => RULE_TYPES.to_vec(),
148 };
149
150 let mut collected: Vec<Rule> = Vec::new();
151 let mut summed: u64 = 0;
152
153 for rule_type in types {
154 let mut type_filter = filter.clone();
155 type_filter.rule_type = Some(rule_type.to_string());
156
157 let slice_total = if filter.rule_type.is_some() {
160 total
161 } else {
162 let (slice_rules, slice_total) = find_page(t, &type_filter, 1, RESULT_WINDOW).await?;
163
164 if slice_total <= u64::from(RESULT_WINDOW) {
165 if (slice_rules.len() as u64) < slice_total {
166 return Err(short_read(slice_total, slice_rules.len()));
167 }
168 summed += slice_total;
169 collected.extend(slice_rules);
170 continue;
171 }
172 slice_total
173 };
174
175 if filter.enabled.is_some() {
178 return Err(oversized(slice_total));
179 }
180
181 for enabled in [true, false] {
182 let mut enabled_filter = type_filter.clone();
183 enabled_filter.enabled = Some(enabled);
184 let (enabled_rules, enabled_total) =
185 find_page(t, &enabled_filter, 1, RESULT_WINDOW).await?;
186 if enabled_total > u64::from(RESULT_WINDOW) {
187 return Err(oversized(enabled_total));
188 }
189 if (enabled_rules.len() as u64) < enabled_total {
190 return Err(short_read(enabled_total, enabled_rules.len()));
191 }
192 summed += enabled_total;
193 collected.extend(enabled_rules);
194 }
195 }
196
197 if summed != total {
200 return Err(Error::new(
201 ErrorKind::Http,
202 format!(
203 "the server counted {total} rules across the corpus but the type slices \
204 sum to {summed}. Refusing a partial corpus: a rule type added by a newer \
205 stack version would otherwise read as zero in every pull and diff."
206 ),
207 ));
208 }
209
210 Ok(collected)
211}
212
213fn short_read(counted: u64, returned: usize) -> Error {
216 Error::new(
217 ErrorKind::Http,
218 format!(
219 "the server counted {counted} rules and returned {returned}. Refusing a partial \
220 corpus: a short read is indistinguishable from rules having been deleted."
221 ),
222 )
223}
224
225fn oversized(count: u64) -> Error {
228 Error::new(
229 ErrorKind::Unsupported,
230 format!(
231 "{count} rules match, more than the {RESULT_WINDOW} a single search can return \
232 even after partitioning by type and enabled. Narrow the selection with a \
233 filter or a tag."
234 ),
235 )
236}
237
238const ID_CHUNK: usize = 50;
243
244pub async fn find_by_rule_ids(t: &Transport, rule_ids: &[String]) -> Result<Vec<Rule>> {
249 let mut found = Vec::with_capacity(rule_ids.len());
250 for chunk in rule_ids.chunks(ID_CHUNK) {
251 let filter = RuleFilter {
252 query: Some(rule_id_query(chunk)),
253 ..Default::default()
254 };
255 let (rules, _) = find_page(t, &filter, 1, RESULT_WINDOW).await?;
256 found.extend(rules);
257 }
258 Ok(found)
259}
260
261pub async fn get(t: &Transport, rule_id: &str) -> Result<Rule> {
262 let body = t
263 .get(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
264 .await?;
265 Rule::from_value(body)
266}
267
268pub async fn create(t: &Transport, rule: &Rule) -> Result<Rule> {
269 let mut payload = rule.clone();
270 normalize::strip_volatile(&mut payload);
271 let response = t
272 .post(BASE, Some(&Value::Object(payload.as_map().clone())))
273 .await?;
274 Rule::from_value(response)
275}
276
277pub async fn update(t: &Transport, rule: &Rule) -> Result<Rule> {
278 let mut payload = rule.clone();
279 normalize::strip_volatile(&mut payload);
280 let response = t
281 .put(BASE, &Value::Object(payload.as_map().clone()))
282 .await?;
283 Rule::from_value(response)
284}
285
286pub async fn patch(t: &Transport, rule_id: &str, patch: &Value) -> Result<Rule> {
287 let mut body = patch.as_object().cloned().unwrap_or_default();
288 body.insert("rule_id".into(), json!(rule_id));
289 let response = t.patch(BASE, &Value::Object(body)).await?;
291 Rule::from_value(response)
292}
293
294pub async fn delete(t: &Transport, rule_id: &str) -> Result<Rule> {
295 let body = t
296 .delete(&format!("{BASE}?rule_id={}", urlencode(rule_id)))
297 .await?;
298 Rule::from_value(body)
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum BulkAction {
303 Enable,
304 Disable,
305 Delete,
306}
307
308impl BulkAction {
309 pub fn as_str(&self) -> &'static str {
310 match self {
311 Self::Enable => "enable",
312 Self::Disable => "disable",
313 Self::Delete => "delete",
314 }
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Default)]
319pub struct BulkOutcome {
320 pub succeeded: u64,
321 pub failed: u64,
322 pub skipped: u64,
323 pub total: u64,
324}
325
326pub async fn bulk_by_rule_ids(
327 t: &Transport,
328 action: BulkAction,
329 rule_ids: &[String],
330 dry_run: bool,
331) -> Result<BulkOutcome> {
332 if rule_ids.is_empty() {
334 return Ok(BulkOutcome::default());
335 }
336
337 let path = if dry_run {
338 format!("{BASE}/_bulk_action?dry_run=true")
339 } else {
340 format!("{BASE}/_bulk_action")
341 };
342 let body = json!({ "action": action.as_str(), "query": rule_id_query(rule_ids) });
343
344 let response = t.post(&path, Some(&body)).await?;
345 let s = &response["attributes"]["summary"];
346 Ok(BulkOutcome {
347 succeeded: s["succeeded"].as_u64().unwrap_or(0),
348 failed: s["failed"].as_u64().unwrap_or(0),
349 skipped: s["skipped"].as_u64().unwrap_or(0),
350 total: s["total"].as_u64().unwrap_or(0),
351 })
352}
353
354pub async fn export(
359 t: &Transport,
360 rule_ids: Option<&[String]>,
361) -> Result<(Vec<Rule>, Option<ExportSummary>)> {
362 let body = rule_ids.map(|ids| {
363 json!({
364 "objects": ids
365 .iter()
366 .map(|id| json!({"rule_id": id}))
367 .collect::<Vec<_>>()
368 })
369 });
370 let text = t
371 .post_text(&format!("{BASE}/_export"), body.as_ref())
372 .await?;
373 codec::decode_ndjson(&text)
374}
375
376const EXISTENCE_CHUNK: usize = 50;
379
380pub async fn existing_rule_ids(
385 t: &Transport,
386 rule_ids: &[String],
387) -> Result<std::collections::BTreeSet<String>> {
388 let mut found = std::collections::BTreeSet::new();
390 if rule_ids.is_empty() {
391 return Ok(found);
392 }
393
394 for chunk in rule_ids.chunks(EXISTENCE_CHUNK) {
395 let path = format!(
396 "{BASE}/_find?page=1&per_page={}&filter={}",
397 chunk.len(),
398 urlencode(&rule_id_query(chunk))
399 );
400 let (rules, _) = decode_find(&t.get(&path).await?)?;
401 for r in rules {
402 if let Ok(id) = r.rule_id() {
403 found.insert(id.to_string());
404 }
405 }
406 }
407
408 Ok(found)
409}
410
411pub async fn import(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
412 t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
413 .await
414}
415
416#[derive(Debug, Clone, Default, PartialEq)]
417pub struct PreviewResult {
418 pub preview_id: Option<String>,
419 pub errors: Vec<String>,
420 pub warnings: Vec<String>,
421}
422
423pub async fn preview(
428 t: &Transport,
429 rule: &Rule,
430 invocation_count: u32,
431 timeframe_end: &str,
432) -> Result<PreviewResult> {
433 let mut body = rule.as_map().clone();
434 for k in [
436 "rule_id",
437 "id",
438 "immutable",
439 "rule_source",
440 "revision",
441 "version",
442 ] {
443 body.remove(k);
444 }
445 body.insert("invocationCount".into(), json!(invocation_count));
446 body.insert("timeframeEnd".into(), json!(timeframe_end));
447
448 let response = t
449 .post(&format!("{BASE}/preview"), Some(&Value::Object(body)))
450 .await?;
451
452 let collect = |key: &str| -> Vec<String> {
453 response["logs"]
454 .as_array()
455 .map(|logs| {
456 logs.iter()
457 .filter_map(|l| l.get(key)?.as_array())
458 .flatten()
459 .filter_map(|v| v.as_str().map(str::to_owned))
460 .collect()
461 })
462 .unwrap_or_default()
463 };
464
465 Ok(PreviewResult {
466 preview_id: response["previewId"].as_str().map(str::to_owned),
467 errors: collect("errors"),
468 warnings: collect("warnings"),
469 })
470}
471
472pub const PREVIEW_ALERTS_INDEX_PREFIX: &str = ".preview.alerts-security.alerts-";
477
478#[derive(Debug, Clone, Default, PartialEq)]
479pub struct PreviewHits {
480 pub total: u64,
481 pub sample: Vec<Value>,
484}
485
486pub async fn preview_hits(
492 t: &Transport,
493 space: &str,
494 preview_id: &str,
495 sample: usize,
496) -> Result<PreviewHits> {
497 let space = if space.is_empty() { "default" } else { space };
498 let index = urlencode(&format!("{PREVIEW_ALERTS_INDEX_PREFIX}{space}"));
499 let body = json!({
500 "size": sample,
501 "track_total_hits": true,
503 "query": {"term": {"kibana.alert.rule.uuid": preview_id}},
504 "sort": [{"@timestamp": {"order": "desc"}}]
505 });
506
507 let response = t
508 .post_absolute_es(&format!("/{index}/_search?ignore_unavailable=true"), &body)
509 .await?;
510
511 Ok(decode_preview_hits(&response))
512}
513
514pub fn decode_preview_hits(response: &Value) -> PreviewHits {
516 let total = response["hits"]["total"]["value"].as_u64().unwrap_or(0);
517 let sample = response["hits"]["hits"]
518 .as_array()
519 .map(|hits| {
520 hits.iter()
521 .map(|h| {
522 json!({
523 "_id": h.get("_id").cloned().unwrap_or(Value::Null),
524 "_source": h.get("_source").cloned().unwrap_or(Value::Null),
525 })
526 })
527 .collect()
528 })
529 .unwrap_or_default();
530
531 PreviewHits { total, sample }
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 #[test]
539 fn kql_escape_doubles_a_lone_backslash() {
540 assert_eq!(kql_escape("a\\b"), "a\\\\b");
541 }
542
543 #[test]
544 fn kql_escape_escapes_a_lone_quote() {
545 let mut expected = String::from("a");
546 expected.push('\\');
547 expected.push('"');
548 expected.push('b');
549 assert_eq!(kql_escape("a\"b"), expected);
550 }
551
552 #[test]
555 fn kql_escape_orders_backslash_before_quote() {
556 let mut input = String::new();
557 input.push('\\');
558 input.push('"');
559
560 let escaped = kql_escape(&input);
561
562 let mut expected = "\\".repeat(3);
565 expected.push('"');
566 assert_eq!(escaped, expected);
567 }
568
569 #[test]
570 fn rule_id_query_escapes_a_quote_in_the_id() {
571 let mut id = String::from("x");
572 id.push('"');
573 id.push('y');
574
575 let q = rule_id_query(&[id]);
576
577 let mut expected = String::from("alert.attributes.params.ruleId: \"x");
578 expected.push('\\');
579 expected.push('"');
580 expected.push_str("y\"");
581 assert_eq!(q, expected);
582 assert!(
583 !q.contains(" OR "),
584 "a single id must produce exactly one clause: {q}"
585 );
586 }
587
588 #[test]
589 fn rule_id_query_neutralizes_a_kql_injection_payload() {
590 let payload = "x\" or alert.attributes.enabled: true or \"";
591 let q = rule_id_query(&[payload.to_string()]);
592 assert!(
593 !q.contains("\" or alert.attributes.enabled: true or \""),
594 "the injected quote must not close the literal: {q}"
595 );
596 }
597
598 #[test]
599 fn to_kql_escapes_a_quote_in_the_tag() {
600 let f = RuleFilter {
601 tag: Some("a\"b".into()),
602 ..Default::default()
603 };
604 let kql = f.to_kql().unwrap();
605
606 let mut expected = String::from("alert.attributes.tags: \"a");
607 expected.push('\\');
608 expected.push('"');
609 expected.push_str("b\"");
610 assert_eq!(kql, expected);
611 }
612}