I found a nice script at Calomel pf that watches web server logs and adds abusive hosts to a blacklist. I made a few minor adjustments to it.

Perl script

#!/usr/local/bin/perl -T

use strict;
use warnings;

## Calomel.org .:. https://calomel.org
##   name     : web_server_abuse_detection.pl
##   version  : 0.04

# description: this script will watch the web server logs (like Apache or Nginx) and
#  count the number of http error codes an ip has triggered. At a user defined amount
#  of errors we can execute a action to block the ip using our firewall software.

## which log file do you want to watch?
#my $log = "/var/log/h2o/louiskphoto.com.log /var/log/h2o/louiskowolowski.com.log /var/log/h2o/cryptomonkeys.com.log";
my $log = "/var/log/h2o/*.log";

## how many errors can an ip address trigger before we block them?
my $errors_block = 10;

## how many seconds before an unseen ip is considered old and removed from the hash?
my $expire_time = 7200;

## how many error log lines before we trigger blocking abusive ips and clean up
## of old ips in the hash? make sure this value is greater than $errors_block above.
my $cleanup_time = 10;

## do you want to debug the scripts output ? on=1 and off=0
my $debug_mode = 0;

## clear the environment and set our path
$ENV{ENV} ="";
$ENV{PATH} = "/bin:/usr/bin:/usr/local/bin";

## declare some internal variables and the hash of abusive ip addresses
my ( $ip, $errors, $time, $newtime, $newerrors );
my $trigger_count=1;
my %abusive_ips = ();

## open the log file. we are using the system binary tail which is smart enough
## to follow rotating logs. We could have used File::Tail, but tail is easier.
open(LOG,"/usr/bin/tail -F $log |") || die "ERROR: could not open log file.\n";

while(<LOG>) {
	## process the log line if it contains one of these error codes 
	if ($_ =~ m/( 301 | 302 | 303 | 307 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 444 | 494 | 495 | 496 | 497 | 500 | 501 | 502 | 503 | 504 | 507 )/) {

		## Whitelisted ips. This is where you can whitelist ips that cause errors,
		## but you do NOT want them to be blocked. Googlebot at 66.249/16 is a good
		## example. We also whitelisted the private subnet 192.168/16 so web
		## developers inside the firewall can test and never be blocked. 
		## 64.41.200.100 ssllabs.com
		## 64.41.200.104 ssllabs.com
		if ($_ !~ m/^(64\.41\.200\.|66\.249\.|192\.168\.)/) {

			## extract the ip address from the log line and get the current unix time
			$time = time();
			$ip = (split ' ')[0];

			## if an ip address has never been seen before we need
			## to initialize the errors value to avoid warning messages.
			$abusive_ips{ $ip }{ 'errors' } = 0 if not defined $abusive_ips{ $ip }{ 'errors' };

			## increment the error counter and update the time stamp.
			$abusive_ips{ $ip }{ 'errors' } = $abusive_ips{ $ip }->{ 'errors' } + 1;
			$abusive_ips{ $ip }{ 'time' } = $time;

			## DEBUG: show detailed output
			if ( $debug_mode == 1 ) {
				$newerrors  = $abusive_ips{ $ip }->{ 'errors' };
				$newtime = $abusive_ips{ $ip }->{ 'time' };
				print "unix_time:  $newtime, errors:  $newerrors, ip:  $ip, cleanup_time: $trigger_count\n";
			}

			## if an ip has triggered the $errors_block value we block them
			if ($abusive_ips{ $ip }->{ 'errors' } >= $errors_block ) {

				## DEBUG: show detailed output
				if ( $debug_mode == 1 ) {
					print "ABUSIVE IP! unix_time:  $newtime, errors:  $newerrors, ip:  $ip, cleanup_time: $trigger_count\n";
				}

				## Untaint the ip variable for use by the following external system() calls
				my $ip_ext = "$1" if ($ip =~ m/^([0-9\.]+)$/ or die "\nError: Illegal characters in ip\n\n" );

				## USER EDIT: this is the system call you will set to block the abuser. You can add the command
				##  line you want to execute on the ip address of the abuser. For example, we are using logger to
				##  echo the line out to /var/log/messages and then we are adding the offending ip address to our
				##  FreeBSD Pf table which we have setup to block ips at Pf firewall.
				system("/usr/bin/logger", "$ip_ext", "is", "abusive,", "sent", "to", "BLOCKTEMP");
				system("/sbin/pfctl", "-t", "BLOCKTEMP", "-T", "add", "$ip_ext");

				## after the ip is blocked it does need to be in the hash anymore
				delete($abusive_ips{ $ip });
			}

			## increment the trigger counter which is used for the following clean up function. 
			$trigger_count++;

			## clean up function: when the trigger counter reaches the $cleanup_time we
			## remove any old hash entries from the $abusive_ips hash
			if ($trigger_count >= $cleanup_time) {
				my $time_current =  time();

				## DEBUG: show detailed output
				if ( $debug_mode == 1 ) {
					print "  Clean up... expire: $expire_time, pre-size of hash:  " . keys( %abusive_ips ) . ".\n";
				}

				## clean up ip addresses we have not seen in a long time
				while (($ip, $time) = each(%abusive_ips)) {

					## DEBUG: show detailed output
					if ( $debug_mode == 1 ) {
						my $total_time = $time_current - $abusive_ips{ $ip }->{ 'time' };
						print "    ip: $ip, seconds_last_seen: $total_time, errors:  $abusive_ips{ $ip }->{ 'errors' }\n";
					}

					if ( ($time_current - $abusive_ips{ $ip }->{ 'time' } ) >= $expire_time) {
						delete($abusive_ips{ $ip });
					}
				}

				## DEBUG: show detailed output
				if ( $debug_mode == 1 ) {
					print "  Clean up... expire: $expire_time, post-size of hash:  " . keys( %abusive_ips ) . ".\n";
				}

				## reset the trigger counter
				$trigger_count = 1;
			}
		}
	}
}
#### EOF ####

