contentctl 5.0.0a0__py3-none-any.whl → 5.0.0a3__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 (107) hide show
  1. contentctl/__init__.py +1 -1
  2. contentctl/actions/build.py +88 -55
  3. contentctl/actions/deploy_acs.py +29 -24
  4. contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
  5. contentctl/actions/detection_testing/GitService.py +134 -76
  6. contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
  7. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
  8. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
  9. contentctl/actions/detection_testing/progress_bar.py +3 -0
  10. contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
  11. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
  12. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
  13. contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
  14. contentctl/actions/doc_gen.py +9 -5
  15. contentctl/actions/initialize.py +45 -33
  16. contentctl/actions/inspect.py +118 -61
  17. contentctl/actions/new_content.py +78 -50
  18. contentctl/actions/release_notes.py +276 -146
  19. contentctl/actions/reporting.py +23 -19
  20. contentctl/actions/test.py +31 -25
  21. contentctl/actions/validate.py +54 -34
  22. contentctl/api.py +54 -45
  23. contentctl/contentctl.py +12 -13
  24. contentctl/enrichments/attack_enrichment.py +112 -72
  25. contentctl/enrichments/cve_enrichment.py +34 -28
  26. contentctl/enrichments/splunk_app_enrichment.py +38 -36
  27. contentctl/helper/link_validator.py +101 -78
  28. contentctl/helper/splunk_app.py +69 -41
  29. contentctl/helper/utils.py +58 -39
  30. contentctl/input/director.py +69 -37
  31. contentctl/input/new_content_questions.py +26 -34
  32. contentctl/input/yml_reader.py +22 -17
  33. contentctl/objects/abstract_security_content_objects/detection_abstract.py +250 -314
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +58 -36
  35. contentctl/objects/alert_action.py +8 -8
  36. contentctl/objects/annotated_types.py +1 -1
  37. contentctl/objects/atomic.py +64 -54
  38. contentctl/objects/base_test.py +2 -1
  39. contentctl/objects/base_test_result.py +16 -8
  40. contentctl/objects/baseline.py +41 -30
  41. contentctl/objects/baseline_tags.py +29 -22
  42. contentctl/objects/config.py +772 -560
  43. contentctl/objects/constants.py +29 -58
  44. contentctl/objects/correlation_search.py +75 -55
  45. contentctl/objects/dashboard.py +55 -41
  46. contentctl/objects/data_source.py +13 -13
  47. contentctl/objects/deployment.py +44 -37
  48. contentctl/objects/deployment_email.py +1 -1
  49. contentctl/objects/deployment_notable.py +2 -1
  50. contentctl/objects/deployment_phantom.py +5 -5
  51. contentctl/objects/deployment_rba.py +1 -1
  52. contentctl/objects/deployment_scheduling.py +1 -1
  53. contentctl/objects/deployment_slack.py +1 -1
  54. contentctl/objects/detection.py +5 -2
  55. contentctl/objects/detection_metadata.py +1 -0
  56. contentctl/objects/detection_stanza.py +7 -2
  57. contentctl/objects/detection_tags.py +54 -64
  58. contentctl/objects/drilldown.py +66 -35
  59. contentctl/objects/enums.py +61 -43
  60. contentctl/objects/errors.py +16 -24
  61. contentctl/objects/integration_test.py +3 -3
  62. contentctl/objects/integration_test_result.py +1 -0
  63. contentctl/objects/investigation.py +41 -26
  64. contentctl/objects/investigation_tags.py +29 -17
  65. contentctl/objects/lookup.py +234 -113
  66. contentctl/objects/macro.py +55 -38
  67. contentctl/objects/manual_test.py +3 -3
  68. contentctl/objects/manual_test_result.py +1 -0
  69. contentctl/objects/mitre_attack_enrichment.py +17 -16
  70. contentctl/objects/notable_action.py +2 -1
  71. contentctl/objects/notable_event.py +1 -3
  72. contentctl/objects/playbook.py +37 -35
  73. contentctl/objects/playbook_tags.py +22 -16
  74. contentctl/objects/rba.py +14 -8
  75. contentctl/objects/risk_analysis_action.py +15 -11
  76. contentctl/objects/risk_event.py +27 -20
  77. contentctl/objects/risk_object.py +1 -0
  78. contentctl/objects/savedsearches_conf.py +9 -7
  79. contentctl/objects/security_content_object.py +5 -2
  80. contentctl/objects/story.py +45 -44
  81. contentctl/objects/story_tags.py +56 -44
  82. contentctl/objects/test_group.py +5 -2
  83. contentctl/objects/threat_object.py +1 -0
  84. contentctl/objects/throttling.py +27 -18
  85. contentctl/objects/unit_test.py +3 -4
  86. contentctl/objects/unit_test_baseline.py +4 -5
  87. contentctl/objects/unit_test_result.py +6 -6
  88. contentctl/output/api_json_output.py +22 -22
  89. contentctl/output/attack_nav_output.py +21 -21
  90. contentctl/output/attack_nav_writer.py +29 -37
  91. contentctl/output/conf_output.py +230 -174
  92. contentctl/output/data_source_writer.py +38 -25
  93. contentctl/output/doc_md_output.py +53 -27
  94. contentctl/output/jinja_writer.py +19 -15
  95. contentctl/output/json_writer.py +20 -8
  96. contentctl/output/svg_output.py +56 -38
  97. contentctl/output/templates/savedsearches_detections.j2 +1 -1
  98. contentctl/output/templates/transforms.j2 +2 -2
  99. contentctl/output/yml_writer.py +18 -24
  100. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
  101. contentctl-5.0.0a3.dist-info/RECORD +168 -0
  102. contentctl/actions/initialize_old.py +0 -245
  103. contentctl/objects/observable.py +0 -39
  104. contentctl-5.0.0a0.dist-info/RECORD +0 -170
  105. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
  106. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
  107. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/entry_points.txt +0 -0
