diff --git a/config.py b/config.py index f990a5daa952955da33be8742615bda5ea766f75..ee6ed8d6518887c6289551d29d6f2adad3b15e8e 100644 --- a/config.py +++ b/config.py @@ -12,6 +12,7 @@ KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/ke MISP_API_KEY_FILE = os.path.join(SOCTOOLS_BASE, "secrets/tokens/misp") THEHIVE_API_KEY_FILE = os.path.join(SOCTOOLS_BASE, "secrets/tokens/thehive_secret_key") +CORTEX_API_KEY_FILE = os.path.join(SOCTOOLS_BASE, "secrets/tokens/cortex_secret_key") # Credentials of the special user for account management # Cert and key should be in .pem format, unencrypted @@ -35,3 +36,6 @@ MISP_API_KEY = None THEHIVE_API_KEY = None THEHIVE_ORG_NAME = None # set to "domain" from variables file + +CORTEX_API_KEY = None +CORTEX_ORG_NAME = None # set to "domain" from variables file diff --git a/cortex.py b/cortex.py new file mode 100644 index 0000000000000000000000000000000000000000..dbbaf15a7810a69f9b219ab06c76fd5cb635c876 --- /dev/null +++ b/cortex.py @@ -0,0 +1,142 @@ +"""Functions to manage user accounts in Cortex""" + +from typing import List, Dict, Optional +import requests +from datetime import datetime + +import config + +# Base URL to Cortex API endpoints +CORTEX_API_BASE_URL = "https://{soctools_proxy}:9001/api" + +# Cortex documentation: https://github.com/Cortex-Project/CortexDocs/blob/master/api/api-guide.md + +class CortexError(Exception): + pass + +class CortexUserNotFoundError(CortexError): + pass + +class CortexUserExistsError(CortexError): + pass + +class CortexUnexpectedReplyError(CortexError): + pass + + +# ========================= +# Public interface + +def cortex_get_users() -> List[Dict]: + """ + List users defined in Cortex in the configured organization + + :return List of dicts with keys 'login', 'name', 'roles' (list), 'status', 'created' (datetime) + :raise CortexUnexpectedReplyError + """ + # all users in given org + url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/organization/{config.THEHIVE_ORG_NAME}/user" + # all users + #url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user" + + resp = _send_request("get", url) + if not resp.ok: + raise CortexUnexpectedReplyError(f"Can't get list of users from Cortex: Unexpected reply {resp.status_code}") + users = [] + try: + for user_entry in resp.json(): + # ref: https://github.com/TheHive-Project/CortexDocs/blob/master/api/api-guide.md#user-model + users.append({ + "login": user_entry["id"], + "name": user_entry["name"], + "roles": user_entry["roles"], + "org": user_entry["organization"], + "status": user_entry["status"], + "created": datetime.utcfromtimestamp(user_entry["createdAt"]/1000), # time of account creation (timestamp in ms -> datetime) + }) + except (ValueError, TypeError, KeyError) as e: + print(f"Can't get list of users from Cortex: Unexpected content received: {type(e).__name__}: {e})") + raise CortexUnexpectedReplyError(f"Can't get list of users from Cortex: Unexpected content received") + + return users + + +def cortex_add_user(user: 'UserAccount') -> None: + """Add a new user to Cortex + + :raise CortexUnexpectedReplyError + """ + url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/user" + data = { + "login": user.email, + "name": f"{user.firstname} {user.lastname}".strip() or user.username, + "organization": config.CORTEX_ORG_NAME, + "roles": ["read", "analyze"], #, "orgadmin"], + } + resp = _send_request("post", url, data) + #print(resp.json()) + if not resp.ok: + try: + if "already exists" in resp.json()["message"]: + raise CortexUserExistsError() + except Exception: + pass + print(f"Can't add user to Cortex: Unexpected reply {resp.status_code}: {resp.text[:500]}") + raise CortexUnexpectedReplyError(f"Can't add user to Cortex: Unexpected reply {resp.status_code}") + return None + + +def cortex_edit_user(login: str, user: 'UserAccount') -> None: + """Edit existing user in Cortex (only name can be changed) + + :raise CortexUserNotFoundError,CortexUnexpectedReplyError + """ + url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}" + data = { + "name": f"{user.firstname} {user.lastname}".strip() or user.username, + #"organisation": config.CORTEX_ORG_NAME, + #"roles": ["read", "analyze"], # TODO allow to set different roles? + } + print(url, data) + resp = _send_request("patch", url, data) + print(resp.status_code, resp.text) + if not resp.ok: + if resp.status_code == 404: + raise CortexUserNotFoundError() + print(f"Can't edit user in Cortex: Unexpected reply {resp.status_code}: {resp.text[:500]}") + raise CortexUnexpectedReplyError(f"Can't edit user in Cortex: Unexpected reply {resp.status_code}") + return None + + +def cortex_delete_user(login: str) -> None: + """Lock existing user from Cortex + + Cortex doesn't allow to delete user accounts, they can only be marked as Locked. + + :raise CortexUserNotFoundError,CortexUnexpectedReplyError + """ + url = CORTEX_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}" + data = { + "status": "Locked" + } + resp = _send_request("patch", url, data) + if not resp.ok: + if resp.status_code == 404: + raise CortexUserNotFoundError() + print(f"Can't delete user from Cortex: Unexpected reply {resp.status_code}: {resp.text[:500]}") + raise CortexUnexpectedReplyError(f"Can't delete user from Cortex: Unexpected reply {resp.status_code}") + return None + + +# ========================= +# Auxiliary functions + +def _send_request(method:str, url:str, data:Optional[dict]=None): + return getattr(requests, method)( + url, + headers={ + "Authorization": "Bearer " + config.CORTEX_API_KEY, + }, + verify=config.CA_CERT_FILE, + json=data + ) diff --git a/main.py b/main.py index 2666941bc02c1d59e7ad25db05c378062ce27241..dea769b96148cabdcccb2667da8c65a815c6af52 100644 --- a/main.py +++ b/main.py @@ -1,8 +1,6 @@ #!/usr/bin/env python3 import sys from datetime import datetime, timezone -import os.path -import re from typing import List, Dict, Optional, Union, Tuple from flask import Flask, render_template, request, make_response, redirect, flash, send_file @@ -19,6 +17,7 @@ import certificates from nifi import * from misp import * from thehive import * +from cortex import * app = Flask(__name__) app.secret_key = "ASDF1234 - CHANGE ME!" @@ -43,13 +42,17 @@ def load_config(): config.MISP_API_KEY = open(config.MISP_API_KEY_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long # Load API key for The Hive config.THEHIVE_API_KEY = open(config.THEHIVE_API_KEY_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long - # Load organization name (for The Hive) - config.THEHIVE_ORG_NAME = variables["domain"] + # Load API key for Cortex + config.CORTEX_API_KEY = open(config.CORTEX_API_KEY_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long + # Load organization name (for The Hive and Cortex) + config.THEHIVE_ORG_NAME = variables["org_name"] + config.CORTEX_ORG_NAME = variables["org_name"] print(f"Config loaded:\nSOCTOOLSPROXY={config.SOCTOOLSPROXY}\nKEYCLOAK_BASE_URL={config.KEYCLOAK_BASE_URL}\n" f"KEYCLOAK_ADMIN_PASSWORD={config.KEYCLOAK_ADMIN_PASSWORD[:3]}...{config.KEYCLOAK_ADMIN_PASSWORD[-4:]}\n" f"MISP_API_KEY={config.MISP_API_KEY[:3]}...{config.MISP_API_KEY[-4:]}\n" f"THEHIVE_API_KEY={config.THEHIVE_API_KEY[:3]}...{config.THEHIVE_API_KEY[-4:]}\n" + f"CORTEX_API_KEY={config.CORTEX_API_KEY[:3]}...{config.CORTEX_API_KEY[-4:]}\n" f"THEHIVE_ORG_NAME={config.THEHIVE_ORG_NAME}\n") @@ -318,6 +321,16 @@ def main(): # List of usernames only (for easier cross-check with Keycloak users) thehive_usernames = set(u["login"] for u in thehive_users) + # =================== + # Load Cortex users + try: + cortex_users = cortex_get_users() + except CortexError as e: + flash(f"ERROR: {e}", "error") + cortex_users = [] + # List of usernames only (for easier cross-check with Keycloak users) + cortex_usernames = set(u["login"] for u in cortex_users) + return render_template("main.html", **locals()) @@ -376,10 +389,19 @@ def add_user(): thehive_add_user(user) flash(f'User "{user.email}" successfully created in The Hive.', "success") except TheHiveUserExistsError: - flash(f'User with email "{user.email}" already exists in TheHive, nothing has changed.', "warning") + flash(f'User with email "{user.email}" already exists in The Hive, nothing has changed.', "warning") except Exception as e: flash(f'Error when creating user in The Hive: {e}', "error") + # Cortex + try: + cortex_add_user(user) + flash(f'User "{user.username}" successfully created in Cortex.', "success") + except CortexUserExistsError: + flash(f'User "{user.username}" already exists in Cortex, nothing has changed.', "warning") + except Exception as e: + flash(f'Error when creating user in Cortex: {e}', "error") + # Send email to the user if form_user.send_email.data: ok, err = _send_token(user.username, user.email) # TODO @@ -441,18 +463,33 @@ def edit_user(username: str): # The Hive # IMPORTANT: Email is used as user identifier, it can't be changed - we have to delete and re-create the account. - if user.email != new_user.email: - thehive_delete_user(user.email) - thehive_add_user(new_user) - flash(f'User {user.email} deleted and new user "{new_user.username}" created in The Hive.', "success") - else: - try: + try: + if user.email != new_user.email: + thehive_delete_user(user.email) + thehive_add_user(new_user) + flash(f'User "{user.email}" deleted and new user "{new_user.email}" created in The Hive.', "success") + else: thehive_edit_user(user.email, new_user) - flash(f'User "{new_user.username}" successfully updated in The Hive.', "success") - except TheHiveUserNotFoundError: - flash(f'Error when updating user in The Hive: User with email "{user.email}" not found', "error") - except Exception as e: - flash(f'Error when updating user in The Hive: {e}', "error") + flash(f'User "{new_user.email}" successfully updated in The Hive.', "success") + except TheHiveUserNotFoundError: + flash(f'Error when updating user in The Hive: User with email "{user.email}" not found', "error") + except Exception as e: + flash(f'Error when updating user in The Hive: {e}', "error") + + # Cortex + # IMPORTANT: Email is used as user identifier, it can't be changed - we have to delete (lock) and re-create the account. + try: + if user.email != new_user.email: + cortex_delete_user(user.email) + cortex_add_user(new_user) + flash(f'User "{user.email}" marked as locked and new user "{new_user.email}" created in Cortex.', "success") + else: + cortex_edit_user(user.email, new_user) + flash(f'User "{new_user.email}" successfully updated in Cortex.', "success") + except CortexUserNotFoundError: + flash(f'Error when updating user in Cortex: User with email "{user.email}" not found', "error") + except Exception as e: + flash(f'Error when updating user in Cortex: {e}', "error") return redirect("/") # Success - go back to main page @@ -508,6 +545,15 @@ def delete_user(username: str): except TheHiveError as e: flash(f'Error when deleting user from The Hive: {e}', "error") + # Cortex + try: + cortex_delete_user(user_spec.email) + flash(f'User "{user_spec.email}" marked as "locked" in Cortex.', "success") + except CortexUserNotFoundError: + flash(f'Error when trying to mark user as "locked" in Cortex: User with email "{user_spec.email}" not found', "error") + except TheHiveError as e: + flash(f'Error when trying to mark user as "locked" in Cortex: {e}', "error") + return redirect("/") diff --git a/static/style.css b/static/style.css index fc7ab0e6a56153250dde8a2fb43d326fb098b873..e42fb85ddece64aa8d829f0c5331a6dd09e44a56 100644 --- a/static/style.css +++ b/static/style.css @@ -47,3 +47,7 @@ input[readonly] { .internal-user { color: #ccc; } +.locked-user { + color: #aaa; + text-decoration: line-through; +} diff --git a/templates/add_edit_user.html b/templates/add_edit_user.html index d95c09aa1e9d4e34a18ec4a23f69675df9e95901..206270145f3c642a640acc90bc913dbbbe51b7e6 100644 --- a/templates/add_edit_user.html +++ b/templates/add_edit_user.html @@ -22,7 +22,7 @@ {{ form_user.username.label }} {{ form_user.username(size=20, readonly=True if user else False) }}<br> {{ form_user.firstname.label }} {{ form_user.firstname(size=20) }}<br> {{ form_user.lastname.label }} {{ form_user.lastname(size=20) }}<br> - {{ form_user.email.label }} {{ form_user.email(size=20) }}<br> + {{ form_user.email.label }} {{ form_user.email(size=20) }}{% if user %} Notice: Email is used as primary identifier and can't be changed in some services (TheHive, Cortex). If it's changed, the old user account will be deleted and a new one created in those services.{% endif %}<br> {% if user %} <input type="submit" value="Update user"> {% else %} diff --git a/templates/main.html b/templates/main.html index 50a828d8e9ce17b4ed54b5474072147dc15fed04..32acabd09d51f8ecb57402f6c2706b2032e4c875 100644 --- a/templates/main.html +++ b/templates/main.html @@ -14,8 +14,8 @@ document.getElementById('show-internal').addEventListener('change', function(eve </script> <table> -<tr><th>Username</th><th>First name</th><th>Last name</th><th>Email</th><th>CN</th><th>DN</th><th>Time created (UTC)</th><th>NiFi</th><th>MISP</th><th>The Hive</th><th>Actions</th> -{% for user in users %} +<tr><th>Username</th><th>First name</th><th>Last name</th><th>Email</th><th>CN</th><th>DN</th><th>Time created (UTC)</th><th>NiFi</th><th>MISP</th><th>The Hive</th><th>Cortex</th><th>Actions</th> +{% for user in users|sort(attribute="username") %} <tr{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.username }}</td> <td>{{ user.firstname }}</td> @@ -26,7 +26,8 @@ document.getElementById('show-internal').addEventListener('change', function(eve <td>{{ user.ts_created|ts_to_str }}</td> <td>{{ icon('check' if user.username in nifi_usernames else 'close') }}</td> <td>{{ icon('check' if user.email in misp_emails else 'close') }}</td> -<td>{{ icon('check' if user.login in thehive_usernames else 'close') }}</td> +<td>{{ icon('check' if user.email in thehive_usernames else 'close') }}</td> +<td>{{ icon('check' if user.email in cortex_usernames else 'close') }}</td> <td> {% if not user.internal -%} <a href="{{ url_for('edit_user', username=user.username) }}" title="Edit user">{{ icon('pencil') }}</a> @@ -47,7 +48,7 @@ document.getElementById('show-internal').addEventListener('change', function(eve <table> <tr><th>Username</th><th>Group</th><th>ID</th> -{% for user in nifi_users %} +{% for user in nifi_users|sort(attribute="name") %} <tr{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.name }}</td> <td>{{ user.groups|join(',') }}</td> @@ -61,7 +62,7 @@ document.getElementById('show-internal').addEventListener('change', function(eve <table> <tr><th>Email (username)</th><th>ID</th><th>Organization</th><th>Role</th><th>Created</th><th>Last login</th> -{% for user in misp_users %} +{% for user in misp_users|sort(attribute="email") %} <tr{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.email }}</td> <td>{{ user.id }}</td> @@ -76,9 +77,9 @@ document.getElementById('show-internal').addEventListener('change', function(eve <h3>The Hive</h3> <table> -<tr><th>Login</th><th>Name</th><th>Organization</th><th>Role (profile)</th><th>ID</th><th>Created</th><th></th> +<tr><th>Login</th><th>Name</th><th>Organization</th><th>Role (profile)</th><th>ID</th><th>Created</th> -{% for user in thehive_users %} +{% for user in thehive_users|sort(attribute="login") %} <tr{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.login }}</td> <td>{{ user.name }}</td> @@ -90,4 +91,20 @@ document.getElementById('show-internal').addEventListener('change', function(eve {% endfor %} </table> +<h3>Cortex</h3> +<table> +<tr><th>Login</th><th>Name</th><th>Organization</th><th>Roles</th><th>Status</th><th>Created</th> + +{% for user in cortex_users|sort(attribute="login")|sort(attribute="status",reverse=True) %} +<tr{% if user.status == "Locked"%} class="locked-user"{% elif user.internal %} class="internal-user"{% endif %}> +<td>{{ user.login }}</td> +<td>{{ user.name }}</td> +<td>{{ user.org }}</td> +<td>{{ user.roles|join(', ') }}</td> +<td>{{ user.status }}</td> +<td>{{ user.created|ts_to_str }}</td> +</tr> +{% endfor %} +</table> + {% endblock %} \ No newline at end of file diff --git a/thehive.py b/thehive.py index f7fec61e72db581e38409d2b0e6f26f1778c6373..2083b029abade974713df3143bc17a1e8d0c7f7b 100644 --- a/thehive.py +++ b/thehive.py @@ -3,12 +3,8 @@ from typing import List, Dict, Optional import requests from datetime import datetime -import re -from operator import itemgetter -import urllib.parse import config -from main import UserAccount # Base URL to The Hive API endpoints THEHIVE_API_BASE_URL = "https://{soctools_proxy}:9000/api/v1" @@ -72,7 +68,7 @@ def thehive_get_users() -> List[Dict]: return users -def thehive_add_user(user: UserAccount) -> None: +def thehive_add_user(user: 'UserAccount') -> None: """Add a new user to TheHive :raise TheHiveUnexpectedReplyError @@ -80,7 +76,7 @@ def thehive_add_user(user: UserAccount) -> None: url = THEHIVE_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/user" data = { "login": user.email, - "name": f"{user.firstname} {user.lastname}" if (user.firstname or user.lastname) else user.username, + "name": f"{user.firstname} {user.lastname}".strip() or user.username, "organisation": config.THEHIVE_ORG_NAME, "profile": "analyst", # TODO allow to set different roles? #"email": user.email, @@ -96,14 +92,14 @@ def thehive_add_user(user: UserAccount) -> None: return None -def thehive_edit_user(login: str, user: UserAccount) -> None: +def thehive_edit_user(login: str, user: 'UserAccount') -> None: """Edit existing user in The Hive (only name can be changed) :raise TheHiveUnexpectedReplyError """ url = THEHIVE_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}" data = { - "name": f"{user.firstname} {user.lastname}".strip() if (user.firstname or user.lastname) else user.username, + "name": f"{user.firstname} {user.lastname}".strip() or user.username, #"organisation": config.THEHIVE_ORG_NAME, #"profile": "analyst", # TODO allow to set different roles? }