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