@@ -1,71 +1,102 @@
1
1
  from __future__ import annotations
2
- from pydantic import BaseModel, Field, model_serializer
2
+
3
3
  from typing import TYPE_CHECKING
4
+
5
+ from pydantic import BaseModel, Field, model_serializer
6
+
4
7
  if TYPE_CHECKING:
5
8
  from contentctl.objects.detection import Detection
9
+
6
10
  from contentctl.objects.enums import AnalyticsType
11
+
7
12
  DRILLDOWN_SEARCH_PLACEHOLDER = "%original_detection_search%"
8
13
  EARLIEST_OFFSET = "$info_min_time$"
9
14
  LATEST_OFFSET = "$info_max_time$"
10
15
  RISK_SEARCH = "index = risk starthoursago = 168 endhoursago = 0 | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) "
11
16
 
17
+
12
18
  class Drilldown(BaseModel):
13
19
  name: str = Field(..., description="The name of the drilldown search", min_length=5)
14
- search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1)
15
- earliest_offset:None | str = Field(...,
16
- description="Earliest offset time for the drilldown search. "
17
- f"The most common value for this field is '{EARLIEST_OFFSET}', "
18
- "but it is NOT the default value and must be supplied explicitly.",
19
- min_length= 1)
20
- latest_offset:None | str = Field(...,
21
- description="Latest offset time for the driolldown search. "
22
- f"The most common value for this field is '{LATEST_OFFSET}', "
23
- "but it is NOT the default value and must be supplied explicitly.",
24
- min_length= 1)
20
+ search: str = Field(
21
+ ...,
22
+ description="The text of a drilldown search. This must be valid SPL.",
23
+ min_length=1,
24
+ )
25
+ earliest_offset: None | str = Field(
26
+ ...,
27
+ description="Earliest offset time for the drilldown search. "
28
+ f"The most common value for this field is '{EARLIEST_OFFSET}', "
29
+ "but it is NOT the default value and must be supplied explicitly.",
30
+ min_length=1,
31
+ )
32
+ latest_offset: None | str = Field(
33
+ ...,
34
+ description="Latest offset time for the driolldown search. "
35
+ f"The most common value for this field is '{LATEST_OFFSET}', "
36
+ "but it is NOT the default value and must be supplied explicitly.",
37
+ min_length=1,
38
+ )
25
39
 
26
- # TODO (cmcginley): @ljstella the drilldowns will need to be updated
27
40
  @classmethod
28
41
  def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]:
29
- victim_observables = [o for o in detection.tags.observable if o.role[0] == "Victim"]
42
+ # Ensure the rba object is defined
43
+ if detection.rba is None:
44
+ raise NotImplementedError(
45
+ f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
46
+ "with it; cannot construct drilldowns."
47
+ )
48
+
49
+ victim_observables = [o for o in detection.rba.risk_objects]
30
50
  if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting:
31
51
  # No victims, so no drilldowns
32
52
  return []
33
53
  print(f"Adding default drilldowns for [{detection.name}]")
34
- variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables])
54
+ variableNamesString = " and ".join([f"${o.field}$" for o in victim_observables])
35
55
  nameField = f"View the detection results for {variableNamesString}"
