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.
- psengine/__init__.py +22 -0
- psengine/_sdk_id.py +16 -0
- psengine/_version.py +14 -0
- psengine/analyst_notes/__init__.py +32 -0
- psengine/analyst_notes/constants.py +15 -0
- psengine/analyst_notes/errors.py +42 -0
- psengine/analyst_notes/helpers.py +90 -0
- psengine/analyst_notes/models.py +219 -0
- psengine/analyst_notes/note.py +149 -0
- psengine/analyst_notes/note_mgr.py +400 -0
- psengine/base_http_client.py +285 -0
- psengine/classic_alerts/__init__.py +24 -0
- psengine/classic_alerts/classic_alert.py +275 -0
- psengine/classic_alerts/classic_alert_mgr.py +507 -0
- psengine/classic_alerts/constants.py +31 -0
- psengine/classic_alerts/errors.py +38 -0
- psengine/classic_alerts/helpers.py +87 -0
- psengine/classic_alerts/markdown/__init__.py +13 -0
- psengine/classic_alerts/markdown/markdown.py +359 -0
- psengine/classic_alerts/models.py +141 -0
- psengine/collective_insights/__init__.py +29 -0
- psengine/collective_insights/collective_insights.py +164 -0
- psengine/collective_insights/constants.py +44 -0
- psengine/collective_insights/errors.py +18 -0
- psengine/collective_insights/insight.py +89 -0
- psengine/collective_insights/models.py +81 -0
- psengine/common_models.py +89 -0
- psengine/config/__init__.py +15 -0
- psengine/config/config.py +284 -0
- psengine/config/errors.py +18 -0
- psengine/constants.py +63 -0
- psengine/detection/__init__.py +17 -0
- psengine/detection/detection_mgr.py +135 -0
- psengine/detection/detection_rule.py +85 -0
- psengine/detection/errors.py +26 -0
- psengine/detection/helpers.py +56 -0
- psengine/detection/models.py +47 -0
- psengine/endpoints.py +98 -0
- psengine/enrich/__init__.py +28 -0
- psengine/enrich/constants.py +73 -0
- psengine/enrich/errors.py +26 -0
- psengine/enrich/lookup.py +299 -0
- psengine/enrich/lookup_mgr.py +341 -0
- psengine/enrich/models/__init__.py +13 -0
- psengine/enrich/models/base_enriched_entity.py +43 -0
- psengine/enrich/models/lookup.py +271 -0
- psengine/enrich/models/soar.py +138 -0
- psengine/enrich/soar.py +89 -0
- psengine/enrich/soar_mgr.py +176 -0
- psengine/entity_lists/__init__.py +16 -0
- psengine/entity_lists/constants.py +19 -0
- psengine/entity_lists/entity_list.py +435 -0
- psengine/entity_lists/entity_list_mgr.py +185 -0
- psengine/entity_lists/errors.py +26 -0
- psengine/entity_lists/models.py +87 -0
- psengine/entity_match/__init__.py +16 -0
- psengine/entity_match/entity_match.py +90 -0
- psengine/entity_match/entity_match_mgr.py +235 -0
- psengine/entity_match/errors.py +18 -0
- psengine/entity_match/models.py +22 -0
- psengine/errors.py +41 -0
- psengine/helpers/__init__.py +23 -0
- psengine/helpers/helpers.py +471 -0
- psengine/logger/__init__.py +15 -0
- psengine/logger/constants.py +39 -0
- psengine/logger/errors.py +18 -0
- psengine/logger/rf_logger.py +148 -0
- psengine/markdown/__init__.py +21 -0
- psengine/markdown/markdown.py +169 -0
- psengine/markdown/models.py +22 -0
- psengine/playbook_alerts/__init__.py +34 -0
- psengine/playbook_alerts/constants.py +35 -0
- psengine/playbook_alerts/errors.py +35 -0
- psengine/playbook_alerts/helpers.py +80 -0
- psengine/playbook_alerts/mappings.py +44 -0
- psengine/playbook_alerts/markdown/__init__.py +13 -0
- psengine/playbook_alerts/markdown/markdown.py +98 -0
- psengine/playbook_alerts/markdown/markdown_code_repo.py +64 -0
- psengine/playbook_alerts/markdown/markdown_domain_abuse.py +118 -0
- psengine/playbook_alerts/markdown/markdown_identity_exposure.py +158 -0
- psengine/playbook_alerts/models/__init__.py +36 -0
- psengine/playbook_alerts/models/common_models.py +18 -0
- psengine/playbook_alerts/models/panel_log.py +329 -0
- psengine/playbook_alerts/models/panel_status.py +70 -0
- psengine/playbook_alerts/models/pba_code_repo_leak.py +52 -0
- psengine/playbook_alerts/models/pba_cyber_vulnerability.py +53 -0
- psengine/playbook_alerts/models/pba_domain_abuse.py +139 -0
- psengine/playbook_alerts/models/pba_identity_exposures.py +93 -0
- psengine/playbook_alerts/models/pba_third_party_risk.py +103 -0
- psengine/playbook_alerts/models/search_endpoint.py +68 -0
- psengine/playbook_alerts/pa_category.py +37 -0
- psengine/playbook_alerts/playbook_alert_mgr.py +593 -0
- psengine/playbook_alerts/playbook_alerts.py +393 -0
- psengine/rf_client.py +430 -0
- psengine/risklists/__init__.py +17 -0
- psengine/risklists/constants.py +15 -0
- psengine/risklists/errors.py +20 -0
- psengine/risklists/models.py +65 -0
- psengine/risklists/risklist_mgr.py +156 -0
- psengine/stix2/__init__.py +21 -0
- psengine/stix2/base_stix_entity.py +62 -0
- psengine/stix2/complex_entity.py +372 -0
- psengine/stix2/constants.py +81 -0
- psengine/stix2/enriched_indicator.py +261 -0
- psengine/stix2/errors.py +22 -0
- psengine/stix2/helpers.py +68 -0
- psengine/stix2/rf_bundle.py +240 -0
- psengine/stix2/simple_entity.py +145 -0
- psengine/stix2/util.py +53 -0
- psengine-2.0.4.dist-info/METADATA +189 -0
- psengine-2.0.4.dist-info/RECORD +115 -0
- psengine-2.0.4.dist-info/WHEEL +5 -0
- psengine-2.0.4.dist-info/entry_points.txt +2 -0
- psengine-2.0.4.dist-info/licenses/LICENSE +21 -0
- 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)
|