Skip to main content

elasticctl_api/exceptions/
list_endpoints.rs

1//! Exception list and item endpoints, pagination, and export.
2
3use 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";
14/// Elasticsearch's `_find` result window, shared with rules: `from + size` must
15/// not exceed 10,000. A server that caps `per_page` lower simply returns fewer
16/// objects with a larger `total`, which the paging loop handles.
17const 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    /// Friendly name-substring search (spec 4.7).
25    pub search: Option<String>,
26}
27
28impl ListFilter {
29    /// The KQL `filter` for this selection, or `None` when nothing filters.
30    ///
31    /// The list `_find` route filters over the namespace's saved-object type,
32    /// not the rules vertical's `alert.attributes.*` (measured, spec 7.7).
33    /// Values are quoted and escaped like `rules::to_kql`.
34    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
62/// Decode the shared `{data, page, per_page, total}` envelope.
63fn 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
125/// Read every object a `_find` route serves, paging until `total` is reached.
126///
127/// Refuses a page that returns nothing while `total` is still ahead: a short
128/// read is indistinguishable from objects deleted between pages, and a mirror
129/// built on it would silently drop them.
130async 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        // An empty filter is a 400 (measured, spec 7.7), so it is omitted,
167        // never sent as `filter=`.
168        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}
202/// Map each key to the live container `id` on this stack. A key with no live
203/// container is absent from the map rather than mapped to a placeholder, so
204/// callers distinguish "exists here with this id" from "does not exist here".
205pub 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/// The required final line of one exception-list export response.
274///
275/// This deliberately does not reuse `ExportSummary`: that type defaults absent
276/// fields for importing historical bundles, while this live endpoint boundary
277/// must reject a response that does not state its measured outcome.
278#[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    // The item count is required even though one container export may carry
317    // any number of items. Destructure it so the wire contract remains
318    // explicit at this boundary.
319    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
347/// Export the given containers and their items as NDJSON.
348///
349/// The export route is the one path that refuses `list_id` alone (measured,
350/// fact E), so each key is resolved to its live container `id` first. Identity
351/// stays `list_id` plus `namespace_type` everywhere; the `id` is fetched only
352/// here, at the boundary that demands it. A key with no live container is
353/// refused rather than skipped: a silently dropped key is a short export
354/// reported as a success.
355pub 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    // Name every missing key at once, the way the mirror names every colliding
360    // filename pair: one refusal per run beats a re-run per missing key.
361    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}