psengine 2.0.4__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 (115) hide show
  1. psengine/__init__.py +22 -0
  2. psengine/_sdk_id.py +16 -0
  3. psengine/_version.py +14 -0
  4. psengine/analyst_notes/__init__.py +32 -0
  5. psengine/analyst_notes/constants.py +15 -0
  6. psengine/analyst_notes/errors.py +42 -0
  7. psengine/analyst_notes/helpers.py +90 -0
  8. psengine/analyst_notes/models.py +219 -0
  9. psengine/analyst_notes/note.py +149 -0
  10. psengine/analyst_notes/note_mgr.py +400 -0
  11. psengine/base_http_client.py +285 -0
  12. psengine/classic_alerts/__init__.py +24 -0
  13. psengine/classic_alerts/classic_alert.py +275 -0
  14. psengine/classic_alerts/classic_alert_mgr.py +507 -0
  15. psengine/classic_alerts/constants.py +31 -0
  16. psengine/classic_alerts/errors.py +38 -0
  17. psengine/classic_alerts/helpers.py +87 -0
  18. psengine/classic_alerts/markdown/__init__.py +13 -0
  19. psengine/classic_alerts/markdown/markdown.py +359 -0
  20. psengine/classic_alerts/models.py +141 -0
  21. psengine/collective_insights/__init__.py +29 -0
  22. psengine/collective_insights/collective_insights.py +164 -0
  23. psengine/collective_insights/constants.py +44 -0
  24. psengine/collective_insights/errors.py +18 -0
  25. psengine/collective_insights/insight.py +89 -0
  26. psengine/collective_insights/models.py +81 -0
  27. psengine/common_models.py +89 -0
  28. psengine/config/__init__.py +15 -0
  29. psengine/config/config.py +284 -0
  30. psengine/config/errors.py +18 -0
  31. psengine/constants.py +63 -0
  32. psengine/detection/__init__.py +17 -0
  33. psengine/detection/detection_mgr.py +135 -0
  34. psengine/detection/detection_rule.py +85 -0
  35. psengine/detection/errors.py +26 -0
  36. psengine/detection/helpers.py +56 -0
  37. psengine/detection/models.py +47 -0
  38. psengine/endpoints.py +98 -0
  39. psengine/enrich/__init__.py +28 -0
  40. psengine/enrich/constants.py +73 -0
  41. psengine/enrich/errors.py +26 -0
  42. psengine/enrich/lookup.py +299 -0
  43. psengine/enrich/lookup_mgr.py +341 -0
  44. psengine/enrich/models/__init__.py +13 -0
  45. psengine/enrich/models/base_enriched_entity.py +43 -0
  46. psengine/enrich/models/lookup.py +271 -0
  47. psengine/enrich/models/soar.py +138 -0
  48. psengine/enrich/soar.py +89 -0
  49. psengine/enrich/soar_mgr.py +176 -0
  50. psengine/entity_lists/__init__.py +16 -0
  51. psengine/entity_lists/constants.py +19 -0
  52. psengine/entity_lists/entity_list.py +435 -0
  53. psengine/entity_lists/entity_list_mgr.py +185 -0
  54. psengine/entity_lists/errors.py +26 -0
  55. psengine/entity_lists/models.py +87 -0
  56. psengine/entity_match/__init__.py +16 -0
  57. psengine/entity_match/entity_match.py +90 -0
  58. psengine/entity_match/entity_match_mgr.py +235 -0
  59. psengine/entity_match/errors.py +18 -0
  60. psengine/entity_match/models.py +22 -0
  61. psengine/errors.py +41 -0
  62. psengine/helpers/__init__.py +23 -0
  63. psengine/helpers/helpers.py +471 -0
  64. psengine/logger/__init__.py +15 -0
  65. psengine/logger/constants.py +39 -0
  66. psengine/logger/errors.py +18 -0
  67. psengine/logger/rf_logger.py +148 -0
  68. psengine/markdown/__init__.py +21 -0
  69. psengine/markdown/markdown.py +169 -0
  70. psengine/markdown/models.py +22 -0
  71. psengine/playbook_alerts/__init__.py +34 -0
  72. psengine/playbook_alerts/constants.py +35 -0
  73. psengine/playbook_alerts/errors.py +35 -0
  74. psengine/playbook_alerts/helpers.py +80 -0
  75. psengine/playbook_alerts/mappings.py +44 -0
  76. psengine/playbook_alerts/markdown/__init__.py +13 -0
  77. psengine/playbook_alerts/markdown/markdown.py +98 -0
  78. psengine/playbook_alerts/markdown/markdown_code_repo.py +64 -0
  79. psengine/playbook_alerts/markdown/markdown_domain_abuse.py +118 -0
  80. psengine/playbook_alerts/markdown/markdown_identity_exposure.py +158 -0
  81. psengine/playbook_alerts/models/__init__.py +36 -0
  82. psengine/playbook_alerts/models/common_models.py +18 -0
  83. psengine/playbook_alerts/models/panel_log.py +329 -0
  84. psengine/playbook_alerts/models/panel_status.py +70 -0
  85. psengine/playbook_alerts/models/pba_code_repo_leak.py +52 -0
  86. psengine/playbook_alerts/models/pba_cyber_vulnerability.py +53 -0
  87. psengine/playbook_alerts/models/pba_domain_abuse.py +139 -0
  88. psengine/playbook_alerts/models/pba_identity_exposures.py +93 -0
  89. psengine/playbook_alerts/models/pba_third_party_risk.py +103 -0
  90. psengine/playbook_alerts/models/search_endpoint.py +68 -0
  91. psengine/playbook_alerts/pa_category.py +37 -0
  92. psengine/playbook_alerts/playbook_alert_mgr.py +593 -0
  93. psengine/playbook_alerts/playbook_alerts.py +393 -0
  94. psengine/rf_client.py +430 -0
  95. psengine/risklists/__init__.py +17 -0
  96. psengine/risklists/constants.py +15 -0
  97. psengine/risklists/errors.py +20 -0
  98. psengine/risklists/models.py +65 -0
  99. psengine/risklists/risklist_mgr.py +156 -0
  100. psengine/stix2/__init__.py +21 -0
  101. psengine/stix2/base_stix_entity.py +62 -0
  102. psengine/stix2/complex_entity.py +372 -0
  103. psengine/stix2/constants.py +81 -0
  104. psengine/stix2/enriched_indicator.py +261 -0
  105. psengine/stix2/errors.py +22 -0
  106. psengine/stix2/helpers.py +68 -0
  107. psengine/stix2/rf_bundle.py +240 -0
  108. psengine/stix2/simple_entity.py +145 -0
  109. psengine/stix2/util.py +53 -0
  110. psengine-2.0.4.dist-info/METADATA +189 -0
  111. psengine-2.0.4.dist-info/RECORD +115 -0
  112. psengine-2.0.4.dist-info/WHEEL +5 -0
  113. psengine-2.0.4.dist-info/entry_points.txt +2 -0
  114. psengine-2.0.4.dist-info/licenses/LICENSE +21 -0
  115. psengine-2.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,593 @@
