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.
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +41 -47
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +1 -1
- contentctl/actions/detection_testing/views/DetectionTestingView.py +1 -4
- contentctl/actions/validate.py +40 -1
- contentctl/enrichments/attack_enrichment.py +6 -8
- contentctl/enrichments/cve_enrichment.py +3 -3
- contentctl/helper/splunk_app.py +263 -0
- contentctl/input/director.py +1 -1
- contentctl/input/ssa_detection_builder.py +8 -6
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +362 -336
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +117 -103
- contentctl/objects/atomic.py +7 -10
- contentctl/objects/base_test.py +1 -1
- contentctl/objects/base_test_result.py +7 -5
- contentctl/objects/baseline_tags.py +2 -30
- contentctl/objects/config.py +5 -4
- contentctl/objects/correlation_search.py +316 -96
- contentctl/objects/data_source.py +7 -2
- contentctl/objects/detection_tags.py +128 -102
- contentctl/objects/errors.py +18 -0
- contentctl/objects/lookup.py +3 -1
- contentctl/objects/mitre_attack_enrichment.py +3 -3
- contentctl/objects/notable_event.py +20 -0
- contentctl/objects/observable.py +20 -26
- contentctl/objects/risk_analysis_action.py +2 -2
- contentctl/objects/risk_event.py +315 -0
- contentctl/objects/ssa_detection_tags.py +1 -1
- contentctl/objects/story_tags.py +2 -2
- contentctl/objects/unit_test.py +1 -9
- contentctl/output/data_source_writer.py +4 -4
- contentctl/output/templates/savedsearches_detections.j2 +0 -8
- {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/METADATA +5 -8
- {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/RECORD +36 -32
- {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/LICENSE.md +0 -0
- {contentctl-4.2.1.dist-info → contentctl-4.2.4.dist-info}/WHEEL +0 -0
- {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)
|
contentctl/input/director.py
CHANGED
|
@@ -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:
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|