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/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()
|