Analysing Office 365 Failed Login Alerts with Python

Office 365, a cloud-based suite of productivity tools from Microsoft, is widely used by organisations around the world. With the increasing reliance on Office 365 for communication and collaboration, monitoring and securing access to Office 365 accounts has become crucial. One way to enhance security is by closely monitoring failed login attempts. In this blog post, we’ll explore how a Python script can help you review and analyse Office 365 failed login alerts.

Introduction

Failed login attempts can be indicative of various security threats, including brute-force attacks, user errors, or account credential issues. Analysing these login failures can help organisations identify potential security risks and take appropriate actions to mitigate them.

My Python script leverages various libraries and APIs to provide a comprehensive review of Office 365 failed login alerts. Here’s a breakdown of what the script does:

Extracting Information

The script parses the provided log, which contains Office 365 login failure data. It extracts key information such as target users, login failure reasons, source IPs, and error numbers using regular expression.

IP Address Information

For each source IP address detected, the script queries the free AbuseIPDB API to gather additional information, such as the Internet Service Provider (ISP), country, and abuse confidence score. This information can help assess the potential threat level associated with each IP address.

Error Number Explanation

The script grabs error reasons & numbers from the log, then fetches error details from the Microsoft Azure login error page based on the error numbers extracted. This provides insights into the specific issues causing the login failures and suggests potential remediations. (login.microsoftonline.com/error)

Generating a Report

Finally, the script compiles all the extracted information and explanations into a detailed report. It also copies this report to your clipboard for easy sharing and reference.

The report includes:

  • Target users.
  • Source IP addresses and associated information.
  • Login failure reasons.
  • Error numbers and explanations with suggested remediations.

Now, let’s break down the key components of the script and how they work.

Script Components

  1. Importing Libraries
import re 
import pyperclip 
import requests from bs4 
import BeautifulSoup
  • re: This library is used for regular expressions and is used to search for patterns in the provided log.
  • pyperclip: It allows you to interact with the clipboard, making it easier to copy and paste data.
  • requests: This library enables you to send HTTP requests to external services, which is used to fetch data from the AbuseIPDB API and Microsoft Azure’s login error page.
  • BeautifulSoup: It’s a library for web scraping and parsing HTML. Here, it’s used to parse the HTML response from the Azure login error page.
  1. API Key Setup
API_KEY = "redacted"

You need to replace “redacted” with your actual AbuseIPDB API key. This key is required to make requests to the AbuseIPDB API.

  1. analyse_log Function This is the core function of the script. It takes the Office 365 login failure log as input and performs several tasks:

a. Extracting Information

# Extract target users 
user_matches = re.findall(r'UserId\\":\\"([^\\]+)', log)

This line uses regular expressions to find matches for target users in the log. It looks for patterns like “UserId\”:\”some_user” and extracts the usernames. The script similarly extracts login failure reasons, source IP addresses, and error numbers using regular expressions.

b. IP Address Information

# Extract source IPs
    ip_matches = re.findall(r'ClientIP\\":\\"([^\\]+)', log)
    if ip_matches:
        log_data["Source IPs"] = set(ip_matches)

    # Fetch information about the source IPs from AbuseIPDB API
    if "Source IPs" in log_data:
        ip_info = []
        for ip_address in log_data["Source IPs"]:
            abuse_info = get_abuse_info(ip_address)
            if abuse_info is not None:
                ip_info.append(abuse_info)
        log_data["IP Information"] = ip_info

If source IP addresses are detected in the log, the script iterates through them and calls the get_abuse_info function for each IP address to fetch additional information from the AbuseIPDB API. This information includes ISP, country, and reputation.

c. Error Number Explanation

    # Extract error numbers
    error_number_matches = re.findall(r'ErrorNumber\\":\\"([^\\]+)', log)
    if error_number_matches:
        log_data["Error Numbers"] = set(error_number_matches)

If error numbers are detected in the log, the script iterates through them and calls the get_explanation function for each error number. This function fetches error details from the Microsoft Azure login error page, including messages and remediation suggestions.

  1. get_explanation Function

