1
+ import json
1
2
import logging
2
3
import time
3
- import json
4
- from typing import Any
5
- from enum import StrEnum , IntEnum
4
+ from enum import IntEnum , StrEnum
6
5
from functools import cached_property
6
+ from typing import Any
7
7
8
- from pydantic import ConfigDict , BaseModel , computed_field , Field , PrivateAttr
9
- from splunklib .results import JSONResultsReader , Message # type: ignore
10
- from splunklib .binding import HTTPError , ResponseReader # type: ignore
11
8
import splunklib .client as splunklib # type: ignore
9
+ from pydantic import BaseModel , ConfigDict , Field , PrivateAttr , computed_field
10
+ from splunklib .binding import HTTPError , ResponseReader # type: ignore
11
+ from splunklib .results import JSONResultsReader , Message # type: ignore
12
12
from tqdm import tqdm # type: ignore
13
13
14
- from contentctl .objects .risk_analysis_action import RiskAnalysisAction
15
- from contentctl .objects .notable_action import NotableAction
16
- from contentctl .objects .base_test_result import TestResultStatus
17
- from contentctl .objects .integration_test_result import IntegrationTestResult
18
14
from contentctl .actions .detection_testing .progress_bar import (
19
- format_pbar_string , # type: ignore
20
- TestReportingType ,
21
15
TestingStates ,
16
+ TestReportingType ,
17
+ format_pbar_string , # type: ignore
22
18
)
19
+ from contentctl .objects .base_security_event import BaseSecurityEvent
20
+ from contentctl .objects .base_test_result import TestResultStatus
21
+ from contentctl .objects .detection import Detection
23
22
from contentctl .objects .errors import (
23
+ ClientError ,
24
24
IntegrationTestingError ,
25
25
ServerError ,
26
- ClientError ,
27
26
ValidationFailed ,
28
27
)
29
- from contentctl .objects .detection import Detection
30
- from contentctl .objects .risk_event import RiskEvent
28
+ from contentctl .objects .integration_test_result import IntegrationTestResult
29
+ from contentctl .objects .notable_action import NotableAction
31
30
from contentctl .objects .notable_event import NotableEvent
32
-
31
+ from contentctl .objects .risk_analysis_action import RiskAnalysisAction
32
+ from contentctl .objects .risk_event import RiskEvent
33
33
34
34
# Suppress logging by default; enable for local testing
35
- ENABLE_LOGGING = False
35
+ ENABLE_LOGGING = True
36
36
LOG_LEVEL = logging .DEBUG
37
37
LOG_PATH = "correlation_search.log"
38
38
@@ -232,6 +232,9 @@ class CorrelationSearch(BaseModel):
232
232
# The list of risk events found
233
233
_risk_events : list [RiskEvent ] | None = PrivateAttr (default = None )
234
234
235
+ # The list of risk data model events found
236
+ _risk_dm_events : list [BaseSecurityEvent ] | None = PrivateAttr (default = None )
237
+
235
238
# The list of notable events found
236
239
_notable_events : list [NotableEvent ] | None = PrivateAttr (default = None )
237
240
@@ -519,6 +522,9 @@ def risk_event_exists(self) -> bool:
519
522
events = self .get_risk_events (force_update = True )
520
523
return len (events ) > 0
521
524
525
+ # TODO (cmcginley): to minimize number of queries, perhaps filter these events from the
526
+ # returned risk dm events? --> I think no; we want to validate product behavior; we should
527
+ # instead compare the risk dm and the risk index (maybe...)
522
528
def get_risk_events (self , force_update : bool = False ) -> list [RiskEvent ]:
523
529
"""Get risk events from the Splunk instance
524
530
@@ -551,6 +557,8 @@ def get_risk_events(self, force_update: bool = False) -> list[RiskEvent]:
551
557
events : list [RiskEvent ] = []
552
558
try :
553
559
for result in result_iterator :
560
+ # TODO (cmcginley): Do we need an else condition here for when the index is
561
+ # anything other than expected?
554
562
# sanity check that this result from the iterator is a risk event and not some
555
563
# other metadata
556
564
if result ["index" ] == Indexes .RISK_INDEX :
@@ -647,15 +655,116 @@ def get_notable_events(self, force_update: bool = False) -> list[NotableEvent]:
647
655
648
656
return events
649
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 (cmcginley): optimize this query? don't REALLY need the full events here for the
692
+ # depth of validation we're doing -> really just need the index
693
+ # TODO (#248): Refactor risk/notable querying to pin to a single savedsearch ID
694
+ # Search for all risk data model events from a single scheduled search (indicated by
695
+ # orig_sid)
696
+ query = (
697
+ f'datamodel Risk All_Risk flat | search search_name="{ self .name } " [datamodel Risk '
698
+ f'All_Risk flat | search search_name="{ self .name } " | tail 1 | fields orig_sid] '
699
+ "| tojson"
700
+ )
701
+ result_iterator = self ._search (query )
702
+
703
+ # TODO (cmcginley): make parent structure for risk and notabel events for shared fields (** START HERE **)
704
+ # TODO (cmcginley): make new structure for risk DM events? parent structure for risk/notable events?
705
+ # Iterate over the events, storing them in a list and checking for any errors
706
+ events : list [BaseSecurityEvent ] = []
707
+ risk_count = 0
708
+ notable_count = 0
709
+ try :
710
+ for result in result_iterator :
711
+ # sanity check that this result from the iterator is a risk event and not some
712
+ # other metadata
713
+ if result ["index" ] == Indexes .RISK_INDEX :
714
+ try :
715
+ parsed_raw = json .loads (result ["_raw" ])
716
+ event = RiskEvent .model_validate (parsed_raw )
717
+ except Exception :
718
+ self .logger .error (
719
+ f"Failed to parse RiskEvent from search result: { result } "
720
+ )
721
+ raise
722
+ events .append (event )
723
+ risk_count += 1
724
+ self .logger .debug (
725
+ f"Found risk event in risk data model for '{ self .name } ': { event } "
726
+ )
727
+ elif result ["index" ] == Indexes .NOTABLE_INDEX :
728
+ try :
729
+ parsed_raw = json .loads (result ["_raw" ])
730
+ event = NotableEvent .model_validate (parsed_raw )
731
+ except Exception :
732
+ self .logger .error (
733
+ f"Failed to parse NotableEvent from search result: { result } "
734
+ )
735
+ raise
736
+ events .append (event )
737
+ notable_count += 1
738
+ self .logger .debug (
739
+ f"Found notable event in risk data model for '{ self .name } ': { event } "
740
+ )
741
+ except ServerError as e :
742
+ self .logger .error (f"Error returned from Splunk instance: { e } " )
743
+ raise e
744
+
745
+ # Log if no events were found
746
+ if len (events ) < 1 :
747
+ self .logger .debug (f"No events found in risk data model for '{ self .name } '" )
748
+ else :
749
+ # Set the cache if we found events
750
+ self ._risk_dm_events = events
751
+ self .logger .debug (
752
+ f"Caching { len (self ._risk_dm_events )} risk data model events."
753
+ )
754
+
755
+ # Log counts of risk and notable events found
756
+ self .logger .debug (
757
+ f"Found { risk_count } risk events and { notable_count } notable events in the risk data "
758
+ "model"
759
+ )
760
+
761
+ return events
762
+
650
763
def validate_risk_events (self ) -> None :
651
764
"""Validates the existence of any expected risk events
652
765
653
766
First ensure the risk event exists, and if it does validate its risk message and make sure
654
- any events align with the specified risk object. Also adds the risk index to the purge list
655
- if risk events existed
656
- :param elapsed_sleep_time: an int representing the amount of time slept thus far waiting to
657
- check the risks/notables
658
- :returns: an IntegrationTestResult on failure; None on success
767
+ any events align with the specified risk object.
659
768
"""
660
769
# Ensure the rba object is defined
661
770
if self .detection .rba is None :
@@ -745,13 +854,33 @@ def validate_risk_events(self) -> None:
745
854
def validate_notable_events (self ) -> None :
746
855
"""Validates the existence of any expected notables
747
856
748
- Ensures the notable exists. Also adds the notable index to the purge list if notables
749
- existed
750
- :param elapsed_sleep_time: an int representing the amount of time slept thus far waiting to
751
- check the risks/notables
752
- :returns: an IntegrationTestResult on failure; None on success
857
+ Check various fields within the notable to ensure alignment with the detection definition.
858
+ Additionally, ensure that the notable does not appear in the risk data model, as this is
859
+ currently undesired behavior for ESCU detections.
860
+ """
861
+ if self .notable_in_risk_dm ():
862
+ raise ValidationFailed (
863
+ "One or more notables appeared in the risk data model. This could lead to risk "
864
+ "score doubling, and/or notable multiplexing, depending on the detection type "
865
+ "(e.g. TTP), or the number of risk modifiers."
866
+ )
867
+
868
+ # TODO (cmcginley): implement... Should this maybe be baked into the notable validation
869
+ # routine? since we are returning an integration test result; I think yes; get the risk dm
870
+ # events directly in the notable validation routine and ensure no notables are found in the
871
+ # data model
872
+ def notable_in_risk_dm (self ) -> bool :
873
+ """Check if notables are in the risk data model
874
+
875
+ Returns a bool indicating whether notables are in the risk data model or not.
876
+
877
+ :returns: a bool, True if notables are in the risk data model results; False if not
753
878
"""
754
- raise NotImplementedError ()
879
+ if self .risk_dm_event_exists ():
880
+ for event in self .get_risk_dm_events ():
881
+ if isinstance (event , NotableEvent ):
882
+ return True
883
+ return False
755
884
756
885
# NOTE: it would be more ideal to switch this to a system which gets the handle of the saved search job and polls
757
886
# it for completion, but that seems more tricky
@@ -838,8 +967,8 @@ def test(
838
967
839
968
try :
840
969
# Validate risk events
841
- self .logger .debug ("Checking for matching risk events" )
842
970
if self .has_risk_analysis_action :
971
+ self .logger .debug ("Checking for matching risk events" )
843
972
if self .risk_event_exists ():
844
973
# TODO (PEX-435): should this in the retry loop? or outside it?
845
974
# -> I've observed there being a missing risk event (15/16) on
@@ -856,22 +985,28 @@ def test(
856
985
raise ValidationFailed (
857
986
f"TEST FAILED: No matching risk event created for: { self .name } "
858
987
)
988
+ else :
989
+ self .logger .debug (
990
+ f"No risk action defined for '{ self .name } '"
991
+ )
859
992
860
993
# Validate notable events
861
- self .logger .debug ("Checking for matching notable events" )
862
994
if self .has_notable_action :
995
+ self .logger .debug ("Checking for matching notable events" )
863
996
# NOTE: because we check this last, if both fail, the error message about notables will
864
997
# always be the last to be added and thus the one surfaced to the user
865
998
if self .notable_event_exists ():
866
999
# TODO (PEX-435): should this in the retry loop? or outside it?
867
- # TODO (PEX-434): implement deeper notable validation (the method
868
- # commented out below is unimplemented)
869
- # self.validate_notable_events(elapsed_sleep_time)
1000
+ self .validate_notable_events ()
870
1001
pass
871
1002
else :
872
1003
raise ValidationFailed (
873
1004
f"TEST FAILED: No matching notable event created for: { self .name } "
874
1005
)
1006
+ else :
1007
+ self .logger .debug (
1008
+ f"No notable action defined for '{ self .name } '"
1009
+ )
875
1010
except ValidationFailed as e :
876
1011
self .logger .error (f"Risk/notable validation failed: { e } " )
877
1012
result = IntegrationTestResult (
@@ -1025,6 +1160,7 @@ def cleanup(self, delete_test_index: bool = False) -> None:
1025
1160
# reset caches
1026
1161
self ._risk_events = None
1027
1162
self ._notable_events = None
1163
+ self ._risk_dm_events = None
1028
1164
1029
1165
def update_pbar (self , state : str ) -> str :
1030
1166
"""
0 commit comments