├── tests ├── __init__.py ├── util_test.py ├── state_machine_test.py ├── bot_test.py └── user_test.py ├── frontend ├── __init__.py ├── static │ ├── css │ │ └── main.css │ └── js │ │ └── main.js ├── securitybot_frontend.py ├── securitybot_api.py └── templates │ └── index.html ├── securitybot ├── __init__.py ├── auth │ ├── __init__.py │ ├── auth.py │ └── duo.py ├── chat │ ├── __init__.py │ ├── chat.py │ └── slack.py ├── blacklist │ ├── __init__.py │ ├── blacklist.py │ └── sql_blacklist.py ├── tasker │ ├── __init__.py │ ├── sql_tasker.py │ └── tasker.py ├── ignored_alerts.py ├── sql.py ├── util.py ├── commands.py ├── state_machine.py ├── bot.py └── user.py ├── plugins ├── splunk │ ├── apps │ │ └── securitybot_alerts │ │ │ ├── default │ │ │ ├── restmap.conf │ │ │ ├── transforms.conf │ │ │ ├── app.conf │ │ │ ├── alert_actions.conf │ │ │ ├── data │ │ │ │ └── ui │ │ │ │ │ └── alerts │ │ │ │ │ └── send_bot_alerts.html │ │ │ └── macros.conf │ │ │ ├── metadata │ │ │ └── default.meta │ │ │ ├── appserver │ │ │ └── static │ │ │ │ └── securitybot.png │ │ │ └── bin │ │ │ ├── send_bot_alerts.sh │ │ │ ├── bot_lookup_launcher.py │ │ │ ├── bot_lookup.py │ │ │ └── send_bot_alerts.py │ └── README.md └── README.md ├── config ├── bot.yaml ├── commands.yaml └── messages.yaml ├── setup.cfg ├── .travis.yml ├── frontend.py ├── requirements.txt ├── .eslintrc.js ├── scripts ├── custom_alert.py └── query_db.py ├── .gitignore ├── main.py ├── util └── db_up.py ├── README.md └── LICENSE /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /securitybot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /securitybot/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /securitybot/chat/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /securitybot/blacklist/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /securitybot/tasker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/default/restmap.conf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/metadata/default.meta: -------------------------------------------------------------------------------- 1 | [] 2 | export = system 3 | -------------------------------------------------------------------------------- /config/bot.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | messages_path: config/messages.yaml 3 | commands_path: config/commands.yaml 4 | ... 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = D,E101,E401,E901,F,H201,W191,W402 3 | max-line-length = 100 4 | max-complexity = 50 5 | 6 | 7 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/appserver/static/securitybot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropbox/securitybot/HEAD/plugins/splunk/apps/securitybot_alerts/appserver/static/securitybot.png -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/default/transforms.conf: -------------------------------------------------------------------------------- 1 | [securitybot] 2 | external_cmd = bot_lookup_launcher.py hash comment performed authenticated 3 | fields_list = hash, comment, performed, authenticated 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | sudo: false 5 | install: pip install -r requirements.txt 6 | script: 7 | - flake8 --ignore F401 securitybot/ 8 | - PYTHONPATH=$(pwd) py.test -v tests/ 9 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/bin/send_bot_alerts.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Wrapper for send_bot_alerts.py to scrub PYTHONPATH 3 | 4 | set -e 5 | env -u PYTHONPATH -u LD_LIBRARY_PATH ./send_bot_alerts.py "$@" >> /var/log/securitybot/send_bot_alerts.log 2>&1 6 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/default/app.conf: -------------------------------------------------------------------------------- 1 | [ui] 2 | is_visible = 0 3 | label = Securitybot alert 4 | 5 | [launcher] 6 | author = Alex Bertsch 7 | description = An app to send alerts to the distributed security bot. 8 | version = 0.1 9 | 10 | [install] 11 | state = enabled 12 | is_configured = 1 13 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/default/alert_actions.conf: -------------------------------------------------------------------------------- 1 | [send_bot_alerts] 2 | is_custom = 1 3 | label = Create Securitybot tasks 4 | description = Configures an alert to be sent to the distributed security bot. 5 | icon_path = securitybot.png 6 | alert.execute.cmd = send_bot_alerts.sh 7 | payload_format = json 8 | disabled = 0 9 | -------------------------------------------------------------------------------- /plugins/README.md: -------------------------------------------------------------------------------- 1 | ## Securitybot plugins 2 | This directory contains a set of plugins for various alerting and logging systems that can be used to send alerts to the bot and also look up their results later. 3 | 4 | Each plugin probably requires some amount of tweaking to make sure that it works for your group, but otherwise most should be development-environment agnostic. 5 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/bin/bot_lookup_launcher.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import sys 3 | from subprocess import call 4 | 5 | logging_file = open('/var/log/securitybot/bot_lookup.log', 'a+') 6 | # Build arguments 7 | c = ['env', '-u', 'PYTHONPATH', '-u', 'LD_LIBRARY_PATH', '/path/to/securitybot'] 8 | c.extend(sys.argv[1:]) 9 | call(c, stderr=logging_file) 10 | -------------------------------------------------------------------------------- /frontend.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/env python 2 | import argparse 3 | from frontend.securitybot_frontend import main, init 4 | 5 | if __name__ == '__main__': 6 | init() 7 | 8 | parser = argparse.ArgumentParser(description='Securitybot frontent') 9 | parser.add_argument('--port', dest='port', default='8888', type=int) 10 | args = parser.parse_args() 11 | 12 | main(args.port) 13 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/default/data/ui/alerts/send_bot_alerts.html: -------------------------------------------------------------------------------- 1 |
10 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/default/macros.conf: -------------------------------------------------------------------------------- 1 | # Securitybot macros to make sure alerts get proper hashes 2 | [securitybot_hashes] 3 | definition = eval hash=sha256(_raw) 4 | 5 | [securitybot_squash_hashes(1)] 6 | definition = rename $hashes$ as old_hashes | eval hash=(mvindex(old_hashes), 0) | fields - old_hashes 7 | 8 | [securitybot_responses] 9 | definition = `securitybot_hashes` | lookup securitybot hash OUTPUT comment, performed, authenticated | fields - hash 10 | -------------------------------------------------------------------------------- /frontend/static/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Helvetica", "Arial", sans-serif; 3 | 4 | padding-top: 20px; 5 | padding-bottom: 20px; 6 | } 7 | 8 | h1, h2, h3 { 9 | font-weight: 300; 10 | } 11 | 12 | .heading { 13 | padding-bottom: 20px; 14 | } 15 | 16 | .column-selector { 17 | padding-top: 20px; 18 | padding-bottom: 20px; 19 | } 20 | 21 | .loading-icon { 22 | visibility: hidden; 23 | } 24 | 25 | #globalAlert { 26 | display: none; 27 | } 28 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | backports-abc==0.4 2 | certifi==2016.8.8 3 | configparser==3.5.0 4 | duo-client==3.0 5 | enum34==1.1.6 6 | flake8==3.0.4 7 | funcsigs==1.0.2 8 | linecache2==1.0.0 9 | mccabe==0.5.2 10 | mock==2.0.0 11 | MySQL-python==1.2.5 12 | nose==1.3.7 13 | pbr==1.10.0 14 | py==1.4.31 15 | pycodestyle==2.0.0 16 | pyflakes==1.2.3 17 | pytest==3.0.1 18 | pytz==2016.6.1 19 | PyYAML==3.11 20 | requests==2.11.1 21 | singledispatch==3.4.0.3 22 | six==1.10.0 23 | slackclient==1.0.1 24 | tornado==4.4.1 25 | traceback2==1.4.0 26 | typing==3.5.2.2 27 | unittest2==1.1.0 28 | websocket-client==0.37.0 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "jquery": true, 5 | "es6": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "rules": { 9 | "indent": [ 10 | "error", 11 | 2 12 | ], 13 | "linebreak-style": [ 14 | "error", 15 | "unix" 16 | ], 17 | "quotes": [ 18 | "error", 19 | "double" 20 | ], 21 | "semi": [ 22 | "error", 23 | "always" 24 | ], 25 | "no-unused-vars": [ 26 | "error", 27 | { "vars": "local"} 28 | ], 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /securitybot/blacklist/blacklist.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A generic blacklist class. 3 | ''' 4 | __author__ = 'Alex Bertsch' 5 | __email__ = 'abertsch@dropbox.com' 6 | 7 | from abc import ABCMeta, abstractmethod 8 | 9 | class Blacklist(object): 10 | __metaclass__ = ABCMeta 11 | 12 | @abstractmethod 13 | def is_present(self, name): 14 | # type: (str) -> bool 15 | ''' 16 | Checks if a name is on the blacklist. 17 | 18 | Args: 19 | name (str): The name to check. 20 | ''' 21 | pass 22 | 23 | @abstractmethod 24 | def add(self, name): 25 | # type: (str) -> None 26 | ''' 27 | Adds a name to the blacklist. 28 | 29 | Args: 30 | name (str): The name to add to the blacklist. 31 | ''' 32 | pass 33 | 34 | @abstractmethod 35 | def remove(self, name): 36 | # type: (str) -> None 37 | ''' 38 | Removes a name to the blacklist. 39 | 40 | Args: 41 | name (str): The name to remove from the blacklist. 42 | ''' 43 | pass 44 | -------------------------------------------------------------------------------- /scripts/custom_alert.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | ''' 3 | Creates a custom Securitybot alert for a specified user. 4 | ''' 5 | import argparse 6 | from securitybot.sql import SQLEngine 7 | from securitybot.util import create_new_alert 8 | 9 | from typing import Any 10 | 11 | def main(args): 12 | # type: (Any) -> None 13 | SQLEngine('localhost', 'root', '', 'securitybot') 14 | 15 | create_new_alert('custom_alert', args.name[0], args.title[0], args.reason[0]) 16 | 17 | if __name__ == '__main__': 18 | parser = argparse.ArgumentParser(description='Send a custom Securitybot alert') 19 | 20 | parser.add_argument('-n', '--name', dest='name', nargs=1, required=True, 21 | help='Username to send alert to') 22 | parser.add_argument('-t', '--title', dest='title', nargs=1, required=True, 23 | help='User-visible alert title') 24 | parser.add_argument('-r', '--reason', dest='reason', nargs=1, 25 | help='Long-form reason for the alert to provided context to user.') 26 | 27 | args = parser.parse_args() 28 | main(args) 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import logging 3 | 4 | from securitybot.bot import SecurityBot 5 | from securitybot.chat.slack import Slack 6 | from securitybot.tasker.sql_tasker import SQLTasker 7 | from securitybot.auth.duo import DuoAuth 8 | from securitybot.sql import init_sql 9 | import duo_client 10 | 11 | CONFIG = {} 12 | SLACK_KEY = 'slack_api_token' 13 | DUO_INTEGRATION = 'duo_integration_key' 14 | DUO_SECRET = 'duo_secret_key' 15 | DUO_ENDPOINT = 'duo_endpoint' 16 | REPORTING_CHANNEL = 'some_slack_channel_id' 17 | ICON_URL = 'https://dl.dropboxusercontent.com/s/t01pwfrqzbz3gzu/securitybot.png' 18 | 19 | def init(): 20 | # Setup logging 21 | logging.basicConfig(level=logging.DEBUG, 22 | format='[%(asctime)s %(levelname)s] %(message)s') 23 | logging.getLogger('requests').setLevel(logging.WARNING) 24 | logging.getLogger('usllib3').setLevel(logging.WARNING) 25 | 26 | def main(): 27 | init() 28 | init_sql() 29 | 30 | # Create components needed for Securitybot 31 | duo_api = duo_client.Auth( 32 | ikey=DUO_INTEGRATION, 33 | skey=DUO_SECRET, 34 | host=DUO_ENDPOINT 35 | ) 36 | duo_builder = lambda name: DuoAuth(duo_api, name) 37 | 38 | chat = Slack('securitybot', SLACK_KEY, ICON_URL) 39 | tasker = SQLTasker() 40 | 41 | sb = SecurityBot(chat, tasker, duo_builder, REPORTING_CHANNEL, 'config/bot.yaml') 42 | sb.run() 43 | 44 | if __name__ == '__main__': 45 | main() 46 | -------------------------------------------------------------------------------- /securitybot/blacklist/sql_blacklist.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A MySQL-based blacklist class. 3 | ''' 4 | __author__ = 'Alex Bertsch' 5 | __email__ = 'abertsch@dropbox.com' 6 | 7 | from securitybot.blacklist.blacklist import Blacklist 8 | from securitybot.sql import SQLEngine 9 | 10 | class SQLBlacklist(Blacklist): 11 | def __init__(self): 12 | # type: () -> None 13 | ''' 14 | Creates a new blacklist tied to a table named "blacklist". 15 | ''' 16 | # Load from table 17 | names = SQLEngine.execute('SELECT * FROM blacklist') 18 | # Break tuples into names 19 | self._blacklist = {name[0] for name in names} 20 | 21 | def is_present(self, name): 22 | # type: (str) -> bool 23 | ''' 24 | Checks if a name is on the blacklist. 25 | 26 | Args: 27 | name (str): The name to check. 28 | ''' 29 | return name in self._blacklist 30 | 31 | def add(self, name): 32 | # type: (str) -> None 33 | ''' 34 | Adds a name to the blacklist. 35 | 36 | Args: 37 | name (str): The name to add to the blacklist. 38 | ''' 39 | self._blacklist.add(name) 40 | SQLEngine.execute('INSERT INTO blacklist (ldap) VALUES (%s)', (name,)) 41 | 42 | def remove(self, name): 43 | # type: (str) -> None 44 | ''' 45 | Removes a name to the blacklist. 46 | 47 | Args: 48 | name (str): The name to remove from the blacklist. 49 | ''' 50 | self._blacklist.remove(name) 51 | SQLEngine.execute('DELETE FROM blacklist WHERE ldap = %s', (name,)) 52 | -------------------------------------------------------------------------------- /securitybot/auth/auth.py: -------------------------------------------------------------------------------- 1 | ''' 2 | An authentication object for doing 2FA on Slack users. 3 | ''' 4 | __author__ = 'Alex Bertsch' 5 | __email__ = 'abertsch@dropbox.com' 6 | 7 | from securitybot.util import enum 8 | from datetime import timedelta 9 | from abc import ABCMeta, abstractmethod 10 | 11 | AUTH_STATES = enum('NONE', 12 | 'PENDING', 13 | 'AUTHORIZED', 14 | 'DENIED', 15 | ) 16 | 17 | # Allowable time before 2FA is checked again. 18 | # Ideally this should be as low as possible without being annoying. 19 | AUTH_TIME = timedelta(hours=2) 20 | 21 | class Auth(object): 22 | ''' 23 | When designing Auth subclasses, try to make sure that the authorization 24 | attempt is as non-blocking as possible. 25 | ''' 26 | __metaclass__ = ABCMeta 27 | 28 | @abstractmethod 29 | def can_auth(self): 30 | # type: () -> bool 31 | ''' 32 | Returns: 33 | (bool) Whether 2FA is available. 34 | ''' 35 | pass 36 | 37 | @abstractmethod 38 | def auth(self, reason=None): 39 | # type: (str) -> None 40 | ''' 41 | Begins an authorization request, which should be non-blocking. 42 | 43 | Args: 44 | reason (str): Optional reason string that may be provided 45 | ''' 46 | pass 47 | 48 | @abstractmethod 49 | def auth_status(self): 50 | # type: () -> int 51 | ''' 52 | Returns: 53 | (enum) The current auth status, one of AUTH_STATES. 54 | ''' 55 | pass 56 | 57 | @abstractmethod 58 | def reset(self): 59 | # type: () -> None 60 | ''' 61 | Resets auth status. 62 | ''' 63 | pass 64 | -------------------------------------------------------------------------------- /util/db_up.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import MySQLdb 3 | import sys 4 | 5 | # DB CONFIG GOES HERE 6 | host = 'localhost' 7 | user = 'root' 8 | passwd= '' 9 | 10 | db = MySQLdb.connect(host=host, 11 | user=user, 12 | passwd=passwd, 13 | db='securitybot') 14 | 15 | cur = db.cursor() 16 | 17 | # Start fresh 18 | print 'Removing all tables' 19 | cur.execute('SHOW TABLES') 20 | tables = cur.fetchall() 21 | for table in tables: 22 | table = table[0] 23 | print 'Dropping {0}'.format(table) 24 | cur.execute('DROP TABLE {0}'.format(MySQLdb.escape_string(table))) 25 | 26 | # Create tables 27 | print 'Creating tables...' 28 | 29 | cur.execute( 30 | ''' 31 | CREATE TABLE blacklist ( 32 | ldap VARCHAR(255) NOT NULL, 33 | PRIMARY KEY ( ldap ) 34 | ) 35 | ''' 36 | ) 37 | 38 | cur.execute( 39 | ''' 40 | CREATE TABLE ignored ( 41 | ldap VARCHAR(255) NOT NULL, 42 | title VARCHAR(255) NOT NULL, 43 | reason VARCHAR(255) NOT NULL, 44 | until DATETIME NOT NULL, 45 | CONSTRAINT ignored_ID PRIMARY KEY ( ldap, title ) 46 | ) 47 | ''' 48 | ) 49 | 50 | cur.execute( 51 | ''' 52 | CREATE TABLE alerts ( 53 | hash BINARY(32) NOT NULL, 54 | ldap VARCHAR(255) NOT NULL, 55 | title VARCHAR(255) NOT NULL, 56 | description VARCHAR(255) NOT NULL, 57 | reason TEXT NOT NULL, 58 | url VARCHAR(511) NOT NULL, 59 | event_time DATETIME NOT NULL, 60 | PRIMARY KEY ( hash ) 61 | ) 62 | ''' 63 | ) 64 | 65 | cur.execute( 66 | ''' 67 | CREATE TABLE alert_status ( 68 | hash BINARY(32) NOT NULL, 69 | status TINYINT UNSIGNED NOT NULL, 70 | PRIMARY KEY ( hash ) 71 | ) 72 | ''' 73 | ) 74 | 75 | cur.execute( 76 | ''' 77 | CREATE TABLE user_responses( 78 | hash BINARY(32) NOT NULL, 79 | comment TEXT, 80 | performed BOOL, 81 | authenticated BOOL, 82 | PRIMARY KEY ( hash ) 83 | ) 84 | ''' 85 | ) 86 | 87 | print 'Done!' 88 | -------------------------------------------------------------------------------- /securitybot/ignored_alerts.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A small file for keeping track of ignored alerts in the database. 3 | ''' 4 | import pytz 5 | from datetime import datetime, timedelta 6 | from securitybot.sql import SQLEngine 7 | from typing import Dict 8 | 9 | def __update_ignored_list(): 10 | # type: () -> None 11 | ''' 12 | Prunes the ignored table of old ignored alerts. 13 | ''' 14 | SQLEngine.execute('''DELETE FROM ignored WHERE until <= NOW()''') 15 | 16 | def get_ignored(username): 17 | # type: (str) -> Dict[str, str] 18 | ''' 19 | Returns a dictionary of ignored alerts to reasons why 20 | the ignored are ignored. 21 | 22 | Args: 23 | username (str): The username of the user to retrieve ignored alerts for. 24 | Returns: 25 | Dict[str, str]: A mapping of ignored alert titles to reasons 26 | ''' 27 | __update_ignored_list() 28 | rows = SQLEngine.execute('''SELECT title, reason FROM ignored WHERE ldap = %s''', (username,)) 29 | return {row[0]: row[1] for row in rows} 30 | 31 | def ignore_task(username, title, reason, ttl): 32 | # type: (str, str, str, timedelta) -> None 33 | ''' 34 | Adds a task with the given title to the ignore list for the given 35 | amount of time. Additionally adds an optional message to specify the 36 | reason that the alert was ignored. 37 | 38 | Args: 39 | username (str): The username of the user to ignore the given alert for. 40 | title (str): The title of the alert to ignore. 41 | ttl (Timedelta): The amount of time to ignore the alert for. 42 | msg (str): An optional string specifying why an alert was ignored 43 | ''' 44 | expiry_time = datetime.now(tz=pytz.utc) + ttl 45 | # NB: Non-standard MySQL specific query 46 | SQLEngine.execute('''INSERT INTO ignored (ldap, title, reason, until) 47 | VALUES (%s, %s, %s, %s) 48 | ON DUPLICATE KEY UPDATE reason=VALUES(reason), until=VALUES(until) 49 | ''', (username, title, reason, expiry_time.strftime('%Y-%m-%d %H:%M:%S'))) 50 | -------------------------------------------------------------------------------- /plugins/splunk/README.md: -------------------------------------------------------------------------------- 1 | ## Splunk 2 | A plugin for Splunk in the form of a custom application. 3 | When installed, this app createes a new alerting action called "Send bot alerts" that sends alerts to Securitybot, creates a custom lookup that can find alerts from the database, and installs several macros that can be used to easily work with the bot. 4 | 5 | ### Alert actions 6 | The "Send bot alerts" action can be added to any Splunk alert to send alerts to the bot. 7 | However, the alert needs a little extra fine tuning before it's ready to go. 8 | Every alert needs to output the following fields: 9 | 1. `hash`: A unique hash that identifies the alert. 10 | 2. `ldap`: The username of the user to send the alert to, ideally whoever caused the event. 11 | 3. `event_info`: A friendly explanation of what happened to be displayed to someone using the bot. 12 | The alert action will also ask for a title field which will be displayed to the user as the title of the alert that went off. 13 | 14 | ### Macros 15 | We have three macros that make generating hashes and later incorporating bot responses into alert rollups easier. 16 | 1. `securitybot_hashes`: Generates a `hash` field for every event. 17 | This should be added to a search immediately after the main search query as weird things happen otherwise. 18 | 2. `securitybot_squash_hashes(1)`: Compresses `values(hash)` or `list(hash)` into just one field. 19 | The parameter should be the name of the field to "squash". 20 | The send bot alerts alert action expects only a single hash, so we simply choose one if we've aggregated our events using `stats`. 21 | 3. `securitybot_responses`: Gather responses from bot alerts. 22 | When put right after the main query in a search generates fields `comment`, `performed`, and `authenticated`, which are the user's comment about an action, whether or not they performed and action, and whehter or not they successfully completed 2FA. 23 | 24 | ### Lookups 25 | This addes the `securitybot` lookup which allows you to query the Securitybot database from within Splunk. 26 | The fields in the lookup are `hash`, `comment`, `performed`, `authenticated`. 27 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/bin/bot_lookup.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/env python 2 | ''' 3 | Performs a MySQL query to return any events that have a SHA-256 hash matching 4 | an event handled by the bot. 5 | ''' 6 | __author__ = 'Alex Bertsch' 7 | __email__ = 'abertsch@dropbox.com' 8 | 9 | import sys 10 | import csv 11 | import logging 12 | 13 | from securitybot.sql import SQLEngine, init_sql 14 | 15 | from typing import Any, Sequence 16 | 17 | def find_on_hash(hash): 18 | # type: (str) -> Sequence[Any] 19 | match = SQLEngine.execute('SELECT comment, performed, authenticated FROM user_responses WHERE hash=UNHEX(%s)', (hash,)) 20 | if len(match) != 1: 21 | # This catches collisions too, which is probably (hopefully) overkill 22 | return None 23 | item = match[0] 24 | return item[0], bool(item[1]), bool(item[2]) 25 | 26 | def main(): 27 | # type: () -> None 28 | if len(sys.argv) != 5: 29 | print 'Usage: python bot_lookup.py [hash] [comment] [performed] [authenticated]' 30 | 31 | # Initialize SQL 32 | init_sql() 33 | 34 | hash_field = sys.argv[1] 35 | comment_field = sys.argv[2] 36 | performed_field = sys.argv[3] 37 | authenticated_field = sys.argv[4] 38 | 39 | infile = sys.stdin 40 | outfile = sys.stdout 41 | 42 | # Load in query from stdin 43 | inbound = csv.DictReader(infile) 44 | 45 | # Prep return CSV with the same format 46 | header = inbound.fieldnames 47 | outbound = csv.DictWriter(outfile, fieldnames=header) 48 | outbound.writeheader() 49 | 50 | for entry in inbound: 51 | hash = entry[hash_field] 52 | 53 | try: 54 | res = find_on_hash(hash) 55 | if res is not None: 56 | comment, performed, authenticated = res 57 | 58 | entry[comment_field] = comment 59 | entry[performed_field] = performed 60 | entry[authenticated_field] = authenticated 61 | except Exception as e: 62 | logging.warn('An exception was encountered making a DB call: {0}'.format(e)) 63 | 64 | outbound.writerow(entry) 65 | 66 | if __name__ == '__main__': 67 | logging.basicConfig(level=logging.DEBUG) 68 | main() 69 | -------------------------------------------------------------------------------- /config/commands.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Securitybot commands 3 | # Each command must have an information string and function to call. 4 | # They may also excplitly say whether they are hidden and provide their usage along with 5 | # success and failure messages, located in messages.yaml 6 | 7 | help: # Displays each command, their info and useage 8 | info: This display here. 9 | usage: 10 | - "-a\t\tShow all commands, including ones hidden by default" 11 | fn: help 12 | 13 | hi: # Says hello! 14 | info: Hi! 15 | fn: hi 16 | 17 | stop: # Adds a user to the blacklist 18 | info: Adds you to the blacklist. 19 | fn: add_to_blacklist 20 | success_msg: Alright, you've been added to the blacklist. I won't bother you anymore. 21 | failure_msg: Looks like you're already on the blacklist. 22 | 23 | start: # Removes a user from the blacklist 24 | info: Removes you from the blacklist. 25 | fn: remove_from_blacklist 26 | success_msg: You're off the blacklist. Welcome back! 27 | failure_msg: Looks like you're not on the blacklist. 28 | 29 | "yes": # Registers a positive response for a user 30 | info: Registers a positive response from you. 31 | fn: positive_response 32 | hidden: Yes 33 | 34 | "no": # Registers a negative reponse for a user 35 | info: Registers a negative response from you. 36 | fn: negative_response 37 | hidden: Yes 38 | 39 | ignore: # Ignores the user for one or more alerts for some period of time 40 | info: Ignores the current alert or previous alert if there is no current alert. 41 | usage: 42 | - "`ignore (last|current) XhYm`" 43 | - "Ignores an alert for X hours and Y minutes. At least one of hours or minutes must be provided." 44 | - "Selectable whether this ignores the current alert 45 | (whatever the bot is bothering you about right now) or the last one 46 | (whichever one you just dealt with)." 47 | fn: ignore 48 | success_msg: > 49 | Alright, I'll stop bothering you about that for now. 50 | You'll need to finish up your current alert if you chose to ignore it in the future. 51 | failure_msg: Sorry, either that command was malformed or you have no alerts to ignore. 52 | 53 | test: # Generates a test alert for a user 54 | info: Generates a test alert for you. 55 | fn: test 56 | hidden: Yes 57 | success_msg: 'Sending you a testing alert. Be patient...' 58 | failure_msg: 'I was unable to generate a testing alert. Sorry. :sadpanda:' 59 | 60 | ... 61 | -------------------------------------------------------------------------------- /securitybot/chat/chat.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A simple wrapper over an abstract chat/messaging system 3 | like Slack. 4 | ''' 5 | __author__ = 'Alex Bertsch' 6 | __email__ = 'abertsch@dropbox.com' 7 | 8 | from securitybot.user import User 9 | from abc import ABCMeta, abstractmethod 10 | 11 | from typing import Any, Dict, List 12 | 13 | class Chat(object): 14 | ''' 15 | A wrapper over various chat frameworks, like Slack. 16 | ''' 17 | __metaclass__ = ABCMeta 18 | 19 | @abstractmethod 20 | def connect(self): 21 | # type: () -> None 22 | '''Connects to the chat system.''' 23 | pass 24 | 25 | @abstractmethod 26 | def get_users(self): 27 | # type: () -> List[Dict[str, Any]] 28 | ''' 29 | Returns a list of all users in the chat system. 30 | 31 | Returns: 32 | A list of dictionaries, each dictionary representing a user. 33 | The rest of the bot expects the following minimal format: 34 | { 35 | "name": The username of a user, 36 | "id": A user's unique ID in the chat system, 37 | "profile": A dictionary representing a user with at least: 38 | { 39 | "first_name": A user's first name 40 | } 41 | } 42 | ''' 43 | pass 44 | 45 | @abstractmethod 46 | def get_messages(self): 47 | # type () -> List[Dict[str, Any]] 48 | ''' 49 | Gets a list of all new messages received by the bot in direct 50 | messaging channels. That is, this function ignores all messages 51 | posted in group chats as the bot never interacts with those. 52 | 53 | Each message should have the following format, minimally: 54 | { 55 | "user": The unique ID of the user who sent a message. 56 | "text": The text of the received message. 57 | } 58 | ''' 59 | pass 60 | 61 | @abstractmethod 62 | def send_message(self, channel, message): 63 | # type: (Any, str) -> None 64 | ''' 65 | Sends some message to a desired channel. 66 | As channels are possibly chat-system specific, this function has a horrible 67 | type signature. 68 | ''' 69 | pass 70 | 71 | @abstractmethod 72 | def message_user(self, user, message): 73 | # type: (User, str) -> None 74 | ''' 75 | Sends some message to a desired user, using a User object and a string message. 76 | ''' 77 | pass 78 | 79 | class ChatException(Exception): 80 | pass 81 | -------------------------------------------------------------------------------- /securitybot/auth/duo.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Authentication using Duo. 3 | ''' 4 | __author__ = 'Alex Bertsch' 5 | __email__ = 'abertsch@dropbox.com' 6 | 7 | import logging 8 | from datetime import datetime 9 | from urllib import urlencode 10 | from securitybot.auth.auth import Auth, AUTH_STATES, AUTH_TIME 11 | 12 | from typing import Any 13 | 14 | class DuoAuth(Auth): 15 | def __init__(self, duo_api, username): 16 | # type: (Any, str) -> None 17 | ''' 18 | Args: 19 | duo_api (duo_client.Auth): An Auth API client from Duo. 20 | username (str): The username of the person authorized through 21 | this object. 22 | ''' 23 | self.client = duo_api 24 | self.username = username 25 | self.txid = None # type: str 26 | self.auth_time = datetime.min 27 | self.state = AUTH_STATES.NONE 28 | 29 | def can_auth(self): 30 | # type: () -> bool 31 | # Use Duo preauth to look for a device with Push 32 | # TODO: This won't work for anyone who's set to auto-allow, but 33 | # I don't believe we have anyone like that... 34 | logging.debug('Checking auth capabilities for {}'.format(self.username)) 35 | res = self.client.preauth(username=self.username) 36 | if res['result'] == 'auth': 37 | for device in res['devices']: 38 | if 'push' in device['capabilities']: 39 | return True 40 | return False 41 | 42 | def auth(self, reason=None): 43 | # type: (str) -> None 44 | logging.debug('Sending Duo Push request for {}'.format(self.username)) 45 | pushinfo = 'from=Securitybot' 46 | if reason: 47 | pushinfo += '&' 48 | pushinfo += urlencode({'reason': reason}) 49 | 50 | res = self.client.auth( 51 | username=self.username, 52 | async=True, 53 | factor='push', 54 | device='auto', 55 | type='Securitybot', 56 | pushinfo=pushinfo 57 | ) 58 | self.txid = res['txid'] 59 | self.state = AUTH_STATES.PENDING 60 | 61 | def _recently_authed(self): 62 | # type: () -> bool 63 | return (datetime.now() - self.auth_time) < AUTH_TIME 64 | 65 | def auth_status(self): 66 | # type: () -> int 67 | if self.state == AUTH_STATES.PENDING: 68 | res = self.client.auth_status(self.txid) 69 | if not res['waiting']: 70 | if res['success']: 71 | self.state = AUTH_STATES.AUTHORIZED 72 | self.auth_time = datetime.now() 73 | else: 74 | self.state = AUTH_STATES.DENIED 75 | self.auth_time = datetime.min 76 | elif self.state == AUTH_STATES.AUTHORIZED: 77 | if not self._recently_authed(): 78 | self.state = AUTH_STATES.NONE 79 | return self.state 80 | 81 | def reset(self): 82 | # type: () -> None 83 | self.txid = None 84 | self.state = AUTH_STATES.NONE 85 | -------------------------------------------------------------------------------- /plugins/splunk/apps/securitybot_alerts/bin/send_bot_alerts.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/env python 2 | 3 | import sys 4 | import csv 5 | import gzip 6 | import logging 7 | 8 | import json 9 | 10 | from securitybot.sql import SQLEngine, init_sql 11 | from securitybot.util import create_new_alert 12 | 13 | def create_securitybot_task(search_name, hash, username, description, reason, url): 14 | ''' 15 | Creates a new Maniphest task with the securitybot tag so that the bot can 16 | reach out to the relevant people. 17 | ''' 18 | logging.info('Creating new task about {} for {}'.format(description, 19 | username)) 20 | 21 | # Check for collision 22 | rows = SQLEngine.execute('SELECT title FROM alerts WHERE hash=UNHEX(%s)', (hash,)) 23 | if rows: 24 | raise CollisionException( 25 | '''We found a collision with {0} for {1}. 26 | Most likely the Splunk alert with configured incorrectly. 27 | However, if this is a geniune collision, then you have a paper to write. Good luck. 28 | '''.format(rows, hash)) 29 | 30 | # Insert that into the database as a new alert 31 | create_new_alert(search_name, username, description, reason, url, hash) 32 | 33 | class CollisionException(Exception): 34 | pass 35 | 36 | def send_bot_alerts(payload): 37 | ''' 38 | Creates alerts for securitybot using data provided by Splunk. 39 | 40 | Args: 41 | payload (Dict[str, str]): A dictionary of parameters provided by Splunk. 42 | ''' 43 | # Generic things 44 | results_file = payload['results_file'] 45 | alert_name = payload['search_name'] 46 | splunk_url = payload['results_link'] 47 | 48 | # Action specific things 49 | title = payload['configuration']['title'] 50 | 51 | try: 52 | with gzip.open(results_file, 'rb') as alert_file: 53 | reader = csv.DictReader(alert_file) 54 | for row in reader: 55 | # TODO: eventually group by username and concat event_info 56 | create_securitybot_task(alert_name, 57 | row['hash'], 58 | row['ldap'], 59 | title, 60 | row['event_info'], 61 | splunk_url 62 | ) 63 | 64 | except Exception: 65 | # Can't fix anything, so just re-raise and move on 66 | raise 67 | 68 | def main(): 69 | logging.basicConfig(level=logging.INFO, 70 | format='%(asctime)s %(levelname)s %(message)s') 71 | 72 | try: 73 | # Parse stdin from Splunk 74 | payload = json.loads(sys.stdin.read()) 75 | logging.info('Sending bot alert: {0}'.format(payload['search_name'])) 76 | 77 | # initialize SQL 78 | init_sql() 79 | 80 | send_bot_alerts(payload) 81 | 82 | logging.info('Alert {} fired successfully.\n'.format(payload['search_name'])) 83 | except Exception as e: 84 | logging.error('Failure: {}'.format(e)) 85 | logging.info('Exiting') 86 | 87 | if __name__ == '__main__': 88 | main() 89 | -------------------------------------------------------------------------------- /securitybot/tasker/sql_tasker.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A tasker on top of a SQL database. 3 | ''' 4 | from securitybot.tasker.tasker import Task, Tasker, STATUS_LEVELS 5 | from securitybot.sql import SQLEngine 6 | 7 | from typing import List 8 | 9 | # Note: this order is provided to match the SQLTask constructor 10 | GET_ALERTS = ''' 11 | SELECT HEX(alerts.hash), 12 | title, 13 | ldap, 14 | reason, 15 | description, 16 | url, 17 | performed, 18 | comment, 19 | authenticated, 20 | status 21 | FROM alerts 22 | JOIN user_responses ON alerts.hash = user_responses.hash 23 | JOIN alert_status ON alerts.hash = alert_status.hash 24 | WHERE status = %s 25 | ''' 26 | 27 | class SQLTasker(Tasker): 28 | def _get_tasks(self, level): 29 | # type: (int) -> List[Task] 30 | ''' 31 | Gets all tasks of a certain level. 32 | 33 | Args: 34 | level (int): One of STATUS_LEVELS 35 | Returns: 36 | List of SQLTasks. 37 | ''' 38 | alerts = SQLEngine.execute(GET_ALERTS, (level,)) 39 | return [SQLTask(*alert) for alert in alerts] 40 | 41 | def get_new_tasks(self): 42 | # type: () -> List[Task] 43 | return self._get_tasks(STATUS_LEVELS.OPEN) 44 | 45 | 46 | def get_active_tasks(self): 47 | # type: () -> List[Task] 48 | return self._get_tasks(STATUS_LEVELS.INPROGRESS) 49 | 50 | def get_pending_tasks(self): 51 | # type: () -> List[Task] 52 | return self._get_tasks(STATUS_LEVELS.VERIFICATION) 53 | 54 | SET_STATUS = ''' 55 | UPDATE alert_status 56 | SET status=%s 57 | WHERE hash=UNHEX(%s) 58 | ''' 59 | 60 | SET_RESPONSE = ''' 61 | UPDATE user_responses 62 | SET comment=%s, 63 | performed=%s, 64 | authenticated=%s 65 | WHERE hash=UNHEX(%s) 66 | ''' 67 | 68 | class SQLTask(Task): 69 | def __init__(self, hsh, title, username, reason, description, url, 70 | performed, comment, authenticated, status): 71 | # type: (str, str, str, str, str, str, bool, str, bool, int) -> None 72 | ''' 73 | Args: 74 | hsh (str): SHA256 primary key hash. 75 | ''' 76 | super(SQLTask, self).__init__(title, username, reason, description, url, 77 | performed, comment, authenticated, status) 78 | self.hash = hsh 79 | 80 | def _set_status(self, status): 81 | # type: (int) -> None 82 | ''' 83 | Sets the status of a task in the DB. 84 | 85 | Args: 86 | status (int): The new status to use. 87 | ''' 88 | SQLEngine.execute(SET_STATUS, (status, self.hash)) 89 | 90 | def _set_response(self): 91 | # type: () -> None 92 | ''' 93 | Updates the user response for this task. 94 | ''' 95 | SQLEngine.execute(SET_RESPONSE, (self.comment, 96 | self.performed, 97 | self.authenticated, 98 | self.hash)) 99 | 100 | def set_open(self): 101 | self._set_status(STATUS_LEVELS.OPEN) 102 | 103 | def set_in_progress(self): 104 | self._set_status(STATUS_LEVELS.INPROGRESS) 105 | 106 | def set_verifying(self): 107 | self._set_status(STATUS_LEVELS.VERIFICATION) 108 | self._set_response() 109 | -------------------------------------------------------------------------------- /config/messages.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Messages for securitybot 3 | # This file is loaded, parsed and fed into the bot. 4 | 5 | # Basic bot greeting. 6 | # Formatting parameter 0 is the name to use when addressing the user. 7 | greeting: > 8 | Hi there {0}! 9 | 10 | Feel free to message me `help` for some commands you can use, e.g `stop` or `ignore`. 11 | 12 | # Message to send between alerts. (but wait there's more) 13 | bwtm: > 14 | One more thing... 15 | 16 | # Message to send once all current alerts have been dealt with 17 | bye: > 18 | Thanks for your help! 19 | 20 | # Message sent when alerting a user that a security action occured 21 | # Takes alert description and alert reason as formatting parameters 0 and 1 rsp 22 | alert: > 23 | There's an alert, `{0}`, that's associated with your username. 24 | 25 | Here's some more information on the alert: 26 | 27 | {1} 28 | 29 | # Message sent when asking for user response on whether they're responsible 30 | action_prompt: > 31 | Did you do this? 32 | 33 | Respond with either "yes" or "no" followed by an explanation in one message. 34 | 35 | # Message sent if the user is not responsible 36 | escalated: > 37 | That's fine. Don't worry, I'll share this with the security team and they'll 38 | look into this and get back to you if they need more. 39 | 40 | # Message sent when no 2FA is available 41 | no_2fa: > 42 | It doesn't look like you have 2FA set up on your account. 43 | 44 | # Message sent when asking for 2FA 45 | 2fa: > 46 | Great! To confirm this I'm going to send a 2FA to your device. 47 | Are you okay with that? Respond with either "yes" or "no" on their own. 48 | 49 | # Message send when sending a 2FA 50 | sending_push: > 51 | I'm sending you a 2FA right now. Check to make sure that it's from me 52 | and then feel free to accept it. 53 | 54 | # Message sent on 2FA approval 55 | good_auth: > 56 | Awesome, I've noted what you've said and should take care of this. 57 | 58 | # Message sent on 2FA denial 59 | bad_auth: > 60 | Sadly that 2FA request didn't go through... 61 | 62 | The task has been shared with the security team who will look into it 63 | shortly. They'll get back to you when they can. 64 | 65 | # Message sent when a bad resposne is retrieved 66 | bad_response: > 67 | Sorry, I didn't understand that. Try again, please. 68 | 69 | # Message sent when automatically escalating a task 70 | no_response: > 71 | I didn't hear anything from you, so I'll just send that off to the security team and they'll 72 | contact you soon if needed. 73 | 74 | # Message sent to report a user didn't do something. 75 | report: > 76 | `{username}` reports they didn't do `{title}` (`{description}`): 77 | 78 | {comment} 79 | 80 | URL: {url} 81 | 82 | # Sent when a message is unrecognized 83 | bad_command: > 84 | I'm sorry, I don't understand. Try saying `help` for more information. 85 | 86 | # Command messages 87 | help_header: > 88 | Securitybot commands: 89 | 90 | help_usage: Usage 91 | 92 | help_footer: > 93 | Treat these like terminal commands; you can pass in flags and parameters. 94 | 95 | hi: > 96 | Hello there {0}! 97 | 98 | ignore_time: > 99 | Ignoring specific alerts is limited to just four hours. 100 | I've reduced the amount of time to that limit. 101 | 102 | ignore_no_time: > 103 | You must provide a non-zero amount of time. 104 | ... 105 | -------------------------------------------------------------------------------- /securitybot/sql.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A wrapper for the securitybot to access its database. 3 | ''' 4 | import MySQLdb 5 | import logging 6 | 7 | from typing import Any, Sequence 8 | 9 | class SQLEngine(object): 10 | # Whether the singleton has been instantiated 11 | _host = None # type: str 12 | _user = None # type: str 13 | _passwd = None # type: str 14 | _db = None # type: str 15 | _created = False # type: bool 16 | _conn = None 17 | _cursor = None 18 | 19 | def __init__(self, host, user, passwd, db): 20 | # type: (str, str, str, str) -> None 21 | ''' 22 | Initializes the SQL connection to be used for the bot. 23 | 24 | Args: 25 | host (str): The hostname of the SQL server. 26 | user (str): The username to use. 27 | passwd (str): Password for MySQL user. 28 | db (str): The name of the database to connect to. 29 | ''' 30 | if not SQLEngine._created: 31 | SQLEngine._host = host 32 | SQLEngine._user = user 33 | SQLEngine._passwd = passwd 34 | SQLEngine._db = db 35 | SQLEngine._create_engine(host, user, passwd, db) 36 | SQLEngine._created = True 37 | 38 | @staticmethod 39 | def _create_engine(host, user, passwd, db): 40 | # type: (str, str, str, str) -> None 41 | ''' 42 | Args: 43 | host (str): The hostname of the SQL server. 44 | user (str): The username to use. 45 | passwd (str): Password for MySQL user. 46 | db (str): The name of the database to connect to. 47 | ''' 48 | SQLEngine._conn = MySQLdb.connect(host=host, 49 | user=user, 50 | passwd=passwd, 51 | db=db) 52 | SQLEngine._cursor = SQLEngine._conn.cursor() 53 | 54 | @staticmethod 55 | def execute(query, params=None): 56 | # type: (str, Sequence[Any]) -> Sequence[Sequence[Any]] 57 | ''' 58 | Executes a given SQL query with some possible params. 59 | 60 | Args: 61 | query (str): The query to perform. 62 | params (Tuple[str]): Optional parameters to pass to the query. 63 | Returns: 64 | Tuple[Tuple[str]]: The output from the SQL query. 65 | ''' 66 | if params is None: 67 | params = () 68 | try: 69 | SQLEngine._cursor.execute(query, params) 70 | rows = SQLEngine._cursor.fetchall() 71 | SQLEngine._conn.commit() 72 | except (AttributeError, MySQLdb.OperationalError): 73 | # Recover from lost connection 74 | logging.warn('Recovering from lost MySQL connection.') 75 | SQLEngine._create_engine(SQLEngine._host, 76 | SQLEngine._user, 77 | SQLEngine._passwd, 78 | SQLEngine._db) 79 | return SQLEngine.execute(query, params) 80 | except MySQLdb.Error as e: 81 | try: 82 | raise SQLEngineException('MySQL error [{0}]: {1}'.format(e.args[0], e.args[1])) 83 | except IndexError: 84 | raise SQLEngineException('MySQL error: {0}'.format(e)) 85 | return rows 86 | 87 | class SQLEngineException(Exception): 88 | pass 89 | 90 | def init_sql(): 91 | # type: () -> None 92 | '''Initializes SQL.''' 93 | SQLEngine('localhost', 'root', '', 'securitybot') 94 | -------------------------------------------------------------------------------- /securitybot/util.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Alex Bertsch' 2 | __email__ = 'abertsch@dropbox.com' 3 | 4 | import pytz 5 | import binascii 6 | import os 7 | from datetime import datetime, timedelta 8 | from collections import namedtuple 9 | 10 | from securitybot.sql import SQLEngine 11 | 12 | # http://stackoverflow.com/questions/36932/how-can-i-represent-an-enum-in-python 13 | def enum(*sequential, **named): 14 | enums = dict(zip(sequential, range(len(sequential))), **named) 15 | return type('Enum', (), enums) 16 | 17 | def tuple_builder(answer=None, text=None): 18 | tup = namedtuple('Response', ['answer', 'text']) 19 | tup.answer = answer if answer is not None else None 20 | tup.text = text if text is not None else '' 21 | return tup 22 | 23 | OPENING_HOUR = 10 24 | CLOSING_HOUR = 18 25 | LOCAL_TZ = pytz.timezone('America/Los_Angeles') 26 | 27 | def during_business_hours(time): 28 | ''' 29 | Checks if a given time is within business hours. Currently is true 30 | from 10:00 to 17:59. Also checks to make sure that the day is a weekday. 31 | 32 | Args: 33 | time (Datetime): A datetime object to check. 34 | ''' 35 | if time.tzinfo is not None: 36 | here = time.astimezone(LOCAL_TZ) 37 | else: 38 | here = time.replace(tzinfo=pytz.utc).astimezone(LOCAL_TZ) 39 | return (OPENING_HOUR <= here.hour < CLOSING_HOUR and 40 | 1 <= time.isoweekday() <= 5) 41 | 42 | def get_expiration_time(start, time): 43 | ''' 44 | Gets an expiration time for an alert. 45 | Works by adding on a certain time and wrapping around after business hours 46 | so that alerts that are started near the end of the day don't expire. 47 | 48 | Args: 49 | start (Datetime): A datetime object indicating when an alert was started. 50 | time (Timedelta): A timedelta representing the amount of time the alert 51 | should live for. 52 | Returns: 53 | Datetime: The expiry time for an alert. 54 | ''' 55 | if start.tzinfo is None: 56 | start = start.replace(tzinfo=pytz.utc) 57 | end = start + time 58 | if not during_business_hours(end): 59 | end_of_day = datetime(year=start.year, 60 | month=start.month, 61 | day=start.day, 62 | hour=CLOSING_HOUR, 63 | tzinfo=LOCAL_TZ) 64 | delta = end - end_of_day 65 | next_day = end_of_day + timedelta(hours=(OPENING_HOUR - CLOSING_HOUR) % 24) 66 | # This may land on a weekend, so march to the next weekday 67 | while not during_business_hours(next_day): 68 | next_day += timedelta(days=1) 69 | end = next_day + delta 70 | return end 71 | 72 | def create_new_alert(title, ldap, description, reason, url='N/A', key=None): 73 | # type: (str, str, str, str, str, str) -> None 74 | ''' 75 | Creates a new alert in the SQL DB with an optionally random hash. 76 | ''' 77 | # Generate random key if none provided 78 | if key is None: 79 | key = binascii.hexlify(os.urandom(32)) 80 | 81 | # Insert that into the database as a new alert 82 | SQLEngine.execute(''' 83 | INSERT INTO alerts (hash, ldap, title, description, reason, url, event_time) 84 | VALUES (UNHEX(%s), %s, %s, %s, %s, %s, NOW()) 85 | ''', 86 | (key, ldap, title, description, reason, url)) 87 | 88 | SQLEngine.execute(''' 89 | INSERT INTO user_responses (hash, comment, performed, authenticated) 90 | VALUES (UNHEX(%s), '', false, false) 91 | ''', 92 | (key,)) 93 | 94 | SQLEngine.execute('INSERT INTO alert_status (hash, status) VALUES (UNHEX(%s), 0)', 95 | (key,)) 96 | -------------------------------------------------------------------------------- /securitybot/commands.py: -------------------------------------------------------------------------------- 1 | ''' 2 | File for securitybot commands. 3 | 4 | Each command function takes a user and arguments as its arguments. 5 | It also has `bot`, a reference to the bot that called it. 6 | They return True upon success and False upon failure, or just None 7 | if the command doesn't have success/failure messages. 8 | ''' 9 | import re 10 | 11 | from datetime import timedelta 12 | 13 | import securitybot.ignored_alerts as ignored_alerts 14 | from securitybot.util import create_new_alert 15 | 16 | def hi(bot, user, args): 17 | '''Says hello to a user.''' 18 | bot.chat.message_user(user, bot.messages['hi'].format(user.get_name())) 19 | 20 | def help(bot, user, args): 21 | '''Prints help for each command.''' 22 | msg = '{0}\n\n'.format(bot.messages['help_header']) 23 | for name, info in sorted(bot.commands.items()): 24 | if not info['hidden'] or '-a' in args: 25 | msg += '`{0}`: {1}\n'.format(name, info['info']) 26 | if info['usage']: 27 | usage_str = '\n'.join(['> \t' + s for s in info['usage']]) 28 | msg += '> {0}:\n{1}\n'.format(bot.messages['help_usage'], usage_str) 29 | msg += bot.messages['help_footer'] 30 | bot.chat.message_user(user, msg) 31 | 32 | def add_to_blacklist(bot, user, args): 33 | '''Adds a user to the blacklist.''' 34 | name = user['name'] 35 | if not bot.blacklist.is_present(name): 36 | bot.blacklist.add(name) 37 | return True 38 | return False 39 | 40 | def remove_from_blacklist(bot, user, args): 41 | '''Removes a user from the blacklist.''' 42 | name = user['name'] 43 | if bot.blacklist.is_present(name): 44 | bot.blacklist.remove(name) 45 | return True 46 | return False 47 | 48 | def positive_response(bot, user, args): 49 | '''Registers a postive response from a user.''' 50 | user.positive_response(' '.join(args)) 51 | 52 | def negative_response(bot, user, args): 53 | '''Registers a negative response from a user.''' 54 | user.negative_response(' '.join(args)) 55 | 56 | TIME_REGEX = re.compile(r'([0-9]+h)?([0-9]+m)?', flags=re.IGNORECASE) 57 | OUTATIME = timedelta() 58 | TIME_LIMIT = timedelta(hours=4) 59 | 60 | def ignore(bot, user, args): 61 | '''Ignores a specific alert for a user for some period of time.''' 62 | if len(args) != 2: 63 | return False 64 | 65 | which, time = args 66 | 67 | # Find correct task in user object 68 | task = None 69 | if which == 'last' and user.tasks: 70 | task = user.tasks[-1] 71 | elif which == 'current' and user.pending_task: 72 | task = user.pending_task 73 | if task is None: 74 | return False 75 | 76 | # Parse given time using above regex 77 | match = TIME_REGEX.match(time) 78 | if not (match or match.group(0)): 79 | return False 80 | # Parse time returned by regex, snipping off the trailing letter 81 | hours = int(match.group(1)[:-1]) if match.group(1) else 0 82 | minutes = int(match.group(2)[:-1]) if match.group(2) else 0 83 | # Build and cap time if needed 84 | ignoretime = timedelta(hours=hours, minutes=minutes) 85 | if ignoretime > TIME_LIMIT: 86 | bot.chat.message_user(user, bot.messages['ignore_time']) 87 | ignoretime = TIME_LIMIT 88 | elif ignoretime <= OUTATIME: 89 | bot.chat.message_user(user, bot.messages['ignore_no_time']) 90 | return False 91 | 92 | ignored_alerts.ignore_task(user['name'], task.title, 'ignored', ignoretime) 93 | return True 94 | 95 | def test(bot, user, args): 96 | '''Creates a new test alert in Maniphest for a user.''' 97 | create_new_alert('testing_alert', user['name'], 'Testing alert', 'Testing Securitybot') 98 | 99 | return True 100 | -------------------------------------------------------------------------------- /securitybot/tasker/tasker.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A system for retrieving and assigning tasks for the bot as well as updating 3 | their statuses once acted up. This file contains two abstract classes, 4 | Tasker and Task, which define a class to manage tasks and a task class 5 | respectively. 6 | ''' 7 | __author__ = 'Alex Bertsch' 8 | __email__ = 'abertsch@dropbox.com' 9 | 10 | from abc import ABCMeta, abstractmethod 11 | from securitybot.util import enum 12 | 13 | class Tasker(object): 14 | ''' 15 | A simple interface to retrieve tasks on which the bot should act upon. 16 | ''' 17 | __metaclass__ = ABCMeta 18 | 19 | @abstractmethod 20 | def get_new_tasks(self): 21 | # type: () -> List[Task] 22 | ''' 23 | Returns a list of new Task objects that need to be acted upon, i.e. 24 | the intial message needs to be sent out to the alertee. 25 | ''' 26 | pass 27 | 28 | @abstractmethod 29 | def get_active_tasks(self): 30 | # type: () -> List[Task] 31 | ''' 32 | Returns a list of Task objects for which the alertees have been 33 | contacted but have not replied. Periodically this list should be polled 34 | and stale tasks should have their alertees pinged. 35 | ''' 36 | pass 37 | 38 | @abstractmethod 39 | def get_pending_tasks(self): 40 | # type: () -> List[Task] 41 | ''' 42 | Retrieves a list of tasks for which the user has responded and it now 43 | waiting for manual closure. 44 | ''' 45 | pass 46 | 47 | # Task status levels 48 | STATUS_LEVELS = enum('OPEN', 'INPROGRESS', 'VERIFICATION') 49 | 50 | class Task(object): 51 | __metaclass__ = ABCMeta 52 | 53 | def __init__(self, title, username, reason, description, url, performed, comment, 54 | authenticated, status): 55 | # type: (str, str, str, str, str, bool, str, bool, int) -> None 56 | ''' 57 | Creates a new Task for an alert that should go to `username` and is 58 | currently set to `status`. 59 | 60 | Args: 61 | title (str): The title of this task. 62 | username (str): The user who should be alerted from the Task. 63 | reason (str): The reason that the alert was fired. 64 | description (str): A description of the alert in question. 65 | url (str): A URL in which more information can be found about the 66 | alert itself, not the Task. 67 | performed (bool): Whether or not the user performed the action that 68 | caused this alert. 69 | comment (str): The user's comment on why the action occured. 70 | authenticated (bool): Whether 2FA has suceeded. 71 | status (enum): See `STATUS_LEVELS` from above. 72 | ''' 73 | self.title = title 74 | self.username = username 75 | self.reason = reason 76 | self.description = description 77 | self.url = url 78 | self.performed = performed 79 | self.comment = comment 80 | self.authenticated = authenticated 81 | self.status = status 82 | 83 | @abstractmethod 84 | def set_open(self): 85 | # type: () -> None 86 | ''' 87 | Sets this task to be open and performs any needed actions to ensure that 88 | the corresponding tasker will be able to properly see it as such. 89 | ''' 90 | pass 91 | 92 | @abstractmethod 93 | def set_in_progress(self): 94 | # type: () -> None 95 | ''' 96 | Sets this task to be in progress and performs any needed actions to 97 | ensure the corresponding tasker will be able to see it as such. 98 | ''' 99 | pass 100 | 101 | @abstractmethod 102 | def set_verifying(self): 103 | # type: () -> None 104 | ''' 105 | Sets this task to be waiting for verification and performs and needed 106 | actions to ensure that the corresponding tasker sees it as such. 107 | ''' 108 | pass 109 | -------------------------------------------------------------------------------- /tests/util_test.py: -------------------------------------------------------------------------------- 1 | from unittest2 import TestCase 2 | 3 | from datetime import datetime, timedelta 4 | import securitybot.util as util 5 | 6 | class VarTest(TestCase): 7 | def test_hours(self): 8 | assert util.OPENING_HOUR < util.CLOSING_HOUR, 'Closing hour must be after opening hour.' 9 | 10 | class NamedTupleTest(TestCase): 11 | def test_empty(self): 12 | tup = util.tuple_builder() 13 | assert tup.answer is None 14 | assert tup.text == '' 15 | 16 | def test_full(self): 17 | tup = util.tuple_builder(True, 'Yes') 18 | assert tup.answer is True 19 | assert tup.text == 'Yes' 20 | 21 | class BusinessHoursTest(TestCase): 22 | def test_weekday(self): 23 | '''Test business hours during a weekday.''' 24 | # 18 July 2016 is a Monday. If this changes, please contact the IERS. 25 | morning = datetime(year=2016, month=7, day=18, hour=util.OPENING_HOUR, 26 | tzinfo=util.LOCAL_TZ) 27 | assert util.during_business_hours(morning) 28 | noon = datetime(year=2016, month=7, day=18, hour=12, tzinfo=util.LOCAL_TZ) 29 | assert util.during_business_hours(noon), \ 30 | 'This may fail if noon is no longer during business hours.' 31 | afternoon = datetime(year=2016, month=7, day=18, hour=util.CLOSING_HOUR - 1, 32 | minute=59, second=59, tzinfo=util.LOCAL_TZ) 33 | assert util.during_business_hours(afternoon) 34 | 35 | breakfast = datetime(year=2016, month=7, day=18, hour=util.OPENING_HOUR - 1, minute=59, 36 | second=59, tzinfo=util.LOCAL_TZ) 37 | assert not util.during_business_hours(breakfast) 38 | supper = datetime(year=2016, month=7, day=18, hour=util.CLOSING_HOUR, 39 | tzinfo=util.LOCAL_TZ) 40 | assert not util.during_business_hours(supper) 41 | 42 | def test_weekend(self): 43 | '''Test "business hours" during a weekend.''' 44 | # As such, 17 July 2016 is a Sunday. 45 | sunday_morning = datetime(year=2016, month=7, day=17, hour=util.OPENING_HOUR, 46 | tzinfo=util.LOCAL_TZ) 47 | assert not util.during_business_hours(sunday_morning) 48 | 49 | class ExpirationTimeTest(TestCase): 50 | def test_same_day(self): 51 | '''Test time delta within the same day.''' 52 | date = datetime(year=2016, month=7, day=18, hour=util.OPENING_HOUR, tzinfo=util.LOCAL_TZ) 53 | td = timedelta(hours=((util.CLOSING_HOUR - util.OPENING_HOUR) % 24) / 2) 54 | after = date + td 55 | assert util.get_expiration_time(date, td) == after 56 | 57 | def test_next_weekday(self): 58 | '''Test time delta overnight.''' 59 | date = datetime(year=2016, month=7, day=18, hour=util.CLOSING_HOUR - 1, 60 | tzinfo=util.LOCAL_TZ) 61 | next_date = datetime(year=2016, month=7, day=19, hour=util.OPENING_HOUR + 1, 62 | tzinfo=util.LOCAL_TZ) 63 | assert util.get_expiration_time(date, timedelta(hours=2)) == next_date 64 | 65 | def test_edge_weekday(self): 66 | '''Test time delta overnight just barely within range.''' 67 | date = datetime(year=2016, month=7, day=18, hour=util.CLOSING_HOUR - 1, minute=59, 68 | second=59, tzinfo=util.LOCAL_TZ) 69 | td = timedelta(seconds=1) 70 | after = datetime(year=2016, month=7, day=19, hour=util.OPENING_HOUR, 71 | tzinfo=util.LOCAL_TZ) 72 | assert util.get_expiration_time(date, td) == after 73 | 74 | def test_next_weekend(self): 75 | '''Test time delta over a weekend.''' 76 | date = datetime(year=2016, month=7, day=15, hour=util.CLOSING_HOUR - 1, 77 | tzinfo=util.LOCAL_TZ) 78 | next_date = datetime(year=2016, month=7, day=18, hour=util.OPENING_HOUR + 1, 79 | tzinfo=util.LOCAL_TZ) 80 | assert util.get_expiration_time(date, timedelta(hours=2)) == next_date 81 | 82 | def test_edge_weekend(self): 83 | '''Test time delta over a weekend just barely within range.''' 84 | date = datetime(year=2016, month=7, day=15, hour=util.CLOSING_HOUR - 1, minute=59, 85 | second=59, tzinfo=util.LOCAL_TZ) 86 | td = timedelta(seconds=1) 87 | after = datetime(year=2016, month=7, day=18, hour=util.OPENING_HOUR, 88 | tzinfo=util.LOCAL_TZ) 89 | assert util.get_expiration_time(date, td) == after 90 | -------------------------------------------------------------------------------- /securitybot/chat/slack.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A wrapper over the Slack API. 3 | ''' 4 | __author__ = 'Alex Bertsch' 5 | __email__ = 'abertsch@dropbox.com' 6 | 7 | import logging 8 | from slackclient import SlackClient 9 | import json 10 | 11 | from securitybot.user import User 12 | from securitybot.chat.chat import Chat, ChatException 13 | 14 | from typing import Any, Dict, List 15 | 16 | class Slack(Chat): 17 | ''' 18 | A wrapper around the Slack API designed for Securitybot. 19 | ''' 20 | def __init__(self, username, token, icon_url): 21 | # type: (str, str, str) -> None 22 | ''' 23 | Constructs the Slack API object using the bot's username, a Slack 24 | token, and a URL to what the bot's profile pic should be. 25 | ''' 26 | self._username = username 27 | self._icon_url = icon_url 28 | 29 | self._slack = SlackClient(token) 30 | self._validate() 31 | 32 | def _validate(self): 33 | # type: () -> None 34 | '''Validates Slack API connection.''' 35 | response = self._api_call('api.test') 36 | if not response['ok']: 37 | raise ChatException('Unable to connect to Slack API.') 38 | logging.info('Connection to Slack API successful!') 39 | 40 | def _api_call(self, method, **kwargs): 41 | # type: (str, **Any) -> Dict[str, Any] 42 | ''' 43 | Performs a _validated_ Slack API call. After performing a normal API 44 | call using SlackClient, validate that the call returned 'ok'. If not, 45 | log and error. 46 | 47 | Args: 48 | method (str): The API endpoint to call. 49 | **kwargs: Any arguments to pass on to the request. 50 | Returns: 51 | (dict): Parsed JSON from the response. 52 | ''' 53 | response = self._slack.api_call(method, **kwargs) 54 | if not ('ok' in response and response['ok']): 55 | if kwargs: 56 | logging.error('Bad Slack API request on {} with {}'.format(method, kwargs)) 57 | else: 58 | logging.error('Bad Slack API request on {}'.format(method)) 59 | return response 60 | 61 | def connect(self): 62 | # type: () -> None 63 | '''Connects to the chat system.''' 64 | logging.info('Attempting to start Slack RTM session.') 65 | if self._slack.rtm_connect(): 66 | logging.info('Slack RTM connection successful.') 67 | else: 68 | raise ChatException('Unable to start Slack RTM session') 69 | 70 | def get_users(self): 71 | # type: () -> List[Dict[str, Any]] 72 | ''' 73 | Returns a list of all users in the chat system. 74 | 75 | Returns: 76 | A list of dictionaries, each dictionary representing a user. 77 | The rest of the bot expects the following minimal format: 78 | { 79 | "name": The username of a user, 80 | "id": A user's unique ID in the chat system, 81 | "profile": A dictionary representing a user with at least: 82 | { 83 | "first_name": A user's first name 84 | } 85 | } 86 | ''' 87 | return self._api_call('users.list')['members'] 88 | 89 | def get_messages(self): 90 | # type () -> List[Dict[str, Any]] 91 | ''' 92 | Gets a list of all new messages received by the bot in direct 93 | messaging channels. That is, this function ignores all messages 94 | posted in group chats as the bot never interacts with those. 95 | 96 | Each message should have the following format, minimally: 97 | { 98 | "user": The unique ID of the user who sent a message. 99 | "text": The text of the received message. 100 | } 101 | ''' 102 | events = self._slack.rtm_read() 103 | messages = [e for e in events if e['type'] == 'message'] 104 | return [m for m in messages if 'user' in m and m['channel'].startswith('D')] 105 | 106 | def send_message(self, channel, message): 107 | # type: (Any, str) -> None 108 | ''' 109 | Sends some message to a desired channel. 110 | As channels are possibly chat-system specific, this function has a horrible 111 | type signature. 112 | ''' 113 | self._api_call('chat.postMessage', channel=channel, 114 | text=message, 115 | username=self._username, 116 | as_user=False, 117 | icon_url=self._icon_url) 118 | 119 | def message_user(self, user, message): 120 | # type: (User, str) -> None 121 | ''' 122 | Sends some message to a desired user, using a User object and a string message. 123 | ''' 124 | channel = self._api_call('im.open', user=user['id'])['channel']['id'] 125 | self.send_message(channel, message) 126 | -------------------------------------------------------------------------------- /frontend/securitybot_frontend.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | ''' 3 | Front-end for the Securitybot database. 4 | ''' 5 | # Python includes 6 | import argparse 7 | from csv import reader 8 | import logging 9 | import os 10 | 11 | # Tornado includes 12 | import tornado.httpserver 13 | import tornado.ioloop 14 | import tornado.netutil 15 | import tornado.web 16 | 17 | # Securitybot includes 18 | import securitybot_api as api 19 | 20 | # Typing 21 | from typing import Sequence 22 | 23 | def get_endpoint(handler, defaults, callback): 24 | ''' 25 | Makes a call to an API endpoint, using parameters from default. 26 | ''' 27 | try: 28 | args = {} 29 | for name, default, parser in defaults: 30 | arg = handler.get_argument(name, default=None) 31 | if arg is None: 32 | args[name] = default 33 | else: 34 | args[name] = parser(arg) 35 | handler.write(callback(**args)) 36 | except Exception as e: 37 | handler.write(api.exception_response(e)) 38 | 39 | # List of tuples of name, default, parser 40 | QUERY_ARGUMENTS = [ 41 | ('limit', 50, int), 42 | ('titles', None, lambda s: list(reader([s]))[0]), 43 | ('ldap', None, lambda s: list(reader([s]))[0]), 44 | ('status', None, int), 45 | ('performed', None, int), 46 | ('authenticated', None, int), 47 | ('after', None, int), 48 | ('before', None, int), 49 | ] 50 | 51 | class QueryHandler(tornado.web.RequestHandler): 52 | def get(self): 53 | get_endpoint(self, QUERY_ARGUMENTS, api.query) 54 | 55 | IGNORED_ARGUMENTS = [ 56 | ('limit', 50, int), 57 | ('ldap', None, lambda s: list(reader([s]))[0]), 58 | ] 59 | 60 | class IgnoredHandler(tornado.web.RequestHandler): 61 | def get(self): 62 | get_endpoint(self, IGNORED_ARGUMENTS, api.ignored) 63 | 64 | BLACKLIST_ARGUMENTS = [ 65 | ('limit', 50, int), 66 | ] 67 | 68 | class BlacklistHandler(tornado.web.RequestHandler): 69 | def get(self): 70 | get_endpoint(self, BLACKLIST_ARGUMENTS, api.blacklist) 71 | 72 | class NewAlertHandler(tornado.web.RequestHandler): 73 | def post(self): 74 | response = api.build_response() 75 | args = {} 76 | for name in ['title', 'ldap', 'description', 'reason']: 77 | args[name] = self.get_argument(name, default=None) 78 | if args[name] is None: 79 | response['error'] += 'ERROR: {} must be specified!\n'.format(name) 80 | if all(v is not None for v in args.values()): 81 | self.write(api.create_alert(args['ldap'], 82 | args['title'], 83 | args['description'], 84 | args['reason'])) 85 | else: 86 | self.write(response) 87 | 88 | class IndexHandler(tornado.web.RequestHandler): 89 | def get(self): 90 | self.render() 91 | 92 | def render(self): 93 | self.write(self.render_string("templates/index.html")) 94 | 95 | class SecuritybotService(object): 96 | '''Registers handlers and kicks off the HTTPServer and IOLoop''' 97 | 98 | def __init__(self, port): 99 | # type: (str, str, bool) -> None 100 | self.requests = 0 101 | self.port = port 102 | static_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'static/') 103 | self._app = tornado.web.Application([ 104 | (r'/', IndexHandler), 105 | (r'/api/query', QueryHandler), 106 | (r'/api/ignored', IgnoredHandler), 107 | (r'/api/blacklist', BlacklistHandler), 108 | (r'/api/create', NewAlertHandler), 109 | ], 110 | xsrf_cookie=True, 111 | static_path=static_path, 112 | ) 113 | self.server = tornado.httpserver.HTTPServer(self._app) 114 | self.sockets = tornado.netutil.bind_sockets(self.port, '0.0.0.0') 115 | self.server.add_sockets(self.sockets) 116 | for s in self.sockets: 117 | sockname = s.getsockname() 118 | logging.info('Listening on {socket}, port {port}' 119 | .format(socket=sockname[0], port=sockname[1])) 120 | 121 | def start(self): 122 | # type: () -> None 123 | logging.info('Starting.') 124 | tornado.ioloop.IOLoop.instance().start() 125 | 126 | def stop(self): 127 | # type: () -> None 128 | logging.info('Stopping.') 129 | self.server.stop() 130 | 131 | def get_socket(self): 132 | # type: () -> Sequence[str] 133 | return self.sockets[0].getsockname()[:2] 134 | 135 | def init(): 136 | # type: () -> None 137 | logging.basicConfig(level=logging.DEBUG, 138 | format='[%(asctime)s %(levelname)s] %(message)s') 139 | 140 | api.init_api() 141 | 142 | def main(port): 143 | # type: (int) -> None 144 | logging.info('Starting up!') 145 | try: 146 | service = SecuritybotService(port) 147 | 148 | def shutdown(): 149 | logging.info('Shutting down!') 150 | service.stop() 151 | logging.info('Stopped.') 152 | os._exit(0) 153 | 154 | service.start() 155 | except Exception as e: 156 | logging.error('Uncaught exception: {e}'.format(e=e)) 157 | 158 | 159 | if __name__ == '__main__': 160 | init() 161 | 162 | parser = argparse.ArgumentParser(description='Securitybot frontent') 163 | parser.add_argument('--port', dest='port', default='8888', type=int) 164 | args = parser.parse_args() 165 | 166 | main(args.port) 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Securitybot 2 | ### Distributed alerting for the masses! 3 | Securitybot is an open-source implementation of a distributed alerting chat bot, as described in Ryan Huber's [blog post][slack-blog]. 4 | Distributed alerting improves the monitoring efficiency of your security team and can help you catch security incidents faster and more efficiently. 5 | We've tried to remove all Dropbox-isms from this code so that setting up your own instance should be fairly painless. 6 | It should be relatively easy to install the listed requirements in a virtualenv/Docker container and simply have the bot do its thing. 7 | We also provide a simple front end to dive through the database, receive API calls, and create custom alerts for the bot to reach out to people as desired. 8 | 9 | ## Deploying 10 | This guide runs through setting up a Securitybot instance as quickly as possible with no frills. 11 | We'll be connecting it to Slack, SQL, and Duo. 12 | Once we're done, we'll have a file that looks something like `main.py`. 13 | 14 | ### SQL 15 | You'll need a database called `securitybot` on some MySQL server somewhere. 16 | We've provided a function called `init_sql` located in `securitybot/sql.py` that will initialize SQL. 17 | Currently it's set up to use the host `localhost` with user `root` and no password. 18 | You'll need to change this because of course that's not how your database is set up. 19 | 20 | ### Slack 21 | You'll need a token to be able to integrate with Slack. 22 | The best thing to do would be to [create a bot user][bot-user] and use that token for Securitybot. 23 | You'll also want to set up a channel to which the bot will report when users specify that they haven't performed an action. 24 | Find the unique ID for that channel (it'll look similar to `C123456`) and be sure to invite the bot user into that channel, otherwise it won't be able to send messages. 25 | 26 | ### Duo 27 | For Duo, you'll want to create an [Auth API][auth-api] instances, name it something clever, and keep track of the integration key, secret key, and auth API endpoint URI. 28 | 29 | ### Running the bot 30 | Take a look at the provided `main.py` in the root directory for an example on how to use all of these. 31 | Replace all of the global variables with whatever you found above. 32 | If the following were all generated successfully, Securitybot should be up and running. 33 | To test it, message the bot user it's assigned to and say `hi`. 34 | To test the process of dealing with an alert, message `test` to test the bot. 35 | 36 | ## Architecture 37 | Securitybot was designed to be as modular as possible. 38 | This means that it's possible to easily swap out chat systems, 2FA providers, and alerting data sources. 39 | The only system that is tightly integrated with the bot is SQL, but adding support for other databases shouldn't be difficult. 40 | Having a database allows alerts to be persistent and means that the bot doesn't lose (too much) state if there's some transient failure. 41 | 42 | ### Securitybot proper 43 | The bot itself performs a small set of functions: 44 | 45 | 1. Reads messages, interpreting them as commands. 46 | 1. Polls each user object to update their state of applicable. 47 | 1. Grabs new alerts from the database and assigns them to users or escalates on an unknown user. 48 | 49 | Messaging, 2FA, and alert management are provided by configurable modules, and added to the bot upon initialization. 50 | 51 | #### Commands 52 | The bot handles incoming messages as commands. 53 | Command parsing and handling is done in the `Securitybot` class and the commands themselves are provided in two places. 54 | The functions for the commands are defined in `commands.py` and their structure is defined in `commands.yaml` under the `config/` directory. 55 | 56 | ### Messaging 57 | Securitybot is designed to be compatible with a wide variety of messaging systems. 58 | We currently provide bindings for Slack, but feel free to contribute any other plugins, like for Gitter or Zulip, upstream. 59 | Messaging is made possible by `securitybot/chat/chat.py` which provides a small number of functions for querying users in a messaging group, messaging those users, and sending messages to a specific channel/room. 60 | To add bindings for a new messaging system, subclass `Chat`. 61 | 62 | ### 2FA 63 | 2FA support is provided by `auth/auth.py`, which wraps async 2FA in a few functions that enable checking for 2FA capability, starting a 2FA session, and polling the state of the 2FA session. 64 | We provide support for Duo Push via the Duo Auth API, but adding support for a different product or some in-house 2FA solution is as easy as creating a subclass of `Auth`. 65 | 66 | ### Task management 67 | Task management is provided by `tasker/tasker.py` and the `Tasker` class. 68 | Since alerts are logged in an SQL database, the provided Tasker is `SQLTasker`. 69 | This provides support for grabbing new tasks and updating them via individual `Task` objects. 70 | 71 | ### Blacklists 72 | Blacklists are handled by the SQL database, provided in `blacklist/blacklist.py` and the subclass `blacklist/sql_blacklist.py`. 73 | 74 | ### Users 75 | The `User` object provides support for handling user state. 76 | We keep track of whatever information a messaging system gives to us, but really only ever use a user's unique ID and username in order to contact them. 77 | 78 | ### Alerts 79 | Alerts are uniquely identified by a SHA-256 hash which comes from some hash of the event that generated them. 80 | We assume that a SHA-256 hash is sufficiently random for there to be no collisions. 81 | If you encounter a SHA-256 collision, please contact someone at your nearest University and enjoy the fame and fortune it brings upon you. 82 | 83 | ## FAQ 84 | 85 | Please ask us things 86 | 87 | ## Contributing 88 | Contributors must abide by the [Dropbox Contributor License Agreement][cla]. 89 | 90 | ## License 91 | 92 | Copyright 2016 Dropbox, Inc. 93 | 94 | Licensed under the Apache License, Version 2.0 (the "License"); 95 | you may not use this file except in compliance with the License. 96 | You may obtain a copy of the License at 97 | 98 | http://www.apache.org/licenses/LICENSE-2.0 99 | 100 | Unless required by applicable law or agreed to in writing, software 101 | distributed under the License is distributed on an "AS IS" BASIS, 102 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 103 | See the License for the specific language governing permissions and 104 | limitations under the License. 105 | 106 | 107 | 108 | [slack-blog]: https://slack.engineering/distributed-security-alerting-c89414c992d6 "Distributed Alerting" 109 | [bot-user]: https://api.slack.com/bot-users "Slack Bot Users" 110 | [auth-api]: https://duo.com/docs/authapi "Duo Auth API" 111 | [cla]: https://opensource.dropbox.com/cla/ "Dropbox CLA" 112 | -------------------------------------------------------------------------------- /tests/state_machine_test.py: -------------------------------------------------------------------------------- 1 | from unittest2 import TestCase 2 | 3 | from securitybot.state_machine import StateMachine, StateMachineException 4 | 5 | # Helper junk 6 | class Helper(object): 7 | def __init__(self): 8 | self.x = 0 9 | 10 | def increment(self): 11 | self.x += 1 12 | 13 | def x_is_five(self): 14 | return self.x == 5 15 | 16 | class Helper2(object): 17 | def __init__(self): 18 | self.x = 0 19 | self.y = 0 20 | self.z = 0 21 | 22 | def increment_x(self): 23 | self.x += 1 24 | 25 | def x_at_least_two(self): 26 | return self.x >= 2 27 | 28 | def increment_y(self): 29 | self.y += 5 30 | 31 | def increment_z(self): 32 | self.z += 10 33 | 34 | class FSMTest(TestCase): 35 | # Functionality tests 36 | 37 | def test_simple_chain(self): 38 | '''Test most basic transition chain.''' 39 | states = ['one', 'two', 'three'] 40 | transitions = [ 41 | {'source': 'one', 'dest': 'two'}, 42 | {'source': 'two', 'dest': 'three'}, 43 | {'source': 'three', 'dest': 'one'}, 44 | ] 45 | sm = StateMachine(states, transitions, 'one') 46 | assert(str(sm.state) == 'one') 47 | sm.step() 48 | assert(str(sm.state) == 'two') 49 | sm.step() 50 | assert(str(sm.state) == 'three') 51 | sm.step() 52 | assert(str(sm.state) == 'one') 53 | 54 | def test_simple_during(self): 55 | '''Tests a simple action being performed while in a state.''' 56 | helper = Helper() 57 | states = ['one', 'two'] 58 | transitions = [ 59 | {'source': 'one', 'dest': 'two'}, 60 | {'source': 'two', 'dest': 'one'}, 61 | ] 62 | during = { 63 | 'one': helper.increment 64 | } 65 | sm = StateMachine(states, transitions, 'one', during=during) 66 | assert(helper.x == 0) 67 | sm.step() 68 | assert(helper.x == 1) 69 | sm.step() 70 | assert(helper.x == 1) 71 | 72 | def test_simple_on_enter(self): 73 | '''Tests a simple action being performed entering into a state.''' 74 | helper = Helper() 75 | states = ['one', 'two'] 76 | transitions = [ 77 | {'source': 'one', 'dest': 'two'}, 78 | {'source': 'two', 'dest': 'one'}, 79 | ] 80 | on_enter = { 81 | 'two': helper.increment 82 | } 83 | sm = StateMachine(states, transitions, 'one', on_enter=on_enter) 84 | assert(helper.x == 0) 85 | sm.step() 86 | assert(helper.x == 1) 87 | sm.step() 88 | assert(helper.x == 1) 89 | 90 | def test_simple_on_exit(self): 91 | '''Tests a simple action being performed exiting from a state.''' 92 | helper = Helper() 93 | states = ['one', 'two'] 94 | transitions = [ 95 | {'source': 'one', 'dest': 'two'}, 96 | {'source': 'two', 'dest': 'one'}, 97 | ] 98 | on_exit = { 99 | 'one': helper.increment 100 | } 101 | sm = StateMachine(states, transitions, 'one', on_exit=on_exit) 102 | assert(helper.x == 0) 103 | sm.step() 104 | assert(helper.x == 1) 105 | sm.step() 106 | assert(helper.x == 1) 107 | 108 | def test_simple_condition(self): 109 | '''Tests a simple condition check before transitioning.''' 110 | helper = Helper() 111 | states = ['one', 'two'] 112 | transitions = [ 113 | {'source': 'one', 'dest': 'two', 'condition': helper.x_is_five}, 114 | {'source': 'two', 'dest': 'one'}, 115 | ] 116 | during = { 117 | 'one': helper.increment 118 | } 119 | sm = StateMachine(states, transitions, 'one', during=during) 120 | for x in range(5): 121 | assert(helper.x == x) 122 | assert(str(sm.state) == 'one') 123 | sm.step() 124 | assert(helper.x == 5) 125 | assert(str(sm.state) == 'two') 126 | sm.step() 127 | assert(helper.x == 5) 128 | assert(str(sm.state) == 'one') 129 | sm.step() 130 | assert(helper.x == 6) 131 | assert(str(sm.state) == 'one') 132 | sm.step() 133 | assert(helper.x == 7) 134 | assert(str(sm.state) == 'one') 135 | 136 | def test_simple_action(self): 137 | '''Tests a simple action being performed upon transitioning.''' 138 | helper = Helper() 139 | states = ['one', 'two'] 140 | transitions = [ 141 | {'source': 'one', 'dest': 'two', 'action': helper.increment}, 142 | {'source': 'two', 'dest': 'one'} 143 | ] 144 | sm = StateMachine(states, transitions, 'one') 145 | assert(helper.x == 0) 146 | assert(str(sm.state) == 'one') 147 | sm.step() 148 | assert(helper.x == 1) 149 | assert(str(sm.state) == 'two') 150 | 151 | def test_correct_state_actions(self): 152 | ''' 153 | Tests that durings, on_enters, and on_exits are called correctly and 154 | don't interfere with one another. 155 | ''' 156 | helper = Helper2() 157 | states = ['one', 'two'] 158 | transitions = [ 159 | {'source': 'one', 'dest': 'two', 'condition': helper.x_at_least_two}, 160 | {'source': 'two', 'dest': 'one'} 161 | ] 162 | during = { 163 | 'one': helper.increment_x 164 | } 165 | on_enter = { 166 | 'one': helper.increment_y 167 | } 168 | on_exit = { 169 | 'one': helper.increment_z 170 | } 171 | sm = StateMachine(states, transitions, 'one', during=during, on_enter=on_enter, 172 | on_exit=on_exit) 173 | sm.step() 174 | assert(helper.x == 1) 175 | assert(helper.y == 0) 176 | assert(helper.z == 0) 177 | sm.step() 178 | assert(helper.x == 2) 179 | assert(helper.y == 0) 180 | assert(helper.z == 10) 181 | sm.step() 182 | assert(helper.x == 2) 183 | assert(helper.y == 5) 184 | assert(helper.z == 10) 185 | 186 | # Invalid input error notification tests 187 | 188 | def test_duplicate_states(self): 189 | states = ['one', 'one'] 190 | try: 191 | StateMachine(states, {}, 'one') 192 | except StateMachineException: 193 | return 194 | assert False, "No exception thrown on duplicate state names." 195 | 196 | def test_invalid_initial_state(self): 197 | try: 198 | StateMachine([], {}, 'foo') 199 | except StateMachineException: 200 | return 201 | assert False, "No exception thrown on invalid initial state name." 202 | 203 | def test_invalid_transition_source_name(self): 204 | states = ['one'] 205 | transitions = [ 206 | {'source': 'foo', 'dest': 'one'} 207 | ] 208 | try: 209 | StateMachine(states, transitions, 'one') 210 | except StateMachineException: 211 | return 212 | assert False, "No exception thrown on invalid transition source name." 213 | 214 | def test_invalid_transition_dest_name(self): 215 | states = ['one'] 216 | transitions = [ 217 | {'source': 'one', 'dest': 'foo'} 218 | ] 219 | try: 220 | StateMachine(states, transitions, 'one') 221 | except StateMachineException: 222 | return 223 | assert False, "No exception thrown on invalid transition source name." 224 | -------------------------------------------------------------------------------- /frontend/static/js/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Oh javascript, how I haven't missed you. 3 | * @author = Alex Bertsch 4 | * @email = abertsch@dropbox.com 5 | * 6 | * This is the main JavaScript source file for the Securitybot front end. 7 | * This should be placed _last_ in the HTML file. 8 | * It relies on jQuery and DataTables. 9 | */ 10 | 11 | function removeEmpty(obj) { 12 | for (let key in obj) { 13 | if (obj[key] === "") { 14 | delete obj[key]; 15 | } 16 | } 17 | } 18 | 19 | function updateTable(id, arr) { 20 | let table = $(id).dataTable(); 21 | table.fnClearTable(); 22 | if (arr.length > 0) { 23 | table.fnAddData(arr); 24 | } 25 | } 26 | 27 | function setVisible(id) { 28 | document.getElementById(id).style.visibility = "visible"; 29 | } 30 | 31 | function setHidden(id) { 32 | document.getElementById(id).style.visibility = "hidden"; 33 | } 34 | 35 | function hideAlert() { 36 | document.getElementById("globalAlert").style.display = "none"; 37 | } 38 | 39 | function showAlert() { 40 | document.getElementById("globalAlert").style.display = "block"; 41 | } 42 | 43 | function presentAlert(style, message) { 44 | let alert = document.getElementById("globalAlert"); 45 | showAlert(); 46 | alert.innerHTML = message; 47 | alert.className = "alert " + style; 48 | setTimeout(hideAlert, 5000); 49 | } 50 | 51 | /** 52 | * Submit actions for the alerts form. 53 | * Parses form data and sends a GET request to update the table. 54 | */ 55 | function submitAlerts(form) { 56 | // Read data from form 57 | // There's absolutely a better way to do this. 58 | let data = {}; 59 | data["limit"] = form.alertsLimit.value; 60 | data["titles"] = form.alertsTitles.value; 61 | data["ldap"] = form.alertsLdap.value; 62 | 63 | data["status"] = form.querySelector("input[name=\"alertsStatus\"]:checked").value; 64 | data["performed"] = form.querySelector("input[name=\"alertsPerformed\"]:checked").value; 65 | data["authenticated"] = form.querySelector("input[name=\"alertsAuthenticated\"]:checked").value; 66 | // Remove "any"s 67 | for (let key of ["status", "performed", "authenticated"]) { 68 | if (data[key] === "any") { 69 | delete data[key]; 70 | } 71 | } 72 | 73 | data["after"] = form.alertsAfter.value; 74 | data["before"] = form.alertsBefore.value; 75 | // Parse dates 76 | for (let key of ["after", "before"]) { 77 | if (data[key]) { 78 | data[key] = Date.parse(data[key]) / 1000; 79 | } 80 | } 81 | 82 | removeEmpty(data); 83 | 84 | // Use jQuery for a GET request because I'm so sorry 85 | setVisible("alertsLoading"); 86 | $.get("api/query", data, updateAlerts); 87 | 88 | // Prevent page from updating 89 | return false; 90 | } 91 | 92 | let statuses = { 93 | 0: "New", 94 | 1: "In progress", 95 | 2: "Complete", 96 | }; 97 | 98 | /** 99 | * Actually update the alerts form using JSON data from an API request. 100 | */ 101 | function updateAlerts(data) { 102 | setHidden("alertsLoading"); 103 | if (!data["ok"]) { 104 | presentAlert("alert-danger", "Error: " + data["error"]); 105 | return; 106 | } 107 | 108 | // Convert various values 109 | for (let alert of data["content"]["alerts"]) { 110 | // Timestamp => ISO string 111 | alert["event_time"] = new Date(parseInt(alert["event_time"]) * 1000).toISOString(); 112 | 113 | // Status => readable string 114 | alert["status"] = statuses[alert["status"]]; 115 | 116 | // Cast integers to booleans 117 | alert["performed"] = Boolean(alert["performed"]); 118 | alert["authenticated"] = Boolean(alert["authenticated"]); 119 | } 120 | 121 | updateTable("#alertsTable", data["content"]["alerts"]); 122 | } 123 | 124 | /** 125 | * Form submission handler for querying ignored alerts. 126 | */ 127 | function submitIgnored(form) { 128 | let data = {}; 129 | data["limit"] = form.ignoredLimit.value; 130 | data["ldap"] = form.ignoredLdap.value; 131 | 132 | removeEmpty(data); 133 | 134 | setVisible("ignoredLoading"); 135 | $.get("api/ignored", data, updateIgnored); 136 | 137 | return false; 138 | } 139 | 140 | /** 141 | * Updates the ignored table with a JSON response. 142 | */ 143 | function updateIgnored(data) { 144 | setHidden("ignoredLoading"); 145 | if (!data["ok"]) { 146 | presentAlert("alert-danger", "Error: " + data["error"]); 147 | return; 148 | } 149 | 150 | // Convert dates to something readable 151 | for (let alert of data["content"]["ignored"]) { 152 | alert["until"] = new Date(parseInt(alert["until"]) * 1000).toISOString(); 153 | } 154 | 155 | updateTable("#ignoredTable", data["content"]["ignored"]); 156 | } 157 | 158 | /** 159 | * Form submission handler for the blacklist. 160 | */ 161 | function submitBlacklist(form) { 162 | let data = {}; 163 | data["limit"] = form.blacklistLimit.value; 164 | 165 | removeEmpty(data); 166 | 167 | setVisible("blacklistLoading"); 168 | $.get("api/blacklist", data, updateBlacklist); 169 | 170 | return false; 171 | } 172 | 173 | /** 174 | * Updates the blacklist table with JSON from an API response. 175 | */ 176 | function updateBlacklist(data) { 177 | setHidden("blacklistLoading"); 178 | if (!data["ok"]) { 179 | presentAlert("alert-danger", "Error: " + data["error"]); 180 | return; 181 | } 182 | 183 | updateTable("#blacklistTable", data["content"]["blacklist"]); 184 | } 185 | 186 | /** 187 | * Easter egg to bother use if they toggle the one column in the table. 188 | */ 189 | function didYouReallyToggleThat(item) { 190 | let box = $(item); 191 | if (!box.prop("checked")) { 192 | alert("Really?"); 193 | } 194 | } 195 | 196 | /** 197 | * Submission call for custom alert form. 198 | */ 199 | function submitCustom(form) { 200 | let data = {}; 201 | data["title"] = form.customTitle.value; 202 | data["ldap"] = form.customLdap.value; 203 | data["description"] = form.customDescription.value; 204 | data["reason"] = form.customReason.value; 205 | 206 | // Check for empty title or ldap 207 | let hasError = false; 208 | if (data["title"] === "") { 209 | form.customTitle.parentElement.parentElement.classList.add("has-error"); 210 | hasError = true; 211 | } else { 212 | form.customTitle.parentElement.parentElement.classList.remove("has-error"); 213 | } 214 | if (data["ldap"] === "") { 215 | form.customLdap.parentElement.parentElement.classList.add("has-error"); 216 | hasError = true; 217 | } else { 218 | form.customLdap.parentElement.parentElement.classList.remove("has-error"); 219 | } 220 | 221 | if (hasError) { 222 | presentAlert("alert-danger", "Please fill in the fields highlighted in red."); 223 | } else { 224 | setVisible("customLoading"); 225 | $.post("api/create", data, validateCustom); 226 | } 227 | 228 | return false; 229 | } 230 | 231 | /** 232 | * Validate custom alert creation. 233 | */ 234 | function validateCustom(data) { 235 | setHidden("customLoading"); 236 | if (data["ok"]) { 237 | presentAlert("alert-success", "Success: Alert created!"); 238 | } else { 239 | presentAlert("alert-danger", "Error creating alert: " + data["error"]); 240 | } 241 | } 242 | 243 | // Page initialization 244 | $(document).ready(function() { 245 | // Initialize DataTables 246 | let alertsTable = $("#alertsTable").DataTable({ 247 | columns: [ 248 | { name: "title", data: "title", title: "Title" }, 249 | { name: "username", data: "ldap", title: "Username" }, 250 | { name: "description", data: "description", title: "Description" }, 251 | { name: "reason", data: "reason", title: "Reason" }, 252 | { name: "performed", data: "performed", title: "Performed" }, 253 | { name: "authenticated", data: "authenticated", title: "Authenticated" }, 254 | { name: "url", data: "url", title: "URL" }, 255 | { name: "status", data: "status", title: "Status" }, 256 | { name: "event_time", data: "event_time", title: "Event time" }, 257 | { name: "hash", data: "hash", title: "Hash", visible: false } 258 | ], 259 | colReorder: true, 260 | }); 261 | 262 | let ignoredTable = $("#ignoredTable").DataTable({ 263 | columns: [ 264 | { name: "title", data: "title", title: "Title" }, 265 | { name: "username", data: "ldap", title: "Username" }, 266 | { name: "until", data: "until", title: "Ignored until" }, 267 | { name: "reason", data: "reason", title: "Reason" }, 268 | ], 269 | colReorder: true, 270 | }); 271 | 272 | let blacklistTable = $("#blacklistTable").DataTable({ 273 | columns: [ 274 | { name: "username", data: "ldap", title: "Username" }, 275 | ], 276 | colReorder: true, 277 | }); 278 | 279 | let tables = { 280 | alerts: alertsTable, 281 | ignored: ignoredTable, 282 | blacklist: blacklistTable, 283 | }; 284 | 285 | // Set up visibility toggles 286 | $(".toggle-vis").on("click", function () { 287 | let box = $(this); 288 | 289 | // Get the column API object 290 | let column = tables[box.attr("parent")].column(box.attr("name") + ":name"); 291 | 292 | // Toggle column visibility 293 | column.visible(!column.visible()); 294 | }); 295 | }); 296 | -------------------------------------------------------------------------------- /securitybot/state_machine.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A simple FSM for controlling user state. 3 | You know, as opposed to all those state machines that _don't_ manage state. 4 | ''' 5 | __author__ = 'Alex Bertsch' 6 | __email__ = 'abertsch@dropbox.com' 7 | 8 | import logging 9 | from collections import defaultdict 10 | 11 | from typing import Callable 12 | 13 | class StateMachine(object): 14 | ''' 15 | A minimal state machine with the ability to declare state transition 16 | conditions, functions to call when entering and exiting any state, and 17 | functions to call while in any particular state. All of this is done 18 | through a single `step` function, which performs all tasks and potentially 19 | advances to the next state if a condition is met. 20 | 21 | Essentially, this state machine is "eager", it always wants to transition 22 | to the next state if possible. You could make a main loop out of simply 23 | chaining state together with various conditions, but this would probably 24 | make for a terrible UI state machine. 25 | ''' 26 | 27 | def __init__(self, states, transitions, initial, during=None, on_enter=None, 28 | on_exit=None): 29 | ''' 30 | Creates a new state machine. The `during`, `on_enter`, and `on_exit` 31 | dictionaries are all optional. Additionally, each is free to have 32 | as few or as many of each state as desired, i.e. leaving out states 33 | is fine. 34 | Args: 35 | states (List[str]): A list of all possible states in the FSM. 36 | transitions (List[Dict[str, function]]): Dictionaries of transitions 37 | and conditions. Each dictionary must contain the following keys: 38 | source (str): The source state of the transition. 39 | dest (str): The destination state of the transition. 40 | Each dictionary may contain the following keys: 41 | condition (function): A condition that must be true for the 42 | transition to occur. If no condition is provided then the 43 | state machine will transition on a step. 44 | action (function): A function to be executed while the 45 | transition occurs. 46 | during (Dict[str, function]): A mapping of states to functions to 47 | execute while in that state. 48 | initial (str): The state to start in. 49 | on_enter (Dict[str, function]): A mapping of states to functions to 50 | execute when entering that state. 51 | on_exit (Dict[str, function]): A mapping of states to functions to 52 | execute when exiting from that state. 53 | ''' 54 | if during is None: 55 | during = {} 56 | if on_enter is None: 57 | on_enter = {} 58 | if on_exit is None: 59 | on_exit = {} 60 | 61 | # Build states 62 | if sorted(list(set(states))) != sorted(states): 63 | raise StateMachineException('Duplicate state names encountered:\n{0}'.format(states)) 64 | 65 | self._states = {} 66 | for state in states: 67 | self._states[state] = State(state, 68 | during.get(state, None), 69 | on_enter.get(state, None), 70 | on_exit.get(state, None) 71 | ) 72 | 73 | # Set initial state 74 | if initial not in self._states: 75 | raise StateMachineException('Invalid initial state: {0}'.format(initial)) 76 | self.state = self._states[initial] 77 | 78 | # Build transitions 79 | self._transitions = defaultdict(list) 80 | for transition in transitions: 81 | # Validate transition for correct states 82 | if transition['source'] not in self._states: 83 | raise StateMachineException('Invalid source state: {0}' 84 | .format(transition['source'])) 85 | if transition['dest'] not in self._states: 86 | raise StateMachineException('Invalid destination state: {0}' 87 | .format(transition['dest'])) 88 | 89 | source_state = self._states[transition['source']] 90 | dest_state = self._states[transition['dest']] 91 | self._transitions[transition['source']].append(Transition(source_state, 92 | dest_state, 93 | transition.get('condition', None), 94 | transition.get('action', None) 95 | )) 96 | 97 | def step(self): 98 | # type: () -> None 99 | ''' 100 | Performs a step in the state machine. 101 | Each step iterates over the current state's `during` function then checks all 102 | possible transition paths, evaluates their condition, and transitions if possible. 103 | The next state is which transition condition was true first or the current state 104 | if no conditions were true. 105 | ''' 106 | self.state.during() 107 | 108 | for transition in self._transitions[self.state.name]: 109 | if transition.condition(): 110 | logging.debug('Transitioning: {0}'.format(transition)) 111 | transition.action() 112 | self.state.on_exit() 113 | self.state = transition.dest 114 | self.state.on_enter() 115 | break 116 | 117 | class State(object): 118 | ''' 119 | A simple representation of a state in `StateMachine`. 120 | Each state has a function to perform while it's active, when it's entered 121 | into, and when it's exited. These functions may be None. 122 | ''' 123 | def __init__(self, name, during, on_enter, on_exit): 124 | # type: (str, Callable[..., None], Callable[..., None], Callable[..., None]) -> None 125 | ''' 126 | Args: 127 | name (str): The name of this state. 128 | during (function): A function to call while this state is active. 129 | on_enter (function): A function to call when transitioning into 130 | this state. 131 | on_exit (function): A function to call when transitioning out of 132 | this state. 133 | ''' 134 | self.name = name 135 | self._during = during 136 | self._on_enter = on_enter 137 | self._on_exit = on_exit 138 | 139 | def __repr__(self): 140 | # type: () -> str 141 | return "State({0}, {1}, {2}, {3})".format(self.name, 142 | self._during, 143 | self._on_enter, 144 | self._on_exit 145 | ) 146 | 147 | def __str__(self): 148 | # type: () -> str 149 | return self.name 150 | 151 | def during(self): 152 | # type: () -> None 153 | if self._during is not None: 154 | self._during() 155 | 156 | def on_enter(self): 157 | # type: () -> None 158 | if self._on_enter is not None: 159 | self._on_enter() 160 | 161 | def on_exit(self): 162 | # type: () -> None 163 | if self._on_exit is not None: 164 | self._on_exit() 165 | 166 | class Transition(object): 167 | ''' 168 | A transition object to move between states. Each transition object holds 169 | a reference to its source and destination states, as well as the condition 170 | function it requires for transitioning and the action to perform upon 171 | transitioning. 172 | ''' 173 | 174 | def __init__(self, source, dest, condition, action): 175 | # type: (State, State, Callable[..., bool], Callable[..., None]) -> None 176 | ''' 177 | Args: 178 | source (State): The source State for this transition. 179 | dest (State): The destination State for this transition. 180 | condition (function): The transitioning condition callback. 181 | action (function): An action to perform upon transitioning. 182 | ''' 183 | self.source = source 184 | self.dest = dest 185 | self._condition = condition 186 | self._action = action 187 | 188 | def __repr__(self): 189 | # type: () -> str 190 | return "Transition({0}, {1}, {2}, {3})".format(repr(self.source), 191 | repr(self.dest), 192 | self._condition, 193 | self._action 194 | ) 195 | 196 | def __str__(self): 197 | # type: () -> str 198 | return "{0} => {1}".format(self.source, self.dest) 199 | 200 | def condition(self): 201 | # type: () -> bool 202 | # Conditions default to True if none is provided 203 | return True if self._condition is None else self._condition() 204 | 205 | def action(self): 206 | # type: () -> None 207 | if self._action is not None: 208 | self._action() 209 | 210 | class StateMachineException(Exception): 211 | pass 212 | -------------------------------------------------------------------------------- /frontend/securitybot_api.py: -------------------------------------------------------------------------------- 1 | ''' 2 | API for the Securitybot database. 3 | ''' 4 | # Securitybot imports 5 | from securitybot.sql import SQLEngine, SQLEngineException, init_sql 6 | from securitybot.util import create_new_alert 7 | 8 | # Typing 9 | from typing import Any, Dict, List, Sequence 10 | 11 | def init_api(): 12 | # type: () -> None 13 | init_sql() 14 | 15 | # API functions 16 | ''' 17 | Every API call returns a JSON response through the web endpoints. 18 | These functions themselves return dictionaries. 19 | The generic dictionary format is as follows: 20 | { 21 | "ok": [True, False] # Whether or not the API call was successful 22 | "error": str # Error from the API endpoint 23 | "info": str # Additional information about the API call 24 | "content": Dict # Full results from the API call. 25 | } 26 | The value of "content" varies on the API call. 27 | ''' 28 | 29 | def build_response(): 30 | # type: () -> Dict[str, Any] 31 | '''Builds an empty response dictionary.''' 32 | return { 33 | 'ok': False, 34 | 'error': '', 35 | 'info': '', 36 | 'content': {} 37 | } 38 | 39 | def exception_response(e): 40 | res = build_response() 41 | res['error'] = str(e) 42 | return res 43 | 44 | def build_arguments(default, args, response): 45 | # type: (Dict[str, Any], Dict[str, Any], Dict[str, Any]) -> None 46 | # Add all default arguments to args 47 | for arg in default: 48 | if arg not in args: 49 | args[arg] = default[arg] 50 | 51 | # Warn about additional arguments 52 | for arg in args: 53 | if arg not in default: 54 | response['info'] += 'WARNING: unknown argument {}\n'.format(arg) 55 | 56 | def build_in(clause, num_titles): 57 | # type: (str, int) -> str 58 | return clause.format(','.join(['%s' for _ in range(num_titles)])) 59 | 60 | def build_where(condition, has_where): 61 | # type: (str, bool) -> str 62 | ''' 63 | Builds another part of a where clause depending on whether any other clauses 64 | have been used yet. 65 | ''' 66 | s = 'AND' if has_where else 'WHERE' 67 | return '{0} {1}\n'.format(s, condition) 68 | 69 | def build_query_dict(fields, results): 70 | # type: (List[str], Sequence[Sequence[Any]]) -> List[Dict[str, Any]] 71 | '''Builds a list of dictionaries from the results of a query.''' 72 | return [{field: value for field, value in zip(fields, row)} for row in results] 73 | 74 | # Querying alerts 75 | 76 | ALERTS_QUERY = ''' 77 | SELECT HEX(alerts.hash), 78 | title, 79 | ldap, 80 | reason, 81 | description, 82 | url, 83 | comment, 84 | performed, 85 | authenticated, 86 | status, 87 | event_time 88 | FROM alerts 89 | JOIN user_responses ON alerts.hash = user_responses.hash 90 | JOIN alert_status ON alerts.hash = alert_status.hash 91 | ''' 92 | 93 | ALERTS_FIELDS = ['hash', 94 | 'title', 95 | 'ldap', 96 | 'reason', 97 | 'description', 98 | 'url', 99 | 'comment', 100 | 'performed', 101 | 'authenticated', 102 | 'status', 103 | 'event_time'] 104 | 105 | 106 | 107 | STATUS_WHERE = 'status = %s' 108 | PERFORMED_WHERE = 'performed = %s' 109 | TITLE_IN = 'title IN ({0})' 110 | LDAP_IN = 'ldap IN ({0})' 111 | BEFORE = 'event_time <= FROM_UNIXTIME(%s)' 112 | AFTER = 'event_time >= FROM_UNIXTIME(%s)' 113 | LIMIT = 'LIMIT %s' 114 | 115 | DEFAULT_QUERY_ARGUMENTS = { 116 | 'limit': 50, # max number of alerts to return 117 | 'titles': None, # titles of alerts to return 118 | 'ldap': None, # usernames of alerts to return 119 | 'status': None, # status of alerts to return 120 | 'performed': None, # performed status of alerts to return 121 | 'authenticated': None, # authenticated status of alerts to return 122 | 'after': None, # starting time of alerts to return, as a unix timestamp 123 | 'before': None, # ending time of alerts to return, as a unix timestamp 124 | } 125 | 126 | def query(**kwargs): 127 | # type: (**Any) -> Dict[str, Any] 128 | ''' 129 | Queries the alerts database. 130 | 131 | Args: 132 | **kwargs: Arguments to the API endpoint. 133 | Content: 134 | { 135 | "alerts": List[Dict]: list of dictionaries representing alerts in the database 136 | } 137 | Each alert has all of the fields in QUERY_FIELDS. 138 | ''' 139 | response = build_response() 140 | args = kwargs 141 | build_arguments(DEFAULT_QUERY_ARGUMENTS, args, response) 142 | 143 | # Build query 144 | query = ALERTS_QUERY 145 | params = [] # type: List[Any] 146 | has_where = False 147 | 148 | # Add possible where statements 149 | if args['status'] is not None: 150 | query += build_where(STATUS_WHERE, has_where) 151 | params.append(args['status']) 152 | has_where = True 153 | 154 | if args['performed'] is not None: 155 | query += build_where(PERFORMED_WHERE, has_where) 156 | params.append(args['performed']) 157 | has_where = True 158 | 159 | if args['titles'] is not None: 160 | query += build_where(build_in(TITLE_IN, len(args['titles'])), has_where) 161 | params.extend(args['titles']) 162 | has_where = True 163 | 164 | if args['ldap'] is not None: 165 | query += build_where(build_in(LDAP_IN, len(args['ldap'])), has_where) 166 | params.extend(args['ldap']) 167 | has_where = True 168 | 169 | # Add time bounds 170 | if args['before'] is not None: 171 | query += build_where(BEFORE, has_where) 172 | params.append(args['before']) 173 | has_where = True 174 | if args['after'] is not None: 175 | query += build_where(AFTER, has_where) 176 | params.append(args['after']) 177 | has_where = True 178 | 179 | # Add limit 180 | query += 'ORDER BY event_time DESC\n' 181 | query += LIMIT 182 | params.append(args['limit']) 183 | 184 | # Make SQL query 185 | try: 186 | raw_results = SQLEngine.execute(query, params) 187 | except SQLEngineException: 188 | response['error'] = 'Invalid parameters' 189 | return response 190 | 191 | results = build_query_dict(ALERTS_FIELDS, raw_results) 192 | 193 | # Convert datetimes to unix time 194 | for alert in results: 195 | alert['event_time'] = int(alert['event_time'].strftime('%s')) 196 | 197 | response['content']['alerts'] = results 198 | response['ok'] = True 199 | return response 200 | 201 | # Querying ignored 202 | 203 | IGNORED_QUERY = ''' 204 | SELECT ldap, title, reason, until 205 | FROM ignored 206 | ''' 207 | 208 | IGNORED_ORDER_BY = 'ORDER BY until DESC\n' 209 | 210 | IGNORED_FIELDS = ['ldap', 'title', 'reason', 'until'] 211 | 212 | DEFAULT_IGNORED_ARGUMENTS = { 213 | 'limit': 50, 214 | 'ldap': None, 215 | } 216 | 217 | def ignored(**kwargs): 218 | # type: (**Any) -> Dict[str, Any] 219 | ''' 220 | Makes a call to the ignored database. 221 | Content: 222 | { 223 | "ignored": List[Dict]: list of dictionaries representing ignored alerts 224 | } 225 | Each item in "ignored" has fields in IGNORED_FIELDS 226 | ''' 227 | response = build_response() 228 | args = kwargs 229 | build_arguments(DEFAULT_IGNORED_ARGUMENTS, args, response) 230 | 231 | query = IGNORED_QUERY 232 | params = [] # type: List[Any] 233 | 234 | if args['ldap'] is not None: 235 | query += build_where(build_in(LDAP_IN, len(args['ldap'])), False) 236 | params.extend(args['ldap']) 237 | 238 | query += IGNORED_ORDER_BY 239 | query += LIMIT 240 | params.append(args['limit']) 241 | 242 | try: 243 | raw_results = SQLEngine.execute(query, params) 244 | except SQLEngineException: 245 | response['error'] = 'Invalid parameters' 246 | return response 247 | 248 | results = build_query_dict(IGNORED_FIELDS, raw_results) 249 | 250 | # Convert datetimes to timestamps 251 | for ignored in results: 252 | ignored['until'] = int(ignored['until'].strftime('%s')) 253 | 254 | response['content']['ignored'] = results 255 | response['ok'] = True 256 | return response 257 | 258 | # Querying blacklist 259 | 260 | BLACKLIST_QUERY = ''' 261 | SELECT ldap 262 | FROM blacklist 263 | ORDER BY ldap 264 | LIMIT %s 265 | ''' 266 | 267 | BLACKLIST_FIELDS = ['ldap'] 268 | 269 | DEFAULT_BLACKLIST_ARGUMENTS = { 270 | 'limit': 50, 271 | } 272 | 273 | def blacklist(**kwargs): 274 | # type: (**Any) -> Dict[str, Any] 275 | ''' 276 | Makes a call to the ignored database. 277 | Content: 278 | { 279 | "blacklist": List[Dict]: list of dictionaries representing ignored alerts 280 | } 281 | Each item in "blacklist" has only an ldap 282 | ''' 283 | response = build_response() 284 | args = kwargs 285 | build_arguments(DEFAULT_BLACKLIST_ARGUMENTS, args, response) 286 | try: 287 | raw_results = SQLEngine.execute(BLACKLIST_QUERY, (args['limit'],)) 288 | except SQLEngineException: 289 | response['error'] = 'Invalid parameters' 290 | return response 291 | 292 | results = build_query_dict(BLACKLIST_FIELDS, raw_results) 293 | 294 | response['content']['blacklist'] = results 295 | response['ok'] = True 296 | return response 297 | 298 | # Custom alert creation 299 | def create_alert(ldap, title, description, reason): 300 | # type: (str, str, str, str) -> Dict[str, Any] 301 | ''' 302 | Creates a new alert. 303 | Args: 304 | ldap: The username of the person to send an alert to 305 | title: The internal title 306 | description: A short slug that describes the alert/user visible title 307 | reason: The reason for creating the alert 308 | Content: 309 | Empty. 310 | ''' 311 | response = build_response() 312 | try: 313 | create_new_alert(title, ldap, description, reason) 314 | except SQLEngineException: 315 | response['error'] = 'Invalid parameters' 316 | return response 317 | response['ok'] = True 318 | return response 319 | -------------------------------------------------------------------------------- /scripts/query_db.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A simple script to allow querying the securitybot DB and 3 | viewing recent alerts, mostly for debugging purposes. 4 | ''' 5 | import argparse 6 | import re 7 | 8 | from securitybot.sql import SQLEngine 9 | 10 | from typing import Any, Dict, List, Sequence 11 | 12 | BLACKLIST_QUERY = ''' 13 | SELECT ldap 14 | FROM blacklist 15 | ''' 16 | 17 | IGNORED_QUERY = ''' 18 | SELECT ldap, title, reason, until 19 | FROM ignored 20 | ''' 21 | 22 | IGNORED_FIELDS = ['ldap', 'title', 'reason', 'until'] 23 | 24 | MAIN_QUERY = ''' 25 | SELECT HEX(alerts.hash), 26 | title, 27 | ldap, 28 | reason, 29 | description, 30 | splunk_url, 31 | comment, 32 | performed, 33 | authenticated, 34 | status, 35 | event_time 36 | FROM alerts 37 | JOIN user_responses ON alerts.hash = user_responses.hash 38 | JOIN alert_status ON alerts.hash = alert_status.hash 39 | ''' 40 | 41 | QUERY_FIELDS = ['hash', 42 | 'title', 43 | 'ldap', 44 | 'reason', 45 | 'description', 46 | 'splunk_url', 47 | 'comment', 48 | 'performed', 49 | 'authenticated', 50 | 'status', 51 | 'event_time'] 52 | 53 | STATUS_WHERE = 'status = %s' 54 | 55 | PERFORMED_WHERE = 'performed = %s' 56 | 57 | TITLE_WHERE = 'title IN ({0})' 58 | 59 | ORDER_BY = 'ORDER BY {0}' # wow 60 | 61 | BEFORE = 'event_time <= DATE_ADD(NOW(), INTERVAL %s HOUR)' 62 | 63 | AFTER = 'event_time >= DATE_ADD(NOW(), INTERVAL %s HOUR)' 64 | 65 | HAS_WHERE = False 66 | 67 | def build_in(num_titles): 68 | # type: (int) -> str 69 | return TITLE_WHERE.format(','.join(['%s' for _ in range(num_titles)])) 70 | 71 | LIMIT = 'LIMIT %s' 72 | 73 | def init(): 74 | # type: () -> None 75 | SQLEngine('localhost', 'root', '', 'securitybot') 76 | 77 | def main(args): 78 | # type: (Any) -> None 79 | if args.blacklist: 80 | fields, matrix = blacklist(args) 81 | elif args.ignored: 82 | fields, matrix = ignored(args) 83 | else: 84 | fields, matrix = alerts(args) 85 | 86 | pretty_print(fields, matrix) 87 | 88 | def blacklist(args): 89 | # type: (Any) -> Sequence[Any] 90 | fields = ['ldap'] 91 | results = SQLEngine.execute(BLACKLIST_QUERY) 92 | return fields, [list(row) for row in results] 93 | 94 | def ignored(alerts): 95 | # type: (Any) -> Sequence[Any] 96 | results = SQLEngine.execute(IGNORED_QUERY) 97 | return IGNORED_FIELDS, [list(row) for row in results] 98 | 99 | def alerts(args): 100 | # type: (Any) -> Sequence[Any] 101 | params = [] # type: List[Any] 102 | query = MAIN_QUERY 103 | 104 | # Prepare for possible limited status 105 | if args.status is not None: 106 | query += build_where(STATUS_WHERE) 107 | params += args.status 108 | 109 | # Prepare for possible limited performed boolean 110 | if args.performed is not None: 111 | query += build_where(PERFORMED_WHERE) 112 | params += args.performed 113 | 114 | # Prepare for possible title restrictions 115 | if args.titles is not None: 116 | query += build_where(build_in(len(args.titles))) 117 | params.extend(args.titles) 118 | 119 | # Add time bounding 120 | if args.before is not None: 121 | query += build_where(BEFORE) 122 | params += [parse_time(args.before[0])] 123 | if args.after is not None: 124 | query += build_where(AFTER) 125 | params += [parse_time(args.after[0])] 126 | 127 | # Append limit restriction and order by 128 | query += build_order_by(args.order[0]) + '\n' 129 | query += LIMIT 130 | params += args.limit 131 | 132 | # Perform query 133 | raw_results = SQLEngine.execute(query, params) 134 | results = build_query_dict(raw_results) 135 | 136 | to_remove = [] # type: List[str] 137 | 138 | # Set extra fields to remove 139 | if args.drop is not None: 140 | to_remove.extend(args.drop) 141 | 142 | # Remove hashes if not specified 143 | if not args.hash: 144 | to_remove.append('hash') 145 | 146 | # Remove status if one was specified earlier 147 | if args.status is not None: 148 | to_remove.append('status') 149 | 150 | # Remove time if not specified 151 | if not args.time: 152 | to_remove.append('event_time') 153 | 154 | # Remove URL if not specified 155 | if not args.url: 156 | to_remove.append('splunk_url') 157 | 158 | # Anonymize if specified 159 | if args.anon: 160 | to_remove.append('ldap') 161 | 162 | # Remove columns 163 | for row in results: 164 | for col in to_remove: 165 | row.pop(col, None) 166 | 167 | # Grab list of fields and convert to list of lists 168 | fields = [field for field in QUERY_FIELDS if field not in to_remove] 169 | matrix = [[row[field] for field in fields] for row in results] 170 | 171 | return fields, matrix 172 | 173 | def build_where(condition): 174 | # type: (str) -> str 175 | ''' 176 | Builds another part of a where clause depending on whether any other clauses 177 | have been used yet. Inspects global HAS_WHERE and adds either a WHERE or AND. 178 | ''' 179 | global HAS_WHERE 180 | s = '' 181 | if HAS_WHERE: 182 | s += 'AND' 183 | else: 184 | HAS_WHERE = True 185 | s += 'WHERE' 186 | return '{0} {1}\n'.format(s, condition) 187 | 188 | def build_order_by(order): 189 | # type (str) -> str 190 | if order in ['event_time', 'ldap', 'title']: 191 | formatted = ORDER_BY.format(order) 192 | if order == 'event_time': 193 | formatted += ' DESC' 194 | return formatted 195 | raise ValueError('{0} is an invalid column to order on.'.format(order)) 196 | 197 | TIME_REGEX = re.compile(r'(-?[0-9]+)h', flags=re.IGNORECASE) 198 | 199 | def parse_time(time): 200 | # type: (str) -> int 201 | ''' 202 | Parses a -Xh string to an int. 203 | Within the code, the fact that it's negative is actually optional, but I'd rather 204 | not directly expose that fact. 205 | ''' 206 | m = TIME_REGEX.match(time) 207 | if m is None: 208 | raise ValueError('{0} is an invalid time.'.format(time)) 209 | return int(m.group(1)) 210 | 211 | def build_query_dict(results): 212 | # type: (Sequence[Sequence[Any]]) -> List[Dict[str, Any]] 213 | '''Builds a list of dictionaries from the results of a query.''' 214 | return [{field: value for field, value in zip(QUERY_FIELDS, row)} for row in results] 215 | 216 | def pretty_print(fields, matrix): 217 | # type: (List[str], List[List[Any]]) -> None 218 | '''Pretty prints a matrix of data.''' 219 | contents = [fields] + matrix 220 | contents = [[str(i) for i in row] for row in contents] 221 | # Pretty print rows 222 | # Find maximum length for each column in the context matrix 223 | lens = [max([len(item) for item in col]) for col in zip(*contents)] 224 | # Prepare formatting string for column sizes 225 | fmt = ' | '.join('{{:{}}}'.format(x) for x in lens) 226 | # Apply formatting to each row in the context string 227 | table = [fmt.format(*row) for row in contents] 228 | # Add header separator 229 | table.insert(1, '-' * len(table[0])) 230 | # Join lines with newline 231 | result = '\n'.join(table) 232 | 233 | print result 234 | 235 | if __name__ == '__main__': 236 | parser = argparse.ArgumentParser(description='Explore the Securitybot DB') 237 | 238 | # Which table to query on -- defaults to alerts 239 | parser.add_argument('--blacklist', dest='blacklist', action='store_true', 240 | help='Rather than query alerts, displays the blacklist.') 241 | 242 | parser.add_argument('--ignored', dest='ignored', action='store_true', 243 | help='Rather than query alerts, displays currently ignored alerts.') 244 | 245 | # Alert arguments 246 | parser.add_argument('--titles', dest='titles', type=str, nargs='+', 247 | help='One or more titles of alerts to grab.') 248 | 249 | parser.add_argument('-o', '--order', dest='order', type=str, default=['event_time'], nargs=1, 250 | help='Name of column to order by. Must be one of event_time, ldap, title. '+ 251 | 'Defaults to event_time.') 252 | 253 | parser.add_argument('-s', '--status', dest='status', type=int, nargs=1, 254 | help='The status of the alerts to return. ' + 255 | '0 is new, 1 is in progress, 2 is closed.') 256 | 257 | parser.add_argument('-p', '--performed', dest='performed', type=int, nargs=1, 258 | help='0 to select alerts that the user did not perform, 1 otherwise.') 259 | 260 | parser.add_argument('-l', '--limit', dest='limit', type=int, default=[25], nargs=1, 261 | help='The maximum number of results to return. Defaults to 25.') 262 | 263 | parser.add_argument('--hash', dest='hash', action='store_true', 264 | help='If present, will display hash of alert.') 265 | 266 | parser.add_argument('-t', '--time', dest='time', action='store_true', 267 | help='If present, will output time at which alert was first logged.') 268 | 269 | parser.add_argument('-u', '--url', dest='url', action='store_true', 270 | help='If present, will output Splunk URL of alert\'s saved search.') 271 | 272 | parser.add_argument('-a', '--anon', dest='anon', action='store_true', 273 | help='If present, will anonymize output.') 274 | 275 | parser.add_argument('--drop', dest='drop', type=str, nargs='+', 276 | help='One or more extra fields to drop.') 277 | 278 | # Time bounding 279 | parser.add_argument('--after', dest='after', type=str, nargs=1, 280 | help='Time range all alerts must be after in negative hours, e.g. -6h. ' + 281 | 'Note: you may have to use --after=-6h.') 282 | parser.add_argument('--before', dest='before', type=str, nargs=1, 283 | help='Time range all alerts must be before in negative hours, e.g. -2h. ' + 284 | 'Note: you may have to use --before=-2h.') 285 | 286 | args = parser.parse_args() 287 | 288 | init() 289 | main(args) 290 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /tests/bot_test.py: -------------------------------------------------------------------------------- 1 | from unittest2 import TestCase 2 | from mock import Mock, patch 3 | 4 | import yaml 5 | import types 6 | import os.path 7 | import pytz 8 | from datetime import datetime 9 | 10 | import securitybot.bot as bot 11 | import securitybot.commands as commands 12 | import securitybot.user 13 | import securitybot.chat.chat 14 | 15 | MAIN_CONFIG = 'config/bot.yaml' 16 | COMMAND_CONFIG = 'config/commands.yaml' 17 | MESSAGE_CONFIG = 'config/messages.yaml' 18 | 19 | @patch('securitybot.chat.chat.Chat', autospec=True) 20 | def fake_init(self, tasker, auth_builder, reporting_channel, client): 21 | self.username = 'testing-bot' 22 | self.tasker = tasker 23 | self.auth_builder = auth_builder 24 | self.reporting_channel = reporting_channel 25 | self._last_task_poll = datetime.min.replace(tzinfo=pytz.utc) 26 | self._last_report = datetime.min.replace(tzinfo=pytz.utc) 27 | 28 | self.chat = client 29 | 30 | self.users = {} 31 | self.users_by_name = {} 32 | self.active_users = {} 33 | 34 | self.commands = {} 35 | 36 | self.messages = {} 37 | 38 | bot.SecurityBot.__init__ = fake_init 39 | 40 | class ConfigTest(TestCase): 41 | # Validate configuration files 42 | def test_config(self): 43 | '''Tests bot.yaml.''' 44 | with open(MAIN_CONFIG) as f: 45 | config = yaml.safe_load(f) 46 | for path in [s + '_path' for s in ['messages', 'commands']]: 47 | assert path in config, 'No {0} provided'.format(path.replace('_', ' ')) 48 | # Test that files exist 49 | assert os.path.isfile(config[path]), '{0} missing'.format(path.replace('_', ' ')) 50 | 51 | def test_commands(self): 52 | '''Tests commands.yaml''' 53 | with open(COMMAND_CONFIG) as f: 54 | config = yaml.safe_load(f) 55 | for name, items in config.items(): 56 | assert 'info' in items, 'No info provided for {0}'.format(name) 57 | assert 'fn' in items, 'No function provided for {0}'.format(name) 58 | assert isinstance(getattr(commands, items['fn'], None), types.FunctionType), \ 59 | '{0}: {1} is not a function'.format(name, items['fn']) 60 | 61 | def test_messages(self): 62 | '''Tests messages.yaml.''' 63 | with open(MESSAGE_CONFIG) as f: 64 | config = yaml.safe_load(f) 65 | for name, string in config.items(): 66 | assert type(string) is str, 'All messages must be strings.' 67 | 68 | class BotMessageTest(TestCase): 69 | ''' 70 | Tests different kinds of message handling. 71 | ''' 72 | def setUp(self): 73 | self.bot = bot.SecurityBot(None, None, None) 74 | self.bot.messages['bad_command'] = 'bad-command' 75 | self.bot.users = {'id': {'id': 'id', 'name': 'name'}} 76 | 77 | def test_handle_messages_command(self): 78 | '''Test receiving a command.''' 79 | self.bot.commands = {'test': None} 80 | self.bot.handle_command = Mock() 81 | self.bot.chat.get_messages.return_value = [{'type': 'message', 82 | 'user': 'id', 83 | 'channel': 'D12345', 84 | 'text': 'test command'}] 85 | self.bot.handle_messages() 86 | self.bot.handle_command.assert_called_with(self.bot.users['id'], 'test command') 87 | 88 | def test_handle_messages_not_command(self): 89 | '''Test receiving a message that isn't a command.''' 90 | self.bot.commands = {'test': None} 91 | self.bot.message_user = Mock() 92 | self.bot.chat.get_messages.return_value = [{'type': 'message', 93 | 'user': 'id', 94 | 'channel': 'D12345', 95 | 'text': 'not a command'}] 96 | self.bot.handle_messages() 97 | self.bot.chat.message_user.assert_called_with(self.bot.users['id'], 'bad-command') 98 | 99 | def test_handle_messages_not_dm(self): 100 | '''Test receiving a message that's not from a DM channel.''' 101 | self.bot.user_lookup = Mock() 102 | self.bot.chat.get_messages.return_value = [] 103 | self.bot.handle_messages() 104 | assert not self.bot.user_lookup.called, 'No user should have been looked up' 105 | 106 | class BotCommandTest(TestCase): 107 | ''' 108 | Tests handling a command. 109 | ''' 110 | 111 | def test_command_success(self): 112 | b = bot.SecurityBot(None, None, None) 113 | mock_command = Mock() 114 | mock_command.return_value = True 115 | b.commands = {'test': {'fn': mock_command, 'success_msg': 'success_msg'}} 116 | user = {'id': '123', 'name': 'test-user'} 117 | b.handle_command(user, 'test command') 118 | mock_command.assert_called_with(b, user, ['command']) 119 | b.chat.message_user.assert_called_with(user, 'success_msg') 120 | 121 | def test_command_failure(self): 122 | b = bot.SecurityBot(None, None, None) 123 | mock_command = Mock() 124 | mock_command.return_value = False 125 | b.commands = {'test': {'fn': mock_command, 'failure_msg': 'failure_msg'}} 126 | user = {'id': '123', 'name': 'test-user'} 127 | b.handle_command(user, 'test command') 128 | mock_command.assert_called_with(b, user, ['command']) 129 | b.chat.message_user.assert_called_with(user, 'failure_msg') 130 | 131 | class BotTaskTest(TestCase): 132 | ''' 133 | Tests handling of tasks. 134 | ''' 135 | 136 | @patch('securitybot.tasker.tasker.Task') 137 | @patch('securitybot.tasker.tasker.Tasker', autospec=True) 138 | def setUp(self, tasker, patch_task): 139 | self.bot = bot.SecurityBot(tasker, None, None) 140 | self.bot.greet_user = Mock() 141 | self.bot.blacklist = Mock() 142 | self.bot.blacklist.is_present.return_value = False 143 | 144 | self.patch_task = patch_task 145 | self.task = patch_task.start() 146 | self.task.title = 'title' 147 | self.task.username = 'user' 148 | self.task.comment = '' 149 | 150 | tasker.get_new_tasks.return_value = [self.task] 151 | tasker.get_pending_tasks.return_value = [self.task] 152 | 153 | self.user = securitybot.user.User({'id': 'id', 'name': 'user'}, None, self.bot) 154 | self.bot.users_by_name = {'user': self.user} 155 | 156 | import securitybot.ignored_alerts as ignored_alerts 157 | self.ignored_alerts = ignored_alerts 158 | ignored_alerts.__update_ignored_list = Mock() 159 | ignored_alerts.get_ignored = Mock(return_value={}) 160 | ignored_alerts.ignore_task = Mock() 161 | 162 | def tearDown(self): 163 | self.patch_task.stop() 164 | 165 | def test_new_task(self): 166 | ''' 167 | Tests receiving a new task that is neither for a blacklisted 168 | user or an ignored task. 169 | ''' 170 | self.bot.handle_new_tasks() 171 | self.bot.greet_user.assert_called_with(self.user) 172 | assert self.user['id'] in self.bot.active_users 173 | 174 | def test_blacklisted_task(self): 175 | '''Tests receiving a new task that is blacklisted.''' 176 | self.bot.blacklist.is_present.return_value = True 177 | self.bot.handle_new_tasks() 178 | assert self.task.comment == 'blacklisted' 179 | self.task.set_verifying.assert_called_with() 180 | 181 | def test_ignored_task(self): 182 | '''Tests receiving a new task that is ignored by the user.''' 183 | self.ignored_alerts.get_ignored = Mock(return_value={'title': 'ignored'}) 184 | self.bot.handle_new_tasks() 185 | assert self.task.comment == 'ignored' 186 | self.task.set_verifying.assert_called_with() 187 | 188 | def test_no_user_task(self): 189 | '''Tests a task assigned to an unknown or invalid username.''' 190 | self.task.username = 'another user' 191 | self.bot.handle_new_tasks() 192 | assert self.task.comment == 'invalid user' 193 | self.task.set_verifying.assert_called_with() 194 | 195 | class BotUserTest(TestCase): 196 | def test_populate(self): 197 | ''' 198 | Tests populating users. 199 | ''' 200 | sb = bot.SecurityBot(None, lambda *args: None, None) 201 | user = {'id': 'id', 'name': 'name'} 202 | sb._api_call = Mock() 203 | sb.chat.get_users.return_value = [user] 204 | sb._populate_users() 205 | sb.chat.get_users.assert_called_with() 206 | assert user['id'] in sb.users 207 | assert user['name'] in sb.users_by_name 208 | 209 | @patch('securitybot.user.User', autospec=True) 210 | def test_step(self, user): 211 | ''' 212 | Tests stepping over all users on a user step. 213 | ''' 214 | sb = bot.SecurityBot(None, None, None) 215 | sb.active_users = {'key': user} 216 | sb.handle_users() 217 | user.step.assert_called_with() 218 | 219 | class BotHelperTest(TestCase): 220 | ''' 221 | Test cases for help functions in the bot that don't require 222 | actually constructing a bot properly. 223 | ''' 224 | 225 | # User handling tests 226 | def test_user_lookup(self): 227 | '''Tests user lookup on ID.''' 228 | sb = bot.SecurityBot(None, None, None) 229 | user = {'id': 'id', 'name': 'user'} 230 | sb.users = {user['id']: user} 231 | assert sb.user_lookup('id') == user 232 | try: 233 | sb.user_lookup('not-a-real-id') 234 | except Exception: 235 | return 236 | assert False, 'A user should not have been found.' 237 | 238 | def test_user_lookup_by_name(self): 239 | '''Tests user lookup on ID.''' 240 | sb = bot.SecurityBot(None, None, None) 241 | user = {'id': 'id', 'name': 'user'} 242 | sb.users_by_name = {user['name']: user} 243 | assert sb.user_lookup_by_name('user') == user 244 | try: 245 | sb.user_lookup_by_name('not-a-real-user') 246 | except Exception: 247 | return 248 | assert False, 'A user should not have been found.' 249 | 250 | def test_valid_user_valid(self): 251 | '''Tests valid_user with a valid user.''' 252 | sb = bot.SecurityBot(None, None, None) 253 | user = {'id': '1234', 'name': 'mock-user'} 254 | sb.users = {user['id']: user} 255 | sb.users_by_name = {user['name']: user} 256 | assert sb.valid_user('mock-user') 257 | 258 | def test_valid_user_invalid(self): 259 | '''Tests valid_user with an invalid user.''' 260 | sb = bot.SecurityBot(None, None, None) 261 | user = {'id': '1234', 'name': 'mock-user'} 262 | sb.users = {user['id']: user} 263 | sb.users_by_name = {user['name']: user} 264 | assert not sb.valid_user('fake-user') 265 | 266 | def test_valid_user_malformed(self): 267 | '''Tests valid_user with a malformed user.''' 268 | sb = bot.SecurityBot(None, None, None) 269 | user = {'id': '1234', 'name': 'mock-user'} 270 | sb.users = {user['id']: user} 271 | sb.users_by_name = {user['name']: user} 272 | assert not sb.valid_user('mock-user\nfake-user') 273 | 274 | def test_valid_user_empty(self): 275 | '''Tests valid_user with a malformed user.''' 276 | sb = bot.SecurityBot(None, None, None) 277 | user = {'id': '1234', 'name': 'mock-user'} 278 | sb.users = {user['id']: user} 279 | sb.users_by_name = {user['name']: user} 280 | assert not sb.valid_user('') 281 | 282 | def test_cleanup_user(self): 283 | ''' 284 | Test users being cleaned up properly. 285 | 286 | ... or at least the active_users dictionary having an element removed. 287 | ''' 288 | # We'll mock a user using a dictionary since that's easier... 289 | user = {'id': '1234', 'name': 'mock-user'} 290 | fake_user = {'id': '5678', 'name': 'fake-user'} 291 | sb = bot.SecurityBot(None, None, None) 292 | sb.active_users = {user['id']: user} 293 | assert len(sb.active_users) == 1 294 | sb.cleanup_user(fake_user) 295 | assert len(sb.active_users) == 1 296 | sb.cleanup_user(user) 297 | assert len(sb.active_users) == 0 298 | 299 | # Command parsing tests 300 | def test_parse_command(self): 301 | '''Test parsing simple commands.''' 302 | sb = bot.SecurityBot(None, None, None) 303 | assert sb.parse_command('command text') == ('command', ['text']) 304 | assert sb.parse_command('command unquoted text') == ('command', ['unquoted', 'text']) 305 | assert sb.parse_command('command "quoted text"') == ('command', ['quoted text']) 306 | 307 | def test_parse_punctuation(self): 308 | '''Test parsing with punctuation in the command.''' 309 | sb = bot.SecurityBot(None, None, None) 310 | root = 'command' 311 | # Minimal set of punctuation to always avoid 312 | for char in bot.PUNCTUATION: 313 | assert sb.parse_command(root + char) == (root, []) 314 | 315 | def test_parse_nl_command(self): 316 | '''Test parsing commands that contain text in natural language.''' 317 | sb = bot.SecurityBot(None, None, None) 318 | command = 'command With some language.' 319 | assert sb.parse_command(command) == ('command', ['With', 'some', 'language.']) 320 | command = 'command I\'m cool.' 321 | assert sb.parse_command(command) == ('command', ['I\'m', 'cool.']) 322 | 323 | def test_parse_unicode(self): 324 | '''Tests parsing a command with unicode.''' 325 | sb = bot.SecurityBot(None, None, None) 326 | command = u'command \u2014flag' 327 | assert sb.parse_command(command) == ('command', ['--flag']) 328 | command = u'command \u201cquoted text\u201d' 329 | assert sb.parse_command(command) == ('command', ['quoted text']) 330 | -------------------------------------------------------------------------------- /tests/user_test.py: -------------------------------------------------------------------------------- 1 | from unittest2 import TestCase 2 | from mock import Mock, patch 3 | 4 | from collections import defaultdict 5 | from datetime import timedelta 6 | from time import sleep 7 | 8 | import securitybot.user as user 9 | import securitybot.bot 10 | import securitybot.chat.chat 11 | import securitybot.auth.auth 12 | 13 | # Mock away ignoring alerts 14 | import securitybot.ignored_alerts as ignored_alerts 15 | ignored_alerts.__update_ignored_list = Mock() 16 | ignored_alerts.get_ignored = Mock(return_value={}) 17 | ignored_alerts.get_ignored.return_value = {} 18 | ignored_alerts.ignore_task = Mock() 19 | 20 | class UserTest(TestCase): 21 | @patch('securitybot.chat.chat.Chat', autospec=True) 22 | @patch('securitybot.bot.SecurityBot', autospec=True) 23 | def setUp(self, bot, chat): 24 | bot.chat = chat 25 | self.bot = bot 26 | 27 | def test_construction(self): 28 | '''Tests basic construction of a user.''' 29 | user.User({}, None, None) 30 | 31 | def test_get_attributes(self): 32 | '''Tests grabbing attributes like a dictionary.''' 33 | test_user = user.User({'alphabet': 'soup', 34 | 'animal': 'crackers'}, 35 | None, None) 36 | assert test_user['alphabet'] == 'soup' 37 | assert test_user['animal'] == 'crackers' 38 | 39 | def test_name(self): 40 | '''Tests getting a user's name.''' 41 | test_user = user.User({'profile': {'first_name': 'Bot'}}, None, None) 42 | assert test_user.get_name() == 'Bot' 43 | test_user = user.User({'profile': {}, 'name': 'Bot2'}, None, None) 44 | assert test_user.get_name() == 'Bot2' 45 | 46 | # User interaction flows 47 | 48 | @patch('securitybot.tasker.tasker.Task') 49 | @patch('securitybot.auth.auth.Auth', autospec=True) 50 | def test_basic_flow(self, auth, mock_task): 51 | ''' 52 | Tests basic flow through the bot. 53 | This is the most basic flow: 54 | new task => did perform => allow 2FA => valid 2FA => no task 55 | This will ensure that the states progress as expected and the bot 56 | cleans itself up afterwards. 57 | ''' 58 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.NONE 59 | auth.can_auth.return_value = True 60 | self.bot.messages = defaultdict(str) 61 | test_user = user.User({}, auth, self.bot) 62 | 63 | task = mock_task.start() 64 | 65 | assert str(test_user._fsm.state) == 'need_task' 66 | 67 | # Also test not advancing on no queued task 68 | test_user.step() 69 | assert str(test_user._fsm.state) == 'need_task' 70 | 71 | test_user.add_task(task) 72 | test_user.step() 73 | assert str(test_user._fsm.state) == 'action_performed_check' 74 | 75 | test_user.positive_response('Dummy explanation.') 76 | test_user.step() 77 | assert str(test_user._fsm.state) == 'auth_permission_check' 78 | assert (test_user._last_message.answer is None and 79 | test_user._last_message.text == '') 80 | 81 | test_user.positive_response('Dummy explanation.') 82 | test_user.step() 83 | assert str(test_user._fsm.state) == 'waiting_on_auth' 84 | assert (test_user._last_message.answer is None and 85 | test_user._last_message.text == '') 86 | 87 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.AUTHORIZED 88 | test_user.step() 89 | assert str(test_user._fsm.state) == 'task_finished' 90 | 91 | test_user.step() 92 | self.bot.cleanup_user.assert_called_with(test_user) 93 | assert str(test_user._fsm.state) == 'need_task' 94 | task.set_verifying.assert_called_with() 95 | 96 | mock_task.stop() 97 | 98 | @patch('securitybot.tasker.tasker.Task') 99 | @patch('securitybot.auth.auth.Auth', autospec=True) 100 | def test_did_not_do_flow(self, auth, mock_task): 101 | ''' 102 | Tests flow if a user did not perform an action. 103 | ''' 104 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.NONE 105 | auth.can_auth.return_value = True 106 | self.bot.messages = defaultdict(str) 107 | self.bot.reporting_channel = None 108 | test_user = user.User({}, auth, self.bot) 109 | 110 | task = mock_task.start() 111 | 112 | assert str(test_user._fsm.state) == 'need_task' 113 | 114 | # Also test not advancing on no queued task 115 | test_user.step() 116 | assert str(test_user._fsm.state) == 'need_task' 117 | 118 | test_user.add_task(task) 119 | test_user.step() 120 | assert str(test_user._fsm.state) == 'action_performed_check' 121 | 122 | test_user.negative_response('Dummy explanation.') 123 | test_user.step() 124 | assert str(test_user._fsm.state) == 'task_finished' 125 | assert (test_user._last_message.answer is None and 126 | test_user._last_message.text == '') 127 | 128 | mock_task.stop() 129 | 130 | @patch('securitybot.tasker.tasker.Task') 131 | @patch('securitybot.auth.auth.Auth', autospec=True) 132 | def test_two_task_flow(self, auth, mock_task): 133 | ''' 134 | Tests two task. Once the first is completed, the bot should send a 135 | a message announcing that another task exists. 136 | ''' 137 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.NONE 138 | auth.can_auth.return_value = True 139 | self.bot.messages = defaultdict(str) 140 | test_user = user.User({}, auth, self.bot) 141 | test_user.send_message = Mock() 142 | 143 | task = mock_task.start() 144 | 145 | assert str(test_user._fsm.state) == 'need_task' 146 | 147 | # Add two tasks to the queue 148 | test_user.add_task(task) 149 | test_user.add_task(task) 150 | test_user.step() 151 | assert str(test_user._fsm.state) == 'action_performed_check' 152 | 153 | test_user.positive_response('Dummy explanation.') 154 | test_user.step() 155 | assert str(test_user._fsm.state) == 'auth_permission_check' 156 | assert (test_user._last_message.answer is None and 157 | test_user._last_message.text == '') 158 | 159 | test_user.positive_response('Dummy explanation.') 160 | test_user.step() 161 | assert str(test_user._fsm.state) == 'waiting_on_auth' 162 | assert (test_user._last_message.answer is None and 163 | test_user._last_message.text == '') 164 | 165 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.AUTHORIZED 166 | test_user.step() 167 | assert str(test_user._fsm.state) == 'task_finished' 168 | 169 | test_user.step() 170 | test_user.send_message.assert_called_with('bwtm') 171 | assert str(test_user._fsm.state) == 'need_task' 172 | task.set_verifying.assert_called_with() 173 | 174 | mock_task.stop() 175 | 176 | @patch('securitybot.tasker.tasker.Task') 177 | @patch('securitybot.auth.auth.Auth', autospec=True) 178 | def test_already_authorized_flow(self, auth, mock_task): 179 | ''' 180 | Tests already being authorized after confirming an alert. 181 | This is the most basic flow: 182 | new task => did perform => already authorized => no task 183 | ''' 184 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.AUTHORIZED 185 | auth.can_auth.return_value = True 186 | self.bot.messages = defaultdict(str) 187 | test_user = user.User({}, auth, self.bot) 188 | 189 | task = mock_task.start() 190 | 191 | assert str(test_user._fsm.state) == 'need_task' 192 | 193 | test_user.add_task(task) 194 | test_user.step() 195 | assert str(test_user._fsm.state) == 'action_performed_check' 196 | 197 | test_user.positive_response('Dummy explanation.') 198 | test_user.step() 199 | assert str(test_user._fsm.state) == 'task_finished' 200 | assert (test_user._last_message.answer is None and 201 | test_user._last_message.text == '') 202 | 203 | test_user.step() 204 | self.bot.cleanup_user.assert_called_with(test_user) 205 | assert str(test_user._fsm.state) == 'need_task' 206 | task.set_verifying.assert_called_with() 207 | 208 | mock_task.stop() 209 | 210 | @patch('securitybot.tasker.tasker.Task') 211 | @patch('securitybot.auth.auth.Auth', autospec=True) 212 | def test_no_2fa(self, auth, mock_task): 213 | ''' 214 | Tests a user not having 2FA capability. 215 | This is the most basic flow: 216 | new task => did perform => allow 2FA => valid 2FA => no task 217 | ''' 218 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.NONE 219 | auth.can_auth.return_value = False 220 | self.bot.messages = defaultdict(str) 221 | test_user = user.User({}, auth, self.bot) 222 | 223 | task = mock_task.start() 224 | 225 | assert str(test_user._fsm.state) == 'need_task' 226 | 227 | test_user.step() 228 | assert str(test_user._fsm.state) == 'need_task' 229 | 230 | test_user.add_task(task) 231 | test_user.step() 232 | assert str(test_user._fsm.state) == 'action_performed_check' 233 | 234 | test_user.positive_response('Dummy explanation.') 235 | test_user.step() 236 | assert str(test_user._fsm.state) == 'task_finished' 237 | assert (test_user._last_message.answer is None and 238 | test_user._last_message.text == '') 239 | 240 | test_user.step() 241 | self.bot.cleanup_user.assert_called_with(test_user) 242 | assert str(test_user._fsm.state) == 'need_task' 243 | task.set_verifying.assert_called_with() 244 | 245 | mock_task.stop() 246 | 247 | @patch('securitybot.tasker.tasker.Task') 248 | @patch('securitybot.auth.auth.Auth', autospec=True) 249 | def test_not_allow_2fa_flow(self, auth, mock_task): 250 | ''' 251 | Tests if the user denies being sent a Duo Push. 252 | ''' 253 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.NONE 254 | auth.can_auth.return_value = True 255 | self.bot.messages = defaultdict(str) 256 | test_user = user.User({}, auth, self.bot) 257 | 258 | task = mock_task.start() 259 | 260 | assert str(test_user._fsm.state) == 'need_task' 261 | 262 | # Also test not advancing on no queued task 263 | test_user.step() 264 | assert str(test_user._fsm.state) == 'need_task' 265 | 266 | test_user.add_task(task) 267 | test_user.step() 268 | assert str(test_user._fsm.state) == 'action_performed_check' 269 | 270 | test_user.positive_response('Dummy explanation.') 271 | test_user.step() 272 | assert str(test_user._fsm.state) == 'auth_permission_check' 273 | assert (test_user._last_message.answer is None and 274 | test_user._last_message.text == '') 275 | 276 | test_user.negative_response('Dummy explanation.') 277 | test_user.step() 278 | assert str(test_user._fsm.state) == 'task_finished' 279 | assert (test_user._last_message.answer is None and 280 | test_user._last_message.text == '') 281 | 282 | mock_task.stop() 283 | # Putting escalation time in the past to make sure tests outsisde of 284 | # business hours won't fail 285 | @patch('securitybot.user.ESCALATION_TIME', timedelta(weeks=-1)) 286 | @patch('securitybot.tasker.tasker.Task') 287 | @patch('securitybot.auth.auth.Auth', autospec=True) 288 | def test_auto_escalate(self, auth, mock_task): 289 | '''Tests that after some time an alert automatically escalates.''' 290 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.DENIED 291 | auth.can_auth.return_value = True 292 | self.bot.messages = defaultdict(str) 293 | test_user = user.User({}, auth, self.bot) 294 | 295 | task = mock_task.start() 296 | 297 | assert str(test_user._fsm.state) == 'need_task' 298 | 299 | test_user.add_task(task) 300 | test_user.step() 301 | assert str(test_user._fsm.state) == 'action_performed_check' 302 | 303 | # Auto-escalation should happen immediately because escalation time 304 | # is set in the past 305 | 306 | 307 | test_user.step() 308 | assert str(test_user._fsm.state) == 'task_finished' 309 | task.set_verifying.assert_called_with() 310 | 311 | mock_task.stop() 312 | 313 | @patch('securitybot.tasker.tasker.Task') 314 | @patch('securitybot.auth.auth.Auth', autospec=True) 315 | def test_deny_resets_auth(self, auth, mock_task): 316 | '''Tests that receiving a deny from 2FA resets any saved authorization.''' 317 | auth.auth_status.return_value = securitybot.auth.auth.AUTH_STATES.DENIED 318 | auth.can_auth.return_value = True 319 | self.bot.messages = defaultdict(str) 320 | test_user = user.User({}, auth, self.bot) 321 | 322 | task = mock_task.start() 323 | 324 | assert str(test_user._fsm.state) == 'need_task' 325 | 326 | test_user.add_task(task) 327 | test_user.step() 328 | assert str(test_user._fsm.state) == 'action_performed_check' 329 | 330 | test_user.positive_response('Dummy explanation.') 331 | test_user.step() 332 | assert str(test_user._fsm.state) == 'auth_permission_check' 333 | assert (test_user._last_message.answer is None and 334 | test_user._last_message.text == '') 335 | 336 | test_user.positive_response('Dummy explanation.') 337 | test_user.step() 338 | assert str(test_user._fsm.state) == 'waiting_on_auth' 339 | assert (test_user._last_message.answer is None and 340 | test_user._last_message.text == '') 341 | 342 | test_user.step() 343 | assert str(test_user._fsm.state) == 'task_finished' 344 | auth.reset.assert_called_with() 345 | 346 | mock_task.stop() 347 | 348 | # Auth interactions 349 | 350 | @patch('securitybot.tasker.tasker.Task') 351 | @patch('securitybot.auth.auth.Auth', autospec=True) 352 | def test_start_auth(self, auth, mock_task): 353 | '''Tests that authorization calls call the auth object.''' 354 | self.bot.messages = defaultdict(str) 355 | test_user = user.User({}, auth, self.bot) 356 | task = mock_task.start() 357 | 358 | test_user.pending_task = task 359 | test_user.begin_auth() 360 | auth.auth.assert_called_with(task.description) 361 | 362 | mock_task.stop() 363 | 364 | @patch('securitybot.auth.auth.Auth', autospec=True) 365 | def test_check_auth(self, auth): 366 | '''Tests that auth status calls interact properly.''' 367 | test_user = user.User({}, auth, None) 368 | 369 | test_user.auth_status() 370 | auth.auth_status.assert_called_with() 371 | 372 | @patch('securitybot.auth.auth.Auth', autospec=True) 373 | def test_reset_auth(self, auth): 374 | '''Tests that auth is properly reset on `reset_auth`.''' 375 | test_user = user.User({}, auth, None) 376 | 377 | test_user.reset_auth() 378 | auth.reset.assert_called_with() 379 | -------------------------------------------------------------------------------- /securitybot/bot.py: -------------------------------------------------------------------------------- 1 | ''' 2 | The internals of securitybot. Defines a core class SecurityBot that manages 3 | most of the bot's behavior. 4 | ''' 5 | __author__ = 'Alex Bertsch' 6 | __email__ = 'abertsch@dropbox.com' 7 | 8 | import logging 9 | from securitybot.user import User 10 | import time 11 | from datetime import datetime, timedelta 12 | import pytz 13 | import shlex 14 | import yaml 15 | import string 16 | 17 | import securitybot.commands as bot_commands 18 | from securitybot.blacklist.sql_blacklist import SQLBlacklist 19 | from securitybot.chat.chat import Chat 20 | from securitybot.tasker.tasker import Task, Tasker 21 | from securitybot.auth.auth import Auth 22 | 23 | from typing import Any, Callable, Dict, List, Tuple 24 | 25 | TASK_POLL_TIME = timedelta(minutes=1) 26 | REPORTING_TIME = timedelta(hours=1) 27 | 28 | DEFAULT_COMMAND = { 29 | 'fn': lambda b, u, a: logging.warn('No function provided for this command.'), 30 | 'info': 'I was too lazy to provide information for this command', 31 | 'hidden': False, 32 | 'usage': None, 33 | 'success_msg': None, 34 | 'failure_msg': None, 35 | } 36 | 37 | def clean_input(text): 38 | # type: (unicode) -> str 39 | ''' 40 | Cleans some input text, doing things such as removing smart quotes. 41 | ''' 42 | # Replaces smart quotes; Shlex crashes if it encounters an unbalanced 43 | # smart quote, as happens with auto-formatting. 44 | text = (text.replace(u'\u2018', '\'') 45 | .replace(u'\u2019', '\'') 46 | .replace(u'\u201c','"') 47 | .replace(u'\u201d', '"')) 48 | # Undo autoformatting of dashes 49 | text = (text.replace(u'\u2013', '--') 50 | .replace(u'\u2014', '--')) 51 | 52 | return text.encode('utf-8') 53 | 54 | PUNCTUATION = '.,!?\'"`' 55 | 56 | def clean_command(command): 57 | # type: (str) -> str 58 | '''Cleans a command.''' 59 | command = command.lower() 60 | # Force to str 61 | command = command.encode('utf-8') 62 | # Remove punctuation people are likely to use and won't interfere with command names 63 | command = command.translate(string.maketrans('', ''), PUNCTUATION) 64 | return command 65 | 66 | class SecurityBot(object): 67 | ''' 68 | It's always dangerous naming classes the same name as the project... 69 | ''' 70 | 71 | def __init__(self, chat, tasker, auth_builder, reporting_channel, config_path): 72 | # type: (Chat, Tasker, Callable[[str], Auth], str, str) -> None 73 | ''' 74 | Args: 75 | chat (Chat): The chat object to use for messaging. 76 | tasker (Tasker): The Tasker object to get tasks from 77 | auth_builder (Auth): The constructor to build Auth objects from. 78 | It should take in only a username as a parameter. 79 | reporting_channel (str): Channel ID to report alerts in need of verification to. 80 | config_path (str): Path to configuration file 81 | ''' 82 | logging.info('Creating securitybot.') 83 | self.tasker = tasker 84 | self.auth_builder = auth_builder 85 | self.reporting_channel = reporting_channel 86 | self._last_task_poll = datetime.min.replace(tzinfo=pytz.utc) 87 | self._last_report = datetime.min.replace(tzinfo=pytz.utc) 88 | 89 | self._load_config(config_path) 90 | 91 | self.chat = chat 92 | chat.connect() 93 | 94 | # Load blacklist from SQL 95 | self.blacklist = SQLBlacklist() 96 | 97 | # A dictionary to be populated with all members of the team 98 | self.users = {} # type: Dict[str, User] 99 | self.users_by_name = {} # type: Dict[str, User] 100 | self._populate_users() 101 | 102 | # Dictionary of users who have outstanding tasks 103 | self.active_users = {} # type: Dict[str, User] 104 | 105 | # Recover tasks 106 | self.recover_in_progress_tasks() 107 | 108 | logging.info('Done!') 109 | 110 | # Initialization functions 111 | 112 | def _load_config(self, config_path): 113 | # type: (str) -> None 114 | ''' 115 | Loads a configuration file for the bot. 116 | ''' 117 | logging.info('Loading configuration.') 118 | with open(config_path, 'r') as f: 119 | config = yaml.safe_load(f) 120 | 121 | # Required parameters 122 | try: 123 | self._load_messages(config['messages_path']) 124 | self._load_commands(config['commands_path']) 125 | except KeyError as e: 126 | logging.error('Missing parameter: {0}'.format(e)) 127 | raise SecurityBotException('Configuration file missing parameters.') 128 | 129 | # Optional parameters 130 | self.icon_url = config.get('icon_url', 'https://placehold.it/256x256') 131 | 132 | def _load_messages(self, messages_path): 133 | # type: (str) -> None 134 | ''' 135 | Loads messages from a YAML file. 136 | 137 | Args: 138 | messages_path (str): Path to messages file. 139 | ''' 140 | self.messages = yaml.safe_load(open(messages_path)) 141 | 142 | def _load_commands(self, commands_path): 143 | # type: (str) -> None 144 | ''' 145 | Loads commands from a configuration file. 146 | 147 | Args: 148 | commands_path (str): Path to commands file. 149 | ''' 150 | with open(commands_path, 'r') as f: 151 | commands = yaml.safe_load(f) 152 | 153 | self.commands = {} # type: Dict[str, Any] 154 | for name, cmd in commands.items(): 155 | new_cmd = DEFAULT_COMMAND.copy() 156 | new_cmd.update(cmd) 157 | 158 | try: 159 | new_cmd['fn'] = getattr(bot_commands, format(cmd['fn'])) 160 | except AttributeError as e: 161 | raise SecurityBotException('Invalid function: {0}'.format(e)) 162 | 163 | self.commands[name] = new_cmd 164 | logging.info('Loaded commands: {0}'.format(self.commands.keys())) 165 | 166 | # Bot functions 167 | 168 | def run(self): 169 | # type: () -> None 170 | ''' 171 | Main loop for the bot. 172 | ''' 173 | while True: 174 | now = datetime.now(tz=pytz.utc) 175 | if now - self._last_task_poll > TASK_POLL_TIME: 176 | self._last_task_poll = now 177 | self.handle_new_tasks() 178 | self.handle_in_progress_tasks() 179 | self.handle_verifying_tasks() 180 | self.handle_messages() 181 | self.handle_users() 182 | time.sleep(.1) 183 | 184 | def handle_messages(self): 185 | # type: () -> None 186 | ''' 187 | Handles all messages sent to securitybot. 188 | Currently only active users are considered, i.e. we don't care if a user 189 | sends us a message but we haven't sent them anything. 190 | ''' 191 | messages = self.chat.get_messages() 192 | for message in messages: 193 | user_id = message['user'] 194 | text = message['text'] 195 | user = self.user_lookup(user_id) 196 | 197 | # Parse each received line as a command, otherwise send an error message 198 | if self.is_command(text): 199 | self.handle_command(user, text) 200 | else: 201 | self.chat.message_user(user, self.messages['bad_command']) 202 | 203 | def handle_command(self, user, command): 204 | # type: (User, str) -> None 205 | ''' 206 | Handles a given command from a user. 207 | ''' 208 | key, args = self.parse_command(command) 209 | logging.info('Handling command {0} for {1}'.format(key, user['name'])) 210 | cmd = self.commands[key] 211 | if cmd['fn'](self, user, args): 212 | if cmd['success_msg']: 213 | self.chat.message_user(user, cmd['success_msg']) 214 | else: 215 | if cmd['failure_msg']: 216 | self.chat.message_user(user, cmd['failure_msg']) 217 | 218 | def valid_user(self, username): 219 | # type: (str) -> bool 220 | ''' 221 | Validates a username to be valid. 222 | ''' 223 | if len(username.split()) != 1: 224 | return False 225 | try: 226 | self.user_lookup_by_name(username) 227 | return True 228 | except SecurityBotException as e: 229 | logging.warn('{}'.format(e)) 230 | return False 231 | 232 | def _add_task(self, task): 233 | # type: (Task) -> None 234 | ''' 235 | Adds a new task to the user specified by that task. 236 | 237 | Args: 238 | task (Task): the task to add. 239 | ''' 240 | username = task.username 241 | if self.valid_user(username): 242 | # Ignore blacklisted users 243 | if self.blacklist.is_present(username): 244 | logging.info('Ignoring task for blacklisted {0}'.format(username)) 245 | task.comment = 'blacklisted' 246 | task.set_verifying() 247 | else: 248 | user = self.user_lookup_by_name(username) 249 | user_id = user['id'] 250 | if user_id not in self.active_users: 251 | logging.debug('Adding {} to active users'.format(username)) 252 | self.active_users[user_id] = user 253 | self.greet_user(user) 254 | user.add_task(task) 255 | task.set_in_progress() 256 | else: 257 | # Escalate if no valid user is found 258 | logging.warn('Invalid user: {0}'.format(username)) 259 | task.comment = 'invalid user' 260 | task.set_verifying() 261 | 262 | def handle_new_tasks(self): 263 | # type: () -> None 264 | ''' 265 | Handles all new tasks. 266 | ''' 267 | for task in self.tasker.get_new_tasks(): 268 | # Log new task 269 | logging.info('Handling new task for {0}'.format(task.username)) 270 | 271 | self._add_task(task) 272 | 273 | def handle_in_progress_tasks(self): 274 | # type: () -> None 275 | ''' 276 | Handles all in progress tasks. 277 | ''' 278 | pass 279 | 280 | def recover_in_progress_tasks(self): 281 | # type: () -> None 282 | ''' 283 | Recovers in progress tasks from a previous run. 284 | ''' 285 | for task in self.tasker.get_active_tasks(): 286 | # Log new task 287 | logging.info('Recovering task for {0}'.format(task.username)) 288 | 289 | self._add_task(task) 290 | 291 | 292 | def handle_verifying_tasks(self): 293 | # type: () -> None 294 | ''' 295 | Handles all tasks which are currently waiting for verification. 296 | ''' 297 | pass 298 | 299 | def handle_users(self): 300 | # type: () -> None 301 | ''' 302 | Handles all users. 303 | ''' 304 | for user_id in self.active_users.keys(): 305 | user = self.active_users[user_id] 306 | user.step() 307 | 308 | def cleanup_user(self, user): 309 | # type: (User) -> None 310 | ''' 311 | Cleanup a user from the active users list once they have no remaining 312 | tasks. 313 | ''' 314 | logging.debug('Removing {} from active users'.format(user['name'])) 315 | self.active_users.pop(user['id'], None) 316 | 317 | def alert_user(self, user, task): 318 | # type: (User, Task) -> None 319 | ''' 320 | Alerts a user about an alert that was trigged and associated with their 321 | name. 322 | 323 | Args: 324 | user (User): The user associated with the task. 325 | task (Task): A task to alert on. 326 | ''' 327 | # Format the reason to be indented 328 | reason = '\n'.join(['>' + s for s in task.reason.split('\n')]) 329 | 330 | message = self.messages['alert'].format(task.description, reason) 331 | message += '\n' 332 | message += self.messages['action_prompt'] 333 | self.chat.message_user(user, message) 334 | 335 | # User creation and lookup methods 336 | 337 | def _populate_users(self): 338 | # type: () -> None 339 | ''' 340 | Populates the members dictionary mapping user IDs to username, avatar, 341 | etc. 342 | ''' 343 | logging.info('Gathering information about all team members...') 344 | members = self.chat.get_users() 345 | for member in members: 346 | user = User(member, self.auth_builder(member['name']), self) 347 | self.users[member['id']] = user 348 | self.users_by_name[member['name']] = user 349 | logging.info('Gathered info on {} users.'.format(len(self.users))) 350 | 351 | def user_lookup(self, id): 352 | # type: (str) -> User 353 | ''' 354 | Looks up a user by their ID. 355 | 356 | Args: 357 | id (str): The ID of a user to look up, formatted like U12345678. 358 | Returns: 359 | (dict): All known information about that user. 360 | ''' 361 | if id not in self.users: 362 | raise SecurityBotException('User {} not found'.format(id)) 363 | return self.users[id] 364 | 365 | def user_lookup_by_name(self, username): 366 | # type: (str) -> User 367 | ''' 368 | Looks up a user by their username. 369 | 370 | Args: 371 | username (str): The username of the user to look up. 372 | Resturns: 373 | (dict): All known information about that user. 374 | ''' 375 | if username not in self.users_by_name: 376 | raise SecurityBotException('User {} not found'.format(username)) 377 | return self.users_by_name[username] 378 | 379 | # Chat methods 380 | 381 | def greet_user(self, user): 382 | # type: (User) -> None 383 | ''' 384 | Sends a greeting message to a user. 385 | 386 | Args: 387 | user (User): The user to greet. 388 | ''' 389 | self.chat.message_user(user, self.messages['greeting'].format(user.get_name())) 390 | 391 | # Command functions 392 | def is_command(self, command): 393 | # type: (str) -> bool 394 | '''Checks if a raw command is a command.''' 395 | return clean_command(command.split()[0]) in self.commands 396 | 397 | def parse_command(self, command): 398 | # type: (str) -> Tuple[str, List[str]] 399 | ''' 400 | Parses a given command. 401 | 402 | Args: 403 | command (str): The raw command to parse. 404 | Returns: 405 | (str, List[str]): A tuple of the command followed by arguments. 406 | ''' 407 | # First try shlex 408 | command = clean_input(command) 409 | try: 410 | split = shlex.split(command) 411 | except ValueError: 412 | # ignore shlex exception 413 | # Fall back to naive method 414 | split = command.split() 415 | 416 | return (clean_command(split[0]), split[1:]) 417 | 418 | class SecurityBotException(Exception): 419 | pass 420 | -------------------------------------------------------------------------------- /securitybot/user.py: -------------------------------------------------------------------------------- 1 | ''' 2 | An object to manage user interactions. 3 | Wraps user information, all known alerts, and an active DM channel with the user. 4 | ''' 5 | __author__ = 'Alex Bertsch' 6 | __email__ = 'abertsch@dropbox.com' 7 | 8 | import logging 9 | import pytz 10 | from datetime import datetime, timedelta 11 | import securitybot.ignored_alerts as ignored_alerts 12 | from securitybot.tasker.tasker import Task 13 | from securitybot.auth.auth import AUTH_STATES 14 | from securitybot.state_machine import StateMachine 15 | from securitybot.util import tuple_builder, get_expiration_time 16 | 17 | from typing import Any, Dict, List 18 | 19 | ESCALATION_TIME = timedelta(hours=2) 20 | BACKOFF_TIME = timedelta(hours=21) 21 | 22 | class User(object): 23 | ''' 24 | A user to be contacted by the security bot. Each user stores all of the 25 | information provided by chat, which is indexable similar to a dictionary. 26 | A user also holds a reference to an authentication object for 2FA and the 27 | bot who spawned it for sending messages. 28 | ''' 29 | 30 | def __init__(self, user, auth, parent): 31 | # type: (Dict[str, Any], Any, Any) -> None 32 | ''' 33 | Args: 34 | user (dict): Chat information about a user. 35 | auth (Auth): The authentication object to use. 36 | parent (Bot): The bot object that spawned this user. 37 | ''' 38 | self._user = user # type: Dict[str, Any] 39 | self.tasks = [] # type: List[Task] 40 | self.pending_task = None # type: Task 41 | # Authetnication object specific to this user 42 | self.auth = auth 43 | 44 | # Parent pointer to bot 45 | self.parent = parent 46 | 47 | # Last parsed message from this user 48 | self._last_message = tuple_builder() 49 | 50 | # Last authorization status 51 | self._last_auth = AUTH_STATES.NONE 52 | 53 | # Task auto-escalation time 54 | self._escalation_time = datetime.max.replace(tzinfo=pytz.utc) 55 | 56 | # Build state hierarchy 57 | states = ['need_task', 58 | 'action_performed_check', 59 | 'auth_permission_check', 60 | 'waiting_on_auth', 61 | 'task_finished', 62 | ] 63 | transitions = [ 64 | # Handle new tasks 65 | { 66 | 'source': 'need_task', 67 | 'dest': 'action_performed_check', 68 | 'condition': self._has_tasks 69 | }, 70 | # Finish task if user says action was performed and recently authorized 71 | { 72 | 'source': 'action_performed_check', 73 | 'dest': 'task_finished', 74 | 'condition': self._already_authed, 75 | }, 76 | # Finish task if user says action was performed and no 2FA capability exists 77 | { 78 | 'source': 'action_performed_check', 79 | 'dest': 'task_finished', 80 | 'condition': self._cannot_2fa, 81 | 'action': lambda: self.send_message('no_2fa') 82 | }, 83 | # Ask for 2FA if user says action was performed and can do 2FA 84 | { 85 | 'source': 'action_performed_check', 86 | 'dest': 'auth_permission_check', 87 | 'condition': self._performed_action, 88 | }, 89 | # Finish task if user says action wasn't performed 90 | { 91 | 'source': 'action_performed_check', 92 | 'dest': 'task_finished', 93 | 'condition': self._did_not_perform_action, 94 | 'action': self._act_on_not_performed, 95 | }, 96 | # Silently escalate and wait after some time goes by 97 | { 98 | 'source': 'action_performed_check', 99 | 'dest': 'task_finished', 100 | 'condition': self._slow_response_time, 101 | 'action': self._auto_escalate, 102 | }, 103 | # Perform 2FA if permission is granted 104 | { 105 | 'source': 'auth_permission_check', 106 | 'dest': 'waiting_on_auth', 107 | 'condition': self._allows_authorization, 108 | }, 109 | # Don't perform 2FA if permission is not granted 110 | { 111 | 'source': 'auth_permission_check', 112 | 'dest': 'task_finished', 113 | 'condition': self._denies_authorization, 114 | 'action': lambda: self.send_message('escalated'), 115 | }, 116 | # Silently escalate and wait after some time goes by again 117 | { 118 | 'source': 'auth_permission_check', 119 | 'dest': 'task_finished', 120 | 'condition': self._slow_response_time, 121 | 'action': self._auto_escalate, 122 | }, 123 | # Wait for authorization response then finish the task 124 | { 125 | 'source': 'waiting_on_auth', 126 | 'dest': 'task_finished', 127 | 'condition': self._auth_completed, 128 | }, 129 | # Go to the first needed task, possibly quitting, when task is completed 130 | { 131 | 'source': 'task_finished', 132 | 'dest': 'need_task', 133 | }, 134 | ] 135 | during = { 136 | 'waiting_on_auth': self._update_auth, 137 | } 138 | on_enter = { 139 | 'auth_permission_check': lambda: self.send_message('2fa'), 140 | 'waiting_on_auth': lambda: self.begin_auth(), 141 | } 142 | on_exit = { 143 | 'need_task': self._next_task, 144 | 'action_performed_check': self._update_task_response, 145 | 'auth_permission_check': self._reset_message, 146 | 'waiting_on_auth': self._update_task_auth, 147 | 'task_finished': self._complete_task, 148 | } 149 | 150 | self._fsm = StateMachine(states, transitions, 'need_task', during=during, on_enter=on_enter, 151 | on_exit=on_exit) 152 | 153 | def __getitem__(self, key): 154 | # type: (str) -> Any 155 | ''' 156 | Allows for indexing on the user infomation pulled from our chat system. 157 | ''' 158 | return self._user.get(key, None) 159 | 160 | def step(self): 161 | # type: () -> None 162 | self._fsm.step() 163 | 164 | def _update_auth(self): 165 | # type: () -> None 166 | self._last_auth = self.auth_status() 167 | 168 | # State conditions 169 | 170 | def _has_tasks(self): 171 | # type: () -> bool 172 | '''Checks if the user has any tasks.''' 173 | return len(self.tasks) != 0 174 | 175 | def _already_authed(self): 176 | # type: () -> bool 177 | ''' 178 | Checks if the user performed the last action and 179 | if they are already authorized. 180 | ''' 181 | return self._performed_action() and self.auth_status() == AUTH_STATES.AUTHORIZED 182 | 183 | def _cannot_2fa(self): 184 | # type: () -> bool 185 | return self._performed_action() and not self.auth.can_auth() 186 | 187 | def _performed_action(self): 188 | # type: () -> bool 189 | '''Checks if the user performed their current action.''' 190 | return self._last_message.answer is True 191 | 192 | def _did_not_perform_action(self): 193 | # type: () -> bool 194 | '''Checks if the user _did not_ perform their current action.''' 195 | return self._last_message.answer is False 196 | 197 | def _slow_response_time(self): 198 | # type: () -> bool 199 | '''Returns true if the user has taken a long time to respond.''' 200 | return datetime.now(tz=pytz.utc) > self._escalation_time 201 | 202 | def _allows_authorization(self): 203 | # type: () -> bool 204 | '''Checks if the user is okay with 2FA.''' 205 | return self._last_message.answer is True 206 | 207 | def _denies_authorization(self): 208 | # type: () -> bool 209 | '''Checks if the user is not okay with 2FA.''' 210 | return self._last_message.answer is False 211 | 212 | def _auth_completed(self): 213 | # type: () -> bool 214 | '''Checks if authentication has been completed.''' 215 | return self._last_auth is AUTH_STATES.AUTHORIZED or self._last_auth is AUTH_STATES.DENIED 216 | 217 | # State actions 218 | 219 | def _auto_escalate(self): 220 | # type: () -> None 221 | '''Marks the current task as needing verification and moves on.''' 222 | logging.info('Silently escalating {0} for {1}' 223 | .format(self.pending_task.description, self['name'])) 224 | # Append in the case that this is called when waiting for auth permission 225 | self.pending_task.comment += 'Automatically escalated. No response received.' 226 | self.pending_task.set_verifying() 227 | self._escalation_time = datetime.max.replace(tzinfo=pytz.utc) 228 | self.send_message('no_response') 229 | 230 | def _act_on_not_performed(self): 231 | # type: () -> None 232 | ''' 233 | Acts on a user not performing an action. 234 | Sends a message and alerts the bot's reporting channel. 235 | ''' 236 | # Send escalation method 237 | self.send_message('escalated') 238 | # Alert bot's reporting channel 239 | if self.parent.reporting_channel is not None: 240 | # Format message 241 | if self._last_message.text: 242 | comment = self._last_message.text 243 | else: 244 | comment = 'No comment provided.' 245 | comment = '\n'.join('> ' + s for s in comment.split('\n')) 246 | self.parent.chat.send_message( 247 | self.parent.reporting_channel, 248 | self.parent.messages['report'].format(username=self['name'], 249 | title=self.pending_task.title, 250 | description=self.pending_task.description, 251 | comment=comment, 252 | url=self.pending_task.url)) 253 | 254 | 255 | # Exit actions 256 | 257 | def _update_task_response(self): 258 | # type: () -> None 259 | ''' 260 | Updates the task with information gained from the user's response. 261 | ''' 262 | if self._last_message.answer is not None: 263 | self.pending_task.performed = self._last_message.answer 264 | self.pending_task.comment = self._last_message.text 265 | 266 | self._reset_message() 267 | 268 | def _update_task_auth(self): 269 | # type: () -> None 270 | ''' 271 | Updates the task with authorization permission. 272 | ''' 273 | if self._last_auth is AUTH_STATES.AUTHORIZED: 274 | self.send_message('good_auth') 275 | self.pending_task.authenticated = True 276 | else: 277 | self.send_message('bad_auth') 278 | self.reset_auth() 279 | self.pending_task.authenticated = False 280 | 281 | def _reset_message(self): 282 | # type: () -> None 283 | self._last_message = tuple_builder() 284 | 285 | # Task methods 286 | 287 | def add_task(self, task): 288 | # type: (Task) -> None 289 | ''' 290 | Adds a task to this user's new tasks. 291 | 292 | Args: 293 | task (Task): The Task to add. 294 | ''' 295 | self.tasks.append(task) 296 | self._update_tasks() 297 | 298 | def _next_task(self): 299 | # type: () -> None 300 | ''' 301 | Advances to the next task if there is no pending task and alerts the 302 | user of its existence. 303 | ''' 304 | self.pending_task = self.tasks.pop(0) 305 | self.parent.alert_user(self, self.pending_task) 306 | self._reset_message() 307 | self._escalation_time = get_expiration_time(datetime.now(tz=pytz.utc), ESCALATION_TIME) 308 | logging.info('Beginning task for {0}'.format(self['name'])) 309 | 310 | def _complete_task(self): 311 | # type: () -> None 312 | ''' 313 | Completes the user's pending task. If any remaining tasks exist, sends 314 | a message alerting the user of more. Otherwise sends a farewell message 315 | and removes itself from the bot. 316 | ''' 317 | # Ignore an alert if they did it 318 | if self.pending_task.performed: 319 | ignored_alerts.ignore_task(self['name'], self.pending_task.title, 320 | 'auto backoff after confirmation', BACKOFF_TIME) 321 | self.pending_task.set_verifying() 322 | self.pending_task = None 323 | self._reset_message() 324 | self._update_tasks() 325 | if self.tasks: 326 | self.send_message('bwtm') 327 | else: 328 | self.send_message('bye') 329 | self.parent.cleanup_user(self) 330 | 331 | def _update_tasks(self): 332 | # type: () -> None 333 | ''' 334 | Updates the user's stored list of tasks, removing all of those that should be ignored. 335 | ''' 336 | ignored = ignored_alerts.get_ignored(self['name']) 337 | cleaned_tasks = [] 338 | for task in self.tasks: 339 | if task.title in ignored: 340 | logging.info('Ignoring task {0} for {1}'.format(task.title, self['name'])) 341 | task.comment = ignored[task.title] 342 | task.set_verifying() 343 | else: 344 | cleaned_tasks.append(task) 345 | self.tasks = cleaned_tasks 346 | 347 | # Message methods 348 | 349 | def positive_response(self, text): 350 | # type: (str) -> None 351 | ''' 352 | Registers a positive response having been received. 353 | 354 | Args: 355 | text (str): Some message accompanying the response. 356 | ''' 357 | self._last_message = tuple_builder(True, text) 358 | 359 | def negative_response(self, text): 360 | # type: (str) -> None 361 | ''' 362 | Registers a negative response having been received. 363 | 364 | Args: 365 | text (str): Some message accompanying the response. 366 | ''' 367 | self._last_message = tuple_builder(False, text) 368 | 369 | def send_message(self, key): 370 | # type: (str) -> None 371 | ''' 372 | Sends a message from the pre-loaded messages.yaml. 373 | 374 | Args: 375 | key (str): The key in messages.yaml of the message to send. 376 | ''' 377 | self.parent.chat.message_user(self, self.parent.messages[key]) 378 | 379 | # Authorization methods 380 | 381 | def begin_auth(self): 382 | # type: () -> None 383 | ''' 384 | Attempts to authorize this user. Changes the user's state to 385 | WAITING_ON_AUTH. 386 | ''' 387 | self.send_message('sending_push') 388 | self.auth.auth(self.pending_task.description) 389 | 390 | def auth_status(self): 391 | # type: () -> int 392 | ''' 393 | Gets the current authorization status. 394 | ''' 395 | return self.auth.auth_status() 396 | 397 | def reset_auth(self): 398 | # type: () -> None 399 | ''' 400 | Resets this user's authorization status, including no longer accepting 401 | authorization due to being "recently" authorized. 402 | ''' 403 | self.auth.reset() 404 | 405 | # Utility methods 406 | 407 | def get_name(self): 408 | # type: () -> str 409 | ''' 410 | Tries to find the best name to use when talking to a user. 411 | ''' 412 | if ('profile' in self._user and 413 | 'first_name' in self._user['profile'] and 414 | self._user['profile']['first_name']): 415 | return self._user['profile']['first_name'] 416 | return self._user['name'] 417 | 418 | class UserException(Exception): 419 | pass 420 | -------------------------------------------------------------------------------- /frontend/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |