pangea-sdk 3.7.0__py3-none-any.whl → 3.8.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
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