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.
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/CHANGELOG.md +73 -1
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/PKG-INFO +10 -3
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/README.md +6 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/pyproject.toml +24 -3
- qualys_cli-0.1.6/src/qualys_cli/__init__.py +1 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/audit.py +20 -1
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/auth.py +60 -21
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/cache.py +26 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/cli.py +2 -2
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/client.py +60 -3
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/sub.py +7 -3
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/extras.py +17 -3
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/formatters.py +40 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/http_server.py +47 -4
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/mcp_server.py +43 -3
- qualys_cli-0.1.6/tests/test_review_fixes.py +545 -0
- qualys_cli-0.1.4/src/qualys_cli/__init__.py +0 -1
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/.gitignore +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/LICENSE +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/__main__.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/_docs/usage.html +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/__init__.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/asset.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/ca.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/cs.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/csam.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/etm.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/pc.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/pm.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/scanauth.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/tc.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/user.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/vm.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/commands/was.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/config.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/flair.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/history.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/metrics.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/platforms.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/src/qualys_cli/queries.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/__init__.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/conftest.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_agent_mode.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_all_commands_smoke.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_asset.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_audit.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_ca.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_client.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_csam.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_error_handling.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_etm.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_features.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_flair.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_formatters.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_global_flags.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_help.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_high_fixes.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_main.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_med_fixes.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_pc.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_pm.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_resiliency.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_scanauth.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_security_fixes.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_sub.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_tc.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_user.py +0 -0
- {qualys_cli-0.1.4 → qualys_cli-0.1.6}/tests/test_vm.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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,
|
|
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
|
[](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
|
[](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.
|
|
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 = [
|
|
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 = "
|
|
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
|
-
|
|
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,
|
|
@@ -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.
|
|
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
|
-
|
|
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)
|
|
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)
|