rc script

The rc script (/usr/local/etc/rc.d/) looks like this:

#!/bin/sh

# PROVIDE: webabuse
# BEFORE:  LOGIN
# KEYWORD:

. /etc/rc.subr

name=webabuse
rcvar=`set_rcvar`
command=/usr/local/bin/web_server_abuse_detection.pl
command_interpreter=/usr/local/bin/perl
webabuse_user=root
start_cmd="/usr/sbin/daemon -u $webabuse_user $command"

load_rc_config $name
run_rc_command "$1"

#### vi /usr/local/etc/rc.d/webabuse ####

Enable in rc.conf

You can enable it by adding this to /etc/rc.conf:

sudo sysrc webabuse_enable=YES

Starting the service

and starting it with:

service webabuse start

Sample output

Here is an example of a log message:

Dec 22 19:39:12 mx root: 10.167.17.1 is abusive, sent to BLOCKTEMP

You may wish to whitelist certain IPs or IP blocks. For example, ssllabs.com (who hosts the web tool for testing a server’s SSL properties) does all manner of poking and prodding and, like a good firewall, pf blocks that right away.

Web version

Based on the web_server_abuse_detection.pl script, I made an ssh_server_abuse_detection.pl script that looks like this:

#!/usr/local/bin/perl -T

use strict;
use warnings;

## Calomel.org .:. https://calomel.org
##   name     : web_server_abuse_detection.pl
##   version  : 0.04

# description: this script will watch the web server logs (like Apache or Nginx) and
#  count the number of http error codes an ip has triggered. At a user defined amount
#  of errors we can execute a action to block the ip using our firewall software.

## which log file do you want to watch?
  my $log = "/var/log/auth.log";

## how many errors can an ip address trigger before we block them?
  my $errors_block = 2;

## how many seconds before an unseen ip is considered old and removed from the hash?
  my $expire_time = 7200;

## how many error log lines before we trigger blocking abusive ips and clean up
## of old ips in the hash? make sure this value is greater than $errors_block above.
  my $cleanup_time = 10;

## which table do we want to add IPs to when they misbehave?
  my $table = "sshguard";

## do you want to debug the scripts output ? on=1 and off=0
  my $debug_mode = 0;

## clear the environment and set our path
  $ENV{ENV} ="";
  $ENV{PATH} = "/bin:/usr/bin:/usr/local/bin";

## declare some internal variables and the hash of abusive ip addresses
  my ( $ip, $errors, $time, $newtime, $newerrors );
  my $trigger_count=1;
  my %abusive_ips = ();

