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