From 7aa90b798176f77012961758200ee399682b7bc8 Mon Sep 17 00:00:00 2001 From: Grazfather Date: Thu, 26 Sep 2019 21:03:29 -0700 Subject: [PATCH] New release 0.1.0 (#197) --- .circleci/config.yml | 55 ++ .dockerignore | 2 + .gitignore | 1 + CHANGELOG.md | 8 + Dockerfile | 10 +- Makefile | 23 +- README.md | 45 ++ addons/syscalls/syscallinfo.py | 31 +- bottypes/challenge.py | 2 + bottypes/command.py | 8 +- bottypes/ctf.py | 2 +- bottypes/invalid_command.py | 3 +- bottypes/invalid_console_command.py | 3 +- config.json.template | 4 +- config_savelink.json.template | 7 + handlers/__init__.py | 3 +- handlers/admin_handler.py | 30 +- handlers/base_handler.py | 43 +- handlers/bot_handler.py | 66 ++- handlers/challenge_handler.py | 318 ++++++++---- handlers/handler_factory.py | 34 +- handlers/linksave_handler.py | 94 ++++ handlers/syscalls_handler.py | 29 +- handlers/wolfram_handler.py | 24 +- pylintrc | 477 ++++++++++++++++++ requirements.txt | 28 +- run.py | 10 +- runtests.py | 303 +++++++++++ server/botserver.py | 91 ++-- server/consolethread.py | 11 +- tests/__init__.py | 3 + tests/slack_test_response.py | 10 + tests/slackwrapper_mock.py | 127 +++++ ...eate_channel_private_response_default.json | 32 ++ ...reate_channel_public_response_default.json | 38 ++ .../get_member_response_default.json | 41 ++ .../get_members_response_default.json | 115 +++++ ...get_private_channels_response_default.json | 53 ++ .../get_public_channels_response_default.json | 67 +++ .../set_purpose_response_default.json | 4 + util/githandler.py | 6 +- util/savelinkhelper.py | 65 +++ util/slack_wrapper.py | 45 +- util/solveposthelper.py | 2 +- util/util.py | 43 +- 45 files changed, 2120 insertions(+), 296 deletions(-) create mode 100644 .circleci/config.yml create mode 100644 .dockerignore create mode 100644 CHANGELOG.md create mode 100644 config_savelink.json.template create mode 100644 handlers/linksave_handler.py create mode 100644 pylintrc create mode 100644 runtests.py create mode 100644 tests/__init__.py create mode 100644 tests/slack_test_response.py create mode 100644 tests/slackwrapper_mock.py create mode 100644 tests/testfiles/create_channel_private_response_default.json create mode 100644 tests/testfiles/create_channel_public_response_default.json create mode 100644 tests/testfiles/get_member_response_default.json create mode 100644 tests/testfiles/get_members_response_default.json create mode 100644 tests/testfiles/get_private_channels_response_default.json create mode 100644 tests/testfiles/get_public_channels_response_default.json create mode 100644 tests/testfiles/set_purpose_response_default.json create mode 100644 util/savelinkhelper.py diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..9a3756a --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,55 @@ +# Python CircleCI 2.0 configuration file +# +# Check https://circleci.com/docs/2.0/language-python/ for more details +# +version: 2 +jobs: + build: + docker: + - image: circleci/python:3.6.1 + + # Specify service dependencies here if necessary + # CircleCI maintains a library of pre-built images + # documented at https://circleci.com/docs/2.0/circleci-images/ + # - image: circleci/postgres:9.4 + + working_directory: ~/repo + + steps: + - checkout + + # Download and cache dependencies + - restore_cache: + keys: + - v1-dependencies-{{ checksum "requirements.txt" }} + # fallback to using the latest cache if no exact match is found + - v1-dependencies- + + - run: + name: install dependencies + command: | + python3 -m venv venv + . venv/bin/activate + pip install -r requirements.txt + + - save_cache: + paths: + - ./venv + key: v1-dependencies-{{ checksum "requirements.txt" }} + + - run: + name: run tests + command: | + . venv/bin/activate + python runtests.py + + - run: + name: lint + command: | + . venv/bin/activate + pylint **/*.py -E + pylint **/*.py --exit-zero + + - store_artifacts: + path: test-reports + destination: test-reports diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2ee975 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +.git +.venv diff --git a/.gitignore b/.gitignore index 9df4731..a220570 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.pyc config.json +config_savelink.json config_solvetracker.json *.bin *.log diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..9afdf7f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog +All notable changes to this project will be kept in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + +## [0.1.0] - 2019-09-10 +Initial release diff --git a/Dockerfile b/Dockerfile index c165e3a..e8a506f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,14 @@ FROM python:3 -# Bundle app source -COPY . /src/ -WORKDIR /src/ +WORKDIR /src + +# Copy requirements +COPY ./requirements.txt /src # Install requirements RUN pip install --no-cache-dir -r requirements.txt +# Copy rest of source +COPY . /src + CMD ["python", "run.py"] diff --git a/Makefile b/Makefile index 442b41a..68ea360 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,28 @@ PWD = $(shell pwd) -image: +build: docker build . -t otabot -lint: +image: + docker images | grep otabot || docker build . -t otabot + +lint: image docker run --rm -v ${PWD}/:/src/ otabot pylint **/*.py -E -run: image +checklint: image + docker run --rm -v ${PWD}/:/src/ otabot pylint **/*.py --exit-zero + +run: build docker run --rm -it otabot -runlocal: +runlocal: image docker run --rm -it -v ${PWD}/:/src/ otabot + +test: + docker run --rm -v ${PWD}/:/src/ otabot python3 runtests.py + +background: image + docker run --rm -d --name otabot otabot + +stop: + docker stop otabot diff --git a/README.md b/README.md index ed458e0..da42cc4 100644 --- a/README.md +++ b/README.md @@ -108,3 +108,48 @@ Example: 3. Update the templates in `templates` according to your preferences (or go with the default ones). 4. Make sure that there's a `_posts` and `_stats` folder in your git repository. 4. You should be good to go now and git support should be active on the next startup. You can now use the `postsolves` command to push blog posts with the current solve status to git. + + +## Using Link saver + +1. Setup a github repo with jekyll and staticman (e.g. https://github.com/ujjwal96/links). +2. Copy `config_savelink.json.template` to `config_savelink.json`. +3. Configure the git repo and branch to be used. +4. Add the decrypted staticman-token used in `staticman.yml` in the config. +5. Add a link to your repo, so people can look it up via `showlinkurl` + +Example: +``` +{ + "git_repo": "reponame/links", + "git_branch": "gh-pages", + "staticman-token": "9d837771-945a-489d-cd80-13abcdefa112", + "allowed_users": [], + "repo_link_url": "https://reponame.github.io/links/" +} +``` + +## Archive reminder + +To enable archive reminders set an offset (in hours) in `config.json` for `archive_ctf_reminder_offset`. Clear or remove the setting to disable reminder handling. + +If active, the bot will create a reminder for every bot admin on `!endctf` to inform him, when the ctf was finished for the specified time and it should be archived. + +Example (for being reminded one week after the ctf has finished): +``` +{ + ... + "archive_ctf_reminder_offset" : "168" +} +``` + +## Log command deletion + +To enable logging of deleting messages containing specific keywords, set `delete_watch_keywords` in `config.json` to a comma separated list of keywords. +Clear or remove the setting to disable deletion logging. + +Example +``` +{ + "delete_watch_keywords" : "workon, reload, endctf" +} diff --git a/addons/syscalls/syscallinfo.py b/addons/syscalls/syscallinfo.py index 2075229..b0b5693 100644 --- a/addons/syscalls/syscallinfo.py +++ b/addons/syscalls/syscallinfo.py @@ -11,7 +11,7 @@ def __init__(self, filename): self.parse_table(filename) - def getEntryDict(self, parts, identifiers): + def get_entry_dict(self, parts, identifiers): entry = collections.OrderedDict() for i in range(len(parts)): @@ -33,23 +33,20 @@ def parse_table(self, filename): for line in lines[1:]: parts = line.split("\t") - self.entries[parts[1]] = self.getEntryDict( + self.entries[parts[1]] = self.get_entry_dict( line.split("\t"), identifiers) - def getEntryByID(self, idx): + def get_entry_by_id(self, idx): for entry in self.entries: if self.entries[entry]["#"] == str(idx): return self.entries[entry] return None - def getEntryByName(self, name): - if name in self.entries: - return self.entries[name] + def get_entry_by_name(self, name): + return self.entries.get(name) - return None - - def getInfoMessage(self, entry): + def get_info_message(self, entry): if entry: msg = "" @@ -60,13 +57,13 @@ def getInfoMessage(self, entry): return None - def getInfoMessageByID(self, idx): - entry = self.getEntryByID(idx) - return self.getInfoMessage(entry) + def get_info_message_by_id(self, idx): + entry = self.get_entry_by_id(idx) + return self.get_info_message(entry) - def getInfoMessageByName(self, name): - entry = self.getEntryByName(name) - return self.getInfoMessage(entry) + def get_info_message_by_name(self, name): + entry = self.get_entry_by_name(name) + return self.get_info_message(entry) class SyscallInfo: @@ -79,10 +76,10 @@ def __init__(self, basedir): self.tables[table] = SyscallTable(filename) - def getAvailableArchitectures(self): + def get_available_architectures(self): return self.tables.keys() - def getArch(self, arch): + def get_arch(self, arch): if arch in self.tables: return self.tables[arch] diff --git a/bottypes/challenge.py b/bottypes/challenge.py index c6e67c8..0897e45 100644 --- a/bottypes/challenge.py +++ b/bottypes/challenge.py @@ -5,8 +5,10 @@ class Challenge: def __init__(self, ctf_channel_id, channel_id, name, category): """ An object representation of an ongoing challenge. + ctf_channel_id : The slack id for the associated parent ctf channel channel_id : The slack id for the associated channel name : The name of the challenge + category : The category of the challenge """ self.channel_id = channel_id diff --git a/bottypes/command.py b/bottypes/command.py index 74df8c9..e598491 100644 --- a/bottypes/command.py +++ b/bottypes/command.py @@ -1,10 +1,12 @@ -from abc import ABC, abstractmethod +from abc import ABC class Command(ABC): """Defines the command interface.""" - def __init__(self): pass + def __init__(self): + pass @classmethod - def execute(cls, slack_client, args, channel_id, user, user_is_admin): pass + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): + pass diff --git a/bottypes/ctf.py b/bottypes/ctf.py index 0413b13..60f10bf 100644 --- a/bottypes/ctf.py +++ b/bottypes/ctf.py @@ -12,9 +12,9 @@ def __init__(self, channel_id, name, long_name): self.challenges = [] self.cred_user = "" self.cred_pw = "" - self.cred_url = "" self.long_name = long_name self.finished = False + self.finished_on = 0 def add_challenge(self, challenge): """ diff --git a/bottypes/invalid_command.py b/bottypes/invalid_command.py index 2aa85ef..b7cf34c 100644 --- a/bottypes/invalid_command.py +++ b/bottypes/invalid_command.py @@ -4,5 +4,4 @@ class InvalidCommand(Exception): The message should be the usage for that command. """ - def __init__(self, message): - self.message = message + pass diff --git a/bottypes/invalid_console_command.py b/bottypes/invalid_console_command.py index 74d403d..0c8cf90 100644 --- a/bottypes/invalid_console_command.py +++ b/bottypes/invalid_console_command.py @@ -4,5 +4,4 @@ class InvalidConsoleCommand(Exception): The message should be the usage for that command. """ - def __init__(self, message): - self.message = message + pass diff --git a/config.json.template b/config.json.template index 8ec1039..436da4c 100644 --- a/config.json.template +++ b/config.json.template @@ -4,5 +4,7 @@ "send_help_as_dm" : "1", "admin_users" : [], "auto_invite" : [], - "wolfram_app_id" : "" + "wolfram_app_id" : "", + "archive_ctf_reminder_offset" : "168", + "delete_watch_keywords" : "" } diff --git a/config_savelink.json.template b/config_savelink.json.template new file mode 100644 index 0000000..5ed7d53 --- /dev/null +++ b/config_savelink.json.template @@ -0,0 +1,7 @@ +{ + "git_repo": "user/repo", + "git_branch": "master", + "staticman-token": "", + "allowed_users": [], + "repo_link_url": "" +} diff --git a/handlers/__init__.py b/handlers/__init__.py index b3528fa..026dfc5 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -3,5 +3,6 @@ "syscalls_handler", "bot_handler", "admin_handler", - "wolfram_handler" + "wolfram_handler", + "linksave_handler" ] diff --git a/handlers/admin_handler.py b/handlers/admin_handler.py index cda645c..46b8e40 100644 --- a/handlers/admin_handler.py +++ b/handlers/admin_handler.py @@ -1,19 +1,17 @@ -import re - -from bottypes.command import * -from bottypes.command_descriptor import * -from bottypes.invalid_command import * -import handlers.handler_factory as handler_factory -from handlers.base_handler import * -from addons.syscalls.syscallinfo import * -from util.util import * +from bottypes.command import Command +from bottypes.command_descriptor import CommandDesc +from bottypes.invalid_command import InvalidCommand +from handlers import handler_factory +from handlers.base_handler import BaseHandler +from util.util import (get_display_name_from_user, parse_user_id, + resolve_user_by_user_id) class ShowAdminsCommand(Command): """Shows list of users in the admin user group.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the ShowAdmins command.""" admin_users = handler_factory.botserver.get_config_option("admin_users") @@ -26,7 +24,7 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): user_object = slack_wrapper.get_member(admin_id) if user_object['ok']: - response += "*{}* ({})\n".format(user_object['user']['name'], admin_id) + response += "*{}* ({})\n".format(get_display_name_from_user(user_object["user"]), admin_id) response += "===================================" @@ -46,7 +44,7 @@ class AddAdminCommand(Command): """Add a user to the admin user group.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the AddAdmin command.""" user_object = resolve_user_by_user_id(slack_wrapper, args[0]) @@ -72,7 +70,7 @@ class RemoveAdminCommand(Command): """Remove a user from the admin user group.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the RemoveAdmin command.""" user = parse_user_id(args[0]) @@ -93,7 +91,7 @@ class AsCommand(Command): """Execute a command as another user.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the As command.""" dest_user = args[0].lower() dest_command = args[1].lower().lstrip("!") @@ -106,8 +104,8 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): dest_user_id = user_obj['user']['id'] # Redirecting command execution to handler factory - handler_factory.process_command(slack_wrapper, dest_command, [ - dest_command] + dest_arguments, channel_id, dest_user_id, user_is_admin) + handler_factory.process_command(slack_wrapper, dest_command, + [dest_command] + dest_arguments, timestamp, channel_id, dest_user_id, user_is_admin) else: raise InvalidCommand("You have to specify a valid user (use @-notation).") diff --git a/handlers/base_handler.py b/handlers/base_handler.py index 67167d7..e8fe3c2 100644 --- a/handlers/base_handler.py +++ b/handlers/base_handler.py @@ -1,11 +1,11 @@ -from abc import ABC, abstractmethod +from abc import ABC -from bottypes.command import * -from bottypes.invalid_command import * +from bottypes.invalid_command import InvalidCommand class BaseHandler(ABC): commands = {} # Overridden by concrete class + aliases = {} # Overridden by concrete class reactions = {} handler_name = "" # Overridden by concrete class @@ -16,6 +16,10 @@ def can_handle(self, command, user_is_admin): if user_is_admin or not cmd_desc.is_admin_cmd: return True + if command in self.aliases: + if self.can_handle(self.aliases[command], user_is_admin): + return True + return False def can_handle_reaction(self, reaction): @@ -26,6 +30,19 @@ def can_handle_reaction(self, reaction): def init(self, slack_wrapper): pass + def get_aliases_for_command(self, command): + cmd_aliases = [] + + if self.aliases: + for alias in self.aliases: + if self.aliases[alias] == command: + cmd_aliases.append(alias) + + if cmd_aliases: + return " `(Alias: {})`".format(", ".join(cmd_aliases)) + + return "" + def parse_command_usage(self, command, descriptor): """Returns a usage string from a given command and descriptor.""" msg = "`!{} {}".format(self.handler_name, command) @@ -38,6 +55,9 @@ def parse_command_usage(self, command, descriptor): msg += "`" + # check for aliases + msg += self.get_aliases_for_command(command) + if descriptor.description: msg += "\n\t({})".format(descriptor.description) @@ -65,18 +85,21 @@ def get_usage(self, user_is_admin): return msg - def process(self, slack_wrapper, command, args, channel, user, user_is_admin): + def process(self, slack_wrapper, command, args, timestamp, channel, user, user_is_admin): """Check if enough arguments were passed for this command.""" - cmd_descriptor = self.commands[command] + if command in self.aliases: + self.process(slack_wrapper, self.aliases[command], args, timestamp, channel, user, user_is_admin) + elif command in self.commands: + cmd_descriptor = self.commands[command] - if cmd_descriptor: - if len(args) < len(cmd_descriptor.arguments): - raise InvalidCommand(self.command_usage(command, cmd_descriptor)) - cmd_descriptor.command.execute(slack_wrapper, args, channel, user, user_is_admin) + if cmd_descriptor: + if len(args) < len(cmd_descriptor.arguments): + raise InvalidCommand(self.command_usage(command, cmd_descriptor)) + cmd_descriptor.command.execute(slack_wrapper, args, timestamp, channel, user, user_is_admin) def process_reaction(self, slack_wrapper, reaction, channel, timestamp, user, user_is_admin): reaction_descriptor = self.reactions[reaction] if reaction_descriptor: reaction_descriptor.command.execute( - slack_wrapper, {"reaction": reaction, "timestamp": timestamp}, channel, user, user_is_admin) + slack_wrapper, {"reaction": reaction, "timestamp": timestamp}, timestamp, channel, user, user_is_admin) diff --git a/handlers/bot_handler.py b/handlers/bot_handler.py index dfe778d..5841e5e 100644 --- a/handlers/bot_handler.py +++ b/handlers/bot_handler.py @@ -1,23 +1,18 @@ -import shlex -import pickle -import re -import json - -from unidecode import unidecode - -from bottypes.command import * -from bottypes.command_descriptor import * -import handlers.handler_factory as handler_factory -from handlers.base_handler import * +from bottypes.command import Command +from bottypes.command_descriptor import CommandDesc +from bottypes.invalid_command import InvalidCommand +from handlers import handler_factory +from handlers.base_handler import BaseHandler from util.githandler import GitHandler from util.loghandler import log +import subprocess class PingCommand(Command): """Ping this server to check for uptime.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Announce the bot's presence in the channel.""" slack_wrapper.post_message(channel_id, "Pong!") @@ -26,7 +21,7 @@ class IntroCommand(Command): """Show an introduction message for new members.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Intro command.""" try: with open("intro_msg") as f: @@ -43,7 +38,7 @@ class VersionCommand(Command): """Show git information about the current running version of the bot.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Version command.""" try: message = GitHandler(".").get_version() @@ -54,6 +49,45 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): raise InvalidCommand("Sorry, couldn't retrieve the git information for the bot...") +class InviteCommand(Command): + """ + Invite a list of members to the current channel, ignores members already + present. + """ + + @classmethod + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): + current_members = slack_wrapper.get_channel_members(channel_id) + # strip uid formatting + invited_users = [user.strip("<>@") for user in args] + # remove already present members + invited_users = [user for user in invited_users if user not in current_members] + failed_users = [] + for member in invited_users: + if not slack_wrapper.invite_user(member, channel_id)["ok"]: + failed_users.append(member) + + if failed_users: + log.exception("BotHandler::InviteCommand") + raise InvalidCommand("Sorry, couldn't invite the following members to the channel: " + ' '.join(failed_users)) + + +class SysInfoCommand(Command): + """ + Show information about system resources on the machine, otabot is running on. + """ + + @classmethod + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): + result = b"```\n" + result += b'\n'.join(subprocess.check_output(['top', '-bn1']).split(b"\n")[:20]) + result += b"\n\n" + result += subprocess.check_output(['df', '-h']) + result += b"```\n" + + slack_wrapper.post_message(user_id, result) + + class BotHandler(BaseHandler): """Handler for generic bot commands.""" @@ -61,7 +95,9 @@ def __init__(self): self.commands = { "ping": CommandDesc(PingCommand, "Ping the bot", None, None), "intro": CommandDesc(IntroCommand, "Show an introduction message for new members", None, None), - "version": CommandDesc(VersionCommand, "Show git information about the running version of the bot", None, None) + "version": CommandDesc(VersionCommand, "Show git information about the running version of the bot", None, None), + "invite": CommandDesc(InviteCommand, "Invite a list of members (using @username) to the current channel (smarter than /invite)", ["user_list"], None), + "sysinfo": CommandDesc(SysInfoCommand, "Show system information", None, None, True) } diff --git a/handlers/challenge_handler.py b/handlers/challenge_handler.py index 4e7ce66..1babdf5 100644 --- a/handlers/challenge_handler.py +++ b/handlers/challenge_handler.py @@ -1,36 +1,54 @@ import pickle - -from bottypes.ctf import * -from bottypes.challenge import * -from bottypes.player import * -from bottypes.command_descriptor import * -from bottypes.reaction_descriptor import * -import handlers.handler_factory as handler_factory -from handlers.base_handler import * +import time +from random import randint +from dateutil.relativedelta import relativedelta + +from bottypes.challenge import Challenge +from bottypes.command import Command +from bottypes.command_descriptor import CommandDesc +from bottypes.ctf import CTF +from bottypes.player import Player +from bottypes.reaction_descriptor import ReactionDesc +from handlers import handler_factory +from handlers.base_handler import BaseHandler +from util.loghandler import log +from util.solveposthelper import ST_GIT_SUPPORT, post_ctf_data from util.util import * -from util.slack_wrapper import * -from util.solveposthelper import * -from util.githandler import * -from util.ctf_template_resolver import * -def is_valid_name(name): - if re.match(r"^[\w\-_]+$", name): - return True - return False +class RollCommand(Command): + """Roll the dice. ;)""" + + @classmethod + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): + """Execute Roll command.""" + val = randint(0, 100) + + member = slack_wrapper.get_member(user_id) + display_name = get_display_name(member) + + message = "*{}* rolled the dice... *{}*".format(display_name, val) + + slack_wrapper.post_message(channel_id, message) +MAX_CHANNEL_NAME_LENGTH = 80 +MAX_CTF_NAME_LENGTH = 40 class AddCTFCommand(Command): """Add and keep track of a new CTF.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute AddCTF command.""" name = args[0].lower() long_name = " ".join(args[1:]) - if len(name) > 10: - raise InvalidCommand("Add CTF failed: CTF name must be <= 10 characters.") + # Don't allow incorrectly parsed long names + if " MAX_CTF_NAME_LENGTH: + raise InvalidCommand("Add CTF failed: CTF name must be <= {} characters.".format(MAX_CTF_NAME_LENGTH)) # Check for invalid characters if not is_valid_name(name): @@ -40,7 +58,7 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): response = slack_wrapper.create_channel(name) # Validate that the channel was successfully created. - if response['ok'] == False: + if not response['ok']: raise InvalidCommand("\"{}\" channel creation failed:\nError : {}".format(name, response['error'])) ctf_channel_id = response['channel']['id'] @@ -63,8 +81,8 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): auto_invite_list = handler_factory.botserver.get_config_option("auto_invite") if type(auto_invite_list) == list: - for user_id in auto_invite_list: - slack_wrapper.invite_user(user_id, ctf_channel_id) + for invite_user_id in auto_invite_list: + slack_wrapper.invite_user(invite_user_id, ctf_channel_id) # Notify people of new channel message = "Created channel #{}".format(response['channel']['name']).strip() @@ -75,7 +93,7 @@ class RenameChallengeCommand(Command): """Renames an existing challenge channel.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): old_name = args[0].lower() new_name = args[1].lower() @@ -85,9 +103,10 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): if not ctf: raise InvalidCommand("Rename challenge failed: You are not in a CTF channel.") - if len(new_name) > (20 - len(ctf.name)): + if len(new_name) > (MAX_CHANNEL_NAME_LENGTH - len(ctf.name) - 1): raise InvalidCommand( - "Rename challenge failed: Challenge name must be <= {} characters.".format(20 - len(ctf.name))) + "Rename challenge failed: Challenge name must be <= {} characters.".format( + MAX_CHANNEL_NAME_LENGTH - len(ctf.name) - 1)) # Check for invalid characters if not is_valid_name(new_name): @@ -102,7 +121,7 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): if not challenge: raise InvalidCommand("Rename challenge failed: Challenge '{}' not found.".format(old_name)) - log.debug("Renaming channel {} to {}".format(channel_id, new_name)) + log.debug("Renaming channel %s to %s", channel_id, new_name) response = slack_wrapper.rename_channel(challenge.channel_id, new_channel_name, is_private=True) if not response['ok']: @@ -123,7 +142,7 @@ class RenameCTFCommand(Command): """Renames an existing challenge channel.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): old_name = args[0].lower() new_name = args[1].lower() @@ -136,13 +155,13 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): # pre-check challenges, if renaming would break channel name length for chall in ctf.challenges: - if len(chall.name) + ctflen > 20: + if len(chall.name) + ctflen > MAX_CHANNEL_NAME_LENGTH - 1: raise InvalidCommand( "Rename CTF failed: Challenge {} would break channel name length restriction.".format(chall.name)) # still ctf name shouldn't be longer than 10 characters for allowing reasonable challenge names - if len(new_name) > 10: - raise InvalidCommand("Rename CTF failed: CTF name must be <= 10 characters.") + if len(new_name) > MAX_CTF_NAME_LENGTH: + raise InvalidCommand("Rename CTF failed: CTF name must be <= {} characters.".format(MAX_CTF_NAME_LENGTH)) # Check for invalid characters if not is_valid_name(new_name): @@ -166,7 +185,7 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): # Rename all challenge channels for this ctf for chall in ctf.challenges: RenameChallengeCommand().execute( - slack_wrapper, [chall.name, chall.name], ctf.channel_id, user_id, user_is_admin) + slack_wrapper, [chall.name, chall.name], timestamp, ctf.channel_id, user_id, user_is_admin) text = "CTF `{}` renamed to `{}` (#{})".format(old_name, new_name, new_name) slack_wrapper.post_message(ctf.channel_id, text) @@ -178,7 +197,7 @@ class AddChallengeCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the AddChallenge command.""" name = args[0].lower() category = args[1] if len(args) > 1 else None @@ -189,9 +208,10 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): if not ctf: raise InvalidCommand("Add challenge failed: You are not in a CTF channel.") - if len(name) > (20 - len(ctf.name)): + if len(name) > (MAX_CHANNEL_NAME_LENGTH - len(ctf.name) - 1): raise InvalidCommand( - "Add challenge failed: Challenge name must be <= {} characters.".format(20 - len(ctf.name))) + "Add challenge failed: Challenge name must be <= {} characters." + .format(MAX_CHANNEL_NAME_LENGTH - len(ctf.name) - 1)) # Check for invalid characters if not is_valid_name(name): @@ -220,8 +240,8 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): slack_wrapper.set_purpose(challenge_channel_id, purpose, is_private=True) # Invite everyone in the auto-invite list - for user_id in handler_factory.botserver.get_config_option("auto_invite"): - slack_wrapper.invite_user(user_id, challenge_channel_id, is_private=True) + for invite_user_id in handler_factory.botserver.get_config_option("auto_invite"): + slack_wrapper.invite_user(invite_user_id, challenge_channel_id, is_private=True) # New Challenge challenge = Challenge(ctf.channel_id, challenge_channel_id, name, category) @@ -243,7 +263,7 @@ class RemoveChallengeCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the RemoveChallenge command.""" challenge_name = args[0].lower() if args else None @@ -281,7 +301,7 @@ class UpdateStatusCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the UpdateStatus command.""" timestamp = args["timestamp"] @@ -291,7 +311,11 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): if result["ok"] and result["messages"]: if "==========" in result["messages"][0]["text"]: - status, _ = StatusCommand().build_status_message(slack_wrapper, None, channel_id, user_id, user_is_admin, True) + # check if status contained a category and only update for category then + category_match = re.search(r"=== .*? \[(.*?)\] ====", result["messages"][0]["text"], re.S) + category = category_match.group(1) if category_match else "" + + status, _ = StatusCommand().build_status_message(slack_wrapper, None, channel_id, user_id, user_is_admin, True, category) slack_wrapper.update_message(channel_id, timestamp, status) @@ -302,7 +326,7 @@ class UpdateShortStatusCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the UpdateStatus command.""" timestamp = args["timestamp"] @@ -323,7 +347,7 @@ class StatusCommand(Command): """ @classmethod - def build_short_status(cls, ctf_list, check_for_finish): + def build_short_status(cls, ctf_list): """Build short status list.""" response = "" @@ -331,8 +355,11 @@ def build_short_status(cls, ctf_list, check_for_finish): # Build short status list solved = [c for c in ctf.challenges if c.is_solved] - response += "*#{} : _{}_ {} [{} solved / {} total]*\n".format( - ctf.name, ctf.long_name, "(finished)" if ctf.finished else "", len(solved), len(ctf.challenges)) + def get_finish_info(ctf): + return "(finished {} ago)".format(cls.get_finished_string(ctf)) if ctf.finished_on else "(finished)" + + response += "*#{} : _{}_ [{} solved / {} total] {}*\n".format( + ctf.name, ctf.long_name, len(solved), len(ctf.challenges), get_finish_info(ctf) if ctf.finished else "") response = response.strip() @@ -342,23 +369,47 @@ def build_short_status(cls, ctf_list, check_for_finish): return response @classmethod - def build_verbose_status(cls, slack_wrapper, ctf_list, check_for_finish): + def get_finished_string(cls, ctf): + timespan = time.time()-ctf.finished_on + + if timespan < 3600: + return "less than an hour" + + # https://stackoverflow.com/a/11157649 + attrs = ['years', 'months', 'days', 'hours'] + def human_readable(delta): return ['%d %s' % (getattr(delta, attr), getattr(delta, attr) > 1 and attr or attr[:-1]) + for attr in attrs if getattr(delta, attr)] + + return ', '.join(human_readable(relativedelta(seconds=timespan))) + + @classmethod + def build_verbose_status(cls, slack_wrapper, ctf_list, check_for_finish, category): """Build verbose status list.""" - members = slack_wrapper.get_members() + member_list = slack_wrapper.get_members() # Bail out, if we couldn't read member list - if not "members" in members: + if not "members" in member_list: raise InvalidCommand("Status failed. Could not refresh member list...") - members = {m["id"]: m["profile"]["display_name"] - for m in members['members'] if m.get("presence") == "active"} + members = {m["id"]: get_display_name_from_user(m) + for m in member_list['members']} response = "" for ctf in ctf_list: # Build long status list - response += "*============= #{} {} =============*\n".format(ctf.name, "(finished)" if ctf.finished else "") - solved = sorted([c for c in ctf.challenges if c.is_solved], key=lambda x: x.solve_date) - unsolved = [c for c in ctf.challenges if not c.is_solved] + solved = sorted([c for c in ctf.challenges if c.is_solved and ( + not category or c.category == category)], key=lambda x: x.solve_date) + unsolved = [c for c in ctf.challenges if not c.is_solved and (not category or c.category == category)] + + # Don't show ctfs not having a category challenge if filter is active + if category and not solved and not unsolved: + continue + + response += "*============= #{} {} {}=============*\n".format( + ctf.name, "(finished)" if ctf.finished else "", "[{}] ".format(category) if category else "") + + if ctf.finished and ctf.finished_on: + response += "* > Finished {} ago*\n".format(cls.get_finished_string(ctf)) # Check if the CTF has any challenges if check_for_finish and ctf.finished and not solved: @@ -399,7 +450,7 @@ def build_verbose_status(cls, slack_wrapper, ctf_list, check_for_finish): return response @classmethod - def build_status_message(cls, slack_wrapper, args, channel_id, user_id, user_is_admin, verbose=True): + def build_status_message(cls, slack_wrapper, args, channel_id, user_id, user_is_admin, verbose=True, category=""): """Gathers the ctf information and builds the status response.""" ctfs = pickle.load(open(ChallengeHandler.DB, "rb")) @@ -415,20 +466,25 @@ def build_status_message(cls, slack_wrapper, args, channel_id, user_id, user_is_ check_for_finish = True if verbose: - response = cls.build_verbose_status(slack_wrapper, ctf_list, check_for_finish) + response = cls.build_verbose_status(slack_wrapper, ctf_list, check_for_finish, category) else: - response = cls.build_short_status(ctf_list, check_for_finish) + response = cls.build_short_status(ctf_list) return response, verbose @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Status command.""" - verbose = args[0] == "-v" if len(args) > 0 else False + verbose = args[0] == "-v" if args else False - response, verbose = cls.build_status_message(slack_wrapper, args, channel_id, user_id, user_is_admin, verbose) + if verbose: + category = args[1] if len(args) > 1 else "" + else: + category = args[0] if args else "" + + response, verbose = cls.build_status_message( + slack_wrapper, args, channel_id, user_id, user_is_admin, verbose, category) - #slack_wrapper.post_message(channel_id, response) if verbose: slack_wrapper.post_message_with_react(channel_id, response, "arrows_clockwise") else: @@ -441,7 +497,7 @@ class WorkonCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Workon command.""" challenge_name = args[0].lower() if args else None @@ -487,7 +543,7 @@ class SolveCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Solve command.""" challenge = "" @@ -575,7 +631,7 @@ class UnsolveCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Unsolve command.""" challenge = "" @@ -616,20 +672,20 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): slack_wrapper.post_message(ctf.channel_id, message) return - else: - raise InvalidCommand("This challenge isn't marked as solve.") + + raise InvalidCommand("This challenge isn't marked as solve.") class ArchiveCTFCommand(Command): """Archive the challenge channels for a given CTF.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the ArchiveCTF command.""" no_post = args[0].lower() if args else None ctf = get_ctf_by_channel_id(ChallengeHandler.DB, channel_id) - if not ctf: + if not ctf or ctf.channel_id != channel_id: raise InvalidCommand("Archive CTF failed: You are not in a CTF channel.") # Post solves if git support is enabled @@ -657,6 +713,9 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): slack_wrapper.archive_private_channel(challenge.channel_id) remove_challenge_by_channel_id(ChallengeHandler.DB, challenge.channel_id, ctf.channel_id) + # Remove possible configured reminders for this ctf + cleanup_reminders(slack_wrapper, handler_factory, ctf) + # Stop tracking the main CTF channel slack_wrapper.set_purpose(channel_id, "") remove_ctf_by_channel_id(ChallengeHandler.DB, ctf.channel_id) @@ -664,6 +723,9 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): # Show confirmation message slack_wrapper.post_message(channel_id, message) + # Archive the main CTF channel also to cleanup + slack_wrapper.archive_public_channel(channel_id) + class EndCTFCommand(Command): """ @@ -672,21 +734,44 @@ class EndCTFCommand(Command): """ @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def handle_archive_reminder(cls, slack_wrapper, ctf): + """Sets a reminder for admins to archive this ctf in a set time.""" + reminder_offset = handler_factory.botserver.get_config_option("archive_ctf_reminder_offset") + + if not reminder_offset: + return + + admin_users = handler_factory.botserver.get_config_option("admin_users") + + if not admin_users: + return + + msg = "CTF {} - {} (#{}) should be archived.".format(ctf.name, ctf.long_name, ctf.name) + + for admin in admin_users: + slack_wrapper.add_reminder_hours(admin, msg, reminder_offset) + + @classmethod + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the EndCTF command.""" ctf = get_ctf_by_channel_id(ChallengeHandler.DB, channel_id) if not ctf: raise InvalidCommand("End CTF failed: You are not in a CTF channel.") + if ctf.finished: + raise InvalidCommand("CTF is already marked as finished...") + def update_func(ctf): ctf.finished = True + ctf.finished_on = int(time.time()) # Update database ctf = update_ctf(ChallengeHandler.DB, ctf.channel_id, update_func) if ctf: ChallengeHandler.update_ctf_purpose(slack_wrapper, ctf) + cls.handle_archive_reminder(slack_wrapper, ctf) slack_wrapper.post_message(channel_id, "CTF *{}* finished...".format(ctf.name)) @@ -694,7 +779,7 @@ class ReloadCommand(Command): """Reload the ctf information from slack to reflect updates of channel purposes.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Reload command.""" slack_wrapper.post_message(channel_id, "Updating CTFs and challenges...") @@ -706,23 +791,28 @@ class AddCredsCommand(Command): """Add credential informations for current ctf.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the AddCreds command.""" cur_ctf = get_ctf_by_channel_id(ChallengeHandler.DB, channel_id) if not cur_ctf: - raise InvalidCommand("Add Creds faile:. You are not in a CTF channel.") + raise InvalidCommand("Add Creds failed:. You are not in a CTF channel.") def update_func(ctf): ctf.cred_user = args[0] ctf.cred_pw = args[1] - ctf.cred_url = args[2] if len(args) > 2 else "" # Update database ctf = update_ctf(ChallengeHandler.DB, cur_ctf.channel_id, update_func) if ctf: ChallengeHandler.update_ctf_purpose(slack_wrapper, ctf) + + ctf_cred_url = args[2] if len(args) > 2 else "" + + if ctf_cred_url: + slack_wrapper.set_topic(channel_id, ctf_cred_url) + message = "Credentials for CTF *{}* updated...".format(ctf.name) slack_wrapper.post_message(channel_id, message) @@ -731,7 +821,7 @@ class ShowCredsCommand(Command): """Shows credential informations for current ctf.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the ShowCreds command.""" cur_ctf = get_ctf_by_channel_id(ChallengeHandler.DB, channel_id) @@ -741,17 +831,13 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): if cur_ctf.cred_user and cur_ctf.cred_pw: message = "Credentials for CTF *{}*\n".format(cur_ctf.name) message += "```" - - if cur_ctf.cred_url: - message += "URL : {}\n".format(cur_ctf.cred_url) - message += "Username : {}\n".format(cur_ctf.cred_user) message += "Password : {}\n".format(cur_ctf.cred_pw) message += "```" else: message = "No credentials provided for CTF *{}*.".format(cur_ctf.name) - slack_wrapper.post_message(channel_id, message, "") + slack_wrapper.post_message(channel_id, message, "", parse=None) class ChallengeHandler(BaseHandler): @@ -774,18 +860,17 @@ class ChallengeHandler(BaseHandler): DB = "databases/challenge_handler.bin" CTF_PURPOSE = { - "ota_bot": "DO_NOT_DELETE_THIS", + "ota_bot": "OTABOT", "name": "", "type": "CTF", "cred_user": "", "cred_pw": "", - "cred_url": "", "long_name": "", "finished": False } CHALL_PURPOSE = { - "ota_bot": "DO_NOT_DELETE_THIS", + "ota_bot": "OTABOT", "ctf_id": "", "name": "", "solved": "", @@ -798,22 +883,27 @@ def __init__(self): "addctf": CommandDesc(AddCTFCommand, "Adds a new ctf", ["ctf_name", "long_name"], None), "addchallenge": CommandDesc(AddChallengeCommand, "Adds a new challenge for current ctf", ["challenge_name", "challenge_category"], None), "workon": CommandDesc(WorkonCommand, "Show that you're working on a challenge", None, ["challenge_name"]), - "status": CommandDesc(StatusCommand, "Show the status for all ongoing ctf's", None, None), + "status": CommandDesc(StatusCommand, "Show the status for all ongoing ctf's", None, ["category"]), "solve": CommandDesc(SolveCommand, "Mark a challenge as solved", None, ["challenge_name", "support_member"]), "renamechallenge": CommandDesc(RenameChallengeCommand, "Renames a challenge", ["old_challenge_name", "new_challenge_name"], None), "renamectf": CommandDesc(RenameCTFCommand, "Renames a ctf", ["old_ctf_name", "new_ctf_name"], None), - "reload": CommandDesc(ReloadCommand, "Reload ctf information from slack", None, None), + "reload": CommandDesc(ReloadCommand, "Reload ctf information from slack", None, None, True), "archivectf": CommandDesc(ArchiveCTFCommand, "Archive the challenges of a ctf", None, ["nopost"], True), "endctf": CommandDesc(EndCTFCommand, "Mark a ctf as ended, but not archive it directly", None, None, True), "addcreds": CommandDesc(AddCredsCommand, "Add credentials for current ctf", ["ctf_user", "ctf_pw"], ["ctf_url"]), "showcreds": CommandDesc(ShowCredsCommand, "Show credentials for current ctf", None, None), "unsolve": CommandDesc(UnsolveCommand, "Remove solve of a challenge", None, ["challenge_name"]), - "removechallenge": CommandDesc(RemoveChallengeCommand, "Remove challenge", None, ["challenge_name"], True) + "removechallenge": CommandDesc(RemoveChallengeCommand, "Remove challenge", None, ["challenge_name"], True), + "roll": CommandDesc(RollCommand, "Roll the dice", None, None) } self.reactions = { "arrows_clockwise": ReactionDesc(UpdateStatusCommand), "arrows_counterclockwise": ReactionDesc(UpdateShortStatusCommand) } + self.aliases = { + "finishctf": "endctf", + "addchall": "addchallenge", + } @staticmethod def update_ctf_purpose(slack_wrapper, ctf): @@ -821,14 +911,14 @@ def update_ctf_purpose(slack_wrapper, ctf): Update the purpose for the ctf channel. """ purpose = dict(ChallengeHandler.CTF_PURPOSE) - purpose["ota_bot"] = "DO_NOT_DELETE_THIS" + purpose["ota_bot"] = "OTABOT" purpose["name"] = ctf.name purpose["type"] = "CTF" purpose["cred_user"] = ctf.cred_user purpose["cred_pw"] = ctf.cred_pw - purpose["cred_url"] = ctf.cred_url purpose["long_name"] = ctf.long_name purpose["finished"] = ctf.finished + purpose["finished_on"] = ctf.finished_on slack_wrapper.set_purpose(ctf.channel_id, purpose) @@ -843,41 +933,45 @@ def update_database_from_slack(slack_wrapper): # Find active CTF channels for channel in [*privchans, *pubchans]: - purpose = load_json(channel['purpose']['value']) + try: + purpose = load_json(channel['purpose']['value']) - if not channel['is_archived'] and purpose and "ota_bot" in purpose and purpose["type"] == "CTF": - ctf = CTF(channel['id'], purpose['name'], purpose['long_name']) + if not channel['is_archived'] and purpose and "ota_bot" in purpose and purpose["type"] == "CTF": + ctf = CTF(channel['id'], purpose['name'], purpose['long_name']) - ctf.cred_user = purpose.get("cred_user", "") - ctf.cred_pw = purpose.get("cred_pw", "") - ctf.cred_url = purpose.get("cred_url", "") - ctf.finished = purpose.get("finished", False) + ctf.cred_user = purpose.get("cred_user", "") + ctf.cred_pw = purpose.get("cred_pw", "") + ctf.finished = purpose.get("finished", False) + ctf.finished_on = purpose.get("finished_on", 0) - database[ctf.channel_id] = ctf + database[ctf.channel_id] = ctf + except: + pass # Find active challenge channels response = slack_wrapper.get_private_channels() for channel in response['groups']: - purpose = load_json(channel['purpose']['value']) - - if not channel['is_archived'] and \ - purpose and "ota_bot" in purpose and \ - purpose["type"] == "CHALLENGE": - challenge = Challenge(purpose["ctf_id"], channel['id'], purpose["name"], purpose.get("category")) - ctf_channel_id = purpose["ctf_id"] - solvers = purpose["solved"] - ctf = database.get(ctf_channel_id) - - # Mark solved challenges - if solvers: - challenge.mark_as_solved(solvers, purpose.get("solve_date")) - - if ctf: - for member_id in channel['members']: - if member_id != slack_wrapper.user_id: - challenge.add_player(Player(member_id)) - - ctf.add_challenge(challenge) + try: + purpose = load_json(channel['purpose']['value']) + + if not channel['is_archived'] and purpose and "ota_bot" in purpose and purpose["type"] == "CHALLENGE": + challenge = Challenge(purpose["ctf_id"], channel['id'], purpose["name"], purpose.get("category")) + ctf_channel_id = purpose["ctf_id"] + solvers = purpose["solved"] + ctf = database.get(ctf_channel_id) + + # Mark solved challenges + if solvers: + challenge.mark_as_solved(solvers, purpose.get("solve_date")) + + if ctf: + for member_id in channel['members']: + if member_id != slack_wrapper.user_id: + challenge.add_player(Player(member_id)) + + ctf.add_challenge(challenge) + except: + pass # Create the database accordingly pickle.dump(database, open(ChallengeHandler.DB, "wb+")) diff --git a/handlers/handler_factory.py b/handlers/handler_factory.py index 4fbc831..aef9ed2 100644 --- a/handlers/handler_factory.py +++ b/handlers/handler_factory.py @@ -6,23 +6,24 @@ resolve it and execute it """ import shlex + from unidecode import unidecode -from util.loghandler import * -from bottypes.invalid_command import * +from bottypes.invalid_command import InvalidCommand +from util.loghandler import log handlers = {} botserver = None def register(handler_name, handler): - log.info("Registering new handler: {} ({})".format(handler_name, handler.__class__.__name__)) + log.info("Registering new handler: %s (%s)", handler_name, handler.__class__.__name__) handlers[handler_name] = handler handler.handler_name = handler_name -def initialize(slack_wrapper, bot_id, _botserver): +def initialize(slack_wrapper, _botserver): """ Initializes all handler with common information. @@ -34,8 +35,8 @@ def initialize(slack_wrapper, bot_id, _botserver): handlers[handler].init(slack_wrapper) -def process(slack_wrapper, botserver, message, channel_id, user_id): - log.debug("Processing message: {} from {} ({})".format(message, channel_id, user_id)) +def process(slack_wrapper, botserver, message, timestamp, channel_id, user_id): + log.debug("Processing message: %s from %s (%s)", message, channel_id, user_id) try: # Parse command and check for malformed input command_line = unidecode(message) @@ -47,17 +48,15 @@ def process(slack_wrapper, botserver, message, channel_id, user_id): args = list(lexer) except: message = "Command failed : Malformed input." - slack_wrapper.post_message(channel_id, message) + slack_wrapper.post_message(channel_id, message, timestamp) return - process_command(slack_wrapper, message, args, channel_id, user_id) + process_command(slack_wrapper, message, args, timestamp, channel_id, user_id) def process_reaction(slack_wrapper, reaction, timestamp, channel_id, user_id): try: - log.debug("Processing reaction: {} from {} ({})".format(reaction, channel_id, timestamp)) - - processed = False + log.debug("Processing reaction: %s from %s (%s)", reaction, channel_id, timestamp) admin_users = botserver.get_config_option("admin_users") user_is_admin = admin_users and user_id in admin_users @@ -65,15 +64,14 @@ def process_reaction(slack_wrapper, reaction, timestamp, channel_id, user_id): for handler_name, handler in handlers.items(): if handler.can_handle_reaction(reaction): handler.process_reaction(slack_wrapper, reaction, channel_id, timestamp, user_id, user_is_admin) - processed = True except InvalidCommand as e: - slack_wrapper.post_message(channel_id, e.message) + slack_wrapper.post_message(channel_id, e, timestamp) except Exception: log.exception("An error has occured while processing a command") -def process_command(slack_wrapper, message, args, channel_id, user_id, admin_override=False): +def process_command(slack_wrapper, message, args, timestamp, channel_id, user_id, admin_override=False): try: handler_name = args[0].lower() @@ -98,7 +96,7 @@ def process_command(slack_wrapper, message, args, channel_id, user_id, admin_ove else: # Send command to specified handler command = args[1].lower() if handler.can_handle(command, user_is_admin): - handler.process(slack_wrapper, command, args[2:], channel_id, user_id, user_is_admin) + handler.process(slack_wrapper, command, args[2:], timestamp, channel_id, user_id, user_is_admin) processed = True else: # Pass the command to every available handler @@ -111,12 +109,12 @@ def process_command(slack_wrapper, message, args, channel_id, user_id, admin_ove elif handler.can_handle(command, user_is_admin): # Send command to handler handler.process(slack_wrapper, command, - args[1:], channel_id, user_id, user_is_admin) + args[1:], timestamp, channel_id, user_id, user_is_admin) processed = True if not processed: # Send error message message = "Unknown handler or command : `{}`".format(message) - slack_wrapper.post_message(channel_id, message) + slack_wrapper.post_message(channel_id, message, timestamp) if usage_msg: # Send usage message send_help_as_dm = botserver.get_config_option("send_help_as_dm") == "1" @@ -124,7 +122,7 @@ def process_command(slack_wrapper, message, args, channel_id, user_id, admin_ove slack_wrapper.post_message(target_id, usage_msg) except InvalidCommand as e: - slack_wrapper.post_message(channel_id, e.message) + slack_wrapper.post_message(channel_id, e, timestamp) except Exception: log.exception("An error has occured while processing a command") diff --git a/handlers/linksave_handler.py b/handlers/linksave_handler.py new file mode 100644 index 0000000..affc0ea --- /dev/null +++ b/handlers/linksave_handler.py @@ -0,0 +1,94 @@ +import re + +import requests + +from bottypes.command import Command +from bottypes.command_descriptor import CommandDesc +from bottypes.invalid_command import InvalidCommand +from handlers import handler_factory +from handlers.base_handler import BaseHandler +from util.loghandler import log +from util.savelinkhelper import LINKSAVE_CONFIG, LINKSAVE_SUPPORT, unfurl + +CATEGORIES = ["web", "pwn", "re", "crypto", "misc"] + + +class SaveLinkCommand(Command): + """Save a url from a slack message according to a specific category.""" + + @classmethod + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): + """Execute the save command.""" + + if not LINKSAVE_SUPPORT: + raise InvalidCommand("Save Link failed: Link saver not configured.") + if args[0] not in CATEGORIES: + raise InvalidCommand("Save Link failed: Invalid Category.") + if LINKSAVE_CONFIG["allowed_users"] and user_id not in LINKSAVE_CONFIG["allowed_users"]: + raise InvalidCommand("Save Link failed: User not allowed to save links") + + message = slack_wrapper.get_message(channel_id, timestamp)["messages"][0]["text"] + profile_details = slack_wrapper.get_member(user_id)["user"]["profile"] + # http://www.noah.org/wiki/RegEx_Python#URL_regex_pattern + url_regex = "http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+" + url = re.search(url_regex, message) + + if not url: + slack_wrapper.post_message(channel_id, "Save Link failed: Unable to extract URL", timestamp) + return + + try: + url_data = unfurl(url.group()) + except requests.exceptions.Timeout as e: + slack_wrapper.post_message(channel_id, "Save Link failed: Request timed out", timestamp) + log.error(e) + return + + data = { + "options[staticman-token]": LINKSAVE_CONFIG["staticman-token"], + "fields[title]": url_data["title"], + "fields[link]": url.group(), + "fields[excerpt]": url_data["desc"], + "fields[category]": args[0], + "fields[header][overlay_image]": url_data["img"], + "fields[user]": profile_details["display_name"] or profile_details["real_name"] + } + resp = requests.post( + "https://mystaticmanapp.herokuapp.com/v2/entry/{git_repo}/{git_branch}/links".format_map( + LINKSAVE_CONFIG + ), + data=data + ).json() + + if resp["success"]: + slack_wrapper.post_message(channel_id, "Link saved successfully", timestamp) + else: + slack_wrapper.post_message(channel_id, "Error saving the link", timestamp) + log.error(resp) + + +class ShowLinkSaveURLCommand(Command): + """Show the url for the link saver repo.""" + + @classmethod + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): + url = LINKSAVE_CONFIG["repo_link_url"] + + if not url: + raise InvalidCommand("Link saver: URL for link repository not configured.") + + slack_wrapper.post_message(channel_id, "Link saver: {}".format(url)) + + +class LinkSaveHandler(BaseHandler): + """Handler for saving links.""" + + def __init__(self): + if LINKSAVE_SUPPORT: + self.commands = { + "link": CommandDesc(SaveLinkCommand, "Save a link in one of the categories: {}".format(", ".join(CATEGORIES)), ["category"], None), + "showlinkurl": CommandDesc(ShowLinkSaveURLCommand, "Show the url for linksaver repo", None, None) + } + + +handler_factory.register("linksave", LinkSaveHandler()) diff --git a/handlers/syscalls_handler.py b/handlers/syscalls_handler.py index 206be32..70cd502 100644 --- a/handlers/syscalls_handler.py +++ b/handlers/syscalls_handler.py @@ -1,23 +1,22 @@ -from bottypes.command import * -from bottypes.command_descriptor import * -from bottypes.invalid_command import * -import handlers.handler_factory as handler_factory -from handlers.base_handler import * -from addons.syscalls.syscallinfo import * +from addons.syscalls.syscallinfo import SyscallInfo +from bottypes.command import Command +from bottypes.command_descriptor import CommandDesc +from handlers import handler_factory +from handlers.base_handler import BaseHandler class ShowAvailableArchCommand(Command): """Shows the available architecture tables for syscalls.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the ShowAvailableArch command.""" - archList = SyscallsHandler.syscallInfo.getAvailableArchitectures() + arch_list = SyscallsHandler.syscallInfo.get_available_architectures() msg = "\n" msg += "Available architectures:```" - for arch in archList: + for arch in arch_list: msg += "{}\t".format(arch) msg = msg.strip() + "```" @@ -39,20 +38,20 @@ def parse_syscall_info(cls, syscall_entries): return msg.strip() + "```" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the ShowSyscall command.""" - archObj = SyscallsHandler.syscallInfo.getArch(args[0].lower()) + arch = SyscallsHandler.syscallInfo.get_arch(args[0].lower()) - if archObj: + if arch: entry = None # convenience : Try to search syscall by id or by name, depending on what # the user has specified try: - syscallID = int(args[1]) - entry = archObj.getEntryByID(syscallID) + syscall_id = int(args[1]) + entry = arch.get_entry_by_id(syscall_id) except: - entry = archObj.getEntryByName(args[1].lower()) + entry = arch.get_entry_by_name(args[1].lower()) if entry: slack_wrapper.post_message(channel_id, cls.parse_syscall_info(entry)) diff --git a/handlers/wolfram_handler.py b/handlers/wolfram_handler.py index 59542dd..580fa20 100644 --- a/handlers/wolfram_handler.py +++ b/handlers/wolfram_handler.py @@ -1,25 +1,20 @@ -import re - import wolframalpha -from bottypes.command import * -from bottypes.command_descriptor import * -from bottypes.invalid_command import * -import handlers.handler_factory as handler_factory -from handlers.base_handler import * -from addons.syscalls.syscallinfo import * -from util.util import * +from bottypes.command import Command +from bottypes.command_descriptor import CommandDesc +from handlers import handler_factory +from handlers.base_handler import BaseHandler class AskCommand(Command): """Asks wolfram alpha a question and shows the answer.""" @classmethod - def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): + def execute(cls, slack_wrapper, args, timestamp, channel_id, user_id, user_is_admin): """Execute the Ask command.""" app_id = handler_factory.botserver.get_config_option("wolfram_app_id") - verbose = (args[0] if len(args) > 0 else "") == "-v" + verbose = (args[0] if args else "") == "-v" if app_id: try: @@ -40,13 +35,16 @@ def execute(cls, slack_wrapper, args, channel_id, user_id, user_is_admin): answer += "```\n" answer += subpod.plaintext[:512] + "\n" answer += "```\n" - if (len(subpod.plaintext) > 512): + if len(subpod.plaintext) > 512: answer += "*shortened*" else: answer = next(res.results, None) if answer: - answer = answer.text + if len(answer.text) > 2048: + answer = answer.text[:2048] + "*shortened*" + else: + answer = answer.text slack_wrapper.post_message(channel_id, answer) except Exception as ex: diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..627d954 --- /dev/null +++ b/pylintrc @@ -0,0 +1,477 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Specify a configuration file. +#rcfile= + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" + +disable=missing-docstring, + too-few-public-methods, + unused-argument, + too-many-arguments, + too-many-instance-attributes + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio).You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=yes + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=optparse.Values,sys.exit + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,io,builtins + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[BASIC] + +# Naming style matching correct argument names +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style +#argument-rgx= + +# Naming style matching correct attribute names +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style +#class-attribute-rgx= + +# Naming style matching correct class names +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming-style +#class-rgx= + +# Naming style matching correct constant names +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma +good-names=i, + j, + k, + ex, + Run, + e, + f, + _ + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# Naming style matching correct inline iteration names +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style +#inlinevar-rgx= + +# Naming style matching correct method names +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style +#method-rgx= + +# Naming style matching correct module names +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style +#variable-rgx= + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=150 + +# Maximum number of lines in a module +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of statements in function / method body +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/requirements.txt b/requirements.txt index 8bb14f6..e7670bd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,23 @@ -unidecode -slackclient -wolframalpha -dulwich -pylint +astroid==2.0.4 +beautifulsoup4==4.7.1 +certifi==2018.10.15 +chardet==3.0.4 +dulwich==0.19.6 +idna==2.7 +inflect==1.0.1 +isort==4.3.4 +jaraco.itertools==2.5.2 +lazy-object-proxy==1.3.1 +mccabe==0.6.1 +more-itertools==4.3.0 +pylint==2.1.1 +requests==2.20.0 +six==1.11.0 +slackclient==1.3.0 +Unidecode==1.0.22 +urllib3==1.24 +websocket-client==0.53.0 +wolframalpha==3.0.1 +wrapt==1.10.11 +xmltodict==0.11.0 +python-dateutil==2.8.0 \ No newline at end of file diff --git a/run.py b/run.py index 0b14a9a..d6723a3 100644 --- a/run.py +++ b/run.py @@ -1,20 +1,16 @@ #!/usr/bin/env python3 -from util.loghandler import * -from server.botserver import * -from server.consolethread import * +from util.loghandler import log +from server.botserver import BotServer if __name__ == "__main__": log.info("Initializing threads...") - server = BotServer() - console = ConsoleThread(server) + server = BotServer() - console.start() server.start() # Server should be up and running. Quit when server shuts down server.join() - console.quit() log.info("Server has shut down. Quit") diff --git a/runtests.py b/runtests.py new file mode 100644 index 0000000..3af5176 --- /dev/null +++ b/runtests.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +from unittest import TestCase +from tests.slackwrapper_mock import SlackWrapperMock +import unittest +from util.loghandler import log, logging +from server.botserver import BotServer +from bottypes.invalid_command import InvalidCommand + + +class BotBaseTest(TestCase): + def setUp(self): + self.botserver = BotServer() + + self.botserver.config = { + "bot_name": "unittest_bot", + "api_key": "unittest_apikey", + "send_help_as_dm": "1", + "admin_users": [ + "admin_user" + ], + "auto_invite": [], + "wolfram_app_id": "wolfram_dummyapi" + } + + self.botserver.slack_wrapper = SlackWrapperMock("testapikey") + self.botserver.init_bot_data() + + # replace set_config_option to avoid overwriting original bot configuration. + self.botserver.set_config_option = self.set_config_option_mock + + def set_config_option_mock(self, option, value): + if option in self.botserver.config: + self.botserver.config[option] = value + else: + raise InvalidCommand("The specified configuration option doesn't exist: {}".format(option)) + + def create_slack_wrapper_mock(self, api_key): + return SlackWrapperMock(api_key) + + def exec_command(self, msg, exec_user="normal_user"): + """Simulate execution of the specified message as the specified user in the test environment.""" + testmsg = [{'type': 'message', 'user': exec_user, 'text': msg, 'client_msg_id': '738e4beb-d50e-42a4-a60e-3fafd4bd71da', + 'team': 'UNITTESTTEAMID', 'channel': 'UNITTESTCHANNELID', 'event_ts': '1549715670.002000', 'ts': '1549715670.002000'}] + self.botserver.handle_message(testmsg) + + def exec_reaction(self, reaction, exec_user="normal_user"): + """Simulate execution of the specified reaction as the specified user in the test environment.""" + testmsg = [{'type': 'reaction_added', 'user': exec_user, 'item': {'type': 'message', 'channel': 'UNITTESTCHANNELID', 'ts': '1549117537.000500'}, + 'reaction': reaction, 'item_user': 'UNITTESTUSERID', 'event_ts': '1549715822.000800', 'ts': '1549715822.000800'}] + + self.botserver.handle_message(testmsg) + + def check_for_response_available(self): + return len(self.botserver.slack_wrapper.message_list) > 0 + + def check_for_response(self, expected_result): + """ Check if the simulated slack responses contain an expected result. """ + for msg in self.botserver.slack_wrapper.message_list: + if expected_result in msg.message: + return True + + return False + + +class TestSyscallsHandler(BotBaseTest): + def test_available(self): + self.exec_command("!syscalls available") + self.assertTrue(self.check_for_response("Available architectures"), + msg="Available architectures didn't respond correct.") + + def test_show_x86_execve(self): + self.exec_command("!syscalls show x86 execve") + self.assertTrue(self.check_for_response("execve"), msg="Didn't receive execve syscall from bot") + self.assertTrue(self.check_for_response("0x0b"), + msg="Didn't receive correct execve syscall no for x86 from bot") + + def test_show_amd64_execve(self): + self.exec_command("!syscalls show x64 execve") + self.assertTrue(self.check_for_response("execve"), msg="Didn't receive execve syscall from bot") + self.assertTrue(self.check_for_response("0x3b"), + msg="Didn't receive correct execve syscall no for x64 from bot") + + def test_syscall_not_found(self): + self.exec_command("!syscalls show x64 notexist") + self.assertTrue(self.check_for_response("Specified syscall not found"), + msg="Bot didn't respond with expected response on non-existing syscall") + + +class TestBotHandler(BotBaseTest): + def test_ping(self): + self.exec_command("!bot ping") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertTrue(self.check_for_response("Pong!"), msg="Ping command didn't reply with pong.") + + def test_intro(self): + self.exec_command("!bot intro") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response( + "Unknown handler or command"), msg="Intro didn't execute properly.") + + def test_version(self): + self.exec_command("!bot version") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response( + "Unknown handler or command"), msg="Version didn't execute properly.") + + +class TestAdminHandler(BotBaseTest): + def test_show_admins(self): + self.exec_command("!admin show_admins", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="ShowAdmins didn't execute properly.") + self.assertTrue(self.check_for_response("Administrators"), + msg="ShowAdmins didn't reply with expected result.") + + def test_add_admin(self): + self.exec_command("!admin add_admin test", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response( + "Unknown handler or command"), msg="AddAdmin didn't execute properly.") + + def test_remove_admin(self): + self.exec_command("!admin remove_admin test", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RemoveAdmin didn't execute properly.") + + def test_as(self): + self.exec_command("!admin as @unittest_user1 addchallenge test pwn", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response( + "Unknown handler or command"), msg="As didn't execute properly.") + + +class TestChallengeHandler(BotBaseTest): + def test_addctf_name_too_long(self): + self.exec_command("!ctf addctf unittest_ctf unittest_ctf") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertTrue(self.check_for_response("CTF name must be <= 10 characters."), + msg="Challenge handler didn't respond with expected result for name_too_long.") + + def test_addctf_success(self): + self.exec_command("!ctf addctf test_ctf test_ctf") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertTrue(self.check_for_response("Created channel #test_ctf"), + msg="Challenge handler failed on creating ctf channel.") + + def test_addchallenge(self): + self.exec_command("!ctf addchall testchall pwn") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="AddChallenge command didn't execute properly.") + + def test_workon(self): + self.exec_command("!ctf workon test_challenge") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="Workon command didn't execute properly.") + + def test_status(self): + self.exec_command("!ctf status") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="Status command didn't execute properly.") + + def test_solve(self): + self.exec_command("!ctf solve testchall") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="Solve command didn't execute properly.") + + def test_solve_support(self): + self.exec_command("!ctf solve testchall supporter") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="Solve with supporter didn't execute properly.") + + def test_rename_challenge_name(self): + self.exec_command("!ctf renamechallenge testchall test1") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameChallenge didn't execute properly.") + + def test_renamectf(self): + self.exec_command("!ctf renamectf testctf test2") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameCTF didn't execute properly.") + + def test_reload(self): + self.exec_command("!ctf reload", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertTrue(self.check_for_response( + "Updating CTFs and challenges"), msg="Reload didn't execute properly.") + + def test_addcreds(self): + self.exec_command("!ctf addcreds user pw url") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameCTF didn't execute properly.") + + def test_endctf(self): + self.exec_command("!ctf endctf", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response( + "Unknown handler or command"), msg="EndCTF didn't execute properly.") + + def test_showcreds(self): + self.exec_command("!ctf showcreds") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameCTF didn't execute properly.") + + def test_unsolve(self): + self.exec_command("!ctf unsolve testchall") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameCTF didn't execute properly.") + + def test_removechallenge(self): + self.exec_command("!ctf removechallenge testchall", "admin_user") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameCTF didn't execute properly.") + + def test_roll(self): + self.exec_command("!ctf roll") + + self.assertTrue(self.check_for_response_available(), + msg="Bot didn't react on unit test. Check for possible exceptions.") + self.assertFalse(self.check_for_response("Unknown handler or command"), + msg="RenameCTF didn't execute properly.") + + +def run_tests(): + # borrowed from gef test suite (https://github.com/hugsy/gef/blob/dev/tests/runtests.py) + test_instances = [ + TestSyscallsHandler, + TestBotHandler, + TestAdminHandler, + TestChallengeHandler + ] + + # don't show bot debug messages for running tests + log.setLevel(logging.ERROR) + + runner = unittest.TextTestRunner(verbosity=3) + total_failures = 0 + + for test in [unittest.TestLoader().loadTestsFromTestCase(x) for x in test_instances]: + res = runner.run(test) + total_failures += len(res.errors) + len(res.failures) + + return total_failures + + +if __name__ == "__main__": + run_tests() diff --git a/server/botserver.py b/server/botserver.py index b31a026..b1d7da4 100644 --- a/server/botserver.py +++ b/server/botserver.py @@ -1,14 +1,16 @@ import json import threading import time + import websocket +from slackclient.server import SlackConnectionError -import handlers.handler_factory as handler_factory +from bottypes.invalid_console_command import InvalidConsoleCommand from handlers import * -from util.loghandler import * -from util.slack_wrapper import * -from bottypes.invalid_console_command import * -from slackclient.server import SlackConnectionError +from handlers import handler_factory +from util.loghandler import log +from util.slack_wrapper import SlackWrapper +from util.util import get_display_name, resolve_user_by_user_id class BotServer(threading.Thread): @@ -26,6 +28,7 @@ def __init__(self): self.bot_id = "" self.bot_at = "" self.slack_wrapper = None + self.read_websocket_delay = 1 def lock(self): """Acquire global lock for working with global (not thread-safe) data.""" @@ -62,7 +65,7 @@ def set_config_option(self, option, value): try: if option in self.config: self.config[option] = value - log.info("Updated configuration: {} => {}".format(option, value)) + log.info("Updated configuration: %s => %s", option, value) with open("./config.json", "w") as f: json.dump(self.config, f) @@ -74,25 +77,43 @@ def set_config_option(self, option, value): def parse_slack_message(self, message_list): """ The Slack Real Time Messaging API is an events firehose. - Return (message, channel, user) if the message is directed at the bot, - otherwise return (None, None, None). + Return (message, channel, ts, user) if the message is directed at the bot, + otherwise return (None, None, None, None). """ for msg in message_list: if msg.get("type") == "message" and "subtype" not in msg: if self.bot_at in msg.get("text", ""): # Return text after the @ mention, whitespace removed - return msg['text'].split(self.bot_at)[1].strip(), msg['channel'], msg['user'] + return msg['text'].split(self.bot_at)[1].strip(), msg['channel'], msg['thread_ts'] if 'thread_ts' in msg else msg['ts'], msg['user'] elif msg.get("text", "").startswith("!"): # Return text after the ! - return msg['text'][1:].strip(), msg['channel'], msg['user'] + return msg['text'][1:].strip(), msg['channel'], msg['thread_ts'] if 'thread_ts' in msg else msg['ts'], msg['user'] + # Check if user tampers with channel purpose + elif msg.get("type") == "message" and msg["subtype"] == "channel_purpose" and msg["user"] != self.bot_id: + source_user = get_display_name(resolve_user_by_user_id(self.slack_wrapper, msg['user'])) + warning = "*User '{}' changed the channel purpose ```{}```*".format(source_user, msg['text']) + self.slack_wrapper.post_message(msg['channel'], warning) + # Check for deletion of messages containing keywords + elif "subtype" in msg and msg["subtype"] == "message_deleted": + log_deletions = self.get_config_option("delete_watch_keywords") + + if log_deletions: + previous_msg = msg['previous_message']['text'] + delete_keywords = log_deletions.split(",") + + if any(keyword.strip() in previous_msg for keyword in delete_keywords): + user_name = self.slack_wrapper.get_member(msg['previous_message']['user']) + display_name = get_display_name(user_name) + self.slack_wrapper.post_message(msg['channel'], "*{}* deleted : `{}`".format(display_name, previous_msg)) - return None, None, None + + return None, None, None, None def parse_slack_reaction(self, message_list): for msg in message_list: msgtype = msg.get("type") - if msgtype == "reaction_removed" or msgtype == "reaction_added": + if msgtype in("reaction_removed", "reaction_added"): # Ignore reactions from the bot itself if msg["user"] == self.bot_id: continue @@ -102,7 +123,7 @@ def parse_slack_reaction(self, message_list): return None, None, None, None - def load_bot_data(self): + def init_bot_data(self): """ Fetches the bot user information such as bot_name, bot_id and bot_at. @@ -111,9 +132,26 @@ def load_bot_data(self): self.bot_name = self.slack_wrapper.username self.bot_id = self.slack_wrapper.user_id self.bot_at = "<@{}>".format(self.bot_id) - log.debug("Found bot user {} ({})".format(self.bot_name, self.bot_id)) + log.debug("Found bot user %s (%s)", self.bot_name, self.bot_id) self.running = True + # Might even pass the bot server for handlers? + log.info("Initializing handlers...") + handler_factory.initialize(self.slack_wrapper, self) + + def handle_message(self, message): + reaction, channel, time_stamp, reaction_user = self.parse_slack_reaction(message) + + if reaction and not self.bot_id == reaction_user: + log.debug("Received reaction : %s (%s)", reaction, channel) + handler_factory.process_reaction(self.slack_wrapper, reaction, time_stamp, channel, reaction_user) + + command, channel, time_stamp, user = self.parse_slack_message(message) + + if command and not self.bot_id == user: + log.debug("Received bot command : %s (%s)", command, channel) + handler_factory.process(self.slack_wrapper, self, command, time_stamp, channel, user) + def run(self): log.info("Starting server thread...") @@ -126,33 +164,17 @@ def run(self): if self.slack_wrapper.connected: log.info("Connection successful...") - self.load_bot_data() - READ_WEBSOCKET_DELAY = 1 # 1 second delay between reading from firehose - - # Might even pass the bot server for handlers? - log.info("Initializing handlers...") - handler_factory.initialize(self.slack_wrapper, self.bot_id, self) + self.init_bot_data() # Main loop log.info("Bot is running...") while self.running: message = self.slack_wrapper.read() if message: - reaction, channel, ts, reaction_user = self.parse_slack_reaction(message) + self.handle_message(message) - if reaction: - log.debug("Received reaction : {} ({})".format(reaction, channel)) - handler_factory.process_reaction( - self.slack_wrapper, reaction, ts, channel, reaction_user) + time.sleep(self.read_websocket_delay) - command, channel, user = self.parse_slack_message(message) - - if command: - log.debug("Received bot command : {} ({})".format(command, channel)) - handler_factory.process(self.slack_wrapper, self, - command, channel, user) - - time.sleep(READ_WEBSOCKET_DELAY) else: log.error("Connection failed. Invalid slack token or bot id?") self.running = False @@ -163,5 +185,8 @@ def run(self): # and remove the superfluous exception handling if auto_reconnect works. log.exception("Slack connection error. Trying manual reconnect in 5 seconds...") time.sleep(5) + except: + log.exception("Unhandled error. Try reconnect...") + time.sleep(5) log.info("Shutdown complete...") diff --git a/server/consolethread.py b/server/consolethread.py index b517b8a..b0ed019 100644 --- a/server/consolethread.py +++ b/server/consolethread.py @@ -1,12 +1,7 @@ -import json import threading -import time -from util.loghandler import * -from util.util import * -from bottypes.invalid_console_command import * -# This should also be refactored to a "ConsoleHandler" and work with Commands like the BotHandlers. -# Would make a much cleaner design, than using if/else +from bottypes.invalid_console_command import InvalidConsoleCommand +from util.loghandler import log class ConsoleThread(threading.Thread): @@ -19,7 +14,7 @@ def update_config(self, option, value): try: self.botserver.set_config_option(option, value) except InvalidConsoleCommand as e: - log.error(e.message) + log.error(e) def show_set_usage(self): print("\nUsage: set