36
- appendedSearch = " | search " + ' '.join([f"{o.name} = ${o.name}$" for o in victim_observables])
56
+ appendedSearch = " | search " + " ".join(
57
+ [f"{o.field} = ${o.field}$" for o in victim_observables]
58
+ )
37
59
  search_field = f"{detection.search}{appendedSearch}"
38
- detection_results = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field)
39
-
40
-
60
+ detection_results = cls(
61
+ name=nameField,
62
+ earliest_offset=EARLIEST_OFFSET,
63
+ latest_offset=LATEST_OFFSET,
64
+ search=search_field,
65
+ )
66
+
41
67
  nameField = f"View risk events for the last 7 days for {variableNamesString}"
42
- fieldNamesListString = ', '.join([o.name for o in victim_observables])
68
+ fieldNamesListString = ", ".join([o.field for o in victim_observables])
43
69
  search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}"
44
- risk_events_last_7_days = cls(name=nameField, earliest_offset=None, latest_offset=None, search=search_field)
70
+ risk_events_last_7_days = cls(
71
+ name=nameField,
72
+ earliest_offset=None,
73
+ latest_offset=None,
74
+ search=search_field,
75
+ )
45
76
 
46
- return [detection_results,risk_events_last_7_days]
47
-
77
+ return [detection_results, risk_events_last_7_days]
48
78
 
