From c991df53fa69cb2f5d9aaa3488a68d42673a8536 Mon Sep 17 00:00:00 2001 From: Ruben van Staveren Date: Mon, 16 Jan 2023 16:38:37 +0000 Subject: [PATCH] Resolve "Create a /list route for showing banned addresses" --- .gitignore | 2 + .pylintrc | 2 + jail2ban/__init__.py | 67 +++++++++++++++++---- jail2ban/pfctl.py | 16 ++++- tests/test_list.py | 140 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 215 insertions(+), 12 deletions(-) create mode 100644 .pylintrc create mode 100644 tests/test_list.py diff --git a/.gitignore b/.gitignore index 7e71edb..2ecc921 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ venv/ +*.sw? + *.pyc __pycache__/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..3fdb488 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[TYPECHECK] +generated-members=app.logger.* diff --git a/jail2ban/__init__.py b/jail2ban/__init__.py index da969d1..66aa24c 100644 --- a/jail2ban/__init__.py +++ b/jail2ban/__init__.py @@ -1,11 +1,16 @@ +''' +jail2ban, a remote fail2ban action plugin using OpenBSD pf(8) +''' +from ipaddress import ip_address +import re +from subprocess import CalledProcessError + from flask import Flask, request, jsonify, current_app from flask_httpauth import HTTPBasicAuth from werkzeug.security import check_password_hash -from ipaddress import ip_address -import re + from jail2ban.pfctl import pfctl_table_op, pfctl_cfg_read, pfctl_cfg_write from jail2ban.auth import get_users -from subprocess import CalledProcessError auth = HTTPBasicAuth() @@ -15,6 +20,13 @@ PAT_PORT = r'^any(?:\s+port\s+{\w+(?:,\w+)*})?$' PAT_PROT = r'^(?:tcp|udp)$' PAT_NAME = r'^[\w\-]+$' +_PFCTL_TABLE_PAT = r'''\s+(?P\S+)\n +\s+Cleared:\s+(?P\S+\s+\S+\s+\d+\s+(?:\d{2}:){2}\d{2}\s+\d{4})\n +\s+In/Block:\s+\[\s+Packets:\s+(?P\d+)\s+Bytes:\s+(?P\d+)\s+\]\n +\s+In/Pass:\s+\[\s+Packets:\s+(?P\d+)\s+Bytes:\s+(?P\d+)\s+\]\n +\s+Out/Block:\s+\[\s+Packets:\s+(?P\d+)\s+Bytes:\s+(?P\d+)\s+\]\n +\s+Out/Pass:\s+\[\s+Packets:\s+(?P\d+)\s+Bytes:\s+(?P\d+)\s+\]''' + def untaint(pattern, string): ''' @@ -23,11 +35,13 @@ def untaint(pattern, string): match = re.match(pattern, string) if match: return match.string - else: - raise ValueError(f'"{string}" is tainted') + raise ValueError(f'"{string}" is tainted') def create_app(): + ''' + Create wsgi application instance + ''' app = Flask(__name__, instance_relative_config=True) # load the instance config, if it exists, when not testing @@ -41,6 +55,7 @@ def create_app(): if username in users and \ check_password_hash(users.get(username), password): return username + return None @app.route("/ping", methods=['GET']) @auth.login_required @@ -66,6 +81,36 @@ def create_app(): 'operation': 'flush', 'result': [x.decode('ascii') for x in res]}) + @app.route("/list/", methods=['GET']) + @auth.login_required + def list_table(name): + remote_user = auth.username() + name = untaint(PAT_NAME, name) + app.logger.info(f'Flushing table f2b-{name}' + f' in anchor f2b-jail/{remote_user}') + reply = {'anchor': f'f2b-jail/{remote_user}', + 'table': f'f2b-{name}', + 'operation': 'list'} + try: + res = pfctl_table_op('f2b-jail/{remote_user}', + table='f2b-{name}', + operation='show', + verbose=True) + except CalledProcessError as err: + if err.stderr.find(b'pfctl: Table does not exist.') > 0: + res = [] + reply.update({'error': f'\'{name}\' is not a known fail2ban jail'}) + else: + raise err + + result = [entry.groupdict() for entry in + re.finditer(_PFCTL_TABLE_PAT, + '\n'.join([x.decode('ascii') for x in res]), + re.MULTILINE|re.VERBOSE)] + reply.update({'result': result}) + + return jsonify(reply), 200 if len(res) else 404 + @app.route("/register", methods=['PUT', 'DELETE']) @auth.login_required def register(): @@ -111,21 +156,21 @@ def create_app(): data = request.get_json() # name / ip name = untaint(PAT_NAME, data['name']) - ip = ip_address(data['ip']) + ip_addr = ip_address(data['ip']) if request.method == 'PUT': - app.logger.info(f'Add {ip} to f2b-{name}' + app.logger.info(f'Add {ip_addr} to f2b-{name}' f' in anchor f2b-jail/{remote_user}') res = pfctl_table_op(f'f2b-jail/{remote_user}', table=f'f2b-{name}', operation='add', - value=str(ip)) + value=str(ip_addr)) else: # 'DELETE': - app.logger.info(f'Remove {ip} from f2b-{name}' + app.logger.info(f'Remove {ip_addr} from f2b-{name}' f' in anchor f2b-jail/{remote_user}') res = pfctl_table_op(f'f2b-jail/{remote_user}', table=f'f2b-{name}', operation='delete', - value=str(ip)) + value=str(ip_addr)) return jsonify({'anchor': f'f2b-jail/{remote_user}', 'table': f'f2b-{name}', 'operation': 'add' if request.method == 'PUT' @@ -146,6 +191,8 @@ def create_app(): Show a json parsable error if the value is illegal ''' app.logger.fatal(error) + app.logger.fatal('stdout: %s', error.stderr) + app.logger.fatal('stderr: %s', error.stderr) return jsonify({'error': str(error)}), 500 @app.errorhandler(FileNotFoundError) diff --git a/jail2ban/pfctl.py b/jail2ban/pfctl.py index 0e062c5..b85b360 100644 --- a/jail2ban/pfctl.py +++ b/jail2ban/pfctl.py @@ -1,11 +1,16 @@ +''' +Lowlevel routines for calling the pf binary with passwordless sudo +''' import logging from subprocess import run _SUDO = '/usr/local/bin/sudo' _PFCTL = '/sbin/pfctl' - def pfctl_cfg_read(anchor): + ''' + Read pf rules stored under a certain anchor + ''' cmd = [_SUDO, _PFCTL, '-a', anchor, '-sr'] logging.info('Running %s', cmd) @@ -16,6 +21,9 @@ def pfctl_cfg_read(anchor): def pfctl_cfg_write(anchor, cfg): + ''' + Write pf rules under a certain anchor + ''' cmd = [_SUDO, _PFCTL, '-a', anchor, '-f-'] logging.info('Running %s', cmd) logging.info('Config %s', cfg) @@ -30,10 +38,14 @@ def pfctl_cfg_write(anchor, cfg): def pfctl_table_op(anchor, **kwargs): + ''' + pf table operation + ''' table = kwargs['table'] operation = kwargs['operation'] value = kwargs['value'] if 'value' in kwargs else None - cmd = [_SUDO, _PFCTL, '-a', anchor, '-t', table, '-T', operation, value] + verbose = '-v' if 'verbose' in kwargs and kwargs['verbose'] else None + cmd = [_SUDO, _PFCTL, '-a', anchor, '-t', table, verbose, '-T', operation, value] logging.info('Running %s', cmd) diff --git a/tests/test_list.py b/tests/test_list.py new file mode 100644 index 0000000..9fcbbcd --- /dev/null +++ b/tests/test_list.py @@ -0,0 +1,140 @@ +''' +Tests for /list route +''' +from types import SimpleNamespace +from subprocess import CalledProcessError + + +_PF_TABLE_LIST = b''' 192.0.2.66 + Cleared: Sat Jan 7 12:50:36 2023 + In/Block: [ Packets: 0 Bytes: 0 ] + In/Pass: [ Packets: 0 Bytes: 0 ] + Out/Block: [ Packets: 0 Bytes: 0 ] + Out/Pass: [ Packets: 0 Bytes: 0 ] + 2001:db8::abad:cafe + Cleared: Sat Jan 7 05:13:53 2023 + In/Block: [ Packets: 4 Bytes: 240 ] + In/Pass: [ Packets: 0 Bytes: 0 ] + Out/Block: [ Packets: 0 Bytes: 0 ] + Out/Pass: [ Packets: 0 Bytes: 0 ] + 2001:db8::abad:f00d:cafe + Cleared: Sat Jan 7 05:05:16 2023 + In/Block: [ Packets: 48 Bytes: 2880 ] + In/Pass: [ Packets: 0 Bytes: 0 ] + Out/Block: [ Packets: 0 Bytes: 0 ] + Out/Pass: [ Packets: 0 Bytes: 0 ]''' + +_LIST_RESULT = [{'addr': '192.0.2.66', + 'date': 'Sat Jan 7 12:50:36 2023', + 'in_pckt_block': '0', + 'in_bytes_block': '0', + 'in_pckt_pass': '0', + 'in_bytes_pass': '0', + 'out_pckt_block': '0', + 'out_bytes_block': '0', + 'out_pckt_pass': '0', + 'out_bytes_pass': '0'}, + {'addr': '2001:db8::abad:cafe', + 'date': 'Sat Jan 7 05:13:53 2023', + 'in_pckt_block': '4', + 'in_bytes_block': '240', + 'in_pckt_pass': '0', + 'in_bytes_pass': '0', + 'out_pckt_block': '0', + 'out_bytes_block': '0', + 'out_pckt_pass': '0', + 'out_bytes_pass': '0'}, + {'addr': '2001:db8::abad:f00d:cafe', + 'date': 'Sat Jan 7 05:05:16 2023', + 'in_pckt_block': '48', + 'in_bytes_block': '2880', + 'in_pckt_pass': '0', + 'in_bytes_pass': '0', + 'out_pckt_block': '0', + 'out_bytes_block': '0', + 'out_pckt_pass': '0', + 'out_bytes_pass': '0'}] + + +def test_list_single_table(client, mocker, valid_credentials): + ''' + List a single pf table using the fail2ban jail name + ''' + + def noop(): + pass + + run_res = SimpleNamespace() + run_res.stdout = _PF_TABLE_LIST + run_res.stderr = b'No ALTQ support in kernel\nALTQ related functions disabled\n' + run_res.returncode = 0 + run_res.check_returncode = noop + + mocker.patch('jail2ban.pfctl.run', return_value=run_res) + + response = client.get("/list/sshd", + headers={"Authorization": + "Basic " + valid_credentials}) + + assert response.json['table'] == 'f2b-sshd' + assert response.json['result'] == _LIST_RESULT + + +def test_list_nonexistent_table(client, mocker, valid_credentials): + ''' + Test for nonexistent table. Should result in a 404 not found + ''' + def noop(): + pass + + run_res = SimpleNamespace() + run_res.stdout = b'' + run_res.stderr = b'No ALTQ support in kernel\nALTQ related functions disabled\n' \ + b'pfctl: Table does not exist.\n' + run_res.returncode = 255 + run_res.check_returncode = noop + + mocker.patch('jail2ban.pfctl.run', + return_value=run_res, + side_effect=CalledProcessError(run_res.returncode, + 'foobar', + output=run_res.stdout, + stderr=run_res.stderr) + ) + + response = client.get("/list/nonexistent", + headers={"Authorization": + "Basic " + valid_credentials}) + + assert response.status_code == 404 + assert response.json['error'] == "'nonexistent' is not " \ + "a known fail2ban jail" + +def test_list_wrong_table_name(client, mocker, valid_credentials): + ''' + Test for an wrong table name that lets pfctl fail. should result in a 500 + ''' + def noop(): + pass + + run_res = SimpleNamespace() + run_res.stdout = b'' + run_res.stderr = b'No ALTQ support in kernel\nALTQ related functions disabled\n' \ + b'pfctl: Invalid argument.\n' + run_res.returncode = 255 + run_res.check_returncode = noop + + mocker.patch('jail2ban.pfctl.run', + return_value=run_res, + side_effect=CalledProcessError(run_res.returncode, + 'foobar', + output=run_res.stdout, + stderr=run_res.stderr) + ) + + response = client.get("/list/notanerrorbuttestneedstofail", + headers={"Authorization": + "Basic " + valid_credentials}) + + assert response.status_code == 500 + assert response.json['error'] == "Command 'foobar' returned non-zero exit status 255."