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/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 []