qualys-cli 0.1.4__tar.gz → 0.1.6__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.4 → qualys_cli-0.1.6}/CHANGELOG.md +73 -1
  2. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/PKG-INFO +10 -3
  3. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/README.md +6 -0
  4. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/pyproject.toml +24 -3
  5. qualys_cli-0.1.6/src/qualys_cli/__init__.py +1 -0
  6. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/audit.py +20 -1
  7. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/auth.py +60 -21
  8. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/cache.py +26 -0
  9. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/cli.py +2 -2
  10. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/client.py +60 -3
  11. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/sub.py +7 -3
  12. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/extras.py +17 -3
  13. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/formatters.py +40 -0
  14. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/http_server.py +47 -4
  15. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/mcp_server.py +43 -3
  16. qualys_cli-0.1.6/tests/test_review_fixes.py +545 -0
  17. qualys_cli-0.1.4/src/qualys_cli/__init__.py +0 -1
  18. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/.gitignore +0 -0
  19. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/LICENSE +0 -0
  20. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/__main__.py +0 -0
  21. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/_docs/usage.html +0 -0
  22. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/__init__.py +0 -0
  23. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/asset.py +0 -0
  24. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/ca.py +0 -0
  25. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/cs.py +0 -0
  26. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/csam.py +0 -0
  27. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/etm.py +0 -0
  28. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/pc.py +0 -0
  29. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/pm.py +0 -0
  30. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/scanauth.py +0 -0
  31. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/tc.py +0 -0
  32. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/user.py +0 -0
  33. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/vm.py +0 -0
  34. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/was.py +0 -0
  35. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/config.py +0 -0
  36. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/flair.py +0 -0
  37. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/history.py +0 -0
  38. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/metrics.py +0 -0
  39. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/platforms.py +0 -0
  40. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/queries.py +0 -0
  41. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/__init__.py +0 -0
  42. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/conftest.py +0 -0
  43. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_agent_mode.py +0 -0
  44. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_all_commands_smoke.py +0 -0
  45. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_asset.py +0 -0
  46. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_audit.py +0 -0
  47. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_ca.py +0 -0
  48. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_client.py +0 -0
  49. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_csam.py +0 -0
  50. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_error_handling.py +0 -0
  51. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_etm.py +0 -0
  52. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_features.py +0 -0
  53. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_flair.py +0 -0
  54. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_formatters.py +0 -0
  55. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_global_flags.py +0 -0
  56. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_help.py +0 -0
  57. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_high_fixes.py +0 -0
  58. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_main.py +0 -0
  59. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_med_fixes.py +0 -0
  60. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_pc.py +0 -0
  61. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_pm.py +0 -0
  62. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_resiliency.py +0 -0
  63. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_scanauth.py +0 -0
  64. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_security_fixes.py +0 -0
  65. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_sub.py +0 -0
  66. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_tc.py +0 -0
  67. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_user.py +0 -0
  68. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_vm.py +0 -0
  69. {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_was.py +0 -0
@@ -6,6 +6,76 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.6] — 2026-07-17
10
+
11
+ ### Security
12
+ - `qualys-cli serve`'s local HTTP server now runs on `ThreadingHTTPServer`
13
+ (was single-threaded — one slow client blocked every other caller), enforces
14
+ a request body-size cap with a safe `Content-Length` parse (413/400 instead
15
+ of an unhandled exception on a bad header), and sets a socket timeout so a
16
+ half-open connection can't hang a worker indefinitely.
17
+ - The response body-size guard (`QUALYS_BODY_LIMIT_BYTES`) now actually bounds
18
+ memory use: responses are streamed and the read aborts as soon as the cap is
19
+ exceeded, instead of always fully buffering the body first and rejecting it
20
+ only after the fact.
21
+ - `qualys-cli mcp`'s `tools/call` now rejects tool names that aren't in the
22
+ built catalog before shelling out, and bounds how much of a single stdin
23
+ line it will buffer (a pathological/misbehaving client could previously
24
+ make it buffer an unbounded amount of memory).
25
+ - `--where field~regex` now rejects patterns with classic catastrophic-
26
+ backtracking shapes (nested quantifiers) before compiling — relevant
27
+ because saved queries (`qualys-cli query save/run`) can be authored by one
28
+ team member and run by another.
29
+ - `--output-file` exports (`sub export`, `sub user-prefs export`, generic
30
+ `--output-file`, `batch --output-file`) are now written with `0600`
31
+ permissions instead of the process umask default, consistent with every
32
+ other sensitive artifact the CLI writes (config, audit log, cache, history).
33
+ - Audit-log secret redaction now also catches secret-shaped field names beyond
34
+ the exact-match list (e.g. `db_password`, `adminSecret`, `x-custom-token`),
35
+ not just the ~29 literal key names it recognised before.
36
+
37
+ ### Fixed
38
+ - `qualys-cli docs` no longer triggers the first-run credential-setup wizard
39
+ or scope-restriction checks — it never talks to the Qualys API and doesn't
40
+ need a profile.
41
+ - Stale `--where` help text pointing at a removed `docs/usage.html` path now
42
+ points at `qualys-cli docs`.
43
+ - `batch --parallel` now stops on first failure and respects
44
+ `--continue-on-error` the same way `--parallel 1` always did; previously
45
+ `--parallel` silently ran every command to completion regardless of the
46
+ flag.
47
+
48
+ ### Changed
49
+ - JWT credential exchange (`/auth`) now retries transient network errors and
50
+ 5xx responses with backoff (never 4xx — a bad password won't fix itself on
51
+ retry). Previously a single dropped packet during login failed the whole
52
+ command with no retry at all.
53
+ - `paginate_json_parallel` refuses to dispatch more than
54
+ `QUALYS_MAX_PARALLEL_PAGES` (default 10,000) concurrent page fetches, so a
55
+ bad/inflated `totalCount` from upstream can't fan out an unbounded number of
56
+ requests.
57
+ - The on-disk response cache (`cache.sqlite`) now evicts its oldest entries
58
+ once the row count passes `QUALYS_CACHE_MAX_ROWS` (default 2000) instead of
59
+ growing without bound on long-lived hosts.
60
+ - Added a recursion depth cap to the internal Qualys pagination-URL walker as
61
+ a defensive measure against adversarial/malformed XML responses.
62
+
63
+ ## [0.1.5] — 2026-07-08
64
+
65
+ ### Changed
66
+ - Package metadata: author is now `Qualys <support@qualys.com>`, with Balaji
67
+ Venkatesan and Andrew Nelson listed as maintainers.
68
+ - Expanded and clarified PyPI keywords to match the modules the CLI ships
69
+ (policy compliance, cloud/container security, WAS, EASM, asset inventory).
70
+ Dropped the ambiguous `vm` keyword, which reads as "virtual machine" on PyPI.
71
+ - The Qualys logo now heads the PyPI project description / README.
72
+
73
+ ### Fixed
74
+ - `qualys_cli.__version__` was pinned at `0.1.0` and had drifted three releases
75
+ behind the real package version. It is now kept in step with the release.
76
+ (User-facing `--version` output was unaffected: it reads installed package
77
+ metadata via `importlib.metadata`, not this constant.)
78
+
9
79
  ## [0.1.4] — 2026-07-08
