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,400 @@
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
+ import re
16
+ from itertools import chain
17
+ from typing import Optional, Union
18
+
19
+ from pydantic import Field, validate_call
20
+
21
+ from ..constants import DEFAULT_LIMIT
22
+ from ..endpoints import (
23
+ EP_ANALYST_NOTE_ATTACHMENT,
24
+ EP_ANALYST_NOTE_DELETE,
25
+ EP_ANALYST_NOTE_LOOKUP,
26
+ EP_ANALYST_NOTE_PREVIEW,
27
+ EP_ANALYST_NOTE_PUBLISH,
28
+ EP_ANALYST_NOTE_SEARCH,
29
+ )
30
+ from ..helpers import debug_call
31
+ from ..helpers.helpers import connection_exceptions
32
+ from ..rf_client import RFClient
33
+ from .constants import NOTES_PER_PAGE
34
+ from .errors import (
35
+ AnalystNoteAttachmentError,
36
+ AnalystNoteDeleteError,
37
+ AnalystNoteLookupError,
38
+ AnalystNotePreviewError,
39
+ AnalystNotePublishError,
40
+ AnalystNoteSearchError,
41
+ )
42
+ from .note import (
43
+ AnalystNote,
44
+ AnalystNotePreviewIn,
45
+ AnalystNotePreviewOut,
46
+ AnalystNotePublishIn,
47
+ AnalystNotePublishOut,
48
+ AnalystNoteSearchIn,
49
+ )
50
+
51
+
52
+ class AnalystNoteMgr:
53
+ """Manages requests for Recorded Future analyst notes."""
54
+
55
+ def __init__(self, rf_token: str = None):
56
+ """Initializes the AnalystNoteMgr object.
57
+
58
+ Args:
59
+ rf_token (str, optional): Recorded Future API token. Defaults to None
60
+ """
61
+ self.log = logging.getLogger(__name__)
62
+ self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient()
63
+
64
+ @debug_call
65
+ @validate_call
66
+ def search(
67
+ self,
68
+ published: Optional[str] = None,
69
+ entity: Optional[str] = None,
70
+ author: Optional[str] = None,
71
+ title: Optional[str] = None,
72
+ topic: Optional[Union[str, list]] = None,
73
+ label: Optional[str] = None,
74
+ source: Optional[str] = None,
75
+ serialization: Optional[str] = None,
76
+ tagged_text: Optional[bool] = None,
77
+ max_results: Optional[int] = Field(ge=1, le=1000, default=DEFAULT_LIMIT),
78
+ notes_per_page: Optional[int] = Field(ge=1, le=1000, default=NOTES_PER_PAGE),
79
+ ) -> list[AnalystNote]:
80
+ """Execute a search for the analyst notes based on the parameters provided. Every parameter
81
+ that has not been set up will be discarded.
82
+ If more than one topic is specified, a search for each topic is executed and the
83
+ AnalystNotes will be deduplicated.
84
+
85
+ ``max_results`` is the maximum number of references, not notes.
86
+
87
+ Endpoint:
88
+ ``/analystnote/search``
89
+
90
+ Args:
91
+ published (str): Notes published after this date. Defaults to -1d.
92
+ entity (str): Notes referring entity, RF ID.
93
+ author (str): Notes by author, RF ID.
94
+ title (str): Notes by title.
95
+ topic (Union[str, list]): Notes by topic, RF ID.
96
+ label (str): Notes by label, by name.
97
+ source (str): source of note.
98
+ tagged_text (bool): Should text contain tags. Defaults to False.
99
+ serialization (str): Entity serializer (id, min, full, raw).
100
+ max_results (int): Maximum number of references (not notes), at most 1000. Default 10.
101
+ notes_per_page (int): Number of notes for each paged request.
102
+
103
+ Raises:
104
+ ValidationError if any supplied parameter is of incorrect type.
105
+ AnalystNoteSearchError: if API error occurs.
106
+
107
+ Returns:
108
+ List[AnalystNote]: List of deduplicated AnalystNote objects.
109
+ """
110
+ responses = []
111
+ topic = None if topic == [] else topic
112
+ data = {
113
+ 'published': published,
114
+ 'entity': entity,
115
+ 'author': author,
116
+ 'title': title,
117
+ 'topic': topic,
118
+ 'label': label,
119
+ 'source': source,
120
+ 'serialization': serialization,
121
+ 'taggedText': tagged_text,
122
+ 'limit': min(max_results, notes_per_page),
123
+ }
124
+ data = {key: val for key, val in data.items() if val is not None}
125
+
126
+ max_results = DEFAULT_LIMIT if max_results is None else max_results
127
+
128
+ responses = []
129
+ if isinstance(topic, list) and len(topic):
130
+ for t in topic:
131
+ data['topic'] = t
132
+ responses.append(self._search(data, max_results))
133
+ return list(set(chain.from_iterable(responses)))
134
+
135
+ return list(set(self._search(data, max_results)))
136
+
137
+ @debug_call
138
+ @validate_call
139
+ @connection_exceptions(
140
+ ignore_status_code=[404],
141
+ exception_to_raise=AnalystNoteLookupError,
142
+ )
143
+ def lookup(
144
+ self, note_id: str, tagged_text: bool = False, serialization: str = 'full'
145
+ ) -> AnalystNote:
146
+ """Lookup an analyst note by ID.
147
+
148
+ Endpoint:
149
+ ``/analystnote/lookup/{note_id}``
150
+
151
+ Args:
152
+ note_id (str): The ID of the analyst note to lookup
153
+ tagged_text (bool): Add RF IDs to note entities, default to False.
154
+ serialization (str): Serialization type of payload. Default to full.
155
+
156
+ Raises:
157
+ ValidationError if any supplied parameter is of incorrect type.
158
+ AnalystNoteLookupError: if API error occurs.
159
+
160
+ Returns:
161
+ AnalystNote: Requested note.
162
+ """
163
+ if not note_id.startswith('doc:'):
164
+ note_id = f'doc:{note_id}'
165
+
166
+ data = {'tagged_text': tagged_text, 'serialization': serialization}
167
+ self.log.info(f'Looking up analyst note: {note_id}')
168
+ response = self.rf_client.request(
169
+ 'post', EP_ANALYST_NOTE_LOOKUP.format(note_id), data=data
170
+ ).json()
171
+ return AnalystNote.model_validate(response)
172
+
173
+ @debug_call
174
+ @validate_call
175
+ @connection_exceptions(
176
+ ignore_status_code=[404], exception_to_raise=AnalystNoteDeleteError, on_ignore_return=False
177
+ )
178
+ def delete(self, note_id: str) -> bool:
179
+ """Delete Analyst Note.
180
+
181
+ Endpoint:
182
+ ``/analystnote/delete/{note_id}``
183
+
184
+ Args:
185
+ note_id (str): The ID of the note to delete
186
+
187
+ Raises:
188
+ ValidationError if any supplied parameter is of incorrect type.
189
+ AnalystNoteDeleteError: if connection error occurs.
190
+
191
+ Returns:
192
+ Union[bool, None]: True if delete ok else False
193
+ """
194
+ if not note_id.startswith('doc:'):
195
+ note_id = f'doc:{note_id}'
196
+
197
+ self.log.info(f'Deleting note {note_id}')
198
+ self.rf_client.request('delete', url=EP_ANALYST_NOTE_DELETE.format(note_id))
199
+ return True
200
+
201
+ @debug_call
202
+ @validate_call
203
+ @connection_exceptions(
204
+ ignore_status_code=[404],
205
+ exception_to_raise=AnalystNotePreviewError,
206
+ )
207
+ def preview(
208
+ self,
209
+ title: str,
210
+ text: str,
211
+ published: Optional[str] = None,
212
+ topic: Union[str, list[str], None] = None,
213
+ context_entities: Optional[list[str]] = None,
214
+ note_entities: Optional[list[str]] = None,
215
+ validation_urls: Optional[list[str]] = None,
216
+ source: Optional[str] = None,
217
+ ) -> AnalystNotePreviewOut:
218
+ """Preview of the AnalystNote. It does not create a note, it just return how the note
219
+ will look like.
220
+
221
+ Endpoint:
222
+ ``/analystnote/preview``
223
+
224
+ Args:
225
+ title (str): title of the note.
226
+ text (str): text of the note.
227
+ published (Optional[str]): date when the note was published.
228
+ topic (Optional[List[str]]): topic of the note.
229
+ context_entities (Optional[List[str]]): context entities of the note.
230
+ note_entities (Optional[List[str]]): note entities of the note.
231
+ source (Optional[List[str]]): source of the note.
232
+ validation_urls (Optional[List[str]]): validation urls of the note.
233
+
234
+ Raises:
235
+ ValidationError if any supplied parameter is of incorrect type.
236
+ AnalystNotePreviewRequest: if connection error occurs.
237
+
238
+ Returns:
239
+ AnalystNotePreviewOut: note that will be created.
240
+ """
241
+ if topic:
242
+ topic = topic if isinstance(topic, list) else [topic]
243
+
244
+ data = {
245
+ 'attributes': {
246
+ 'title': title,
247
+ 'text': text,
248
+ 'published': published,
249
+ 'context_entities': context_entities,
250
+ 'note_entities': note_entities,
251
+ 'validation_urls': validation_urls,
252
+ 'topic': topic,
253
+ },
254
+ 'source': source,
255
+ }
256
+
257
+ note = AnalystNotePreviewIn.model_validate(data)
258
+ self.log.info(f'Previewing note: {note.attributes.title}')
259
+ resp = self.rf_client.request('post', EP_ANALYST_NOTE_PREVIEW, data=note.json()).json()
260
+
261
+ return AnalystNotePreviewOut.model_validate(resp)
262
+
263
+ @debug_call
264
+ @validate_call
265
+ @connection_exceptions(ignore_status_code=[404], exception_to_raise=AnalystNotePublishError)
266
+ def publish(
267
+ self,
268
+ title: str,
269
+ text: str,
270
+ published: Optional[str] = None,
271
+ topic: Union[str, list[str], None] = None,
272
+ context_entities: Optional[list[str]] = None,
273
+ note_entities: Optional[list[str]] = None,
274
+ validation_urls: Optional[list[str]] = None,
275
+ source: Optional[str] = None,
276
+ note_id: Optional[str] = None,
277
+ ) -> AnalystNotePublishOut:
278
+ """Publish of data. This method does create a note and returns the id.
279
+
280
+ Endpoint:
281
+ ``/analystnote/publish``
282
+
283
+ Args:
284
+ title (str): title of the note.
285
+ text (str): text of the note.
286
+ published (Optional[str]): date when the note was published.
287
+ topic (Optional[List[str]]): topic of the note.
288
+ context_entities (Optional[List[str]]): context entities of the note.
289
+ note_entities (Optional[List[str]]): note entities of the note.
290
+ entities (Optional[List[str]]): entities of the note.
291
+ validation_urls (Optional[List[str]]): validation urls of the note.
292
+ note_id (Optional[str]): id of the note, use if you want to modify an existing note.
293
+ source (Optional[str]): source of the note.
294
+
295
+ Raises:
296
+ ValidationError if any supplied parameter is of incorrect type.
297
+ AnalystNotePublishError: if connection error occurs.
298
+
299
+ Returns:
300
+ AnalystNotePublishOut: published note
301
+ """
302
+ if topic:
303
+ topic = topic if isinstance(topic, list) else [topic]
304
+
305
+ data = {
306
+ 'attributes': {
307
+ 'title': title,
308
+ 'text': text,
309
+ 'published': published,
310
+ 'context_entities': context_entities,
311
+ 'note_entities': note_entities,
312
+ 'validation_urls': validation_urls,
313
+ 'topic': topic,
314
+ },
315
+ 'source': source,
316
+ 'note_id': note_id,
317
+ }
318
+ note = AnalystNotePublishIn.model_validate(data)
319
+ self.log.info(f'Publishing note: {note.attributes.title}')
320
+ resp = self.rf_client.request('post', EP_ANALYST_NOTE_PUBLISH, data=note.json()).json()
321
+ return AnalystNotePublishOut.model_validate(resp)
322
+
323
+ @debug_call
324
+ @validate_call
325
+ @connection_exceptions(
326
+ ignore_status_code=[404],
327
+ exception_to_raise=AnalystNoteAttachmentError,
328
+ on_ignore_return=(b'', None),
329
+ )
330
+ def fetch_attachment(self, note_id: str) -> tuple[bytes, str]:
331
+ """Get Analyst Note Attachment. To work with the attachment is the same no matter the ext.
332
+
333
+
334
+ Example:
335
+ Fetch and save an attachment from an Analyst Note:
336
+
337
+ .. code-block:: python
338
+ :linenos:
339
+
340
+ from psengine.analyst_notes import save_attachment
341
+
342
+ # note with pdf attachment
343
+ attachment, extension = note_mgr.fetch_attachment('tPtLVw')
344
+ save_attachment('tPtLVw', attachment, extension)
345
+
346
+ # note with yar attachment
347
+ attachment, extension = note_mgr.fetch_attachment('oJeqDP')
348
+ save_attachment('oJeqDP', attachment, extension)
349
+
350
+
351
+ Endpoint:
352
+ ``/analystnote/attachment/{note_id}``
353
+
354
+
355
+ Args:
356
+ note_id (str): id of the note
357
+
358
+ Raises:
359
+ ValidationError if any supplied parameter is of incorrect type.
360
+ AnalystNoteAttachmentError: if connection error occurs.
361
+
362
+ Returns:
363
+ Tuple[bytes, str]: content of the attachment in bytes and extension of the file
364
+
365
+ """
366
+ if not note_id.startswith('doc:'):
367
+ note_id = f'doc:{note_id}'
368
+
369
+ self.log.info(f"Looking up analyst note's {note_id} attachment")
370
+ response = self.rf_client.request('get', EP_ANALYST_NOTE_ATTACHMENT.format(note_id))
371
+
372
+ content_disp = response.headers.get('Content-Disposition')
373
+ ext = re.findall(r'filename=.*\.(\w+)', content_disp)
374
+
375
+ ext = ext[-1] if ext else ''
376
+
377
+ return response.content, ext
378
+
379
+ @connection_exceptions(ignore_status_code=[404], exception_to_raise=AnalystNoteSearchError)
380
+ def _search(self, data: dict, max_results: int) -> list[AnalystNote]:
381
+ """Search for Analayst notes.
382
+
383
+ Raises:
384
+ AnalystNoteSearchError: if connection error occurs.
385
+
386
+ Return:
387
+ dict: json data for 'data.results' for the analayst notes found by the search.
388
+ """
389
+ self.log.info(f'Searching analyst notes with query: {data}')
390
+ search_data = AnalystNoteSearchIn.model_validate(data)
391
+ response = self.rf_client.request_paged(
392
+ method='post',
393
+ url=EP_ANALYST_NOTE_SEARCH,
394
+ data=search_data.json(),
395
+ offset_key='from',
396
+ results_path='data',
397
+ max_results=max_results,
398
+ )
399
+
400
+ return [AnalystNote.model_validate(d) for d in response]
@@ -0,0 +1,285 @@
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 json
15
+ import logging
16
+ from typing import Union
17
+
18
+ from pydantic import validate_call
19
+ from requests import ConnectionError, ConnectTimeout, HTTPError, ReadTimeout, Session, adapters
20
+ from requests.adapters import HTTPAdapter, Retry
21
+ from requests.exceptions import JSONDecodeError
22
+ from requests.models import Response
23
+
24
+ from ._sdk_id import SDK_ID
25
+ from .config import get_config
26
+ from .constants import (
27
+ BACKOFF_FACTOR,
28
+ POOL_MAX_SIZE,
29
+ REQUEST_TIMEOUT,
30
+ RETRY_TOTAL,
31
+ SSL_VERIFY,
32
+ STATUS_FORCELIST,
33
+ )
34
+ from .endpoints import BASE_URL
35
+ from .helpers import OSHelpers, debug_call
36
+
37
+
38
+ class BaseHTTPClient:
39
+ """Generic HTTP client for making requests (requests wrapper)."""
40
+
41
+ def __init__(
42
+ self,
43
+ http_proxy: str = None,
44
+ https_proxy: str = None,
45
+ verify: Union[str, bool] = SSL_VERIFY,
46
+ auth: tuple[str, str] = None,
47
+ cert: Union[str, tuple[str, str], None] = None,
48
+ timeout=REQUEST_TIMEOUT,
49
+ retries=RETRY_TOTAL,
50
+ backoff_factor=BACKOFF_FACTOR,
51
+ status_forcelist=STATUS_FORCELIST,
52
+ pool_max_size=POOL_MAX_SIZE,
53
+ ):
54
+ """Generic HTTP client for making requests (``requests`` wrapper).
55
+
56
+ Args:
57
+ http_proxy (str, optional): HTTP Proxy URL. Defaults to None.
58
+ https_proxy (str, optional): HTTPS Proxy URL. Defaults to None.
59
+ verify (Union[str, bool], optional): SSL verification flag or path to CA bundle.
60
+ Defaults to True.
61
+ auth (Tuple[str, str], optional): Basic Auth credentials. Defaults to None.
62
+ cert (Union[str, Tuple[str, str], None], optional): Client certificates.
63
+ Defaults to None.
64
+ timeout (int, optional): Request timeout. Defaults to 120.
65
+ retries (int, optional): Number of retries. Defaults to 5.
66
+ backoff_factor (int, optional): Backoff factor. Defaults to 1.
67
+ status_forcelist (int, optional): List of status codes to force a retry.
68
+ Defaults to [502, 503, 504].
69
+ pool_max_size (int, optional): Maximum number of connections in the pool.
70
+ Defaults to 120.
71
+ """
72
+ self.log = logging.getLogger(__name__)
73
+ self.config = get_config()
74
+ self.http_proxy = http_proxy if http_proxy is not None else self.config.http_proxy
75
+ self.https_proxy = https_proxy if https_proxy is not None else self.config.https_proxy
76
+ self.proxies = self._set_proxies()
77
+ self.verify = verify if verify is not None else self.config.client_ssl_verify
78
+ self.timeout = timeout if timeout is not None else self.config.client_timeout
79
+ self.retries = retries if retries is not None else self.config.client_retries
80
+ self.backoff_factor = (
81
+ backoff_factor if backoff_factor is not None else self.config.client_backoff_factor
82
+ )
83
+ self.status_forcelist = (
84
+ status_forcelist
85
+ if status_forcelist is not None
86
+ else self.config.client_status_forcelist
87
+ )
88
+ self.pool_max_size = (
89
+ pool_max_size if pool_max_size is not None else self.config.client_pool_max_size
90
+ )
91
+ self.session = self._create_session()
92
+ self.session.cert = cert if cert is not None else self.config.client_cert
93
+ self.session.auth = auth if auth is not None else self.config.client_basic_auth
94
+
95
+ self._set_retry_config()
96
+
97
+ @debug_call
98
+ @validate_call
99
+ def call(
100
+ self,
101
+ method: str,
102
+ url: str,
103
+ data: Union[dict, list[dict], None] = None,
104
+ *,
105
+ params: Union[dict, None] = None,
106
+ headers: Union[dict, None] = None,
107
+ **kwargs,
108
+ ) -> Response:
109
+ """Invokes a HTTP request using the ``requests`` library.
110
+
111
+ Args:
112
+ method (str): HTTP Method, one of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH
113
+ url (str): URL to make the request to
114
+ headers (dict, optional): If specified it will override default headers and wont
115
+ set the token. Defaults to None.
116
+ data (dict, optional): Body. Defaults to None.
117
+ params (dict, optional): HTTP query parameters. Defaults to None.
118
+ **kwargs: Additional keyword arguments, passed to the requests library
119
+
120
+ Raises:
121
+ ValueError: if method is neither of GET, PUT, POST, DELETE, HEAD, OPTIONS, PATCH
122
+ HTTPError: if requests returns a non 2xx status.
123
+ JSONDecodeError: if requests returns malformed data.
124
+ ConnectTimeout: if requests times out while trying to connect to the server.
125
+ ConnectionError: if requests fails before terminating.
126
+ ReadTimeout: if the server didnt send any data on time.
127
+
128
+ Returns:
129
+ requests.Response: requests.Response object
130
+ """
131
+ method_func = self._choose_method_type(method)
132
+
133
+ if not headers:
134
+ headers = {}
135
+
136
+ if 'User-Agent' not in headers:
137
+ headers['User-Agent'] = self._get_user_agent_header()
138
+
139
+ data = json.dumps(data) if data is not None else None
140
+
141
+ try:
142
+ response = method_func(
143
+ url=url,
144
+ headers=headers,
145
+ data=data,
146
+ params=params,
147
+ verify=self.verify,
148
+ timeout=self.timeout,
149
+ **kwargs,
150
+ )
151
+ self.log.debug(f'HTTP Status Code: {response.status_code}')
152
+
153
+ except (ConnectionError, ConnectTimeout, ReadTimeout) as err:
154
+ self.log.debug(f'GET request failed. Cause: {err}')
155
+ raise
156
+
157
+ try:
158
+ response.raise_for_status()
159
+
160
+ except HTTPError as err:
161
+ msg = str(err)
162
+ try:
163
+ data = response.json()
164
+ except JSONDecodeError:
165
+ data = {}
166
+
167
+ message = data.get('message') or data.get('error', {}).get('message')
168
+ if message:
169
+ msg += f', Cause: {message}'
170
+
171
+ self.log.debug(f'{method} request failed. {msg}')
172
+
173
+ raise HTTPError(msg, response=response) from err
174
+
175
+ return response
176
+
177
+ @debug_call
178
+ @validate_call
179
+ def can_connect(self, method: str = 'get', url: str = BASE_URL) -> bool:
180
+ """Check if the client can reach the specified API URL.
181
+
182
+ Args:
183
+ method (str, optional): HTTP Method, one of GET, PUT, POST, DELETE,
184
+ HEAD, OPTIONS, PATCH. Default: GET
185
+ url (str, optional): url to test. Default: api.recordedfuture.com
186
+
187
+ Returns:
188
+ bool: True if connection is 200 else False
189
+ """
190
+ try:
191
+ request = self.call(method=method, url=url)
192
+ request.raise_for_status()
193
+ return True
194
+ except Exception as err: # noqa: BLE001
195
+ self.log.error(f'Error during connectivity test: {err}')
196
+ return False
197
+
198
+ @debug_call
199
+ @validate_call
200
+ def set_urllib_log_level(self, level: str) -> None:
201
+ """Set log level for urllib3 library.
202
+
203
+ Args:
204
+ level (str): log level to be set: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET
205
+ """
206
+ if not level or level.upper() not in (
207
+ 'CRITICAL',
208
+ 'ERROR',
209
+ 'WARNING',
210
+ 'INFO',
211
+ 'DEBUG',
212
+ 'NOTSET',
213
+ ):
214
+ self.log.warning('Log level is empty or not valid')
215
+ return
216
+ logging.getLogger('urllib3').setLevel(level.upper())
217
+
218
+ def _set_proxies(self):
219
+ """Set the proxy configuration.
220
+
221
+ Returns:
222
+ dict: Proxy configuration
223
+ """
224
+ proxies = {}
225
+ if self.http_proxy:
226
+ proxies['http'] = self.http_proxy
227
+ if self.https_proxy:
228
+ proxies['https'] = self.https_proxy
229
+ return proxies
230
+
231
+ def _create_session(self):
232
+ """Create a base HTTP client session.
233
+
234
+ Returns:
235
+ session: requests.Session object
236
+ """
237
+ self.log.debug('Creating an HTTP client session')
238
+ session = Session()
239
+ adapter = adapters.HTTPAdapter(pool_maxsize=self.pool_max_size)
240
+ session.mount('https://', adapter)
241
+
242
+ if len(self.proxies) > 0:
243
+ session.proxies.update(self.proxies)
244
+
245
+ return session
246
+
247
+ def _set_retry_config(self):
248
+ """Set the retry configuration for the session."""
249
+ retries = Retry(
250
+ total=self.retries,
251
+ backoff_factor=self.backoff_factor,
252
+ status_forcelist=self.status_forcelist,
253
+ )
254
+ for prefix in 'http://', 'https://':
255
+ self.session.mount(prefix, HTTPAdapter(max_retries=retries))
256
+
257
+ def _choose_method_type(self, method: str):
258
+ method_func = {
259
+ 'GET': self.session.get,
260
+ 'PUT': self.session.put,
261
+ 'POST': self.session.post,
262
+ 'DELETE': self.session.delete,
263
+ 'HEAD': self.session.head,
264
+ 'OPTIONS': self.session.options,
265
+ 'PATCH': self.session.patch,
266
+ }.get(method.upper())
267
+
268
+ if not method_func:
269
+ raise ValueError(f'Unknown HTTP method: {method}')
270
+
271
+ return method_func
272
+
273
+ def _get_user_agent_header(self):
274
+ os_info = OSHelpers.os_platform()
275
+ app_id = self.config.app_id or 'app_id unknown'
276
+ platform_id = self.config.platform_id or 'platform_id unknown'
277
+ user_agent_list = []
278
+
279
+ user_agent_list.append(app_id)
280
+ if os_info is not None:
281
+ user_agent_list.append(f'({os_info})')
282
+ user_agent_list.append(SDK_ID)
283
+ user_agent_list.append(platform_id)
284
+
285
+ return ' '.join(user_agent_list)