qualys-cli 0.1.5__tar.gz → 0.1.7__tar.gz
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-0.1.5 → qualys_cli-0.1.7}/CHANGELOG.md +68 -1
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/PKG-INFO +1 -1
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/pyproject.toml +1 -1
- qualys_cli-0.1.7/src/qualys_cli/__init__.py +1 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/_docs/usage.html +2 -2
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/audit.py +20 -1
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/auth.py +60 -21
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/cache.py +26 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/cli.py +2 -2
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/client.py +69 -11
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/sub.py +7 -3
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/extras.py +17 -3
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/formatters.py +40 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/http_server.py +47 -4
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/mcp_server.py +43 -3
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_features.py +18 -0
- qualys_cli-0.1.7/tests/test_review_fixes.py +545 -0
- qualys_cli-0.1.5/src/qualys_cli/__init__.py +0 -1
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/.gitignore +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/LICENSE +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/README.md +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/__main__.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/__init__.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/asset.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/ca.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/cs.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/csam.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/etm.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/pc.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/pm.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/scanauth.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/tc.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/user.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/vm.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/was.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/config.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/flair.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/history.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/metrics.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/platforms.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/queries.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/__init__.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/conftest.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_agent_mode.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_all_commands_smoke.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_asset.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_audit.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_ca.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_client.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_csam.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_error_handling.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_etm.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_flair.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_formatters.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_global_flags.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_help.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_high_fixes.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_main.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_med_fixes.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_pc.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_pm.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_resiliency.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_scanauth.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_security_fixes.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_sub.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_tc.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_user.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_vm.py +0 -0
- {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_was.py +0 -0
|
@@ -6,6 +6,72 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.7] — 2026-07-17
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `--dry-run` / `--explain` now renders JSON **array** request bodies instead
|
|
13
|
+
of dropping them to `null`. Commands that send a bare JSON array — e.g.
|
|
14
|
+
`etm report bulk-delete`, which `DELETE`s `/reports` with a raw
|
|
15
|
+
`["id", …]` body — previously showed `"body": null` in the explain output.
|
|
16
|
+
Because that JSON is the documented contract parsed by the `mcp`, `serve`,
|
|
17
|
+
and `batch` consumers, the actual payload was invisible to them and to
|
|
18
|
+
anyone auditing a request before sending it. Array bodies are now shown
|
|
19
|
+
(and secret-redacted) in both the human panel and the machine-readable JSON.
|
|
20
|
+
|
|
21
|
+
## [0.1.6] — 2026-07-17
|
|
22
|
+
|
|
23
|
+
### Security
|
|
24
|
+
- `qualys-cli serve`'s local HTTP server now runs on `ThreadingHTTPServer`
|
|
25
|
+
(was single-threaded — one slow client blocked every other caller), enforces
|
|
26
|
+
a request body-size cap with a safe `Content-Length` parse (413/400 instead
|
|
27
|
+
of an unhandled exception on a bad header), and sets a socket timeout so a
|
|
28
|
+
half-open connection can't hang a worker indefinitely.
|
|
29
|
+
- The response body-size guard (`QUALYS_BODY_LIMIT_BYTES`) now actually bounds
|
|
30
|
+
memory use: responses are streamed and the read aborts as soon as the cap is
|
|
31
|
+
exceeded, instead of always fully buffering the body first and rejecting it
|
|
32
|
+
only after the fact.
|
|
33
|
+
- `qualys-cli mcp`'s `tools/call` now rejects tool names that aren't in the
|
|
34
|
+
built catalog before shelling out, and bounds how much of a single stdin
|
|
35
|
+
line it will buffer (a pathological/misbehaving client could previously
|
|
36
|
+
make it buffer an unbounded amount of memory).
|
|
37
|
+
- `--where field~regex` now rejects patterns with classic catastrophic-
|
|
38
|
+
backtracking shapes (nested quantifiers) before compiling — relevant
|
|
39
|
+
because saved queries (`qualys-cli query save/run`) can be authored by one
|
|
40
|
+
team member and run by another.
|
|
41
|
+
- `--output-file` exports (`sub export`, `sub user-prefs export`, generic
|
|
42
|
+
`--output-file`, `batch --output-file`) are now written with `0600`
|
|
43
|
+
permissions instead of the process umask default, consistent with every
|
|
44
|
+
other sensitive artifact the CLI writes (config, audit log, cache, history).
|
|
45
|
+
- Audit-log secret redaction now also catches secret-shaped field names beyond
|
|
46
|
+
the exact-match list (e.g. `db_password`, `adminSecret`, `x-custom-token`),
|
|
47
|
+
not just the ~29 literal key names it recognised before.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- `qualys-cli docs` no longer triggers the first-run credential-setup wizard
|
|
51
|
+
or scope-restriction checks — it never talks to the Qualys API and doesn't
|
|
52
|
+
need a profile.
|
|
53
|
+
- Stale `--where` help text pointing at a removed `docs/usage.html` path now
|
|
54
|
+
points at `qualys-cli docs`.
|
|
55
|
+
- `batch --parallel` now stops on first failure and respects
|
|
56
|
+
`--continue-on-error` the same way `--parallel 1` always did; previously
|
|
57
|
+
`--parallel` silently ran every command to completion regardless of the
|
|
58
|
+
flag.
|
|
59
|
+
|
|
60
|
+
### Changed
|
|
61
|
+
- JWT credential exchange (`/auth`) now retries transient network errors and
|
|
62
|
+
5xx responses with backoff (never 4xx — a bad password won't fix itself on
|
|
63
|
+
retry). Previously a single dropped packet during login failed the whole
|
|
64
|
+
command with no retry at all.
|
|
65
|
+
- `paginate_json_parallel` refuses to dispatch more than
|
|
66
|
+
`QUALYS_MAX_PARALLEL_PAGES` (default 10,000) concurrent page fetches, so a
|
|
67
|
+
bad/inflated `totalCount` from upstream can't fan out an unbounded number of
|
|
68
|
+
requests.
|
|
69
|
+
- The on-disk response cache (`cache.sqlite`) now evicts its oldest entries
|
|
70
|
+
once the row count passes `QUALYS_CACHE_MAX_ROWS` (default 2000) instead of
|
|
71
|
+
growing without bound on long-lived hosts.
|
|
72
|
+
- Added a recursion depth cap to the internal Qualys pagination-URL walker as
|
|
73
|
+
a defensive measure against adversarial/malformed XML responses.
|
|
74
|
+
|
|
9
75
|
## [0.1.5] — 2026-07-08
|
|
10
76
|
|
|
11
77
|
### Changed
|
|
@@ -163,7 +229,8 @@ Initial public release.
|
|
|
163
229
|
- Ruff lint clean (E, F, I, UP, B, C4, SIM rule sets).
|
|
164
230
|
- Codegen pipeline (`scripts/`) is opt-in via the `codegen` extra.
|
|
165
231
|
|
|
166
|
-
[Unreleased]: https://github.com/qualys/qualys-cli/compare/v0.1.
|
|
232
|
+
[Unreleased]: https://github.com/qualys/qualys-cli/compare/v0.1.6...HEAD
|
|
233
|
+
[0.1.6]: https://github.com/qualys/qualys-cli/compare/v0.1.5...v0.1.6
|
|
167
234
|
[0.1.5]: https://github.com/qualys/qualys-cli/compare/v0.1.4...v0.1.5
|
|
168
235
|
[0.1.4]: https://github.com/qualys/qualys-cli/compare/v0.1.2...v0.1.4
|
|
169
236
|
[0.1.2]: https://github.com/qualys/qualys-cli/compare/v0.1.1...v0.1.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qualys-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.7
|
|
4
4
|
Summary: Unified command-line interface for the Qualys Cloud Platform REST APIs (VM, PC, PM, ETM, WAS, TotalCloud, Cloud Agent, Container Security, CSAM, Subscription, Asset, and User management).
|
|
5
5
|
Project-URL: Homepage, https://www.qualys.com
|
|
6
6
|
Author-email: Qualys <support@qualys.com>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qualys-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
8
8
|
description = "Unified command-line interface for the Qualys Cloud Platform REST APIs (VM, PC, PM, ETM, WAS, TotalCloud, Cloud Agent, Container Security, CSAM, Subscription, Asset, and User management)."
|
|
9
9
|
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.7"
|
|
@@ -612,7 +612,7 @@ body.filtering.no-match section.module{display:none}
|
|
|
612
612
|
<aside class="sidebar" aria-label="Documentation navigation">
|
|
613
613
|
<div class="side-header">
|
|
614
614
|
<img src="https://ik.imagekit.io/qualys/image/logo/qualys.svg" alt="Qualys">
|
|
615
|
-
<span class="ver">v0.1.
|
|
615
|
+
<span class="ver">v0.1.7</span>
|
|
616
616
|
</div>
|
|
617
617
|
<div class="side-search">
|
|
618
618
|
<input type="search" id="filter" placeholder="Search commands…"
|
|
@@ -696,7 +696,7 @@ body.filtering.no-match section.module{display:none}
|
|
|
696
696
|
<div class="hero-stats">
|
|
697
697
|
<div class="hero-stat"><span class="n">28</span><span class="l">Modules</span></div>
|
|
698
698
|
<div class="hero-stat"><span class="n">228</span><span class="l">Commands</span></div>
|
|
699
|
-
<div class="hero-stat"><span class="n">v0.1.
|
|
699
|
+
<div class="hero-stat"><span class="n">v0.1.7</span><span class="l">Version</span></div>
|
|
700
700
|
<div class="hero-stat"><span class="n">3.12+</span><span class="l">Python</span></div>
|
|
701
701
|
<div class="hero-stat"><span class="n">MIT</span><span class="l">License</span></div>
|
|
702
702
|
</div>
|
|
@@ -279,8 +279,27 @@ def _maybe_rotate() -> None:
|
|
|
279
279
|
# Redaction helpers
|
|
280
280
|
# ---------------------------------------------------------------------------
|
|
281
281
|
|
|
282
|
+
# Defence in depth: an exact-match allowlist (``_SECRET_KEYS``) can never
|
|
283
|
+
# enumerate every secret-shaped field name a future or third-party API might
|
|
284
|
+
# use (``db_password``, ``newPassword``, ``adminSecret``, ``x-custom-token``,
|
|
285
|
+
# …). These are unambiguous root words that we redact wherever they appear as
|
|
286
|
+
# a substring of the (separator-stripped) key, even if the full key isn't in
|
|
287
|
+
# the exact-match set above. Deliberately excludes short/ambiguous roots like
|
|
288
|
+
# bare "auth" or "key" that would false-positive on unrelated field names
|
|
289
|
+
# (e.g. "author", "keyword") — those stay covered by the exact-match set only.
|
|
290
|
+
_SECRET_KEY_ROOTS = (
|
|
291
|
+
"password", "passwd", "token", "secret", "credential",
|
|
292
|
+
"passphrase", "cookie", "apikey", "privatekey", "signingkey",
|
|
293
|
+
"encryptionkey", "sshkey",
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
282
297
|
def _is_secret_key(name: str) -> bool:
|
|
283
|
-
|
|
298
|
+
lname = name.lower()
|
|
299
|
+
if lname in _SECRET_KEYS:
|
|
300
|
+
return True
|
|
301
|
+
compact = lname.replace("-", "").replace("_", "")
|
|
302
|
+
return any(root in compact for root in _SECRET_KEY_ROOTS)
|
|
284
303
|
|
|
285
304
|
|
|
286
305
|
def _redact_value(_v: Any) -> str:
|
|
@@ -25,6 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import base64
|
|
27
27
|
import json
|
|
28
|
+
import random
|
|
28
29
|
import threading
|
|
29
30
|
import time
|
|
30
31
|
|
|
@@ -122,6 +123,64 @@ def exchange_no_cache(profile: Profile) -> str:
|
|
|
122
123
|
return _exchange(profile, persist=False)
|
|
123
124
|
|
|
124
125
|
|
|
126
|
+
_EXCHANGE_MAX_ATTEMPTS = 3
|
|
127
|
+
_EXCHANGE_BACKOFF_BASE = 1.0
|
|
128
|
+
_EXCHANGE_BACKOFF_CAP = 5.0
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _exchange_with_retry(auth_base: str, profile: Profile) -> httpx.Response:
|
|
132
|
+
"""POST /auth with a small retry budget for transient failures.
|
|
133
|
+
|
|
134
|
+
Unlike :class:`QualysClient.request`, this call happens outside any
|
|
135
|
+
client instance (it's the credential exchange that *bootstraps* the
|
|
136
|
+
client), so it previously had no retry/backoff at all — a single dropped
|
|
137
|
+
packet or transient 503 during login failed the whole command. Retries
|
|
138
|
+
only network errors and 5xx (never 4xx — a bad password won't fix itself
|
|
139
|
+
on retry) and only if there's budget left.
|
|
140
|
+
"""
|
|
141
|
+
last_exc: Exception | None = None
|
|
142
|
+
for attempt in range(_EXCHANGE_MAX_ATTEMPTS):
|
|
143
|
+
try:
|
|
144
|
+
resp = httpx.post(
|
|
145
|
+
f"{auth_base}{_JWT_PATH}",
|
|
146
|
+
data={
|
|
147
|
+
"username": profile.username,
|
|
148
|
+
"password": profile.password,
|
|
149
|
+
"token": "true",
|
|
150
|
+
},
|
|
151
|
+
headers={"X-Requested-With": "qualys-cli"},
|
|
152
|
+
timeout=30.0,
|
|
153
|
+
)
|
|
154
|
+
except httpx.RequestError as exc:
|
|
155
|
+
last_exc = exc
|
|
156
|
+
if attempt < _EXCHANGE_MAX_ATTEMPTS - 1:
|
|
157
|
+
time.sleep(_backoff(attempt))
|
|
158
|
+
continue
|
|
159
|
+
raise RuntimeError(f"JWT exchange failed (network error): {exc}") from exc
|
|
160
|
+
|
|
161
|
+
if resp.status_code >= 500 and attempt < _EXCHANGE_MAX_ATTEMPTS - 1:
|
|
162
|
+
time.sleep(_backoff(attempt))
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
except httpx.HTTPStatusError as exc:
|
|
168
|
+
raise RuntimeError(
|
|
169
|
+
f"JWT exchange failed ({exc.response.status_code}): "
|
|
170
|
+
f"{exc.response.text[:200]}"
|
|
171
|
+
) from exc
|
|
172
|
+
return resp
|
|
173
|
+
|
|
174
|
+
# Unreachable in practice — the loop always returns or raises — but keeps
|
|
175
|
+
# mypy happy and guards against a future refactor of the loop above.
|
|
176
|
+
raise RuntimeError(f"JWT exchange failed (network error): {last_exc}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _backoff(attempt: int) -> float:
|
|
180
|
+
ceiling = min(_EXCHANGE_BACKOFF_CAP, _EXCHANGE_BACKOFF_BASE * (2 ** attempt))
|
|
181
|
+
return random.uniform(0, ceiling)
|
|
182
|
+
|
|
183
|
+
|
|
125
184
|
def _exchange(profile: Profile, *, persist: bool = True) -> str:
|
|
126
185
|
"""Perform a credential exchange under a per-profile lock.
|
|
127
186
|
|
|
@@ -154,27 +213,7 @@ def _exchange(profile: Profile, *, persist: bool = True) -> str:
|
|
|
154
213
|
"[dim]exchanging credentials with Qualys[/dim]",
|
|
155
214
|
spinner="dots",
|
|
156
215
|
):
|
|
157
|
-
|
|
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
|
|
216
|
+
resp = _exchange_with_retry(auth_base, profile)
|
|
178
217
|
|
|
179
218
|
token = resp.text.strip()
|
|
180
219
|
if persist:
|
|
@@ -16,6 +16,9 @@ Design constraints:
|
|
|
16
16
|
the file. ``QUALYS_CACHE_TTL`` caps fall-back age when the upstream returns
|
|
17
17
|
no validators.
|
|
18
18
|
- Cache misses degrade silently to a fresh request — never break a command.
|
|
19
|
+
- Bounded size: ``put()`` evicts the oldest entries once the row count passes
|
|
20
|
+
``QUALYS_CACHE_MAX_ROWS`` (default 2000) so long-lived hosts don't grow
|
|
21
|
+
``cache.sqlite`` without limit.
|
|
19
22
|
"""
|
|
20
23
|
|
|
21
24
|
from __future__ import annotations
|
|
@@ -31,6 +34,12 @@ from .config import CACHE_DIR
|
|
|
31
34
|
_DEFAULT_PATH = CACHE_DIR / "cache.sqlite"
|
|
32
35
|
_DEFAULT_TTL_SECONDS = 24 * 60 * 60 # 24 h fallback when no validators
|
|
33
36
|
|
|
37
|
+
# Nothing ever expired entries before — a long-lived host hitting many
|
|
38
|
+
# distinct catalog endpoints would grow cache.sqlite indefinitely. Cap the
|
|
39
|
+
# row count and evict the oldest entries (by `ts`) once `put()` pushes past
|
|
40
|
+
# it. Override via QUALYS_CACHE_MAX_ROWS.
|
|
41
|
+
_DEFAULT_MAX_ROWS = 2000
|
|
42
|
+
|
|
34
43
|
|
|
35
44
|
def _path() -> Path | None:
|
|
36
45
|
raw = os.environ.get("QUALYS_CACHE", "").strip()
|
|
@@ -93,6 +102,13 @@ def get(key: str) -> tuple[bytes, str, str, int, int] | None:
|
|
|
93
102
|
conn.close()
|
|
94
103
|
|
|
95
104
|
|
|
105
|
+
def _max_rows() -> int:
|
|
106
|
+
raw = os.environ.get("QUALYS_CACHE_MAX_ROWS", "").strip()
|
|
107
|
+
if raw.isdigit() and int(raw) > 0:
|
|
108
|
+
return int(raw)
|
|
109
|
+
return _DEFAULT_MAX_ROWS
|
|
110
|
+
|
|
111
|
+
|
|
96
112
|
def put(key: str, body: bytes, etag: str, modified: str, status: int) -> None:
|
|
97
113
|
conn = _connect()
|
|
98
114
|
if conn is None:
|
|
@@ -103,6 +119,16 @@ def put(key: str, body: bytes, etag: str, modified: str, status: int) -> None:
|
|
|
103
119
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
104
120
|
(key, etag, modified, int(time.time()), body, int(status)),
|
|
105
121
|
)
|
|
122
|
+
max_rows = _max_rows()
|
|
123
|
+
cur = conn.execute("SELECT COUNT(*) FROM cache")
|
|
124
|
+
total = cur.fetchone()[0]
|
|
125
|
+
if total > max_rows:
|
|
126
|
+
conn.execute(
|
|
127
|
+
"DELETE FROM cache WHERE key IN ("
|
|
128
|
+
" SELECT key FROM cache ORDER BY ts ASC LIMIT ?"
|
|
129
|
+
")",
|
|
130
|
+
(total - max_rows,),
|
|
131
|
+
)
|
|
106
132
|
finally:
|
|
107
133
|
conn.close()
|
|
108
134
|
|
|
@@ -207,7 +207,7 @@ _NO_AUTH_CMDS = {
|
|
|
207
207
|
"configure", "profiles", "help", "mode",
|
|
208
208
|
# Local-only utility commands that don't need credentials to be useful
|
|
209
209
|
"stats", "verify-audit", "last", "replay", "completion", "cache",
|
|
210
|
-
"query", "batch", "watch",
|
|
210
|
+
"query", "batch", "watch", "docs",
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
|
|
@@ -1015,7 +1015,7 @@ def _build_post_options() -> list[Any]:
|
|
|
1015
1015
|
_click.Option(
|
|
1016
1016
|
["--where"], type=str, default=None, expose_value=False,
|
|
1017
1017
|
callback=_cb("where"),
|
|
1018
|
-
help="Filter rows (ops: =, !=, ~, >=, >, <=, <). See docs
|
|
1018
|
+
help="Filter rows (ops: =, !=, ~, >=, >, <=, <). See `qualys-cli docs`.",
|
|
1019
1019
|
),
|
|
1020
1020
|
_click.Option(
|
|
1021
1021
|
["--columns"], type=str, default=None, expose_value=False,
|
|
@@ -408,11 +408,12 @@ class QualysClient:
|
|
|
408
408
|
url fully-qualified request URL (profile + path)
|
|
409
409
|
auth "basic" or "jwt"
|
|
410
410
|
params query string as a JSON object (redacted)
|
|
411
|
-
body request body normalised to a JSON object
|
|
412
|
-
possible. If the original body was a
|
|
413
|
-
parse it back so callers see structure,
|
|
414
|
-
If the body is non-JSON (XML,
|
|
415
|
-
surfaced as a string preview.
|
|
411
|
+
body request body normalised to a JSON value (object or
|
|
412
|
+
array) whenever possible. If the original body was a
|
|
413
|
+
JSON string we parse it back so callers see structure,
|
|
414
|
+
not a string. If the body is non-JSON (XML,
|
|
415
|
+
form-encoded), it is surfaced as a string preview.
|
|
416
|
+
``null`` if no body.
|
|
416
417
|
headers request headers (Authorization redacted)
|
|
417
418
|
|
|
418
419
|
This contract is the public surface that MCP / serve / batch
|
|
@@ -432,7 +433,7 @@ class QualysClient:
|
|
|
432
433
|
|
|
433
434
|
# Redact secrets in the rendered body / params
|
|
434
435
|
params = _audit.redact_mapping(dict(kwargs.get("params") or {}))
|
|
435
|
-
if isinstance(body, dict):
|
|
436
|
+
if isinstance(body, (dict, list)):
|
|
436
437
|
body = _audit.redact_mapping(body)
|
|
437
438
|
elif isinstance(body, str):
|
|
438
439
|
# Try to round-trip a string body back into a dict — ``post_xml``
|
|
@@ -457,7 +458,7 @@ class QualysClient:
|
|
|
457
458
|
lines.append(f"[dim]auth:[/dim] {self.auth_type}")
|
|
458
459
|
if params:
|
|
459
460
|
lines.append(f"[dim]params:[/dim] {_json.dumps(params, default=str)}")
|
|
460
|
-
if isinstance(body, dict):
|
|
461
|
+
if isinstance(body, (dict, list)):
|
|
461
462
|
lines.append(f"[dim]body:[/dim] {_json.dumps(body, default=str, indent=2)}")
|
|
462
463
|
elif isinstance(body, str):
|
|
463
464
|
preview = body if len(body) < 400 else body[:400] + "…"
|
|
@@ -468,7 +469,7 @@ class QualysClient:
|
|
|
468
469
|
if not _console.is_terminal:
|
|
469
470
|
payload = {
|
|
470
471
|
"method": method, "url": url, "auth": self.auth_type,
|
|
471
|
-
"params": params, "body": body if isinstance(body, (dict, str)) else None,
|
|
472
|
+
"params": params, "body": body if isinstance(body, (dict, list, str)) else None,
|
|
472
473
|
"headers": hdrs,
|
|
473
474
|
}
|
|
474
475
|
print(_json.dumps(payload, default=str))
|
|
@@ -480,6 +481,40 @@ class QualysClient:
|
|
|
480
481
|
expand=False,
|
|
481
482
|
))
|
|
482
483
|
|
|
484
|
+
# ------------------------------------------------------------------
|
|
485
|
+
# Body-capped send
|
|
486
|
+
# ------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
489
|
+
"""Send one HTTP attempt, enforcing ``body_limit`` without fully
|
|
490
|
+
buffering an oversized response first.
|
|
491
|
+
|
|
492
|
+
``httpx.Client.request()`` always reads the entire body into memory
|
|
493
|
+
before returning. That made the previous body-size guard (checked via
|
|
494
|
+
``len(resp.content)`` after the call) purely cosmetic: a 2 GB response
|
|
495
|
+
was already fully resident in memory by the time we could reject it.
|
|
496
|
+
When a cap is configured we instead stream the response and abort as
|
|
497
|
+
soon as the cumulative size exceeds the cap, so the guard actually
|
|
498
|
+
bounds memory use.
|
|
499
|
+
"""
|
|
500
|
+
if self.body_limit <= 0:
|
|
501
|
+
return self._http.request(method, path, **kwargs)
|
|
502
|
+
|
|
503
|
+
req = self._http.build_request(method, path, **kwargs)
|
|
504
|
+
resp = self._http.send(req, stream=True)
|
|
505
|
+
buf = bytearray()
|
|
506
|
+
try:
|
|
507
|
+
for chunk in resp.iter_bytes():
|
|
508
|
+
buf.extend(chunk)
|
|
509
|
+
if len(buf) > self.body_limit:
|
|
510
|
+
break
|
|
511
|
+
finally:
|
|
512
|
+
resp.close()
|
|
513
|
+
# Mirrors httpx.Response.read()'s own caching behaviour so downstream
|
|
514
|
+
# .content / .text / .json() keep working on the buffered bytes.
|
|
515
|
+
resp._content = bytes(buf) # noqa: SLF001 — no public API for this
|
|
516
|
+
return resp
|
|
517
|
+
|
|
483
518
|
# ------------------------------------------------------------------
|
|
484
519
|
# Core request
|
|
485
520
|
# ------------------------------------------------------------------
|
|
@@ -548,7 +583,7 @@ class QualysClient:
|
|
|
548
583
|
while attempt < self.max_retries:
|
|
549
584
|
attempts_made = attempt + 1
|
|
550
585
|
try:
|
|
551
|
-
resp = self.
|
|
586
|
+
resp = self._send(method, path, **kwargs)
|
|
552
587
|
except httpx.RequestError as exc:
|
|
553
588
|
last_exc = exc
|
|
554
589
|
resp = None
|
|
@@ -840,9 +875,24 @@ class QualysClient:
|
|
|
840
875
|
|
|
841
876
|
Use when total page count is known up-front (e.g. after one cheap GET
|
|
842
877
|
that returns ``totalCount``). Output is in deterministic page order.
|
|
878
|
+
|
|
879
|
+
``page_count`` is typically derived from a server-reported
|
|
880
|
+
``totalCount`` / ``totalCount / page_size``. A misbehaving or
|
|
881
|
+
malicious upstream inflating that count would otherwise make this
|
|
882
|
+
method dispatch an unbounded number of concurrent GETs. Refuse to
|
|
883
|
+
proceed past a sane ceiling (override via
|
|
884
|
+
``QUALYS_MAX_PARALLEL_PAGES`` for legitimately huge datasets).
|
|
843
885
|
"""
|
|
844
886
|
import concurrent.futures as _f
|
|
845
887
|
|
|
888
|
+
max_pages = _env_int("QUALYS_MAX_PARALLEL_PAGES", 10_000, minimum=1)
|
|
889
|
+
if page_count > max_pages:
|
|
890
|
+
raise ValueError(
|
|
891
|
+
f"page_count={page_count} exceeds the safety ceiling of "
|
|
892
|
+
f"{max_pages} pages. If this is a legitimately large "
|
|
893
|
+
f"dataset, raise it via QUALYS_MAX_PARALLEL_PAGES."
|
|
894
|
+
)
|
|
895
|
+
|
|
846
896
|
def _one(page: int) -> Any:
|
|
847
897
|
p = dict(params or {})
|
|
848
898
|
p[size_param] = page_size
|
|
@@ -1017,7 +1067,15 @@ def _extract_error(resp: httpx.Response) -> str:
|
|
|
1017
1067
|
return resp.text[:300]
|
|
1018
1068
|
|
|
1019
1069
|
|
|
1020
|
-
|
|
1070
|
+
_MAX_NEXT_URL_DEPTH = 25
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _qualys_next_url(data: dict[str, Any], *, _depth: int = 0) -> str:
|
|
1074
|
+
# Defensive depth cap: `data` is a parsed XML response from the network
|
|
1075
|
+
# (or a MITM without a trusted CA). An adversarially deep/cyclic
|
|
1076
|
+
# structure should not be able to blow the Python call stack here.
|
|
1077
|
+
if _depth > _MAX_NEXT_URL_DEPTH:
|
|
1078
|
+
return ""
|
|
1021
1079
|
for v in data.values():
|
|
1022
1080
|
if isinstance(v, dict):
|
|
1023
1081
|
resp = v.get("RESPONSE", v)
|
|
@@ -1026,7 +1084,7 @@ def _qualys_next_url(data: dict[str, Any]) -> str:
|
|
|
1026
1084
|
url = warning.get("URL", "")
|
|
1027
1085
|
if url:
|
|
1028
1086
|
return str(url)
|
|
1029
|
-
deeper = _qualys_next_url(v)
|
|
1087
|
+
deeper = _qualys_next_url(v, _depth=_depth + 1)
|
|
1030
1088
|
if deeper:
|
|
1031
1089
|
return deeper
|
|
1032
1090
|
return ""
|
|
@@ -20,7 +20,7 @@ from pathlib import Path
|
|
|
20
20
|
import typer
|
|
21
21
|
|
|
22
22
|
from .. import client as _c
|
|
23
|
-
from ..formatters import guard_output_file, output, output_success
|
|
23
|
+
from ..formatters import _restrict_permissions, guard_output_file, output, output_success
|
|
24
24
|
|
|
25
25
|
app = typer.Typer(
|
|
26
26
|
name="sub",
|
|
@@ -76,7 +76,9 @@ def sub_export(
|
|
|
76
76
|
if output_file:
|
|
77
77
|
guard_output_file()
|
|
78
78
|
resp = client.request("GET", _BASE, params={"action": "export"})
|
|
79
|
-
Path(output_file)
|
|
79
|
+
out_path = Path(output_file)
|
|
80
|
+
out_path.write_bytes(resp.content)
|
|
81
|
+
_restrict_permissions(out_path)
|
|
80
82
|
output_success(f"Subscription config exported to {output_file} ({len(resp.content):,} bytes)")
|
|
81
83
|
else:
|
|
82
84
|
data = client.get_xml(_BASE, params={"action": "export"})
|
|
@@ -136,7 +138,9 @@ def user_prefs_export(
|
|
|
136
138
|
"GET", _PREFS,
|
|
137
139
|
params={"action": "export", "user_id": user_id},
|
|
138
140
|
)
|
|
139
|
-
Path(output_file)
|
|
141
|
+
out_path = Path(output_file)
|
|
142
|
+
out_path.write_bytes(resp.content)
|
|
143
|
+
_restrict_permissions(out_path)
|
|
140
144
|
output_success(f"User preferences exported to {output_file} ({len(resp.content):,} bytes)")
|
|
141
145
|
|
|
142
146
|
|
|
@@ -47,7 +47,7 @@ from . import config as _cfg
|
|
|
47
47
|
from . import history as _history
|
|
48
48
|
from . import metrics as _metrics
|
|
49
49
|
from . import queries as _queries
|
|
50
|
-
from .formatters import output, output_success
|
|
50
|
+
from .formatters import _restrict_permissions, output, output_success
|
|
51
51
|
|
|
52
52
|
_err = Console(stderr=True)
|
|
53
53
|
|
|
@@ -427,9 +427,22 @@ def batch(
|
|
|
427
427
|
if not r["ok"] and not continue_on_error:
|
|
428
428
|
break
|
|
429
429
|
else:
|
|
430
|
+
# Mirror the sequential path's fail-fast behaviour: without this,
|
|
431
|
+
# --parallel always ran every item to completion regardless of
|
|
432
|
+
# --continue-on-error, silently diverging from --parallel 1's
|
|
433
|
+
# documented stop-on-first-failure default. Already-running workers
|
|
434
|
+
# can't be interrupted, but not-yet-started ones are cancelled.
|
|
430
435
|
with _f.ThreadPoolExecutor(max_workers=parallel) as ex:
|
|
431
|
-
|
|
432
|
-
|
|
436
|
+
futures = {ex.submit(run_one, it): idx for idx, it in enumerate(items)}
|
|
437
|
+
by_idx: dict[int, dict[str, Any]] = {}
|
|
438
|
+
for fut in _f.as_completed(futures):
|
|
439
|
+
r = fut.result()
|
|
440
|
+
by_idx[futures[fut]] = r
|
|
441
|
+
if not r["ok"] and not continue_on_error:
|
|
442
|
+
for pending in futures:
|
|
443
|
+
pending.cancel()
|
|
444
|
+
break
|
|
445
|
+
results = [by_idx[i] for i in sorted(by_idx)]
|
|
433
446
|
|
|
434
447
|
payload = {"profile": profile, "results": results}
|
|
435
448
|
if output_file:
|
|
@@ -437,6 +450,7 @@ def batch(
|
|
|
437
450
|
try:
|
|
438
451
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
439
452
|
out_path.write_text(_json.dumps(payload, indent=2, default=str))
|
|
453
|
+
_restrict_permissions(out_path)
|
|
440
454
|
except OSError as exc:
|
|
441
455
|
_err.print(f"[bold red]✗[/bold red] Could not write {output_file}: {exc}")
|
|
442
456
|
raise typer.Exit(1) from exc
|
|
@@ -39,6 +39,7 @@ The jq expression accepts:
|
|
|
39
39
|
|
|
40
40
|
from __future__ import annotations
|
|
41
41
|
|
|
42
|
+
import contextlib
|
|
42
43
|
import csv
|
|
43
44
|
import io
|
|
44
45
|
import json
|
|
@@ -458,6 +459,13 @@ def _apply_where(rows: list[dict[str, Any]], where: str | None) -> list[dict[str
|
|
|
458
459
|
op = m.group(1)
|
|
459
460
|
value = expr[m.end():].strip()
|
|
460
461
|
if op == "~":
|
|
462
|
+
if not _is_safe_regex(value):
|
|
463
|
+
output_warn(
|
|
464
|
+
f"Rejected --where pattern {value!r} — looks like it could "
|
|
465
|
+
"cause catastrophic backtracking (nested quantifiers). "
|
|
466
|
+
"Simplify the pattern or pipe through real `jq`/`grep` instead."
|
|
467
|
+
)
|
|
468
|
+
return rows
|
|
461
469
|
try:
|
|
462
470
|
rx = re.compile(value, re.IGNORECASE)
|
|
463
471
|
except re.error:
|
|
@@ -466,6 +474,24 @@ def _apply_where(rows: list[dict[str, Any]], where: str | None) -> list[dict[str
|
|
|
466
474
|
return [r for r in rows if _compare(_row_get_ci(r, field), value, op)]
|
|
467
475
|
|
|
468
476
|
|
|
477
|
+
# Saved queries (``qualys-cli query save/run``) can be authored by one team
|
|
478
|
+
# member and executed by another, so a ``--where field~regex`` predicate is
|
|
479
|
+
# not purely self-inflicted the way an ad-hoc CLI flag would be. re.compile
|
|
480
|
+
# has no built-in timeout, so we reject a couple of the classic
|
|
481
|
+
# catastrophic-backtracking shapes outright rather than let an evil/careless
|
|
482
|
+
# regex hang the process indefinitely. This is a heuristic, not a proof —
|
|
483
|
+
# it blocks the well-known nested-quantifier family, not every possible
|
|
484
|
+
# pathological pattern.
|
|
485
|
+
_MAX_WHERE_REGEX_LEN = 200
|
|
486
|
+
_REDOS_NESTED_QUANTIFIER_RE = re.compile(r"\([^()]*[+*][^()]*\)[+*?]")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _is_safe_regex(pattern: str) -> bool:
|
|
490
|
+
if len(pattern) > _MAX_WHERE_REGEX_LEN:
|
|
491
|
+
return False
|
|
492
|
+
return not _REDOS_NESTED_QUANTIFIER_RE.search(pattern)
|
|
493
|
+
|
|
494
|
+
|
|
469
495
|
def _apply_sort(rows: list[dict[str, Any]], sort: str | None) -> list[dict[str, Any]]:
|
|
470
496
|
if not sort:
|
|
471
497
|
return rows
|
|
@@ -894,3 +920,17 @@ def _dump_file(data: Any, path: str) -> None:
|
|
|
894
920
|
fh.write("\n")
|
|
895
921
|
else:
|
|
896
922
|
p.write_text(json.dumps(data, indent=2, default=str))
|
|
923
|
+
_restrict_permissions(p)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _restrict_permissions(p: Path) -> None:
|
|
927
|
+
"""Tighten a freshly-written export file to owner-only (``0600``).
|
|
928
|
+
|
|
929
|
+
``--output-file`` exports can contain vulnerability findings, subscription
|
|
930
|
+
config, or other sensitive Qualys data. Without this, the file lands at
|
|
931
|
+
the process umask (often world- or group-readable). Best-effort — chmod
|
|
932
|
+
is unsupported/unnecessary on some platforms (Windows), so failures are
|
|
933
|
+
swallowed rather than breaking the export.
|
|
934
|
+
"""
|
|
935
|
+
with contextlib.suppress(OSError, NotImplementedError):
|
|
936
|
+
p.chmod(0o600)
|