49
- def perform_search_substitutions(self, detection:Detection)->None:
79
+ def perform_search_substitutions(self, detection: Detection) -> None:
50
80
  """Replaces the field DRILLDOWN_SEARCH_PLACEHOLDER (%original_detection_search%)
51
81
  with the search contained in the detection. We do this so that the YML does not
52
82
  need the search copy/pasted from the search field into the drilldown object.
53
83
 
54
84
  Args:
55
85
  detection (Detection): Detection to be used to update the search field of the drilldown
56
- """
57
- self.search = self.search.replace(DRILLDOWN_SEARCH_PLACEHOLDER, detection.search)
58
-
86
+ """
87
+ self.search = self.search.replace(
88
+ DRILLDOWN_SEARCH_PLACEHOLDER, detection.search
89
+ )
59
90
 
60
91
  @model_serializer
61
- def serialize_model(self) -> dict[str,str]:
62
- #Call serializer for parent
63
- model:dict[str,str] = {}
92
+ def serialize_model(self) -> dict[str, str]:
93
+ # Call serializer for parent
94
+ model: dict[str, str] = {}
64
95
 
65
- model['name'] = self.name
66
- model['search'] = self.search
96
+ model["name"] = self.name
97
+ model["search"] = self.search
67
98
  if self.earliest_offset is not None:
68
- model['earliest_offset'] = self.earliest_offset
99
+ model["earliest_offset"] = self.earliest_offset
69
100
  if self.latest_offset is not None:
70
- model['latest_offset'] = self.latest_offset
71
- return model
101
+ model["latest_offset"] = self.latest_offset
102
+ return model
@@ -9,6 +9,7 @@ class AnalyticsType(StrEnum):
9
9
  Hunting = "Hunting"
10
10
  Correlation = "Correlation"
11
11
 
12
+
12
13
  class DeploymentType(StrEnum):
13
14
  TTP = "TTP"
14
15
  Anomaly = "Anomaly"
@@ -20,7 +21,7 @@ class DeploymentType(StrEnum):
20
21
 
21
22
  class DataModel(StrEnum):
22
23
  ENDPOINT = "Endpoint"
23
- NETWORK_TRAFFIC = "Network_Traffic"
24
+ NETWORK_TRAFFIC = "Network_Traffic"
24
25
  AUTHENTICATION = "Authentication"
25
26
  CHANGE = "Change"
26
27
  CHANGE_ANALYSIS = "Change_Analysis"
@@ -31,11 +32,11 @@ class DataModel(StrEnum):
31
32
  UPDATES = "Updates"
32
33
  VULNERABILITIES = "Vulnerabilities"
33
34
  WEB = "Web"
34
- #Should the following more specific DMs be added?
35
- #Or should they just fall under endpoint?
36
- #ENDPOINT_PROCESSES = "Endpoint_Processes"
37
- #ENDPOINT_FILESYSTEM = "Endpoint_Filesystem"
38
- #ENDPOINT_REGISTRY = "Endpoint_Registry"
35
+ # Should the following more specific DMs be added?
36
+ # Or should they just fall under endpoint?
37
+ # ENDPOINT_PROCESSES = "Endpoint_Processes"
38
+ # ENDPOINT_FILESYSTEM = "Endpoint_Filesystem"
39
+ # ENDPOINT_REGISTRY = "Endpoint_Registry"
39
40
  RISK = "Risk"
40
41
  SPLUNK_AUDIT = "Splunk_Audit"
41
42
 
@@ -44,6 +45,7 @@ class PlaybookType(StrEnum):
44
45
  INVESTIGATION = "Investigation"
45
46
  RESPONSE = "Response"
46
47
 
48
+
47
49
  class SecurityContentType(IntEnum):
48
50
  detections = 1
49
51
  baselines = 2
@@ -68,7 +70,6 @@ class SecurityContentType(IntEnum):
68
70
  # json_objects = "json_objects"
69
71
 
70
72
 
71
-
72
73
  class SecurityContentProductName(StrEnum):
73
74
  SPLUNK_ENTERPRISE = "Splunk Enterprise"
74
75
  SPLUNK_ENTERPRISE_SECURITY = "Splunk Enterprise Security"
@@ -76,6 +77,7 @@ class SecurityContentProductName(StrEnum):
76
77
  SPLUNK_SECURITY_ANALYTICS_FOR_AWS = "Splunk Security Analytics for AWS"
77
78
  SPLUNK_BEHAVIORAL_ANALYTICS = "Splunk Behavioral Analytics"
78
79
 
80
+
79
81
  class SecurityContentInvestigationProductName(StrEnum):
80
82
  SPLUNK_ENTERPRISE = "Splunk Enterprise"
81
83
  SPLUNK_ENTERPRISE_SECURITY = "Splunk Enterprise Security"
@@ -83,7 +85,7 @@ class SecurityContentInvestigationProductName(StrEnum):
83
85
  SPLUNK_SECURITY_ANALYTICS_FOR_AWS = "Splunk Security Analytics for AWS"
84
86
  SPLUNK_BEHAVIORAL_ANALYTICS = "Splunk Behavioral Analytics"
85
87
  SPLUNK_PHANTOM = "Splunk Phantom"
86
-
88
+
87
89
 
88
90
  class DetectionStatus(StrEnum):
89
91
  production = "production"
@@ -147,7 +149,7 @@ class DetectionTestingMode(StrEnum):
147
149
  # "Actions on Objectives": 7
148
150
  # }
149
151
  class KillChainPhase(StrEnum):
150
- UNKNOWN ="Unknown"
152
+ UNKNOWN = "Unknown"
151
153
  RECONNAISSANCE = "Reconnaissance"
152
154
  WEAPONIZATION = "Weaponization"
153
155
  DELIVERY = "Delivery"
@@ -197,6 +199,7 @@ class DataSource(StrEnum):
197
199
  WINDOWS_SECURITY_5145 = "Windows Security 5145"
198
200
  WINDOWS_SYSTEM_7045 = "Windows System 7045"
199
201
 
202
+
200
203
  class ProvidingTechnology(StrEnum):
201
204
  AMAZON_SECURITY_LAKE = "Amazon Security Lake"
202
205
  AMAZON_WEB_SERVICES_CLOUDTRAIL = "Amazon Web Services - Cloudtrail"
@@ -216,9 +219,9 @@ class ProvidingTechnology(StrEnum):
216
219
  SPLUNK_INTERNAL_LOGS = "Splunk Internal Logs"
217
220
  SYMANTEC_ENDPOINT_PROTECTION = "Symantec Endpoint Protection"
218
221
  ZEEK = "Zeek"
219
-
222
+
220
223
  @staticmethod
221
- def getProvidingTechFromSearch(search_string:str)->List[ProvidingTechnology]:
224
+ def getProvidingTechFromSearch(search_string: str) -> List[ProvidingTechnology]:
222
225
  """_summary_
223
226
 
224
227
  Args:
@@ -230,34 +233,45 @@ class ProvidingTechnology(StrEnum):
230
233
  Returns:
231
234
  List[ProvidingTechnology]: List of providing technologies (with no duplicates because
232
235
  it is derived from a set) calculated from the search string.
