pangea-sdk 3.8.0b1__py3-none-any.whl → 5.3.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. pangea/__init__.py +1 -1
  2. pangea/asyncio/file_uploader.py +1 -1
  3. pangea/asyncio/request.py +49 -31
  4. pangea/asyncio/services/__init__.py +2 -0
  5. pangea/asyncio/services/audit.py +192 -31
  6. pangea/asyncio/services/authn.py +187 -109
  7. pangea/asyncio/services/authz.py +285 -0
  8. pangea/asyncio/services/base.py +21 -2
  9. pangea/asyncio/services/embargo.py +2 -2
  10. pangea/asyncio/services/file_scan.py +24 -9
  11. pangea/asyncio/services/intel.py +108 -34
  12. pangea/asyncio/services/redact.py +72 -4
  13. pangea/asyncio/services/sanitize.py +217 -0
  14. pangea/asyncio/services/share.py +246 -73
  15. pangea/asyncio/services/vault.py +1710 -750
  16. pangea/crypto/rsa.py +135 -0
  17. pangea/deep_verify.py +7 -1
  18. pangea/dump_audit.py +9 -8
  19. pangea/request.py +83 -59
  20. pangea/response.py +49 -31
  21. pangea/services/__init__.py +2 -0
  22. pangea/services/audit/audit.py +205 -42
  23. pangea/services/audit/models.py +56 -8
  24. pangea/services/audit/signing.py +6 -5
  25. pangea/services/audit/util.py +3 -3
  26. pangea/services/authn/authn.py +140 -70
  27. pangea/services/authn/models.py +167 -11
  28. pangea/services/authz.py +400 -0
  29. pangea/services/base.py +39 -8
  30. pangea/services/embargo.py +2 -2
  31. pangea/services/file_scan.py +32 -15
  32. pangea/services/intel.py +157 -32
  33. pangea/services/redact.py +152 -4
  34. pangea/services/sanitize.py +388 -0
  35. pangea/services/share/share.py +683 -107
  36. pangea/services/vault/models/asymmetric.py +120 -18
  37. pangea/services/vault/models/common.py +439 -141
  38. pangea/services/vault/models/keys.py +94 -0
  39. pangea/services/vault/models/secret.py +27 -3
  40. pangea/services/vault/models/symmetric.py +68 -22
  41. pangea/services/vault/vault.py +1690 -749
  42. pangea/tools.py +6 -7
  43. pangea/utils.py +16 -27
  44. pangea/verify_audit.py +270 -83
  45. {pangea_sdk-3.8.0b1.dist-info → pangea_sdk-5.3.0.dist-info}/METADATA +43 -35
  46. pangea_sdk-5.3.0.dist-info/RECORD +56 -0
  47. {pangea_sdk-3.8.0b1.dist-info → pangea_sdk-5.3.0.dist-info}/WHEEL +1 -1
  48. pangea_sdk-3.8.0b1.dist-info/RECORD +0 -50
pangea/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "3.8.0beta1"
1
+ __version__ = "5.3.0"
2
2
 
3
3
  from pangea.asyncio.request import PangeaRequestAsync
4
4
  from pangea.config import PangeaConfig
@@ -28,7 +28,7 @@ class FileUploaderAsync:
28
28
  ) -> None:
29
29
  if transfer_method == TransferMethod.PUT_URL:
30
30
  files = [("file", ("filename", file, "application/octet-stream"))]
31
- await self._request.put_presigned_url(url=url, files=files) # type: ignore[arg-type]
31
+ await self._request.put_presigned_url(url=url, files=files)
32
32
  elif transfer_method == TransferMethod.POST_URL:
33
33
  files = [("file", ("filename", file, "application/octet-stream"))]
34
34
  await self._request.post_presigned_url(url=url, data=file_details, files=files) # type: ignore[arg-type]
pangea/asyncio/request.py CHANGED
@@ -1,20 +1,24 @@
1
1
  # Copyright 2022 Pangea Cyber Corporation
2
2
  # Author: Pangea Cyber Corporation
3
+ from __future__ import annotations
3
4
 
4
5
  import asyncio
5
6
  import json
6
- import os
7
7
  import time
8
- from typing import Dict, List, Optional, Tuple, Type, Union
8
+ from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
9
9
 
10
10
  import aiohttp
11
11
  from aiohttp import FormData
12
+ from pydantic import BaseModel
13
+ from typing_extensions import Any, TypeVar
12
14
 
