Build a Brute-Force Login Detector

Analyze synthetic login logs to detect brute-force attacks with Python.

Introduction

30 Second Summary

A handful of failed sign-ins can mean a forgotten password or a coordinated attack. The timing behind each attempt helps reveal the difference.

In this project, you will build a defensive login detector in Google Colab using Python. You will tune its alert logic until it isolates suspicious activity from ordinary login mistakes.

What You'll Build

You’ll run a shareable notebook that turns 18 synthetic login events into one evidence-backed brute-force finding you can show another analyst.

By the end of this project, you'll have:

  • A structured event view where you can inspect each timestamp, source address, username, and login result.
  • A false-positive demonstration that shows why raw failure counts can flag ordinary login mistakes.
  • An evidence-backed incident finding with the affected account, suspicious source, MITRE ATT&CK T1110 mapping, and defensive recommendations.
  • Secret Mission: Extend the notebook to detect one source targeting four different accounts within five minutes.

Are there any prerequisites?

You need a Google account with internet access. Step 1 gets Google Colab running in your browser without a local Python installation or terminal.

Before We Start

Before the hands-on work begins, commit to building a defensive detector that uses only synthetic authentication logs. Your goal is to distinguish rapid suspicious login bursts from ordinary failed logins spread over time.

Open Colab and Test Python

A defensive detector needs a working place to run Python before it can inspect authentication logs.

Google Colab gives you a browser-based notebook. Your first successful cell proves the runtime is ready without a local installation or terminal.

In this step, get ready to:
  • Access Google Colab in a supported browser.
  • Create a notebook named brute_force_detector.
  • Run a test code cell that proves Python works.
Reach Colab in a supported browser

Why Colab for this project?

Colab runs notebooks in your browser without local setup. This keeps your attention on the detector instead of software installation.

The project needs no API key or third-party package. Everything runs inside the free hosted notebook.

  • Open the latest Google Chrome from your device's app launcher or search.
  • Use the latest Firefox or Safari if Chrome is unavailable.

You'll see a supported browser window ready for the Colab page.

Signing in gives the notebook a home in your Google Drive account. Google's sign-in flow handles your password.

  • Visit the Google Colab page in your browser.
  • Sign in with the Google account where you want the notebook stored.

You'll see Colab under your signed-in account. This confirms that your browser-based workspace is available.

Can't reach your Colab workspace?

  • Reload the Colab page in the latest Chrome, Firefox, or Safari.
  • Complete the Google account sign-in prompt if the notebook page asks for access.

If the page still does not load, help me troubleshoot access to Google Colab.

Prepare your detector notebook

Colab stores newly created notebooks in Google Drive. A clear name makes this investigation easy to find later.

  • Create a new notebook from the Colab landing page.

You'll see a blank notebook with an empty code cell.

  • Select the generated notebook title in the top-left corner.

The title becomes editable.

  • Type brute_force_detector into the title field.
  • Press Enter to save the name.

You'll see the notebook title as brute_force_detector.ipynb.

That setup is locked in. Your detector notebook now has a stable home in Google Drive.

Run your first Python cell

A code cell separates the instructions you write from the result Python returns. Your code sits in the input area.

  • Select the empty code cell in your notebook.
  • Add the readiness check to the selected code cell by typing this line:
print("Colab is ready")

What does this code do?

  • The print() function sends a value to the cell's output area.
  • The quotation marks tell Python to treat the enclosed words as text.

Before you run the cell, what exact line do you expect to appear beneath it?

  • Run the selected cell by clicking its play button.

You'll see Colab is ready beneath the input cell.

That first success is yours. Python is now running in your browser.

  • Identify the cell containing print("Colab is ready") as the input area.
  • Identify Colab is ready beneath the cell as the output.

No output beneath the cell?

  • Confirm that the code uses lowercase print.
  • Check that the text has matching double quote marks.
  • Check that the line ends with a closing parenthesis.

If the cell still does not run, help me troubleshoot this Colab code cell.

✔️ Awesome, I've got everything!

Great. Confirm that brute_force_detector.ipynb is named correctly and shows the successful output.

ⓧ I'd like to double check the full code

print("Colab is ready")

Your browser-based Python workspace is ready. Next, you'll load the synthetic authentication events that your detector will inspect.

Load and Inspect Authentication Events

Your Google Colab notebook can run Python code in your browser. You now have a working place to build the detector.