233
- """
234
- matched_technologies:set[ProvidingTechnology] = set()
235
- #As there are many different sources that use google logs, we define the set once
236
- google_logs = set([ProvidingTechnology.GOOGLE_WORKSPACE, ProvidingTechnology.GOOGLE_CLOUD_PLATFORM])
236
+ """
237
+ matched_technologies: set[ProvidingTechnology] = set()
238
+ # As there are many different sources that use google logs, we define the set once
239
+ google_logs = set(
240
+ [
241
+ ProvidingTechnology.GOOGLE_WORKSPACE,
242
+ ProvidingTechnology.GOOGLE_CLOUD_PLATFORM,
243
+ ]
244
+ )
237
245
  providing_technologies_mapping = {
238
- '`amazon_security_lake`': set([ProvidingTechnology.AMAZON_SECURITY_LAKE]),
239
- 'audit_searches': set([ProvidingTechnology.SPLUNK_INTERNAL_LOGS]),
240
- '`azure_monitor_aad`': set([ProvidingTechnology.AZURE_AD, ProvidingTechnology.ENTRA_ID]),
241
- '`cloudtrail`': set([ProvidingTechnology.AMAZON_WEB_SERVICES_CLOUDTRAIL]),
242
- #Endpoint is NOT a Macro (and this is intentional since it is to capture Endpoint Datamodel usage)
243
- 'Endpoint': set([ProvidingTechnology.MICROSOFT_SYSMON,
244
- ProvidingTechnology.MICROSOFT_WINDOWS,
245
- ProvidingTechnology.CARBON_BLACK_RESPONSE,
246
- ProvidingTechnology.CROWDSTRIKE_FALCON,
247
- ProvidingTechnology.SYMANTEC_ENDPOINT_PROTECTION]),
248
- '`google_': google_logs,
249
- '`gsuite': google_logs,
250
- '`gws_': google_logs,
251
- '`kube': set([ProvidingTechnology.KUBERNETES]),
252
- '`ms_defender`': set([ProvidingTechnology.MICROSOFT_DEFENDER]),
253
- '`o365_': set([ProvidingTechnology.MICROSOFT_OFFICE_365]),
254
- '`okta': set([ProvidingTechnology.OKTA]),
255
- '`pingid`': set([ProvidingTechnology.PING_ID]),
256
- '`powershell`': set(set([ProvidingTechnology.MICROSOFT_WINDOWS])),
257
- '`splunkd_': set([ProvidingTechnology.SPLUNK_INTERNAL_LOGS]),
258
- '`sysmon`': set([ProvidingTechnology.MICROSOFT_SYSMON]),
259
- '`wineventlog_security`': set([ProvidingTechnology.MICROSOFT_WINDOWS]),
260
- '`zeek_': set([ProvidingTechnology.ZEEK]),
246
+ "`amazon_security_lake`": set([ProvidingTechnology.AMAZON_SECURITY_LAKE]),
247
+ "audit_searches": set([ProvidingTechnology.SPLUNK_INTERNAL_LOGS]),
248
+ "`azure_monitor_aad`": set(
249
+ [ProvidingTechnology.AZURE_AD, ProvidingTechnology.ENTRA_ID]
250
+ ),
251
+ "`cloudtrail`": set([ProvidingTechnology.AMAZON_WEB_SERVICES_CLOUDTRAIL]),
252
+ # Endpoint is NOT a Macro (and this is intentional since it is to capture Endpoint Datamodel usage)
253
+ "Endpoint": set(
254
+ [
255
+ ProvidingTechnology.MICROSOFT_SYSMON,
256
+ ProvidingTechnology.MICROSOFT_WINDOWS,
257
+ ProvidingTechnology.CARBON_BLACK_RESPONSE,
258
+ ProvidingTechnology.CROWDSTRIKE_FALCON,
259
+ ProvidingTechnology.SYMANTEC_ENDPOINT_PROTECTION,
260
+ ]
261
+ ),
262
+ "`google_": google_logs,
263
+ "`gsuite": google_logs,
264
+ "`gws_": google_logs,
265
+ "`kube": set([ProvidingTechnology.KUBERNETES]),
266
+ "`ms_defender`": set([ProvidingTechnology.MICROSOFT_DEFENDER]),
267
+ "`o365_": set([ProvidingTechnology.MICROSOFT_OFFICE_365]),
268
+ "`okta": set([ProvidingTechnology.OKTA]),
269
+ "`pingid`": set([ProvidingTechnology.PING_ID]),
270
+ "`powershell`": set(set([ProvidingTechnology.MICROSOFT_WINDOWS])),
271
+ "`splunkd_": set([ProvidingTechnology.SPLUNK_INTERNAL_LOGS]),
272
+ "`sysmon`": set([ProvidingTechnology.MICROSOFT_SYSMON]),
273
+ "`wineventlog_security`": set([ProvidingTechnology.MICROSOFT_WINDOWS]),
274
+ "`zeek_": set([ProvidingTechnology.ZEEK]),
261
275
  }
262
276
  for key in providing_technologies_mapping:
