kelpgate-client 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.
- kelpgate/__init__.py +61 -0
- kelpgate/_async.py +256 -0
- kelpgate/_common.py +335 -0
- kelpgate/_errors.py +125 -0
- kelpgate/_models.py +65 -0
- kelpgate/_sync.py +267 -0
- kelpgate/_version.py +1 -0
- kelpgate/cli.py +137 -0
- kelpgate/integrations/__init__.py +1 -0
- kelpgate/integrations/django.py +69 -0
- kelpgate/integrations/fastapi.py +76 -0
- kelpgate/py.typed +0 -0
- kelpgate_client-0.1.0.dist-info/METADATA +264 -0
- kelpgate_client-0.1.0.dist-info/RECORD +17 -0
- kelpgate_client-0.1.0.dist-info/WHEEL +4 -0
- kelpgate_client-0.1.0.dist-info/entry_points.txt +2 -0
- kelpgate_client-0.1.0.dist-info/licenses/LICENSE +338 -0
kelpgate/__init__.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Python client for the Kelpgate malware-scanning API.
|
|
2
|
+
|
|
3
|
+
from kelpgate import KelpgateClient
|
|
4
|
+
|
|
5
|
+
with KelpgateClient.from_env() as kg:
|
|
6
|
+
result = kg.scan_file("upload.pdf")
|
|
7
|
+
if result.infected:
|
|
8
|
+
print("Rejected:", result.signature)
|
|
9
|
+
|
|
10
|
+
Use :class:`AsyncKelpgateClient` from asyncio code.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from ._async import AsyncKelpgateClient
|
|
14
|
+
from ._errors import (
|
|
15
|
+
AuthError,
|
|
16
|
+
ConfigError,
|
|
17
|
+
InfectedError,
|
|
18
|
+
KelpgateError,
|
|
19
|
+
NetworkError,
|
|
20
|
+
PayloadTooLargeError,
|
|
21
|
+
QuotaExceededError,
|
|
22
|
+
RateLimitedError,
|
|
23
|
+
RequestTimeout,
|
|
24
|
+
ScanError,
|
|
25
|
+
ServerError,
|
|
26
|
+
UnsupportedFileError,
|
|
27
|
+
)
|
|
28
|
+
from ._models import AcceptedScan, Health, PendingScan, ScanResult
|
|
29
|
+
from ._sync import KelpgateClient
|
|
30
|
+
from ._version import __version__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def assert_clean(result: ScanResult) -> ScanResult:
|
|
34
|
+
"""Return ``result`` if it is clean. Raise :class:`InfectedError` if it is infected."""
|
|
35
|
+
if result.infected:
|
|
36
|
+
raise InfectedError(result)
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AcceptedScan",
|
|
42
|
+
"AsyncKelpgateClient",
|
|
43
|
+
"AuthError",
|
|
44
|
+
"ConfigError",
|
|
45
|
+
"Health",
|
|
46
|
+
"InfectedError",
|
|
47
|
+
"KelpgateClient",
|
|
48
|
+
"KelpgateError",
|
|
49
|
+
"NetworkError",
|
|
50
|
+
"PayloadTooLargeError",
|
|
51
|
+
"PendingScan",
|
|
52
|
+
"QuotaExceededError",
|
|
53
|
+
"RateLimitedError",
|
|
54
|
+
"RequestTimeout",
|
|
55
|
+
"ScanError",
|
|
56
|
+
"ScanResult",
|
|
57
|
+
"ServerError",
|
|
58
|
+
"UnsupportedFileError",
|
|
59
|
+
"__version__",
|
|
60
|
+
"assert_clean",
|
|
61
|
+
]
|
kelpgate/_async.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""The asyncio client. Same behavior as :class:`kelpgate.KelpgateClient`, with ``await``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
9
|
+
from urllib.parse import quote
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._common import (
|
|
14
|
+
REFRESH_AHEAD,
|
|
15
|
+
RETRY_STATUSES,
|
|
16
|
+
Config,
|
|
17
|
+
PreparedUpload,
|
|
18
|
+
Source,
|
|
19
|
+
accepted_outcome,
|
|
20
|
+
backoff_delay,
|
|
21
|
+
basic_auth_header,
|
|
22
|
+
build_config,
|
|
23
|
+
env_options,
|
|
24
|
+
health_outcome,
|
|
25
|
+
parse_json,
|
|
26
|
+
parse_token,
|
|
27
|
+
poll_outcome,
|
|
28
|
+
prepare_upload,
|
|
29
|
+
request_id_of,
|
|
30
|
+
scan_outcome,
|
|
31
|
+
)
|
|
32
|
+
from ._errors import KelpgateError, NetworkError, RequestTimeout
|
|
33
|
+
from ._models import AcceptedScan, Health, PendingScan, ScanResult
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AsyncKelpgateClient:
|
|
37
|
+
"""Asyncio client for the Kelpgate malware-scanning API.
|
|
38
|
+
|
|
39
|
+
Arguments are the same as :class:`kelpgate.KelpgateClient`.
|
|
40
|
+
|
|
41
|
+
Use it as ``async with AsyncKelpgateClient(...) as kg:`` or call :meth:`aclose` when you are done.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_url: str,
|
|
47
|
+
*,
|
|
48
|
+
token_url: Optional[str] = None,
|
|
49
|
+
client_id: Optional[str] = None,
|
|
50
|
+
client_secret: Optional[str] = None,
|
|
51
|
+
bearer_token: Optional[str] = None,
|
|
52
|
+
scope: str = "scan",
|
|
53
|
+
timeout: float = 60.0,
|
|
54
|
+
max_retries: int = 2,
|
|
55
|
+
retry_delay: float = 0.5,
|
|
56
|
+
max_bytes: Optional[int] = None,
|
|
57
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
opts: Dict[str, Any] = {}
|
|
60
|
+
if max_bytes is not None:
|
|
61
|
+
opts["max_bytes"] = max_bytes
|
|
62
|
+
self._cfg: Config = build_config(
|
|
63
|
+
api_url=api_url,
|
|
64
|
+
token_url=token_url,
|
|
65
|
+
client_id=client_id,
|
|
66
|
+
client_secret=client_secret,
|
|
67
|
+
bearer_token=bearer_token,
|
|
68
|
+
scope=scope,
|
|
69
|
+
timeout=timeout,
|
|
70
|
+
max_retries=max_retries,
|
|
71
|
+
retry_delay=retry_delay,
|
|
72
|
+
**opts,
|
|
73
|
+
)
|
|
74
|
+
self._http = httpx.AsyncClient(timeout=timeout, transport=transport, follow_redirects=False)
|
|
75
|
+
self._lock: Optional[asyncio.Lock] = None # created on first use, inside the running loop
|
|
76
|
+
self._token: Optional[str] = None
|
|
77
|
+
self._expires_at = 0.0
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None, **overrides: Any) -> AsyncKelpgateClient:
|
|
81
|
+
"""Build a client from ``KELPGATE_*`` environment variables, like ``KelpgateClient.from_env``."""
|
|
82
|
+
opts = env_options(env)
|
|
83
|
+
opts.update(overrides)
|
|
84
|
+
return cls(**opts)
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"AsyncKelpgateClient(api_url={self._cfg.api_url!r})"
|
|
88
|
+
|
|
89
|
+
async def __aenter__(self) -> AsyncKelpgateClient:
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
93
|
+
await self.aclose()
|
|
94
|
+
|
|
95
|
+
async def aclose(self) -> None:
|
|
96
|
+
"""Release the underlying connections."""
|
|
97
|
+
await self._http.aclose()
|
|
98
|
+
|
|
99
|
+
# ---- authentication
|
|
100
|
+
|
|
101
|
+
async def _access_token(self, force: bool = False) -> str:
|
|
102
|
+
if self._cfg.bearer_token:
|
|
103
|
+
return self._cfg.bearer_token
|
|
104
|
+
if not force and self._token and self._expires_at - time.monotonic() > REFRESH_AHEAD:
|
|
105
|
+
return self._token
|
|
106
|
+
if self._lock is None:
|
|
107
|
+
self._lock = asyncio.Lock()
|
|
108
|
+
async with self._lock: # one renewal at a time; concurrent callers wait, then reuse the result
|
|
109
|
+
now = time.monotonic()
|
|
110
|
+
if not force and self._token and self._expires_at - now > REFRESH_AHEAD:
|
|
111
|
+
return self._token
|
|
112
|
+
try:
|
|
113
|
+
token, ttl = await self._fetch_token()
|
|
114
|
+
except KelpgateError:
|
|
115
|
+
if not force and self._token and self._expires_at > now:
|
|
116
|
+
return self._token # renewing early failed, the old token is still good
|
|
117
|
+
raise
|
|
118
|
+
self._token, self._expires_at = token, time.monotonic() + ttl
|
|
119
|
+
return token
|
|
120
|
+
|
|
121
|
+
async def _fetch_token(self) -> tuple[str, float]:
|
|
122
|
+
cfg = self._cfg
|
|
123
|
+
assert cfg.token_url and cfg.client_id and cfg.client_secret
|
|
124
|
+
try:
|
|
125
|
+
res = await self._http.post(
|
|
126
|
+
cfg.token_url,
|
|
127
|
+
headers={
|
|
128
|
+
"Authorization": basic_auth_header(cfg.client_id, cfg.client_secret),
|
|
129
|
+
"User-Agent": cfg.user_agent,
|
|
130
|
+
},
|
|
131
|
+
data={"grant_type": "client_credentials", "scope": cfg.scope},
|
|
132
|
+
)
|
|
133
|
+
except httpx.TimeoutException as exc:
|
|
134
|
+
raise RequestTimeout(f"The token request timed out after {cfg.timeout:g} s") from exc
|
|
135
|
+
except httpx.HTTPError as exc:
|
|
136
|
+
raise NetworkError(f"The token request failed: {exc}") from exc
|
|
137
|
+
return parse_token(res.status_code, parse_json(res.content), request_id_of(res.headers))
|
|
138
|
+
|
|
139
|
+
# ---- HTTP
|
|
140
|
+
|
|
141
|
+
async def _request(
|
|
142
|
+
self,
|
|
143
|
+
method: str,
|
|
144
|
+
path: str,
|
|
145
|
+
*,
|
|
146
|
+
json_body: Any = None,
|
|
147
|
+
upload: Optional[PreparedUpload] = None,
|
|
148
|
+
form: Optional[Dict[str, str]] = None,
|
|
149
|
+
auth: bool = True,
|
|
150
|
+
) -> tuple[int, Dict[str, Any], httpx.Headers]:
|
|
151
|
+
cfg = self._cfg
|
|
152
|
+
token_retried = False
|
|
153
|
+
attempt = 0
|
|
154
|
+
while True:
|
|
155
|
+
headers = {"User-Agent": cfg.user_agent, "Accept": "application/json"}
|
|
156
|
+
opened: Any = None
|
|
157
|
+
must_close = False
|
|
158
|
+
try:
|
|
159
|
+
if auth:
|
|
160
|
+
headers["Authorization"] = f"Bearer {await self._access_token()}"
|
|
161
|
+
kwargs: Dict[str, Any] = {}
|
|
162
|
+
if upload is not None:
|
|
163
|
+
opened, must_close = upload.open()
|
|
164
|
+
kwargs["files"] = {"file": (upload.filename, opened, "application/octet-stream")}
|
|
165
|
+
if form:
|
|
166
|
+
kwargs["data"] = form
|
|
167
|
+
elif json_body is not None:
|
|
168
|
+
kwargs["content"] = json.dumps(json_body).encode()
|
|
169
|
+
headers["Content-Type"] = "application/json"
|
|
170
|
+
res = await self._http.request(method, cfg.api_url + path, headers=headers, **kwargs)
|
|
171
|
+
except KelpgateError:
|
|
172
|
+
raise
|
|
173
|
+
except httpx.HTTPError as exc:
|
|
174
|
+
err: KelpgateError = (
|
|
175
|
+
RequestTimeout(f"The request timed out after {cfg.timeout:g} s")
|
|
176
|
+
if isinstance(exc, httpx.TimeoutException)
|
|
177
|
+
else NetworkError(f"The request failed: {exc}")
|
|
178
|
+
)
|
|
179
|
+
if attempt >= cfg.max_retries:
|
|
180
|
+
raise err from exc
|
|
181
|
+
await asyncio.sleep(backoff_delay(attempt, cfg.retry_delay))
|
|
182
|
+
attempt += 1
|
|
183
|
+
continue
|
|
184
|
+
finally:
|
|
185
|
+
if must_close and opened is not None:
|
|
186
|
+
opened.close()
|
|
187
|
+
|
|
188
|
+
data = parse_json(res.content)
|
|
189
|
+
if res.status_code == 401 and auth and not cfg.bearer_token and not token_retried:
|
|
190
|
+
token_retried = True # the token may have been revoked or rotated: renew once
|
|
191
|
+
self._token = None
|
|
192
|
+
continue
|
|
193
|
+
if res.status_code in RETRY_STATUSES and attempt < cfg.max_retries:
|
|
194
|
+
await asyncio.sleep(backoff_delay(attempt, cfg.retry_delay, res.headers.get("retry-after")))
|
|
195
|
+
attempt += 1
|
|
196
|
+
continue
|
|
197
|
+
return res.status_code, data, res.headers
|
|
198
|
+
|
|
199
|
+
# ---- scanning
|
|
200
|
+
|
|
201
|
+
async def scan_file(self, source: Source, *, filename: Optional[str] = None) -> ScanResult:
|
|
202
|
+
"""Scan a file given as a path, ``bytes`` or a binary file object, and return the verdict."""
|
|
203
|
+
upload = prepare_upload(source, filename, self._cfg.max_bytes)
|
|
204
|
+
return scan_outcome(*await self._request("POST", "/api/v1/scan/file", upload=upload))
|
|
205
|
+
|
|
206
|
+
async def scan_bytes(
|
|
207
|
+
self, data: Union[bytes, bytearray, memoryview], *, filename: Optional[str] = None
|
|
208
|
+
) -> ScanResult:
|
|
209
|
+
"""Scan bytes you already have in memory."""
|
|
210
|
+
return await self.scan_file(data, filename=filename)
|
|
211
|
+
|
|
212
|
+
async def scan_url(self, url: str) -> ScanResult:
|
|
213
|
+
"""Ask the service to fetch and scan a remote file."""
|
|
214
|
+
return scan_outcome(*await self._request("POST", "/api/v1/scan/url", json_body={"url": str(url)}))
|
|
215
|
+
|
|
216
|
+
async def submit_file(
|
|
217
|
+
self, source: Source, *, filename: Optional[str] = None, webhook_url: Optional[str] = None
|
|
218
|
+
) -> AcceptedScan:
|
|
219
|
+
"""Start an asynchronous scan. Collect the result with :meth:`get_result` or :meth:`wait`."""
|
|
220
|
+
upload = prepare_upload(source, filename, self._cfg.max_bytes)
|
|
221
|
+
form = {"webhook_url": webhook_url} if webhook_url else None
|
|
222
|
+
return accepted_outcome(
|
|
223
|
+
*await self._request("POST", "/api/v1/async-scan/file", upload=upload, form=form)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
async def submit_url(self, url: str, *, webhook_url: Optional[str] = None) -> AcceptedScan:
|
|
227
|
+
"""Start an asynchronous scan of a remote file."""
|
|
228
|
+
body: Dict[str, str] = {"url": str(url)}
|
|
229
|
+
if webhook_url:
|
|
230
|
+
body["webhook_url"] = webhook_url
|
|
231
|
+
return accepted_outcome(*await self._request("POST", "/api/v1/async-scan/url", json_body=body))
|
|
232
|
+
|
|
233
|
+
async def get_result(self, scan_id: str, *, kind: str = "file") -> Union[ScanResult, PendingScan]:
|
|
234
|
+
"""Read an asynchronous result. Returns a :class:`PendingScan` until the verdict is ready."""
|
|
235
|
+
which = "url" if kind == "url" else "file"
|
|
236
|
+
status, data, headers = await self._request(
|
|
237
|
+
"GET", f"/api/v1/async-scan/{which}?scan_id={quote(scan_id, safe='')}"
|
|
238
|
+
)
|
|
239
|
+
return poll_outcome(scan_id, status, data, headers)
|
|
240
|
+
|
|
241
|
+
async def wait(
|
|
242
|
+
self, scan_id: str, *, kind: str = "file", interval: float = 1.0, timeout: float = 120.0
|
|
243
|
+
) -> ScanResult:
|
|
244
|
+
"""Poll until an asynchronous scan finishes. Raises :class:`RequestTimeout` if it takes too long."""
|
|
245
|
+
deadline = time.monotonic() + timeout
|
|
246
|
+
while True:
|
|
247
|
+
result = await self.get_result(scan_id, kind=kind)
|
|
248
|
+
if isinstance(result, ScanResult):
|
|
249
|
+
return result
|
|
250
|
+
if time.monotonic() + interval > deadline:
|
|
251
|
+
raise RequestTimeout(f"Scan {scan_id} was not finished after {timeout:g} s")
|
|
252
|
+
await asyncio.sleep(interval)
|
|
253
|
+
|
|
254
|
+
async def health(self) -> Health:
|
|
255
|
+
"""Check that the service is up. Needs no credentials."""
|
|
256
|
+
return health_outcome(*await self._request("GET", "/api/v1/health", auth=False))
|
kelpgate/_common.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Logic shared by the synchronous and asynchronous clients: no I/O happens here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
import re
|
|
10
|
+
import urllib.parse
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from typing import Any, BinaryIO, Dict, Mapping, Optional, Tuple, Union
|
|
14
|
+
|
|
15
|
+
from ._errors import (
|
|
16
|
+
AuthError,
|
|
17
|
+
ConfigError,
|
|
18
|
+
KelpgateError,
|
|
19
|
+
PayloadTooLargeError,
|
|
20
|
+
QuotaExceededError,
|
|
21
|
+
RateLimitedError,
|
|
22
|
+
ScanError,
|
|
23
|
+
ServerError,
|
|
24
|
+
UnsupportedFileError,
|
|
25
|
+
)
|
|
26
|
+
from ._models import AcceptedScan, Health, PendingScan, ScanResult
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
|
|
29
|
+
DEFAULT_MAX_BYTES = 128 * 1024 * 1024 # a little above the largest plan limit
|
|
30
|
+
REFRESH_AHEAD = 120.0 # renew a token this many seconds before it expires
|
|
31
|
+
RETRY_AFTER_CAP = 30.0
|
|
32
|
+
RETRY_STATUSES = frozenset({429, 502, 503, 504})
|
|
33
|
+
_LOOPBACK = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
34
|
+
# RFC 6749 asks for form-urlencoding of the client id and secret in Basic auth.
|
|
35
|
+
_SAFE = "!~*'()"
|
|
36
|
+
|
|
37
|
+
Source = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview, BinaryIO]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check_url(name: str, value: Optional[str]) -> str:
|
|
41
|
+
"""Return ``value`` without a trailing slash, or raise ConfigError. Plain http is only for localhost."""
|
|
42
|
+
parts = urllib.parse.urlsplit(value or "")
|
|
43
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
44
|
+
raise ConfigError(f"{name} is not a valid URL")
|
|
45
|
+
if parts.scheme == "http" and parts.hostname not in _LOOPBACK:
|
|
46
|
+
raise ConfigError(f"{name} must use https (plain http is only allowed for localhost)")
|
|
47
|
+
return (value or "").rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Config:
|
|
52
|
+
api_url: str
|
|
53
|
+
token_url: Optional[str]
|
|
54
|
+
client_id: Optional[str]
|
|
55
|
+
client_secret: Optional[str]
|
|
56
|
+
bearer_token: Optional[str]
|
|
57
|
+
scope: str
|
|
58
|
+
timeout: float
|
|
59
|
+
max_retries: int
|
|
60
|
+
retry_delay: float
|
|
61
|
+
max_bytes: int
|
|
62
|
+
user_agent: str
|
|
63
|
+
|
|
64
|
+
def __repr__(self) -> str: # never show the secret or token
|
|
65
|
+
return f"Config(api_url={self.api_url!r}, scope={self.scope!r})"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_config(
|
|
69
|
+
*,
|
|
70
|
+
api_url: Optional[str],
|
|
71
|
+
token_url: Optional[str] = None,
|
|
72
|
+
client_id: Optional[str] = None,
|
|
73
|
+
client_secret: Optional[str] = None,
|
|
74
|
+
bearer_token: Optional[str] = None,
|
|
75
|
+
scope: str = "scan",
|
|
76
|
+
timeout: float = 60.0,
|
|
77
|
+
max_retries: int = 2,
|
|
78
|
+
retry_delay: float = 0.5,
|
|
79
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
80
|
+
) -> Config:
|
|
81
|
+
checked_api = check_url("api_url", api_url)
|
|
82
|
+
checked_token: Optional[str] = None
|
|
83
|
+
if not bearer_token:
|
|
84
|
+
checked_token = check_url("token_url", token_url)
|
|
85
|
+
if not client_id or not client_secret:
|
|
86
|
+
raise ConfigError("client_id and client_secret are required (or pass bearer_token)")
|
|
87
|
+
return Config(
|
|
88
|
+
api_url=checked_api,
|
|
89
|
+
token_url=checked_token,
|
|
90
|
+
client_id=client_id,
|
|
91
|
+
client_secret=client_secret,
|
|
92
|
+
bearer_token=bearer_token or None,
|
|
93
|
+
scope=scope,
|
|
94
|
+
timeout=float(timeout),
|
|
95
|
+
max_retries=max(0, int(max_retries)),
|
|
96
|
+
retry_delay=float(retry_delay),
|
|
97
|
+
max_bytes=int(max_bytes),
|
|
98
|
+
user_agent=f"kelpgate-python/{__version__}",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def env_options(env: Optional[Mapping[str, str]] = None) -> Dict[str, Any]:
|
|
103
|
+
"""Client options from ``KELPGATE_*`` environment variables, with the production endpoints as defaults."""
|
|
104
|
+
e = os.environ if env is None else env
|
|
105
|
+
return {
|
|
106
|
+
"api_url": e.get("KELPGATE_API_URL") or "https://api.kelpgate.cloud",
|
|
107
|
+
"token_url": e.get("KELPGATE_TOKEN_URL") or "https://auth.kelpgate.cloud/oauth2/token",
|
|
108
|
+
"client_id": e.get("KELPGATE_CLIENT_ID"),
|
|
109
|
+
"client_secret": e.get("KELPGATE_CLIENT_SECRET"),
|
|
110
|
+
"bearer_token": e.get("KELPGATE_TOKEN"),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def basic_auth_header(client_id: str, client_secret: str) -> str:
|
|
115
|
+
raw = f"{urllib.parse.quote(client_id, safe=_SAFE)}:{urllib.parse.quote(client_secret, safe=_SAFE)}"
|
|
116
|
+
return "Basic " + base64.b64encode(raw.encode()).decode()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_json(content: bytes) -> Dict[str, Any]:
|
|
120
|
+
if not content:
|
|
121
|
+
return {}
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(content)
|
|
124
|
+
except ValueError:
|
|
125
|
+
return {"error": content[:200].decode("utf-8", "replace")}
|
|
126
|
+
return data if isinstance(data, dict) else {"value": data}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_token(status: int, data: Dict[str, Any], request_id: Optional[str]) -> Tuple[str, float]:
|
|
130
|
+
"""Return ``(access_token, lifetime_seconds)`` or raise AuthError."""
|
|
131
|
+
token = data.get("access_token")
|
|
132
|
+
if status != 200 or not isinstance(token, str) or not token:
|
|
133
|
+
why = data.get("error_description") or data.get("error") or f"HTTP {status}"
|
|
134
|
+
safe_body = {k: v for k, v in data.items() if k not in ("access_token", "refresh_token")}
|
|
135
|
+
raise AuthError(
|
|
136
|
+
f"Could not get an access token: {why}",
|
|
137
|
+
code="TOKEN_REQUEST_FAILED",
|
|
138
|
+
status=status,
|
|
139
|
+
request_id=request_id,
|
|
140
|
+
body=safe_body,
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
ttl = float(data.get("expires_in") or 0)
|
|
144
|
+
except (TypeError, ValueError):
|
|
145
|
+
ttl = 0.0
|
|
146
|
+
return token, ttl if ttl > 0 else 300.0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def backoff_delay(attempt: int, base: float, retry_after: Optional[str] = None) -> float:
|
|
150
|
+
if retry_after is not None:
|
|
151
|
+
try:
|
|
152
|
+
seconds = float(retry_after)
|
|
153
|
+
except ValueError:
|
|
154
|
+
seconds = -1.0
|
|
155
|
+
if seconds >= 0:
|
|
156
|
+
return min(seconds, RETRY_AFTER_CAP)
|
|
157
|
+
return base * (2.0**attempt) + random.uniform(0, min(0.1, base)) # noqa: S311 - retry jitter, not security
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _basename(name: str) -> str:
|
|
161
|
+
return re.split(r"[\\/]", name)[-1] or "upload.bin"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class PreparedUpload:
|
|
165
|
+
"""A file ready to send. :meth:`open` gives a fresh file object for each attempt, so retries work."""
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
filename: str,
|
|
170
|
+
size: int,
|
|
171
|
+
*,
|
|
172
|
+
data: Optional[bytes] = None,
|
|
173
|
+
path: Optional[str] = None,
|
|
174
|
+
stream: Optional[BinaryIO] = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.filename = filename
|
|
177
|
+
self.size = size
|
|
178
|
+
self._data = data
|
|
179
|
+
self._path = path
|
|
180
|
+
self._stream = stream
|
|
181
|
+
self._start = stream.tell() if stream is not None else 0
|
|
182
|
+
|
|
183
|
+
def open(self) -> Tuple[Any, bool]:
|
|
184
|
+
"""Return ``(content, must_close)``."""
|
|
185
|
+
if self._data is not None:
|
|
186
|
+
return self._data, False
|
|
187
|
+
if self._path is not None:
|
|
188
|
+
return open(self._path, "rb"), True
|
|
189
|
+
assert self._stream is not None
|
|
190
|
+
self._stream.seek(self._start)
|
|
191
|
+
return self._stream, False
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def prepare_upload(source: Source, filename: Optional[str], max_bytes: int) -> PreparedUpload:
|
|
195
|
+
"""Turn a path, bytes, or binary file object into something we can send, enforcing ``max_bytes``."""
|
|
196
|
+
|
|
197
|
+
def too_big(size: int) -> PayloadTooLargeError:
|
|
198
|
+
return PayloadTooLargeError(
|
|
199
|
+
f"The file is {size} bytes, over this client's limit of {max_bytes} bytes", status=413
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
upload: PreparedUpload
|
|
203
|
+
if isinstance(source, (str, os.PathLike)):
|
|
204
|
+
path = os.fspath(source)
|
|
205
|
+
try:
|
|
206
|
+
size = os.path.getsize(path)
|
|
207
|
+
with open(path, "rb"):
|
|
208
|
+
pass
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
raise ScanError(f"Cannot read {path}: {exc.strerror or exc}", code="FILE_UNREADABLE") from exc
|
|
211
|
+
upload = PreparedUpload(filename or _basename(path), size, path=path)
|
|
212
|
+
elif isinstance(source, (bytes, bytearray, memoryview)):
|
|
213
|
+
data = bytes(source)
|
|
214
|
+
upload = PreparedUpload(filename or "upload.bin", len(data), data=data)
|
|
215
|
+
elif hasattr(source, "read"):
|
|
216
|
+
stream: BinaryIO = source
|
|
217
|
+
name = filename
|
|
218
|
+
if not name:
|
|
219
|
+
attr = getattr(stream, "name", None)
|
|
220
|
+
name = _basename(attr) if isinstance(attr, str) else "upload.bin"
|
|
221
|
+
seekable = False
|
|
222
|
+
try:
|
|
223
|
+
seekable = bool(stream.seekable())
|
|
224
|
+
except (AttributeError, ValueError):
|
|
225
|
+
seekable = False
|
|
226
|
+
if seekable:
|
|
227
|
+
start = stream.tell()
|
|
228
|
+
end = stream.seek(0, os.SEEK_END)
|
|
229
|
+
stream.seek(start)
|
|
230
|
+
if end - start > max_bytes:
|
|
231
|
+
raise too_big(end - start)
|
|
232
|
+
upload = PreparedUpload(name, end - start, stream=stream)
|
|
233
|
+
else: # a pipe or socket: buffer it, but never more than the limit
|
|
234
|
+
buf = BytesIO()
|
|
235
|
+
while True:
|
|
236
|
+
chunk = stream.read(1024 * 1024)
|
|
237
|
+
if not chunk:
|
|
238
|
+
break
|
|
239
|
+
buf.write(chunk)
|
|
240
|
+
if buf.tell() > max_bytes:
|
|
241
|
+
raise too_big(buf.tell())
|
|
242
|
+
data = buf.getvalue()
|
|
243
|
+
upload = PreparedUpload(name, len(data), data=data)
|
|
244
|
+
else:
|
|
245
|
+
raise ConfigError("source must be a file path, bytes, or a binary file object")
|
|
246
|
+
if upload.size > max_bytes:
|
|
247
|
+
raise too_big(upload.size)
|
|
248
|
+
return upload
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def request_id_of(headers: Mapping[str, str]) -> Optional[str]:
|
|
252
|
+
return headers.get("x-request-id")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def to_error(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> KelpgateError:
|
|
256
|
+
"""Map a failed response to the right exception."""
|
|
257
|
+
message = str(data.get("error") or data.get("message") or f"Kelpgate returned HTTP {status}")
|
|
258
|
+
kw: Dict[str, Any] = {"status": status, "request_id": request_id_of(headers), "body": data}
|
|
259
|
+
if status in (401, 403):
|
|
260
|
+
return AuthError(message, code="UNAUTHORIZED" if status == 401 else "FORBIDDEN", **kw)
|
|
261
|
+
if status == 402:
|
|
262
|
+
return QuotaExceededError(message, **kw)
|
|
263
|
+
if status == 413:
|
|
264
|
+
return PayloadTooLargeError(message, **kw)
|
|
265
|
+
if status == 415:
|
|
266
|
+
return UnsupportedFileError(message, **kw)
|
|
267
|
+
if status == 429:
|
|
268
|
+
try:
|
|
269
|
+
retry_after: Optional[float] = float(headers.get("retry-after", ""))
|
|
270
|
+
except ValueError:
|
|
271
|
+
retry_after = None
|
|
272
|
+
return RateLimitedError(message, retry_after=retry_after, **kw)
|
|
273
|
+
if status == 404:
|
|
274
|
+
return KelpgateError(message, code="NOT_FOUND", **kw)
|
|
275
|
+
if status in (400, 412):
|
|
276
|
+
return ScanError(message, **kw)
|
|
277
|
+
if status >= 500:
|
|
278
|
+
return ServerError(message, **kw)
|
|
279
|
+
return KelpgateError(message, **kw)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def to_result(data: Dict[str, Any], headers: Mapping[str, str]) -> ScanResult:
|
|
283
|
+
signature = str(data.get("av-signature") or "")
|
|
284
|
+
authors = data.get("rule_authors")
|
|
285
|
+
return ScanResult(
|
|
286
|
+
infected=data.get("av-status") == "INFECTED",
|
|
287
|
+
status=str(data.get("av-status")),
|
|
288
|
+
signature=signature,
|
|
289
|
+
sha256=data.get("sha256"),
|
|
290
|
+
rule_authors=tuple(str(a) for a in authors) if isinstance(authors, list) else (),
|
|
291
|
+
filename=data.get("filename"),
|
|
292
|
+
scan_id=data.get("scan_id"),
|
|
293
|
+
timestamp=data.get("av-timestamp"),
|
|
294
|
+
s3_path=data.get("s3_path"),
|
|
295
|
+
download_url=data.get("download_url"),
|
|
296
|
+
request_id=request_id_of(headers),
|
|
297
|
+
policy_rejected=signature.startswith("POLICY:"),
|
|
298
|
+
degraded="scan coverage reduced" in signature,
|
|
299
|
+
raw=data,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def scan_outcome(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> ScanResult:
|
|
304
|
+
"""A scan answers 200 (clean) or 406 (infected). Both are verdicts. Anything else is an error."""
|
|
305
|
+
if status in (200, 406) and data.get("av-status"):
|
|
306
|
+
return to_result(data, headers)
|
|
307
|
+
raise to_error(status, data, headers)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def accepted_outcome(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> AcceptedScan:
|
|
311
|
+
if status == 202 and data.get("scan_id"):
|
|
312
|
+
return AcceptedScan(
|
|
313
|
+
scan_id=str(data["scan_id"]),
|
|
314
|
+
filename=data.get("filename"),
|
|
315
|
+
message=data.get("message"),
|
|
316
|
+
request_id=request_id_of(headers),
|
|
317
|
+
)
|
|
318
|
+
raise to_error(status, data, headers)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def poll_outcome(
|
|
322
|
+
scan_id: str, status: int, data: Dict[str, Any], headers: Mapping[str, str]
|
|
323
|
+
) -> Union[ScanResult, PendingScan]:
|
|
324
|
+
if status == 202 or data.get("av-status") == "PROCESSING":
|
|
325
|
+
return PendingScan(scan_id=scan_id, request_id=request_id_of(headers))
|
|
326
|
+
return scan_outcome(status, data, headers)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def health_outcome(status: int, data: Dict[str, Any], headers: Mapping[str, str]) -> Health:
|
|
330
|
+
return Health(
|
|
331
|
+
ok=status == 200,
|
|
332
|
+
status=status,
|
|
333
|
+
signature_version=data.get("signature_version"),
|
|
334
|
+
request_id=request_id_of(headers),
|
|
335
|
+
)
|