10
80
 
11
81
  ### Added
@@ -147,7 +217,9 @@ Initial public release.
147
217
  - Ruff lint clean (E, F, I, UP, B, C4, SIM rule sets).
148
218
  - Codegen pipeline (`scripts/`) is opt-in via the `codegen` extra.
149
219
 
150
- [Unreleased]: https://github.com/qualys/qualys-cli/compare/v0.1.2...HEAD
220
+ [Unreleased]: https://github.com/qualys/qualys-cli/compare/v0.1.5...HEAD
221
+ [0.1.5]: https://github.com/qualys/qualys-cli/compare/v0.1.4...v0.1.5
222
+ [0.1.4]: https://github.com/qualys/qualys-cli/compare/v0.1.2...v0.1.4
151
223
  [0.1.2]: https://github.com/qualys/qualys-cli/compare/v0.1.1...v0.1.2
152
224
  [0.1.1]: https://github.com/qualys/qualys-cli/compare/v0.1.0...v0.1.1
153
225
  [0.1.0]: https://github.com/qualys/qualys-cli/releases/tag/v0.1.0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qualys-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
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
- Author: qualys-cli contributors
6
+ Author-email: Qualys <support@qualys.com>
7
+ Maintainer-email: Balaji Venkatesan <bvenkatesan@qualys.com>, Andrew Nelson <anelson@qualys.com>
7
8
  License: MIT License
