diff --git a/config.py b/config.py index d830ac717501e411dccb52d7df8daa6cf919ab16..f990a5daa952955da33be8742615bda5ea766f75 100644 --- a/config.py +++ b/config.py @@ -11,6 +11,7 @@ CA_CERT_FILE = os.path.join(SOCTOOLS_BASE, "secrets/CA/ca.crt") KEYCLOAK_ADMIN_PASSWORD_FILE = os.path.join(SOCTOOLS_BASE, "secrets/passwords/keykloak_admin") # Note: should be keycloak, not keykloak 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") # Credentials of the special user for account management # Cert and key should be in .pem format, unencrypted @@ -31,3 +32,6 @@ KEYCLOAK_USERS_URL = None KEYCLOAK_ADMIN_PASSWORD = None MISP_API_KEY = None + +THEHIVE_API_KEY = None +THEHIVE_ORG_NAME = None # set to "domain" from variables file diff --git a/main.py b/main.py index 5d8d0de9aca6ab623f0ab4fa6e6320c9fce7a71c..2666941bc02c1d59e7ad25db05c378062ce27241 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,7 @@ import config import certificates from nifi import * from misp import * +from thehive import * app = Flask(__name__) app.secret_key = "ASDF1234 - CHANGE ME!" @@ -40,10 +41,16 @@ def load_config(): config.KEYCLOAK_ADMIN_PASSWORD = open(config.KEYCLOAK_ADMIN_PASSWORD_FILE, "r").read(100).strip() # read max 100 B, the key should never be so long # Load API key for MISP 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"] 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"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"THEHIVE_ORG_NAME={config.THEHIVE_ORG_NAME}\n") # *** Custom Jinja filters *** @@ -297,6 +304,20 @@ def main(): # List of usernames only (for easier cross-check with Keycloak users) misp_emails = set(mu["email"] for mu in misp_users) + # =================== + # Load The Hive users + try: + thehive_users = thehive_get_users() + except TheHiveError as e: + flash(f"ERROR: {e}", "error") + thehive_users = [] + # Mark "internal" users + for u in thehive_users: + if u["login"].startswith("admin@") or u["login"].startswith("kibana@"): + u["internal"] = True + # List of usernames only (for easier cross-check with Keycloak users) + thehive_usernames = set(u["login"] for u in thehive_users) + return render_template("main.html", **locals()) @@ -350,9 +371,18 @@ def add_user(): except Exception as e: flash(f'Error when creating user in MISP: {e}', "error") + # The Hive + try: + 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") + except Exception as e: + flash(f'Error when creating user in The Hive: {e}', "error") + # Send email to the user if form_user.send_email.data: - ok, err = _send_token(user.username, user.email) + ok, err = _send_token(user.username, user.email) # TODO if ok: flash(f"Email successfully sent to '{user.email}'", "success") else: @@ -404,11 +434,26 @@ def edit_user(username: str): flash(f'Error when updating user in MISP: User with email "{user.email}" not found', "error") except MISPUserExistsError: flash(f'Error when updating user in MISP: User with email "{new_user.email}" already exists. ' - 'BEWARE: An inconsistency in user accounts in Keycloak and MISP was probably just introduced ' - 'which needs to be fixed manually in the administration of the individual services!', "error") + 'BEWARE: An inconsistency in user accounts in Keycloak and MISP was probably just created. ' + 'It needs to be fixed manually in the administration of the individual services!', "error") except Exception as e: flash(f'Error when updating user in MISP: {e}', "error") + # 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: + 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") + return redirect("/") # Success - go back to main page # data not valid - show form again @@ -454,6 +499,15 @@ def delete_user(username: str): except MISPError as e: flash(f'Error when deleting user from MISP: {e}', "error") + # The Hive + try: + thehive_delete_user(user_spec.email) + flash(f'User "{user_spec.email}" successfully deleted from The Hive.', "success") + except TheHiveUserNotFoundError: + flash(f'Error when deleting user from The Hive: User with email "{user_spec.email}" not found', "error") + except TheHiveError as e: + flash(f'Error when deleting user from The Hive: {e}', "error") + return redirect("/") @@ -541,7 +595,7 @@ def _send_token(username: str, email: str) -> Tuple[bool, Optional[str]]: # (re)send cert-access token for existing user - DONE (on click in table) # automatically create certificate when creating new user (optionally automatically send email with token) - DONE # revoke and delete certificate when user is deleted -# make CN=username (so cert filename also matches the username (it's stored by CN)) +# make CN=username (so cert filename also matches the username (it's stored by CN)) - DONE # @app.route("/test_cert/<func>") diff --git a/templates/main.html b/templates/main.html index 31c907a02505991d323c97a1fcf1db0e174435fe..50a828d8e9ce17b4ed54b5474072147dc15fed04 100644 --- a/templates/main.html +++ b/templates/main.html @@ -14,7 +14,7 @@ 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>Actions</th> +<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{% if user.internal %} class="internal-user"{% endif %}> <td>{{ user.username }}</td> @@ -26,13 +26,14 @@ 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> {% if not user.internal -%} <a href="{{ url_for('edit_user', username=user.username) }}" title="Edit user">{{ icon('pencil') }}</a> <a href="{{ url_for('send_token', username=user.username) }}" title="Re-send email with token for certificate download" onclick="return confirm('Send an email to "{{user.email}}" containing a unique URL allowing to download the user\'s certificate and private key?')">{{ icon('envelope') }}</a> <a href="{{ url_for('delete_user', username=user.username) }}" title="Delete user" - onclick="return confirm('Are you sure you want to permanently delete user account "{{user.username}}" ({{user.cn}}, {{user.email}})?')">{{ icon('trash') }}</a> + onclick="return confirm('Are you sure you want to permanently delete user account "{{user.username}}" ({{user.email}})?')">{{ icon('trash') }}</a> {%- endif %} </td> </tr> @@ -72,4 +73,21 @@ document.getElementById('show-internal').addEventListener('change', function(eve {% endfor %} </table> + +<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> + +{% for user in thehive_users %} +<tr{% if user.internal %} class="internal-user"{% endif %}> +<td>{{ user.login }}</td> +<td>{{ user.name }}</td> +<td>{{ user.org }}</td> +<td>{{ user.role }}</td> +<td>{{ user.id }}</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 new file mode 100644 index 0000000000000000000000000000000000000000..f7fec61e72db581e38409d2b0e6f26f1778c6373 --- /dev/null +++ b/thehive.py @@ -0,0 +1,149 @@ +"""Functions to manage user accounts in The Hive""" + +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" + +# The Hive API documentation: http://docs.thehive-project.org/thehive/api/ +# But a lot of information is missing there, it is easier to just intercept queries made by a browser. + +class TheHiveError(Exception): + pass + +class TheHiveUserNotFoundError(TheHiveError): + pass + +class TheHiveUserExistsError(TheHiveError): + pass + +class TheHiveUnexpectedReplyError(TheHiveError): + pass + + +# ========================= +# Public interface + +def thehive_get_users() -> List[Dict]: + """ + List users defined in The Hive in the configured organization + + :return List of dicts with keys 'id', 'login', 'name', 'role', 'org', 'created' (datetime) + :raise TheHiveUnexpectedReplyError + """ + url = THEHIVE_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + "/query" + data = { + "query": [ + {"_name": "getOrganisation", "idOrName": config.THEHIVE_ORG_NAME}, + {"_name": "users"} + ] + } + + resp = _send_request("post", url, data=data) + #print(url) + #print(resp.request.headers) + #print(resp.status_code) + if not resp.ok: + raise TheHiveUnexpectedReplyError(f"Can't get list of users from The Hive: Unexpected reply {resp.status_code}") + #print(resp.json()) + users = [] + try: + for user_entry in resp.json(): + users.append({ + "id": user_entry["_id"], + "login": user_entry["login"], + "name": user_entry["name"], + "role": user_entry["profile"], + "org": user_entry["organisation"], + "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 The Hive: Unexpected content received: {type(e).__name__}: {e})") + raise TheHiveUnexpectedReplyError(f"Can't get list of users from The Hive: Unexpected content received") + + return users + + +def thehive_add_user(user: UserAccount) -> None: + """Add a new user to TheHive + + :raise TheHiveUnexpectedReplyError + """ + 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, + "organisation": config.THEHIVE_ORG_NAME, + "profile": "analyst", # TODO allow to set different roles? + #"email": user.email, + #"password": "", + } + resp = _send_request("post", url, data) + #print(resp.json()) + if not resp.ok: + print(f"Can't add user to The Hive: Unexpected reply {resp.status_code}: {resp.text[:500]}") + raise TheHiveUnexpectedReplyError(f"Can't add user to The Hive: Unexpected reply {resp.status_code}") + # Note: There is no check for existing user, because it seems TheHive just do nothing if user already exists. + # There is probably no way to recognize it. + return 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, + #"organisation": config.THEHIVE_ORG_NAME, + #"profile": "analyst", # TODO allow to set different roles? + } + + resp = _send_request("patch", url, data) + #print(resp.text) + if not resp.ok: + if resp.status_code == 404: + raise TheHiveUserNotFoundError() + print(f"Can't edit user in The Hive: Unexpected reply {resp.status_code}: {resp.text[:500]}") + raise TheHiveUnexpectedReplyError(f"Can't edit user in The Hive: Unexpected reply {resp.status_code}") + return None + + +def thehive_delete_user(login: str) -> None: + """Delete existing user from The Hive + + :raise TheHiveUnexpectedReplyError + """ + url = THEHIVE_API_BASE_URL.format(soctools_proxy=config.SOCTOOLSPROXY) + f"/user/{login}/force?organisation={config.THEHIVE_ORG_NAME}" + #print(url) + resp = _send_request("delete", url) + #print(resp.text) + if not resp.ok: + if resp.status_code == 404: + raise TheHiveUserNotFoundError() + print(f"Can't delete user from The Hive: Unexpected reply {resp.status_code}: {resp.text[:500]}") + raise TheHiveUnexpectedReplyError(f"Can't delete user from The Hive: 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.THEHIVE_API_KEY, + }, + verify=config.CA_CERT_FILE, + json=data + )