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
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# Hand-edited — do NOT regenerate from api-model.json (codegen will skip this file).
|
|
2
|
+
# Vulnerability Management API (Basic auth, XML responses)
|
|
3
|
+
# Originally generated from api-model.json; corrections:
|
|
4
|
+
# - kb list: --ids (was --qids → wrong API key); fixed published_after suffix;
|
|
5
|
+
# added id-min/id-max/cve/details/is-patchable/discovery-method/show-* flags
|
|
6
|
+
# - scan launch: --option-id OR --option-title required (was silently optional);
|
|
7
|
+
# added --asset-groups, --asset-group-ids, --priority, --ip-network-id
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from .. import client as _c
|
|
13
|
+
from ..formatters import DEFAULT_LIMIT, output, output_success
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(name="vm", help="Vulnerability Management APIs (Basic auth)")
|
|
16
|
+
|
|
17
|
+
# ── scan ─────────────────────────────────────────────────────────────────────
|
|
18
|
+
_scan = typer.Typer(help="VM scan operations")
|
|
19
|
+
app.add_typer(_scan, name="scan")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@_scan.command("list")
|
|
23
|
+
def scan_list(
|
|
24
|
+
ctx: typer.Context,
|
|
25
|
+
state: str | None = typer.Option(None, "--state", help="Running|Paused|Cancelled|Finished|Error|Queued|Loading"),
|
|
26
|
+
type_: str | None = typer.Option(None, "--type", help="On-Demand|Scheduled|API"),
|
|
27
|
+
target: str | None = typer.Option(None, "--target", help="Target IP/range"),
|
|
28
|
+
launched_after: str | None = typer.Option(None, "--launched-after", help="Datetime YYYY-MM-DD"),
|
|
29
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
30
|
+
all_: bool = typer.Option(False, "--all"),
|
|
31
|
+
output_file: str | None = typer.Option(None, "--output-file", "-o"),
|
|
32
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
33
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
34
|
+
):
|
|
35
|
+
"""List VM scans.
|
|
36
|
+
|
|
37
|
+
Source: https://docs.qualys.com/en/vm/api/scans/vm_scans/list_vm_scans.htm
|
|
38
|
+
"""
|
|
39
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
40
|
+
params: dict = {"action": "list"}
|
|
41
|
+
if state:
|
|
42
|
+
params["state"] = state
|
|
43
|
+
if type_:
|
|
44
|
+
params["type"] = type_
|
|
45
|
+
if target:
|
|
46
|
+
params["target"] = target
|
|
47
|
+
if launched_after:
|
|
48
|
+
params["launched_after_datetime"] = launched_after
|
|
49
|
+
if all_:
|
|
50
|
+
rows: list = []
|
|
51
|
+
for page in client.paginate_xml("/api/2.0/fo/scan/", params):
|
|
52
|
+
items = _unwrap_xml_list(page, "SCAN")
|
|
53
|
+
rows.extend(items)
|
|
54
|
+
output(rows, format=format, output_file=output_file)
|
|
55
|
+
else:
|
|
56
|
+
data = client.get_xml("/api/2.0/fo/scan/", params)
|
|
57
|
+
output(data, format=format, limit=limit, output_file=output_file)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@_scan.command("launch")
|
|
61
|
+
def scan_launch(
|
|
62
|
+
ctx: typer.Context,
|
|
63
|
+
title: str = typer.Option(..., "--title", help="Scan title"),
|
|
64
|
+
ip: str = typer.Option(..., "--ip", help="Target IP(s), ranges or network IDs"),
|
|
65
|
+
option_id: str | None = typer.Option(
|
|
66
|
+
None, "--option-id", help="Option profile ID (required if --option-title not given)"
|
|
67
|
+
),
|
|
68
|
+
option_title: str | None = typer.Option(
|
|
69
|
+
None, "--option-title", help="Option profile title (required if --option-id not given)"
|
|
70
|
+
),
|
|
71
|
+
scanner_name: str | None = typer.Option(
|
|
72
|
+
None, "--scanner", help="Scanner appliance name(s), comma-separated"
|
|
73
|
+
),
|
|
74
|
+
asset_groups: str | None = typer.Option(
|
|
75
|
+
None, "--asset-groups", help="Asset group IDs, comma-separated (alternative to --ip)"
|
|
76
|
+
),
|
|
77
|
+
asset_group_ids: str | None = typer.Option(
|
|
78
|
+
None, "--asset-group-ids", help="Asset group IDs by name, comma-separated"
|
|
79
|
+
),
|
|
80
|
+
priority: int | None = typer.Option(
|
|
81
|
+
None, "--priority", help="0 (no priority) – 9 (emergency)", min=0, max=9
|
|
82
|
+
),
|
|
83
|
+
ip_network_id: str | None = typer.Option(
|
|
84
|
+
None, "--ip-network-id", help="Network ID for the target IPs"
|
|
85
|
+
),
|
|
86
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
87
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
88
|
+
):
|
|
89
|
+
"""Launch a VM scan.
|
|
90
|
+
|
|
91
|
+
The Qualys API requires an option profile to be selected — either by ID
|
|
92
|
+
(--option-id) or by exact title (--option-title). Pass at least one.
|
|
93
|
+
|
|
94
|
+
Source: https://docs.qualys.com/en/vm/api/scans/vm_scans/launch_vm_scan.htm
|
|
95
|
+
"""
|
|
96
|
+
if not option_id and not option_title:
|
|
97
|
+
raise typer.BadParameter(
|
|
98
|
+
"Provide --option-id or --option-title (one is required by the API)."
|
|
99
|
+
)
|
|
100
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
101
|
+
params: dict = {"action": "launch", "scan_title": title, "ip": ip}
|
|
102
|
+
if option_id:
|
|
103
|
+
params["option_id"] = option_id
|
|
104
|
+
if option_title:
|
|
105
|
+
params["option_title"] = option_title
|
|
106
|
+
if scanner_name:
|
|
107
|
+
params["scanner_appliances"] = scanner_name
|
|
108
|
+
if asset_groups:
|
|
109
|
+
params["asset_groups"] = asset_groups
|
|
110
|
+
if asset_group_ids:
|
|
111
|
+
params["asset_group_ids"] = asset_group_ids
|
|
112
|
+
if priority is not None:
|
|
113
|
+
params["priority"] = str(priority)
|
|
114
|
+
if ip_network_id:
|
|
115
|
+
params["ip_network_id"] = ip_network_id
|
|
116
|
+
data = client.post_xml("/api/2.0/fo/scan/", data=params)
|
|
117
|
+
output(data, format=format)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@_scan.command("cancel")
|
|
121
|
+
def scan_cancel(
|
|
122
|
+
ctx: typer.Context,
|
|
123
|
+
scan_ref: str = typer.Argument(..., help="Scan reference (scan/1234567890.12345)"),
|
|
124
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
125
|
+
):
|
|
126
|
+
"""Cancel a running scan.
|
|
127
|
+
|
|
128
|
+
Source: https://docs.qualys.com/en/vm/api/scans/vm_scans/manage_vm_scans.htm
|
|
129
|
+
"""
|
|
130
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
131
|
+
data = client.post_xml("/api/2.0/fo/scan/", data={"action": "cancel", "scan_ref": scan_ref})
|
|
132
|
+
output(data)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@_scan.command("fetch")
|
|
136
|
+
def scan_fetch(
|
|
137
|
+
ctx: typer.Context,
|
|
138
|
+
scan_ref: str = typer.Argument(..., help="Scan reference"),
|
|
139
|
+
output_file: str = typer.Option("scan-results.xml", "--output-file", "-o"),
|
|
140
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
141
|
+
):
|
|
142
|
+
"""Fetch scan results (downloads XML to file).
|
|
143
|
+
|
|
144
|
+
Source: https://docs.qualys.com/en/vm/api/scans/vm_scans/vm_scan_summary.htm
|
|
145
|
+
"""
|
|
146
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
147
|
+
resp = client.request("POST", "/api/2.0/fo/scan/",
|
|
148
|
+
data={"action": "fetch", "scan_ref": scan_ref})
|
|
149
|
+
from pathlib import Path
|
|
150
|
+
Path(output_file).write_bytes(resp.content)
|
|
151
|
+
output_success(f"Scan results saved to {output_file} ({len(resp.content):,} bytes)")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── host ─────────────────────────────────────────────────────────────────────
|
|
155
|
+
_host = typer.Typer(help="Host / asset operations")
|
|
156
|
+
app.add_typer(_host, name="host")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@_host.command("list")
|
|
160
|
+
def host_list(
|
|
161
|
+
ctx: typer.Context,
|
|
162
|
+
ips: str | None = typer.Option(None, "--ips", help="Comma-separated IPs or ranges"),
|
|
163
|
+
ag_ids: str | None = typer.Option(None, "--ag-ids", help="Asset group IDs"),
|
|
164
|
+
os_pattern: str | None = typer.Option(None, "--os", help="OS regex pattern"),
|
|
165
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
166
|
+
all_: bool = typer.Option(False, "--all"),
|
|
167
|
+
output_file: str | None = typer.Option(None, "--output-file", "-o"),
|
|
168
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
169
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
170
|
+
):
|
|
171
|
+
"""List hosts in the asset inventory.
|
|
172
|
+
|
|
173
|
+
Source: https://docs.qualys.com/en/vm/api/assets/host_lists/host_list.htm
|
|
174
|
+
"""
|
|
175
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
176
|
+
params: dict = {"action": "list"}
|
|
177
|
+
if ips:
|
|
178
|
+
params["ips"] = ips
|
|
179
|
+
if ag_ids:
|
|
180
|
+
params["ag_ids"] = ag_ids
|
|
181
|
+
if os_pattern:
|
|
182
|
+
params["os_pattern"] = os_pattern
|
|
183
|
+
if all_:
|
|
184
|
+
rows: list = []
|
|
185
|
+
for page in client.paginate_xml("/api/2.0/fo/asset/host/", params):
|
|
186
|
+
rows.extend(_unwrap_xml_list(page, "HOST"))
|
|
187
|
+
output(rows, format=format, output_file=output_file)
|
|
188
|
+
else:
|
|
189
|
+
data = client.get_xml("/api/2.0/fo/asset/host/", params)
|
|
190
|
+
output(data, format=format, limit=limit, output_file=output_file)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@_host.command("detection")
|
|
194
|
+
def host_detection(
|
|
195
|
+
ctx: typer.Context,
|
|
196
|
+
ips: str | None = typer.Option(None, "--ips"),
|
|
197
|
+
qids: str | None = typer.Option(None, "--qids", help="Comma-separated QIDs"),
|
|
198
|
+
severities: str | None = typer.Option(None, "--severities", help="1-5 comma-separated"),
|
|
199
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
200
|
+
all_: bool = typer.Option(False, "--all"),
|
|
201
|
+
output_file: str | None = typer.Option(None, "--output-file", "-o"),
|
|
202
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
203
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
204
|
+
):
|
|
205
|
+
"""List host vulnerability detections.
|
|
206
|
+
|
|
207
|
+
Source: https://docs.qualys.com/en/vm/api/assets/host_lists/host_detection.htm
|
|
208
|
+
"""
|
|
209
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
210
|
+
params: dict = {"action": "list"}
|
|
211
|
+
if ips:
|
|
212
|
+
params["ips"] = ips
|
|
213
|
+
if qids:
|
|
214
|
+
params["qids"] = qids
|
|
215
|
+
if severities:
|
|
216
|
+
params["severities"] = severities
|
|
217
|
+
if all_:
|
|
218
|
+
rows: list = []
|
|
219
|
+
for page in client.paginate_xml("/api/2.0/fo/asset/host/vm/detection/", params):
|
|
220
|
+
rows.extend(_unwrap_xml_list(page, "HOST"))
|
|
221
|
+
output(rows, format=format, output_file=output_file)
|
|
222
|
+
else:
|
|
223
|
+
data = client.get_xml("/api/2.0/fo/asset/host/vm/detection/", params)
|
|
224
|
+
output(data, format=format, limit=limit, output_file=output_file)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── knowledgebase ─────────────────────────────────────────────────────────────
|
|
228
|
+
_kb = typer.Typer(help="KnowledgeBase / vulnerability operations")
|
|
229
|
+
app.add_typer(_kb, name="kb")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@_kb.command("list")
|
|
233
|
+
def kb_list(
|
|
234
|
+
ctx: typer.Context,
|
|
235
|
+
ids: str | None = typer.Option(None, "--ids", help="Comma-separated QID(s)"),
|
|
236
|
+
id_min: str | None = typer.Option(None, "--id-min", help="Minimum QID in range"),
|
|
237
|
+
id_max: str | None = typer.Option(None, "--id-max", help="Maximum QID in range"),
|
|
238
|
+
cve: str | None = typer.Option(None, "--cve", help="Comma-separated CVE IDs"),
|
|
239
|
+
published_after: str | None = typer.Option(None, "--published-after", help="YYYY-MM-DD[THH:MM:SSZ]"),
|
|
240
|
+
published_before: str | None = typer.Option(None, "--published-before", help="YYYY-MM-DD[THH:MM:SSZ]"),
|
|
241
|
+
last_modified_after: str | None = typer.Option(None, "--last-modified-after", help="YYYY-MM-DD[THH:MM:SSZ]"),
|
|
242
|
+
last_modified_before: str | None = typer.Option(None, "--last-modified-before", help="YYYY-MM-DD[THH:MM:SSZ]"),
|
|
243
|
+
details: str | None = typer.Option(None, "--details", help="Basic|All|None"),
|
|
244
|
+
is_patchable: bool | None = typer.Option(None, "--patchable/--not-patchable", help="Filter by whether a patch exists"),
|
|
245
|
+
discovery_method: str | None = typer.Option(None, "--discovery-method", help="Remote|Authenticated|RemoteOnly|AuthenticatedOnly|RemoteAndAuthenticated"),
|
|
246
|
+
show_pci_reasons: bool = typer.Option(False, "--show-pci-reasons", help="Include PCI pass/fail reasons"),
|
|
247
|
+
show_disabled_flag: bool = typer.Option(False, "--show-disabled-flag", help="Include the disabled flag for each QID"),
|
|
248
|
+
show_supported_modules_info: bool = typer.Option(False, "--show-supported-modules-info", help="Include supported-module info"),
|
|
249
|
+
show_qid_change_log: bool = typer.Option(False, "--show-qid-change-log", help="Include QID change-log entries"),
|
|
250
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
251
|
+
output_file: str | None = typer.Option(None, "--output-file", "-o"),
|
|
252
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
253
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
254
|
+
):
|
|
255
|
+
"""List vulnerabilities from the KnowledgeBase.
|
|
256
|
+
|
|
257
|
+
The full Qualys KnowledgeBase contains hundreds of thousands of QIDs and
|
|
258
|
+
streaming the entire catalog can exceed the request timeout or the in-memory
|
|
259
|
+
body cap. Pass at least one scoping flag — --ids, --id-min/--id-max,
|
|
260
|
+
--cve, --published-after, --last-modified-after, etc. — to keep the
|
|
261
|
+
response manageable. To pull the full KB, use --output-file with
|
|
262
|
+
QUALYS_BODY_LIMIT_BYTES=0 and --last-modified-after spanning the desired
|
|
263
|
+
window.
|
|
264
|
+
|
|
265
|
+
Source: https://docs.qualys.com/en/vm/api/scans/kbase/knowledgebase.htm
|
|
266
|
+
"""
|
|
267
|
+
if not any(
|
|
268
|
+
[ids, id_min, id_max, cve, published_after, published_before,
|
|
269
|
+
last_modified_after, last_modified_before]
|
|
270
|
+
):
|
|
271
|
+
raise typer.BadParameter(
|
|
272
|
+
"vm kb list requires a scoping filter: pass --ids, --id-min/--id-max, "
|
|
273
|
+
"--cve, --published-after, or --last-modified-after. The full KB "
|
|
274
|
+
"exceeds the default response size cap."
|
|
275
|
+
)
|
|
276
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
277
|
+
params: dict = {"action": "list"}
|
|
278
|
+
if ids:
|
|
279
|
+
params["ids"] = ids
|
|
280
|
+
if id_min:
|
|
281
|
+
params["id_min"] = id_min
|
|
282
|
+
if id_max:
|
|
283
|
+
params["id_max"] = id_max
|
|
284
|
+
if cve:
|
|
285
|
+
params["cve"] = cve
|
|
286
|
+
if published_after:
|
|
287
|
+
params["published_after"] = published_after
|
|
288
|
+
if published_before:
|
|
289
|
+
params["published_before"] = published_before
|
|
290
|
+
if last_modified_after:
|
|
291
|
+
params["last_modified_after"] = last_modified_after
|
|
292
|
+
if last_modified_before:
|
|
293
|
+
params["last_modified_before"] = last_modified_before
|
|
294
|
+
if details:
|
|
295
|
+
params["details"] = details
|
|
296
|
+
if is_patchable is not None:
|
|
297
|
+
params["is_patchable"] = "1" if is_patchable else "0"
|
|
298
|
+
if discovery_method:
|
|
299
|
+
params["discovery_method"] = discovery_method
|
|
300
|
+
if show_pci_reasons:
|
|
301
|
+
params["show_pci_reasons"] = "1"
|
|
302
|
+
if show_disabled_flag:
|
|
303
|
+
params["show_disabled_flag"] = "1"
|
|
304
|
+
if show_supported_modules_info:
|
|
305
|
+
params["show_supported_modules_info"] = "1"
|
|
306
|
+
if show_qid_change_log:
|
|
307
|
+
params["show_qid_change_log"] = "1"
|
|
308
|
+
data = client.get_xml("/api/2.0/fo/knowledge_base/vuln/", params)
|
|
309
|
+
output(data, format=format, limit=limit, output_file=output_file)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ── option-profile ────────────────────────────────────────────────────────────
|
|
313
|
+
_op = typer.Typer(help="VM option profile operations")
|
|
314
|
+
app.add_typer(_op, name="option-profile")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@_op.command("list")
|
|
318
|
+
def op_list(
|
|
319
|
+
ctx: typer.Context,
|
|
320
|
+
ids: str | None = typer.Option(None, "--ids"),
|
|
321
|
+
title_contains: str | None = typer.Option(None, "--title-contains"),
|
|
322
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
323
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
324
|
+
output_file: str | None = typer.Option(None, "--output-file", "-o"),
|
|
325
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
326
|
+
):
|
|
327
|
+
"""List VM option profiles.
|
|
328
|
+
|
|
329
|
+
Source: https://docs.qualys.com/en/vm/api/scans/ops/ops_vm/VM_Option_Profile.htm
|
|
330
|
+
"""
|
|
331
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
332
|
+
params: dict = {"action": "list"}
|
|
333
|
+
if ids:
|
|
334
|
+
params["ids"] = ids
|
|
335
|
+
if title_contains:
|
|
336
|
+
params["title"] = title_contains
|
|
337
|
+
data = client.get_xml("/api/2.0/fo/subscription/option_profile/vm/", params)
|
|
338
|
+
output(data, format=format, limit=limit, output_file=output_file)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@_op.command("create")
|
|
342
|
+
def op_create(
|
|
343
|
+
ctx: typer.Context,
|
|
344
|
+
title: str = typer.Option(..., "--title"),
|
|
345
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
346
|
+
):
|
|
347
|
+
"""Create a VM option profile.
|
|
348
|
+
|
|
349
|
+
Source: https://docs.qualys.com/en/vm/api/scans/ops/ops_vm/VM_Option_Profile.htm
|
|
350
|
+
"""
|
|
351
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
352
|
+
data = client.post_xml("/api/2.0/fo/subscription/option_profile/vm/",
|
|
353
|
+
data={"action": "create", "title": title})
|
|
354
|
+
output(data)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@_op.command("delete")
|
|
358
|
+
def op_delete(
|
|
359
|
+
ctx: typer.Context,
|
|
360
|
+
ids: str = typer.Argument(..., help="Option profile ID(s)"),
|
|
361
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
362
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
363
|
+
):
|
|
364
|
+
"""Delete VM option profile(s).
|
|
365
|
+
|
|
366
|
+
Source: https://docs.qualys.com/en/vm/api/scans/ops/ops_vm/VM_Option_Profile.htm
|
|
367
|
+
"""
|
|
368
|
+
if not yes:
|
|
369
|
+
typer.confirm(f"Delete option profile(s) {ids}?", abort=True)
|
|
370
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
371
|
+
data = client.post_xml("/api/2.0/fo/subscription/option_profile/vm/",
|
|
372
|
+
data={"action": "delete", "ids": ids})
|
|
373
|
+
output(data)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ── report ────────────────────────────────────────────────────────────────────
|
|
377
|
+
_report = typer.Typer(help="VM/PC report operations")
|
|
378
|
+
app.add_typer(_report, name="report")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@_report.command("list")
|
|
382
|
+
def report_list(
|
|
383
|
+
ctx: typer.Context,
|
|
384
|
+
id_: str | None = typer.Option(None, "--id"),
|
|
385
|
+
state: str | None = typer.Option(None, "--state", help="Running|Finished|Canceled"),
|
|
386
|
+
type_: str | None = typer.Option(None, "--type"),
|
|
387
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
388
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
389
|
+
output_file: str | None = typer.Option(None, "--output-file", "-o"),
|
|
390
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
391
|
+
):
|
|
392
|
+
"""List VM/PC reports.
|
|
393
|
+
|
|
394
|
+
Source: https://docs.qualys.com/en/vm/api/reports/reports/list_reports.htm
|
|
395
|
+
"""
|
|
396
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
397
|
+
params: dict = {"action": "list"}
|
|
398
|
+
if id_:
|
|
399
|
+
params["id"] = id_
|
|
400
|
+
if state:
|
|
401
|
+
params["state"] = state
|
|
402
|
+
if type_:
|
|
403
|
+
params["type"] = type_
|
|
404
|
+
data = client.get_xml("/api/2.0/fo/report/", params)
|
|
405
|
+
output(data, format=format, limit=limit, output_file=output_file)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@_report.command("launch")
|
|
409
|
+
def report_launch(
|
|
410
|
+
ctx: typer.Context,
|
|
411
|
+
template_id: str = typer.Option(..., "--template-id"),
|
|
412
|
+
report_title: str = typer.Option(..., "--title"),
|
|
413
|
+
output_format: str = typer.Option("PDF", "--output-format", help="PDF|HTML|XML|CSV|DOCX"),
|
|
414
|
+
ips: str | None = typer.Option(None, "--ips"),
|
|
415
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
416
|
+
):
|
|
417
|
+
"""Launch a VM/PC report.
|
|
418
|
+
|
|
419
|
+
Source: https://docs.qualys.com/en/vm/api/reports/reports/launch_report.htm
|
|
420
|
+
"""
|
|
421
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
422
|
+
params: dict = {
|
|
423
|
+
"action": "launch",
|
|
424
|
+
"template_id": template_id,
|
|
425
|
+
"report_title": report_title,
|
|
426
|
+
"output_format": output_format,
|
|
427
|
+
}
|
|
428
|
+
if ips:
|
|
429
|
+
params["ips"] = ips
|
|
430
|
+
data = client.post_xml("/api/2.0/fo/report/", data=params)
|
|
431
|
+
output(data)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@_report.command("fetch")
|
|
435
|
+
def report_fetch(
|
|
436
|
+
ctx: typer.Context,
|
|
437
|
+
report_id: str = typer.Argument(...),
|
|
438
|
+
output_file: str = typer.Option("report.pdf", "--output-file", "-o"),
|
|
439
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
440
|
+
):
|
|
441
|
+
"""Fetch (download) a completed report.
|
|
442
|
+
|
|
443
|
+
Source: https://docs.qualys.com/en/vm/api/reports/reports/fetch_report.htm
|
|
444
|
+
"""
|
|
445
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
446
|
+
resp = client.request("GET", "/api/2.0/fo/report/",
|
|
447
|
+
params={"action": "fetch", "id": report_id})
|
|
448
|
+
from pathlib import Path
|
|
449
|
+
Path(output_file).write_bytes(resp.content)
|
|
450
|
+
output_success(f"Report saved to {output_file}")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@_report.command("delete")
|
|
454
|
+
def report_delete(
|
|
455
|
+
ctx: typer.Context,
|
|
456
|
+
report_id: str = typer.Argument(...),
|
|
457
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
458
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
459
|
+
):
|
|
460
|
+
"""Delete a report.
|
|
461
|
+
|
|
462
|
+
Source: https://docs.qualys.com/en/vm/api/reports/reports/delete_report.htm
|
|
463
|
+
"""
|
|
464
|
+
if not yes:
|
|
465
|
+
typer.confirm(f"Delete report {report_id}?", abort=True)
|
|
466
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
467
|
+
data = client.post_xml("/api/2.0/fo/report/",
|
|
468
|
+
data={"action": "delete", "id": report_id})
|
|
469
|
+
output(data)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ── scan-schedule ─────────────────────────────────────────────────────────────
|
|
473
|
+
_schedule = typer.Typer(help="VM scan schedule operations")
|
|
474
|
+
app.add_typer(_schedule, name="scan-schedule")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@_schedule.command("list")
|
|
478
|
+
def schedule_list(
|
|
479
|
+
ctx: typer.Context,
|
|
480
|
+
ids: str | None = typer.Option(None, "--ids"),
|
|
481
|
+
active: bool | None = typer.Option(None, "--active/--inactive"),
|
|
482
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
483
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
484
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
485
|
+
):
|
|
486
|
+
"""List VM scan schedules.
|
|
487
|
+
|
|
488
|
+
Source: https://docs.qualys.com/en/vm/api/scans/vm_schedules/list_scan_schedules.htm
|
|
489
|
+
"""
|
|
490
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
491
|
+
params: dict = {"action": "list"}
|
|
492
|
+
if ids:
|
|
493
|
+
params["ids"] = ids
|
|
494
|
+
if active is not None:
|
|
495
|
+
params["active"] = "1" if active else "0"
|
|
496
|
+
data = client.get_xml("/api/2.0/fo/schedule/scan/", params)
|
|
497
|
+
output(data, format=format, limit=limit)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@_schedule.command("delete")
|
|
501
|
+
def schedule_delete(
|
|
502
|
+
ctx: typer.Context,
|
|
503
|
+
ids: str = typer.Argument(..., help="Schedule ID(s)"),
|
|
504
|
+
yes: bool = typer.Option(False, "--yes", "-y"),
|
|
505
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
506
|
+
):
|
|
507
|
+
"""Delete scan schedule(s).
|
|
508
|
+
|
|
509
|
+
Source: https://docs.qualys.com/en/vm/api/scans/vm_schedules/delete_scan_schedule.htm
|
|
510
|
+
"""
|
|
511
|
+
if not yes:
|
|
512
|
+
typer.confirm(f"Delete scan schedule(s) {ids}?", abort=True)
|
|
513
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
514
|
+
data = client.post_xml("/api/2.0/fo/schedule/scan/",
|
|
515
|
+
data={"action": "delete", "id": ids})
|
|
516
|
+
output(data)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ── appliance ─────────────────────────────────────────────────────────────────
|
|
520
|
+
_appliance = typer.Typer(help="Scanner appliance operations")
|
|
521
|
+
app.add_typer(_appliance, name="appliance")
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@_appliance.command("list")
|
|
525
|
+
def appliance_list(
|
|
526
|
+
ctx: typer.Context,
|
|
527
|
+
ids: str | None = typer.Option(None, "--ids"),
|
|
528
|
+
name: str | None = typer.Option(None, "--name"),
|
|
529
|
+
limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
|
|
530
|
+
format: str = typer.Option("table", "--format", "-f"),
|
|
531
|
+
verbose: int = typer.Option(0, "--verbose", "-v", count=True),
|
|
532
|
+
):
|
|
533
|
+
"""List scanner appliances.
|
|
534
|
+
|
|
535
|
+
Source: https://docs.qualys.com/en/vm/api/scans/appliances/list_appliances.htm
|
|
536
|
+
"""
|
|
537
|
+
client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
|
|
538
|
+
params: dict = {"action": "list"}
|
|
539
|
+
if ids:
|
|
540
|
+
params["ids"] = ids
|
|
541
|
+
if name:
|
|
542
|
+
params["name"] = name
|
|
543
|
+
data = client.get_xml("/api/2.0/fo/appliance/", params)
|
|
544
|
+
output(data, format=format, limit=limit)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
# Helpers
|
|
549
|
+
# ---------------------------------------------------------------------------
|
|
550
|
+
|
|
551
|
+
def _unwrap_xml_list(data: dict, item_key: str) -> list:
|
|
552
|
+
"""Extract a list of items from a nested xmltodict response."""
|
|
553
|
+
for v in data.values():
|
|
554
|
+
if isinstance(v, dict):
|
|
555
|
+
lst = v.get(f"{item_key}_LIST") or v.get("RESPONSE", {}).get(f"{item_key}_LIST")
|
|
556
|
+
if lst:
|
|
557
|
+
items = lst.get(item_key, [])
|
|
558
|
+
return items if isinstance(items, list) else [items]
|
|
559
|
+
deeper = _unwrap_xml_list(v, item_key)
|
|
560
|
+
if deeper:
|
|
561
|
+
return deeper
|
|
562
|
+
return []
|