|
| 1 | +import re |
| 2 | +import textwrap |
| 3 | +from pathlib import Path |
| 4 | + |
| 5 | +from discord import Message |
| 6 | +from discord.ext import commands |
| 7 | +from pydis_core.utils.logging import get_logger |
| 8 | + |
| 9 | +from bot.bot import SirRobin |
| 10 | + |
| 11 | +log = get_logger(__name__) |
| 12 | + |
| 13 | +PIXELS_API_KEY_REGEX = re.compile(r"owl-[A-Za-z0-9\-\_]{86}") |
| 14 | +PIXELS_JWT_REGEX = re.compile(r"e[yw][A-Za-z0-9-_]+\.(?:e[yw][A-Za-z0-9-_]+)?\.[A-Za-z0-9-_]{2,}") |
| 15 | + |
| 16 | +REVOKED_MESSAGE = textwrap.dedent(""" |
| 17 | +:warning: Your message contained a valid Pixels API key or access token, which has been revoked. |
| 18 | +
|
| 19 | +Visit [`/auth/authorize`](https://pixels.pythondiscord.com/auth/authorize) to generate a new API key. |
| 20 | +""") |
| 21 | + |
| 22 | +PIXELS_API_TOKEN_PATH = Path("/var/run/secrets/pixels-api/token") |
| 23 | + |
| 24 | +class Pixels(commands.Cog): |
| 25 | + """Utilities for working with Pixels API.""" |
| 26 | + |
| 27 | + def __init__(self, bot: SirRobin): |
| 28 | + self.bot = bot |
| 29 | + |
| 30 | + def get_request_headers(self) -> dict[str, str]: |
| 31 | + """Get the Pixels API request headers from the projected service account file.""" |
| 32 | + with open(PIXELS_API_TOKEN_PATH) as f: |
| 33 | + token = f.read().strip() |
| 34 | + |
| 35 | + return {"Authorization": f"Token {token}"} |
| 36 | + |
| 37 | + @commands.Cog.listener() |
| 38 | + async def on_message(self, message: Message) -> None: |
| 39 | + """Check for exposed Pixels API keys and JWTs in messages.""" |
| 40 | + if message.author.bot: |
| 41 | + return |
| 42 | + |
| 43 | + found_jwts = PIXELS_JWT_REGEX.findall(message.content) |
| 44 | + found_api_keys = PIXELS_API_KEY_REGEX.findall(message.content) |
| 45 | + any_invalidated = False |
| 46 | + |
| 47 | + if found_jwts or found_api_keys: |
| 48 | + headers = self.get_request_headers() |
| 49 | + log.info(f"Potential Pixels credentials in message {message.channel}/{message.id} by {message.author}.") |
| 50 | + # Call https://pixels.pythondiscord.com/api/invalidate_credential for each credential found |
| 51 | + |
| 52 | + for jwt in found_jwts: |
| 53 | + async with self.bot.http_session.post( |
| 54 | + "https://pixels.pythondiscord.com/api/invalidate_credential", |
| 55 | + headers=headers, |
| 56 | + json={"jwt": jwt}, |
| 57 | + ) as resp: |
| 58 | + if resp.status == 200: |
| 59 | + log.info(f"Successfully invalidated Pixels JWT from {message.channel}/{message.id}") |
| 60 | + any_invalidated = True |
| 61 | + else: |
| 62 | + if resp.status in (404, 400): |
| 63 | + # False positive, the JWT is already invalid or didn't exist |
| 64 | + continue |
| 65 | + |
| 66 | + log.error(f"Failed to invalidate Pixels JWT: {jwt}. Status code: {resp.status}") |
| 67 | + |
| 68 | + for api_key in found_api_keys: |
| 69 | + async with self.bot.http_session.post( |
| 70 | + "https://pixels.pythondiscord.com/api/invalidate_credential", |
| 71 | + headers=headers, |
| 72 | + json={"api_key": api_key}, |
| 73 | + ) as resp: |
| 74 | + if resp.status == 200: |
| 75 | + log.info(f"Successfully invalidated Pixels key from {message.channel}/{message.id}") |
| 76 | + any_invalidated = True |
| 77 | + else: |
| 78 | + if resp.status in (404, 400): |
| 79 | + # False positive, the API key is already invalid or didn't exist |
| 80 | + continue |
| 81 | + |
| 82 | + log.error(f"Failed to invalidate Pixels API key: {api_key}. Status code: {resp.status}") |
| 83 | + |
| 84 | + if any_invalidated: |
| 85 | + await message.channel.send(REVOKED_MESSAGE) |
| 86 | + await message.delete() |
| 87 | + log.info(f"Deleted message {message.channel}/{message.id} containing exposed Pixels credentials.") |
| 88 | + |
| 89 | + |
| 90 | +async def setup(bot: SirRobin) -> None: |
| 91 | + """Load the Pixels cog.""" |
| 92 | + if not PIXELS_API_TOKEN_PATH.exists(): |
| 93 | + log.warning(f"Pixels API token file {PIXELS_API_TOKEN_PATH} does not exist. Skipping Pixels cog setup.") |
| 94 | + return |
| 95 | + |
| 96 | + await bot.add_cog(Pixels(bot)) |
0 commit comments