contentctl 5.3.1__py3-none-any.whl → 5.4.0__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.
@@ -16,6 +16,7 @@ from contentctl.objects.errors import (
16
16
  DetectionMissingError,
17
17
  MetadataValidationError,
18
18
  VersionBumpingError,
19
+ VersionBumpingTooFarError,
19
20
  VersionDecrementedError,
20
21
  )
21
22
  from contentctl.objects.savedsearches_conf import SavedsearchesConf
@@ -101,7 +102,7 @@ class Inspect:
101
102
  -F "app_package=@<PATH/APP-PACKAGE>" \
102
103
  -F "included_tags=cloud" \
103
104
  --url "https://appinspect.splunk.com/v1/app/validate"
104
-
105
+
105
106
  This is confirmed by the great resource:
106
107
  https://curlconverter.com/
107
108
  """
@@ -429,6 +430,19 @@ class Inspect:
429
430
  )
430
431
  )
431
432
 
433
+ # Versions should never increase more than one version between releases
434
+ if (
435
+ current_stanza.metadata.detection_version
436
+ > previous_stanza.metadata.detection_version + 1
437
+ ):
438
+ validation_errors[rule_name].append(
439
+ VersionBumpingTooFarError(
440
+ rule_name=rule_name,
441
+ current_version=current_stanza.metadata.detection_version,
442
+ previous_version=previous_stanza.metadata.detection_version,
443
+ )
444
+ )
445
+
432
446
  # Convert our dict mapping to a flat list of errors for use in reporting
433
447
  validation_error_list = [
434
448
  x for inner_list in validation_errors.values() for x in inner_list
@@ -4,7 +4,7 @@ from contentctl.enrichments.attack_enrichment import AttackEnrichment
4
4
  from contentctl.enrichments.cve_enrichment import CveEnrichment
5
5
  from contentctl.helper.splunk_app import SplunkApp
6
6
  from contentctl.helper.utils import Utils
7
- from contentctl.input.director import Director, DirectorOutputDto
7
+ from contentctl.input.director import Director, DirectorOutputDto, ValidationFailedError
8
8
  from contentctl.objects.atomic import AtomicEnrichment
9
9
  from contentctl.objects.config import validate
10
10
  from contentctl.objects.data_source import DataSource
@@ -13,19 +13,26 @@ from contentctl.objects.lookup import FileBackedLookup, RuntimeCSV
13
13
 
14
14
  class Validate:
15
15
  def execute(self, input_dto: validate) -> DirectorOutputDto:
16
- director_output_dto = DirectorOutputDto(
17
- AtomicEnrichment.getAtomicEnrichment(input_dto),
18
- AttackEnrichment.getAttackEnrichment(input_dto),
19
- CveEnrichment.getCveEnrichment(input_dto),
20
- )
16
+ try:
17
+ director_output_dto = DirectorOutputDto(
18
+ AtomicEnrichment.getAtomicEnrichment(input_dto),
19
+ AttackEnrichment.getAttackEnrichment(input_dto),
20
+ CveEnrichment.getCveEnrichment(input_dto),
21
+ )
22
+
23
+ director = Director(director_output_dto)
24
+ director.execute(input_dto)
25
+ self.ensure_no_orphaned_files_in_lookups(
26
+ input_dto.path, director_output_dto
27
+ )
28
+ if input_dto.data_source_TA_validation:
29
+ self.validate_latest_TA_information(director_output_dto.data_sources)
21
30
 
22
- director = Director(director_output_dto)
23
- director.execute(input_dto)
24
- self.ensure_no_orphaned_files_in_lookups(input_dto.path, director_output_dto)
25
- if input_dto.data_source_TA_validation:
26
- self.validate_latest_TA_information(director_output_dto.data_sources)
31
+ return director_output_dto
27
32
 
28
- return director_output_dto
33
+ except ValidationFailedError:
34
+ # Just re-raise without additional output since we already formatted everything
35
+ raise SystemExit(1)
29
36
 
30
37
  def ensure_no_orphaned_files_in_lookups(
31
38
  self, repo_path: pathlib.Path, director_output_dto: DirectorOutputDto
@@ -109,6 +109,40 @@ class DirectorOutputDto:
109
109
  self.uuid_to_content_map[content.id] = content
110
110
 
111
111
 
112
+ class Colors:
113
+ HEADER = "\033[95m"
114
+ BLUE = "\033[94m"
115
+ CYAN = "\033[96m"
116
+ GREEN = "\033[92m"
117
+ YELLOW = "\033[93m"
118
+ RED = "\033[91m"
119
+ BOLD = "\033[1m"
120
+ UNDERLINE = "\033[4m"
121
+ END = "\033[0m"
122
+ MAGENTA = "\033[35m"
123
+ BRIGHT_MAGENTA = "\033[95m"
124
+
125
+ # Add fallback symbols for Windows
126
+ CHECK_MARK = "✓" if sys.platform != "win32" else "*"
127
+ WARNING = "⚠️" if sys.platform != "win32" else "!"
128
+ ERROR = "❌" if sys.platform != "win32" else "X"
129
+ ARROW = "🎯" if sys.platform != "win32" else ">"
130
+ TOOLS = "🛠️" if sys.platform != "win32" else "#"
131
+ DOCS = "📚" if sys.platform != "win32" else "?"
132
+ BULB = "💡" if sys.platform != "win32" else "i"
133
+ SEARCH = "🔍" if sys.platform != "win32" else "@"
134
+ SPARKLE = "✨" if sys.platform != "win32" else "*"
135
+ ZAP = "⚡" if sys.platform != "win32" else "!"
136
+
137
+
138
+ class ValidationFailedError(Exception):
139
+ """Custom exception for validation failures that already have formatted output."""
140
+
141
+ def __init__(self, message: str):
142
+ self.message = message
143
+ super().__init__(message)
144
+
145
+
112
146
  class Director:
113
147
  input_dto: validate
114
148
  output_dto: DirectorOutputDto
@@ -268,18 +302,96 @@ class Director:
268
302
  end="",
269
303
  flush=True,
270
304
  )
271
- print("Done!")
272
305
 
273
306
  if len(validation_errors) > 0:
274
- errors_string = "\n\n".join(
275
- [
276
- f"File: {e_tuple[0]}\nError: {str(e_tuple[1])}"
277
- for e_tuple in validation_errors
278
- ]
307
+ if sys.platform == "win32":
308
+ sys.stdout.reconfigure(encoding="utf-8")
309
+
310
+ print("\n") # Clean separation
311
+ print(f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}╔{'═' * 60}╗{Colors.END}")
312
+ print(
313
+ f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}║{Colors.BLUE}{f'{Colors.SEARCH} Content Validation Summary':^59}{Colors.BRIGHT_MAGENTA}║{Colors.END}"
279
314
  )
280
- # print(f"The following {len(validation_errors)} error(s) were found during validation:\n\n{errors_string}\n\nVALIDATION FAILED")
281
- # We quit after validation a single type/group of content because it can cause significant cascading errors in subsequent
282
- # types of content (since they may import or otherwise use it)
283
- raise Exception(
284
- f"The following {len(validation_errors)} error(s) were found during validation:\n\n{errors_string}\n\nVALIDATION FAILED"
315
+ print(f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}╚{'═' * 60}╝{Colors.END}\n")
316
+
317
+ print(
318
+ f"{Colors.BOLD}{Colors.GREEN}{Colors.SPARKLE} Validation Completed{Colors.END} – Issues detected in {Colors.RED}{Colors.BOLD}{len(validation_errors)}{Colors.END} files.\n"
285
319
  )
320
+
321
+ for index, entry in enumerate(validation_errors, 1):
322
+ file_path, error = entry
323
+ width = max(70, len(str(file_path)) + 15)
324
+
325
+ # File header with numbered emoji
326
+ number_emoji = f"{index}️⃣"
327
+ print(f"{Colors.YELLOW}┏{'━' * width}┓{Colors.END}")
328
+ print(
329
+ f"{Colors.YELLOW}┃{Colors.BOLD} {number_emoji} File: {Colors.CYAN}{file_path}{Colors.END}{' ' * (width - len(str(file_path)) - 9)}{Colors.YELLOW}┃{Colors.END}"
330
+ )
331
+ print(f"{Colors.YELLOW}┗{'━' * width}┛{Colors.END}")
332
+
333
+ print(
334
+ f" {Colors.RED}{Colors.BOLD}{Colors.ZAP} Validation Issues:{Colors.END}"
335
+ )
336
+
337
+ if isinstance(error, ValidationError):
338
+ for err in error.errors():
339
+ error_msg = err.get("msg", "")
340
+ if "https://errors.pydantic.dev" in error_msg:
341
+ continue
342
+
343
+ # Clean error categorization
344
+ if "Field required" in error_msg:
345
+ print(
346
+ f" {Colors.YELLOW}{Colors.WARNING} Field Required: {err.get('loc', [''])[0]}{Colors.END}"
347
+ )
348
+ elif "Input should be" in error_msg:
349
+ print(
350
+ f" {Colors.MAGENTA}{Colors.ARROW} Invalid Value for {err.get('loc', [''])[0]}{Colors.END}"
351
+ )
352
+ if err.get("ctx", {}).get("expected", None) is not None:
353
+ print(
354
+ f" Valid options: {err.get('ctx', {}).get('expected', None)}"
355
+ )
356
+ elif "Extra inputs" in error_msg:
357
+ print(
358
+ f" {Colors.BLUE}{Colors.ERROR} Unexpected Field: {err.get('loc', [''])[0]}{Colors.END}"
359
+ )
360
+ elif "Failed to find" in error_msg:
361
+ print(
362
+ f" {Colors.RED}{Colors.SEARCH} Missing Reference: {error_msg}{Colors.END}"
363
+ )
364
+ else:
365
+ print(
366
+ f" {Colors.RED}{Colors.ERROR} {error_msg}{Colors.END}"
367
+ )
368
+ else:
369
+ print(f" {Colors.RED}{Colors.ERROR} {str(error)}{Colors.END}")
370
+ print("")
371
+
372
+ # Clean footer with next steps
373
+ max_width = max(60, max(len(str(e[0])) + 15 for e in validation_errors))
374
+ print(f"{Colors.BOLD}{Colors.CYAN}╔{'═' * max_width}╗{Colors.END}")
375
+ print(
376
+ f"{Colors.BOLD}{Colors.CYAN}║{Colors.BLUE}{Colors.ARROW + ' Next Steps':^{max_width - 1}}{Colors.CYAN}║{Colors.END}"
377
+ )
378
+ print(f"{Colors.BOLD}{Colors.CYAN}╚{'═' * max_width}╝{Colors.END}\n")
379
+
380
+ print(
381
+ f"{Colors.GREEN}{Colors.TOOLS} Fix the validation issues in the listed files{Colors.END}"
382
+ )
383
+ print(
384
+ f"{Colors.YELLOW}{Colors.DOCS} Check the documentation: {Colors.UNDERLINE}https://github.com/splunk/contentctl{Colors.END}"
385
+ )
386
+ print(
387
+ f"{Colors.BLUE}{Colors.BULB} Use --verbose for detailed error information{Colors.END}\n"
388
+ )
389
+
390
+ raise ValidationFailedError(
391
+ f"Validation failed with {len(validation_errors)} error(s)"
392
+ )
393
+
394
+ # Success case
395
+ print(
396
+ f"\r{f'{contentCartegoryName} Progress'.rjust(23)}: [{progress_percent:3.0f}%]... {Colors.GREEN}{Colors.CHECK_MARK} Done!{Colors.END}"
397
+ )
@@ -12,6 +12,7 @@ import pathlib
12
12
  import pprint
13
13
  import uuid
14
14
  from abc import abstractmethod
15
+ from difflib import get_close_matches
15
16
  from functools import cached_property
16
17
  from typing import List, Optional, Tuple, Union
17
18
 
@@ -700,16 +701,18 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
700
701
  def mapNamesToSecurityContentObjects(
701
702
  cls, v: list[str], director: Union[DirectorOutputDto, None]
702
703
  ) -> list[Self]:
703
- if director is not None:
704
- name_map = director.name_to_content_map
705
- else:
706
- name_map = {}
704
+ if director is None:
705
+ raise Exception(
706
+ "Direction was 'None' when passed to "
707
+ "'mapNamesToSecurityContentObjects'. This is "
708
+ "an error in the contentctl codebase which must be resolved."
709
+ )
707
710
 
708
711
  mappedObjects: list[Self] = []
709
712
  mistyped_objects: list[SecurityContentObject_Abstract] = []
710
713
  missing_objects: list[str] = []
711
714
  for object_name in v:
712
- found_object = name_map.get(object_name, None)
715
+ found_object = director.name_to_content_map.get(object_name, None)
713
716
  if not found_object:
714
717
  missing_objects.append(object_name)
715
718
  elif not isinstance(found_object, cls):
@@ -718,22 +721,40 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
718
721
  mappedObjects.append(found_object)
719
722
 
720
723
  errors: list[str] = []
721
- if len(missing_objects) > 0:
724
+ for missing_object in missing_objects:
725
+ if missing_object.endswith("_filter"):
726
+ # Most filter macros are defined as empty at runtime, so we do not
727
+ # want to make any suggestions. It is time consuming and not helpful
728
+ # to make these suggestions, so we just skip them in this check.
729
+ continue
730
+ matches = get_close_matches(
731
+ missing_object,
732
+ director.name_to_content_map.keys(),
733
+ n=3,
734
+ )
735
+ if matches == []:
736
+ matches = ["NO SUGGESTIONS"]
737
+
738
+ matches_string = ", ".join(matches)
722
739
  errors.append(
723
- f"Failed to find the following '{cls.__name__}': {missing_objects}"
740
+ f"Unable to find: {missing_object}\n Suggestions: {matches_string}"
741
+ )
742
+
743
+ for mistyped_object in mistyped_objects:
744
+ matches = get_close_matches(
745
+ mistyped_object.name, director.name_to_content_map.keys(), n=3
746
+ )
747
+
748
+ errors.append(
749
+ f"'{mistyped_object.name}' expected to have type '{cls.__name__}', but actually "
750
+ f"had type '{type(mistyped_object).__name__}'"
724
751
  )
725
- if len(mistyped_objects) > 0:
726
- for mistyped_object in mistyped_objects:
727
- errors.append(
728
- f"'{mistyped_object.name}' expected to have type '{cls}', but actually "
729
- f"had type '{type(mistyped_object)}'"
730
- )
731
752
 
732
753
  if len(errors) > 0:
733
- error_string = "\n - ".join(errors)
754
+ error_string = "\n\n - ".join(errors)
734
755
  raise ValueError(
735
- f"Found {len(errors)} issues when resolving references Security Content Object "
736
- f"names:\n - {error_string}"
756
+ f"Found {len(errors)} issues when resolving references to '{cls.__name__}' objects:\n"
757
+ f" - {error_string}"
737
758
  )
738
759
 
739
760
  # Sort all objects sorted by name
@@ -0,0 +1,28 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+ from contentctl.objects.detection import Detection
6
+
7
+
8
+ class BaseSecurityEvent(BaseModel, ABC):
9
+ """
10
+ Base event class for a Splunk security event (e.g. risks and notables)
11
+ """
12
+
13
+ # The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
14
+ search_name: str
15
+
16
+ # The search ID that found that generated this event
17
+ orig_sid: str
18
+
19
+ # Allowing fields that aren't explicitly defined to be passed since some of the risk/notable
20
+ # event's fields vary depending on the SPL which generated them
21
+ model_config = ConfigDict(extra="allow")
22
+
23
+ @abstractmethod
24
+ def validate_against_detection(self, detection: Detection) -> None:
25
+ """
26
+ Validate this risk/notable event against the given detection
27
+ """
28
+ raise NotImplementedError()
@@ -18,6 +18,7 @@ from contentctl.actions.detection_testing.progress_bar import (
18
18
  format_pbar_string, # type: ignore
19
19
  )
20
20
  from contentctl.helper.utils import Utils
21
+ from contentctl.objects.base_security_event import BaseSecurityEvent
21
22
  from contentctl.objects.base_test_result import TestResultStatus
22
23
  from contentctl.objects.detection import Detection
23
24
  from contentctl.objects.errors import (
@@ -222,6 +223,9 @@ class CorrelationSearch(BaseModel):
222
223
  # The list of risk events found
223
224
  _risk_events: list[RiskEvent] | None = PrivateAttr(default=None)
224
225
 
226
+ # The list of risk data model events found
227
+ _risk_dm_events: list[BaseSecurityEvent] | None = PrivateAttr(default=None)
228
+
225
229
  # The list of notable events found
226
230
  _notable_events: list[NotableEvent] | None = PrivateAttr(default=None)
227
231
 
@@ -554,6 +558,13 @@ class CorrelationSearch(BaseModel):
554
558
  raise
555
559
  events.append(event)
556
560
  self.logger.debug(f"Found risk event for '{self.name}': {event}")
561
+ else:
562
+ msg = (
563
+ f"Found event for unexpected index ({result['index']}) in our query "
564
+ f"results (expected {Indexes.RISK_INDEX})"
565
+ )
566
+ self.logger.error(msg)
567
+ raise ValueError(msg)
557
568
  except ServerError as e:
558
569
  self.logger.error(f"Error returned from Splunk instance: {e}")
559
570
  raise e
@@ -623,6 +634,13 @@ class CorrelationSearch(BaseModel):
623
634
  raise
624
635
  events.append(event)
625
636
  self.logger.debug(f"Found notable event for '{self.name}': {event}")
637
+ else:
638
+ msg = (
639
+ f"Found event for unexpected index ({result['index']}) in our query "
640
+ f"results (expected {Indexes.NOTABLE_INDEX})"
641
+ )
642
+ self.logger.error(msg)
643
+ raise ValueError(msg)
626
644
  except ServerError as e:
627
645
  self.logger.error(f"Error returned from Splunk instance: {e}")
628
646
  raise e
@@ -637,15 +655,119 @@ class CorrelationSearch(BaseModel):
637
655
 
638
656
  return events
639
657
 
658
+ def risk_dm_event_exists(self) -> bool:
659
+ """Whether at least one matching risk data model event exists
660
+
661
+ Queries the `risk` data model and returns True if at least one matching event (could come
662
+ from risk or notable index) exists for this search
663
+ :return: a bool indicating whether a risk data model event for this search exists in the
664
+ risk data model
665
+ """
666
+ # We always force an update on the cache when checking if events exist
667
+ events = self.get_risk_dm_events(force_update=True)
668
+ return len(events) > 0
669
+
670
+ def get_risk_dm_events(self, force_update: bool = False) -> list[BaseSecurityEvent]:
671
+ """Get risk data model events from the Splunk instance
672
+
673
+ Queries the `risk` data model and returns any matching events (could come from risk or
674
+ notable index)
675
+ :param force_update: whether the cached _risk_events should be forcibly updated if already
676
+ set
677
+ :return: a list of risk events
678
+ """
679
+ # Reset the list of risk data model events if we're forcing an update
680
+ if force_update:
681
+ self.logger.debug("Resetting risk data model event cache.")
682
+ self._risk_dm_events = None
683
+
684
+ # Use the cached risk_dm_events unless we're forcing an update
685
+ if self._risk_dm_events is not None:
686
+ self.logger.debug(
687
+ f"Using cached risk data model events ({len(self._risk_dm_events)} total)."
688
+ )
689
+ return self._risk_dm_events
690
+
691
+ # TODO (#248): Refactor risk/notable querying to pin to a single savedsearch ID
692
+ # Search for all risk data model events from a single scheduled search (indicated by
693
+ # orig_sid)
694
+ query = (
695
+ f'datamodel Risk All_Risk flat | search search_name="{self.name}" [datamodel Risk '
696
+ f'All_Risk flat | search search_name="{self.name}" | tail 1 | fields orig_sid] '
697
+ "| tojson"
698
+ )
699
+ result_iterator = self._search(query)
700
+
701
+ # Iterate over the events, storing them in a list and checking for any errors
702
+ events: list[BaseSecurityEvent] = []
703
+ risk_count = 0
704
+ notable_count = 0
705
+ try:
706
+ for result in result_iterator:
707
+ # sanity check that this result from the iterator is a risk event and not some
708
+ # other metadata
709
+ if result["index"] == Indexes.RISK_INDEX:
710
+ try:
711
+ parsed_raw = json.loads(result["_raw"])
712
+ event = RiskEvent.model_validate(parsed_raw)
713
+ except Exception:
714
+ self.logger.error(
715
+ f"Failed to parse RiskEvent from search result: {result}"
716
+ )
717
+ raise
718
+ events.append(event)
719
+ risk_count += 1
720
+ self.logger.debug(
721
+ f"Found risk event in risk data model for '{self.name}': {event}"
722
+ )
723
+ elif result["index"] == Indexes.NOTABLE_INDEX:
724
+ try:
725
+ parsed_raw = json.loads(result["_raw"])
726
+ event = NotableEvent.model_validate(parsed_raw)
727
+ except Exception:
728
+ self.logger.error(
729
+ f"Failed to parse NotableEvent from search result: {result}"
730
+ )
731
+ raise
732
+ events.append(event)
733
+ notable_count += 1
734
+ self.logger.debug(
735
+ f"Found notable event in risk data model for '{self.name}': {event}"
736
+ )
737
+ else:
738
+ msg = (
739
+ f"Found event for unexpected index ({result['index']}) in our query "
740
+ f"results (expected {Indexes.NOTABLE_INDEX} or {Indexes.RISK_INDEX})"
741
+ )
742
+ self.logger.error(msg)
743
+ raise ValueError(msg)
744
+ except ServerError as e:
745
+ self.logger.error(f"Error returned from Splunk instance: {e}")
746
+ raise e
747
+
748
+ # Log if no events were found
749
+ if len(events) < 1:
750
+ self.logger.debug(f"No events found in risk data model for '{self.name}'")
751
+ else:
752
+ # Set the cache if we found events
753
+ self._risk_dm_events = events
754
+ self.logger.debug(
755
+ f"Caching {len(self._risk_dm_events)} risk data model events."
756
+ )
757
+
758
+ # Log counts of risk and notable events found
759
+ self.logger.debug(
760
+ f"Found {risk_count} risk events and {notable_count} notable events in the risk data "
761
+ "model"
762
+ )
763
+
764
+ return events
765
+
640
766
  def validate_risk_events(self) -> None:
641
767
  """Validates the existence of any expected risk events