13
15
  import pangea.exceptions as pe
14
16
  from pangea.request import MultipartResponse, PangeaRequestBase
15
17
  from pangea.response import AttachedFile, PangeaResponse, PangeaResponseResult, ResponseStatus, TransferMethod
16
18
  from pangea.utils import default_encoder
17
19
 
20
+ TResult = TypeVar("TResult", bound=PangeaResponseResult)
21
+
18
22
 
19
23
  class PangeaRequestAsync(PangeaRequestBase):
20
24
  """An object that makes direct calls to Pangea Service APIs.
@@ -28,12 +32,12 @@ class PangeaRequestAsync(PangeaRequestBase):
28
32
  async def post(
29
33
  self,
30
34
  endpoint: str,
31
- result_class: Type[PangeaResponseResult],
32
- data: Union[str, Dict] = {},
33
- files: List[Tuple] = [],
35
+ result_class: Type[TResult],
36
+ data: str | BaseModel | dict[str, Any] | None = None,
37
+ files: Optional[List[Tuple]] = None,
34
38
  poll_result: bool = True,
35
39
  url: Optional[str] = None,
36
- ) -> PangeaResponse:
40
+ ) -> PangeaResponse[TResult]:
37
41
  """Makes the POST call to a Pangea Service endpoint.
38
42
 
39
43
  Args:
@@ -44,6 +48,13 @@ class PangeaRequestAsync(PangeaRequestBase):
44
48
  PangeaResponse which contains the response in its entirety and
45
49
  various properties to retrieve individual fields
46
50
  """
51
+
52
+ if isinstance(data, BaseModel):
53
+ data = data.model_dump(exclude_none=True)
54
+
55
+ if data is None:
56
+ data = {}
57
+
47
58
  if url is None:
48
59
  url = self._url(endpoint)
49
60
 
@@ -91,9 +102,7 @@ class PangeaRequestAsync(PangeaRequestBase):
91
102
 
92
103
  return self._check_response(pangea_response)
93
104
 
94
- async def get(
95
- self, path: str, result_class: Type[PangeaResponseResult], check_response: bool = True
96
- ) -> PangeaResponse[Type[PangeaResponseResult]]:
105
+ async def get(self, path: str, result_class: Type[TResult], check_response: bool = True) -> PangeaResponse[TResult]:
97
106
  """Makes the GET call to a Pangea Service endpoint.
98
107
 
99
108
  Args:
@@ -110,7 +119,7 @@ class PangeaRequestAsync(PangeaRequestBase):
110
119
 
111
120
  async with self.session.get(url, headers=self._headers()) as requests_response:
112
121
  await self._check_http_errors(requests_response)
113
- pangea_response = PangeaResponse( # type: ignore[var-annotated]
122
+ pangea_response = PangeaResponse(
114
123
  requests_response, result_class=result_class, json=await requests_response.json()
115
124
  )
116
125
 
@@ -131,11 +140,11 @@ class PangeaRequestAsync(PangeaRequestBase):
131
140
  raise pe.ServiceTemporarilyUnavailable(await resp.json())
132
141
 
133
142
  async def poll_result_by_id(
134
- self, request_id: str, result_class: Union[Type[PangeaResponseResult], Type[dict]], check_response: bool = True
135
- ):
143
+ self, request_id: str, result_class: Type[TResult], check_response: bool = True
144
+ ) -> PangeaResponse[TResult]:
136
145
  path = self._get_poll_path(request_id)
137
146
  self.logger.debug(json.dumps({"service": self.service, "action": "poll_result_once", "url": path}))
138
- return await self.get(path, result_class, check_response=check_response) # type: ignore[arg-type]
147
+ return await self.get(path, result_class, check_response=check_response)
139
148
 
140
149
  async def poll_result_once(self, response: PangeaResponse, check_response: bool = True):
141
150
  request_id = response.request_id
@@ -160,7 +169,7 @@ class PangeaRequestAsync(PangeaRequestBase):
160
169
  if resp.status < 200 or resp.status >= 300:
161
170
  raise pe.PresignedUploadError(f"presigned POST failure: {resp.status}", await resp.text())
162
171
 
163
- async def put_presigned_url(self, url: str, files: List[Tuple]):
172
+ async def put_presigned_url(self, url: str, files: Sequence[Tuple]):
164
173
  # Send put request with file as body
165
174
  resp = await self._http_put(url=url, files=files)