Raw authentication logs begin as text. A detector needs each row as a dictionary with a parsed timestamp before it can compare activity.

In this step, get ready to:
  • Load a safe synthetic authentication log into the notebook.
  • Turn each CSV row into a structured event.
  • Preview the first five structured records.
Add the synthetic log data

Synthetic events let you practise defensive analysis without interacting with real accounts. The source addresses come from ranges reserved for documentation.

  • In the Google Colab notebook from earlier, select the code cell that contains print("Colab is ready").
  • Replace the cell contents with the imports plus the synthetic log data below:
# Cell 1: Load and inspect the synthetic authentication log
import csv
import datetime as dt
import io

LOG_DATA = """timestamp,source_ip,username,result
2026-11-03T09:00:00,192.0.2.10,carol,SUCCESS
2026-11-03T09:05:00,192.0.2.20,bob,FAILURE
2026-11-03T09:14:00,192.0.2.20,bob,FAILURE
2026-11-03T09:23:00,192.0.2.20,bob,FAILURE
2026-11-03T09:32:00,192.0.2.20,bob,FAILURE
2026-11-03T09:41:00,192.0.2.20,bob,FAILURE
2026-11-03T09:45:00,192.0.2.20,bob,SUCCESS
2026-11-03T10:00:00,203.0.113.77,alice,FAILURE
2026-11-03T10:01:00,203.0.113.77,alice,FAILURE
2026-11-03T10:02:00,203.0.113.77,alice,FAILURE
2026-11-03T10:03:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:30,203.0.113.77,alice,SUCCESS
2026-11-03T11:00:00,198.51.100.44,dave,FAILURE
2026-11-03T11:01:00,198.51.100.44,erin,FAILURE
2026-11-03T11:02:00,198.51.100.44,frank,FAILURE
2026-11-03T11:03:00,198.51.100.44,grace,FAILURE
2026-11-03T11:10:00,192.0.2.30,heidi,SUCCESS
"""

What does this code set up?

  • The csv module provides tools for reading comma-separated rows.
  • The datetime as dt import provides the time parser used by the detector.
  • The io module lets Python read the multiline string like a text stream.
  • The LOG_DATA string holds 18 synthetic login events.
  • Run the cell using its play button.

The cell finishes successfully. Your synthetic log data is now available in the notebook runtime.

Cell not completing?

Check that the opening triple quotes after LOG_DATA = have matching closing triple quotes after the final event.

Confirm that every import appears above LOG_DATA. Help me check this synthetic log data cell.

Parse the CSV into events

The log string still contains plain text rows. Parsing turns each row into named fields that Python can inspect.

  • Click at the end of the code cell after the closing triple quotes.
  • Add the parser plus the event count by pasting this code below the log data:
events = []
for row in csv.DictReader(io.StringIO(LOG_DATA.strip())):
    row["time"] = dt.datetime.fromisoformat(row["timestamp"])
    events.append(row)

print(f"Loaded {len(events)} events")

How does the parser work?

  • The events list collects every structured login event.
  • The csv.DictReader call uses the header row as dictionary field names.
  • The dt.datetime.fromisoformat call converts each timestamp string into a value Python can compare.
  • The parsed value is stored in the new time field while the original timestamp remains available.
  • Run the updated cell using its play button.

You will see Loaded 18 events. That is a solid milestone because every synthetic row is now a structured event.

Seeing the wrong event count?

Check that all 18 data rows sit between the opening triple quotes and the closing triple quotes.

Make sure events = [] appears before the parsing loop. Help me find why my event count is different.

Inspect the first five events

A small preview confirms that the field names match the data you plan to analyze. It also makes malformed rows easy to spot before detection begins.

  • Click at the end of the code cell after the event count line.
  • Add the preview loop by pasting this code below the count output:
for event in events[:5]:
    print(
        event["timestamp"],
        event["source_ip"],
        event["username"],
        event["result"],
    )

What does the preview show?

  • The slice events[:5] selects the first five dictionaries.
  • Each output line keeps the four original log fields in a fixed order.
  • Every dictionary also contains the parsed time field for later comparisons.

Before you run the completed cell, predict which four original fields each preview line will show.

  • Run the completed cell using its play button.

You will see Loaded 18 events followed by five preview lines. Each line shows a timestamp followed by a source address followed by a username followed by a result.

The first preview line contains 2026-11-03T09:00:00 followed by 192.0.2.10 followed by carol followed by SUCCESS.

