diff --git a/config/filter.d/sshd.conf b/config/filter.d/sshd.conf
index b382ffc1..c61cf960 100644
--- a/config/filter.d/sshd.conf
+++ b/config/filter.d/sshd.conf
@@ -40,8 +40,8 @@ prefregex = ^%(__prefix_line)s%(__pref)s.+.* from ( via \S+)?%(__suff)s$
^User not known to the underlying authentication module for .* from %(__suff)s$
- ^Failed publickey for invalid user (?P\S+)|(?:(?! from ).)*? from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
- ^Failed \b(?!publickey)\S+ for (?Pinvalid user )?(?P\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+) from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
+ >
+ ^Failed for (?Pinvalid user )?(?P\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+) from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
^ROOT LOGIN REFUSED FROM
^[iI](?:llegal|nvalid) user .*? from %(__suff)s$
^User .+ from not allowed because not listed in AllowUsers%(__suff)s$
@@ -60,6 +60,12 @@ cmnfailre = ^[aA]uthentication (?:failure|error|failed) for .*
-other>
^Accepted \w+ for \S+ from (?:\s|$)
+cmnfailed-any = \S+
+cmnfailed-ignore = \b(?!publickey)\S+
+cmnfailed-invalid =
+cmnfailed-nofail = (?:publickey|\S+)
+cmnfailed = >
+
mdre-normal =
# used to differentiate "connection closed" with and without `[preauth]` (fail/nofail cases in ddos mode)
mdre-normal-other = ^(Connection closed|Disconnected) (?:by|from)%(__authng_user)s (?:%(__suff)s|\s*)$
@@ -84,6 +90,17 @@ mdre-aggressive = %(mdre-ddos)s
# mdre-extra-other is fully included within mdre-ddos-other:
mdre-aggressive-other = %(mdre-ddos-other)s
+# Parameter "publickey": nofail (default), invalid, any, ignore
+publickey = nofail
+# consider failed publickey for invalid users only:
+cmnfailre-failed-pub-invalid = ^Failed publickey for invalid user (?P\S+)|(?:(?! from ).)*? from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
+# consider failed publickey for valid users too (don't need RE, see cmnfailed):
+cmnfailre-failed-pub-any =
+# same as invalid, but consider failed publickey for valid users too, just as no failure (helper to get IP and user-name only, see cmnfailed):
+cmnfailre-failed-pub-nofail =
+# don't consider failed publickey as failures (don't need RE, see cmnfailed):
+cmnfailre-failed-pub-ignore =
+
cfooterre = ^Connection from
failregex = %(cmnfailre)s
diff --git a/fail2ban/client/fail2banregex.py b/fail2ban/client/fail2banregex.py
index afcb4282..a03125c3 100644
--- a/fail2ban/client/fail2banregex.py
+++ b/fail2ban/client/fail2banregex.py
@@ -493,8 +493,69 @@ class Fail2banRegex(object):
line = "\n".join(lines)
return line, ret, is_ignored
+ def _prepaireOutput(self):
+ """Prepares output- and fetch-function corresponding given '--out' option (format)"""
+ ofmt = self._opts.out
+ if ofmt in ('id', 'ip'):
+ def _out(ret):
+ for r in ret:
+ output(r[1])
+ elif ofmt == 'msg':
+ def _out(ret):
+ for r in ret:
+ for r in r[3].get('matches'):
+ if not isinstance(r, basestring):
+ r = ''.join(r for r in r)
+ output(r)
+ elif ofmt == 'row':
+ def _out(ret):
+ for r in ret:
+ output('[%r,\t%r,\t%r],' % (r[1],r[2],dict((k,v) for k, v in r[3].iteritems() if k != 'matches')))
+ elif '<' not in ofmt:
+ def _out(ret):
+ for r in ret:
+ output(r[3].get(ofmt))
+ else: # extended format with tags substitution:
+ from ..server.actions import Actions, CommandAction, BanTicket
+ def _escOut(t, v):
+ # use safe escape (avoid inject on pseudo tag "\x00msg\x00"):
+ if t not in ('msg',):
+ return v.replace('\x00', '\\x00')
+ return v
+ def _out(ret):
+ rows = []
+ wrap = {'NL':0}
+ for r in ret:
+ ticket = BanTicket(r[1], time=r[2], data=r[3])
+ aInfo = Actions.ActionInfo(ticket)
+ # if msg tag is used - output if single line (otherwise let it as is to wrap multilines later):
+ def _get_msg(self):
+ if not wrap['NL'] and len(r[3].get('matches', [])) <= 1:
+ return self['matches']
+ else: # pseudo tag for future replacement:
+ wrap['NL'] = 1
+ return "\x00msg\x00"
+ aInfo['msg'] = _get_msg
+ # not recursive interpolation (use safe escape):
+ v = CommandAction.replaceDynamicTags(ofmt, aInfo, escapeVal=_escOut)
+ if wrap['NL']: # contains multiline tags (msg):
+ rows.append((r, v))
+ continue
+ output(v)
+ # wrap multiline tag (msg) interpolations to single line:
+ for r, v in rows:
+ for r in r[3].get('matches'):
+ if not isinstance(r, basestring):
+ r = ''.join(r for r in r)
+ r = v.replace("\x00msg\x00", r)
+ output(r)
+ return _out
+
+
def process(self, test_lines):
t0 = time.time()
+ if self._opts.out: # get out function
+ out = self._prepaireOutput()
for line in test_lines:
if isinstance(line, tuple):
line_datetimestripped, ret, is_ignored = self.testRegex(
@@ -509,49 +570,35 @@ class Fail2banRegex(object):
if not is_ignored:
is_ignored = self.testIgnoreRegex(line_datetimestripped)
+ if self._opts.out: # (formated) output:
+ if len(ret) > 0: out(ret)
+ continue
+
if is_ignored:
self._line_stats.ignored += 1
if not self._print_no_ignored and (self._print_all_ignored or self._line_stats.ignored <= self._maxlines + 1):
self._line_stats.ignored_lines.append(line)
if self._debuggex:
self._line_stats.ignored_lines_timeextracted.append(line_datetimestripped)
-
- if len(ret) > 0:
- assert(not is_ignored)
- if self._opts.out:
- if self._opts.out in ('id', 'ip'):
- for ret in ret:
- output(ret[1])
- elif self._opts.out == 'msg':
- for ret in ret:
- for ret in ret[3].get('matches'):
- output(''.join(v for v in ret))
- elif self._opts.out == 'row':
- for ret in ret:
- output('[%r,\t%r,\t%r],' % (ret[1],ret[2],dict((k,v) for k, v in ret[3].iteritems() if k != 'matches')))
- else:
- for ret in ret:
- output(ret[3].get(self._opts.out))
- continue
+ elif len(ret) > 0:
self._line_stats.matched += 1
if self._print_all_matched:
self._line_stats.matched_lines.append(line)
if self._debuggex:
self._line_stats.matched_lines_timeextracted.append(line_datetimestripped)
else:
- if not is_ignored:
- self._line_stats.missed += 1
- if not self._print_no_missed and (self._print_all_missed or self._line_stats.missed <= self._maxlines + 1):
- self._line_stats.missed_lines.append(line)
- if self._debuggex:
- self._line_stats.missed_lines_timeextracted.append(line_datetimestripped)
+ self._line_stats.missed += 1
+ if not self._print_no_missed and (self._print_all_missed or self._line_stats.missed <= self._maxlines + 1):
+ self._line_stats.missed_lines.append(line)
+ if self._debuggex:
+ self._line_stats.missed_lines_timeextracted.append(line_datetimestripped)
self._line_stats.tested += 1
self._time_elapsed = time.time() - t0
def printLines(self, ltype):
lstats = self._line_stats
- assert(self._line_stats.missed == lstats.tested - (lstats.matched + lstats.ignored))
+ assert(lstats.missed == lstats.tested - (lstats.matched + lstats.ignored))
lines = lstats[ltype]
l = lstats[ltype + '_lines']
multiline = self._filter.getMaxLines() > 1
@@ -688,10 +735,10 @@ class Fail2banRegex(object):
test_lines = journal_lines_gen(flt, myjournal)
else:
# if single line parsing (without buffering)
- if self._filter.getMaxLines() <= 1:
+ if self._filter.getMaxLines() <= 1 and '\n' not in cmd_log:
self.output( "Use single line : %s" % shortstr(cmd_log.replace("\n", r"\n")) )
test_lines = [ cmd_log ]
- else: # multi line parsing (with buffering)
+ else: # multi line parsing (with and without buffering)
test_lines = cmd_log.split("\n")
self.output( "Use multi line : %s line(s)" % len(test_lines) )
for i, l in enumerate(test_lines):
diff --git a/fail2ban/server/action.py b/fail2ban/server/action.py
index 2a5bb704..3bc48fe0 100644
--- a/fail2ban/server/action.py
+++ b/fail2ban/server/action.py
@@ -772,7 +772,7 @@ class CommandAction(ActionBase):
ESCAPE_VN_CRE = re.compile(r"\W")
@classmethod
- def replaceDynamicTags(cls, realCmd, aInfo):
+ def replaceDynamicTags(cls, realCmd, aInfo, escapeVal=None):
"""Replaces dynamical tags in `query` with property values.
**Important**
@@ -797,16 +797,17 @@ class CommandAction(ActionBase):
# array for escaped vars:
varsDict = dict()
- def escapeVal(tag, value):
- # if the value should be escaped:
- if cls.ESCAPE_CRE.search(value):
- # That one needs to be escaped since its content is
- # out of our control
- tag = 'f2bV_%s' % cls.ESCAPE_VN_CRE.sub('_', tag)
- varsDict[tag] = value # add variable
- value = '$'+tag # replacement as variable
- # replacement for tag:
- return value
+ if not escapeVal:
+ def escapeVal(tag, value):
+ # if the value should be escaped:
+ if cls.ESCAPE_CRE.search(value):
+ # That one needs to be escaped since its content is
+ # out of our control
+ tag = 'f2bV_%s' % cls.ESCAPE_VN_CRE.sub('_', tag)
+ varsDict[tag] = value # add variable
+ value = '$'+tag # replacement as variable
+ # replacement for tag:
+ return value
# additional replacement as calling map:
ADD_REPL_TAGS_CM = CallingMap(ADD_REPL_TAGS)
diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py
index 3a100fbd..835e9b2b 100644
--- a/fail2ban/server/filter.py
+++ b/fail2ban/server/filter.py
@@ -655,30 +655,12 @@ class Filter(JailThread):
return users
return users
- # # ATM incremental (non-empty only) merge deactivated ...
- # @staticmethod
- # def _updateFailure(self, mlfidGroups, fail):
- # # reset old failure-ids when new types of id available in this failure:
- # fids = set()
- # for k in ('fid', 'ip4', 'ip6', 'dns'):
- # if fail.get(k):
- # fids.add(k)
- # if fids:
- # for k in ('fid', 'ip4', 'ip6', 'dns'):
- # if k not in fids:
- # try:
- # del mlfidGroups[k]
- # except:
- # pass
- # # update not empty values:
- # mlfidGroups.update(((k,v) for k,v in fail.iteritems() if v))
-
def _mergeFailure(self, mlfid, fail, failRegex):
mlfidFail = self.mlfidCache.get(mlfid) if self.__mlfidCache else None
users = None
nfflgs = 0
if fail.get("mlfgained"):
- nfflgs |= 9
+ nfflgs |= (8|1)
if not fail.get('nofail'):
fail['nofail'] = fail["mlfgained"]
elif fail.get('nofail'): nfflgs |= 1
@@ -689,13 +671,11 @@ class Filter(JailThread):
# update users set (hold all users of connect):
users = self._updateUsers(mlfidGroups, fail.get('user'))
# be sure we've correct current state ('nofail' and 'mlfgained' only from last failure)
- mlfidGroups.pop('nofail', None)
- mlfidGroups.pop('mlfgained', None)
- # # ATM incremental (non-empty only) merge deactivated (for future version only),
- # # it can be simulated using alternate value tags, like ...,
- # # so previous value 'val' will be overwritten only if 'alt_val' is not empty...
- # _updateFailure(mlfidGroups, fail)
- #
+ if mlfidGroups.pop('nofail', None): nfflgs |= 4
+ if mlfidGroups.pop('mlfgained', None): nfflgs |= 4
+ # if we had no pending failures then clear the matches (they are already provided):
+ if (nfflgs & 4) == 0 and not mlfidGroups.get('mlfpending', 0):
+ mlfidGroups.pop("matches", None)
# overwrite multi-line failure with all values, available in fail:
mlfidGroups.update(((k,v) for k,v in fail.iteritems() if v is not None))
# new merged failure data:
@@ -709,17 +689,18 @@ class Filter(JailThread):
self.mlfidCache.set(mlfid, mlfidFail)
# check users in order to avoid reset failure by multiple logon-attempts:
if fail.pop('mlfpending', 0) or users and len(users) > 1:
- # we've new user, reset 'nofail' because of multiple users attempts:
+ # we've pending failures or new user, reset 'nofail' because of failures or multiple users attempts:
fail.pop('nofail', None)
- nfflgs &= ~1 # reset nofail
+ fail.pop('mlfgained', None)
+ nfflgs &= ~(8|1) # reset nofail and gained
# merge matches:
- if not (nfflgs & 1): # current nofail state (corresponding users)
+ if (nfflgs & 1) == 0: # current nofail state (corresponding users)
m = fail.pop("nofail-matches", [])
m += fail.get("matches", [])
- if not (nfflgs & 8): # no gain signaled
+ if (nfflgs & 8) == 0: # no gain signaled
m += failRegex.getMatchedTupleLines()
fail["matches"] = m
- elif not (nfflgs & 2) and (nfflgs & 1): # not mlfforget and nofail:
+ elif (nfflgs & 3) == 1: # not mlfforget and nofail:
fail["nofail-matches"] = fail.get("nofail-matches", []) + failRegex.getMatchedTupleLines()
# return merged:
return fail
@@ -895,6 +876,9 @@ class Filter(JailThread):
# otherwise, try to use dns conversion:
else:
ips = DNSUtils.textToIp(host, self.__useDns)
+ # if checkAllRegex we must make a copy (to be sure next RE doesn't change merged/cached failure):
+ if self.checkAllRegex and mlfid is not None:
+ fail = fail.copy()
# append failure with match to the list:
for ip in ips:
failList.append([failRegexIndex, ip, date, fail])
diff --git a/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf b/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
index 549797af..d61a6520 100644
--- a/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
+++ b/fail2ban/tests/config/filter.d/zzz-sshd-obsolete-multiline.conf
@@ -37,7 +37,7 @@ __pam_auth = pam_[a-z]+
cmnfailre = ^%(__prefix_line_sl)s[aA]uthentication (?:failure|error|failed) for .* from ( via \S+)?\s*%(__suff)s$
^%(__prefix_line_sl)sUser not known to the underlying authentication module for .* from \s*%(__suff)s$
^%(__prefix_line_sl)sFailed \S+ for invalid user (?P\S+)|(?:(?! from ).)*? from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
- ^%(__prefix_line_sl)sFailed \b(?!publickey)\S+ for (?Pinvalid user )?(?P\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+) from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
+ ^%(__prefix_line_sl)sFailed (?:publickey|\S+) for (?Pinvalid user )?(?P\S+)|(?(cond_inv)(?:(?! from ).)*?|[^:]+) from %(__on_port_opt)s(?: ssh\d*)?(?(cond_user): |(?:(?:(?! from ).)*)$)
^%(__prefix_line_sl)sROOT LOGIN REFUSED FROM
^%(__prefix_line_sl)s[iI](?:llegal|nvalid) user .*? from %(__suff)s$
^%(__prefix_line_sl)sUser .+ from not allowed because not listed in AllowUsers\s*%(__suff)s$
diff --git a/fail2ban/tests/fail2banregextestcase.py b/fail2ban/tests/fail2banregextestcase.py
index c09c4171..8c6a0e47 100644
--- a/fail2ban/tests/fail2banregextestcase.py
+++ b/fail2ban/tests/fail2banregextestcase.py
@@ -83,13 +83,29 @@ def _test_exec_command_line(*args):
STR_00 = "Dec 31 11:59:59 [sshd] error: PAM: Authentication failure for kevin from 192.0.2.0"
RE_00 = r"(?:(?:Authentication failure|Failed [-/\w+]+) for(?: [iI](?:llegal|nvalid) user)?|[Ii](?:llegal|nvalid) user|ROOT LOGIN REFUSED) .*(?: from|FROM) "
-RE_00_ID = r"Authentication failure for .*? from $"
-RE_00_USER = r"Authentication failure for .*? from $"
+RE_00_ID = r"Authentication failure for .*? from $"
+RE_00_USER = r"Authentication failure for .*? from $"
FILENAME_01 = os.path.join(TEST_FILES_DIR, "testcase01.log")
FILENAME_02 = os.path.join(TEST_FILES_DIR, "testcase02.log")
FILENAME_WRONGCHAR = os.path.join(TEST_FILES_DIR, "testcase-wrong-char.log")
+# STR_ML_SSHD -- multiline log-excerpt with two sessions:
+# 192.0.2.1 (sshd[32307]) makes 2 failed attempts using public keys (without "Disconnecting: Too many authentication"),
+# and delayed success on accepted (STR_ML_SSHD_OK) or no success by close on preauth phase (STR_ML_SSHD_FAIL)
+# 192.0.2.2 (sshd[32310]) makes 2 failed attempts using public keys (with "Disconnecting: Too many authentication"),
+# and closed on preauth phase
+STR_ML_SSHD = """Nov 28 09:16:03 srv sshd[32307]: Failed publickey for git from 192.0.2.1 port 57904 ssh2: ECDSA 0e:ff:xx:xx:xx:xx:xx:xx:xx:xx:xx:...
+Nov 28 09:16:03 srv sshd[32307]: Failed publickey for git from 192.0.2.1 port 57904 ssh2: RSA 04:bc:xx:xx:xx:xx:xx:xx:xx:xx:xx:...
+Nov 28 09:16:03 srv sshd[32307]: Postponed publickey for git from 192.0.2.1 port 57904 ssh2 [preauth]
+Nov 28 09:16:05 srv sshd[32310]: Failed publickey for git from 192.0.2.2 port 57910 ssh2: ECDSA 1e:fe:xx:xx:xx:xx:xx:xx:xx:xx:xx:...
+Nov 28 09:16:05 srv sshd[32310]: Failed publickey for git from 192.0.2.2 port 57910 ssh2: RSA 14:ba:xx:xx:xx:xx:xx:xx:xx:xx:xx:...
+Nov 28 09:16:05 srv sshd[32310]: Disconnecting: Too many authentication failures for git [preauth]
+Nov 28 09:16:05 srv sshd[32310]: Connection closed by 192.0.2.2 [preauth]"""
+STR_ML_SSHD_OK = "Nov 28 09:16:06 srv sshd[32307]: Accepted publickey for git from 192.0.2.1 port 57904 ssh2: DSA 36:48:xx:xx:xx:xx:xx:xx:xx:xx:xx:..."
+STR_ML_SSHD_FAIL = "Nov 28 09:16:06 srv sshd[32307]: Connection closed by 192.0.2.1 [preauth]"
+
+
FILENAME_SSHD = os.path.join(TEST_FILES_DIR, "logs", "sshd")
FILTER_SSHD = os.path.join(CONFIG_DIR, 'filter.d', 'sshd.conf')
FILENAME_ZZZ_SSHD = os.path.join(TEST_FILES_DIR, 'zzz-sshd-obsolete-multiline.log')
@@ -291,10 +307,10 @@ class Fail2banRegexTest(LogCaptureTestCase):
#
self.assertTrue(_test_exec(
"--usedns", "no", "-d", "^Epoch", "--print-all-matched",
- "1490349000 FAIL: failure\nhost: 192.0.2.35",
+ "-L", "2", "1490349000 FAIL: failure\nhost: 192.0.2.35",
r"^\s*FAIL:\s*.*\nhost:\s+$"
))
- self.assertLogged('Lines: 1 lines, 0 ignored, 1 matched, 0 missed')
+ self.assertLogged('Lines: 2 lines, 0 ignored, 2 matched, 0 missed')
def testRegexEpochPatterns(self):
self.assertTrue(_test_exec(
@@ -340,6 +356,55 @@ class Fail2banRegexTest(LogCaptureTestCase):
self.assertTrue(_test_exec('-o', 'user', STR_00, RE_00_USER))
self.assertLogged('kevin')
self.pruneLog()
+ # complex substitution using tags (ip, user, family):
+ self.assertTrue(_test_exec('-o', ', , ', STR_00, RE_00_USER))
+ self.assertLogged('192.0.2.0, kevin, inet4')
+ self.pruneLog()
+
+ def testFrmtOutputWrapML(self):
+ unittest.F2B.SkipIfCfgMissing(stock=True)
+ # complex substitution using tags and message (ip, user, msg):
+ self.assertTrue(_test_exec('-o', ', , ',
+ '-c', CONFIG_DIR, '--usedns', 'no',
+ STR_ML_SSHD + "\n" + STR_ML_SSHD_OK, 'sshd[logtype=short, publickey=invalid]'))
+ # be sure we don't have IP in one line and have it in another:
+ lines = STR_ML_SSHD.split("\n")
+ self.assertTrue('192.0.2.2' not in lines[-2] and '192.0.2.2' in lines[-1])
+ # but both are in output "merged" with IP and user:
+ self.assertLogged(
+ '192.0.2.2, git, '+lines[-2],
+ '192.0.2.2, git, '+lines[-1],
+ all=True)
+ # nothing should be found for 192.0.2.1 (mode is not aggressive):
+ self.assertNotLogged('192.0.2.1, git, ')
+
+ # test with publickey (nofail) - would not produce output for 192.0.2.1 because accepted:
+ self.pruneLog("[test-phase 1] mode=aggressive & publickey=nofail + OK (accepted)")
+ self.assertTrue(_test_exec('-o', ', , ',
+ '-c', CONFIG_DIR, '--usedns', 'no',
+ STR_ML_SSHD + "\n" + STR_ML_SSHD_OK, 'sshd[logtype=short, mode=aggressive]'))
+ self.assertLogged(
+ '192.0.2.2, git, '+lines[-4],
+ '192.0.2.2, git, '+lines[-3],
+ '192.0.2.2, git, '+lines[-2],
+ '192.0.2.2, git, '+lines[-1],
+ all=True)
+ # nothing should be found for 192.0.2.1 (access gained so failures ignored):
+ self.assertNotLogged('192.0.2.1, git, ')
+
+ # now same test but "accepted" replaced with "closed" on preauth phase:
+ self.pruneLog("[test-phase 2] mode=aggressive & publickey=nofail + FAIL (closed on preauth)")
+ self.assertTrue(_test_exec('-o', ', , ',
+ '-c', CONFIG_DIR, '--usedns', 'no',
+ STR_ML_SSHD + "\n" + STR_ML_SSHD_FAIL, 'sshd[logtype=short, mode=aggressive]'))
+ # 192.0.2.1 should be found for every failure (2x failed key + 1x closed):
+ lines = STR_ML_SSHD.split("\n")[0:2] + STR_ML_SSHD_FAIL.split("\n")[-1:]
+ self.assertLogged(
+ '192.0.2.1, git, '+lines[-3],
+ '192.0.2.1, git, '+lines[-2],
+ '192.0.2.1, git, '+lines[-1],
+ all=True)
+
def testWrongFilterFile(self):
# use test log as filter file to cover eror cases...
diff --git a/fail2ban/tests/files/logs/sshd b/fail2ban/tests/files/logs/sshd
index 2b8e6621..3b4f0a0a 100644
--- a/fail2ban/tests/files/logs/sshd
+++ b/fail2ban/tests/files/logs/sshd
@@ -166,9 +166,11 @@ Nov 28 09:16:03 srv sshd[32307]: Connection closed by 192.0.2.1
Nov 28 09:16:05 srv sshd[32310]: Failed publickey for git from 192.0.2.111 port 57910 ssh2: ECDSA 1e:fe:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx
# failJSON: { "match": false }
Nov 28 09:16:05 srv sshd[32310]: Failed publickey for git from 192.0.2.111 port 57910 ssh2: RSA 14:ba:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx
-# failJSON: { "match": false }
+# failJSON: { "constraint": "name == 'sshd'", "time": "2004-11-28T09:16:05", "match": true , "attempts": 3, "desc": "Should catch failure - no success/no accepted public key" }
Nov 28 09:16:05 srv sshd[32310]: Disconnecting: Too many authentication failures for git [preauth]
-# failJSON: { "time": "2004-11-28T09:16:05", "match": true , "host": "192.0.2.111", "desc": "Should catch failure - no success/no accepted public key" }
+# failJSON: { "constraint": "opts.get('mode') != 'aggressive'", "match": false, "desc": "Nofail in normal mode, failure already produced above" }
+Nov 28 09:16:05 srv sshd[32310]: Connection closed by 192.0.2.111 [preauth]
+# failJSON: { "constraint": "opts.get('mode') == 'aggressive'", "time": "2004-11-28T09:16:05", "match": true , "host": "192.0.2.111", "attempts":1, "desc": "Matches in aggressive mode only" }
Nov 28 09:16:05 srv sshd[32310]: Connection closed by 192.0.2.111 [preauth]
# failJSON: { "match": false }
@@ -215,7 +217,7 @@ Apr 27 13:02:04 host sshd[29116]: Received disconnect from 1.2.3.4: 11: Normal S
# Match sshd auth errors on OpenSUSE systems (gh-1024)
# failJSON: { "match": false, "desc": "No failure until closed or another fail (e. g. F-MLFFORGET by success/accepted password can avoid failure, see gh-2070)" }
2015-04-16T18:02:50.321974+00:00 host sshd[2716]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=192.0.2.112 user=root
-# failJSON: { "time": "2015-04-16T20:02:50", "match": true , "host": "192.0.2.112", "desc": "Should catch failure - no success/no accepted password" }
+# failJSON: { "constraint": "opts.get('mode') == 'aggressive'", "time": "2015-04-16T20:02:50", "match": true , "host": "192.0.2.112", "desc": "Should catch failure - no success/no accepted password" }
2015-04-16T18:02:50.568798+00:00 host sshd[2716]: Connection closed by 192.0.2.112 [preauth]
# disable this test-cases block for obsolete multi-line filter (zzz-sshd-obsolete...):
@@ -238,7 +240,7 @@ Mar 7 18:53:20 bar sshd[1556]: Connection closed by 192.0.2.113
Mar 7 18:53:22 bar sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.114
# failJSON: { "time": "2005-03-07T18:53:23", "match": true , "attempts": 2, "users": ["root", "sudoer"], "host": "192.0.2.114", "desc": "Failure: attempt 2nd user" }
Mar 7 18:53:23 bar sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=sudoer rhost=192.0.2.114
-# failJSON: { "time": "2005-03-07T18:53:24", "match": true , "attempts": 2, "users": ["root", "sudoer", "known"], "host": "192.0.2.114", "desc": "Failure: attempt 3rd user" }
+# failJSON: { "time": "2005-03-07T18:53:24", "match": true , "attempts": 1, "users": ["root", "sudoer", "known"], "host": "192.0.2.114", "desc": "Failure: attempt 3rd user" }
Mar 7 18:53:24 bar sshd[1558]: Accepted password for known from 192.0.2.114 port 52100 ssh2
# failJSON: { "match": false , "desc": "No failure" }
Mar 7 18:53:24 bar sshd[1558]: pam_unix(sshd:session): session opened for user known by (uid=0)
@@ -248,14 +250,14 @@ Mar 7 18:53:24 bar sshd[1558]: pam_unix(sshd:session): session opened for user
Mar 7 18:53:32 bar sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
# failJSON: { "match": false , "desc": "Still no failure (second try, same user)" }
Mar 7 18:53:32 bar sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
-# failJSON: { "time": "2005-03-07T18:53:34", "match": true , "attempts": 2, "users": ["root", "known"], "host": "192.0.2.116", "desc": "Failure: attempt 2nd user" }
+# failJSON: { "time": "2005-03-07T18:53:34", "match": true , "attempts": 3, "users": ["root", "known"], "host": "192.0.2.116", "desc": "Failure: attempt 2nd user" }
Mar 7 18:53:34 bar sshd[1559]: Accepted password for known from 192.0.2.116 port 52100 ssh2
# failJSON: { "match": false , "desc": "No failure" }
Mar 7 18:53:38 bar sshd[1559]: Connection closed by 192.0.2.116
# failJSON: { "time": "2005-03-19T16:47:48", "match": true , "attempts": 1, "user": "admin", "host": "192.0.2.117", "desc": "Failure: attempt invalid user" }
Mar 19 16:47:48 test sshd[5672]: Invalid user admin from 192.0.2.117 port 44004
-# failJSON: { "time": "2005-03-19T16:47:49", "match": true , "attempts": 2, "user": "admin", "host": "192.0.2.117", "desc": "Failure: attempt to change user (disallowed)" }
+# failJSON: { "time": "2005-03-19T16:47:49", "match": true , "attempts": 1, "user": "admin", "host": "192.0.2.117", "desc": "Failure: attempt to change user (disallowed)" }
Mar 19 16:47:49 test sshd[5672]: Disconnecting invalid user admin 192.0.2.117 port 44004: Change of username or service not allowed: (admin,ssh-connection) -> (user,ssh-connection) [preauth]
# failJSON: { "time": "2005-03-19T16:47:50", "match": false, "desc": "Disconnected during preauth phase (no failure in normal mode)" }
Mar 19 16:47:50 srv sshd[5672]: Disconnected from authenticating user admin 192.0.2.6 port 33553 [preauth]
@@ -332,7 +334,7 @@ Nov 26 13:03:30 srv sshd[45]: fatal: Unable to negotiate with 192.0.2.2 port 554
Nov 26 15:03:30 host sshd[22440]: Connection from 192.0.2.3 port 39678 on 192.168.1.9 port 22
# failJSON: { "time": "2004-11-26T15:03:31", "match": true , "host": "192.0.2.3", "desc": "Multiline - no matching key exchange method" }
Nov 26 15:03:31 host sshd[22440]: fatal: Unable to negotiate a key exchange method [preauth]
-# failJSON: { "time": "2004-11-26T15:03:32", "match": true , "host": "192.0.2.3", "filter": "sshd", "desc": "Second attempt within the same connect" }
+# failJSON: { "time": "2004-11-26T15:03:32", "match": true , "host": "192.0.2.3", "constraint": "name == 'sshd'", "desc": "Second attempt within the same connect" }
Nov 26 15:03:32 host sshd[22440]: fatal: Unable to negotiate a key exchange method [preauth]
# gh-1943 (previous OpenSSH log-format)
diff --git a/fail2ban/tests/files/logs/sshd-journal b/fail2ban/tests/files/logs/sshd-journal
index ba755645..d19889d7 100644
--- a/fail2ban/tests/files/logs/sshd-journal
+++ b/fail2ban/tests/files/logs/sshd-journal
@@ -167,9 +167,11 @@ srv sshd[32307]: Connection closed by 192.0.2.1
srv sshd[32310]: Failed publickey for git from 192.0.2.111 port 57910 ssh2: ECDSA 1e:fe:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx
# failJSON: { "match": false }
srv sshd[32310]: Failed publickey for git from 192.0.2.111 port 57910 ssh2: RSA 14:ba:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx:xx
-# failJSON: { "match": false }
+# failJSON: { "match": true , "attempts": 3, "desc": "Should catch failure - no success/no accepted public key" }
srv sshd[32310]: Disconnecting: Too many authentication failures for git [preauth]
-# failJSON: { "match": true , "host": "192.0.2.111", "desc": "Should catch failure - no success/no accepted public key" }
+# failJSON: { "constraint": "opts.get('mode') != 'aggressive'", "match": false, "desc": "Nofail in normal mode, failure already produced above" }
+srv sshd[32310]: Connection closed by 192.0.2.111 [preauth]
+# failJSON: { "constraint": "opts.get('mode') == 'aggressive'", "match": true , "host": "192.0.2.111", "attempts":1, "desc": "Matches in aggressive mode only" }
srv sshd[32310]: Connection closed by 192.0.2.111 [preauth]
# failJSON: { "match": false }
@@ -216,7 +218,7 @@ srv sshd[29116]: Received disconnect from 1.2.3.4: 11: Normal Shutdown, Thank yo
# Match sshd auth errors on OpenSUSE systems (gh-1024)
# failJSON: { "match": false, "desc": "No failure until closed or another fail (e. g. F-MLFFORGET by success/accepted password can avoid failure, see gh-2070)" }
srv sshd[2716]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser= rhost=192.0.2.112 user=root
-# failJSON: { "match": true , "host": "192.0.2.112", "desc": "Should catch failure - no success/no accepted password" }
+# failJSON: { "constraint": "opts.get('mode') == 'aggressive'", "match": true , "host": "192.0.2.112", "desc": "Should catch failure - no success/no accepted password" }
srv sshd[2716]: Connection closed by 192.0.2.112 [preauth]
# filterOptions: [{}]
@@ -238,7 +240,7 @@ srv sshd[1556]: Connection closed by 192.0.2.113
srv sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.114
# failJSON: { "match": true , "attempts": 2, "users": ["root", "sudoer"], "host": "192.0.2.114", "desc": "Failure: attempt 2nd user" }
srv sshd[1558]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=sudoer rhost=192.0.2.114
-# failJSON: { "match": true , "attempts": 2, "users": ["root", "sudoer", "known"], "host": "192.0.2.114", "desc": "Failure: attempt 3rd user" }
+# failJSON: { "match": true , "attempts": 1, "users": ["root", "sudoer", "known"], "host": "192.0.2.114", "desc": "Failure: attempt 3rd user" }
srv sshd[1558]: Accepted password for known from 192.0.2.114 port 52100 ssh2
# failJSON: { "match": false , "desc": "No failure" }
srv sshd[1558]: pam_unix(sshd:session): session opened for user known by (uid=0)
@@ -248,14 +250,14 @@ srv sshd[1558]: pam_unix(sshd:session): session opened for user known by (uid=0)
srv sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
# failJSON: { "match": false , "desc": "Still no failure (second try, same user)" }
srv sshd[1559]: pam_unix(sshd:auth): authentication failure; logname= uid=0 euid=0 tty=ssh ruser=root rhost=192.0.2.116
-# failJSON: { "match": true , "attempts": 2, "users": ["root", "known"], "host": "192.0.2.116", "desc": "Failure: attempt 2nd user" }
+# failJSON: { "match": true , "attempts": 3, "users": ["root", "known"], "host": "192.0.2.116", "desc": "Failure: attempt 2nd user" }
srv sshd[1559]: Accepted password for known from 192.0.2.116 port 52100 ssh2
# failJSON: { "match": false , "desc": "No failure" }
srv sshd[1559]: Connection closed by 192.0.2.116
# failJSON: { "match": true , "attempts": 1, "user": "admin", "host": "192.0.2.117", "desc": "Failure: attempt invalid user" }
srv sshd[5672]: Invalid user admin from 192.0.2.117 port 44004
-# failJSON: { "match": true , "attempts": 2, "user": "admin", "host": "192.0.2.117", "desc": "Failure: attempt to change user (disallowed)" }
+# failJSON: { "match": true , "attempts": 1, "user": "admin", "host": "192.0.2.117", "desc": "Failure: attempt to change user (disallowed)" }
srv sshd[5672]: Disconnecting invalid user admin 192.0.2.117 port 44004: Change of username or service not allowed: (admin,ssh-connection) -> (user,ssh-connection) [preauth]
# failJSON: { "match": false, "desc": "Disconnected during preauth phase (no failure in normal mode)" }
srv sshd[5672]: Disconnected from authenticating user admin 192.0.2.6 port 33553 [preauth]
@@ -325,7 +327,7 @@ srv sshd[45]: fatal: Unable to negotiate with 192.0.2.2 port 55419: no matching
srv sshd[22440]: Connection from 192.0.2.3 port 39678 on 192.168.1.9 port 22
# failJSON: { "match": true , "host": "192.0.2.3", "desc": "Multiline - no matching key exchange method" }
srv sshd[22440]: fatal: Unable to negotiate a key exchange method [preauth]
-# failJSON: { "match": true , "host": "192.0.2.3", "filter": "sshd", "desc": "Second attempt within the same connect" }
+# failJSON: { "match": true , "host": "192.0.2.3", "constraint": "name == 'sshd'", "desc": "Second attempt within the same connect" }
srv sshd[22440]: fatal: Unable to negotiate a key exchange method [preauth]
# gh-1943 (previous OpenSSH log-format)
diff --git a/fail2ban/tests/samplestestcase.py b/fail2ban/tests/samplestestcase.py
index 0bbd05f5..9908cba3 100644
--- a/fail2ban/tests/samplestestcase.py
+++ b/fail2ban/tests/samplestestcase.py
@@ -216,9 +216,11 @@ def testSampleRegexsFactory(name, basedir):
# process line using several filter options (if specified in the test-file):
for fltName, flt, opts in self._filterTests:
+ # Bypass if constraint (as expression) is not valid:
+ if faildata.get('constraint') and not eval(faildata['constraint']):
+ continue
flt, regexsUsedIdx = flt
regexList = flt.getFailRegex()
-
failregex = -1
try:
fail = {}
@@ -229,20 +231,23 @@ def testSampleRegexsFactory(name, basedir):
if opts.get('test.prefix-line'): # journal backends creates common prefix-line:
line = opts.get('test.prefix-line') + line
ret = flt.processLine(('', TEST_NOW_STR, line.rstrip('\r\n')), TEST_NOW)
- if not ret:
- # Bypass if filter constraint specified:
- if faildata.get('filter') and name != faildata.get('filter'):
- continue
- # Check line is flagged as none match
- self.assertFalse(faildata.get('match', True),
- "Line not matched when should have")
- continue
+ if ret:
+ # filter matched only (in checkAllRegex mode it could return 'nofail' too):
+ found = []
+ for ret in ret:
+ failregex, fid, fail2banTime, fail = ret
+ # bypass pending and nofail:
+ if fid is None or fail.get('nofail'):
+ regexsUsedIdx.add(failregex)
+ regexsUsedRe.add(regexList[failregex])
+ continue
+ found.append(ret)
+ ret = found
- failregex, fid, fail2banTime, fail = ret[0]
- # Bypass no failure helpers-regexp:
- if not faildata.get('match', False) and (fid is None or fail.get('nofail')):
- regexsUsedIdx.add(failregex)
- regexsUsedRe.add(regexList[failregex])
+ if not ret:
+ # Check line is flagged as none match
+ self.assertFalse(faildata.get('match', False),
+ "Line not matched when should have")
continue
# Check line is flagged to match
@@ -251,39 +256,41 @@ def testSampleRegexsFactory(name, basedir):
self.assertEqual(len(ret), 1,
"Multiple regexs matched %r" % (map(lambda x: x[0], ret)))
- # Verify match captures (at least fid/host) and timestamp as expected
- for k, v in faildata.iteritems():
- if k not in ("time", "match", "desc", "filter"):
- fv = fail.get(k, None)
- if fv is None:
- # Fallback for backwards compatibility (previously no fid, was host only):
- if k == "host":
- fv = fid
- # special case for attempts counter:
- if k == "attempts":
- fv = len(fail.get('matches', {}))
- # compare sorted (if set)
- if isinstance(fv, (set, list, dict)):
- self.assertSortedEqual(fv, v)
- continue
- self.assertEqual(fv, v)
+ for ret in ret:
+ failregex, fid, fail2banTime, fail = ret
+ # Verify match captures (at least fid/host) and timestamp as expected
+ for k, v in faildata.iteritems():
+ if k not in ("time", "match", "desc", "constraint"):
+ fv = fail.get(k, None)
+ if fv is None:
+ # Fallback for backwards compatibility (previously no fid, was host only):
+ if k == "host":
+ fv = fid
+ # special case for attempts counter:
+ if k == "attempts":
+ fv = len(fail.get('matches', {}))
+ # compare sorted (if set)
+ if isinstance(fv, (set, list, dict)):
+ self.assertSortedEqual(fv, v)
+ continue
+ self.assertEqual(fv, v)
- t = faildata.get("time", None)
- if t is not None:
- try:
- jsonTimeLocal = datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S")
- except ValueError:
- jsonTimeLocal = datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S.%f")
- jsonTime = time.mktime(jsonTimeLocal.timetuple())
- jsonTime += jsonTimeLocal.microsecond / 1000000.0
- self.assertEqual(fail2banTime, jsonTime,
- "UTC Time mismatch %s (%s) != %s (%s) (diff %.3f seconds)" %
- (fail2banTime, time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(fail2banTime)),
- jsonTime, time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(jsonTime)),
- fail2banTime - jsonTime) )
+ t = faildata.get("time", None)
+ if t is not None:
+ try:
+ jsonTimeLocal = datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S")
+ except ValueError:
+ jsonTimeLocal = datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S.%f")
+ jsonTime = time.mktime(jsonTimeLocal.timetuple())
+ jsonTime += jsonTimeLocal.microsecond / 1000000.0
+ self.assertEqual(fail2banTime, jsonTime,
+ "UTC Time mismatch %s (%s) != %s (%s) (diff %.3f seconds)" %
+ (fail2banTime, time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(fail2banTime)),
+ jsonTime, time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(jsonTime)),
+ fail2banTime - jsonTime) )
- regexsUsedIdx.add(failregex)
- regexsUsedRe.add(regexList[failregex])
+ regexsUsedIdx.add(failregex)
+ regexsUsedRe.add(regexList[failregex])
except AssertionError as e: # pragma: no cover
import pprint
raise AssertionError("%s: %s on: %s:%i, line:\n %sregex (%s):\n %s\n"