Skip to content

Commit f7a939b

Browse files
authored
Merge pull request #256 from splunk/add_drilldown_support
Drilldown Support Merging to enable testing before the security_content PR containing all drilldowns is merged.
2 parents c558216 + f2caab0 commit f7a939b

File tree

5 files changed

+126
-5
lines changed

5 files changed

+126
-5
lines changed

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from contentctl.objects.integration_test import IntegrationTest
3737
from contentctl.objects.data_source import DataSource
3838
from contentctl.objects.base_test_result import TestResultStatus
39-
39+
from contentctl.objects.drilldown import Drilldown, DRILLDOWN_SEARCH_PLACEHOLDER
4040
from contentctl.objects.enums import ProvidingTechnology
4141
from contentctl.enrichments.cve_enrichment import CveEnrichmentObj
4242
import datetime
@@ -90,6 +90,7 @@ class Detection_Abstract(SecurityContentObject):
9090
test_groups: list[TestGroup] = []
9191

9292
data_source_objects: list[DataSource] = []
93+
drilldown_searches: list[Drilldown] = Field(default=[], description="A list of Drilldowns that should be included with this search")
9394

9495
def get_conf_stanza_name(self, app:CustomApp)->str:
9596
stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name)
@@ -564,6 +565,46 @@ def model_post_init(self, __context: Any) -> None:
564565
# Derive TestGroups and IntegrationTests, adjust for ManualTests, skip as needed
565566
self.adjust_tests_and_groups()
566567

568+
# Ensure that if there is at least 1 drilldown, at least
569+
# 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER.
570+
# This is presently a requirement when 1 or more drilldowns are added to a detection.
571+
# Note that this is only required for production searches that are not hunting
572+
573+
if self.type == AnalyticsType.Hunting.value or self.status != DetectionStatus.production.value:
574+
#No additional check need to happen on the potential drilldowns.
575+
pass
576+
else:
577+
found_placeholder = False
578+
if len(self.drilldown_searches) < 2:
579+
raise ValueError(f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]")
580+
for drilldown in self.drilldown_searches:
581+
if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search:
582+
found_placeholder = True
583+
if not found_placeholder:
584+
raise ValueError("Detection has one or more drilldown_searches, but none of them "
585+
f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
586+
"if drilldown_searches are defined.'")
587+
588+
# Update the search fields with the original search, if required
589+
for drilldown in self.drilldown_searches:
590+
drilldown.perform_search_substitutions(self)
591+
592+
#For experimental purposes, add the default drilldowns
593+
#self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
594+
595+
@property
596+
def drilldowns_in_JSON(self) -> list[dict[str,str]]:
597+
"""This function is required for proper JSON
598+
serializiation of drilldowns to occur in savedsearches.conf.
599+
It returns the list[Drilldown] as a list[dict].
600+
Without this function, the jinja template is unable
601+
to convert list[Drilldown] to JSON
602+
603+
Returns:
604+
list[dict[str,str]]: List of Drilldowns dumped to dict format
605+
"""
606+
return [drilldown.model_dump() for drilldown in self.drilldown_searches]
607+
567608
@field_validator('lookups', mode="before")
568609
@classmethod
569610
def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]:

contentctl/objects/detection_tags.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def severity(self)->RiskSeverity:
7979
security_domain: SecurityDomain = Field(...)
8080
cve: List[CVE_TYPE] = []
8181
atomic_guid: List[AtomicTest] = []
82-
drilldown_search: Optional[str] = None
82+
8383

8484
# enrichment
8585
mitre_attack_enrichments: List[MitreAttackEnrichment] = Field([], validate_default=True)
@@ -114,7 +114,7 @@ def cis20(self) -> list[Cis18Value]:
114114

115115
# TODO (#268): Validate manual_test has length > 0 if not None
116116
manual_test: Optional[str] = None
117-
117+
118118
# The following validator is temporarily disabled pending further discussions
119119
# @validator('message')
120120
# def validate_message(cls,v,values):