Preview lines missing?

Check that the preview loop begins at the left edge of the code cell. Keep the four event fields indented inside print.

Confirm that you ran the full cell after adding the loop. Help me fix this event preview.

✔️ Awesome, I've got everything!

Great. Confirm that the notebook cell is saved with the full parser plus the preview loop.

ⓧ I'd like to double check the full code

# Cell 1: Load and inspect the synthetic authentication log
import csv
import datetime as dt
import io

LOG_DATA = """timestamp,source_ip,username,result
2026-11-03T09:00:00,192.0.2.10,carol,SUCCESS
2026-11-03T09:05:00,192.0.2.20,bob,FAILURE
2026-11-03T09:14:00,192.0.2.20,bob,FAILURE
2026-11-03T09:23:00,192.0.2.20,bob,FAILURE
2026-11-03T09:32:00,192.0.2.20,bob,FAILURE
2026-11-03T09:41:00,192.0.2.20,bob,FAILURE
2026-11-03T09:45:00,192.0.2.20,bob,SUCCESS
2026-11-03T10:00:00,203.0.113.77,alice,FAILURE
2026-11-03T10:01:00,203.0.113.77,alice,FAILURE
2026-11-03T10:02:00,203.0.113.77,alice,FAILURE
2026-11-03T10:03:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:30,203.0.113.77,alice,SUCCESS
2026-11-03T11:00:00,198.51.100.44,dave,FAILURE
2026-11-03T11:01:00,198.51.100.44,erin,FAILURE
2026-11-03T11:02:00,198.51.100.44,frank,FAILURE
2026-11-03T11:03:00,198.51.100.44,grace,FAILURE
2026-11-03T11:10:00,192.0.2.30,heidi,SUCCESS
"""

events = []
for row in csv.DictReader(io.StringIO(LOG_DATA.strip())):
    row["time"] = dt.datetime.fromisoformat(row["timestamp"])
    events.append(row)

print(f"Loaded {len(events)} events")
for event in events[:5]:
    print(
        event["timestamp"],
        event["source_ip"],
        event["username"],
        event["result"],
    )

Your notebook now holds 18 structured authentication events. Next, you will count failed logins to see how a simple detector can produce a noisy alert.

Build a Noisy Failure Counter

Your Google Colab notebook now holds 18 structured events from a synthetic authentication log. You can use those records to test your first alert rule.

A username counter offers a simple starting point. It ignores source and timing context, so ordinary mistakes can look like a rapid attack.

In this step, get ready to:
  • Count failed login events for each username.
  • Alert on usernames with at least five failures.
  • Diagnose the false positive using the event timestamps.
Count failures by username

A Python dictionary can store each username beside its failure total. The loop updates that total every time it finds a FAILURE event.

  • Add a new code cell directly beneath the inspection cell using the notebook toolbar.
  • Paste the noisy failure counter into the new cell:
# Cell 2: Experience the false positive from a naive detector
failure_totals = {}

for event in events:
    if event["result"] == "FAILURE":
        username = event["username"]
        failure_totals[username] = failure_totals.get(username, 0) + 1

naive_alerts = sorted(
    username
    for username, failure_count in failure_totals.items()
    if failure_count >= 5
)

print("Naive alerts:", naive_alerts)

What does this code do?

  • The failure_totals dictionary starts empty so the loop can build the counts from the events.
  • The loop processes only events whose result is FAILURE.
  • Each matching event adds one to the total for its username.
  • The threshold keeps usernames with five or more failures in naive_alerts.
  • The final line prints the sorted alert list so you can inspect the rule's result.
  • Save brute_force_detector.ipynb with the notebook's save control.

Before you run this cell, predict whether a username-only count can separate a rapid burst from failures scattered over time.

  • Run the detector cell with its play button.

Your counter runs exactly as written. You see Naive alerts: ['alice', 'bob'] beneath the cell.

The counter treats both usernames as equivalent. That is the intended shortfall.

Output Missing or Different?

  • Run the first cell again so events contains all 18 records.
  • Check that the detector cell uses FAILURE in uppercase letters.
  • Confirm that the threshold comparison is failure_count >= 5.
  • Ask for help with the cell using Help me diagnose my noisy failure counter.

✔️ Awesome, I've got everything!

Great. Your notebook now contains the working noisy detector cell.

ⓧ I'd like to double check the full code

# Cell 1: Load and inspect the synthetic authentication log
import csv
import datetime as dt
import io

