mantis_api_client 4.9.0__9-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,347 @@
1
+ # -*- coding: utf-8 -*-
2
+ import argparse
3
+ import getpass
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from http.server import BaseHTTPRequestHandler
8
+ from http.server import HTTPServer
9
+ from pathlib import Path
10
+ from threading import Thread
11
+ from typing import Any
12
+ from typing import List
13
+ from urllib.parse import parse_qs
14
+ from urllib.parse import urlparse
15
+
16
+ import requests
17
+ from jose import jwt
18
+ from rich.console import Console
19
+ from rich.prompt import Prompt
20
+
21
+ import mantis_api_client.dataset_api as dataset_api
22
+ import mantis_api_client.oidc
23
+ import mantis_api_client.scenario_api as scenario_api
24
+ from mantis_api_client.config import mantis_api_client_config
25
+ from mantis_api_client.oidc import oidc_client
26
+ from mantis_api_client.utils import colored
27
+
28
+
29
+ class Version:
30
+ def __init__(self, str_vers: str) -> None:
31
+ try:
32
+ self.major, self.minor, self.patch = str_vers.split(".")
33
+ except Exception as e:
34
+ raise Exception(
35
+ "Bad version format for '{}': 'X.Y.Z' expected. Error: {}".format(
36
+ str_vers, e
37
+ )
38
+ )
39
+
40
+
41
+ #
42
+ # 'status' related functions
43
+ #
44
+ def status_handler(args: Any) -> None: # noqa: C901
45
+ """Get platform status."""
46
+
47
+ exit_code = 0
48
+
49
+ client_version = mantis_api_client.__version__
50
+ client_vers = Version(str_vers=client_version)
51
+ client_fullversion = mantis_api_client.__fullversion__
52
+ print(
53
+ f"[+] mantis_api_client version: {client_version} ({client_fullversion})".format(
54
+ client_version
55
+ )
56
+ )
57
+
58
+ active_profile_domain = oidc_client.get_active_profile_domain(raise_exc=False)
59
+ if active_profile_domain:
60
+ print(f"[+] Authenticated to {active_profile_domain}")
61
+ else:
62
+ print(
63
+ colored(
64
+ "[+] Not authenticated, you need to execute 'mantis account login'",
65
+ "red",
66
+ )
67
+ )
68
+ sys.exit(1)
69
+ print("[+] APIs status")
70
+
71
+ # Dataset API
72
+ print(" [+] Dataset API")
73
+ print(" [+] address: {}".format(mantis_api_client_config.dataset_api_url))
74
+ try:
75
+ dataset_api_version = dataset_api.get_version()
76
+ dataset_vers = Version(str_vers=dataset_api_version)
77
+ except requests.exceptions.ConnectionError:
78
+ exit_code = 1
79
+ print(" [-] API status: " + colored("not running !", "white", "on_red"))
80
+ else:
81
+ print(" [+] API status: " + colored("OK", "grey", "on_green"))
82
+ print(" [+] version: {}".format(dataset_api_version))
83
+ if dataset_vers.major != client_vers.major:
84
+ exit_code = 1
85
+ print(
86
+ " [-] "
87
+ + colored(
88
+ "Error: Dataset API major version ({}) mismatchs with mantis_api_client major version ({})".format(
89
+ dataset_vers.major, client_vers.major
90
+ ),
91
+ "white",
92
+ "on_red",
93
+ )
94
+ )
95
+
96
+ # Scenario API
97
+ print(" [+] Scenario API")
98
+ print(" [+] address: {}".format(mantis_api_client_config.scenario_api_url))
99
+ try:
100
+ scenario_api_version = scenario_api.get_version()
101
+ scenario_vers = Version(str_vers=scenario_api_version)
102
+ except requests.exceptions.ConnectionError:
103
+ exit_code = 1
104
+ print(" [-] API status: " + colored("not running !", "white", "on_red"))
105
+ else:
106
+ print(" [+] API status: " + colored("OK", "grey", "on_green"))
107
+ print(" [+] version: {}".format(scenario_api_version))
108
+ if scenario_vers.major != client_vers.major:
109
+ exit_code = 1
110
+ print(
111
+ " [-] "
112
+ + colored(
113
+ "Error: Scenario API major version ({}) mismatchs with mantis_api_client major version ({})".format(
114
+ scenario_vers.major, client_vers.major
115
+ ),
116
+ "white",
117
+ "on_red",
118
+ )
119
+ )
120
+
121
+ if exit_code != 0:
122
+ sys.exit(exit_code)
123
+
124
+
125
+ #
126
+ # 'info' related functions
127
+ #
128
+ def info_handler(args: Any) -> None: # noqa: C901
129
+ """Get personal info."""
130
+
131
+ active_profile_domain = oidc_client.get_active_profile_domain(raise_exc=False)
132
+ if not active_profile_domain:
133
+ print("[+] Not authenticated")
134
+ return
135
+
136
+ console = Console(highlight=False)
137
+ rprint = console.print
138
+ active_tokens = oidc_client.get_active_tokens()
139
+ access_token = jwt.get_unverified_claims(active_tokens["access_token"])
140
+ id_token = jwt.get_unverified_claims(active_tokens["id_token"])
141
+ default_group = oidc_client.get_default_group()
142
+ groups_comma_sep = ", ".join(id_token["groups"])
143
+ if default_group:
144
+ # mark default group bold
145
+ groups_comma_sep = groups_comma_sep.replace(
146
+ default_group, f"[bold]{default_group}[/bold]"
147
+ )
148
+ sorted_scopes = ", ".join(sorted(access_token["scope"].split()))
149
+
150
+ print(f"[+] Connected to {active_profile_domain}")
151
+ print(f" [+] Username: {id_token['preferred_username']}")
152
+ print(f" [+] Email: {id_token.get('email', 'N/A')}")
153
+ rprint(rf" \[+] Group membership: {groups_comma_sep}")
154
+ print(f" [+] Scopes: {sorted_scopes}")
155
+
156
+
157
+ #
158
+ # 'login_handler' handler
159
+ #
160
+ def login_handler(args: Any) -> None:
161
+ # Parameters
162
+ oidc_domain = args.domain
163
+ username = args.username
164
+ if args.password_stdin:
165
+ password = sys.stdin.read().rstrip()
166
+ elif args.password_fd:
167
+ with os.fdopen(args.password_fd) as f:
168
+ password = f.read().rstrip()
169
+ elif args.password_file:
170
+ password = args.password_file.read_text().rstrip()
171
+ elif args.username:
172
+ password = getpass.getpass()
173
+ else:
174
+ password = None
175
+
176
+ scope = "openid offline_access groups profile email"
177
+ redirect_uri = mantis_api_client_config.oidc.redirect_uri
178
+ redirect_auto = redirect_uri.startswith("http")
179
+ code_placeholder: List[str] = []
180
+ if redirect_auto:
181
+ thread = _init_create_callback_request_handler_thread(code_placeholder)
182
+ thread.start()
183
+ if username and password:
184
+ token = oidc_client.token(
185
+ oidc_domain, username, password, redirect_uri=redirect_uri, scope=scope
186
+ )
187
+ else:
188
+ auth_url = oidc_client.auth_url(
189
+ oidc_domain,
190
+ redirect_uri=redirect_uri,
191
+ scope=scope,
192
+ )
193
+ subprocess.run(["xdg-open", auth_url])
194
+ print(f"A web browser should been opened for {oidc_domain!r}")
195
+ if redirect_auto:
196
+ timeout = mantis_api_client_config.oidc.redirect_url_timeout
197
+ print(f"Waiting callback for {timeout}s")
198
+ thread.join(timeout)
199
+ if thread.is_alive():
200
+ print("Timeout exceeded, exiting")
201
+ print("Access NOT granted")
202
+ exit(1)
203
+ code = code_placeholder[0]
204
+ else:
205
+ code = input("Paste the code displayed in the webpage: ")
206
+ token = oidc_client.token(
207
+ oidc_domain,
208
+ grant_type="authorization_code",
209
+ code=code,
210
+ redirect_uri=redirect_uri,
211
+ )
212
+ print("Access granted")
213
+
214
+ claims = jwt.get_unverified_claims(token["access_token"])
215
+ selected_group = args.group
216
+ claimed_groups = claims.get("groups")
217
+ if claimed_groups and len(claimed_groups) == 1 and selected_group is None:
218
+ selected_group = claimed_groups[0]
219
+ if selected_group not in claimed_groups:
220
+ if selected_group:
221
+ print(f"You are not member of group '{selected_group}'")
222
+ selected_group = Prompt.ask(
223
+ "Select a group to activate", choices=claimed_groups
224
+ )
225
+ print(f"Group `{selected_group}` activated")
226
+ oidc_client.configure_profile(oidc_domain, token["refresh_token"], selected_group)
227
+
228
+
229
+ #
230
+ # 'logout_handler' handler
231
+ #
232
+ def logout_handler(args: Any) -> None:
233
+ oidc_client.configure_profile(None)
234
+
235
+
236
+ def _init_create_callback_request_handler_thread(code_placeholder: list) -> Thread:
237
+ class CallbackHTTPRequestHandler(BaseHTTPRequestHandler):
238
+ def do_GET(self) -> None:
239
+ nonlocal code_placeholder
240
+ query = urlparse(self.path).query
241
+ if "error" in query:
242
+ self.send_response(404)
243
+ else:
244
+ code = parse_qs(query)["code"][0]
245
+ code_placeholder.append(code)
246
+ self.send_response(200)
247
+ self.send_header("Content-Type", "text/html")
248
+ self.end_headers()
249
+ self.wfile.write(
250
+ "<script>window.close()</script> Authorization {}. You may close this window.\r\n".format(
251
+ "failed" if "error" in query else "succeeded"
252
+ ).encode()
253
+ )
254
+
255
+ def log_request(*args, **kwargs):
256
+ # do nothing
257
+ pass
258
+
259
+ def serve_one_redirect_callback() -> None:
260
+ with HTTPServer(
261
+ (
262
+ mantis_api_client_config.oidc.redirect_url.host,
263
+ mantis_api_client_config.oidc.redirect_url.port,
264
+ ),
265
+ CallbackHTTPRequestHandler,
266
+ ) as server:
267
+ server.handle_request()
268
+
269
+ return Thread(target=serve_one_redirect_callback, daemon=True)
270
+
271
+
272
+ def add_account_parser(root_parser: argparse.ArgumentParser, subparsers: Any) -> None:
273
+ # -------------------
274
+ # --- login options
275
+ # -------------------
276
+
277
+ parser_account = subparsers.add_parser(
278
+ "account",
279
+ help="Authentication actions for M&NTIS CLI.",
280
+ formatter_class=root_parser.formatter_class,
281
+ )
282
+ subparsers_account = parser_account.add_subparsers()
283
+
284
+ # 'status' command
285
+ parser_status = subparsers_account.add_parser(
286
+ "status",
287
+ help="Get platform status",
288
+ formatter_class=root_parser.formatter_class,
289
+ )
290
+ parser_status.set_defaults(func=status_handler)
291
+
292
+ # 'info' command
293
+ parser_info = subparsers_account.add_parser(
294
+ "info", help="Get personal info", formatter_class=root_parser.formatter_class
295
+ )
296
+ parser_info.set_defaults(func=info_handler)
297
+
298
+ parser_login = subparsers_account.add_parser(
299
+ "login",
300
+ help="Log into a M&ntis account",
301
+ formatter_class=root_parser.formatter_class,
302
+ )
303
+ parser_login.add_argument(
304
+ "--domain",
305
+ help="The M&ntis cluster SSO domain (default: %(default)s)",
306
+ default="mantis-platform.io",
307
+ )
308
+ parser_login.add_argument(
309
+ "--username",
310
+ "-u",
311
+ help="Your M&ntis cluster SSO username",
312
+ )
313
+ parser_login_mex_group = parser_login.add_mutually_exclusive_group()
314
+ parser_login_mex_group.add_argument(
315
+ "--password-stdin",
316
+ action="store_true",
317
+ help="Read your M&ntis cluster SSO password from stdin",
318
+ )
319
+ parser_login_mex_group.add_argument(
320
+ "--password-fd",
321
+ type=int,
322
+ help="Read your M&ntis cluster SSO password from a descriptor",
323
+ )
324
+ parser_login_mex_group.add_argument(
325
+ "--password-file",
326
+ type=Path,
327
+ help="Read your M&ntis cluster SSO password from a file",
328
+ )
329
+ parser_login.add_argument(
330
+ "-g",
331
+ "--group",
332
+ help="Select the group name that will be used for contextual commands like `mantis scenario run`",
333
+ )
334
+ parser_login.set_defaults(func=login_handler)
335
+
336
+ # -------------------
337
+ # --- logout options
338
+ # -------------------
339
+
340
+ parser_logout = subparsers_account.add_parser(
341
+ "logout",
342
+ help="Log out from your M&ntis account",
343
+ formatter_class=root_parser.formatter_class,
344
+ )
345
+ parser_logout.set_defaults(func=logout_handler)
346
+
347
+ parser_account.set_defaults(func=lambda _: parser_account.print_help())
@@ -0,0 +1,221 @@
1
+ # -*- coding: utf-8 -*-
2
+ import argparse
3
+ import sys
4
+ import traceback
5
+ from typing import Any
6
+
7
+ from mantis_scenario_model.scenario_run_config_model import ScenarioRunConfig
8
+ from mantis_scenario_model.unit_attack_model import Empty
9
+ from ruamel.yaml import YAML
10
+
11
+ from mantis_api_client import scenario_api
12
+ from mantis_api_client.oidc import oidc_client
13
+ from mantis_api_client.utils import colored
14
+ from mantis_api_client.utils import wait_lab
15
+
16
+
17
+ #
18
+ # 'attack_list_handler' handler
19
+ #
20
+ def attack_list_handler(args: Any) -> None:
21
+ try:
22
+ attacks = scenario_api.fetch_attacks()
23
+ except Exception as e:
24
+ print(colored(f"Error when fetching attacks: '{e}'", "red"))
25
+ sys.exit(1)
26
+
27
+ if args.json:
28
+ print([item.dict() for item in attacks])
29
+ return
30
+
31
+ width = 35
32
+ print(f"[+] Available attacks ({len(attacks)}):")
33
+ name = "NAME"
34
+ print(f" [+] \033[1m{name: <{width}}- DESCRIPTION\033[0m")
35
+
36
+ for attack in attacks:
37
+ if not isinstance(attack.mitre_data.subtechnique, Empty):
38
+ mitre_print = attack.mitre_data.subtechnique.id
39
+ else:
40
+ mitre_print = attack.mitre_data.technique.id
41
+
42
+ print(" [+] ", end="")
43
+ print(f"{attack.name: <{width}}", end="")
44
+ print(f"- {attack.title} ({mitre_print})")
45
+
46
+
47
+ #
48
+ # 'attack_info_handler' handler
49
+ #
50
+ def attack_info_handler(args: Any) -> None:
51
+ # Parameters
52
+ attack_name = args.attack_name
53
+
54
+ try:
55
+ attack = scenario_api.fetch_attack_by_name(attack_name)
56
+ except Exception as e:
57
+ print(
58
+ colored(
59
+ f"Error when fetching attack {attack_name}: '{e}'", "red", "on_white"
60
+ )
61
+ )
62
+ sys.exit(1)
63
+
64
+ if args.json:
65
+ print(attack.json())
66
+ return
67
+
68
+ print("[+] Attack information:")
69
+ print(f" [+] \033[1mID\033[0m: {attack.worker_id}")
70
+ print(f" [+] \033[1mName\033[0m: {attack.name}")
71
+ print(f" [+] \033[1mDescription\033[0m: {attack.description}")
72
+ print(" [+] \033[1mAvailable scenario config\033[0m:")
73
+ for scenario_config in attack.scenario_config:
74
+ print(
75
+ f" [+] {scenario_config['name']} (Topology: {scenario_config['file']})"
76
+ )
77
+
78
+
79
+ #
80
+ # 'attack_run_handler' handler
81
+ #
82
+ def attack_run_handler(args: Any) -> None:
83
+ # Parameters
84
+ attack_name = args.attack_name
85
+ scenario_config_name = args.scenario_config_name
86
+ scenario_run_config_path = args.scenario_run_config_path
87
+
88
+ # Safety checks
89
+ attack = scenario_api.fetch_attack_by_name(attack_name)
90
+ print(f"[+] Going to execute attack: {attack.name}")
91
+
92
+ # Safety checks
93
+ if len(attack.scenario_config) == 0:
94
+ print(
95
+ f"Cannot run attack '{attack_name}', because it does not have any unit scenario"
96
+ )
97
+ sys.exit(-1)
98
+
99
+ if scenario_config_name is None:
100
+ print(
101
+ "Needed argument --scenario_config_name, in order to choose the unit scenario to run"
102
+ )
103
+ print("Available unit scenarios:")
104
+ for available_scenario_config in attack.scenario_config:
105
+ print(f" [+] {available_scenario_config['name']}")
106
+ sys.exit(-1)
107
+
108
+ for available_scenario_config in attack.scenario_config:
109
+ if available_scenario_config["name"] == scenario_config_name:
110
+ break
111
+ else:
112
+ print(f"Select scenario config '{scenario_config_name}' is not available")
113
+ print("Available unit scenarios:")
114
+ for available_scenario_config in attack.scenario_config:
115
+ print(f" [+] {available_scenario_config['name']}")
116
+ sys.exit(-1)
117
+
118
+ # Manage scenario run configuration
119
+ if scenario_run_config_path is None:
120
+ scenario_run_config_dict = {}
121
+ else:
122
+ with open(scenario_run_config_path, "r") as fd:
123
+ yaml_content = fd.read()
124
+ loader = YAML(typ="rt")
125
+ scenario_run_config_dict = loader.load(yaml_content)
126
+ scenario_run_config = ScenarioRunConfig(**scenario_run_config_dict)
127
+
128
+ # Launch topology
129
+ try:
130
+ lab_id = scenario_api.run_attack(
131
+ attack, scenario_config_name, scenario_run_config, args.group_id
132
+ )
133
+
134
+ print(f"[+] Scenario lab ID: {lab_id}")
135
+
136
+ wait_lab(lab_id)
137
+
138
+ print("[+] Scenario ended")
139
+ except Exception as e:
140
+ print(colored(f"Error when running attack {attack_name}: '{e}'", "red"))
141
+ print(traceback.format_exc())
142
+ sys.exit(1)
143
+ finally:
144
+ if args.destroy_after_scenario:
145
+ print("[+] Stopping lab...")
146
+ scenario_api.stop_lab(lab_id)
147
+
148
+
149
+ def add_attack_parser(
150
+ root_parser: argparse.ArgumentParser,
151
+ subparsers: Any,
152
+ group_required: bool,
153
+ ) -> None:
154
+ # --------------------
155
+ # --- Scenario API options (attack)
156
+ # --------------------
157
+
158
+ parser_attack = subparsers.add_parser(
159
+ "attack",
160
+ help="Scenario API related commands (attacks)",
161
+ formatter_class=root_parser.formatter_class,
162
+ )
163
+ subparsers_attack = parser_attack.add_subparsers()
164
+
165
+ # 'attack_list' command
166
+ parser_attack_list = subparsers_attack.add_parser(
167
+ "list",
168
+ help="List all available attacks",
169
+ formatter_class=root_parser.formatter_class,
170
+ )
171
+ parser_attack_list.set_defaults(func=attack_list_handler)
172
+ parser_attack_list.add_argument(
173
+ "--json", help="Return JSON result.", action="store_true"
174
+ )
175
+
176
+ # 'attack_info' command
177
+ parser_attack_info = subparsers_attack.add_parser(
178
+ "info",
179
+ help="Get information about an attack",
180
+ formatter_class=root_parser.formatter_class,
181
+ )
182
+ parser_attack_info.set_defaults(func=attack_info_handler)
183
+ parser_attack_info.add_argument("attack_name", type=str, help="The attack name")
184
+ parser_attack_info.add_argument(
185
+ "--json", help="Return JSON result.", action="store_true"
186
+ )
187
+
188
+ # 'attack_run' command
189
+ parser_attack_run = subparsers_attack.add_parser(
190
+ "run", help="Run a specific attack", formatter_class=root_parser.formatter_class
191
+ )
192
+ parser_attack_run.set_defaults(func=attack_run_handler)
193
+ parser_attack_run.add_argument("attack_name", type=str, help="The attack name")
194
+ parser_attack_run.add_argument(
195
+ "--destroy",
196
+ action="store_true",
197
+ dest="destroy_after_scenario",
198
+ help="Do not keep the lab up after scenario execution (False by default)",
199
+ )
200
+ parser_attack_run.add_argument(
201
+ "--scenario_config_name",
202
+ dest="scenario_config_name",
203
+ help="Allows to define the unit scenario to run for a unit attack",
204
+ )
205
+ parser_attack_run.add_argument(
206
+ "group_id",
207
+ metavar="group-id",
208
+ type=str,
209
+ nargs=1 if group_required else "?",
210
+ default=oidc_client.get_default_group(raise_exc=False),
211
+ help="The group ID that have ownership on lab (default: %(default)s)",
212
+ )
213
+ parser_attack_run.add_argument(
214
+ "--scenario_run_config",
215
+ action="store",
216
+ required=False,
217
+ dest="scenario_run_config_path",
218
+ help="Input path of a YAML configuration for the scenario run",
219
+ )
220
+
221
+ parser_attack.set_defaults(func=lambda _: parser_attack.print_help())