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 CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "3.7.0"
1
+ __version__ = "3.8.0"
2
2
 
3
3
  from pangea.asyncio.request import PangeaRequestAsync
4
4
  from pangea.config import PangeaConfig
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
- # from requests.adapters import HTTPAdapter, Retry
14
- from pangea.request import PangeaRequestBase
15
- from pangea.response import AcceptedResult, PangeaResponse, PangeaResponseResult, ResponseStatus, TransferMethod
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[PangeaResponseResult],
33
+ result_class: Type[TResult],
32
34
  data: Union[str, Dict] = {},
33
- files: Optional[List[Tuple]] = None,
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 is not None and type(data) is dict and (transfer_method == TransferMethod.POST_URL.value):
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
- pangea_response = PangeaResponse(requests_response, result_class=result_class, json=json_resp) # type: ignore[var-annotated]
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( # type: ignore[var-annotated]
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: Union[Type[PangeaResponseResult], dict], check_response: bool = True
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) # type: ignore[arg-type]
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: Optional[List[Tuple]] = None,
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
- form = FormData()
197
- name, value = files[0]
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: Optional[List[Tuple]] = None,
292
+ files: List[Tuple] = [],
207
293
  ):
208
- if len(files) == 0: # type: ignore[arg-type]
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
- data_to_presigned = response.accepted_result.post_form_data # type: ignore[union-attr]
213
- presigned_url = response.accepted_result.post_url # type: ignore[union-attr]
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) # type: ignore[arg-type]
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) # type: ignore[return-value]
326
+ return await self._poll_presigned_url(accepted_exception.response)
236
327
 
237
- async def _poll_presigned_url(self, response: PangeaResponse) -> AcceptedResult:
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 # type: ignore[return-value]
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 # type: ignore[return-value]
370
+ return loop_resp
280
371
  else:
281
372
  raise loop_exc
282
373
 
@@ -1,5 +1,6 @@
1
1
  from .audit import AuditAsync
2
2
  from .authn import AuthNAsync
3
+ from .authz import AuthZAsync
3
4
  from .embargo import EmbargoAsync
4
5
  from .file_scan import FileScanAsync
5
6
  from .intel import DomainIntelAsync, FileIntelAsync, IpIntelAsync, UrlIntelAsync, UserIntelAsync
@@ -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.response import PangeaResponse
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: Dict[str, str] = {},
59
- tenant_id: Optional[str] = None,
60
- logger_name="pangea",
61
- config_id: Optional[str] = None,
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("v1/log", LogResult, data=input.dict(exclude_none=True))
179
- if response.success:
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("v2/log", LogBulkResult, data=input.dict(exclude_none=True))
213
- if response.success:
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[dict] = None,
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 (dict, optional): A list of keys to restrict the search results to. Useful for partitioning data available to the query string.
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("v1/search", SearchOutput, data=input.dict(exclude_none=True))
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