pangea-sdk 3.7.0__py3-none-any.whl → 3.8.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pangea/__init__.py +1 -1
- pangea/asyncio/request.py +123 -32
- pangea/asyncio/services/__init__.py +1 -0
- pangea/asyncio/services/audit.py +236 -21
- pangea/asyncio/services/authn.py +79 -50
- pangea/asyncio/services/authz.py +259 -0
- pangea/asyncio/services/base.py +9 -6
- pangea/asyncio/services/file_scan.py +3 -4
- pangea/asyncio/services/intel.py +5 -6
- pangea/asyncio/services/redact.py +21 -3
- pangea/asyncio/services/vault.py +28 -12
- pangea/config.py +10 -18
- pangea/dump_audit.py +1 -0
- pangea/exceptions.py +8 -0
- pangea/request.py +164 -74
- pangea/response.py +63 -17
- pangea/services/__init__.py +1 -0
- pangea/services/audit/audit.py +241 -55
- pangea/services/audit/exceptions.py +1 -2
- pangea/services/audit/models.py +83 -21
- pangea/services/audit/signing.py +1 -0
- pangea/services/audit/util.py +1 -0
- pangea/services/authn/authn.py +38 -4
- pangea/services/authn/models.py +9 -9
- pangea/services/authz.py +377 -0
- pangea/services/base.py +34 -14
- pangea/services/embargo.py +1 -2
- pangea/services/file_scan.py +3 -4
- pangea/services/intel.py +3 -4
- pangea/services/redact.py +21 -3
- pangea/services/vault/vault.py +29 -12
- pangea/utils.py +2 -3
- {pangea_sdk-3.7.0.dist-info → pangea_sdk-3.8.0.dist-info}/METADATA +33 -6
- pangea_sdk-3.8.0.dist-info/RECORD +46 -0
- pangea_sdk-3.7.0.dist-info/RECORD +0 -44
- {pangea_sdk-3.7.0.dist-info → pangea_sdk-3.8.0.dist-info}/WHEEL +0 -0
pangea/__init__.py
CHANGED
pangea/asyncio/request.py
CHANGED
@@ -7,14 +7,16 @@ import time
|
|
7
7
|
from typing import Dict, List, Optional, Tuple, Type, Union
|
8
8
|
|
9
9
|
import aiohttp
|
10
|
-
import pangea.exceptions as pe
|
11
10
|
from aiohttp import FormData
|
11
|
+
from typing_extensions import TypeVar
|
12
12
|
|
13
|
-
|
14
|
-
from pangea.request import PangeaRequestBase
|
15
|
-
from pangea.response import
|
13
|
+
import pangea.exceptions as pe
|
14
|
+
from pangea.request import MultipartResponse, PangeaRequestBase
|
15
|
+
from pangea.response import AttachedFile, PangeaResponse, PangeaResponseResult, ResponseStatus, TransferMethod
|
16
16
|
from pangea.utils import default_encoder
|
17
17
|
|
18
|
+
TResult = TypeVar("TResult", bound=PangeaResponseResult, default=PangeaResponseResult)
|
19
|
+
|
18
20
|
|
19
21
|
class PangeaRequestAsync(PangeaRequestBase):
|
20
22
|
"""An object that makes direct calls to Pangea Service APIs.
|
@@ -28,12 +30,12 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
28
30
|
async def post(
|
29
31
|
self,
|
30
32
|
endpoint: str,
|
31
|
-
result_class: Type[
|
33
|
+
result_class: Type[TResult],
|
32
34
|
data: Union[str, Dict] = {},
|
33
|
-
files:
|
35
|
+
files: List[Tuple] = [],
|
34
36
|
poll_result: bool = True,
|
35
37
|
url: Optional[str] = None,
|
36
|
-
) -> PangeaResponse:
|
38
|
+
) -> PangeaResponse[TResult]:
|
37
39
|
"""Makes the POST call to a Pangea Service endpoint.
|
38
40
|
|
39
41
|
Args:
|
@@ -56,7 +58,7 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
56
58
|
)
|
57
59
|
transfer_method = data.get("transfer_method", None) # type: ignore[union-attr]
|
58
60
|
|
59
|
-
if files
|
61
|
+
if files and type(data) is dict and (transfer_method == TransferMethod.POST_URL.value):
|
60
62
|
requests_response = await self._full_post_presigned_url(
|
61
63
|
endpoint, result_class=result_class, data=data, files=files
|
62
64
|
)
|
@@ -66,18 +68,32 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
66
68
|
)
|
67
69
|
|
68
70
|
await self._check_http_errors(requests_response)
|
69
|
-
json_resp = await requests_response.json()
|
70
|
-
self.logger.debug(json.dumps({"service": self.service, "action": "post", "url": url, "response": json_resp}))
|
71
71
|
|
72
|
-
|
72
|
+
if "multipart/form-data" in requests_response.headers.get("content-type", ""):
|
73
|
+
multipart_response = await self._process_multipart_response(requests_response)
|
74
|
+
pangea_response: PangeaResponse = PangeaResponse(
|
75
|
+
requests_response,
|
76
|
+
result_class=result_class,
|
77
|
+
json=multipart_response.pangea_json,
|
78
|
+
attached_files=multipart_response.attached_files,
|
79
|
+
)
|
80
|
+
else:
|
81
|
+
try:
|
82
|
+
json_resp = await requests_response.json()
|
83
|
+
self.logger.debug(
|
84
|
+
json.dumps({"service": self.service, "action": "post", "url": url, "response": json_resp})
|
85
|
+
)
|
86
|
+
|
87
|
+
pangea_response = PangeaResponse(requests_response, result_class=result_class, json=json_resp)
|
88
|
+
except aiohttp.ContentTypeError as e:
|
89
|
+
raise pe.PangeaException(f"Failed to decode json response. {e}. Body: {await requests_response.text()}")
|
90
|
+
|
73
91
|
if poll_result:
|
74
92
|
pangea_response = await self._handle_queued_result(pangea_response)
|
75
93
|
|
76
94
|
return self._check_response(pangea_response)
|
77
95
|
|
78
|
-
async def get(
|
79
|
-
self, path: str, result_class: Type[PangeaResponseResult], check_response: bool = True
|
80
|
-
) -> PangeaResponse:
|
96
|
+
async def get(self, path: str, result_class: Type[TResult], check_response: bool = True) -> PangeaResponse[TResult]:
|
81
97
|
"""Makes the GET call to a Pangea Service endpoint.
|
82
98
|
|
83
99
|
Args:
|
@@ -94,7 +110,7 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
94
110
|
|
95
111
|
async with self.session.get(url, headers=self._headers()) as requests_response:
|
96
112
|
await self._check_http_errors(requests_response)
|
97
|
-
pangea_response = PangeaResponse(
|
113
|
+
pangea_response = PangeaResponse(
|
98
114
|
requests_response, result_class=result_class, json=await requests_response.json()
|
99
115
|
)
|
100
116
|
|
@@ -115,11 +131,11 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
115
131
|
raise pe.ServiceTemporarilyUnavailable(await resp.json())
|
116
132
|
|
117
133
|
async def poll_result_by_id(
|
118
|
-
self, request_id: str, result_class:
|
119
|
-
):
|
134
|
+
self, request_id: str, result_class: Type[TResult], check_response: bool = True
|
135
|
+
) -> PangeaResponse[TResult]:
|
120
136
|
path = self._get_poll_path(request_id)
|
121
137
|
self.logger.debug(json.dumps({"service": self.service, "action": "poll_result_once", "url": path}))
|
122
|
-
return await self.get(path, result_class, check_response=check_response)
|
138
|
+
return await self.get(path, result_class, check_response=check_response)
|
123
139
|
|
124
140
|
async def poll_result_once(self, response: PangeaResponse, check_response: bool = True):
|
125
141
|
request_id = response.request_id
|
@@ -157,12 +173,84 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
157
173
|
if resp.status < 200 or resp.status >= 300:
|
158
174
|
raise pe.PresignedUploadError(f"presigned PUT failure: {resp.status}", await resp.text())
|
159
175
|
|
176
|
+
async def download_file(self, url: str, filename: Optional[str] = None) -> AttachedFile:
|
177
|
+
self.logger.debug(
|
178
|
+
json.dumps(
|
179
|
+
{
|
180
|
+
"service": self.service,
|
181
|
+
"action": "download_file",
|
182
|
+
"url": url,
|
183
|
+
"status": "start",
|
184
|
+
}
|
185
|
+
)
|
186
|
+
)
|
187
|
+
async with self.session.get(url, headers={}) as response:
|
188
|
+
if response.status == 200:
|
189
|
+
if filename is None:
|
190
|
+
content_disposition = response.headers.get("Content-Disposition", "")
|
191
|
+
filename = self._get_filename_from_content_disposition(content_disposition)
|
192
|
+
if filename is None:
|
193
|
+
filename = self._get_filename_from_url(url)
|
194
|
+
if filename is None:
|
195
|
+
filename = "default_filename"
|
196
|
+
|
197
|
+
content_type = response.headers.get("Content-Type", "")
|
198
|
+
self.logger.debug(
|
199
|
+
json.dumps(
|
200
|
+
{
|
201
|
+
"service": self.service,
|
202
|
+
"action": "download_file",
|
203
|
+
"url": url,
|
204
|
+
"filename": filename,
|
205
|
+
"status": "success",
|
206
|
+
}
|
207
|
+
)
|
208
|
+
)
|
209
|
+
|
210
|
+
return AttachedFile(filename=filename, file=await response.read(), content_type=content_type)
|
211
|
+
else:
|
212
|
+
raise pe.DownloadFileError(f"Failed to download file. Status: {response.status}", await response.text())
|
213
|
+
|
214
|
+
async def _get_pangea_json(self, reader: aiohttp.MultipartReader) -> Optional[Dict]:
|
215
|
+
# Iterate through parts
|
216
|
+
async for part in reader:
|
217
|
+
return await part.json()
|
218
|
+
return None
|
219
|
+
|
220
|
+
async def _get_attached_files(self, reader: aiohttp.MultipartReader) -> List[AttachedFile]:
|
221
|
+
files = []
|
222
|
+
i = 0
|
223
|
+
|
224
|
+
async for part in reader:
|
225
|
+
content_type = part.headers.get("Content-Type", "")
|
226
|
+
content_disposition = part.headers.get("Content-Disposition", "")
|
227
|
+
name = self._get_filename_from_content_disposition(content_disposition)
|
228
|
+
if name is None:
|
229
|
+
name = f"default_file_name_{i}"
|
230
|
+
i += 1
|
231
|
+
files.append(AttachedFile(name, await part.read(), content_type))
|
232
|
+
|
233
|
+
return files
|
234
|
+
|
235
|
+
async def _process_multipart_response(self, resp: aiohttp.ClientResponse) -> MultipartResponse:
|
236
|
+
# Parse the multipart response
|
237
|
+
multipart_reader = aiohttp.MultipartReader.from_response(resp)
|
238
|
+
|
239
|
+
pangea_json = await self._get_pangea_json(multipart_reader) # type: ignore[arg-type]
|
240
|
+
self.logger.debug(
|
241
|
+
json.dumps({"service": self.service, "action": "multipart response", "response": pangea_json})
|
242
|
+
)
|
243
|
+
|
244
|
+
multipart_reader = multipart_reader.__aiter__()
|
245
|
+
attached_files = await self._get_attached_files(multipart_reader) # type: ignore[arg-type]
|
246
|
+
return MultipartResponse(pangea_json, attached_files) # type: ignore[arg-type]
|
247
|
+
|
160
248
|
async def _http_post(
|
161
249
|
self,
|
162
250
|
url: str,
|
163
251
|
headers: Dict = {},
|
164
252
|
data: Union[str, Dict] = {},
|
165
|
-
files:
|
253
|
+
files: List[Tuple] = [],
|
166
254
|
presigned_url_post: bool = False,
|
167
255
|
) -> aiohttp.ClientResponse:
|
168
256
|
if files:
|
@@ -193,26 +281,29 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
193
281
|
self.logger.debug(
|
194
282
|
json.dumps({"service": self.service, "action": "http_put", "url": url}, default=default_encoder)
|
195
283
|
)
|
196
|
-
|
197
|
-
|
198
|
-
form.add_field(name, value[1], filename=value[0], content_type=value[2])
|
199
|
-
return await self.session.put(url, headers=headers, data=form)
|
284
|
+
_, value = files[0]
|
285
|
+
return await self.session.put(url, headers=headers, data=value[1])
|
200
286
|
|
201
287
|
async def _full_post_presigned_url(
|
202
288
|
self,
|
203
289
|
endpoint: str,
|
204
290
|
result_class: Type[PangeaResponseResult],
|
205
291
|
data: Union[str, Dict] = {},
|
206
|
-
files:
|
292
|
+
files: List[Tuple] = [],
|
207
293
|
):
|
208
|
-
if len(files) == 0:
|
294
|
+
if len(files) == 0:
|
209
295
|
raise AttributeError("files attribute should have at least 1 file")
|
210
296
|
|
211
297
|
response = await self.request_presigned_url(endpoint=endpoint, result_class=result_class, data=data)
|
212
|
-
|
213
|
-
|
298
|
+
if response.accepted_result is None:
|
299
|
+
raise pe.PangeaException("No accepted_result field when requesting presigned url")
|
300
|
+
if response.accepted_result.post_url is None:
|
301
|
+
raise pe.PresignedURLException("No presigned url", response)
|
302
|
+
|
303
|
+
data_to_presigned = response.accepted_result.post_form_data
|
304
|
+
presigned_url = response.accepted_result.post_url
|
214
305
|
|
215
|
-
await self.post_presigned_url(url=presigned_url, data=data_to_presigned, files=files)
|
306
|
+
await self.post_presigned_url(url=presigned_url, data=data_to_presigned, files=files)
|
216
307
|
return response.raw_response
|
217
308
|
|
218
309
|
async def request_presigned_url(
|
@@ -232,14 +323,14 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
232
323
|
raise e
|
233
324
|
|
234
325
|
# Receive 202
|
235
|
-
return await self._poll_presigned_url(accepted_exception.response)
|
326
|
+
return await self._poll_presigned_url(accepted_exception.response)
|
236
327
|
|
237
|
-
async def _poll_presigned_url(self, response: PangeaResponse) ->
|
328
|
+
async def _poll_presigned_url(self, response: PangeaResponse[TResult]) -> PangeaResponse[TResult]:
|
238
329
|
if response.http_status != 202:
|
239
330
|
raise AttributeError("Response should be 202")
|
240
331
|
|
241
332
|
if response.accepted_result is not None and response.accepted_result.has_upload_url:
|
242
|
-
return response
|
333
|
+
return response
|
243
334
|
|
244
335
|
self.logger.debug(json.dumps({"service": self.service, "action": "poll_presigned_url", "step": "start"}))
|
245
336
|
retry_count = 1
|
@@ -276,7 +367,7 @@ class PangeaRequestAsync(PangeaRequestBase):
|
|
276
367
|
self.logger.debug(json.dumps({"service": self.service, "action": "poll_presigned_url", "step": "exit"}))
|
277
368
|
|
278
369
|
if loop_resp.accepted_result is not None and not loop_resp.accepted_result.has_upload_url:
|
279
|
-
return loop_resp
|
370
|
+
return loop_resp
|
280
371
|
else:
|
281
372
|
raise loop_exc
|
282
373
|
|
pangea/asyncio/services/audit.py
CHANGED
@@ -1,18 +1,28 @@
|
|
1
1
|
# Copyright 2022 Pangea Cyber Corporation
|
2
2
|
# Author: Pangea Cyber Corporation
|
3
|
+
from __future__ import annotations
|
4
|
+
|
3
5
|
import datetime
|
4
|
-
from typing import Any, Dict, List, Optional, Union
|
6
|
+
from typing import Any, Dict, List, Optional, Sequence, Union
|
5
7
|
|
6
8
|
import pangea.exceptions as pexc
|
7
|
-
from pangea.
|
9
|
+
from pangea.asyncio.services.base import ServiceBaseAsync
|
10
|
+
from pangea.config import PangeaConfig
|
11
|
+
from pangea.response import PangeaResponse, PangeaResponseResult
|
8
12
|
from pangea.services.audit.audit import AuditBase
|
9
13
|
from pangea.services.audit.exceptions import AuditException
|
10
14
|
from pangea.services.audit.models import (
|
15
|
+
DownloadFormat,
|
16
|
+
DownloadRequest,
|
17
|
+
DownloadResult,
|
11
18
|
Event,
|
19
|
+
ExportRequest,
|
12
20
|
LogBulkResult,
|
13
21
|
LogResult,
|
22
|
+
PublishedRoot,
|
14
23
|
RootRequest,
|
15
24
|
RootResult,
|
25
|
+
RootSource,
|
16
26
|
SearchOrder,
|
17
27
|
SearchOrderBy,
|
18
28
|
SearchOutput,
|
@@ -22,8 +32,6 @@ from pangea.services.audit.models import (
|
|
22
32
|
)
|
23
33
|
from pangea.services.audit.util import format_datetime
|
24
34
|
|
25
|
-
from .base import ServiceBaseAsync
|
26
|
-
|
27
35
|
|
28
36
|
class AuditAsync(ServiceBaseAsync, AuditBase):
|
29
37
|
"""Audit service client.
|
@@ -52,14 +60,33 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
52
60
|
|
53
61
|
def __init__(
|
54
62
|
self,
|
55
|
-
token,
|
56
|
-
config=None,
|
63
|
+
token: str,
|
64
|
+
config: PangeaConfig | None = None,
|
57
65
|
private_key_file: str = "",
|
58
|
-
public_key_info:
|
59
|
-
tenant_id:
|
60
|
-
logger_name="pangea",
|
61
|
-
config_id:
|
62
|
-
):
|
66
|
+
public_key_info: dict[str, str] = {},
|
67
|
+
tenant_id: str | None = None,
|
68
|
+
logger_name: str = "pangea",
|
69
|
+
config_id: str | None = None,
|
70
|
+
) -> None:
|
71
|
+
"""
|
72
|
+
Audit client
|
73
|
+
|
74
|
+
Initializes a new Audit client.
|
75
|
+
|
76
|
+
Args:
|
77
|
+
token: Pangea API token.
|
78
|
+
config: Configuration.
|
79
|
+
private_key_file: Private key filepath.
|
80
|
+
public_key_info: Public key information.
|
81
|
+
tenant_id: Tenant ID.
|
82
|
+
logger_name: Logger name.
|
83
|
+
config_id: Configuration ID.
|
84
|
+
|
85
|
+
Examples:
|
86
|
+
config = PangeaConfig(domain="pangea_domain")
|
87
|
+
audit = AuditAsync(token="pangea_token", config=config)
|
88
|
+
"""
|
89
|
+
|
63
90
|
# FIXME: Temporary check to deprecate config_id from PangeaConfig.
|
64
91
|
# Delete it when deprecate PangeaConfig.config_id
|
65
92
|
if config_id and config is not None and config.config_id is not None:
|
@@ -175,8 +202,10 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
175
202
|
"""
|
176
203
|
|
177
204
|
input = self._get_log_request(event, sign_local=sign_local, verify=verify, verbose=verbose)
|
178
|
-
response = await self.request.post(
|
179
|
-
|
205
|
+
response: PangeaResponse[LogResult] = await self.request.post(
|
206
|
+
"v1/log", LogResult, data=input.dict(exclude_none=True)
|
207
|
+
)
|
208
|
+
if response.success and response.result is not None:
|
180
209
|
self._process_log_result(response.result, verify=verify)
|
181
210
|
return response
|
182
211
|
|
@@ -209,8 +238,10 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
209
238
|
"""
|
210
239
|
|
211
240
|
input = self._get_log_request(events, sign_local=sign_local, verify=False, verbose=verbose)
|
212
|
-
response = await self.request.post(
|
213
|
-
|
241
|
+
response: PangeaResponse[LogBulkResult] = await self.request.post(
|
242
|
+
"v2/log", LogBulkResult, data=input.dict(exclude_none=True)
|
243
|
+
)
|
244
|
+
if response.success and response.result is not None:
|
214
245
|
for result in response.result.results:
|
215
246
|
self._process_log_result(result, verify=True)
|
216
247
|
return response
|
@@ -245,12 +276,12 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
245
276
|
|
246
277
|
input = self._get_log_request(events, sign_local=sign_local, verify=False, verbose=verbose)
|
247
278
|
try:
|
248
|
-
response = await self.request.post(
|
279
|
+
response: PangeaResponse[LogBulkResult] = await self.request.post(
|
249
280
|
"v2/log_async", LogBulkResult, data=input.dict(exclude_none=True), poll_result=False
|
250
281
|
)
|
251
282
|
except pexc.AcceptedRequestException as e:
|
252
283
|
return e.response
|
253
|
-
if response.success:
|
284
|
+
if response.success and response.result:
|
254
285
|
for result in response.result.results:
|
255
286
|
self._process_log_result(result, verify=True)
|
256
287
|
return response
|
@@ -264,7 +295,7 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
264
295
|
end: Optional[Union[datetime.datetime, str]] = None,
|
265
296
|
limit: Optional[int] = None,
|
266
297
|
max_results: Optional[int] = None,
|
267
|
-
search_restriction: Optional[
|
298
|
+
search_restriction: Optional[Dict[str, Sequence[str]]] = None,
|
268
299
|
verbose: Optional[bool] = None,
|
269
300
|
verify_consistency: bool = False,
|
270
301
|
verify_events: bool = True,
|
@@ -293,7 +324,7 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
293
324
|
end (datetime, optional): An RFC-3339 formatted timestamp, or relative time adjustment from the current time.
|
294
325
|
limit (int, optional): Optional[int] = None,
|
295
326
|
max_results (int, optional): Maximum number of results to return.
|
296
|
-
search_restriction (
|
327
|
+
search_restriction (Dict[str, Sequence[str]], optional): A list of keys to restrict the search results to. Useful for partitioning data available to the query string.
|
297
328
|
verbose (bool, optional): If true, response include root and membership and consistency proofs.
|
298
329
|
verify_consistency (bool): True to verify logs consistency
|
299
330
|
verify_events (bool): True to verify hash events and signatures
|
@@ -326,7 +357,12 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
326
357
|
verbose=verbose,
|
327
358
|
)
|
328
359
|
|
329
|
-
response = await self.request.post(
|
360
|
+
response: PangeaResponse[SearchOutput] = await self.request.post(
|
361
|
+
"v1/search", SearchOutput, data=input.dict(exclude_none=True)
|
362
|
+
)
|
363
|
+
if verify_consistency:
|
364
|
+
await self.update_published_roots(response.result) # type: ignore[arg-type]
|
365
|
+
|
330
366
|
return self.handle_search_response(response, verify_consistency, verify_events)
|
331
367
|
|
332
368
|
async def results(
|
@@ -334,6 +370,7 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
334
370
|
id: str,
|
335
371
|
limit: Optional[int] = 20,
|
336
372
|
offset: Optional[int] = 0,
|
373
|
+
assert_search_restriction: Optional[Dict[str, Sequence[str]]] = None,
|
337
374
|
verify_consistency: bool = False,
|
338
375
|
verify_events: bool = True,
|
339
376
|
) -> PangeaResponse[SearchResultOutput]:
|
@@ -348,6 +385,7 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
348
385
|
id (string): the id of a search action, found in `response.result.id`
|
349
386
|
limit (integer, optional): the maximum number of results to return, default is 20
|
350
387
|
offset (integer, optional): the position of the first result to return, default is 0
|
388
|
+
assert_search_restriction (Dict[str, Sequence[str]], optional): Assert the requested search results were queried with the exact same search restrictions, to ensure the results comply to the expected restrictions.
|
351
389
|
verify_consistency (bool): True to verify logs consistency
|
352
390
|
verify_events (bool): True to verify hash events and signatures
|
353
391
|
Raises:
|
@@ -365,7 +403,8 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
365
403
|
result_res: PangeaResponse[SearchResultsOutput] = audit.results(
|
366
404
|
id=search_res.result.id,
|
367
405
|
limit=10,
|
368
|
-
offset=0
|
406
|
+
offset=0,
|
407
|
+
assert_search_restriction={'source': ["monitor"]})
|
369
408
|
"""
|
370
409
|
|
371
410
|
if limit <= 0: # type: ignore[operator]
|
@@ -378,10 +417,115 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
378
417
|
id=id,
|
379
418
|
limit=limit,
|
380
419
|
offset=offset,
|
420
|
+
assert_search_restriction=assert_search_restriction,
|
381
421
|
)
|
382
422
|
response = await self.request.post("v1/results", SearchResultOutput, data=input.dict(exclude_none=True))
|
423
|
+
if verify_consistency and response.result is not None:
|
424
|
+
await self.update_published_roots(response.result)
|
425
|
+
|
383
426
|
return self.handle_results_response(response, verify_consistency, verify_events)
|
384
427
|
|
428
|
+
async def export(
|
429
|
+
self,
|
430
|
+
*,
|
431
|
+
format: DownloadFormat = DownloadFormat.CSV,
|
432
|
+
start: Optional[datetime.datetime] = None,
|
433
|
+
end: Optional[datetime.datetime] = None,
|
434
|
+
order: Optional[SearchOrder] = None,
|
435
|
+
order_by: Optional[str] = None,
|
436
|
+
verbose: bool = True,
|
437
|
+
) -> PangeaResponse[PangeaResponseResult]:
|
438
|
+
"""
|
439
|
+
Export from the audit log
|
440
|
+
|
441
|
+
Bulk export of data from the Secure Audit Log, with optional filtering.
|
442
|
+
|
443
|
+
OperationId: audit_post_v1_export
|
444
|
+
|
445
|
+
Args:
|
446
|
+
format: Format for the records.
|
447
|
+
start: The start of the time range to perform the search on.
|
448
|
+
end: The end of the time range to perform the search on. If omitted,
|
449
|
+
then all records up to the latest will be searched.
|
450
|
+
order: Specify the sort order of the response.
|
451
|
+
order_by: Name of column to sort the results by.
|
452
|
+
verbose: Whether or not to include the root hash of the tree and the
|
453
|
+
membership proof for each record.
|
454
|
+
|
455
|
+
Raises:
|
456
|
+
AuditException: If an audit based api exception happens
|
457
|
+
PangeaAPIException: If an API Error happens
|
458
|
+
|
459
|
+
Examples:
|
460
|
+
export_res = await audit.export(verbose=False)
|
461
|
+
|
462
|
+
# Export may take several dozens of minutes, so polling for the result
|
463
|
+
# should be done in a loop. That is omitted here for brevity's sake.
|
464
|
+
try:
|
465
|
+
await audit.poll_result(request_id=export_res.request_id)
|
466
|
+
except AcceptedRequestException:
|
467
|
+
# Retry later.
|
468
|
+
|
469
|
+
# Download the result when it's ready.
|
470
|
+
download_res = await audit.download_results(request_id=export_res.request_id)
|
471
|
+
download_res.result.dest_url
|
472
|
+
# => https://pangea-runtime.s3.amazonaws.com/audit/xxxxx/search_results_[...]
|
473
|
+
"""
|
474
|
+
input = ExportRequest(
|
475
|
+
format=format,
|
476
|
+
start=start,
|
477
|
+
end=end,
|
478
|
+
order=order,
|
479
|
+
order_by=order_by,
|
480
|
+
verbose=verbose,
|
481
|
+
)
|
482
|
+
try:
|
483
|
+
return await self.request.post(
|
484
|
+
"v1/export", PangeaResponseResult, data=input.dict(exclude_none=True), poll_result=False
|
485
|
+
)
|
486
|
+
except pexc.AcceptedRequestException as e:
|
487
|
+
return e.response
|
488
|
+
|
489
|
+
async def log_stream(self, data: dict) -> PangeaResponse[PangeaResponseResult]:
|
490
|
+
"""
|
491
|
+
Log streaming endpoint
|
492
|
+
|
493
|
+
This API allows 3rd party vendors (like Auth0) to stream events to this
|
494
|
+
endpoint where the structure of the payload varies across different
|
495
|
+
vendors.
|
496
|
+
|
497
|
+
OperationId: audit_post_v1_log_stream
|
498
|
+
|
499
|
+
Args:
|
500
|
+
data: Event data. The exact schema of this will vary by vendor.
|
501
|
+
|
502
|
+
Raises:
|
503
|
+
AuditException: If an audit based api exception happens
|
504
|
+
PangeaAPIException: If an API Error happens
|
505
|
+
|
506
|
+
Examples:
|
507
|
+
data = {
|
508
|
+
"logs": [
|
509
|
+
{
|
510
|
+
"log_id": "some log ID",
|
511
|
+
"data": {
|
512
|
+
"date": "2024-03-29T17:26:50.193Z",
|
513
|
+
"type": "sapi",
|
514
|
+
"description": "Create a log stream",
|
515
|
+
"client_id": "some client ID",
|
516
|
+
"ip": "127.0.0.1",
|
517
|
+
"user_agent": "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0",
|
518
|
+
"user_id": "some user ID",
|
519
|
+
},
|
520
|
+
}
|
521
|
+
# ...
|
522
|
+
]
|
523
|
+
}
|
524
|
+
|
525
|
+
response = await audit.log_stream(data)
|
526
|
+
"""
|
527
|
+
return await self.request.post("v1/log_stream", PangeaResponseResult, data=data)
|
528
|
+
|
385
529
|
async def root(self, tree_size: Optional[int] = None) -> PangeaResponse[RootResult]:
|
386
530
|
"""
|
387
531
|
Tamperproof verification
|
@@ -405,3 +549,74 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
|
|
405
549
|
"""
|
406
550
|
input = RootRequest(tree_size=tree_size)
|
407
551
|
return await self.request.post("v1/root", RootResult, data=input.dict(exclude_none=True))
|
552
|
+
|
553
|
+
async def download_results(
|
554
|
+
self,
|
555
|
+
result_id: Optional[str] = None,
|
556
|
+
format: DownloadFormat = DownloadFormat.CSV,
|
557
|
+
request_id: Optional[str] = None,
|
558
|
+
) -> PangeaResponse[DownloadResult]:
|
559
|
+
"""
|
560
|
+
Download search results
|
561
|
+
|
562
|
+
Get all search results as a compressed (gzip) CSV file.
|
563
|
+
|
564
|
+
OperationId: audit_post_v1_download_results
|
565
|
+
|
566
|
+
Args:
|
567
|
+
result_id: ID returned by the search API.
|
568
|
+
format: Format for the records.
|
569
|
+
request_id: ID returned by the export API.
|
570
|
+
|
571
|
+
Returns:
|
572
|
+
URL where search results can be downloaded.
|
573
|
+
|
574
|
+
Raises:
|
575
|
+
AuditException: If an Audit-based API exception occurs.
|
576
|
+
PangeaAPIException: If an API exception occurs.
|
577
|
+
|
578
|
+
Examples:
|
579
|
+
response = await audit.download_results(
|
580
|
+
result_id="pas_[...]",
|
581
|
+
format=DownloadFormat.JSON,
|
582
|
+
)
|
583
|
+
"""
|
584
|
+
|
585
|
+
if request_id is None and result_id is None:
|
586
|
+
raise ValueError("must pass one of `request_id` or `result_id`")
|
587
|
+
|
588
|
+
input = DownloadRequest(request_id=request_id, result_id=result_id, format=format)
|
589
|
+
return await self.request.post("v1/download_results", DownloadResult, data=input.dict(exclude_none=True))
|
590
|
+
|
591
|
+
async def update_published_roots(self, result: SearchResultOutput):
|
592
|
+
"""Fetches series of published root hashes from Arweave
|
593
|
+
|
594
|
+
This is used for subsequent calls to verify_consistency_proof(). Root hashes
|
595
|
+
are published on [Arweave](https://arweave.net).
|
596
|
+
|
597
|
+
Args:
|
598
|
+
result (SearchResultOutput): Result object from previous call to AuditAsync.search() or AuditAsync.results()
|
599
|
+
|
600
|
+
Raises:
|
601
|
+
AuditException: If an audit based api exception happens
|
602
|
+
PangeaAPIException: If an API Error happens
|
603
|
+
"""
|
604
|
+
|
605
|
+
if not result.root:
|
606
|
+
return
|
607
|
+
|
608
|
+
tree_sizes, arweave_roots = self._get_tree_sizes_and_roots(result)
|
609
|
+
|
610
|
+
# fill the missing roots from the server (if allowed)
|
611
|
+
for tree_size in tree_sizes:
|
612
|
+
pub_root = None
|
613
|
+
if tree_size in arweave_roots:
|
614
|
+
pub_root = PublishedRoot(**arweave_roots[tree_size].dict(exclude_none=True))
|
615
|
+
pub_root.source = RootSource.ARWEAVE
|
616
|
+
elif self.allow_server_roots:
|
617
|
+
resp = await self.root(tree_size=tree_size)
|
618
|
+
if resp.success and resp.result is not None:
|
619
|
+
pub_root = PublishedRoot(**resp.result.data.dict(exclude_none=True))
|
620
|
+
pub_root.source = RootSource.PANGEA
|
621
|
+
if pub_root is not None:
|
622
|
+
self.pub_roots[tree_size] = pub_root
|