642
768
 
643
769
  First ensure the risk event exists, and if it does validate its risk message and make sure
644
- any events align with the specified risk object. Also adds the risk index to the purge list
645
- if risk events existed
646
- :param elapsed_sleep_time: an int representing the amount of time slept thus far waiting to
647
- check the risks/notables
648
- :returns: an IntegrationTestResult on failure; None on success
770
+ any events align with the specified risk object.
649
771
  """
650
772
  # Ensure the rba object is defined
651
773
  if self.detection.rba is None:
@@ -735,13 +857,29 @@ class CorrelationSearch(BaseModel):
735
857
  def validate_notable_events(self) -> None:
736
858
  """Validates the existence of any expected notables
737
859
 
738
- Ensures the notable exists. Also adds the notable index to the purge list if notables
739
- existed
740
- :param elapsed_sleep_time: an int representing the amount of time slept thus far waiting to
741
- check the risks/notables
742
- :returns: an IntegrationTestResult on failure; None on success
860
+ Check various fields within the notable to ensure alignment with the detection definition.
861
+ Additionally, ensure that the notable does not appear in the risk data model, as this is
862
+ currently undesired behavior for ESCU detections.
863
+ """
864
+ if self.notable_in_risk_dm():
865
+ raise ValidationFailed(
866
+ "One or more notables appeared in the risk data model. This could lead to risk "
867
+ "score doubling, and/or notable multiplexing, depending on the detection type "
868
+ "(e.g. TTP), or the number of risk modifiers."
869
+ )
870
+
871
+ def notable_in_risk_dm(self) -> bool:
872
+ """Check if notables are in the risk data model
873
+
874
+ Returns a bool indicating whether notables are in the risk data model or not.
875
+
876
+ :returns: a bool, True if notables are in the risk data model results; False if not
743
877
  """
744
- raise NotImplementedError()
878
+ if self.risk_dm_event_exists():
879
+ for event in self.get_risk_dm_events():
880
+ if isinstance(event, NotableEvent):
881
+ return True
882
+ return False
745
883
 
746
884
  # NOTE: it would be more ideal to switch this to a system which gets the handle of the saved search job and polls
747
885
  # it for completion, but that seems more tricky
@@ -828,8 +966,8 @@ class CorrelationSearch(BaseModel):
828
966
 
829
967
  try:
830
968
  # Validate risk events
831
- self.logger.debug("Checking for matching risk events")
832
969
  if self.has_risk_analysis_action:
970
+ self.logger.debug("Checking for matching risk events")
833
971
  if self.risk_event_exists():
834
972
  # TODO (PEX-435): should this in the retry loop? or outside it?
835
973
  # -> I've observed there being a missing risk event (15/16) on
@@ -846,22 +984,28 @@ class CorrelationSearch(BaseModel):
846
984
  raise ValidationFailed(
847
985
  f"TEST FAILED: No matching risk event created for: {self.name}"
848
986
  )
987
+ else:
988
+ self.logger.debug(
989
+ f"No risk action defined for '{self.name}'"
990
+ )
849
991
 
850
992
  # Validate notable events
851
- self.logger.debug("Checking for matching notable events")
852
993
  if self.has_notable_action:
994
+ self.logger.debug("Checking for matching notable events")
853
995
  # NOTE: because we check this last, if both fail, the error message about notables will
854
996
  # always be the last to be added and thus the one surfaced to the user
855
997
  if self.notable_event_exists():
856
998
  # TODO (PEX-435): should this in the retry loop? or outside it?
857
- # TODO (PEX-434): implement deeper notable validation (the method
858
- # commented out below is unimplemented)
859
- # self.validate_notable_events(elapsed_sleep_time)
999
+ self.validate_notable_events()
860
1000
  pass
861
1001
  else:
862
1002
  raise ValidationFailed(
863
1003
  f"TEST FAILED: No matching notable event created for: {self.name}"
864
1004
  )
1005
+ else:
1006
+ self.logger.debug(
1007
+ f"No notable action defined for '{self.name}'"
1008
+ )
865
1009
  except ValidationFailed as e:
866
1010
  self.logger.error(f"Risk/notable validation failed: {e}")
867
1011
  result = IntegrationTestResult(
@@ -1015,6 +1159,7 @@ class CorrelationSearch(BaseModel):
1015
1159
  # reset caches
1016
1160
  self._risk_events = None
1017
1161
  self._notable_events = None
1162
+ self._risk_dm_events = None
1018
1163
 
1019
1164
  def update_pbar(self, state: str) -> str:
1020
1165
  """
