diff options
| -rw-r--r-- | bot/__main__.py | 1 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 4 | ||||
| -rw-r--r-- | bot/cogs/off_topic_names.py | 27 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 94 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 9 |
5 files changed, 129 insertions, 6 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index f297c8f1a..f470a42d6 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -71,6 +71,7 @@ bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.snakes") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.tags") +bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.verification") diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 168cc7666..ea50bdf63 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -13,7 +13,7 @@ REJECTION_MESSAGE = """ Hi, {user} - Thanks for your interest in our server! Due to a current (or detected) cyberattack on our community, we've limited access to the server for new accounts. Since -your account is relatively now, we're unable to provide access to the server at this time. +your account is relatively new, we're unable to provide access to the server at this time. Even so, thanks for joining! We're very excited at the possibility of having you here, and we hope that this situation will be resolved soon. In the meantime, please feel free to peruse the resources on our site at @@ -63,7 +63,7 @@ class Defcon: now = datetime.utcnow() if now - member.created_at < self.days: - log.info(f"Rejecting user {member}: Account is too old and DEFCON is enabled") + log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") try: await member.send(REJECTION_MESSAGE.format(user=member.mention)) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 2a3cb2aa7..90510c8c4 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -2,10 +2,12 @@ import asyncio import logging from datetime import datetime, timedelta +from discord import Colour, Embed from discord.ext.commands import BadArgument, Bot, Context, Converter, command from bot.constants import Channels, Keys, Roles, URLs from bot.decorators import with_role +from bot.pagination import LinePaginator CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) @@ -20,10 +22,10 @@ class OffTopicName(Converter): if not (2 <= len(argument) <= 96): raise BadArgument("Channel name must be between 2 and 96 chars long") - elif not all(c.isalpha() or c == '-' for c in argument): + elif not all(c.isalnum() or c == '-' for c in argument): raise BadArgument( "Channel name must only consist of" - " alphabetic characters or minus signs" + " alphanumeric characters or minus signs" ) elif not argument.islower(): @@ -104,6 +106,27 @@ class OffTopicNames: error_reason = response.get('message', "No reason provided.") await ctx.send(f":warning: got non-200 from the API: {error_reason}") + @command(name='otname.list()', aliases=['otname.list']) + @with_role(Roles.owner, Roles.admin, Roles.moderator) + async def otname_list(self, ctx): + """ + Lists all currently known off-topic channel names in a paginator. + Restricted to Moderator and above to not spoil the surprise. + """ + + result = await self.bot.http_session.get( + URLs.site_off_topic_names_api, + headers=self.headers + ) + response = await result.json() + lines = sorted(f"• {name}" for name in response) + + embed = Embed( + title=f"Known off-topic names (`{len(response)}` total)", + colour=Colour.blue() + ) + await LinePaginator.paginate(lines, ctx, embed, max_size=400, empty=False) + def setup(bot: Bot): bot.add_cog(OffTopicNames(bot)) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py new file mode 100644 index 000000000..c8621118b --- /dev/null +++ b/bot/cogs/token_remover.py @@ -0,0 +1,94 @@ +import base64 +import binascii +import logging +import re +import struct +from datetime import datetime + +from discord import Message +from discord.ext.commands import Bot +from discord.utils import snowflake_time + +from bot.constants import Channels + + +log = logging.getLogger(__name__) + +DELETION_MESSAGE_TEMPLATE = ( + "Hey {mention}! I noticed you posted a seemingly valid Discord API " + "token in your message and have removed your message to prevent abuse. " + "We recommend regenerating your token regardless, which you can do here: " + "<https://discordapp.com/developers/applications/me>\n" + "Feel free to re-post it with the token removed. " + "If you believe this was a mistake, please let us know!" +) +DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1) +TOKEN_EPOCH = 1_293_840_000 +TOKEN_RE = re.compile( + r"(?<=(\"|'))" # Lookbehind: Only match if there's a double or single quote in front + r"[^\W\.]+" # Matches token part 1: The user ID string, encoded as base64 + r"\." # Matches a literal dot between the token parts + r"[^\W\.]+" # Matches token part 2: The creation timestamp, as an integer + r"\." # Matches a literal dot between the token parts + r"[^\W\.]+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty + r"(?=(\"|'))" # Lookahead: Only match if there's a double or single quote after +) + + +class TokenRemover: + """Scans messages for potential discord.py bot tokens and removes them.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.modlog = None + + async def on_ready(self): + self.modlog = self.bot.get_channel(Channels.modlog) + + async def on_message(self, msg: Message): + if msg.author.bot: + return + + maybe_match = TOKEN_RE.search(msg.content) + if maybe_match is None: + return + + try: + user_id, creation_timestamp, hmac = maybe_match.group(0).split('.') + except ValueError: + return + + if self.is_valid_user_id(user_id) and self.is_valid_timestamp(creation_timestamp): + await msg.delete() + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + await self.modlog.send( + ":key2::mute: censored a seemingly valid token sent by " + f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " + f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" + ) + + @staticmethod + def is_valid_user_id(b64_content: str) -> bool: + b64_content += '=' * (-len(b64_content) % 4) + + try: + content: bytes = base64.b64decode(b64_content) + return content.decode('utf-8').isnumeric() + except (binascii.Error, UnicodeDecodeError): + return False + + @staticmethod + def is_valid_timestamp(b64_content: str) -> bool: + b64_content += '=' * (-len(b64_content) % 4) + + try: + content = base64.urlsafe_b64decode(b64_content) + snowflake = struct.unpack('i', content)[0] + except (binascii.Error, struct.error): + return False + return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP + + +def setup(bot: Bot): + bot.add_cog(TokenRemover(bot)) + log.info("Cog loaded: TokenRemover") diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index ee1f0a199..7b11f521c 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -30,8 +30,13 @@ class Utils: Fetches information about a PEP and sends it to the channel. """ - # Attempt to fetch the PEP from Github. - pep_url = f"{self.base_github_pep_url}{pep_number.zfill(4)}.txt" + # Newer PEPs are written in RST instead of txt + if int(pep_number) > 542: + pep_url = f"{self.base_github_pep_url}{pep_number.zfill(4)}.rst" + else: + pep_url = f"{self.base_github_pep_url}{pep_number.zfill(4)}.txt" + + # Attempt to fetch the PEP log.trace(f"Requesting PEP {pep_number} with {pep_url}") response = await self.bot.http_session.get(pep_url) |