263
277
  if key in search_string:
@@ -286,6 +300,7 @@ class Cis18Value(StrEnum):
286
300
  CIS_17 = "CIS 17"
287
301
  CIS_18 = "CIS 18"
288
302
 
303
+
289
304
  class SecurityDomain(StrEnum):
290
305
  ENDPOINT = "endpoint"
291
306
  NETWORK = "network"
@@ -294,6 +309,7 @@ class SecurityDomain(StrEnum):
294
309
  ACCESS = "access"
295
310
  AUDIT = "audit"
296
311
 
312
+
297
313
  class AssetType(StrEnum):
298
314
  AWS_ACCOUNT = "AWS Account"
299
315
  AWS_EKS_KUBERNETES_CLUSTER = "AWS EKS Kubernetes cluster"
@@ -303,9 +319,9 @@ class AssetType(StrEnum):
303
319
  AMAZON_EKS_KUBERNETES_CLUSTER = "Amazon EKS Kubernetes cluster"
304
320
  AMAZON_EKS_KUBERNETES_CLUSTER_POD = "Amazon EKS Kubernetes cluster Pod"
305
321
  AMAZON_ELASTIC_CONTAINER_REGISTRY = "Amazon Elastic Container Registry"
306
- #AZURE = "Azure"
307
- #AZURE_AD = "Azure AD"
308
- #AZURE_AD_TENANT = "Azure AD Tenant"
322
+ # AZURE = "Azure"
323
+ # AZURE_AD = "Azure AD"
324
+ # AZURE_AD_TENANT = "Azure AD Tenant"
309
325
  AZURE_TENANT = "Azure Tenant"
310
326
  AZURE_AKS_KUBERNETES_CLUSTER = "Azure AKS Kubernetes cluster"
311
327
  AZURE_ACTIVE_DIRECTORY = "Azure Active Directory"
@@ -332,8 +348,8 @@ class AssetType(StrEnum):
332
348
  INSTANCE = "Instance"
333
349
  KUBERNETES = "Kubernetes"
334
350
  NETWORK = "Network"
335
- #OFFICE_365 = "Office 365"
336
- #OFFICE_365_Tenant = "Office 365 Tenant"
351
+ # OFFICE_365 = "Office 365"
352
+ # OFFICE_365_Tenant = "Office 365 Tenant"
337
353
  O365_TENANT = "O365 Tenant"
338
354
  OKTA_TENANT = "Okta Tenant"
339
355
  PROXY = "Proxy"
@@ -345,6 +361,7 @@ class AssetType(StrEnum):
345
361
  WEB_APPLICATION = "Web Application"
346
362
  WINDOWS = "Windows"
347
363
 
364
+
348
365
  class NistCategory(StrEnum):
349
366
  ID_AM = "ID.AM"
350
367
  ID_BE = "ID.BE"
@@ -369,6 +386,7 @@ class NistCategory(StrEnum):
369
386
  RC_IM = "RC.IM"
370
387
  RC_CO = "RC.CO"
371
388
 
389
+
372
390
  class RiskSeverity(StrEnum):
373
391
  # Levels taken from the following documentation link
374
392
  # https://docs.splunk.com/Documentation/ES/7.3.2/User/RiskScoring
@@ -4,21 +4,25 @@ from uuid import UUID
4
4
 
5
5
  class ValidationFailed(Exception):
6
6
  """Indicates not an error in execution, but a validation failure"""
7
+
7
8
  pass
8
9
 
9
10
 
10
11
  class IntegrationTestingError(Exception):
11
12
  """Base exception class for integration testing"""
13
+
12
14
  pass
13
15
 
14
16
 
15
17
  class ServerError(IntegrationTestingError):
16
18
  """An error encounterd during integration testing, as provided by the server (Splunk instance)"""
19
+
17
20
  pass
18
21
 
19
22
 
20
23
  class ClientError(IntegrationTestingError):
21
24
  """An error encounterd during integration testing, on the client's side (locally)"""
25
+
22
26
  pass
23
27
 
24
28
 
@@ -26,6 +30,7 @@ class MetadataValidationError(Exception, ABC):
26
30
  """
27
31
  Base class for any errors arising from savedsearches.conf detection metadata validation
28
32
  """
33
+
29
34
  # The name of the rule the error relates to
30
35
  rule_name: str
31
36
 
@@ -52,11 +57,8 @@ class DetectionMissingError(MetadataValidationError):
52
57
  """
53
58
  An error indicating a detection in the prior build could not be found in the current build
54
59
  """