def get_explanation(error_number):
    base_url = "https://login.microsoftonline.com/error?code="
    url = f"{base_url}{error_number}"

    try:
        print(f"Fetching error details for {error_number} from: {url}")
        response = requests.get(url)
        response.raise_for_status()
        print("Response status:", response.status_code)
        soup = BeautifulSoup(response.content, 'html.parser')
        table = soup.find('table')

        if table:
            rows = table.find_all('tr')
            error_data = {}
            for row in rows:
                cols = row.find_all('td')
                if len(cols) == 2:
                    key = cols[0].text.strip()
                    value = cols[1].text.strip()
                    error_data[key] = value
            
            explanation = error_data.get("Message", "No explanation available for the given error number.")
            remediation = error_data.get("Remediation", "No remediation information available.")
            
            print(f"Explanation for {error_number}: {explanation}")
            print(f"Remediation for {error_number}: {remediation}")

            return explanation, remediation

    except requests.exceptions.RequestException as e:
        print(f"Error occurred while fetching error details for {error_number}: {e}")

    return "No explanation available for the given error number.", "No remediation information available."

This function fetches error details from the Microsoft Azure login error page based on the provided error number. It uses the requests library to make an HTTP GET request to the Azure login error page and parses the HTML response using BeautifulSoup.

  1. get_abuse_info Function
def get_abuse_info(ip_address):
    url = f"https://api.abuseipdb.com/api/v2/check"

    params = {
        "ipAddress": ip_address,
        "maxAgeInDays": 90,
        "verbose": "",
    }

    headers = {
        "Key": API_KEY,
        "Accept": "application/json",
    }

    try:
        response = requests.get(url, params=params, headers=headers)
        response.raise_for_status()
        data = response.json()
        if "data" in data:
            abuse_info = data["data"]
            isp = abuse_info.get("isp", "Unknown")
            country = abuse_info.get("countryName", "Unknown")
            reputation = abuse_info.get("abuseConfidenceScore", "Unknown")
            return {
                "Source IP": ip_address,
                "ISP": isp,
                "Country": country,
                "Reputation": reputation,
            }
        else:
            print(f"No information available for Source IP: {ip_address}")
    except requests.exceptions.RequestException as e:
        print(f"Error occurred while fetching abuse information: {e}")

This function queries the AbuseIPDB API to gather information about an IP address’s reputation, ISP, and country.

  1. The user interface part of the script
clipboard_confirmation = input("Are the logs in your clipboard? (Y/N): ") 
if clipboard_confirmation.lower() == "y": # Get the log input from the clipboard 
log_input = pyperclip.paste() 
# Analyse the log 
analyse_log(log_input) 
else: 
print("Please copy the logs to your clipboard and run the program again.")

It asks the user if the log data is in their clipboard. If the user confirms, it retrieves the log data from the clipboard and calls the analyse_log function to process it. If the user does not confirm, it asks them to copy the logs to the clipboard and run the program again.

That’s a comprehensive overview of how the script works. It’s designed to simplify the process of analysing Office 365 login failure logs and providing detailed information about the failures and potential threats.

The Output

Potential brute-force login attempts were detected, with no successful logins observed.

This alarm refers to a type of cyberattack in which an attacker uses automated software to repeatedly try different combinations of usernames or passwords in order to gain access to a system, although frequently it is triggered due to user error or cached credentials.

The login failure was associated with the following user:
- [email protected]

The login attempts originated from the following IP address:
-  XXX.XXX.XXX.XXX(ISP Company, United States of America, 0% confidence of abuse)

The login failure reason reported was:
- FreshTokenNeeded

The recorded error number & remediation suggested by Microsoft is:
- 50173: The provided grant has expired due to it being revoked, a fresh auth token is needed. The user might have changed or reset their password. The grant was issued on '{authTime}' and the TokensValidFrom date (before which tokens are not valid) for this user is '{validDate}'.
- Suggested remediation: Expected part of the token lifecycle - either an admin or a user revoked the tokens for this user, causing subsequent token refreshes to fail and require re-authentication. Have the user sign-in again.

Conclusion

