mantis_api_client 5.5.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.
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import os
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ # Component version
8
+ __version__ = "4.8.0"
9
+
10
+ # Remove incompatible env variable
11
+ os.environ.pop("USE_PERMISSIONS", None)
12
+
13
+ # Get component full version from file generated at build time
14
+ current_file_dir = Path(__file__).resolve().parent
15
+ fullversion_file = Path(current_file_dir, "fullversion.txt")
16
+ if os.path.isfile(fullversion_file):
17
+ __fullversion__ = open(fullversion_file, "r").read().strip()
18
+ else:
19
+ __fullversion__ = __version__
20
+
21
+ shutil_make_archive_lock = threading.Lock()
@@ -0,0 +1,451 @@
1
+ # -*- coding: utf-8 -*-
2
+ import argparse
3
+ import getpass
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from http.server import BaseHTTPRequestHandler
9
+ from http.server import HTTPServer
10
+ from pathlib import Path
11
+ from threading import Thread
12
+ from typing import Any
13
+ from typing import List
14
+ from urllib.parse import parse_qs
15
+ from urllib.parse import urlparse
16
+
17
+ import requests
18
+ from mantis_authz import jwt
19
+ from rich.console import Console
20
+ from rich.prompt import Prompt
21
+ from rich.tree import Tree
22
+
23
+ import mantis_api_client
24
+ import mantis_api_client.dataset_api as dataset_api
25
+ import mantis_api_client.scenario_api as scenario_api
26
+ from mantis_api_client import user_api
27
+ from mantis_api_client.config import mantis_api_client_config
28
+ from mantis_api_client.oidc import get_oidc_client
29
+ from mantis_api_client.utils import colored
30
+
31
+
32
+ class Version:
33
+ def __init__(self, str_vers: str) -> None:
34
+ try:
35
+ self.major, self.minor, self.patch = str_vers.split(".", 2)
36
+ except Exception as e:
37
+ raise Exception(
38
+ "Bad version format for '{}': 'X.Y.Z' expected. Error: {}".format(
39
+ str_vers, e
40
+ )
41
+ )
42
+
43
+
44
+ #
45
+ # 'status' related functions
46
+ #
47
+ def status_handler(args: Any) -> None: # noqa: C901
48
+ """Get platform status."""
49
+
50
+ exit_code = 0
51
+
52
+ client_version = mantis_api_client.__version__
53
+ client_vers = Version(str_vers=client_version)
54
+ client_fullversion = mantis_api_client.__fullversion__
55
+ print(
56
+ f"[+] mantis_api_client version: {client_version} ({client_fullversion})".format(
57
+ client_version
58
+ )
59
+ )
60
+
61
+ active_profile_domain = get_oidc_client().get_active_profile_domain(raise_exc=False)
62
+ if active_profile_domain:
63
+ print(f"[+] Authenticated to {active_profile_domain}")
64
+ else:
65
+ print(
66
+ colored(
67
+ "[+] Not authenticated, you need to execute 'mantis account login'",
68
+ "red",
69
+ )
70
+ )
71
+ sys.exit(1)
72
+ print("[+] APIs status")
73
+
74
+ # Dataset API
75
+ print(" [+] Dataset API")
76
+ print(" [+] address: {}".format(mantis_api_client_config.dataset_api_url))
77
+ try:
78
+ dataset_api_version = dataset_api.get_version()
79
+ dataset_vers = Version(str_vers=dataset_api_version)
80
+ except requests.exceptions.ConnectionError:
81
+ exit_code = 1
82
+ print(" [-] API status: " + colored("not running !", "white", "on_red"))
83
+ else:
84
+ print(" [+] API status: " + colored("OK", "grey", "on_green"))
85
+ print(" [+] version: {}".format(dataset_api_version))
86
+ if dataset_vers.major != client_vers.major:
87
+ exit_code = 1
88
+ print(
89
+ " [-] "
90
+ + colored(
91
+ "Error: Dataset API major version ({}) mismatchs with mantis_api_client major version ({})".format(
92
+ dataset_vers.major, client_vers.major
93
+ ),
94
+ "white",
95
+ "on_red",
96
+ )
97
+ )
98
+
99
+ # Scenario API
100
+ print(" [+] Scenario API")
101
+ print(" [+] address: {}".format(mantis_api_client_config.scenario_api_url))
102
+ try:
103
+ scenario_api_version = scenario_api.get_version()
104
+ scenario_vers = Version(str_vers=scenario_api_version)
105
+ cyber_range_version = scenario_api.get_cyberrange_version()
106
+ except requests.exceptions.ConnectionError:
107
+ exit_code = 1
108
+ print(" [-] API status: " + colored("not running !", "white", "on_red"))
109
+ else:
110
+ print(" [+] API status: " + colored("OK", "grey", "on_green"))
111
+ print(" [+] version: {}".format(scenario_api_version))
112
+ print(" [+] Cyber Range version: {}".format(cyber_range_version))
113
+ if scenario_vers.major != client_vers.major:
114
+ exit_code = 1
115
+ print(
116
+ " [-] "
117
+ + colored(
118
+ "Error: Scenario API major version ({}) mismatchs with mantis_api_client major version ({})".format(
119
+ scenario_vers.major, client_vers.major
120
+ ),
121
+ "white",
122
+ "on_red",
123
+ )
124
+ )
125
+
126
+ # User API
127
+ print(" [+] Backoffice API")
128
+ print(" [+] address: {}".format(mantis_api_client_config.user_api_url))
129
+ try:
130
+ user_api_version = user_api.get_version()
131
+ user_vers = Version(str_vers=user_api_version)
132
+ except requests.exceptions.ConnectionError:
133
+ exit_code = 1
134
+ print(" [-] API status: " + colored("not running !", "white", "on_red"))
135
+ else:
136
+ print(" [+] API status: " + colored("OK", "grey", "on_green"))
137
+ print(" [+] version: {}".format(user_api_version))
138
+ if user_vers.major != client_vers.major:
139
+ exit_code = 1
140
+ print(
141
+ " [-] "
142
+ + colored(
143
+ "Error: User API major version ({}) mismatchs with mantis_api_client major version ({})".format(
144
+ user_vers.major, client_vers.major
145
+ ),
146
+ "white",
147
+ "on_red",
148
+ )
149
+ )
150
+
151
+ if exit_code != 0:
152
+ sys.exit(exit_code)
153
+
154
+
155
+ #
156
+ # 'info' related functions
157
+ #
158
+ def info_handler(args: Any) -> None: # noqa: C901
159
+ """Get personal info."""
160
+
161
+ active_profile_domain = get_oidc_client().get_active_profile_domain(raise_exc=False)
162
+ if not active_profile_domain:
163
+ print("[+] Not authenticated")
164
+ return
165
+
166
+ console = Console(highlight=False)
167
+ rprint = console.print
168
+ active_tokens = get_oidc_client().get_active_tokens()
169
+ access_token = jwt.get_unverified_claims(active_tokens["access_token"])
170
+ id_token = jwt.get_unverified_claims(active_tokens["id_token"])
171
+
172
+ sub_orgs = user_api.fetch_current_seats()
173
+ sub_membership: dict[str, list[dict]] = {}
174
+ sub_default_ws = get_oidc_client().get_default_workspace()
175
+ # Handle case where the user pertains to an organization
176
+ if sub_orgs:
177
+ sub_wss = user_api.fetch_current_workspaces()
178
+ for sub_org in sub_orgs:
179
+ sub_org_wss: list[dict] = []
180
+ sub_membership[sub_org["name"]] = sub_org_wss
181
+ for sub_ws in sub_wss:
182
+ if sub_org["id"] == sub_ws["organization_id"]:
183
+ sub_org_wss.append(sub_ws)
184
+
185
+ sorted_scopes = ", ".join(sorted(access_token["scope"].split()))
186
+
187
+ print(f"[+] Connected to {active_profile_domain}")
188
+ print(f" [+] Username: {id_token['preferred_username']}")
189
+ print(f" [+] Email: {id_token.get('email', 'N/A')}")
190
+ if sub_orgs:
191
+
192
+ def hl_activated_ws(ws: dict) -> str:
193
+ if ws["id"] == sub_default_ws:
194
+ return f"[bold]{ws['name']}[/bold]"
195
+ return ws["name"]
196
+
197
+ rprint(
198
+ r" \[+] Organization membership: {}".format(
199
+ ", ".join(
200
+ "{} ({})".format(
201
+ org,
202
+ ", ".join(map(hl_activated_ws, org_wss)),
203
+ )
204
+ for org, org_wss in sub_membership.items()
205
+ )
206
+ )
207
+ )
208
+ print(f" [+] Scopes: {sorted_scopes}")
209
+
210
+
211
+ #
212
+ # 'login_handler' handler
213
+ #
214
+ def login_handler(args: Any) -> None:
215
+ oidc_client = get_oidc_client()
216
+ # Parameters
217
+ oidc_domain = args.domain
218
+ username = args.username
219
+ if args.password_stdin:
220
+ password = sys.stdin.read().rstrip()
221
+ elif args.password_fd:
222
+ with os.fdopen(args.password_fd) as f:
223
+ password = f.read().rstrip()
224
+ elif args.password_file:
225
+ password = args.password_file.read_text().rstrip()
226
+ elif args.username:
227
+ password = getpass.getpass()
228
+ else:
229
+ password = None
230
+
231
+ scope = " ".join(
232
+ [
233
+ "openid",
234
+ "offline_access",
235
+ "groups",
236
+ "profile",
237
+ "email",
238
+ "scenario:run",
239
+ ]
240
+ )
241
+ redirect_uri = mantis_api_client_config.oidc.redirect_uri
242
+ redirect_auto = redirect_uri.startswith("http")
243
+ code_placeholder: List[str] = []
244
+ if redirect_auto:
245
+ thread = _init_create_callback_request_handler_thread(code_placeholder)
246
+ thread.start()
247
+ if username and password:
248
+ token = oidc_client.token(
249
+ oidc_domain, username, password, redirect_uri=redirect_uri, scope=scope
250
+ )
251
+ else:
252
+ auth_url = oidc_client.auth_url(
253
+ oidc_domain,
254
+ redirect_uri=redirect_uri,
255
+ scope=scope,
256
+ )
257
+ if shutil.which("xdg-open") is not None:
258
+ subprocess.run(["xdg-open", auth_url])
259
+ print(f"A web browser should have been opened for {oidc_domain!r}")
260
+ else:
261
+ print(
262
+ f"Open this URL in a web browser in order to create an access token for M&NTIS:\n\n{auth_url}\n"
263
+ )
264
+
265
+ if redirect_auto:
266
+ timeout = mantis_api_client_config.oidc.redirect_url_timeout
267
+ print(f"Waiting callback for {timeout}s")
268
+ thread.join(timeout)
269
+ if thread.is_alive():
270
+ print("Timeout exceeded, exiting")
271
+ print("Access NOT granted")
272
+ exit(1)
273
+ code = code_placeholder[0]
274
+ else:
275
+ code = input("Paste the code displayed in the webpage: ")
276
+ token = get_oidc_client().token(
277
+ oidc_domain,
278
+ grant_type="authorization_code",
279
+ code=code,
280
+ redirect_uri=redirect_uri,
281
+ )
282
+ print("Access granted")
283
+ oidc_client.configure_profile(oidc_domain, token["refresh_token"])
284
+
285
+ # Handle case where the user does not pertain to an organization
286
+ subject_workspaces = user_api.fetch_current_workspaces()
287
+ subject_organizations = user_api.fetch_current_seats()
288
+ subject_orgs_wss = [
289
+ (sub_org, sub_ws)
290
+ for sub_org in subject_organizations
291
+ for sub_ws in subject_workspaces
292
+ if sub_ws["organization_id"] == sub_org["id"]
293
+ ]
294
+ selected_idx: int | None = None
295
+ if args.workspace:
296
+ for i, (_, ws) in enumerate(subject_orgs_wss):
297
+ if args.workspace == ws["id"]:
298
+ selected_idx = i
299
+ console = Console()
300
+ if selected_idx is None:
301
+ match len(subject_orgs_wss):
302
+ case 0:
303
+ return
304
+ case 1:
305
+ selected_idx = 0
306
+ case _:
307
+ i = 1
308
+ root = Tree(":file_folder:[yellow]Workspace memberships")
309
+ for org in subject_organizations:
310
+ org_branch = root.add(f"Organization [magenta]{org['name']}")
311
+ for ws in subject_workspaces:
312
+ if ws["organization_id"] != org["id"]:
313
+ continue
314
+ org_branch.add(rf"[bold green]{i}[/bold green]. {ws['name']}")
315
+ i += 1
316
+ console.print(root)
317
+ selected_idx = (
318
+ int(
319
+ Prompt.ask(
320
+ "Select a default workspace",
321
+ choices=[str(k + 1) for k in range(len(subject_orgs_wss))],
322
+ )
323
+ )
324
+ - 1
325
+ )
326
+ selected_ws = subject_orgs_wss[selected_idx][1]
327
+ console.print(f"Workspace [b green]{selected_ws['name']}[/b green] activated")
328
+
329
+ oidc_client.configure_profile(
330
+ oidc_domain, token["refresh_token"], selected_ws["id"]
331
+ )
332
+
333
+
334
+ #
335
+ # 'logout_handler' handler
336
+ #
337
+ def logout_handler(args: Any) -> None:
338
+ get_oidc_client().configure_profile(None)
339
+
340
+
341
+ def _init_create_callback_request_handler_thread(code_placeholder: list) -> Thread:
342
+ class CallbackHTTPRequestHandler(BaseHTTPRequestHandler):
343
+ def do_GET(self) -> None:
344
+ query = urlparse(self.path).query
345
+ if "error" in query:
346
+ self.send_response(404)
347
+ else:
348
+ code = parse_qs(query)["code"][0]
349
+ code_placeholder.append(code)
350
+ self.send_response(200)
351
+ self.send_header("Content-Type", "text/html")
352
+ self.end_headers()
353
+ self.wfile.write(
354
+ "<script>window.close()</script> Authorization {}. You may close this window.\r\n".format(
355
+ "failed" if "error" in query else "succeeded"
356
+ ).encode()
357
+ )
358
+
359
+ def log_request(*args, **kwargs):
360
+ # do nothing
361
+ pass
362
+
363
+ def serve_one_redirect_callback() -> None:
364
+ with HTTPServer(
365
+ (
366
+ mantis_api_client_config.oidc.redirect_url.host,
367
+ mantis_api_client_config.oidc.redirect_url.port,
368
+ ),
369
+ CallbackHTTPRequestHandler,
370
+ ) as server:
371
+ server.handle_request()
372
+
373
+ return Thread(target=serve_one_redirect_callback, daemon=True)
374
+
375
+
376
+ def add_account_parser(root_parser: argparse.ArgumentParser, subparsers: Any) -> None:
377
+ # -------------------
378
+ # --- login options
379
+ # -------------------
380
+
381
+ parser_account = subparsers.add_parser(
382
+ "account",
383
+ help="Authentication actions for M&NTIS CLI.",
384
+ formatter_class=root_parser.formatter_class,
385
+ )
386
+ subparsers_account = parser_account.add_subparsers()
387
+
388
+ # 'status' command
389
+ parser_status = subparsers_account.add_parser(
390
+ "status",
391
+ help="Get platform status",
392
+ formatter_class=root_parser.formatter_class,
393
+ )
394
+ parser_status.set_defaults(func=status_handler)
395
+
396
+ # 'info' command
397
+ parser_info = subparsers_account.add_parser(
398
+ "info", help="Get personal info", formatter_class=root_parser.formatter_class
399
+ )
400
+ parser_info.set_defaults(func=info_handler)
401
+
402
+ parser_login = subparsers_account.add_parser(
403
+ "login",
404
+ help="Log into a M&ntis account",
405
+ formatter_class=root_parser.formatter_class,
406
+ )
407
+ parser_login.add_argument(
408
+ "--domain",
409
+ help="The M&ntis cluster SSO domain (default: %(default)s)",
410
+ default="mantis-platform.io",
411
+ )
412
+ parser_login.add_argument(
413
+ "--username",
414
+ "-u",
415
+ help="Your M&ntis cluster SSO username",
416
+ )
417
+ parser_login_mex_group = parser_login.add_mutually_exclusive_group()
418
+ parser_login_mex_group.add_argument(
419
+ "--password-stdin",
420
+ action="store_true",
421
+ help="Read your M&ntis cluster SSO password from stdin",
422
+ )
423
+ parser_login_mex_group.add_argument(
424
+ "--password-fd",
425
+ type=int,
426
+ help="Read your M&ntis cluster SSO password from a descriptor",
427
+ )
428
+ parser_login_mex_group.add_argument(
429
+ "--password-file",
430
+ type=Path,
431
+ help="Read your M&ntis cluster SSO password from a file",
432
+ )
433
+ parser_login.add_argument(
434
+ "-w",
435
+ "--workspace",
436
+ help="Pass the workspace that will be used as default for workspace-aware commands",
437
+ )
438
+ parser_login.set_defaults(func=login_handler)
439
+
440
+ # -------------------
441
+ # --- logout options
442
+ # -------------------
443
+
444
+ parser_logout = subparsers_account.add_parser(
445
+ "logout",
446
+ help="Log out from your M&ntis account",
447
+ formatter_class=root_parser.formatter_class,
448
+ )
449
+ parser_logout.set_defaults(func=logout_handler)
450
+
451
+ parser_account.set_defaults(func=lambda _: parser_account.print_help())