2018-04-14 07:08:37 +02:00
|
|
|
# antispam.py: Basic services-side spamfilters for IRC
|
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
import ircmatch
|
|
|
|
|
2018-04-14 07:08:37 +02:00
|
|
|
from pylinkirc import utils, world, conf
|
|
|
|
from pylinkirc.log import log
|
|
|
|
|
|
|
|
mydesc = ("Provides anti-spam functionality.")
|
|
|
|
sbot = utils.register_service("antispam", default_nick="AntiSpam", desc=mydesc)
|
|
|
|
|
|
|
|
def die(irc=None):
|
|
|
|
utils.unregister_service("antispam")
|
|
|
|
|
2018-06-10 01:18:24 +02:00
|
|
|
PUNISH_OPTIONS = ['kill', 'ban', 'quiet', 'kick', 'block']
|
2018-04-14 07:08:37 +02:00
|
|
|
EXEMPT_OPTIONS = ['voice', 'halfop', 'op']
|
|
|
|
DEFAULT_EXEMPT_OPTION = 'halfop'
|
2018-06-02 09:06:21 +02:00
|
|
|
def _punish(irc, target, channel, punishment, reason):
|
2018-04-14 07:14:59 +02:00
|
|
|
"""Punishes the target user. This function returns True if the user was successfully punished."""
|
2018-06-02 09:30:07 +02:00
|
|
|
if target not in irc.users:
|
|
|
|
log.warning("(%s) antispam: got target %r that isn't a user?", irc.name, target)
|
|
|
|
return False
|
2018-06-12 08:56:44 +02:00
|
|
|
elif irc.is_oper(target):
|
2018-04-14 07:08:37 +02:00
|
|
|
log.debug("(%s) antispam: refusing to punish oper %s/%s", irc.name, target, irc.get_friendly_name(target))
|
2018-04-14 07:14:59 +02:00
|
|
|
return False
|
2018-04-14 07:08:37 +02:00
|
|
|
|
2018-06-02 09:30:07 +02:00
|
|
|
target_nick = irc.get_friendly_name(target)
|
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
if channel:
|
|
|
|
c = irc.channels[channel]
|
|
|
|
exempt_level = irc.get_service_option('antispam', 'exempt_level', DEFAULT_EXEMPT_OPTION).lower()
|
2018-04-14 07:08:37 +02:00
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
if exempt_level not in EXEMPT_OPTIONS:
|
|
|
|
log.error('(%s) Antispam exempt %r is not a valid setting, '
|
|
|
|
'falling back to defaults; accepted settings include: %s',
|
|
|
|
irc.name, exempt_level, ', '.join(EXEMPT_OPTIONS))
|
|
|
|
exempt_level = DEFAULT_EXEMPT_OPTION
|
2018-04-14 07:08:37 +02:00
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
if exempt_level == 'voice' and c.is_voice_plus(target):
|
|
|
|
log.debug("(%s) antispam: refusing to punish voiced and above %s/%s", irc.name, target, target_nick)
|
|
|
|
return False
|
|
|
|
elif exempt_level == 'halfop' and c.is_halfop_plus(target):
|
|
|
|
log.debug("(%s) antispam: refusing to punish halfop and above %s/%s", irc.name, target, target_nick)
|
|
|
|
return False
|
|
|
|
elif exempt_level == 'op' and c.is_op_plus(target):
|
|
|
|
log.debug("(%s) antispam: refusing to punish op and above %s/%s", irc.name, target, target_nick)
|
|
|
|
return False
|
2018-04-14 07:08:37 +02:00
|
|
|
|
|
|
|
my_uid = sbot.uids.get(irc.name)
|
2018-04-14 20:39:24 +02:00
|
|
|
# XXX workaround for single-bot protocols like Clientbot
|
|
|
|
if irc.pseudoclient and not irc.has_cap('can-spawn-clients'):
|
|
|
|
my_uid = irc.pseudoclient.uid
|
2018-04-14 07:08:37 +02:00
|
|
|
|
|
|
|
bans = set()
|
|
|
|
log.debug('(%s) antispam: got %r as punishment for %s/%s', irc.name, punishment,
|
|
|
|
target, irc.get_friendly_name(target))
|
|
|
|
|
|
|
|
def _ban():
|
|
|
|
bans.add(irc.make_channel_ban(target))
|
|
|
|
def _quiet():
|
|
|
|
bans.add(irc.make_channel_ban(target, ban_type='quiet'))
|
|
|
|
def _kick():
|
|
|
|
irc.kick(my_uid, channel, target, reason)
|
|
|
|
irc.call_hooks([my_uid, 'ANTISPAM_KICK', {'channel': channel, 'text': reason, 'target': target,
|
|
|
|
'parse_as': 'KICK'}])
|
|
|
|
def _kill():
|
2018-04-14 07:18:03 +02:00
|
|
|
if target not in irc.users:
|
|
|
|
log.debug('(%s) antispam: not killing %s/%s; they already left', irc.name, target,
|
|
|
|
irc.get_friendly_name(target))
|
|
|
|
return
|
2018-04-14 07:08:37 +02:00
|
|
|
userdata = irc.users[target]
|
|
|
|
irc.kill(my_uid, target, reason)
|
|
|
|
irc.call_hooks([my_uid, 'ANTISPAM_KILL', {'target': target, 'text': reason,
|
|
|
|
'userdata': userdata, 'parse_as': 'KILL'}])
|
|
|
|
|
|
|
|
kill = False
|
2018-06-02 09:30:07 +02:00
|
|
|
successful_punishments = 0
|
2018-04-14 07:08:37 +02:00
|
|
|
for action in set(punishment.split('+')):
|
|
|
|
if action not in PUNISH_OPTIONS:
|
|
|
|
log.error('(%s) Antispam punishment %r is not a valid setting; '
|
|
|
|
'accepted settings include: %s OR any combination of '
|
|
|
|
'these joined together with a "+".',
|
|
|
|
irc.name, punishment, ', '.join(PUNISH_OPTIONS))
|
|
|
|
return
|
2018-06-10 01:18:24 +02:00
|
|
|
elif action == 'block':
|
|
|
|
# We only need to increment this for this function to return True
|
|
|
|
successful_punishments += 1
|
2018-04-14 07:08:37 +02:00
|
|
|
elif action == 'kill':
|
|
|
|
kill = True # Delay kills so that the user data doesn't disappear.
|
2018-06-02 09:30:07 +02:00
|
|
|
# XXX factorize these blocks
|
2018-06-09 02:49:26 +02:00
|
|
|
elif action == 'kick' and channel:
|
2018-06-02 09:30:07 +02:00
|
|
|
try:
|
|
|
|
_kick()
|
|
|
|
except NotImplementedError:
|
|
|
|
log.warning("(%s) antispam: Kicks are not supported on this network, skipping; "
|
|
|
|
"target was %s/%s", irc.name, target_nick, channel)
|
|
|
|
else:
|
|
|
|
successful_punishments += 1
|
2018-06-09 02:49:26 +02:00
|
|
|
elif action == 'ban' and channel:
|
2018-06-02 09:30:07 +02:00
|
|
|
try:
|
|
|
|
_ban()
|
|
|
|
except (ValueError, NotImplementedError):
|
|
|
|
log.warning("(%s) antispam: Bans are not supported on this network, skipping; "
|
|
|
|
"target was %s/%s", irc.name, target_nick, channel)
|
|
|
|
else:
|
|
|
|
successful_punishments += 1
|
2018-06-09 02:49:26 +02:00
|
|
|
elif action == 'quiet' and channel:
|
2018-06-02 09:30:07 +02:00
|
|
|
try:
|
|
|
|
_quiet()
|
|
|
|
except (ValueError, NotImplementedError):
|
|
|
|
log.warning("(%s) antispam: Quiet is not supported on this network, skipping; "
|
|
|
|
"target was %s/%s", irc.name, target_nick, channel)
|
|
|
|
else:
|
|
|
|
successful_punishments += 1
|
2018-04-14 07:08:37 +02:00
|
|
|
|
|
|
|
if bans: # Set all bans at once to prevent spam
|
|
|
|
irc.mode(my_uid, channel, bans)
|
|
|
|
irc.call_hooks([my_uid, 'ANTISPAM_BAN',
|
|
|
|
{'target': channel, 'modes': bans, 'parse_as': 'MODE'}])
|
|
|
|
if kill:
|
2018-06-02 09:30:07 +02:00
|
|
|
try:
|
|
|
|
_kill()
|
|
|
|
except NotImplementedError:
|
|
|
|
log.warning("(%s) antispam: Kills are not supported on this network, skipping; "
|
|
|
|
"target was %s/%s", irc.name, target_nick, channel)
|
|
|
|
else:
|
|
|
|
successful_punishments += 1
|
|
|
|
|
2018-06-10 01:18:24 +02:00
|
|
|
if not successful_punishments:
|
|
|
|
log.warning('(%s) antispam: Failed to punish %s with %r, target was %s', irc.name,
|
|
|
|
target_nick, punishment, channel or 'a PM')
|
|
|
|
|
2018-06-02 09:30:07 +02:00
|
|
|
return bool(successful_punishments)
|
2018-04-14 07:08:37 +02:00
|
|
|
|
|
|
|
MASSHIGHLIGHT_DEFAULTS = {
|
|
|
|
'min_length': 50,
|
|
|
|
'min_nicks': 5,
|
2018-06-02 08:48:22 +02:00
|
|
|
'reason': "Mass highlight spam is prohibited",
|
2018-06-02 09:06:21 +02:00
|
|
|
'punishment': 'kick+ban',
|
2018-06-02 08:48:22 +02:00
|
|
|
'enabled': False
|
2018-04-14 07:08:37 +02:00
|
|
|
}
|
|
|
|
def handle_masshighlight(irc, source, command, args):
|
|
|
|
"""Handles mass highlight attacks."""
|
|
|
|
channel = args['target']
|
|
|
|
text = args['text']
|
|
|
|
mhl_settings = irc.get_service_option('antispam', 'masshighlight',
|
|
|
|
MASSHIGHLIGHT_DEFAULTS)
|
2018-06-02 08:48:22 +02:00
|
|
|
|
|
|
|
if not mhl_settings.get('enabled', False):
|
|
|
|
return
|
|
|
|
|
2018-04-14 07:08:37 +02:00
|
|
|
my_uid = sbot.uids.get(irc.name)
|
|
|
|
|
2018-04-14 20:39:24 +02:00
|
|
|
# XXX workaround for single-bot protocols like Clientbot
|
|
|
|
if irc.pseudoclient and not irc.has_cap('can-spawn-clients'):
|
|
|
|
my_uid = irc.pseudoclient.uid
|
|
|
|
|
2018-04-14 07:08:37 +02:00
|
|
|
if (not irc.connected.is_set()) or (not my_uid):
|
|
|
|
# Break if the network isn't ready.
|
2018-06-09 02:54:32 +02:00
|
|
|
log.debug("(%s) antispam.masshighlight: skipping processing; network isn't ready", irc.name)
|
2018-04-14 07:08:37 +02:00
|
|
|
return
|
|
|
|
elif not irc.is_channel(channel):
|
2018-06-09 02:54:32 +02:00
|
|
|
# Not a channel - mass highlight blocking only makes sense within channels
|
|
|
|
log.debug("(%s) antispam.masshighlight: skipping processing; %r is not a channel", irc.name, channel)
|
2018-04-14 07:08:37 +02:00
|
|
|
return
|
|
|
|
elif irc.is_internal_client(source):
|
|
|
|
# Ignore messages from our own clients.
|
2018-06-09 02:54:32 +02:00
|
|
|
log.debug("(%s) antispam.masshighlight: skipping processing message from internal client %s", irc.name, source)
|
|
|
|
return
|
|
|
|
elif source not in irc.users:
|
|
|
|
log.debug("(%s) antispam.masshighlight: ignoring message from non-user %s", irc.name, source)
|
2018-04-14 07:08:37 +02:00
|
|
|
return
|
|
|
|
elif channel not in irc.channels or my_uid not in irc.channels[channel].users:
|
|
|
|
# We're not monitoring this channel.
|
2018-06-09 02:54:32 +02:00
|
|
|
log.debug("(%s) antispam.masshighlight: skipping processing message from channel %r we're not in", irc.name, channel)
|
2018-04-14 07:08:37 +02:00
|
|
|
return
|
|
|
|
elif len(text) < mhl_settings.get('min_length', MASSHIGHLIGHT_DEFAULTS['min_length']):
|
2018-06-09 02:54:32 +02:00
|
|
|
log.debug("(%s) antispam.masshighlight: skipping processing message %r; it's too short", irc.name, text)
|
2018-04-14 07:08:37 +02:00
|
|
|
return
|
|
|
|
|
2018-06-10 01:21:41 +02:00
|
|
|
if irc.get_service_option('antispam', 'strip_formatting', True):
|
|
|
|
text = utils.strip_irc_formatting(text)
|
|
|
|
|
2018-04-14 07:08:37 +02:00
|
|
|
# Strip :, from potential nicks
|
|
|
|
words = [word.rstrip(':,') for word in text.split()]
|
|
|
|
|
|
|
|
userlist = [irc.users[uid].nick for uid in irc.channels[channel].users.copy()]
|
|
|
|
min_nicks = mhl_settings.get('min_nicks', MASSHIGHLIGHT_DEFAULTS['min_nicks'])
|
|
|
|
|
|
|
|
# Don't allow repeating the same nick to trigger punishment
|
|
|
|
nicks_caught = set()
|
|
|
|
|
2018-04-14 07:14:59 +02:00
|
|
|
punished = False
|
2018-04-14 07:08:37 +02:00
|
|
|
for word in words:
|
|
|
|
if word in userlist:
|
|
|
|
nicks_caught.add(word)
|
|
|
|
if len(nicks_caught) >= min_nicks:
|
2018-06-02 09:06:21 +02:00
|
|
|
# Get the punishment and reason.
|
|
|
|
punishment = mhl_settings.get('punishment', MASSHIGHLIGHT_DEFAULTS['punishment']).lower()
|
2018-04-14 07:08:37 +02:00
|
|
|
reason = mhl_settings.get('reason', MASSHIGHLIGHT_DEFAULTS['reason'])
|
2018-06-02 09:06:21 +02:00
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
log.info("(%s) antispam: punishing %s => %s for mass highlight spam",
|
|
|
|
irc.name,
|
|
|
|
irc.get_friendly_name(source),
|
|
|
|
channel)
|
2018-06-02 09:06:21 +02:00
|
|
|
punished = _punish(irc, source, channel, punishment, reason)
|
2018-04-14 07:08:37 +02:00
|
|
|
break
|
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
log.debug('(%s) antispam.masshighlight: got %s/%s nicks on message to %r', irc.name,
|
|
|
|
len(nicks_caught), min_nicks, channel)
|
2018-04-14 07:14:59 +02:00
|
|
|
return not punished # Filter this message from relay, etc. if it triggered protection
|
2018-04-14 07:08:37 +02:00
|
|
|
|
2018-04-14 07:14:59 +02:00
|
|
|
utils.add_hook(handle_masshighlight, 'PRIVMSG', priority=1000)
|
|
|
|
utils.add_hook(handle_masshighlight, 'NOTICE', priority=1000)
|
2018-06-09 02:49:26 +02:00
|
|
|
|
|
|
|
TEXTFILTER_DEFAULTS = {
|
|
|
|
'reason': "Spam is prohibited",
|
2018-06-10 01:18:24 +02:00
|
|
|
'punishment': 'kick+ban+block',
|
2018-06-09 02:49:26 +02:00
|
|
|
'watch_pms': 'false',
|
|
|
|
'enabled': False
|
|
|
|
}
|
|
|
|
def handle_textfilter(irc, source, command, args):
|
|
|
|
"""Antispam text filter handler."""
|
|
|
|
target = args['target']
|
|
|
|
text = args['text']
|
|
|
|
txf_settings = irc.get_service_option('antispam', 'textfilter',
|
|
|
|
TEXTFILTER_DEFAULTS)
|
|
|
|
|
|
|
|
if not txf_settings.get('enabled', False):
|
|
|
|
return
|
|
|
|
|
|
|
|
my_uid = sbot.uids.get(irc.name)
|
|
|
|
|
|
|
|
# XXX workaround for single-bot protocols like Clientbot
|
|
|
|
if irc.pseudoclient and not irc.has_cap('can-spawn-clients'):
|
|
|
|
my_uid = irc.pseudoclient.uid
|
|
|
|
|
|
|
|
if (not irc.connected.is_set()) or (not my_uid):
|
|
|
|
# Break if the network isn't ready.
|
|
|
|
log.debug("(%s) antispam.textfilters: skipping processing; network isn't ready", irc.name)
|
|
|
|
return
|
|
|
|
elif irc.is_internal_client(source):
|
|
|
|
# Ignore messages from our own clients.
|
|
|
|
log.debug("(%s) antispam.textfilters: skipping processing message from internal client %s", irc.name, source)
|
|
|
|
return
|
|
|
|
elif source not in irc.users:
|
|
|
|
log.debug("(%s) antispam.textfilters: ignoring message from non-user %s", irc.name, source)
|
|
|
|
return
|
|
|
|
|
|
|
|
if irc.is_channel(target):
|
|
|
|
channel_or_none = target
|
|
|
|
if target not in irc.channels or my_uid not in irc.channels[target].users:
|
|
|
|
# We're not monitoring this channel.
|
|
|
|
log.debug("(%s) antispam.textfilters: skipping processing message from channel %r we're not in", irc.name, target)
|
|
|
|
return
|
|
|
|
else:
|
|
|
|
channel_or_none = None
|
|
|
|
watch_pms = txf_settings.get('watch_pms', TEXTFILTER_DEFAULTS['watch_pms'])
|
|
|
|
|
|
|
|
if watch_pms == 'services':
|
|
|
|
if not irc.get_service_bot(target):
|
|
|
|
log.debug("(%s) antispam.textfilters: skipping processing; %r is not a service bot (watch_pms='services')", irc.name, target)
|
|
|
|
return
|
|
|
|
elif watch_pms == 'all':
|
|
|
|
log.debug("(%s) antispam.textfilters: checking all PMs (watch_pms='all')", irc.name)
|
|
|
|
pass
|
|
|
|
else:
|
|
|
|
# Not a channel.
|
|
|
|
log.debug("(%s) antispam.textfilters: skipping processing; %r is not a channel and watch_pms is disabled", irc.name, target)
|
|
|
|
return
|
|
|
|
|
|
|
|
# Merge together global and local textfilter lists.
|
|
|
|
txf_globs = set(conf.conf.get('antispam', {}).get('textfilter_globs', [])) | \
|
|
|
|
set(irc.serverdata.get('antispam_textfilter_globs', []))
|
|
|
|
|
|
|
|
punishment = txf_settings.get('punishment', TEXTFILTER_DEFAULTS['punishment']).lower()
|
|
|
|
reason = txf_settings.get('reason', TEXTFILTER_DEFAULTS['reason'])
|
|
|
|
|
2018-06-10 01:21:41 +02:00
|
|
|
if irc.get_service_option('antispam', 'strip_formatting', True):
|
|
|
|
text = utils.strip_irc_formatting(text)
|
|
|
|
|
2018-06-09 02:49:26 +02:00
|
|
|
punished = False
|
|
|
|
for filterglob in txf_globs:
|
|
|
|
if ircmatch.match(1, filterglob, text):
|
|
|
|
log.info("(%s) antispam: punishing %s => %s for text filter %r",
|
|
|
|
irc.name,
|
|
|
|
irc.get_friendly_name(source),
|
|
|
|
irc.get_friendly_name(target),
|
|
|
|
filterglob)
|
|
|
|
punished = _punish(irc, source, channel_or_none, punishment, reason)
|
|
|
|
break
|
|
|
|
|
|
|
|
return not punished # Filter this message from relay, etc. if it triggered protection
|
|
|
|
|
|
|
|
utils.add_hook(handle_textfilter, 'PRIVMSG', priority=999)
|
|
|
|
utils.add_hook(handle_textfilter, 'NOTICE', priority=999)
|