From 1100941f00cd6c227f9e8dc975de8448a3097879 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:18:43 +0800 Subject: [PATCH 01/16] MSEntraID - multiple new responders --- responders/MSEntraID/MSEntraID.py | 186 ++++++++++++++---- .../MSEntraID_ForcePasswordReset.json | 38 ++++ .../MSEntraID_ForcePasswordResetWithMFA.json | 38 ++++ .../MSEntraID/MSEntraID_TokenRevoker.json | 5 +- .../MSEntraID/MSEntraID_disableUser.json | 38 ++++ .../MSEntraID/MSEntraID_enableUser.json | 38 ++++ 6 files changed, 300 insertions(+), 43 deletions(-) create mode 100644 responders/MSEntraID/MSEntraID_ForcePasswordReset.json create mode 100644 responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json create mode 100644 responders/MSEntraID/MSEntraID_disableUser.json create mode 100644 responders/MSEntraID/MSEntraID_enableUser.json diff --git a/responders/MSEntraID/MSEntraID.py b/responders/MSEntraID/MSEntraID.py index 4b8526cf6..80acbb88f 100755 --- a/responders/MSEntraID/MSEntraID.py +++ b/responders/MSEntraID/MSEntraID.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # encoding: utf-8 -# Author: Daniel Weiner @dmweiner, revised by @jahamilto +# Author: Daniel Weiner @dmweiner; revised by @jahamilto; nusantara-self, StrangeBee import requests import traceback import datetime @@ -14,57 +14,159 @@ def __init__(self): self.client_secret = self.get_param('config.client_secret', None, 'Microsoft Entra ID Registered Application Client Secret Missing') self.tenant_id = self.get_param('config.tenant_id', None, 'Microsoft Entra ID Tenant ID Mising') self.time = '' - def run(self): - Responder.run(self) + self.service = self.get_param('config.service', None) - if self.get_param('data.dataType') == 'mail': - try: - self.user = self.get_param('data.data', None, 'No UPN supplied to revoke credentials for') - if not self.user: - self.error("No user supplied") + def authenticate(self): + token_data = { + "grant_type": "client_credentials", + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'scope': 'https://graph.microsoft.com/.default' + } + + redirect_uri = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + token_r = requests.post(redirect_uri, data=token_data) + + if token_r.status_code != 200: + self.error(f'Failure to obtain Azure access token: {token_r.content}') + + return token_r.json().get('access_token') - token_data = { - "grant_type": "client_credentials", - 'client_id': self.client_id, - 'client_secret': self.client_secret, - 'resource': 'https://graph.microsoft.com', - 'scope': 'https://graph.microsoft.com' - } + def check_user_status(self, user, headers, base_url): + r = requests.get(f"{base_url}{user}?$select=accountEnabled", headers=headers) + if r.status_code == 404: + self.error(f'User {user} not found in Microsoft Entra ID') + return None + elif r.status_code != 200: + try: + error_message = r.json().get("error", {}).get("message", "Unknown error") + except ValueError: + error_message = "Invalid response received from API" + self.error(f'Failure to retrieve user status for {user}: {error_message}') + return None - #Authenticate to the graph api - - redirect_uri = "https://login.microsoftonline.com/{}/oauth2/token".format(self.tenant_id) - token_r = requests.post(redirect_uri, data=token_data) - token = token_r.json().get('access_token') - - if token_r.status_code != 200: - self.error('Failure to obtain azure access token: {}'.format(token_r.content)) + try: + user_data = r.json() + return user_data.get("accountEnabled", None) + except ValueError: + self.error("Invalid JSON response received") + return None - # Set headers for future requests - headers = { - 'Authorization': 'Bearer {}'.format(token) - } - base_url = 'https://graph.microsoft.com/v1.0/' + def run(self): + Responder.run(self) + token = self.authenticate() + headers = {'Authorization': f'Bearer {token}', 'Content-Type': 'application/json'} + base_url = 'https://graph.microsoft.com/v1.0/users/' + + if self.service == "tokenRevoker": + if self.get_param('data.dataType') == 'mail': + try: + self.user = self.get_param('data.data', None, 'No UPN supplied to revoke credentials for') + if not self.user: + self.error("No user supplied") + + r = requests.post(f"{base_url}{self.user}/revokeSignInSessions", headers=headers) + + if r.status_code != 200: + self.error(f'Failure to revoke access tokens of user {self.user}: {r.content}') + else: + self.time = datetime.datetime.utcnow() + except Exception: + self.error(traceback.format_exc()) - r = requests.post(base_url + 'users/{}/revokeSignInSessions'.format(self.user), headers=headers) - - if r.status_code != 200: - self.error('Failure to revoke access tokens of user {}: {}'.format(self.user, r.content)) + full_report = {"message": f"User {self.user} authentication tokens successfully revoked at {self.time}"} + self.report(full_report) + else: + self.error('Incorrect dataType. "mail" expected.') + + elif self.service == "forcePasswordReset": + if self.get_param('data.dataType') == 'mail': + try: + self.user = self.get_param('data.data', None, 'No UPN supplied for password reset') + if not self.user: + self.error("No user supplied") + + data = {"passwordProfile": {"forceChangePasswordNextSignIn": True}} + r = requests.patch(f"{base_url}{self.user}", headers=headers, json=data) + + if r.status_code != 204: + self.error(f'Failure to reset password for user {self.user}: {r.content}') + + self.report({"message": f"Password reset initiated for user {self.user}, user will be prompted to change it at next sign-in"}) + except Exception: + self.error(traceback.format_exc()) + else: + self.error('Incorrect dataType. "mail" expected.') + + elif self.service == "forcePasswordResetWithMFA": + try: + self.user = self.get_param('data.data', None, 'No UPN supplied for password reset with MFA') + if not self.user: + self.error("No user supplied") - else: - #record time of successful auth token revokation - self.time = datetime.datetime.utcnow() - - except Exception as ex: + data = {"passwordProfile": {"forceChangePasswordNextSignIn": True, "forceChangePasswordNextSignInWithMfa": True}} + r = requests.patch(f"{base_url}{self.user}", headers=headers, json=data) + + if r.status_code != 204: + self.error(f'Failure to reset password with MFA for user {self.user}: {r.content}') + + self.report({"message": f"Password reset initiated for user {self.user}, user will be prompted for MFA and password change at next sign-in"}) + except Exception: self.error(traceback.format_exc()) - # Build report to return to Cortex - full_report = {"message": "User {} authentication tokens successfully revoked at {}".format(self.user, self.time)} - self.report(full_report) - else: - self.error('Incorrect dataType. "mail" expected.') + + elif self.service == "enableUser": + if self.get_param('data.dataType') == 'mail': + try: + self.user = self.get_param('data.data', None, 'No UPN supplied to enable user') + if not self.user: + self.error("No user supplied") + + user_status = self.check_user_status(self.user, headers, base_url) + if user_status is True: + self.report({"message": f"User {self.user} is already enabled"}) + return + + data = {"accountEnabled": True} + r = requests.patch(f"{base_url}{self.user}", headers=headers, json=data) + + if r.status_code != 204: + self.error(f'Failure to enable user {self.user}: {r.content}') + + self.report({"message": f"User {self.user} has been enabled"}) + except Exception: + self.error(traceback.format_exc()) + else: + self.error('Incorrect dataType. "mail" expected.') + + elif self.service == "disableUser": + if self.get_param('data.dataType') == 'mail': + try: + self.user = self.get_param('data.data', None, 'No UPN supplied to disable user') + if not self.user: + self.error("No user supplied") + + user_status = self.check_user_status(self.user, headers, base_url) + if user_status is False: + self.report({"message": f"User {self.user} is already disabled"}) + return + + data = {"accountEnabled": False} + r = requests.patch(f"{base_url}{self.user}", headers=headers, json=data) + + if r.status_code != 204: + self.error(f'Failure to disable user {self.user}: {r.content}') + + self.report({"message": f"User {self.user} has been disabled"}) + except Exception: + self.error(traceback.format_exc()) + else: + self.error('Incorrect dataType. "mail" expected.') + + else: + self.error({'message': "Unidentified service"}) if __name__ == '__main__': MSEntraID().run() diff --git a/responders/MSEntraID/MSEntraID_ForcePasswordReset.json b/responders/MSEntraID/MSEntraID_ForcePasswordReset.json new file mode 100644 index 000000000..6af48e08c --- /dev/null +++ b/responders/MSEntraID/MSEntraID_ForcePasswordReset.json @@ -0,0 +1,38 @@ +{ + "name": "MSEntraID_ForcePasswordReset", + "version": "1.0", + "author": "nusatanra-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Force password reset at next login", + "dataTypeList": ["thehive:case_artifact"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "forcePasswordReset" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} diff --git a/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json b/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json new file mode 100644 index 000000000..23ee603aa --- /dev/null +++ b/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json @@ -0,0 +1,38 @@ +{ + "name": "MSEntraID_ForcePasswordResetWithMFA", + "version": "1.0", + "author": "nusatanra-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Force password reset at next login with MFA verification before password change", + "dataTypeList": ["thehive:case_artifact"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "forcePasswordResetWithMFA" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} diff --git a/responders/MSEntraID/MSEntraID_TokenRevoker.json b/responders/MSEntraID/MSEntraID_TokenRevoker.json index 32454cbfe..0c90c83c5 100644 --- a/responders/MSEntraID/MSEntraID_TokenRevoker.json +++ b/responders/MSEntraID/MSEntraID_TokenRevoker.json @@ -1,13 +1,16 @@ { "name": "MSEntraID_TokenRevoker", "version": "1.1", - "author": "Daniel Weiner @dmweiner, revised by @jahamilto", + "author": "Daniel Weiner @dmweiner; revised by @jahamilto; nusantara-self, StrangeBee", "url": "https://github.com/TheHive-Project/Cortex-Analyzers", "license": "AGPL-V3", "description": "Revoke all Microsoft Entra ID authentication session tokens for a User Principal Name.", "dataTypeList": ["thehive:case_artifact"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", + "config": { + "service": "tokenRevoker" + }, "configurationItems": [ {"name": "tenant_id", "description": "Microsoft Entra ID Tenant ID", diff --git a/responders/MSEntraID/MSEntraID_disableUser.json b/responders/MSEntraID/MSEntraID_disableUser.json new file mode 100644 index 000000000..6713e9acd --- /dev/null +++ b/responders/MSEntraID/MSEntraID_disableUser.json @@ -0,0 +1,38 @@ +{ + "name": "MSEntraID_disableUser", + "version": "1.0", + "author": "nusatanra-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Disable user in Microsoft Entra ID", + "dataTypeList": ["thehive:case_artifact"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "disableUser" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} diff --git a/responders/MSEntraID/MSEntraID_enableUser.json b/responders/MSEntraID/MSEntraID_enableUser.json new file mode 100644 index 000000000..1a4f7085e --- /dev/null +++ b/responders/MSEntraID/MSEntraID_enableUser.json @@ -0,0 +1,38 @@ +{ + "name": "MSEntraID_enableUser", + "version": "1.0", + "author": "nusatanra-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Enable user in Microsoft Entra ID", + "dataTypeList": ["thehive:case_artifact"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "enableUser" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} From bf30f1b15c26e1eb2e79731b65603010288d8b23 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Thu, 30 Jan 2025 16:53:25 +0800 Subject: [PATCH 02/16] Update README.md --- responders/MSEntraID/README.md | 54 ++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/responders/MSEntraID/README.md b/responders/MSEntraID/README.md index 32b8e1db6..8a4785cd7 100644 --- a/responders/MSEntraID/README.md +++ b/responders/MSEntraID/README.md @@ -1,21 +1,35 @@ -## Microsoft Entra ID Sign In Token Revoker Responder +## Microsoft Entra ID Responders -This responder allows you to revoke the session tokens for an Microsoft Entra ID user. Requires the UPN of the account in question, which should be entered as a "mail" observable in TheHive. +These responders provide various user management capabilities for Microsoft Entra ID, including revoking session tokens, enabling/disabling users, and enforcing password resets. -### Config +### Available Responders + +- **Token Revoker (`tokenRevoker`)** – Revokes session tokens for a Microsoft Entra ID user. + +- **Password Reset (`forcePasswordReset`)** – Forces a password reset at the next login. + +- **Password Reset with MFA (`forcePasswordResetWithMFA`)** – Forces a password reset at the next login, requiring multi-factor authentication (MFA) before changing the password. + +- **Enable User (`enableUser`)** – Enables a previously disabled user account. + +- **Disable User (`disableUser`)** – Disables a user account, preventing further sign-ins. + + +### Configuration To enable the responder, you need three values: -1. Microsoft Entra ID Tenant ID -2. Application ID -3. Application Secret -The first two values can be found at any time in the application's Overview page in the Microsoft Entra ID portal. The secret must be generated and then stored in a safe place, as it is only fully visible when you first make it. +1. **Microsoft Entra ID Tenant ID** +2. **Application ID** +3. **Application Secret** + +The first two values can be found at any time in the application's ***Overview*** page in the [Microsoft Entra ID Portal](https://entra.microsoft.com/). The secret must be generated and then stored in a safe place, as it is only fully visible when you first make it. ## Setup -### Prereqs -User account with the Cloud Application Administrator role. -User account with the Global Administrator Role (most of the steps can be done with only the Cloud App Administrator role, but the final authorization for its API permissions requires GA). +### Pre-requisites + - User account with the **Cloud Application Administrator** role. + - User account with the **Global Administrator Role** (most of the steps can be done with only the Cloud App Administrator role, but the final authorization for its API permissions requires GA). ### Steps @@ -25,13 +39,23 @@ User account with the Global Administrator Role (most of the steps can be done w 3. Provide a display name (this can be anything, and can be changed later). Click Register. #### Secret -4. Navigate to Certificates and Secrets. +4. Navigate to **Certificates and Secrets**. 5. Create a new client secret. Enter a relevant description and set a security-conscious expiration date. 6. Copy the Value. **This will only be fully visible for a short time, so you should immediately copy it and store it in a safe place**. #### API Permissions -7. Navigate to API permissions. -8. Add the Directory.ReadWrite.All and User.ReadWrite.All permissions (Microsoft Graph API, application permissions). -9. Using a GA account, select the "Grant admin consent for *TENANTNAME*" button. +7. Navigate to **API permissions**. +8. Add the `Directory.ReadWrite.All` and `User.ReadWrite.All` permissions (Microsoft Graph API, application permissions). +9. Using a GA account, select the "`Grant admin consent for *TENANTNAME*`" button. +10. Place the relevant values into the config within Cortex. + +*Note: You may use less permissive permissions, such as `User.RevokeSessions.All` for tokenRevoker and so on. Please see the below references* + + +### References + +- [Microsoft Graph API - Revoke Sign-In Sessions](https://learn.microsoft.com/en-us/graph/api/user-revokesigninsessions?view=graph-rest-1.0) + +- [Microsoft Graph API - Reset Password](https://learn.microsoft.com/en-us/graph/api/authenticationmethod-resetpassword?view=graph-rest-1.0) -10. Place the relevant values into the config within Cortex. \ No newline at end of file +- [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference) \ No newline at end of file From 59232aaf22dca589a07ffcef3835d1dc2b0d0a08 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:44:08 +0800 Subject: [PATCH 03/16] Update Auth + GetUserInfo analyzer addition --- analyzers/MSEntraID/MSEntraID.py | 365 ++++++++++++------ analyzers/MSEntraID/MSEntraID_GetSignIns.json | 3 + .../MSEntraID/MSEntraID_GetUserInfo.json | 65 ++++ 3 files changed, 319 insertions(+), 114 deletions(-) create mode 100644 analyzers/MSEntraID/MSEntraID_GetUserInfo.json diff --git a/analyzers/MSEntraID/MSEntraID.py b/analyzers/MSEntraID/MSEntraID.py index 93096f351..1b2d93f00 100755 --- a/analyzers/MSEntraID/MSEntraID.py +++ b/analyzers/MSEntraID/MSEntraID.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # encoding: utf-8 -# Author: @jahamilto +# Author: @jahamilto; nusatanra-self, StrangeBee import requests import traceback from datetime import datetime, timedelta @@ -17,144 +17,281 @@ def __init__(self): self.lookup_limit = self.get_param('config.lookup_limit', 12) self.state = self.get_param('config.state', None) self.country = self.get_param('config.country', None) + self.service = self.get_param('config.service', None) + self.params_list = self.get_param('config.params_list', []) + def authenticate(self): + token_data = { + "grant_type": "client_credentials", + 'client_id': self.client_id, + 'client_secret': self.client_secret, + 'scope': 'https://graph.microsoft.com/.default' + } - def run(self): - Analyzer.run(self) + redirect_uri = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token" + token_r = requests.post(redirect_uri, data=token_data) - if self.data_type == 'mail': - try: - self.user = self.get_data() - if not self.user: - self.error("No user supplied") - - - token_data = { - "grant_type": "client_credentials", - 'client_id': self.client_id, - 'client_secret': self.client_secret, - 'resource': 'https://graph.microsoft.com', - 'scope': 'https://graph.microsoft.com' - } - - filter_time = datetime.utcnow() - timedelta(days=self.time_range) - format_time = str("{}T00:00:00Z".format(filter_time.strftime("%Y-%m-%d"))) + if token_r.status_code != 200: + self.error(f'Failure to obtain Azure access token: {token_r.content}') + return token_r.json().get('access_token') + def handle_get_signins(self, headers, base_url): + """ + Retrieve sign-in logs for a userPrincipalName within a specified time range. + """ + if self.data_type != 'mail': + self.error('Incorrect dataType. "mail" expected.') - #Authenticate to the graph api + try: + self.user = self.get_data() + if not self.user: + self.error("No user supplied") - redirect_uri = "https://login.microsoftonline.com/{}/oauth2/token".format(self.tenant_id) - token_r = requests.post(redirect_uri, data=token_data) - token = token_r.json().get('access_token') + # Build the filter time + filter_time = datetime.utcnow() - timedelta(days=self.time_range) + format_time = filter_time.strftime('%Y-%m-%dT00:00:00Z') - if token_r.status_code != 200: - self.error('Failure to obtain azure access token: {}'.format(token_r.content)) + # Query sign-in logs + endpoint = ( + f"auditLogs/signIns?$filter=startsWith(userPrincipalName,'{self.user}') " + f"and createdDateTime ge {format_time}&$top={self.lookup_limit}" + ) + r = requests.get(base_url + endpoint, headers=headers) - # Set headers for future requests - headers = { - 'Authorization': 'Bearer {}'.format(token) - } + if r.status_code != 200: + self.error(f"Failure to pull sign-ins for user {self.user}: {r.content}") - base_url = 'https://graph.microsoft.com/v1.0/' - - r = requests.get(base_url + "auditLogs/signIns?$filter=startsWith(userPrincipalName,'{}') and createdDateTime ge {}&$top={}".format(self.user, format_time, self.lookup_limit), headers=headers) + signins_data = r.json().get('value', []) - # Check API results - if r.status_code != 200: - self.error('Failure to pull sign ins of user {}: {}'.format(self.user, r.content)) + new_json = { + "filterParameters": None, + "signIns": [] + } + + # Counters for summary + risks = 0 + ex_state = 0 + ex_country = 0 + + for signin in signins_data: + # Basic details + basic_details = {} + basic_details["signInTime"] = signin.get("createdDateTime", "N/A") + basic_details["ip"] = signin.get("ipAddress", "N/A") + basic_details["appName"] = signin.get("appDisplayName", "N/A") + basic_details["clientApp"] = signin.get("clientAppUsed", "N/A") + basic_details["resourceName"] = signin.get("resourceDisplayName", "N/A") + + # Determine success/failure + success = False + status_info = signin.get("status", {}) + if status_info.get("errorCode") == 0: + basic_details["result"] = "Success" + success = True else: - full_json = r.json()['value'] + failure_reason = status_info.get("failureReason", "") + basic_details["result"] = f"Failure: {failure_reason}" if failure_reason else "Failure" - new_json = { - "filterParameters": None, - "signIns": [] - } + # Risk level + basic_details["riskLevel"] = signin.get("riskLevelDuringSignIn", "none") + if basic_details["riskLevel"] != "none" and success: + risks += 1 - # Summary statistics - risks = ex_state = ex_country = 0 + # Device details + device_info = signin.get("deviceDetail", {}) + device_details = { + "id": device_info.get("deviceId") or "Not Available", + "deviceName": device_info.get("displayName") or "Not Available", + "operatingSystem": device_info.get("operatingSystem", "N/A") + } - for signin in full_json: + # Location details + location_info = signin.get("location", {}) + location_details = { + "city": location_info.get("city", "N/A"), + "state": location_info.get("state", "N/A"), + "countryOrRegion": location_info.get("countryOrRegion", "N/A") + } - success = False + # If sign-in was successful, check if it differs from specified state/country + if success: + if self.state and location_details["state"] != self.state: + ex_state += 1 + if self.country and location_details["countryOrRegion"] != self.country: + ex_country += 1 - details = {} - details["signInTime"] = signin["createdDateTime"] - details["ip"] = signin["ipAddress"] - details["appName"] = signin["appDisplayName"] - details["clientApp"] = signin["clientAppUsed"] - details["resourceName"] = signin["resourceDisplayName"] - # Check how to format status result - if signin["status"]["errorCode"] == 0: - details["result"] = "Success" - success = True + # Applied Conditional Access Policies + applied_policies = signin.get("appliedConditionalAccessPolicies", []) + cAC = "None" + for pol in applied_policies: + if pol.get("result") == "success": + policy_name = pol.get("displayName", "Unknown") + if cAC == "None": + cAC = policy_name else: - details["result"] = "Failure: " + signin["status"]["failureReason"] - details["riskLevel"] = signin["riskLevelDuringSignIn"] - #Increase risk counter - if details["riskLevel"] != 'none' and success: risks += 1 - - device = {} - device_info = signin["deviceDetail"] - device["id"] = "Not Available" if device_info["deviceId"] == "" else device_info["deviceId"] - device["deviceName"] = "Not Available" if device_info["displayName"] == "" else device_info["displayName"] - device["operatingSystem"] = device_info["operatingSystem"] - - location = {} - location_info = signin["location"] - location["city"] = location_info["city"] - location["state"] = location_info["state"] - if self.state and location["state"] != self.state and success: ex_state += 1 - location["countryOrRegion"] = location_info["countryOrRegion"] - if self.country and location["countryOrRegion"] != self.country and success: ex_country += 1 - - - cAC = "None" - for policies in signin["appliedConditionalAccessPolicies"]: - if policies["result"] == "success": - if cAC == 'None': - cAC = policies["displayName"] - else: - cAC += (", " + policies["displayName"]) - - - new_json["signIns"].append({ - "id": signin["id"], - "basicDetails": dict(details), - "deviceDetails": dict(device), - "locationDetails": dict(location), - "appliedConditionalAccessPolicies": cAC - }) - - new_json["sum_stats"] = {"riskySignIns": risks, "externalStateSignIns": ex_state, "foreignSignIns": ex_country} - new_json["filterParameters"] = "Top {} signins from the last {} days. Displaying {} signins.".format(self.lookup_limit, self.time_range, len(new_json["signIns"])) - - # Build report to return to Cortex - self.report(new_json) - - except Exception as ex: - self.error(traceback.format_exc()) - - else: + cAC += f", {policy_name}" + + new_json["signIns"].append({ + "id": signin.get("id", "N/A"), + "basicDetails": basic_details, + "deviceDetails": device_details, + "locationDetails": location_details, + "appliedConditionalAccessPolicies": cAC + }) + + # Summary stats + new_json["sum_stats"] = { + "riskySignIns": risks, + "externalStateSignIns": ex_state, + "foreignSignIns": ex_country + } + + new_json["filterParameters"] = ( + f"Top {self.lookup_limit} signins from the last {self.time_range} days. " + f"Displaying {len(new_json['signIns'])} signins." + ) + + self.report(new_json) + + except Exception as ex: + self.error(traceback.format_exc()) + + def handle_get_userinfo(self, headers, base_url): + """Fetch comprehensive user information from Microsoft Entra ID, including manager, license details, and group memberships.""" + if self.data_type != 'mail': self.error('Incorrect dataType. "mail" expected.') + try: + self.user = self.get_data() + if not self.user: + self.error("No user supplied") - def summary(self, raw): - taxonomies = [] + # Use select to retrieve many user attributes. Adjust as needed. + params = { + "$select": ",".join(self.params_list) + } + + user_info_url = f"{base_url}users/{self.user}" +# user_info_url = f"{base_url}users/{self.user}" + + user_response = requests.get(user_info_url, headers=headers, params=params) + + if user_response.status_code != 200: + self.error(f"Failed to fetch user info: {user_response.content}") + + user_data = user_response.json() + + # Construct user details dictionary + user_details = { + "businessPhones": user_data.get("businessPhones", []), + "givenName": user_data.get("givenName", "N/A"), + "surname": user_data.get("surname", "N/A"), + "displayName": user_data.get("displayName", "N/A"), + "jobTitle": user_data.get("jobTitle", "N/A"), + "mail": user_data.get("mail", "N/A"), + "mobilePhone": user_data.get("mobilePhone", "N/A"), + "officeLocation": user_data.get("officeLocation", "N/A"), + "department": user_data.get("department", "N/A"), + "accountEnabled": user_data.get("accountEnabled", "N/A"), + "onPremisesSyncEnabled": user_data.get("onPremisesSyncEnabled", "N/A"), + "onPremisesLastSyncDateTime": user_data.get("onPremisesLastSyncDateTime", "N/A"), + "onPremisesSecurityIdentifier": user_data.get("onPremisesSecurityIdentifier", "N/A"), + "proxyAddresses": user_data.get("proxyAddresses", []), + "usageLocation": user_data.get("usageLocation", "N/A"), + "userType": user_data.get("userType", "N/A"), + "userPrincipalName": user_data.get("userPrincipalName", "N/A"), + "createdDateTime": user_data.get("createdDateTime", "N/A"), + "lastSignInDateTime": user_data.get("signInActivity", {}).get("lastSignInDateTime", "N/A"), + "manager": None, # to be populated below + "assignedLicenses": [], # to be populated via licenseDetails + "memberOf": [] + } + + # Fetch user's manager + manager_url = f"{base_url}users/{self.user}/manager?$select=id,displayName,userPrincipalName" + manager_resp = requests.get(manager_url, headers=headers) + if manager_resp.status_code == 200: + manager_data = manager_resp.json() + # Check if we actually got a manager object + if not manager_data.get("error"): + user_details["manager"] = { + "id": manager_data.get("id", "N/A"), + "displayName": manager_data.get("displayName", "N/A"), + "userPrincipalName": manager_data.get("userPrincipalName", "N/A") + } + + # Fetch user's license details + license_url = f"{base_url}users/{self.user}/licenseDetails" + license_resp = requests.get(license_url, headers=headers) + if license_resp.status_code == 200: + license_data = license_resp.json().get("value", []) + # Each item in license_data has info about assignedLicenses + # We can store them or parse them further. + for lic in license_data: + user_details["assignedLicenses"].append({ + "skuId": lic.get("skuId", "N/A"), + "skuPartNumber": lic.get("skuPartNumber", "N/A"), + "servicePlans": lic.get("servicePlans", []) + }) + + # Fetch user's group memberships + member_of_url = f"{base_url}users/{self.user}/memberOf" + member_of_response = requests.get(member_of_url, headers=headers) + if member_of_response.status_code == 200: + memberships = member_of_response.json().get("value", []) + for group in memberships: + user_details["memberOf"].append({ + "id": group.get("id", "N/A"), + "displayName": group.get("displayName", "Unknown") + }) + + self.report(user_details) - if len(raw.get('signIns', [])) == 0: - taxonomies.append(self.build_taxonomy('info', 'MSEntraIDSignins', 'SignIns', 'None')) + except Exception as ex: + self.error(traceback.format_exc()) + + def run(self): + Analyzer.run(self) + + token = self.authenticate() + headers = { 'Authorization': f'Bearer {token}' } + base_url = 'https://graph.microsoft.com/v1.0/' + + # Decide which service to run + if self.service == "getSignIns": + self.handle_get_signins(headers, base_url) + elif self.service == "getUserInfo": + self.handle_get_userinfo(headers, base_url) else: - taxonomies.append(self.build_taxonomy('safe', 'MSEntraIDSignins', 'Count', len(raw['signIns']))) + self.error({"message": "Unidentified service"}) - stats = raw.get("sum_stats", {}) - if stats.get("riskySignIns", 0) != 0: - taxonomies.append(self.build_taxonomy('suspicious', 'MSEntraIDSignins', 'Risky', stats["riskySignIns"])) - if stats.get("externalStateSignIns", 0) != 0: - taxonomies.append(self.build_taxonomy('suspicious', 'MSEntraIDSignins', 'OutOfState', stats["externalStateSignIns"])) - if stats.get("foreignSignIns", 0) != 0: - taxonomies.append(self.build_taxonomy('malicious', 'MSEntraIDSignins', 'ForeignSignIns', stats["foreignSignIns"])) + def summary(self, raw): + taxonomies = [] + if self.service == "getSignIns": + if len(raw.get('signIns', [])) == 0: + taxonomies.append(self.build_taxonomy('info', 'MSEntraIDSignins', 'SignIns', 'None')) + else: + taxonomies.append(self.build_taxonomy('safe', 'MSEntraIDSignins', 'Count', len(raw['signIns']))) + stats = raw.get("sum_stats", {}) + if stats.get("riskySignIns", 0) != 0: + taxonomies.append(self.build_taxonomy('suspicious', 'MSEntraIDSignins', 'Risky', stats["riskySignIns"])) + if stats.get("externalStateSignIns", 0) != 0: + taxonomies.append(self.build_taxonomy('suspicious', 'MSEntraIDSignins', 'OutOfState', stats["externalStateSignIns"])) + if stats.get("foreignSignIns", 0) != 0: + taxonomies.append(self.build_taxonomy('malicious', 'MSEntraIDSignins', 'ForeignSignIns', stats["foreignSignIns"])) + + elif self.service == "getUserInfo": + if raw.get('userPrincipalName'): + taxonomies.append( + self.build_taxonomy( + "info", + "MSEntraIDUserInfo", + "UPN", + raw["userPrincipalName"] + ) + ) return {'taxonomies': taxonomies} diff --git a/analyzers/MSEntraID/MSEntraID_GetSignIns.json b/analyzers/MSEntraID/MSEntraID_GetSignIns.json index 1e11e2751..096f1f4bd 100644 --- a/analyzers/MSEntraID/MSEntraID_GetSignIns.json +++ b/analyzers/MSEntraID/MSEntraID_GetSignIns.json @@ -8,6 +8,9 @@ "dataTypeList": ["mail"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", + "config": { + "service": "getSignIns" + }, "configurationItems": [ {"name": "tenant_id", "description": "Microsoft Entra ID Tenant ID", diff --git a/analyzers/MSEntraID/MSEntraID_GetUserInfo.json b/analyzers/MSEntraID/MSEntraID_GetUserInfo.json new file mode 100644 index 000000000..5e70fb91c --- /dev/null +++ b/analyzers/MSEntraID/MSEntraID_GetUserInfo.json @@ -0,0 +1,65 @@ +{ + "name": "MSEntraID_GetUserInfo", + "version": "1.0", + "author": "nusantara-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Get information about the user from Microsoft Entra ID, using the mail", + "dataTypeList": ["mail"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "getUserInfo" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + }, + { + "name": "params_list", + "description": "list of query params to get User information", + "type": "string", + "multi": true, + "required": true, + "defaultValue": [ + "businessPhones", + "givenName", + "surname", + "userPrincipalName", + "displayName", + "jobTitle", + "mail", + "mobilePhone", + "officeLocation", + "department", + "accountEnabled", + "onPremisesSyncEnabled", + "onPremisesLastSyncDateTime", + "onPremisesSecurityIdentifier", + "proxyAddresses", + "usageLocation", + "userType", + "createdDateTime" + ] + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} From 58ec53081b997f9df86a91ec6faa0395c8815e5d Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Fri, 31 Jan 2025 10:57:29 +0800 Subject: [PATCH 04/16] MFA methods retrieval support --- analyzers/MSEntraID/MSEntraID.py | 112 +++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/analyzers/MSEntraID/MSEntraID.py b/analyzers/MSEntraID/MSEntraID.py index 1b2d93f00..57ee663c7 100755 --- a/analyzers/MSEntraID/MSEntraID.py +++ b/analyzers/MSEntraID/MSEntraID.py @@ -246,6 +246,118 @@ def handle_get_userinfo(self, headers, base_url): "displayName": group.get("displayName", "Unknown") }) + # MFA Methods + mfa_url = f"{base_url}users/{self.user}/authentication/methods" + mfa_r = requests.get(mfa_url, headers=headers) + + if mfa_r.status_code == 200: + mfa_data = mfa_r.json().get("value", []) + mfa_methods = [] + + for method in mfa_data: + method_odata_type = method.get("@odata.type", "").lower() + + # Default structure + parsed_method = { + "id": method.get("id", "N/A"), + "odataType": method_odata_type, # Full OData type + "methodType": "Unknown" + } + + if "phoneauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/phoneauthenticationmethod + parsed_method["methodType"] = "phone" + parsed_method["phoneNumber"] = method.get("phoneNumber", "N/A") + parsed_method["phoneType"] = method.get("phoneType", "N/A") + parsed_method["smsSignInState"] = method.get("smsSignInState", "N/A") + parsed_method["isDefault"] = method.get("isDefault", "N/A") + + elif "microsoftauthenticatorauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/microsoftauthenticatorauthenticationmethod + parsed_method["methodType"] = "microsoftAuthenticator" + parsed_method["displayName"] = method.get("displayName", "N/A") + parsed_method["deviceTag"] = method.get("deviceTag", "N/A") + parsed_method["phoneAppVersion"] = method.get("phoneAppVersion", "N/A") + parsed_method["isDefault"] = method.get("isDefault", "N/A") + parsed_method["isRegisteredForPasswordless"] = method.get("isRegisteredForPasswordless", "N/A") + + elif "passwordlessmicrosoftauthenticatorauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/passwordlessmicrosoftauthenticatorauthenticationmethod + parsed_method["methodType"] = "passwordlessMicrosoftAuthenticator" + parsed_method["displayName"] = method.get("displayName", "N/A") + parsed_method["deviceTag"] = method.get("deviceTag", "N/A") + parsed_method["phoneAppVersion"] = method.get("phoneAppVersion", "N/A") + parsed_method["isDefault"] = method.get("isDefault", "N/A") + parsed_method["isRegisteredForPasswordless"] = method.get("isRegisteredForPasswordless", "N/A") + + elif "fido2authenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/fido2authenticationmethod + parsed_method["methodType"] = "fido2" + parsed_method["displayName"] = method.get("displayName", "N/A") + parsed_method["aaGuid"] = method.get("aaGuid", "N/A") + parsed_method["attestationCertificates"] = method.get("attestationCertificates", []) + parsed_method["attestationLevel"] = method.get("attestationLevel", "N/A") + parsed_method["createdDateTime"] = method.get("createdDateTime", "N/A") + parsed_method["isSelfServiceRegistration"] = method.get("isSelfServiceRegistration", "N/A") + parsed_method["isSystemProtected"] = method.get("isSystemProtected", "N/A") + + elif "windowshelloforbusinessauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/windowshelloforbusinessauthenticationmethod + parsed_method["methodType"] = "windowsHelloForBusiness" + parsed_method["displayName"] = method.get("displayName", "N/A") + parsed_method["keyStrength"] = method.get("keyStrength", "N/A") + parsed_method["creationDateTime"] = method.get("creationDateTime", "N/A") + parsed_method["isDefault"] = method.get("isDefault", "N/A") + parsed_method["isSystemProtected"] = method.get("isSystemProtected", "N/A") + + elif "emailauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/emailauthenticationmethod + parsed_method["methodType"] = "email" + parsed_method["emailAddress"] = method.get("emailAddress", "N/A") + + elif "softwareoathauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/softwareoathauthenticationmethod + parsed_method["methodType"] = "softwareOath" + parsed_method["secretKey"] = method.get("secretKey", "N/A") + parsed_method["creationDateTime"] = method.get("createdDateTime", "N/A") + + elif "temporaryaccesspassauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/temporaryaccesspassauthenticationmethod + parsed_method["methodType"] = "temporaryAccessPass" + parsed_method["startDateTime"] = method.get("startDateTime", "N/A") + parsed_method["createdDateTime"] = method.get("createdDateTime", "N/A") + parsed_method["lifetimeInMinutes"] = method.get("lifetimeInMinutes", "N/A") + parsed_method["isUsable"] = method.get("isUsable", "N/A") + parsed_method["isUsableOnce"] = method.get("isUsableOnce", "N/A") + + elif "x509certificateauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/x509certificateauthenticationmethod + parsed_method["methodType"] = "x509Certificate" + parsed_method["certificateUserIds"] = method.get("certificateUserIds", []) + parsed_method["createdDateTime"] = method.get("createdDateTime", "N/A") + parsed_method["displayName"] = method.get("displayName", "N/A") + + elif "passwordauthenticationmethod" in method_odata_type: + # https://learn.microsoft.com/en-us/graph/api/resources/passwordauthenticationmethod + parsed_method["methodType"] = "password" + parsed_method["createdDateTime"] = method.get("createdDateTime", "N/A") + + else: + # Fallback value + parsed_method["methodType"] = "other-or-unknown" + + mfa_methods.append(parsed_method) + + user_details["mfaMethods"] = mfa_methods + + else: + # no self.error() if there is permission issue + user_details["mfaMethods"] = [] + user_details["mfaError"] = ( + f"Failed to retrieve MFA methods (HTTP {mfa_r.status_code}). " + f"Details: {mfa_r.content.decode('utf-8', errors='replace')}" + ) + self.report(user_details) except Exception as ex: From d7ee3e5b0647e56ae4fa234d097b60b83cb049db Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Fri, 31 Jan 2025 15:35:13 +0800 Subject: [PATCH 05/16] Add GetDirectoryAuditLogs Analyzer --- analyzers/MSEntraID/MSEntraID.py | 52 +++++++++++++++++++ .../MSEntraID_GetDirectoryAuditLogs.json | 52 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 analyzers/MSEntraID/MSEntraID_GetDirectoryAuditLogs.json diff --git a/analyzers/MSEntraID/MSEntraID.py b/analyzers/MSEntraID/MSEntraID.py index 57ee663c7..ad53fdeef 100755 --- a/analyzers/MSEntraID/MSEntraID.py +++ b/analyzers/MSEntraID/MSEntraID.py @@ -362,6 +362,56 @@ def handle_get_userinfo(self, headers, base_url): except Exception as ex: self.error(traceback.format_exc()) + + def handle_get_directoryAuditLogs(self, headers, base_url): + """ + Retrieves Directory Audit logs from Microsoft Entra ID (Azure AD). + Reference: https://learn.microsoft.com/en-us/graph/api/directoryaudit-list + """ + if self.data_type != 'mail': + self.error('Incorrect dataType. "mail" expected.') + try: + # Pull the userPrincipalName from the observable data (data_type=mail) + user_upn = self.get_data() + if not user_upn: + self.error("No user principal name supplied for directory audit logs") + # Calculate time range (past X days) + filter_time = datetime.utcnow() - timedelta(days=self.time_range) + filter_time_str = filter_time.strftime('%Y-%m-%dT%H:%M:%SZ') + + # Build endpoint + # Example: GET /auditLogs/directoryAudits?$filter=activityDateTime ge 2023-01-01T00:00:00Z&$top=12 + endpoint = ( + "auditLogs/directoryAudits?" + f"$filter=activityDateTime ge {filter_time_str} " + f"and initiatedBy/user/userPrincipalName eq '{user_upn}'" + f"&$top={self.lookup_limit}" + ) + + # Perform the GET request + r = requests.get(base_url + endpoint, headers=headers) + if r.status_code != 200: + self.error(f"Failure to fetch directory audit logs: {r.content}") + + # Parse the returned JSON + audit_data = r.json().get('value', []) + + # Build the result object + result = { + "filterParameters": { + "timeRangeDays": self.time_range, + "lookupLimit": self.lookup_limit, + "startTime": filter_time_str + }, + "directoryAudits": audit_data + } + + # Return the results to TheHive + self.report(result) + + except Exception as ex: + self.error(traceback.format_exc()) + def run(self): Analyzer.run(self) @@ -375,6 +425,8 @@ def run(self): self.handle_get_signins(headers, base_url) elif self.service == "getUserInfo": self.handle_get_userinfo(headers, base_url) + elif self.service == "getDirectoryAuditLogs": + self.handle_get_directoryAuditLogs(headers, base_url) else: self.error({"message": "Unidentified service"}) diff --git a/analyzers/MSEntraID/MSEntraID_GetDirectoryAuditLogs.json b/analyzers/MSEntraID/MSEntraID_GetDirectoryAuditLogs.json new file mode 100644 index 000000000..c83949384 --- /dev/null +++ b/analyzers/MSEntraID/MSEntraID_GetDirectoryAuditLogs.json @@ -0,0 +1,52 @@ +{ + "name": "MSEntraID_GetDirectoryAuditLogs", + "version": "1.0", + "author": "nusantara-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Pull Microsoft Entra ID directory audit logs for a user within the specified timeframe.", + "dataTypeList": ["mail"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "getDirectoryAuditLogs" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + }, + {"name": "lookup_range", + "description": "Check for Directory Audit Logs in the last X days. Should be between 1 and 31 days.", + "type": "number", + "multi": false, + "required": false, + "defaultValue": 7 + }, + {"name": "lookup_limit", + "description": "Display no more than this many Directory Audit Logs.", + "type": "number", + "multi": false, + "required": false, + "defaultValue": 12 + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} From cb50e017941838a201624a6cb5800b3a9d3479ef Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:03:43 +0800 Subject: [PATCH 06/16] Update README.md --- analyzers/MSEntraID/README.md | 216 +++++++++++++++++++++++++++++----- 1 file changed, 188 insertions(+), 28 deletions(-) diff --git a/analyzers/MSEntraID/README.md b/analyzers/MSEntraID/README.md index 55ab06fee..c37d27b73 100644 --- a/analyzers/MSEntraID/README.md +++ b/analyzers/MSEntraID/README.md @@ -1,49 +1,209 @@ -## Microsoft Entra ID Sign In Retriever +# Microsoft Entra ID / Azure AD Analyzers -This responder allows you to revoke the session tokens for an Microsoft Entra ID user. Requires the UPN of the account in question, which should be entered as a "mail" oberservable in TheHive. +This repository provides a set of **Cortex** analyzers to enrich your investigations in TheHive with data from Microsoft Entra ID (Azure AD). All analyzers use the **Microsoft Graph API** for data retrieval. Each analyzer requires an Azure AD **app registration** (client ID + secret) with **admin-consented** permissions (OAuth2 scopes). -### Config +--- -To enable the responder, you *need* three values: -1. Microsoft Entra ID Tenant ID -2. Application ID -3. Application Secret +## Table of Contents -The first two values can be found at any time in the application's Overview page in the Microsoft Entra ID portal. The secret must be generated and then stored in a safe place, as it is only fully visible when you first make it. +1. [Overview of Analyzers](#overview-of-analyzers) +2. [Global Configuration](#global-configuration) +3. [Setup](#setup) + - [Prereqs](#prereqs) + - [Steps](#steps) +4. [Analyzers](#analyzers) + - [getSignIns / Microsoft Entra ID Sign In Retriever](#getsignins--microsoft-entra-id-sign-in-retriever) + - [getUserInfo](#getuserinfo) + - [getDirectoryAuditLogs](#getdirectoryauditlogs) + - [getDeviceByUser](#getdevicebyuser-intune) + - [Future: getRiskInsights / identityProtection](#future-getriskinsights--identityprotection) +5. [Customization](#customization) +6. [General Notes on Permissions](#general-notes-on-permissions) +7. [References](#references) -You can also specify the limits for how far back the analyzer requests sign ins. You can specify time and count for how many sign ins get returned. +--- -Finally, you can specify a state and country/region. These are used as taxonomies. If you run a query on a particular user and they return a few out-of-state sign ins, a taxonomy label will be added to the observable to reflect that. Likewise for the country/region. By default, this analyzer does not support selecting multiple states or countries, so if you have more than one that users will be signing in to, feel free to leave them blank. If the value is not configured, then the analyzer will simply not use the taxonomies. +## Overview of Analyzers + +These analyzers provide useful context for Incident Response teams, such as: + +- **Sign-in logs** (location, risk, IP, etc.) +- **User profile details** (manager, licenses, groups, MFA methods) +- **Directory audit logs** (object changes in Azure AD) +- **Intune-managed devices** (compliance, OS, last sync) + +--- + +## Global Configuration + +All analyzers share these config fields: + +- **`client_id`**: Application (client) ID of your Azure AD app registration +- **`client_secret`**: Client Secret generated for that app +- **`tenant_id`**: Azure AD Tenant ID +- **`service`**: Which analyzer action to run, hardcoded (such as `getSignIns`, `getUserInfo`, `getDirectoryAuditLogs`, `getDeviceByUser`) + +Additional parameters (such as **lookup_range**, **lookup_limit**, **state**, **country**) appear in certain analyzers. They allow you to define: + +- **Time range** (how far back to query logs) +- **Max results** (number of records to retrieve) +- **Location-based taxonomies** (flag sign-ins from out-of-state/country) + +--- ## Setup ### Prereqs -User account with the Cloud Application Administrator role. -User account with the Global Administrator Role (most of the steps can be done with only the Cloud App Administrator role, but the final authorization for its API permissions requires GA). +- A user account with at least the **Cloud Application Administrator** role to create and manage app registrations. +- A user account with the **Global Administrator** role to grant admin consent to the required API permissions. ### Steps -#### Creation -1. Navigate to the [Microsoft Entra ID Portal](https://entra.microsoft.com/) and sign in with the relevant administrator account. -2. Navigate to App Registrations, and create a new registration. -3. Provide a display name (this can be anything, and can be changed later). Click Register. +#### 1. Creation +1. Navigate to the [Microsoft Entra ID Portal](https://entra.microsoft.com/) and sign in with an administrator account. +2. Go to **App Registrations** and **create a new registration**. +3. Provide a display name (any name you want, can be changed later). Click **Register**. + +#### 2. Secret +4. Under **Certificates & secrets**, create a new **client secret**. +5. Enter a relevant description and set an appropriate expiration date. +6. Copy the **Value**. **This will only be fully visible once**, so store it in a safe place right away. + +#### 3. API Permissions +7. Go to **API permissions**. +8. Add the relevant permissions depending on which analyzers you plan to use, for example: + - **`Directory.Read.All`** + - **`AuditLog.Read.All`** + - **`DeviceManagementManagedDevices.Read.All`** (Intune analyzers) + - **`UserAuthenticationMethod.Read.All`** (if fetching MFA) +9. For each **Application** permission, use a **Global Administrator** account to **Grant admin consent**. +10. Copy your **Tenant ID**, **Application (Client) ID**, and the **Client Secret** into the analyzer configuration in Cortex. + +--- + +## Analyzers + +### getSignIns / Microsoft Entra ID Sign In Retriever + +**Purpose** +Retrieves recent **sign-in logs** for a user (by UPN). Shows IP address, client app used, resource name, location, risk level, etc. + +**Key Points** +- **Graph Endpoint** +- [`GET /auditLogs/signIns`](https://learn.microsoft.com/en-us/graph/api/signin-list?view=graph-rest-1.0) +- Filters sign-ins by user principal name (`startswith(userPrincipalName,'xxx')`) and time range. +- You can specify a **`state`** and **`country`**; sign-ins from outside these will be flagged in **taxonomies**. + +**Required Permissions** + +- **`AuditLog.Read.All`** (Application permission) + +**Example Configuration** + +- **lookup_range** = 7 (past 7 days) +- **lookup_limit** = 50 +- **state** = "New York" (to flag out-of-state sign-ins) +- **country** = "US" (to flag out-of-country sign-ins) + +**Sample Usage** + +- Run on TheHive’s observable of type `mail` +- Analyzer returns sign-ins from the last 7 days, up to 50 entries. + +### getUserInfo + +**Purpose** +Enriches context around a user with **user profile details** from Microsoft Entra ID: display name, job title, department, licenses, manager, group memberships, optional MFA methods. -#### Secret -4. Navigate to Certificates and Secrets. -5. Create a new client secret. Enter a relevant description and set a security-conscious expiration date. -6. Copy the Value. **This will only be fully visible for a short time, so you should immediately copy it and store it in a safe place**. +**Key Points** +- **Graph Endpoints** +- [`GET /users/{id}`](https://learn.microsoft.com/en-us/graph/api/user-get?view=graph-rest-1.0) +- [`GET /users/{id}/manager`](https://learn.microsoft.com/en-us/graph/api/user-list-manager?view=graph-rest-1.0) +- [`GET /users/{id}/licenseDetails`](https://learn.microsoft.com/en-us/graph/api/user-list-licensedetails?view=graph-rest-1.0) +- [`GET /users/{id}/memberOf`](https://learn.microsoft.com/en-us/graph/api/user-list-memberof?view=graph-rest-1.0) +- (Optional) [`GET /users/{id}/authentication/methods`](https://learn.microsoft.com/en-us/graph/api/user-list-authenticationmethods?view=graph-rest-1.0) for MFA. -#### API Permissions -7. Navigate to API permissions. -8. Add the Directory.Read.All, AuditLog.Read.All, and Policy.Read.ConditionalAccess permissions (Microsoft Graph API, application permissions). -9. Using a GA account, select the "Grant admin consent for *TENANTNAME*" button. +**Required Permissions** +- **`Directory.Read.All`** or **`User.Read.All`** for user properties & group membership. +- **`UserAuthenticationMethod.Read.All`** if retrieving MFA methods. -10. Place the relevant values into the config within Cortex. +**Sample Usage** + +- Run on TheHive’s observable of type `mail` +- Returns extensive user info, including manager info, assigned licenses, group memberships, etc. + +### getDirectoryAuditLogs + +**Purpose** +Retrieves **Directory Audit** records—administrative and policy changes made within Azure AD (such as user updates, group changes, role assignments). + +**Key Points** +- **Graph Endpoint** +- [`GET /auditLogs/directoryAudits`](https://learn.microsoft.com/en-us/graph/api/directoryaudit-list?view=graph-rest-1.0) +- Filters on **time range** via `activityDateTime ge ` +- Filters on specific user input as observable, thanks to `initiatedBy/user/userPrincipalName eq 'mail@observable.com'`. + +**Required Permissions** +- **`AuditLog.Read.All`** (Application permission) + +**Sample Usage** + +- Run on TheHive’s observable of type `mail` +- Analyzer fetches directory audit logs for that user over the last X days. + +### getDeviceByUser or Hostname (requires MS Intune) + +**Purpose** +Returns **Intune-managed devices** for a given user’s principal name or hostname, letting IR see device compliance, OS, last check-in, etc. + +**Key Points** +- **Graph Endpoint** +- [`GET /deviceManagement/managedDevices`](https://learn.microsoft.com/en-us/graph/api/intune-devices-manageddevice-list?view=graph-rest-1.0) +- Filters with `startswith(userPrincipalName,'xxx')` or an exact match (`eq`), with observable value. + +**Required Permissions** +- **`DeviceManagementManagedDevices.Read.All`** (Application permission) + +**Sample Usage** + +- Run on TheHive’s observable of type `mail` +- Analyzer returns a list of Intune devices assigned to that user. + +--- ## Customization -It is possible to add a color coding system to the long report as viewed from TheHive. Specifically, you can color code the Sign Ins table so that certain ones stand out. +### Sign-Ins Table Color Coding + +In **TheHive**, the analyzer’s *long report* can be customized to highlight sign-ins with certain risk or unusual locations. For instance: + +- **Yellow** for out-of-state sign-ins +- **Red** for foreign sign-ins + +To do this, modify **`long.html`** for the analyzer (Sign In). For example, use `ng-style` or custom logic to check values in the JSON (like `location.state` or `riskLevel`). A sample snippet might be commented out at line 34 of `long.html` (if provided in your code), which you can adapt to your color preferences. + +--- + +## General Notes on Permissions + +- **Application (Client Credentials) Flow** + - These analyzers typically use `.default` scope and **client credentials**. + - Ensure you **Grant admin consent** for the required permissions in Azure AD. + +- **Minimal Scopes** + - If you want all analyzers to function, add each relevant scope (such as `AuditLog.Read.All`, `Directory.Read.All`, `DeviceManagementManagedDevices.Read.All`, `UserAuthenticationMethod.Read.All`) to the same app registration. + - Alternatively, create separate app registrations to follow least-privilege principles. + +- **Licensing** + - Some features (such as Identity Protection, advanced audit logs) require Azure AD Premium licensing. + +--- -### Example +## References -Let's say you are in an organization where almost all of your users will be signing in from a single state. You could color code the table so that out-of-state sign ins are highlighted yellow, and out-of-country sign ins are highlighted in red. To enable customization like this, you must modify this analyzer's long.html to check for values within the full JSON report using the ng-style tag in the *table body > table row* element. An example exists as a comment in the long.html file at line 34. \ No newline at end of file +- [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference?view=graph-rest-1.0) +- [Azure AD Permissions & Admin Consent](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent) +- [Microsoft Graph Query Parameters](https://learn.microsoft.com/en-us/graph/query-parameters?view=graph-rest-1.0) +- [Sign In Logs (auditLogs/signIns)](https://learn.microsoft.com/en-us/graph/api/signin-list?view=graph-rest-1.0) +- [Directory Audits (auditLogs/directoryAudits)](https://learn.microsoft.com/en-us/graph/api/directoryaudit-list?view=graph-rest-1.0) +- [Managed Devices (deviceManagement/managedDevices)](https://learn.microsoft.com/en-us/graph/api/intune-devices-manageddevice-list?view=graph-rest-1.0) \ No newline at end of file From b856561082d2e4d5808fdb50cea48a20f63bd174 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Mon, 3 Feb 2025 15:50:21 +0800 Subject: [PATCH 07/16] Add Analyzer template for MSEntraID GetUserInfo --- .../MSEntraID_GetUserInfo_1_0/long.html | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 thehive-templates/MSEntraID_GetUserInfo_1_0/long.html diff --git a/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html b/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html new file mode 100644 index 000000000..e0d973e1c --- /dev/null +++ b/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html @@ -0,0 +1,146 @@ + +
+
+
+ +
+
+ User Information +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Display Name{{ content.displayName || 'N/A' }}
Job Title{{ content.jobTitle || 'N/A' }}
Email{{ content.mail || 'N/A' }}
Department{{ content.department || 'N/A' }}
Usage Location{{ content.usageLocation || 'N/A' }}
User Type{{ content.userType || 'N/A' }}
Account Enabled{{ content.accountEnabled ? 'Yes' : 'No' }}
Created Date{{ content.createdDateTime || 'N/A' }}
Last Sign-In{{ content.lastSignInDateTime || 'N/A' }}
Manager{{ content.manager.displayName }} ({{ content.manager.userPrincipalName }})
+
+
+ + +
+
+ Membership Groups +
+
+ + + + + + + +
Group Name
{{ group.displayName }}
+
+
+ + +
+
+ Multi-Factor Authentication Methods +
+
+ + + + + + + + + + + +
TypeDisplay NameDetails
{{ method.methodType }}{{ method.displayName || 'N/A' }} + + + {{ key }}: {{ value || 'N/A' }}
+
+
+
+
+ + +
+
+ Error +
+
+
+
Message:
+
{{ content.mfaError }}
+
+
+
+ + +
+
+ Assigned Licenses +
+
+ + + + + + + +
Service Plan ID
{{ license.skuPartNumber }}
+
+
+
+
+
+ + +
+
+ {{(artifact.data || artifact.attachment.name) | fang}} +
+
+
+
MSEntra ID GetUserInfo:
+
{{content.errorMessage}}
+
+
+
+ + + From e73c524d93c31ea70c92b68f8396e9483fc83e2c Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Mon, 3 Feb 2025 16:18:07 +0800 Subject: [PATCH 08/16] Add taxonomy for getDirectoryAuditLogs (count) --- analyzers/MSEntraID/MSEntraID.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/analyzers/MSEntraID/MSEntraID.py b/analyzers/MSEntraID/MSEntraID.py index ad53fdeef..1e1b46de6 100755 --- a/analyzers/MSEntraID/MSEntraID.py +++ b/analyzers/MSEntraID/MSEntraID.py @@ -456,6 +456,10 @@ def summary(self, raw): raw["userPrincipalName"] ) ) + elif self.service == "getDirectoryAuditLogs": + # Get the count of directory audit logs + count = len(raw.get("directoryAudits", [])) + taxonomies.append(self.build_taxonomy('info', 'MSEntraIDAuditLogs', 'count', count)) return {'taxonomies': taxonomies} From 2d35a56210767a0c82e1ede1808f2d8f38a2ff62 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Tue, 4 Feb 2025 08:55:18 +0800 Subject: [PATCH 09/16] Add getDirectoryAuditLogs analyzer template --- .../long.html | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html diff --git a/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html b/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html new file mode 100644 index 000000000..023c8e667 --- /dev/null +++ b/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html @@ -0,0 +1,152 @@ + + +
+
+
+ +
+
+ Filter Parameters +
+
+ + + + + + + + + + + + + +
Time Range (days){{ content.filterParameters.timeRangeDays || 'N/A' }}
Lookup Limit{{ content.filterParameters.lookupLimit || 'N/A' }}
Start Time{{ content.filterParameters.startTime || 'N/A' }}
+
+
+ + +
+
+ Directory Audit Logs +
+
+ + + + + + + + + + + + + + + + + +
IDCategoryActivityResultTimestampInitiated By
{{ audit.id }}{{ audit.category }}{{ audit.activityDisplayName }}{{ audit.result }}{{ audit.activityDateTime }} + + {{ audit.initiatedBy.user.displayName || audit.initiatedBy.user.userPrincipalName || 'N/A' }}
+ IP: {{ audit.initiatedBy.user.ipAddress || 'N/A' }} +
+ + (App: {{ audit.initiatedBy.app.appId || 'N/A' }}) + +
+
+ + +
+
+ Audit Details - {{ audit.id }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Correlation ID{{ audit.correlationId || 'N/A' }}
Operation Type{{ audit.operationType || 'N/A' }}
Logged By Service{{ audit.loggedByService || 'N/A' }}
Result Reason{{ audit.resultReason || 'N/A' }}
Target Resources + + + + + + + + + + + +
TypeDisplay NameUser Principal Name
{{ resource.type || 'N/A' }}{{ resource.displayName || 'N/A' }}{{ resource.userPrincipalName || 'N/A' }}
+
Additional Details + + + + + + + + + +
KeyValue
{{ detail.key }}{{ detail.value }}
+
+
+
+ +
+ +
+
+ No Audit Logs +
+
No directory audit logs found for the given parameters.
+
+
+
+
+ + +
+
+ {{(artifact.data || artifact.attachment.name) | fang}} +
+
+
+
getDirectoryAuditLogs:
+
{{content.errorMessage}}
+
+
+
+ + + From be3387f7978c2708a9a8e0985913e69935435563 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:48:49 +0800 Subject: [PATCH 10/16] Update READMEs --- analyzers/MSEntraID/README.md | 9 ++++----- responders/MSEntraID/README.md | 25 +++++++++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/analyzers/MSEntraID/README.md b/analyzers/MSEntraID/README.md index c37d27b73..218c19477 100644 --- a/analyzers/MSEntraID/README.md +++ b/analyzers/MSEntraID/README.md @@ -15,8 +15,7 @@ This repository provides a set of **Cortex** analyzers to enrich your investigat - [getSignIns / Microsoft Entra ID Sign In Retriever](#getsignins--microsoft-entra-id-sign-in-retriever) - [getUserInfo](#getuserinfo) - [getDirectoryAuditLogs](#getdirectoryauditlogs) - - [getDeviceByUser](#getdevicebyuser-intune) - - [Future: getRiskInsights / identityProtection](#future-getriskinsights--identityprotection) + - [getManagedDeviceInfo](#getmanageddeviceinfo-intune) 5. [Customization](#customization) 6. [General Notes on Permissions](#general-notes-on-permissions) 7. [References](#references) @@ -41,7 +40,7 @@ All analyzers share these config fields: - **`client_id`**: Application (client) ID of your Azure AD app registration - **`client_secret`**: Client Secret generated for that app - **`tenant_id`**: Azure AD Tenant ID -- **`service`**: Which analyzer action to run, hardcoded (such as `getSignIns`, `getUserInfo`, `getDirectoryAuditLogs`, `getDeviceByUser`) +- **`service`**: Which analyzer action to run, hardcoded (such as `getSignIns`, `getUserInfo`, `getDirectoryAuditLogs`, `getManagedDeviceInfo`) Additional parameters (such as **lookup_range**, **lookup_limit**, **state**, **country**) appear in certain analyzers. They allow you to define: @@ -151,7 +150,7 @@ Retrieves **Directory Audit** records—administrative and policy changes made w - Run on TheHive’s observable of type `mail` - Analyzer fetches directory audit logs for that user over the last X days. -### getDeviceByUser or Hostname (requires MS Intune) +### getManagedDeviceInfo (requires MS Intune) **Purpose** Returns **Intune-managed devices** for a given user’s principal name or hostname, letting IR see device compliance, OS, last check-in, etc. @@ -166,7 +165,7 @@ Returns **Intune-managed devices** for a given user’s principal name or hostna **Sample Usage** -- Run on TheHive’s observable of type `mail` +- Run on TheHive’s observable of type `mail` or `hostname` - Analyzer returns a list of Intune devices assigned to that user. --- diff --git a/responders/MSEntraID/README.md b/responders/MSEntraID/README.md index 8a4785cd7..6a8f83823 100644 --- a/responders/MSEntraID/README.md +++ b/responders/MSEntraID/README.md @@ -45,17 +45,30 @@ The first two values can be found at any time in the application's ***Overview** #### API Permissions 7. Navigate to **API permissions**. -8. Add the `Directory.ReadWrite.All` and `User.ReadWrite.All` permissions (Microsoft Graph API, application permissions). -9. Using a GA account, select the "`Grant admin consent for *TENANTNAME*`" button. -10. Place the relevant values into the config within Cortex. - -*Note: You may use less permissive permissions, such as `User.RevokeSessions.All` for tokenRevoker and so on. Please see the below references* +8. Add the following Microsoft Graph API application permissions: + - **Option A (Broader Permissions):** + - `Directory.ReadWrite.All` + - `User.ReadWrite.All` + + *(These permissions cover all responder functionalities.)* + + - **Option B (Least Privileged – Recommended):** + - For the **Token Revoker** responder: `User.RevokeSessions.All` + - For the **Enable User** and **Disable User** responders: + - `User.EnableDisableAccount.All` + - `User.Read.All` + - For the **Password Reset** responders: `User-PasswordProfile.ReadWrite.All` + +9. Using a Global Administrator account, click the "`Grant admin consent for [TENANTNAME]`" button. +10. Enter the corresponding values (`tenant_id`, `client_id`, `client_secret`) into your responders Cortex configuration. + +*Note: For enhanced security, it is recommended to use the least privileged permissions (Option B) that are sufficient for your use case. Please refer to the [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference) for further details.* ### References - [Microsoft Graph API - Revoke Sign-In Sessions](https://learn.microsoft.com/en-us/graph/api/user-revokesigninsessions?view=graph-rest-1.0) -- [Microsoft Graph API - Reset Password](https://learn.microsoft.com/en-us/graph/api/authenticationmethod-resetpassword?view=graph-rest-1.0) +- [Microsoft Graph API - Update User](https://learn.microsoft.com/en-us/graph/api/user-update?view=graph-rest-1.0&tabs=http) - [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference) \ No newline at end of file From 78d1b6ef5c47e246d153cf8b5430256fb303f313 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Wed, 5 Feb 2025 10:20:39 +0800 Subject: [PATCH 11/16] Analyzer templates fixes --- .../MSEntraID_GetDirectoryAuditLogs_1_0/long.html | 5 +---- thehive-templates/MSEntraID_GetUserInfo_1_0/long.html | 3 --- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html b/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html index 023c8e667..40a2d239a 100644 --- a/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html +++ b/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html @@ -146,7 +146,4 @@
{{content.errorMessage}}
- - - - + \ No newline at end of file diff --git a/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html b/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html index e0d973e1c..09ec2ccd2 100644 --- a/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html +++ b/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html @@ -141,6 +141,3 @@ - - - From c77280fbc95120318abc85c046d0fd9d5e42918f Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Wed, 5 Feb 2025 11:37:13 +0800 Subject: [PATCH 12/16] Add GetManagedDevicesInfo analyzer --- analyzers/MSEntraID/MSEntraID.py | 51 +++++++++++++++++++ .../MSEntraID_GetManagedDevicesInfo.json | 38 ++++++++++++++ analyzers/MSEntraID/README.md | 6 +-- 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 analyzers/MSEntraID/MSEntraID_GetManagedDevicesInfo.json diff --git a/analyzers/MSEntraID/MSEntraID.py b/analyzers/MSEntraID/MSEntraID.py index 1e1b46de6..e60a29208 100755 --- a/analyzers/MSEntraID/MSEntraID.py +++ b/analyzers/MSEntraID/MSEntraID.py @@ -412,6 +412,51 @@ def handle_get_directoryAuditLogs(self, headers, base_url): except Exception as ex: self.error(traceback.format_exc()) + def handle_get_devices(self, headers, base_url): + """ + Retrieves enrolled device(s) from Intune by either: + - deviceName (hostname) if self.data_type == 'hostname', or + - userPrincipalName (mail) if self.data_type == 'mail'. + + Reference: https://learn.microsoft.com/en-us/graph/api/intune-devices-manageddevice-list + """ + try: + # Check if the data_type is valid + if self.data_type not in ['hostname', 'mail']: + self.error('Incorrect dataType. Expected "hostname" or "mail".') + + # Get the query value from the observable data + query_value = self.get_data() + if not query_value: + if self.data_type == 'hostname': + self.error("No device name supplied") + else: + self.error("No user UPN supplied") + + # Build the appropriate endpoint based on the observable type + if self.data_type == 'hostname': + endpoint = ( + "deviceManagement/managedDevices?" + f"$filter=startswith(deviceName,'{query_value}')" + ) + elif self.data_type == 'mail': + endpoint = ( + "deviceManagement/managedDevices?" + f"$filter=startswith(userPrincipalName,'{query_value}')" + ) + + # Perform the GET request + r = requests.get(base_url + endpoint, headers=headers) + if r.status_code != 200: + self.error(f"Failure to pull device(s) for query '{query_value}': {r.content}") + + # Parse and report the results + devices_data = r.json().get('value', []) + self.report({"query": query_value, "devices": devices_data}) + + except Exception as ex: + self.error(traceback.format_exc()) + def run(self): Analyzer.run(self) @@ -427,6 +472,8 @@ def run(self): self.handle_get_userinfo(headers, base_url) elif self.service == "getDirectoryAuditLogs": self.handle_get_directoryAuditLogs(headers, base_url) + elif self.service == "getManagedDevicesInfo": + self.handle_get_devices(headers, base_url) else: self.error({"message": "Unidentified service"}) @@ -460,6 +507,10 @@ def summary(self, raw): # Get the count of directory audit logs count = len(raw.get("directoryAudits", [])) taxonomies.append(self.build_taxonomy('info', 'MSEntraIDAuditLogs', 'count', count)) + elif self.service == "getManagedDevicesInfo": + # Get the count of devices returned. + count = len(raw.get("devices", [])) + taxonomies.append(self.build_taxonomy('info', 'MSEntraIDManagedDevices', 'count', count)) return {'taxonomies': taxonomies} diff --git a/analyzers/MSEntraID/MSEntraID_GetManagedDevicesInfo.json b/analyzers/MSEntraID/MSEntraID_GetManagedDevicesInfo.json new file mode 100644 index 000000000..49f5ccc22 --- /dev/null +++ b/analyzers/MSEntraID/MSEntraID_GetManagedDevicesInfo.json @@ -0,0 +1,38 @@ +{ + "name": "MSEntraID_GetManagedDevicesInfo", + "version": "1.0", + "author": "nusantara-self, StrangeBee", + "url": "https://github.com/TheHive-Project/Cortex-Analyzers", + "license": "AGPL-V3", + "description": "Get Microsoft Intune Managed Device(s) Details from hostname or mail", + "dataTypeList": ["mail", "hostname"], + "command": "MSEntraID/MSEntraID.py", + "baseConfig": "MSEntraID", + "config": { + "service": "getManagedDevicesInfo" + }, + "configurationItems": [ + {"name": "tenant_id", + "description": "Microsoft Entra ID Tenant ID", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_id", + "description": "Client ID/Application ID of Microsoft Entra ID Registered App", + "type": "string", + "multi": false, + "required": true + }, + {"name": "client_secret", + "description": "Secret for Microsoft Entra ID Registered Application", + "type": "string", + "multi": false, + "required": true + } + ], + "registration_required": true, + "subscription_required": true, + "free_subscription": false, + "service_homepage": "https://www.microsoft.com/security/business/identity-access/microsoft-entra-id" +} \ No newline at end of file diff --git a/analyzers/MSEntraID/README.md b/analyzers/MSEntraID/README.md index 218c19477..594785aae 100644 --- a/analyzers/MSEntraID/README.md +++ b/analyzers/MSEntraID/README.md @@ -15,7 +15,7 @@ This repository provides a set of **Cortex** analyzers to enrich your investigat - [getSignIns / Microsoft Entra ID Sign In Retriever](#getsignins--microsoft-entra-id-sign-in-retriever) - [getUserInfo](#getuserinfo) - [getDirectoryAuditLogs](#getdirectoryauditlogs) - - [getManagedDeviceInfo](#getmanageddeviceinfo-intune) + - [getManagedDevicesInfo](#getmanageddevicesinfo-requires-ms-intune) 5. [Customization](#customization) 6. [General Notes on Permissions](#general-notes-on-permissions) 7. [References](#references) @@ -40,7 +40,7 @@ All analyzers share these config fields: - **`client_id`**: Application (client) ID of your Azure AD app registration - **`client_secret`**: Client Secret generated for that app - **`tenant_id`**: Azure AD Tenant ID -- **`service`**: Which analyzer action to run, hardcoded (such as `getSignIns`, `getUserInfo`, `getDirectoryAuditLogs`, `getManagedDeviceInfo`) +- **`service`**: Which analyzer action to run, hardcoded (such as `getSignIns`, `getUserInfo`, `getDirectoryAuditLogs`, `getManagedDevicesInfo`) Additional parameters (such as **lookup_range**, **lookup_limit**, **state**, **country**) appear in certain analyzers. They allow you to define: @@ -150,7 +150,7 @@ Retrieves **Directory Audit** records—administrative and policy changes made w - Run on TheHive’s observable of type `mail` - Analyzer fetches directory audit logs for that user over the last X days. -### getManagedDeviceInfo (requires MS Intune) +### getManagedDevicesInfo (requires MS Intune) **Purpose** Returns **Intune-managed devices** for a given user’s principal name or hostname, letting IR see device compliance, OS, last check-in, etc. From aa791da568cb6f45db665f19e0cd03183ce81fe9 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Wed, 5 Feb 2025 13:51:29 +0800 Subject: [PATCH 13/16] add basic artifacts extraction --- analyzers/MSEntraID/MSEntraID.py | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/analyzers/MSEntraID/MSEntraID.py b/analyzers/MSEntraID/MSEntraID.py index e60a29208..191115e7d 100755 --- a/analyzers/MSEntraID/MSEntraID.py +++ b/analyzers/MSEntraID/MSEntraID.py @@ -5,6 +5,8 @@ import traceback from datetime import datetime, timedelta from cortexutils.analyzer import Analyzer +import re +#import json # Initialize Azure Class class MSEntraID(Analyzer): @@ -513,6 +515,41 @@ def summary(self, raw): taxonomies.append(self.build_taxonomy('info', 'MSEntraIDManagedDevices', 'count', count)) return {'taxonomies': taxonomies} + def artifacts(self, raw): + artifacts = [] + raw_str = str(raw) + + # Attempt to parse the raw data as JSON to later capture structured fields + #try: + # data = json.loads(raw_str) + #except Exception: + # data = None + + extracted_data = self.get_data() # Store observed value + observed_type = self.data_type # Store expected data type + + # Extract IPv4 addresses + ipv4_regex = r'\b(?:\d{1,3}\.){3}\d{1,3}\b' + ipv4s = re.findall(ipv4_regex, raw_str) + for ip in set(ipv4s): + if not (observed_type == "ip" and extracted_data == ip): + artifacts.append(self.build_artifact('ip', ip)) + + # Extract IPv6 addresses + ipv6_regex = r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b' + ipv6s = re.findall(ipv6_regex, raw_str) + for ip in set(ipv6s): + if not (observed_type == "ip" and extracted_data == ip): + artifacts.append(self.build_artifact('ip', ip)) + + # Extract email addresses + email_regex = r'[\w\.-]+@[\w\.-]+\.\w+' + emails = re.findall(email_regex, raw_str) + for email in set(emails): + if not (observed_type == "mail" and extracted_data == email): + artifacts.append(self.build_artifact('mail', email)) + + return artifacts if __name__ == '__main__': MSEntraID().run() From 567924d23f58250bd8d887ac73b593da47b794ad Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Thu, 6 Feb 2025 09:41:10 +0800 Subject: [PATCH 14/16] JSON definition update --- responders/MSEntraID/MSEntraID_ForcePasswordReset.json | 4 ++-- responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json | 4 ++-- responders/MSEntraID/MSEntraID_TokenRevoker.json | 2 +- responders/MSEntraID/MSEntraID_disableUser.json | 2 +- responders/MSEntraID/MSEntraID_enableUser.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/responders/MSEntraID/MSEntraID_ForcePasswordReset.json b/responders/MSEntraID/MSEntraID_ForcePasswordReset.json index 6af48e08c..0195ab41d 100644 --- a/responders/MSEntraID/MSEntraID_ForcePasswordReset.json +++ b/responders/MSEntraID/MSEntraID_ForcePasswordReset.json @@ -4,12 +4,12 @@ "author": "nusatanra-self, StrangeBee", "url": "https://github.com/TheHive-Project/Cortex-Analyzers", "license": "AGPL-V3", - "description": "Force password reset at next login", + "description": "Force password reset at next login for a User Principal Name. (mail)", "dataTypeList": ["thehive:case_artifact"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", "config": { - "service": "forcePasswordReset" + "service": "forcePasswordReset" }, "configurationItems": [ {"name": "tenant_id", diff --git a/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json b/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json index 23ee603aa..bb8211226 100644 --- a/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json +++ b/responders/MSEntraID/MSEntraID_ForcePasswordResetWithMFA.json @@ -4,12 +4,12 @@ "author": "nusatanra-self, StrangeBee", "url": "https://github.com/TheHive-Project/Cortex-Analyzers", "license": "AGPL-V3", - "description": "Force password reset at next login with MFA verification before password change", + "description": "Force password reset at next login with MFA verification before password change for a User Principal Name. (mail)", "dataTypeList": ["thehive:case_artifact"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", "config": { - "service": "forcePasswordResetWithMFA" + "service": "forcePasswordResetWithMFA" }, "configurationItems": [ {"name": "tenant_id", diff --git a/responders/MSEntraID/MSEntraID_TokenRevoker.json b/responders/MSEntraID/MSEntraID_TokenRevoker.json index 0c90c83c5..ebd9c9467 100644 --- a/responders/MSEntraID/MSEntraID_TokenRevoker.json +++ b/responders/MSEntraID/MSEntraID_TokenRevoker.json @@ -4,7 +4,7 @@ "author": "Daniel Weiner @dmweiner; revised by @jahamilto; nusantara-self, StrangeBee", "url": "https://github.com/TheHive-Project/Cortex-Analyzers", "license": "AGPL-V3", - "description": "Revoke all Microsoft Entra ID authentication session tokens for a User Principal Name.", + "description": "Revoke all Microsoft Entra ID authentication session tokens for a User Principal Name. (mail)", "dataTypeList": ["thehive:case_artifact"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", diff --git a/responders/MSEntraID/MSEntraID_disableUser.json b/responders/MSEntraID/MSEntraID_disableUser.json index 6713e9acd..e725543b9 100644 --- a/responders/MSEntraID/MSEntraID_disableUser.json +++ b/responders/MSEntraID/MSEntraID_disableUser.json @@ -4,7 +4,7 @@ "author": "nusatanra-self, StrangeBee", "url": "https://github.com/TheHive-Project/Cortex-Analyzers", "license": "AGPL-V3", - "description": "Disable user in Microsoft Entra ID", + "description": "Disable user in Microsoft Entra ID for a User Principal Name. (mail)", "dataTypeList": ["thehive:case_artifact"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", diff --git a/responders/MSEntraID/MSEntraID_enableUser.json b/responders/MSEntraID/MSEntraID_enableUser.json index 1a4f7085e..bb48dd2e3 100644 --- a/responders/MSEntraID/MSEntraID_enableUser.json +++ b/responders/MSEntraID/MSEntraID_enableUser.json @@ -4,7 +4,7 @@ "author": "nusatanra-self, StrangeBee", "url": "https://github.com/TheHive-Project/Cortex-Analyzers", "license": "AGPL-V3", - "description": "Enable user in Microsoft Entra ID", + "description": "Enable user in Microsoft Entra ID for a User Principal Name. (mail)", "dataTypeList": ["thehive:case_artifact"], "command": "MSEntraID/MSEntraID.py", "baseConfig": "MSEntraID", From a6573bca4419f2e79c0506b3f8f90b52c41da85c Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Thu, 6 Feb 2025 10:19:56 +0800 Subject: [PATCH 15/16] analyzer templates improvements --- .../long.html | 156 +++++++++--------- .../MSEntraID_GetUserInfo_1_0/long.html | 27 +-- 2 files changed, 98 insertions(+), 85 deletions(-) diff --git a/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html b/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html index 40a2d239a..cec748362 100644 --- a/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html +++ b/thehive-templates/MSEntraID_GetDirectoryAuditLogs_1_0/long.html @@ -1,4 +1,3 @@ -
@@ -26,8 +25,18 @@
- -
+ +
+
+ No Audit Logs Found +
+
+

No directory audit logs were found for the given parameters.

+
+
+ + +
Directory Audit Logs
@@ -45,12 +54,19 @@ {{ audit.id }} {{ audit.category }} {{ audit.activityDisplayName }} - {{ audit.result }} - {{ audit.activityDateTime }} + + + {{ audit.result || 'N/A' }} + + + {{ audit.activityDateTime || 'N/A' }} {{ audit.initiatedBy.user.displayName || audit.initiatedBy.user.userPrincipalName || 'N/A' }}
- IP: {{ audit.initiatedBy.user.ipAddress || 'N/A' }} + IP: {{ audit.initiatedBy.user.ipAddress || 'N/A' }}
(App: {{ audit.initiatedBy.app.appId || 'N/A' }}) @@ -59,77 +75,69 @@
+
- -
-
- Audit Details - {{ audit.id }} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Correlation ID{{ audit.correlationId || 'N/A' }}
Operation Type{{ audit.operationType || 'N/A' }}
Logged By Service{{ audit.loggedByService || 'N/A' }}
Result Reason{{ audit.resultReason || 'N/A' }}
Target Resources - - - - - - - - - - - -
TypeDisplay NameUser Principal Name
{{ resource.type || 'N/A' }}{{ resource.displayName || 'N/A' }}{{ resource.userPrincipalName || 'N/A' }}
-
Additional Details - - - - - - - - - -
KeyValue
{{ detail.key }}{{ detail.value }}
-
-
+ +
+
+ Audit Details - {{ audit.id }}
+
+ + + + + + + + + + + + + + + + + - - -
-
- No Audit Logs + +
+ + + + + + + + + +
Correlation ID{{ audit.correlationId || 'N/A' }}
Operation Type{{ audit.operationType || 'N/A' }}
Logged By Service{{ audit.loggedByService || 'N/A' }}
Result Reason{{ audit.resultReason || 'N/A' }}
Target Resources + + + + + + + + + + + +
TypeDisplay NameUser Principal Name
{{ resource.type || 'N/A' }}{{ resource.displayName || 'N/A' }}{{ resource.userPrincipalName || 'N/A' }}
+
Additional Details + + + + + + + + + +
KeyValue
{{ detail.key }}{{ detail.value }}
+
-
No directory audit logs found for the given parameters.
@@ -146,4 +154,4 @@
{{content.errorMessage}}
- \ No newline at end of file + diff --git a/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html b/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html index 09ec2ccd2..525cdc597 100644 --- a/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html +++ b/thehive-templates/MSEntraID_GetUserInfo_1_0/long.html @@ -35,7 +35,14 @@ Account Enabled - {{ content.accountEnabled ? 'Yes' : 'No' }} + + + {{ content.accountEnabled ? 'Yes' : 'No' }} + + Created Date @@ -69,7 +76,7 @@ - +
@@ -85,7 +92,6 @@ {{ method.methodType }} {{ method.displayName || 'N/A' }} - {{ key }}: {{ value || 'N/A' }}
@@ -96,19 +102,16 @@
- +
- Error + MFA Error
-
-
Message:
-
{{ content.mfaError }}
-
+

{{ content.mfaError }}

- +
@@ -118,9 +121,11 @@ + +
Service Plan IDPlan Name
{{ license.skuPartNumber }}{{ license.servicePlanName || 'N/A' }}
@@ -140,4 +145,4 @@
{{content.errorMessage}}
- + \ No newline at end of file From 89adf798e5ac4a8def8c9776ed45875f03b60ca2 Mon Sep 17 00:00:00 2001 From: nusantara-self <15647296+nusantara-self@users.noreply.github.com> Date: Thu, 6 Feb 2025 11:05:29 +0800 Subject: [PATCH 16/16] Add MSentraID_getManagedDeviceInfo analyzer template --- .../long.html | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 thehive-templates/MSEntraID_GetManagedDevicesInfo_1_0/long.html diff --git a/thehive-templates/MSEntraID_GetManagedDevicesInfo_1_0/long.html b/thehive-templates/MSEntraID_GetManagedDevicesInfo_1_0/long.html new file mode 100644 index 000000000..9f98b1b40 --- /dev/null +++ b/thehive-templates/MSEntraID_GetManagedDevicesInfo_1_0/long.html @@ -0,0 +1,142 @@ + +
+
+
+ +
+
+ Queried User Email or Hostname +
+
+ + + + + +
Query{{ content.query || 'N/A' }}
+
+
+ + +
+
+ No Devices Found +
+
No devices found for this user in Intune.
+
+ + +
+
+ Enrolled Devices +
+
+ + + + + + + + + + + + + + + + + +
Device NameOperating SystemOS VersionCompliance StateLast Sync DateEnrollment Type
{{ device.deviceName || 'N/A' }}{{ device.operatingSystem || 'N/A' }}{{ device.osVersion || 'N/A' }} + + {{ device.complianceState || 'N/A' }} + + {{ device.lastSyncDateTime || 'N/A' }}{{ device.deviceEnrollmentType || 'N/A' }}
+
+
+ + +
+
+ Security & Compliance +
+
+ + + + + + + + + + + + + + + + + +
DeviceBitLockerSecure BootEncryptionJailbrokenThreat State
{{ device.deviceName || 'N/A' }}{{ device.deviceHealthAttestationState.bitLockerStatus || 'N/A' }}{{ device.deviceHealthAttestationState.secureBoot || 'N/A' }}{{ device.isEncrypted ? 'Yes' : 'No' }} + + {{ device.jailBroken || 'N/A' }} + + + + {{ device.partnerReportedThreatState || 'N/A' }} + +
+
+
+ + +
+
+ Device Actions - {{ device.deviceName }} +
+
+ + + + + + + + + + + + + +
Action NameAction StateStart DateLast Updated
{{ action.actionName || 'N/A' }}{{ action.actionState || 'N/A' }}{{ action.startDateTime || 'N/A' }}{{ action.lastUpdatedDateTime || 'N/A' }}
+
+
+
+
+
+ + +
+
+ {{(artifact.data || artifact.attachment.name) | fang}} +
+
+
+
GetManagedDeviceInfo:
+
{{content.errorMessage}}
+
+
+
+