diff options
| -rw-r--r-- | bot/__main__.py | 12 | ||||
| -rw-r--r-- | bot/cogs/defcon.py | 166 | ||||
| -rw-r--r-- | bot/cogs/token_remover.py | 94 | ||||
| -rw-r--r-- | bot/cogs/utils.py | 9 | ||||
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | config-default.yml | 1 |
6 files changed, 276 insertions, 7 deletions
diff --git a/bot/__main__.py b/bot/__main__.py index 8eb40757d..f470a42d6 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -5,7 +5,7 @@ from aiohttp import AsyncResolver, ClientSession, TCPConnector from discord import Game from discord.ext.commands import Bot, when_mentioned_or -from bot.constants import Bot as BotConfig, ClickUp +from bot.constants import Bot as BotConfig # , ClickUp from bot.formatter import Formatter from bot.utils.service_discovery import wait_for_rmq @@ -56,12 +56,13 @@ bot.load_extension("bot.cogs.cogs") # Local setups usually don't have the clickup key set, # and loading the cog would simply spam errors in the console. -if ClickUp.key is not None: - bot.load_extension("bot.cogs.clickup") -else: - log.info("`CLICKUP_KEY` not set in the environment, not loading the ClickUp cog.") +# if ClickUp.key is not None: +# bot.load_extension("bot.cogs.clickup") +# else: +# log.info("`CLICKUP_KEY` not set in the environment, not loading the ClickUp cog.") bot.load_extension("bot.cogs.deployment") +bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.doc") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.fun") @@ -70,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 new file mode 100644 index 000000000..ea50bdf63 --- /dev/null +++ b/bot/cogs/defcon.py @@ -0,0 +1,166 @@ +import logging +from datetime import datetime, timedelta + +from discord import Colour, Embed, Member +from discord.ext.commands import Bot, Context, command + +from bot.constants import Channels, Keys, Roles, URLs +from bot.decorators import with_role + +log = logging.getLogger(__name__) + +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 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 +<https://pythondiscord.com/>, and have a nice day! +""" + + +class Defcon: + """Time-sensitive server defense mechanisms""" + days = None # type: timedelta + enabled = False # type: bool + + def __init__(self, bot: Bot): + self.bot = bot + self.days = timedelta(days=0) + self.headers = {"X-API-KEY": Keys.site_api} + + async def on_ready(self): + try: + response = await self.bot.http_session.get( + URLs.site_settings_api, + headers=self.headers, + params={"keys": "defcon_enabled,defcon_days"} + ) + + data = await response.json() + + except Exception: # Yikes! + log.exception("Unable to get DEFCON settings!") + await self.bot.get_channel(Channels.devlog).send( + f"<@&{Roles.admin}> **WARNING**: Unable to get DEFCON settings!" + ) + + else: + if data["defcon_enabled"]: + self.enabled = True + self.days = timedelta(days=data["defcon_days"]) + log.warning(f"DEFCON enabled: {self.days.days} days") + + else: + self.enabled = False + self.days = timedelta(days=0) + log.warning(f"DEFCON disabled") + + async def on_member_join(self, member: Member): + if self.enabled and self.days.days > 0: + now = datetime.utcnow() + + if now - member.created_at < self.days: + log.info(f"Rejecting user {member}: Account is too new and DEFCON is enabled") + + try: + await member.send(REJECTION_MESSAGE.format(user=member.mention)) + except Exception: + log.exception(f"Unable to send rejection message to user: {member}") + + await member.kick(reason="DEFCON active, user is too new") + + @with_role(Roles.admin, Roles.owner) + @command(name="defcon.enable", aliases=["defcon.enable()", "defcon_enable", "defcon_enable()"]) + async def enable(self, ctx: Context): + """ + Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! + + Currently, this just adds an account age requirement. Use bot.defcon.days(int) to set how old an account must + be, in days. + """ + + self.enabled = True + + try: + response = await self.bot.http_session.put( + URLs.site_settings_api, + headers=self.headers, + json={"defcon_enabled": True} + ) + + await response.json() + except Exception: + log.exception("Unable to update DEFCON settings.") + await ctx.send("DEFCON enabled locally, but there was a problem updating the site.") + else: + await ctx.send("DEFCON enabled.") + + @with_role(Roles.admin, Roles.owner) + @command(name="defcon.disable", aliases=["defcon.disable()", "defcon_disable", "defcon_disable()"]) + async def disable(self, ctx: Context): + """ + Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! + """ + + self.enabled = False + + try: + response = await self.bot.http_session.put( + URLs.site_settings_api, + headers=self.headers, + json={"defcon_enabled": False} + ) + + await response.json() + except Exception: + log.exception("Unable to update DEFCON settings.") + await ctx.send("DEFCON disabled locally, but there was a problem updating the site.") + else: + await ctx.send("DEFCON disabled.") + + @with_role(Roles.admin, Roles.owner) + @command(name="defcon", aliases=["defcon()", "defcon.status", "defcon.status()"]) + async def defcon(self, ctx: Context): + """ + Check the current status of DEFCON mode. + """ + + embed = Embed(colour=Colour.blurple(), title="DEFCON Status") + embed.add_field(name="Enabled", value=str(self.enabled), inline=True) + embed.add_field(name="Days", value=str(self.days.days), inline=True) + + await ctx.send(embed=embed) + + @with_role(Roles.admin, Roles.owner) + @command(name="defcon.days", aliases=["defcon.days()", "defcon_days", "defcon_days()"]) + async def days_command(self, ctx: Context, days: int): + """ + Set how old an account must be to join the server, in days, with DEFCON mode enabled. + """ + + self.days = timedelta(days=days) + + try: + response = await self.bot.http_session.put( + URLs.site_settings_api, + headers=self.headers, + json={"defcon_days": days} + ) + + await response.json() + except Exception: + log.exception("Unable to update DEFCON settings.") + await ctx.send( + f"DEFCON days updated; accounts must be {days} days old to join to the server " + f"- but there was a problem updating the site." + ) + else: + await ctx.send(f"DEFCON days updated; accounts must be {days} days old to join to the server") + + +def setup(bot: Bot): + bot.add_cog(Defcon(bot)) + log.info("Cog loaded: Defcon") 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) diff --git a/bot/constants.py b/bot/constants.py index ce8fbb1cb..6433d068a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -257,6 +257,7 @@ class URLs(metaclass=YAMLGetter): site_idioms_api: str site_names_api: str site_quiz_api: str + site_settings_api: str site_special_api: str site_tags_api: str site_user_api: str diff --git a/config-default.yml b/config-default.yml index b58666ca1..4402eb9f1 100644 --- a/config-default.yml +++ b/config-default.yml @@ -80,6 +80,7 @@ urls: site_names_api: 'https://api.pythondiscord.com/bot/snake_names' site_off_topic_names_api: 'https://api.pythondiscord.com/bot/off-topic-names' site_quiz_api: 'https://api.pythondiscord.com/bot/snake_quiz' + site_settings_api: 'https://api.pythondiscord.com/bot/settings' site_special_api: 'https://api.pythondiscord.com/bot/special_snakes' site_tags_api: 'https://api.pythondiscord.com/bot/tags' site_user_api: 'https://api.pythondiscord.com/bot/users' |