DevOps / Security · Linux · Ruby 2.5+ · Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

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.

auth_log_scan.rb pipeline: auth.log parsed by regex into per-IP stats, checked against a threshold, producing a report and optional firewall command suggestions

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.log on Debian/Ubuntu, or /var/log/secure on RHEL/CentOS/Fedora
  • Read access to the log file (root or a user in the adm group 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 means scp-ing one file over and running it — no bundle 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

Terminal output of auth_log_scan.rb showing a security report with top offending IPs, a flagged brute-force suspect, and suggested ufw/iptables block commands

Troubleshooting

SymptomLikely cause & fix
ERROR: cannot read log fileRun with sudo, or add your user to the adm group (sudo usermod -aG adm $USER, then re-login).
Zero counts on a RHEL boxRHEL-family distros log to /var/log/secure, not /var/log/auth.log. Pass --file /var/log/secure.
Counts look wrong right after New Year’sThe 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 flaggedUsually 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 failuresSome 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.foreach for a loop polling for new lines, turning this into a real-time monitor.
  • Allowlisting: read a trusted_ips.txt file 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.

Leave a Reply

Your email address will not be published. Required fields are marked *

Click to access the login or register cheese