From f7f1b2560358a4dec0b86c6d9db97d1ed94dff29 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 19 Jan 2017 20:32:02 +0000 Subject: [PATCH 01/90] Updating ARN.py to look for StringEqualsIgnoreCase in policy condition blocks --- security_monkey/common/arn.py | 1 + 1 file changed, 1 insertion(+) diff --git a/security_monkey/common/arn.py b/security_monkey/common/arn.py index 9830f1ff2..34308d1c9 100644 --- a/security_monkey/common/arn.py +++ b/security_monkey/common/arn.py @@ -80,6 +80,7 @@ def extract_arns_from_statement_condition(condition): condition.get('ForAllValues:StringLike', {}) or \ condition.get('ForAnyValue:StringLike', {}) or \ condition.get('StringEquals', {}) or \ + condition.get('StringEqualsIgnoreCase', {}) or \ condition.get('ForAllValues:StringEquals', {}) or \ condition.get('ForAnyValue:StringEquals', {}) From 9ce2240932e728d60ebd10c7f84fec865a173e18 Mon Sep 17 00:00:00 2001 From: Nestor Almanza-Rodriguez Date: Thu, 14 Apr 2016 15:40:40 -0500 Subject: [PATCH 02/90] Add additional JIRA configurations * Make JIRA transitions configurable * Support JIRA proxy settings * Support JIRA ticket assignee --- docs/jirasync.rst | 6 ++++++ env-config/config-deploy.py | 8 +++++++- security_monkey/jirasync.py | 29 ++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/jirasync.rst b/docs/jirasync.rst index d3181b61a..fd1b61adc 100644 --- a/docs/jirasync.rst +++ b/docs/jirasync.rst @@ -21,6 +21,9 @@ To use JIRA sync, you will need to create a YAML configuration file, specifying project: SECURITYMONKEY issue_type: Task url: https://securitymonkey.example.com + ip_proxy: example.proxy.com + port_proxy: 443 + assignee: SecMonkeyJIRA ``server`` - The location of the JIRA server. ``account`` - The account with which Security Monkey will create tickets @@ -29,6 +32,9 @@ To use JIRA sync, you will need to create a YAML configuration file, specifying ``issue_type`` - The type of issue each ticket will be created as. ``url`` - The URL for Security Monkey. This will be used to create links back to Security Monkey. ``disable_transitions`` - If true, Security Monkey will not close or reopen tickets. This is false by default. +``ip_proxy`` - Optional proxy endpoint for JIRA client. NOTE: Proxy authentication not currently supported. +``port_proxy`` - Optional proxy port for JIRA client. NOTE: Proxy authentication not currently supported. +``assignee`` - Optional default assignee for generated JIRA tickets. Assignee should be username. Using JIRA Synchronization --------------------------- diff --git a/env-config/config-deploy.py b/env-config/config-deploy.py index bb4f7b522..f70cbc49d 100644 --- a/env-config/config-deploy.py +++ b/env-config/config-deploy.py @@ -229,4 +229,10 @@ # Length of time, in seconds, before a scheduled job is cancelled due to thread contention or other issues MISFIRE_GRACE_TIME=30 # Delay, in seconds, until reporter starts -REPORTER_START_DELAY=10 \ No newline at end of file +REPORTER_START_DELAY=10 + +# JIRA Settings +# Verify JIRA SSL certs - useful for testing on JIRA sandbox server +JIRA_SSL_VERIFY = True +JIRA_OPEN = 'Open' # Opened ticket JIRA transition name (e.g. 'Open', 'To Do') +JIRA_CLOSED = 'Closed' # Closed ticket JIRA transition name (e.g. 'Closed', 'Done') diff --git a/security_monkey/jirasync.py b/security_monkey/jirasync.py index f07eb5174..6ce7c3a64 100644 --- a/security_monkey/jirasync.py +++ b/security_monkey/jirasync.py @@ -31,7 +31,10 @@ def __init__(self, jira_file): self.server = data['server'] self.issue_type = data['issue_type'] self.url = data['url'] + self.ip_proxy = data.get('ip_proxy') + self.port_proxy = data.get('port_proxy') self.disable_transitions = data.get('disable_transitions', False) + self.assignee = data.get('assignee', None) except KeyError as e: raise Exception('JIRA sync configuration missing required field: {}'.format(e)) except IOError as e: @@ -40,19 +43,32 @@ def __init__(self, jira_file): raise Exception('JIRA sync configuration file contains malformed YAML: {}'.format(e)) try: - self.client = JIRA(self.server, basic_auth=(self.account, self.password)) + options = {} + options['verify'] = app.config.get('JIRA_SSL_VERIFY', True) + + proxies = None + if (self.ip_proxy and self.port_proxy): + proxy_connect = '{}:{}'.format(self.ip_proxy, self.port_proxy) + proxies = {'http': proxy_connect, 'https': proxy_connect} + elif (self.ip_proxy and self.port_proxy is None): + app.logger.warn("Proxy host set, but not proxy port. Skipping JIRA proxy settings.") + elif (self.ip_proxy is None and self.port_proxy): + app.logger.warn("Proxy port set, but not proxy host. Skipping JIRA proxy settings.") + + self.client = JIRA(self.server, basic_auth=(self.account, self.password), options=options, proxies=proxies) + except Exception as e: raise Exception("Error connecting to JIRA: {}".format(str(e)[:1024])) def close_issue(self, issue): try: - self.transition_issue(issue, 'Closed') + self.transition_issue(issue, app.config.get('JIRA_CLOSED', 'Closed')) except Exception as e: app.logger.error('Error closing issue {} ({}): {}'.format(issue.fields.summary, issue.key, e)) def open_issue(self, issue): try: - self.transition_issue(issue, 'Open') + self.transition_issue(issue, app.config.get('JIRA_OPEN', 'Open')) except Exception as e: app.logger.error('Error opening issue {} ({}): {}'.format(issue.fields.summary, issue.key, e)) @@ -94,10 +110,10 @@ def add_or_update_issue(self, issue, technology, account, count): if self.disable_transitions: return - if issue.fields.status.name == 'Closed' and count: + if issue.fields.status.name == app.config.get('JIRA_CLOSED', 'Closed') and count: self.open_issue(issue) app.logger.debug("Reopened issue {} ({})".format(summary, issue.key)) - elif issue.fields.status.name != 'Closed' and count == 0: + elif issue.fields.status.name != app.config.get('JIRA_CLOSED', 'Closed') and count == 0: self.close_issue(issue) app.logger.debug("Closed issue {} ({})".format(summary, issue.key)) return @@ -111,6 +127,9 @@ def add_or_update_issue(self, issue, technology, account, count): 'summary': summary, 'description': description} + if self.assignee is not None: + jira_args['assignee'] = {'name': self.assignee} + try: issue = self.client.create_issue(**jira_args) app.logger.debug("Created issue {} ({})".format(summary, issue.key)) From c9fbdc8e1d492d25267d9c4b5ec2295c8f51eb41 Mon Sep 17 00:00:00 2001 From: Sergey Skripnick Date: Wed, 25 Jan 2017 15:56:01 +0200 Subject: [PATCH 03/90] Plugins support --- docs/plugins.rst | 43 +++++++++++++++++++++++++++++++++ manage.py | 3 ++- security_monkey/common/utils.py | 9 +++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 docs/plugins.rst diff --git a/docs/plugins.rst b/docs/plugins.rst new file mode 100644 index 000000000..a7fe35f5d --- /dev/null +++ b/docs/plugins.rst @@ -0,0 +1,43 @@ +======= +Plugins +======= + +Security Monkey can be extended by writing own Account Managers, Watchers and Auditors. To do this you need to create a subclass +of either ``security_monkey.account_manager.AccountManager``, ``security_monkey.watcher.Watcher`` or ``security_monkey.auditor.Auditor``. + +To make extension available to Security Monkey it should have entry point under group ``security_monkey.plugins``. + +Sample AccountManager plugin +============================ + +Assume we have a file account.py in directory my_sm_plugins/my_sm_plugins/account.py: + +.. code-block:: python + + from security_monkey.account_manager import AccountManager + + class MyAccountManager(AccountManager): + pass + +NOTE: there also shoule be file my_sm_plugins/my_sm_plugins/__init__.py + +And we have a file setup.py in directory my_sm_plugins: + +.. code-block:: python + + from setuptools import setup, find_packages + + setup( + name="my_sm_plugins", + version="0.1-dev0", + packages=find_packages(), + include_package_data=True, + install_requires=["security_monkey"], + entry_points={ + "security_monkey.plugins": [ + "my_sm_plugins.account = my_sm_plugins.account", + ] + } + ) + +Then we can install ``my_sm_plugins`` package and have security_monkey with our plugin available. diff --git a/manage.py b/manage.py index e1582604c..ea3de3062 100644 --- a/manage.py +++ b/manage.py @@ -26,7 +26,7 @@ from security_monkey.scheduler import find_changes as sm_find_changes from security_monkey.scheduler import audit_changes as sm_audit_changes from security_monkey.backup import backup_config_to_json as sm_backup_config_to_json -from security_monkey.common.utils import find_modules +from security_monkey.common.utils import find_modules, load_plugins from security_monkey.datastore import Account from security_monkey.watcher import watcher_registry @@ -47,6 +47,7 @@ find_modules('watchers') find_modules('auditors') +load_plugins('security_monkey.plugins') @manager.command def drop_db(): diff --git a/security_monkey/common/utils.py b/security_monkey/common/utils.py index dcff480d2..1496d3594 100644 --- a/security_monkey/common/utils.py +++ b/security_monkey/common/utils.py @@ -163,3 +163,12 @@ def find_modules(folder): modname = os.path.splitext(fname)[0] app.logger.debug("Loading module %s from %s", modname, os.path.join(root,fname)) module=imp.load_source(modname, os.path.join(root,fname)) + +def load_plugins(group): + """Find and load plugins by iterating entry points.""" + + import pkg_resources + + for entry_point in pkg_resources.iter_entry_points(group): + app.logger.debug("Loading plugin %s", entry_point.module_name) + entry_point.load() From 14e7dbbaf4118bbcecfd0805f41a002d5e1b4b2d Mon Sep 17 00:00:00 2001 From: Anne Ebie Date: Fri, 21 Oct 2016 15:54:26 -0400 Subject: [PATCH 04/90] [secmonkey] Organize tests into directories Type: generic Why is this change necessary? Pytest patching has a known issue where patches are ignored when a class has already been loaded by any other test running in the same process. This caused random failures when tests are added that import classes patched by other tests that used to run correctly. This change addresses the need by: Splitting the tests out into directories based on the level in the application of the components being tested and running each directory seperatly. Potential Side Effects: No known side effects --- .travis.yml | 6 +- security_monkey/tests/auditors/__init__.py | 13 + .../tests/{ => auditors}/test_arn.py | 2 +- .../test_elasticsearch_service.py | 5 +- .../tests/{ => auditors}/test_iam.py | 42 ++-- security_monkey/tests/core/__init__.py | 13 + security_monkey/tests/{ => core}/db_mock.py | 8 +- security_monkey/tests/core/monitor_mock.py | 138 +++++++++++ .../tests/{ => core}/test_auditor.py | 31 ++- .../{ => core}/test_exception_logging.py | 30 +-- .../tests/{ => core}/test_monitors.py | 74 +++--- .../tests/{ => core}/test_policydiff.py | 2 +- security_monkey/tests/core/test_scheduler.py | 222 +++++++++++++++++ security_monkey/tests/interface/__init__.py | 13 + .../tests/interface/test_manager.py | 73 ++++++ security_monkey/tests/test_scheduler.py | 227 ------------------ .../tests/watchers/ec2/test_ebs_snapshot.py | 2 +- .../tests/watchers/ec2/test_ebs_volume.py | 2 +- .../tests/watchers/ec2/test_ec2_image.py | 2 +- .../tests/watchers/ec2/test_ec2_instance.py | 2 +- .../watchers/rds/test_rds_db_instance.py | 2 +- .../watchers/rds/test_rds_security_group.py | 2 +- .../watchers/rds/test_rds_subnet_group.py | 4 +- .../tests/watchers/test_lambda_function.py | 2 +- .../tests/watchers/test_route53.py | 2 +- .../tests/watchers/vpc/test_dhcp.py | 2 +- .../tests/watchers/vpc/test_networkacl.py | 2 +- .../tests/watchers/vpc/test_peering.py | 2 +- .../tests/watchers/vpc/test_route_table.py | 2 +- setup.py | 1 + 30 files changed, 594 insertions(+), 334 deletions(-) create mode 100644 security_monkey/tests/auditors/__init__.py rename security_monkey/tests/{ => auditors}/test_arn.py (99%) rename security_monkey/tests/{ => auditors}/test_elasticsearch_service.py (98%) rename security_monkey/tests/{ => auditors}/test_iam.py (90%) create mode 100644 security_monkey/tests/core/__init__.py rename security_monkey/tests/{ => core}/db_mock.py (95%) create mode 100644 security_monkey/tests/core/monitor_mock.py rename security_monkey/tests/{ => core}/test_auditor.py (70%) rename security_monkey/tests/{ => core}/test_exception_logging.py (90%) rename security_monkey/tests/{ => core}/test_monitors.py (66%) rename security_monkey/tests/{ => core}/test_policydiff.py (99%) create mode 100644 security_monkey/tests/core/test_scheduler.py create mode 100644 security_monkey/tests/interface/__init__.py create mode 100644 security_monkey/tests/interface/test_manager.py delete mode 100644 security_monkey/tests/test_scheduler.py diff --git a/.travis.yml b/.travis.yml index 5b5e3db54..a7543f9c8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,7 +36,11 @@ before_script: script: - sh env_tests/test_dart.sh - - coverage run -m py.test security_monkey/tests || exit 1 + - coverage run -m py.test security_monkey/tests/auditors || exit 1 + - coverage run -m py.test security_monkey/tests/watchers || exit 1 + - coverage run -m py.test security_monkey/tests/core || exit 1 + - coverage run -m py.test security_monkey/tests/views || exit 1 + - coverage run -m py.test security_monkey/tests/interface || exit 1 after_success: - coveralls diff --git a/security_monkey/tests/auditors/__init__.py b/security_monkey/tests/auditors/__init__.py new file mode 100644 index 000000000..25b5eeeeb --- /dev/null +++ b/security_monkey/tests/auditors/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/security_monkey/tests/test_arn.py b/security_monkey/tests/auditors/test_arn.py similarity index 99% rename from security_monkey/tests/test_arn.py rename to security_monkey/tests/auditors/test_arn.py index e09ca2d9d..8f72d8ddc 100644 --- a/security_monkey/tests/test_arn.py +++ b/security_monkey/tests/auditors/test_arn.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.tests.test_arn +.. module: security_monkey.tests.auditors.test_arn :platform: Unix .. version:: $$VERSION$$ diff --git a/security_monkey/tests/test_elasticsearch_service.py b/security_monkey/tests/auditors/test_elasticsearch_service.py similarity index 98% rename from security_monkey/tests/test_elasticsearch_service.py rename to security_monkey/tests/auditors/test_elasticsearch_service.py index 5bc6b5c08..35e814747 100644 --- a/security_monkey/tests/test_elasticsearch_service.py +++ b/security_monkey/tests/auditors/test_elasticsearch_service.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.tests.test_elasticsearch_service +.. module: security_monkey.tests.auditors.test_elasticsearch_service :platform: Unix .. version:: $$VERSION$$ @@ -23,8 +23,7 @@ from security_monkey.datastore import NetworkWhitelistEntry, Account from security_monkey.tests import SecurityMonkeyTestCase -from security_monkey.tests.db_mock import MockAccountQuery -from security_monkey import db +from security_monkey.tests.core.db_mock import MockAccountQuery # TODO: Make a ES test for spulec/moto, then make test cases that use it. from security_monkey.watchers.elasticsearch_service import ElasticSearchServiceItem diff --git a/security_monkey/tests/test_iam.py b/security_monkey/tests/auditors/test_iam.py similarity index 90% rename from security_monkey/tests/test_iam.py rename to security_monkey/tests/auditors/test_iam.py index d0f989e6a..1073ff171 100644 --- a/security_monkey/tests/test_iam.py +++ b/security_monkey/tests/auditors/test_iam.py @@ -166,7 +166,7 @@ """ EXTERNAL_CERT = x509.load_pem_x509_certificate(EXTERNAL_VALID_STR, default_backend()) -FULL_ADMIN_POLICY_BARE=""" +FULL_ADMIN_POLICY_BARE = """ { "Statement": { "Effect": "Allow", @@ -175,7 +175,7 @@ } """ -FULL_ADMIN_POLICY_SINGLE_ENTRY=""" +FULL_ADMIN_POLICY_SINGLE_ENTRY = """ { "Statement": { "Effect": "Allow", @@ -184,7 +184,7 @@ } """ -FULL_ADMIN_POLICY_LIST=""" +FULL_ADMIN_POLICY_LIST = """ { "Statement": { "Effect": "Allow", @@ -197,7 +197,7 @@ } """ -NO_ADMIN_POLICY_LIST=""" +NO_ADMIN_POLICY_LIST = """ { "Statement": { "Effect": "Allow", @@ -210,6 +210,7 @@ """ + class MockIAMObj: def __init__(self): self.config = {} @@ -271,50 +272,61 @@ def test_iam_full_admin_only(self): import json from security_monkey.auditors.iam.iam_policy import IAMPolicyAuditor - auditor = IAMPolicyAuditor( accounts=['unittest']) + auditor = IAMPolicyAuditor(accounts=['unittest']) iamobj = MockIAMObj() iamobj.config = {'InlinePolicies': json.loads(FULL_ADMIN_POLICY_BARE)} - self.assertIs(len(iamobj.audit_issues), 0, "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 0, + "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + auditor.library_check_iamobj_has_star_privileges(iamobj, multiple_policies=False) - self.assertIs(len(iamobj.audit_issues), 1, "Policy should have 1 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 1, + "Policy should have 1 alert but has {}".format(len(iamobj.audit_issues))) def test_iam_full_admin_list_single_entry(self): import json from security_monkey.auditors.iam.iam_policy import IAMPolicyAuditor - auditor = IAMPolicyAuditor( accounts=['unittest']) + auditor = IAMPolicyAuditor(accounts=['unittest']) iamobj = MockIAMObj() iamobj.config = {'InlinePolicies': json.loads(FULL_ADMIN_POLICY_SINGLE_ENTRY)} - self.assertIs(len(iamobj.audit_issues), 0, "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 0, + "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + auditor.library_check_iamobj_has_star_privileges(iamobj, multiple_policies=False) - self.assertIs(len(iamobj.audit_issues), 1, "Policy should have 1 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 1, + "Policy should have 1 alert but has {}".format(len(iamobj.audit_issues))) def test_iam_full_admin_list(self): import json from security_monkey.auditors.iam.iam_policy import IAMPolicyAuditor - auditor = IAMPolicyAuditor( accounts=['unittest']) + auditor = IAMPolicyAuditor(accounts=['unittest']) iamobj = MockIAMObj() iamobj.config = {'InlinePolicies': json.loads(FULL_ADMIN_POLICY_LIST)} - self.assertIs(len(iamobj.audit_issues), 0, "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 0, + "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + auditor.library_check_iamobj_has_star_privileges(iamobj, multiple_policies=False) - self.assertIs(len(iamobj.audit_issues), 1, "Policy should have 1 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 1, + "Policy should have 1 alert but has {}".format(len(iamobj.audit_issues))) def test_iam_no_admin_list(self): import json from security_monkey.auditors.iam.iam_policy import IAMPolicyAuditor - auditor = IAMPolicyAuditor( accounts=['unittest']) + auditor = IAMPolicyAuditor(accounts=['unittest']) iamobj = MockIAMObj() iamobj.config = {'InlinePolicies': json.loads(NO_ADMIN_POLICY_LIST)} - self.assertIs(len(iamobj.audit_issues), 0, "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + self.assertIs(len(iamobj.audit_issues), 0, + "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) + auditor.library_check_iamobj_has_star_privileges(iamobj, multiple_policies=False) self.assertIs(len(iamobj.audit_issues), 0, "Policy should have 0 alert but has {}".format(len(iamobj.audit_issues))) diff --git a/security_monkey/tests/core/__init__.py b/security_monkey/tests/core/__init__.py new file mode 100644 index 000000000..25b5eeeeb --- /dev/null +++ b/security_monkey/tests/core/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/security_monkey/tests/db_mock.py b/security_monkey/tests/core/db_mock.py similarity index 95% rename from security_monkey/tests/db_mock.py rename to security_monkey/tests/core/db_mock.py index a447a6989..a8c9be472 100644 --- a/security_monkey/tests/db_mock.py +++ b/security_monkey/tests/core/db_mock.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.tests.db_mock +.. module: security_monkey.tests.core.db_mock :platform: Unix .. version:: $$VERSION$$ @@ -57,6 +57,9 @@ def __init__(self): def add_account(self, account): self.test_accounts.append(account) + def clear(self): + self.test_accounts = [] + def filter(self, *criterion): if self.filtered_accounts is None: self.filtered_accounts = list(self.test_accounts) @@ -97,3 +100,6 @@ class MockDBSession(): def expunge(self, item): pass + + def commit(self): + pass diff --git a/security_monkey/tests/core/monitor_mock.py b/security_monkey/tests/core/monitor_mock.py new file mode 100644 index 000000000..0289902fe --- /dev/null +++ b/security_monkey/tests/core/monitor_mock.py @@ -0,0 +1,138 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.core.mock_monitor + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + +""" +from security_monkey.watcher import ChangeItem + +from collections import defaultdict + +RUNTIME_WATCHERS = defaultdict(list) +RUNTIME_AUDITORS = defaultdict(list) +CURRENT_MONITORS = [] + + +class MockMonitor(object): + def __init__(self, watcher, auditors): + self.watcher = watcher + self.auditors = auditors + + +class MockRunnableWatcher(object): + def __init__(self, index, interval): + self.index = index + self.interval = interval + self.i_am_singular = index + self.created_items = [] + self.deleted_items = [] + self.changed_items = [] + + def slurp(self): + RUNTIME_WATCHERS[self.index].append(self) + item_list = [] + exception_map = {} + return item_list, exception_map + + def save(self): + pass + + def get_interval(self): + return self.interval + + def find_changes(self, current=[], exception_map={}): + self.created_items.append(ChangeItem(index=self.index)) + + +class MockRunnableAuditor(object): + def __init__(self, index, support_auditor_indexes, support_watcher_indexes): + self.index = index + self.support_auditor_indexes = support_auditor_indexes + self.support_watcher_indexes = support_watcher_indexes + + def audit_all_objects(self): + RUNTIME_AUDITORS[self.index].append(self) + + def audit_these_objects(self, items): + RUNTIME_AUDITORS[self.index].append(self) + + def save_issues(self): + pass + + def applies_to_account(self, db_account): + return True + + def read_previous_items(self): + return [ChangeItem(index=self.index)] + + +def build_mock_result(watcher_configs, auditor_configs): + """ + Builds mock monitor results that can be used to override the results of the + monitor methods. + """ + del CURRENT_MONITORS[:] + + for config in watcher_configs: + watcher = mock_watcher(config) + + auditors = [] + + for config in auditor_configs: + if config['index'] == watcher.index: + auditors.append(mock_auditor(config)) + + CURRENT_MONITORS.append(MockMonitor(watcher, auditors)) + + +def mock_watcher(config): + """ + Builds a mock watcher from a config dictionary like: + { + 'index': 'index1', + 'interval: 15' + } + """ + return MockRunnableWatcher(config['index'], config['interval']) + + +def mock_auditor(config): + """ + Builds a mock auditor from a config dictionary like: + { + 'index': 'index1', + 'support_auditor_indexes': [], + 'support_watcher_indexes': ['index2'] + } + """ + return MockRunnableAuditor(config['index'], + config['support_auditor_indexes'], + config['support_watcher_indexes']) + + +def mock_all_monitors(account_name, debug=False): + return CURRENT_MONITORS + + +def mock_get_monitors(account_name, monitor_names, debug=False): + monitors = [] + for monitor in CURRENT_MONITORS: + if monitor.watcher.index in monitor_names: + monitors.append(monitor) + + return monitors diff --git a/security_monkey/tests/test_auditor.py b/security_monkey/tests/core/test_auditor.py similarity index 70% rename from security_monkey/tests/test_auditor.py rename to security_monkey/tests/core/test_auditor.py index 7a7bdd0cb..5583c9339 100644 --- a/security_monkey/tests/test_auditor.py +++ b/security_monkey/tests/core/test_auditor.py @@ -11,13 +11,42 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +""" +.. module: security_monkey.tests.core.test_auditor + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watcher import ChangeItem -from security_monkey.datastore import Item, ItemAudit +from security_monkey.datastore import Item, ItemAudit, Account, Technology, ItemRevision from security_monkey.auditor import Auditor +from mixer.backend.flask import mixer + class AuditorTestCase(SecurityMonkeyTestCase): + def test_save_issues(self): + mixer.init_app(self.app) + test_account = mixer.blend(Account, name='test_account') + technology = mixer.blend(Technology, name='testtech') + item = Item(region="us-west-2", name="testitem", technology=technology, account=test_account) + revision = mixer.blend(ItemRevision, item=item, config={}, active=True) + item.latest_revision_id = revision.id + mixer.blend(ItemAudit, item=item, issue='test issue') + + auditor = Auditor(accounts=[test_account.name]) + auditor.index = technology.name + auditor.i_am_singular = technology.name + auditor.audit_all_objects() + + try: + auditor.save_issues() + except AttributeError as e: + self.fail("Auditor.save_issues() raised AttributeError unexpectedly: {}".format(e.message)) def test_link_to_support_item_issue(self): sub_item_id = 2 diff --git a/security_monkey/tests/test_exception_logging.py b/security_monkey/tests/core/test_exception_logging.py similarity index 90% rename from security_monkey/tests/test_exception_logging.py rename to security_monkey/tests/core/test_exception_logging.py index 4176f35af..3ac1d942c 100644 --- a/security_monkey/tests/test_exception_logging.py +++ b/security_monkey/tests/core/test_exception_logging.py @@ -1,7 +1,8 @@ -from ..datastore import Account, Technology, Item, store_exception, ExceptionLogs, clear_old_exceptions, AccountType -from . import SecurityMonkeyTestCase, db - -from manage import clear_expired_exceptions +from security_monkey.datastore import Account, Technology, Item +from security_monkey.datastore import store_exception, ExceptionLogs +from security_monkey.datastore import clear_old_exceptions, AccountType +from security_monkey import db +from security_monkey.tests import SecurityMonkeyTestCase import traceback import datetime @@ -191,27 +192,6 @@ def test_exception_clearing(self): assert len(exc_list) == 1 - def test_manager_command(self): - location = ("iamrole", "testing", "us-west-2", "testrole") - - for i in range(0, 5): - try: - raise ValueError("This is test: {}".format(i)) - except ValueError as e: - test_exception = e - - store_exception("tests", location, test_exception, - ttl=(datetime.datetime.now() - datetime.timedelta(days=1))) - - store_exception("tests", location, test_exception) - - clear_expired_exceptions() - - # Get all the exceptions: - exc_list = ExceptionLogs.query.all() - - assert len(exc_list) == 1 - def test_store_exception_with_new_techid(self): try: raise ValueError("This is a test") diff --git a/security_monkey/tests/test_monitors.py b/security_monkey/tests/core/test_monitors.py similarity index 66% rename from security_monkey/tests/test_monitors.py rename to security_monkey/tests/core/test_monitors.py index 5d40c9539..88619a495 100644 --- a/security_monkey/tests/test_monitors.py +++ b/security_monkey/tests/core/test_monitors.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.tests.test_auditor +.. module: security_monkey.tests.core.test_monitors :platform: Unix .. version:: $$VERSION$$ @@ -24,63 +24,36 @@ from security_monkey.watcher import watcher_registry from security_monkey.auditor import auditor_registry from security_monkey.monitors import get_monitors_and_dependencies -from security_monkey.tests.db_mock import MockAccountQuery, MockDBSession from security_monkey.datastore import Account, AccountType +from security_monkey import db +from security_monkey.watcher import Watcher +from security_monkey.auditor import Auditor from mock import patch from collections import defaultdict -from security_monkey import app -mock_query = MockAccountQuery() -mock_db_session = MockDBSession() - - -test_account = Account() -test_account.name = "TEST_ACCOUNT" -test_account.notes = "TEST ACCOUNT" -test_account.s3_name = "TEST_ACCOUNT" -test_account.number = "012345678910" -test_account.role_name = "TEST_ACCOUNT" -test_account.account_type = AccountType(name='AWS') -test_account.third_party = False -test_account.active = True -mock_query.add_account(test_account) - - -class MockWatcher(object): +class MockWatcher(Watcher): def __init__(self, accounts=None, debug=False): - self.accounts = accounts - + super(MockWatcher, self).__init__(accounts=accounts, debug=debug) -class MockAuditor(object): - support_auditor_indexes = [] - support_watcher_indexes = [] +class MockAuditor(Auditor): def __init__(self, accounts=None, debug=False): - self.accounts = accounts + super(MockAuditor, self).__init__(accounts=accounts, debug=debug) - def applies_to_account(self, account): - return True test_watcher_registry = {} test_auditor_registry = defaultdict(list) watcher_configs = [ - { 'type': 'MockWatcher1', 'index': 'index1', 'account_type': 'AWS' }, - { 'type': 'MockWatcher2', 'index': 'index2', 'account_type': 'AWS' }, - { 'type': 'MockWatcher3', 'index': 'index3', 'account_type': 'AWS' } + {'type': 'MockWatcher1', 'index': 'index1'}, + {'type': 'MockWatcher2', 'index': 'index2'}, + {'type': 'MockWatcher3', 'index': 'index3'} ] for config in watcher_configs: - watcher = type( - config['type'], (MockWatcher,), - { - 'index': config['index'], - 'account_type': config['account_type'] - } - ) - + watcher = type(config['type'], (MockWatcher,), {'index': config['index']}) test_watcher_registry[config['index']] = watcher auditor_configs = [ @@ -122,26 +95,37 @@ def applies_to_account(self, account): test_auditor_registry[config['index']].append(auditor) + class MonitorTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + account_result = Account.query.filter(Account.name == 'TEST_ACCOUNT').first() + if not account_result: + account_type = AccountType(name='AWS') + db.session.add(account_type) + db.session.commit() + + account_result = Account( + name='TEST_ACCOUNT', + number='012345678910', + s3_name='testing', + role_name='SecurityMonkey', + account_type_id=account_type.id + ) + db.session.add(account_result) + db.session.commit() - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) @patch.dict(watcher_registry, test_watcher_registry, clear=True) @patch.dict(auditor_registry, test_auditor_registry, clear=True) def test_get_monitors_and_dependencies_all(self): mons = get_monitors_and_dependencies('TEST_ACCOUNT', test_watcher_registry.keys()) assert len(mons) == 3 - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) @patch.dict(watcher_registry, test_watcher_registry, clear=True) @patch.dict(auditor_registry, test_auditor_registry, clear=True) def test_get_monitors_and_dependencies_all_dependencies(self): mons = get_monitors_and_dependencies('TEST_ACCOUNT', ['index2']) assert len(mons) == 3 - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) @patch.dict(watcher_registry, test_watcher_registry, clear=True) @patch.dict(auditor_registry, test_auditor_registry, clear=True) def test_get_monitors_and_dependencies_no_dependencies(self): diff --git a/security_monkey/tests/test_policydiff.py b/security_monkey/tests/core/test_policydiff.py similarity index 99% rename from security_monkey/tests/test_policydiff.py rename to security_monkey/tests/core/test_policydiff.py index e2184f973..2bb7aa5df 100644 --- a/security_monkey/tests/test_policydiff.py +++ b/security_monkey/tests/core/test_policydiff.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.tests.test_policydiff +.. module: security_monkey.tests.core.test_policydiff :platform: Unix .. version:: $$VERSION$$ diff --git a/security_monkey/tests/core/test_scheduler.py b/security_monkey/tests/core/test_scheduler.py new file mode 100644 index 000000000..9389f280b --- /dev/null +++ b/security_monkey/tests/core/test_scheduler.py @@ -0,0 +1,222 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.core.test_scheduler + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + +""" +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.datastore import Account, AccountType +from security_monkey.tests.core.db_mock import MockAccountQuery, MockDBSession +from security_monkey.tests.core.monitor_mock import RUNTIME_WATCHERS, RUNTIME_AUDITORS +from security_monkey.tests.core.monitor_mock import build_mock_result +from security_monkey.tests.core.monitor_mock import mock_get_monitors, mock_all_monitors + +from mock import patch + + +mock_query = MockAccountQuery() +mock_db_session = MockDBSession() + +watcher_configs = [ + {'index': 'index1', 'interval': 15}, + {'index': 'index2', 'interval': 15}, + {'index': 'index3', 'interval': 60} +] + + +auditor_configs = [ + { + 'index': 'index1', + 'support_auditor_indexes': [], + 'support_watcher_indexes': [] + }, + { + 'index': 'index2', + 'support_auditor_indexes': [], + 'support_watcher_indexes': [] + }, + { + 'index': 'index3', + 'support_auditor_indexes': [], + 'support_watcher_indexes': [] + } +] + + +@patch('security_monkey.monitors.all_monitors', mock_all_monitors) +@patch('security_monkey.monitors.get_monitors', mock_get_monitors) +class SchedulerTestCase(SecurityMonkeyTestCase): + test_account1 = None + test_account2 = None + test_account3 = None + test_account4 = None + + def setUp(self): + mock_query.clear() + self.test_account1 = Account() + self.test_account1.name = "TEST_ACCOUNT1" + self.test_account1.notes = "TEST ACCOUNT1" + self.test_account1.s3_name = "TEST_ACCOUNT1" + self.test_account1.number = "012345678910" + self.test_account1.role_name = "TEST_ACCOUNT" + self.test_account1.account_type = AccountType(name='AWS') + self.test_account1.third_party = False + self.test_account1.active = True + mock_query.add_account(self.test_account1) + + self.test_account2 = Account() + self.test_account2.name = "TEST_ACCOUNT2" + self.test_account2.notes = "TEST ACCOUNT2" + self.test_account2.s3_name = "TEST_ACCOUNT2" + self.test_account2.number = "123123123123" + self.test_account2.role_name = "TEST_ACCOUNT" + self.test_account2.account_type = AccountType(name='AWS') + self.test_account2.third_party = False + self.test_account2.active = True + mock_query.add_account(self.test_account2) + + self.test_account3 = Account() + self.test_account3.name = "TEST_ACCOUNT3" + self.test_account3.notes = "TEST ACCOUNT3" + self.test_account3.s3_name = "TEST_ACCOUNT3" + self.test_account3.number = "012345678910" + self.test_account3.role_name = "TEST_ACCOUNT" + self.test_account3.account_type = AccountType(name='AWS') + self.test_account3.third_party = False + self.test_account3.active = False + mock_query.add_account(self.test_account3) + + self.test_account4 = Account() + self.test_account4.name = "TEST_ACCOUNT4" + self.test_account4.notes = "TEST ACCOUNT4" + self.test_account4.s3_name = "TEST_ACCOUNT4" + self.test_account4.number = "123123123123" + self.test_account4.role_name = "TEST_ACCOUNT" + self.test_account4.account_type = AccountType(name='AWS') + self.test_account4.third_party = False + self.test_account4.active = False + mock_query.add_account(self.test_account4) + + RUNTIME_WATCHERS.clear() + RUNTIME_AUDITORS.clear() + + @patch('security_monkey.datastore.Account.query', new=mock_query) + @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) + def test_find_all_changes(self): + from security_monkey.scheduler import find_changes + build_mock_result(watcher_configs, auditor_configs) + + find_changes(['TEST_ACCOUNT1', 'TEST_ACCOUNT2'], + ['index1', 'index2', 'index3']) + + watcher_keys = RUNTIME_WATCHERS.keys() + self.assertEqual(first=3, second=len(watcher_keys), + msg="Should run 3 watchers but ran {}" + .format(len(watcher_keys))) + + self.assertTrue('index1' in watcher_keys, + msg="Watcher index1 not run") + self.assertTrue('index2' in watcher_keys, + msg="Watcher index3 not run") + self.assertTrue('index3' in watcher_keys, + msg="Watcher index3 not run") + + self.assertEqual(first=2, second=len(RUNTIME_WATCHERS['index1']), + msg="Watcher index1 should run twice but ran {} times" + .format(len(RUNTIME_WATCHERS['index1']))) + self.assertEqual(first=2, second=len(RUNTIME_WATCHERS['index2']), + msg="Watcher index2 should run twice but ran {} times" + .format(len(RUNTIME_WATCHERS['index2']))) + self.assertEqual(first=2, second=len(RUNTIME_WATCHERS['index3']), + msg="Watcher index2 should run twice but ran {} times" + .format(len(RUNTIME_WATCHERS['index3']))) + + auditor_keys = RUNTIME_AUDITORS.keys() + self.assertEqual(first=3, second=len(auditor_keys), + msg="Should run 3 auditors but ran {}" + .format(len(auditor_keys))) + + self.assertTrue('index1' in auditor_keys, + msg="Auditor index1 not run") + self.assertTrue('index2' in auditor_keys, + msg="Auditor index2 not run") + self.assertTrue('index3' in auditor_keys, + msg="Auditor index3 not run") + + self.assertEqual(first=2, second=len(RUNTIME_AUDITORS['index1']), + msg="Auditor index1 should run twice but ran {} times" + .format(len(RUNTIME_AUDITORS['index1']))) + self.assertEqual(first=2, second=len(RUNTIME_AUDITORS['index2']), + msg="Auditor index2 should run twice but ran {} times" + .format(len(RUNTIME_AUDITORS['index2']))) + self.assertEqual(first=2, second=len(RUNTIME_AUDITORS['index3']), + msg="Auditor index3 should run twice but ran {} times" + .format(len(RUNTIME_AUDITORS['index3']))) + + @patch('security_monkey.datastore.Account.query', new=mock_query) + @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) + def test_find_account_changes(self): + from security_monkey.scheduler import find_changes + build_mock_result(watcher_configs, auditor_configs) + + find_changes(['TEST_ACCOUNT1'], + ['index1', 'index2', 'index3']) + + watcher_keys = RUNTIME_WATCHERS.keys() + self.assertEqual(first=3, second=len(watcher_keys), + msg="Should run 3 watchers but ran {}" + .format(len(watcher_keys))) + + self.assertTrue('index1' in watcher_keys, + msg="Watcher index1 not run") + self.assertTrue('index2' in watcher_keys, + msg="Watcher index3 not run") + self.assertTrue('index3' in watcher_keys, + msg="Watcher index3 not run") + + self.assertEqual(first=1, second=len(RUNTIME_WATCHERS['index1']), + msg="Watcher index1 should run once but ran {} times" + .format(len(RUNTIME_WATCHERS['index1']))) + self.assertEqual(first=1, second=len(RUNTIME_WATCHERS['index2']), + msg="Watcher index2 should run once but ran {} times" + .format(len(RUNTIME_WATCHERS['index2']))) + self.assertEqual(first=1, second=len(RUNTIME_WATCHERS['index3']), + msg="Watcher index2 should run once but ran {} times" + .format(len(RUNTIME_WATCHERS['index3']))) + + auditor_keys = RUNTIME_AUDITORS.keys() + self.assertEqual(first=3, second=len(auditor_keys), + msg="Should run 3 auditors but ran {}" + .format(len(auditor_keys))) + + self.assertTrue('index1' in auditor_keys, + msg="Auditor index1 not run") + self.assertTrue('index2' in auditor_keys, + msg="Auditor index2 not run") + self.assertTrue('index3' in auditor_keys, + msg="Auditor index3 not run") + + self.assertEqual(first=1, second=len(RUNTIME_AUDITORS['index1']), + msg="Auditor index1 should run once but ran {} times" + .format(len(RUNTIME_AUDITORS['index1']))) + self.assertEqual(first=1, second=len(RUNTIME_AUDITORS['index2']), + msg="Auditor index2 should run once but ran {} times" + .format(len(RUNTIME_AUDITORS['index2']))) + self.assertEqual(first=1, second=len(RUNTIME_AUDITORS['index3']), + msg="Auditor index3 should run once but ran {} times" + .format(len(RUNTIME_AUDITORS['index3']))) diff --git a/security_monkey/tests/interface/__init__.py b/security_monkey/tests/interface/__init__.py new file mode 100644 index 000000000..25b5eeeeb --- /dev/null +++ b/security_monkey/tests/interface/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/security_monkey/tests/interface/test_manager.py b/security_monkey/tests/interface/test_manager.py new file mode 100644 index 000000000..ae1aecc73 --- /dev/null +++ b/security_monkey/tests/interface/test_manager.py @@ -0,0 +1,73 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.interface.test_manager + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + +""" + +from security_monkey.datastore import Account, Technology, Item, store_exception, ExceptionLogs, AccountType +from security_monkey import db +from security_monkey.tests import SecurityMonkeyTestCase + +from manage import clear_expired_exceptions + +import datetime + + +class ManageTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + account_type_result = AccountType.query.filter(AccountType.name == 'AWS').first() + if not account_type_result: + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(number="012345678910", name="testing", + s3_name="testing", role_name="SecurityMonkey", + account_type_id=account_type_result.id) + self.technology = Technology(name="iamrole") + self.item = Item(region="us-west-2", name="testrole", + arn="arn:aws:iam::012345678910:role/testrole", technology=self.technology, + account=self.account) + + db.session.add(self.account) + db.session.add(self.technology) + db.session.add(self.item) + + db.session.commit() + + def test_clear_expired_exceptions(self): + location = ("iamrole", "testing", "us-west-2", "testrole") + + for i in range(0, 5): + try: + raise ValueError("This is test: {}".format(i)) + except ValueError as e: + test_exception = e + + store_exception("tests", location, test_exception, + ttl=(datetime.datetime.now() - datetime.timedelta(days=1))) + + store_exception("tests", location, test_exception) + + clear_expired_exceptions() + + # Get all the exceptions: + exc_list = ExceptionLogs.query.all() + + assert len(exc_list) == 1 diff --git a/security_monkey/tests/test_scheduler.py b/security_monkey/tests/test_scheduler.py deleted file mode 100644 index b9742d8be..000000000 --- a/security_monkey/tests/test_scheduler.py +++ /dev/null @@ -1,227 +0,0 @@ -# Copyright 2016 Bridgewater Associates -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -.. module: security_monkey.tests.test_auditor - :platform: Unix - -.. version:: $$VERSION$$ -.. moduleauthor:: Bridgewater OSS - - -""" -from security_monkey.tests import SecurityMonkeyTestCase -from security_monkey.watcher import watcher_registry -from security_monkey.auditor import auditor_registry -from security_monkey.datastore import Account, AccountType -from security_monkey.tests.db_mock import MockAccountQuery, MockDBSession -from security_monkey.scheduler import find_changes - -from mock import patch -from collections import defaultdict -from copy import copy - -RUNTIME_WATCHERS = defaultdict(list) -RUNTIME_AUDITORS = defaultdict(list) - -orig_watcher_registry = copy(watcher_registry) -orig_auditor_registry = copy(auditor_registry) - - -def slurp(self): - RUNTIME_WATCHERS[self.__class__.__name__].append(self) - item_list = [] - exception_map = {} - return item_list, exception_map - - -def save(self): - pass - - -def audit_all_objects(self): - RUNTIME_AUDITORS[self.__class__.__name__].append(self) - - -def save_issues(self): - pass - - -def applies_to_account(self, account): - return True - -mock_query = MockAccountQuery() -mock_db_session = MockDBSession() - -test_account = Account() -test_account.name = "TEST_ACCOUNT" -test_account.notes = "TEST ACCOUNT" -test_account.s3_name = "TEST_ACCOUNT" -test_account.number = "012345678910" -test_account.role_name = "TEST_ACCOUNT" -test_account.account_type = AccountType(name='AWS') -test_account.third_party = False -test_account.active = True -mock_query.add_account(test_account) - -test_account2 = Account() -test_account2.name = "TEST_ACCOUNT2" -test_account2.notes = "TEST ACCOUNT2" -test_account2.s3_name = "TEST_ACCOUNT2" -test_account2.number = "123123123123" -test_account2.role_name = "TEST_ACCOUNT" -test_account2.account_type = AccountType(name='AWS') -test_account2.third_party = False -test_account2.active = True -mock_query.add_account(test_account2) - - -class MockWatcher(object): - - def __init__(self, accounts=None, debug=False): - self.accounts = accounts - - def find_changes(self, current=[], exception_map={}): - pass - - -class MockAuditor(object): - - def __init__(self, accounts=None, debug=False): - self.accounts = accounts - -test_watcher_registry = {} -test_auditor_registry = {} -for key in watcher_registry: - base_watcher_class = watcher_registry[key] - test_watcher_registry[key] = type( - base_watcher_class.__name__, (MockWatcher,), - { - 'slurp': slurp, - 'save': save, - 'index': base_watcher_class.index, - 'account_type': base_watcher_class.account_type - } - ) - -for key in auditor_registry: - auditor_list = [] - for base_auditor_class in auditor_registry[key]: - auditor = type( - base_auditor_class.__name__, (MockAuditor,), - { - 'audit_all_objects': audit_all_objects, - 'save_issues': save_issues, - 'index': base_auditor_class.index, - 'support_auditor_indexes': base_auditor_class.support_auditor_indexes, - 'support_watcher_indexes': base_auditor_class.support_watcher_indexes, - 'applies_to_account': applies_to_account - } - ) - - auditor_list.append(auditor) - test_auditor_registry[key] = auditor_list - - -class SchedulerTestCase(SecurityMonkeyTestCase): - - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) - @patch.dict(watcher_registry, test_watcher_registry, clear=True) - @patch.dict(auditor_registry, test_auditor_registry, clear=True) - def test_find_all_changes(self): - RUNTIME_AUDITORS.clear() - RUNTIME_WATCHERS.clear() - find_changes(['TEST_ACCOUNT', 'TEST_ACCOUNT2'], - watcher_registry.keys()) - - expected_watcher_count = 0 - expected_auditor_count = 0 - for key in orig_watcher_registry: - expected_watcher_count = expected_watcher_count + 1 - wa_list = RUNTIME_WATCHERS[orig_watcher_registry[key].__name__] - self.assertEqual(first=len(wa_list), second=2, - msg="Watcher {} should run once but ran {} time(s)" - .format(orig_watcher_registry[key].__name__, len(wa_list))) - - for au in orig_auditor_registry[orig_watcher_registry[key].index]: - expected_auditor_count = expected_auditor_count + 1 - au_list = RUNTIME_AUDITORS[au.__name__] - self.assertEqual(first=len(au_list), second=2, - msg="Auditor {} should run once but ran {} time(s)" - .format(au.__name__, len(au_list))) - - self.assertEqual(first=len(RUNTIME_WATCHERS.keys()), second=expected_watcher_count, - msg="Should run {} watchers but ran {}" - .format(expected_watcher_count, len(RUNTIME_WATCHERS.keys()))) - - self.assertEqual(first=len(RUNTIME_AUDITORS.keys()), second=expected_auditor_count, - msg="Should run {} auditor(s) but ran {}" - .format(expected_auditor_count, len(RUNTIME_AUDITORS.keys()))) - - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) - @patch.dict(watcher_registry, test_watcher_registry, clear=True) - @patch.dict(auditor_registry, test_auditor_registry, clear=True) - def test_find_account_changes(self): - RUNTIME_AUDITORS.clear() - RUNTIME_WATCHERS.clear() - find_changes(['TEST_ACCOUNT'], watcher_registry.keys()) - - expected_watcher_count = 0 - expected_auditor_count = 0 - for key in orig_watcher_registry: - expected_watcher_count = expected_watcher_count + 1 - wa_list = RUNTIME_WATCHERS[orig_watcher_registry[key].__name__] - self.assertEqual(first=len(wa_list), second=1, - msg="Watcher {} should run once but ran {} time(s)" - .format(orig_watcher_registry[key].__name__, len(wa_list))) - for au in auditor_registry[orig_watcher_registry[key].index]: - expected_auditor_count = expected_auditor_count + 1 - au_list = RUNTIME_AUDITORS[au.__name__] - self.assertEqual(first=len(au_list), second=1, - msg="Auditor {} should run once but ran {} time(s)" - .format(au.__name__, len(au_list))) - - self.assertEqual(first=len(RUNTIME_WATCHERS.keys()), second=expected_watcher_count, - msg="Should run {} watchers but ran {}" - .format(expected_watcher_count, len(RUNTIME_WATCHERS.keys()))) - - self.assertEqual(first=len(RUNTIME_AUDITORS.keys()), second=expected_auditor_count, - msg="Should run {} auditor(s) but ran {}" - .format(expected_auditor_count, len(RUNTIME_AUDITORS.keys()))) - - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) - @patch.dict(watcher_registry, test_watcher_registry, clear=True) - @patch.dict(auditor_registry, test_auditor_registry, clear=True) - def test_find_monitor_change(self): - RUNTIME_AUDITORS.clear() - RUNTIME_WATCHERS.clear() - find_changes(['TEST_ACCOUNT'], ['s3']) - - self.assertEqual(first=len(RUNTIME_WATCHERS.keys()), second=1, - msg="Should run one watchers but ran {}" - .format(len(RUNTIME_WATCHERS.keys()))) - - expected_auditor_count = 0 - for au in auditor_registry['s3']: - expected_auditor_count = expected_auditor_count + 1 - au_list = RUNTIME_AUDITORS[au.__name__] - self.assertEqual(first=len(au_list), second=1, - msg="Auditor {} should run once but ran {} time(s)" - .format(au.__name__, len(au_list))) - - self.assertEqual(first=len(RUNTIME_AUDITORS.keys()), second=expected_auditor_count, - msg="Should run {} auditor but ran {}" - .format(expected_auditor_count, len(RUNTIME_AUDITORS.keys()))) diff --git a/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py b/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py index 2cc57a882..5bf2d5932 100644 --- a/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py +++ b/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.ec2.ebs_snapshot import EBSSnapshot from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/ec2/test_ebs_volume.py b/security_monkey/tests/watchers/ec2/test_ebs_volume.py index 64c48556a..9b55a1d95 100644 --- a/security_monkey/tests/watchers/ec2/test_ebs_volume.py +++ b/security_monkey/tests/watchers/ec2/test_ebs_volume.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.ec2.ebs_volume import EBSVolume from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/ec2/test_ec2_image.py b/security_monkey/tests/watchers/ec2/test_ec2_image.py index 556bdd3c7..a0ca77b02 100644 --- a/security_monkey/tests/watchers/ec2/test_ec2_image.py +++ b/security_monkey/tests/watchers/ec2/test_ec2_image.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.ec2.ec2_image import EC2Image from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/ec2/test_ec2_instance.py b/security_monkey/tests/watchers/ec2/test_ec2_instance.py index 9dd9c7416..035507962 100644 --- a/security_monkey/tests/watchers/ec2/test_ec2_instance.py +++ b/security_monkey/tests/watchers/ec2/test_ec2_instance.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.ec2.ec2_instance import EC2Instance from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/rds/test_rds_db_instance.py b/security_monkey/tests/watchers/rds/test_rds_db_instance.py index 40ebd68d4..a748d909f 100644 --- a/security_monkey/tests/watchers/rds/test_rds_db_instance.py +++ b/security_monkey/tests/watchers/rds/test_rds_db_instance.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.rds.rds_db_instance import RDSDBInstance from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_rds diff --git a/security_monkey/tests/watchers/rds/test_rds_security_group.py b/security_monkey/tests/watchers/rds/test_rds_security_group.py index 6a984a9f9..78a354548 100644 --- a/security_monkey/tests/watchers/rds/test_rds_security_group.py +++ b/security_monkey/tests/watchers/rds/test_rds_security_group.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.rds.rds_security_group import RDSSecurityGroup from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_rds diff --git a/security_monkey/tests/watchers/rds/test_rds_subnet_group.py b/security_monkey/tests/watchers/rds/test_rds_subnet_group.py index 16767a55b..4cea5949d 100644 --- a/security_monkey/tests/watchers/rds/test_rds_subnet_group.py +++ b/security_monkey/tests/watchers/rds/test_rds_subnet_group.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.rds.rds_subnet_group import RDSSubnetGroup from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_rds, mock_ec2 @@ -49,7 +49,7 @@ def test_slurp(self): test_account.role_name = "TEST_ACCOUNT" mock_query.add_account(test_account) - vpc_conn = boto.vpc.connect_to_region("us-east-1") + vpc_conn = boto.connect_vpc('the_key', 'the_secret') vpc = vpc_conn.create_vpc("10.0.0.0/16") subnet = vpc_conn.create_subnet(vpc.id, "10.1.0.0/24") diff --git a/security_monkey/tests/watchers/test_lambda_function.py b/security_monkey/tests/watchers/test_lambda_function.py index 056aacd69..acc7ed954 100644 --- a/security_monkey/tests/watchers/test_lambda_function.py +++ b/security_monkey/tests/watchers/test_lambda_function.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.lambda_function import LambdaFunction from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_lambda diff --git a/security_monkey/tests/watchers/test_route53.py b/security_monkey/tests/watchers/test_route53.py index 0354a751b..60015b84f 100644 --- a/security_monkey/tests/watchers/test_route53.py +++ b/security_monkey/tests/watchers/test_route53.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.route53 import Route53 from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_route53 diff --git a/security_monkey/tests/watchers/vpc/test_dhcp.py b/security_monkey/tests/watchers/vpc/test_dhcp.py index bb23dbb9e..2561a466e 100644 --- a/security_monkey/tests/watchers/vpc/test_dhcp.py +++ b/security_monkey/tests/watchers/vpc/test_dhcp.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.vpc.dhcp import DHCP from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/vpc/test_networkacl.py b/security_monkey/tests/watchers/vpc/test_networkacl.py index b6f8f8a02..37c00ba2c 100644 --- a/security_monkey/tests/watchers/vpc/test_networkacl.py +++ b/security_monkey/tests/watchers/vpc/test_networkacl.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.vpc.networkacl import NetworkACL from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/vpc/test_peering.py b/security_monkey/tests/watchers/vpc/test_peering.py index 44a940925..6eb7696ae 100644 --- a/security_monkey/tests/watchers/vpc/test_peering.py +++ b/security_monkey/tests/watchers/vpc/test_peering.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.vpc.peering import Peering from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 diff --git a/security_monkey/tests/watchers/vpc/test_route_table.py b/security_monkey/tests/watchers/vpc/test_route_table.py index 0afa4f3ce..eba4bb660 100644 --- a/security_monkey/tests/watchers/vpc/test_route_table.py +++ b/security_monkey/tests/watchers/vpc/test_route_table.py @@ -23,7 +23,7 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watchers.vpc.route_table import RouteTable from security_monkey.datastore import Account -from security_monkey.tests.db_mock import MockAccountQuery +from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 diff --git a/setup.py b/setup.py index 7fdbe6389..a083bb8ce 100644 --- a/setup.py +++ b/setup.py @@ -54,6 +54,7 @@ 'onelogin': ['python-saml>=2.2.0'], 'tests': [ 'nose==1.3.0', + 'mixer==5.5.7', 'mock==1.0.1', 'moto==0.4.30', 'freezegun>=0.3.7' From 76e3aa07c03859db98ce1c0b547dc06d659da525 Mon Sep 17 00:00:00 2001 From: Anne Ebie Date: Thu, 12 Jan 2017 18:13:01 -0500 Subject: [PATCH 05/90] [secmonkey] Fix KMSAuditor exceptions Type: generic-bugfix Why is this change necessary? The Netflix merge introduced a two defects: 1) Did not insure condition_accounts was created before used 2) Moved the arn check inside a condition block This change addresses the need by: Move condition_accounts and arn check to cover all paths Potential Side Effects: No known side effects --- security_monkey/auditors/kms.py | 2 +- security_monkey/tests/auditors/test_kms.py | 116 +++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 security_monkey/tests/auditors/test_kms.py diff --git a/security_monkey/auditors/kms.py b/security_monkey/auditors/kms.py index e11a72ab1..b183afb5a 100644 --- a/security_monkey/auditors/kms.py +++ b/security_monkey/auditors/kms.py @@ -60,9 +60,9 @@ def check_for_kms_policy_with_foreign_account(self, kms_item): for policy in key_policies: for statement in policy.get("Statement"): + condition_accounts = [] if 'Condition' in statement: condition = statement.get('Condition') - condition_accounts = [] if condition: condition_accounts = extract_condition_account_numbers(condition) diff --git a/security_monkey/tests/auditors/test_kms.py b/security_monkey/tests/auditors/test_kms.py new file mode 100644 index 000000000..811667e90 --- /dev/null +++ b/security_monkey/tests/auditors/test_kms.py @@ -0,0 +1,116 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.test_kms + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" + +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.auditors.kms import KMSAuditor +from security_monkey.watchers.kms import KMSMasterKey + +key_no_condition = { + "Origin": "AWS_KMS", + "KeyId": "key_id", + "Description": "Description", + "Enabled": True, + "KeyUsage": "ENCRYPT_DECRYPT", + "Grants": [], + "Policies": [ + { + "Version": "2012-10-17", + "Id": "key-consolepolicy-2", + "Statement": [ + { + "Action": "kms:*", + "Sid": "Enable IAM User Permissions", + "Resource": "*", + "Effect": "Allow", + "Principal": { + "AWS": "*" + } + } + ] + } + ], + "KeyState": "Enabled", + "CreationDate": "2017-01-05T20:39:18.960000+00:00", + "Arn": "arn:aws:kms:us-east-1:123456789123:key/key_id", + "AWSAccountId": "123456789123" +} + +key_arn_is_role_id = { + "Origin": "AWS_KMS", + "KeyId": "key_id", + "Description": "Description", + "Enabled": True, + "KeyUsage": "ENCRYPT_DECRYPT", + "Grants": [], + "Policies": [ + { + "Version": "2012-10-17", + "Id": "key-consolepolicy-2", + "Statement": [ + { + "Resource": "*", + "Effect": "Allow", + "Sid": "Allow attachment of persistent resources", + "Action": [ + "kms:CreateGrant", + "kms:ListGrants", + "kms:RevokeGrant" + ], + "Condition": { + "Bool": { + "kms:GrantIsForAWSResource": "true" + } + }, + "Principal": { + "AWS": "role_id_for_arn" + } + } + ] + } + ], + "KeyState": "Enabled", + "CreationDate": "2017-01-05T20:39:18.960000+00:00", + "Arn": "arn:aws:kms:us-east-1:123456789123:key/key_id", + "AWSAccountId": "123456789123" +} + + +class KMSTestCase(SecurityMonkeyTestCase): + + def test_check_for_kms_policy_with_foreign_account_no_condition(self): + auditor = KMSAuditor(accounts=['unittestaccount']) + item = KMSMasterKey(arn='arn:aws:kms:us-east-1:123456789123:key/key_id', + config=key_no_condition) + + self.assertEquals(len(item.audit_issues), 0) + auditor.check_for_kms_policy_with_foreign_account(item) + self.assertEquals(len(item.audit_issues), 1) + + def test_check_for_kms_policy_with_foreign_account_key_arn_is_role_id(self): + auditor = KMSAuditor(accounts=['unittestaccount']) + item = KMSMasterKey(arn='arn:aws:kms:us-east-1:123456789123:key/key_id', + config=key_arn_is_role_id) + + self.assertEquals(len(item.audit_issues), 0) + auditor.check_for_kms_policy_with_foreign_account(item) + self.assertEquals(len(item.audit_issues), 0) From 856dc60ce4579d35e1b8f5dff15f44de25fb9b73 Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Mon, 30 Jan 2017 09:44:46 -0800 Subject: [PATCH 06/90] Fix for S3 watcher errors. --- .../tests/test_exception_logging.py | 3 +- security_monkey/tests/test_s3.py | 53 +++++++++++++++++++ security_monkey/watchers/s3.py | 11 ++-- 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 security_monkey/tests/test_s3.py diff --git a/security_monkey/tests/test_exception_logging.py b/security_monkey/tests/test_exception_logging.py index 4176f35af..7a8691249 100644 --- a/security_monkey/tests/test_exception_logging.py +++ b/security_monkey/tests/test_exception_logging.py @@ -18,7 +18,8 @@ def pre_test_setup(self): db.session.add(account_type_result) db.session.commit() - self.account = Account(number="012345678910", name="testing", s3_name="testing", role_name="SecurityMonkey", account_type_id=account_type_result.id) + self.account = Account(number="012345678910", name="testing", s3_name="testing", + role_name="SecurityMonkey", account_type_id=account_type_result.id) self.technology = Technology(name="iamrole") self.item = Item(region="us-west-2", name="testrole", arn="arn:aws:iam::012345678910:role/testrole", technology=self.technology, diff --git a/security_monkey/tests/test_s3.py b/security_monkey/tests/test_s3.py new file mode 100644 index 000000000..185d44307 --- /dev/null +++ b/security_monkey/tests/test_s3.py @@ -0,0 +1,53 @@ +import boto3 +from moto import mock_s3 +from moto import mock_sts + +from security_monkey.watchers.s3 import S3 +from ..datastore import Account, Technology, Item, ExceptionLogs, AccountType +from . import SecurityMonkeyTestCase, db + + +class S3TestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + account_type_result = AccountType.query.filter(AccountType.name == 'AWS').first() + if not account_type_result: + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(number="012345678910", name="testing", s3_name="testing", role_name="SecurityMonkey", + account_type_id=account_type_result.id) + self.technology = Technology(name="s3") + self.item = Item(region="us-west-2", name="somebucket", + arn="arn:aws:s3:::somebucket", technology=self.technology, + account=self.account) + + db.session.add(self.account) + db.session.add(self.technology) + db.session.add(self.item) + + db.session.commit() + + mock_s3().start() + client = boto3.client("s3") + client.create_bucket(Bucket="somebucket") + client.create_bucket(Bucket="someotherbucket") + client.create_bucket(Bucket="someotherbucket2") + + def test_watcher_exceptions(self): + """ + Tests that if exceptions are encountered, the watcher continues. + + Unfortunately -- moto lacks all of the S3 methods that we need. So this is just a + test to ensure that exception handling works OK. + :return: + """ + mock_sts().start() + + s3_watcher = S3(accounts=[self.account.name]) + s3_watcher.slurp() + + assert len(ExceptionLogs.query.all()) == 3 # We created 3 buckets + + mock_s3().stop() + mock_sts().stop() diff --git a/security_monkey/watchers/s3.py b/security_monkey/watchers/s3.py index a5228478a..f20579683 100644 --- a/security_monkey/watchers/s3.py +++ b/security_monkey/watchers/s3.py @@ -22,6 +22,7 @@ from cloudaux.orchestration.aws.s3 import get_bucket from cloudaux.aws.s3 import list_buckets from security_monkey.decorators import record_exception, iter_account_region +from security_monkey.exceptions import SecurityMonkeyException from security_monkey.watcher import ChangeItem from security_monkey.watcher import Watcher from security_monkey import app @@ -41,12 +42,15 @@ def list_buckets(self, **kwargs): return [bucket['Name'] for bucket in buckets['Buckets'] if not self.check_ignore_list(bucket['Name'])] @record_exception(source="s3-watcher", pop_exception_fields=True) - def process_bucket(self, bucket, **kwargs): + def process_bucket(self, bucket_name, **kwargs): app.logger.debug("Slurping {index} ({name}) from {account}".format( index=self.i_am_singular, - name=bucket, + name=bucket_name, account=kwargs['account_number'])) - return get_bucket(bucket, **kwargs) + bucket = get_bucket(bucket_name, **kwargs) + + if bucket and bucket.get("Error"): + raise SecurityMonkeyException("S3 Bucket: {} fetching error: {}".format(bucket_name, bucket["Error"])) def slurp(self): self.prep_for_slurp() @@ -58,6 +62,7 @@ def slurp_items(**kwargs): for bucket_name in bucket_names: bucket = self.process_bucket(bucket_name, name=bucket_name, **kwargs) + if bucket: item = S3Item.from_slurp(bucket_name, bucket, **kwargs) item_list.append(item) From 3a7b1681db7d698963eb33704bf4fc2a82c2c971 Mon Sep 17 00:00:00 2001 From: Francesco Badraun Date: Tue, 24 Jan 2017 14:30:46 +1300 Subject: [PATCH 07/90] Add ability to press enter to search in search bar component Add to second set of fields in search component --- .../search_bar_component/search_bar_component.html | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dart/lib/component/search_bar_component/search_bar_component.html b/dart/lib/component/search_bar_component/search_bar_component.html index 90e2dea8d..c3cf8a0b0 100644 --- a/dart/lib/component/search_bar_component/search_bar_component.html +++ b/dart/lib/component/search_bar_component/search_bar_component.html @@ -42,17 +42,20 @@
ARN
Search Config
- +
Min Total Score
- +
Min Unjustified Score
- +
From bc711a831a408144bf052ef259019a748b28267a Mon Sep 17 00:00:00 2001 From: Francesco Badraun Date: Fri, 27 Jan 2017 11:15:36 +1300 Subject: [PATCH 08/90] Remove broken packages link --- dart/web/css/packages | 1 - dart/web/fonts/packages | 1 - dart/web/images/packages | 1 - dart/web/js/packages | 1 - dart/web/packages | 1 - dart/web/select2-3.4.5/packages | 1 - dart/web/views/packages | 1 - 7 files changed, 7 deletions(-) delete mode 120000 dart/web/css/packages delete mode 120000 dart/web/fonts/packages delete mode 120000 dart/web/images/packages delete mode 120000 dart/web/js/packages delete mode 120000 dart/web/packages delete mode 120000 dart/web/select2-3.4.5/packages delete mode 120000 dart/web/views/packages diff --git a/dart/web/css/packages b/dart/web/css/packages deleted file mode 120000 index 4b727bf68..000000000 --- a/dart/web/css/packages +++ /dev/null @@ -1 +0,0 @@ -../../packages \ No newline at end of file diff --git a/dart/web/fonts/packages b/dart/web/fonts/packages deleted file mode 120000 index 4b727bf68..000000000 --- a/dart/web/fonts/packages +++ /dev/null @@ -1 +0,0 @@ -../../packages \ No newline at end of file diff --git a/dart/web/images/packages b/dart/web/images/packages deleted file mode 120000 index 4b727bf68..000000000 --- a/dart/web/images/packages +++ /dev/null @@ -1 +0,0 @@ -../../packages \ No newline at end of file diff --git a/dart/web/js/packages b/dart/web/js/packages deleted file mode 120000 index 4b727bf68..000000000 --- a/dart/web/js/packages +++ /dev/null @@ -1 +0,0 @@ -../../packages \ No newline at end of file diff --git a/dart/web/packages b/dart/web/packages deleted file mode 120000 index a16c40501..000000000 --- a/dart/web/packages +++ /dev/null @@ -1 +0,0 @@ -../packages \ No newline at end of file diff --git a/dart/web/select2-3.4.5/packages b/dart/web/select2-3.4.5/packages deleted file mode 120000 index 4b727bf68..000000000 --- a/dart/web/select2-3.4.5/packages +++ /dev/null @@ -1 +0,0 @@ -../../packages \ No newline at end of file diff --git a/dart/web/views/packages b/dart/web/views/packages deleted file mode 120000 index 4b727bf68..000000000 --- a/dart/web/views/packages +++ /dev/null @@ -1 +0,0 @@ -../../packages \ No newline at end of file From 25048a66b22432e2f1bfcb7981e30db5263ef810 Mon Sep 17 00:00:00 2001 From: Francesco Date: Wed, 1 Feb 2017 10:42:58 +1300 Subject: [PATCH 09/90] =?UTF-8?q?Update=20dev=5Fsetup=5Fosx.rst=20to=20get?= =?UTF-8?q?=20it=20up-to-date=20(#514)=20=F0=9F=93=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update dev_setup_osx.rst to get it up-to-date * Update dev_setup_osx doc with Install Python step Add step before Virtualenv to fix ‘sudo pip install virtualenvwrapper --ignore-installed six’ hack * Remove ‘--ignore-installed six’ hack Oops * Update dev_setup_osx doc with Upgrade Pip step Remove Install Pip step --- docs/dev_setup_osx.rst | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/docs/dev_setup_osx.rst b/docs/dev_setup_osx.rst index a03ba8e8c..2fc1478ed 100644 --- a/docs/dev_setup_osx.rst +++ b/docs/dev_setup_osx.rst @@ -10,12 +10,12 @@ You will need to have the proper IAM Role configuration in place. See `Configur Additionally, see the boto documentation for more information: http://boto.readthedocs.org/en/latest/boto_config_tut.html -Install XCode +Install Xcode ========================== -XCode contains a number of tools that are required to install Security Monkey dependencies. This needs to be installed from the App Store (free download): +Xcode contains a number of tools that are required to install Security Monkey dependencies. This needs to be installed from the App Store (free download): https://itunes.apple.com/us/app/xcode/id497799835?mt=12 -After XCode is installed, you need to accept the XCode license agreement. To do that, run:: +After Xcode is installed, you need to accept the Xcode license agreement. To do that, run:: sudo xcodebuild -license # You will need to type in 'agree' @@ -23,13 +23,19 @@ Install Homebrew (http://brew.sh) ========================== Requirement - Xcode Command Line Tools (Popup - Just click Install):: - ruby -e "$(curl -fsSL https://raw.github.com/Homebrew/homebrew/go/install)" + ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" -Install Pip +Install Python ========================== -A tool for installing and managing Python packages:: +Install the latest version of Python 2.7 with Homebrew:: - sudo easy_install pip + sudo brew install python + +Upgrade Pip +========================== +A tool for installing and managing Python packages. You may need to update Pip, so run:: + + sudo pip install --upgrade pip Setup Virtualenv ========================== @@ -82,11 +88,11 @@ Install Postgres. Create a database for security monkey and add a role. Set th brew install postgresql -Start the DB in a new shell:: +Open a new shell, then start the DB:: postgres -D /usr/local/var/postgres -Create the database and users and set the timezone. :: +Go back to your previous shell, then create the database and users and set the timezone. :: psql -d postgres -h localhost CREATE DATABASE "securitymonkeydb"; @@ -182,6 +188,11 @@ Next, you will create the ``securitymonkey.conf`` NGINX configuration file. Cre } } +Create the ``devlog/security_monkey.access.log`` file. :: + + mkdir devlog + touch devlog/security_monkey.access.log + NGINX can be started by running the ``nginx`` command in the Terminal. You will need to run ``nginx`` before moving on. This will also output any errors that are encountered when reading the configuration files. Launch and Configure the WebStorm Editor From a7d4cb1cdb61854a27fd7fbcad9f99cc52ac69c0 Mon Sep 17 00:00:00 2001 From: Francesco Date: Thu, 2 Feb 2017 13:46:30 +1300 Subject: [PATCH 10/90] Update dev_setup_osx Remove 'sudo' from 'brew install python' --- docs/dev_setup_osx.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev_setup_osx.rst b/docs/dev_setup_osx.rst index 2fc1478ed..b80848c0d 100644 --- a/docs/dev_setup_osx.rst +++ b/docs/dev_setup_osx.rst @@ -29,7 +29,7 @@ Install Python ========================== Install the latest version of Python 2.7 with Homebrew:: - sudo brew install python + brew install python Upgrade Pip ========================== From a1e3a7acbf6e6d0c250e699957da5e9fa888ed8e Mon Sep 17 00:00:00 2001 From: Joe Selman Date: Fri, 3 Feb 2017 11:25:55 -0800 Subject: [PATCH 11/90] Minor reformatting/style changes to Docker docs --- docs/docker.rst | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/docs/docker.rst b/docs/docker.rst index 6e0844fd2..34a54e990 100644 --- a/docs/docker.rst +++ b/docs/docker.rst @@ -8,31 +8,38 @@ Also, the docker/nginx/Dockerfile file is used to build an NGINX container that Quick Start: ------------ - Define your specific settings in **secmonkey.env** file. For example, this file will look like:: +Define your specific settings in **secmonkey.env** file. For example, this file will look like:: - AWS_ACCESS_KEY_ID= - AWS_SECRET_ACCESS_KEY= - SECURITY_MONKEY_POSTGRES_HOST=postgres - SECURITY_MONKEY_FQDN=192.168.99.100 + AWS_ACCESS_KEY_ID= + AWS_SECRET_ACCESS_KEY= + SECURITY_MONKEY_POSTGRES_HOST=postgres + SECURITY_MONKEY_FQDN=192.168.99.100 + +Next, you can build all the containers by running:: $ docker-compose build - ``this will locally build all the containers necessary`` - + +The database is then started via:: + $ docker-compose up -d postgres - ``this will start the database container`` + +On a fresh database instance, various initial configuration must be run such as database setup, initial user creation, etc. You can run the ``init`` container via:: $ docker-compose up -d init - ``this will start a container in which you canuse to setup the database, create users, and other manual configurations, see the below section for more info`` + +See the section below for more details. + +Now that the database is setup, you can start up the remaining containers (Security Monkey, nginx, and the scheduler) via:: $ docker-compose up - ``this will bring up the remaining containers (scheduler and nginx)`` Commands: --------- - - $ docker-compose build ``[api | scheduler | nginx | init]`` +:: + + $ docker-compose build [api | scheduler | nginx | init] - $ docker-compose up -d ``[postgres | api | scheduler | nginx | init]`` + $ docker-compose up -d [postgres | api | scheduler | nginx | init] More Info: ---------- From be4b06ab30adcf8aee7c004f35c6574e3225e029 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 7 Feb 2017 20:49:28 +0000 Subject: [PATCH 12/90] Adding -a to coverage run --- .travis.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index a7543f9c8..285e31be4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,14 +36,15 @@ before_script: script: - sh env_tests/test_dart.sh - - coverage run -m py.test security_monkey/tests/auditors || exit 1 - - coverage run -m py.test security_monkey/tests/watchers || exit 1 - - coverage run -m py.test security_monkey/tests/core || exit 1 - - coverage run -m py.test security_monkey/tests/views || exit 1 - - coverage run -m py.test security_monkey/tests/interface || exit 1 + - coverage run -a -m py.test security_monkey/tests/auditors || exit 1 + - coverage run -a -m py.test security_monkey/tests/watchers || exit 1 + - coverage run -a -m py.test security_monkey/tests/core || exit 1 + - coverage run -a -m py.test security_monkey/tests/views || exit 1 + - coverage run -a -m py.test security_monkey/tests/interface || exit 1 after_success: - coveralls + - coverage report notifications: email: From 4b496dad1b8e822eee0cf821f024ebf6d96417db Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 7 Feb 2017 21:07:41 +0000 Subject: [PATCH 13/90] Moving Mike's S3 watcher into the watchers directory. --- .../tests/{ => watchers}/test_s3.py | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) rename security_monkey/tests/{ => watchers}/test_s3.py (65%) diff --git a/security_monkey/tests/test_s3.py b/security_monkey/tests/watchers/test_s3.py similarity index 65% rename from security_monkey/tests/test_s3.py rename to security_monkey/tests/watchers/test_s3.py index 185d44307..b2167f5f5 100644 --- a/security_monkey/tests/test_s3.py +++ b/security_monkey/tests/watchers/test_s3.py @@ -1,10 +1,29 @@ +# Copyright 2014 Netflix, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.watchers.test_s3 + :platform: Unix +.. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima +""" import boto3 from moto import mock_s3 from moto import mock_sts from security_monkey.watchers.s3 import S3 -from ..datastore import Account, Technology, Item, ExceptionLogs, AccountType -from . import SecurityMonkeyTestCase, db +from securitymonkey.datastore import Account, Technology, Item, ExceptionLogs, AccountType +from security_monkey.tests import SecurityMonkeyTestCase, db class S3TestCase(SecurityMonkeyTestCase): From 3c40c003569e2798340f880c8abda4e39b3a427a Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 7 Feb 2017 21:16:32 +0000 Subject: [PATCH 14/90] Fixing import typo. --- security_monkey/tests/watchers/test_s3.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security_monkey/tests/watchers/test_s3.py b/security_monkey/tests/watchers/test_s3.py index b2167f5f5..8917fa235 100644 --- a/security_monkey/tests/watchers/test_s3.py +++ b/security_monkey/tests/watchers/test_s3.py @@ -22,7 +22,7 @@ from moto import mock_sts from security_monkey.watchers.s3 import S3 -from securitymonkey.datastore import Account, Technology, Item, ExceptionLogs, AccountType +from security_monkey.datastore import Account, Technology, Item, ExceptionLogs, AccountType from security_monkey.tests import SecurityMonkeyTestCase, db From f00a3f8138e4d63efa24c125f58e4bdce30150cc Mon Sep 17 00:00:00 2001 From: Anne Ebie Date: Thu, 7 Jul 2016 17:22:08 -0400 Subject: [PATCH 15/90] Optimize SQL for account delete Type: generic-bugfix Why is this change necessary? We were getting intermittent integrity errors and timeouts when deleting accounts with many items and issues. This appears to be caused by SQL Alchemy's method of handling cascading deletes, which is inefficient and does not appear to handle transactional locks well, allowing for race conditions. This change addresses the need by: Deleting accounts and related records with a raw sql query --- manage.py | 6 +++ security_monkey/account_manager.py | 78 +++++++++++++++++++++++++++++- security_monkey/views/account.py | 16 +----- 3 files changed, 85 insertions(+), 15 deletions(-) diff --git a/manage.py b/manage.py index ea3de3062..758f45f40 100644 --- a/manage.py +++ b/manage.py @@ -260,6 +260,12 @@ def _parse_accounts(account_str): return account_str.split(',') +@manager.option('-n', '--name', dest='name', type=unicode, required=True) +def delete_account(name): + from security_monkey.account_manager import delete_account_by_name + delete_account_by_name(name) + + class APIServer(Command): def __init__(self, host='127.0.0.1', port=app.config.get('API_PORT'), workers=6): self.address = "{}:{}".format(host, port) diff --git a/security_monkey/account_manager.py b/security_monkey/account_manager.py index 61516c786..59130ab39 100644 --- a/security_monkey/account_manager.py +++ b/security_monkey/account_manager.py @@ -22,9 +22,12 @@ """ -from datastore import Account, AccountType, AccountTypeCustomValues +from datastore import Account, AccountType, AccountTypeCustomValues, User from security_monkey import app, db from security_monkey.common.utils import find_modules +import psycopg2 +import time +import traceback account_registry = {} @@ -195,4 +198,77 @@ def get_account_by_name(account_name): db.session.expunge(account) return account + +def delete_account_by_id(account_id): + + # Need to unsubscribe any users first: + users = User.query.filter( + User.accounts.any(Account.id == account_id)).all() + for user in users: + user.accounts = [ + account for account in user.accounts if not account.id == account_id] + db.session.add(user) + db.session.commit() + + conn = None + try: + # The SQL Alchemy method of handling cascading deletes is inefficient. + # As a result, deleting accounts with large numbers of items and issues + # can result is a very lengthy service call that time out. This section + # deletes issues, items and associated child rows using database + # optimized queries, which results in much faster performance + conn = psycopg2.connect(app.config.get('SQLALCHEMY_DATABASE_URI')) + cur = conn.cursor() + cur.execute('DELETE from issue_item_association ' + 'WHERE super_issue_id IN ' + '(SELECT itemaudit.id from itemaudit, item ' + 'WHERE itemaudit.item_id = item.id AND item.account_id = %s);', [account_id]) + + cur.execute('DELETE from itemaudit WHERE item_id IN ' + '(SELECT id from item WHERE account_id = %s);', [account_id]) + + cur.execute('DELETE from itemrevisioncomment WHERE revision_id IN ' + '(SELECT itemrevision.id from itemrevision, item WHERE ' + 'itemrevision.item_id = item.id AND item.account_id = %s);', [account_id]) + + cur.execute('DELETE from cloudtrail WHERE revision_id IN ' + '(SELECT itemrevision.id from itemrevision, item WHERE ' + 'itemrevision.item_id = item.id AND item.account_id = %s);', [account_id]) + + cur.execute('DELETE from itemrevision WHERE item_id IN ' + '(SELECT id from item WHERE account_id = %s);', [account_id]) + + cur.execute('DELETE from itemcomment WHERE item_id IN ' + '(SELECT id from item WHERE account_id = %s);', [account_id]) + + cur.execute('DELETE from exceptions WHERE item_id IN ' + '(SELECT id from item WHERE account_id = %s);', [account_id]) + + cur.execute('DELETE from cloudtrail WHERE item_id IN ' + '(SELECT id from item WHERE account_id = %s);', [account_id]) + + cur.execute('DELETE from item WHERE account_id = %s;', [account_id]) + + cur.execute('DELETE from exceptions WHERE account_id = %s;', [account_id]) + + cur.execute('DELETE from auditorsettings WHERE account_id = %s;', [account_id]) + + cur.execute('DELETE from account_type_values WHERE account_id = %s;', [account_id]) + + cur.execute('DELETE from account WHERE id = %s;', [account_id]) + + conn.commit() + except Exception as e: + app.logger.warn(traceback.format_exc()) + finally: + if conn: + conn.close() + + +def delete_account_by_name(name): + account = Account.query.filter(Account.name == name).first() + account_id = account.id + db.session.expunge(account) + delete_account_by_id(account_id) + find_modules('account_managers') diff --git a/security_monkey/views/account.py b/security_monkey/views/account.py index 3559def3c..6117ca8bb 100644 --- a/security_monkey/views/account.py +++ b/security_monkey/views/account.py @@ -16,7 +16,7 @@ from security_monkey.views import ACCOUNT_FIELDS from security_monkey.datastore import Account from security_monkey.datastore import User -from security_monkey.account_manager import get_account_by_id +from security_monkey.account_manager import get_account_by_id, delete_account_by_id from security_monkey import db, rbac from flask import request @@ -187,19 +187,7 @@ def delete(self, account_id): :statuscode 202: accepted :statuscode 401: Authentication Error. Please Login. """ - - # Need to unsubscribe any users first: - users = User.query.filter(User.accounts.any(Account.id == account_id)).all() - for user in users: - user.accounts = [account for account in user.accounts if not account.id == account_id] - db.session.add(user) - db.session.commit() - - account = Account.query.filter(Account.id == account_id).first() - - db.session.delete(account) - db.session.commit() - + delete_account_by_id(account_id) return {'status': 'deleted'}, 202 From 5dd4ffc4c68b38f7683ddf47eb08288fe47ef701 Mon Sep 17 00:00:00 2001 From: Anne Ebie Date: Wed, 2 Nov 2016 11:09:05 -0400 Subject: [PATCH 16/90] [secmonkey] Handle known kms boto exceptions Type: generic-bugfix Why is this change necessary? Some kms keys are partially visible but throw exceptions when viewing details. The current code does not handle these cases so we get false access errors and do not see the keys. This change addresses the need by: Handles the access errors and shows partial results. Potential Side Effects: No known side effects --- security_monkey/watchers/kms.py | 113 +++++++++++++++++++------------- 1 file changed, 67 insertions(+), 46 deletions(-) diff --git a/security_monkey/watchers/kms.py b/security_monkey/watchers/kms.py index c771d9535..a22ced637 100644 --- a/security_monkey/watchers/kms.py +++ b/security_monkey/watchers/kms.py @@ -27,6 +27,7 @@ from dateutil.tz import tzutc import json +from botocore.exceptions import ClientError class KMS(Watcher): @@ -83,38 +84,55 @@ def list_grants(self, kms, key_id, **kwargs): @record_exception() def describe_key(self, kms, key_id, **kwargs): - response = self.wrap_aws_rate_limited_call( - kms.describe_key, - KeyId=key_id - ) - return response.get("KeyMetadata") - - @record_exception() - def list_key_policies(self, kms, key_id, **kwargs): - policy_names = [] - policy_names = self.paged_wrap_aws_rate_limited_call( - "PolicyNames", - kms.list_key_policies, + try: + response = self.wrap_aws_rate_limited_call( + kms.describe_key, KeyId=key_id ) + except ClientError as e: + if e.response.get("Error", {}).get("Code") != "AccessDeniedException": + raise + + arn = "arn:aws:kms:{}:{}:key/{}".format(kwargs['region'], + kwargs['account_name'], + key_id) + + return { + 'Error': 'Unauthorized', + 'Arn': arn, + "AWSAccountId": kwargs['account_name'], + 'Policies': [], + 'Grants': [] + } - return policy_names + return response.get("KeyMetadata") @record_exception() - def get_key_policy(self, kms, key_id, policy_name, **kwargs): - from security_monkey.common.sts_connect import connect + def list_key_policies(self, kms, key_id, alias, **kwargs): + policy_names = [] try: - policy = self.wrap_aws_rate_limited_call( - kms.get_key_policy, - KeyId=key_id, - PolicyName=policy_name - ) - except Exception as e: - if e.response.get("Error", {}).get("Code") == "AccessDeniedException": + policy_names = self.paged_wrap_aws_rate_limited_call( + "PolicyNames", + kms.list_key_policies, + KeyId=key_id + ) + except ClientError as e: + if e.response.get("Error", {}).get("Code") == "AccessDeniedException" and alias == 'aws/acm': # This is expected for the AWS owned ACM KMS key. - app.logger.debug("{} {} is an AWS supplied {} that has no policies".format(self.i_am_singular, key_id, self.i_am_singular)) + app.logger.debug("{} {} is an AWS supplied aws/acm, overriding to [default] for policy".format(self.i_am_singular, key_id)) + policy_names = ['default'] else: - raise e + raise + + return policy_names + + @record_exception() + def get_key_policy(self, kms, key_id, policy_name, alias, **kwargs): + policy = self.wrap_aws_rate_limited_call( + kms.get_key_policy, + KeyId=key_id, + PolicyName=policy_name + ) return json.loads(policy.get("Policy")) @@ -123,7 +141,7 @@ def __init__(self, accounts=None, debug=False): def slurp(self): """ - :returns: item_list - list of SES Identities. + :returns: item_list - list of KMS keys. :returns: exception_map - A dict where the keys are a tuple containing the location of the exception and the value is the actual exception @@ -158,27 +176,6 @@ def slurp_items(**kwargs): # get the key's config object and grants config = self.describe_key(kms, key_id, **kwargs) if config: - grants = self.list_grants(kms, key_id, **kwargs) - policy_names = self.list_key_policies(kms, key_id, **kwargs) - - if policy_names: - for policy_name in policy_names: - policy = self.get_key_policy(kms, key_id, policy_name, **kwargs) - policies.append(policy) - - # Convert the datetime objects into ISO formatted strings in UTC - if config.get('CreationDate'): - config.update({ 'CreationDate': config.get('CreationDate').astimezone(tzutc()).isoformat() }) - if config.get('DeletionDate'): - config.update({ 'DeletionDate': config.get('DeletionDate').astimezone(tzutc()).isoformat() }) - - if grants: - for grant in grants: - if grant.get("CreationDate"): - grant.update({ 'CreationDate': grant.get('CreationDate').astimezone(tzutc()).isoformat() }) - - config[u"Policies"] = policies - config[u"Grants"] = grants # filter the list of all aliases and save them with the key they're for config[u"Aliases"] = [a.get("AliasName") for a in aliases if a.get("TargetKeyId") == key_id] @@ -190,6 +187,30 @@ def slurp_items(**kwargs): name = "{alias} ({key_id})".format(alias=alias, key_id=key_id) + if config.get('Error') is None: + grants = self.list_grants(kms, key_id, **kwargs) + policy_names = self.list_key_policies(kms, key_id, alias, **kwargs) + + if policy_names: + for policy_name in policy_names: + policy = self.get_key_policy(kms, key_id, policy_name, alias, **kwargs) + policies.append(policy) + + # Convert the datetime objects into ISO formatted strings in UTC + if config.get('CreationDate'): + config.update({ 'CreationDate': config.get('CreationDate').astimezone(tzutc()).isoformat() }) + if config.get('DeletionDate'): + config.update({ 'DeletionDate': config.get('DeletionDate').astimezone(tzutc()).isoformat() }) + + if grants: + for grant in grants: + if grant.get("CreationDate"): + grant.update({ 'CreationDate': grant.get('CreationDate').astimezone(tzutc()).isoformat() }) + + + config[u"Policies"] = policies + config[u"Grants"] = grants + item = KMSMasterKey(region=kwargs['region'], account=kwargs['account_name'], name=name, arn=config.get('Arn'), config=dict(config)) item_list.append(item) From 7452ae846e3a9c8c87b2fe57f4d98f710e86db40 Mon Sep 17 00:00:00 2001 From: Marius Grigaitis Date: Fri, 10 Feb 2017 10:27:21 +0200 Subject: [PATCH 17/90] Usage of GOOGLE_HOSTED_DOMAIN in sample configs --- env-config/config-deploy.py | 1 + env-config/config-docker.py | 1 + env-config/config-local.py | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/env-config/config-deploy.py b/env-config/config-deploy.py index f70cbc49d..fa794e48b 100644 --- a/env-config/config-deploy.py +++ b/env-config/config-deploy.py @@ -121,6 +121,7 @@ GOOGLE_CLIENT_ID = '' GOOGLE_AUTH_ENDPOINT = '' GOOGLE_SECRET = '' +# GOOGLE_HOSTED_DOMAIN = 'example.com' # Verify that token issued by comes from domain ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address diff --git a/env-config/config-docker.py b/env-config/config-docker.py index 20127e133..c8c4c2997 100644 --- a/env-config/config-docker.py +++ b/env-config/config-docker.py @@ -148,6 +148,7 @@ def env_to_bool(input): GOOGLE_CLIENT_ID = '' GOOGLE_AUTH_ENDPOINT = '' GOOGLE_SECRET = '' +# GOOGLE_HOSTED_DOMAIN = 'example.com' # Verify that token issued by comes from domain ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address diff --git a/env-config/config-local.py b/env-config/config-local.py index 2001ca273..78b0cfc63 100644 --- a/env-config/config-local.py +++ b/env-config/config-local.py @@ -116,6 +116,7 @@ GOOGLE_CLIENT_ID = '' GOOGLE_AUTH_ENDPOINT = '' GOOGLE_SECRET = '' +# GOOGLE_HOSTED_DOMAIN = 'example.com' # Verify that token issued by comes from domain ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address @@ -207,4 +208,4 @@ # Public x509 certificate of the IdP "x509cert": "" } -} +} From 3ac8957ac39101ee6ce2886350b266f72a2b15c3 Mon Sep 17 00:00:00 2001 From: Anne Ebie Date: Thu, 26 Jan 2017 11:54:14 -0500 Subject: [PATCH 18/90] [secmonkey] Remove DB mock class Why is this change necessary? Netflix's direction for unit tests is to use a temporary DB, but we have been using a DB mock class This change addresses the need by: Removing db_mock.py and dependencies and refactoring watcher test setup into new, base watcher test class. Potential Side Effects: None --- .../auditors/test_elasticsearch_service.py | 28 ++--- security_monkey/tests/core/db_mock.py | 105 ------------------ security_monkey/tests/core/test_backup.py | 98 ++++++++++++++++ security_monkey/tests/core/test_scheduler.py | 86 ++++++-------- security_monkey/tests/watchers/__init__.py | 18 +++ .../tests/watchers/ec2/test_ebs_snapshot.py | 20 +--- .../tests/watchers/ec2/test_ebs_volume.py | 20 +--- .../tests/watchers/ec2/test_ec2_image.py | 20 +--- .../tests/watchers/ec2/test_ec2_instance.py | 20 +--- .../watchers/rds/test_rds_db_instance.py | 20 +--- .../watchers/rds/test_rds_security_group.py | 20 +--- .../watchers/rds/test_rds_subnet_group.py | 22 +--- .../tests/watchers/test_lambda_function.py | 20 +--- .../tests/watchers/test_route53.py | 20 +--- .../tests/watchers/vpc/test_dhcp.py | 20 +--- .../tests/watchers/vpc/test_networkacl.py | 20 +--- .../tests/watchers/vpc/test_peering.py | 20 +--- .../tests/watchers/vpc/test_route_table.py | 20 +--- 18 files changed, 202 insertions(+), 395 deletions(-) delete mode 100644 security_monkey/tests/core/db_mock.py create mode 100644 security_monkey/tests/core/test_backup.py diff --git a/security_monkey/tests/auditors/test_elasticsearch_service.py b/security_monkey/tests/auditors/test_elasticsearch_service.py index 35e814747..f48608a01 100644 --- a/security_monkey/tests/auditors/test_elasticsearch_service.py +++ b/security_monkey/tests/auditors/test_elasticsearch_service.py @@ -21,14 +21,13 @@ """ import json -from security_monkey.datastore import NetworkWhitelistEntry, Account +from security_monkey.datastore import NetworkWhitelistEntry, Account, AccountType from security_monkey.tests import SecurityMonkeyTestCase -from security_monkey.tests.core.db_mock import MockAccountQuery +from security_monkey import db # TODO: Make a ES test for spulec/moto, then make test cases that use it. from security_monkey.watchers.elasticsearch_service import ElasticSearchServiceItem -from mock import patch CONFIG_ONE = { "name": "es_test", @@ -288,11 +287,9 @@ ("Test two", "100.0.0.1/16"), ] -mock_query = MockAccountQuery() - class ElasticSearchServiceTestCase(SecurityMonkeyTestCase): - def setUp(self): + def pre_test_setup(self): self.es_items = [ ElasticSearchServiceItem(region="us-east-1", account="TEST_ACCOUNT", name="es_test", config=CONFIG_ONE), ElasticSearchServiceItem(region="us-west-2", account="TEST_ACCOUNT", name="es_test_2", config=CONFIG_TWO), @@ -305,15 +302,18 @@ def setUp(self): ElasticSearchServiceItem(region="us-east-1", account="TEST_ACCOUNT", name="es_test_9", config=CONFIG_NINE), ] - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + account = Account(number="012345678910", name="TEST_ACCOUNT", + s3_name="TEST_ACCOUNT", role_name="TEST_ACCOUNT", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT", + third_party=False, active=True) + + db.session.add(account) + db.session.commit() - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_es_auditor(self): from security_monkey.auditors.elasticsearch_service import ElasticSearchServiceAuditor es_auditor = ElasticSearchServiceAuditor(accounts=["012345678910"]) diff --git a/security_monkey/tests/core/db_mock.py b/security_monkey/tests/core/db_mock.py deleted file mode 100644 index a8c9be472..000000000 --- a/security_monkey/tests/core/db_mock.py +++ /dev/null @@ -1,105 +0,0 @@ -# Copyright 2016 Bridgewater Associates -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -.. module: security_monkey.tests.core.db_mock - :platform: Unix - -.. version:: $$VERSION$$ -.. moduleauthor:: Bridgewater OSS - - -""" - - -def parse_criteria(criteria): - """ - Builds a unique key for the filter operator. - Currently only supports column and value operations - """ - left = criteria.left - if left.__class__.__name__ == 'AnnotatedColumn': - left_val = criteria.left.name - else: - # May add additional filter types as needed - raise NotImplementedError() - - right = criteria.right - if right.__class__.__name__ == 'BindParameter': - right_val = criteria.right.value - elif right.__class__.__name__ == 'False_': - right_val = False - elif right.__class__.__name__ == 'True_': - right_val = True - else: - # May add additional filter types as needed - raise NotImplementedError() - - return left_val, right_val - - -class MockAccountQuery(): - - def __init__(self): - self.test_accounts = [] - self.filtered_accounts = None - - def add_account(self, account): - self.test_accounts.append(account) - - def clear(self): - self.test_accounts = [] - - def filter(self, *criterion): - if self.filtered_accounts is None: - self.filtered_accounts = list(self.test_accounts) - - matching_accounts = [] - - for criteria in criterion: - (left_val, right_val) = parse_criteria(criteria) - for account in self.filtered_accounts: - if getattr(account, left_val) == right_val: - matching_accounts.append(account) - - self.filtered_accounts = list(matching_accounts) - return self - - def first(self): - if self.filtered_accounts is not None: - accounts = self.filtered_accounts - self.filtered_accounts = None - else: - accounts = self.test_accounts - - if len(accounts) > 0: - return accounts[0] - return None - - def all(self): - if self.filtered_accounts is not None: - accounts = self.filtered_accounts - self.filtered_accounts = None - else: - accounts = self.test_accounts - - return accounts - - -class MockDBSession(): - - def expunge(self, item): - pass - - def commit(self): - pass diff --git a/security_monkey/tests/core/test_backup.py b/security_monkey/tests/core/test_backup.py new file mode 100644 index 000000000..5a19aee8f --- /dev/null +++ b/security_monkey/tests/core/test_backup.py @@ -0,0 +1,98 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.core.test_backup + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + +""" +from security_monkey.datastore import Account, AccountType +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.core.monitor_mock import build_mock_result, mock_get_monitors +from security_monkey import db + +from mock import patch +from collections import defaultdict + + +watcher_configs = [ + {'index': 'index1', 'interval': 15}, + {'index': 'index2', 'interval': 15}, + {'index': 'index3', 'interval': 15} +] + +mock_file_system = defaultdict(list) + + +def mock_backup_items_in_account(account_name, watcher, output_folder): + mock_file_system[account_name].append(watcher.index) + + +@patch('security_monkey.backup._backup_items_in_account', mock_backup_items_in_account) +@patch('security_monkey.monitors.get_monitors', mock_get_monitors) +class BackupTestCase(SecurityMonkeyTestCase): + + def pre_test_setup(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + account = Account(number="012345678910", name="TEST_ACCOUNT", + s3_name="TEST_ACCOUNT", role_name="TEST_ACCOUNT", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT", + third_party=False, active=True) + + db.session.add(account) + db.session.commit() + + mock_file_system.clear() + build_mock_result(watcher_configs, []) + + def test_backup_with_all_watchers(self): + from security_monkey.backup import backup_config_to_json + + backup_config_to_json(['TEST_ACCOUNT'], ['index1', 'index2', 'index3'], 'none') + + self.assertTrue('TEST_ACCOUNT' in mock_file_system.keys(), + msg="Did not backup TEST_ACCOUNT") + self.assertEqual(first=1, second=len(mock_file_system.keys()), + msg="Should backup account once but backed up {} times" + .format(len(mock_file_system.keys()))) + self.assertEqual(first=3, second=len(mock_file_system['TEST_ACCOUNT']), + msg="Should backup 3 technologies but backed up {}" + .format(len(mock_file_system['TEST_ACCOUNT']))) + self.assertTrue('index1' in mock_file_system['TEST_ACCOUNT'], + msg="Did not backup index1") + self.assertTrue('index2' in mock_file_system['TEST_ACCOUNT'], + msg="Did not backup index2") + self.assertTrue('index3' in mock_file_system['TEST_ACCOUNT'], + msg="Did not backup index3") + + def test_backup_with_one_watchers(self): + from security_monkey.backup import backup_config_to_json + + backup_config_to_json(['TEST_ACCOUNT'], ['index1'], 'none') + + self.assertTrue('TEST_ACCOUNT' in mock_file_system.keys(), + msg="Did not backup TEST_ACCOUNT") + self.assertEqual(first=1, second=len(mock_file_system.keys()), + msg="Should backup account once but backed up {} times" + .format(len(mock_file_system.keys()))) + self.assertEqual(first=1, second=len(mock_file_system['TEST_ACCOUNT']), + msg="Should backup 1 technologies but backed up {}" + .format(len(mock_file_system['TEST_ACCOUNT']))) + self.assertTrue('index1' in mock_file_system['TEST_ACCOUNT'], + msg="Did not backup index1") diff --git a/security_monkey/tests/core/test_scheduler.py b/security_monkey/tests/core/test_scheduler.py index 9389f280b..d65870008 100644 --- a/security_monkey/tests/core/test_scheduler.py +++ b/security_monkey/tests/core/test_scheduler.py @@ -21,17 +21,14 @@ """ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.datastore import Account, AccountType -from security_monkey.tests.core.db_mock import MockAccountQuery, MockDBSession from security_monkey.tests.core.monitor_mock import RUNTIME_WATCHERS, RUNTIME_AUDITORS from security_monkey.tests.core.monitor_mock import build_mock_result from security_monkey.tests.core.monitor_mock import mock_get_monitors, mock_all_monitors +from security_monkey import db from mock import patch -mock_query = MockAccountQuery() -mock_db_session = MockDBSession() - watcher_configs = [ {'index': 'index1', 'interval': 15}, {'index': 'index2', 'interval': 15}, @@ -66,57 +63,40 @@ class SchedulerTestCase(SecurityMonkeyTestCase): test_account3 = None test_account4 = None - def setUp(self): - mock_query.clear() - self.test_account1 = Account() - self.test_account1.name = "TEST_ACCOUNT1" - self.test_account1.notes = "TEST ACCOUNT1" - self.test_account1.s3_name = "TEST_ACCOUNT1" - self.test_account1.number = "012345678910" - self.test_account1.role_name = "TEST_ACCOUNT" - self.test_account1.account_type = AccountType(name='AWS') - self.test_account1.third_party = False - self.test_account1.active = True - mock_query.add_account(self.test_account1) - - self.test_account2 = Account() - self.test_account2.name = "TEST_ACCOUNT2" - self.test_account2.notes = "TEST ACCOUNT2" - self.test_account2.s3_name = "TEST_ACCOUNT2" - self.test_account2.number = "123123123123" - self.test_account2.role_name = "TEST_ACCOUNT" - self.test_account2.account_type = AccountType(name='AWS') - self.test_account2.third_party = False - self.test_account2.active = True - mock_query.add_account(self.test_account2) - - self.test_account3 = Account() - self.test_account3.name = "TEST_ACCOUNT3" - self.test_account3.notes = "TEST ACCOUNT3" - self.test_account3.s3_name = "TEST_ACCOUNT3" - self.test_account3.number = "012345678910" - self.test_account3.role_name = "TEST_ACCOUNT" - self.test_account3.account_type = AccountType(name='AWS') - self.test_account3.third_party = False - self.test_account3.active = False - mock_query.add_account(self.test_account3) - - self.test_account4 = Account() - self.test_account4.name = "TEST_ACCOUNT4" - self.test_account4.notes = "TEST ACCOUNT4" - self.test_account4.s3_name = "TEST_ACCOUNT4" - self.test_account4.number = "123123123123" - self.test_account4.role_name = "TEST_ACCOUNT" - self.test_account4.account_type = AccountType(name='AWS') - self.test_account4.third_party = False - self.test_account4.active = False - mock_query.add_account(self.test_account4) + def pre_test_setup(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + account = Account(number="012345678910", name="TEST_ACCOUNT1", + s3_name="TEST_ACCOUNT1", role_name="TEST_ACCOUNT1", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT1", + third_party=False, active=True) + db.session.add(account) + + account = Account(number="123123123123", name="TEST_ACCOUNT2", + s3_name="TEST_ACCOUNT2", role_name="TEST_ACCOUNT2", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT2", + third_party=False, active=True) + db.session.add(account) + + account = Account(number="109876543210", name="TEST_ACCOUNT3", + s3_name="TEST_ACCOUNT3", role_name="TEST_ACCOUNT3", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT3", + third_party=False, active=False) + db.session.add(account) + + account = Account(number="456456456456", name="TEST_ACCOUNT4", + s3_name="TEST_ACCOUNT4", role_name="TEST_ACCOUNT4", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT4", + third_party=False, active=False) + db.session.add(account) + + db.session.commit() RUNTIME_WATCHERS.clear() RUNTIME_AUDITORS.clear() - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) def test_find_all_changes(self): from security_monkey.scheduler import find_changes build_mock_result(watcher_configs, auditor_configs) @@ -168,8 +148,6 @@ def test_find_all_changes(self): msg="Auditor index3 should run twice but ran {} times" .format(len(RUNTIME_AUDITORS['index3']))) - @patch('security_monkey.datastore.Account.query', new=mock_query) - @patch('security_monkey.db.session.expunge', new=mock_db_session.expunge) def test_find_account_changes(self): from security_monkey.scheduler import find_changes build_mock_result(watcher_configs, auditor_configs) @@ -219,4 +197,4 @@ def test_find_account_changes(self): .format(len(RUNTIME_AUDITORS['index2']))) self.assertEqual(first=1, second=len(RUNTIME_AUDITORS['index3']), msg="Auditor index3 should run once but ran {} times" - .format(len(RUNTIME_AUDITORS['index3']))) + .format(len(RUNTIME_AUDITORS['index3']))) \ No newline at end of file diff --git a/security_monkey/tests/watchers/__init__.py b/security_monkey/tests/watchers/__init__.py index d6db0b8c6..4c571c8e7 100644 --- a/security_monkey/tests/watchers/__init__.py +++ b/security_monkey/tests/watchers/__init__.py @@ -11,3 +11,21 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.datastore import Account, AccountType +from security_monkey import db + +class SecurityMonkeyWatcherTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(number="012345678910", name="TEST_ACCOUNT", + s3_name="TEST_ACCOUNT", role_name="TEST_ACCOUNT", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT", + third_party=False, active=True) + + db.session.add(self.account) + db.session.commit() diff --git a/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py b/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py index 5bf2d5932..9cb3aeb69 100644 --- a/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py +++ b/security_monkey/tests/watchers/ec2/test_ebs_snapshot.py @@ -20,39 +20,25 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.ec2.ebs_snapshot import EBSSnapshot -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class EBSSnapshotWatcherTestCase(SecurityMonkeyTestCase): +class EBSSnapshotWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.connect_ec2('the_key', 'the_secret') vol = conn.create_volume(50, "us-east-1a") conn.create_snapshot(vol.id, 'My snapshot') - watcher = EBSSnapshot(accounts=[test_account.name]) + watcher = EBSSnapshot(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/ec2/test_ebs_volume.py b/security_monkey/tests/watchers/ec2/test_ebs_volume.py index 9b55a1d95..8eac9402c 100644 --- a/security_monkey/tests/watchers/ec2/test_ebs_volume.py +++ b/security_monkey/tests/watchers/ec2/test_ebs_volume.py @@ -20,38 +20,24 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.ec2.ebs_volume import EBSVolume -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class EBSVolumeWatcherTestCase(SecurityMonkeyTestCase): +class EBSVolumeWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.connect_ec2('the_key', 'the_secret') conn.create_volume(50, "us-east-1a") - watcher = EBSVolume(accounts=[test_account.name]) + watcher = EBSVolume(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/ec2/test_ec2_image.py b/security_monkey/tests/watchers/ec2/test_ec2_image.py index a0ca77b02..50202ee12 100644 --- a/security_monkey/tests/watchers/ec2/test_ec2_image.py +++ b/security_monkey/tests/watchers/ec2/test_ec2_image.py @@ -20,34 +20,20 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.ec2.ec2_image import EC2Image -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class EC2ImageWatcherTestCase(SecurityMonkeyTestCase): +class EC2ImageWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto3.client('ec2', 'us-east-1') reservation = conn.run_instances( ImageId='ami-1234abcd', MinCount=1, MaxCount=1) @@ -55,7 +41,7 @@ def test_slurp(self): conn.create_image(InstanceId=instance[ 'InstanceId'], Name="test-ami", Description="this is a test ami") - watcher = EC2Image(accounts=[test_account.name]) + watcher = EC2Image(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/ec2/test_ec2_instance.py b/security_monkey/tests/watchers/ec2/test_ec2_instance.py index 035507962..f36f2d29f 100644 --- a/security_monkey/tests/watchers/ec2/test_ec2_instance.py +++ b/security_monkey/tests/watchers/ec2/test_ec2_instance.py @@ -20,38 +20,24 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.ec2.ec2_instance import EC2Instance -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class EC2InstanceWatcherTestCase(SecurityMonkeyTestCase): +class EC2InstanceWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto3.client('ec2', 'us-east-1') conn.run_instances(ImageId='ami-1234abcd', MinCount=1, MaxCount=1) - watcher = EC2Instance(accounts=[test_account.name]) + watcher = EC2Instance(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/rds/test_rds_db_instance.py b/security_monkey/tests/watchers/rds/test_rds_db_instance.py index a748d909f..21576b653 100644 --- a/security_monkey/tests/watchers/rds/test_rds_db_instance.py +++ b/security_monkey/tests/watchers/rds/test_rds_db_instance.py @@ -20,39 +20,25 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.rds.rds_db_instance import RDSDBInstance -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_rds from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class RDSDBInstanceWatcherTestCase(SecurityMonkeyTestCase): +class RDSDBInstanceWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_rds - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.rds.connect_to_region('us-east-1') conn.create_dbinstance( "db-master-1", 10, 'db.m1.small', 'root', 'hunter2') - watcher = RDSDBInstance(accounts=[test_account.name]) + watcher = RDSDBInstance(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/rds/test_rds_security_group.py b/security_monkey/tests/watchers/rds/test_rds_security_group.py index 78a354548..e5e70532b 100644 --- a/security_monkey/tests/watchers/rds/test_rds_security_group.py +++ b/security_monkey/tests/watchers/rds/test_rds_security_group.py @@ -20,38 +20,24 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.rds.rds_security_group import RDSSecurityGroup -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_rds from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class RDSecurityGroupWatcherTestCase(SecurityMonkeyTestCase): +class RDSecurityGroupWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_rds - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.rds.connect_to_region('us-east-1') conn.create_dbsecurity_group('db_sg1', 'DB Security Group') - watcher = RDSSecurityGroup(accounts=[test_account.name]) + watcher = RDSSecurityGroup(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/rds/test_rds_subnet_group.py b/security_monkey/tests/watchers/rds/test_rds_subnet_group.py index 4cea5949d..32016fb9b 100644 --- a/security_monkey/tests/watchers/rds/test_rds_subnet_group.py +++ b/security_monkey/tests/watchers/rds/test_rds_subnet_group.py @@ -20,36 +20,22 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.rds.rds_subnet_group import RDSSubnetGroup -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_rds, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class RDSSubnetGroupWatcherTestCase(SecurityMonkeyTestCase): +class RDSSubnetGroupWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_rds @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - - vpc_conn = boto.connect_vpc('the_key', 'the_secret') + vpc_conn = boto.connect_vpc("us-east-1") vpc = vpc_conn.create_vpc("10.0.0.0/16") subnet = vpc_conn.create_subnet(vpc.id, "10.1.0.0/24") @@ -57,7 +43,7 @@ def test_slurp(self): conn = boto.rds.connect_to_region("us-east-1") conn.create_db_subnet_group("db_subnet", "my db subnet", subnet_ids) - watcher = RDSSubnetGroup(accounts=[test_account.name]) + watcher = RDSSubnetGroup(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/test_lambda_function.py b/security_monkey/tests/watchers/test_lambda_function.py index acc7ed954..a102e65e6 100644 --- a/security_monkey/tests/watchers/test_lambda_function.py +++ b/security_monkey/tests/watchers/test_lambda_function.py @@ -20,20 +20,15 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.lambda_function import LambdaFunction -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_lambda from freezegun import freeze_time -from mock import patch import io import zipfile -mock_query = MockAccountQuery() - def get_test_zip_file(): zip_output = io.BytesIO() @@ -47,21 +42,12 @@ def handler(event, context): return zip_output.read() -class LambdaFunctionWatcherTestCase(SecurityMonkeyTestCase): +class LambdaFunctionWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_lambda - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto3.client('lambda', 'us-east-1') conn.create_function( @@ -77,7 +63,7 @@ def test_slurp(self): MemorySize=128, Publish=True, ) - watcher = LambdaFunction(accounts=[test_account.name]) + watcher = LambdaFunction(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/test_route53.py b/security_monkey/tests/watchers/test_route53.py index 60015b84f..c835876e3 100644 --- a/security_monkey/tests/watchers/test_route53.py +++ b/security_monkey/tests/watchers/test_route53.py @@ -20,34 +20,20 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.route53 import Route53 -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_route53 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class Route53WatcherTestCase(SecurityMonkeyTestCase): +class Route53WatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_route53 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.connect_route53('the_key', 'the_secret') zone = conn.create_hosted_zone("testdns.aws.com") zone_id = zone["CreateHostedZoneResponse"][ @@ -57,7 +43,7 @@ def test_slurp(self): change.add_value("10.1.1.1") changes.commit() - watcher = Route53(accounts=[test_account.name]) + watcher = Route53(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/vpc/test_dhcp.py b/security_monkey/tests/watchers/vpc/test_dhcp.py index 2561a466e..d92424f2d 100644 --- a/security_monkey/tests/watchers/vpc/test_dhcp.py +++ b/security_monkey/tests/watchers/vpc/test_dhcp.py @@ -20,34 +20,20 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.vpc.dhcp import DHCP -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto3 from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class DHCPTestCase(SecurityMonkeyTestCase): +class DHCPTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - ec2 = boto3.resource('ec2', region_name='us-east-1') ec2.create_dhcp_options(DhcpConfigurations=[ @@ -55,7 +41,7 @@ def test_slurp(self): {'Key': 'domain-name-servers', 'Values': ['10.0.10.2']} ]) - watcher = DHCP(accounts=[test_account.name]) + watcher = DHCP(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/vpc/test_networkacl.py b/security_monkey/tests/watchers/vpc/test_networkacl.py index 37c00ba2c..925507ce3 100644 --- a/security_monkey/tests/watchers/vpc/test_networkacl.py +++ b/security_monkey/tests/watchers/vpc/test_networkacl.py @@ -20,38 +20,24 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.vpc.networkacl import NetworkACL -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class NetworkACLWatcherTestCase(SecurityMonkeyTestCase): +class NetworkACLWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.connect_vpc('the_key', 'the secret') conn.create_vpc("10.0.0.0/16") - watcher = NetworkACL(accounts=[test_account.name]) + watcher = NetworkACL(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/vpc/test_peering.py b/security_monkey/tests/watchers/vpc/test_peering.py index 6eb7696ae..f700f86cb 100644 --- a/security_monkey/tests/watchers/vpc/test_peering.py +++ b/security_monkey/tests/watchers/vpc/test_peering.py @@ -20,41 +20,27 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.vpc.peering import Peering -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class PeeringWatcherTestCase(SecurityMonkeyTestCase): +class PeeringWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.connect_vpc('the_key', 'the secret') vpc = conn.create_vpc("10.0.0.0/16") peer_vpc = conn.create_vpc("10.0.0.0/16") conn.create_vpc_peering_connection(vpc.id, peer_vpc.id) - watcher = Peering(accounts=[test_account.name]) + watcher = Peering(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( diff --git a/security_monkey/tests/watchers/vpc/test_route_table.py b/security_monkey/tests/watchers/vpc/test_route_table.py index eba4bb660..30cc2392a 100644 --- a/security_monkey/tests/watchers/vpc/test_route_table.py +++ b/security_monkey/tests/watchers/vpc/test_route_table.py @@ -20,38 +20,24 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase from security_monkey.watchers.vpc.route_table import RouteTable -from security_monkey.datastore import Account -from security_monkey.tests.core.db_mock import MockAccountQuery import boto from moto import mock_sts, mock_ec2 from freezegun import freeze_time -from mock import patch -mock_query = MockAccountQuery() - -class RouteTableWatcherTestCase(SecurityMonkeyTestCase): +class RouteTableWatcherTestCase(SecurityMonkeyWatcherTestCase): @freeze_time("2016-07-18 12:00:00") @mock_sts @mock_ec2 - @patch('security_monkey.datastore.Account.query', new=mock_query) def test_slurp(self): - test_account = Account() - test_account.name = "TEST_ACCOUNT" - test_account.notes = "TEST ACCOUNT" - test_account.s3_name = "TEST_ACCOUNT" - test_account.number = "012345678910" - test_account.role_name = "TEST_ACCOUNT" - mock_query.add_account(test_account) - conn = boto.connect_vpc('the_key', 'the secret') conn.create_vpc("10.0.0.0/16") - watcher = RouteTable(accounts=[test_account.name]) + watcher = RouteTable(accounts=[self.account.name]) item_list, exception_map = watcher.slurp() self.assertIs( From c5e9a0eca0f9e8466c99d5e4247b76cc3441582e Mon Sep 17 00:00:00 2001 From: Anne Ebie Date: Thu, 1 Sep 2016 17:06:21 -0400 Subject: [PATCH 19/90] Add bulk enable and disable account service Type: feature Why is this change necessary? As the number of accounts being watched increases, it becomes harder to manage different environments and which accounts they should be watching. This service enables an API and command-line tool (through manage.py) that allows a bulk list of accounts to be enabled or disabled in one command. --- .../settings_component.dart | 18 ++++++ .../settings_component.html | 21 +++++-- dart/lib/model/AccountBulkUpdate.dart | 20 +++++++ dart/lib/model/hammock_config.dart | 8 ++- dart/lib/security_monkey.dart | 1 + manage.py | 19 ++++++- security_monkey/__init__.py | 6 +- security_monkey/scheduler.py | 24 ++++++++ security_monkey/tests/core/test_scheduler.py | 56 +++++++++++++++++- security_monkey/views/account_bulk_update.py | 57 +++++++++++++++++++ setup.py | 2 +- 11 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 dart/lib/model/AccountBulkUpdate.dart create mode 100644 security_monkey/views/account_bulk_update.py diff --git a/dart/lib/component/settings_component/settings_component.dart b/dart/lib/component/settings_component/settings_component.dart index c2ff1b5eb..edac972cf 100644 --- a/dart/lib/component/settings_component/settings_component.dart +++ b/dart/lib/component/settings_component/settings_component.dart @@ -13,6 +13,8 @@ class SettingsComponent extends PaginatedTable { List auditorlist; ObjectStore store; UserSetting user_setting; + bool active_edit_mode = false; + SettingsComponent(this.router, this.store, this.us) { accounts = new List(); @@ -93,6 +95,22 @@ class SettingsComponent extends PaginatedTable { router.go('createaccount', {}); } + void toggleActiveEditMode() { + super.is_loaded = false; + this.active_edit_mode = true; + super.is_loaded = true; + } + + void storeActive() { + super.is_loaded = false; + AccountBulkUpdate bulkUpdate = new AccountBulkUpdate.fromAccountList(this.accounts); + + this.store.update(bulkUpdate).then((_) { + this.active_edit_mode = false; + super.is_loaded = true; + }); + } + void disableAuditor(auditor) { auditor.disabled = true; store.update(auditor); diff --git a/dart/lib/component/settings_component/settings_component.html b/dart/lib/component/settings_component/settings_component.html index 3aaeaf0e1..c381dfc99 100644 --- a/dart/lib/component/settings_component/settings_component.html +++ b/dart/lib/component/settings_component/settings_component.html @@ -47,8 +47,12 @@
Daily Email
- - + + + @@ -62,10 +66,15 @@
Daily Email
- - - - + + + + diff --git a/dart/lib/model/AccountBulkUpdate.dart b/dart/lib/model/AccountBulkUpdate.dart new file mode 100644 index 000000000..2fccec48d --- /dev/null +++ b/dart/lib/model/AccountBulkUpdate.dart @@ -0,0 +1,20 @@ +library security_monkey.account_bulk_update; + +import 'Account.dart'; +import 'dart:convert'; + +class AccountBulkUpdate { + Map accounts_map = new Map(); + + AccountBulkUpdate(); + + AccountBulkUpdate.fromAccountList(List accounts) { + for (var account in accounts) { + this.accounts_map[account.name] = account.active; + } + } + + String toJson() { + return JSON.encode(this.accounts_map); + } +} diff --git a/dart/lib/model/hammock_config.dart b/dart/lib/model/hammock_config.dart index 40ae37fba..34b8da789 100644 --- a/dart/lib/model/hammock_config.dart +++ b/dart/lib/model/hammock_config.dart @@ -14,19 +14,21 @@ import 'ignore_entry.dart'; import 'User.dart'; import 'Role.dart'; import 'account_config.dart'; +import 'AccountBulkUpdate.dart'; @MirrorsUsed( targets: const[ Account, IgnoreEntry, Issue, AuditorSetting, Item, ItemComment, NetworkWhitelistEntry, Revision, RevisionComment, UserSetting, User, Role, - AccountConfig], + AccountConfig, AccountBulkUpdate], override: '*') import 'dart:mirrors'; import 'package:security_monkey/util/constants.dart'; Resource serializeAccount(Account account) => resource("accounts", account.id, account.toJson()); +Resource serializeAccountBulkUpdate(AccountBulkUpdate bulk_update) => resource("accounts_bulk", "batch", bulk_update.toJson()); final serializeIssue = serializer("issues", ["id", "score", "issue", "notes", "justified", "justified_user", "justification", "justified_date", "item_id"]); final serializeRevision = serializer("revisions", ["id", "item_id", "config", "active", "date_created", "diff_html"]); final serializeItem = serializer("items", ["id", "technology", "region", "account", "name"]); @@ -131,6 +133,10 @@ createHammockConfig(Injector inj) { "deserializer": { "query": deserializeRole } + }, + "account_bulk_update": { + "type": AccountBulkUpdate, + "serializer": serializeAccountBulkUpdate } }) ..urlRewriter.baseUrl = '$API_HOST' diff --git a/dart/lib/security_monkey.dart b/dart/lib/security_monkey.dart index d8738b294..8608bf36e 100644 --- a/dart/lib/security_monkey.dart +++ b/dart/lib/security_monkey.dart @@ -37,6 +37,7 @@ import 'model/Role.dart'; import 'model/ItemLink.dart'; import 'model/account_config.dart'; import 'model/custom_field_config.dart'; +import 'model/AccountBulkUpdate.dart'; // Routing import 'routing/securitymonkey_router.dart'; diff --git a/manage.py b/manage.py index ea3de3062..319b8d5b4 100644 --- a/manage.py +++ b/manage.py @@ -25,6 +25,8 @@ from security_monkey.scheduler import run_change_reporter as sm_run_change_reporter from security_monkey.scheduler import find_changes as sm_find_changes from security_monkey.scheduler import audit_changes as sm_audit_changes +from security_monkey.scheduler import disable_accounts as sm_disable_accounts +from security_monkey.scheduler import enable_accounts as sm_enable_accounts from security_monkey.backup import backup_config_to_json as sm_backup_config_to_json from security_monkey.common.utils import find_modules, load_plugins from security_monkey.datastore import Account @@ -244,6 +246,19 @@ def create_user(email, role): db.session.commit() +@manager.option('-a', '--accounts', dest='accounts', type=unicode, default=u'all') +def disable_accounts(accounts): + """ Bulk disables one or more accounts """ + account_names = _parse_accounts(accounts) + sm_disable_accounts(account_names) + +@manager.option('-a', '--accounts', dest='accounts', type=unicode, default=u'all') +def enable_accounts(accounts): + """ Bulk enables one or more accounts """ + account_names = _parse_accounts(accounts, active=False) + sm_enable_accounts(account_names) + + def _parse_tech_names(tech_str): if tech_str == 'all': return watcher_registry.keys() @@ -251,9 +266,9 @@ def _parse_tech_names(tech_str): return tech_str.split(',') -def _parse_accounts(account_str): +def _parse_accounts(account_str, active=True): if account_str == 'all': - accounts = Account.query.filter(Account.third_party==False).filter(Account.active==True).all() + accounts = Account.query.filter(Account.third_party==False).filter(Account.active==active).all() accounts = [account.name for account in accounts] return accounts else: diff --git a/security_monkey/__init__.py b/security_monkey/__init__.py index a8053f0e6..020267238 100644 --- a/security_monkey/__init__.py +++ b/security_monkey/__init__.py @@ -163,6 +163,10 @@ def send_email(msg): from security_monkey.views.account_config import AccountConfigGet api.add_resource(AccountConfigGet, '/api/1/account_config/') +from security_monkey.views.account_bulk_update import AccountListPut +api.add_resource(AccountListPut, '/api/1/accounts_bulk/batch') + + ## Jira Sync import os from security_monkey.jirasync import JiraSync @@ -204,7 +208,7 @@ def setup_logging(): LOG_LEVEL = "DEBUG" LOG_FILE = "/var/log/security_monkey/securitymonkey.log" - 2) Set LOG_CFG in your config to a PEP-0391 compatible + 2) Set LOG_CFG in your config to a PEP-0391 compatible logging configuration. LOG_CFG = { diff --git a/security_monkey/scheduler.py b/security_monkey/scheduler.py index 9aea35627..de32a4e8f 100644 --- a/security_monkey/scheduler.py +++ b/security_monkey/scheduler.py @@ -59,6 +59,30 @@ def audit_changes(accounts, monitor_names, send_report, debug=True): _audit_changes(account, monitor.auditors, send_report, debug) +def disable_accounts(account_names): + for account_name in account_names: + account = Account.query.filter(Account.name == account_name).first() + if account: + app.logger.debug("Disabling account %s", account.name) + account.active = False + db.session.add(account) + + db.session.commit() + db.session.close() + + +def enable_accounts(account_names): + for account_name in account_names: + account = Account.query.filter(Account.name == account_name).first() + if account: + app.logger.debug("Enabling account %s", account.name) + account.active = True + db.session.add(account) + + db.session.commit() + db.session.close() + + def _audit_changes(account, auditors, send_report, debug=True): """ Runs auditors on all items """ try: diff --git a/security_monkey/tests/core/test_scheduler.py b/security_monkey/tests/core/test_scheduler.py index d65870008..2b2007eb2 100644 --- a/security_monkey/tests/core/test_scheduler.py +++ b/security_monkey/tests/core/test_scheduler.py @@ -197,4 +197,58 @@ def test_find_account_changes(self): .format(len(RUNTIME_AUDITORS['index2']))) self.assertEqual(first=1, second=len(RUNTIME_AUDITORS['index3']), msg="Auditor index3 should run once but ran {} times" - .format(len(RUNTIME_AUDITORS['index3']))) \ No newline at end of file + .format(len(RUNTIME_AUDITORS['index3']))) + + def test_disable_all_accounts(self): + from security_monkey.scheduler import disable_accounts + disable_accounts(['TEST_ACCOUNT1', 'TEST_ACCOUNT2', 'TEST_ACCOUNT3', 'TEST_ACCOUNT4']) + accounts = Account.query.all() + for account in accounts: + self.assertFalse(account.active) + + def test_disable_one_accounts(self): + from security_monkey.scheduler import disable_accounts + disable_accounts(['TEST_ACCOUNT1']) + accounts = Account.query.all() + for account in accounts: + if account.name == 'TEST_ACCOUNT2': + self.assertTrue(account.active) + else: + self.assertFalse(account.active) + + def test_enable_all_accounts(self): + from security_monkey.scheduler import enable_accounts + enable_accounts(['TEST_ACCOUNT1', 'TEST_ACCOUNT2', 'TEST_ACCOUNT3', 'TEST_ACCOUNT4']) + accounts = Account.query.all() + for account in accounts: + self.assertTrue(account.active) + + def test_enable_one_accounts(self): + from security_monkey.scheduler import enable_accounts + enable_accounts(['TEST_ACCOUNT3']) + accounts = Account.query.all() + for account in accounts: + if account.name != 'TEST_ACCOUNT4': + self.assertTrue(account.active) + else: + self.assertFalse(account.active) + + def test_enable_bad_accounts(self): + from security_monkey.scheduler import enable_accounts + enable_accounts(['BAD_ACCOUNT']) + accounts = Account.query.all() + for account in accounts: + if account.name == 'TEST_ACCOUNT1' or account.name == 'TEST_ACCOUNT2': + self.assertTrue(account.active) + else: + self.assertFalse(account.active) + + def test_disable_bad_accounts(self): + from security_monkey.scheduler import disable_accounts + disable_accounts(['BAD_ACCOUNT']) + accounts = Account.query.all() + for account in accounts: + if account.name == 'TEST_ACCOUNT1' or account.name == 'TEST_ACCOUNT2': + self.assertTrue(account.active) + else: + self.assertFalse(account.active) diff --git a/security_monkey/views/account_bulk_update.py b/security_monkey/views/account_bulk_update.py new file mode 100644 index 000000000..049ad253e --- /dev/null +++ b/security_monkey/views/account_bulk_update.py @@ -0,0 +1,57 @@ +# Copyright 2017 Bridgewater Associates, LP +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.views.account_bulk_update + :platform: Unix + :synopsis: Updates the active flag for a list of accounts. + + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" + + +from security_monkey.views import AuthenticatedService +from security_monkey.datastore import Account +from security_monkey import app, db, rbac + +from flask import request +from flask_restful import reqparse +import json + + +class AccountListPut(AuthenticatedService): + decorators = [ + rbac.allow(["Admin"], ["PUT"]) + ] + + def __init__(self): + super(AccountListPut, self).__init__() + self.reqparse = reqparse.RequestParser() + + def put(self): + values = json.loads(request.json) + app.logger.debug("Account bulk update {}".format(values)) + for account_name in values.keys(): + account = Account.query.filter(Account.name == account_name).first() + if account: + account.active = values[account_name] + db.session.add(account) + + db.session.commit() + db.session.close() + + return {'status': 'updated'}, 200 diff --git a/setup.py b/setup.py index 02c941a3c..a04bb8d3e 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ 'itsdangerous==0.23', 'psycopg2==2.6.2', 'bcrypt==3.1.2', - 'Sphinx==1.2.2', + 'Sphinx==1.5.1', 'gunicorn==18.0', 'cryptography==1.7.1', 'boto3>=1.4.2', From 4b9329b33188af647b06891eaa091e87e3989123 Mon Sep 17 00:00:00 2001 From: Barrett Jones Date: Mon, 24 Oct 2016 10:45:31 -0500 Subject: [PATCH 20/90] [secmonkey] Add sorting to accounts tables Type: generic-feature-enhancement Why is this change necessary? Improve user interaction with account settings This change addresses the need by: Add sorting capabilities to the account settings table Potential Side Effects: No known side effects --- .../settings_component.dart | 4 ++- .../settings_component.html | 25 ++++++++++++---- security_monkey/views/account.py | 29 +++++++++++++++++-- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/dart/lib/component/settings_component/settings_component.dart b/dart/lib/component/settings_component/settings_component.dart index c2ff1b5eb..1d147b1c0 100644 --- a/dart/lib/component/settings_component/settings_component.dart +++ b/dart/lib/component/settings_component/settings_component.dart @@ -31,7 +31,9 @@ class SettingsComponent extends PaginatedTable { void list() { store.list(Account, params: { "count": ipp_as_int, - "page": currentPage + "page": currentPage, + "order_by": sorting_column, + "order_dir": order_dir() }).then((accounts) { super.setPaginationData(accounts.meta); this.accounts = accounts; diff --git a/dart/lib/component/settings_component/settings_component.html b/dart/lib/component/settings_component/settings_component.html index 3aaeaf0e1..daf107ea5 100644 --- a/dart/lib/component/settings_component/settings_component.html +++ b/dart/lib/component/settings_component/settings_component.html @@ -49,11 +49,26 @@
Daily Email
- - - - - + + + + + diff --git a/security_monkey/views/account.py b/security_monkey/views/account.py index 3559def3c..7592e3b00 100644 --- a/security_monkey/views/account.py +++ b/security_monkey/views/account.py @@ -14,7 +14,7 @@ from security_monkey.views import AuthenticatedService from security_monkey.views import ACCOUNT_FIELDS -from security_monkey.datastore import Account +from security_monkey.datastore import Account, AccountType from security_monkey.datastore import User from security_monkey.account_manager import get_account_by_id from security_monkey import db, rbac @@ -326,12 +326,35 @@ def get(self): self.reqparse.add_argument('count', type=int, default=30, location='args') self.reqparse.add_argument('page', type=int, default=1, location='args') + self.reqparse.add_argument('order_by', type=str, default=None, location='args') + self.reqparse.add_argument('order_dir', type=str, default='desc', location='args') args = self.reqparse.parse_args() page = args.pop('page', None) count = args.pop('count', None) - - result = Account.query.order_by(Account.id).paginate(page, count, error_out=False) + order_by = args.pop('order_by', None) + order_dir = args.pop('order_dir', None) + for k, v in args.items(): + if not v: + del args[k] + + query = Account.query + + if order_by and hasattr(Account, order_by): + if order_dir.lower() == 'asc': + if order_by == 'account_type': + query = query.join(Account.account_type).order_by(getattr(AccountType, 'name').asc()) + else: + query = query.order_by(getattr(Account, order_by).asc()) + else: + if order_by == 'account_type': + query = query.join(Account.account_type).order_by(getattr(AccountType, 'name').desc()) + else: + query = query.order_by(getattr(Account, order_by).desc()) + else: + query = query.order_by(Account.id) + + result = query.paginate(page, count, error_out=False) items = [] for account in result.items: From 1f745fd887b74462f1a4779503abaa1cca29045e Mon Sep 17 00:00:00 2001 From: Domonkos Czinke Date: Tue, 14 Feb 2017 23:36:32 +0100 Subject: [PATCH 21/90] =?UTF-8?q?Add=20more=20Docker=20envvars=20(#538)=20?= =?UTF-8?q?=F0=9F=9A=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add some more envvars * Add GOOGLE_HOSTED_DOMAIN env var * Make Register button controlled by env variable * Add same comment --- env-config/config-docker.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/env-config/config-docker.py b/env-config/config-docker.py index c8c4c2997..83ae574de 100644 --- a/env-config/config-docker.py +++ b/env-config/config-docker.py @@ -92,10 +92,10 @@ def env_to_bool(input): NGINX_PORT = '443' BASE_URL = 'https://{}/'.format(FQDN) -SECRET_KEY = '' +SECRET_KEY = os.getenv('SECURITY_MONKEY_SECRET_KEY', '') MAIL_DEFAULT_SENDER = os.getenv('SECURITY_MONKEY_EMAIL_DEFAULT_SENDER', 'securitymonkey@example.com') -SECURITY_REGISTERABLE = True +SECURITY_REGISTERABLE = os.getenv('SECURITY_MONKEY_SECURITY_REGISTERABLE', 'True') SECURITY_CONFIRMABLE = False SECURITY_RECOVERABLE = False SECURITY_PASSWORD_HASH = 'bcrypt' @@ -134,7 +134,7 @@ def env_to_bool(input): MAX_THREADS = 30 # SSO SETTINGS: -ACTIVE_PROVIDERS = [] # "ping", "google" or "onelogin" +ACTIVE_PROVIDERS = [ os.getenv('SECURITY_MONKEY_ACTIVE_PROVIDERS', '') ] # "ping", "google" or "onelogin" PING_NAME = '' # Use to override the Ping name in the UI. PING_REDIRECT_URI = "{BASE}api/1/auth/ping".format(BASE=BASE_URL) @@ -145,10 +145,10 @@ def env_to_bool(input): PING_JWKS_URL = '' # Often something ending in JWKS PING_SECRET = '' # Provided by your administrator -GOOGLE_CLIENT_ID = '' -GOOGLE_AUTH_ENDPOINT = '' -GOOGLE_SECRET = '' -# GOOGLE_HOSTED_DOMAIN = 'example.com' # Verify that token issued by comes from domain +GOOGLE_CLIENT_ID = os.getenv('SECURITY_MONKEY_GOOGLE_CLIENT_ID', '') +GOOGLE_AUTH_ENDPOINT = os.getenv('SECURITY_MONKEY_GOOGLE_AUTH_ENDPOINT', '') +GOOGLE_SECRET = os.getenv('SECURITY_MONKEY_GOOGLE_SECRET', '') +GOOGLE_HOSTED_DOMAIN = os.getenv('SECURITY_MONKEY_GOOGLE_HOSTED_DOMAIN', '') # Verify that token issued by comes from domain ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address From 714775b2dde29f840584e2945257ceceb4af71dd Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 14 Feb 2017 15:27:03 -0800 Subject: [PATCH 22/90] =?UTF-8?q?Supertom=20acct=20type=20(#540)=20?= =?UTF-8?q?=F0=9F=91=8F=20=F0=9F=92=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add account type field to item, item details and search bar. This PR adds the Account Type field in three places: 1. The item_component_table (item table on main screen). 2. Item details (appears in sidebar) 3. Search Bar. Autocomplete is also supported. This change is meant as UI only; no logic changes have been made as part of this PR. * Updating Reports dropdown for the new URL param. (Also, replacing tabs with spaces.) --- .../dashboard_component.dart | 2 + .../item_table_component.html | 2 + .../component/itemdetails/itemdetails.html | 1 + .../search_bar_component.dart | 6 ++- .../search_bar_component.html | 10 +++- dart/lib/model/Item.dart | 2 + dart/lib/routing/securitymonkey_router.dart | 6 +-- dart/web/js/searchpage.js | 45 +++++++++++++++- dart/web/ui.html | 52 +++++++++---------- security_monkey/views/distinct.py | 18 ++++++- security_monkey/views/item.py | 6 ++- 11 files changed, 115 insertions(+), 35 deletions(-) diff --git a/dart/lib/component/dashboard_component/dashboard_component.dart b/dart/lib/component/dashboard_component/dashboard_component.dart index 0900d9825..468ff97e6 100644 --- a/dart/lib/component/dashboard_component/dashboard_component.dart +++ b/dart/lib/component/dashboard_component/dashboard_component.dart @@ -30,6 +30,7 @@ class DashboardComponent { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypess': '', 'names': '', 'active': true, 'searchconfig': null, @@ -42,6 +43,7 @@ class DashboardComponent { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypess': '', 'names': '', 'active': null, 'searchconfig': null, diff --git a/dart/lib/component/item_table_component/item_table_component.html b/dart/lib/component/item_table_component/item_table_component.html index 484e33754..967edc500 100644 --- a/dart/lib/component/item_table_component/item_table_component.html +++ b/dart/lib/component/item_table_component/item_table_component.html @@ -49,6 +49,7 @@

Items

+ @@ -64,6 +65,7 @@

Items

+ diff --git a/dart/lib/component/itemdetails/itemdetails.html b/dart/lib/component/itemdetails/itemdetails.html index 15d5178c2..991eedd0a 100644 --- a/dart/lib/component/itemdetails/itemdetails.html +++ b/dart/lib/component/itemdetails/itemdetails.html @@ -7,6 +7,7 @@
  • Technology {{item.technology}}
  • Region {{item.region}}
  • Account {{item.account}}
  • +
  • Account Type{{item.account_type}}
  • diff --git a/dart/lib/component/search_bar_component/search_bar_component.dart b/dart/lib/component/search_bar_component/search_bar_component.dart index 849dfd69a..58ba28eb5 100644 --- a/dart/lib/component/search_bar_component/search_bar_component.dart +++ b/dart/lib/component/search_bar_component/search_bar_component.dart @@ -18,6 +18,7 @@ class SearchBarComponent { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypes': '', 'names': '', 'arns': '', 'active': null, @@ -52,6 +53,7 @@ class SearchBarComponent { updateS2Tags(this.filter_params['regions'], 'regions'); updateS2Tags(this.filter_params['technologies'], 'technologies'); updateS2Tags(this.filter_params['accounts'], 'accounts'); + updateS2Tags(this.filter_params['accounttypes'], 'accounttypes'); updateS2Tags(this.filter_params['names'], 'names'); updateS2Tags(this.filter_params['arns'], 'arns'); } @@ -107,6 +109,7 @@ class SearchBarComponent { _add_param(filter_params, 'regions'); _add_param(filter_params, 'technologies'); _add_param(filter_params, 'accounts'); + _add_param(filter_params, 'accounttypes'); _add_param(filter_params, 'names'); _add_param(filter_params, 'arns'); filter_params['active'] = param_to_url(active_filter_value); @@ -127,10 +130,11 @@ class SearchBarComponent { String regions = getParamString("filterregions", "regions"); String technologies = getParamString("filtertechnologies", "technologies"); String accounts = getParamString("filteraccounts", "accounts"); + String accounttypes = getParamString("filteraccounttypes", "accounttypes"); String names = getParamString("filternames", "names"); String arns = getParamString("filterarns", "arns"); String active = this.active_filter_value != "null" ? "&active=$active_filter_value" : ""; - String retval = "&$regions&$technologies&$accounts&$names&$arns$active"; + String retval = "&$regions&$technologies&$accounts&$accounttypes&$names&$arns$active"; print("getFilterString returning $retval"); return retval; } diff --git a/dart/lib/component/search_bar_component/search_bar_component.html b/dart/lib/component/search_bar_component/search_bar_component.html index c3cf8a0b0..8d3d16236 100644 --- a/dart/lib/component/search_bar_component/search_bar_component.html +++ b/dart/lib/component/search_bar_component/search_bar_component.html @@ -23,6 +23,14 @@
    Account
    multiple="multiple" id="s2_accounts">
    +
    +
    AccountType
    + + + +
    Name
    @@ -93,4 +101,4 @@
    Type
    \ No newline at end of file + diff --git a/dart/lib/model/Item.dart b/dart/lib/model/Item.dart index b73ea68a2..9cf551b1c 100644 --- a/dart/lib/model/Item.dart +++ b/dart/lib/model/Item.dart @@ -10,6 +10,7 @@ class Item { String technology; String region; String account; + String account_type; String name; bool selected_for_action = false; @@ -36,6 +37,7 @@ class Item { technology = item['technology']; region = item['region']; account = item['account']; + account_type = item['account_type']; name = item['name']; // These are returned in item-list so that all issues and revisions needn't be downloaded. diff --git a/dart/lib/routing/securitymonkey_router.dart b/dart/lib/routing/securitymonkey_router.dart index d1025aad3..b55793f04 100644 --- a/dart/lib/routing/securitymonkey_router.dart +++ b/dart/lib/routing/securitymonkey_router.dart @@ -15,7 +15,7 @@ void securityMonkeyRouteInitializer(Router router, RouteViewFactory views) { view: 'views/error.html') }), 'items': ngRoute( - path: '/items/:regions/:technologies/:accounts/:names/:arns/:active/:searchconfig/:min_score/:min_unjustified_score/:page/:count', + path: '/items/:regions/:technologies/:accounts/:accounttypes/:names/:arns/:active/:searchconfig/:min_score/:min_unjustified_score/:page/:count', defaultRoute: true, mount: { 'view': ngRoute( @@ -27,7 +27,7 @@ void securityMonkeyRouteInitializer(Router router, RouteViewFactory views) { }), 'revisions': ngRoute( // Is this the best way to pass params? - path: '/revisions/:regions/:technologies/:accounts/:names/:arns/:active/:searchconfig/:page/:count', + path: '/revisions/:regions/:technologies/:accounts/:accounttypes/:names/:arns/:active/:searchconfig/:page/:count', mount: { 'view': ngRoute( path: '', @@ -38,7 +38,7 @@ void securityMonkeyRouteInitializer(Router router, RouteViewFactory views) { }), 'issues': ngRoute( // Is this the best way to pass params? - path: '/issues/:regions/:technologies/:accounts/:names/:arns/:active/:searchconfig/:page/:count', + path: '/issues/:regions/:technologies/:accounts/:accounttypes/:names/:arns/:active/:searchconfig/:page/:count', mount: { 'view': ngRoute( path: '', diff --git a/dart/web/js/searchpage.js b/dart/web/js/searchpage.js index 131c5cfc6..f26210359 100644 --- a/dart/web/js/searchpage.js +++ b/dart/web/js/searchpage.js @@ -132,6 +132,49 @@ $(document).ready(function() { pushFilterRoutes(); }); + $("#s2_accounttypes").select2({ + tags:["red", "green", "blue"], + tokenSeparators: [",", " "], + placeholder: "", + allowClear: true, + blurOnChange: true, + openOnEnter: false, + multiple: true, + ajax: { + url: function() { + return getAPIHost()+"/distinct/accounttype?select2=True"+getFilterString(); + }, + params: { + xhrFields: { withCredentials: true } + }, + dataType: 'json', + data: function (term, page) { + return { + page: page, + count: 25, + searchconfig: term + }; + }, + results: function (data, page) { + var more = (page * 25) < data.total; + return { results: data.items, more: more }; + } + }, + formatResult: namesformat, + formatSelection: namesformat, + escapeMarkup: function(m) { return m; }, + initSelection : function (element, callback) { + var data = []; + $(element.val().split(",")).each(function () { + data.push({id: this, text: this}); + }); + callback(data); + } + }); + $('#s2_accounttypes').on('change', function(e) { + $('#filteraccounttypes').val(e.val); + pushFilterRoutes(); + }); // If the name is too long ( > 25 characters) // Insert a special character (​) every 25 characters // that will be invisible but will allow the name to wrap. @@ -240,4 +283,4 @@ $(document).ready(function() { pushFilterRoutes(); }); -}); \ No newline at end of file +}); diff --git a/dart/web/ui.html b/dart/web/ui.html index e432fd296..ceaecaaa1 100644 --- a/dart/web/ui.html +++ b/dart/web/ui.html @@ -43,42 +43,42 @@
    -
    +
    - +
    diff --git a/dart/lib/component/auditscore_view_component/auditscore_view_component.dart b/dart/lib/component/auditscore_view_component/auditscore_view_component.dart new file mode 100644 index 000000000..7d2c61520 --- /dev/null +++ b/dart/lib/component/auditscore_view_component/auditscore_view_component.dart @@ -0,0 +1,92 @@ +part of security_monkey; + +@Component( + selector: 'auditscoreview', + templateUrl: 'packages/security_monkey/component/auditscore_view_component/auditscore_view_component.html', + //cssUrl: const ['/css/bootstrap.min.css'] + useShadowDom: false +) +class AuditScoreComponent implements ScopeAware { + RouteProvider routeProvider; + Router router; + AuditScore auditscore; + List technologies; + Map> methods; + bool create = false; + bool _as_loaded = false; + bool _is_error = false; + String err_message = ""; + ObjectStore store; + UsernameService us; + + AuditScoreComponent(this.routeProvider, this.router, this.store, this.us) { + this.store = store; + // If the URL has an ID, then let's view/edit + if (routeProvider.parameters.containsKey("auditscoreid")) { + store.one(AuditScore, routeProvider.parameters['auditscoreid']).then((auditscore) { + this.auditscore = auditscore; + _as_loaded = true; + }); + create = false; + } else { + // If the URL does not have an ID, then let's create + auditscore = new AuditScore(); + create = true; + } + store.one(TechMethods, "all").then((techmethods) { + this.technologies = techmethods.technologies; + this.methods = techmethods.methods; + }); + + } + + void set scope(Scope scope) { + scope.on("globalAlert").listen(this._showMessage); + } + + get isLoaded => create || _as_loaded; + get isError => _is_error; + + void _showMessage(ScopeEvent event) { + this._is_error = true; + this.err_message = event.data; + } + + void saveEntry() { + if (create) { + this.store.create(this.auditscore).then((CommandResponse r) { + int id = r.content['id']; + router.go('viewauditscore', { + 'auditscoreid': id + }); + }); + } else { + this.store.update(this.auditscore).then( (_) { + // let the page flicker so people know the update happened. + // (poor man's UX) + _as_loaded = false; + store.one(AuditScore, routeProvider.parameters['auditscoreid']).then((auditscore) { + this.auditscore = auditscore; + _as_loaded = true; + }); + }); + } + } + + void deleteEntry() { + this.store.delete(this.auditscore).then((_) { + router.go('settings', {}); + }); + } + + void createAccountPatternAuditScore() { + router.go('createaccountpatternauditscore', { + 'auditscoreid': routeProvider.parameters['auditscoreid'] + }); + } + + void reload() { + _as_loaded = false; + _as_loaded = true; + } +} diff --git a/dart/lib/component/auditscore_view_component/auditscore_view_component.html b/dart/lib/component/auditscore_view_component/auditscore_view_component.html new file mode 100644 index 000000000..84797e432 --- /dev/null +++ b/dart/lib/component/auditscore_view_component/auditscore_view_component.html @@ -0,0 +1,121 @@ +
    + +
    +
    + {{err_message}} +
    +
    + +
    +
    +

    Loading. . .

    +
    +
    +
    +

    Edit Override Audit Score

    +

    Create Overide Audit Score

    +
    +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    Account Pattern Audit Scores + + {{auditscore.account_pattern_scores.length}} of {{auditscore.account_pattern_scores.length}} + +
    +
    NotifyActiveNotifyActive + + + Third Party Name Type
    +
    +
    +
    + +
    {{account.name}} {{account.name}} {{account.account_type}}
    Notify ActiveThird PartyNameTypeIdentifierNotes + Third Party + + + Name + + + Type + + + Identifier + + + Notes + +
    Active Technology AccountAccount Type Region Name Issues
    {{item.technology}} {{item.account}}{{item.account_type}} {{item.region}} {{item.name}} {{item.number_issues}}
    + + + + + + + + + + + + + + + + + +
    Account TypeAccount FieldValueScore + +
    + {{accountpatternscore.account_type}} + + {{accountpatternscore.account_field}} + + + {{accountpatternscore.account_pattern}} + + {{accountpatternscore.account_pattern}}{{accountpatternscore.score}}
    +
    +
    +
    +
    + + +
    +
    + + + + + diff --git a/dart/lib/component/settings/audit_score_component/audit_score_component.dart b/dart/lib/component/settings/audit_score_component/audit_score_component.dart new file mode 100644 index 000000000..84861ce91 --- /dev/null +++ b/dart/lib/component/settings/audit_score_component/audit_score_component.dart @@ -0,0 +1,46 @@ +part of security_monkey; + +@Component( + selector: 'audit-score-cmp', + templateUrl: 'packages/security_monkey/component/settings/audit_score_component/audit_score_component.html', + useShadowDom: false +) + +class AuditScoreListComponent extends PaginatedTable { + UsernameService us; + Router router; + List auditscores; + ObjectStore store; + + get isLoaded => super.is_loaded; + get isError => super.is_error; + + AuditScoreListComponent(this.router, this.store, this.us) { + list(); + } + + void list() { + super.is_loaded = false; + store.list(AuditScore, params: { + "count": ipp_as_int, + "page": currentPage, + }).then((auditscores) { + super.setPaginationData(auditscores.meta); + this.auditscores = auditscores; + super.is_loaded = true; + }); + } + + void createAuditScore() { + router.go('createauditscore', {}); + } + + void deleteAuditScoreList(auditscoreitem) { + store.delete(auditscoreitem).then( (_) { + store.list(AuditScore).then( (auditscoreitems) { + this.auditscores = auditscoreitems; + }); + }); + list(); + } +} diff --git a/dart/lib/component/settings/audit_score_component/audit_score_component.html b/dart/lib/component/settings/audit_score_component/audit_score_component.html new file mode 100644 index 000000000..3c486ce8d --- /dev/null +++ b/dart/lib/component/settings/audit_score_component/audit_score_component.html @@ -0,0 +1,52 @@ +
    +
    +
    Audit Scores{{ items_displayed() }} of {{ totalItems }}
    +
    +

    Loading . . .

    +
    +
    + + + + + + + + + + + + + + + + + + +
    TechnologyMethodEnabledScore
    {{auditscore.technology}}{{auditscore.technology}}{{auditscore.method}}{{auditscore.score}}N/A
    +
    + +
    +
    diff --git a/dart/lib/component/settings_component/settings_component.html b/dart/lib/component/settings_component/settings_component.html index d9552816b..734357130 100644 --- a/dart/lib/component/settings_component/settings_component.html +++ b/dart/lib/component/settings_component/settings_component.html @@ -141,6 +141,10 @@
    Daily Email

    + +
    + +

    diff --git a/dart/lib/model/AccountPatternAuditScore.dart b/dart/lib/model/AccountPatternAuditScore.dart new file mode 100644 index 000000000..3f4af9233 --- /dev/null +++ b/dart/lib/model/AccountPatternAuditScore.dart @@ -0,0 +1,34 @@ +library security_monkey.account_pattern_audit_score; + +class AccountPatternAuditScore { + int id; + String account_type; + String account_field; + String account_pattern; + int score; + int itemauditscores_id; + + AccountPatternAuditScore(); + + AccountPatternAuditScore.fromMap(Map data) { + id = data["id"]; + account_type = data["account_type"]; + account_field = data["account_field"]; + account_pattern = data["account_pattern"]; + score = data["score"]; + itemauditscores_id = data["itemauditscores_id"]; + } + + String toJson() { + Map objmap = { + "id": id, + "account_type": account_type, + "account_field": account_field, + "account_pattern": account_pattern, + "score": score, + "itemauditscores_id": itemauditscores_id + }; + return JSON.encode(objmap); + } + +} diff --git a/dart/lib/model/account_config.dart b/dart/lib/model/account_config.dart index b21a22e4d..8cc76d9aa 100644 --- a/dart/lib/model/account_config.dart +++ b/dart/lib/model/account_config.dart @@ -6,7 +6,7 @@ class AccountConfig { List account_types = new List(); Map identifier_labels = new Map(); Map identifier_tool_tips = new Map(); - Map> custom_fields = new Map>(); + Map> fields = new Map>(); AccountConfig(); @@ -23,10 +23,10 @@ class AccountConfig { this.identifier_tool_tips[account_type] = values['identifier_tool_tip']; var list = new List(); - var field_config = values['custom_fields']; + var field_config = values['fields']; for (var field in field_config) { list.add(new CustomFieldConfig.fromMap(field)); } - this.custom_fields[account_type] = list; + this.fields[account_type] = list; } } diff --git a/dart/lib/model/auditscore.dart b/dart/lib/model/auditscore.dart new file mode 100644 index 000000000..748f9e52b --- /dev/null +++ b/dart/lib/model/auditscore.dart @@ -0,0 +1,34 @@ +library security_monkey.auditscore; + +class AuditScore { + String method; + String technology; + int score; + int id; + bool disabled; + + List account_pattern_scores = new List(); + + AuditScore(); + + AuditScore.fromMap(Map data) { + method = data["method"]; + score = data["score"]; + technology = data["technology"]; + id = data["id"]; + disabled = data["disabled"]; + account_pattern_scores = data["account_pattern_scores"]; + } + + String toJson() { + Map objmap = { + "id": id, + "method": method, + "technology": technology, + "notes": notes, + "disabled": disabled + }; + return JSON.encode(objmap); + } + +} diff --git a/dart/lib/model/custom_field_config.dart b/dart/lib/model/custom_field_config.dart index e8575e9e1..fdcedfadc 100644 --- a/dart/lib/model/custom_field_config.dart +++ b/dart/lib/model/custom_field_config.dart @@ -6,6 +6,7 @@ class CustomFieldConfig { bool editable; String tool_tip; bool password; + List allowed_values; CustomFieldConfig.fromMap(Map data) { name = data['name']; @@ -13,5 +14,6 @@ class CustomFieldConfig { editable = data['editable']; tool_tip = data['tool_tip']; password = data['password']; + allowed_values = data['allowed_values']; } } diff --git a/dart/lib/model/hammock_config.dart b/dart/lib/model/hammock_config.dart index 34b8da789..99935ca19 100644 --- a/dart/lib/model/hammock_config.dart +++ b/dart/lib/model/hammock_config.dart @@ -15,13 +15,17 @@ import 'User.dart'; import 'Role.dart'; import 'account_config.dart'; import 'AccountBulkUpdate.dart'; +import 'auditscore.dart'; +import 'techmethods.dart'; +import 'AccountPatternAuditScore.dart'; @MirrorsUsed( targets: const[ Account, IgnoreEntry, Issue, AuditorSetting, Item, ItemComment, NetworkWhitelistEntry, Revision, RevisionComment, UserSetting, User, Role, - AccountConfig, AccountBulkUpdate], + AccountConfig, AccountBulkUpdate, AuditScore, + TechMethods, AccountPatternAuditScore], override: '*') import 'dart:mirrors'; @@ -40,6 +44,8 @@ final serializeUser = serializer("users", ["id", "email", "active", "role_id"]); final serializeRole = serializer("roles", ["id"]); final serializeIgnoreListEntry = serializer("ignorelistentries", ["id", "prefix", "notes", "technology"]); final serializeAuditorSettingEntry = serializer("auditorsettings", ["account", "technology", "issue", "count", "disabled", "id"]); +final serializeAuditScore = serializer("auditscores", ["id", "method", "score", "technology", "disabled"]); +final serializeAccountPatternAuditScore = serializer("accountpatternauditscores", ["id", "account_type", "account_field", "account_pattern", "score", "itemauditscores_id"]); createHammockConfig(Injector inj) { return new HammockConfig(inj) @@ -58,6 +64,19 @@ createHammockConfig(Injector inj) { "query": deserializeIgnoreListEntry } }, + "auditscores": { + "type": AuditScore, + "serializer": serializeAuditScore, + "deserializer": { + "query": deserializeAuditScore + } + }, + "techmethods": { + "type": TechMethods, + "deserializer": { + "query": deserializeTechMethods + } + }, "auditorsettings": { "type": AuditorSetting, "serializer": serializeAuditorSettingEntry, @@ -137,6 +156,13 @@ createHammockConfig(Injector inj) { "account_bulk_update": { "type": AccountBulkUpdate, "serializer": serializeAccountBulkUpdate + }, + "accountpatternauditscores": { + "type": AccountPatternAuditScore, + "serializer": serializeAccountPatternAuditScore, + "deserializer": { + "query": deserializeAccountPatternAuditScore + } } }) ..urlRewriter.baseUrl = '$API_HOST' @@ -171,8 +197,11 @@ deserializeUserSetting(r) => new UserSetting.fromMap(r.content); deserializeNetworkWhitelistEntry(r) => new NetworkWhitelistEntry.fromMap(r.content); deserializeIgnoreListEntry(r) => new IgnoreEntry.fromMap(r.content); deserializeAuditorSettingEntry(r) => new AuditorSetting.fromMap(r.content); +deserializeAuditScore(r) => new AuditScore.fromMap(r.content); +deserializeTechMethods(r) => new TechMethods.fromMap(r.content); deserializeUser(r) => new User.fromMap(r.content); deserializeRole(r) => new Role.fromMap(r.content); +deserializeAccountPatternAuditScore(r) => new AccountPatternAuditScore.fromMap(r.content); class JsonApiOrgFormat extends JsonDocumentFormat { resourceToJson(Resource res) { diff --git a/dart/lib/model/techmethods.dart b/dart/lib/model/techmethods.dart new file mode 100644 index 000000000..8d1efba5b --- /dev/null +++ b/dart/lib/model/techmethods.dart @@ -0,0 +1,20 @@ +library security_monkey.techmethods; + +class TechMethods { + List technologies = new List(); + Map> methods = new Map>(); + + TechMethods(); + + TechMethods.fromMap(Map data) { + if (data.containsKey('tech_methods')) { + var tech_methods = data['tech_methods']; + tech_methods.forEach((k,v) => this._addToLists(k, v)); + } + } + + void _addToLists(k, v) { + technologies.add(k); + methods[k] = v; + } +} diff --git a/dart/lib/routing/securitymonkey_router.dart b/dart/lib/routing/securitymonkey_router.dart index b55793f04..c4d73a205 100644 --- a/dart/lib/routing/securitymonkey_router.dart +++ b/dart/lib/routing/securitymonkey_router.dart @@ -92,7 +92,20 @@ void securityMonkeyRouteInitializer(Router router, RouteViewFactory views) { view: 'views/ignoreentry.html'), 'createignoreentry': ngRoute( path: '/createignoreentry', - view: 'views/create_ignoreentry.html') + view: 'views/create_ignoreentry.html'), + 'createauditscore': ngRoute( + path: '/createauditscore', + view: 'views/create_auditscore.html'), + 'viewauditscore': ngRoute( + path: '/viewauditscore/:auditscoreid', + view: 'views/auditscore.html'), + 'createaccountpatternauditscore': ngRoute( + path: '/auditscore/:auditscoreid/createaccountpatternauditscore', + view: 'views/accountpatternauditscore.html'), + 'viewaccountpatternauditscore': ngRoute( + path: '/auditscore/:auditscoreid/viewaccountpatternauditscore/:accountpatternauditscoreid', + view: 'views/accountpatternauditscore.html') + }); } diff --git a/dart/lib/security_monkey.dart b/dart/lib/security_monkey.dart index 8608bf36e..71f834022 100644 --- a/dart/lib/security_monkey.dart +++ b/dart/lib/security_monkey.dart @@ -38,6 +38,9 @@ import 'model/ItemLink.dart'; import 'model/account_config.dart'; import 'model/custom_field_config.dart'; import 'model/AccountBulkUpdate.dart'; +import 'model/auditscore.dart'; +import 'model/techmethods.dart'; +import 'model/AccountPatternAuditScore.dart'; // Routing import 'routing/securitymonkey_router.dart'; @@ -74,6 +77,9 @@ part 'component/dashboard_component/dashboard_component.dart'; part 'component/settings/user_role_component/user_role_component.dart'; part 'component/settings/network_whitelist_component/network_whitelist_component.dart'; part 'component/settings/ignore_list_component/ignore_list_component.dart'; +part 'component/auditscore_view_component/auditscore_view_component.dart'; +part 'component/account_pattern_audit_score_view_component/account_pattern_audit_score_view_component.dart'; +part 'component/settings/audit_score_component/audit_score_component.dart'; class SecurityMonkeyModule extends Module { @@ -111,6 +117,9 @@ class SecurityMonkeyModule extends Module { bind(UserRoleComponent); bind(NetworkWhitelistComponent); bind(IgnoreListComponent); + bind(AuditScoreComponent); + bind(AccountPatternAuditScoreComponent); + bind(AuditScoreListComponent); // Services bind(JustificationService); diff --git a/dart/web/views/accountpatternauditscore.html b/dart/web/views/accountpatternauditscore.html new file mode 100644 index 000000000..c348d5cc0 --- /dev/null +++ b/dart/web/views/accountpatternauditscore.html @@ -0,0 +1 @@ + diff --git a/dart/web/views/auditscore.html b/dart/web/views/auditscore.html new file mode 100644 index 000000000..b622e5076 --- /dev/null +++ b/dart/web/views/auditscore.html @@ -0,0 +1 @@ + diff --git a/dart/web/views/create_auditscore.html b/dart/web/views/create_auditscore.html new file mode 100644 index 000000000..b622e5076 --- /dev/null +++ b/dart/web/views/create_auditscore.html @@ -0,0 +1 @@ + diff --git a/docs/images/check_score_with_pattern.png b/docs/images/check_score_with_pattern.png new file mode 100644 index 0000000000000000000000000000000000000000..90af49a3f3b32b3e9513fb7a2c29a0c090249bfa GIT binary patch literal 33842 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 R`0(Qn^3qCDl@cbw{|EC6olyV) literal 0 HcmV?d00001 diff --git a/docs/images/created_check_score.png b/docs/images/created_check_score.png new file mode 100644 index 0000000000000000000000000000000000000000..c6adef7887954ad164dcf8c72d56b6bfbffddac6 GIT binary patch literal 31130 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 literal 0 HcmV?d00001 diff --git a/docs/images/disable_check.png b/docs/images/disable_check.png new file mode 100644 index 0000000000000000000000000000000000000000..6d585f0b9c0619c7c238638feac75a5a258e5412 GIT binary patch literal 20182 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 Da-vTd literal 0 HcmV?d00001 diff --git a/docs/images/override_check_score.png b/docs/images/override_check_score.png new file mode 100644 index 0000000000000000000000000000000000000000..065792e443d6e6b8a6728c7211d6270547e2d6ce GIT binary patch literal 21894 zcmce-Wmq0b6E2JehhV`ixVsbFEx5ZA+}%C6ySqCN4#C~s-Q68NHoMtSNC+^ch$^KneQTS(3sFbKtOO}qJnZjKp+7h*N>3kAOB^Qq(y*$pmTl*2*`*D z2;j@uS{eN?Hv|F_{TZJCp&fSuh zIue*j=|kawP{M3MNJG;ju8Na;Q6X!Ac^gf%%?trLff6zaUvj`@xtY2L?c7ZRk@p^s zP9&X+IEVaz5Xh(!=rDk;(3cALt72twpn(k;xr6#el6`8BQ-`;*(n4!&TwVKMm2=B7 zFK9Ui-wTrQ=M1W2dcQf*)(Ty07(z{{l)jDkUv^M(Nin_Sq#Wc}2XJRIaz2e5$uYk4 zj7USmrWoTIJ9;KG5o89t7#TP9k1+=AZnBQ&%s|e&7(t;`>>4t5@2e&uQp`z@T)`qA zlQO*a;!c6|ruAIwjoIO{elyLmbB^}8PK(FfYoObvIfD_()*Bb&r@iDVFs63auSNZ4 zVAt(|+A0*vWsSu3t=GfT-&rf_lw=Mi?^EbL@^owOYgroIW&Pp-o_U76N_!^^N9Wc) zXqWe=s!w~rARdfK_l(BT9mWyLkgj5y7VVX9@`$FA-2uh)t6d{2sNTtTtoOC;Ej(@+ z_%2=i(skJ0#LvLq+Eh0Q&A=1fu->pn*@b4HK;(Gdc4OwzbjB4y$u6BBNKs%lyog1> z)LEY;Wt>=k0XG6kZ@~ESvIPSV17lh9aUl4{>%c8QLi5otz&rXuTO(itJ8Yn{0WbZ+ zum;uuX8lENjrI*hKnJB240!`I$rm5;v+P&QJjh}JjwnO}V0wJg0pzP7{%mwIT=Bq% zC`dI>3*V4zxgrn_*ja%N-ok8{3Fe~Du0U`C+Y{;sFk!z?R8W-y95#FDKpg{KH>{m8 z(qN6dT&#gxzt9AgZsb~1x&yZZuLlHeBwi4KATSU_n&6W_g4&4qMyU`%Arc%%5#+%Z z^QT0?=V3L4!+x!d8b`+t49wP-q=QGtlLQM4AM7dXj?4G5_

    hbLkCaTgZV7Io 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 BawPx& literal 0 HcmV?d00001 diff --git a/docs/misc.rst b/docs/misc.rst index 091a8b577..32436b23a 100644 --- a/docs/misc.rst +++ b/docs/misc.rst @@ -53,3 +53,48 @@ Edit ``security_monkey/scheduler.py`` to change daily check schedule:: Edit ``security_monkey/watcher.py`` to change check interval from every 15 minutes +Overriding and Disabling Audit Checks +------------------------------------- + +Auditor checks may be disabled or the default scores overridden by navigating to the "Audit Issue Scores" tab on the Settings page. + +Audit check functions may be disabled by selecting the auditor's technology and method: + +.. image:: images/disable_check.png + +This will result in the check method not being run on the next audit full, which will remove any existing issue previously generated. + +The default score of the check method may also be overridden: + +.. image:: images/override_check_score.png + +This will replace the score of issues generated by this check method with the configured one on the next full audit. + +Once an audit score is added it becomes possible to create additional override scores based on account patterns: + +.. image:: images/created_check_score.png + +The Account Pattern Audit Scores box allows the user to add or update additional conditions for overriding the audit scores: + +.. image:: images/create_pattern_check_score.png + +The Account Field box is prepopulated with both the standard and non-password type custom fields for the given Account Type. + +After saving the pattern score, it will be associated the the Audit Override Score record: + +.. image:: images/check_score_with_pattern.png + +On the next full audit, the score for the configured check method will be replaced with an audit override score from the account pattern list if the account field matches the value. + +If no account pattern scores match the account, the override score it will default to the generic override score configured. + +Audit override scores may also be set up though the `Command line interface <../manage.py>`_ functions +add_override_score (for a single score) and add_override_scores (from a csv file) + +*Note:*:: + + Currently there is no implementation of an account pattern field hierarchy, so the first account + pattern score encountered that matches the account being audited will be used as the override for + the check method in question. As such, if account pattern scores of different account fields are + entered for a single check method there is a possibility of unpredictable results and it is recommended + that only a single field is selected for defining patterns. diff --git a/manage.py b/manage.py index aef16f879..3e3558f9b 100644 --- a/manage.py +++ b/manage.py @@ -251,6 +251,7 @@ def disable_accounts(accounts): account_names = _parse_accounts(accounts) sm_disable_accounts(account_names) + @manager.option('-a', '--accounts', dest='accounts', type=unicode, default=u'all') def enable_accounts(accounts): """ Bulk enables one or more accounts """ @@ -258,6 +259,220 @@ def enable_accounts(accounts): sm_enable_accounts(account_names) +@manager.option('-t', '--tech_name', dest='tech_name', type=str, required=True) +@manager.option('-m', '--method', dest='method', type=str, required=True) +@manager.option('-a', '--auditor', dest='auditor', type=str, required=True) +@manager.option('-s', '--score', dest='score', type=int, required=False) +@manager.option('-b', '--disabled', dest='disabled', type=bool, default=False) +@manager.option('-p', '--pattern_scores', dest='pattern_scores', type=str, required=False) +def add_override_score(tech_name, method, auditor, score, disabled, pattern_scores): + """ + Adds an audit disable/override scores + :param tech_name: technology index + :param method: the neme of the auditor method to override + :param auditor: The class name of the auditor containing the check method + :param score: The default override score to assign to the check method issue + :param disabled: Flag indicating whether the check method should be run + :param pattern_scores: A comma separated list of account field values and scores. + This can be used to override the default score based on some field in the account + that the check method is running against. The format of each value/score is: + account_type.account_field.account_value=score + """ + from security_monkey.datastore import ItemAuditScore + from security_monkey.auditor import auditor_registry + + if tech_name not in auditor_registry: + sys.stderr.write('Invalid tech name {}.\n'.format(tech_name)) + sys.exit(1) + + valid = False + auditor_classes = auditor_registry[tech_name] + for auditor_class in auditor_classes: + if auditor_class.__name__ == auditor: + valid = True + break + if not valid: + sys.stderr.write('Invalid auditor {}.\n'.format(auditor)) + sys.exit(1) + + if not getattr(auditor_class, method, None): + sys.stderr.write('Invalid method {}.\n'.format(method)) + sys.exit(1) + + if score is None and not disabled: + sys.stderr.write('Either score (-s) or disabled (-b) required') + sys.exit(1) + + if score is None: + score = 0 + + query = ItemAuditScore.query.filter(ItemAuditScore.technology == tech_name) + query = query.filter(ItemAuditScore.method == method + ' (' + auditor + ')') + entry = query.first() + + if not entry: + entry = ItemAuditScore() + entry.technology = tech_name + entry.method = method + ' (' + auditor + ')' + + entry.score = score + entry.disabled = disabled + + if pattern_scores is not None: + scores = pattern_scores.split(',') + for score in scores: + left_right = score.split('=') + if len(left_right) != 2: + sys.stderr.write('pattern_scores (-p) format account_type.account_field.account_value=score\n') + sys.exit(1) + + account_info = left_right[0].split('.') + if len(account_info) != 3: + sys.stderr.write('pattern_scores (-p) format account_type.account_field.account_value=score\n') + sys.exit(1) + + from security_monkey.account_manager import account_registry + if account_info[0] not in account_registry: + sys.stderr.write('Invalid account type {}\n'.format(account_info[0])) + sys.exit(1) + + entry.add_or_update_pattern_score(account_info[0], account_info[1], account_info[2], int(left_right[1])) + + db.session.add(entry) + db.session.commit() + db.session.close() + +@manager.option('-f', '--file_name', dest='file_name', type=str, required=True) +@manager.option('-m', '--mappings', dest='field_mappings', type=str, required=False) +def add_override_scores(file_name, field_mappings): + """ + Refreshes the audit disable/override scores from a csv file. Old scores not in + the csv will be removed. + :param file_name: path to the csv file + :param field_mappings: Comma separated list of mappings of known types to csv file + headers. Ex. 'tech=Tech Name,score=default score' + """ + from security_monkey.datastore import ItemAuditScore, AccountPatternAuditScore + from security_monkey.auditor import auditor_registry + import csv + + csvfile = open(file_name, 'r') + reader = csv.DictReader(csvfile) + errors = [] + + mappings = { + 'tech': 'tech', + 'auditor': 'auditor', + 'method': 'method', + 'disabled': 'disabled', + 'score': 'score', + 'patterns': {} + } + + if field_mappings: + mapping_defs = field_mappings.split(',') + for mapping_def in mapping_defs: + mapping = mapping_def.split('=') + if mapping[0] in mappings: + mappings[mapping[0]] = mapping[1] + else: + patterns = mappings['patterns'] + patterns[mapping[0]] = mapping[1] + + line_num = 0 + entries = [] + for row in reader: + line_num = line_num + 1 + tech_name = row[mappings['tech']] + auditor = row[mappings['auditor']] + method = row[mappings['method']] + + if not tech_name or not auditor or not method: + continue + + score = None + str_score = row[mappings['score']].decode('ascii', 'ignore').strip('') + if str_score != '': + if not str_score.isdigit(): + errors.append('Score {} line {} is not a positive int.'.format(str_score, line_num)) + continue + score = int(str_score) + + if row[mappings['disabled']].lower() == 'true': + disabled = True + else: + disabled = False + + if score is None and not disabled: + continue + + if score is None: + score = 0 + + if tech_name not in auditor_registry: + errors.append('Invalid tech name {} line {}.'.format(tech_name, line_num)) + continue + + valid = False + auditor_classes = auditor_registry[tech_name] + for auditor_class in auditor_classes: + if auditor_class.__name__ == auditor: + valid = True + break + + if not valid: + errors.append('Invalid auditor {} line {}.'.format(auditor, line_num)) + continue + + if not getattr(auditor_class, method, None): + errors.append('Invalid method {} line {}.'.format(method, line_num)) + continue + + entry = ItemAuditScore(technology=tech_name, method=method + ' (' + auditor + ')', + score=score, disabled=disabled) + + pattern_mappings = mappings['patterns'] + for mapping in pattern_mappings: + str_pattern_score = row[pattern_mappings[mapping]].decode('ascii', 'ignore').strip() + if str_pattern_score != '': + if not str_pattern_score.isdigit(): + errors.append('Pattern score {} line {} is not a positive int.'.format(str_pattern_score, line_num)) + continue + + account_info = mapping.split('.') + if len(account_info) != 3: + errors.append('Invalid pattern mapping {}.'.format(mapping)) + continue + + from security_monkey.account_manager import account_registry + if account_info[0] not in account_registry: + errors.append('Invalid account type {}'.format(account_info[0])) + continue + + db_pattern_score = AccountPatternAuditScore(account_type=account_info[0], + account_field=account_info[1], + account_pattern=account_info[2], + score=int(str_pattern_score)) + + entry.account_pattern_scores.append(db_pattern_score) + + entries.append(entry) + + if len(errors) > 0: + for error in errors: + sys.stderr.write("{}\n".format(error)) + sys.exit(1) + + AccountPatternAuditScore.query.delete() + ItemAuditScore.query.delete() + + for entry in entries: + db.session.add(entry) + + db.session.commit() + db.session.close() + + def _parse_tech_names(tech_str): if tech_str == 'all': return watcher_registry.keys() diff --git a/migrations/versions/67ea2aac5ea0_.py b/migrations/versions/67ea2aac5ea0_.py new file mode 100644 index 000000000..34e4f4e6f --- /dev/null +++ b/migrations/versions/67ea2aac5ea0_.py @@ -0,0 +1,35 @@ +"""Adding itemauditscores table + +Revision ID: 67ea2aac5ea0 +Revises: 55725cc4bf25 +Create Date: 2016-01-26 21:43:10.398048 + +""" + +# revision identifiers, used by Alembic. +revision = '67ea2aac5ea0' +down_revision = '55725cc4bf25' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('itemauditscores', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('technology', sa.String(length=128), nullable=False), + sa.Column('method', sa.String(length=256), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('disabled', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + + op.create_unique_constraint('tech_method_uc', 'itemauditscores', ['technology', 'method']) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('itemauditscores') + ### end Alembic commands ### diff --git a/migrations/versions/6d2354fb841c_.py b/migrations/versions/6d2354fb841c_.py new file mode 100644 index 000000000..0b929448a --- /dev/null +++ b/migrations/versions/6d2354fb841c_.py @@ -0,0 +1,35 @@ +"""Adding accountpatternauditscore table + +Revision ID: 6d2354fb841c +Revises: 67ea2aac5ea0 +Create Date: 2016-06-21 19:58:12.949279 + +""" + +# revision identifiers, used by Alembic. +revision = '6d2354fb841c' +down_revision = '67ea2aac5ea0' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('accountpatternauditscore', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('account_type', sa.String(length=80), nullable=False), + sa.Column('account_field', sa.String(length=128), nullable=False), + sa.Column('account_pattern', sa.String(length=128), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('itemauditscores_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['itemauditscores_id'], ['itemauditscores.id'], ), + sa.PrimaryKeyConstraint('id') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('accountpatternauditscore') + ### end Alembic commands ### diff --git a/security_monkey/__init__.py b/security_monkey/__init__.py index f98949983..75402b0dc 100644 --- a/security_monkey/__init__.py +++ b/security_monkey/__init__.py @@ -161,7 +161,23 @@ def send_email(msg): api.add_resource(AuditorSettingsPut, '/api/1/auditorsettings/') from security_monkey.views.account_config import AccountConfigGet -api.add_resource(AccountConfigGet, '/api/1/account_config/') +api.add_resource(AccountConfigGet, '/api/1/account_config/') + +from security_monkey.views.audit_scores import AuditScoresGet +from security_monkey.views.audit_scores import AuditScoreGetPutDelete +api.add_resource(AuditScoresGet, '/api/1/auditscores') +api.add_resource(AuditScoreGetPutDelete, '/api/1/auditscores/') + +from security_monkey.views.tech_methods import TechMethodsGet +api.add_resource(TechMethodsGet, '/api/1/techmethods/') + +from security_monkey.views.account_pattern_audit_score import AccountPatternAuditScoreGet +from security_monkey.views.account_pattern_audit_score import AccountPatternAuditScorePost +from security_monkey.views.account_pattern_audit_score import AccountPatternAuditScoreGetPutDelete +api.add_resource(AccountPatternAuditScoreGet, '/api/1/auditscores//accountpatternauditscores') +api.add_resource(AccountPatternAuditScorePost, '/api/1/accountpatternauditscores') +api.add_resource(AccountPatternAuditScoreGetPutDelete, '/api/1/accountpatternauditscores/') + from security_monkey.views.account_bulk_update import AccountListPut api.add_resource(AccountListPut, '/api/1/accounts_bulk/batch') diff --git a/security_monkey/account_manager.py b/security_monkey/account_manager.py index 59130ab39..9291e2ae4 100644 --- a/security_monkey/account_manager.py +++ b/security_monkey/account_manager.py @@ -50,13 +50,14 @@ class CustomFieldConfig(object): Defines additional field types for custom account types """ - def __init__(self, name, label, db_item, tool_tip, password=False): + def __init__(self, name, label, db_item, tool_tip, password=False, allowed_values=None): super(CustomFieldConfig, self).__init__() self.name = name self.label = label self.db_item = db_item self.tool_tip = tool_tip self.password = password + self.allowed_values = allowed_values class AccountManager(object): diff --git a/security_monkey/auditor.py b/security_monkey/auditor.py index 5b619d66a..e54f195db 100644 --- a/security_monkey/auditor.py +++ b/security_monkey/auditor.py @@ -26,8 +26,9 @@ from security_monkey import app, db from security_monkey.watcher import ChangeItem from security_monkey.common.jinja import get_jinja_env -from security_monkey.datastore import User, AuditorSettings, Item, ItemAudit, Technology, Account +from security_monkey.datastore import User, AuditorSettings, Item, ItemAudit, Technology, Account, ItemAuditScore, AccountPatternAuditScore from security_monkey.common.utils import send_email +from security_monkey.account_manager import get_account_by_name from sqlalchemy import and_ from collections import defaultdict @@ -70,6 +71,8 @@ def __init__(self, accounts=None, debug=False): self.team_emails = app.config.get('SECURITY_TEAM_EMAIL', []) self.emails = [] self.current_support_items = {} + self.override_scores = None + self.current_method_name = None if type(self.team_emails) in (str, unicode): self.emails.append(self.team_emails) @@ -91,6 +94,13 @@ def add_issue(self, score, issue, item, notes=None): if notes and len(notes) > 1024: notes = notes[0:1024] + if not self.override_scores: + query = ItemAuditScore.query.filter(ItemAuditScore.technology == self.index) + self.override_scores = query.all() + + # Check for override scores to apply + score = self._check_for_override_score(score, item.account) + for existing_issue in item.audit_issues: if existing_issue.issue == issue: if existing_issue.notes == notes: @@ -127,14 +137,34 @@ def audit_these_objects(self, items): app.logger.debug("Asked to audit {} Objects".format(len(items))) self.prep_for_audit() self.current_support_items = {} + query = ItemAuditScore.query.filter(ItemAuditScore.technology == self.index) + self.override_scores = query.all() methods = [getattr(self, method_name) for method_name in dir(self) if method_name.find("check_") == 0] app.logger.debug("methods: {}".format(methods)) for item in items: for method in methods: - method(item) + self.current_method_name = method.func_name + # If the check function is disabled by an entry on Settings/Audit Issue Scores + # the function will not be run and any previous issues will be cleared + if not self._is_current_method_disabled(): + method(item) self.items = items + self.override_scores = None + + def _is_current_method_disabled(self): + """ + Determines whether this method has been marked as disabled based on Audit Issue Scores + settings. + """ + for override_score in self.override_scores: + if override_score.method == self.current_method_name + ' (' + self.__class__.__name__ + ')': + return override_score.disabled + + return False + + def audit_all_objects(self): """ Read all items from the database and inspect them all. @@ -491,3 +521,42 @@ def _item_list_string(self, issue): item_ids.sort() return str(item_ids) + + def _check_for_override_score(self, score, account): + """ + Return an override to the hard coded score for an issue being added. This could either + be a general override score for this check method or one that is specific to a particular + field in the account. + + :param score: the hard coded score which will be returned back if there is + no applicable override + :param account: The account name, used to look up the value of any pattern + based overrides + :return: + """ + for override_score in self.override_scores: + # Look for an oberride entry that applies to + if override_score.method == self.current_method_name + ' (' + self.__class__.__name__ + ')': + # Check for account pattern override where a field in the account matches + # one configured in Settings/Audit Issue Scores + account = get_account_by_name(account) + for account_pattern_score in override_score.account_pattern_scores: + if getattr(account, account_pattern_score.account_field, None): + # Standard account field, such as identifier or notes + account_pattern_value = getattr(account, account_pattern_score.account_field) + else: + # If there is no attribute, this is an account custom field + account_pattern_value = account.getCustom(account_pattern_score.account_field) + + if account_pattern_value is not None: + # Override the score based on the matching pattern + if account_pattern_value == account_pattern_score.account_pattern: + app.logger.debug("Overriding score based on config {}:{} {}/{}".format(self.index, self.current_method_name + '(' + self.__class__.__name__ + ')', score, account_pattern_score.score)) + score = account_pattern_score.score + break + else: + # No specific override pattern fund. use the generic override score + app.logger.debug("Overriding score based on config {}:{} {}/{}".format(self.index, self.current_method_name + '(' + self.__class__.__name__ + ')', score, override_score.score)) + score = override_score.score + + return score diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index 9a704a8dc..ceb912f0c 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -400,6 +400,50 @@ class ExceptionLogs(db.Model): item_id = Column(Integer, ForeignKey("item.id", ondelete="CASCADE"), index=True) account_id = Column(Integer, ForeignKey("account.id", ondelete="CASCADE"), index=True) +class ItemAuditScore(db.Model): + """ + This table maps scores to audit methods, allowing for configurable scores. + """ + __tablename__ = "itemauditscores" + id = Column(Integer, primary_key=True) + technology = Column(String(128), nullable=False) + method = Column(String(256), nullable=False) + score = Column(Integer, nullable=False) + disabled = Column(Boolean, default=False) + account_pattern_scores = relationship("AccountPatternAuditScore", backref="itemauditscores", cascade="all, delete, delete-orphan") + __table_args__ = (UniqueConstraint('technology', 'method'), ) + + + def add_or_update_pattern_score(self, account_type, field, pattern, score): + db_pattern_score = self.get_account_pattern_audit_score(account_type, field, pattern) + if db_pattern_score is not None: + db_pattern_score.score = score + else: + db_pattern_score = AccountPatternAuditScore(account_type=account_type, + account_field=field, + account_pattern=pattern, + score=score) + + self.account_pattern_scores.append(db_pattern_score) + + def get_account_pattern_audit_score(self, account_type, field, pattern): + for db_pattern_score in self.account_pattern_scores: + if db_pattern_score.account_field == field and db_pattern_score.account_pattern == pattern and db_pattern_score.account_type == account_type: + return db_pattern_score + + +class AccountPatternAuditScore(db.Model): + """ + This table allows the value(s) of an account field to be mapped to scores, allowing for + configurable scores by account. + """ + __tablename__ = "accountpatternauditscore" + id = Column(Integer, primary_key=True) + account_type = Column(String(80), nullable=False) + account_field = Column(String(128), nullable=False) + account_pattern = Column(String(128), nullable=False) + score = Column(Integer, nullable=False) + itemauditscores_id = Column(Integer, ForeignKey("itemauditscores.id"), nullable=False) class Datastore(object): def __init__(self, debug=False): diff --git a/security_monkey/tests/core/test_auditor.py b/security_monkey/tests/core/test_auditor.py index 5583c9339..a12c019a7 100644 --- a/security_monkey/tests/core/test_auditor.py +++ b/security_monkey/tests/core/test_auditor.py @@ -23,11 +23,22 @@ from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.watcher import ChangeItem from security_monkey.datastore import Item, ItemAudit, Account, Technology, ItemRevision +from security_monkey.datastore import AccountType, ItemAuditScore, AccountPatternAuditScore from security_monkey.auditor import Auditor from mixer.backend.flask import mixer +class TestAuditor(Auditor): + index = 'test_index' + i_am_singular = "test auditor" + + def __init__(self, accounts=None, debug=False): + super(TestAuditor, self).__init__(accounts=accounts, debug=debug) + + def check_test(self, item): + self.add_issue(score=10, issue="Test issue", item=item) + class AuditorTestCase(SecurityMonkeyTestCase): def test_save_issues(self): mixer.init_app(self.app) @@ -90,3 +101,65 @@ def test_link_to_support_item_issues(self): self.assertTrue(new_issue.issue == "TEST") self.assertTrue(len(new_issue.sub_items) == 1) self.assertTrue(new_issue.sub_items[0] == sub_item) + + def test_audit_item(self): + auditor = TestAuditor(accounts=['test_account']) + item = ChangeItem(index='test_index', + account='test_account', name='item_name') + + self.assertEquals(len(item.audit_issues), 0) + auditor.audit_these_objects([item]) + self.assertEquals(len(item.audit_issues), 1) + self.assertEquals(item.audit_issues[0].issue, 'Test issue') + self.assertEquals(item.audit_issues[0].score, 10) + + def test_audit_item_method_disabled(self): + mixer.init_app(self.app) + mixer.blend(ItemAuditScore, technology='test_index', method='check_test (TestAuditor)', + score=0, disabled=True) + + auditor = TestAuditor(accounts=['test_account']) + item = ChangeItem(index='test_index', + account='test_account', name='item_name') + + self.assertEquals(len(item.audit_issues), 0) + auditor.audit_these_objects([item]) + self.assertEquals(len(item.audit_issues), 0) + + def test_audit_item_method_score_override(self): + mixer.init_app(self.app) + mixer.blend(ItemAuditScore, technology='test_index', method='check_test (TestAuditor)', + score=5, disabled=False) + test_account_type = mixer.blend(AccountType, name='AWS') + test_account = mixer.blend(Account, name='test_account', account_type=test_account_type) + + item = ChangeItem(index='test_index', + account=test_account.name, name='item_name') + + auditor = TestAuditor(accounts=[test_account.name]) + self.assertEquals(len(item.audit_issues), 0) + auditor.audit_these_objects([item]) + self.assertEquals(len(item.audit_issues), 1) + self.assertEquals(item.audit_issues[0].issue, 'Test issue') + self.assertEquals(item.audit_issues[0].score, 5) + + def test_audit_item_method_account_pattern_score_override(self): + mixer.init_app(self.app) + test_account_type = mixer.blend(AccountType, name='AWS') + test_account = mixer.blend(Account, name='test_account', account_type=test_account_type) + account_pattern_score = AccountPatternAuditScore(account_type=test_account_type.name, + account_field='name', account_pattern=test_account.name, + score=2) + + mixer.blend(ItemAuditScore, technology='test_index', method='check_test (TestAuditor)', + score=5, disabled=False, account_pattern_scores=[account_pattern_score]) + + item = ChangeItem(index='test_index', + account=test_account.name, name='item_name') + + auditor = TestAuditor(accounts=[test_account.name]) + self.assertEquals(len(item.audit_issues), 0) + auditor.audit_these_objects([item]) + self.assertEquals(len(item.audit_issues), 1) + self.assertEquals(item.audit_issues[0].issue, 'Test issue') + self.assertEquals(item.audit_issues[0].score, 2) diff --git a/security_monkey/tests/core/test_reporter.py b/security_monkey/tests/core/test_reporter.py index 6d3e0ea88..6e95548bd 100644 --- a/security_monkey/tests/core/test_reporter.py +++ b/security_monkey/tests/core/test_reporter.py @@ -101,8 +101,7 @@ def pre_test_setup(self): db.session.add(account_type_result) db.session.commit() - account = Account(number="012345678910", name="TEST_ACCOUNT", - s3_name="TEST_ACCOUNT", role_name="TEST_ACCOUNT", + account = Account(identifier="012345678910", name="TEST_ACCOUNT", account_type_id=account_type_result.id, notes="TEST_ACCOUNT", third_party=False, active=True) diff --git a/security_monkey/views/__init__.py b/security_monkey/views/__init__.py index 9d4a2e8d1..5816778a5 100644 --- a/security_monkey/views/__init__.py +++ b/security_monkey/views/__init__.py @@ -139,6 +139,22 @@ 'name': fields.String } +AUDIT_SCORE_FIELDS = { + 'id': fields.Integer, + 'method': fields.String, + 'technology': fields.String, + 'score': fields.String, + 'disabled': fields.Boolean +} + +ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS = { + 'id': fields.Integer, + 'account_type': fields.String, + 'account_field': fields.String, + 'account_pattern': fields.String, + 'score': fields.String +} + class AuthenticatedService(Resource): def __init__(self): self.reqparse = reqparse.RequestParser() diff --git a/security_monkey/views/account_config.py b/security_monkey/views/account_config.py index a77ba397c..e87ef59f4 100644 --- a/security_monkey/views/account_config.py +++ b/security_monkey/views/account_config.py @@ -30,7 +30,6 @@ from flask.ext.restful import reqparse - class AccountConfigGet(AuthenticatedService): decorators = [ rbac.allow(["View"], ["GET"]), @@ -40,9 +39,9 @@ def __init__(self): self.reqparse = reqparse.RequestParser() super(AccountConfigGet, self).__init__() - def get(self, account_type): + def get(self, account_fields): """ - .. http:get:: /api/1/account_config + .. http:get:: /api/1/account_config/account_fields (all or custom) Get a list of Account types @@ -72,29 +71,60 @@ def get(self, account_type): :statuscode 200: no error :statuscode 401: Authentication failure. Please login. """ - load_all_account_types() marshaled = {} account_types = AccountType.query.all() configs_marshaled = {} + for account_type in account_types: acc_manager = account_registry.get(account_type.name) if acc_manager is not None: values = {} values['identifier_label'] = acc_manager.identifier_label values['identifier_tool_tip'] = acc_manager.identifier_tool_tip - fields = [] + + if account_fields == 'all': + fields.append({ 'name': 'identifier', + 'label': '', + 'editable': True, + 'tool_tip': '', + 'password': False, + 'allowed_values': None + } + ) + + fields.append({ 'name': 'name', + 'label': '', + 'editable': True, + 'tool_tip': '', + 'password': False, + 'allowed_values': None + } + ) + + fields.append({ 'name': 'notes', + 'label': '', + 'editable': True, + 'tool_tip': '', + 'password': False, + 'allowed_values': None + } + ) + for config in acc_manager.custom_field_configs: - field_marshaled = { - 'name': config.name, - 'label': config.label, - 'editable': config.db_item, - 'tool_tip': config.tool_tip, - 'password': config.password - } - fields.append(field_marshaled) - values['custom_fields'] = fields + if account_fields == 'custom' or not config.password: + field_marshaled = { + 'name': config.name, + 'label': config.label, + 'editable': config.db_item, + 'tool_tip': config.tool_tip, + 'password': config.password, + 'allowed_values': config.allowed_values + } + fields.append(field_marshaled) + + values['fields'] = fields configs_marshaled[account_type.name] = values marshaled['custom_configs'] = configs_marshaled diff --git a/security_monkey/views/account_pattern_audit_score.py b/security_monkey/views/account_pattern_audit_score.py new file mode 100644 index 000000000..99546c106 --- /dev/null +++ b/security_monkey/views/account_pattern_audit_score.py @@ -0,0 +1,373 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.views.account_pattern_audit_score + :platform: Unix + :synopsis: Manages restful view for account pattern audit scores + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + +""" +from security_monkey.views import AuthenticatedService +from security_monkey.views import ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS +from security_monkey.datastore import AccountPatternAuditScore +from security_monkey.datastore import ItemAuditScore +from security_monkey import db, app, rbac + +from flask.ext.restful import marshal, reqparse + + +class AccountPatternAuditScoreGet(AuthenticatedService): + decorators = [ + rbac.allow(["View"], ["GET"]), + ] + + def __init__(self): + super(AccountPatternAuditScoreGet, self).__init__() + + def get(self, auditscores_id): + """ + .. http:get:: /api/1/auditscores//accountpatternauditscores + + Get a list of override scores for account pattern audit scores. + + **Example Request**: + + .. sourcecode:: http + + GET /api/1/auditscores/123/accountpatternauditscores HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + count: 1, + items: [ + { + "id": 234, + "account_pattern": "AccountPattern", + "score": 8, + itemauditscores_id: 123 + }, + ], + total: 1, + page: 1, + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 401: Authentication failure. Please login. + """ + + result = ItemAuditScore.query.filter( + ItemAuditScore.id == auditscores_id).first() + + if not result: + return {"status": "Override Audit Score with the given ID not found."}, 404 + + self.reqparse.add_argument( + 'count', type=int, default=30, location='args') + self.reqparse.add_argument( + 'page', type=int, default=1, location='args') + + args = self.reqparse.parse_args() + page = args.pop('page', None) + count = args.pop('count', None) + + result = AccountPatternAuditScore.query.paginate( + page, count, error_out=False) + + items = [] + for entry in result.items: + accountpatternauditscore_marshaled = marshal( + entry.__dict__, ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS) + items.append(accountpatternauditscore_marshaled) + + marshaled_dict = { + 'total': result.total, + 'count': len(items), + 'page': result.page, + 'items': items, + 'auth': self.auth_dict + } + + return marshaled_dict, 200 + + +class AccountPatternAuditScorePost(AuthenticatedService): + decorators = [ + rbac.allow(["Admin"], ["POST"]) + ] + + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(AccountPatternAuditScorePost, self).__init__() + + def post(self): + """ + .. http:post:: /api/1/accountpatternauditscores + + Create a new override account pattern audit score. + + **Example Request**: + + .. sourcecode:: http + + POST /api/1/accountpatternauditscores HTTP/1.1 + Host: example.com + Accept: application/json + + { + "account_pattern": "AccountPattern", + "score": 8, + "itemauditscores_id": 123 + } + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 234, + "account_pattern": "AccountPattern", + "score": 8, + "itemauditscores_id": 123 + } + + :statuscode 201: created + :statuscode 401: Authentication Error. Please Login. + """ + + self.reqparse.add_argument('account_type', required=False, type=unicode, location='json') + self.reqparse.add_argument('account_field', required=True, type=unicode, help='Must provide account field', + location='json') + self.reqparse.add_argument('account_pattern', required=True, type=unicode, help='Must provide account pattern', + location='json') + self.reqparse.add_argument('score', required=True, type=unicode, help='Override score required', + location='json') + self.reqparse.add_argument('itemauditscores_id', required=True, type=unicode, help='Audit Score ID required', + location='json') + args = self.reqparse.parse_args() + + result = ItemAuditScore.query.filter( + ItemAuditScore.id == args['itemauditscores_id']).first() + + if not result: + return {"status": "Audit Score ID not found."}, 404 + + result.add_or_update_pattern_score(args['account_type'], args['account_field'], + args['account_pattern'], int(args['score'])) + db.session.commit() + db.session.refresh(result) + + accountpatternauditscore = result.get_account_pattern_audit_score(args['account_type'], + args['account_field'], + args['account_pattern']) + + + accountpatternauditscore_marshaled = marshal(accountpatternauditscore.__dict__, + ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS) + accountpatternauditscore_marshaled['auth'] = self.auth_dict + return accountpatternauditscore_marshaled, 201 + + +class AccountPatternAuditScoreGetPutDelete(AuthenticatedService): + decorators = [ + rbac.allow(["View"], ["GET"]), + rbac.allow(["Admin"], ["PUT", "DELETE"]) + ] + + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(AccountPatternAuditScoreGetPutDelete, self).__init__() + + def get(self, id): + """ + .. http:get:: /api/1/accountpatternauditscores/ + + Get the overide account pattern audit score with given ID. + + **Example Request**: + + .. sourcecode:: http + + GET /api/1/accountpatternauditscores/234 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 234, + "account_pattern": "AccountPattern", + "score": 8, + "itemauditscores_id": 123 + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 404: item with given ID not found + :statuscode 401: Authentication failure. Please login. + """ + + app.logger.info('ID: ' + str(id)) + + result = AccountPatternAuditScore.query.filter( + AccountPatternAuditScore.id == id).first() + if not result: + return {"status": "Override Account Pattern Audit Score with the given ID not found."}, 404 + + app.logger.info('RESULT DICT: ' + str(result.__dict__)) + + accountpatternauditscore_marshaled = marshal( + result.__dict__, ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS) + accountpatternauditscore_marshaled['auth'] = self.auth_dict + + app.logger.info('RETURN: ' + str(accountpatternauditscore_marshaled)) + + return accountpatternauditscore_marshaled, 200 + + def put(self, id): + """ + .. http:put:: /api/1/accountpatternauditscores/ + + Update override account pattern audit score with the given ID. + + **Example Request**: + + .. sourcecode:: http + + PUT /api/1/accountpatternauditscores/234 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "id": 234, + "account_pattern": "AccountPattern", + "score": 5 + } + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 234, + "account_pattern": "AccountPattern" + "score": 5, + "itemauditscores_id": 123 + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 404: item with given ID not found + :statuscode 401: Authentication failure. Please login. + """ + + self.reqparse.add_argument('account_type', required=False, type=unicode, + help='Must provide account type.', location='json') + self.reqparse.add_argument('account_field', required=False, type=unicode, + help='Must provide account field.', location='json') + self.reqparse.add_argument('account_pattern', required=False, type=unicode, + help='Must provide account pattern.', location='json') + self.reqparse.add_argument( + 'score', required=False, type=unicode, help='Must provide score.', location='json') + args = self.reqparse.parse_args() + + result = AccountPatternAuditScore.query.filter( + AccountPatternAuditScore.id == id).first() + if not result: + return {"status": "Override Account Pattern Audit Score with the given ID not found."}, 404 + + result.account_type = args['account_type'] + result.account_field = args['account_field'] + result.account_pattern = args['account_pattern'] + result.score = int(args['score']) + + db.session.add(result) + db.session.commit() + db.session.refresh(result) + + accountpatternauditscore_marshaled = marshal( + result.__dict__, ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS) + accountpatternauditscore_marshaled['auth'] = self.auth_dict + + return accountpatternauditscore_marshaled, 200 + + def delete(self, id): + """ + .. http:delete:: /api/1/accountpatternauditscores/ + + Delete an override account pattern audit score + + **Example Request**: + + .. sourcecode:: http + + DELETE /api/1/accountpatternauditscores/234 HTTP/1.1 + Host: example.com + Accept: application/json + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 202 Accepted + Vary: Accept + Content-Type: application/json + + { + 'status': 'deleted' + } + + :statuscode 202: accepted + :statuscode 401: Authentication Error. Please Login. + """ + + AccountPatternAuditScore.query.filter( + AccountPatternAuditScore.id == id).delete() + db.session.commit() + + return {'status': 'deleted'}, 202 diff --git a/security_monkey/views/audit_scores.py b/security_monkey/views/audit_scores.py new file mode 100644 index 000000000..e12d57434 --- /dev/null +++ b/security_monkey/views/audit_scores.py @@ -0,0 +1,364 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.views.audit_scores + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.views import AuthenticatedService +from security_monkey.views import AUDIT_SCORE_FIELDS +from security_monkey.views import ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS +from security_monkey.datastore import ItemAuditScore +from security_monkey import db, rbac + +from flask.ext.restful import marshal, reqparse + + +class AuditScoresGet(AuthenticatedService): + decorators = [ + rbac.allow(["View"], ["GET"]), + rbac.allow(["Admin"], ["POST"]) + ] + + def __init__(self): + super(AuditScoresGet, self).__init__() + + def get(self): + """ + .. http:get:: /api/1/auditscores + + Get a list of override scores for audit items. + + **Example Request**: + + .. sourcecode:: http + + GET /api/1/auditscores HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + count: 1, + items: [ + { + "id": 123, + "method": "check_xxx", + "technology": "policy", + "score": 1 + }, + ], + total: 1, + page: 1, + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 401: Authentication failure. Please login. + """ + + self.reqparse.add_argument( + 'count', type=int, default=30, location='args') + self.reqparse.add_argument( + 'page', type=int, default=1, location='args') + + args = self.reqparse.parse_args() + page = args.pop('page', None) + count = args.pop('count', None) + + result = ItemAuditScore.query.order_by(ItemAuditScore.technology).paginate(page, count, error_out=False) + + items = [] + for entry in result.items: + auditscore_marshaled = marshal(entry.__dict__, AUDIT_SCORE_FIELDS) + items.append(auditscore_marshaled) + + marshaled_dict = { + 'total': result.total, + 'count': len(items), + 'page': result.page, + 'items': items, + 'auth': self.auth_dict + } + + return marshaled_dict, 200 + + def post(self): + """ + .. http:post:: /api/1/auditscores + + Create a new override audit score. + + **Example Request**: + + .. sourcecode:: http + + POST /api/1/auditscores HTTP/1.1 + Host: example.com + Accept: application/json + + { + "method": "check_xxx", + "technology": "policy", + "score": 1 + } + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 123, + "name": "Corp", + "notes": "Corporate Network", + "cidr": "1.2.3.4/22" + } + + :statuscode 201: created + :statuscode 401: Authentication Error. Please Login. + """ + + self.reqparse.add_argument('method', required=True, type=unicode, help='Must provide method name', + location='json') + self.reqparse.add_argument('technology', required=True, type=unicode, help='Technology required.', + location='json') + self.reqparse.add_argument('score', required=False, type=unicode, help='Override score required', + location='json') + self.reqparse.add_argument('disabled', required=True, type=unicode, help='Disabled flag', + location='json') + args = self.reqparse.parse_args() + + method = args['method'] + technology = args['technology'] + score = args['score'] + if score is None: + score = 0 + disabled = args['disabled'] + + query = ItemAuditScore.query.filter(ItemAuditScore.technology == technology) + query = query.filter(ItemAuditScore.method == method) + auditscore = query.first() + + if not auditscore: + auditscore = ItemAuditScore() + auditscore.method = method + auditscore.technology = technology + + auditscore.score = int(score) + auditscore.disabled = bool(disabled) + + db.session.add(auditscore) + db.session.commit() + db.session.refresh(auditscore) + + auditscore_marshaled = marshal(auditscore.__dict__, AUDIT_SCORE_FIELDS) + auditscore_marshaled['auth'] = self.auth_dict + return auditscore_marshaled, 201 + + +class AuditScoreGetPutDelete(AuthenticatedService): + decorators = [ + rbac.allow(["View"], ["GET"]), + rbac.allow(["Admin"], ["PUT", "DELETE"]) + ] + + def __init__(self): + self.reqparse = reqparse.RequestParser() + super(AuditScoreGetPutDelete, self).__init__() + + def get(self, id): + """ + .. http:get:: /api/1/auditscores/ + + Get the overide audit score with given ID. + + **Example Request**: + + .. sourcecode:: http + + GET /api/1/auditscores/123 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 123, + "method": "check_xxx", + "technology": "policy", + "score": "1", + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 404: item with given ID not found + :statuscode 401: Authentication failure. Please login. + """ + + result = ItemAuditScore.query.filter(ItemAuditScore.id == id).first() + + if not result: + return {"status": "Override Audit Score with the given ID not found."}, 404 + + auditscore_marshaled = marshal(result.__dict__, AUDIT_SCORE_FIELDS) + auditscore_marshaled['auth'] = self.auth_dict + + account_pattern_scores_marshaled = [] + for account_pattern_score in result.account_pattern_scores: + account_pattern_score_marshaled = marshal(account_pattern_score, ACCOUNT_PATTERN_AUDIT_SCORE_FIELDS) + account_pattern_scores_marshaled.append(account_pattern_score_marshaled) + auditscore_marshaled['account_pattern_scores'] = account_pattern_scores_marshaled + + return auditscore_marshaled, 200 + + def put(self, id): + """ + .. http:get:: /api/1/auditscores/ + + Update override audit score with the given ID. + + **Example Request**: + + .. sourcecode:: http + + PUT /api/1/auditscores/123 HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + { + "id": 123, + "method": "check_xxx", + "technology": "policy", + "Score": "1" + } + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 123, + "score": "1", + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 404: item with given ID not found + :statuscode 401: Authentication failure. Please login. + """ + + self.reqparse.add_argument('method', required=True, type=unicode, help='Must provide method name', + location='json') + self.reqparse.add_argument('technology', required=True, type=unicode, help='Technology required.', + location='json') + self.reqparse.add_argument('score', required=False, type=unicode, help='Must provide score.', + location='json') + self.reqparse.add_argument('disabled', required=True, type=unicode, help='Must disabled flag.', + location='json') + + args = self.reqparse.parse_args() + + score = args['score'] + if score is None: + score = 0 + + result = ItemAuditScore.query.filter(ItemAuditScore.id == id).first() + + if not result: + return {"status": "Override audit score with the given ID not found."}, 404 + + result.method = args['method'] + result.technology = args['technology'] + result.disabled = args['disabled'] + result.score = int(score) + + db.session.add(result) + db.session.commit() + db.session.refresh(result) + + auditscore_marshaled = marshal(result.__dict__, AUDIT_SCORE_FIELDS) + auditscore_marshaled['auth'] = self.auth_dict + + return auditscore_marshaled, 200 + + def delete(self, id): + """ + .. http:delete:: /api/1/auditscores/123 + + Delete an override audit score + + **Example Request**: + + .. sourcecode:: http + + DELETE /api/1/auditscores/123 HTTP/1.1 + Host: example.com + Accept: application/json + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 202 Accepted + Vary: Accept + Content-Type: application/json + + { + 'status': 'deleted' + } + + :statuscode 202: accepted + :statuscode 401: Authentication Error. Please Login. + """ + + result = ItemAuditScore.query.filter(ItemAuditScore.id == id).first() + + db.session.delete(result) + db.session.commit() + + return {'status': 'deleted'}, 202 diff --git a/security_monkey/views/tech_methods.py b/security_monkey/views/tech_methods.py new file mode 100644 index 000000000..bbe860c0a --- /dev/null +++ b/security_monkey/views/tech_methods.py @@ -0,0 +1,89 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.views.tech_methods + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.views import AuthenticatedService +from security_monkey.auditor import auditor_registry +from security_monkey import rbac + + +class TechMethodsGet(AuthenticatedService): + decorators = [ + rbac.allow(["View"], ["GET"]), + ] + + def __init__(self): + super(TechMethodsGet, self).__init__() + + def get(self, tech_ids): + """ + .. http:get:: /api/1/techmethods + + Get a list of technologies and associated auditor check methods + + **Example Request**: + + .. sourcecode:: http + + GET /api/1/techmethods HTTP/1.1 + Host: example.com + Accept: application/json, text/javascript + + **Example Response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "technologies": [ "subnet" ] + "tech_methods": { "subnet": [ "check_internet_access" ] } + auth: { + authenticated: true, + user: "user@example.com" + } + } + + :statuscode 200: no error + :statuscode 401: Authentication failure. Please login. + """ + tech_methods = {} + + for key in auditor_registry.keys(): + methods = [] + + for auditor_class in auditor_registry[key]: + auditor = auditor_class('') + for method_name in dir(auditor): + method_name = method_name + ' (' + auditor.__class__.__name__ + ')' + if (method_name.find("check_")) == 0: + methods.append(method_name) + + tech_methods[key] = methods + + marshaled_dict = { + 'tech_methods': tech_methods, + 'auth': self.auth_dict + } + + return marshaled_dict, 200 diff --git a/setup.py b/setup.py index a04bb8d3e..53a84c57e 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,8 @@ 'mixer==5.5.7', 'mock==1.0.1', 'moto==0.4.30', - 'freezegun>=0.3.7' + 'freezegun>=0.3.7', + 'mixer==5.5.7' ] } ) From 69bb9af68e22bc440a348e038cafd36e1f566ae2 Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Mon, 20 Feb 2017 13:53:14 -0800 Subject: [PATCH 29/90] Upped CloudAux version - Addresses issue #550. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 53a84c57e..54f19e762 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ 'dpath==1.3.2', 'pyyaml==3.11', 'jira==0.32', - 'cloudaux>=1.0.7', + 'cloudaux>=1.1.1', 'joblib>=0.9.4', 'pyjwt>=1.01', ], From c6cb5aea01de79e41e9688a850defa7b4f6ef52b Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 21 Feb 2017 15:03:03 -0800 Subject: [PATCH 30/90] =?UTF-8?q?Add=20Watcher=20configuration=20(#559)=20?= =?UTF-8?q?=F0=9F=91=80=E2=8F=B2=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [secmonkey] Add Watcher configuration Type: generic-large-feature Why is this change necessary? We've run into instances where we have wanted to reconfigure watcher run intervals to improve performance or even remove watchers that were causing problems. Currently this requires logging into the server and physically changing the code This change addresses the need by: Provides a Watcher Config page in Settings and an api where a user can configure the run interval and the active flag Potential Side Effects: No known side effects * Updating DB migration path. * Changing default interval from 1 day to 1 hour. --- .../watcher_config_component.dart | 39 ++++++ .../watcher_config_component.html | 68 ++++++++++ .../settings_component.html | 6 + dart/lib/model/hammock_config.dart | 12 +- dart/lib/model/watcher_config.dart | 20 +++ dart/lib/security_monkey.dart | 3 + manage.py | 29 +++++ migrations/versions/c93f246859f7_.py | 33 +++++ security_monkey/__init__.py | 4 + security_monkey/datastore.py | 12 ++ security_monkey/monitors.py | 20 +-- .../tests/views/test_view_watcher_config.py | 107 ++++++++++++++++ security_monkey/views/__init__.py | 10 +- security_monkey/views/watcher_config.py | 119 ++++++++++++++++++ security_monkey/watcher.py | 19 ++- 15 files changed, 489 insertions(+), 12 deletions(-) create mode 100644 dart/lib/component/settings/watcher_config_component/watcher_config_component.dart create mode 100644 dart/lib/component/settings/watcher_config_component/watcher_config_component.html create mode 100644 dart/lib/model/watcher_config.dart create mode 100644 migrations/versions/c93f246859f7_.py create mode 100644 security_monkey/tests/views/test_view_watcher_config.py create mode 100644 security_monkey/views/watcher_config.py diff --git a/dart/lib/component/settings/watcher_config_component/watcher_config_component.dart b/dart/lib/component/settings/watcher_config_component/watcher_config_component.dart new file mode 100644 index 000000000..ae64b2b09 --- /dev/null +++ b/dart/lib/component/settings/watcher_config_component/watcher_config_component.dart @@ -0,0 +1,39 @@ +part of security_monkey; + +@Component( + selector: 'watcher-config-cmp', + templateUrl: 'packages/security_monkey/component/settings/watcher_config_component/watcher_config_component.html', + useShadowDom: false +) + +class WatcherConfigComponent extends PaginatedTable { + UsernameService us; + ObjectStore store; + List configs; + + WatcherConfigComponent(this.store) { + this.store = store; + this.list(); + } + + void list() { + configs = new List(); + store.list(WatcherConfig, params: { + "count": ipp_as_int, + "page": currentPage + }).then((config_response) { + super.setPaginationData(config_response.meta); + this.configs = config_response; + super.is_loaded = true; + }); + } + + void updateSetting(WatcherConfig config) { + this.store.update(config).then((_) { + list(); + }); + } + + get isLoaded => super.is_loaded; + get isError => super.is_error; +} diff --git a/dart/lib/component/settings/watcher_config_component/watcher_config_component.html b/dart/lib/component/settings/watcher_config_component/watcher_config_component.html new file mode 100644 index 000000000..aed564aae --- /dev/null +++ b/dart/lib/component/settings/watcher_config_component/watcher_config_component.html @@ -0,0 +1,68 @@ +

    +
    +
    +
    Users {{ items_displayed() }} of {{ totalItems }}
    +
    +

    Loading . . .

    +
    + {{err_message}} +
    +
    +
    + + + + + + + + + + + + + +
    TechnologyActiveInterval
    {{config.index}} + + +     Remove Items? + + + + + + +
    +
    + +
    +
    +
    diff --git a/dart/lib/component/settings_component/settings_component.html b/dart/lib/component/settings_component/settings_component.html index 734357130..6c933de1b 100644 --- a/dart/lib/component/settings_component/settings_component.html +++ b/dart/lib/component/settings_component/settings_component.html @@ -149,4 +149,10 @@
    Daily Email

    + + +
    + +
    + diff --git a/dart/lib/model/hammock_config.dart b/dart/lib/model/hammock_config.dart index 99935ca19..6e8319e11 100644 --- a/dart/lib/model/hammock_config.dart +++ b/dart/lib/model/hammock_config.dart @@ -18,6 +18,7 @@ import 'AccountBulkUpdate.dart'; import 'auditscore.dart'; import 'techmethods.dart'; import 'AccountPatternAuditScore.dart'; +import 'watcher_config.dart'; @MirrorsUsed( targets: const[ @@ -25,7 +26,7 @@ import 'AccountPatternAuditScore.dart'; Item, ItemComment, NetworkWhitelistEntry, Revision, RevisionComment, UserSetting, User, Role, AccountConfig, AccountBulkUpdate, AuditScore, - TechMethods, AccountPatternAuditScore], + TechMethods, AccountPatternAuditScore, WatcherConfig], override: '*') import 'dart:mirrors'; @@ -46,6 +47,7 @@ final serializeIgnoreListEntry = serializer("ignorelistentries", ["id", "prefix" final serializeAuditorSettingEntry = serializer("auditorsettings", ["account", "technology", "issue", "count", "disabled", "id"]); final serializeAuditScore = serializer("auditscores", ["id", "method", "score", "technology", "disabled"]); final serializeAccountPatternAuditScore = serializer("accountpatternauditscores", ["id", "account_type", "account_field", "account_pattern", "score", "itemauditscores_id"]); +final serializeWatcherConfig = serializer("watcher_config", ["index", "interval", "active", "remove_items"]); createHammockConfig(Injector inj) { return new HammockConfig(inj) @@ -163,6 +165,13 @@ createHammockConfig(Injector inj) { "deserializer": { "query": deserializeAccountPatternAuditScore } + }, + "watcher_config": { + "type": WatcherConfig, + "serializer": serializeWatcherConfig, + "deserializer": { + "query": deserializeWatcherConfig + } } }) ..urlRewriter.baseUrl = '$API_HOST' @@ -202,6 +211,7 @@ deserializeTechMethods(r) => new TechMethods.fromMap(r.content); deserializeUser(r) => new User.fromMap(r.content); deserializeRole(r) => new Role.fromMap(r.content); deserializeAccountPatternAuditScore(r) => new AccountPatternAuditScore.fromMap(r.content); +deserializeWatcherConfig(r) => new WatcherConfig.fromMap(r.content); class JsonApiOrgFormat extends JsonDocumentFormat { resourceToJson(Resource res) { diff --git a/dart/lib/model/watcher_config.dart b/dart/lib/model/watcher_config.dart new file mode 100644 index 000000000..7be9376ab --- /dev/null +++ b/dart/lib/model/watcher_config.dart @@ -0,0 +1,20 @@ +library security_monkey.watcher_config; + +class WatcherConfig { + int id; + String index; + String interval; + bool active; + bool remove_items; + bool changed; + + WatcherConfig(); + + WatcherConfig.fromMap(Map data) { + id = data["id"]; + index = data["index"]; + interval = data["interval"]; + active = data["active"]; + remove_items = false; + } +} diff --git a/dart/lib/security_monkey.dart b/dart/lib/security_monkey.dart index 71f834022..04b9b551e 100644 --- a/dart/lib/security_monkey.dart +++ b/dart/lib/security_monkey.dart @@ -41,6 +41,7 @@ import 'model/AccountBulkUpdate.dart'; import 'model/auditscore.dart'; import 'model/techmethods.dart'; import 'model/AccountPatternAuditScore.dart'; +import 'model/watcher_config.dart'; // Routing import 'routing/securitymonkey_router.dart'; @@ -80,6 +81,7 @@ part 'component/settings/ignore_list_component/ignore_list_component.dart'; part 'component/auditscore_view_component/auditscore_view_component.dart'; part 'component/account_pattern_audit_score_view_component/account_pattern_audit_score_view_component.dart'; part 'component/settings/audit_score_component/audit_score_component.dart'; +part 'component/settings/watcher_config_component/watcher_config_component.dart'; class SecurityMonkeyModule extends Module { @@ -120,6 +122,7 @@ class SecurityMonkeyModule extends Module { bind(AuditScoreComponent); bind(AccountPatternAuditScoreComponent); bind(AuditScoreListComponent); + bind(WatcherConfigComponent); // Services bind(JustificationService); diff --git a/manage.py b/manage.py index 3e3558f9b..aa57d09d7 100644 --- a/manage.py +++ b/manage.py @@ -495,6 +495,35 @@ def delete_account(name): delete_account_by_name(name) +@manager.option('-t', '--tech_name', dest='tech_name', type=str, required=True) +@manager.option('-d', '--disabled', dest='disabled', type=bool, default=False) +# We are locking down the allowed intervals here to 15 minutes, 1 hour, 12 hours, 24 +# hours or one week because too many different intervals could result in too many +# scheduler threads, impacting performance. +@manager.option('-i', '--interval', dest='interval', type=int, default=60, choices= [15, 60, 720, 1440, 10080]) +def add_watcher_config(tech_name, disabled, interval): + from security_monkey.datastore import WatcherConfig + from security_monkey.watcher import watcher_registry + + if tech_name not in watcher_registry: + sys.stderr.write('Invalid tech name {}.\n'.format(tech_name)) + sys.exit(1) + + query = WatcherConfig.query.filter(WatcherConfig.index == tech_name) + entry = query.first() + + if not entry: + entry = WatcherConfig() + + entry.index = tech_name + entry.interval = interval + entry.active = not disabled + + db.session.add(entry) + db.session.commit() + db.session.close() + + class APIServer(Command): def __init__(self, host='127.0.0.1', port=app.config.get('API_PORT'), workers=6): self.address = "{}:{}".format(host, port) diff --git a/migrations/versions/c93f246859f7_.py b/migrations/versions/c93f246859f7_.py new file mode 100644 index 000000000..0e14dc510 --- /dev/null +++ b/migrations/versions/c93f246859f7_.py @@ -0,0 +1,33 @@ +"""Add watcher config table + +Revision ID: c93f246859f7 +Revises: 6d2354fb841c +Create Date: 2016-09-29 16:13:29.946116 + +""" + +# revision identifiers, used by Alembic. +revision = 'c93f246859f7' +down_revision = '6d2354fb841c' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('watcher_config', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('index', sa.String(length=80), nullable=True), + sa.Column('interval', sa.Integer(), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('index') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('watcher_config') + ### end Alembic commands ### diff --git a/security_monkey/__init__.py b/security_monkey/__init__.py index 75402b0dc..27dbe01fc 100644 --- a/security_monkey/__init__.py +++ b/security_monkey/__init__.py @@ -182,6 +182,10 @@ def send_email(msg): from security_monkey.views.account_bulk_update import AccountListPut api.add_resource(AccountListPut, '/api/1/accounts_bulk/batch') +from security_monkey.views.watcher_config import WatcherConfigGetList +from security_monkey.views.watcher_config import WatcherConfigPut +api.add_resource(WatcherConfigGetList, '/api/1/watcher_config') +api.add_resource(WatcherConfigPut, '/api/1/watcher_config/') ## Jira Sync import os diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index ceb912f0c..7a5be574f 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -445,6 +445,18 @@ class AccountPatternAuditScore(db.Model): score = Column(Integer, nullable=False) itemauditscores_id = Column(Integer, ForeignKey("itemauditscores.id"), nullable=False) + +class WatcherConfig(db.Model): + """ + Defines watcher configurations for interval and active + """ + __tablename__ = "watcher_config" + id = Column(Integer, primary_key=True) + index = Column(db.String(80), unique=True) + interval = Column(Integer, nullable=False) + active = Column(Boolean(), nullable=False) + + class Datastore(object): def __init__(self, debug=False): pass diff --git a/security_monkey/monitors.py b/security_monkey/monitors.py index 5f049c922..cad9b0591 100644 --- a/security_monkey/monitors.py +++ b/security_monkey/monitors.py @@ -38,7 +38,8 @@ def get_monitors(account_name, monitor_names, debug=False): watcher_class = watcher_registry[monitor_name] if account_manager.is_compatible_with_account_type(watcher_class.account_type): monitor = Monitor(watcher_class, account, debug) - requested_mons.append(monitor) + if monitor.watcher.is_active(): + requested_mons.append(monitor) return requested_mons @@ -70,8 +71,9 @@ def all_monitors(account_name, debug=False): for watcher_class in watcher_registry.itervalues(): if account_manager.is_compatible_with_account_type(watcher_class.account_type): - monitor = Monitor(watcher_class, account, debug) - monitor_dict[monitor.watcher.index] = monitor + monitor = Monitor(watcher_class, account, debug) + if monitor.watcher.is_active(): + monitor_dict[monitor.watcher.index] = monitor for mon in monitor_dict.values(): if len(mon.auditors) > 0: @@ -96,11 +98,15 @@ def _set_dependency_hierarchies(monitor_dict, monitor, path, level): auditor_flow = auditor_flow + '->' + index raise Exception('Detected circular dependency in support auditor', auditor_flow) - support_mon = monitor_dict[support_index] - if support_mon.audit_tier < level: - support_mon.audit_tier = level + support_mon = monitor_dict.get(support_index) + if support_mon == None: + app.logger.warn("Monitor {0} depends on monitor {1}, but {1} is unavailable" + .format(monitor.watcher.index, support_index)) + else: + if support_mon.audit_tier < level: + support_mon.audit_tier = level - _set_dependency_hierarchies(monitor_dict, support_mon, current_path, level + 1) + _set_dependency_hierarchies(monitor_dict, support_mon, current_path, level + 1) def _find_dependent_monitors(monitors, monitor_names): """ diff --git a/security_monkey/tests/views/test_view_watcher_config.py b/security_monkey/tests/views/test_view_watcher_config.py new file mode 100644 index 000000000..a5a2f5950 --- /dev/null +++ b/security_monkey/tests/views/test_view_watcher_config.py @@ -0,0 +1,107 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.views.test_watcher_config + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.tests.views import SecurityMonkeyApiTestCase +from security_monkey.watcher import watcher_registry +from security_monkey.datastore import WatcherConfig +from security_monkey import db +from mock import patch + +import json + + +# Mock watcher_registry because this is used when building watcher config response +class MockWatcher(object): + def __init__(self, accounts=None, debug=False): + self.accounts = accounts + +watcher_configs = [ + {'type': 'MockWatcher1', 'index': 'index1', 'interval': 1440}, + {'type': 'MockWatcher2', 'index': 'index2', 'interval': 1440}, + {'type': 'MockWatcher3', 'index': 'index3', 'interval': 1440} +] + +test_watcher_registry = {} +for config in watcher_configs: + watcher = type(config['type'], (MockWatcher,), {'index': config['index'], 'interval': config['interval']}) + test_watcher_registry[config['index']] = watcher + + +@patch.dict(watcher_registry, test_watcher_registry, clear=True) +class WatcherConfigApiTestCase(SecurityMonkeyApiTestCase): + def test_get_empty_watcher_configs(self): + r = self.test_app.get('/api/1/watcher_config', headers=self.headers) + r_json = json.loads(r.data) + assert r.status_code == 200 + assert len(r_json['items']) == len(watcher_configs) + assert r_json['items'][0]['id'] == 0 + + def test_get_watcher_configs(self): + watcher_config = WatcherConfig(index='index1', interval=1440, active=True) + db.session.add(watcher_config) + db.session.commit() + db.session.refresh(watcher_config) + + r = self.test_app.get('/api/1/watcher_config', headers=self.headers) + r_json = json.loads(r.data) + assert r.status_code == 200 + assert len(r_json['items']) == len(watcher_configs) + assert r_json['items'][0]['id'] != 0 + + def test_put_watcher_config(self): + watcher_config = WatcherConfig(index='index1', interval=1440, active=True) + db.session.add(watcher_config) + db.session.commit() + db.session.refresh(watcher_config) + + d = dict(index='account', interval=1440, active=True) + r = self.test_app.put( + "/api/1/watcher_config/{}".format(watcher_config.id), + headers=self.headers, + data=json.dumps(d) + ) + assert r.status_code == 200 + + # Update the response code when we handle this appropriately (404) + def test_put_watcher_config_wrong_id(self): + watcher_config = WatcherConfig(index='index1', interval=1440, active=True) + db.session.add(watcher_config) + db.session.commit() + db.session.refresh(watcher_config) + + d = dict(index='account', interval=1440, active=True) + r = self.test_app.put("/api/1/watcher_config/{}".format('100'), headers=self.headers, data=json.dumps(d)) + assert r.status_code == 500 + + def test_put_watcher_config_wrong_data(self): + watcher_config = WatcherConfig(index='index1', interval=1440, active=True) + db.session.add(watcher_config) + db.session.commit() + db.session.refresh(watcher_config) + + d = dict(index='account', foobar=1440, active=True) + r = self.test_app.put( + "/api/1/watcher_config/{}".format(watcher_config.id), + headers=self.headers, + data=json.dumps(d) + ) + assert r.status_code == 400 diff --git a/security_monkey/views/__init__.py b/security_monkey/views/__init__.py index 5816778a5..a4f8528e4 100644 --- a/security_monkey/views/__init__.py +++ b/security_monkey/views/__init__.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from security_monkey import app, db +from security_monkey import app from flask_wtf.csrf import generate_csrf from security_monkey.auth.models import RBACRole from security_monkey.decorators import crossdomain @@ -155,6 +155,14 @@ 'score': fields.String } +WATCHER_CONFIG_FIELDS = { + 'id': fields.Integer, + 'index': fields.String, + 'interval': fields.String, + 'active': fields.Boolean +} + + class AuthenticatedService(Resource): def __init__(self): self.reqparse = reqparse.RequestParser() diff --git a/security_monkey/views/watcher_config.py b/security_monkey/views/watcher_config.py new file mode 100644 index 000000000..39989e8fb --- /dev/null +++ b/security_monkey/views/watcher_config.py @@ -0,0 +1,119 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.views.watcher_config + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.views import AuthenticatedService +from security_monkey.datastore import WatcherConfig, Item, Technology +from security_monkey.watcher import watcher_registry +from security_monkey.views import WATCHER_CONFIG_FIELDS +from security_monkey import rbac, db + +from flask_restful import marshal, reqparse + + +class WatcherConfigGetList(AuthenticatedService): + decorators = [ + rbac.allow(["Admin"], ["GET"]), + ] + + def __init__(self): + super(WatcherConfigGetList, self).__init__() + self.reqparse = reqparse.RequestParser() + + def get(self): + self.reqparse.add_argument('count', type=int, default=30, location='args') + self.reqparse.add_argument('page', type=int, default=1, location='args') + + args = self.reqparse.parse_args() + page = args.pop('page', None) + count = args.pop('count', None) + + configs = [] + all_keys = watcher_registry.keys() + all_keys.sort() + + start_index = (page - 1) * count + keys = all_keys[start_index:start_index + count] + + for key in keys: + watcher_class = watcher_registry[key] + config = WatcherConfig.query.filter(WatcherConfig.index == watcher_class.index).first() + if config is None: + config = WatcherConfig(id=0, + index=watcher_class.index, + interval=watcher_class.interval, + active=True) + + configs.append(config) + + return_dict = { + "page": page, + "total": len(all_keys), + "count": len(configs), + "items": [marshal(item.__dict__, WATCHER_CONFIG_FIELDS) for item in configs], + "auth": self.auth_dict + } + + return return_dict, 200 + + +class WatcherConfigPut(AuthenticatedService): + decorators = [ + rbac.allow(["Admin"], ["Put"]), + ] + + def __init__(self): + super(WatcherConfigPut, self).__init__() + + def put(self, id): + self.reqparse.add_argument('index', required=True, type=unicode, location='json') + self.reqparse.add_argument('interval', required=True, type=int, location='json') + self.reqparse.add_argument('active', required=True, type=bool, location='json') + self.reqparse.add_argument('remove_items', required=False, type=bool, location='json') + args = self.reqparse.parse_args() + index = args['index'] + interval = args['interval'] + active = args['active'] + remove_items = args.get('remove_items', False) + + if id > 0: + config = WatcherConfig.query.filter(WatcherConfig.id == id).first() + config.interval = interval + config.active = active + else: + config = WatcherConfig(index=index, interval=interval, active=active) + + db.session.add(config) + db.session.commit() + + if active is False and remove_items is True: + results = Item.query.join((Technology, Item.tech_id == Technology.id)) \ + .filter(Technology.name == index).all() + + for item in results: + db.session.delete(item) + db.session.commit() + + marshaled_dict = { + 'auth': self.auth_dict + } + + return marshaled_dict, 200 diff --git a/security_monkey/watcher.py b/security_monkey/watcher.py index 7b06f511d..56734095c 100644 --- a/security_monkey/watcher.py +++ b/security_monkey/watcher.py @@ -13,7 +13,8 @@ from common.PolicyDiff import PolicyDiff from common.utils import sub_dict from security_monkey import app -from security_monkey.datastore import Account, IgnoreListEntry, Technology, store_exception +from security_monkey.datastore import Account, IgnoreListEntry +from security_monkey.datastore import Technology, WatcherConfig, store_exception from security_monkey.common.jinja import get_jinja_env from boto.exception import BotoServerError @@ -41,7 +42,8 @@ class Watcher(object): i_am_plural = 'Abstracts' rate_limit_delay = 0 ignore_list = [] - interval = 15 #in minutes + interval = 60 #in minutes + active = True account_type = 'AWS' __metaclass__ = WatcherType @@ -60,7 +62,6 @@ def __init__(self, accounts=None, debug=False): self.ephemeral_items = [] # TODO: grab these from DB, keyed on account self.rate_limit_delay = 0 - self.interval = 15 self.honor_ephemerals = False self.ephemeral_paths = [] @@ -396,8 +397,20 @@ def singular_name(self): def get_interval(self): """ Returns interval time (in minutes) """ + config = WatcherConfig.query.filter(WatcherConfig.index == self.index).first() + if config: + return config.interval + return self.interval + def is_active(self): + """ Returns active """ + config = WatcherConfig.query.filter(WatcherConfig.index == self.index).first() + if config: + return config.active + + return self.active + def ephemerals_skipped(self): """ Returns whether ephemerals locations are ignored """ return self.honor_ephemerals From 86c4a7e4d0ecfa471cd387a79e2bb1b2978e684d Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 21 Feb 2017 15:53:54 -0800 Subject: [PATCH 31/90] =?UTF-8?q?Re-adding=20reporter=20timing=20informati?= =?UTF-8?q?on=20to=20the=20logs.=20(#562)=20=E2=8F=B2=EF=B8=8F=F0=9F=93=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- security_monkey/reporter.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/security_monkey/reporter.py b/security_monkey/reporter.py index 618e505ff..4dcc71bcb 100644 --- a/security_monkey/reporter.py +++ b/security_monkey/reporter.py @@ -28,6 +28,8 @@ from security_monkey import app, db from security_monkey.datastore import store_exception +import time + class Reporter(object): """Sets up all watchers and auditors and the alerters""" @@ -39,6 +41,7 @@ def __init__(self, account=None, debug=False): def run(self, account, interval=None): """Starts the process of watchers -> auditors -> alerters """ app.logger.info("Starting work on account {}.".format(account)) + time1 = time.time() mons = self.get_monitors_to_run(account, interval) watchers_with_changes = set() @@ -67,6 +70,9 @@ def run(self, account, interval=None): store_exception('reporter-run-auditor', (auditor.index, account), e) continue + time2 = time.time() + app.logger.info('Run Account %s took %0.1f s' % (account, (time2-time1))) + self.account_alerter.report() db.session.close() @@ -91,7 +97,7 @@ def get_intervals(self, account): for monitor in self.all_monitors: if monitor.watcher: interval = monitor.watcher.get_interval() - if not interval in buckets: + if interval not in buckets: buckets.append(interval) return buckets From 83e572a83a9c003efe79399631873b232c7086f6 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 22 Feb 2017 14:11:44 -0500 Subject: [PATCH 32/90] =?UTF-8?q?Add=20justified=20issues=20report=20(#557?= =?UTF-8?q?)=20=E2=9A=96=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [secmonkey] Add justified issues report * Adding extra urlparam to the justified link. Adding an extra `/-` in the justified url to account for AccountType. * Adding :accounttypes to justified route. --- .../justified_table_component.dart | 71 +++++++++++++++++++ .../justified_table_component.html | 70 ++++++++++++++++++ .../search_bar_component.html | 1 + .../search_page_component.html | 1 + dart/lib/routing/securitymonkey_router.dart | 10 +++ dart/lib/security_monkey.dart | 2 + dart/web/ui.html | 2 + 7 files changed, 157 insertions(+) create mode 100644 dart/lib/component/justified_table_component/justified_table_component.dart create mode 100644 dart/lib/component/justified_table_component/justified_table_component.html diff --git a/dart/lib/component/justified_table_component/justified_table_component.dart b/dart/lib/component/justified_table_component/justified_table_component.dart new file mode 100644 index 000000000..cf685f913 --- /dev/null +++ b/dart/lib/component/justified_table_component/justified_table_component.dart @@ -0,0 +1,71 @@ +part of security_monkey; + +@Component( + selector: 'justified-table', + templateUrl: 'packages/security_monkey/component/justified_table_component/justified_table_component.html', + useShadowDom: false +) +class JustifiedTableComponent extends PaginatedTable implements ScopeAware { + List issues = []; + RouteProvider routeProvider; + Router router; + ObjectStore store; + bool constructor_complete = false; + Scope _scope; + + Map filter_params = { + 'regions': '', + 'technologies': '', + 'accounts': '', + 'names': '', + 'arns': '', + 'active': null, + 'searchconfig': null, + 'page': '1', + 'count': '25', + 'enabledonly': 'true', + 'justified': true + }; + + JustifiedTableComponent(this.routeProvider, this.router, this.store) { + filter_params = map_from_url(filter_params, this.routeProvider); + + /// The AngularUI Pagination tries to correct the currentPage value + /// to page 1 when the API server hasn't yet responded with results. + /// To fix, don't set the currentPage variable until we have received + /// a response from the API server containing totalItems. + store.list(Issue, params: filter_params).then((issues) { + super.setPaginationData(issues.meta); + this.issues = issues; + super.is_loaded = true; + super.items_per_page = filter_params['count']; + super.currentPage = int.parse(filter_params['page']); + constructor_complete = true; + }); + } + + void list() { + if (!constructor_complete) { + return; + } + if (filter_params['page'] != super.currentPage.toString() || filter_params['count'] != super.items_per_page) { + filter_params['page'] = super.currentPage.toString(); + filter_params['count'] = super.items_per_page; + this.pushFilterRoutes(); + } else { + print("Loading Filtered Data."); + store.list(Issue, params: filter_params).then((issues) { + super.setPaginationData(justifiled_issues.meta); + this.issues = issues; + super.is_loaded = true; + }); + } + } + + void pushFilterRoutes() { + filter_params = map_to_url(filter_params); + print("Pushing justified_table_component filter routes: $filter_params"); + router.go('justified', filter_params); + } + +} diff --git a/dart/lib/component/justified_table_component/justified_table_component.html b/dart/lib/component/justified_table_component/justified_table_component.html new file mode 100644 index 000000000..77aaf493e --- /dev/null +++ b/dart/lib/component/justified_table_component/justified_table_component.html @@ -0,0 +1,70 @@ +
    +
    Justified Issues Report + + +
    +
    +
    +
    +

    Loading . . .

    +
    +
    +
    + {{err_message}} +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Item NameTechnologyAccountRegionIssueNotesScoreJustification
    {{issue.item.name}}{{issue.item.technology}}{{issue.item.account}}{{issue.item.region}}{{issue.issue}}{{issue.notes}}{{issue.score}}{{issue.justified_user}}
    {{issue.justification}}
    +
    + +
    diff --git a/dart/lib/component/search_bar_component/search_bar_component.html b/dart/lib/component/search_bar_component/search_bar_component.html index 8d3d16236..ee9375dc9 100644 --- a/dart/lib/component/search_bar_component/search_bar_component.html +++ b/dart/lib/component/search_bar_component/search_bar_component.html @@ -81,6 +81,7 @@
    Type
    + diff --git a/dart/lib/component/search_page_component/search_page_component.html b/dart/lib/component/search_page_component/search_page_component.html index a2743ce90..4f564e0aa 100644 --- a/dart/lib/component/search_page_component/search_page_component.html +++ b/dart/lib/component/search_page_component/search_page_component.html @@ -8,6 +8,7 @@ +

    Enter a search on the left.

    diff --git a/dart/lib/routing/securitymonkey_router.dart b/dart/lib/routing/securitymonkey_router.dart index c4d73a205..f687c4089 100644 --- a/dart/lib/routing/securitymonkey_router.dart +++ b/dart/lib/routing/securitymonkey_router.dart @@ -47,6 +47,16 @@ void securityMonkeyRouteInitializer(Router router, RouteViewFactory views) { defaultRoute: true, view: 'views/error.html') }), + 'justified': ngRoute( + path: '/justified/:regions/:technologies/:accounts/:accounttypes/:names/:arns/:active/:searchconfig/:page/:count', + mount: { + 'view': ngRoute( + path: '', + view: 'views/searchpage.html'), + 'view_default': ngRoute( + defaultRoute: true, + view: 'views/error.html') + }), 'viewitemrevision': ngRoute( path: '/viewitem/:itemid/:revid', view: 'views/itemdetailsview.html' diff --git a/dart/lib/security_monkey.dart b/dart/lib/security_monkey.dart index 04b9b551e..7c32ce990 100644 --- a/dart/lib/security_monkey.dart +++ b/dart/lib/security_monkey.dart @@ -78,6 +78,7 @@ part 'component/dashboard_component/dashboard_component.dart'; part 'component/settings/user_role_component/user_role_component.dart'; part 'component/settings/network_whitelist_component/network_whitelist_component.dart'; part 'component/settings/ignore_list_component/ignore_list_component.dart'; +part 'component/justified_table_component/justified_table_component.dart'; part 'component/auditscore_view_component/auditscore_view_component.dart'; part 'component/account_pattern_audit_score_view_component/account_pattern_audit_score_view_component.dart'; part 'component/settings/audit_score_component/audit_score_component.dart'; @@ -119,6 +120,7 @@ class SecurityMonkeyModule extends Module { bind(UserRoleComponent); bind(NetworkWhitelistComponent); bind(IgnoreListComponent); + bind(JustifiedTableComponent); bind(AuditScoreComponent); bind(AccountPatternAuditScoreComponent); bind(AuditScoreListComponent); diff --git a/dart/web/ui.html b/dart/web/ui.html index ceaecaaa1..f9d05ce04 100644 --- a/dart/web/ui.html +++ b/dart/web/ui.html @@ -79,6 +79,8 @@
  • IAM Group Issues
  • Managed Policy Issues
  • Redshift Issues
  • +
  • +
  • Justified Issues
  • Settings
  • From dcb557d7cf37dc4adb3592d127e80cc9751cad20 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Fri, 24 Feb 2017 23:46:02 -0800 Subject: [PATCH 33/90] =?UTF-8?q?duplicate=20ARN=20issue=20(#573)=20?= =?UTF-8?q?=E2=80=BC=EF=B8=8F=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- security_monkey/datastore.py | 1 + 1 file changed, 1 insertion(+) diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index 7a5be574f..2054e0ae5 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -592,6 +592,7 @@ def store(self, ctype, region, account, name, active_flag, config, arn=None, new item=item.name )) db.session.add(duplicate_item) + db.session.commit() if arn: item.arn = arn From 4552f57b8b131b5eda091eab4304aa71f1f39e51 Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Thu, 9 Feb 2017 18:52:31 +0000 Subject: [PATCH 34/90] GCP Watchers/Auditors for Security Monkey --- .../account_managers/gcp_account.py | 59 +++++++ security_monkey/auditors/gcp/__init__.py | 0 security_monkey/auditors/gcp/gce/__init__.py | 0 security_monkey/auditors/gcp/gce/firewall.py | 144 +++++++++++++++ security_monkey/auditors/gcp/gce/network.py | 77 ++++++++ security_monkey/auditors/gcp/gcs/__init__.py | 0 security_monkey/auditors/gcp/gcs/bucket.py | 165 ++++++++++++++++++ security_monkey/auditors/gcp/iam/__init__.py | 0 .../auditors/gcp/iam/serviceaccount.py | 98 +++++++++++ security_monkey/auditors/gcp/util.py | 48 +++++ security_monkey/common/gcp/__init__.py | 0 security_monkey/common/gcp/config.py | 82 +++++++++ security_monkey/common/gcp/error.py | 28 +++ security_monkey/common/gcp/util.py | 36 ++++ security_monkey/watchers/gcp/__init__.py | 0 security_monkey/watchers/gcp/gce/__init__.py | 0 security_monkey/watchers/gcp/gce/firewall.py | 88 ++++++++++ security_monkey/watchers/gcp/gce/network.py | 91 ++++++++++ security_monkey/watchers/gcp/gcs/__init__.py | 0 security_monkey/watchers/gcp/gcs/bucket.py | 91 ++++++++++ security_monkey/watchers/gcp/iam/__init__.py | 0 .../watchers/gcp/iam/serviceaccount.py | 100 +++++++++++ 22 files changed, 1107 insertions(+) create mode 100644 security_monkey/account_managers/gcp_account.py create mode 100644 security_monkey/auditors/gcp/__init__.py create mode 100644 security_monkey/auditors/gcp/gce/__init__.py create mode 100644 security_monkey/auditors/gcp/gce/firewall.py create mode 100644 security_monkey/auditors/gcp/gce/network.py create mode 100644 security_monkey/auditors/gcp/gcs/__init__.py create mode 100644 security_monkey/auditors/gcp/gcs/bucket.py create mode 100644 security_monkey/auditors/gcp/iam/__init__.py create mode 100644 security_monkey/auditors/gcp/iam/serviceaccount.py create mode 100644 security_monkey/auditors/gcp/util.py create mode 100644 security_monkey/common/gcp/__init__.py create mode 100644 security_monkey/common/gcp/config.py create mode 100644 security_monkey/common/gcp/error.py create mode 100644 security_monkey/common/gcp/util.py create mode 100644 security_monkey/watchers/gcp/__init__.py create mode 100644 security_monkey/watchers/gcp/gce/__init__.py create mode 100644 security_monkey/watchers/gcp/gce/firewall.py create mode 100644 security_monkey/watchers/gcp/gce/network.py create mode 100644 security_monkey/watchers/gcp/gcs/__init__.py create mode 100644 security_monkey/watchers/gcp/gcs/bucket.py create mode 100644 security_monkey/watchers/gcp/iam/__init__.py create mode 100644 security_monkey/watchers/gcp/iam/serviceaccount.py diff --git a/security_monkey/account_managers/gcp_account.py b/security_monkey/account_managers/gcp_account.py new file mode 100644 index 000000000..4c7504ea5 --- /dev/null +++ b/security_monkey/account_managers/gcp_account.py @@ -0,0 +1,59 @@ +# Copyright 2017 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.accounts.gcp_account + :platform: Unix + :synopsis: Manages generic GCP account. + + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez (@supertom) + + +""" +from security_monkey.account_manager import AccountManager, CustomFieldConfig +from security_monkey.datastore import Account + + +class GCPAccountManager(AccountManager): + account_type = 'GCP' + identifier_label = 'Project ID' + identifier_tool_tip = 'Enter the GCP Project ID.' + creds_file_tool_tip = 'Enter the path on disk to the credentials file.' + custom_field_configs = [ + CustomFieldConfig('creds_file', 'Credentials File', True, creds_file_tool_tip), + ] + + def __init__(self): + super(GCPAccountManager, self).__init__() + + def lookup_account_by_identifier(self, identifier): + """ + Overrides the lookup to also check the number for backwards compatibility + """ + account = super(GCPAccountManager, + self).lookup_account_by_identifier(identifier) + return account + + def _populate_account(self, account, account_type, name, active, third_party, + notes, identifier, custom_fields=None): + """ + # TODO(supertom): look into this. + Overrides create and update to also save the number, s3_name and role_name + for backwards compatibility + """ + account = super(GCPAccountManager, self)._populate_account(account, account_type, name, active, third_party, + notes, identifier, custom_fields) + + return account diff --git a/security_monkey/auditors/gcp/__init__.py b/security_monkey/auditors/gcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/auditors/gcp/gce/__init__.py b/security_monkey/auditors/gcp/gce/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/auditors/gcp/gce/firewall.py b/security_monkey/auditors/gcp/gce/firewall.py new file mode 100644 index 000000000..fe0b49b4d --- /dev/null +++ b/security_monkey/auditors/gcp/gce/firewall.py @@ -0,0 +1,144 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.auditors.gcp.gce.firewall + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.auditor import Auditor +from security_monkey.auditors.gcp.util import make_audit_issue, process_issues +from security_monkey.common.gcp.config import AuditorConfig +from security_monkey.common.gcp.error import AuditIssue +from security_monkey.watchers.gcp.gce.firewall import GCEFirewallRule + +# NOTE: issue scores and messages are defined in +# security_monkey/common/gcp/config.py + + +class GCEFirewallRuleAuditor(Auditor): + index = GCEFirewallRule.index + i_am_singular = GCEFirewallRule.i_am_singular + i_am_plural = GCEFirewallRule.i_am_plural + gcp_config = AuditorConfig.GCEFirewallRule + + def __init__(self, accounts=None, debug=True): + super( + GCEFirewallRuleAuditor, + self).__init__( + accounts=accounts, + debug=debug) + + def _port_range_exists(self, allowed_list, error_cat='ALLOWED'): + """ + Check to see if a port range exists in the allowed field. + """ + errors = [] + for allowed in allowed_list: + ports = allowed.get('ports', None) + if ports: + for port in ports: + if str(port).find('-') > -1: + ae = make_audit_issue( + error_cat, 'EXISTS', 'PORTRANGE') + ae.notes = '%s:%s' % (allowed['IPProtocol'], port) + errors.append(ae) + return errors + + def _target_tags_valid(self, target_tags, error_cat='TARGET_TAGS'): + """ + Check to see if target tags are present. + """ + errors = [] + + if not target_tags: + ae = make_audit_issue( + error_cat, 'FOUND', 'NOT') + errors.append(ae) + return errors + + def _source_ranges_open(self, source_ranges, error_cat='SOURCE_RANGES'): + """ + Check to see if the source range field is set to allow all traffic + """ + errors = [] + open_range = '0.0.0.0/0' + for source_range in source_ranges: + if source_range == open_range: + ae = make_audit_issue( + error_cat, 'OPEN', 'TRAFFIC') + errors.append(ae) + return errors + + def inspect_target_tags(self, item): + """ + Driver for Target Tags. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + errors = [] + + target_tags = item.config.get('TargetTags', None) + err = self._target_tags_valid(target_tags) + errors.extend(err) if err else None + + if errors: + return (False, errors) + return (True, None) + + def inspect_source_ranges(self, item): + """ + Driver for Source Ranges. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + errors = [] + + source_ranges = item.config.get('SourceRanges', None) + if source_ranges: + err = self._source_ranges_open(source_ranges) + errors.extend(err) if err else None + + if errors: + return (False, errors) + return (True, None) + + def inspect_allowed(self, item): + """ + Driver for Allowed field (protocol/ports list). Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + errors = [] + + err = self._port_range_exists(item.config.get('Allowed')) + errors.extend(err) if err else None + + if errors: + return (False, errors) + return (True, None) + + def check_allowed(self, item): + (ok, errors) = self.inspect_allowed(item) + process_issues(self, ok, errors, item) + + def check_target_tags(self, item): + (ok, errors) = self.inspect_target_tags(item) + process_issues(self, ok, errors, item) + + def check_source_ranges(self, item): + (ok, errors) = self.inspect_source_ranges(item) + process_issues(self, ok, errors, item) diff --git a/security_monkey/auditors/gcp/gce/network.py b/security_monkey/auditors/gcp/gce/network.py new file mode 100644 index 000000000..b040c0cc1 --- /dev/null +++ b/security_monkey/auditors/gcp/gce/network.py @@ -0,0 +1,77 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.auditors.gcp.gce.network + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.auditor import Auditor +from security_monkey.auditors.gcp.util import make_audit_issue, process_issues +from security_monkey.common.gcp.config import AuditorConfig +from security_monkey.common.gcp.error import AuditIssue +from security_monkey.watchers.gcp.gce.network import GCENetwork + +# NOTE: issue scores and messages are defined in +# security_monkey/common/gcp/config.py + + +class GCENetworkAuditor(Auditor): + index = GCENetwork.index + i_am_singular = GCENetwork.i_am_singular + i_am_plural = GCENetwork.i_am_plural + gcp_config = AuditorConfig.GCENetwork + + def __init__(self, accounts=None, debug=True): + super(GCENetworkAuditor, self).__init__(accounts=accounts, debug=debug) + + def _legacy_exists(self, network, error_cat='NET'): + """ + Look for legacy-style (non-subnetwork style) network. + + return: [list of AuditIssues] + """ + errors = [] + subnetworks = network.get('Subnetworks', None) + auto_create_subnetworks = network.get( + 'AutoCreateSubnetworks', None) + + # A network is considered 'legacy' if 'Subnetworks' AND 'AutoCreateSubnetworks' + # do not exist in the dictionary. + if subnetworks is None and auto_create_subnetworks is None: + ae = make_audit_issue( + error_cat, 'EXISTS', 'LEGACY') + errors.append(ae) + return errors + + def inspect_network(self, item): + """ + Driver for Network. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + errors = [] + network = item.config + err = self._legacy_exists(network) + errors.extend(err) if err else None + + if errors: + return (False, errors) + return (True, None) + + def check_networks(self, item): + (ok, errors) = self.inspect_network(item) + process_issues(self, ok, errors, item) diff --git a/security_monkey/auditors/gcp/gcs/__init__.py b/security_monkey/auditors/gcp/gcs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/auditors/gcp/gcs/bucket.py b/security_monkey/auditors/gcp/gcs/bucket.py new file mode 100644 index 000000000..7c48320de --- /dev/null +++ b/security_monkey/auditors/gcp/gcs/bucket.py @@ -0,0 +1,165 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.auditors.gcp.gcs.bucket + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.auditor import Auditor +from security_monkey.auditors.gcp.util import make_audit_issue, process_issues +from security_monkey.common.gcp.config import AuditorConfig +from security_monkey.common.gcp.error import AuditIssue +from security_monkey.watchers.gcp.gcs.bucket import GCSBucket + +# NOTE: issue scores and messages are defined in +# security_monkey/common/gcp/config.py + + +class GCSBucketAuditor(Auditor): + index = GCSBucket.index + i_am_singular = GCSBucket.i_am_singular + i_am_plural = GCSBucket.i_am_plural + gcp_config = AuditorConfig.GCSBucket + + def __init__(self, accounts=None, debug=True): + super(GCSBucketAuditor, self).__init__(accounts=accounts, debug=debug) + + def _acl_allusers_exists(self, acl_list, error_cat='ACL'): + """ + Looks for allUsers in acl. + + return: [list of AuditIssues] + """ + allusers = 'allUsers' + errors = [] + for acl in acl_list: + entity = acl.get('entity') + role = acl.get('role') + if entity == allusers: + # TODO(supertom): notes + ae = make_audit_issue(error_cat, 'ROLE', allusers, role) + errors.append(ae) + return errors + + def _acl_max_owners(self, acl_list, error_cat='ACL'): + """ + Looks for Max OWNERS in acl. + + return: [list of AuditIssues] + """ + errors = [] + if self.gcp_config.MAX_OWNERS_PER_BUCKET: + owner = 'OWNER' + count = 0 + for acl in acl_list: + role = acl.get('role') + if role == owner: + count += 1 + if count > self.gcp_config.MAX_OWNERS_PER_BUCKET: + ae = make_audit_issue( + error_cat, 'MAX', owner) + errors.append(ae) + return errors + + def _cors_method(self, cors_list, error_cat='CORS'): + """ + Looks at the CORS method. Anything other than GET is flagged. + + return: [list of AuditIssues] + """ + errors = [] + for cors in cors_list: + methods = cors.get('method') + for method in methods: + if method == '*': + method = 'ALL' + if method != 'GET': + ae = make_audit_issue( + error_cat, 'METHOD', method) + errors.append(ae) + return errors + + def inspect_acl(self, item): + """ + Driver for Bucket ACL. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + acl = item.config.get('Acl') + errors_acl = [] + if acl: + err = self._acl_allusers_exists(acl, 'ACL') + errors_acl.extend(err) if err else None + + err = self._acl_max_owners(acl, 'ACL') + errors_acl.extend(err) if err else None + if errors_acl: + return (False, errors_acl) + return (True, None) + else: + return (False, ['ACL_NOT_FOUND']) + + def inspect_default_object_acl(self, item): + """ + Driver for Default Object ACL. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + def_obj_acl = item.config.get('DefaultObjectAcl') + errors_acl = [] + if def_obj_acl: + err = self._acl_allusers_exists(def_obj_acl, 'DEFAULT_OBJECT_ACL') + errors_acl.extend(err) if err else None + + err = self._acl_max_owners(def_obj_acl, 'DEFAULT_OBJECT_ACL') + errors_acl.extend(err) if err else None + if errors_acl: + return (False, errors_acl) + return (True, None) + else: + return (False, ['DEF_OBJ_ACL_NOT_FOUND']) + + def inspect_cors(self, item): + """ + Driver for CORS field. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + cors = item.config.get('Cors') + if cors: + errors = [] + err = self._cors_method(cors) + errors.extend(err) if err else None + if errors: + return (False, errors) + return (True, None) + + def check_cors(self, item): + """ + Check CORS field. + CORS policy is set with: gsutil cors set /tmp/cors.json gs://your-bucket + """ + (ok, errors) = self.inspect_cors(item) + process_issues(self, ok, errors, item) + + def check_acl(self, item): + (ok, errors) = self.inspect_acl(item) + process_issues(self, ok, errors, item) + + def check_default_object_acl(self, item): + (ok, errors) = self.inspect_default_object_acl(item) + process_issues(self, ok, errors, item) diff --git a/security_monkey/auditors/gcp/iam/__init__.py b/security_monkey/auditors/gcp/iam/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/auditors/gcp/iam/serviceaccount.py b/security_monkey/auditors/gcp/iam/serviceaccount.py new file mode 100644 index 000000000..44677ff6c --- /dev/null +++ b/security_monkey/auditors/gcp/iam/serviceaccount.py @@ -0,0 +1,98 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.auditors.gcp.gce_iam + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" + +from security_monkey.auditor import Auditor +from security_monkey.auditors.gcp.util import make_audit_issue, process_issues +from security_monkey.common.gcp.config import AuditorConfig +from security_monkey.common.gcp.error import AuditIssue +from security_monkey.watchers.gcp.iam.serviceaccount import IAMServiceAccount + +# NOTE: issue scores and messages are defined in +# security_monkey/common/gcp/config.py + + +class IAMServiceAccountAuditor(Auditor): + index = IAMServiceAccount.index + i_am_singular = IAMServiceAccount.i_am_singular + i_am_plural = IAMServiceAccount.i_am_plural + gcp_config = AuditorConfig.IAMServiceAccount + + def __init__(self, accounts=None, debug=True): + super( + IAMServiceAccountAuditor, + self).__init__( + accounts=accounts, + debug=debug) + + def _max_keys(self, key_count, error_cat='SA'): + """ + Alert when a service account has too many keys. + + return: [list of AuditIssues] + """ + errors = [] + if key_count > self.gcp_config.MAX_SERVICEACCOUNT_KEYS: + ae = make_audit_issue( + error_cat, 'MAX', 'KEYS') + ae.notes = 'Too Many Keys (count: %s, max: %s)' % ( + key_count, self.gcp_config.MAX_SERVICEACCOUNT_KEYS) + errors.append(ae) + return errors + + def _actor_role(self, policies, error_cat='SA'): + """ + Determine if a serviceaccount actor is specified. + + return: [list of AuditIssues] + """ + errors = [] + for policy in policies: + role = policy.get('Role') + if role and role == 'iam.serviceAccountActor': + ae = make_audit_issue( + error_cat, 'POLICY', 'ROLE', 'ACTOR') + errors.append(ae) + return errors + + def inspect_serviceaccount(self, item): + """ + Driver for ServiceAccount. Calls helpers as needed. + + return: (bool, [list of AuditIssues]) + """ + errors = [] + + err = self._max_keys(item.config.get('keys')) + errors.extend(err) if err else None + + policies = item.config.get('policy') + if policies: + err = self._actor_role(policies) + errors.extend(err) if err else None + + if errors: + return (False, errors) + return (True, None) + + def check_serviceaccount(self, item): + (ok, errors) = self.inspect_serviceaccount(item) + process_issues(self, ok, errors, item) diff --git a/security_monkey/auditors/gcp/util.py b/security_monkey/auditors/gcp/util.py new file mode 100644 index 000000000..d9491f0c1 --- /dev/null +++ b/security_monkey/auditors/gcp/util.py @@ -0,0 +1,48 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.auditors.gcp.util + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.common.gcp.config import AuditorConfig as config +from security_monkey.common.gcp.error import AuditIssue + +def _gen_error_code(cat, subcat, prefix, postfix=None): + s = "%s_%s_%s" % (str(cat).upper(), + str(prefix).upper(), + str(subcat).upper()) + if postfix: + s += '_' + str(postfix).upper() + return s + +def make_audit_issue(cat, subcat, prefix, postfix=None, notes=None): + ec = _gen_error_code( + cat, subcat, prefix, postfix) + return AuditIssue(code=ec, notes=notes) + +def process_issues(auditor, ok, issues, item): + if not ok: + for issue in issues: + sev = auditor.gcp_config.ISSUE_MAP[issue.code]['score'] + msg = auditor.gcp_config.ISSUE_MAP[issue.code]['msg'] + notes = None + if issue.notes: + notes = issue.notes + auditor.add_issue(sev, msg, item, notes) + return True + diff --git a/security_monkey/common/gcp/__init__.py b/security_monkey/common/gcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/common/gcp/config.py b/security_monkey/common/gcp/config.py new file mode 100644 index 000000000..34d8c86d7 --- /dev/null +++ b/security_monkey/common/gcp/config.py @@ -0,0 +1,82 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.auditors.gcp.config + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" + + +class AuditorConfig(object): + """ + Each Auditor has it's own configuration class defined. They are all guaranteed to contain + an ISSUE_MAP member, containing issue scores and messages. Auditor-specific configuration + variables can be defined/modified here as well. + """ + class GCSBucket(): + MAX_OWNERS_PER_BUCKET = 1 + + ISSUE_MAP = { + 'ACL_ALLUSERS_ROLE_READER': {'score': 7, 'msg': "allUsers with Role READER set in bucket ACL."}, + 'ACL_ALLUSERS_ROLE_WRITER': {'score': 8, 'msg': "allUsers with Role WRITER set in bucket ACL."}, + 'ACL_ALLUSERS_ROLE_OWNER': {'score': 10, 'msg': "allUsers with Role OWNER set in bucket ACL."}, + 'ACL_OWNER_MAX': {'score': 7, 'msg': "OWNERS max exceeded in bucket ACL."}, + 'ACL_NOT_FOUND': {'score': 10, 'msg': "ACL not found in bucket config."}, + 'CORS_PRESENT': {'score': 7, 'msg': "CORS set in bucket config."}, + 'CORS_ALL_METHOD': {'score': 10, 'msg': "CORS method '*' allowed in bucket config"}, + 'CORS_DELETE_METHOD': {'score': 10, 'msg': "CORS method DELETE allowed in bucket config"}, + 'CORS_HEAD_METHOD': {'score': 7, 'msg': "CORS method HEAD allowed in bucket config"}, + 'CORS_OPTIONS_METHOD': {'score': 7, 'msg': "CORS method OPTIONS allowed in bucket config"}, + 'CORS_POST_METHOD': {'score': 9, 'msg': "CORS method POST allowed in bucket config"}, + 'CORS_PUT_METHOD': {'score': 9, 'msg': "CORS method PUT allowed in bucket config"}, + 'DEFAULT_OBJECT_ACL_ALLUSERS_ROLE_READER': {'score': 7, 'msg': "allUsers with Role READER set in Default Object ACL."}, + 'DEFAULT_OBJECT_ACL_ALLUSERS_ROLE_WRITER': {'score': 8, 'msg': "allUsers with Role WRITER set in Default Object ACL."}, + 'DEFAULT_OBJECT_ACL_ALLUSERS_ROLE_OWNER': {'score': 10, 'msg': "allUsers with Role OWNER set in Default Object ACL."}, + 'DEFAULT_OBJECT_ACL_OWNER_MAX': {'score': 7, 'msg': "OWNERS max exceeded in Default Object ACL"}, + 'DEFAULT_OBJECT_ACL_NOT_FOUND': {'score': 10, 'msg': "Default Object ACL not found in bucket config."}, + } + + class IAMServiceAccount(): + MAX_SERVICEACCOUNT_KEYS = 4 + ISSUE_MAP = { + 'SA_KEYS_MAX': { + 'score': 7, + 'msg': "Max keys for service account exceeded."}, + 'SA_POLICY_ROLE_ACTOR': { + 'score': 6, + 'msg': "ServiceAccount Actor contained in policy."}, + } + + class GCEFirewallRule(): + ISSUE_MAP = { + 'ALLOWED_PORTRANGE_EXISTS': { + 'score': 3, + 'msg': "Port Range Found in Firewall Rule."}, + 'SOURCE_RANGES_TRAFFIC_OPEN': { + 'score': 7, + 'msg': "Source range open. Traffic permitted from any host."}, + 'TARGET_TAGS_NOT_FOUND': { + 'score': 3, + 'msg': "Target Tags Not Found in Firewall Rule."}, + } + + class GCENetwork(): + ISSUE_MAP = { + 'NET_LEGACY_EXISTS': { + 'score': 8, + 'msg': "Legacy networks are not recommended."}, + } diff --git a/security_monkey/common/gcp/error.py b/security_monkey/common/gcp/error.py new file mode 100644 index 000000000..9c4e5de47 --- /dev/null +++ b/security_monkey/common/gcp/error.py @@ -0,0 +1,28 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.common.gcp.error + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" + + +class AuditIssue(object): + + def __init__(self, code, notes=None): + self.code = code + self.notes = notes diff --git a/security_monkey/common/gcp/util.py b/security_monkey/common/gcp/util.py new file mode 100644 index 000000000..65fe28422 --- /dev/null +++ b/security_monkey/common/gcp/util.py @@ -0,0 +1,36 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.watchers.gcp.util + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom +""" +from security_monkey.datastore import Account +from cloudaux.orchestration import modify as cloudaux_modify + + +def identifiers_from_account_names(account_names): + accounts = Account.query.filter(Account.name.in_(account_names)).all() + return [account.identifier for account in accounts] + + +def gcp_resource_id_builder(service, identifier, region=''): + resource = 'gcp:%s:%s:%s' % (region, service, identifier) + return resource.replace('/', ':').replace('.', ':') + + +def modify(d, format='camelized'): + return cloudaux_modify(d, format=format) diff --git a/security_monkey/watchers/gcp/__init__.py b/security_monkey/watchers/gcp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/watchers/gcp/gce/__init__.py b/security_monkey/watchers/gcp/gce/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/watchers/gcp/gce/firewall.py b/security_monkey/watchers/gcp/gce/firewall.py new file mode 100644 index 000000000..90fedfe58 --- /dev/null +++ b/security_monkey/watchers/gcp/gce/firewall.py @@ -0,0 +1,88 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.watchers.gcp.gce.firewall + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom +""" +from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names, modify +from security_monkey.watcher import Watcher +from security_monkey.watcher import ChangeItem + +from cloudaux.gcp.decorators import iter_project +from cloudaux.gcp.gce.firewall import list_firewall_rules +from cloudaux.orchestration import modify + + +class GCEFirewallRule(Watcher): + index = 'gcefirewallrule' + i_am_singular = 'GCEFirewallRule' + i_am_plural = 'GCEFirewallRules' + account_type = 'GCP' + + def __init__(self, accounts=None, debug=False): + super(GCEFirewallRule, self).__init__(accounts=accounts, debug=debug) + self.honor_ephemerals = True + self.ephemeral_paths = [ + "Etag", + ] + + def slurp(self): + """ + :returns: item_list - list of GCEFirewallRules. + :returns: exception _map - A dict where the keys are a tuple containing the + location of the exception and the value is the actual exception + """ + self.prep_for_slurp() + account_identifiers = identifiers_from_account_names(self.accounts) + + @iter_project(projects=account_identifiers) + def slurp_items(**kwargs): + item_list = [] + rules = list_firewall_rules(**kwargs) + + for rule in rules: + resource_id = gcp_resource_id_builder( + 'compute.firewall.get', rule['name']) + item_list.append( + GCEFirewallRuleItem( + region='global', + account=kwargs['project'], + name=rule['name'], + arn=resource_id, + config=modify(rule, format='camelized'))) + return item_list, kwargs.get('exception_map', {}) + + return slurp_items() + + +class GCEFirewallRuleItem(ChangeItem): + + def __init__(self, + region=None, + account=None, + name=None, + arn=None, + config=None): + if config is None: + config = {} + super(GCEFirewallRuleItem, self).__init__( + index=GCEFirewallRule.index, + region=region, + account=account, + name=name, + arn=arn, + new_config=config) diff --git a/security_monkey/watchers/gcp/gce/network.py b/security_monkey/watchers/gcp/gce/network.py new file mode 100644 index 000000000..7f2e6c511 --- /dev/null +++ b/security_monkey/watchers/gcp/gce/network.py @@ -0,0 +1,91 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.watchers.gcp.gce.network + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names +from security_monkey.watcher import Watcher +from security_monkey.watcher import ChangeItem + +from cloudaux.gcp.decorators import iter_project +from cloudaux.gcp.gce.network import list_networks +from cloudaux.orchestration.gcp.gce.network import get_network_and_subnetworks + + +class GCENetwork(Watcher): + index = 'gcenetwork' + i_am_singular = 'GCENetwork' + i_am_plural = 'GCENetworks' + account_type = 'GCP' + + def __init__(self, accounts=None, debug=False): + super(GCENetwork, self).__init__(accounts=accounts, debug=debug) + self.honor_ephemerals = True + self.ephemeral_paths = [ + "Etag", + ] + + def slurp(self): + """ + :returns: item_list - list of GCENetwork. + :returns: exception _map - A dict where the keys are a tuple containing the + location of the exception and the value is the actual exception + """ + self.prep_for_slurp() + account_identifiers = identifiers_from_account_names(self.accounts) + + @iter_project(projects=account_identifiers) + def slurp_items(**kwargs): + item_list = [] + networks = list_networks(**kwargs) + + for network in networks: + resource_id = gcp_resource_id_builder( + 'compute.network.get', network['name']) + net_complete = get_network_and_subnetworks( + network['name'], **kwargs) + item_list.append( + GCENetworkItem( + region='global', + account=kwargs['project'], + name=net_complete['Name'], + arn=resource_id, + config=net_complete)) + return item_list, kwargs.get('exception_map', {}) + + return slurp_items() + + +class GCENetworkItem(ChangeItem): + + def __init__(self, + region=None, + account=None, + name=None, + arn=None, + config=None): + if config is None: + config = {} + super(GCENetworkItem, self).__init__( + index=GCENetwork.index, + region=region, + account=account, + name=name, + arn=arn, + new_config=config) diff --git a/security_monkey/watchers/gcp/gcs/__init__.py b/security_monkey/watchers/gcp/gcs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/watchers/gcp/gcs/bucket.py b/security_monkey/watchers/gcp/gcs/bucket.py new file mode 100644 index 000000000..1216fba7a --- /dev/null +++ b/security_monkey/watchers/gcp/gcs/bucket.py @@ -0,0 +1,91 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.watchers.gcp.gcs.bucket + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names +from security_monkey.watcher import Watcher +from security_monkey.watcher import ChangeItem + +from cloudaux.gcp.decorators import iter_project +from cloudaux.gcp.gcs import list_buckets +from cloudaux.orchestration.gcp.gcs.bucket import get_bucket + + +class GCSBucket(Watcher): + index = 'gcsbucket' + i_am_singular = 'GCSBucket' + i_am_plural = 'GCSBuckets' + account_type = 'GCP' + + def __init__(self, accounts=None, debug=False): + super(GCSBucket, self).__init__(accounts=accounts, debug=debug) + self.honor_ephemerals = True + self.ephemeral_paths = [ + "Etag", + ] + + def slurp(self): + """ + :returns: item_list - list of GCSBuckets. + :returns: exception _map - A dict where the keys are a tuple containing the + location of the exception and the value is the actual exception + """ + self.prep_for_slurp() + account_identifiers = identifiers_from_account_names(self.accounts) + + @iter_project(projects=account_identifiers) + def slurp_items(**kwargs): + item_list = [] + buckets = list_buckets() + + for bucket in buckets: + resource_id = gcp_resource_id_builder( + 'storage.bucket.get', bucket['name']) + b = get_bucket( + bucket_name=bucket['name'], **kwargs) + item_list.append( + GCSBucketItem( + region=b['Location'], + account=kwargs['project'], + name=b['Id'], + arn=resource_id, + config=b)) + return item_list, kwargs.get('exception_map', {}) + + return slurp_items() + + +class GCSBucketItem(ChangeItem): + + def __init__(self, + region=None, + account=None, + name=None, + arn=None, + config=None): + if config is None: + config = {} + super(GCSBucketItem, self).__init__( + index=GCSBucket.index, + region=region, + account=account, + name=name, + arn=arn, + new_config=config) diff --git a/security_monkey/watchers/gcp/iam/__init__.py b/security_monkey/watchers/gcp/iam/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/watchers/gcp/iam/serviceaccount.py b/security_monkey/watchers/gcp/iam/serviceaccount.py new file mode 100644 index 000000000..1d8cd0ff0 --- /dev/null +++ b/security_monkey/watchers/gcp/iam/serviceaccount.py @@ -0,0 +1,100 @@ +# Copyright 2017 Google, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.watchers.gcp.iam.serviceaccount + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Tom Melendez @supertom + +""" +from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names +from security_monkey.watcher import Watcher +from security_monkey.watcher import ChangeItem + +from cloudaux.gcp.decorators import iter_project +from cloudaux.gcp.iam import list_serviceaccounts +from cloudaux.orchestration.gcp.iam.serviceaccount import get_serviceaccount_complete + + +class IAMServiceAccount(Watcher): + index = 'iamserviceaccount' + i_am_singular = 'IAMServiceAccount' + i_am_plural = 'IAMServiceAccounts' + account_type = 'GCP' + + def __init__(self, accounts=None, debug=False): + super(IAMServiceAccount, self).__init__(accounts=accounts, debug=debug) + self.honor_ephemerals = True + self.ephemeral_paths = [ + "Etag", + ] + + def slurp(self): + """ + :returns: item_list - list of IAMServiceAccounts. + :returns: exception _map - A dict where the keys are a tuple containing the + location of the exception and the value is the actual exception + """ + self.prep_for_slurp() + account_identifiers = identifiers_from_account_names(self.accounts) + + @iter_project(projects=account_identifiers) + def slurp_items(**kwargs): + item_list = [] + service_accounts = list_serviceaccounts(**kwargs) + + for service_account in service_accounts: + resource_id = gcp_resource_id_builder( + 'projects.serviceaccounts.get', service_account['name']) + sa = get_serviceaccount_complete( + service_account=service_account['name'], **kwargs) + + key_count = 0 + if 'Keys' in sa: + key_count = len(sa['Keys']) + + item_list.append( + IAMServiceAccountItem( + region='global', + account=sa['ProjectId'], + name=sa['DisplayName'], + arn=resource_id, + config={ + 'policy': sa.get('Policy', None), + 'email': sa['Email'], + 'keys': key_count, + })) + return item_list, kwargs.get('exception_map', {}) + + return slurp_items() + + +class IAMServiceAccountItem(ChangeItem): + + def __init__(self, + region=None, + account=None, + name=None, + arn=None, + config=None): + if config is None: + config = {} + super(IAMServiceAccountItem, self).__init__( + index=IAMServiceAccount.index, + region=region, + account=account, + name=name, + arn=arn, + new_config=config) From 877620eda2935be2fd4b127505558b424daf7631 Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Mon, 6 Mar 2017 12:37:34 -0800 Subject: [PATCH 35/90] Upped CloudAux to 1.1.3 to address #574 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 54f19e762..0b4ac49d1 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ 'dpath==1.3.2', 'pyyaml==3.11', 'jira==0.32', - 'cloudaux>=1.1.1', + 'cloudaux>=1.1.3', 'joblib>=0.9.4', 'pyjwt>=1.01', ], From 132865cc71fc7fbcd25fc961769b48a2469fb225 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:16:25 -0500 Subject: [PATCH 36/90] [secmonkey] Fix justification preservation bug (#564) Type: general-bugfix Why is this change necessary? Any item configuration change (ephemeral or non-ephemeral) causes Security Monkey to remove existing audit issues, which in turn also removes and justifications on said issues. While audit issues are re-added during the next audit, justifications are lost. Adding test coverage for watcher class when there are changes, no changes, and ephemeral changes. This change addresses the need by: Ensuring audit issues are preserved when config changes are detected for any item. Potential Side Effects: No known side effects --- security_monkey/tests/core/test_watcher.py | 303 +++++++++++++++++++++ security_monkey/watcher.py | 6 +- 2 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 security_monkey/tests/core/test_watcher.py diff --git a/security_monkey/tests/core/test_watcher.py b/security_monkey/tests/core/test_watcher.py new file mode 100644 index 000000000..d2127318a --- /dev/null +++ b/security_monkey/tests/core/test_watcher.py @@ -0,0 +1,303 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.core.test_watcher + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.watcher import Watcher, ChangeItem +from security_monkey.datastore import Item, ItemAudit, Technology +from security_monkey.datastore import Account, AccountType, Datastore +from security_monkey import db + + +CONFIG_1 = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + 'key4': 'value4' +} + +CONFIG_2 = { + 'key1': 'value1', + 'key2': 'value2', + 'key3': 'value3', + 'key4': 'newvalue' +} + + +class WatcherTestCase(SecurityMonkeyTestCase): + def test_from_items(self): + issue = ItemAudit() + issue.score = 1 + issue.justified = True + issue.issue = 'test issue' + issue.justification = 'test justification' + + old_item_w_issues = ChangeItem(index='testtech', region='us-west-2', account='testaccount', + new_config=CONFIG_1, active=True, audit_issues=[issue]) + old_item_wo_issues = ChangeItem(index='testtech', region='us-west-2', account='testaccount', + new_config=CONFIG_1, active=True) + new_item = ChangeItem(index='testtech', region='us-west-2', account='testaccount', new_config=CONFIG_2, + active=True) + + merged_item_w_issues = ChangeItem.from_items(old_item=old_item_w_issues, new_item=new_item) + merged_item_wo_issues = ChangeItem.from_items(old_item=old_item_wo_issues, new_item=new_item) + + assert len(merged_item_w_issues.audit_issues) == 1 + assert len(merged_item_wo_issues.audit_issues) == 0 + + def test_no_change_items(self): + + previous = [ + ChangeItem( + index='test_index', + account='test_account', + name='item1_name', + new_config={ + 'config': 'test1' + } + ), + ChangeItem( + index='test_index', + account='test_account', + name='item2_name', + new_config={ + 'config': 'test2' + } + ) + ] + + current = [ + ChangeItem( + index='test_index', + account='test_account', + name='item1_name', + new_config={ + 'config': 'test1' + } + ), + ChangeItem( + index='test_index', + account='test_account', + name='item2_name', + new_config={ + 'config': 'test2' + } + ) + ] + + watcher = Watcher(accounts=['test_account']) + + watcher.find_modified(previous, current) + assert len(watcher.changed_items) == 0 + + def test_changed_item(self): + + previous = [ + ChangeItem( + index='test_index', + account='test_account', + name='item1_name', + new_config={ + 'config': 'test1' + } + ), + ChangeItem( + index='test_index', + account='test_account', + name='item2_name', + new_config={ + 'config': 'test2' + } + ) + ] + + current = [ + ChangeItem( + index='test_index', + account='test_account', + name='item1_name', + new_config={ + 'config': 'test1' + } + ), + ChangeItem( + index='test_index', + account='test_account', + name='item2_name', + new_config={ + 'config': 'test3' + } + ) + ] + + watcher = Watcher(accounts=['test_account']) + + watcher.find_modified(previous, current) + assert len(watcher.changed_items) == 1 + + def test_ephemeral_change(self): + + previous = [ + ChangeItem( + index='test_index', + account='test_account', + name='item1_name', + new_config={ + 'normal': True + } + ), + ChangeItem( + index='test_index', + account='test_account', + name='item2_name', + new_config={ + 'normal': False, + 'test_ephemeral': 'previous ephemeral' + } + ) + ] + + current = [ + ChangeItem( + index='test_index', + account='test_account', + name='item1_name', + new_config={ + 'normal': True + } + ), + ChangeItem( + index='test_index', + account='test_account', + name='item2_name', + new_config={ + 'normal': False, + 'test_ephemeral': 'current ephemeral' + } + ) + ] + + watcher = Watcher(accounts=['test_account']) + watcher.honor_ephemerals = True + watcher.ephemeral_paths = ['test_ephemeral'] + + watcher.find_modified(previous, current) + assert len(watcher.changed_items) == 0 + + def test_save_changed_item(self): + self._setup_account() + + datastore = Datastore() + + old_item = ChangeItem( + index='test_index', + account='test_account', + name='item_name', + active=True, + new_config={ + 'config': 'test1' + } + ) + + old_item.save(datastore) + + query = Item.query.filter(Technology.name == 'test_index').filter(Account.name == 'test_account') + items = query.all() + self.assertEquals(len(items), 1) + revisions = items[0].revisions.all() + self.assertEquals(len(revisions), 1) + + new_item = ChangeItem( + index='test_index', + account='test_account', + name='item_name', + active=True, + new_config={ + 'config': 'test2' + } + ) + watcher = Watcher(accounts=['test_account']) + watcher.index = 'test_index' + watcher.find_changes(current=[new_item]) + watcher.save() + + query = Item.query.filter(Technology.name == 'test_index').filter(Account.name == 'test_account') + items = query.all() + self.assertEquals(len(items), 1) + revisions = items[0].revisions.all() + self.assertEquals(len(revisions), 2) + + def test_save_ephemeral_changed_item(self): + self._setup_account() + + datastore = Datastore() + + old_item = ChangeItem( + index='test_index', + account='test_account', + name='item_name', + active=True, + new_config={ + 'config': 'test1' + } + ) + + old_item.save(datastore) + + query = Item.query.filter(Technology.name == 'test_index').filter(Account.name == 'test_account') + items = query.all() + self.assertEquals(len(items), 1) + revisions = items[0].revisions.all() + self.assertEquals(len(revisions), 1) + + new_item = ChangeItem( + index='test_index', + account='test_account', + name='item_name', + active=True, + new_config={ + 'config': 'test2' + } + ) + watcher = Watcher(accounts=['test_account']) + watcher.index = 'test_index' + watcher.honor_ephemerals = True + watcher.ephemeral_paths = ["config"] + + watcher.find_changes(current=[new_item]) + watcher.save() + + query = Item.query.filter(Technology.name == 'test_index').filter(Account.name == 'test_account') + items = query.all() + self.assertEquals(len(items), 1) + revisions = items[0].revisions.all() + self.assertEquals(len(revisions), 1) + + def _setup_account(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + account = Account(identifier="012345678910", name="test_account", + account_type_id=account_type_result.id) + + db.session.add(account) + db.session.commit() diff --git a/security_monkey/watcher.py b/security_monkey/watcher.py index 56734095c..eebe44076 100644 --- a/security_monkey/watcher.py +++ b/security_monkey/watcher.py @@ -319,7 +319,8 @@ def read_previous_items(self): region=item.region, account=item.account.name, name=item.name, - new_config=item_revision.config) + new_config=item_revision.config, + audit_issues=list(item.issues)) prev_list.append(new_item) return prev_list @@ -445,6 +446,7 @@ def from_items(cls, old_item=None, new_item=None): if not old_item and not new_item: return valid_item = new_item if new_item else old_item + audit_issues = old_item.audit_issues if old_item else [] active = True if new_item else False old_config = old_item.config if old_item else {} new_config = new_item.config if new_item else {} @@ -456,7 +458,7 @@ def from_items(cls, old_item=None, new_item=None): old_config=old_config, new_config=new_config, active=active, - audit_issues=valid_item.audit_issues) + audit_issues=audit_issues) @property def config(self): From 777cedbecc501a26ef610f1fdc27436736bc4356 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:16:41 -0500 Subject: [PATCH 37/90] [secmonkey] Handle unicode name tags (#565) Type: generic-bugfix Why is this change necessary? The peering watcher was throwing an exception when the name tag contained unicode characters. This change addresses the need by: Encoding the name tag before processing Potential Side Effects: No known side effects --- security_monkey/watchers/vpc/peering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security_monkey/watchers/vpc/peering.py b/security_monkey/watchers/vpc/peering.py index 467f971bd..f89be0c59 100644 --- a/security_monkey/watchers/vpc/peering.py +++ b/security_monkey/watchers/vpc/peering.py @@ -77,7 +77,7 @@ def slurp_items(**kwargs): if not (peering_name is None): peering_name = "{0} ({1})".format( - peering_name, connection_id) + peering_name.encode('utf-8', 'ignore'), connection_id) else: peering_name = connection_id From 402bbfb5cd4b7256e93aacc070468660dcf27123 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:17:00 -0500 Subject: [PATCH 38/90] [secmonkey] Explicitly set export filename (#571) Type: generic-bugfix Why is this change necessary? Some browsers/environments download export file as plaintext, without .csv file extension. This change addresses the need by: Explicitly setting the exported filename to 'security-monkey-items.csv' as part of the response header. Potential Side Effects: No known side effects --- security_monkey/export/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/security_monkey/export/__init__.py b/security_monkey/export/__init__.py index 390415617..3bbb4d891 100644 --- a/security_monkey/export/__init__.py +++ b/security_monkey/export/__init__.py @@ -84,7 +84,8 @@ def export_items(): values.append('"{val}"'.format(val=val)) out += ",".join(values) + "\n" - return Response(out, mimetype='text/csv') + return Response(out, mimetype='text/csv', + headers={"Content-disposition": "attachment; filename=security-monkey-items.csv"}) @export_blueprint.route("/export/issues") @@ -166,4 +167,5 @@ def export_issues(): values.append('"{val}"'.format(val=val)) out += ",".join(values) + "\n" - return Response(out, mimetype='text/csv') + return Response(out, mimetype='text/csv', + headers={"Content-disposition": "attachment; filename=security-monkey-items.csv"}) From efac2d44d1095d43729934c0a7eca6158d626037 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:17:27 -0500 Subject: [PATCH 39/90] [secmonkey] Fix minor watcher bugs (#572) Type: generic-bugfix Why is this change necessary? Several watchers had minor defects which caused them to fail in various situations This change addresses the need by: Handling the errors Potential Side Effects: No known side effects --- security_monkey/watchers/ec2/ec2_instance.py | 2 +- security_monkey/watchers/elb.py | 15 ++++++++++----- security_monkey/watchers/iam/iam_ssl.py | 10 ++++++---- security_monkey/watchers/vpc/peering.py | 2 +- security_monkey/watchers/vpc/route_table.py | 5 ++++- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/security_monkey/watchers/ec2/ec2_instance.py b/security_monkey/watchers/ec2/ec2_instance.py index cf9b4d2ba..18675003c 100644 --- a/security_monkey/watchers/ec2/ec2_instance.py +++ b/security_monkey/watchers/ec2/ec2_instance.py @@ -74,7 +74,7 @@ def slurp_items(**kwargs): for tag in instance.get('Tags'): if tag['Key'] == 'Name': name = tag['Value'] - break + break instance_id = instance['InstanceId'] if name is None: diff --git a/security_monkey/watchers/elb.py b/security_monkey/watchers/elb.py index ec791e439..df82a5c4d 100644 --- a/security_monkey/watchers/elb.py +++ b/security_monkey/watchers/elb.py @@ -118,15 +118,20 @@ def slurp(self): account_db = Account.query.filter(Account.name == account).first() account_number = account_db.identifier - self._setup_botocore(account) + try: + self._setup_botocore(account) + except Exception as e: + self.slurp_exception((self.index, account), e, exception_map) + continue + for region in regions(): app.logger.debug("Checking {}/{}/{}".format(self.index, account, region.name)) - elb_conn = connect(account, 'ec2.elb', region=region.name) + try: + elb_conn = connect(account, 'ec2.elb', region=region.name) - botocore_client = self.botocore_session.create_client('elb', region_name=region.name) - botocore_operation = botocore_client.describe_load_balancer_policies + botocore_client = self.botocore_session.create_client('elb', region_name=region.name) + botocore_operation = botocore_client.describe_load_balancer_policies - try: all_elbs = [] marker = None diff --git a/security_monkey/watchers/iam/iam_ssl.py b/security_monkey/watchers/iam/iam_ssl.py index 1eed8f740..0af8fb56e 100644 --- a/security_monkey/watchers/iam/iam_ssl.py +++ b/security_monkey/watchers/iam/iam_ssl.py @@ -127,9 +127,10 @@ def cert_get_cn(cert): :param cert: :return: Common name or None """ - return cert.subject.get_attributes_for_oid( - x509.OID_COMMON_NAME - )[0].value.strip() + cn = cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME) + if len(cn) > 0: + return cn[0].value.strip() + return '' def cert_is_san(cert): @@ -155,7 +156,8 @@ def cert_is_wildcard(cert): if len(domains) == 1 and domains[0][0:1] == "*": return True - if cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)[0].value[0:1] == "*": + cn = cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME) + if len(cn) > 0 and cn[0].value[0:1] == "*": return True diff --git a/security_monkey/watchers/vpc/peering.py b/security_monkey/watchers/vpc/peering.py index f89be0c59..666a8c9f2 100644 --- a/security_monkey/watchers/vpc/peering.py +++ b/security_monkey/watchers/vpc/peering.py @@ -88,7 +88,7 @@ def slurp_items(**kwargs): "name": peering_name, "status": peering['Status'], "accepter_vpc_info": peering['AccepterVpcInfo'], - "expiration_time": peering.get('ExpirationTime'), + "expiration_time": str(peering.get('ExpirationTime')), "requester_vpc_info": peering['RequesterVpcInfo'], "vpc_peering_connection_id": peering['VpcPeeringConnectionId'] } diff --git a/security_monkey/watchers/vpc/route_table.py b/security_monkey/watchers/vpc/route_table.py index 65080198f..0f2e803bf 100644 --- a/security_monkey/watchers/vpc/route_table.py +++ b/security_monkey/watchers/vpc/route_table.py @@ -68,7 +68,10 @@ def slurp_items(**kwargs): for route_table in all_route_tables: tags = route_table.get('Tags', {}) - joined_tags = {i['Key']: i['Value'] for i in tags} + joined_tags = {} + for tag in tags: + if tag.get('Key') and tag.get('Value'): + joined_tags[tag['Key']] = tag['Value'] subnet_name = joined_tags.get('Name') if subnet_name: From 7a11855a197f6df7290b67cc64270f1661c78bc3 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:18:22 -0500 Subject: [PATCH 40/90] [secmonkey] Set user role via SSO profile (#576) Type: generic-feature Why is this change necessary? Current SSO implementation adds all SSO users with View role. This change addresses the need by: Supporting group app configurations that will check SSO user profile for 'groups' setting to determine initial role for user, if it's the first time they're logging in. Potential Side Effects: No known side effects --- env-config/config-deploy.py | 2 + security_monkey/sso/service.py | 38 ++++++++++++ security_monkey/sso/views.py | 30 +--------- .../tests/core/test_sso_service.py | 59 +++++++++++++++++++ 4 files changed, 102 insertions(+), 27 deletions(-) create mode 100644 security_monkey/tests/core/test_sso_service.py diff --git a/env-config/config-deploy.py b/env-config/config-deploy.py index fa794e48b..c016cde57 100644 --- a/env-config/config-deploy.py +++ b/env-config/config-deploy.py @@ -117,11 +117,13 @@ PING_USER_API_URL = '' # Often something ending in idp/userinfo.openid PING_JWKS_URL = '' # Often something ending in JWKS PING_SECRET = '' # Provided by your administrator +PING_DEFAULT_ROLE = 'View' GOOGLE_CLIENT_ID = '' GOOGLE_AUTH_ENDPOINT = '' GOOGLE_SECRET = '' # GOOGLE_HOSTED_DOMAIN = 'example.com' # Verify that token issued by comes from domain +GOOGLE_DEFAULT_ROLE = 'View' ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address diff --git a/security_monkey/sso/service.py b/security_monkey/sso/service.py index 79ef24cb3..01e375aea 100644 --- a/security_monkey/sso/service.py +++ b/security_monkey/sso/service.py @@ -86,3 +86,41 @@ def on_identity_loaded(sender, identity): identity.provides.add(RoleNeed(role.name)) g.user = user + + +def setup_user(email, groups=[], default_role='View'): + from security_monkey import app, db + + user = User.query.filter(User.email == email).first() + + if default_role: + role = default_role + else: + role = 'View' + + if groups: + if app.config.get('ADMIN_GROUP') and app.config.get('ADMIN_GROUP') in groups: + role = 'Admin' + elif app.config.get('JUSTIFY_GROUP') and app.config.get('JUSTIFY_GROUP') in groups: + role = 'Justify' + elif app.config.get('VIEW_GROUP') and app.config.get('VIEW_GROUP') in groups: + role = 'View' + + # if we get an sso user create them an account + if not user: + user = User( + email=email, + active=True, + role=role + ) + db.session.add(user) + db.session.commit() + db.session.refresh(user) + + if user.role != role: + user.role = role + db.session.add(user) + db.session.commit() + db.session.refresh(user) + + return user diff --git a/security_monkey/sso/views.py b/security_monkey/sso/views.py index aef04d5d7..40b6b473e 100644 --- a/security_monkey/sso/views.py +++ b/security_monkey/sso/views.py @@ -22,7 +22,7 @@ except ImportError: onelogin_import_success = False -from .service import fetch_token_header_payload, get_rsa_public_key +from .service import fetch_token_header_payload, get_rsa_public_key, setup_user from security_monkey.datastore import User from security_monkey import db, rbac @@ -124,19 +124,7 @@ def post(self): r = requests.get(user_api_url, params=user_params) profile = r.json() - user = User.query.filter(User.email==profile['email']).first() - - # if we get an sso user create them an account - if not user: - user = User( - email=profile['email'], - active=True, - role='View' - # profile_picture=profile.get('thumbnailPhotoUrl') - ) - db.session.add(user) - db.session.commit() - db.session.refresh(user) + user = setup_user(profile.get('email'), profile.get('groups', []), current_app.config.get('PING_DEFAULT_ROLE')) # Tell Flask-Principal the identity changed identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) @@ -217,19 +205,7 @@ def post(self): r = requests.get(people_api_url, headers=headers) profile = r.json() - user = User.query.filter(User.email == profile['email']).first() - - # if we get an sso user create them an account - if not user: - user = User( - email=profile['email'], - active=True, - role='View' - # profile_picture=profile.get('thumbnailPhotoUrl') - ) - db.session.add(user) - db.session.commit() - db.session.refresh(user) + user = setup_user(profile.get('email'), profile.get('groups', []), current_app.config.get('GOOGLE_DEFAULT_ROLE')) # Tell Flask-Principal the identity changed identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) diff --git a/security_monkey/tests/core/test_sso_service.py b/security_monkey/tests/core/test_sso_service.py new file mode 100644 index 000000000..a6553168a --- /dev/null +++ b/security_monkey/tests/core/test_sso_service.py @@ -0,0 +1,59 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.core.test_sso_service + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.sso.service import setup_user +from security_monkey.datastore import User +from security_monkey import db + + +class SSOServiceTestCase(SecurityMonkeyTestCase): + def test_create_user(self): + existing_user = User( + email='test@test.com', + active=True, + role='View' + ) + db.session.add(existing_user) + db.session.commit() + db.session.refresh(existing_user) + + user1 = setup_user('test@test.com') + self.assertEqual(existing_user.id, user1.id) + self.assertEqual(existing_user.role, user1.role) + + user2 = setup_user('test2@test.com') + self.assertEqual(user2.email, 'test2@test.com') + self.assertEqual(user2.role, 'View') + + self.app.config.update( + ADMIN_GROUP='test_admin_group', + JUSTIFY_GROUP='test_justify_group', + VIEW_GROUP='test_view_group' + ) + admin_user = setup_user('admin@test.com', ['test_admin_group']) + justify_user = setup_user('justifier@test.com', ['test_justify_group']) + view_user = setup_user('viewer@test.com', ['test_view_group']) + + self.assertEqual(admin_user.role, 'Admin') + self.assertEqual(justify_user.role, 'Justify') + self.assertEqual(view_user.role, 'View') From c3b9d2003cecb19b63cff31cb2d0774c54109009 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:19:06 -0500 Subject: [PATCH 41/90] [secmonkey] Split check_access_keys method (#569) Type: generic-feature Why is this change necessary? This is required in order to allow overriding scores for each of these issues. This change addresses the need by: Splitting the check_access_keys method into 2 separate check_ methods, one for each issue. Potential Side Effects: None --- security_monkey/auditors/iam/iam_user.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/security_monkey/auditors/iam/iam_user.py b/security_monkey/auditors/iam/iam_user.py index d3a0ea9cd..024f8663f 100644 --- a/security_monkey/auditors/iam/iam_user.py +++ b/security_monkey/auditors/iam/iam_user.py @@ -46,16 +46,26 @@ def prep_for_audit(self): then = now - datetime.timedelta(days=90) self.ninety_days_ago = then.replace(tzinfo=tz.gettz('UTC')) - def check_access_keys(self, iamuser_item): + def check_active_access_keys(self, iamuser_item): """ alert when an IAM User has an active access key. + score: 1 """ akeys = iamuser_item.config.get('AccessKeys', {}) for akey in akeys: if 'Status' in akey: if akey['Status'] == 'Active': self.add_issue(1, 'User has active accesskey.', iamuser_item, notes=akey['AccessKeyId']) - else: + + def check_inactive_access_keys(self, iamuser_item): + """ + alert when an IAM User has an inactive access key. + score: 0 + """ + akeys = iamuser_item.config.get('AccessKeys', {}) + for akey in akeys: + if 'Status' in akey: + if akey['Status'] != 'Active': self.add_issue(0, 'User has an inactive accesskey.', iamuser_item, notes=akey['AccessKeyId']) def check_access_key_rotation(self, iamuser_item): From 06a75e396a683d25b4492e823c9766807e231f9b Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:21:29 -0500 Subject: [PATCH 42/90] =?UTF-8?q?[secmonkey]=20Convert=20watchers=20to=20b?= =?UTF-8?q?oto3=20(#566)=20=F0=9F=98=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type: generic-bugfix Why is this change necessary? There is a known defect with boto that returns a None connection for us-east-2 (https://github.com/boto/boto/issues/3629) This change addresses the need by: Moves affected watchers to boto3 connection. Potential Side Effects: None --- .../tests/watchers/vpc/test_subnet.py | 48 +++++++ security_monkey/watchers/elastic_ip.py | 31 +++-- .../watchers/elasticsearch_service.py | 93 ++++++------- security_monkey/watchers/keypair.py | 17 ++- .../watchers/rds/rds_security_group.py | 49 +++---- security_monkey/watchers/security_group.py | 129 +++++++++--------- security_monkey/watchers/vpc/subnet.py | 36 ++--- 7 files changed, 226 insertions(+), 177 deletions(-) create mode 100644 security_monkey/tests/watchers/vpc/test_subnet.py diff --git a/security_monkey/tests/watchers/vpc/test_subnet.py b/security_monkey/tests/watchers/vpc/test_subnet.py new file mode 100644 index 000000000..186352c1e --- /dev/null +++ b/security_monkey/tests/watchers/vpc/test_subnet.py @@ -0,0 +1,48 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.watchers.vpc.test_subnet + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.tests.watchers import SecurityMonkeyWatcherTestCase +from security_monkey.watchers.vpc.subnet import Subnet + +import boto +from moto import mock_sts, mock_ec2 +from freezegun import freeze_time + + +class SubnetWatcherTestCase(SecurityMonkeyWatcherTestCase): + + @freeze_time("2017-02-13 12:00:00") + @mock_sts + @mock_ec2 + def test_slurp(self): + conn = boto.connect_vpc('the_key', 'the secret') + vpc = conn.create_vpc("10.0.0.0/16") + + conn.create_subnet(vpc.id, "10.0.0.0/18") + + watcher = Subnet(accounts=[self.account.name]) + item_list, exception_map = watcher.slurp() + + self.assertIs( + expr1=len(item_list), + expr2=1, + msg="Watcher should have 1 item but has {}".format(len(item_list))) diff --git a/security_monkey/watchers/elastic_ip.py b/security_monkey/watchers/elastic_ip.py index 6c6f69866..cfac12bb9 100644 --- a/security_monkey/watchers/elastic_ip.py +++ b/security_monkey/watchers/elastic_ip.py @@ -61,13 +61,13 @@ def slurp(self): app.logger.debug("Checking {}/{}/{}".format(self.index, account, region.name)) try: - rec2 = connect(account, 'ec2', region=region) + rec2 = connect(account, 'boto3.ec2.client', region=region) el_ips = self.wrap_aws_rate_limited_call( - rec2.get_all_addresses + rec2.describe_addresses ) # Retrieve account tags to later match assigned EIP to instance tags = self.wrap_aws_rate_limited_call( - rec2.get_all_tags + rec2.describe_tags ) except Exception as e: if region.name not in TROUBLE_REGIONS: @@ -77,13 +77,14 @@ def slurp(self): continue app.logger.debug("Found {} {}".format(len(el_ips), self.i_am_plural)) - for ip in el_ips: + for ip in el_ips['Addresses']: - if self.check_ignore_list(str(ip.public_ip)): + if self.check_ignore_list(str(ip['PublicIp'])): continue instance_name = None - instance_tags = [x.value for x in tags if x.name == "Name" and x.res_id == ip.instance_id] + instance_tags = [x['Value'] for x in tags['Tags'] if x['Key'] == "Name" and + x.get('ResourceId') == ip.get('InstanceId')] if instance_tags: (instance_name,) = instance_tags if self.check_ignore_list(instance_name): @@ -91,17 +92,17 @@ def slurp(self): item_config = { "assigned_to": instance_name, - "public_ip": ip.public_ip, - "instance_id": ip.instance_id, - "domain": ip.domain, - "allocation_id": ip.allocation_id, - "association_id": ip.association_id, - "network_interface_id": ip.network_interface_id, - "network_interface_owner_id": ip.network_interface_owner_id, - "private_ip_address": ip.private_ip_address + "public_ip": ip.get('PublicIp'), + "instance_id": ip.get('InstanceId'), + "domain": ip.get('Domain'), + "allocation_id": ip.get('AllocationId'), + "association_id": ip.get('AssociationId'), + "network_interface_id": ip.get('NetworkInterfaceId'), + "network_interface_owner_id": ip.get('NetworkInterfaceOwnerId'), + "private_ip_address": ip.get('PrivateIpAddress') } - ip_label = "{0}".format(ip.public_ip) + ip_label = "{0}".format(ip.get('PublicIp')) item = ElasticIPItem(region=region.name, account=account, name=ip_label, config=item_config) item_list.append(item) diff --git a/security_monkey/watchers/elasticsearch_service.py b/security_monkey/watchers/elasticsearch_service.py index 8353db0b6..e11df9453 100644 --- a/security_monkey/watchers/elasticsearch_service.py +++ b/security_monkey/watchers/elasticsearch_service.py @@ -21,14 +21,12 @@ """ import json -from security_monkey.constants import TROUBLE_REGIONS -from security_monkey.exceptions import BotoConnectionIssue +from security_monkey.decorators import record_exception +from security_monkey.decorators import iter_account_region from security_monkey.watcher import Watcher, ChangeItem from security_monkey.datastore import Account from security_monkey import app -import boto3 - class ElasticSearchService(Watcher): index = 'elasticsearchservice' @@ -47,72 +45,63 @@ def slurp(self): """ self.prep_for_slurp() - item_list = [] - exception_map = {} - for account in self.accounts: - account_db = Account.query.filter(Account.name == account).first() - account_number = account_db.identifier - - for region in boto3.session.Session().get_available_regions(service_name="es"): - try: - if region in TROUBLE_REGIONS: - continue - - (client, domains) = self.get_all_es_domains_in_region(account, region) - except Exception as e: - if region not in TROUBLE_REGIONS: - exc = BotoConnectionIssue(str(e), self.index, account, region) - self.slurp_exception((self.index, account, region), exc, exception_map, - source="{}-watcher".format(self.index)) + @iter_account_region(index=self.index, accounts=self.accounts, service_name='es') + def slurp_items(**kwargs): + item_list = [] + exception_map = {} + kwargs['exception_map'] = exception_map + + account_db = Account.query.filter(Account.name == kwargs['account_name']).first() + account_num = account_db.identifier + + + (client, domains) = self.get_all_es_domains_in_region(**kwargs) + + app.logger.debug("Found {} {}".format(len(domains), ElasticSearchService.i_am_plural)) + for domain in domains: + if self.check_ignore_list(domain["DomainName"]): continue - app.logger.debug("Found {} {}".format(len(domains), ElasticSearchService.i_am_plural)) - for domain in domains: - if self.check_ignore_list(domain["DomainName"]): - continue + # Fetch the policy: + item = self.build_item(domain["DomainName"], client, account_num, **kwargs) - # Fetch the policy: - item = self.build_item(domain["DomainName"], client, region, account, account_number, - exception_map) - if item: - item_list.append(item) + if item: + item_list.append(item) - return item_list, exception_map + return item_list, exception_map + return slurp_items() - def get_all_es_domains_in_region(self, account, region): + @record_exception() + def get_all_es_domains_in_region(self, **kwargs): from security_monkey.common.sts_connect import connect - client = connect(account, "boto3.es.client", region=region) - app.logger.debug("Checking {}/{}/{}".format(ElasticSearchService.index, account, region)) + client = connect(kwargs['account_name'], "boto3.es.client", region=kwargs['region']) + app.logger.debug("Checking {}/{}/{}".format(ElasticSearchService.index, kwargs['account_name'], kwargs['region'])) # No need to paginate according to: client.can_paginate("list_domain_names") domains = self.wrap_aws_rate_limited_call(client.list_domain_names)["DomainNames"] return client, domains - def build_item(self, domain, client, region, account, account_number, exception_map): + @record_exception() + def build_item(self, domain, client, account_num, **kwargs): arn = 'arn:aws:es:{region}:{account_number}:domain/{domain_name}'.format( - region=region, - account_number=account_number, + region=kwargs['region'], + account_number=account_num, domain_name=domain) config = { 'arn': arn } - try: - domain_config = self.wrap_aws_rate_limited_call(client.describe_elasticsearch_domain_config, - DomainName=domain) - # Does the cluster have a policy? - if domain_config["DomainConfig"]["AccessPolicies"]["Options"] == "": - config['policy'] = {} - else: - config['policy'] = json.loads(domain_config["DomainConfig"]["AccessPolicies"]["Options"]) - config['name'] = domain - - except Exception as e: - self.slurp_exception((domain, account, region), e, exception_map, source="{}-watcher".format(self.index)) - return None - - return ElasticSearchServiceItem(region=region, account=account, name=domain, arn=arn, config=config) + domain_config = self.wrap_aws_rate_limited_call(client.describe_elasticsearch_domain_config, + DomainName=domain) + # Does the cluster have a policy? + if domain_config["DomainConfig"]["AccessPolicies"]["Options"] == "": + config['policy'] = {} + else: + config['policy'] = json.loads(domain_config["DomainConfig"]["AccessPolicies"]["Options"]) + config['name'] = domain + + return ElasticSearchServiceItem(region=kwargs['region'], account=kwargs['account_name'], name=domain, arn=arn, config=config) class ElasticSearchServiceItem(ChangeItem): diff --git a/security_monkey/watchers/keypair.py b/security_monkey/watchers/keypair.py index 68a818f9f..ce58812f5 100644 --- a/security_monkey/watchers/keypair.py +++ b/security_monkey/watchers/keypair.py @@ -64,9 +64,9 @@ def slurp(self): app.logger.debug("Checking {}/{}/{}".format(Keypair.index, account, region.name)) try: - rec2 = connect(account, 'ec2', region=region) + rec2 = connect(account, 'boto3.ec2.client', region=region) kps = self.wrap_aws_rate_limited_call( - rec2.get_all_key_pairs + rec2.describe_key_pairs ) except Exception as e: if region.name not in TROUBLE_REGIONS: @@ -76,21 +76,20 @@ def slurp(self): continue app.logger.debug("Found {} {}".format(len(kps), Keypair.i_am_plural)) - for kp in kps: - - if self.check_ignore_list(kp.name): + for kp in kps['KeyPairs']: + if self.check_ignore_list(kp['KeyName']): continue arn = 'arn:aws:ec2:{region}:{account_number}:key-pair/{name}'.format( region=region.name, account_number=account_number, - name=kp.name) + name=kp["KeyName"]) - item_list.append(KeypairItem(region=region.name, account=account, name=kp.name, arn=arn, + item_list.append(KeypairItem(region=region.name, account=account, name=kp["KeyName"], arn=arn, config={ - 'fingerprint': kp.fingerprint, + 'fingerprint': kp["KeyFingerprint"], 'arn': arn, - 'name': kp.name + 'name': kp["KeyName"] })) return item_list, exception_map diff --git a/security_monkey/watchers/rds/rds_security_group.py b/security_monkey/watchers/rds/rds_security_group.py index fbcfdfdfa..ba949c8c2 100644 --- a/security_monkey/watchers/rds/rds_security_group.py +++ b/security_monkey/watchers/rds/rds_security_group.py @@ -37,17 +37,21 @@ def __init__(self, accounts=None, debug=False): def get_all_dbsecurity_groups(self, **kwargs): from security_monkey.common.sts_connect import connect sgs = [] - rds = connect(kwargs['account_name'], 'rds', region=kwargs['region'], + rds = connect(kwargs['account_name'], 'boto3.rds.client', region=kwargs['region'], assumed_role=kwargs['assumed_role']) marker = None while True: - response = self.wrap_aws_rate_limited_call( - rds.get_all_dbsecurity_groups, marker=marker) + if marker: + response = self.wrap_aws_rate_limited_call( + rds.describe_db_security_groups, Marker=marker) + else: + response = self.wrap_aws_rate_limited_call( + rds.describe_db_security_groups) - sgs.extend(response) - if response.marker: - marker = response.marker + sgs.extend(response.get('DBSecurityGroups', [])) + if response.get('Marker'): + marker = response.get('Marker') else: break return sgs @@ -74,47 +78,44 @@ def slurp_items(**kwargs): app.logger.debug("Found {} {}".format( len(sgs), self.i_am_plural)) for sg in sgs: - if self.check_ignore_list(sg.name): + name = sg.get('DBSecurityGroupName') + if self.check_ignore_list(name): continue - name = sg.name vpc_id = None if hasattr(sg, 'VpcId'): - vpc_id = sg.VpcId - name = "{} (in {})".format(sg.name, vpc_id) + vpc_id = sg.get('VpcId') + name = "{} (in {})".format(name, vpc_id) item_config = { - "name": sg.name, - "description": sg.description, - "owner_id": sg.owner_id, + "name": name, + "description": sg.get('DBSecurityGroupDescription'), + "owner_id": sg.get('OwnerId'), "region": kwargs['region'], "ec2_groups": [], "ip_ranges": [], "vpc_id": vpc_id } - for ipr in sg.ip_ranges: + for ipr in sg.get('IPRanges'): ipr_config = { - "cidr_ip": ipr.cidr_ip, - "status": ipr.status, + "cidr_ip": ipr.get('CIDRIP'), + "status": ipr.get('Status'), } item_config["ip_ranges"].append(ipr_config) item_config["ip_ranges"] = sorted(item_config["ip_ranges"]) - for ec2_sg in sg.ec2_groups: + for ec2_sg in sg.get('EC2SecurityGroups'): ec2sg_config = { - "name": ec2_sg.name, - "owner_id": ec2_sg.owner_id, - "Status": ec2_sg.Status, + "name": ec2_sg.get('EC2SecurityGroupName'), + "owner_id": ec2_sg.get('EC2SecurityGroupOwnerId'), + "Status": ec2_sg.get('Status'), } item_config["ec2_groups"].append(ec2sg_config) item_config["ec2_groups"] = sorted( item_config["ec2_groups"]) - arn = 'arn:aws:rds:{region}:{account_number}:secgrp:{name}'.format( - region=kwargs["region"], - account_number=kwargs["account_number"], - name=name) + arn = sg.get('DBSecurityGroupArn') item_config['arn'] = arn diff --git a/security_monkey/watchers/security_group.py b/security_monkey/watchers/security_group.py index e8831924c..6e04cd8b2 100644 --- a/security_monkey/watchers/security_group.py +++ b/security_monkey/watchers/security_group.py @@ -47,6 +47,32 @@ def get_detail_level(self): else: return 'NONE' + def _build_rule(self, rule, rule_type): + rule_config = { + "ip_protocol": rule.get('IpProtocol'), + "rule_type": rule_type, + "from_port": rule.get('FromPort'), + "to_port": rule.get('ToPort'), + } + + ips = rule.get('IpRanges') + if ips and len(ips) > 0: + rule_config['cidr_ip'] = ips[0].get('CidrIp') + else: + rule_config['cidr_ip'] = None + + user_id_group_pairs = rule.get('UserIdGroupPairs') + if user_id_group_pairs and len(user_id_group_pairs) > 0: + rule_config['owner_id'] = user_id_group_pairs[0].get('UserId') + rule_config['group_id'] = user_id_group_pairs[0].get('GroupId') + rule_config['name'] = user_id_group_pairs[0].get('GroupName') + else: + rule_config['owner_id'] = None + rule_config['group_id'] = None + rule_config['name'] = None + + return rule_config + def slurp(self): """ :returns: item_list - list of Security Groups. @@ -76,20 +102,20 @@ def slurp(self): app.logger.debug("Checking {}/{}/{}".format(self.index, account, region.name)) try: - rec2 = connect(account, 'ec2', region=region) + rec2 = connect(account, 'boto3.ec2.client', region=region) # Retrieve security groups here sgs = self.wrap_aws_rate_limited_call( - rec2.get_all_security_groups + rec2.describe_security_groups ) if self.get_detail_level() != 'NONE': # We fetch tags here to later correlate instances tags = self.wrap_aws_rate_limited_call( - rec2.get_all_tags + rec2.describe_tags ) # Retrieve all instances instances = self.wrap_aws_rate_limited_call( - rec2.get_only_instances + rec2.describe_instances ) app.logger.info("Number of instances found in region {}: {}".format(region.name, len(instances))) except Exception as e: @@ -105,98 +131,79 @@ def slurp(self): app.logger.info("Creating mapping of sg_id's to instances") # map sgid => instance sg_instances = {} - for instance in instances: - for group in instance.groups: - if group.id not in sg_instances: - sg_instances[group.id] = [instance] - else: - sg_instances[group.id].append(instance) + for reservation in instances['Reservations']: + for instance in reservation['Instances']: + for group in instance['SecurityGroups']: + if group['GroupId'] not in sg_instances: + sg_instances[group['GroupId']] = [instance] + else: + sg_instances[group['GroupId']].append(instance) app.logger.info("Creating mapping of instance_id's to tags") # map instanceid => tags instance_tags = {} - for tag in tags: - if tag.res_id not in instance_tags: - instance_tags[tag.res_id] = [tag] + for tag in tags['Tags']: + if tag['ResourceId'] not in instance_tags: + instance_tags[tag['ResourceId']] = [tag] else: - instance_tags[tag.res_id].append(tag) + instance_tags[tag['ResourceId']].append(tag) app.logger.info("Done creating mappings") - for sg in sgs: + for sg in sgs['SecurityGroups']: - if self.check_ignore_list(sg.name): + if self.check_ignore_list(sg['GroupName']): continue arn = 'arn:aws:ec2:{region}:{account_number}:security-group/{security_group_id}'.format( region=region.name, account_number=account_number, - security_group_id=sg.id) + security_group_id=sg['GroupId']) item_config = { - "id": sg.id, - "name": sg.name, - "description": sg.description, - "vpc_id": sg.vpc_id, - "owner_id": sg.owner_id, - "region": sg.region.name, + "id": sg['GroupId'], + "name": sg['GroupName'], + "description": sg.get('Description'), + "vpc_id": sg.get('VpcId'), + "owner_id": sg.get('OwnerId'), + "region": region.name, "rules": [], "assigned_to": None, "arn": arn } - for rule in sg.rules: - for grant in rule.grants: - rule_config = { - "ip_protocol": rule.ip_protocol, - "rule_type": "ingress", - "from_port": rule.from_port, - "to_port": rule.to_port, - "cidr_ip": grant.cidr_ip, - "group_id": grant.group_id, - "name": grant.name, - "owner_id": grant.owner_id - } - item_config['rules'].append(rule_config) - - for rule in sg.rules_egress: - for grant in rule.grants: - rule_config = { - "ip_protocol": rule.ip_protocol, - "rule_type": "egress", - "from_port": rule.from_port, - "to_port": rule.to_port, - "cidr_ip": grant.cidr_ip, - "group_id": grant.group_id, - "name": grant.name, - "owner_id": grant.owner_id - } - item_config['rules'].append(rule_config) + + for rule in sg['IpPermissions']: + item_config['rules'].append(self._build_rule(rule, "ingress")) + + for rule in sg['IpPermissionsEgress']: + item_config['rules'].append(self._build_rule(rule, "egress")) + item_config['rules'] = sorted(item_config['rules']) if self.get_detail_level() == 'SUMMARY': - if sg.id in sg_instances: - item_config["assigned_to"] = "{} instances".format(len(sg_instances[sg.id])) + if sg['InstanceId'] in sg_instances: + item_config["assigned_to"] = "{} instances".format(len(sg_instances[sg['GroupId']])) else: item_config["assigned_to"] = "0 instances" elif self.get_detail_level() == 'FULL': assigned_to = [] - if sg.id in sg_instances: - for instance in sg_instances[sg.id]: - if instance.id in instance_tags: - tagdict = {tag.name: tag.value for tag in instance_tags[instance.id]} - tagdict["instance_id"] = instance.id + if sg['GroupId'] in sg_instances: + for instance in sg_instances[sg['GroupId']]: + if instance['InstanceId'] in instance_tags: + tagdict = {tag['Key']: tag['Value'] for tag in instance_tags[instance['InstanceId']]} + tagdict["instance_id"] = instance['InstanceId'] else: - tagdict = {"instance_id": instance.id} + tagdict = {"instance_id": instance['InstanceId']} assigned_to.append(tagdict) item_config["assigned_to"] = assigned_to # Issue 40: Security Groups can have a name collision between EC2 and # VPC or between different VPCs within a given region. - if sg.vpc_id: - sg_name = "{0} ({1} in {2})".format(sg.name, sg.id, sg.vpc_id) + if sg.get('VpcId'): + sg_name = "{0} ({1} in {2})".format(sg['GroupName'], sg['GroupId'], sg['VpcId']) else: - sg_name = "{0} ({1})".format(sg.name, sg.id) + sg_name = "{0} ({1})".format(sg['GroupName'], sg['GroupId']) item = SecurityGroupItem(region=region.name, account=account, name=sg_name, arn=arn, config=item_config) item_list.append(item) diff --git a/security_monkey/watchers/vpc/subnet.py b/security_monkey/watchers/vpc/subnet.py index b5715f6a4..98211d0be 100644 --- a/security_monkey/watchers/vpc/subnet.py +++ b/security_monkey/watchers/vpc/subnet.py @@ -36,11 +36,11 @@ def __init__(self, accounts=None, debug=False): @record_exception() def get_all_subnets(self, **kwargs): from security_monkey.common.sts_connect import connect - conn = connect(kwargs['account_name'], 'vpc', region=kwargs['region'], + conn = connect(kwargs['account_name'], 'boto3.ec2.client', region=kwargs['region'], assumed_role=kwargs['assumed_role']) - all_subnets = self.wrap_aws_rate_limited_call(conn.get_all_subnets) - return all_subnets + all_subnets = self.wrap_aws_rate_limited_call(conn.describe_subnets) + return all_subnets.get('Subnets') def slurp(self): """ @@ -64,11 +64,15 @@ def slurp_items(**kwargs): for subnet in all_subnets: - subnet_name = subnet.tags.get(u'Name', None) + subnet_name = None + for tag in subnet.get('Tags', []): + if tag.get('Key') == 'Name': + subnet_name = tag.get('Value') + subnet_id = subnet.get('SubnetId') if subnet_name: - subnet_name = "{0} ({1})".format(subnet_name, subnet.id) + subnet_name = "{0} ({1})".format(subnet_name, subnet_id) else: - subnet_name = subnet.id + subnet_name = subnet_id if self.check_ignore_list(subnet_name): continue @@ -76,23 +80,23 @@ def slurp_items(**kwargs): arn = 'arn:aws:ec2:{region}:{account_number}:subnet/{subnet_id}'.format( region=kwargs["region"], account_number=kwargs["account_number"], - subnet_id=subnet.id) + subnet_id=subnet_id) config = { - "name": subnet.tags.get(u'Name', None), + "name": subnet_name, "arn": arn, - "id": subnet.id, - "cidr_block": subnet.cidr_block, - "availability_zone": subnet.availability_zone, + "id": subnet_id, + "cidr_block": subnet.get('CidrBlock'), + "availability_zone": subnet.get('AvailabilityZone'), # TODO: # available_ip_address_count is likely to change often # and should be in the upcoming ephemeral section. # "available_ip_address_count": subnet.available_ip_address_count, - "defaultForAz": subnet.defaultForAz, - "mapPublicIpOnLaunch": subnet.mapPublicIpOnLaunch, - "state": subnet.state, - "tags": dict(subnet.tags), - "vpc_id": subnet.vpc_id + "defaultForAz": subnet.get('DefaultForAz'), + "mapPublicIpOnLaunch": subnet.get('MapPublicIpOnLaunch'), + "state": subnet.get('State'), + "tags": subnet.get('Tags'), + "vpc_id": subnet.get('VpcId') } item = SubnetItem(region=kwargs['region'], From 4c179ad4ee9d29c3382afe8157ae6950f917f311 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:36:13 -0500 Subject: [PATCH 43/90] [secmonkey] Replace ELBAuditor DB query with support watcher (#568) Why is this change necessary? DB query will perform a very inefficient table scan on the item table which could slow down DB performance when the item table gets large. It is also not the best solution because the security group may not be in the database. This change addresses the need by: A better solution would be to add the security group results as a support watcher and find the security group out of the in memory list. Potential Side Effects: None --- security_monkey/auditors/elb.py | 40 ++++++++++++++++----------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/security_monkey/auditors/elb.py b/security_monkey/auditors/elb.py index 5990ba3e8..23cd9014b 100644 --- a/security_monkey/auditors/elb.py +++ b/security_monkey/auditors/elb.py @@ -24,6 +24,7 @@ from security_monkey.common.utils import check_rfc_1918 from security_monkey.datastore import NetworkWhitelistEntry from security_monkey.datastore import Item +from security_monkey.watchers.security_group import SecurityGroup import ipaddr @@ -127,6 +128,7 @@ class ELBAuditor(Auditor): i_am_singular = ELB.i_am_singular i_am_plural = ELB.i_am_plural network_whitelist = [] + support_watcher_indexes = [SecurityGroup.index] def __init__(self, accounts=None, debug=False): super(ELBAuditor, self).__init__(accounts=accounts, debug=debug) @@ -152,26 +154,24 @@ def check_internet_scheme(self, elb_item): # Grab each attached security group and determine if they contain # a public IP security_groups = elb_item.config.get('security_groups', []) + sg_items = self.get_watcher_support_items(SecurityGroup.index, elb_item.account) for sgid in security_groups: - # shouldn't be more than one with that ID. - sg = Item.query.filter(Item.name.ilike('%'+sgid+'%')).first() - if not sg: - # It's possible that the security group is new and not yet in the DB. - continue - - sg_cidrs = [] - config = sg.revisions[0].config - for rule in config.get('rules', []): - cidr = rule.get('cidr_ip', '') - if rule.get('rule_type', None) == 'ingress' and cidr: - if not check_rfc_1918(cidr) and not self._check_inclusion_in_network_whitelist(cidr): - sg_cidrs.append(cidr) - if sg_cidrs: - notes = 'SG [{sgname}] via [{cidr}]'.format( - sgname=sg.name, - cidr=', '.join(sg_cidrs) - ) - self.add_issue(1, 'VPC ELB is Internet accessible.', elb_item, notes=notes) + for sg in sg_items: + if sg.config.get('id') == sgid: + sg_cidrs = [] + for rule in sg.config.get('rules', []): + cidr = rule.get('cidr_ip', '') + if rule.get('rule_type', None) == 'ingress' and cidr: + if not _check_rfc_1918(cidr) and not self._check_inclusion_in_network_whitelist(cidr): + sg_cidrs.append(cidr) + + if sg_cidrs: + notes = 'SG [{sgname}] via [{cidr}]'.format( + sgname=sg.name, + cidr=', '.join(sg_cidrs) + ) + self.add_issue(1, 'VPC ELB is Internet accessible.', elb_item, notes=notes) + break def check_listener_reference_policy(self, elb_item): """ @@ -295,5 +295,3 @@ def _process_custom_listener_policy(self, policy, port, elb_item): if cipher in NOTRECOMMENDED_CIPHERS: c_notes = "{0} - {1}".format(notes, cipher) self.add_issue(10, "Cipher Not Recommended.", elb_item, notes=c_notes) - - From 573c8f94d8c3512a38139c26da910fe5ddc7d84a Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:45:14 -0500 Subject: [PATCH 44/90] Reduce AWS managed policy audit noise (#567) Why is this change necessary? Currently, audits are performed against AWS created managed policies. These checks are not meaningful unless another resource is using the policy This change addresses the need by: Only performing policy auditor checks on AWS policies if there is a resource attached Potential Side Effects: No known side effects --- .../auditors/iam/managed_policy.py | 82 ++++++++------ .../tests/auditors/test_managed_policy.py | 100 ++++++++++++++++++ 2 files changed, 151 insertions(+), 31 deletions(-) create mode 100644 security_monkey/tests/auditors/test_managed_policy.py diff --git a/security_monkey/auditors/iam/managed_policy.py b/security_monkey/auditors/iam/managed_policy.py index a37d7e71a..9d84825ab 100644 --- a/security_monkey/auditors/iam/managed_policy.py +++ b/security_monkey/auditors/iam/managed_policy.py @@ -22,6 +22,21 @@ from security_monkey.watchers.iam.managed_policy import ManagedPolicy from security_monkey.auditors.iam.iam_policy import IAMPolicyAuditor +def is_aws_managed_policy(iam_obj): + if 'arn:aws:iam::aws:policy/' in iam_obj.config['arn']: + return True + else: + return False + +def has_attached_resources(iam_obj): + if iam_obj.config['attached_users'] and len(iam_obj.config['attached_users']) > 0: + return True + elif iam_obj.config['attached_roles'] and len(iam_obj.config['attached_roles']) > 0: + return True + elif iam_obj.config['attached_groups'] and len(iam_obj.config['attached_groups']) > 0: + return True + else: + return False class ManagedPolicyAuditor(IAMPolicyAuditor): index = ManagedPolicy.index @@ -41,42 +56,46 @@ def check_star_privileges(self, iam_object): """ alert when an IAM Object has a policy allowing '*'. """ - self.library_check_iamobj_has_star_privileges( - iam_object, - policies_key='policy', - multiple_policies=False - ) + if not is_aws_managed_policy(iam_object) or (is_aws_managed_policy(iam_object) and has_attached_resources(iam_object)): + self.library_check_iamobj_has_star_privileges( + iam_object, + policies_key='policy', + multiple_policies=False + ) def check_iam_star_privileges(self, iam_object): """ alert when an IAM Object has a policy allowing 'iam:*'. """ - self.library_check_iamobj_has_iam_star_privileges( - iam_object, - policies_key='policy', - multiple_policies=False - ) + if not is_aws_managed_policy(iam_object) or (is_aws_managed_policy(iam_object) and has_attached_resources(iam_object)): + self.library_check_iamobj_has_iam_star_privileges( + iam_object, + policies_key='policy', + multiple_policies=False + ) def check_iam_privileges(self, iam_object): """ alert when an IAM Object has a policy allowing 'iam:XxxxxXxxx'. """ - self.library_check_iamobj_has_iam_privileges( - iam_object, - policies_key='policy', - multiple_policies=False - ) + if not is_aws_managed_policy(iam_object) or (is_aws_managed_policy(iam_object) and has_attached_resources(iam_object)): + self.library_check_iamobj_has_iam_privileges( + iam_object, + policies_key='policy', + multiple_policies=False + ) def check_iam_passrole(self, iam_object): """ alert when an IAM Object has a policy allowing 'iam:PassRole'. This allows the object to pass any role specified in the resource block to an ec2 instance. """ - self.library_check_iamobj_has_iam_passrole( - iam_object, - policies_key='policy', - multiple_policies=False - ) + if not is_aws_managed_policy(iam_object) or (is_aws_managed_policy(iam_object) and has_attached_resources(iam_object)): + self.library_check_iamobj_has_iam_passrole( + iam_object, + policies_key='policy', + multiple_policies=False + ) def check_notaction(self, iam_object): """ @@ -84,19 +103,20 @@ def check_notaction(self, iam_object): NotAction combined with an "Effect": "Allow" often provides more privilege than is desired. """ - self.library_check_iamobj_has_notaction( - iam_object, - policies_key='policy', - multiple_policies=False - ) + if not is_aws_managed_policy(iam_object) or (is_aws_managed_policy(iam_object) and has_attached_resources(iam_object)): + self.library_check_iamobj_has_notaction( + iam_object, + policies_key='policy', + multiple_policies=False + ) def check_security_group_permissions(self, iam_object): """ alert when an IAM Object has ec2:AuthorizeSecurityGroupEgress or ec2:AuthorizeSecurityGroupIngress. """ - self.library_check_iamobj_has_security_group_permissions( - iam_object, - policies_key='policy', - multiple_policies=False - ) - + if not is_aws_managed_policy(iam_object) or (is_aws_managed_policy(iam_object) and has_attached_resources(iam_object)): + self.library_check_iamobj_has_security_group_permissions( + iam_object, + policies_key='policy', + multiple_policies=False + ) diff --git a/security_monkey/tests/auditors/test_managed_policy.py b/security_monkey/tests/auditors/test_managed_policy.py new file mode 100644 index 000000000..3be050398 --- /dev/null +++ b/security_monkey/tests/auditors/test_managed_policy.py @@ -0,0 +1,100 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.auditors.test_managed_policy + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + +""" +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.auditors.iam.managed_policy import ManagedPolicyAuditor +from security_monkey.watchers.iam.managed_policy import ManagedPolicyItem + + +FULL_ADMIN_POLICY_BARE = """ +{ + "Statement": { + "Effect": "Allow", + "Action": "*" + } +} +""" + + +class ManagedPolicyAuditorTestCase(SecurityMonkeyTestCase): + + def test_issue_on_non_aws_policy(self): + import json + + config = { + 'policy': json.loads(FULL_ADMIN_POLICY_BARE), + 'arn': 'arn:aws:iam::123456789:policy/TEST', + 'attached_users': [], + 'attached_roles': [], + 'attached_groups': [] + } + + auditor = ManagedPolicyAuditor(accounts=['unittest']) + policyobj = ManagedPolicyItem(account="TEST_ACCOUNT", name="policy_test", config=config) + + self.assertIs(len(policyobj.audit_issues), 0, + "Managed Policy should have 0 alert but has {}".format(len(policyobj.audit_issues))) + + auditor.check_star_privileges(policyobj) + self.assertIs(len(policyobj.audit_issues), 1, + "Managed Policy should have 1 alert but has {}".format(len(policyobj.audit_issues))) + + def test_issue_on_aws_policy_no_attachments(self): + import json + + config = { + 'policy': json.loads(FULL_ADMIN_POLICY_BARE), + 'arn': 'arn:aws:iam::aws:policy/TEST', + 'attached_users': [], + 'attached_roles': [], + 'attached_groups': [] + } + + auditor = ManagedPolicyAuditor(accounts=['unittest']) + policyobj = ManagedPolicyItem(account="TEST_ACCOUNT", name="policy_test", config=config) + + self.assertIs(len(policyobj.audit_issues), 0, + "Managed Policy should have 0 alert but has {}".format(len(policyobj.audit_issues))) + + auditor.check_star_privileges(policyobj) + self.assertIs(len(policyobj.audit_issues), 0, + "Managed Policy should have 0 alerts but has {}".format(len(policyobj.audit_issues))) + + def test_issue_on_aws_policy_with_attachment(self): + import json + + config = { + 'policy': json.loads(FULL_ADMIN_POLICY_BARE), + 'arn': 'arn:aws:iam::aws:policy/TEST', + 'attached_users': [], + 'attached_roles': ['arn:aws:iam::123456789:role/TEST'], + 'attached_groups': [] + } + + auditor = ManagedPolicyAuditor(accounts=['unittest']) + policyobj = ManagedPolicyItem(account="TEST_ACCOUNT", name="policy_test", config=config) + + self.assertIs(len(policyobj.audit_issues), 0, + "Managed Policy should have 0 alert but has {}".format(len(policyobj.audit_issues))) + + auditor.check_star_privileges(policyobj) + self.assertIs(len(policyobj.audit_issues), 1, + "Managed Policy should have 1 alert but has {}".format(len(policyobj.audit_issues))) From 9cf987e2f5370fcff1d74a4ed97e175a23c37469 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:51:59 -0500 Subject: [PATCH 45/90] Add support for custom watcher and auditor alerters (#570) This change allows users to customize how they report auditor and watcher changes during both reporter runs and forced runs of `audit_changes` and `find_changes`. --- docs/misc.rst | 103 ++++++++++++++++++++- manage.py | 1 + security_monkey/alerters/__init__.py | 13 +++ security_monkey/alerters/custom_alerter.py | 45 +++++++++ security_monkey/auditor.py | 2 + security_monkey/datastore.py | 3 +- security_monkey/watcher.py | 5 +- 7 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 security_monkey/alerters/__init__.py create mode 100644 security_monkey/alerters/custom_alerter.py diff --git a/docs/misc.rst b/docs/misc.rst index 32436b23a..176ef43e0 100644 --- a/docs/misc.rst +++ b/docs/misc.rst @@ -4,7 +4,7 @@ Miscellaneous Force Audit ----------- -Sometimes you will want to force an audit even though there is no configuration +Sometimes you will want to force an audit even though there is no configuration change in AWS resources. For instance when you change a whitelist or add a 3rd party account, configuration @@ -13,7 +13,7 @@ will not be audited again until the daily check at 10am. In this case, you can force an audit by running: .. code-block:: bash - + export SECURITY_MONKEY_SETTINGS=/usr/local/src/security_monkey/env-config/config-deploy.py python manage.py audit_changes -m s3 @@ -98,3 +98,102 @@ add_override_score (for a single score) and add_override_scores (from a csv file the check method in question. As such, if account pattern scores of different account fields are entered for a single check method there is a possibility of unpredictable results and it is recommended that only a single field is selected for defining patterns. + + +Custom Alerters +--------------- + +Adding a custom alerter class allows users to add their own alerting anytime changes are found in watchers or auditors. +The functionality in the `alerter.py` module send emails only when the reporter is finished running. The custom alerter +reports are triggered when manually running `find_changes` and `audit_changes` as well as when the reporter runs. + +A sample customer alerter would be a `SplunkAlerter` module that logs watcher and auditor changes to be ingested into Splunk: + +.. code-block:: python + + class SplunkAlerter(object): + __metaclass__ = AlerterType + + def report_watcher_changes(self, watcher): + """ + Collect change summaries from watchers defined logs them + """ + """ + Logs created, changed and deleted items for Splunk consumption. + """ + + for item in watcher.created_items: + app.splunk_logger.info( + "action=\"Item created\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\"".format( + item.db_item.id, + item.index, + item.account, + item.region, + item.name)) + + for item in watcher.changed_items: + app.splunk_logger.info( + "action=\"Item changed\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\"".format( + item.db_item.id, + item.index, + item.account, + item.region, + item.name)) + + for item in watcher.deleted_items: + app.splunk_logger.info( + "action=\"Item deleted\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\"".format( + item.db_item.id, + item.index, + item.account, + item.region, + item.name)) + + def report_auditor_changes(self, items): + for item in items: + for issue in item.confirmed_new_issues: + app.splunk_logger.info( + "action=\"Issue created\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\" " + "issue=\"{}\"".format( + issue.id, + item.index, + item.account, + item.region, + item.name, + issue.issue)) + + for issue in item.confirmed_fixed_issues: + app.splunk_logger.info( + "action=\"Issue fixed\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\" " + "issue=\"{}\"".format( + issue.id, + item.index, + item.account, + item.region, + item.name, + issue.issue)) diff --git a/manage.py b/manage.py index aa57d09d7..a7a54cb3b 100644 --- a/manage.py +++ b/manage.py @@ -47,6 +47,7 @@ migrate = Migrate(app, db) manager.add_command('db', MigrateCommand) +find_modules('alerters') find_modules('watchers') find_modules('auditors') load_plugins('security_monkey.plugins') diff --git a/security_monkey/alerters/__init__.py b/security_monkey/alerters/__init__.py new file mode 100644 index 000000000..d6db0b8c6 --- /dev/null +++ b/security_monkey/alerters/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/security_monkey/alerters/custom_alerter.py b/security_monkey/alerters/custom_alerter.py new file mode 100644 index 000000000..9ea5d5505 --- /dev/null +++ b/security_monkey/alerters/custom_alerter.py @@ -0,0 +1,45 @@ +# Copyright 2016 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.alerters.custom_alerter + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey import app + +alerter_registry = [] + + +class AlerterType(type): + + def __init__(cls, name, bases, attrs): + if getattr(cls, "report_auditor_changes", None) and getattr(cls, "report_watcher_changes", None): + app.logger.debug("Registering alerter %s", cls.__name__) + alerter_registry.append(cls) + + +def report_auditor_changes(items): + for alerter_class in alerter_registry: + alerter = alerter_class() + alerter.report_auditor_changes(items) + + +def report_watcher_changes(watcher): + for alerter_class in alerter_registry: + alerter = alerter_class() + alerter.report_watcher_changes(watcher) diff --git a/security_monkey/auditor.py b/security_monkey/auditor.py index e54f195db..5ce8437b0 100644 --- a/security_monkey/auditor.py +++ b/security_monkey/auditor.py @@ -29,6 +29,7 @@ from security_monkey.datastore import User, AuditorSettings, Item, ItemAudit, Technology, Account, ItemAuditScore, AccountPatternAuditScore from security_monkey.common.utils import send_email from security_monkey.account_manager import get_account_by_name +from security_monkey.alerters.custom_alerter import report_auditor_changes from sqlalchemy import and_ from collections import defaultdict @@ -294,6 +295,7 @@ def save_issues(self): db.session.commit() self._create_auditor_settings() + report_auditor_changes(self) def email_report(self, report): """ diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index 2054e0ae5..b2be78e74 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -236,7 +236,7 @@ class Item(db.Model): cloudtrail_entries = relationship("CloudTrailEntry", backref="item", cascade="all, delete, delete-orphan", order_by="CloudTrailEntry.event_time") issues = relationship("ItemAudit", backref="item", cascade="all, delete, delete-orphan", foreign_keys="ItemAudit.item_id") exceptions = relationship("ExceptionLogs", backref="item", cascade="all, delete, delete-orphan") - + @hybrid_property def score(self): return db.session.query( @@ -628,6 +628,7 @@ def store(self, ctype, region, account, name, active_flag, config, arn=None, new db.session.commit() self._set_latest_revision(item) + return item def _set_latest_revision(self, item): latest_revision = item.revisions.first() diff --git a/security_monkey/watcher.py b/security_monkey/watcher.py index eebe44076..9a0bf6931 100644 --- a/security_monkey/watcher.py +++ b/security_monkey/watcher.py @@ -16,6 +16,8 @@ from security_monkey.datastore import Account, IgnoreListEntry from security_monkey.datastore import Technology, WatcherConfig, store_exception from security_monkey.common.jinja import get_jinja_env +from security_monkey.common.utils import find_modules +from security_monkey.alerters.custom_alerter import report_watcher_changes from boto.exception import BotoServerError import time @@ -381,6 +383,7 @@ def save(self): app.logger.info("{} changed {} in {}".format(len(self.changed_items), self.i_am_plural, self.accounts)) for item in self.changed_items: item.save(self.datastore) + report_watcher_changes(self) def plural_name(self): """ @@ -502,7 +505,7 @@ def save(self, datastore, ephemeral=False): Save the item """ app.logger.debug("Saving {}/{}/{}/{}\n\t{}".format(self.index, self.account, self.region, self.name, self.new_config)) - datastore.store( + self.db_item = datastore.store( self.index, self.region, self.account, From 95a65730b67f9f1847d14f8cd7a49bd3f9304728 Mon Sep 17 00:00:00 2001 From: kalpatel01 Date: Wed, 8 Mar 2017 00:54:08 -0500 Subject: [PATCH 46/90] [secmonkey] Add functionality to clean up stale issues (#575) Type: generic-feature JIRA Ticket: CVA-1222 Why is this change necessary? In some instances such an an account pattern change or removal of an auditor, issues are never cleared because the auditor that created them never gets run against the account. This change addresses the need by: Adding cleanup methods to be run at build time Potential Side Effects: No known side effects Change-Id: I2dc4b24d2a898acb9beb85ba0cd91ad112b0a253 --- manage.py | 12 ++ security_monkey/account_manager.py | 1 + security_monkey/common/audit_issue_cleanup.py | 80 ++++++++++ security_monkey/datastore.py | 2 +- .../tests/core/test_audit_issue_cleanup.py | 146 ++++++++++++++++++ security_monkey/views/account.py | 3 + 6 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 security_monkey/common/audit_issue_cleanup.py create mode 100644 security_monkey/tests/core/test_audit_issue_cleanup.py diff --git a/manage.py b/manage.py index a7a54cb3b..6d0838126 100644 --- a/manage.py +++ b/manage.py @@ -198,6 +198,9 @@ def add_account(number, third_party, name, s3_name, active, notes, account_type, account_manager = account_registry.get(account_type)() account = account_manager.lookup_account_by_identifier(number) if account: + from security_monkey.common.audit_issue_cleanup import clean_account_issues + clean_account_issues(account) + if force: account_manager.update(account.id, account_type, name, active, third_party, notes, number, @@ -525,6 +528,15 @@ def add_watcher_config(tech_name, disabled, interval): db.session.close() +@manager.command +def clean_stale_issues(): + """ + Cleans up issues for auditors that have been removed + """ + from security_monkey.common.audit_issue_cleanup import clean_stale_issues + clean_stale_issues() + + class APIServer(Command): def __init__(self, host='127.0.0.1', port=app.config.get('API_PORT'), workers=6): self.address = "{}:{}".format(host, port) diff --git a/security_monkey/account_manager.py b/security_monkey/account_manager.py index 9291e2ae4..a69d5c053 100644 --- a/security_monkey/account_manager.py +++ b/security_monkey/account_manager.py @@ -89,6 +89,7 @@ def update(self, account_id, account_type, name, active, third_party, notes, db.session.commit() db.session.refresh(account) account = self._load(account) + db.session.expunge(account) return account def create(self, account_type, name, active, third_party, notes, identifier, diff --git a/security_monkey/common/audit_issue_cleanup.py b/security_monkey/common/audit_issue_cleanup.py new file mode 100644 index 000000000..e2ab33e37 --- /dev/null +++ b/security_monkey/common/audit_issue_cleanup.py @@ -0,0 +1,80 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.common.audit_issue_cleanup + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" + +from security_monkey.auditor import auditor_registry +from security_monkey.datastore import AuditorSettings, Account, Technology, Datastore +from security_monkey.watcher import ChangeItem +from security_monkey import app, db + + +existing_auditor_classes = {} +for key in auditor_registry: + for auditor in auditor_registry[key]: + existing_auditor_classes[auditor.__name__] = auditor + + +def clean_stale_issues(): + results = AuditorSettings.query.filter().all() + for settings in results: + if settings.auditor_class is None or settings.auditor_class not in existing_auditor_classes: + app.logger.info("Cleaning up issues for removed auditor %s", settings.auditor_class) + _delete_issues(settings) + + db.session.commit() + + +def clean_account_issues(account): + results = AuditorSettings.query.filter(AuditorSettings.account_id == account.id).all() + for settings in results: + auditor_class = existing_auditor_classes.get(settings.auditor_class) + if auditor_class: + if not auditor_class([account.name]).applies_to_account(account): + app.logger.info("Cleaning up %s issues for %s", settings.auditor_class, account.name) + _delete_issues(settings) + + db.session.commit() + + +def _delete_issues(settings): + account = Account.query.filter(Account.id == settings.account_id).first() + tech = Technology.query.filter(Technology.id == settings.tech_id).first() + if account and tech: + # Report issues as fixed + db_items = Datastore().get_all_ctype_filtered(tech=tech.name, account=account.name, include_inactive=False) + items = [] + for item in db_items: + new_item = ChangeItem(index=tech.name, + region=item.region, + account=account.name, + name=item.name, + arn=item.arn) + new_item.audit_issues = [] + new_item.db_item = item + items.append(new_item) + + for item in items: + for issue in item.db_item.issues: + if issue.auditor_setting_id == settings.id: + item.confirmed_fixed_issues.append(issue) + + db.session.delete(settings) diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index b2be78e74..a805225f0 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -210,7 +210,7 @@ class AuditorSettings(db.Model): disabled = Column(Boolean(), nullable=False) issue_text = Column(String(512), nullable=True) auditor_class = Column(String(128)) - issues = relationship("ItemAudit", backref="auditor_setting") + issues = relationship("ItemAudit", backref="auditor_setting", cascade="all, delete, delete-orphan") tech_id = Column(Integer, ForeignKey("technology.id"), index=True) account_id = Column(Integer, ForeignKey("account.id"), index=True) unique_const = UniqueConstraint('account_id', 'issue_text', 'tech_id') diff --git a/security_monkey/tests/core/test_audit_issue_cleanup.py b/security_monkey/tests/core/test_audit_issue_cleanup.py new file mode 100644 index 000000000..94b385d63 --- /dev/null +++ b/security_monkey/tests/core/test_audit_issue_cleanup.py @@ -0,0 +1,146 @@ +# Copyright 2017 Bridgewater Associates +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.core.test_audit_issue_cleanup + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Bridgewater OSS + + +""" +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey.auditor import Auditor +from security_monkey.datastore import Account, AccountType, Technology +from security_monkey.datastore import Item, ItemAudit, AuditorSettings +from security_monkey.auditor import auditor_registry +from security_monkey import db, app + +from mock import patch +from collections import defaultdict + + +class MockAuditor(Auditor): + def __init__(self, accounts=None, debug=False): + super(MockAuditor, self).__init__(accounts=accounts, debug=debug) + + def applies_to_account(self, account): + return self.applies + + +test_auditor_registry = defaultdict(list) + + +auditor_configs = [ + { + 'type': 'MockAuditor1', + 'index': 'index1', + 'applies': True + }, + { + 'type': 'MockAuditor2', + 'index': 'index2', + 'applies': False + }, +] + +for config in auditor_configs: + auditor = type( + config['type'], (MockAuditor,), + { + 'applies': config['applies'] + } + ) + app.logger.debug(auditor.__name__) + + test_auditor_registry[config['index']].append(auditor) + + +class AuditIssueCleanupTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + account_type_result = AccountType.query.filter(AccountType.name == 'AWS').first() + if not account_type_result: + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(identifier="012345678910", name="testing", + account_type_id=account_type_result.id) + + self.technology = Technology(name="iamrole") + item = Item(region="us-west-2", name="testrole", + arn="arn:aws:iam::012345678910:role/testrole", technology=self.technology, + account=self.account) + + db.session.add(self.account) + db.session.add(self.technology) + db.session.add(item) + db.session.commit() + + @patch.dict(auditor_registry, test_auditor_registry, clear=True) + def test_clean_stale_issues(self): + from security_monkey.common.audit_issue_cleanup import clean_stale_issues + + items = Item.query.all() + assert len(items) == 1 + item = items[0] + item.issues.append(ItemAudit(score=1, issue='Test Issue', + auditor_setting=AuditorSettings(disabled=False, + technology=self.technology, + account=self.account, + auditor_class='MockAuditor1'))) + + item.issues.append(ItemAudit(score=1, issue='Issue with missing auditor', + auditor_setting=AuditorSettings(disabled=False, + technology=self.technology, + account=self.account, + auditor_class='MissingAuditor'))) + + db.session.commit() + + clean_stale_issues() + items = Item.query.all() + assert len(items) == 1 + item = items[0] + assert len(item.issues) == 1 + assert item.issues[0].issue == 'Test Issue' + + @patch.dict(auditor_registry, test_auditor_registry, clear=True) + def test_clean_account_issues(self): + from security_monkey.common.audit_issue_cleanup import clean_account_issues + + items = Item.query.all() + assert len(items) == 1 + item = items[0] + + item.issues.append(ItemAudit(score=1, issue='Test Issue 1', + auditor_setting=AuditorSettings(disabled=False, + technology=self.technology, + account=self.account, + auditor_class='MockAuditor1'))) + + item.issues.append(ItemAudit(score=1, issue='Test Issue 2', + auditor_setting=AuditorSettings(disabled=False, + technology=self.technology, + account=self.account, + auditor_class='MockAuditor2'))) + + db.session.commit() + + clean_account_issues(self.account) + items = Item.query.all() + assert len(items) == 1 + item = items[0] + assert len(item.issues) == 1 + assert item.issues[0].issue == 'Test Issue 1' diff --git a/security_monkey/views/account.py b/security_monkey/views/account.py index 4038c92ca..9a05d7fab 100644 --- a/security_monkey/views/account.py +++ b/security_monkey/views/account.py @@ -153,6 +153,9 @@ def put(self, account_id): if not account: return {'status': 'error. Account ID not found.'}, 404 + from security_monkey.common.audit_issue_cleanup import clean_account_issues + clean_account_issues(account) + marshaled_account = marshal(account.__dict__, ACCOUNT_FIELDS) marshaled_account['auth'] = self.auth_dict From 13c683ec37a67d91f5581185d0e74d4acdea5cfb Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Tue, 7 Mar 2017 02:01:54 +0000 Subject: [PATCH 47/90] GCP Docs: Changes to Quickstart to support GCP. * Made specific AWS and GCP headings * Added IAM and VM sections for GCP * fixed links, added service account link * linked to internal headings * some grammar changes and typo fixes --- docs/quickstart.rst | 88 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 2f4ecc384..a303bce55 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -2,8 +2,18 @@ Quick Start Guide ================= +Setup on AWS or GCP +=================== + +Security Monkey can run on an Amazon EC2 (AWS) instance or a Google Cloud Platform (GCP) instance (Google Cloud Platform). The only real difference in the installation is the IAM configuration and the bringup of the Virtual Machine that runs Security Monkey. + +AWS Configuration +===================== + +Below there are two options for configuring Security Monkey to run on AWS. See below for `GCP Configuration`_. + Docker Images -============= +------------- Before we start, consider following the `docker instructions `_ . Docker helps simplify the process to get up and running. The docker images are not currently ready for production use, but are good enough to get up and running with an instance of security_monkey. @@ -13,7 +23,7 @@ Local `docker instructions <./docker.html>`_ Not into the docker thing? Keep reading. Setup IAM Roles -=============== +---------------- We need to create two roles for security monkey. The first role will be an instance profile that we will launch security monkey into. The permissions @@ -21,7 +31,7 @@ on this role allow the monkey to use STS to assume to other roles as well as use SES to send email. Creating SecurityMonkeyInstanceProfile Role -------------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Create a new role and name it "SecurityMonkeyInstanceProfile": @@ -62,7 +72,7 @@ Review and create your new role: .. image:: images/resized_role_confirmation.png Creating SecurityMonkey Role ----------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Create a new role and name it "SecurityMonkey": @@ -196,7 +206,7 @@ Paste in this JSON with the name "SecurityMonkeyReadOnly": Review and create the new role. Allow SecurityMonkeyInstanceProfile to AssumeRole to SecurityMonkey -------------------------------------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You should now have two roles available in your AWS Console: @@ -227,7 +237,7 @@ Edit the Trust Relationship and paste this in: } Adding more accounts --------------------- +^^^^^^^^^^^^^^^^^^^^ To have your instance of security monkey monitor additional accounts, you must add a SecurityMonkey role in the new account. Follow the instructions above to create the new SecurityMonkey role. The Trust Relationship policy should have the account ID of the account where the security monkey instance is running. @@ -246,7 +256,7 @@ You will also need to add the new account in the Web UI, and restart the schedul Document how to setup an SES account and validate it. Launch an Ubuntu Instance -========================= +-------------------------- Netflix monitors dozens AWS accounts easily on a single m3.large instance. For this guide, we will launch a m1.small. @@ -271,14 +281,14 @@ You may now launch the new instance. Please take note of the "Public DNS" entry Now may also be a good time to edit the "launch-wizard-1" security group to restrict access to your IP. Make sure you leave TCP 22 open for ssh and TCP 443 for HTTPS. Keypair -------- +^^^^^^^ You may be prompted to download a keypair. You should protect this keypair; it is used to provide ssh access to the new instance. Put it in a safe place. You will need to change the permissions on the keypair to 400:: $ chmod 400 SecurityMonkeyKeypair.pem Connecting to your new instance: --------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ We will connect to the new instance over ssh:: @@ -286,6 +296,64 @@ We will connect to the new instance over ssh:: Replace the last parameter () with the Public IP of your instance. + +GCP configuration +============================== + +Below describes how to install Security Monkey on GCP. See the section on `AWS Configuration`_ to install on an EC2 instance. + +Install gcloud +--------------- + +If you haven't already, install *gcloud* from the downloads_ page. *gcloud* enables you to administer VMs, IAM policies, services and more from the command line. + +.. _downloads: https://cloud.google.com/sdk/downloads + +Foobar Setup Service Account +--------------------- + +To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. + +.. _`Service Account`: https://cloud.google.com/compute/docs/access/service-accounts + +Then, we'll launch an instance using that service account. +Navigate to the `Service Account page`_ for your project. + +.. _`Service Account page`: https://console.developers.google.com/iam-admin/serviceaccounts + +Click the *Create Service Account* button at the top of the screen. + +* **Service Account Name**: securitymonkey +* **Roles**: Security Reviewer, Storage Object Viewer +* **Tags**: Allow HTTPS traffic + +Then, click the *Create* button. + +Launch an Ubuntu Instance +---------------------- +Create an instance running Ubuntu 14.04 LTS using our 'securitymonkey' service account. + +Navigate to the `Create Instance page`_. Fill in the following fields: + +* **Name**: securitymonkey +* **Zone**: us-west1-b (or whatever zone you wish) +* **Machine Type**: 1vCPU, 3.75GB (minimum; also known as n1-standard-1) +* **Boot Disk**: Ubuntu 14.04 LTS +* **Service Account**: securitymonkey + +.. _`Create Instance page`: https://console.developers.google.com/compute/instancesAdd + +Click the *Create* button to create the instance. + +Connecting to your new instance: +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +We will connect to the new instance over ssh with the gcloud command:: + + $ gcloud compute ssh @ --zone us-west1-b + +Replace the first parameter () with the username you authenticated gcloud with. Replace the last parameter () with the Public IP of your instance. + Install Pre-requisites ====================== @@ -587,7 +655,7 @@ A few things need to be modified in this file before we move on. **SQLALCHEMY_DATABASE_URI**: The value above will be correct for the username "postgres" with the password "securitymonkeypassword" and the database name of "secmonkey". Please edit this line if you have created a different database name or username or password. -**FQDN**: You will need to enter the public DNS name you obtained when you launched the security monkey instance. +**FQDN**: You will need to enter the public DNS name you obtained when you launched the security monkey instance. For GCP, this is the IP address. **SECRET_KEY**: This is used by Flask modules to verify user sessions. Please use your own random string. (Keep it secret.) From 6b109712996a37a88a3117e63be0c4928be55178 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Wed, 8 Mar 2017 17:09:27 -0800 Subject: [PATCH 48/90] =?UTF-8?q?no=20foobar=20=F0=9F=8D=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index a303bce55..137f6ecf0 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -309,7 +309,7 @@ If you haven't already, install *gcloud* from the downloads_ page. *gcloud* ena .. _downloads: https://cloud.google.com/sdk/downloads -Foobar Setup Service Account +Setup Service Account --------------------- To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. From 20f6335a9596360f872cb914bf692a4a95a278c1 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Wed, 8 Mar 2017 23:58:52 -0800 Subject: [PATCH 49/90] SSO Role modifications (#592) * Making SSO User roles only impact new user creation so it doesn't require all users to update their config files. * Fixing sso tests --- security_monkey/sso/service.py | 35 ++++++++++++++-------------------- security_monkey/sso/views.py | 10 +++++----- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/security_monkey/sso/service.py b/security_monkey/sso/service.py index 01e375aea..3fda4d85b 100644 --- a/security_monkey/sso/service.py +++ b/security_monkey/sso/service.py @@ -88,16 +88,15 @@ def on_identity_loaded(sender, identity): g.user = user -def setup_user(email, groups=[], default_role='View'): +def setup_user(email, groups=None, default_role='View'): from security_monkey import app, db user = User.query.filter(User.email == email).first() + if user: + return user - if default_role: - role = default_role - else: - role = 'View' - + role = default_role + groups = groups or [] if groups: if app.config.get('ADMIN_GROUP') and app.config.get('ADMIN_GROUP') in groups: role = 'Admin' @@ -107,20 +106,14 @@ def setup_user(email, groups=[], default_role='View'): role = 'View' # if we get an sso user create them an account - if not user: - user = User( - email=email, - active=True, - role=role - ) - db.session.add(user) - db.session.commit() - db.session.refresh(user) - - if user.role != role: - user.role = role - db.session.add(user) - db.session.commit() - db.session.refresh(user) + user = User( + email=email, + active=True, + role=role + ) + + db.session.add(user) + db.session.commit() + db.session.refresh(user) return user diff --git a/security_monkey/sso/views.py b/security_monkey/sso/views.py index 40b6b473e..13e22a6b6 100644 --- a/security_monkey/sso/views.py +++ b/security_monkey/sso/views.py @@ -107,9 +107,6 @@ def post(self): # validate your token based on the key it was signed with try: - current_app.logger.debug(id_token) - current_app.logger.debug(secret) - current_app.logger.debug(algo) jwt.decode(id_token, secret.decode('utf-8'), algorithms=[algo], audience=client_id) except jwt.DecodeError: return dict(message='Token is invalid'), 403 @@ -124,7 +121,10 @@ def post(self): r = requests.get(user_api_url, params=user_params) profile = r.json() - user = setup_user(profile.get('email'), profile.get('groups', []), current_app.config.get('PING_DEFAULT_ROLE')) + user = setup_user( + profile.get('email'), + profile.get('groups', profile.get('googleGroups', [])), + current_app.config.get('PING_DEFAULT_ROLE', 'View')) # Tell Flask-Principal the identity changed identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) @@ -205,7 +205,7 @@ def post(self): r = requests.get(people_api_url, headers=headers) profile = r.json() - user = setup_user(profile.get('email'), profile.get('groups', []), current_app.config.get('GOOGLE_DEFAULT_ROLE')) + user = setup_user(profile.get('email'), profile.get('groups', []), current_app.config.get('GOOGLE_DEFAULT_ROLE', 'View')) # Tell Flask-Principal the identity changed identity_changed.send(current_app._get_current_object(), identity=Identity(user.id)) From c2e8b048c109aa835c7e7f345d7914c11f203cb8 Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Fri, 10 Mar 2017 18:37:26 +0000 Subject: [PATCH 50/90] GCP: fixed issue where client wasn't receiving user-specified credentials. * Created new function get_gcp_project_creds utility function build list of creds. * Updated existing GCP watchers. * Removed name_to_identifier utility function as it's no longer needed --- security_monkey/common/gcp/util.py | 34 ++++++++++++++++--- security_monkey/watchers/gcp/gce/firewall.py | 6 ++-- security_monkey/watchers/gcp/gce/network.py | 6 ++-- security_monkey/watchers/gcp/gcs/bucket.py | 6 ++-- .../watchers/gcp/iam/serviceaccount.py | 6 ++-- 5 files changed, 42 insertions(+), 16 deletions(-) diff --git a/security_monkey/common/gcp/util.py b/security_monkey/common/gcp/util.py index 65fe28422..787ccc378 100644 --- a/security_monkey/common/gcp/util.py +++ b/security_monkey/common/gcp/util.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.watchers.gcp.util +.. module: security_monkey.common.gcp.util :platform: Unix .. version:: $$VERSION$$ @@ -21,16 +21,42 @@ from security_monkey.datastore import Account from cloudaux.orchestration import modify as cloudaux_modify +def get_gcp_project_creds(account_names): + """ + Build list of dicts with project credentials. + + Takes a list of account names and fetches all of those accounts. + If the custom_field 'credentials_file' (custom field) is set, the list + item will be in the format of: {'project': 'my-project', 'key_file': 'my-key'} + Otherwise, it will be simply the project string. + + Returns a list containing strings or dictionaries with necessary credentials + for connecting to GCP. + + :param account_names: list of account names + :type account_names: ``list`` + + :return: list of dictionaries with project credentials + :rtype: ``list`` + """ + # The name of the field as defined in the GCP Account Manager. + creds_field = 'creds_file' + project_creds = [] -def identifiers_from_account_names(account_names): accounts = Account.query.filter(Account.name.in_(account_names)).all() - return [account.identifier for account in accounts] + for account in accounts: + key_file = account.getCustom(creds_field) + if key_file: + project_creds.append({'project': account.identifier, 'key_file': key_file}) + else: + project_creds.append(account.identifier) + + return project_creds def gcp_resource_id_builder(service, identifier, region=''): resource = 'gcp:%s:%s:%s' % (region, service, identifier) return resource.replace('/', ':').replace('.', ':') - def modify(d, format='camelized'): return cloudaux_modify(d, format=format) diff --git a/security_monkey/watchers/gcp/gce/firewall.py b/security_monkey/watchers/gcp/gce/firewall.py index 90fedfe58..daafe1c4a 100644 --- a/security_monkey/watchers/gcp/gce/firewall.py +++ b/security_monkey/watchers/gcp/gce/firewall.py @@ -18,7 +18,7 @@ .. version:: $$VERSION$$ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names, modify +from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder, modify from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -47,9 +47,9 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() - account_identifiers = identifiers_from_account_names(self.accounts) + project_creds = get_gcp_project_creds(self.accounts) - @iter_project(projects=account_identifiers) + @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] rules = list_firewall_rules(**kwargs) diff --git a/security_monkey/watchers/gcp/gce/network.py b/security_monkey/watchers/gcp/gce/network.py index 7f2e6c511..3f0308f10 100644 --- a/security_monkey/watchers/gcp/gce/network.py +++ b/security_monkey/watchers/gcp/gce/network.py @@ -19,7 +19,7 @@ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names +from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -48,9 +48,9 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() - account_identifiers = identifiers_from_account_names(self.accounts) + project_creds = get_gcp_project_creds(self.accounts) - @iter_project(projects=account_identifiers) + @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] networks = list_networks(**kwargs) diff --git a/security_monkey/watchers/gcp/gcs/bucket.py b/security_monkey/watchers/gcp/gcs/bucket.py index 1216fba7a..e22b441b4 100644 --- a/security_monkey/watchers/gcp/gcs/bucket.py +++ b/security_monkey/watchers/gcp/gcs/bucket.py @@ -19,7 +19,7 @@ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names +from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -48,9 +48,9 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() - account_identifiers = identifiers_from_account_names(self.accounts) + project_creds = get_gcp_project_creds(self.accounts) - @iter_project(projects=account_identifiers) + @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] buckets = list_buckets() diff --git a/security_monkey/watchers/gcp/iam/serviceaccount.py b/security_monkey/watchers/gcp/iam/serviceaccount.py index 1d8cd0ff0..144324392 100644 --- a/security_monkey/watchers/gcp/iam/serviceaccount.py +++ b/security_monkey/watchers/gcp/iam/serviceaccount.py @@ -19,7 +19,7 @@ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import gcp_resource_id_builder, identifiers_from_account_names +from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -48,9 +48,9 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() - account_identifiers = identifiers_from_account_names(self.accounts) + project_creds = get_gcp_project_creds(self.accounts) - @iter_project(projects=account_identifiers) + @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] service_accounts = list_serviceaccounts(**kwargs) From 790f5d2d507574273f96bf7d363cf3b8ca9cc156 Mon Sep 17 00:00:00 2001 From: Sergey Skripnick Date: Mon, 13 Mar 2017 17:56:40 +0200 Subject: [PATCH 51/90] Implement add_account for custom accounts --- manage.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/manage.py b/manage.py index e1582604c..491cd7b61 100644 --- a/manage.py +++ b/manage.py @@ -301,6 +301,41 @@ def load(self): FlaskApplication().run() +class AddAccount(Command): + + def __init__(self, account_manager, *args, **kwargs): + super(AddAccount, self).__init__(*args, **kwargs) + self._account_manager = account_manager + self.__doc__ = "Add %s account" % account_manager.account_type + + def get_options(self): + options = [ + Option('-n', '--name', type=unicode, required=True), + Option('--thirdparty', action='store_true'), + Option('--active', action='store_true'), + Option('--notes', type=unicode), + Option('--id', dest='identifier', type=unicode, required=True), + ] + for cf in self._account_manager.custom_field_configs: + options.append(Option('--%s' % cf.name, dest=cf.name, type=str)) + return options + + def handle(self, app, *args, **kwargs): + name = kwargs.pop('name') + active = kwargs.pop('active', False) + thirdparty = kwargs.pop('thirdparty', False) + notes = kwargs.pop('notes', u'') + identifier = kwargs.pop('identifier') + self._account_manager.create( + self._account_manager.account_type, + name, active, thirdparty, notes, identifier, + custom_fields=kwargs) + db.session.close() + + if __name__ == "__main__": + from security_monkey.account_manager import account_registry + for name, account_manager in account_registry.items(): + manager.add_command("add_account_%s" % name.lower(), AddAccount(account_manager())) manager.add_command("run_api_server", APIServer()) manager.run() From 9917ea1996c3c805d74c0e938d735c17c580d4bc Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Mon, 13 Mar 2017 21:16:04 +0000 Subject: [PATCH 52/90] GCP: fixed issue where bucket functions weren't receiving credentials --- security_monkey/watchers/gcp/gcs/bucket.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security_monkey/watchers/gcp/gcs/bucket.py b/security_monkey/watchers/gcp/gcs/bucket.py index e22b441b4..0d5b36a76 100644 --- a/security_monkey/watchers/gcp/gcs/bucket.py +++ b/security_monkey/watchers/gcp/gcs/bucket.py @@ -53,7 +53,7 @@ def slurp(self): @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] - buckets = list_buckets() + buckets = list_buckets(**kwargs) for bucket in buckets: resource_id = gcp_resource_id_builder( From 7186152e9c962099348b9e7f3acc3783e74d7a0d Mon Sep 17 00:00:00 2001 From: Carl Rutherford Date: Wed, 15 Mar 2017 14:09:27 +0000 Subject: [PATCH 53/90] Added permission for DescribeVpnGateways missing for #406 --- docs/configuration.rst | 1 + docs/quickstart.rst | 1 + scripts/secmonkey_role_setup.py | 1 + 3 files changed, 3 insertions(+) diff --git a/docs/configuration.rst b/docs/configuration.rst index e9845a41c..fb17779ed 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -98,6 +98,7 @@ SM-ReadOnly "ec2:describevpcendpoints", "ec2:describevpcpeeringconnections", "ec2:describevpcs", + "ec2:describevpngateways", "elasticloadbalancing:describeloadbalancerattributes", "elasticloadbalancing:describeloadbalancerpolicies", "elasticloadbalancing:describeloadbalancers", diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 137f6ecf0..c70b03a9f 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -122,6 +122,7 @@ Paste in this JSON with the name "SecurityMonkeyReadOnly": "ec2:describevpcendpoints", "ec2:describevpcpeeringconnections", "ec2:describevpcs", + "ec2:describevpngateways", "elasticloadbalancing:describeloadbalancerattributes", "elasticloadbalancing:describeloadbalancerpolicies", "elasticloadbalancing:describeloadbalancers", diff --git a/scripts/secmonkey_role_setup.py b/scripts/secmonkey_role_setup.py index 2b746c7a3..a5e72ec1a 100755 --- a/scripts/secmonkey_role_setup.py +++ b/scripts/secmonkey_role_setup.py @@ -83,6 +83,7 @@ "ec2:describevpcendpoints", "ec2:describevpcpeeringconnections", "ec2:describevpcs", + "ec2:describevpngateways", "elasticloadbalancing:describeloadbalancerattributes", "elasticloadbalancing:describeloadbalancerpolicies", "elasticloadbalancing:describeloadbalancers", From 99907afe48bb10d4ac40ed34d7a284f52fea0392 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Wed, 15 Mar 2017 19:46:31 +0000 Subject: [PATCH 54/90] Fixing reference to check_rfc_1918 --- security_monkey/auditors/elb.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security_monkey/auditors/elb.py b/security_monkey/auditors/elb.py index 23cd9014b..d6ed51a75 100644 --- a/security_monkey/auditors/elb.py +++ b/security_monkey/auditors/elb.py @@ -162,7 +162,7 @@ def check_internet_scheme(self, elb_item): for rule in sg.config.get('rules', []): cidr = rule.get('cidr_ip', '') if rule.get('rule_type', None) == 'ingress' and cidr: - if not _check_rfc_1918(cidr) and not self._check_inclusion_in_network_whitelist(cidr): + if not check_rfc_1918(cidr) and not self._check_inclusion_in_network_whitelist(cidr): sg_cidrs.append(cidr) if sg_cidrs: From 65d3a5e68abded2bd62d06d5846ba7f41148cb6a Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Thu, 9 Feb 2017 18:52:31 +0000 Subject: [PATCH 55/90] GCP Watchers/Auditors for Security Monkey --- security_monkey/watchers/gcp/gce/network.py | 1 + security_monkey/watchers/gcp/gcs/bucket.py | 1 + security_monkey/watchers/gcp/iam/serviceaccount.py | 1 + 3 files changed, 3 insertions(+) diff --git a/security_monkey/watchers/gcp/gce/network.py b/security_monkey/watchers/gcp/gce/network.py index 3f0308f10..49faced0a 100644 --- a/security_monkey/watchers/gcp/gce/network.py +++ b/security_monkey/watchers/gcp/gce/network.py @@ -48,6 +48,7 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() + project_creds = get_gcp_project_creds(self.accounts) @iter_project(projects=project_creds) diff --git a/security_monkey/watchers/gcp/gcs/bucket.py b/security_monkey/watchers/gcp/gcs/bucket.py index 0d5b36a76..82e9a9bf7 100644 --- a/security_monkey/watchers/gcp/gcs/bucket.py +++ b/security_monkey/watchers/gcp/gcs/bucket.py @@ -48,6 +48,7 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() + project_creds = get_gcp_project_creds(self.accounts) @iter_project(projects=project_creds) diff --git a/security_monkey/watchers/gcp/iam/serviceaccount.py b/security_monkey/watchers/gcp/iam/serviceaccount.py index 144324392..f073bdabc 100644 --- a/security_monkey/watchers/gcp/iam/serviceaccount.py +++ b/security_monkey/watchers/gcp/iam/serviceaccount.py @@ -48,6 +48,7 @@ def slurp(self): location of the exception and the value is the actual exception """ self.prep_for_slurp() + project_creds = get_gcp_project_creds(self.accounts) @iter_project(projects=project_creds) From 7c48405c26592228300ec36030288aac369730e5 Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Mon, 6 Mar 2017 19:17:02 +0000 Subject: [PATCH 56/90] GCP: support user_agent. --- security_monkey/common/gcp/config.py | 8 +++++++- security_monkey/common/gcp/util.py | 4 ++++ security_monkey/watchers/gcp/gce/firewall.py | 4 +++- security_monkey/watchers/gcp/gce/network.py | 4 +++- security_monkey/watchers/gcp/gcs/bucket.py | 4 +++- security_monkey/watchers/gcp/iam/serviceaccount.py | 4 +++- 6 files changed, 23 insertions(+), 5 deletions(-) diff --git a/security_monkey/common/gcp/config.py b/security_monkey/common/gcp/config.py index 34d8c86d7..cd011ab9e 100644 --- a/security_monkey/common/gcp/config.py +++ b/security_monkey/common/gcp/config.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """ -.. module: security_monkey.auditors.gcp.config +.. module: security_monkey.common.gcp.config :platform: Unix .. version:: $$VERSION$$ @@ -20,6 +20,12 @@ """ +class ApplicationConfig(object): + SECURITY_MONKEY_VERSION = '0.8.0' + + @staticmethod + def get_version(): + return ApplicationConfig.SECURITY_MONKEY_VERSION class AuditorConfig(object): """ diff --git a/security_monkey/common/gcp/util.py b/security_monkey/common/gcp/util.py index 787ccc378..eff7e40c7 100644 --- a/security_monkey/common/gcp/util.py +++ b/security_monkey/common/gcp/util.py @@ -60,3 +60,7 @@ def gcp_resource_id_builder(service, identifier, region=''): def modify(d, format='camelized'): return cloudaux_modify(d, format=format) + +def get_user_agent(**kwargs): + from security_monkey.common.gcp.config import ApplicationConfig as appconfig + return 'security-monkey/%s' % appconfig.get_version() diff --git a/security_monkey/watchers/gcp/gce/firewall.py b/security_monkey/watchers/gcp/gce/firewall.py index daafe1c4a..3e4d3870c 100644 --- a/security_monkey/watchers/gcp/gce/firewall.py +++ b/security_monkey/watchers/gcp/gce/firewall.py @@ -18,7 +18,7 @@ .. version:: $$VERSION$$ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder, modify +from security_monkey.common.gcp.util import get_gcp_project_creds, get_user_agent, gcp_resource_id_builder, modify from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -39,6 +39,7 @@ def __init__(self, accounts=None, debug=False): self.ephemeral_paths = [ "Etag", ] + self.user_agent = get_user_agent() def slurp(self): """ @@ -52,6 +53,7 @@ def slurp(self): @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] + kwargs['user_agent'] = self.user_agent rules = list_firewall_rules(**kwargs) for rule in rules: diff --git a/security_monkey/watchers/gcp/gce/network.py b/security_monkey/watchers/gcp/gce/network.py index 49faced0a..c71f8ba43 100644 --- a/security_monkey/watchers/gcp/gce/network.py +++ b/security_monkey/watchers/gcp/gce/network.py @@ -19,7 +19,7 @@ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder +from security_monkey.common.gcp.util import get_gcp_project_creds, get_user_agent, gcp_resource_id_builder from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -40,6 +40,7 @@ def __init__(self, accounts=None, debug=False): self.ephemeral_paths = [ "Etag", ] + self.user_agent = get_user_agent() def slurp(self): """ @@ -54,6 +55,7 @@ def slurp(self): @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] + kwargs['user_agent'] = self.user_agent networks = list_networks(**kwargs) for network in networks: diff --git a/security_monkey/watchers/gcp/gcs/bucket.py b/security_monkey/watchers/gcp/gcs/bucket.py index 82e9a9bf7..df85bc0bc 100644 --- a/security_monkey/watchers/gcp/gcs/bucket.py +++ b/security_monkey/watchers/gcp/gcs/bucket.py @@ -19,7 +19,7 @@ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder +from security_monkey.common.gcp.util import get_gcp_project_creds, get_user_agent, gcp_resource_id_builder from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -40,6 +40,7 @@ def __init__(self, accounts=None, debug=False): self.ephemeral_paths = [ "Etag", ] + self.user_agent = get_user_agent() def slurp(self): """ @@ -54,6 +55,7 @@ def slurp(self): @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] + kwargs['user_agent'] = self.user_agent buckets = list_buckets(**kwargs) for bucket in buckets: diff --git a/security_monkey/watchers/gcp/iam/serviceaccount.py b/security_monkey/watchers/gcp/iam/serviceaccount.py index f073bdabc..9b83704ac 100644 --- a/security_monkey/watchers/gcp/iam/serviceaccount.py +++ b/security_monkey/watchers/gcp/iam/serviceaccount.py @@ -19,7 +19,7 @@ .. moduleauthor:: Tom Melendez @supertom """ -from security_monkey.common.gcp.util import get_gcp_project_creds, gcp_resource_id_builder +from security_monkey.common.gcp.util import get_gcp_project_creds, get_user_agent, gcp_resource_id_builder from security_monkey.watcher import Watcher from security_monkey.watcher import ChangeItem @@ -40,6 +40,7 @@ def __init__(self, accounts=None, debug=False): self.ephemeral_paths = [ "Etag", ] + self.user_agent = get_user_agent() def slurp(self): """ @@ -54,6 +55,7 @@ def slurp(self): @iter_project(projects=project_creds) def slurp_items(**kwargs): item_list = [] + kwargs['user_agent'] = self.user_agent service_accounts = list_serviceaccounts(**kwargs) for service_account in service_accounts: From 45ffd836aeeaca2bf335f8528784b95fbc087e71 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 16 Mar 2017 14:03:49 -0700 Subject: [PATCH 57/90] Adding unique index to Technology Name. Updating Account Name to have Unique Index instead of Unique constraint. --- migrations/versions/1583a48cb978_.py | 30 ++++++++++++++++++++++++++++ security_monkey/datastore.py | 4 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 migrations/versions/1583a48cb978_.py diff --git a/migrations/versions/1583a48cb978_.py b/migrations/versions/1583a48cb978_.py new file mode 100644 index 000000000..14aeaf739 --- /dev/null +++ b/migrations/versions/1583a48cb978_.py @@ -0,0 +1,30 @@ +"""Adding Unique Index to TechName and AccountName + +Revision ID: 1583a48cb978 +Revises: c93f246859f7 +Create Date: 2017-03-16 14:01:37.463000 + +""" + +# revision identifiers, used by Alembic. +revision = '1583a48cb978' +down_revision = 'c93f246859f7' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('ix_account_name', 'account', ['name'], unique=True) + op.drop_constraint(u'account_name_key', 'account', type_='unique') + op.create_index('ix_technology_name', 'technology', ['name'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_technology_name', table_name='technology') + op.create_unique_constraint(u'account_name_key', 'account', ['name']) + op.drop_index('ix_account_name', table_name='account') + # ### end Alembic commands ### diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index a805225f0..3ee3acb7a 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -74,7 +74,7 @@ class Account(db.Model): id = Column(Integer, primary_key=True) active = Column(Boolean()) third_party = Column(Boolean()) - name = Column(String(32), unique=True) + name = Column(String(32), index=True, unique=True) notes = Column(String(256)) identifier = Column(String(256)) # Unique id of the account, the number for AWS. items = relationship("Item", backref="account", cascade="all, delete, delete-orphan") @@ -110,7 +110,7 @@ class Technology(db.Model): """ __tablename__ = 'technology' id = Column(Integer, primary_key=True) - name = Column(String(32)) # elb, s3, iamuser, iamgroup, etc. + name = Column(String(32), index=True, unique=True) # elb, s3, iamuser, iamgroup, etc. items = relationship("Item", backref="technology") issue_categories = relationship("AuditorSettings", backref="technology") ignore_items = relationship("IgnoreListEntry", backref="technology") From 74141f0de3a0d84af285509204028540dd912d04 Mon Sep 17 00:00:00 2001 From: Roman Vynar Date: Fri, 17 Mar 2017 00:03:42 +0200 Subject: [PATCH 58/90] Quick start improvements. --- docs/configuration.rst | 4 ++-- docs/misc.rst | 4 +++- docs/quickstart.rst | 28 +++++++++++++++------------- env-config/config-deploy.py | 2 +- 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index fb17779ed..01bfc8781 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -23,7 +23,7 @@ SecurityMonkeyInstanceProfile is the IAM role you will launch your instance with Here is are example polices for the SecurityMonkeyInstanceProfile: -SES-SendEmail +SES-SendEmail .. code-block:: python @@ -193,7 +193,7 @@ Below is an example policy: .. code-block:: python { - "Version": "2008-10-17", + "Version": "2012-10-17", "Statement": [ { "Sid": "", diff --git a/docs/misc.rst b/docs/misc.rst index 176ef43e0..5628f1ef2 100644 --- a/docs/misc.rst +++ b/docs/misc.rst @@ -50,7 +50,9 @@ Edit ``security_monkey/scheduler.py`` to change daily check schedule:: scheduler.add_cron_job(_audit_changes, hour=10, day_of_week="mon-fri", args=[account, auditors, True]) -Edit ``security_monkey/watcher.py`` to change check interval from every 15 minutes +Edit ``security_monkey/watcher.py`` to change check interval from every 15 minutes:: + + self.interval = 15 Overriding and Disabling Audit Checks diff --git a/docs/quickstart.rst b/docs/quickstart.rst index c70b03a9f..cd31e3799 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -313,12 +313,12 @@ If you haven't already, install *gcloud* from the downloads_ page. *gcloud* ena Setup Service Account --------------------- -To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. +To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. .. _`Service Account`: https://cloud.google.com/compute/docs/access/service-accounts Then, we'll launch an instance using that service account. -Navigate to the `Service Account page`_ for your project. +Navigate to the `Service Account page`_ for your project. .. _`Service Account page`: https://console.developers.google.com/iam-admin/serviceaccounts @@ -371,18 +371,14 @@ Add this to /etc/hosts: (Use nano if you're not familiar with vi.):: Create the logging folders:: sudo mkdir /var/log/security_monkey - sudo chown www-data /var/log/security_monkey sudo mkdir /var/www + sudo chown www-data /var/log/security_monkey sudo chown www-data /var/www - sudo touch /var/log/security_monkey/security_monkey.error.log - sudo touch /var/log/security_monkey/security_monkey.access.log - sudo touch /var/log/security_monkey/security_monkey-deploy.log - sudo chown www-data /var/log/security_monkey/security_monkey-deploy.log Let's install the tools we need for Security Monkey:: $ sudo apt-get update - $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev + $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev gcc Setup Postgres -------------- @@ -410,6 +406,11 @@ Next we'll clone and install the package:: cd security_monkey sudo python setup.py install +Fix ownership for python modules:: + + sudo usermod -a -G staff www-data + sudo chgrp staff /usr/local/lib/python2.7/dist-packages/*.egg + **New in 0.2.0** - Compile the web-app from the Dart code:: # Get the Google Linux package signing key. @@ -428,8 +429,9 @@ Next we'll clone and install the package:: sudo /usr/lib/dart/bin/pub build # Copy the compiled Web UI to the appropriate destination - sudo /bin/mkdir -p /usr/local/src/security_monkey/security_monkey/static/ + sudo mkdir -p /usr/local/src/security_monkey/security_monkey/static/ sudo /bin/cp -R /usr/local/src/security_monkey/dart/build/web/* /usr/local/src/security_monkey/security_monkey/static/ + sudo chgrp -R www-data /usr/local/src/security_monkey Configure the Application ------------------------- @@ -731,18 +733,18 @@ it were to crash. environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config-deploy.py" command=python /usr/local/src/security_monkey/manage.py start_scheduler +Copy supervisor config:: -Copy /usr/local/src/security_monkey/supervisor/security_monkey.conf to /etc/supervisor/conf.d/security_monkey.conf and make sure it points to the locations where you cloned the security monkey repo.:: - + sudo cp /usr/local/src/security_monkey/supervisor/security_monkey.conf /etc/supervisor/conf.d/security_monkey.conf sudo service supervisor restart - sudo supervisorctl & + sudo supervisorctl status Supervisor will attempt to start two python jobs and make sure they are running. The first job, securitymonkey, is gunicorn, which it launches by calling manage.py run_api_server. The second job supervisor runs is the scheduler, which looks for changes every 15 minutes. **The scheduler will fail to start at this time because there are no accounts for it to monitor** Later, we will add an account and start the scheduler. -You can track progress by tailing security_monkey-deploy.log. +You can track progress by tailing /var/log/security_monkey/securitymonkey.log. Create an SSL Certificate ========================= diff --git a/env-config/config-deploy.py b/env-config/config-deploy.py index c016cde57..583c7d483 100644 --- a/env-config/config-deploy.py +++ b/env-config/config-deploy.py @@ -52,7 +52,7 @@ } } -SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:securitymonkeypassword@localhost:5432/secmonkey' +SQLALCHEMY_DATABASE_URI = 'postgresql://securitymonkeyuser:securitymonkeypassword@localhost:5432/secmonkey' SQLALCHEMY_POOL_SIZE = 50 SQLALCHEMY_MAX_OVERFLOW = 15 From 46eaf82d48d4f354d9baf079927363eee086ba4f Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 16 Mar 2017 15:33:29 -0700 Subject: [PATCH 59/90] Fixing name of account constraint to account_name_uc --- migrations/versions/1583a48cb978_.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/versions/1583a48cb978_.py b/migrations/versions/1583a48cb978_.py index 14aeaf739..72af7ac9b 100644 --- a/migrations/versions/1583a48cb978_.py +++ b/migrations/versions/1583a48cb978_.py @@ -17,7 +17,7 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### op.create_index('ix_account_name', 'account', ['name'], unique=True) - op.drop_constraint(u'account_name_key', 'account', type_='unique') + op.drop_constraint(u'account_name_uc', 'account', type_='unique') op.create_index('ix_technology_name', 'technology', ['name'], unique=True) # ### end Alembic commands ### @@ -25,6 +25,6 @@ def upgrade(): def downgrade(): # ### commands auto generated by Alembic - please adjust! ### op.drop_index('ix_technology_name', table_name='technology') - op.create_unique_constraint(u'account_name_key', 'account', ['name']) + op.create_unique_constraint(u'account_name_uc', 'account', ['name']) op.drop_index('ix_account_name', table_name='account') # ### end Alembic commands ### From 7a53349166a41f4f2ac7697b44f04d2bfeeb8227 Mon Sep 17 00:00:00 2001 From: Carise Fernandez Date: Thu, 16 Mar 2017 17:00:17 -0700 Subject: [PATCH 60/90] Some instructions on using GCP Cloud SQL and Cloud SQL Proxy. --- docs/quickstart.rst | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index c70b03a9f..ef2c6cc82 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -387,7 +387,7 @@ Let's install the tools we need for Security Monkey:: Setup Postgres -------------- -*For production, you will want to use an AWS RDS Postgres database.* For this guide, we will setup a database on the instance that was just launched. +*For production, you will want to use an AWS RDS Postgres database (or Cloud SQL Postgres, which is currently in beta).* For this guide, we will setup a database on the instance that was just launched. First, set a password for the postgres user. For this guide, we will use ``securitymonkeypassword``: :: @@ -400,6 +400,26 @@ First, set a password for the postgres user. For this guide, we will use ``secu select now(); \q +Postgres on GCP +--------------- + +If you are deploying Security Monkey on GCP and decide to use Cloud SQL, it's recommended to run `Cloud SQL Proxy `_ to connect to Postgres. You'll need to run Cloud SQL Proxy on whichever machine needs to access Postgres, e.g. on your local workstation as well as on the GCE instance where you're running Security Monkey. + +To use Cloud SQL Postgres, create a new instance from your GCP console. After the instance is up, run Cloud SQL Proxy: + + ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & + +You can find the instance connection name by clicking on your Cloud SQL instance name on the `Cloud SQL dashboard '_ and looking under "Properties". The instance connection name is something like [PROJECT_ID]:[REGION]:[INSTANCENAME]. + +Connect to the Postgres instance: + + sudo -u postgres psql -h 127.0.0.1 -p 5432 + +If you need to set the postgres user's password, refer to the `Cloud SQL documentation `_. + +After you've connected successfully in psql, follow the instructions in `Setup Postgres`_ to set up the Security Monkey database. + + Clone the Security Monkey Repo ============================== From 60b2d2dfa88c41735a15433d0cc00eed6708b4bf Mon Sep 17 00:00:00 2001 From: Carise Fernandez Date: Thu, 16 Mar 2017 17:01:40 -0700 Subject: [PATCH 61/90] Fix link --- docs/quickstart.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index ef2c6cc82..b4fe621e8 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -409,7 +409,7 @@ To use Cloud SQL Postgres, create a new instance from your GCP console. After th ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & -You can find the instance connection name by clicking on your Cloud SQL instance name on the `Cloud SQL dashboard '_ and looking under "Properties". The instance connection name is something like [PROJECT_ID]:[REGION]:[INSTANCENAME]. +You can find the instance connection name by clicking on your Cloud SQL instance name on the `Cloud SQL dashboard `_ and looking under "Properties". The instance connection name is something like [PROJECT_ID]:[REGION]:[INSTANCENAME]. Connect to the Postgres instance: From b211fb1adf84705e110a88c132f5540a8bee1f7d Mon Sep 17 00:00:00 2001 From: Carise Fernandez Date: Thu, 16 Mar 2017 17:04:46 -0700 Subject: [PATCH 62/90] Fix colons --- docs/quickstart.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index b4fe621e8..91a85e6e2 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -405,15 +405,15 @@ Postgres on GCP If you are deploying Security Monkey on GCP and decide to use Cloud SQL, it's recommended to run `Cloud SQL Proxy `_ to connect to Postgres. You'll need to run Cloud SQL Proxy on whichever machine needs to access Postgres, e.g. on your local workstation as well as on the GCE instance where you're running Security Monkey. -To use Cloud SQL Postgres, create a new instance from your GCP console. After the instance is up, run Cloud SQL Proxy: +To use Cloud SQL Postgres, create a new instance from your GCP console. After the instance is up, run Cloud SQL Proxy:: - ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & + $ ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & You can find the instance connection name by clicking on your Cloud SQL instance name on the `Cloud SQL dashboard `_ and looking under "Properties". The instance connection name is something like [PROJECT_ID]:[REGION]:[INSTANCENAME]. -Connect to the Postgres instance: +Connect to the Postgres instance:: - sudo -u postgres psql -h 127.0.0.1 -p 5432 + $ sudo -u postgres psql -h 127.0.0.1 -p 5432 If you need to set the postgres user's password, refer to the `Cloud SQL documentation `_. From d448172eb55b2240f5bc68c5a339b1e1ead6657e Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Thu, 16 Mar 2017 12:43:48 -0700 Subject: [PATCH 63/90] Added ephemeral section to S3 for "GrantReferences". --- security_monkey/datastore.py | 3 +++ security_monkey/watchers/s3.py | 3 +++ setup.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index a805225f0..d9c8f5f9e 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -484,6 +484,9 @@ def ephemeral_paths_for_tech(self, tech=None): "accesskeys$*$LastUsedDate", "accesskeys$*$Region", "accesskeys$*$ServiceName" + ], + 's3': [ + "GrantReferences" ] } return ephemeral_paths.get(tech, []) diff --git a/security_monkey/watchers/s3.py b/security_monkey/watchers/s3.py index 62ddbf941..521a7bb43 100644 --- a/security_monkey/watchers/s3.py +++ b/security_monkey/watchers/s3.py @@ -36,6 +36,9 @@ class S3(Watcher): def __init__(self, accounts=None, debug=False): super(S3, self).__init__(accounts=accounts, debug=debug) + self.ephemeral_paths = ["GrantReferences"] + self.honor_ephemerals = True + @record_exception(source="s3-watcher", pop_exception_fields=True) def list_buckets(self, **kwargs): buckets = list_buckets(**kwargs) diff --git a/setup.py b/setup.py index 0b4ac49d1..458ceaf85 100644 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ 'dpath==1.3.2', 'pyyaml==3.11', 'jira==0.32', - 'cloudaux>=1.1.3', + 'cloudaux>=1.1.5', 'joblib>=0.9.4', 'pyjwt>=1.01', ], From c9700333f15064a67308d2bfa7c38fd7c14ba080 Mon Sep 17 00:00:00 2001 From: Carise Fernandez Date: Thu, 16 Mar 2017 21:41:21 -0700 Subject: [PATCH 64/90] Change some verbiage --- docs/quickstart.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 91a85e6e2..57f1f9912 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -387,7 +387,7 @@ Let's install the tools we need for Security Monkey:: Setup Postgres -------------- -*For production, you will want to use an AWS RDS Postgres database (or Cloud SQL Postgres, which is currently in beta).* For this guide, we will setup a database on the instance that was just launched. +*For production, you will want to use your cloud provider's managed Postgres database (such as AWS RDS Postgres or Cloud SQL Postgres) for improved reliability.* For this guide, we will setup a database on the instance that was just launched. First, set a password for the postgres user. For this guide, we will use ``securitymonkeypassword``: :: @@ -403,20 +403,20 @@ First, set a password for the postgres user. For this guide, we will use ``secu Postgres on GCP --------------- -If you are deploying Security Monkey on GCP and decide to use Cloud SQL, it's recommended to run `Cloud SQL Proxy `_ to connect to Postgres. You'll need to run Cloud SQL Proxy on whichever machine needs to access Postgres, e.g. on your local workstation as well as on the GCE instance where you're running Security Monkey. +If you are deploying Security Monkey on GCP and decide to use Cloud SQL, it's recommended to run `Cloud SQL Proxy `_ to connect to Postgres. To use Postgres on Cloud SQL, create a new instance from your GCP console and create a password for the ``postgres`` user when Cloud SQL prompts you. (If you ever need to reset the ``postgres`` user's password, refer to the `Cloud SQL documentation `_.) -To use Cloud SQL Postgres, create a new instance from your GCP console. After the instance is up, run Cloud SQL Proxy:: +After the instance is up, run Cloud SQL Proxy:: $ ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & You can find the instance connection name by clicking on your Cloud SQL instance name on the `Cloud SQL dashboard `_ and looking under "Properties". The instance connection name is something like [PROJECT_ID]:[REGION]:[INSTANCENAME]. +You'll need to run Cloud SQL Proxy on whichever machine is accessing Postgres, e.g. on your local workstation as well as on the GCE instance where you're running Security Monkey. + Connect to the Postgres instance:: $ sudo -u postgres psql -h 127.0.0.1 -p 5432 -If you need to set the postgres user's password, refer to the `Cloud SQL documentation `_. - After you've connected successfully in psql, follow the instructions in `Setup Postgres`_ to set up the Security Monkey database. From 3069fa51b2097a1ef5efce3856dcea42555f784a Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 16 Mar 2017 22:00:24 -0700 Subject: [PATCH 65/90] Setting Item.issue_count to deferred. Only joining tables in distinct if necessary. --- security_monkey/datastore.py | 3 ++- security_monkey/views/distinct.py | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index 3ee3acb7a..a8d42a1a8 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -281,7 +281,8 @@ def unjustified_score(cls): select([func.count(ItemAudit.id)]) .where(ItemAudit.item_id == id) .where(ItemAudit.auditor_setting_id == AuditorSettings.id) - .where(AuditorSettings.disabled == False) + .where(AuditorSettings.disabled == False), + deferred=True ) diff --git a/security_monkey/views/distinct.py b/security_monkey/views/distinct.py index a98ee71fd..58f13c64b 100644 --- a/security_monkey/views/distinct.py +++ b/security_monkey/views/distinct.py @@ -87,20 +87,20 @@ def get(self, key_id): select2 = False query = Item.query - query = query.join((Account, Account.id == Item.account_id)).join( - (AccountType, AccountType.id == Account.account_type_id)) - query = query.join((Technology, Technology.id == Item.tech_id)) - query = query.join((ItemRevision, Item.latest_revision_id == ItemRevision.id)) if 'regions' in args and key_id != 'region': regions = args['regions'].split(',') query = query.filter(Item.region.in_(regions)) if 'accounts' in args and key_id != 'account': + query = query.join((Account, Account.id == Item.account_id)) accounts = args['accounts'].split(',') query = query.filter(Account.name.in_(accounts)) if 'accounttypes' in args and key_id != 'accounttype': + query = query.join((Account, Account.id == Item.account_id)).join( + (AccountType, AccountType.id == Account.account_type_id)) accounttypes = args['accounttypes'].split(',') query = query.filter(AccountType.name.in_(accounttypes)) if 'technologies' in args and key_id != 'tech': + query = query.join((Technology, Technology.id == Item.tech_id)) technologies = args['technologies'].split(',') query = query.filter(Technology.name.in_(technologies)) if 'names' in args and key_id != 'name': @@ -110,20 +110,25 @@ def get(self, key_id): names = args['arns'].split(',') query = query.filter(Item.arn.in_(names)) if 'active' in args: + query = query.join((ItemRevision, Item.latest_revision_id == ItemRevision.id)) active = args['active'].lower() == "true" query = query.filter(ItemRevision.active == active) if key_id == 'tech': + query = query.join((Technology, Technology.id == Item.tech_id)) if select2: query = query.distinct(Technology.name).filter(func.lower(Technology.name).like('%' + q + '%')) else: query = query.distinct(Technology.name) elif key_id == 'accounttype': + query = query.join((Account, Account.id == Item.account_id)).join( + (AccountType, AccountType.id == Account.account_type_id)) if select2: query = query.distinct(AccountType.name).filter(func.lower(AccountType.name).like('%' + q + '%')) else: query = query.distinct(AccountType.name) elif key_id == 'account': + query = query.join((Account, Account.id == Item.account_id)) if select2: query = query.filter(Account.third_party == False) query = query.distinct(Account.name).filter(func.lower(Account.name).like('%' + q + '%')) From 5abfbe6460b646a75cfa3c005ce4c1cff7b02269 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 16 Mar 2017 22:11:08 -0700 Subject: [PATCH 66/90] Increasing default timeout --- docs/quickstart.rst | 8 ++++---- manage.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index c70b03a9f..32f0ea3c5 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -313,12 +313,12 @@ If you haven't already, install *gcloud* from the downloads_ page. *gcloud* ena Setup Service Account --------------------- -To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. +To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. .. _`Service Account`: https://cloud.google.com/compute/docs/access/service-accounts Then, we'll launch an instance using that service account. -Navigate to the `Service Account page`_ for your project. +Navigate to the `Service Account page`_ for your project. .. _`Service Account page`: https://console.developers.google.com/iam-admin/serviceaccounts @@ -382,7 +382,7 @@ Create the logging folders:: Let's install the tools we need for Security Monkey:: $ sudo apt-get update - $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev + $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev Setup Postgres -------------- @@ -792,7 +792,7 @@ Save the config file below to: :: error_log /var/log/security_monkey/security_monkey.error.log; location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) { - proxy_read_timeout 120; + proxy_read_timeout 1800; proxy_pass http://127.0.0.1:5000; proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; proxy_redirect off; diff --git a/manage.py b/manage.py index 5881e8b33..c89b058fd 100644 --- a/manage.py +++ b/manage.py @@ -570,7 +570,8 @@ class FlaskApplication(Application): def init(self, parser, opts, args): return { 'bind': address, - 'workers': workers + 'workers': workers, + 'timeout': 1800 } def load(self): From 487b0a7b4364b29469f68bc916b2d92093a194d7 Mon Sep 17 00:00:00 2001 From: Sergey Skripnick Date: Fri, 17 Mar 2017 12:16:03 +0200 Subject: [PATCH 67/90] Fix docs and variable names related to custom alerters --- docs/misc.rst | 9 ++++++--- security_monkey/alerters/custom_alerter.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/misc.rst b/docs/misc.rst index 176ef43e0..dadd06cf0 100644 --- a/docs/misc.rst +++ b/docs/misc.rst @@ -111,8 +111,11 @@ A sample customer alerter would be a `SplunkAlerter` module that logs watcher an .. code-block:: python + from security_monkey.alerters import custom_alerter + + class SplunkAlerter(object): - __metaclass__ = AlerterType + __metaclass__ = custom_alerter.AlerterType def report_watcher_changes(self, watcher): """ @@ -164,8 +167,8 @@ A sample customer alerter would be a `SplunkAlerter` module that logs watcher an item.region, item.name)) - def report_auditor_changes(self, items): - for item in items: + def report_auditor_changes(self, auditor): + for item in auditor.items: for issue in item.confirmed_new_issues: app.splunk_logger.info( "action=\"Issue created\" " diff --git a/security_monkey/alerters/custom_alerter.py b/security_monkey/alerters/custom_alerter.py index 9ea5d5505..f6bcca11d 100644 --- a/security_monkey/alerters/custom_alerter.py +++ b/security_monkey/alerters/custom_alerter.py @@ -33,10 +33,10 @@ def __init__(cls, name, bases, attrs): alerter_registry.append(cls) -def report_auditor_changes(items): +def report_auditor_changes(auditor): for alerter_class in alerter_registry: alerter = alerter_class() - alerter.report_auditor_changes(items) + alerter.report_auditor_changes(auditor) def report_watcher_changes(watcher): From 7587d34973d05b348f6d13b4f21515289d140d30 Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Fri, 17 Mar 2017 14:45:12 -0700 Subject: [PATCH 68/90] =?UTF-8?q?Fix=20for=20ptxt=20passwords=20in=20db=20?= =?UTF-8?q?if=20using=20CLI=20for=20user=20creation=20=F0=9F=98=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manage.py | 8 ++- migrations/versions/908b0085d28d_.py | 75 +++++++++++++++++++++++++ security_monkey/tests/views/__init__.py | 3 +- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/908b0085d28d_.py diff --git a/manage.py b/manage.py index c89b058fd..7a66a773e 100644 --- a/manage.py +++ b/manage.py @@ -15,7 +15,7 @@ import sys from flask.ext.script import Manager, Command, Option, prompt_pass -from security_monkey.datastore import ExceptionLogs, clear_old_exceptions, store_exception +from security_monkey.datastore import clear_old_exceptions, store_exception from security_monkey import app, db from security_monkey.common.route53 import Route53Service @@ -221,6 +221,8 @@ def create_user(email, role): from flask_security import SQLAlchemyUserDatastore from security_monkey.datastore import User from security_monkey.datastore import Role + from flask_security.utils import encrypt_password + user_datastore = SQLAlchemyUserDatastore(db, User, Role) ROLES = ['View', 'Comment', 'Justify', 'Admin'] @@ -238,7 +240,9 @@ def create_user(email, role): sys.stderr.write("[!] Passwords do not match\n") sys.exit(1) - user = user_datastore.create_user(email=email, password=password1, confirmed_at=datetime.now()) + user = user_datastore.create_user(email=email, + password=encrypt_password(password1), + confirmed_at=datetime.now()) else: sys.stdout.write("[+] Updating existing user\n") user = users.first() diff --git a/migrations/versions/908b0085d28d_.py b/migrations/versions/908b0085d28d_.py new file mode 100644 index 000000000..c64ca301d --- /dev/null +++ b/migrations/versions/908b0085d28d_.py @@ -0,0 +1,75 @@ +"""Find unencrypted passwords in the database and delete them + +Revision ID: 908b0085d28d +Revises: 1583a48cb978 +Create Date: 2017-03-17 13:16:19.539970 +Author: Mike Grima + +""" +import re +import sqlalchemy as sa + +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from alembic import op + + +revision = '908b0085d28d' +down_revision = '1583a48cb978' + + +Session = sessionmaker() +Base = declarative_base() + + +class User(Base): + __tablename__ = "user" + id = sa.Column(sa.Integer, primary_key=True) + email = sa.Column(sa.String(255), unique=True) + password = sa.Column(sa.String(255)) + active = sa.Column(sa.Boolean()) + confirmed_at = sa.Column(sa.DateTime()) + daily_audit_email = sa.Column(sa.Boolean()) + change_reports = sa.Column(sa.String(32)) + last_login_at = sa.Column(sa.DateTime()) + current_login_at = sa.Column(sa.DateTime()) + login_count = sa.Column(sa.Integer) + last_login_ip = sa.Column(sa.String(45)) + current_login_ip = sa.Column(sa.String(45)) + role = sa.Column(sa.String(30)) + + def __str__(self): + return '' % (self.id, self.email) + + +def upgrade(): + print("[@] Checking for users that have non-bcrypted/plaintext passwords in the database.") + + bind = op.get_bind() + session = Session(bind=bind) + + # Need to get a list of all users in the DB that don't have bcrypted passwords. + users = session.query(User).all() + + for user in users: + # If the password isn't bcrypted, then delete it -- Also output that it was deleted! + if user.password: + if not re.match("^\$2[ayb]\$.{56}$", user.password): + print("[!] User: {} has a plaintext password! Deleting the password!".format(user.email)) + user.password = "" + session.add(user) + session.commit() + print("[-] Deleted plaintext password from user: {}'s account".format(user.email)) + + else: + print("[:D] User: {} has bcrypted password -- so all good!.".format(user.email)) + + else: + print("[:D] User: {} does not appear to be using username/password login, so all good!".format(user.email)) + + print("[@] Completed check.") + + +def downgrade(): + # There is no going back! + pass diff --git a/security_monkey/tests/views/__init__.py b/security_monkey/tests/views/__init__.py index 27fb01212..1f7abd898 100644 --- a/security_monkey/tests/views/__init__.py +++ b/security_monkey/tests/views/__init__.py @@ -25,6 +25,7 @@ from security_monkey.datastore import User from security_monkey.datastore import Role from datetime import datetime +from flask_security.utils import encrypt_password class SecurityMonkeyApiTestCase(SecurityMonkeyTestCase): @@ -38,7 +39,7 @@ def pre_test_setup(self): def create_test_user(self, email, password): user_datastore = SQLAlchemyUserDatastore(db, User, Role) - user = user_datastore.create_user(email=email, password=password, confirmed_at=datetime.now()) + user = user_datastore.create_user(email=email, password=encrypt_password(password), confirmed_at=datetime.now()) user.role = 'Admin' user.active = True From d2641aec08cce438efb44bd9328751e7e6907a7f Mon Sep 17 00:00:00 2001 From: Jon Hadfield Date: Mon, 20 Mar 2017 20:12:37 +0000 Subject: [PATCH 69/90] format ACM certificate ImportedAt timestamp --- security_monkey/watchers/acm.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/security_monkey/watchers/acm.py b/security_monkey/watchers/acm.py index cb516ec4b..77139c619 100644 --- a/security_monkey/watchers/acm.py +++ b/security_monkey/watchers/acm.py @@ -95,6 +95,8 @@ def slurp(self): config.update({ 'CreatedAt': config.get('CreatedAt').astimezone(tzutc()).isoformat() }) if config.get('IssuedAt'): config.update({ 'IssuedAt': config.get('IssuedAt').astimezone(tzutc()).isoformat() }) + if config.get('ImportedAt'): + config.update({ 'ImportedAt': config.get('ImportedAt').astimezone(tzutc()).isoformat()}) item = ACMCertificate(region=region.name, account=account, name=cert.get('DomainName'), arn=cert.get('CertificateArn'), config=dict(config)) item_list.append(item) From 1acf2744397258f4adee47e7332123100efa6aff Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Mon, 23 Jan 2017 20:38:53 -0800 Subject: [PATCH 70/90] Batch support for watchers. - Currently supporting IAM Role watcher --- .gitignore | 6 +- security_monkey/auditor.py | 19 +- security_monkey/datastore.py | 4 +- security_monkey/datastore_utils.py | 246 +++++++++++++ security_monkey/monitors.py | 5 +- security_monkey/reporter.py | 23 +- security_monkey/scheduler.py | 86 ++++- security_monkey/tests/core/monitor_mock.py | 15 +- security_monkey/tests/core/test_auditor.py | 34 +- .../tests/core/test_datastore_utils.py | 341 ++++++++++++++++++ security_monkey/tests/core/test_reporter.py | 134 ++++++- security_monkey/tests/core/test_scheduler.py | 229 +++++++++++- security_monkey/tests/core/test_watcher.py | 183 +++++++++- .../tests/watchers/test_iam_role.py | 163 +++++++++ security_monkey/watcher.py | 111 +++++- security_monkey/watchers/iam/iam_role.py | 93 ++++- 16 files changed, 1611 insertions(+), 81 deletions(-) create mode 100644 security_monkey/datastore_utils.py create mode 100644 security_monkey/tests/core/test_datastore_utils.py create mode 100644 security_monkey/tests/watchers/test_iam_role.py diff --git a/.gitignore b/.gitignore index 2de573b06..eea12d057 100644 --- a/.gitignore +++ b/.gitignore @@ -51,10 +51,12 @@ security_monkey/static dart/lib/util/constants.dart devlog/ venv/ +venv .idea/ - +dart/.idea +dart/.packages +dart/web/ico/packages boto.cfg secmonkey.env *.crt *.key - diff --git a/security_monkey/auditor.py b/security_monkey/auditor.py index 5ce8437b0..ddb9abca2 100644 --- a/security_monkey/auditor.py +++ b/security_monkey/auditor.py @@ -36,6 +36,7 @@ auditor_registry = defaultdict(list) + class AuditorType(type): def __init__(cls, name, bases, attrs): super(AuditorType, cls).__init__(name, bases, attrs) @@ -51,6 +52,7 @@ def __init__(cls, name, bases, attrs): app.logger.debug("Registering auditor {} {}.{}".format(cls.index, cls.__module__, cls.__name__)) auditor_registry[cls.index].append(cls) + class Auditor(object): """ This class (and subclasses really) run a number of rules against the configurations @@ -131,11 +133,11 @@ def prep_for_audit(self): """ pass - def audit_these_objects(self, items): + def audit_objects(self): """ - Only inspect the given items. + Inspect all of the auditor's items. """ - app.logger.debug("Asked to audit {} Objects".format(len(items))) + app.logger.debug("Asked to audit {} Objects".format(len(self.items))) self.prep_for_audit() self.current_support_items = {} query = ItemAuditScore.query.filter(ItemAuditScore.technology == self.index) @@ -143,14 +145,13 @@ def audit_these_objects(self, items): methods = [getattr(self, method_name) for method_name in dir(self) if method_name.find("check_") == 0] app.logger.debug("methods: {}".format(methods)) - for item in items: + for item in self.items: for method in methods: self.current_method_name = method.func_name # If the check function is disabled by an entry on Settings/Audit Issue Scores # the function will not be run and any previous issues will be cleared if not self._is_current_method_disabled(): method(item) - self.items = items self.override_scores = None @@ -165,14 +166,6 @@ def _is_current_method_disabled(self): return False - - def audit_all_objects(self): - """ - Read all items from the database and inspect them all. - """ - self.items = self.read_previous_items() - self.audit_these_objects(self.items) - def read_previous_items(self): """ Pulls the last-recorded configuration from the database. diff --git a/security_monkey/datastore.py b/security_monkey/datastore.py index 605ebe908..99a102a05 100644 --- a/security_monkey/datastore.py +++ b/security_monkey/datastore.py @@ -401,6 +401,7 @@ class ExceptionLogs(db.Model): item_id = Column(Integer, ForeignKey("item.id", ondelete="CASCADE"), index=True) account_id = Column(Integer, ForeignKey("account.id", ondelete="CASCADE"), index=True) + class ItemAuditScore(db.Model): """ This table maps scores to audit methods, allowing for configurable scores. @@ -718,8 +719,7 @@ def store_exception(source, location, exception, ttl=None): db.session.add(technology) db.session.commit() db.session.refresh(technology) - app.logger.info("Creating a new Technology: {} - ID: {}" - .format(technology.name, technology.id)) + app.logger.info("Creating a new Technology: {} - ID: {}".format(technology.name, technology.id)) exception_entry.tech_id = technology.id db.session.add(exception_entry) diff --git a/security_monkey/datastore_utils.py b/security_monkey/datastore_utils.py new file mode 100644 index 000000000..804a70cec --- /dev/null +++ b/security_monkey/datastore_utils.py @@ -0,0 +1,246 @@ +import hashlib +import json + +import datetime +import dpath.util +from dpath.exceptions import PathNotFound +from copy import deepcopy + +from security_monkey import datastore, app +from cloudaux.orchestration.aws.arn import ARN +from security_monkey.datastore import Item, ItemRevision, ItemAudit + +prims = [int, str, unicode, bool, float, type(None)] + + +def persist_item(item, db_item, technology, account, complete_hash, durable_hash, durable): + if not db_item: + db_item = create_item(item, technology, account) + + if db_item.latest_revision_complete_hash == complete_hash: + app.logger.debug("Change persister doesn't see any change. Ignoring...") + return + + # Create the new revision + if durable: + revision = create_revision(item.config, db_item) + db_item.revisions.append(revision) + + # Ephemeral -- update the existing revision: + else: + revision = db_item.revisions.first() + revision.date_last_ephemeral_change = datetime.datetime.utcnow() + revision.config = item.config + app.logger.debug("Persisting EPHEMERAL change to item: {technology}/{account}/{item}".format( + technology=technology.name, account=account.name, item=db_item.name + )) + + db_item.latest_revision_complete_hash = complete_hash + db_item.latest_revision_durable_hash = durable_hash + + datastore.db.session.add(db_item) + datastore.db.session.add(revision) + datastore.db.session.commit() + + if durable: + app.logger.debug("Persisting DURABLE change to item: {technology}/{account}/{item}".format( + technology=technology.name, account=account.name, item=db_item.name + )) + datastore.db.session.refresh(revision) + db_item.latest_revision_id = revision.id + datastore.db.session.add(revision) + datastore.db.session.add(db_item) + datastore.db.session.commit() + + +def is_active(config): + if config.keys() == ['Arn']: + return False + + if set(config.keys()) == {'account_number', 'technology', 'region', 'name'}: + return False + + return True + + +def create_revision(config, db_item): + return ItemRevision( + active=is_active(config), + config=config, + item_id=db_item.id, + ) + + +def create_item(item, technology, account): + arn = ARN(item.config.get('Arn')) + return Item( + region=arn.region or 'universal', + name=arn.parsed_name or arn.name, + arn=item.config.get('Arn'), + tech_id=technology.id, + account_id=account.id + ) + + +def detect_change(item, account, technology, complete_hash, durable_hash): + """ + Checks the database to see if the latest revision of the specified + item matches what Security Monkey has pulled from AWS. + + Note: this method makes no distinction between a changed item, a new item, + a deleted item, or one that only has changes in the ephemeral section. + + :param item: dict describing an item tracked by Security Monkey + :param hash: hash of the item dict for quicker change detection + :return: bool. True if the database differs from our copy of item + """ + result = result_from_item(item, account, technology) + + # new item doesn't yet exist in DB + if not result: + app.logger.debug("Couldn't find item: {tech}/{account}/{region}/{item} in DB.".format( + tech=item.index, account=item.account, region=item.region, item=item.name + )) + return True, 'durable', result + + if result.latest_revision_durable_hash != durable_hash: + app.logger.debug("Item: {tech}/{account}/{region}/{item} in DB has a DURABLE CHANGE.".format( + tech=item.index, account=item.account, region=item.region, item=item.name + )) + return True, 'durable', result + + elif result.latest_revision_complete_hash != complete_hash: + app.logger.debug("Item: {tech}/{account}/{region}/{item} in DB has an EPHEMERAL CHANGE.".format( + tech=item.index, account=item.account, region=item.region, item=item.name + )) + return True, 'ephemeral', result + + else: + app.logger.debug("Item: {tech}/{account}/{region}/{item} in DB has NO CHANGE.".format( + tech=item.index, account=item.account, region=item.region, item=item.name + )) + return False, None, result + + +def result_from_item(item, account, technology): + # Construct the query to obtain the specific item from the database: + return datastore.Item.query.filter(Item.name == item.name, Item.region == item.region, + Item.account_id == account.id, Item.tech_id == technology.id).scalar() + + +def inactivate_old_revisions(watcher, arns, account, technology): + result = Item.query.filter( + Item.account_id == account.id, + Item.tech_id == technology.id, + Item.arn.notin_(arns) + ).join((ItemRevision, Item.latest_revision_id == ItemRevision.id)) \ + .filter(ItemRevision.active == True).all() # noqa + + for db_item in result: + app.logger.debug("Deleting {technology}/{account}/{name}".format( + technology=technology.name, account=account.name, name=db_item.name + )) + + # Create the new revision + config = {"Arn": db_item.arn} + revision = create_revision(config, db_item) + db_item.revisions.append(revision) + + complete_hash, durable_hash = hash_item(config, watcher.ephemeral_paths) + + db_item.latest_revision_complete_hash = complete_hash + db_item.latest_revision_durable_hash = durable_hash + + # Add the revision: + datastore.db.session.add(db_item) + datastore.db.session.add(revision) + datastore.db.session.commit() + + # Do it again to update the latest revision ID: + datastore.db.session.refresh(revision) + db_item.latest_revision_id = revision.id + datastore.db.session.add(db_item) + + # Find any audit issues associated with this revision, and delete it: + ia = ItemAudit.query.filter(ItemAudit.item_id == db_item.id).all() + + for audit_item in ia: + datastore.db.session.delete(audit_item) + + datastore.db.session.commit() + + return result + + +def hash_item(config, ephemeral_paths): + """ + Finds the hash of a dict. + + :param ephemeral_paths: + :param config: + :param item: dictionary, typically representing an item tracked in SM + such as an IAM role + :return: hash of the json dump of the item + """ + complete = hash_config(config) + durable = durable_hash(config, ephemeral_paths) + return complete, durable + + +def durable_hash(config, ephemeral_paths): + durable_item = deepcopy(config) + for path in ephemeral_paths: + try: + dpath.util.delete(durable_item, path, separator='$') + except PathNotFound: + pass + return hash_config(durable_item) + + +def hash_config(config): + item = sub_dict(config) + item_str = json.dumps(item, sort_keys=True) + item_hash = hashlib.md5(item_str) + return item_hash.hexdigest() + + +def sub_list(l): + """ + Recursively walk a data-structure sorting any lists along the way. + + :param l: list + :return: sorted list, where any child lists are also sorted. + """ + r = [] + + for i in l: + if type(i) in prims: + r.append(i) + elif type(i) is list: + r.append(sub_list(i)) + elif type(i) is dict: + r.append(sub_dict(i)) + else: + print "Unknown Type: {}".format(type(i)) + r = sorted(r) + return r + + +def sub_dict(d): + """ + Recursively walk a data-structure sorting any lists along the way. + + :param d: dict + :return: dict where any lists, even those buried deep in the structure, have been sorted. + """ + r = {} + for k in d: + if type(d[k]) in prims: + r[k] = d[k] + elif type(d[k]) is list: + r[k] = sub_list(d[k]) + elif type(d[k]) is dict: + r[k] = sub_dict(d[k]) + else: + print "Unknown Type: {}".format(type(d[k])) + return r diff --git a/security_monkey/monitors.py b/security_monkey/monitors.py index cad9b0591..32b784ee2 100644 --- a/security_monkey/monitors.py +++ b/security_monkey/monitors.py @@ -7,17 +7,18 @@ .. moduleauthor:: Patrick Kelley @monkeysecurity """ -from security_monkey import app from security_monkey.auditor import auditor_registry from security_monkey.watcher import watcher_registry from security_monkey.account_manager import account_registry, get_account_by_name + class Monitor(object): """Collects a watcher with the associated auditors""" def __init__(self, watcher_class, account, debug=False): self.watcher = watcher_class(accounts=[account.name], debug=debug) self.auditors = [] self.audit_tier = 0 + self.batch_support = self.watcher.batched_size > 0 for auditor_class in auditor_registry[self.watcher.index]: au = auditor_class([account.name], debug=debug) @@ -77,7 +78,7 @@ def all_monitors(account_name, debug=False): for mon in monitor_dict.values(): if len(mon.auditors) > 0: - path = [ mon.watcher.index ] + path = [mon.watcher.index] _set_dependency_hierarchies(monitor_dict, mon, path, mon.audit_tier + 1) monitors = sorted(monitor_dict.values(), key=lambda item: item.audit_tier, reverse=True) diff --git a/security_monkey/reporter.py b/security_monkey/reporter.py index 4dcc71bcb..0cb92c70c 100644 --- a/security_monkey/reporter.py +++ b/security_monkey/reporter.py @@ -47,15 +47,25 @@ def run(self, account, interval=None): for monitor in mons: app.logger.info("Running slurp {} for {} ({} minutes interval)".format(monitor.watcher.i_am_singular, account, interval)) - (items, exception_map) = monitor.watcher.slurp() - monitor.watcher.find_changes(items, exception_map) - if (len(monitor.watcher.created_items) > 0) or (len(monitor.watcher.changed_items) > 0): - watchers_with_changes.add(monitor.watcher.index) - monitor.watcher.save() + + # Batch logic needs to be handled differently: + if monitor.batch_support: + from security_monkey.scheduler import batch_logic + batch_logic(monitor, monitor.watcher, account, False) + else: + (items, exception_map) = monitor.watcher.slurp() + monitor.watcher.find_changes(items, exception_map) + if (len(monitor.watcher.created_items) > 0) or (len(monitor.watcher.changed_items) > 0): + watchers_with_changes.add(monitor.watcher.index) + monitor.watcher.save() db_account = get_account_by_name(account) for monitor in self.all_monitors: + # Skip over batched items, since they are done: + if monitor.batch_support: + continue + for auditor in monitor.auditors: if auditor.applies_to_account(db_account): items_to_audit = self.get_items_to_audit(monitor.watcher, auditor, watchers_with_changes) @@ -64,7 +74,8 @@ def run(self, account, interval=None): account)) try: - auditor.audit_these_objects(items_to_audit) + auditor.items = items_to_audit + auditor.audit_objects() auditor.save_issues() except Exception as e: store_exception('reporter-run-auditor', (auditor.index, account), e) diff --git a/security_monkey/scheduler.py b/security_monkey/scheduler.py index 1c0680135..21c3ccd52 100644 --- a/security_monkey/scheduler.py +++ b/security_monkey/scheduler.py @@ -38,24 +38,68 @@ def run_change_reporter(account_names, interval=None): def find_changes(accounts, monitor_names, debug=True): """ - Runs the watcher and stores the result, reaudits all types to account + Runs the watcher and stores the result, re-audits all types to account for downstream dependencies. """ for account_name in accounts: monitors = get_monitors(account_name, monitor_names, debug) for mon in monitors: cw = mon.watcher - (items, exception_map) = cw.slurp() - cw.find_changes(current=items, exception_map=exception_map) - cw.save() + if mon.batch_support: + batch_logic(mon, cw, account_name, debug) + else: + # Just fetch normally... + (items, exception_map) = cw.slurp() + cw.find_changes(current=items, exception_map=exception_map) + cw.save() + + # Batched monitors have already been monitored, and they will be skipped over. audit_changes(accounts, monitor_names, False, debug) db.session.close() +def batch_logic(monitor, current_watcher, account_name, debug): + # Fetch the full list of items that we need to obtain: + exception_map = current_watcher.slurp_list() + if len(exception_map) > 0: + # Get the location tuple to collect the region: + location = exception_map.keys()[0] + if len(location) > 2: + region = location[2] + else: + region = "unknown" + + app.logger.error("Exceptions have caused nothing to be fetched for {technology}" + "/{account}/{region}..." + " CANNOT CONTINUE FOR THIS WATCHER!".format(technology=current_watcher.i_am_plural, + account=account_name, + region=region)) + return + + while not current_watcher.done_slurping: + app.logger.debug("Fetching a batch of {batch} items for {technology}/{account}.".format( + batch=current_watcher.batched_size, technology=current_watcher.i_am_plural, account=account_name + )) + (items, exception_map) = current_watcher.slurp() + + audit_items = current_watcher.find_changes(current=items, exception_map=exception_map) + _audit_specific_changes(monitor, audit_items, False, debug) + + # Delete the items that no longer exist: + app.logger.debug("Deleting all items for {technology}/{account} that no longer exist.".format( + technology=current_watcher.i_am_plural, account=account_name + )) + current_watcher.find_deleted_batch(account_name) + + def audit_changes(accounts, monitor_names, send_report, debug=True): for account in accounts: monitors = get_monitors_and_dependencies(account, monitor_names, debug) for monitor in monitors: + # Skip batch support monitors... They have already been monitored. + if monitor.batch_support: + continue + _audit_changes(account, monitor.auditors, send_report, debug) @@ -87,7 +131,9 @@ def _audit_changes(account, auditors, send_report, debug=True): """ Runs auditors on all items """ try: for au in auditors: - au.audit_all_objects() + au.items = au.read_previous_items() + au.audit_objects() + # au.audit_all_objects() au.save_issues() if send_report: report = au.create_report() @@ -102,6 +148,33 @@ def _audit_changes(account, auditors, send_report, debug=True): store_exception("scheduler-audit-changes", None, e) +def _audit_specific_changes(monitor, audit_items, send_report, debug=True): + """ + Runs the auditor on specific items that are passed in. + :param monitor: + :param audit_items: + :param send_report: + :param debug: + :return: + """ + try: + for au in monitor.auditors: + au.items = audit_items + au.audit_objects() + au.save_issues() + if send_report: + report = au.create_report() + au.email_report(report) + + if jirasync: + app.logger.info('Syncing {} issues on {} with Jira'.format(au.index, monitor.watcher.accounts[0])) + jirasync.sync_issues(monitor.watcher.accounts, au.index) + except (OperationalError, InvalidRequestError, StatementError) as e: + app.logger.exception("Database error processing accounts %s, cleaning up session.", monitor.watcher.accounts[0]) + db.session.remove() + store_exception("scheduler-audit-changes", None, e) + + def _clear_old_exceptions(): print("Clearing out exceptions that have an expired TTL...") clear_old_exceptions() @@ -120,11 +193,13 @@ def _clear_old_exceptions(): misfire_grace_time=app.config.get('MISFIRE_GRACE_TIME', 30) ) + def exception_listener(event): store_exception("scheduler-change-reporter-uncaught", None, event.exception) scheduler.add_listener(exception_listener, events.EVENT_JOB_ERROR) + def setup_scheduler(): """Sets up the APScheduler""" log = logging.getLogger('apscheduler') @@ -149,7 +224,6 @@ def setup_scheduler(): auditors.extend(monitor.auditors) scheduler.add_cron_job(_audit_changes, hour=10, day_of_week="mon-fri", args=[account, auditors, True]) - # Clear out old exceptions: scheduler.add_cron_job(_clear_old_exceptions, hour=3, minute=0) diff --git a/security_monkey/tests/core/monitor_mock.py b/security_monkey/tests/core/monitor_mock.py index 625b16bb9..d80560b3c 100644 --- a/security_monkey/tests/core/monitor_mock.py +++ b/security_monkey/tests/core/monitor_mock.py @@ -32,6 +32,7 @@ class MockMonitor(object): def __init__(self, watcher, auditors): self.watcher = watcher self.auditors = auditors + self.batch_support = self.watcher.batched_size > 0 class MockRunnableWatcher(object): @@ -43,6 +44,11 @@ def __init__(self, index, interval): self.deleted_items = [] self.changed_items = [] + self.batched_size = 0 + self.done_slurping = True + self.total_list = [] + self.batch_counter = 0 + def slurp(self): RUNTIME_WATCHERS[self.index].append(self) item_list = [] @@ -64,14 +70,11 @@ def __init__(self, index, support_auditor_indexes, support_watcher_indexes): self.index = index self.support_auditor_indexes = support_auditor_indexes self.support_watcher_indexes = support_watcher_indexes + self.items = [] - def audit_all_objects(self): - item_count = RUNTIME_AUDIT_COUNTS.get(self.index, 0) - RUNTIME_AUDIT_COUNTS[self.index] = item_count + 1 - - def audit_these_objects(self, items): + def audit_objects(self): item_count = RUNTIME_AUDIT_COUNTS.get(self.index, 0) - RUNTIME_AUDIT_COUNTS[self.index] = item_count + len(items) + RUNTIME_AUDIT_COUNTS[self.index] = item_count + len(self.items) def save_issues(self): pass diff --git a/security_monkey/tests/core/test_auditor.py b/security_monkey/tests/core/test_auditor.py index a12c019a7..9c8ae0b63 100644 --- a/security_monkey/tests/core/test_auditor.py +++ b/security_monkey/tests/core/test_auditor.py @@ -29,16 +29,17 @@ from mixer.backend.flask import mixer -class TestAuditor(Auditor): +class AuditorTestObj(Auditor): index = 'test_index' i_am_singular = "test auditor" def __init__(self, accounts=None, debug=False): - super(TestAuditor, self).__init__(accounts=accounts, debug=debug) + super(AuditorTestObj, self).__init__(accounts=accounts, debug=debug) def check_test(self, item): self.add_issue(score=10, issue="Test issue", item=item) + class AuditorTestCase(SecurityMonkeyTestCase): def test_save_issues(self): mixer.init_app(self.app) @@ -52,7 +53,8 @@ def test_save_issues(self): auditor = Auditor(accounts=[test_account.name]) auditor.index = technology.name auditor.i_am_singular = technology.name - auditor.audit_all_objects() + auditor.items = auditor.read_previous_items() + auditor.audit_objects() try: auditor.save_issues() @@ -103,32 +105,34 @@ def test_link_to_support_item_issues(self): self.assertTrue(new_issue.sub_items[0] == sub_item) def test_audit_item(self): - auditor = TestAuditor(accounts=['test_account']) + auditor = AuditorTestObj(accounts=['test_account']) item = ChangeItem(index='test_index', account='test_account', name='item_name') self.assertEquals(len(item.audit_issues), 0) - auditor.audit_these_objects([item]) + auditor.items = [item] + auditor.audit_objects() self.assertEquals(len(item.audit_issues), 1) self.assertEquals(item.audit_issues[0].issue, 'Test issue') self.assertEquals(item.audit_issues[0].score, 10) def test_audit_item_method_disabled(self): mixer.init_app(self.app) - mixer.blend(ItemAuditScore, technology='test_index', method='check_test (TestAuditor)', + mixer.blend(ItemAuditScore, technology='test_index', method='check_test (AuditorTestObj)', score=0, disabled=True) - auditor = TestAuditor(accounts=['test_account']) + auditor = AuditorTestObj(accounts=['test_account']) item = ChangeItem(index='test_index', account='test_account', name='item_name') self.assertEquals(len(item.audit_issues), 0) - auditor.audit_these_objects([item]) + auditor.items = [item] + auditor.audit_objects() self.assertEquals(len(item.audit_issues), 0) def test_audit_item_method_score_override(self): mixer.init_app(self.app) - mixer.blend(ItemAuditScore, technology='test_index', method='check_test (TestAuditor)', + mixer.blend(ItemAuditScore, technology='test_index', method='check_test (AuditorTestObj)', score=5, disabled=False) test_account_type = mixer.blend(AccountType, name='AWS') test_account = mixer.blend(Account, name='test_account', account_type=test_account_type) @@ -136,9 +140,10 @@ def test_audit_item_method_score_override(self): item = ChangeItem(index='test_index', account=test_account.name, name='item_name') - auditor = TestAuditor(accounts=[test_account.name]) + auditor = AuditorTestObj(accounts=[test_account.name]) self.assertEquals(len(item.audit_issues), 0) - auditor.audit_these_objects([item]) + auditor.items = [item] + auditor.audit_objects() self.assertEquals(len(item.audit_issues), 1) self.assertEquals(item.audit_issues[0].issue, 'Test issue') self.assertEquals(item.audit_issues[0].score, 5) @@ -151,15 +156,16 @@ def test_audit_item_method_account_pattern_score_override(self): account_field='name', account_pattern=test_account.name, score=2) - mixer.blend(ItemAuditScore, technology='test_index', method='check_test (TestAuditor)', + mixer.blend(ItemAuditScore, technology='test_index', method='check_test (AuditorTestObj)', score=5, disabled=False, account_pattern_scores=[account_pattern_score]) item = ChangeItem(index='test_index', account=test_account.name, name='item_name') - auditor = TestAuditor(accounts=[test_account.name]) + auditor = AuditorTestObj(accounts=[test_account.name]) self.assertEquals(len(item.audit_issues), 0) - auditor.audit_these_objects([item]) + auditor.items = [item] + auditor.audit_objects() self.assertEquals(len(item.audit_issues), 1) self.assertEquals(item.audit_issues[0].issue, 'Test issue') self.assertEquals(item.audit_issues[0].score, 2) diff --git a/security_monkey/tests/core/test_datastore_utils.py b/security_monkey/tests/core/test_datastore_utils.py new file mode 100644 index 000000000..1032622f4 --- /dev/null +++ b/security_monkey/tests/core/test_datastore_utils.py @@ -0,0 +1,341 @@ +# Copyright 2017 Netflix, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.watchers.test_datastore_utils + :platform: Unix +.. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima +""" +import json + +from security_monkey.datastore import Account, Technology, AccountType, ItemAudit +from security_monkey.tests import SecurityMonkeyTestCase, db +from security_monkey.watcher import ChangeItem + +ACTIVE_CONF = { + "account_number": "012345678910", + "technology": "iamrole", + "region": "universal", + "name": "SomeRole", + "policy": { + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*" + } + ] + }, + "Arn": "arn:aws:iam::012345678910:role/SomeRole" +} + + +class SomeTestItem(ChangeItem): + def __init__(self, account=None, name=None, arn=None, config=None): + super(SomeTestItem, self).__init__( + index="iamrole", + region='universal', + account=account, + name=name, + arn=arn, + new_config=config or {}) + + @classmethod + def from_slurp(cls, role, **kwargs): + return cls( + account=kwargs['account_name'], + name=role['name'], + config=role, + arn=role['Arn']) + + +class SomeWatcher: + def __init__(self): + self.ephemeral_paths = [] + + +class DatabaseUtilsTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + pass + + def setup_db(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(identifier="012345678910", name="testing", + account_type_id=account_type_result.id) + self.technology = Technology(name="iamrole") + + db.session.add(self.account) + db.session.add(self.technology) + db.session.commit() + + def test_is_active(self): + from security_monkey.datastore_utils import is_active + + not_active = {"Arn": "arn:aws:iam::012345678910:role/someDeletedRole"} + assert not is_active(not_active) + + still_not_active = { + "account_number": "012345678910", + "technology": "iamrole", + "region": "universal", + "name": "somethingThatWasDeleted" + } + assert not is_active(still_not_active) + + assert is_active(ACTIVE_CONF) + + def test_create_revision(self): + from security_monkey.datastore_utils import create_revision + from security_monkey.datastore import Item + + self.setup_db() + + db_item = Item(region="universal", + name="SomeRole", + arn="arn:aws:iam::012345678910:role/SomeRole", + tech_id=self.technology.id, + account_id=self.account.id + ) + db.session.add(db_item) + db.session.commit() + + revision = create_revision(ACTIVE_CONF, db_item) + assert revision + assert revision.active + assert json.dumps(revision.config) == json.dumps(ACTIVE_CONF) + assert revision.item_id == db_item.id + + def test_create_item(self): + from security_monkey.datastore_utils import create_item + + self.setup_db() + + sti = SomeTestItem.from_slurp(ACTIVE_CONF, account_name=self.account.name) + + item = create_item(sti, self.technology, self.account) + assert item.region == "universal" + assert item.name == "SomeRole" + assert item.arn == "arn:aws:iam::012345678910:role/SomeRole" + assert item.tech_id == self.technology.id + assert item.account_id == self.account.id + + def test_hash_item(self): + from security_monkey.datastore_utils import hash_item + test_config = { + "SomeDurableProp": "is some value", + "ephemeralPath": "some thing that changes", + "some_area": { + "some_nested_place": { + "Durable": True + }, + "ephemeral": True + } + } + + ephemeral_paths = [ + "ephemeralPath", + "some_area*$ephemeral" + ] + + # Ran the first time -- verified that this is correct: + original_complete_hash = "85b8874a7ca98d7f5f4587d80d310bc5" + durable_hash = "1d1d718ea820b14f620f5262ae6d06fb" + + assert hash_item(test_config, ephemeral_paths) == (original_complete_hash, durable_hash) + + # Change a durable value: + test_config["SomeDurableProp"] = "is some OTHER value" + assert hash_item(test_config, ephemeral_paths) != (original_complete_hash, durable_hash) + + # Go back: + test_config["SomeDurableProp"] = "is some value" + assert hash_item(test_config, ephemeral_paths) == (original_complete_hash, durable_hash) + + # Change ephemeral values: + test_config["ephemeralPath"] = "askldjfpwojf0239f32" + test_ephemeral = hash_item(test_config, ephemeral_paths) + assert test_ephemeral[0] != original_complete_hash + assert test_ephemeral[1] == durable_hash + + def test_result_from_item(self): + from security_monkey.datastore_utils import result_from_item + from security_monkey.datastore import Item + + self.setup_db() + + item = Item(region="universal", + name="SomeRole", + arn="arn:aws:iam::012345678910:role/SomeRole", + tech_id=self.technology.id, + account_id=self.account.id + ) + + # This is actually what is passed into result_from_item: + sti = SomeTestItem().from_slurp(ACTIVE_CONF, account_name=self.account.name) + + assert not result_from_item(sti, self.account, self.technology) + + db.session.add(item) + db.session.commit() + + assert result_from_item(sti, self.account, self.technology).id == item.id + + def test_detect_change(self): + from security_monkey.datastore_utils import detect_change, hash_item + from security_monkey.datastore import Item + + self.setup_db() + + item = Item(region="universal", + name="SomeRole", + arn="arn:aws:iam::012345678910:role/SomeRole", + tech_id=self.technology.id, + account_id=self.account.id, + ) + + sti = SomeTestItem().from_slurp(ACTIVE_CONF, account_name=self.account.name) + + # Get the hash: + complete_hash, durable_hash = hash_item(sti.config, []) + + # Item does not exist in the DB yet: + assert (True, 'durable', None) == detect_change(sti, self.account, self.technology, complete_hash, + durable_hash) + + # Add the item to the DB: + db.session.add(item) + db.session.commit() + + # Durable change (nothing hashed in DB yet) + assert (True, 'durable', item) == detect_change(sti, self.account, self.technology, complete_hash, + durable_hash) + + # No change: + item.latest_revision_complete_hash = complete_hash + item.latest_revision_durable_hash = durable_hash + db.session.add(item) + db.session.commit() + + assert (False, None, item) == detect_change(sti, self.account, self.technology, complete_hash, + durable_hash) + + # Ephemeral change: + mod_conf = dict(ACTIVE_CONF) + mod_conf["IGNORE_ME"] = "I am ephemeral!" + complete_hash, durable_hash = hash_item(mod_conf, ["IGNORE_ME"]) + + assert (True, 'ephemeral', item) == detect_change(sti, self.account, self.technology, complete_hash, + durable_hash) + + def test_persist_item(self): + from security_monkey.datastore_utils import persist_item, hash_item, result_from_item + + self.setup_db() + + sti = SomeTestItem().from_slurp(ACTIVE_CONF, account_name=self.account.name) + + # Get the hash: + complete_hash, durable_hash = hash_item(sti.config, []) + + # Persist a durable change: + persist_item(sti, None, self.technology, self.account, complete_hash, durable_hash, True) + + db_item = result_from_item(sti, self.account, self.technology) + assert db_item + assert db_item.revisions.count() == 1 + assert db_item.latest_revision_durable_hash == durable_hash == complete_hash + assert db_item.latest_revision_complete_hash == complete_hash == durable_hash + + # No changes: + persist_item(sti, db_item, self.technology, self.account, complete_hash, durable_hash, True) + db_item = result_from_item(sti, self.account, self.technology) + assert db_item + assert db_item.revisions.count() == 1 + assert db_item.latest_revision_durable_hash == complete_hash == durable_hash + assert db_item.latest_revision_complete_hash == complete_hash == durable_hash + + # Ephemeral change: + mod_conf = dict(ACTIVE_CONF) + mod_conf["IGNORE_ME"] = "I am ephemeral!" + new_complete_hash, new_durable_hash = hash_item(mod_conf, ["IGNORE_ME"]) + sti = SomeTestItem().from_slurp(mod_conf, account_name=self.account.name) + persist_item(sti, db_item, self.technology, self.account, new_complete_hash, new_durable_hash, False) + + db_item = result_from_item(sti, self.account, self.technology) + assert db_item + assert db_item.revisions.count() == 1 + assert db_item.latest_revision_durable_hash == new_durable_hash == durable_hash + assert db_item.latest_revision_complete_hash == new_complete_hash != complete_hash + + def test_inactivate_old_revisions(self): + from security_monkey.datastore_utils import inactivate_old_revisions, hash_item, persist_item, result_from_item + from security_monkey.datastore import ItemRevision, Item + + self.setup_db() + + # Need to create 3 items first before we can test deletions: + for x in range(0, 3): + modConf = dict(ACTIVE_CONF) + modConf["name"] = "SomeRole{}".format(x) + modConf["Arn"] = "arn:aws:iam::012345678910:role/SomeRole{}".format(x) + + sti = SomeTestItem().from_slurp(modConf, account_name=self.account.name) + + # Get the hash: + complete_hash, durable_hash = hash_item(sti.config, []) + + # persist: + persist_item(sti, None, self.technology, self.account, complete_hash, durable_hash, True) + + db_item = result_from_item(sti, self.account, self.technology) + + # Add issues for these items: (just add two for testing purposes) + db.session.add(ItemAudit(score=10, + issue="IAM Role has full admin permissions.", + notes=json.dumps(sti.config), + item_id=db_item.id)) + db.session.add(ItemAudit(score=9001, issue="Some test issue", notes="{}", item_id=db_item.id)) + + db.session.commit() + + # Now, actually test for deleted revisions: + arns = [ + "arn:aws:iam::012345678910:role/SomeRole", # <-- Does not exist in the list + "arn:aws:iam::012345678910:role/SomeRole0", # <-- Does exist -- should not get deleted + ] + + inactivate_old_revisions(SomeWatcher(), arns, self.account, self.technology) + + # Check that SomeRole1 and SomeRole2 are marked as inactive: + for x in range(1, 3): + item_revision = ItemRevision.query.join((Item, ItemRevision.id == Item.latest_revision_id)).filter( + Item.arn == "arn:aws:iam::012345678910:role/SomeRole{}".format(x), + ).one() + + assert not item_revision.active + + # Check that there are no issues associated with this item: + assert len(ItemAudit.query.filter(ItemAudit.item_id == item_revision.item_id).all()) == 0 + + # Check that the SomeRole0 is still OK: + item_revision = ItemRevision.query.join((Item, ItemRevision.id == Item.latest_revision_id)).filter( + Item.arn == "arn:aws:iam::012345678910:role/SomeRole0".format(x), + ).one() + + assert len(ItemAudit.query.filter(ItemAudit.item_id == item_revision.item_id).all()) == 2 + + assert item_revision.active diff --git a/security_monkey/tests/core/test_reporter.py b/security_monkey/tests/core/test_reporter.py index 6e95548bd..7a879f7df 100644 --- a/security_monkey/tests/core/test_reporter.py +++ b/security_monkey/tests/core/test_reporter.py @@ -19,6 +19,12 @@ .. moduleauthor:: Bridgewater OSS """ +import json + +import boto3 +from moto import mock_iam +from moto import mock_sts + from security_monkey.tests import SecurityMonkeyTestCase from security_monkey.datastore import Account, AccountType from security_monkey.tests.core.monitor_mock import RUNTIME_WATCHERS, RUNTIME_AUDIT_COUNTS @@ -88,6 +94,16 @@ } ] +OPEN_POLICY = { + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*" + } + ] +} + def mock_report(self): pass @@ -95,7 +111,6 @@ def mock_report(self): @patch('security_monkey.monitors.all_monitors', mock_all_monitors) class ReporterTestCase(SecurityMonkeyTestCase): - def pre_test_setup(self): account_type_result = AccountType(name='AWS') db.session.add(account_type_result) @@ -284,3 +299,120 @@ def test_run_with_interval_watcher_dependencies(self): self.assertEqual(first=1, second=RUNTIME_AUDIT_COUNTS['index3'], msg="Auditor index3 should run once but ran {} times" .format(RUNTIME_AUDIT_COUNTS['index3'])) + + def add_roles(self, initial=True): + mock_iam().start() + mock_sts().start() + + mock_iam().start() + client = boto3.client("iam") + + aspd = { + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ] + } + + if initial: + last = 11 + else: + last = 9 # Simulates 2 deleted roles... + + for x in range(0, last): + # Create the IAM Role via Moto: + aspd["Statement"][0]["Resource"] = "arn:aws:iam:012345678910:role/roleNumber{}".format(x) + client.create_role(Path="/", RoleName="roleNumber{}".format(x), + AssumeRolePolicyDocument=json.dumps(aspd, indent=4)) + client.put_role_policy(RoleName="roleNumber{}".format(x), PolicyName="testpolicy", + PolicyDocument=json.dumps(OPEN_POLICY, indent=4)) + + + def test_report_batch_changes(self): + from security_monkey.alerter import Alerter + from security_monkey.reporter import Reporter + from security_monkey.datastore import Item, ItemRevision, ItemAudit + from security_monkey.monitors import Monitor + from security_monkey.watchers.iam.iam_role import IAMRole + from security_monkey.auditors.iam.iam_role import IAMRoleAuditor + + account_type_result = AccountType.query.filter(AccountType.name == "AWS").one() + db.session.add(account_type_result) + db.session.commit() + + test_account = Account(name="TEST_ACCOUNT") + watcher = IAMRole(accounts=[test_account.name]) + db.session.commit() + + watcher.batched_size = 3 # should loop 4 times + + self.add_roles() + + # Set up the monitor: + batched_monitor = Monitor(IAMRole, test_account) + batched_monitor.watcher = watcher + batched_monitor.auditors = [IAMRoleAuditor(accounts=[test_account.name])] + + # Set up the Reporter: + import security_monkey.reporter + old_all_monitors = security_monkey.reporter.all_monitors + security_monkey.reporter.all_monitors = lambda x, y: [] + + test_reporter = Reporter() + test_reporter.all_monitors = [batched_monitor] + test_reporter.account_alerter = Alerter(watchers_auditors=test_reporter.all_monitors, account=test_account.name) + + import security_monkey.scheduler + # import security_monkey.monitors + # old_get_monitors = security_monkey.scheduler.get_monitors + security_monkey.scheduler.get_monitors = lambda x, y, z: [batched_monitor] + + # Moto screws up the IAM Role ARN -- so we need to fix it: + original_slurp_list = watcher.slurp_list + original_slurp = watcher.slurp + + def mock_slurp_list(): + exception_map = original_slurp_list() + + for item in watcher.total_list: + item["Arn"] = "arn:aws:iam::012345678910:role/{}".format(item["RoleName"]) + + return exception_map + + def mock_slurp(): + batched_items, exception_map = original_slurp() + + for item in batched_items: + item.arn = "arn:aws:iam::012345678910:role/{}".format(item.name) + item.config["Arn"] = item.arn + item.config["RoleId"] = item.name # Need this to stay the same + + return batched_items, exception_map + + watcher.slurp_list = mock_slurp_list + watcher.slurp = mock_slurp + + test_reporter.run(account=test_account.name) + + # Check that all items were added to the DB: + assert len(Item.query.all()) == 11 + + # Check that we have exactly 11 item revisions: + assert len(ItemRevision.query.all()) == 11 + + # Check that there are audit issues for all 11 items: + assert len(ItemAudit.query.all()) == 11 + + mock_iam().stop() + mock_sts().stop() + + # Something isn't cleaning itself up properly and causing other core tests to fail. + # This is the solution: + security_monkey.reporter.all_monitors = old_all_monitors + import monitor_mock + security_monkey.scheduler.get_monitors = monitor_mock.mock_get_monitors diff --git a/security_monkey/tests/core/test_scheduler.py b/security_monkey/tests/core/test_scheduler.py index 8db2e4e59..4d813d284 100644 --- a/security_monkey/tests/core/test_scheduler.py +++ b/security_monkey/tests/core/test_scheduler.py @@ -19,8 +19,14 @@ .. moduleauthor:: Bridgewater OSS """ +import json + +import boto3 +from moto import mock_iam +from moto import mock_sts + from security_monkey.tests import SecurityMonkeyTestCase -from security_monkey.datastore import Account, AccountType +from security_monkey.datastore import Account, AccountType, Technology, Item, ItemAudit, ItemRevision from security_monkey.tests.core.monitor_mock import RUNTIME_WATCHERS, RUNTIME_AUDIT_COUNTS from security_monkey.tests.core.monitor_mock import build_mock_result from security_monkey.tests.core.monitor_mock import mock_get_monitors, mock_all_monitors @@ -28,6 +34,7 @@ from mock import patch +from security_monkey.watcher import ChangeItem, Watcher watcher_configs = [ {'index': 'index1', 'interval': 15}, @@ -35,7 +42,6 @@ {'index': 'index3', 'interval': 60} ] - auditor_configs = [ { 'index': 'index1', @@ -54,6 +60,44 @@ } ] +OPEN_POLICY = { + "Statement": [ + { + "Effect": "Allow", + "Action": "*", + "Resource": "*" + } + ] +} + +ROLE_CONF = { + "account_number": "012345678910", + "technology": "iamrole", + "region": "universal", + "name": "roleNumber", + "InlinePolicies": {"ThePolicy": OPEN_POLICY}, + "Arn": "arn:aws:iam::012345678910:role/roleNumber" +} + + +class SomeTestItem(ChangeItem): + def __init__(self, account=None, name=None, arn=None, config=None): + super(SomeTestItem, self).__init__( + index="iamrole", + region='universal', + account=account, + name=name, + arn=arn, + new_config=config or {}) + + @classmethod + def from_slurp(cls, role, **kwargs): + return cls( + account=kwargs['account_name'], + name=role['name'], + config=role, + arn=role['Arn']) + @patch('security_monkey.monitors.all_monitors', mock_all_monitors) @patch('security_monkey.monitors.get_monitors', mock_get_monitors) @@ -195,6 +239,155 @@ def test_find_account_changes(self): msg="Auditor index3 should have audited 1 item but audited {}" .format(RUNTIME_AUDIT_COUNTS['index3'])) + def test_find_batch_changes(self): + """ + Runs through a full find job via the IAMRole watcher, as that supports batching. + + However, this is mostly testing the logic through each function call -- this is + not going to do any boto work and that will instead be mocked out. + :return: + """ + from security_monkey.scheduler import find_changes + from security_monkey.monitors import Monitor + from security_monkey.watchers.iam.iam_role import IAMRole + from security_monkey.auditors.iam.iam_role import IAMRoleAuditor + + test_account = Account(name="TEST_ACCOUNT1") + watcher = IAMRole(accounts=[test_account.name]) + + technology = Technology(name="iamrole") + db.session.add(technology) + db.session.commit() + + watcher.batched_size = 3 # should loop 4 times + + self.add_roles() + + # Set up the monitor: + batched_monitor = Monitor(IAMRole, test_account) + batched_monitor.watcher = watcher + batched_monitor.auditors = [IAMRoleAuditor(accounts=[test_account.name])] + + import security_monkey.scheduler + security_monkey.scheduler.get_monitors = lambda x, y, z: [batched_monitor] + + # Moto screws up the IAM Role ARN -- so we need to fix it: + original_slurp_list = watcher.slurp_list + original_slurp = watcher.slurp + + def mock_slurp_list(): + exception_map = original_slurp_list() + + for item in watcher.total_list: + item["Arn"] = "arn:aws:iam::012345678910:role/{}".format(item["RoleName"]) + + return exception_map + + def mock_slurp(): + batched_items, exception_map = original_slurp() + + for item in batched_items: + item.arn = "arn:aws:iam::012345678910:role/{}".format(item.name) + item.config["Arn"] = item.arn + item.config["RoleId"] = item.name # Need this to stay the same + + return batched_items, exception_map + + watcher.slurp_list = mock_slurp_list + watcher.slurp = mock_slurp + + find_changes([test_account.name], test_account.name) + + # Check that all items were added to the DB: + assert len(Item.query.all()) == 11 + + # Check that we have exactly 11 item revisions: + assert len(ItemRevision.query.all()) == 11 + + # Check that there are audit issues for all 11 items: + assert len(ItemAudit.query.all()) == 11 + + # Delete one of the items: + # Moto lacks implementation for "delete_role" (and I'm too lazy to submit a PR :D) -- so need to create again... + mock_iam().stop() + mock_sts().stop() + self.add_roles(initial=False) + + # Run the it again: + watcher.current_account = None # Need to reset the watcher + find_changes([test_account.name], test_account.name) + + # Check that nothing new was added: + assert len(Item.query.all()) == 11 + + # There should be 2 less issues and 2 more revisions: + assert len(ItemAudit.query.all()) == 9 + assert len(ItemRevision.query.all()) == 13 + + # Check that the deleted roles show as being inactive: + ir = ItemRevision.query.join((Item, ItemRevision.id == Item.latest_revision_id)) \ + .filter(Item.arn.in_( + ["arn:aws:iam::012345678910:role/roleNumber9", + "arn:aws:iam::012345678910:role/roleNumber10"])).all() + + assert len(ir) == 2 + assert not ir[0].active + assert not ir[1].active + + # Finally -- test with a slurp list exception (just checking that things don't blow up): + def mock_slurp_list_with_exception(): + import security_monkey.watchers.iam.iam_role + security_monkey.watchers.iam.iam_role.list_roles = lambda **kwargs: 1 / 0 + + exception_map = original_slurp_list() + + assert len(exception_map) > 0 + return exception_map + + watcher.slurp_list = mock_slurp_list_with_exception + watcher.current_account = None # Need to reset the watcher + find_changes([test_account.name], test_account.name) + + mock_iam().stop() + mock_sts().stop() + + def test_audit_specific_changes(self): + from security_monkey.scheduler import _audit_specific_changes + from security_monkey.monitors import Monitor + from security_monkey.watchers.iam.iam_role import IAMRole, IAMRoleItem + from security_monkey.auditors.iam.iam_role import IAMRoleAuditor + + # Set up the monitor: + test_account = Account.query.filter(Account.name == "TEST_ACCOUNT1").one() + batched_monitor = Monitor(IAMRole, test_account) + batched_monitor.auditors = [IAMRoleAuditor(accounts=[test_account.name])] + + technology = Technology(name="iamrole") + db.session.add(technology) + db.session.commit() + + watcher = Watcher(accounts=[test_account.name]) + watcher.current_account = (test_account, 0) + watcher.technology = technology + + # Create some IAM roles for testing: + items = [] + for x in range(0, 3): + role_policy = dict(ROLE_CONF) + role_policy["Arn"] = "arn:aws:iam::012345678910:role/roleNumber{}".format(x) + role_policy["RoleName"] = "roleNumber{}".format(x) + role = IAMRoleItem.from_slurp(role_policy, account_name=test_account.name) + items.append(role) + + audit_items = watcher.find_changes_batch(items, {}) + assert len(audit_items) == 3 + + # Perform the audit: + _audit_specific_changes(batched_monitor, audit_items, False) + + # Check all the issues are there: + assert len(ItemAudit.query.all()) == 3 + def test_disable_all_accounts(self): from security_monkey.scheduler import disable_accounts disable_accounts(['TEST_ACCOUNT1', 'TEST_ACCOUNT2', 'TEST_ACCOUNT3', 'TEST_ACCOUNT4']) @@ -248,3 +441,35 @@ def test_disable_bad_accounts(self): self.assertTrue(account.active) else: self.assertFalse(account.active) + + def add_roles(self, initial=True): + mock_iam().start() + mock_sts().start() + + mock_iam().start() + client = boto3.client("iam") + + aspd = { + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ] + } + + if initial: + last = 11 + else: + last = 9 # Simulates 2 deleted roles... + + for x in range(0, last): + # Create the IAM Role via Moto: + aspd["Statement"][0]["Resource"] = "arn:aws:iam:012345678910:role/roleNumber{}".format(x) + client.create_role(Path="/", RoleName="roleNumber{}".format(x), + AssumeRolePolicyDocument=json.dumps(aspd, indent=4)) + client.put_role_policy(RoleName="roleNumber{}".format(x), PolicyName="testpolicy", + PolicyDocument=json.dumps(OPEN_POLICY, indent=4)) diff --git a/security_monkey/tests/core/test_watcher.py b/security_monkey/tests/core/test_watcher.py index d2127318a..35fd0a303 100644 --- a/security_monkey/tests/core/test_watcher.py +++ b/security_monkey/tests/core/test_watcher.py @@ -20,12 +20,15 @@ """ -from security_monkey.tests import SecurityMonkeyTestCase +import datetime +import json + from security_monkey.watcher import Watcher, ChangeItem -from security_monkey.datastore import Item, ItemAudit, Technology -from security_monkey.datastore import Account, AccountType, Datastore +from security_monkey.datastore import Account, AccountType, Datastore, Item, ItemAudit, Technology, ItemRevision from security_monkey import db +from security_monkey.tests import SecurityMonkeyTestCase + CONFIG_1 = { 'key1': 'value1', @@ -41,6 +44,61 @@ 'key4': 'newvalue' } +ACTIVE_CONF = { + "account_number": "012345678910", + "technology": "iamrole", + "region": "universal", + "name": "SomeRole", + "policy": { + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*" + } + ] + }, + "Arn": "arn:aws:iam::012345678910:role/SomeRole" +} + +ASPD = { + "Arn": "arn:aws:iam::012345678910:role/SomeRole", + "Path": "/", + "RoleId": "a2wdg1234x12ih4maj4mv", + "RoleName": "SomeRole", + "CreateDate": datetime.datetime.utcnow(), + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ] + } +} + + +class SomeTestItem(ChangeItem): + def __init__(self, account=None, name=None, arn=None, config=None): + super(SomeTestItem, self).__init__( + index="iamrole", + region='universal', + account=account, + name=name, + arn=arn, + new_config=config or {}) + + @classmethod + def from_slurp(cls, role, **kwargs): + return cls( + account=kwargs['account_name'], + name=role['name'], + config=role, + arn=role['Arn']) + class WatcherTestCase(SecurityMonkeyTestCase): def test_from_items(self): @@ -63,8 +121,20 @@ def test_from_items(self): assert len(merged_item_w_issues.audit_issues) == 1 assert len(merged_item_wo_issues.audit_issues) == 0 - def test_no_change_items(self): + def setup_batch_db(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(identifier="012345678910", name="testing", + account_type_id=account_type_result.id) + self.technology = Technology(name="iamrole") + db.session.add(self.account) + db.session.add(self.technology) + db.session.commit() + + def test_no_change_items(self): previous = [ ChangeItem( index='test_index', @@ -301,3 +371,108 @@ def _setup_account(self): db.session.add(account) db.session.commit() + + def test_find_changes_batch(self): + """ + This will test the entry point via the find_changes() method vs. the find_changes_batch() method. + + This will also use the IAMRole watcher, since that already has batching support. + :return: + """ + from security_monkey.watchers.iam.iam_role import IAMRole + + self.setup_batch_db() + + watcher = IAMRole(accounts=[self.account.name]) + watcher.current_account = (self.account, 0) + watcher.technology = self.technology + + items = [] + for x in range(0, 5): + mod_conf = dict(ACTIVE_CONF) + mod_conf["name"] = "SomeRole{}".format(x) + mod_conf["Arn"] = "arn:aws:iam::012345678910:role/SomeRole{}".format(x) + + items.append(SomeTestItem().from_slurp(mod_conf, account_name=self.account.name)) + + assert len(watcher.find_changes(items)) == 5 + + # Try again -- audit_items should be 0 since nothing was changed: + assert len(watcher.find_changes(items)) == 0 + + def test_find_deleted_batch(self): + """ + This will use the IAMRole watcher, since that already has batching support. + :return: + """ + from security_monkey.watchers.iam.iam_role import IAMRole + + self.setup_batch_db() + + # Set everything up: + watcher = IAMRole(accounts=[self.account.name]) + watcher.current_account = (self.account, 0) + watcher.technology = self.technology + + items = [] + for x in range(0, 5): + mod_conf = dict(ACTIVE_CONF) + mod_conf["name"] = "SomeRole{}".format(x) + mod_conf["Arn"] = "arn:aws:iam::012345678910:role/SomeRole{}".format(x) + items.append(SomeTestItem().from_slurp(mod_conf, account_name=self.account.name)) + + mod_aspd = dict(ASPD) + mod_aspd["Arn"] = "arn:aws:iam::012345678910:role/SomeRole{}".format(x) + mod_aspd["RoleName"] = "SomeRole{}".format(x) + watcher.total_list.append(mod_aspd) + + watcher.find_changes(items) + + # Check for deleted items: + watcher.find_deleted_batch({}) + + # Check that nothing was deleted: + for x in range(0, 5): + item_revision = ItemRevision.query.join((Item, ItemRevision.id == Item.latest_revision_id)).filter( + Item.arn == "arn:aws:iam::012345678910:role/SomeRole{}".format(x), + ).one() + + assert item_revision.active + + # Create some issues for testing purposes: + db.session.add(ItemAudit(score=10, + issue="IAM Role has full admin permissions.", + notes=json.dumps(item_revision.config), + item_id=item_revision.item_id)) + db.session.add(ItemAudit(score=9001, issue="Some test issue", notes="{}", item_id=item_revision.item_id)) + + db.session.commit() + assert len(ItemAudit.query.all()) == len(items) * 2 + + # Remove the last two items: + removed_arns = [] + removed_arns.append(watcher.total_list.pop()["Arn"]) + removed_arns.append(watcher.total_list.pop()["Arn"]) + + # Check for deleted items again: + watcher.find_deleted_batch({}) + + # Check that the last two items were deleted: + for arn in removed_arns: + item_revision = ItemRevision.query.join((Item, ItemRevision.id == Item.latest_revision_id)).filter( + Item.arn == arn, + ).one() + + assert not item_revision.active + assert len(ItemAudit.query.filter(ItemAudit.item_id == item_revision.item_id).all()) == 0 + + # Check that the current ones weren't deleted: + for current_item in watcher.total_list: + item_revision = ItemRevision.query.join((Item, ItemRevision.id == Item.latest_revision_id)).filter( + Item.arn == current_item["Arn"], + ).one() + + assert item_revision.active + assert len(ItemAudit.query.filter(ItemAudit.item_id == item_revision.item_id).all()) == 2 + + assert len(ItemAudit.query.all()) == len(watcher.total_list) * 2 diff --git a/security_monkey/tests/watchers/test_iam_role.py b/security_monkey/tests/watchers/test_iam_role.py new file mode 100644 index 000000000..e5f2880e8 --- /dev/null +++ b/security_monkey/tests/watchers/test_iam_role.py @@ -0,0 +1,163 @@ +# Copyright 2017 Netflix, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.watchers.test_iam_role + :platform: Unix +.. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima +""" +import json + +import boto3 +from moto import mock_iam +from moto import mock_sts + +from security_monkey.datastore import Account, Technology, ExceptionLogs, AccountType +from security_monkey.tests import SecurityMonkeyTestCase, db +from security_monkey.watchers.iam.iam_role import IAMRole + + +class IAMRoleTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + self.account = Account(identifier="012345678910", name="testing", + account_type_id=account_type_result.id) + self.technology = Technology(name="iamrole") + + self.total_roles = 75 + + db.session.add(self.account) + db.session.add(self.technology) + db.session.commit() + mock_iam().start() + client = boto3.client("iam") + + aspd = { + "Statement": [ + { + "Effect": "Allow", + "Action": "sts:AssumeRole", + "Principal": { + "Service": "ec2.amazonaws.com" + } + } + ] + } + + policy = { + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*" + } + ] + } + + for x in range(0, self.total_roles): + # Create the IAM Role via Moto: + aspd["Statement"][0]["Resource"] = "arn:aws:iam:012345678910:role/roleNumber{}".format(x) + client.create_role(Path="/", RoleName="roleNumber{}".format(x), + AssumeRolePolicyDocument=json.dumps(aspd, indent=4)) + client.put_role_policy(RoleName="roleNumber{}".format(x), PolicyName="testpolicy", + PolicyDocument=json.dumps(policy, indent=4)) + + def test_slurp_list(self): + mock_sts().start() + + watcher = IAMRole(accounts=[self.account.name]) + + exceptions = watcher.slurp_list() + + assert len(exceptions) == 0 + assert len(watcher.total_list) == self.total_roles + assert not watcher.done_slurping + + mock_sts().stop() + + def test_empty_slurp_list(self): + mock_sts().start() + + watcher = IAMRole(accounts=[self.account.name]) + watcher.list_roles = lambda **kwargs: [] + + exceptions = watcher.slurp_list() + assert len(exceptions) == 0 + assert len(watcher.total_list) == 0 + assert watcher.done_slurping + + mock_sts().stop() + + def test_slurp_list_exceptions(self): + mock_sts().start() + + watcher = IAMRole(accounts=[self.account.name]) + + def raise_exception(): + raise Exception("LOL, HAY!") + + import security_monkey.watchers.iam.iam_role + security_monkey.watchers.iam.iam_role.list_roles = lambda **kwargs: raise_exception() + + exceptions = watcher.slurp_list() + assert len(exceptions) == 1 + assert len(ExceptionLogs.query.all()) == 1 + + mock_sts().stop() + + def test_slurp_items(self): + mock_sts().start() + + watcher = IAMRole(accounts=[self.account.name]) + + # Or else this will take forever: + watcher.batched_size = 10 + watcher.slurp_list() + + items, exceptions = watcher.slurp() + assert len(exceptions) == 0 + assert self.total_roles > len(items) == watcher.batched_size + assert watcher.batch_counter == 1 + + # Slurp again: + items, exceptions = watcher.slurp() + assert len(exceptions) == 0 + assert self.total_roles > len(items) == watcher.batched_size + assert watcher.batch_counter == 2 + + mock_sts().stop() + + def test_slurp_items_with_exceptions(self): + mock_sts().start() + + watcher = IAMRole(accounts=[self.account.name]) + + # Or else this will take forever: + watcher.batched_size = 10 + watcher.slurp_list() + + def raise_exception(): + raise Exception("LOL, HAY!") + + import security_monkey.watchers.iam.iam_role + security_monkey.watchers.iam.iam_role.get_role = lambda **kwargs: raise_exception() + + items, exceptions = watcher.slurp() + assert len(exceptions) == self.total_roles + assert len(items) == 0 + + mock_sts().stop() diff --git a/security_monkey/watcher.py b/security_monkey/watcher.py index 9a0bf6931..9a3fe5178 100644 --- a/security_monkey/watcher.py +++ b/security_monkey/watcher.py @@ -13,10 +13,9 @@ from common.PolicyDiff import PolicyDiff from common.utils import sub_dict from security_monkey import app -from security_monkey.datastore import Account, IgnoreListEntry +from security_monkey.datastore import Account, IgnoreListEntry, db from security_monkey.datastore import Technology, WatcherConfig, store_exception from security_monkey.common.jinja import get_jinja_env -from security_monkey.common.utils import find_modules from security_monkey.alerters.custom_alerter import report_watcher_changes from boto.exception import BotoServerError @@ -29,6 +28,7 @@ watcher_registry = {} + class WatcherType(type): def __init__(cls, name, bases, attrs): super(WatcherType, cls).__init__(name, bases, attrs) @@ -36,6 +36,7 @@ def __init__(cls, name, bases, attrs): app.logger.debug("Registering watcher {} {}.{}".format(cls.index, cls.__module__, cls.__name__)) watcher_registry[cls.index] = cls + class Watcher(object): """Slurps the current config from AWS and compares it to what has previously been recorded in the database to find any changes.""" @@ -67,6 +68,16 @@ def __init__(self, accounts=None, debug=False): self.honor_ephemerals = False self.ephemeral_paths = [] + # Batching attributes: + self.batched_size = 0 # Don't batch anything by default + self.done_slurping = True # Don't batch anything by default + self.total_list = [] # This will hold the full list of items to batch over + self.batch_counter = 0 # Keeps track of the batch we are on -- can be used for retry logic + self.current_account = None # Tuple that holds the current account and account index we are on. + self.technology = None + # Region is probably not needed if we are using CloudAux's iter_account_region -- will test this in + # the future as we add more items with batching support. + def prep_for_slurp(self): """ Should be run before slurp is run to grab the IgnoreList. @@ -75,6 +86,40 @@ def prep_for_slurp(self): query = query.join((Technology, Technology.id == IgnoreListEntry.tech_id)) self.ignore_list = query.filter(Technology.name == self.index).all() + def prep_for_batch_slurp(self): + """ + Should be run before batching slurps to set the current account (and region). + + This will load the DB objects for account and technology for where we are currently at in the process. + :return: + """ + self.prep_for_slurp() + + # Which account are we currently on? + if not self.current_account: + index = 0 + + # Get the Technology + # If technology doesn't exist, then create it: + technology = Technology.query.filter(Technology.name == self.index).first() + if not technology: + technology = Technology(name=self.index) + db.session.add(technology) + db.session.commit() + app.logger.info("Technology: {} did not exist... created it...".format(self.index)) + + self.technology = technology + else: + index = self.current_account[1] + 1 + + self.current_account = (Account.query.filter(Account.name == self.accounts[index]).one(), index) + + # We will not be using CloudAux's iter_account_region for multi-account -- we want + # to have per-account level of batching + self.total_list = [] # Reset the total list for a new account to run against. + self.done_slurping = False + self.batch_counter = 0 + def check_ignore_list(self, name): """ See if the given item has a name flagging it to be ignored by security_monkey. @@ -155,6 +200,14 @@ def changed(self): """ return len(self.changed_items) > 0 + def slurp_list(self): + """ + This will fetch all the items in question that will need to get slurped. + This is used to know what we are going to have to batch up. + :return: + """ + raise NotImplementedError() + def slurp(self): """ method to slurp configuration from AWS for whatever it is that I'm @@ -295,16 +348,58 @@ def find_modified(self, previous=[], current=[], exception_map={}): self.changed_items.append(eph_change_item) app.logger.debug("%s: changes in item %s/%s/%s" % (self.i_am_singular, eph_change_item.account, eph_change_item.region, eph_change_item.name)) - def find_changes(self, current=[], exception_map={}): + def find_changes(self, current=None, exception_map=None): """ Identify changes between the configuration I have and what I had last time the watcher ran. This ignores any account/region which caused an exception during slurp. """ - prev = self.read_previous_items() - self.find_deleted(previous=prev, current=current, exception_map=exception_map) - self.find_new(previous=prev, current=current) - self.find_modified(previous=prev, current=current, exception_map=exception_map) + current = current or [] + exception_map = exception_map or {} + + # Batching only logic here: + if self.batched_size > 0: + # Return the items that should be audited: + return self.find_changes_batch(current, exception_map) + + else: + prev = self.read_previous_items() + self.find_deleted(previous=prev, current=current, exception_map=exception_map) + self.find_new(previous=prev, current=current) + self.find_modified(previous=prev, current=current, exception_map=exception_map) + + def find_changes_batch(self, items, exception_map): + # Given the list of items, find new items that don't yet exist: + durable_items = [] + + from security_monkey.datastore_utils import hash_item, detect_change, persist_item + for item in items: + complete_hash, durable_hash = hash_item(item.config, self.ephemeral_paths) + + # Detect if a change occurred: + is_change, change_type, db_item = detect_change(item, self.current_account[0], self.technology, + complete_hash, durable_hash) + + # As Officer Barbrady says: "Move along... Nothing to see here..." + if not is_change: + continue + + # Now call out to persist item: + is_durable = (change_type == "durable") + + persist_item(item, db_item, self.technology, self.current_account[0], complete_hash, + durable_hash, is_durable) + + if is_durable: + durable_items.append(item) + + return durable_items + + def find_deleted_batch(self, exception_map): + arns = [item["Arn"] for item in self.total_list] + + from datastore_utils import inactivate_old_revisions + return inactivate_old_revisions(self, arns, self.current_account[0], self.technology) def read_previous_items(self): """ @@ -492,7 +587,7 @@ def _dict_for_template(self): def description(self): """ Provide an HTML description of the object for change emails and the Jinja templates. - :return: string of HTML desribing the object. + :return: string of HTML describing the object. """ jenv = get_jinja_env() template = jenv.get_template('jinja_change_item.html') diff --git a/security_monkey/watchers/iam/iam_role.py b/security_monkey/watchers/iam/iam_role.py index 41af92000..2515752c5 100644 --- a/security_monkey/watchers/iam/iam_role.py +++ b/security_monkey/watchers/iam/iam_role.py @@ -16,12 +16,15 @@ :platform: Unix .. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima .. moduleauthor:: Patrick Kelley @monkeysecurity """ from cloudaux.orchestration.aws.iam.role import get_role from cloudaux.aws.iam import list_roles -from security_monkey.decorators import record_exception, iter_account_region +from cloudaux.decorators import iter_account_region + +from security_monkey.decorators import record_exception from security_monkey.watcher import ChangeItem from security_monkey.watcher import Watcher from security_monkey import app @@ -35,39 +38,99 @@ class IAMRole(Watcher): def __init__(self, accounts=None, debug=False): super(IAMRole, self).__init__(accounts=accounts, debug=debug) + self.batched_size = 100 + self.done_slurping = False + self.next_role = 0 + @record_exception(source="iamrole-watcher", pop_exception_fields=True) def list_roles(self, **kwargs): - roles = list_roles(**kwargs) + roles = list_roles(**kwargs["conn_dict"]) return [role for role in roles if not self.check_ignore_list(role['RoleName'])] @record_exception(source="iamrole-watcher", pop_exception_fields=True) def process_role(self, role, **kwargs): - app.logger.debug("Slurping {index} ({name}) from {account}".format( + app.logger.debug("Slurping {index} ({name}) from {account}/{region}".format( index=self.i_am_singular, name=role['RoleName'], - account=kwargs['account_number'])) - return get_role(role, **kwargs) + account=kwargs["conn_dict"]["account_number"], + region=kwargs["conn_dict"]["region"])) + + # Need to send a copy, since we don't want to alter the total list! + return get_role(dict(role), **kwargs["conn_dict"]) + + def slurp_list(self): + self.prep_for_batch_slurp() + exception_map = {} + + @iter_account_region("iam", accounts=[self.current_account[0].identifier], session_name="SecurityMonkey", + assume_role=self.current_account[0].getCustom("role_name") or 'SecurityMonkey', + regions=["us-east-1"], conn_type="dict") + def get_role_list(**kwargs): + app.logger.debug("Fetching the full list of {index} that need to be slurped from {account}" + "/{region}...".format(index=self.i_am_plural, + account=self.current_account[0].name, + region=kwargs["conn_dict"]["region"])) + roles = self.list_roles(index=self.index, exception_record_region="universal", + account_name=self.current_account[0].name, + exception_map=exception_map, + **kwargs) + + # Are there any roles? + if not roles: + self.done_slurping = True + roles = [] + + return roles + + for r in get_role_list(): + self.total_list.extend(r) + + return exception_map def slurp(self): - self.prep_for_slurp() + exception_map = {} + batched_items = [] - @iter_account_region(index=self.index, accounts=self.accounts, exception_record_region='universal') + @iter_account_region("iam", accounts=[self.current_account[0].identifier], session_name="SecurityMonkey", + assume_role=self.current_account[0].getCustom("role_name") or 'SecurityMonkey', + regions=["us-east-1"], conn_type="dict") def slurp_items(**kwargs): - item_list = [] - roles = self.list_roles(**kwargs) + item_list = [] # Only one region, so just keeping in iter_account_region... - for role in roles: - role = self.process_role(role, name=role['RoleName'], **kwargs) + # This sets the role counting index -- which will then be incremented as things progress... + role_counter = self.batch_counter * self.batched_size + while self.batched_size - len(item_list) > 0 and not self.done_slurping: + current_role = self.total_list[role_counter] + role = self.process_role(current_role, name=current_role["RoleName"], + index=self.index, exception_record_region="universal", + account_name=self.current_account[0].name, + exception_map=exception_map, + **kwargs) if role: - item = IAMRoleItem.from_slurp(role, **kwargs) + item = IAMRoleItem.from_slurp(role, account_name=self.current_account[0].name, **kwargs) item_list.append(item) - return item_list, kwargs.get('exception_map', {}) - return slurp_items() + # If an exception is encountered -- skip the role for now... + role_counter += 1 + + # Are we done yet? + if role_counter == len(self.total_list): + self.done_slurping = True + + self.batch_counter += 1 + + return item_list + + for r in slurp_items(): + batched_items.extend(r) + + return batched_items, exception_map class IAMRoleItem(ChangeItem): - def __init__(self, account=None, name=None, arn=None, config={}): + def __init__(self, account=None, name=None, arn=None, config=None): + config = config or {} + super(IAMRoleItem, self).__init__( index=IAMRole.index, region='universal', From 1724b0b6e79897e393781b8334d7558a3b52652c Mon Sep 17 00:00:00 2001 From: BobPeterson1881 Date: Thu, 23 Mar 2017 09:26:19 -0400 Subject: [PATCH 71/90] Fix security group rule parsing (Issue 661) Updated _build_rule and related calls to correct a problem parsing security groups returned via boto3 The function was only returning the first 'IpRanges' and/or 'UserIdGroupPairs' value from the 'IpPermission' and 'IpPermissionEgress' dictionary items. If there were multiple list items in 'IpRanges' or 'UserIdGroupPairs', those are not being included in the list of rules. Each item in these lists should be returned as a separate "rule" entry. --- security_monkey/watchers/security_group.py | 46 ++++++++++++---------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/security_monkey/watchers/security_group.py b/security_monkey/watchers/security_group.py index 6e04cd8b2..782732a3f 100644 --- a/security_monkey/watchers/security_group.py +++ b/security_monkey/watchers/security_group.py @@ -48,30 +48,34 @@ def get_detail_level(self): return 'NONE' def _build_rule(self, rule, rule_type): + rule_list=[] + #base rule information rule_config = { "ip_protocol": rule.get('IpProtocol'), "rule_type": rule_type, "from_port": rule.get('FromPort'), "to_port": rule.get('ToPort'), + "cidr_ip": None, + "owner_id": None, + "group_id": None, + "name": None } - - ips = rule.get('IpRanges') - if ips and len(ips) > 0: - rule_config['cidr_ip'] = ips[0].get('CidrIp') - else: - rule_config['cidr_ip'] = None - - user_id_group_pairs = rule.get('UserIdGroupPairs') - if user_id_group_pairs and len(user_id_group_pairs) > 0: - rule_config['owner_id'] = user_id_group_pairs[0].get('UserId') - rule_config['group_id'] = user_id_group_pairs[0].get('GroupId') - rule_config['name'] = user_id_group_pairs[0].get('GroupName') - else: - rule_config['owner_id'] = None - rule_config['group_id'] = None - rule_config['name'] = None - - return rule_config + + for ips in rule.get('IpRanges'): + #make a copy of the base rule info. + new_rule=rule_config.copy() + new_rule['cidr_ip'] = ips.get('CidrIp') + rule_list.append(new_rule) + + for user_id_group_pairs in rule.get('UserIdGroupPairs'): + #make a copy of the base rule info. + new_rule=rule_config.copy() + new_rule['owner_id'] = user_id_group_pairs.get('UserId') + new_rule['group_id'] = user_id_group_pairs.get('GroupId') + new_rule['name'] = user_id_group_pairs.get('GroupName') + rule_list.append(new_rule) + + return rule_list def slurp(self): """ @@ -173,10 +177,10 @@ def slurp(self): for rule in sg['IpPermissions']: - item_config['rules'].append(self._build_rule(rule, "ingress")) - + item_config['rules'] += self._build_rule(rule,"ingress") + for rule in sg['IpPermissionsEgress']: - item_config['rules'].append(self._build_rule(rule, "egress")) + item_config['rules'] += self._build_rule(rule,"egress") item_config['rules'] = sorted(item_config['rules']) From f5588368eda1ad05db554060be48f71995bf6ffa Mon Sep 17 00:00:00 2001 From: BobPeterson1881 Date: Thu, 23 Mar 2017 10:25:22 -0400 Subject: [PATCH 72/90] Update dashboard view filter links Updated the generated links in "Accounts" and "Technology Issue Scores" to properly filter the resulting view. Corrected the number of URL parameters that are passed. --- .../component/dashboard_component/dashboard_component.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dart/lib/component/dashboard_component/dashboard_component.html b/dart/lib/component/dashboard_component/dashboard_component.html index 608ce5c8f..052f75401 100644 --- a/dart/lib/component/dashboard_component/dashboard_component.html +++ b/dart/lib/component/dashboard_component/dashboard_component.html @@ -33,7 +33,7 @@

    Accounts

    - {{account.name}} + {{account.name}} {{account.total_score}} @@ -69,7 +69,7 @@

    Technology Issue Scores

    - {{tech.name}} + {{tech.name}} {{tech.score}} From abf63f029d0004024c6fd9cac0f99838f817ab19 Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Thu, 23 Mar 2017 18:47:18 +0000 Subject: [PATCH 73/90] Added __version__ property, util function and updated GCP code to use it. --- security_monkey/__init__.py | 3 +++ security_monkey/common/gcp/config.py | 4 ++-- security_monkey/common/utils.py | 4 ++++ setup.py | 9 ++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/security_monkey/__init__.py b/security_monkey/__init__.py index 27dbe01fc..2678c8e0f 100644 --- a/security_monkey/__init__.py +++ b/security_monkey/__init__.py @@ -19,6 +19,9 @@ .. moduleauthor:: Patrick Kelley """ +### VERSION ### +__version__ = '0.9.0' + ### FLASK ### from flask import Flask from flask import render_template diff --git a/security_monkey/common/gcp/config.py b/security_monkey/common/gcp/config.py index cd011ab9e..e1fc7c73a 100644 --- a/security_monkey/common/gcp/config.py +++ b/security_monkey/common/gcp/config.py @@ -21,11 +21,11 @@ """ class ApplicationConfig(object): - SECURITY_MONKEY_VERSION = '0.8.0' @staticmethod def get_version(): - return ApplicationConfig.SECURITY_MONKEY_VERSION + from security_monkey.common.utils import get_version + return get_version() class AuditorConfig(object): """ diff --git a/security_monkey/common/utils.py b/security_monkey/common/utils.py index a8bdb386a..8f9c5ff09 100644 --- a/security_monkey/common/utils.py +++ b/security_monkey/common/utils.py @@ -145,3 +145,7 @@ def load_plugins(group): for entry_point in pkg_resources.iter_entry_points(group): app.logger.debug("Loading plugin %s", entry_point.module_name) entry_point.load() + +def get_version(): + import security_monkey + return security_monkey.__version__ diff --git a/setup.py b/setup.py index 458ceaf85..e9fcabacc 100644 --- a/setup.py +++ b/setup.py @@ -11,11 +11,18 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +import re +import ast from setuptools import setup +_version_re = re.compile(r'__version__\s+=\s+(.*)') +with open('security_monkey/__init__.py', 'rb') as f: + SECURITY_MONKEY_VERSION = str(ast.literal_eval(_version_re.search( + f.read().decode('utf-8')).group(1))) + setup( name='security_monkey', - version='0.8.0', + version=SECURITY_MONKEY_VERSION, long_description=__doc__, packages=['security_monkey'], include_package_data=True, From 2a898615a766d5c34bc27a403bb73a2cdbfea26d Mon Sep 17 00:00:00 2001 From: Steve Date: Fri, 24 Mar 2017 09:56:47 +0000 Subject: [PATCH 74/90] Set the default value of SECURITY_REGISTERABLE to False --- docs/quickstart.rst | 2 +- env-config/config-deploy.py | 2 +- env-config/config-docker.py | 2 +- env-config/config-local.py | 2 +- scripts/secmonkey_auto_install.sh | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 520d14cce..711625b11 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -518,7 +518,7 @@ Edit /usr/local/src/security_monkey/env-config/config-deploy.py: SECRET_KEY = '' MAIL_DEFAULT_SENDER = 'securitymonkey@.com' - SECURITY_REGISTERABLE = True + SECURITY_REGISTERABLE = False SECURITY_CONFIRMABLE = False SECURITY_RECOVERABLE = False SECURITY_PASSWORD_HASH = 'bcrypt' diff --git a/env-config/config-deploy.py b/env-config/config-deploy.py index 583c7d483..a3a837d59 100644 --- a/env-config/config-deploy.py +++ b/env-config/config-deploy.py @@ -69,7 +69,7 @@ SECRET_KEY = '' MAIL_DEFAULT_SENDER = 'securitymonkey@example.com' -SECURITY_REGISTERABLE = True +SECURITY_REGISTERABLE = False SECURITY_CONFIRMABLE = False SECURITY_RECOVERABLE = False SECURITY_PASSWORD_HASH = 'bcrypt' diff --git a/env-config/config-docker.py b/env-config/config-docker.py index 83ae574de..40d5d2dbe 100644 --- a/env-config/config-docker.py +++ b/env-config/config-docker.py @@ -95,7 +95,7 @@ def env_to_bool(input): SECRET_KEY = os.getenv('SECURITY_MONKEY_SECRET_KEY', '') MAIL_DEFAULT_SENDER = os.getenv('SECURITY_MONKEY_EMAIL_DEFAULT_SENDER', 'securitymonkey@example.com') -SECURITY_REGISTERABLE = os.getenv('SECURITY_MONKEY_SECURITY_REGISTERABLE', 'True') +SECURITY_REGISTERABLE = os.getenv('SECURITY_MONKEY_SECURITY_REGISTERABLE', 'False') SECURITY_CONFIRMABLE = False SECURITY_RECOVERABLE = False SECURITY_PASSWORD_HASH = 'bcrypt' diff --git a/env-config/config-local.py b/env-config/config-local.py index 7a70383a5..75f15cd74 100644 --- a/env-config/config-local.py +++ b/env-config/config-local.py @@ -69,7 +69,7 @@ SECRET_KEY = '' MAIL_DEFAULT_SENDER = 'securitymonkey@example.com' -SECURITY_REGISTERABLE = True +SECURITY_REGISTERABLE = False SECURITY_CONFIRMABLE = False SECURITY_RECOVERABLE = False SECURITY_PASSWORD_HASH = 'bcrypt' diff --git a/scripts/secmonkey_auto_install.sh b/scripts/secmonkey_auto_install.sh index bc63c9f72..621598357 100755 --- a/scripts/secmonkey_auto_install.sh +++ b/scripts/secmonkey_auto_install.sh @@ -424,7 +424,7 @@ WEB_PATH = '/static/ui.html' SECRET_KEY = '${SECRET_KEY}' MAIL_DEFAULT_SENDER = '$sender' -SECURITY_REGISTERABLE = True +SECURITY_REGISTERABLE = False SECURITY_CONFIRMABLE = False SECURITY_RECOVERABLE = False SECURITY_PASSWORD_HASH = 'bcrypt' From a16cbd2f952eca92d02ca2ea5e0a3d80518729f1 Mon Sep 17 00:00:00 2001 From: Steve Date: Fri, 24 Mar 2017 10:53:20 +0000 Subject: [PATCH 75/90] Log Warning when S3 ACL can't be retrieved. If MFA is enabled on the bucket we can't retrieve the ACL. This just logs a warning and carries on. It should also fix issue #579 when an ACL doesn't actually exist or has been deleted. --- security_monkey/watchers/s3.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/security_monkey/watchers/s3.py b/security_monkey/watchers/s3.py index 521a7bb43..115350033 100644 --- a/security_monkey/watchers/s3.py +++ b/security_monkey/watchers/s3.py @@ -69,8 +69,11 @@ def slurp_items(**kwargs): bucket = self.process_bucket(bucket_name, name=bucket_name, **kwargs) if bucket: - item = S3Item.from_slurp(bucket_name, bucket, **kwargs) - item_list.append(item) + if bucket.has_key('Error'): + app.logger.warn("Couldn't obtain ACL for S3 bucket {}. Error: {}".format(bucket_name, bucket['Error'])) + else: + item = S3Item.from_slurp(bucket_name, bucket, **kwargs) + item_list.append(item) return item_list, kwargs.get('exception_map', {}) return slurp_items() From bf5d3d80b9d6bfa6ec2761643e37e98834d511e5 Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Tue, 21 Mar 2017 17:03:48 -0700 Subject: [PATCH 76/90] =?UTF-8?q?Adding=20utilities=20to=20get=20S3=20cano?= =?UTF-8?q?nical=20IDs.=20=F0=9F=86=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .travis.yml | 1 + docs/configuration.rst | 1 + docs/quickstart.rst | 1 + manage.py | 100 ++++--- migrations/versions/908b0085d28d_.py | 8 +- migrations/versions/b8ccf5b8089b_.py | 55 ++++ security_monkey/account_manager.py | 38 ++- .../account_managers/aws_account.py | 12 +- security_monkey/auditors/s3.py | 28 +- security_monkey/common/s3_canonical.py | 82 ++++++ security_monkey/common/utils.py | 1 + security_monkey/tests/auditors/test_s3.py | 247 ++++++++++++++++++ security_monkey/tests/utilities/__init__.py | 0 .../tests/utilities/test_s3_canonical.py | 179 +++++++++++++ 14 files changed, 693 insertions(+), 60 deletions(-) create mode 100644 migrations/versions/b8ccf5b8089b_.py create mode 100644 security_monkey/common/s3_canonical.py create mode 100644 security_monkey/tests/auditors/test_s3.py create mode 100644 security_monkey/tests/utilities/__init__.py create mode 100644 security_monkey/tests/utilities/test_s3_canonical.py diff --git a/.travis.yml b/.travis.yml index 285e31be4..c0c4c1ec4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -41,6 +41,7 @@ script: - coverage run -a -m py.test security_monkey/tests/core || exit 1 - coverage run -a -m py.test security_monkey/tests/views || exit 1 - coverage run -a -m py.test security_monkey/tests/interface || exit 1 + - coverage run -a -m py.test security_monkey/tests/utilities || exit 1 after_success: - coveralls diff --git a/docs/configuration.rst b/docs/configuration.rst index 01bfc8781..2f95e380e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -159,6 +159,7 @@ SM-ReadOnly "s3:getbucketversioning", "s3:getbucketwebsite", "s3:getlifecycleconfiguration", + "s3:listbucket", "s3:listallmybuckets", "s3:getreplicationconfiguration", "s3:getanalyticsconfiguration", diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 711625b11..e82d20231 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -183,6 +183,7 @@ Paste in this JSON with the name "SecurityMonkeyReadOnly": "s3:getbucketversioning", "s3:getbucketwebsite", "s3:getlifecycleconfiguration", + "s3:listbucket", "s3:listallmybuckets", "s3:getreplicationconfiguration", "s3:getanalyticsconfiguration", diff --git a/manage.py b/manage.py index 7a66a773e..cad9cd7e6 100644 --- a/manage.py +++ b/manage.py @@ -15,7 +15,9 @@ import sys from flask.ext.script import Manager, Command, Option, prompt_pass -from security_monkey.datastore import clear_old_exceptions, store_exception + +from security_monkey.common.s3_canonical import get_canonical_ids, fetch_id +from security_monkey.datastore import clear_old_exceptions, store_exception, AccountType from security_monkey import app, db from security_monkey.common.route53 import Route53Service @@ -34,6 +36,7 @@ try: from gunicorn.app.base import Application + GUNICORN = True except ImportError: # Gunicorn does not yet support Windows. @@ -42,7 +45,6 @@ print('Could not import gunicorn, skipping.') GUNICORN = False - manager = Manager(app) migrate = Migrate(app, db) manager.add_command('db', MigrateCommand) @@ -52,6 +54,7 @@ find_modules('auditors') load_plugins('security_monkey.plugins') + @manager.command def drop_db(): """ Drops the database. """ @@ -184,34 +187,35 @@ def amazon_accounts(): store_exception("manager-amazon-accounts", None, e) -@manager.option('-u', '--number', dest='number', type=unicode, required=True) -@manager.option('-a', '--active', dest='active', type=bool, default=True) -@manager.option('-t', '--thirdparty', dest='third_party', type=bool, default=False) -@manager.option('-n', '--name', dest='name', type=unicode, required=True) -@manager.option('-s', '--s3name', dest='s3_name', type=unicode, default=u'') -@manager.option('-o', '--notes', dest='notes', type=unicode, default=u'') -@manager.option('-y', '--type', dest='account_type', type=unicode, default=u'AWS') -@manager.option('-r', '--rolename', dest='role_name', type=unicode, default=u'SecurityMonkey') -@manager.option('-f', '--force', dest='force', help='Override existing accounts', action='store_true') -def add_account(number, third_party, name, s3_name, active, notes, account_type, role_name, force): - from security_monkey.account_manager import account_registry - account_manager = account_registry.get(account_type)() - account = account_manager.lookup_account_by_identifier(number) - if account: - from security_monkey.common.audit_issue_cleanup import clean_account_issues - clean_account_issues(account) - - if force: - account_manager.update(account.id, account_type, name, active, - third_party, notes, number, - custom_fields={ 's3_name': s3_name, 'role_name': role_name }) - else: - app.logger.info('Account with id {} already exists'.format(number)) - else: - account_manager.create(account_type, name, active, third_party, notes, number, - custom_fields={ 's3_name': s3_name, 'role_name': role_name }) - - db.session.close() +# DEPRECATED: +# @manager.option('-u', '--number', dest='number', type=unicode, required=True) +# @manager.option('-a', '--active', dest='active', type=bool, default=True) +# @manager.option('-t', '--thirdparty', dest='third_party', type=bool, default=False) +# @manager.option('-n', '--name', dest='name', type=unicode, required=True) +# @manager.option('-s', '--s3name', dest='s3_name', type=unicode, default=u'') +# @manager.option('-o', '--notes', dest='notes', type=unicode, default=u'') +# @manager.option('-y', '--type', dest='account_type', type=unicode, default=u'AWS') +# @manager.option('-r', '--rolename', dest='role_name', type=unicode, default=u'SecurityMonkey') +# @manager.option('-f', '--force', dest='force', help='Override existing accounts', action='store_true') +# def add_account(number, third_party, name, s3_name, active, notes, account_type, role_name, force): +# from security_monkey.account_manager import account_registry +# account_manager = account_registry.get(account_type)() +# account = account_manager.lookup_account_by_identifier(number) +# if account: +# from security_monkey.common.audit_issue_cleanup import clean_account_issues +# clean_account_issues(account) +# +# if force: +# account_manager.update(account.id, account_type, name, active, +# third_party, notes, number, +# custom_fields={ 's3_name': s3_name, 'role_name': role_name }) +# else: +# app.logger.info('Account with id {} already exists'.format(number)) +# else: +# account_manager.create(account_type, name, active, third_party, notes, number, +# custom_fields={ 's3_name': s3_name, 'role_name': role_name }) +# +# db.session.close() @manager.command @@ -350,6 +354,7 @@ def add_override_score(tech_name, method, auditor, score, disabled, pattern_scor db.session.commit() db.session.close() + @manager.option('-f', '--file_name', dest='file_name', type=str, required=True) @manager.option('-m', '--mappings', dest='field_mappings', type=str, required=False) def add_override_scores(file_name, field_mappings): @@ -490,7 +495,7 @@ def _parse_tech_names(tech_str): def _parse_accounts(account_str, active=True): if account_str == 'all': - accounts = Account.query.filter(Account.third_party==False).filter(Account.active==active).all() + accounts = Account.query.filter(Account.third_party == False).filter(Account.active == active).all() accounts = [account.name for account in accounts] return accounts else: @@ -508,7 +513,7 @@ def delete_account(name): # We are locking down the allowed intervals here to 15 minutes, 1 hour, 12 hours, 24 # hours or one week because too many different intervals could result in too many # scheduler threads, impacting performance. -@manager.option('-i', '--interval', dest='interval', type=int, default=60, choices= [15, 60, 720, 1440, 10080]) +@manager.option('-i', '--interval', dest='interval', type=int, default=60, choices=[15, 60, 720, 1440, 10080]) def add_watcher_config(tech_name, disabled, interval): from security_monkey.datastore import WatcherConfig from security_monkey.watcher import watcher_registry @@ -532,6 +537,22 @@ def add_watcher_config(tech_name, disabled, interval): db.session.close() +@manager.option("--override", dest="override", type=bool, default=True) +def fetch_aws_canonical_ids(override): + """ + Adds S3 canonical IDs in for all AWS accounts in SM. + """ + app.logger.info("[ ] Fetching S3 canonical IDs for all AWS accounts being monitored by Security Monkey.") + + # Get all the active AWS accounts: + accounts = Account.query.filter(Account.active == True) \ + .join(AccountType).filter(AccountType.name == "AWS").all() # noqa + + get_canonical_ids(accounts, override=override) + + app.logger.info("[@] Completed canonical ID fetching.") + + @manager.command def clean_stale_issues(): """ @@ -585,7 +606,6 @@ def load(self): class AddAccount(Command): - def __init__(self, account_manager, *args, **kwargs): super(AddAccount, self).__init__(*args, **kwargs) self._account_manager = account_manager @@ -598,6 +618,7 @@ def get_options(self): Option('--active', action='store_true'), Option('--notes', type=unicode), Option('--id', dest='identifier', type=unicode, required=True), + Option('--update-existing', action="store_true") ] for cf in self._account_manager.custom_field_configs: options.append(Option('--%s' % cf.name, dest=cf.name, type=str)) @@ -609,15 +630,26 @@ def handle(self, app, *args, **kwargs): thirdparty = kwargs.pop('thirdparty', False) notes = kwargs.pop('notes', u'') identifier = kwargs.pop('identifier') - self._account_manager.create( + update = kwargs.pop('update_existing', False) + if update: + result = self._account_manager.update( + self._account_manager.account_type, name, active, thirdparty, notes, identifier, + custom_fields=kwargs + ) + else: + result = self._account_manager.create( self._account_manager.account_type, name, active, thirdparty, notes, identifier, custom_fields=kwargs) db.session.close() + if not result: + return -1 + if __name__ == "__main__": from security_monkey.account_manager import account_registry + for name, account_manager in account_registry.items(): manager.add_command("add_account_%s" % name.lower(), AddAccount(account_manager())) manager.add_command("run_api_server", APIServer()) diff --git a/migrations/versions/908b0085d28d_.py b/migrations/versions/908b0085d28d_.py index c64ca301d..ceb8a2c41 100644 --- a/migrations/versions/908b0085d28d_.py +++ b/migrations/versions/908b0085d28d_.py @@ -61,13 +61,7 @@ def upgrade(): session.commit() print("[-] Deleted plaintext password from user: {}'s account".format(user.email)) - else: - print("[:D] User: {} has bcrypted password -- so all good!.".format(user.email)) - - else: - print("[:D] User: {} does not appear to be using username/password login, so all good!".format(user.email)) - - print("[@] Completed check.") + print("[@] Completed plaintext password check.") def downgrade(): diff --git a/migrations/versions/b8ccf5b8089b_.py b/migrations/versions/b8ccf5b8089b_.py new file mode 100644 index 000000000..eac8cde1d --- /dev/null +++ b/migrations/versions/b8ccf5b8089b_.py @@ -0,0 +1,55 @@ +"""Fetch the S3 Canonical IDs for all active AWS accounts. + +Revision ID: b8ccf5b8089b +Revises: 908b0085d28d +Create Date: 2017-03-23 11:00:43.792538 +Author: Mike Grima + +""" + +# revision identifiers, used by Alembic. +import sqlalchemy as sa + +from alembic import op +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +from manage import fetch_aws_canonical_ids + +Session = sessionmaker() +Base = declarative_base() + +revision = 'b8ccf5b8089b' +down_revision = '908b0085d28d' + +class Account(Base): + """ + Meant to model AWS accounts. + """ + __tablename__ = "account" + id = sa.Column(sa.Integer, primary_key=True) + active = sa.Column(sa.Boolean()) + third_party = sa.Column(sa.Boolean()) + name = sa.Column(sa.String(32), index=True, unique=True) + notes = sa.Column(sa.String(256)) + identifier = sa.Column(sa.String(256)) # Unique id of the account, the number for AWS. + account_type_id = sa.Column(sa.Integer, sa.ForeignKey("account_type.id"), nullable=False) + unique_const = sa.UniqueConstraint('account_type_id', 'identifier') + + +def upgrade(): + print("[-->] Adding canonical IDs to all AWS accounts that are active...") + bind = op.get_bind() + session = Session(bind=bind) + + # If there are currently no accounts, then skip... (avoids alembic issues...) + accounts = session.query(Account).all() + if len(accounts) > 0: + fetch_aws_canonical_ids(True) + + print("[@] Completed adding canonical IDs to all active AWS accounts...") + + +def downgrade(): + # No need to go back... + pass diff --git a/security_monkey/account_manager.py b/security_monkey/account_manager.py index a69d5c053..c09a0e9ee 100644 --- a/security_monkey/account_manager.py +++ b/security_monkey/account_manager.py @@ -34,7 +34,7 @@ class AccountManagerType(type): """ - Generates a global account regstry as AccountManager derived classes + Generates a global account registry as AccountManager derived classes are loaded """ def __init__(cls, name, bases, attrs): @@ -68,22 +68,22 @@ class AccountManager(object): identifier_label = None identifier_tool_tip = None - def update(self, account_id, account_type, name, active, third_party, notes, - identifier, custom_fields=None): + def update(self, account_type, name, active, third_party, notes, identifier, custom_fields=None): """ Updates an existing account in the database. """ account_type_result = _get_or_create_account_type(account_type) - query = Account.query.filter(Account.id == account_id) - if query.count(): - account = query.first() - else: - app.logger.info( - 'Account with id {} does not exist exists'.format(account_id)) + account = Account.query.filter(Account.name == name, Account.account_type_id == account_type_result.id).first() + if not account: + app.logger.error( + 'Account with name {} does not exist'.format(name)) return None - account = self._populate_account(account, account_type_result.id, name, - active, third_party, notes, identifier, custom_fields) + account.active = active + account.notes = notes + account.active = active + account.third_party = third_party + self._update_custom_fields(account, custom_fields) db.session.add(account) db.session.commit() @@ -98,6 +98,14 @@ def create(self, account_type, name, active, third_party, notes, identifier, Creates an account in the database. """ account_type_result = _get_or_create_account_type(account_type) + account = Account.query.filter(Account.name == name, Account.account_type_id == account_type_result.id).first() + + # Make sure the account doesn't already exist: + if account: + app.logger.error( + 'Account with name {} already exists!'.format(name)) + return None + account = Account() account = self._populate_account(account, account_type_result.id, name, active, third_party, notes, identifier, custom_fields) @@ -135,6 +143,12 @@ def _populate_account(self, account, account_type_id, name, active, third_party, account.active = active account.third_party = third_party account.account_type_id = account_type_id + + self._update_custom_fields(account, custom_fields) + + return account + + def _update_custom_fields(self, account, custom_fields): if account.custom_fields is None: account.custom_fields = [] @@ -152,8 +166,6 @@ def _populate_account(self, account, account_type_id, name, active, third_party, name=field_name, value=custom_fields.get(field_name)) account.custom_fields.append(new_value) - return account - def is_compatible_with_account_type(self, account_type): if self.account_type == account_type or account_type in self.compatable_account_types: return True diff --git a/security_monkey/account_managers/aws_account.py b/security_monkey/account_managers/aws_account.py index 71d8ee795..97e88ce8f 100644 --- a/security_monkey/account_managers/aws_account.py +++ b/security_monkey/account_managers/aws_account.py @@ -23,21 +23,27 @@ """ from security_monkey.account_manager import AccountManager, CustomFieldConfig -from security_monkey.datastore import Account class AWSAccountManager(AccountManager): account_type = 'AWS' identifier_label = 'Number' identifier_tool_tip = 'Enter the AWS account number, if you have it. (12 digits)' - s3_name_label = ('The S3 Name is the way AWS presents the account in an ACL policy. ' - 'This is often times the first part of the email address that was used ' + s3_name_label = ('[DEPRECATED -- use canonical id] The S3 Name is the way AWS presents the account ' + 'in an ACL policy. This is often times the first part of the email address that was used ' 'to create the Amazon account. (myaccount@example.com may be represented ' 'as myaccount\). If you see S3 issues appear for unknown cross account ' 'access, you may need to update the S3 Name.') + s3_canonical_id = ('The Canonical ID is the way AWS presents the account in an ACL policy. ' + 'It is a unique set of characters that is tied to an AWS account. ' + 'If you see S3 issues appear for unknown cross account ' + 'access, you may need to update the canonical ID. A manager.py command has been ' + 'included that can fetch this for you automatically (fetch_aws_canonical_ids), since it ' + 'requires a \'list_buckets\' API call against AWS to obtain.') role_name_label = ("Optional custom role name, otherwise the default 'SecurityMonkey' is used. " "When deploying roles via CloudFormation, this is the Physical ID of the generated IAM::ROLE.") custom_field_configs = [ + CustomFieldConfig('canonical_id', "Canonical ID", True, s3_canonical_id), CustomFieldConfig('s3_name', 'S3 Name', True, s3_name_label), CustomFieldConfig('role_name', 'Role Name', True, role_name_label) ] diff --git a/security_monkey/auditors/s3.py b/security_monkey/auditors/s3.py index 9b334f929..a6ed856b0 100644 --- a/security_monkey/auditors/s3.py +++ b/security_monkey/auditors/s3.py @@ -36,7 +36,12 @@ def __init__(self, accounts=None, debug=False): def check_acl(self, s3_item): accounts = Account.query.all() S3_ACCOUNT_NAMES = [account.getCustom("s3_name").lower() for account in accounts if not account.third_party and account.getCustom("s3_name")] + S3_CANONICAL_IDS = [account.getCustom("canonical_id").lower() for account in accounts if not account.third_party and account.getCustom("canonical_id")] S3_THIRD_PARTY_ACCOUNTS = [account.getCustom("s3_name").lower() for account in accounts if account.third_party and account.getCustom("s3_name")] + S3_THIRD_PARTY_ACCOUNT_CANONICAL_IDS = [account.getCustom("canonical_id").lower() for account in accounts if account.third_party and account.getCustom("canonical_id")] + + # Get the owner ID: + owner = s3_item.config["Owner"]["ID"].lower() acl = s3_item.config.get('Grants', {}) for user in acl.keys(): @@ -52,6 +57,8 @@ def check_acl(self, s3_item): message = "ACL - LogDelivery USED." notes = "{}".format(",".join(acl[user])) self.add_issue(0, message, s3_item, notes=notes) + + # DEPRECATED: elif user.lower() in S3_ACCOUNT_NAMES: message = "ACL - Friendly Account Access." notes = "{} {}".format(",".join(acl[user]), user) @@ -60,6 +67,21 @@ def check_acl(self, s3_item): message = "ACL - Friendly Third Party Access." notes = "{} {}".format(",".join(acl[user]), user) self.add_issue(0, message, s3_item, notes=notes) + + elif user.lower() in S3_CANONICAL_IDS: + # Owning account -- no issue + if user.lower() == owner.lower(): + continue + + message = "ACL - Friendly Account Access." + notes = "{} {}".format(",".join(acl[user]), user) + self.add_issue(0, message, s3_item, notes=notes) + + elif user.lower() in S3_THIRD_PARTY_ACCOUNT_CANONICAL_IDS: + message = "ACL - Friendly Third Party Access." + notes = "{} {}".format(",".join(acl[user]), user) + self.add_issue(0, message, s3_item, notes=notes) + else: message = "ACL - Unknown Cross Account Access." notes = "{} {}".format(",".join(acl[user]), user) @@ -103,19 +125,19 @@ def inspect_policy_cross_account(self, statement, s3_item, complained): aws_entries = principal["AWS"] if type(aws_entries) is str or type(aws_entries) is unicode: if aws_entries[0:26] not in complained: - self.processCrossAccount(aws_entries, s3_item) + self.process_cross_account(aws_entries, s3_item) complained.append(aws_entries[0:26]) else: for aws_entry in aws_entries: if aws_entry[0:26] not in complained: - self.processCrossAccount(aws_entry, s3_item) + self.process_cross_account(aws_entry, s3_item) complained.append(aws_entry[0:26]) except Exception as e: print("Exception in cross_account. {} {}".format(Exception, e)) import traceback print(traceback.print_exc()) - def processCrossAccount(self, input, s3_item): + def process_cross_account(self, input, s3_item): from security_monkey.common.arn import ARN arn = ARN(input) diff --git a/security_monkey/common/s3_canonical.py b/security_monkey/common/s3_canonical.py new file mode 100644 index 000000000..9459e2d78 --- /dev/null +++ b/security_monkey/common/s3_canonical.py @@ -0,0 +1,82 @@ +# Copyright 2017 Netflix, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.common.s3_canonical + :platform: Unix + :synopsis: Fetchs the S3 canonical IDs for a given AWS account. + +.. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima + +""" +from cloudaux.decorators import iter_account_region +from cloudaux.aws.s3 import list_buckets + +from security_monkey import app, db +from security_monkey.datastore import AccountTypeCustomValues +from security_monkey.decorators import record_exception + + +def get_canonical_ids(accounts, override=False): + """ + Given a list of AWS Account IDs, reach out to AWS to fetch the Canonical IDs + :param override: + :param accounts: + :return: + """ + if not override: + app.logger.info("[@] Override flag was not passed in -- will skip over accounts with a canonical " + "ID associated.") + + # Loop over each account manually: + for account in accounts: + # Does the account already have the canonical ID set? + current_canonical = AccountTypeCustomValues.query \ + .filter(AccountTypeCustomValues.name == "canonical_id", + AccountTypeCustomValues.account_id == account.id).first() + if not override and current_canonical: + app.logger.info("[/] Account {} already has a canonical ID associated... Skipping...".format(account.name)) + continue + + @iter_account_region("s3", accounts=[account.identifier], regions=["us-east-1"], + assume_role=account.getCustom("role_name") or "SecurityMonkey", + session_name="SecurityMonkey", conn_type="dict") + def loop_over_accounts(**kwargs): + app.logger.info("[-->] Fetching canonical ID for account: {}".format(account.name)) + + return fetch_id(index="s3", exception_record_region="us-east-1", account_name=account.name, + exception_map={}, **kwargs) + + result = loop_over_accounts() + + if not result: + app.logger.error("[x] Did not receive a proper response back. Check the exception log for details.") + continue + + # Fetch out the owner: + app.logger.info("[+] Associating Canonical ID: {} with account: {}".format(result[0]["Owner"]["ID"], + account.name)) + + if not current_canonical: + current_canonical = AccountTypeCustomValues(account_id=account.id, name="canonical_id") + + current_canonical.value = result[0]["Owner"]["ID"] + + db.session.add(current_canonical) + db.session.commit() + + +@record_exception(source="canonical-id-fetcher", pop_exception_fields=True) +def fetch_id(**kwargs): + return list_buckets(**kwargs["conn_dict"]) diff --git a/security_monkey/common/utils.py b/security_monkey/common/utils.py index 8f9c5ff09..17a09a9fa 100644 --- a/security_monkey/common/utils.py +++ b/security_monkey/common/utils.py @@ -137,6 +137,7 @@ def find_modules(folder): app.logger.debug("Loading module %s from %s", modname, os.path.join(root,fname)) module=imp.load_source(modname, os.path.join(root,fname)) + def load_plugins(group): """Find and load plugins by iterating entry points.""" diff --git a/security_monkey/tests/auditors/test_s3.py b/security_monkey/tests/auditors/test_s3.py new file mode 100644 index 000000000..d3cf2555f --- /dev/null +++ b/security_monkey/tests/auditors/test_s3.py @@ -0,0 +1,247 @@ +# Copyright 2017 Netflix, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.auditors.test_s3 + :platform: Unix + +.. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima + +""" +import json + +from security_monkey.auditors.s3 import S3Auditor +from security_monkey.datastore import Account, AccountType, AccountTypeCustomValues +from security_monkey.tests import SecurityMonkeyTestCase +from security_monkey import db + +from security_monkey.watchers.s3 import S3Item + +# With same account ownership: +CONFIG_ONE = json.loads(b"""{ + "Acceleration": null, + "AnalyticsConfigurations": [], + "Arn": "arn:aws:s3:::bucket1", + "Cors": [], + "GrantReferences": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": "test_accnt1" + }, + "Grants": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": [ + "FULL_CONTROL" + ] + }, + "InventoryConfigurations": [], + "LifecycleRules": [], + "Logging": {}, + "MetricsConfigurations": [], + "Notifications": {}, + "Owner": { + "ID": "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389" + }, + "Policy": null, + "Region": "us-east-1", + "Replication": {}, + "Tags": { + "LOL": "UNITTESTS" + }, + "Versioning": {}, + "Website": null, + "_version": 5 + } + """) + +# ACL with unknown access: +CONFIG_TWO = json.loads(b"""{ + "Acceleration": null, + "AnalyticsConfigurations": [], + "Arn": "arn:aws:s3:::bucket2", + "Cors": [], + "GrantReferences": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": "test_accnt1" + }, + "Grants": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": [ + "FULL_CONTROL" + ], + "34589673489752397023749287uiouwshjksdhfdjkshfdjkshf2381": [ + "FULL_CONTROL" + ] + }, + "InventoryConfigurations": [], + "LifecycleRules": [], + "Logging": {}, + "MetricsConfigurations": [], + "Notifications": {}, + "Owner": { + "ID": "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389" + }, + "Policy": null, + "Region": "us-east-1", + "Replication": {}, + "Tags": { + "LOL": "UNITTESTS" + }, + "Versioning": {}, + "Website": null, + "_version": 5 + } + """) + +# ACL with friendly access: +CONFIG_THREE = json.loads(b"""{ + "Acceleration": null, + "AnalyticsConfigurations": [], + "Arn": "arn:aws:s3:::bucket3", + "Cors": [], + "GrantReferences": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": "test_accnt1", + "lksdjfilou32890u47238974189237euhuu128937192837189uyh1hr3": "test_accnt2" + }, + "Grants": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": [ + "FULL_CONTROL" + ], + "lksdjfilou32890u47238974189237euhuu128937192837189uyh1hr3": [ + "FULL_CONTROL" + ] + }, + "InventoryConfigurations": [], + "LifecycleRules": [], + "Logging": {}, + "MetricsConfigurations": [], + "Notifications": {}, + "Owner": { + "ID": "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389" + }, + "Policy": null, + "Region": "us-east-1", + "Replication": {}, + "Tags": { + "LOL": "UNITTESTS" + }, + "Versioning": {}, + "Website": null, + "_version": 5 + } + """) + +# ACL with friendly 3rd party account access: +CONFIG_FOUR = json.loads(b"""{ + "Acceleration": null, + "AnalyticsConfigurations": [], + "Arn": "arn:aws:s3:::bucket4", + "Cors": [], + "GrantReferences": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": "test_accnt1" + }, + "Grants": { + "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389": [ + "FULL_CONTROL" + ], + "dsfhgiouhy23984723789y4riuwhfkajshf91283742389u823723": [ + "FULL_CONTROL" + ] + }, + "InventoryConfigurations": [], + "LifecycleRules": [], + "Logging": {}, + "MetricsConfigurations": [], + "Notifications": {}, + "Owner": { + "ID": "23984723987489237489237489237489uwedfjhdsjklfhksdfh2389" + }, + "Policy": null, + "Region": "us-east-1", + "Replication": {}, + "Tags": { + "LOL": "UNITTESTS" + }, + "Versioning": {}, + "Website": null, + "_version": 5 + } + """) + + +asdf = "dsfhgiouhy23984723789y4riuwhfkajshf91283742389u823723" + +class S3AuditorTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + self.s3_items = [ + S3Item(region="us-east-1", account="TEST_ACCOUNT", name="bucket1", config=CONFIG_ONE), + S3Item(region="us-east-1", account="TEST_ACCOUNT", name="bucket2", config=CONFIG_TWO), + S3Item(region="us-east-1", account="TEST_ACCOUNT2", name="bucket3", config=CONFIG_THREE), + S3Item(region="us-east-1", account="TEST_ACCOUNT3", name="bucket4", config=CONFIG_FOUR) + ] + + account_type_result = AccountType(name='AWS') + db.session.add(account_type_result) + db.session.commit() + + account = Account(identifier="012345678910", name="TEST_ACCOUNT", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT", + third_party=False, active=True) + account.custom_fields.append(AccountTypeCustomValues(name="canonical_id", + value="23984723987489237489237489237489uwedfjhdsjklfhksdf" + "h2389")) + account.custom_fields.append(AccountTypeCustomValues(name="s3_name", value="test_accnt1")) + + account2 = Account(identifier="012345678911", name="TEST_ACCOUNT2", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT2", + third_party=False, active=True) + + account2.custom_fields.append(AccountTypeCustomValues(name="canonical_id", + value="lksdjfilou32890u47238974189237euhuu128937192837189" + "uyh1hr3")) + account2.custom_fields.append(AccountTypeCustomValues(name="s3_name", value="test_accnt2")) + + account3 = Account(identifier="012345678912", name="TEST_ACCOUNT3", + account_type_id=account_type_result.id, notes="TEST_ACCOUNT3", + third_party=True, active=True) + + account3.custom_fields.append(AccountTypeCustomValues(name="canonical_id", + value="dsfhgiouhy23984723789y4riuwhfkajshf91283742389u" + "823723")) + account3.custom_fields.append(AccountTypeCustomValues(name="s3_name", value="test_accnt3")) + + db.session.add(account) + db.session.add(account2) + db.session.add(account3) + db.session.commit() + + def test_s3_acls(self): + s3_auditor = S3Auditor(accounts=["012345678910"]) + + # CONFIG ONE: + s3_auditor.check_acl(self.s3_items[0]) + assert len(self.s3_items[0].audit_issues) == 0 + + # CONFIG TWO: + s3_auditor.check_acl(self.s3_items[1]) + assert len(self.s3_items[1].audit_issues) == 1 + assert self.s3_items[1].audit_issues[0].score == 10 + assert self.s3_items[1].audit_issues[0].issue == "ACL - Unknown Cross Account Access." + + # CONFIG THREE: + s3_auditor.check_acl(self.s3_items[2]) + assert len(self.s3_items[2].audit_issues) == 1 + assert self.s3_items[2].audit_issues[0].score == 0 + assert self.s3_items[2].audit_issues[0].issue == "ACL - Friendly Account Access." + + # CONFIG FOUR: + s3_auditor.check_acl(self.s3_items[3]) + assert len(self.s3_items[3].audit_issues) == 1 + assert self.s3_items[3].audit_issues[0].score == 0 + assert self.s3_items[3].audit_issues[0].issue == "ACL - Friendly Third Party Access." diff --git a/security_monkey/tests/utilities/__init__.py b/security_monkey/tests/utilities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/security_monkey/tests/utilities/test_s3_canonical.py b/security_monkey/tests/utilities/test_s3_canonical.py new file mode 100644 index 000000000..693e812c1 --- /dev/null +++ b/security_monkey/tests/utilities/test_s3_canonical.py @@ -0,0 +1,179 @@ +# Copyright 2017 Netflix, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +.. module: security_monkey.tests.utilities.test_s3_canonical + :platform: Unix +.. version:: $$VERSION$$ +.. moduleauthor:: Mike Grima +""" +import unittest + +from manage import fetch_aws_canonical_ids, AddAccount, manager +from security_monkey import db +from security_monkey.common.s3_canonical import get_canonical_ids +from security_monkey.datastore import AccountType, Account, ExceptionLogs, AccountTypeCustomValues +from security_monkey.tests import SecurityMonkeyTestCase + +from moto import mock_sts, mock_s3 + +import boto3 + + +class S3CanonicalTestCase(SecurityMonkeyTestCase): + def pre_test_setup(self): + self.account_type = AccountType(name='AWS') + db.session.add(self.account_type) + db.session.commit() + + for x in range(0, 9): + db.session.add(Account(name="account{}".format(x), account_type_id=self.account_type.id, + identifier="01234567891{}".format(x), active=True)) + + db.session.commit() + + mock_sts().start() + mock_s3().start() + + self.s3_client = boto3.client("s3") + self.s3_client.create_bucket(Bucket="testBucket") + + def test_get_canonical_ids(self): + accounts = Account.query.all() + get_canonical_ids(accounts) + + for account in accounts: + assert len(account.custom_fields) == 1 + assert account.custom_fields[0].name == "canonical_id" + assert account.custom_fields[0].value == "bcaf1ffd86f41161ca5fb16fd081034f" # Default from moto. + + # Make it something else to test overrides: + account.custom_fields[0].value = "replaceme" + db.session.add(account) + + db.session.commit() + + # Test without override (nothing should be changed): + get_canonical_ids(accounts) + for account in accounts: + assert len(account.custom_fields) == 1 + assert account.custom_fields[0].name == "canonical_id" + assert account.custom_fields[0].value == "replaceme" + + # Test override: + get_canonical_ids(accounts, override=True) + for account in accounts: + assert len(account.custom_fields) == 1 + assert account.custom_fields[0].name == "canonical_id" + assert account.custom_fields[0].value == "bcaf1ffd86f41161ca5fb16fd081034f" # Default from moto. + + mock_sts().stop() + mock_s3().stop() + + def test_fetch_aws_canonical_ids_command(self): + accounts = Account.query.all() + fetch_aws_canonical_ids(False) + + for account in accounts: + assert len(account.custom_fields) == 1 + assert account.custom_fields[0].name == "canonical_id" + assert account.custom_fields[0].value == "bcaf1ffd86f41161ca5fb16fd081034f" # Default from moto. + + # Make it something else to test overrides: + account.custom_fields[0].value = "replaceme" + db.session.add(account) + + db.session.commit() + + # Test without override (nothing should be changed): + fetch_aws_canonical_ids(False) + for account in accounts: + assert len(account.custom_fields) == 1 + assert account.custom_fields[0].name == "canonical_id" + assert account.custom_fields[0].value == "replaceme" + + # Test override: + fetch_aws_canonical_ids(True) + for account in accounts: + assert len(account.custom_fields) == 1 + assert account.custom_fields[0].name == "canonical_id" + assert account.custom_fields[0].value == "bcaf1ffd86f41161ca5fb16fd081034f" # Default from moto. + + # Create an inactive account: + inactive = Account(name="inactive", account_type_id=self.account_type.id, + identifier="109876543210") + db.session.add(inactive) + db.session.commit() + + # Run the change again: + fetch_aws_canonical_ids(True) + + # Ensure that nothing happened to the inactive account: + assert len(inactive.custom_fields) == 0 + + # Also verify that no exceptions were encountered: + assert len(ExceptionLogs.query.all()) == 0 + + mock_sts().stop() + mock_s3().stop() + + def test_create_account_with_canonical(self): + from security_monkey.account_manager import account_registry + + for name, account_manager in account_registry.items(): + manager.add_command("add_account_%s" % name.lower(), AddAccount(account_manager())) + + manager.handle("manage.py", ["add_account_aws", "-n", "test", "--active", "--id", "99999999999", + "--canonical_id", "bcaf1ffd86f41161ca5fb16fd081034f", "--s3_name", "test", + "--role_name", "SecurityMonkey"]) + + account = Account.query.filter(Account.name == "test").first() + assert account + assert account.identifier == "99999999999" + assert account.active + assert len(account.custom_fields) == 3 + + # Get the canonical ID field: + c_id = AccountTypeCustomValues.query.filter(AccountTypeCustomValues.name == "canonical_id", + AccountTypeCustomValues.account_id == account.id).first() + + assert c_id + assert c_id.value == "bcaf1ffd86f41161ca5fb16fd081034f" + + assert manager.handle("manage.py", ["add_account_aws", "-n", "test", "--active", "--id", "99999999999", + "--canonical_id", "bcaf1ffd86f41161ca5fb16fd081034f", "--s3_name", "test", + "--role_name", "SecurityMonkey"]) == -1 + + def test_update_account_with_canonical(self): + from security_monkey.account_manager import account_registry + + for name, account_manager in account_registry.items(): + manager.add_command("add_account_%s" % name.lower(), AddAccount(account_manager())) + + # Update: + manager.handle("manage.py", ["add_account_aws", "-n", "account0", "--active", "--id", "012345678910", + "--canonical_id", "bcaf1ffd86f41161ca5fb16fd081034f", "--s3_name", "test", + "--role_name", "SecurityMonkey", "--update-existing"]) + + account = Account.query.filter(Account.name == "account0").first() + assert account + assert account.identifier == "012345678910" + assert account.active + assert len(account.custom_fields) == 3 + + # Get the canonical ID field: + c_id = AccountTypeCustomValues.query.filter(AccountTypeCustomValues.name == "canonical_id", + AccountTypeCustomValues.account_id == account.id).first() + + assert c_id + assert c_id.value == "bcaf1ffd86f41161ca5fb16fd081034f" From 202f95405de77986b7dd55dc98f2d8d24b27a609 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Mon, 27 Mar 2017 17:03:10 +0000 Subject: [PATCH 77/90] Fixing issue #603. Removing reference to zerotodocker. --- docs/quickstart.rst | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 711625b11..61aa1aeb8 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -15,12 +15,8 @@ Below there are two options for configuring Security Monkey to run on AWS. See Docker Images ------------- -Before we start, consider following the `docker instructions `_ -. Docker helps simplify the process to get up and running. The docker images are not currently ready for production use, but are good enough to get up and running with an instance of security_monkey. +For local development, please see `docker instructions <./docker.html>`. -Local `docker instructions <./docker.html>`_ - -Not into the docker thing? Keep reading. Setup IAM Roles ---------------- From d2780ec81c01e4ef10949ac24d791cf13cdae2ee Mon Sep 17 00:00:00 2001 From: Tom Melendez Date: Mon, 27 Mar 2017 16:53:54 +0000 Subject: [PATCH 78/90] GCP: fixed UI Account Type filtering Filtering on Account Type wasn't working correctly and had not been added to several screens. Filtering added to: * Audit Issues * Justified Issues * Items plus Historical Changes --- .../issue_table_component/issue_table_component.dart | 1 + .../issue_table_component/issue_table_component.html | 2 ++ .../item_table_component/item_table_component.dart | 1 + .../justified_table_component.dart | 1 + .../justified_table_component.html | 2 ++ .../revision_table_component.dart | 1 + .../revision_table_component.html | 4 +++- security_monkey/views/item.py | 9 ++++++++- security_monkey/views/item_issue.py | 10 ++++++++++ security_monkey/views/revision.py | 10 ++++++++++ 10 files changed, 39 insertions(+), 2 deletions(-) diff --git a/dart/lib/component/issue_table_component/issue_table_component.dart b/dart/lib/component/issue_table_component/issue_table_component.dart index a7e1c3e84..8ccd59f6b 100644 --- a/dart/lib/component/issue_table_component/issue_table_component.dart +++ b/dart/lib/component/issue_table_component/issue_table_component.dart @@ -18,6 +18,7 @@ class IssueTableComponent extends PaginatedTable implements ScopeAware { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypes': '', 'names': '', 'arns': '', 'active': null, diff --git a/dart/lib/component/issue_table_component/issue_table_component.html b/dart/lib/component/issue_table_component/issue_table_component.html index ef1de588d..bb2b6fef3 100644 --- a/dart/lib/component/issue_table_component/issue_table_component.html +++ b/dart/lib/component/issue_table_component/issue_table_component.html @@ -31,6 +31,7 @@ Item Name Technology Account + Account Type Region Issue Notes @@ -44,6 +45,7 @@ {{issue.item.name}} {{issue.item.technology}} {{issue.item.account}} + {{issue.item.account_type}} {{issue.item.region}} {{issue.issue}} {{issue.notes}} diff --git a/dart/lib/component/item_table_component/item_table_component.dart b/dart/lib/component/item_table_component/item_table_component.dart index 1bf1e1c2f..96835f8c9 100644 --- a/dart/lib/component/item_table_component/item_table_component.dart +++ b/dart/lib/component/item_table_component/item_table_component.dart @@ -27,6 +27,7 @@ class ItemTableComponent extends PaginatedTable implements DetachAware { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypes': '', 'names': '', 'arns': '', 'active': null, diff --git a/dart/lib/component/justified_table_component/justified_table_component.dart b/dart/lib/component/justified_table_component/justified_table_component.dart index cf685f913..4ec4679d8 100644 --- a/dart/lib/component/justified_table_component/justified_table_component.dart +++ b/dart/lib/component/justified_table_component/justified_table_component.dart @@ -17,6 +17,7 @@ class JustifiedTableComponent extends PaginatedTable implements ScopeAware { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypes': '', 'names': '', 'arns': '', 'active': null, diff --git a/dart/lib/component/justified_table_component/justified_table_component.html b/dart/lib/component/justified_table_component/justified_table_component.html index 77aaf493e..21d910a61 100644 --- a/dart/lib/component/justified_table_component/justified_table_component.html +++ b/dart/lib/component/justified_table_component/justified_table_component.html @@ -22,6 +22,7 @@ Item Name Technology Account + Account Type Region Issue Notes @@ -34,6 +35,7 @@ {{issue.item.name}} {{issue.item.technology}} {{issue.item.account}} + {{issue.item.account_type}} {{issue.item.region}} {{issue.issue}} {{issue.notes}} diff --git a/dart/lib/component/revision_table_component/revision_table_component.dart b/dart/lib/component/revision_table_component/revision_table_component.dart index 7f99a06dd..7e66e6e0b 100644 --- a/dart/lib/component/revision_table_component/revision_table_component.dart +++ b/dart/lib/component/revision_table_component/revision_table_component.dart @@ -27,6 +27,7 @@ class RevisionTableComponent extends PaginatedTable implements DetachAware { 'regions': '', 'technologies': '', 'accounts': '', + 'accounttypes': '', 'names': '', 'arns': '', 'active': null, diff --git a/dart/lib/component/revision_table_component/revision_table_component.html b/dart/lib/component/revision_table_component/revision_table_component.html index 3eb716551..99725a43c 100644 --- a/dart/lib/component/revision_table_component/revision_table_component.html +++ b/dart/lib/component/revision_table_component/revision_table_component.html @@ -46,6 +46,7 @@ Active Technology Account + Account Type Region Name Date @@ -58,6 +59,7 @@
    {{rev.item.technology}} {{rev.item.account}} + {{rev.item.account_type}} {{rev.item.region}} {{rev.item.name}} {{rev.date_created | date:'medium'}} @@ -89,4 +91,4 @@
    - \ No newline at end of file + diff --git a/security_monkey/views/item.py b/security_monkey/views/item.py index 72492490b..b12fc90fb 100644 --- a/security_monkey/views/item.py +++ b/security_monkey/views/item.py @@ -20,6 +20,7 @@ from security_monkey.views import ITEM_LINK_FIELDS from security_monkey.datastore import Item from security_monkey.datastore import Account +from security_monkey.datastore import AccountType from security_monkey.datastore import Technology from security_monkey.datastore import ItemRevision from security_monkey import rbac @@ -138,7 +139,7 @@ def get(self, item_id): # Returns a list of items optionally filtered by -# account, region, name, ctype or id. +# account, account_type, region, name, ctype or id. class ItemList(AuthenticatedService): decorators = [rbac.allow(['View'], ["GET"])] @@ -197,6 +198,7 @@ def get(self): self.reqparse.add_argument('page', type=int, default=1, location='args') self.reqparse.add_argument('regions', type=str, default=None, location='args') self.reqparse.add_argument('accounts', type=str, default=None, location='args') + self.reqparse.add_argument('accounttypes', type=str, default=None, location='args') self.reqparse.add_argument('active', type=str, default=None, location='args') self.reqparse.add_argument('names', type=str, default=None, location='args') self.reqparse.add_argument('arns', type=str, default=None, location='args') @@ -224,6 +226,11 @@ def get(self): accounts = args['accounts'].split(',') query = query.join((Account, Account.id == Item.account_id)) query = query.filter(Account.name.in_(accounts)) + if 'accounttypes' in args: + accounttypes = args['accounttypes'].split(',') + query = query.join((Account, Account.id == Item.account_id)) + query = query.join((AccountType, AccountType.id == Account.account_type_id)) + query = query.filter(AccountType.name.in_(accounttypes)) if 'technologies' in args: technologies = args['technologies'].split(',') query = query.join((Technology, Technology.id == Item.tech_id)) diff --git a/security_monkey/views/item_issue.py b/security_monkey/views/item_issue.py index f5e279268..4322c1e73 100644 --- a/security_monkey/views/item_issue.py +++ b/security_monkey/views/item_issue.py @@ -21,6 +21,7 @@ from security_monkey.datastore import ItemAudit from security_monkey.datastore import Item from security_monkey.datastore import Account +from security_monkey.datastore import AccountType from security_monkey.datastore import Technology from security_monkey.datastore import ItemRevision from security_monkey.datastore import AuditorSettings @@ -59,6 +60,7 @@ def get(self): items: [ { account: "example_account", + account_type: "AWS", justification: null, name: "example_name", technology: "s3", @@ -88,6 +90,7 @@ def get(self): self.reqparse.add_argument('page', type=int, default=1, location='args') self.reqparse.add_argument('regions', type=str, default=None, location='args') self.reqparse.add_argument('accounts', type=str, default=None, location='args') + self.reqparse.add_argument('accounttypes', type=str, default=None, location='args') self.reqparse.add_argument('technologies', type=str, default=None, location='args') self.reqparse.add_argument('names', type=str, default=None, location='args') self.reqparse.add_argument('arns', type=str, default=None, location='args') @@ -112,6 +115,11 @@ def get(self): accounts = args['accounts'].split(',') query = query.join((Account, Account.id == Item.account_id)) query = query.filter(Account.name.in_(accounts)) + if 'accounttypes' in args: + accounttypes = args['accounttypes'].split(',') + query = query.join((Account, Account.id == Item.account_id)) + query = query.join((AccountType, AccountType.id == Account.account_type_id)) + query = query.filter(AccountType.name.in_(accounttypes)) if 'technologies' in args: technologies = args['technologies'].split(',') query = query.join((Technology, Technology.id == Item.tech_id)) @@ -161,6 +169,7 @@ def get(self): item_marshaled = marshal(issue.item.__dict__, ITEM_FIELDS) issue_marshaled = marshal(issue.__dict__, AUDIT_FIELDS) account_marshaled = {'account': issue.item.account.name} + accounttype_marshaled = {'account_type': issue.item.account.account_type.name} technology_marshaled = {'technology': issue.item.technology.name} links = [] @@ -178,6 +187,7 @@ def get(self): item_marshaled.items() + issue_marshaled.items() + account_marshaled.items() + + accounttype_marshaled.items() + technology_marshaled.items()) items_marshaled.append(merged_marshaled) diff --git a/security_monkey/views/revision.py b/security_monkey/views/revision.py index 75fda3009..2e65287e8 100644 --- a/security_monkey/views/revision.py +++ b/security_monkey/views/revision.py @@ -19,6 +19,7 @@ from security_monkey.views import ITEM_FIELDS from security_monkey.datastore import Item from security_monkey.datastore import Account +from security_monkey.datastore import AccountType from security_monkey.datastore import Technology from security_monkey.datastore import ItemRevision from security_monkey import rbac @@ -149,6 +150,7 @@ def get(self): "items": [ { "account": "example_account", + "accounttype": "AWS", "name": "Example Name", "region": "us-east-1", "item_id": 144, @@ -175,6 +177,7 @@ def get(self): self.reqparse.add_argument('active', type=str, default=None, location='args') self.reqparse.add_argument('regions', type=str, default=None, location='args') self.reqparse.add_argument('accounts', type=str, default=None, location='args') + self.reqparse.add_argument('accounttypes', type=str, default=None, location='args') self.reqparse.add_argument('names', type=str, default=None, location='args') self.reqparse.add_argument('arns', type=str, default=None, location='args') self.reqparse.add_argument('technologies', type=str, default=None, location='args') @@ -195,6 +198,11 @@ def get(self): accounts = args['accounts'].split(',') query = query.join((Account, Account.id == Item.account_id)) query = query.filter(Account.name.in_(accounts)) + if 'accounttypes' in args: + accounttypes = args['accounttypes'].split(',') + query = query.join((Account, Account.id == Item.account_id)) + query = query.join((AccountType, AccountType.id == Account.account_type_id)) + query = query.filter(AccountType.name.in_(accounttypes)) if 'technologies' in args: technologies = args['technologies'].split(',') query = query.join((Technology, Technology.id == Item.tech_id)) @@ -225,11 +233,13 @@ def get(self): item_marshaled = marshal(revision.item.__dict__, ITEM_FIELDS) revision_marshaled = marshal(revision.__dict__, REVISION_FIELDS) account_marshaled = {'account': revision.item.account.name} + accounttype_marshaled = {'account_type': revision.item.account.account_type.name} technology_marshaled = {'technology': revision.item.technology.name} merged_marshaled = dict( item_marshaled.items() + revision_marshaled.items() + account_marshaled.items() + + accounttype_marshaled.items() + technology_marshaled.items()) items_marshaled.append(merged_marshaled) From cee324ef810a961767da8241a1f940c99f13a813 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 28 Mar 2017 20:45:13 +0000 Subject: [PATCH 79/90] Adding active and third_party flags to account view --- security_monkey/views/account.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/security_monkey/views/account.py b/security_monkey/views/account.py index 9a05d7fab..d5f7d2e2f 100644 --- a/security_monkey/views/account.py +++ b/security_monkey/views/account.py @@ -319,6 +319,8 @@ def get(self): self.reqparse.add_argument('page', type=int, default=1, location='args') self.reqparse.add_argument('order_by', type=str, default=None, location='args') self.reqparse.add_argument('order_dir', type=str, default='desc', location='args') + self.reqparse.add_argument('active', type=str, default=None, location='args') + self.reqparse.add_argument('third_party', type=str, default=None, location='args') args = self.reqparse.parse_args() page = args.pop('page', None) @@ -330,6 +332,12 @@ def get(self): del args[k] query = Account.query + if 'active' in args: + active = args['active'].lower() == "true" + query = query.filter(Account.active == active) + if 'third_party' in args: + third_party = args['third_party'].lower() == "true" + query = query.filter(Account.third_party == third_party) if order_by and hasattr(Account, order_by): if order_dir.lower() == 'asc': From e95bae5ca35d61c99662bb2cc24182d7f674ed71 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Wed, 29 Mar 2017 18:20:43 +0000 Subject: [PATCH 80/90] Removing s3_name from exporter and renaming Account.number to identifier --- security_monkey/export/__init__.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/security_monkey/export/__init__.py b/security_monkey/export/__init__.py index 3bbb4d891..34e1c67cf 100644 --- a/security_monkey/export/__init__.py +++ b/security_monkey/export/__init__.py @@ -62,8 +62,7 @@ def export_items(): attributes = [ ["technology", "name"], ["account", "name"], - ["account", "s3_name"], - ["account", "number"], + ["account", "identifier"], ["region"], ["name"], ["issues"], @@ -140,8 +139,7 @@ def export_issues(): attributes = [ ["item", "technology", "name"], ["item", "account", "name"], - ["item", "account", "s3_name"], - ["item", "account", "number"], + ["item", "account", "identifier"], ["item", "region"], ["item", "name"], ["item", "comments"], From 9b7cb95b38c0c957e671aab1e9aafbc17fc9157f Mon Sep 17 00:00:00 2001 From: Mike Grima Date: Wed, 29 Mar 2017 14:09:54 -0700 Subject: [PATCH 81/90] =?UTF-8?q?Fix=20for=20UI=20Account=20creation=20bug?= =?UTF-8?q?=20=F0=9F=90=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- security_monkey/views/account.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/security_monkey/views/account.py b/security_monkey/views/account.py index d5f7d2e2f..9aab7040e 100644 --- a/security_monkey/views/account.py +++ b/security_monkey/views/account.py @@ -23,6 +23,7 @@ from flask_restful import marshal, reqparse import json + class AccountGetPutDelete(AuthenticatedService): decorators = [ rbac.allow(["View"], ["GET"]), @@ -142,13 +143,12 @@ def put(self, account_id): notes = args['notes'] active = args['active'] third_party = args['third_party'] - account_id = args['id'] custom_fields = args['custom_fields'] from security_monkey.account_manager import account_registry account_manager = account_registry.get(account_type)() - account = account_manager.update(account_id, account_type, name, active, - third_party, notes, identifier, custom_fields=custom_fields) + account = account_manager.update(account_type, name, active, third_party, notes, identifier, + custom_fields=custom_fields) if not account: return {'status': 'error. Account ID not found.'}, 404 From ecd0926431aaa14b52207a64954f74c42f660c35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Mathevet?= Date: Fri, 7 Apr 2017 21:03:13 +0100 Subject: [PATCH 82/90] Fix Docker (#657) * Change nginx conf location * Change nginx conf location * Change nginx conf location * Cleaner sed if no SSL * Add SESSION_COOKIE_SECURE env * Create default admin user * Refactor and clean docker and docker-compose files * Wait the db * Update gitignore * Update docker docs * Restore SSL in nginx config --- .gitignore | 1 + Dockerfile | 2 - docker-compose.init.yml | 21 +++++++ docker-compose.shell.yml | 8 +++ docker-compose.yml | 55 ++++++----------- docker/api-init.sh | 8 +++ docker/api-start.sh | 3 + docker/nginx/Dockerfile | 4 +- docker/nginx/{ => conf.d}/securitymonkey.conf | 0 docker/nginx/start-nginx.sh | 5 +- docker/scheduler-start.sh | 3 + docs/docker.rst | 60 ++++++++++++++----- env-config/config-docker.py | 2 +- 13 files changed, 113 insertions(+), 59 deletions(-) create mode 100644 docker-compose.init.yml create mode 100644 docker-compose.shell.yml rename docker/nginx/{ => conf.d}/securitymonkey.conf (100%) diff --git a/.gitignore b/.gitignore index eea12d057..bdafdc658 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ boto.cfg secmonkey.env *.crt *.key +postgres-data/ diff --git a/Dockerfile b/Dockerfile index 2959018a7..e76c2fd74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,5 +42,3 @@ RUN chmod +x /usr/local/src/security_monkey/docker/*.sh &&\ WORKDIR /usr/local/src/security_monkey EXPOSE 5000 - -ENTRYPOINT ["/usr/local/src/security_monkey/docker/api-start.sh"] diff --git a/docker-compose.init.yml b/docker-compose.init.yml new file mode 100644 index 000000000..e6d44b390 --- /dev/null +++ b/docker-compose.init.yml @@ -0,0 +1,21 @@ +--- + +version: '2' +services: + postgres: + container_name: secmonkey-db + image: postgres:9 + + init: + container_name: init + build: . + image: secmonkey:latest + working_dir: /usr/local/src/security_monkey + volumes: + - ./data/aws_accounts.json:/usr/local/src/security_monkey/data/aws_accounts.json + - ./docker:/usr/local/src/security_monkey/docker/ + - ./env-config/config-docker.py:/usr/local/src/security_monkey/env-config/config-docker.py + depends_on: + - postgres + env_file: secmonkey.env + entrypoint: /usr/local/src/security_monkey/docker/api-init.sh diff --git a/docker-compose.shell.yml b/docker-compose.shell.yml new file mode 100644 index 000000000..6fdbdae9e --- /dev/null +++ b/docker-compose.shell.yml @@ -0,0 +1,8 @@ +--- + +version: '2' +services: + data: + stdin_open: true + tty: true + entrypoint: ["/bin/bash"] diff --git a/docker-compose.yml b/docker-compose.yml index 57d61f52a..7dba3e374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,25 +5,34 @@ # Documentation: http://securitymonkey.readthedocs.io/en/latest/index.html # http://securitymonkey.readthedocs.io/en/latest/docker.html # -# shortcuts -# open https://$(docker-machine active | xargs docker-machine ip) -# ### - version: '2' services: postgres: container_name: secmonkey-db image: postgres:9 - # volumes: - # - ./postgres-data/:/var/lib/postgresql/data + #volumes: + # - ./postgres-data/:/var/lib/postgresql/data + + data: + container_name: secmonkey-data + build: . + image: secmonkey:latest + working_dir: /usr/local/src/security_monkey + volumes: + - ./data/aws_accounts.json:/usr/local/src/security_monkey/data/aws_accounts.json + - ./docker:/usr/local/src/security_monkey/docker/ + - ./env-config/config-docker.py:/usr/local/src/security_monkey/env-config/config-docker.py + depends_on: + - postgres + env_file: secmonkey.env api: container_name: secmonkey-api image: secmonkey:latest volumes_from: - - init + - data depends_on: - postgres env_file: secmonkey.env @@ -33,7 +42,7 @@ services: container_name: secmonkey-scheduler image: secmonkey:latest volumes_from: - - init + - data depends_on: - api env_file: secmonkey.env @@ -42,14 +51,14 @@ services: nginx: container_name: secmonkey-nginx build: - context: ./ + context: . dockerfile: ./docker/nginx/Dockerfile image: secmonkey-nginx:latest working_dir: /etc/nginx volumes: - ./docker/nginx/server.crt:/etc/nginx/ssl/server.crt - ./docker/nginx/server.key:/etc/nginx/ssl/server.key - - ./docker/nginx/securitymonkey.conf:/etc/nginx/conf.d/securitymonkey.conf + - ./docker/nginx/conf.d:/etc/nginx/conf.d/ - ./docker/nginx/start-nginx.sh:/usr/local/src/security_monkey/docker/nginx/start-nginx.sh depends_on: - api @@ -58,29 +67,3 @@ services: - 443:443 links: - api:smapi - -# volumes: -# - postgres-data: {} - -### ### ### - ### ### ### - - init: - container_name: init - build: . - image: secmonkey:latest - working_dir: /usr/local/src/security_monkey - volumes: - - ./data/aws_accounts.json:/usr/local/src/security_monkey/data/aws_accounts.json - - ./docker:/usr/local/src/security_monkey/docker/ - - ./env-config/config-docker.py:/usr/local/src/security_monkey/env-config/config-docker.py - depends_on: - - postgres - env_file: secmonkey.env - # environment: - # - AWS_ACCESS_KEY_ID= - # - AWS_SECRET_ACCESS_KEY= - # - SECURITY_MONKEY_POSTGRES_HOST= - entrypoint: # /usr/local/src/security_monkey/docker/api-init.sh - - sleep - - 8h diff --git a/docker/api-init.sh b/docker/api-init.sh index 74bb8182f..b33e6a258 100755 --- a/docker/api-init.sh +++ b/docker/api-init.sh @@ -1,5 +1,8 @@ #!/bin/bash +# Wait the database +sleep 10 + sudo -u ${SECURITY_MONKEY_POSTGRES_USER:-postgres} psql\ -h ${SECURITY_MONKEY_POSTGRES_HOST:-postgres} -p ${SECURITY_MONKEY_POSTGRES_PORT:-5432}\ --command "ALTER USER ${SECURITY_MONKEY_POSTGRES_USER:-postgres} with PASSWORD '${SECURITY_MONKEY_POSTGRES_PASSWORD:-securitymonkeypassword}';" @@ -13,3 +16,8 @@ touch "/var/log/security_monkey/security_monkey-deploy.log" cd /usr/local/src/security_monkey python manage.py db upgrade + +cat < Date: Mon, 10 Apr 2017 18:22:37 +0100 Subject: [PATCH 83/90] Typo (#658) --- docker/api-start.sh | 2 +- docker/scheduler-start.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/api-start.sh b/docker/api-start.sh index e028de0f3..462772709 100755 --- a/docker/api-start.sh +++ b/docker/api-start.sh @@ -1,7 +1,7 @@ #!/bin/bash # wait the database -wait 10 +sleep 10 cd /usr/local/src/security_monkey python manage.py run_api_server -b 0.0.0.0:${SECURITY_MONKEY_API_PORT:-5000} diff --git a/docker/scheduler-start.sh b/docker/scheduler-start.sh index 21b4d9832..d56b049ef 100755 --- a/docker/scheduler-start.sh +++ b/docker/scheduler-start.sh @@ -1,7 +1,7 @@ #!/bin/bash # wait the database -wait 10 +sleep 10 mkdir -p /var/log/security_monkey touch /var/log/security_monkey/security_monkey-deploy.log From 605b6f06d426f3bdecc7e34c0783e297c8397ef6 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Mon, 10 Apr 2017 19:29:10 -0700 Subject: [PATCH 84/90] =?UTF-8?q?Updating=20quickstart/install=20documenta?= =?UTF-8?q?tion=20to=20simplify.=20(#655)=20=F0=9F=93=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Updating install documentation to simplify. * Hyperlink test. * Another test * Link test * Move to MD? * Moving more things to Markdown * Moving everything to markdown * Updating docker docs * Adding IAM GCP instructions. * Adding RDS Postgres docs * Adding RDS Postgres docs1 * Adding proper apt-get for installing local postgres. * Bumping FlaskSecurityFork. Making SECURITY_MONKEY_SETTINGS optional, doc updates * cli docs and typos * Fixing config path in sample supervisor config * doc updates * Adding thigns back into userguide * Moving travis to config.py * Creating log folder * Travis log permissions * Updating travis DB name * Disabling CSRF on travis for our tests * Fixing travis sed * Moving readme to markdown. various syntax fixes in docs * Updating readme. * Adding table to readme * Putting waffle/gitter on same line * Reorganizing README * Adding link to cloudaux in readme --- .travis.yml | 12 +- README.md | 27 + README.rst | 40 - .../account_view_component.html | 3 +- docs/Makefile | 177 --- docs/api/index.md | 20 + docs/api/index.rst | 46 - docs/api/security_monkey.auditors.md | 41 + docs/api/security_monkey.auditors.rst | 107 -- docs/api/security_monkey.common.md | 17 + docs/api/security_monkey.common.rst | 42 - docs/api/security_monkey.common.utils.md | 11 + docs/api/security_monkey.common.utils.rst | 27 - docs/api/security_monkey.md | 38 + docs/api/security_monkey.rst | 102 -- docs/api/security_monkey.tests.md | 11 + docs/api/security_monkey.tests.rst | 27 - docs/api/security_monkey.views.md | 41 + docs/api/security_monkey.views.rst | 107 -- docs/api/security_monkey.watchers.md | 47 + docs/api/security_monkey.watchers.rst | 123 -- docs/authors.md | 6 + docs/authors.rst | 9 - docs/changelog.md | 643 +++++++++++ docs/changelog.rst | 643 ----------- docs/conf.py | 261 ----- docs/configuration.rst | 256 ----- docs/contributing.md | 39 + docs/contributing.rst | 59 - docs/dev_setup_osx.md | 314 +++++ docs/dev_setup_osx.rst | 296 ----- docs/dev_setup_ubuntu.md | 265 +++++ docs/dev_setup_ubuntu.rst | 248 ---- docs/dev_setup_windows.md | 270 +++++ docs/dev_setup_windows.rst | 268 ----- docs/development.md | 173 +++ docs/development.rst | 216 ---- docs/docker.md | 89 ++ docs/docker.rst | 90 -- docs/iam_aws.md | 230 ++++ docs/iam_gcp.md | 37 + docs/images/Security_Monkey.png | Bin 0 -> 23862 bytes docs/images/add_user_to_service_account.png | Bin 0 -> 21647 bytes docs/images/aws_rds.png | Bin 0 -> 144317 bytes docs/images/create_service_account.png | Bin 0 -> 62181 bytes docs/index.rst | 51 - docs/instance_launch_aws.md | 45 + docs/instance_launch_gcp.md | 33 + docs/jirasync.md | 72 ++ docs/jirasync.rst | 97 -- docs/make.bat | 242 ---- docs/misc.md | 176 +++ docs/misc.rst | 204 ---- docs/{nginx_install.rst => nginx_install.md} | 54 +- docs/options.md | 136 +++ docs/options.rst | 135 --- docs/plugins.md | 41 + docs/plugins.rst | 43 - docs/postgres_aws.md | 19 + docs/postgres_gcp.md | 23 + docs/quickstart.md | 260 +++++ docs/quickstart.rst | 1011 ----------------- docs/userguide.md | 105 ++ docs/userguide.rst | 14 - ...ing_supervisor.rst => using_supervisor.md} | 24 +- env-config/config-local.py | 211 ---- env-config/{config-deploy.py => config.py} | 2 +- nginx/security_monkey.conf | 36 + scripts/secmonkey_auto_install.sh | 2 +- security_monkey/__init__.py | 21 +- setup.py | 2 +- supervisor/security_monkey.conf | 5 +- 72 files changed, 3328 insertions(+), 5214 deletions(-) create mode 100644 README.md delete mode 100644 README.rst delete mode 100644 docs/Makefile create mode 100644 docs/api/index.md delete mode 100644 docs/api/index.rst create mode 100644 docs/api/security_monkey.auditors.md delete mode 100644 docs/api/security_monkey.auditors.rst create mode 100644 docs/api/security_monkey.common.md delete mode 100644 docs/api/security_monkey.common.rst create mode 100644 docs/api/security_monkey.common.utils.md delete mode 100644 docs/api/security_monkey.common.utils.rst create mode 100644 docs/api/security_monkey.md delete mode 100644 docs/api/security_monkey.rst create mode 100644 docs/api/security_monkey.tests.md delete mode 100644 docs/api/security_monkey.tests.rst create mode 100644 docs/api/security_monkey.views.md delete mode 100644 docs/api/security_monkey.views.rst create mode 100644 docs/api/security_monkey.watchers.md delete mode 100644 docs/api/security_monkey.watchers.rst create mode 100644 docs/authors.md delete mode 100644 docs/authors.rst create mode 100644 docs/changelog.md delete mode 100644 docs/changelog.rst delete mode 100644 docs/conf.py delete mode 100644 docs/configuration.rst create mode 100644 docs/contributing.md delete mode 100644 docs/contributing.rst create mode 100644 docs/dev_setup_osx.md delete mode 100644 docs/dev_setup_osx.rst create mode 100644 docs/dev_setup_ubuntu.md delete mode 100644 docs/dev_setup_ubuntu.rst create mode 100644 docs/dev_setup_windows.md delete mode 100644 docs/dev_setup_windows.rst create mode 100644 docs/development.md delete mode 100644 docs/development.rst create mode 100644 docs/docker.md delete mode 100644 docs/docker.rst create mode 100644 docs/iam_aws.md create mode 100644 docs/iam_gcp.md create mode 100644 docs/images/Security_Monkey.png create mode 100644 docs/images/add_user_to_service_account.png create mode 100644 docs/images/aws_rds.png create mode 100644 docs/images/create_service_account.png delete mode 100644 docs/index.rst create mode 100644 docs/instance_launch_aws.md create mode 100644 docs/instance_launch_gcp.md create mode 100644 docs/jirasync.md delete mode 100644 docs/jirasync.rst delete mode 100644 docs/make.bat create mode 100644 docs/misc.md delete mode 100644 docs/misc.rst rename docs/{nginx_install.rst => nginx_install.md} (62%) create mode 100644 docs/options.md delete mode 100644 docs/options.rst create mode 100644 docs/plugins.md delete mode 100644 docs/plugins.rst create mode 100644 docs/postgres_aws.md create mode 100644 docs/postgres_gcp.md create mode 100644 docs/quickstart.md delete mode 100644 docs/quickstart.rst create mode 100644 docs/userguide.md delete mode 100644 docs/userguide.rst rename docs/{using_supervisor.rst => using_supervisor.md} (70%) delete mode 100644 env-config/config-local.py rename env-config/{config-deploy.py => config.py} (99%) create mode 100644 nginx/security_monkey.conf diff --git a/.travis.yml b/.travis.yml index c0c4c1ec4..b1fc37160 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,18 +16,22 @@ cache: env: global: - PIP_DOWNLOAD_CACHE=".pip_download_cache" - - SECURITY_MONKEY_SETTINGS=`pwd`/env-config/config-local.py + - SECURITY_MONKEY_SETTINGS=`pwd`/env-config/config.py install: + - sed -i '/WTF_CSRF_ENABLED = True/c\WTF_CSRF_ENABLED = False' `pwd`/env-config/config.py before_install: # - sudo apt-get -qq update # - sudo apt-get install -y libxml2-dev libxmlsec1-dev + - sudo mkdir -p /var/log/security_monkey/ + - sudo touch /var/log/security_monkey/securitymonkey.log + - sudo chown travis /var/log/security_monkey/securitymonkey.log before_script: - - psql -c "CREATE DATABASE securitymonkeydb;" -U postgres - - psql -c "CREATE ROLE securitymonkeyuser LOGIN PASSWORD 'securitymonkeypass';" -U postgres - - psql -c "CREATE SCHEMA securitymonkeydb GRANT Usage, Create ON SCHEMA securitymonkeydb TO securitymonkeyuser;" -U postgres + - psql -c "CREATE DATABASE secmonkey;" -U postgres + - psql -c "CREATE ROLE securitymonkeyuser LOGIN PASSWORD 'securitymonkeypassword';" -U postgres + - psql -c "CREATE SCHEMA secmonkey GRANT Usage, Create ON SCHEMA secmonkey TO securitymonkeyuser;" -U postgres - psql -c "set timezone TO 'GMT';" -U postgres - python setup.py develop - pip install .[tests] diff --git a/README.md b/README.md new file mode 100644 index 000000000..3f8feee66 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +Security Monkey +=============== + +Security Monkey Logo 2017 + +Security Monkey monitors your [AWS and GCP accounts](https://medium.com/@Netflix_Techblog/netflix-security-monkey-on-google-cloud-platform-gcp-f221604c0cc7) for policy changes and alerts on insecure configurations. It provides a single UI to browse and search through all of your accounts, regions, and cloud services. The monkey remembers previous states and can show you exactly what changed, and when. + +Security Monkey can be extended with [custom account types](plugins.md), custom watchers, custom auditors, and [custom alerters](docs/misc.md#custom-alerters). + +It works on CPython 2.7. It is known to work on Ubuntu Linux and OS X. + +[![Stories in Ready](https://badge.waffle.io/Netflix/security_monkey.svg?label=ready&title=Ready)](http://waffle.io/Netflix/security_monkey) [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/Netflix/security_monkey) + +| Develop Branch | Master Branch | +| ------------- | ------------- | +| [![Build Status](https://travis-ci.org/Netflix/security_monkey.svg?branch=develop)](https://travis-ci.org/Netflix/security_monkey) | [![Build Status](https://travis-ci.org/Netflix/security_monkey.svg?branch=master)](https://travis-ci.org/Netflix/security_monkey) | +| [![Coverage Status](https://coveralls.io/repos/github/Netflix/security_monkey/badge.svg?branch=develop)](https://coveralls.io/github/Netflix/security_monkey?branch=develop) | [![Coverage Status](https://coveralls.io/repos/github/Netflix/security_monkey/badge.svg?branch=master)](https://coveralls.io/github/Netflix/security_monkey?branch=master) | + + +Project resources +----------------- + +- [Quickstart](docs/quickstart.md) +- [Source code](https://github.com/netflix/security_monkey) +- [Issue tracker](https://github.com/netflix/security_monkey/issues) +- [Gitter.im Chat Room](https://gitter.im/Netflix/security_monkey) +- [CloudAux](https://github.com/Netflix-Skunkworks/cloudaux) diff --git a/README.rst b/README.rst deleted file mode 100644 index 33a72ce09..000000000 --- a/README.rst +++ /dev/null @@ -1,40 +0,0 @@ -.. image:: https://badge.waffle.io/Netflix/security_monkey.png?label=ready&title=Ready - :target: https://waffle.io/Netflix/security_monkey - :alt: 'Stories in Ready' - -.. image:: https://badges.gitter.im/Join%20Chat.svg - :alt: Join the chat at https://gitter.im/Netflix/security_monkey - :target: https://gitter.im/Netflix/security_monkey?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge - -**develop branch**: - -.. image:: https://travis-ci.org/Netflix/security_monkey.svg?branch=develop - :target: https://travis-ci.org/Netflix/security_monkey - -.. image:: https://coveralls.io/repos/github/Netflix/security_monkey/badge.svg?branch=develop - :target: https://coveralls.io/github/Netflix/security_monkey - -**master branch**: - -.. image:: https://travis-ci.org/Netflix/security_monkey.svg?branch=master - :target: https://travis-ci.org/Netflix/security_monkey - -.. image:: https://coveralls.io/repos/github/Netflix/security_monkey/badge.svg?branch=master - :target: https://coveralls.io/github/Netflix/security_monkey - - -*************** -Security Monkey -*************** - -Security Monkey monitors policy changes and alerts on insecure configurations in an AWS account. While Security Monkey’s main purpose is security, it also proves a useful tool for tracking down potential problems as it is essentially a change tracking system. - -It works on CPython 2.7. It is known -to work on Ubuntu Linux and OS X. - -Project resources -================= - -- `Documentation `_ -- `Source code `_ -- `Issue tracker `_ diff --git a/dart/lib/component/account_view_component/account_view_component.html b/dart/lib/component/account_view_component/account_view_component.html index 9bce384f5..0056bc5a0 100644 --- a/dart/lib/component/account_view_component/account_view_component.html +++ b/dart/lib/component/account_view_component/account_view_component.html @@ -89,7 +89,8 @@

    Create Account


    Due to an open issue. You must restart the scheduler after adding a new account.

    -$ sudo  supervisorctl -c security_monkey.ini
    +$ sudo  supervisorctl
    +supervisor> status
     securitymonkeyapi                RUNNING    pid 19198, uptime 0:00:05
     securitymonkeyscheduler          RUNNING    pid 19199, uptime 0:00:05
     supervisor> restart securitymonkeyscheduler
    diff --git a/docs/Makefile b/docs/Makefile
    deleted file mode 100644
    index 021b30c96..000000000
    --- a/docs/Makefile
    +++ /dev/null
    @@ -1,177 +0,0 @@
    -# Makefile for Sphinx documentation
    -#
    -
    -# You can set these variables from the command line.
    -SPHINXOPTS    =
    -SPHINXBUILD   = sphinx-build
    -PAPER         =
    -BUILDDIR      = _build
    -
    -# User-friendly check for sphinx-build
    -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
    -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
    -endif
    -
    -# Internal variables.
    -PAPEROPT_a4     = -D latex_paper_size=a4
    -PAPEROPT_letter = -D latex_paper_size=letter
    -ALLSPHINXOPTS   = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
    -# the i18n builder cannot share the environment and doctrees with the others
    -I18NSPHINXOPTS  = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
    -
    -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
    -
    -help:
    -	@echo "Please use \`make ' where  is one of"
    -	@echo "  html       to make standalone HTML files"
    -	@echo "  dirhtml    to make HTML files named index.html in directories"
    -	@echo "  singlehtml to make a single large HTML file"
    -	@echo "  pickle     to make pickle files"
    -	@echo "  json       to make JSON files"
    -	@echo "  htmlhelp   to make HTML files and a HTML help project"
    -	@echo "  qthelp     to make HTML files and a qthelp project"
    -	@echo "  devhelp    to make HTML files and a Devhelp project"
    -	@echo "  epub       to make an epub"
    -	@echo "  latex      to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
    -	@echo "  latexpdf   to make LaTeX files and run them through pdflatex"
    -	@echo "  latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
    -	@echo "  text       to make text files"
    -	@echo "  man        to make manual pages"
    -	@echo "  texinfo    to make Texinfo files"
    -	@echo "  info       to make Texinfo files and run them through makeinfo"
    -	@echo "  gettext    to make PO message catalogs"
    -	@echo "  changes    to make an overview of all changed/added/deprecated items"
    -	@echo "  xml        to make Docutils-native XML files"
    -	@echo "  pseudoxml  to make pseudoxml-XML files for display purposes"
    -	@echo "  linkcheck  to check all external links for integrity"
    -	@echo "  doctest    to run all doctests embedded in the documentation (if enabled)"
    -
    -clean:
    -	rm -rf $(BUILDDIR)/*
    -
    -html:
    -	$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
    -	@echo
    -	@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
    -
    -dirhtml:
    -	$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
    -	@echo
    -	@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
    -
    -singlehtml:
    -	$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
    -	@echo
    -	@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
    -
    -pickle:
    -	$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
    -	@echo
    -	@echo "Build finished; now you can process the pickle files."
    -
    -json:
    -	$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
    -	@echo
    -	@echo "Build finished; now you can process the JSON files."
    -
    -htmlhelp:
    -	$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
    -	@echo
    -	@echo "Build finished; now you can run HTML Help Workshop with the" \
    -	      ".hhp project file in $(BUILDDIR)/htmlhelp."
    -
    -qthelp:
    -	$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
    -	@echo
    -	@echo "Build finished; now you can run "qcollectiongenerator" with the" \
    -	      ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
    -	@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/security_monkey.qhcp"
    -	@echo "To view the help file:"
    -	@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/security_monkey.qhc"
    -
    -devhelp:
    -	$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
    -	@echo
    -	@echo "Build finished."
    -	@echo "To view the help file:"
    -	@echo "# mkdir -p $$HOME/.local/share/devhelp/security_monkey"
    -	@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/security_monkey"
    -	@echo "# devhelp"
    -
    -epub:
    -	$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
    -	@echo
    -	@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
    -
    -latex:
    -	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
    -	@echo
    -	@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
    -	@echo "Run \`make' in that directory to run these through (pdf)latex" \
    -	      "(use \`make latexpdf' here to do that automatically)."
    -
    -latexpdf:
    -	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
    -	@echo "Running LaTeX files through pdflatex..."
    -	$(MAKE) -C $(BUILDDIR)/latex all-pdf
    -	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
    -
    -latexpdfja:
    -	$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
    -	@echo "Running LaTeX files through platex and dvipdfmx..."
    -	$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
    -	@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
    -
    -text:
    -	$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
    -	@echo
    -	@echo "Build finished. The text files are in $(BUILDDIR)/text."
    -
    -man:
    -	$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
    -	@echo
    -	@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
    -
    -texinfo:
    -	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
    -	@echo
    -	@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
    -	@echo "Run \`make' in that directory to run these through makeinfo" \
    -	      "(use \`make info' here to do that automatically)."
    -
    -info:
    -	$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
    -	@echo "Running Texinfo files through makeinfo..."
    -	make -C $(BUILDDIR)/texinfo info
    -	@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
    -
    -gettext:
    -	$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
    -	@echo
    -	@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
    -
    -changes:
    -	$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
    -	@echo
    -	@echo "The overview file is in $(BUILDDIR)/changes."
    -
    -linkcheck:
    -	$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
    -	@echo
    -	@echo "Link check complete; look for any errors in the above output " \
    -	      "or in $(BUILDDIR)/linkcheck/output.txt."
    -
    -doctest:
    -	$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
    -	@echo "Testing of doctests in the sources finished, look at the " \
    -	      "results in $(BUILDDIR)/doctest/output.txt."
    -
    -xml:
    -	$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
    -	@echo
    -	@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
    -
    -pseudoxml:
    -	$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
    -	@echo
    -	@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
    diff --git a/docs/api/index.md b/docs/api/index.md
    new file mode 100644
    index 000000000..01fc4a8b0
    --- /dev/null
    +++ b/docs/api/index.md
    @@ -0,0 +1,20 @@
    +API Reference
    +=============
    +
    +At a high-level, Security Monkey consists of the following components:
    +
    +Watcher - Component that monitors a given AWS account and technology (e.g. S3, EC2). The Watcher detects and records changes to configurations. So, if an S3 bucket policy changes, the Watcher will detect this and store the change.
    +
    +Notifier - Component that lets a user or group of users know when a particular item has changed. This component also provides notification based on the triggering of audit rules.
    +
    +Auditor - Component that executes a set of business rules against an AWS configuration to determine the level of risk associated with the configuration. For example, a rule may look for a security group with a rule allowing ingress from 0.0.0.0/0 (meaning the security group is open to the Internet). Or, a rule may look for an S3 policy that allows access from an unknown AWS account (meaning you may be unintentionally sharing the data stored in your S3 bucket). Security Monkey has a number of built-in rules included, and users are free to add their own rules.
    +
    +Class and method level definitions and documentation
    +
    +Indices and tables
    +------------------
    +
    +-   genindex
    +-   modindex
    +-   search
    +
    diff --git a/docs/api/index.rst b/docs/api/index.rst
    deleted file mode 100644
    index c16f1e737..000000000
    --- a/docs/api/index.rst
    +++ /dev/null
    @@ -1,46 +0,0 @@
    -*************
    -API Reference
    -*************
    -
    -At a high-level, Security Monkey consists of the following components:
    -
    -Watcher - Component that monitors a given AWS account and technology (e.g. S3, EC2). The
    -Watcher detects and records changes to configurations. So, if an S3 bucket policy
    -changes, the Watcher will detect this and store the change.
    -
    -Notifier - Component that lets a user or group of users know when a particular item has changed. This component also provides notification based on the triggering of audit rules.
    -
    -Auditor - Component that executes a set of business rules against an AWS configuration to
    -determine the level of risk associated with the configuration. For example, a rule may
    -look for a security group with a rule allowing ingress from 0.0.0.0/0 (meaning the
    -security group is open to the Internet). Or, a rule may look for an S3 policy that
    -allows access from an unknown AWS account (meaning you may be unintentionally sharing
    -the data stored in your S3 bucket). Security Monkey has a number of built-in rules
    -included, and users are free to add their own rules.
    -
    -.. module:: security_monkey
    -
    -.. attribute:: __version__
    -
    -	security_monkey's version number in :pep:`386` format.
    -
    -	::
    -
    -		>>> import security_monkey
    -		>>> security_monkey.__version__
    -		u'0.8.0'
    -
    -
    -Class and method level definitions and documentation
    -
    -.. toctree::
    -    :maxdepth: 2
    -
    -    security_monkey
    -
    -Indices and tables
    -==================
    -
    -* :ref:`genindex`
    -* :ref:`modindex`
    -* :ref:`search`
    diff --git a/docs/api/security_monkey.auditors.md b/docs/api/security_monkey.auditors.md
    new file mode 100644
    index 000000000..2be894e0e
    --- /dev/null
    +++ b/docs/api/security_monkey.auditors.md
    @@ -0,0 +1,41 @@
    +auditors Package
    +================
    +
    +auditors Package
    +----------------
    +
    +elb Module
    +----------
    +
    +iam\_group Module
    +-----------------
    +
    +iam\_policy Module
    +------------------
    +
    +iam\_role Module
    +----------------
    +
    +iam\_ssl Module
    +---------------
    +
    +iam\_user Module
    +----------------
    +
    +rds\_security\_group Module
    +---------------------------
    +
    +redshift Module
    +---------------
    +
    +s3 Module
    +---------
    +
    +security\_group Module
    +----------------------
    +
    +ses Module
    +----------
    +
    +sns Module
    +----------
    diff --git a/docs/api/security_monkey.auditors.rst b/docs/api/security_monkey.auditors.rst
    deleted file mode 100644
    index 453f145f4..000000000
    --- a/docs/api/security_monkey.auditors.rst
    +++ /dev/null
    @@ -1,107 +0,0 @@
    -auditors Package
    -================
    -
    -:mod:`auditors` Package
    ------------------------
    -
    -.. automodule:: security_monkey.auditors
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`elb` Module
    ------------------
    -
    -.. automodule:: security_monkey.auditors.elb
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_group` Module
    ------------------------
    -
    -.. automodule:: security_monkey.auditors.iam_group
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_policy` Module
    -------------------------
    -
    -.. automodule:: security_monkey.auditors.iam_policy
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_role` Module
    -----------------------
    -
    -.. automodule:: security_monkey.auditors.iam_role
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_ssl` Module
    ----------------------
    -
    -.. automodule:: security_monkey.auditors.iam_ssl
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_user` Module
    -----------------------
    -
    -.. automodule:: security_monkey.auditors.iam_user
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`rds_security_group` Module
    ---------------------------------
    -
    -.. automodule:: security_monkey.auditors.rds_security_group
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`redshift` Module
    -----------------------
    -
    -.. automodule:: security_monkey.auditors.redshift
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`s3` Module
    -----------------
    -
    -.. automodule:: security_monkey.auditors.s3
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`security_group` Module
    -----------------------------
    -
    -.. automodule:: security_monkey.auditors.security_group
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`ses` Module
    ------------------
    -
    -.. automodule:: security_monkey.auditors.ses
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`sns` Module
    ------------------
    -
    -.. automodule:: security_monkey.auditors.sns
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    diff --git a/docs/api/security_monkey.common.md b/docs/api/security_monkey.common.md
    new file mode 100644
    index 000000000..76c6c1427
    --- /dev/null
    +++ b/docs/api/security_monkey.common.md
    @@ -0,0 +1,17 @@
    +common Package
    +==============
    +
    +common Package
    +--------------
    +
    +jinja Module
    +------------
    +
    +route53 Module
    +--------------
    +
    +sts\_connect Module
    +-------------------
    +
    +Subpackages
    +-----------
    diff --git a/docs/api/security_monkey.common.rst b/docs/api/security_monkey.common.rst
    deleted file mode 100644
    index 5a7f3115d..000000000
    --- a/docs/api/security_monkey.common.rst
    +++ /dev/null
    @@ -1,42 +0,0 @@
    -common Package
    -==============
    -
    -:mod:`common` Package
    ----------------------
    -
    -.. automodule:: security_monkey.common
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`jinja` Module
    --------------------
    -
    -.. automodule:: security_monkey.common.jinja
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`route53` Module
    ----------------------
    -
    -.. automodule:: security_monkey.common.route53
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`sts_connect` Module
    --------------------------
    -
    -.. automodule:: security_monkey.common.sts_connect
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -Subpackages
    ------------
    -
    -.. toctree::
    -
    -    security_monkey.common.utils
    -
    diff --git a/docs/api/security_monkey.common.utils.md b/docs/api/security_monkey.common.utils.md
    new file mode 100644
    index 000000000..720b41338
    --- /dev/null
    +++ b/docs/api/security_monkey.common.utils.md
    @@ -0,0 +1,11 @@
    +utils Package
    +=============
    +
    +utils Package
    +-------------
    +
    +PolicyDiff Module
    +-----------------
    +
    +utils Module
    +------------
    diff --git a/docs/api/security_monkey.common.utils.rst b/docs/api/security_monkey.common.utils.rst
    deleted file mode 100644
    index e52e18181..000000000
    --- a/docs/api/security_monkey.common.utils.rst
    +++ /dev/null
    @@ -1,27 +0,0 @@
    -utils Package
    -=============
    -
    -:mod:`utils` Package
    ---------------------
    -
    -.. automodule:: security_monkey.common.utils
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`PolicyDiff` Module
    -------------------------
    -
    -.. automodule:: security_monkey.common.utils.PolicyDiff
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`utils` Module
    --------------------
    -
    -.. automodule:: security_monkey.common.utils.utils
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    diff --git a/docs/api/security_monkey.md b/docs/api/security_monkey.md
    new file mode 100644
    index 000000000..22a865a83
    --- /dev/null
    +++ b/docs/api/security_monkey.md
    @@ -0,0 +1,38 @@
    +security\_monkey Package
    +========================
    +
    +security\_monkey Package
    +------------------------
    +
    +alerter Module
    +--------------
    +
    +auditor Module
    +--------------
    +
    +constants Module
    +----------------
    +
    +datastore Module
    +----------------
    +
    +decorators Module
    +-----------------
    +
    +exceptions Module
    +-----------------
    +
    +monitors Module
    +---------------
    +
    +reporter Module
    +---------------
    +
    +scheduler Module
    +----------------
    +
    +watcher Module
    +--------------
    +
    +Subpackages
    +-----------
    diff --git a/docs/api/security_monkey.rst b/docs/api/security_monkey.rst
    deleted file mode 100644
    index 600aa5a1e..000000000
    --- a/docs/api/security_monkey.rst
    +++ /dev/null
    @@ -1,102 +0,0 @@
    -security_monkey Package
    -=======================
    -
    -:mod:`security_monkey` Package
    -------------------------------
    -
    -.. automodule:: security_monkey.__init__
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`alerter` Module
    ----------------------
    -
    -.. automodule:: security_monkey.alerter
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`auditor` Module
    ----------------------
    -
    -.. automodule:: security_monkey.auditor
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`constants` Module
    ------------------------
    -
    -.. automodule:: security_monkey.constants
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`datastore` Module
    ------------------------
    -
    -.. automodule:: security_monkey.datastore
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`decorators` Module
    -------------------------
    -
    -.. automodule:: security_monkey.decorators
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`exceptions` Module
    -------------------------
    -
    -.. automodule:: security_monkey.exceptions
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`monitors` Module
    -----------------------
    -
    -.. automodule:: security_monkey.monitors
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`reporter` Module
    -----------------------
    -
    -.. automodule:: security_monkey.reporter
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`scheduler` Module
    ------------------------
    -
    -.. automodule:: security_monkey.scheduler
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`watcher` Module
    ----------------------
    -
    -.. automodule:: security_monkey.watcher
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -Subpackages
    ------------
    -
    -.. toctree::
    -
    -    security_monkey.auditors
    -    security_monkey.common
    -    security_monkey.tests
    -    security_monkey.views
    -    security_monkey.watchers
    -
    diff --git a/docs/api/security_monkey.tests.md b/docs/api/security_monkey.tests.md
    new file mode 100644
    index 000000000..d5f660f39
    --- /dev/null
    +++ b/docs/api/security_monkey.tests.md
    @@ -0,0 +1,11 @@
    +tests Package
    +=============
    +
    +tests Package
    +-------------
    +
    +test\_s3 Module
    +---------------
    +
    +test\_sns Module
    +----------------
    diff --git a/docs/api/security_monkey.tests.rst b/docs/api/security_monkey.tests.rst
    deleted file mode 100644
    index b6378fa5d..000000000
    --- a/docs/api/security_monkey.tests.rst
    +++ /dev/null
    @@ -1,27 +0,0 @@
    -tests Package
    -=============
    -
    -:mod:`tests` Package
    ---------------------
    -
    -.. automodule:: security_monkey.tests
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`test_s3` Module
    ----------------------
    -
    -.. automodule:: security_monkey.tests.test_s3
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`test_sns` Module
    -----------------------
    -
    -.. automodule:: security_monkey.tests.test_sns
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    diff --git a/docs/api/security_monkey.views.md b/docs/api/security_monkey.views.md
    new file mode 100644
    index 000000000..a37505f24
    --- /dev/null
    +++ b/docs/api/security_monkey.views.md
    @@ -0,0 +1,41 @@
    +views Package
    +=============
    +
    +views Package
    +-------------
    +
    +account Module
    +--------------
    +
    +distinct Module
    +---------------
    +
    +ignore\_list Module
    +-------------------
    +
    +item Module
    +-----------
    +
    +item\_comment Module
    +--------------------
    +
    +item\_issue Module
    +------------------
    +
    +item\_issue\_justification Module
    +---------------------------------
    +
    +logout Module
    +-------------
    +
    +revision Module
    +---------------
    +
    +revision\_comment Module
    +------------------------
    +
    +user\_settings Module
    +---------------------
    +
    +whitelist Module
    +----------------
    diff --git a/docs/api/security_monkey.views.rst b/docs/api/security_monkey.views.rst
    deleted file mode 100644
    index 08ce0b35a..000000000
    --- a/docs/api/security_monkey.views.rst
    +++ /dev/null
    @@ -1,107 +0,0 @@
    -views Package
    -=============
    -
    -:mod:`views` Package
    ---------------------
    -
    -.. automodule:: security_monkey.views
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`account` Module
    ----------------------
    -
    -.. automodule:: security_monkey.views.account
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`distinct` Module
    -----------------------
    -
    -.. automodule:: security_monkey.views.distinct
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`ignore_list` Module
    --------------------------
    -
    -.. automodule:: security_monkey.views.ignore_list
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`item` Module
    -------------------
    -
    -.. automodule:: security_monkey.views.item
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`item_comment` Module
    ---------------------------
    -
    -.. automodule:: security_monkey.views.item_comment
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`item_issue` Module
    -------------------------
    -
    -.. automodule:: security_monkey.views.item_issue
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`item_issue_justification` Module
    ---------------------------------------
    -
    -.. automodule:: security_monkey.views.item_issue_justification
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`logout` Module
    ---------------------
    -
    -.. automodule:: security_monkey.views.logout
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`revision` Module
    -----------------------
    -
    -.. automodule:: security_monkey.views.revision
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`revision_comment` Module
    -------------------------------
    -
    -.. automodule:: security_monkey.views.revision_comment
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`user_settings` Module
    ----------------------------
    -
    -.. automodule:: security_monkey.views.user_settings
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`whitelist` Module
    ------------------------
    -
    -.. automodule:: security_monkey.views.whitelist
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    diff --git a/docs/api/security_monkey.watchers.md b/docs/api/security_monkey.watchers.md
    new file mode 100644
    index 000000000..c28b8bbb9
    --- /dev/null
    +++ b/docs/api/security_monkey.watchers.md
    @@ -0,0 +1,47 @@
    +watchers Package
    +================
    +
    +watchers Package
    +----------------
    +
    +elastic\_ip Module
    +------------------
    +
    +elb Module
    +----------
    +
    +iam\_group Module
    +-----------------
    +
    +iam\_role Module
    +----------------
    +
    +iam\_ssl Module
    +---------------
    +
    +iam\_user Module
    +----------------
    +
    +keypair Module
    +--------------
    +
    +rds\_security\_group Module
    +---------------------------
    +
    +redshift Module
    +---------------
    +
    +s3 Module
    +---------
    +
    +security\_group Module
    +----------------------
    +
    +ses Module
    +----------
    +
    +sns Module
    +----------
    +
    +sqs Module
    +----------
    diff --git a/docs/api/security_monkey.watchers.rst b/docs/api/security_monkey.watchers.rst
    deleted file mode 100644
    index 3ea177a80..000000000
    --- a/docs/api/security_monkey.watchers.rst
    +++ /dev/null
    @@ -1,123 +0,0 @@
    -watchers Package
    -================
    -
    -:mod:`watchers` Package
    ------------------------
    -
    -.. automodule:: security_monkey.watchers
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`elastic_ip` Module
    -------------------------
    -
    -.. automodule:: security_monkey.watchers.elastic_ip
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`elb` Module
    ------------------
    -
    -.. automodule:: security_monkey.watchers.elb
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_group` Module
    ------------------------
    -
    -.. automodule:: security_monkey.watchers.iam_group
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_role` Module
    -----------------------
    -
    -.. automodule:: security_monkey.watchers.iam_role
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_ssl` Module
    ----------------------
    -
    -.. automodule:: security_monkey.watchers.iam_ssl
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`iam_user` Module
    -----------------------
    -
    -.. automodule:: security_monkey.watchers.iam_user
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`keypair` Module
    ----------------------
    -
    -.. automodule:: security_monkey.watchers.keypair
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`rds_security_group` Module
    ---------------------------------
    -
    -.. automodule:: security_monkey.watchers.rds_security_group
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`redshift` Module
    -----------------------
    -
    -.. automodule:: security_monkey.watchers.redshift
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`s3` Module
    -----------------
    -
    -.. automodule:: security_monkey.watchers.s3
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`security_group` Module
    -----------------------------
    -
    -.. automodule:: security_monkey.watchers.security_group
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`ses` Module
    ------------------
    -
    -.. automodule:: security_monkey.watchers.ses
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`sns` Module
    ------------------
    -
    -.. automodule:: security_monkey.watchers.sns
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    -:mod:`sqs` Module
    ------------------
    -
    -.. automodule:: security_monkey.watchers.sqs
    -    :members:
    -    :undoc-members:
    -    :show-inheritance:
    -
    diff --git a/docs/authors.md b/docs/authors.md
    new file mode 100644
    index 000000000..9c1964d9a
    --- /dev/null
    +++ b/docs/authors.md
    @@ -0,0 +1,6 @@
    +Authors
    +=======
    +
    +securitymonkey 0.8.0 is copyright 2014,2015,2016 Netflix. inc.
    +
    +If you want to contribute to security monkey, see contributing.
    diff --git a/docs/authors.rst b/docs/authors.rst
    deleted file mode 100644
    index dcb4ccd5d..000000000
    --- a/docs/authors.rst
    +++ /dev/null
    @@ -1,9 +0,0 @@
    -*******
    -Authors
    -*******
    -
    -securitymonkey 0.8.0 is copyright 2014,2015,2016 Netflix. inc.
    -
    -.. include:: ../AUTHORS
    -
    -If you want to contribute to security monkey, see :doc:`contributing`.
    diff --git a/docs/changelog.md b/docs/changelog.md
    new file mode 100644
    index 000000000..40b71b580
    --- /dev/null
    +++ b/docs/changelog.md
    @@ -0,0 +1,643 @@
    +Changelog
    +=========
    +
    +v0.8.0 (2016-12-02-delayed-\>2017-01-13)
    +----------------------------------------
    +
    +-   PR \#425 - @crruthe - Fixed a few report hyperlinks.
    +-   PR \#428 - @nagwww - Documentation fix. Renamed module: security\_monkey.auditors.elb to module: security\_monkey.auditors.elasticsearch\_service
    +-   PR \#424 - @mikegrima - OS X Install doc updates for El Capitan and higher.
    +-   PR \#426 - @mikegrima - Added "route53domains:getdomaindetail" to permissions doc.
    +-   PR \#427 - @mikegrima - Fix for ARN parsing of cloudfront ARNs.
    +-   PR \#431 - @mikegrima - Removed s3 ARN check for ElasticSearch Service.
    +-   PR \#448 - @zollman - Fix exception logging in store\_exception.
    +-   PR \#444 - @zollman - Adds exception logging listener for appscheduler.
    +-   PR \#454 - @mikegrima - Updated S3 Permissions to reflect latest changes to cloudaux.
    +-   PR \#455 - @zollman - Add Dashboard.
    +-   PR \#456 - @zollman - Increase issue note size.
    +-   PR \#420 - @crruthe - Added support for SSO OneLogin.
    +-   PR \#432 - @robertoriv - Add pagination for whitelist and ignore list.
    +-   PR \#438 - @AngeloCiffa - Pin moto==0.4.25. (TODO: Bump Jinja2 version.)
    +-   PR \#433 - @jnbnyc - Added Docker/Docker Compose support for local dev.
    +-   PR \#408 - @zollman - Add support for custom account metadata. (An important step that will allow us to support multiple cloud providers in the future.)
    +-   PR \#439 - @monkeysecurity - Replace botor lib with Netflix CloudAux.
    +-   PR \#441 - @monkeysecurity - Auditor ChangeItems now receive ARN.
    +-   PR \#446 - @zollman - Fix item 'first\_seen' query .
    +-   PR \#447 - @zollman - Refactor rdsdbcluster array params.
    +-   PR \#445 - @zollman - Make misfire grace time and reporter start time configurable.
    +-   PR \#451 - @monkeysecurity - Add coverage with Coveralls.io.
    +-   PR \#452 - @monkeysecurity - Refactor & add tests for the PolicyDiff module.
    +-   PR \#449 - @monkeysecurity - Refactoring s3 watcher to use Netflix CloudAux.
    +-   PR \#453 - @monkeysecurity - Fixing two policy diff cases.
    +-   PR \#442 - @monkeysecurity - Adding index to region. Dropping unused item.cloud.
    +-   PR \#450 - @monkeysecurity - Moved test & onelogin requirements to the setup.py extras\_require section.
    +-   PR \#407 - @zollman - Link together issues by enabling auditor dependencies.
    +-   PR \#419 - @monkeysecurity - Auditor will now fix any issues that are not attached to an AuditorSetting.
    +-   PR NONE - @monkeysecurity - Item View no longer returns revision configuration bodies. Should improve UI for items with many revisions.
    +-   PR NONE - @monkeysecurity - Fixing bug where SSO arguments weren't passed along for branded sso. (Where the name is not google or ping or onelogin)
    +-   PR \#476 - @markofu - Update aws\_accounts.json to add Canada and Ohio regions.
    +-   PR NONE - @monkeysecurity - Fixing manage.py::amazon\_accounts() to use new AccountType and adding delete\_unjustified\_issues().
    +-   PR \#480 - @monkeysecurity - Making Gunicorn an optional import to help support dev on Windows.
    +-   PR \#481 - @monkeysecurity - Fixing a couple dart warnings.
    +-   PR \#482 - @monkeysecurity - Replacing Flask-Security with Flask-Security-Fork.
    +-   PR \#483 - @monkeysecurity - issue \#477 - Fixes IAM User Auditor login\_profile check.
    +-   PR \#484 - @monkeysecurity - Bumping Jinja2 to \>=2.8.1
    +-   PR \#485 - @robertoriv - New IAM Role Auditor feature - Check for unknown cross account assumerole.
    +-   PR \#487 - @hyperbolist - issue \#486 - Upgrade setuptools in Dockerfile.
    +-   PR \#489 - @monkeysecurity - issue \#251 - Fix IAM SSL Auditor regression. Issue should be raised if we cannot obtain cert issuer.
    +-   PR \#490 - @monkeysecurity - issue \#421 - Adding ephemeral field to RDS DB issue.
    +-   PR \#491 - @monkeysecurity - Adding new RDS DB Cluster ephemeral field.
    +-   PR \#492 - @monkeysecurity - issue \#466 - Updating S3 Auditor to use the ARN class.
    +-   PR NONE - @monkeysecurity - Fixing typo in dart files.
    +-   PR \#495 - @monkeysecurity - issue \#494 - Refactoring to work with the new Flask-WTF.
    +-   PR \#493 - @monkeysecurity - Windows 10 Development instructions.
    +-   PR NONE - @monkeysecurity - issue \#496 - Bumping CloudAux to \>=1.0.7 to fix IAM User UploadDate field JSON serialization error.
    +
    +Important Notes:
    +
    +-   New permissions required:  
    +    -   s3:getaccelerateconfiguration
    +    -   s3:getbucketcors
    +    -   s3:getbucketnotification
    +    -   s3:getbucketwebsite
    +    -   s3:getreplicationconfiguration
    +    -   s3:getanalyticsconfiguration
    +    -   s3:getmetricsconfiguration
    +    -   s3:getinventoryconfiguration
    +    -   route53domains:getdomaindetail
    +    -   cloudtrail:gettrailstatus
    +
    +Contributors:
    +
    +-   @zollman
    +-   @robertoriv
    +-   @hyperbolist
    +-   @markofu
    +-   @AngeloCiffa
    +-   @jnbnyc
    +-   @crruthe
    +-   @nagwww
    +-   @mikegrima
    +-   @monkeysecurity
    +
    +v0.7.0 (2016-09-21)
    +-------------------
    +
    +-   PR \#410/\#405 - @zollman - Custom Watcher/Auditor Support. (Dynamic Loading)
    +-   PR \#412 - @llange - Google SSO Fixes
    +-   PR \#409 - @kyelberry - Fixed Report URLs in UI.
    +-   PR \#413 - @markofu - Better handle IAM SSL certificates that we cannot parse.
    +-   PR \#411 - @zollman - Many, many new watchers and auditors.
    +
    +New Watchers:
    +
    +> -   CloudTrail
    +> -   AWSConfig
    +> -   AWSConfigRecorder
    +> -   DirectConnect::Connection
    +> -   EC2::EbsSnapshot
    +> -   EC2::EbsVolume
    +> -   EC2::Image
    +> -   EC2::Instance
    +> -   ENI
    +> -   KMS::Grant
    +> -   KMS::Key
    +> -   Lambda
    +> -   RDS::ClusterSnapshot
    +> -   RDS::DBCluster
    +> -   RDS::DBInstace
    +> -   RDS::Snapshot
    +> -   RDS::SubnetGroup
    +> -   Route53
    +> -   Route53Domains
    +> -   TrustedAdvisor
    +> -   VPC::DHCP
    +> -   VPC::Endpoint
    +> -   VPC::FlowLog
    +> -   VPC::NatGateway
    +> -   VPC::NetworkACL
    +> -   VPC::Peering
    +
    +Important Notes:
    +
    +-   New permissions required:  
    +    -   cloudtrail:describetrails
    +    -   config:describeconfigrules
    +    -   config:describeconfigurationrecorders
    +    -   directconnect:describeconnections
    +    -   ec2:describeflowlogs
    +    -   ec2:describeimages
    +    -   ec2:describenatgateways
    +    -   ec2:describenetworkacls
    +    -   ec2:describenetworkinterfaces
    +    -   ec2:describesnapshots
    +    -   ec2:describevolumes
    +    -   ec2:describevpcendpoints
    +    -   ec2:describevpcpeeringconnections,
    +    -   iam:getaccesskeylastused
    +    -   iam:listattachedgrouppolicies
    +    -   iam:listattacheduserpolicies
    +    -   lambda:listfunctions
    +    -   rds:describedbclusters
    +    -   rds:describedbclustersnapshots
    +    -   rds:describedbinstances
    +    -   rds:describedbsnapshots
    +    -   rds:describedbsubnetgroups
    +    -   redshift:describeclusters
    +    -   route53domains:listdomains
    +
    +Contributors:
    +
    +-   @zollman
    +-   @kyleberry
    +-   @llange
    +-   @markofu
    +-   @monkeysecurity
    +
    +v0.6.0 (2016-08-29)
    +-------------------
    +
    +-   issue \#292 - PR \#332 - Add ephemeral sections to the redshift watcher
    +-   PR \#338 - Added access key last used to IAM Users.
    +-   Added an IAM User auditor check to look for access keys without use in past 90 days.
    +-   PR \#334 - @alexcline - Route53 watcher and auditor. (Updated to use botor in PR \#343)
    +-   Logo updated. Weapon replaced with banana. Expect more logo changes soon.
    +-   PR \#345 - Ephemeral changes now update the latest revision. Revisions now have a date\_last\_ephemeral\_change column as well as a date\_created column.
    +-   PR \#349 - @mikegrima - Install documentation updates
    +-   PR \#354 - Feature/SSO (YAY)
    +-   PR \#365 - @alexcline - Added ACM (Amazon Certificate Manager) watcher/auditor
    +-   PR \#358/\#370 - @alexcline - Alex cline feature/kms
    +-   Updated Dart/Angular dart versions.
    +-   PR \#362 - @crruthe - Changed to dictConfig logging format
    +-   PR \#372 - @ollytheninja - SQS principal bugfix
    +-   PR \#379 - @bunjiboys - Adding Mumbai region
    +-   PR \#380 - @bunjiboys - Adding Mumbai ELB Log AWS Account info
    +-   PR \#381 - @ollytheninja - Adding tags to the S3 watcher
    +-   Boto updates
    +-   PR \#376 - Adding item.arn field. Adding item.latest\_revision\_complete\_hash and item.latest\_revision\_durable\_hash. These are for the bananapeel rearchitecture.
    +-   PR \#386 - Shortening sessions from default value to 60 minutes. Setting Cookie HTTPONLY and SECURE flags.
    +-   PR \#389 - Adding CloudTrail table, linked to itemrevision. (To be used by bananapeel rearchitecture.)
    +-   PR \#390 - @ollytheninja - Adding export CSV button.
    +-   PR \#394 - @mikegrima - Saving exceptions to database table
    +-   PR \#402 - issue \#401 - Adding new ELB Reference Policy ELBSecurityPolicy-2016-08
    +
    +Hotfixes:
    +
    +-   Upgraded Cryptography to 1.3.1
    +-   Updated docs to use sudo -E when calling manage.py amazon\_accounts.
    +-   Updated the @record\_exception decorator to allow the region to be overwritten. (Useful for region-less technology that likes to be recorded in the "universal" region.)
    +-   issue \#331 - IAMSSL watcher failed on elliptic curve certs
    +
    +Important Notes:
    +
    +-   Route53 IgnoreList entries may match zone name or recordset name.
    +-   Checkout the new log configuration format from PR \#362. You may want to update your config.py.
    +-   New permissions required:  
    +    -   "acm:ListCertificates",
    +    -   "acm:DescribeCertificate",
    +    -   "kms:DescribeKey",
    +    -   "kms:GetKeyPolicy",
    +    -   "kms:ListKeys",
    +    -   "kms:ListAliases",
    +    -   "kms:ListGrants",
    +    -   "kms:ListKeyPolicies",
    +    -   "s3:GetBucketTagging"
    +
    +-   Some dependencies have been updated (cryptography, boto, boto3, botocore, botor, pyjwt). Please re-run python setup.py install.
    +-   Please add the following lines to your config.py for more time-limited sessions:
    +
    +~~~~ {.sourceCode .python}
    +PERMANENT_SESSION_LIFETIME=timedelta(minutes=60)   # Will logout users after period of inactivity.
    +SESSION_REFRESH_EACH_REQUEST=True
    +SESSION_COOKIE_SECURE=True
    +SESSION_COOKIE_HTTPONLY=True
    +PREFERRED_URL_SCHEME='https'
    +
    +REMEMBER_COOKIE_DURATION=timedelta(minutes=60)  # Can make longer if  you want remember_me to be useful
    +REMEMBER_COOKIE_SECURE=True
    +REMEMBER_COOKIE_HTTPONLY=True
    +~~~~
    +
    +Contributors:
    +
    +-   @alexcline
    +-   @crruthe
    +-   @ollytheninja
    +-   @bunjiboys
    +-   @mikegrima
    +-   @monkeysecurity
    +
    +v0.5.0 (2016-04-26)
    +-------------------
    +
    +-   PR \#286 - bunjiboys - Added Seoul region AWS Account IDs to import scripts
    +-   PR \#291 - sbasgall - Corrected ignore\_list.py variable names and help strings
    +-   PR \#284 - mikegrima - Fixed cross-account root reporting for ES service (Issue \#283)
    +-   PR \#293 - mikegrima - Updated quickstart documentation to remove permission wildcards (Issue \#287)
    +-   PR \#301 - monkeysecurity - iamrole watcher can now handle many more roles (1000+) and no longer times out.
    +-   PR \#316 - DenverJ - Handle database exceptions by cleaning up session.
    +-   PR \#289 - delikat - Persist custom role names on account creation
    +-   PR \#321 - monkeysecurity - Item List and Item View will no longer display disabled issues.
    +-   PR \#322 (PR \#308) - llange - Ability to add AWS owned managed policies to ignore list by ARN (Issue \#148)
    +-   PR \#323 - snixon - Breaks check\_securitygroup\_any into ingress and egress (Issue \#239)
    +-   PR \#309 - DenverJ - Significant database query optimizations by tuning itemrevision retrievals
    +-   PR \#324 - mikegrima - Handling invalid ARNs more consistently between watchers (Issue \#248)
    +-   PR \#317 - ollytheninja - Add Role Based Access Control
    +-   PR \#327 - monkeysecurity - Added Flask-Security's SECURITY\_TRACKABLE to backend and UI
    +-   PR \#328 - monkeysecurity - Added ability to parse AWS service "ARNs" like events.amazonaws.com as well as ARNS that use \* for the account number like arn:aws:s3:​\*:\*​:some-s3-bucket
    +-   PR \#314 - pdbogen - Update Logging to have the ability to log to stdout, useful for dockerizing.
    +
    +Hotfixes:
    +
    +-   s3\_acl\_compare\_lowercase: AWS now returns S3 ACLs with a lowercased owner. security\_monkey now does a case insensitive compare
    +-   longer\_resource\_ids. Updating DB to handle longer AWS resource IDs: 
    +-   Removed requests from requirements.txt/setup.py as it was pinned to a very old version and not directly required (Issue \#312)
    +-   arn\_condition\_awssourcearn\_can\_be\_list. Updated security\_monkey to be able to handle a list of ARNS in a policy condition.
    +-   ignore\_list\_fails\_on\_empty\_string: security\_monkey now properly handles an ignorelist entry containing a prefix string of length 0.
    +-   protocol\_sslv2\_deprecation: AWS stopped returning whether an ELB listener supported SSLv2. Fixed security\_monkey to handle the new format correctly.
    +
    +Important Notes:
    +
    +-   security\_monkey IAM roles now require a new permission: iam:listattachedrolepolicies
    +-   Your security\_monkey config file should contain a new flag: SECURITY\_TRACKABLE = True
    +-   You'll need to rerun python setup.py install to obtain the new dependencies.
    +
    +Contributors:
    +
    +-   @bunjiboys
    +-   @sbasgall
    +-   @mikegrima
    +-   @DenverJ
    +-   @delikat
    +-   @snixon
    +-   @ollytheninja
    +-   @pdbogen
    +-   @monkeysecurity
    +
    +v0.4.1 (2015-12-22)
    +-------------------
    +
    +-   PR \#269 - mikegrima - TravisCI now ensures that dart builds.
    +-   PR \#270 - monkeysecurity - Refactored sts\_connect to dynamically import boto resources.
    +-   PR \#271 - OllyTheNinja-Xero - Fixed indentation mistake in auditor.py
    +-   PR \#275 - AlexCline - Added elb logging to ELB watcher and auditor.
    +-   PR \#279 - mikegrima - Added ElasticSearch Watcher and Auditor (with tests).
    +-   PR \#280 - monkeysecurity - PolicyDiff better handling of changes to primitives (like ints) in dictionay values and added explicit escaping instead of relying on Angular.
    +-   PR \#282 - mikegrima - Documentation Fixes to configuration.rst and quickstart.rst adding es: permissions and other fixes.
    +
    +Hotfixes:
    +
    +-   Added OSSMETADATA file to master/develop for internal Netflix tracking.
    +
    +Contributors:
    +
    +-   @mikegrima
    +-   @monkeysecurity
    +-   @OllyTheNinja-Xero
    +-   @AlexCline
    +
    +v0.4.0 (2015-11-20)
    +-------------------
    +
    +-   PR \#228 - jeremy-h - IAM check misses '\*' when found within a list. (Issue \#223)
    +-   PR \#230 - markofu - New error and echo functions to simplify code for scripts/secmonkey\_auto\_install.sh
    +-   PR \#233 - mikegrima - Write tests for security\_monkey.common.ARN (Issue \#222)
    +-   PR \#238 - monkeysecurity - Refactoring \_check\_rfc\_1918 and improving VPC ELB Internet Accessible Check
    +-   PR \#241 - bunjiboys - Seed Amazon owned AWS accounts (Issue \#169)
    +-   PR \#243 - mikegrima - Fix for underscores not being detected in SNS watcher. (Issue \#240)
    +-   PR \#244 - mikegrima - Setup TravisCI (Issue \#227)
    +-   PR \#250 - OllyTheNinja-Xero - upgrade deprecated botocore calls in ELB watcher (Issue \#249)
    +-   PR \#256 - mikegrima - Latest Boto3/botocore versions (Issue \#254)
    +-   PR \#261 - bunjiboys - Add ec2:DescribeInstances to quickstart role documentation (Issue \#260)
    +-   PR \#263 - monkeysecurity - Updating docs/scripts to pin to dart 1.12.2-1 (Issue \#259)
    +-   PR \#265 - monkeysecurity - Remove ratelimiting max attempts, wrap ELB watcher with try/except/continue
    +
    +Hotfixes:
    +
    +-   Issue \#235 - OllyTheNinja-Xero - SNS Auditor - local variable 'entry' referenced before assignment
    +
    +Contributors:
    +
    +-   @jeremy-h
    +-   @mark-fu
    +-   @mikegrima
    +-   @bunjiboys
    +-   @OllyTheNinja-Xero
    +-   @monkeysecurity
    +
    +v0.3.9 (2015-10-08)
    +-------------------
    +
    +-   PR \#212 - bunjiboys - Make email failures warnings instead of debug messages
    +-   PR \#203 - markofu - Added license to secmonkey\_auto\_install.sh.
    +-   PR \#207 - cbarrac - Updated dependencies and dart installation for secmonkey\_auto\_install.sh
    +-   PR \#209 - mikegrima - Make SNS Ignorelist use name instead of ARN.
    +-   PR \#213 - Qmando - Added more exception handling to the S3 watcher.
    +-   PR \#215 - Dklotz-Circle - Added egress rules to the security group watcher.
    +-   monkeysecurity - Updated quickstart.rst IAM policy to remove wildcards and include redshift permissions.
    +-   PR \#218 - monkeysecurity - Added exception handling to the S3 bucket.get\_location API call.
    +-   PR \#221 - Qmando - Retry on AWS API error when slurping ELBs.
    +-   monkeysecurity - Updated cryptography package from 1.0 to 1.0.2 for easier installation under OS X El Capitan.
    +
    +Hotfixes:
    +
    +-   Updated quickstart.rst and secmonkey\_auto\_install.sh to remove swig/python-m2crypto and add libffi-dev
    +-   Issue \#220 - SQS Auditor not correctly parsing ARNs, halting security\_monkey. Fixed by abstracting ARN parsing into a new class (security\_monkey.common.arn). Updated the SNS Auditor to also use this new class.
    +
    +Contributors:
    +
    +-   bunjiboys
    +-   markofu
    +-   cbarrac
    +-   mikegrima
    +-   Qmando
    +-   Dklotz-Circle
    +-   monkeysecurity
    +
    +v0.3.8 (2015-08-28)
    +-------------------
    +
    +-   PR \#165 - echiu64 - S3 watcher now tracking S3 Logging Configuration.
    +-   None - monkeysecurity - Certs with an invalid issuer now flagged.
    +-   PR \#177 - DenverJ -Added new SQS Auditor.
    +-   PR \#188 - kevgliss - Removed dependency on M2Crypto/Swig and replaced with Cryptography.
    +-   PR \#164 - Qmando - URL encoding issue with certain searches containing spaces corrected.
    +-   None - monkeysecurity - Fixed issue where corrected issues were not removed.
    +-   PR \#198 - monkeysecurity - Adding ability to select up to four items or revisions to be compared.
    +-   PR \#194 \#195 - bunjiboys - SECURITY\_TEAM\_EMAIL should accept not only a list, but also a string or tuple.
    +-   PR \#180 \#181 \#190 \#191 \#192 \#193 - cbarrac - A number of udpates and fixes for the bash installer. (scripts/secmonkey\_auto\_installer.sh)
    +-   PR \#176 \#178 - mikegrima - Updated documentation for contributors on OS X and Ubuntu to use Webstorm instead of the Dart Editor.
    +
    +Contributors:
    +
    +-   Qmando
    +-   echiu64
    +-   DenverJ
    +-   cbarrac
    +-   kevgliss
    +-   mikegrima
    +-   monkeysecurity
    +
    +v0.3.7 (2015-07-20)
    +-------------------
    +
    +-   PR \#122 - Qmando - Jira Sync. Quentin from Yelp added Jira Integration.
    +-   PR \#147 - echiu64 - Added colors to audit emails and added missing justifications back into emails.
    +-   PR \#150 - echiu64 - Fixed a missing comma from setup.py
    +-   PR \#155 - echiu64 - Fixed a previous merge issue where \_audit\_changes() was looking for a Monitor instance instead of an list of Auditors.
    +-   Issue \#154 - monkeysecurity - Added support for ELB Reference Policy 2015-05.
    +-   None - monkeysecurity - Added db.session.refresh(...) where appropriate in a few API views to replace some very ugly code.
    +-   Issue \#133 - lucab - Upgraded Flask-RESTful from v0.2.5 to v0.3.3 to fix an issue where request arguments were being persisted as the string "None" when they should have remained the javascript literal null.
    +-   PR \#120 - lucab - Add custom role\_name field for each account to replace the previously hardcoded 'SecurityMonkey' role name.
    +-   PR \#120 - gene1wood - Add support for the custom role\_name into manage.py.
    +-   PR \#161 - Asbjorn Kjaer - Increase s3\_name from 32 characters to 64 characters to avoid errors or truncation where s3\_name is longer.
    +-   None - monkeysecurity - Set the 'defer' (lazy-load) attribute for the JSON config column on the ItemRevision table. This speeds up the web API in a number of places.
    +
    +Hotfixes:
    +
    +-   Issue \#149 - Python scoping issue where managed policies attached to more than one entity would cause an error.
    +-   Issue \#152 - SNS topics were being saved by ARN instead of by name, causing exceptions for very long names.
    +-   Issue \#141 - Setup cascading deletes on the Account table to prevent the error which occured when trying to delete an account with items and users attached.
    +
    +Contributors:
    +
    +-   Qmando
    +-   echiu64
    +-   lucab
    +-   gene1wood
    +-   Asbjorn Kjaer (akjaer)
    +-   monkeysecurity
    +
    +v0.3.6 (2015-04-09)
    +-------------------
    +
    +-   Changes to issue score in code will now cause all existing issues to be re-scored in the database.
    +-   A new configuration parameter called SECURITYGROUP\_INSTANCE\_DETAIL can now be set to:  
    +    -   "FULL": Security Groups will display each instances, and all instance tags, that are associated with the security group.
    +    -   "SUMMARY": Security Groups will display the number of instances attached to the security group.
    +    -   "NONE": Security Groups will not retrieve any data about instances attached to a security group.
    +    -   If SECURITY\_GROUP\_INSTANCE\_DETAIL is set to "FULL" or "SUMMARY", empty security groups audit issues will have their score set to zero.
    +    -   For accounts with many thousands of instances, it is advised to set this to "NONE" as the AWS API's do not respond in a timely manner with that many instances.
    +
    +-   Each watcher can be set to run at a different interval in code. We will want to move this to be a UI setting.
    +-   Watchers may specify a list of ephemeral paths. Security\_monkey will not send out change alerts for items in the ephemeral section. This is a good place for metadata that is often changing like the number of instances attached to a security\_group or the number of remaining IP addresses in a VPC subnet.
    +
    +Contributors:
    +
    +-   lucab
    +-   monkeysecurity
    +
    +v0.3.5 (2015-03-28)
    +-------------------
    +
    +-   Adding policy minimizer & expander to the revision component
    +-   Adding tracking of instance profiles attached to a role
    +-   Adding marker/pagination code to redshift.describe\_clusters()
    +-   Adding pagination to IAM User get\_all\_user\_policies, get\_all\_access\_keys, get\_all\_mfa\_devices, get\_all\_signing\_certs
    +-   Typo & minor corrections on postgres commands
    +-   CLI command to save your current configurations to a JSON file for backup
    +-   added a VPC watcher
    +-   Adding DHCP Options and Internet Gateways to the VPC Watcher
    +-   Adding a subnet watcher. Fixing the VPC watcher with deep\_dict
    +-   Adding the vpc route\_table watcher
    +-   Removing subnet remaining IP field until ephemeral section is merged in
    +-   Adding IAM Managed Policies
    +-   Typo & minor corrections on postgres commands in documentation
    +-   Adds ELBSecurityPolicy-2015-03. Moves export grade ciphers to their own section and alerts on FREAK vuln.
    +-   Provides context on refpol 2015-03 vs 2015-02.
    +-   Adding a Managed Policies Auditor
    +-   Added Manged Policy tracking to the IAM users, groups, and roles
    +
    +Summary of new watchers:
    +
    +-   vpc  
    +    -   DHCP Options
    +    -   Internet Gateways
    +
    +-   subnet
    +-   routetable
    +-   managed policies
    +
    +Summary of new Auditors or audit checks:
    +
    +-   managed policies
    +-   New reference policy 2015-03 for ELB listeners.
    +-   New alerts for FREAK vulnerable ciphers.
    +
    +Contributors:
    +
    +-   markofu
    +-   monkeysecurity
    +
    +v0.3.4 (2015-2-19)
    +------------------
    +
    +-   Merged in a new AuditorSettings tab created by Qmando at Yelp enabling you to disable audit checks with per-account granularity.
    +-   security\_monkey is now CSP compliant.
    +-   security\_monkey has removed all shadow-DOM components. Also removed webcomponents.js and dart\_support.js, as they were not CSP compliant.
    +-   security\_monkey now advises users to enable standard security headers following headers:
    +
    +~~~~ {.sourceCode .python}
    +X-Content-Type-Options "nosniff";
    +X-XSS-Protection "1; mode=block";
    +X-Frame-Options "SAMEORIGIN";
    +Strict-Transport-Security "max-age=631138519";
    +Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;"
    +~~~~
    +
    +-   security\_monkey now has XSRF protection against all DELETE, POST, PUT, and PATCH calls.
    +-   Updated the ELB Auditor to be aware of the ELBSecurityPolicy-2015-02 reference policy.
    +
    +Contributers:
    +
    +-   Qmando
    +-   monkeysecurity
    +
    +v0.3.3 (2015-2-3)
    +-----------------
    +
    +-   Added MirorsUsed() to my dart code to reduce compiled javascript size.
    +-   Added support for non-chrome browsers by importing webcomponents.js and dart\_support.js
    +-   Upgraded to Angulardart 1.1.0 and Angular-dart.ui 0.6.3
    +
    +v0.3.2 (2015-1-20)
    +------------------
    +
    +-   A bug has been corrected where IAM Groups with \> 100 members or policies would be truncated.
    +-   The web UI has been updated to use AngularDart 1.0.0. Significantly smaller javascript size.
    +
    +v0.3.1 (2015-1-11)
    +------------------
    +
    +-   Change emails again show issues and justifications.
    +-   Change emails now use jinja templating.
    +-   Fixed an issue where issue justifications would disappear when the item was changed.
    +-   Merged a pull request from github user jijojv to start the scheduler at launch instead of waiting 15 minutes.
    +
    +v0.3.0 (2014-12-19)
    +-------------------
    +
    +-   Add localhost to CORS for development.
    +-   Big refactor adding monitors. Adding new watchers/auditors is now much simpler.
    +-   Return to the current URL after authenticating.
    +-   Added SES\_REGION config. Now you can send email out of regions other than us-east-1.
    +-   Changing default log location to /var/log/security\_monkey.
    +-   Docs now have cleaner nginx.conf.
    +-   Add M2Crypto to get a number of new iamssl fields.
    +-   Added favicon.
    +
    +new watchers:
    +
    +-   eip
    +-   redshift
    +-   ses
    +
    +enhanced watchers:
    +
    +-   iamssl - new fields from m2crypto
    +-   elb - new listener policies from botocore
    +-   sns - added sns subscriptions
    +-   s3 - now tracks lifecycle rules
    +
    +new auditors:
    +
    +-   redshift - checks for non-vpc deployment.
    +-   ses - checks for verified identities
    +
    +enhanced auditors:
    +
    +-   iamssl - cert size, signature hashing algorithm, upcoming expiration, heartbleed
    +-   elb - check reference policy and certain custom policy fields
    +
    +hotfixes:
    +
    +-   Fixed issue \#12 - Deleting account results in foreign key constraint.
    +-   Added missing alembic script for the ignorelist.
    +-   Various minor documentation updates.
    +-   API server now respects --bind parameter. (Required for the docker image).
    +-   SES connection in utils.py is now surrounded in a try/except.
    +-   FlaskSecurity upgraded to latest.
    +
    +Contributers:
    +
    +-   ivanlei
    +-   lucab
    +-   yograterol
    +-   monkeysecurity
    +
    +v0.2.0 (2014-10-31)
    +-------------------
    +
    +Changes in the Web UI:
    +
    +-   Dart: Dates are now displayed in your local timezone.
    +-   Dart: Added Item-level comments.
    +-   Dart: Added the ability to bulk-justify issues from the Issues Table view. This uses the AngularDartUI Modal Component.
    +-   Dart: Added better messaging around the settings for adding an account. This closes issue \#38. This uses the AngularDartUI tooltip component.
    +-   Bug Fix: Colors in the Item table now correctly represent the justification status.
    +-   Dart: Added AngularUI Tabs to select between diff and current configuration display.
    +-   Dart: Added a timer-based auto-refresh so SM can be used as a dashboard.
    +-   Dart: Replaced a number of custom http services with Victor Savkin's Hammock library.
    +    - More than 965 lines of code removed after using Hammock.
    +-   Dart: Replaced custom pagination code with AngularDartUI's Pagination Component.
    +    -   IssueTable
    +    -   RevisionTable
    +    -   ItemTable
    +
    +    - AccountSettingsTable
    +-   Dart: Network CIDR whitelist is now configured in the web UI under settings.
    +-   Dart: Object Ignorelist is now configured in the web UI under settings.
    +-   Created a new PaginatedTable parent class for all components that wish to display paginated data. This table works with AngularDart's Pagination Component and also provides the ability to change the number of items displayed on each page.
    +-   Dart: Added ng\_infinite\_scroll to the item\_detail\_view for loading revisions
    +-   Dart: Moved a number of components from being their own libraries to being `` `part of ``\` the security\_monkey library.
    +-   Dart: Replaced the last controller (UsernameController) with a Component to prepare for AngularDart 1.0.0
    +-   Dart: Style - Renamed library from SecurityMonkey to security\_monkey to follow the dart style guide. Refactored much of main.dart into lib/security\_monkey.dart to try and mimic the cleaner design of the new angular sample app: 
    +
    +Changes in the core product:
    +
    +-   Updated API endpoints to better follow REST architecture.
    +-   Added table for NetworkWhitelist.
    +-   Added rest API endpoints for NetworkWhitelist.
    +-   Added Alembic migration script to add the new NetworkWhitelist table to the DB.
    +-   Added table for IgnoreList.
    +-   Added rest API endpoints for Ignorelist.
    +-   Added Alembic migration script to add the new IgnoreList table to the DB.
    +-   Added check for rfc-1918 CIDRs in non-VPC security groups.
    +-   Saving IAMSSL Certs by cert name instead of cert ID
    +-   Marking VPC RDS Security Groups with their VPC ID
    +-   Supports Paginated Boto access for RDS Security Groups.
    +-   Added alert for non-VPC RDS SG's containing RFC-1918 CIDRs
    +-   Added check for IAM USER AKEY rotation
    +-   Added check for IAM USER with login profile (console access) And Access Keys (API Access)
    +-   Added an ELB Auditor with a check for internet-facing ELB.
    +-   Added check for security groups with large port ranges.
    +
    +v0.1.2 (2014-08-11)
    +-------------------
    +
    +Changes in the Web UI:
    +
    +-   Dart: Removed Shadow DOM dependency and set version bounds in pubspec.yaml.
    +-   Dart: Replaced package:js with dart:js.
    +-   Dart: Added the Angular Pub Transformer.
    +
    +Changes in the core product:
    +
    +-   Added AWS Rate Limiting Protection with exponential backoff code.
    +-   Added instructions to get a local development environment setup for contributing to security\_monkey.
    +-   Added support for boto's new ELB pagination. The pull request to boto and to security\_monkey came from Kevin Glisson.
    +-   Bug fix: Security Group Audit Issues now include the port the issue was reported on.
    +
    +These were already in master, but weren't tied to a new release:
    +
    +-   Bug fix: Supervisor script now sets SECURITY\_MONKEY\_SETTINGS envvar for the API server whereas it only previously set the envvar for the scheduler. This came from a pull request from parabolic.
    +-   Bug fix: Audit reports will only be sent if there are issues to report on.
    +-   Bug fix: Daily Audit Email setting (ALL/NONE/ISSUES) is now respected.
    +-   Bug fix: Command Line Auditor Command Arguments are now coerced into being booleans.
    +-   Quickstart Guide now instructs user to setup the web UI on SSL.
    +-   Various Smaller Bug Fixes.
    +
    +v0.1.1 (2014-06-30)
    +-------------------
    +
    +Initial release of Security Monkey!
    diff --git a/docs/changelog.rst b/docs/changelog.rst
    deleted file mode 100644
    index 6210b3e26..000000000
    --- a/docs/changelog.rst
    +++ /dev/null
    @@ -1,643 +0,0 @@
    -*********
    -Changelog
    -*********
    -
    -v0.8.0 (2016-12-02-delayed->2017-01-13)
    -=======================================
    -- PR #425 - @crruthe - Fixed a few report hyperlinks.
    -- PR #428 - @nagwww - Documentation fix. Renamed `module: security_monkey.auditors.elb` to `module: security_monkey.auditors.elasticsearch_service`
    -- PR #424 - @mikegrima - OS X Install doc updates for El Capitan and higher.
    -- PR #426 - @mikegrima - Added "route53domains:getdomaindetail" to permissions doc.
    -- PR #427 - @mikegrima - Fix for ARN parsing of cloudfront ARNs.
    -- PR #431 - @mikegrima - Removed s3 ARN check for ElasticSearch Service.
    -- PR #448 - @zollman - Fix exception logging in store_exception.
    -- PR #444 - @zollman - Adds exception logging listener for appscheduler.
    -- PR #454 - @mikegrima - Updated S3 Permissions to reflect latest changes to cloudaux.
    -- PR #455 - @zollman - Add Dashboard.
    -- PR #456 - @zollman - Increase issue note size.
    -- PR #420 - @crruthe - Added support for SSO OneLogin.
    -- PR #432 - @robertoriv - Add pagination for whitelist and ignore list.
    -- PR #438 - @AngeloCiffa - Pin moto==0.4.25. (TODO: Bump Jinja2 version.)
    -- PR #433 - @jnbnyc - Added Docker/Docker Compose support for local dev.
    -- PR #408 - @zollman - Add support for custom account metadata. (An important step that will allow us to support multiple cloud providers in the future.)
    -- PR #439 - @monkeysecurity - Replace botor lib with Netflix CloudAux.
    -- PR #441 - @monkeysecurity - Auditor ChangeItems now receive ARN.
    -- PR #446 - @zollman - Fix item 'first_seen' query .
    -- PR #447 - @zollman - Refactor rdsdbcluster array params.
    -- PR #445 - @zollman - Make misfire grace time and reporter start time configurable.
    -- PR #451 - @monkeysecurity - Add coverage with Coveralls.io.
    -- PR #452 - @monkeysecurity - Refactor & add tests for the PolicyDiff module.
    -- PR #449 - @monkeysecurity - Refactoring s3 watcher to use Netflix CloudAux.
    -- PR #453 - @monkeysecurity - Fixing two policy diff cases.
    -- PR #442 - @monkeysecurity - Adding index to region. Dropping unused item.cloud.
    -- PR #450 - @monkeysecurity - Moved test & onelogin requirements to the setup.py extras_require section.
    -- PR #407 - @zollman - Link together issues by enabling auditor dependencies.
    -- PR #419 - @monkeysecurity - Auditor will now fix any issues that are not attached to an AuditorSetting.
    -- PR NONE - @monkeysecurity - Item View no longer returns revision configuration bodies.  Should improve UI for items with many revisions.
    -- PR NONE - @monkeysecurity - Fixing bug where SSO arguments weren't passed along for branded sso. (Where the name is not google or ping or onelogin)
    -- PR #476 - @markofu - Update aws_accounts.json to add Canada and Ohio regions.
    -- PR NONE - @monkeysecurity - Fixing `manage.py::amazon_accounts()` to use new AccountType and adding `delete_unjustified_issues()`.
    -- PR #480 - @monkeysecurity - Making Gunicorn an optional import to help support dev on Windows.
    -- PR #481 - @monkeysecurity - Fixing a couple dart warnings.
    -- PR #482 - @monkeysecurity - Replacing `Flask-Security` with `Flask-Security-Fork`.
    -- PR #483 - @monkeysecurity - issue #477 - Fixes IAM User Auditor login_profile check.
    -- PR #484 - @monkeysecurity - Bumping Jinja2 to `>=2.8.1`
    -- PR #485 - @robertoriv - New IAM Role Auditor feature - Check for unknown cross account assumerole.
    -- PR #487 - @hyperbolist - issue #486 - Upgrade setuptools in Dockerfile.
    -- PR #489 - @monkeysecurity - issue #251 - Fix IAM SSL Auditor regression. Issue should be raised if we cannot obtain cert issuer.
    -- PR #490 - @monkeysecurity - issue #421 - Adding ephemeral field to RDS DB issue.
    -- PR #491 - @monkeysecurity - Adding new RDS DB Cluster ephemeral field.
    -- PR #492 - @monkeysecurity - issue #466 - Updating S3 Auditor to use the ARN class.
    -- PR NONE - @monkeysecurity - Fixing typo in dart files.
    -- PR #495 - @monkeysecurity - issue #494 - Refactoring to work with the new Flask-WTF.
    -- PR #493 - @monkeysecurity - Windows 10 Development instructions.
    -- PR NONE - @monkeysecurity - issue #496 - Bumping CloudAux to >=1.0.7 to fix IAM User UploadDate field JSON serialization error.
    -
    -Important Notes:
    -
    -- New permissions required:
    -    - s3:getaccelerateconfiguration
    -    - s3:getbucketcors
    -    - s3:getbucketnotification
    -    - s3:getbucketwebsite
    -    - s3:getreplicationconfiguration
    -    - s3:getanalyticsconfiguration
    -    - s3:getmetricsconfiguration
    -    - s3:getinventoryconfiguration
    -    - route53domains:getdomaindetail
    -    - cloudtrail:gettrailstatus
    -
    -Contributors:
    -
    -- @zollman
    -- @robertoriv
    -- @hyperbolist
    -- @markofu
    -- @AngeloCiffa
    -- @jnbnyc
    -- @crruthe
    -- @nagwww
    -- @mikegrima
    -- @monkeysecurity
    -
    -v0.7.0 (2016-09-21)
    -===================
    -- PR #410/#405 - @zollman - Custom Watcher/Auditor Support. (Dynamic Loading)
    -- PR #412 - @llange - Google SSO Fixes
    -- PR #409 - @kyelberry - Fixed Report URLs in UI.
    -- PR #413 - @markofu - Better handle IAM SSL certificates that we cannot parse.
    -- PR #411 - @zollman - Many, many new watchers and auditors.
    -
    -
    -New Watchers:
    -
    -    * CloudTrail
    -    * AWSConfig
    -    * AWSConfigRecorder
    -    * DirectConnect::Connection
    -    * EC2::EbsSnapshot
    -    * EC2::EbsVolume
    -    * EC2::Image
    -    * EC2::Instance
    -    * ENI
    -    * KMS::Grant
    -    * KMS::Key
    -    * Lambda
    -    * RDS::ClusterSnapshot
    -    * RDS::DBCluster
    -    * RDS::DBInstace
    -    * RDS::Snapshot
    -    * RDS::SubnetGroup
    -    * Route53
    -    * Route53Domains
    -    * TrustedAdvisor
    -    * VPC::DHCP
    -    * VPC::Endpoint
    -    * VPC::FlowLog
    -    * VPC::NatGateway
    -    * VPC::NetworkACL
    -    * VPC::Peering
    -
    -Important Notes:
    -
    -- New permissions required:
    -    - cloudtrail:describetrails
    -    - config:describeconfigrules
    -    - config:describeconfigurationrecorders
    -    - directconnect:describeconnections
    -    - ec2:describeflowlogs
    -    - ec2:describeimages
    -    - ec2:describenatgateways
    -    - ec2:describenetworkacls
    -    - ec2:describenetworkinterfaces
    -    - ec2:describesnapshots
    -    - ec2:describevolumes
    -    - ec2:describevpcendpoints
    -    - ec2:describevpcpeeringconnections,
    -    - iam:getaccesskeylastused
    -    - iam:listattachedgrouppolicies
    -    - iam:listattacheduserpolicies
    -    - lambda:listfunctions
    -    - rds:describedbclusters
    -    - rds:describedbclustersnapshots
    -    - rds:describedbinstances
    -    - rds:describedbsnapshots
    -    - rds:describedbsubnetgroups
    -    - redshift:describeclusters
    -    - route53domains:listdomains
    -
    -Contributors:
    -
    -- @zollman
    -- @kyleberry
    -- @llange
    -- @markofu
    -- @monkeysecurity
    -
    -v0.6.0 (2016-08-29)
    -===================
    -- issue #292 - PR #332 - Add ephemeral sections to the redshift watcher
    -- PR #338 - Added access key last used to IAM Users.
    -- Added an IAM User auditor check to look for access keys without use in past 90 days.
    -- PR #334 - @alexcline - Route53 watcher and auditor. (Updated to use botor in PR #343)
    -- Logo updated. Weapon replaced with banana. Expect more logo changes soon.
    -- PR #345 - Ephemeral changes now update the latest revision.  Revisions now have a date_last_ephemeral_change column as well as a date_created column.
    -- PR #349 - @mikegrima - Install documentation updates
    -- PR #354 - Feature/SSO (YAY)
    -- PR #365 - @alexcline - Added ACM (Amazon Certificate Manager) watcher/auditor
    -- PR #358/#370 - @alexcline - Alex cline feature/kms
    -- Updated Dart/Angular dart versions.
    -- PR #362 - @crruthe - Changed to dictConfig logging format
    -- PR #372 - @ollytheninja - SQS principal bugfix
    -- PR #379 - @bunjiboys - Adding Mumbai region
    -- PR #380 - @bunjiboys - Adding Mumbai ELB Log AWS Account info
    -- PR #381 - @ollytheninja - Adding tags to the S3 watcher
    -- Boto updates
    -- PR #376 - Adding item.arn field.  Adding item.latest_revision_complete_hash and item.latest_revision_durable_hash.  These are for the bananapeel rearchitecture.
    -- PR #386 - Shortening sessions from default value to 60 minutes. Setting Cookie HTTPONLY and SECURE flags.
    -- PR #389 - Adding CloudTrail table, linked to itemrevision. (To be used by bananapeel rearchitecture.)
    -- PR #390 - @ollytheninja - Adding export CSV button.
    -- PR #394 - @mikegrima - Saving exceptions to database table
    -- PR #402 - issue #401 - Adding new ELB Reference Policy ELBSecurityPolicy-2016-08
    -
    -
    -Hotfixes:
    -
    -- Upgraded Cryptography to 1.3.1
    -- Updated docs to use `sudo -E` when calling `manage.py amazon_accounts`.
    -- Updated the @record_exception decorator to allow the region to be overwritten. (Useful for region-less technology that likes to be recorded in the "universal" region.)
    -- issue #331 - IAMSSL watcher failed on elliptic curve certs
    -
    -Important Notes:
    -
    -- Route53 IgnoreList entries may match zone name or recordset name.
    -- Checkout the new log configuration format from PR #362.  You may want to update your config.py.
    -- New permissions required:
    -    - "acm:ListCertificates",
    -    - "acm:DescribeCertificate",
    -    - "kms:DescribeKey",
    -    - "kms:GetKeyPolicy",
    -    - "kms:ListKeys",
    -    - "kms:ListAliases",
    -    - "kms:ListGrants",
    -    - "kms:ListKeyPolicies",
    -    - "s3:GetBucketTagging"
    -- Some dependencies have been updated (cryptography, boto, boto3, botocore, botor, pyjwt).  Please re-run python setup.py install.
    -- Please add the following lines to your config.py for more time-limited sessions:
    -
    -.. code-block:: python
    -
    -    PERMANENT_SESSION_LIFETIME=timedelta(minutes=60)   # Will logout users after period of inactivity.
    -    SESSION_REFRESH_EACH_REQUEST=True
    -    SESSION_COOKIE_SECURE=True
    -    SESSION_COOKIE_HTTPONLY=True
    -    PREFERRED_URL_SCHEME='https'
    -
    -    REMEMBER_COOKIE_DURATION=timedelta(minutes=60)  # Can make longer if  you want remember_me to be useful
    -    REMEMBER_COOKIE_SECURE=True
    -    REMEMBER_COOKIE_HTTPONLY=True
    -
    -
    -Contributors:
    -
    -- @alexcline
    -- @crruthe
    -- @ollytheninja
    -- @bunjiboys
    -- @mikegrima
    -- @monkeysecurity
    -
    -
    -v0.5.0 (2016-04-26)
    -===================
    -- PR #286 - bunjiboys - Added Seoul region AWS Account IDs to import scripts
    -- PR #291 - sbasgall - Corrected ignore_list.py variable names and help strings
    -- PR #284 - mikegrima - Fixed cross-account root reporting for ES service (Issue #283)
    -- PR #293 - mikegrima - Updated quickstart documentation to remove permission wildcards (Issue #287)
    -- PR #301 - monkeysecurity - iamrole watcher can now handle many more roles (1000+) and no longer times out.
    -- PR #316 - DenverJ - Handle database exceptions by cleaning up session.
    -- PR #289 - delikat - Persist custom role names on account creation
    -- PR #321 - monkeysecurity - Item List and Item View will no longer display disabled issues.
    -- PR #322 (PR #308) - llange - Ability to add AWS owned managed policies to ignore list by ARN (Issue #148)
    -- PR #323 - snixon - Breaks check_securitygroup_any into ingress and egress (Issue #239)
    -- PR #309 - DenverJ -  Significant database query optimizations by tuning itemrevision retrievals
    -- PR #324 - mikegrima - Handling invalid ARNs more consistently between watchers (Issue #248)
    -- PR #317 - ollytheninja - Add Role Based Access Control
    -- PR #327 - monkeysecurity - Added Flask-Security's SECURITY_TRACKABLE to backend and UI
    -- PR #328 - monkeysecurity - Added ability to parse AWS service "ARNs" like events.amazonaws.com as well as ARNS that use * for the account number like `arn:aws:s3:​*:*​:some-s3-bucket`
    -- PR #314 - pdbogen - Update Logging to have the ability to log to stdout, useful for dockerizing.
    -
    -Hotfixes:
    -
    -- s3_acl_compare_lowercase: AWS now returns S3 ACLs with a lowercased owner.  security_monkey now does a case insensitive compare
    -- longer_resource_ids. Updating DB to handle longer AWS resource IDs: https://aws.amazon.com/blogs/aws/theyre-here-longer-ec2-resource-ids-now-available/
    -- Removed requests from requirements.txt/setup.py as it was pinned to a very old version and not directly required (Issue #312)
    -- arn_condition_awssourcearn_can_be_list. Updated security_monkey to be able to handle a list of ARNS in a policy condition.
    -- ignore_list_fails_on_empty_string: security_monkey now properly handles an ignorelist entry containing a prefix string of length 0.
    -- protocol_sslv2_deprecation: AWS stopped returning whether an ELB listener supported SSLv2.  Fixed security_monkey to handle the new format correctly.
    -
    -Important Notes:
    -
    -- security_monkey IAM roles now require a new permission: `iam:listattachedrolepolicies`
    -- Your security_monkey config file should contain a new flag: `SECURITY_TRACKABLE = True`
    -- You'll need to rerun `python setup.py install` to obtain the new dependencies.
    -
    -Contributors:
    -
    -- @bunjiboys
    -- @sbasgall
    -- @mikegrima
    -- @DenverJ
    -- @delikat
    -- @snixon
    -- @ollytheninja
    -- @pdbogen
    -- @monkeysecurity
    -
    -
    -v0.4.1 (2015-12-22)
    -===================
    -- PR #269 - mikegrima - TravisCI now ensures that dart builds.
    -- PR #270 - monkeysecurity - Refactored sts_connect to dynamically import boto resources.
    -- PR #271 - OllyTheNinja-Xero - Fixed indentation mistake in auditor.py
    -- PR #275 - AlexCline - Added elb logging to ELB watcher and auditor.
    -- PR #279 - mikegrima - Added ElasticSearch Watcher and Auditor (with tests).
    -- PR #280 - monkeysecurity - PolicyDiff better handling of changes to primitives (like ints) in dictionay values and added explicit escaping instead of relying on Angular.
    -- PR #282 - mikegrima - Documentation Fixes to configuration.rst and quickstart.rst adding es: permissions and other fixes.
    -
    -Hotfixes:
    -
    -- Added OSSMETADATA file to master/develop for internal Netflix tracking.
    -
    -Contributors:
    -
    -- @mikegrima
    -- @monkeysecurity
    -- @OllyTheNinja-Xero
    -- @AlexCline
    -
    -v0.4.0 (2015-11-20)
    -===================
    -- PR #228 - jeremy-h - IAM check misses '*' when found within a list. (Issue #223)
    -- PR #230 - markofu - New error and echo functions to simplify code for scripts/secmonkey_auto_install.sh
    -- PR #233 - mikegrima - Write tests for security_monkey.common.ARN (Issue #222)
    -- PR #238 - monkeysecurity - Refactoring _check_rfc_1918 and improving VPC ELB Internet Accessible Check
    -- PR #241 - bunjiboys - Seed Amazon owned AWS accounts (Issue #169)
    -- PR #243 - mikegrima - Fix for underscores not being detected in SNS watcher. (Issue #240)
    -- PR #244 - mikegrima - Setup TravisCI (Issue #227)
    -- PR #250 - OllyTheNinja-Xero - upgrade deprecated botocore calls in ELB watcher (Issue #249)
    -- PR #256 - mikegrima - Latest Boto3/botocore versions (Issue #254)
    -- PR #261 - bunjiboys - Add ec2:DescribeInstances to quickstart role documentation (Issue #260)
    -- PR #263 - monkeysecurity - Updating docs/scripts to pin to dart 1.12.2-1 (Issue #259)
    -- PR #265 - monkeysecurity - Remove ratelimiting max attempts, wrap ELB watcher with try/except/continue
    -
    -Hotfixes:
    -
    -- Issue #235 - OllyTheNinja-Xero - SNS Auditor - local variable 'entry' referenced before assignment
    -
    -Contributors:
    -
    -- @jeremy-h
    -- @mark-fu
    -- @mikegrima
    -- @bunjiboys
    -- @OllyTheNinja-Xero
    -- @monkeysecurity
    -
    -
    -v0.3.9 (2015-10-08)
    -===================
    -- PR #212 - bunjiboys - Make email failures warnings instead of debug messages
    -- PR #203 - markofu - Added license to secmonkey_auto_install.sh.
    -- PR #207 - cbarrac - Updated dependencies and dart installation for secmonkey_auto_install.sh
    -- PR #209 - mikegrima - Make SNS Ignorelist use name instead of ARN.
    -- PR #213 - Qmando - Added more exception handling to the S3 watcher.
    -- PR #215 - Dklotz-Circle - Added egress rules to the security group watcher.
    -- monkeysecurity - Updated quickstart.rst IAM policy to remove wildcards and include redshift permissions.
    -- PR #218 - monkeysecurity - Added exception handling to the S3 bucket.get_location API call.
    -- PR #221 - Qmando - Retry on AWS API error when slurping ELBs.
    -- monkeysecurity - Updated cryptography package from 1.0 to 1.0.2 for easier installation under OS X El Capitan.
    -
    -Hotfixes:
    -
    -- Updated quickstart.rst and secmonkey_auto_install.sh to remove swig/python-m2crypto and add libffi-dev
    -- Issue #220 - SQS Auditor not correctly parsing ARNs, halting security_monkey. Fixed by abstracting ARN parsing into a new class (security_monkey.common.arn).  Updated the SNS Auditor to also use this new class.
    -
    -Contributors:
    -
    -- bunjiboys
    -- markofu
    -- cbarrac
    -- mikegrima
    -- Qmando
    -- Dklotz-Circle
    -- monkeysecurity
    -
    -
    -v0.3.8 (2015-08-28)
    -===================
    -- PR #165 - echiu64 - S3 watcher now tracking S3 Logging Configuration.
    -- None - monkeysecurity - Certs with an invalid issuer now flagged.
    -- PR #177 - DenverJ -Added new SQS Auditor.
    -- PR #188 - kevgliss - Removed dependency on M2Crypto/Swig and replaced with Cryptography.
    -- PR #164 - Qmando - URL encoding issue with certain searches containing spaces corrected.
    -- None - monkeysecurity - Fixed issue where corrected issues were not removed.
    -- PR #198 - monkeysecurity - Adding ability to select up to four items or revisions to be compared.
    -- PR #194 #195 - bunjiboys - SECURITY_TEAM_EMAIL should accept not only a list, but also a string or tuple.
    -- PR #180 #181 #190 #191 #192 #193 - cbarrac - A number of udpates and fixes for the bash installer. (scripts/secmonkey_auto_installer.sh)
    -- PR #176 #178 - mikegrima - Updated documentation for contributors on OS X and Ubuntu to use Webstorm instead of the Dart Editor.
    -
    -
    -Contributors:
    -
    -- Qmando
    -- echiu64
    -- DenverJ
    -- cbarrac
    -- kevgliss
    -- mikegrima
    -- monkeysecurity
    -
    -
    -v0.3.7 (2015-07-20)
    -===================
    -- PR #122 - Qmando - Jira Sync.  Quentin from Yelp added Jira Integration.
    -- PR #147 - echiu64 - Added colors to audit emails and added missing justifications back into emails.
    -- PR #150 - echiu64 - Fixed a missing comma from setup.py
    -- PR #155 - echiu64 - Fixed a previous merge issue where _audit_changes() was looking for a Monitor instance instead of an list of Auditors.
    -- Issue #154 - monkeysecurity - Added support for ELB Reference Policy 2015-05.
    -- None - monkeysecurity - Added db.session.refresh(...) where appropriate in a few API views to replace some very ugly code.
    -- Issue #133 - lucab - Upgraded Flask-RESTful from v0.2.5 to v0.3.3 to fix an issue where request arguments were being persisted as the string "None" when they should have remained the javascript literal null.
    -- PR #120 - lucab - Add custom role_name field for each account to replace the previously hardcoded 'SecurityMonkey' role name.
    -- PR #120 - gene1wood - Add support for the custom role_name into manage.py.
    -- PR #161 - Asbjorn Kjaer - Increase s3_name from 32 characters to 64 characters to avoid errors or truncation where s3_name is longer.
    -- None - monkeysecurity - Set the 'defer' (lazy-load) attribute for the JSON config column on the ItemRevision table.  This speeds up the web API in a number of places.
    -
    -
    -Hotfixes:
    -
    -- Issue #149 - Python scoping issue where managed policies attached to more than one entity would cause an error.
    -- Issue #152 - SNS topics were being saved by ARN instead of by name, causing exceptions for very long names.
    -- Issue #141 - Setup cascading deletes on the Account table to prevent the error which occured when trying to delete an account with items and users attached.
    -
    -
    -Contributors:
    -
    -- Qmando
    -- echiu64
    -- lucab
    -- gene1wood
    -- Asbjorn Kjaer (akjaer)
    -- monkeysecurity
    -
    -
    -v0.3.6 (2015-04-09)
    -===================
    -- Changes to issue score in code will now cause all existing issues to be re-scored in the database.
    -- A new configuration parameter called SECURITYGROUP_INSTANCE_DETAIL can now be set to:
    -    - "FULL": Security Groups will display each instances, and all instance tags, that are associated with the security group.
    -    - "SUMMARY": Security Groups will display the number of instances attached to the security group.
    -    - "NONE": Security Groups will not retrieve any data about instances attached to a security group.
    -    - If SECURITY_GROUP_INSTANCE_DETAIL is set to "FULL" or "SUMMARY", empty security groups audit issues will have their score set to zero.
    -    - For accounts with many thousands of instances, it is advised to set this to "NONE" as the AWS API's do not respond in a timely manner with that many instances.
    -- Each watcher can be set to run at a different interval in code.  We will want to move this to be a UI setting.
    -- Watchers may specify a list of ephemeral paths.  Security_monkey will not send out change alerts for items in the ephemeral section.  This is a good place for metadata that is often changing like the number of instances attached to a security_group or the number of remaining IP addresses in a VPC subnet.
    -
    -Contributors:
    -
    -- lucab
    -- monkeysecurity
    -
    -v0.3.5 (2015-03-28)
    -===================
    -- Adding policy minimizer & expander to the revision component
    -- Adding tracking of instance profiles attached to a role
    -- Adding marker/pagination code to redshift.describe_clusters()
    -- Adding pagination to IAM User get_all_user_policies, get_all_access_keys, get_all_mfa_devices, get_all_signing_certs
    -- Typo & minor corrections on postgres commands
    -- CLI command to save your current configurations to a JSON file for backup
    -- added a VPC watcher
    -- Adding DHCP Options and Internet Gateways to the VPC Watcher
    -- Adding a subnet watcher. Fixing the VPC watcher with deep_dict
    -- Adding the vpc route_table watcher
    -- Removing subnet remaining IP field until ephemeral section is merged in
    -- Adding IAM Managed Policies
    -- Typo & minor corrections on postgres commands in documentation
    -- Adds ELBSecurityPolicy-2015-03. Moves export grade ciphers to their own section and alerts on FREAK vuln.
    -- Provides context on refpol 2015-03 vs 2015-02.
    -- Adding a Managed Policies Auditor
    -- Added Manged Policy tracking to the IAM users, groups, and roles
    -
    -
    -Summary of new watchers:
    -
    -- vpc
    -    - DHCP Options
    -    - Internet Gateways
    -- subnet
    -- routetable
    -- managed policies
    -
    -
    -Summary of new Auditors or audit checks:
    -
    -- managed policies
    -- New reference policy 2015-03 for ELB listeners.
    -- New alerts for FREAK vulnerable ciphers.
    -
    -
    -Contributors:
    -
    -- markofu
    -- monkeysecurity
    -
    -v0.3.4 (2015-2-19)
    -==================
    -- Merged in a new AuditorSettings tab created by Qmando at Yelp enabling you to disable audit checks with per-account granularity.
    -- security_monkey is now CSP compliant.
    -- security_monkey has removed all shadow-DOM components.  Also removed webcomponents.js and dart_support.js, as they were not CSP compliant.
    -- security_monkey now advises users to enable standard security headers following headers:
    -
    -.. code-block:: python
    -
    -    X-Content-Type-Options "nosniff";
    -    X-XSS-Protection "1; mode=block";
    -    X-Frame-Options "SAMEORIGIN";
    -    Strict-Transport-Security "max-age=631138519";
    -    Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;"
    -
    -
    -- security_monkey now has XSRF protection against all DELETE, POST, PUT, and PATCH calls.
    -- Updated the ELB Auditor to be aware of the ELBSecurityPolicy-2015-02 reference policy.
    -
    -
    -Contributers:
    -
    -- Qmando
    -- monkeysecurity
    -
    -
    -v0.3.3 (2015-2-3)
    -=================
    -- Added MirorsUsed() to my dart code to reduce compiled javascript size.
    -- Added support for non-chrome browsers by importing webcomponents.js and dart_support.js
    -- Upgraded to Angulardart 1.1.0 and Angular-dart.ui 0.6.3
    -
    -v0.3.2 (2015-1-20)
    -==================
    -- A bug has been corrected where IAM Groups with > 100 members or policies would be truncated.
    -- The web UI has been updated to use AngularDart 1.0.0.  Significantly smaller javascript size.
    -
    -v0.3.1 (2015-1-11)
    -==================
    -- Change emails again show issues and justifications.
    -- Change emails now use jinja templating.
    -- Fixed an issue where issue justifications would disappear when the item was changed.
    -- Merged a pull request from github user jijojv to start the scheduler at launch instead of waiting 15 minutes.
    -
    -v0.3.0 (2014-12-19)
    -===================
    -- Add localhost to CORS for development.
    -- Big refactor adding monitors.  Adding new watchers/auditors is now much simpler.
    -- Return to the current URL after authenticating.
    -- Added SES_REGION config.  Now you can send email out of regions other than us-east-1.
    -- Changing default log location to /var/log/security_monkey.
    -- Docs now have cleaner nginx.conf.
    -- Add M2Crypto to get a number of new iamssl fields.
    -- Added favicon.
    -
    -new watchers:
    -
    -- eip
    -- redshift
    -- ses
    -
    -enhanced watchers:
    -
    -- iamssl - new fields from m2crypto
    -- elb - new listener policies from botocore
    -- sns - added sns subscriptions
    -- s3 - now tracks lifecycle rules
    -
    -new auditors:
    -
    -- redshift - checks for non-vpc deployment.
    -- ses - checks for verified identities
    -
    -enhanced auditors:
    -
    -- iamssl - cert size, signature hashing algorithm, upcoming expiration, heartbleed
    -- elb - check reference policy and certain custom policy fields
    -
    -hotfixes:
    -
    -- Fixed issue #12 - Deleting account results in foreign key constraint.
    -- Added missing alembic script for the ignorelist.
    -- Various minor documentation updates.
    -- API server now respects --bind parameter. (Required for the docker image).
    -- SES connection in utils.py is now surrounded in a try/except.
    -- FlaskSecurity upgraded to latest.
    -
    -Contributers:
    -
    -- ivanlei
    -- lucab
    -- yograterol
    -- monkeysecurity
    -
    -v0.2.0 (2014-10-31)
    -===================
    -
    -Changes in the Web UI:
    -
    -- Dart: Dates are now displayed in your local timezone.
    -- Dart: Added Item-level comments.
    -- Dart: Added the ability to bulk-justify issues from the Issues Table view. This uses the AngularDartUI Modal Component.
    -- Dart: Added better messaging around the settings for adding an account.  This closes issue #38. This uses the AngularDartUI tooltip component.
    -- Bug Fix: Colors in the Item table now correctly represent the justification status.
    -- Dart: Added AngularUI Tabs to select between diff and current configuration display.
    -- Dart: Added a timer-based auto-refresh so SM can be used as a dashboard.
    -- Dart: Replaced a number of custom http services with Victor Savkin's Hammock library.
    -  - More than 965 lines of code removed after using Hammock.
    -- Dart: Replaced custom pagination code with AngularDartUI's Pagination Component.
    -  - IssueTable
    -  - RevisionTable
    -  - ItemTable
    -  - AccountSettingsTable
    -- Dart: Network CIDR whitelist is now configured in the web UI under settings.
    -- Dart: Object Ignorelist is now configured in the web UI under settings.
    -- Created a new PaginatedTable parent class for all components that wish to display paginated data.  This table works with AngularDart's Pagination Component and also provides the ability to change the number of items displayed on each page.
    -- Dart: Added ng_infinite_scroll to the item_detail_view for loading revisions
    -- Dart: Moved a number of components from being their own libraries to being ```part of``` the security_monkey library.
    -- Dart: Replaced the last controller (UsernameController) with a Component to prepare for AngularDart 1.0.0
    -- Dart: Style - Renamed library from SecurityMonkey to security_monkey to follow the dart style guide.  Refactored much of main.dart into lib/security_monkey.dart to try and mimic the cleaner design of the new angular sample app: https://github.com/vsavkin/angulardart-sample-app
    -
    -Changes in the core product:
    -
    -- Updated API endpoints to better follow REST architecture.
    -- Added table for NetworkWhitelist.
    -- Added rest API endpoints for NetworkWhitelist.
    -- Added Alembic migration script to add the new NetworkWhitelist table to the DB.
    -- Added table for IgnoreList.
    -- Added rest API endpoints for Ignorelist.
    -- Added Alembic migration script to add the new IgnoreList table to the DB.
    -- Added check for rfc-1918 CIDRs in non-VPC security groups.
    -- Saving IAMSSL Certs by cert name instead of cert ID
    -- Marking VPC RDS Security Groups with their VPC ID
    -- Supports Paginated Boto access for RDS Security Groups.
    -- Added alert for non-VPC RDS SG's containing RFC-1918 CIDRs
    -- Added check for IAM USER AKEY rotation
    -- Added check for IAM USER with login profile (console access) And Access Keys (API Access)
    -- Added an ELB Auditor with a check for internet-facing ELB.
    -- Added check for security groups with large port ranges.
    -
    -v0.1.2 (2014-08-11)
    -===================
    -
    -Changes in the Web UI:
    -
    -- Dart: Removed Shadow DOM dependency and set version bounds in pubspec.yaml.
    -- Dart: Replaced package:js with dart:js.
    -- Dart: Added the Angular Pub Transformer.
    -
    -Changes in the core product:
    -
    -- Added AWS Rate Limiting Protection with exponential backoff code.
    -- Added instructions to get a local development environment setup for contributing to security_monkey.
    -- Added support for boto's new ELB pagination.  The pull request to boto and to security_monkey came from Kevin Glisson.
    -- Bug fix: Security Group Audit Issues now include the port the issue was reported on.
    -
    -
    -These were already in master, but weren't tied to a new release:
    -
    -- Bug fix: Supervisor script now sets SECURITY_MONKEY_SETTINGS envvar for the API server whereas it only previously set the envvar for the scheduler. This came from a pull request from parabolic.
    -- Bug fix: Audit reports will only be sent if there are issues to report on.
    -- Bug fix: Daily Audit Email setting (ALL/NONE/ISSUES) is now respected.
    -- Bug fix: Command Line Auditor Command Arguments are now coerced into being booleans.
    -- Quickstart Guide now instructs user to setup the web UI on SSL.
    -- Various Smaller Bug Fixes.
    -
    -v0.1.1 (2014-06-30)
    -=====================
    -
    -Initial release of Security Monkey!
    diff --git a/docs/conf.py b/docs/conf.py
    deleted file mode 100644
    index 1b56d9ad4..000000000
    --- a/docs/conf.py
    +++ /dev/null
    @@ -1,261 +0,0 @@
    -# -*- coding: utf-8 -*-
    -#
    -# security_monkey documentation build configuration file, created by
    -# sphinx-quickstart on Sat Jun  7 18:43:48 2014.
    -#
    -# This file is execfile()d with the current directory set to its
    -# containing dir.
    -#
    -# Note that not all possible configuration values are present in this
    -# autogenerated file.
    -#
    -# All configuration values have a default; values that are commented out
    -# serve to show the default.
    -
    -import sys
    -import os
    -
    -# If extensions (or modules to document with autodoc) are in another directory,
    -# add these directories to sys.path here. If the directory is relative to the
    -# documentation root, use os.path.abspath to make it absolute, like shown here.
    -sys.path.insert(0, os.path.abspath('..'))
    -
    -# -- General configuration ------------------------------------------------
    -
    -# If your documentation needs a minimal Sphinx version, state it here.
    -#needs_sphinx = '1.0'
    -
    -# Add any Sphinx extension module names here, as strings. They can be
    -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
    -# ones.
    -extensions = [
    -    'sphinx.ext.autodoc',
    -    'sphinx.ext.todo',
    -]
    -
    -# Add any paths that contain templates here, relative to this directory.
    -templates_path = ['_templates']
    -
    -# The suffix of source filenames.
    -source_suffix = '.rst'
    -
    -# The encoding of source files.
    -#source_encoding = 'utf-8-sig'
    -
    -# The master toctree document.
    -master_doc = 'index'
    -
    -# General information about the project.
    -project = u'security_monkey'
    -copyright = u'2014, Patrick Kelley'
    -
    -# The version info for the project you're documenting, acts as replacement for
    -# |version| and |release|, also used in various other places throughout the
    -# built documents.
    -#
    -# The short X.Y version.
    -version = '0.6'
    -# The full version, including alpha/beta/rc tags.
    -release = '0.6.0'
    -
    -# The language for content autogenerated by Sphinx. Refer to documentation
    -# for a list of supported languages.
    -#language = None
    -
    -# There are two options for replacing |today|: either, you set today to some
    -# non-false value, then it is used:
    -#today = ''
    -# Else, today_fmt is used as the format for a strftime call.
    -#today_fmt = '%B %d, %Y'
    -
    -# List of patterns, relative to source directory, that match files and
    -# directories to ignore when looking for source files.
    -exclude_patterns = ['_build']
    -
    -# The reST default role (used for this markup: `text`) to use for all
    -# documents.
    -#default_role = None
    -
    -# If true, '()' will be appended to :func: etc. cross-reference text.
    -#add_function_parentheses = True
    -
    -# If true, the current module name will be prepended to all description
    -# unit titles (such as .. function::).
    -#add_module_names = True
    -
    -# If true, sectionauthor and moduleauthor directives will be shown in the
    -# output. They are ignored by default.
    -#show_authors = False
    -
    -# The name of the Pygments (syntax highlighting) style to use.
    -pygments_style = 'sphinx'
    -
    -# A list of ignored prefixes for module index sorting.
    -#modindex_common_prefix = []
    -
    -# If true, keep warnings as "system message" paragraphs in the built documents.
    -#keep_warnings = False
    -
    -
    -# -- Options for HTML output ----------------------------------------------
    -
    -# The theme to use for HTML and HTML Help pages.  See the documentation for
    -# a list of builtin themes.
    -html_theme = 'default'
    -
    -# Theme options are theme-specific and customize the look and feel of a theme
    -# further.  For a list of options available for each theme, see the
    -# documentation.
    -#html_theme_options = {}
    -
    -# Add any paths that contain custom themes here, relative to this directory.
    -#html_theme_path = []
    -
    -# The name for this set of Sphinx documents.  If None, it defaults to
    -# " v documentation".
    -#html_title = None
    -
    -# A shorter title for the navigation bar.  Default is the same as html_title.
    -#html_short_title = None
    -
    -# The name of an image file (relative to this directory) to place at the top
    -# of the sidebar.
    -#html_logo = None
    -
    -# The name of an image file (within the static path) to use as favicon of the
    -# docs.  This file should be a Windows icon file (.ico) being 16x16 or 32x32
    -# pixels large.
    -#html_favicon = None
    -
    -# Add any paths that contain custom static files (such as style sheets) here,
    -# relative to this directory. They are copied after the builtin static files,
    -# so a file named "default.css" will overwrite the builtin "default.css".
    -html_static_path = ['_static']
    -
    -# Add any extra paths that contain custom files (such as robots.txt or
    -# .htaccess) here, relative to this directory. These files are copied
    -# directly to the root of the documentation.
    -#html_extra_path = []
    -
    -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
    -# using the given strftime format.
    -#html_last_updated_fmt = '%b %d, %Y'
    -
    -# If true, SmartyPants will be used to convert quotes and dashes to
    -# typographically correct entities.
    -#html_use_smartypants = True
    -
    -# Custom sidebar templates, maps document names to template names.
    -#html_sidebars = {}
    -
    -# Additional templates that should be rendered to pages, maps page names to
    -# template names.
    -#html_additional_pages = {}
    -
    -# If false, no module index is generated.
    -#html_domain_indices = True
    -
    -# If false, no index is generated.
    -#html_use_index = True
    -
    -# If true, the index is split into individual pages for each letter.
    -#html_split_index = False
    -
    -# If true, links to the reST sources are added to the pages.
    -#html_show_sourcelink = True
    -
    -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
    -#html_show_sphinx = True
    -
    -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
    -#html_show_copyright = True
    -
    -# If true, an OpenSearch description file will be output, and all pages will
    -# contain a  tag referring to it.  The value of this option must be the
    -# base URL from which the finished HTML is served.
    -#html_use_opensearch = ''
    -
    -# This is the file name suffix for HTML files (e.g. ".xhtml").
    -#html_file_suffix = None
    -
    -# Output file base name for HTML help builder.
    -htmlhelp_basename = 'security_monkeydoc'
    -
    -
    -# -- Options for LaTeX output ---------------------------------------------
    -
    -latex_elements = {
    -# The paper size ('letterpaper' or 'a4paper').
    -#'papersize': 'letterpaper',
    -
    -# The font size ('10pt', '11pt' or '12pt').
    -#'pointsize': '10pt',
    -
    -# Additional stuff for the LaTeX preamble.
    -#'preamble': '',
    -}
    -
    -# Grouping the document tree into LaTeX files. List of tuples
    -# (source start file, target name, title,
    -#  author, documentclass [howto, manual, or own class]).
    -latex_documents = [
    -  ('index', 'security_monkey.tex', u'security\\_monkey Documentation',
    -   u'Patrick Kelley', 'manual'),
    -]
    -
    -# The name of an image file (relative to this directory) to place at the top of
    -# the title page.
    -#latex_logo = None
    -
    -# For "manual" documents, if this is true, then toplevel headings are parts,
    -# not chapters.
    -#latex_use_parts = False
    -
    -# If true, show page references after internal links.
    -#latex_show_pagerefs = False
    -
    -# If true, show URL addresses after external links.
    -#latex_show_urls = False
    -
    -# Documents to append as an appendix to all manuals.
    -#latex_appendices = []
    -
    -# If false, no module index is generated.
    -#latex_domain_indices = True
    -
    -
    -# -- Options for manual page output ---------------------------------------
    -
    -# One entry per manual page. List of tuples
    -# (source start file, name, description, authors, manual section).
    -man_pages = [
    -    ('index', 'security_monkey', u'security_monkey Documentation',
    -     [u'Patrick Kelley'], 1)
    -]
    -
    -# If true, show URL addresses after external links.
    -#man_show_urls = False
    -
    -
    -# -- Options for Texinfo output -------------------------------------------
    -
    -# Grouping the document tree into Texinfo files. List of tuples
    -# (source start file, target name, title, author,
    -#  dir menu entry, description, category)
    -texinfo_documents = [
    -  ('index', 'security_monkey', u'security_monkey Documentation',
    -   u'Patrick Kelley', 'security_monkey', 'One line description of project.',
    -   'Miscellaneous'),
    -]
    -
    -# Documents to append as an appendix to all manuals.
    -#texinfo_appendices = []
    -
    -# If false, no module index is generated.
    -#texinfo_domain_indices = True
    -
    -# How to display URL addresses: 'footnote', 'no', or 'inline'.
    -#texinfo_show_urls = 'footnote'
    -
    -# If true, do not generate a @detailmenu in the "Top" node's menu.
    -#texinfo_no_detailmenu = False
    diff --git a/docs/configuration.rst b/docs/configuration.rst
    deleted file mode 100644
    index 2f95e380e..000000000
    --- a/docs/configuration.rst
    +++ /dev/null
    @@ -1,256 +0,0 @@
    -=============
    -Configuration
    -=============
    -
    -AWS Configuration
    -=================
    -
    -In order for Security Monkey to monitor it's own account and other accounts
    -we must ensure it has the correct AWS permissions to do so.
    -
    -There are several different ways you could do this, below detail the recommended.
    -
    -Setting up IAM roles
    ---------------------
    -
    -Security Monkey uses boto heavily to talk to all the AWS resources it monitors. By default it uses the on-instance credentials to make the necessary calls.
    -
    -In order to limit the permissions (Security Monkey is a read-only) we will create a new two IAM roles for Security Monkey. You can name them whatever you would like but for example sake we will be calling them SecurityMonkeyInstanceProfile and SecurityMonkey.
    -
    -Security Monkey uses to STS to talk to different accounts. For monitoring one account this isn't necessary but we will still use it so that we can easily add new accounts.
    -
    -SecurityMonkeyInstanceProfile is the IAM role you will launch your instance with. It actually has almost no rights. In fact it should really only be able to use STS to assume role to the SecurityMonkey role.
    -
    -Here is are example polices for the SecurityMonkeyInstanceProfile:
    -
    -SES-SendEmail
    -
    -.. code-block:: python
    -
    -    {
    -      "Version": "2012-10-17",
    -      "Statement": [
    -        {
    -          "Effect": "Allow",
    -          "Action": [
    -            "ses:SendEmail"
    -          ],
    -          "Resource": "*"
    -        }
    -      ]
    -    }
    -
    -
    -STS-AssumeRole
    -
    -.. code-block:: python
    -
    -    {
    -      "Version": "2012-10-17",
    -      "Statement": [
    -        {
    -          "Effect": "Allow",
    -          "Action": "sts:AssumeRole",
    -          "Resource": "arn:aws:iam::*:role/SecurityMonkey"
    -        }
    -      ]
    -    }
    -
    -
    -
    -Next we will create the the SecurityMonkey IAM role. This is the role that actually has access (read-only) to the different technology resources.
    -
    -Here is an example policy for SecurityMonkey:
    -
    -SM-ReadOnly
    -
    -.. code-block:: json
    -
    -    {
    -        "Version": "2012-10-17",
    -        "Statement": [
    -            {
    -                "Action": [
    -                    "acm:describecertificate",
    -                    "acm:listcertificates",
    -                    "cloudtrail:describetrails",
    -                    "cloudtrail:gettrailstatus",
    -                    "config:describeconfigrules",
    -                    "config:describeconfigurationrecorders",
    -                    "directconnect:describeconnections",
    -                    "ec2:describeaddresses",
    -                    "ec2:describedhcpoptions",
    -                    "ec2:describeflowlogs",
    -                    "ec2:describeimages",
    -                    "ec2:describeinstances",
    -                    "ec2:describeinternetgateways",
    -                    "ec2:describekeypairs",
    -                    "ec2:describenatgateways",
    -                    "ec2:describenetworkacls",
    -                    "ec2:describenetworkinterfaces",
    -                    "ec2:describeregions",
    -                    "ec2:describeroutetables",
    -                    "ec2:describesecuritygroups",
    -                    "ec2:describesnapshots",
    -                    "ec2:describesubnets",
    -                    "ec2:describetags",
    -                    "ec2:describevolumes",
    -                    "ec2:describevpcendpoints",
    -                    "ec2:describevpcpeeringconnections",
    -                    "ec2:describevpcs",
    -                    "ec2:describevpngateways",
    -                    "elasticloadbalancing:describeloadbalancerattributes",
    -                    "elasticloadbalancing:describeloadbalancerpolicies",
    -                    "elasticloadbalancing:describeloadbalancers",
    -                    "es:describeelasticsearchdomainconfig",
    -                    "es:listdomainnames",
    -                    "iam:getaccesskeylastused",
    -                    "iam:getgroup",
    -                    "iam:getgrouppolicy",
    -                    "iam:getloginprofile",
    -                    "iam:getpolicyversion",
    -                    "iam:getrole",
    -                    "iam:getrolepolicy",
    -                    "iam:getservercertificate",
    -                    "iam:getuser",
    -                    "iam:getuserpolicy",
    -                    "iam:listaccesskeys",
    -                    "iam:listattachedgrouppolicies",
    -                    "iam:listattachedrolepolicies",
    -                    "iam:listattacheduserpolicies",
    -                    "iam:listentitiesforpolicy",
    -                    "iam:listgrouppolicies",
    -                    "iam:listgroups",
    -                    "iam:listinstanceprofilesforrole",
    -                    "iam:listmfadevices",
    -                    "iam:listpolicies",
    -                    "iam:listrolepolicies",
    -                    "iam:listroles",
    -                    "iam:listservercertificates",
    -                    "iam:listsigningcertificates",
    -                    "iam:listuserpolicies",
    -                    "iam:listusers",
    -                    "kms:describekey",
    -                    "kms:getkeypolicy",
    -                    "kms:listaliases",
    -                    "kms:listgrants",
    -                    "kms:listkeypolicies",
    -                    "kms:listkeys",
    -                    "lambda:listfunctions",
    -                    "rds:describedbclusters",
    -                    "rds:describedbclustersnapshots",
    -                    "rds:describedbinstances",
    -                    "rds:describedbsecuritygroups",
    -                    "rds:describedbsnapshots",
    -                    "rds:describedbsubnetgroups",
    -                    "redshift:describeclusters",
    -                    "route53:listhostedzones",
    -                    "route53:listresourcerecordsets",
    -                    "route53domains:listdomains",
    -                    "route53domains:getdomaindetail",
    -                    "s3:getaccelerateconfiguration",
    -                    "s3:getbucketacl",
    -                    "s3:getbucketcors",
    -                    "s3:getbucketlocation",
    -                    "s3:getbucketlogging",
    -                    "s3:getbucketnotification",
    -                    "s3:getbucketpolicy",
    -                    "s3:getbuckettagging",
    -                    "s3:getbucketversioning",
    -                    "s3:getbucketwebsite",
    -                    "s3:getlifecycleconfiguration",
    -                    "s3:listbucket",
    -                    "s3:listallmybuckets",
    -                    "s3:getreplicationconfiguration",
    -                    "s3:getanalyticsconfiguration",
    -                    "s3:getmetricsconfiguration",
    -                    "s3:getinventoryconfiguration",
    -                    "ses:getidentityverificationattributes",
    -                    "ses:listidentities",
    -                    "ses:listverifiedemailaddresses",
    -                    "ses:sendemail",
    -                    "sns:gettopicattributes",
    -                    "sns:listsubscriptionsbytopic",
    -                    "sns:listtopics",
    -                    "sqs:getqueueattributes",
    -                    "sqs:listqueues"
    -                ],
    -                "Effect": "Allow",
    -                "Resource": "*"
    -            }
    -        ]
    -    }
    -
    -
    -
    -Setting up STS access
    ----------------------
    -Once we have setup our accounts we need to ensure that we create a trust relationship so that SecurityMonkeyInstanceProfile can assume the SecurityMonkey role.
    -
    -In the AWS console select the SecurityMonkey IAM role and select the Trust Relationships tab and click Edit Trust Relationship
    -
    -Below is an example policy:
    -
    -.. code-block:: python
    -
    -    {
    -      "Version": "2012-10-17",
    -      "Statement": [
    -        {
    -          "Sid": "",
    -          "Effect": "Allow",
    -          "Principal": {
    -            "AWS": [
    -              "arn:aws:iam::*:role/SecurityMonkeyInstanceProfile",
    -            ]
    -          },
    -          "Action": "sts:AssumeRole"
    -        }
    -      ]
    -    }
    -
    -
    -
    -Security Monkey Configuration
    -=============================
    -
    -Most of Security Monkey's configuration is done via the Security Monkey Configuration file see: :doc:`configuration options <./options>` for a full list of options.
    -
    -The default config includes a few values that you will need to change before starting Security Monkey the first time. see: security_monkey/env-config/config-deploy.py
    -
    -FQDN
    -----
    -
    -To perform redirection security monkey needs to know the FQDN you intend to use. IF R53 is enabled this FQDN will be
    -automatically added to Route53 when Security Monkey starts, assuming the SecurityMonkeyInstanceProfile has permission to do so.
    -
    -
    -SQLACHEMY_DATABASE_URI
    -----------------------
    -
    -If you have ever used sqlalchemy before this is the standard connection string used. Security Monkey uses a postgres database and the connection string would look something like:
    -
    -    SQLALCHEMY_DATABASE_URI = 'postgressql://:@:5432/SecurityMonkey'
    -
    -SECRET_KEY
    -----------
    -
    -This SECRET_KEY is essential to ensure the sessions generated by Flask cannot be guessed. You must generate a RANDOM SECRET_KEY for this value.
    -
    -An example of how you might generate a random string:
    -
    -    >>> import random
    -    >>> secret_key = ''.join(random.choice(string.ascii_uppercase) for x in range(6))
    -    >>> secret_key = secret_key + ''.join(random.choice("~!@#$%^&*()_+") for x in range(6))
    -    >>> secret_key = secret_key + ''.join(random.choice(string.ascii_lowercase) for x in range(6))
    -    >>> secret_key = secret_key + ''.join(random.choice(string.digits) for x in range(6))
    -
    -
    -SECURITY_PASSWORD_SALT
    -----------------------
    -
    -For many of the same reasons we want want a random SECRET_KEY we want to ensure our password salt is random. see: `Salt `_
    -
    -You can use the same method used to generate the SECRET_KEY to generate the SECURITY_PASSWORD_SALT
    -
    -
    diff --git a/docs/contributing.md b/docs/contributing.md
    new file mode 100644
    index 000000000..ed42aaa9c
    --- /dev/null
    +++ b/docs/contributing.md
    @@ -0,0 +1,39 @@
    +Contributing
    +============
    +
    +Contributions to Security Monkey are welcome! Here are some tips to get you started hacking on Security Monkey and contributing back your patches.
    +
    +Development Setup OS X
    +----------------------
    +
    +Please review the [Mac OS X Development Setup Instructions](dev_setup_osx.md) to set up your Mac for Security Monkey development.
    +
    +Development Setup Ubuntu
    +------------------------
    +
    +Please review the [Ubuntu Development Setup Instructions](dev_setup_ubuntu.md) to set up your Ubuntu installation for Security Monkey Development.
    +
    +Development Setup Windows
    +-------------------------
    +
    +Please review the [Windows Development Setup Instructions](dev_setup_windows.md) to set up Windows for Security Monkey development.
    +
    +Submitting changes
    +------------------
    +
    +-   Code should be accompanied by tests and documentation. Maintain our excellent test coverage.
    +-   Follow the existing code style, especially make sure `flake8` does not complain about anything.
    +-   Write good commit messages. Here's three blog posts on how to do it right:
    +    -   [Writing Git commit messages](http://365git.tumblr.com/post/3308646748/writing-git-commit-messages)
    +    -   [A Note About Git Commit Messages](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html)
    +    -   [On commit messages](http://who-t.blogspot.ch/2009/12/on-commit-messages.html)
    +-   One branch per feature or fix. Keep branches small and on topic.
    +-   Send a pull request to the `v1/develop` branch. See the [GitHub pull request docs](https://help.github.com/articles/using-pull-requests) for help.
    +
    +Additional resources
    +--------------------
    +
    +-   [Issue tracker](https://github.com/netflix/security_monkey/issues)
    +-   [GitHub documentation](https://help.github.com/)
    +-   [Development Guidelines](development.md)
    +
    diff --git a/docs/contributing.rst b/docs/contributing.rst
    deleted file mode 100644
    index 367bbbe15..000000000
    --- a/docs/contributing.rst
    +++ /dev/null
    @@ -1,59 +0,0 @@
    -************
    -Contributing
    -************
    -
    -Contributions to Security Monkey are welcome! Here are some tips to get you started
    -hacking on Security Monkey and contributing back your patches.
    -
    -
    -Development Setup OS X
    -======================
    -
    -Please review the `Mac OS X Development Setup Instructions `_ to set up your Mac for Security Monkey development.
    -
    -
    -Development Setup Ubuntu
    -========================
    -
    -Please review the `Ubuntu Development Setup Instructions `_ to set up your Ubuntu installation for Security Monkey Development.
    -
    -Development Setup Windows
    -========================
    -
    -Please review the `Windows Development Setup Instructions `_ to set up Windows for Security Monkey development.
    -
    -Submitting changes
    -==================
    -
    -- Code should be accompanied by tests and documentation. Maintain our excellent
    -  test coverage.
    -
    -- Follow the existing code style, especially make sure ``flake8`` does not
    -  complain about anything.
    -
    -- Write good commit messages. Here's three blog posts on how to do it right:
    -
    -  - `Writing Git commit messages
    -    `_
    -
    -  - `A Note About Git Commit Messages
    -    `_
    -
    -  - `On commit messages
    -    `_
    -
    -- One branch per feature or fix. Keep branches small and on topic.
    -
    -- Send a pull request to the ``v1/develop`` branch. See the `GitHub pull
    -  request docs `_ for
    -  help.
    -
    -
    -Additional resources
    -====================
    -
    -- `Issue tracker `_
    -
    -- `GitHub documentation `_
    -
    -- `Development Guidelines `_
    diff --git a/docs/dev_setup_osx.md b/docs/dev_setup_osx.md
    new file mode 100644
    index 000000000..4779a4754
    --- /dev/null
    +++ b/docs/dev_setup_osx.md
    @@ -0,0 +1,314 @@
    +***********\* Development Setup on Mac OS X***********\*
    +
    +Please follow the instructions below for setting up the Security Monkey development environment on Mac OS X.
    +
    +AWS Credentials
    +===============
    +
    +You will need to have the proper IAM Role configuration in place. See [IAM Role Setup on AWS](iam_aws.md) for more details. Additionally, you will need to have IAM keys available within your environment variables. There are many ways to accomplish this. Please see Amazon's documentation for additional details: .
    +
    +Additionally, see the boto documentation for more information: 
    +
    +Install Xcode
    +=============
    +
    +Xcode contains a number of tools that are required to install Security Monkey dependencies. This needs to be installed from the App Store (free download): 
    +
    +After Xcode is installed, you need to accept the Xcode license agreement. To do that, run:
    +
    +    sudo xcodebuild -license   # You will need to type in 'agree'
    +
    +Install Homebrew ()
    +===================================
    +
    +Requirement - Xcode Command Line Tools (Popup - Just click Install):
    +
    +    ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
    +
    +Install Python
    +==============
    +
    +Install the latest version of Python 2.7 with Homebrew:
    +
    +    brew install python
    +
    +Upgrade Pip
    +===========
    +
    +A tool for installing and managing Python packages. You may need to update Pip, so run:
    +
    +    sudo pip install --upgrade pip
    +
    +Setup Virtualenv
    +================
    +
    +Virtualenv is a tool to create isolated Python environments. You will need to install it:
    +
    +    sudo pip install virtualenv
    +
    +VirtualenvWrapper  
    +virtualenvwrapper is a set of extensions to Ian Bicking’s virtualenv tool. The extensions include wrappers for creating and deleting virtual environments and otherwise managing your development workflow, making it easier to work on more than one project at a time without introducing conflicts in their dependencies. :
    +
    +    sudo pip install virtualenvwrapper
    +
    +Configure VirtualEnvWrapper  
    +Configure VirtualEnvWrapper so it knows where to store the virtualenvs and where the virtualenvwerapper script is located. :
    +
    +    cd ~
    +    mkdir virtual_envs
    +    vi ~/.bash_profile
    +
    +Add these two lines to your \~/.bash\_profile:
    +
    +    export WORKON_HOME="$HOME/virtual_envs/"
    +    source "/usr/local/bin/virtualenvwrapper.sh"
    +
    +You'll need to open a new terminal (or run `source ~/.bash_profile`) before you can create the virtualenv:
    +
    +    mkvirtualenv security_monkey
    +    workon security_monkey
    +
    +Clone Security Monkey
    +=====================
    +
    +Clone the security monkey code repository. :
    +
    +    git clone https://github.com/Netflix/security_monkey.git
    +    cd security_monkey
    +
    +SECURITY_MONKEY_SETTINGS  
    +
    +You can set the SECURITY_MONKEY_SETTINGS environment variable if you would like security_monkey to use a config file other than `env-config/config.py`.  It may be a good idea to create a `config-local.py` and use that instead.
    +
    +    export SECURITY_MONKEY_SETTINGS=`pwd`/env-config/config-local.py
    +
    +Note - I like to append this to the virtualenv activate script:
    +
    +    vi $HOME/virtual_envs/security_monkey/bin/activate
    +    export SECURITY_MONKEY_SETTINGS=$HOME/security_monkey/env-config/config-local.py
    +
    +Install PostgreSQL
    +==================
    +
    +Install Postgres. Create a database for security monkey and add a role. Set the timezone to GMT. :
    +
    +    brew install postgresql
    +
    +Open a new shell, then start the DB:
    +
    +    postgres -D /usr/local/var/postgres
    +
    +Go back to your previous shell, then create the database and users and set the timezone. :
    +
    +    psql -d postgres -h localhost
    +    CREATE DATABASE "securitymonkeydb";
    +    CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypass';
    +    CREATE SCHEMA securitymonkeydb
    +    GRANT Usage, Create ON SCHEMA "securitymonkeydb" TO "securitymonkeyuser";
    +    set timezone to 'GMT';
    +    select now();
    +
    +Exit the Postgres CLI tool:
    +
    +    CTRL-D
    +
    +Install Pip Requirements
    +========================
    +
    +Pip will install all the dependencies into the current virtualenv. :
    +
    +    # Note for El Capitan users and above: Apple has removed OpenSSL from OS X, which is a dependency
    +    # of the cryptography library. OpenSSL gets installed with Postgres above. However, there are compiler
    +    # path errors that result when trying to install the cryptography Python dependency.
    +    # To resolve this, you need to run:
    +    env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/include" python setup.py develop
    +    # The above fully installs all the Python dependencies.
    +
    +    # For OS X versions prior to El Capitan, run:
    +    python setup.py develop
    +
    +Init the Security Monkey DB
    +===========================
    +
    +Run Alembic/FlaskMigrate to create all the database tables. :
    +
    +    python manage.py db upgrade
    +
    +Install and configure NGINX
    +===========================
    +
    +NGINX will be used to serve static content for Security Monkey. Use `brew` to install. :
    +
    +    brew install nginx  
    +
    +There will be some output about how to start NGINX, and where it's configuration resides. Choose the approach that works best for you. (We personally advise against starting things automatically on boot for your development box)
    +
    +The NGINX configuration will be located at: `/usr/local/etc/nginx/`. You will need to make a modification to the nginx.conf file. The configuration changes include the following:
    +
    +-   Disabling port 8080 for the main nginx.conf file
    +-   Importing the Security Monkey specific configuration
    +
    +Open the main NGINX configuration file: `/usr/local/etc/nginx/nginx.conf`, and in the `http` section, add the line :
    +
    +    include securitymonkey.conf;
    +
    +Next, comment out the `listen` line (under the `server` section) :
    +
    +    server {
    +      listen       8080;   # Comment out this line by placing a '#' in front of 'listen'
    +
    +Next, you will create the `securitymonkey.conf` NGINX configuration file. Create this file under `/usr/local/etc/nginx/`, and paste in the following (MAKE NOTE OF SPECIFIC SECTIONS) :
    +
    +    add_header X-Content-Type-Options "nosniff";
    +    add_header X-XSS-Protection "1; mode=block";
    +    add_header X-Frame-Options "SAMEORIGIN";
    +    add_header Strict-Transport-Security "max-age=631138519";
    +    add_header Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src     'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;";
    +
    +    server {
    +     listen      0.0.0.0:8080;
    +
    +     # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    +     access_log          /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.access.log;
    +     error_log           /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.error.log;
    +
    +     location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) {
    +          proxy_read_timeout 120;
    +          proxy_pass  http://127.0.0.1:5000;
    +          proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
    +          proxy_redirect off;
    +          proxy_buffering off;
    +          proxy_set_header        Host            $http_host;
    +          proxy_set_header        X-Real-IP       $remote_addr;
    +          proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    +      }
    +
    +      location /static {
    +          rewrite ^/static/(.*)$ /$1 break;
    +          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    +          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    +          index ui.html;
    +      }
    +
    +      location / {
    +          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    +          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    +          index ui.html;
    +      }
    +    }
    +
    +Create the `devlog/security_monkey.access.log` file. :
    +
    +    mkdir devlog
    +    touch devlog/security_monkey.access.log
    +
    +NGINX can be started by running the `nginx` command in the Terminal. You will need to run `nginx` before moving on. This will also output any errors that are encountered when reading the configuration files.
    +
    +Launch and Configure the WebStorm Editor
    +========================================
    +
    +We prefer the WebStorm IDE for developing with Dart: . Webstorm requires the JDK to be installed. If you don't already have Java and the JDK installed, please download it here: .
    +
    +In addition to WebStorm, you will also need to have the Dart SDK installed. Please download and install the Dart suite (SDK and Dartium) via Homebrew:
    +
    +    $ brew tap dart-lang/dart
    +    $ brew install dart --with-content-shell --with-dartium
    +
    +**Pro-Tip:** During the Dart installation, make note of the Dart SDK Path, and the Dartium path, as this will be used later during the WebStorm Dart plugin configuration.
    +
    +For WebStorm to be useful, it will need to have the Dart plugin installed. You can verify that it is installed by going to WebStorm preferences \> Plugins, and searching for "Dart". If it is checked off, then you have it installed. If not, then check the box to install it, and click OK.
    +
    +At this point, you can import the Security Monkey project into WebStorm. Please reference the WebStorm documentation for details on importing projects.
    +
    +The Dart plugin needs to be configured to utilize the Dart SDK. To configure the Dart plugin, open WebStorm preferences \> Languages & Frameworks \> Dart. If it is not already checked, check "Enable Dart Support for the project ...", and paste in the paths for the Dart SDK path Dartium.
    +
    +-   As an example, for a typical Dart OS X installation (via `brew`), the Dart path will be at: `/usr/local/opt/dart/libexec`, and the Dartium path will be: `/usr/local/opt/dart/Chromium.app`
    +
    +Toggle-On Security Monkey Development Mode
    +==========================================
    +
    +Once the Dart plugin is configured, you will need to alter a line of Dart code so that Security Monkey can be loaded in your development environment. You will need to edit the `dart/lib/util/constants.dart` file:
    +
    +-   Comment out the `API_HOST` variable under the `// Same Box` section, and uncomment the `API_HOST` variable under the `// LOCAL DEV` section.
    +
    +Additionally, CSRF protection will cause issues for local development and needs to be disabled.
    +
    +-   To disable CSRF protection, modify the `env-config/config-local.py` file, and set the `WTF_CSRF_ENABLED` flag to `False`.
    +-   **NOTE: DO \_\_NOT\_\_ DO THIS IN PRODUCTION!**
    +
    +Add Amazon Accounts
    +===================
    +
    +This will add Amazon owned AWS accounts to security monkey. :
    +
    +    python manage.py amazon_accounts
    +
    +Add a user account
    +==================
    +
    +This will add a user account that can be used later to login to the web ui:
    +
    +    python manage.py create_user email@youremail.com Admin
    +
    +The first argument is the email address of the new user. The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin].
    +
    +Start the Security Monkey API
    +==============================
    +
    +This starts the REST API that the Angular application will communicate with. :
    +
    +    python manage.py runserver
    +
    +Launch Dartium from within WebStorm
    +===================================
    +
    +From within the Security Monkey project in WebStorm, we will launch the UI (inside the Dartium app).
    +
    +To do this, within the Project Viewer/Explorer, right-click on the `dart/web/ui.html` file, and select "Open in Browser" \> Dartium.
    +
    +This will open the Dartium browser with the Security Monkey web UI.
    +
    +-   **Note:** If you get a `502: Bad Gateway`, try refreshing the page a few times.
    +-   **Another Note:** If the page appears, and then quickly becomes a 404 -- this is normal. The site is attempting to redirect you to the login page. However, the path for the login page is going to be: `http://127.0.0.1:8080/login` instead of the WebStorm port. This is only present inside of the development environment -- not in production.
    +
    +Register a user in Security Monkey
    +==================================
    +
    +If you didn't create a user on the command line (as instructed earlier), you can create one with the web ui:
    +
    +Chromium/Dartium will launch and will try to redirect to the login page. Per the note above, it should result in a 404. This is due to the browser redirecting you to the WebStorm port, and not the NGINX hosted port. This is normal in the development environment. Thus, clear your browser address bar, and navigate to: `http://127.0.0.1:8080/login` (Note: do not use `localhost`, use the localhost IP.)
    +
    +Select the Register link (`http://127.0.0.1:8080/register`) to create an account.
    +
    +Log into Security Monkey
    +========================
    +
    +Logging into Security Monkey is done by accessing the login page: `http://127.0.0.1:8080/login`. Please note, that in the development environment, when you log in, you will be redirected to `http://127.0.0.1/None`. This only occurs in the development environment. You will need to navigate to the WebStorm address and port (you can simply use WebStorm to re-open the page in Daritum). Once you are back in Dartium, you will be greeted with the main Security Monkey interface.
    +
    +Watch an AWS Account
    +====================
    +
    +After you have registered a user, logged in, and re-opened Dartium from WebStorm, you should be at the main Security Monkey interface. Once here, click on Settings and on the *+* to add a new AWS account to sync.
    +
    +Manually Run the Account Watchers
    +=================================
    +
    +Run the watchers to put some data in the database. :
    +
    +    cd ~/security_monkey/
    +    python manage.py run_change_reporter all
    +
    +You can also run an individual watcher:
    +
    +    python manage.py find_changes -a all -m all
    +    python manage.py find_changes -a all -m iamrole
    +    python manage.py find_changes -a "My Test Account" -m iamgroup
    +
    +You can run the auditors against the items currently in the database:
    +
    +    python manage.py audit_changes -a all -m redshift --send_report=False
    +
    +Next Steps
    +==========
    +
    +Continue reading the [Contributing](contributing.md) guide for additional instructions.
    diff --git a/docs/dev_setup_osx.rst b/docs/dev_setup_osx.rst
    deleted file mode 100644
    index b80848c0d..000000000
    --- a/docs/dev_setup_osx.rst
    +++ /dev/null
    @@ -1,296 +0,0 @@
    -************
    -Development Setup on Mac OS X
    -************
    -
    -Please follow the instructions below for setting up the Security Monkey development environment on Mac OS X.
    -
    -AWS Credentials
    -==========================
    -You will need to have the proper IAM Role configuration in place.  See `Configuration `_ for more details.  Additionally, you will need to have IAM keys available within your environment variables.  There are many ways to accomplish this.  Please see Amazon's documentation for additional details: http://docs.aws.amazon.com/general/latest/gr/getting-aws-sec-creds.html.
    -  
    -Additionally, see the boto documentation for more information: http://boto.readthedocs.org/en/latest/boto_config_tut.html
    -
    -Install Xcode
    -==========================
    -Xcode contains a number of tools that are required to install Security Monkey dependencies.  This needs to be installed from the App Store (free download):
    -https://itunes.apple.com/us/app/xcode/id497799835?mt=12
    -
    -After Xcode is installed, you need to accept the Xcode license agreement.  To do that, run::
    -
    -    sudo xcodebuild -license   # You will need to type in 'agree'
    -
    -Install Homebrew (http://brew.sh)
    -==========================
    -Requirement - Xcode Command Line Tools (Popup - Just click Install)::
    -
    -    ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
    -
    -Install Python
    -==========================
    -Install the latest version of Python 2.7 with Homebrew::
    -
    -    brew install python
    -
    -Upgrade Pip
    -==========================
    -A tool for installing and managing Python packages. You may need to update Pip, so run::
    -
    -    sudo pip install --upgrade pip
    -
    -Setup Virtualenv
    -==========================
    -Virtualenv is a tool to create isolated Python environments.  You will need to install it::
    -
    -    sudo pip install virtualenv
    -
    -VirtualenvWrapper
    -  virtualenvwrapper is a set of extensions to Ian Bicking’s virtualenv tool. The extensions include wrappers for creating and deleting virtual environments and otherwise managing your development workflow, making it easier to work on more than one project at a time without introducing conflicts in their dependencies. ::
    -
    -    sudo pip install virtualenvwrapper
    -
    -Configure VirtualEnvWrapper
    -  Configure VirtualEnvWrapper so it knows where to store the virtualenvs and where the virtualenvwerapper script is located. ::
    -
    -    cd ~
    -    mkdir virtual_envs
    -    vi ~/.bash_profile
    -
    -  Add these two lines to your ~/.bash_profile::
    -
    -    export WORKON_HOME="$HOME/virtual_envs/"
    -    source "/usr/local/bin/virtualenvwrapper.sh"
    -
    -  You'll need to open a new terminal (or run ``source ~/.bash_profile``) before you can create the virtualenv::
    -
    -    mkvirtualenv security_monkey
    -    workon security_monkey
    -
    -Clone Security Monkey
    -==========================
    -Clone the security monkey code repository. ::
    -
    -    git clone https://github.com/Netflix/security_monkey.git
    -    cd security_monkey
    -
    -SECURITY_MONKEY_SETTINGS
    -  Set the environment variable in your current session that tells Flask where the configuration file is located. ::
    -
    -    export SECURITY_MONKEY_SETTINGS=`pwd`/env-config/config-local.py
    -
    -  Note - I like to append this to the virtualenv activate script::
    -
    -    vi $HOME/virtual_envs/security_monkey/bin/activate
    -    export SECURITY_MONKEY_SETTINGS=$HOME/security_monkey/env-config/config-local.py
    -
    -Install PostgreSQL
    -==========================
    -Install Postgres.  Create a database for security monkey and add a role.  Set the timezone to GMT. ::
    -
    -    brew install postgresql
    -
    -Open a new shell, then start the DB::
    -
    -    postgres -D /usr/local/var/postgres
    -
    -Go back to your previous shell, then create the database and users and set the timezone. ::
    -
    -    psql -d postgres -h localhost
    -    CREATE DATABASE "securitymonkeydb";
    -    CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypass';
    -    CREATE SCHEMA securitymonkeydb
    -    GRANT Usage, Create ON SCHEMA "securitymonkeydb" TO "securitymonkeyuser";
    -    set timezone to 'GMT';
    -    select now();
    -
    -Exit the Postgres CLI tool::
    -
    -    CTRL-D
    -
    -Install Pip Requirements
    -==========================
    -Pip will install all the dependencies into the current virtualenv. ::
    -
    -    # Note for El Capitan users and above: Apple has removed OpenSSL from OS X, which is a dependency
    -    # of the cryptography library. OpenSSL gets installed with Postgres above. However, there are compiler
    -    # path errors that result when trying to install the cryptography Python dependency.
    -    # To resolve this, you need to run:
    -    env LDFLAGS="-L$(brew --prefix openssl)/lib" CFLAGS="-I$(brew --prefix openssl)/include" python setup.py develop
    -    # The above fully installs all the Python dependencies.
    -
    -    # For OS X versions prior to El Capitan, run:
    -    python setup.py develop
    -
    -Init the Security Monkey DB
    -==========================
    -Run Alembic/FlaskMigrate to create all the database tables. ::
    -
    -    python manage.py db upgrade
    -
    -Install and configure NGINX
    -==========================
    -NGINX will be used to serve static content for Security Monkey.  Use ``brew`` to install. ::
    -
    -   brew install nginx  
    -  
    -There will be some output about how to start NGINX, and where it's configuration resides. Choose the approach that works best for you. (We personally advise against starting things automatically on boot for your development box)
    -
    -The NGINX configuration will be located at: ``/usr/local/etc/nginx/``. You will need to make a modification to the nginx.conf file. The configuration changes include the following:
    -
    -- Disabling port 8080 for the main nginx.conf file
    -- Importing the Security Monkey specific configuration
    -  
    -Open the main NGINX configuration file: ``/usr/local/etc/nginx/nginx.conf``, and in the ``http`` section, add the line ::
    -  
    -    include securitymonkey.conf;
    -
    -Next, comment out the ``listen`` line (under the ``server`` section) ::
    -  
    -    server {
    -      listen       8080;   # Comment out this line by placing a '#' in front of 'listen'
    -  
    -Next, you will create the ``securitymonkey.conf`` NGINX configuration file.  Create this file under ``/usr/local/etc/nginx/``, and paste in the following (MAKE NOTE OF SPECIFIC SECTIONS) ::
    -  
    -    add_header X-Content-Type-Options "nosniff";
    -    add_header X-XSS-Protection "1; mode=block";
    -    add_header X-Frame-Options "SAMEORIGIN";
    -    add_header Strict-Transport-Security "max-age=631138519";
    -    add_header Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src     'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;";
    -    
    -    server {
    -     listen      0.0.0.0:8080;
    -   
    -     # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    -     access_log          /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.access.log;
    -     error_log           /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.error.log;
    -     
    -     location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) {
    -          proxy_read_timeout 120;
    -          proxy_pass  http://127.0.0.1:5000;
    -          proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
    -          proxy_redirect off;
    -          proxy_buffering off;
    -          proxy_set_header        Host            $host;
    -          proxy_set_header        X-Real-IP       $remote_addr;
    -          proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    -      }
    -      
    -      location /static {
    -          rewrite ^/static/(.*)$ /$1 break;
    -          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    -          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    -          index ui.html;
    -      }
    -      
    -      location / {
    -          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    -          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    -          index ui.html;
    -      }
    -    }
    -
    -Create the ``devlog/security_monkey.access.log`` file. ::
    -
    -    mkdir devlog
    -    touch devlog/security_monkey.access.log
    -
    -NGINX can be started by running the ``nginx`` command in the Terminal.  You will need to run ``nginx`` before moving on.  This will also output any errors that are encountered when reading the configuration files.
    -
    -Launch and Configure the WebStorm Editor
    -==========================
    -We prefer the WebStorm IDE for developing with Dart: https://www.jetbrains.com/webstorm/.  Webstorm requires the JDK to be installed.  If you don't already have Java and the JDK installed, please download it here: http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html.
    -
    -In addition to WebStorm, you will also need to have the Dart SDK installed.  Please download and install the Dart suite (SDK and Dartium) via Homebrew::
    -
    -    $ brew tap dart-lang/dart
    -    $ brew install dart --with-content-shell --with-dartium
    -
    -**Pro-Tip:** During the Dart installation, make note of the Dart SDK Path, and the Dartium path, as this will be used later during the WebStorm Dart plugin configuration. 
    -  
    -For WebStorm to be useful, it will need to have the Dart plugin installed.  You can verify that it is installed by going to WebStorm preferences > Plugins, and searching for "Dart".  If it is checked off, then you have it installed.  If not, then check the box to install it, and click OK.
    -
    -At this point, you can import the Security Monkey project into WebStorm.  Please reference the WebStorm documentation for details on importing projects.
    -
    -The Dart plugin needs to be configured to utilize the Dart SDK. To configure the Dart plugin, open WebStorm preferences > Languages & Frameworks > Dart.  If it is not already checked, check "Enable Dart Support for the project ...", and paste in the paths for the Dart SDK path Dartium.
    -  
    -- As an example, for a typical Dart OS X installation (via ``brew``), the Dart path will be at: ``/usr/local/opt/dart/libexec``, and the Dartium path will be: ``/usr/local/opt/dart/Chromium.app``
    -
    -Toggle-On Security Monkey Development Mode
    -==========================
    -Once the Dart plugin is configured, you will need to alter a line of Dart code so that Security Monkey can be loaded in your development environment.  You will need to edit the ``dart/lib/util/constants.dart`` file: 
    -
    -- Comment out the ``API_HOST`` variable under the ``// Same Box`` section, and uncomment the ``API_HOST`` variable under the ``// LOCAL DEV`` section.
    -
    -Additionally, CSRF protection will cause issues for local development and needs to be disabled.  
    -
    -- To disable CSRF protection, modify the ``env-config/config-local.py`` file, and set the ``WTF_CSRF_ENABLED`` flag to ``False``.
    -- **NOTE: DO __NOT__ DO THIS IN PRODUCTION!**
    -
    -Add Amazon Accounts
    -==========================
    -This will add Amazon owned AWS accounts to security monkey. ::
    -
    -    python manage.py amazon_accounts
    -
    -Add a user account
    -==========================
    -This will add a user account that can be used later to login to the web ui::
    -
    -    python manage.py create_user email@youremail.com Admin
    -
    -The first argument is the email address of the new user.  The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin].
    -
    -
    -Start the Security Monkey API
    -==========================
    -This starts the REST API that the Angular application will communicate with. ::
    -
    -    python manage.py runserver
    -
    -Launch Dartium from within WebStorm
    -==========================
    -From within the Security Monkey project in WebStorm, we will launch the UI (inside the Dartium app).
    -
    -To do this, within the Project Viewer/Explorer, right-click on the ``dart/web/ui.html`` file, and select "Open in Browser" > Dartium.
    -
    -This will open the Dartium browser with the Security Monkey web UI.
    -
    -- **Note:** If you get a ``502: Bad Gateway``, try refreshing the page a few times.
    -- **Another Note:** If the page appears, and then quickly becomes a 404 -- this is normal. The site is attempting to redirect you to the login page.  However, the path for the login page is going to be: ``http://127.0.0.1:8080/login`` instead of the WebStorm port.  This is only present inside of the development environment -- not in production.
    -
    -Register a user in Security Monkey
    -==========================
    -If you didn't create a user on the command line (as instructed earlier), you can create one with the web ui:
    -
    -Chromium/Dartium will launch and will try to redirect to the login page.  Per the note above, it should result in a 404. This is due to the browser redirecting you to the WebStorm port, and not the NGINX hosted port.  This is normal in the development environment.  Thus, clear your browser address bar, and navigate to: ``http://127.0.0.1:8080/login`` (Note: do not use ``localhost``, use the localhost IP.)
    -  
    -Select the Register link (``http://127.0.0.1:8080/register``) to create an account.
    -  
    -Log into Security Monkey
    -==========================
    -Logging into Security Monkey is done by accessing the login page: ``http://127.0.0.1:8080/login``.  Please note, that in the development environment, when you log in, you will be redirected to ``http://127.0.0.1/None``.  This only occurs in the development environment.  You will need to navigate to the WebStorm address and port (you can simply use WebStorm to re-open the page in Daritum).  Once you are back in Dartium, you will be greeted with the main Security Monkey interface.
    -
    -Watch an AWS Account
    -==========================
    -After you have registered a user, logged in, and re-opened Dartium from WebStorm, you should be at the main Security Monkey interface. Once here, click on Settings and on the *+* to add a new AWS account to sync.
    -
    -Manually Run the Account Watchers
    -==========================
    -Run the watchers to put some data in the database. ::
    -
    -    cd ~/security_monkey/
    -    python manage.py run_change_reporter all
    -
    -You can also run an individual watcher::
    -
    -    python manage.py find_changes -a all -m all
    -    python manage.py find_changes -a all -m iamrole
    -    python manage.py find_changes -a "My Test Account" -m iamgroup
    -
    -You can run the auditors against the items currently in the database::
    -
    -    python manage.py audit_changes -a all -m redshift --send_report=False
    -
    -Next Steps
    -========================
    -Continue reading the `Contributing `_ guide for additional instructions.
    -
    diff --git a/docs/dev_setup_ubuntu.md b/docs/dev_setup_ubuntu.md
    new file mode 100644
    index 000000000..c32d3e11d
    --- /dev/null
    +++ b/docs/dev_setup_ubuntu.md
    @@ -0,0 +1,265 @@
    +***********\* Development Setup on Ubuntu***********\*
    +
    +Please follow the instructions below for setting up the Security Monkey development environment on Ubuntu Trusty (14.04).
    +
    +AWS Credentials
    +===============
    +
    +You will need to have the proper IAM Role configuration in place. See [IAM Role Setup on AWS](iam_aws.md) for more details. Additionally, you will need to have IAM keys available within your environment variables. There are many ways to accomplish this. Please see Amazon's documentation for additional details: .
    +
    +Additionally, see the boto documentation for more information: 
    +
    +Install Primary Packages:
    +=========================
    +
    +These must be installed first. :
    +
    +    sudo apt-get install git git-flow python-pip postgresql postgresql-contrib libpq-dev python-dev nginx libffi-dev
    +
    +Setup Virtualenv
    +================
    +
    +A tool to create isolated Python environments:
    +
    +    sudo pip install virtualenv
    +
    +Create a folder to hold your virtualenvs:
    +
    +    cd ~
    +    mkdir virtual_envs
    +    cd virtual_envs
    +
    +Create a virtualenv for security\_monkey:
    +
    +    virtualenv security_monkey
    +
    +Activate the security\_monkey virtualenv:
    +
    +    source ~/virtual_envs/security_monkey/bin/activate
    +
    +Clone Security Monkey
    +=====================
    +
    +Clone the security monkey code repository. :
    +
    +    cd ~
    +    git clone https://github.com/Netflix/security_monkey.git
    +    cd security_monkey
    +
    +Install Pip Requirements
    +========================
    +
    +Pip will install all the dependencies into the current virtualenv. :
    +
    +    python setup.py develop
    +
    +SECURITY_MONKEY_SETTINGS  
    +You can set the SECURITY_MONKEY_SETTINGS environment variable if you would like security_monkey to use a config file other than `env-config/config.py`.  It may be a good idea to create a `config-local.py` and use that instead.
    +
    +    export SECURITY_MONKEY_SETTINGS=`pwd`/env-config/config-local.py
    +    # Note - I like to append this to the virtualenv activate script
    +    vi $HOME/virtual_envs/security_monkey/bin/activate
    +    export SECURITY_MONKEY_SETTINGS=$HOME/security_monkey/env-config/config-local.py
    +
    +Configure PostgreSQL
    +====================
    +
    +Create a PostgreSQL database for security monkey and add a role. Set the timezone to GMT. :
    +
    +    sudo -u postgres psql
    +    CREATE DATABASE "securitymonkeydb";
    +    CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypass';
    +    CREATE SCHEMA securitymonkeydb
    +    GRANT Usage, Create ON SCHEMA "securitymonkeydb" TO "securitymonkeyuser";
    +    set timezone TO 'GMT';
    +    select now();
    +    \q
    +
    +Init the Security Monkey DB
    +==========================
    +
    +Run Alembic/FlaskMigrate to create all the database tables. :
    +
    +    python manage.py db upgrade
    +
    +Configure NGINX
    +===============
    +
    +On Ubuntu, the NGINX configuration files will be located at: `/etc/nginx`. You will need to make a modification to the nginx.conf file. The configuration changes include the following:
    +
    +-   Disabling port 8080 for the main nginx.conf file
    +-   Importing the Security Monkey specific configuration
    +
    +Open the main NGINX configuration file: `/etc/nginx/nginx.conf`, and in the `http` section, add the line :
    +
    +    include securitymonkey.conf;
    +
    +Next, in the file: `/etc/nginx/sites-enabled/default`, comment out the `listen` line (under the `server` section) :
    +
    +    server {
    +      listen 80 default_server;   # Comment out this line by placing a '#' in front of 'listen'
    +
    +Next, you will create the `securitymonkey.conf` NGINX configuration file. Create this file under `/etc/nginx/`, and paste in the following (MAKE NOTE OF SPECIFIC SECTIONS) :
    +
    +    add_header X-Content-Type-Options "nosniff";
    +    add_header X-XSS-Protection "1; mode=block";
    +    add_header X-Frame-Options "SAMEORIGIN";
    +    add_header Strict-Transport-Security "max-age=631138519";
    +    add_header Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src     'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;";
    +
    +    server {
    +     listen      0.0.0.0:8080;
    +
    +     # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    +     access_log          /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.access.log;
    +     error_log           /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.error.log;
    +
    +     location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) {
    +          proxy_read_timeout 120;
    +          proxy_pass  http://127.0.0.1:5000;
    +          proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
    +          proxy_redirect off;
    +          proxy_buffering off;
    +          proxy_set_header        Host            $host;
    +          proxy_set_header        X-Real-IP       $remote_addr;
    +          proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    +      }
    +
    +      location /static {
    +          rewrite ^/static/(.*)$ /$1 break;
    +          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    +          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    +          index ui.html;
    +      }
    +
    +      location / {
    +          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    +          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    +          index ui.html;
    +      }
    +    }
    +
    +NGINX can be started by running the `sudo nginx` command in the console. You will need to run `sudo nginx` before moving on. This will also output any errors that are encountered when reading the configuration files.
    +
    +Launch and Configure the WebStorm Editor:
    +=========================================
    +
    +We prefer the WebStorm IDE for developing with Dart: . Webstorm requires the JDK to be installed. If you don't already have Java installed, then install it by running the commands: :
    +
    +    sudo apt-get install default-jre default-jdk
    +
    +In addition to WebStorm, you will also need to have the Dart SDK installed. Please download and install the Dart SDK :
    +
    +    sudo curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
    +    sudo curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list
    +    sudo apt-get update
    +    sudo apt-get install dart
    +
    +**Note:** You will need to install Dartium as well. This requires extra steps and is unfortunately not available as a Debian package. Dartium is packaged as a .zip file in the section "Installing from a zip file" on the Dart download page. Download the Dartium zip file, and follow the following instructions:
    +
    +1.) Extract the .zip file
    +
    +2.) Run the following commands. :
    +
    +    sudo cp -R /path/to/your/extracted/Dartium/zip/file /opt/Dartium
    +    sudo chmod 755 /opt/Dartium
    +    cd /opt/Dartium
    +    sudo find ./ -type d -exec chmod 755 {} \;
    +    sudo find ./ -type f -exec chmod 644 {} \;
    +    sudo chmod +x chrome
    +    sudo ln -s /lib/x86_64-linux-gnu/libudev.so.1 /lib/x86_64-linux-gnu/libudev.so.0
    +
    +For WebStorm to be useful, it will need to have the Dart plugin installed. You can verify that it is installed by going to WebStorm preferences \> Plugins, and searching for "Dart". If it is checked off, then you have it installed. If not, then check the box to install it, and click OK.
    +
    +At this point, you can import the Security Monkey project into WebStorm. Please reference the WebStorm documentation for details on importing projects.
    +
    +The Dart plugin needs to be configured to utilize the Dart SDK. To configure the Dart plugin, open WebStorm preferences \> Languages & Frameworks \> Dart. If it is not already checked, check "Enable Dart Support for the project ...", and paste in the paths for the Dart SDK path Dartium.
    +
    +-   As an example, for a typical Dart Ubuntu installation (via `apt-get`), the Dart path will be at: `/usr/lib/dart`, and the Dartium path (following the instructions above) will be: `/opt/Dartium/chrome`
    +
    +Toggle-On Security Monkey Development Mode
    +==========================================
    +
    +Once the Dart plugin is configured, you will need to alter a line of Dart code so that Security Monkey can be loaded in your development environment. You will need to edit the `dart/lib/util/constants.dart` file:
    +
    +-   Comment out the `API_HOST` variable under the `// Same Box` section, and uncomment the `API_HOST` variable under the `// LOCAL DEV` section.
    +
    +Additionally, CSRF protection will cause issues for local development and needs to be disabled.
    +
    +-   To disable CSRF protection, modify the `env-config/config-local.py` file, and set the `WTF_CSRF_ENABLED` flag to `False`.
    +-   **NOTE: DO __NOT__ DO THIS IN PRODUCTION!**
    +
    +Add Amazon Accounts
    +===================
    +
    +This will add Amazon owned AWS accounts to security monkey. :
    +
    +    python manage.py amazon_accounts
    +
    +Add a user account
    +==================
    +
    +This will add a user account that can be used later to login to the web ui:
    +
    +    python manage.py create_user  Admin
    +
    +The first argument is the email address of the new user. The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin].
    +
    +Start the Security Monkey API
    +=============================
    +
    +This starts the REST API that the Angular application will communicate with. :
    +
    +    python manage.py runserver
    +
    +Launch Dartium from within WebStorm
    +===================================
    +
    +From within the Security Monkey project in WebStorm, we will launch the UI (inside the Dartium app).
    +
    +To do this, within the Project Viewer/Explorer, right-click on the `dart/web/ui.html` file, and select "Open in Browser" \> Dartium.
    +
    +This will open the Dartium browser with the Security Monkey web UI.
    +
    +-   **Note:** If you get a `502: Bad Gateway`, try refreshing the page a few times.
    +-   **Another Note:** If the page appears, and then quickly becomes a 404 -- this is normal. The site is attempting to redirect you to the login page. However, the path for the login page is going to be: `http://127.0.0.1:8080/login` instead of the WebStorm port. This is only present inside of the development environment -- not in production.
    +
    +Register a user in Security Monkey
    +==================================
    +
    +Chromium/Dartium will launch and will try to redirect to the login page. Per the note above, it should result in a 404. This is due to the browser redirecting you to the WebStorm port, and not the NGINX hosted port. This is normal in the development environment. Thus, clear your browser address bar, and navigate to: `http://127.0.0.1:8080/login` (Note: do not use `localhost`, use the localhost IP.)
    +
    +Select the Register link (`http://127.0.0.1:8080/register`) to create an account.
    +
    +Log into Security Monkey
    +========================
    +
    +Logging into Security Monkey is done by accessing the login page: `http://127.0.0.1:8080/login`. Please note, that in the development environment, when you log in, you will be redirected to `http://127.0.0.1/None`. This only occurs in the development environment. You will need to navigate to the WebStorm address and port (you can simply use WebStorm to re-open the page in Daritum). Once you are back in Dartium, you will be greeted with the main Security Monkey interface.
    +
    +Watch an AWS Account
    +====================
    +
    +After you have registered a user, logged in, and re-opened Dartium from WebStorm, you should be at the main Security Monkey interface. Once here, click on Settings and on the *+* to add a new AWS account to sync.
    +
    +Manually Run the Account Watchers
    +=================================
    +
    +Run the watchers to put some data in the database. :
    +
    +    cd ~/security_monkey/
    +    python manage.py run_change_reporter all
    +
    +You can also run an individual watcher:
    +
    +    python manage.py find_changes -a all -m all
    +    python manage.py find_changes -a all -m iamrole
    +    python manage.py find_changes -a "My Test Account" -m iamgroup
    +
    +You can run the auditors against the items currently in the database:
    +
    +    python manage.py audit_changes -a all -m redshift --send_report=False
    +
    +Next Steps
    +==========
    +
    +Continue reading the [Contributing](contributing.md) guide for additional instructions.
    diff --git a/docs/dev_setup_ubuntu.rst b/docs/dev_setup_ubuntu.rst
    deleted file mode 100644
    index 6b1a732e4..000000000
    --- a/docs/dev_setup_ubuntu.rst
    +++ /dev/null
    @@ -1,248 +0,0 @@
    -************
    -Development Setup on Ubuntu
    -************
    -
    -Please follow the instructions below for setting up the Security Monkey development environment on Ubuntu Trusty (14.04).
    -
    -AWS Credentials
    -==========================
    -You will need to have the proper IAM Role configuration in place.  See `Configuration `_ for more details.  Additionally, you will need to have IAM keys available within your environment variables.  There are many ways to accomplish this.  Please see Amazon's documentation for additional details: http://docs.aws.amazon.com/general/latest/gr/getting-aws-sec-creds.html.
    -  
    -Additionally, see the boto documentation for more information: http://boto.readthedocs.org/en/latest/boto_config_tut.html
    -
    -Install Primary Packages:
    -==========================
    -These must be installed first. ::
    -
    -    sudo apt-get install git git-flow python-pip postgresql postgresql-contrib libpq-dev python-dev nginx libffi-dev
    -
    -Setup Virtualenv
    -==========================
    -A tool to create isolated Python environments::
    -
    -    sudo pip install virtualenv
    -
    -Create a folder to hold your virtualenvs::
    -
    -    cd ~
    -    mkdir virtual_envs
    -    cd virtual_envs
    -
    -Create a virtualenv for security_monkey::
    -
    -    virtualenv security_monkey
    -
    -Activate the security_monkey virtualenv::
    -
    -    source ~/virtual_envs/security_monkey/bin/activate
    -
    -Clone Security Monkey
    -==========================
    -Clone the security monkey code repository. ::
    -
    -    cd ~
    -    git clone https://github.com/Netflix/security_monkey.git
    -    cd security_monkey
    -
    -Install Pip Requirements
    -==========================
    -Pip will install all the dependencies into the current virtualenv. ::
    -
    -    python setup.py develop
    -
    -SECURITY_MONKEY_SETTINGS
    -  Set the environment variable in your current session that tells Flask where the conifguration file is located. ::
    -
    -    export SECURITY_MONKEY_SETTINGS=`pwd`/env-config/config-local.py
    -    # Note - I like to append this to the virtualenv activate script
    -    vi $HOME/virtual_envs/security_monkey/bin/activate
    -    export SECURITY_MONKEY_SETTINGS=$HOME/security_monkey/env-config/config-local.py
    -
    -Configure PostgreSQL
    -==========================
    -Create a PostgreSQL database for security monkey and add a role.  Set the timezone to GMT. ::
    -
    -    sudo -u postgres psql
    -    CREATE DATABASE "securitymonkeydb";
    -    CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypass';
    -    CREATE SCHEMA securitymonkeydb
    -    GRANT Usage, Create ON SCHEMA "securitymonkeydb" TO "securitymonkeyuser";
    -    set timezone TO 'GMT';
    -    select now();
    -    \q
    -
    -Init the Security Monkey DB
    -==========================
    -Run Alembic/FlaskMigrate to create all the database tables. ::
    -
    -    python manage.py db upgrade
    -
    -Configure NGINX
    -==========================
    -On Ubuntu, the NGINX configuration files will be located at: ``/etc/nginx``. You will need to make a modification to the nginx.conf file. The configuration changes include the following:
    -
    -- Disabling port 8080 for the main nginx.conf file
    -- Importing the Security Monkey specific configuration
    -
    -Open the main NGINX configuration file: ``/etc/nginx/nginx.conf``, and in the ``http`` section, add the line ::
    -  
    -    include securitymonkey.conf;
    -
    -Next, in the file: ``/etc/nginx/sites-enabled/default``, comment out the ``listen`` line (under the ``server`` section) ::
    -
    -    server {
    -      listen 80 default_server;   # Comment out this line by placing a '#' in front of 'listen'
    -  
    -Next, you will create the ``securitymonkey.conf`` NGINX configuration file.  Create this file under ``/etc/nginx/``, and paste in the following (MAKE NOTE OF SPECIFIC SECTIONS) ::
    -  
    -    add_header X-Content-Type-Options "nosniff";
    -    add_header X-XSS-Protection "1; mode=block";
    -    add_header X-Frame-Options "SAMEORIGIN";
    -    add_header Strict-Transport-Security "max-age=631138519";
    -    add_header Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src     'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;";
    -    
    -    server {
    -     listen      0.0.0.0:8080;
    -   
    -     # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    -     access_log          /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.access.log;
    -     error_log           /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/devlog/security_monkey.error.log;
    -     
    -     location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) {
    -          proxy_read_timeout 120;
    -          proxy_pass  http://127.0.0.1:5000;
    -          proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
    -          proxy_redirect off;
    -          proxy_buffering off;
    -          proxy_set_header        Host            $host;
    -          proxy_set_header        X-Real-IP       $remote_addr;
    -          proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    -      }
    -      
    -      location /static {
    -          rewrite ^/static/(.*)$ /$1 break;
    -          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    -          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    -          index ui.html;
    -      }
    -      
    -      location / {
    -          # EDIT THIS TO YOUR DEVELOPMENT PATH HERE:
    -          root /PATH/TO/YOUR/CLONED/SECURITY_MONKEY_BASE_DIR/dart/web;
    -          index ui.html;
    -      }
    -    }
    -
    -NGINX can be started by running the ``sudo nginx`` command in the console.  You will need to run ``sudo nginx`` before moving on.  This will also output any errors that are encountered when reading the configuration files.
    -
    -Launch and Configure the WebStorm Editor:
    -==========================
    -We prefer the WebStorm IDE for developing with Dart: https://www.jetbrains.com/webstorm/.  Webstorm requires the JDK to be installed.  If you don't already have Java installed, then install it by running the commands: ::
    -
    -  sudo apt-get install default-jre default-jdk
    -
    -In addition to WebStorm, you will also need to have the Dart SDK installed.  Please download and install the Dart SDK ::
    -
    -    sudo curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -
    -    sudo curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list
    -    sudo apt-get update
    -    sudo apt-get install dart
    -
    -**Note:** You will need to install Dartium as well.  This requires extra steps and is unfortunately not available as a Debian package.  Dartium is packaged as a .zip file in the section "Installing from a zip file" on the Dart download page.  Download the Dartium zip file, and follow the following instructions:
    -
    -1.) Extract the .zip file
    -  
    -2.) Run the following commands. ::
    -
    -    sudo cp -R /path/to/your/extracted/Dartium/zip/file /opt/Dartium
    -    sudo chmod 755 /opt/Dartium
    -    cd /opt/Dartium
    -    sudo find ./ -type d -exec chmod 755 {} \;
    -    sudo find ./ -type f -exec chmod 644 {} \;
    -    sudo chmod +x chrome
    -    sudo ln -s /lib/x86_64-linux-gnu/libudev.so.1 /lib/x86_64-linux-gnu/libudev.so.0
    -
    -For WebStorm to be useful, it will need to have the Dart plugin installed.  You can verify that it is installed by going to WebStorm preferences > Plugins, and searching for "Dart".  If it is checked off, then you have it installed.  If not, then check the box to install it, and click OK.
    -
    -At this point, you can import the Security Monkey project into WebStorm.  Please reference the WebStorm documentation for details on importing projects.
    -
    -The Dart plugin needs to be configured to utilize the Dart SDK. To configure the Dart plugin, open WebStorm preferences > Languages & Frameworks > Dart.  If it is not already checked, check "Enable Dart Support for the project ...", and paste in the paths for the Dart SDK path Dartium.
    -
    -- As an example, for a typical Dart Ubuntu installation (via ``apt-get``), the Dart path will be at: ``/usr/lib/dart``, and the Dartium path (following the instructions above) will be: ``/opt/Dartium/chrome``
    -
    -Toggle-On Security Monkey Development Mode
    -==========================
    -Once the Dart plugin is configured, you will need to alter a line of Dart code so that Security Monkey can be loaded in your development environment.  You will need to edit the ``dart/lib/util/constants.dart`` file: 
    -
    -- Comment out the ``API_HOST`` variable under the ``// Same Box`` section, and uncomment the ``API_HOST`` variable under the ``// LOCAL DEV`` section.
    -
    -Additionally, CSRF protection will cause issues for local development and needs to be disabled.  
    -
    -- To disable CSRF protection, modify the ``env-config/config-local.py`` file, and set the ``WTF_CSRF_ENABLED`` flag to ``False``.
    -- **NOTE: DO __NOT__ DO THIS IN PRODUCTION!**
    -
    -Add Amazon Accounts
    -==========================
    -This will add Amazon owned AWS accounts to security monkey. ::
    -
    -    python manage.py amazon_accounts
    -
    -Add a user account
    -==========================
    -This will add a user account that can be used later to login to the web ui:
    -
    -    python manage.py create_user email@youremail.com Admin
    -
    -The first argument is the email address of the new user.  The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin].
    -
    -Start the Security Monkey API
    -==========================
    -This starts the REST API that the Angular application will communicate with. ::
    -
    -    python manage.py runserver
    -
    -Launch Dartium from within WebStorm
    -==========================
    -From within the Security Monkey project in WebStorm, we will launch the UI (inside the Dartium app).
    -
    -To do this, within the Project Viewer/Explorer, right-click on the ``dart/web/ui.html`` file, and select "Open in Browser" > Dartium.
    -
    -This will open the Dartium browser with the Security Monkey web UI.
    -
    -- **Note:** If you get a ``502: Bad Gateway``, try refreshing the page a few times.
    -- **Another Note:** If the page appears, and then quickly becomes a 404 -- this is normal. The site is attempting to redirect you to the login page.  However, the path for the login page is going to be: ``http://127.0.0.1:8080/login`` instead of the WebStorm port.  This is only present inside of the development environment -- not in production.
    -
    -Register a user in Security Monkey
    -==========================
    -Chromium/Dartium will launch and will try to redirect to the login page.  Per the note above, it should result in a 404. This is due to the browser redirecting you to the WebStorm port, and not the NGINX hosted port.  This is normal in the development environment.  Thus, clear your browser address bar, and navigate to: ``http://127.0.0.1:8080/login`` (Note: do not use ``localhost``, use the localhost IP.)
    -  
    -Select the Register link (``http://127.0.0.1:8080/register``) to create an account.
    -  
    -Log into Security Monkey
    -==========================
    -Logging into Security Monkey is done by accessing the login page: ``http://127.0.0.1:8080/login``.  Please note, that in the development environment, when you log in, you will be redirected to ``http://127.0.0.1/None``.  This only occurs in the development environment.  You will need to navigate to the WebStorm address and port (you can simply use WebStorm to re-open the page in Daritum).  Once you are back in Dartium, you will be greeted with the main Security Monkey interface.
    -
    -Watch an AWS Account
    -==========================
    -After you have registered a user, logged in, and re-opened Dartium from WebStorm, you should be at the main Security Monkey interface. Once here, click on Settings and on the *+* to add a new AWS account to sync.
    -
    -Manually Run the Account Watchers
    -==========================
    -Run the watchers to put some data in the database. ::
    -
    -    cd ~/security_monkey/
    -    python manage.py run_change_reporter all
    -
    -You can also run an individual watcher::
    -
    -    python manage.py find_changes -a all -m all
    -    python manage.py find_changes -a all -m iamrole
    -    python manage.py find_changes -a "My Test Account" -m iamgroup
    -
    -You can run the auditors against the items currently in the database::
    -
    -    python manage.py audit_changes -a all -m redshift --send_report=False
    -
    -Next Steps
    -========================
    -Continue reading the `Contributing `_ guide for additional instructions.
    diff --git a/docs/dev_setup_windows.md b/docs/dev_setup_windows.md
    new file mode 100644
    index 000000000..b75fddbbb
    --- /dev/null
    +++ b/docs/dev_setup_windows.md
    @@ -0,0 +1,270 @@
    +Development Setup on Windows
    +============================
    +
    +Please follow the instructions below for setting up the Security Monkey development environment on Windows 10.
    +
    +These instructions were created after consulting my install notes after recently getting a Windows 10 machine. If you're a Powershell guru, please feel free to send a PR to fix any errors.
    +
    +Windows Development
    +-------------------
    +
    +I'm pretty happy with development on Windows. Docker seems much easier to work with (No need for virtualbox). Gunicorn does not yet support Windows (Issue \#524). Luckily, we don't need Gunicorn for local dev. Powershell is a worthy command line environment. If all else fails, use WSL (Windows Subsystem for Linux).
    +
    +AWS Credentials
    +---------------
    +
    +You will need to have the proper IAM Role configuration in place. See [IAM Role Setup on AWS](iam_aws.md) for more details. Additionally, you will need to have IAM keys available within your environment variables. There are many ways to accomplish this. Please see Amazon's documentation for additional details: .
    +
    +Additionally, see the boto documentation for more information: 
    +
    +Install Chocolatey
    +------------------
    +
    +Follow the instructions to install Chocolatey:
    +
    +
    +
    +Install Python
    +--------------
    +
    +Install python 2.7 with Chocolatey.:
    +
    +    choco install python2
    +
    +Setup Powershell
    +----------------
    +
    +The following steps are a summary of the steps at 
    +
    +### Execution Policy
    +
    +You'll need to set the execution policy. There are a few options described at 
    +
    +You'll need to run something like this:
    +
    +    Set-ExecutionPolicy RemoteSigned
    +
    +### VirtualEnv
    +
    +Install virtualenv and virtualenvwrapper from pypi:
    +
    +    pip install virtualenv
    +    pip install virtualenvwrapper-powershell
    +
    +Try to import the powershell module:
    +
    +    Import-Module virtualenvwrapper
    +
    +At this point you may receive the following error:
    +
    +    Get-Content : Cannot find path 'Function:\TabExpansion' because it does not exist.
    +
    +You'll need to find and edit the file virtualenvwrapperTabExpansion.psm1. On line 12, replace Get-Content Function:TabExpansion with Get-Content Function:TabExpansion2. This should fix the import error.
    +
    +If the \~/.virtualenvs folder wasn't created, do that now:
    +
    +    mkdir ~/.virtualenvs
    +
    +### Automatically import the virtualenvwrapper module on powershell startup.
    +
    +In bash, you would typically edit your \~/.bashrc to load modules and setup your environment. On Powershell, you'll use \$profile. Powershell has a few different \$profiles you can use. You can see them all with this command:
    +
    +    $profile | Format-List * -Force
    +    AllUsersAllHosts       : C:\Windows\System32\WindowsPowerShell\v1.0\profile.ps1
    +    AllUsersCurrentHost    : C:\Windows\System32\WindowsPowerShell\v1.0\Microsoft.PowerShell_profile.ps1
    +    CurrentUserAllHosts    : C:\Users\\Documents\WindowsPowerShell\profile.ps1
    +    CurrentUserCurrentHost : C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1
    +    Length                 : 77
    +
    +If you're not sure, we're going to use CurrentUserAllHosts. If the file doesn't already exist, we can easily create it:
    +
    +    New-Item -Path $Profile.CurrentUserAllHosts -Type file -Force
    +
    +Now open it with a text editor and add this line:
    +
    +    Import-Module virtualenvwrapper
    +
    +All new powershell windows should have this module.:
    +
    +    Get-Command *virtualenv*
    +
    +Clone the Codebase
    +------------------
    +
    +Navigate to wherever you like to mash on code and clone the repository. We'll use \~\\Github here.:
    +
    +    cd ~
    +    mkdir Github
    +    cd Github
    +
    +If you don't already have git installed:
    +
    +    choco install git
    +
    +Clone security\_monkey:
    +
    +    git clone git@github.com:Netflix/security_monkey.git
    +
    +Create a security\_monkey virtualenv
    +------------------------------------
    +
    +You can use the powershell syntax:
    +
    +    New-VirtualEnvironment security_monkey
    +
    +Or use the aliased commands you're probably more familiar with:
    +
    +    mkvirtualenv security_monkey
    +
    +Before we attempt to install setup.py, let's grab a couple modules from pypi so we don't need to compile them.:
    +
    +    pip install cryptography
    +    pip install bcrypt
    +
    +### Install psycopg2
    +
    +This part seems a bit yucky. Let me know if you find a cleaner way.
    +
    +-   Go to 
    +-   Download the exe for your python version and processor architecture. I'll continue with psycopg2-2.6.2.win-amd64-py2.7-pg9.5.3-release.exe
    +-   In powershell, ensure your virtualenv is activated and install the exe:
    +
    +        workon security_monkey
    +        easy_install psycopg2-2.6.2.win-amd64-py2.7-pg9.5.3-release.exe
    +
    +### Setting SECURITY\_MONKEY\_SETTINGS
    +
    +You can set the SECURITY_MONKEY_SETTINGS environment variable if you would like security_monkey to use a config file other than `env-config/config.py`.  It may be a good idea to create a `config-local.py` and use that instead.
    +
    +You set powershell environment variables with `$env:`
    +
    +    $env:SECURITY_MONKEY_SETTINGS = "C:\Users\\...\GitHub\security_monkey\env-config\config-local.py"
    +
    +It might be a good idea to drop this into your `$profile` as well...
    +
    +Install Setup.py
    +----------------
    +
    +With your virtualenv activated, this will install the security\_monkey python module for dev::
    +
    +    cd \~/Github/security\_monkey/
    +    workon security\_monkey
    +    python setup.py develop
    +
    +We should be able to run manage.py to see usage information:
    +
    +    python manage.py
    +
    +### Setup a development DB
    +
    +Instead of installing postgres, let's use docker for the DB. Windows has good docker support. You should be able to use Chocolatey, but I downloaded it directly from their website.:
    +
    +    choco install docker
    +
    +I actually downloaded the stable branch from here: 
    +
    +Once you have docker, pull a postgres container down. I'm using this one:  You should be able to start it with this command:
    +
    +    docker run --name some-postgres
    +
    +Kitematic is a nice UI tool for managing running containers. You can use it to set the postgres container to be reachable from localhost on 5432 and to set environment variables which the container uses to set the database name, username, password, etc.
    +
    +If you leave the DB paramaters at their default, you'll need to modify config-local.py:
    +
    +    SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:mysecretpassword@localhost:5432/postgres'
    +
    +Install the security\_monkey DB tables:
    +
    +    python manage.py db upgrade
    +
    +FYI - Navicat is a great tool for exploring the DB.
    +
    +Add Amazon Accounts
    +-------------------
    +
    +This will add Amazon owned AWS accounts to security monkey. :
    +
    +    python manage.py amazon_accounts
    +
    +Add a user account
    +------------------
    +
    +This will add a user account that can be used later to login to the web ui:
    +
    +    python manage.py create\_user  Admin
    +
    +The first argument is the email address of the new user. The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin].
    +
    +Start the Security Monkey API
    +-----------------------------
    +
    +This starts the REST API that the Angular application will communicate with. :
    +
    +    python manage.py runserver
    +
    +### Dart Development
    +
    +Install the dart SDK:
    +
    +    choco install dart-sdk
    +
    +This will install a few tools in C:tools. Let's install webstorm and configure it to use the dart-sdk:
    +
    +    choco install webstorm
    +
    +Open Webstorm and select the \~/Github/security\_monkey/dart folder to open. We need webstorm to install the dart package. I believe it will popup and ask to install the dart package if you open the pubspec.yaml, or one of the dart files. Once the dart package is installed, go to File-\>Settings and select dart from the left column.
    +
    +-   Check the box Enable Dart Support ... and provide the path C:\\tools\\dart-sdk
    +-   Provide the path to dartium: C:\\tools\\dartium\\chrome.exe
    +
    +Before we instruct webstorm to open ui.html with Dartium, we'll need to update \`dart/lib/util/constants.dart\`:
    +
    +    library security_monkey.constants;
    +    ...
    +    // LOCAL DEV
    +    final String API_HOST = 'http://127.0.0.1:5000/api/1';
    +    //final bool REMOTE_AUTH = true;
    +
    +    // Same Box
    +    //final String API_HOST = '/api/1';
    +    final bool REMOTE_AUTH = false;
    +
    +You should now be able to use webstorm and dartium to work on the web ui.
    +
    +TODO: Determine if it makes sense to modify security\_monkey/\_\_init\_\_.py to change the static\_url path to the dart folder for webstorm development:
    +
    +    app = Flask(__name__, static_url_path='../dart/')
    +    # does this work?
    +
    +Log into Security Monkey
    +------------------------
    +
    +Logging into Security Monkey is done by accessing the login page: `http://127.0.0.1:8080/login`. Please note, that in the development environment, when you log in, you will be redirected to `http://127.0.0.1/None`. This only occurs in the development environment. You will need to navigate to the WebStorm address and port (you can simply use WebStorm to re-open the page in Daritum). Once you are back in Dartium, you will be greeted with the main Security Monkey interface.
    +
    +Watch an AWS Account
    +--------------------
    +
    +After you have registered a user, logged in, and re-opened Dartium from WebStorm, you should be at the main Security Monkey interface. Once here, click on Settings and on the *+* to add a new AWS account to sync.
    +
    +Manually Run the Account Watchers
    +---------------------------------
    +
    +Run the watchers to put some data in the database. :
    +
    +    cd ~/Github/security_monkey/
    +    python manage.py run_change_reporter all
    +
    +You can also run an individual watcher:
    +
    +    python manage.py find_changes -a all -m all
    +    python manage.py find_changes -a all -m iamrole
    +    python manage.py find_changes -a "My Test Account" -m iamgroup
    +
    +You can run the auditors against the items currently in the database:
    +
    +    python manage.py audit_changes -a all -m redshift --send_report=False
    +
    +Next Steps
    +----------
    +
    +Continue reading the [Contributing](contributing.md) guide for additional instructions.
    diff --git a/docs/dev_setup_windows.rst b/docs/dev_setup_windows.rst
    deleted file mode 100644
    index 53ea1f9cc..000000000
    --- a/docs/dev_setup_windows.rst
    +++ /dev/null
    @@ -1,268 +0,0 @@
    -************************************
    -Development Setup on Windows
    -************************************
    -
    -Please follow the instructions below for setting up the Security Monkey development environment on Windows 10.
    -
    -These instructions were created after consulting my install notes after recently getting a Windows 10 machine.  If you're a Powershell guru, please feel free to send a PR to fix any errors.  
    -
    -Windows Development
    -===================
    -I'm pretty happy with development on Windows.  Docker seems much easier to work with (No need for virtualbox). Gunicorn does not yet support Windows (Issue #524).  Luckily, we don't need Gunicorn for local dev.  Powershell is a worthy command line environment.  If all else fails, use WSL (Windows Subsystem for Linux).
    -
    -AWS Credentials
    -==========================
    -You will need to have the proper IAM Role configuration in place.  See `Configuration `_ for more details.  Additionally, you will need to have IAM keys available within your environment variables.  There are many ways to accomplish this.  Please see Amazon's documentation for additional details: http://docs.aws.amazon.com/general/latest/gr/getting-aws-sec-creds.html.
    -  
    -Additionally, see the boto documentation for more information: http://boto.readthedocs.org/en/latest/boto_config_tut.html
    -
    -Install Chocolatey
    -==========================
    -
    -Follow the instructions to install Chocolatey:
    -
    -https://chocolatey.org/install
    -
    -
    -Install Python
    -==========================
    -
    -Install python 2.7 with Chocolatey.::
    -
    -    choco install python2
    -
    -Setup Powershell
    -==========================
    -
    -The following steps are a summary of the steps at http://www.tylerbutler.com/2012/05/how-to-install-python-pip-and-virtualenv-on-windows-with-powershell/
    -
    -Execution Policy
    ------------------
    -
    -You'll need to set the execution policy.  There are a few options described at https://technet.microsoft.com/en-us/library/ee176961.aspx
    -
    -You'll need to run something like this::
    -
    -    Set-ExecutionPolicy RemoteSigned
    -
    -VirtualEnv
    -----------------
    -
    -Install virtualenv and virtualenvwrapper from pypi::
    -
    -    pip install virtualenv
    -    pip install virtualenvwrapper-powershell
    -
    -Try to import the powershell module::
    -
    -    Import-Module virtualenvwrapper
    -
    -At this point you may receive the following error::
    -
    -    Get-Content : Cannot find path 'Function:\TabExpansion' because it does not exist.
    -
    -You'll need to find and edit the file `virtualenvwrapperTabExpansion.psm1`.  On line 12, replace `Get-Content Function:TabExpansion` with `Get-Content Function:TabExpansion2`.  This should fix the import error.
    -
    -If the `~/.virtualenvs` folder wasn't created, do that now::
    -
    -    mkdir ~/.virtualenvs
    -
    -Automatically import the virtualenvwrapper module on powershell startup.
    -------------------------------------------------------------------------
    -
    -In bash, you would typically edit your `~/.bashrc` to load modules and setup your environment.  On Powershell, you'll use $profile.  Powershell has a few different $profiles you can use.  You can see them all with this command::
    -
    -    $profile | Format-List * -Force
    -    AllUsersAllHosts       : C:\Windows\System32\WindowsPowerShell\v1.0\profile.ps1
    -    AllUsersCurrentHost    : C:\Windows\System32\WindowsPowerShell\v1.0\Microsoft.PowerShell_profile.ps1
    -    CurrentUserAllHosts    : C:\Users\\Documents\WindowsPowerShell\profile.ps1
    -    CurrentUserCurrentHost : C:\Users\\Documents\WindowsPowerShell\Microsoft.PowerShell_profile.ps1
    -    Length                 : 77
    -
    -If you're not sure, we're going to use `CurrentUserAllHosts`.  If the file doesn't already exist, we can easily create it::
    -
    -    New-Item -Path $Profile.CurrentUserAllHosts -Type file -Force
    -
    -Now open it with a text editor and add this line::
    -
    -    Import-Module virtualenvwrapper
    -
    -All new powershell windows should have this module.::
    -
    -    Get-Command *virtualenv*
    -
    -Clone the Codebase
    -==================
    -
    -Navigate to wherever you like to mash on code and clone the repository.  We'll use `~\Github` here.::
    -
    -    cd ~
    -    mkdir Github
    -    cd Github
    -
    -If you don't already have git installed::
    -
    -    choco install git
    -
    -Clone security_monkey::
    -
    -    git clone git@github.com:Netflix/security_monkey.git
    -
    -Create a security_monkey virtualenv
    -===================================
    -
    -You can use the powershell syntax::
    -
    -    New-VirtualEnvironment security_monkey
    -
    -Or use the aliased commands you're probably more familiar with::
    -
    -    mkvirtualenv security_monkey
    -
    -Before we attempt to install `setup.py`, let's grab a couple modules from pypi so we don't need to compile them.::
    -
    -    pip install cryptography
    -    pip install bcrypt
    -
    -Install psycopg2
    -----------------
    -
    -This part seems a bit yucky.  Let me know if you find a cleaner way.
    -
    -* Go to http://www.stickpeople.com/projects/python/win-psycopg/
    -* Download the exe for your python version and processor architecture.  I'll continue with `psycopg2-2.6.2.win-amd64-py2.7-pg9.5.3-release.exe`
    -* In powershell, ensure your virtualenv is activated and install the exe::
    -
    -    workon security_monkey
    -    easy_install psycopg2-2.6.2.win-amd64-py2.7-pg9.5.3-release.exe
    -
    -Setting SECURITY_MONKEY_SETTINGS
    ---------------------------------
    -
    -You set powershell environment variables with `$env:`::
    -
    -    $env:SECURITY_MONKEY_SETTINGS = "C:\Users\\...\GitHub\security_monkey\env-config\config-local.py"
    -
    -It might be a good idea to drop this into your $profile as well...
    -
    -Install Setup.py
    -----------------
    -
    -With your virtualenv activated, this will install the security_monkey python module for dev::
    -
    -    cd ~/Github/security_monkey/
    -    workon security_monkey
    -    python setup.py develop
    -
    -We should be able to run `manage.py` to see usage information:
    -
    -    python manage.py
    -
    -Setup a development DB
    -----------------------
    -
    -Instead of installing postgres, let's use docker for the DB.  Windows has good docker support.  You should be able to use Chocolatey, but I downloaded it directly from their website.::
    -
    -    choco install docker
    -
    -I actually downloaded the stable branch from here: https://docs.docker.com/docker-for-windows/
    -
    -Once you have docker, pull a postgres container down.  I'm using this one: https://hub.docker.com/r/library/postgres/  You should be able to start it with this command::
    -
    -    docker run --name some-postgres
    -
    -Kitematic is a nice UI tool for managing running containers.  You can use it to set the postgres container to be reachable from localhost on 5432 and to set environment variables which the container uses to set the database name, username, password, etc.
    -
    -If you leave the DB paramaters at their default, you'll need to modify config-local.py::
    -
    -    SQLALCHEMY_DATABASE_URI = 'postgresql://postgres:mysecretpassword@localhost:5432/postgres'
    -
    -Install the security_monkey DB tables::
    -
    -    python manage.py db upgrade
    -
    -FYI - Navicat is a great tool for exploring the DB.
    -
    -Add Amazon Accounts
    -==========================
    -This will add Amazon owned AWS accounts to security monkey. ::
    -
    -    python manage.py amazon_accounts
    -
    -Add a user account
    -==========================
    -This will add a user account that can be used later to login to the web ui:
    -
    -    python manage.py create_user email@youremail.com Admin
    -
    -The first argument is the email address of the new user.  The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin].
    -
    -Start the Security Monkey API
    -=============================
    -This starts the REST API that the Angular application will communicate with. ::
    -
    -    python manage.py runserver
    -
    -Dart Development
    -----------------
    -
    -Install the dart SDK::
    -
    -    choco install dart-sdk
    -
    -This will install a few tools in C:\tools.  Let's install webstorm and configure it to use the dart-sdk::
    -
    -    choco install webstorm
    -
    -Open Webstorm and select the ~/Github/security_monkey/dart folder to open.  We need webstorm to install the dart package.  I believe it will popup and ask to install the dart package if you open the pubspec.yaml, or one of the dart files.  Once the dart package is installed, go to File->Settings and select dart from the left column.
    -
    -* Check the box `Enable Dart Support ...` and provide the path `C:\tools\dart-sdk`
    -* Provide the path to dartium: `C:\tools\dartium\chrome.exe`
    -
    -Before we instruct webstorm to open ui.html with Dartium, we'll need to update `dart/lib/util/constants.dart`::
    -
    -    library security_monkey.constants;
    -    ...
    -    // LOCAL DEV
    -    final String API_HOST = 'http://127.0.0.1:5000/api/1';
    -    //final bool REMOTE_AUTH = true;
    -
    -    // Same Box
    -    //final String API_HOST = '/api/1';
    -    final bool REMOTE_AUTH = false;
    -
    -You should now be able to use webstorm and dartium to work on the web ui.
    -
    -TODO: Determine if it makes sense to modify `security_monkey/__init__.py` to change the static_url path to the dart folder for webstorm development::
    -
    -    app = Flask(__name__, static_url_path='../dart/')
    -    # does this work?
    -
    -Log into Security Monkey
    -==========================
    -Logging into Security Monkey is done by accessing the login page: ``http://127.0.0.1:8080/login``.  Please note, that in the development environment, when you log in, you will be redirected to ``http://127.0.0.1/None``.  This only occurs in the development environment.  You will need to navigate to the WebStorm address and port (you can simply use WebStorm to re-open the page in Daritum).  Once you are back in Dartium, you will be greeted with the main Security Monkey interface.
    -
    -Watch an AWS Account
    -==========================
    -After you have registered a user, logged in, and re-opened Dartium from WebStorm, you should be at the main Security Monkey interface. Once here, click on Settings and on the *+* to add a new AWS account to sync.
    -
    -Manually Run the Account Watchers
    -=================================
    -Run the watchers to put some data in the database. ::
    -
    -    cd ~/Github/security_monkey/
    -    python manage.py run_change_reporter all
    -
    -You can also run an individual watcher::
    -
    -    python manage.py find_changes -a all -m all
    -    python manage.py find_changes -a all -m iamrole
    -    python manage.py find_changes -a "My Test Account" -m iamgroup
    -
    -You can run the auditors against the items currently in the database::
    -
    -    python manage.py audit_changes -a all -m redshift --send_report=False
    -
    -Next Steps
    -========================
    -Continue reading the `Contributing `_ guide for additional instructions.
    diff --git a/docs/development.md b/docs/development.md
    new file mode 100644
    index 000000000..414c6a1ef
    --- /dev/null
    +++ b/docs/development.md
    @@ -0,0 +1,173 @@
    +Development Guidelines
    +======================
    +
    +Adding a Watcher
    +----------------
    +
    +Watchers are located in the [watchers](../security_monkey/watchers/) directory. Some related watcher types are grouped together in common sub directories. An example would be IAM types.
    +
    +If a watcher is specific to an organization and is not intended to be contributed back to the OSS community, it should be placed under the watchers/custom directory.
    +
    +Any class that extends Watcher, overrides index and is located under the watchers directory will be dynamically loaded by the Security Monkey application at runtime.
    +
    +All watchers extend the Watcher class located in the [watcher.py](../security_monkey/watcher.py) file. This base class implements common functionality such as storing items to the database and determining which items are new, changed or deleted. Some related watchers also have a common base class to implement common functionality. Examples would be IAM watchers.
    +
    +Each watcher implementation must override the following:
    +
    +1.  The slurp() method pulls the current set of items in scheduled intervals.
    +2.  The watcher should implement a subclass of the ChangeItem found in the watcher module that is specific to the type the watcher will be pulling in the slurp method
    +3.  The member variables index must be overridden with a unique String that will identify the item type in the database.
    +4.  the member variables i\_am\_singular and i\_am\_plural must be overridden with unique values for use in logging.
    +
    +Watchers may benefit from using the joblib library to parallelize the processing of jobs. This will substantially increase performance of the watcher, especially for those requiring multiple API calls to fetch relevant data. Refer to [IAMRole Watcher](../security_monkey/watchers/iam/iam_role.py) for an example.
    +
    +Sample Watcher structure:
    +
    +    from security_monkey.watcher import Watcher
    +    from security_monkey.watcher import ChangeItem
    +
    +    class Sample(Watcher):
    +        index = 'sample'
    +        i_am_singular = 'Sample'
    +        i_am_plural = 'Samples'
    +
    +    def __init__(self, accounts=None, debug=False):
    +        super(Sample, self).__init__(accounts=accounts, debug=debug)
    +
    +    def slurp(self):
    +        # Look up relevant items, convert to list of SampleItem's, return list
    +
    +    class SampleItem(ChangeItem):
    +        def __init__(self, account=None, name=None, region=None, config={}):
    +            super(SampleItem, self).__init__(
    +                    index=Sample.index,
    +                    region=region,
    +                    account=account,
    +                    name=name,
    +                    new_config=config)
    +
    +New Watchers may also require additional code:
    +
    +-   If the api to access the system to be watched requires an explicit connection, connection functionality should be placed in the [sts\_connect](../security_monkey/common/sts_connect.py) module.
    +
    +### Adding an Auditor
    +
    +A watcher may have one or more associated Auditors that will be run against all new or modified items to determine if there are any security issues. In order to be associated with a Watcher, the auditor class must override the index to match that of it's associated watcher.
    +
    +If an auditor is specific to an organization and is not intended to be contributed back to the OSS community, it should be placed under the auditors/custom directory.
    +
    +Any class extending Auditor, overriding index and residing under the [auditors](../security_monkey/auditors/) directory. will be dynamically loaded and considered for execution agains a watcher. As with the related watchers, closely related auditors may be grouped within sub directories or have base classes with common functionality.
    +
    +All auditors override the [Auditor](../security_monkey/auditor.py) base class. Minimal functionality would override the index, i\_am\_singular and i\_am\_plural to match those in the associated watcher class. In addition, at least one method starting with 'check\_' would be present, as each method starting with 'check\_' will be run against new or changed items returned by the watcher:
    +
    +    from security_monkey.watchers.sample import Sample
    +
    +    class SampleAuditor(Auditor):
    +        index = Sample.index
    +        i_am_singular = Sample.i_am_singular
    +        i_am_plural = Sample.i_am_plural
    +
    +        def __init__(self, accounts=None, debug=False):
    +            super(SampleAuditor, self).__init__(accounts=accounts, debug=debug)
    +
    +        check_xxx(self, sample_item):
    +            # check the item for security risks
    +            if risk:
    +                self.add_issue(0, 'issue message', sample_item, notes='optional notes')
    +
    +If an issue is found, the 'check\_' method should call add\_issue to save the issue to the database.
    +
    +### Advanced Auditor Dependencies
    +
    +In some cases, an auditor needs information from technology types other than that of the associated watcher to determine if there is a security risk. One example is the determination of whether or not a route table is open to the internet. This requires the ability to match a route gateway with the results of the VPC internet gateway returned by the VPC watcher. The Auditor base class provides the method get\_watcher\_support\_items() to make the current results from one watcher available to another. In order to easily track which watchers and auditors are dependent on each other, an additional configuration is required in the in the Auditor class:
    +
    +    class SampleAuditor(Auditor):
    +        index = Sample.index
    +        i_am_singular = Sample.i_am_singular
    +        i_am_plural = Sample.i_am_plural
    +        support_watcher_indexes=[DependencyWatcher.index]
    +
    +Without this declaration the call to get\_watcher\_support\_items() will fail.
    +
    +There are instances where auditor logic is dependent not just on the items from other watchers, but also on the actual audit results. One example would be an IAM Group which was configured to use an AWS managed policy. If the managed policy contained a security risk, that risk would also be present in IAM Groups using this policy. The concept of auditor hierarchies was introduces to manage this.
    +
    +The base Auditor object contains a method called get\_auditor\_support\_items() that is similar to get\_watcher\_support\_items() except that in addition to the items returned by the watcher, it also returns the latest audit results for each item. This introduces the risk of circular dependencies because if AuditorA is dependent on AuditorB, in order to make AuditorB results available when AuditorA is run:
    +
    +1.  AuditorB must be run before AuditorA and
    +2.  AuditorB cannot be dependent an AuditorA, nor may any dependencies of AuditorB be dependent on AuditorA
    +
    +In order to manage this, the the auditor class required a list of dependent auditors to be declared:
    +
    +    class SampleAuditor(Auditor):
    +        index = Sample.index
    +        i_am_singular = Sample.i_am_singular
    +        i_am_plural = Sample.i_am_plural
    +        support_auditor_indexes=[DependencyAuditor.index]
    +
    +Without this declaration the call to get\_auditor\_support\_items() will fail.
    +
    +However, if any circular dependencies are detected the system will throw an exception with the the message at startup:
    +
    +    Detected circular dependency in support auditor {path of circular dependency}
    +
    +### Linking to Auditor Dependencies
    +
    +Typically, if an audit issue is dependent on another one, a the two should be linked:
    +
    +![image](images/linked_issue.png)
    +
    +This can be achieved by the [Auditor](../../security_monkey/auditor.py) link\_to\_support\_item\_issues() method.
    +
    +Custom Account Types
    +--------------------
    +
    +By default, Security Monkey runs against a basic AWS account but the custom account framework allows the developer to either extend an AWS account with additional metadata or to create a totally different account type to be monitored, such as an Active Directory account.
    +
    +All account types extend the [AccountManager](../security_monkey/account_manager.py) class and are located in the [account\_managers](../security_monkey/account_managers/) directory. Account types specific to an organization which are not intended to be contributed back to the OSS community should be placed in the [account\_managers/custom](../security_monkey/account_managers/custom) directory.
    +
    +### Data Structure
    +
    +The account contains five common fields:
    +
    +-   name is the Security Monkey application defined name
    +-   identifer is unique identifier of the account used to connect. For AWS accounts this would be the number
    +-   active is a flag that determines whether to report on the account
    +-   notes additional account information
    +-   third\_party AWS specific field that is used in Auditor.\_check\_cross\_account
    +
    +When creating a custom account type, additional fields may be added using the account\_manager.CustomFieldConfig objects which is used to display the fields on the Account Settings page:
    +
    +    class CustomFieldConfig(object):
    +        """
    +        Defines additional field types for custom account types
    +       """
    +       def __init__(self, name, label, db_item, tool_tip, password=False):
    +          super(CustomFieldConfig, self).__init__()
    +          self.name = name
    +          self.label = label
    +          self.db_item = db_item
    +          self.tool_tip = tool_tip
    +          self.password = password
    +
    +Values created from this page are saved in the DB using the datastore.AccountTypeCustomValues class is the db\_item flag is True.
    +
    +### Creating a Custom Account Type
    +
    +Custom account types must override three values:
    +
    +-   account\_type is a unique identifier for the type which is also used in the Watcher class to determine which watcher(s) to run against which account(s).
    +-   identifier\_label is used in the Account Settings page to display the label for the unique identifier for the account.
    +-   identifier\_tooltip is also used in the Account Settings page.
    +
    +The following overrides are optional:
    +
    +-   compatable\_account\_types is a list that will cause watchers of these account types to also be run against the account. This is used when an account type overrides another account type to add additional data elements.
    +-   custom\_field\_configs adds additional fields as described above
    +-   def \_load(self, account): this method is called to load custom fields from some third party datasource when the CustomFieldConfig.db\_item field is defined as False
    +
    +Examples of these overrides are available at:
    +
    +-   [Sample Active Directory Account Type](../security_monkey/account_managers/custom/sample_active_directory.py)
    +-   [Sample Active DB Extended AWS Account Type](../security_monkey/account_managers/custom/sample_db_extended_aws.py)
    +-   [Sample Active External Extended AWS Type](../security_monkey/account_managers/custom/sample_extended_aws.py)
    +
    diff --git a/docs/development.rst b/docs/development.rst
    deleted file mode 100644
    index 1a15f6559..000000000
    --- a/docs/development.rst
    +++ /dev/null
    @@ -1,216 +0,0 @@
    -======================
    -Development Guidelines
    -======================
    -
    -Adding a Watcher
    -================
    -Watchers are located in the `watchers <../security_monkey/watchers/>`_ directory. Some related
    -watcher types are grouped together in common sub directories. An example would be IAM types.
    -
    -If a watcher is specific to an organization and is not intended to be contributed
    -back to the OSS community, it should be placed under the watchers/custom directory.
    -
    -Any class that extends Watcher, overrides index and is located under the watchers
    -directory will be dynamically loaded by the Security Monkey application at runtime.
    -
    -All watchers extend the Watcher class located in the `watcher.py <../security_monkey/watcher.py>`_ file. This
    -base class implements common functionality such as storing items to the database and
    -determining which items are new, changed or deleted. Some related watchers also have
    -a common base class to implement common functionality. Examples would be IAM watchers.
    -
    -Each watcher implementation must override the following:
    -
    -1. The slurp() method pulls the current set of items in scheduled intervals.
    -2. The watcher should implement a subclass of the ChangeItem found in the watcher module that is specific to the type the watcher will be pulling in the slurp method
    -3. The member variables index must be overridden with a unique String that will identify the item type in the database.
    -4. the member variables i_am_singular and i_am_plural must be overridden with unique values for use in logging.
    -
    -Watchers may benefit from using the `joblib` library to parallelize the processing of jobs. This will substantially increase
    -performance of the watcher, especially for those requiring multiple API calls to fetch relevant data. Refer to
    -`IAMRole Watcher <../security_monkey/watchers/iam/iam_role.py>`_ for an example.
    -
    -Sample Watcher structure::
    -
    -    from security_monkey.watcher import Watcher
    -    from security_monkey.watcher import ChangeItem
    -
    -    class Sample(Watcher):
    -        index = 'sample'
    -        i_am_singular = 'Sample'
    -        i_am_plural = 'Samples'
    -
    -    def __init__(self, accounts=None, debug=False):
    -        super(Sample, self).__init__(accounts=accounts, debug=debug)
    -
    -    def slurp(self):
    -        # Look up relevant items, convert to list of SampleItem's, return list
    -
    -    class SampleItem(ChangeItem):
    -        def __init__(self, account=None, name=None, region=None, config={}):
    -            super(SampleItem, self).__init__(
    -                    index=Sample.index,
    -                    region=region,
    -                    account=account,
    -                    name=name,
    -                    new_config=config)
    -
    -New Watchers may also require additional code:
    -
    -- If the api to access the system to be watched requires an explicit connection, connection functionality should be placed in the `sts_connect <../security_monkey/common/sts_connect.py>`_ module.
    -
    -Adding an Auditor
    ------------------
    -A watcher may have one or more associated Auditors that will be run against all new or modified
    -items to determine if there are any security issues. In order to be associated with a Watcher,
    -the auditor class must override the index to match that of it's associated watcher.
    -
    -If an auditor is specific to an organization and is not intended to be contributed
    -back to the OSS community, it should be placed under the auditors/custom directory.
    -
    -Any class extending Auditor, overriding index and residing under the `auditors <../security_monkey/auditors/>`_ directory.
    -will be dynamically loaded and considered for execution agains a watcher. As with the related
    -watchers, closely related auditors may be grouped within sub directories or have base classes
    -with common functionality.
    -
    -
    -All auditors override the `Auditor <../security_monkey/auditor.py>`_ base class. Minimal
    -functionality would override the index, i_am_singular and i_am_plural to match those
    -in the associated watcher class. In addition, at least one method starting with 'check_'
    -would be present, as each method starting with 'check_' will be run against new or
    -changed items returned by the watcher::
    -
    -    from security_monkey.watchers.sample import Sample
    -
    -    class SampleAuditor(Auditor):
    -        index = Sample.index
    -        i_am_singular = Sample.i_am_singular
    -        i_am_plural = Sample.i_am_plural
    -
    -        def __init__(self, accounts=None, debug=False):
    -            super(SampleAuditor, self).__init__(accounts=accounts, debug=debug)
    -
    -        check_xxx(self, sample_item):
    -            # check the item for security risks
    -            if risk:
    -                self.add_issue(0, 'issue message', sample_item, notes='optional notes')
    -
    -If an issue is found, the 'check_' method should call add_issue to save the issue to
    -the database.
    -
    -Advanced Auditor Dependencies
    ------------------------------
    -In some cases, an auditor needs information from technology types other than that of
    -the associated watcher to determine if there is a security risk. One example is the
    -determination of whether or not a route table is open to the internet. This requires
    -the ability to match a route gateway with the results of the VPC internet gateway returned
    -by the VPC watcher. The Auditor base class provides the method get_watcher_support_items()
    -to make the current results from one watcher available to another. In order to easily track
    -which watchers and auditors are dependent on each other, an additional configuration
    -is required in the in the Auditor class::
    -
    -    class SampleAuditor(Auditor):
    -        index = Sample.index
    -        i_am_singular = Sample.i_am_singular
    -        i_am_plural = Sample.i_am_plural
    -        support_watcher_indexes=[DependencyWatcher.index]
    -
    -Without this declaration the call to get_watcher_support_items() will fail.
    -
    -There are instances where auditor logic is dependent not just on the items from other watchers,
    -but also on the actual audit results. One example would be an IAM Group which was
    -configured to use an AWS managed policy. If the managed policy contained a security
    -risk, that risk would also be present in IAM Groups using this policy. The concept
    -of auditor hierarchies was introduces to manage this.
    -
    -The base Auditor object contains a method called get_auditor_support_items() that is similar
    -to get_watcher_support_items() except that in addition to the items returned by the watcher,
    -it also returns the latest audit results for each item. This introduces the risk of circular
    -dependencies because if AuditorA is dependent on AuditorB, in order to make AuditorB results
    -available when AuditorA is run:
    -
    -1. AuditorB must be run before AuditorA and
    -2. AuditorB cannot be dependent an AuditorA, nor may any dependencies of AuditorB be dependent on AuditorA
    -
    -In order to manage this, the the auditor class required a list of dependent auditors to be declared::
    -
    -    class SampleAuditor(Auditor):
    -        index = Sample.index
    -        i_am_singular = Sample.i_am_singular
    -        i_am_plural = Sample.i_am_plural
    -        support_auditor_indexes=[DependencyAuditor.index]
    -
    -Without this declaration the call to get_auditor_support_items() will fail.
    -
    -However, if any circular dependencies are detected the system will throw an exception with the the message at startup::
    -
    -    Detected circular dependency in support auditor {path of circular dependency}
    -
    -Linking to Auditor Dependencies
    --------------------------------
    -
    -Typically, if an audit issue is dependent on another one, a the two should be linked:
    -
    -.. image:: images/linked_issue.png
    -
    -This can be achieved by the `Auditor <../../security_monkey/auditor.py>`_ link_to_support_item_issues() method.
    -
    -Custom Account Types
    -====================
    -By default, Security Monkey runs against a basic AWS account but the custom account
    -framework allows the developer to either extend an AWS account with additional metadata
    -or to create a totally different account type to be monitored, such as an Active Directory
    -account.
    -
    -All account types extend the `AccountManager <../security_monkey/account_manager.py>`_ class and are located
    -in the `account_managers <../security_monkey/account_managers/>`_ directory. Account
    -types specific to an organization which are not intended to be contributed back to
    -the OSS community should be placed in the `account_managers/custom <../security_monkey/account_managers/custom>`_ directory.
    -
    -Data Structure
    ---------------
    -The account contains five common fields:
    -
    -- name is the Security Monkey application defined name
    -- identifer is unique identifier of the account used to connect. For AWS accounts this would be the number
    -- active is a flag that determines whether to report on the account
    -- notes additional account information
    -- third_party AWS specific field that is used in Auditor._check_cross_account
    -
    -When creating a custom account type, additional fields may be added using the
    -account_manager.CustomFieldConfig objects which is used to display the fields on
    -the Account Settings page::
    -
    -    class CustomFieldConfig(object):
    -        """
    -        Defines additional field types for custom account types
    -       """
    -       def __init__(self, name, label, db_item, tool_tip, password=False):
    -          super(CustomFieldConfig, self).__init__()
    -          self.name = name
    -          self.label = label
    -          self.db_item = db_item
    -          self.tool_tip = tool_tip
    -          self.password = password
    -
    -Values created from this page are saved in the DB using the datastore.AccountTypeCustomValues
    -class is the db_item flag is True.
    -
    -Creating a Custom Account Type
    -------------------------------
    -Custom account types must override three values:
    -
    -- account_type is a unique identifier for the type which is also used in the Watcher class to determine which watcher(s) to run against which account(s).
    -- identifier_label is used in the Account Settings page to display the label for the unique identifier for the account.
    -- identifier_tooltip is also used in the Account Settings page.
    -
    -The following overrides are optional:
    -
    -- compatable_account_types is a list that will cause watchers of these account types to also be run against the account. This is used when an account type overrides another account type to add additional data elements.
    -- custom_field_configs adds additional fields as described above
    -- def _load(self, account): this method is called to load custom fields from some third party datasource when the CustomFieldConfig.db_item field is defined as False
    -
    -Examples of these overrides are available at:
    -
    -- `Sample Active Directory Account Type <../security_monkey/account_managers/custom/sample_active_directory.py>`_
    -- `Sample Active DB Extended AWS Account Type <../security_monkey/account_managers/custom/sample_db_extended_aws.py>`_
    -- `Sample Active External Extended AWS Type <../security_monkey/account_managers/custom/sample_extended_aws.py>`_
    diff --git a/docs/docker.md b/docs/docker.md
    new file mode 100644
    index 000000000..3c6a91466
    --- /dev/null
    +++ b/docs/docker.md
    @@ -0,0 +1,89 @@
    +Docker Instructions
    +===================
    +
    +The docker-compose.yml file describes the SecurityMonkey environment. This is intended for local development with the intention of deploying SecurityMonkey containers with a Docker Orchestration tool like Kubernetes.
    +
    +The Dockerfile builds SecurityMonkey into a container with several different entrypoints. These are for the different responsibilities SecurityMonkey has. Also, the docker/nginx/Dockerfile file is used to build an NGINX container that will front the API, serve the static assets, and provide TLS.
    +
    +Quick Start:
    +------------
    +
    +Define your specific settings in **secmonkey.env** file. For example, this file will look like:
    +
    +    AWS_ACCESS_KEY_ID=
    +    AWS_SECRET_ACCESS_KEY=
    +    SECURITY_MONKEY_POSTGRES_HOST=postgres
    +    SECURITY_MONKEY_FQDN=127.0.0.1
    +    # Must be false if HTTP
    +    SESSION_COOKIE_SECURE=False
    +
    +Next, you can build all the containers by running:
    +
    +    $ docker-compose build
    +
    +On a fresh database instance, various initial configuration must be run such as database setup, initial user creation ( / admin), etc. You can run the `init` container via:
    +
    +    $ docker-compose -f docker-compose.init.yml up -d
    +
    +Before you bring the containers up, you need to add an AWS account for the scheduler to monitor:
    +
    +    $ python manage.py add_account_aws --number $account --name $name -r SecurityMonkey
    +
    +Now that the database is setup, you can start up the remaining containers (Security Monkey, nginx, and the scheduler) via:
    +
    +    $ docker-compose up -d
    +
    +You can stop the containers with:
    +
    +    $ docker-compose stop
    +
    +Otherwise you can shutdown and clean the images and volumes with:
    +
    +    $ docker-compose down
    +
    +Commands:
    +---------
    +
    +    $ docker-compose build [api | scheduler | nginx | data]
    +
    +    $ docker-compose up -d [postgres | api | scheduler | nginx | data]
    +
    +    $ docker-compose restart [postgres | api | scheduler | nginx | data]
    +
    +    $ docker-compose stop
    +
    +    $ docker-compose down
    +
    +More Info:
    +----------
    +
    +You can get a shell thanks to the docker-compose.shell.yml override:
    +
    +    $ docker-compose -f docker-compose.yml -f docker-compose.shell.yml up -d data
    +    $ docker attach $(docker ps -aqf "name=secmonkey-data")
    +
    +This allows you to access SecurityMonkey code, and run manual configurations such as:
    +
    +    $ python manage.py create_user admin@example.com Admin
    +
    +and/or:
    +
    +    $ python manage.py add_account_aws --number $account --name $name -r SecurityMonkey
    +
    +This container is useful for local development. It is not required otherwise.
    +
    +Tips and tricks:
    +----------------
    +
    +If you have to restart the scheduler, you don't have to restart all the stack. Just run:
    +
    +    $ docker-compose restart scheduler
    +
    +If you want to persist the DB data, create a postgres-data directory in the repository root:
    +
    +    $ mkdir postgres-data
    +
    +and uncomment these two lines in docker-compose.yml (in the postgres section):
    +
    +    #volumes:
    +    #    - ./postgres-data/:/var/lib/postgresql/data
    diff --git a/docs/docker.rst b/docs/docker.rst
    deleted file mode 100644
    index 0c2309aed..000000000
    --- a/docs/docker.rst
    +++ /dev/null
    @@ -1,90 +0,0 @@
    -Docker Instructions
    -===================
    -
    -The docker-compose.yml file describes the SecurityMonkey environment. This is intended for local development with the intention of deploying SecurityMonkey containers with a Docker Orchestration tool like Kubernetes.
    -
    -The Dockerfile builds SecurityMonkey into a container with several different entrypoints. These are for the different responsibilities SecurityMonkey has.
    -Also, the docker/nginx/Dockerfile file is used to build an NGINX container that will front the API, serve the static assets, and provide TLS.
    -
    -
    -Quick Start:
    -------------
    -Define your specific settings in **secmonkey.env** file. For example, this file will look like::
    -
    -  AWS_ACCESS_KEY_ID=
    -  AWS_SECRET_ACCESS_KEY=
    -  SECURITY_MONKEY_POSTGRES_HOST=postgres
    -  SECURITY_MONKEY_FQDN=127.0.0.1
    -  # Must be false if HTTP
    -  SESSION_COOKIE_SECURE=False
    -
    -Next, you can build all the containers by running::
    -
    -  $ docker-compose build
    -
    -On a fresh database instance, various initial configuration must be run such as database setup, initial user creation (admin@example.org / admin), etc. You can run the ``init`` container via::
    -
    -  $ docker-compose -f docker-compose.init.yml up -d
    -
    -Now that the database is setup, you can start up the remaining containers (Security Monkey, nginx, and the scheduler) via::
    -
    -  $ docker-compose up -d
    -
    -You can stop the containers with::
    -
    -  $ docker-compose stop
    -
    -Otherwise you can shutdown and clean the images and volumes with::
    -
    -  $ docker-compose down
    -
    -
    -Commands:
    ----------
    -::
    -
    -  $ docker-compose build [api | scheduler | nginx | data]
    -
    -  $ docker-compose up -d [postgres | api | scheduler | nginx | data]
    -
    -  $ docker-compose restart [postgres | api | scheduler | nginx | data]
    -
    -  $ docker-compose stop
    -
    -  $ docker-compose down
    -
    -
    -More Info:
    -----------
    -
    -You can get a shell thanks to the docker-compose.shell.yml override::
    -
    -  $ docker-compose -f docker-compose.yml -f docker-compose.shell.yml up -d data
    -  $ docker attach $(docker ps -aqf "name=secmonkey-data")
    -
    -This allows you to access SecurityMonkey code, and run manual configurations such as::
    -
    -  $ python manage.py create_user admin@example.com Admin
    -
    -and/or::
    -
    -  $ python manage.py add_account --number $account --name $name -r SecurityMonkey
    -
    -This container is useful for local development. It is not required otherwise.
    -
    -
    -Tips and tricks:
    -----------------
    -
    -If you have to restart the scheduler, you don't have to restart all the stack. Just run::
    -
    -  $ docker-compose restart scheduler
    -
    -If you want to persist the DB data, create a postgres-data directory in the repository root::
    -
    -  $ mkdir postgres-data
    -
    -and uncomment these two lines in docker-compose.yml (in the postgres section)::
    -
    -  #volumes:
    -  #    - ./postgres-data/:/var/lib/postgresql/data
    diff --git a/docs/iam_aws.md b/docs/iam_aws.md
    new file mode 100644
    index 000000000..fdc576759
    --- /dev/null
    +++ b/docs/iam_aws.md
    @@ -0,0 +1,230 @@
    +IAM Role Setup on AWS
    +=====================
    +
    +We need to create two roles for security monkey. The first role will be an instance profile that we will launch security monkey into. The permissions on this role allow the monkey to use STS to assume to other roles as well as use SES to send email.
    +
    +Creating SecurityMonkeyInstanceProfile Role
    +-------------------------------------------
    +
    +Create a new role and name it "SecurityMonkeyInstanceProfile":
    +
    +![image](images/resized_name_securitymonkeyinstanceprofile_role.png)
    +
    +Select "Amazon EC2" under "AWS Service Roles".
    +
    +![image](images/resized_create_role.png)
    +
    +Select "Custom Policy":
    +
    +![image](images/resized_role_policy.png)
    +
    +Paste in this JSON with the name "SecurityMonkeyLaunchPerms":
    +
    +~~~~ {.sourceCode .json}
    +{
    +  "Version": "2012-10-17",
    +  "Statement": [
    +    {
    +      "Effect": "Allow",
    +      "Action": [
    +        "ses:SendEmail"
    +      ],
    +      "Resource": "*"
    +    },
    +    {
    +      "Effect": "Allow",
    +      "Action": "sts:AssumeRole",
    +      "Resource": "arn:aws:iam::*:role/SecurityMonkey"
    +    }
    +  ]
    +}
    +~~~~
    +
    +Review and create your new role:
    +
    +![image](images/resized_role_confirmation.png)
    +
    +Creating SecurityMonkey Role
    +----------------------------
    +
    +Create a new role and name it "SecurityMonkey":
    +
    +![image](images/resized_name_securitymonkey_role.png)
    +
    +Select "Amazon EC2" under "AWS Service Roles".
    +
    +![image](images/resized_create_role.png)
    +
    +Select "Custom Policy":
    +
    +![image](images/resized_role_policy.png)
    +
    +Paste in this JSON with the name "SecurityMonkeyReadOnly":
    +
    +~~~~ {.sourceCode .json}
    +{
    +    "Version": "2012-10-17",
    +    "Statement": [
    +        {
    +            "Action": [
    +                "acm:describecertificate",
    +                "acm:listcertificates",
    +                "cloudtrail:describetrails",
    +                "cloudtrail:gettrailstatus",
    +                "config:describeconfigrules",
    +                "config:describeconfigurationrecorders",
    +                "directconnect:describeconnections",
    +                "ec2:describeaddresses",
    +                "ec2:describedhcpoptions",
    +                "ec2:describeflowlogs",
    +                "ec2:describeimages",
    +                "ec2:describeinstances",
    +                "ec2:describeinternetgateways",
    +                "ec2:describekeypairs",
    +                "ec2:describenatgateways",
    +                "ec2:describenetworkacls",
    +                "ec2:describenetworkinterfaces",
    +                "ec2:describeregions",
    +                "ec2:describeroutetables",
    +                "ec2:describesecuritygroups",
    +                "ec2:describesnapshots",
    +                "ec2:describesubnets",
    +                "ec2:describetags",
    +                "ec2:describevolumes",
    +                "ec2:describevpcendpoints",
    +                "ec2:describevpcpeeringconnections",
    +                "ec2:describevpcs",
    +                "ec2:describevpngateways",
    +                "elasticloadbalancing:describeloadbalancerattributes",
    +                "elasticloadbalancing:describeloadbalancerpolicies",
    +                "elasticloadbalancing:describeloadbalancers",
    +                "es:describeelasticsearchdomainconfig",
    +                "es:listdomainnames",
    +                "iam:getaccesskeylastused",
    +                "iam:getgroup",
    +                "iam:getgrouppolicy",
    +                "iam:getloginprofile",
    +                "iam:getpolicyversion",
    +                "iam:getrole",
    +                "iam:getrolepolicy",
    +                "iam:getservercertificate",
    +                "iam:getuser",
    +                "iam:getuserpolicy",
    +                "iam:listaccesskeys",
    +                "iam:listattachedgrouppolicies",
    +                "iam:listattachedrolepolicies",
    +                "iam:listattacheduserpolicies",
    +                "iam:listentitiesforpolicy",
    +                "iam:listgrouppolicies",
    +                "iam:listgroups",
    +                "iam:listinstanceprofilesforrole",
    +                "iam:listmfadevices",
    +                "iam:listpolicies",
    +                "iam:listrolepolicies",
    +                "iam:listroles",
    +                "iam:listservercertificates",
    +                "iam:listsigningcertificates",
    +                "iam:listuserpolicies",
    +                "iam:listusers",
    +                "kms:describekey",
    +                "kms:getkeypolicy",
    +                "kms:listaliases",
    +                "kms:listgrants",
    +                "kms:listkeypolicies",
    +                "kms:listkeys",
    +                "lambda:listfunctions",
    +                "rds:describedbclusters",
    +                "rds:describedbclustersnapshots",
    +                "rds:describedbinstances",
    +                "rds:describedbsecuritygroups",
    +                "rds:describedbsnapshots",
    +                "rds:describedbsubnetgroups",
    +                "redshift:describeclusters",
    +                "route53:listhostedzones",
    +                "route53:listresourcerecordsets",
    +                "route53domains:listdomains",
    +                "route53domains:getdomaindetail",
    +                "s3:getaccelerateconfiguration",
    +                "s3:getbucketacl",
    +                "s3:getbucketcors",
    +                "s3:getbucketlocation",
    +                "s3:getbucketlogging",
    +                "s3:getbucketnotification",
    +                "s3:getbucketpolicy",
    +                "s3:getbuckettagging",
    +                "s3:getbucketversioning",
    +                "s3:getbucketwebsite",
    +                "s3:getlifecycleconfiguration",
    +                "s3:listbucket",
    +                "s3:listallmybuckets",
    +                "s3:getreplicationconfiguration",
    +                "s3:getanalyticsconfiguration",
    +                "s3:getmetricsconfiguration",
    +                "s3:getinventoryconfiguration",
    +                "ses:getidentityverificationattributes",
    +                "ses:listidentities",
    +                "ses:listverifiedemailaddresses",
    +                "ses:sendemail",
    +                "sns:gettopicattributes",
    +                "sns:listsubscriptionsbytopic",
    +                "sns:listtopics",
    +                "sqs:getqueueattributes",
    +                "sqs:listqueues"
    +            ],
    +            "Effect": "Allow",
    +            "Resource": "*"
    +        }
    +    ]
    +}
    +~~~~
    +
    +Review and create the new role.
    +
    +Allow SecurityMonkeyInstanceProfile to AssumeRole to SecurityMonkey
    +-------------------------------------------------------------------
    +
    +You should now have two roles available in your AWS Console:
    +
    +![image](images/resized_both_roles.png)
    +
    +Select the "SecurityMonkey" role and open the "Trust Relationships" tab.
    +
    +![image](images/resized_edit_trust_relationship.png)
    +
    +Edit the Trust Relationship and paste this in:
    +
    +~~~~ {.sourceCode .json}
    +{
    +  "Version": "2008-10-17",
    +  "Statement": [
    +    {
    +      "Sid": "",
    +      "Effect": "Allow",
    +      "Principal": {
    +        "AWS": [
    +          "arn:aws:iam:::role/SecurityMonkeyInstanceProfile"
    +        ]
    +      },
    +      "Action": "sts:AssumeRole"
    +    }
    +  ]
    +}
    +~~~~
    +
    +Adding more accounts
    +--------------------
    +
    +To have your instance of security monkey monitor additional accounts, you must add a SecurityMonkey role in the new account. Follow the instructions above to create the new SecurityMonkey role. The Trust Relationship policy should have the account ID of the account where the security monkey instance is running.
    +
    +**Note**
    +
    +Additional SecurityMonkeyInstanceProfile roles are not required. You only need to create a new SecurityMonkey role.
    +
    +**Note**
    +
    +You will also need to add the new account in the Web UI, and restart the scheduler. More information on how do to this will be presented later in this guide.
    +
    +Next:
    +-----
    +
    +- [Back to the Quickstart](quickstart.md#database)
    \ No newline at end of file
    diff --git a/docs/iam_gcp.md b/docs/iam_gcp.md
    new file mode 100644
    index 000000000..ff2334419
    --- /dev/null
    +++ b/docs/iam_gcp.md
    @@ -0,0 +1,37 @@
    +IAM Role Setup on GCP
    +=====================
    +
    +Below describes how to install Security Monkey on GCP.
    +
    +Install gcloud
    +---------------
    +
    +If you haven't already, install *gcloud* from the [downloads](https://cloud.google.com/sdk/downloads) page.  *gcloud* enables you to administer VMs, IAM policies, services and more from the command line.
    +
    +Setup Service Account
    +---------------------
    +
    +To restrict which permissions Security Monkey has to your projects, we'll create a [Service Account](https://cloud.google.com/compute/docs/access/service-accounts) with a special role.
    +
    +- Access the [Google console](https://console.cloud.google.com/home/dashboard).
    +- Under "IAM & Admin", select "Service accounts."
    +- Select "Create Service Account".
    +  - Name: "securitymonkey"
    +  - Add Role "IAM->SecurityReviewer"
    +  - Add Role "Project->Viewer"
    +  - If you're going to monitor your GCP services from an AWS instance, check the box "Furnish a new private key" and ensure JSON is selected as the Key type.
    +  - Hit "Create"
    +
    +![Create Service Account](images/create_service_account.png "Create Service Account")
    +
    + - Select the newly created "securitymonkey" services account and click on "Permissions".
    +   -  Type in your Google email adddress and select the Owner role.
    +   -  Press "Add".
    +
    +![Add User to Service Account](images/add_user_to_service_account.png "Add User to Service Account")
    +
    +
    +Next:
    +-----
    +
    +- [Back to the Quickstart](quickstart.md#database)
    \ No newline at end of file
    diff --git a/docs/images/Security_Monkey.png b/docs/images/Security_Monkey.png
    new file mode 100644
    index 0000000000000000000000000000000000000000..e380501495f47b06f3a44bd928d75aaf0bbf918a
    GIT binary patch
    literal 23862
    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	GVP<+OI?tt6Cq?pj_g@%(NU{
    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

    c&u9?hSGATUHB6&BXQV{6KRl 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 QZ^yvPP|sMm3gHm(-|OxNcK`qY literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index e117c1d1a..000000000 --- a/docs/index.rst +++ /dev/null @@ -1,51 +0,0 @@ -.. security_monkey documentation master file, created by - sphinx-quickstart on Sat Jun 7 18:43:48 2014. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. -.. include:: ../README.rst - - -Quick Start -=========== - -.. toctree:: - :maxdepth: 3 - - quickstart - - -Changelog -========= - -.. toctree:: - :maxdepth: 3 - - changelog - -API Reference -============= - -Class and method level definitions and documentation. - -.. toctree:: - :maxdepth: 5 - - api/index - -Miscellaneous -============= - -.. toctree:: - :maxdepth: 3 - - misc - -About -===== - -.. toctree:: - :maxdepth: 2 - - authors - changelog - contributing diff --git a/docs/instance_launch_aws.md b/docs/instance_launch_aws.md new file mode 100644 index 000000000..89af89ca8 --- /dev/null +++ b/docs/instance_launch_aws.md @@ -0,0 +1,45 @@ +Launch an AWS Instance +====================== + +Netflix monitors dozens AWS accounts easily on a single m3.large instance. For this guide, we will launch a m1.small. + +In the console, start the process to launch a new Ubuntu instance. The screenshot below shows EC2 classic, but you can also launch this in external VPC.: + +![image](images/resized_ubuntu.png) + +Select an m1.small and select "Next: Configure Instance Details". + +**Note: Do not select "Review and Launch". We need to launch this instance in a specific role.** + +![image](images/resized_select_ec2_instance.png) + +Under "IAM Role", select SecurityMonkeyInstanceProfile: + +![image](images/resized_launch_instance_with_role.png) + +You may now launch the new instance. Please take note of the "Public DNS" entry. We will need that later when configuring security monkey. + +![image](images/resized_launched_sm.png) + +Now may also be a good time to edit the "launch-wizard-1" security group to restrict access to your IP. Make sure you leave TCP 22 open for ssh and TCP 443 for HTTPS. + +Keypair +------- + +You may be prompted to download a keypair. You should protect this keypair; it is used to provide ssh access to the new instance. Put it in a safe place. You will need to change the permissions on the keypair to 400: + + $ chmod 400 SecurityMonkeyKeypair.pem + +Connecting to your new instance: +-------------------------------- + +We will connect to the new instance over ssh: + + $ ssh -i SecurityMonkeyKeyPair.pem -l ubuntu + +Replace the last parameter (\) with the Public IP of your instance. + +Next: +----- + +- [Back to the Quickstart](quickstart.md#install-security-monkey-on-your-instance) \ No newline at end of file diff --git a/docs/instance_launch_gcp.md b/docs/instance_launch_gcp.md new file mode 100644 index 000000000..a16ff10e8 --- /dev/null +++ b/docs/instance_launch_gcp.md @@ -0,0 +1,33 @@ +Launch a GCP instance +===================== + +Create an instance running Ubuntu 14.04 LTS using our 'securitymonkey' service account. + +Navigate to the [Create Instance page](https://console.developers.google.com/compute/instancesAdd). Fill in the following fields: + +- **Name**: securitymonkey +- **Zone**: If using GCP Cloud SQL, select the same zone here. +- **Machine Type**: 1vCPU, 3.75GB (minimum; also known as n1-standard-1) +- **Boot Disk**: Ubuntu 14.04 LTS +- **Service Account**: securitymonkey + +Click the *Create* button to create the instance. + +Install gcloud +-------------- + +If you haven't already, install *gcloud* from the [downloads](https://cloud.google.com/sdk/downloads) page. *gcloud* enables you to administer VMs, IAM policies, services and more from the command line. + +Connecting to your new instance: +-------------------------------- + +We will connect to the new instance over ssh with the gcloud command: + + $ gcloud compute ssh @ --zone us-central + +Replace the first parameter `` with the username you authenticated gcloud with. Replace the last parameter `` with the Public IP of your instance. + +Next: +----- + +- [Back to the Quickstart](quickstart.md#install-security-monkey-on-your-instance) \ No newline at end of file diff --git a/docs/jirasync.md b/docs/jirasync.md new file mode 100644 index 000000000..6e4e7f164 --- /dev/null +++ b/docs/jirasync.md @@ -0,0 +1,72 @@ +JIRA Synchronization +==================== + +Overview +-------- + +JIRA synchronization is a feature that allows Security Monkey to automatically create and update JIRA tickets based on issues it finds. Each ticket corresponds to a single type of issue for a single account. The tickets contain the number of open issues and a link back to the Security Monkey details page for that issue type. + +Configuring JIRA Synchronization +-------------------------------- + +To use JIRA sync, you will need to create a YAML configuration file, specifying several settings. + +~~~~ {.sourceCode .yaml + server: https://jira.example.com + account: securitymonkey-service + password: hunter2 + project: SECURITYMONKEY + issue_type: Task + url: https://securitymonkey.example.com + ip_proxy: example.proxy.com + port_proxy: 443 + assignee: SecMonkeyJIRA} +~~~~ + +`server` - The location of the JIRA server. `account` - The account with which Security Monkey will create tickets `password` - The password to the account. `project` - The project key where tickets will be created. `issue_type` - The type of issue each ticket will be created as. `url` - The URL for Security Monkey. This will be used to create links back to Security Monkey. `disable_transitions` - If true, Security Monkey will not close or reopen tickets. This is false by default. `ip_proxy` - Optional proxy endpoint for JIRA client. NOTE: Proxy authentication not currently supported. `port_proxy` - Optional proxy port for JIRA client. NOTE: Proxy authentication not currently supported. `assignee` - Optional default assignee for generated JIRA tickets. Assignee should be username. + +### Using JIRA Synchronization + +To use JIRA sync, set the environment variable `SECURITY_MONKEY_JIRA_SYNC` to the location of the YAML configuration file. This file will be loaded once when the application starts. If set, JIRA sync will run for each account after the auditors run. You can also manually run a sync through `manage.py`. + +`python manage.py sync_jira` + +Details +------- + +Tickets are created with the summary: + +` - - ` + +And the description: + +`` ` This ticket was automatically created by Security Monkey. DO NOT EDIT ANYTHING BELOW THIS LINE Number of issues: X Account: Y View on Security Monkey Last Updated: TIMESTAMP ``\` + +Security Monkey will update tickets based on the summary. If it is changed in any way, Security Monkey will open a new ticket instead of updating the existing one. When updating, the number of issues and last updated fields will change. Security Monkey will preserve all text in the description before "This ticket was automatically created by Security Monkey", and remove anything after. + +Security Monkey will automatically close tickets when they have zero open issues, by setting the state of the ticket to "Closed". Likewise, it will reopen a closed ticket if there are new open issues. This feature can be disabled by setting `disable_transitions: true` in the config. + +Justifying an issue will cause it to no longer be counted as an open issue. + +If an auditor is disabled, its issues will no longer be updated, opened or closed. + +Logs +---- + +JIRA sync will generate the following log lines. + +`Created issue

    ` (debug) - A new JIRA ticket was opened. + +`Updated issue ` (debug) - An existing ticket was updated. + +`Error creating ticket: ` (error) - An error was encounted when creating a ticket. This could be due to a misconfigured project name, issue type or connectivity problems. + +`JIRA sync configuration missing required field: ` (error) - One of the 6 required fields in the YAML configuration is missing. + +`Error opening JIRA sync configuration file: ` (error) - Security Monkey could not open the file located at `SECURITY_MONKEY_JIRA_SYNC`. + +`JIRA sync configuration file contains malformed YAML: ` (error) - The YAML could not be parsed. + +`Syncing issues with Jira` (info) - Auditors have finished running and JIRA sync is starting. + +`Error opening/closing ticket: `: (error) - Security Monkey tried to set an issue to "Closed" or "Open". This error may mean that these transitions are named differently in your JIRA project. To disable ticket transitions, set `disable_transitions: true` in the config file. diff --git a/docs/jirasync.rst b/docs/jirasync.rst deleted file mode 100644 index fd1b61adc..000000000 --- a/docs/jirasync.rst +++ /dev/null @@ -1,97 +0,0 @@ -==================== -JIRA Synchronization -==================== - -Overview -============= - -JIRA synchronization is a feature that allows Security Monkey to automatically create and update JIRA tickets based on issues it finds. -Each ticket corresponds to a single type of issue for a single account. The tickets contain the number of open issues and a link back -to the Security Monkey details page for that issue type. - -Configuring JIRA Synchronization -=================================== - -To use JIRA sync, you will need to create a YAML configuration file, specifying several settings. - -.. code-block:: yaml - server: https://jira.example.com - account: securitymonkey-service - password: hunter2 - project: SECURITYMONKEY - issue_type: Task - url: https://securitymonkey.example.com - ip_proxy: example.proxy.com - port_proxy: 443 - assignee: SecMonkeyJIRA - -``server`` - The location of the JIRA server. -``account`` - The account with which Security Monkey will create tickets -``password`` - The password to the account. -``project`` - The project key where tickets will be created. -``issue_type`` - The type of issue each ticket will be created as. -``url`` - The URL for Security Monkey. This will be used to create links back to Security Monkey. -``disable_transitions`` - If true, Security Monkey will not close or reopen tickets. This is false by default. -``ip_proxy`` - Optional proxy endpoint for JIRA client. NOTE: Proxy authentication not currently supported. -``port_proxy`` - Optional proxy port for JIRA client. NOTE: Proxy authentication not currently supported. -``assignee`` - Optional default assignee for generated JIRA tickets. Assignee should be username. - -Using JIRA Synchronization ---------------------------- - -To use JIRA sync, set the environment variable ``SECURITY_MONKEY_JIRA_SYNC`` to the location of the YAML configuration file. -This file will be loaded once when the application starts. If set, JIRA sync will run for each account after the auditors run. -You can also manually run a sync through ``manage.py``. - -``python manage.py sync_jira`` - -Details -======= - -Tickets are created with the summary: - -`` - - `` - -And the description: - -``` -This ticket was automatically created by Security Monkey. DO NOT EDIT ANYTHING BELOW THIS LINE -Number of issues: X -Account: Y -View on Security Monkey -Last Updated: TIMESTAMP -``` - -Security Monkey will update tickets based on the summary. If it is changed in any way, Security Monkey will -open a new ticket instead of updating the existing one. When updating, the number of issues and last updated fields will change. Security Monkey -will preserve all text in the description before "This ticket was automatically created by Security Monkey", and remove anything after. - -Security Monkey will automatically close tickets when they have zero open issues, by setting the state of the ticket to "Closed". Likewise, it will -reopen a closed ticket if there are new open issues. This feature can be disabled by setting ``disable_transitions: true`` in the config. - -Justifying an issue will cause it to no longer be counted as an open issue. - -If an auditor is disabled, its issues will no longer be updated, opened or closed. - -Logs -==== - -JIRA sync will generate the following log lines. - -``Created issue `` (debug) - A new JIRA ticket was opened. - -``Updated issue `` (debug) - An existing ticket was updated. - -``Error creating ticket: `` (error) - An error was encounted when creating a ticket. This could be due to a misconfigured project name, issue type -or connectivity problems. - -``JIRA sync configuration missing required field: `` (error) - One of the 6 required fields in the YAML configuration is missing. - -``Error opening JIRA sync configuration file: `` (error) - Security Monkey could not open the file located at ``SECURITY_MONKEY_JIRA_SYNC``. - -``JIRA sync configuration file contains malformed YAML: `` (error) - The YAML could not be parsed. - -``Syncing issues with Jira`` (info) - Auditors have finished running and JIRA sync is starting. - -``Error opening/closing ticket: ``: (error) - Security Monkey tried to set an issue to "Closed" or "Open". This error may mean that these transitions -are named differently in your JIRA project. To disable ticket transitions, set ``disable_transitions: true`` in the config file. diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 1d6c9eb25..000000000 --- a/docs/make.bat +++ /dev/null @@ -1,242 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=_build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . -set I18NSPHINXOPTS=%SPHINXOPTS% . -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\security_monkey.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\security_monkey.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %BUILDDIR%/.. - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -:end diff --git a/docs/misc.md b/docs/misc.md new file mode 100644 index 000000000..a23fbc301 --- /dev/null +++ b/docs/misc.md @@ -0,0 +1,176 @@ +Miscellaneous +============= + +Force Audit +----------- + +Sometimes you will want to force an audit even though there is no configuration change in AWS resources. + +For instance when you change a whitelist or add a 3rd party account, configuration will not be audited again until the daily check at 10am. + +In this case, you can force an audit by running: + +~~~~ {.sourceCode .bash} +python manage.py audit_changes -m s3 +~~~~ + +For an email by adding `-r True`: + +~~~~ {.sourceCode .bash} +python manage.py audit_changes -m s3 -r True +~~~~ + +Scheduler Hacking +----------------- + +Edit `security_monkey/scheduler.py` to change daily check schedule: + + scheduler.add_cron_job(_audit_changes, hour=10, day_of_week="mon-fri", args=[account, auditors, True]) + +Edit `security_monkey/watcher.py` to change check interval from every 15 minutes: + + self.interval = 15 + +Overriding and Disabling Audit Checks +------------------------------------- + +Auditor checks may be disabled or the default scores overridden by navigating to the "Audit Issue Scores" tab on the Settings page. + +Audit check functions may be disabled by selecting the auditor's technology and method: + +![image](images/disable_check.png) + +This will result in the check method not being run on the next audit full, which will remove any existing issue previously generated. + +The default score of the check method may also be overridden: + +![image](images/override_check_score.png) + +This will replace the score of issues generated by this check method with the configured one on the next full audit. + +Once an audit score is added it becomes possible to create additional override scores based on account patterns: + +![image](images/created_check_score.png) + +The Account Pattern Audit Scores box allows the user to add or update additional conditions for overriding the audit scores: + +![image](images/create_pattern_check_score.png) + +The Account Field box is prepopulated with both the standard and non-password type custom fields for the given Account Type. + +After saving the pattern score, it will be associated the the Audit Override Score record: + +![image](images/check_score_with_pattern.png) + +On the next full audit, the score for the configured check method will be replaced with an audit override score from the account pattern list if the account field matches the value. + +If no account pattern scores match the account, the override score it will default to the generic override score configured. + +Audit override scores may also be set up though the [Command line interface](../manage.py) functions add\_override\_score (for a single score) and add\_override\_scores (from a csv file) + +*Note:*: + + Currently there is no implementation of an account pattern field hierarchy, so the first account + pattern score encountered that matches the account being audited will be used as the override for + the check method in question. As such, if account pattern scores of different account fields are + entered for a single check method there is a possibility of unpredictable results and it is recommended + that only a single field is selected for defining patterns. + +Custom Alerters +--------------- + +Adding a custom alerter class allows users to add their own alerting anytime changes are found in watchers or auditors. The functionality in the alerter.py module send emails only when the reporter is finished running. The custom alerter reports are triggered when manually running find\_changes and audit\_changes as well as when the reporter runs. + +A sample customer alerter would be a SplunkAlerter module that logs watcher and auditor changes to be ingested into Splunk: + +~~~~ {.sourceCode .python} +from security_monkey.alerters import custom_alerter + + +class SplunkAlerter(object): + __metaclass__ = custom_alerter.AlerterType + + def report_watcher_changes(self, watcher): + """ + Collect change summaries from watchers defined logs them + """ + """ + Logs created, changed and deleted items for Splunk consumption. + """ + + for item in watcher.created_items: + app.splunk_logger.info( + "action=\"Item created\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\"".format( + item.db_item.id, + item.index, + item.account, + item.region, + item.name)) + + for item in watcher.changed_items: + app.splunk_logger.info( + "action=\"Item changed\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\"".format( + item.db_item.id, + item.index, + item.account, + item.region, + item.name)) + + for item in watcher.deleted_items: + app.splunk_logger.info( + "action=\"Item deleted\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\"".format( + item.db_item.id, + item.index, + item.account, + item.region, + item.name)) + + def report_auditor_changes(self, auditor): + for item in auditor.items: + for issue in item.confirmed_new_issues: + app.splunk_logger.info( + "action=\"Issue created\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\" " + "issue=\"{}\"".format( + issue.id, + item.index, + item.account, + item.region, + item.name, + issue.issue)) + + for issue in item.confirmed_fixed_issues: + app.splunk_logger.info( + "action=\"Issue fixed\" " + "id={} " + "resource={} " + "account={} " + "region={} " + "name=\"{}\" " + "issue=\"{}\"".format( + issue.id, + item.index, + item.account, + item.region, + item.name, + issue.issue)) +~~~~ diff --git a/docs/misc.rst b/docs/misc.rst deleted file mode 100644 index 5885f7928..000000000 --- a/docs/misc.rst +++ /dev/null @@ -1,204 +0,0 @@ -============== -Miscellaneous -============== - -Force Audit ------------ -Sometimes you will want to force an audit even though there is no configuration -change in AWS resources. - -For instance when you change a whitelist or add a 3rd party account, configuration -will not be audited again until the daily check at 10am. - -In this case, you can force an audit by running: - -.. code-block:: bash - - export SECURITY_MONKEY_SETTINGS=/usr/local/src/security_monkey/env-config/config-deploy.py - python manage.py audit_changes -m s3 - -Be sure to set your SECURITY_MONKEY_SETTINGS environment variable first. - -For an email by adding ``-r True``: - -.. code-block:: bash - - python manage.py audit_changes -m s3 -r True - -Valid values for ``audit_changes -m`` are: - - elb - - elasticip - - elasticsearchservice - - iamrole, iamssl, iamuser, iamgroup - - keypair - - policy - - redshift - - rds - - securitygroup - - ses - - sns - - sqs - - s3 - - vpc - - subnet - - routetable - -Scheduler Hacking ------------------ - -Edit ``security_monkey/scheduler.py`` to change daily check schedule:: - - scheduler.add_cron_job(_audit_changes, hour=10, day_of_week="mon-fri", args=[account, auditors, True]) - -Edit ``security_monkey/watcher.py`` to change check interval from every 15 minutes:: - - self.interval = 15 - - -Overriding and Disabling Audit Checks -------------------------------------- - -Auditor checks may be disabled or the default scores overridden by navigating to the "Audit Issue Scores" tab on the Settings page. - -Audit check functions may be disabled by selecting the auditor's technology and method: - -.. image:: images/disable_check.png - -This will result in the check method not being run on the next audit full, which will remove any existing issue previously generated. - -The default score of the check method may also be overridden: - -.. image:: images/override_check_score.png - -This will replace the score of issues generated by this check method with the configured one on the next full audit. - -Once an audit score is added it becomes possible to create additional override scores based on account patterns: - -.. image:: images/created_check_score.png - -The Account Pattern Audit Scores box allows the user to add or update additional conditions for overriding the audit scores: - -.. image:: images/create_pattern_check_score.png - -The Account Field box is prepopulated with both the standard and non-password type custom fields for the given Account Type. - -After saving the pattern score, it will be associated the the Audit Override Score record: - -.. image:: images/check_score_with_pattern.png - -On the next full audit, the score for the configured check method will be replaced with an audit override score from the account pattern list if the account field matches the value. - -If no account pattern scores match the account, the override score it will default to the generic override score configured. - -Audit override scores may also be set up though the `Command line interface <../manage.py>`_ functions -add_override_score (for a single score) and add_override_scores (from a csv file) - -*Note:*:: - - Currently there is no implementation of an account pattern field hierarchy, so the first account - pattern score encountered that matches the account being audited will be used as the override for - the check method in question. As such, if account pattern scores of different account fields are - entered for a single check method there is a possibility of unpredictable results and it is recommended - that only a single field is selected for defining patterns. - - -Custom Alerters ---------------- - -Adding a custom alerter class allows users to add their own alerting anytime changes are found in watchers or auditors. -The functionality in the `alerter.py` module send emails only when the reporter is finished running. The custom alerter -reports are triggered when manually running `find_changes` and `audit_changes` as well as when the reporter runs. - -A sample customer alerter would be a `SplunkAlerter` module that logs watcher and auditor changes to be ingested into Splunk: - -.. code-block:: python - - from security_monkey.alerters import custom_alerter - - - class SplunkAlerter(object): - __metaclass__ = custom_alerter.AlerterType - - def report_watcher_changes(self, watcher): - """ - Collect change summaries from watchers defined logs them - """ - """ - Logs created, changed and deleted items for Splunk consumption. - """ - - for item in watcher.created_items: - app.splunk_logger.info( - "action=\"Item created\" " - "id={} " - "resource={} " - "account={} " - "region={} " - "name=\"{}\"".format( - item.db_item.id, - item.index, - item.account, - item.region, - item.name)) - - for item in watcher.changed_items: - app.splunk_logger.info( - "action=\"Item changed\" " - "id={} " - "resource={} " - "account={} " - "region={} " - "name=\"{}\"".format( - item.db_item.id, - item.index, - item.account, - item.region, - item.name)) - - for item in watcher.deleted_items: - app.splunk_logger.info( - "action=\"Item deleted\" " - "id={} " - "resource={} " - "account={} " - "region={} " - "name=\"{}\"".format( - item.db_item.id, - item.index, - item.account, - item.region, - item.name)) - - def report_auditor_changes(self, auditor): - for item in auditor.items: - for issue in item.confirmed_new_issues: - app.splunk_logger.info( - "action=\"Issue created\" " - "id={} " - "resource={} " - "account={} " - "region={} " - "name=\"{}\" " - "issue=\"{}\"".format( - issue.id, - item.index, - item.account, - item.region, - item.name, - issue.issue)) - - for issue in item.confirmed_fixed_issues: - app.splunk_logger.info( - "action=\"Issue fixed\" " - "id={} " - "resource={} " - "account={} " - "region={} " - "name=\"{}\" " - "issue=\"{}\"".format( - issue.id, - item.index, - item.account, - item.region, - item.name, - issue.issue)) diff --git a/docs/nginx_install.rst b/docs/nginx_install.md similarity index 62% rename from docs/nginx_install.rst rename to docs/nginx_install.md index 9331db6ea..d2384ae71 100644 --- a/docs/nginx_install.rst +++ b/docs/nginx_install.md @@ -1,57 +1,44 @@ -============ Nginx setup -============ - -.. Note:: - You need to have followed the :doc:`easy install <./easy_install>` first. +=========== Nginx is a very popular choice to serve a Python project: -- It's fast. -- It's lightweight. -- Configuration files are simple. +- It's fast. +- It's lightweight. +- Configuration files are simple. -If you have your own server, it's the best choice. If not, try the - :doc:`easiest setup <./easy_install>` +If you have your own server, it's the best choice. -Nginx doesn't run any Python process, it only serve requests from outside to -the Python server. +Nginx doesn't run any Python process, it only serve requests from outside to the Python server. Therefor there are two steps: -- Run the Python process. -- Run Nginx. +- Run the Python process. +- Run Nginx. You will benefit from having: -- the possibility to have several projects listening to the port 80; -- your web site processes won't run with admin rights, even if --user doesn't - work on your OS; -- the ability to manage a Python process without touching Nginx or the other - processes. It's very handy for updates. +- the possibility to have several projects listening to the port 80; +- your web site processes won't run with admin rights, even if --user doesn't work on your OS; +- the ability to manage a Python process without touching Nginx or the other processes. It's very handy for updates. The Python process -================== +------------------ -Run Security Monkey as usual, but this time make it listen to a local port and host. E.G:: +Run Security Monkey as usual, but this time make it listen to a local port and host. E.G: python manage.py run_api_server -In PHP, when you edit a file, the changes are immediately visible. In Python, -the whole code is often loaded in memory for performance reasons. This means -you have to restart the Python process to see the changes effect. Having a -separate process let you do this without having to restart the server. +In PHP, when you edit a file, the changes are immediately visible. In Python, the whole code is often loaded in memory for performance reasons. This means you have to restart the Python process to see the changes effect. Having a separate process let you do this without having to restart the server. Nginx -====== +----- -Nginx can be installed with you usual package manager, so we won't cover -installing it. +Nginx can be installed with you usual package manager, so we won't cover installing it. -You must create a Nginx configuration file for Security Monkey. On GNU/Linux, they usually -go into /etc/nginx/conf.d/. Name it securitymonkey.conf. +You must create a Nginx configuration file for Security Monkey. On GNU/Linux, they usually go into /etc/nginx/conf.d/. Name it securitymonkey.conf. -The minimal configuration file to run the site is:: +The minimal configuration file to run the site is: add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection "1; mode=block"; @@ -72,7 +59,7 @@ The minimal configuration file to run the site is:: proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; proxy_redirect off; proxy_buffering off; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } @@ -90,7 +77,6 @@ The minimal configuration file to run the site is:: } -`proxy_pass` just passes the external request to the Python process. -The port much match the one used by the 0bin process of course. +proxy\_pass just passes the external request to the Python process. The port much match the one used by the 0bin process of course. This makes Nginx serve the favicon and static files which is is much better at than python. diff --git a/docs/options.md b/docs/options.md new file mode 100644 index 000000000..438499730 --- /dev/null +++ b/docs/options.md @@ -0,0 +1,136 @@ +Options +======= + +Security Monkey's behavior can be adjusted with options passed using a configuration file or directly using the command line. Some parameters are only available in the configuration file. + +If an option is not passed, Security Monkey will use the default value from the file security\_monkey/default-config.py. + +You also have the option of providing environment aware configurations through the use of the SECURITY\_MONKEY\_SETTINGS environmental variable. + +Any variables set via this variable will override the default values specified in default-config.py + +Config File +----------- + +### LOG\_LEVEL + +Standard python logging levels (ERROR, WARNING, DEBUG) depending on how much output you would like to see in your logs. + +### LOG\_FILE + +If set, specifies a file to which Security Monkey will write logs. If unset, Security Monkey will log to stderr. + +### LOG\_CFG + +Can be used instead of LOG\_LEVEL and LOG\_FILE. Should be set to a PEP-0391 compatible logging configuration. Example: + + LOG_CFG = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s %(levelname)s: %(message)s ' + '[in %(pathname)s:%(lineno)d]' + } + }, + 'handlers': { + 'file': { + 'class': 'logging.handlers.RotatingFileHandler', + 'level': 'DEBUG', + 'formatter': 'standard', + 'filename': '/var/log/security_monkey/securitymonkey.log', + 'maxBytes': 10485760, + 'backupCount': 100, + 'encoding': 'utf8' + }, + 'console': { + 'class': 'logging.StreamHandler', + 'level': 'DEBUG', + 'formatter': 'standard', + 'stream': 'ext://sys.stdout' + } + }, + 'loggers': { + 'security_monkey': { + 'handlers': ['file', 'console'], + 'level': 'DEBUG' + }, + 'apscheduler': { + 'handlers': ['file', 'console'], + 'level': 'INFO' + } + } + } + +### R53 + +Specify if you want Security Monkey to create a DNS entry for itself and what DNS name you would like + +### FQDN + +This is used for various redirection magic that to get the Security Monkey UI working nice with the API + +### SQLALCHEMY\_POOL\_SIZE & SQLALCHEMY\_MAX\_OVERFLOW + +Because of the parallel nature of Security Monkey we have to have the ability to tweak the number of concurrent connections we can make. The default values should be sufficient for \<= 20 accounts. This may need to be increased if you are dealing with a greater number of accounts. + +### API\_PORT + +Needed for CORS whitelisting -- this should match the port you have told Security Monkey to listen on. If you are using nginx it should match the port that nginx is listening on for the /api endpoint. + +### WEB\_PORT + +Needed for CORS whitelisting -- this should match the port you have configured nginx to listen on for static content. + +### WEB\_PATH + +### FQDN + +To perform redirection security monkey needs to know the FQDN you intend to use. IF R53 is enabled this FQDN will be automatically added to Route53 when Security Monkey starts, assuming the SecurityMonkeyInstanceProfile has permission to do so. + +### SQLACHEMY\_DATABASE\_URI + +If you have ever used sqlalchemy before this is the standard connection string used. Security Monkey uses a postgres database and the connection string would look something like: + + SQLALCHEMY_DATABASE_URI = 'postgressql://:@:5432/SecurityMonkey' + +### SECRET_KEY + +This `SECRET_KEY` is essential to ensure the sessions generated by Flask cannot be guessed. You must generate a RANDOM SECRET\_KEY for this value. + +An example of how you might generate a random string: + + >>> import random + >>> secret_key = ''.join(random.choice(string.ascii_uppercase) for x in range(6)) + >>> secret_key = secret_key + ''.join(random.choice("~!@#$%^&*()_+") for x in range(6)) + >>> secret_key = secret_key + ''.join(random.choice(string.ascii_lowercase) for x in range(6)) + >>> secret_key = secret_key + ''.join(random.choice(string.digits) for x in range(6)) + +### SECURITY\_PASSWORD\_SALT + +For many of the same reasons we want want a random SECRET\_KEY we want to ensure our password salt is random. see: [Salt](http://en.wikipedia.org/wiki/Salt_(cryptography)) + +You can use the same method used to generate the SECRET\_KEY to generate the SECURITY\_PASSWORD\_SALT + +### Additional Options + +As Security Monkey uses Flask-Security for authentication see .. \_Flask-Security: for additional configuration options. + +Command line +------------ + +### --host and --port + +The host and port on which to listen for incoming request. Usually 127.0.0.1 and 8000 to listen locally or 0.0.0.0 and 80 to listen from the outside. + +Default: 127.0.0.1 and 8000 + +Setting file : HOST and PORT + +### --version and --help + +Display the help or the version of 0bin. + +Default: None + +Configuration file equivalent: None diff --git a/docs/options.rst b/docs/options.rst deleted file mode 100644 index ab356d688..000000000 --- a/docs/options.rst +++ /dev/null @@ -1,135 +0,0 @@ -============ -Options -============ - -Security Monkey's behavior can be adjusted with options passed using a configuration -file or directly using the command line. Some parameters are only available -in the configuration file. - -If an option is not passed, Security Monkey will use the default value from the file -security_monkey/default-config.py. - -You also have the option of providing environment aware configurations through the use -of the SECURITY_MONKEY_SETTINGS environmental variable. - -Any variables set via this variable will override the default values specified in default-config.py - - -Config File -=========== - -LOG_LEVEL ---------- - -Standard python logging levels (ERROR, WARNING, DEBUG) depending on how much output you would like to see in your logs. - -LOG_FILE --------- - -If set, specifies a file to which Security Monkey will write logs. If unset, Security Monkey will log to stderr. - -LOG_CFG -------- -Can be used instead of LOG_LEVEL and LOG_FILE. Should be set to a PEP-0391 compatible logging configuration. Example:: - - LOG_CFG = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'standard': { - 'format': '%(asctime)s %(levelname)s: %(message)s ' - '[in %(pathname)s:%(lineno)d]' - } - }, - 'handlers': { - 'file': { - 'class': 'logging.handlers.RotatingFileHandler', - 'level': 'DEBUG', - 'formatter': 'standard', - 'filename': '/var/log/security_monkey/securitymonkey.log', - 'maxBytes': 10485760, - 'backupCount': 100, - 'encoding': 'utf8' - }, - 'console': { - 'class': 'logging.StreamHandler', - 'level': 'DEBUG', - 'formatter': 'standard', - 'stream': 'ext://sys.stdout' - } - }, - 'loggers': { - 'security_monkey': { - 'handlers': ['file', 'console'], - 'level': 'DEBUG' - }, - 'apscheduler': { - 'handlers': ['file', 'console'], - 'level': 'INFO' - } - } - } - -R53 ---- - -Specify if you want Security Monkey to create a DNS entry for itself and what DNS name you would like - -FQDN ----- - -This is used for various redirection magic that to get the Security Monkey UI working nice with the API - - -SQLALCHEMY_DATABASE_URI ------------------------ - -Specify where you would like Security Monkey to store it's results - -SQLALCHEMY_POOL_SIZE & SQLALCHEMY_MAX_OVERFLOW ----------------------------------------------- - -Because of the parallel nature of Security Monkey we have to have the ability to tweak the number of concurrent connections we can make. The default values should be sufficient for <= 20 accounts. This may need to be increased if you are dealing with a greater number of accounts. - -API_PORT --------- - -Needed for CORS whitelisting -- this should match the port you have told Security Monkey to listen on. If you are using nginx it should match the port that nginx is listening on for the /api endpoint. - -WEB_PORT --------- - -Needed for CORS whitelisting -- this should match the port you have configured nginx to listen on for static content. - -WEB_PATH --------- - - -Additional Options ------------------- - -As Security Monkey uses Flask-Security for authentication see .. _Flask-Security: https://pythonhosted.org/Flask-Security/configuration.html for additional configuration options. - - -Command line -================== - ---host and --port -------------------- - -The host and port on which to listen for incoming request. Usually 127.0.0.1 -and 8000 to listen locally or 0.0.0.0 and 80 to listen from the outside. - -Default: 127.0.0.1 and 8000 - -Setting file : HOST and PORT - ---version and --help --------------------- - -Display the help or the version of 0bin. - -Default: None - -Configuration file equivalent: None - diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 000000000..3dd3fcd02 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,41 @@ +Plugins +======= + +Security Monkey can be extended by writing own Account Managers, Watchers and Auditors. To do this you need to create a subclass of either `security_monkey.account_manager.AccountManager`, `security_monkey.watcher.Watcher` or `security_monkey.auditor.Auditor`. + +To make extension available to Security Monkey it should have entry point under group `security_monkey.plugins`. + +Sample AccountManager plugin +---------------------------- + +Assume we have a file account.py in directory my\_sm\_plugins/my\_sm\_plugins/account.py: + +~~~~ {.sourceCode .python} +from security_monkey.account_manager import AccountManager + +class MyAccountManager(AccountManager): + pass +~~~~ + +NOTE: there also shoule be file my\_sm\_plugins/my\_sm\_plugins/\_\_init\_\_.py + +And we have a file setup.py in directory my\_sm\_plugins: + +~~~~ {.sourceCode .python} +from setuptools import setup, find_packages + +setup( + name="my_sm_plugins", + version="0.1-dev0", + packages=find_packages(), + include_package_data=True, + install_requires=["security_monkey"], + entry_points={ + "security_monkey.plugins": [ + "my_sm_plugins.account = my_sm_plugins.account", + ] + } +) +~~~~ + +Then we can install `my_sm_plugins` package and have security\_monkey with our plugin available. diff --git a/docs/plugins.rst b/docs/plugins.rst deleted file mode 100644 index a7fe35f5d..000000000 --- a/docs/plugins.rst +++ /dev/null @@ -1,43 +0,0 @@ -======= -Plugins -======= - -Security Monkey can be extended by writing own Account Managers, Watchers and Auditors. To do this you need to create a subclass -of either ``security_monkey.account_manager.AccountManager``, ``security_monkey.watcher.Watcher`` or ``security_monkey.auditor.Auditor``. - -To make extension available to Security Monkey it should have entry point under group ``security_monkey.plugins``. - -Sample AccountManager plugin -============================ - -Assume we have a file account.py in directory my_sm_plugins/my_sm_plugins/account.py: - -.. code-block:: python - - from security_monkey.account_manager import AccountManager - - class MyAccountManager(AccountManager): - pass - -NOTE: there also shoule be file my_sm_plugins/my_sm_plugins/__init__.py - -And we have a file setup.py in directory my_sm_plugins: - -.. code-block:: python - - from setuptools import setup, find_packages - - setup( - name="my_sm_plugins", - version="0.1-dev0", - packages=find_packages(), - include_package_data=True, - install_requires=["security_monkey"], - entry_points={ - "security_monkey.plugins": [ - "my_sm_plugins.account = my_sm_plugins.account", - ] - } - ) - -Then we can install ``my_sm_plugins`` package and have security_monkey with our plugin available. diff --git a/docs/postgres_aws.md b/docs/postgres_aws.md new file mode 100644 index 000000000..f4b1b0d95 --- /dev/null +++ b/docs/postgres_aws.md @@ -0,0 +1,19 @@ +Postgres on AWS +=============== + +Amazon can host your postgres database in their [RDS service](https://aws.amazon.com/rds/). We recommend using AWS RDS or [GCP Cloud SQL](postgres_gcp.md) to productionalize your security_monkey deployment. + +Create a Postgres RDS instance in the same region you intend to launch your security_monkey instance. + +![Create RDS Instance](images/aws_rds.png "Create RDS Instance") + +The AWS supplied defaults should get you going. You will need to use the hostname, dbname, username, password to create a SQLALCHEMY_DATABASE_URI for your config. + + SQLALCHEMY_DATABASE_URI = 'postgresql://securitymonkeyuser:securitymonkeypassword@hostname:5432/secmonkey' + +Advanced users may wish to supply a KMS key for encryption at rest. + +Next: +----- + +- [Quickstart](quickstart.md#launch-an-instance) \ No newline at end of file diff --git a/docs/postgres_gcp.md b/docs/postgres_gcp.md new file mode 100644 index 000000000..b64739af9 --- /dev/null +++ b/docs/postgres_gcp.md @@ -0,0 +1,23 @@ +Postgres on GCP +=============== + +If you are deploying Security Monkey on GCP and decide to use Cloud SQL, it's recommended to run [Cloud SQL Proxy](https://cloud.google.com/sql/docs/postgres/sql-proxy) to connect to Postgres. To use Postgres on Cloud SQL, create a new instance from your GCP console and create a password for the `postgres` user when Cloud SQL prompts you. (If you ever need to reset the `postgres` user's password, refer to the [Cloud SQL documentation](https://cloud.google.com/sql/docs/postgres/create-manage-users).) + +After the instance is up, run Cloud SQL Proxy: + + $ ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & + +You can find the instance connection name by clicking on your Cloud SQL instance name on the [Cloud SQL dashboard](https://console.cloud.google.com/sql/instances) and looking under "Properties". The instance connection name is something like [PROJECT\_ID]:[REGION]:[INSTANCENAME]. + +You'll need to run Cloud SQL Proxy on whichever machine is accessing Postgres, e.g. on your local workstation as well as on the GCE instance where you're running Security Monkey. + +Connect to the Postgres instance: + + $ sudo -u postgres psql -h 127.0.0.1 -p 5432 + +After you've connected successfully in psql, follow the instructions in Setup Postgres\_ to set up the Security Monkey database. + +Next: +----- + +- [Back to the Quickstart](quickstart.md#launch-an-instance) \ No newline at end of file diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 000000000..a1d6fa584 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,260 @@ +Quick Start Guide +================= + +Setup on AWS or GCP +------------------- + +Security Monkey can run on an Amazon EC2 (AWS) instance or a Google Cloud Platform (GCP) instance (Google Cloud Platform). The only real difference in the installation is the IAM configuration and the bringup of the Virtual Machine that runs Security Monkey. + +IAM Permissions +--------------- + +- For AWS, please see [AWS IAM instructions](iam_aws.md). +- For GCP, please see [GCP IAM instructions](iam_gcp.md). + +Database +-------- + +Security Monkey needs a postgres database. Select one of the following: + +- Local Postgres +- [Postgres on AWS RDS](postgres_aws.md). +- [Postgres on GCP's Cloud SQL](postgres_gcp.md). + +### Local Postgres + +Install Posgres: + + sudo apt-get install postgresql postgresql-contrib + +Configure the DB: + + sudo -u postgres psql + CREATE DATABASE "secmonkey"; + CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypassword'; + CREATE SCHEMA secmonkey; + GRANT Usage, Create ON SCHEMA "secmonkey" TO "securitymonkeyuser"; + set timezone TO 'GMT'; + select now(); + \q + +Launch an Instance: +------------------- + +- [docker instructions](docker.md). +- [Launch an AWS instance](instance_launch_aws.md). +- [Launch a GCP instance](instance_launch_gcp.md). + +Install Security Monkey on your Instance +---------------------------------------- + +Installation Steps: + +- Prerequisites +- Clone security_monkey +- Compile (or Download) the web UI +- Review the config + +### Prerequisites + +We now have a fresh install of Ubuntu. Let's add the hostname to the hosts file: + + $ hostname + ip-172-30-0-151 + +Add this to /etc/hosts: (Use nano if you're not familiar with vi.): + + $ sudo vi /etc/hosts + 127.0.0.1 ip-172-30-0-151 + +Create the logging folders: + + sudo mkdir /var/log/security_monkey + sudo mkdir /var/www + sudo chown www-data /var/log/security_monkey + sudo chown www-data /var/www + +Let's install the tools we need for Security Monkey: + + $ sudo apt-get update + $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev gcc python-virtualenv + +### Clone security_monkey + +Releases are on the master branch and are updated about every three months. Bleeding edge features are on the develop branch. + + cd /usr/local/src + sudo git clone --depth 1 --branch master https://github.com/Netflix/security_monkey.git + cd security_monkey + sudo virtualenv venv + sudo pip install --upgrade setuptools + sudo python setup.py install + +Fix ownership for python modules: + + sudo usermod -a -G staff www-data + sudo chgrp staff /usr/local/lib/python2.7/dist-packages/*.egg + +### Compile (or Download) the web UI + +If you're using the stable (master) branch, you have the option of downloading the web UI instead of compiling it. Visit the latest release and download static.tar.gz. + +If you're using the bleeding edge (develop) branch, you will need to compile the web UI by following these instructions: + + # Get the Google Linux package signing key. + $ curl https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - + + # Set up the location of the stable repository. + cd ~ + curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > dart_stable.list + sudo mv dart_stable.list /etc/apt/sources.list.d/dart_stable.list + sudo apt-get update + sudo apt-get install -y dart + + # Build the Web UI + cd /usr/local/src/security_monkey/dart + sudo /usr/lib/dart/bin/pub get + sudo /usr/lib/dart/bin/pub build + + # Copy the compiled Web UI to the appropriate destination + sudo mkdir -p /usr/local/src/security_monkey/security_monkey/static/ + sudo /bin/cp -R /usr/local/src/security_monkey/dart/build/web/* /usr/local/src/security_monkey/security_monkey/static/ + sudo chgrp -R www-data /usr/local/src/security_monkey + +### Configure the Application + +Security Monkey ships with a config for this quickstart guide called config.py. You can override this behavior by setting the `SECURITY_MONKEY_SETTINGS` environment variable. + +Modify `env-config/config.py`: +- FQDN: Add the IP or DNS entry of your instance. +- SQLACHEMY_DATABASE_URI: This config assumes that you are using the local db option. If you setup AWS RDS or GCP Cloud SQL as your database, you will need to modify the SQLACHEMY_DATABASE_URI to point to your DB. +- SECRET_KEY: Something random. +- SECURITY_PASSWORD_SALT: Something random. + +For an explanation of the configuration options, see [options](options.md). + +### Create the database tables: + +Security Monkey uses Flask-Migrate (Alembic) to keep database tables up to date. To create the tables, run this command: + + cd /usr/local/src/security_monkey/ + sudo -E python manage.py db upgrade + +Populate Security Monkey with Accounts +-------------------------------------- + +### Add Amazon Accounts + +This will add Amazon owned AWS accounts to security monkey. : + + $ sudo -E python manage.py amazon_accounts + +### Add Your AWS/GCP Accounts + +You'll need to add at least one account before starting the scheduler. It's easiest to add them from the command line, but it can also be done through the web UI. : + + $ python manage.py add_account_aws + usage: manage.py add_account_aws [-h] -n NAME [--thirdparty] [--active] + [--notes NOTES] --id IDENTIFIER + [--update-existing] + [--canonical_id CANONICAL_ID] + [--s3_name S3_NAME] [--role_name ROLE_NAME] + + $ python manage.py add_account_gcp + usage: manage.py add_account_gcp [-h] -n NAME [--thirdparty] [--active] + [--notes NOTES] --id IDENTIFIER + [--update-existing] [--creds_file CREDS_FILE] + +### Create the first user: + +Users can be created on the command line or by registering in the web UI: + + $ sudo -E python manage.py create_user "you@youremail.com" "Admin" + > Password: + > Confirm Password: + +create\_user takes two parameters. 1) is the email address and 2) is the role. + +Roles should be one of these: + +- View +- Comment +- Justify +- Admin + +Setting up Supervisor +--------------------- + +Supervisor will auto-start security monkey and will auto-restart security monkey if it crashes. + +Copy supervisor config: + + sudo cp /usr/local/src/security_monkey/supervisor/security_monkey.conf /etc/supervisor/conf.d/security_monkey.conf + sudo service supervisor restart + sudo supervisorctl status + +Supervisor will attempt to start two python jobs and make sure they are running. The first job, securitymonkey, is gunicorn, which it launches by calling manage.py run\_api\_server. + +The second job supervisor runs is the scheduler, which polls for changes. + +You can track progress by tailing /var/log/security\_monkey/securitymonkey.log. + +Create an SSL Certificate +------------------------- + +For this quickstart guide, we will use a self-signed SSL certificate. In production, you will want to use a certificate that has been signed by a trusted certificate authority.: + + $ cd ~ + +There are some great instructions for generating a certificate on the Ubuntu website: + +[Ubuntu - Create a Self Signed SSL Certificate](https://help.ubuntu.com/12.04/serverguide/certificates-and-security.html) + +The last commands you need to run from that tutorial are in the "Installing the Certificate" section: + +~~~~ {.sourceCode .bash} +sudo cp server.crt /etc/ssl/certs +sudo cp server.key /etc/ssl/private +~~~~ + +Once you have finished the instructions at the link above, and these two files are in your /etc/ssl/certs and /etc/ssl/private, you are ready to move on in this guide. + +Setup Nginx: +------------ + +Security Monkey uses gunicorn to serve up content on its internal 127.0.0.1 address. For better performance, and to offload the work of serving static files, we wrap gunicorn with nginx. Nginx listens on 0.0.0.0 and proxies some connections to gunicorn for processing and serves up static files quickly. + +### securitymonkey.conf + +Copy the config file into place: + + sudo cp /usr/local/src/security_monkey/nginx/security_monkey.conf /etc/nginx/sites-available/security_monkey.conf + +Symlink the sites-available file to the sites-enabled folder: + + $ sudo ln -s /etc/nginx/sites-available/security_monkey.conf /etc/nginx/sites-enabled/security_monkey.conf + +Delete the default configuration: + + $ sudo rm /etc/nginx/sites-enabled/default + +Restart nginx: + + $ sudo service nginx restart + +Logging into the UI +------------------- + +You should now be able to reach your server + +![image](images/resized_login_page-1.png) + +User Guide +---------- + +See the [User Guide](userguide.md) for a walkthrough of the security\_monkey features. + +Contribute +---------- + +It's easy to extend security\_monkey with new rules or new technologies. Please read our [Contributing Documentation](contributing.md). diff --git a/docs/quickstart.rst b/docs/quickstart.rst deleted file mode 100644 index 85d7ef5ff..000000000 --- a/docs/quickstart.rst +++ /dev/null @@ -1,1011 +0,0 @@ -================= -Quick Start Guide -================= - -Setup on AWS or GCP -=================== - -Security Monkey can run on an Amazon EC2 (AWS) instance or a Google Cloud Platform (GCP) instance (Google Cloud Platform). The only real difference in the installation is the IAM configuration and the bringup of the Virtual Machine that runs Security Monkey. - -AWS Configuration -===================== - -Below there are two options for configuring Security Monkey to run on AWS. See below for `GCP Configuration`_. - -Docker Images -------------- - -For local development, please see `docker instructions <./docker.html>`. - - -Setup IAM Roles ----------------- - -We need to create two roles for security monkey. The first role will be an -instance profile that we will launch security monkey into. The permissions -on this role allow the monkey to use STS to assume to other roles as well as -use SES to send email. - -Creating SecurityMonkeyInstanceProfile Role -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Create a new role and name it "SecurityMonkeyInstanceProfile": - -.. image:: images/resized_name_securitymonkeyinstanceprofile_role.png - -Select "Amazon EC2" under "AWS Service Roles". - -.. image:: images/resized_create_role.png - -Select "Custom Policy": - -.. image:: images/resized_role_policy.png - -Paste in this JSON with the name "SecurityMonkeyLaunchPerms": - -.. code-block:: json - - { - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "ses:SendEmail" - ], - "Resource": "*" - }, - { - "Effect": "Allow", - "Action": "sts:AssumeRole", - "Resource": "arn:aws:iam::*:role/SecurityMonkey" - } - ] - } - -Review and create your new role: - -.. image:: images/resized_role_confirmation.png - -Creating SecurityMonkey Role -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Create a new role and name it "SecurityMonkey": - -.. image:: images/resized_name_securitymonkey_role.png - -Select "Amazon EC2" under "AWS Service Roles". - -.. image:: images/resized_create_role.png - -Select "Custom Policy": - -.. image:: images/resized_role_policy.png - -Paste in this JSON with the name "SecurityMonkeyReadOnly": - -.. code-block:: json - - { - "Version": "2012-10-17", - "Statement": [ - { - "Action": [ - "acm:describecertificate", - "acm:listcertificates", - "cloudtrail:describetrails", - "cloudtrail:gettrailstatus", - "config:describeconfigrules", - "config:describeconfigurationrecorders", - "directconnect:describeconnections", - "ec2:describeaddresses", - "ec2:describedhcpoptions", - "ec2:describeflowlogs", - "ec2:describeimages", - "ec2:describeinstances", - "ec2:describeinternetgateways", - "ec2:describekeypairs", - "ec2:describenatgateways", - "ec2:describenetworkacls", - "ec2:describenetworkinterfaces", - "ec2:describeregions", - "ec2:describeroutetables", - "ec2:describesecuritygroups", - "ec2:describesnapshots", - "ec2:describesubnets", - "ec2:describetags", - "ec2:describevolumes", - "ec2:describevpcendpoints", - "ec2:describevpcpeeringconnections", - "ec2:describevpcs", - "ec2:describevpngateways", - "elasticloadbalancing:describeloadbalancerattributes", - "elasticloadbalancing:describeloadbalancerpolicies", - "elasticloadbalancing:describeloadbalancers", - "es:describeelasticsearchdomainconfig", - "es:listdomainnames", - "iam:getaccesskeylastused", - "iam:getgroup", - "iam:getgrouppolicy", - "iam:getloginprofile", - "iam:getpolicyversion", - "iam:getrole", - "iam:getrolepolicy", - "iam:getservercertificate", - "iam:getuser", - "iam:getuserpolicy", - "iam:listaccesskeys", - "iam:listattachedgrouppolicies", - "iam:listattachedrolepolicies", - "iam:listattacheduserpolicies", - "iam:listentitiesforpolicy", - "iam:listgrouppolicies", - "iam:listgroups", - "iam:listinstanceprofilesforrole", - "iam:listmfadevices", - "iam:listpolicies", - "iam:listrolepolicies", - "iam:listroles", - "iam:listservercertificates", - "iam:listsigningcertificates", - "iam:listuserpolicies", - "iam:listusers", - "kms:describekey", - "kms:getkeypolicy", - "kms:listaliases", - "kms:listgrants", - "kms:listkeypolicies", - "kms:listkeys", - "lambda:listfunctions", - "rds:describedbclusters", - "rds:describedbclustersnapshots", - "rds:describedbinstances", - "rds:describedbsecuritygroups", - "rds:describedbsnapshots", - "rds:describedbsubnetgroups", - "redshift:describeclusters", - "route53:listhostedzones", - "route53:listresourcerecordsets", - "route53domains:listdomains", - "route53domains:getdomaindetail", - "s3:getaccelerateconfiguration", - "s3:getbucketacl", - "s3:getbucketcors", - "s3:getbucketlocation", - "s3:getbucketlogging", - "s3:getbucketnotification", - "s3:getbucketpolicy", - "s3:getbuckettagging", - "s3:getbucketversioning", - "s3:getbucketwebsite", - "s3:getlifecycleconfiguration", - "s3:listbucket", - "s3:listallmybuckets", - "s3:getreplicationconfiguration", - "s3:getanalyticsconfiguration", - "s3:getmetricsconfiguration", - "s3:getinventoryconfiguration", - "ses:getidentityverificationattributes", - "ses:listidentities", - "ses:listverifiedemailaddresses", - "ses:sendemail", - "sns:gettopicattributes", - "sns:listsubscriptionsbytopic", - "sns:listtopics", - "sqs:getqueueattributes", - "sqs:listqueues" - ], - "Effect": "Allow", - "Resource": "*" - } - ] - } - -Review and create the new role. - -Allow SecurityMonkeyInstanceProfile to AssumeRole to SecurityMonkey -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -You should now have two roles available in your AWS Console: - -.. image:: images/resized_both_roles.png - -Select the "SecurityMonkey" role and open the "Trust Relationships" tab. - -.. image:: images/resized_edit_trust_relationship.png - -Edit the Trust Relationship and paste this in: - -.. code-block:: json - - { - "Version": "2008-10-17", - "Statement": [ - { - "Sid": "", - "Effect": "Allow", - "Principal": { - "AWS": [ - "arn:aws:iam:::role/SecurityMonkeyInstanceProfile" - ] - }, - "Action": "sts:AssumeRole" - } - ] - } - -Adding more accounts -^^^^^^^^^^^^^^^^^^^^ - -To have your instance of security monkey monitor additional accounts, you must add a SecurityMonkey role in the new account. Follow the instructions above to create the new SecurityMonkey role. The Trust Relationship policy should have the account ID of the account where the security monkey instance is running. - - - -**Note** - -Additional SecurityMonkeyInstanceProfile roles are not required. You only need to create a new SecurityMonkey role. - -**Note** - -You will also need to add the new account in the Web UI, and restart the scheduler. More information on how do to this will be presented later in this guide. - -**TODO** - -Document how to setup an SES account and validate it. - -Launch an Ubuntu Instance --------------------------- - -Netflix monitors dozens AWS accounts easily on a single m3.large instance. For this guide, we will launch a m1.small. - -In the console, start the process to launch a new Ubuntu instance. The screenshot below shows EC2 classic, but you can also launch this in external VPC.: - -.. image:: images/resized_ubuntu.png - -Select an m1.small and select "Next: Configure Instance Details". - -**Note: Do not select "Review and Launch". We need to launch this instance in a specific role.** - -.. image:: images/resized_select_ec2_instance.png - -Under "IAM Role", select SecurityMonkeyInstanceProfile: - -.. image:: images/resized_launch_instance_with_role.png - -You may now launch the new instance. Please take note of the "Public DNS" entry. We will need that later when configuring security monkey. - -.. image:: images/resized_launched_sm.png - -Now may also be a good time to edit the "launch-wizard-1" security group to restrict access to your IP. Make sure you leave TCP 22 open for ssh and TCP 443 for HTTPS. - -Keypair -^^^^^^^ - -You may be prompted to download a keypair. You should protect this keypair; it is used to provide ssh access to the new instance. Put it in a safe place. You will need to change the permissions on the keypair to 400:: - - $ chmod 400 SecurityMonkeyKeypair.pem - -Connecting to your new instance: -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -We will connect to the new instance over ssh:: - - $ ssh -i SecurityMonkeyKeyPair.pem -l ubuntu - -Replace the last parameter () with the Public IP of your instance. - - -GCP configuration -============================== - -Below describes how to install Security Monkey on GCP. See the section on `AWS Configuration`_ to install on an EC2 instance. - -Install gcloud ---------------- - -If you haven't already, install *gcloud* from the downloads_ page. *gcloud* enables you to administer VMs, IAM policies, services and more from the command line. - -.. _downloads: https://cloud.google.com/sdk/downloads - -Setup Service Account ---------------------- - -To restrict which permissions Security Monkey has to your projects, we'll create a `Service Account`_ with a special role. - -.. _`Service Account`: https://cloud.google.com/compute/docs/access/service-accounts - -Then, we'll launch an instance using that service account. -Navigate to the `Service Account page`_ for your project. - -.. _`Service Account page`: https://console.developers.google.com/iam-admin/serviceaccounts - -Click the *Create Service Account* button at the top of the screen. - -* **Service Account Name**: securitymonkey -* **Roles**: Security Reviewer, Storage Object Viewer -* **Tags**: Allow HTTPS traffic - -Then, click the *Create* button. - -Launch an Ubuntu Instance ----------------------- -Create an instance running Ubuntu 14.04 LTS using our 'securitymonkey' service account. - -Navigate to the `Create Instance page`_. Fill in the following fields: - -* **Name**: securitymonkey -* **Zone**: us-west1-b (or whatever zone you wish) -* **Machine Type**: 1vCPU, 3.75GB (minimum; also known as n1-standard-1) -* **Boot Disk**: Ubuntu 14.04 LTS -* **Service Account**: securitymonkey - -.. _`Create Instance page`: https://console.developers.google.com/compute/instancesAdd - -Click the *Create* button to create the instance. - -Connecting to your new instance: -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -We will connect to the new instance over ssh with the gcloud command:: - - $ gcloud compute ssh @ --zone us-west1-b - -Replace the first parameter () with the username you authenticated gcloud with. Replace the last parameter () with the Public IP of your instance. - -Install Pre-requisites -====================== - -We now have a fresh install of Ubuntu. Let's add the hostname to the hosts file:: - - $ hostname - ip-172-30-0-151 - -Add this to /etc/hosts: (Use nano if you're not familiar with vi.):: - - $ sudo vi /etc/hosts - 127.0.0.1 ip-172-30-0-151 - -Create the logging folders:: - - sudo mkdir /var/log/security_monkey - sudo mkdir /var/www - sudo chown www-data /var/log/security_monkey - sudo chown www-data /var/www - -Let's install the tools we need for Security Monkey:: - - $ sudo apt-get update - $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev gcc - -Setup Postgres --------------- - -*For production, you will want to use your cloud provider's managed Postgres database (such as AWS RDS Postgres or Cloud SQL Postgres) for improved reliability.* For this guide, we will setup a database on the instance that was just launched. - -First, set a password for the postgres user. For this guide, we will use ``securitymonkeypassword``: :: - - sudo -u postgres psql - CREATE DATABASE "secmonkey"; - CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypassword'; - CREATE SCHEMA secmonkey - GRANT Usage, Create ON SCHEMA "secmonkey" TO "securitymonkeyuser"; - set timezone TO 'GMT'; - select now(); - \q - -Postgres on GCP ---------------- - -If you are deploying Security Monkey on GCP and decide to use Cloud SQL, it's recommended to run `Cloud SQL Proxy `_ to connect to Postgres. To use Postgres on Cloud SQL, create a new instance from your GCP console and create a password for the ``postgres`` user when Cloud SQL prompts you. (If you ever need to reset the ``postgres`` user's password, refer to the `Cloud SQL documentation `_.) - -After the instance is up, run Cloud SQL Proxy:: - - $ ./cloud_sql_proxy -instances=[INSTANCE CONNECTION NAME]=tcp:5432 & - -You can find the instance connection name by clicking on your Cloud SQL instance name on the `Cloud SQL dashboard `_ and looking under "Properties". The instance connection name is something like [PROJECT_ID]:[REGION]:[INSTANCENAME]. - -You'll need to run Cloud SQL Proxy on whichever machine is accessing Postgres, e.g. on your local workstation as well as on the GCE instance where you're running Security Monkey. - -Connect to the Postgres instance:: - - $ sudo -u postgres psql -h 127.0.0.1 -p 5432 - -After you've connected successfully in psql, follow the instructions in `Setup Postgres`_ to set up the Security Monkey database. - - -Clone the Security Monkey Repo -============================== - -Next we'll clone and install the package:: - - cd /usr/local/src - sudo git clone --depth 1 --branch master https://github.com/Netflix/security_monkey.git - cd security_monkey - sudo python setup.py install - -Fix ownership for python modules:: - - sudo usermod -a -G staff www-data - sudo chgrp staff /usr/local/lib/python2.7/dist-packages/*.egg - -**New in 0.2.0** - Compile the web-app from the Dart code:: - - # Get the Google Linux package signing key. - $ curl https://dl-ssl.google.com/linux/linux_signing_key.pub | sudo apt-key add - - - # Set up the location of the stable repository. - cd ~ - curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > dart_stable.list - sudo mv dart_stable.list /etc/apt/sources.list.d/dart_stable.list - sudo apt-get update - sudo apt-get install -y dart - - # Build the Web UI - cd /usr/local/src/security_monkey/dart - sudo /usr/lib/dart/bin/pub get - sudo /usr/lib/dart/bin/pub build - - # Copy the compiled Web UI to the appropriate destination - sudo mkdir -p /usr/local/src/security_monkey/security_monkey/static/ - sudo /bin/cp -R /usr/local/src/security_monkey/dart/build/web/* /usr/local/src/security_monkey/security_monkey/static/ - sudo chgrp -R www-data /usr/local/src/security_monkey - -Configure the Application -------------------------- - -Edit /usr/local/src/security_monkey/env-config/config-deploy.py: - -.. code-block:: python - - # Insert any config items here. - # This will be fed into Flask/SQLAlchemy inside security_monkey/__init__.py - - LOG_CFG = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'standard': { - 'format': '%(asctime)s %(levelname)s: %(message)s ' - '[in %(pathname)s:%(lineno)d]' - } - }, - 'handlers': { - 'file': { - 'class': 'logging.handlers.RotatingFileHandler', - 'level': 'DEBUG', - 'formatter': 'standard', - 'filename': '/var/log/security_monkey/securitymonkey.log', - 'maxBytes': 10485760, - 'backupCount': 100, - 'encoding': 'utf8' - }, - 'console': { - 'class': 'logging.StreamHandler', - 'level': 'DEBUG', - 'formatter': 'standard', - 'stream': 'ext://sys.stdout' - } - }, - 'loggers': { - 'security_monkey': { - 'handlers': ['file', 'console'], - 'level': 'DEBUG' - }, - 'apscheduler': { - 'handlers': ['file', 'console'], - 'level': 'INFO' - } - } - } - - SQLALCHEMY_DATABASE_URI = 'postgresql://securitymonkeyuser:securitymonkeypassword@localhost:5432/secmonkey' - - SQLALCHEMY_POOL_SIZE = 50 - SQLALCHEMY_MAX_OVERFLOW = 15 - ENVIRONMENT = 'ec2' - USE_ROUTE53 = False - FQDN = '' - API_PORT = '5000' - WEB_PORT = '443' - FRONTED_BY_NGINX = True - NGINX_PORT = '443' - WEB_PATH = '/static/ui.html' - BASE_URL = 'https://{}/'.format(FQDN) - - SECRET_KEY = '' - - MAIL_DEFAULT_SENDER = 'securitymonkey@.com' - SECURITY_REGISTERABLE = False - SECURITY_CONFIRMABLE = False - SECURITY_RECOVERABLE = False - SECURITY_PASSWORD_HASH = 'bcrypt' - SECURITY_PASSWORD_SALT = '' - SECURITY_TRACKABLE = True - - SECURITY_POST_LOGIN_VIEW = BASE_URL - SECURITY_POST_REGISTER_VIEW = BASE_URL - SECURITY_POST_CONFIRM_VIEW = BASE_URL - SECURITY_POST_RESET_VIEW = BASE_URL - SECURITY_POST_CHANGE_VIEW = BASE_URL - - # This address gets all change notifications - SECURITY_TEAM_EMAIL = [] - - # These are only required if using SMTP instead of SES - EMAILS_USE_SMTP = True # Otherwise, Use SES - SES_REGION = 'us-east-1' - MAIL_SERVER = 'smtp..com' - MAIL_PORT = 465 - MAIL_USE_SSL = True - MAIL_USERNAME = 'securitymonkey' - MAIL_PASSWORD = '' - - WTF_CSRF_ENABLED = True - WTF_CSRF_SSL_STRICT = True # Checks Referer Header. Set to False for API access. - WTF_CSRF_METHODS = ['DELETE', 'POST', 'PUT', 'PATCH'] - - # "NONE", "SUMMARY", or "FULL" - SECURITYGROUP_INSTANCE_DETAIL = 'FULL' - - # Threads used by the scheduler. - # You will likely need at least one core thread for every account being monitored. - CORE_THREADS = 25 - MAX_THREADS = 30 - - # SSO SETTINGS: - ACTIVE_PROVIDERS = [] # "ping", "google" or "onelogin" - - PING_NAME = '' # Use to override the Ping name in the UI. - PING_REDIRECT_URI = "{BASE}api/1/auth/ping".format(BASE=BASE_URL) - PING_CLIENT_ID = '' # Provided by your administrator - PING_AUTH_ENDPOINT = '' # Often something ending in authorization.oauth2 - PING_ACCESS_TOKEN_URL = '' # Often something ending in token.oauth2 - PING_USER_API_URL = '' # Often something ending in idp/userinfo.openid - PING_JWKS_URL = '' # Often something ending in JWKS - PING_SECRET = '' # Provided by your administrator - - GOOGLE_CLIENT_ID = '' - GOOGLE_AUTH_ENDPOINT = '' - GOOGLE_SECRET = '' - - ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator - ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address - ONELOGIN_DEFAULT_ROLE = 'View' # Default RBAC when user doesn't already exist - ONELOGIN_HTTPS = True # If using HTTPS strict mode will check the requests are HTTPS - ONELOGIN_SETTINGS = { - # If strict is True, then the Python Toolkit will reject unsigned - # or unencrypted messages if it expects them to be signed or encrypted. - # Also it will reject the messages if the SAML standard is not strictly - # followed. Destination, NameId, Conditions ... are validated too. - "strict": True, - - # Enable debug mode (outputs errors). - "debug": True, - - # Service Provider Data that we are deploying. - "sp": { - # Identifier of the SP entity (must be a URI) - "entityId": "{BASE}metadata/".format(BASE=BASE_URL), - # Specifies info about where and how the message MUST be - # returned to the requester, in this case our SP. - "assertionConsumerService": { - # URL Location where the from the IdP will be returned - "url": "{BASE}api/1/auth/onelogin?acs".format(BASE=BASE_URL), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports this endpoint for the - # HTTP-POST binding only. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" - }, - # If you need to specify requested attributes, set a - # attributeConsumingService. nameFormat, attributeValue and - # friendlyName can be omitted - #"attributeConsumingService": { - # "ServiceName": "SP test", - # "serviceDescription": "Test Service", - # "requestedAttributes": [ - # { - # "name": "", - # "isRequired": False, - # "nameFormat": "", - # "friendlyName": "", - # "attributeValue": "" - # } - # ] - #}, - # Specifies info about where and how the message MUST be - # returned to the requester, in this case our SP. - "singleLogoutService": { - # URL Location where the from the IdP will be returned - "url": "{BASE}api/1/auth/onelogin?sls".format(BASE=BASE_URL), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - # Specifies the constraints on the name identifier to be used to - # represent the requested subject. - # Take a look on src/onelogin/saml2/constants.py to see the NameIdFormat that are supported. - "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - # Usually x509cert and privateKey of the SP are provided by files placed at - # the certs folder. But we can also provide them with the following parameters - "x509cert": "", - "privateKey": "" - }, - - # Identity Provider Data that we want connected with our SP. - "idp": { - # Identifier of the IdP entity (must be a URI) - "entityId": "https://app.onelogin.com/saml/metadata/{APP_ID}".format(APP_ID=ONELOGIN_APP_ID), - # SSO endpoint info of the IdP. (Authentication Request protocol) - "singleSignOnService": { - # URL Target of the IdP where the Authentication Request Message - # will be sent. - "url": "https://app.onelogin.com/trust/saml2/http-post/sso/{APP_ID}".format(APP_ID=ONELOGIN_APP_ID), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - # SLO endpoint info of the IdP. - "singleLogoutService": { - # URL Location of the IdP where SLO Request will be sent. - "url": "https://app.onelogin.com/trust/saml2/http-redirect/slo/{APP_ID}".format(APP_ID=ONELOGIN_APP_ID), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - # Public x509 certificate of the IdP - "x509cert": "" - } - } - - from datetime import timedelta - PERMANENT_SESSION_LIFETIME=timedelta(minutes=60) # Will logout users after period of inactivity. - SESSION_REFRESH_EACH_REQUEST=True - SESSION_COOKIE_SECURE=True - SESSION_COOKIE_HTTPONLY=True - PREFERRED_URL_SCHEME='https' - - REMEMBER_COOKIE_DURATION=timedelta(minutes=60) # Can make longer if you want remember_me to be useful - REMEMBER_COOKIE_SECURE=True - REMEMBER_COOKIE_HTTPONLY=True - -A few things need to be modified in this file before we move on. - -**SQLALCHEMY_DATABASE_URI**: The value above will be correct for the username "postgres" with the password "securitymonkeypassword" and the database name of "secmonkey". Please edit this line if you have created a different database name or username or password. - -**FQDN**: You will need to enter the public DNS name you obtained when you launched the security monkey instance. For GCP, this is the IP address. - -**SECRET_KEY**: This is used by Flask modules to verify user sessions. Please use your own random string. (Keep it secret.) - -**SECURITY_CONFIRMABLE**: Leave this off (False) until you have configured and validated an SES account. More information will be made available on this topic soon. - -**SECURITY_RECOVERABLE**: Leave this off (False) until you have configured and validated an SES account. More information will be made available on this topic soon. - -**SECURITY_PASSWORD_SALT**: This is used by flask to salt credentials before putting them into the database. Please use your own random string. - -Other values are self-explanatory. - -SECURITY_MONKEY_SETTINGS: ----------------------------------- - -The SECURITY_MONKEY_SETTINGS environment variable needs to exist and should point to the config-deploy.py we just reviewed.:: - - $ export SECURITY_MONKEY_SETTINGS= - -For example:: - - $ export SECURITY_MONKEY_SETTINGS=/usr/local/src/security_monkey/env-config/config-deploy.py - -Create the database tables: ---------------------------- - -Security Monkey uses Flask-Migrate (Alembic) to keep database tables up to date. To create the tables, run this command:: - - cd /usr/local/src/security_monkey/ - sudo -E python manage.py db upgrade - -Add Amazon Accounts -========================== -This will add Amazon owned AWS accounts to security monkey. :: - - $ sudo -E python manage.py amazon_accounts - -Create the first user: ---------------------------- - -Users can be created on the command line or by registering in the web UI:: - - $ sudo -E python manage.py create_user "you@youremail.com" "Admin" - > Password: - > Confirm Password: - -create_user takes two parameters. 1) is the email address and 2) is the role. Roles should be one of these: [View Comment Justify Admin] - -Setting up Supervisor -===================== - -Supervisor will auto-start security monkey and will auto-restart security monkey if -it were to crash. - -.. code-block:: python - - # Control Startup/Shutdown: - # sudo supervisorctl - - [program:securitymonkey] - user=www-data - - environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config-deploy.py" - autostart=true - autorestart=true - command=python /usr/local/src/security_monkey/manage.py run_api_server - - [program:securitymonkeyscheduler] - user=www-data - autostart=true - autorestart=true - directory=/usr/local/src/security_monkey/ - environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config-deploy.py" - command=python /usr/local/src/security_monkey/manage.py start_scheduler - -Copy supervisor config:: - - sudo cp /usr/local/src/security_monkey/supervisor/security_monkey.conf /etc/supervisor/conf.d/security_monkey.conf - sudo service supervisor restart - sudo supervisorctl status - -Supervisor will attempt to start two python jobs and make sure they are running. The first job, securitymonkey, -is gunicorn, which it launches by calling manage.py run_api_server. - -The second job supervisor runs is the scheduler, which looks for changes every 15 minutes. **The scheduler will fail to start at this time because there are no accounts for it to monitor** Later, we will add an account and start the scheduler. - -You can track progress by tailing /var/log/security_monkey/securitymonkey.log. - -Create an SSL Certificate -========================= - -For this quickstart guide, we will use a self-signed SSL certificate. In production, you will want to use a certificate that has been signed by a trusted certificate authority.:: - - $ cd ~ - -There are some great instructions for generating a certificate on the Ubuntu website: - -`Ubuntu - Create a Self Signed SSL Certificate `_ - -The last commands you need to run from that tutorial are in the "Installing the Certificate" section: - -.. code-block:: bash - - sudo cp server.crt /etc/ssl/certs - sudo cp server.key /etc/ssl/private - -Once you have finished the instructions at the link above, and these two files are in your /etc/ssl/certs and /etc/ssl/private, you are ready to move on in this guide. - -Setup Nginx: -============ - -Security Monkey uses gunicorn to serve up content on its internal 127.0.0.1 address. For better performance, and to offload the work of serving static files, we wrap gunicorn with nginx. Nginx listens on 0.0.0.0 and proxies some connections to gunicorn for processing and serves up static files quickly. - -securitymonkey.conf -------------------- - -Save the config file below to: :: - - /etc/nginx/sites-available/securitymonkey.conf - -.. code-block:: nginx - - add_header X-Content-Type-Options "nosniff"; - add_header X-XSS-Protection "1; mode=block"; - add_header X-Frame-Options "SAMEORIGIN"; - add_header Strict-Transport-Security "max-age=631138519"; - add_header Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;"; - - server { - listen 0.0.0.0:443 ssl; - ssl_certificate /etc/ssl/certs/server.crt; - ssl_certificate_key /etc/ssl/private/server.key; - access_log /var/log/security_monkey/security_monkey.access.log; - error_log /var/log/security_monkey/security_monkey.error.log; - - location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) { - proxy_read_timeout 1800; - proxy_pass http://127.0.0.1:5000; - proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; - proxy_redirect off; - proxy_buffering off; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - location /static { - rewrite ^/static/(.*)$ /$1 break; - root /usr/local/src/security_monkey/security_monkey/static; - index ui.html; - } - - location / { - root /usr/local/src/security_monkey/security_monkey/static; - index ui.html; - } - - } - -Symlink the sites-available file to the sites-enabled folder:: - - $ sudo ln -s /etc/nginx/sites-available/securitymonkey.conf /etc/nginx/sites-enabled/securitymonkey.conf - -Delete the default configuration:: - - $ sudo rm /etc/nginx/sites-enabled/default - -Restart nginx:: - - $ sudo service nginx restart - -Logging into the UI -=================== - -You should now be able to reach your server - -.. image:: images/resized_login_page-1.png - -After you have registered a new account and logged in, you need to add an account for Security Monkey to monitor. Click on "Settings" in the very top menu bar. - -.. image:: images/resized_settings_link.png - -Adding an Account in the Web UI -------------------------------- - -Here you will see a list of the accounts Security Monkey is monitoring. (It should be empty.) - -Click on the plus sign to create a new account: - -.. image:: images/empty_settings_page.png - -Now we will provide Security Monkey with information about the account you would like to monitor. - -.. image:: images/empty_create_account_page.png - -When creating a new account in Security Monkey, you may use any "Name" that you would like. Example names are 'prod', 'test', 'dev', or 'it'. Names should be unique. - -The **S3 Name** has special meaning. This is the name used on S3 ACL policies. If you are unsure, it is probably the beginning of the email address that was used to create the AWS account. (If you signed up as super_geek@example.com, your s3 name is probably super_geek.) You can edit this value at any time. - -The **Number** is the AWS account number. This must be provided. - -**Notes** is an optional field. - -**Active** specifies whether Security Monkey should track policies and changes in this account. There are cases where you want Security Monkey to know about a friendly account, but don't want Security Monkey to track it's changes. - -**Third Party** This is a way to tell security monkey that the account is friendly and not owned by you. - -**Note: You will need to restart the scheduler whenever you add a new account or disable an existing account.** -We plan to remove this requirement in the future.:: - - $ sudo supervisorctl - securitymonkey RUNNING pid 11401, uptime 0:05:56 - securitymonkeyscheduler FATAL Exited too quickly (process log may have details) - supervisor> start securitymonkeyscheduler - securitymonkeyscheduler: started - supervisor> status - securitymonkey RUNNING pid 11401, uptime 0:06:49 - securitymonkeyscheduler RUNNING pid 11519, uptime 0:00:42 - supervisor> - -The first run will occur in 15 minutes. You can monitor all the log files in /var/log/security_monkey/. In the browser, you can hit the ```AutoRefresh``` button so the browser will attempt to load results every 30 seconds. - -**Note: You can also add accounts via the command line with manage.py**:: - - $ python manage.py add_account --number 12345678910 --name account_foo - Successfully added account account_foo - -If an account with the same number already exists, this will do nothing, unless you pass ``--force``, in which case, it will override the existing account:: - - $ python manage.py add_account --number 12345678910 --name account_foo - An account with id 12345678910 already exists - $ python manage.py add_account --number 12345678910 --name account_foo --active false --force - Successfully added account account_foo - -Now What? -========= - -Wow. We have accomplished a lot. Now we can use the Web UI to review our security posture. - -Searching in the Web UI ------------------------ - -On the Web UI, click the Search button at the top left. If the scheduler is setup correctly, we should now see items filling the table. These items are colored if they have issues. Yellow is for minor issues like friendly cross account access while red indicates more important security issues, like an S3 bucket granting access to "AllUsers" or a security group allowing 0.0.0.0/0. The newest results are always at the top. - -.. image:: images/search_results.png - -We can filter these results using the searchbox on the left. The Region, Tech, Account, and Name fields use auto-complete to help you find what you need. - -.. image:: images/filtered_search_1.png - -Security Monkey also provides you the ability to search only for issues: - -.. image:: images/issues_page.png - -Viewing an Item in the Web UI ------------------------------ - -Clicking on an item in the web UI brings up the view-item page. - -.. image:: images/item_with_issue.png - -This item has an attached issue. Someone has left SSH open to the Internet! Security Monkey helps you find these types of insecure configurations and correct them. - - -If Security Monkey finds an issue that you aren't worried about, you should justify the issue and leave a message explaining to others why the configuration is okay. - - -.. image:: images/justified_issue.png - -Security Monkey looks for changes in configurations. When there is a change, it uses colors to show you the part of the configuration that was affected. Green tells you that a section was added while red says something has been removed. - -.. image:: images/colored_JSON.png - -Each revision to an item can have comments attached. These can explain why a change was made. - -.. image:: images/revision_comments.png - - -Productionalizing Security Monkey -================================= - -This guide has been focused on getting Security Monkey up and running quickly. For a production deployment, you should make a few changes. - -Location --------- - -Run security_monkey from a separate account. This will help isolate the instance and the database and ensure the integrity of the change data. - -SES ---- - -Security Monkey uses SES to send email. While you can install and use Security Monkey without SES, it is recommended that you eventually setup SES to receive Change Reports and Audit Reports. Enabling SES also allows you to enable the "forgot my password" flow and force users to confirm their email addresses when registering for an account. - -To begin the process, you will need to request that AWS enable SES on your account - -.. image:: images/SES_LIMITED.png - -TODO: Add further documentation on setting up and confirming SES. - -RDS ---- - -In this guide, we setup a postgres database on the instance we launched. This would be a horrible way to run in production. You would lose all your data whenever Chaos Monkey unplugged your instance! - -Make sure you move your database to an RDS instance. Create a database user with limited permissions and use a different password than the one used in this guide. - - -Logs ----- - -If you are relying on security monkey, you really need to ensure that it is running correctly and not hitting a bizarre exception. - -Check the Security Monkey logs occasionally. Let us know if you are seeing exceptions, or better yet, send us a pull request. - -Justify Issues --------------- - -The daily audit report and the issues-search are most helpful when all the existing issues are worked or justified. Spend some time to work through the issues found today, so that the ones found tomorrow pop out and catch your attention. - -SSL ---- - -In this guide, we setup a self-signed SSL certificate. For production, you will want to use a certificate that has been signed by a trusted certificate authority. You can also attach an SSL cert to an ELB listener. If so, please use the latest listener reference policy to avoid deprecated ciphers and TLS/SSLv3 attacks. - - -Ignore List -------------- - -If your environment has rapidly changing items that you would prefer not to track in security monkey, please look at the "Ignore List" under the settings page. You can provide a list of prefixes for each technology, and Security Monkey will ignore those objects when it is inspecting your current AWS configuration. **Be careful: an attacker could use the ignore list to subvert your monitoring.** - -Contribute ----------- - -It's easy to extend security_monkey with new rules or new technologies. If you have a good idea, **please send us a pull request**. I'll be delighted to include them. diff --git a/docs/userguide.md b/docs/userguide.md new file mode 100644 index 000000000..ceb04dc77 --- /dev/null +++ b/docs/userguide.md @@ -0,0 +1,105 @@ +User Guide +========== + +Logging into the UI +=================== + +You should now be able to reach your server + +![image](images/resized_login_page-1.png) + +After you have registered a new account and logged in, you need to add an account for Security Monkey to monitor. Click on "Settings" in the very top menu bar. + +![image](images/resized_settings_link.png) + +Adding an Account in the Web UI +------------------------------- + +Here you will see a list of the accounts Security Monkey is monitoring. (It should be empty.) + +Click on the plus sign to create a new account: + +![image](images/empty_settings_page.png) + +Now we will provide Security Monkey with information about the account you would like to monitor. + +![image](images/empty_create_account_page.png) + +When creating a new account in Security Monkey, you may use any "Name" that you would like. Example names are 'prod', 'test', 'dev', or 'it'. Names should be unique. + +The **S3 Name** has special meaning. This is the name used on S3 ACL policies. If you are unsure, it is probably the beginning of the email address that was used to create the AWS account. (If you signed up as , your s3 name is probably super\_geek.) You can edit this value at any time. + +The **Number** is the AWS account number. This must be provided. + +**Notes** is an optional field. + +**Active** specifies whether Security Monkey should track policies and changes in this account. There are cases where you want Security Monkey to know about a friendly account, but don't want Security Monkey to track it's changes. + +**Third Party** This is a way to tell security monkey that the account is friendly and not owned by you. + +**Note: You will need to restart the scheduler whenever you add a new account or disable an existing account.** We plan to remove this requirement in the future.: + + $ sudo supervisorctl + securitymonkey RUNNING pid 11401, uptime 0:05:56 + securitymonkeyscheduler FATAL Exited too quickly (process log may have details) + supervisor> start securitymonkeyscheduler + securitymonkeyscheduler: started + supervisor> status + securitymonkey RUNNING pid 11401, uptime 0:06:49 + securitymonkeyscheduler RUNNING pid 11519, uptime 0:00:42 + supervisor> + +The first run will occur in 15 minutes. You can monitor all the log files in /var/log/security\_monkey/. In the browser, you can hit the `` `AutoRefresh ``\` button so the browser will attempt to load results every 30 seconds. + +**Note: You can also add accounts via the command line with manage.py**: + + $ python manage.py add_account_aws --number 12345678910 --name account_foo + Successfully added account account_foo + +If an account with the same number already exists, this will do nothing, unless you pass `--force`, in which case, it will override the existing account: + + $ python manage.py add_account_aws --number 12345678910 --name account_foo + An account with id 12345678910 already exists + $ python manage.py add_account_aws --number 12345678910 --name account_foo --active false --force + Successfully added account account_foo + +Now What? +========= + +Wow. We have accomplished a lot. Now we can use the Web UI to review our security posture. + +Searching in the Web UI +----------------------- + +On the Web UI, click the Search button at the top left. If the scheduler is setup correctly, we should now see items filling the table. These items are colored if they have issues. Yellow is for minor issues like friendly cross account access while red indicates more important security issues, like an S3 bucket granting access to "AllUsers" or a security group allowing 0.0.0.0/0. The newest results are always at the top. + +![image](images/search_results.png) + +We can filter these results using the searchbox on the left. The Region, Tech, Account, and Name fields use auto-complete to help you find what you need. + +![image](images/filtered_search_1.png) + +Security Monkey also provides you the ability to search only for issues: + +![image](images/issues_page.png) + +Viewing an Item in the Web UI +----------------------------- + +Clicking on an item in the web UI brings up the view-item page. + +![image](images/item_with_issue.png) + +This item has an attached issue. Someone has left SSH open to the Internet! Security Monkey helps you find these types of insecure configurations and correct them. + +If Security Monkey finds an issue that you aren't worried about, you should justify the issue and leave a message explaining to others why the configuration is okay. + +![image](images/justified_issue.png) + +Security Monkey looks for changes in configurations. When there is a change, it uses colors to show you the part of the configuration that was affected. Green tells you that a section was added while red says something has been removed. + +![image](images/colored_JSON.png) + +Each revision to an item can have comments attached. These can explain why a change was made. + +![image](images/revision_comments.png) diff --git a/docs/userguide.rst b/docs/userguide.rst deleted file mode 100644 index 4d58663c2..000000000 --- a/docs/userguide.rst +++ /dev/null @@ -1,14 +0,0 @@ -========== -User Guide -========== - -Adding an Account -================= - -Once you have Security Monkey up and running you will need to add an account to monitor. Security Monkey does not -monitor the account it is running in by default and requires you to add the account through the GUI. - -Navigate to https://FQDN:WEB_PORT/#/createaccount - -Fill in the required information and create an account. - diff --git a/docs/using_supervisor.rst b/docs/using_supervisor.md similarity index 70% rename from docs/using_supervisor.rst rename to docs/using_supervisor.md index 38a99cb74..61de5fef8 100644 --- a/docs/using_supervisor.rst +++ b/docs/using_supervisor.md @@ -1,13 +1,9 @@ - -================= Using supervisor -================= +================ -Supervisor is a very nice way to manage you Python processes. We won't cover -the setup (which is just apt-get install supervisor or pip install supervisor -most of the time), but here is a quick overview on how to use it. +Supervisor is a very nice way to manage you Python processes. We won't cover the setup (which is just apt-get install supervisor or pip install supervisor most of the time), but here is a quick overview on how to use it. -Create a configuration file named security_monkey.conf under /etc/supervisor/conf.d/:: +Create a configuration file named security\_monkey.conf under /etc/supervisor/conf.d/: # Control Startup/Shutdown: # sudo supervisorctl @@ -27,29 +23,25 @@ Create a configuration file named security_monkey.conf under /etc/supervisor/con environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/secmonkey-config/env-config/config-local.py" command=python /usr/local/src/security_monkey/manage.py start_scheduler - -The 4 first entries are just boiler plate to get you started, you can copy -them verbatim. +The 4 first entries are just boiler plate to get you started, you can copy them verbatim. The last one define one (you can have many) process supervisor should manage. -It means it will run the command:: +It means it will run the command: python manage.py run_api_server - In the directory, with the environment and the user you defined. This command will be ran as a daemon, in the background. -`autostart` and `autorestart` just make it fire and forget: the site will always be -running, even it crashes temporarily or if you restart the machine. +autostart and autorestart just make it fire and forget: the site will always be running, even it crashes temporarily or if you restart the machine. -Normally run supervisor:: +Normally run supervisor: sudo service supervisor restart -Then you can manage the process by running:: +Then you can manage the process by running: sudo supervisorctl diff --git a/env-config/config-local.py b/env-config/config-local.py deleted file mode 100644 index 75f15cd74..000000000 --- a/env-config/config-local.py +++ /dev/null @@ -1,211 +0,0 @@ -# Copyright 2014 Netflix, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# Insert any config items for local devleopment here. -# This will be fed into Flask/SQLAlchemy inside security_monkey/__init__.py - -LOG_CFG = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'standard': { - 'format': '%(asctime)s %(levelname)s: %(message)s ' - '[in %(pathname)s:%(lineno)d]' - } - }, - 'handlers': { - 'file': { - 'class': 'logging.handlers.RotatingFileHandler', - 'level': 'DEBUG', - 'formatter': 'standard', - 'filename': 'security_monkey-local.log', - 'maxBytes': 10485760, - 'backupCount': 100, - 'encoding': 'utf8' - }, - 'console': { - 'class': 'logging.StreamHandler', - 'level': 'DEBUG', - 'formatter': 'standard', - 'stream': 'ext://sys.stdout' - } - }, - 'loggers': { - 'security_monkey': { - 'handlers': ['file', 'console'], - 'level': 'INFO' - }, - 'apscheduler': { - 'handlers': ['file', 'console'], - 'level': 'WARN' - } - } -} - -SQLALCHEMY_DATABASE_URI = 'postgresql://securitymonkeyuser:securitymonkeypass@localhost:5432/securitymonkeydb' - -SQLALCHEMY_POOL_SIZE = 50 -SQLALCHEMY_MAX_OVERFLOW = 15 -ENVIRONMENT = 'local' -USE_ROUTE53 = False -FQDN = 'localhost' -API_PORT = '5000' -WEB_PORT = '5000' -WEB_PATH = '/static/ui.html' -FRONTED_BY_NGINX = False -NGINX_PORT = '80' -BASE_URL = 'http://{}:{}{}'.format(FQDN, WEB_PORT, WEB_PATH) - -SECRET_KEY = '' - -MAIL_DEFAULT_SENDER = 'securitymonkey@example.com' -SECURITY_REGISTERABLE = False -SECURITY_CONFIRMABLE = False -SECURITY_RECOVERABLE = False -SECURITY_PASSWORD_HASH = 'bcrypt' -SECURITY_PASSWORD_SALT = '' -SECURITY_TRACKABLE = True - -SECURITY_POST_LOGIN_VIEW = BASE_URL -SECURITY_POST_REGISTER_VIEW = BASE_URL -SECURITY_POST_CONFIRM_VIEW = BASE_URL -SECURITY_POST_RESET_VIEW = BASE_URL -SECURITY_POST_CHANGE_VIEW = BASE_URL - -# This address gets all change notifications (i.e. 'securityteam@example.com') -SECURITY_TEAM_EMAIL = [] - -# These are only required if using SMTP instead of SES -EMAILS_USE_SMTP = False # Otherwise, Use SES -SES_REGION = 'us-east-1' -MAIL_SERVER = 'smtp.example.com' -MAIL_PORT = 465 -MAIL_USE_SSL = True -MAIL_USERNAME = 'username' -MAIL_PASSWORD = 'password' - -WTF_CSRF_ENABLED = False -WTF_CSRF_SSL_STRICT = True # Checks Referer Header. Set to False for API access. -WTF_CSRF_METHODS = ['DELETE', 'POST', 'PUT', 'PATCH'] - -# "NONE", "SUMMARY", or "FULL" -SECURITYGROUP_INSTANCE_DETAIL = 'FULL' - -# SSO SETTINGS: -ACTIVE_PROVIDERS = [] # "ping", "google" or "onelogin" - -PING_NAME = '' # Use to override the Ping name in the UI. -PING_REDIRECT_URI = "http://{FQDN}:{PORT}/api/1/auth/ping".format(FQDN=FQDN, PORT=WEB_PORT) -PING_CLIENT_ID = '' # Provided by your administrator -PING_AUTH_ENDPOINT = '' # Often something ending in authorization.oauth2 -PING_ACCESS_TOKEN_URL = '' # Often something ending in token.oauth2 -PING_USER_API_URL = '' # Often something ending in idp/userinfo.openid -PING_JWKS_URL = '' # Often something ending in JWKS -PING_SECRET = '' # Provided by your administrator - -GOOGLE_CLIENT_ID = '' -GOOGLE_AUTH_ENDPOINT = '' -GOOGLE_SECRET = '' -# GOOGLE_HOSTED_DOMAIN = 'example.com' # Verify that token issued by comes from domain - -ONELOGIN_APP_ID = '' # OneLogin App ID provider by your administrator -ONELOGIN_EMAIL_FIELD = 'User.email' # SAML attribute used to provide email address -ONELOGIN_DEFAULT_ROLE = 'View' # Default RBAC when user doesn't already exist -ONELOGIN_HTTPS = True # If using HTTPS strict mode will check the requests are HTTPS -ONELOGIN_SETTINGS = { - # If strict is True, then the Python Toolkit will reject unsigned - # or unencrypted messages if it expects them to be signed or encrypted. - # Also it will reject the messages if the SAML standard is not strictly - # followed. Destination, NameId, Conditions ... are validated too. - "strict": True, - - # Enable debug mode (outputs errors). - "debug": True, - - # Service Provider Data that we are deploying. - "sp": { - # Identifier of the SP entity (must be a URI) - "entityId": "http://{FQDN}:{PORT}/metadata/".format(FQDN=FQDN, PORT=WEB_PORT), - # Specifies info about where and how the message MUST be - # returned to the requester, in this case our SP. - "assertionConsumerService": { - # URL Location where the from the IdP will be returned - "url": "http://{FQDN}:{PORT}/api/1/auth/onelogin?acs".format(FQDN=FQDN, PORT=WEB_PORT), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports this endpoint for the - # HTTP-POST binding only. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" - }, - # If you need to specify requested attributes, set a - # attributeConsumingService. nameFormat, attributeValue and - # friendlyName can be omitted - #"attributeConsumingService": { - # "ServiceName": "SP test", - # "serviceDescription": "Test Service", - # "requestedAttributes": [ - # { - # "name": "", - # "isRequired": False, - # "nameFormat": "", - # "friendlyName": "", - # "attributeValue": "" - # } - # ] - #}, - # Specifies info about where and how the message MUST be - # returned to the requester, in this case our SP. - "singleLogoutService": { - # URL Location where the from the IdP will be returned - "url": "http://{FQDN}:{PORT}/api/1/auth/onelogin?sls".format(FQDN=FQDN, PORT=WEB_PORT), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - # Specifies the constraints on the name identifier to be used to - # represent the requested subject. - # Take a look on src/onelogin/saml2/constants.py to see the NameIdFormat that are supported. - "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - # Usually x509cert and privateKey of the SP are provided by files placed at - # the certs folder. But we can also provide them with the following parameters - "x509cert": "", - "privateKey": "" - }, - - # Identity Provider Data that we want connected with our SP. - "idp": { - # Identifier of the IdP entity (must be a URI) - "entityId": "https://app.onelogin.com/saml/metadata/{APP_ID}".format(APP_ID=ONELOGIN_APP_ID), - # SSO endpoint info of the IdP. (Authentication Request protocol) - "singleSignOnService": { - # URL Target of the IdP where the Authentication Request Message - # will be sent. - "url": "https://app.onelogin.com/trust/saml2/http-post/sso/{APP_ID}".format(APP_ID=ONELOGIN_APP_ID), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - # SLO endpoint info of the IdP. - "singleLogoutService": { - # URL Location of the IdP where SLO Request will be sent. - "url": "https://app.onelogin.com/trust/saml2/http-redirect/slo/{APP_ID}".format(APP_ID=ONELOGIN_APP_ID), - # SAML protocol binding to be used when returning the - # message. OneLogin Toolkit supports the HTTP-Redirect binding - # only for this endpoint. - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - }, - # Public x509 certificate of the IdP - "x509cert": "" - } -} diff --git a/env-config/config-deploy.py b/env-config/config.py similarity index 99% rename from env-config/config-deploy.py rename to env-config/config.py index a3a837d59..d5a5d894e 100644 --- a/env-config/config-deploy.py +++ b/env-config/config.py @@ -43,7 +43,7 @@ 'loggers': { 'security_monkey': { 'handlers': ['file', 'console'], - 'level': 'DEBUG' + 'level': 'WARN' }, 'apscheduler': { 'handlers': ['file', 'console'], diff --git a/nginx/security_monkey.conf b/nginx/security_monkey.conf new file mode 100644 index 000000000..504e5279f --- /dev/null +++ b/nginx/security_monkey.conf @@ -0,0 +1,36 @@ +add_header X-Content-Type-Options "nosniff"; +add_header X-XSS-Protection "1; mode=block"; +add_header X-Frame-Options "SAMEORIGIN"; +add_header Strict-Transport-Security "max-age=631138519"; +add_header Content-Security-Policy "default-src 'self'; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://ajax.googleapis.com; style-src 'self' https://fonts.googleapis.com;"; + +server { + listen 0.0.0.0:443 ssl; + ssl_certificate /etc/ssl/certs/server.crt; + ssl_certificate_key /etc/ssl/private/server.key; + access_log /var/log/security_monkey/security_monkey.access.log; + error_log /var/log/security_monkey/security_monkey.error.log; + + location ~* ^/(reset|confirm|healthcheck|register|login|logout|api) { + proxy_read_timeout 1800; + proxy_pass http://127.0.0.1:5000; + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + proxy_redirect off; + proxy_buffering off; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + location /static { + rewrite ^/static/(.*)$ /$1 break; + root /usr/local/src/security_monkey/security_monkey/static; + index ui.html; + } + + location / { + root /usr/local/src/security_monkey/security_monkey/static; + index ui.html; + } + +} \ No newline at end of file diff --git a/scripts/secmonkey_auto_install.sh b/scripts/secmonkey_auto_install.sh index 621598357..df1c0d17e 100755 --- a/scripts/secmonkey_auto_install.sh +++ b/scripts/secmonkey_auto_install.sh @@ -213,7 +213,7 @@ create_static_var () dir_super="$dir_sm/supervisor" # Supervisor Directory in Security Monkey dir_nginx_log="/var/log/nginx/log" dir_ssl="/etc/ssl" - file_deploy="$dir_config/config-deploy.py" + file_deploy="$dir_config/config.py" file_ini="$dir_super/security_monkey.conf" file_rc="$HOME/.bashrc" diff --git a/security_monkey/__init__.py b/security_monkey/__init__.py index 2678c8e0f..f0d38b12c 100644 --- a/security_monkey/__init__.py +++ b/security_monkey/__init__.py @@ -28,9 +28,28 @@ from flask.helpers import make_response from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager +import os app = Flask(__name__, static_url_path='/static') -app.config.from_envvar("SECURITY_MONKEY_SETTINGS") + +# If SECURITY_MONKEY_SETTINGS is set, then use that. +# Otherwise, use env-config/config.py +if os.environ.get('SECURITY_MONKEY_SETTINGS'): + app.config.from_envvar('SECURITY_MONKEY_SETTINGS') +else: + # find env-config/config.py + from os.path import dirname, join, isfile + path = dirname(dirname(__file__)) + path = join(path, 'env-config') + path = join(path, 'config.py') + + if isfile(path): + app.config.from_pyfile(path) + else: + print('PLEASE SET A CONFIG FILE WITH SECURITY_MONKEY_SETTINGS OR PUT ONE AT env-config/config.py') + exit(-1) + + db = SQLAlchemy(app) # For ELB and/or Eureka diff --git a/setup.py b/setup.py index e9fcabacc..42769e72d 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ 'Flask-SQLAlchemy==1.0', 'Flask-Script==0.6.3', # 'Flask-Security==1.7.4', - 'Flask-Security-Fork==1.8.2', + 'Flask-Security-Fork==2.0.1', 'Flask-WTF>=0.14.2', 'Jinja2>=2.8.1', 'SQLAlchemy==0.9.2', diff --git a/supervisor/security_monkey.conf b/supervisor/security_monkey.conf index 9c7178cc6..34b2bdc3a 100644 --- a/supervisor/security_monkey.conf +++ b/supervisor/security_monkey.conf @@ -7,10 +7,9 @@ [program:securitymonkey] user=www-data - -environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config-deploy.py" autostart=true autorestart=true +environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config.py",PATH="/usr/local/src/security_monkey/venv:%(ENV_PATH)s" command=python /usr/local/src/security_monkey/manage.py run_api_server [program:securitymonkeyscheduler] @@ -18,5 +17,5 @@ user=www-data autostart=true autorestart=true directory=/usr/local/src/security_monkey/ -environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config-deploy.py" +environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config.py",PATH="/usr/local/src/security_monkey/venv:%(ENV_PATH)s" command=python /usr/local/src/security_monkey/manage.py start_scheduler From 9cb374c392a874606661bc598dfe5ba79f52e473 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Tue, 11 Apr 2017 16:56:14 -0700 Subject: [PATCH 85/90] Quickstart GCP Fixes (#659) * Quickstart updates Removing lots of `sudo`. Ran through on a new GCP instance and made some changes for clarity. * quickstart tweaks * Updating supervisor virtualenv path * quickstart updates --- docs/quickstart.md | 134 +++++++++++++------------------- supervisor/security_monkey.conf | 4 +- 2 files changed, 58 insertions(+), 80 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index a1d6fa584..84d0a6b8f 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -9,35 +9,18 @@ Security Monkey can run on an Amazon EC2 (AWS) instance or a Google Cloud Platfo IAM Permissions --------------- -- For AWS, please see [AWS IAM instructions](iam_aws.md). -- For GCP, please see [GCP IAM instructions](iam_gcp.md). +- [AWS IAM instructions](iam_aws.md). +- [GCP IAM instructions](iam_gcp.md). Database -------- Security Monkey needs a postgres database. Select one of the following: -- Local Postgres +- Local Postgres (You'll set this up later once you have an instance up.) - [Postgres on AWS RDS](postgres_aws.md). - [Postgres on GCP's Cloud SQL](postgres_gcp.md). -### Local Postgres - -Install Posgres: - - sudo apt-get install postgresql postgresql-contrib - -Configure the DB: - - sudo -u postgres psql - CREATE DATABASE "secmonkey"; - CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypassword'; - CREATE SCHEMA secmonkey; - GRANT Usage, Create ON SCHEMA "secmonkey" TO "securitymonkeyuser"; - set timezone TO 'GMT'; - select now(); - \q - Launch an Instance: ------------------- @@ -51,49 +34,57 @@ Install Security Monkey on your Instance Installation Steps: - Prerequisites +- Setup a local postgres server - Clone security_monkey - Compile (or Download) the web UI - Review the config ### Prerequisites -We now have a fresh install of Ubuntu. Let's add the hostname to the hosts file: - - $ hostname - ip-172-30-0-151 - -Add this to /etc/hosts: (Use nano if you're not familiar with vi.): - - $ sudo vi /etc/hosts - 127.0.0.1 ip-172-30-0-151 - Create the logging folders: sudo mkdir /var/log/security_monkey sudo mkdir /var/www - sudo chown www-data /var/log/security_monkey + sudo chown -R `whoami`:www-data /var/log/security_monkey/ sudo chown www-data /var/www Let's install the tools we need for Security Monkey: - $ sudo apt-get update - $ sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev gcc python-virtualenv + sudo apt-get update + sudo apt-get -y install python-pip python-dev python-psycopg2 postgresql postgresql-contrib libpq-dev nginx supervisor git libffi-dev gcc python-virtualenv + +### Local Postgres + +If you're not ready to setup AWS RDS or Cloud SQL, follow these instructions to setup a local postgres DB. + +Install Postgres: + + sudo apt-get install postgresql postgresql-contrib + +Configure the DB: + + sudo -u postgres psql + CREATE DATABASE "secmonkey"; + CREATE ROLE "securitymonkeyuser" LOGIN PASSWORD 'securitymonkeypassword'; + CREATE SCHEMA secmonkey; + GRANT Usage, Create ON SCHEMA "secmonkey" TO "securitymonkeyuser"; + set timezone TO 'GMT'; + select now(); + \q ### Clone security_monkey Releases are on the master branch and are updated about every three months. Bleeding edge features are on the develop branch. cd /usr/local/src - sudo git clone --depth 1 --branch master https://github.com/Netflix/security_monkey.git + sudo git clone --depth 1 --branch develop https://github.com/Netflix/security_monkey.git + sudo chown -R `whoami`:www-data /usr/local/src/security_monkey cd security_monkey - sudo virtualenv venv - sudo pip install --upgrade setuptools - sudo python setup.py install - -Fix ownership for python modules: - - sudo usermod -a -G staff www-data - sudo chgrp staff /usr/local/lib/python2.7/dist-packages/*.egg + virtualenv venv + source venv/bin/activate + pip install --upgrade setuptools + pip install google-compute-engine # Only required on GCP + python setup.py install ### Compile (or Download) the web UI @@ -113,23 +104,23 @@ If you're using the bleeding edge (develop) branch, you will need to compile the # Build the Web UI cd /usr/local/src/security_monkey/dart - sudo /usr/lib/dart/bin/pub get - sudo /usr/lib/dart/bin/pub build + /usr/lib/dart/bin/pub get + /usr/lib/dart/bin/pub build # Copy the compiled Web UI to the appropriate destination - sudo mkdir -p /usr/local/src/security_monkey/security_monkey/static/ - sudo /bin/cp -R /usr/local/src/security_monkey/dart/build/web/* /usr/local/src/security_monkey/security_monkey/static/ - sudo chgrp -R www-data /usr/local/src/security_monkey + mkdir -p /usr/local/src/security_monkey/security_monkey/static/ + /bin/cp -R /usr/local/src/security_monkey/dart/build/web/* /usr/local/src/security_monkey/security_monkey/static/ + chgrp -R www-data /usr/local/src/security_monkey ### Configure the Application Security Monkey ships with a config for this quickstart guide called config.py. You can override this behavior by setting the `SECURITY_MONKEY_SETTINGS` environment variable. Modify `env-config/config.py`: -- FQDN: Add the IP or DNS entry of your instance. -- SQLACHEMY_DATABASE_URI: This config assumes that you are using the local db option. If you setup AWS RDS or GCP Cloud SQL as your database, you will need to modify the SQLACHEMY_DATABASE_URI to point to your DB. -- SECRET_KEY: Something random. -- SECURITY_PASSWORD_SALT: Something random. +- `FQDN`: Add the IP or DNS entry of your instance. +- `SQLACHEMY_DATABASE_URI`: This config assumes that you are using the local db option. If you setup AWS RDS or GCP Cloud SQL as your database, you will need to modify the SQLACHEMY_DATABASE_URI to point to your DB. +- `SECRET_KEY`: Something random. +- `SECURITY_PASSWORD_SALT`: Something random. For an explanation of the configuration options, see [options](options.md). @@ -138,7 +129,7 @@ For an explanation of the configuration options, see [options](options.md). Security Monkey uses Flask-Migrate (Alembic) to keep database tables up to date. To create the tables, run this command: cd /usr/local/src/security_monkey/ - sudo -E python manage.py db upgrade + python manage.py db upgrade Populate Security Monkey with Accounts -------------------------------------- @@ -147,20 +138,20 @@ Populate Security Monkey with Accounts This will add Amazon owned AWS accounts to security monkey. : - $ sudo -E python manage.py amazon_accounts + python manage.py amazon_accounts ### Add Your AWS/GCP Accounts You'll need to add at least one account before starting the scheduler. It's easiest to add them from the command line, but it can also be done through the web UI. : - $ python manage.py add_account_aws + python manage.py add_account_aws usage: manage.py add_account_aws [-h] -n NAME [--thirdparty] [--active] [--notes NOTES] --id IDENTIFIER [--update-existing] [--canonical_id CANONICAL_ID] [--s3_name S3_NAME] [--role_name ROLE_NAME] - $ python manage.py add_account_gcp + python manage.py add_account_gcp usage: manage.py add_account_gcp [-h] -n NAME [--thirdparty] [--active] [--notes NOTES] --id IDENTIFIER [--update-existing] [--creds_file CREDS_FILE] @@ -169,18 +160,13 @@ You'll need to add at least one account before starting the scheduler. It's easi Users can be created on the command line or by registering in the web UI: - $ sudo -E python manage.py create_user "you@youremail.com" "Admin" + $ python manage.py create_user "you@youremail.com" "Admin" > Password: > Confirm Password: -create\_user takes two parameters. 1) is the email address and 2) is the role. - -Roles should be one of these: - -- View -- Comment -- Justify -- Admin +`create_user` takes two parameters: +- email address +- role (One of `[View, Comment, Justify, Admin]`) Setting up Supervisor --------------------- @@ -189,15 +175,16 @@ Supervisor will auto-start security monkey and will auto-restart security monkey Copy supervisor config: + chgrp -R www-data /var/log/security_monkey sudo cp /usr/local/src/security_monkey/supervisor/security_monkey.conf /etc/supervisor/conf.d/security_monkey.conf sudo service supervisor restart sudo supervisorctl status -Supervisor will attempt to start two python jobs and make sure they are running. The first job, securitymonkey, is gunicorn, which it launches by calling manage.py run\_api\_server. +Supervisor will attempt to start two python jobs and make sure they are running. The first job, securitymonkey, is gunicorn, which it launches by calling manage.py `run_api_server`. The second job supervisor runs is the scheduler, which polls for changes. -You can track progress by tailing /var/log/security\_monkey/securitymonkey.log. +You can track progress by tailing `/var/log/security_monkey/securitymonkey.log`. Create an SSL Certificate ------------------------- @@ -229,18 +216,9 @@ Security Monkey uses gunicorn to serve up content on its internal 127.0.0.1 addr Copy the config file into place: sudo cp /usr/local/src/security_monkey/nginx/security_monkey.conf /etc/nginx/sites-available/security_monkey.conf - -Symlink the sites-available file to the sites-enabled folder: - - $ sudo ln -s /etc/nginx/sites-available/security_monkey.conf /etc/nginx/sites-enabled/security_monkey.conf - -Delete the default configuration: - - $ sudo rm /etc/nginx/sites-enabled/default - -Restart nginx: - - $ sudo service nginx restart + sudo ln -s /etc/nginx/sites-available/security_monkey.conf /etc/nginx/sites-enabled/security_monkey.conf + sudo rm /etc/nginx/sites-enabled/default + sudo service nginx restart Logging into the UI ------------------- diff --git a/supervisor/security_monkey.conf b/supervisor/security_monkey.conf index 34b2bdc3a..faf70e0e3 100644 --- a/supervisor/security_monkey.conf +++ b/supervisor/security_monkey.conf @@ -9,7 +9,7 @@ user=www-data autostart=true autorestart=true -environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config.py",PATH="/usr/local/src/security_monkey/venv:%(ENV_PATH)s" +environment=PYTHONPATH='/usr/local/src/security_monkey/',PATH="/usr/local/src/security_monkey/venv/bin:%(ENV_PATH)s" command=python /usr/local/src/security_monkey/manage.py run_api_server [program:securitymonkeyscheduler] @@ -17,5 +17,5 @@ user=www-data autostart=true autorestart=true directory=/usr/local/src/security_monkey/ -environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/security_monkey/env-config/config.py",PATH="/usr/local/src/security_monkey/venv:%(ENV_PATH)s" +environment=PYTHONPATH='/usr/local/src/security_monkey/',PATH="/usr/local/src/security_monkey/venv/bin:%(ENV_PATH)s" command=python /usr/local/src/security_monkey/manage.py start_scheduler From f96ba77de0cf7486562c9cb6e2702527fdaab518 Mon Sep 17 00:00:00 2001 From: ume Date: Wed, 12 Apr 2017 09:12:35 +0900 Subject: [PATCH 86/90] Fix no principal error and deep nest (#625) --- security_monkey/auditors/s3.py | 39 ++++++++++++++-------------------- 1 file changed, 16 insertions(+), 23 deletions(-) diff --git a/security_monkey/auditors/s3.py b/security_monkey/auditors/s3.py index a6ed856b0..99c48225e 100644 --- a/security_monkey/auditors/s3.py +++ b/security_monkey/auditors/s3.py @@ -102,36 +102,29 @@ def check_policy(self, s3_item): self.inspect_policy_conditionals(statement, s3_item) def inspect_policy_allow_all(self, statement, s3_item): - if statement['Effect'] == "Allow": - if statement['Principal'] == "*": + if statement.get('Effect') == "Allow": + principal = statement.get('Principal') + if isinstance(principal, basestring) and principal == "*": message = "POLICY - This Policy Allows Access From Anyone." self.add_issue(10, message, s3_item) return - if 'AWS' in statement['Principal']: - if statement['Principal']['AWS'] == "*": - message = "POLICY - This Policy Allows Access From Anyone." - self.add_issue(10, message, s3_item) - return + if isinstance(principal, dict) and principal.get('AWS') == "*": + message = "POLICY - This Policy Allows Access From Anyone." + self.add_issue(10, message, s3_item) + return def inspect_policy_cross_account(self, statement, s3_item, complained): try: - if 'Effect' in statement: - effect = statement['Effect'] - if effect == 'Allow': - if 'Principal' in statement: - principal = statement["Principal"] - if type(principal) is dict and 'AWS' in principal: - aws_entries = principal["AWS"] - if type(aws_entries) is str or type(aws_entries) is unicode: - if aws_entries[0:26] not in complained: - self.process_cross_account(aws_entries, s3_item) - complained.append(aws_entries[0:26]) - else: - for aws_entry in aws_entries: - if aws_entry[0:26] not in complained: - self.process_cross_account(aws_entry, s3_item) - complained.append(aws_entry[0:26]) + if statement.get('Effect') == 'Allow' and isinstance(statement.get("Principal"), dict): + aws_entries = statement["Principal"].get("AWS", []) + if isinstance(aws_entries, basestring): + aws_entries = [aws_entries] + for aws_entry in aws_entries: + if aws_entry not in complained: + self.process_cross_account(aws_entry, s3_item) + complained.append(aws_entry) + except Exception as e: print("Exception in cross_account. {} {}".format(Exception, e)) import traceback From 4a0850f45e4208e3f130dc59743105aab102841d Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 13 Apr 2017 12:05:44 -0700 Subject: [PATCH 87/90] Replacing `python manage.py` with `monkey` (#662) * Creating monkey entrypoint to replace python manage.py * Fixing MD headers * Adding README links to custom watchers and auditors. * Updating travis to use monkey commands * Removing manage.py imports --- .travis.yml | 2 +- README.md | 2 +- docker/README.md | 12 + docker/README.rst | 9 - docker/api-init.sh | 4 +- docker/api-start.sh | 2 +- docker/scheduler-start.sh | 2 +- docs/dev_setup_osx.md | 21 +- docs/dev_setup_ubuntu.md | 21 +- docs/dev_setup_windows.md | 20 +- docs/docker.md | 6 +- docs/jirasync.md | 2 +- docs/misc.md | 6 +- docs/nginx_install.md | 5 +- docs/quickstart.md | 10 +- docs/userguide.md | 6 +- docs/using_supervisor.md | 26 +- generate_docs.py | 262 ------------------ migrations/versions/b8ccf5b8089b_.py | 2 +- manage.py => security_monkey/manage.py | 47 +--- .../tests/interface/test_manager.py | 2 +- .../tests/utilities/test_s3_canonical.py | 2 +- setup.py | 5 + supervisor/security_monkey.conf | 4 +- 24 files changed, 104 insertions(+), 376 deletions(-) create mode 100644 docker/README.md delete mode 100644 docker/README.rst delete mode 100644 generate_docs.py rename manage.py => security_monkey/manage.py (91%) diff --git a/.travis.yml b/.travis.yml index b1fc37160..3aec4e5b6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -36,7 +36,7 @@ before_script: - python setup.py develop - pip install .[tests] - pip install coveralls - - python manage.py db upgrade + - monkey db upgrade script: - sh env_tests/test_dart.sh diff --git a/README.md b/README.md index 3f8feee66..59112ecac 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Security Monkey Security Monkey monitors your [AWS and GCP accounts](https://medium.com/@Netflix_Techblog/netflix-security-monkey-on-google-cloud-platform-gcp-f221604c0cc7) for policy changes and alerts on insecure configurations. It provides a single UI to browse and search through all of your accounts, regions, and cloud services. The monkey remembers previous states and can show you exactly what changed, and when. -Security Monkey can be extended with [custom account types](plugins.md), custom watchers, custom auditors, and [custom alerters](docs/misc.md#custom-alerters). +Security Monkey can be extended with [custom account types](docs/plugins.md), [custom watchers](docs/development.md#adding-a-watcher), [custom auditors](docs/development.md#adding-an-auditor), and [custom alerters](docs/misc.md#custom-alerters). It works on CPython 2.7. It is known to work on Ubuntu Linux and OS X. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..f23708f4b --- /dev/null +++ b/docker/README.md @@ -0,0 +1,12 @@ +Docker local development +======================== + +Project resources +----------------- + +- [Docker documentation](../docs/docker.md) +- [Development documentation](../docs/development.md) +- [OSX Develepment Setup](../docs/dev_setup_osx.md) +- [Windows Develepment Setup](../docs/dev_setup_windows.md) +- [Ubuntu Develepment Setup](../docs/dev_setup_ubuntu.md) + diff --git a/docker/README.rst b/docker/README.rst deleted file mode 100644 index 04d86b4e2..000000000 --- a/docker/README.rst +++ /dev/null @@ -1,9 +0,0 @@ -************************ -Docker local development -************************ - -Project resources -================= - -- `Docker documentation `_ -- `Development documentation `_ diff --git a/docker/api-init.sh b/docker/api-init.sh index b33e6a258..f82e7e379 100755 --- a/docker/api-init.sh +++ b/docker/api-init.sh @@ -15,9 +15,9 @@ mkdir -p /var/log/security_monkey/ touch "/var/log/security_monkey/security_monkey-deploy.log" cd /usr/local/src/security_monkey -python manage.py db upgrade +python security_monkey/manage.py db upgrade -cat < Admin + monkey create_user Admin The first argument is the email address of the new user. The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin]. @@ -210,7 +211,7 @@ Start the Security Monkey API This starts the REST API that the Angular application will communicate with. : - python manage.py runserver + monkey runserver Launch Dartium from within WebStorm =================================== @@ -247,17 +248,17 @@ Manually Run the Account Watchers Run the watchers to put some data in the database. : cd ~/security_monkey/ - python manage.py run_change_reporter all + monkey run_change_reporter all You can also run an individual watcher: - python manage.py find_changes -a all -m all - python manage.py find_changes -a all -m iamrole - python manage.py find_changes -a "My Test Account" -m iamgroup + monkey find_changes -a all -m all + monkey find_changes -a all -m iamrole + monkey find_changes -a "My Test Account" -m iamgroup You can run the auditors against the items currently in the database: - python manage.py audit_changes -a all -m redshift --send_report=False + monkey audit_changes -a all -m redshift --send_report=False Next Steps ========== diff --git a/docs/dev_setup_windows.md b/docs/dev_setup_windows.md index b75fddbbb..8ecee34c7 100644 --- a/docs/dev_setup_windows.md +++ b/docs/dev_setup_windows.md @@ -153,7 +153,7 @@ With your virtualenv activated, this will install the security\_monkey python mo We should be able to run manage.py to see usage information: - python manage.py + monkey ### Setup a development DB @@ -175,7 +175,7 @@ If you leave the DB paramaters at their default, you'll need to modify config-lo Install the security\_monkey DB tables: - python manage.py db upgrade + monkey db upgrade FYI - Navicat is a great tool for exploring the DB. @@ -184,14 +184,14 @@ Add Amazon Accounts This will add Amazon owned AWS accounts to security monkey. : - python manage.py amazon_accounts + monkey amazon_accounts Add a user account ------------------ This will add a user account that can be used later to login to the web ui: - python manage.py create\_user Admin + monkey create\_user Admin The first argument is the email address of the new user. The second parameter is the role and must be one of [anonymous, View, Comment, Justify, Admin]. @@ -200,7 +200,7 @@ Start the Security Monkey API This starts the REST API that the Angular application will communicate with. : - python manage.py runserver + monkey runserver ### Dart Development @@ -252,17 +252,17 @@ Manually Run the Account Watchers Run the watchers to put some data in the database. : cd ~/Github/security_monkey/ - python manage.py run_change_reporter all + monkey run_change_reporter all You can also run an individual watcher: - python manage.py find_changes -a all -m all - python manage.py find_changes -a all -m iamrole - python manage.py find_changes -a "My Test Account" -m iamgroup + monkey find_changes -a all -m all + monkey find_changes -a all -m iamrole + monkey find_changes -a "My Test Account" -m iamgroup You can run the auditors against the items currently in the database: - python manage.py audit_changes -a all -m redshift --send_report=False + monkey audit_changes -a all -m redshift --send_report=False Next Steps ---------- diff --git a/docs/docker.md b/docs/docker.md index 3c6a91466..299984f00 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -27,7 +27,7 @@ On a fresh database instance, various initial configuration must be run such as Before you bring the containers up, you need to add an AWS account for the scheduler to monitor: - $ python manage.py add_account_aws --number $account --name $name -r SecurityMonkey + $ monkey add_account_aws --number $account --name $name -r SecurityMonkey Now that the database is setup, you can start up the remaining containers (Security Monkey, nginx, and the scheduler) via: @@ -64,11 +64,11 @@ You can get a shell thanks to the docker-compose.shell.yml override: This allows you to access SecurityMonkey code, and run manual configurations such as: - $ python manage.py create_user admin@example.com Admin + $ monkey create_user admin@example.com Admin and/or: - $ python manage.py add_account_aws --number $account --name $name -r SecurityMonkey + $ monkey add_account_aws --number $account --name $name -r SecurityMonkey This container is useful for local development. It is not required otherwise. diff --git a/docs/jirasync.md b/docs/jirasync.md index 6e4e7f164..f2f7e62eb 100644 --- a/docs/jirasync.md +++ b/docs/jirasync.md @@ -29,7 +29,7 @@ To use JIRA sync, you will need to create a YAML configuration file, specifying To use JIRA sync, set the environment variable `SECURITY_MONKEY_JIRA_SYNC` to the location of the YAML configuration file. This file will be loaded once when the application starts. If set, JIRA sync will run for each account after the auditors run. You can also manually run a sync through `manage.py`. -`python manage.py sync_jira` +`monkey sync_jira` Details ------- diff --git a/docs/misc.md b/docs/misc.md index a23fbc301..c1f8cc7e7 100644 --- a/docs/misc.md +++ b/docs/misc.md @@ -11,13 +11,13 @@ For instance when you change a whitelist or add a 3rd party account, configurati In this case, you can force an audit by running: ~~~~ {.sourceCode .bash} -python manage.py audit_changes -m s3 +monkey audit_changes -m s3 ~~~~ For an email by adding `-r True`: ~~~~ {.sourceCode .bash} -python manage.py audit_changes -m s3 -r True +monkey audit_changes -m s3 -r True ~~~~ Scheduler Hacking @@ -66,7 +66,7 @@ On the next full audit, the score for the configured check method will be replac If no account pattern scores match the account, the override score it will default to the generic override score configured. -Audit override scores may also be set up though the [Command line interface](../manage.py) functions add\_override\_score (for a single score) and add\_override\_scores (from a csv file) +Audit override scores may also be set up though the [Command line interface](../security_monkey/manage.py) functions `add_override_score` (for a single score) and `add_override_scores` (from a csv file) *Note:*: diff --git a/docs/nginx_install.md b/docs/nginx_install.md index d2384ae71..aa7e6c8fe 100644 --- a/docs/nginx_install.md +++ b/docs/nginx_install.md @@ -27,9 +27,10 @@ The Python process Run Security Monkey as usual, but this time make it listen to a local port and host. E.G: - python manage.py run_api_server + monkey run_api_server -In PHP, when you edit a file, the changes are immediately visible. In Python, the whole code is often loaded in memory for performance reasons. This means you have to restart the Python process to see the changes effect. Having a separate process let you do this without having to restart the server. +If using the flask server in debug mode (`monkey runserver`), the python code will be reloaded when any file is changed. +However, in production we use gunicorn (`monkey run_api_server`) which does not reload. This means you have to restart the Python process to see the changes effect. Having a separate process let you do this without having to restart the server. Nginx ----- diff --git a/docs/quickstart.md b/docs/quickstart.md index 84d0a6b8f..d3377f302 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -129,7 +129,7 @@ For an explanation of the configuration options, see [options](options.md). Security Monkey uses Flask-Migrate (Alembic) to keep database tables up to date. To create the tables, run this command: cd /usr/local/src/security_monkey/ - python manage.py db upgrade + monkey db upgrade Populate Security Monkey with Accounts -------------------------------------- @@ -138,20 +138,20 @@ Populate Security Monkey with Accounts This will add Amazon owned AWS accounts to security monkey. : - python manage.py amazon_accounts + monkey amazon_accounts ### Add Your AWS/GCP Accounts You'll need to add at least one account before starting the scheduler. It's easiest to add them from the command line, but it can also be done through the web UI. : - python manage.py add_account_aws + monkey add_account_aws usage: manage.py add_account_aws [-h] -n NAME [--thirdparty] [--active] [--notes NOTES] --id IDENTIFIER [--update-existing] [--canonical_id CANONICAL_ID] [--s3_name S3_NAME] [--role_name ROLE_NAME] - python manage.py add_account_gcp + monkey add_account_gcp usage: manage.py add_account_gcp [-h] -n NAME [--thirdparty] [--active] [--notes NOTES] --id IDENTIFIER [--update-existing] [--creds_file CREDS_FILE] @@ -160,7 +160,7 @@ You'll need to add at least one account before starting the scheduler. It's easi Users can be created on the command line or by registering in the web UI: - $ python manage.py create_user "you@youremail.com" "Admin" + $ monkey create_user "you@youremail.com" "Admin" > Password: > Confirm Password: diff --git a/docs/userguide.md b/docs/userguide.md index ceb04dc77..92962d8ca 100644 --- a/docs/userguide.md +++ b/docs/userguide.md @@ -53,14 +53,14 @@ The first run will occur in 15 minutes. You can monitor all the log files in /va **Note: You can also add accounts via the command line with manage.py**: - $ python manage.py add_account_aws --number 12345678910 --name account_foo + $ monkey add_account_aws --number 12345678910 --name account_foo Successfully added account account_foo If an account with the same number already exists, this will do nothing, unless you pass `--force`, in which case, it will override the existing account: - $ python manage.py add_account_aws --number 12345678910 --name account_foo + $ monkey add_account_aws --number 12345678910 --name account_foo An account with id 12345678910 already exists - $ python manage.py add_account_aws --number 12345678910 --name account_foo --active false --force + $ monkey add_account_aws --number 12345678910 --name account_foo --active false --force Successfully added account account_foo Now What? diff --git a/docs/using_supervisor.md b/docs/using_supervisor.md index 61de5fef8..2ec3d5655 100644 --- a/docs/using_supervisor.md +++ b/docs/using_supervisor.md @@ -7,29 +7,35 @@ Create a configuration file named security\_monkey.conf under /etc/supervisor/co # Control Startup/Shutdown: # sudo supervisorctl - + [program:securitymonkey] user=www-data - environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/secmonkey-config/env-config/config-local.py" autostart=true autorestart=true - command=python /usr/local/src/security_monkey/manage.py run_api_server - + environment=PYTHONPATH='/usr/local/src/security_monkey/',PATH="/usr/local/src/security_monkey/venv/bin:%(ENV_PATH)s" + command=monkey run_api_server + [program:securitymonkeyscheduler] user=www-data autostart=true autorestart=true directory=/usr/local/src/security_monkey/ - environment=PYTHONPATH='/usr/local/src/security_monkey/',SECURITY_MONKEY_SETTINGS="/usr/local/src/secmonkey-config/env-config/config-local.py" - command=python /usr/local/src/security_monkey/manage.py start_scheduler + environment=PYTHONPATH='/usr/local/src/security_monkey/',PATH="/usr/local/src/security_monkey/venv/bin:%(ENV_PATH)s" + command=monkey start_scheduler -The 4 first entries are just boiler plate to get you started, you can copy them verbatim. +The 3 first entries are just boiler plate to get you started, you can copy them verbatim. -The last one define one (you can have many) process supervisor should manage. +The fourth line enables the `virtualenv` created in /usr/local/src/security_monkey/venv/. + +The fifth line defines one process supervisor should manage. It means it will run the command: - python manage.py run_api_server + monkey run_api_server + +which translates to: + + python security_monkey/manage.py run_api_server In the directory, with the environment and the user you defined. @@ -47,4 +53,4 @@ Then you can manage the process by running: It will start a shell from were you can start/stop/restart the service -You can read all errors that might occurs from /tmp/securitymonkey.log. +It's common for supervisor to log to `/var/log/supervisor/` and security_monkey is often configured to log to `/var/log/security_monkey`. \ No newline at end of file diff --git a/generate_docs.py b/generate_docs.py deleted file mode 100644 index dad155453..000000000 --- a/generate_docs.py +++ /dev/null @@ -1,262 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -sphinx-autopackage-script - -This script parses a directory tree looking for python modules and packages and -creates ReST files appropriately to create code documentation with Sphinx. -It also creates a modules index (named modules.). -""" - -# Copyright 2008 Société des arts technologiques (SAT), http://www.sat.qc.ca/ -# Copyright 2010 Thomas Waldmann -# All rights reserved. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - - -import os -import optparse - - -# automodule options -OPTIONS = ['members', - 'undoc-members', - # 'inherited-members', # disabled because there's a bug in sphinx - 'show-inheritance', - ] - -INIT = '__init__.py' - -def makename(package, module): - """Join package and module with a dot.""" - # Both package and module can be None/empty. - if package: - name = package - if module: - name += '.' + module - else: - name = module - return name - -def write_file(name, text, opts): - """Write the output file for module/package .""" - if opts.dryrun: - return - fname = os.path.join(opts.destdir, "%s.%s" % (name, opts.suffix)) - if not opts.force and os.path.isfile(fname): - print 'File %s already exists, skipping.' % fname - else: - print 'Creating file %s.' % fname - f = open(fname, 'w') - f.write(text) - f.close() - -def format_heading(level, text): - """Create a heading of [1, 2 or 3 supported].""" - underlining = ['=', '-', '~', ][level-1] * len(text) - return '%s\n%s\n\n' % (text, underlining) - -def format_directive(module, package=None): - """Create the automodule directive and add the options.""" - directive = '.. automodule:: %s\n' % makename(package, module) - for option in OPTIONS: - directive += ' :%s:\n' % option - return directive - -def create_module_file(package, module, opts): - """Build the text of the file and write the file.""" - text = format_heading(1, '%s Module' % module) - text += format_heading(2, ':mod:`%s` Module' % module) - text += format_directive(module, package) - write_file(makename(package, module), text, opts) - -def create_package_file(root, master_package, subroot, py_files, opts, subs): - """Build the text of the file and write the file.""" - package = os.path.split(root)[-1] - text = format_heading(1, '%s Package' % package) - # add each package's module - for py_file in py_files: - if shall_skip(os.path.join(root, py_file)): - continue - is_package = py_file == INIT - py_file = os.path.splitext(py_file)[0] - py_path = makename(subroot, py_file) - if is_package: - heading = ':mod:`%s` Package' % package - else: - heading = ':mod:`%s` Module' % py_file - text += format_heading(2, heading) - text += format_directive(is_package and subroot or py_path, master_package) - text += '\n' - - # build a list of directories that are packages (they contain an INIT file) - subs = [sub for sub in subs if os.path.isfile(os.path.join(root, sub, INIT))] - # if there are some package directories, add a TOC for theses subpackages - if subs: - text += format_heading(2, 'Subpackages') - text += '.. toctree::\n\n' - for sub in subs: - text += ' %s.%s\n' % (makename(master_package, subroot), sub) - text += '\n' - - write_file(makename(master_package, subroot), text, opts) - -def create_modules_toc_file(master_package, modules, opts, name='modules'): - """ - Create the module's index. - """ - text = format_heading(1, '%s Modules' % opts.header) - text += '.. toctree::\n' - text += ' :maxdepth: %s\n\n' % opts.maxdepth - - modules.sort() - prev_module = '' - for module in modules: - # look if the module is a subpackage and, if yes, ignore it - if module.startswith(prev_module + '.'): - continue - prev_module = module - text += ' %s\n' % module - - write_file(name, text, opts) - -def shall_skip(module): - """ - Check if we want to skip this module. - """ - # skip it, if there is nothing (or just \n or \r\n) in the file - return os.path.getsize(module) < 3 - -def recurse_tree(path, excludes, opts): - """ - Look for every file in the directory tree and create the corresponding - ReST files. - """ - # use absolute path for root, as relative paths like '../../foo' cause - # 'if "/." in root ...' to filter out *all* modules otherwise - path = os.path.abspath(path) - # check if the base directory is a package and get is name - if INIT in os.listdir(path): - package_name = path.split(os.path.sep)[-1] - else: - package_name = None - - toc = [] - tree = os.walk(path, False) - for root, subs, files in tree: - # keep only the Python script files - py_files = sorted([f for f in files if os.path.splitext(f)[1] == '.py']) - if INIT in py_files: - py_files.remove(INIT) - py_files.insert(0, INIT) - # remove hidden ('.') and private ('_') directories - subs = sorted([sub for sub in subs if sub[0] not in ['.', '_']]) - # check if there are valid files to process - # TODO: could add check for windows hidden files - if "/." in root or "/_" in root \ - or not py_files \ - or is_excluded(root, excludes): - continue - if INIT in py_files: - # we are in package ... - if (# ... with subpackage(s) - subs - or - # ... with some module(s) - len(py_files) > 1 - or - # ... with a not-to-be-skipped INIT file - not shall_skip(os.path.join(root, INIT)) - ): - subroot = root[len(path):].lstrip(os.path.sep).replace(os.path.sep, '.') - create_package_file(root, package_name, subroot, py_files, opts, subs) - toc.append(makename(package_name, subroot)) - elif root == path: - # if we are at the root level, we don't require it to be a package - for py_file in py_files: - if not shall_skip(os.path.join(path, py_file)): - module = os.path.splitext(py_file)[0] - create_module_file(package_name, module, opts) - toc.append(makename(package_name, module)) - - # create the module's index - if not opts.notoc: - create_modules_toc_file(package_name, toc, opts) - -def normalize_excludes(rootpath, excludes): - """ - Normalize the excluded directory list: - * must be either an absolute path or start with rootpath, - * otherwise it is joined with rootpath - * with trailing slash - """ - sep = os.path.sep - f_excludes = [] - for exclude in excludes: - if not os.path.isabs(exclude) and not exclude.startswith(rootpath): - exclude = os.path.join(rootpath, exclude) - if not exclude.endswith(sep): - exclude += sep - f_excludes.append(exclude) - return f_excludes - -def is_excluded(root, excludes): - """ - Check if the directory is in the exclude list. - - Note: by having trailing slashes, we avoid common prefix issues, like - e.g. an exlude "foo" also accidentally excluding "foobar". - """ - sep = os.path.sep - if not root.endswith(sep): - root += sep - for exclude in excludes: - if root.startswith(exclude): - return True - return False - -def main(): - """ - Parse and check the command line arguments. - """ - parser = optparse.OptionParser(usage="""usage: %prog [options] [exclude paths, ...] - -Note: By default this script will not overwrite already created files.""") - parser.add_option("-n", "--doc-header", action="store", dest="header", help="Documentation Header (default=Project)", default="Project") - parser.add_option("-d", "--dest-dir", action="store", dest="destdir", help="Output destination directory", default="") - parser.add_option("-s", "--suffix", action="store", dest="suffix", help="module suffix (default=txt)", default="txt") - parser.add_option("-m", "--maxdepth", action="store", dest="maxdepth", help="Maximum depth of submodules to show in the TOC (default=4)", type="int", default=4) - parser.add_option("-r", "--dry-run", action="store_true", dest="dryrun", help="Run the script without creating the files") - parser.add_option("-f", "--force", action="store_true", dest="force", help="Overwrite all the files") - parser.add_option("-t", "--no-toc", action="store_true", dest="notoc", help="Don't create the table of content file") - (opts, args) = parser.parse_args() - if not args: - parser.error("package path is required.") - else: - rootpath, excludes = args[0], args[1:] - if os.path.isdir(rootpath): - # check if the output destination is a valid directory - if opts.destdir and os.path.isdir(opts.destdir): - excludes = normalize_excludes(rootpath, excludes) - recurse_tree(rootpath, excludes, opts) - else: - print '%s is not a valid output destination directory.' % opts.destdir - else: - print '%s is not a valid directory.' % rootpath - - -if __name__ == '__main__': - main() - diff --git a/migrations/versions/b8ccf5b8089b_.py b/migrations/versions/b8ccf5b8089b_.py index eac8cde1d..ce36df97d 100644 --- a/migrations/versions/b8ccf5b8089b_.py +++ b/migrations/versions/b8ccf5b8089b_.py @@ -14,7 +14,7 @@ from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -from manage import fetch_aws_canonical_ids +from security_monkey.manage import fetch_aws_canonical_ids Session = sessionmaker() Base = declarative_base() diff --git a/manage.py b/security_monkey/manage.py similarity index 91% rename from manage.py rename to security_monkey/manage.py index cad9cd7e6..ecddf6ea3 100644 --- a/manage.py +++ b/security_monkey/manage.py @@ -94,8 +94,7 @@ def delete_unjustified_issues(accounts, monitors): monitor_names = _parse_tech_names(monitors) account_names = _parse_accounts(accounts) from security_monkey.datastore import ItemAudit - # ItemAudit.query.filter_by(justified=False).delete() - issues = ItemAudit.query.filter_by(justified=False).all() + issues = ItemAudit.query.filter_by(ItemAudit.justified==False).all() for issue in issues: del issue.sub_items[:] db.session.delete(issue) @@ -187,37 +186,6 @@ def amazon_accounts(): store_exception("manager-amazon-accounts", None, e) -# DEPRECATED: -# @manager.option('-u', '--number', dest='number', type=unicode, required=True) -# @manager.option('-a', '--active', dest='active', type=bool, default=True) -# @manager.option('-t', '--thirdparty', dest='third_party', type=bool, default=False) -# @manager.option('-n', '--name', dest='name', type=unicode, required=True) -# @manager.option('-s', '--s3name', dest='s3_name', type=unicode, default=u'') -# @manager.option('-o', '--notes', dest='notes', type=unicode, default=u'') -# @manager.option('-y', '--type', dest='account_type', type=unicode, default=u'AWS') -# @manager.option('-r', '--rolename', dest='role_name', type=unicode, default=u'SecurityMonkey') -# @manager.option('-f', '--force', dest='force', help='Override existing accounts', action='store_true') -# def add_account(number, third_party, name, s3_name, active, notes, account_type, role_name, force): -# from security_monkey.account_manager import account_registry -# account_manager = account_registry.get(account_type)() -# account = account_manager.lookup_account_by_identifier(number) -# if account: -# from security_monkey.common.audit_issue_cleanup import clean_account_issues -# clean_account_issues(account) -# -# if force: -# account_manager.update(account.id, account_type, name, active, -# third_party, notes, number, -# custom_fields={ 's3_name': s3_name, 'role_name': role_name }) -# else: -# app.logger.info('Account with id {} already exists'.format(number)) -# else: -# account_manager.create(account_type, name, active, third_party, notes, number, -# custom_fields={ 's3_name': s3_name, 'role_name': role_name }) -# -# db.session.close() - - @manager.command @manager.option('-e', '--email', dest='email', type=unicode, required=True) @manager.option('-r', '--role', dest='role', type=str, required=True) @@ -319,13 +287,14 @@ def add_override_score(tech_name, method, auditor, score, disabled, pattern_scor score = 0 query = ItemAuditScore.query.filter(ItemAuditScore.technology == tech_name) - query = query.filter(ItemAuditScore.method == method + ' (' + auditor + ')') + method_str = "{method} ({auditor})".format(method=method, auditor=auditor) + query = query.filter(ItemAuditScore.method == method_str) entry = query.first() if not entry: entry = ItemAuditScore() entry.technology = tech_name - entry.method = method + ' (' + auditor + ')' + entry.method = method_str entry.score = score entry.disabled = disabled @@ -563,7 +532,7 @@ def clean_stale_issues(): class APIServer(Command): - def __init__(self, host='127.0.0.1', port=app.config.get('API_PORT'), workers=6): + def __init__(self, host='127.0.0.1', port=app.config.get('API_PORT'), workers=12): self.address = "{}:{}".format(host, port) self.workers = workers @@ -647,10 +616,14 @@ def handle(self, app, *args, **kwargs): return -1 -if __name__ == "__main__": +def main(): from security_monkey.account_manager import account_registry for name, account_manager in account_registry.items(): manager.add_command("add_account_%s" % name.lower(), AddAccount(account_manager())) manager.add_command("run_api_server", APIServer()) manager.run() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/security_monkey/tests/interface/test_manager.py b/security_monkey/tests/interface/test_manager.py index c1c42537c..cf0b6441d 100644 --- a/security_monkey/tests/interface/test_manager.py +++ b/security_monkey/tests/interface/test_manager.py @@ -24,7 +24,7 @@ from security_monkey import db from security_monkey.tests import SecurityMonkeyTestCase -from manage import clear_expired_exceptions +from security_monkey.manage import clear_expired_exceptions import datetime diff --git a/security_monkey/tests/utilities/test_s3_canonical.py b/security_monkey/tests/utilities/test_s3_canonical.py index 693e812c1..bb4bb112a 100644 --- a/security_monkey/tests/utilities/test_s3_canonical.py +++ b/security_monkey/tests/utilities/test_s3_canonical.py @@ -19,7 +19,7 @@ """ import unittest -from manage import fetch_aws_canonical_ids, AddAccount, manager +from security_monkey.manage import fetch_aws_canonical_ids, AddAccount, manager from security_monkey import db from security_monkey.common.s3_canonical import get_canonical_ids from security_monkey.datastore import AccountType, Account, ExceptionLogs, AccountTypeCustomValues diff --git a/setup.py b/setup.py index 42769e72d..325779617 100644 --- a/setup.py +++ b/setup.py @@ -68,5 +68,10 @@ 'freezegun>=0.3.7', 'mixer==5.5.7' ] + }, + entry_points={ + 'console_scripts': [ + 'monkey = security_monkey.manage:main', + ], } ) diff --git a/supervisor/security_monkey.conf b/supervisor/security_monkey.conf index faf70e0e3..b8105e916 100644 --- a/supervisor/security_monkey.conf +++ b/supervisor/security_monkey.conf @@ -10,7 +10,7 @@ user=www-data autostart=true autorestart=true environment=PYTHONPATH='/usr/local/src/security_monkey/',PATH="/usr/local/src/security_monkey/venv/bin:%(ENV_PATH)s" -command=python /usr/local/src/security_monkey/manage.py run_api_server +command=monkey run_api_server [program:securitymonkeyscheduler] user=www-data @@ -18,4 +18,4 @@ autostart=true autorestart=true directory=/usr/local/src/security_monkey/ environment=PYTHONPATH='/usr/local/src/security_monkey/',PATH="/usr/local/src/security_monkey/venv/bin:%(ENV_PATH)s" -command=python /usr/local/src/security_monkey/manage.py start_scheduler +command=monkey start_scheduler From 24db56d3ea33db9a62348583c30df49360cadc86 Mon Sep 17 00:00:00 2001 From: Travis McPeak Date: Thu, 13 Apr 2017 13:16:29 -0700 Subject: [PATCH 88/90] Adding an option to allow group write for logfiles (#660) * Adding an option to preserve group write permission for log files This commit adds a new log rotate handler that will allow write permission for members of the same group. This is useful in deployment scenarios where other processes manage logs created by Security Monkey. Users can enable this feature by uncommenting the new handler in the config. * Fixing bug in group write access preserving rotate handler This commit fixes a bug in the rotating file handler that preserves group write permission. * fixing comma * Update AUTHORS --- AUTHORS | 1 + env-config/config-docker.py | 3 ++- env-config/config.py | 3 ++- scripts/secmonkey_auto_install.sh | 3 ++- security_monkey/__init__.py | 26 ++++++++++++++++++++++++-- 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index e77132621..63ab1c3b1 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,3 +1,4 @@ - Patrick Kelley - Kevin Glisson - Roy Rapoport +- Travis McPeak diff --git a/env-config/config-docker.py b/env-config/config-docker.py index aa7a441e3..b188fbc5f 100644 --- a/env-config/config-docker.py +++ b/env-config/config-docker.py @@ -42,7 +42,8 @@ def env_to_bool(input): }, 'handlers': { 'file': { - 'class': 'logging.handlers.RotatingFileHandler', + # 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.GroupWriteRotatingFileHandler', 'level': 'DEBUG', 'formatter': 'standard', 'filename': '/var/log/security_monkey/securitymonkey.log', diff --git a/env-config/config.py b/env-config/config.py index d5a5d894e..993ed6464 100644 --- a/env-config/config.py +++ b/env-config/config.py @@ -25,7 +25,8 @@ }, 'handlers': { 'file': { - 'class': 'logging.handlers.RotatingFileHandler', + # 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.GroupWriteRotatingFileHandler', 'level': 'DEBUG', 'formatter': 'standard', 'filename': '/var/log/security_monkey/securitymonkey.log', diff --git a/scripts/secmonkey_auto_install.sh b/scripts/secmonkey_auto_install.sh index df1c0d17e..d7ecdf18f 100755 --- a/scripts/secmonkey_auto_install.sh +++ b/scripts/secmonkey_auto_install.sh @@ -381,7 +381,8 @@ LOG_CFG = { }, 'handlers': { 'file': { - 'class': 'logging.handlers.RotatingFileHandler', + # 'class': 'logging.handlers.RotatingFileHandler', + 'class': 'logging.handlers.GroupWriteRotatingFileHandler', 'level': 'DEBUG', 'formatter': 'standard', 'filename': 'security_monkey-deploy.log', diff --git a/security_monkey/__init__.py b/security_monkey/__init__.py index f0d38b12c..f659020a0 100644 --- a/security_monkey/__init__.py +++ b/security_monkey/__init__.py @@ -19,6 +19,9 @@ .. moduleauthor:: Patrick Kelley """ +import os +import stat + ### VERSION ### __version__ = '0.9.0' @@ -210,7 +213,6 @@ def send_email(msg): api.add_resource(WatcherConfigPut, '/api/1/watcher_config/') ## Jira Sync -import os from security_monkey.jirasync import JiraSync jirasync_file = os.environ.get('SECURITY_MONKEY_JIRA_SYNC') if jirasync_file: @@ -232,13 +234,33 @@ def send_email(msg): # Logging import sys -from logging import Formatter +from logging import Formatter, handlers from logging.handlers import RotatingFileHandler from logging import StreamHandler from logging.config import dictConfig from logging import DEBUG +# Use this handler to have log rotator give newly minted logfiles +gw perm +class GroupWriteRotatingFileHandler(handlers.RotatingFileHandler): + def doRollover(self): + """ + Override base class method to make the new log file group writable. + """ + # Rotate the file first. + handlers.RotatingFileHandler.doRollover(self) + + # Add group write to the current permissions. + try: + currMode = os.stat(self.baseFilename).st_mode + os.chmod(self.baseFilename, currMode | stat.S_IWGRP) + except OSError: + pass + + +handlers.GroupWriteRotatingFileHandler = GroupWriteRotatingFileHandler + + def setup_logging(): """ Logging in security_monkey can be configured in two ways. From a335af89d1365ddd13eb3c0260c9ed11673e3b78 Mon Sep 17 00:00:00 2001 From: Shrikant Pandhare Date: Thu, 13 Apr 2017 13:25:38 -0700 Subject: [PATCH 89/90] Added doc on update/upgrade steps (#661) * Added doc on update steps * Updating the filename and a few other minor things * Updating title of update document. --- docs/update.md | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 docs/update.md diff --git a/docs/update.md b/docs/update.md new file mode 100644 index 000000000..c040641c9 --- /dev/null +++ b/docs/update.md @@ -0,0 +1,113 @@ +Update Security Monkey +====================== + +Update Security Monkey on your Instance +---------------------------------------- + +Update Steps: + +- Prerequisites +- Backup and stop services +- Clone security_monkey and update environment +- Compile (or download) the web UI +- Update database and configurations +- Start services + +### Prerequisites + +This doc assumes you already have installed and running security monkey environment. Especially it assumes you have following on your system +1. https://github.com/Netflix/security_monkey project files are available under /usr/local/src/security_monkey +2. [Supervisor](http://supervisord.org/) configured and running +3. Python virtualenv + +### Backup config and installation files + +Backup your `/usr/local/src/security_monkey/env-config/config.py` and move your exsiting installation to backup directory +``` +cp /usr/local/src/security_monkey/env-config/config.py ~/ +mkdir ~/security_monkey_backup && mv /usr/local/src/security_monkey/ ~/security_monkey_backup/ +``` + +### Stop services + +Stop securitymonkey and the scheduler services using supervisorctl. +``` +sudo supervisorctl stop securitymonkey +sudo supervisorctl stop securitymonkeyscheduler +``` + +### Clone security_monkey + +Releases are on the master branch and are updated about every three months. Bleeding edge features are on the develop branch. +git clone https://github.com/Netflix/security_monkey.git into the your security monkey location +``` +$ cd /usr/local/src +$ sudo git clone --depth 1 --branch develop https://github.com/Netflix/security_monkey.git +``` + + +### Update Python environment + +Activate your python virtualenv and run python setup.py install +``` +cd security_monkey +virtualenv venv +source venv/bin/activate +pip install --upgrade setuptools +pip install google-compute-engine # Only required on GCP +python setup.py install +``` + +### Compile (or Download) the web UI +If you're using the stable (master) branch, you have the option of downloading the web UI instead of compiling it. Visit the latest release and download static.tar.gz. + +If you're using the bleeding edge (develop) branch, you will need to compile the web UI by following these instructions. +If you have not done this during installation follow [this](quickstart.md#compile-or-download-the-web-ui) section in [quickstart](quickstart.md) guide + +Compile the web-app from the Dart code + +#### Build the Web UI +``` +cd /usr/local/src/security_monkey/dart +sudo /usr/lib/dart/bin/pub get +sudo /usr/lib/dart/bin/pub build +``` + +#### Copy the compiled Web UI to the appropriate destination +``` +mkdir -p /usr/local/src/security_monkey/security_monkey/static/ +/bin/cp -R /usr/local/src/security_monkey/dart/build/web/* /usr/local/src/security_monkey/security_monkey/static/ +chgrp -R www-data /usr/local/src/security_monkey +``` + +### Update configurations + +Replace the config file that we previously backed up. + +``` +sudo mv ~/config.py /usr/local/src/security_monkey/env-config/ +``` + +If your file is named something other than `config.py`, you will want to set the `SECURITY_MONKEY_SETTINGS` environment variable to point to your config: + +``` +export SECURITY_MONKEY_SETTINGS=/usr/local/src/security_monkey/env-config/config-deploy.py +``` + +### Update the database tables + +Security Monkey uses Flask-Migrate (Alembic) to keep database tables up to date. To update the tables, run this command. +Note: python manage.py db upgrade is idempotent. You can re-run it without causing any harm. + +``` +cd /usr/local/src/security_monkey/ +monkey db upgrade +``` + +### Start services +``` +sudo supervisorctl start securitymonkey +sudo supervisorctl start securitymonkeyscheduler +``` +*Note:* +*Netflix doesn't upgrade/patch Security Monkey systems. Instead simply rebake a new instance with the new version.* From 3ddf79b7db86a60f970ffe49c5de6255893de1f3 Mon Sep 17 00:00:00 2001 From: Patrick Kelley Date: Thu, 13 Apr 2017 18:54:17 -0700 Subject: [PATCH 90/90] Changelog updates 090 (#664) * Adding v0.9.0 changelog * Bumping version from 0.8.0 to 0.9.0 --- Dockerfile | 2 +- dart/pubspec.yaml | 2 +- docker/nginx/Dockerfile | 2 +- docs/authors.md | 4 +- docs/changelog.md | 116 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index e76c2fd74..6b48c5b41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ FROM ubuntu:14.04 MAINTAINER Netflix Open Source Development -ENV SECURITY_MONKEY_VERSION=v0.8.0 \ +ENV SECURITY_MONKEY_VERSION=v0.9.0 \ SECURITY_MONKEY_SETTINGS=/usr/local/src/security_monkey/env-config/config-docker.py RUN apt-get update &&\ diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index 3987fcb17..467d66426 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -1,6 +1,6 @@ name: security_monkey description: An AWS Policy Monitoring and Alerting Tool -version: 0.8.0 +version: 0.9.0 dependencies: angular: "^1.1.2+2" angular_ui: ">=0.6.8 <0.7.0" diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index bc0ed3d3f..fcd0c835b 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -15,7 +15,7 @@ FROM nginx:1.11.4 MAINTAINER Netflix Open Source Development -ENV SECURITY_MONKEY_VERSION=v0.8.0 +ENV SECURITY_MONKEY_VERSION=v0.9.0 RUN apt-get update &&\ apt-get install -y curl git sudo apt-transport-https &&\ curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ diff --git a/docs/authors.md b/docs/authors.md index 9c1964d9a..36e141fb6 100644 --- a/docs/authors.md +++ b/docs/authors.md @@ -1,6 +1,6 @@ Authors ======= -securitymonkey 0.8.0 is copyright 2014,2015,2016 Netflix. inc. +securitymonkey 0.9.0 is copyright 2014,2015,2016,2017 Netflix. inc. -If you want to contribute to security monkey, see contributing. +If you want to contribute to security monkey, see [contributing](contributing.md). diff --git a/docs/changelog.md b/docs/changelog.md index 40b71b580..4045cdbb9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,6 +1,122 @@ Changelog ========= +v0.9.0 (2017-04-13) +---------------------------------------- + +- PR #500 - @monkeysecurity - Updating ARN.py to look for StringEqualsIgnoreCase in policy condition blocks +- PR #511 - @kalpatel01 - Fix KMSAuditor exceptions +- PR #510 - @kalpatel01 - Add additional JIRA configurations +- PR #504 - @redixin - Plugins support +- PR #515 - @badraufran - Add ability to press enter to search in search bar component +- PR #514 - @badraufran - Update dev_setup_osx.rst to get it up-to-date +- PR #513 / #545- @mikegrima - Fix for S3 watcher errors. +- PR #516 - @badraufran - Remove broken packages link +- PR #518 - @badraufran - Update `dev_setup_osx` (Remove sudo) +- PR #519 - @selmanj - Minor reformatting/style changes to Docker docs +- PR #512 / #521 - @kalpatel01 - Organize tests into directories +- PR #524 - @kalpatel01 - Remove DB mock class +- PR #522 - @kalpatel01 - Optimize SQL for account delete +- PR #525 - @kalpatel01 - Handle known kms boto exceptions +- PR #529 - @mariusgrigaitis - Usage of `GOOGLE_HOSTED_DOMAIN` in sample configs +- PR #532 - @kalpatel01 - Add sorting to account tables (UI) +- PR #538 - @cu12 - Add more Docker envvars +- PR #536 / #540 - @supertom - Add account type field to item, item details and search bar. +- PR #534 / #541 - @kalpatel01 - Add bulk enable and disable account service +- PR #546 - @supertom - GCP: fixed accounttypes typo. +- PR #547 - @monkeysecurity - Delete deprecated Account fields +- PR #528 - @kalpatel01 - Fix reaudit issue for watchers in different intervals +- PR #553 - @mikegrima - Fixed bugs in the ES watcher +- PR #535 / #552 - @kalpatel01 - Add support for overriding audit scores +- PR #560 / #587 - @mikegrima - Bump CloudAux version +- PR #533 / #559 - @kalpatel01 - Add Watcher configuration +- PR #562 - @monkeysecurity - Re-adding reporter timing information to the logs. +- PR #557 - @kalpatel01 - Add justified issues report +- PR #573 - @monkeysecurity - fixing issue duplicate ARN issue… +- PR #564 - @kalpatel01 - Fix justification preservation bug +- PR #565 - @kalpatel01 - Handle unicode name tags +- PR #571 - @kalpatel01 - Explicitly set export filename +- PR #572 - @kalpatel01 - Fix minor watcher bugs +- PR #576 - @kalpatel01 - Set user role via SSO profile +- PR #569 - @kalpatel01 - Split `check_access_keys` method in the IAM User Auditor +- PR #566 - @kalpatel01 - Convert watchers to boto3 +- PR #568 - @kalpatel01 - Replace ELBAuditor DB query with support watcher +- PR #567 - @kalpatel01 - Reduce AWS managed policy audit noise +- PR #570 - @kalpatel01 - Add support for custom watcher and auditor alerters +- PR #575 - @kalpatel01 - Add functionality to clean up stale issues +- PR #582 - @supertom - [GCP] Watchers/Auditors for GCP +- PR #588 - @supertom - GCP docs: Draft of GCP changes +- PR #592 - @monkeysecurity - SSO Role Modifications +- PR #597 - @supertom - GCP: fixed issue where client wasn't receiving user-specified creds +- PR #598 - @redixin - Implement `add_account_%s` for custom accounts +- PR #600 - @supertom - GCP: fixed issue where bucket watcher wasn't sending credentials to Cloudaux +- PR #602 - @crruthe - Added permission for DescribeVpnGateways missing +- PR #605 - @monkeysecurity - ELB Auditor - Fixing reference to check_rfc_1918 +- PR #610 - @monkeysecurity - Adding Unique Index to TechName and AccountName +- PR #612 - @carise - Add a section on using GCP Cloud SQL Postgres with Cloud SQL Proxy +- PR #613 - @monkeysecurity - Setting Item.issue_count to deferred. Only joining tables in distinct if necessary. +- PR #614 - @monkeysecurity - Increasing default timeout +- PR #607 - @supertom - GCP: Set User Agent +- PR #609 - @mikegrima - Added ephemeral section to S3 for "GrantReferences" +- PR #611 - @roman-vynar - Quick start improvements +- PR #619 - @mikegrima - Fix for plaintext passwords in DB if using CLI for user creation +- PR #622 - @jonhadfield - Fix ACM certificate ImportedAt timestamp +- PR #616 - @redixin - Fix docs and variable names related to custom alerters +- PR #502 - @mikegrima - Batching support for watchers +- PR #631 - @supertom - Added `__version__` property +- PR #632 - @sysboy - Set the default value of SECURITY_REGISTERABLE to False +- PR #629 - @BobPeterson1881 - Fix security group rule parsing +- PR #630 - @BobPeterson1881 - Update dashboard view filter links +- PR #633 - @sysboy - Log Warning when S3 ACL can't be retrieved. +- PR #639 - @monkeysecurity - Removing reference to zerotodocker. +- PR #624 - @mikegrima - Adding utilities to get S3 canonical IDs. +- PR #640 - @supertom - GCP: fixed UI Account Type filtering +- PR #642 - @monkeysecurity - Adding active and third_party flags to account view API +- PR #646 - @monkeysecurity - Removing s3_name from exporter and renaming Account.number to identifier +- PR #648 - @mikegrima - Fix for UI Account creation bug +- PR #657 #658 - @jeyglk - Fix Docker +- PR #655 - @monkeysecurity - Updating quickstart/install documentation to simplify. +- PR #659 - @monkeysecurity - Quickstart GCP Fixes +- PR #625 - @bungoume - Fix principal KeyError +- PR #662 - @monkeysecurity - Replacing `python manage.py` with `monkey` +- PR #660 - @mcpeak - Adding an option to allow group write for logfiles +- PR #661 - @shrikant0013 - Added doc on update/upgrade steps + +Important Notes: + +- `SECURITY_MONKEY_SETTINGS` is no longer a required environment variable. + - If supplied, security_monkey will respect the variable. Otherwise it will default to env-config/config.py +- `manage.py` has been moved inside the package and a `monkey` alias has been setup. + - Where you might once call `python manage.py ` you will now call `monkey ` +- Documentation has been converted from RST to Markdown. + - I will no longer be using readthedocs or RST. + - Quickstart guide has been largely re-written. + - Quickstart now instructs you to create and use a virtualenv (and how to get supervisor to work with it) +- This release contains [GCP Watcher Support](https://medium.com/@Netflix_Techblog/netflix-security-monkey-on-google-cloud-platform-gcp-f221604c0cc7). +- Additional Permissions Required: + - ec2:DescribeVpnGateways + +Contributors: +- @kalpatel01 +- @redixin +- @badraufran +- @selmanj +- @mariusgrigaitis +- @cu12 +- @supertom +- @crruthe +- @carise +- @roman-vynar +- @jonhadfield +- @sysboy +- @jeyglk +- @bungoume +- @mcpeak +- @shrikant0013 +- @mikegrima +- @monkeysecurity + + v0.8.0 (2016-12-02-delayed-\>2017-01-13) ----------------------------------------