Shore Up
A night watchman at a desk reviewing a guest logbook with a magnifying glass, tallying repeated knocks at a locked door made by the same stranger.
LinuxSecurity

Detect and Report Failed SSH Login Attempts with a Log-Parsing Script

Ketan Aagja9 min read
No ratings yet

Before you run this

This guide builds a read-only bash script that parses your SSH log, counts failed password attempts, and prints a summary of the busiest source IP addresses and the usernames they tried. Its purpose is visibility — spotting brute-force patterns — not blocking. It does not change firewall rules, ban anyone, edit config, or delete anything. Running it and re-running it leaves your system exactly as it was.

Privileges: on a stock Debian/Ubuntu box the SSH log at /var/log/auth.log is readable only by root and members of the adm group, so you'll typically run the script with sudo (or as a user in adm). It needs no other special rights.

Test first: read the script before you run it, and try it on a test VM or a non-production host first. It is safe by design, but you should still confirm the log path and the log format match your system before you trust its numbers — a script that silently matches nothing looks the same as one that finds no attacks.

Assumptions. I'm writing for Debian 12 / Ubuntu 22.04+ with OpenSSH, where sshd logs to /var/log/auth.log. On RHEL/AlmaCentos-family systems the file is /var/log/secure instead, and the systemd unit is sshd.service rather than Debian's ssh.service — I'll point out where that matters. The parsing itself works the same on both because the OpenSSH log lines are identical.

What the log lines actually look like

Everything here depends on one fact: how OpenSSH writes a failed password. A rejected attempt looks like this:

Nov 10 12:34:56 host sshd[2451]: Failed password for root from 203.0.113.5 port 40522 ssh2
Nov 10 12:34:57 host sshd[2452]: Failed password for invalid user admin from 203.0.113.5 port 40544 ssh2

The two forms differ by one phrase: a real account gives Failed password for <user>, and a non-existent one gives Failed password for invalid user <user>. Both end with from <ip> port <n>. That's the whole contract we parse against. Confirm you actually have lines like these before going further:

sudo grep "Failed password" /var/log/auth.log | head

If that prints nothing, either there've been no failures (good) or your log lives somewhere else — check /var/log/secure, or query journald (below) instead.

The script

Save this as ssh-failed-report.sh:

#!/usr/bin/env bash
#
# ssh-failed-report.sh — summarise failed SSH logins from the auth log.
# Read-only: it changes nothing. Run with sudo to read the log.

set -euo pipefail

# Log path: /var/log/auth.log on Debian/Ubuntu, /var/log/secure on RHEL/Alma.
# Override by passing a file as the first argument.
LOGFILE="${1:-/var/log/auth.log}"
TOP="${2:-20}"          # how many rows to show in each table

if [[ ! -r "$LOGFILE" ]]; then
    echo "Cannot read $LOGFILE — try running with sudo, or pass the correct path." >&2
    exit 1
fi

total=$(grep -c "Failed password" "$LOGFILE" || true)

echo "SSH failed-login report"
echo "Source log : $LOGFILE"
echo "Generated  : $(date)"
echo "Total failed-password events: $total"
echo

echo "== Top $TOP source IPv4 addresses =="
grep "Failed password" "$LOGFILE" \
    | grep -oE 'from [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
    | awk '{print $2}' \
    | sort | uniq -c | sort -rn | head -n "$TOP"

echo
echo "== Top $TOP targeted usernames =="
# Strip the optional "invalid user " and pull out the account name.
grep "Failed password" "$LOGFILE" \
    | sed -E 's/.*Failed password for (invalid user )?([^ ]+) from.*/\2/' \
    | sort | uniq -c | sort -rn | head -n "$TOP"

Make it executable and run it:

chmod +x ssh-failed-report.sh
sudo ./ssh-failed-report.sh

You'll get a total, a table of the noisiest IPs, and a table of the usernames being guessed (root and admin dominate on any internet-facing box). To point it at a rotated or alternate log, pass the path: sudo ./ssh-failed-report.sh /var/log/secure.

One honest limitation. The IP table matches IPv4 only — that [0-9]+\.[0-9]+... pattern won't catch IPv6 sources. If your host takes SSH over IPv6, those attempts still show up in the total and the username table, just not in the IP table. Extending the regex to IPv6 is fiddly and easy to get subtly wrong, so I've left it out rather than ship something that looks complete but isn't. If you need it, treat IPv6 address matching as its own careful task.

Reading from journald instead of the file

If you rely on the systemd journal (or your distro has moved logs there), read the same events from journalctl and pipe them into the same parsing. On Debian/Ubuntu the unit is ssh:

# Debian/Ubuntu: unit is "ssh"; on RHEL/Alma use "sshd".
journalctl -u ssh --since today --no-pager \
    | grep "Failed password" \
    | grep -oE 'from [0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
    | awk '{print $2}' | sort | uniq -c | sort -rn

--since accepts values like today, "1 hour ago", or an absolute timestamp — see the journalctl man page for the exact accepted forms.

Running it on a schedule

To get a daily summary by email, wrap the script in a small cron job. This assumes a working local MTA (Postfix, or the mail command from mailutils / s-nail) already delivers mail on this host — cron won't set that up for you.

Create /etc/cron.d/ssh-failed-report (adjust the path and address — both are placeholders you replace):

# Daily at 07:00 — mail a failed-SSH summary to the admin.
# Replace the script path and the email address.
0 7 * * * root /usr/local/sbin/ssh-failed-report.sh | mail -s "SSH failed-login report: $(hostname)" admin@example.com

Copy the script to a root-owned location first so the cron job can find it:

sudo install -m 0750 ssh-failed-report.sh /usr/local/sbin/ssh-failed-report.sh

This reports; it does not block. If you want automatic blocking of repeat offenders, the standard, well-established tool is fail2ban, which watches the same log and inserts firewall bans. That's a separate install with its own config, and I'd reach for it rather than hand-rolling banning logic into a parser.

Verify it worked

Generate a known failure and confirm the script counts it. From another machine (or the same host), attempt a login as a user that will fail, and type a wrong password:

ssh nosuchuser@your-server.example.com

Then cross-check the script's total against a raw count of the log:

sudo grep -c "Failed password" /var/log/auth.log

The script's "Total failed-password events" line should match that number exactly, and your bogus username should now appear in the "targeted usernames" table. If the numbers match, the parser is seeing what the log holds.

For the cron job, confirm it's registered and check that cron actually ran it:

sudo run-parts --test /etc/cron.d 2>/dev/null   # lists the file is present
sudo grep CRON /var/log/syslog | tail            # shows cron executed the job

Undo

The script itself changes nothing, so there's nothing to reverse. If you scheduled it and want it gone, remove the two files you added:

sudo rm /etc/cron.d/ssh-failed-report
sudo rm /usr/local/sbin/ssh-failed-report.sh

That leaves your system exactly as it was before you started — no config touched, no service restarted, no rule added.

For the log line format and options behind all of this, the authoritative reference is the OpenSSH sshd_config manual and the journalctl man page on your own system (man sshd_config, man journalctl).

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.