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.
Files changed (69) hide show
  1. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/CHANGELOG.md +68 -1
  2. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/PKG-INFO +1 -1
  3. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/pyproject.toml +1 -1
  4. qualys_cli-0.1.7/src/qualys_cli/__init__.py +1 -0
  5. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/_docs/usage.html +2 -2
  6. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/audit.py +20 -1
  7. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/auth.py +60 -21
  8. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/cache.py +26 -0
  9. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/cli.py +2 -2
  10. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/client.py +69 -11
  11. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/sub.py +7 -3
  12. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/extras.py +17 -3
  13. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/formatters.py +40 -0
  14. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/http_server.py +47 -4
  15. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/mcp_server.py +43 -3
  16. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_features.py +18 -0
  17. qualys_cli-0.1.7/tests/test_review_fixes.py +545 -0
  18. qualys_cli-0.1.5/src/qualys_cli/__init__.py +0 -1
  19. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/.gitignore +0 -0
  20. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/LICENSE +0 -0
  21. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/README.md +0 -0
  22. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/__main__.py +0 -0
  23. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/__init__.py +0 -0
  24. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/asset.py +0 -0
  25. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/ca.py +0 -0
  26. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/cs.py +0 -0
  27. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/csam.py +0 -0
  28. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/etm.py +0 -0
  29. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/pc.py +0 -0
  30. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/pm.py +0 -0
  31. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/scanauth.py +0 -0
  32. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/tc.py +0 -0
  33. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/user.py +0 -0
  34. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/vm.py +0 -0
  35. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/commands/was.py +0 -0
  36. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/config.py +0 -0
  37. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/flair.py +0 -0
  38. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/history.py +0 -0
  39. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/metrics.py +0 -0
  40. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/platforms.py +0 -0
  41. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/src/qualys_cli/queries.py +0 -0
  42. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/__init__.py +0 -0
  43. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/conftest.py +0 -0
  44. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_agent_mode.py +0 -0
  45. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_all_commands_smoke.py +0 -0
  46. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_asset.py +0 -0
  47. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_audit.py +0 -0
  48. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_ca.py +0 -0
  49. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_client.py +0 -0
  50. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_csam.py +0 -0
  51. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_error_handling.py +0 -0
  52. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_etm.py +0 -0
  53. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_flair.py +0 -0
  54. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_formatters.py +0 -0
  55. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_global_flags.py +0 -0
  56. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_help.py +0 -0
  57. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_high_fixes.py +0 -0
  58. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_main.py +0 -0
  59. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_med_fixes.py +0 -0
  60. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_pc.py +0 -0
  61. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_pm.py +0 -0
  62. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_resiliency.py +0 -0
  63. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_scanauth.py +0 -0
  64. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_security_fixes.py +0 -0
  65. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_sub.py +0 -0
  66. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_tc.py +0 -0
  67. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_user.py +0 -0
  68. {qualys_cli-0.1.5 → qualys_cli-0.1.7}/tests/test_vm.py +0 -0
  69. {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.5...HEAD
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.5
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.5"
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.3</span>
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.3</span><span class="l">Version</span></div>
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
- return name.lower() in _SECRET_KEYS
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
- 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
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/usage.html.",
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 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.
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._http.request(method, path, **kwargs)
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
- def _qualys_next_url(data: dict[str, Any]) -> str:
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).write_bytes(resp.content)
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).write_bytes(resp.content)
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
- for r in ex.map(run_one, items):
432
- results.append(r)
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)