Analysing Office 365 failed login alerts is essential for maintaining the security of your organisation’s accounts and data. This Python script streamlines the process, making it easier to identify potential threats and take proactive measures.

By leveraging the script’s capabilities to review and understand the causes of failed login attempts, you can enhance your organisation’s security posture and ensure that Office 365 accounts remain protected.

Remember to keep your script up-to-date and adjust it as needed to accommodate changes in Office 365 or the sources of your login alerts. Regularly reviewing and analysing these alerts is a proactive step towards safeguarding your organisation’s digital assets.

Full Code

import re
import pyperclip
import requests
from bs4 import BeautifulSoup

API_KEY = "redacted"

def analyse_log(log):
    log_data = {}

    # Extract target users
    user_matches = re.findall(r'UserId\\":\\"([^\\]+)', log)
    if user_matches:
        log_data["Target Users"] = set(user_matches)

    # Extract login failure reasons
    reason_matches = re.findall(r'LogonError\\":\\"([^\\]+)', log)
    if reason_matches:
        log_data["Login Failure Reasons"] = set(reason_matches)

    # Extract source IPs
    ip_matches = re.findall(r'ClientIP\\":\\"([^\\]+)', log)
    if ip_matches:
        log_data["Source IPs"] = set(ip_matches)

    # Fetch information about the source IPs from AbuseIPDB API
    if "Source IPs" in log_data:
        ip_info = []
        for ip_address in log_data["Source IPs"]:
            abuse_info = get_abuse_info(ip_address)
            if abuse_info is not None:
                ip_info.append(abuse_info)
        log_data["IP Information"] = ip_info

    # Extract error numbers
    error_number_matches = re.findall(r'ErrorNumber\\":\\"([^\\]+)', log)
    if error_number_matches:
        log_data["Error Numbers"] = set(error_number_matches)

    # Print the extracted information
    if "Target Users" in log_data:
        target_users = log_data["Target Users"]
        print("The login failure was associated with the following users:")
        for user in target_users:
            print(f"- {user}")
        print()

    if "Source IPs" in log_data:
        source_ips = log_data["Source IPs"]
        if "IP Information" in log_data:
            ip_info = log_data["IP Information"]
            print("The login attempts originated from the following IP addresses:")
            for info in ip_info:
                ip_address = info["Source IP"]
                isp = info.get("ISP", "Unknown")
                country = info.get("Country", "Unknown")
                reputation = info.get("Reputation", "Unknown")
                print(f"- {ip_address} ({isp}, {country}, {reputation}% confidence of abuse)")
        else:
            print("The login attempts originated from the following IP addresses:")
            for ip_address in source_ips:
                print(f"- {ip_address}")
        print()

    if "Login Failure Reasons" in log_data:
        login_failure_reasons = log_data["Login Failure Reasons"]
        print("The login failure reasons reported were:")
        for reason in login_failure_reasons:
            print(f"- {reason}")
        print()

    if "Error Numbers" in log_data:
        error_numbers = log_data["Error Numbers"]
        print("The recorded error numbers are:")
        for error_number in error_numbers:
            explanation, remediation = get_explanation(error_number)
            print(f"- {error_number}: {explanation}")
            print(f"- Suggested remediation: {remediation}")
        print()

    # Print the automated explanation based on the extracted information
    result = "Potential brute-force login attempts were detected, with no successful logins observed.\n"
    result += "\nThis alarm refers to a type of cyberattack in which an attacker uses automated software to repeatedly try different combinations of passwords in order to gain access to a system, although frequently it is triggered due to user error or cached credentials.\n\n" if len(target_users) == 1 else "\nThis alarm refers to a type of cyberattack in which an attacker uses automated software to repeatedly try different combinations of passwords and/or usernames in order to gain access to a system, although frequently it is triggered due to user error or cached credentials.\n\n"

    # Print the extracted information
    if "Target Users" in log_data:
        target_users = log_data["Target Users"]
        result += "The login failure was associated with the following user:\n" if len(target_users) == 1 else "The login failure was associated with the following users:\n"
        result += "\n".join(f"- {user}" for user in target_users) + "\n\n"

    if "Source IPs" in log_data:
        source_ips = log_data["Source IPs"]
        result += "The login attempts originated from the following IP address:\n" if len(source_ips) == 1 else "The login attempts originated from the following IP addresses:\n"
        if "IP Information" in log_data:
            ip_info = log_data["IP Information"]
            for info in ip_info:
                ip_address = info["Source IP"]
                isp = info.get("ISP", "Unknown")
                country = info.get("Country", "Unknown")
                reputation = info.get("Reputation", "Unknown")
                result += f"- {ip_address} ({isp}, {country}, {reputation}% confidence of abuse)\n"
        else:
            result += "\n".join(f"- {ip}" for ip in source_ips) + "\n"
    result += "\n"
    if "Login Failure Reasons" in log_data:
        login_failure_reasons = log_data["Login Failure Reasons"]
        result += "\nThe login failure reason reported was:\n" if len(login_failure_reasons) == 1 else "The login failure reasons reported were:\n"
        result += "\n".join(f"- {reason}" for reason in login_failure_reasons) + "\n\n"

    if "Error Numbers" in log_data:
        error_numbers = log_data["Error Numbers"]
        result += "The recorded error number & remediation suggested by Microsoft is:\n" if len(error_numbers) == 1 else "The recorded error numbers & remediations suggested by Microsoft are:\n"
        for error_number in error_numbers:
            explanation, remediation = get_explanation(error_number)
            result += f"- {error_number}: {explanation}\n"
            result += f"- Suggested remediation: {remediation}\n\n"
        result += "\n"


    pyperclip.copy(result)
    print(result)


