contentctl 4.4.7__py3-none-any.whl → 5.0.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 (123) hide show
  1. contentctl/__init__.py +1 -1
  2. contentctl/actions/build.py +102 -57
  3. contentctl/actions/deploy_acs.py +29 -24
  4. contentctl/actions/detection_testing/DetectionTestingManager.py +66 -42
  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 +192 -147
  8. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
  9. contentctl/actions/detection_testing/progress_bar.py +9 -6
  10. contentctl/actions/detection_testing/views/DetectionTestingView.py +16 -19
  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 +155 -108
  18. contentctl/actions/release_notes.py +276 -146
  19. contentctl/actions/reporting.py +23 -19
  20. contentctl/actions/test.py +33 -28
  21. contentctl/actions/validate.py +55 -34
  22. contentctl/api.py +54 -45
  23. contentctl/contentctl.py +124 -90
  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 -53
  30. contentctl/input/director.py +68 -36
  31. contentctl/input/new_content_questions.py +27 -35
  32. contentctl/input/yml_reader.py +28 -18
  33. contentctl/objects/abstract_security_content_objects/detection_abstract.py +303 -259
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +115 -52
  35. contentctl/objects/alert_action.py +10 -9
  36. contentctl/objects/annotated_types.py +1 -1
  37. contentctl/objects/atomic.py +65 -54
  38. contentctl/objects/base_test.py +5 -3
  39. contentctl/objects/base_test_result.py +19 -11
  40. contentctl/objects/baseline.py +62 -30
  41. contentctl/objects/baseline_tags.py +30 -24
  42. contentctl/objects/config.py +790 -597
  43. contentctl/objects/constants.py +33 -56
  44. contentctl/objects/correlation_search.py +150 -136
  45. contentctl/objects/dashboard.py +55 -41
  46. contentctl/objects/data_source.py +16 -17
  47. contentctl/objects/deployment.py +43 -44
  48. contentctl/objects/deployment_email.py +3 -2
  49. contentctl/objects/deployment_notable.py +4 -2
  50. contentctl/objects/deployment_phantom.py +7 -6
  51. contentctl/objects/deployment_rba.py +3 -2
  52. contentctl/objects/deployment_scheduling.py +3 -2
  53. contentctl/objects/deployment_slack.py +3 -2
  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 +58 -103
  58. contentctl/objects/drilldown.py +66 -34
  59. contentctl/objects/enums.py +81 -100
  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 +59 -36
  64. contentctl/objects/investigation_tags.py +30 -19
  65. contentctl/objects/lookup.py +304 -101
  66. contentctl/objects/macro.py +55 -39
  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 +23 -13
  74. contentctl/objects/rba.py +96 -0
  75. contentctl/objects/risk_analysis_action.py +15 -11
  76. contentctl/objects/risk_event.py +110 -160
  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 +54 -49
  81. contentctl/objects/story_tags.py +56 -45
  82. contentctl/objects/test_attack_data.py +2 -1
  83. contentctl/objects/test_group.py +5 -2
  84. contentctl/objects/threat_object.py +1 -0
  85. contentctl/objects/throttling.py +27 -18
  86. contentctl/objects/unit_test.py +3 -4
  87. contentctl/objects/unit_test_baseline.py +5 -5
  88. contentctl/objects/unit_test_result.py +6 -6
  89. contentctl/output/api_json_output.py +233 -220
  90. contentctl/output/attack_nav_output.py +21 -21
  91. contentctl/output/attack_nav_writer.py +29 -37
  92. contentctl/output/conf_output.py +235 -172
  93. contentctl/output/conf_writer.py +201 -125
  94. contentctl/output/data_source_writer.py +38 -26
  95. contentctl/output/doc_md_output.py +53 -27
  96. contentctl/output/jinja_writer.py +19 -15
  97. contentctl/output/json_writer.py +21 -11
  98. contentctl/output/svg_output.py +56 -38
  99. contentctl/output/templates/analyticstories_detections.j2 +2 -2
  100. contentctl/output/templates/analyticstories_stories.j2 +1 -1
  101. contentctl/output/templates/collections.j2 +1 -1
  102. contentctl/output/templates/doc_detections.j2 +0 -5
  103. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  104. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  105. contentctl/output/templates/savedsearches_baselines.j2 +2 -2
  106. contentctl/output/templates/savedsearches_detections.j2 +10 -11
  107. contentctl/output/templates/savedsearches_investigations.j2 +2 -2
  108. contentctl/output/templates/transforms.j2 +6 -8
  109. contentctl/output/yml_writer.py +29 -20
  110. contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +16 -34
  111. contentctl/templates/stories/cobalt_strike.yml +1 -0
  112. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/METADATA +5 -4
  113. contentctl-5.0.0.dist-info/RECORD +168 -0
  114. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/WHEEL +1 -1
  115. contentctl/actions/initialize_old.py +0 -245
  116. contentctl/objects/event_source.py +0 -11
  117. contentctl/objects/observable.py +0 -37
  118. contentctl/output/detection_writer.py +0 -28
  119. contentctl/output/new_content_yml_output.py +0 -56
  120. contentctl/output/yml_output.py +0 -66
  121. contentctl-4.4.7.dist-info/RECORD +0 -173
  122. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/LICENSE.md +0 -0
  123. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/entry_points.txt +0 -0