LOG_DATA = """timestamp,source_ip,username,result
2026-11-03T09:00:00,192.0.2.10,carol,SUCCESS
2026-11-03T09:05:00,192.0.2.20,bob,FAILURE
2026-11-03T09:14:00,192.0.2.20,bob,FAILURE
2026-11-03T09:23:00,192.0.2.20,bob,FAILURE
2026-11-03T09:32:00,192.0.2.20,bob,FAILURE
2026-11-03T09:41:00,192.0.2.20,bob,FAILURE
2026-11-03T09:45:00,192.0.2.20,bob,SUCCESS
2026-11-03T10:00:00,203.0.113.77,alice,FAILURE
2026-11-03T10:01:00,203.0.113.77,alice,FAILURE
2026-11-03T10:02:00,203.0.113.77,alice,FAILURE
2026-11-03T10:03:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:30,203.0.113.77,alice,SUCCESS
2026-11-03T11:00:00,198.51.100.44,dave,FAILURE
2026-11-03T11:01:00,198.51.100.44,erin,FAILURE
2026-11-03T11:02:00,198.51.100.44,frank,FAILURE
2026-11-03T11:03:00,198.51.100.44,grace,FAILURE
2026-11-03T11:10:00,192.0.2.30,heidi,SUCCESS
"""

events = []
for row in csv.DictReader(io.StringIO(LOG_DATA.strip())):
    row["time"] = dt.datetime.fromisoformat(row["timestamp"])
    events.append(row)

print(f"Loaded {len(events)} events")
for event in events[:5]:
    print(
        event["timestamp"],
        event["source_ip"],
        event["username"],
        event["result"],
    )

# Cell 2: Experience the false positive from a naive detector
failure_totals = {}

for event in events:
    if event["result"] == "FAILURE":
        username = event["username"]
        failure_totals[username] = failure_totals.get(username, 0) + 1

naive_alerts = sorted(
    username
    for username, failure_count in failure_totals.items()
    if failure_count >= 5
)

print("Naive alerts:", naive_alerts)
Diagnose the false positive

A false positive is an alert that labels ordinary activity as suspicious. The raw timestamps reveal whether the two usernames reached five failures in the same way.

  • Return to the LOG_DATA text in the first cell.
  • Locate Bob's first failed event at 2026-11-03T09:05:00.
  • Locate Bob's fifth failed event at 2026-11-03T09:41:00.
  • Locate Alice's first failed event at 2026-11-03T10:00:00.
  • Locate Alice's fifth failed event at 2026-11-03T10:04:00.

Bob's five failures stretch from 09:05 to 09:41. The span is 36 minutes.

Alice's five failures stretch from 10:00 to 10:04. The span is four minutes.

Why Is the Alert Too Noisy?

The detector sees only each username's total. Both users reach the threshold of five failures.

Bob's failures are spaced across 36 minutes. That timing makes Bob's alert a false positive in this synthetic lab.

Alice's failures form a rapid four-minute burst. Timing changes the meaning of the same failure count.

Before you rerun the cell, predict which alert still has weak timing evidence.

  • Run the detector cell again with its play button.

You still see Naive alerts: ['alice', 'bob']. The unchanged output proves that the counter cannot use the timing difference you identified.

You have exposed the noisy baseline behind a misleading alert. Next, you will correlate each account with its source and timing so the rapid burst stands apart.

Correlate Brute-Force Evidence

Your first detector exposed repeated login failures. It also flagged Bob even though his failures were spread across 36 minutes.

Event correlation connects each account to its source. It also checks whether the failures occur shortly before a successful login.

In this step, get ready to:
  • Group events by source and account.
  • Detect five failures followed by success inside five minutes.
  • Verify that the tuned result includes Alice while excluding Bob.
Define the correlation rule

A five-minute window gives your parsed timestamps a clear boundary. The detector creates one group for each source-account pair.

A group becomes suspicious when five failures occur before a successful login inside that window. These values are authored parameters for this synthetic lab.

  • Add a new code cell beneath Cell 2 using the notebook's code-cell control from earlier.
  • Define the correlation rule in the new cell by pasting this code:
# Cell 3: Correlate failures by source, account, and time
FAILURE_THRESHOLD = 5
WINDOW_MINUTES = 5

