elasticctl_api/exceptions/
list_endpoints.rs1use crate::model::{ExceptionItem, ExceptionList, ListKey};
4use crate::normalize;
5use crate::ops::ExportOutcome;
6use crate::rules::{kql_escape, kql_escape_wildcard};
7use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
8use serde::Deserialize;
9use serde_json::{Value, json};
10use std::collections::BTreeMap;
11
12const BASE: &str = "/api/exception_lists";
13const ITEMS: &str = "/api/exception_lists/items";
14const RESULT_WINDOW: u32 = 10_000;
18
19#[derive(Debug, Clone, Default)]
20pub struct ListFilter {
21 pub list_type: Option<String>,
22 pub tag: Option<String>,
23 pub namespace: Option<String>,
24 pub search: Option<String>,
26}
27
28impl ListFilter {
29 pub fn to_kql(&self) -> Option<String> {
35 let object_type = match self.namespace.as_deref() {
36 Some("agnostic") => "exception-list-agnostic",
37 _ => "exception-list",
38 };
39 let mut parts: Vec<String> = Vec::new();
40 if let Some(ty) = &self.list_type {
41 parts.push(format!(
42 "{object_type}.attributes.type: \"{}\"",
43 kql_escape(ty)
44 ));
45 }
46 if let Some(tag) = &self.tag {
47 parts.push(format!(
48 "{object_type}.attributes.tags: \"{}\"",
49 kql_escape(tag)
50 ));
51 }
52 if let Some(search) = &self.search {
53 parts.push(format!(
54 "{object_type}.attributes.name: \"*{}*\"",
55 kql_escape_wildcard(search)
56 ));
57 }
58 (!parts.is_empty()).then(|| parts.join(" AND "))
59 }
60}
61
62fn decode_find(body: &Value) -> Result<(Vec<Value>, u64)> {
64 let object = body.as_object().ok_or_else(|| {
65 Error::new(
66 ErrorKind::Http,
67 "invalid exception _find response: expected an object",
68 )
69 })?;
70 let data = object
71 .get("data")
72 .and_then(Value::as_array)
73 .cloned()
74 .ok_or_else(|| find_field_error("data", "an array"))?;
75 let page = required_positive_find_number(object, "page")?;
76 let per_page = required_positive_find_number(object, "per_page")?;
77 let total = object
78 .get("total")
79 .and_then(Value::as_u64)
80 .ok_or_else(|| find_field_error("total", "a non-negative integer"))?;
81
82 let returned = data.len() as u64;
83 if returned > per_page {
84 return Err(Error::new(
85 ErrorKind::Http,
86 format!(
87 "invalid exception _find response: page {page} returned {returned} objects, \
88 exceeding per_page {per_page}"
89 ),
90 ));
91 }
92 if returned > total {
93 return Err(Error::new(
94 ErrorKind::Http,
95 format!(
96 "invalid exception _find response: page {page} returned {returned} objects, \
97 exceeding total {total}"
98 ),
99 ));
100 }
101 Ok((data, total))
102}
103
104fn required_positive_find_number(
105 object: &serde_json::Map<String, Value>,
106 field: &str,
107) -> Result<u64> {
108 let value = object
109 .get(field)
110 .and_then(Value::as_u64)
111 .ok_or_else(|| find_field_error(field, "a positive integer"))?;
112 if value == 0 {
113 return Err(find_field_error(field, "a positive integer"));
114 }
115 Ok(value)
116}
117
118fn find_field_error(field: &str, expected: &str) -> Error {
119 Error::new(
120 ErrorKind::Http,
121 format!("invalid exception _find response field {field}: expected {expected}"),
122 )
123}
124
125async fn find_paged(t: &Transport, path_for: impl Fn(u32) -> String) -> Result<Vec<Value>> {
131 t.require_feature(Feature::ExceptionLists).await?;
132 let mut out: Vec<Value> = Vec::new();
133 let mut page = 1u32;
134 loop {
135 let body = t.get(&path_for(page)).await?;
136 let (data, total) = decode_find(&body)?;
137 let before = out.len();
138 out.extend(data);
139 if (out.len() as u64) >= total {
140 return Ok(out);
141 }
142 if out.len() == before {
143 return Err(short_read(total, out.len()));
144 }
145 page += 1;
146 }
147}
148
149fn short_read(counted: u64, returned: usize) -> Error {
150 Error::new(
151 ErrorKind::Http,
152 format!(
153 "the server counted {counted} objects and returned {returned}. Refusing a partial \
154 read: a short read is indistinguishable from objects having been deleted."
155 ),
156 )
157}
158
159pub async fn find_lists(t: &Transport, f: &ListFilter) -> Result<Vec<ExceptionList>> {
160 let kql = f.to_kql();
161 let values = find_paged(t, |page| {
162 let mut path = format!("{BASE}/_find?page={page}&per_page={RESULT_WINDOW}");
163 if let Some(ns) = &f.namespace {
164 path.push_str(&format!("&namespace_type={}", urlencode(ns)));
165 }
166 if let Some(k) = &kql {
169 path.push_str(&format!("&filter={}", urlencode(k)));
170 }
171 path
172 })
173 .await?;
174
175 values.into_iter().map(ExceptionList::from_value).collect()
176}
177
178pub async fn get_list(t: &Transport, key: &ListKey) -> Result<ExceptionList> {
179 t.require_feature(Feature::ExceptionLists).await?;
180 let body = t
181 .get(&format!(
182 "{BASE}?list_id={}&namespace_type={}",
183 urlencode(&key.list_id),
184 urlencode(&key.namespace_type),
185 ))
186 .await?;
187 ExceptionList::from_value(body)
188}
189
190pub async fn find_items(t: &Transport, key: &ListKey) -> Result<Vec<ExceptionItem>> {
191 let values = find_paged(t, |page| {
192 format!(
193 "{ITEMS}/_find?list_id={}&namespace_type={}&page={page}&per_page={RESULT_WINDOW}",
194 urlencode(&key.list_id),
195 urlencode(&key.namespace_type),
196 )
197 })
198 .await?;
199
200 values.into_iter().map(ExceptionItem::from_value).collect()
201}
202pub async fn resolve_ids(t: &Transport, keys: &[ListKey]) -> Result<BTreeMap<ListKey, String>> {
206 let mut map = BTreeMap::new();
207 for key in keys {
208 match get_list(t, key).await {
209 Ok(list) => {
210 if let Some(id) = list.as_map().get("id").and_then(Value::as_str) {
211 map.insert(key.clone(), id.to_string());
212 }
213 }
214 Err(e) if e.kind == ErrorKind::NotFound => {}
215 Err(e) => return Err(e),
216 }
217 }
218 Ok(map)
219}
220
221pub async fn create_list(t: &Transport, l: &ExceptionList) -> Result<ExceptionList> {
222 t.require_feature(Feature::ExceptionLists).await?;
223 let payload = normalize::canonical_list(l);
224 let response = t.post(BASE, Some(&payload.into_value())).await?;
225 ExceptionList::from_value(response)
226}
227
228pub async fn update_list(t: &Transport, l: &ExceptionList) -> Result<ExceptionList> {
229 t.require_feature(Feature::ExceptionLists).await?;
230 let payload = normalize::canonical_list(l);
231 let response = t.put(BASE, &payload.into_value()).await?;
232 ExceptionList::from_value(response)
233}
234
235pub async fn delete_list(t: &Transport, key: &ListKey) -> Result<ExceptionList> {
236 t.require_feature(Feature::ExceptionLists).await?;
237 let body = t
238 .delete(&format!(
239 "{BASE}?list_id={}&namespace_type={}",
240 urlencode(&key.list_id),
241 urlencode(&key.namespace_type),
242 ))
243 .await?;
244 ExceptionList::from_value(body)
245}
246
247pub async fn create_item(t: &Transport, i: &ExceptionItem) -> Result<ExceptionItem> {
248 t.require_feature(Feature::ExceptionLists).await?;
249 let payload = normalize::canonical_item(i);
250 let response = t.post(ITEMS, Some(&payload.into_value())).await?;
251 ExceptionItem::from_value(response)
252}
253
254pub async fn update_item(t: &Transport, i: &ExceptionItem) -> Result<ExceptionItem> {
255 t.require_feature(Feature::ExceptionLists).await?;
256 let payload = normalize::canonical_item(i);
257 let response = t.put(ITEMS, &payload.into_value()).await?;
258 ExceptionItem::from_value(response)
259}
260
261pub async fn delete_item(t: &Transport, item_id: &str, namespace: &str) -> Result<ExceptionItem> {
262 t.require_feature(Feature::ExceptionLists).await?;
263 let body = t
264 .delete(&format!(
265 "{ITEMS}?item_id={}&namespace_type={}",
266 urlencode(item_id),
267 urlencode(namespace),
268 ))
269 .await?;
270 ExceptionItem::from_value(body)
271}
272
273#[derive(Deserialize)]
279struct ExceptionExportTrailer {
280 exported_exception_list_count: u64,
281 exported_exception_list_item_count: u64,
282 missing_exception_lists: Vec<Value>,
283 missing_exception_list_items: Vec<Value>,
284}
285
286struct DecodedExceptionExport {
287 exported_lists: u64,
288 missing: Vec<Value>,
289}
290
291fn decode_exception_export(body: &str, key: &ListKey) -> Result<DecodedExceptionExport> {
292 let trailer = body
293 .lines()
294 .rev()
295 .find(|line| !line.trim().is_empty())
296 .ok_or_else(|| Error::new(ErrorKind::Http, "missing exception export trailer"))?;
297 let trailer: ExceptionExportTrailer = serde_json::from_str(trailer).map_err(|e| {
298 Error::new(
299 ErrorKind::Http,
300 format!("invalid exception export trailer: {e}"),
301 )
302 })?;
303 if trailer.exported_exception_list_count > 1 {
304 return Err(Error::new(
305 ErrorKind::Http,
306 "contradictory exception export trailer: one request exported more than one list",
307 ));
308 }
309
310 let mut missing = trailer.missing_exception_lists;
311 missing.extend(trailer.missing_exception_list_items);
312 for value in &mut missing {
313 add_missing_identity(value, key);
314 }
315
316 let _ = trailer.exported_exception_list_item_count;
320 Ok(DecodedExceptionExport {
321 exported_lists: trailer.exported_exception_list_count,
322 missing,
323 })
324}
325
326fn add_missing_identity(value: &mut Value, key: &ListKey) {
327 if let Some(object) = value.as_object_mut() {
328 if !object.get("list_id").is_some_and(Value::is_string) {
329 object.insert("list_id".to_string(), Value::String(key.list_id.clone()));
330 }
331 if !object.get("namespace_type").is_some_and(Value::is_string) {
332 object.insert(
333 "namespace_type".to_string(),
334 Value::String(key.namespace_type.clone()),
335 );
336 }
337 return;
338 }
339
340 *value = json!({
341 "list_id": key.list_id,
342 "namespace_type": key.namespace_type,
343 "missing": std::mem::take(value),
344 });
345}
346
347pub async fn export_lists(t: &Transport, keys: &[ListKey]) -> Result<ExportOutcome> {
356 t.require_feature(Feature::ExceptionLists).await?;
357 let ids = resolve_ids(t, keys).await?;
358
359 let missing: Vec<String> = keys
362 .iter()
363 .filter(|k| !ids.contains_key(*k))
364 .map(|k| format!("{} ({})", k.list_id, k.namespace_type))
365 .collect();
366 if !missing.is_empty() {
367 return Err(Error::new(
368 ErrorKind::NotFound,
369 format!("exception list not found: {}", missing.join(", ")),
370 ));
371 }
372
373 let mut body = String::new();
374 let mut exported = 0u64;
375 let mut missing = Vec::new();
376 for key in keys {
377 let id = ids.get(key).expect("every key resolved before export");
378 let path = format!(
379 "{BASE}/_export?id={}&list_id={}&namespace_type={}&include_expired_exceptions=true",
380 urlencode(id),
381 urlencode(&key.list_id),
382 urlencode(&key.namespace_type),
383 );
384 let response = t.post_text(&path, None).await?;
385 let decoded = decode_exception_export(&response, key)?;
386 exported = exported
387 .checked_add(decoded.exported_lists)
388 .ok_or_else(|| {
389 Error::new(
390 ErrorKind::Http,
391 "invalid exception export trailers: exported-list count overflow",
392 )
393 })?;
394 missing.extend(decoded.missing);
395 body.push_str(&response);
396 if !response.ends_with('\n') {
397 body.push('\n');
398 }
399 }
400 Ok(ExportOutcome {
401 body,
402 exported,
403 missing,
404 })
405}
406
407pub async fn import_lists(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
408 t.require_feature(Feature::ExceptionLists).await?;
409 t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
410 .await
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn to_kql_is_none_when_nothing_filters() {
419 assert_eq!(ListFilter::default().to_kql(), None);
420 }
421
422 #[test]
423 fn to_kql_filters_type_over_the_measured_prefix() {
424 let f = ListFilter {
425 list_type: Some("detection".into()),
426 ..Default::default()
427 };
428 assert_eq!(
429 f.to_kql().unwrap(),
430 "exception-list.attributes.type: \"detection\""
431 );
432 }
433
434 #[test]
435 fn to_kql_filters_tags_over_the_measured_prefix() {
436 let f = ListFilter {
437 tag: Some("alpha".into()),
438 ..Default::default()
439 };
440 assert_eq!(
441 f.to_kql().unwrap(),
442 "exception-list.attributes.tags: \"alpha\""
443 );
444 }
445
446 #[test]
447 fn to_kql_uses_the_agnostic_saved_object_type_for_that_namespace() {
448 let f = ListFilter {
449 tag: Some("alpha".into()),
450 namespace: Some("agnostic".into()),
451 ..Default::default()
452 };
453 assert_eq!(
454 f.to_kql().unwrap(),
455 "exception-list-agnostic.attributes.tags: \"alpha\""
456 );
457 }
458
459 #[test]
460 fn to_kql_combines_clauses_with_and() {
461 let f = ListFilter {
462 list_type: Some("detection".into()),
463 tag: Some("alpha".into()),
464 ..Default::default()
465 };
466 assert_eq!(
467 f.to_kql().unwrap(),
468 "exception-list.attributes.type: \"detection\" AND \
469 exception-list.attributes.tags: \"alpha\""
470 );
471 }
472
473 #[test]
474 fn to_kql_search_matches_name_substring_over_the_measured_prefix() {
475 let f = ListFilter {
476 search: Some("Sub".into()),
477 ..Default::default()
478 };
479 assert_eq!(
480 f.to_kql().unwrap(),
481 "exception-list.attributes.name: \"*Sub*\""
482 );
483 }
484
485 #[test]
486 fn to_kql_search_uses_the_agnostic_saved_object_type_for_that_namespace() {
487 let f = ListFilter {
488 search: Some("Sub".into()),
489 namespace: Some("agnostic".into()),
490 ..Default::default()
491 };
492 assert_eq!(
493 f.to_kql().unwrap(),
494 "exception-list-agnostic.attributes.name: \"*Sub*\""
495 );
496 }
497
498 #[test]
499 fn to_kql_search_combines_with_other_clauses() {
500 let f = ListFilter {
501 list_type: Some("detection".into()),
502 search: Some("Sub".into()),
503 ..Default::default()
504 };
505 assert_eq!(
506 f.to_kql().unwrap(),
507 "exception-list.attributes.type: \"detection\" AND \
508 exception-list.attributes.name: \"*Sub*\""
509 );
510 }
511
512 #[test]
513 fn to_kql_search_escapes_wildcards_in_the_name() {
514 let f = ListFilter {
515 search: Some("a*b?c".into()),
516 ..Default::default()
517 };
518 assert_eq!(
519 f.to_kql().unwrap(),
520 "exception-list.attributes.name: \"*a\\*b\\?c*\""
521 );
522 }
523
524 #[test]
525 fn to_kql_escapes_a_quote_in_the_value() {
526 let f = ListFilter {
527 tag: Some("a\"b".into()),
528 ..Default::default()
529 };
530 let kql = f.to_kql().unwrap();
531 assert!(kql.contains("a\\\"b"), "{kql}");
532 }
533}