qualys-cli 0.1.7__tar.gz → 0.1.8__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.7 → qualys_cli-0.1.8}/CHANGELOG.md +18 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/PKG-INFO +1 -1
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/pyproject.toml +1 -1
- qualys_cli-0.1.8/src/qualys_cli/__init__.py +1 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/_docs/usage.html +2 -2
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/cli.py +3 -2
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/client.py +64 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/config.py +38 -0
- qualys_cli-0.1.7/src/qualys_cli/__init__.py +0 -1
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/.gitignore +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/LICENSE +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/README.md +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/__main__.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/audit.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/auth.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/cache.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/__init__.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/asset.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/ca.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/cs.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/csam.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/etm.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/pc.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/pm.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/scanauth.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/sub.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/tc.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/user.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/vm.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/commands/was.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/extras.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/flair.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/formatters.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/history.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/http_server.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/mcp_server.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/metrics.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/platforms.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/src/qualys_cli/queries.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/__init__.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/conftest.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_agent_mode.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_all_commands_smoke.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_asset.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_audit.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_ca.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_client.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_csam.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_error_handling.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_etm.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_features.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_flair.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_formatters.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_global_flags.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_help.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_high_fixes.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_main.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_med_fixes.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_pc.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_pm.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_resiliency.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_review_fixes.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_scanauth.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_security_fixes.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_sub.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_tc.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_user.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_vm.py +0 -0
- {qualys_cli-0.1.7 → qualys_cli-0.1.8}/tests/test_was.py +0 -0
|
@@ -6,6 +6,24 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.8] — 2026-07-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Every API request now carries client-identification headers so server-side
|
|
13
|
+
operators can attribute traffic to the CLI and count calls per install/user:
|
|
14
|
+
a descriptive `User-Agent` (`qualys-cli/<version> (python/<ver>; <os> <arch>)`
|
|
15
|
+
in place of the opaque `python-httpx/*` default), plus `X-Client-Id`,
|
|
16
|
+
`X-Client-Version`, `X-Install-Id`, and `X-User-Id`.
|
|
17
|
+
- `X-Install-Id` is a stable, opaque per-installation UUID persisted to
|
|
18
|
+
`~/.config/qualys-cli/install_id` (mode `0600`); it encodes no username,
|
|
19
|
+
hostname, or other host information. Resolution is best-effort — a read-only
|
|
20
|
+
or unwritable home degrades to an ephemeral id and never fails a request.
|
|
21
|
+
- The two identifying headers (`X-Install-Id`, `X-User-Id`) can be suppressed
|
|
22
|
+
with `QUALYS_NO_TELEMETRY_HEADERS=1`; the non-identifying `User-Agent` /
|
|
23
|
+
`X-Client-*` headers are always sent. The `X-User-Id` value is sanitised
|
|
24
|
+
(control characters and non-latin-1 bytes stripped) so an unusual username
|
|
25
|
+
can neither break client construction nor enable header injection.
|
|
26
|
+
|
|
9
27
|
## [0.1.7] — 2026-07-17
|
|
10
28
|
|
|
11
29
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qualys-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.8
|
|
4
4
|
Summary: Unified command-line interface for the Qualys Cloud Platform REST APIs (VM, PC, PM, ETM, WAS, TotalCloud, Cloud Agent, Container Security, CSAM, Subscription, Asset, and User management).
|
|
5
5
|
Project-URL: Homepage, https://www.qualys.com
|
|
6
6
|
Author-email: Qualys <support@qualys.com>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qualys-cli"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.8"
|
|
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.8"
|
|
@@ -612,7 +612,7 @@ body.filtering.no-match section.module{display:none}
|
|
|
612
612
|
<aside class="sidebar" aria-label="Documentation navigation">
|
|
613
613
|
<div class="side-header">
|
|
614
614
|
<img src="https://ik.imagekit.io/qualys/image/logo/qualys.svg" alt="Qualys">
|
|
615
|
-
<span class="ver">v0.1.
|
|
615
|
+
<span class="ver">v0.1.8</span>
|
|
616
616
|
</div>
|
|
617
617
|
<div class="side-search">
|
|
618
618
|
<input type="search" id="filter" placeholder="Search commands…"
|
|
@@ -696,7 +696,7 @@ body.filtering.no-match section.module{display:none}
|
|
|
696
696
|
<div class="hero-stats">
|
|
697
697
|
<div class="hero-stat"><span class="n">28</span><span class="l">Modules</span></div>
|
|
698
698
|
<div class="hero-stat"><span class="n">228</span><span class="l">Commands</span></div>
|
|
699
|
-
<div class="hero-stat"><span class="n">v0.1.
|
|
699
|
+
<div class="hero-stat"><span class="n">v0.1.8</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>
|
|
@@ -13,6 +13,7 @@ from rich.rule import Rule
|
|
|
13
13
|
from rich.table import Table
|
|
14
14
|
from rich.text import Text
|
|
15
15
|
|
|
16
|
+
from . import __version__ as _VERSION
|
|
16
17
|
from . import audit as _audit
|
|
17
18
|
from . import config as _cfg
|
|
18
19
|
from . import extras as _extras
|
|
@@ -57,7 +58,7 @@ def _print_banner() -> None:
|
|
|
57
58
|
from importlib.metadata import version
|
|
58
59
|
ver = version("qualys-cli")
|
|
59
60
|
except Exception:
|
|
60
|
-
ver =
|
|
61
|
+
ver = _VERSION
|
|
61
62
|
|
|
62
63
|
_err.print()
|
|
63
64
|
_err.print(Align.center(Text(_WORDMARK, style=f"bold {_QUALYS_RED}")))
|
|
@@ -198,7 +199,7 @@ def _version_callback(value: bool) -> None:
|
|
|
198
199
|
from importlib.metadata import version
|
|
199
200
|
ver = version("qualys-cli")
|
|
200
201
|
except Exception:
|
|
201
|
-
ver =
|
|
202
|
+
ver = _VERSION
|
|
202
203
|
_err.print(f"[bold]qualys-cli[/bold] [cyan]{ver}[/cyan]")
|
|
203
204
|
raise typer.Exit()
|
|
204
205
|
|
|
@@ -32,6 +32,7 @@ from __future__ import annotations
|
|
|
32
32
|
|
|
33
33
|
import json as _json
|
|
34
34
|
import os
|
|
35
|
+
import platform as _platform
|
|
35
36
|
import random
|
|
36
37
|
import threading
|
|
37
38
|
import time
|
|
@@ -45,6 +46,7 @@ import httpx
|
|
|
45
46
|
import xmltodict
|
|
46
47
|
from rich.console import Console
|
|
47
48
|
|
|
49
|
+
from . import __version__ as _VERSION
|
|
48
50
|
from . import audit as _audit
|
|
49
51
|
from . import flair as _flair
|
|
50
52
|
from .auth import basic_auth, bearer_header, get_jwt
|
|
@@ -52,11 +54,72 @@ from .config import Profile
|
|
|
52
54
|
|
|
53
55
|
_console = Console(stderr=True)
|
|
54
56
|
|
|
57
|
+
# A descriptive User-Agent lets the server attribute traffic to the CLI (tool,
|
|
58
|
+
# version, platform) instead of the opaque default ``python-httpx/x.y.z``.
|
|
59
|
+
_USER_AGENT = (
|
|
60
|
+
f"qualys-cli/{_VERSION} "
|
|
61
|
+
f"(python/{_platform.python_version()}; "
|
|
62
|
+
f"{_platform.system() or 'unknown'} {_platform.machine() or 'unknown'})"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Static identity headers sent on every request. The per-installation
|
|
66
|
+
# ``X-Install-Id`` and per-user ``X-User-Id`` headers are added dynamically in
|
|
67
|
+
# ``QualysClient.__init__`` (they depend on config / the active profile).
|
|
55
68
|
_BASE_HEADERS = {
|
|
56
69
|
"X-Requested-With": "qualys-cli",
|
|
57
70
|
"Accept-Encoding": "br, gzip, zstd",
|
|
71
|
+
"User-Agent": _USER_AGENT,
|
|
72
|
+
"X-Client-Id": "qualys-cli",
|
|
73
|
+
"X-Client-Version": _VERSION,
|
|
58
74
|
}
|
|
59
75
|
|
|
76
|
+
|
|
77
|
+
def _telemetry_headers_enabled() -> bool:
|
|
78
|
+
"""Whether the *identifying* headers (X-Install-Id / X-User-Id) are sent.
|
|
79
|
+
|
|
80
|
+
Enabled by default. Set ``QUALYS_NO_TELEMETRY_HEADERS=1`` to suppress the
|
|
81
|
+
two headers that identify the installation and user; the non-identifying
|
|
82
|
+
User-Agent / X-Client-* headers are always sent.
|
|
83
|
+
"""
|
|
84
|
+
return os.environ.get("QUALYS_NO_TELEMETRY_HEADERS", "").strip().lower() \
|
|
85
|
+
not in {"1", "true", "yes", "on"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _identity_headers(profile: Profile) -> dict[str, str]:
|
|
89
|
+
"""Build the per-installation / per-user identity headers for *profile*.
|
|
90
|
+
|
|
91
|
+
Returns an empty dict when the user has opted out via
|
|
92
|
+
``QUALYS_NO_TELEMETRY_HEADERS``. Fully best-effort: any failure resolving
|
|
93
|
+
the install id degrades to omitting that header rather than raising.
|
|
94
|
+
"""
|
|
95
|
+
if not _telemetry_headers_enabled():
|
|
96
|
+
return {}
|
|
97
|
+
from .config import get_install_id
|
|
98
|
+
|
|
99
|
+
headers: dict[str, str] = {}
|
|
100
|
+
try:
|
|
101
|
+
install_id = get_install_id()
|
|
102
|
+
if install_id:
|
|
103
|
+
headers["X-Install-Id"] = install_id
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
user = _header_safe(profile.username)
|
|
107
|
+
if user:
|
|
108
|
+
headers["X-User-Id"] = user
|
|
109
|
+
return headers
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _header_safe(v: str) -> str:
|
|
113
|
+
"""Coerce *v* into a value safe to use as an HTTP header.
|
|
114
|
+
|
|
115
|
+
Header values must be latin-1 encodable and must not contain CR/LF (which
|
|
116
|
+
would allow header injection). Strips control characters and drops any
|
|
117
|
+
byte that cannot be latin-1 encoded, so an unusual username can never make
|
|
118
|
+
``httpx`` raise while constructing the client.
|
|
119
|
+
"""
|
|
120
|
+
cleaned = "".join(ch for ch in v if ch >= " " and ch != "\x7f")
|
|
121
|
+
return cleaned.encode("latin-1", "ignore").decode("latin-1")
|
|
122
|
+
|
|
60
123
|
_METHOD_STYLE: dict[str, str] = {
|
|
61
124
|
"GET": "cyan", "POST": "yellow", "PUT": "blue",
|
|
62
125
|
"DELETE": "red", "PATCH": "magenta",
|
|
@@ -283,6 +346,7 @@ class QualysClient:
|
|
|
283
346
|
headers={
|
|
284
347
|
**_BASE_HEADERS,
|
|
285
348
|
"X-Correlation-ID": _audit.CORRELATION_ID or "uninitialized",
|
|
349
|
+
**_identity_headers(profile),
|
|
286
350
|
},
|
|
287
351
|
timeout=httpx.Timeout(read_t, connect=connect_t),
|
|
288
352
|
limits=httpx.Limits(
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
import contextlib
|
|
21
21
|
import os
|
|
22
22
|
import tomllib
|
|
23
|
+
import uuid
|
|
23
24
|
from dataclasses import dataclass, field
|
|
24
25
|
from pathlib import Path
|
|
25
26
|
from typing import Any
|
|
@@ -32,6 +33,7 @@ except Exception:
|
|
|
32
33
|
|
|
33
34
|
CONFIG_DIR = Path.home() / ".config" / "qualys-cli"
|
|
34
35
|
_CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
36
|
+
_INSTALL_ID_FILE = CONFIG_DIR / "install_id"
|
|
35
37
|
QUERIES_DIR = CONFIG_DIR / "queries"
|
|
36
38
|
CACHE_DIR = CONFIG_DIR / "cache"
|
|
37
39
|
HISTORY_DIR = CONFIG_DIR / "history"
|
|
@@ -193,6 +195,42 @@ def validate_profile_dict(name: str, body: dict[str, Any]) -> list[str]:
|
|
|
193
195
|
# Public API
|
|
194
196
|
# ---------------------------------------------------------------------------
|
|
195
197
|
|
|
198
|
+
def get_install_id() -> str:
|
|
199
|
+
"""Return a stable, opaque per-installation identifier.
|
|
200
|
+
|
|
201
|
+
Generated once and persisted to ``~/.config/qualys-cli/install_id`` so the
|
|
202
|
+
server can attribute CLI traffic to a single installation across
|
|
203
|
+
invocations. It is a random UUID hex — it encodes no username, hostname,
|
|
204
|
+
or other host-identifying information.
|
|
205
|
+
|
|
206
|
+
Stored in its own file rather than ``config.toml`` because
|
|
207
|
+
:func:`_save_file` only serializes the ``profiles`` table; a top-level key
|
|
208
|
+
would be dropped on the next profile save.
|
|
209
|
+
|
|
210
|
+
Best-effort: if the file cannot be read or written (read-only home,
|
|
211
|
+
permission error, …) a fresh ephemeral id is returned instead of raising,
|
|
212
|
+
so header construction never breaks a request.
|
|
213
|
+
"""
|
|
214
|
+
with contextlib.suppress(OSError):
|
|
215
|
+
if _INSTALL_ID_FILE.exists():
|
|
216
|
+
existing = _INSTALL_ID_FILE.read_text(encoding="utf-8").strip()
|
|
217
|
+
if existing:
|
|
218
|
+
return existing
|
|
219
|
+
|
|
220
|
+
new_id = uuid.uuid4().hex
|
|
221
|
+
with contextlib.suppress(OSError):
|
|
222
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
223
|
+
fd = os.open(_INSTALL_ID_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
224
|
+
try:
|
|
225
|
+
with os.fdopen(fd, "w") as fh:
|
|
226
|
+
fh.write(new_id)
|
|
227
|
+
except BaseException:
|
|
228
|
+
with contextlib.suppress(OSError):
|
|
229
|
+
os.close(fd)
|
|
230
|
+
raise
|
|
231
|
+
return new_id
|
|
232
|
+
|
|
233
|
+
|
|
196
234
|
def get_profile(name: str = "default") -> Profile:
|
|
197
235
|
"""Load a profile, auto-detecting platform URLs from the username.
|
|
198
236
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.7"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|