def detect_brute_force(events, failure_threshold=5, window_minutes=5):
    window = dt.timedelta(minutes=window_minutes)
    grouped_events = {}
    alerts = []
    for event in events:
        key = (event["source_ip"], event["username"])
        grouped_events.setdefault(key, []).append(event)
    for (source_ip, username), account_events in grouped_events.items():
        account_events = sorted(account_events, key=lambda item: item["time"])
        for index, event in enumerate(account_events):
            if event["result"] != "SUCCESS":
                continue
            window_start = event["time"] - window
            recent_failures = [candidate for candidate in account_events[:index] if candidate["result"] == "FAILURE" and window_start <= candidate["time"] < event["time"]]
            if len(recent_failures) >= failure_threshold:
                alerts.append({"source_ip": source_ip, "username": username, "failure_count": len(recent_failures), "first_failure": recent_failures[0]["timestamp"], "success_time": event["timestamp"], "window_minutes": window_minutes})
    return alerts

How does the correlation work?

  • The FAILURE_THRESHOLD value requires five failures.
  • The WINDOW_MINUTES value limits the evidence to five minutes.
  • The key tuple keeps each source-account pair in its own group.
  • The dt.timedelta duration sets the start of the window before each success.
  • The alert records the source, account, failure count, first failure, success time, and window length.
  • Save brute_force_detector.ipynb using the notebook's save control.
  • Run Cell 3 using the play button.

The execution indicator confirms that Python accepted the detector definition. Your correlation rule is now ready to analyze the events.

Does the detector definition stop with an error?

Confirm that Cell 1 has already run so dt exists. Check that every dictionary key uses matching quotation marks.

Compare the indentation beneath each loop with the reference code. Help me debug the correlation function.

Run the tuned detector

The function defines the detection logic. A caller now passes in the parsed events and prints every resulting alert.

  • Find the return alerts line at the bottom of Cell 3.
  • Add the detector call beneath that line by pasting this code:
tuned_alerts = detect_brute_force(events, failure_threshold=FAILURE_THRESHOLD, window_minutes=WINDOW_MINUTES)
print(f"Tuned alerts: {len(tuned_alerts)}")
for alert in tuned_alerts:
    print(alert)

What does this code do?

  • The detect_brute_force() call analyzes the parsed events with the lab parameters.
  • The returned alert dictionaries are stored in tuned_alerts.
  • The first print statement reveals the total number of tuned alerts.
  • The loop prints the evidence stored in each alert.

Before you run the completed cell, predict which username the source-and-time window will keep.

  • Save brute_force_detector.ipynb using the notebook's save control.
  • Run Cell 3 using the play button.

You will see Tuned alerts: 1. The printed alert identifies alice at 203.0.113.77.

That is the noisy result fixed. The detector now keeps the rapid failure burst that led to a successful login.

Seeing the wrong alert count?

Confirm that recent_failures only includes events before the current success. Check that the comparison uses each parsed time value.

Make sure the grouping key contains both source_ip and username. Help me find why my tuned alert count is incorrect.

✔️ Awesome, I've got everything!

Great. Double-check that Cell 3 is saved in brute_force_detector.ipynb.

ⓧ I'd like to double check the full code

# Cell 1: Load and inspect the synthetic authentication log
import csv
import datetime as dt
import io

LOG_DATA = """timestamp,source_ip,username,result
2026-11-03T09:00:00,192.0.2.10,carol,SUCCESS
2026-11-03T09:05:00,192.0.2.20,bob,FAILURE
2026-11-03T09:14:00,192.0.2.20,bob,FAILURE
2026-11-03T09:23:00,192.0.2.20,bob,FAILURE
2026-11-03T09:32:00,192.0.2.20,bob,FAILURE
2026-11-03T09:41:00,192.0.2.20,bob,FAILURE
2026-11-03T09:45:00,192.0.2.20,bob,SUCCESS
2026-11-03T10:00:00,203.0.113.77,alice,FAILURE
2026-11-03T10:01:00,203.0.113.77,alice,FAILURE
2026-11-03T10:02:00,203.0.113.77,alice,FAILURE
2026-11-03T10:03:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:30,203.0.113.77,alice,SUCCESS
2026-11-03T11:00:00,198.51.100.44,dave,FAILURE
2026-11-03T11:01:00,198.51.100.44,erin,FAILURE
2026-11-03T11:02:00,198.51.100.44,frank,FAILURE
2026-11-03T11:03:00,198.51.100.44,grace,FAILURE
2026-11-03T11:10:00,192.0.2.30,heidi,SUCCESS
"""