@@ -1,91 +1,62 @@
1
1
  from __future__ import annotations
2
+
2
3
  import uuid
3
4
  from typing import TYPE_CHECKING, List, Optional, Union
5
+
4
6
  from pydantic import (
7
+ UUID4,
5
8
  BaseModel,
9
+ ConfigDict,
6
10
  Field,
7
- NonNegativeInt,
8
- PositiveInt,
9
- computed_field,
10
- UUID4,
11
11
  HttpUrl,
12
- ConfigDict,
13
- field_validator,
14
12
  ValidationInfo,
13
+ computed_field,
14
+ field_validator,
15
15
  model_serializer,
16
- model_validator
16
+ model_validator,
17
17
  )
18
+
18
19
  from contentctl.objects.story import Story
19
20
  from contentctl.objects.throttling import Throttling
21
+
20
22
  if TYPE_CHECKING:
21
23
  from contentctl.input.director import DirectorOutputDto
22
24
 
23
- from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
25
+ from contentctl.objects.annotated_types import CVE_TYPE, MITRE_ATTACK_ID_TYPE
26
+ from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
24
27
  from contentctl.objects.constants import ATTACK_TACTICS_KILLCHAIN_MAPPING
25
- from contentctl.objects.observable import Observable
26
28
  from contentctl.objects.enums import (
27
- Cis18Value,
28
29
  AssetType,
29
- SecurityDomain,
30
- RiskSeverity,
30
+ Cis18Value,
31
31
  KillChainPhase,
32
32
  NistCategory,
33
- SecurityContentProductName
33
+ SecurityContentProductName,
34
+ SecurityDomain,
34
35
  )
35
- from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
36
- from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
36
+ from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
37
+
37
38
 
38
- # TODO (#266): disable the use_enum_values configuration
39
39
  class DetectionTags(BaseModel):
40
40
  # detection spec
41
- model_config = ConfigDict(use_enum_values=True, validate_default=False)
41
+
42
+ model_config = ConfigDict(validate_default=False, extra="forbid")
42
43
  analytic_story: list[Story] = Field(...)
43
44
  asset_type: AssetType = Field(...)
