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/auth.py ADDED
@@ -0,0 +1,187 @@
1
+ """
2
+ Authentication helpers.
3
+
4
+ basic – httpx.BasicAuth passed directly to the client
5
+ jwt – username+password exchanged for a Bearer token via /auth endpoint;
6
+ token is cached on the Profile object for the lifetime of the command
7
+ and written back to ~/.config/qualys-cli/config.toml for reuse
8
+
9
+ Stability features
10
+ ------------------
11
+ - :func:`token_is_fresh` decodes the cached JWT's ``exp`` claim and refuses to
12
+ reuse a token that is within ``leeway`` seconds of expiry.
13
+ - :func:`force_refresh` clears the cached token and exchanges a fresh one;
14
+ used by ``qualys-cli rotate-jwt``.
15
+ - All exchanges are serialised by a per-profile lock so parallel workers
16
+ (``paginate_json_parallel``, ``batch --parallel``) cannot clobber each
17
+ other's tokens. After acquiring the lock we re-check the cached token, so
18
+ the second concurrent caller sees the first one's fresh result instead of
19
+ performing a duplicate ``/auth`` round trip.
20
+ - :func:`exchange_no_cache` performs the network exchange without writing to
21
+ disk — used by ``qualys-cli doctor`` so a preflight stays read-only.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import json
28
+ import threading
29
+ import time
30
+
31
+ import httpx
32
+ from rich.console import Console
33
+
34
+ from .config import Profile, cache_token
35
+
36
+ _console = Console(stderr=True)
37
+
38
+ _JWT_PATH = "/auth"
39
+
40
+ _DEFAULT_LEEWAY_SECONDS = 60
41
+
42
+ # Per-profile lock for /auth exchanges. The map itself is guarded by
43
+ # ``_exchange_locks_guard`` so two callers with the same brand-new profile
44
+ # name don't race on the dict insertion.
45
+ _exchange_locks: dict[str, threading.Lock] = {}
46
+ _exchange_locks_guard = threading.Lock()
47
+
48
+
49
+ def basic_auth(profile: Profile) -> httpx.BasicAuth:
50
+ return httpx.BasicAuth(profile.username, profile.password)
51
+
52
+
53
+ def _b64url_decode(s: str) -> bytes:
54
+ pad = "=" * (-len(s) % 4)
55
+ return base64.urlsafe_b64decode(s + pad)
56
+
57
+
58
+ def jwt_exp(token: str) -> int | None:
59
+ """Return the ``exp`` claim of a JWT (Unix timestamp), or None on
60
+ malformed / unsigned / claim-less tokens. Never raises."""
61
+ if not token or token.count(".") < 2:
62
+ return None
63
+ try:
64
+ payload_b64 = token.split(".")[1]
65
+ payload = json.loads(_b64url_decode(payload_b64))
66
+ except (ValueError, json.JSONDecodeError):
67
+ return None
68
+ exp = payload.get("exp")
69
+ if isinstance(exp, (int, float)):
70
+ return int(exp)
71
+ return None
72
+
73
+
74
+ def token_is_fresh(token: str, *, leeway: int = _DEFAULT_LEEWAY_SECONDS) -> bool:
75
+ """True iff the token's ``exp`` is at least *leeway* seconds in the future.
76
+
77
+ A structurally-malformed token (not 3 dot-separated segments) is treated
78
+ as stale so we re-exchange instead of sending garbage as a Bearer.
79
+ Some Qualys services answer with 500 ("JWT parsing failed") rather than
80
+ 401 for an unparseable token, so we cannot rely on the 401-refresh path
81
+ to recover.
82
+
83
+ A well-formed token without an ``exp`` claim is treated as fresh — the
84
+ 401-refresh path catches that case safely.
85
+ """
86
+ if not token or token.count(".") != 2:
87
+ return False
88
+ exp = jwt_exp(token)
89
+ if exp is None:
90
+ return True
91
+ return exp - time.time() > leeway
92
+
93
+
94
+ def _profile_lock(name: str) -> threading.Lock:
95
+ with _exchange_locks_guard:
96
+ return _exchange_locks.setdefault(name, threading.Lock())
97
+
98
+
99
+ def get_jwt(profile: Profile) -> str:
100
+ """Return a valid JWT, exchanging credentials only if the cached one is
101
+ stale or absent. Concurrent calls for the same profile share one
102
+ exchange via :func:`_profile_lock`."""
103
+ if profile.token and token_is_fresh(profile.token):
104
+ return profile.token
105
+ return _exchange(profile)
106
+
107
+
108
+ def force_refresh(profile: Profile) -> str:
109
+ """Drop any cached token and force a fresh credential exchange.
110
+ The new token is persisted to ``config.toml`` for subsequent runs."""
111
+ profile.token = ""
112
+ return _exchange(profile)
113
+
114
+
115
+ def exchange_no_cache(profile: Profile) -> str:
116
+ """Run a fresh ``/auth`` exchange but do NOT persist the result.
117
+
118
+ Used by ``qualys-cli doctor`` so a preflight check is fully read-only —
119
+ the caller's on-disk profile stays byte-identical, and any other
120
+ concurrent users of the profile keep their cached token.
121
+ """
122
+ return _exchange(profile, persist=False)
123
+
124
+
125
+ def _exchange(profile: Profile, *, persist: bool = True) -> str:
126
+ """Perform a credential exchange under a per-profile lock.
127
+
128
+ On lock acquisition we re-check the cached token: if another thread
129
+ beat us to the refresh, we return its result instead of doing a
130
+ duplicate exchange. ``persist=False`` keeps the result in memory only
131
+ (used by the read-only doctor preflight).
132
+ """
133
+ if not profile.password:
134
+ raise ValueError(
135
+ "JWT auth requires a password. "
136
+ "Run `qualys-cli configure` or set QUALYS_PASSWORD."
137
+ )
138
+
139
+ lock = _profile_lock(profile.name)
140
+ with lock:
141
+ # Double-checked locking: another thread may have refreshed while we
142
+ # were waiting for the lock. Only honour fresh-enough tokens (small
143
+ # leeway so we don't accept one that's about to expire mid-call).
144
+ if persist and profile.token and token_is_fresh(profile.token, leeway=5):
145
+ return profile.token
146
+
147
+ # Qualys exposes /auth on the gateway host (e.g.
148
+ # gateway.qg2.apps.qualys.com), not on the API host
149
+ # (qualysapi.qg2.apps.qualys.com) — the API host returns a 404 HTML
150
+ # page for POSTs to /auth.
151
+ auth_base = (profile.gateway_url or profile.url).rstrip("/")
152
+ with _console.status(
153
+ "[bold cyan]Forging secure session token…[/bold cyan] "
154
+ "[dim]exchanging credentials with Qualys[/dim]",
155
+ spinner="dots",
156
+ ):
157
+ try:
158
+ resp = httpx.post(
159
+ f"{auth_base}{_JWT_PATH}",
160
+ data={
161
+ "username": profile.username,
162
+ "password": profile.password,
163
+ "token": "true",
164
+ },
165
+ headers={"X-Requested-With": "qualys-cli"},
166
+ timeout=30.0,
167
+ )
168
+ resp.raise_for_status()
169
+ except httpx.HTTPStatusError as exc:
170
+ raise RuntimeError(
171
+ f"JWT exchange failed ({exc.response.status_code}): "
172
+ f"{exc.response.text[:200]}"
173
+ ) from exc
174
+ except httpx.RequestError as exc:
175
+ raise RuntimeError(
176
+ f"JWT exchange failed (network error): {exc}"
177
+ ) from exc
178
+
179
+ token = resp.text.strip()
180
+ if persist:
181
+ profile.token = token
182
+ cache_token(profile.name, profile.username, token)
183
+ return token
184
+
185
+
186
+ def bearer_header(token: str) -> dict[str, str]:
187
+ return {"Authorization": f"Bearer {token}"}
qualys_cli/cache.py ADDED
@@ -0,0 +1,123 @@
1
+ """
2
+ ETag-aware response cache for catalog-style endpoints.
3
+
4
+ Some Qualys endpoints (KB, patch catalog, organization list, …) return tens
5
+ of MB of mostly-static data. This module provides a tiny SQLite-backed cache
6
+ keyed on (method, full URL) that stores the last response body and its ETag /
7
+ Last-Modified header. The :class:`QualysClient` doesn't auto-cache — callers
8
+ opt in by wrapping a request with :func:`cached_get`.
9
+
10
+ Design constraints:
11
+
12
+ - Pure stdlib (sqlite3) — no extra dependency.
13
+ - Cache is per-host and per-user (``~/.config/qualys-cli/cache/cache.sqlite``,
14
+ mode ``0600``).
15
+ - A separate ``QUALYS_CACHE`` env var lets users opt out (``off``) or relocate
16
+ the file. ``QUALYS_CACHE_TTL`` caps fall-back age when the upstream returns
17
+ no validators.
18
+ - Cache misses degrade silently to a fresh request — never break a command.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import os
25
+ import sqlite3
26
+ import time
27
+ from pathlib import Path
28
+
29
+ from .config import CACHE_DIR
30
+
31
+ _DEFAULT_PATH = CACHE_DIR / "cache.sqlite"
32
+ _DEFAULT_TTL_SECONDS = 24 * 60 * 60 # 24 h fallback when no validators
33
+
34
+
35
+ def _path() -> Path | None:
36
+ raw = os.environ.get("QUALYS_CACHE", "").strip()
37
+ if raw.lower() in {"off", "none", "false", "0", "disabled", "no"}:
38
+ return None
39
+ if raw and raw.lower() != "default":
40
+ return Path(raw).expanduser()
41
+ return _DEFAULT_PATH
42
+
43
+
44
+ def _ttl() -> int:
45
+ raw = os.environ.get("QUALYS_CACHE_TTL", "").strip()
46
+ if raw.isdigit():
47
+ n = int(raw)
48
+ if n > 0:
49
+ return n
50
+ return _DEFAULT_TTL_SECONDS
51
+
52
+
53
+ def _connect() -> sqlite3.Connection | None:
54
+ p = _path()
55
+ if p is None:
56
+ return None
57
+ try:
58
+ p.parent.mkdir(parents=True, exist_ok=True)
59
+ conn = sqlite3.connect(str(p), timeout=2.0, isolation_level=None)
60
+ conn.execute(
61
+ """
62
+ CREATE TABLE IF NOT EXISTS cache(
63
+ key TEXT PRIMARY KEY,
64
+ etag TEXT,
65
+ modified TEXT,
66
+ ts INTEGER NOT NULL,
67
+ body BLOB NOT NULL,
68
+ status INTEGER NOT NULL
69
+ )
70
+ """
71
+ )
72
+ with contextlib.suppress(Exception):
73
+ p.chmod(0o600)
74
+ return conn
75
+ except Exception:
76
+ return None
77
+
78
+
79
+ def get(key: str) -> tuple[bytes, str, str, int, int] | None:
80
+ """Return ``(body, etag, modified, ts, status)`` for *key* or None on miss."""
81
+ conn = _connect()
82
+ if conn is None:
83
+ return None
84
+ try:
85
+ cur = conn.execute(
86
+ "SELECT body, etag, modified, ts, status FROM cache WHERE key = ?", (key,),
87
+ )
88
+ row = cur.fetchone()
89
+ if row is None:
90
+ return None
91
+ return (row[0], row[1] or "", row[2] or "", int(row[3]), int(row[4]))
92
+ finally:
93
+ conn.close()
94
+
95
+
96
+ def put(key: str, body: bytes, etag: str, modified: str, status: int) -> None:
97
+ conn = _connect()
98
+ if conn is None:
99
+ return
100
+ try:
101
+ conn.execute(
102
+ "INSERT OR REPLACE INTO cache(key, etag, modified, ts, body, status) "
103
+ "VALUES (?, ?, ?, ?, ?, ?)",
104
+ (key, etag, modified, int(time.time()), body, int(status)),
105
+ )
106
+ finally:
107
+ conn.close()
108
+
109
+
110
+ def is_fresh(ts: int) -> bool:
111
+ return (time.time() - ts) < _ttl()
112
+
113
+
114
+ def clear() -> int:
115
+ """Remove all cache entries. Returns the number of rows deleted."""
116
+ conn = _connect()
117
+ if conn is None:
118
+ return 0
119
+ try:
120
+ cur = conn.execute("DELETE FROM cache")
121
+ return int(cur.rowcount or 0)
122
+ finally:
123
+ conn.close()