events = []
for row in csv.DictReader(io.StringIO(LOG_DATA.strip())):
    row["time"] = dt.datetime.fromisoformat(row["timestamp"])
    events.append(row)

print(f"Loaded {len(events)} events")
for event in events[:5]:
    print(event["timestamp"], event["source_ip"], event["username"], event["result"])

# Cell 2: Experience the false positive from a naive detector
failure_totals = {}
for event in events:
    if event["result"] == "FAILURE":
        username = event["username"]
        failure_totals[username] = failure_totals.get(username, 0) + 1
naive_alerts = sorted(username for username, failure_count in failure_totals.items() if failure_count >= 5)
print("Naive alerts:", naive_alerts)

# Cell 3: Correlate failures by source, account, and time
FAILURE_THRESHOLD = 5
WINDOW_MINUTES = 5

def detect_brute_force(events, failure_threshold=5, window_minutes=5):
    window = dt.timedelta(minutes=window_minutes)
    grouped_events = {}
    alerts = []
    for event in events:
        key = (event["source_ip"], event["username"])
        grouped_events.setdefault(key, []).append(event)
    for (source_ip, username), account_events in grouped_events.items():
        account_events = sorted(account_events, key=lambda item: item["time"])
        for index, event in enumerate(account_events):
            if event["result"] != "SUCCESS":
                continue
            window_start = event["time"] - window
            recent_failures = [candidate for candidate in account_events[:index] if candidate["result"] == "FAILURE" and window_start <= candidate["time"] < event["time"]]
            if len(recent_failures) >= failure_threshold:
                alerts.append({"source_ip": source_ip, "username": username, "failure_count": len(recent_failures), "first_failure": recent_failures[0]["timestamp"], "success_time": event["timestamp"], "window_minutes": window_minutes})
    return alerts

tuned_alerts = detect_brute_force(events, failure_threshold=FAILURE_THRESHOLD, window_minutes=WINDOW_MINUTES)
print(f"Tuned alerts: {len(tuned_alerts)}")
for alert in tuned_alerts:
    print(alert)
Inspect the alert evidence

Each alert dictionary preserves the evidence behind the match. This gives you enough context to explain why Alice triggered the tuned rule.

  • Find alice in the printed username field.
  • Find 203.0.113.77 in the printed source_ip field.
  • Confirm that failure_count contains 5.
  • Confirm that first_failure contains 2026-11-03T10:00:00.
  • Confirm that success_time contains 2026-11-03T10:04:30.

Alice has five failures followed by success within five minutes. Bob is absent because his five failures span 36 minutes.

Before the final run, predict whether bob can satisfy the five-minute correlation window.

  • Run Cell 3 again using the play button.
  • Compare the new tuned_alerts output with naive_alerts from earlier.

You will see one tuned alert for alice from 203.0.113.77. You will not see bob in the tuned output.

Your tuned detector now produces one evidence-backed alert. Next, you will turn that alert into an incident finding another analyst can use.

Create an Incident Finding

Your tuned detector in Google Colab now isolates the rapid login burst for alice. The next goal is to turn that alert into a handoff another analyst can use.

A useful incident finding presents what happened. It explains why the activity matters before giving the reader clear defensive actions.

In this step, get ready to:
  • Format the tuned alert as readable evidence.
  • Map the behavior to MITRE ATT&CK T1110.
  • Print the incident finding with three defensive recommendations.
Connect the alert to a response

The tuned alert already contains the evidence needed for a defensible finding. Each report statement can point back to a specific field in the alert dictionary.

What makes this finding useful?

  • The source_ip field identifies where the activity originated.
  • The username field identifies the affected account.
  • The failure_count field measures the suspicious burst.
  • The first_failure field marks the beginning of the evidence sequence.
  • The success_time field shows that a successful login followed the failures.
  • The window_minutes field shows how tightly the events occurred.
  • The T1110 mapping gives the behavior a shared defensive label.
  • Return to the Cell 3 output from earlier.
  • Match the printed alert values to the evidence fields described above.

Your alert traces five failed logins from 203.0.113.77 to alice. The sequence ends in success inside five minutes.

Build and run the incident report

The report function converts the alert dictionary into a compact analyst handoff. Its final guard prints the report only when the detector has produced an alert.

  • Add a new code cell directly beneath Cell 3 in the brute_force_detector notebook.
  • Paste the report builder into the new cell using this code:
# Cell 4: Produce an incident finding
def build_incident_report(alert):
    lines = [
        "INCIDENT FINDING",
        "Technique: MITRE ATT&CK T1110 - Brute Force",
        f"Source: {alert['source_ip']}",
        f"Affected account: {alert['username']}",
        f"Evidence: {alert['failure_count']} failed logins from {alert['first_failure']} followed by success at {alert['success_time']} inside {alert['window_minutes']} minutes.",
        "Recommended actions:",
        "1. Investigate the source and consider a temporary restriction.",
        "2. Review the affected account and reset it if compromise is suspected.",
        "3. Enable multi-factor authentication and review account use policies.",
    ]
    return "\n".join(lines)

if tuned_alerts:
    print(build_incident_report(tuned_alerts[0]))

What does this code do?

  • The lines list arranges the finding in the order another analyst needs to read it.
  • The formatted strings pull evidence directly from the supplied alert dictionary.
  • The "\n".join(lines) expression places each report section on its own line.
  • The if tuned_alerts: guard avoids requesting the first alert when the list is empty.
  • The recommendations connect the evidence to source investigation, account review, and stronger authentication controls.

Before you run the cell, which alert details do you expect the report to surface?

  • Run the new cell by selecting its play button.

What should I see?

  • The output begins with INCIDENT FINDING.
  • The next line maps the behavior to MITRE ATT&CK T1110 - Brute Force.
  • The evidence identifies 203.0.113.77 as the source.
  • The evidence identifies alice as the affected account.
  • The timeline shows five failures followed by success inside five minutes.
  • The final section recommends source investigation or restriction.
  • The final section recommends account review or reset.
  • The final section recommends multi-factor authentication plus account-use-policy review.

Report cell not producing output?

  • Run Cell 3 again if the runtime no longer contains tuned_alerts.
  • Check that every dictionary key in build_incident_report() matches the keys created by detect_brute_force().
  • Confirm that the final condition uses tuned_alerts.
  • Help me debug why my incident report cell does not print the tuned alert.

✔️ Awesome, I've got everything!

Your notebook now includes the saved report builder and its printed incident finding.

ⓧ I'd like to double check the full code

# Cell 1: Load and inspect the synthetic authentication log
import csv
import datetime as dt
import io

LOG_DATA = """timestamp,source_ip,username,result
2026-11-03T09:00:00,192.0.2.10,carol,SUCCESS
2026-11-03T09:05:00,192.0.2.20,bob,FAILURE
2026-11-03T09:14:00,192.0.2.20,bob,FAILURE
2026-11-03T09:23:00,192.0.2.20,bob,FAILURE
2026-11-03T09:32:00,192.0.2.20,bob,FAILURE
2026-11-03T09:41:00,192.0.2.20,bob,FAILURE
2026-11-03T09:45:00,192.0.2.20,bob,SUCCESS
2026-11-03T10:00:00,203.0.113.77,alice,FAILURE
2026-11-03T10:01:00,203.0.113.77,alice,FAILURE
2026-11-03T10:02:00,203.0.113.77,alice,FAILURE
2026-11-03T10:03:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:00,203.0.113.77,alice,FAILURE
2026-11-03T10:04:30,203.0.113.77,alice,SUCCESS
2026-11-03T11:00:00,198.51.100.44,dave,FAILURE
2026-11-03T11:01:00,198.51.100.44,erin,FAILURE
2026-11-03T11:02:00,198.51.100.44,frank,FAILURE
2026-11-03T11:03:00,198.51.100.44,grace,FAILURE
2026-11-03T11:10:00,192.0.2.30,heidi,SUCCESS
"""

events = []
for row in csv.DictReader(io.StringIO(LOG_DATA.strip())):
    row["time"] = dt.datetime.fromisoformat(row["timestamp"])
    events.append(row)

print(f"Loaded {len(events)} events")
for event in events[:5]:
    print(event["timestamp"], event["source_ip"], event["username"], event["result"])

# Cell 2: Experience the false positive from a naive detector
failure_totals = {}
for event in events:
    if event["result"] == "FAILURE":
        username = event["username"]
        failure_totals[username] = failure_totals.get(username, 0) + 1
naive_alerts = sorted(username for username, failure_count in failure_totals.items() if failure_count >= 5)
print("Naive alerts:", naive_alerts)

# Cell 3: Correlate failures by source, account, and time
FAILURE_THRESHOLD = 5
WINDOW_MINUTES = 5

