arcticsecurity 0.1.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.
- arcticsecurity/__init__.py +0 -0
- arcticsecurity/py.typed +0 -0
- arcticsecurity/sharing_api/__init__.py +4 -0
- arcticsecurity/sharing_api/_api_client.py +376 -0
- arcticsecurity/sharing_api/_version.py +11 -0
- arcticsecurity/sharing_api/errors.py +84 -0
- arcticsecurity/sharing_api/sharing_api.py +349 -0
- arcticsecurity-0.1.0.dist-info/METADATA +116 -0
- arcticsecurity-0.1.0.dist-info/RECORD +11 -0
- arcticsecurity-0.1.0.dist-info/WHEEL +4 -0
- arcticsecurity-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
arcticsecurity/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sharing API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, Optional, Union
|
|
13
|
+
from urllib.parse import parse_qs, urlparse, urlunparse
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from . import _version
|
|
18
|
+
from .errors import (
|
|
19
|
+
ConfigError,
|
|
20
|
+
Error,
|
|
21
|
+
InvalidTokenError,
|
|
22
|
+
NetworkError,
|
|
23
|
+
Retry,
|
|
24
|
+
ServerError,
|
|
25
|
+
TimeoutError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Timeout:
|
|
32
|
+
def __init__(self, timeout: Optional[float]):
|
|
33
|
+
self._max_duration = timeout or 0
|
|
34
|
+
self._start_ts = time.monotonic()
|
|
35
|
+
|
|
36
|
+
def start(self) -> None:
|
|
37
|
+
self._start_ts = time.monotonic()
|
|
38
|
+
|
|
39
|
+
def stop(self) -> None:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def check(self) -> None:
|
|
43
|
+
if 0 < self._max_duration < time.monotonic() - self._start_ts:
|
|
44
|
+
raise TimeoutError("Query timed out")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Query:
|
|
49
|
+
"""Handle one 3-phase async query.
|
|
50
|
+
|
|
51
|
+
Check for timeout every time client is accessed.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_client: httpx.Client
|
|
55
|
+
timeout: Timeout
|
|
56
|
+
|
|
57
|
+
# Query details for introspection
|
|
58
|
+
post_url: Optional[httpx.URL] = None
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "Query":
|
|
61
|
+
self._client.__enter__()
|
|
62
|
+
self.timeout.start()
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(
|
|
66
|
+
self,
|
|
67
|
+
exc_type: Optional[type[BaseException]],
|
|
68
|
+
exc: Optional[BaseException],
|
|
69
|
+
tb: Optional[TracebackType],
|
|
70
|
+
) -> Any:
|
|
71
|
+
self.timeout.stop()
|
|
72
|
+
return self._client.__exit__(exc_type, exc, tb)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def client(self) -> httpx.Client:
|
|
76
|
+
self.timeout.check()
|
|
77
|
+
return self._client
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _ApiClient:
|
|
81
|
+
"""Sharing API client."""
|
|
82
|
+
|
|
83
|
+
# Allowed query parameters
|
|
84
|
+
allowed_params = {
|
|
85
|
+
"filter",
|
|
86
|
+
"projection",
|
|
87
|
+
"limit",
|
|
88
|
+
"token",
|
|
89
|
+
"start",
|
|
90
|
+
"end",
|
|
91
|
+
"sort",
|
|
92
|
+
"reverse",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
url: str,
|
|
98
|
+
*,
|
|
99
|
+
user_agent: Optional[str] = None,
|
|
100
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
101
|
+
sleep_before_first_status_query: float = 0.5,
|
|
102
|
+
sleep_after_50x_error_within_query: float = 10,
|
|
103
|
+
):
|
|
104
|
+
self.urls = _ShareUrls(url)
|
|
105
|
+
self.user_agent = user_agent or _version.user_agent
|
|
106
|
+
|
|
107
|
+
# Transport to use in httpx client. Should only be defined in testing
|
|
108
|
+
self.transport = transport
|
|
109
|
+
# Time to wait for before first GET status after POST query
|
|
110
|
+
self.sleep_before_first_status_query = sleep_before_first_status_query
|
|
111
|
+
# Time to wait after 50x response within a query. These are typically
|
|
112
|
+
# transitory errors on the server side
|
|
113
|
+
self.sleep_after_50x_error_within_query = sleep_after_50x_error_within_query
|
|
114
|
+
|
|
115
|
+
def _get_client(self) -> httpx.Client:
|
|
116
|
+
"""Initiaze new client."""
|
|
117
|
+
return httpx.Client(
|
|
118
|
+
base_url=self.urls.base_url,
|
|
119
|
+
follow_redirects=True,
|
|
120
|
+
timeout=60,
|
|
121
|
+
headers={
|
|
122
|
+
**self.urls.authorization_header,
|
|
123
|
+
"USER-AGENT": self.user_agent,
|
|
124
|
+
"ACCEPT-ENCODING": "gzip",
|
|
125
|
+
"ACCEPT": "application/json",
|
|
126
|
+
},
|
|
127
|
+
transport=self.transport,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def _init_query(self, timeout: Optional[float]) -> Query:
|
|
131
|
+
return Query(
|
|
132
|
+
self._get_client(),
|
|
133
|
+
Timeout(timeout),
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
def async_query(
|
|
137
|
+
self,
|
|
138
|
+
params: Optional[dict[str, Union[str, Sequence[str], int, float]]] = None,
|
|
139
|
+
timeout: Optional[float] = None,
|
|
140
|
+
) -> httpx.Response:
|
|
141
|
+
"""Execute 3-phase async query."""
|
|
142
|
+
qp = {**self.urls.qp, **(params or {})}
|
|
143
|
+
invalid_params = qp.keys() - self.allowed_params
|
|
144
|
+
if invalid_params:
|
|
145
|
+
raise ConfigError(f"Invalid query parameters: {invalid_params}")
|
|
146
|
+
|
|
147
|
+
with self._init_query(timeout) as query:
|
|
148
|
+
status_url = self._async_post_query(query, qp)
|
|
149
|
+
time.sleep(self.sleep_before_first_status_query)
|
|
150
|
+
result_url = self._async_get_result_url(query, status_url)
|
|
151
|
+
return self._async_get_result_response(query, result_url)
|
|
152
|
+
|
|
153
|
+
def _async_post_query(
|
|
154
|
+
self,
|
|
155
|
+
query: Query,
|
|
156
|
+
params: Optional[dict[str, Union[str, Sequence[str], int, float]]] = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""POST async query.
|
|
159
|
+
|
|
160
|
+
Returns status url.
|
|
161
|
+
"""
|
|
162
|
+
try:
|
|
163
|
+
response = query.client.post(url=self.urls.async_path, params=params)
|
|
164
|
+
query.post_url = response.request.url
|
|
165
|
+
except httpx.RequestError as error:
|
|
166
|
+
raise NetworkError(
|
|
167
|
+
f"Downloading {error.request.url} failed: {error}",
|
|
168
|
+
url=str(error.request.url),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if self._server_unavailable(response.status_code):
|
|
172
|
+
logger.debug(
|
|
173
|
+
f"Error posting job, retry later ({response.status_code} {response.text})"
|
|
174
|
+
)
|
|
175
|
+
# Server error on initial post -> suggest retrying whole query later again
|
|
176
|
+
raise Retry(
|
|
177
|
+
after=response.headers.get("Retry-After", 10), url=str(response.url)
|
|
178
|
+
)
|
|
179
|
+
elif (invalid_inputs := self._invalid_input_error(response)) is not None:
|
|
180
|
+
raise ConfigError(invalid_inputs, url=str(response.url))
|
|
181
|
+
elif response.status_code == 500:
|
|
182
|
+
raise ServerError(
|
|
183
|
+
f"Sharing API server error 500 for submit, {response.text}",
|
|
184
|
+
url=str(response.url),
|
|
185
|
+
)
|
|
186
|
+
elif response.status_code != 202:
|
|
187
|
+
raise NetworkError(
|
|
188
|
+
f"Unexpected status {response.status_code} for submit, {response.text}",
|
|
189
|
+
url=str(response.request.url),
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
return response.headers["Location"]
|
|
194
|
+
except KeyError:
|
|
195
|
+
raise Error("Location header missing from response", url=str(response.url))
|
|
196
|
+
|
|
197
|
+
def _async_get_result_url(self, query: Query, url: str) -> str:
|
|
198
|
+
"""GET async result url from status url."""
|
|
199
|
+
while True:
|
|
200
|
+
try:
|
|
201
|
+
response = query.client.get(url=url, follow_redirects=False)
|
|
202
|
+
except httpx.RequestError as error:
|
|
203
|
+
raise NetworkError(
|
|
204
|
+
f"Downloading {error.request.url} failed: {error}",
|
|
205
|
+
url=str(error.request.url),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if response.status_code == 302:
|
|
209
|
+
# results are ready
|
|
210
|
+
break
|
|
211
|
+
elif response.status_code == 202:
|
|
212
|
+
time.sleep(int(response.headers.get("Retry-After", 1)))
|
|
213
|
+
elif response.status_code == 500:
|
|
214
|
+
raise ServerError(
|
|
215
|
+
f"Sharing API server error 500 getting status, {response.text}",
|
|
216
|
+
url=str(response.url),
|
|
217
|
+
)
|
|
218
|
+
elif self._server_unavailable(response.status_code):
|
|
219
|
+
logger.debug(
|
|
220
|
+
f"Error getting status, try again after {self.sleep_after_50x_error_within_query} secs ({response.status_code} {response.text})"
|
|
221
|
+
)
|
|
222
|
+
time.sleep(self.sleep_after_50x_error_within_query)
|
|
223
|
+
elif response.status_code == 410:
|
|
224
|
+
if response.headers.get("X-STATUS") == "Job expired":
|
|
225
|
+
raise Error("The query has expired", url=str(response.url))
|
|
226
|
+
else:
|
|
227
|
+
raise Retry(
|
|
228
|
+
"Job no longer exists (may have become stale)",
|
|
229
|
+
url=str(response.url),
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
raise Retry(
|
|
233
|
+
f"Unexpected status {response.status_code} loading results, {response.text}",
|
|
234
|
+
url=str(response.url),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
return response.headers["Location"]
|
|
239
|
+
except KeyError:
|
|
240
|
+
raise Error("Location header missing from response", url=str(response.url))
|
|
241
|
+
|
|
242
|
+
def _async_get_result_response(self, query: Query, url: str) -> httpx.Response:
|
|
243
|
+
"""GET async result response."""
|
|
244
|
+
while True:
|
|
245
|
+
try:
|
|
246
|
+
response = query.client.get(url)
|
|
247
|
+
except httpx.RequestError as error:
|
|
248
|
+
raise NetworkError(
|
|
249
|
+
f"Downloading {error.request.url} failed: {error}",
|
|
250
|
+
url=str(error.request.url),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if response.status_code == 200:
|
|
254
|
+
break
|
|
255
|
+
elif response.status_code == 410:
|
|
256
|
+
if response.headers.get("X-STATUS") == "Job expired":
|
|
257
|
+
raise Error("The query has expired", url=str(response.url))
|
|
258
|
+
else:
|
|
259
|
+
raise Retry(
|
|
260
|
+
"Results have been fetched already", url=str(response.url)
|
|
261
|
+
)
|
|
262
|
+
elif response.status_code == 500:
|
|
263
|
+
raise ServerError(
|
|
264
|
+
f"Sharing API server error 500 fetching results, {response.text}",
|
|
265
|
+
url=str(response.url),
|
|
266
|
+
)
|
|
267
|
+
elif self._server_unavailable(response.status_code):
|
|
268
|
+
# sleep only 1 sec since results are stored only for a limited time
|
|
269
|
+
logger.debug(
|
|
270
|
+
f"Error getting results, try again after {self.sleep_after_50x_error_within_query} secs ({response.status_code} {response.text})"
|
|
271
|
+
)
|
|
272
|
+
time.sleep(self.sleep_after_50x_error_within_query)
|
|
273
|
+
elif self._is_invalid_token_error(response):
|
|
274
|
+
assert query.post_url is not None # for mypy
|
|
275
|
+
raise InvalidTokenError(
|
|
276
|
+
query.post_url.params.get("token"), url=str(response.url)
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
raise Retry(
|
|
280
|
+
f"Unexpected status {response.status_code} fetching results, {response.text}",
|
|
281
|
+
url=str(response.url),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
return response
|
|
285
|
+
|
|
286
|
+
@staticmethod
|
|
287
|
+
def _server_unavailable(status: int) -> bool:
|
|
288
|
+
"""Does status mean server is unavailable?
|
|
289
|
+
|
|
290
|
+
502 / 503 / 504 typically overloaded system. All of these should be
|
|
291
|
+
transient on a valid hub url.
|
|
292
|
+
"""
|
|
293
|
+
return status in (502, 503, 504)
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _is_invalid_token_error(response: httpx.Response) -> bool:
|
|
297
|
+
"""Does response contain "invalid token" error.
|
|
298
|
+
|
|
299
|
+
{"title": "400 Bad Request", "errors": [{"type": "storage", "key": "token", "message": "Invalid token: foo"}]}%
|
|
300
|
+
"""
|
|
301
|
+
if response.status_code != 400:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
for error in response.json().get("errors", ()):
|
|
306
|
+
if error.get("key") == "token" and error.get("message", "").startswith(
|
|
307
|
+
"Invalid token"
|
|
308
|
+
):
|
|
309
|
+
return True
|
|
310
|
+
except json.decoder.JSONDecodeError:
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _invalid_input_error(response: httpx.Response) -> Optional[Any]:
|
|
317
|
+
"""If response contains "invalid inputs" error, return it.
|
|
318
|
+
|
|
319
|
+
{'title': '400 Invalid input(s)', 'description': '2 invalid input(s)', 'errors': [{'key': 'start', 'type': 'query validation', 'message': "Invalid start: ['foo']"}, {'key': 'startt', 'type': 'query validation', 'message': 'Unknown parameter: startt'}]}
|
|
320
|
+
"""
|
|
321
|
+
if response.status_code != 400:
|
|
322
|
+
return None
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
d = response.json()
|
|
326
|
+
if d.get("title") == "400 Invalid input(s)":
|
|
327
|
+
return d.get("errors", [])
|
|
328
|
+
except json.decoder.JSONDecodeError:
|
|
329
|
+
return None
|
|
330
|
+
|
|
331
|
+
return None
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@dataclass
|
|
335
|
+
class _ShareUrls:
|
|
336
|
+
"""Share url handling."""
|
|
337
|
+
|
|
338
|
+
base_url: str
|
|
339
|
+
sync_path: str
|
|
340
|
+
async_path: str
|
|
341
|
+
authorization_header: dict[str, str]
|
|
342
|
+
qp: dict[str, list[str]]
|
|
343
|
+
|
|
344
|
+
def __init__(self, sync_url: str):
|
|
345
|
+
"""Build urls from a sync url.
|
|
346
|
+
|
|
347
|
+
The provided sync url must have apikey query parameter, which will be
|
|
348
|
+
separated from the url and used in the Authorization header on async api
|
|
349
|
+
queries. All the query parameters are parsed and saved in `qp`, this is
|
|
350
|
+
to allow merging them with the parameters provided in the url.
|
|
351
|
+
(by default httpx overrides qp's in url)
|
|
352
|
+
|
|
353
|
+
>>> _ShareUrls("https://example.com/shares/v2/share-id?apikey=api-key&filter=foo=bar")
|
|
354
|
+
_ShareUrls(base_url='https://example.com', sync_path='/shares/v2/share-id', async_path='/shares/v2/async/share-id', authorization_header={'Authorization': 'token api-key'}, qp={'filter': ['foo=bar']})
|
|
355
|
+
>>> str(_ShareUrls("https://example.com/shares/v2/share-id?apikey=api-key&filter=foo=bar"))
|
|
356
|
+
'https://example.com//shares/v2/async/share-id'
|
|
357
|
+
>>> _ShareUrls("https://example.com/shares/v2/share-id?filter=foo=bar")
|
|
358
|
+
Traceback (most recent call last):
|
|
359
|
+
arcticsecurity.sharing_api.errors.ConfigError: API share url must have apikey parameter
|
|
360
|
+
"""
|
|
361
|
+
o = urlparse(sync_url)
|
|
362
|
+
qp = parse_qs(o.query, keep_blank_values=True)
|
|
363
|
+
|
|
364
|
+
if "apikey" not in qp:
|
|
365
|
+
raise ConfigError("API share url must have apikey parameter")
|
|
366
|
+
elif len(qp["apikey"]) > 1:
|
|
367
|
+
raise ConfigError("API share url must have exactly one apikey parameter")
|
|
368
|
+
|
|
369
|
+
self.base_url = urlunparse(o._replace(path="", query=""))
|
|
370
|
+
self.sync_path = o.path
|
|
371
|
+
self.async_path = re.sub(r"^/shares(/v2)?", "/shares/v2/async", o.path)
|
|
372
|
+
self.authorization_header = {"Authorization": f"token {qp.pop('apikey')[0]}"}
|
|
373
|
+
self.qp = qp
|
|
374
|
+
|
|
375
|
+
def __str__(self) -> str:
|
|
376
|
+
return f"{self.base_url}/{self.async_path}"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Library version info.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import platform as platform_library
|
|
6
|
+
|
|
7
|
+
library_name = "sharing-api"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
platform = platform_library.platform()
|
|
10
|
+
python_version = platform_library.python_version()
|
|
11
|
+
user_agent = f"{library_name}-v{version}-{platform}-py{python_version}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sharing API errors
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional, Union
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Error",
|
|
9
|
+
"ConfigError",
|
|
10
|
+
"InvalidTokenError",
|
|
11
|
+
"ServerError",
|
|
12
|
+
"NetworkError",
|
|
13
|
+
"TimeoutError",
|
|
14
|
+
"Retry",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Error(Exception):
|
|
19
|
+
"""Base class for all errors."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, *args: Any, url: Optional[str] = None, **kwargs: Any):
|
|
22
|
+
self.url = url
|
|
23
|
+
super().__init__(*args, **kwargs)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(Error):
|
|
27
|
+
"""User error in query configuration.
|
|
28
|
+
|
|
29
|
+
This error may be raised by this library or by the server.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InvalidTokenError(Error):
|
|
36
|
+
"""The server responded with "invalid token" error."""
|
|
37
|
+
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ServerError(Error):
|
|
42
|
+
"""The server responded with a error message.
|
|
43
|
+
|
|
44
|
+
This results from a server which doesn't operate properly at the moment.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NetworkError(Error):
|
|
51
|
+
"""Error communicating with the sharing API server.
|
|
52
|
+
|
|
53
|
+
This results from error communicating the with the sharing API server.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class TimeoutError(Error):
|
|
60
|
+
"""Query to sharing API server timed out.
|
|
61
|
+
|
|
62
|
+
The three-phase query to the sharing API exceeded the configured timeout.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Retry(NetworkError):
|
|
69
|
+
"""Network error that should be retried.
|
|
70
|
+
|
|
71
|
+
This is raised from errors that are likely transient and may succeed if retried
|
|
72
|
+
after some time.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *args: Any, after: Union[str, int, None] = None, **kwargs: Any):
|
|
76
|
+
super().__init__(*args, **kwargs)
|
|
77
|
+
|
|
78
|
+
if after is None:
|
|
79
|
+
self.after = None
|
|
80
|
+
else:
|
|
81
|
+
try:
|
|
82
|
+
self.after = int(after)
|
|
83
|
+
except ValueError:
|
|
84
|
+
self.after = None
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sharing API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Iterable, Iterator
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Optional, Union
|
|
12
|
+
|
|
13
|
+
from ._api_client import _ApiClient
|
|
14
|
+
from .errors import ConfigError, ServerError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
Event = dict[str, Union[str, list[str]]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class SyncReadResponse:
|
|
22
|
+
events: list[Event]
|
|
23
|
+
token: Optional[str]
|
|
24
|
+
has_more: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Sync:
|
|
28
|
+
"""Sync events from sharing API."""
|
|
29
|
+
|
|
30
|
+
allowed_user_provided_qps = {
|
|
31
|
+
"filter",
|
|
32
|
+
"projection",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
url: str,
|
|
38
|
+
*,
|
|
39
|
+
filter: Optional[str] = None,
|
|
40
|
+
projection: Optional[Iterable[str]] = None,
|
|
41
|
+
start: Union[datetime, int, float, None] = None,
|
|
42
|
+
**kwargs: Any,
|
|
43
|
+
):
|
|
44
|
+
"""
|
|
45
|
+
Initialize Sync class.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
url: Sharing API url, must include `apikey` query parameter.
|
|
49
|
+
filter: Rulelang filter.
|
|
50
|
+
projection: List of event field names to include in the results. Note that the list of keys provided by the server can only be limited by this parameter.
|
|
51
|
+
start: Start time for the initial query. Can also be set with seek(). Default value `None` means current time. Positive numbers are interpret as epoch time. Non-positive numbers are interpret as that many seconds in the past.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
# Check args
|
|
55
|
+
if not isinstance(url, str):
|
|
56
|
+
raise TypeError(f"url must be string not {type(url)}")
|
|
57
|
+
|
|
58
|
+
if not (filter is None or isinstance(filter, str)):
|
|
59
|
+
raise TypeError(f"filter must be string or None, not {type(filter)}")
|
|
60
|
+
|
|
61
|
+
if not (
|
|
62
|
+
projection is None
|
|
63
|
+
or (
|
|
64
|
+
isinstance(projection, Iterable)
|
|
65
|
+
and not isinstance(projection, str)
|
|
66
|
+
and all(isinstance(x, str) for x in projection)
|
|
67
|
+
)
|
|
68
|
+
):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
"projection must be a list of key names, each an instance of str"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
user_agent = kwargs.pop("user_agent", None)
|
|
74
|
+
if not (user_agent is None or isinstance(user_agent, str)):
|
|
75
|
+
raise TypeError(
|
|
76
|
+
f"user_agent must be string or None, not {type(user_agent)}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if kwargs:
|
|
80
|
+
raise ValueError(f"Unknown parameter(s) {tuple(kwargs.keys())}")
|
|
81
|
+
|
|
82
|
+
# Initialize client
|
|
83
|
+
self.api_client = _ApiClient(url, user_agent=user_agent)
|
|
84
|
+
|
|
85
|
+
invalid_qps_in_url = (
|
|
86
|
+
self.api_client.urls.qp.keys() - self.allowed_user_provided_qps
|
|
87
|
+
)
|
|
88
|
+
if invalid_qps_in_url:
|
|
89
|
+
raise ConfigError(f"Invalid qps in url: {invalid_qps_in_url}")
|
|
90
|
+
|
|
91
|
+
self.qp = {
|
|
92
|
+
"filter": filter,
|
|
93
|
+
"projection": projection,
|
|
94
|
+
"sort": "_id",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
self.start = self._seek(start)
|
|
98
|
+
|
|
99
|
+
def read(
|
|
100
|
+
self,
|
|
101
|
+
*,
|
|
102
|
+
token: Optional[str] = None,
|
|
103
|
+
pagesize: int = 1000,
|
|
104
|
+
timeout: Optional[float] = 600,
|
|
105
|
+
) -> SyncReadResponse:
|
|
106
|
+
"""Sync events from sharing API
|
|
107
|
+
|
|
108
|
+
Events are returned sorted by insertion time.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
token: Continuation token. If not given, the start time set earlier is used.
|
|
112
|
+
pagesize: Maximum number of events to return.
|
|
113
|
+
timeout: Timeout for the operation in [s].
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Batch of events and a continuation token for the next query.
|
|
117
|
+
"""
|
|
118
|
+
if not (token is None or isinstance(token, str)):
|
|
119
|
+
raise TypeError(f"token must be string or None, not {type(token)}")
|
|
120
|
+
|
|
121
|
+
if not isinstance(pagesize, int):
|
|
122
|
+
raise TypeError(f"pagesize must be int not {type(pagesize)}")
|
|
123
|
+
|
|
124
|
+
if not (timeout is None or isinstance(timeout, (int, float))):
|
|
125
|
+
raise TypeError(f"timeout must be float or None, not {type(timeout)}")
|
|
126
|
+
|
|
127
|
+
qp = _remove_none_values(
|
|
128
|
+
{
|
|
129
|
+
**self.qp,
|
|
130
|
+
**{
|
|
131
|
+
"token": token,
|
|
132
|
+
"limit": pagesize if pagesize != 0 else None,
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
if "token" not in qp:
|
|
138
|
+
qp["start"] = self.start
|
|
139
|
+
|
|
140
|
+
resp = self.api_client.async_query(qp, timeout=timeout)
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
token = resp.headers["x-next-token"]
|
|
144
|
+
has_more = True
|
|
145
|
+
except KeyError:
|
|
146
|
+
token = resp.headers.get("x-last-inserted-token", None)
|
|
147
|
+
has_more = False
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
return SyncReadResponse(
|
|
151
|
+
resp.json(),
|
|
152
|
+
token,
|
|
153
|
+
has_more,
|
|
154
|
+
)
|
|
155
|
+
except json.decoder.JSONDecodeError:
|
|
156
|
+
logger.error(f"Invalid response from server {resp.content=}")
|
|
157
|
+
raise ServerError(f"Invalid response from server {resp}")
|
|
158
|
+
|
|
159
|
+
def seek(self, ts: Union[datetime, int, float, None]) -> None:
|
|
160
|
+
"""Set sync start to specific time.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
ts: See start in __init__.
|
|
164
|
+
"""
|
|
165
|
+
self.start = self._seek(ts)
|
|
166
|
+
|
|
167
|
+
def _seek(self, ts: Union[datetime, int, float, None]) -> float:
|
|
168
|
+
"""Set sync start to specific time."""
|
|
169
|
+
if not (ts is None or isinstance(ts, (int, float, datetime))):
|
|
170
|
+
raise TypeError(f"ts must be int, float, datetime or None, not {type(ts)}")
|
|
171
|
+
|
|
172
|
+
if ts is None:
|
|
173
|
+
return time.time()
|
|
174
|
+
elif isinstance(ts, (int, float)):
|
|
175
|
+
return float(ts)
|
|
176
|
+
else:
|
|
177
|
+
return ts.timestamp()
|
|
178
|
+
|
|
179
|
+
def __str__(self) -> str:
|
|
180
|
+
return f"Sync({self.api_client.urls})"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Query:
|
|
184
|
+
"""Query events from sharing API."""
|
|
185
|
+
|
|
186
|
+
allowed_user_provided_qps = {
|
|
187
|
+
"filter",
|
|
188
|
+
"projection",
|
|
189
|
+
"limit",
|
|
190
|
+
"start",
|
|
191
|
+
"end",
|
|
192
|
+
"reverse",
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def __init__(self, url: str, **kwargs: Any):
|
|
196
|
+
"""
|
|
197
|
+
Initialize Query class.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
url: Sharing API url, must include `apikey` query parameter.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
# Check args
|
|
204
|
+
if not isinstance(url, str):
|
|
205
|
+
raise TypeError(f"url must be string not {type(url)}")
|
|
206
|
+
|
|
207
|
+
user_agent = kwargs.pop("user_agent", None)
|
|
208
|
+
if not (user_agent is None or isinstance(user_agent, str)):
|
|
209
|
+
raise TypeError(
|
|
210
|
+
f"user_agent must be string or None, not {type(user_agent)}"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if kwargs:
|
|
214
|
+
raise ValueError(f"Unknown parameter(s) {tuple(kwargs.keys())}")
|
|
215
|
+
|
|
216
|
+
# Initialize client
|
|
217
|
+
self.api_client = _ApiClient(url, user_agent=user_agent)
|
|
218
|
+
|
|
219
|
+
invalid_qps_in_url = (
|
|
220
|
+
self.api_client.urls.qp.keys() - self.allowed_user_provided_qps
|
|
221
|
+
)
|
|
222
|
+
if invalid_qps_in_url:
|
|
223
|
+
raise ConfigError(f"Invalid qps in url: {invalid_qps_in_url}")
|
|
224
|
+
|
|
225
|
+
def query(
|
|
226
|
+
self,
|
|
227
|
+
*,
|
|
228
|
+
filter: Optional[str] = None,
|
|
229
|
+
projection: Optional[Iterable[str]] = None,
|
|
230
|
+
start: Union[datetime, int, float, None] = None,
|
|
231
|
+
end: Union[datetime, int, float, None] = None,
|
|
232
|
+
reverse: bool = False,
|
|
233
|
+
max_events: int = 0,
|
|
234
|
+
timeout: Optional[float] = 600,
|
|
235
|
+
**kwargs: Any,
|
|
236
|
+
) -> Iterator[Event]:
|
|
237
|
+
"""Query sharing API.
|
|
238
|
+
|
|
239
|
+
Events are returned sorted by timestamp.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
filter: Rulelang filter.
|
|
243
|
+
projection: List of event field names to include in the results. Note that the list of keys provided by the server can only be limited by this parameter.
|
|
244
|
+
start: Start time for the query. Default value `None` means forever. Positive numbers are interpret as epoch time. Non-positive numbers are interpret as that many seconds in the past.
|
|
245
|
+
end: End time for the query. Default value `None` means current time. Positive numbers are interpret as epoch time. Non-positive numbers are interpret as that many seconds in the past.
|
|
246
|
+
reverse: Return events in reverse order.
|
|
247
|
+
max_events: Maximum number of events to generate.
|
|
248
|
+
timeout: Timeout for one sharing API query fetching more events in [s].
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Generator yielding events matching the query.
|
|
252
|
+
"""
|
|
253
|
+
if not (filter is None or isinstance(filter, str)):
|
|
254
|
+
raise TypeError(f"filter must be string or None, not {type(filter)}")
|
|
255
|
+
|
|
256
|
+
if not (
|
|
257
|
+
projection is None
|
|
258
|
+
or (
|
|
259
|
+
isinstance(projection, Iterable)
|
|
260
|
+
and not isinstance(projection, str)
|
|
261
|
+
and all(isinstance(x, str) for x in projection)
|
|
262
|
+
)
|
|
263
|
+
):
|
|
264
|
+
raise TypeError(
|
|
265
|
+
"projection must be a list of key names, each an instance of str"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if not (start is None or isinstance(start, (int, float, datetime))):
|
|
269
|
+
raise TypeError(
|
|
270
|
+
f"start must be int, float, datetime or None, not {type(start)}"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
if not (end is None or isinstance(end, (int, float, datetime))):
|
|
274
|
+
raise TypeError(
|
|
275
|
+
f"end must be int, float, datetime or None, not {type(end)}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if not isinstance(reverse, bool):
|
|
279
|
+
raise TypeError(f"reverse must be bool, not {type(bool)}")
|
|
280
|
+
|
|
281
|
+
if not isinstance(max_events, int):
|
|
282
|
+
raise TypeError(f"max_events must be int, not {type(max_events)}")
|
|
283
|
+
|
|
284
|
+
if not (timeout is None or isinstance(timeout, (int, float))):
|
|
285
|
+
raise TypeError(f"timeout must be float or None, not {type(timeout)}")
|
|
286
|
+
|
|
287
|
+
pagesize = kwargs.pop("pagesize", 1000)
|
|
288
|
+
if not isinstance(pagesize, int):
|
|
289
|
+
raise TypeError(f"pagesize must be int, not {type(kwargs.get('pagesize'))}")
|
|
290
|
+
|
|
291
|
+
if kwargs:
|
|
292
|
+
raise ValueError(f"Unknown parameter(s) {tuple(kwargs.keys())}")
|
|
293
|
+
|
|
294
|
+
qp = _remove_none_values(
|
|
295
|
+
{
|
|
296
|
+
"filter": filter,
|
|
297
|
+
"projection": projection,
|
|
298
|
+
"start": _build_start_end(start),
|
|
299
|
+
"end": _build_start_end(end),
|
|
300
|
+
"reverse": "" if reverse else None,
|
|
301
|
+
"limit": pagesize,
|
|
302
|
+
}
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
more = True
|
|
306
|
+
token = None
|
|
307
|
+
n_events = 0
|
|
308
|
+
|
|
309
|
+
while more:
|
|
310
|
+
resp = self.api_client.async_query(qp, timeout=timeout)
|
|
311
|
+
events = resp.json()
|
|
312
|
+
logger.debug(f"queried, got {len(events)} events")
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
token = resp.headers["x-next-token"]
|
|
316
|
+
except KeyError:
|
|
317
|
+
more = False
|
|
318
|
+
else:
|
|
319
|
+
qp["token"] = token
|
|
320
|
+
more = True
|
|
321
|
+
|
|
322
|
+
for event in events:
|
|
323
|
+
yield event
|
|
324
|
+
n_events += 1
|
|
325
|
+
if 0 < max_events <= n_events:
|
|
326
|
+
more = False
|
|
327
|
+
break
|
|
328
|
+
|
|
329
|
+
def __str__(self) -> str:
|
|
330
|
+
return f"Query({self.api_client.urls})"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def query(url: str, **kwargs: Any) -> Iterable[Event]:
|
|
334
|
+
"""Shortcut to Query(url).query()."""
|
|
335
|
+
return Query(url).query(**kwargs)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _remove_none_values(d: dict[str, Optional[Any]]) -> dict[str, Any]:
|
|
339
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _build_start_end(ts: Union[datetime, int, float, None]) -> Union[int, float, None]:
|
|
343
|
+
"""Build start / end query parameter."""
|
|
344
|
+
if ts is None:
|
|
345
|
+
return None
|
|
346
|
+
elif isinstance(ts, (int, float)):
|
|
347
|
+
return ts
|
|
348
|
+
else:
|
|
349
|
+
return ts.timestamp()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arcticsecurity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Library to access Arctic Security APIs
|
|
5
|
+
Author: Arctic Security
|
|
6
|
+
Author-email: Arctic Security <pypi@arcticsecurity.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Dist: httpx>=0.28.1
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Project-URL: Home, https://github.com/arcticsecurity/arcticsecurity-python
|
|
26
|
+
Project-URL: Issues, https://github.com/arcticsecurity/arcticsecurity-python/issues
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Arctic Security Library
|
|
30
|
+
|
|
31
|
+
A Python library to access Arctic Security's Sharing API endpoints and retrieve event data.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install arcticsecurity
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Python >= 3.9 is required.
|
|
40
|
+
|
|
41
|
+
## Quick Start
|
|
42
|
+
|
|
43
|
+
The library supports two distinct ways to retrieve events from the Sharing API: `Query` and `Sync`.
|
|
44
|
+
|
|
45
|
+
### Query
|
|
46
|
+
|
|
47
|
+
Use the `Query` class to perform one-off queries for specific events.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from arcticsecurity.sharing_api import Query
|
|
51
|
+
|
|
52
|
+
url = "https://example.com/shares/v2/share-id?apikey=YOUR_API_KEY"
|
|
53
|
+
for event in Query(url).query(filter='"network owner"="Example Co"', max_events=50):
|
|
54
|
+
print(event)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Sync
|
|
58
|
+
|
|
59
|
+
Use the `Sync` class to reliably fetch all event data. It uses pagination with opaque continuation tokens to ensure no events are missed.
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from arcticsecurity.sharing_api import Sync
|
|
63
|
+
|
|
64
|
+
url = "https://example.com/shares/v2/share-id?apikey=YOUR_API_KEY"
|
|
65
|
+
sync = Sync(url, filter='"network owner"="Example Co"')
|
|
66
|
+
|
|
67
|
+
# Fetch token from previous run
|
|
68
|
+
token = ...
|
|
69
|
+
|
|
70
|
+
while True:
|
|
71
|
+
res = sync.read(token=token, pagesize=1000)
|
|
72
|
+
if not res.events:
|
|
73
|
+
break
|
|
74
|
+
# process res.events...
|
|
75
|
+
|
|
76
|
+
token = res.token
|
|
77
|
+
|
|
78
|
+
if not res.has_more:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
# store the token for the next run
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Common features
|
|
85
|
+
|
|
86
|
+
- Robust error handling with specific exceptions (`ConfigError`, `NetworkError`, `Retry`, `TimeoutError`, `InvalidTokenError`, `ServerError`).
|
|
87
|
+
- Optional timeout and user-agent customization
|
|
88
|
+
|
|
89
|
+
## Versioning
|
|
90
|
+
|
|
91
|
+
Follows [Semantic Versioning v2](https://semver.org/spec/v2.0.0.html).
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
[uv](https://docs.astral.sh/uv/) is required.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv sync --group dev
|
|
99
|
+
|
|
100
|
+
uv run ruff check .
|
|
101
|
+
uv run pytest --doctest-modules
|
|
102
|
+
|
|
103
|
+
uv run mypy --config-file pyproject.toml -p arcticsecurity
|
|
104
|
+
uv run ty check
|
|
105
|
+
|
|
106
|
+
uv run mkdocs build
|
|
107
|
+
uv run mkdocs serve
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Contributing
|
|
111
|
+
|
|
112
|
+
Issues and pull requests are welcome. Please add tests for any new behavior.
|
|
113
|
+
|
|
114
|
+
## Status
|
|
115
|
+
|
|
116
|
+
Experimental (`0.1.x`); the API may evolve. Pin the library version for production use.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
arcticsecurity/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
arcticsecurity/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
arcticsecurity/sharing_api/__init__.py,sha256=B0TgFkilX9dAaEyHRi1hO4CMX_jr5VFzFR2JICNCYT8,91
|
|
4
|
+
arcticsecurity/sharing_api/_api_client.py,sha256=8i_8kvsGfnufMqH1lHHHuE1q3V5XgdOK5deWSqX-B3I,13563
|
|
5
|
+
arcticsecurity/sharing_api/_version.py,sha256=Icv4zNjQLmDfNstVPJvkTQ1TO0T-Iac7S79XmZsG2BA,277
|
|
6
|
+
arcticsecurity/sharing_api/errors.py,sha256=elWNc3v2d8GlXtySsQYvCL25QBEa8JCjw-ySXpbmx6Q,1711
|
|
7
|
+
arcticsecurity/sharing_api/sharing_api.py,sha256=a7jivPbfddlaVe1uKAqchbCttuMHWs6EYgP3Jeg6sr8,11336
|
|
8
|
+
arcticsecurity-0.1.0.dist-info/licenses/LICENSE,sha256=zwVs8Z8wzlOjkzY9dM65qoFgzhRTrvOHKy4wFVPlxQg,1072
|
|
9
|
+
arcticsecurity-0.1.0.dist-info/WHEEL,sha256=iCTolw4aw2dP3yfM-EQCGTDsFCXL_ymmbYnBRVH7plA,81
|
|
10
|
+
arcticsecurity-0.1.0.dist-info/METADATA,sha256=qqUSUP0k9F-uLgVBFsrQ1_irFjZIdMxmcLXLf9YIGBA,3189
|
|
11
|
+
arcticsecurity-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Arctic Security
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|