44
-
45
- confidence: NonNegativeInt = Field(..., le=100)
46
- impact: NonNegativeInt = Field(..., le=100)
47
-
48
- @computed_field
49
- @property
50
- def risk_score(self) -> int:
51
- return round((self.confidence * self.impact)/100)
52
-
53
- @computed_field
54
- @property
55
- def severity(self)->RiskSeverity:
56
- if 0 <= self.risk_score <= 20:
57
- return RiskSeverity.INFORMATIONAL
58
- elif 20 < self.risk_score <= 40:
59
- return RiskSeverity.LOW
60
- elif 40 < self.risk_score <= 60:
61
- return RiskSeverity.MEDIUM
62
- elif 60 < self.risk_score <= 80:
63
- return RiskSeverity.HIGH
64
- elif 80 < self.risk_score <= 100:
65
- return RiskSeverity.CRITICAL
66
- else:
67
- raise Exception(f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}")
68
-
45
+ group: list[str] = []
69
46
 
70
47
  mitre_attack_id: List[MITRE_ATTACK_ID_TYPE] = []
71
48
  nist: list[NistCategory] = []
72
49
 
73
- # TODO (#249): Add pydantic validator to ensure observables are unique within a detection
74
- observable: List[Observable] = []
75
- message: str = Field(...)
76
50
  product: list[SecurityContentProductName] = Field(..., min_length=1)
77
- required_fields: list[str] = Field(min_length=1)
78
51
  throttling: Optional[Throttling] = None
79
52
  security_domain: SecurityDomain = Field(...)
80
53
  cve: List[CVE_TYPE] = []
81
54
  atomic_guid: List[AtomicTest] = []
82
-
83
55
 
84
56
  # enrichment
85
- mitre_attack_enrichments: List[MitreAttackEnrichment] = Field([], validate_default=True)
86
- confidence_id: Optional[PositiveInt] = Field(None, ge=1, le=3)
87
- impact_id: Optional[PositiveInt] = Field(None, ge=1, le=5)
88
- evidence_str: Optional[str] = None
57
+ mitre_attack_enrichments: List[MitreAttackEnrichment] = Field(
58
+ [], validate_default=True
59
+ )
89
60
 
90
61
  @computed_field
91
62
  @property
@@ -114,55 +85,19 @@ class DetectionTags(BaseModel):
114
85
 
115
86
  # TODO (#268): Validate manual_test has length > 0 if not None
116
87
  manual_test: Optional[str] = None
117
-
118
- # The following validator is temporarily disabled pending further discussions
119
- # @validator('message')
120
- # def validate_message(cls,v,values):
121
-
122
- # observables:list[Observable] = values.get("observable",[])
123
- # observable_names = set([o.name for o in observables])
124
- # #find all of the observables used in the message by name
125
- # name_match_regex = r"\$([^\s.]*)\$"
126
-
127
- # message_observables = set()
128
-
129
- # #Make sure that all observable names in
130
- # for match in re.findall(name_match_regex, v):
131
- # #Remove
132
- # match_without_dollars = match.replace("$", "")
133
- # message_observables.add(match_without_dollars)
134
-
135
- # missing_observables = message_observables - observable_names
136
- # unused_observables = observable_names - message_observables
137
- # if len(missing_observables) > 0:
138
- # raise ValueError(
139
- # "The following observables are referenced in the message, but were not declared as"
140
- # f" observables: {missing_observables}"
141
- # )
142
-
143
- # if len(unused_observables) > 0:
144
- # raise ValueError(
145
- # "The following observables were declared, but are not referenced in the message:"
146
- # f" {unused_observables}"
147
- # )
148
- # return v
149
88
 
150
89
  @model_serializer
151
90
  def serialize_model(self):
152
91
  # Since this field has no parent, there is no need to call super() serialization function
153
92
  return {
154
93
  "analytic_story": [story.name for story in self.analytic_story],
155
- "asset_type": self.asset_type.value,
94
+ "asset_type": self.asset_type,
156
95
  "cis20": self.cis20,
157
96
  "kill_chain_phases": self.kill_chain_phases,
158
97
  "nist": self.nist,
159
- "observable": self.observable,
160
- "message": self.message,
161
- "risk_score": self.risk_score,
162
98
  "security_domain": self.security_domain,
163
- "risk_severity": self.severity,
164
99
  "mitre_attack_id": self.mitre_attack_id,
165
- "mitre_attack_enrichments": self.mitre_attack_enrichments
100
+ "mitre_attack_enrichments": self.mitre_attack_enrichments,
166
101
  }
