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
|
@@ -4,14 +4,14 @@ from typing import TYPE_CHECKING, Any, Self
|
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from contentctl.input.director import DirectorOutputDto
|
|
7
|
+
from contentctl.objects.config import CustomApp
|
|
7
8
|
from contentctl.objects.deployment import Deployment
|
|
8
|
-
from contentctl.objects.security_content_object import SecurityContentObject
|
|
9
|
-
|
|
10
9
|
import abc
|
|
11
10
|
import datetime
|
|
12
11
|
import pathlib
|
|
13
12
|
import pprint
|
|
14
13
|
import uuid
|
|
14
|
+
from abc import abstractmethod
|
|
15
15
|
from functools import cached_property
|
|
16
16
|
from typing import List, Optional, Tuple, Union
|
|
17
17
|
|
|
@@ -26,16 +26,449 @@ from pydantic import (
|
|
|
26
26
|
computed_field,
|
|
27
27
|
field_validator,
|
|
28
28
|
model_serializer,
|
|
29
|
+
model_validator,
|
|
29
30
|
)
|
|
31
|
+
from rich import console, table
|
|
32
|
+
from semantic_version import Version
|
|
30
33
|
|
|
31
34
|
from contentctl.objects.constants import (
|
|
32
35
|
CONTENTCTL_MAX_STANZA_LENGTH,
|
|
33
36
|
DEPRECATED_TEMPLATE,
|
|
34
37
|
EXPERIMENTAL_TEMPLATE,
|
|
35
38
|
)
|
|
36
|
-
from contentctl.objects.enums import
|
|
39
|
+
from contentctl.objects.enums import (
|
|
40
|
+
CONTENT_STATUS_THAT_REQUIRES_DEPRECATION_INFO,
|
|
41
|
+
AnalyticsType,
|
|
42
|
+
ContentStatus,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DeprecationInfo(BaseModel):
|
|
47
|
+
contentType: type[SecurityContentObject_Abstract]
|
|
48
|
+
removed_in_version: str = Field(
|
|
49
|
+
...,
|
|
50
|
+
description="In which version of the app was this content deprecated? "
|
|
51
|
+
"If an app is built on or after this version and contains this content, an exception will be generated.",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
reason: str = Field(
|
|
55
|
+
...,
|
|
56
|
+
description="An explanation of why this content was deprecated.",
|
|
57
|
+
min_length=6,
|
|
58
|
+
)
|
|
59
|
+
replacement_content: list[SecurityContentObject_Abstract] = Field(
|
|
60
|
+
[],
|
|
61
|
+
description="A list of 0 to N pieces of content that replace this deprecated piece of content. "
|
|
62
|
+
"It is possible that the type(s) of the replacement content may be different than the replaced content. "
|
|
63
|
+
"For example, a detection may be replaced by a story or a macro may be replaced by a lookup.",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def noDeprecatedOrRemovedReplacementContent(self) -> Self:
|
|
68
|
+
from contentctl.objects.detection import Detection
|
|
69
|
+
from contentctl.objects.story import Story
|
|
70
|
+
|
|
71
|
+
bad_mapping_types = [
|
|
72
|
+
content
|
|
73
|
+
for content in self.replacement_content
|
|
74
|
+
if not (isinstance(content, Story) or isinstance(content, Detection))
|
|
75
|
+
]
|
|
76
|
+
if len(bad_mapping_types) > 0:
|
|
77
|
+
names_only = [
|
|
78
|
+
f"{content.name} - {type(content).__name__}"
|
|
79
|
+
for content in bad_mapping_types
|
|
80
|
+
]
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"Replacement content MUST have type {Story.__name__} or {Detection.__name__}: {names_only}"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
deprecated_replacement_content = [
|
|
86
|
+
content
|
|
87
|
+
for content in self.replacement_content
|
|
88
|
+
if content.status in CONTENT_STATUS_THAT_REQUIRES_DEPRECATION_INFO
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
if len(deprecated_replacement_content) > 0:
|
|
92
|
+
names_only = [
|
|
93
|
+
f"{content.name} - {content.status}"
|
|
94
|
+
for content in deprecated_replacement_content
|
|
95
|
+
]
|
|
96
|
+
raise ValueError(
|
|
97
|
+
f"Replacement content cannot have status deprecated or removed {names_only}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
class DeprecationException(Exception):
|
|
103
|
+
app: CustomApp
|
|
104
|
+
content_name: str
|
|
105
|
+
content_type_from_content: str
|
|
106
|
+
content_type_from_deprecation_mapping: str
|
|
107
|
+
content_status_from_yml: str
|
|
108
|
+
removed_in_version: str
|
|
109
|
+
UNDEFINED_VALUE: str = "N/A"
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
app: CustomApp,
|
|
114
|
+
content_name: str = UNDEFINED_VALUE,
|
|
115
|
+
content_type_from_content: str = UNDEFINED_VALUE,
|
|
116
|
+
content_type_from_deprecation_mapping: str = UNDEFINED_VALUE,
|
|
117
|
+
content_status_from_yml: str = UNDEFINED_VALUE,
|
|
118
|
+
removed_in_version: str = UNDEFINED_VALUE,
|
|
119
|
+
):
|
|
120
|
+
self.app = app
|
|
121
|
+
self.content_name = content_name
|
|
122
|
+
self.content_type_from_content = content_type_from_content
|
|
123
|
+
self.content_type_from_deprecation_mapping = (
|
|
124
|
+
content_type_from_deprecation_mapping
|
|
125
|
+
)
|
|
126
|
+
self.content_status_from_yml = content_status_from_yml
|
|
127
|
+
self.removed_in_version = removed_in_version
|
|
128
|
+
|
|
129
|
+
@abstractmethod
|
|
130
|
+
def message(self) -> str:
|
|
131
|
+
raise NotImplementedError(
|
|
132
|
+
"Base Deprecation Exception does not implement the message function."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def generateTableRow(
|
|
136
|
+
self,
|
|
137
|
+
) -> tuple[str, str, str, str, str, str, str]:
|
|
138
|
+
return (
|
|
139
|
+
self.content_name,
|
|
140
|
+
self.content_type_from_content,
|
|
141
|
+
self.content_type_from_deprecation_mapping,
|
|
142
|
+
self.content_status_from_yml,
|
|
143
|
+
self.removed_in_version,
|
|
144
|
+
self.app.version,
|
|
145
|
+
self.message(),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def renderExceptionsAsTable(
|
|
150
|
+
exceptions: list[DeprecationInfo.DeprecationException],
|
|
151
|
+
):
|
|
152
|
+
t = table.Table(title="Content Deprecation and Removal Errors")
|
|
153
|
+
t.add_column("Content Name", justify="left", style="cyan", no_wrap=True)
|
|
154
|
+
t.add_column("Type (YML)", justify="left", style="yellow", no_wrap=True)
|
|
155
|
+
t.add_column("Type (Mapping)", justify="left", style="yellow", no_wrap=True)
|
|
156
|
+
t.add_column("Status (YML)", justify="left", style="magenta", no_wrap=True)
|
|
157
|
+
t.add_column(
|
|
158
|
+
"Remove In",
|
|
159
|
+
justify="left",
|
|
160
|
+
style="magenta",
|
|
161
|
+
no_wrap=True,
|
|
162
|
+
)
|
|
163
|
+
t.add_column(
|
|
164
|
+
"App Version",
|
|
165
|
+
justify="left",
|
|
166
|
+
style="magenta",
|
|
167
|
+
no_wrap=True,
|
|
168
|
+
)
|
|
169
|
+
t.add_column("Error Message", justify="left", style="red", no_wrap=False)
|
|
170
|
+
|
|
171
|
+
for e in exceptions:
|
|
172
|
+
t.add_row(*e.generateTableRow())
|
|
173
|
+
|
|
174
|
+
console.Console().print(t)
|
|
175
|
+
|
|
176
|
+
class DeprecationInfoMissing(DeprecationException):
|
|
177
|
+
def __init__(self, app: CustomApp, obj: SecurityContentObject_Abstract):
|
|
178
|
+
super().__init__(
|
|
179
|
+
app=app,
|
|
180
|
+
content_name=obj.name,
|
|
181
|
+
content_type_from_content=type(obj).__name__,
|
|
182
|
+
content_status_from_yml=obj.status,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def message(self) -> str:
|
|
186
|
+
return "Missing entry in deprecation_mapping.YML"
|
|
187
|
+
|
|
188
|
+
class NoContentForDeprecationInfo(DeprecationException):
|
|
189
|
+
def __init__(self, app: CustomApp, deprecation_info: DeprecationInfoInFile):
|
|
190
|
+
super().__init__(
|
|
191
|
+
app=app,
|
|
192
|
+
content_name=deprecation_info.content,
|
|
193
|
+
content_type_from_deprecation_mapping=deprecation_info.content_type.__name__,
|
|
194
|
+
removed_in_version=deprecation_info.removed_in_version,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def message(self) -> str:
|
|
198
|
+
return "Exists in deprecation_mapping.yml, but it does not match a piece of content."
|
|
199
|
+
|
|
200
|
+
class DeprecationStatusMismatch(DeprecationException):
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
app: CustomApp,
|
|
204
|
+
obj: SecurityContentObject_Abstract,
|
|
205
|
+
deprecation_info: DeprecationInfoInFile,
|
|
206
|
+
):
|
|
207
|
+
super().__init__(
|
|
208
|
+
app=app,
|
|
209
|
+
content_name=obj.name,
|
|
210
|
+
content_type_from_content=type(obj).__name__,
|
|
211
|
+
content_type_from_deprecation_mapping=deprecation_info.content_type.__name__,
|
|
212
|
+
content_status_from_yml=obj.status,
|
|
213
|
+
removed_in_version=deprecation_info.removed_in_version,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def message(self) -> str:
|
|
217
|
+
if Version(self.app.version) >= Version(self.removed_in_version):
|
|
218
|
+
val = ContentStatus.removed
|
|
219
|
+
else:
|
|
220
|
+
val = ContentStatus.deprecated
|
|
221
|
+
return f"Based on 'Remove In' and 'App Version', Content Status should be {val}"
|
|
222
|
+
|
|
223
|
+
class DeprecationTypeMismatch(DeprecationException):
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
app: CustomApp,
|
|
227
|
+
obj: SecurityContentObject_Abstract,
|
|
228
|
+
deprecation_info: DeprecationInfoInFile,
|
|
229
|
+
):
|
|
230
|
+
super().__init__(
|
|
231
|
+
app=app,
|
|
232
|
+
content_name=obj.name,
|
|
233
|
+
content_type_from_content=type(obj).__name__,
|
|
234
|
+
content_type_from_deprecation_mapping=deprecation_info.content_type.__name__,
|
|
235
|
+
content_status_from_yml=obj.status,
|
|
236
|
+
removed_in_version=deprecation_info.removed_in_version,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def message(self) -> str:
|
|
240
|
+
return "The type of the content yml and in the deprecation_mapping.YML do not match."
|
|
241
|
+
|
|
242
|
+
class DeprecationInfoDoubleMapped(DeprecationException):
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
app: CustomApp,
|
|
246
|
+
obj: SecurityContentObject_Abstract,
|
|
247
|
+
deprecation_info: DeprecationInfoInFile,
|
|
248
|
+
):
|
|
249
|
+
super().__init__(
|
|
250
|
+
app=app,
|
|
251
|
+
content_name=obj.name,
|
|
252
|
+
content_type_from_content=type(obj).__name__,
|
|
253
|
+
content_type_from_deprecation_mapping=deprecation_info.content_type.__name__,
|
|
254
|
+
content_status_from_yml=obj.status,
|
|
255
|
+
removed_in_version=deprecation_info.removed_in_version,
|
|
256
|
+
)
|
|
37
257
|
|
|
38
|
-
|
|
258
|
+
def message(self) -> str:
|
|
259
|
+
return "Any entry in the deprecation_mapping.YML file mapped to two pieces of content."
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
def constructFromFileInfoAndDirector(
|
|
263
|
+
cls, info: DeprecationInfoInFile, director: DirectorOutputDto
|
|
264
|
+
) -> DeprecationInfo:
|
|
265
|
+
replacement_content = (
|
|
266
|
+
SecurityContentObject_Abstract.mapNamesToSecurityContentObjects(
|
|
267
|
+
info.replacement_content, director
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
return cls(
|
|
271
|
+
contentType=info.content_type,
|
|
272
|
+
removed_in_version=info.removed_in_version,
|
|
273
|
+
reason=info.reason,
|
|
274
|
+
replacement_content=replacement_content,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class DeprecationInfoInFile(BaseModel):
|
|
279
|
+
content: str
|
|
280
|
+
mapped: bool = False
|
|
281
|
+
content_type: type = Field(
|
|
282
|
+
description="This value is inferred from the section of the file that the content occurs in."
|
|
283
|
+
)
|
|
284
|
+
removed_in_version: str = Field(
|
|
285
|
+
...,
|
|
286
|
+
description="In which version of the app was this content deprecated? "
|
|
287
|
+
"If an app is built on or after this version and contains this content, an exception will be generated.",
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
reason: str = Field(
|
|
291
|
+
...,
|
|
292
|
+
description="An explanation of why this content was deprecated.",
|
|
293
|
+
min_length=6,
|
|
294
|
+
)
|
|
295
|
+
replacement_content: list[str] = Field(
|
|
296
|
+
[],
|
|
297
|
+
description="A list of 0 to N pieces of content that replace this deprecated piece of content. "
|
|
298
|
+
"It is possible that the type(s) of the replacement content may be different than the replaced content. "
|
|
299
|
+
"For example, a detection may be replaced by a story or a macro may be replaced by a lookup.",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class DeprecationDocumentationFile(BaseModel):
|
|
304
|
+
# The follow are presently supported
|
|
305
|
+
baselines: list[DeprecationInfoInFile] = []
|
|
306
|
+
detections: list[DeprecationInfoInFile] = []
|
|
307
|
+
investigations: list[DeprecationInfoInFile] = []
|
|
308
|
+
stories: list[DeprecationInfoInFile] = []
|
|
309
|
+
|
|
310
|
+
# These types may be supported in the future
|
|
311
|
+
dashboards: list[DeprecationInfoInFile] = []
|
|
312
|
+
data_sources: list[DeprecationInfoInFile] = []
|
|
313
|
+
deployments: list[DeprecationInfoInFile] = []
|
|
314
|
+
lookups: list[DeprecationInfoInFile] = []
|
|
315
|
+
macros: list[DeprecationInfoInFile] = []
|
|
316
|
+
|
|
317
|
+
def __add__(self, o: DeprecationDocumentationFile) -> DeprecationDocumentationFile:
|
|
318
|
+
return DeprecationDocumentationFile(
|
|
319
|
+
baselines=self.baselines + o.baselines,
|
|
320
|
+
detections=self.detections + o.detections,
|
|
321
|
+
investigations=self.investigations + o.investigations,
|
|
322
|
+
stories=self.stories + o.stories,
|
|
323
|
+
dashboards=self.dashboards + o.dashboards,
|
|
324
|
+
data_sources=self.data_sources + o.data_sources,
|
|
325
|
+
deployments=self.deployments + o.deployments,
|
|
326
|
+
macros=self.macros + o.macros,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
@computed_field
|
|
330
|
+
@property
|
|
331
|
+
def all_content(self) -> list[DeprecationInfoInFile]:
|
|
332
|
+
return (
|
|
333
|
+
self.baselines
|
|
334
|
+
+ self.detections
|
|
335
|
+
+ self.investigations
|
|
336
|
+
+ self.stories
|
|
337
|
+
+ self.dashboards
|
|
338
|
+
+ self.data_sources
|
|
339
|
+
+ self.deployments
|
|
340
|
+
+ self.macros
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
@computed_field
|
|
344
|
+
@property
|
|
345
|
+
def mapping(self) -> dict[str, DeprecationInfoInFile]:
|
|
346
|
+
mapping: dict[str, DeprecationInfoInFile] = {}
|
|
347
|
+
for content in self.all_content:
|
|
348
|
+
mapping[content.content] = content
|
|
349
|
+
return mapping
|
|
350
|
+
|
|
351
|
+
@model_validator(mode="after")
|
|
352
|
+
def ensureUniqueNames(self) -> Self:
|
|
353
|
+
all_names: list[str] = [n.content for n in self.all_content]
|
|
354
|
+
duplicate_names: set[str] = set()
|
|
355
|
+
for name in all_names:
|
|
356
|
+
if all_names.count(name) > 1:
|
|
357
|
+
duplicate_names.add(name)
|
|
358
|
+
if len(duplicate_names) > 0:
|
|
359
|
+
raise ValueError(
|
|
360
|
+
f"The following content names were defined more than once in deprection_mapping.YML:{duplicate_names}"
|
|
361
|
+
)
|
|
362
|
+
return self
|
|
363
|
+
|
|
364
|
+
def mapAllContent(self, director: DirectorOutputDto, app: CustomApp) -> None:
|
|
365
|
+
mapping_exceptions: list[DeprecationInfo.DeprecationException] = []
|
|
366
|
+
|
|
367
|
+
# Check that every piece of content which should have a mapping
|
|
368
|
+
# in the file does
|
|
369
|
+
for content in director.name_to_content_map.values():
|
|
370
|
+
try:
|
|
371
|
+
content.deprecation_info = self.getMappedContent(content, director, app)
|
|
372
|
+
except DeprecationInfo.DeprecationException as e:
|
|
373
|
+
mapping_exceptions.append(e)
|
|
374
|
+
|
|
375
|
+
# Check that every entry in the file actually maps to a piece of content
|
|
376
|
+
unmapped_deprecations = [d for d in self.mapping.values() if not d.mapped]
|
|
377
|
+
for unmapped_deprecation in unmapped_deprecations:
|
|
378
|
+
mapping_exceptions.append(
|
|
379
|
+
DeprecationInfo.NoContentForDeprecationInfo(app, unmapped_deprecation)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if len(mapping_exceptions):
|
|
383
|
+
DeprecationInfo.DeprecationException.renderExceptionsAsTable(
|
|
384
|
+
mapping_exceptions
|
|
385
|
+
)
|
|
386
|
+
raise Exception(
|
|
387
|
+
f"{len(mapping_exceptions)} error processing deprecation_mapping.YML"
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
def getMappedContent(
|
|
391
|
+
self,
|
|
392
|
+
obj: SecurityContentObject_Abstract,
|
|
393
|
+
director: DirectorOutputDto,
|
|
394
|
+
app: CustomApp,
|
|
395
|
+
) -> DeprecationInfo | None:
|
|
396
|
+
deprecation_info: DeprecationInfoInFile | None = self.mapping.get(
|
|
397
|
+
obj.name, None
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
obj.checkDeprecationInfo(app, deprecation_info)
|
|
401
|
+
if deprecation_info is None:
|
|
402
|
+
return
|
|
403
|
+
|
|
404
|
+
return DeprecationInfo.constructFromFileInfoAndDirector(
|
|
405
|
+
deprecation_info, director
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
@field_validator(
|
|
409
|
+
"detections",
|
|
410
|
+
"baselines",
|
|
411
|
+
"investigations",
|
|
412
|
+
"stories",
|
|
413
|
+
mode="before",
|
|
414
|
+
)
|
|
415
|
+
@classmethod
|
|
416
|
+
def setTypeSupportedContent(
|
|
417
|
+
cls, v: list[dict[str, Any] | DeprecationInfoInFile], info: ValidationInfo
|
|
418
|
+
) -> list[dict[str, Any] | DeprecationInfoInFile]:
|
|
419
|
+
"""
|
|
420
|
+
This function is important because we need to ensure that the heading a piece of
|
|
421
|
+
content is under in the Deprecation File matches the actual type of that content.
|
|
422
|
+
For non-removed content, this is a bit easier since the content itself carries a
|
|
423
|
+
type. However, it is more difficult for DeprecatedSecurityContent_Object since
|
|
424
|
+
that no longer has a meaningful type, every piece of removed content has the same
|
|
425
|
+
typing. In that case, the heading in the deprecation mapping file is used to
|
|
426
|
+
determine proper type information for that content.
|
|
427
|
+
"""
|
|
428
|
+
for entry in v:
|
|
429
|
+
if isinstance(entry, DeprecationInfoInFile):
|
|
430
|
+
# This is already a fully loaded/parsed Object and we are probably
|
|
431
|
+
# getting here by adding two of them together. No need to do the
|
|
432
|
+
# enrichment as the content_type has already been set.
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
if info.field_name == "detections":
|
|
436
|
+
from contentctl.objects.detection import Detection
|
|
437
|
+
|
|
438
|
+
entry["content_type"] = Detection
|
|
439
|
+
elif info.field_name == "baselines":
|
|
440
|
+
from contentctl.objects.baseline import Baseline
|
|
441
|
+
|
|
442
|
+
entry["content_type"] = Baseline
|
|
443
|
+
elif info.field_name == "stories":
|
|
444
|
+
from contentctl.objects.story import Story
|
|
445
|
+
|
|
446
|
+
entry["content_type"] = Story
|
|
447
|
+
elif info.field_name == "investigations":
|
|
448
|
+
from contentctl.objects.investigation import Investigation
|
|
449
|
+
|
|
450
|
+
entry["content_type"] = Investigation
|
|
451
|
+
else:
|
|
452
|
+
raise Exception(
|
|
453
|
+
f"Trying to map list of unsupported content types '{info.field_name}' in the Mapping YML"
|
|
454
|
+
)
|
|
455
|
+
return v
|
|
456
|
+
|
|
457
|
+
@field_validator(
|
|
458
|
+
"dashboards",
|
|
459
|
+
"data_sources",
|
|
460
|
+
"deployments",
|
|
461
|
+
"lookups",
|
|
462
|
+
"macros",
|
|
463
|
+
mode="before",
|
|
464
|
+
)
|
|
465
|
+
@classmethod
|
|
466
|
+
def setTypeUnsupportedContent(
|
|
467
|
+
cls, v: list[dict[str, Any]], info: ValidationInfo
|
|
468
|
+
) -> list[SecurityContentObject_Abstract]:
|
|
469
|
+
if len(v) > 0:
|
|
470
|
+
raise Exception("Deprecation of this content is not yet supported")
|
|
471
|
+
return []
|
|
39
472
|
|
|
40
473
|
|
|
41
474
|
class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
@@ -48,10 +481,104 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
48
481
|
description: str = Field(..., max_length=10000)
|
|
49
482
|
file_path: Optional[FilePath] = None
|
|
50
483
|
references: Optional[List[HttpUrl]] = None
|
|
484
|
+
deprecation_info: DeprecationInfo | None = None
|
|
485
|
+
status: ContentStatus = Field(
|
|
486
|
+
description="All SecurityContentObjects must have a status. "
|
|
487
|
+
"Further refinements to status are included in each specific object, "
|
|
488
|
+
"since not every object supports all possible statuses. "
|
|
489
|
+
"This is done via a slightly complex regex scheme due to "
|
|
490
|
+
"limitations in Type Checking."
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def checkDeprecationInfo(
|
|
494
|
+
self, app: CustomApp, deprecation_info: DeprecationInfoInFile | None
|
|
495
|
+
):
|
|
496
|
+
if deprecation_info is None:
|
|
497
|
+
if self.status not in CONTENT_STATUS_THAT_REQUIRES_DEPRECATION_INFO:
|
|
498
|
+
return
|
|
499
|
+
else:
|
|
500
|
+
raise DeprecationInfo.DeprecationInfoMissing(app, self)
|
|
501
|
+
|
|
502
|
+
if deprecation_info.mapped:
|
|
503
|
+
# This content was already mapped - we cannot use it again and should give an exception
|
|
504
|
+
raise DeprecationInfo.DeprecationInfoDoubleMapped(
|
|
505
|
+
app, self, deprecation_info
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
# Once an entry is mapped ot the file, we cannot map to it again
|
|
509
|
+
# Even if we generate an exception later in this function, we still want
|
|
510
|
+
# to mark the deprecation info as mapped because we did successfully handle it
|
|
511
|
+
deprecation_info.mapped = True
|
|
512
|
+
|
|
513
|
+
# Make sure that the type in the deprecation info matches the type of
|
|
514
|
+
# the object
|
|
515
|
+
if type(self) is not deprecation_info.content_type:
|
|
516
|
+
from contentctl.objects.removed_security_content_object import (
|
|
517
|
+
RemovedSecurityContentObject,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
if type(self) is not RemovedSecurityContentObject:
|
|
521
|
+
raise DeprecationInfo.DeprecationTypeMismatch(
|
|
522
|
+
app, self, deprecation_info
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# This means we have deprecation info
|
|
526
|
+
if self.status not in CONTENT_STATUS_THAT_REQUIRES_DEPRECATION_INFO:
|
|
527
|
+
# However this piece of content should not have the info
|
|
528
|
+
raise DeprecationInfo.DeprecationStatusMismatch(app, self, deprecation_info)
|
|
529
|
+
|
|
530
|
+
# Content should be removed if the app version is greater than or equal to
|
|
531
|
+
# when we say the content should be removed
|
|
532
|
+
removed_in = Version(deprecation_info.removed_in_version)
|
|
533
|
+
current_version = Version(app.version)
|
|
534
|
+
|
|
535
|
+
if current_version >= removed_in:
|
|
536
|
+
# Content should have status removed
|
|
537
|
+
if self.status is not ContentStatus.removed:
|
|
538
|
+
raise DeprecationInfo.DeprecationStatusMismatch(
|
|
539
|
+
app, self, deprecation_info
|
|
540
|
+
)
|
|
541
|
+
else:
|
|
542
|
+
if self.status is not ContentStatus.deprecated:
|
|
543
|
+
raise DeprecationInfo.DeprecationStatusMismatch(
|
|
544
|
+
app, self, deprecation_info
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
@classmethod
|
|
548
|
+
def NarrowStatusTemplate(
|
|
549
|
+
cls, status: ContentStatus, allowed_types: list[ContentStatus]
|
|
550
|
+
) -> ContentStatus:
|
|
551
|
+
if status not in allowed_types:
|
|
552
|
+
raise ValueError(
|
|
553
|
+
f"The status '{status}' is not allowed. Only {allowed_types} are supported status for this object."
|
|
554
|
+
)
|
|
555
|
+
return status
|
|
556
|
+
|
|
557
|
+
@field_validator("status", mode="after")
|
|
558
|
+
@classmethod
|
|
559
|
+
def NarrowStatus(cls, status: ContentStatus) -> ContentStatus:
|
|
560
|
+
raise NotImplementedError(
|
|
561
|
+
"Narrow Status must be implemented for each SecurityContentObject"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
@classmethod
|
|
565
|
+
@abstractmethod
|
|
566
|
+
def containing_folder(cls) -> pathlib.Path:
|
|
567
|
+
raise NotImplementedError(
|
|
568
|
+
f"Containing folder has not been implemented for {cls.__name__}"
|
|
569
|
+
)
|
|
51
570
|
|
|
52
571
|
def model_post_init(self, __context: Any) -> None:
|
|
53
572
|
self.ensureFileNameMatchesSearchName()
|
|
54
573
|
|
|
574
|
+
@computed_field
|
|
575
|
+
@cached_property
|
|
576
|
+
@abstractmethod
|
|
577
|
+
def researchSiteLink(self) -> HttpUrl:
|
|
578
|
+
raise NotImplementedError(
|
|
579
|
+
f"researchSiteLink has not been implemented for [{type(self).__name__} - {self.name}]"
|
|
580
|
+
)
|
|
581
|
+
|
|
55
582
|
@computed_field
|
|
56
583
|
@cached_property
|
|
57
584
|
def status_aware_description(self) -> str:
|
|
@@ -72,15 +599,15 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
72
599
|
"""
|
|
73
600
|
status = getattr(self, "status", None)
|
|
74
601
|
|
|
75
|
-
if not isinstance(status,
|
|
602
|
+
if not isinstance(status, ContentStatus):
|
|
76
603
|
raise NotImplementedError(
|
|
77
604
|
f"Detection status is not implemented for [{self.name}] of type '{type(self).__name__}'"
|
|
78
605
|
)
|
|
79
|
-
if status ==
|
|
606
|
+
if status == ContentStatus.experimental:
|
|
80
607
|
return EXPERIMENTAL_TEMPLATE.format(
|
|
81
608
|
content_type=type(self).__name__, description=self.description
|
|
82
609
|
)
|
|
83
|
-
elif status ==
|
|
610
|
+
elif status == ContentStatus.deprecated:
|
|
84
611
|
return DEPRECATED_TEMPLATE.format(
|
|
85
612
|
content_type=type(self).__name__, description=self.description
|
|
86
613
|
)
|
|
@@ -108,8 +635,21 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
108
635
|
f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' "
|
|
109
636
|
)
|
|
110
637
|
|
|
638
|
+
@classmethod
|
|
639
|
+
def static_get_conf_stanza_name(cls, name: str, app: CustomApp) -> str:
|
|
640
|
+
raise NotImplementedError(
|
|
641
|
+
"{cls.__name__} does not have an implementation for static_get_conf_stanza_name"
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def get_conf_stanza_name(self, app: CustomApp) -> str:
|
|
645
|
+
stanza_name = self.static_get_conf_stanza_name(self.name, app)
|
|
646
|
+
self.check_conf_stanza_max_length(stanza_name)
|
|
647
|
+
return stanza_name
|
|
648
|
+
|
|
111
649
|
@staticmethod
|
|
112
|
-
def objectListToNameList(
|
|
650
|
+
def objectListToNameList(
|
|
651
|
+
objects: list[SecurityContentObject_Abstract],
|
|
652
|
+
) -> list[str]:
|
|
113
653
|
return [object.getName() for object in objects]
|
|
114
654
|
|
|
115
655
|
# This function is overloadable by specific types if they want to redefine names, for example
|
contentctl/objects/baseline.py
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import TYPE_CHECKING, Annotated, Any, List
|
|
3
|
+
from typing import TYPE_CHECKING, Annotated, Any, List
|
|
4
4
|
|
|
5
5
|
if TYPE_CHECKING:
|
|
6
6
|
from contentctl.input.director import DirectorOutputDto
|
|
7
7
|
|
|
8
|
+
import pathlib
|
|
9
|
+
|
|
8
10
|
from pydantic import (
|
|
9
11
|
Field,
|
|
10
12
|
ValidationInfo,
|
|
@@ -20,7 +22,7 @@ from contentctl.objects.constants import (
|
|
|
20
22
|
CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
|
|
21
23
|
)
|
|
22
24
|
from contentctl.objects.deployment import Deployment
|
|
23
|
-
from contentctl.objects.enums import
|
|
25
|
+
from contentctl.objects.enums import ContentStatus, DataModel
|
|
24
26
|
from contentctl.objects.lookup import Lookup
|
|
25
27
|
from contentctl.objects.security_content_object import SecurityContentObject
|
|
26
28
|
|
|
@@ -35,7 +37,18 @@ class Baseline(SecurityContentObject):
|
|
|
35
37
|
lookups: list[Lookup] = Field([], validate_default=True)
|
|
36
38
|
# enrichment
|
|
37
39
|
deployment: Deployment = Field({})
|
|
38
|
-
status:
|
|
40
|
+
status: ContentStatus
|
|
41
|
+
|
|
42
|
+
@field_validator("status", mode="after")
|
|
43
|
+
@classmethod
|
|
44
|
+
def NarrowStatus(cls, status: ContentStatus) -> ContentStatus:
|
|
45
|
+
return cls.NarrowStatusTemplate(
|
|
46
|
+
status, [ContentStatus.production, ContentStatus.deprecated]
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def containing_folder(cls) -> pathlib.Path:
|
|
51
|
+
return pathlib.Path("baselines")
|
|
39
52
|
|
|
40
53
|
@field_validator("lookups", mode="before")
|
|
41
54
|
@classmethod
|
|
@@ -51,11 +64,15 @@ class Baseline(SecurityContentObject):
|
|
|
51
64
|
lookups = Lookup.get_lookups(search, director)
|
|
52
65
|
return lookups
|
|
53
66
|
|
|
54
|
-
|
|
67
|
+
@classmethod
|
|
68
|
+
def static_get_conf_stanza_name(cls, name: str, app: CustomApp) -> str:
|
|
69
|
+
"""
|
|
70
|
+
This is exposed as a static method since it may need to be used for SecurityContentObject which does not
|
|
71
|
+
pass all currenty validations - most notable Deprecated content.
|
|
72
|
+
"""
|
|
55
73
|
stanza_name = CONTENTCTL_BASELINE_STANZA_NAME_FORMAT_TEMPLATE.format(
|
|
56
|
-
app_label=app.label, detection_name=
|
|
74
|
+
app_label=app.label, detection_name=name
|
|
57
75
|
)
|
|
58
|
-
self.check_conf_stanza_max_length(stanza_name)
|
|
59
76
|
return stanza_name
|
|
60
77
|
|
|
61
78
|
@field_validator("deployment", mode="before")
|
|
@@ -87,3 +104,4 @@ class Baseline(SecurityContentObject):
|
|
|
87
104
|
|
|
88
105
|
# return the model
|
|
89
106
|
return super_fields
|
|
107
|
+
return super_fields
|