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