@@ -185,7 +185,7 @@ class VersionBumpingError(VersioningError):
185
185
  return (
186
186
  f"Rule '{self.rule_name}' has changed in current build compared to previous "
187
187
  "build (stanza hashes differ); the detection version should be bumped "
188
- f"to at least {self.previous_version + 1}."
188
+ f"to {self.previous_version + 1}."
189
189
  )
190
190
 
191
191
  @property
@@ -194,4 +194,30 @@ class VersionBumpingError(VersioningError):
194
194
  A short-form error message
195
195
  :returns: a str, the message
196
196
  """
197
- return f"Detection version in current build should be bumped to at least {self.previous_version + 1}."
197
+ return f"Detection version in current build should be bumped to {self.previous_version + 1}."
198
+
199
+
200
+ class VersionBumpingTooFarError(VersioningError):
201
+ """
202
+ An error indicating the detection changed but its version was bumped too far
203
+ """
204
+
205
+ @property
206
+ def long_message(self) -> str:
207
+ """
208
+ A long-form error message
209
+ :returns: a str, the message
210
+ """
211
+ return (
212
+ f"Rule '{self.rule_name}' has changed in current build compared to previous "
213
+ "build (stanza hashes differ); however the detection version increased too much"
214
+ f"The version should be reduced to {self.previous_version + 1}."
215
+ )
216
+
217
+ @property
218
+ def short_message(self) -> str:
219
+ """
220
+ A short-form error message
221
+ :returns: a str, the message
222
+ """
223
+ return f"Detection version in current build should be reduced to {self.previous_version + 1}."
@@ -1,19 +1,12 @@
1
- from pydantic import ConfigDict, BaseModel
2
-
1
+ from contentctl.objects.base_security_event import BaseSecurityEvent
3
2
  from contentctl.objects.detection import Detection
4
3
 
5
4
 
6
- # TODO (PEX-434): implement deeper notable validation
7
- class NotableEvent(BaseModel):
8
- # The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
9
- search_name: str
10
-
11
- # The search ID that found that generated this risk event
12
- orig_sid: str
13
-
14
- # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
15
- # fields vary depending on the SPL which generated them
16
- model_config = ConfigDict(extra="allow")
5
+ class NotableEvent(BaseSecurityEvent):
6
+ # TODO (PEX-434): implement deeper notable validation
17
7
 
18
8
  def validate_against_detection(self, detection: Detection) -> None:
9
+ """
10
+ Validate this risk/notable event against the given detection
11
+ """
19
12
  raise NotImplementedError()
@@ -1,26 +1,17 @@
1
1
  import re
2
2
  from functools import cached_property
3
3
 
4
- from pydantic import (
5
- BaseModel,
6
- ConfigDict,
7
- Field,
8
- PrivateAttr,
9
- computed_field,
10
- field_validator,
11
- )
4
+ from pydantic import Field, PrivateAttr, computed_field, field_validator
12
5
 
6
+ from contentctl.objects.base_security_event import BaseSecurityEvent
13
7
  from contentctl.objects.detection import Detection
14
8
  from contentctl.objects.errors import ValidationFailed
15
9
  from contentctl.objects.rba import RiskObject
16
10
 
17
11
 
18
- class RiskEvent(BaseModel):
12
+ class RiskEvent(BaseSecurityEvent):
19
13
  """Model for risk event in ES"""
20
14
 
21
- # The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
22
- search_name: str
23
-
24
15
  # The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
25
16
  # (not to be confused w/ the risk object from the detection)
26
17
  es_risk_object: int | str = Field(alias="risk_object")
@@ -32,9 +23,6 @@ class RiskEvent(BaseModel):
32
23
  # The level of risk associated w/ the risk event
33
24
  risk_score: int
34
25
 
35
- # The search ID that found that generated this risk event
36
- orig_sid: str
37
-
38
26
  # The message for the risk event
39
27
  risk_message: str
40
28
 
@@ -53,10 +41,6 @@ class RiskEvent(BaseModel):
53
41
  # Private attribute caching the risk object this RiskEvent is mapped to
54
42
  _matched_risk_object: RiskObject | None = PrivateAttr(default=None)
55
43
 
56
- # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
57
- # fields vary depending on the SPL which generated them
58
- model_config = ConfigDict(extra="allow")
59
-
60
44
  @field_validator("annotations_mitre_attack", "analyticstories", mode="before")
61
45
  @classmethod
62
46
  def _convert_str_value_to_singleton(cls, v: str | list[str]) -> list[str]:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: contentctl
3
- Version: 5.3.1
3
+ Version: 5.4.0
4
4
  Summary: Splunk Content Control Tool
5
5
  License: Apache 2.0
6
6
  Author: STRT
@@ -14,12 +14,12 @@ contentctl/actions/detection_testing/views/DetectionTestingViewFile.py,sha256=G-
14
14
  contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py,sha256=CXV1fByf3J-Jc4D9U6jgWSaUhNzjcMpvEgRMuusF2vU,4740
15
15
  contentctl/actions/doc_gen.py,sha256=P2-RYsJoW-QuhAkSpOQespDLJBC-4Cq3-XGTmadK8Ys,936
16
16
  contentctl/actions/initialize.py,sha256=KXVUyjLMS7yE34wd2odyj5pVXyc_eOlvH_d7LzgR_Bc,4238
17
- contentctl/actions/inspect.py,sha256=zFNbDXY7Bi1xTBHirNyHpH1-2A1n3rsOsRvu8E0xUao,19375
17
+ contentctl/actions/inspect.py,sha256=ER1CJZk5ls4bithhDimXmBJepQ6ha1Ns-D2z-AZUdcQ,19991
18
18
  contentctl/actions/new_content.py,sha256=xs0QvHzlrf0g-EgdUJTkdDdFaA-uEGmzMTixDt6NcTY,8212
19
19
  contentctl/actions/release_notes.py,sha256=rrloomsLBfl53xpjqDez6RgHU5AE4Gb9ASrivGbYYVs,17122
20
20
  contentctl/actions/reporting.py,sha256=GF32i7sHdc47bw-VWSW-nZ1QBaUl6Ni1JjV5_SOyiAU,1660
21
21
  contentctl/actions/test.py,sha256=ftZazqoqv7bLNhyW23aRnDpetG9zltS8wr4Xq9Hls0k,6268
22
- contentctl/actions/validate.py,sha256=hgcczMFA8xOo4T--RviBPJdZ7INfkq5kCZkWFB7rnDs,5879
22
+ contentctl/actions/validate.py,sha256=teqRVxNlUgzDvKQm-sXb05TST05duA2-NhJOzNxlBTw,6152
23
23
  contentctl/api.py,sha256=6s17vNOW1E1EzQqOCXAa5uWuhwwShu-JkGSgrsOFEMs,6329
24
24
  contentctl/contentctl.py,sha256=nR8nHxXY0elvQogVHFqsyid7Ch5sMnIiNAOFbCa0yzI,11755
25
25
  contentctl/enrichments/attack_enrichment.py,sha256=68C9xQ8Q3YX-luRdK2hLnwWtRFpheFA2kE4v5GOLGEo,6358
@@ -29,14 +29,15 @@ contentctl/helper/link_validator.py,sha256=kzEi2GdncPWSi-UKNerXm2jtTJfFQ5goS9pqy
29
29
  contentctl/helper/logger.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
30
30
  contentctl/helper/splunk_app.py,sha256=Zq_C9rjNVqCjBNgm-5CWdBpXyeX5jSpbE-QTGptEZlk,14571
31
31
  contentctl/helper/utils.py,sha256=1_6cbvvbPXWxym3ZhRhL18ttmXLXiHbavpXAkROtGcg,21154
32
- contentctl/input/director.py,sha256=rThzfssOG4v52ClhVwUx-sU0MKWb_UaMy3MSANCrwmo,11999
32
+ contentctl/input/director.py,sha256=h2F7vfP56RyBhrUmI9_9Wn_gDIH2d1NKepc8Yt5cHf4,17002
33
33
  contentctl/input/new_content_questions.py,sha256=z2C4Mg7-EyxtiF2z9m4SnSbi6QO4CUPB3wg__JeMXIQ,4067
34
34
  contentctl/input/yml_reader.py,sha256=L27b14_xXQYypUV1eAzZrfMtwtkzAZx--6nRo3RNZnE,2729
35
35
  contentctl/objects/abstract_security_content_objects/detection_abstract.py,sha256=Gsb6v44VpWeGnMi79viJ0zO81pZ9GuiaqQJyHM-ba5I,46861
36
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py,sha256=VzAkq-oJ05Y7Y2BzoUG8De8tVANV5X9ZvHbrDe65iRA,33282
36
+ contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py,sha256=un1tjoZdxKi07dSddPD5MXRN3tea3dj0poKmFrbCu-k,34206
37
37
  contentctl/objects/alert_action.py,sha256=iEvdEOT4TrTXT0z4rQ_W5v79hPJpPhFPSzo7TuHDxwA,1376
38
38
  contentctl/objects/annotated_types.py,sha256=xR4EKvdOpNDEt0doGs8XjxCzKK99J2NHZgHFAmt7p2c,424
39
39
  contentctl/objects/atomic.py,sha256=5nl-JhZnymadi8B8ZEJ8l80DnpvjG-OlRxUjVKR6ffY,7341
40
+ contentctl/objects/base_security_event.py,sha256=VxfsMuu1IV0uKutEkqowEZRbXORmu51VKCjHC-KUbeo,890
40
41
  contentctl/objects/base_test.py,sha256=JG6qlr7xe9P71n3CzKOro8_bsmDQGYDfTG9YooHQSIE,1105
41
42
  contentctl/objects/base_test_result.py,sha256=TYYzTPKWqp9rHTebWoid50uxAp_iALZouril4sFwIcA,5197
42
43
  contentctl/objects/baseline.py,sha256=EMcuz_9sVgOFh3YCj871GSAA6v3FIkRTf90-LAHq-J0,3700
@@ -44,7 +45,7 @@ contentctl/objects/baseline_tags.py,sha256=Eomy8y3HV-E6Lym5B5ZZTtsmQJYi6Jd4y8GZp
44
45
  contentctl/objects/config.py,sha256=8F_zpcnFyE_rSdGomm2qeVDG6tTpjhrVyK6BsNHM8js,49357
45
46
  contentctl/objects/constants.py,sha256=VwwQtJBGC_zb3ukjb3A7P0CwAlyhacWiXczwAW5Jiog,5466
46
47
  contentctl/objects/content_versioning_service.py,sha256=BDk_TV1PTVoXpPcUxqTLa5_bjkfOs9PFYgqTuzOS9UI,20566
47
- contentctl/objects/correlation_search.py,sha256=Zui6GAtYSUnMUzJkKOQ5sSYDTYoAxODUICpHXwxNMwo,45147
48
+ contentctl/objects/correlation_search.py,sha256=tvcFeHGFRyZso50KHsEVCjTmHh3rHDegcfqaD-TjCFg,51473
48
49
  contentctl/objects/dashboard.py,sha256=owp-bYVagmSHUpVyOHgGtAEaKFSHAXN7kDhYqs09H_g,4998
49
50
  contentctl/objects/data_source.py,sha256=O58GArXVlflz3dCtVOn96Ubyi5_ekSC1N9LuveQNws4,2019
50
51
  contentctl/objects/deployment.py,sha256=OctNayxFPRvrQtTklAKgfjCXFKOspD19swLj0hi6dWE,3323
@@ -60,7 +61,7 @@ contentctl/objects/detection_stanza.py,sha256=-BRQNib5NNhY7Z2fILS5xkpjNkGSLF7qBc
60
61
  contentctl/objects/detection_tags.py,sha256=j92t4TWlNNVdFi4_DoHvEyvJuURlBp5_o1xv2w2pAVk,10699
61
62
  contentctl/objects/drilldown.py,sha256=Vinw6UYlOl0YzoRA_0oBCfHA5Gvgu5p-rEsfBIgMCdI,4186
62
63
  contentctl/objects/enums.py,sha256=nWufu5YgzllBfDQBneIe_Hf_erNXouERciqU_di5DNo,13754
63
- contentctl/objects/errors.py,sha256=xX_FDUaJbJiOWgjgrzjtYW5QsD41UZ2KWqH-yGkHaCU,5554
64
+ contentctl/objects/errors.py,sha256=7ebvjAR9W2Wj0a4ihdOakGPZRNr7rDDZe0X3rvhh_dE,6367
64
65
  contentctl/objects/integration_test.py,sha256=TYjKyH4YinUnYXOse5BQGCa4-ez_5mtoMwvh1JJcb0o,1254
65
66
  contentctl/objects/integration_test_result.py,sha256=_uUSgqgjFhEZM8UwOJI6Q9K-ekIrbKU6OPdqHZycl-s,279
66
67
  contentctl/objects/investigation.py,sha256=GZsvhSZO7ZSmhg2ZeT-kPMqDG-GYpTXIvGBgV1H2lwQ,4030
@@ -71,13 +72,13 @@ contentctl/objects/manual_test.py,sha256=cx_XAtQ8VG8Ui_F553Xnut75vFEOtRwm1dDIIWN
71
72
  contentctl/objects/manual_test_result.py,sha256=FyCVVf-f1DKs-qBkM4tbKfY6mkrW25NcIEBqyaDC2rE,156
72
73
  contentctl/objects/mitre_attack_enrichment.py,sha256=PCakRksW5qrTENIZ7JirEZplE9xpmvSvX2GKv7N8j_k,3683
73
74
  contentctl/objects/notable_action.py,sha256=sW5XlpGznMHqyBmGXtXrl22hWLiCoKkfGCasGtK3rGo,1607
74
- contentctl/objects/notable_event.py,sha256=2aOtmfnsdInTtN_fHAGIKmBTBritjHbS_Nc-pqL-GbY,689
75
+ contentctl/objects/notable_event.py,sha256=jMmD1sGtTvOFNfjAfienWD2-sVL67axzdLrLZSGQ8Sw,421
75
76
  contentctl/objects/playbook.py,sha256=veG2luPfFrOMdzl99D8gsO85HYSJ8kZMYWj3GG64HKk,2879
76
77
  contentctl/objects/playbook_tags.py,sha256=O5obkQyb82YdJEii8ZJEQtrHtLOSnAvAkT1qIgpCK2s,1547
77
78
  contentctl/objects/rba.py,sha256=2xE_DXhQvG6tVLJTXYaFEBm9owePE4QG0NVgdcVgoiY,3547
78
79
  contentctl/objects/removed_security_content_object.py,sha256=bx-gVCqzT81E5jKncMD3-yKawTnl3tWsuzRBmsAqeqQ,1852
79
80
  contentctl/objects/risk_analysis_action.py,sha256=v-TQktXEEzbGzmTtqwEykXoSKdGnIlK_JojnqvvAE1s,4370
80
- contentctl/objects/risk_event.py,sha256=JQUmXriiwi5FetqVnhM0hf5cUp6LzLSNPuoecC2JKK0,12593
81
+ contentctl/objects/risk_event.py,sha256=p3WJQrDNuhoXBMOxCX5x8P7xKr2XwROcog_pPpb653A,12219
81
82
  contentctl/objects/risk_object.py,sha256=5iUKW_UwQLjjLWiD_vlE78uwH9bkaMNCHRNmKM25W1Q,905
82
83
  contentctl/objects/savedsearches_conf.py,sha256=Dn_Pxd9i3RT6DwNh6JrgmfxjsO3q15xzMksYr3wIGwQ,8624
83
84
  contentctl/objects/security_content_object.py,sha256=2mEf-wt3hMsLEyo4yatyK66jKjgUOVjJHIN9fgQB5nA,246
@@ -163,8 +164,8 @@ contentctl/templates/detections/web/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRk
163
164
  contentctl/templates/macros/security_content_ctime.yml,sha256=Gg1YNllHVsX_YB716H1SJLWzxXZEfuJlnsgB2fuyoHU,159
164
165
  contentctl/templates/macros/security_content_summariesonly.yml,sha256=9BYUxAl2E4Nwh8K19F3AJS8Ka7ceO6ZDBjFiO3l3LY0,162
165
166
  contentctl/templates/stories/cobalt_strike.yml,sha256=uj8idtDNOAIqpZ9p8usQg6mop1CQkJ5TlB4Q7CJdTIE,3082
166
- contentctl-5.3.1.dist-info/LICENSE.md,sha256=hQWUayRk-pAiOZbZnuy8djmoZkjKBx8MrCFpW-JiOgo,11344
167
- contentctl-5.3.1.dist-info/METADATA,sha256=1nKu_O4jpY0Vtv0lNteAbgJR_Fx8Wc5UhTal5frFyTg,5134
168
- contentctl-5.3.1.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
169
- contentctl-5.3.1.dist-info/entry_points.txt,sha256=5bjZ2NkbQfSwK47uOnA77yCtjgXhvgxnmCQiynRF_-U,57
170
- contentctl-5.3.1.dist-info/RECORD,,
167
+ contentctl-5.4.0.dist-info/LICENSE.md,sha256=hQWUayRk-pAiOZbZnuy8djmoZkjKBx8MrCFpW-JiOgo,11344
168
+ contentctl-5.4.0.dist-info/METADATA,sha256=0Ym0O-VJlAqZbBBwe5BWrymkdZgj-SW1pncPksgZ9jM,5134
169
+ contentctl-5.4.0.dist-info/WHEEL,sha256=fGIA9gx4Qxk2KDKeNJCbOEwSrmLtjWCwzBz351GyrPQ,88
170
+ contentctl-5.4.0.dist-info/entry_points.txt,sha256=5bjZ2NkbQfSwK47uOnA77yCtjgXhvgxnmCQiynRF_-U,57
171
+ contentctl-5.4.0.dist-info/RECORD,,