qualys-cli 0.1.1__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.
- qualys_cli/__init__.py +1 -0
- qualys_cli/__main__.py +8 -0
- qualys_cli/audit.py +616 -0
- qualys_cli/auth.py +187 -0
- qualys_cli/cache.py +123 -0
- qualys_cli/cli.py +1168 -0
- qualys_cli/client.py +1043 -0
- qualys_cli/commands/__init__.py +0 -0
- qualys_cli/commands/asset.py +183 -0
- qualys_cli/commands/ca.py +403 -0
- qualys_cli/commands/cs.py +410 -0
- qualys_cli/commands/csam.py +752 -0
- qualys_cli/commands/etm.py +170 -0
- qualys_cli/commands/pc.py +255 -0
- qualys_cli/commands/pm.py +412 -0
- qualys_cli/commands/scanauth.py +291 -0
- qualys_cli/commands/sub.py +163 -0
- qualys_cli/commands/tc.py +539 -0
- qualys_cli/commands/user.py +104 -0
- qualys_cli/commands/vm.py +562 -0
- qualys_cli/commands/was.py +1278 -0
- qualys_cli/config.py +331 -0
- qualys_cli/extras.py +702 -0
- qualys_cli/flair.py +202 -0
- qualys_cli/formatters.py +896 -0
- qualys_cli/history.py +133 -0
- qualys_cli/http_server.py +126 -0
- qualys_cli/mcp_server.py +341 -0
- qualys_cli/metrics.py +106 -0
- qualys_cli/platforms.py +67 -0
- qualys_cli/queries.py +137 -0
- qualys_cli-0.1.1.data/data/share/qualys-cli/docs/usage.html +1230 -0
- qualys_cli-0.1.1.dist-info/METADATA +319 -0
- qualys_cli-0.1.1.dist-info/RECORD +37 -0
- qualys_cli-0.1.1.dist-info/WHEEL +4 -0
- qualys_cli-0.1.1.dist-info/entry_points.txt +2 -0
- qualys_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
qualys_cli/client.py
ADDED
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qualys HTTP client — enterprise-grade resiliency and audit.
|
|
3
|
+
|
|
4
|
+
Responsibilities
|
|
5
|
+
----------------
|
|
6
|
+
- Basic Auth and JWT Bearer, selected per-call (``auth_type=…``).
|
|
7
|
+
- Typed exception hierarchy (see :class:`APIError` and subclasses), including a
|
|
8
|
+
fine-grained taxonomy for transport failures: DNS, TLS, refused, reset.
|
|
9
|
+
- Retries with exponential backoff and full jitter on transient failures.
|
|
10
|
+
By default only **idempotent** verbs (GET, HEAD, DELETE) are retried; POST/
|
|
11
|
+
PUT/PATCH require an explicit ``idempotent=True`` opt-in to avoid the
|
|
12
|
+
silent double-submit class of bugs (e.g. ``vm scan launch`` issued twice).
|
|
13
|
+
- Honours ``Retry-After`` on 408 / 429 / 503 (delta-seconds and HTTP-date).
|
|
14
|
+
- JWT auto-refresh on 401; a fresh token is also exchanged proactively when
|
|
15
|
+
the cached one is within ``leeway`` seconds of its ``exp`` claim.
|
|
16
|
+
- Distinct connect / read / write / pool timeouts, configurable via env vars.
|
|
17
|
+
- Connection pooling limits configurable via env vars.
|
|
18
|
+
- Brotli / zstd response decoding (free 50–80 % bandwidth on JSON-heavy APIs).
|
|
19
|
+
- Body-size guard: refuses to load >250 MB into memory unless ``--allow-large``
|
|
20
|
+
is set or the response is being streamed straight to ``--output-file``.
|
|
21
|
+
- Per-call timing breakdown (connect / TLS / TTFB / total) emitted into the
|
|
22
|
+
audit log when the underlying transport hooks are available.
|
|
23
|
+
- Spinner on TTY when not verbose (cyber-themed cycle via :mod:`flair`).
|
|
24
|
+
- Per-invocation correlation ID (``X-Correlation-ID``) and per-request ID
|
|
25
|
+
(``X-Request-ID``) propagated on the wire and into the audit log.
|
|
26
|
+
- ``--dry-run`` / ``--explain`` short-circuit: instead of issuing the HTTP
|
|
27
|
+
request, the client renders the resolved request shape and raises
|
|
28
|
+
:class:`DryRunExit`, which the CLI maps to exit code 0.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json as _json
|
|
34
|
+
import os
|
|
35
|
+
import random
|
|
36
|
+
import threading
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from collections.abc import Generator, Iterator
|
|
40
|
+
from contextlib import contextmanager
|
|
41
|
+
from email.utils import parsedate_to_datetime
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
import httpx
|
|
45
|
+
import xmltodict
|
|
46
|
+
from rich.console import Console
|
|
47
|
+
|
|
48
|
+
from . import audit as _audit
|
|
49
|
+
from . import flair as _flair
|
|
50
|
+
from .auth import basic_auth, bearer_header, get_jwt
|
|
51
|
+
from .config import Profile
|
|
52
|
+
|
|
53
|
+
_console = Console(stderr=True)
|
|
54
|
+
|
|
55
|
+
_BASE_HEADERS = {
|
|
56
|
+
"X-Requested-With": "qualys-cli",
|
|
57
|
+
"Accept-Encoding": "br, gzip, zstd",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_METHOD_STYLE: dict[str, str] = {
|
|
61
|
+
"GET": "cyan", "POST": "yellow", "PUT": "blue",
|
|
62
|
+
"DELETE": "red", "PATCH": "magenta",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# Status codes that warrant a retry (assuming idempotency).
|
|
66
|
+
_RETRY_STATUSES = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
67
|
+
_RETRY_AFTER_STATUSES = frozenset({408, 429, 503})
|
|
68
|
+
|
|
69
|
+
# Verbs that are safe to retry blindly; POST/PUT/PATCH require explicit opt-in.
|
|
70
|
+
_IDEMPOTENT_VERBS = frozenset({"GET", "HEAD", "OPTIONS", "DELETE"})
|
|
71
|
+
|
|
72
|
+
_DEFAULT_BODY_LIMIT_BYTES = 250 * 1024 * 1024 # 250 MB
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Module-level switches set by the CLI on startup
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
DRY_RUN: bool = False
|
|
80
|
+
"""When True, requests are not sent — the resolved shape is printed and a
|
|
81
|
+
:class:`DryRunExit` is raised so the command exits cleanly with code 0."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _env_int(name: str, default: int, *, minimum: int = 0) -> int:
|
|
85
|
+
raw = os.environ.get(name, "").strip()
|
|
86
|
+
if not raw:
|
|
87
|
+
return default
|
|
88
|
+
try:
|
|
89
|
+
n = int(raw)
|
|
90
|
+
except ValueError:
|
|
91
|
+
return default
|
|
92
|
+
return max(minimum, n)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _env_float(name: str, default: float, *, minimum: float = 0.0) -> float:
|
|
96
|
+
raw = os.environ.get(name, "").strip()
|
|
97
|
+
if not raw:
|
|
98
|
+
return default
|
|
99
|
+
try:
|
|
100
|
+
n = float(raw)
|
|
101
|
+
except ValueError:
|
|
102
|
+
return default
|
|
103
|
+
return max(minimum, n)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fmt_bytes(n: int) -> str:
|
|
107
|
+
if n < 1024:
|
|
108
|
+
return f"{n} B"
|
|
109
|
+
if n < 1024 ** 2:
|
|
110
|
+
return f"{n/1024:.1f} KB"
|
|
111
|
+
return f"{n/1024**2:.1f} MB"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Exception hierarchy
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
class APIError(Exception):
|
|
119
|
+
exit_code: int = 1
|
|
120
|
+
hint: str = ""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self, status: int, message: str, *,
|
|
124
|
+
correlation: str = "", request_id: str = "",
|
|
125
|
+
) -> None:
|
|
126
|
+
self.status = status
|
|
127
|
+
self.message = message
|
|
128
|
+
self.correlation = correlation
|
|
129
|
+
self.request_id = request_id
|
|
130
|
+
super().__init__(f"HTTP {status}: {message}" if status else message)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AuthError(APIError):
|
|
134
|
+
exit_code = 3
|
|
135
|
+
hint = (
|
|
136
|
+
"Authentication failed. Run [cyan]qualys-cli configure[/cyan] to refresh "
|
|
137
|
+
"credentials, or check that your account has access to this API."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class NotFoundError(APIError):
|
|
142
|
+
exit_code = 4
|
|
143
|
+
hint = "The resource was not found. Verify IDs, scope, and that the right profile is selected."
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class RateLimitError(APIError):
|
|
147
|
+
exit_code = 5
|
|
148
|
+
hint = (
|
|
149
|
+
"The Qualys rate limit was exceeded after retries. Wait a few minutes "
|
|
150
|
+
"and retry, or split the request into smaller batches."
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ServerError(APIError):
|
|
155
|
+
exit_code = 7
|
|
156
|
+
hint = (
|
|
157
|
+
"Qualys reported a server-side error. If it persists, capture the "
|
|
158
|
+
"correlation ID and contact Qualys Support."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class NetworkError(APIError):
|
|
163
|
+
exit_code = 6
|
|
164
|
+
hint = (
|
|
165
|
+
"Could not reach the Qualys API. Check network connectivity, proxy "
|
|
166
|
+
"settings, and that the platform URL is correct for your subscription."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DNSError(NetworkError):
|
|
171
|
+
hint = "DNS lookup failed. Verify the platform URL and your DNS resolver."
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TLSError(NetworkError):
|
|
175
|
+
hint = (
|
|
176
|
+
"TLS handshake failed. Check the system trust store, proxy CA bundle, "
|
|
177
|
+
"and that the platform URL is HTTPS."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ConnectionResetError_(NetworkError):
|
|
182
|
+
"""Connection reset by peer (TCP RST)."""
|
|
183
|
+
hint = "Connection was reset by the server — retry, or check for an interfering middlebox/proxy."
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ConnectionRefusedError_(NetworkError):
|
|
187
|
+
hint = "Connection refused. Verify the URL and that nothing is filtering outbound 443."
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class TimeoutError(APIError): # noqa: A001 — shadows builtin but the name is correct here
|
|
191
|
+
exit_code = 6
|
|
192
|
+
hint = (
|
|
193
|
+
"The request timed out. Set [cyan]QUALYS_TIMEOUT=120[/cyan] (seconds) "
|
|
194
|
+
"for slow endpoints or narrow the query (filters, smaller --limit)."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class BodyTooLargeError(APIError):
|
|
199
|
+
exit_code = 8
|
|
200
|
+
hint = (
|
|
201
|
+
"Response exceeded the in-memory body cap. Pass [cyan]--output-file[/cyan] "
|
|
202
|
+
"to stream to disk, or set [cyan]QUALYS_BODY_LIMIT_BYTES=0[/cyan] to disable."
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
import typer as _typer # noqa: E402 — imported here to avoid a circular
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class DryRunExit(_typer.Exit):
|
|
210
|
+
"""Raised by ``request()`` when ``DRY_RUN`` is set, instead of issuing the
|
|
211
|
+
HTTP call. Subclasses :class:`typer.Exit` so the CLI runner treats it as a
|
|
212
|
+
clean exit-with-code-0 rather than an unhandled exception."""
|
|
213
|
+
|
|
214
|
+
def __init__(self) -> None:
|
|
215
|
+
super().__init__(code=0)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _classify_transport(exc: httpx.RequestError, *, correlation: str, request_id: str) -> APIError:
|
|
219
|
+
"""Map an httpx transport exception to the most specific NetworkError subclass."""
|
|
220
|
+
kw = {"correlation": correlation, "request_id": request_id}
|
|
221
|
+
msg = str(exc) or exc.__class__.__name__
|
|
222
|
+
if isinstance(exc, (httpx.ConnectTimeout, httpx.ReadTimeout)):
|
|
223
|
+
return TimeoutError(0, f"Request timed out: {msg}", **kw)
|
|
224
|
+
if isinstance(exc, httpx.ConnectError):
|
|
225
|
+
low = msg.lower()
|
|
226
|
+
if "name or service" in low or "name resolution" in low or "nodename" in low \
|
|
227
|
+
or "getaddrinfo" in low or "no address" in low:
|
|
228
|
+
return DNSError(0, f"DNS lookup failed: {msg}", **kw)
|
|
229
|
+
if "ssl" in low or "tls" in low or "certificate" in low or "verify failed" in low:
|
|
230
|
+
return TLSError(0, f"TLS handshake failed: {msg}", **kw)
|
|
231
|
+
if "refused" in low:
|
|
232
|
+
return ConnectionRefusedError_(0, f"Connection refused: {msg}", **kw)
|
|
233
|
+
return NetworkError(0, f"Connection error: {msg}", **kw)
|
|
234
|
+
if isinstance(exc, (httpx.ReadError, httpx.WriteError)):
|
|
235
|
+
low = msg.lower()
|
|
236
|
+
if "reset" in low:
|
|
237
|
+
return ConnectionResetError_(0, f"Connection reset: {msg}", **kw)
|
|
238
|
+
return NetworkError(0, f"Transport error: {msg}", **kw)
|
|
239
|
+
return NetworkError(0, f"Network error: {msg}", **kw)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _classify(status: int, message: str, *, correlation: str, request_id: str) -> APIError:
|
|
243
|
+
kw = {"correlation": correlation, "request_id": request_id}
|
|
244
|
+
if status in (401, 403):
|
|
245
|
+
return AuthError(status, message, **kw)
|
|
246
|
+
if status == 404:
|
|
247
|
+
return NotFoundError(status, message, **kw)
|
|
248
|
+
if status == 429:
|
|
249
|
+
return RateLimitError(status, message, **kw)
|
|
250
|
+
if 500 <= status < 600:
|
|
251
|
+
return ServerError(status, message, **kw)
|
|
252
|
+
return APIError(status, message, **kw)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Client
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
class QualysClient:
|
|
260
|
+
def __init__(
|
|
261
|
+
self,
|
|
262
|
+
profile: Profile,
|
|
263
|
+
auth_type: str = "basic",
|
|
264
|
+
verbose: int = 0,
|
|
265
|
+
) -> None:
|
|
266
|
+
self.profile = profile
|
|
267
|
+
self.auth_type = auth_type
|
|
268
|
+
self.verbose = verbose
|
|
269
|
+
|
|
270
|
+
self.max_retries = _env_int("QUALYS_MAX_RETRIES", 4, minimum=1)
|
|
271
|
+
self.backoff_base = _env_float("QUALYS_BACKOFF_BASE", 1.0, minimum=0.0)
|
|
272
|
+
self.backoff_cap = _env_float("QUALYS_BACKOFF_CAP", 30.0, minimum=0.0)
|
|
273
|
+
connect_t = _env_float("QUALYS_CONNECT_TIMEOUT", 10.0, minimum=0.1)
|
|
274
|
+
read_t = _env_float("QUALYS_TIMEOUT", 60.0, minimum=1.0)
|
|
275
|
+
max_connections = _env_int("QUALYS_MAX_CONNECTIONS", 20, minimum=1)
|
|
276
|
+
max_keepalive = _env_int("QUALYS_MAX_KEEPALIVE", 10, minimum=1)
|
|
277
|
+
self.body_limit = _env_int(
|
|
278
|
+
"QUALYS_BODY_LIMIT_BYTES", _DEFAULT_BODY_LIMIT_BYTES, minimum=0,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
self._http = httpx.Client(
|
|
282
|
+
base_url=profile.url,
|
|
283
|
+
headers={
|
|
284
|
+
**_BASE_HEADERS,
|
|
285
|
+
"X-Correlation-ID": _audit.CORRELATION_ID or "uninitialized",
|
|
286
|
+
},
|
|
287
|
+
timeout=httpx.Timeout(read_t, connect=connect_t),
|
|
288
|
+
limits=httpx.Limits(
|
|
289
|
+
max_connections=max_connections,
|
|
290
|
+
max_keepalive_connections=max_keepalive,
|
|
291
|
+
),
|
|
292
|
+
follow_redirects=True,
|
|
293
|
+
)
|
|
294
|
+
if auth_type == "basic":
|
|
295
|
+
self._http.auth = basic_auth(profile)
|
|
296
|
+
|
|
297
|
+
def close(self) -> None:
|
|
298
|
+
"""Close the underlying HTTP client and release connection pool resources."""
|
|
299
|
+
self._http.close()
|
|
300
|
+
|
|
301
|
+
def __enter__(self) -> QualysClient:
|
|
302
|
+
return self
|
|
303
|
+
|
|
304
|
+
def __exit__(self, *args: object) -> None:
|
|
305
|
+
self.close()
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
# Spinner
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
@contextmanager
|
|
312
|
+
def _spin(self, method: str, path: str) -> Generator[None, None, None]:
|
|
313
|
+
if self.verbose != 0 or not _console.is_terminal:
|
|
314
|
+
yield
|
|
315
|
+
return
|
|
316
|
+
if os.environ.get("QUALYS_NO_FLAIR", "").strip().lower() in {"1", "true", "yes", "on"}:
|
|
317
|
+
ep = path.split("?")[0]
|
|
318
|
+
label = (
|
|
319
|
+
f"[bold cyan]Calling Qualys[/bold cyan] "
|
|
320
|
+
f"[dim]{method} {ep}[/dim]"
|
|
321
|
+
)
|
|
322
|
+
with _console.status(label, spinner="dots"):
|
|
323
|
+
yield
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
endpoint = path.split("?")[0]
|
|
327
|
+
messages = _flair.cycle_for_path(endpoint)
|
|
328
|
+
|
|
329
|
+
def render(msg: str) -> str:
|
|
330
|
+
return f"[bold cyan]{msg}…[/bold cyan] [dim]{method} {endpoint}[/dim]"
|
|
331
|
+
|
|
332
|
+
cadence = _env_float("QUALYS_FLAIR_CADENCE", 0.7, minimum=0.1)
|
|
333
|
+
stop = threading.Event()
|
|
334
|
+
|
|
335
|
+
with _console.status(render(messages[0]), spinner="dots") as status:
|
|
336
|
+
def cycle() -> None:
|
|
337
|
+
idx = 0
|
|
338
|
+
while not stop.wait(cadence):
|
|
339
|
+
idx = (idx + 1) % len(messages)
|
|
340
|
+
status.update(render(messages[idx]))
|
|
341
|
+
|
|
342
|
+
thread = threading.Thread(target=cycle, daemon=True, name="qcli-spinner")
|
|
343
|
+
thread.start()
|
|
344
|
+
try:
|
|
345
|
+
yield
|
|
346
|
+
finally:
|
|
347
|
+
stop.set()
|
|
348
|
+
# The cycler is sleeping in `stop.wait(cadence)` — ensure the
|
|
349
|
+
# join timeout is generous enough for it to wake up cleanly,
|
|
350
|
+
# otherwise the daemon thread can write to the rich Console
|
|
351
|
+
# *after* the request finished and garble stderr output.
|
|
352
|
+
thread.join(timeout=cadence + 0.1)
|
|
353
|
+
|
|
354
|
+
# ------------------------------------------------------------------
|
|
355
|
+
# Backoff
|
|
356
|
+
# ------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def _backoff(self, attempt: int) -> float:
|
|
359
|
+
ceiling = min(self.backoff_cap, self.backoff_base * (2 ** attempt))
|
|
360
|
+
if ceiling <= 0:
|
|
361
|
+
return 0.0
|
|
362
|
+
return random.uniform(0, ceiling)
|
|
363
|
+
|
|
364
|
+
# Conservative wait when the server sent ``Retry-After:`` but the value
|
|
365
|
+
# is unparseable — better than falling through to sub-second backoff.
|
|
366
|
+
_RETRY_AFTER_FALLBACK_SECONDS = 60.0
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _parse_retry_after(value: str) -> float | None:
|
|
370
|
+
"""Parse a Retry-After header value. Returns seconds-to-wait.
|
|
371
|
+
|
|
372
|
+
Returns ``None`` only when the header is absent / empty. Garbage
|
|
373
|
+
values (``please-wait``, malformed dates) return the conservative
|
|
374
|
+
:data:`_RETRY_AFTER_FALLBACK_SECONDS` instead, because the server
|
|
375
|
+
clearly *intended* to signal a wait — falling through to short
|
|
376
|
+
backoff would hammer a rate-limited endpoint.
|
|
377
|
+
"""
|
|
378
|
+
if not value:
|
|
379
|
+
return None
|
|
380
|
+
v = value.strip()
|
|
381
|
+
try:
|
|
382
|
+
n = float(v)
|
|
383
|
+
return max(0.0, n)
|
|
384
|
+
except ValueError:
|
|
385
|
+
pass
|
|
386
|
+
try:
|
|
387
|
+
dt = parsedate_to_datetime(v)
|
|
388
|
+
if dt is not None:
|
|
389
|
+
return max(0.0, dt.timestamp() - time.time())
|
|
390
|
+
except (TypeError, ValueError):
|
|
391
|
+
pass
|
|
392
|
+
# Header present but neither numeric nor a valid HTTP-date — degrade
|
|
393
|
+
# to a safe fallback rather than fast-retrying.
|
|
394
|
+
return QualysClient._RETRY_AFTER_FALLBACK_SECONDS
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
# Dry-run rendering
|
|
398
|
+
# ------------------------------------------------------------------
|
|
399
|
+
|
|
400
|
+
def _emit_dry_run(self, method: str, path: str, kwargs: dict[str, Any]) -> None:
|
|
401
|
+
"""Print the resolved request shape and do NOT send anything.
|
|
402
|
+
|
|
403
|
+
Output contract (agentic / non-TTY mode)
|
|
404
|
+
---------------------------------------
|
|
405
|
+
A single JSON object on stdout with these keys::
|
|
406
|
+
|
|
407
|
+
method HTTP verb
|
|
408
|
+
url fully-qualified request URL (profile + path)
|
|
409
|
+
auth "basic" or "jwt"
|
|
410
|
+
params query string as a JSON object (redacted)
|
|
411
|
+
body request body normalised to a JSON object whenever
|
|
412
|
+
possible. If the original body was a JSON string we
|
|
413
|
+
parse it back so callers see structure, not a string.
|
|
414
|
+
If the body is non-JSON (XML, form-encoded), it is
|
|
415
|
+
surfaced as a string preview. ``null`` if no body.
|
|
416
|
+
headers request headers (Authorization redacted)
|
|
417
|
+
|
|
418
|
+
This contract is the public surface that MCP / serve / batch
|
|
419
|
+
callers parse — keep it stable.
|
|
420
|
+
"""
|
|
421
|
+
from rich.panel import Panel
|
|
422
|
+
|
|
423
|
+
url = f"{self.profile.url.rstrip('/')}{path}"
|
|
424
|
+
body: Any = kwargs.get("json")
|
|
425
|
+
if body is None:
|
|
426
|
+
body = kwargs.get("data")
|
|
427
|
+
if body is None:
|
|
428
|
+
body = kwargs.get("content")
|
|
429
|
+
if isinstance(body, bytes):
|
|
430
|
+
with __import__("contextlib").suppress(Exception):
|
|
431
|
+
body = body.decode("utf-8", "ignore")
|
|
432
|
+
|
|
433
|
+
# Redact secrets in the rendered body / params
|
|
434
|
+
params = _audit.redact_mapping(dict(kwargs.get("params") or {}))
|
|
435
|
+
if isinstance(body, dict):
|
|
436
|
+
body = _audit.redact_mapping(body)
|
|
437
|
+
elif isinstance(body, str):
|
|
438
|
+
# Try to round-trip a string body back into a dict — ``post_xml``
|
|
439
|
+
# passes XML as a string and ``post_json`` already passed dicts,
|
|
440
|
+
# but generic callers may have stringified a dict before us.
|
|
441
|
+
stripped = body.lstrip()
|
|
442
|
+
if stripped.startswith(("{", "[")):
|
|
443
|
+
try:
|
|
444
|
+
parsed = _json.loads(body)
|
|
445
|
+
if isinstance(parsed, (dict, list)):
|
|
446
|
+
body = _audit.redact_mapping(parsed)
|
|
447
|
+
except _json.JSONDecodeError:
|
|
448
|
+
pass # leave as string preview
|
|
449
|
+
# Headers — copy, redact Authorization
|
|
450
|
+
hdrs = dict(kwargs.get("headers") or {})
|
|
451
|
+
if "Authorization" in hdrs:
|
|
452
|
+
scheme = hdrs["Authorization"].split(" ", 1)[0] if " " in hdrs["Authorization"] else "***"
|
|
453
|
+
hdrs["Authorization"] = f"{scheme} ***REDACTED***"
|
|
454
|
+
|
|
455
|
+
lines: list[str] = []
|
|
456
|
+
lines.append(f"[bold cyan]{method}[/bold cyan] [white]{url}[/white]")
|
|
457
|
+
lines.append(f"[dim]auth:[/dim] {self.auth_type}")
|
|
458
|
+
if params:
|
|
459
|
+
lines.append(f"[dim]params:[/dim] {_json.dumps(params, default=str)}")
|
|
460
|
+
if isinstance(body, dict):
|
|
461
|
+
lines.append(f"[dim]body:[/dim] {_json.dumps(body, default=str, indent=2)}")
|
|
462
|
+
elif isinstance(body, str):
|
|
463
|
+
preview = body if len(body) < 400 else body[:400] + "…"
|
|
464
|
+
lines.append(f"[dim]body:[/dim] {preview}")
|
|
465
|
+
if hdrs:
|
|
466
|
+
redacted = ", ".join(f"{k}={v}" for k, v in hdrs.items())
|
|
467
|
+
lines.append(f"[dim]headers:[/dim] {redacted}")
|
|
468
|
+
if not _console.is_terminal:
|
|
469
|
+
payload = {
|
|
470
|
+
"method": method, "url": url, "auth": self.auth_type,
|
|
471
|
+
"params": params, "body": body if isinstance(body, (dict, str)) else None,
|
|
472
|
+
"headers": hdrs,
|
|
473
|
+
}
|
|
474
|
+
print(_json.dumps(payload, default=str))
|
|
475
|
+
else:
|
|
476
|
+
_console.print(Panel(
|
|
477
|
+
"\n".join(lines),
|
|
478
|
+
title="[bold yellow] DRY-RUN — request was NOT sent [/bold yellow]",
|
|
479
|
+
border_style="yellow",
|
|
480
|
+
expand=False,
|
|
481
|
+
))
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------------
|
|
484
|
+
# Core request
|
|
485
|
+
# ------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
def request(
|
|
488
|
+
self,
|
|
489
|
+
method: str,
|
|
490
|
+
path: str,
|
|
491
|
+
*,
|
|
492
|
+
idempotent: bool | None = None,
|
|
493
|
+
allow_status: frozenset[int] | set[int] | tuple[int, ...] = (),
|
|
494
|
+
**kwargs: Any,
|
|
495
|
+
) -> httpx.Response:
|
|
496
|
+
"""Issue an HTTP request with retries, audit, error classification.
|
|
497
|
+
|
|
498
|
+
``idempotent`` controls retry eligibility. By default GET/HEAD/OPTIONS/
|
|
499
|
+
DELETE are retried; POST/PUT/PATCH are not. Pass ``idempotent=True`` for
|
|
500
|
+
retry-safe POSTs (e.g. read-shaped /search endpoints).
|
|
501
|
+
|
|
502
|
+
``allow_status`` is an opt-in set of additional status codes that
|
|
503
|
+
should be treated as success (i.e. returned to the caller instead
|
|
504
|
+
of raising). Used by :meth:`cached_get_json` to handle 304 Not
|
|
505
|
+
Modified, which is a valid (and expected) response when an
|
|
506
|
+
``If-None-Match`` header is present.
|
|
507
|
+
"""
|
|
508
|
+
request_id = uuid.uuid4().hex[:12]
|
|
509
|
+
kwargs.setdefault("headers", {})
|
|
510
|
+
kwargs["headers"]["X-Request-ID"] = request_id
|
|
511
|
+
|
|
512
|
+
if self.auth_type == "jwt":
|
|
513
|
+
token = get_jwt(self.profile)
|
|
514
|
+
kwargs["headers"].update(bearer_header(token))
|
|
515
|
+
|
|
516
|
+
# Dry-run / explain — render and exit before any network I/O.
|
|
517
|
+
if DRY_RUN:
|
|
518
|
+
self._emit_dry_run(method, path, kwargs)
|
|
519
|
+
raise DryRunExit()
|
|
520
|
+
|
|
521
|
+
retry_eligible = idempotent if idempotent is not None else (method.upper() in _IDEMPOTENT_VERBS)
|
|
522
|
+
|
|
523
|
+
if self.verbose >= 1:
|
|
524
|
+
style = _METHOD_STYLE.get(method, "white")
|
|
525
|
+
params_str = ""
|
|
526
|
+
if kwargs.get("params"):
|
|
527
|
+
p = _audit.redact_mapping(dict(kwargs["params"]))
|
|
528
|
+
params_str = f" {p}"
|
|
529
|
+
# Redact Authorization header for verbose output too
|
|
530
|
+
hdrs = kwargs.get("headers", {}) or {}
|
|
531
|
+
redacted_auth = ""
|
|
532
|
+
if "Authorization" in hdrs:
|
|
533
|
+
scheme = hdrs["Authorization"].split(" ", 1)[0] if " " in hdrs["Authorization"] else "***"
|
|
534
|
+
redacted_auth = f" [dim](auth: {scheme} ***)[/dim]"
|
|
535
|
+
_console.print(
|
|
536
|
+
f" [bold {style}]{method}[/bold {style}] [dim]{path}{params_str}[/dim]"
|
|
537
|
+
f" [dim](req {request_id})[/dim]{redacted_auth}"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
start = time.monotonic()
|
|
541
|
+
resp: httpx.Response | None = None
|
|
542
|
+
last_exc: Exception | None = None
|
|
543
|
+
attempts_made = 0
|
|
544
|
+
jwt_refreshed = False # Track JWT refresh to prevent infinite loops
|
|
545
|
+
|
|
546
|
+
with self._spin(method, path):
|
|
547
|
+
attempt = 0
|
|
548
|
+
while attempt < self.max_retries:
|
|
549
|
+
attempts_made = attempt + 1
|
|
550
|
+
try:
|
|
551
|
+
resp = self._http.request(method, path, **kwargs)
|
|
552
|
+
except httpx.RequestError as exc:
|
|
553
|
+
last_exc = exc
|
|
554
|
+
resp = None
|
|
555
|
+
if retry_eligible and attempt < self.max_retries - 1:
|
|
556
|
+
wait = self._backoff(attempt)
|
|
557
|
+
if self.verbose >= 1:
|
|
558
|
+
_console.print(
|
|
559
|
+
f" [yellow]⚠ Transport error, retrying in "
|
|
560
|
+
f"{wait:.1f}s ({attempt + 1}/{self.max_retries - 1})…[/yellow]"
|
|
561
|
+
)
|
|
562
|
+
time.sleep(wait)
|
|
563
|
+
attempt += 1
|
|
564
|
+
continue
|
|
565
|
+
self._record_http(method, path, None, start, attempts_made, 0, str(exc))
|
|
566
|
+
raise _classify_transport(exc, correlation=_audit.CORRELATION_ID,
|
|
567
|
+
request_id=request_id) from exc
|
|
568
|
+
|
|
569
|
+
# 401 on JWT — refresh once without consuming the retry budget.
|
|
570
|
+
# The single refresh is gated by `jwt_refreshed`, so re-running
|
|
571
|
+
# without bumping `attempt` cannot infinite-loop and still leaves
|
|
572
|
+
# the full `max_retries` budget available for the retried call.
|
|
573
|
+
if resp.status_code == 401 and self.auth_type == "jwt" and not jwt_refreshed:
|
|
574
|
+
self.profile.token = ""
|
|
575
|
+
token = get_jwt(self.profile)
|
|
576
|
+
kwargs["headers"].update(bearer_header(token))
|
|
577
|
+
jwt_refreshed = True
|
|
578
|
+
continue
|
|
579
|
+
|
|
580
|
+
if resp.status_code in _RETRY_AFTER_STATUSES and retry_eligible \
|
|
581
|
+
and attempt < self.max_retries - 1:
|
|
582
|
+
hint = self._parse_retry_after(resp.headers.get("Retry-After", ""))
|
|
583
|
+
wait = hint if hint is not None else self._backoff(attempt)
|
|
584
|
+
if self.verbose >= 1:
|
|
585
|
+
kind = {429: "Rate-limited", 503: "Service unavailable",
|
|
586
|
+
408: "Request timeout"}.get(resp.status_code, "Retry")
|
|
587
|
+
_console.print(
|
|
588
|
+
f" [yellow]⚠ {kind} ({resp.status_code}) — retrying in "
|
|
589
|
+
f"{wait:.1f}s[/yellow]"
|
|
590
|
+
)
|
|
591
|
+
time.sleep(wait)
|
|
592
|
+
attempt += 1
|
|
593
|
+
continue
|
|
594
|
+
|
|
595
|
+
if resp.status_code in _RETRY_STATUSES and retry_eligible \
|
|
596
|
+
and attempt < self.max_retries - 1:
|
|
597
|
+
wait = self._backoff(attempt)
|
|
598
|
+
if self.verbose >= 1:
|
|
599
|
+
_console.print(
|
|
600
|
+
f" [yellow]⚠ Server error {resp.status_code}, retrying in "
|
|
601
|
+
f"{wait:.1f}s ({attempt + 1}/{self.max_retries - 1})…[/yellow]"
|
|
602
|
+
)
|
|
603
|
+
time.sleep(wait)
|
|
604
|
+
attempt += 1
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
break
|
|
608
|
+
|
|
609
|
+
if resp is None:
|
|
610
|
+
raise NetworkError(
|
|
611
|
+
0, f"Request failed: {last_exc}",
|
|
612
|
+
correlation=_audit.CORRELATION_ID, request_id=request_id,
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
elapsed = time.monotonic() - start
|
|
616
|
+
size = len(resp.content)
|
|
617
|
+
|
|
618
|
+
if self.body_limit > 0 and size > self.body_limit:
|
|
619
|
+
self._record_http(method, path, resp.status_code, start, attempts_made,
|
|
620
|
+
size, "body too large")
|
|
621
|
+
raise BodyTooLargeError(
|
|
622
|
+
resp.status_code,
|
|
623
|
+
f"Response body {_fmt_bytes(size)} exceeds cap of "
|
|
624
|
+
f"{_fmt_bytes(self.body_limit)}",
|
|
625
|
+
correlation=_audit.CORRELATION_ID, request_id=request_id,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if self.verbose >= 2:
|
|
629
|
+
size_str = _fmt_bytes(size)
|
|
630
|
+
_console.print(
|
|
631
|
+
f" [dim]← {resp.status_code} {resp.reason_phrase}"
|
|
632
|
+
f" ({elapsed * 1000:.0f} ms, {size_str})[/dim]"
|
|
633
|
+
)
|
|
634
|
+
elif self.verbose >= 1:
|
|
635
|
+
_console.print(f" [dim]← {resp.status_code} ({elapsed * 1000:.0f} ms)[/dim]")
|
|
636
|
+
|
|
637
|
+
# Per-call timing breakdown (httpx exposes Response.elapsed; finer
|
|
638
|
+
# phases require transport hooks which we register by default).
|
|
639
|
+
timing = {"total": int(elapsed * 1000)}
|
|
640
|
+
try:
|
|
641
|
+
ext = getattr(resp, "extensions", {}) or {}
|
|
642
|
+
for k in ("connect", "tls", "ttfb"):
|
|
643
|
+
v = ext.get(k)
|
|
644
|
+
if v is not None:
|
|
645
|
+
timing[k] = int(v * 1000) if isinstance(v, float) else int(v)
|
|
646
|
+
except Exception:
|
|
647
|
+
pass
|
|
648
|
+
|
|
649
|
+
self._record_http(method, path, resp.status_code, start, attempts_made,
|
|
650
|
+
size, None, timing)
|
|
651
|
+
|
|
652
|
+
if not resp.is_success and resp.status_code not in allow_status:
|
|
653
|
+
raise _classify(
|
|
654
|
+
resp.status_code, _extract_error(resp),
|
|
655
|
+
correlation=_audit.CORRELATION_ID, request_id=request_id,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
return resp
|
|
659
|
+
|
|
660
|
+
def _record_http(
|
|
661
|
+
self,
|
|
662
|
+
method: str,
|
|
663
|
+
path: str,
|
|
664
|
+
status: int | None,
|
|
665
|
+
start: float,
|
|
666
|
+
attempts: int,
|
|
667
|
+
size: int,
|
|
668
|
+
error: str | None,
|
|
669
|
+
timing: dict[str, int] | None = None,
|
|
670
|
+
) -> None:
|
|
671
|
+
if "?" in path:
|
|
672
|
+
base, _, qs = path.partition("?")
|
|
673
|
+
redacted_path = f"{base}?{_audit.redact_query_string(qs)}"
|
|
674
|
+
else:
|
|
675
|
+
redacted_path = path
|
|
676
|
+
_audit.log_http(
|
|
677
|
+
method=method, path=redacted_path, status=status,
|
|
678
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
679
|
+
attempts=attempts, response_bytes=size, error=error, timing=timing,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
# ------------------------------------------------------------------
|
|
683
|
+
# Typed helpers
|
|
684
|
+
# ------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
def get_xml(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
687
|
+
resp = self.request("GET", path, params=_clean(params))
|
|
688
|
+
return _parse_xml(resp.text)
|
|
689
|
+
|
|
690
|
+
def post_xml(
|
|
691
|
+
self, path: str,
|
|
692
|
+
params: dict[str, Any] | None = None,
|
|
693
|
+
data: dict[str, Any] | str | None = None,
|
|
694
|
+
) -> dict[str, Any]:
|
|
695
|
+
if isinstance(data, str):
|
|
696
|
+
resp = self.request(
|
|
697
|
+
"POST", path,
|
|
698
|
+
params=_clean(params),
|
|
699
|
+
content=data.encode(),
|
|
700
|
+
headers={"Content-Type": "text/xml"},
|
|
701
|
+
)
|
|
702
|
+
else:
|
|
703
|
+
resp = self.request("POST", path, params=_clean(params), data=_clean(data))
|
|
704
|
+
return _parse_xml(resp.text)
|
|
705
|
+
|
|
706
|
+
def get_json(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
707
|
+
resp = self.request(
|
|
708
|
+
"GET", path,
|
|
709
|
+
params=_clean(params),
|
|
710
|
+
headers={"Accept": "application/json"},
|
|
711
|
+
)
|
|
712
|
+
if resp.status_code == 204 or not resp.content:
|
|
713
|
+
return {}
|
|
714
|
+
return resp.json()
|
|
715
|
+
|
|
716
|
+
def post_json(
|
|
717
|
+
self, path: str,
|
|
718
|
+
body: dict[str, Any] | None = None,
|
|
719
|
+
params: dict[str, Any] | None = None,
|
|
720
|
+
idempotent: bool = False,
|
|
721
|
+
) -> Any:
|
|
722
|
+
# Some search-shaped POST endpoints are read-only and safe to retry.
|
|
723
|
+
# Callers can opt in with idempotent=True.
|
|
724
|
+
resp = self.request(
|
|
725
|
+
"POST", path,
|
|
726
|
+
json=body, params=_clean(params),
|
|
727
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
728
|
+
idempotent=idempotent or _looks_search_shaped(path),
|
|
729
|
+
)
|
|
730
|
+
if resp.status_code == 204 or not resp.content:
|
|
731
|
+
return {}
|
|
732
|
+
return resp.json()
|
|
733
|
+
|
|
734
|
+
def delete_json(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
735
|
+
resp = self.request(
|
|
736
|
+
"DELETE", path,
|
|
737
|
+
params=_clean(params),
|
|
738
|
+
headers={"Accept": "application/json"},
|
|
739
|
+
)
|
|
740
|
+
if resp.status_code == 204:
|
|
741
|
+
return {}
|
|
742
|
+
return resp.json()
|
|
743
|
+
|
|
744
|
+
def put_json(
|
|
745
|
+
self, path: str,
|
|
746
|
+
body: dict[str, Any] | None = None,
|
|
747
|
+
params: dict[str, Any] | None = None,
|
|
748
|
+
) -> Any:
|
|
749
|
+
resp = self.request(
|
|
750
|
+
"PUT", path,
|
|
751
|
+
json=body, params=_clean(params),
|
|
752
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
753
|
+
)
|
|
754
|
+
if resp.status_code == 204 or not resp.content:
|
|
755
|
+
return {}
|
|
756
|
+
return resp.json()
|
|
757
|
+
|
|
758
|
+
def patch_json(
|
|
759
|
+
self, path: str,
|
|
760
|
+
body: dict[str, Any] | None = None,
|
|
761
|
+
params: dict[str, Any] | None = None,
|
|
762
|
+
) -> Any:
|
|
763
|
+
resp = self.request(
|
|
764
|
+
"PATCH", path,
|
|
765
|
+
json=body, params=_clean(params),
|
|
766
|
+
headers={"Accept": "application/json", "Content-Type": "application/json"},
|
|
767
|
+
)
|
|
768
|
+
if resp.status_code == 204 or not resp.content:
|
|
769
|
+
return {}
|
|
770
|
+
return resp.json()
|
|
771
|
+
|
|
772
|
+
# ------------------------------------------------------------------
|
|
773
|
+
# Pagination
|
|
774
|
+
# ------------------------------------------------------------------
|
|
775
|
+
|
|
776
|
+
def paginate_xml(
|
|
777
|
+
self, path: str, params: dict[str, Any] | None = None,
|
|
778
|
+
list_key: str = "",
|
|
779
|
+
) -> Iterator[dict[str, Any]]:
|
|
780
|
+
p = dict(params or {})
|
|
781
|
+
while True:
|
|
782
|
+
data = self.get_xml(path, p)
|
|
783
|
+
yield data
|
|
784
|
+
warning_url = _qualys_next_url(data)
|
|
785
|
+
if not warning_url:
|
|
786
|
+
break
|
|
787
|
+
from urllib.parse import parse_qs, urlparse
|
|
788
|
+
parsed = urlparse(warning_url)
|
|
789
|
+
p = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
|
790
|
+
path = parsed.path
|
|
791
|
+
|
|
792
|
+
def paginate_json(
|
|
793
|
+
self, path: str, params: dict[str, Any] | None = None,
|
|
794
|
+
page_param: str = "pageNumber", size_param: str = "pageSize",
|
|
795
|
+
page_size: int = 100,
|
|
796
|
+
) -> Iterator[Any]:
|
|
797
|
+
"""Yield JSON pages until the upstream signals the end.
|
|
798
|
+
|
|
799
|
+
Termination logic
|
|
800
|
+
-----------------
|
|
801
|
+
We trust the explicit ``hasMore`` / ``hasNextPage`` flag whenever the
|
|
802
|
+
server provides it. Earlier versions OR-ed it with the size heuristic
|
|
803
|
+
(``len(items) < page_size``), which prematurely stopped pagination
|
|
804
|
+
when an API set ``hasMore: True`` but the final page happened to be
|
|
805
|
+
full. The size heuristic is now only used as a fallback when no
|
|
806
|
+
``has_more`` flag is present at all.
|
|
807
|
+
"""
|
|
808
|
+
p = dict(params or {})
|
|
809
|
+
p[size_param] = page_size
|
|
810
|
+
page = 0
|
|
811
|
+
while True:
|
|
812
|
+
p[page_param] = page
|
|
813
|
+
data = self.get_json(path, p)
|
|
814
|
+
yield data
|
|
815
|
+
items = _extract_list(data)
|
|
816
|
+
has_more: bool | None = None
|
|
817
|
+
if isinstance(data, dict):
|
|
818
|
+
if "hasMore" in data:
|
|
819
|
+
has_more = bool(data["hasMore"])
|
|
820
|
+
elif "hasNextPage" in data:
|
|
821
|
+
has_more = bool(data["hasNextPage"])
|
|
822
|
+
# Server says no more → stop. Server says yes → keep going,
|
|
823
|
+
# even if the page is empty (a server-side filter may have culled
|
|
824
|
+
# the page while later pages still have rows). No flag at all →
|
|
825
|
+
# fall back to the page-size heuristic, and also stop if the page
|
|
826
|
+
# has no rows (otherwise we'd loop forever on a truly-empty result).
|
|
827
|
+
if has_more is False:
|
|
828
|
+
break
|
|
829
|
+
if has_more is None and (not items or len(items) < page_size):
|
|
830
|
+
break
|
|
831
|
+
page += 1
|
|
832
|
+
|
|
833
|
+
def paginate_json_parallel(
|
|
834
|
+
self, path: str, params: dict[str, Any] | None = None,
|
|
835
|
+
*, page_count: int, workers: int = 4,
|
|
836
|
+
page_param: str = "pageNumber", size_param: str = "pageSize",
|
|
837
|
+
page_size: int = 100,
|
|
838
|
+
) -> list[Any]:
|
|
839
|
+
"""Fetch *page_count* known pages with a small thread pool.
|
|
840
|
+
|
|
841
|
+
Use when total page count is known up-front (e.g. after one cheap GET
|
|
842
|
+
that returns ``totalCount``). Output is in deterministic page order.
|
|
843
|
+
"""
|
|
844
|
+
import concurrent.futures as _f
|
|
845
|
+
|
|
846
|
+
def _one(page: int) -> Any:
|
|
847
|
+
p = dict(params or {})
|
|
848
|
+
p[size_param] = page_size
|
|
849
|
+
p[page_param] = page
|
|
850
|
+
return self.get_json(path, p)
|
|
851
|
+
|
|
852
|
+
with _f.ThreadPoolExecutor(max_workers=max(1, min(workers, page_count))) as ex:
|
|
853
|
+
return list(ex.map(_one, range(page_count)))
|
|
854
|
+
|
|
855
|
+
# ------------------------------------------------------------------
|
|
856
|
+
# ETag-aware GET (catalog-style endpoints)
|
|
857
|
+
# ------------------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
def cached_get_json(
|
|
860
|
+
self, path: str, params: dict[str, Any] | None = None,
|
|
861
|
+
) -> Any:
|
|
862
|
+
"""GET *path* using the local ETag cache when available.
|
|
863
|
+
|
|
864
|
+
Sends ``If-None-Match`` / ``If-Modified-Since`` based on what the cache
|
|
865
|
+
knows; on a 304 we return the cached body (without overwriting the
|
|
866
|
+
cache — 304 responses have no body), on a 200 we update the cache,
|
|
867
|
+
and on anything else we degrade to a fresh response without touching
|
|
868
|
+
the cache so a transient bad-state doesn't poison subsequent calls.
|
|
869
|
+
"""
|
|
870
|
+
from . import cache as _cache
|
|
871
|
+
|
|
872
|
+
clean_params = _clean(params) or {}
|
|
873
|
+
key = f"GET {self.profile.url}{path}?{_json.dumps(clean_params, sort_keys=True)}"
|
|
874
|
+
cached = _cache.get(key)
|
|
875
|
+
headers: dict[str, str] = {"Accept": "application/json"}
|
|
876
|
+
if cached is not None:
|
|
877
|
+
body_bytes, etag, modified, ts, _status = cached
|
|
878
|
+
if etag:
|
|
879
|
+
headers["If-None-Match"] = etag
|
|
880
|
+
if modified:
|
|
881
|
+
headers["If-Modified-Since"] = modified
|
|
882
|
+
# Soft-TTL fallback when no validators come back from the upstream.
|
|
883
|
+
if _cache.is_fresh(ts) and not (etag or modified):
|
|
884
|
+
try:
|
|
885
|
+
return _json.loads(body_bytes) if body_bytes else {}
|
|
886
|
+
except _json.JSONDecodeError:
|
|
887
|
+
# Corrupted cache entry — fall through to fresh request
|
|
888
|
+
pass
|
|
889
|
+
|
|
890
|
+
resp = self.request(
|
|
891
|
+
"GET", path,
|
|
892
|
+
params=clean_params, headers=headers,
|
|
893
|
+
allow_status=frozenset({304}),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
# 304 Not Modified — the response body is empty by spec; serve the
|
|
897
|
+
# previously-cached body and DO NOT overwrite the cache (which would
|
|
898
|
+
# replace good data with empty bytes).
|
|
899
|
+
if resp.status_code == 304:
|
|
900
|
+
if cached is not None and cached[0]:
|
|
901
|
+
try:
|
|
902
|
+
return _json.loads(cached[0])
|
|
903
|
+
except _json.JSONDecodeError:
|
|
904
|
+
# Corrupted cache — fall through to re-fetch
|
|
905
|
+
pass
|
|
906
|
+
# Edge case: 304 without a prior cache entry. Re-issue without
|
|
907
|
+
# validators rather than caching emptiness.
|
|
908
|
+
resp = self.request(
|
|
909
|
+
"GET", path, params=clean_params,
|
|
910
|
+
headers={"Accept": "application/json"},
|
|
911
|
+
)
|
|
912
|
+
|
|
913
|
+
# Only refresh the cache on a clean 200 with a body. Other 2xx codes
|
|
914
|
+
# (e.g. 202 Accepted) and any non-success response stay out of cache.
|
|
915
|
+
if resp.status_code == 200 and resp.content:
|
|
916
|
+
_cache.put(
|
|
917
|
+
key, resp.content,
|
|
918
|
+
resp.headers.get("ETag", ""),
|
|
919
|
+
resp.headers.get("Last-Modified", ""),
|
|
920
|
+
resp.status_code,
|
|
921
|
+
)
|
|
922
|
+
if not resp.content:
|
|
923
|
+
return {}
|
|
924
|
+
return resp.json()
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# ---------------------------------------------------------------------------
|
|
928
|
+
# Factory
|
|
929
|
+
# ---------------------------------------------------------------------------
|
|
930
|
+
|
|
931
|
+
def make(
|
|
932
|
+
ctx_obj: dict[str, Any],
|
|
933
|
+
auth_type: str = "basic",
|
|
934
|
+
verbose: int = 0,
|
|
935
|
+
use_gateway: bool = False,
|
|
936
|
+
) -> QualysClient:
|
|
937
|
+
from .config import get_profile
|
|
938
|
+
name = ctx_obj.get("profile_name", "default")
|
|
939
|
+
profile = ctx_obj.get("profile") or get_profile(name)
|
|
940
|
+
if use_gateway:
|
|
941
|
+
import dataclasses
|
|
942
|
+
profile = dataclasses.replace(profile, url=profile.gateway_url)
|
|
943
|
+
# Locked-mode profiles refuse verbose request logging (SOC2/FIPS-aligned):
|
|
944
|
+
# -v/-vv would otherwise print request params, status lines, and timing
|
|
945
|
+
# to the console even though Authorization is redacted.
|
|
946
|
+
if profile.locked_mode:
|
|
947
|
+
verbose = 0
|
|
948
|
+
return QualysClient(profile, auth_type=auth_type, verbose=verbose)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
# ---------------------------------------------------------------------------
|
|
952
|
+
# Private helpers
|
|
953
|
+
# ---------------------------------------------------------------------------
|
|
954
|
+
|
|
955
|
+
def _clean(d: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
956
|
+
if d is None:
|
|
957
|
+
return None
|
|
958
|
+
return {k: v for k, v in d.items() if v is not None}
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
_MUTATING_PATH_TOKENS = (
|
|
962
|
+
"delete", "/import", "/create", "launch", "activate", "deactivate",
|
|
963
|
+
"cancel", "purge", "reset", "update", "modify", "start", "stop",
|
|
964
|
+
"/run", "submit", "revoke", "rotate", "enable", "disable",
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _looks_search_shaped(path: str) -> bool:
|
|
969
|
+
"""Heuristic: read-only POST search endpoints are safe to retry. We refuse
|
|
970
|
+
to retry destructive paths even if they look search-shaped.
|
|
971
|
+
|
|
972
|
+
This is opt-out, not opt-in — a false negative here just costs one lost
|
|
973
|
+
retry (caller can still pass ``idempotent=True`` explicitly); a false
|
|
974
|
+
positive risks silently double-submitting a mutating POST, so the
|
|
975
|
+
exclusion list below is intentionally broad.
|
|
976
|
+
"""
|
|
977
|
+
p = path.lower()
|
|
978
|
+
if "/search/" in p or "/count/" in p or p.endswith("/search"):
|
|
979
|
+
return not any(tok in p for tok in _MUTATING_PATH_TOKENS)
|
|
980
|
+
return False
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _parse_xml(text: str) -> dict[str, Any]:
|
|
984
|
+
_fl = (
|
|
985
|
+
"HOST", "SCAN", "REPORT", "ITEM", "TICKET",
|
|
986
|
+
"WebApp", "WasScan", "Finding", "WasScanSchedule",
|
|
987
|
+
"WebAppAuthRecord", "OptionProfile", "Report",
|
|
988
|
+
"HostAsset", "AssetGroup", "DnsOverride",
|
|
989
|
+
"Connector", "Rule", "Exception",
|
|
990
|
+
)
|
|
991
|
+
parsed: dict[str, Any] = xmltodict.parse(text, force_list=_fl)
|
|
992
|
+
return parsed
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _extract_error(resp: httpx.Response) -> str:
|
|
996
|
+
ct = resp.headers.get("content-type", "")
|
|
997
|
+
try:
|
|
998
|
+
if "xml" in ct:
|
|
999
|
+
d = xmltodict.parse(resp.text)
|
|
1000
|
+
sr = d.get("SIMPLE_RETURN", {})
|
|
1001
|
+
return (
|
|
1002
|
+
sr.get("RESPONSE", {}).get("TEXT", "")
|
|
1003
|
+
or d.get("ServiceResponse", {})
|
|
1004
|
+
.get("responseErrorDetails", {})
|
|
1005
|
+
.get("errorMessage", "")
|
|
1006
|
+
or resp.text[:300]
|
|
1007
|
+
)
|
|
1008
|
+
else:
|
|
1009
|
+
d = resp.json()
|
|
1010
|
+
return (
|
|
1011
|
+
d.get("message")
|
|
1012
|
+
or d.get("error")
|
|
1013
|
+
or d.get("errorMessage")
|
|
1014
|
+
or str(d)[:300]
|
|
1015
|
+
)
|
|
1016
|
+
except Exception:
|
|
1017
|
+
return resp.text[:300]
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _qualys_next_url(data: dict[str, Any]) -> str:
|
|
1021
|
+
for v in data.values():
|
|
1022
|
+
if isinstance(v, dict):
|
|
1023
|
+
resp = v.get("RESPONSE", v)
|
|
1024
|
+
warning = resp.get("WARNING", {})
|
|
1025
|
+
if isinstance(warning, dict):
|
|
1026
|
+
url = warning.get("URL", "")
|
|
1027
|
+
if url:
|
|
1028
|
+
return str(url)
|
|
1029
|
+
deeper = _qualys_next_url(v)
|
|
1030
|
+
if deeper:
|
|
1031
|
+
return deeper
|
|
1032
|
+
return ""
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _extract_list(data: Any) -> list[Any]:
|
|
1036
|
+
if isinstance(data, list):
|
|
1037
|
+
return data
|
|
1038
|
+
if isinstance(data, dict):
|
|
1039
|
+
for key in ("data", "items", "content", "list", "results"):
|
|
1040
|
+
if isinstance(data.get(key), list):
|
|
1041
|
+
items: list[Any] = data[key]
|
|
1042
|
+
return items
|
|
1043
|
+
return []
|