From 9489aeb0a3bd817a6ae8495c9cb8036a24f8ace4 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 21:26:47 +0200 Subject: [PATCH 01/19] ENH+TST: add XARF v4 evidence builder (stdlib) Create xarfreport module with _build_evidence_stdlib() function to encode log text as base64, compute SHA256 hash, and produce XARF v4 evidence dictionary. Test validates all fields including payload decoding, hash format, and size computation. Co-Authored-By: Claude Opus 4.8 (1M context) --- fail2ban/server/xarfreport.py | 57 ++++++++++++++++++++++++++++ fail2ban/tests/utils.py | 3 ++ fail2ban/tests/xarfreporttestcase.py | 50 ++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 fail2ban/server/xarfreport.py create mode 100644 fail2ban/tests/xarfreporttestcase.py diff --git a/fail2ban/server/xarfreport.py b/fail2ban/server/xarfreport.py new file mode 100644 index 00000000..ec7ce724 --- /dev/null +++ b/fail2ban/server/xarfreport.py @@ -0,0 +1,57 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- +# vi: set ft=python sts=4 ts=4 sw=4 noet : + +# This file is part of Fail2Ban. +# +# Fail2Ban is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# Fail2Ban is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Fail2Ban; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2025 Fail2Ban Developers" +__license__ = "GPL" + +"""Build XARF v4 abuse reports from fail2ban ban data. + +Uses the official ``xarf`` library when importable (full schema +validation and spec-tracking); otherwise falls back to a stdlib-only +builder that produces the same document shape. +""" + +import base64 +import hashlib +import uuid + +from ..helpers import getLogger + +logSys = getLogger(__name__) + +# Pinned fallback spec version used by the stdlib builder when the +# official ``xarf`` library is not installed. +XARF_VERSION_FALLBACK = "4.2.0" +CATEGORY = "connection" +TYPE = "login_attack" + + +def _build_evidence_stdlib(text, description=None): + """Return a single XARF v4 evidence item for a block of log text.""" + raw = text.encode('utf-8') + ev = { + "content_type": "text/plain", + "payload": base64.b64encode(raw).decode('ascii'), + "hash": "sha256:" + hashlib.sha256(raw).hexdigest(), + "size": len(raw), + } + if description is not None: + ev["description"] = description + return ev diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 6510e271..e2cbae54 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -382,6 +382,7 @@ def gatherTests(regexps=None, opts=None): from . import samplestestcase from . import fail2banclienttestcase from . import fail2banregextestcase + from . import xarfreporttestcase if not regexps: # pragma: no cover tests = unittest.TestSuite() @@ -448,6 +449,8 @@ def gatherTests(regexps=None, opts=None): tests.addTest(loadTests(misctestcase.SetupTest)) tests.addTest(loadTests(misctestcase.TestsUtilsTest)) tests.addTest(loadTests(misctestcase.MyTimeTest)) + # XARF v4 reporting + tests.addTest(loadTests(xarfreporttestcase.XarfEvidenceTest)) # Database tests.addTest(loadTests(databasetestcase.DatabaseTest)) # Observer diff --git a/fail2ban/tests/xarfreporttestcase.py b/fail2ban/tests/xarfreporttestcase.py new file mode 100644 index 00000000..5d840ee1 --- /dev/null +++ b/fail2ban/tests/xarfreporttestcase.py @@ -0,0 +1,50 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- +# vi: set ft=python sts=4 ts=4 sw=4 noet : + +# This file is part of Fail2Ban. +# +# Fail2Ban is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# Fail2Ban is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Fail2Ban; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2025 Fail2Ban Developers" +__license__ = "GPL" + +import base64 +import hashlib +import unittest + +from ..server import xarfreport +from .utils import LogCaptureTestCase + + +class XarfEvidenceTest(unittest.TestCase): + + def testBuildEvidenceStdlib(self): + text = "Dec 31 11:59:59 sshd: auth failure from 87.142.124.10" + ev = xarfreport._build_evidence_stdlib(text, description="logs") + self.assertEqual(ev['content_type'], "text/plain") + self.assertEqual(ev['description'], "logs") + # payload is base64 of the utf-8 text: + self.assertEqual( + base64.b64decode(ev['payload']).decode('utf-8'), text) + # hash is "sha256:": + digest = hashlib.sha256(text.encode('utf-8')).hexdigest() + self.assertEqual(ev['hash'], "sha256:" + digest) + self.assertEqual(ev['size'], len(text.encode('utf-8'))) + + def testBuildEvidenceStdlibNoDescription(self): + ev = xarfreport._build_evidence_stdlib("a log line") + self.assertNotIn('description', ev) + self.assertEqual(ev['content_type'], "text/plain") From dc1aa6a59db9a8a3e8e7855de0a24ee789882c8f Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 21:33:00 +0200 Subject: [PATCH 02/19] ENH+TST: build XARF v4 login_attack report (stdlib path) Co-Authored-By: Claude Opus 4.8 (1M context) --- fail2ban/server/xarfreport.py | 27 +++++++++++++++ fail2ban/tests/utils.py | 1 + fail2ban/tests/xarfreporttestcase.py | 52 ++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/fail2ban/server/xarfreport.py b/fail2ban/server/xarfreport.py index ec7ce724..edc418e0 100644 --- a/fail2ban/server/xarfreport.py +++ b/fail2ban/server/xarfreport.py @@ -55,3 +55,30 @@ def _build_evidence_stdlib(text, description=None): if description is not None: ev["description"] = description return ev + + +def _build_login_attack_stdlib(data): + """Build a XARF v4 login_attack report dict using only the stdlib.""" + report = { + "xarf_version": XARF_VERSION_FALLBACK, + "report_id": str(uuid.uuid4()), + "timestamp": data["timestamp"], + "reporter": dict(data["reporter"]), + "sender": dict(data["sender"]), + "source_identifier": data["source_identifier"], + "category": CATEGORY, + "type": TYPE, + "protocol": data["protocol"], + "first_seen": data["first_seen"], + } + # optional fields — include only when present and not None: + for key in ("evidence_source", "service", "destination_ip", + "destination_port", "source_port", "attempt_count"): + val = data.get(key) + if val is not None: + report[key] = val + evtext = data.get("evidence_text") + if evtext: + report["evidence"] = [ + _build_evidence_stdlib(evtext, description="fail2ban log matches")] + return report diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index e2cbae54..6456c47a 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -451,6 +451,7 @@ def gatherTests(regexps=None, opts=None): tests.addTest(loadTests(misctestcase.MyTimeTest)) # XARF v4 reporting tests.addTest(loadTests(xarfreporttestcase.XarfEvidenceTest)) + tests.addTest(loadTests(xarfreporttestcase.XarfLoginAttackStdlibTest)) # Database tests.addTest(loadTests(databasetestcase.DatabaseTest)) # Observer diff --git a/fail2ban/tests/xarfreporttestcase.py b/fail2ban/tests/xarfreporttestcase.py index 5d840ee1..91d2fd1c 100644 --- a/fail2ban/tests/xarfreporttestcase.py +++ b/fail2ban/tests/xarfreporttestcase.py @@ -48,3 +48,55 @@ class XarfEvidenceTest(unittest.TestCase): ev = xarfreport._build_evidence_stdlib("a log line") self.assertNotIn('description', ev) self.assertEqual(ev['content_type'], "text/plain") + + +class XarfLoginAttackStdlibTest(LogCaptureTestCase): + + def _data(self, **over): + data = { + "source_identifier": "87.142.124.10", + "timestamp": "2025-01-11T12:17:20Z", + "first_seen": "2025-01-11T06:17:20Z", + "protocol": "tcp", + "service": "sshd", + "destination_port": 22, + "attempt_count": 5, + "evidence_text": "auth failure from 87.142.124.10", + "evidence_source": "log", + "reporter": {"org": "Acme", "contact": "abuse@acme.example", + "domain": "acme.example"}, + "sender": {"org": "Acme", "contact": "abuse@acme.example", + "domain": "acme.example"}, + } + data.update(over) + return data + + def testBuildLoginAttackStdlib(self): + r = xarfreport._build_login_attack_stdlib(self._data()) + self.assertEqual(r['xarf_version'], xarfreport.XARF_VERSION_FALLBACK) + self.assertEqual(r['category'], "connection") + self.assertEqual(r['type'], "login_attack") + self.assertEqual(r['source_identifier'], "87.142.124.10") + self.assertEqual(r['destination_port'], 22) + self.assertEqual(r['protocol'], "tcp") + self.assertEqual(r['service'], "sshd") + self.assertEqual(r['attempt_count'], 5) + self.assertEqual(r['timestamp'], "2025-01-11T12:17:20Z") + self.assertEqual(r['first_seen'], "2025-01-11T06:17:20Z") + self.assertEqual(r['reporter']['org'], "Acme") + self.assertEqual(r['sender']['domain'], "acme.example") + self.assertEqual(len(r['evidence']), 1) + self.assertEqual(r['evidence'][0]['content_type'], "text/plain") + import uuid as _uuid + _uuid.UUID(r['report_id']) + + def testBuildLoginAttackStdlibOmitsMissingOptionals(self): + data = self._data() + del data['destination_port'] + del data['attempt_count'] + r = xarfreport._build_login_attack_stdlib(data) + self.assertNotIn('destination_port', r) + self.assertNotIn('attempt_count', r) + self.assertIn('source_identifier', r) + self.assertIn('protocol', r) + self.assertIn('first_seen', r) From 90482944aa915df882cafa367761d34ab21bb7cd Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 21:38:08 +0200 Subject: [PATCH 03/19] ENH+TST: hybrid XARF v4 generator with optional xarf library Co-Authored-By: Claude Sonnet 4.6 --- fail2ban/server/xarfreport.py | 51 ++++++++++++++++++++++++++++ fail2ban/tests/utils.py | 1 + fail2ban/tests/xarfreporttestcase.py | 38 +++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/fail2ban/server/xarfreport.py b/fail2ban/server/xarfreport.py index edc418e0..f0cbc2ce 100644 --- a/fail2ban/server/xarfreport.py +++ b/fail2ban/server/xarfreport.py @@ -36,6 +36,13 @@ from ..helpers import getLogger logSys = getLogger(__name__) +try: + import xarf as _xarf + _HAVE_XARF = True +except ImportError: # pragma: no cover - depends on optional install + _xarf = None + _HAVE_XARF = False + # Pinned fallback spec version used by the stdlib builder when the # official ``xarf`` library is not installed. XARF_VERSION_FALLBACK = "4.2.0" @@ -82,3 +89,47 @@ def _build_login_attack_stdlib(data): report["evidence"] = [ _build_evidence_stdlib(evtext, description="fail2ban log matches")] return report + + +def _build_login_attack_lib(data): + """Build + validate a login_attack report via the official xarf lib.""" + kwargs = { + "protocol": data["protocol"], + "first_seen": data["first_seen"], + "timestamp": data["timestamp"], + } + for key in ("evidence_source", "service", "destination_ip", + "destination_port", "source_port", "attempt_count"): + val = data.get(key) + if val is not None: + kwargs[key] = val + evtext = data.get("evidence_text") + if evtext: + kwargs["evidence"] = [_xarf.create_evidence( + "text/plain", evtext, description="fail2ban log matches")] + result = _xarf.create_report( + category=CATEGORY, + type=TYPE, + source_identifier=data["source_identifier"], + reporter=dict(data["reporter"]), + sender=dict(data["sender"]), + **kwargs) + if result.errors or result.report is None: + raise ValueError("xarf validation failed: %r" % (result.errors,)) + return result.report.model_dump(by_alias=True, exclude_none=True) + + +def build_login_attack(data): + """Return a XARF v4 login_attack report dict from fail2ban ban data. + + Uses the official ``xarf`` library when available; on any error falls + back to the stdlib builder so a report is still produced. + """ + if _HAVE_XARF: + try: + return _build_login_attack_lib(data) + except Exception as e: # noqa: broad - never block reporting + logSys.warning( + "xarf library report build failed (%s: %s); " + "falling back to stdlib builder", type(e).__name__, e) + return _build_login_attack_stdlib(data) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 6456c47a..9bd10553 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -452,6 +452,7 @@ def gatherTests(regexps=None, opts=None): # XARF v4 reporting tests.addTest(loadTests(xarfreporttestcase.XarfEvidenceTest)) tests.addTest(loadTests(xarfreporttestcase.XarfLoginAttackStdlibTest)) + tests.addTest(loadTests(xarfreporttestcase.XarfBuildLoginAttackTest)) # Database tests.addTest(loadTests(databasetestcase.DatabaseTest)) # Observer diff --git a/fail2ban/tests/xarfreporttestcase.py b/fail2ban/tests/xarfreporttestcase.py index 91d2fd1c..38f3f4f5 100644 --- a/fail2ban/tests/xarfreporttestcase.py +++ b/fail2ban/tests/xarfreporttestcase.py @@ -100,3 +100,41 @@ class XarfLoginAttackStdlibTest(LogCaptureTestCase): self.assertIn('source_identifier', r) self.assertIn('protocol', r) self.assertIn('first_seen', r) + + +class XarfBuildLoginAttackTest(XarfLoginAttackStdlibTest): + + def testPublicEntryPointReturnsValidShape(self): + # Regardless of whether the lib is present, the public function + # returns a dict with the required v4 fields. + r = xarfreport.build_login_attack(self._data()) + for field in ("xarf_version", "report_id", "timestamp", "reporter", + "sender", "source_identifier", "category", "type", + "protocol", "first_seen"): + self.assertIn(field, r) + self.assertEqual(r['category'], "connection") + self.assertEqual(r['type'], "login_attack") + + @unittest.skipUnless(xarfreport._HAVE_XARF, "xarf library not installed") + def testLibPathValidatesAgainstSchema(self): + # When the lib is present, output must pass xarf's own validation. + import xarf + r = xarfreport.build_login_attack(self._data()) + result = xarf.parse(r) + self.assertEqual(result.errors, []) + + def testFallbackOnLibError(self): + # Force the lib path to raise; expect a clean fallback + warning. + orig_flag = xarfreport._HAVE_XARF + orig_lib = xarfreport._build_login_attack_lib + try: + xarfreport._HAVE_XARF = True + def boom(data): + raise RuntimeError("simulated lib failure") + xarfreport._build_login_attack_lib = boom + r = xarfreport.build_login_attack(self._data()) + self.assertEqual(r['type'], "login_attack") + self.assertLogged("falling back to stdlib") + finally: + xarfreport._HAVE_XARF = orig_flag + xarfreport._build_login_attack_lib = orig_lib From c25be3fe0aee4fa7fdd414167d85670a653e237e Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 21:46:02 +0200 Subject: [PATCH 04/19] ENH+TST: add XARF v4 email action skeleton with identity validation Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 82 ++++++++++++++++++++++++++++++++ fail2ban/tests/servertestcase.py | 40 ++++++++++++++++ fail2ban/tests/utils.py | 1 + 3 files changed, 123 insertions(+) create mode 100644 config/action.d/xarf.py diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py new file mode 100644 index 00000000..cb151a8e --- /dev/null +++ b/config/action.d/xarf.py @@ -0,0 +1,82 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t : +# vi: set ft=python sts=4 ts=4 sw=4 noet : + +# This file is part of Fail2Ban. +# +# Fail2Ban is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# Fail2Ban is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Fail2Ban; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +"""Fail2Ban action: report login attacks as XARF v4 over email. + +Resolves the offending IP's abuse contact (Abusix Contact DB), builds a +XARF v4 login_attack report, and sends it via the official XARF email +transport (RFC 5965 multipart/report). Requires the ``dig`` command +(bind-utils) for contact resolution. +""" + +from fail2ban.server.actions import ActionBase +from fail2ban.server import xarfreport + + +class XarfV4Action(ActionBase): + + def __init__(self, jail, name, + reporter_org=None, reporter_contact=None, reporter_domain=None, + sender_org=None, sender_contact=None, sender_domain=None, + service="unspecified", port="0", protocol="tcp", + sender=None, mailcmd="/usr/sbin/sendmail", + resolver="abuse-contacts.abusix.org", matches="ipmatches"): + # reporter_*/sender_* are the XARF v4 report identities; the separate + # `sender` arg is the e-mail envelope From address (self.envelope_from). + super(XarfV4Action, self).__init__(jail, name) + self.reporter = {"org": reporter_org, "contact": reporter_contact, + "domain": reporter_domain} + self.sender_id = {"org": sender_org, "contact": sender_contact, + "domain": sender_domain} + self.service = service + self.port = port + self.protocol = protocol + self.envelope_from = sender or "fail2ban" + self.mailcmd = mailcmd + self.resolver = resolver + self.matches = matches + # bypass ban/unban for restored tickets + self.norestored = 1 + + def _identity_ok(self): + return all(self.reporter.values()) and all(self.sender_id.values()) + + def start(self): + pass + + def stop(self): + pass + + def unban(self, aInfo): + pass + + def ban(self, aInfo): + if aInfo.get('restored'): + return + if not self._identity_ok(): + self._logSys.error( + "xarf action %s: reporter/sender identity is incomplete; " + "set reporter_org/contact/domain and sender_org/contact/" + "domain - skipping report", self._name) + return + # remaining wiring added in later tasks + self._logSys.debug("xarf action %s: ban() reached", self._name) + + +Action = XarfV4Action diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index fefb5bf7..3b31b2cf 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2411,3 +2411,43 @@ class ServerConfigReaderTests(LogCaptureTestCase): ticket = _actions.Actions.ActionInfo(ticket, dmyjail) action.ban(ticket) self.assertLogged(*tests[test], all=True) + + +class XarfV4ActionTest(LogCaptureTestCase): + + def setUp(self): + super(XarfV4ActionTest, self).setUp() + self.__jail = DummyJail() + actfile = os.path.join(CONFIG_DIR, "action.d", "xarf.py") + mod = Utils.load_python_module(actfile) + self.Action = mod.Action + + def _mk(self, **over): + opts = dict( + reporter_org="Acme", reporter_contact="abuse@acme.example", + reporter_domain="acme.example", + sender_org="Acme", sender_contact="abuse@acme.example", + sender_domain="acme.example", + service="sshd", port="22") + opts.update(over) + return self.Action(self.__jail, "xarf", **opts) + + def testMissingIdentitySkips(self): + act = self._mk(reporter_org=None) + sent = [] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act.ban({'ip': '87.142.124.10', 'failures': 3, + 'time': 1736597840, 'ipmatches': 'log line'}) + self.assertEqual(sent, []) + self.assertLogged("reporter/sender identity is incomplete") + + def testMissingSenderIdentitySkips(self): + act = self._mk(sender_domain=None) + sent = [] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act.ban({'ip': '87.142.124.10', 'failures': 3, + 'time': 1736597840, 'ipmatches': 'log line'}) + self.assertEqual(sent, []) + self.assertLogged("reporter/sender identity is incomplete") diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 9bd10553..9d08776e 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -417,6 +417,7 @@ def gatherTests(regexps=None, opts=None): tests.addTest(loadTests(servertestcase.RegexTests)) tests.addTest(loadTests(servertestcase.LoggingTests)) tests.addTest(loadTests(servertestcase.ServerConfigReaderTests)) + tests.addTest(loadTests(servertestcase.XarfV4ActionTest)) tests.addTest(loadTests(actiontestcase.CommandActionTest)) tests.addTest(loadTests(actionstestcase.ExecuteActions)) # Ticket, BanTicket, FailTicket From 0f2d859a28ca772933f187501622048f3340983e Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 21:52:29 +0200 Subject: [PATCH 05/19] ENH+TST: resolve abuse contacts via Abusix Contact DB in xarf action Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 36 ++++++++++++++++++++++++++++++++ fail2ban/tests/servertestcase.py | 19 +++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index cb151a8e..9a677527 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -25,6 +25,8 @@ transport (RFC 5965 multipart/report). Requires the ``dig`` command (bind-utils) for contact resolution. """ +import subprocess + from fail2ban.server.actions import ActionBase from fail2ban.server import xarfreport @@ -66,6 +68,40 @@ class XarfV4Action(ActionBase): def unban(self, aInfo): pass + def _reverse_ip(self, ip): + """Return the reversed-nibble/octet label for a TXT lookup.""" + from fail2ban.server.ipdns import IPAddr + return IPAddr(ip).getPTR("") + + def _dig_txt(self, fqdn): + """Return raw `dig +short TXT` output for fqdn (overridable in tests).""" + try: + out = subprocess.check_output( + ["dig", "+short", "-t", "txt", "-q", fqdn], + universal_newlines=True, timeout=30) + except Exception as e: + self._logSys.error("xarf action %s: dig failed for %s: %s", + self._name, fqdn, e) + return '' + return out + + def _resolveAbuseContacts(self, ip): + """Resolve abuse contact email(s) for ip via the Abusix Contact DB.""" + fqdn = self._reverse_ip(ip) + self.resolver + self._logSys.debug("xarf action %s: try to resolve %s", + self._name, fqdn) + raw = self._dig_txt(fqdn) + addrs = [] + for line in raw.splitlines(): + line = line.strip().strip('"') + if not line or line.startswith(';;'): + continue + for part in line.split(','): + part = part.strip() + if part: + addrs.append(part) + return addrs + def ban(self, aInfo): if aInfo.get('restored'): return diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 3b31b2cf..52207d46 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2451,3 +2451,22 @@ class XarfV4ActionTest(LogCaptureTestCase): 'time': 1736597840, 'ipmatches': 'log line'}) self.assertEqual(sent, []) self.assertLogged("reporter/sender identity is incomplete") + + def testResolveParsesDigTxt(self): + act = self._mk() + # stub the dig invocation: return a TXT answer like dig +short + act._dig_txt = lambda fqdn: '"abuse-1@isp.example, abuse-2@isp.example"' + addrs = act._resolveAbuseContacts('87.142.124.10') + self.assertEqual(addrs, ["abuse-1@isp.example", "abuse-2@isp.example"]) + + def testResolveBuildsAbusixFqdn(self): + act = self._mk() + seen = [] + act._dig_txt = lambda fqdn: seen.append(fqdn) or '"abuse@isp.example"' + act._resolveAbuseContacts('87.142.124.10') + self.assertEqual(seen, ["10.124.142.87.abuse-contacts.abusix.org"]) + + def testResolveEmptyOnNoAnswer(self): + act = self._mk() + act._dig_txt = lambda fqdn: '' + self.assertEqual(act._resolveAbuseContacts('87.142.124.10'), []) From 5e11dd1fad4415c8ac3bdf4044440c22f4a2a379 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 21:58:48 +0200 Subject: [PATCH 06/19] ENH+TST: build official XARF v4 multipart/report email Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 39 ++++++++++++++++++++++++++++++++ fail2ban/tests/servertestcase.py | 25 ++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index 9a677527..f5ce1089 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -25,7 +25,12 @@ transport (RFC 5965 multipart/report). Requires the ``dig`` command (bind-utils) for contact resolution. """ +import json import subprocess +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from fail2ban.server.actions import ActionBase from fail2ban.server import xarfreport @@ -102,6 +107,40 @@ class XarfV4Action(ActionBase): addrs.append(part) return addrs + def _build_email(self, report): + """Wrap a XARF v4 report dict in the official XARF email transport. + + The returned message has no recipient header; the caller is + responsible for setting the recipients when sending. + """ + ip = report.get("source_identifier", "") + msg = MIMEMultipart("report") + msg.set_param("report-type", "feedback-report") + msg["Subject"] = "Abuse report (login-attack) about %s" % ip + msg["From"] = self.envelope_from + msg["Auto-Submitted"] = "auto-generated" + + human = ("An abuse login-attack report follows.\n\n" + "Report-Type: %s\nSource: %s\nTimestamp: %s\nReport-ID: %s\n\n" + "The machine-readable XARF v4 report is attached as xarf.json.\n" + % (report.get("type", "login_attack"), ip, + report.get("timestamp", ""), report.get("report_id", ""))) + msg.attach(MIMEText(human, "plain", "us-ascii")) + + fb = MIMEBase("message", "feedback-report") + fb.set_payload( + "Feedback-Type: xarf\nUser-Agent: Fail2Ban\nVersion: 1\n") + fb.add_header("Content-Disposition", "inline") + msg.attach(fb) + + jsonpart = MIMEBase("application", "json", name="xarf.json") + jsonpart.set_payload(json.dumps(report).encode("utf-8")) + encoders.encode_base64(jsonpart) + jsonpart.add_header( + "Content-Disposition", "attachment", filename="xarf.json") + msg.attach(jsonpart) + return msg + def ban(self, aInfo): if aInfo.get('restored'): return diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 52207d46..568327dd 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2470,3 +2470,28 @@ class XarfV4ActionTest(LogCaptureTestCase): act = self._mk() act._dig_txt = lambda fqdn: '' self.assertEqual(act._resolveAbuseContacts('87.142.124.10'), []) + + def testBuildEmailStructure(self): + import base64 as _b64, json as _json + act = self._mk() + report = {"xarf_version": "4.2.0", "type": "login_attack", + "source_identifier": "87.142.124.10", + "timestamp": "2025-01-11T12:17:20Z", "report_id": "abc-123"} + msg = act._build_email(report) + self.assertEqual(msg.get_content_type(), "multipart/report") + self.assertEqual(msg.get_param("report-type"), "feedback-report") + parts = msg.get_payload() + self.assertEqual(len(parts), 3) + self.assertEqual(parts[0].get_content_type(), "text/plain") + self.assertEqual(parts[1].get_content_type(), "message/feedback-report") + fbtext = parts[1].get_payload() + self.assertIn("Feedback-Type: xarf", fbtext) + self.assertIn("User-Agent", fbtext) + self.assertIn("Version: 1", fbtext) + self.assertEqual(parts[2].get_content_type(), "application/json") + self.assertEqual(parts[2].get_filename(), "xarf.json") + decoded = _b64.b64decode(parts[2].get_payload()) + self.assertEqual(_json.loads(decoded)['source_identifier'], + "87.142.124.10") + # subject references the source IP: + self.assertIn("87.142.124.10", msg['Subject']) From 3f9cac2728ecad74c6f4484398c6dae26dc89331 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 22:05:24 +0200 Subject: [PATCH 07/19] ENH+TST: wire xarf v4 action ban() end-to-end Add _iso, _aInfoToData, _sendmail helpers and wire ban() to resolve abuse contacts, build XARF v4 report + email, set To header, and send via sendmail. Short-circuits when no contact found. Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 57 ++++++++++++++++++++++++++++++-- fail2ban/tests/servertestcase.py | 30 +++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index f5ce1089..56408546 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -26,7 +26,9 @@ transport (RFC 5965 multipart/report). Requires the ``dig`` command """ import json +import shlex import subprocess +import time from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart @@ -141,6 +143,46 @@ class XarfV4Action(ActionBase): msg.attach(jsonpart) return msg + def _iso(self, epoch): + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(epoch))) + + def _aInfoToData(self, aInfo): + try: + dport = int(self.port) + except (TypeError, ValueError): + dport = None + ts = self._iso(aInfo.get('time') or time.time()) + evidence = aInfo.get(self.matches) or aInfo.get('matches') or '' + failures = aInfo.get('failures') + return { + "source_identifier": str(aInfo['ip']), + "timestamp": ts, + "first_seen": ts, + "protocol": self.protocol, + "service": self.service, + "destination_port": dport, + "attempt_count": int(failures) if failures is not None else None, + "evidence_text": evidence, + "evidence_source": "log", + "reporter": self.reporter, + "sender": self.sender_id, + } + + def _sendmail(self, recipients, msg): + cmd = shlex.split(self.mailcmd) + list(recipients) + p = subprocess.Popen(cmd, stdin=subprocess.PIPE) + try: + p.communicate(msg.as_bytes(), timeout=30) + except subprocess.TimeoutExpired: + p.kill() + p.communicate() + self._logSys.error( + "xarf action %s: mail command timed out", self._name) + return + if p.returncode: + self._logSys.error("xarf action %s: mail command failed (rc=%s)", + self._name, p.returncode) + def ban(self, aInfo): if aInfo.get('restored'): return @@ -150,8 +192,19 @@ class XarfV4Action(ActionBase): "set reporter_org/contact/domain and sender_org/contact/" "domain - skipping report", self._name) return - # remaining wiring added in later tasks - self._logSys.debug("xarf action %s: ban() reached", self._name) + contacts = self._resolveAbuseContacts(str(aInfo['ip'])) + if not contacts: + self._logSys.info( + "xarf action %s: no abuse contact for %s; skipping", + self._name, aInfo['ip']) + return + data = self._aInfoToData(aInfo) + report = xarfreport.build_login_attack(data) + msg = self._build_email(report) + msg["To"] = ", ".join(contacts) + self._sendmail(contacts, msg) + self._logSys.debug("xarf action %s: report sent for %s to %s", + self._name, aInfo['ip'], contacts) Action = XarfV4Action diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 568327dd..4288811b 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2495,3 +2495,33 @@ class XarfV4ActionTest(LogCaptureTestCase): "87.142.124.10") # subject references the source IP: self.assertIn("87.142.124.10", msg['Subject']) + + def testBanSendsReport(self): + import base64 as _b64, json as _json + act = self._mk(service="sshd", port="22") + sent = [] + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act.ban({'ip': '87.142.124.10', 'failures': 7, + 'time': 1736597840, 'ipmatches': 'Jan 11 sshd auth failure'}) + self.assertEqual(len(sent), 1) + recipients, msg = sent[0] + self.assertEqual(recipients, ["abuse@isp.example"]) + self.assertEqual(msg['To'], "abuse@isp.example") + jsonpart = msg.get_payload()[2] + report = _json.loads(_b64.b64decode(jsonpart.get_payload())) + self.assertEqual(report['source_identifier'], '87.142.124.10') + self.assertEqual(report['type'], 'login_attack') + self.assertEqual(report['destination_port'], 22) + self.assertEqual(report['attempt_count'], 7) + self.assertEqual(len(report['evidence']), 1) + + def testBanNoContactNoSend(self): + act = self._mk() + sent = [] + act._resolveAbuseContacts = lambda ip: [] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act.ban({'ip': '87.142.124.10', 'failures': 1, + 'time': 1736597840, 'ipmatches': 'x'}) + self.assertEqual(sent, []) + self.assertLogged("no abuse contact") From 3e04155205cc885ac2109c83f70293371795da49 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Tue, 16 Jun 2026 22:11:09 +0200 Subject: [PATCH 08/19] ENH: add action_xarf_v4 shortcut for XARF v4 email reporting Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 6 ++++++ config/jail.conf | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index 56408546..573580a6 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -23,6 +23,12 @@ Resolves the offending IP's abuse contact (Abusix Contact DB), builds a XARF v4 login_attack report, and sends it via the official XARF email transport (RFC 5965 multipart/report). Requires the ``dig`` command (bind-utils) for contact resolution. + +IMPORTANT: Reporting an IP for abuse is a serious complaint. Only use this +action where you are confident the activity is genuinely abusive, e.g.: + * recidive (the IP has been banned repeatedly), + * a high maxretry, well beyond a normal user mistyping a password, + * filters with a low likelihood of false positives. """ import json diff --git a/config/jail.conf b/config/jail.conf index 8f02e9af..63503a4b 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -198,6 +198,15 @@ port = 0:65535 # Format of user-agent https://tools.ietf.org/html/rfc7231#section-5.5.3 fail2ban_agent = Fail2Ban/%(fail2ban_version)s +# XARF v4 reporter/sender identity (set these in jail.local to use action_xarf_v4) +# (xarf_sender_* defaults to xarf_reporter_* if not overridden) +xarf_reporter_org = +xarf_reporter_contact = +xarf_reporter_domain = +xarf_sender_org = %(xarf_reporter_org)s +xarf_sender_contact = %(xarf_reporter_contact)s +xarf_sender_domain = %(xarf_reporter_domain)s + # # Action shortcuts. To be used to define action parameter @@ -227,6 +236,19 @@ action_mwl = %(action_)s action_xarf = %(action_)s xarf-login-attack[service=%(__name__)s, sender="%(sender)s", logpath="%(logpath)s", port="%(port)s"] +# See the IMPORTANT note in action.d/xarf.py for when to use this action +# +# ban & send a XARF v4 login-attack report by e-mail to the IP's abuse +# contact (resolved via the Abusix Contact DB). Requires reporter/sender +# identity (xarf_*_org/contact/domain) set in jail.local. Optionally install +# the `xarf` python package for full schema validation. +action_xarf_v4 = %(action_)s + xarf.py[service="%(__name__)s", port="%(port)s", protocol="%(protocol)s", + reporter_org="%(xarf_reporter_org)s", reporter_contact="%(xarf_reporter_contact)s", + reporter_domain="%(xarf_reporter_domain)s", + sender_org="%(xarf_sender_org)s", sender_contact="%(xarf_sender_contact)s", + sender_domain="%(xarf_sender_domain)s"] + # ban & send a notification to one or more of the 120+ services supported by Apprise. # action = %(action_)s # apprise[config="/alternate/path/to/apprise.yaml", args='--tag fail2ban'] From 78c120203e488babc11d10b6740872652264458b Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 17 Jun 2026 06:44:41 +0200 Subject: [PATCH 09/19] DOC: deprecate legacy xarf-login-attack action (x-arf 0.2) --- config/action.d/xarf-login-attack.conf | 5 +++++ config/jail.conf | 1 + 2 files changed, 6 insertions(+) diff --git a/config/action.d/xarf-login-attack.conf b/config/action.d/xarf-login-attack.conf index 00da7f79..9affd0b2 100644 --- a/config/action.d/xarf-login-attack.conf +++ b/config/action.d/xarf-login-attack.conf @@ -30,6 +30,11 @@ # # +# DEPRECATED: this action emits the obsolete x-arf 0.2 format. It is superseded +# by the `xarf` action (XARF v4) - see action_xarf_v4 in jail.conf. This action +# remains functional for now but will be removed in a future release. Please +# migrate to action_xarf_v4. + [Definition] # bypass ban/unban for restored tickets diff --git a/config/jail.conf b/config/jail.conf index 63503a4b..664954f2 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -229,6 +229,7 @@ action_mw = %(action_)s action_mwl = %(action_)s %(mta)s-whois-lines[sender="%(sender)s", dest="%(destemail)s", logpath="%(logpath)s", chain="%(chain)s"] +# DEPRECATED (x-arf 0.2): use action_xarf_v4 (XARF v4) instead; this will be removed in a future release. # See the IMPORTANT note in action.d/xarf-login-attack for when to use this action # # ban & send a xarf e-mail to abuse contact of IP address and include relevant log lines From 20690f66c32438b4326d88e3370c02df95b5b30e Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 17 Jun 2026 06:46:37 +0200 Subject: [PATCH 10/19] DOC: changelog for XARF v4 reporting action and legacy deprecation Co-Authored-By: Claude Opus 4.8 (1M context) --- ChangeLog | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ChangeLog b/ChangeLog index 32f2dec0..f17bbebd 100644 --- a/ChangeLog +++ b/ChangeLog @@ -99,7 +99,11 @@ ver. 1.1.1 (2026/08/15) - triple-one-win several log messages will be tagged with as originating from a process named "sshd-session" rather than "sshd" (gh-3782) - `ddos` and `aggressive` modes: regex extended for timeout before authentication (optional connection from part, gh-3907) * `filter.d/vsftpd.conf` - fixed regex (if failures generated by systemd-journal, gh-3954) -* `filter.d/froxlor-auth.conf` - updated the regex to the new logging situation for froxlor and changed logpath in jail.conf (gh-4075). +* `filter.d/froxlor-auth.conf` - updated the regex to the new logging situation for froxlor and changed logpath in jail.conf (gh-4075). + +### Deprecations +* `action.d/xarf-login-attack.conf` - deprecated (emits obsolete x-arf 0.2); + superseded by the `xarf` action (XARF v4). Will be removed in a future release. ### New Features and Enhancements * `fail2ban-server` supports `systemd` socket activation (`sd_listen_fds(3)`); optional `fail2ban.socket` unit provided @@ -144,6 +148,11 @@ ver. 1.1.1 (2026/08/15) - triple-one-win * `filter.d/vaultwarden.conf` - new filter and jail for Vaultwarden (gh-3979) * `filter.d/xrdp.conf` - new filter for XRDP, an open source RDP server (gh-3254) * `fail2ban-regex` extended with new option `-i` or `--invert` to output not-matched lines by `-o` or `--out` (gh-4001) +* `action.d/xarf.py` - new action reporting login attacks as XARF v4 + (https://xarf.org) by e-mail to the IP's abuse contact (resolved via the + Abusix Contact DB). Uses the optional `xarf` python package for schema + validation when installed, otherwise a built-in stdlib generator. Enable + via `action_xarf_v4` in jail.conf. ver. 1.1.0 (2024/04/25) - object-found--norad-59479-cospar-2024-069a--altitude-36267km From f52f21e1246e1d1d03366c7f2004d7822ca09a77 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 17 Jun 2026 06:56:55 +0200 Subject: [PATCH 11/19] ENH+TST: harden xarf v4 action (mail headers, ban guard, From default) + tests Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 34 +++++++++++++++++++++----------- fail2ban/tests/servertestcase.py | 14 +++++++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index 573580a6..08537da4 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -20,8 +20,8 @@ """Fail2Ban action: report login attacks as XARF v4 over email. Resolves the offending IP's abuse contact (Abusix Contact DB), builds a -XARF v4 login_attack report, and sends it via the official XARF email -transport (RFC 5965 multipart/report). Requires the ``dig`` command +XARF v4 login_attack report, and sends it via the XARF v4 email transport +format (RFC 5965 multipart/report). Requires the ``dig`` command (bind-utils) for contact resolution. IMPORTANT: Reporting an IP for abuse is a serious complaint. Only use this @@ -33,12 +33,14 @@ action where you are confident the activity is genuinely abusive, e.g.: import json import shlex +import socket import subprocess import time from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formatdate, make_msgid from fail2ban.server.actions import ActionBase from fail2ban.server import xarfreport @@ -62,7 +64,7 @@ class XarfV4Action(ActionBase): self.service = service self.port = port self.protocol = protocol - self.envelope_from = sender or "fail2ban" + self.envelope_from = sender or ("fail2ban@" + socket.getfqdn()) self.mailcmd = mailcmd self.resolver = resolver self.matches = matches @@ -116,7 +118,7 @@ class XarfV4Action(ActionBase): return addrs def _build_email(self, report): - """Wrap a XARF v4 report dict in the official XARF email transport. + """Wrap a XARF v4 report dict in the XARF v4 email transport format. The returned message has no recipient header; the caller is responsible for setting the recipients when sending. @@ -127,6 +129,8 @@ class XarfV4Action(ActionBase): msg["Subject"] = "Abuse report (login-attack) about %s" % ip msg["From"] = self.envelope_from msg["Auto-Submitted"] = "auto-generated" + msg["Date"] = formatdate() + msg["Message-ID"] = make_msgid() human = ("An abuse login-attack report follows.\n\n" "Report-Type: %s\nSource: %s\nTimestamp: %s\nReport-ID: %s\n\n" @@ -160,19 +164,21 @@ class XarfV4Action(ActionBase): ts = self._iso(aInfo.get('time') or time.time()) evidence = aInfo.get(self.matches) or aInfo.get('matches') or '' failures = aInfo.get('failures') - return { + data = { "source_identifier": str(aInfo['ip']), "timestamp": ts, "first_seen": ts, "protocol": self.protocol, "service": self.service, - "destination_port": dport, "attempt_count": int(failures) if failures is not None else None, "evidence_text": evidence, "evidence_source": "log", "reporter": self.reporter, "sender": self.sender_id, } + if dport is not None: + data["destination_port"] = dport + return data def _sendmail(self, recipients, msg): cmd = shlex.split(self.mailcmd) + list(recipients) @@ -204,11 +210,17 @@ class XarfV4Action(ActionBase): "xarf action %s: no abuse contact for %s; skipping", self._name, aInfo['ip']) return - data = self._aInfoToData(aInfo) - report = xarfreport.build_login_attack(data) - msg = self._build_email(report) - msg["To"] = ", ".join(contacts) - self._sendmail(contacts, msg) + try: + data = self._aInfoToData(aInfo) + report = xarfreport.build_login_attack(data) + msg = self._build_email(report) + msg["To"] = ", ".join(contacts) + self._sendmail(contacts, msg) + except Exception as e: # never block the ban path + self._logSys.error( + "xarf action %s: failed to build/send report for %s: %s", + self._name, aInfo.get('ip'), e) + return self._logSys.debug("xarf action %s: report sent for %s to %s", self._name, aInfo['ip'], contacts) diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 4288811b..22e86767 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2466,6 +2466,13 @@ class XarfV4ActionTest(LogCaptureTestCase): act._resolveAbuseContacts('87.142.124.10') self.assertEqual(seen, ["10.124.142.87.abuse-contacts.abusix.org"]) + def testResolveBuildsAbusixFqdnIPv6(self): + act = self._mk() + seen = [] + act._dig_txt = lambda fqdn: seen.append(fqdn) or '"abuse@isp.example"' + act._resolveAbuseContacts('2001:db8::1') + self.assertEqual(seen, ["1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.abuse-contacts.abusix.org"]) + def testResolveEmptyOnNoAnswer(self): act = self._mk() act._dig_txt = lambda fqdn: '' @@ -2516,6 +2523,13 @@ class XarfV4ActionTest(LogCaptureTestCase): self.assertEqual(report['attempt_count'], 7) self.assertEqual(len(report['evidence']), 1) + def testAInfoToDataDropsPortRange(self): + act = self._mk(port="0:65535") + d = act._aInfoToData({'ip': '1.2.3.4', 'time': 1736597840, + 'failures': 2, 'ipmatches': 'x'}) + self.assertNotIn('destination_port', d) + self.assertEqual(d['source_identifier'], '1.2.3.4') + def testBanNoContactNoSend(self): act = self._mk() sent = [] From 6b5c685abe7bc4dbd72895bec493a9da0bffa30d Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 17 Jun 2026 07:03:14 +0200 Subject: [PATCH 12/19] BF: prevent argv flag injection from DNS-resolved abuse contacts in xarf action Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 15 +++++++++++++-- fail2ban/tests/servertestcase.py | 8 ++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index 08537da4..ce93db6f 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -32,6 +32,7 @@ action where you are confident the activity is genuinely abusive, e.g.: """ import json +import re import shlex import socket import subprocess @@ -45,6 +46,10 @@ from email.utils import formatdate, make_msgid from fail2ban.server.actions import ActionBase from fail2ban.server import xarfreport +# Strict address validation for abuse contacts resolved from DNS (untrusted): +# rejects anything that is not an email and anything that could be an argv flag. +_EMAIL_CRE = re.compile(r'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$') + class XarfV4Action(ActionBase): @@ -113,8 +118,14 @@ class XarfV4Action(ActionBase): continue for part in line.split(','): part = part.strip() - if part: + if not part or part.startswith('-'): + continue + if _EMAIL_CRE.match(part): addrs.append(part) + else: + self._logSys.warning( + "xarf action %s: ignoring invalid abuse contact %r", + self._name, part) return addrs def _build_email(self, report): @@ -181,7 +192,7 @@ class XarfV4Action(ActionBase): return data def _sendmail(self, recipients, msg): - cmd = shlex.split(self.mailcmd) + list(recipients) + cmd = shlex.split(self.mailcmd) + ['--'] + list(recipients) p = subprocess.Popen(cmd, stdin=subprocess.PIPE) try: p.communicate(msg.as_bytes(), timeout=30) diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 22e86767..cacb65b3 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2478,6 +2478,14 @@ class XarfV4ActionTest(LogCaptureTestCase): act._dig_txt = lambda fqdn: '' self.assertEqual(act._resolveAbuseContacts('87.142.124.10'), []) + def testResolveRejectsFlagAndJunkTokens(self): + # DNS is untrusted: drop argv-flag-like and non-email tokens. + act = self._mk() + act._dig_txt = lambda fqdn: '"-X/tmp/evil, not-an-email, good@isp.example"' + self.assertEqual( + act._resolveAbuseContacts('87.142.124.10'), ["good@isp.example"]) + self.assertLogged("ignoring invalid abuse contact") + def testBuildEmailStructure(self): import base64 as _b64, json as _json act = self._mk() From 54165d3cdea514deb4c576f3e4d7c6479484bc31 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 17 Jun 2026 08:02:04 +0200 Subject: [PATCH 13/19] ENH: only use xarf library when it exposes the v4 generator API; clarify jail.conf note Co-Authored-By: Claude Opus 4.8 (1M context) --- config/jail.conf | 5 +++-- fail2ban/server/xarfreport.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/config/jail.conf b/config/jail.conf index 664954f2..cc6c79db 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -241,8 +241,9 @@ action_xarf = %(action_)s # # ban & send a XARF v4 login-attack report by e-mail to the IP's abuse # contact (resolved via the Abusix Contact DB). Requires reporter/sender -# identity (xarf_*_org/contact/domain) set in jail.local. Optionally install -# the `xarf` python package for full schema validation. +# identity (xarf_*_org/contact/domain) set in jail.local. When the official +# XARF v4 `xarf` python package is available and installed, it is used +# automatically for full schema validation; otherwise a built-in generator is used. action_xarf_v4 = %(action_)s xarf.py[service="%(__name__)s", port="%(port)s", protocol="%(protocol)s", reporter_org="%(xarf_reporter_org)s", reporter_contact="%(xarf_reporter_contact)s", diff --git a/fail2ban/server/xarfreport.py b/fail2ban/server/xarfreport.py index f0cbc2ce..238ce826 100644 --- a/fail2ban/server/xarfreport.py +++ b/fail2ban/server/xarfreport.py @@ -38,7 +38,9 @@ logSys = getLogger(__name__) try: import xarf as _xarf - _HAVE_XARF = True + # Only use the library when it exposes the expected XARF v4 generator API + # (guards against an unrelated/older package squatting the `xarf` name). + _HAVE_XARF = hasattr(_xarf, "create_report") and hasattr(_xarf, "create_evidence") except ImportError: # pragma: no cover - depends on optional install _xarf = None _HAVE_XARF = False From 1e2087558827eddc7c7abf2c80170845a7bb7a1b Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 17 Jun 2026 10:59:15 +0200 Subject: [PATCH 14/19] ENH+TST: best-effort source_port extraction for XARF v4 login_attack; recommend xarf install Co-Authored-By: Claude Sonnet 4.6 --- config/action.d/xarf.py | 27 ++++++++++++++++++++++++++- config/jail.conf | 6 +++--- fail2ban/tests/servertestcase.py | 22 ++++++++++++++++++++++ fail2ban/tests/xarfreporttestcase.py | 2 ++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index ce93db6f..9a938848 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -58,7 +58,8 @@ class XarfV4Action(ActionBase): sender_org=None, sender_contact=None, sender_domain=None, service="unspecified", port="0", protocol="tcp", sender=None, mailcmd="/usr/sbin/sendmail", - resolver="abuse-contacts.abusix.org", matches="ipmatches"): + resolver="abuse-contacts.abusix.org", matches="ipmatches", + source_port_regex=r"\bport (\d{1,5})\b"): # reporter_*/sender_* are the XARF v4 report identities; the separate # `sender` arg is the e-mail envelope From address (self.envelope_from). super(XarfV4Action, self).__init__(jail, name) @@ -73,6 +74,8 @@ class XarfV4Action(ActionBase): self.mailcmd = mailcmd self.resolver = resolver self.matches = matches + self.source_port_regex = source_port_regex + self._source_port_cre = re.compile(source_port_regex) if source_port_regex else None # bypass ban/unban for restored tickets self.norestored = 1 @@ -167,6 +170,25 @@ class XarfV4Action(ActionBase): def _iso(self, epoch): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(epoch))) + def _extract_source_port(self, text): + """Best-effort: extract the attacker source port from matched log text. + + fail2ban generally knows only the attacked (destination) port; some + services (e.g. sshd) log the source port in the failure line. When + present, the most recent (triggering) match is used. Returns an int + in 1..65535, or None when unavailable. + """ + if not self._source_port_cre or not text: + return None + for val in reversed(self._source_port_cre.findall(text)): + try: + p = int(val) + except (TypeError, ValueError): + continue + if 1 <= p <= 65535: + return p + return None + def _aInfoToData(self, aInfo): try: dport = int(self.port) @@ -189,6 +211,9 @@ class XarfV4Action(ActionBase): } if dport is not None: data["destination_port"] = dport + sport = self._extract_source_port(evidence) + if sport is not None: + data["source_port"] = sport return data def _sendmail(self, recipients, msg): diff --git a/config/jail.conf b/config/jail.conf index cc6c79db..d04b3c93 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -241,9 +241,9 @@ action_xarf = %(action_)s # # ban & send a XARF v4 login-attack report by e-mail to the IP's abuse # contact (resolved via the Abusix Contact DB). Requires reporter/sender -# identity (xarf_*_org/contact/domain) set in jail.local. When the official -# XARF v4 `xarf` python package is available and installed, it is used -# automatically for full schema validation; otherwise a built-in generator is used. +# identity (xarf_*_org/contact/domain) set in jail.local. Install the official +# XARF v4 `xarf` python package (pip install xarf) for full schema validation; +# a built-in generator is used otherwise. action_xarf_v4 = %(action_)s xarf.py[service="%(__name__)s", port="%(port)s", protocol="%(protocol)s", reporter_org="%(xarf_reporter_org)s", reporter_contact="%(xarf_reporter_contact)s", diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index cacb65b3..e2531014 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2538,6 +2538,28 @@ class XarfV4ActionTest(LogCaptureTestCase): self.assertNotIn('destination_port', d) self.assertEqual(d['source_identifier'], '1.2.3.4') + def testAInfoExtractsSourcePortFromSshdLine(self): + act = self._mk(port="22") + d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, + 'failures': 4, + 'ipmatches': 'Failed password for root from 203.0.113.5 port 52431 ssh2'}) + self.assertEqual(d['source_port'], 52431) + self.assertEqual(d['destination_port'], 22) + + def testAInfoNoSourcePortWhenAbsent(self): + act = self._mk(port="22") + d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, + 'failures': 4, + 'ipmatches': 'authentication failure for kevin from 203.0.113.5'}) + self.assertNotIn('source_port', d) + + def testAInfoSourcePortLastMatchWins(self): + act = self._mk(port="22") + d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, + 'failures': 2, + 'ipmatches': 'from 203.0.113.5 port 1111 ssh2\nfrom 203.0.113.5 port 2222 ssh2'}) + self.assertEqual(d['source_port'], 2222) + def testBanNoContactNoSend(self): act = self._mk() sent = [] diff --git a/fail2ban/tests/xarfreporttestcase.py b/fail2ban/tests/xarfreporttestcase.py index 38f3f4f5..2593e13f 100644 --- a/fail2ban/tests/xarfreporttestcase.py +++ b/fail2ban/tests/xarfreporttestcase.py @@ -60,6 +60,7 @@ class XarfLoginAttackStdlibTest(LogCaptureTestCase): "protocol": "tcp", "service": "sshd", "destination_port": 22, + "source_port": 45621, "attempt_count": 5, "evidence_text": "auth failure from 87.142.124.10", "evidence_source": "log", @@ -78,6 +79,7 @@ class XarfLoginAttackStdlibTest(LogCaptureTestCase): self.assertEqual(r['type'], "login_attack") self.assertEqual(r['source_identifier'], "87.142.124.10") self.assertEqual(r['destination_port'], 22) + self.assertEqual(r['source_port'], 45621) self.assertEqual(r['protocol'], "tcp") self.assertEqual(r['service'], "sshd") self.assertEqual(r['attempt_count'], 5) From 5c08c2da176e7b591c13f2b844fb0079ef5b3a71 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Wed, 29 Jul 2026 21:36:23 +0200 Subject: [PATCH 15/19] DOC: gitignore local fail2ban-python link bin/fail2ban-python is a machine-local symlink generated by the test runner, not source to track. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5f1b8924..688e3a5e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ __pycache__ .vagrant/ .idea/ .venv/ +bin/fail2ban-python From 0fbe4f317da4f70e91d4c95f5ee69058dd30cb2b Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Thu, 30 Jul 2026 16:15:35 +0200 Subject: [PATCH 16/19] TST: consolidate XARF v4 tests into fail2ban/tests/action_d/test_xarf.py Move XarfV4ActionTest out of servertestcase.py and the generator tests out of the standalone xarfreporttestcase.py into a single action_d/test_xarf.py, matching the existing convention for testing python actions (see test_smtp.py). Files under action_d/test_*.py are auto-discovered, so the manual registration in utils.py gatherTests() is no longer needed. --- fail2ban/tests/action_d/test_xarf.py | 302 +++++++++++++++++++++++++++ fail2ban/tests/servertestcase.py | 157 -------------- fail2ban/tests/utils.py | 6 - fail2ban/tests/xarfreporttestcase.py | 142 ------------- 4 files changed, 302 insertions(+), 305 deletions(-) create mode 100644 fail2ban/tests/action_d/test_xarf.py delete mode 100644 fail2ban/tests/xarfreporttestcase.py diff --git a/fail2ban/tests/action_d/test_xarf.py b/fail2ban/tests/action_d/test_xarf.py new file mode 100644 index 00000000..605e4a12 --- /dev/null +++ b/fail2ban/tests/action_d/test_xarf.py @@ -0,0 +1,302 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- +# vi: set ft=python sts=4 ts=4 sw=4 noet : + +# This file is part of Fail2Ban. +# +# Fail2Ban is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# Fail2Ban is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Fail2Ban; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2025 Fail2Ban Developers" +__license__ = "GPL" + +import base64 +import hashlib +import os +import unittest + +from ...server import xarfreport +from ..dummyjail import DummyJail +from ..utils import CONFIG_DIR, Utils, LogCaptureTestCase + + +class XarfEvidenceTest(unittest.TestCase): + + def testBuildEvidenceStdlib(self): + text = "Dec 31 11:59:59 sshd: auth failure from 87.142.124.10" + ev = xarfreport._build_evidence_stdlib(text, description="logs") + self.assertEqual(ev['content_type'], "text/plain") + self.assertEqual(ev['description'], "logs") + # payload is base64 of the utf-8 text: + self.assertEqual( + base64.b64decode(ev['payload']).decode('utf-8'), text) + # hash is "sha256:": + digest = hashlib.sha256(text.encode('utf-8')).hexdigest() + self.assertEqual(ev['hash'], "sha256:" + digest) + self.assertEqual(ev['size'], len(text.encode('utf-8'))) + + def testBuildEvidenceStdlibNoDescription(self): + ev = xarfreport._build_evidence_stdlib("a log line") + self.assertNotIn('description', ev) + self.assertEqual(ev['content_type'], "text/plain") + + +class XarfLoginAttackStdlibTest(LogCaptureTestCase): + + def _data(self, **over): + data = { + "source_identifier": "87.142.124.10", + "timestamp": "2025-01-11T12:17:20Z", + "first_seen": "2025-01-11T06:17:20Z", + "protocol": "tcp", + "service": "sshd", + "destination_port": 22, + "source_port": 45621, + "attempt_count": 5, + "evidence_text": "auth failure from 87.142.124.10", + "evidence_source": "log", + "reporter": {"org": "Acme", "contact": "abuse@acme.example", + "domain": "acme.example"}, + "sender": {"org": "Acme", "contact": "abuse@acme.example", + "domain": "acme.example"}, + } + data.update(over) + return data + + def testBuildLoginAttackStdlib(self): + r = xarfreport._build_login_attack_stdlib(self._data()) + self.assertEqual(r['xarf_version'], xarfreport.XARF_VERSION_FALLBACK) + self.assertEqual(r['category'], "connection") + self.assertEqual(r['type'], "login_attack") + self.assertEqual(r['source_identifier'], "87.142.124.10") + self.assertEqual(r['destination_port'], 22) + self.assertEqual(r['source_port'], 45621) + self.assertEqual(r['protocol'], "tcp") + self.assertEqual(r['service'], "sshd") + self.assertEqual(r['attempt_count'], 5) + self.assertEqual(r['timestamp'], "2025-01-11T12:17:20Z") + self.assertEqual(r['first_seen'], "2025-01-11T06:17:20Z") + self.assertEqual(r['reporter']['org'], "Acme") + self.assertEqual(r['sender']['domain'], "acme.example") + self.assertEqual(len(r['evidence']), 1) + self.assertEqual(r['evidence'][0]['content_type'], "text/plain") + import uuid as _uuid + _uuid.UUID(r['report_id']) + + def testBuildLoginAttackStdlibOmitsMissingOptionals(self): + data = self._data() + del data['destination_port'] + del data['attempt_count'] + r = xarfreport._build_login_attack_stdlib(data) + self.assertNotIn('destination_port', r) + self.assertNotIn('attempt_count', r) + self.assertIn('source_identifier', r) + self.assertIn('protocol', r) + self.assertIn('first_seen', r) + + +class XarfBuildLoginAttackTest(XarfLoginAttackStdlibTest): + + def testPublicEntryPointReturnsValidShape(self): + # Regardless of whether the lib is present, the public function + # returns a dict with the required v4 fields. + r = xarfreport.build_login_attack(self._data()) + for field in ("xarf_version", "report_id", "timestamp", "reporter", + "sender", "source_identifier", "category", "type", + "protocol", "first_seen"): + self.assertIn(field, r) + self.assertEqual(r['category'], "connection") + self.assertEqual(r['type'], "login_attack") + + @unittest.skipUnless(xarfreport._HAVE_XARF, "xarf library not installed") + def testLibPathValidatesAgainstSchema(self): + # When the lib is present, output must pass xarf's own validation. + import xarf + r = xarfreport.build_login_attack(self._data()) + result = xarf.parse(r) + self.assertEqual(result.errors, []) + + def testFallbackOnLibError(self): + # Force the lib path to raise; expect a clean fallback + warning. + orig_flag = xarfreport._HAVE_XARF + orig_lib = xarfreport._build_login_attack_lib + try: + xarfreport._HAVE_XARF = True + def boom(data): + raise RuntimeError("simulated lib failure") + xarfreport._build_login_attack_lib = boom + r = xarfreport.build_login_attack(self._data()) + self.assertEqual(r['type'], "login_attack") + self.assertLogged("falling back to stdlib") + finally: + xarfreport._HAVE_XARF = orig_flag + xarfreport._build_login_attack_lib = orig_lib + + +class XarfV4ActionTest(LogCaptureTestCase): + + def setUp(self): + super(XarfV4ActionTest, self).setUp() + self.__jail = DummyJail() + actfile = os.path.join(CONFIG_DIR, "action.d", "xarf.py") + mod = Utils.load_python_module(actfile) + self.Action = mod.Action + + def _mk(self, **over): + opts = dict( + reporter_org="Acme", reporter_contact="abuse@acme.example", + reporter_domain="acme.example", + sender_org="Acme", sender_contact="abuse@acme.example", + sender_domain="acme.example", + service="sshd", port="22") + opts.update(over) + return self.Action(self.__jail, "xarf", **opts) + + def testMissingIdentitySkips(self): + act = self._mk(reporter_org=None) + sent = [] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act.ban({'ip': '87.142.124.10', 'failures': 3, + 'time': 1736597840, 'ipmatches': 'log line'}) + self.assertEqual(sent, []) + self.assertLogged("reporter/sender identity is incomplete") + + def testMissingSenderIdentitySkips(self): + act = self._mk(sender_domain=None) + sent = [] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act.ban({'ip': '87.142.124.10', 'failures': 3, + 'time': 1736597840, 'ipmatches': 'log line'}) + self.assertEqual(sent, []) + self.assertLogged("reporter/sender identity is incomplete") + + def testResolveParsesDigTxt(self): + act = self._mk() + # stub the dig invocation: return a TXT answer like dig +short + act._dig_txt = lambda fqdn: '"abuse-1@isp.example, abuse-2@isp.example"' + addrs = act._resolveAbuseContacts('87.142.124.10') + self.assertEqual(addrs, ["abuse-1@isp.example", "abuse-2@isp.example"]) + + def testResolveBuildsAbusixFqdn(self): + act = self._mk() + seen = [] + act._dig_txt = lambda fqdn: seen.append(fqdn) or '"abuse@isp.example"' + act._resolveAbuseContacts('87.142.124.10') + self.assertEqual(seen, ["10.124.142.87.abuse-contacts.abusix.org"]) + + def testResolveBuildsAbusixFqdnIPv6(self): + act = self._mk() + seen = [] + act._dig_txt = lambda fqdn: seen.append(fqdn) or '"abuse@isp.example"' + act._resolveAbuseContacts('2001:db8::1') + self.assertEqual(seen, ["1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.abuse-contacts.abusix.org"]) + + def testResolveEmptyOnNoAnswer(self): + act = self._mk() + act._dig_txt = lambda fqdn: '' + self.assertEqual(act._resolveAbuseContacts('87.142.124.10'), []) + + def testResolveRejectsFlagAndJunkTokens(self): + # DNS is untrusted: drop argv-flag-like and non-email tokens. + act = self._mk() + act._dig_txt = lambda fqdn: '"-X/tmp/evil, not-an-email, good@isp.example"' + self.assertEqual( + act._resolveAbuseContacts('87.142.124.10'), ["good@isp.example"]) + self.assertLogged("ignoring invalid abuse contact") + + def testBuildEmailStructure(self): + import base64 as _b64, json as _json + act = self._mk() + report = {"xarf_version": "4.2.0", "type": "login_attack", + "source_identifier": "87.142.124.10", + "timestamp": "2025-01-11T12:17:20Z", "report_id": "abc-123"} + msg = act._build_email(report) + self.assertEqual(msg.get_content_type(), "multipart/report") + self.assertEqual(msg.get_param("report-type"), "feedback-report") + parts = msg.get_payload() + self.assertEqual(len(parts), 3) + self.assertEqual(parts[0].get_content_type(), "text/plain") + self.assertEqual(parts[1].get_content_type(), "message/feedback-report") + fbtext = parts[1].get_payload() + self.assertIn("Feedback-Type: xarf", fbtext) + self.assertIn("User-Agent", fbtext) + self.assertIn("Version: 1", fbtext) + self.assertEqual(parts[2].get_content_type(), "application/json") + self.assertEqual(parts[2].get_filename(), "xarf.json") + decoded = _b64.b64decode(parts[2].get_payload()) + self.assertEqual(_json.loads(decoded)['source_identifier'], + "87.142.124.10") + # subject references the source IP: + self.assertIn("87.142.124.10", msg['Subject']) + + def testBanSendsReport(self): + import base64 as _b64, json as _json + act = self._mk(service="sshd", port="22") + sent = [] + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act.ban({'ip': '87.142.124.10', 'failures': 7, + 'time': 1736597840, 'ipmatches': 'Jan 11 sshd auth failure'}) + self.assertEqual(len(sent), 1) + recipients, msg = sent[0] + self.assertEqual(recipients, ["abuse@isp.example"]) + self.assertEqual(msg['To'], "abuse@isp.example") + jsonpart = msg.get_payload()[2] + report = _json.loads(_b64.b64decode(jsonpart.get_payload())) + self.assertEqual(report['source_identifier'], '87.142.124.10') + self.assertEqual(report['type'], 'login_attack') + self.assertEqual(report['destination_port'], 22) + self.assertEqual(report['attempt_count'], 7) + self.assertEqual(len(report['evidence']), 1) + + def testAInfoToDataDropsPortRange(self): + act = self._mk(port="0:65535") + d = act._aInfoToData({'ip': '1.2.3.4', 'time': 1736597840, + 'failures': 2, 'ipmatches': 'x'}) + self.assertNotIn('destination_port', d) + self.assertEqual(d['source_identifier'], '1.2.3.4') + + def testAInfoExtractsSourcePortFromSshdLine(self): + act = self._mk(port="22") + d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, + 'failures': 4, + 'ipmatches': 'Failed password for root from 203.0.113.5 port 52431 ssh2'}) + self.assertEqual(d['source_port'], 52431) + self.assertEqual(d['destination_port'], 22) + + def testAInfoNoSourcePortWhenAbsent(self): + act = self._mk(port="22") + d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, + 'failures': 4, + 'ipmatches': 'authentication failure for kevin from 203.0.113.5'}) + self.assertNotIn('source_port', d) + + def testAInfoSourcePortLastMatchWins(self): + act = self._mk(port="22") + d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, + 'failures': 2, + 'ipmatches': 'from 203.0.113.5 port 1111 ssh2\nfrom 203.0.113.5 port 2222 ssh2'}) + self.assertEqual(d['source_port'], 2222) + + def testBanNoContactNoSend(self): + act = self._mk() + sent = [] + act._resolveAbuseContacts = lambda ip: [] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act.ban({'ip': '87.142.124.10', 'failures': 1, + 'time': 1736597840, 'ipmatches': 'x'}) + self.assertEqual(sent, []) + self.assertLogged("no abuse contact") diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index e2531014..55b946bf 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2412,160 +2412,3 @@ class ServerConfigReaderTests(LogCaptureTestCase): action.ban(ticket) self.assertLogged(*tests[test], all=True) - -class XarfV4ActionTest(LogCaptureTestCase): - - def setUp(self): - super(XarfV4ActionTest, self).setUp() - self.__jail = DummyJail() - actfile = os.path.join(CONFIG_DIR, "action.d", "xarf.py") - mod = Utils.load_python_module(actfile) - self.Action = mod.Action - - def _mk(self, **over): - opts = dict( - reporter_org="Acme", reporter_contact="abuse@acme.example", - reporter_domain="acme.example", - sender_org="Acme", sender_contact="abuse@acme.example", - sender_domain="acme.example", - service="sshd", port="22") - opts.update(over) - return self.Action(self.__jail, "xarf", **opts) - - def testMissingIdentitySkips(self): - act = self._mk(reporter_org=None) - sent = [] - act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) - act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] - act.ban({'ip': '87.142.124.10', 'failures': 3, - 'time': 1736597840, 'ipmatches': 'log line'}) - self.assertEqual(sent, []) - self.assertLogged("reporter/sender identity is incomplete") - - def testMissingSenderIdentitySkips(self): - act = self._mk(sender_domain=None) - sent = [] - act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) - act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] - act.ban({'ip': '87.142.124.10', 'failures': 3, - 'time': 1736597840, 'ipmatches': 'log line'}) - self.assertEqual(sent, []) - self.assertLogged("reporter/sender identity is incomplete") - - def testResolveParsesDigTxt(self): - act = self._mk() - # stub the dig invocation: return a TXT answer like dig +short - act._dig_txt = lambda fqdn: '"abuse-1@isp.example, abuse-2@isp.example"' - addrs = act._resolveAbuseContacts('87.142.124.10') - self.assertEqual(addrs, ["abuse-1@isp.example", "abuse-2@isp.example"]) - - def testResolveBuildsAbusixFqdn(self): - act = self._mk() - seen = [] - act._dig_txt = lambda fqdn: seen.append(fqdn) or '"abuse@isp.example"' - act._resolveAbuseContacts('87.142.124.10') - self.assertEqual(seen, ["10.124.142.87.abuse-contacts.abusix.org"]) - - def testResolveBuildsAbusixFqdnIPv6(self): - act = self._mk() - seen = [] - act._dig_txt = lambda fqdn: seen.append(fqdn) or '"abuse@isp.example"' - act._resolveAbuseContacts('2001:db8::1') - self.assertEqual(seen, ["1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.abuse-contacts.abusix.org"]) - - def testResolveEmptyOnNoAnswer(self): - act = self._mk() - act._dig_txt = lambda fqdn: '' - self.assertEqual(act._resolveAbuseContacts('87.142.124.10'), []) - - def testResolveRejectsFlagAndJunkTokens(self): - # DNS is untrusted: drop argv-flag-like and non-email tokens. - act = self._mk() - act._dig_txt = lambda fqdn: '"-X/tmp/evil, not-an-email, good@isp.example"' - self.assertEqual( - act._resolveAbuseContacts('87.142.124.10'), ["good@isp.example"]) - self.assertLogged("ignoring invalid abuse contact") - - def testBuildEmailStructure(self): - import base64 as _b64, json as _json - act = self._mk() - report = {"xarf_version": "4.2.0", "type": "login_attack", - "source_identifier": "87.142.124.10", - "timestamp": "2025-01-11T12:17:20Z", "report_id": "abc-123"} - msg = act._build_email(report) - self.assertEqual(msg.get_content_type(), "multipart/report") - self.assertEqual(msg.get_param("report-type"), "feedback-report") - parts = msg.get_payload() - self.assertEqual(len(parts), 3) - self.assertEqual(parts[0].get_content_type(), "text/plain") - self.assertEqual(parts[1].get_content_type(), "message/feedback-report") - fbtext = parts[1].get_payload() - self.assertIn("Feedback-Type: xarf", fbtext) - self.assertIn("User-Agent", fbtext) - self.assertIn("Version: 1", fbtext) - self.assertEqual(parts[2].get_content_type(), "application/json") - self.assertEqual(parts[2].get_filename(), "xarf.json") - decoded = _b64.b64decode(parts[2].get_payload()) - self.assertEqual(_json.loads(decoded)['source_identifier'], - "87.142.124.10") - # subject references the source IP: - self.assertIn("87.142.124.10", msg['Subject']) - - def testBanSendsReport(self): - import base64 as _b64, json as _json - act = self._mk(service="sshd", port="22") - sent = [] - act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] - act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) - act.ban({'ip': '87.142.124.10', 'failures': 7, - 'time': 1736597840, 'ipmatches': 'Jan 11 sshd auth failure'}) - self.assertEqual(len(sent), 1) - recipients, msg = sent[0] - self.assertEqual(recipients, ["abuse@isp.example"]) - self.assertEqual(msg['To'], "abuse@isp.example") - jsonpart = msg.get_payload()[2] - report = _json.loads(_b64.b64decode(jsonpart.get_payload())) - self.assertEqual(report['source_identifier'], '87.142.124.10') - self.assertEqual(report['type'], 'login_attack') - self.assertEqual(report['destination_port'], 22) - self.assertEqual(report['attempt_count'], 7) - self.assertEqual(len(report['evidence']), 1) - - def testAInfoToDataDropsPortRange(self): - act = self._mk(port="0:65535") - d = act._aInfoToData({'ip': '1.2.3.4', 'time': 1736597840, - 'failures': 2, 'ipmatches': 'x'}) - self.assertNotIn('destination_port', d) - self.assertEqual(d['source_identifier'], '1.2.3.4') - - def testAInfoExtractsSourcePortFromSshdLine(self): - act = self._mk(port="22") - d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, - 'failures': 4, - 'ipmatches': 'Failed password for root from 203.0.113.5 port 52431 ssh2'}) - self.assertEqual(d['source_port'], 52431) - self.assertEqual(d['destination_port'], 22) - - def testAInfoNoSourcePortWhenAbsent(self): - act = self._mk(port="22") - d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, - 'failures': 4, - 'ipmatches': 'authentication failure for kevin from 203.0.113.5'}) - self.assertNotIn('source_port', d) - - def testAInfoSourcePortLastMatchWins(self): - act = self._mk(port="22") - d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, - 'failures': 2, - 'ipmatches': 'from 203.0.113.5 port 1111 ssh2\nfrom 203.0.113.5 port 2222 ssh2'}) - self.assertEqual(d['source_port'], 2222) - - def testBanNoContactNoSend(self): - act = self._mk() - sent = [] - act._resolveAbuseContacts = lambda ip: [] - act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) - act.ban({'ip': '87.142.124.10', 'failures': 1, - 'time': 1736597840, 'ipmatches': 'x'}) - self.assertEqual(sent, []) - self.assertLogged("no abuse contact") diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 9d08776e..6510e271 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -382,7 +382,6 @@ def gatherTests(regexps=None, opts=None): from . import samplestestcase from . import fail2banclienttestcase from . import fail2banregextestcase - from . import xarfreporttestcase if not regexps: # pragma: no cover tests = unittest.TestSuite() @@ -417,7 +416,6 @@ def gatherTests(regexps=None, opts=None): tests.addTest(loadTests(servertestcase.RegexTests)) tests.addTest(loadTests(servertestcase.LoggingTests)) tests.addTest(loadTests(servertestcase.ServerConfigReaderTests)) - tests.addTest(loadTests(servertestcase.XarfV4ActionTest)) tests.addTest(loadTests(actiontestcase.CommandActionTest)) tests.addTest(loadTests(actionstestcase.ExecuteActions)) # Ticket, BanTicket, FailTicket @@ -450,10 +448,6 @@ def gatherTests(regexps=None, opts=None): tests.addTest(loadTests(misctestcase.SetupTest)) tests.addTest(loadTests(misctestcase.TestsUtilsTest)) tests.addTest(loadTests(misctestcase.MyTimeTest)) - # XARF v4 reporting - tests.addTest(loadTests(xarfreporttestcase.XarfEvidenceTest)) - tests.addTest(loadTests(xarfreporttestcase.XarfLoginAttackStdlibTest)) - tests.addTest(loadTests(xarfreporttestcase.XarfBuildLoginAttackTest)) # Database tests.addTest(loadTests(databasetestcase.DatabaseTest)) # Observer diff --git a/fail2ban/tests/xarfreporttestcase.py b/fail2ban/tests/xarfreporttestcase.py deleted file mode 100644 index 2593e13f..00000000 --- a/fail2ban/tests/xarfreporttestcase.py +++ /dev/null @@ -1,142 +0,0 @@ -# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- -# vi: set ft=python sts=4 ts=4 sw=4 noet : - -# This file is part of Fail2Ban. -# -# Fail2Ban is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# Fail2Ban is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Fail2Ban; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -__author__ = "Fail2Ban Developers" -__copyright__ = "Copyright (c) 2025 Fail2Ban Developers" -__license__ = "GPL" - -import base64 -import hashlib -import unittest - -from ..server import xarfreport -from .utils import LogCaptureTestCase - - -class XarfEvidenceTest(unittest.TestCase): - - def testBuildEvidenceStdlib(self): - text = "Dec 31 11:59:59 sshd: auth failure from 87.142.124.10" - ev = xarfreport._build_evidence_stdlib(text, description="logs") - self.assertEqual(ev['content_type'], "text/plain") - self.assertEqual(ev['description'], "logs") - # payload is base64 of the utf-8 text: - self.assertEqual( - base64.b64decode(ev['payload']).decode('utf-8'), text) - # hash is "sha256:": - digest = hashlib.sha256(text.encode('utf-8')).hexdigest() - self.assertEqual(ev['hash'], "sha256:" + digest) - self.assertEqual(ev['size'], len(text.encode('utf-8'))) - - def testBuildEvidenceStdlibNoDescription(self): - ev = xarfreport._build_evidence_stdlib("a log line") - self.assertNotIn('description', ev) - self.assertEqual(ev['content_type'], "text/plain") - - -class XarfLoginAttackStdlibTest(LogCaptureTestCase): - - def _data(self, **over): - data = { - "source_identifier": "87.142.124.10", - "timestamp": "2025-01-11T12:17:20Z", - "first_seen": "2025-01-11T06:17:20Z", - "protocol": "tcp", - "service": "sshd", - "destination_port": 22, - "source_port": 45621, - "attempt_count": 5, - "evidence_text": "auth failure from 87.142.124.10", - "evidence_source": "log", - "reporter": {"org": "Acme", "contact": "abuse@acme.example", - "domain": "acme.example"}, - "sender": {"org": "Acme", "contact": "abuse@acme.example", - "domain": "acme.example"}, - } - data.update(over) - return data - - def testBuildLoginAttackStdlib(self): - r = xarfreport._build_login_attack_stdlib(self._data()) - self.assertEqual(r['xarf_version'], xarfreport.XARF_VERSION_FALLBACK) - self.assertEqual(r['category'], "connection") - self.assertEqual(r['type'], "login_attack") - self.assertEqual(r['source_identifier'], "87.142.124.10") - self.assertEqual(r['destination_port'], 22) - self.assertEqual(r['source_port'], 45621) - self.assertEqual(r['protocol'], "tcp") - self.assertEqual(r['service'], "sshd") - self.assertEqual(r['attempt_count'], 5) - self.assertEqual(r['timestamp'], "2025-01-11T12:17:20Z") - self.assertEqual(r['first_seen'], "2025-01-11T06:17:20Z") - self.assertEqual(r['reporter']['org'], "Acme") - self.assertEqual(r['sender']['domain'], "acme.example") - self.assertEqual(len(r['evidence']), 1) - self.assertEqual(r['evidence'][0]['content_type'], "text/plain") - import uuid as _uuid - _uuid.UUID(r['report_id']) - - def testBuildLoginAttackStdlibOmitsMissingOptionals(self): - data = self._data() - del data['destination_port'] - del data['attempt_count'] - r = xarfreport._build_login_attack_stdlib(data) - self.assertNotIn('destination_port', r) - self.assertNotIn('attempt_count', r) - self.assertIn('source_identifier', r) - self.assertIn('protocol', r) - self.assertIn('first_seen', r) - - -class XarfBuildLoginAttackTest(XarfLoginAttackStdlibTest): - - def testPublicEntryPointReturnsValidShape(self): - # Regardless of whether the lib is present, the public function - # returns a dict with the required v4 fields. - r = xarfreport.build_login_attack(self._data()) - for field in ("xarf_version", "report_id", "timestamp", "reporter", - "sender", "source_identifier", "category", "type", - "protocol", "first_seen"): - self.assertIn(field, r) - self.assertEqual(r['category'], "connection") - self.assertEqual(r['type'], "login_attack") - - @unittest.skipUnless(xarfreport._HAVE_XARF, "xarf library not installed") - def testLibPathValidatesAgainstSchema(self): - # When the lib is present, output must pass xarf's own validation. - import xarf - r = xarfreport.build_login_attack(self._data()) - result = xarf.parse(r) - self.assertEqual(result.errors, []) - - def testFallbackOnLibError(self): - # Force the lib path to raise; expect a clean fallback + warning. - orig_flag = xarfreport._HAVE_XARF - orig_lib = xarfreport._build_login_attack_lib - try: - xarfreport._HAVE_XARF = True - def boom(data): - raise RuntimeError("simulated lib failure") - xarfreport._build_login_attack_lib = boom - r = xarfreport.build_login_attack(self._data()) - self.assertEqual(r['type'], "login_attack") - self.assertLogged("falling back to stdlib") - finally: - xarfreport._HAVE_XARF = orig_flag - xarfreport._build_login_attack_lib = orig_lib From fe0d8191eefbf663651acf6bad56c7ab7038834e Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Sun, 23 Aug 2026 10:51:39 +0200 Subject: [PATCH 17/19] BF+ENH+TST: fix XARF v4 field-mapping and delivery bugs from review Addresses review feedback on the xarf action/generator: - xarf.py: anchor source_port extraction to the banned IP itself, rather than taking the last "port N" match in the log line. sshd's verbose "Connection from port NNNNN on 127.0.0.1 port 22" line otherwise yielded the local listening port instead of the attacker's. - xarf.py: replace int(self.port) with a real parser for fail2ban's port option (service name, comma list, or range) so destination_port is populated for the values jail.conf actually passes (`ssh`, `http,https`, `0:65535`), and never emits destination_port: 0, which violates the schema's minimum of 1. - xarf.py: skip reporting entirely when a ban ticket carries no log evidence (e.g. a manual `fail2ban-client set banip `), instead of sending a report claiming attempt_count: 0. - xarf.py: pass -f to the mail command by default (new mailargs option to override), and resolve the default envelope sender via DNSUtils.getHostname() instead of a raw socket.getfqdn(). Fixes SPF/DMARC alignment and the localhost/PTR edge cases. - jail.conf: pass sender="%(sender)s" into action_xarf_v4, matching action_mw/action_mwl/legacy action_xarf; it was previously dropped on the floor. - xarfreport.py: catch more than ImportError around the optional xarf import - an incompatible install (e.g. pydantic mismatch) could otherwise crash the whole action instead of falling back to stdlib. - xarfreport.py: the xarf library ignores the timestamp we pass to create_report() and stamps its own, leaving the document internally inconsistent with first_seen; now enforced on the returned dict. - xarfreport.py: distinguish "library unavailable/erroring" (fall back to stdlib and still send) from "library validated and rejected the report" (XarfValidationError; drop the report rather than send the same invalid data unvalidated via the stdlib builder). - xarf.py: fix malformed emacs modeline. - ChangeLog: add missing gh-3820 refs. 10 new regression tests in fail2ban/tests/action_d/test_xarf.py, one per bug above (including a real broken-xarf-install import-guard test via module reload). --- ChangeLog | 4 +- config/action.d/xarf.py | 93 ++++++++++++----- config/jail.conf | 1 + fail2ban/server/xarfreport.py | 47 +++++++-- fail2ban/tests/action_d/test_xarf.py | 146 +++++++++++++++++++++++++++ 5 files changed, 258 insertions(+), 33 deletions(-) diff --git a/ChangeLog b/ChangeLog index f17bbebd..be4f0d1a 100644 --- a/ChangeLog +++ b/ChangeLog @@ -103,7 +103,7 @@ ver. 1.1.1 (2026/08/15) - triple-one-win ### Deprecations * `action.d/xarf-login-attack.conf` - deprecated (emits obsolete x-arf 0.2); - superseded by the `xarf` action (XARF v4). Will be removed in a future release. + superseded by the `xarf` action (XARF v4). Will be removed in a future release (gh-3820) ### New Features and Enhancements * `fail2ban-server` supports `systemd` socket activation (`sd_listen_fds(3)`); optional `fail2ban.socket` unit provided @@ -152,7 +152,7 @@ ver. 1.1.1 (2026/08/15) - triple-one-win (https://xarf.org) by e-mail to the IP's abuse contact (resolved via the Abusix Contact DB). Uses the optional `xarf` python package for schema validation when installed, otherwise a built-in stdlib generator. Enable - via `action_xarf_v4` in jail.conf. + via `action_xarf_v4` in jail.conf (gh-3820) ver. 1.1.0 (2024/04/25) - object-found--norad-59479-cospar-2024-069a--altitude-36267km diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index 9a938848..6eb4a8b7 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -1,4 +1,4 @@ -# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t : +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- # vi: set ft=python sts=4 ts=4 sw=4 noet : # This file is part of Fail2Ban. @@ -57,12 +57,12 @@ class XarfV4Action(ActionBase): reporter_org=None, reporter_contact=None, reporter_domain=None, sender_org=None, sender_contact=None, sender_domain=None, service="unspecified", port="0", protocol="tcp", - sender=None, mailcmd="/usr/sbin/sendmail", - resolver="abuse-contacts.abusix.org", matches="ipmatches", - source_port_regex=r"\bport (\d{1,5})\b"): + sender=None, mailcmd="/usr/sbin/sendmail", mailargs=None, + resolver="abuse-contacts.abusix.org", matches="ipmatches"): # reporter_*/sender_* are the XARF v4 report identities; the separate # `sender` arg is the e-mail envelope From address (self.envelope_from). super(XarfV4Action, self).__init__(jail, name) + from fail2ban.server.ipdns import DNSUtils self.reporter = {"org": reporter_org, "contact": reporter_contact, "domain": reporter_domain} self.sender_id = {"org": sender_org, "contact": sender_contact, @@ -70,12 +70,13 @@ class XarfV4Action(ActionBase): self.service = service self.port = port self.protocol = protocol - self.envelope_from = sender or ("fail2ban@" + socket.getfqdn()) + self.envelope_from = sender or ("fail2ban@" + DNSUtils.getHostname()) self.mailcmd = mailcmd + # default envelope-sender flag so SPF/DMARC can align with envelope_from; + # operators can override entirely via mailargs. + self.mailargs = mailargs if mailargs is not None else ("-f " + self.envelope_from) self.resolver = resolver self.matches = matches - self.source_port_regex = source_port_regex - self._source_port_cre = re.compile(source_port_regex) if source_port_regex else None # bypass ban/unban for restored tickets self.norestored = 1 @@ -170,30 +171,56 @@ class XarfV4Action(ActionBase): def _iso(self, epoch): return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(float(epoch))) - def _extract_source_port(self, text): + def _extract_source_port(self, text, ip): """Best-effort: extract the attacker source port from matched log text. fail2ban generally knows only the attacked (destination) port; some - services (e.g. sshd) log the source port in the failure line. When - present, the most recent (triggering) match is used. Returns an int - in 1..65535, or None when unavailable. + services (e.g. sshd) log the source port in the failure line. The + pattern is anchored to the banned IP itself, since a single log line + can name more than one " port " pair - e.g. sshd's verbose + "Connection from port 51353 on 127.0.0.1 port 22" also names the + local listening port, which is not the attacker's port. When present, + the most recent (triggering) match is used. Returns an int in + 1..65535, or None when unavailable. """ - if not self._source_port_cre or not text: + if not text: return None - for val in reversed(self._source_port_cre.findall(text)): + cre = re.compile( + r'\b' + re.escape(str(ip)) + r'\s+port\s+(\d{1,5})\b') + matches = cre.findall(text) + if not matches: + return None + try: + p = int(matches[-1]) + except (TypeError, ValueError): + return None + return p if 1 <= p <= 65535 else None + + def _resolve_port(self, port): + """Best-effort parse of fail2ban's `port` option into a single XARF + v4 `destination_port`. + + `port` may be a service name (``ssh``), a comma-separated list + (``http,https``) or a colon/dash range (``0:65535``); XARF v4 wants + one numeric port in 1..65535, so anything else is treated as unknown + (``None``) rather than guessed at. + """ + if not port: + return None + first = str(port).split(',', 1)[0].strip() + if not first or ':' in first or '-' in first: + return None + try: + p = int(first) + except (TypeError, ValueError): try: - p = int(val) - except (TypeError, ValueError): - continue - if 1 <= p <= 65535: - return p - return None + p = socket.getservbyname(first) + except OSError: + return None + return p if 1 <= p <= 65535 else None def _aInfoToData(self, aInfo): - try: - dport = int(self.port) - except (TypeError, ValueError): - dport = None + dport = self._resolve_port(self.port) ts = self._iso(aInfo.get('time') or time.time()) evidence = aInfo.get(self.matches) or aInfo.get('matches') or '' failures = aInfo.get('failures') @@ -211,13 +238,14 @@ class XarfV4Action(ActionBase): } if dport is not None: data["destination_port"] = dport - sport = self._extract_source_port(evidence) + sport = self._extract_source_port(evidence, aInfo['ip']) if sport is not None: data["source_port"] = sport return data def _sendmail(self, recipients, msg): - cmd = shlex.split(self.mailcmd) + ['--'] + list(recipients) + cmd = (shlex.split(self.mailcmd) + shlex.split(self.mailargs) + + ['--'] + list(recipients)) p = subprocess.Popen(cmd, stdin=subprocess.PIPE) try: p.communicate(msg.as_bytes(), timeout=30) @@ -240,6 +268,13 @@ class XarfV4Action(ActionBase): "set reporter_org/contact/domain and sender_org/contact/" "domain - skipping report", self._name) return + if not (aInfo.get(self.matches) or aInfo.get('matches')): + # e.g. a manual `fail2ban-client set banip ` ticket: + # no log evidence to report and nothing to substantiate a claim. + self._logSys.info( + "xarf action %s: no evidence for %s (manual ban?); skipping " + "report", self._name, aInfo.get('ip')) + return contacts = self._resolveAbuseContacts(str(aInfo['ip'])) if not contacts: self._logSys.info( @@ -249,6 +284,14 @@ class XarfV4Action(ActionBase): try: data = self._aInfoToData(aInfo) report = xarfreport.build_login_attack(data) + if report is None: + # the xarf library validated the report and rejected it as + # schema-invalid; sending it unvalidated would be worse than + # not reporting at all. + self._logSys.warning( + "xarf action %s: report for %s failed schema " + "validation; not sending", self._name, aInfo.get('ip')) + return msg = self._build_email(report) msg["To"] = ", ".join(contacts) self._sendmail(contacts, msg) diff --git a/config/jail.conf b/config/jail.conf index d04b3c93..39afe1f9 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -246,6 +246,7 @@ action_xarf = %(action_)s # a built-in generator is used otherwise. action_xarf_v4 = %(action_)s xarf.py[service="%(__name__)s", port="%(port)s", protocol="%(protocol)s", + sender="%(sender)s", reporter_org="%(xarf_reporter_org)s", reporter_contact="%(xarf_reporter_contact)s", reporter_domain="%(xarf_reporter_domain)s", sender_org="%(xarf_sender_org)s", sender_contact="%(xarf_sender_contact)s", diff --git a/fail2ban/server/xarfreport.py b/fail2ban/server/xarfreport.py index 238ce826..f5653282 100644 --- a/fail2ban/server/xarfreport.py +++ b/fail2ban/server/xarfreport.py @@ -41,7 +41,13 @@ try: # Only use the library when it exposes the expected XARF v4 generator API # (guards against an unrelated/older package squatting the `xarf` name). _HAVE_XARF = hasattr(_xarf, "create_report") and hasattr(_xarf, "create_evidence") -except ImportError: # pragma: no cover - depends on optional install +except Exception as e: # pragma: no cover - depends on optional install + # Broad on purpose: a broken/incompatible install (e.g. a pydantic + # version mismatch) can raise more than ImportError at import time, and + # that must not take down the whole action - fall back to the stdlib + # builder instead. + logSys.warning("xarf library unusable (%s: %s); using stdlib builder", + type(e).__name__, e) _xarf = None _HAVE_XARF = False @@ -52,6 +58,17 @@ CATEGORY = "connection" TYPE = "login_attack" +class XarfValidationError(Exception): + """Raised when the xarf library validated a report and rejected it. + + Kept distinct from other failures (missing dependency, unexpected + library errors) so build_login_attack() can choose not to send a + document the library has already told us is schema-invalid, rather + than silently shipping the same data unvalidated via the stdlib + fallback. + """ + + def _build_evidence_stdlib(text, description=None): """Return a single XARF v4 evidence item for a block of log text.""" raw = text.encode('utf-8') @@ -117,19 +134,37 @@ def _build_login_attack_lib(data): sender=dict(data["sender"]), **kwargs) if result.errors or result.report is None: - raise ValueError("xarf validation failed: %r" % (result.errors,)) - return result.report.model_dump(by_alias=True, exclude_none=True) + raise XarfValidationError( + "xarf validation failed: %r" % (result.errors,)) + report = result.report.model_dump(by_alias=True, exclude_none=True) + # create_report() stamps `timestamp` with its own value (now) rather than + # honoring the one we pass in, leaving the document internally + # inconsistent with `first_seen`; enforce the ban-time timestamp we were + # given. + report["timestamp"] = data["timestamp"] + return report def build_login_attack(data): - """Return a XARF v4 login_attack report dict from fail2ban ban data. + """Return a XARF v4 login_attack report dict from fail2ban ban data, or + None if the report is known to be schema-invalid. - Uses the official ``xarf`` library when available; on any error falls - back to the stdlib builder so a report is still produced. + Uses the official ``xarf`` library when available. If the library + itself is unavailable or fails unexpectedly, falls back to the stdlib + builder so a report is still produced (availability over strictness). + If the library successfully validates the data and rejects it as + non-conformant, no report is returned at all: sending a document the + library has already flagged as invalid would be worse than not + reporting. """ if _HAVE_XARF: try: return _build_login_attack_lib(data) + except XarfValidationError as e: + logSys.error( + "xarf library rejected report as schema-invalid (%s); " + "not sending an unvalidated fallback", e) + return None except Exception as e: # noqa: broad - never block reporting logSys.warning( "xarf library report build failed (%s: %s); " diff --git a/fail2ban/tests/action_d/test_xarf.py b/fail2ban/tests/action_d/test_xarf.py index 605e4a12..fed0a860 100644 --- a/fail2ban/tests/action_d/test_xarf.py +++ b/fail2ban/tests/action_d/test_xarf.py @@ -23,7 +23,10 @@ __license__ = "GPL" import base64 import hashlib +import importlib import os +import subprocess +import sys import unittest from ...server import xarfreport @@ -143,6 +146,50 @@ class XarfBuildLoginAttackTest(XarfLoginAttackStdlibTest): xarfreport._HAVE_XARF = orig_flag xarfreport._build_login_attack_lib = orig_lib + def testDropsReportOnLibValidationRejection(self): + # When the lib validates and rejects the data (e.g. a genuinely + # missing required field), no report should be sent at all - not + # even the unvalidated stdlib version of the same broken data. + orig_flag = xarfreport._HAVE_XARF + orig_lib = xarfreport._build_login_attack_lib + try: + xarfreport._HAVE_XARF = True + def reject(data): + raise xarfreport.XarfValidationError( + "xarf validation failed: " + "[\"'source_port' is a required property\"]") + xarfreport._build_login_attack_lib = reject + r = xarfreport.build_login_attack(self._data()) + self.assertIsNone(r) + self.assertLogged("rejected report as schema-invalid") + finally: + xarfreport._HAVE_XARF = orig_flag + xarfreport._build_login_attack_lib = orig_lib + + +class XarfImportGuardTest(LogCaptureTestCase): + + def testHaveXarfFalseOnBrokenImport(self): + # A broken/incompatible `xarf` install (e.g. a pydantic version + # mismatch) can raise more than ImportError while `xarf` itself + # imports fine but blows up on attribute access; that must not + # crash the whole module import. + class _BrokenXarf(object): + def __getattr__(self, name): + raise RuntimeError("simulated broken xarf install") + orig_mod = sys.modules.get('xarf') + sys.modules['xarf'] = _BrokenXarf() + try: + importlib.reload(xarfreport) + self.assertFalse(xarfreport._HAVE_XARF) + self.assertLogged("xarf library unusable") + finally: + if orig_mod is not None: + sys.modules['xarf'] = orig_mod + else: + sys.modules.pop('xarf', None) + importlib.reload(xarfreport) + class XarfV4ActionTest(LogCaptureTestCase): @@ -269,6 +316,25 @@ class XarfV4ActionTest(LogCaptureTestCase): self.assertNotIn('destination_port', d) self.assertEqual(d['source_identifier'], '1.2.3.4') + def testAInfoToDataResolvesServiceName(self): + act = self._mk(port="ssh") + d = act._aInfoToData({'ip': '1.2.3.4', 'time': 1736597840, + 'failures': 2, 'ipmatches': 'x'}) + self.assertEqual(d['destination_port'], 22) + + def testAInfoToDataCommaListUsesFirstEntry(self): + act = self._mk(port="http,https") + d = act._aInfoToData({'ip': '1.2.3.4', 'time': 1736597840, + 'failures': 2, 'ipmatches': 'x'}) + self.assertEqual(d['destination_port'], 80) + + def testAInfoToDataOmitsZeroPort(self): + # port="0" "parses" but 0 violates the schema's minimum of 1. + act = self._mk(port="0") + d = act._aInfoToData({'ip': '1.2.3.4', 'time': 1736597840, + 'failures': 2, 'ipmatches': 'x'}) + self.assertNotIn('destination_port', d) + def testAInfoExtractsSourcePortFromSshdLine(self): act = self._mk(port="22") d = act._aInfoToData({'ip': '203.0.113.5', 'time': 1736597840, @@ -291,6 +357,86 @@ class XarfV4ActionTest(LogCaptureTestCase): 'ipmatches': 'from 203.0.113.5 port 1111 ssh2\nfrom 203.0.113.5 port 2222 ssh2'}) self.assertEqual(d['source_port'], 2222) + def testAInfoSourcePortIgnoresServicePortInVerboseLine(self): + # LogLevel VERBOSE sshd logs both the attacker's port and the local + # listening port on the same line; only the one next to the + # attacker's own IP is the attacker's source port. + act = self._mk(port="22") + d = act._aInfoToData({'ip': '115.249.163.77', 'time': 1736597840, + 'failures': 1, + 'ipmatches': + 'Connection from 115.249.163.77 port 51353 on 127.0.0.1 port 22'}) + self.assertEqual(d['source_port'], 51353) + + def testBanSkipsWhenNoEvidence(self): + # e.g. `fail2ban-client set banip `: no log evidence at + # all, nothing to substantiate a complaint. + act = self._mk() + sent = [] + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + act.ban({'ip': '87.142.124.10', 'failures': 0, 'time': 1736597840, + 'ipmatches': []}) + self.assertEqual(sent, []) + self.assertLogged("no evidence") + + def testBanSkipsWhenReportFailsValidation(self): + act = self._mk() + sent = [] + act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] + act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) + orig = xarfreport.build_login_attack + xarfreport.build_login_attack = lambda data: None + try: + act.ban({'ip': '87.142.124.10', 'failures': 3, + 'time': 1736597840, 'ipmatches': 'log line'}) + finally: + xarfreport.build_login_attack = orig + self.assertEqual(sent, []) + self.assertLogged("failed schema validation; not sending") + + def testSendmailPassesEnvelopeFromFlagByDefault(self): + act = self._mk(sender="fail2ban@example.com") + captured = {} + class FakeProc(object): + def __init__(self, cmd, **kw): + captured['cmd'] = cmd + self.returncode = 0 + def communicate(self, data=None, timeout=None): + return (b'', b'') + orig_popen = subprocess.Popen + subprocess.Popen = FakeProc + try: + act._sendmail(["abuse@isp.example"], + act._build_email({"source_identifier": "1.2.3.4"})) + finally: + subprocess.Popen = orig_popen + self.assertIn('-f', captured['cmd']) + self.assertEqual( + captured['cmd'][captured['cmd'].index('-f') + 1], + "fail2ban@example.com") + + def testSendmailRespectsMailargsOverride(self): + act = self._mk(mailargs="-oi -f custom@example.com") + captured = {} + class FakeProc(object): + def __init__(self, cmd, **kw): + captured['cmd'] = cmd + self.returncode = 0 + def communicate(self, data=None, timeout=None): + return (b'', b'') + orig_popen = subprocess.Popen + subprocess.Popen = FakeProc + try: + act._sendmail(["abuse@isp.example"], + act._build_email({"source_identifier": "1.2.3.4"})) + finally: + subprocess.Popen = orig_popen + self.assertEqual( + captured['cmd'][captured['cmd'].index('-f') + 1], + "custom@example.com") + self.assertIn('-oi', captured['cmd']) + def testBanNoContactNoSend(self): act = self._mk() sent = [] From 17328f4643b98957563ad13c350a2202bdb72cc9 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Thu, 17 Sep 2026 13:30:00 +0200 Subject: [PATCH 18/19] ENH+TST: fold xarfreport generator into config/action.d/xarf.py Per review: fail2ban/server/xarfreport.py had exactly one consumer (this action), and the only other Python action (smtp.py) is fully self-contained with no bespoke helper module in fail2ban/server/. Move the generator (build_login_attack and friends) directly into xarf.py to match that convention; extract it again if a second XARF-based transport ever needs to share it. Test classes now load the generator functions off the dynamically loaded action module (fail2ban/tests/action_d/test_xarf.py already does this for the action itself) instead of importing a package module. The module-level logger is now named explicitly ("fail2ban.xarf") since __name__ is just "xarf" for a standalone- loaded module and would otherwise sit outside the fail2ban.* logger hierarchy fail2ban's log handlers (and test log capture) attach to. --- config/action.d/xarf.py | 161 ++++++++++++++++++++++++- fail2ban/server/xarfreport.py | 172 --------------------------- fail2ban/tests/action_d/test_xarf.py | 81 +++++++------ 3 files changed, 206 insertions(+), 208 deletions(-) delete mode 100644 fail2ban/server/xarfreport.py diff --git a/config/action.d/xarf.py b/config/action.d/xarf.py index 6eb4a8b7..80a4f04b 100644 --- a/config/action.d/xarf.py +++ b/config/action.d/xarf.py @@ -31,20 +31,177 @@ action where you are confident the activity is genuinely abusive, e.g.: * filters with a low likelihood of false positives. """ +import base64 +import hashlib import json import re import shlex import socket import subprocess import time +import uuid from email import encoders from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from email.utils import formatdate, make_msgid +from fail2ban.helpers import getLogger from fail2ban.server.actions import ActionBase -from fail2ban.server import xarfreport + +# Loaded standalone (see Utils.load_python_module), so __name__ is just +# "xarf" - name explicitly under the fail2ban.* hierarchy so it reaches +# the daemon's configured log handlers (and the test log capture). +logSys = getLogger("fail2ban.xarf") + +# --- XARF v4 report generator ----------------------------------------- +# +# Builds the XARF v4 login_attack document from fail2ban ban data. Kept +# local to this action (its only consumer today) rather than in +# fail2ban/server/: if a second XARF-based transport needs it later, it +# can be extracted then. +# +# Uses the official ``xarf`` library when importable (full schema +# validation and spec-tracking); otherwise falls back to a stdlib-only +# builder that produces the same document shape. + +try: + import xarf as _xarf + # Only use the library when it exposes the expected XARF v4 generator API + # (guards against an unrelated/older package squatting the `xarf` name). + _HAVE_XARF = hasattr(_xarf, "create_report") and hasattr(_xarf, "create_evidence") +except Exception as e: # pragma: no cover - depends on optional install + # Broad on purpose: a broken/incompatible install (e.g. a pydantic + # version mismatch) can raise more than ImportError at import time, and + # that must not take down the whole action - fall back to the stdlib + # builder instead. + logSys.warning("xarf library unusable (%s: %s); using stdlib builder", + type(e).__name__, e) + _xarf = None + _HAVE_XARF = False + +# Pinned fallback spec version used by the stdlib builder when the +# official ``xarf`` library is not installed. +XARF_VERSION_FALLBACK = "4.2.0" +CATEGORY = "connection" +TYPE = "login_attack" + + +class XarfValidationError(Exception): + """Raised when the xarf library validated a report and rejected it. + + Kept distinct from other failures (missing dependency, unexpected + library errors) so build_login_attack() can choose not to send a + document the library has already told us is schema-invalid, rather + than silently shipping the same data unvalidated via the stdlib + fallback. + """ + + +def _build_evidence_stdlib(text, description=None): + """Return a single XARF v4 evidence item for a block of log text.""" + raw = text.encode('utf-8') + ev = { + "content_type": "text/plain", + "payload": base64.b64encode(raw).decode('ascii'), + "hash": "sha256:" + hashlib.sha256(raw).hexdigest(), + "size": len(raw), + } + if description is not None: + ev["description"] = description + return ev + + +def _build_login_attack_stdlib(data): + """Build a XARF v4 login_attack report dict using only the stdlib.""" + report = { + "xarf_version": XARF_VERSION_FALLBACK, + "report_id": str(uuid.uuid4()), + "timestamp": data["timestamp"], + "reporter": dict(data["reporter"]), + "sender": dict(data["sender"]), + "source_identifier": data["source_identifier"], + "category": CATEGORY, + "type": TYPE, + "protocol": data["protocol"], + "first_seen": data["first_seen"], + } + # optional fields — include only when present and not None: + for key in ("evidence_source", "service", "destination_ip", + "destination_port", "source_port", "attempt_count"): + val = data.get(key) + if val is not None: + report[key] = val + evtext = data.get("evidence_text") + if evtext: + report["evidence"] = [ + _build_evidence_stdlib(evtext, description="fail2ban log matches")] + return report + + +def _build_login_attack_lib(data): + """Build + validate a login_attack report via the official xarf lib.""" + kwargs = { + "protocol": data["protocol"], + "first_seen": data["first_seen"], + "timestamp": data["timestamp"], + } + for key in ("evidence_source", "service", "destination_ip", + "destination_port", "source_port", "attempt_count"): + val = data.get(key) + if val is not None: + kwargs[key] = val + evtext = data.get("evidence_text") + if evtext: + kwargs["evidence"] = [_xarf.create_evidence( + "text/plain", evtext, description="fail2ban log matches")] + result = _xarf.create_report( + category=CATEGORY, + type=TYPE, + source_identifier=data["source_identifier"], + reporter=dict(data["reporter"]), + sender=dict(data["sender"]), + **kwargs) + if result.errors or result.report is None: + raise XarfValidationError( + "xarf validation failed: %r" % (result.errors,)) + report = result.report.model_dump(by_alias=True, exclude_none=True) + # create_report() stamps `timestamp` with its own value (now) rather than + # honoring the one we pass in, leaving the document internally + # inconsistent with `first_seen`; enforce the ban-time timestamp we were + # given. + report["timestamp"] = data["timestamp"] + return report + + +def build_login_attack(data): + """Return a XARF v4 login_attack report dict from fail2ban ban data, or + None if the report is known to be schema-invalid. + + Uses the official ``xarf`` library when available. If the library + itself is unavailable or fails unexpectedly, falls back to the stdlib + builder so a report is still produced (availability over strictness). + If the library successfully validates the data and rejects it as + non-conformant, no report is returned at all: sending a document the + library has already flagged as invalid would be worse than not + reporting. + """ + if _HAVE_XARF: + try: + return _build_login_attack_lib(data) + except XarfValidationError as e: + logSys.error( + "xarf library rejected report as schema-invalid (%s); " + "not sending an unvalidated fallback", e) + return None + except Exception as e: # noqa: broad - never block reporting + logSys.warning( + "xarf library report build failed (%s: %s); " + "falling back to stdlib builder", type(e).__name__, e) + return _build_login_attack_stdlib(data) + + +# --- XARF v4 email action ----------------------------------------- # Strict address validation for abuse contacts resolved from DNS (untrusted): # rejects anything that is not an email and anything that could be an argv flag. @@ -283,7 +440,7 @@ class XarfV4Action(ActionBase): return try: data = self._aInfoToData(aInfo) - report = xarfreport.build_login_attack(data) + report = build_login_attack(data) if report is None: # the xarf library validated the report and rejected it as # schema-invalid; sending it unvalidated would be worse than diff --git a/fail2ban/server/xarfreport.py b/fail2ban/server/xarfreport.py deleted file mode 100644 index f5653282..00000000 --- a/fail2ban/server/xarfreport.py +++ /dev/null @@ -1,172 +0,0 @@ -# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- -# vi: set ft=python sts=4 ts=4 sw=4 noet : - -# This file is part of Fail2Ban. -# -# Fail2Ban is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# Fail2Ban is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Fail2Ban; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - -__author__ = "Fail2Ban Developers" -__copyright__ = "Copyright (c) 2025 Fail2Ban Developers" -__license__ = "GPL" - -"""Build XARF v4 abuse reports from fail2ban ban data. - -Uses the official ``xarf`` library when importable (full schema -validation and spec-tracking); otherwise falls back to a stdlib-only -builder that produces the same document shape. -""" - -import base64 -import hashlib -import uuid - -from ..helpers import getLogger - -logSys = getLogger(__name__) - -try: - import xarf as _xarf - # Only use the library when it exposes the expected XARF v4 generator API - # (guards against an unrelated/older package squatting the `xarf` name). - _HAVE_XARF = hasattr(_xarf, "create_report") and hasattr(_xarf, "create_evidence") -except Exception as e: # pragma: no cover - depends on optional install - # Broad on purpose: a broken/incompatible install (e.g. a pydantic - # version mismatch) can raise more than ImportError at import time, and - # that must not take down the whole action - fall back to the stdlib - # builder instead. - logSys.warning("xarf library unusable (%s: %s); using stdlib builder", - type(e).__name__, e) - _xarf = None - _HAVE_XARF = False - -# Pinned fallback spec version used by the stdlib builder when the -# official ``xarf`` library is not installed. -XARF_VERSION_FALLBACK = "4.2.0" -CATEGORY = "connection" -TYPE = "login_attack" - - -class XarfValidationError(Exception): - """Raised when the xarf library validated a report and rejected it. - - Kept distinct from other failures (missing dependency, unexpected - library errors) so build_login_attack() can choose not to send a - document the library has already told us is schema-invalid, rather - than silently shipping the same data unvalidated via the stdlib - fallback. - """ - - -def _build_evidence_stdlib(text, description=None): - """Return a single XARF v4 evidence item for a block of log text.""" - raw = text.encode('utf-8') - ev = { - "content_type": "text/plain", - "payload": base64.b64encode(raw).decode('ascii'), - "hash": "sha256:" + hashlib.sha256(raw).hexdigest(), - "size": len(raw), - } - if description is not None: - ev["description"] = description - return ev - - -def _build_login_attack_stdlib(data): - """Build a XARF v4 login_attack report dict using only the stdlib.""" - report = { - "xarf_version": XARF_VERSION_FALLBACK, - "report_id": str(uuid.uuid4()), - "timestamp": data["timestamp"], - "reporter": dict(data["reporter"]), - "sender": dict(data["sender"]), - "source_identifier": data["source_identifier"], - "category": CATEGORY, - "type": TYPE, - "protocol": data["protocol"], - "first_seen": data["first_seen"], - } - # optional fields — include only when present and not None: - for key in ("evidence_source", "service", "destination_ip", - "destination_port", "source_port", "attempt_count"): - val = data.get(key) - if val is not None: - report[key] = val - evtext = data.get("evidence_text") - if evtext: - report["evidence"] = [ - _build_evidence_stdlib(evtext, description="fail2ban log matches")] - return report - - -def _build_login_attack_lib(data): - """Build + validate a login_attack report via the official xarf lib.""" - kwargs = { - "protocol": data["protocol"], - "first_seen": data["first_seen"], - "timestamp": data["timestamp"], - } - for key in ("evidence_source", "service", "destination_ip", - "destination_port", "source_port", "attempt_count"): - val = data.get(key) - if val is not None: - kwargs[key] = val - evtext = data.get("evidence_text") - if evtext: - kwargs["evidence"] = [_xarf.create_evidence( - "text/plain", evtext, description="fail2ban log matches")] - result = _xarf.create_report( - category=CATEGORY, - type=TYPE, - source_identifier=data["source_identifier"], - reporter=dict(data["reporter"]), - sender=dict(data["sender"]), - **kwargs) - if result.errors or result.report is None: - raise XarfValidationError( - "xarf validation failed: %r" % (result.errors,)) - report = result.report.model_dump(by_alias=True, exclude_none=True) - # create_report() stamps `timestamp` with its own value (now) rather than - # honoring the one we pass in, leaving the document internally - # inconsistent with `first_seen`; enforce the ban-time timestamp we were - # given. - report["timestamp"] = data["timestamp"] - return report - - -def build_login_attack(data): - """Return a XARF v4 login_attack report dict from fail2ban ban data, or - None if the report is known to be schema-invalid. - - Uses the official ``xarf`` library when available. If the library - itself is unavailable or fails unexpectedly, falls back to the stdlib - builder so a report is still produced (availability over strictness). - If the library successfully validates the data and rejects it as - non-conformant, no report is returned at all: sending a document the - library has already flagged as invalid would be worse than not - reporting. - """ - if _HAVE_XARF: - try: - return _build_login_attack_lib(data) - except XarfValidationError as e: - logSys.error( - "xarf library rejected report as schema-invalid (%s); " - "not sending an unvalidated fallback", e) - return None - except Exception as e: # noqa: broad - never block reporting - logSys.warning( - "xarf library report build failed (%s: %s); " - "falling back to stdlib builder", type(e).__name__, e) - return _build_login_attack_stdlib(data) diff --git a/fail2ban/tests/action_d/test_xarf.py b/fail2ban/tests/action_d/test_xarf.py index fed0a860..215e0031 100644 --- a/fail2ban/tests/action_d/test_xarf.py +++ b/fail2ban/tests/action_d/test_xarf.py @@ -23,22 +23,33 @@ __license__ = "GPL" import base64 import hashlib -import importlib import os import subprocess import sys import unittest -from ...server import xarfreport from ..dummyjail import DummyJail from ..utils import CONFIG_DIR, Utils, LogCaptureTestCase +_ACTFILE = os.path.join(CONFIG_DIR, "action.d", "xarf.py") + +try: + import xarf as _xarf_lib + _HAVE_XARF_LIB = (hasattr(_xarf_lib, "create_report") + and hasattr(_xarf_lib, "create_evidence")) +except Exception: + _HAVE_XARF_LIB = False + class XarfEvidenceTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.mod = Utils.load_python_module(_ACTFILE) + def testBuildEvidenceStdlib(self): text = "Dec 31 11:59:59 sshd: auth failure from 87.142.124.10" - ev = xarfreport._build_evidence_stdlib(text, description="logs") + ev = self.mod._build_evidence_stdlib(text, description="logs") self.assertEqual(ev['content_type'], "text/plain") self.assertEqual(ev['description'], "logs") # payload is base64 of the utf-8 text: @@ -50,13 +61,17 @@ class XarfEvidenceTest(unittest.TestCase): self.assertEqual(ev['size'], len(text.encode('utf-8'))) def testBuildEvidenceStdlibNoDescription(self): - ev = xarfreport._build_evidence_stdlib("a log line") + ev = self.mod._build_evidence_stdlib("a log line") self.assertNotIn('description', ev) self.assertEqual(ev['content_type'], "text/plain") class XarfLoginAttackStdlibTest(LogCaptureTestCase): + @classmethod + def setUpClass(cls): + cls.mod = Utils.load_python_module(_ACTFILE) + def _data(self, **over): data = { "source_identifier": "87.142.124.10", @@ -78,8 +93,8 @@ class XarfLoginAttackStdlibTest(LogCaptureTestCase): return data def testBuildLoginAttackStdlib(self): - r = xarfreport._build_login_attack_stdlib(self._data()) - self.assertEqual(r['xarf_version'], xarfreport.XARF_VERSION_FALLBACK) + r = self.mod._build_login_attack_stdlib(self._data()) + self.assertEqual(r['xarf_version'], self.mod.XARF_VERSION_FALLBACK) self.assertEqual(r['category'], "connection") self.assertEqual(r['type'], "login_attack") self.assertEqual(r['source_identifier'], "87.142.124.10") @@ -101,7 +116,7 @@ class XarfLoginAttackStdlibTest(LogCaptureTestCase): data = self._data() del data['destination_port'] del data['attempt_count'] - r = xarfreport._build_login_attack_stdlib(data) + r = self.mod._build_login_attack_stdlib(data) self.assertNotIn('destination_port', r) self.assertNotIn('attempt_count', r) self.assertIn('source_identifier', r) @@ -114,7 +129,7 @@ class XarfBuildLoginAttackTest(XarfLoginAttackStdlibTest): def testPublicEntryPointReturnsValidShape(self): # Regardless of whether the lib is present, the public function # returns a dict with the required v4 fields. - r = xarfreport.build_login_attack(self._data()) + r = self.mod.build_login_attack(self._data()) for field in ("xarf_version", "report_id", "timestamp", "reporter", "sender", "source_identifier", "category", "type", "protocol", "first_seen"): @@ -122,49 +137,49 @@ class XarfBuildLoginAttackTest(XarfLoginAttackStdlibTest): self.assertEqual(r['category'], "connection") self.assertEqual(r['type'], "login_attack") - @unittest.skipUnless(xarfreport._HAVE_XARF, "xarf library not installed") + @unittest.skipUnless(_HAVE_XARF_LIB, "xarf library not installed") def testLibPathValidatesAgainstSchema(self): # When the lib is present, output must pass xarf's own validation. import xarf - r = xarfreport.build_login_attack(self._data()) + r = self.mod.build_login_attack(self._data()) result = xarf.parse(r) self.assertEqual(result.errors, []) def testFallbackOnLibError(self): # Force the lib path to raise; expect a clean fallback + warning. - orig_flag = xarfreport._HAVE_XARF - orig_lib = xarfreport._build_login_attack_lib + orig_flag = self.mod._HAVE_XARF + orig_lib = self.mod._build_login_attack_lib try: - xarfreport._HAVE_XARF = True + self.mod._HAVE_XARF = True def boom(data): raise RuntimeError("simulated lib failure") - xarfreport._build_login_attack_lib = boom - r = xarfreport.build_login_attack(self._data()) + self.mod._build_login_attack_lib = boom + r = self.mod.build_login_attack(self._data()) self.assertEqual(r['type'], "login_attack") self.assertLogged("falling back to stdlib") finally: - xarfreport._HAVE_XARF = orig_flag - xarfreport._build_login_attack_lib = orig_lib + self.mod._HAVE_XARF = orig_flag + self.mod._build_login_attack_lib = orig_lib def testDropsReportOnLibValidationRejection(self): # When the lib validates and rejects the data (e.g. a genuinely # missing required field), no report should be sent at all - not # even the unvalidated stdlib version of the same broken data. - orig_flag = xarfreport._HAVE_XARF - orig_lib = xarfreport._build_login_attack_lib + orig_flag = self.mod._HAVE_XARF + orig_lib = self.mod._build_login_attack_lib try: - xarfreport._HAVE_XARF = True + self.mod._HAVE_XARF = True def reject(data): - raise xarfreport.XarfValidationError( + raise self.mod.XarfValidationError( "xarf validation failed: " "[\"'source_port' is a required property\"]") - xarfreport._build_login_attack_lib = reject - r = xarfreport.build_login_attack(self._data()) + self.mod._build_login_attack_lib = reject + r = self.mod.build_login_attack(self._data()) self.assertIsNone(r) self.assertLogged("rejected report as schema-invalid") finally: - xarfreport._HAVE_XARF = orig_flag - xarfreport._build_login_attack_lib = orig_lib + self.mod._HAVE_XARF = orig_flag + self.mod._build_login_attack_lib = orig_lib class XarfImportGuardTest(LogCaptureTestCase): @@ -180,15 +195,14 @@ class XarfImportGuardTest(LogCaptureTestCase): orig_mod = sys.modules.get('xarf') sys.modules['xarf'] = _BrokenXarf() try: - importlib.reload(xarfreport) - self.assertFalse(xarfreport._HAVE_XARF) + mod = Utils.load_python_module(_ACTFILE) + self.assertFalse(mod._HAVE_XARF) self.assertLogged("xarf library unusable") finally: if orig_mod is not None: sys.modules['xarf'] = orig_mod else: sys.modules.pop('xarf', None) - importlib.reload(xarfreport) class XarfV4ActionTest(LogCaptureTestCase): @@ -196,9 +210,8 @@ class XarfV4ActionTest(LogCaptureTestCase): def setUp(self): super(XarfV4ActionTest, self).setUp() self.__jail = DummyJail() - actfile = os.path.join(CONFIG_DIR, "action.d", "xarf.py") - mod = Utils.load_python_module(actfile) - self.Action = mod.Action + self.mod = Utils.load_python_module(_ACTFILE) + self.Action = self.mod.Action def _mk(self, **over): opts = dict( @@ -385,13 +398,13 @@ class XarfV4ActionTest(LogCaptureTestCase): sent = [] act._resolveAbuseContacts = lambda ip: ["abuse@isp.example"] act._sendmail = lambda recipients, msg: sent.append((recipients, msg)) - orig = xarfreport.build_login_attack - xarfreport.build_login_attack = lambda data: None + orig = self.mod.build_login_attack + self.mod.build_login_attack = lambda data: None try: act.ban({'ip': '87.142.124.10', 'failures': 3, 'time': 1736597840, 'ipmatches': 'log line'}) finally: - xarfreport.build_login_attack = orig + self.mod.build_login_attack = orig self.assertEqual(sent, []) self.assertLogged("failed schema validation; not sending") From 96a2f29dc8e00f6de059f8b3d3d178e639934a56 Mon Sep 17 00:00:00 2001 From: Tobias Knecht Date: Thu, 17 Sep 2026 13:30:57 +0200 Subject: [PATCH 19/19] DOC: move XARF v4 ChangeLog entries to 1.1.2-dev-1 1.1.1 was released (2026/08/15) while this branch was stale; the new-feature and deprecation entries were still sitting under that now-historical section instead of the current dev section. Also drops a stray trailing blank line left in servertestcase.py from an earlier test-file truncation. --- ChangeLog | 18 +++++++++--------- fail2ban/tests/servertestcase.py | 1 - 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/ChangeLog b/ChangeLog index be4f0d1a..921d5a13 100644 --- a/ChangeLog +++ b/ChangeLog @@ -18,10 +18,19 @@ ver. 1.1.2-dev-1 (20??/??/??) - development nightly edition * `paths-*.conf`: add default banactions for several includes depending on default net-filter for distribution (gh-4224, gh-4229): `paths-arch.conf`, `paths-fedora.conf`, `paths-opensuse.conf` - nftables, `paths-freebsd.conf`, `paths-osx.conf ` - pf; +### Deprecations +* `action.d/xarf-login-attack.conf` - deprecated (emits obsolete x-arf 0.2); + superseded by the `xarf` action (XARF v4). Will be removed in a future release (gh-3820) + ### New Features and Enhancements * `filter.d/cowrie.conf` - new filter and jail for Cowrie SSH/Telnet honeypot JSON log output (gh-4216) * `filter.d/gitlab.conf` - `logtype` option `json` adding support for the `application_json.log` format that superseded the deprecated `application.log`; default type `file` keeps the previous behaviour (gh-3566, gh-4230) +* `action.d/xarf.py` - new action reporting login attacks as XARF v4 + (https://xarf.org) by e-mail to the IP's abuse contact (resolved via the + Abusix Contact DB). Uses the optional `xarf` python package for schema + validation when installed, otherwise a built-in stdlib generator. Enable + via `action_xarf_v4` in jail.conf (gh-3820) ver. 1.1.1 (2026/08/15) - triple-one-win @@ -101,10 +110,6 @@ ver. 1.1.1 (2026/08/15) - triple-one-win * `filter.d/vsftpd.conf` - fixed regex (if failures generated by systemd-journal, gh-3954) * `filter.d/froxlor-auth.conf` - updated the regex to the new logging situation for froxlor and changed logpath in jail.conf (gh-4075). -### Deprecations -* `action.d/xarf-login-attack.conf` - deprecated (emits obsolete x-arf 0.2); - superseded by the `xarf` action (XARF v4). Will be removed in a future release (gh-3820) - ### New Features and Enhancements * `fail2ban-server` supports `systemd` socket activation (`sd_listen_fds(3)`); optional `fail2ban.socket` unit provided * backend `systemd` extended with new parameter `rotated` (default `false`, as prevention against "too many open files"), @@ -148,11 +153,6 @@ ver. 1.1.1 (2026/08/15) - triple-one-win * `filter.d/vaultwarden.conf` - new filter and jail for Vaultwarden (gh-3979) * `filter.d/xrdp.conf` - new filter for XRDP, an open source RDP server (gh-3254) * `fail2ban-regex` extended with new option `-i` or `--invert` to output not-matched lines by `-o` or `--out` (gh-4001) -* `action.d/xarf.py` - new action reporting login attacks as XARF v4 - (https://xarf.org) by e-mail to the IP's abuse contact (resolved via the - Abusix Contact DB). Uses the optional `xarf` python package for schema - validation when installed, otherwise a built-in stdlib generator. Enable - via `action_xarf_v4` in jail.conf (gh-3820) ver. 1.1.0 (2024/04/25) - object-found--norad-59479-cospar-2024-069a--altitude-36267km diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 55b946bf..fefb5bf7 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -2411,4 +2411,3 @@ class ServerConfigReaderTests(LogCaptureTestCase): ticket = _actions.Actions.ActionInfo(ticket, dmyjail) action.ban(ticket) self.assertLogged(*tests[test], all=True) -