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,19 @@
|
|
|
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
|
+
IS_READY_INCREMENT = 5
|
|
15
|
+
|
|
16
|
+
ADD_OP = 'add'
|
|
17
|
+
REMOVE_OP = 'remove'
|
|
18
|
+
UNCHANGED_NAME = 'unchanged'
|
|
19
|
+
ERROR_NAME = 'error'
|
|
@@ -0,0 +1,435 @@
|
|
|
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 time
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from functools import total_ordering
|
|
18
|
+
from typing import Optional, Union
|
|
19
|
+
|
|
20
|
+
from pydantic import ConfigDict, Field, validate_call
|
|
21
|
+
|
|
22
|
+
from ..common_models import IdNameType, RFBaseModel
|
|
23
|
+
from ..constants import TIMESTAMP_STR
|
|
24
|
+
from ..endpoints import EP_LIST
|
|
25
|
+
from ..entity_match import EntityMatchMgr, MatchApiError
|
|
26
|
+
from ..helpers import debug_call
|
|
27
|
+
from ..helpers.helpers import connection_exceptions
|
|
28
|
+
from ..rf_client import RFClient
|
|
29
|
+
from .constants import ADD_OP, ERROR_NAME, IS_READY_INCREMENT, REMOVE_OP, UNCHANGED_NAME
|
|
30
|
+
from .errors import ListApiError
|
|
31
|
+
from .models import (
|
|
32
|
+
AddEntityRequestModel,
|
|
33
|
+
ListEntityOperationResponse,
|
|
34
|
+
OwnerOrganisationDetails,
|
|
35
|
+
RemoveEntityRequestModel,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ListInfoOut(RFBaseModel):
|
|
40
|
+
"""Validate data received from ``/{listId}/info`` endpoint."""
|
|
41
|
+
|
|
42
|
+
id_: str = Field(alias='id')
|
|
43
|
+
name: str
|
|
44
|
+
type_: str = Field(alias='type')
|
|
45
|
+
created: datetime
|
|
46
|
+
updated: datetime
|
|
47
|
+
owner_organisation_details: OwnerOrganisationDetails = Field(
|
|
48
|
+
default_factory=OwnerOrganisationDetails
|
|
49
|
+
)
|
|
50
|
+
owner_id: str
|
|
51
|
+
owner_name: str
|
|
52
|
+
organisation_id: str
|
|
53
|
+
organisation_name: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ListStatusOut(RFBaseModel):
|
|
57
|
+
"""Validate data received from ``/{listId}/status`` endpoint."""
|
|
58
|
+
|
|
59
|
+
size: int
|
|
60
|
+
status: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@total_ordering
|
|
64
|
+
class ListEntity(RFBaseModel):
|
|
65
|
+
"""Validate data received from ``/{listId}/entities`` endpoint."""
|
|
66
|
+
|
|
67
|
+
entity: IdNameType
|
|
68
|
+
context: Optional[dict] = None
|
|
69
|
+
status: str
|
|
70
|
+
added: datetime
|
|
71
|
+
|
|
72
|
+
def __hash__(self):
|
|
73
|
+
return hash(self.entity.id_)
|
|
74
|
+
|
|
75
|
+
def __eq__(self, other: 'ListEntity'):
|
|
76
|
+
return self.entity.id_ == other.entity.id_
|
|
77
|
+
|
|
78
|
+
def __gt__(self, other: 'ListEntity'):
|
|
79
|
+
return (self.entity.name, self.added) > (other.entity.name, other.added)
|
|
80
|
+
|
|
81
|
+
def __str__(self):
|
|
82
|
+
return (
|
|
83
|
+
f'{self.entity.type_}: {self.entity.name}, added {self.added.strftime(TIMESTAMP_STR)}'
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class EntityList(RFBaseModel):
|
|
88
|
+
"""Validate data received from ``/create`` endpoint."""
|
|
89
|
+
|
|
90
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
91
|
+
rf_client: RFClient = Field(exclude=True)
|
|
92
|
+
match_mgr: EntityMatchMgr = Field(exclude=True)
|
|
93
|
+
log: logging.Logger = Field(exclude=True, default=logging.getLogger(__name__))
|
|
94
|
+
id_: str = Field(alias='id')
|
|
95
|
+
name: str
|
|
96
|
+
type_: str = Field(alias='type')
|
|
97
|
+
created: datetime
|
|
98
|
+
updated: datetime
|
|
99
|
+
owner_id: str
|
|
100
|
+
owner_name: str
|
|
101
|
+
organisation_id: Optional[str] = None
|
|
102
|
+
organisation_name: Optional[str] = None
|
|
103
|
+
owner_organisation_details: OwnerOrganisationDetails = Field(
|
|
104
|
+
default_factory=OwnerOrganisationDetails
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def __hash__(self):
|
|
108
|
+
return hash(self.id_)
|
|
109
|
+
|
|
110
|
+
def __eq__(self, other: 'EntityList'):
|
|
111
|
+
return self.id_ == other.id_
|
|
112
|
+
|
|
113
|
+
def __str__(self):
|
|
114
|
+
"""String representation of the list.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
str: list data with standard info + entities
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def format_date(date):
|
|
121
|
+
return date.strftime(TIMESTAMP_STR)
|
|
122
|
+
|
|
123
|
+
def format_field(name, value):
|
|
124
|
+
return f"{name}: {value or 'None'}"
|
|
125
|
+
|
|
126
|
+
main_fields = [
|
|
127
|
+
format_field('id', self.id_),
|
|
128
|
+
format_field('name', self.name),
|
|
129
|
+
format_field('type', self.type_),
|
|
130
|
+
format_field('created', format_date(self.created)),
|
|
131
|
+
format_field('last updated', format_date(self.updated)),
|
|
132
|
+
format_field('owner id', self.owner_id),
|
|
133
|
+
format_field('owner name', self.owner_name),
|
|
134
|
+
format_field('organisation id', self.organisation_id),
|
|
135
|
+
format_field('organisation name', self.organisation_name),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
org_details = self.owner_organisation_details
|
|
139
|
+
org_fields = [
|
|
140
|
+
format_field('owner id', org_details.owner_id),
|
|
141
|
+
format_field('owner name', org_details.owner_name),
|
|
142
|
+
format_field('enterprise id', org_details.enterprise_id),
|
|
143
|
+
format_field('enterprise name', org_details.enterprise_name),
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
sub_orgs = org_details.organisations
|
|
147
|
+
if sub_orgs:
|
|
148
|
+
sub_org_str = '\n '.join(
|
|
149
|
+
f'organisation id: {org.organisation_id}\n'
|
|
150
|
+
f' organisation name: {org.organisation_name}'
|
|
151
|
+
for org in sub_orgs
|
|
152
|
+
)
|
|
153
|
+
org_fields.append(f'sub-organisations:\n {sub_org_str}')
|
|
154
|
+
else:
|
|
155
|
+
org_fields.append('sub-organisations: None')
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
'\n'.join(main_fields) + '\nowner organisation details:\n ' + '\n '.join(org_fields)
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
@debug_call
|
|
162
|
+
@validate_call
|
|
163
|
+
def add(
|
|
164
|
+
self, entity: Union[str, tuple[str, str]], context: dict = None
|
|
165
|
+
) -> ListEntityOperationResponse:
|
|
166
|
+
"""Add entity to list.
|
|
167
|
+
|
|
168
|
+
Endpoint:
|
|
169
|
+
``list/{id}/entity/add``
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
entity (str, tuple): ID or (name, type) tuple of entity to add
|
|
173
|
+
context (dict, optional): context object for entity. Defaults to None
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ListApiError: if connection error occurs.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
ListEntityOperationResponse: list/{id}/entity/add response
|
|
180
|
+
"""
|
|
181
|
+
return self._list_op(entity, ADD_OP, context=context or {})
|
|
182
|
+
|
|
183
|
+
@debug_call
|
|
184
|
+
@validate_call
|
|
185
|
+
def remove(self, entity: Union[str, tuple[str, str]]) -> ListEntityOperationResponse:
|
|
186
|
+
"""Remove entity from list.
|
|
187
|
+
|
|
188
|
+
Endpoint:
|
|
189
|
+
``list/{id}/entity/remove``
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
entity (str, tuple): ID or (name, type) tuple of entity to remove
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ListApiError: if connection error occurs.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
ListEntityOperationResponse: list/{id}/entity/remove response
|
|
199
|
+
"""
|
|
200
|
+
return self._list_op(entity, REMOVE_OP)
|
|
201
|
+
|
|
202
|
+
@debug_call
|
|
203
|
+
@validate_call
|
|
204
|
+
def bulk_add(self, entities: list[Union[str, tuple[str, str]]]) -> dict:
|
|
205
|
+
"""Bulk add entities to list.
|
|
206
|
+
|
|
207
|
+
Adds entities 1 at a time due to List API requirement. Logs progress every 10%.
|
|
208
|
+
|
|
209
|
+
Endpoint:
|
|
210
|
+
``list/{id}/entity/add``
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
entities (list[Union[str, tuple[str, str]]]): list of entity string IDs or
|
|
214
|
+
entity (name, type) tuples to add
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ValidationError if any supplied parameter is of incorrect type.
|
|
218
|
+
ValueError: if wrong operation is supplied
|
|
219
|
+
ListApiError: if connection error occurs
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
dict: results JSON with added, unchanged, error keys containing lists of entities
|
|
223
|
+
"""
|
|
224
|
+
result = self._bulk_op(entities, ADD_OP)
|
|
225
|
+
status = self.status()
|
|
226
|
+
while status.status != 'ready':
|
|
227
|
+
self.log.info(f"Awaiting list 'ready' status, current status '{status.status}'")
|
|
228
|
+
status = self.status()
|
|
229
|
+
time.sleep(IS_READY_INCREMENT)
|
|
230
|
+
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
@debug_call
|
|
234
|
+
@validate_call
|
|
235
|
+
def bulk_remove(self, entities: list[Union[str, tuple[str, str]]]) -> dict:
|
|
236
|
+
"""Bulk remove entities from list.
|
|
237
|
+
|
|
238
|
+
Removes entities 1 at a time due to List API requirement. Logs progress every 10%.
|
|
239
|
+
|
|
240
|
+
Endpoint:
|
|
241
|
+
``list/{id}/entity/remove``
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
entities (list): list of entity string IDs or entity (name, type) tuples to remove
|
|
245
|
+
|
|
246
|
+
Raises:
|
|
247
|
+
ValidationError if any supplied parameter is of incorrect type.
|
|
248
|
+
ValueError: if wrong operation is supplied
|
|
249
|
+
ListApiError: if connection error occurs
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
dict: results JSON with removed, unchanged, error keys containing lists of entities
|
|
253
|
+
"""
|
|
254
|
+
result = self._bulk_op(entities, REMOVE_OP)
|
|
255
|
+
status = self.status()
|
|
256
|
+
while status.status != 'ready':
|
|
257
|
+
self.log.info(f"Awaiting list 'ready' status, current status '{status.status}'")
|
|
258
|
+
status = self.status()
|
|
259
|
+
time.sleep(IS_READY_INCREMENT)
|
|
260
|
+
|
|
261
|
+
return result
|
|
262
|
+
|
|
263
|
+
@debug_call
|
|
264
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
265
|
+
def entities(self) -> list[ListEntity]:
|
|
266
|
+
"""Get entities for list.
|
|
267
|
+
|
|
268
|
+
Endpoint:
|
|
269
|
+
``list/{id}/entities``
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
ListApiError: if connection error occurs.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
list[ListEntity]: list/{id}/entities JSON response
|
|
276
|
+
"""
|
|
277
|
+
url = EP_LIST + '/' + self.id_ + '/entities'
|
|
278
|
+
response = self.rf_client.request('get', url)
|
|
279
|
+
return [ListEntity.model_validate(entity) for entity in response.json()]
|
|
280
|
+
|
|
281
|
+
@debug_call
|
|
282
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
283
|
+
def text_entries(self) -> list[str]:
|
|
284
|
+
"""Get text entries for list.
|
|
285
|
+
|
|
286
|
+
Endpoint:
|
|
287
|
+
``list/{id}/textEntries``
|
|
288
|
+
|
|
289
|
+
Raises:
|
|
290
|
+
ListApiError: if connection error occurs.
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
list[str]: list/{id}/textEntries JSON response
|
|
294
|
+
"""
|
|
295
|
+
url = EP_LIST + '/' + self.id_ + '/textEntries'
|
|
296
|
+
return self.rf_client.request('get', url).json()
|
|
297
|
+
|
|
298
|
+
@debug_call
|
|
299
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
300
|
+
def status(self) -> ListStatusOut:
|
|
301
|
+
"""Get status information about list.
|
|
302
|
+
|
|
303
|
+
Endpoint:
|
|
304
|
+
``list/{id}/status``
|
|
305
|
+
|
|
306
|
+
Raises:
|
|
307
|
+
ListApiError: if connection error occurs
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
ListStatusOut: list/{id}/status response
|
|
311
|
+
"""
|
|
312
|
+
self.log.debug(f"Getting list status for '{self.name}'")
|
|
313
|
+
url = EP_LIST + f'/{self.id_}/status'
|
|
314
|
+
response = self.rf_client.request('get', url)
|
|
315
|
+
validated_status = ListStatusOut.model_validate(response.json())
|
|
316
|
+
self.log.debug(
|
|
317
|
+
f"List '{self.name}' status: {validated_status.status}, entities: {validated_status.size}" # noqa: E501
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return validated_status
|
|
321
|
+
|
|
322
|
+
@debug_call
|
|
323
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
324
|
+
def info(self) -> ListInfoOut:
|
|
325
|
+
"""Get info for list.
|
|
326
|
+
|
|
327
|
+
Endpoint:
|
|
328
|
+
``list/{id}/info``
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
ListApiError: if connection error occurs
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
ListInfoOut: list/{id}/info response
|
|
335
|
+
"""
|
|
336
|
+
self.log.debug(f"Getting list status for '{self.name}'")
|
|
337
|
+
url = EP_LIST + f'/{self.id_}/info'
|
|
338
|
+
response = self.rf_client.request('get', url)
|
|
339
|
+
validated_info = ListInfoOut.model_validate(response.json())
|
|
340
|
+
|
|
341
|
+
return validated_info
|
|
342
|
+
|
|
343
|
+
@debug_call
|
|
344
|
+
def _bulk_op(self, entities: list[Union[str, tuple[str, str]]], operation: str) -> dict:
|
|
345
|
+
"""Bulk add or remove entities from list.
|
|
346
|
+
|
|
347
|
+
List API requires that entities are added one at a time. Logs progress every 10%
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
entities (list): list of entity string IDs or (name, type) tuples to add
|
|
351
|
+
operation (str): the operation to perform on the list. Can be 'added' or 'removed'.
|
|
352
|
+
|
|
353
|
+
Raises:
|
|
354
|
+
ValueError: if wrong operation is supplied
|
|
355
|
+
ListApiError: if connection error occurs
|
|
356
|
+
|
|
357
|
+
Returns:
|
|
358
|
+
dict: results JSON with added, unchanged, error keys containing lists of entities added
|
|
359
|
+
"""
|
|
360
|
+
if operation == ADD_OP:
|
|
361
|
+
op_func = self.add
|
|
362
|
+
op_name = 'added'
|
|
363
|
+
elif operation == REMOVE_OP:
|
|
364
|
+
op_func = self.remove
|
|
365
|
+
op_name = 'removed'
|
|
366
|
+
else:
|
|
367
|
+
raise ValueError(f"Operation must be either '{ADD_OP}' or '{REMOVE_OP}'")
|
|
368
|
+
result = {op_name: [], UNCHANGED_NAME: [], ERROR_NAME: []}
|
|
369
|
+
total = len(entities)
|
|
370
|
+
step = 10
|
|
371
|
+
for idx, entity in enumerate(entities):
|
|
372
|
+
try:
|
|
373
|
+
if isinstance(entity, str):
|
|
374
|
+
entity_id = entity
|
|
375
|
+
else: # entity is tuple
|
|
376
|
+
entity_id = self.match_mgr.resolve_entity_id(entity[0], entity_type=entity[1])
|
|
377
|
+
if not entity_id.is_found:
|
|
378
|
+
result[ERROR_NAME].append({'message': entity_id.content, 'id': entity})
|
|
379
|
+
continue
|
|
380
|
+
else:
|
|
381
|
+
entity_id = entity_id.content.id_
|
|
382
|
+
response = op_func(entity)
|
|
383
|
+
if response.result == op_name:
|
|
384
|
+
result[op_name].append(entity_id)
|
|
385
|
+
elif response.result == UNCHANGED_NAME:
|
|
386
|
+
result[UNCHANGED_NAME].append(entity_id)
|
|
387
|
+
except (TypeError, ListApiError, MatchApiError) as err:
|
|
388
|
+
result[ERROR_NAME].append({'message': str(err), 'id': entity})
|
|
389
|
+
if ((idx + 1) / total) * 100 >= step:
|
|
390
|
+
self.log.info(f'{op_name.capitalize()} {step}% of entities')
|
|
391
|
+
step += 10
|
|
392
|
+
|
|
393
|
+
return result
|
|
394
|
+
|
|
395
|
+
@debug_call
|
|
396
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
397
|
+
def _list_op(
|
|
398
|
+
self, entity: Union[str, tuple[str, str]], op_name: str, context: dict = None
|
|
399
|
+
) -> ListEntityOperationResponse:
|
|
400
|
+
"""Add or remove an entity from list.
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
entity (str, tuple): ID or (name, type) tuple of entity to add
|
|
404
|
+
op_name (str): operation to perform. Either 'added' or 'removed'
|
|
405
|
+
context (dict, optional): context object for entity. Defaults to {}
|
|
406
|
+
|
|
407
|
+
Raises:
|
|
408
|
+
ListApiError: if connection error occurs
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
ListEntityOperationResponse: list/{id}/entity/[add|remove] response
|
|
412
|
+
"""
|
|
413
|
+
if isinstance(entity, str):
|
|
414
|
+
resolved_entity_id = entity
|
|
415
|
+
else:
|
|
416
|
+
resolved_entity = self.match_mgr.resolve_entity_id(entity[0], entity_type=entity[1])
|
|
417
|
+
if not resolved_entity.is_found:
|
|
418
|
+
return ListEntityOperationResponse(result=resolved_entity.content)
|
|
419
|
+
resolved_entity_id = resolved_entity.content.id_
|
|
420
|
+
|
|
421
|
+
url = EP_LIST + f'/{self.id_}/entity/' + op_name
|
|
422
|
+
request_body = {'entity': {'id': resolved_entity_id}}
|
|
423
|
+
|
|
424
|
+
if context:
|
|
425
|
+
request_body['context'] = context
|
|
426
|
+
if op_name == ADD_OP:
|
|
427
|
+
AddEntityRequestModel.model_validate(request_body)
|
|
428
|
+
else:
|
|
429
|
+
RemoveEntityRequestModel.model_validate(request_body)
|
|
430
|
+
response = self.rf_client.request('post', url, data=request_body)
|
|
431
|
+
validated_response = ListEntityOperationResponse.model_validate(response.json())
|
|
432
|
+
if validated_response.result != UNCHANGED_NAME:
|
|
433
|
+
self.log.debug(f'Entity {entity} {validated_response.result} to list {self.id_}')
|
|
434
|
+
|
|
435
|
+
return validated_response
|
|
@@ -0,0 +1,185 @@
|
|
|
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 typing import Union
|
|
16
|
+
|
|
17
|
+
from pydantic import validate_call
|
|
18
|
+
|
|
19
|
+
from ..constants import DEFAULT_LIMIT
|
|
20
|
+
from ..endpoints import EP_CREATE_LIST, EP_LIST, EP_SEARCH_LIST
|
|
21
|
+
from ..entity_match import EntityMatchMgr
|
|
22
|
+
from ..helpers import debug_call
|
|
23
|
+
from ..helpers.helpers import connection_exceptions
|
|
24
|
+
from ..rf_client import RFClient
|
|
25
|
+
from .entity_list import EntityList
|
|
26
|
+
from .errors import ListApiError, ListResolutionError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EntityListMgr:
|
|
30
|
+
"""Manages requests for Recorded Future List API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, rf_token: str = None) -> None:
|
|
33
|
+
"""Initialize EntityListMgr object.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
rf_token (str, optional): Recorded Future API token. Defaults to None
|
|
37
|
+
"""
|
|
38
|
+
self.log = logging.getLogger(__name__)
|
|
39
|
+
self.rf_client = RFClient(api_token=rf_token) if rf_token else RFClient()
|
|
40
|
+
self.match_mgr = EntityMatchMgr(rf_token=rf_token) if rf_token else EntityMatchMgr()
|
|
41
|
+
|
|
42
|
+
@debug_call
|
|
43
|
+
@validate_call
|
|
44
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
45
|
+
def fetch(self, list_: Union[str, tuple[str, str]]) -> EntityList:
|
|
46
|
+
"""Gets a list by its ID. Use this function for list info response.
|
|
47
|
+
|
|
48
|
+
Endpoint:
|
|
49
|
+
``list/{list_id}/info``
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
list_ (str, tuple): list string ID or tuple of (name, type)
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
ValidationError if any supplied parameter is of incorrect type.
|
|
56
|
+
ListResolutionError: when ``list_`` is a tuple and name matches 0 or multiple entities
|
|
57
|
+
ListApiError: if connection error occurs.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
RFList: RFList object for list ID
|
|
61
|
+
"""
|
|
62
|
+
resolved_id = self._resolve_list_id(list_)
|
|
63
|
+
self.log.info(f'Getting list with ID: {resolved_id}')
|
|
64
|
+
url = EP_LIST + f'/{resolved_id}/info'
|
|
65
|
+
response = self.rf_client.request('get', url)
|
|
66
|
+
list_info_data = response.json()
|
|
67
|
+
self.log.debug("Found list ID '{}'".format(list_info_data['id']))
|
|
68
|
+
self.log.debug(' Type: {}'.format(list_info_data['type']))
|
|
69
|
+
self.log.debug(' Created: {}'.format(list_info_data['created']))
|
|
70
|
+
self.log.debug(' Updated: {}'.format(list_info_data['updated']))
|
|
71
|
+
|
|
72
|
+
return EntityList(rf_client=self.rf_client, match_mgr=self.match_mgr, **list_info_data)
|
|
73
|
+
|
|
74
|
+
@debug_call
|
|
75
|
+
@validate_call
|
|
76
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
77
|
+
def create(self, list_name: str, list_type: str = 'entity') -> EntityList:
|
|
78
|
+
"""Create list.
|
|
79
|
+
|
|
80
|
+
Endpoint:
|
|
81
|
+
``list/create``
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
list_name (str): list name to use for new list
|
|
85
|
+
list_type (str, optional): list type. Defaults to "entity"
|
|
86
|
+
|
|
87
|
+
Supported list types are available on the support page for List API:
|
|
88
|
+
https://support.recordedfuture.com/hc/en-us/articles/360058691913-List-API
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValidationError if any supplied parameter is of incorrect type.
|
|
92
|
+
ListApiError: if connection error occurs.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
EntityList: EntityList object for new list
|
|
96
|
+
"""
|
|
97
|
+
self.log.debug(f"Creating list '{list_name}'")
|
|
98
|
+
request_body = {'name': list_name, 'type': list_type}
|
|
99
|
+
response = self.rf_client.request('post', EP_CREATE_LIST, data=request_body)
|
|
100
|
+
list_create_data = response.json()
|
|
101
|
+
self.log.debug(f"List '{list_name}' created")
|
|
102
|
+
self.log.debug(' ID: {}'.format(list_create_data['id']))
|
|
103
|
+
self.log.debug(' Type: {}'.format(list_create_data['type']))
|
|
104
|
+
|
|
105
|
+
return EntityList(rf_client=self.rf_client, match_mgr=self.match_mgr, **list_create_data)
|
|
106
|
+
|
|
107
|
+
@debug_call
|
|
108
|
+
@validate_call
|
|
109
|
+
@connection_exceptions(ignore_status_code=[], exception_to_raise=ListApiError)
|
|
110
|
+
def search(
|
|
111
|
+
self, list_name: str = None, list_type: str = None, max_results: int = DEFAULT_LIMIT
|
|
112
|
+
) -> list[EntityList]:
|
|
113
|
+
"""Search lists.
|
|
114
|
+
|
|
115
|
+
Endpoint:
|
|
116
|
+
``list/search``
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
list_name (str): list name to search
|
|
120
|
+
list_type (str, optional): list type. Defaults to None, ignored when None
|
|
121
|
+
max_results (int, optional): maximum total number of lists to return
|
|
122
|
+
|
|
123
|
+
Raises:
|
|
124
|
+
ValidationError if any supplied parameter is of incorrect type.
|
|
125
|
+
ListApiError: if list API call fails
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
list: EntityList objects from list/search
|
|
129
|
+
"""
|
|
130
|
+
request_body = {}
|
|
131
|
+
request_body['limit'] = max_results
|
|
132
|
+
if list_name:
|
|
133
|
+
request_body['name'] = list_name
|
|
134
|
+
if list_type:
|
|
135
|
+
request_body['type'] = list_type
|
|
136
|
+
self.log.info(f'Searching list API with parameters: {request_body}')
|
|
137
|
+
response = self.rf_client.request('post', EP_SEARCH_LIST, data=request_body)
|
|
138
|
+
list_search_data = response.json()
|
|
139
|
+
self.log.info(
|
|
140
|
+
'Found {} matching {}'.format(
|
|
141
|
+
len(list_search_data), 'lists' if len(list_search_data) != 1 else 'list'
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return [
|
|
146
|
+
EntityList(rf_client=self.rf_client, match_mgr=self.match_mgr, **list_)
|
|
147
|
+
for list_ in list_search_data
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
@debug_call
|
|
151
|
+
def _resolve_list_id(self, list_: Union[str, tuple[str, str]]) -> str:
|
|
152
|
+
"""Resolves a list name to a list ID.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
list_ (str, tuple): list string ID or (name, type) tuple
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
ListResolutionError: when a list name matches none or multiple entities
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
str: list ID
|
|
162
|
+
"""
|
|
163
|
+
if isinstance(list_, str):
|
|
164
|
+
resolved_id = list_
|
|
165
|
+
else:
|
|
166
|
+
list_name, list_type = list_
|
|
167
|
+
self.log.info(f"Resolving ID for list '{list_name}' with type '{list_type}'")
|
|
168
|
+
matches = self.search(list_name, list_type)
|
|
169
|
+
if len(matches) == 0:
|
|
170
|
+
message = f"No match found for string '{list_name}'"
|
|
171
|
+
raise ListResolutionError(message)
|
|
172
|
+
elif len(matches) > 1:
|
|
173
|
+
exact_count = 0
|
|
174
|
+
resolved_id = None
|
|
175
|
+
for match in matches:
|
|
176
|
+
if match.name == list_name:
|
|
177
|
+
resolved_id = match.id_
|
|
178
|
+
exact_count += 1
|
|
179
|
+
if (not resolved_id) or exact_count > 1:
|
|
180
|
+
message = f"Multiple matches found for string '{list_name}'"
|
|
181
|
+
raise ListResolutionError(message)
|
|
182
|
+
else:
|
|
183
|
+
resolved_id = matches[0].id_
|
|
184
|
+
|
|
185
|
+
return resolved_id
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
from ..errors import RecordedFutureError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ListApiError(RecordedFutureError):
|
|
18
|
+
"""Error raised when an exception occurs performing a list API operation."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ListStateError(RecordedFutureError):
|
|
22
|
+
"""Error raised when list state is invalid."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ListResolutionError(RecordedFutureError):
|
|
26
|
+
"""Error raised when list resolution fails."""
|