166
175
  self.logger.debug(
@@ -173,7 +182,20 @@ class PangeaRequestAsync(PangeaRequestBase):
173
182
  if resp.status < 200 or resp.status >= 300:
174
183
  raise pe.PresignedUploadError(f"presigned PUT failure: {resp.status}", await resp.text())
175
184
 
176
- async def download_file(self, url: str, filename: Optional[str] = None) -> AttachedFile:
185
+ async def download_file(self, url: str, filename: str | None = None) -> AttachedFile:
186
+ """
187
+ Download file
188
+
189
+ Download a file from the specified URL and save it with the given
190
+ filename.
191
+
192
+ Args:
193
+ url: URL of the file to download
194
+ filename: Name to save the downloaded file as. If not provided, the
195
+ filename will be determined from the Content-Disposition header or
196
+ the URL.
197
+ """
198
+
177
199
  self.logger.debug(
178
200
  json.dumps(
179
201
  {
@@ -209,16 +231,16 @@ class PangeaRequestAsync(PangeaRequestBase):
209
231
  )
210
232
 
211
233
  return AttachedFile(filename=filename, file=await response.read(), content_type=content_type)
212
- else:
213
- raise pe.DownloadFileError(f"Failed to download file. Status: {response.status}", await response.text())
234
+ raise pe.DownloadFileError(f"Failed to download file. Status: {response.status}", await response.text())
214
235
 
215
- async def _get_pangea_json(self, reader: aiohttp.MultipartReader) -> Optional[Dict]:
236
+ async def _get_pangea_json(self, reader: aiohttp.multipart.MultipartResponseWrapper) -> Optional[Dict[str, Any]]:
216
237
  # Iterate through parts
217
238
  async for part in reader:
218
- return await part.json()
239
+ if isinstance(part, aiohttp.BodyPartReader):
240
+ return await part.json()
219
241
  return None
220
242
 
221
- async def _get_attached_files(self, reader: aiohttp.MultipartReader) -> List[AttachedFile]:
243
+ async def _get_attached_files(self, reader: aiohttp.multipart.MultipartResponseWrapper) -> List[AttachedFile]:
222
244
  files = []
223
245
  i = 0
224
246
 
@@ -229,7 +251,7 @@ class PangeaRequestAsync(PangeaRequestBase):
229
251
  if name is None:
230
252
  name = f"default_file_name_{i}"
231
253
  i += 1
232
- files.append(AttachedFile(name, await part.read(), content_type))
254
+ files.append(AttachedFile(name, await part.read(), content_type)) # type: ignore[union-attr]
233
255
 
234
256
  return files
235
257
 
@@ -237,13 +259,12 @@ class PangeaRequestAsync(PangeaRequestBase):
237
259
  # Parse the multipart response
238
260
  multipart_reader = aiohttp.MultipartReader.from_response(resp)
239
261
 
240
- pangea_json = await self._get_pangea_json(multipart_reader) # type: ignore[arg-type]
262
+ pangea_json = await self._get_pangea_json(multipart_reader)
241
263
  self.logger.debug(
242
264
  json.dumps({"service": self.service, "action": "multipart response", "response": pangea_json})
243
265
  )
244
266
 
245
- multipart_reader = multipart_reader.__aiter__()
246
- attached_files = await self._get_attached_files(multipart_reader) # type: ignore[arg-type]
267
+ attached_files = await self._get_attached_files(multipart_reader)
247
268
  return MultipartResponse(pangea_json, attached_files) # type: ignore[arg-type]
248
269
 
249
270
  async def _http_post(
@@ -251,7 +272,7 @@ class PangeaRequestAsync(PangeaRequestBase):
251
272
  url: str,
252
273
  headers: Dict = {},
253
274
  data: Union[str, Dict] = {},
254
- files: List[Tuple] = [],
275
+ files: Optional[List[Tuple]] = [],
255
276
  presigned_url_post: bool = False,
256
277
  ) -> aiohttp.ClientResponse:
257
278
  if files:
@@ -276,7 +297,7 @@ class PangeaRequestAsync(PangeaRequestBase):
276
297
  async def _http_put(
277
298
  self,
278
299
  url: str,
279
- files: List[Tuple],
300
+ files: Sequence[Tuple],
280
301
  headers: Dict = {},
281
302
  ) -> aiohttp.ClientResponse:
282
303
  self.logger.debug(
@@ -328,9 +349,7 @@ class PangeaRequestAsync(PangeaRequestBase):
328
349
  # Receive 202
329
350
  return await self._poll_presigned_url(accepted_exception.response)
330
351
 
331
- async def _poll_presigned_url(
332
- self, response: PangeaResponse[Type[PangeaResponseResult]]
333
- ) -> PangeaResponse[Type[PangeaResponseResult]]:
352
+ async def _poll_presigned_url(self, response: PangeaResponse[TResult]) -> PangeaResponse[TResult]:
334
353
  if response.http_status != 202:
335
354
  raise AttributeError("Response should be 202")
336
355
 
@@ -373,8 +392,7 @@ class PangeaRequestAsync(PangeaRequestBase):
373
392
 
374
393
  if loop_resp.accepted_result is not None and not loop_resp.accepted_result.has_upload_url:
375
394
  return loop_resp
376
- else:
377
- raise loop_exc
395
+ raise loop_exc
378
396
 
379
397
  async def _handle_queued_result(self, response: PangeaResponse) -> PangeaResponse:
380
398
  if self._queued_retry_enabled and response.http_status == 202:
@@ -1,8 +1,10 @@
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
6
7
  from .redact import RedactAsync
8
+ from .sanitize import SanitizeAsync
7
9
  from .share import ShareAsync
8
10
  from .vault import VaultAsync
@@ -1,11 +1,14 @@
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, Iterable, List, Optional, Sequence, Union
5
7
 
6
8
  import pangea.exceptions as pexc
7
9
  from pangea.asyncio.services.base import ServiceBaseAsync
8
- from pangea.response import PangeaResponse
10
+ from pangea.config import PangeaConfig
11
+ from pangea.response import PangeaResponse, PangeaResponseResult
9
12
  from pangea.services.audit.audit import AuditBase
10
13
  from pangea.services.audit.exceptions import AuditException
11
14
  from pangea.services.audit.models import (
@@ -13,6 +16,7 @@ from pangea.services.audit.models import (
13
16
  DownloadRequest,
14
17
  DownloadResult,
15
18
  Event,
19
+ ExportRequest,
16
20
  LogBulkResult,
17
21
  LogResult,
18
22
  PublishedRoot,
@@ -56,14 +60,33 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
56
60
 
57
61
  def __init__(
58
62
  self,
59
- token,
60
- config=None,
63
+ token: str,
64
+ config: PangeaConfig | None = None,
61
65
  private_key_file: str = "",
62
- public_key_info: Dict[str, str] = {},
63
- tenant_id: Optional[str] = None,
64
- logger_name="pangea",
65
- config_id: Optional[str] = None,
66
- ):
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
+
67
90
  # FIXME: Temporary check to deprecate config_id from PangeaConfig.
68
91
  # Delete it when deprecate PangeaConfig.config_id
69
92
  if config_id and config is not None and config.config_id is not None:
@@ -151,14 +174,16 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
151
174
  verbose: Optional[bool] = None,
152
175
  ) -> PangeaResponse[LogResult]:
153
176
  """
154
- Log an entry
177
+ Log an event
155
178
 
156
179
  Create a log entry in the Secure Audit Log.
180
+
157
181
  Args:
158
182
  event (dict[str, Any]): event to be logged
159
183
  verify (bool, optional): True to verify logs consistency after response.
160
184
  sign_local (bool, optional): True to sign event with local key.
161
185
  verbose (bool, optional): True to get a more verbose response.
186
+
162
187
  Raises:
163
188
  AuditException: If an audit based api exception happens
164
189
  PangeaAPIException: If an API Error happens
@@ -169,18 +194,12 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
169
194
  Available response fields can be found in our [API documentation](https://pangea.cloud/docs/api/audit#log-an-entry).
170
195
 
171
196
  Examples:
172
- try:
173
- log_response = audit.log({"message"="Hello world"}, verbose=False)
174
- print(f"Response. Hash: {log_response.result.hash}")
175
- except pe.PangeaAPIException as e:
176
- print(f"Request Error: {e.response.summary}")
177
- for err in e.errors:
178
- print(f"\\t{err.detail} \\n")
197
+ response = await audit.log_event({"message": "hello world"}, verbose=True)
179
198
  """
180
199
 
181
200
  input = self._get_log_request(event, sign_local=sign_local, verify=verify, verbose=verbose)
182
201
  response: PangeaResponse[LogResult] = await self.request.post(
183
- "v1/log", LogResult, data=input.dict(exclude_none=True)
202
+ "v1/log", LogResult, data=input.model_dump(exclude_none=True)
184
203
  )
185
204
  if response.success and response.result is not None:
186
205
  self._process_log_result(response.result, verify=verify)
@@ -216,7 +235,7 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
216
235
 
217
236
  input = self._get_log_request(events, sign_local=sign_local, verify=False, verbose=verbose)
218
237
  response: PangeaResponse[LogBulkResult] = await self.request.post(
219
- "v2/log", LogBulkResult, data=input.dict(exclude_none=True)
238
+ "v2/log", LogBulkResult, data=input.model_dump(exclude_none=True)
220
239
  )
221
240
  if response.success and response.result is not None:
222
241
  for result in response.result.results:
@@ -254,7 +273,7 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
254
273
  input = self._get_log_request(events, sign_local=sign_local, verify=False, verbose=verbose)
255
274
  try:
256
275
  response: PangeaResponse[LogBulkResult] = await self.request.post(
257
- "v2/log_async", LogBulkResult, data=input.dict(exclude_none=True), poll_result=False
276
+ "v2/log_async", LogBulkResult, data=input.model_dump(exclude_none=True), poll_result=False
258
277
  )
259
278
  except pexc.AcceptedRequestException as e:
260
279
  return e.response
@@ -272,10 +291,11 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
272
291
  end: Optional[Union[datetime.datetime, str]] = None,
273
292
  limit: Optional[int] = None,
274
293
  max_results: Optional[int] = None,
275
- search_restriction: Optional[dict] = None,
294
+ search_restriction: Optional[Dict[str, Sequence[str]]] = None,
276
295
  verbose: Optional[bool] = None,
277
296
  verify_consistency: bool = False,
278
297
  verify_events: bool = True,
298
+ return_context: Optional[bool] = None,
279
299
  ) -> PangeaResponse[SearchOutput]:
280
300
  """
281
301
  Search the log
@@ -301,10 +321,11 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
301
321
  end (datetime, optional): An RFC-3339 formatted timestamp, or relative time adjustment from the current time.
302
322
  limit (int, optional): Optional[int] = None,
303
323
  max_results (int, optional): Maximum number of results to return.
304
- search_restriction (dict, optional): A list of keys to restrict the search results to. Useful for partitioning data available to the query string.
324
+ 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.
305
325
  verbose (bool, optional): If true, response include root and membership and consistency proofs.
306
326
  verify_consistency (bool): True to verify logs consistency
307
327
  verify_events (bool): True to verify hash events and signatures
328
+ return_context (bool): Return the context data needed to decrypt secure audit events that have been redacted with format preserving encryption.
308
329
 
309
330
  Raises:
310
331
  AuditException: If an audit based api exception happens
@@ -332,10 +353,11 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
332
353
  max_results=max_results,
333
354
  search_restriction=search_restriction,
334
355
  verbose=verbose,
356
+ return_context=return_context,
335
357
  )
336
358
 
337
359
  response: PangeaResponse[SearchOutput] = await self.request.post(
338
- "v1/search", SearchOutput, data=input.dict(exclude_none=True)
360
+ "v1/search", SearchOutput, data=input.model_dump(exclude_none=True)
339
361
  )
340
362
  if verify_consistency:
341
363
  await self.update_published_roots(response.result) # type: ignore[arg-type]
@@ -347,8 +369,10 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
347
369
  id: str,
348
370
  limit: Optional[int] = 20,
349
371
  offset: Optional[int] = 0,
372
+ assert_search_restriction: Optional[Dict[str, Sequence[str]]] = None,
350
373
  verify_consistency: bool = False,
351
374
  verify_events: bool = True,
375
+ return_context: Optional[bool] = None,
352
376
  ) -> PangeaResponse[SearchResultOutput]:
353
377
  """
354
378
  Results of a search
@@ -361,8 +385,11 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
361
385
  id (string): the id of a search action, found in `response.result.id`
362
386
  limit (integer, optional): the maximum number of results to return, default is 20
363
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.
364
389
  verify_consistency (bool): True to verify logs consistency
365
390
  verify_events (bool): True to verify hash events and signatures
391
+ return_context (bool): Return the context data needed to decrypt secure audit events that have been redacted with format preserving encryption.
392
+
366
393
  Raises:
367
394
  AuditException: If an audit based api exception happens
368
395
  PangeaAPIException: If an API Error happens
@@ -378,7 +405,8 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
378
405
  result_res: PangeaResponse[SearchResultsOutput] = audit.results(
379
406
  id=search_res.result.id,
380
407
  limit=10,
381
- offset=0)
408
+ offset=0,
409
+ assert_search_restriction={'source': ["monitor"]})
382
410
  """
383
411
 
384
412
  if limit <= 0: # type: ignore[operator]
@@ -391,13 +419,116 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
391
419
  id=id,
392
420
  limit=limit,
393
421
  offset=offset,
422
+ assert_search_restriction=assert_search_restriction,
423
+ return_context=return_context,
394
424
  )
395
- response = await self.request.post("v1/results", SearchResultOutput, data=input.dict(exclude_none=True))
425
+ response = await self.request.post("v1/results", SearchResultOutput, data=input.model_dump(exclude_none=True))
396
426
  if verify_consistency and response.result is not None:
397
427
  await self.update_published_roots(response.result)
398
428
 
399
429
  return self.handle_results_response(response, verify_consistency, verify_events)
400
430
 
431
+ async def export(
432
+ self,
433
+ *,
434
+ format: DownloadFormat = DownloadFormat.CSV,
435
+ start: Optional[datetime.datetime] = None,
436
+ end: Optional[datetime.datetime] = None,
437
+ order: Optional[SearchOrder] = None,
438
+ order_by: Optional[str] = None,
439
+ verbose: bool = True,
440
+ ) -> PangeaResponse[PangeaResponseResult]:
441
+ """
442
+ Export from the audit log
443
+
444
+ Bulk export of data from the Secure Audit Log, with optional filtering.
445
+
446
+ OperationId: audit_post_v1_export
447
+
448
+ Args:
449
+ format: Format for the records.
450
+ start: The start of the time range to perform the search on.
451
+ end: The end of the time range to perform the search on. If omitted,
452
+ then all records up to the latest will be searched.
453
+ order: Specify the sort order of the response.
454
+ order_by: Name of column to sort the results by.
455
+ verbose: Whether or not to include the root hash of the tree and the
456
+ membership proof for each record.
457
+
458
+ Raises:
459
+ AuditException: If an audit based api exception happens
460
+ PangeaAPIException: If an API Error happens
461
+
462
+ Examples:
463
+ export_res = await audit.export(verbose=False)
464
+
465
+ # Export may take several dozens of minutes, so polling for the result
466
+ # should be done in a loop. That is omitted here for brevity's sake.
467
+ try:
468
+ await audit.poll_result(request_id=export_res.request_id)
469
+ except AcceptedRequestException:
470
+ # Retry later.
471
+
472
+ # Download the result when it's ready.
473
+ download_res = await audit.download_results(request_id=export_res.request_id)
474
+ download_res.result.dest_url
475
+ # => https://pangea-runtime.s3.amazonaws.com/audit/xxxxx/search_results_[...]
476
+ """
477
+ input = ExportRequest(
478
+ format=format,
479
+ start=start,
480
+ end=end,
481
+ order=order,
482
+ order_by=order_by,
483
+ verbose=verbose,
484
+ )
485
+ try:
486
+ return await self.request.post(
487
+ "v1/export", PangeaResponseResult, data=input.model_dump(exclude_none=True), poll_result=False
488
+ )
489
+ except pexc.AcceptedRequestException as e:
490
+ return e.response
491
+
492
+ async def log_stream(self, data: dict) -> PangeaResponse[PangeaResponseResult]:
493
+ """
494
+ Log streaming endpoint
495
+
496
+ This API allows 3rd party vendors (like Auth0) to stream events to this
497
+ endpoint where the structure of the payload varies across different
498
+ vendors.
499
+
500
+ OperationId: audit_post_v1_log_stream
501
+
502
+ Args:
503
+ data: Event data. The exact schema of this will vary by vendor.
504
+
505
+ Raises:
506
+ AuditException: If an audit based api exception happens
507
+ PangeaAPIException: If an API Error happens
508
+
509
+ Examples:
510
+ data = {
511
+ "logs": [
512
+ {
513
+ "log_id": "some log ID",
514
+ "data": {
515
+ "date": "2024-03-29T17:26:50.193Z",
516
+ "type": "sapi",
517
+ "description": "Create a log stream",
518
+ "client_id": "some client ID",
519
+ "ip": "127.0.0.1",
520
+ "user_agent": "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0",
521
+ "user_id": "some user ID",
522
+ },
523
+ }
524
+ # ...
525
+ ]
526
+ }
527
+
528
+ response = await audit.log_stream(data)
529
+ """
530
+ return await self.request.post("v1/log_stream", PangeaResponseResult, data=data)
531
+
401
532
  async def root(self, tree_size: Optional[int] = None) -> PangeaResponse[RootResult]:
402
533
  """
403
534
  Tamperproof verification
@@ -420,10 +551,14 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
420
551
  response = audit.root(tree_size=7)
421
552
  """
422
553
  input = RootRequest(tree_size=tree_size)
423
- return await self.request.post("v1/root", RootResult, data=input.dict(exclude_none=True))
554
+ return await self.request.post("v1/root", RootResult, data=input.model_dump(exclude_none=True))
424
555
 
425
556
  async def download_results(
426
- self, result_id: str, format: Optional[DownloadFormat] = None
557
+ self,
558
+ result_id: Optional[str] = None,
559
+ format: DownloadFormat = DownloadFormat.CSV,
560
+ request_id: Optional[str] = None,
561
+ return_context: Optional[bool] = None,
427
562
  ) -> PangeaResponse[DownloadResult]:
428
563
  """
429
564
  Download search results
@@ -435,6 +570,8 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
435
570
  Args:
436
571
  result_id: ID returned by the search API.
437
572
  format: Format for the records.
573
+ request_id: ID returned by the export API.
574
+ return_context (bool): Return the context data needed to decrypt secure audit events that have been redacted with format preserving encryption.
438
575
 
439
576
  Returns:
440
577
  URL where search results can be downloaded.
@@ -450,8 +587,13 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
450
587
  )
451
588
  """
452
589
 
453
- input = DownloadRequest(result_id=result_id, format=format)
454
- return await self.request.post("v1/download_results", DownloadResult, data=input.dict(exclude_none=True))
590
+ if request_id is None and result_id is None:
591
+ raise ValueError("must pass one of `request_id` or `result_id`")
592
+
593
+ input = DownloadRequest(
594
+ request_id=request_id, result_id=result_id, format=format, return_context=return_context
595
+ )
596
+ return await self.request.post("v1/download_results", DownloadResult, data=input.model_dump(exclude_none=True))
455
597
 
456
598
  async def update_published_roots(self, result: SearchResultOutput):
457
599
  """Fetches series of published root hashes from Arweave
@@ -476,12 +618,31 @@ class AuditAsync(ServiceBaseAsync, AuditBase):
476
618
  for tree_size in tree_sizes:
477
619
  pub_root = None
478
620
  if tree_size in arweave_roots:
479
- pub_root = PublishedRoot(**arweave_roots[tree_size].dict(exclude_none=True))
621
+ pub_root = PublishedRoot(**arweave_roots[tree_size].model_dump(exclude_none=True))
480
622
  pub_root.source = RootSource.ARWEAVE
481
623
  elif self.allow_server_roots:
482
624
  resp = await self.root(tree_size=tree_size)
483
625
  if resp.success and resp.result is not None:
484
- pub_root = PublishedRoot(**resp.result.data.dict(exclude_none=True))
626
+ pub_root = PublishedRoot(**resp.result.data.model_dump(exclude_none=True))
485
627
  pub_root.source = RootSource.PANGEA
486
628
  if pub_root is not None:
487
629
  self.pub_roots[tree_size] = pub_root
630
+
631
+ await self._fix_consistency_proofs(tree_sizes)
632
+
633
+ async def _fix_consistency_proofs(self, tree_sizes: Iterable[int]) -> None:
634
+ # on very rare occasions, the consistency proof in Arweave may be wrong
635
+ # override it with the proof from pangea (not the root hash, just the proof)
636
+ for tree_size in tree_sizes:
637
+ if tree_size not in self.pub_roots or tree_size - 1 not in self.pub_roots:
638
+ continue
639
+
640
+ if self.pub_roots[tree_size].source == RootSource.PANGEA:
641
+ continue
642
+
643
+ if self.verify_consistency_proof(tree_size):
644
+ continue
645
+
646
+ resp = await self.root(tree_size=tree_size)
647
+ if resp.success and resp.result is not None and resp.result.data is not None:
648
+ self.pub_roots[tree_size].consistency_proof = resp.result.data.consistency_proof