167
102
 
168
103
  @model_validator(mode="after")
@@ -176,9 +111,13 @@ class DetectionTags(BaseModel):
176
111
  f" at runtime. Instead, this field contained: {self.mitre_attack_enrichments}"
177
112
  )
178
113
 
179
- output_dto: Union[DirectorOutputDto, None] = info.context.get("output_dto", None)
114
+ output_dto: Union[DirectorOutputDto, None] = info.context.get(
115
+ "output_dto", None
116
+ )
180
117
  if output_dto is None:
181
- raise ValueError("Context not provided to detection.detection_tags model post validator")
118
+ raise ValueError(
119
+ "Context not provided to detection.detection_tags model post validator"
120
+ )
182
121
 
183
122
  if output_dto.attack_enrichment.use_enrichment is False:
184
123
  return self
@@ -187,7 +126,9 @@ class DetectionTags(BaseModel):
187
126
  missing_tactics: list[str] = []
188
127
  for mitre_attack_id in self.mitre_attack_id:
189
128
  try:
190
- mitre_enrichments.append(output_dto.attack_enrichment.getEnrichmentByMitreID(mitre_attack_id))
129
+ mitre_enrichments.append(
130
+ output_dto.attack_enrichment.getEnrichmentByMitreID(mitre_attack_id)
131
+ )
191
132
  except Exception:
192
133
  missing_tactics.append(mitre_attack_id)
193
134
 
@@ -198,7 +139,7 @@ class DetectionTags(BaseModel):
198
139
 
199
140
  return self
200
141
 
