summaryrefslogtreecommitdiff
path: root/sshban.py
diff options
context:
space:
mode:
authorAnton Khirnov <anton@khirnov.net>2020-04-04 09:40:03 +0200
committerAnton Khirnov <anton@khirnov.net>2020-04-04 09:40:03 +0200
commita28f06a06cd6ead092652988e99ffb58bf780e0c (patch)
tree8c9c65dce1b1330e82ff48ba8f44bd1486c2240e /sshban.py
Initial commit.
Diffstat (limited to 'sshban.py')
-rwxr-xr-xsshban.py216
1 files changed, 216 insertions, 0 deletions
diff --git a/sshban.py b/sshban.py
new file mode 100755
index 0000000..0ac8c56
--- /dev/null
+++ b/sshban.py
@@ -0,0 +1,216 @@
+#!/usr/bin/python3
+
+import argparse
+import logging
+import logging.handlers
+import os
+import re
+import select
+import sys
+import subprocess
+import time
+
+ACT_NOTHING = "nothing"
+ACT_BAN_SHORT = "ban_short"
+ACT_BAN_MEDIUM = "ban_medium"
+ACT_BAN_LONG = "ban_long"
+
+IFF_EVIL = 0
+IFF_GOOD = 1
+IFF_GRAY = 2
+
+MINUTE = 60 # seconds
+HOUR = 60 * MINUTE
+DAY = 24 * HOUR
+
+regexes = {
+ IFF_GOOD : [
+ r'^Accepted publickey .* from (\S+)',
+ ],
+ IFF_EVIL : [
+ r'^Invalid user .* from (\S+)',
+ r'^Failed password .* from (\S+)',
+ r'^PAM .* authentication failure .* rhost=(\S+)',
+ ],
+ IFF_GRAY : [
+ r'^Received disconnect from (\S+)',
+ ]
+}
+
+def process_msg(ts, msg):
+ for iff, rr in regexes.items():
+ for r in rr:
+ m = re.search(r, msg)
+ if m is None:
+ continue
+
+ return (iff, m.group(1))
+
+ return None
+
+class ExpiringCounter:
+ default_timeout = None
+
+ _data = None
+
+ def __init__(self, default_timeout):
+ self._data = {}
+ self.default_timeout = default_timeout
+
+ def __contains__(self, key):
+ if not key in self._data:
+ return False
+
+ now = self._now()
+ ts, val = self._data[key]
+ if now - ts > self.default_timeout:
+ del self._data[key]
+ return False
+
+ return True
+
+ def __delitem__(self, key):
+ del self._data[key]
+
+ def _now(self):
+ return time.clock_gettime(time.CLOCK_BOOTTIME)
+
+ def inc(self, key, count = 1):
+ now = self._now()
+
+ oldval = self._data[key][1] if key in self else 0
+ newval = max(0, oldval + count)
+
+ if newval > 0:
+ self._data[key] = (now, newval)
+ elif key in self:
+ del self[key]
+
+ return newval
+
+ def dec(self, key, count = 1):
+ return self.inc(item, -count)
+
+class Judge:
+ # FIXME: arbitrary constants
+
+ _whitelist = None
+ _blacklists = None
+ _graylist = None
+
+ _gray_threshold = None
+ _black_thresholds = None
+
+ def __init__(self, thresh):
+ self._whitelist = ExpiringCounter(DAY)
+ self._graylist = ExpiringCounter(DAY)
+
+ self._blacklists = {}
+ self._blacklists[ACT_BAN_SHORT] = ExpiringCounter(MINUTE)
+ self._blacklists[ACT_BAN_MEDIUM] = ExpiringCounter(HOUR)
+ self._blacklists[ACT_BAN_LONG] = ExpiringCounter(DAY)
+
+ self._black_thresholds = thresh
+ self._gray_threshold = 8 * thresh[ACT_BAN_MEDIUM]
+
+ def process(self, iff, host):
+ if iff == IFF_GOOD:
+ # add to whitelist
+ self._whitelist.inc(host)
+
+ # remove from graylist
+ if host in self._graylist:
+ del self._graylist[host]
+
+ # reduce blacklist entries
+ for bl in self._blacklists:
+ if host in bl:
+ bl.dec(host, 4)
+ elif iff == IFF_GRAY:
+ if not host in self._whitelist:
+ count = self._graylist.inc(host)
+ if count > self._gray_threshold:
+ return ACT_BAN_MEDIUM
+ elif iff == IFF_EVIL:
+ for bl_id in (ACT_BAN_LONG, ACT_BAN_MEDIUM, ACT_BAN_SHORT):
+ bl = self._blacklists[bl_id]
+ thresh = self._black_thresholds[bl_id]
+ count = bl.inc(host)
+ if count > thresh:
+ return bl_id
+
+ return ACT_NOTHING
+
+parser = argparse.ArgumentParser('Parse logs and ban SSH abusers')
+
+parser.add_argument('-s', '--thresh-short', type = int, default = 8,
+ help = 'Maximum number of abuses per minute to get banned')
+parser.add_argument('-m', '--thresh-medium', type = int, default = 16,
+ help = 'Maximum number of abuses per hour to get banned')
+parser.add_argument('-l', '--thresh-long', type = int, default = 32,
+ help = 'Maximum number of abuses per day to get banned')
+
+parser.add_argument('-d', '--debug', action = 'store_true')
+
+parser.add_argument('inputfifo', help = 'FIFO from which the log lines will be read')
+parser.add_argument('action', help = 'Executable to run. It will get two parameters:'
+ ' the action to take and the hostname/address of the offender')
+
+args = parser.parse_args(sys.argv[1:])
+
+progname = os.path.basename(sys.argv[0])
+
+logger = logging.getLogger(progname)
+loglevel = logging.DEBUG if args.debug else logging.INFO
+logger.setLevel(loglevel)
+
+formatter = logging.Formatter(fmt = progname + ': %(message)s')
+
+syslog = logging.handlers.SysLogHandler('/dev/log')
+
+handlers = [syslog]
+if args.debug:
+ handlers.append(logging.StreamHandler())
+
+for h in handlers:
+ h.setFormatter(formatter)
+ logger.addHandler(h)
+
+judge = Judge({ ACT_BAN_SHORT : args.thresh_short, ACT_BAN_MEDIUM : args.thresh_medium, ACT_BAN_LONG : args.thresh_long })
+
+# open FIFO read-write so poll() won't return HUP endlessly if the writer dies
+fifofd = os.open(args.inputfifo, os.O_RDWR | os.O_NONBLOCK)
+with open(fifofd) as fifo:
+ poll = select.epoll()
+ poll.register(fifofd, select.EPOLLIN)
+
+ while True:
+ for line in fifo:
+ line = line.strip()
+ if len(line) == 0:
+ continue
+
+ ts, msg = line.rstrip().split(maxsplit = 1)
+
+ logger.debug('processing message: %s' % msg)
+ res = process_msg(ts, msg)
+ if res is None:
+ logger.debug('message not matched')
+ continue
+
+ iff, host = res
+ verdict = judge.process(iff, host)
+ if verdict == ACT_NOTHING:
+ continue
+
+ logger.info('Action %s for: %s' % (verdict, host))
+ # TODO: rate-limit actions?
+ cmdline = [args.action, verdict, host]
+ res = subprocess.run(cmdline, capture_output = True, text = True)
+ if res.returncode != 0:
+ logger.error('Error running action "%s": return code %d' % (str(cmdline), res.returncode))
+ if res.stderr:
+ logger.error('stderr: ' + res.stderr)
+
+ logger.debug('polling input')
+ poll.poll()