SSH Log Analyzer with Python

Build a CLI tool that detects brute-force attacks in SSH auth logs.

Introduction

30 Second Summary

Every Linux server connected to the internet gets bombarded with SSH login attempts from automated bots trying thousands of username and password combinations. Security analysts detect these attacks by reading authentication logs and spotting patterns.

In this project, you will build a Python command-line tool that analyzes SSH authentication logs and detects brute-force attacks. This is the same type of analysis that SOC (Security Operations Center) analysts perform daily.

What You'll Build

A modular Python CLI tool called ssh-log-analyzer that parses Linux auth.log files, identifies brute-force attacks using a sliding window algorithm, and generates structured JSON reports with threat severity classifications.

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

  • A working Python CLI tool that parses SSH authentication logs using regex patterns and extracts IPs, usernames, and event types.
  • A sliding window detection engine that identifies brute-force attacks and classifies threats by severity (MEDIUM, HIGH, CRITICAL).
  • A GitHub repository with a professional README that showcases your security tool to employers.
  • Secret Mission: Add compromise detection that flags successful logins from IPs that previously had failed attempts.

Do I need cybersecurity experience?

No. You only need basic Python knowledge (functions, classes, lists, and dictionaries). The project teaches you the security concepts as you build the tool.

Set Up Git, GitHub, and Project Structure

Every professional security tool needs a home. Before writing any detection logic, you need version control to track changes and a remote repository to share your work.

In this step, you will install Git for version control, create a GitHub repository to host your project, and set up the file structure for your SSH log analyzer.

In this step, get ready to:
  • Install and configure Git on your machine.
  • Create a GitHub repository to host your project.
  • Initialize your local project with the right file structure and push it to GitHub.
Install and configure Git

Git tracks every change you make to your code. It lets you revert mistakes, see what changed between versions, and collaborate with others.

  • Check if Git is already installed by running this command in your terminal:
git --version

✔️ I see a version number

Git is ready to go. Skip ahead to the configuration commands below.

ⓧ Command not found

  • Install Git by running this command:
sudo apt install git-all

Enter your system password when prompted. The install may take a minute.

  • Verify the installation by running:
git --version

You should now see a version number like 2.54.0 printed in your terminal.

Still seeing command not found?

Close your terminal and open a new one, then try git --version again. Some installs require a fresh terminal session to register.

help me troubleshoot why Git isn't installing on my Linux machine

Now configure Git with your identity. Every commit you make will be tagged with this name and email.

  • Set up your Git identity and preferences by running these commands one at a time:
git config --global user.name "[[YOUR_NAME="Your Name"]]"
git config --global user.email [[YOUR_EMAIL="you@example.com"]]
git config --global init.defaultBranch main
git config --global core.editor "code --wait"

What do these commands do?

  • user.name and user.email attach your identity to every commit so collaborators know who made each change.
  • init.defaultBranch main sets the default branch name to main for any new repository you create.
  • core.editor tells Git to open VS Code when it needs you to write a commit message or resolve conflicts.
  • Confirm your configuration saved correctly by running:
git config --global --list

You should see your name, email, default branch, and editor listed in the output.

Not seeing your name or email?

