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,1278 @@
1
+ # Auto-generated from api-model.json — run scripts/generate_commands.py to regenerate.
2
+ # Web Application Scanning APIs (Basic auth, XML responses)
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from .. import client as _c
8
+ from ..formatters import DEFAULT_LIMIT, output, output_success
9
+
10
+ app = typer.Typer(name="was", help="Web Application Scanning APIs — webapps, scans, findings, auth records, schedules, reports")
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Helpers
14
+ # ---------------------------------------------------------------------------
15
+
16
+ def _search_xml(filters: list[tuple[str, str, str]] | None = None, *, count_only: bool = False) -> str:
17
+ """Build a minimal WAS search ServiceRequest XML body."""
18
+ parts = ['<?xml version="1.0" encoding="UTF-8"?>', "<ServiceRequest>"]
19
+ if filters:
20
+ parts.append("<filters>")
21
+ for field, op, value in filters:
22
+ parts.append(f'<Criteria field="{field}" operator="{op}">{value}</Criteria>')
23
+ parts.append("</filters>")
24
+ parts.append("</ServiceRequest>")
25
+ return "\n".join(parts)
26
+
27
+
28
+ def _wrap(tag: str, body: str) -> str:
29
+ return f"<ServiceRequest><data><{tag}>{body}</{tag}></data></ServiceRequest>"
30
+
31
+
32
+ # ── webapp ────────────────────────────────────────────────────────────────────
33
+ _webapp = typer.Typer(help="Web application operations")
34
+ app.add_typer(_webapp, name="webapp")
35
+
36
+
37
+ @_webapp.command("list")
38
+ def webapp_list(
39
+ ctx: typer.Context,
40
+ name: str | None = typer.Option(None, "--name", help="Filter by name (CONTAINS)"),
41
+ url: str | None = typer.Option(None, "--url", help="Filter by URL (CONTAINS)"),
42
+ tag_id: str | None = typer.Option(None, "--tag-id", help="Filter by tag ID"),
43
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
44
+ format: str = typer.Option("table", "--format", "-f"),
45
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
46
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
47
+ ):
48
+ """Search/list web applications.
49
+
50
+ Source: https://docs.qualys.com/en/was/api/web_apps/get_webapp_details.htm
51
+ """
52
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
53
+ filters: list[tuple[str, str, str]] = []
54
+ if name:
55
+ filters.append(("name", "CONTAINS", name))
56
+ if url:
57
+ filters.append(("url", "CONTAINS", url))
58
+ if tag_id:
59
+ filters.append(("tags.id", "EQUALS", tag_id))
60
+ data = client.post_xml("/qps/rest/3.0/search/was/webapp", data=_search_xml(filters or None))
61
+ output(data, format=format, limit=limit, output_file=output_file)
62
+
63
+
64
+ @_webapp.command("get")
65
+ def webapp_get(
66
+ ctx: typer.Context,
67
+ webapp_id: str = typer.Argument(..., help="Web application ID"),
68
+ format: str = typer.Option("table", "--format", "-f"),
69
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
70
+ ):
71
+ """Get details of a web application.
72
+
73
+ Source: https://docs.qualys.com/en/was/api/web_apps/get_webapp_details.htm
74
+ """
75
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
76
+ data = client.get_xml(f"/qps/rest/3.0/get/was/webapp/{webapp_id}", {})
77
+ output(data, format=format)
78
+
79
+
80
+ @_webapp.command("count")
81
+ def webapp_count(
82
+ ctx: typer.Context,
83
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
84
+ format: str = typer.Option("table", "--format", "-f"),
85
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
86
+ ):
87
+ """Get the count of web applications.
88
+
89
+ Source: https://docs.qualys.com/en/was/api/web_apps/webapp_count.htm
90
+ """
91
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
92
+ data = client.get_xml("/qps/rest/3.0/count/was/webapp", {})
93
+ output(data, format=format, output_file=output_file)
94
+
95
+
96
+ @_webapp.command("create")
97
+ def webapp_create(
98
+ ctx: typer.Context,
99
+ name: str = typer.Option(..., "--name", help="Web application name"),
100
+ url: str = typer.Option(..., "--url", help="Starting URL (e.g. https://myapp.com)"),
101
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
102
+ ):
103
+ """Create a web application.
104
+
105
+ Source: https://docs.qualys.com/en/was/api/web_apps/create_webapp.htm
106
+ """
107
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
108
+ xml_body = (
109
+ '<?xml version="1.0" encoding="UTF-8"?>'
110
+ "<ServiceRequest><data><WebApp>"
111
+ f"<name><![CDATA[{name}]]></name>"
112
+ f"<url><![CDATA[{url}]]></url>"
113
+ "</WebApp></data></ServiceRequest>"
114
+ )
115
+ data = client.post_xml("/qps/rest/3.0/create/was/webapp", data=xml_body)
116
+ output(data)
117
+
118
+
119
+ @_webapp.command("update")
120
+ def webapp_update(
121
+ ctx: typer.Context,
122
+ webapp_id: str = typer.Argument(..., help="Web application ID"),
123
+ name: str | None = typer.Option(None, "--name", help="New name"),
124
+ url: str | None = typer.Option(None, "--url", help="New URL"),
125
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
126
+ ):
127
+ """Update a web application.
128
+
129
+ Source: https://docs.qualys.com/en/was/api/web_apps/update_webapp.htm
130
+ """
131
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
132
+ fields = ""
133
+ if name:
134
+ fields += f"<name><![CDATA[{name}]]></name>"
135
+ if url:
136
+ fields += f"<url><![CDATA[{url}]]></url>"
137
+ xml_body = (
138
+ '<?xml version="1.0" encoding="UTF-8"?>'
139
+ f"<ServiceRequest><data><WebApp>{fields}</WebApp></data></ServiceRequest>"
140
+ )
141
+ data = client.post_xml(f"/qps/rest/3.0/update/was/webapp/{webapp_id}", data=xml_body)
142
+ output(data)
143
+
144
+
145
+ @_webapp.command("delete")
146
+ def webapp_delete(
147
+ ctx: typer.Context,
148
+ webapp_id: str = typer.Argument(..., help="Web application ID"),
149
+ yes: bool = typer.Option(False, "--yes", "-y"),
150
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
151
+ ):
152
+ """Delete a web application.
153
+
154
+ Source: https://docs.qualys.com/en/was/api/web_apps/delete_webapp.htm
155
+ """
156
+ if not yes:
157
+ typer.confirm(f"Delete web application {webapp_id}?", abort=True)
158
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
159
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/webapp/{webapp_id}", data=_search_xml())
160
+ output(data)
161
+
162
+
163
+ @_webapp.command("purge")
164
+ def webapp_purge(
165
+ ctx: typer.Context,
166
+ webapp_id: str = typer.Argument(..., help="Web application ID"),
167
+ yes: bool = typer.Option(False, "--yes", "-y"),
168
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
169
+ ):
170
+ """Purge all scan data for a web application.
171
+
172
+ Source: https://docs.qualys.com/en/was/api/web_apps/purge_webapp.htm
173
+ """
174
+ if not yes:
175
+ typer.confirm(f"Purge all scan data for web application {webapp_id}?", abort=True)
176
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
177
+ data = client.post_xml(f"/qps/rest/3.0/purge/was/webapp/{webapp_id}", data=_search_xml())
178
+ output(data)
179
+
180
+
181
+ # ── scan ──────────────────────────────────────────────────────────────────────
182
+ _scan = typer.Typer(help="WAS scan operations")
183
+ app.add_typer(_scan, name="scan")
184
+
185
+
186
+ @_scan.command("list")
187
+ def scan_list(
188
+ ctx: typer.Context,
189
+ name: str | None = typer.Option(None, "--name", help="Filter by scan name (CONTAINS)"),
190
+ webapp_id: str | None = typer.Option(None, "--webapp-id", help="Filter by web application ID"),
191
+ status: str | None = typer.Option(None, "--status", help="SUBMITTED|RUNNING|FINISHED|ERROR|CANCELLED"),
192
+ scan_type: str | None = typer.Option(None, "--type", help="VULNERABILITY|DISCOVERY"),
193
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
194
+ format: str = typer.Option("table", "--format", "-f"),
195
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
196
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
197
+ ):
198
+ """Search/list WAS scans.
199
+
200
+ Source: https://docs.qualys.com/en/was/api/scans/get_scan_details.htm
201
+ """
202
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
203
+ filters: list[tuple[str, str, str]] = []
204
+ if name:
205
+ filters.append(("name", "CONTAINS", name))
206
+ if webapp_id:
207
+ filters.append(("webApp.id", "EQUALS", webapp_id))
208
+ if status:
209
+ filters.append(("status", "EQUALS", status))
210
+ if scan_type:
211
+ filters.append(("type", "EQUALS", scan_type))
212
+ data = client.post_xml("/qps/rest/3.0/search/was/wasscan", data=_search_xml(filters or None))
213
+ output(data, format=format, limit=limit, output_file=output_file)
214
+
215
+
216
+ @_scan.command("get")
217
+ def scan_get(
218
+ ctx: typer.Context,
219
+ scan_id: str = typer.Argument(..., help="Scan ID"),
220
+ format: str = typer.Option("table", "--format", "-f"),
221
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
222
+ ):
223
+ """Get scan details.
224
+
225
+ Source: https://docs.qualys.com/en/was/api/scans/get_scan_details.htm
226
+ """
227
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
228
+ data = client.get_xml(f"/qps/rest/3.0/get/was/wasscan/{scan_id}", {})
229
+ output(data, format=format)
230
+
231
+
232
+ @_scan.command("count")
233
+ def scan_count(
234
+ ctx: typer.Context,
235
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
236
+ format: str = typer.Option("table", "--format", "-f"),
237
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
238
+ ):
239
+ """Get count of WAS scans.
240
+
241
+ Source: https://docs.qualys.com/en/was/api/scans/scan_count.htm
242
+ """
243
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
244
+ data = client.get_xml("/qps/rest/3.0/count/was/wasscan", {})
245
+ output(data, format=format, output_file=output_file)
246
+
247
+
248
+ @_scan.command("status")
249
+ def scan_status(
250
+ ctx: typer.Context,
251
+ scan_id: str = typer.Argument(..., help="Scan ID"),
252
+ format: str = typer.Option("table", "--format", "-f"),
253
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
254
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
255
+ ):
256
+ """Get current status of a scan.
257
+
258
+ Source: https://docs.qualys.com/en/was/api/scans/scan_status.htm
259
+ """
260
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
261
+ data = client.get_xml(f"/qps/rest/3.0/status/was/wasscan/{scan_id}", {})
262
+ output(data, format=format, output_file=output_file)
263
+
264
+
265
+ @_scan.command("launch")
266
+ def scan_launch(
267
+ ctx: typer.Context,
268
+ name: str = typer.Option(..., "--name", help="Scan name"),
269
+ webapp_id: str = typer.Option(..., "--webapp-id", help="Web application ID to scan"),
270
+ profile_id: str = typer.Option(..., "--profile-id", help="Option profile ID"),
271
+ scan_type: str = typer.Option("VULNERABILITY", "--type", help="VULNERABILITY|DISCOVERY"),
272
+ scanner_type: str = typer.Option("EXTERNAL", "--scanner-type", help="EXTERNAL|INTERNAL"),
273
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
274
+ ):
275
+ """Launch a WAS scan on a single web application.
276
+
277
+ Source: https://docs.qualys.com/en/was/api/scans/launch_scan_single.htm
278
+ """
279
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
280
+ xml_body = (
281
+ '<?xml version="1.0" encoding="UTF-8"?>'
282
+ "<ServiceRequest><data><WasScan>"
283
+ f"<name><![CDATA[{name}]]></name>"
284
+ f"<type>{scan_type}</type>"
285
+ f"<target><webApp><id>{webapp_id}</id></webApp>"
286
+ f"<scannerAppliance><type>{scanner_type}</type></scannerAppliance></target>"
287
+ f"<profile><id>{profile_id}</id></profile>"
288
+ "</WasScan></data></ServiceRequest>"
289
+ )
290
+ data = client.post_xml("/qps/rest/3.0/launch/was/wasscan", data=xml_body)
291
+ output(data)
292
+
293
+
294
+ @_scan.command("cancel")
295
+ def scan_cancel(
296
+ ctx: typer.Context,
297
+ scan_id: str = typer.Argument(..., help="Scan ID"),
298
+ keep_results: bool = typer.Option(False, "--keep-results", help="Retain partial results"),
299
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
300
+ ):
301
+ """Cancel an in-progress scan.
302
+
303
+ Source: https://docs.qualys.com/en/was/api/scans/cancel_scan.htm
304
+ """
305
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
306
+ xml_body = (
307
+ '<?xml version="1.0" encoding="UTF-8"?>'
308
+ "<ServiceRequest><data><WasScan>"
309
+ f"<cancelWithResults>{str(keep_results).lower()}</cancelWithResults>"
310
+ "</WasScan></data></ServiceRequest>"
311
+ )
312
+ data = client.post_xml(f"/qps/rest/3.0/cancel/was/wasscan/{scan_id}", data=xml_body)
313
+ output(data)
314
+
315
+
316
+ @_scan.command("results")
317
+ def scan_results(
318
+ ctx: typer.Context,
319
+ scan_id: str = typer.Argument(..., help="Scan ID"),
320
+ output_file: str = typer.Option("scan-results.xml", "--output-file", "-o"),
321
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
322
+ ):
323
+ """Download raw XML results for a completed scan.
324
+
325
+ Source: https://docs.qualys.com/en/was/api/scans/scan_results.htm
326
+ """
327
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
328
+ resp = client.request("GET", f"/qps/rest/3.0/download/was/wasscan/{scan_id}")
329
+ from pathlib import Path
330
+ Path(output_file).write_bytes(resp.content)
331
+ output_success(f"Scan results saved to {output_file} ({len(resp.content):,} bytes)")
332
+
333
+
334
+ @_scan.command("delete")
335
+ def scan_delete(
336
+ ctx: typer.Context,
337
+ scan_id: str = typer.Argument(..., help="Scan ID"),
338
+ yes: bool = typer.Option(False, "--yes", "-y"),
339
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
340
+ ):
341
+ """Delete a scan.
342
+
343
+ Source: https://docs.qualys.com/en/was/api/scans/delete_scan.htm
344
+ """
345
+ if not yes:
346
+ typer.confirm(f"Delete scan {scan_id}?", abort=True)
347
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
348
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/wasscan/{scan_id}", data=_search_xml())
349
+ output(data)
350
+
351
+
352
+ @_scan.command("scan-again")
353
+ def scan_scan_again(
354
+ ctx: typer.Context,
355
+ scan_id: str = typer.Argument(..., help="Scan ID to re-run"),
356
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
357
+ ):
358
+ """Re-run a completed scan with the same settings.
359
+
360
+ Source: https://docs.qualys.com/en/was/api/scan_again.htm
361
+ """
362
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
363
+ data = client.post_xml(f"/qps/rest/3.0/scanAgain/was/wasscan/{scan_id}", data=_search_xml())
364
+ output(data)
365
+
366
+
367
+ # ── finding ───────────────────────────────────────────────────────────────────
368
+ _finding = typer.Typer(help="WAS finding operations")
369
+ app.add_typer(_finding, name="finding")
370
+
371
+
372
+ @_finding.command("list")
373
+ def finding_list(
374
+ ctx: typer.Context,
375
+ webapp_id: str | None = typer.Option(None, "--webapp-id"),
376
+ qid: str | None = typer.Option(None, "--qid", help="Qualys finding ID"),
377
+ status: str | None = typer.Option(None, "--status", help="NEW|ACTIVE|REOPENED|PROTECTED|FIXED"),
378
+ finding_type: str | None = typer.Option(None, "--type", help="VULNERABILITY|SENSITIVE_CONTENT|INFORMATION_GATHERED"),
379
+ severity: str | None = typer.Option(None, "--severity", help="1-5"),
380
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
381
+ format: str = typer.Option("table", "--format", "-f"),
382
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
383
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
384
+ ):
385
+ """Search/list findings.
386
+
387
+ Source: https://docs.qualys.com/en/was/api/finding/get_finding_details.htm
388
+ """
389
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
390
+ filters: list[tuple[str, str, str]] = []
391
+ if webapp_id:
392
+ filters.append(("webApp.id", "EQUALS", webapp_id))
393
+ if qid:
394
+ filters.append(("qid", "EQUALS", qid))
395
+ if status:
396
+ filters.append(("status", "EQUALS", status))
397
+ if finding_type:
398
+ filters.append(("type", "EQUALS", finding_type))
399
+ if severity:
400
+ filters.append(("severity", "EQUALS", severity))
401
+ data = client.post_xml("/qps/rest/3.0/search/was/finding", data=_search_xml(filters or None))
402
+ output(data, format=format, limit=limit, output_file=output_file)
403
+
404
+
405
+ @_finding.command("get")
406
+ def finding_get(
407
+ ctx: typer.Context,
408
+ finding_id: str = typer.Argument(..., help="Finding ID"),
409
+ format: str = typer.Option("table", "--format", "-f"),
410
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
411
+ ):
412
+ """Get details of a finding.
413
+
414
+ Source: https://docs.qualys.com/en/was/api/finding/get_finding_details.htm
415
+ """
416
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
417
+ data = client.get_xml(f"/qps/rest/3.0/get/was/finding/{finding_id}", {})
418
+ output(data, format=format)
419
+
420
+
421
+ @_finding.command("count")
422
+ def finding_count(
423
+ ctx: typer.Context,
424
+ webapp_id: str | None = typer.Option(None, "--webapp-id"),
425
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
426
+ format: str = typer.Option("table", "--format", "-f"),
427
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
428
+ ):
429
+ """Get count of findings.
430
+
431
+ Source: https://docs.qualys.com/en/was/api/finding/finding_count.htm
432
+ """
433
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
434
+ filters: list[tuple[str, str, str]] = []
435
+ if webapp_id:
436
+ filters.append(("webApp.id", "EQUALS", webapp_id))
437
+ data = client.post_xml("/qps/rest/3.0/count/was/finding", data=_search_xml(filters or None))
438
+ output(data, format=format, output_file=output_file)
439
+
440
+
441
+ @_finding.command("ignore")
442
+ def finding_ignore(
443
+ ctx: typer.Context,
444
+ finding_id: str = typer.Argument(..., help="Finding ID"),
445
+ reason: str = typer.Option(..., "--reason", help="Ignore reason (e.g. FALSE_POSITIVE, NOT_APPLICABLE)"),
446
+ comment: str | None = typer.Option(None, "--comment"),
447
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
448
+ ):
449
+ """Ignore a finding.
450
+
451
+ Source: https://docs.qualys.com/en/was/api/finding/ignore_finding.htm
452
+ """
453
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
454
+ comment_xml = f"<comments><![CDATA[{comment}]]></comments>" if comment else ""
455
+ xml_body = (
456
+ '<?xml version="1.0" encoding="UTF-8"?>'
457
+ "<ServiceRequest><data><Finding>"
458
+ f"<ignoredReason>{reason}</ignoredReason>"
459
+ f"{comment_xml}"
460
+ "</Finding></data></ServiceRequest>"
461
+ )
462
+ data = client.post_xml(f"/qps/rest/3.0/ignore/was/finding/{finding_id}", data=xml_body)
463
+ output(data)
464
+
465
+
466
+ @_finding.command("reactivate")
467
+ def finding_reactivate(
468
+ ctx: typer.Context,
469
+ finding_id: str = typer.Argument(..., help="Finding ID"),
470
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
471
+ ):
472
+ """Reactivate (un-ignore) a finding.
473
+
474
+ Source: https://docs.qualys.com/en/was/api/finding/activate_finding.htm
475
+ """
476
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
477
+ data = client.post_xml(f"/qps/rest/3.0/activate/was/finding/{finding_id}", data=_search_xml())
478
+ output(data)
479
+
480
+
481
+ @_finding.command("restore")
482
+ def finding_restore(
483
+ ctx: typer.Context,
484
+ finding_id: str = typer.Argument(..., help="Finding ID"),
485
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
486
+ ):
487
+ """Restore a fixed finding to active state.
488
+
489
+ Source: https://docs.qualys.com/en/was/api/finding/restore_finding.htm
490
+ """
491
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
492
+ data = client.post_xml(f"/qps/rest/3.0/restore/was/finding/{finding_id}", data=_search_xml())
493
+ output(data)
494
+
495
+
496
+ @_finding.command("retest")
497
+ def finding_retest(
498
+ ctx: typer.Context,
499
+ finding_id: str = typer.Argument(..., help="Finding ID"),
500
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
501
+ ):
502
+ """Request a retest of a specific finding.
503
+
504
+ Source: https://docs.qualys.com/en/was/api/finding/retest_finding.htm
505
+ """
506
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
507
+ data = client.post_xml(f"/qps/rest/3.0/retest/was/finding/{finding_id}", data=_search_xml())
508
+ output(data)
509
+
510
+
511
+ @_finding.command("retest-status")
512
+ def finding_retest_status(
513
+ ctx: typer.Context,
514
+ finding_id: str = typer.Argument(..., help="Finding ID"),
515
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
516
+ ):
517
+ """Get the retest status of a finding.
518
+
519
+ Source: https://docs.qualys.com/en/was/api/finding/retrieve_finding_retest_status.htm
520
+ """
521
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
522
+ data = client.get_xml(f"/qps/rest/3.0/retestStatus/was/finding/{finding_id}", {})
523
+ output(data)
524
+
525
+
526
+ @_finding.command("edit-severity")
527
+ def finding_edit_severity(
528
+ ctx: typer.Context,
529
+ finding_id: str = typer.Argument(..., help="Finding ID"),
530
+ severity: int = typer.Option(..., "--severity", help="New severity 1-5"),
531
+ comment: str | None = typer.Option(None, "--comment"),
532
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
533
+ ):
534
+ """Edit the severity of a finding.
535
+
536
+ Source: https://docs.qualys.com/en/was/api/finding/edit_finding.htm
537
+ """
538
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
539
+ comment_xml = f"<comments><![CDATA[{comment}]]></comments>" if comment else ""
540
+ xml_body = (
541
+ '<?xml version="1.0" encoding="UTF-8"?>'
542
+ "<ServiceRequest><data><Finding>"
543
+ f"<severity>{severity}</severity>"
544
+ f"{comment_xml}"
545
+ "</Finding></data></ServiceRequest>"
546
+ )
547
+ data = client.post_xml(f"/qps/rest/3.0/editSeverity/was/finding/{finding_id}", data=xml_body)
548
+ output(data)
549
+
550
+
551
+ # ── auth-record ───────────────────────────────────────────────────────────────
552
+ _auth = typer.Typer(help="Authentication record operations")
553
+ app.add_typer(_auth, name="auth-record")
554
+
555
+
556
+ @_auth.command("list")
557
+ def auth_list(
558
+ ctx: typer.Context,
559
+ name: str | None = typer.Option(None, "--name", help="Filter by name (CONTAINS)"),
560
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
561
+ format: str = typer.Option("table", "--format", "-f"),
562
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
563
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
564
+ ):
565
+ """Search/list authentication records.
566
+
567
+ Source: https://docs.qualys.com/en/was/api/auth/get_auth_details.htm
568
+ """
569
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
570
+ filters: list[tuple[str, str, str]] = []
571
+ if name:
572
+ filters.append(("name", "CONTAINS", name))
573
+ data = client.post_xml("/qps/rest/3.0/search/was/webappauthrecord", data=_search_xml(filters or None))
574
+ output(data, format=format, limit=limit, output_file=output_file)
575
+
576
+
577
+ @_auth.command("get")
578
+ def auth_get(
579
+ ctx: typer.Context,
580
+ auth_id: str = typer.Argument(..., help="Authentication record ID"),
581
+ format: str = typer.Option("table", "--format", "-f"),
582
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
583
+ ):
584
+ """Get authentication record details.
585
+
586
+ Source: https://docs.qualys.com/en/was/api/auth/get_auth_details.htm
587
+ """
588
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
589
+ data = client.get_xml(f"/qps/rest/3.0/get/was/webappauthrecord/{auth_id}", {})
590
+ output(data, format=format)
591
+
592
+
593
+ @_auth.command("count")
594
+ def auth_count(
595
+ ctx: typer.Context,
596
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
597
+ format: str = typer.Option("table", "--format", "-f"),
598
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
599
+ ):
600
+ """Get count of authentication records.
601
+
602
+ Source: https://docs.qualys.com/en/was/api/auth/auth_count.htm
603
+ """
604
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
605
+ data = client.get_xml("/qps/rest/3.0/count/was/webappauthrecord", {})
606
+ output(data, format=format, output_file=output_file)
607
+
608
+
609
+ @_auth.command("delete")
610
+ def auth_delete(
611
+ ctx: typer.Context,
612
+ auth_id: str = typer.Argument(..., help="Authentication record ID"),
613
+ yes: bool = typer.Option(False, "--yes", "-y"),
614
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
615
+ ):
616
+ """Delete an authentication record.
617
+
618
+ Source: https://docs.qualys.com/en/was/api/auth/delete_auth.htm
619
+ """
620
+ if not yes:
621
+ typer.confirm(f"Delete auth record {auth_id}?", abort=True)
622
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
623
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/webappauthrecord/{auth_id}", data=_search_xml())
624
+ output(data)
625
+
626
+
627
+ # ── option-profile ────────────────────────────────────────────────────────────
628
+ _profile = typer.Typer(help="Option profile operations")
629
+ app.add_typer(_profile, name="option-profile")
630
+
631
+
632
+ @_profile.command("list")
633
+ def profile_list(
634
+ ctx: typer.Context,
635
+ name: str | None = typer.Option(None, "--name", help="Filter by name (CONTAINS)"),
636
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
637
+ format: str = typer.Option("table", "--format", "-f"),
638
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
639
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
640
+ ):
641
+ """Search/list option profiles.
642
+
643
+ Source: https://docs.qualys.com/en/was/api/option_profile/get_op_details.htm
644
+ """
645
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
646
+ filters: list[tuple[str, str, str]] = []
647
+ if name:
648
+ filters.append(("name", "CONTAINS", name))
649
+ data = client.post_xml("/qps/rest/3.0/search/was/optionprofile", data=_search_xml(filters or None))
650
+ output(data, format=format, limit=limit, output_file=output_file)
651
+
652
+
653
+ @_profile.command("get")
654
+ def profile_get(
655
+ ctx: typer.Context,
656
+ profile_id: str = typer.Argument(..., help="Option profile ID"),
657
+ format: str = typer.Option("table", "--format", "-f"),
658
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
659
+ ):
660
+ """Get option profile details.
661
+
662
+ Source: https://docs.qualys.com/en/was/api/option_profile/get_op_details.htm
663
+ """
664
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
665
+ data = client.get_xml(f"/qps/rest/3.0/get/was/optionprofile/{profile_id}", {})
666
+ output(data, format=format)
667
+
668
+
669
+ @_profile.command("count")
670
+ def profile_count(
671
+ ctx: typer.Context,
672
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
673
+ format: str = typer.Option("table", "--format", "-f"),
674
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
675
+ ):
676
+ """Get count of option profiles.
677
+
678
+ Source: https://docs.qualys.com/en/was/api/option_profile/op_count.htm
679
+ """
680
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
681
+ data = client.get_xml("/qps/rest/3.0/count/was/optionprofile", {})
682
+ output(data, format=format, output_file=output_file)
683
+
684
+
685
+ @_profile.command("create")
686
+ def profile_create(
687
+ ctx: typer.Context,
688
+ name: str = typer.Option(..., "--name", help="Option profile name"),
689
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
690
+ ):
691
+ """Create an option profile with default settings.
692
+
693
+ Source: https://docs.qualys.com/en/was/api/option_profile/create_op.htm
694
+ """
695
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
696
+ xml_body = (
697
+ '<?xml version="1.0" encoding="UTF-8"?>'
698
+ "<ServiceRequest><data><OptionProfile>"
699
+ f"<name><![CDATA[{name}]]></name>"
700
+ "</OptionProfile></data></ServiceRequest>"
701
+ )
702
+ data = client.post_xml("/qps/rest/3.0/create/was/optionprofile", data=xml_body)
703
+ output(data)
704
+
705
+
706
+ @_profile.command("delete")
707
+ def profile_delete(
708
+ ctx: typer.Context,
709
+ profile_id: str = typer.Argument(..., help="Option profile ID"),
710
+ yes: bool = typer.Option(False, "--yes", "-y"),
711
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
712
+ ):
713
+ """Delete an option profile.
714
+
715
+ Source: https://docs.qualys.com/en/was/api/option_profile/delete_op.htm
716
+ """
717
+ if not yes:
718
+ typer.confirm(f"Delete option profile {profile_id}?", abort=True)
719
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
720
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/optionprofile/{profile_id}", data=_search_xml())
721
+ output(data)
722
+
723
+
724
+ # ── schedule ──────────────────────────────────────────────────────────────────
725
+ _schedule = typer.Typer(help="Scan schedule operations")
726
+ app.add_typer(_schedule, name="schedule")
727
+
728
+
729
+ @_schedule.command("list")
730
+ def schedule_list(
731
+ ctx: typer.Context,
732
+ name: str | None = typer.Option(None, "--name", help="Filter by name (CONTAINS)"),
733
+ webapp_id: str | None = typer.Option(None, "--webapp-id"),
734
+ active: bool | None = typer.Option(None, "--active/--inactive"),
735
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
736
+ format: str = typer.Option("table", "--format", "-f"),
737
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
738
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
739
+ ):
740
+ """Search/list scan schedules.
741
+
742
+ Source: https://docs.qualys.com/en/was/api/schedules/get_schedule_details.htm
743
+ """
744
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
745
+ filters: list[tuple[str, str, str]] = []
746
+ if name:
747
+ filters.append(("name", "CONTAINS", name))
748
+ if webapp_id:
749
+ filters.append(("webApp.id", "EQUALS", webapp_id))
750
+ if active is not None:
751
+ filters.append(("active", "EQUALS", str(active).upper()))
752
+ data = client.post_xml("/qps/rest/3.0/search/was/wasscanschedule", data=_search_xml(filters or None))
753
+ output(data, format=format, limit=limit, output_file=output_file)
754
+
755
+
756
+ @_schedule.command("get")
757
+ def schedule_get(
758
+ ctx: typer.Context,
759
+ schedule_id: str = typer.Argument(..., help="Schedule ID"),
760
+ format: str = typer.Option("table", "--format", "-f"),
761
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
762
+ ):
763
+ """Get schedule details.
764
+
765
+ Source: https://docs.qualys.com/en/was/api/schedules/get_schedule_details.htm
766
+ """
767
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
768
+ data = client.get_xml(f"/qps/rest/3.0/get/was/wasscanschedule/{schedule_id}", {})
769
+ output(data, format=format)
770
+
771
+
772
+ @_schedule.command("count")
773
+ def schedule_count(
774
+ ctx: typer.Context,
775
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
776
+ format: str = typer.Option("table", "--format", "-f"),
777
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
778
+ ):
779
+ """Get count of scan schedules.
780
+
781
+ Source: https://docs.qualys.com/en/was/api/schedules/schedule_count.htm
782
+ """
783
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
784
+ data = client.get_xml("/qps/rest/3.0/count/was/wasscanschedule", {})
785
+ output(data, format=format, output_file=output_file)
786
+
787
+
788
+ @_schedule.command("create")
789
+ def schedule_create(
790
+ ctx: typer.Context,
791
+ name: str = typer.Option(..., "--name", help="Schedule name"),
792
+ webapp_id: str = typer.Option(..., "--webapp-id", help="Web application ID"),
793
+ profile_id: str = typer.Option(..., "--profile-id", help="Option profile ID"),
794
+ scan_type: str = typer.Option("VULNERABILITY", "--type", help="VULNERABILITY|DISCOVERY"),
795
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
796
+ ):
797
+ """Create a scan schedule.
798
+
799
+ Source: https://docs.qualys.com/en/was/api/schedules/create_schedule.htm
800
+ """
801
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
802
+ xml_body = (
803
+ '<?xml version="1.0" encoding="UTF-8"?>'
804
+ "<ServiceRequest><data><WasScanSchedule>"
805
+ f"<name><![CDATA[{name}]]></name>"
806
+ f"<type>{scan_type}</type>"
807
+ f"<target><webApp><id>{webapp_id}</id></webApp></target>"
808
+ f"<profile><id>{profile_id}</id></profile>"
809
+ "</WasScanSchedule></data></ServiceRequest>"
810
+ )
811
+ data = client.post_xml("/qps/rest/3.0/create/was/wasscanschedule", data=xml_body)
812
+ output(data)
813
+
814
+
815
+ @_schedule.command("activate")
816
+ def schedule_activate(
817
+ ctx: typer.Context,
818
+ schedule_id: str = typer.Argument(..., help="Schedule ID"),
819
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
820
+ ):
821
+ """Activate a scan schedule.
822
+
823
+ Source: https://docs.qualys.com/en/was/api/schedules/activate_schedule.htm
824
+ """
825
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
826
+ xml_body = (
827
+ '<?xml version="1.0" encoding="UTF-8"?>'
828
+ "<ServiceRequest><data><WasScanSchedule>"
829
+ "<active>true</active>"
830
+ "</WasScanSchedule></data></ServiceRequest>"
831
+ )
832
+ data = client.post_xml(f"/qps/rest/3.0/update/was/wasscanschedule/{schedule_id}", data=xml_body)
833
+ output(data)
834
+
835
+
836
+ @_schedule.command("deactivate")
837
+ def schedule_deactivate(
838
+ ctx: typer.Context,
839
+ schedule_id: str = typer.Argument(..., help="Schedule ID"),
840
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
841
+ ):
842
+ """Deactivate a scan schedule.
843
+
844
+ Source: https://docs.qualys.com/en/was/api/schedules/deactivate_schedule.htm
845
+ """
846
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
847
+ xml_body = (
848
+ '<?xml version="1.0" encoding="UTF-8"?>'
849
+ "<ServiceRequest><data><WasScanSchedule>"
850
+ "<active>false</active>"
851
+ "</WasScanSchedule></data></ServiceRequest>"
852
+ )
853
+ data = client.post_xml(f"/qps/rest/3.0/update/was/wasscanschedule/{schedule_id}", data=xml_body)
854
+ output(data)
855
+
856
+
857
+ @_schedule.command("delete")
858
+ def schedule_delete(
859
+ ctx: typer.Context,
860
+ schedule_id: str = typer.Argument(..., help="Schedule ID"),
861
+ yes: bool = typer.Option(False, "--yes", "-y"),
862
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
863
+ ):
864
+ """Delete a scan schedule.
865
+
866
+ Source: https://docs.qualys.com/en/was/api/schedules/delete_schedule.htm
867
+ """
868
+ if not yes:
869
+ typer.confirm(f"Delete schedule {schedule_id}?", abort=True)
870
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
871
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/wasscanschedule/{schedule_id}", data=_search_xml())
872
+ output(data)
873
+
874
+
875
+ # ── report ────────────────────────────────────────────────────────────────────
876
+ _report = typer.Typer(help="Report operations")
877
+ app.add_typer(_report, name="report")
878
+
879
+
880
+ @_report.command("list")
881
+ def report_list(
882
+ ctx: typer.Context,
883
+ name: str | None = typer.Option(None, "--name", help="Filter by name (CONTAINS)"),
884
+ status: str | None = typer.Option(None, "--status", help="RUNNING|COMPLETE|ERROR"),
885
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
886
+ format: str = typer.Option("table", "--format", "-f"),
887
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
888
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
889
+ ):
890
+ """Search/list reports.
891
+
892
+ Source: https://docs.qualys.com/en/was/api/reports/get_report_details.htm
893
+ """
894
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
895
+ filters: list[tuple[str, str, str]] = []
896
+ if name:
897
+ filters.append(("name", "CONTAINS", name))
898
+ if status:
899
+ filters.append(("status", "EQUALS", status))
900
+ data = client.post_xml("/qps/rest/3.0/search/was/report", data=_search_xml(filters or None))
901
+ output(data, format=format, limit=limit, output_file=output_file)
902
+
903
+
904
+ @_report.command("get")
905
+ def report_get(
906
+ ctx: typer.Context,
907
+ report_id: str = typer.Argument(..., help="Report ID"),
908
+ format: str = typer.Option("table", "--format", "-f"),
909
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
910
+ ):
911
+ """Get report details.
912
+
913
+ Source: https://docs.qualys.com/en/was/api/reports/get_report_details.htm
914
+ """
915
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
916
+ data = client.get_xml(f"/qps/rest/3.0/get/was/report/{report_id}", {})
917
+ output(data, format=format)
918
+
919
+
920
+ @_report.command("status")
921
+ def report_status(
922
+ ctx: typer.Context,
923
+ report_id: str = typer.Argument(..., help="Report ID"),
924
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
925
+ ):
926
+ """Get the status of a report.
927
+
928
+ Source: https://docs.qualys.com/en/was/api/reports/get_report_status.htm
929
+ """
930
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
931
+ data = client.get_xml(f"/qps/rest/3.0/status/was/report/{report_id}", {})
932
+ output(data)
933
+
934
+
935
+ @_report.command("count")
936
+ def report_count(
937
+ ctx: typer.Context,
938
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
939
+ format: str = typer.Option("table", "--format", "-f"),
940
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
941
+ ):
942
+ """Get count of reports.
943
+
944
+ Source: https://docs.qualys.com/en/was/api/reports/report_count.htm
945
+ """
946
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
947
+ data = client.get_xml("/qps/rest/3.0/count/was/report", {})
948
+ output(data, format=format, output_file=output_file)
949
+
950
+
951
+ @_report.command("create-webapp")
952
+ def report_create_webapp(
953
+ ctx: typer.Context,
954
+ name: str = typer.Option(..., "--name", help="Report name"),
955
+ webapp_id: str = typer.Option(..., "--webapp-id", help="Web application ID"),
956
+ template_id: str | None = typer.Option(None, "--template-id", help="Report template ID"),
957
+ report_format: str = typer.Option("PDF", "--format-type", help="PDF|HTML|XML|CSV|WORD"),
958
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
959
+ ):
960
+ """Create a web application vulnerability report.
961
+
962
+ Source: https://docs.qualys.com/en/was/api/report_creation/webapp_report.htm
963
+ """
964
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
965
+ tmpl_xml = f"<template><id>{template_id}</id></template>" if template_id else ""
966
+ xml_body = (
967
+ '<?xml version="1.0" encoding="UTF-8"?>'
968
+ "<ServiceRequest><data><Report>"
969
+ f"<name><![CDATA[{name}]]></name>"
970
+ f"<format>{report_format}</format>"
971
+ "<type>WEB_APP_REPORT</type>"
972
+ f"<config><webAppReport><target><webApps><WebApp><id>{webapp_id}</id></WebApp></webApps></target></webAppReport></config>"
973
+ f"{tmpl_xml}"
974
+ "</Report></data></ServiceRequest>"
975
+ )
976
+ data = client.post_xml("/qps/rest/3.0/create/was/report", data=xml_body)
977
+ output(data)
978
+
979
+
980
+ @_report.command("create-scan")
981
+ def report_create_scan(
982
+ ctx: typer.Context,
983
+ name: str = typer.Option(..., "--name", help="Report name"),
984
+ scan_id: str = typer.Option(..., "--scan-id", help="Scan ID"),
985
+ template_id: str | None = typer.Option(None, "--template-id"),
986
+ report_format: str = typer.Option("PDF", "--format-type", help="PDF|HTML|XML|CSV"),
987
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
988
+ ):
989
+ """Create a scan-based report.
990
+
991
+ Source: https://docs.qualys.com/en/was/api/report_creation/scan_report.htm
992
+ """
993
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
994
+ tmpl_xml = f"<template><id>{template_id}</id></template>" if template_id else ""
995
+ xml_body = (
996
+ '<?xml version="1.0" encoding="UTF-8"?>'
997
+ "<ServiceRequest><data><Report>"
998
+ f"<name><![CDATA[{name}]]></name>"
999
+ f"<format>{report_format}</format>"
1000
+ "<type>SCAN_REPORT</type>"
1001
+ f"<config><scanReport><target><scans><WasScan><id>{scan_id}</id></WasScan></scans></target></scanReport></config>"
1002
+ f"{tmpl_xml}"
1003
+ "</Report></data></ServiceRequest>"
1004
+ )
1005
+ data = client.post_xml("/qps/rest/3.0/create/was/report", data=xml_body)
1006
+ output(data)
1007
+
1008
+
1009
+ @_report.command("download")
1010
+ def report_download(
1011
+ ctx: typer.Context,
1012
+ report_id: str = typer.Argument(..., help="Report ID"),
1013
+ output_file: str = typer.Option("was-report.pdf", "--output-file", "-o"),
1014
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1015
+ ):
1016
+ """Download a completed report.
1017
+
1018
+ Source: https://docs.qualys.com/en/was/api/reports/download_report.htm
1019
+ """
1020
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1021
+ resp = client.request("GET", f"/qps/rest/3.0/download/was/report/{report_id}")
1022
+ from pathlib import Path
1023
+ Path(output_file).write_bytes(resp.content)
1024
+ output_success(f"Report saved to {output_file} ({len(resp.content):,} bytes)")
1025
+
1026
+
1027
+ @_report.command("delete")
1028
+ def report_delete(
1029
+ ctx: typer.Context,
1030
+ report_id: str = typer.Argument(..., help="Report ID"),
1031
+ yes: bool = typer.Option(False, "--yes", "-y"),
1032
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1033
+ ):
1034
+ """Delete a report.
1035
+
1036
+ Source: https://docs.qualys.com/en/was/api/reports/delete_report.htm
1037
+ """
1038
+ if not yes:
1039
+ typer.confirm(f"Delete WAS report {report_id}?", abort=True)
1040
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1041
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/report/{report_id}", data=_search_xml())
1042
+ output(data)
1043
+
1044
+
1045
+ # ── catalog ───────────────────────────────────────────────────────────────────
1046
+ _catalog = typer.Typer(help="Web application catalog operations")
1047
+ app.add_typer(_catalog, name="catalog")
1048
+
1049
+
1050
+ @_catalog.command("list")
1051
+ def catalog_list(
1052
+ ctx: typer.Context,
1053
+ name: str | None = typer.Option(None, "--name"),
1054
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
1055
+ format: str = typer.Option("table", "--format", "-f"),
1056
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
1057
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1058
+ ):
1059
+ """List catalog entries.
1060
+
1061
+ Source: https://docs.qualys.com/en/was/api/catalog/get_catalog_entry_details.htm
1062
+ """
1063
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1064
+ filters: list[tuple[str, str, str]] = []
1065
+ if name:
1066
+ filters.append(("name", "CONTAINS", name))
1067
+ data = client.post_xml("/qps/rest/3.0/search/was/catalog", data=_search_xml(filters or None))
1068
+ output(data, format=format, limit=limit, output_file=output_file)
1069
+
1070
+
1071
+ @_catalog.command("count")
1072
+ def catalog_count(
1073
+ ctx: typer.Context,
1074
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
1075
+ format: str = typer.Option("table", "--format", "-f"),
1076
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1077
+ ):
1078
+ """Get count of catalog entries.
1079
+
1080
+ Source: https://docs.qualys.com/en/was/api/catalog/count_catalog_entires.htm
1081
+ """
1082
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1083
+ data = client.get_xml("/qps/rest/3.0/count/was/catalog", {})
1084
+ output(data, format=format, output_file=output_file)
1085
+
1086
+
1087
+ @_catalog.command("add-to-subscription")
1088
+ def catalog_add(
1089
+ ctx: typer.Context,
1090
+ catalog_id: str = typer.Argument(..., help="Catalog entry ID"),
1091
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1092
+ ):
1093
+ """Add a catalog entry to the subscription (creates a web application).
1094
+
1095
+ Source: https://docs.qualys.com/en/was/api/catalog/add_to_subscription.htm
1096
+ """
1097
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1098
+ data = client.post_xml(f"/qps/rest/3.0/addToSubscription/was/catalog/{catalog_id}", data=_search_xml())
1099
+ output(data)
1100
+
1101
+
1102
+ @_catalog.command("delete")
1103
+ def catalog_delete(
1104
+ ctx: typer.Context,
1105
+ catalog_id: str = typer.Argument(..., help="Catalog entry ID"),
1106
+ yes: bool = typer.Option(False, "--yes", "-y"),
1107
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1108
+ ):
1109
+ """Delete catalog entries.
1110
+
1111
+ Source: https://docs.qualys.com/en/was/api/catalog/delete_catalog_entries.htm
1112
+ """
1113
+ if not yes:
1114
+ typer.confirm(f"Delete catalog entry {catalog_id}?", abort=True)
1115
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1116
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/catalog/{catalog_id}", data=_search_xml())
1117
+ output(data)
1118
+
1119
+
1120
+ # ── dns ───────────────────────────────────────────────────────────────────────
1121
+ _dns = typer.Typer(help="DNS override operations")
1122
+ app.add_typer(_dns, name="dns")
1123
+
1124
+
1125
+ @_dns.command("list")
1126
+ def dns_list(
1127
+ ctx: typer.Context,
1128
+ name: str | None = typer.Option(None, "--name"),
1129
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
1130
+ format: str = typer.Option("table", "--format", "-f"),
1131
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
1132
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1133
+ ):
1134
+ """List DNS overrides.
1135
+
1136
+ Source: https://docs.qualys.com/en/was/api/dns_override/get_dns_details.htm
1137
+ """
1138
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1139
+ filters: list[tuple[str, str, str]] = []
1140
+ if name:
1141
+ filters.append(("name", "CONTAINS", name))
1142
+ data = client.post_xml("/qps/rest/3.0/search/was/dnsoverride", data=_search_xml(filters or None))
1143
+ output(data, format=format, limit=limit, output_file=output_file)
1144
+
1145
+
1146
+ @_dns.command("get")
1147
+ def dns_get(
1148
+ ctx: typer.Context,
1149
+ dns_id: str = typer.Argument(..., help="DNS override ID"),
1150
+ format: str = typer.Option("table", "--format", "-f"),
1151
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1152
+ ):
1153
+ """Get DNS override details.
1154
+
1155
+ Source: https://docs.qualys.com/en/was/api/dns_override/get_dns_details.htm
1156
+ """
1157
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1158
+ data = client.get_xml(f"/qps/rest/3.0/get/was/dnsoverride/{dns_id}", {})
1159
+ output(data, format=format)
1160
+
1161
+
1162
+ @_dns.command("count")
1163
+ def dns_count(
1164
+ ctx: typer.Context,
1165
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
1166
+ format: str = typer.Option("table", "--format", "-f"),
1167
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1168
+ ):
1169
+ """Get count of DNS overrides.
1170
+
1171
+ Source: https://docs.qualys.com/en/was/api/dns_override/dns_count.htm
1172
+ """
1173
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1174
+ data = client.get_xml("/qps/rest/3.0/count/was/dnsoverride", {})
1175
+ output(data, format=format, output_file=output_file)
1176
+
1177
+
1178
+ @_dns.command("create")
1179
+ def dns_create(
1180
+ ctx: typer.Context,
1181
+ name: str = typer.Option(..., "--name", help="DNS override name"),
1182
+ hostname: str = typer.Option(..., "--hostname", help="Hostname to override"),
1183
+ ip: str = typer.Option(..., "--ip", help="IP address to map to"),
1184
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1185
+ ):
1186
+ """Create a DNS override.
1187
+
1188
+ Source: https://docs.qualys.com/en/was/api/dns_override/create_dns.html
1189
+ """
1190
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1191
+ xml_body = (
1192
+ '<?xml version="1.0" encoding="UTF-8"?>'
1193
+ "<ServiceRequest><data><DnsOverride>"
1194
+ f"<name><![CDATA[{name}]]></name>"
1195
+ "<mappings><set>"
1196
+ f"<DnsMapping><hostName>{hostname}</hostName><ipAddress>{ip}</ipAddress></DnsMapping>"
1197
+ "</set></mappings>"
1198
+ "</DnsOverride></data></ServiceRequest>"
1199
+ )
1200
+ data = client.post_xml("/qps/rest/3.0/create/was/dnsoverride", data=xml_body)
1201
+ output(data)
1202
+
1203
+
1204
+ @_dns.command("delete")
1205
+ def dns_delete(
1206
+ ctx: typer.Context,
1207
+ dns_id: str = typer.Argument(..., help="DNS override ID"),
1208
+ yes: bool = typer.Option(False, "--yes", "-y"),
1209
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1210
+ ):
1211
+ """Delete a DNS override.
1212
+
1213
+ Source: https://docs.qualys.com/en/was/api/dns_override/delete_dns.htm
1214
+ """
1215
+ if not yes:
1216
+ typer.confirm(f"Delete DNS override {dns_id}?", abort=True)
1217
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1218
+ data = client.post_xml(f"/qps/rest/3.0/delete/was/dnsoverride/{dns_id}", data=_search_xml())
1219
+ output(data)
1220
+
1221
+
1222
+ # ── import ────────────────────────────────────────────────────────────────────
1223
+ _import = typer.Typer(help="Import findings from external tools")
1224
+ app.add_typer(_import, name="import")
1225
+
1226
+
1227
+ @_import.command("burp")
1228
+ def import_burp(
1229
+ ctx: typer.Context,
1230
+ webapp_id: str = typer.Option(..., "--webapp-id", help="Target web application ID"),
1231
+ file_path: str = typer.Option(..., "--file", help="Path to Burp Suite XML export file"),
1232
+ purge_results: bool = typer.Option(False, "--purge-results", help="Remove previous results"),
1233
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1234
+ ):
1235
+ """Import findings from a Burp Suite XML export.
1236
+
1237
+ Source: https://docs.qualys.com/en/was/api/burp/import_burp.htm
1238
+ """
1239
+ from pathlib import Path as _Path
1240
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1241
+ xml_body = (
1242
+ '<?xml version="1.0" encoding="UTF-8"?>'
1243
+ "<ServiceRequest><data><WasBurpScan>"
1244
+ f"<webApp><id>{webapp_id}</id></webApp>"
1245
+ f"<purgeResults>{str(purge_results).lower()}</purgeResults>"
1246
+ f"<file><![CDATA[{_Path(file_path).read_text()}]]></file>"
1247
+ "</WasBurpScan></data></ServiceRequest>"
1248
+ )
1249
+ data = client.post_xml("/qps/rest/3.0/import/was/burpsuite/", data=xml_body)
1250
+ output(data)
1251
+
1252
+
1253
+ @_import.command("owasp-zap")
1254
+ def import_owasp_zap(
1255
+ ctx: typer.Context,
1256
+ webapp_id: str = typer.Option(..., "--webapp-id", help="Target web application ID"),
1257
+ file_path: str = typer.Option(..., "--file", help="Path to OWASP ZAP XML report file"),
1258
+ purge_results: bool = typer.Option(False, "--purge-results"),
1259
+ close_unreported: bool = typer.Option(False, "--close-unreported", help="Mark unfound issues as fixed"),
1260
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
1261
+ ):
1262
+ """Import findings from an OWASP ZAP XML report.
1263
+
1264
+ Source: https://docs.qualys.com/en/was/api/zap/import_owasp_findings.htm
1265
+ """
1266
+ from pathlib import Path as _Path
1267
+ client = _c.make(ctx.obj, auth_type="basic", verbose=verbose)
1268
+ xml_body = (
1269
+ '<?xml version="1.0" encoding="UTF-8"?>'
1270
+ "<ServiceRequest><data><WasOwaspZapScan>"
1271
+ f"<webApp><id>{webapp_id}</id></webApp>"
1272
+ f"<purgeResults>{str(purge_results).lower()}</purgeResults>"
1273
+ f"<closeUnreportedIssues>{str(close_unreported).lower()}</closeUnreportedIssues>"
1274
+ f"<file><![CDATA[{_Path(file_path).read_text()}]]></file>"
1275
+ "</WasOwaspZapScan></data></ServiceRequest>"
1276
+ )
1277
+ data = client.post_xml("/qps/rest/3.0/import/was/owaspzap/", data=xml_body)
1278
+ output(data)