Skip to content

Commit aca2b3d

Browse files
adding risk data model validation
1 parent 46378f7 commit aca2b3d

File tree

4 files changed

+208
-65
lines changed

4 files changed

+208
-65
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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()

contentctl/objects/correlation_search.py

Lines changed: 169 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
1+
import json
12
import logging
23
import time
3-
import json
4-
from typing import Any
5-
from enum import StrEnum, IntEnum
4+
from enum import IntEnum, StrEnum
65
from functools import cached_property
6+
from typing import Any
77

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
118
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
1212
from tqdm import tqdm # type: ignore
1313

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
1814
from contentctl.actions.detection_testing.progress_bar import (
19-
format_pbar_string, # type: ignore
20-
TestReportingType,
2115
TestingStates,
16+
TestReportingType,
17+
format_pbar_string, # type: ignore
2218
)
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
2322
from contentctl.objects.errors import (
23+
ClientError,
2424
IntegrationTestingError,
2525
ServerError,
26-
ClientError,
2726
ValidationFailed,
2827
)
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
3130
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
3333

3434
# Suppress logging by default; enable for local testing
35-
ENABLE_LOGGING = False
35+
ENABLE_LOGGING = True
3636
LOG_LEVEL = logging.DEBUG
3737
LOG_PATH = "correlation_search.log"
3838

@@ -232,6 +232,9 @@ class CorrelationSearch(BaseModel):
232232
# The list of risk events found
233233
_risk_events: list[RiskEvent] | None = PrivateAttr(default=None)
234234

235+
# The list of risk data model events found
236+
_risk_dm_events: list[BaseSecurityEvent] | None = PrivateAttr(default=None)
237+
235238
# The list of notable events found
236239
_notable_events: list[NotableEvent] | None = PrivateAttr(default=None)
237240

@@ -519,6 +522,9 @@ def risk_event_exists(self) -> bool:
519522
events = self.get_risk_events(force_update=True)
520523
return len(events) > 0
521524

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...)
522528
def get_risk_events(self, force_update: bool = False) -> list[RiskEvent]:
523529
"""Get risk events from the Splunk instance
524530
@@ -551,6 +557,8 @@ def get_risk_events(self, force_update: bool = False) -> list[RiskEvent]:
551557
events: list[RiskEvent] = []
552558
try:
553559
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?
554562
# sanity check that this result from the iterator is a risk event and not some
555563
# other metadata
556564
if result["index"] == Indexes.RISK_INDEX:
@@ -647,15 +655,116 @@ def get_notable_events(self, force_update: bool = False) -> list[NotableEvent]:
647655

648656
return events
649657

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+
650763
def validate_risk_events(self) -> None:
651764
"""Validates the existence of any expected risk events
652765
653766
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.
659768
"""
660769
# Ensure the rba object is defined
661770
if self.detection.rba is None:
@@ -745,13 +854,33 @@ def validate_risk_events(self) -> None:
745854
def validate_notable_events(self) -> None:
746855
"""Validates the existence of any expected notables
747856
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
753878
"""
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
755884

756885
# NOTE: it would be more ideal to switch this to a system which gets the handle of the saved search job and polls
757886
# it for completion, but that seems more tricky
@@ -838,8 +967,8 @@ def test(
838967

839968
try:
840969
# Validate risk events
841-
self.logger.debug("Checking for matching risk events")
842970
if self.has_risk_analysis_action:
971+
self.logger.debug("Checking for matching risk events")
843972
if self.risk_event_exists():
844973
# TODO (PEX-435): should this in the retry loop? or outside it?
845974
# -> I've observed there being a missing risk event (15/16) on
@@ -856,22 +985,28 @@ def test(
856985
raise ValidationFailed(
857986
f"TEST FAILED: No matching risk event created for: {self.name}"
858987
)
988+
else:
989+
self.logger.debug(
990+
f"No risk action defined for '{self.name}'"
991+
)
859992

860993
# Validate notable events
861-
self.logger.debug("Checking for matching notable events")
862994
if self.has_notable_action:
995+
self.logger.debug("Checking for matching notable events")
863996
# NOTE: because we check this last, if both fail, the error message about notables will
864997
# always be the last to be added and thus the one surfaced to the user
865998
if self.notable_event_exists():
866999
# 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()
8701001
pass
8711002
else:
8721003
raise ValidationFailed(
8731004
f"TEST FAILED: No matching notable event created for: {self.name}"
8741005
)
1006+
else:
1007+
self.logger.debug(
1008+
f"No notable action defined for '{self.name}'"
1009+
)
8751010
except ValidationFailed as e:
8761011
self.logger.error(f"Risk/notable validation failed: {e}")
8771012
result = IntegrationTestResult(
@@ -1025,6 +1160,7 @@ def cleanup(self, delete_test_index: bool = False) -> None:
10251160
# reset caches
10261161
self._risk_events = None
10271162
self._notable_events = None
1163+
self._risk_dm_events = None
10281164

10291165
def update_pbar(self, state: str) -> str:
10301166
"""
Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,14 @@
1-
from pydantic import ConfigDict, BaseModel
2-
1+
from contentctl.objects.base_security_event import BaseSecurityEvent
32
from contentctl.objects.detection import Detection
43

54

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
7+
# TODO (cmcginley): do I need to define the abstractmethods?
8+
pass
179

1810
def validate_against_detection(self, detection: Detection) -> None:
11+
"""
12+
Validate this risk/notable event against the given detection
13+
"""
1914
raise NotImplementedError()

0 commit comments

Comments
 (0)