contentctl/objects/drilldown.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
from __future__ import annotations
2+
from pydantic import BaseModel, Field, model_serializer
3+
from typing import TYPE_CHECKING
4+
if TYPE_CHECKING:
5+
from contentctl.objects.detection import Detection
6+
from contentctl.objects.enums import AnalyticsType
7+
DRILLDOWN_SEARCH_PLACEHOLDER = "%original_detection_search%"
8+
EARLIEST_OFFSET = "$info_min_time$"
9+
LATEST_OFFSET = "$info_max_time$"
10+
RISK_SEARCH = "index = risk starthoursago = 168 endhoursago = 0 | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) "
11+
12+
class Drilldown(BaseModel):
13+
name: str = Field(..., description="The name of the drilldown search", min_length=5)
14+
search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1)
15+
earliest_offset:None | str = Field(...,
16+
description="Earliest offset time for the drilldown search. "
17+
f"The most common value for this field is '{EARLIEST_OFFSET}', "
18+
"but it is NOT the default value and must be supplied explicitly.",
19+
min_length= 1)
20+
latest_offset:None | str = Field(...,
21+
description="Latest offset time for the driolldown search. "
22+
f"The most common value for this field is '{LATEST_OFFSET}', "
23+
"but it is NOT the default value and must be supplied explicitly.",
24+
min_length= 1)
25+
26+
@classmethod
27+
def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]:
28+
victim_observables = [o for o in detection.tags.observable if o.role[0] == "Victim"]
29+
if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting:
30+
# No victims, so no drilldowns
31+
return []
32+
print(f"Adding default drilldowns for [{detection.name}]")
33+
variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables])
34+
nameField = f"View the detection results for {variableNamesString}"
35+
appendedSearch = " | search " + ' '.join([f"{o.name} = ${o.name}$" for o in victim_observables])
36+
search_field = f"{detection.search}{appendedSearch}"
37+
detection_results = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field)
38+
39+
40+
nameField = f"View risk events for the last 7 days for {variableNamesString}"
41+
fieldNamesListString = ', '.join([o.name for o in victim_observables])
42+
search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}"
43+
risk_events_last_7_days = cls(name=nameField, earliest_offset=None, latest_offset=None, search=search_field)
44+
45+
return [detection_results,risk_events_last_7_days]
46+
47+
48+
def perform_search_substitutions(self, detection:Detection)->None:
49+
"""Replaces the field DRILLDOWN_SEARCH_PLACEHOLDER (%original_detection_search%)
50+
with the search contained in the detection. We do this so that the YML does not
51+
need the search copy/pasted from the search field into the drilldown object.
52+
53+
Args:
54+
detection (Detection): Detection to be used to update the search field of the drilldown
55+
"""
56+
self.search = self.search.replace(DRILLDOWN_SEARCH_PLACEHOLDER, detection.search)
57+
58+
59+
@model_serializer
60+
def serialize_model(self) -> dict[str,str]:
61+
#Call serializer for parent
62+
model:dict[str,str] = {}
63+
64+
model['name'] = self.name
65+
model['search'] = self.search
66+
if self.earliest_offset is not None:
67+
model['earliest_offset'] = self.earliest_offset
68+
if self.latest_offset is not None:
69+
model['latest_offset'] = self.latest_offset
70+
return model

contentctl/output/templates/savedsearches_detections.j2

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,8 @@ alert.suppress.fields = {{ detection.tags.throttling.conf_formatted_fields() }}
112112
alert.suppress.period = {{ detection.tags.throttling.period }}
113113
{% endif %}
114114
search = {{ detection.search | escapeNewlines() }}
115-
115+
action.notable.param.drilldown_searches = {{ detection.drilldowns_in_JSON | tojson | escapeNewlines() }}
116116
{% endif %}
117+
117118
{% endfor %}
118119
### END {{ app.label }} DETECTIONS ###

contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ references:
2929
- https://attack.mitre.org/techniques/T1560/001/
3030
- https://www.microsoft.com/security/blog/2021/01/20/deep-dive-into-the-solorigate-second-stage-activation-from-sunburst-to-teardrop-and-raindrop/
3131
- https://thedfirreport.com/2021/01/31/bazar-no-ryuk/
32+
drilldown_searches:
33+
- name: View the detection results for $user$ and $dest$
34+
search: '%original_detection_search% | search user = $user$ dest = $dest$'
35+
earliest_offset: $info_min_time$
36+
latest_offset: $info_max_time$
37+
- name: View risk events for the last 7 days for $user$ and $dest$
38+
search: '| from datamodel Risk.All_Risk | search normalized_risk_object IN ($user$, $dest$) starthoursago=168 endhoursago=1 | stats count min(_time) as firstTime max(_time) as lastTime values(search_name) as "Search Name" values(risk_message) as "Risk Message" values(analyticstories) as "Analytic Stories" values(annotations._all) as "Annotations" values(annotations.mitre_attack.mitre_tactic) as "ATT&CK Tactics" by normalized_risk_object | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`'
39+
earliest_offset: $info_min_time$
40+
latest_offset: $info_max_time$
3241
tags:
3342
analytic_story:
3443
- Cobalt Strike
@@ -80,4 +89,4 @@ tests:
8089
attack_data:
8190
- data: https://media.githubusercontent.com/media/splunk/attack_data/master/datasets/attack_techniques/T1560.001/archive_utility/windows-sysmon.log
8291
source: XmlWinEventLog:Microsoft-Windows-Sysmon/Operational
83-
sourcetype: xmlwineventlog
92+
sourcetype: xmlwineventlog

0 commit comments

Comments
 (0)