8
9
 
9
10
  Copyright (c) 2026 Qualys Inc
@@ -26,7 +27,7 @@ License: MIT License
26
27
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
28
  SOFTWARE.
28
29
  License-File: LICENSE
29
- Keywords: cli,cspm,patch-management,qualys,security,vm,vulnerability-management
30
+ Keywords: asset-inventory,attack-surface-management,cli,cloud-security,container-security,cspm,devsecops,infosec,patch-management,policy-compliance,qualys,qualys-api,qualys-cloud-platform,security,vulnerability-management,web-application-scanning
30
31
  Classifier: Development Status :: 4 - Beta
31
32
  Classifier: Environment :: Console
32
33
  Classifier: Intended Audience :: Information Technology
@@ -62,6 +63,12 @@ Requires-Dist: selectolax>=0.3; extra == 'codegen'
62
63
  Requires-Dist: trafilatura>=1.9; extra == 'codegen'
63
64
  Description-Content-Type: text/markdown
64
65
 
66
+ <p align="center">
67
+ <a href="https://www.qualys.com/">
68
+ <img src="https://ik.imagekit.io/qualys/image/logo/qualys-vertical-sans-tagline-social.png?tr=w-360" width="360" alt="Qualys" />
69
+ </a>
70
+ </p>
71
+
65
72
  # qualys-cli
66
73
 
