14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SpoofCheck Self Test
2 | Web application that lets you test if your domain is vulnerable to email spoofing.
3 |
4 | ## NOTE: This application is still in development.
5 |
6 | # Installation:
7 | This application is designed to be used as a service. However, if you want to host this yourself, do the following:
8 |
9 | git clone https://github.com/bishopfox/spoofcheckselftest.git
10 | cd spoofcheckselftest
11 | pip install -r requirements.txt
12 |
13 | # Running:
14 | ./selftest.py -s
15 |
16 | Then visit http://localhost:8888 in a web browser
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "SpoofcheckSelfTest",
3 | "homepage": "https://github.com/BishopFox/SpoofcheckSelfTest",
4 | "authors": [
5 | "Alex DeFreese "
6 | ],
7 | "description": "",
8 | "main": "",
9 | "license": "MIT",
10 | "private": true,
11 | "ignore": [
12 | "**/.*",
13 | "node_modules",
14 | "bower_components",
15 | "test",
16 | "tests"
17 | ],
18 | "dependencies": {
19 | "angular": "^1.6.2",
20 | "angular-websocket": "^2.0.0",
21 | "angular-route": "^1.6.2",
22 | "angular-animate": "^1.6.2",
23 | "bootstrap": "^3.3.7",
24 | "angular-recaptcha": "^4.0.3"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tasks/__init__.py:
--------------------------------------------------------------------------------
1 | from celery import Celery
2 | from tornado.options import options
3 |
4 | from tasks.helpers import create_mq_url
5 |
6 | queue_conf = {
7 | 'CELERY_TASK_SERIALIZER': 'json',
8 | 'CELERY_ACCEPT_CONTENT': ['json'],
9 | 'CELERY_RESULT_SERIALIZER': 'json',
10 | 'CELERY_TASK_RESULT_EXPIRES': 3600
11 | }
12 |
13 | selftest_task_queue = Celery(
14 | 'selftest_task_queue',
15 | backend='rpc',
16 | broker=create_mq_url(options.mq_hostname, options.mq_port,
17 | username=options.mq_username,
18 | password=options.mq_password),
19 | include=[
20 | "tasks.message_tasks",
21 | "tasks.notifiers",
22 | ])
23 | selftest_task_queue.conf.update(**queue_conf)
--------------------------------------------------------------------------------
/static/ico/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App",
3 | "icons": [
4 | {
5 | "src": "\/android-icon-36x36.png",
6 | "sizes": "36x36",
7 | "type": "image\/png",
8 | "density": "0.75"
9 | },
10 | {
11 | "src": "\/android-icon-48x48.png",
12 | "sizes": "48x48",
13 | "type": "image\/png",
14 | "density": "1.0"
15 | },
16 | {
17 | "src": "\/android-icon-72x72.png",
18 | "sizes": "72x72",
19 | "type": "image\/png",
20 | "density": "1.5"
21 | },
22 | {
23 | "src": "\/android-icon-96x96.png",
24 | "sizes": "96x96",
25 | "type": "image\/png",
26 | "density": "2.0"
27 | },
28 | {
29 | "src": "\/android-icon-144x144.png",
30 | "sizes": "144x144",
31 | "type": "image\/png",
32 | "density": "3.0"
33 | },
34 | {
35 | "src": "\/android-icon-192x192.png",
36 | "sizes": "192x192",
37 | "type": "image\/png",
38 | "density": "4.0"
39 | }
40 | ]
41 | }
--------------------------------------------------------------------------------
/tasks/notifiers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | @author: moloch
4 | Copyright 2016
5 | """
6 | import json
7 | import logging
8 |
9 | from celery.utils.log import get_task_logger
10 |
11 | from libs.events import TASK_EVENTS, TASK_ROUTING_KEY
12 | from tasks import selftest_task_queue
13 | from tasks.helpers.mq import mq_send_once
14 |
15 |
16 | LOGGER = get_task_logger(__name__)
17 |
18 |
19 | @selftest_task_queue.task
20 | def task_complete_notify(result, task_id):
21 | """ Notifies consumers that a task has been completed """
22 | logging.debug('Task result is: %r', result)
23 | logging.critical('Sending complete notification for id: %s', task_id)
24 | msg = json.dumps({'task_id': task_id})
25 | if mq_send_once(TASK_EVENTS, TASK_ROUTING_KEY, msg):
26 | logging.info('Confirmed delivery of task complete notification')
27 | else:
28 | logging.warning('Could not confirm task complete notification delivery')
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 |
3 | # Byte-compiled / optimized / DLL files
4 | __pycache__/
5 | *.py[cod]
6 |
7 | # C extensions
8 | *.so
9 |
10 | # Distribution / packaging
11 | .Python
12 | env/
13 | build/
14 | develop-eggs/
15 | dist/
16 | downloads/
17 | eggs/
18 | .eggs/
19 | lib/
20 | lib64/
21 | parts/
22 | sdist/
23 | var/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 |
28 | # PyInstaller
29 | # Usually these files are written by a python script from a template
30 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
31 | *.manifest
32 | *.spec
33 |
34 | # Installer logs
35 | pip-log.txt
36 | pip-delete-this-directory.txt
37 |
38 | # Unit test / coverage reports
39 | htmlcov/
40 | .tox/
41 | .coverage
42 | .coverage.*
43 | .cache
44 | nosetests.xml
45 | coverage.xml
46 | *,cover
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 |
55 | # Sphinx documentation
56 | docs/_build/
57 |
58 | # PyBuilder
59 | target/
60 |
61 | .DS_Store
62 |
63 | static/lib
--------------------------------------------------------------------------------
/handlers/ErrorHandlers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2013
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 | from handlers.BaseHandlers import BaseHandler
21 |
22 |
23 | class ForbiddenHandler(BaseHandler):
24 |
25 | def get(self, *args, **kwargs):
26 | # TODO: Return error code
27 | pass
28 |
29 |
30 | class NotFoundHandler(BaseHandler):
31 |
32 | def get(self, *args, **kwargs):
33 | self.render('errors/404.html')
--------------------------------------------------------------------------------
/static/ng-app/home.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
34 |
--------------------------------------------------------------------------------
/mixins/celery_task_mixin.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | @author: moloch
4 | Copyright 2016
5 | """
6 |
7 | import logging
8 |
9 | from uuid import uuid4
10 |
11 | from tornado.gen import coroutine, Return, Task
12 |
13 | from tasks.notifiers import task_complete_notify
14 |
15 |
16 | class CeleryTaskMixin(object):
17 |
18 | @property
19 | def task_event_consumer(self):
20 | return self.settings["task_event_consumer"]
21 |
22 | @coroutine
23 | def execute_task(self, task, *args, **kwargs):
24 | """ Allows tasks to be executed using coroutines """
25 | result = yield Task(self._execute_task, task, *args, **kwargs)
26 | raise Return(result.result)
27 |
28 | def _execute_task(self, task, *args, **kwargs):
29 | """ Not thread safe, only call this once per-notification """
30 | logging.critical('task = %r', task)
31 | logging.critical('args = %r', args)
32 | logging.critical('kwargs = %r', kwargs)
33 |
34 | self._callback = kwargs['callback']
35 | del kwargs['callback']
36 |
37 | task_id = str(uuid4())
38 | self.task_event_consumer.add_event_listener(self, task_id)
39 | self._task_result = task.apply_async(args, kwargs,
40 | task_id=task_id,
41 | link=task_complete_notify.s(task_id))
42 |
43 | def on_task_completed(self):
44 | self._callback(self._task_result)
45 |
--------------------------------------------------------------------------------
/libs/Singleton.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2013
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 |
21 | from threading import Lock
22 |
23 |
24 | class Singleton(object):
25 | ''' Thread safe singleton '''
26 |
27 | def __init__(self, decorated):
28 | self._decorated = decorated
29 | self._instance_lock = Lock()
30 |
31 | def instance(self):
32 | '''
33 | Returns the singleton instance. Upon its first call, it creates a
34 | new instance of the decorated class and calls its `__init__` method.
35 | On all subsequent calls, the already created instance is returned.
36 | '''
37 | if not hasattr(self, "_instance"):
38 | with self._instance_lock:
39 | if not hasattr(self, "_instance"):
40 | self._instance = self._decorated()
41 | return self._instance
42 |
43 | def __call__(self):
44 | raise TypeError(
45 | 'Singletons must be accessed through the `instance` method.'
46 | )
47 |
--------------------------------------------------------------------------------
/libs/SecurityDecorators.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2013
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 | import logging
21 | import functools
22 |
23 |
24 | def csp(src, policy):
25 | ''' Decorator for easy CSP management '''
26 |
27 | def func(method):
28 | @functools.wraps(method)
29 | def wrapper(self, *args, **kwargs):
30 | self.add_content_policy(src, policy)
31 | return wrapper
32 | return func
33 |
34 |
35 |
36 | def restrict_ip_address(method):
37 | ''' Only allows access to ip addresses in a provided list '''
38 |
39 | @functools.wraps(method)
40 | def wrapper(self, *args, **kwargs):
41 | if self.request.remote_ip in self.application.settings['admin_ips']:
42 | return method(self, *args, **kwargs)
43 | else:
44 | self.redirect(self.application.settings['forbidden_url'])
45 | return wrapper
46 |
47 |
48 | def restrict_origin(method):
49 | ''' Check the origin header / prevent CSRF+WebSocket '''
50 |
51 | @functools.wraps(method)
52 | def wrapper(self, *args, **kwargs):
53 | if self.request.headers['Origin'] == self.config.origin:
54 | return method(self, *args, **kwargs)
55 | return wrapper
--------------------------------------------------------------------------------
/tasks/helpers/mq.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | @author: moloch
4 | Copyright 2016
5 | """
6 |
7 | import pika
8 |
9 | from tornado.options import options
10 | from furl import furl
11 |
12 |
13 | def create_mq_url(hostname, port, username=None, password=None):
14 | """ Creates a well formed amqp:// connection URI """
15 | mq_url = furl()
16 | mq_url.scheme = "amqp"
17 | mq_url.host = hostname
18 | mq_url.port = port
19 | mq_url.username = username
20 | mq_url.password = password
21 | return str(mq_url)
22 |
23 |
24 | def mq_connect():
25 | """ Connects to the MQ and returns the connection """
26 | _url = create_mq_url(options.mq_hostname, options.mq_port,
27 | username=options.mq_username,
28 | password=options.mq_password)
29 | parameters = pika.URLParameters(_url)
30 | connection = pika.BlockingConnection(parameters)
31 | return connection
32 |
33 |
34 | def mq_send(channel, exchange, routing_key, message):
35 | """
36 | Sends an MQ message on a channel to an exchange returns confirmation
37 | """
38 | return channel.basic_publish(exchange, routing_key, message,
39 | pika.BasicProperties(content_type='text/plain',
40 | delivery_mode=1))
41 |
42 |
43 | def mq_send_once(exchange, routing_key, message):
44 | """
45 | Publishes a single rabbit mq message, and returns the
46 | delivery confirmation.
47 |
48 | NOTE: If you're wondering the reason for using this over
49 | `libs.events.event_producers` those producers are dependent
50 | on the Tornado ioloop, which we don't have here, nor do we
51 | want here.
52 | """
53 | connection = mq_connect()
54 | confirmation = mq_send(connection.channel(), exchange, routing_key, message)
55 | connection.close()
56 | return confirmation
57 |
--------------------------------------------------------------------------------
/libs/LoggingHelpers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2013
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 |
21 | import logging
22 |
23 | from libs.Singleton import Singleton
24 | from collections import deque
25 |
26 |
27 | @Singleton
28 | class ObservableLoggingHandler(logging.StreamHandler):
29 | '''
30 | An observable logging class, just shuffles logging messages
31 | from the main logger to the observers. A small history is
32 | stored in volatile memory.
33 | '''
34 |
35 | max_history_size = 100
36 | _observers = []
37 | _history = deque()
38 |
39 | def add_observer(self, observer):
40 | ''' Add new observer and send them any history '''
41 | if observer not in self._observers:
42 | self._observers.append(observer)
43 | observer.update(list(self._history))
44 |
45 | def remove_observer(self, observer):
46 | ''' Remove ref to an observer '''
47 | if observer in self._observers:
48 | self._observers.remove(observer)
49 |
50 | def emit(self, record):
51 | '''
52 | Overloaded method, gets called when logging messages are sent
53 | '''
54 | msg = self.format(record)
55 | for observer in self._observers:
56 | observer.update([msg])
57 | if self.max_history_size < len(self._history):
58 | self._history.popleft()
59 | self._history.append(msg)
60 |
--------------------------------------------------------------------------------
/app.cfg:
--------------------------------------------------------------------------------
1 | ###############################################
2 | # Configuration File
3 | ###############################################
4 |
5 | [Server]
6 | # Web server listen port
7 | port = 8888
8 |
9 | # Debug mode provides stack traces, etc
10 | debug = True
11 |
12 | # Bootstrap type 'production' / 'developement'
13 | bootstrap = developement
14 |
15 | [Logging]
16 | # Sets the log level for the console / terminal
17 | console_level = debug
18 |
19 | # Enable/disable file logs
20 | file_logs = True
21 |
22 | # Set logging level for the file output (if save_logs is enabled)
23 | file_logs_level = debug
24 |
25 | # Path to output file for logs (if save_logs is enabled)
26 | file_logs_filename = app.log
27 |
28 | [Ssl]
29 | # Enable/disable SSL server
30 | # You'll need to manually setup an HTTP/302 to HTTPS if you want it
31 | use_ssl = False
32 |
33 | # Generate a self signed certificate/key like so:
34 | # openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout bar.key -out foo.crt
35 |
36 | # Certificate file path
37 | certificate_file = foo.crt
38 | # Key file path
39 | key_file = bar.key
40 |
41 | [Memcached]
42 | # Memcached settings
43 | host = 127.0.0.1
44 | port = 11211
45 |
46 | [Sessions]
47 | # Session settings (stored in memcached)
48 | max_age = 1800
49 | regeneration_interval = 1800
50 |
51 | [Security]
52 | # Comma separated list of ips that can access the admin interface
53 | # For example: admin_ips = 127.0.0.1,192.168.0.25,10.34.0.2
54 | admin_ips = 127.0.0.1
55 |
56 | # If x-headers is set to 'True', the app will honor the real IP address
57 | # provided by a load balancer in the X-Real-Ip or X-Forwarded-For header.
58 | # NOTE: This can affect the IP address security restrictions.
59 | x-headers = False
60 |
61 | [Database]
62 | # Supports 'mysql', 'postgresql', or 'sqlite'; defaults to mysql
63 | dialect = sqlite
64 |
65 | # This is the database name we're connecting to
66 | name = app
67 |
68 | # These are not used for sqlite
69 | # Leave blank or set to 'RUNTIME' if you want to prompt for creds when the application starts
70 | # this keeps sensitive db credentials out of plain-text files, and is recommended for production.
71 | host = localhost
72 | user = dbuser
73 | password = dbpassword
74 |
--------------------------------------------------------------------------------
/libs/ConsoleColors.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2013
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 | import platform
21 |
22 | if platform.system().lower() in ['linux', 'darwin']:
23 |
24 | # === Text Colors ===
25 | W = "\033[0m" # default/white
26 | BLA = "\033[30m" # black
27 | R = "\033[31m" # red
28 | G = "\033[32m" # green
29 | O = "\033[33m" # orange
30 | BLU = "\033[34m" # blue
31 | P = "\033[35m" # purple
32 | C = "\033[36m" # cyan
33 | GR = "\033[37m" # gray
34 |
35 | # === Styles ===
36 | bold = "\033[1m"
37 | underline = "\033[4m"
38 | blink = "\033[5m"
39 | reverse = "\033[7m"
40 | concealed = "\033[8m"
41 |
42 | # === Background Colors ===
43 | bkgd_black = "\033[40m"
44 | bkgd_red = "\033[41m"
45 | bkgd_green = "\033[42m"
46 | bkgd_yellow = "\033[43m"
47 | bkgd_blue = "\033[44m"
48 | bkgd_magenta = "\033[45m"
49 | bkgd_cyan = "\033[46m"
50 | bkgd_white = "\033[47m"
51 |
52 | else:
53 |
54 | # Sets all colors to blank strings
55 | # === Text Colors ===
56 | W = ""
57 | BLA = ""
58 | R = ""
59 | G = ""
60 | O = ""
61 | BLU = ""
62 | P = ""
63 | C = ""
64 | GR = ""
65 |
66 | # === Styles ===
67 | bold = ""
68 | underline = ""
69 | blink = ""
70 | reverse = ""
71 | concealed = ""
72 |
73 | # === Background Colors ===
74 | bkgd_black = ""
75 | bkgd_red = ""
76 | bkgd_green = ""
77 | bkgd_yellow = ""
78 | bkgd_blue = ""
79 | bkgd_magenta = ""
80 | bkgd_cyan = ""
81 | bkgd_white = ""
82 |
83 | # === Macros ===
84 | INFO = bold + C + "[*] " + W
85 | WARN = bold + R + "[!] " + W
86 | PROMPT = bold + P + "[?] " + W
87 |
--------------------------------------------------------------------------------
/static/css/selftest.css:
--------------------------------------------------------------------------------
1 | @font-face{
2 | font-family: 'Source Code Pro';
3 | font-weight: 500;
4 | font-style: normal;
5 | font-stretch: normal;
6 | src: url('/static/fonts/SourceCodePro-Regular.ttf.woff2') format('woff2'),
7 | url('/static/fonts/SourceCodePro-Regular.ttf.woff') format('woff');
8 | }
9 |
10 | #header {
11 | background: #2c2f34;
12 | border-bottom: 4px solid #d24126;
13 | color: #fff;
14 | }
15 |
16 | h2 {
17 | border-top: 2px solid rgb(209, 65, 38);
18 | margin: 1em 0 .5em 0;
19 | padding-top: 5px;
20 | text-transform: uppercase;
21 | }
22 |
23 | .content {
24 | position: relative;
25 | margin-top: 60px;
26 | padding-top: 50px;
27 | padding-bottom: 100px;
28 | }
29 |
30 | .img-center {
31 | margin: 0 auto;
32 | }
33 |
34 | .bf-logo-lg {
35 | padding: 1em;
36 | }
37 |
38 | .bf-logo-sm {
39 | width: 200px;
40 | height: auto;
41 | padding: 10px;
42 | }
43 |
44 | .domain-check-form {
45 | padding-top: 40px;
46 | }
47 |
48 | .headline {
49 |
50 | }
51 |
52 | li.dmarc {
53 | padding-top: 7px;
54 | }
55 |
56 | .code {
57 | font-family: source-code-pro, Monaco, "Bitstream Vera Sans Mono", "Lucida Console", Terminal, monospace;
58 | }
59 |
60 | .highlight {
61 | color: rgb(209, 65, 38);
62 | }
63 |
64 | .good {
65 | color: rgb(0, 159, 44);
66 | }
67 |
68 | .bad {
69 | color: rgb(255, 0, 0);
70 | }
71 |
72 | .slide {
73 | position: absolute;
74 | left: 0;
75 | top: 0;
76 | width: 100%;
77 | height: 100%;
78 | }
79 |
80 | .slide.ng-enter,
81 | .slide.ng-leave {
82 | -webkit-transition: all 1s ease;
83 | transition: all 1s ease;
84 | }
85 | .slide.ng-enter {
86 | left: 100%;
87 | }
88 | .slide.ng-enter-active {
89 | left: 0;
90 | }
91 | .slide.ng-leave {
92 | left: 0;
93 | }
94 | .slide.ng-leave-active {
95 | left: -100%;
96 | }
97 |
98 | .other-vulnerable-recommendations {
99 | padding-top: .5em;
100 | }
101 |
102 | p {
103 | margin-bottom: 5px;
104 | }
105 |
106 | ul.recommendations {
107 |
108 | }
109 | ul.recommendations li {
110 | padding-bottom: 2px;
111 | }
112 |
113 | .new-scan-button {
114 | float: left;
115 |
116 | }
117 |
118 | ul {
119 | list-style: disc;
120 | }
121 |
122 | .captcha {
123 | margin-left: auto;
124 | margin-right: auto;
125 | width: 304px;
126 | padding-top: 1em;
127 | }
--------------------------------------------------------------------------------
/handlers/CheckHandler.py:
--------------------------------------------------------------------------------
1 | from handlers.BaseHandlers import BaseWebSocketHandler
2 | import logging
3 | import json
4 | import re
5 |
6 | from tornado.gen import coroutine
7 |
8 | from mixins.celery_task_mixin import CeleryTaskMixin
9 |
10 | from tasks.message_tasks import *
11 |
12 |
13 | class MonitorSocketHandler(BaseWebSocketHandler, CeleryTaskMixin):
14 |
15 | def open(self):
16 | logging.debug("[WebSocket] Opened a Monitor Websocket")
17 | self._setup_opcodes()
18 |
19 | def _setup_opcodes(self):
20 | self.opcodes = {
21 | "check": self.check_domain
22 | }
23 |
24 | @coroutine
25 | def on_message(self, message):
26 | ''' Routes response to the correct opcode '''
27 | try:
28 | message = json.loads(message)
29 | if 'opcode' in message and message['opcode'] in self.opcodes:
30 | yield self.opcodes[message['opcode']](message)
31 | else:
32 | raise Exception("Malformed message")
33 | except Exception as error:
34 | self.send_error('Error', str(error))
35 | logging.exception(
36 | '[WebSocket]: Exception while routing json message')
37 |
38 |
39 | # Opcodes
40 | @coroutine
41 | def check_domain(self, message):
42 | try:
43 | domain = message["domain"]
44 |
45 | groups = re.search("@([\w.]+)", domain)
46 |
47 | if groups is not None:
48 | domain = groups.group(1)
49 |
50 | recaptcha_solution = message["captchaResponse"]
51 |
52 | ip_address = self.request.remote_ip
53 |
54 | solution_correct = yield self.execute_task(check_recaptcha_solution, **{
55 | "user_solution": recaptcha_solution,
56 | "ip_address": ip_address
57 | })
58 |
59 | logging.debug(solution_correct)
60 |
61 | if not solution_correct:
62 | output = {
63 | "opcode": "fail",
64 | "reason": "Failed Recaptcha"
65 | }
66 |
67 | self.write_message(output)
68 |
69 | else:
70 | logging.info("Checking domain " + domain)
71 | output = yield self.execute_task(email_spoofing_analysis, **{
72 | "domain": domain
73 | })
74 |
75 | self.write_message(output)
76 | except Exception as error:
77 | logging.exception(error)
78 |
--------------------------------------------------------------------------------
/templates/home.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Spoofcheck Self Test
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
SpoofCheck Self Test
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
62 |
63 |
--------------------------------------------------------------------------------
/handlers/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2013
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 | import logging
21 |
22 | from os import urandom
23 | from tornado import netutil
24 | from tornado.web import Application, StaticFileHandler
25 | from tornado.httpserver import HTTPServer
26 | from tornado.ioloop import IOLoop
27 | from libs.ConfigManager import ConfigManager
28 | from handlers.ErrorHandlers import *
29 | from handlers.HomePageHandler import *
30 | from handlers.CheckHandler import *
31 |
32 | from libs.events.event_consumers import TaskEventConsumer
33 |
34 | # Config
35 | config = ConfigManager.instance()
36 |
37 |
38 | def start_server():
39 | ''' Main entry point for the application '''
40 |
41 | task_event_consumer = TaskEventConsumer()
42 |
43 | # Application setup
44 | app = Application([
45 |
46 | # Static Handlers - Serves static CSS, JS and images
47 | (r'/static/(.*\.(css|js|png|jpg|jpeg|svg|ttf|html|json))',
48 | StaticFileHandler, {'path': 'static/'}),
49 |
50 | # Home page serving SPA app
51 | (r'/', HomePageHandler),
52 |
53 | # Monitor Socket
54 | (r'/connect/monitor', MonitorSocketHandler),
55 |
56 | # Error Handlers -
57 | (r'/403', ForbiddenHandler),
58 |
59 | # Catch all 404 page
60 | (r'(.*)', NotFoundHandler),
61 | ],
62 |
63 | # Randomly generated secret key
64 | cookie_secret=urandom(32).encode('hex'),
65 |
66 | # Request that does not pass @authorized will be
67 | # redirected here
68 | forbidden_url='/403',
69 |
70 | # Requests that does not pass @authenticated will be
71 | # redirected here
72 | login_url='/login',
73 |
74 | # Template directory
75 | template_path='templates/',
76 |
77 | # Debug mode
78 | debug=config.debug,
79 |
80 | task_event_consumer=task_event_consumer
81 | )
82 |
83 | sockets = netutil.bind_sockets(config.listen_port)
84 | if config.use_ssl:
85 | server = HTTPServer(app,
86 | ssl_options={
87 | "certfile": config.certfile,
88 | "keyfile": config.keyfile,
89 | },
90 | xheaders=config.x_headers)
91 | else:
92 | server = HTTPServer(app, xheaders=config.x_headers)
93 | server.add_sockets(sockets)
94 | io_loop = IOLoop.instance()
95 | try:
96 | io_loop.add_callback(task_event_consumer.connect)
97 | io_loop.start()
98 | except KeyboardInterrupt:
99 | logging.warn("Keyboard interrupt, shutdown everything!")
100 | except:
101 | logging.exception("Main I/O Loop threw an excetion!")
102 | finally:
103 | io_loop.stop()
104 |
--------------------------------------------------------------------------------
/tasks/message_tasks.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | @author: lunarca
4 | Copyright 2017
5 | """
6 |
7 | import os
8 | import requests
9 | import logging
10 |
11 | from tasks import selftest_task_queue
12 |
13 | from emailprotectionslib import spf as spflib
14 | from emailprotectionslib import dmarc as dmarclib
15 |
16 |
17 | @selftest_task_queue.task
18 | def email_spoofing_analysis(domain):
19 | spf_record = spflib.SpfRecord.from_domain(domain)
20 | dmarc_record = dmarclib.DmarcRecord.from_domain(domain)
21 |
22 | spf_existence = spf_record.record is not None
23 | spf_strong = spf_record.is_record_strong() if spf_existence else False
24 |
25 | dmarc_existence = dmarc_record.record is not None
26 | dmarc_policy = ""
27 | dmarc_strong = False
28 | dmarc_aggregate_reports = False
29 | dmarc_forensic_reports = False
30 |
31 | org_domain = None
32 | org_record_record = None
33 | org_sp = None
34 | org_policy = None
35 | org_aggregate_reports = None
36 | org_forensic_reports = None
37 |
38 | is_subdomain = False
39 |
40 | if dmarc_record is not None:
41 | dmarc_strong = dmarc_record.is_record_strong()
42 | dmarc_policy = dmarc_record.policy
43 | dmarc_aggregate_reports = dmarc_record.rua is not None and dmarc_record.rua != ""
44 | dmarc_forensic_reports = dmarc_record.ruf is not None and dmarc_record.ruf != ""
45 |
46 | if not dmarc_existence:
47 | try:
48 | org_domain = dmarc_record.get_org_domain()
49 | org_record = dmarc_record.get_org_record()
50 | org_record_record = org_record.record
51 | org_sp = org_record.subdomain_policy
52 | org_policy = org_record.policy
53 | org_aggregate_reports = dmarc_record.rua is not None and org_record.rua != ""
54 | org_forensic_reports = dmarc_record.ruf is not None and org_record.ruf != ""
55 | is_subdomain = True
56 | except dmarclib.OrgDomainException:
57 | org_domain = None
58 | org_record_record = None
59 | org_sp = None
60 | org_policy = None
61 | org_aggregate_reports = None
62 | org_forensic_reports = None
63 |
64 | domain_vulnerable = not (dmarc_strong and (spf_strong or org_domain is not None))
65 |
66 | output = {
67 | 'opcode': "test",
68 | 'message': {
69 | 'vulnerable': domain_vulnerable,
70 | 'isSubdomain': is_subdomain,
71 | 'spf': {
72 | 'existence': spf_existence,
73 | 'strongConfiguration': spf_strong,
74 | 'record': spf_record.record if spf_existence else None,
75 | },
76 | 'dmarc': {
77 | 'existence': dmarc_existence,
78 | 'policy': dmarc_policy,
79 | 'aggregateReports': dmarc_aggregate_reports,
80 | 'forensicReports': dmarc_forensic_reports,
81 | 'record': dmarc_record.record if dmarc_existence else None,
82 | 'orgRecord': {
83 | 'existence': org_record_record is not None,
84 | 'domain': org_domain,
85 | 'record': org_record_record,
86 | 'sp': org_sp,
87 | 'policy': org_policy,
88 | 'rua': org_aggregate_reports,
89 | 'ruf': org_forensic_reports,
90 | },
91 | },
92 |
93 | },
94 | }
95 |
96 | return output
97 |
98 |
99 | @selftest_task_queue.task
100 | def check_recaptcha_solution(user_solution, ip_address):
101 | payload = {
102 | "secret": os.environ["RECAPTCHA_SECRET_KEY"],
103 | "response": user_solution,
104 | "remoteip": ip_address,
105 | }
106 |
107 | recaptcha_response = requests.post("https://www.google.com/recaptcha/api/siteverify", data=payload)
108 |
109 | logging.debug("[RECAPTCHA Response] " + recaptcha_response.text)
110 | return recaptcha_response.json()["success"]
111 |
--------------------------------------------------------------------------------
/selftest.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | '''
3 | Copyright 2012 Root the Box
4 |
5 | Licensed under the Apache License, Version 2.0 (the "License");
6 | you may not use this file except in compliance with the License.
7 | You may obtain a copy of the License at
8 |
9 | http://www.apache.org/licenses/LICENSE-2.0
10 |
11 | Unless required by applicable law or agreed to in writing, software
12 | distributed under the License is distributed on an "AS IS" BASIS,
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | See the License for the specific language governing permissions and
15 | limitations under the License.
16 | ----------------------------------------------------------------------------
17 |
18 | This file is the main starting point for the application, based on the
19 | command line arguments it calls various components setup/start/etc.
20 |
21 | '''
22 |
23 | import os
24 | import sys
25 |
26 | from tornado.options import options, define
27 |
28 | from datetime import datetime
29 | from libs.ConsoleColors import *
30 |
31 | __version__ = 'v0.0.1'
32 | current_time = lambda: str(datetime.now()).split(' ')[1].split('.')[0]
33 |
34 |
35 | def serve():
36 | ''' Starts the application '''
37 | from handlers import start_server
38 |
39 | print(INFO + '%s : Starting application ...' % current_time())
40 | start_server()
41 |
42 |
43 | def start_worker():
44 | from tasks import selftest_task_queue
45 | from tasks.helpers import create_mq_url
46 | from celery.bin import worker
47 |
48 | worker = worker.worker(app=selftest_task_queue)
49 | worker_options = {
50 | 'broker': create_mq_url(options.mq_hostname, options.mq_port,
51 | username=options.mq_username,
52 | password=options.mq_password),
53 | 'loglevel': options.mq_loglevel,
54 | 'traceback': options.debug,
55 | }
56 | worker.run(**worker_options)
57 |
58 |
59 | def main():
60 | ''' Call functions in the correct order based on CLI params '''
61 | fpath = os.path.abspath(__file__)
62 | fdir = os.path.dirname(fpath)
63 | if fdir != os.getcwd():
64 | print(INFO + "Switching CWD to %s" % fdir)
65 | os.chdir(fdir)
66 | if options.api:
67 | serve()
68 | elif options.celery:
69 | start_worker()
70 | else:
71 | print(INFO + "Failed to start server")
72 |
73 | define("config",
74 | default="app.cfg",
75 | help="path to config file",
76 | type=str,
77 | callback=lambda path: options.parse_config_file(path, final=False))
78 |
79 | # RabbitMQ host
80 | define("mq_hostname",
81 | group="mq",
82 | default=os.environ.get("SPOOFCHECK_MQ_HOST", "127.0.0.1"),
83 | help="the mq host")
84 |
85 | define("mq_port",
86 | group="mq",
87 | default="5672",
88 | help="the mq port",
89 | type=str)
90 |
91 | define("mq_username",
92 | group="mq",
93 | default=os.environ.get("SPOOFCHECK_MQ_USERNAME", "guest"),
94 | help="the mq username",
95 | type=str)
96 |
97 | define("mq_password",
98 | group="mq",
99 | default=os.environ.get("SPOOFCHECK_MQ_PASSWORD", "guest"),
100 | help="the mq password",
101 | type=str)
102 |
103 | define("mq_loglevel",
104 | group="mq",
105 | default=os.environ.get("SPOOFCHECK_MQ_LOGLEVEL", "INFO"),
106 | help="the mq log level")
107 |
108 | define("debug",
109 | default=bool(os.environ.get("SPOOFCHECK_DEBUG", False)),
110 | help="start server in debugging mode",
111 | group="debug",
112 | type=bool)
113 |
114 | define("celery",
115 | default=False,
116 | help="Start Celery task server",
117 | type=bool)
118 |
119 | define("api",
120 | default=False,
121 | help="Start Server",
122 | type=bool)
123 |
124 |
125 | # Main
126 | if __name__ == '__main__':
127 | try:
128 | options.parse_command_line()
129 | except IOError as error:
130 | print(WARN + str(error))
131 | sys.exit()
132 | main()
133 |
134 |
--------------------------------------------------------------------------------
/static/ng-app/base.js:
--------------------------------------------------------------------------------
1 | var app = angular.module('spoofcheck', ['ngRoute', 'ngAnimate', 'ngWebSocket', 'vcRecaptcha']);
2 |
3 | app.config( ['$routeProvider', '$locationProvider', 'vcRecaptchaServiceProvider',
4 | function($routeProvider, $locationProvider, vcRecaptchaServiceProvider) {
5 | $routeProvider.when(
6 | '/', {
7 | templateUrl: '/static/ng-app/home.html',
8 | controller: 'HomeController'
9 | }
10 | ).when(
11 | '/watch', {
12 | templateUrl: '/static/ng-app/watch.html',
13 | controller: 'WatchController'
14 | }
15 | ).when(
16 | '/report', {
17 | templateUrl: '/static/ng-app/report.html',
18 | controller: 'ReportController'
19 | }
20 | ).otherwise({
21 | redirectTo: '/'
22 | })
23 | ;
24 |
25 | // TODO: Fix to pull from config file, or modify in production
26 | vcRecaptchaServiceProvider.setSiteKey("6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI");
27 | }
28 |
29 | ])
30 | .constant('EVENTS', {
31 | output: 'output',
32 | sent: 'sent'
33 | })
34 | ;
35 |
36 | app
37 | .factory('MonitorWebSocket', ['$websocket', 'EVENTS', '$rootScope',
38 | function($websocket, EVENTS, $rootScope) {
39 | console.log("Activated websocket");
40 | var ws = $websocket("ws://" + location.host + "/connect/monitor");
41 | var output = [];
42 |
43 | ws.onMessage(function(message) {
44 | var data = JSON.parse(message.data);
45 | console.log(message);
46 |
47 | // Routing
48 | if (data.opcode != "Error") {
49 | output.push(data.message);
50 | $rootScope.$broadcast(EVENTS.output);
51 | console.log(output);
52 | }
53 |
54 | });
55 |
56 | return {
57 | output: output,
58 | checkDomain: function(domain, captchaResponse) {
59 | var data = JSON.stringify(
60 | {
61 | opcode: 'check',
62 | domain: domain,
63 | captchaResponse: captchaResponse
64 | }
65 | );
66 |
67 | console.log(data);
68 | ws.send(data);
69 | }
70 | };
71 |
72 |
73 | }
74 | ])
75 | ;
76 |
77 | app
78 | .controller('HomeController', ['$scope', '$rootScope', 'MonitorWebSocket', '$location', 'EVENTS',
79 | function($scope, $rootScope, MonitorWebSocket, $location, EVENTS) {
80 | $rootScope.domain = undefined;
81 |
82 | $scope.response = null;
83 | $scope.widgetId = null;
84 |
85 | $scope.setResponse = function (response) {
86 | console.info('Response available: %s', response);
87 | $scope.captchaResponse = response;
88 | };
89 | $scope.setWidgetId = function (widgetId) {
90 | console.info('Created widget ID: %s', widgetId);
91 | $scope.widgetId = widgetId;
92 | };
93 |
94 | $scope.checkDomain = function(domain) {
95 | console.log("Firing checkDomain with " + domain);
96 | var parsedDomain = domain;
97 | var emailRegex = /@([\w.]+)/;
98 | var emailRegexOut = domain.match(emailRegex);
99 | if (emailRegexOut != null) {
100 | parsedDomain = emailRegexOut[1];
101 | }
102 |
103 | $rootScope.domain = parsedDomain;
104 | $rootScope.ws = MonitorWebSocket;
105 |
106 | $rootScope.$on(EVENTS.output, function(evt, data) {
107 | console.log(evt);
108 | $location.path("/report").replace();
109 | });
110 |
111 | $rootScope.ws.checkDomain(domain, $scope.captchaResponse);
112 |
113 | $location.path("/watch");
114 | $rootScope.$broadcast(EVENTS.sent);
115 | }
116 | }
117 | ])
118 |
119 | .controller('WatchController', ['$scope', '$rootScope', '$location', 'EVENTS',
120 | function($scope, $rootScope, $location, EVENTS) {
121 | console.log("Loading watch controller");
122 | if ($rootScope.ws === undefined || $rootScope.domain === undefined) {
123 | $location.path("/");
124 | }
125 |
126 |
127 | }
128 | ])
129 |
130 |
131 | .controller('ReportController', ["$scope", '$rootScope', "$location",
132 | function($scope, $rootScope, $location) {
133 | if ($rootScope.ws === undefined || $rootScope.domain === undefined) {
134 | $location.path("/");
135 | } else {
136 | $scope.message = $rootScope.ws.output[$rootScope.ws.output.length - 1];
137 | }
138 | }
139 | ])
140 | ;
--------------------------------------------------------------------------------
/handlers/BaseHandlers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | Created on Mar 15, 2012
4 |
5 | @author: moloch
6 |
7 | Copyright 2012 Root the Box
8 |
9 | Licensed under the Apache License, Version 2.0 (the "License");
10 | you may not use this file except in compliance with the License.
11 | You may obtain a copy of the License at
12 |
13 | http://www.apache.org/licenses/LICENSE-2.0
14 |
15 | Unless required by applicable law or agreed to in writing, software
16 | distributed under the License is distributed on an "AS IS" BASIS,
17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18 | See the License for the specific language governing permissions and
19 | limitations under the License.
20 | ----------------------------------------------------------------------------
21 |
22 | This file contains the base handlers, all other handlers should inherit
23 | from these base classes.
24 |
25 | '''
26 |
27 |
28 | import traceback
29 |
30 | from libs.ConfigManager import ConfigManager
31 | from libs.SecurityDecorators import *
32 | from tornado.web import RequestHandler
33 | from tornado.ioloop import IOLoop
34 | from tornado.websocket import WebSocketHandler
35 |
36 |
37 | class BaseHandler(RequestHandler):
38 | ''' User handlers extend this class '''
39 |
40 | io_loop = IOLoop.instance()
41 | csp = {
42 | "default-src": ["'self'"],
43 | "script-src": [],
44 | "connect-src": [],
45 | "frame-src": [],
46 | "img-src": [],
47 | "media-src": [],
48 | "font-src": [],
49 | "object-src": [],
50 | "style-src": [],
51 | }
52 |
53 | def initialize(self):
54 | ''' Setup sessions, etc '''
55 | self.config = ConfigManager.instance()
56 |
57 |
58 | def set_default_headers(self):
59 | ''' Set security HTTP headers '''
60 | self.set_header("Server", "'; DROP TABLE server_types;--")
61 | self.add_header("X-Frame-Options", "DENY")
62 | self.add_header("X-XSS-Protection", "1; mode=block")
63 | self.add_header("X-Content-Type-Options", "nosniff")
64 | self._refresh_csp()
65 |
66 | def _refresh_csp(self):
67 | ''' Rebuild the Content-Security-Policy header '''
68 | _csp = ''
69 | for src, policies in self.csp.iteritems():
70 | if len(policies):
71 | _csp += "%s: %s;" % (src, " ".join(policies))
72 | self.set_header("Content-Security-Policy", _csp)
73 |
74 | def append_content_policy(self, src, policy):
75 | if src in self.csp:
76 | self.csp[src].append(policy)
77 | self._refresh_csp()
78 | else:
79 | raise ValueError("Invalid content source")
80 |
81 | def write_error(self, status_code, **kwargs):
82 | ''' Write our custom error pages '''
83 | if not self.config.debug:
84 | trace = "".join(traceback.format_exception(*kwargs["exc_info"]))
85 | logging.error("Request from %s resulted in an error code %d:\n%s" % (
86 | self.request.remote_ip, status_code, trace
87 | ))
88 | if status_code in [403]:
89 | # This should only get called when the _xsrf check fails,
90 | # all other '403' cases we just send a redirect to /403
91 | self.redirect('/403')
92 | else:
93 | # Never tell the user we got a 500
94 | self.render('public/404.html')
95 | else:
96 | # If debug mode is enabled, just call Tornado's write_error()
97 | super(BaseHandler, self).write_error(status_code, **kwargs)
98 |
99 | def get(self, *args, **kwargs):
100 | ''' Placeholder, incase child class does not impl this method '''
101 | self.render("public/404.html")
102 |
103 | def post(self, *args, **kwargs):
104 | ''' Placeholder, incase child class does not impl this method '''
105 | self.render("public/404.html")
106 |
107 | def put(self, *args, **kwargs):
108 | ''' Log odd behavior, this should never get legitimately called '''
109 | logging.warn(
110 | "%s attempted to use PUT method" % self.request.remote_ip
111 | )
112 |
113 | def delete(self, *args, **kwargs):
114 | ''' Log odd behavior, this should never get legitimately called '''
115 | logging.warn(
116 | "%s attempted to use DELETE method" % self.request.remote_ip
117 | )
118 |
119 | def head(self, *args, **kwargs):
120 | ''' Ignore it '''
121 | logging.warn(
122 | "%s attempted to use HEAD method" % self.request.remote_ip
123 | )
124 |
125 | def options(self, *args, **kwargs):
126 | ''' Log odd behavior, this should never get legitimately called '''
127 | logging.warn(
128 | "%s attempted to use OPTIONS method" % self.request.remote_ip
129 | )
130 |
131 | def on_finish(self, *args, **kwargs):
132 | ''' Called after a response is sent to the client '''
133 | pass
134 |
135 |
136 | class BaseWebSocketHandler(WebSocketHandler):
137 | ''' Handles websocket connections '''
138 |
139 | def initialize(self):
140 | ''' Setup sessions, etc '''
141 | self.config = ConfigManager.instance()
142 |
143 | def open(self):
144 | pass
145 |
146 | def on_message(self, message):
147 | pass
148 |
149 | def on_close(self):
150 | pass
--------------------------------------------------------------------------------
/libs/ConfigManager.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | '''
3 | @author: moloch
4 |
5 | Copyright 2012
6 |
7 | Licensed under the Apache License, Version 2.0 (the "License");
8 | you may not use this file except in compliance with the License.
9 | You may obtain a copy of the License at
10 |
11 | http://www.apache.org/licenses/LICENSE-2.0
12 |
13 | Unless required by applicable law or agreed to in writing, software
14 | distributed under the License is distributed on an "AS IS" BASIS,
15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | See the License for the specific language governing permissions and
17 | limitations under the License.
18 | '''
19 |
20 |
21 | import os
22 | import sys
23 | import urllib
24 | import socket
25 | import getpass
26 | import logging
27 | import ConfigParser
28 |
29 | from libs.ConsoleColors import *
30 | from libs.Singleton import Singleton
31 |
32 |
33 | logging_levels = {
34 | 'notset': logging.NOTSET,
35 | 'debug': logging.DEBUG,
36 | 'info': logging.INFO,
37 | 'information': logging.INFO,
38 | 'warn': logging.WARN,
39 | 'warning': logging.WARN,
40 | }
41 |
42 |
43 | @Singleton
44 | class ConfigManager(object):
45 | ''' Central class which handles any user-controlled settings '''
46 |
47 | def __init__(self, cfg_file='app.cfg'):
48 | self.filename = cfg_file
49 | if os.path.exists(cfg_file) and os.path.isfile(cfg_file):
50 | self.conf = os.path.abspath(cfg_file)
51 | else:
52 | sys.stderr.write(WARN+"No configuration file found at: %s." % self.conf)
53 | os._exit(1)
54 | self.refresh()
55 | self.__logging__()
56 |
57 | def __logging__(self):
58 | ''' Load network configurations '''
59 | level = self.config.get("Logging", 'console_level').lower()
60 | logger = logging.getLogger()
61 | logger.setLevel(logging_levels.get(level, logging.NOTSET))
62 | if self.config.getboolean("Logging", 'file_logs'):
63 | self._file_logger(logger)
64 |
65 | def _file_logger(self, logger):
66 | ''' Configure File Logger '''
67 | file_handler = logging.FileHandler('%s' % self.logfilename)
68 | logger.addHandler(file_handler)
69 | file_format = logging.Formatter('[%(levelname)s] %(asctime)s - %(message)s')
70 | file_handler.setFormatter(file_format)
71 | flevel = self.config.get("Logging", 'file_logs_level').lower()
72 | file_handler.setLevel(logging_levels.get(flevel, logging.NOTSET))
73 |
74 | def refresh(self):
75 | ''' Refresh config file settings '''
76 | self.config = ConfigParser.SafeConfigParser()
77 | self.config_fp = open(self.conf, 'r')
78 | self.config.readfp(self.config_fp)
79 |
80 | def save(self):
81 | ''' Write current config to file '''
82 | self.config_fp.close()
83 | fp = open(self.conf, 'w')
84 | self.config.write(fp)
85 | fp.close()
86 | self.refresh()
87 |
88 | @property
89 | def logfilename(self):
90 | return self.config.get("Logging", 'file_logs_filename')
91 |
92 | @property
93 | def listen_port(self):
94 | ''' Web app listen port, only read once '''
95 | lport = self.config.getint("Server", 'port')
96 | if not 0 < lport < 65535:
97 | logging.fatal("Listen port not in valid range: %d" % lport)
98 | os._exit(1)
99 | return lport
100 |
101 | @property
102 | def bootstrap(self):
103 | return self.config.get("Server", 'bootstrap')
104 |
105 | @property
106 | def log_filename(self):
107 | return self.config.get("Logging", 'log_filename')
108 |
109 | @property
110 | def debug(self):
111 | ''' Debug mode '''
112 | return self.config.getboolean("Server", 'debug')
113 |
114 | @debug.setter
115 | def debug(self, value):
116 | assert isinstance(value, bool)
117 | self.config.set("Server", 'debug', str(value))
118 |
119 | @property
120 | def domain(self):
121 | ''' Automatically resolve domain, or use manual setting '''
122 | _domain = self.config.get("Server", 'domain').strip()
123 | if _domain.lower() == 'auto':
124 | try:
125 | _domain = socket.gethostbyname(socket.gethostname())
126 | # On some Linux systems the hostname resolves to ~127.0.0.1
127 | # per /etc/hosts, so fallback and try to get the fqdn if we can.
128 | if _domain.startswith('127.'):
129 | _domain = socket.gethostbyname(socket.getfqdn())
130 | except:
131 | logging.warn("Failed to automatically resolve domain, please set manually")
132 | _domain = 'localhost'
133 | logging.debug("Domain was automatically configured to '%s'" % _domain)
134 | if _domain == 'localhost' or _domain.startswith('127.') or _domain == '::1':
135 | logging.warn("Possible misconfiguration 'domain' is set to 'localhost'")
136 | return _domain
137 |
138 | @property
139 | def origin(self):
140 | http = 'https://' if self.use_ssl else 'http://'
141 | return "%s%s:%d" % (http, self.domain, self.listen_port)
142 |
143 | @property
144 | def memcached(self):
145 | ''' Memached settings, cannot be changed from webui '''
146 | host = self.config.get("Memcached", 'host')
147 | port = self.config.getint("Memcached", 'port')
148 | if not 0 < port < 65535:
149 | logging.fatal("Memcached port not in valid range: %d" % port)
150 | os._exit(1)
151 | return "%s:%d" % (host, port)
152 |
153 | @property
154 | def session_duration(self):
155 | ''' Max session age in seconds '''
156 | return abs(self.config.getint("Sessions", 'max_age'))
157 |
158 | @property
159 | def admin_ips(self):
160 | ''' Whitelist admin ip address, this may be bypassed if x-headers is enabled '''
161 | ips = self.config.get("Security", 'admin_ips')
162 | ips = ips.replace(" ", "").split(',')
163 | ips.append('127.0.0.1')
164 | ips.append('::1')
165 | return tuple(set(ips))
166 |
167 | @property
168 | def x_headers(self):
169 | ''' Enable/disable HTTP X-Headers '''
170 | xheaders = self.config.getboolean("Security", 'x-headers')
171 | if xheaders:
172 | logging.warn("X-Headers is enabled, this may affect IP security restrictions")
173 | return xheaders
174 |
175 | @property
176 | def use_ssl(self):
177 | ''' Enable/disabled SSL server '''
178 | return self.config.getboolean("Ssl", 'use_ssl')
179 |
180 | @property
181 | def certfile(self):
182 | ''' SSL Certificate file path '''
183 | cert = os.path.abspath(self.config.get("Ssl", 'certificate_file'))
184 | if not os.path.exists(cert):
185 | logging.fatal("SSL misconfiguration, certificate file '%s' not found." % cert)
186 | os._exit(1)
187 | return cert
188 |
189 | @property
190 | def keyfile(self):
191 | ''' SSL Key file path '''
192 | key = os.path.abspath(self.config.get("Ssl", 'key_file'))
193 | if not os.path.exists(key):
194 | logging.fatal("SSL misconfiguration, key file '%s' not found." % key)
195 | os._exit(1)
196 | return key
197 |
198 | @property
199 | def db_connection(self):
200 | ''' Construct the database connection string '''
201 | dialect = self.config.get("Database", 'dialect').lower().strip()
202 | dialects = {
203 | 'sqlite': self._sqlite,
204 | 'postgresql': self._postgresql,
205 | 'mysql': self._mysql,
206 | }
207 | _db = dialects.get(dialect, self._sqlite)()
208 | self._test_connection(_db)
209 | return _db
210 |
211 | def _postgresql(self):
212 | '''
213 | Configure to use postgresql, there is not built-in support for postgresql
214 | so make sure we can import the 3rd party python lib 'pypostgresql'
215 | '''
216 | logging.debug("Configured to use Postgresql for a database")
217 | try:
218 | import pypostgresql
219 | except ImportError:
220 | print(WARN+"You must install 'pypostgresql' to use a postgresql database.")
221 | os._exit(1)
222 | db_host, db_name, db_user, db_password = self._db_credentials()
223 | return 'postgresql+pypostgresql://%s:%s@%s/%s' % (
224 | db_user, db_password, db_host, db_name,
225 | )
226 |
227 | def _sqlite(self):
228 | ''' SQLite connection string, always save db file to cwd, or in-memory '''
229 | logging.debug("Configured to use SQLite for a database")
230 | db_name = os.path.basename(self.config.get("Database", 'name'))
231 | if not len(db_name):
232 | db_name = 'app'
233 | return ('sqlite:///%s.db' % db_name) if db_name != ':memory:' else 'sqlite://'
234 |
235 | def _mysql(self):
236 | ''' Configure db_connection for MySQL '''
237 | logging.debug("Configured to use MySQL for a database")
238 | db_server, db_name, db_user, db_password = self._db_credentials()
239 | return 'mysql://%s:%s@%s/%s' % (
240 | db_user, db_password, db_server, db_name
241 | )
242 |
243 | def _test_connection(self, connection_string):
244 | ''' Test the connection string to see if we can connect to the database'''
245 | engine = create_engine(connection_string)
246 | try:
247 | connection = engine.connect()
248 | connection.close()
249 | except:
250 | if self.debug:
251 | logging.exception("Database connection failed")
252 | logging.critical("Failed to connect to database, check .cfg")
253 | os._exit(1)
254 |
255 | def _db_credentials(self):
256 | ''' Pull db creds and return them url encoded '''
257 | host = self.config.get("Database", 'host')
258 | name = self.config.get("Database", 'name')
259 | user = self.config.get("Database", 'user')
260 | password = self.config.get("Database", 'password')
261 | if user == '' or user == 'RUNTIME':
262 | user = raw_input(PROMPT+"Database User: ")
263 | if password == '' or password == 'RUNTIME':
264 | sys.stdout.write(PROMPT+"Database password: ")
265 | sys.stdout.flush()
266 | password = getpass.getpass()
267 | db_host = urllib.quote(host)
268 | db_name = urllib.quote(name)
269 | db_user = urllib.quote(user)
270 | db_password = urllib.quote_plus(password)
271 | return db_host, db_name, db_user, db_password
272 |
273 |
--------------------------------------------------------------------------------
/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 |
203 |
--------------------------------------------------------------------------------
/static/ng-app/report.html:
--------------------------------------------------------------------------------
1 |
The SPF record for {{domain}} has a strong defensive configuration.
29 |
The SPF record for {{domain}} has a weak configuration.
30 |
Record:
31 | {{ message.spf.record}}
32 |
33 |
34 |
DMARC
35 |
36 |
{{ domain }}has a DMARC record.
37 |
{{ domain }} is a subdomain of {{message.dmarc.orgRecord.domain}}.
38 |
{{ message.dmarc.orgRecord.domain }}has a DMARC record.
39 |
{{ domain }}has no DMARC record.
40 |
41 |
42 | The DMARC record for {{ domain }} is configured with a policy of {{message.dmarc.policy}}.
43 |
44 |
45 | The DMARC record for {{domain}} is configured with a policy of none.
46 | A DMARC policy of none still delivers emails that fail SPF and DKIM alignment.
47 |
48 |
49 |
50 | The DMARC record for {{domain}} is configured to send aggregate reports.
51 |
52 |
53 | The DMARC record for {{domain}} is configured to send forensic reports.
54 |
55 |
56 |
57 | The DMARC record for {{message.dmarc.orgRecord.domain}} is configured with a subdomain policy of none.
58 | A DMARC subdomain policy of none still delivers emails for subdomains that fail SPF and DKIM alignment.
59 |
60 |
61 |
62 | The DMARC record for {{message.dmarc.orgRecord.domain}} is configured with a subdomain policy of {{message.dmarc.orgRecord.sp}}.
63 | A DMARC subdomain policy of message.dmarc.orgRecord.sp will prevent email spoofing from subdomains of {{message.dmarc.orgRecord.domain}}.
64 |
65 |
66 |
67 | The DMARC record for {{message.dmarc.orgRecord.domain}} has no specified subdomain policy. Without one, the subdomain alignment configuration defaults to the basic DMARC policy.
68 |
69 |
70 |
71 | The DMARC record for {{ message.dmarc.orgRecord.domain }} is configured with a policy of {{message.dmarc.orgRecord.policy}}. A policy of {{message.dmarc.orgRecord.p}} will prevent email spoofing from {{domain}}.
72 |
73 |
74 |
75 | The DMARC record for {{ message.dmarc.orgRecord.domain }} is configured with a policy of {{message.dmarc.orgRecord.policy}}. A policy of {{message.dmarc.orgRecord.p}} still delivers emails for subdomains that fail SPF and DKIM alignment.
76 |
To avoid the risk of email spoofing from {{domain}},
96 | Bishop Fox recommends the following:
97 |
98 |
99 |
100 |
101 |
102 | Begin implementing an SPF record for {{domain}}. SPF records are DNS
103 | TXT records on the root {{domain}} domain
104 | that define all of the IP addresses permitted to send emails from {{domain}}.
105 | Additional information on setting up SPF records can be found at the OpenSPF project's
106 | SPF Record Syntax page.
107 |
108 |
109 |
110 | Modify the SPF record for {{domain}} to contain a
111 | reject all mechanism.This is performed by adding the string -all
112 | or ~all to the SPF record for {{domain}}.
113 | Additional information on setting up SPF records can be found at the OpenSPF project's
114 | SPF Record Syntax page.
115 |
116 |
117 |
118 | Begin implementing a DMARC record for {{domain}}. DMARC records are
119 | DNS TXT records, located at the _dmarc.{{domain}}
120 | subdomain, that instruct receiving mail servers how to handle emails that fail SPF and DKIM
121 | alignment. For DMARC to function, {{domain}} needs to have both SPF and
122 | DKIM configured. Additional information about setting up DMARC records can be found from the
123 | Google Apps DMARC setup guide.
124 |
125 |
126 |
127 | A DMARC policy of none allows spoofed emails to be delivered.
128 | Begin implementing a DMARC policy of quarantine or
129 | reject. As implementing strict DMARC policies may interfere with the delivery of email from
130 | {{domain}} email addresses, Bishop Fox recommends
131 | setting up and monitoring aggregate report
132 | notifications for legitimate emails before beginning to implement a stricter policy. If no
133 | legitimate emails are reported, set the DMARC policy to quarantine
134 | and set the pct field to a low percentage. This process is described
135 | in more detail in the Google Apps
136 | DMARC setup guide.
137 |
138 |
139 |
140 | A DMARC subdomain policy of none allows spoofed emails from subdomains
141 | to be delivered. Begin implementing a DMARC subdomain policy of quarantine or
142 | reject, or remove the subdomain policy and implement a DMARC policy of
143 | quarantine or reject. As implementing strict
144 | DMARC policies may interfere with the delivery of email from
145 | {{message.dmarc.orgRecord.domain}} email addresses, Bishop Fox recommends
146 | setting up and monitoring aggregate report
147 | notifications for legitimate emails before beginning to implement a stricter policy. If no
148 | legitimate emails are reported, set the DMARC policy to quarantine
149 | and set the pct field to a low percentage. This process is described
150 | in more detail in the Google Apps
151 | DMARC setup guide.
152 |
153 |
154 |
155 |
156 |
157 |
158 |
To manage the risk of email spoofing from domains other than {{domain}},
159 | Bishop Fox recommends the following:
160 |
161 |
162 |
163 | Configure the {{domain}} email server to quarantine emails that fail
164 | SPF alignment on the From field. Nearly 41% of the Alexa top million domains are configured
165 | with SPF records, but only 1.8% of those domains are configured with a strict DMARC record.
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
--------------------------------------------------------------------------------
/libs/events/event_consumers.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | """
3 | @author: moloch
4 | Copyright 2015
5 | Adapted from docs example:
6 | https://pika.readthedocs.org/en/0.10.0/examples/tornado_consumer.html
7 | """
8 |
9 | from pika import adapters
10 |
11 | import json
12 | import pika
13 | import logging
14 |
15 | from uuid import uuid4
16 |
17 | from tornado.options import options
18 | from tornado.ioloop import IOLoop
19 | from libs.events import TASK_EVENTS, TASK_ROUTING_KEY
20 |
21 | LOGGER = logging.getLogger(__name__)
22 |
23 |
24 | class BaseEventConsumer(object):
25 |
26 | """
27 | This is a consumer that will handle unexpected interactions
28 | with RabbitMQ such as channel and connection closures.
29 |
30 | If RabbitMQ closes the connection, it will reopen it. You should
31 | look at the output, as there are limited reasons why the connection may
32 | be closed, which usually are tied to permission related issues or
33 | socket timeouts.
34 | If the channel is closed, it will indicate a problem with one of the
35 | commands that were issued and that should surface in the output as well.
36 | """
37 |
38 | EXCHANGE = 'base_events'
39 | EXCHANGE_TYPE = 'fanout'
40 | QUEUE = 'text'
41 | ROUTING_KEY = ''
42 |
43 | def __init__(self):
44 | """
45 | Create a new instance of the consumer class, passing in the AMQP
46 | URL used to connect to RabbitMQ.
47 | :param str amqp_url: The AMQP url to connect with
48 | """
49 | LOGGER.info("======= [EventConsumer: %s] =======", self.EXCHANGE)
50 | self._channel = None
51 | self._closing = False
52 | self._consumer_tag = None
53 | self._connection = None
54 | self.io_loop = IOLoop.instance()
55 |
56 |
57 | def add_event_listener(self, listener):
58 | """ Add an event listener based on team uuid """
59 | raise NotImplementedError()
60 |
61 | def remove_event_listener(self, listener):
62 | """ Remove an event listener """
63 | raise NotImplementedError()
64 |
65 | def on_mq_message(self, unused_channel, basic_deliver, properties, body):
66 | """
67 | Invoked by pika when a message is delivered from RabbitMQ. The
68 | channel is passed for your convenience. The basic_deliver object that
69 | is passed in carries the exchange, routing key, delivery tag and
70 | a redelivered flag for the message. The properties passed in is an
71 | instance of BasicProperties with the message properties and the body
72 | is the message that was sent.
73 | :param pika.channel.Channel unused_channel: The channel object
74 | :param pika.Spec.Basic.Deliver: basic_deliver method
75 | :param pika.Spec.BasicProperties: properties
76 | :param str|unicode body: The message body
77 | """
78 | raise NotImplementedError()
79 |
80 | @property
81 | def _url(self):
82 | """ MQ connection URL """
83 | return "amqp://%s:%s@%s:%s" % (
84 | options.mq_username, options.mq_password, options.mq_hostname,
85 | options.mq_port)
86 |
87 | def connect(self, *args, **kwargs):
88 | """
89 | This method connects to RabbitMQ, returning the connection handle.
90 | When the connection is established, the on_connection_open method
91 | will be invoked by pika.
92 | :rtype: pika.SelectConnection
93 | """
94 | LOGGER.debug('Connecting to %s', self._url)
95 | LOGGER.debug('Got arguments: %r %r', args, kwargs)
96 | conn = adapters.TornadoConnection(pika.URLParameters(self._url),
97 | self.on_connection_open)
98 | self._connection = conn
99 | return conn
100 |
101 | def close_connection(self):
102 | """This method closes the connection to RabbitMQ."""
103 | LOGGER.debug('Closing connection')
104 | self._connection.close()
105 |
106 | def add_on_connection_close_callback(self):
107 | """
108 | This method adds an on close callback that will be invoked by pika
109 | when RabbitMQ closes the connection to the publisher unexpectedly.
110 | """
111 | LOGGER.debug('Adding connection close callback')
112 | self._connection.add_on_close_callback(self.on_connection_closed)
113 |
114 | def on_connection_closed(self, connection, reply_code, reply_text):
115 | """
116 | This method is invoked by pika when the connection to RabbitMQ is
117 | closed unexpectedly. Since it is unexpected, we will reconnect to
118 | RabbitMQ if it disconnects.
119 | :param pika.connection.Connection connection: The closed connection obj
120 | :param int reply_code: The server provided reply_code if given
121 | :param str reply_text: The server provided reply_text if given
122 | """
123 | self._channel = None
124 | LOGGER.warning('Connection closed reopening in 5 seconds: (%s) %s',
125 | reply_code, reply_text)
126 | self._connection.add_timeout(5, self.reconnect)
127 |
128 | def on_connection_open(self, unused_connection):
129 | """
130 | This method is called by pika once the connection to RabbitMQ has
131 | been established. It passes the handle to the connection object in
132 | case we need it, but in this case, we'll just mark it unused.
133 | :type unused_connection: pika.SelectConnection
134 | """
135 | LOGGER.debug('Connection opened')
136 | self.add_on_connection_close_callback()
137 | self.open_channel()
138 |
139 | def reconnect(self):
140 | """
141 | Will be invoked by the IOLoop timer if the connection is
142 | closed. See the on_connection_closed method.
143 | """
144 | if not self._closing:
145 | self._connection = self.connect()
146 |
147 | def add_on_channel_close_callback(self):
148 | """
149 | This method tells pika to call the on_channel_closed method if
150 | RabbitMQ unexpectedly closes the channel.
151 | """
152 | LOGGER.debug('Adding channel close callback')
153 | self._channel.add_on_close_callback(self.on_channel_closed)
154 |
155 | def on_channel_closed(self, channel, reply_code, reply_text):
156 | """
157 | Invoked by pika when RabbitMQ unexpectedly closes the channel.
158 | Channels are usually closed if you attempt to do something that
159 | violates the protocol, such as re-declare an exchange or queue with
160 | different parameters. In this case, we'll close the connection
161 | to shutdown the object.
162 | :param pika.channel.Channel: The closed channel
163 | :param int reply_code: The numeric reason the channel was closed
164 | :param str reply_text: The text reason the channel was closed
165 | """
166 | LOGGER.warning('Channel %i was closed: (%s) %s',
167 | channel, reply_code, reply_text)
168 | self._connection.close()
169 |
170 | def on_channel_open(self, channel):
171 | """
172 | This method is invoked by pika when the channel has been opened.
173 | The channel object is passed in so we can make use of it.
174 | Since the channel is now open, we'll declare the exchange to use.
175 | :param pika.channel.Channel channel: The channel object
176 | """
177 | LOGGER.debug('Channel opened')
178 | self._channel = channel
179 | self.add_on_channel_close_callback()
180 | self.setup_exchange(self.EXCHANGE)
181 |
182 | def setup_exchange(self, exchange_name):
183 | """
184 | Setup the exchange on RabbitMQ by invoking the Exchange.Declare RPC
185 | command. When it is complete, the on_exchange_declareok method will
186 | be invoked by pika.
187 | :param str|unicode exchange_name: The name of the exchange to declare
188 | """
189 | LOGGER.info('Declaring exchange %s', exchange_name)
190 | self._channel.exchange_declare(self.on_exchange_declareok,
191 | exchange_name,
192 | self.EXCHANGE_TYPE)
193 |
194 | def on_exchange_declareok(self, unused_frame):
195 | """
196 | Invoked by pika when RabbitMQ has finished the Exchange.Declare RPC
197 | command.
198 | :param pika.Frame.Method unused_frame: Exchange.DeclareOk response
199 | frame
200 | """
201 | LOGGER.debug('Exchange declared')
202 | self.setup_queue(self.QUEUE)
203 |
204 | def setup_queue(self, queue_name):
205 | """
206 | Setup the queue on RabbitMQ by invoking the Queue.Declare RPC
207 | command. When it is complete, the on_queue_declareok method will
208 | be invoked by pika.
209 | :param str|unicode queue_name: The name of the queue to declare.
210 | """
211 | LOGGER.debug('Declaring queue %s', queue_name)
212 | self._channel.queue_declare(self.on_queue_declareok, queue_name)
213 |
214 | def on_queue_declareok(self, method_frame):
215 | """
216 | Method invoked by pika when the Queue.Declare RPC call made in
217 | setup_queue has completed. In this method we will bind the queue
218 | and exchange together with the routing key by issuing the Queue.Bind
219 | RPC command. When this command is complete, the on_bindok method will
220 | be invoked by pika.
221 | :param pika.frame.Method method_frame: The Queue.DeclareOk frame
222 | """
223 | LOGGER.debug('Binding %s to %s with %s',
224 | self.EXCHANGE, self.QUEUE, self.ROUTING_KEY)
225 | self._channel.queue_bind(self.on_bindok, self.QUEUE,
226 | self.EXCHANGE, self.ROUTING_KEY)
227 |
228 | def add_on_cancel_callback(self):
229 | """
230 | Add a callback that will be invoked if RabbitMQ cancels the consumer
231 | for some reason. If RabbitMQ does cancel the consumer,
232 | on_consumer_cancelled will be invoked by pika.
233 | """
234 | LOGGER.debug('Adding consumer cancellation callback')
235 | self._channel.add_on_cancel_callback(self.on_consumer_cancelled)
236 |
237 | def on_consumer_cancelled(self, method_frame):
238 | """
239 | Invoked by pika when RabbitMQ sends a Basic.Cancel for a consumer
240 | receiving messages.
241 | :param pika.frame.Method method_frame: The Basic.Cancel frame
242 | """
243 | LOGGER.debug('Consumer was cancelled remotely, shutting down: %r',
244 | method_frame)
245 | if self._channel:
246 | self._channel.close()
247 |
248 | def acknowledge_message(self, delivery_tag):
249 | """
250 | Acknowledge the message delivery from RabbitMQ by sending a
251 | Basic.Ack RPC method for the delivery tag.
252 | :param int delivery_tag: The delivery tag from the Basic.Deliver frame
253 | """
254 | LOGGER.debug('Acknowledging message %s', delivery_tag)
255 | self._channel.basic_ack(delivery_tag)
256 |
257 | def on_cancelok(self, unused_frame):
258 | """
259 | This method is invoked by pika when RabbitMQ acknowledges the
260 | cancellation of a consumer. At this point we will close the channel.
261 | This will invoke the on_channel_closed method once the channel has been
262 | closed, which will in-turn close the connection.
263 | :param pika.frame.Method unused_frame: The Basic.CancelOk frame
264 | """
265 | LOGGER.debug('RabbitMQ acknowledged the cancellation of the consumer')
266 | self.close_channel()
267 |
268 | def stop_consuming(self):
269 | """Tell RabbitMQ that you would like to stop consuming by sending the
270 | Basic.Cancel RPC command.
271 | """
272 | if self._channel:
273 | LOGGER.info('Sending a Basic.Cancel RPC command to RabbitMQ')
274 | self._channel.basic_cancel(self.on_cancelok, self._consumer_tag)
275 |
276 | def start_consuming(self):
277 | """
278 | This method sets up the consumer by first calling
279 | add_on_cancel_callback so that the object is notified if RabbitMQ
280 | cancels the consumer. It then issues the Basic.Consume RPC command
281 | which returns the consumer tag that is used to uniquely identify the
282 | consumer with RabbitMQ. We keep the value to use it when we want to
283 | cancel consuming. The on_mq_message method is passed in as a callback
284 | pika will invoke when a message is fully received.
285 | """
286 | LOGGER.debug('Issuing consumer related RPC commands')
287 | self.add_on_cancel_callback()
288 | self._consumer_tag = self._channel.basic_consume(self.on_mq_message,
289 | self.QUEUE)
290 |
291 | def on_bindok(self, unused_frame):
292 | """
293 | Invoked by pika when the Queue.Bind method has completed. At this
294 | point we will start consuming messages by calling start_consuming
295 | which will invoke the needed RPC commands to start the process.
296 | :param pika.frame.Method unused_frame: The Queue.BindOk response frame
297 | """
298 | LOGGER.debug('Queue bind: OK')
299 | self.start_consuming()
300 |
301 | def close_channel(self):
302 | """
303 | Call to close the channel with RabbitMQ cleanly by issuing the
304 | Channel.Close RPC command.
305 | """
306 | LOGGER.debug('Closing the channel')
307 | self._channel.close()
308 |
309 | def open_channel(self):
310 | """
311 | Open a new channel with RabbitMQ by issuing the Channel.Open RPC
312 | command. When RabbitMQ responds that the channel is open, the
313 | on_channel_open callback will be invoked by pika.
314 | """
315 | LOGGER.debug('Creating a new channel')
316 | self._connection.channel(on_open_callback=self.on_channel_open)
317 |
318 | def run(self):
319 | """
320 | Run the example consumer by connecting to RabbitMQ and then
321 | starting the IOLoop to block and allow the SelectConnection to operate.
322 | """
323 | self._connection = self.connect()
324 |
325 | def stop(self):
326 | """
327 | Cleanly shutdown the connection to RabbitMQ by stopping the consumer
328 | with RabbitMQ. When RabbitMQ confirms the cancellation, on_cancelok
329 | will be invoked by pika, which will then closing the channel and
330 | connection. The IOLoop is started again because this method is invoked
331 | when CTRL-C is pressed raising a KeyboardInterrupt exception. This
332 | exception stops the IOLoop which needs to be running for pika to
333 | communicate with RabbitMQ.
334 | """
335 | LOGGER.debug('Stopping')
336 | self._closing = True
337 | self.stop_consuming()
338 |
339 |
340 | class TaskEventConsumer(BaseEventConsumer):
341 |
342 | EXCHANGE = TASK_EVENTS
343 | EXCHANGE_TYPE = 'fanout'
344 | QUEUE = str(uuid4())
345 | ROUTING_KEY = TASK_ROUTING_KEY
346 |
347 | def __init__(self):
348 | self._event_listeners = {}
349 | super(TaskEventConsumer, self).__init__()
350 |
351 | def on_mq_message(self, unused_channel, basic_deliver, properties, body):
352 | """
353 | Consume messages based on team_id
354 | """
355 | LOGGER.info('Received message # %s from %s: %s',
356 | basic_deliver.delivery_tag, properties.app_id, body)
357 | self.acknowledge_message(basic_deliver.delivery_tag)
358 | msg = json.loads(body)
359 | listener = self._event_listeners.get(msg['task_id'], None)
360 | if listener is not None:
361 | self.io_loop.add_callback(listener.on_task_completed)
362 | else:
363 | LOGGER.warning('No listener for task with id: %s', msg['task_id'])
364 |
365 | def add_event_listener(self, listener, notification_id):
366 | """ Add an event listener based on team uuid """
367 | self._event_listeners[notification_id] = listener
368 |
369 | def remove_event_listener(self, notification_id):
370 | """ Remove an event listener """
371 | if notification_id in self._event_listeners:
372 | del self._event_listeners[notification_id]
373 | logging.debug("Removed listener for task with id: %s", notification_id)
374 |
375 |
--------------------------------------------------------------------------------
/static/fonts/glyphicons-halflings-regular.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------