contentctl 4.2.1__py3-none-any.whl → 4.2.4__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.
Files changed (36) hide show
  1. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +41 -47
  2. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +1 -1
  3. contentctl/actions/detection_testing/views/DetectionTestingView.py +1 -4
  4. contentctl/actions/validate.py +40 -1
  5. contentctl/enrichments/attack_enrichment.py +6 -8
  6. contentctl/enrichments/cve_enrichment.py +3 -3
  7. contentctl/helper/splunk_app.py +263 -0
  8. contentctl/input/director.py +1 -1
  9. contentctl/input/ssa_detection_builder.py +8 -6
  10. contentctl/objects/abstract_security_content_objects/detection_abstract.py +362 -336
  11. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +117 -103
  12. contentctl/objects/atomic.py +7 -10
  13. contentctl/objects/base_test.py +1 -1
  14. contentctl/objects/base_test_result.py +7 -5
  15. contentctl/objects/baseline_tags.py +2 -30
  16. contentctl/objects/config.py +5 -4
  17. contentctl/objects/correlation_search.py +316 -96
  18. contentctl/objects/data_source.py +7 -2
  19. contentctl/objects/detection_tags.py +128 -102
  20. contentctl/objects/errors.py +18 -0
  21. contentctl/objects/lookup.py +3 -1
  22. contentctl/objects/mitre_attack_enrichment.py +3 -3
  23. contentctl/objects/notable_event.py +20 -0
  24. contentctl/objects/observable.py +20 -26
  25. contentctl/objects/risk_analysis_action.py +2 -2
  26. contentctl/objects/risk_event.py +315 -0
  27. contentctl/objects/ssa_detection_tags.py +1 -1
  28. contentctl/objects/story_tags.py +2 -2
  29. contentctl/objects/unit_test.py +1 -9
  30. contentctl/output/data_source_writer.py +4 -4
  31. contentctl/output/templates/savedsearches_detections.j2 +0 -8
  32. {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/METADATA +5 -8
  33. {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/RECORD +36 -32
  34. {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/LICENSE.md +0 -0
  35. {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/WHEEL +0 -0
  36. {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,263 @@
1
+ import os
2
+ import time
3
+ import json
4
+ import xml.etree.ElementTree as ET
5
+ from typing import List, Tuple, Optional
6
+ from urllib.parse import urlencode
7
+
8
+ import requests
9
+ import urllib3
10
+ import xmltodict
11
+ from requests.adapters import HTTPAdapter
12
+ from requests.packages.urllib3.util.retry import Retry
13
+
14
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
15
+
16
+ MAX_RETRY = 3
17
+
18
+ class APIEndPoint:
19
+ """
20
+ Class which contains Static Endpoint
21
+ """
22
+
23
+ SPLUNK_BASE_AUTH_URL = "https://splunkbase.splunk.com/api/account:login/"
24
+ SPLUNK_BASE_FETCH_APP_BY_ENTRY_ID = (
25
+ "https://apps.splunk.com/api/apps/entriesbyid/{app_name_id}"
26
+ )
27
+ SPLUNK_BASE_GET_UID_REDIRECT = "https://apps.splunk.com/apps/id/{app_name_id}"
28
+ SPLUNK_BASE_APP_INFO = "https://splunkbase.splunk.com/api/v1/app/{app_uid}"
29
+
30
+ class RetryConstant:
31
+ """
32
+ Class which contains Retry Constant
33
+ """
34
+
35
+ RETRY_COUNT = 3
36
+ RETRY_INTERVAL = 15
37
+
38
+
39
+ class SplunkBaseError(requests.HTTPError):
40
+ """An error raise in communicating with Splunkbase"""
41
+ pass
42
+
43
+
44
+ # TODO (PEX-306): validate w/ Splunkbase team if there are better APIs we can rely on being supported
45
+ class SplunkApp:
46
+ """
47
+ A Splunk app available for download on Splunkbase
48
+ """
49
+
50
+ class InitializationError(Exception):
51
+ """An initialization error during SplunkApp setup"""
52
+ pass
53
+
54
+ @staticmethod
55
+ def requests_retry_session(
56
+ retries=RetryConstant.RETRY_COUNT,
57
+ backoff_factor=1,
58
+ status_forcelist=(500, 502, 503, 504),
59
+ session=None,
60
+ ):
61
+ session = session or requests.Session()
62
+ retry = Retry(
63
+ total=retries,
64
+ read=retries,
65
+ connect=retries,
66
+ backoff_factor=backoff_factor,
67
+ status_forcelist=status_forcelist,
68
+ )
69
+ adapter = HTTPAdapter(max_retries=retry)
70
+ session.mount('http://', adapter)
71
+ session.mount('https://', adapter)
72
+ return session
73
+
74
+ def __init__(
75
+ self,
76
+ app_uid: Optional[int] = None,
77
+ app_name_id: Optional[str] = None,
78
+ manual_setup: bool = False,
79
+ ) -> None:
80
+ if app_uid is None and app_name_id is None:
81
+ raise SplunkApp.InitializationError(
82
+ "Either app_uid (the numeric app UID e.g. 742) or app_name_id (the app name "
83
+ "idenitifier e.g. Splunk_TA_windows) must be provided"
84
+ )
85
+
86
+ # init or declare instance vars
87
+ self.app_uid: Optional[int] = app_uid
88
+ self.app_name_id: Optional[str] = app_name_id
89
+ self.manual_setup = manual_setup
90
+ self.app_title: str
91
+ self.latest_version: str
92
+ self.latest_version_download_url: str
93
+ self._app_info_cache: Optional[dict] = None
94
+
95
+ # set instance vars as needed; skip if manual setup was indicated
96
+ if not self.manual_setup:
97
+ self.set_app_name_id()
98
+ self.set_app_uid()
99
+ self.set_app_title()
100
+ self.set_latest_version_info()
101
+
102
+ def __eq__(self, __value: object) -> bool:
103
+ if isinstance(__value, SplunkApp):
104
+ return self.app_uid == __value.app_uid
105
+ return False
106
+
107
+ def __repr__(self) -> str:
108
+ return (
109
+ f"SplunkApp(app_name_id='{self.app_name_id}', app_uid={self.app_uid}, "
110
+ f"latest_version_download_url='{self.latest_version_download_url}')"
111
+ )
112
+
113
+ def __str__(self) -> str:
114
+ return f"<'{self.app_name_id}' ({self.app_uid})"
115
+
116
+ def get_app_info_by_uid(self) -> dict:
117
+ """
118
+ Retrieve app info via app_uid (e.g. 742)
119
+ :return: dictionary of app info
120
+ """
121
+ # return cache if already set and raise and raise is app_uid is not set
122
+ if self._app_info_cache is not None:
123
+ return self._app_info_cache
124
+ elif self.app_uid is None:
125
+ raise SplunkApp.InitializationError("app_uid must be set in order to fetch app info")
126
+
127
+ # NOTE: auth not required
128
+ # Get app info by uid
129
+ try:
130
+ response = self.requests_retry_session().get(
131
+ APIEndPoint.SPLUNK_BASE_APP_INFO.format(app_uid=self.app_uid),
132
+ timeout=RetryConstant.RETRY_INTERVAL
133
+ )
134
+ response.raise_for_status()
135
+ except requests.exceptions.RequestException as e:
136
+ raise SplunkBaseError(f"Error fetching app info for app_uid {self.app_uid}: {str(e)}")
137
+
138
+ # parse JSON and set cache
139
+ self._app_info_cache: dict = json.loads(response.content)
140
+
141
+ return self._app_info_cache
142
+
143
+ def set_app_name_id(self) -> None:
144
+ """
145
+ Set app_name_id
146
+ """
147
+ # return if app_name_id is already set
148
+ if self.app_name_id is not None:
149
+ return
150
+
151
+ # get app info by app_uid
152
+ app_info = self.get_app_info_by_uid()
153
+
154
+ # set app_name_id if found
155
+ if "appid" in app_info:
156
+ self.app_name_id = app_info["appid"]
157
+ else:
158
+ raise SplunkBaseError(f"Invalid response from Splunkbase; missing key 'appid': {app_info}")
159
+
160
+ def set_app_uid(self) -> None:
161
+ """
162
+ Set app_uid
163
+ """
164
+ # return if app_uid is already set and raise if app_name_id was not set
165
+ if self.app_uid is not None:
166
+ return
167
+ elif self.app_name_id is None:
168
+ raise SplunkApp.InitializationError("app_name_id must be set in order to fetch app_uid")
169
+
170
+ # NOTE: auth not required
171
+ # Get app_uid by app_name_id via a redirect
172
+ try:
173
+ response = self.requests_retry_session().get(
174
+ APIEndPoint.SPLUNK_BASE_GET_UID_REDIRECT.format(app_name_id=self.app_name_id),
175
+ allow_redirects=False,
176
+ timeout=RetryConstant.RETRY_INTERVAL
177
+ )
178
+ response.raise_for_status()
179
+ except requests.exceptions.RequestException as e:
180
+ raise SplunkBaseError(f"Error fetching app_uid for app_name_id '{self.app_name_id}': {str(e)}")
181
+
182
+ # Extract the app_uid from the redirect path
183
+ if "Location" in response.headers:
184
+ self.app_uid = response.headers.split("/")[-1]
185
+ else:
186
+ raise SplunkBaseError(
187
+ "Invalid response from Splunkbase; missing 'Location' in redirect header"
188
+ )
189
+
190
+ def set_app_title(self) -> None:
191
+ """
192
+ Set app_title
193
+ """
194
+ # get app info by app_uid
195
+ app_info = self.get_app_info_by_uid()
196
+
197
+ # set app_title if found
198
+ if "title" in app_info:
199
+ self.app_title = app_info["title"]
200
+ else:
201
+ raise SplunkBaseError(f"Invalid response from Splunkbase; missing key 'title': {app_info}")
202
+
203
+ def __fetch_url_latest_version_info(self) -> str:
204
+ """
205
+ Identify latest version of the app and return a URL pointing to download info for the build
206
+ :return: url for download info on the latest build
207
+ """
208
+ # retrieve app entries using the app_name_id
209
+ try:
210
+ response = self.requests_retry_session().get(
211
+ APIEndPoint.SPLUNK_BASE_FETCH_APP_BY_ENTRY_ID.format(app_name_id=self.app_name_id),
212
+ timeout=RetryConstant.RETRY_INTERVAL
213
+ )
214
+ response.raise_for_status()
215
+ except requests.exceptions.RequestException as e:
216
+ raise SplunkBaseError(f"Error fetching app entries for app_name_id '{self.app_name_id}': {str(e)}")
217
+
218
+ # parse xml
219
+ app_xml = xmltodict.parse(response.content)
220
+
221
+ # convert to list if only one entry exists
222
+ app_entries = app_xml.get("feed").get("entry")
223
+ if not isinstance(app_entries, list):
224
+ app_entries = [app_entries]
225
+
226
+ # iterate over multiple entries if present
227
+ for entry in app_entries:
228
+ for key in entry.get("content").get("s:dict").get("s:key"):
229
+ if key.get("@name") == "islatest" and key.get("#text") == "True":
230
+ return entry.get("link").get("@href")
231
+
232
+ # raise if no entry was found
233
+ raise SplunkBaseError(f"No app entry found with 'islatest' tag set to True: {self.app_name_id}")
234
+
235
+ def __fetch_url_latest_version_download(self, info_url: str) -> str:
236
+ """
237
+ Fetch the download URL via the provided URL to build info
238
+ :param info_url: URL for download info for the latest build
239
+ :return: URL for downloading the latest build
240
+ """
241
+ # fetch download info
242
+ try:
243
+ response = self.requests_retry_session().get(info_url, timeout=RetryConstant.RETRY_INTERVAL)
244
+ response.raise_for_status()
245
+ except requests.exceptions.RequestException as e:
246
+ raise SplunkBaseError(f"Error fetching download info for app_name_id '{self.app_name_id}': {str(e)}")
247
+
248
+ # parse XML and extract download URL
249
+ build_xml = xmltodict.parse(response.content)
250
+ download_url = build_xml.get("feed").get("entry").get("link").get("@href")
251
+ return download_url
252
+
253
+ def set_latest_version_info(self) -> None:
254
+ # raise if app_name_id not set
255
+ if self.app_name_id is None:
256
+ raise SplunkApp.InitializationError("app_name_id must be set in order to fetch latest version info")
257
+
258
+ # fetch the info URL
259
+ info_url = self.__fetch_url_latest_version_info()
260
+
261
+ # parse out the version number and fetch the download URL
262
+ self.latest_version = info_url.split("/")[-1]
263
+ self.latest_version_download_url = self.__fetch_url_latest_version_download(info_url)
@@ -45,7 +45,7 @@ from contentctl.helper.utils import Utils
45
45
  class DirectorOutputDto:
46
46
  # Atomic Tests are first because parsing them
47
47
  # is far quicker than attack_enrichment
48
- atomic_tests: Union[list[AtomicTest],None]
48
+ atomic_tests: None | list[AtomicTest]
49
49
  attack_enrichment: AttackEnrichment
50
50
  cve_enrichment: CveEnrichment
51
51
  detections: list[Detection]
@@ -89,12 +89,14 @@ class SSADetectionBuilder():
89
89
  #print("mitre_attack_id " + mitre_attack_id + " doesn't exist for detecction " + self.security_content_obj.name)
90
90
  raise ValueError("mitre_attack_id " + mitre_attack_id + " doesn't exist for detection " + self.security_content_obj.name)
91
91
  def addMitreAttackEnrichmentNew(self, attack_enrichment: AttackEnrichment) -> None:
92
- if self.security_content_obj and self.security_content_obj.tags.mitre_attack_id:
93
- self.security_content_obj.tags.mitre_attack_enrichments = []
94
- for mitre_attack_id in self.security_content_obj.tags.mitre_attack_id:
95
- enrichment_obj = attack_enrichment.getEnrichmentByMitreID(mitre_attack_id)
96
- if enrichment_obj is not None:
97
- self.security_content_obj.tags.mitre_attack_enrichments.append(enrichment_obj)
92
+ # We skip enriching if configured to do so
93
+ if attack_enrichment.use_enrichment:
94
+ if self.security_content_obj and self.security_content_obj.tags.mitre_attack_id:
95
+ self.security_content_obj.tags.mitre_attack_enrichments = []
96
+ for mitre_attack_id in self.security_content_obj.tags.mitre_attack_id:
97
+ enrichment_obj = attack_enrichment.getEnrichmentByMitreID(mitre_attack_id)
98
+ if enrichment_obj is not None:
99
+ self.security_content_obj.tags.mitre_attack_enrichments.append(enrichment_obj)
98
100
 
99
101
 
100
102