55
- def __init__(
56
- self,
57
- rule_name: str,
58
- *args: object
59
- ) -> None:
60
+
61
+ def __init__(self, rule_name: str, *args: object) -> None:
60
62
  self.rule_name = rule_name
61
63
  super().__init__(self.long_message, *args)
62
64
 
@@ -77,15 +79,14 @@ class DetectionMissingError(MetadataValidationError):
77
79
  A short-form error message
78
80
  :returns: a str, the message
79
81
  """
80
- return (
81
- "Detection from previous build not found in current build."
82
- )
82
+ return "Detection from previous build not found in current build."
83
83
 
84
84
 
85
85
  class DetectionIDError(MetadataValidationError):
86
86
  """
87
87
  An error indicating the detection ID may have changed between builds
88
88
  """
89
+
89
90
  # The ID from the current build
90
91
  current_id: UUID
91
92
 
@@ -93,11 +94,7 @@ class DetectionIDError(MetadataValidationError):
93
94
  previous_id: UUID
94
95
 
95
96
  def __init__(
96
- self,
97
- rule_name: str,
98
- current_id: UUID,
99
- previous_id: UUID,
100
- *args: object
97
+ self, rule_name: str, current_id: UUID, previous_id: UUID, *args: object
101
98
  ) -> None:
102
99
  self.rule_name = rule_name
103
100
  self.current_id = current_id
@@ -122,15 +119,14 @@ class DetectionIDError(MetadataValidationError):
122
119
  A short-form error message
123
120
  :returns: a str, the message
124
121
  """
125
- return (
126
- f"Detection ID {self.current_id} in current build does not match ID {self.previous_id} in previous build."
127
- )
122
+ return f"Detection ID {self.current_id} in current build does not match ID {self.previous_id} in previous build."
128
123
 
129
124
 
130
125
  class VersioningError(MetadataValidationError, ABC):
131
126
  """
132
127
  A base class for any metadata validation errors relating to detection versioning
133
128
  """
129
+
134
130
  # The version in the current build
135
131
  current_version: int
136
132
 
@@ -138,11 +134,7 @@ class VersioningError(MetadataValidationError, ABC):
138
134
  previous_version: int
139
135
 
140
136
  def __init__(
141
- self,
142
- rule_name: str,
143
- current_version: int,
144
- previous_version: int,
145
- *args: object
137
+ self, rule_name: str, current_version: int, previous_version: int, *args: object
146
138
  ) -> None:
147
139
  self.rule_name = rule_name
148
140
  self.current_version = current_version
@@ -154,6 +146,7 @@ class VersionDecrementedError(VersioningError):
154
146
  """
155
147
  An error indicating the version number went down between builds
156
148
  """
149
+
157
150
  @property
158
151
  def long_message(self) -> str:
159
152
  """
@@ -182,6 +175,7 @@ class VersionBumpingError(VersioningError):
182
175
  """
183
176
  An error indicating the detection changed but its version wasn't bumped appropriately
184
177
  """
178
+
185
179
  @property
186
180
  def long_message(self) -> str:
187
181
  """
@@ -200,6 +194,4 @@ class VersionBumpingError(VersioningError):
200
194
  A short-form error message
201
195
  :returns: a str, the message
202
196
  """
203
- return (
204
- f"Detection version in current build should be bumped to at least {self.previous_version + 1}."
205
- )
197
+ return f"Detection version in current build should be bumped to at least {self.previous_version + 1}."
@@ -10,6 +10,7 @@ class IntegrationTest(BaseTest):
10
10
  """
11
11
  An integration test for a detection against ES
12
12
  """
13
+
13
14
  # The test type (integration)
14
15
  test_type: TestType = Field(default=TestType.INTEGRATION)
15
16
 
@@ -34,7 +35,6 @@ class IntegrationTest(BaseTest):
34
35
  Skip the test by setting its result status
35
36
  :param message: the reason for skipping
36
37
  """