def get_explanation(error_number):
    base_url = "https://login.microsoftonline.com/error?code="
    url = f"{base_url}{error_number}"

    try:
        print(f"Fetching error details for {error_number} from: {url}")
        response = requests.get(url)
        response.raise_for_status()
        print("Response status:", response.status_code)
        soup = BeautifulSoup(response.content, 'html.parser')
        table = soup.find('table')

        if table:
            rows = table.find_all('tr')
            error_data = {}
            for row in rows:
                cols = row.find_all('td')
                if len(cols) == 2:
                    key = cols[0].text.strip()
                    value = cols[1].text.strip()
                    error_data[key] = value
            
            explanation = error_data.get("Message", "No explanation available for the given error number.")
            remediation = error_data.get("Remediation", "No remediation information available.")
            
            print(f"Explanation for {error_number}: {explanation}")
            print(f"Remediation for {error_number}: {remediation}")

            return explanation, remediation

    except requests.exceptions.RequestException as e:
        print(f"Error occurred while fetching error details for {error_number}: {e}")

    return "No explanation available for the given error number.", "No remediation information available."



def get_abuse_info(ip_address):
    url = f"https://api.abuseipdb.com/api/v2/check"

    params = {
        "ipAddress": ip_address,
        "maxAgeInDays": 90,
        "verbose": "",
    }

    headers = {
        "Key": API_KEY,
        "Accept": "application/json",
    }

    try:
        response = requests.get(url, params=params, headers=headers)
        response.raise_for_status()
        data = response.json()
        if "data" in data:
            abuse_info = data["data"]
            isp = abuse_info.get("isp", "Unknown")
            country = abuse_info.get("countryName", "Unknown")
            reputation = abuse_info.get("abuseConfidenceScore", "Unknown")
            return {
                "Source IP": ip_address,
                "ISP": isp,
                "Country": country,
                "Reputation": reputation,
            }
        else:
            print(f"No information available for Source IP: {ip_address}")
    except requests.exceptions.RequestException as e:
        print(f"Error occurred while fetching abuse information: {e}")


# Check if the logs are in the clipboard
clipboard_confirmation = input("Are the logs in your clipboard? (Y/N): ")
if clipboard_confirmation.lower() == "y":
    # Get the log input from the clipboard
    log_input = pyperclip.paste()

    # Analyse the log
    analyse_log(log_input)

else:
    print("Please copy the logs to your clipboard and run the program again.")