1use crate::codec::{self, Bundle, Format};
8use crate::model::{ExceptionItem, ExceptionList, ListKey};
9use crate::normalize;
10use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
11use crate::rules::{kql_escape, kql_escape_wildcard};
12use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport, urlencode};
13use serde::{Deserialize, Serialize};
14use serde_json::{Value, json};
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::Path;
17
18const BASE: &str = "/api/exception_lists";
19const ITEMS: &str = "/api/exception_lists/items";
20
21#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
26pub(crate) struct ValueListRef {
27 pub id: String,
28}
29
30const RESULT_WINDOW: u32 = 10_000;
34
35#[derive(Debug, Clone, Default)]
36pub struct ListFilter {
37 pub list_type: Option<String>,
38 pub tag: Option<String>,
39 pub namespace: Option<String>,
40 pub search: Option<String>,
42}
43
44impl ListFilter {
45 pub fn to_kql(&self) -> Option<String> {
51 let object_type = match self.namespace.as_deref() {
52 Some("agnostic") => "exception-list-agnostic",
53 _ => "exception-list",
54 };
55 let mut parts: Vec<String> = Vec::new();
56 if let Some(ty) = &self.list_type {
57 parts.push(format!(
58 "{object_type}.attributes.type: \"{}\"",
59 kql_escape(ty)
60 ));
61 }
62 if let Some(tag) = &self.tag {
63 parts.push(format!(
64 "{object_type}.attributes.tags: \"{}\"",
65 kql_escape(tag)
66 ));
67 }
68 if let Some(search) = &self.search {
69 parts.push(format!(
70 "{object_type}.attributes.name: \"*{}*\"",
71 kql_escape_wildcard(search)
72 ));
73 }
74 (!parts.is_empty()).then(|| parts.join(" AND "))
75 }
76}
77
78fn decode_find(body: &Value) -> Result<(Vec<Value>, u64)> {
80 let object = body.as_object().ok_or_else(|| {
81 Error::new(
82 ErrorKind::Http,
83 "invalid exception _find response: expected an object",
84 )
85 })?;
86 let data = object
87 .get("data")
88 .and_then(Value::as_array)
89 .cloned()
90 .ok_or_else(|| find_field_error("data", "an array"))?;
91 let page = required_positive_find_number(object, "page")?;
92 let per_page = required_positive_find_number(object, "per_page")?;
93 let total = object
94 .get("total")
95 .and_then(Value::as_u64)
96 .ok_or_else(|| find_field_error("total", "a non-negative integer"))?;
97
98 let returned = data.len() as u64;
99 if returned > per_page {
100 return Err(Error::new(
101 ErrorKind::Http,
102 format!(
103 "invalid exception _find response: page {page} returned {returned} objects, \
104 exceeding per_page {per_page}"
105 ),
106 ));
107 }
108 if returned > total {
109 return Err(Error::new(
110 ErrorKind::Http,
111 format!(
112 "invalid exception _find response: page {page} returned {returned} objects, \
113 exceeding total {total}"
114 ),
115 ));
116 }
117 Ok((data, total))
118}
119
120fn required_positive_find_number(
121 object: &serde_json::Map<String, Value>,
122 field: &str,
123) -> Result<u64> {
124 let value = object
125 .get(field)
126 .and_then(Value::as_u64)
127 .ok_or_else(|| find_field_error(field, "a positive integer"))?;
128 if value == 0 {
129 return Err(find_field_error(field, "a positive integer"));
130 }
131 Ok(value)
132}
133
134fn find_field_error(field: &str, expected: &str) -> Error {
135 Error::new(
136 ErrorKind::Http,
137 format!("invalid exception _find response field {field}: expected {expected}"),
138 )
139}
140
141async fn find_paged(t: &Transport, path_for: impl Fn(u32) -> String) -> Result<Vec<Value>> {
147 t.require_feature(Feature::ExceptionLists).await?;
148 let mut out: Vec<Value> = Vec::new();
149 let mut page = 1u32;
150 loop {
151 let body = t.get(&path_for(page)).await?;
152 let (data, total) = decode_find(&body)?;
153 let before = out.len();
154 out.extend(data);
155 if (out.len() as u64) >= total {
156 return Ok(out);
157 }
158 if out.len() == before {
159 return Err(short_read(total, out.len()));
160 }
161 page += 1;
162 }
163}
164
165fn short_read(counted: u64, returned: usize) -> Error {
166 Error::new(
167 ErrorKind::Http,
168 format!(
169 "the server counted {counted} objects and returned {returned}. Refusing a partial \
170 read: a short read is indistinguishable from objects having been deleted."
171 ),
172 )
173}
174
175pub async fn find_lists(t: &Transport, f: &ListFilter) -> Result<Vec<ExceptionList>> {
176 let kql = f.to_kql();
177 let values = find_paged(t, |page| {
178 let mut path = format!("{BASE}/_find?page={page}&per_page={RESULT_WINDOW}");
179 if let Some(ns) = &f.namespace {
180 path.push_str(&format!("&namespace_type={}", urlencode(ns)));
181 }
182 if let Some(k) = &kql {
185 path.push_str(&format!("&filter={}", urlencode(k)));
186 }
187 path
188 })
189 .await?;
190
191 values.into_iter().map(ExceptionList::from_value).collect()
192}
193
194pub async fn get_list(t: &Transport, key: &ListKey) -> Result<ExceptionList> {
195 t.require_feature(Feature::ExceptionLists).await?;
196 let body = t
197 .get(&format!(
198 "{BASE}?list_id={}&namespace_type={}",
199 urlencode(&key.list_id),
200 urlencode(&key.namespace_type),
201 ))
202 .await?;
203 ExceptionList::from_value(body)
204}
205
206pub async fn find_items(t: &Transport, key: &ListKey) -> Result<Vec<ExceptionItem>> {
207 let values = find_paged(t, |page| {
208 format!(
209 "{ITEMS}/_find?list_id={}&namespace_type={}&page={page}&per_page={RESULT_WINDOW}",
210 urlencode(&key.list_id),
211 urlencode(&key.namespace_type),
212 )
213 })
214 .await?;
215
216 values.into_iter().map(ExceptionItem::from_value).collect()
217}
218
219pub(crate) async fn value_lists_bootstrapped(t: &Transport) -> Result<bool> {
227 match t.get("/api/lists/index").await {
228 Ok(body) => {
229 let object = body.as_object();
230 let list_index = object
231 .and_then(|value| value.get("list_index"))
232 .and_then(Value::as_bool)
233 .ok_or_else(|| value_list_index_field_error("list_index"))?;
234 let list_item_index = object
235 .and_then(|value| value.get("list_item_index"))
236 .and_then(Value::as_bool)
237 .ok_or_else(|| value_list_index_field_error("list_item_index"))?;
238 Ok(list_index && list_item_index)
239 }
240 Err(e) if e.kind == ErrorKind::NotFound => Ok(false),
241 Err(e) => Err(e),
242 }
243}
244
245fn value_list_index_field_error(field: &str) -> Error {
246 Error::new(
247 ErrorKind::Http,
248 format!("invalid value-list index response field {field}: expected a boolean"),
249 )
250}
251
252pub(crate) async fn value_list_exists(t: &Transport, id: &str) -> Result<bool> {
257 match t.get(&format!("/api/lists?id={}", urlencode(id))).await {
258 Ok(_) => Ok(true),
259 Err(e) if e.kind == ErrorKind::NotFound => Ok(false),
260 Err(e) => Err(e),
261 }
262}
263
264pub async fn resolve_ids(t: &Transport, keys: &[ListKey]) -> Result<BTreeMap<ListKey, String>> {
268 let mut map = BTreeMap::new();
269 for key in keys {
270 match get_list(t, key).await {
271 Ok(list) => {
272 if let Some(id) = list.as_map().get("id").and_then(Value::as_str) {
273 map.insert(key.clone(), id.to_string());
274 }
275 }
276 Err(e) if e.kind == ErrorKind::NotFound => {}
277 Err(e) => return Err(e),
278 }
279 }
280 Ok(map)
281}
282
283pub async fn create_list(t: &Transport, l: &ExceptionList) -> Result<ExceptionList> {
284 t.require_feature(Feature::ExceptionLists).await?;
285 let payload = normalize::canonical_list(l);
286 let response = t.post(BASE, Some(&payload.into_value())).await?;
287 ExceptionList::from_value(response)
288}
289
290pub async fn update_list(t: &Transport, l: &ExceptionList) -> Result<ExceptionList> {
291 t.require_feature(Feature::ExceptionLists).await?;
292 let payload = normalize::canonical_list(l);
293 let response = t.put(BASE, &payload.into_value()).await?;
294 ExceptionList::from_value(response)
295}
296
297pub async fn delete_list(t: &Transport, key: &ListKey) -> Result<ExceptionList> {
298 t.require_feature(Feature::ExceptionLists).await?;
299 let body = t
300 .delete(&format!(
301 "{BASE}?list_id={}&namespace_type={}",
302 urlencode(&key.list_id),
303 urlencode(&key.namespace_type),
304 ))
305 .await?;
306 ExceptionList::from_value(body)
307}
308
309pub async fn create_item(t: &Transport, i: &ExceptionItem) -> Result<ExceptionItem> {
310 t.require_feature(Feature::ExceptionLists).await?;
311 let payload = normalize::canonical_item(i);
312 let response = t.post(ITEMS, Some(&payload.into_value())).await?;
313 ExceptionItem::from_value(response)
314}
315
316pub async fn update_item(t: &Transport, i: &ExceptionItem) -> Result<ExceptionItem> {
317 t.require_feature(Feature::ExceptionLists).await?;
318 let payload = normalize::canonical_item(i);
319 let response = t.put(ITEMS, &payload.into_value()).await?;
320 ExceptionItem::from_value(response)
321}
322
323pub async fn delete_item(t: &Transport, item_id: &str, namespace: &str) -> Result<ExceptionItem> {
324 t.require_feature(Feature::ExceptionLists).await?;
325 let body = t
326 .delete(&format!(
327 "{ITEMS}?item_id={}&namespace_type={}",
328 urlencode(item_id),
329 urlencode(namespace),
330 ))
331 .await?;
332 ExceptionItem::from_value(body)
333}
334
335#[derive(Deserialize)]
341struct ExceptionExportTrailer {
342 exported_exception_list_count: u64,
343 exported_exception_list_item_count: u64,
344 missing_exception_lists: Vec<Value>,
345 missing_exception_list_items: Vec<Value>,
346}
347
348struct DecodedExceptionExport {
349 exported_lists: u64,
350 missing: Vec<Value>,
351}
352
353fn decode_exception_export(body: &str, key: &ListKey) -> Result<DecodedExceptionExport> {
354 let trailer = body
355 .lines()
356 .rev()
357 .find(|line| !line.trim().is_empty())
358 .ok_or_else(|| Error::new(ErrorKind::Http, "missing exception export trailer"))?;
359 let trailer: ExceptionExportTrailer = serde_json::from_str(trailer).map_err(|e| {
360 Error::new(
361 ErrorKind::Http,
362 format!("invalid exception export trailer: {e}"),
363 )
364 })?;
365 if trailer.exported_exception_list_count > 1 {
366 return Err(Error::new(
367 ErrorKind::Http,
368 "contradictory exception export trailer: one request exported more than one list",
369 ));
370 }
371
372 let mut missing = trailer.missing_exception_lists;
373 missing.extend(trailer.missing_exception_list_items);
374 for value in &mut missing {
375 add_missing_identity(value, key);
376 }
377
378 let _ = trailer.exported_exception_list_item_count;
382 Ok(DecodedExceptionExport {
383 exported_lists: trailer.exported_exception_list_count,
384 missing,
385 })
386}
387
388fn add_missing_identity(value: &mut Value, key: &ListKey) {
389 if let Some(object) = value.as_object_mut() {
390 if !object.get("list_id").is_some_and(Value::is_string) {
391 object.insert("list_id".to_string(), Value::String(key.list_id.clone()));
392 }
393 if !object.get("namespace_type").is_some_and(Value::is_string) {
394 object.insert(
395 "namespace_type".to_string(),
396 Value::String(key.namespace_type.clone()),
397 );
398 }
399 return;
400 }
401
402 *value = json!({
403 "list_id": key.list_id,
404 "namespace_type": key.namespace_type,
405 "missing": std::mem::take(value),
406 });
407}
408
409pub async fn export_lists(t: &Transport, keys: &[ListKey]) -> Result<ExportOutcome> {
418 t.require_feature(Feature::ExceptionLists).await?;
419 let ids = resolve_ids(t, keys).await?;
420
421 let missing: Vec<String> = keys
424 .iter()
425 .filter(|k| !ids.contains_key(*k))
426 .map(|k| format!("{} ({})", k.list_id, k.namespace_type))
427 .collect();
428 if !missing.is_empty() {
429 return Err(Error::new(
430 ErrorKind::NotFound,
431 format!("exception list not found: {}", missing.join(", ")),
432 ));
433 }
434
435 let mut body = String::new();
436 let mut exported = 0u64;
437 let mut missing = Vec::new();
438 for key in keys {
439 let id = ids.get(key).expect("every key resolved before export");
440 let path = format!(
441 "{BASE}/_export?id={}&list_id={}&namespace_type={}&include_expired_exceptions=true",
442 urlencode(id),
443 urlencode(&key.list_id),
444 urlencode(&key.namespace_type),
445 );
446 let response = t.post_text(&path, None).await?;
447 let decoded = decode_exception_export(&response, key)?;
448 exported = exported
449 .checked_add(decoded.exported_lists)
450 .ok_or_else(|| {
451 Error::new(
452 ErrorKind::Http,
453 "invalid exception export trailers: exported-list count overflow",
454 )
455 })?;
456 missing.extend(decoded.missing);
457 body.push_str(&response);
458 if !response.ends_with('\n') {
459 body.push('\n');
460 }
461 }
462 Ok(ExportOutcome {
463 body,
464 exported,
465 missing,
466 })
467}
468
469pub async fn import_lists(t: &Transport, ndjson: &str, overwrite: bool) -> Result<Value> {
470 t.require_feature(Feature::ExceptionLists).await?;
471 t.post_multipart_ndjson(&format!("{BASE}/_import?overwrite={overwrite}"), ndjson)
472 .await
473}
474
475const NAMESPACES: [&str; 2] = ["single", "agnostic"];
479
480#[derive(Debug, Clone, PartialEq, Serialize)]
482pub struct ListReport {
483 pub total: usize,
484 pub lists: Vec<ExceptionList>,
485}
486
487#[derive(Debug, Clone, PartialEq, Serialize)]
489pub struct ListDetail {
490 pub list: ExceptionList,
491 pub items: Vec<ExceptionItem>,
492}
493
494fn namespaces_to_search(namespace: Option<&str>) -> Vec<&str> {
497 match namespace {
498 Some(ns) => vec![ns],
499 None => NAMESPACES.to_vec(),
500 }
501}
502
503async fn resolve_list_key(
511 t: &Transport,
512 list_id: &str,
513 namespace: Option<&str>,
514) -> Result<ListKey> {
515 if let Some(ns) = namespace {
516 let key = ListKey {
517 list_id: list_id.to_string(),
518 namespace_type: ns.to_string(),
519 };
520 match get_list(t, &key).await {
521 Ok(_) => return Ok(key),
522 Err(e) if e.kind == ErrorKind::NotFound => {
524 return Err(Error::new(
525 ErrorKind::NotFound,
526 format!("exception list not found: {list_id} ({ns})"),
527 ));
528 }
529 Err(e) => return Err(e),
530 }
531 }
532
533 let mut matches = Vec::new();
534 for ns in NAMESPACES {
535 let key = ListKey {
536 list_id: list_id.to_string(),
537 namespace_type: ns.to_string(),
538 };
539 match get_list(t, &key).await {
540 Ok(_) => matches.push(key),
541 Err(e) if e.kind == ErrorKind::NotFound => {}
542 Err(e) => return Err(e),
543 }
544 }
545 match matches.len() {
546 1 => Ok(matches.pop().expect("one match")),
547 0 => Err(Error::new(
548 ErrorKind::NotFound,
549 format!("exception list not found: {list_id}"),
550 )),
551 _ => Err(Error::new(
552 ErrorKind::Conflict,
553 format!(
554 "exception list '{list_id}' exists in both the 'single' and 'agnostic' \
555 namespaces; pass --namespace to select one"
556 ),
557 )),
558 }
559}
560
561async fn all_list_keys(t: &Transport, namespace: Option<&str>) -> Result<Vec<ListKey>> {
563 let mut keys = Vec::new();
564 for ns in namespaces_to_search(namespace) {
565 let filter = ListFilter {
566 namespace: Some(ns.to_string()),
567 ..Default::default()
568 };
569 for list in find_lists(t, &filter).await? {
570 keys.push(list.key()?);
571 }
572 }
573 keys.sort();
574 keys.dedup();
575 Ok(keys)
576}
577
578async fn resolve_selection(
582 t: &Transport,
583 selectors: &[String],
584 tag: Option<&str>,
585 namespace: Option<&str>,
586 noun: &str,
587) -> Result<Option<Vec<ListKey>>> {
588 if selectors.is_empty() && tag.is_none() {
589 return Ok(None);
590 }
591
592 let mut keys = Vec::new();
593 for s in selectors {
594 keys.push(resolve_list_key(t, s, namespace).await?);
595 }
596
597 let mut tag_matched = false;
598 if let Some(tag) = tag {
599 for ns in namespaces_to_search(namespace) {
600 let filter = ListFilter {
601 tag: Some(tag.to_string()),
602 namespace: Some(ns.to_string()),
603 ..Default::default()
604 };
605 for list in find_lists(t, &filter).await? {
606 tag_matched = true;
607 keys.push(list.key()?);
608 }
609 }
610 if !tag_matched {
611 return Err(Error::new(
612 ErrorKind::NotFound,
613 format!("No exception lists matched tag '{tag}'; nothing to {noun}"),
614 ));
615 }
616 }
617
618 keys.sort();
619 keys.dedup();
620 Ok(Some(keys))
621}
622
623pub async fn list_op(t: &Transport, f: &ListFilter) -> Result<ListReport> {
627 let mut lists = Vec::new();
628 if f.namespace.is_some() {
629 lists = find_lists(t, f).await?;
630 } else {
631 for ns in NAMESPACES {
632 let mut per_ns = f.clone();
633 per_ns.namespace = Some(ns.to_string());
634 lists.extend(find_lists(t, &per_ns).await?);
635 }
636 }
637 normalize::sort_lists(&mut lists);
638 let total = lists.len();
639 Ok(ListReport { total, lists })
640}
641
642pub async fn get_op(t: &Transport, list_id: &str, namespace: Option<&str>) -> Result<ListDetail> {
644 let key = resolve_list_key(t, list_id, namespace).await?;
645 let list = get_list(t, &key).await?;
646 let items = find_items(t, &key).await?;
647 Ok(ListDetail { list, items })
648}
649
650pub fn validate_op(path: &Path) -> Result<Bundle> {
655 let body = std::fs::read_to_string(path)
656 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
657 if Format::from_path(path) == Format::Yaml {
658 return Err(Error::new(
659 ErrorKind::Unsupported,
660 format!(
661 "{} is YAML, which cannot represent exception lists or items; validate an NDJSON bundle",
662 path.display()
663 ),
664 ));
665 }
666 codec::decode_bundle(&body)
667}
668
669pub async fn export_op(
674 t: &Transport,
675 list_ids: &[String],
676 tag: Option<&str>,
677 namespace: Option<&str>,
678 format: Format,
679) -> Result<ExportOutcome> {
680 if format == Format::Yaml {
681 return Err(Error::new(
682 ErrorKind::Unsupported,
683 "exception bundles have no YAML form; re-run with --format-file ndjson",
684 ));
685 }
686 let keys = match resolve_selection(t, list_ids, tag, namespace, "export").await? {
687 Some(keys) => keys,
688 None => all_list_keys(t, namespace).await?,
689 };
690 export_lists(t, &keys).await
691}
692
693#[derive(Debug, Clone, PartialEq)]
696pub struct DeletePlan {
697 pub preview: MutationPlan,
698 pub keys: Vec<ListKey>,
699}
700
701pub async fn plan_delete_op(
706 t: &Transport,
707 list_ids: &[String],
708 namespace: Option<&str>,
709) -> Result<DeletePlan> {
710 let mut resolved = BTreeSet::new();
711 for id in list_ids {
712 resolved.insert(resolve_list_key(t, id, namespace).await?);
713 }
714 let keys: Vec<_> = resolved.into_iter().collect();
715 let targets: Vec<_> = keys
716 .iter()
717 .map(|key| format!("{} ({})", key.list_id, key.namespace_type))
718 .collect();
719 Ok(DeletePlan {
720 preview: MutationPlan {
721 preview_action: format!("Delete {} exception list(s)", targets.len()),
722 preview_details: targets.clone(),
723 targets,
724 },
725 keys,
726 })
727}
728
729pub async fn apply_delete_op(t: &Transport, plan: &DeletePlan) -> Result<DeleteOutcome> {
732 let mut deleted = Vec::new();
733 let mut failed = Vec::new();
734 for key in &plan.keys {
735 match delete_list(t, key).await {
736 Ok(_) => deleted.push(json!({
737 "list_id": key.list_id,
738 "namespace_type": key.namespace_type,
739 })),
740 Err(e) => failed.push(json!({
741 "list_id": key.list_id,
742 "namespace_type": key.namespace_type,
743 "error": e.message,
744 })),
745 }
746 }
747 Ok(DeleteOutcome {
748 applied: true,
749 deleted,
750 failed,
751 total: plan.keys.len(),
752 })
753}
754
755pub async fn plan_import_op(
764 t: Option<&Transport>,
765 path: &Path,
766 overwrite: bool,
767 skip_existing: bool,
768) -> Result<ImportPlan> {
769 let body = std::fs::read_to_string(path)
770 .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
771
772 if Format::from_path(path) == Format::Yaml {
773 return Err(Error::new(
774 ErrorKind::Unsupported,
775 format!(
776 "{} is YAML, which cannot represent exception lists or items; import an NDJSON bundle",
777 path.display()
778 ),
779 ));
780 }
781
782 let bundle = codec::decode_bundle(&body)?;
783
784 if !bundle.rules.is_empty() {
787 return Err(Error::new(
788 ErrorKind::Unsupported,
789 format!(
790 "this file carries {} rule(s), which the exception import route cannot import; \
791 use `rules import` for a rules bundle",
792 bundle.rules.len()
793 ),
794 ));
795 }
796
797 for item in &bundle.items {
801 if item.list_id().is_err() {
802 return Err(Error::new(
803 ErrorKind::Error,
804 format!(
805 "an exception item ('{}') has no readable list_id",
806 item.item_id().unwrap_or("<unreadable>")
807 ),
808 ));
809 }
810 }
811
812 let total = bundle.lists.len() + bundle.items.len();
813 let mut lists = bundle.lists;
814 let mut items = bundle.items;
815 let mut skipped: Vec<Value> = Vec::new();
816
817 if skip_existing {
818 let t = t.ok_or_else(|| {
819 Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
820 })?;
821 let keys: Vec<ListKey> = lists
822 .iter()
823 .map(ExceptionList::key)
824 .collect::<Result<_>>()?;
825 let existing = resolve_ids(t, &keys).await?;
826
827 let mut keep = Vec::with_capacity(lists.len());
828 let mut skip_keys = Vec::new();
829 for list in lists {
830 let key = list.key()?;
831 if existing.contains_key(&key) {
832 skipped.push(json!({
833 "list_id": key.list_id,
834 "namespace_type": key.namespace_type,
835 "reason": "exists",
836 }));
837 skip_keys.push(key);
838 } else {
839 keep.push(list);
840 }
841 }
842 lists = keep;
843
844 if !skip_keys.is_empty() {
847 let skip_set: std::collections::BTreeSet<ListKey> = skip_keys.into_iter().collect();
848 items.retain(|i| {
849 let key = ListKey {
850 list_id: i.list_id().expect("list_id validated above").to_string(),
851 namespace_type: i.namespace_type().to_string(),
852 };
853 !skip_set.contains(&key)
854 });
855 }
856 }
857
858 let mut details = Vec::with_capacity(lists.len() + items.len() + skipped.len());
859 for l in &lists {
860 details.push(format!("{} {} import", l.list_id()?, l.name()));
861 }
862 for i in &items {
863 details.push(format!("{} {} import", i.item_id()?, i.list_id()?));
864 }
865 details.extend(skipped.iter().map(|s| {
866 format!(
867 "{} skip (already exists)",
868 s["list_id"].as_str().unwrap_or_default()
869 )
870 }));
871
872 let mut targets = Vec::with_capacity(lists.len() + items.len());
873 for l in &lists {
874 targets.push(l.list_id()?.to_string());
875 }
876 for i in &items {
877 targets.push(i.item_id()?.to_string());
878 }
879
880 let qualifier = if overwrite {
881 ", overwriting existing".to_string()
882 } else if skip_existing && !skipped.is_empty() {
883 format!(", skipping {} that already exist", skipped.len())
884 } else {
885 String::new()
886 };
887 let preview = MutationPlan {
888 preview_action: format!(
889 "Import {} exception list(s) and {} item(s) from {}{qualifier}",
890 lists.len(),
891 items.len(),
892 path.display()
893 ),
894 preview_details: details,
895 targets,
896 };
897
898 let ndjson = codec::encode_bundle(&Bundle {
902 rules: Vec::new(),
903 lists,
904 items,
905 summary: None,
906 })?;
907
908 Ok(ImportPlan {
909 preview,
910 ndjson,
911 total,
912 skipped,
913 })
914}
915
916pub async fn apply_import_op(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
918 if ndjson.is_empty() {
920 return Ok(ImportReport {
921 succeeded: json!(0),
922 failed: json!([]),
923 });
924 }
925
926 let response = import_lists(t, ndjson, overwrite).await?;
927 crate::ops::decode_import_report(&response, "exceptions")
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933 use elasticctl_core::Profile;
934 use wiremock::matchers::{method, path, query_param};
935 use wiremock::{Mock, MockServer, ResponseTemplate};
936
937 fn transport(server: &MockServer) -> Transport {
938 Transport::new(&Profile {
939 kibana_url: server.uri(),
940 es_url: None,
941 api_key: Some("essu_test".into()),
942 username: None,
943 password: None,
944 space: "default".into(),
945 verify: true,
946 timeout_secs: 5,
947 })
948 .unwrap()
949 }
950
951 #[tokio::test]
954 async fn value_list_lookup_accepts_any_successful_response_body() {
955 let server = MockServer::start().await;
956 Mock::given(method("GET"))
957 .and(path("/api/lists"))
958 .and(query_param("id", "ip-allowlist"))
959 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
960 "id": "ip-allowlist", "extra": {"server": "owned"}
961 })))
962 .expect(1)
963 .mount(&server)
964 .await;
965
966 assert!(
967 value_list_exists(&transport(&server), "ip-allowlist")
968 .await
969 .unwrap()
970 );
971 }
972
973 #[tokio::test]
976 async fn value_list_lookup_treats_only_not_found_as_absent() {
977 let server = MockServer::start().await;
978 Mock::given(method("GET"))
979 .and(path("/api/lists"))
980 .and(query_param("id", "missing"))
981 .respond_with(ResponseTemplate::new(404).set_body_json(json!({
982 "message": "value list is absent"
983 })))
984 .expect(1)
985 .mount(&server)
986 .await;
987
988 assert!(
989 !value_list_exists(&transport(&server), "missing")
990 .await
991 .unwrap()
992 );
993 }
994
995 #[tokio::test]
996 async fn value_list_lookup_propagates_classified_errors_after_transport_retries() {
997 for (status, kind, expected_requests) in [
998 (400, ErrorKind::Http, 1),
999 (403, ErrorKind::Permission, 1),
1000 (429, ErrorKind::Http, 3),
1001 (500, ErrorKind::Http, 3),
1002 ] {
1003 let server = MockServer::start().await;
1004 Mock::given(method("GET"))
1005 .and(path("/api/lists"))
1006 .and(query_param("id", "broken"))
1007 .respond_with(ResponseTemplate::new(status).set_body_json(json!({
1008 "message": format!("HTTP {status}")
1009 })))
1010 .expect(expected_requests)
1011 .mount(&server)
1012 .await;
1013
1014 let err = value_list_exists(&transport(&server), "broken")
1015 .await
1016 .unwrap_err();
1017 assert_eq!(err.kind, kind, "HTTP {status}");
1018 assert_eq!(err.http_status, Some(status), "HTTP {status}");
1019 }
1020 }
1021
1022 #[test]
1023 fn to_kql_is_none_when_nothing_filters() {
1024 assert_eq!(ListFilter::default().to_kql(), None);
1025 }
1026
1027 #[test]
1028 fn to_kql_filters_type_over_the_measured_prefix() {
1029 let f = ListFilter {
1030 list_type: Some("detection".into()),
1031 ..Default::default()
1032 };
1033 assert_eq!(
1034 f.to_kql().unwrap(),
1035 "exception-list.attributes.type: \"detection\""
1036 );
1037 }
1038
1039 #[test]
1040 fn to_kql_filters_tags_over_the_measured_prefix() {
1041 let f = ListFilter {
1042 tag: Some("alpha".into()),
1043 ..Default::default()
1044 };
1045 assert_eq!(
1046 f.to_kql().unwrap(),
1047 "exception-list.attributes.tags: \"alpha\""
1048 );
1049 }
1050
1051 #[test]
1052 fn to_kql_uses_the_agnostic_saved_object_type_for_that_namespace() {
1053 let f = ListFilter {
1054 tag: Some("alpha".into()),
1055 namespace: Some("agnostic".into()),
1056 ..Default::default()
1057 };
1058 assert_eq!(
1059 f.to_kql().unwrap(),
1060 "exception-list-agnostic.attributes.tags: \"alpha\""
1061 );
1062 }
1063
1064 #[test]
1065 fn to_kql_combines_clauses_with_and() {
1066 let f = ListFilter {
1067 list_type: Some("detection".into()),
1068 tag: Some("alpha".into()),
1069 ..Default::default()
1070 };
1071 assert_eq!(
1072 f.to_kql().unwrap(),
1073 "exception-list.attributes.type: \"detection\" AND \
1074 exception-list.attributes.tags: \"alpha\""
1075 );
1076 }
1077
1078 #[test]
1079 fn to_kql_search_matches_name_substring_over_the_measured_prefix() {
1080 let f = ListFilter {
1081 search: Some("Sub".into()),
1082 ..Default::default()
1083 };
1084 assert_eq!(
1085 f.to_kql().unwrap(),
1086 "exception-list.attributes.name: \"*Sub*\""
1087 );
1088 }
1089
1090 #[test]
1091 fn to_kql_search_uses_the_agnostic_saved_object_type_for_that_namespace() {
1092 let f = ListFilter {
1093 search: Some("Sub".into()),
1094 namespace: Some("agnostic".into()),
1095 ..Default::default()
1096 };
1097 assert_eq!(
1098 f.to_kql().unwrap(),
1099 "exception-list-agnostic.attributes.name: \"*Sub*\""
1100 );
1101 }
1102
1103 #[test]
1104 fn to_kql_search_combines_with_other_clauses() {
1105 let f = ListFilter {
1106 list_type: Some("detection".into()),
1107 search: Some("Sub".into()),
1108 ..Default::default()
1109 };
1110 assert_eq!(
1111 f.to_kql().unwrap(),
1112 "exception-list.attributes.type: \"detection\" AND \
1113 exception-list.attributes.name: \"*Sub*\""
1114 );
1115 }
1116
1117 #[test]
1118 fn to_kql_search_escapes_wildcards_in_the_name() {
1119 let f = ListFilter {
1120 search: Some("a*b?c".into()),
1121 ..Default::default()
1122 };
1123 assert_eq!(
1124 f.to_kql().unwrap(),
1125 "exception-list.attributes.name: \"*a\\*b\\?c*\""
1126 );
1127 }
1128
1129 #[test]
1130 fn to_kql_escapes_a_quote_in_the_value() {
1131 let f = ListFilter {
1132 tag: Some("a\"b".into()),
1133 ..Default::default()
1134 };
1135 let kql = f.to_kql().unwrap();
1136 assert!(kql.contains("a\\\"b"), "{kql}");
1137 }
1138}