qualys-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qualys_cli/__init__.py +1 -0
- qualys_cli/__main__.py +8 -0
- qualys_cli/audit.py +616 -0
- qualys_cli/auth.py +187 -0
- qualys_cli/cache.py +123 -0
- qualys_cli/cli.py +1168 -0
- qualys_cli/client.py +1043 -0
- qualys_cli/commands/__init__.py +0 -0
- qualys_cli/commands/asset.py +183 -0
- qualys_cli/commands/ca.py +403 -0
- qualys_cli/commands/cs.py +410 -0
- qualys_cli/commands/csam.py +752 -0
- qualys_cli/commands/etm.py +170 -0
- qualys_cli/commands/pc.py +255 -0
- qualys_cli/commands/pm.py +412 -0
- qualys_cli/commands/scanauth.py +291 -0
- qualys_cli/commands/sub.py +163 -0
- qualys_cli/commands/tc.py +539 -0
- qualys_cli/commands/user.py +104 -0
- qualys_cli/commands/vm.py +562 -0
- qualys_cli/commands/was.py +1278 -0
- qualys_cli/config.py +331 -0
- qualys_cli/extras.py +702 -0
- qualys_cli/flair.py +202 -0
- qualys_cli/formatters.py +896 -0
- qualys_cli/history.py +133 -0
- qualys_cli/http_server.py +126 -0
- qualys_cli/mcp_server.py +341 -0
- qualys_cli/metrics.py +106 -0
- qualys_cli/platforms.py +67 -0
- qualys_cli/queries.py +137 -0
- qualys_cli-0.1.1.data/data/share/qualys-cli/docs/usage.html +1230 -0
- qualys_cli-0.1.1.dist-info/METADATA +319 -0
- qualys_cli-0.1.1.dist-info/RECORD +37 -0
- qualys_cli-0.1.1.dist-info/WHEEL +4 -0
- qualys_cli-0.1.1.dist-info/entry_points.txt +2 -0
- qualys_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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)
|