37
- self.result = IntegrationTestResult( # type: ignore
38
- message=message,
39
- status=TestResultStatus.SKIP
38
+ self.result = IntegrationTestResult( # type: ignore
39
+ message=message, status=TestResultStatus.SKIP
40
40
  )
@@ -5,5 +5,6 @@ class IntegrationTestResult(BaseTestResult):
5
5
  """
6
6
  An integration test result
7
7
  """
8
+
8
9
  # the total time we slept waiting for the detection to fire after activating it
9
10
  wait_duration: int | None = None
@@ -1,21 +1,22 @@
1
1
  from __future__ import annotations
2
2
  import re
3
3
  from typing import List, Any
4
- from pydantic import computed_field, Field, ConfigDict,model_serializer
4
+ from pydantic import computed_field, Field, ConfigDict, model_serializer
5
5
  from contentctl.objects.security_content_object import SecurityContentObject
6
6
  from contentctl.objects.enums import DataModel
7
7
  from contentctl.objects.investigation_tags import InvestigationTags
8
8
  from contentctl.objects.constants import (
9
9
  CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
10
10
  CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE,
11
- CONTENTCTL_MAX_STANZA_LENGTH
11
+ CONTENTCTL_MAX_STANZA_LENGTH,
12
12
  )
13
13
  from contentctl.objects.config import CustomApp
14
14
 
15
+
15
16
  class Investigation(SecurityContentObject):
16
17
  model_config = ConfigDict(validate_default=False)
17
- type: str = Field(...,pattern="^Investigation$")
18
- name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
18
+ type: str = Field(..., pattern="^Investigation$")
19
+ name: str = Field(..., max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
19
20
  search: str = Field(...)
20
21
  how_to_implement: str = Field(...)
21
22
  known_false_positives: str = Field(...)
@@ -24,9 +25,9 @@ class Investigation(SecurityContentObject):
24
25
  # enrichment
25
26
  @computed_field
26
27
  @property
27
- def inputs(self)->List[str]:
28
- #Parse out and return all inputs from the searchj
29
- inputs:List[str] = []
28
+ def inputs(self) -> List[str]:
29
+ # Parse out and return all inputs from the searchj
30
+ inputs: List[str] = []
30
31
  pattern = r"\$([^\s.]*)\$"
31
32
 
32
33
  for input in re.findall(pattern, self.search):
@@ -41,27 +42,42 @@ class Investigation(SecurityContentObject):
41
42
 
42
43
  @computed_field
43
44
  @property
44
- def lowercase_name(self)->str:
45
- return self.name.replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower().replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower()
45
+ def lowercase_name(self) -> str:
46
+ return (
47
+ self.name.replace(" ", "_")
48
+ .replace("-", "_")
49
+ .replace(".", "_")
50
+ .replace("/", "_")
51
+ .lower()
52
+ .replace(" ", "_")
53
+ .replace("-", "_")
54
+ .replace(".", "_")
55
+ .replace("/", "_")
56
+ .lower()
57
+ )
46
58
 
47
-
48
59
  # This is a slightly modified version of the get_conf_stanza_name function from
49
60
  # SecurityContentObject_Abstract
50
- def get_response_task_name(self, app:CustomApp, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH)->str:
51
- stanza_name = CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name)
61
+ def get_response_task_name(
62
+ self, app: CustomApp, max_stanza_length: int = CONTENTCTL_MAX_STANZA_LENGTH
63
+ ) -> str:
64
+ stanza_name = CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE.format(
65
+ app_label=app.label, detection_name=self.name
66
+ )
52
67
  if len(stanza_name) > max_stanza_length:
53
- raise ValueError(f"conf stanza may only be {max_stanza_length} characters, "
54
- f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ")
68
+ raise ValueError(
69
+ f"conf stanza may only be {max_stanza_length} characters, "
70
+ f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' "
71
+ )
55
72
  return stanza_name
56
-
57
73
 
58
74
  @model_serializer
59
75
  def serialize_model(self):
60
- #Call serializer for parent
76
+ # Call serializer for parent
61
77
  super_fields = super().serialize_model()
62
-
63
- #All fields custom to this model
64
- model= {
78
+
79
+ # All fields custom to this model
80
+ model = {
65
81
  "type": self.type,
66
82
  "datamodel": self.datamodel,
67
83
  "search": self.search,
@@ -69,17 +85,16 @@ class Investigation(SecurityContentObject):
69
85
  "known_false_positives": self.known_false_positives,
70
86
  "inputs": self.inputs,
71
87
  "tags": self.tags.model_dump(),
72
- "lowercase_name":self.lowercase_name
88
+ "lowercase_name": self.lowercase_name,
73
89
  }
74
-
75
- #Combine fields from this model with fields from parent
90
+
91
+ # Combine fields from this model with fields from parent
76
92
  super_fields.update(model)
77
-
78
- #return the model
79
- return super_fields
80
93
 
94
+ # return the model
95
+ return super_fields
81
96
 
82
- def model_post_init(self, ctx:dict[str,Any]):
97
+ def model_post_init(self, ctx: dict[str, Any]):
83
98
  # Ensure we link all stories this investigation references
84
99
  # back to itself
85
100
  for story in self.tags.analytic_story: