qualys-cli 0.1.1__py3-none-any.whl
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/__init__.py +1 -0
- qualys_cli/__main__.py +8 -0
- qualys_cli/audit.py +616 -0
- qualys_cli/auth.py +187 -0
- qualys_cli/cache.py +123 -0
- qualys_cli/cli.py +1168 -0
- qualys_cli/client.py +1043 -0
- qualys_cli/commands/__init__.py +0 -0
- qualys_cli/commands/asset.py +183 -0
- qualys_cli/commands/ca.py +403 -0
- qualys_cli/commands/cs.py +410 -0
- qualys_cli/commands/csam.py +752 -0
- qualys_cli/commands/etm.py +170 -0
- qualys_cli/commands/pc.py +255 -0
- qualys_cli/commands/pm.py +412 -0
- qualys_cli/commands/scanauth.py +291 -0
- qualys_cli/commands/sub.py +163 -0
- qualys_cli/commands/tc.py +539 -0
- qualys_cli/commands/user.py +104 -0
- qualys_cli/commands/vm.py +562 -0
- qualys_cli/commands/was.py +1278 -0
- qualys_cli/config.py +331 -0
- qualys_cli/extras.py +702 -0
- qualys_cli/flair.py +202 -0
- qualys_cli/formatters.py +896 -0
- qualys_cli/history.py +133 -0
- qualys_cli/http_server.py +126 -0
- qualys_cli/mcp_server.py +341 -0
- qualys_cli/metrics.py +106 -0
- qualys_cli/platforms.py +67 -0
- qualys_cli/queries.py +137 -0
- qualys_cli-0.1.1.data/data/share/qualys-cli/docs/usage.html +1230 -0
- qualys_cli-0.1.1.dist-info/METADATA +319 -0
- qualys_cli-0.1.1.dist-info/RECORD +37 -0
- qualys_cli-0.1.1.dist-info/WHEEL +4 -0
- qualys_cli-0.1.1.dist-info/entry_points.txt +2 -0
- qualys_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
qualys_cli/cli.py
ADDED
|
@@ -0,0 +1,1168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import signal as _signal
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.align import Align
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.rule import Rule
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from . import audit as _audit
|
|
17
|
+
from . import config as _cfg
|
|
18
|
+
from . import extras as _extras
|
|
19
|
+
from .commands import asset, ca, cs, csam, etm, pc, pm, scanauth, sub, tc, user, vm, was
|
|
20
|
+
|
|
21
|
+
_err = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _qualys_cli_version() -> str:
|
|
25
|
+
try:
|
|
26
|
+
from importlib.metadata import version
|
|
27
|
+
return version("qualys-cli")
|
|
28
|
+
except Exception:
|
|
29
|
+
return "0.0.0"
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(
|
|
32
|
+
name="qualys-cli",
|
|
33
|
+
help="Qualys API CLI — VM, PC, PM, ETM, WAS, TC, CA, CS, CSAM and more.",
|
|
34
|
+
invoke_without_command=True,
|
|
35
|
+
pretty_exceptions_enable=False,
|
|
36
|
+
rich_markup_mode="rich",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Qualys brand red (#ed2e26).
|
|
40
|
+
_QUALYS_RED = "#ed2e26"
|
|
41
|
+
|
|
42
|
+
# "QUALYS" wordmark in block capitals (the Q keeps its little tail).
|
|
43
|
+
_WORDMARK = """\
|
|
44
|
+
██████ ██ ██ █████ ██ ██ ██ ███████
|
|
45
|
+
██ ██ ██ ██ ██ ██ ██ ██ ██ ██
|
|
46
|
+
██ ██ ██ ██ ███████ ██ ████ ███████
|
|
47
|
+
██ ▄▄ ██ ██ ██ ██ ██ ██ ██ ██
|
|
48
|
+
██████ ██████ ██ ██ ███████ ██ ███████
|
|
49
|
+
▀▀\
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _print_banner() -> None:
|
|
54
|
+
if not _err.is_terminal:
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
from importlib.metadata import version
|
|
58
|
+
ver = version("qualys-cli")
|
|
59
|
+
except Exception:
|
|
60
|
+
ver = "0.1.0"
|
|
61
|
+
|
|
62
|
+
_err.print()
|
|
63
|
+
_err.print(Align.center(Text(_WORDMARK, style=f"bold {_QUALYS_RED}")))
|
|
64
|
+
_err.print(Rule(
|
|
65
|
+
f"[dim]v{ver} · A unified command-line interface for the Qualys REST APIs[/dim]",
|
|
66
|
+
style=_QUALYS_RED,
|
|
67
|
+
))
|
|
68
|
+
_err.print()
|
|
69
|
+
|
|
70
|
+
# ── Capabilities summary ────────────────────────────────────────────────
|
|
71
|
+
cap = Table(
|
|
72
|
+
box=None, show_header=False, show_edge=False,
|
|
73
|
+
padding=(0, 1), pad_edge=False,
|
|
74
|
+
)
|
|
75
|
+
cap.add_column(no_wrap=True) # icon
|
|
76
|
+
cap.add_column(no_wrap=True, min_width=20) # label
|
|
77
|
+
cap.add_column(no_wrap=False) # description
|
|
78
|
+
cap.add_row(
|
|
79
|
+
"[bold #ed2e26]●[/bold #ed2e26]",
|
|
80
|
+
"[bold]Vuln & Compliance[/bold]",
|
|
81
|
+
"[red1]vm[/red1] · [red1]pc[/red1] · [red1]was[/red1] "
|
|
82
|
+
"[dim]scans, hosts, KB, policies, posture, web-app findings[/dim]",
|
|
83
|
+
)
|
|
84
|
+
cap.add_row(
|
|
85
|
+
"[bold #ed2e26]●[/bold #ed2e26]",
|
|
86
|
+
"[bold]Cloud Security[/bold]",
|
|
87
|
+
"[dodger_blue1]tc[/dodger_blue1] · [dodger_blue1]ca[/dodger_blue1] · "
|
|
88
|
+
"[dodger_blue1]cs[/dodger_blue1] · [dodger_blue1]csam[/dodger_blue1] "
|
|
89
|
+
"[dim]CSPM, agents, containers, asset & EASM inventory[/dim]",
|
|
90
|
+
)
|
|
91
|
+
cap.add_row(
|
|
92
|
+
"[bold #ed2e26]●[/bold #ed2e26]",
|
|
93
|
+
"[bold]Patch & Risk[/bold]",
|
|
94
|
+
"[dark_orange]pm[/dark_orange] · [dark_orange]etm[/dark_orange] "
|
|
95
|
+
"[dim]deployment jobs, mitigations, TruRisk reports[/dim]",
|
|
96
|
+
)
|
|
97
|
+
cap.add_row(
|
|
98
|
+
"[bold #ed2e26]●[/bold #ed2e26]",
|
|
99
|
+
"[bold]Asset & User Mgmt[/bold]",
|
|
100
|
+
"[medium_spring_green]asset[/medium_spring_green] · "
|
|
101
|
+
"[medium_spring_green]user[/medium_spring_green] · "
|
|
102
|
+
"[medium_spring_green]scanauth[/medium_spring_green] · "
|
|
103
|
+
"[medium_spring_green]sub[/medium_spring_green] "
|
|
104
|
+
"[dim]IPs, networks, groups, auth records, subscription[/dim]",
|
|
105
|
+
)
|
|
106
|
+
_err.print(cap)
|
|
107
|
+
_err.print()
|
|
108
|
+
|
|
109
|
+
# ── Quick-start hints ───────────────────────────────────────────────────
|
|
110
|
+
hint = Table(
|
|
111
|
+
box=None, show_header=False, show_edge=False,
|
|
112
|
+
padding=(0, 1), pad_edge=False,
|
|
113
|
+
)
|
|
114
|
+
hint.add_column(no_wrap=True) # arrow
|
|
115
|
+
hint.add_column(no_wrap=True, min_width=20) # description
|
|
116
|
+
hint.add_column(no_wrap=False) # command
|
|
117
|
+
hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Get started[/dim]",
|
|
118
|
+
"[cyan]qualys-cli configure[/cyan]")
|
|
119
|
+
hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Browse all modules[/dim]",
|
|
120
|
+
"[cyan]qualys-cli help[/cyan]")
|
|
121
|
+
hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Switch output mode[/dim]",
|
|
122
|
+
"[cyan]qualys-cli mode agentic[/cyan] "
|
|
123
|
+
"[dim](JSON for scripts/agents)[/dim]")
|
|
124
|
+
hint.add_row("[bold yellow]→[/bold yellow]", "[dim]Per-command flags[/dim]",
|
|
125
|
+
"[cyan]qualys-cli <module> <cmd> --help[/cyan]")
|
|
126
|
+
_err.print(hint)
|
|
127
|
+
|
|
128
|
+
_err.print()
|
|
129
|
+
_err.print(Rule(
|
|
130
|
+
"[dim]209 commands across 13 Qualys APIs · "
|
|
131
|
+
"rich tables for humans, clean JSON for agents · "
|
|
132
|
+
"JWT auto-refresh · retry/backoff · OS-keyring credential storage[/dim]",
|
|
133
|
+
style=_QUALYS_RED,
|
|
134
|
+
))
|
|
135
|
+
_err.print()
|
|
136
|
+
|
|
137
|
+
# ── Register every command group into a named Typer help panel ─────────────
|
|
138
|
+
# Typer's `rich_help_panel` argument groups entries into separately-bordered
|
|
139
|
+
# boxes in the root --help output. Without this, all 27 sub-apps render as
|
|
140
|
+
# one flat list, which is hard to scan. Panels mirror the four operational
|
|
141
|
+
# domains (Vuln & Compliance, Cloud Security, Patch & Risk, Asset & User
|
|
142
|
+
# Management) plus an "Operator & Automation" section for the cross-cutting
|
|
143
|
+
# utility commands and a "Configuration & Diagnostics" section for the local
|
|
144
|
+
# top-level entries (configure / mode / profiles / doctor / etc.).
|
|
145
|
+
|
|
146
|
+
_PANEL_VC = "Vulnerability & Compliance"
|
|
147
|
+
_PANEL_CS = "Cloud Security"
|
|
148
|
+
_PANEL_PR = "Patch & Risk"
|
|
149
|
+
_PANEL_AU = "Asset & User Management"
|
|
150
|
+
_PANEL_OP = "Operator & Automation"
|
|
151
|
+
_PANEL_AGENT = "Agent & Integration Bridges"
|
|
152
|
+
_PANEL_CFG = "Configuration & Diagnostics"
|
|
153
|
+
|
|
154
|
+
# Vulnerability & Compliance APIs
|
|
155
|
+
app.add_typer(vm.app, rich_help_panel=_PANEL_VC)
|
|
156
|
+
app.add_typer(pc.app, rich_help_panel=_PANEL_VC)
|
|
157
|
+
app.add_typer(was.app, rich_help_panel=_PANEL_VC)
|
|
158
|
+
|
|
159
|
+
# Cloud Security APIs
|
|
160
|
+
app.add_typer(tc.app, rich_help_panel=_PANEL_CS)
|
|
161
|
+
app.add_typer(ca.app, rich_help_panel=_PANEL_CS)
|
|
162
|
+
app.add_typer(cs.app, rich_help_panel=_PANEL_CS)
|
|
163
|
+
app.add_typer(csam.app, rich_help_panel=_PANEL_CS)
|
|
164
|
+
|
|
165
|
+
# Patch & Risk APIs
|
|
166
|
+
app.add_typer(pm.app, rich_help_panel=_PANEL_PR)
|
|
167
|
+
app.add_typer(etm.app, rich_help_panel=_PANEL_PR)
|
|
168
|
+
|
|
169
|
+
# Asset & User Management APIs
|
|
170
|
+
app.add_typer(asset.app, rich_help_panel=_PANEL_AU)
|
|
171
|
+
app.add_typer(scanauth.app, rich_help_panel=_PANEL_AU)
|
|
172
|
+
app.add_typer(user.app, rich_help_panel=_PANEL_AU)
|
|
173
|
+
app.add_typer(sub.app, rich_help_panel=_PANEL_AU)
|
|
174
|
+
|
|
175
|
+
# Operator & Automation utilities — preflight, audit, history, batch
|
|
176
|
+
app.add_typer(_extras.doctor_app, name="doctor", rich_help_panel=_PANEL_OP)
|
|
177
|
+
app.add_typer(_extras.health_app, name="health", rich_help_panel=_PANEL_OP)
|
|
178
|
+
app.add_typer(_extras.stats_app, name="stats", rich_help_panel=_PANEL_OP)
|
|
179
|
+
app.add_typer(_extras.verify_audit_app, name="verify-audit", rich_help_panel=_PANEL_OP)
|
|
180
|
+
app.add_typer(_extras.rotate_jwt_app, name="rotate-jwt", rich_help_panel=_PANEL_OP)
|
|
181
|
+
app.add_typer(_extras.last_app, name="last", rich_help_panel=_PANEL_OP)
|
|
182
|
+
app.add_typer(_extras.replay_app, name="replay", rich_help_panel=_PANEL_OP)
|
|
183
|
+
app.add_typer(_extras.watch_app, name="watch", rich_help_panel=_PANEL_OP)
|
|
184
|
+
app.add_typer(_extras.batch_app, name="batch", rich_help_panel=_PANEL_OP)
|
|
185
|
+
app.add_typer(_extras.query_app, name="query", rich_help_panel=_PANEL_OP)
|
|
186
|
+
app.add_typer(_extras.cache_app, name="cache", rich_help_panel=_PANEL_OP)
|
|
187
|
+
|
|
188
|
+
# Agent / integration bridges (MCP, HTTP server, shell completion)
|
|
189
|
+
app.add_typer(_extras.mcp_app, name="mcp", rich_help_panel=_PANEL_AGENT)
|
|
190
|
+
app.add_typer(_extras.serve_app, name="serve", rich_help_panel=_PANEL_AGENT)
|
|
191
|
+
app.add_typer(_extras.completion_app, name="completion", rich_help_panel=_PANEL_AGENT)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _version_callback(value: bool) -> None:
|
|
195
|
+
if value:
|
|
196
|
+
try:
|
|
197
|
+
from importlib.metadata import version
|
|
198
|
+
ver = version("qualys-cli")
|
|
199
|
+
except Exception:
|
|
200
|
+
ver = "0.1.0"
|
|
201
|
+
_err.print(f"[bold]qualys-cli[/bold] [cyan]{ver}[/cyan]")
|
|
202
|
+
raise typer.Exit()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
_NO_AUTH_CMDS = {
|
|
206
|
+
"configure", "profiles", "help", "mode",
|
|
207
|
+
# Local-only utility commands that don't need credentials to be useful
|
|
208
|
+
"stats", "verify-audit", "last", "replay", "completion", "cache",
|
|
209
|
+
"query", "batch", "watch",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _apply_profile_mode(profile_name: str) -> None:
|
|
214
|
+
"""Read output_mode from the profile and patch all Console instances."""
|
|
215
|
+
import os
|
|
216
|
+
|
|
217
|
+
from rich.console import Console as _Console
|
|
218
|
+
|
|
219
|
+
from . import client as _client_mod
|
|
220
|
+
from . import formatters as _fmt
|
|
221
|
+
|
|
222
|
+
mode = os.environ.get("QUALYS_OUTPUT_MODE", "")
|
|
223
|
+
if not mode:
|
|
224
|
+
try:
|
|
225
|
+
p = _cfg.get_profile(profile_name)
|
|
226
|
+
mode = p.output_mode
|
|
227
|
+
except Exception:
|
|
228
|
+
mode = "interactive"
|
|
229
|
+
|
|
230
|
+
if mode == "agentic":
|
|
231
|
+
force, no_color = False, True
|
|
232
|
+
else: # interactive
|
|
233
|
+
force, no_color = True, False
|
|
234
|
+
|
|
235
|
+
global _err
|
|
236
|
+
_err = _Console(stderr=True, force_terminal=force, no_color=no_color)
|
|
237
|
+
_fmt._console = _Console(force_terminal=force, no_color=no_color)
|
|
238
|
+
_fmt._err = _Console(stderr=True, force_terminal=force, no_color=no_color)
|
|
239
|
+
_client_mod._console = _Console(stderr=True, force_terminal=force, no_color=no_color)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _maybe_auto_configure(profile: str) -> None:
|
|
243
|
+
"""If credentials are missing and we're in a TTY, run the configure wizard automatically."""
|
|
244
|
+
# In non-TTY mode errors surface as JSON — don't prompt
|
|
245
|
+
if not _err.is_terminal:
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
_cfg.get_profile(profile)
|
|
249
|
+
return # credentials OK
|
|
250
|
+
except ValueError:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
_err.print()
|
|
254
|
+
_err.print(Panel(
|
|
255
|
+
f"No credentials found for profile [bold]{profile}[/bold].\n"
|
|
256
|
+
"Let's configure them now.",
|
|
257
|
+
title="[bold yellow] First-time Setup [/bold yellow]",
|
|
258
|
+
border_style="yellow",
|
|
259
|
+
expand=False,
|
|
260
|
+
))
|
|
261
|
+
_err.print()
|
|
262
|
+
|
|
263
|
+
username = typer.prompt("Username")
|
|
264
|
+
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
265
|
+
_save_and_show(profile, username, password, output_mode="interactive", continuing=True)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@app.callback()
|
|
269
|
+
def _root(
|
|
270
|
+
ctx: typer.Context,
|
|
271
|
+
profile: str = typer.Option(
|
|
272
|
+
"default", "--profile", "-p", envvar="QUALYS_PROFILE",
|
|
273
|
+
help="Named credential profile",
|
|
274
|
+
),
|
|
275
|
+
version: bool | None = typer.Option(
|
|
276
|
+
None, "--version", "-V",
|
|
277
|
+
is_eager=True, callback=_version_callback,
|
|
278
|
+
help="Show version and exit",
|
|
279
|
+
),
|
|
280
|
+
dry_run: bool = typer.Option(
|
|
281
|
+
False, "--dry-run", "--explain",
|
|
282
|
+
help="Print the resolved HTTP request and exit without sending it",
|
|
283
|
+
),
|
|
284
|
+
) -> None:
|
|
285
|
+
"""Qualys API CLI."""
|
|
286
|
+
ctx.ensure_object(dict)
|
|
287
|
+
ctx.obj["profile_name"] = profile
|
|
288
|
+
|
|
289
|
+
# Apply output mode before any output so banners/JSON/spinners are correct.
|
|
290
|
+
_apply_profile_mode(profile)
|
|
291
|
+
|
|
292
|
+
# Always assign so a test using --dry-run doesn't leak DRY_RUN=True into
|
|
293
|
+
# subsequent in-process invocations (CliRunner reuses module state).
|
|
294
|
+
from . import client as _client_mod
|
|
295
|
+
_client_mod.DRY_RUN = bool(dry_run)
|
|
296
|
+
|
|
297
|
+
# Stamp the active profile/username onto the audit log as soon as we know
|
|
298
|
+
# them. The username may be unavailable (no config yet) — that's fine,
|
|
299
|
+
# subsequent log lines still carry the correlation ID.
|
|
300
|
+
try:
|
|
301
|
+
prof = _cfg.get_profile(profile)
|
|
302
|
+
username = prof.username
|
|
303
|
+
except Exception:
|
|
304
|
+
prof = None
|
|
305
|
+
username = ""
|
|
306
|
+
_audit.init(profile=profile, username=username)
|
|
307
|
+
|
|
308
|
+
# Surface schema warnings on the active profile (typos in config.toml etc.)
|
|
309
|
+
# once per invocation, never blocking the command itself.
|
|
310
|
+
for warn in _cfg.collect_warnings():
|
|
311
|
+
if _err.is_terminal:
|
|
312
|
+
_err.print(f" [yellow]⚠ config:[/yellow] [dim]{warn}[/dim]")
|
|
313
|
+
|
|
314
|
+
# Wire profile-driven output redaction (PII / customer-field protection).
|
|
315
|
+
# Always assign LOCKED_MODE (even when there's no profile yet) so a
|
|
316
|
+
# locked profile from an earlier in-process invocation can't leak into
|
|
317
|
+
# a later one that has no profile (relevant for CliRunner-based tests
|
|
318
|
+
# that reuse module state, same concern as DRY_RUN above).
|
|
319
|
+
from . import formatters as _fmt
|
|
320
|
+
_fmt.LOCKED_MODE = bool(prof and prof.locked_mode)
|
|
321
|
+
if prof and prof.redact_fields:
|
|
322
|
+
_fmt.REDACT_FIELDS = list(prof.redact_fields)
|
|
323
|
+
|
|
324
|
+
# Locked-mode and scope-restricted profile enforcement.
|
|
325
|
+
if prof:
|
|
326
|
+
invoked = ctx.invoked_subcommand or ""
|
|
327
|
+
|
|
328
|
+
# Locked mode: refuses free-form --output-file paths (enforced in
|
|
329
|
+
# formatters.guard_output_file / output()), suppresses verbose request
|
|
330
|
+
# logging (enforced in client.make(), which zeroes `verbose` for a
|
|
331
|
+
# locked profile), and refuses to run at all against a profile that
|
|
332
|
+
# still has a plaintext password in config.toml.
|
|
333
|
+
if prof.locked_mode:
|
|
334
|
+
has_plaintext = any(
|
|
335
|
+
"plaintext" in w and f"'{profile}'" in w for w in _cfg.collect_warnings()
|
|
336
|
+
)
|
|
337
|
+
if has_plaintext and invoked not in _NO_AUTH_CMDS:
|
|
338
|
+
_err.print(Panel(
|
|
339
|
+
f"Profile [bold]{profile}[/bold] is locked but still has a "
|
|
340
|
+
"plaintext password in config.toml.\n\n"
|
|
341
|
+
"Run [cyan]qualys-cli configure[/cyan] to move it into the "
|
|
342
|
+
"OS keyring before using a locked profile.",
|
|
343
|
+
title="[bold red] Locked mode — plaintext password refused [/bold red]",
|
|
344
|
+
border_style="red", expand=False,
|
|
345
|
+
))
|
|
346
|
+
raise typer.Exit(3)
|
|
347
|
+
|
|
348
|
+
if prof.allowed_modules and invoked and invoked not in _NO_AUTH_CMDS \
|
|
349
|
+
and invoked not in {*prof.allowed_modules, "doctor", "health"}:
|
|
350
|
+
_err.print(Panel(
|
|
351
|
+
f"Profile [bold]{profile}[/bold] is scope-restricted and may not invoke "
|
|
352
|
+
f"the [bold]{invoked}[/bold] module.\n\n"
|
|
353
|
+
f"Allowed: [cyan]{', '.join(prof.allowed_modules)}[/cyan]",
|
|
354
|
+
title="[bold red] Scope restriction [/bold red]",
|
|
355
|
+
border_style="red", expand=False,
|
|
356
|
+
))
|
|
357
|
+
raise typer.Exit(3)
|
|
358
|
+
|
|
359
|
+
if ctx.invoked_subcommand is None:
|
|
360
|
+
_print_banner()
|
|
361
|
+
_err.print(ctx.get_help())
|
|
362
|
+
raise typer.Exit()
|
|
363
|
+
|
|
364
|
+
if ctx.invoked_subcommand not in _NO_AUTH_CMDS:
|
|
365
|
+
_maybe_auto_configure(profile)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
_OP_STYLES: dict[str, str] = {
|
|
369
|
+
"list": "bold cyan",
|
|
370
|
+
"get": "bold cyan",
|
|
371
|
+
"count": "bold cyan",
|
|
372
|
+
"create": "bold green",
|
|
373
|
+
"update": "bold green",
|
|
374
|
+
"patch": "bold green",
|
|
375
|
+
"import": "bold green",
|
|
376
|
+
"add": "bold green",
|
|
377
|
+
"manage": "bold green",
|
|
378
|
+
"launch": "bold yellow",
|
|
379
|
+
"run": "bold yellow",
|
|
380
|
+
"fetch": "bold yellow",
|
|
381
|
+
"scan": "bold yellow",
|
|
382
|
+
"delete": "bold red",
|
|
383
|
+
"remove": "bold red",
|
|
384
|
+
"cancel": "bold red",
|
|
385
|
+
"purge": "bold red",
|
|
386
|
+
"export": "bold blue",
|
|
387
|
+
"download": "bold blue",
|
|
388
|
+
"config": "bold magenta",
|
|
389
|
+
"info": "bold cyan",
|
|
390
|
+
"sync": "bold green",
|
|
391
|
+
"activate": "bold green",
|
|
392
|
+
"deactivate": "bold red",
|
|
393
|
+
"enable": "bold green",
|
|
394
|
+
"disable": "bold red",
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
# Per-module metadata for the in-CLI help screen. Each entry:
|
|
398
|
+
# color, friendly title, domain (groups modules into colored sections)
|
|
399
|
+
_VC = "Vulnerability & Compliance"
|
|
400
|
+
_CS = "Cloud Security"
|
|
401
|
+
_PR = "Patch & Risk"
|
|
402
|
+
_AU = "Asset & User Management"
|
|
403
|
+
_MODULE_DISPLAY: dict[str, tuple[str, str, str]] = {
|
|
404
|
+
"vm": ("red1", "Vulnerability Management", _VC),
|
|
405
|
+
"pc": ("red1", "Policy Compliance", _VC),
|
|
406
|
+
"was": ("red1", "Web App Scanning", _VC),
|
|
407
|
+
"tc": ("dodger_blue1", "TotalCloud CSPM", _CS),
|
|
408
|
+
"ca": ("dodger_blue1", "Cloud Agent", _CS),
|
|
409
|
+
"cs": ("dodger_blue1", "Container Security", _CS),
|
|
410
|
+
"csam": ("dodger_blue1", "CyberSecurity Asset Mgmt", _CS),
|
|
411
|
+
"pm": ("dark_orange", "Patch Management", _PR),
|
|
412
|
+
"etm": ("dark_orange", "Enterprise TruRisk Mgmt", _PR),
|
|
413
|
+
"asset": ("medium_spring_green", "IP / Network / Group Mgmt", _AU),
|
|
414
|
+
"user": ("medium_spring_green", "User Management", _AU),
|
|
415
|
+
"scanauth": ("medium_spring_green", "Scan Auth Records", _AU),
|
|
416
|
+
"sub": ("medium_spring_green", "Subscription", _AU),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
# Order of domain sections in the help screen
|
|
420
|
+
_DOMAIN_ORDER = [
|
|
421
|
+
"Vulnerability & Compliance",
|
|
422
|
+
"Cloud Security",
|
|
423
|
+
"Patch & Risk",
|
|
424
|
+
"Asset & User Management",
|
|
425
|
+
]
|
|
426
|
+
_DOMAIN_RULE_COLORS = {
|
|
427
|
+
"Vulnerability & Compliance": "red1",
|
|
428
|
+
"Cloud Security": "dodger_blue1",
|
|
429
|
+
"Patch & Risk": "dark_orange",
|
|
430
|
+
"Asset & User Management": "medium_spring_green",
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
_BUCKET_REWRITES: dict[str, str] = {
|
|
435
|
+
# Map command-final tokens to a sensible action bucket so help stays tidy
|
|
436
|
+
"scan-again": "run",
|
|
437
|
+
"edit-severity": "manage",
|
|
438
|
+
"ignore": "manage",
|
|
439
|
+
"reactivate": "manage",
|
|
440
|
+
"restore": "manage",
|
|
441
|
+
"retest": "run",
|
|
442
|
+
"retest-status": "get",
|
|
443
|
+
"results": "fetch",
|
|
444
|
+
"burp": "import",
|
|
445
|
+
"owasp-zap": "import",
|
|
446
|
+
"add-to-subscription": "create",
|
|
447
|
+
"create-webapp": "create",
|
|
448
|
+
"create-scan": "create",
|
|
449
|
+
"list-by-asset": "list",
|
|
450
|
+
"list-easm": "list",
|
|
451
|
+
"list-scan": "list",
|
|
452
|
+
"import-asset": "import",
|
|
453
|
+
"import-app": "import",
|
|
454
|
+
"sn-export": "export",
|
|
455
|
+
"change-status": "manage",
|
|
456
|
+
"bulk-delete": "delete",
|
|
457
|
+
"bulk-activate": "manage",
|
|
458
|
+
"bulk-deactivate": "manage",
|
|
459
|
+
"launch-ods": "launch",
|
|
460
|
+
"user-prefs": "config",
|
|
461
|
+
"download-resource": "download",
|
|
462
|
+
"data": "get",
|
|
463
|
+
"products": "list",
|
|
464
|
+
"columns": "get",
|
|
465
|
+
"runs": "list",
|
|
466
|
+
"summary": "get",
|
|
467
|
+
"findings": "list",
|
|
468
|
+
"vulns": "list",
|
|
469
|
+
"software": "list",
|
|
470
|
+
"generate": "run",
|
|
471
|
+
"change-password": "manage",
|
|
472
|
+
"orgs": "list",
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _walk_module(t: typer.Typer, prefix: str = "") -> list[tuple[str, str]]:
|
|
477
|
+
"""Return [(operation_name, action_bucket), ...] for every command under a Typer.
|
|
478
|
+
|
|
479
|
+
operation_name is the dotted command path inside the module
|
|
480
|
+
(e.g. "scan list", "host detection", "act-key create").
|
|
481
|
+
action_bucket is the bucket label used to colour and group the entry
|
|
482
|
+
(e.g. "list", "create", "delete").
|
|
483
|
+
"""
|
|
484
|
+
out: list[tuple[str, str]] = []
|
|
485
|
+
for c in (t.registered_commands or []):
|
|
486
|
+
if isinstance(c.name, str):
|
|
487
|
+
cname = c.name
|
|
488
|
+
elif c.callback is not None:
|
|
489
|
+
cname = c.callback.__name__.replace("_", "-")
|
|
490
|
+
else:
|
|
491
|
+
continue
|
|
492
|
+
full = f"{prefix} {cname}".strip()
|
|
493
|
+
# Pick the bucket from the final token; rewrites map oddities into
|
|
494
|
+
# canonical buckets (e.g. scan-again → run, owasp-zap → import).
|
|
495
|
+
bucket = _BUCKET_REWRITES.get(cname, cname)
|
|
496
|
+
out.append((full, bucket))
|
|
497
|
+
for g in (t.registered_groups or []):
|
|
498
|
+
sub = g.typer_instance
|
|
499
|
+
if sub is None:
|
|
500
|
+
continue
|
|
501
|
+
gname = sub.info.name if sub.info and isinstance(sub.info.name, str) else g.name
|
|
502
|
+
if not isinstance(gname, str):
|
|
503
|
+
continue
|
|
504
|
+
out.extend(_walk_module(sub, f"{prefix} {gname}".strip()))
|
|
505
|
+
return out
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _bucket_ops(ops: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|
509
|
+
"""Group commands by their action word into [(label, "cmd1 · cmd2"), ...]."""
|
|
510
|
+
buckets: dict[str, list[str]] = {}
|
|
511
|
+
for op, action in ops:
|
|
512
|
+
buckets.setdefault(action, []).append(op)
|
|
513
|
+
# Order: list-style first, then mutate, then delete. Falls back to alpha.
|
|
514
|
+
order = [
|
|
515
|
+
"info", "list", "count", "get", "status",
|
|
516
|
+
"create", "add", "import", "patch", "update", "activate", "enable", "sync",
|
|
517
|
+
"launch", "run", "fetch", "scan", "scan-again",
|
|
518
|
+
"export", "download",
|
|
519
|
+
"deactivate", "disable", "cancel", "purge", "remove", "delete",
|
|
520
|
+
]
|
|
521
|
+
out: list[tuple[str, str]] = []
|
|
522
|
+
seen: set[str] = set()
|
|
523
|
+
for label in order:
|
|
524
|
+
if label in buckets:
|
|
525
|
+
out.append((label, " · ".join(buckets[label])))
|
|
526
|
+
seen.add(label)
|
|
527
|
+
for label in sorted(buckets):
|
|
528
|
+
if label not in seen:
|
|
529
|
+
out.append((label, " · ".join(buckets[label])))
|
|
530
|
+
return out
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _module_table(
|
|
534
|
+
color: str,
|
|
535
|
+
modules: list[tuple[str, str, list[tuple[str, str]]]],
|
|
536
|
+
) -> Table:
|
|
537
|
+
"""Build a table for one module group.
|
|
538
|
+
|
|
539
|
+
Each module gets a bold header row followed by indented operation rows,
|
|
540
|
+
one row per action type, color-coded by type.
|
|
541
|
+
"""
|
|
542
|
+
t = Table(box=None, show_header=False, show_edge=False, padding=(0, 1), pad_edge=True)
|
|
543
|
+
t.add_column("cmd", min_width=12, no_wrap=True)
|
|
544
|
+
t.add_column("label", min_width=10, no_wrap=True)
|
|
545
|
+
t.add_column("cmds", no_wrap=False)
|
|
546
|
+
|
|
547
|
+
for i, (cmd, name, ops) in enumerate(modules):
|
|
548
|
+
if i:
|
|
549
|
+
t.add_row("", "", "") # blank spacer between modules
|
|
550
|
+
# Module header row
|
|
551
|
+
t.add_row(
|
|
552
|
+
f"[bold {color}]{cmd}[/bold {color}]",
|
|
553
|
+
f"[bold white]{name}[/bold white]",
|
|
554
|
+
"",
|
|
555
|
+
)
|
|
556
|
+
# One row per operation bucket
|
|
557
|
+
for label, cmds in ops:
|
|
558
|
+
style = _OP_STYLES.get(label, "bold white")
|
|
559
|
+
t.add_row(
|
|
560
|
+
"",
|
|
561
|
+
f" [{style}]{label}[/{style}]",
|
|
562
|
+
f"[dim]{cmds}[/dim]",
|
|
563
|
+
)
|
|
564
|
+
return t
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@app.command("help", rich_help_panel=_PANEL_CFG)
|
|
568
|
+
def help_cmd(ctx: typer.Context) -> None:
|
|
569
|
+
"""Show detailed help: modules grouped by domain, with color-coded operations.
|
|
570
|
+
|
|
571
|
+
The list is generated by introspecting the live Typer app, so it stays in
|
|
572
|
+
sync with the actual registered commands automatically.
|
|
573
|
+
"""
|
|
574
|
+
_print_banner()
|
|
575
|
+
|
|
576
|
+
# Collect every module's commands by walking the live Typer app
|
|
577
|
+
by_module: dict[str, list[tuple[str, str]]] = {}
|
|
578
|
+
for grp in (app.registered_groups or []):
|
|
579
|
+
sub = grp.typer_instance
|
|
580
|
+
if sub is None:
|
|
581
|
+
continue
|
|
582
|
+
mod_key = sub.info.name if sub.info else grp.name
|
|
583
|
+
if not isinstance(mod_key, str) or mod_key not in _MODULE_DISPLAY:
|
|
584
|
+
continue
|
|
585
|
+
by_module[mod_key] = _walk_module(sub)
|
|
586
|
+
|
|
587
|
+
# Group modules by their domain section
|
|
588
|
+
by_domain: dict[str, list[tuple[str, str, list[tuple[str, str]]]]] = {}
|
|
589
|
+
for mod_key, ops in by_module.items():
|
|
590
|
+
color, title, domain = _MODULE_DISPLAY[mod_key]
|
|
591
|
+
by_domain.setdefault(domain, []).append((mod_key, title, _bucket_ops(ops)))
|
|
592
|
+
|
|
593
|
+
# Render each domain section in the documented order
|
|
594
|
+
for i, domain in enumerate(_DOMAIN_ORDER):
|
|
595
|
+
if domain not in by_domain:
|
|
596
|
+
continue
|
|
597
|
+
if i:
|
|
598
|
+
_err.print()
|
|
599
|
+
rule_color = _DOMAIN_RULE_COLORS[domain]
|
|
600
|
+
_err.print(Rule(
|
|
601
|
+
f"[bold {rule_color}] {domain} [/bold {rule_color}]",
|
|
602
|
+
style=rule_color,
|
|
603
|
+
))
|
|
604
|
+
# All entries in a domain share the same accent colour
|
|
605
|
+
_err.print(_module_table(rule_color, by_domain[domain]))
|
|
606
|
+
|
|
607
|
+
# ── Output formats ──────────────────────────────────────────────────────
|
|
608
|
+
_err.print()
|
|
609
|
+
_err.print(Rule("[bold] Output Formats [/bold]", style="dim"))
|
|
610
|
+
fmt = Table(
|
|
611
|
+
box=box.SIMPLE_HEAD, show_header=True, border_style="dim",
|
|
612
|
+
show_edge=False, padding=(0, 1), header_style="bold",
|
|
613
|
+
)
|
|
614
|
+
fmt.add_column("Flag / Context", style="cyan", min_width=28)
|
|
615
|
+
fmt.add_column("Behaviour", style="white")
|
|
616
|
+
fmt.add_row("qualys-cli mode",
|
|
617
|
+
"Show or change the persistent output mode for this profile")
|
|
618
|
+
fmt.add_row("qualys-cli mode interactive",
|
|
619
|
+
"Default: rich tables · colours · spinners")
|
|
620
|
+
fmt.add_row("qualys-cli mode agentic",
|
|
621
|
+
"Clean JSON · no decoration — for scripts and AI agents")
|
|
622
|
+
fmt.add_row("--format json", "One-shot: full JSON to stdout")
|
|
623
|
+
fmt.add_row("--format jsonl", "One JSON object per line (NDJSON)")
|
|
624
|
+
fmt.add_row("--format yaml", "Full YAML to stdout")
|
|
625
|
+
fmt.add_row("--format table", "Rich table (forced)")
|
|
626
|
+
fmt.add_row("--output-file out.json",
|
|
627
|
+
"Dump full data to file (JSON or YAML by extension)")
|
|
628
|
+
fmt.add_row("--limit 0", "Remove 25-row cap — return all rows")
|
|
629
|
+
fmt.add_row("--profile NAME", "Use a named credential profile")
|
|
630
|
+
fmt.add_row("-v / -vv",
|
|
631
|
+
"Log requests and response sizes (secrets never logged)")
|
|
632
|
+
_err.print(fmt)
|
|
633
|
+
|
|
634
|
+
# ── Quick examples ──────────────────────────────────────────────────────
|
|
635
|
+
_err.print()
|
|
636
|
+
_err.print(Rule("[bold] Quick Examples [/bold]", style="dim"))
|
|
637
|
+
ex = Table(
|
|
638
|
+
box=None, show_header=False, show_edge=False, padding=(0, 1),
|
|
639
|
+
)
|
|
640
|
+
ex.add_column("desc", style="dim", min_width=30)
|
|
641
|
+
ex.add_column("cmd", style="cyan")
|
|
642
|
+
|
|
643
|
+
examples = [
|
|
644
|
+
("First-time setup", "qualys-cli configure"),
|
|
645
|
+
("VM host detections", "qualys-cli vm host detection --ips 10.0.0.0/24"),
|
|
646
|
+
("Launch a VM scan",
|
|
647
|
+
'qualys-cli vm scan launch --title "Weekly" --ip 10.0.1.0/24 --option-id 123'),
|
|
648
|
+
("List patch jobs (JSON)", "qualys-cli pm job list --format json"),
|
|
649
|
+
("All CSAM assets, no row cap", "qualys-cli csam asset list --limit 0"),
|
|
650
|
+
("Pipe-friendly output", "qualys-cli csam asset list | jq '.[].name'"),
|
|
651
|
+
("TotalCloud AWS connectors", "qualys-cli tc connector list --provider AWS"),
|
|
652
|
+
("Container images with vulns",
|
|
653
|
+
'qualys-cli cs image list --filter "vulns.severity5Count:>0"'),
|
|
654
|
+
("Cloud agent list", "qualys-cli ca agent list"),
|
|
655
|
+
("Subscription info", "qualys-cli sub info"),
|
|
656
|
+
]
|
|
657
|
+
for desc, cmd in examples:
|
|
658
|
+
ex.add_row(desc, cmd)
|
|
659
|
+
_err.print(ex)
|
|
660
|
+
_err.print()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _save_and_show(
|
|
664
|
+
profile: str,
|
|
665
|
+
username: str,
|
|
666
|
+
password: str,
|
|
667
|
+
output_mode: str = "interactive",
|
|
668
|
+
continuing: bool = False,
|
|
669
|
+
) -> None:
|
|
670
|
+
"""Save credentials and display a confirmation panel with detected platform info."""
|
|
671
|
+
from .platforms import detect as _detect_platform
|
|
672
|
+
|
|
673
|
+
in_keyring = _cfg.save_profile(
|
|
674
|
+
profile, username=username, password=password, output_mode=output_mode,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
try:
|
|
678
|
+
plat = _detect_platform(username)
|
|
679
|
+
api_url = plat.api_url
|
|
680
|
+
gateway_url = plat.gateway_url
|
|
681
|
+
platform_name = plat.name
|
|
682
|
+
except ValueError:
|
|
683
|
+
api_url = gateway_url = "[dim]unknown — set QUALYS_URL to override[/dim]"
|
|
684
|
+
platform_name = "unknown"
|
|
685
|
+
|
|
686
|
+
mode_label = (
|
|
687
|
+
"[cyan]agentic[/cyan] (JSON, no decoration)"
|
|
688
|
+
if output_mode == "agentic"
|
|
689
|
+
else "[green]interactive[/green] (rich tables)"
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
t = Table(
|
|
693
|
+
box=box.SIMPLE_HEAD, show_header=False, border_style="dim",
|
|
694
|
+
show_edge=False, padding=(0, 1),
|
|
695
|
+
)
|
|
696
|
+
t.add_column("key", style="dim")
|
|
697
|
+
t.add_column("val")
|
|
698
|
+
t.add_row("Profile", f"[bold]{profile}[/bold]")
|
|
699
|
+
t.add_row("Platform", f"[cyan]{platform_name}[/cyan]")
|
|
700
|
+
t.add_row("API URL", api_url)
|
|
701
|
+
t.add_row("Gateway URL", gateway_url)
|
|
702
|
+
t.add_row("Username", username)
|
|
703
|
+
t.add_row(
|
|
704
|
+
"Password",
|
|
705
|
+
"[dim]stored in system keyring[/dim]" if in_keyring
|
|
706
|
+
else "[bold yellow]WARNING[/bold yellow] [dim]keyring unavailable — "
|
|
707
|
+
"stored in plaintext in config.toml[/dim]",
|
|
708
|
+
)
|
|
709
|
+
t.add_row("Output mode", mode_label)
|
|
710
|
+
|
|
711
|
+
suffix = " Continuing with your command…" if continuing else ""
|
|
712
|
+
_err.print()
|
|
713
|
+
_err.print(Panel(
|
|
714
|
+
t,
|
|
715
|
+
title="[bold green] Profile saved [/bold green]",
|
|
716
|
+
subtitle=f"[dim]{suffix}[/dim]" if suffix else None,
|
|
717
|
+
border_style="green",
|
|
718
|
+
expand=False,
|
|
719
|
+
))
|
|
720
|
+
_err.print()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@app.command("configure", rich_help_panel=_PANEL_CFG)
|
|
724
|
+
def configure(
|
|
725
|
+
profile: str = typer.Option(
|
|
726
|
+
"default", "--profile", "-p", help="Profile name to create/update"
|
|
727
|
+
),
|
|
728
|
+
username: str | None = typer.Option(None, "--username", "-u"),
|
|
729
|
+
password: str | None = typer.Option(
|
|
730
|
+
None, "--password",
|
|
731
|
+
help="Stored in system keyring. WARNING: passing a secret as a CLI "
|
|
732
|
+
"flag can leak via shell history or `ps`; prefer the "
|
|
733
|
+
"interactive prompt or the QUALYS_PASSWORD env var.",
|
|
734
|
+
),
|
|
735
|
+
output_mode: str | None = typer.Option(
|
|
736
|
+
None, "--output-mode",
|
|
737
|
+
help="interactive (rich tables) or agentic (JSON, no decoration)",
|
|
738
|
+
),
|
|
739
|
+
) -> None:
|
|
740
|
+
"""Create or update a named credential profile.
|
|
741
|
+
|
|
742
|
+
Platform URLs are auto-detected from your username
|
|
743
|
+
(see https://www.qualys.com/platform-identification).
|
|
744
|
+
Override with [dim]QUALYS_URL[/dim] / [dim]QUALYS_GATEWAY_URL[/dim] env vars if needed.
|
|
745
|
+
"""
|
|
746
|
+
if not username:
|
|
747
|
+
username = typer.prompt("Username")
|
|
748
|
+
if password and _err.is_terminal:
|
|
749
|
+
_err.print(
|
|
750
|
+
" [yellow]⚠[/yellow] [dim]--password was passed on the command line — it may be "
|
|
751
|
+
"visible in shell history or `ps`. Prefer the interactive prompt or "
|
|
752
|
+
"QUALYS_PASSWORD.[/dim]"
|
|
753
|
+
)
|
|
754
|
+
if not password:
|
|
755
|
+
password = typer.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
756
|
+
if not output_mode:
|
|
757
|
+
raw = typer.prompt(
|
|
758
|
+
"Output mode",
|
|
759
|
+
default="interactive",
|
|
760
|
+
prompt_suffix=" [interactive/agentic]: ",
|
|
761
|
+
show_default=False,
|
|
762
|
+
).strip().lower()
|
|
763
|
+
output_mode = raw if raw in ("interactive", "agentic") else "interactive"
|
|
764
|
+
|
|
765
|
+
_save_and_show(profile, username, password, output_mode=output_mode)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@app.command("mode", rich_help_panel=_PANEL_CFG)
|
|
769
|
+
def mode_cmd(
|
|
770
|
+
ctx: typer.Context,
|
|
771
|
+
new_mode: str | None = typer.Argument(
|
|
772
|
+
None, help="Set mode: [bold]interactive[/bold] or [bold]agentic[/bold]"
|
|
773
|
+
),
|
|
774
|
+
profile: str = typer.Option(
|
|
775
|
+
"default", "--profile", "-p", help="Profile to update"
|
|
776
|
+
),
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Show or change the output mode for a profile.
|
|
779
|
+
|
|
780
|
+
[bold]interactive[/bold] Rich tables, colours, spinners — for human use.\n
|
|
781
|
+
[bold]agentic[/bold] Plain JSON, no decoration — for scripts and AI agents.
|
|
782
|
+
|
|
783
|
+
Examples:\n
|
|
784
|
+
qualys-cli mode\n
|
|
785
|
+
qualys-cli mode agentic\n
|
|
786
|
+
qualys-cli mode interactive
|
|
787
|
+
"""
|
|
788
|
+
_VALID = ("interactive", "agentic")
|
|
789
|
+
|
|
790
|
+
if new_mode is None:
|
|
791
|
+
# Show current mode
|
|
792
|
+
try:
|
|
793
|
+
p = _cfg.get_profile(profile)
|
|
794
|
+
current = p.output_mode
|
|
795
|
+
except Exception:
|
|
796
|
+
current = "interactive"
|
|
797
|
+
_err.print()
|
|
798
|
+
_err.print(
|
|
799
|
+
f" Profile [bold]{profile}[/bold] is in "
|
|
800
|
+
f"[bold cyan]{current}[/bold cyan] mode."
|
|
801
|
+
)
|
|
802
|
+
other = "agentic" if current == "interactive" else "interactive"
|
|
803
|
+
_err.print(
|
|
804
|
+
f" Run [bold]qualys-cli mode {other}[/bold] to switch."
|
|
805
|
+
)
|
|
806
|
+
_err.print()
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
new_mode = new_mode.lower()
|
|
810
|
+
if new_mode not in _VALID:
|
|
811
|
+
_err.print(
|
|
812
|
+
f" [bold red]Invalid mode[/bold red] [bold]{new_mode!r}[/bold]. "
|
|
813
|
+
f"Choose: {' | '.join(_VALID)}"
|
|
814
|
+
)
|
|
815
|
+
raise typer.Exit(1)
|
|
816
|
+
|
|
817
|
+
_cfg.set_output_mode(profile, new_mode)
|
|
818
|
+
label = (
|
|
819
|
+
"[green]interactive[/green] (rich tables)"
|
|
820
|
+
if new_mode == "interactive"
|
|
821
|
+
else "[cyan]agentic[/cyan] (JSON, no decoration)"
|
|
822
|
+
)
|
|
823
|
+
_err.print()
|
|
824
|
+
_err.print(
|
|
825
|
+
f" [bold green]✓[/bold green] Profile [bold]{profile}[/bold] "
|
|
826
|
+
f"→ output mode set to {label}."
|
|
827
|
+
)
|
|
828
|
+
_err.print(
|
|
829
|
+
" [dim]Takes effect on the next command.[/dim]"
|
|
830
|
+
)
|
|
831
|
+
_err.print()
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
@app.command("profiles", rich_help_panel=_PANEL_CFG)
|
|
835
|
+
def profiles() -> None:
|
|
836
|
+
"""List configured credential profiles."""
|
|
837
|
+
from pathlib import Path
|
|
838
|
+
config_file = Path.home() / ".config" / "qualys-cli" / "config.toml"
|
|
839
|
+
if not config_file.exists():
|
|
840
|
+
_err.print(Panel(
|
|
841
|
+
"No profiles configured.\n\n"
|
|
842
|
+
"Run [bold cyan]qualys-cli configure[/bold cyan] to get started.",
|
|
843
|
+
title="[bold yellow] No profiles [/bold yellow]",
|
|
844
|
+
border_style="yellow",
|
|
845
|
+
expand=False,
|
|
846
|
+
))
|
|
847
|
+
raise typer.Exit(1)
|
|
848
|
+
|
|
849
|
+
try:
|
|
850
|
+
import tomllib
|
|
851
|
+
except ImportError:
|
|
852
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
853
|
+
|
|
854
|
+
data = tomllib.loads(config_file.read_text())
|
|
855
|
+
profiles_data = data.get("profiles", {})
|
|
856
|
+
if not profiles_data:
|
|
857
|
+
_err.print("[dim]No profiles configured.[/dim]")
|
|
858
|
+
raise typer.Exit(1)
|
|
859
|
+
|
|
860
|
+
from .platforms import detect as _detect_platform
|
|
861
|
+
|
|
862
|
+
t = Table(
|
|
863
|
+
box=box.SIMPLE_HEAD, header_style="bold", border_style="dim",
|
|
864
|
+
show_edge=False, padding=(0, 1),
|
|
865
|
+
)
|
|
866
|
+
t.add_column("Profile")
|
|
867
|
+
t.add_column("Platform")
|
|
868
|
+
t.add_column("Username")
|
|
869
|
+
t.add_column("API URL")
|
|
870
|
+
for name, prof in profiles_data.items():
|
|
871
|
+
uname = prof.get("username", "")
|
|
872
|
+
try:
|
|
873
|
+
plat = _detect_platform(uname)
|
|
874
|
+
platform_name = f"[cyan]{plat.name}[/cyan]"
|
|
875
|
+
api_url = prof.get("url") or plat.api_url
|
|
876
|
+
except ValueError:
|
|
877
|
+
platform_name = "[dim]unknown[/dim]"
|
|
878
|
+
api_url = prof.get("url", "[dim]—[/dim]")
|
|
879
|
+
t.add_row(f"[bold]{name}[/bold]", platform_name, uname or "[dim]—[/dim]", api_url)
|
|
880
|
+
|
|
881
|
+
_err.print()
|
|
882
|
+
_err.print(t)
|
|
883
|
+
_err.print()
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
# ---------------------------------------------------------------------------
|
|
887
|
+
# Entry point with global exception handling
|
|
888
|
+
# ---------------------------------------------------------------------------
|
|
889
|
+
|
|
890
|
+
# Map APIError subclasses to (panel title, machine "type" tag) used by _show_error.
|
|
891
|
+
_ERROR_TITLES: dict[str, tuple[str, str]] = {
|
|
892
|
+
"AuthError": ("Authentication Failed", "auth_error"),
|
|
893
|
+
"NotFoundError": ("Not Found", "not_found"),
|
|
894
|
+
"RateLimitError": ("Rate Limited", "rate_limit"),
|
|
895
|
+
"ServerError": ("Server Error", "server_error"),
|
|
896
|
+
"TimeoutError": ("Timeout", "timeout"),
|
|
897
|
+
"NetworkError": ("Network Error", "network_error"),
|
|
898
|
+
"APIError": ("API Error", "api_error"),
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def _show_error(exc: Exception) -> int:
|
|
903
|
+
"""Render *exc* and return the exit code the process should use."""
|
|
904
|
+
import json as _json
|
|
905
|
+
import sys as _sys
|
|
906
|
+
|
|
907
|
+
from .client import APIError
|
|
908
|
+
|
|
909
|
+
if isinstance(exc, APIError):
|
|
910
|
+
kind = type(exc).__name__
|
|
911
|
+
title, type_tag = _ERROR_TITLES.get(kind, _ERROR_TITLES["APIError"])
|
|
912
|
+
exit_code = exc.exit_code
|
|
913
|
+
# Record the failure in the audit log so reviewers can see *why* a
|
|
914
|
+
# command exited non-zero, not just that it did.
|
|
915
|
+
_audit.log_error(kind, exc.message, status=exc.status or None)
|
|
916
|
+
|
|
917
|
+
if not _err.is_terminal:
|
|
918
|
+
payload = {
|
|
919
|
+
"error": exc.message,
|
|
920
|
+
"status": exc.status,
|
|
921
|
+
"type": type_tag,
|
|
922
|
+
"correlation": exc.correlation or _audit.CORRELATION_ID,
|
|
923
|
+
}
|
|
924
|
+
if exc.request_id:
|
|
925
|
+
payload["request_id"] = exc.request_id
|
|
926
|
+
print(_json.dumps(payload), file=_sys.stderr)
|
|
927
|
+
return exit_code
|
|
928
|
+
|
|
929
|
+
# Build a friendly panel: the API message, then a hint, then the
|
|
930
|
+
# correlation/request IDs so users can find this run in the audit log.
|
|
931
|
+
body = f"[white]{exc.message}[/white]"
|
|
932
|
+
if exc.hint:
|
|
933
|
+
body += f"\n\n[dim]{exc.hint}[/dim]"
|
|
934
|
+
ids: list[str] = []
|
|
935
|
+
if exc.correlation or _audit.CORRELATION_ID:
|
|
936
|
+
ids.append(f"correlation [bold]{exc.correlation or _audit.CORRELATION_ID}[/bold]")
|
|
937
|
+
if exc.request_id:
|
|
938
|
+
ids.append(f"request [bold]{exc.request_id}[/bold]")
|
|
939
|
+
if ids:
|
|
940
|
+
body += f"\n[dim]({' · '.join(ids)})[/dim]"
|
|
941
|
+
status_label = f"HTTP {exc.status}" if exc.status else "Network"
|
|
942
|
+
_err.print(Panel(
|
|
943
|
+
body,
|
|
944
|
+
title=f"[bold red] {status_label} — {title} [/bold red]",
|
|
945
|
+
border_style="red",
|
|
946
|
+
padding=(0, 1),
|
|
947
|
+
expand=False,
|
|
948
|
+
))
|
|
949
|
+
return exit_code
|
|
950
|
+
|
|
951
|
+
if isinstance(exc, ValueError):
|
|
952
|
+
_audit.log_error("ConfigurationError", str(exc))
|
|
953
|
+
if not _err.is_terminal:
|
|
954
|
+
print(_json.dumps({
|
|
955
|
+
"error": str(exc),
|
|
956
|
+
"type": "configuration_error",
|
|
957
|
+
"correlation": _audit.CORRELATION_ID,
|
|
958
|
+
}), file=_sys.stderr)
|
|
959
|
+
return 2
|
|
960
|
+
_err.print(Panel(
|
|
961
|
+
f"[white]{exc}[/white]\n\n"
|
|
962
|
+
"[dim]Run [cyan]qualys-cli configure[/cyan] to set up credentials, or set "
|
|
963
|
+
"the QUALYS_USERNAME / QUALYS_PASSWORD env vars.[/dim]",
|
|
964
|
+
title="[bold red] Configuration Error [/bold red]",
|
|
965
|
+
border_style="red",
|
|
966
|
+
padding=(0, 1),
|
|
967
|
+
expand=False,
|
|
968
|
+
))
|
|
969
|
+
return 2
|
|
970
|
+
|
|
971
|
+
# Unknown / unexpected error — still log it so it shows up in audit.
|
|
972
|
+
_audit.log_error(type(exc).__name__, str(exc))
|
|
973
|
+
if not _err.is_terminal:
|
|
974
|
+
print(_json.dumps({
|
|
975
|
+
"error": str(exc),
|
|
976
|
+
"type": "error",
|
|
977
|
+
"correlation": _audit.CORRELATION_ID,
|
|
978
|
+
}), file=_sys.stderr)
|
|
979
|
+
return 1
|
|
980
|
+
_err.print(Panel(
|
|
981
|
+
f"[white]{exc}[/white]\n\n"
|
|
982
|
+
f"[dim]correlation [bold]{_audit.CORRELATION_ID}[/bold] — share this ID "
|
|
983
|
+
"if you file a bug report.[/dim]",
|
|
984
|
+
title="[bold red] Error [/bold red]",
|
|
985
|
+
border_style="red",
|
|
986
|
+
padding=(0, 1),
|
|
987
|
+
expand=False,
|
|
988
|
+
))
|
|
989
|
+
return 1
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _build_post_options() -> list[Any]:
|
|
993
|
+
"""Construct the cross-cutting post-processing options
|
|
994
|
+
(``--where`` / ``--columns`` / ``--sort`` / ``--sort-by`` / ``--sort-desc``
|
|
995
|
+
/ ``--jq``) used by the formatter.
|
|
996
|
+
|
|
997
|
+
Each option is registered with ``expose_value=False`` so it never reaches
|
|
998
|
+
the underlying Typer command function — instead a callback stashes the
|
|
999
|
+
parsed value into a ContextVar that the output formatter reads at render
|
|
1000
|
+
time. This avoids threading these flags through ~200 command signatures.
|
|
1001
|
+
"""
|
|
1002
|
+
import click as _click
|
|
1003
|
+
|
|
1004
|
+
from . import formatters as _fmt
|
|
1005
|
+
|
|
1006
|
+
def _cb(name: str) -> Any:
|
|
1007
|
+
def _set(_ctx: _click.Context, _param: _click.Parameter, value: Any) -> Any:
|
|
1008
|
+
if value is not None:
|
|
1009
|
+
_fmt.set_post_process_opt(name, value)
|
|
1010
|
+
return value
|
|
1011
|
+
return _set
|
|
1012
|
+
|
|
1013
|
+
return [
|
|
1014
|
+
_click.Option(
|
|
1015
|
+
["--where"], type=str, default=None, expose_value=False,
|
|
1016
|
+
callback=_cb("where"),
|
|
1017
|
+
help="Filter rows (ops: =, !=, ~, >=, >, <=, <). See docs/usage.html.",
|
|
1018
|
+
),
|
|
1019
|
+
_click.Option(
|
|
1020
|
+
["--columns"], type=str, default=None, expose_value=False,
|
|
1021
|
+
callback=_cb("columns"),
|
|
1022
|
+
help="Override default table columns (comma-separated)",
|
|
1023
|
+
),
|
|
1024
|
+
_click.Option(
|
|
1025
|
+
["--sort"], type=str, default=None, expose_value=False,
|
|
1026
|
+
callback=_cb("sort"),
|
|
1027
|
+
help="Sort rows by field[:asc|:desc]",
|
|
1028
|
+
),
|
|
1029
|
+
_click.Option(
|
|
1030
|
+
["--sort-by"], type=str, default=None, expose_value=False,
|
|
1031
|
+
callback=_cb("sort_by"),
|
|
1032
|
+
help="Sort rows by FIELD (combine with --sort-desc for descending)",
|
|
1033
|
+
),
|
|
1034
|
+
_click.Option(
|
|
1035
|
+
["--sort-desc"], is_flag=True, default=False, expose_value=False,
|
|
1036
|
+
callback=_cb("sort_desc"),
|
|
1037
|
+
help="Reverse sort order when using --sort-by",
|
|
1038
|
+
),
|
|
1039
|
+
_click.Option(
|
|
1040
|
+
["--jq"], type=str, default=None, expose_value=False,
|
|
1041
|
+
callback=_cb("jq"),
|
|
1042
|
+
help="jq-lite query against the parsed payload, e.g. '.foo[].id'",
|
|
1043
|
+
),
|
|
1044
|
+
]
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _inject_post_options_into(root_cmd: Any) -> Any:
|
|
1048
|
+
"""Walk a Click command tree and append the post-processing options to
|
|
1049
|
+
every leaf (idempotent — skips options already present).
|
|
1050
|
+
|
|
1051
|
+
Collision is checked against the user-facing flag strings (``--sort``,
|
|
1052
|
+
``--where``, …) rather than the click ``.name`` attribute, because Typer
|
|
1053
|
+
derives the click name from the Python parameter name, not from the
|
|
1054
|
+
``--flag`` declaration. A command that declared ``sort: str = typer.Option(
|
|
1055
|
+
None, "--server-sort")`` would otherwise still shadow our global
|
|
1056
|
+
``--sort`` despite the rename.
|
|
1057
|
+
"""
|
|
1058
|
+
import click as _click
|
|
1059
|
+
|
|
1060
|
+
post_opts = _build_post_options()
|
|
1061
|
+
|
|
1062
|
+
def _walk(cmd: _click.Command) -> None:
|
|
1063
|
+
if isinstance(cmd, _click.Group):
|
|
1064
|
+
for sub in cmd.commands.values():
|
|
1065
|
+
_walk(sub)
|
|
1066
|
+
return
|
|
1067
|
+
existing_flags: set[str] = set()
|
|
1068
|
+
for p in cmd.params:
|
|
1069
|
+
existing_flags.update(getattr(p, "opts", ()) or ())
|
|
1070
|
+
for opt in post_opts:
|
|
1071
|
+
if not any(decl in existing_flags for decl in opt.opts):
|
|
1072
|
+
cmd.params.append(opt)
|
|
1073
|
+
|
|
1074
|
+
_walk(root_cmd)
|
|
1075
|
+
return root_cmd
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# Patch ``typer.main.get_command`` so that any caller — the qualys-cli binary
|
|
1079
|
+
# via main(), Typer's CliRunner used by the test suite, or anything that imports
|
|
1080
|
+
# ``app`` and invokes it — sees the injected options. Without this, options
|
|
1081
|
+
# only attach when main() is the entry point and tests / MCP / embedded use
|
|
1082
|
+
# would lose them.
|
|
1083
|
+
#
|
|
1084
|
+
# Scope / lifetime caveats:
|
|
1085
|
+
# - The patch is process-lifetime; there is no unpatch hook. We guard with
|
|
1086
|
+
# ``typer_instance is app`` so unrelated Typer apps in the same process
|
|
1087
|
+
# are not mutated.
|
|
1088
|
+
# - If qualys-cli is ever imported as a library alongside other Typer apps,
|
|
1089
|
+
# the patched function is still on ``typer.main`` — those other apps just
|
|
1090
|
+
# fall through to the original behaviour via the identity check.
|
|
1091
|
+
# - typer.testing's late-binding alias is also patched below for the same
|
|
1092
|
+
# reason CliRunner.invoke would otherwise miss the injection.
|
|
1093
|
+
_original_get_command = typer.main.get_command
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _patched_get_command(typer_instance: typer.Typer) -> Any:
|
|
1097
|
+
cmd = _original_get_command(typer_instance)
|
|
1098
|
+
if typer_instance is app:
|
|
1099
|
+
_inject_post_options_into(cmd)
|
|
1100
|
+
return cmd
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
typer.main.get_command = _patched_get_command
|
|
1104
|
+
|
|
1105
|
+
# typer.testing rebinds get_command at *its* module-load time via
|
|
1106
|
+
# ``from typer.main import get_command as _get_command``, so if a test module
|
|
1107
|
+
# imported typer.testing before this module, its alias still points to the
|
|
1108
|
+
# original. Patch the alias directly when the testing module is loaded so
|
|
1109
|
+
# CliRunner.invoke(app, ...) also sees the injected options.
|
|
1110
|
+
try:
|
|
1111
|
+
import typer.testing as _typer_testing
|
|
1112
|
+
_typer_testing._get_command = _patched_get_command # type: ignore[attr-defined]
|
|
1113
|
+
except ImportError:
|
|
1114
|
+
pass
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def main() -> None:
|
|
1118
|
+
import contextlib as _ctx
|
|
1119
|
+
|
|
1120
|
+
# Top-level Ctrl-C handler: ensures the spinner / audit state is flushed
|
|
1121
|
+
# before the process exits even when interrupted between calls.
|
|
1122
|
+
def _sigint(_signum: int, _frame: object) -> None:
|
|
1123
|
+
raise KeyboardInterrupt()
|
|
1124
|
+
with _ctx.suppress(ValueError, OSError):
|
|
1125
|
+
_signal.signal(_signal.SIGINT, _sigint)
|
|
1126
|
+
|
|
1127
|
+
# Initialise audit early so even argument-parsing errors are captured.
|
|
1128
|
+
_audit.init()
|
|
1129
|
+
version = _qualys_cli_version()
|
|
1130
|
+
started = _audit.log_command_start(sys.argv, version)
|
|
1131
|
+
exit_code = 0
|
|
1132
|
+
# Clear any leftover formatter post-processing state. Today every CLI
|
|
1133
|
+
# invocation is a fresh process so this is paranoia; if a future caller
|
|
1134
|
+
# ever invokes main() twice in-process (e.g. embedded harness) the reset
|
|
1135
|
+
# guarantees option values can't bleed between runs.
|
|
1136
|
+
from . import formatters as _fmt
|
|
1137
|
+
_fmt.reset_post_process_opts()
|
|
1138
|
+
try:
|
|
1139
|
+
app(standalone_mode=False)
|
|
1140
|
+
except typer.Exit as e:
|
|
1141
|
+
exit_code = int(e.exit_code or 0)
|
|
1142
|
+
_audit.log_command_end(started, exit_code, version)
|
|
1143
|
+
raise SystemExit(exit_code)
|
|
1144
|
+
except typer.Abort:
|
|
1145
|
+
exit_code = 130
|
|
1146
|
+
_err.print("\n [yellow]Aborted.[/yellow]\n")
|
|
1147
|
+
_audit.log_command_end(started, exit_code, version)
|
|
1148
|
+
raise SystemExit(exit_code)
|
|
1149
|
+
except KeyboardInterrupt:
|
|
1150
|
+
exit_code = 130
|
|
1151
|
+
_err.print("\n [yellow]Interrupted.[/yellow]\n")
|
|
1152
|
+
_audit.log_command_end(started, exit_code, version)
|
|
1153
|
+
raise SystemExit(exit_code)
|
|
1154
|
+
except SystemExit as se:
|
|
1155
|
+
exit_code = int(se.code or 0) if se.code is not None else 0
|
|
1156
|
+
_audit.log_command_end(started, exit_code, version)
|
|
1157
|
+
raise
|
|
1158
|
+
except Exception as exc:
|
|
1159
|
+
# Special-case --dry-run: it's not an error; exit cleanly with 0.
|
|
1160
|
+
from .client import DryRunExit as _DryRunExit
|
|
1161
|
+
if isinstance(exc, _DryRunExit):
|
|
1162
|
+
_audit.log_command_end(started, 0, version)
|
|
1163
|
+
raise SystemExit(0)
|
|
1164
|
+
exit_code = _show_error(exc)
|
|
1165
|
+
_audit.log_command_end(started, exit_code, version)
|
|
1166
|
+
raise SystemExit(exit_code)
|
|
1167
|
+
else:
|
|
1168
|
+
_audit.log_command_end(started, exit_code, version)
|