Make sure you included the --global flag. Without it, the config only applies to a specific repository (which you haven't created yet).

help me fix my Git configuration

Create a GitHub account and repository

GitHub is where your code lives online. Employers and collaborators will find your project here.

  • Open your browser and go to github.com.
  • Click Sign up and follow the prompts to create a free account (skip this if you already have one).
  • Once signed in, click the + icon in the top-right corner.
  • Select New repository.
  • Enter ssh-log-analyzer as the Repository name.
  • Select Public for the visibility.
  • Leave Add a README file unchecked.
  • Leave Add .gitignore set to None.
  • Click Create repository.

GitHub shows a Quick Setup page with your repository URL. You will need this URL in the next substep.

  • Copy the HTTPS URL shown on the Quick Setup page (it looks like https://github.com/YOUR_USERNAME/ssh-log-analyzer.git).

Why create an empty repository?

Skipping the README and .gitignore avoids conflicts when you push your local project. You will create these files locally instead, giving you full control over their contents.

Initialize the local project and push to GitHub

Now you will create the project folder on your machine, set up the file structure, and connect everything to your GitHub repository.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir ssh-log-analyzer
cd ssh-log-analyzer
  • Initialize a Git repository in this folder by running:
git init

You should see a message like Initialized empty Git repository in /home/.../ssh-log-analyzer/.git/.

  • Create the project file structure by running these commands:
mkdir sample_logs
touch parser.py detector.py analyzer.py config.py sample_logs/auth.log .gitignore
  • Confirm the files exist by running:
ls

You should see analyzer.py, config.py, detector.py, parser.py, sample_logs, and .gitignore in the output.

What are these files for?

  • parser.py will extract structured data from raw log lines.
  • detector.py will contain the brute-force detection algorithms.
  • analyzer.py will be the CLI entry point that ties everything together.
  • config.py will hold detection thresholds and settings.
  • sample_logs/auth.log will contain realistic test data for your analyzer.
  • Open the project in VS Code by running:
code .

A new VS Code window opens with your project files visible in the Explorer sidebar.

  • In the VS Code Explorer sidebar, click on .gitignore to open it.
  • Paste the following content into the file:
__pycache__/
*.pyc
*.json
!sample_logs/

What does this .gitignore do?

  • __pycache__/ and *.pyc prevent compiled Python bytecode from cluttering your repository.
  • *.json keeps generated report files out of version control so you do not accidentally commit sensitive analysis data.
  • !sample_logs/ overrides the JSON rule to ensure your sample test data directory stays tracked.
  • Save .gitignore with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

__pycache__/
*.pyc
*.json
!sample_logs/

Now commit all these files to your local Git repository and push them to GitHub.

  • Stage and commit all project files by running these commands in the VS Code terminal (open it with Ctrl+`):
git add .
git commit -m "Initial project structure"

You should see output listing the new files that were committed.

What do git add and git commit do?

git add . stages every file in the current directory, telling Git to include them in the next snapshot.

git commit -m "..." saves that snapshot permanently with a descriptive message. Think of it like pressing "Save" on your entire project's state.

  • Connect your local repository to GitHub and push your code by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/ssh-log-analyzer.git"]]
git push -u origin main

What do these commands do?

git remote add origin links your local repository to the GitHub repository you created. The name origin is a conventional shorthand for your remote URL.

git push -u origin main uploads your committed files to GitHub. The -u flag sets origin main as the default so future pushes only need git push.

GitHub may prompt you to authenticate. If a browser window opens, sign in and authorize the connection.

  • Verify your push succeeded by refreshing your GitHub repository page in the browser.

You should see all your project files listed on the repository page: parser.py, detector.py, analyzer.py, config.py, sample_logs/, and .gitignore.

Push failed or authentication error?

If you see a permission error, make sure you are signed in to the correct GitHub account in your browser. GitHub uses browser-based authentication by default on Linux.

Double-check that the repository URL matches exactly what GitHub showed you on the Quick Setup page.

help me fix my git push authentication error

Your project structure is set up and version-controlled. Next up, you will fill these files with real code. Starting with the sample attack logs and the parser that reads them.

Create Sample Logs and Build the Parser

Your project structure is ready and connected to GitHub. Now it is time to give your tool something to analyze.

Real SSH servers generate authentication logs every time someone tries to connect. These logs live in /var/log/auth.log on Linux systems and record every failed password, accepted login, and invalid username attempt. In this step, you will create a realistic sample log and build a parser that extracts structured data from each line.

In this step, get ready to:
  • Create a sample auth.log file with realistic brute-force attack patterns.
  • Build a log parser using regex that extracts IPs, usernames, and event types.
  • Test the parser against the sample log to confirm it works.
Create a realistic sample auth.log file

Before building any detection logic, you need data to test against. Real auth.log files from production servers contain thousands of lines. Your sample will be smaller but include every attack pattern your tool needs to detect.

  • In VS Code, open sample_logs/auth.log from the Explorer sidebar.
  • Paste the following log entries into the file:
Jun 15 03:12:01 server sshd[2341]: Failed password for invalid user oracle from 192.168.1.50 port 45123 ssh2
Jun 15 03:12:05 server sshd[2342]: Failed password for invalid user admin from 192.168.1.50 port 45124 ssh2
Jun 15 03:12:10 server sshd[2343]: Failed password for invalid user test from 192.168.1.50 port 45125 ssh2
Jun 15 03:12:15 server sshd[2344]: Failed password for invalid user guest from 192.168.1.50 port 45126 ssh2
Jun 15 03:12:20 server sshd[2345]: Failed password for root from 192.168.1.50 port 45127 ssh2
Jun 15 03:12:25 server sshd[2346]: Failed password for root from 192.168.1.50 port 45128 ssh2
Jun 15 03:12:30 server sshd[2347]: Failed password for root from 192.168.1.50 port 45129 ssh2
Jun 15 03:13:01 server sshd[2348]: Accepted password for root from 192.168.1.50 port 45130 ssh2
Jun 15 04:01:11 server sshd[2401]: Failed password for root from 10.0.0.88 port 33201 ssh2
Jun 15 04:01:14 server sshd[2402]: Failed password for root from 10.0.0.88 port 33202 ssh2
Jun 15 04:01:17 server sshd[2403]: Failed password for root from 10.0.0.88 port 33203 ssh2
Jun 15 04:01:20 server sshd[2404]: Failed password for root from 10.0.0.88 port 33204 ssh2
Jun 15 04:01:23 server sshd[2405]: Failed password for root from 10.0.0.88 port 33205 ssh2
Jun 15 04:01:26 server sshd[2406]: Failed password for root from 10.0.0.88 port 33206 ssh2
Jun 15 04:01:29 server sshd[2407]: Failed password for root from 10.0.0.88 port 33207 ssh2
Jun 15 04:01:32 server sshd[2408]: Failed password for root from 10.0.0.88 port 33208 ssh2
Jun 15 04:01:35 server sshd[2409]: Failed password for root from 10.0.0.88 port 33209 ssh2
Jun 15 04:01:38 server sshd[2410]: Failed password for root from 10.0.0.88 port 33210 ssh2
Jun 15 04:01:41 server sshd[2411]: Failed password for root from 10.0.0.88 port 33211 ssh2
Jun 15 04:01:44 server sshd[2412]: Failed password for root from 10.0.0.88 port 33212 ssh2
Jun 15 04:01:47 server sshd[2413]: Failed password for root from 10.0.0.88 port 33213 ssh2
Jun 15 04:01:50 server sshd[2414]: Failed password for root from 10.0.0.88 port 33214 ssh2
Jun 15 04:01:53 server sshd[2415]: Failed password for root from 10.0.0.88 port 33215 ssh2
Jun 15 04:01:56 server sshd[2416]: Failed password for root from 10.0.0.88 port 33216 ssh2
Jun 15 04:01:59 server sshd[2417]: Failed password for root from 10.0.0.88 port 33217 ssh2
Jun 15 04:02:02 server sshd[2418]: Failed password for root from 10.0.0.88 port 33218 ssh2
Jun 15 04:02:05 server sshd[2419]: Failed password for root from 10.0.0.88 port 33219 ssh2
Jun 15 04:02:08 server sshd[2420]: Failed password for root from 10.0.0.88 port 33220 ssh2
Jun 15 04:02:11 server sshd[2421]: Failed password for root from 10.0.0.88 port 33221 ssh2
Jun 15 05:30:00 server sshd[2500]: Accepted password for alice from 203.0.113.10 port 52100 ssh2
Jun 15 06:00:15 server sshd[2601]: Invalid user postgres from 45.155.205.100 port 44001
Jun 15 06:00:20 server sshd[2602]: Invalid user mysql from 45.155.205.100 port 44002
Jun 15 06:00:25 server sshd[2603]: Invalid user deploy from 45.155.205.100 port 44003
Jun 15 06:00:30 server sshd[2604]: Invalid user ubuntu from 45.155.205.100 port 44004
Jun 15 06:00:35 server sshd[2605]: Invalid user ftpuser from 45.155.205.100 port 44005
Jun 15 08:15:00 server sshd[2700]: Accepted publickey for admin from 10.0.1.5 port 60100 ssh2
Jun 15 09:00:00 server sshd[2800]: Failed password for admin from 172.16.0.99 port 55001 ssh2
Jun 15 09:00:03 server sshd[2801]: Failed password for admin from 172.16.0.99 port 55002 ssh2
Jun 15 09:00:06 server sshd[2802]: Failed password for admin from 172.16.0.99 port 55003 ssh2

What attack patterns are in this log?

This sample contains three distinct threat patterns your tool will need to detect:

  • IP 192.168.1.50 tries 7 failed logins (4 invalid users + 3 root attempts) then succeeds. This is a brute-force attack followed by a compromise.
  • IP 10.0.0.88 hammers root with 21 rapid failures in about 60 seconds. This is an aggressive automated brute-force attack.
  • IP 45.155.205.100 tries 5 different invalid usernames. This is username enumeration, where an attacker probes for valid accounts.
  • Save sample_logs/auth.log.
Build the log parser

Your parser will read each line of the log file and extract structured fields using regular expressions. Every SSH log line follows the same syslog format, which makes regex an effective tool for pulling out timestamps, IPs, and usernames.

  • In VS Code, open parser.py from the Explorer sidebar.
  • Add the module imports and the LogEntry dataclass by pasting this code:
"""SSH auth.log parser module."""

import re
from dataclasses import dataclass
from datetime import datetime


@dataclass
class LogEntry:
    timestamp: str
    hostname: str
    pid: int
    event_type: str
    username: str
    ip_address: str
    port: int

What does this code do?

The LogEntry dataclass defines the structured shape of every parsed log event. Instead of passing around raw strings or dictionaries, you get a typed object with named fields.

Each field maps to a piece of information from the log line: the timestamp of the event, which hostname logged it, the SSH process ID, what type of event occurred, and who was targeted from which IP.

  • Below the LogEntry class, add the regex patterns that match each type of SSH event:
# Regex patterns for SSH log events
FAILED_PASSWORD_RE = re.compile(
    r"^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+sshd\[(\d+)\]:\s+"
    r"Failed password for (?:invalid user )?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)\s+ssh2"
)

ACCEPTED_RE = re.compile(
    r"^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+sshd\[(\d+)\]:\s+"
    r"Accepted\s+(?:password|publickey)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)\s+ssh2"
)

INVALID_USER_RE = re.compile(
    r"^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+sshd\[(\d+)\]:\s+"
    r"Invalid user\s+(\S+)\s+from\s+(\S+)(?:\s+port\s+(\d+))?"
)

What do these patterns match?

  • FAILED_PASSWORD_RE matches lines like "Failed password for invalid user admin from 192.168.1.50 port 45124 ssh2". The (?:invalid user )? part handles both valid and invalid usernames.
  • ACCEPTED_RE matches successful logins via password or public key authentication.
  • INVALID_USER_RE matches lines where SSH rejects the connection because the username does not exist on the system.

Each pattern uses capture groups () to extract the timestamp, hostname, PID, username, IP address, and port from a matching line.

  • Below the regex patterns, add the functions that use them to parse individual lines:
def parse_timestamp(timestamp_str: str) -> datetime:
    """Parse syslog timestamp string into datetime object."""
    current_year = datetime.now().year
    return datetime.strptime(f"{current_year} {timestamp_str}", "%Y %b %d %H:%M:%S")


def parse_line(line: str) -> LogEntry | None:
    """Parse a single log line and return a LogEntry or None."""
    match = FAILED_PASSWORD_RE.match(line)
    if match:
        return LogEntry(
            timestamp=match.group(1),
            hostname=match.group(2),
            pid=int(match.group(3)),
            event_type="FAILED_PASSWORD",
            username=match.group(4),
            ip_address=match.group(5),
            port=int(match.group(6)),
        )

    match = ACCEPTED_RE.match(line)
    if match:
        return LogEntry(
            timestamp=match.group(1),
            hostname=match.group(2),
            pid=int(match.group(3)),
            event_type="ACCEPTED_LOGIN",
            username=match.group(4),
            ip_address=match.group(5),
            port=int(match.group(6)),
        )

    match = INVALID_USER_RE.match(line)
    if match:
        port = int(match.group(6)) if match.group(6) else 0
        return LogEntry(
            timestamp=match.group(1),
            hostname=match.group(2),
            pid=int(match.group(3)),
            event_type="INVALID_USER",
            username=match.group(4),
            ip_address=match.group(5),
            port=port,
        )

    return None

What does this code do?

parse_timestamp() converts the syslog date string (like "Jun 15 03:12:01") into a Python datetime object. Syslog timestamps do not include the year, so the function adds the current year.

parse_line() tries each regex pattern in order against a single log line. When a pattern matches, it extracts the captured groups into a LogEntry object. If no pattern matches, it returns None so the caller can skip irrelevant lines.

  • Finally, add the file-reading function and the test runner at the bottom of parser.py:
def parse_file(filepath: str) -> list[LogEntry]:
    """Parse an entire log file and return list of LogEntry objects."""
    entries = []
    with open(filepath, "r") as f:
        for line in f:
            entry = parse_line(line.strip())
            if entry is not None:
                entries.append(entry)
    return entries


if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("Usage: python3 parser.py <logfile>")
        sys.exit(1)

    entries = parse_file(sys.argv[1])
    print(f"Parsed {len(entries)} SSH events")
    for entry in entries[:10]:
        print(f"  [{entry.event_type}] {entry.ip_address} -> {entry.username}")

What does this code do?

parse_file() opens a log file, reads it line by line, and returns a list of all successfully parsed entries. Lines that do not match any pattern are silently skipped.

The if __name__ == "__main__" block lets you run parser.py directly from the terminal for testing. It prints how many events were parsed and shows the first 10 entries with their type, source IP, and target username.

  • Save parser.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved parser.py before moving on.

ⓧ I'd like to double check the full code

"""SSH auth.log parser module."""

import re
from dataclasses import dataclass
from datetime import datetime


@dataclass
class LogEntry:
    timestamp: str
    hostname: str
    pid: int
    event_type: str
    username: str
    ip_address: str
    port: int


# Regex patterns for SSH log events
FAILED_PASSWORD_RE = re.compile(
    r"^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+sshd\[(\d+)\]:\s+"
    r"Failed password for (?:invalid user )?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)\s+ssh2"
)

ACCEPTED_RE = re.compile(
    r"^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+sshd\[(\d+)\]:\s+"
    r"Accepted\s+(?:password|publickey)\s+for\s+(\S+)\s+from\s+(\S+)\s+port\s+(\d+)\s+ssh2"
)

INVALID_USER_RE = re.compile(
    r"^(\w+\s+\d+\s+\d+:\d+:\d+)\s+(\S+)\s+sshd\[(\d+)\]:\s+"
    r"Invalid user\s+(\S+)\s+from\s+(\S+)(?:\s+port\s+(\d+))?"
)


def parse_timestamp(timestamp_str: str) -> datetime:
    """Parse syslog timestamp string into datetime object."""
    current_year = datetime.now().year
    return datetime.strptime(f"{current_year} {timestamp_str}", "%Y %b %d %H:%M:%S")


def parse_line(line: str) -> LogEntry | None:
    """Parse a single log line and return a LogEntry or None."""
    match = FAILED_PASSWORD_RE.match(line)
    if match:
        return LogEntry(
            timestamp=match.group(1),
            hostname=match.group(2),
            pid=int(match.group(3)),
            event_type="FAILED_PASSWORD",
            username=match.group(4),
            ip_address=match.group(5),
            port=int(match.group(6)),
        )

    match = ACCEPTED_RE.match(line)
    if match:
        return LogEntry(
            timestamp=match.group(1),
            hostname=match.group(2),
            pid=int(match.group(3)),
            event_type="ACCEPTED_LOGIN",
            username=match.group(4),
            ip_address=match.group(5),
            port=int(match.group(6)),
        )

    match = INVALID_USER_RE.match(line)
    if match:
        port = int(match.group(6)) if match.group(6) else 0
        return LogEntry(
            timestamp=match.group(1),
            hostname=match.group(2),
            pid=int(match.group(3)),
            event_type="INVALID_USER",
            username=match.group(4),
            ip_address=match.group(5),
            port=port,
        )

    return None


def parse_file(filepath: str) -> list[LogEntry]:
    """Parse an entire log file and return list of LogEntry objects."""
    entries = []
    with open(filepath, "r") as f:
        for line in f:
            entry = parse_line(line.strip())
            if entry is not None:
                entries.append(entry)
    return entries


if __name__ == "__main__":
    import sys

    if len(sys.argv) < 2:
        print("Usage: python3 parser.py <logfile>")
        sys.exit(1)

    entries = parse_file(sys.argv[1])
    print(f"Parsed {len(entries)} SSH events")
    for entry in entries[:10]:
        print(f"  [{entry.event_type}] {entry.ip_address} -> {entry.username}")
Test the parser against the sample log

Your parser and sample data are both ready. Running the parser directly confirms it can extract structured events from every line pattern in the log file.

  • Open the integrated terminal in VS Code by pressing Ctrl+`.
  • Run the parser against your sample log by running this command:
python3 parser.py sample_logs/auth.log

You should see output showing 38 parsed SSH events, followed by the first 10 entries with their event type, source IP, and target username:

Parsed 38 SSH events
  [FAILED_PASSWORD] 192.168.1.50 -> oracle
  [FAILED_PASSWORD] 192.168.1.50 -> admin
  [FAILED_PASSWORD] 192.168.1.50 -> test
  [FAILED_PASSWORD] 192.168.1.50 -> guest
  [FAILED_PASSWORD] 192.168.1.50 -> root
  [FAILED_PASSWORD] 192.168.1.50 -> root
  [FAILED_PASSWORD] 192.168.1.50 -> root
  [ACCEPTED_LOGIN] 192.168.1.50 -> root
  [FAILED_PASSWORD] 10.0.0.88 -> root
  [FAILED_PASSWORD] 10.0.0.88 -> root

Seeing a "No module named" or syntax error?

  • Make sure you saved both parser.py and sample_logs/auth.log before running the command.
  • Check that your terminal is in the ssh-log-analyzer directory. Run pwd to confirm.
  • If you see 0 events parsed, open sample_logs/auth.log and confirm it contains log lines (not an empty file).

Why isn't my parser finding any SSH events in the log file?

Your parser is working and can extract structured security events from raw logs. Next up, you will build the detection engine that identifies brute-force patterns hiding in those events.

Build the Brute-Force Detection Engine

Your parser can now read raw log lines and turn them into structured data. The next question is: how do you tell the difference between a legitimate user who mistyped their password once and an attacker hammering the server with thousands of attempts?

In this step, you will build a detection engine that uses a sliding window algorithm to spot brute-force attacks. This is the same core approach used by tools like fail2ban and commercial intrusion detection systems.

In this step, get ready to:
  • Define detection thresholds and severity levels in a configuration file.
  • Implement a sliding window algorithm that detects brute-force attacks by IP address.
  • Add username enumeration detection for IPs trying many different usernames.
Define the detection configuration

Before writing detection logic, you need to define the rules that determine what counts as an attack. These thresholds live in a separate configuration file so you can tune them without touching the detection code.

  • Open config.py in VS Code and add the following configuration:
"""Configuration for SSH Log Analyzer."""

# Detection thresholds
BRUTE_FORCE_THRESHOLD = 5
TIME_WINDOW_SECONDS = 300

# Username enumeration threshold
USER_ENUM_THRESHOLD = 3

# Severity levels based on failure count within time window
SEVERITY_LEVELS = {
    "MEDIUM": 5,
    "HIGH": 15,
    "CRITICAL": 20,
}

# ANSI color codes for terminal output
COLORS = {
    "RED": "\033[91m",
    "YELLOW": "\033[93m",
    "CYAN": "\033[96m",
    "GREEN": "\033[92m",
    "BOLD": "\033[1m",
    "RESET": "\033[0m",
}

What does this configuration do?

BRUTE_FORCE_THRESHOLD = 5 means an IP needs at least 5 failed login attempts to trigger an alert. Fewer than that could be a legitimate user mistyping their password.

TIME_WINDOW_SECONDS = 300 sets a 5-minute sliding window. Five failures spread across a week is normal. Five failures in 5 minutes is suspicious.

SEVERITY_LEVELS escalates the alert based on volume: 5+ attempts is MEDIUM, 15+ is HIGH, and 20+ is CRITICAL. The COLORS dictionary stores ANSI escape codes for coloring terminal output later.

  • Save config.py.

Why a sliding window instead of a simple count?

A simple total count would flag a sysadmin who fat-fingers their password a few times over several months. The sliding window only counts failures that happen close together in time. If 5 failures occur within any 300-second window, that pattern looks automated.

This is the same approach that fail2ban and commercial IDS tools use in production.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

"""Configuration for SSH Log Analyzer."""

# Detection thresholds
BRUTE_FORCE_THRESHOLD = 5
TIME_WINDOW_SECONDS = 300

# Username enumeration threshold
USER_ENUM_THRESHOLD = 3

# Severity levels based on failure count within time window
SEVERITY_LEVELS = {
    "MEDIUM": 5,
    "HIGH": 15,
    "CRITICAL": 20,
}

# ANSI color codes for terminal output
COLORS = {
    "RED": "\033[91m",
    "YELLOW": "\033[93m",
    "CYAN": "\033[96m",
    "GREEN": "\033[92m",
    "BOLD": "\033[1m",
    "RESET": "\033[0m",
}
Implement the sliding window detector

Now you will build the detection engine itself. The detector groups failed login events by IP address, then slides a time window across each IP's attempts to find the peak concentration of failures.

  • Open detector.py and add the imports, the Alert dataclass, and the severity classification function:
"""Brute-force and threat detection engine."""

from collections import defaultdict
from dataclasses import dataclass, field

from config import (
    BRUTE_FORCE_THRESHOLD,
    SEVERITY_LEVELS,
    TIME_WINDOW_SECONDS,
    USER_ENUM_THRESHOLD,
)
from parser import LogEntry, parse_timestamp


@dataclass
class Alert:
    ip_address: str
    alert_type: str
    severity: str
    count: int
    first_seen: str
    last_seen: str
    targeted_users: list[str] = field(default_factory=list)


def classify_severity(count: int) -> str:
    """Classify threat severity based on failure count."""
    if count >= SEVERITY_LEVELS["CRITICAL"]:
        return "CRITICAL"
    elif count >= SEVERITY_LEVELS["HIGH"]:
        return "HIGH"
    elif count >= SEVERITY_LEVELS["MEDIUM"]:
        return "MEDIUM"
    return "LOW"

What does this code do?

The Alert dataclass is the structured output of your detection engine. Each alert captures the attacking IP, the type of attack, its severity, how many events triggered it, the time range, and which usernames were targeted.

The classify_severity() function maps a raw count of failures to a human-readable severity label. It checks from highest to lowest so that 20+ failures always returns CRITICAL, not MEDIUM.

  • Below the classify_severity() function, add the core brute-force detection logic:
def detect_brute_force(
    entries: list[LogEntry],
    threshold: int = BRUTE_FORCE_THRESHOLD,
    window: int = TIME_WINDOW_SECONDS,
) -> list[Alert]:
    """Detect brute-force attacks using a sliding window algorithm."""
    failed_by_ip = defaultdict(list)

    for entry in entries:
        if entry.event_type == "FAILED_PASSWORD":
            failed_by_ip[entry.ip_address].append(entry)

    alerts = []

    for ip, attempts in failed_by_ip.items():
        attempts.sort(key=lambda e: parse_timestamp(e.timestamp))

        timestamps = [parse_timestamp(a.timestamp) for a in attempts]
        max_in_window = 0

        for i in range(len(timestamps)):
            count = 0
            for j in range(i, len(timestamps)):
                diff = (timestamps[j] - timestamps[i]).total_seconds()
                if diff <= window:
                    count += 1
                else:
                    break
            max_in_window = max(max_in_window, count)

        if max_in_window >= threshold:
            targeted = list(set(a.username for a in attempts))
            alerts.append(
                Alert(
                    ip_address=ip,
                    alert_type="BRUTE_FORCE",
                    severity=classify_severity(max_in_window),
                    count=max_in_window,
                    first_seen=attempts[0].timestamp,
                    last_seen=attempts[-1].timestamp,
                    targeted_users=targeted,
                )
            )

    return alerts

How does the sliding window work?

The algorithm groups all failed login entries by IP address first. For each IP, it sorts the attempts chronologically and then checks every possible starting point.

Starting at each attempt i, it counts how many subsequent attempts fall within the time window (300 seconds). Once an attempt falls outside the window, it stops counting (the break statement). It tracks the maximum count seen across all starting points.

If the peak count meets the threshold, the IP gets flagged with a severity based on how many failures concentrated in that window.

  • Save detector.py.

You will not see a visible change yet. The next substep adds the remaining detection functions and a test run.

Seeing a syntax error on save?

Make sure the detect_brute_force function is at the same indentation level as classify_severity (top-level, no indent). Also confirm that the imports from config and parser are at the top of the file.

Help me debug a syntax error in detector.py

Add username enumeration detection

Brute-force is not the only attack pattern. Attackers also perform username enumeration, trying many different usernames to discover which accounts exist on the system. Your detector can catch this by counting distinct invalid usernames from a single IP.

  • Below the detect_brute_force() function in detector.py, add the username enumeration detector and the combined detection runner:
def detect_user_enumeration(entries: list[LogEntry]) -> list[Alert]:
    """Detect username enumeration attempts."""
    invalid_by_ip = defaultdict(set)

    for entry in entries:
        if entry.event_type == "INVALID_USER":
            invalid_by_ip[entry.ip_address].add(entry.username)

    alerts = []

    for ip, usernames in invalid_by_ip.items():
        if len(usernames) >= USER_ENUM_THRESHOLD:
            ip_entries = [
                e
                for e in entries
                if e.ip_address == ip and e.event_type == "INVALID_USER"
            ]
            ip_entries.sort(key=lambda e: parse_timestamp(e.timestamp))
            alerts.append(
                Alert(
                    ip_address=ip,
                    alert_type="USER_ENUMERATION",
                    severity="MEDIUM",
                    count=len(usernames),
                    first_seen=ip_entries[0].timestamp,
                    last_seen=ip_entries[-1].timestamp,
                    targeted_users=list(usernames),
                )
            )

    return alerts


def run_all_detections(
    entries: list[LogEntry],
    threshold: int = BRUTE_FORCE_THRESHOLD,
    window: int = TIME_WINDOW_SECONDS,
) -> list[Alert]:
    """Run all detection modules and return combined alerts."""
    alerts = []
    alerts.extend(detect_brute_force(entries, threshold, window))
    alerts.extend(detect_user_enumeration(entries))
    return alerts

What does this code do?

detect_user_enumeration() collects the distinct usernames each IP tried. If an IP attempted 3 or more unique invalid usernames (the USER_ENUM_THRESHOLD from your config), it flags that IP. It uses a set so duplicate username attempts from the same IP only count once.

run_all_detections() is the single entry point that runs every detector and combines the results. This makes it easy to add new detection types later without changing the calling code.

  • Save detector.py.
  • Test the detection engine by running this command in your terminal:
python3 -c "from parser import parse_file; from detector import run_all_detections; entries = parse_file('sample_logs/auth.log'); alerts = run_all_detections(entries); print(f'{len(alerts)} alerts generated'); [print(f'  [{a.severity}] {a.ip_address} - {a.alert_type} ({a.count})') for a in alerts]"

You should see 3 alerts generated: a CRITICAL brute-force alert for 10.0.0.88 (21 attempts), a HIGH brute-force alert for 192.168.1.50 (7 attempts), and a MEDIUM user enumeration alert for 45.155.205.100 (5 usernames).

Seeing a ModuleNotFoundError?

Make sure you are running the command from inside your ssh-log-analyzer folder where all your Python files live. The imports expect config.py, parser.py, and detector.py to be in the same directory.

Help me fix a ModuleNotFoundError in my detection engine

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

"""Configuration for SSH Log Analyzer."""

# Detection thresholds
BRUTE_FORCE_THRESHOLD = 5
TIME_WINDOW_SECONDS = 300

# Username enumeration threshold
USER_ENUM_THRESHOLD = 3

# Severity levels based on failure count within time window
SEVERITY_LEVELS = {
    "MEDIUM": 5,
    "HIGH": 15,
    "CRITICAL": 20,
}

# ANSI color codes for terminal output
COLORS = {
    "RED": "\033[91m",
    "YELLOW": "\033[93m",
    "CYAN": "\033[96m",
    "GREEN": "\033[92m",
    "BOLD": "\033[1m",
    "RESET": "\033[0m",
}
"""Brute-force and threat detection engine."""

from collections import defaultdict
from dataclasses import dataclass, field

from config import (
    BRUTE_FORCE_THRESHOLD,
    SEVERITY_LEVELS,
    TIME_WINDOW_SECONDS,
    USER_ENUM_THRESHOLD,
)
from parser import LogEntry, parse_timestamp


@dataclass
class Alert:
    ip_address: str
    alert_type: str
    severity: str
    count: int
    first_seen: str
    last_seen: str
    targeted_users: list[str] = field(default_factory=list)


def classify_severity(count: int) -> str:
    """Classify threat severity based on failure count."""
    if count >= SEVERITY_LEVELS["CRITICAL"]:
        return "CRITICAL"
    elif count >= SEVERITY_LEVELS["HIGH"]:
        return "HIGH"
    elif count >= SEVERITY_LEVELS["MEDIUM"]:
        return "MEDIUM"
    return "LOW"


def detect_brute_force(
    entries: list[LogEntry],
    threshold: int = BRUTE_FORCE_THRESHOLD,
    window: int = TIME_WINDOW_SECONDS,
) -> list[Alert]:
    """Detect brute-force attacks using a sliding window algorithm."""
    failed_by_ip = defaultdict(list)

    for entry in entries:
        if entry.event_type == "FAILED_PASSWORD":
            failed_by_ip[entry.ip_address].append(entry)

    alerts = []

    for ip, attempts in failed_by_ip.items():
        attempts.sort(key=lambda e: parse_timestamp(e.timestamp))

        timestamps = [parse_timestamp(a.timestamp) for a in attempts]
        max_in_window = 0

        for i in range(len(timestamps)):
            count = 0
            for j in range(i, len(timestamps)):
                diff = (timestamps[j] - timestamps[i]).total_seconds()
                if diff <= window:
                    count += 1
                else:
                    break
            max_in_window = max(max_in_window, count)

        if max_in_window >= threshold:
            targeted = list(set(a.username for a in attempts))
            alerts.append(
                Alert(
                    ip_address=ip,
                    alert_type="BRUTE_FORCE",
                    severity=classify_severity(max_in_window),
                    count=max_in_window,
                    first_seen=attempts[0].timestamp,
                    last_seen=attempts[-1].timestamp,
                    targeted_users=targeted,
                )
            )

    return alerts


def detect_user_enumeration(entries: list[LogEntry]) -> list[Alert]:
    """Detect username enumeration attempts."""
    invalid_by_ip = defaultdict(set)

    for entry in entries:
        if entry.event_type == "INVALID_USER":
            invalid_by_ip[entry.ip_address].add(entry.username)

    alerts = []

    for ip, usernames in invalid_by_ip.items():
        if len(usernames) >= USER_ENUM_THRESHOLD:
            ip_entries = [
                e
                for e in entries
                if e.ip_address == ip and e.event_type == "INVALID_USER"
            ]
            ip_entries.sort(key=lambda e: parse_timestamp(e.timestamp))
            alerts.append(
                Alert(
                    ip_address=ip,
                    alert_type="USER_ENUMERATION",
                    severity="MEDIUM",
                    count=len(usernames),
                    first_seen=ip_entries[0].timestamp,
                    last_seen=ip_entries[-1].timestamp,
                    targeted_users=list(usernames),
                )
            )

    return alerts


def run_all_detections(
    entries: list[LogEntry],
    threshold: int = BRUTE_FORCE_THRESHOLD,
    window: int = TIME_WINDOW_SECONDS,
) -> list[Alert]:
    """Run all detection modules and return combined alerts."""
    alerts = []
    alerts.extend(detect_brute_force(entries, threshold, window))
    alerts.extend(detect_user_enumeration(entries))
    return alerts

Your detection engine is working. It spotted a CRITICAL-level automated attack, a smaller brute-force attempt, and a username enumeration scan. Next up, you will build the CLI interface and JSON report so this tool can be run from the command line and produce structured output.

Build the CLI and JSON Report

Your parser reads log files and your detector identifies threats. The missing piece is a way for you (or any analyst) to actually use these modules from the command line and get structured output.

In this step, you will build the main entry point for your tool. It accepts command-line arguments, runs the full detection pipeline, and presents results as both a colored terminal summary and a machine-readable JSON report.

In this step, get ready to:
  • Create a CLI interface with argparse that accepts log file path and configuration overrides.
  • Generate a structured JSON report with summary statistics, alerts, and top attackers.
  • Add colored terminal output that highlights threats by severity.
Create the CLI interface in analyzer.py

Python's argparse module lets you define command-line flags that users pass when running your script. This is how real security tools accept input without hardcoding file paths.

  • Open analyzer.py in VS Code. The file is currently empty.
  • Add the following imports at the top of the file:
"""SSH Log Analyzer - Main CLI entry point."""

import argparse
import json
import sys
from collections import Counter

from config import BRUTE_FORCE_THRESHOLD, COLORS, TIME_WINDOW_SECONDS
from detector import run_all_detections
from parser import parse_file

What do these imports do?

The first group (argparse, json, sys, Counter) are all Python standard library modules. No external packages needed.

The second group pulls in the constants and functions you built in previous steps. parse_file reads the log, and run_all_detections runs your brute-force and enumeration detectors.

Now you need the main() function that defines the CLI arguments and wires everything together.

  • Add the following code at the bottom of analyzer.py, below the imports:
def main():
    """Main entry point."""
    parser = argparse.ArgumentParser(
        description="SSH Log Analyzer - Detect brute-force attacks in auth.log files"
    )
    parser.add_argument("--log", "-l", required=True, help="Path to auth.log file")
    parser.add_argument("--output", "-o", help="Path to save JSON report")
    parser.add_argument(
        "--threshold",
        "-t",
        type=int,
        default=BRUTE_FORCE_THRESHOLD,
        help=f"Failed attempts to trigger alert (default: {BRUTE_FORCE_THRESHOLD})",
    )
    parser.add_argument(
        "--window",
        "-w",
        type=int,
        default=TIME_WINDOW_SECONDS,
        help=f"Time window in seconds (default: {TIME_WINDOW_SECONDS})",
    )

    args = parser.parse_args()

    entries = parse_file(args.log)
    if not entries:
        print("No SSH events found in log file.")
        sys.exit(1)

    alerts = run_all_detections(entries, args.threshold, args.window)

    print_summary(entries, alerts)

    report = generate_report(entries, alerts)

    if args.output:
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2)
        print(f"  Report saved to: {args.output}\n")
    else:
        print("  Tip: Use --output report.json to save the full report\n")


if __name__ == "__main__":
    main()

What does this code do?

  • ArgumentParser defines four flags: --log (required), --output (optional JSON path), --threshold (override detection threshold), and --window (override time window).
  • parse_file(args.log) reads the log file using your parser module and returns a list of LogEntry objects.
  • run_all_detections() runs both your brute-force and username enumeration detectors on the parsed entries.
  • The output logic either saves a JSON file or prints a tip telling the user how to save one.

You won't run the tool yet because print_summary() and generate_report() don't exist yet. You will add those next.

Generate structured JSON output

Security tools need machine-readable output so that other systems (SIEMs, ticketing platforms, dashboards) can ingest the findings. Your report will include a summary, the full alert list, and a ranked list of top attacking IPs.

  • In analyzer.py, add the generate_report() function above the main() function:
def generate_report(entries, alerts) -> dict:
    """Generate structured JSON report."""
    failed_ips = Counter(
        e.ip_address for e in entries if e.event_type == "FAILED_PASSWORD"
    )

    report = {
        "summary": {
            "total_events": len(entries),
            "failed_attempts": sum(
                1 for e in entries if e.event_type == "FAILED_PASSWORD"
            ),
            "successful_logins": sum(
                1 for e in entries if e.event_type == "ACCEPTED_LOGIN"
            ),
            "unique_ips": len(set(e.ip_address for e in entries)),
            "total_alerts": len(alerts),
            "alerts_by_severity": {
                "CRITICAL": sum(1 for a in alerts if a.severity == "CRITICAL"),
                "HIGH": sum(1 for a in alerts if a.severity == "HIGH"),
                "MEDIUM": sum(1 for a in alerts if a.severity == "MEDIUM"),
                "LOW": sum(1 for a in alerts if a.severity == "LOW"),
            },
        },
        "alerts": [
            {
                "ip_address": a.ip_address,
                "alert_type": a.alert_type,
                "severity": a.severity,
                "count": a.count,
                "first_seen": a.first_seen,
                "last_seen": a.last_seen,
                "targeted_users": a.targeted_users,
            }
            for a in sorted(alerts, key=lambda a: a.count, reverse=True)
        ],
        "top_attackers": [
            {"ip": ip, "total_attempts": count}
            for ip, count in failed_ips.most_common(10)
        ],
    }

    return report

What does this code do?

  • Counter tallies how many failed attempts came from each IP address, giving you the "top attackers" ranking.
  • The summary section gives high-level stats: total events, failure counts, unique IPs, and a breakdown of alerts by severity level.
  • The alerts list includes every alert sorted by count (most events first), so the worst offenders appear at the top.
  • The top_attackers list uses most_common(10) to show the 10 IPs with the highest total failed attempts.

You won't see a change yet. The next chunk adds the colored terminal output that completes the tool.

Add colored terminal output

Raw text output is hard to scan when you are triaging dozens of alerts. ANSI color codes let you highlight severity levels so that CRITICAL alerts jump out immediately in red.

  • In analyzer.py, add the following two functions between the imports and generate_report():
def color(text: str, color_name: str) -> str:
    """Wrap text in ANSI color codes."""
    return f"{COLORS[color_name]}{text}{COLORS['RESET']}"


def print_summary(entries, alerts):
    """Print colored terminal summary."""
    print(f"\n{color('=' * 50, 'BOLD')}")
    print(f"{color('  SSH Log Analyzer - Security Report', 'BOLD')}")
    print(f"{color('=' * 50, 'BOLD')}\n")

    total_failed = sum(1 for e in entries if e.event_type == "FAILED_PASSWORD")
    total_accepted = sum(1 for e in entries if e.event_type == "ACCEPTED_LOGIN")
    unique_ips = len(set(e.ip_address for e in entries))

    print(f"  Events parsed: {len(entries)}")
    print(f"  Failed attempts: {color(str(total_failed), 'RED')}")
    print(f"  Successful logins: {color(str(total_accepted), 'GREEN')}")
    print(f"  Unique IPs: {unique_ips}")
    print(f"  Alerts generated: {len(alerts)}\n")

    if not alerts:
        print(f"  {color('No threats detected.', 'GREEN')}\n")
        return

    print(f"  {color('--- ALERTS ---', 'BOLD')}\n")

    severity_colors = {"CRITICAL": "RED", "HIGH": "YELLOW", "MEDIUM": "CYAN"}

    for alert in sorted(alerts, key=lambda a: a.count, reverse=True)[:10]:
        sev_color = severity_colors.get(alert.severity, "CYAN")
        severity_label = color(f"[{alert.severity}]", sev_color)
        print(
            f"  {severity_label} {alert.ip_address} - "
            f"{alert.alert_type} ({alert.count} events)"
        )
        print(f"         Users: {', '.join(alert.targeted_users[:5])}")
        print(f"         Window: {alert.first_seen} -> {alert.last_seen}\n")

What does this code do?

  • color() is a small helper that wraps any text in ANSI escape codes from your COLORS dictionary, then appends a RESET code so subsequent text returns to normal.
  • print_summary() prints a header banner, then overall stats (events, failures, logins, unique IPs, alert count).
  • The severity color mapping uses RED for CRITICAL, YELLOW for HIGH, and CYAN for MEDIUM so the most dangerous alerts are visually loudest.
  • Only the top 10 alerts (sorted by event count) are shown in the terminal to keep output manageable.
  • Save analyzer.py.
  • Run your tool against the sample log by running this command:
python3 analyzer.py --log sample_logs/auth.log

You should see a colored security report in your terminal with a bold header, event counts in red and green, and alert entries showing IP addresses with severity labels like [CRITICAL] and [MEDIUM].

  • Now generate a JSON report by running:
python3 analyzer.py --log sample_logs/auth.log --output report.json

You should see the same terminal summary, plus a line that says Report saved to: report.json.

Seeing a NameError or ImportError?

  • If you see NameError: name 'print_summary' is not defined, make sure color() and print_summary() appear above generate_report() in the file. Python reads top to bottom.
  • If you see ModuleNotFoundError: No module named 'config', make sure you are running the command from inside the ssh-log-analyzer folder where config.py lives.
  • Check the double-check tab below to compare your full file against the expected code.

Help me debug my analyzer.py file

What is argparse?

argparse is Python's built-in module for building command-line interfaces. It automatically generates help text (try python3 analyzer.py --help), validates argument types, and provides sensible error messages when users pass invalid flags.

Professional security tools always use CLI arguments instead of hardcoded paths. This makes your tool reusable across different log files and environments.

✔️ Awesome, I've got everything!

Great. Double check you have saved analyzer.py and confirmed both the terminal summary and the JSON report work.

ⓧ I'd like to double check the full code

Here is the complete analyzer.py file. Make sure your code matches this exactly:

"""SSH Log Analyzer - Main CLI entry point."""

import argparse
import json
import sys
from collections import Counter

from config import BRUTE_FORCE_THRESHOLD, COLORS, TIME_WINDOW_SECONDS
from detector import run_all_detections
from parser import parse_file


def color(text: str, color_name: str) -> str:
    """Wrap text in ANSI color codes."""
    return f"{COLORS[color_name]}{text}{COLORS['RESET']}"


def print_summary(entries, alerts):
    """Print colored terminal summary."""
    print(f"\n{color('=' * 50, 'BOLD')}")
    print(f"{color('  SSH Log Analyzer - Security Report', 'BOLD')}")
    print(f"{color('=' * 50, 'BOLD')}\n")

    total_failed = sum(1 for e in entries if e.event_type == "FAILED_PASSWORD")
    total_accepted = sum(1 for e in entries if e.event_type == "ACCEPTED_LOGIN")
    unique_ips = len(set(e.ip_address for e in entries))

    print(f"  Events parsed: {len(entries)}")
    print(f"  Failed attempts: {color(str(total_failed), 'RED')}")
    print(f"  Successful logins: {color(str(total_accepted), 'GREEN')}")
    print(f"  Unique IPs: {unique_ips}")
    print(f"  Alerts generated: {len(alerts)}\n")

    if not alerts:
        print(f"  {color('No threats detected.', 'GREEN')}\n")
        return

    print(f"  {color('--- ALERTS ---', 'BOLD')}\n")

    severity_colors = {"CRITICAL": "RED", "HIGH": "YELLOW", "MEDIUM": "CYAN"}

    for alert in sorted(alerts, key=lambda a: a.count, reverse=True)[:10]:
        sev_color = severity_colors.get(alert.severity, "CYAN")
        severity_label = color(f"[{alert.severity}]", sev_color)
        print(
            f"  {severity_label} {alert.ip_address} - "
            f"{alert.alert_type} ({alert.count} events)"
        )
        print(f"         Users: {', '.join(alert.targeted_users[:5])}")
        print(f"         Window: {alert.first_seen} -> {alert.last_seen}\n")


def generate_report(entries, alerts) -> dict:
    """Generate structured JSON report."""
    failed_ips = Counter(
        e.ip_address for e in entries if e.event_type == "FAILED_PASSWORD"
    )

    report = {
        "summary": {
            "total_events": len(entries),
            "failed_attempts": sum(
                1 for e in entries if e.event_type == "FAILED_PASSWORD"
            ),
            "successful_logins": sum(
                1 for e in entries if e.event_type == "ACCEPTED_LOGIN"
            ),
            "unique_ips": len(set(e.ip_address for e in entries)),
            "total_alerts": len(alerts),
            "alerts_by_severity": {
                "CRITICAL": sum(1 for a in alerts if a.severity == "CRITICAL"),
                "HIGH": sum(1 for a in alerts if a.severity == "HIGH"),
                "MEDIUM": sum(1 for a in alerts if a.severity == "MEDIUM"),
                "LOW": sum(1 for a in alerts if a.severity == "LOW"),
            },
        },
        "alerts": [
            {
                "ip_address": a.ip_address,
                "alert_type": a.alert_type,
                "severity": a.severity,
                "count": a.count,
                "first_seen": a.first_seen,
                "last_seen": a.last_seen,
                "targeted_users": a.targeted_users,
            }
            for a in sorted(alerts, key=lambda a: a.count, reverse=True)
        ],
        "top_attackers": [
            {"ip": ip, "total_attempts": count}
            for ip, count in failed_ips.most_common(10)
        ],
    }

    return report


def main():
    """Main entry point."""
    parser = argparse.ArgumentParser(
        description="SSH Log Analyzer - Detect brute-force attacks in auth.log files"
    )
    parser.add_argument("--log", "-l", required=True, help="Path to auth.log file")
    parser.add_argument("--output", "-o", help="Path to save JSON report")
    parser.add_argument(
        "--threshold",
        "-t",
        type=int,
        default=BRUTE_FORCE_THRESHOLD,
        help=f"Failed attempts to trigger alert (default: {BRUTE_FORCE_THRESHOLD})",
    )
    parser.add_argument(
        "--window",
        "-w",
        type=int,
        default=TIME_WINDOW_SECONDS,
        help=f"Time window in seconds (default: {TIME_WINDOW_SECONDS})",
    )

    args = parser.parse_args()

    entries = parse_file(args.log)
    if not entries:
        print("No SSH events found in log file.")
        sys.exit(1)

    alerts = run_all_detections(entries, args.threshold, args.window)

    print_summary(entries, alerts)

    report = generate_report(entries, alerts)

    if args.output:
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2)
        print(f"  Report saved to: {args.output}\n")
    else:
        print("  Tip: Use --output report.json to save the full report\n")


if __name__ == "__main__":
    main()

Your SSH Log Analyzer is now a fully working command-line tool. Next up, you will push the entire project to GitHub with a professional README that showcases your work.

Push to GitHub with a Professional README

Your SSH log analyzer is fully functional. It parses logs, detects brute-force attacks, classifies severity, and outputs colored terminal reports.

A working tool is only half the story. Employers and collaborators judge your project by its README before they ever read your code. In this step, you will write a professional README and push everything to GitHub so your repository is ready to share.

In this step, get ready to:
  • Write a professional README.md with features, quick start, and project structure.
  • Commit all project files and push to GitHub.
  • Verify your repository renders correctly on GitHub.
Write a professional README.md

A good README answers three questions for anyone who lands on your repository: what does this do, how do I run it, and how is it organized.

  • Create a new file called README.md in the root of your ssh-log-analyzer folder by running:
touch README.md
  • Open README.md in VS Code and paste the following content:
# ssh-log-analyzer

A command-line security tool that analyzes SSH authentication logs to detect brute-force attacks, username enumeration, and potential compromises.

## Features
- Detects brute-force attacks using a sliding window algorithm
- Classifies threats by severity: MEDIUM, HIGH, CRITICAL
- Detects username enumeration attempts
- Generates structured JSON reports
- Colored terminal output

## Quick Start
```bash
git clone <your-repo-url>
cd ssh-log-analyzer
python3 analyzer.py --log sample_logs/auth.log
```

## Project Structure
```
ssh-log-analyzer/
├── analyzer.py
├── detector.py
├── parser.py
├── config.py
└── sample_logs/
    └── auth.log
```

What does each section do?

  • The title and description tell visitors what this tool is in one glance.
  • Features lists the detection capabilities so readers know the scope.
  • Quick Start gives anyone a copy-paste path to run the tool immediately after cloning.
  • Project Structure shows how the codebase is organized so reviewers can navigate it quickly.
  • Save README.md.

Replace the clone URL

Leave <your-repo-url> as-is for now. After you push to GitHub in the next substep, you can come back and replace it with your actual repository URL if you like.

✔️ Awesome, I've got everything!

Great. Make sure you have saved README.md before moving on.

ⓧ I'd like to double check the full code

# ssh-log-analyzer

A command-line security tool that analyzes SSH authentication logs to detect brute-force attacks, username enumeration, and potential compromises.

## Features
- Detects brute-force attacks using a sliding window algorithm
- Classifies threats by severity: MEDIUM, HIGH, CRITICAL
- Detects username enumeration attempts
- Generates structured JSON reports
- Colored terminal output

## Quick Start
```bash
git clone <your-repo-url>
cd ssh-log-analyzer
python3 analyzer.py --log sample_logs/auth.log
```

## Project Structure
```
ssh-log-analyzer/
├── analyzer.py
├── detector.py
├── parser.py
├── config.py
└── sample_logs/
    └── auth.log
```
Commit and push to GitHub

With the README in place, your project is complete. Time to stage everything, commit, and push to your remote repository.

  • Stage all changes by running:
git add .
  • Commit with a descriptive message by running:
git commit -m "Complete SSH log analyzer with detection engine and reports"
  • Push your commit to GitHub by running:
git push origin main

What do these commands do?

  • git add . stages every new and modified file in the current directory for the next commit.
  • git commit -m "..." saves a snapshot of the staged files with a message describing what changed.
  • git push origin main uploads your local commits to the main branch on your GitHub remote.
  • You should see output showing objects being uploaded and a confirmation that main was updated on the remote.

Push rejected or authentication failed?

  • If you see a permission error, make sure you are authenticated with GitHub. You may need to set up a personal access token or SSH key depending on how you configured your account.
  • If the push is rejected because the remote contains work you do not have locally, run git pull origin main --rebase first, then push again.

Help me troubleshoot my git push to GitHub

Verify on GitHub
  • Open your browser and navigate to your repository URL on GitHub.
  • Confirm you can see all project files listed: parser.py, detector.py, analyzer.py, config.py, sample_logs/auth.log, .gitignore, and README.md.
  • Scroll down below the file list. GitHub automatically renders README.md as formatted HTML. You should see your project title, features list, quick start code block, and project structure tree displayed cleanly.

README not rendering?

  • Make sure the file is named exactly README.md with uppercase letters. GitHub looks for this specific filename.
  • If you see raw markdown text instead of formatted output, check that the file extension is .md and not .txt.

Help me fix my README rendering on GitHub

Secret mission

Add Compromise Detection

Your tool detects brute-force attacks, but what about the ones that actually succeed? A truly dangerous scenario is when an attacker tries hundreds of passwords and eventually gets in. Add compromise detection that flags successful logins from IPs that previously had failed attempts.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine and uses a free GitHub repository. There are no ongoing costs or cloud resources to worry about.

Decide whether to keep your resources as a portfolio piece, or delete them entirely.

Resources you used:

  • GitHub repository (ssh-log-analyzer, free, no ongoing cost).
  • Local project files (~/Desktop/ssh-log-analyzer directory).

Keep everything running

No action needed. Choose this if you want to keep your project as a portfolio piece or continue experimenting with it.

  • Your GitHub repository is free and public. It stays live as long as your account exists.
  • Your local files take minimal disk space and can be used for future experiments.
  • Git remains installed on your system for use with other projects.

Pause - I'll come back to this later

There is nothing to pause for this project. Your GitHub repository is free and your local files consume negligible disk space.

Everything stays as-is until you decide to delete.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to delete everything.

  • Delete the local project folder by running this command in your terminal:
rm -rf ~/Desktop/ssh-log-analyzer
  • Confirm the folder is gone by running:
ls ~/Desktop/ssh-log-analyzer

You should see a message indicating the folder does not exist.

  • To delete your GitHub repository, open your ssh-log-analyzer repository page on GitHub.
  • Click Settings in the repository navigation bar.
  • Scroll down to the Danger Zone section at the bottom of the page.
  • Click Delete this repository.
  • Follow the confirmation prompts and type the repository name to confirm deletion.

What about Git?

Git is a general-purpose tool you will use for many future projects. Keeping it installed is recommended.

If you do want to remove it, run:

sudo apt remove git-all

Nice Work!

Nice Work!

You just built a production-grade cybersecurity tool from scratch and published it to GitHub for the world to see. This is exactly the kind of project that SOC analyst and sysadmin hiring managers look for on a candidate's profile.

You've learned how to:

  • Built a Python CLI tool that parses real Linux auth.log files using regex patterns to extract SSH authentication events.
  • Implemented a sliding window algorithm for brute-force detection and classified threats by severity levels from MEDIUM through CRITICAL.
  • Created your first GitHub repository with a professional README, learning Git fundamentals: init, add, commit, push, and remote.
  • Secret Mission: Added compromise detection that correlates failed and successful logins from the same IP to flag potential account takeovers.

Ready to quiz yourself?