67
74
  [![PyPI](https://img.shields.io/pypi/v/qualys-cli.svg)](https://pypi.org/project/qualys-cli/)
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://www.qualys.com/">
3
+ <img src="https://ik.imagekit.io/qualys/image/logo/qualys-vertical-sans-tagline-social.png?tr=w-360" width="360" alt="Qualys" />
4
+ </a>
5
+ </p>
6
+
1
7
  # qualys-cli
2
8
 
3
9
  [![PyPI](https://img.shields.io/pypi/v/qualys-cli.svg)](https://pypi.org/project/qualys-cli/)
@@ -4,14 +4,35 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "qualys-cli"
7
- version = "0.1.4"
7
+ version = "0.1.6"
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" }
11
11
  requires-python = ">=3.11"
12
- keywords = ["qualys", "cli", "security", "vulnerability-management", "vm", "patch-management", "cspm"]
12
+ keywords = [
13
+ "qualys",
14
+ "qualys-api",
15
+ "qualys-cloud-platform",
16
+ "cli",
17
+ "security",
18
+ "infosec",
19
+ "devsecops",
20
+ "vulnerability-management",
21
+ "patch-management",
22
+ "policy-compliance",
23
+ "cspm",
24
+ "cloud-security",
25
+ "container-security",
26
+ "web-application-scanning",
27
+ "attack-surface-management",
28
+ "asset-inventory",
29
+ ]
13
30
  authors = [
14
- { name = "qualys-cli contributors" },
31
+ { name = "Qualys", email = "support@qualys.com" },
32
+ ]
33
+ maintainers = [
34
+ { name = "Balaji Venkatesan", email = "bvenkatesan@qualys.com" },
35
+ { name = "Andrew Nelson", email = "anelson@qualys.com" },
15
36
  ]
16
37
  classifiers = [
17
38
  "Development Status :: 4 - Beta",
@@ -0,0 +1 @@
1
+ __version__ = "0.1.6"
@@ -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,
@@ -480,6 +480,40 @@ class QualysClient:
480
480
  expand=False,
481
481
  ))
482
482
 
483
+ # ------------------------------------------------------------------
484
+ # Body-capped send
485
+ # ------------------------------------------------------------------
486
+
487
+ def _send(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
488
+ """Send one HTTP attempt, enforcing ``body_limit`` without fully
489
+ buffering an oversized response first.
490
+
491
+ ``httpx.Client.request()`` always reads the entire body into memory
492
+ before returning. That made the previous body-size guard (checked via
493
+ ``len(resp.content)`` after the call) purely cosmetic: a 2 GB response
494
+ was already fully resident in memory by the time we could reject it.
495
+ When a cap is configured we instead stream the response and abort as
496
+ soon as the cumulative size exceeds the cap, so the guard actually
497
+ bounds memory use.
498
+ """
499
+ if self.body_limit <= 0:
500
+ return self._http.request(method, path, **kwargs)
501
+
502
+ req = self._http.build_request(method, path, **kwargs)
503
+ resp = self._http.send(req, stream=True)
504
+ buf = bytearray()
505
+ try:
506
+ for chunk in resp.iter_bytes():
507
+ buf.extend(chunk)
508
+ if len(buf) > self.body_limit:
509
+ break
510
+ finally:
511
+ resp.close()
512
+ # Mirrors httpx.Response.read()'s own caching behaviour so downstream
513
+ # .content / .text / .json() keep working on the buffered bytes.
514
+ resp._content = bytes(buf) # noqa: SLF001 — no public API for this
515
+ return resp
516
+
483
517
  # ------------------------------------------------------------------
484
518
  # Core request
485
519
  # ------------------------------------------------------------------
@@ -548,7 +582,7 @@ class QualysClient:
548
582
  while attempt < self.max_retries:
549
583
  attempts_made = attempt + 1
550
584
  try:
551
- resp = self._http.request(method, path, **kwargs)
585
+ resp = self._send(method, path, **kwargs)
552
586
  except httpx.RequestError as exc:
553
587
  last_exc = exc
554
588
  resp = None
@@ -840,9 +874,24 @@ class QualysClient:
840
874
 
841
875
  Use when total page count is known up-front (e.g. after one cheap GET
842
876
  that returns ``totalCount``). Output is in deterministic page order.
877
+
878
+ ``page_count`` is typically derived from a server-reported
879
+ ``totalCount`` / ``totalCount / page_size``. A misbehaving or
880
+ malicious upstream inflating that count would otherwise make this
881
+ method dispatch an unbounded number of concurrent GETs. Refuse to
882
+ proceed past a sane ceiling (override via
883
+ ``QUALYS_MAX_PARALLEL_PAGES`` for legitimately huge datasets).
843
884
  """
844
885
  import concurrent.futures as _f
845
886
 
887
+ max_pages = _env_int("QUALYS_MAX_PARALLEL_PAGES", 10_000, minimum=1)
888
+ if page_count > max_pages:
889
+ raise ValueError(
890
+ f"page_count={page_count} exceeds the safety ceiling of "
891
+ f"{max_pages} pages. If this is a legitimately large "
892
+ f"dataset, raise it via QUALYS_MAX_PARALLEL_PAGES."
893
+ )
894
+
846
895
  def _one(page: int) -> Any:
847
896
  p = dict(params or {})
848
897
  p[size_param] = page_size
@@ -1017,7 +1066,15 @@ def _extract_error(resp: httpx.Response) -> str:
1017
1066
  return resp.text[:300]
1018
1067
 
1019
1068
 
1020
- def _qualys_next_url(data: dict[str, Any]) -> str:
1069
+ _MAX_NEXT_URL_DEPTH = 25
1070
+
1071
+
1072
+ def _qualys_next_url(data: dict[str, Any], *, _depth: int = 0) -> str:
1073
+ # Defensive depth cap: `data` is a parsed XML response from the network
1074
+ # (or a MITM without a trusted CA). An adversarially deep/cyclic
1075
+ # structure should not be able to blow the Python call stack here.
1076
+ if _depth > _MAX_NEXT_URL_DEPTH:
1077
+ return ""
1021
1078
  for v in data.values():
1022
1079
  if isinstance(v, dict):
1023
1080
  resp = v.get("RESPONSE", v)
@@ -1026,7 +1083,7 @@ def _qualys_next_url(data: dict[str, Any]) -> str:
1026
1083
  url = warning.get("URL", "")
1027
1084
  if url:
1028
1085
  return str(url)
1029
- deeper = _qualys_next_url(v)
1086
+ deeper = _qualys_next_url(v, _depth=_depth + 1)
1030
1087
  if deeper:
1031
1088
  return deeper
1032
1089
  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)