cylera-cli 1.1.0__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.
cylera.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Cylera CLI - Command line interface for the Cylera Partner API.
|
|
4
|
+
|
|
5
|
+
Usage: cylera <command> [arguments...]
|
|
6
|
+
|
|
7
|
+
This CLI provides read-only access to the Cylera Partner API for querying
|
|
8
|
+
device inventory, threats, vulnerabilities, and network information.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Annotated, Any, Optional
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from dotenv import load_dotenv
|
|
20
|
+
|
|
21
|
+
from cylera_client import (
|
|
22
|
+
CyleraClient,
|
|
23
|
+
Inventory,
|
|
24
|
+
Network,
|
|
25
|
+
Organization,
|
|
26
|
+
Risk,
|
|
27
|
+
Threat,
|
|
28
|
+
Utilization,
|
|
29
|
+
CyleraAPIError,
|
|
30
|
+
CyleraAuthError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Available Cylera API endpoints
|
|
34
|
+
CYLERA_URLS = [
|
|
35
|
+
"https://partner.us1.cylera.com/",
|
|
36
|
+
"https://partner.uk1.cylera.com/",
|
|
37
|
+
"https://partner.demo.cylera.com/",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
name="cylera",
|
|
42
|
+
help="Cylera CLI - Command line interface for the Cylera Partner API",
|
|
43
|
+
add_completion=False,
|
|
44
|
+
no_args_is_help=True,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_json(data: Any) -> None:
|
|
49
|
+
"""Print data as formatted JSON."""
|
|
50
|
+
print(json.dumps(data, indent=2))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_client() -> CyleraClient:
|
|
54
|
+
"""Create and return a CyleraClient using environment variables."""
|
|
55
|
+
base_url = os.environ.get("CYLERA_BASE_URL")
|
|
56
|
+
username = os.environ.get("CYLERA_USERNAME")
|
|
57
|
+
password = os.environ.get("CYLERA_PASSWORD")
|
|
58
|
+
|
|
59
|
+
if not base_url or not username or not password:
|
|
60
|
+
print(
|
|
61
|
+
"Error: Missing required environment variables.\n"
|
|
62
|
+
"Please set CYLERA_BASE_URL, CYLERA_USERNAME, and CYLERA_PASSWORD\n"
|
|
63
|
+
"in your environment or .env file.",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
|
|
68
|
+
return CyleraClient(username=username, password=password, base_url=base_url)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check_environment() -> bool:
|
|
72
|
+
"""Check if required environment variables are set."""
|
|
73
|
+
load_dotenv(Path.cwd() / ".env")
|
|
74
|
+
base_url = os.environ.get("CYLERA_BASE_URL")
|
|
75
|
+
username = os.environ.get("CYLERA_USERNAME")
|
|
76
|
+
password = os.environ.get("CYLERA_PASSWORD")
|
|
77
|
+
return bool(base_url and username and password)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def require_config() -> None:
|
|
81
|
+
"""Check that CLI is configured, exit with message if not."""
|
|
82
|
+
if not check_environment():
|
|
83
|
+
print(
|
|
84
|
+
f"Cylera CLI is not configured.\n\n"
|
|
85
|
+
f"Current directory: {Path.cwd()}\n\n"
|
|
86
|
+
"Run 'cylera init' to set up your credentials.",
|
|
87
|
+
file=sys.stderr,
|
|
88
|
+
)
|
|
89
|
+
raise typer.Exit(1)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Common option types
|
|
93
|
+
PageOption = Annotated[Optional[int], typer.Option(
|
|
94
|
+
help="Page number for pagination")]
|
|
95
|
+
PageSizeOption = Annotated[
|
|
96
|
+
Optional[int], typer.Option(
|
|
97
|
+
"--page-size", help="Results per page (max 100)")
|
|
98
|
+
]
|
|
99
|
+
MacAddressOption = Annotated[
|
|
100
|
+
Optional[str], typer.Option("--mac-address", help="MAC address of device")
|
|
101
|
+
]
|
|
102
|
+
SeverityOption = Annotated[
|
|
103
|
+
Optional[str],
|
|
104
|
+
typer.Option(help="Severity level: INFO, LOW, MEDIUM, HIGH, CRITICAL"),
|
|
105
|
+
]
|
|
106
|
+
StatusOption = Annotated[
|
|
107
|
+
Optional[str],
|
|
108
|
+
typer.Option(help="Status: OPEN, IN_PROGRESS, RESOLVED, SUPPRESSED"),
|
|
109
|
+
]
|
|
110
|
+
DetectedAfterOption = Annotated[
|
|
111
|
+
Optional[int], typer.Option(
|
|
112
|
+
"--detected-after", help="Epoch timestamp filter")
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_existing_config() -> None:
|
|
117
|
+
"""Exit with error if Cylera environment variables are already set."""
|
|
118
|
+
var_names = ["CYLERA_BASE_URL", "CYLERA_USERNAME", "CYLERA_PASSWORD"]
|
|
119
|
+
existing_vars = [v for v in var_names if os.environ.get(v)]
|
|
120
|
+
if existing_vars:
|
|
121
|
+
print(
|
|
122
|
+
"Error: The following environment variables are already set:\n"
|
|
123
|
+
f" {', '.join(existing_vars)}\n\n"
|
|
124
|
+
"To reconfigure, unset these variables first or delete the .env file.\n"
|
|
125
|
+
"Example: unset CYLERA_BASE_URL CYLERA_USERNAME CYLERA_PASSWORD",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
raise typer.Exit(1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _prompt_base_url() -> str:
|
|
132
|
+
"""Prompt user to select a Cylera API endpoint and return the URL."""
|
|
133
|
+
print("Select your Cylera API endpoint:")
|
|
134
|
+
for i, url in enumerate(CYLERA_URLS, 1):
|
|
135
|
+
print(f" {i}. {url}")
|
|
136
|
+
print()
|
|
137
|
+
|
|
138
|
+
while True:
|
|
139
|
+
try:
|
|
140
|
+
choice = input(f"Enter choice [1-{len(CYLERA_URLS)}]: ").strip()
|
|
141
|
+
choice_num = int(choice)
|
|
142
|
+
if 1 <= choice_num <= len(CYLERA_URLS):
|
|
143
|
+
return CYLERA_URLS[choice_num - 1]
|
|
144
|
+
print(f"Please enter a number between 1 and {len(CYLERA_URLS)}")
|
|
145
|
+
except ValueError:
|
|
146
|
+
print("Please enter a valid number")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _test_auth(username: str, password: str, base_url: str) -> None:
|
|
150
|
+
"""Test authentication and print the response. Exits on failure."""
|
|
151
|
+
print("Testing authentication...", end=" ", flush=True)
|
|
152
|
+
try:
|
|
153
|
+
client = CyleraClient(
|
|
154
|
+
username=username, password=password, base_url=base_url)
|
|
155
|
+
auth_response = client.test_authenticate()
|
|
156
|
+
client.close()
|
|
157
|
+
except CyleraAuthError as e:
|
|
158
|
+
print("Failed!")
|
|
159
|
+
print(f"\nAuthentication error: {e}", file=sys.stderr)
|
|
160
|
+
print("\nPlease check your credentials and try again.", file=sys.stderr)
|
|
161
|
+
raise typer.Exit(1)
|
|
162
|
+
|
|
163
|
+
print("Success!")
|
|
164
|
+
print()
|
|
165
|
+
print("Authentication response:")
|
|
166
|
+
for key, value in auth_response.items():
|
|
167
|
+
if key != "token":
|
|
168
|
+
print(f" {key}: {value}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _save_env_config(base_url: str, username: str, password: str) -> Path:
|
|
172
|
+
"""Append Cylera config to the .env file and return the path."""
|
|
173
|
+
env_path = Path.cwd() / ".env"
|
|
174
|
+
|
|
175
|
+
existing_content = ""
|
|
176
|
+
if env_path.exists():
|
|
177
|
+
existing_content = env_path.read_text()
|
|
178
|
+
if existing_content and not existing_content.endswith("\n"):
|
|
179
|
+
existing_content += "\n"
|
|
180
|
+
|
|
181
|
+
cylera_config = (
|
|
182
|
+
f"\n# Cylera CLI Configuration\n"
|
|
183
|
+
f"CYLERA_BASE_URL={base_url}\n"
|
|
184
|
+
f"CYLERA_USERNAME={username}\n"
|
|
185
|
+
f"CYLERA_PASSWORD={password}\n"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
env_path.write_text(existing_content + cylera_config)
|
|
189
|
+
return env_path
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command()
|
|
193
|
+
def init() -> None:
|
|
194
|
+
"""Initialize Cylera CLI configuration interactively."""
|
|
195
|
+
load_dotenv()
|
|
196
|
+
_check_existing_config()
|
|
197
|
+
|
|
198
|
+
print("Cylera CLI Configuration")
|
|
199
|
+
print("=" * 40)
|
|
200
|
+
print()
|
|
201
|
+
|
|
202
|
+
base_url = _prompt_base_url()
|
|
203
|
+
print()
|
|
204
|
+
|
|
205
|
+
username = input("Enter your Cylera username (email): ").strip()
|
|
206
|
+
if not username:
|
|
207
|
+
print("Error: Username cannot be empty", file=sys.stderr)
|
|
208
|
+
raise typer.Exit(1)
|
|
209
|
+
|
|
210
|
+
print()
|
|
211
|
+
|
|
212
|
+
password = getpass.getpass("Enter your Cylera password: ")
|
|
213
|
+
if not password:
|
|
214
|
+
print("Error: Password cannot be empty", file=sys.stderr)
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
|
|
217
|
+
print()
|
|
218
|
+
_test_auth(username, password, base_url)
|
|
219
|
+
|
|
220
|
+
env_path = _save_env_config(base_url, username, password)
|
|
221
|
+
|
|
222
|
+
print()
|
|
223
|
+
print(f"Configuration saved to {env_path}")
|
|
224
|
+
print()
|
|
225
|
+
print("You can now use the Cylera CLI. Try:")
|
|
226
|
+
print(" cylera devices --page-size 5")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@app.command()
|
|
230
|
+
def organization() -> None:
|
|
231
|
+
"""Get the organization associated with the current credentials."""
|
|
232
|
+
require_config()
|
|
233
|
+
try:
|
|
234
|
+
with get_client() as client:
|
|
235
|
+
result = Organization(client).get_organization()
|
|
236
|
+
print_json(result)
|
|
237
|
+
except CyleraAPIError as e:
|
|
238
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
239
|
+
raise typer.Exit(1)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def organizations() -> None:
|
|
244
|
+
"""List organizations available to switch into."""
|
|
245
|
+
require_config()
|
|
246
|
+
try:
|
|
247
|
+
with get_client() as client:
|
|
248
|
+
result = Organization(client).get_available_organizations()
|
|
249
|
+
print_json(result)
|
|
250
|
+
except CyleraAPIError as e:
|
|
251
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
252
|
+
raise typer.Exit(1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@app.command()
|
|
256
|
+
def switchorg(
|
|
257
|
+
organization_id: Annotated[str, typer.Argument(help="Organization ID to switch into")],
|
|
258
|
+
) -> None:
|
|
259
|
+
"""Switch to a different organization."""
|
|
260
|
+
require_config()
|
|
261
|
+
try:
|
|
262
|
+
with get_client() as client:
|
|
263
|
+
result = Organization(client).switch_organization(organization_id)
|
|
264
|
+
print_json(result)
|
|
265
|
+
except CyleraAPIError as e:
|
|
266
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
267
|
+
raise typer.Exit(1)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@app.command()
|
|
271
|
+
def resetorg() -> None:
|
|
272
|
+
"""Reset organization back to home."""
|
|
273
|
+
require_config()
|
|
274
|
+
try:
|
|
275
|
+
with get_client() as client:
|
|
276
|
+
result = Organization(client).reset_organization()
|
|
277
|
+
print_json(result)
|
|
278
|
+
except CyleraAPIError as e:
|
|
279
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
280
|
+
raise typer.Exit(1)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@app.command()
|
|
284
|
+
def device(
|
|
285
|
+
device_id: Annotated[str, typer.Argument(help="MAC address of the device")],
|
|
286
|
+
) -> None:
|
|
287
|
+
"""Get details for a specific device by MAC address."""
|
|
288
|
+
require_config()
|
|
289
|
+
try:
|
|
290
|
+
with get_client() as client:
|
|
291
|
+
result = Inventory(client).get_device(device_id)
|
|
292
|
+
print_json(result)
|
|
293
|
+
except CyleraAPIError as e:
|
|
294
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
295
|
+
raise typer.Exit(1)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@app.command()
|
|
299
|
+
def devices(
|
|
300
|
+
aetitle: Annotated[Optional[str], typer.Option(
|
|
301
|
+
help="Complete AE Title")] = None,
|
|
302
|
+
device_class: Annotated[
|
|
303
|
+
Optional[str],
|
|
304
|
+
typer.Option(
|
|
305
|
+
"--class", help="Device class (Medical, Infrastructure, etc.)"),
|
|
306
|
+
] = None,
|
|
307
|
+
hostname: Annotated[Optional[str], typer.Option(
|
|
308
|
+
help="Complete hostname")] = None,
|
|
309
|
+
ip_address: Annotated[
|
|
310
|
+
Optional[str], typer.Option(
|
|
311
|
+
"--ip-address", help="Partial or complete IP")
|
|
312
|
+
] = None,
|
|
313
|
+
mac_address: MacAddressOption = None,
|
|
314
|
+
model: Annotated[Optional[str], typer.Option(help="Device model")] = None,
|
|
315
|
+
os: Annotated[Optional[str], typer.Option(help="Operating system")] = None,
|
|
316
|
+
page: PageOption = None,
|
|
317
|
+
page_size: PageSizeOption = None,
|
|
318
|
+
serial_number: Annotated[
|
|
319
|
+
Optional[str], typer.Option(
|
|
320
|
+
"--serial-number", help="Complete serial number")
|
|
321
|
+
] = None,
|
|
322
|
+
since_last_seen: Annotated[
|
|
323
|
+
Optional[int],
|
|
324
|
+
typer.Option("--since-last-seen",
|
|
325
|
+
help="[DEPRECATED] Seconds since last seen"),
|
|
326
|
+
] = None,
|
|
327
|
+
device_type: Annotated[
|
|
328
|
+
Optional[str], typer.Option(
|
|
329
|
+
"--type", help="Device type (EEG, X-Ray, etc.)")
|
|
330
|
+
] = None,
|
|
331
|
+
vendor: Annotated[Optional[str], typer.Option(
|
|
332
|
+
help="Device vendor")] = None,
|
|
333
|
+
first_seen_before: Annotated[
|
|
334
|
+
Optional[int], typer.Option(
|
|
335
|
+
"--first-seen-before", help="Epoch timestamp")
|
|
336
|
+
] = None,
|
|
337
|
+
first_seen_after: Annotated[
|
|
338
|
+
Optional[int], typer.Option(
|
|
339
|
+
"--first-seen-after", help="Epoch timestamp")
|
|
340
|
+
] = None,
|
|
341
|
+
last_seen_before: Annotated[
|
|
342
|
+
Optional[int], typer.Option(
|
|
343
|
+
"--last-seen-before", help="Epoch timestamp")
|
|
344
|
+
] = None,
|
|
345
|
+
last_seen_after: Annotated[
|
|
346
|
+
Optional[int], typer.Option(
|
|
347
|
+
"--last-seen-after", help="Epoch timestamp")
|
|
348
|
+
] = None,
|
|
349
|
+
attribute_label: Annotated[
|
|
350
|
+
Optional[str], typer.Option(
|
|
351
|
+
"--attribute-label", help="Attribute label filter")
|
|
352
|
+
] = None,
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Get a list of devices with optional filters."""
|
|
355
|
+
require_config()
|
|
356
|
+
try:
|
|
357
|
+
with get_client() as client:
|
|
358
|
+
result = Inventory(client).get_devices(
|
|
359
|
+
aetitle=aetitle,
|
|
360
|
+
device_class=device_class,
|
|
361
|
+
hostname=hostname,
|
|
362
|
+
ip_address=ip_address,
|
|
363
|
+
mac_address=mac_address,
|
|
364
|
+
model=model,
|
|
365
|
+
os=os,
|
|
366
|
+
page=page,
|
|
367
|
+
page_size=page_size,
|
|
368
|
+
serial_number=serial_number,
|
|
369
|
+
since_last_seen=since_last_seen,
|
|
370
|
+
device_type=device_type,
|
|
371
|
+
vendor=vendor,
|
|
372
|
+
first_seen_before=first_seen_before,
|
|
373
|
+
first_seen_after=first_seen_after,
|
|
374
|
+
last_seen_before=last_seen_before,
|
|
375
|
+
last_seen_after=last_seen_after,
|
|
376
|
+
attribute_label=attribute_label,
|
|
377
|
+
)
|
|
378
|
+
print_json(result)
|
|
379
|
+
except CyleraAPIError as e:
|
|
380
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
381
|
+
raise typer.Exit(1)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@app.command()
|
|
385
|
+
def deviceattributes(
|
|
386
|
+
mac_address: Annotated[str, typer.Argument(help="MAC address of the device")],
|
|
387
|
+
) -> None:
|
|
388
|
+
"""Get attributes for a device by MAC address."""
|
|
389
|
+
require_config()
|
|
390
|
+
try:
|
|
391
|
+
with get_client() as client:
|
|
392
|
+
result = Inventory(client).get_device_attributes(mac_address)
|
|
393
|
+
print_json(result)
|
|
394
|
+
except CyleraAPIError as e:
|
|
395
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
396
|
+
raise typer.Exit(1)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.command()
|
|
400
|
+
def procedures(
|
|
401
|
+
procedure_name: Annotated[
|
|
402
|
+
Optional[str],
|
|
403
|
+
typer.Option("--procedure-name",
|
|
404
|
+
help="Procedure name (partial match)"),
|
|
405
|
+
] = None,
|
|
406
|
+
accession_number: Annotated[
|
|
407
|
+
Optional[str], typer.Option(
|
|
408
|
+
"--accession-number", help="Accession number")
|
|
409
|
+
] = None,
|
|
410
|
+
device_uuid: Annotated[
|
|
411
|
+
Optional[str], typer.Option("--device-uuid", help="Device UUID")
|
|
412
|
+
] = None,
|
|
413
|
+
completed_after: Annotated[
|
|
414
|
+
Optional[str], typer.Option(
|
|
415
|
+
"--completed-after", help="Date (YYYY/MM/DD)")
|
|
416
|
+
] = None,
|
|
417
|
+
page: PageOption = None,
|
|
418
|
+
page_size: PageSizeOption = None,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Get a list of medical procedures."""
|
|
421
|
+
require_config()
|
|
422
|
+
try:
|
|
423
|
+
with get_client() as client:
|
|
424
|
+
result = Utilization(client).get_procedures(
|
|
425
|
+
procedure_name=procedure_name,
|
|
426
|
+
accession_number=accession_number,
|
|
427
|
+
device_uuid=device_uuid,
|
|
428
|
+
completed_after=completed_after,
|
|
429
|
+
page=page,
|
|
430
|
+
page_size=page_size,
|
|
431
|
+
)
|
|
432
|
+
print_json(result)
|
|
433
|
+
except CyleraAPIError as e:
|
|
434
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
435
|
+
raise typer.Exit(1)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@app.command()
|
|
439
|
+
def subnets(
|
|
440
|
+
cidr_range: Annotated[
|
|
441
|
+
Optional[str], typer.Option(
|
|
442
|
+
"--cidr-range", help="CIDR range (partial match)")
|
|
443
|
+
] = None,
|
|
444
|
+
description: Annotated[
|
|
445
|
+
Optional[str], typer.Option(help="Subnet description")
|
|
446
|
+
] = None,
|
|
447
|
+
vlan: Annotated[Optional[int], typer.Option(help="VLAN number")] = None,
|
|
448
|
+
page: PageOption = None,
|
|
449
|
+
page_size: PageSizeOption = None,
|
|
450
|
+
) -> None:
|
|
451
|
+
"""Get a list of network subnets."""
|
|
452
|
+
require_config()
|
|
453
|
+
try:
|
|
454
|
+
with get_client() as client:
|
|
455
|
+
result = Network(client).get_subnets(
|
|
456
|
+
cidr_range=cidr_range,
|
|
457
|
+
description=description,
|
|
458
|
+
vlan=vlan,
|
|
459
|
+
page=page,
|
|
460
|
+
page_size=page_size,
|
|
461
|
+
)
|
|
462
|
+
print_json(result)
|
|
463
|
+
except CyleraAPIError as e:
|
|
464
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
465
|
+
raise typer.Exit(1)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
@app.command()
|
|
469
|
+
def riskmitigations(
|
|
470
|
+
vulnerability: Annotated[str, typer.Argument(help="Name of the vulnerability")],
|
|
471
|
+
) -> None:
|
|
472
|
+
"""Get mitigations for a specific vulnerability."""
|
|
473
|
+
require_config()
|
|
474
|
+
try:
|
|
475
|
+
with get_client() as client:
|
|
476
|
+
result = Risk(client).get_mitigations(vulnerability)
|
|
477
|
+
print_json(result)
|
|
478
|
+
except CyleraAPIError as e:
|
|
479
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
480
|
+
raise typer.Exit(1)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
@app.command()
|
|
484
|
+
def vulnerabilities(
|
|
485
|
+
confidence: Annotated[
|
|
486
|
+
Optional[str], typer.Option(help="Confidence: LOW, MEDIUM, HIGH")
|
|
487
|
+
] = None,
|
|
488
|
+
detected_after: DetectedAfterOption = None,
|
|
489
|
+
mac_address: MacAddressOption = None,
|
|
490
|
+
name: Annotated[
|
|
491
|
+
Optional[str], typer.Option(help="Vulnerability name (partial match)")
|
|
492
|
+
] = None,
|
|
493
|
+
page: PageOption = None,
|
|
494
|
+
page_size: PageSizeOption = None,
|
|
495
|
+
severity: SeverityOption = None,
|
|
496
|
+
status: StatusOption = None,
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Get a list of vulnerabilities."""
|
|
499
|
+
require_config()
|
|
500
|
+
try:
|
|
501
|
+
with get_client() as client:
|
|
502
|
+
result = Risk(client).get_vulnerabilities(
|
|
503
|
+
confidence=confidence,
|
|
504
|
+
detected_after=detected_after,
|
|
505
|
+
mac_address=mac_address,
|
|
506
|
+
name=name,
|
|
507
|
+
page=page,
|
|
508
|
+
page_size=page_size,
|
|
509
|
+
severity=severity,
|
|
510
|
+
status=status,
|
|
511
|
+
)
|
|
512
|
+
print_json(result)
|
|
513
|
+
except CyleraAPIError as e:
|
|
514
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
515
|
+
raise typer.Exit(1)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@app.command()
|
|
519
|
+
def threats(
|
|
520
|
+
detected_after: DetectedAfterOption = None,
|
|
521
|
+
mac_address: MacAddressOption = None,
|
|
522
|
+
name: Annotated[
|
|
523
|
+
Optional[str], typer.Option(help="Threat name (partial match)")
|
|
524
|
+
] = None,
|
|
525
|
+
page: PageOption = None,
|
|
526
|
+
page_size: PageSizeOption = None,
|
|
527
|
+
severity: SeverityOption = None,
|
|
528
|
+
status: StatusOption = None,
|
|
529
|
+
) -> None:
|
|
530
|
+
"""Get a list of detected threats."""
|
|
531
|
+
require_config()
|
|
532
|
+
try:
|
|
533
|
+
with get_client() as client:
|
|
534
|
+
result = Threat(client).get_threats(
|
|
535
|
+
detected_after=detected_after,
|
|
536
|
+
mac_address=mac_address,
|
|
537
|
+
name=name,
|
|
538
|
+
page=page,
|
|
539
|
+
page_size=page_size,
|
|
540
|
+
severity=severity,
|
|
541
|
+
status=status,
|
|
542
|
+
)
|
|
543
|
+
print_json(result)
|
|
544
|
+
except CyleraAPIError as e:
|
|
545
|
+
print(f"API error: {e}", file=sys.stderr)
|
|
546
|
+
raise typer.Exit(1)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
if __name__ == "__main__":
|
|
550
|
+
app()
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cylera-cli
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Command-line interface for the Cylera Partner API
|
|
5
|
+
Author-email: Cylera <support@cylera.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Cylera/cylera-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/Cylera/cylera-cli
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/Cylera/cylera-cli/issues
|
|
10
|
+
Keywords: cylera,medical devices,iot security,healthcare
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: System :: Systems Administration
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: cylera-client>=1.1.0
|
|
26
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
27
|
+
Requires-Dist: requests>=2.32.5
|
|
28
|
+
Requires-Dist: typer>=0.21.1
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Cylera CLI
|
|
32
|
+
|
|
33
|
+
A command-line interface for the [Cylera Partner API](https://github.com/Cylera/cylera-partner-api), providing read-only access to device inventory, threats, vulnerabilities, and network information.
|
|
34
|
+
|
|
35
|
+
It can be used stand-alone or integrated into an AI workflow using the bundled Claude Code skill (see below).
|
|
36
|
+
|
|
37
|
+
Alternatively, if you are looking to incorporate the power of Cylera into your AI workflows, you may also consider the [Cylera MCP Server](https://github.com/Cylera/cylera-mcp-server).
|
|
38
|
+
|
|
39
|
+
This [Cylera AI Integration Options document](CLI_VS_MCP.md) may help you decide which one may make sense depending on your AI workflows.
|
|
40
|
+
|
|
41
|
+
## Demo
|
|
42
|
+
![Demo][demo]
|
|
43
|
+
|
|
44
|
+
## Requirements
|
|
45
|
+
|
|
46
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
47
|
+
- Credentials for accessing Cylera
|
|
48
|
+
|
|
49
|
+
## Installation
|
|
50
|
+
|
|
51
|
+
Install using [uv](https://docs.astral.sh/uv/):
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv tool install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then run commands directly:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
cylera --help
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
To uninstall:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv tool uninstall cylera
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Alternatively, run without installing:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
uvx cylera --help
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
Run the interactive setup to configure your credentials:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
cylera init
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
This will prompt you to:
|
|
84
|
+
1. Select your Cylera API endpoint (US, UK, or Demo)
|
|
85
|
+
2. Enter your username (email)
|
|
86
|
+
3. Enter your password
|
|
87
|
+
|
|
88
|
+
Credentials are stored in a `.env` file in the current directory.
|
|
89
|
+
|
|
90
|
+
### Manual Configuration
|
|
91
|
+
|
|
92
|
+
Alternatively, set environment variables directly:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
export CYLERA_BASE_URL="https://partner.us1.cylera.com/"
|
|
96
|
+
export CYLERA_USERNAME="your-email@example.com"
|
|
97
|
+
export CYLERA_PASSWORD="your-password"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Doppler Secrets Management
|
|
101
|
+
|
|
102
|
+
Instead of storing the secrets in a .env file, you may choose to use a secrets
|
|
103
|
+
management solution such as [Doppler](https://www.doppler.com).
|
|
104
|
+
|
|
105
|
+
To use [Doppler](https://www.doppler.com), simply add the following prefix to all commands.
|
|
106
|
+
|
|
107
|
+
doppler run --
|
|
108
|
+
|
|
109
|
+
For example, to run "cylera devices" accessing secrets from Doppler, you would
|
|
110
|
+
run the following:
|
|
111
|
+
|
|
112
|
+
doppler run -- cylera devices
|
|
113
|
+
|
|
114
|
+
### 1Password Secrets Management
|
|
115
|
+
|
|
116
|
+
Alternatively, you can use [1Password CLI](https://developer.1password.com/docs/cli/get-started/)
|
|
117
|
+
for secrets management. Set your environment ID and prefix commands with `op run`:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
export OP_ENVIRONMENT_ID=<your-environment-id>
|
|
121
|
+
op run --environment "$OP_ENVIRONMENT_ID" -- cylera devices
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
cylera <command> [options]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Available Commands
|
|
131
|
+
|
|
132
|
+
| Command | Description |
|
|
133
|
+
|---------|-------------|
|
|
134
|
+
| `init` | Initialize Cylera CLI configuration interactively |
|
|
135
|
+
| `organization` | Get the organization associated with the current credentials |
|
|
136
|
+
| `organizations` | List organizations available to switch into |
|
|
137
|
+
| `switchorg` | Switch to a different organization |
|
|
138
|
+
| `resetorg` | Reset organization back to home |
|
|
139
|
+
| `device` | Get details for a specific device by MAC address |
|
|
140
|
+
| `devices` | Get a list of devices with optional filters |
|
|
141
|
+
| `deviceattributes` | Get attributes for a device by MAC address |
|
|
142
|
+
| `procedures` | Get a list of medical procedures |
|
|
143
|
+
| `subnets` | Get a list of network subnets |
|
|
144
|
+
| `riskmitigations` | Get mitigations for a specific vulnerability |
|
|
145
|
+
| `vulnerabilities` | Get a list of vulnerabilities |
|
|
146
|
+
| `threats` | Get a list of detected threats |
|
|
147
|
+
|
|
148
|
+
#### Organization
|
|
149
|
+
|
|
150
|
+
**Get organization info:**
|
|
151
|
+
```bash
|
|
152
|
+
cylera organization
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**List available organizations:**
|
|
156
|
+
```bash
|
|
157
|
+
cylera organizations
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Switch to a different organization:**
|
|
161
|
+
```bash
|
|
162
|
+
cylera switchorg <organization-id>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Reset back to home organization:**
|
|
166
|
+
```bash
|
|
167
|
+
cylera resetorg
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
#### Device Inventory
|
|
171
|
+
|
|
172
|
+
**List devices:**
|
|
173
|
+
```bash
|
|
174
|
+
cylera devices --page-size 10
|
|
175
|
+
cylera devices --vendor Philips --class Medical
|
|
176
|
+
cylera devices --ip-address 10.40
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
**Get a specific device:**
|
|
180
|
+
```bash
|
|
181
|
+
cylera device 7f:14:22:72:00:e5
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Get device attributes:**
|
|
185
|
+
```bash
|
|
186
|
+
cylera deviceattributes 7f:14:22:72:00:e5
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### Vulnerabilities & Risk
|
|
190
|
+
|
|
191
|
+
**List vulnerabilities:**
|
|
192
|
+
```bash
|
|
193
|
+
cylera vulnerabilities --severity CRITICAL
|
|
194
|
+
cylera vulnerabilities --status OPEN --page-size 20
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**Get mitigations for a vulnerability:**
|
|
198
|
+
```bash
|
|
199
|
+
cylera riskmitigations "Ripple20 (ICSA-20-168-01)"
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
#### Threats
|
|
203
|
+
|
|
204
|
+
**List threats:**
|
|
205
|
+
```bash
|
|
206
|
+
cylera threats --severity HIGH
|
|
207
|
+
cylera threats --mac-address bb:b0:71:cf:30:0a
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### Network
|
|
211
|
+
|
|
212
|
+
**List subnets:**
|
|
213
|
+
```bash
|
|
214
|
+
cylera subnets
|
|
215
|
+
cylera subnets --vlan 477
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
#### Medical Procedures
|
|
219
|
+
|
|
220
|
+
**List procedures:**
|
|
221
|
+
```bash
|
|
222
|
+
cylera procedures --page-size 10
|
|
223
|
+
cylera procedures --completed-after 2025/01/01
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Common Options
|
|
227
|
+
|
|
228
|
+
Most list commands support these options:
|
|
229
|
+
|
|
230
|
+
| Option | Description |
|
|
231
|
+
|--------|-------------|
|
|
232
|
+
| `--page` | Page number for pagination |
|
|
233
|
+
| `--page-size` | Results per page (max 100) |
|
|
234
|
+
| `--mac-address` | Filter by device MAC address |
|
|
235
|
+
| `--severity` | Filter by severity (INFO, LOW, MEDIUM, HIGH, CRITICAL) |
|
|
236
|
+
| `--status` | Filter by status (OPEN, IN_PROGRESS, RESOLVED, SUPPRESSED) |
|
|
237
|
+
|
|
238
|
+
### Output
|
|
239
|
+
|
|
240
|
+
All commands output JSON to stdout, which can be piped to tools like `jq`:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
cylera devices --page-size 5 | jq '.devices[].hostname'
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Debugging
|
|
247
|
+
|
|
248
|
+
Enable debug output to see request details:
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
DEBUG=1 cylera devices --page-size 1
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## API Endpoints
|
|
255
|
+
|
|
256
|
+
The CLI supports these Cylera Partner API regions:
|
|
257
|
+
|
|
258
|
+
- US: `https://partner.us1.cylera.com/`
|
|
259
|
+
- UK: `https://partner.uk1.cylera.com/`
|
|
260
|
+
- Demo: `https://partner.demo.cylera.com/`
|
|
261
|
+
|
|
262
|
+
## Claude Code Skills
|
|
263
|
+
|
|
264
|
+
This repo includes Claude Code skills for querying Cylera data and exporting results to CSV directly from a conversation — no scripting required.
|
|
265
|
+
|
|
266
|
+
| Skill | Description |
|
|
267
|
+
|-------|-------------|
|
|
268
|
+
| `/cylera-attributes` | Find devices by attribute label (e.g. TeamViewer, end of life) |
|
|
269
|
+
| `/cylera-vulnerabilities` | Export vulnerabilities filtered by severity and/or status |
|
|
270
|
+
| `/cylera-threats` | Export threats filtered by severity and/or status |
|
|
271
|
+
| `/cylera-devices` | Export device inventory filtered by class, vendor, type, or OS |
|
|
272
|
+
|
|
273
|
+
See [CODING_AGENT_SKILLS.md](CODING_AGENT_SKILLS.md) for full installation instructions and usage examples.
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
See LICENSE file for details.
|
|
278
|
+
|
|
279
|
+
[demo]: demo.webp
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
cylera.py,sha256=qUPdFvVQz1e-R6gkI8OlcgQmO39X-OsgxFMRCAKR6aQ,17128
|
|
2
|
+
cylera_cli-1.1.0.dist-info/licenses/LICENSE,sha256=z5MXeo8SX4SGe9m8d32Y_xk3qmlxZN7qR1wBNkgoafI,1063
|
|
3
|
+
cylera_cli-1.1.0.dist-info/METADATA,sha256=9PhCXB2Hw8i95eW9I9wsreE6_jYYi86_sCa4biPsRaM,7261
|
|
4
|
+
cylera_cli-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
cylera_cli-1.1.0.dist-info/entry_points.txt,sha256=faFTlCYuXBa9t9YPxLDDZ8REjP253QhWuvnatVyLs-0,38
|
|
6
|
+
cylera_cli-1.1.0.dist-info/top_level.txt,sha256=-zMu-_6aNhoqo5R-MIX843HvPdGG5VA4Q5Pfvc9aQ68,7
|
|
7
|
+
cylera_cli-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Cylera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cylera
|