Skip to content

Commit 0862bc6

Browse files
authored
Merge pull request #241 from splunk/feature/risk-observable-matching
Enabling risk/observable matching
2 parents b498bd7 + 6af5d57 commit 0862bc6

File tree

5 files changed

+134
-112
lines changed

5 files changed

+134
-112
lines changed

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,6 @@ def execute(self):
374374
return
375375

376376
try:
377-
# NOTE: (THIS CODE HAS MOVED) we handle skipping entire detections differently than
378-
# we do skipping individual test cases; we skip entire detections by excluding
379-
# them to an entirely separate queue, while we skip individual test cases via the
380-
# BaseTest.skip() method, such as when we are skipping all integration tests (see
381-
# DetectionBuilder.skipIntegrationTests)
382-
# TODO: are we skipping by production status elsewhere?
383377
detection = self.sync_obj.inputQueue.pop()
384378
self.sync_obj.currentTestingQueue[self.get_name()] = detection
385379
except IndexError:

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,13 @@ def nes_fields(self) -> Optional[str]:
322322
@property
323323
def providing_technologies(self) -> List[ProvidingTechnology]:
324324
return ProvidingTechnology.getProvidingTechFromSearch(self.search)
325-
326-
325+
326+
# TODO (#247): Refactor the risk property of detection_abstract
327327
@computed_field
328328
@property
329329
def risk(self) -> list[dict[str, Any]]:
330330
risk_objects: list[dict[str, str | int]] = []
331+
# TODO (#246): "User Name" type should map to a "user" risk object and not "other"
331332
risk_object_user_types = {'user', 'username', 'email address'}
332333
risk_object_system_types = {'device', 'endpoint', 'hostname', 'ip address'}
333334
process_threat_object_types = {'process name', 'process'}

contentctl/objects/correlation_search.py

Lines changed: 35 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -575,10 +575,11 @@ def get_risk_events(self, force_update: bool = False) -> list[RiskEvent]:
575575
self.logger.debug(f"Using cached risk events ({len(self._risk_events)} total).")
576576
return self._risk_events
577577

578+
# TODO (#248): Refactor risk/notable querying to pin to a single savedsearch ID
578579
# Search for all risk events from a single scheduled search (indicated by orig_sid)
579580
query = (
580581
f'search index=risk search_name="{self.name}" [search index=risk search '
581-
f'search_name="{self.name}" | head 1 | fields orig_sid] | tojson'
582+
f'search_name="{self.name}" | tail 1 | fields orig_sid] | tojson'
582583
)
583584
result_iterator = self._search(query)
584585

@@ -643,7 +644,7 @@ def get_notable_events(self, force_update: bool = False) -> list[NotableEvent]:
643644
# Search for all notable events from a single scheduled search (indicated by orig_sid)
644645
query = (
645646
f'search index=notable search_name="{self.name}" [search index=notable search '
646-
f'search_name="{self.name}" | head 1 | fields orig_sid] | tojson'
647+
f'search_name="{self.name}" | tail 1 | fields orig_sid] | tojson'
647648
)
648649
result_iterator = self._search(query)
649650

@@ -686,15 +687,17 @@ def validate_risk_events(self) -> None:
686687
check the risks/notables
687688
:returns: an IntegrationTestResult on failure; None on success
688689
"""
689-
# TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the false
690-
# positive rate in risk/obseravble matching
691690
# Create a mapping of the relevant observables to counters
692-
# observables = CorrelationSearch._get_relevant_observables(self.detection.tags.observable)
693-
# observable_counts: dict[str, int] = {str(x): 0 for x in observables}
694-
# if len(observables) != len(observable_counts):
695-
# raise ClientError(
696-
# f"At least two observables in '{self.detection.name}' have the same name."
697-
# )
691+
observables = CorrelationSearch._get_relevant_observables(self.detection.tags.observable)
692+
observable_counts: dict[str, int] = {str(x): 0 for x in observables}
693+
694+
# NOTE: we intentionally want this to be an error state and not a failure state, as
695+
# ultimately this validation should be handled during the build process
696+
if len(observables) != len(observable_counts):
697+
raise ClientError(
698+
f"At least two observables in '{self.detection.name}' have the same name; "
699+
"each observable for a detection should be unique."
700+
)
698701

699702
# Get the risk events; note that we use the cached risk events, expecting they were
700703
# saved by a prior call to risk_event_exists
@@ -710,25 +713,29 @@ def validate_risk_events(self) -> None:
710713
)
711714
event.validate_against_detection(self.detection)
712715

713-
# TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the
714-
# false positive rate in risk/obseravble matching
715716
# Update observable count based on match
716-
# matched_observable = event.get_matched_observable(self.detection.tags.observable)
717-
# self.logger.debug(
718-
# f"Matched risk event ({event.risk_object}, {event.risk_object_type}) to observable "
719-
# f"({matched_observable.name}, {matched_observable.type}, {matched_observable.role})"
720-
# )
721-
# observable_counts[str(matched_observable)] += 1
722-
723-
# TODO (PEX-433): test my new contentctl logic against an old ESCU build; my logic should
724-
# detect the faulty attacker events -> this was the issue from the 4.28/4.27 release;
725-
# recreate by testing against one of those old builds w/ the bad config
726-
# TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the false
727-
# positive
728-
# rate in risk/obseravble matching
729-
# TODO (PEX-433): I foresee issues here if for example a parent and child process share a
730-
# name (matched observable could be either) -> these issues are confirmed to exist, e.g.
731-
# `Windows Steal Authentication Certificates Export Certificate`
717+
matched_observable = event.get_matched_observable(self.detection.tags.observable)
718+
self.logger.debug(
719+
f"Matched risk event (object={event.risk_object}, type={event.risk_object_type}) "
720+
f"to observable (name={matched_observable.name}, type={matched_observable.type}, "
721+
f"role={matched_observable.role}) using the source field "
722+
f"'{event.source_field_name}'"
723+
)
724+
observable_counts[str(matched_observable)] += 1
725+
726+
# Report any observables which did not have at least one match to a risk event
727+
for observable in observables:
728+
self.logger.debug(
729+
f"Matched observable (name={observable.name}, type={observable.type}, "
730+
f"role={observable.role}) to {observable_counts[str(observable)]} risk events."
731+
)
732+
if observable_counts[str(observable)] == 0:
733+
raise ValidationFailed(
734+
f"Observable (name={observable.name}, type={observable.type}, "
735+
f"role={observable.role}) was not matched to any risk events."
736+
)
737+
738+
# TODO (#250): Re-enable and refactor code that validates the specific risk counts
732739
# Validate risk events in aggregate; we should have an equal amount of risk events for each
733740
# relevant observable, and the total count should match the total number of events
734741
# individual_count: Optional[int] = None

contentctl/objects/detection_tags.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def risk_score(self) -> int:
5252

5353
mitre_attack_id: List[MITRE_ATTACK_ID_TYPE] = []
5454
nist: list[NistCategory] = []
55+
56+
# TODO (#249): Add pydantic validator to ensure observables are unique within a detection
5557
observable: List[Observable] = []
5658
message: str = Field(...)
5759
product: list[SecurityContentProductName] = Field(..., min_length=1)

0 commit comments

Comments
 (0)