## open the log file. we are using the system binary tail which is smart enough
## to follow rotating logs. We could have used File::Tail, but tail is easier.
  open(LOG,"/usr/bin/tail -F $log |") || die "ERROR: could not open log file.\n";

  while(<LOG>) {
       ## process the log line if it contains one of these error codes 
	# Invalid user
       if ($_ =~ m/( Invalid\ user )/) {

         ## Whitelisted ips. This is where you can whitelist ips that cause errors,
         ## but you do NOT want them to be blocked. Googlebot at 66.249/16 is a good
         ## example. We also whitelisted the private subnet 192.168/16 so web
         ## developers inside the firewall can test and never be blocked. 
         if ($_ !~ m/^(66.220.108.250)/) {

         ## extract the ip address from the log line and get the current unix time
          $time = time();
          $ip = (split ' ')[9];

         ## if an ip address has never been seen before we need
         ## to initialize the errors value to avoid warning messages.
          $abusive_ips{ $ip }{ 'errors' } = 0 if not defined $abusive_ips{ $ip }{ 'errors' };

         ## increment the error counter and update the time stamp.
          $abusive_ips{ $ip }{ 'errors' } = $abusive_ips{ $ip }->{ 'errors' } + 1;
          $abusive_ips{ $ip }{ 'time' } = $time;

         ## DEBUG: show detailed output
         if ( $debug_mode == 1 ) {
           $newerrors  = $abusive_ips{ $ip }->{ 'errors' };
           $newtime = $abusive_ips{ $ip }->{ 'time' };
           print "unix_time:  $newtime, errors:  $newerrors, ip:  $ip, cleanup_time: $trigger_count\n";
         }

         ## if an ip has triggered the $errors_block value we block them
          if ($abusive_ips{ $ip }->{ 'errors' } >= $errors_block ) {

             ## DEBUG: show detailed output
             if ( $debug_mode == 1 ) {
               print "ABUSIVE IP! unix_time:  $newtime, errors:  $newerrors, ip:  $ip, cleanup_time: $trigger_count\n";
             }

             ## Untaint the ip variable for use by the following external system() calls
             my $ip_ext = "$1" if ($ip =~ m/^([0-9\.]+)$/ or die "\nError: Illegal characters in ip\n\n" );

             ## USER EDIT: this is the system call you will set to block the abuser. You can add the command
             ##  line you want to execute on the ip address of the abuser. For example, we are using logger to
             ##  echo the line out to /var/log/messages and then we are adding the offending ip address to our
             ##  FreeBSD Pf table which we have setup to block ips at Pf firewall.
             system("/usr/bin/logger", "$ip_ext", "is", "abusive,", "sent", "to", "$table");
             system("/sbin/pfctl", "-t", "$table", "-T", "add", "$ip_ext");

             ## after the ip is blocked it does need to be in the hash anymore
             delete($abusive_ips{ $ip });
          }

         ## increment the trigger counter which is used for the following clean up function. 
          $trigger_count++;

         ## clean up function: when the trigger counter reaches the $cleanup_time we
         ## remove any old hash entries from the $abusive_ips hash
          if ($trigger_count >= $cleanup_time) {
             my $time_current =  time();

             ## DEBUG: show detailed output
             if ( $debug_mode == 1 ) {
               print "  Clean up... expire: $expire_time, pre-size of hash:  " . keys( %abusive_ips ) . ".\n";
             }

              ## clean up ip addresses we have not seen in a long time
               while (($ip, $time) = each(%abusive_ips)){

               ## DEBUG: show detailed output
               if ( $debug_mode == 1 ) {
                 my $total_time = $time_current - $abusive_ips{ $ip }->{ 'time' };
                 print "    ip: $ip, seconds_last_seen: $total_time, errors:  $abusive_ips{ $ip }->{ 'errors' }\n";
               }

                  if ( ($time_current - $abusive_ips{ $ip }->{ 'time' } ) >= $expire_time) {
                       delete($abusive_ips{ $ip });
                  }
               }

            ## DEBUG: show detailed output
            if ( $debug_mode == 1 ) {
               print "  Clean up... expire: $expire_time, post-size of hash:  " . keys( %abusive_ips ) . ".\n";
             }

             ## reset the trigger counter
              $trigger_count = 1;
          }
         }
       }
  }
#### EOF ####

The rc script (/usr/local/etc/rc.d/) looks like this:

#!/bin/sh

# PROVIDE: sshabuse
# BEFORE:  LOGIN
# KEYWORD:

. /etc/rc.subr

name=sshabuse
rcvar=`set_rcvar`
command=/usr/local/bin/ssh_server_abuse_detection.pl
command_interpreter=/usr/local/bin/perl
sshabuse_user=root
start_cmd="/usr/sbin/daemon -u $sshabuse_user $command"

load_rc_config $name
run_rc_command "$1"

#### vi /usr/local/etc/rc.d/sshabuse ####

You can enable it by adding this to /etc/rc.conf:

sudo sysrc sshabuse_enable=YES

