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.
@@ -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 []