contentctl 5.2.0__py3-none-any.whl → 5.3.0__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 (42) hide show
  1. contentctl/actions/build.py +5 -43
  2. contentctl/actions/detection_testing/DetectionTestingManager.py +64 -24
  3. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +146 -42
  4. contentctl/actions/detection_testing/views/DetectionTestingView.py +5 -6
  5. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +2 -0
  6. contentctl/actions/initialize.py +35 -9
  7. contentctl/actions/release_notes.py +14 -12
  8. contentctl/actions/test.py +16 -20
  9. contentctl/actions/validate.py +8 -15
  10. contentctl/helper/utils.py +69 -20
  11. contentctl/input/director.py +147 -119
  12. contentctl/input/yml_reader.py +39 -27
  13. contentctl/objects/abstract_security_content_objects/detection_abstract.py +94 -20
  14. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +548 -8
  15. contentctl/objects/baseline.py +24 -6
  16. contentctl/objects/config.py +32 -8
  17. contentctl/objects/content_versioning_service.py +508 -0
  18. contentctl/objects/correlation_search.py +53 -63
  19. contentctl/objects/dashboard.py +15 -1
  20. contentctl/objects/data_source.py +13 -1
  21. contentctl/objects/deployment.py +23 -9
  22. contentctl/objects/detection.py +2 -0
  23. contentctl/objects/enums.py +28 -18
  24. contentctl/objects/investigation.py +40 -20
  25. contentctl/objects/lookup.py +61 -5
  26. contentctl/objects/macro.py +19 -4
  27. contentctl/objects/playbook.py +16 -2
  28. contentctl/objects/rba.py +1 -33
  29. contentctl/objects/removed_security_content_object.py +50 -0
  30. contentctl/objects/security_content_object.py +1 -0
  31. contentctl/objects/story.py +37 -5
  32. contentctl/output/api_json_output.py +5 -3
  33. contentctl/output/conf_output.py +9 -1
  34. contentctl/output/runtime_csv_writer.py +111 -0
  35. contentctl/output/svg_output.py +4 -5
  36. contentctl/output/templates/savedsearches_detections.j2 +2 -6
  37. {contentctl-5.2.0.dist-info → contentctl-5.3.0.dist-info}/METADATA +4 -3
  38. {contentctl-5.2.0.dist-info → contentctl-5.3.0.dist-info}/RECORD +41 -39
  39. {contentctl-5.2.0.dist-info → contentctl-5.3.0.dist-info}/WHEEL +1 -1
  40. contentctl/output/data_source_writer.py +0 -52
  41. {contentctl-5.2.0.dist-info → contentctl-5.3.0.dist-info}/LICENSE.md +0 -0
  42. {contentctl-5.2.0.dist-info → contentctl-5.3.0.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 AnalyticsType, DetectionStatus
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
- NO_FILE_NAME = "NO_FILE_NAME"
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, DetectionStatus):
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 == DetectionStatus.experimental:
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 == DetectionStatus.deprecated:
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(objects: list[SecurityContentObject]) -> list[str]:
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
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING, Annotated, Any, List, Literal
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 DataModel, DetectionStatus
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: Literal[DetectionStatus.production, DetectionStatus.deprecated]
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
- def get_conf_stanza_name(self, app: CustomApp) -> str:
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=self.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