The Problem
If you run anything on a public IP, your SSH logs fill up with strangers trying root, admin, and every default password under the sun. Most of it is noise from automated botnets, but buried in there can be a real, sustained attack against one specific account — and if you’re not watching, you won’t know until something gets popped. fail2ban solves this at the OS level, but plenty of shops don’t have it installed everywhere, want a portable one-file version they can drop onto any box, or want the raw data in JSON to feed into their own alerting.
This tutorial builds auth_log_scan.rb: a dependency-free Ruby script that reads /var/log/auth.log (Debian/Ubuntu) or /var/log/secure (RHEL/CentOS), tallies failed and accepted logins per source IP, flags anything over a configurable threshold as a brute-force suspect, and can print ready-to-run ufw/iptables block commands.
Prerequisites
- Ruby 2.5+ (tested on 3.0.2; no gems, no
bundle install) - A Linux host with an SSH auth log —
/var/log/auth.logon Debian/Ubuntu, or/var/log/secureon RHEL/CentOS/Fedora - Read access to the log file (root or a user in the
admgroup on most distros) - Nothing else — the whole script uses only Ruby’s standard library (
optparse,json,time)
Why no gems? A script like this often needs to run on a freshly-provisioned box before configuration management has even gotten to it, or inside a minimal container. Sticking to the standard library meansscp-ing one file over and running it — nobundle install, no network access required, no version conflicts with whatever else is on the box.
The Complete Code
#!/usr/bin/env ruby
# auth_log_scan.rb
#
# Parses Linux SSH authentication logs (/var/log/auth.log on Debian/Ubuntu,
# /var/log/secure on RHEL/CentOS) to detect brute-force login attempts,
# summarize failed/successful logins by IP and user, and optionally emit
# firewall block commands for the worst offenders.
#
# No gems required -- uses only Ruby's standard library.
#
# Usage:
# ruby auth_log_scan.rb --file /var/log/auth.log
# ruby auth_log_scan.rb --file /var/log/auth.log --threshold 5 --json
# ruby auth_log_scan.rb --file /var/log/auth.log --suggest-blocks
#
# Ruby version tested: 3.0+ (no version-specific features used; should work on 2.5+)
require 'optparse'
require 'json'
require 'time'
# ---------------------------------------------------------------------------
# Data structure to hold everything we learn about one source IP address.
# ---------------------------------------------------------------------------
class IpStats
attr_accessor :failed_count, :accepted_count, :users_tried, :first_seen, :last_seen
def initialize
@failed_count = 0
@accepted_count = 0
@users_tried = Hash.new(0) # username => attempt count
@first_seen = nil
@last_seen = nil
end
# Record a timestamp, keeping track of the earliest/latest we've observed.
def touch(timestamp)
@first_seen = timestamp if @first_seen.nil? || timestamp < @first_seen
@last_seen = timestamp if @last_seen.nil? || timestamp > @last_seen
end
end
# ---------------------------------------------------------------------------
# The main parser/analyzer. Reads a log file line by line (so it can handle
# multi-gigabyte logs without loading them fully into memory) and builds up
# per-IP statistics.
# ---------------------------------------------------------------------------
class AuthLogScanner
# Matches lines like:
# Jul 15 02:14:31 web01 sshd[12345]: Failed password for invalid user admin from 203.0.113.7 port 51514 ssh2
# Jul 15 02:14:29 web01 sshd[12345]: Failed password for root from 203.0.113.7 port 51500 ssh2
FAILED_RE = /
Failedspasswordsfors
(?:invalidsusers)? # optional "invalid user" marker
(?<user>S+)sfroms
(?<ip>d{1,3}(?:.d{1,3}){3}|[0-9a-fA-F:]+)s
portsd+
/x
# Matches lines like:
# Jul 15 02:15:02 web01 sshd[12399]: Accepted publickey for deploy from 198.51.100.9 port 51522 ssh2
ACCEPTED_RE = /
Accepteds(?:password|publickey)sfors
(?<user>S+)sfroms
(?<ip>d{1,3}(?:.d{1,3}){3}|[0-9a-fA-F:]+)s
portsd+
/x
# Syslog timestamps ("Jul 15 02:14:31") don't include a year, so we assume
# the current year. Good enough for log-rotation-sized windows.
TIMESTAMP_RE = /A(?<ts>w{3}s+d{1,2}sd{2}:d{2}:d{2})/
attr_reader :stats, :total_lines, :total_failed, :total_accepted
def initialize(path)
@path = path
@stats = Hash.new { |h, k| h[k] = IpStats.new }
@total_lines = 0
@total_failed = 0
@total_accepted = 0
end
def scan
File.foreach(@path) do |line|
@total_lines += 1
timestamp = parse_timestamp(line)
if (m = FAILED_RE.match(line))
@total_failed += 1
record(m, timestamp, failed: true)
elsif (m = ACCEPTED_RE.match(line))
@total_accepted += 1
record(m, timestamp, failed: false)
end
end
self
end
# Returns IPs sorted by failed-attempt count, descending.
def worst_offenders(limit: 10)
@stats.sort_by { |_ip, s| -s.failed_count }.first(limit)
end
# IPs whose failed_count meets/exceeds the brute-force threshold.
def suspects(threshold:)
@stats.select { |_ip, s| s.failed_count >= threshold }
end
private
def record(match, timestamp, failed:)
ip = match[:ip]
entry = @stats[ip]
entry.touch(timestamp) if timestamp
if failed
entry.failed_count += 1
entry.users_tried[match[:user]] += 1
else
entry.accepted_count += 1
end
end
def parse_timestamp(line)
m = TIMESTAMP_RE.match(line)
return nil unless m
# Prepend the current year since syslog format omits it.
Time.parse("#{m[:ts]} #{Time.now.year}")
rescue ArgumentError
nil
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = {
file: '/var/log/auth.log',
threshold: 5,
limit: 10,
json: false,
suggest_blocks: false
}
OptionParser.new do |opts|
opts.banner = "Usage: ruby auth_log_scan.rb [options]"
opts.on('-f', '--file PATH', 'Path to auth log (default: /var/log/auth.log)') { |v| options[:file] = v }
opts.on('-t', '--threshold N', Integer, 'Failed-attempt threshold to flag as brute force (default: 5)') { |v| options[:threshold] = v }
opts.on('-l', '--limit N', Integer, 'How many top offenders to show (default: 10)') { |v| options[:limit] = v }
opts.on('--json', 'Emit machine-readable JSON instead of a text report') { options[:json] = true }
opts.on('--suggest-blocks', 'Print ufw/iptables commands for suspect IPs (does not execute them)') { options[:suggest_blocks] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit }
end.parse!
unless File.readable?(options[:file])
warn "ERROR: cannot read log file: #{options[:file]}"
exit 1
end
scanner = AuthLogScanner.new(options[:file]).scan
suspects = scanner.suspects(threshold: options[:threshold])
if options[:json]
payload = {
file: options[:file],
total_lines: scanner.total_lines,
total_failed: scanner.total_failed,
total_accepted: scanner.total_accepted,
threshold: options[:threshold],
suspects: suspects.map { |ip, s|
{
ip: ip,
failed_count: s.failed_count,
accepted_count: s.accepted_count,
users_tried: s.users_tried,
first_seen: s.first_seen&.iso8601,
last_seen: s.last_seen&.iso8601
}
}
}
puts JSON.pretty_generate(payload)
else
puts "=" * 70
puts "AUTH LOG SECURITY REPORT"
puts "=" * 70
puts "File: #{options[:file]}"
puts "Lines scanned: #{scanner.total_lines}"
puts "Failed logins: #{scanner.total_failed}"
puts "Accepted logins: #{scanner.total_accepted}"
puts "Brute-force threshold: #{options[:threshold]} failed attempts"
puts
puts "-- Top offending IPs " + "-" * 47
printf("%-20s %8s %8s %-30s\n", "IP ADDRESS", "FAILED", "OK", "USERNAMES TRIED")
scanner.worst_offenders(limit: options[:limit]).each do |ip, s|
users = s.users_tried.keys.first(4).join(', ')
users += ", ..." if s.users_tried.keys.size > 4
printf("%-20s %8d %8d %-30s\n", ip, s.failed_count, s.accepted_count, users)
end
puts
if suspects.empty?
puts "No IPs exceeded the brute-force threshold. All quiet."
else
puts "-- #{suspects.size} IP(s) flagged as brute-force suspects (>= #{options[:threshold]} failed attempts) " + "-" * 5
suspects.sort_by { |_ip, s| -s.failed_count }.each do |ip, s|
window = if s.first_seen && s.last_seen
"#{s.first_seen.strftime('%H:%M:%S')} - #{s.last_seen.strftime('%H:%M:%S')}"
else
"unknown window"
end
puts " #{ip} #{s.failed_count} failures (#{window})"
end
end
if options[:suggest_blocks] && !suspects.empty?
puts
puts "-- Suggested firewall commands (review before running!) " + "-" * 12
suspects.each_key do |ip|
puts " sudo ufw deny from #{ip} to any port 22"
puts " # or: sudo iptables -A INPUT -s #{ip} -p tcp --dport 22 -j DROP"
end
end
end
Step-by-Step Walkthrough
1. Two regexes carry the whole parser
FAILED_RE matches lines like Failed password for invalid user admin from 203.0.113.7 port 51514 ssh2, and ACCEPTED_RE matches successful logins. Both use named captures ((?<user>...), (?<ip>...)) so the matching code can pull out m[:user] and m[:ip] instead of tracking numbered groups. The extended regex flag lets the patterns span multiple lines with comments, keeping a gnarly-looking regex readable six months from now.
2. Streaming instead of slurping
File.foreach(@path) reads one line at a time rather than loading the whole file into memory. Auth logs on a busy internet-facing box can run into the hundreds of megabytes after a bad week of bot traffic; streaming keeps memory flat regardless of log size.
3. Per-IP aggregation with a default-valued Hash
@stats = Hash.new { |h, k| h[k] = IpStats.new } means touching @stats[ip] for a never-seen IP automatically creates a fresh IpStats object — no @stats[ip] ||= IpStats.new boilerplate scattered through the code. Each IpStats tracks failed and accepted counts, a per-username attempt tally, and the first/last time that IP was seen.
4. Timestamps without a year
Syslog’s default format (Jul 15 02:14:31) omits the year, so parse_timestamp appends Time.now.year before parsing. That covers the vast majority of real-world use — see Troubleshooting below for the edge case where it isn’t.
5. Threshold check and reporting
suspects(threshold:) filters @stats down to IPs whose failed_count meets or exceeds --threshold (default: 5). The CLI then either prints a human-readable report or, with --json, emits a structured payload you can pipe into jq, a SIEM, or a Slack webhook.
6. Suggested blocks, never automatic blocks
--suggest-blocks prints ufw/iptables commands for each suspect IP — it does not execute them. Auto-blocking based on a log heuristic is exactly the kind of thing that locks you out of your own box when a NAT gateway or VPN exit node shows up with a few failed logins. Keep a human in the loop.
Example Output
Troubleshooting
| Symptom | Likely cause & fix |
|---|---|
ERROR: cannot read log file | Run with sudo, or add your user to the adm group (sudo usermod -aG adm $USER, then re-login). |
| Zero counts on a RHEL box | RHEL-family distros log to /var/log/secure, not /var/log/auth.log. Pass --file /var/log/secure. |
| Counts look wrong right after New Year’s | The year-inference trick breaks if you scan a log spanning a year boundary after January 1st. Rotate logs more often, or extend parse_timestamp to detect a decreasing month sequence. |
| An internal IP or VPN gateway gets flagged | Usually a misconfigured client retrying with a stale key, not an attacker. Raise --threshold, or add an allowlist check. |
| Script finds nothing on a log with clear failures | Some log shippers reformat sshd lines. Run grep 'Failed password' /var/log/auth.log | head -3 and compare against FAILED_RE. |
Extending It
- Geo-flagging: shell out to a local GeoIP database to annotate suspect IPs with country of origin.
- Live tailing: swap
File.foreachfor a loop polling for new lines, turning this into a real-time monitor. - Allowlisting: read a
trusted_ips.txtfile and skip those IPs entirely. - fail2ban integration: write matching IPs to a file a custom jail can consume.
- Webhook alerts: POST the JSON payload to Slack/Discord/PagerDuty using
Net::HTTP.
Script tested against a synthetic sample log during the writing of this article (regex matching, threshold flagging, JSON output, and suggested block commands all verified); confirm the regexes against a real log sample from your distro before relying on it in production, since exact sshd log formats vary slightly across OpenSSH versions and syslog daemons.