1
+ ##################################### TERMS OF USE ###########################################
2
+ # The following code is provided for demonstration purpose only, and should not be used #
3
+ # without independent verification. Recorded Future makes no representations or warranties, #
4
+ # express, implied, statutory, or otherwise, regarding any aspect of this code or of the #
5
+ # information it may retrieve, and provides it both strictly “as-is” and without assuming #
6
+ # responsibility for any information it may retrieve. Recorded Future shall not be liable #
7
+ # for, and you assume all risk of using, the foregoing. By using this code, Customer #
8
+ # represents that it is solely responsible for having all necessary licenses, permissions, #
9
+ # rights, and/or consents to connect to third party APIs, and that it is solely responsible #
10
+ # for having all necessary licenses, permissions, rights, and/or consents to any data #
11
+ # accessed from any third party API. #
12
+ ##############################################################################################
13
+
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import Optional, Union
17
+
18
+ import pydantic
19
+ import requests
20
+ from more_itertools import batched
21
+ from pydantic import validate_call
22
+
23
+ from ..constants import DEFAULT_LIMIT, ROOT_DIR
24
+ from ..endpoints import (
25
+ EP_PLAYBOOK_ALERT,
26
+ EP_PLAYBOOK_ALERT_COMMON,
27
+ EP_PLAYBOOK_ALERT_DOMAIN_ABUSE,
28
+ EP_PLAYBOOK_ALERT_SEARCH,
29
+ )
30
+ from ..helpers import TimeHelpers, connection_exceptions, debug_call
31
+ from ..rf_client import RFClient
32
+ from .constants import PLAYBOOK_ALERT_TYPE, STATUS_PANEL_NAME
33
+ from .errors import (
34
+ PlaybookAlertFetchError,
35
+ PlaybookAlertRetrieveImageError,
36
+ PlaybookAlertSearchError,
37
+ PlaybookAlertUpdateError,
38
+ )
39
+ from .mappings import CATEGORY_ENDPOINTS, CATEGORY_TO_OBJECT_MAP
40
+ from .models import SearchResponse
41
+ from .pa_category import PACategory
42
+ from .playbook_alerts import (
43
+ LookupAlertIn,
44
+ PBA_DomainAbuse,
45
+ PBA_Generic,
46
+ PreviewAlertOut,
47
+ SearchIn,
48
+ UpdateAlertIn,
49
+ )
50
+
51
+ BULK_LOOKUP_BATCH_SIZE = 200
52
+
53
+ DEFAULT_ALERTS_OUTPUT_DIR = Path(ROOT_DIR) / 'playbook_alerts'
54
+ PLAYBOOK_ALERTS_OUTPUT_FNAME = 'rf_playbook_alerts_'
55
+
56
+
57
+ class PlaybookAlertMgr:
58
+ """Manages requests for Recorded Future playbook alerts."""
59
+
60
+ def __init__(self, rf_token: str = None):
61
+ """Initializes the PlaybookAlertMgr object.
62
+
63
+ Args:
64
+ rf_token (str, optional): Recorded Future API token. Defaults to None
65
+ """
66
+ self.log = logging.getLogger(__name__)
67
+ self.rf_client = RFClient(api_token=rf_token) if rf_token is not None else RFClient()
68
+
69
+ @debug_call
70
+ @validate_call
71
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=PlaybookAlertFetchError)
72
+ def fetch(
73
+ self,
74
+ alert_id: str,
75
+ category: Optional[str] = None,
76
+ panels: Optional[list[str]] = None,
77
+ fetch_images: Optional[bool] = True,
78
+ ) -> PLAYBOOK_ALERT_TYPE:
79
+ """Fetch an individual Playbook Alert.
80
+
81
+ Endpoints:
82
+
83
+ - ``playbook-alert/{category}``
84
+ - ``playbook-alert/common/{alert_id}``
85
+
86
+ Args:
87
+ alert_id (str): Alert ID to fetch
88
+ category (Optional[PACategory], optional): Category to fetch. When this is not supplied,
89
+ fetch uses ``playbook-alert/common`` to find
90
+ the alert category. Defaults to None
91
+ panels (Optional[List[str]]): Panels to fetch. The ``status`` panel is always fetched,
92
+ to correctly initialize ADTs. Defaults to None (all)
93
+ fetch_images (Optional[bool]): Fetch images for Domain Abuse alerts. Defaults to True
94
+
95
+ Raises:
96
+ ValidationError if any parameter is of incorrect type
97
+ PlaybookAlertFetchError: if an API-related error occurs
98
+
99
+ Returns:
100
+ PBA ADT: Any one of the playbook alert ADTs. Unknown alert types return PBA_Generic
101
+ """
102
+ if category is None:
103
+ category = self._fetch_alert_category(alert_id)
104
+
105
+ category = category.lower()
106
+ if category in CATEGORY_ENDPOINTS:
107
+ endpoint = CATEGORY_ENDPOINTS[category]
108
+ else:
109
+ # Workaround to fetch new PAs that have not been officially supported
110
+ self.log.warning(
111
+ f'Unknown playbook alert category: {category}, for alert: {alert_id}. '
112
+ 'Using category as an endpoint for this lookup'
113
+ )
114
+ endpoint = f'{EP_PLAYBOOK_ALERT}/{category}'
115
+
116
+ data = {}
117
+ if panels:
118
+ # We must always fetch status panel for ADT initialization
119
+ panels = panels.append(STATUS_PANEL_NAME) if STATUS_PANEL_NAME not in panels else panels
120
+ data = {'panels': panels}
121
+ request_data = LookupAlertIn.model_validate(data)
122
+
123
+ url = f'{endpoint}/{alert_id}'
124
+ self.log.info(f'Fetching playbook alert: {alert_id}, category: {category}')
125
+ response = self.rf_client.request('post', url=url, data=request_data.json())
126
+ p_alert = self._playbook_alert_factory(category, response.json()['data'])
127
+
128
+ if isinstance(p_alert, PBA_DomainAbuse) and fetch_images:
129
+ self.fetch_images(p_alert)
130
+
131
+ return p_alert
132
+
133
+ @debug_call
134
+ @validate_call
135
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=PlaybookAlertFetchError)
136
+ def fetch_bulk(
137
+ self,
138
+ alerts: Optional[list[tuple[str, str]]] = None,
139
+ panels: Optional[list] = None,
140
+ fetch_images: Optional[bool] = False,
141
+ filter_from: Optional[int] = 0,
142
+ max_results: Optional[int] = DEFAULT_LIMIT,
143
+ order_by: Optional[str] = None,
144
+ direction: Optional[str] = None,
145
+ entity: Optional[Union[str, list]] = None,
146
+ statuses: Optional[Union[str, list]] = None,
147
+ priority: Optional[Union[str, list]] = None,
148
+ category: Optional[Union[str, list]] = None,
149
+ assignee: Optional[Union[str, list]] = None,
150
+ created_from: Optional[str] = None,
151
+ created_until: Optional[str] = None,
152
+ updated_from: Optional[str] = None,
153
+ updated_until: Optional[str] = None,
154
+ ) -> list[PLAYBOOK_ALERT_TYPE]:
155
+ """Fetch alerts in bulk based on a search query.
156
+
157
+ Endpoints:
158
+
159
+ - ``playbook-alert/search``
160
+ - ``playbook-alert/{category}/{alert_id}``
161
+
162
+ Args:
163
+ alerts (tuple, optional): Alert (id, category) tuples to fetch. If alerts are supplied,
164
+ other search parameters (query, limit, statuses, category,
165
+ priority, created, updated) are ignored. Defaults to None
166
+ panels (list, optional): Panels to fetch. Always fetches status panel. Defaults to None
167
+ fetch_images (bool, optional): Fetch images for Domain Abuse alerts. Defaults to True
168
+ filter_from: (int, optional): Offset to page from. Defaults to 0
169
+ max_results (int, optional): Maximum total number of alerts to fetch. Defaults to 10
170
+ order_by (str, optional): Order alerts by field [created|updated]
171
+ direction (str, optional): Direction to order alerts [asc|desc]
172
+ entity (list, optional): List of entities to fetch alerts for
173
+ statuses (list, optional): List of statuses to fetch e.g. ['New', 'InProgress']
174
+ priority (list, optional): Priority of alerts to fetch e.g. ['High', 'Informational']
175
+ category (list, optional): List of categories to fetch e.g. ['domain_abuse']
176
+ assignee (list, optional): List of assignee uhashes to fetch alerts for
177
+ created_from (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
178
+ created_until (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
179
+ updated_from (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
180
+ updated_until (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
181
+
182
+ Raises:
183
+ ValidationError if any parameter is of incorrect type
184
+ PlaybookAlertFetchError: if connection error occurs
185
+
186
+ Returns:
187
+ list: PlaybookAlert ADTs
188
+ """
189
+ query_params = locals()
190
+ for param in ['self', 'alerts', 'panels', 'fetch_images']:
191
+ query_params.pop(param)
192
+ if alerts is None:
193
+ search_result = self.search(**query_params)
194
+ alerts = [
195
+ {'id': x.playbook_alert_id, 'category': x.category} for x in search_result.data
196
+ ]
197
+ else:
198
+ alerts = [{'id': x[0], 'category': x[1]} for x in alerts]
199
+
200
+ fetched_alerts = []
201
+ errors = 0
202
+ for cat in {x['category'] for x in alerts}:
203
+ in_cat_alerts = filter(lambda x: x['category'] == cat, alerts)
204
+ in_cat_ids = [x['id'] for x in in_cat_alerts]
205
+ try:
206
+ fetched_alerts.extend(self._do_bulk(in_cat_ids, cat, fetch_images, panels or []))
207
+ except PlaybookAlertFetchError as err: # noqa: PERF203
208
+ errors += 1
209
+ self.log.error(err)
210
+
211
+ if errors:
212
+ self.log.error(f'Failed to fetch alerts due to {errors} error(s). See errors above')
213
+ raise PlaybookAlertFetchError('Failed to fetch alerts')
214
+
215
+ return fetched_alerts
216
+
217
+ @debug_call
218
+ @validate_call
219
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=PlaybookAlertSearchError)
220
+ def search(
221
+ self,
222
+ filter_from: Optional[int] = 0,
223
+ max_results: Optional[int] = DEFAULT_LIMIT,
224
+ order_by: Optional[str] = None,
225
+ direction: Optional[str] = None,
226
+ entity: Optional[Union[str, list]] = None,
227
+ statuses: Optional[Union[str, list]] = None,
228
+ priority: Optional[Union[str, list]] = None,
229
+ category: Optional[Union[str, list]] = None,
230
+ assignee: Optional[Union[str, list]] = None,
231
+ created_from: Optional[str] = None,
232
+ created_until: Optional[str] = None,
233
+ updated_from: Optional[str] = None,
234
+ updated_until: Optional[str] = None,
235
+ ) -> SearchResponse:
236
+ """Search playbook alerts.
237
+
238
+ Endpoints:
239
+ ``playbook-alert/search``
240
+
241
+ Args:
242
+ filter_from: (int, optional): Offset to page from. Defaults to 0
243
+ max_results (int, optional): Maximum total number of alerts to fetch. Defaults to 10
244
+ order_by (str, optional): Order alerts by field [created|updated]
245
+ direction (str, optional): Direction to order alerts [asc|desc]
246
+ entity (list, optional): List of entities to fetch alerts for
247
+ statuses (list, optional): List of statuses to fetch e.g. ['New', 'InProgress']
248
+ priority (list, optional): Priority of alerts to fetch e.g. ['High', 'Informational']
249
+ category (list, optional): List of categories to fetch e.g. ['domain_abuse']
250
+ assignee (list, optional): List of assignee uhashes to fetch alerts for
251
+ created_from (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
252
+ created_until (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
253
+ updated_from (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
254
+ updated_until (str, optional): ISO or relative [h|d], e.g. -3d or 2023-07-21T17:32:28Z
255
+
256
+ Raises:
257
+ ValidationError if any parameter is of incorrect type
258
+ PlaybookAlertSearchError: if connection error occurs
259
+
260
+ Returns:
261
+ SearchResponse: Search results
262
+ """
263
+ query_params = locals()
264
+ query_params.pop('self')
265
+ request_body = self._prepare_query(**query_params).json()
266
+ self.log.info(f'Searching for playbook alerts with params {request_body}')
267
+ response = self.rf_client.request('post', EP_PLAYBOOK_ALERT_SEARCH, data=request_body)
268
+ search_results = response.json()
269
+
270
+ status_message = search_results.get('status', {}).get('status_message')
271
+ count_returned = search_results.get('counts', {}).get('returned')
272
+ count_total = search_results.get('counts', {}).get('total')
273
+ self.log.info(
274
+ 'Status: {}, returned: {} {}, total: {} {}'.format(
275
+ status_message,
276
+ count_returned,
277
+ 'alert' if count_returned == 1 else 'alerts',
278
+ count_total,
279
+ 'alert' if count_total == 1 else 'alerts',
280
+ ),
281
+ )
282
+
283
+ return SearchResponse.model_validate(search_results)
284
+
285
+ @debug_call
286
+ @validate_call
287
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=PlaybookAlertUpdateError)
288
+ def update(
289
+ self,
290
+ alert: PLAYBOOK_ALERT_TYPE,
291
+ priority: Optional[str] = None,
292
+ status: Optional[str] = None,
293
+ assignee: Optional[str] = None,
294
+ log_entry: Optional[str] = None,
295
+ reopen_strategy: Optional[str] = None,
296
+ ) -> requests.Response:
297
+ """Update a playbook alert.
298
+
299
+ Endpoints:
300
+ ``playbook-alert/common/{playbook_alert_id}``
301
+
302
+ Args:
303
+ alert (BasePlaybookAlert): Playbook alert to update
304
+ priority (str, optional): Alert priority. Defaults to None
305
+ status (str, optional): Alert Status. Defaults to None
306
+ assignee (str, optional): Assignee. Defaults to None
307
+ log_entry (str, optional): Log entry. Defaults to None
308
+ reopen_strategy (str, optional): Reopen strategy. Defaults to None
309
+
310
+ Raises:
311
+ ValidationError if any parameter is of incorrect type
312
+ ValueError: If no update parameters are supplied
313
+ PlaybookAlertUpdateError: If the update request fails
314
+
315
+ Returns:
316
+ requests.Response: API response
317
+ """
318
+ if (
319
+ priority is None
320
+ and status is None
321
+ and assignee is None
322
+ and log_entry is None
323
+ and reopen_strategy is None
324
+ ):
325
+ raise ValueError('No update parameters were supplied')
326
+
327
+ body = {}
328
+ if priority is not None:
329
+ body['priority'] = priority
330
+
331
+ if status is not None:
332
+ body['status'] = status
333
+
334
+ if assignee is not None:
335
+ body['assignee'] = assignee
336
+
337
+ if log_entry is not None:
338
+ body['log_entry'] = log_entry
339
+
340
+ if reopen_strategy is not None:
341
+ body['reopen'] = reopen_strategy
342
+
343
+ alert_id = alert.playbook_alert_id
344
+ validated_payload = UpdateAlertIn.model_validate(body)
345
+ url = f'{EP_PLAYBOOK_ALERT_COMMON}/{alert_id}'
346
+ self.log.info(f'Updating playbook alert: {alert_id}')
347
+ response = self.rf_client.request('put', url=url, data=validated_payload.json())
348
+
349
+ return response
350
+
351
+ @debug_call
352
+ @validate_call
353
+ def _prepare_query(
354
+ self,
355
+ filter_from: Optional[int] = 0,
356
+ max_results: Optional[int] = DEFAULT_LIMIT,
357
+ order_by: Optional[str] = None,
358
+ direction: Optional[str] = None,
359
+ entity: Optional[Union[str, list]] = None,
360
+ statuses: Optional[Union[str, list]] = None,
361
+ priority: Optional[Union[str, list]] = None,
362
+ category: Optional[Union[str, list]] = None,
363
+ assignee: Optional[Union[str, list]] = None,
364
+ created_from: Optional[str] = None,
365
+ created_until: Optional[str] = None,
366
+ updated_from: Optional[str] = None,
367
+ updated_until: Optional[str] = None,
368
+ ) -> SearchIn:
369
+ """Create a query for searching playbook alerts.
370
+
371
+ See search() and fetch_bulk() for parameter descriptions.
372
+
373
+ Raises:
374
+ ValidationError if any parameter is of incorrect type
375
+
376
+ Returns:
377
+ SearchIn: Validated search query
378
+ """
379
+ params = {key: val for key, val in locals().items() if val and key != 'self'}
380
+
381
+ query = {'created_range': {}, 'updated_range': {}}
382
+
383
+ for arg in params:
384
+ key, value = self._process_arg(arg, params[arg])
385
+ if isinstance(value, dict):
386
+ query[key].update(value)
387
+ else:
388
+ query[key] = value
389
+
390
+ query = {
391
+ key: val
392
+ for key, val in query.items()
393
+ if not ((isinstance(val, (dict, list))) and len(val) == 0)
394
+ }
395
+
396
+ return SearchIn.model_validate(query)
397
+
398
+ @debug_call
399
+ @validate_call
400
+ @connection_exceptions(
401
+ ignore_status_code=[], exception_to_raise=PlaybookAlertRetrieveImageError
402
+ )
403
+ def fetch_one_image(self, alert_id: str, image_id: str) -> bytes:
404
+ """Retrieve image from Domain Abuse playbook alert.
405
+
406
+ Endpoints:
407
+ ``playbook-alert/domain_abuse/{alert_id}/image/{image_id}``
408
+
409
+ Args:
410
+ alert_id (str): Alert ID for corresponding image ID
411
+ image_id (str): Image ID to fetch
412
+
413
+ Raises:
414
+ ValidationError if any parameter is of incorrect type
415
+ PlaybookAlertRetrieveImageError: If the image fetch fails
416
+
417
+ Returns:
418
+ bytes: Bytes of the image
419
+ """
420
+ url = f'{EP_PLAYBOOK_ALERT_DOMAIN_ABUSE}/{alert_id}/image/{image_id}'
421
+
422
+ self.log.info(f'Retrieving image: {image_id} for alert: {alert_id}')
423
+ response = self.rf_client.request('get', url)
424
+
425
+ return response.content
426
+
427
+ @debug_call
428
+ @validate_call
429
+ @connection_exceptions(
430
+ ignore_status_code=[], exception_to_raise=PlaybookAlertRetrieveImageError
431
+ )
432
+ def fetch_images(self, playbook_alert: PBA_DomainAbuse) -> None:
433
+ """Domain Abuse: Retrieve the associated images, if any available.
434
+
435
+ Endpoint:
436
+ ``playbook-alert/domain_abuse/{alert_id}/image/{image_id}``
437
+
438
+ Args:
439
+ playbook_alert (DomainAbuse): Domain Abuse Playbook Alert ADT
440
+
441
+ Raises:
442
+ ValidationError if any parameter is of incorrect type
443
+ PlaybookAlertRetrieveImageError: if an API error occurs
444
+ """
445
+ if isinstance(playbook_alert, PBA_DomainAbuse):
446
+ for image_id in playbook_alert.image_ids:
447
+ image_bytes = self.fetch_one_image(playbook_alert.playbook_alert_id, image_id)
448
+ playbook_alert.store_image(image_id, image_bytes)
449
+ else:
450
+ self.log.debug('Image fetching is only supported for Domain Abuse alerts')
451
+
452
+ @debug_call
453
+ @connection_exceptions(ignore_status_code=[], exception_to_raise=PlaybookAlertFetchError)
454
+ def _fetch_alert_category(self, alert_id: str) -> PACategory:
455
+ """Fetch the alert category based on the alert ID.
456
+
457
+ Endpoints:
458
+ ``playbook-alert/common/{alert_id}``
459
+
460
+ Args:
461
+ alert_id (str): Alert ID
462
+
463
+ Returns:
464
+ RFPACategory: Alert category
465
+ """
466
+ endpoint = EP_PLAYBOOK_ALERT_COMMON + '/' + alert_id
467
+ result = self.rf_client.request('get', endpoint)
468
+ validated_alert_info = PreviewAlertOut.model_validate(result.json()['data'])
469
+
470
+ return PACategory(validated_alert_info.category)
471
+
472
+ @debug_call
473
+ def _playbook_alert_factory(
474
+ self,
475
+ category: str,
476
+ raw_alert: dict,
477
+ ) -> PLAYBOOK_ALERT_TYPE:
478
+ """Return correct playbook alert type from raw alert and category.
479
+
480
+ Args:
481
+ category (string): Alert category
482
+ raw_alert (dict): Raw alert payload
483
+
484
+ Returns:
485
+ Playbook Alert ADT
486
+ """
487
+ p_alert = None
488
+ try:
489
+ try:
490
+ p_alert = CATEGORY_TO_OBJECT_MAP[category].model_validate(raw_alert)
491
+ except KeyError:
492
+ # This way when we consume an unmapped(new) PA, we get a base object to work with
493
+ self.log.warning(
494
+ f'Unmapped playbook alert category: {category}. '
495
+ + 'Will initialize {} as a BasePlaybookAlert'.format(
496
+ raw_alert['playbook_alert_id']
497
+ ),
498
+ )
499
+ p_alert = PBA_Generic.model_validate(raw_alert)
500
+ except pydantic.ValidationError as validation_error:
501
+ self.log.error(
502
+ 'Error validating playbook alert {}'.format(raw_alert['playbook_alert_id'])
503
+ )
504
+ for error in validation_error.errors():
505
+ self.log.error('{} at location: {}'.format(error['msg'], error['loc']))
506
+ raise
507
+
508
+ return p_alert
509
+
510
+ @validate_call
511
+ @connection_exceptions(
512
+ ignore_status_code=[], exception_to_raise=PlaybookAlertRetrieveImageError
513
+ )
514
+ def _do_bulk(
515
+ self, alert_ids: list, category: str, fetch_image: bool, panels: list
516
+ ) -> list[PLAYBOOK_ALERT_TYPE]:
517
+ """Does bulk fetch (used by bulk() after alert IDs have been sorted by category).
518
+
519
+ Args:
520
+ alert_ids (list): List of alert IDs to fetch
521
+ category (str): Category of alert to fetch
522
+ fetch_image (bool): Whether to fetch images for Domain Abuse alerts
523
+ panels (list): List of panels to fetch
524
+
525
+ Raises:
526
+ ValidationError if any supplied parameter is of incorrect type
527
+ PlaybookAlertRetrieveImageError: if connection error occurs
528
+
529
+ Returns:
530
+ list: Playbook alert ADTs. Unknown alert types return PBA_Generic
531
+ """
532
+ category = category.lower()
533
+ if category in CATEGORY_ENDPOINTS:
534
+ endpoint = CATEGORY_ENDPOINTS[category]
535
+ else:
536
+ # Workaround to fetch new PAs that have not been officially supported
537
+ self.log.warning(
538
+ f'Unknown playbook alert category: {category}.'
539
+ 'Using category as an endpoint for this lookup',
540
+ )
541
+ endpoint = f'{EP_PLAYBOOK_ALERT}/{category}'
542
+
543
+ data = {}
544
+ if panels:
545
+ # We must always fetch status panel for ADT initialization
546
+ panels = panels.append(STATUS_PANEL_NAME) if STATUS_PANEL_NAME not in panels else panels
547
+ data = {'panels': panels}
548
+
549
+ self.log.info(f'Fetching {len(alert_ids)} {category} alerts')
550
+
551
+ results = []
552
+ for batch in batched(alert_ids, BULK_LOOKUP_BATCH_SIZE):
553
+ data['playbook_alert_ids'] = batch
554
+ response = self.rf_client.request('post', url=endpoint, data=data)
555
+ results += response.json()['data']
556
+
557
+ p_alerts = [self._playbook_alert_factory(category, raw_alert) for raw_alert in results]
558
+
559
+ if category == PACategory.DOMAIN_ABUSE.value and fetch_image:
560
+ for alert in p_alerts:
561
+ self.fetch_images(alert)
562
+
563
+ return p_alerts
564
+
565
+ def _process_arg(
566
+ self,
567
+ attr: str,
568
+ value: Union[int, str, list],
569
+ ) -> tuple[str, Union[str, list]]:
570
+ """Return attribute and value normalized based on type of value.
571
+
572
+ Args:
573
+ attr (str): Attribute to verify
574
+ value (Union[str, list]): Value of attribute
575
+
576
+ Returns:
577
+ tuple (str, Union[str, list]): canonicalized query attributes
578
+ """
579
+ list_or_str_args = ['entity', 'statuses', 'priority', 'category', 'assignee']
580
+ if attr == 'filter_from':
581
+ return 'from', value
582
+ elif attr in ['created_from', 'created_until', 'updated_from', 'updated_until']:
583
+ range_field = attr.split('_')[0] + '_range'
584
+ query_key = 'from' if attr.endswith('from') else 'until'
585
+ if TimeHelpers.is_rel_time_valid(value):
586
+ return range_field, {query_key: TimeHelpers.rel_time_to_date(value)}
587
+ return range_field, {query_key: value}
588
+ elif attr in list_or_str_args and isinstance(value, str):
589
+ return attr, [value]
590
+ elif attr == 'max_results':
591
+ return 'limit', value
592
+
593
+ return attr, value