From dd8081ade5ace23782efcf0c96b7f8c9f16228a7 Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 12 Jun 2020 20:00:42 +0200 Subject: [PATCH 01/10] extends capturing alternate tags in filter, implementing new tag prefix `` with all value of tags), for examples see new tests in fail2banregextestcase; closes gh-2755 (extends #1454 and #1698). --- fail2ban/server/action.py | 2 +- fail2ban/server/failregex.py | 56 +++++++++++++++++-------- fail2ban/server/ipdns.py | 2 +- fail2ban/tests/fail2banregextestcase.py | 17 ++++++++ 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/fail2ban/server/action.py b/fail2ban/server/action.py index d0262171..1c313cf0 100644 --- a/fail2ban/server/action.py +++ b/fail2ban/server/action.py @@ -868,7 +868,7 @@ class CommandAction(ActionBase): tickData = aInfo.get("F-*") if not tickData: tickData = {} def substTag(m): - tag = mapTag2Opt(m.groups()[0]) + tag = mapTag2Opt(m.group(1)) try: value = uni_string(tickData[tag]) except KeyError: diff --git a/fail2ban/server/failregex.py b/fail2ban/server/failregex.py index 0ae9acc5..9bbf5779 100644 --- a/fail2ban/server/failregex.py +++ b/fail2ban/server/failregex.py @@ -87,20 +87,24 @@ RH4TAG = { # default failure groups map for customizable expressions (with different group-id): R_MAP = { - "ID": "fid", - "PORT": "fport", + "id": "fid", + "port": "fport", } def mapTag2Opt(tag): - try: # if should be mapped: - return R_MAP[tag] - except KeyError: - return tag.lower() + tag = tag.lower() + return R_MAP.get(tag, tag) -# alternate names to be merged, e. g. alt_user_1 -> user ... +# complex names: +# ALT_ - alternate names to be merged, e. g. alt_user_1 -> user ... ALTNAME_PRE = 'alt_' -ALTNAME_CRE = re.compile(r'^' + ALTNAME_PRE + r'(.*)(?:_\d+)?$') +# TUPLE_ - names of parts to be combined to single value as tuple +TUPNAME_PRE = 'tuple_' + +COMPLNAME_PRE = (ALTNAME_PRE, TUPNAME_PRE) +COMPLNAME_CRE = re.compile(r'^(' + '|'.join(COMPLNAME_PRE) + r')(.*?)(?:_\d+)?$') + ## # Regular expression class. @@ -128,18 +132,23 @@ class Regex: self._regexObj = re.compile(regex, re.MULTILINE if multiline else 0) self._regex = regex self._altValues = {} + self._tupleValues = {} for k in filter( - lambda k: len(k) > len(ALTNAME_PRE) and k.startswith(ALTNAME_PRE), - self._regexObj.groupindex + lambda k: len(k) > len(COMPLNAME_PRE[0]), self._regexObj.groupindex ): - n = ALTNAME_CRE.match(k).group(1) - self._altValues[k] = n + n = COMPLNAME_CRE.match(k) + if n: + if n.group(1) == ALTNAME_PRE: + self._altValues[k] = mapTag2Opt(n.group(2)) + else: + self._tupleValues[k] = mapTag2Opt(n.group(2)) self._altValues = list(self._altValues.items()) if len(self._altValues) else None + self._tupleValues = list(self._tupleValues.items()) if len(self._tupleValues) else None except sre_constants.error: raise RegexException("Unable to compile regular expression '%s'" % regex) # set fetch handler depending on presence of alternate tags: - self.getGroups = self._getGroupsWithAlt if self._altValues else self._getGroups + self.getGroups = self._getGroupsWithAlt if (self._altValues or self._tupleValues) else self._getGroups def __str__(self): return "%s(%r)" % (self.__class__.__name__, self._regex) @@ -284,12 +293,23 @@ class Regex: def _getGroupsWithAlt(self): fail = self._matchCache.groupdict() - # merge alternate values (e. g. 'alt_user_1' -> 'user' or 'alt_host' -> 'host'): #fail = fail.copy() - for k,n in self._altValues: - v = fail.get(k) - if v and not fail.get(n): - fail[n] = v + # merge alternate values (e. g. 'alt_user_1' -> 'user' or 'alt_host' -> 'host'): + if self._altValues: + for k,n in self._altValues: + v = fail.get(k) + if v and not fail.get(n): + fail[n] = v + # combine tuple values (e. g. 'id', 'tuple_id' ... 'tuple_id_N' -> 'id'): + if self._tupleValues: + for k,n in self._tupleValues: + v = fail.get(k) + t = fail.get(n) + if isinstance(t, tuple): + t += (v,) + else: + t = (t,v,) + fail[n] = t return fail def getGroups(self): # pragma: no cover - abstract function (replaced in __init__) diff --git a/fail2ban/server/ipdns.py b/fail2ban/server/ipdns.py index 335fc473..18e1bd02 100644 --- a/fail2ban/server/ipdns.py +++ b/fail2ban/server/ipdns.py @@ -337,7 +337,7 @@ class IPAddr(object): return repr(self.ntoa) def __str__(self): - return self.ntoa + return self.ntoa if isinstance(self.ntoa, basestring) else str(self.ntoa) def __reduce__(self): """IPAddr pickle-handler, that simply wraps IPAddr to the str diff --git a/fail2ban/tests/fail2banregextestcase.py b/fail2ban/tests/fail2banregextestcase.py index 8c6a0e47..334fad1c 100644 --- a/fail2ban/tests/fail2banregextestcase.py +++ b/fail2ban/tests/fail2banregextestcase.py @@ -340,6 +340,23 @@ class Fail2banRegexTest(LogCaptureTestCase): self.assertTrue(_test_exec('-o', 'id', STR_00, RE_00_ID)) self.assertLogged('kevin') self.pruneLog() + # multiple id combined to a tuple (id, tuple_id): + self.assertTrue(_test_exec('-o', 'id', + '1591983743.667 192.0.2.1 192.0.2.2', + r'^\s* \S+')) + self.assertLogged(str(('192.0.2.1', '192.0.2.2'))) + self.pruneLog() + # multiple id combined to a tuple, id first - (id, tuple_id_1, tuple_id_2): + self.assertTrue(_test_exec('-o', 'id', + '1591983743.667 left 192.0.2.3 right', + r'^\s*\S+ \S+')) + self.pruneLog() + # id had higher precedence as ip-address: + self.assertTrue(_test_exec('-o', 'id', + '1591983743.667 left [192.0.2.4]:12345 right', + r'^\s*\S+ : \S+')) + self.assertLogged(str(('[192.0.2.4]:12345', 'left', 'right'))) + self.pruneLog() # row with id : self.assertTrue(_test_exec('-o', 'row', STR_00, RE_00_ID)) self.assertLogged("['kevin'", "'ip4': '192.0.2.0'", "'fid': 'kevin'", all=True) From ec3000798d9b42c3f05d40e810f43ae444c14d97 Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 12 Jun 2020 21:25:42 +0200 Subject: [PATCH 02/10] ensure that set of alternate tags or combine tuple tags take place ordered (sort the lists by its name or index) --- fail2ban/server/failregex.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/fail2ban/server/failregex.py b/fail2ban/server/failregex.py index 9bbf5779..4032ecdb 100644 --- a/fail2ban/server/failregex.py +++ b/fail2ban/server/failregex.py @@ -131,23 +131,26 @@ class Regex: try: self._regexObj = re.compile(regex, re.MULTILINE if multiline else 0) self._regex = regex - self._altValues = {} - self._tupleValues = {} + self._altValues = [] + self._tupleValues = [] for k in filter( lambda k: len(k) > len(COMPLNAME_PRE[0]), self._regexObj.groupindex ): n = COMPLNAME_CRE.match(k) if n: - if n.group(1) == ALTNAME_PRE: - self._altValues[k] = mapTag2Opt(n.group(2)) + g, n = n.group(1), mapTag2Opt(n.group(2)) + if g == ALTNAME_PRE: + self._altValues.append((k,n)) else: - self._tupleValues[k] = mapTag2Opt(n.group(2)) - self._altValues = list(self._altValues.items()) if len(self._altValues) else None - self._tupleValues = list(self._tupleValues.items()) if len(self._tupleValues) else None + self._tupleValues.append((k,n)) + self._altValues.sort() + self._tupleValues.sort() + self._altValues = self._altValues if len(self._altValues) else None + self._tupleValues = self._tupleValues if len(self._tupleValues) else None except sre_constants.error: raise RegexException("Unable to compile regular expression '%s'" % regex) - # set fetch handler depending on presence of alternate tags: + # set fetch handler depending on presence of alternate (or tuple) tags: self.getGroups = self._getGroupsWithAlt if (self._altValues or self._tupleValues) else self._getGroups def __str__(self): From 309c8dddd7adc2de140ed5a72088cd4f2dcc9b91 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 24 Jun 2020 19:20:36 +0200 Subject: [PATCH 03/10] action.d/nftables.conf (type=multiport only): fixed port range selector (replacing `:` with `-`) --- config/action.d/nftables.conf | 2 +- fail2ban/tests/servertestcase.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/action.d/nftables.conf b/config/action.d/nftables.conf index c1fb8550..77cf3661 100644 --- a/config/action.d/nftables.conf +++ b/config/action.d/nftables.conf @@ -34,7 +34,7 @@ type = multiport rule_match-custom = rule_match-allports = meta l4proto \{ \} -rule_match-multiport = $proto dport \{ \} +rule_match-multiport = $proto dport \{ $(echo '' | sed s/:/-/g) \} match = > # Option: rule_stat diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index b771ab50..f1b667b1 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -1296,11 +1296,11 @@ class ServerConfigReaderTests(LogCaptureTestCase): ), 'ip4-start': ( r"`nft add set inet f2b-table addr-set-j-w-nft-mp \{ type ipv4_addr\; \}`", - r"`nft add rule inet f2b-table f2b-chain $proto dport \{ http,https \} ip saddr @addr-set-j-w-nft-mp reject`", + r"`nft add rule inet f2b-table f2b-chain $proto dport \{ $(echo 'http,https' | sed s/:/-/g) \} ip saddr @addr-set-j-w-nft-mp reject`", ), 'ip6-start': ( r"`nft add set inet f2b-table addr6-set-j-w-nft-mp \{ type ipv6_addr\; \}`", - r"`nft add rule inet f2b-table f2b-chain $proto dport \{ http,https \} ip6 saddr @addr6-set-j-w-nft-mp reject`", + r"`nft add rule inet f2b-table f2b-chain $proto dport \{ $(echo 'http,https' | sed s/:/-/g) \} ip6 saddr @addr6-set-j-w-nft-mp reject`", ), 'flush': ( "`{ nft flush set inet f2b-table addr-set-j-w-nft-mp 2> /dev/null; } || ", From 08dbe4abd593c09be8be9101964c2ee1915374f5 Mon Sep 17 00:00:00 2001 From: "Sergey G. Brester" Date: Fri, 3 Jul 2020 13:45:29 +0200 Subject: [PATCH 04/10] fixed comment for loglevel, default is INFO --- config/fail2ban.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/fail2ban.conf b/config/fail2ban.conf index ba0e9204..f3867839 100644 --- a/config/fail2ban.conf +++ b/config/fail2ban.conf @@ -19,7 +19,7 @@ # NOTICE # INFO # DEBUG -# Values: [ LEVEL ] Default: ERROR +# Values: [ LEVEL ] Default: INFO # loglevel = INFO From ea35f2ad754810c51f5cf01d5fa3018002917a50 Mon Sep 17 00:00:00 2001 From: "Sergey G. Brester" Date: Fri, 3 Jul 2020 13:47:46 +0200 Subject: [PATCH 05/10] default loglevel is INFO --- man/jail.conf.5 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/man/jail.conf.5 b/man/jail.conf.5 index 662b3f48..4d01b6a1 100644 --- a/man/jail.conf.5 +++ b/man/jail.conf.5 @@ -130,7 +130,7 @@ Comments: use '#' for comment lines and '; ' (space is important) for inline com The items that can be set in section [Definition] are: .TP .B loglevel -verbosity level of log output: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACEDEBUG, HEAVYDEBUG or corresponding numeric value (50-5). Default: ERROR (equal 40) +verbosity level of log output: CRITICAL, ERROR, WARNING, NOTICE, INFO, DEBUG, TRACEDEBUG, HEAVYDEBUG or corresponding numeric value (50-5). Default: INFO (equal 20) .TP .B logtarget log target: filename, SYSLOG, STDERR or STDOUT. Default: STDOUT if not set in fail2ban.conf/fail2ban.local From 73a8175bb00ed2f456448f6edcfbf78ab7f0630e Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 4 Aug 2020 13:22:02 +0200 Subject: [PATCH 06/10] resolves names conflict (command action timeout and ipset timeout); closes gh-2790 --- config/action.d/firewallcmd-ipset.conf | 14 +++++++------- .../action.d/iptables-ipset-proto6-allports.conf | 14 +++++++------- config/action.d/iptables-ipset-proto6.conf | 14 +++++++------- config/action.d/shorewall-ipset-proto6.conf | 14 +++++++------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/config/action.d/firewallcmd-ipset.conf b/config/action.d/firewallcmd-ipset.conf index 9dd9fbb2..42513933 100644 --- a/config/action.d/firewallcmd-ipset.conf +++ b/config/action.d/firewallcmd-ipset.conf @@ -18,7 +18,7 @@ before = firewallcmd-common.conf [Definition] -actionstart = ipset create hash:ip timeout +actionstart = ipset create hash:ip timeout firewall-cmd --direct --add-rule filter 0 -m set --match-set src -j actionflush = ipset flush @@ -27,7 +27,7 @@ actionstop = firewall-cmd --direct --remove-rule filter 0 ipset destroy -actionban = ipset add timeout -exist +actionban = ipset add timeout -exist actionunban = ipset del -exist @@ -40,18 +40,18 @@ actionunban = ipset del -exist # chain = INPUT_direct -# Option: default-timeout +# Option: default-ipsettime # Notes: specifies default timeout in seconds (handled default ipset timeout only) # Values: [ NUM ] Default: 0 (no timeout, managed by fail2ban by unban) -default-timeout = 0 +default-ipsettime = 0 -# Option: timeout +# Option: ipsettime # Notes: specifies ticket timeout (handled ipset timeout only) # Values: [ NUM ] Default: 0 (managed by fail2ban by unban) -timeout = 0 +ipsettime = 0 # expresion to caclulate timeout from bantime, example: -# banaction = %(known/banaction)s[timeout=''] +# banaction = %(known/banaction)s[ipsettime=''] timeout-bantime = $([ "" -le 2147483 ] && echo "" || echo 0) # Option: actiontype diff --git a/config/action.d/iptables-ipset-proto6-allports.conf b/config/action.d/iptables-ipset-proto6-allports.conf index 4f200db0..addb2b95 100644 --- a/config/action.d/iptables-ipset-proto6-allports.conf +++ b/config/action.d/iptables-ipset-proto6-allports.conf @@ -26,7 +26,7 @@ before = iptables-common.conf # Notes.: command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false). # Values: CMD # -actionstart = ipset create hash:ip timeout +actionstart = ipset create hash:ip timeout -I -m set --match-set src -j # Option: actionflush @@ -49,7 +49,7 @@ actionstop = -D -m set --match-set src -j timeout -exist +actionban = ipset add timeout -exist # Option: actionunban # Notes.: command executed when unbanning an IP. Take care that the @@ -61,18 +61,18 @@ actionunban = ipset del -exist [Init] -# Option: default-timeout +# Option: default-ipsettime # Notes: specifies default timeout in seconds (handled default ipset timeout only) # Values: [ NUM ] Default: 0 (no timeout, managed by fail2ban by unban) -default-timeout = 0 +default-ipsettime = 0 -# Option: timeout +# Option: ipsettime # Notes: specifies ticket timeout (handled ipset timeout only) # Values: [ NUM ] Default: 0 (managed by fail2ban by unban) -timeout = 0 +ipsettime = 0 # expresion to caclulate timeout from bantime, example: -# banaction = %(known/banaction)s[timeout=''] +# banaction = %(known/banaction)s[ipsettime=''] timeout-bantime = $([ "" -le 2147483 ] && echo "" || echo 0) ipmset = f2b- diff --git a/config/action.d/iptables-ipset-proto6.conf b/config/action.d/iptables-ipset-proto6.conf index 8956ec6a..7677564f 100644 --- a/config/action.d/iptables-ipset-proto6.conf +++ b/config/action.d/iptables-ipset-proto6.conf @@ -26,7 +26,7 @@ before = iptables-common.conf # Notes.: command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false). # Values: CMD # -actionstart = ipset create hash:ip timeout +actionstart = ipset create hash:ip timeout -I -p -m multiport --dports -m set --match-set src -j # Option: actionflush @@ -49,7 +49,7 @@ actionstop = -D -p -m multiport --dports -m # Tags: See jail.conf(5) man page # Values: CMD # -actionban = ipset add timeout -exist +actionban = ipset add timeout -exist # Option: actionunban # Notes.: command executed when unbanning an IP. Take care that the @@ -61,18 +61,18 @@ actionunban = ipset del -exist [Init] -# Option: default-timeout +# Option: default-ipsettime # Notes: specifies default timeout in seconds (handled default ipset timeout only) # Values: [ NUM ] Default: 0 (no timeout, managed by fail2ban by unban) -default-timeout = 0 +default-ipsettime = 0 -# Option: timeout +# Option: ipsettime # Notes: specifies ticket timeout (handled ipset timeout only) # Values: [ NUM ] Default: 0 (managed by fail2ban by unban) -timeout = 0 +ipsettime = 0 # expresion to caclulate timeout from bantime, example: -# banaction = %(known/banaction)s[timeout=''] +# banaction = %(known/banaction)s[ipsettime=''] timeout-bantime = $([ "" -le 2147483 ] && echo "" || echo 0) ipmset = f2b- diff --git a/config/action.d/shorewall-ipset-proto6.conf b/config/action.d/shorewall-ipset-proto6.conf index cbcc5524..75eef218 100644 --- a/config/action.d/shorewall-ipset-proto6.conf +++ b/config/action.d/shorewall-ipset-proto6.conf @@ -51,7 +51,7 @@ # Values: CMD # actionstart = if ! ipset -quiet -name list f2b- >/dev/null; - then ipset -quiet -exist create f2b- hash:ip timeout ; + then ipset -quiet -exist create f2b- hash:ip timeout ; fi # Option: actionstop @@ -66,7 +66,7 @@ actionstop = ipset flush f2b- # Tags: See jail.conf(5) man page # Values: CMD # -actionban = ipset add f2b- timeout -exist +actionban = ipset add f2b- timeout -exist # Option: actionunban # Notes.: command executed when unbanning an IP. Take care that the @@ -76,16 +76,16 @@ actionban = ipset add f2b- timeout -exist # actionunban = ipset del f2b- -exist -# Option: default-timeout +# Option: default-ipsettime # Notes: specifies default timeout in seconds (handled default ipset timeout only) # Values: [ NUM ] Default: 0 (no timeout, managed by fail2ban by unban) -default-timeout = 0 +default-ipsettime = 0 -# Option: timeout +# Option: ipsettime # Notes: specifies ticket timeout (handled ipset timeout only) # Values: [ NUM ] Default: 0 (managed by fail2ban by unban) -timeout = 0 +ipsettime = 0 # expresion to caclulate timeout from bantime, example: -# banaction = %(known/banaction)s[timeout=''] +# banaction = %(known/banaction)s[ipsettime=''] timeout-bantime = $([ "" -le 2147483 ] && echo "" || echo 0) From 0ef8f6675d7c7d68e0b19d42071e1d1838f94627 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 4 Aug 2020 14:25:31 +0200 Subject: [PATCH 07/10] fix travis builds (pipy in xenial, don't error if doc missing in default path after install) --- .travis.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 59a50313..064b678b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,6 @@ matrix: - python: 2.7 name: 2.7 (xenial) - python: pypy - dist: trusty - python: 3.3 dist: trusty - python: 3.4 @@ -70,8 +69,8 @@ script: - if [[ "$F2B_PY" = 3 ]]; then coverage run bin/fail2ban-testcases --verbosity=2; fi # Use $VENV_BIN (not python) or else sudo will always run the system's python (2.7) - sudo $VENV_BIN/pip install . - # Doc files should get installed on Travis under Linux (python >= 3.8 seem to use another path segment) - - if [[ $TRAVIS_PYTHON_VERSION < 3.8 ]]; then test -e /usr/share/doc/fail2ban/FILTERS; fi + # Doc files should get installed on Travis under Linux (some builds/python's seem to use another path segment) + - test -e /usr/share/doc/fail2ban/FILTERS && echo 'found' || echo 'not found' # Test initd script - shellcheck -s bash -e SC1090,SC1091 files/debian-initd after_success: From 9510346507e70da6b25f27a20b3b6f6a84e8eeeb Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 4 Aug 2020 14:31:11 +0200 Subject: [PATCH 08/10] typo in skip message --- fail2ban/tests/fail2banregextestcase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fail2ban/tests/fail2banregextestcase.py b/fail2ban/tests/fail2banregextestcase.py index 8c6a0e47..2da0098e 100644 --- a/fail2ban/tests/fail2banregextestcase.py +++ b/fail2ban/tests/fail2banregextestcase.py @@ -485,7 +485,7 @@ class Fail2banRegexTest(LogCaptureTestCase): def testLogtypeSystemdJournal(self): # pragma: no cover if not fail2banregex.FilterSystemd: - raise unittest.SkipTest('Skip test because no systemd backand available') + raise unittest.SkipTest('Skip test because no systemd backend available') self.assertTrue(_test_exec( "systemd-journal", FILTER_ZZZ_GEN +'[journalmatch="SYSLOG_IDENTIFIER=\x01\x02dummy\x02\x01",' From 253d47d33c40c8f8f52b5d5fced6f2be7d942524 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 4 Aug 2020 14:52:15 +0200 Subject: [PATCH 09/10] compat: some 2.x pypy versions produce UnicodeEncodeError: 'ascii' codec can't encode character on surrogates (uni_string must be fixed also for UTF-8 system encoding) --- fail2ban/tests/misctestcase.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/fail2ban/tests/misctestcase.py b/fail2ban/tests/misctestcase.py index c1a6a345..458e9a23 100644 --- a/fail2ban/tests/misctestcase.py +++ b/fail2ban/tests/misctestcase.py @@ -201,7 +201,8 @@ class TestsUtilsTest(LogCaptureTestCase): uni_decode((b'test\xcf' if sys.version_info >= (3,) else u'test\xcf')) uni_string(b'test\xcf') uni_string('test\xcf') - uni_string(u'test\xcf') + if sys.version_info < (3,) and 'PyPy' not in sys.version: + uni_string(u'test\xcf') def testSafeLogging(self): # logging should be exception-safe, to avoid possible errors (concat, str. conversion, representation failures, etc) From 98983adf761282ba4e6ad4d83a77fffcc6030573 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 4 Aug 2020 17:14:13 +0200 Subject: [PATCH 10/10] update ChangeLog --- ChangeLog | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ChangeLog b/ChangeLog index 266ca614..65568f62 100644 --- a/ChangeLog +++ b/ChangeLog @@ -71,6 +71,8 @@ ver. 0.11.2-dev (20??/??/??) - development edition * introduced new prefix `{UNB}` for `datepattern` to disable word boundaries in regex; * datetemplate: improved anchor detection for capturing groups `(^...)`; * performance optimization of `datepattern` (better search algorithm in datedetector, especially for single template); +* extended capturing of alternate tags in filter, allowing combine of multiple groups to single tuple token with new tag + prefix `` with all value of `` tags (gh-2755) ver. 0.11.1 (2020/01/11) - this-is-the-way