contentctl 5.2.0__py3-none-any.whl → 5.3.1__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/build.py +5 -43
- contentctl/actions/detection_testing/DetectionTestingManager.py +64 -24
- contentctl/actions/detection_testing/GitService.py +4 -1
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +146 -42
- contentctl/actions/detection_testing/views/DetectionTestingView.py +5 -6
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +2 -0
- contentctl/actions/initialize.py +35 -9
- contentctl/actions/release_notes.py +14 -12
- contentctl/actions/test.py +16 -20
- contentctl/actions/validate.py +9 -16
- contentctl/helper/utils.py +69 -20
- contentctl/input/director.py +147 -119
- contentctl/input/yml_reader.py +39 -27
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +95 -21
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +548 -8
- contentctl/objects/baseline.py +24 -6
- contentctl/objects/config.py +32 -8
- contentctl/objects/content_versioning_service.py +508 -0
- contentctl/objects/correlation_search.py +53 -63
- contentctl/objects/dashboard.py +15 -1
- contentctl/objects/data_source.py +13 -1
- contentctl/objects/deployment.py +23 -9
- contentctl/objects/detection.py +2 -0
- contentctl/objects/enums.py +28 -18
- contentctl/objects/investigation.py +40 -20
- contentctl/objects/lookup.py +62 -6
- contentctl/objects/macro.py +19 -4
- contentctl/objects/playbook.py +16 -2
- contentctl/objects/rba.py +1 -33
- contentctl/objects/removed_security_content_object.py +50 -0
- contentctl/objects/security_content_object.py +1 -0
- contentctl/objects/story.py +37 -5
- contentctl/output/api_json_output.py +5 -3
- contentctl/output/conf_output.py +9 -1
- contentctl/output/runtime_csv_writer.py +111 -0
- contentctl/output/svg_output.py +4 -5
- contentctl/output/templates/savedsearches_detections.j2 +2 -6
- {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/METADATA +4 -3
- {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/RECORD +42 -40
- {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/WHEEL +1 -1
- contentctl/output/data_source_writer.py +0 -52
- {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/LICENSE.md +0 -0
- {contentctl-5.2.0.dist-info → contentctl-5.3.1.dist-info}/entry_points.txt +0 -0
contentctl/objects/config.py
CHANGED
|
@@ -97,7 +97,8 @@ class TestApp(App_Base):
|
|
|
97
97
|
return str(self.getSplunkbasePath())
|
|
98
98
|
if self.version is None or self.uid is None:
|
|
99
99
|
print(
|
|
100
|
-
f"Not downloading {self.title} from Splunkbase since uid[{self.uid}] AND
|
|
100
|
+
f"Not downloading {self.title} from Splunkbase since uid[{self.uid}] AND "
|
|
101
|
+
f"version[{self.version}] MUST be defined"
|
|
101
102
|
)
|
|
102
103
|
|
|
103
104
|
elif isinstance(self.hardcoded_path, pathlib.Path):
|
|
@@ -149,7 +150,10 @@ class CustomApp(App_Base):
|
|
|
149
150
|
exclude=True,
|
|
150
151
|
default=int(datetime.now(UTC).strftime("%Y%m%d%H%M%S")),
|
|
151
152
|
validate_default=True,
|
|
152
|
-
description=
|
|
153
|
+
description=(
|
|
154
|
+
"Build number for your app. This will always be a number that corresponds to the "
|
|
155
|
+
"time of the build in the format YYYYMMDDHHMMSS"
|
|
156
|
+
),
|
|
153
157
|
)
|
|
154
158
|
# id has many restrictions:
|
|
155
159
|
# * Omit this setting for apps that are for internal use only and not intended
|
|
@@ -194,7 +198,8 @@ class CustomApp(App_Base):
|
|
|
194
198
|
except Exception as e:
|
|
195
199
|
raise (
|
|
196
200
|
ValueError(
|
|
197
|
-
|
|
201
|
+
"The specified version does not follow the semantic versioning spec "
|
|
202
|
+
f"(https://semver.org/). {str(e)}"
|
|
198
203
|
)
|
|
199
204
|
)
|
|
200
205
|
return v
|
|
@@ -238,6 +243,10 @@ class Config_Base(BaseModel):
|
|
|
238
243
|
def serialize_path(path: DirectoryPath) -> str:
|
|
239
244
|
return str(path)
|
|
240
245
|
|
|
246
|
+
@property
|
|
247
|
+
def removed_content_path(self) -> pathlib.Path:
|
|
248
|
+
return self.path / "removed"
|
|
249
|
+
|
|
241
250
|
|
|
242
251
|
class init(Config_Base):
|
|
243
252
|
model_config = ConfigDict(validate_default=True, arbitrary_types_allowed=True)
|
|
@@ -254,6 +263,16 @@ class init(Config_Base):
|
|
|
254
263
|
|
|
255
264
|
class validate(Config_Base):
|
|
256
265
|
model_config = ConfigDict(validate_default=True, arbitrary_types_allowed=True)
|
|
266
|
+
enforce_deprecation_mapping_requirement: bool = Field(
|
|
267
|
+
default=False,
|
|
268
|
+
description="contentctl can support graceful deprecation and removal of "
|
|
269
|
+
"content using files that match the name removed/deprecation_mapping*.YML. "
|
|
270
|
+
"If this option is enabled, then any content marked as "
|
|
271
|
+
"status: [deprecated, removed] MUST have a mapping that exists in an "
|
|
272
|
+
"appropriate file at the listed path. "
|
|
273
|
+
"If this is not set, then that requirement is not enforced. Even if the"
|
|
274
|
+
"mapping files exist, they are NOT validated/built.",
|
|
275
|
+
)
|
|
257
276
|
enrichments: bool = Field(
|
|
258
277
|
default=False,
|
|
259
278
|
description="Enable MITRE, APP, and CVE Enrichments. "
|
|
@@ -416,8 +435,6 @@ class inspect(build):
|
|
|
416
435
|
f"or CLI invocation appropriately] {validate.model_fields['enrichments'].description}"
|
|
417
436
|
),
|
|
418
437
|
)
|
|
419
|
-
# TODO (cmcginley): wording should change here if we want to be able to download any app from
|
|
420
|
-
# Splunkbase
|
|
421
438
|
previous_build: str | None = Field(
|
|
422
439
|
default=None,
|
|
423
440
|
description=(
|
|
@@ -548,7 +565,10 @@ class ContainerSettings(BaseModel):
|
|
|
548
565
|
)
|
|
549
566
|
full_image_path: str = Field(
|
|
550
567
|
default="registry.hub.docker.com/splunk/splunk:9.3",
|
|
551
|
-
title=
|
|
568
|
+
title=(
|
|
569
|
+
"Full path to the container image to be used. We are currently pinned to 9.3 as we "
|
|
570
|
+
"resolve an issue with waiting to run until app installation completes."
|
|
571
|
+
),
|
|
552
572
|
)
|
|
553
573
|
|
|
554
574
|
def getContainers(self) -> List[Container]:
|
|
@@ -577,7 +597,10 @@ class Changes(BaseModel):
|
|
|
577
597
|
mode_name: str = "Changes"
|
|
578
598
|
target_branch: str = Field(
|
|
579
599
|
...,
|
|
580
|
-
description=
|
|
600
|
+
description=(
|
|
601
|
+
"The target branch to diff against. Note that this includes uncommitted changes in the "
|
|
602
|
+
"working directory as well."
|
|
603
|
+
),
|
|
581
604
|
)
|
|
582
605
|
|
|
583
606
|
|
|
@@ -821,7 +844,8 @@ class test_common(build):
|
|
|
821
844
|
f"'{PostTestBehavior.always_pause}' - the state of "
|
|
822
845
|
"the test will always pause after a test, allowing the user to log into the "
|
|
823
846
|
"server and experiment with the search and data before it is removed.\n\n"
|
|
824
|
-
f"'{PostTestBehavior.pause_on_failure}' - pause execution ONLY when a test fails.
|
|
847
|
+
f"'{PostTestBehavior.pause_on_failure}' - pause execution ONLY when a test fails. "
|
|
848
|
+
"The user may press ENTER in the terminal "
|
|
825
849
|
"running the test to move on to the next test.\n\n"
|
|
826
850
|
f"'{PostTestBehavior.never_pause}' - never stop testing, even if a test fails.\n\n"
|
|
827
851
|
"***SPECIAL NOTE FOR CI/CD*** 'never_pause' MUST be used for a test to "
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
import splunklib.client as splunklib # type: ignore
|
|
10
|
+
from pydantic import BaseModel, Field, PrivateAttr, computed_field
|
|
11
|
+
from splunklib.binding import HTTPError, ResponseReader # type: ignore
|
|
12
|
+
from splunklib.data import Record # type: ignore
|
|
13
|
+
|
|
14
|
+
from contentctl.helper.utils import Utils
|
|
15
|
+
from contentctl.objects.config import Infrastructure, test_common
|
|
16
|
+
from contentctl.objects.correlation_search import ResultIterator
|
|
17
|
+
from contentctl.objects.detection import Detection
|
|
18
|
+
|
|
19
|
+
# Suppress logging by default; enable for local testing
|
|
20
|
+
ENABLE_LOGGING = False
|
|
21
|
+
LOG_LEVEL = logging.DEBUG
|
|
22
|
+
LOG_PATH = "content_versioning_service.log"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContentVersioningService(BaseModel):
|
|
26
|
+
"""
|
|
27
|
+
A model representing the content versioning service used in ES 8.0.0+. This model can be used
|
|
28
|
+
to validate that detections have been installed in a way that is compatible with content
|
|
29
|
+
versioning.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# The global contentctl config
|
|
33
|
+
global_config: test_common
|
|
34
|
+
|
|
35
|
+
# The instance specific infra config
|
|
36
|
+
infrastructure: Infrastructure
|
|
37
|
+
|
|
38
|
+
# The splunklib service
|
|
39
|
+
service: splunklib.Service
|
|
40
|
+
|
|
41
|
+
# The list of detections
|
|
42
|
+
detections: list[Detection]
|
|
43
|
+
|
|
44
|
+
# The logger to use (logs all go to a null pipe unless ENABLE_LOGGING is set to True, so as not
|
|
45
|
+
# to conflict w/ tqdm)
|
|
46
|
+
logger: logging.Logger = Field(
|
|
47
|
+
default_factory=lambda: Utils.get_logger(
|
|
48
|
+
__name__, LOG_LEVEL, LOG_PATH, ENABLE_LOGGING
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def model_post_init(self, __context: Any) -> None:
|
|
53
|
+
super().model_post_init(__context)
|
|
54
|
+
|
|
55
|
+
# Log instance details
|
|
56
|
+
self.logger.info(
|
|
57
|
+
f"[{self.infrastructure.instance_name} ({self.infrastructure.instance_address})] "
|
|
58
|
+
"Initing ContentVersioningService"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# The cached job on the splunk instance of the cms events
|
|
62
|
+
_cms_main_job: splunklib.Job | None = PrivateAttr(default=None)
|
|
63
|
+
|
|
64
|
+
class Config:
|
|
65
|
+
# We need to allow arbitrary type for the splunklib service
|
|
66
|
+
arbitrary_types_allowed = True
|
|
67
|
+
|
|
68
|
+
@computed_field
|
|
69
|
+
@property
|
|
70
|
+
def setup_functions(self) -> list[tuple[Callable[[], None], str]]:
|
|
71
|
+
"""
|
|
72
|
+
Returns the list of setup functions needed for content versioning testing
|
|
73
|
+
"""
|
|
74
|
+
return [
|
|
75
|
+
(self.activate_versioning, "Activating Content Versioning"),
|
|
76
|
+
(self.wait_for_cms_main, "Waiting for CMS Parser"),
|
|
77
|
+
(self.validate_content_against_cms, "Validating Against CMS"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
def _query_content_versioning_service(
|
|
81
|
+
self, method: str, body: dict[str, Any] = {}
|
|
82
|
+
) -> Record:
|
|
83
|
+
"""
|
|
84
|
+
Queries the SA-ContentVersioning service. Output mode defaults to JSON.
|
|
85
|
+
|
|
86
|
+
:param method: HTTP request method (e.g. GET)
|
|
87
|
+
:type method: str
|
|
88
|
+
:param body: the payload/data/body of the request
|
|
89
|
+
:type body: dict[str, Any]
|
|
90
|
+
|
|
91
|
+
:returns: a splunklib Record object (wrapper around dict) indicating the response
|
|
92
|
+
:rtype: :class:`splunklib.data.Record`
|
|
93
|
+
"""
|
|
94
|
+
# Add output mode to body
|
|
95
|
+
if "output_mode" not in body:
|
|
96
|
+
body["output_mode"] = "json"
|
|
97
|
+
|
|
98
|
+
# Query the content versioning service
|
|
99
|
+
try:
|
|
100
|
+
response = self.service.request( # type: ignore
|
|
101
|
+
method=method,
|
|
102
|
+
path_segment="configs/conf-feature_flags/general",
|
|
103
|
+
body=body,
|
|
104
|
+
app="SA-ContentVersioning",
|
|
105
|
+
)
|
|
106
|
+
except HTTPError as e:
|
|
107
|
+
# Raise on any HTTP errors
|
|
108
|
+
raise HTTPError(f"Error querying content versioning service: {e}") from e
|
|
109
|
+
|
|
110
|
+
return response
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def is_versioning_activated(self) -> bool:
|
|
114
|
+
"""
|
|
115
|
+
Indicates whether the versioning service is activated or not
|
|
116
|
+
|
|
117
|
+
:returns: a bool indicating if content versioning is activated or not
|
|
118
|
+
:rtype: bool
|
|
119
|
+
"""
|
|
120
|
+
# Query the SA-ContentVersioning service for versioning status
|
|
121
|
+
response = self._query_content_versioning_service(method="GET")
|
|
122
|
+
|
|
123
|
+
# Grab the response body and check for errors
|
|
124
|
+
if "body" not in response:
|
|
125
|
+
raise KeyError(
|
|
126
|
+
f"Cannot retrieve versioning status, 'body' was not found in JSON response: {response}"
|
|
127
|
+
)
|
|
128
|
+
body: Any = response["body"] # type: ignore
|
|
129
|
+
if not isinstance(body, ResponseReader):
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"Cannot retrieve versioning status, value at 'body' in JSON response had an unexpected"
|
|
132
|
+
f" type: expected '{ResponseReader}', received '{type(body)}'"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Read the JSON and parse it into a dictionary
|
|
136
|
+
json_ = body.readall()
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(json_)
|
|
139
|
+
except json.JSONDecodeError as e:
|
|
140
|
+
raise ValueError(f"Unable to parse response body as JSON: {e}") from e
|
|
141
|
+
|
|
142
|
+
# Find the versioning_activated field and report any errors
|
|
143
|
+
try:
|
|
144
|
+
for entry in data["entry"]:
|
|
145
|
+
if entry["name"] == "general":
|
|
146
|
+
return bool(int(entry["content"]["versioning_activated"]))
|
|
147
|
+
except KeyError as e:
|
|
148
|
+
raise KeyError(
|
|
149
|
+
"Cannot retrieve versioning status, unable to determine versioning status using "
|
|
150
|
+
f"the expected keys: {e}"
|
|
151
|
+
) from e
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Cannot retrieve versioning status, unable to find an entry matching 'general' in the "
|
|
154
|
+
"response."
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def activate_versioning(self) -> None:
|
|
158
|
+
"""
|
|
159
|
+
Activate the content versioning service
|
|
160
|
+
"""
|
|
161
|
+
# Post to the SA-ContentVersioning service to set versioning status
|
|
162
|
+
self._query_content_versioning_service(
|
|
163
|
+
method="POST", body={"versioning_activated": True}
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Confirm versioning has been enabled
|
|
167
|
+
if not self.is_versioning_activated:
|
|
168
|
+
raise Exception(
|
|
169
|
+
"Something went wrong, content versioning is still disabled."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self.logger.info(
|
|
173
|
+
f"[{self.infrastructure.instance_name}] Versioning service successfully activated"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
@computed_field
|
|
177
|
+
@cached_property
|
|
178
|
+
def cms_fields(self) -> list[str]:
|
|
179
|
+
"""
|
|
180
|
+
Property listing the fields we want to pull from the cms_main index
|
|
181
|
+
|
|
182
|
+
:returns: a list of strings, the fields we want
|
|
183
|
+
:rtype: list[str]
|
|
184
|
+
"""
|
|
185
|
+
return [
|
|
186
|
+
"app_name",
|
|
187
|
+
"detection_id",
|
|
188
|
+
"version",
|
|
189
|
+
"action.correlationsearch.label",
|
|
190
|
+
"sourcetype",
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def is_cms_parser_enabled(self) -> bool:
|
|
195
|
+
"""
|
|
196
|
+
Indicates whether the cms_parser mod input is enabled or not.
|
|
197
|
+
|
|
198
|
+
:returns: a bool indicating if cms_parser mod input is activated or not
|
|
199
|
+
:rtype: bool
|
|
200
|
+
"""
|
|
201
|
+
# Get the data input entity
|
|
202
|
+
cms_parser = self.service.input("data/inputs/cms_parser/main") # type: ignore
|
|
203
|
+
|
|
204
|
+
# Convert the 'disabled' field to an int, then a bool, and then invert to be 'enabled'
|
|
205
|
+
return not bool(int(cms_parser.content["disabled"])) # type: ignore
|
|
206
|
+
|
|
207
|
+
def force_cms_parser(self) -> None:
|
|
208
|
+
"""
|
|
209
|
+
Force the cms_parser to run by disabling and re-enabling it.
|
|
210
|
+
"""
|
|
211
|
+
# Get the data input entity
|
|
212
|
+
cms_parser = self.service.input("data/inputs/cms_parser/main") # type: ignore
|
|
213
|
+
|
|
214
|
+
# Disable and re-enable
|
|
215
|
+
cms_parser.disable()
|
|
216
|
+
cms_parser.enable()
|
|
217
|
+
|
|
218
|
+
# Confirm the cms_parser is enabled
|
|
219
|
+
if not self.is_cms_parser_enabled:
|
|
220
|
+
raise Exception("Something went wrong, cms_parser is still disabled.")
|
|
221
|
+
|
|
222
|
+
self.logger.info(
|
|
223
|
+
f"[{self.infrastructure.instance_name}] cms_parser successfully toggled to force run"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def wait_for_cms_main(self) -> None:
|
|
227
|
+
"""
|
|
228
|
+
Checks the cms_main index until it has the expected number of events, or it times out.
|
|
229
|
+
"""
|
|
230
|
+
# Force the cms_parser to start parsing our savedsearches.conf
|
|
231
|
+
self.force_cms_parser()
|
|
232
|
+
|
|
233
|
+
# Set counters and limits for out exp. backoff timer
|
|
234
|
+
elapsed_sleep_time = 0
|
|
235
|
+
num_tries = 0
|
|
236
|
+
time_to_sleep = 2**num_tries
|
|
237
|
+
max_sleep = 600
|
|
238
|
+
|
|
239
|
+
# Loop until timeout
|
|
240
|
+
while elapsed_sleep_time < max_sleep:
|
|
241
|
+
# Sleep, and add the time to the elapsed counter
|
|
242
|
+
self.logger.info(
|
|
243
|
+
f"[{self.infrastructure.instance_name}] Waiting {time_to_sleep} for cms_parser to "
|
|
244
|
+
"finish"
|
|
245
|
+
)
|
|
246
|
+
time.sleep(time_to_sleep)
|
|
247
|
+
elapsed_sleep_time += time_to_sleep
|
|
248
|
+
self.logger.info(
|
|
249
|
+
f"[{self.infrastructure.instance_name}] Checking cms_main (attempt #{num_tries + 1}"
|
|
250
|
+
f" - {elapsed_sleep_time} seconds elapsed of {max_sleep} max)"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Check if the number of CMS events matches or exceeds the number of detections
|
|
254
|
+
if self.get_num_cms_events() >= len(self.detections):
|
|
255
|
+
self.logger.info(
|
|
256
|
+
f"[{self.infrastructure.instance_name}] Found "
|
|
257
|
+
f"{self.get_num_cms_events(use_cache=True)} events in cms_main which "
|
|
258
|
+
f"meets or exceeds the expected {len(self.detections)}."
|
|
259
|
+
)
|
|
260
|
+
break
|
|
261
|
+
else:
|
|
262
|
+
self.logger.info(
|
|
263
|
+
f"[{self.infrastructure.instance_name}] Found "
|
|
264
|
+
f"{self.get_num_cms_events(use_cache=True)} matching events in cms_main; "
|
|
265
|
+
f"expecting {len(self.detections)}. Continuing to wait..."
|
|
266
|
+
)
|
|
267
|
+
# Update the number of times we've tried, and increment the time to sleep
|
|
268
|
+
num_tries += 1
|
|
269
|
+
time_to_sleep = 2**num_tries
|
|
270
|
+
|
|
271
|
+
# If the computed time to sleep will exceed max_sleep, adjust appropriately
|
|
272
|
+
if (elapsed_sleep_time + time_to_sleep) > max_sleep:
|
|
273
|
+
time_to_sleep = max_sleep - elapsed_sleep_time
|
|
274
|
+
|
|
275
|
+
def _query_cms_main(self, use_cache: bool = False) -> splunklib.Job:
|
|
276
|
+
"""
|
|
277
|
+
Queries the cms_main index, optionally appending the provided query suffix.
|
|
278
|
+
|
|
279
|
+
:param use_cache: a flag indicating whether the cached job should be returned
|
|
280
|
+
:type use_cache: bool
|
|
281
|
+
|
|
282
|
+
:returns: a search Job entity
|
|
283
|
+
:rtype: :class:`splunklib.client.Job`
|
|
284
|
+
"""
|
|
285
|
+
# Use the cached job if asked to do so
|
|
286
|
+
if use_cache:
|
|
287
|
+
if self._cms_main_job is not None:
|
|
288
|
+
return self._cms_main_job
|
|
289
|
+
raise Exception(
|
|
290
|
+
"Attempting to return a cached job against the cms_main index, but no job has been"
|
|
291
|
+
" cached yet."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
# Construct the query looking for CMS events matching the content app name
|
|
295
|
+
query = (
|
|
296
|
+
f"search index=cms_main sourcetype=stash_common_detection_model "
|
|
297
|
+
f'app_name="{self.global_config.app.appid}" | fields {", ".join(self.cms_fields)}'
|
|
298
|
+
)
|
|
299
|
+
self.logger.debug(
|
|
300
|
+
f"[{self.infrastructure.instance_name}] Query on cms_main: {query}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Get the job as a blocking operation, set the cache, and return
|
|
304
|
+
self._cms_main_job = self.service.search(query, exec_mode="blocking") # type: ignore
|
|
305
|
+
return self._cms_main_job
|
|
306
|
+
|
|
307
|
+
def get_num_cms_events(self, use_cache: bool = False) -> int:
|
|
308
|
+
"""
|
|
309
|
+
Gets the number of matching events in the cms_main index
|
|
310
|
+
|
|
311
|
+
:param use_cache: a flag indicating whether the cached job should be returned
|
|
312
|
+
:type use_cache: bool
|
|
313
|
+
|
|
314
|
+
:returns: the count of matching events
|
|
315
|
+
:rtype: int
|
|
316
|
+
"""
|
|
317
|
+
# Query the cms_main index
|
|
318
|
+
job = self._query_cms_main(use_cache=use_cache)
|
|
319
|
+
|
|
320
|
+
# Convert the result count to an int
|
|
321
|
+
return int(job["resultCount"])
|
|
322
|
+
|
|
323
|
+
def validate_content_against_cms(self) -> None:
|
|
324
|
+
"""
|
|
325
|
+
Using the cms_main index, validate content against the index to ensure our
|
|
326
|
+
savedsearches.conf is compatible with ES content versioning features. **NOTE**: while in
|
|
327
|
+
the future, this function may validate more types of content, currently, we only validate
|
|
328
|
+
detections against the cms_main index.
|
|
329
|
+
"""
|
|
330
|
+
# Get the cached job and result count
|
|
331
|
+
result_count = self.get_num_cms_events(use_cache=True)
|
|
332
|
+
job = self._query_cms_main(use_cache=True)
|
|
333
|
+
|
|
334
|
+
# Create a running list of validation errors
|
|
335
|
+
exceptions: list[Exception] = []
|
|
336
|
+
|
|
337
|
+
# Generate an error for the count mismatch
|
|
338
|
+
if result_count != len(self.detections):
|
|
339
|
+
msg = (
|
|
340
|
+
f"[{self.infrastructure.instance_name}] Expected {len(self.detections)} matching "
|
|
341
|
+
f"events in cms_main, but found {result_count}."
|
|
342
|
+
)
|
|
343
|
+
self.logger.error(msg)
|
|
344
|
+
exceptions.append(Exception(msg))
|
|
345
|
+
self.logger.info(
|
|
346
|
+
f"[{self.infrastructure.instance_name}] Expecting {len(self.detections)} matching "
|
|
347
|
+
f"events in cms_main, found {result_count}."
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Init some counters and a mapping of detections to their names
|
|
351
|
+
count = 100
|
|
352
|
+
offset = 0
|
|
353
|
+
remaining_detections = {
|
|
354
|
+
x.get_action_dot_correlationsearch_dot_label(self.global_config.app): x
|
|
355
|
+
for x in self.detections
|
|
356
|
+
}
|
|
357
|
+
matched_detections: dict[str, Detection] = {}
|
|
358
|
+
|
|
359
|
+
# Create a filter for a specific memory error we're ok ignoring
|
|
360
|
+
sub_second_order_pattern = re.compile(
|
|
361
|
+
r".*Events might not be returned in sub-second order due to search memory limits.*"
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
# Iterate over the results until we've gone through them all
|
|
365
|
+
while offset < result_count:
|
|
366
|
+
iterator = ResultIterator(
|
|
367
|
+
response_reader=job.results( # type: ignore
|
|
368
|
+
output_mode="json", count=count, offset=offset
|
|
369
|
+
),
|
|
370
|
+
error_filters=[sub_second_order_pattern],
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Iterate over the currently fetched results
|
|
374
|
+
for cms_event in iterator:
|
|
375
|
+
# Increment the offset for each result
|
|
376
|
+
offset += 1
|
|
377
|
+
|
|
378
|
+
# Get the name of the search in the CMS event
|
|
379
|
+
cms_entry_name = cms_event["action.correlationsearch.label"]
|
|
380
|
+
self.logger.info(
|
|
381
|
+
f"[{self.infrastructure.instance_name}] {offset}: Matching cms_main entry "
|
|
382
|
+
f"'{cms_entry_name}' against detections"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# If CMS entry name matches one of the detections already matched, we've got an
|
|
386
|
+
# unexpected repeated entry
|
|
387
|
+
if cms_entry_name in matched_detections:
|
|
388
|
+
msg = (
|
|
389
|
+
f"[{self.infrastructure.instance_name}] [{cms_entry_name}]: Detection "
|
|
390
|
+
f"appears more than once in the cms_main index."
|
|
391
|
+
)
|
|
392
|
+
self.logger.error(msg)
|
|
393
|
+
exceptions.append(Exception(msg))
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
# Iterate over the detections and compare the CMS entry name against each
|
|
397
|
+
result_matches_detection = False
|
|
398
|
+
for detection_cs_label in remaining_detections:
|
|
399
|
+
# If we find a match, break this loop, set the found flag and move the detection
|
|
400
|
+
# from those that still need to matched to those already matched
|
|
401
|
+
if cms_entry_name == detection_cs_label:
|
|
402
|
+
self.logger.info(
|
|
403
|
+
f"[{self.infrastructure.instance_name}] {offset}: Succesfully matched "
|
|
404
|
+
f"cms_main entry against detection ('{detection_cs_label}')!"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
# Validate other fields of the cms_event against the detection
|
|
408
|
+
exception = self.validate_detection_against_cms_event(
|
|
409
|
+
cms_event, remaining_detections[detection_cs_label]
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Save the exception if validation failed
|
|
413
|
+
if exception is not None:
|
|
414
|
+
exceptions.append(exception)
|
|
415
|
+
|
|
416
|
+
# Delete the matched detection and move it to the matched list
|
|
417
|
+
result_matches_detection = True
|
|
418
|
+
matched_detections[detection_cs_label] = remaining_detections[
|
|
419
|
+
detection_cs_label
|
|
420
|
+
]
|
|
421
|
+
del remaining_detections[detection_cs_label]
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
# Generate an exception if we couldn't match the CMS main entry to a detection
|
|
425
|
+
if result_matches_detection is False:
|
|
426
|
+
msg = (
|
|
427
|
+
f"[{self.infrastructure.instance_name}] [{cms_entry_name}]: Could not "
|
|
428
|
+
"match entry in cms_main against any of the expected detections."
|
|
429
|
+
)
|
|
430
|
+
self.logger.error(msg)
|
|
431
|
+
exceptions.append(Exception(msg))
|
|
432
|
+
|
|
433
|
+
# If we have any remaining detections, they could not be matched against an entry in
|
|
434
|
+
# cms_main and there may have been a parsing issue with savedsearches.conf
|
|
435
|
+
if len(remaining_detections) > 0:
|
|
436
|
+
# Generate exceptions for the unmatched detections
|
|
437
|
+
for detection_cs_label in remaining_detections:
|
|
438
|
+
msg = (
|
|
439
|
+
f"[{self.infrastructure.instance_name}] [{detection_cs_label}]: Detection not "
|
|
440
|
+
"found in cms_main; there may be an issue with savedsearches.conf"
|
|
441
|
+
)
|
|
442
|
+
self.logger.error(msg)
|
|
443
|
+
exceptions.append(Exception(msg))
|
|
444
|
+
|
|
445
|
+
# Raise exceptions as a group
|
|
446
|
+
if len(exceptions) > 0:
|
|
447
|
+
raise ExceptionGroup(
|
|
448
|
+
"1 or more issues validating our detections against the cms_main index",
|
|
449
|
+
exceptions,
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Else, we've matched/validated all detections against cms_main
|
|
453
|
+
self.logger.info(
|
|
454
|
+
f"[{self.infrastructure.instance_name}] Matched and validated all detections against "
|
|
455
|
+
"cms_main!"
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def validate_detection_against_cms_event(
|
|
459
|
+
self, cms_event: dict[str, Any], detection: Detection
|
|
460
|
+
) -> Exception | None:
|
|
461
|
+
"""
|
|
462
|
+
Given an event from the cms_main index and the matched detection, compare fields and look
|
|
463
|
+
for any inconsistencies
|
|
464
|
+
|
|
465
|
+
:param cms_event: The event from the cms_main index
|
|
466
|
+
:type cms_event: dict[str, Any]
|
|
467
|
+
:param detection: The matched detection
|
|
468
|
+
:type detection: :class:`contentctl.objects.detection.Detection`
|
|
469
|
+
|
|
470
|
+
:return: The generated exception, or None
|
|
471
|
+
:rtype: Exception | None
|
|
472
|
+
"""
|
|
473
|
+
# TODO (PEX-509): validate additional fields between the cms_event and the detection
|
|
474
|
+
|
|
475
|
+
cms_uuid = uuid.UUID(cms_event["detection_id"])
|
|
476
|
+
rule_name_from_detection = detection.get_action_dot_correlationsearch_dot_label(
|
|
477
|
+
self.global_config.app
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
# Compare the correlation search label
|
|
481
|
+
if cms_event["action.correlationsearch.label"] != rule_name_from_detection:
|
|
482
|
+
msg = (
|
|
483
|
+
f"[{self.infrastructure.instance_name}][{detection.name}]: Correlation search "
|
|
484
|
+
f"label in cms_event ('{cms_event['action.correlationsearch.label']}') does not "
|
|
485
|
+
"match detection name"
|
|
486
|
+
)
|
|
487
|
+
self.logger.error(msg)
|
|
488
|
+
return Exception(msg)
|
|
489
|
+
elif cms_uuid != detection.id:
|
|
490
|
+
# Compare the UUIDs
|
|
491
|
+
msg = (
|
|
492
|
+
f"[{self.infrastructure.instance_name}] [{detection.name}]: UUID in cms_event "
|
|
493
|
+
f"('{cms_uuid}') does not match UUID in detection ('{detection.id}')"
|
|
494
|
+
)
|
|
495
|
+
self.logger.error(msg)
|
|
496
|
+
return Exception(msg)
|
|
497
|
+
elif cms_event["version"] != f"{detection.version}.1":
|
|
498
|
+
# Compare the versions (we append '.1' to the detection version to be in line w/ the
|
|
499
|
+
# internal representation in ES)
|
|
500
|
+
msg = (
|
|
501
|
+
f"[{self.infrastructure.instance_name}] [{detection.name}]: Version in cms_event "
|
|
502
|
+
f"('{cms_event['version']}') does not match version in detection "
|
|
503
|
+
f"('{detection.version}.1')"
|
|
504
|
+
)
|
|
505
|
+
self.logger.error(msg)
|
|
506
|
+
return Exception(msg)
|
|
507
|
+
|
|
508
|
+
return None
|