Files
waf/waf3.py

307 lines
7.7 KiB
Python
Raw Normal View History

2023-03-23 11:37:27 +01:00
#!/usr/bin/env python3
import os
os.nice(20)
import asyncio, subprocess
import arrow
import peewee
import click
from path import Path
import pyufw as ufw
from playhouse.sqlite_ext import SqliteExtDatabase
2023-03-23 11:58:04 +01:00
import yaml
2023-04-25 11:59:50 +02:00
from peewee import fn
from rich import print
2023-03-23 11:58:04 +01:00
base_path = Path('/var/opt/waf')
conf_file = base_path / 'config.yml'
2024-11-25 19:12:51 +01:00
2023-03-23 11:58:04 +01:00
if conf_file.exists():
conf = yaml.safe_load(conf_file.text())
else:
conf = {
'vroots': "/srv",
2024-11-25 19:17:55 +01:00
'whitelist_ips': [
'127.0.0.1',
],
2024-11-25 19:12:51 +01:00
'date_range': 'last_hour',
'purge_older_than': 'three_days',
'db_filename': 'waf.db',
2023-03-23 11:58:04 +01:00
}
conf_file.touch()
yaml.dump(conf, conf_file.open('w'))
2024-11-25 19:12:51 +01:00
# db_path = base_path / 'waf.db'
2023-03-23 11:37:27 +01:00
now = arrow.utcnow()
older_than = now.shift(days=-3).floor('day')
2023-03-23 11:37:27 +01:00
last_hour = now.shift(hours=-1).floor('hour')
last_thirty_min = now.shift(minutes=-30)
2024-11-25 19:12:51 +01:00
# Configuring
2024-11-25 19:22:51 +01:00
db_path = base_path / conf['db_filename']
2024-11-25 19:12:51 +01:00
date_range = globals()[conf['date_range']]
2023-03-23 11:58:04 +01:00
vroots = Path(conf['vroots'])
2023-03-23 11:37:27 +01:00
logs = vroots.glob('*/logs/*access*.log')
2023-03-27 11:59:37 +02:00
whitelist_ips = conf['whitelist_ips']
2023-03-23 11:37:27 +01:00
2024-11-25 19:12:51 +01:00
# Database
2024-11-25 19:16:27 +01:00
2023-03-23 11:58:04 +01:00
db = SqliteExtDatabase(db_path, pragmas={'journal_mode': 'wal'})
2024-11-25 19:12:51 +01:00
2023-03-23 11:37:27 +01:00
class Attack(peewee.Model):
ip = peewee.CharField(unique=True)
host = peewee.CharField(index = True)
date = peewee.DateTimeField(default=now.datetime)
count = peewee.BigIntegerField()
class Meta:
database = db
Attack.create_table(True)
2024-11-25 19:16:27 +01:00
# Utils
def report():
click.echo(
click.style(
f"Config file at: {conf_file}",
fg="cyan"
)
)
click.echo(
click.style(
f"Hosting logs: {len(logs)}",
fg="cyan"
)
)
report_attacks()
for ip in whitelist_ips:
click.echo(
click.style(
f"Whitelisted: {ip}",
fg="green"
)
)
# Check rules
checklist = [
{
'where': 'url',
'in': 'xmlrpc',
'store': 'suspects',
},
{
'where': 'url',
'in': "shell",
'store': 'suspects',
},
{
'where': 'url',
'in': "\\x00",
'store': 'suspects',
},
{
'method': 'post',
'where': 'url',
'in': 'wp-login',
'store': 'suspects',
},
{
'where': 'url',
'startswith': '/.',
'notin': '.well_known',
'store': 'suspects',
},
]
# App
2023-03-23 11:37:27 +01:00
async def nginx_reload():
returned_value = subprocess.call('/usr/bin/systemctl reload nginx', shell=True)
if returned_value == 0:
click.echo(click.style('Nginx reloaded', fg="blue"))
2023-03-23 11:37:27 +01:00
async def get_denied():
denied = []
for rule in ufw.get_rules().values():
if "deny from " in rule:
denied.append(rule.split(' ')[-1])
return denied
async def deny(ip):
ufw.add('deny from ' + ip + ' to any', number=1)
def undeny(ip):
# Not async to avoid deleting wrong ufw rule.
for key, rule in ufw.get_rules().items():
if rule == "deny from " + ip:
ufw.delete(key)
# attack = Attack.get_or_none(Attack.ip == ip)
# if attack:
# attack.delete_instance()
2023-03-23 11:37:27 +01:00
async def check(ip, host, date_position):
date = arrow.get(date_position,'DD/MMM/YYYY:HH:mm:ss')
2024-11-25 19:12:51 +01:00
if date > date_range:
2023-03-23 11:37:27 +01:00
find = Attack.get_or_none(Attack.ip == ip)
if find:
find.count = find.count + 1
find.save()
else:
data = {'ip': ip, 'date':date.datetime, 'host': host, 'count':1}
Attack.create(**data)
async def scan(log):
suspects = []
suspects_404 = {}
for line in log.lines():
splitted = line.split()
ip = splitted[0].strip()
method = splitted[5].strip()[1:]
url = splitted[6].strip()
status = splitted[8].strip()
date_position = splitted[3][1:]
host = log.parent.parent.basename()
if ip not in whitelist_ips:
2023-04-25 11:59:50 +02:00
for rule in checklist:
where = url
if rule['where'] == 'url':
where = url
store = suspects
if rule['store'] == 'suspects':
store = suspects
if 'in' in rule and rule['in'] in where:
store.append(check(ip, host, date_position))
break
elif 'startswith' in rule and url.startswith(rule['startswith']) and 'notin' in rule and rule['notin'] not in url:
store.append(check(ip, host, date_position))
break
elif 'startswith' in rule and url.startswith(rule['startswith']):
store.append(check(ip, host, date_position))
break
# elif 'wp-admin' in url and status not in ['200','302','499']:
2024-11-25 19:12:51 +01:00
# store.append(check(ip, host, date_position))
# def is_suspicious_login(item):
# return len(item[1]) > 18
# filtered = dict(filter(is_suspicious_login, suspects_login.items()))
# for ip,suspect in filtered.items():
# suspects.append(check(ip, suspect[-1][1], suspect[-1][2]))
2023-04-25 11:59:50 +02:00
2023-03-23 11:37:27 +01:00
await asyncio.gather(*suspects)
2023-04-25 11:59:50 +02:00
2023-03-23 11:37:27 +01:00
async def block():
denied = await get_denied()
found = Attack.select().where(
(Attack.ip.not_in(denied)) &
2023-04-25 11:59:50 +02:00
(Attack.count > 15)
2023-03-23 11:37:27 +01:00
)
if found.count() > 0:
click.echo(click.style('New IPs to block: {}'.format(found.count()), fg="yellow"))
to_deny = []
for attack in found:
to_deny.append( deny(attack.ip) )
await asyncio.gather(*to_deny)
await nginx_reload()
2023-03-23 11:37:27 +01:00
else:
click.echo(click.style('No IPs to block', fg="blue"))
2023-03-23 11:37:27 +01:00
async def start():
report()
scans = []
for log in logs:
scans.append(scan(log))
await asyncio.gather(*scans)
await block()
report_attacks()
2024-11-25 19:16:27 +01:00
async def info(ip, unblock=False):
denied = await get_denied()
find = Attack.get_or_none(Attack.ip == ip)
if find:
2023-04-25 11:59:50 +02:00
click.echo(click.style(f'IP {find.ip} found with {find.count} attacks at {find.host}', fg="yellow", bold=True))
if unblock is True:
find.delete_instance()
click.echo(click.style(f'IP {ip} removed from attacks', fg="green", bold=True))
else:
click.echo(click.style(f'IP {ip} no attacks found', fg="blue"))
if ip in denied:
if unblock is False:
click.echo(click.style(f'IP {ip} is BLOCKED', fg="yellow", bold=True))
else:
undeny(ip)
click.echo(click.style(f'IP {ip} UNBLOCKED', fg="green", bold=True))
2023-04-25 11:59:50 +02:00
def purge():
print(older_than)
found = Attack.select(Attack.ip).where(
(Attack.date < older_than.datetime ) )
for attack in found:
undeny(attack.ip)
attack.delete_instance()
2023-04-25 11:59:50 +02:00
def empty():
found = Attack.select()
for attack in found:
undeny(attack.ip)
attack.delete_instance()
2024-11-25 19:16:27 +01:00
# CLI App
@click.group()
def cli():
pass
2023-04-25 11:59:50 +02:00
@cli.command('info')
@click.argument('ip')
def waf_info(ip):
click.echo('Find blocked IP')
asyncio.run(info(ip))
@cli.command('unblock')
@click.argument('ip')
def waf_unblock(ip):
click.echo('Unblock blocked IP')
asyncio.run(info(ip,unblock=True))
2023-03-23 11:37:27 +01:00
@cli.command('purge')
def waf_purge():
2023-04-25 11:59:50 +02:00
click.echo('Clean old blocked IPs')
purge()
@cli.command('empty')
def waf_empty():
2023-03-23 11:37:27 +01:00
click.echo('Clean all blocked IPs')
2023-04-25 11:59:50 +02:00
empty()
2023-03-23 11:37:27 +01:00
@cli.command('report')
def waf_report():
click.echo(click.style('Report WAF attacks', fg="blue", bold=True))
report()
2023-03-23 11:37:27 +01:00
@cli.command('scan')
def waf_scan():
before = arrow.utcnow()
click.echo(click.style('Scan', fg="blue", blink=True, bold=True))
asyncio.run(start())
after = arrow.utcnow()
2023-03-27 11:59:37 +02:00
click.echo(click.style(f'Finished in {after - before}', fg="blue"))
2023-03-23 11:37:27 +01:00
2023-03-23 11:37:27 +01:00
if __name__ == '__main__':
cli()