├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── Changes.md ├── FAQ.md ├── INSTALL.md ├── MANIFEST ├── Makefile.PL ├── README.md ├── check_sentry ├── sentry.pl └── t └── 01-syntax.t /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Sentry version 2 | 3 | ### Expected behavior 4 | 5 | ### Observed behavior 6 | 7 | ### Steps to reproduce 8 | 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Changes proposed in this pull request: 2 | - 3 | - 4 | - 5 | 6 | Fixes # 7 | 8 | Checklist: 9 | - [ ] docs updated 10 | - [ ] tests updated 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bak 2 | *~$ 3 | MANIFEST.bak 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: perl 2 | perl: 3 | - "5.22" 4 | - "5.12" 5 | - "5.10" 6 | -------------------------------------------------------------------------------- /Changes.md: -------------------------------------------------------------------------------- 1 | 2 | ### 1.06 2019-12-19 3 | 4 | - don't try using Net::IP when it's not installed, fixes #8 5 | - format Changes to markdown 6 | 7 | ### 1.05 2016-12-28 8 | 9 | - make new sshd error 'maximum authentication attempts exceeded for root' a naughy condition. 10 | - improve the --update function 11 | 12 | ### 1.04 2016-06-11 13 | 14 | - invalid users are naughty 15 | 16 | ### 1.03 2015-07-14 17 | 18 | - change shebang: /usr/bin/env perl -> `which perl` 19 | 20 | ### 1.02 2015-07-02 21 | 22 | - changed shebang: /usr/bin/perl -> /usr/bin/env perl 23 | 24 | ### 1.01 2015-06-20 25 | 26 | - try loading Net::IP early, set $has_netip 27 | - move "or die..." clause to prevent an innocuous perl 5.20 warning 28 | - if LWP::UserAgent not installed, try using curl, wget, or fetch 29 | 30 | ### 1.00 2015-05-30 31 | 32 | - pass perl critic 33 | - count root attempts as naughty (was failed) 34 | - updated version check to GitHub URL 35 | 36 | ### 0.28 2013-12-10 37 | 38 | - added fix for FTP log parsing, m/ / -> m/\s+/ for WS split 39 | - added vpopmail vchkpw-smtp parsing (limit SMTP brute-force) 40 | - added check_sentry NRPE plugin, for Nagios monitoring 41 | 42 | ### 0.27 2013-12-03 43 | 44 | - added fix for ssh log parsing, m/ / -> m/\s+/ for WS split 45 | - added dovecot log parsing (limit POP & IMAP brute-force) 46 | 47 | ### 0.26 48 | 49 | - added the skeleton of a CPAN dist 50 | - fix for unblacklist not removing from tcpwrappers (sn3ak) 51 | 52 | ### 0.25 2013-04-02 -mps 53 | 54 | - Adds IPv6 support if Net::IP is installed 55 | - switched from file/dir to DB|GDBM|NDBM (whichever is installed) 56 | - automatically imports old format into DB (uses FAR less disk space) 57 | - reporting even a million connections is practically instant 58 | - added error messages where failures were previously handled silently 59 | 60 | ### 0.22 2012-03-07 -mps 61 | 62 | - adjusted SYNOPSIS docs so usage syntax is clearer -ian 63 | - added POD docs for update feature 64 | 65 | ### 0.21 2010-01-03 -mps 66 | 67 | - removed wc TODO after testing. Using wc is no faster than perls builtin methods. Added notes to EFFICIENT portion of man page. 68 | 69 | ### 0.20 2010-06-03 -mps 70 | 71 | - POD cleanups 72 | 73 | ### 0.19 2010-04-22 -mps 74 | 75 | - added methods _unblock_tcpwrappers, _unblock_pf, unblock_ipfw. When unblocking an IP, also remove the IP from the tcpd/firewall 76 | 77 | ### 0.18 2010-04-22 -mps 78 | 79 | - added FTP log parsing 80 | - properly account for FreeBSD + sshd + PAM logins 81 | - if an IP is whitelisted and blacklisted, remove from blacklist 82 | - IPs were getting whitelisted and blacklisted, because the do_???list subs were not returning a success code "do_ and exit;". Added missing result code and altered methods to not depend on them "do_; exit;". 83 | - sentry.pl wasn't being installed unless --update was selected. If no version is installed, install anyway. 84 | - when doing an IP report, show the log file entries if --verbose 85 | - if FTP logs are enabled but not found, report error. 86 | 87 | ### 0.17 2010-01-18 -mps 88 | 89 | - updates to pod docs and comments. -mps 90 | - added a couple more sshd log pattern matches 91 | - reworked the --update feature, works better. 92 | 93 | ### 0.16 2009-06-15 -mps 94 | 95 | - ssh log entries that didn't meet the listed criteria were not being counted, preventing the 10 strikes and you're out rule from kicking in 96 | 97 | ### 0.15 2009-06-09 -mps 98 | 99 | - added ssh probe detection in log parser (spurred on by Kevin Golding) 100 | 101 | ### 0.14 2009-04-15 -mps 102 | 103 | - rewrote portions of the pod docs 104 | - added method version_check, (checks tnpi web site for latest version) 105 | - abstracted self_update and configure_tcpwrappers out of check_setup 106 | 107 | ### 0.12 2009-04-14 -mps 108 | 109 | - pf table in docs was sentry, updated to sentry_blacklist (make docs match the code (thanks Kevin Golding). 110 | - pfctl add/remove was not working properly. Fixed and tested. 111 | 112 | ### 0.11 2009-03-01 -mps 113 | 114 | - fixed some bugs in the POD documentation 115 | - skip setup checks when --help is selected 116 | 117 | ### 2009-03-01 -mps 118 | 119 | - added --help option, based on usability study 120 | - sprinkled a few comments in various places 121 | - added a few more file tests before attempting to open file handles 122 | - added better error handling for file accessing methods 123 | - added placeholder _parse_mail_logs 124 | 125 | ### 2009-02-28 -mps 126 | 127 | - CentOS 5.2 logs this IP to syslog: 24.19.45.95 but the 'address' it logs to hosts via %a is ::ffff:208.75.177.98. Sure would be nice if that change was documented in hosts_access(5). 128 | - warn if unable to determine syslog location for sshd 129 | 130 | ### 2009-02-27 -mps 131 | 132 | - discovered and worked around an 'interesting' problem in external files referenced by some implementations of tcpwrappers 133 | - fixed a copy/paste induced bug preventing delist from working on whitelist 134 | - updated syslog finding script to be more robust on various platforms 135 | - added checks for read permission to $root_dir before attempting to report or setup 136 | 137 | ### 2009-02-26 -mps 138 | 139 | - firewalls off by default 140 | - if selected, report is dispatched first 141 | - script will self-install itself in $root_dir if it doesn't exist there 142 | - don't require an IP for reports 143 | - added warnings when common and important operations fail 144 | - added report summary (-v -r) 145 | - added pod documentation 146 | 147 | ### 2009-02-25 -mps 148 | 149 | - reworked the tcpwrappers black/whitelisting after discovery that libwrap doesn't include the very handy file include feature that tcpwrappers has on FreeBSD and Linux 150 | - if hosts.allow and deny are missing, auto-configure them 151 | - added default log locations for numerous platforms 152 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 | 2 | ### What Platforms does Sentry Run On? 3 | 4 | Sentry should run on any UNIX variant on which tcpwrappers is installed, which 5 | is nearly all of them. Sentry has been tested on: 6 | 7 | * FreeBSD 8 | * Mac OS X 9 | * Linux (CentOS, Debian, Ubuntu) 10 | 11 | # I get an error saying a perl module isn't available 12 | 13 | You are probably running a variant of Linux. The entity that prepared it for 14 | you installed perl without the modules that perl normally ships with. Complain 15 | and ask them to fix it. You can likely fix it yourself by installing perl with 16 | your package manager of choice: 17 | 18 | ### CentOS 19 | 20 | ```sh 21 | yum install perl 22 | ``` 23 | 24 | ### Debian 25 | ```sh 26 | apt-get install perl 27 | ``` 28 | 29 | -------------------------------------------------------------------------------- /INSTALL.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ## Download Sentry 4 | 5 | ```sh 6 | bash || sh 7 | export SENTRY_URL=https://raw.githubusercontent.com/msimerson/sentry/master/sentry.pl 8 | curl -O $SENTRY_URL || wget $SENTRY_URL || fetch --no-verify-peer $SENTRY_URL 9 | ``` 10 | 11 | ### Run it: 12 | 13 | ```sh 14 | perl sentry.pl --update 15 | ``` 16 | 17 | Running `sentry.pl --update` will: 18 | 19 | * create the sentry database (if needed) 20 | * install the perl script (if needed) 21 | * prompt you to edit /etc/hosts.allow (if needed) 22 | 23 | That's all. 24 | 25 | ## Upgrading 26 | 27 | ### Easy Way 28 | ```sh 29 | perl /var/db/sentry/sentry.pl --update 30 | ``` 31 | 32 | ### Hard Way 33 | 34 | download as above 35 | 36 | ```sh 37 | diff sentry.pl /var/db/sentry/sentry.pl 38 | ``` 39 | 40 | resolve any configuration differences 41 | 42 | ```sh 43 | cp sentry.pl /var/db/sentry/sentry.pl 44 | chmod 755 /var/db/sentry/sentry.pl 45 | ``` 46 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | Changes.md 3 | check_sentry 4 | FAQ.md 5 | INSTALL.md 6 | Makefile.PL 7 | MANIFEST This list of files 8 | README.md 9 | sentry.pl 10 | t/01-syntax.t 11 | -------------------------------------------------------------------------------- /Makefile.PL: -------------------------------------------------------------------------------- 1 | require 5.006001; 2 | use ExtUtils::MakeMaker; 3 | WriteMakefile 4 | ( 5 | 'PL_FILES' => {}, 6 | 'INSTALLDIRS' => 'site', 7 | 'NAME' => 'Sentry', 8 | 'EXE_FILES' => [ 9 | 'sentry.pl', 10 | ], 11 | 'VERSION_FROM' => 'sentry.pl', 12 | 'PREREQ_PM' => { 13 | 'Net::IP' => 1, 14 | } 15 | ) 16 | ; 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NAME 2 | 3 | sentry - safe and effective protection against bruteforce attacks 4 | 5 | # SYNOPSIS 6 | 7 | ```sh 8 | sentry --ip=N.N.N.N [ --connect | --blacklist | --whitelist | --delist ] 9 | sentry --report [--verbose --ip=N.N.N.N ] 10 | sentry --help 11 | sentry --update 12 | ``` 13 | 14 | # ADDITIONAL DOCUMENTATION 15 | 16 | * [Install](INSTALL.md) 17 | * [FAQ](FAQ.md) 18 | 19 | # DESCRIPTION 20 | 21 | Sentry detects and prevents bruteforce attacks against sshd using minimal system resources. 22 | 23 | ## SAFE 24 | 25 | To prevent inadvertant lockouts, Sentry auto-whitelists IPs that have connected more than 3 times and succeeded at least once. Now that forgetful colleague behind the office NAT router won't get us locked out of our system. Again. Nor the admin whose script just failed to login 12 times in 2 seconds. 26 | 27 | Sentry includes support for adding IPs to a firewall. Support for IPFW, PF, ipchains is included. Firewall support is disabled by default. Firewall rules may terminate existing session(s) to the host (attn. IPFW users). Get your IPs whitelisted (connect 3x or use --whitelist) before enabling the firewall option. 28 | 29 | ## SIMPLE 30 | 31 | Sentry is written in perl, which is installed nearly everywhere you find sshd. It has no 32 | dependencies. Installation and deployment is extremely simple. 33 | 34 | ## FLEXIBLE 35 | 36 | Sentry supports blocking connection attempts using tcpwrappers and several 37 | popular firewalls. It is easy to extend sentry to support additional 38 | blocking lists. 39 | 40 | Sentry was written to protect the SSH daemon but also blocks on FTP and MUA logs. As this was written, the primary attack platform in use is bot nets comprised of exploited PCs on high-speed internet connections. These bots are used for carrying out SSH attacks as well as spam delivery. Blocking bots prevents multiple attack vectors. 41 | 42 | The programming style of sentry makes it easy to insert code for additonal functionality. 43 | 44 | ## EFFICIENT 45 | 46 | The primary goal of Sentry is to minimize the resources an attacker can steal, while consuming minimal resources itself. Most bruteforce blocking apps (denyhosts, fail2ban, sshdfilter) expect to run as a daemon, tailing a log file. That requires a language interpreter to always be running, consuming at least 10MB of RAM. A single hardware node with dozens of virtual servers will lose hundreds of megs to daemon protection. Sentry uses resources only when connections are made. 47 | 48 | Once an IP is blacklisted for abuse, whether by tcpd or a firewall, the resources it can consume are practically zero. 49 | 50 | # REQUIRED ARGUMENTS 51 | 52 | - ip 53 | 54 | An IP address. The IP should come from a reliable source that is 55 | difficult to spoof. Tcpwrappers is an excellent source. UDP connections 56 | are a poor source as they are easily spoofed. The log files of TCP daemons 57 | can be good source if they are parsed carefully to avoid log injection attacks. 58 | 59 | All actions except __report__ and __help__ require an IP address. The IP address can 60 | be manually specified by an administrator, or preferably passed in by a TCP 61 | server such as tcpd (tcpwrappers), inetd, or tcpserver (daemontools). 62 | 63 | # ACTIONS 64 | 65 | - blacklist 66 | 67 | deny all future connections 68 | 69 | - whitelist 70 | 71 | whitelist all future connections, remove the IP from the blacklists, 72 | and make it immune to future connection tests. 73 | 74 | - delist 75 | 76 | remove an IP from the white and blacklists. This is useful for testing 77 | that sentry is working as expected. 78 | 79 | - connect 80 | 81 | register a connection by an IP. The connect method will log the attempt 82 | and the time. See CONNECT. 83 | 84 | - update 85 | 86 | Check the most recent version of sentry against the installed version and update if a newer version is available. This is most reliable when LWP::UserAgent is installed. 87 | 88 | # EXAMPLES 89 | 90 | See 91 | [https://github.com/msimerson/sentry/wiki/Examples](https://github.com/msimerson/sentry/wiki/Examples) 92 | 93 | 94 | # NAUGHTY 95 | 96 | Sentry has flexible rules for what constitutes a naughty connection. For SSH, 97 | attempts to log in as an invalid user are considered naughty. 98 | See the configuration section in the script related settings. 99 | 100 | 101 | # CONNECT 102 | 103 | When new connections arrive, the connect method will log the attempt 104 | and the time. If the IP is white or blacklisted, sentry exits immediately. 105 | 106 | Next, sentry checks to see if the IP has been seen more than 3 times. If so, 107 | check the logs for successful, failed, and naughty attempts from that IP. 108 | If there are any successful logins, whitelist the IP and exit. 109 | 110 | If there are no successful logins and there are naughty ones, blacklist 111 | the IP. If there are no successful and no naughty attempts but more than 10 112 | connection attempts, blacklist the IP. See also NAUGHTY. 113 | 114 | 115 | # CONFIGURATION AND ENVIRONMENT 116 | 117 | There is a very brief configuration section at the top of the script. Once 118 | your IP is whitelisted, update the booleans for your firewall preference 119 | and Sentry will update your firewall too. 120 | 121 | Sentry does NOT make changes to your firewall configuration. It merely adds 122 | IPs to a table/list/chain. It does this dynamically and it is up to the 123 | firewall administrator to add a rule that does whatever you'd like with the 124 | IPs in the sentry table. 125 | 126 | See also: [PF](https://github.com/msimerson/sentry/wiki/PF) 127 | 128 | 129 | # DIAGNOSTICS 130 | 131 | Sentry can be run with --verbose which will print informational messages 132 | as it runs. 133 | 134 | # DEPENDENCIES 135 | 136 | Sentry uses only modules built into perl. Additional modules may be used in 137 | the future but Sentry will not depend upon them. In other words, if you extend 138 | Sentry with modules are aren't built-ins, also include a fallback method. 139 | 140 | # BUGS AND LIMITATIONS 141 | 142 | 143 | The IPFW and ipchains code is barely tested. 144 | 145 | Report problems to author. 146 | 147 | 148 | # AUTHOR 149 | 150 | Matt Simerson (@msimerson) 151 | 152 | 153 | # ACKNOWLEDGEMENTS 154 | 155 | Those who came before me: denyhosts, fail2ban, sshblacklist, et al 156 | 157 | 158 | # LICENCE AND COPYRIGHT 159 | 160 | Copyright (c) 2015 The Network People, Inc. http://www.tnpi.net/ 161 | 162 | This module is free software; you can redistribute it and/or 163 | modify it under the same terms as Perl itself. See [perlartistic](http://search.cpan.org/perldoc?perlartistic). 164 | 165 | This program is distributed in the hope that it will be useful, 166 | but WITHOUT ANY WARRANTY; without even the implied warranty of 167 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 168 | -------------------------------------------------------------------------------- /check_sentry: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # check_sentry - a nagios plugin for checking if sentry is active 4 | # by Matt Simerson 5 | # Dec 10, 2013 - initial writing 6 | 7 | # INSTRUCTIONS 8 | # install this script in your nagios libexec dir (check nrpe.cfg) 9 | # fetch -o /usr/local/libexec/nagios/check_sentry https://raw.githubusercontent.com/msimerson/sentry/master/check_sentry 10 | # 11 | # add a line like this nrpe.cfg: 12 | # command[check_sentry]=/usr/local/libexec/nagios/check_sentry 13 | # 14 | # and restart nrpe: 15 | # service nrpe2 restart 16 | # 17 | # install this script in your nagios libexec dir (check nrpe.cfg) 18 | 19 | SENTRY_DIR=/var/db/sentry 20 | SENTRY_BIN="$SENTRY_DIR/sentry.pl" 21 | GREP=/usr/bin/grep 22 | 23 | if [ ! -x $GREP ]; then 24 | echo "ERROR: edit check_sentry and set GREP" 25 | GREP=grep 26 | fi 27 | 28 | echoerr() { echo "$@" >&2; } 29 | usage() { 30 | echo " usage: $0" 31 | echo " " 32 | exit 3 33 | } 34 | 35 | $GREP -v '^#' /etc/hosts.allow | $GREP -q sentry 36 | if [ $? -ne 0 ]; then 37 | echo "sentry not active in hosts.allow!" 38 | exit 2 39 | fi 40 | 41 | if [ ! -x $SENTRY_BIN ]; then 42 | echoerr "sentry not executable by k$USER!" 43 | if [ ! -d $SENTRY_DIR ]; then 44 | echo "sentry dir ($SENTRY_DIR) doesn't exist!" 45 | exit 2 46 | fi 47 | 48 | echo "OK - sentry appears installed and active" 49 | exit 0 50 | fi 51 | 52 | 53 | echo "OK - sentry installed and active" 54 | exit 0 55 | -------------------------------------------------------------------------------- /sentry.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | use strict; 3 | use warnings; 4 | 5 | our $VERSION = '1.06'; 6 | 7 | # configuration. Adjust these to taste (boolean, unless noted) 8 | my $root_dir = '/var/db/sentry'; 9 | my $add_to_tcpwrappers = 1; 10 | my $add_to_pf = 1; 11 | my $add_to_ipfw = 0; # untested 12 | my $add_to_iptables = 0; # untested 13 | my $firewall_table = 'sentry_blacklist'; 14 | my $expire_block_days = 90; # 0 to never expire 15 | my $protect_ftp = 1; 16 | my $protect_smtp = 0; 17 | my $protect_mua = 1; # dovecot POP3 & IMAP 18 | my $dl_url = 'https://raw.githubusercontent.com/msimerson/sentry/master/sentry.pl'; 19 | 20 | # perl modules from CPAN 21 | my $has_netip = 0; 22 | eval 'use Net::IP'; ## no critic 23 | if ( !$@ ) { 24 | $has_netip = 1; 25 | } 26 | else { 27 | warn "Net::IP not installed. No IPv6 support.\n"; 28 | }; 29 | 30 | # perl built-in modules 31 | BEGIN { @AnyDBM_File::ISA = qw(DB_File GDBM_File NDBM_File) } 32 | use AnyDBM_File; 33 | use Fcntl qw(:DEFAULT :flock LOCK_EX LOCK_NB); 34 | use Data::Dumper; 35 | use English qw( -no_match_vars ); 36 | use File::Copy; 37 | use File::Path; 38 | use Getopt::Long; 39 | use Pod::Usage; 40 | 41 | # parse command line options 42 | Getopt::Long::GetOptions( 43 | 'ip=s' => \my $ip, 44 | 'connect' => \my $connect, 45 | 'delist' => \my $delist, 46 | 'whitelist' => \my $whitelist, 47 | 'blacklist' => \my $blacklist, 48 | 'report' => \my $report, 49 | 'update' => \my $self_update, 50 | 'nfslock' => \my $nfslock, # TODO: document this 51 | 'dbdump' => \my $db_dump, 52 | 'verbose' => \my $verbose, 53 | 'help' => \my $help, 54 | ) or die "error parsing command line options\n"; 55 | 56 | my $tcpd_denylist = _get_denylist_file(); # where to put hosts.deny entries 57 | my $latest_script = undef; 58 | check_setup() or pod2usage( -verbose => 1); 59 | 60 | my ($db_path,$db_lock,$db_tie,$db_key); 61 | my %ip_record = ( seen => 0, white => 0, black => 0 ); 62 | 63 | _init_db(); 64 | 65 | # dispatch the request 66 | if ( $report ) { do_report() } 67 | elsif ( $connect ) { do_connect() } 68 | elsif ( $whitelist ) { do_whitelist() } 69 | elsif ( $blacklist ) { do_blacklist() } 70 | elsif ( $delist ) { do_delist() } 71 | elsif ( $self_update ) { do_version_check(); upgrade_to_db(); } 72 | elsif ( $help ) { pod2usage( -verbose => 2) } 73 | else { pod2usage( -verbose => 1) }; 74 | 75 | if ( $ip ) { 76 | $db_tie->{$db_key} = join('^', $ip_record{seen}, $ip_record{white}, $ip_record{black} ); 77 | }; 78 | upgrade_to_db(); 79 | untie $db_tie; 80 | close $db_lock; 81 | exit; 82 | 83 | sub is_valid_ip { 84 | return unless $ip; 85 | if ( $has_netip ) { 86 | new Net::IP ( $ip ) or die Net::IP::Error(); 87 | print "ip $ip is valid\n" if $verbose; 88 | return $ip 89 | }; 90 | 91 | if ( $ip =~ /^::ffff:/ ) { 92 | # we have seen $ip in this IPv6 notation: ::ffff:208.75.177.98 93 | ($ip) = (split /:/, $ip)[-1]; # grab everything after the last : 94 | }; 95 | 96 | my @octets = split /\./, $ip; 97 | return unless @octets == 4; # need 4 octets 98 | 99 | return if $octets[0] < 1; 100 | return if grep( $_ eq '255', @octets ) == 4; # 255.255.255.255 invalid 101 | 102 | foreach (@octets) { 103 | return unless /^\d{1,3}$/ and $_ >= 0 and $_ <= 255; 104 | $_ = 0 + $_; 105 | } 106 | 107 | print "ip $ip is valid\n" if $verbose; 108 | return $ip 109 | }; 110 | 111 | sub is_whitelisted { 112 | return if ! $ip_record{white}; 113 | print "is whitelisted\n" if $verbose; 114 | return 1; 115 | }; 116 | 117 | sub is_blacklisted { 118 | return if ! $ip_record{black}; 119 | print "is blacklisted\n" if $verbose; 120 | return 1 if ! $expire_block_days; 121 | my $bl_ts = $ip_record{black}; 122 | my $days_old = ( time() - $bl_ts ) / 3600 / 24; 123 | do_unblacklist() if $days_old > $expire_block_days; 124 | return 1; 125 | }; 126 | 127 | sub check_setup { 128 | 129 | return 1 if $help; 130 | 131 | # check $root_dir is present 132 | if ( ! -d $root_dir ) { 133 | print "creating ssh sentry root at $root_dir\n"; 134 | mkpath($root_dir, undef, oct('0750')) 135 | or die "unable to create $root_dir: $!\n"; 136 | }; 137 | 138 | configure_tcpwrappers(); 139 | 140 | return 1 if ( $report || $self_update ); 141 | 142 | return if ! is_valid_ip(); 143 | 144 | print "setup checks succeeded\n" if $verbose; 145 | return 1; 146 | }; 147 | 148 | sub configure_tcpwrappers { 149 | 150 | my $is_setup; 151 | foreach ( '/etc/hosts.allow', '/etc/hosts.deny', $tcpd_denylist ) { 152 | next if ! $_; 153 | next if ! -f $_ || ! -r $_; 154 | 155 | open my $FH, '<', $_; 156 | my @matches = grep { $_ =~ /sentry/ } <$FH>; 157 | close $FH; 158 | 159 | if ( scalar @matches > 0 ) { 160 | $is_setup++; 161 | last; 162 | }; 163 | }; 164 | 165 | return 1 if $is_setup; 166 | 167 | my $script_loc = _get_script_location(); 168 | my $spawn = 'sshd : ALL : spawn ' . $script_loc . ' -c --ip=%a : allow'; 169 | 170 | if ( $OSNAME =~ /freebsd|linux/ ) { 171 | # FreeBSD & Linux have a modified tcpd, adding support for include files 172 | print " 173 | NOTICE: you need to add these lines near the top of your /etc/hosts.allow file\n 174 | sshd : $tcpd_denylist : deny 175 | $spawn\n\n"; 176 | return; 177 | } 178 | 179 | open my $FH, '>>', '/etc/hosts.deny' 180 | or warn "could not write to /etc/hosts.deny: $!" and return; 181 | print $FH "$spawn\n"; 182 | close $FH; 183 | }; 184 | 185 | sub install_myself { 186 | my $script_loc = _get_script_location(); 187 | print "installing $0 to $script_loc\n" if $verbose; 188 | open FHW, '>', $script_loc or do { ## no critic 189 | warn "unable to write to $script_loc: $!\n"; 190 | return; 191 | }; 192 | open my $fhr, '<', $0 or do { 193 | warn "unable to read $0: $!\n"; 194 | close FHW; 195 | return; 196 | }; 197 | print FHW '#!' . `which perl`; 198 | while (<$fhr>) { next if /^#!/; print FHW $_; } 199 | close $fhr; 200 | close FHW; 201 | chmod 0755, $script_loc; 202 | print "installed to $script_loc\n"; 203 | return 1; 204 | }; 205 | 206 | sub do_version_check { 207 | 208 | my $installed_ver = _get_installed_version() or do { 209 | install_myself() and return 1; 210 | }; 211 | 212 | return if ! $self_update; 213 | 214 | my $release_ver = _get_latest_release_version(); 215 | my $this_ver = $VERSION; 216 | 217 | if ( $installed_ver && $release_ver > $installed_ver ) { 218 | warn "you have sentry $installed_ver installed, version $release_ver is available\n"; 219 | }; 220 | if ( $installed_ver && $this_ver > $installed_ver ) { 221 | warn "you have sentry $installed_ver installed, version $release_ver is running\n"; 222 | }; 223 | 224 | if ($installed_ver >= $release_ver && $installed_ver >= $this_ver) { 225 | print "the latest version ($installed_ver) is installed\n"; 226 | return; 227 | }; 228 | 229 | install_myself() and return if $this_ver > $release_ver; 230 | install_from_web() if $release_ver > $this_ver; 231 | 232 | return 1; 233 | }; 234 | 235 | sub install_from_web { 236 | return if ! $latest_script; 237 | my $script_loc = _get_script_location(); 238 | 239 | print "installing latest sentry.pl to $script_loc\n"; 240 | open FH, '>', $script_loc or die "error: $!\n"; ## no critic 241 | print FH '#!' . `which perl`; 242 | print FH $latest_script; 243 | close FH; 244 | chmod 0755, $script_loc; 245 | my ($latest_ver) = $latest_script =~ /VERSION\s*=\s*\'([0-9\.]+)\'/; 246 | print "upgraded $script_loc to $latest_ver\n"; 247 | return 1; 248 | }; 249 | 250 | sub do_connect { 251 | $ip_record{seen}++; 252 | 253 | return if is_whitelisted(); 254 | return if is_blacklisted(); 255 | return if $ip_record{seen} < 3; 256 | 257 | _parse_ssh_logs(); 258 | _parse_ftp_logs() if $protect_ftp; 259 | _parse_mail_logs() if $protect_smtp || $protect_mua; 260 | }; 261 | 262 | sub do_whitelist { 263 | print "whitelisting $ip\n" if $verbose; 264 | 265 | #printf( " called by %s, %s, %s\n", caller ); 266 | $ip_record{white} = time; 267 | 268 | _allow_tcpwrappers() if $add_to_tcpwrappers; 269 | _allow_pf() if $add_to_pf; 270 | _allow_ipfw() if $add_to_ipfw; 271 | 272 | return 1; 273 | }; 274 | 275 | sub do_blacklist { 276 | print "blacklisting $ip\n" if $verbose; 277 | 278 | $ip_record{black} = time; 279 | 280 | _block_tcpwrappers() if $add_to_tcpwrappers; 281 | _block_pf() if $add_to_pf; 282 | _block_ipfw() if $add_to_ipfw; 283 | 284 | return 1; 285 | }; 286 | 287 | sub do_delist { 288 | do_unblacklist(); 289 | do_unwhitelist(); 290 | }; 291 | 292 | sub do_unblacklist { 293 | print "unblacklisting $ip\n" if $verbose; 294 | $ip_record{black} = 0; 295 | 296 | _unblock_tcpwrappers() if $add_to_tcpwrappers; 297 | _unblock_pf() if $add_to_pf; 298 | _unblock_ipfw() if $add_to_ipfw; 299 | 300 | return; 301 | }; 302 | 303 | sub do_unwhitelist { 304 | print "unwhitelisting $ip\n" if $verbose; 305 | $ip_record{white} = 0; 306 | }; 307 | 308 | sub do_report { 309 | 310 | die "you cannot read $root_dir: $!\n" if ! -r $root_dir; 311 | 312 | upgrade_to_db(); 313 | 314 | return if $ip && ! $verbose; 315 | 316 | my $unique_ips = keys %$db_tie; 317 | my %counts = ( seen => 0, white => 0, black => 0 ); 318 | foreach my $key ( keys %$db_tie ) { 319 | my @vals = _parse_db_val( $db_tie->{ $key } ); 320 | $counts{seen} += $vals[0] || 0; 321 | $counts{white} ++ if $vals[1]; 322 | $counts{black} ++ if $vals[2]; 323 | print "$key: seen=$vals[0], w=$vals[1]\n" if $db_dump; 324 | }; 325 | 326 | print " -------- summary ---------\n"; 327 | printf "%4.0f unique IPs have connected", $unique_ips; 328 | print " $counts{seen} times\n"; 329 | printf "%4.0f IPs are whitelisted\n", $counts{white}; 330 | printf "%4.0f IPs are blacklisted\n", $counts{black}; 331 | print "\n"; 332 | 333 | if ( $ip ) { 334 | _get_ssh_logs(); 335 | _parse_ftp_logs() if $protect_ftp; 336 | }; 337 | }; 338 | 339 | 340 | sub _get_installed_version { 341 | my $script_loc = "$root_dir/sentry.pl"; 342 | if ( ! -e $script_loc ) { 343 | warn "sentry not installed\n"; 344 | return; 345 | }; 346 | my ($ver) = `grep VERSION $script_loc` =~ /VERSION\s*=\s*\'([0-9\.]+)\'/ or do { 347 | warn "unable to determine installed version"; 348 | return; 349 | }; 350 | print "installed version is $ver\n" if $verbose; 351 | return $ver; 352 | }; 353 | 354 | sub _get_url_lwp { 355 | eval 'require LWP::UserAgent'; ## no critic 356 | if ( $EVAL_ERROR ) { 357 | warn "LWP::UserAgent not installed\n"; 358 | return; 359 | } 360 | 361 | my $ua = LWP::UserAgent->new( timeout => 4); 362 | my $response = $ua->get($dl_url); 363 | if (!$response->is_success) { 364 | warn $response->status_line; 365 | return; 366 | } 367 | 368 | return $response->decoded_content; 369 | } 370 | 371 | sub _get_url_cli { 372 | return `curl $dl_url || wget $dl_url || fetch -o - $dl_url`; 373 | } 374 | 375 | sub _get_latest_release_version { 376 | 377 | my $manual_msg = "try upgrading manually with:\n 378 | curl -O /var/db/sentry/sentry.pl $dl_url 379 | or 380 | fetch -o /var/db/sentry/sentry.pl $dl_url 381 | 382 | chmod 755 /var/db/sentry/sentry.pl\n"; 383 | 384 | my $doc = _get_url_lwp() || _get_url_cli(); 385 | if (!$doc) { 386 | warn "unable to download latest script, $manual_msg"; 387 | return 0; 388 | } 389 | 390 | my ($latest_ver) = $doc =~ /VERSION\s*=\s*\'([0-9\.]+)\'/; 391 | if ( ! $latest_ver ) { 392 | warn "could not determine latest version, $manual_msg\n"; 393 | return 0; 394 | }; 395 | print "most recent version: $latest_ver\n" if $verbose; 396 | return $latest_ver; 397 | }; 398 | 399 | sub _get_script_location { 400 | return "$root_dir/sentry.pl"; 401 | }; 402 | 403 | sub _get_denylist_file { 404 | 405 | # Linux and FreeBSD systems have custom versions of libwrap that permit 406 | # storing IP lists in file referenced from hosts.allow or hosts.deny. 407 | # On those systems, dump the blacklisted IPs into a special file 408 | 409 | return "$root_dir/hosts.deny" if $OSNAME =~ /linux|freebsd/i; 410 | return "/etc/hosts.deny"; 411 | }; 412 | 413 | 414 | sub _count_lines { 415 | my $path = shift; 416 | 417 | return 0 if ! -f $path; 418 | 419 | my $count; 420 | open my $FH, '<', $path; 421 | while ( <$FH> ) { $count++ }; 422 | close $FH; 423 | return $count; 424 | }; 425 | 426 | sub _allow_tcpwrappers { 427 | 428 | return if ! -e $tcpd_denylist; 429 | 430 | if ( ! -w $tcpd_denylist ) { 431 | warn "file $tcpd_denylist is not writable!\n"; 432 | return; 433 | }; 434 | 435 | my $err = "failed to delist from tcpwrappers\n"; 436 | open my $TMP, '>', "$tcpd_denylist.tmp" or warn $err and return; 437 | open my $CUR, '<', $tcpd_denylist or warn $err and return; 438 | while ( <$CUR> ) { 439 | next if $_ =~ / $ip /; # discard the IP we want to whitelist 440 | print $TMP $_; 441 | }; 442 | close $TMP; 443 | close $CUR; 444 | move( "$tcpd_denylist.tmp", $tcpd_denylist) or $err; 445 | }; 446 | 447 | sub _allow_ipfw { 448 | 449 | my $ipfw = `which ipfw`; 450 | chomp $ipfw; 451 | if ( !$ipfw || ! -x $ipfw ) { 452 | warn "could not find ipfw!"; 453 | return; 454 | }; 455 | 456 | # TODO: look up the rule number and delete it 457 | my $rule_num = ''; 458 | my $cmd = "delete $rule_num\n"; 459 | }; 460 | 461 | sub _allow_pf { 462 | 463 | my $pfctl = `which pfctl`; 464 | chomp $pfctl; 465 | if ( ! -x $pfctl ) { 466 | warn "could not find pfctl!"; 467 | return; 468 | }; 469 | 470 | # remove the IP from the PF table 471 | my $cmd = "-q -t $firewall_table -Tdelete $ip"; 472 | system "$pfctl $cmd" 473 | and warn "failed to remove $ip from PF table $firewall_table"; 474 | }; 475 | 476 | 477 | sub _block_tcpwrappers { 478 | 479 | if ( -e $tcpd_denylist && ! -w $tcpd_denylist ) { 480 | warn "file $tcpd_denylist is not writable!\n"; 481 | return; 482 | }; 483 | 484 | my $error = "could not add $ip to blocklist: $!\n"; 485 | 486 | # prepend the naughty IP to the hosts.deny file 487 | open(my $TMP, '>', "$tcpd_denylist.tmp") or warn $error and return; 488 | ### WARY: THAR BE DRAGONS HERE! 489 | print $TMP "ALL: $ip : deny\n"; 490 | # Linux and FreeBSD support an external filename referenced from 491 | # /etc/hosts.[allow|deny]. However, that filename parsing is not 492 | # identical to /etc/hosts.allow. Specifically, this works as 493 | # expected in /etc/hosts.allow: 494 | # ALL : N.N.N.N : deny 495 | # but it does not work in an external file! Be sure to use this syntax: 496 | # ALL: N.N.N.N : deny 497 | # Lest thee find thyself wishing thou hadst 498 | ### /WARY 499 | 500 | # append the current hosts.deny to the temp file 501 | if ( -e $tcpd_denylist && -r $tcpd_denylist ) { 502 | open my $BL, '<', $tcpd_denylist or warn $error and return; 503 | while ( my $line = <$BL> ) { 504 | print $TMP $line; 505 | } 506 | close $BL; 507 | } 508 | close $TMP; 509 | 510 | # and finally install the new file 511 | move( "$tcpd_denylist.tmp", $tcpd_denylist ); 512 | }; 513 | 514 | sub _block_ipfw { 515 | 516 | my $ipfw = `which ipfw`; 517 | chomp $ipfw; 518 | if ( !$ipfw || ! -x $ipfw ) { 519 | warn "could not find ipfw!"; 520 | return; 521 | }; 522 | 523 | # TODO: set this to a reasonable default 524 | my $cmd = "add deny all from $ip to any"; 525 | warn "$ipfw $cmd\n"; 526 | #system "$ipfw $cmd"; # TODO: this this 527 | }; 528 | 529 | sub _block_pf { 530 | 531 | my $pfctl = `which pfctl`; 532 | chomp $pfctl; 533 | if ( ! -x $pfctl ) { 534 | warn "could not find pfctl!"; 535 | return; 536 | }; 537 | 538 | # add the IP to the chosen PF table 539 | my $args = "-q -t $firewall_table -T add $ip"; 540 | #warn "$pfctl $args\n"; 541 | system "$pfctl $args" and warn "failed to add $ip to PF table $firewall_table"; 542 | 543 | # kill all state entries for the blocked host 544 | system "$pfctl -q -k $ip"; 545 | }; 546 | 547 | sub _unblock_tcpwrappers { 548 | 549 | if ( ! -e $tcpd_denylist ) { 550 | warn "IP $ip not blocked in tcpwrappers\n"; 551 | return; 552 | }; 553 | 554 | if ( ! -w $tcpd_denylist ) { 555 | warn "file $tcpd_denylist is not writable!\n"; 556 | return; 557 | }; 558 | 559 | my $tmp = "$tcpd_denylist.tmp"; 560 | if ( -e $tmp && ! -w $tmp ) { 561 | warn "file $tmp is not writable!\n"; 562 | return; 563 | }; 564 | 565 | my $error = "could not remove $ip from blocklist: $!\n"; 566 | 567 | # open a temp file 568 | open(my $TMP, '>', $tmp) or warn $error and return; 569 | 570 | # cat the current hosts.deny to the temp file, omitting $ip 571 | open my $BL, '<', $tcpd_denylist or warn $error and return; 572 | while ( my $line = <$BL> ) { 573 | next if $line =~ /$ip/; 574 | print $TMP $line; 575 | } 576 | close $BL; 577 | close $TMP; 578 | 579 | # install the new file 580 | move( $tmp, $tcpd_denylist ); 581 | }; 582 | 583 | sub _unblock_ipfw { 584 | 585 | my $ipfw = `which ipfw`; 586 | chomp $ipfw; 587 | if ( !$ipfw || ! -x $ipfw ) { 588 | warn "could not find ipfw!"; 589 | return; 590 | }; 591 | 592 | # TODO: test that this is reasonable 593 | my $cmd = "delete deny all from $ip to any"; 594 | warn "$ipfw $cmd\n"; 595 | #system "$ipfw $cmd"; 596 | }; 597 | 598 | sub _unblock_pf { 599 | 600 | my $pfctl = `which pfctl`; 601 | chomp $pfctl; 602 | if ( ! -x $pfctl ) { 603 | warn "could not find pfctl!"; 604 | return; 605 | }; 606 | 607 | # add the IP to the chosen PF table 608 | my $args = "-q -t $firewall_table -T delete $ip"; 609 | #warn "$pfctl $args\n"; 610 | system "$pfctl $args" and warn "failed to delete $ip from PF table $firewall_table"; 611 | return 1; 612 | }; 613 | 614 | sub _parse_ssh_logs { 615 | my $ssh_attempts = _get_ssh_logs(); 616 | 617 | # fail safely. If we can't parse the logs, skip the white/blacklist steps 618 | return if ! $ssh_attempts; 619 | 620 | if ( $ssh_attempts->{success} ) { do_whitelist(); return; }; 621 | if ( $ssh_attempts->{naughty} ) { do_blacklist(); return; }; 622 | 623 | # do not use $seen_count here. If the ssh log parsing failed for any reason, 624 | # legit users would not get whitelisted, and then after 10 attempts they 625 | # would get backlisted. 626 | 627 | # no success or naughty, but > 10 connects, blacklist 628 | do_blacklist() if $ssh_attempts->{total} > 10; 629 | }; 630 | 631 | sub _get_ssh_logs { 632 | 633 | my $logfile = _get_sshd_log_location(); 634 | return if ! -f $logfile; 635 | print "checking for SSH logins in $logfile\n" if $verbose; 636 | 637 | my %count; 638 | open my $FH, '<', $logfile or warn "unable to read $logfile: $!\n" and return; 639 | while ( my $line = <$FH> ) { 640 | chomp $line; 641 | 642 | next if $line !~ / sshd/; 643 | next if $line !~ /$ip/; 644 | 645 | # consider using Parse::Syslog if available 646 | # 647 | # WARNING: if you modify this, be mindful of log injection attacks. 648 | # Anchor any regexps or otherwise exclude the user modifiable portions of the 649 | # log entries when parsing 650 | 651 | # Dec 3 12:14:16 pe sshd[4026]: Accepted publickey for tnpimatt from 67.171.0.90 port 45189 ssh2 652 | # Feb 8 20:49:21 spry sshd[1550]: Failed password for invalid user pentakill from 93.62.1.201 port 33210 ssh2 653 | 654 | my @bits = split /\s+/, $line; # split on WS 655 | if ( $bits[5] eq 'Accepted' ) { $count{success}++ } 656 | elsif ( $bits[5] eq 'Invalid' ) { $count{naughty}++ } 657 | elsif ( $bits[5] eq 'Failed' ) { $count{failed}++ } 658 | elsif ( $bits[5] eq 'Did' ) { $count{probed}++ } 659 | elsif ( $bits[5] eq 'warning:' ) { $count{warnings}++ } 660 | # 113.160.203.24 661 | # PAM: authentication error for root from 113.160.203.24 662 | elsif ( $bits[5] eq '(pam_unix)' ) { 663 | $count{failed}++ and next if $line =~ /authentication failure; /; 664 | $count{naughty}++ and next if $line =~ /check pass; user unknown$/; 665 | print "pam_unix unknown: $line\n"; 666 | } 667 | elsif ( $bits[5] eq 'error:' ) { 668 | $count{naughty}++ and next if $line =~ /exceeded for root/; 669 | if ( $bits[6] eq 'PAM:' ) { 670 | # FreeBSD PAM authentication 671 | $count{naughty}++ and next if $line =~ /error for root/; 672 | $count{failed}++ and next if $line =~ /authentication error/; 673 | $count{naughty}++ and next if $line =~ /(invalid|illegal) user/; 674 | }; 675 | $count{errors}++; 676 | } 677 | else { 678 | # if ( $line =~ /POSSIBLE BREAK-IN ATTEMPT!$/ ) { 679 | # This only means their forward/reverse DNS isn't set up properly. Not a 680 | # good criteria for blacklisting 681 | # $count{naughty}++; 682 | # }; 683 | # 684 | # if ( $line =~ /Did not receive identification string from/ ) { 685 | # This entry means that something connected using the SSH protocol, but didn't 686 | # attempt to authenticate. This could a SSH version probe, or a 687 | # monitoring tool like Nagios or Hobbit. 688 | # }; 689 | 690 | $count{unknown}++; 691 | print "unknown: $bits[5]: $line\n"; 692 | } 693 | }; 694 | close $FH; 695 | 696 | print Dumper(\%count) if $verbose; 697 | foreach ( qw/ success naughty errors failed probed warning unknown / ) { 698 | $count{total} += $count{$_} || 0; 699 | }; 700 | 701 | return \%count; 702 | }; 703 | 704 | sub _get_sshd_log_location { 705 | 706 | # TODO 707 | # a. check the date on the file, and make sure it is within the past month 708 | # b. sample the file, and make sure its contents are what we expect 709 | 710 | # check the most common places 711 | my @log_files; 712 | push @log_files, 'auth.log'; # freebsd, debian 713 | push @log_files, 'secure'; # centos 714 | push @log_files, 'secure.log'; # darwin 715 | 716 | foreach ( @log_files ) { 717 | return "/var/log/$_" if -f "/var/log/$_"; 718 | }; 719 | 720 | # os specific locations (some are legacy) 721 | my $log; 722 | $log = '/var/log/system.log' if $OSNAME =~ /darwin/i; 723 | $log = '/var/log/messages' if $OSNAME =~ /freebsd/i; 724 | $log = '/var/log/messages' if $OSNAME =~ /linux/i; 725 | $log = '/var/log/syslog' if $OSNAME =~ /solaris/i; 726 | $log = '/var/adm/SYSLOG' if $OSNAME =~ /irix/i; 727 | $log = '/var/adm/messages' if $OSNAME =~ /aix/i; 728 | $log = '/var/log/messages' if $OSNAME =~ /bsd/i; 729 | $log = '/usr/spool/mqueue/syslog' if $OSNAME =~ /hpux/i; 730 | 731 | return $log if -f $log; 732 | warn "unable to find your sshd logs.\n"; 733 | 734 | # TODO: check /etc/syslog.conf for location? 735 | return; 736 | }; 737 | 738 | sub _parse_mail_logs { 739 | my $attempts = _get_mail_logs() or return; 740 | 741 | if ( $attempts->{success} ) { do_whitelist(); return; }; 742 | if ( $attempts->{naughty} ) { do_blacklist(); return; }; 743 | 744 | do_blacklist() if ($attempts->{total} && $attempts->{total} > 10); 745 | }; 746 | 747 | sub _get_mail_logs { 748 | # if you want to blacklist spamming IPs, you must alter this to support your 749 | # MTA's log files. 750 | # Note the comments in the _get_ssh_logs sub. 751 | # I recommend returning a hashref like the one used in the ssh function. 752 | # If parsing SpamAssassin logs, I'd set success to be anything virus free 753 | # and a spam score less than 5. 754 | # Naughty might be more than 3 messages with spam scores above 10 755 | 756 | my $logfile = _get_mail_log_location(); 757 | return if ! -f $logfile; 758 | print "checking for email logins in $logfile\n" if $verbose; 759 | 760 | open my $FH, '<', $logfile or do { 761 | warn "unable to read $logfile: $!\n"; 762 | return; 763 | }; 764 | 765 | my %count; 766 | while ( my $line = <$FH> ) { 767 | chomp $line; 768 | next if $line !~ /$ip/; # ignore lines for other IPs 769 | 770 | # Dec 3 05:42:59 pe dovecot: pop3-login: Aborted login (auth failed, 1 attempts): user=, method=PLAIN, rip=37.46.80.95, lip=18.28.0.30 771 | # Dec 3 05:43:06 pe dovecot: pop3-login: Disconnected (auth failed, 2 attempts): user=, method=PLAIN, rip=93.153.9.210, lip=18.28.0.30 772 | # Dec 3 00:04:45 pe dovecot: imap-login: Login: user=, method=PLAIN, rip=127.0.0.24, lip=127.0.0.6, mpid=81292, session= 773 | # Dec 3 00:04:47 pe dovecot: imap-login: Login: user=, method=CRAM-MD5, rip=65.100.142.26, lip=127.0.0.6, mpid=81301, TLS, session=<4PFBZI4a> 774 | 775 | my ($mon, $day, $time, $host, $app, $proc, $msg) = split /\s+/, $line, 6; 776 | next if $msg !~ /$ip/; # ignore lines for other IPs 777 | 778 | if ( $app eq 'dovecot:' ) { 779 | if ( $msg =~ '^Login:' ) { $count{success}++ } 780 | elsif ( $msg =~ /auth failed/ ) { $count{naughty}++ } 781 | elsif ( $msg =~ /no auth attempt/ ) { $count{probed}++ } 782 | elsif ( $msg =~ /Disconnected/ ) { $count{info}++ } 783 | else { 784 | print "unknown mail: $line\n"; 785 | $count{unknown}++; 786 | }; 787 | } 788 | elsif ( $proc eq 'vchkpw-smtp:' ) { 789 | # Dec 5 07:56:27 pe vpopmail[1783]: vchkpw-smtp: null password given root:178.33.94.90 790 | if ( $msg =~ /vpopmail user not found/ ) { $count{naughty}++ } 791 | elsif ( $msg =~ /null password/ ) { $count{naughty}++ }; 792 | }; 793 | }; 794 | close $FH; 795 | 796 | foreach ( qw/ success naughty errors failed probed warning unknown info / ) { 797 | $count{total} += $count{$_} || 0; 798 | }; 799 | print Dumper(\%count) if $verbose; 800 | 801 | return \%count; 802 | }; 803 | 804 | sub _get_mail_log_location { 805 | 806 | # check the most common places 807 | my @log_files; 808 | push @log_files, 'maillog'; # freebsd 809 | push @log_files, 'mail.log'; 810 | 811 | foreach ( @log_files ) { 812 | return "/var/log/$_" if -f "/var/log/$_"; 813 | }; 814 | 815 | warn "unable to find your mail logs.\n"; 816 | return; 817 | }; 818 | 819 | 820 | sub _parse_ftp_logs { 821 | my $logfile = _get_ftpd_log_location() or return; 822 | print "checking for FTP logins in $logfile\n" if $verbose; 823 | 824 | # sample success 825 | #Nov 8 11:27:51 vhost0 ftpd[29864]: connection from adsl-69-209-115-194.dsl.klmzmi.ameritech.net (69.209.115.194) 826 | #Nov 8 11:27:51 vhost0 ftpd[29864]: FTP LOGIN FROM adsl-69-209-115-194.dsl.klmzmi.ameritech.net as rollins 827 | 828 | # sample failed 829 | #Nov 21 21:33:57 vhost0 ftpd[5398]: connection from 87-194-156-116.bethere.co.uk (87.194.156.116) 830 | #Nov 21 21:33:57 vhost0 ftpd[5398]: FTP LOGIN FAILED FROM 87-194-156-116.bethere.co.uk 831 | 832 | open my $FH, '<', $logfile or warn "unable to read $logfile: $!\n" and return; 833 | my (%count, $rdns); 834 | while ( my $line = <$FH> ) { 835 | 836 | my ($mon, $day, $time, $host, $proc, $msg) = split /\s+/, $line, 6; 837 | 838 | next if ! $proc; 839 | next if $proc !~ /^ftpd/; 840 | 841 | if ( $rdns ) { 842 | # xferlog format has 'connection from' line followed by status 843 | if ( $msg =~ /FROM $rdns/i ) { 844 | $count{failed}++ if $line =~ /^FTP LOGIN FAILED/; 845 | $count{success}++ if $line =~ /^FTP LOGIN FROM/; 846 | $rdns = undef; 847 | next; 848 | }; 849 | }; 850 | 851 | ( $rdns ) = $msg =~ /connection from (.*?) \($ip\)/; 852 | }; 853 | close $FH; 854 | 855 | foreach ( qw/ success failed / ) { 856 | $count{total} += $count{$_} || 0; 857 | }; 858 | 859 | print Dumper(\%count) if $verbose; 860 | 861 | if ( $count{success} ) { do_whitelist(); return; }; 862 | if ( $count{naughty} ) { do_blacklist(); return; }; 863 | 864 | do_blacklist() if $count{total} > 10; 865 | } 866 | 867 | sub _get_ftpd_log_location { 868 | my @log_files; 869 | push @log_files, 'xferlog'; # freebsd, debian 870 | push @log_files, 'ftp.log'; # Mac OS X 871 | push @log_files, 'auth.log'; 872 | 873 | foreach ( @log_files ) { 874 | return "/var/log/$_" if -f "/var/log/$_"; 875 | }; 876 | 877 | warn "unable to find FTP logs\n"; 878 | return; 879 | }; 880 | 881 | 882 | sub upgrade_to_db { 883 | my @files = glob "$root_dir/seen/*/*/*/*"; 884 | return if ! @files; 885 | print "upgrading to DB format\n"; 886 | 887 | foreach my $f ( @files ) { 888 | my $a_ip = join('.', (split /\//, $f)[-4,-3,-2,-1]); 889 | my $key = _get_db_key( $a_ip ) or die "unable to convert ip to an int"; 890 | my $count = _count_lines( $f ); 891 | my $white_path = $f; $white_path =~ s/seen/white/; 892 | my $black_path = $f; $black_path =~ s/seen/black/; 893 | print "$f \t $key $a_ip $count\n"; 894 | $db_tie->{$key} = join('^', $count, -f $white_path ? 1 : 0, -f $black_path ? 1 : 0); 895 | unlink $white_path if -f $white_path; 896 | unlink $black_path if -f $black_path; 897 | unlink $f; 898 | }; 899 | system "find $root_dir -type dir -empty -delete" 900 | }; 901 | 902 | sub _init_db { 903 | $db_path = _get_db_location() or exit; 904 | $db_lock = _get_db_lock( $db_path ) or exit; 905 | $db_tie = _get_db_tie( $db_path, $db_lock ) or exit; 906 | 907 | if ( ! $ip ) { print "no IP, skip info\n"; return; }; 908 | 909 | $db_key = _get_db_key() or die "unable to get DB key"; 910 | if ( $db_tie->{ $db_key } ) { # we've seen this IP before 911 | my @vals = _parse_db_val( $db_tie->{ $db_key } ); 912 | $ip_record{seen} = $vals[0]; 913 | $ip_record{white} = $vals[1]; 914 | $ip_record{black} = $vals[2]; 915 | }; 916 | printf "%4.0f connections from $ip (key: $db_key)\n", $ip_record{seen}; 917 | print "\tand it is whitelisted\n" if $ip_record{white}; 918 | print "\tand it is blacklisted\n" if $ip_record{black}; 919 | }; 920 | 921 | sub _parse_db_val { 922 | return split /\^/, shift; # using ^ char as delimiter 923 | }; 924 | 925 | sub _get_db_key { 926 | my $lip = shift || $ip; 927 | if ( ! $has_netip ) { 928 | return unpack 'N', pack 'C4', split /\./, $lip; # works for IPv4 only 929 | }; 930 | return Net::IP->new( $lip )->intip; 931 | }; 932 | 933 | sub _get_db_tie { 934 | my ( $db, $lock ) = @_; 935 | 936 | tie( my %db, 'AnyDBM_File', $db, O_CREAT|O_RDWR, oct('0600')) or do { 937 | warn "error, tie to database $db failed: $!"; 938 | close $lock; 939 | return; 940 | }; 941 | return \%db; 942 | }; 943 | 944 | sub _get_db_location { 945 | 946 | # Setup database location 947 | my @candidate_dirs = ( $root_dir, "/var/db/sentry", "/var/db", '.' ); 948 | 949 | my $dbdir; 950 | for my $d ( @candidate_dirs ) { 951 | next if ! $d || ! -d $d; # impossible 952 | $dbdir = $d; 953 | last; # first match wins 954 | } 955 | my $db = "$dbdir/sentry.dbm"; 956 | print "using $db as database\n" if $verbose; 957 | return $db; 958 | }; 959 | 960 | sub _get_db_lock { 961 | my $db = shift; 962 | 963 | return _get_db_lock_nfs($db) if $nfslock; 964 | 965 | # Check denysoft db 966 | open(my $lock, '>', "$db.lock") or do { 967 | warn "error, opening lockfile failed: $!"; 968 | return; 969 | }; 970 | 971 | flock( $lock, LOCK_EX ) or do { 972 | warn "error, flock of lockfile failed: $!"; 973 | close $lock; 974 | return; 975 | }; 976 | 977 | return $lock; 978 | } 979 | 980 | sub _get_db_lock_nfs { 981 | my $db = shift; 982 | 983 | require File::NFSLock; 984 | 985 | ### set up a lock - lasts until object looses scope 986 | my $nfslock = new File::NFSLock { 987 | file => "$db.lock", 988 | lock_type => LOCK_EX|LOCK_NB, 989 | blocking_timeout => 10, # 10 sec 990 | stale_lock_timeout => 30 * 60, # 30 min 991 | } or do { 992 | warn "error, nfs lockfile failed: $!"; 993 | return; 994 | }; 995 | 996 | open(my $lock, '+<', "$db.lock") or do { 997 | warn "error, opening nfs lockfile failed: $!"; 998 | return; 999 | }; 1000 | 1001 | return $lock; 1002 | }; 1003 | 1004 | sub ignore_this {}; 1005 | 1006 | __END__ 1007 | 1008 | =head1 NAME 1009 | 1010 | Sentry - safe and effective protection against bruteforce attacks 1011 | 1012 | 1013 | =head1 SYNOPSIS 1014 | 1015 | sentry --ip= [ --whitelist | --blacklist | --delist | --connect ] 1016 | sentry --report [--verbose --ip= ] 1017 | sentry --help 1018 | sentry --update 1019 | 1020 | 1021 | =head1 ADDITIONAL DOCUMENTATION 1022 | 1023 | See https://github.com/msimerson/sentry 1024 | 1025 | =head1 DESCRIPTION 1026 | 1027 | Sentry limits bruteforce attacks using minimal system resources. 1028 | 1029 | =head2 SAFE 1030 | 1031 | To prevent inadvertant lockouts, Sentry manages a whitelist of IPs that connect more than 3 times and succeed at least once. A forgetful colleague or errant script running behind the office NAT is far less likely to get the entire office locked out than with many bruteforce blockers. 1032 | 1033 | Sentry includes firewall support for IPFW, PF, and ipchains. It is disabled by default. Be careful though, adding dynamic firewall rules may terminate existing sessions (attn IPFW users). Whitelist your IPs (connect 3x or use --whitelist) before enabling the firewall option. 1034 | 1035 | =head2 SIMPLE 1036 | 1037 | Sentry has a compact database for tracking IPs. It records the number of connects and the date when an IP was white or blacklisted. 1038 | 1039 | Sentry is written in perl, which is installed practically everywhere sshd is. The only dependency is Net::IP for IPv6 handling. Sentry installation is extremely simple. 1040 | 1041 | =head2 FLEXIBLE 1042 | 1043 | Sentry supports blocking connection attempts using tcpwrappers and several popular firewalls. It is easy to extend Sentry to support additional blocking lists. 1044 | 1045 | Sentry was written to protect the SSH daemon but is also used for FTP and SMTP protection. A primary attack platform is bot nets. The bots are used for carrying out SSH attacks as well as spam delivery. Blocking on multiple attack criteria reduces overall abuse. 1046 | 1047 | The programming style of Sentry makes it easy to insert code for additional functionality. 1048 | 1049 | =head2 EFFICIENT 1050 | 1051 | A goal of Sentry is to minimize resource abuse. Many bruteforce blockers (denyhosts, fail2ban, sshdfilter) expect to run as a daemon, tailing a log file. That requires an interpreter to always be running, consuming CPU and RAM. A single hardware node with dozens of virtual servers loses hundreds of megs of RAM to daemon protection. 1052 | 1053 | Sentry uses resources only when connections are made, and then only a few times before an IP is white/blacklisted. Once an IP is blacklisted for abuse, the resources it can abuse are neglible. 1054 | 1055 | =head1 REQUIRED ARGUMENTS 1056 | 1057 | =over 4 1058 | 1059 | =item ip 1060 | 1061 | An IPv4 or IPv6 address. The IP should come from a reliable source that is 1062 | difficult to spoof. Tcpwrappers is an excellent source. UDP connections 1063 | are a poor source as they are easily spoofed. The log files of TCP daemons 1064 | can be good source if they are parsed carefully to avoid log injection attacks. 1065 | 1066 | =back 1067 | 1068 | All actions except B and B require an IP address. The IP can 1069 | be manually specified by an administrator, or preferably passed in by a TCP 1070 | server such as tcpd (tcpwrappers), inetd, or tcpserver (daemontools). 1071 | 1072 | =head1 ACTIONS 1073 | 1074 | =over 1075 | 1076 | =item blacklist 1077 | 1078 | deny all future connections 1079 | 1080 | =item whitelist 1081 | 1082 | whitelist all future connections, remove the IP from the blacklists, 1083 | and make it immune to future connection tests. 1084 | 1085 | =item delist 1086 | 1087 | remove an IP from the white and blacklists. This is useful for testing 1088 | that Sentry is working as expected. 1089 | 1090 | =item connect 1091 | 1092 | register a connection by an IP. The connect method will log the attempt 1093 | and the time. See CONNECT. 1094 | 1095 | =item update 1096 | 1097 | Check the most recent version of Sentry against the installed version and update if a newer version is available. 1098 | 1099 | =back 1100 | 1101 | =head1 EXAMPLES 1102 | 1103 | https://github.com/msimerson/sentry/wiki/Examples 1104 | 1105 | 1106 | =head1 NAUGHTY 1107 | 1108 | Sentry has flexible rules for what constitutes a naughty connection. For SSH, 1109 | attempts to log in as an invalid user are considered naughty. For SMTP, the 1110 | sending of a virus, or an email with a high spam score could be considered 1111 | naughty. See the configuration section in the script related settings. 1112 | 1113 | 1114 | =head1 CONNECT 1115 | 1116 | When new connections arrive, the connect method will log the attempt. 1117 | If the IP is already white or blacklisted, it exits immediately. 1118 | 1119 | Next, Sentry checks to see if it has seen the IP more than 3 times. If so, 1120 | check the logs for successful, failed, and naughty attempts from that IP. 1121 | If there are any successful logins, whitelist the IP and exit. 1122 | 1123 | If there are no successful logins and there are naughty ones, blacklist 1124 | the IP. If there are no successful and no naughty attempts but more than 10 1125 | connection attempts, blacklist the IP. See also NAUGHTY. 1126 | 1127 | 1128 | =head1 CONFIGURATION AND ENVIRONMENT 1129 | 1130 | There is a very brief configuration section at the top of the script. Once 1131 | your IP is whitelisted, update the booleans for your firewall preference 1132 | and Sentry will update your firewall too. 1133 | 1134 | Sentry does NOT make changes to your firewall configuration. It merely adds 1135 | IPs to a table/list/chain. It does this dynamically and it is up to the 1136 | firewall administrator to add a rule that does whatever you'd like with the 1137 | IPs in the sentry table. 1138 | 1139 | See PF: https://github.com/msimerson/sentry/wiki/PF 1140 | 1141 | 1142 | =head1 DIAGNOSTICS 1143 | 1144 | Sentry can be run with --verbose which will print informational messages 1145 | as it runs. 1146 | 1147 | =head1 DEPENDENCIES 1148 | 1149 | Net::IP, for IPv6 support. 1150 | 1151 | =head1 BUGS AND LIMITATIONS 1152 | 1153 | The IPFW and ipchains code is barely tested. 1154 | 1155 | Report problems to author. 1156 | 1157 | =head1 AUTHOR 1158 | 1159 | Matt Simerson (msimerson@cpan.org) 1160 | 1161 | 1162 | =head1 ACKNOWLEDGEMENTS 1163 | 1164 | Those who came before: denyhosts, fail2ban, sshblacklist, et al 1165 | 1166 | 1167 | =head1 LICENCE AND COPYRIGHT 1168 | 1169 | Copyright (c) 2015 The Network People, Inc. http://www.tnpi.net/ 1170 | 1171 | This module is free software; you can redistribute it and/or 1172 | modify it under the same terms as Perl itself. See L. 1173 | 1174 | This program is distributed in the hope that it will be useful, 1175 | but WITHOUT ANY WARRANTY; without even the implied warranty of 1176 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 1177 | 1178 | 1179 | -------------------------------------------------------------------------------- /t/01-syntax.t: -------------------------------------------------------------------------------- 1 | 2 | use Config qw/ myconfig /; 3 | use Data::Dumper; 4 | use English qw/ -no_match_vars /; 5 | use Test::More tests => 2; 6 | 7 | use lib 'lib'; 8 | 9 | my $this_perl = $Config{'perlpath'} || $EXECUTABLE_NAME; 10 | 11 | ok( $this_perl, "this_perl: $this_perl" ); 12 | 13 | if ($OSNAME ne 'VMS' && $Config{_exe} ) { 14 | $this_perl .= $Config{_exe} 15 | unless $this_perl =~ m/$Config{_exe}$/i; 16 | } 17 | 18 | my $cmd = "$this_perl -c 'sentry.pl'"; 19 | my $r = system "$cmd 2>/dev/null >/dev/null"; 20 | ok( $r == 0, "syntax sentry.pl"); 21 | 22 | --------------------------------------------------------------------------------