201
- '''
142
+ """
202
143
  @field_validator('mitre_attack_enrichments', mode="before")
203
144
  @classmethod
204
145
  def addAttackEnrichments(cls, v:list[MitreAttackEnrichment], info:ValidationInfo)->list[MitreAttackEnrichment]:
@@ -216,31 +157,43 @@ class DetectionTags(BaseModel):
216
157
  enrichments = []
217
158
 
218
159
  return enrichments
219
- '''
160
+ """
220
161
 
221
- @field_validator('analytic_story', mode="before")
162
+ @field_validator("analytic_story", mode="before")
222
163
  @classmethod
223
- def mapStoryNamesToStoryObjects(cls, v: list[str], info: ValidationInfo) -> list[Story]:
164
+ def mapStoryNamesToStoryObjects(
165
+ cls, v: list[str], info: ValidationInfo
166
+ ) -> list[Story]:
224
167
  if info.context is None:
225
168
  raise ValueError("ValidationInfo.context unexpectedly null")
226
169
 
227
- return Story.mapNamesToSecurityContentObjects(v, info.context.get("output_dto", None))
170
+ return Story.mapNamesToSecurityContentObjects(
171
+ v, info.context.get("output_dto", None)
172
+ )
228
173
 
229
174
  def getAtomicGuidStringArray(self) -> List[str]:
230
- return [str(atomic_guid.auto_generated_guid) for atomic_guid in self.atomic_guid]
175
+ return [
176
+ str(atomic_guid.auto_generated_guid) for atomic_guid in self.atomic_guid
177
+ ]
231
178
 
232
- @field_validator('atomic_guid', mode="before")
179
+ @field_validator("atomic_guid", mode="before")
233
180
  @classmethod
234
- def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> List[AtomicTest]:
181
+ def mapAtomicGuidsToAtomicTests(
182
+ cls, v: List[UUID4], info: ValidationInfo
183
+ ) -> List[AtomicTest]:
235
184
  if len(v) == 0:
236
185
  return []
237
186
 
238
187
  if info.context is None:
239
188
  raise ValueError("ValidationInfo.context unexpectedly null")
240
189
 
241
- output_dto: Union[DirectorOutputDto, None] = info.context.get("output_dto", None)
190
+ output_dto: Union[DirectorOutputDto, None] = info.context.get(
191
+ "output_dto", None
192
+ )
242
193
  if output_dto is None:
243
- raise ValueError("Context not provided to detection.detection_tags.atomic_guid validator")
194
+ raise ValueError(
195
+ "Context not provided to detection.detection_tags.atomic_guid validator"
196
+ )
244
197
 
245
198
  atomic_enrichment: AtomicEnrichment = output_dto.atomic_enrichment
246
199
 
@@ -282,4 +235,6 @@ class DetectionTags(BaseModel):
282
235
  elif len(missing_tests) > 0:
283
236
  raise ValueError(missing_tests_string)
284
237
 
285
- return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
238
+ return matched_tests + [
239
+ AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests
240
+ ]
@@ -1,70 +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
40
  @classmethod
27
41
  def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]:
28
- 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]
29
50
  if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting:
30
51
  # No victims, so no drilldowns
31
52
  return []
32
53
  print(f"Adding default drilldowns for [{detection.name}]")
33
- variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables])
54
+ variableNamesString = " and ".join([f"${o.field}$" for o in victim_observables])
34
55
  nameField = f"View the detection results for {variableNamesString}"
35
- 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
+ )
36
59
  search_field = f"{detection.search}{appendedSearch}"
37
- detection_results = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field)
38
-
39
-
60
+ detection_results = cls(
61
+ name=nameField,
62
+ earliest_offset=EARLIEST_OFFSET,
63
+ latest_offset=LATEST_OFFSET,
64
+ search=search_field,
65
+ )
66
+
40
67
  nameField = f"View risk events for the last 7 days for {variableNamesString}"
41
- fieldNamesListString = ', '.join([o.name for o in victim_observables])
68
+ fieldNamesListString = ", ".join([o.field for o in victim_observables])
42
69
  search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}"
43
- 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
+ )
44
76
 
45
- return [detection_results,risk_events_last_7_days]
46
-
77
+ return [detection_results, risk_events_last_7_days]
47
78
 
48
- def perform_search_substitutions(self, detection:Detection)->None:
79
+ def perform_search_substitutions(self, detection: Detection) -> None:
49
80
  """Replaces the field DRILLDOWN_SEARCH_PLACEHOLDER (%original_detection_search%)
50
81
  with the search contained in the detection. We do this so that the YML does not
51
82
  need the search copy/pasted from the search field into the drilldown object.
52
83
 
53
84
  Args:
54
85
  detection (Detection): Detection to be used to update the search field of the drilldown
55
- """
56
- self.search = self.search.replace(DRILLDOWN_SEARCH_PLACEHOLDER, detection.search)
57
-
86
+ """
87
+ self.search = self.search.replace(
88
+ DRILLDOWN_SEARCH_PLACEHOLDER, detection.search
89
+ )
58
90
 
59
91
  @model_serializer
60
- def serialize_model(self) -> dict[str,str]:
61
- #Call serializer for parent
62
- model:dict[str,str] = {}
92
+ def serialize_model(self) -> dict[str, str]:
93
+ # Call serializer for parent
94
+ model: dict[str, str] = {}
63
95
 
64
- model['name'] = self.name
65
- model['search'] = self.search
96
+ model["name"] = self.name
97
+ model["search"] = self.search
66
98
  if self.earliest_offset is not None:
67
- model['earliest_offset'] = self.earliest_offset
99
+ model["earliest_offset"] = self.earliest_offset
68
100
  if self.latest_offset is not None:
69
- model['latest_offset'] = self.latest_offset
70
- return model
101
+ model["latest_offset"] = self.latest_offset
102
+ return model