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,752 @@
1
+ # Hand-edited — do NOT regenerate from api-model.json (codegen will skip this file).
2
+ # Cyber Security Asset Management (CSAM) APIs (JWT Bearer auth, JSON responses)
3
+ # Originally generated from api-model.json; extended with: software components,
4
+ # business metadata import, third-party connector ingest, organization search,
5
+ # EASM status / PATCH update, and ServiceNow-style asset export.
6
+ # Corrections:
7
+ # - asset get: v2 endpoint is GET /rest/2.0/get/am/asset?assetId=<id>
8
+ # (was incorrectly GET /rest/2.0/am/asset/<id> — Spring "no static resource" 404)
9
+ # - vuln list-easm: POST /rest/2.0/search/am/easm/vulns
10
+ # (was incorrectly GET /rest/2.0/am/easm/vuln — Spring "no static resource" 404)
11
+ # - vuln list-scan: POST /rest/2.0/search/am/easm/scan/vulns
12
+ # (was incorrectly GET /rest/2.0/am/easm/scan/vuln — Spring "no static resource" 404)
13
+ # Both now accept --filter field=op=value (repeatable) + --last-seen-id for pagination.
14
+ from __future__ import annotations
15
+
16
+ import typer
17
+
18
+ from .. import client as _c
19
+ from ..formatters import DEFAULT_LIMIT, output, output_success
20
+
21
+ app = typer.Typer(name="csam", help="Cyber Security Asset Management APIs — assets, EASM, vulnerabilities, domains, reports")
22
+
23
+ # ── asset ─────────────────────────────────────────────────────────────────────
24
+ _asset = typer.Typer(help="Asset host data operations")
25
+ app.add_typer(_asset, name="asset")
26
+
27
+
28
+ @_asset.command("count")
29
+ def asset_count(
30
+ ctx: typer.Context,
31
+ filter_: list[str] | None = typer.Option(
32
+ None, "--filter",
33
+ help="Repeatable filter as field=op=value (e.g. asset.riskScore=GREATER=500)",
34
+ ),
35
+ asset_last_updated: str | None = typer.Option(None, "--updated-after", help="yyyy-MM-ddTHH:mmZ"),
36
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
37
+ format: str = typer.Option("table", "--format", "-f"),
38
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
39
+ ):
40
+ """Get the count of assets matching filter criteria.
41
+
42
+ Examples:
43
+ csam asset count
44
+ csam asset count --filter asset.riskScore=GREATER=500
45
+
46
+ Source: https://docs.qualys.com/en/csam/api/asset_host_data/count_of_assets.htm
47
+ """
48
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
49
+ body = _build_filter_body(filter_)
50
+ if asset_last_updated:
51
+ body["assetLastUpdated"] = asset_last_updated
52
+ data = client.post_json("/rest/2.0/count/am/asset", body)
53
+ output(data, format=format, output_file=output_file)
54
+
55
+
56
+ @_asset.command("list")
57
+ def asset_list(
58
+ ctx: typer.Context,
59
+ filter_: list[str] | None = typer.Option(
60
+ None, "--filter",
61
+ help="Repeatable filter as field=op=value (e.g. asset.riskScore=GREATER=500)",
62
+ ),
63
+ asset_last_updated: str | None = typer.Option(None, "--updated-after", help="yyyy-MM-ddTHH:mmZ"),
64
+ last_seen_asset_id: int | None = typer.Option(None, "--last-seen-id", help="Paginate from this asset ID"),
65
+ include_fields: str | None = typer.Option(None, "--include-fields", help="Comma-separated fields to include"),
66
+ exclude_fields: str | None = typer.Option(None, "--exclude-fields", help="Comma-separated fields to exclude"),
67
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
68
+ format: str = typer.Option("table", "--format", "-f"),
69
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
70
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
71
+ ):
72
+ """Get host details for all assets (max 100 per call, use --last-seen-id to paginate).
73
+
74
+ Examples:
75
+ csam asset list --limit 10
76
+ csam asset list --filter asset.riskScore=GREATER=500
77
+ csam asset list --filter asset.riskScore=GREATER=700 --limit 10
78
+
79
+ Source: https://docs.qualys.com/en/csam/api/asset_host_data/get_host_details_of_all_assets.htm
80
+ """
81
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
82
+ body = _build_filter_body(filter_)
83
+ if asset_last_updated:
84
+ body["assetLastUpdated"] = asset_last_updated
85
+ # These must be query params (API ignores them in body)
86
+ params: dict = {}
87
+ if last_seen_asset_id is not None:
88
+ params["lastSeenAssetId"] = str(last_seen_asset_id)
89
+ if include_fields:
90
+ params["includeFields"] = include_fields
91
+ if exclude_fields:
92
+ params["excludeFields"] = exclude_fields
93
+ data = client.post_json("/rest/2.0/search/am/asset", body, params=params)
94
+ output(data, format=format, limit=limit, output_file=output_file)
95
+
96
+
97
+ @_asset.command("get")
98
+ def asset_get(
99
+ ctx: typer.Context,
100
+ asset_id: str = typer.Argument(..., help="Asset ID"),
101
+ include_fields: str | None = typer.Option(None, "--include-fields"),
102
+ exclude_fields: str | None = typer.Option(None, "--exclude-fields"),
103
+ format: str = typer.Option("table", "--format", "-f"),
104
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
105
+ ):
106
+ """Get host details for a specific asset.
107
+
108
+ Source: https://docs.qualys.com/en/csam/api/asset_host_data/get_host_details_of_specific_asset.htm
109
+
110
+ Note: the v2 CSAM endpoint takes assetId as a *query* parameter at
111
+ /rest/2.0/get/am/asset, not as a path segment.
112
+ """
113
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
114
+ params: dict = {"assetId": asset_id}
115
+ if include_fields:
116
+ params["includeFields"] = include_fields
117
+ if exclude_fields:
118
+ params["excludeFields"] = exclude_fields
119
+ data = client.get_json("/rest/2.0/get/am/asset", params)
120
+ output(data, format=format)
121
+
122
+
123
+ # ── easm ──────────────────────────────────────────────────────────────────────
124
+ _easm = typer.Typer(help="External Attack Surface Management profile operations")
125
+ app.add_typer(_easm, name="easm")
126
+
127
+
128
+ @_easm.command("list")
129
+ def easm_list(
130
+ ctx: typer.Context,
131
+ profile_name: str | None = typer.Option(None, "--name", help="Filter by profile name"),
132
+ page_number: int = typer.Option(0, "--page-number"),
133
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
134
+ format: str = typer.Option("table", "--format", "-f"),
135
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
136
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
137
+ ):
138
+ """List EASM profiles.
139
+
140
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/get_easm_profile_data.htm
141
+ """
142
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
143
+ params: dict = {"pageNumber": page_number}
144
+ if profile_name:
145
+ params["profileName"] = profile_name
146
+ data = client.get_json("/easm/v2/profile", params)
147
+ output(data, format=format, limit=limit, output_file=output_file)
148
+
149
+
150
+ @_easm.command("create")
151
+ def easm_create(
152
+ ctx: typer.Context,
153
+ name: str = typer.Option(..., "--name", help="Profile name"),
154
+ default_profile: bool = typer.Option(False, "--default", help="Set as default profile"),
155
+ enable_domain_security: bool = typer.Option(False, "--domain-security"),
156
+ format: str = typer.Option("table", "--format", "-f"),
157
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
158
+ ):
159
+ """Create an EASM profile.
160
+
161
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/create_easm_profile.htm
162
+ """
163
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
164
+ body: dict = {"name": name, "defaultProfile": default_profile, "enableDomainSecurity": enable_domain_security}
165
+ data = client.post_json("/easm/v2/profile", body)
166
+ output(data, format=format)
167
+
168
+
169
+ @_easm.command("update")
170
+ def easm_update(
171
+ ctx: typer.Context,
172
+ profile_id: str = typer.Argument(..., help="EASM profile ID"),
173
+ name: str | None = typer.Option(None, "--name"),
174
+ active: bool | None = typer.Option(None, "--active/--inactive"),
175
+ format: str = typer.Option("table", "--format", "-f"),
176
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
177
+ ):
178
+ """Update an EASM profile.
179
+
180
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/update_easm_profile_data.htm
181
+ """
182
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
183
+ body: dict = {}
184
+ if name:
185
+ body["name"] = name
186
+ if active is not None:
187
+ body["active"] = active
188
+ data = client.post_json(f"/easm/v2/profile/{profile_id}", body)
189
+ output(data, format=format)
190
+
191
+
192
+ @_easm.command("delete")
193
+ def easm_delete(
194
+ ctx: typer.Context,
195
+ profile_id: str = typer.Argument(..., help="EASM profile ID"),
196
+ yes: bool = typer.Option(False, "--yes", "-y"),
197
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
198
+ ):
199
+ """Delete an EASM profile.
200
+
201
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/delete_easm_profile.htm
202
+ """
203
+ if not yes:
204
+ typer.confirm(f"Delete EASM profile {profile_id}?", abort=True)
205
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
206
+ client.delete_json(f"/easm/v2/profile/{profile_id}")
207
+ output_success(f"EASM profile {profile_id} deleted.")
208
+
209
+
210
+ @_easm.command("activate")
211
+ def easm_activate(
212
+ ctx: typer.Context,
213
+ profile_id: str = typer.Argument(..., help="EASM profile ID"),
214
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
215
+ ):
216
+ """Activate an EASM profile.
217
+
218
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/activate_deactive_easm_profile.htm
219
+ """
220
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
221
+ data = client.post_json(f"/easm/v2/profile/{profile_id}/activate", {})
222
+ output(data)
223
+
224
+
225
+ @_easm.command("deactivate")
226
+ def easm_deactivate(
227
+ ctx: typer.Context,
228
+ profile_id: str = typer.Argument(..., help="EASM profile ID"),
229
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
230
+ ):
231
+ """Deactivate an EASM profile.
232
+
233
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/activate_deactive_easm_profile.htm
234
+ """
235
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
236
+ data = client.post_json(f"/easm/v2/profile/{profile_id}/deactivate", {})
237
+ output(data)
238
+
239
+
240
+ @_easm.command("orgs")
241
+ def easm_orgs(
242
+ ctx: typer.Context,
243
+ page_number: int = typer.Option(0, "--page-number"),
244
+ page_size: int = typer.Option(50, "--page-size"),
245
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
246
+ format: str = typer.Option("table", "--format", "-f"),
247
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
248
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
249
+ ):
250
+ """List EASM organizations/subsidiaries.
251
+
252
+ Calls POST /rest/2.0/am/organization/list — the legacy GET /easm/v2/org
253
+ endpoint returns 404 on current pods. Same data as `csam org list`.
254
+
255
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/org_list_api.htm
256
+ """
257
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
258
+ params: dict = {"pageNumber": page_number, "pageSize": page_size}
259
+ data = client.post_json("/rest/2.0/am/organization/list", {}, params=params)
260
+ output(data, format=format, limit=limit, output_file=output_file)
261
+
262
+
263
+ # ── vuln ──────────────────────────────────────────────────────────────────────
264
+ _vuln = typer.Typer(help="Vulnerability operations")
265
+ app.add_typer(_vuln, name="vuln")
266
+
267
+
268
+ def _build_filter_body(filters: list[str] | None) -> dict:
269
+ """Translate repeatable ``--filter field=op=value`` flags into the
270
+ Qualys filter body shape ``{"filters": [{field, operator, value}, …]}``.
271
+
272
+ Operator is case-normalised to upper (the API expects e.g. ``EQUALS``,
273
+ ``CONTAINS``, ``IN``, ``GREATER``, ``LESSER``). An empty/None list returns
274
+ ``{}`` so the API's "no filter = match all" semantics are preserved.
275
+ """
276
+ out: dict = {}
277
+ if not filters:
278
+ return out
279
+ parsed: list[dict] = []
280
+ for entry in filters:
281
+ # Allow either "field=op=value" or "field:op:value" for ergonomics.
282
+ sep = "=" if entry.count("=") >= 2 else (":" if entry.count(":") >= 2 else "=")
283
+ parts = entry.split(sep, 2)
284
+ if len(parts) != 3:
285
+ raise typer.BadParameter(
286
+ f"--filter expects field{sep}op{sep}value, got {entry!r}",
287
+ )
288
+ field, op, value = (p.strip() for p in parts)
289
+ if not field or not op or value == "":
290
+ raise typer.BadParameter(f"--filter has empty field/op/value: {entry!r}")
291
+ parsed.append({"field": field, "operator": op.upper(), "value": value})
292
+ out["filters"] = parsed
293
+ return out
294
+
295
+
296
+ @_vuln.command("list-easm")
297
+ def vuln_list_easm(
298
+ ctx: typer.Context,
299
+ filter_: list[str] | None = typer.Option(
300
+ None, "--filter",
301
+ help="Repeatable filter as field=op=value (e.g. vulnerabilities.severity=EQUALS=2)",
302
+ ),
303
+ last_seen_id: str | None = typer.Option(
304
+ None, "--last-seen-id",
305
+ help="Pagination cursor — pass the lastSeenId from the previous response",
306
+ ),
307
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
308
+ format: str = typer.Option("table", "--format", "-f"),
309
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
310
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
311
+ ):
312
+ """List EASM-discovered vulnerabilities.
313
+
314
+ The API is POST /rest/2.0/search/am/easm/vulns (max 1000 records per page,
315
+ paginate with the lastSeenId from each response).
316
+
317
+ Source: https://docs.qualys.com/en/csam/api/vulnerabilities/get_list_easm_discovered_vuln.htm
318
+ """
319
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
320
+ body = _build_filter_body(filter_)
321
+ params: dict = {}
322
+ if last_seen_id is not None:
323
+ params["lastSeenId"] = last_seen_id
324
+ data = client.post_json("/rest/2.0/search/am/easm/vulns", body, params=params)
325
+ output(data, format=format, limit=limit, output_file=output_file)
326
+
327
+
328
+ @_vuln.command("list-scan")
329
+ def vuln_list_scan(
330
+ ctx: typer.Context,
331
+ filter_: list[str] | None = typer.Option(
332
+ None, "--filter",
333
+ help="Repeatable filter as field=op=value (e.g. vulnerabilities.severity=EQUALS=2)",
334
+ ),
335
+ last_seen_id: str | None = typer.Option(
336
+ None, "--last-seen-id",
337
+ help="Pagination cursor — pass the lastSeenId from the previous response",
338
+ ),
339
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
340
+ format: str = typer.Option("table", "--format", "-f"),
341
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
342
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
343
+ ):
344
+ """List vulnerabilities discovered by EASM scan.
345
+
346
+ The API is POST /rest/2.0/search/am/easm/scan/vulns (max 1000 records per
347
+ page, paginate with the lastSeenId from each response).
348
+
349
+ Source: https://docs.qualys.com/en/csam/api/vulnerabilities/vulns_discovered_easm_scan.htm
350
+ """
351
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
352
+ body = _build_filter_body(filter_)
353
+ params: dict = {}
354
+ if last_seen_id is not None:
355
+ params["lastSeenId"] = last_seen_id
356
+ data = client.post_json("/rest/2.0/search/am/easm/scan/vulns", body, params=params)
357
+ output(data, format=format, limit=limit, output_file=output_file)
358
+
359
+
360
+ @_vuln.command("data")
361
+ def vuln_data(
362
+ ctx: typer.Context,
363
+ cpe_ids: str = typer.Option(..., "--cpe-ids", help="Comma-separated CPE IDs (max 100)"),
364
+ cpe_type: str | None = typer.Option(None, "--cpe-type", help="Qualys|NIST"),
365
+ page_number: int = typer.Option(0, "--page-number"),
366
+ page_size: int = typer.Option(50, "--page-size"),
367
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
368
+ format: str = typer.Option("table", "--format", "-f"),
369
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
370
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
371
+ ):
372
+ """Fetch vulnerability data for software/OS CPEs across assets.
373
+
374
+ Source: https://docs.qualys.com/en/csam/api/vulnerabilities/vuln_data_sw_os_across_assets.htm
375
+ """
376
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
377
+ params: dict = {"cpeIds": cpe_ids, "pageNumber": page_number, "pageSize": page_size}
378
+ if cpe_type:
379
+ params["cpeType"] = cpe_type
380
+ data = client.post_json("/rest/2.0/am/catalog/cve/detail", params)
381
+ output(data, format=format, limit=limit, output_file=output_file)
382
+
383
+
384
+ # ── domain ────────────────────────────────────────────────────────────────────
385
+ _domain = typer.Typer(help="Domain operations")
386
+ app.add_typer(_domain, name="domain")
387
+
388
+
389
+ @_domain.command("list")
390
+ def domain_list(
391
+ ctx: typer.Context,
392
+ domain_type: str | None = typer.Option(None, "--type", help="UNRESOLVED_DOMAINS"),
393
+ domain_filter_type: str | None = typer.Option(None, "--filter-type", help="ALL|DOMAIN|SUBDOMAIN"),
394
+ page_size: int = typer.Option(5000, "--page-size"),
395
+ last_fetch_domain_id: int | None = typer.Option(None, "--last-id", help="Paginate from this domain ID"),
396
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
397
+ format: str = typer.Option("table", "--format", "-f"),
398
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
399
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
400
+ ):
401
+ """List unresolved and typosquatted domains discovered by EASM.
402
+
403
+ Source: https://docs.qualys.com/en/csam/api/domains/get_list_of_unresolved_domains.htm
404
+ """
405
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
406
+ body: dict = {"pageSize": page_size}
407
+ if domain_type:
408
+ body["domainType"] = domain_type
409
+ if domain_filter_type:
410
+ body["domainFilterType"] = domain_filter_type
411
+ if last_fetch_domain_id is not None:
412
+ body["lastFetchDomainID"] = last_fetch_domain_id
413
+ data = client.post_json("/rest/2.0/am/domain/list", body)
414
+ output(data, format=format, limit=limit, output_file=output_file)
415
+
416
+
417
+ @_domain.command("count")
418
+ def domain_count(
419
+ ctx: typer.Context,
420
+ domain_type: str | None = typer.Option(None, "--type", help="UNRESOLVED_DOMAINS"),
421
+ domain_filter_type: str | None = typer.Option(None, "--filter-type", help="ALL|DOMAIN|SUBDOMAIN"),
422
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
423
+ format: str = typer.Option("table", "--format", "-f"),
424
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
425
+ ):
426
+ """Get count of unresolved domains.
427
+
428
+ Source: https://docs.qualys.com/en/csam/api/domains/get_count_of_unresolved_domains.htm
429
+ """
430
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
431
+ body: dict = {}
432
+ if domain_type:
433
+ body["domainType"] = domain_type
434
+ if domain_filter_type:
435
+ body["domainFilterType"] = domain_filter_type
436
+ data = client.post_json("/rest/2.0/am/domain/count", body)
437
+ output(data, format=format, output_file=output_file)
438
+
439
+
440
+ # ── report ────────────────────────────────────────────────────────────────────
441
+ _report = typer.Typer(help="Report operations")
442
+ app.add_typer(_report, name="report")
443
+
444
+
445
+ @_report.command("create")
446
+ def report_create(
447
+ ctx: typer.Context,
448
+ name: str = typer.Option(..., "--name", help="Report name"),
449
+ report_format: str = typer.Option(..., "--format-type", help="Output format e.g. PDF"),
450
+ template_name: str = typer.Option(..., "--template", help="e.g. EASM_LEAD"),
451
+ report_type: str = typer.Option(..., "--report-type", help="e.g. EASM_LEAD"),
452
+ description: str | None = typer.Option(None, "--description"),
453
+ query: str | None = typer.Option(None, "--query"),
454
+ format: str = typer.Option("table", "--format", "-f"),
455
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
456
+ ):
457
+ """Create an EASM summary report.
458
+
459
+ Source: https://docs.qualys.com/en/csam/api/reports/create_reports_api.htm
460
+ """
461
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
462
+ body: dict = {
463
+ "name": name,
464
+ "format": report_format,
465
+ "templateName": template_name,
466
+ "reportType": report_type,
467
+ }
468
+ if description:
469
+ body["description"] = description
470
+ if query:
471
+ body["query"] = query
472
+ data = client.post_json("/rest/2.0/am/report", body)
473
+ output(data, format=format)
474
+
475
+
476
+ @_report.command("download")
477
+ def report_download(
478
+ ctx: typer.Context,
479
+ report_name: str = typer.Option(..., "--name", help="Exact report name"),
480
+ output_file: str = typer.Option("csam-report.pdf", "--output-file", "-o"),
481
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
482
+ ):
483
+ """Download a completed report by name.
484
+
485
+ Source: https://docs.qualys.com/en/csam/api/reports/download_reports.htm
486
+ """
487
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
488
+ resp = client.request("GET", f"/rest/2.0/am/report/download?reportName={report_name}")
489
+ from pathlib import Path
490
+ Path(output_file).write_bytes(resp.content)
491
+ output_success(f"Report saved to {output_file} ({len(resp.content):,} bytes)")
492
+
493
+
494
+ # ── component (software components on assets) ────────────────────────────────
495
+ _component = typer.Typer(help="Software component (SwCA) operations across assets")
496
+ app.add_typer(_component, name="component")
497
+
498
+
499
+ def _filter_body(filter_json: str | None) -> dict:
500
+ """Parse a --filter argument as JSON (object or array) into a request body."""
501
+ body: dict = {}
502
+ if not filter_json:
503
+ return body
504
+ import json as _json
505
+ try:
506
+ parsed = _json.loads(filter_json)
507
+ except _json.JSONDecodeError:
508
+ body["filterXml"] = filter_json
509
+ return body
510
+ if isinstance(parsed, list):
511
+ body["filters"] = parsed
512
+ elif isinstance(parsed, dict):
513
+ body.update(parsed)
514
+ return body
515
+
516
+
517
+ @_component.command("list")
518
+ def component_list(
519
+ ctx: typer.Context,
520
+ filter_json: str | None = typer.Option(None, "--filter", help="JSON filter body"),
521
+ page_number: int = typer.Option(0, "--page-number"),
522
+ page_size: int = typer.Option(50, "--page-size"),
523
+ last_seen_id: int | None = typer.Option(None, "--last-seen-id", help="Paginate from this component ID"),
524
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
525
+ format: str = typer.Option("table", "--format", "-f"),
526
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
527
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
528
+ ):
529
+ """List software components (SwCA) discovered across assets.
530
+
531
+ Source: https://docs.qualys.com/en/csam/api/software_component/get_list_of_all_software_components.htm
532
+ """
533
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
534
+ params: dict = {"pageNumber": page_number, "pageSize": page_size}
535
+ if last_seen_id is not None:
536
+ params["lastSeenComponentId"] = last_seen_id
537
+ body = _filter_body(filter_json)
538
+ data = client.post_json("/rest/2.0/am/asset/component", body, params=params)
539
+ output(data, format=format, limit=limit, output_file=output_file)
540
+
541
+
542
+ @_component.command("list-by-asset")
543
+ def component_list_by_asset(
544
+ ctx: typer.Context,
545
+ asset_id: str = typer.Argument(..., help="Asset ID to fetch components for"),
546
+ filter_json: str | None = typer.Option(None, "--filter", help="JSON filter body"),
547
+ page_number: int = typer.Option(0, "--page-number"),
548
+ page_size: int = typer.Option(50, "--page-size"),
549
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
550
+ format: str = typer.Option("table", "--format", "-f"),
551
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
552
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
553
+ ):
554
+ """List software components (SwCA) for a single asset.
555
+
556
+ Source: https://docs.qualys.com/en/csam/api/software_component/get_list_of_software_components_for_specific_asset.htm
557
+ """
558
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
559
+ params: dict = {"pageNumber": page_number, "pageSize": page_size}
560
+ body = _filter_body(filter_json)
561
+ data = client.post_json(f"/rest/2.0/am/asset/component/{asset_id}", body, params=params)
562
+ output(data, format=format, limit=limit, output_file=output_file)
563
+
564
+
565
+ # ── business (business metadata import for assets / business apps) ───────────
566
+ _business = typer.Typer(help="Business metadata import (asset criticality, owner, etc.)")
567
+ app.add_typer(_business, name="business")
568
+
569
+
570
+ @_business.command("import-asset")
571
+ def business_import_asset(
572
+ ctx: typer.Context,
573
+ input_file: str = typer.Argument(..., help="CSV/JSON file with asset business metadata"),
574
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
575
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
576
+ ):
577
+ """Import business metadata (criticality, owner, environment, etc.) for assets.
578
+
579
+ Source: https://docs.qualys.com/en/csam/api/business_information/import_asset_business_metadata.htm
580
+ """
581
+ if not yes:
582
+ typer.confirm(f"Import asset business metadata from '{input_file}'?", abort=True)
583
+ from pathlib import Path
584
+ p = Path(input_file)
585
+ payload = p.read_bytes()
586
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
587
+ mime = "text/csv" if p.suffix.lower() == ".csv" else "application/json"
588
+ resp = client.request(
589
+ "POST", "/rest/2.0/update/am/asset/business/metadata",
590
+ files={"file": (p.name, payload, mime)},
591
+ )
592
+ if resp.content:
593
+ try:
594
+ output(resp.json())
595
+ except ValueError:
596
+ output_success(f"Imported {len(payload):,} bytes from '{input_file}'.")
597
+ else:
598
+ output_success(f"Imported {len(payload):,} bytes from '{input_file}'.")
599
+
600
+
601
+ @_business.command("import-app")
602
+ def business_import_app(
603
+ ctx: typer.Context,
604
+ input_file: str = typer.Argument(..., help="CSV/JSON file with business application metadata"),
605
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
606
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
607
+ ):
608
+ """Upsert business application metadata (apps and asset linkages).
609
+
610
+ Source: https://docs.qualys.com/en/csam/api/business_information/import_business_app_metadata.htm
611
+ """
612
+ if not yes:
613
+ typer.confirm(f"Import business app metadata from '{input_file}'?", abort=True)
614
+ from pathlib import Path
615
+ p = Path(input_file)
616
+ payload = p.read_bytes()
617
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
618
+ mime = "text/csv" if p.suffix.lower() == ".csv" else "application/json"
619
+ resp = client.request(
620
+ "POST", "/rest/2.0/upsert/am/businessapp/metadata",
621
+ files={"file": (p.name, payload, mime)},
622
+ )
623
+ if resp.content:
624
+ try:
625
+ output(resp.json())
626
+ except ValueError:
627
+ output_success(f"Imported {len(payload):,} bytes from '{input_file}'.")
628
+ else:
629
+ output_success(f"Imported {len(payload):,} bytes from '{input_file}'.")
630
+
631
+
632
+ # ── connector (third-party / webhook asset ingest) ────────────────────────────
633
+ _connector = typer.Typer(help="Third-party connector ingestion (webhook asset/finding sync)")
634
+ app.add_typer(_connector, name="connector")
635
+
636
+
637
+ @_connector.command("sync")
638
+ def connector_sync(
639
+ ctx: typer.Context,
640
+ input_file: str = typer.Argument(..., help="JSON payload describing third-party assets/findings"),
641
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
642
+ ):
643
+ """Push third-party assets/findings into CSAM via the webhook connector.
644
+
645
+ Source: https://docs.qualys.com/en/csam/api/third_party/import_third_party_assets.htm
646
+ """
647
+ import json as _json
648
+ from pathlib import Path
649
+ payload_text = Path(input_file).read_text()
650
+ body = _json.loads(payload_text)
651
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
652
+ data = client.post_json("/rest/2.0/am/connector/asset/data/sync", body)
653
+ output(data)
654
+
655
+
656
+ # ── org (organization / subsidiary search) ───────────────────────────────────
657
+ _org = typer.Typer(help="Organization / subsidiary search (CSAM 3.7+)")
658
+ app.add_typer(_org, name="org")
659
+
660
+
661
+ @_org.command("list")
662
+ def org_list(
663
+ ctx: typer.Context,
664
+ filter_json: str | None = typer.Option(None, "--filter", help="JSON filter body"),
665
+ page_number: int = typer.Option(0, "--page-number"),
666
+ page_size: int = typer.Option(50, "--page-size"),
667
+ limit: int = typer.Option(DEFAULT_LIMIT, "--limit", "-n"),
668
+ format: str = typer.Option("table", "--format", "-f"),
669
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
670
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
671
+ ):
672
+ """Search organizations and subsidiaries (POST search).
673
+
674
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/org_list_api.htm
675
+ """
676
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
677
+ params: dict = {"pageNumber": page_number, "pageSize": page_size}
678
+ body = _filter_body(filter_json)
679
+ data = client.post_json("/rest/2.0/am/organization/list", body, params=params)
680
+ output(data, format=format, limit=limit, output_file=output_file)
681
+
682
+
683
+ @_org.command("count")
684
+ def org_count(
685
+ ctx: typer.Context,
686
+ filter_json: str | None = typer.Option(None, "--filter", help="JSON filter body"),
687
+ format: str = typer.Option("table", "--format", "-f"),
688
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
689
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
690
+ ):
691
+ """Count organizations and subsidiaries matching a filter.
692
+
693
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/org_count_api.htm
694
+ """
695
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
696
+ body = _filter_body(filter_json)
697
+ data = client.post_json("/rest/2.0/am/organization/count", body)
698
+ output(data, format=format, output_file=output_file)
699
+
700
+
701
+ # ── easm: additive (status + PATCH partial update; existing commands untouched)
702
+ @_easm.command("status")
703
+ def easm_status(
704
+ ctx: typer.Context,
705
+ profile_name: str = typer.Option(..., "--name", help="EASM profile name"),
706
+ format: str = typer.Option("table", "--format", "-f"),
707
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
708
+ ):
709
+ """Get EASM discovery status for a profile.
710
+
711
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/get_easm_profile_discovery_status.htm
712
+ """
713
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
714
+ data = client.get_json("/easm/v2/profile/status", {"profileName": profile_name})
715
+ output(data, format=format)
716
+
717
+
718
+ @_easm.command("patch")
719
+ def easm_patch(
720
+ ctx: typer.Context,
721
+ profile_name: str = typer.Argument(..., help="EASM profile name"),
722
+ body_json: str = typer.Option(..., "--body", help="Raw JSON body for the PATCH request"),
723
+ format: str = typer.Option("table", "--format", "-f"),
724
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
725
+ ):
726
+ """Partially update an EASM profile via PATCH (additive — does not affect `easm update`).
727
+
728
+ Source: https://docs.qualys.com/en/csam/api/easm_apis/patch_easm_profile_data.htm
729
+ """
730
+ import json as _json
731
+ body = _json.loads(body_json)
732
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
733
+ data = client.patch_json(f"/easm/v2/profile/{profile_name}", body)
734
+ output(data, format=format)
735
+
736
+
737
+ # ── asset: additive ServiceNow-style export ──────────────────────────────────
738
+ @_asset.command("sn-export")
739
+ def asset_sn_export(
740
+ ctx: typer.Context,
741
+ asset_id: str = typer.Argument(..., help="Asset ID"),
742
+ format: str = typer.Option("json", "--format", "-f"),
743
+ output_file: str | None = typer.Option(None, "--output-file", "-o"),
744
+ verbose: int = typer.Option(0, "--verbose", "-v", count=True),
745
+ ):
746
+ """Export an asset in ServiceNow-style format including software-instance details.
747
+
748
+ Source: https://docs.qualys.com/en/csam/release-notes/cybersecurity_asset_management/release_3_7_api.htm
749
+ """
750
+ client = _c.make(ctx.obj, auth_type="jwt", verbose=verbose, use_gateway=True)
751
+ data = client.get_json(f"/am/v2/sn/assets/host/{asset_id}")
752
+ output(data, format=format, output_file=output_file)