335 lines
8.4 KiB
Python
335 lines
8.4 KiB
Python
#!/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
|
|
import yaml
|
|
from peewee import fn
|
|
from rich import print
|
|
|
|
base_path = Path('/var/opt/waf')
|
|
conf_file = base_path / 'config.yml'
|
|
|
|
if conf_file.exists():
|
|
conf = yaml.safe_load(conf_file.read_text())
|
|
else:
|
|
conf = {
|
|
'vroots': "/srv",
|
|
'logs_glob': "*/logs/*access*.log",
|
|
'whitelist_ips': [
|
|
'127.0.0.1',
|
|
],
|
|
'date_range': 'last_hour',
|
|
'purge_older_than': 'three_days',
|
|
'db_filename': 'waf.db',
|
|
}
|
|
conf_file.touch()
|
|
yaml.dump(conf, conf_file.open('w'))
|
|
|
|
now = arrow.utcnow()
|
|
older_than = now.shift(days=-3).floor('day')
|
|
last_hour = now.shift(hours=-1).floor('hour')
|
|
last_thirty_min = now.shift(minutes=-30)
|
|
|
|
# Configuring
|
|
db_path = base_path / conf['db_filename']
|
|
date_range = globals()[conf['date_range']]
|
|
logs = Path(conf['vroots']).glob(conf['logs_glob'])
|
|
whitelist_ips = conf['whitelist_ips']
|
|
|
|
# Database
|
|
|
|
db = SqliteExtDatabase(db_path, pragmas={'journal_mode': 'wal'})
|
|
|
|
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)
|
|
|
|
# Utils
|
|
|
|
def report_attacks():
|
|
click.echo(
|
|
click.style(
|
|
f"Attacks in database: {Attack.select().count()}",
|
|
fg="cyan"
|
|
)
|
|
)
|
|
hosts = {}
|
|
for a in Attack.select():
|
|
if a.host in hosts:
|
|
hosts[a.host] = hosts[a.host] + 1
|
|
else:
|
|
hosts[a.host] = 1
|
|
|
|
sorted_hosts = dict(sorted(hosts.items(), key=lambda x:x[1]))
|
|
|
|
for h, v in sorted_hosts.items():
|
|
print(h, v)
|
|
|
|
|
|
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': 'agent',
|
|
'in': 'PHP/6',
|
|
'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
|
|
|
|
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"))
|
|
|
|
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()
|
|
|
|
async def check(ip, host, date_position):
|
|
date = arrow.get(date_position,'DD/MMM/YYYY:HH:mm:ss')
|
|
if date > date_range:
|
|
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()
|
|
# print(splitted)
|
|
# print(splitted[-2].strip())
|
|
ip = splitted[0].strip()
|
|
method = splitted[5].strip()[1:]
|
|
url = splitted[6].strip()
|
|
agent = splitted[-2].strip()
|
|
status = splitted[8].strip()
|
|
date_position = splitted[3][1:]
|
|
host = log.splitall()[2]
|
|
if ip not in whitelist_ips:
|
|
for rule in checklist:
|
|
where = url
|
|
if rule['where'] == 'url':
|
|
where = url
|
|
elif rule['where'] == 'agent':
|
|
where = agent
|
|
store = suspects
|
|
if rule['store'] == 'suspects':
|
|
store = suspects
|
|
if 'in' in rule and rule['in'] in where:
|
|
# if rule['where'] == 'agent':
|
|
# print(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']:
|
|
# 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]))
|
|
|
|
await asyncio.gather(*suspects)
|
|
|
|
|
|
|
|
async def block():
|
|
denied = await get_denied()
|
|
found = Attack.select().where(
|
|
(Attack.ip.not_in(denied)) &
|
|
(Attack.count > 15)
|
|
)
|
|
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()
|
|
else:
|
|
click.echo(click.style('No IPs to block', fg="blue"))
|
|
|
|
|
|
|
|
async def start():
|
|
report()
|
|
scans = []
|
|
for log in logs:
|
|
scans.append(scan(log))
|
|
await asyncio.gather(*scans)
|
|
await block()
|
|
report_attacks()
|
|
|
|
async def info(ip, unblock=False):
|
|
denied = await get_denied()
|
|
find = Attack.get_or_none(Attack.ip == ip)
|
|
if find:
|
|
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))
|
|
|
|
|
|
def purge():
|
|
found = Attack.select(Attack.ip).where(
|
|
(Attack.date < older_than.datetime ) )
|
|
for attack in found:
|
|
undeny(attack.ip)
|
|
attack.delete_instance()
|
|
|
|
def empty():
|
|
found = Attack.select()
|
|
for attack in found:
|
|
undeny(attack.ip)
|
|
attack.delete_instance()
|
|
|
|
# CLI App
|
|
@click.group()
|
|
def cli():
|
|
pass
|
|
|
|
@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))
|
|
|
|
|
|
@cli.command('purge')
|
|
def waf_purge():
|
|
click.echo('Clean old blocked IPs')
|
|
purge()
|
|
|
|
|
|
@cli.command('empty')
|
|
def waf_empty():
|
|
click.echo('Clean all blocked IPs')
|
|
empty()
|
|
|
|
|
|
@cli.command('report')
|
|
def waf_report():
|
|
click.echo(click.style('Report WAF attacks', fg="blue", bold=True))
|
|
report()
|
|
|
|
|
|
@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()
|
|
click.echo(click.style(f'Finished in {after - before}', fg="blue"))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
cli()
|