Skip to main content

elasticctl_api/exceptions/
list_endpoints.rs

1use 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}