contentctl 5.2.0__py3-none-any.whl → 5.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. contentctl/actions/build.py +5 -43
  2. contentctl/actions/detection_testing/DetectionTestingManager.py +64 -24
  3. contentctl/actions/detection_testing/GitService.py +4 -1
  4. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +146 -42
  5. contentctl/actions/detection_testing/views/DetectionTestingView.py +5 -6
  6. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +2 -0
  7. contentctl/actions/initialize.py +35 -9
  8. contentctl/actions/release_notes.py +14 -12
  9. contentctl/actions/test.py +16 -20
  10. contentctl/actions/validate.py +9 -16
  11. contentctl/helper/utils.py +69 -20
  12. contentctl/input/director.py +147 -119
  13. contentctl/input/yml_reader.py +39 -27
  14. contentctl/objects/abstract_security_content_objects/detection_abstract.py +95 -21
  15. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +548 -8
  16. contentctl/objects/baseline.py +24 -6
  17. contentctl/objects/config.py +32 -8
  18. contentctl/objects/content_versioning_service.py +508 -0
  19. contentctl/objects/correlation_search.py +53 -63
  20. contentctl/objects/dashboard.py +15 -1
  21. contentctl/objects/data_source.py +13 -1
  22. contentctl/objects/deployment.py +23 -9
  23. contentctl/objects/detection.py +2 -0
  24. contentctl/objects/enums.py +28 -18
  25. contentctl/objects/investigation.py +40 -20
  26. contentctl/objects/lookup.py +62 -6
  27. contentctl/objects/macro.py +19 -4
  28. contentctl/objects/playbook.py +16 -2
  29. contentctl/objects/rba.py +1 -33
  30. contentctl/objects/removed_security_content_object.py +50 -0
  31. contentctl/objects/security_content_object.py +1 -0
  32. contentctl/objects/story.py +37 -5
  33. contentctl/output/api_json_output.py +5 -3
  34. contentctl/output/conf_output.py +9 -1
  35. contentctl/output/runtime_csv_writer.py +111 -0
  36. contentctl/output/svg_output.py +4 -5
  37. contentctl/output/templates/savedsearches_detections.j2 +2 -6
  38. {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/METADATA +4 -3
  39. {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/RECORD +42 -40
  40. {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/WHEEL +1 -1
  41. contentctl/output/data_source_writer.py +0 -52
  42. {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/LICENSE.md +0 -0
  43. {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/entry_points.txt +0 -0
@@ -97,7 +97,8 @@ class TestApp(App_Base):
97
97
  return str(self.getSplunkbasePath())
98
98
  if self.version is None or self.uid is None:
99
99
  print(
100
- f"Not downloading {self.title} from Splunkbase since uid[{self.uid}] AND version[{self.version}] MUST be defined"
100
+ f"Not downloading {self.title} from Splunkbase since uid[{self.uid}] AND "
101
+ f"version[{self.version}] MUST be defined"
101
102
  )
102
103
 
103
104
  elif isinstance(self.hardcoded_path, pathlib.Path):
@@ -149,7 +150,10 @@ class CustomApp(App_Base):
149
150
  exclude=True,
150
151
  default=int(datetime.now(UTC).strftime("%Y%m%d%H%M%S")),
151
152
  validate_default=True,
152
- description="Build number for your app. This will always be a number that corresponds to the time of the build in the format YYYYMMDDHHMMSS",
153
+ description=(
154
+ "Build number for your app. This will always be a number that corresponds to the "
155
+ "time of the build in the format YYYYMMDDHHMMSS"
156
+ ),
153
157
  )
154
158
  # id has many restrictions:
155
159
  # * Omit this setting for apps that are for internal use only and not intended
@@ -194,7 +198,8 @@ class CustomApp(App_Base):
194
198
  except Exception as e:
195
199
  raise (
196
200
  ValueError(
197
- f"The specified version does not follow the semantic versioning spec (https://semver.org/). {str(e)}"
201
+ "The specified version does not follow the semantic versioning spec "
202
+ f"(https://semver.org/). {str(e)}"
198
203
  )
199
204
  )
200
205
  return v
@@ -238,6 +243,10 @@ class Config_Base(BaseModel):
238
243
  def serialize_path(path: DirectoryPath) -> str:
239
244
  return str(path)
240
245
 
246
+ @property
247
+ def removed_content_path(self) -> pathlib.Path:
248
+ return self.path / "removed"
249
+
241
250
 
242
251
  class init(Config_Base):
243
252
  model_config = ConfigDict(validate_default=True, arbitrary_types_allowed=True)
@@ -254,6 +263,16 @@ class init(Config_Base):
254
263
 
255
264
  class validate(Config_Base):
256
265
  model_config = ConfigDict(validate_default=True, arbitrary_types_allowed=True)
266
+ enforce_deprecation_mapping_requirement: bool = Field(
267
+ default=False,
268
+ description="contentctl can support graceful deprecation and removal of "
269
+ "content using files that match the name removed/deprecation_mapping*.YML. "
270
+ "If this option is enabled, then any content marked as "
271
+ "status: [deprecated, removed] MUST have a mapping that exists in an "
272
+ "appropriate file at the listed path. "
273
+ "If this is not set, then that requirement is not enforced. Even if the"
274
+ "mapping files exist, they are NOT validated/built.",
275
+ )
257
276
  enrichments: bool = Field(
258
277
  default=False,
259
278
  description="Enable MITRE, APP, and CVE Enrichments. "
@@ -416,8 +435,6 @@ class inspect(build):
416
435
  f"or CLI invocation appropriately] {validate.model_fields['enrichments'].description}"
417
436
  ),
418
437
  )
419
- # TODO (cmcginley): wording should change here if we want to be able to download any app from
420
- # Splunkbase
421
438
  previous_build: str | None = Field(
422
439
  default=None,
423
440
  description=(
@@ -548,7 +565,10 @@ class ContainerSettings(BaseModel):
548
565
  )
549
566
  full_image_path: str = Field(
550
567
  default="registry.hub.docker.com/splunk/splunk:9.3",
551
- title="Full path to the container image to be used. We are currently pinned to 9.3 as we resolve an issue with waiting to run until app installation completes.",
568
+ title=(
569
+ "Full path to the container image to be used. We are currently pinned to 9.3 as we "
570
+ "resolve an issue with waiting to run until app installation completes."
571
+ ),
552
572
  )
553
573
 
554
574
  def getContainers(self) -> List[Container]:
@@ -577,7 +597,10 @@ class Changes(BaseModel):
577
597
  mode_name: str = "Changes"
578
598
  target_branch: str = Field(
579
599
  ...,
580
- description="The target branch to diff against. Note that this includes uncommitted changes in the working directory as well.",
600
+ description=(
601
+ "The target branch to diff against. Note that this includes uncommitted changes in the "
602
+ "working directory as well."
603
+ ),
581
604
  )
582
605
 
583
606
 
@@ -821,7 +844,8 @@ class test_common(build):
821
844
  f"'{PostTestBehavior.always_pause}' - the state of "
822
845
  "the test will always pause after a test, allowing the user to log into the "
823
846
  "server and experiment with the search and data before it is removed.\n\n"
824
- f"'{PostTestBehavior.pause_on_failure}' - pause execution ONLY when a test fails. The user may press ENTER in the terminal "
847
+ f"'{PostTestBehavior.pause_on_failure}' - pause execution ONLY when a test fails. "
848
+ "The user may press ENTER in the terminal "
825
849
  "running the test to move on to the next test.\n\n"
826
850
  f"'{PostTestBehavior.never_pause}' - never stop testing, even if a test fails.\n\n"
827
851
  "***SPECIAL NOTE FOR CI/CD*** 'never_pause' MUST be used for a test to "
@@ -0,0 +1,508 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ import time
5
+ import uuid
6
+ from functools import cached_property
7
+ from typing import Any, Callable
8
+
9
+ import splunklib.client as splunklib # type: ignore
10
+ from pydantic import BaseModel, Field, PrivateAttr, computed_field
11
+ from splunklib.binding import HTTPError, ResponseReader # type: ignore
12
+ from splunklib.data import Record # type: ignore
13
+
14
+ from contentctl.helper.utils import Utils
15
+ from contentctl.objects.config import Infrastructure, test_common
16
+ from contentctl.objects.correlation_search import ResultIterator
17
+ from contentctl.objects.detection import Detection
18
+
19
+ # Suppress logging by default; enable for local testing
20
+ ENABLE_LOGGING = False
21
+ LOG_LEVEL = logging.DEBUG
22
+ LOG_PATH = "content_versioning_service.log"
23
+
24
+
25
+ class ContentVersioningService(BaseModel):
26
+ """
27
+ A model representing the content versioning service used in ES 8.0.0+. This model can be used
28
+ to validate that detections have been installed in a way that is compatible with content
29
+ versioning.
30
+ """
31
+
32
+ # The global contentctl config
33
+ global_config: test_common
34
+
35
+ # The instance specific infra config
36
+ infrastructure: Infrastructure
37
+
38
+ # The splunklib service
39
+ service: splunklib.Service
40
+
41
+ # The list of detections
42
+ detections: list[Detection]
43
+
44
+ # The logger to use (logs all go to a null pipe unless ENABLE_LOGGING is set to True, so as not
45
+ # to conflict w/ tqdm)
46
+ logger: logging.Logger = Field(
47
+ default_factory=lambda: Utils.get_logger(
48
+ __name__, LOG_LEVEL, LOG_PATH, ENABLE_LOGGING
49
+ )
50
+ )
51
+
52
+ def model_post_init(self, __context: Any) -> None:
53
+ super().model_post_init(__context)
54
+
55
+ # Log instance details
56
+ self.logger.info(
57
+ f"[{self.infrastructure.instance_name} ({self.infrastructure.instance_address})] "
58
+ "Initing ContentVersioningService"
59
+ )
60
+
61
+ # The cached job on the splunk instance of the cms events
62
+ _cms_main_job: splunklib.Job | None = PrivateAttr(default=None)
63
+
64
+ class Config:
65
+ # We need to allow arbitrary type for the splunklib service
66
+ arbitrary_types_allowed = True
67
+
68
+ @computed_field
69
+ @property
70
+ def setup_functions(self) -> list[tuple[Callable[[], None], str]]:
71
+ """
72
+ Returns the list of setup functions needed for content versioning testing
73
+ """
74
+ return [
75
+ (self.activate_versioning, "Activating Content Versioning"),
76
+ (self.wait_for_cms_main, "Waiting for CMS Parser"),
77
+ (self.validate_content_against_cms, "Validating Against CMS"),
78
+ ]
79
+
80
+ def _query_content_versioning_service(
81
+ self, method: str, body: dict[str, Any] = {}
82
+ ) -> Record:
83
+ """
84
+ Queries the SA-ContentVersioning service. Output mode defaults to JSON.
85
+
86
+ :param method: HTTP request method (e.g. GET)
87
+ :type method: str
88
+ :param body: the payload/data/body of the request
89
+ :type body: dict[str, Any]
90
+
91
+ :returns: a splunklib Record object (wrapper around dict) indicating the response
92
+ :rtype: :class:`splunklib.data.Record`
93
+ """
94
+ # Add output mode to body
95
+ if "output_mode" not in body:
96
+ body["output_mode"] = "json"
97
+
98
+ # Query the content versioning service
99
+ try:
100
+ response = self.service.request( # type: ignore
101
+ method=method,
102
+ path_segment="configs/conf-feature_flags/general",
103
+ body=body,
104
+ app="SA-ContentVersioning",
105
+ )
106
+ except HTTPError as e:
107
+ # Raise on any HTTP errors
108
+ raise HTTPError(f"Error querying content versioning service: {e}") from e
109
+
110
+ return response
111
+
112
+ @property
113
+ def is_versioning_activated(self) -> bool:
114
+ """
115
+ Indicates whether the versioning service is activated or not
116
+
117
+ :returns: a bool indicating if content versioning is activated or not
118
+ :rtype: bool
119
+ """
120
+ # Query the SA-ContentVersioning service for versioning status
121
+ response = self._query_content_versioning_service(method="GET")
122
+
123
+ # Grab the response body and check for errors
124
+ if "body" not in response:
125
+ raise KeyError(
126
+ f"Cannot retrieve versioning status, 'body' was not found in JSON response: {response}"
127
+ )
128
+ body: Any = response["body"] # type: ignore
129
+ if not isinstance(body, ResponseReader):
130
+ raise ValueError(
131
+ "Cannot retrieve versioning status, value at 'body' in JSON response had an unexpected"
132
+ f" type: expected '{ResponseReader}', received '{type(body)}'"
133
+ )
134
+
135
+ # Read the JSON and parse it into a dictionary
136
+ json_ = body.readall()
137
+ try:
138
+ data = json.loads(json_)
139
+ except json.JSONDecodeError as e:
140
+ raise ValueError(f"Unable to parse response body as JSON: {e}") from e
141
+
142
+ # Find the versioning_activated field and report any errors
143
+ try:
144
+ for entry in data["entry"]:
145
+ if entry["name"] == "general":
146
+ return bool(int(entry["content"]["versioning_activated"]))
147
+ except KeyError as e:
148
+ raise KeyError(
149
+ "Cannot retrieve versioning status, unable to determine versioning status using "
150
+ f"the expected keys: {e}"
151
+ ) from e
152
+ raise ValueError(
153
+ "Cannot retrieve versioning status, unable to find an entry matching 'general' in the "
154
+ "response."
155
+ )
156
+
157
+ def activate_versioning(self) -> None:
158
+ """
159
+ Activate the content versioning service
160
+ """
161
+ # Post to the SA-ContentVersioning service to set versioning status
162
+ self._query_content_versioning_service(
163
+ method="POST", body={"versioning_activated": True}
164
+ )
165
+
166
+ # Confirm versioning has been enabled
167
+ if not self.is_versioning_activated:
168
+ raise Exception(
169
+ "Something went wrong, content versioning is still disabled."
170
+ )
171
+
172
+ self.logger.info(
173
+ f"[{self.infrastructure.instance_name}] Versioning service successfully activated"
174
+ )
175
+
176
+ @computed_field
177
+ @cached_property
178
+ def cms_fields(self) -> list[str]:
179
+ """
180
+ Property listing the fields we want to pull from the cms_main index
181
+
182
+ :returns: a list of strings, the fields we want
183
+ :rtype: list[str]
184
+ """
185
+ return [
186
+ "app_name",
187
+ "detection_id",
188
+ "version",
189
+ "action.correlationsearch.label",
190
+ "sourcetype",
191
+ ]
192
+
193
+ @property
194
+ def is_cms_parser_enabled(self) -> bool:
195
+ """
196
+ Indicates whether the cms_parser mod input is enabled or not.
197
+
198
+ :returns: a bool indicating if cms_parser mod input is activated or not
199
+ :rtype: bool
200
+ """
201
+ # Get the data input entity
202
+ cms_parser = self.service.input("data/inputs/cms_parser/main") # type: ignore
203
+
204
+ # Convert the 'disabled' field to an int, then a bool, and then invert to be 'enabled'
205
+ return not bool(int(cms_parser.content["disabled"])) # type: ignore
206
+
207
+ def force_cms_parser(self) -> None:
208
+ """
209
+ Force the cms_parser to run by disabling and re-enabling it.
210
+ """
211
+ # Get the data input entity
212
+ cms_parser = self.service.input("data/inputs/cms_parser/main") # type: ignore
213
+
214
+ # Disable and re-enable
215
+ cms_parser.disable()
216
+ cms_parser.enable()
217
+
218
+ # Confirm the cms_parser is enabled
219
+ if not self.is_cms_parser_enabled:
220
+ raise Exception("Something went wrong, cms_parser is still disabled.")
221
+
222
+ self.logger.info(
223
+ f"[{self.infrastructure.instance_name}] cms_parser successfully toggled to force run"
224
+ )
225
+
226
+ def wait_for_cms_main(self) -> None:
227
+ """
228
+ Checks the cms_main index until it has the expected number of events, or it times out.
229
+ """
230
+ # Force the cms_parser to start parsing our savedsearches.conf
231
+ self.force_cms_parser()
232
+
233
+ # Set counters and limits for out exp. backoff timer
234
+ elapsed_sleep_time = 0
235
+ num_tries = 0
236
+ time_to_sleep = 2**num_tries
237
+ max_sleep = 600
238
+
239
+ # Loop until timeout
240
+ while elapsed_sleep_time < max_sleep:
241
+ # Sleep, and add the time to the elapsed counter
242
+ self.logger.info(
243
+ f"[{self.infrastructure.instance_name}] Waiting {time_to_sleep} for cms_parser to "
244
+ "finish"
245
+ )
246
+ time.sleep(time_to_sleep)
247
+ elapsed_sleep_time += time_to_sleep
248
+ self.logger.info(
249
+ f"[{self.infrastructure.instance_name}] Checking cms_main (attempt #{num_tries + 1}"
250
+ f" - {elapsed_sleep_time} seconds elapsed of {max_sleep} max)"
251
+ )
252
+
253
+ # Check if the number of CMS events matches or exceeds the number of detections
254
+ if self.get_num_cms_events() >= len(self.detections):
255
+ self.logger.info(
256
+ f"[{self.infrastructure.instance_name}] Found "
257
+ f"{self.get_num_cms_events(use_cache=True)} events in cms_main which "
258
+ f"meets or exceeds the expected {len(self.detections)}."
259
+ )
260
+ break
261
+ else:
262
+ self.logger.info(
263
+ f"[{self.infrastructure.instance_name}] Found "
264
+ f"{self.get_num_cms_events(use_cache=True)} matching events in cms_main; "
265
+ f"expecting {len(self.detections)}. Continuing to wait..."
266
+ )
267
+ # Update the number of times we've tried, and increment the time to sleep
268
+ num_tries += 1
269
+ time_to_sleep = 2**num_tries
270
+
271
+ # If the computed time to sleep will exceed max_sleep, adjust appropriately
272
+ if (elapsed_sleep_time + time_to_sleep) > max_sleep:
273
+ time_to_sleep = max_sleep - elapsed_sleep_time
274
+
275
+ def _query_cms_main(self, use_cache: bool = False) -> splunklib.Job:
276
+ """
277
+ Queries the cms_main index, optionally appending the provided query suffix.
278
+
279
+ :param use_cache: a flag indicating whether the cached job should be returned
280
+ :type use_cache: bool
281
+
282
+ :returns: a search Job entity
283
+ :rtype: :class:`splunklib.client.Job`
284
+ """
285
+ # Use the cached job if asked to do so
286
+ if use_cache:
287
+ if self._cms_main_job is not None:
288
+ return self._cms_main_job
289
+ raise Exception(
290
+ "Attempting to return a cached job against the cms_main index, but no job has been"
291
+ " cached yet."
292
+ )
293
+
294
+ # Construct the query looking for CMS events matching the content app name
295
+ query = (
296
+ f"search index=cms_main sourcetype=stash_common_detection_model "
297
+ f'app_name="{self.global_config.app.appid}" | fields {", ".join(self.cms_fields)}'
298
+ )
299
+ self.logger.debug(
300
+ f"[{self.infrastructure.instance_name}] Query on cms_main: {query}"
301
+ )
302
+
303
+ # Get the job as a blocking operation, set the cache, and return
304
+ self._cms_main_job = self.service.search(query, exec_mode="blocking") # type: ignore
305
+ return self._cms_main_job
306
+
307
+ def get_num_cms_events(self, use_cache: bool = False) -> int:
308
+ """
309
+ Gets the number of matching events in the cms_main index
310
+
311
+ :param use_cache: a flag indicating whether the cached job should be returned
312
+ :type use_cache: bool
313
+
314
+ :returns: the count of matching events
315
+ :rtype: int
316
+ """
317
+ # Query the cms_main index
318
+ job = self._query_cms_main(use_cache=use_cache)
319
+
320
+ # Convert the result count to an int
321
+ return int(job["resultCount"])
322
+
323
+ def validate_content_against_cms(self) -> None:
324
+ """
325
+ Using the cms_main index, validate content against the index to ensure our
326
+ savedsearches.conf is compatible with ES content versioning features. **NOTE**: while in
327
+ the future, this function may validate more types of content, currently, we only validate
328
+ detections against the cms_main index.
329
+ """
330
+ # Get the cached job and result count
331
+ result_count = self.get_num_cms_events(use_cache=True)
332
+ job = self._query_cms_main(use_cache=True)
333
+
334
+ # Create a running list of validation errors
335
+ exceptions: list[Exception] = []
336
+
337
+ # Generate an error for the count mismatch
338
+ if result_count != len(self.detections):
339
+ msg = (
340
+ f"[{self.infrastructure.instance_name}] Expected {len(self.detections)} matching "
341
+ f"events in cms_main, but found {result_count}."
342
+ )
343
+ self.logger.error(msg)
344
+ exceptions.append(Exception(msg))
345
+ self.logger.info(
346
+ f"[{self.infrastructure.instance_name}] Expecting {len(self.detections)} matching "
347
+ f"events in cms_main, found {result_count}."
348
+ )
349
+
350
+ # Init some counters and a mapping of detections to their names
351
+ count = 100
352
+ offset = 0
353
+ remaining_detections = {
354
+ x.get_action_dot_correlationsearch_dot_label(self.global_config.app): x
355
+ for x in self.detections
356
+ }
357
+ matched_detections: dict[str, Detection] = {}
358
+
359
+ # Create a filter for a specific memory error we're ok ignoring
360
+ sub_second_order_pattern = re.compile(
361
+ r".*Events might not be returned in sub-second order due to search memory limits.*"
362
+ )
363
+
364
+ # Iterate over the results until we've gone through them all
365
+ while offset < result_count:
366
+ iterator = ResultIterator(
367
+ response_reader=job.results( # type: ignore
368
+ output_mode="json", count=count, offset=offset
369
+ ),
370
+ error_filters=[sub_second_order_pattern],
371
+ )
372
+
373
+ # Iterate over the currently fetched results
374
+ for cms_event in iterator:
375
+ # Increment the offset for each result
376
+ offset += 1
377
+
378
+ # Get the name of the search in the CMS event
379
+ cms_entry_name = cms_event["action.correlationsearch.label"]
380
+ self.logger.info(
381
+ f"[{self.infrastructure.instance_name}] {offset}: Matching cms_main entry "
382
+ f"'{cms_entry_name}' against detections"
383
+ )
384
+
385
+ # If CMS entry name matches one of the detections already matched, we've got an
386
+ # unexpected repeated entry
387
+ if cms_entry_name in matched_detections:
388
+ msg = (
389
+ f"[{self.infrastructure.instance_name}] [{cms_entry_name}]: Detection "
390
+ f"appears more than once in the cms_main index."
391
+ )
392
+ self.logger.error(msg)
393
+ exceptions.append(Exception(msg))
394
+ continue
395
+
396
+ # Iterate over the detections and compare the CMS entry name against each
397
+ result_matches_detection = False
398
+ for detection_cs_label in remaining_detections:
399
+ # If we find a match, break this loop, set the found flag and move the detection
400
+ # from those that still need to matched to those already matched
401
+ if cms_entry_name == detection_cs_label:
402
+ self.logger.info(
403
+ f"[{self.infrastructure.instance_name}] {offset}: Succesfully matched "
404
+ f"cms_main entry against detection ('{detection_cs_label}')!"
405
+ )
406
+
407
+ # Validate other fields of the cms_event against the detection
408
+ exception = self.validate_detection_against_cms_event(
409
+ cms_event, remaining_detections[detection_cs_label]
410
+ )
411
+
412
+ # Save the exception if validation failed
413
+ if exception is not None:
414
+ exceptions.append(exception)
415
+
416
+ # Delete the matched detection and move it to the matched list
417
+ result_matches_detection = True
418
+ matched_detections[detection_cs_label] = remaining_detections[
419
+ detection_cs_label
420
+ ]
421
+ del remaining_detections[detection_cs_label]
422
+ break
423
+
424
+ # Generate an exception if we couldn't match the CMS main entry to a detection
425
+ if result_matches_detection is False:
426
+ msg = (
427
+ f"[{self.infrastructure.instance_name}] [{cms_entry_name}]: Could not "
428
+ "match entry in cms_main against any of the expected detections."
429
+ )
430
+ self.logger.error(msg)
431
+ exceptions.append(Exception(msg))
432
+
433
+ # If we have any remaining detections, they could not be matched against an entry in
434
+ # cms_main and there may have been a parsing issue with savedsearches.conf
435
+ if len(remaining_detections) > 0:
436
+ # Generate exceptions for the unmatched detections
437
+ for detection_cs_label in remaining_detections:
438
+ msg = (
439
+ f"[{self.infrastructure.instance_name}] [{detection_cs_label}]: Detection not "
440
+ "found in cms_main; there may be an issue with savedsearches.conf"
441
+ )
442
+ self.logger.error(msg)
443
+ exceptions.append(Exception(msg))
444
+
445
+ # Raise exceptions as a group
446
+ if len(exceptions) > 0:
447
+ raise ExceptionGroup(
448
+ "1 or more issues validating our detections against the cms_main index",
449
+ exceptions,
450
+ )
451
+
452
+ # Else, we've matched/validated all detections against cms_main
453
+ self.logger.info(
454
+ f"[{self.infrastructure.instance_name}] Matched and validated all detections against "
455
+ "cms_main!"
456
+ )
457
+
458
+ def validate_detection_against_cms_event(
459
+ self, cms_event: dict[str, Any], detection: Detection
460
+ ) -> Exception | None:
461
+ """
462
+ Given an event from the cms_main index and the matched detection, compare fields and look
463
+ for any inconsistencies
464
+
465
+ :param cms_event: The event from the cms_main index
466
+ :type cms_event: dict[str, Any]
467
+ :param detection: The matched detection
468
+ :type detection: :class:`contentctl.objects.detection.Detection`
469
+
470
+ :return: The generated exception, or None
471
+ :rtype: Exception | None
472
+ """
473
+ # TODO (PEX-509): validate additional fields between the cms_event and the detection
474
+
475
+ cms_uuid = uuid.UUID(cms_event["detection_id"])
476
+ rule_name_from_detection = detection.get_action_dot_correlationsearch_dot_label(
477
+ self.global_config.app
478
+ )
479
+
480
+ # Compare the correlation search label
481
+ if cms_event["action.correlationsearch.label"] != rule_name_from_detection:
482
+ msg = (
483
+ f"[{self.infrastructure.instance_name}][{detection.name}]: Correlation search "
484
+ f"label in cms_event ('{cms_event['action.correlationsearch.label']}') does not "
485
+ "match detection name"
486
+ )
487
+ self.logger.error(msg)
488
+ return Exception(msg)
489
+ elif cms_uuid != detection.id:
490
+ # Compare the UUIDs
491
+ msg = (
492
+ f"[{self.infrastructure.instance_name}] [{detection.name}]: UUID in cms_event "
493
+ f"('{cms_uuid}') does not match UUID in detection ('{detection.id}')"
494
+ )
495
+ self.logger.error(msg)
496
+ return Exception(msg)
497
+ elif cms_event["version"] != f"{detection.version}.1":
498
+ # Compare the versions (we append '.1' to the detection version to be in line w/ the
499
+ # internal representation in ES)
500
+ msg = (
501
+ f"[{self.infrastructure.instance_name}] [{detection.name}]: Version in cms_event "
502
+ f"('{cms_event['version']}') does not match version in detection "
503
+ f"('{detection.version}.1')"
504
+ )
505
+ self.logger.error(msg)
506
+ return Exception(msg)
507
+
508
+ return None