and starting it with:

sudo service sshabuse start

At some point, you may want to know which hosts are in your block list or block temp. Here is another script from calomel.org that will list abusive hosts. I’ve tweaked it to account for additional pf tables.

#!/usr/local/bin/bash
#
## Calomel.org  show_abusive_hosts.sh
## Purpose: Display ips and hostnames in the abusive hosts tables
#
echo "May prompt for sudo password"
total_blacklist=`sudo pfctl -t BLOCKPERM -T show | wc -l`
total_blacklist=`sudo pfctl -t BLOCKTEMP -T show | wc -l`
total_blacklist=`sudo pfctl -t BLACKLIST -T show | wc -l`
echo " "
echo -n "BLOCKPERM"; echo -n " ("; echo -n $total_blockperm; echo ")"
for i in $( sudo pfctl -t BLOCKPERM -T show ) ; do
	echo -n " "; echo -n $i; echo -n -e "\t" ; echo -n "  "; host $i | awk '{print $5}'
done
echo " "
echo -n "BLOCKTEMP"; echo -n " ("; echo -n $total_blocktemp; echo ")"
for i in $( sudo pfctl -t BLOCKTEMP -T show ) ; do
	echo -n " "; echo -n $i; echo -n -e "\t" ; echo -n "  "; host $i | awk '{print $5}'
done
echo " "
echo -n "BLACKLIST"; echo -n " ("; echo -n $total_blacklist; echo ")"
for i in $( sudo pfctl -t BLACKLIST -T show ) ; do
	echo -n " "; echo -n $i; echo -n -e "\t" ; echo -n "  "; host $i | awk '{print $5}'
done

Output looks like this (I terminated it because the list gets long):

[louisk@mail louisk 13 ]$ ./show_abusive_hosts.sh
May prompt for sudo password

BLOCKPERM ()

BLOCKTEMP ()

BLACKLIST (294)
 1.34.22.137	  1-34-22-137.HINET-IP.hinet.net.
 1.34.37.220	  1-34-37-220.HINET-IP.hinet.net.
 1.34.118.204	  1-34-118-204.HINET-IP.hinet.net.
 1.34.120.110	  1-34-120-110.HINET-IP.hinet.net.
 1.34.186.220	  1-34-186-220.HINET-IP.hinet.net.
 5.14.166.230	  5-14-166-230.residential.rdsnet.ro.
 5.40.247.175	  5.40.247.175.static.user.ono.com.
 5.140.218.7	  3(NXDOMAIN)
 14.55.82.153	  3(NXDOMAIN)
 14.162.1.22	  static.vnpt.vn.
 14.162.2.86	  static.vnpt.vn.
 14.162.224.99	  static.vnpt.vn.
 14.164.64.57	  static.vnpt.vn.
 14.169.41.21	  static.vnpt.vn.
 14.175.228.185	  static.vnpt.vn.
 14.181.87.119	  static.vnpt.vn.
 14.189.184.169	  static.vnpt.vn.
 23.31.62.17	  23-31-62-17-static.hfc.comcastbusiness.net.
^C 27.64.74.16	  localhost.
 27.105.106.193	  27-105-106-193-adsl-TPE.static.so-net.net.tw.
 27.152.7.38	  ^C
[louisk@mail louisk 14 ]$

If you’re looking for a script to modify your pf tables, this will do the trick. Its not terribly pretty, but it works.

#!/bin/sh
#
echo "May prompt for sudo password"

TABLE=$1
ACTION=$2
IP=$3
if [ -z ${TABLE} -o -z ${ACTION} -o -z ${IP} ] ; then
	echo "Syntax: $0 <table_name> <action> <ip>"
	echo "action could be: add delete
	exit 1
fi

#CMD_PFX='echo "Would Do: "'
echo "${ACTION} ${IP} to/from ${TABLE}"
${CMD_PFX} sudo /sbin/pfctl -t ${TABLE} -T ${ACTION} ${IP}

Lastly, you will want a cron entry to clean out your pf tables. I find that most abusive hosts are not permanent infrastructure, but hosts that get compromised and added to bot nets. Because of this, I don’t leave things in the BLOCKTEMP for very long. sshguard is different, I leave them in for a while longer. Here are the crontab entries:

#### Clear entries older than 3600 secs from the table "blacklist"
0	*	*	*	*	root	pfctl -q -t BLOCKTEMP -T expire 3600 > /dev/null
0	5	1	*	*	root	pfctl -q -t sshguard -T expire 2000000 > /dev/null

Footnotes and References