def detect_brute_force(events, failure_threshold=5, window_minutes=5):
    window = dt.timedelta(minutes=window_minutes)
    grouped_events = {}
    alerts = []
    for event in events:
        key = (event["source_ip"], event["username"])
        grouped_events.setdefault(key, []).append(event)
    for (source_ip, username), account_events in grouped_events.items():
        account_events = sorted(account_events, key=lambda item: item["time"])
        for index, event in enumerate(account_events):
            if event["result"] != "SUCCESS":
                continue
            window_start = event["time"] - window
            recent_failures = [candidate for candidate in account_events[:index] if candidate["result"] == "FAILURE" and window_start <= candidate["time"] < event["time"]]
            if len(recent_failures) >= failure_threshold:
                alerts.append({"source_ip": source_ip, "username": username, "failure_count": len(recent_failures), "first_failure": recent_failures[0]["timestamp"], "success_time": event["timestamp"], "window_minutes": window_minutes})
    return alerts

tuned_alerts = detect_brute_force(events, failure_threshold=FAILURE_THRESHOLD, window_minutes=WINDOW_MINUTES)
print(f"Tuned alerts: {len(tuned_alerts)}")
for alert in tuned_alerts:
    print(alert)

# Cell 4: Produce an incident finding
def build_incident_report(alert):
    lines = [
        "INCIDENT FINDING",
        "Technique: MITRE ATT&CK T1110 - Brute Force",
        f"Source: {alert['source_ip']}",
        f"Affected account: {alert['username']}",
        f"Evidence: {alert['failure_count']} failed logins from {alert['first_failure']} followed by success at {alert['success_time']} inside {alert['window_minutes']} minutes.",
        "Recommended actions:",
        "1. Investigate the source and consider a temporary restriction.",
        "2. Review the affected account and reset it if compromise is suspected.",
        "3. Enable multi-factor authentication and review account use policies.",
    ]
    return "\n".join(lines)

if tuned_alerts:
    print(build_incident_report(tuned_alerts[0]))

You made the detector useful to someone beyond its author. Your notebook now turns correlated evidence into a clear defensive handoff.

Secret mission

Detect Password Spraying

Extend your notebook to detect one source targeting several accounts within five minutes. You will map the resulting alert to MITRE ATT&CK T1110.003 Password Spraying.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your Google Colab notebook available, pause your work, or delete it permanently. It stays in Google Drive without paid infrastructure, so this project costs $0.

Resources you used:

  • The brute_force_detector.ipynb Google Colab notebook stored in Google Drive.

Keep everything running

No action is needed for the saved notebook. This option fits when you want to rerun the analysis later.

  • Leave brute_force_detector.ipynb in Google Drive.
  • Return to the notebook whenever you want to rerun the analysis.

Pause - I'll come back to this later

Pausing ends this browser session while preserving the notebook for your return.

  • Close the browser tab that contains brute_force_detector.ipynb.

Your notebook remains in Google Drive. Colab may delete the temporary runtime after inactivity.

Delete - I don't want to use this again

Deleting the notebook becomes permanent after Trash is emptied. Your detector code disappears with it.

  • Open Google Drive in a new browser tab.
  • Locate brute_force_detector.ipynb using the search bar at the top of Google Drive.
  • Move brute_force_detector.ipynb to Trash from its context menu.

You'll see the notebook listed in Trash. This confirms the reversible part of the cleanup worked.

Emptying Trash makes the deletion final. The prior Trash check gives you one last chance to keep the notebook.

  • Open Trash from the Google Drive navigation panel.
  • Confirm brute_force_detector.ipynb is listed in Trash.
  • Choose the option to empty Trash.

Cleanup is complete. The notebook is no longer stored in Google Drive.

Nice Work!

Nice Work!

You did it! Your Google Colab notebook now turns synthetic authentication logs into an evidence-backed brute-force finding.

You've learned how to:

  • Use Python to parse CSV log text into 18 structured authentication events with usable timestamps.
  • Diagnose a false positive from a naive threshold. Use event correlation to isolate the rapid burst from ordinary failed logins.
  • Produce an incident finding with observable evidence. Map the behavior to MITRE ATT&CK T1110 with three defensive recommendations.
  • Secret Mission: Extend the notebook to detect one source targeting four distinct accounts in five minutes. Map the result to MITRE ATT&CK T1110.003 Password Spraying.

Ready to quiz yourself?