class OneLoginSAMLAuth(BaseAuthPlugin): name = 'OneLoginSAML' ns = NAMESPACE views = (SamlLoginRequest, SamlLoginConsumer, SamlLogoutRequest, SamlLogoutConsumer) options = ( ConfigOption('strict', True, 'bool', 'Strict validation of SAML responses'), ConfigOption('debug', False, 'bool', 'Enable SAML debug mode'), ConfigOption('sp_entity_id', None, 'string', 'Service Provider Entity ID'), ConfigOption('sp_acs', None, 'string', 'Assertion Consumer endpoint'), ConfigOption('sp_sls', None, 'string', 'Single Logout Service endpoint'), ConfigOption('idp_entity_id', None, 'string', 'Identity Provider Entity ID'), ConfigOption('idp_ssos', None, 'string', 'Single Sign-On Service endpoint'), ConfigOption('idp_sls', None, 'string', 'Single Logout Service endpoint'), ConfigOption('idp_x509cert', None, 'string', 'Base64 encoded x509 certificate for SAML validation') ) readonly = True login = {'url': '/auth/saml/login'} logout = '/auth/saml/logout'
class RequiredTagsAuditor(BaseAuditor): name = 'Required Tags Compliance' ns = NS_AUDITOR_REQUIRED_TAGS interval = dbconfig.get('interval', ns, 30) tracking_enabled = dbconfig.get('enabled', NS_GOOGLE_ANALYTICS, False) tracking_id = dbconfig.get('tracking_id', NS_GOOGLE_ANALYTICS) confirm_shutdown = dbconfig.get('confirm_shutdown', ns, True) required_tags = [] collect_only = None start_delay = 0 options = ( ConfigOption('action_taker_arn', '', 'string', 'Lambda entry point for action taker'), ConfigOption( 'alert_settings', { '*': { 'alert': ['0 seconds', '3 weeks', '27 days'], 'stop': '4 weeks', 'remove': '12 weeks', 'scope': ['*'] } }, 'json', 'Schedule for warning, stop and removal'), ConfigOption( 'audit_scope', # max_items is 99 here, but is pulled during runtime and adjusted to the # max number of available resources it doesn't really matter what we put { 'enabled': [], 'available': ['aws_ec2_instance', 'aws_s3_bucket', 'aws_rds_instance'], 'max_items': 99, 'min_items': 0 }, 'choice', 'Select the services you would like to audit'), ConfigOption('audit_ignore_tag', 'cinq_ignore', 'string', 'Do not audit resources have this tag set'), ConfigOption('always_send_email', True, 'bool', 'Send emails even in collect mode'), ConfigOption('collect_only', True, 'bool', 'Do not shutdown instances, only update caches'), ConfigOption( 'confirm_shutdown', True, 'bool', 'Require manual confirmation before shutting down instances'), ConfigOption('email_subject', 'Required tags audit notification', 'string', 'Subject of the email notification'), ConfigOption('enabled', False, 'bool', 'Enable the Required Tags auditor'), ConfigOption( 'enable_delete_s3_buckets', True, 'bool', 'Enable actual S3 bucket deletion. This might make you vulnerable to domain hijacking' ), ConfigOption('grace_period', 4, 'int', 'Only audit resources X minutes after being created'), ConfigOption('interval', 30, 'int', 'How often the auditor executes, in minutes.'), ConfigOption('partial_owner_match', True, 'bool', 'Allow partial matches of the Owner tag'), ConfigOption('permanent_recipient', [], 'array', 'List of email addresses to receive all alerts'), ConfigOption('required_tags', ['owner', 'accounting', 'name'], 'array', 'List of required tags'), ConfigOption( 'lifecycle_expiration_days', 3, 'int', 'How many days we should set in the bucket policy for non-empty S3 buckets removal' ), ConfigOption('gdpr_enabled', False, 'bool', 'Enable auditing for GDPR compliance'), ConfigOption('gdpr_accounts', [], 'array', 'List of accounts requiring GDPR compliance'), ConfigOption('gdpr_tag', 'gdpr_compliance', 'string', 'Name of GDPR compliance tag'), ConfigOption('gdpr_tag_values', ['pending', 'v1'], 'array', 'List of valid values for GDPR compliance tag')) def __init__(self): super().__init__() self.log.debug('Starting RequiredTags auditor') self.required_tags = dbconfig.get('required_tags', self.ns, ['owner', 'accounting', 'name']) self.collect_only = dbconfig.get('collect_only', self.ns, True) self.always_send_email = dbconfig.get('always_send_email', self.ns, False) self.permanent_emails = [{ 'type': 'email', 'value': contact } for contact in dbconfig.get('permanent_recipient', self.ns, [])] self.email_subject = dbconfig.get('email_subject', self.ns, 'Required tags audit notification') self.grace_period = dbconfig.get('grace_period', self.ns, 4) self.partial_owner_match = dbconfig.get('partial_owner_match', self.ns, True) self.audit_ignore_tag = dbconfig.get('audit_ignore_tag', NS_AUDITOR_REQUIRED_TAGS) self.alert_schedule = dbconfig.get('alert_settings', NS_AUDITOR_REQUIRED_TAGS) self.audited_types = dbconfig.get('audit_scope', NS_AUDITOR_REQUIRED_TAGS)['enabled'] self.email_from_address = dbconfig.get('from_address', NS_EMAIL) self.resource_types = { resource_type.resource_type_id: resource_type.resource_type for resource_type in db.ResourceType.find() } self.gdpr_enabled = dbconfig.get('gdpr_enabled', self.ns, False) self.gdpr_accounts = dbconfig.get('gdpr_accounts', self.ns, []) self.gdpr_tag = dbconfig.get('gdpr_tag', self.ns, 'gdpr_compliance') self.gdpr_tag_values = dbconfig.get('gdpr_tag_values', self.ns, ['pending', 'v1']) self.resource_classes = { resource.resource_type: resource for resource in map( lambda plugin: plugin.load(), CINQ_PLUGINS['cloud_inquisitor.plugins.types']['plugins']) } def run(self, *args, **kwargs): known_issues, new_issues, fixed_issues = self.get_resources() known_issues += self.create_new_issues(new_issues) actions = [ *[{ 'action': AuditActions.FIXED, 'action_description': None, 'last_alert': issue.last_alert, 'issue': issue, 'resource': issue.resource, 'owners': self.get_contacts(issue), 'notes': issue.notes, 'missing_tags': issue.missing_tags } for issue in fixed_issues], *self.get_actions(known_issues) ] notifications = self.process_actions(actions) self.notify(notifications) def get_known_resources_missing_tags(self): non_compliant_resources = {} audited_types = dbconfig.get('audit_scope', NS_AUDITOR_REQUIRED_TAGS, {'enabled': []})['enabled'] try: # resource_info is a tuple with the resource typename as [0] and the resource class as [1] resources = filter( lambda resource_info: resource_info[0] in audited_types, self.resource_classes.items()) for resource_name, resource_class in resources: for resource_id, resource in resource_class.get_all().items(): missing_tags, notes = self.check_required_tags_compliance( resource) if missing_tags: # Not really a get, it generates a new resource ID issue_id = get_resource_id('reqtag', resource_id) non_compliant_resources[issue_id] = { 'issue_id': issue_id, 'missing_tags': missing_tags, 'notes': notes, 'resource_id': resource_id, 'resource': resource } finally: db.session.rollback() return non_compliant_resources def get_resources(self): found_issues = self.get_known_resources_missing_tags() existing_issues = RequiredTagsIssue.get_all().items() known_issues = [] fixed_issues = [] for existing_issue_id, existing_issue in existing_issues: # Check if the existing issue is still persists resource = found_issues.pop(existing_issue_id, None) if resource: if resource['missing_tags'] != existing_issue.missing_tags: existing_issue.set_property('missing_tags', resource['missing_tags']) if resource['notes'] != existing_issue.notes: existing_issue.set_property('notes', resource['notes']) db.session.add(existing_issue.issue) known_issues.append(existing_issue) else: fixed_issues.append(existing_issue) new_issues = {} for resource_id, resource in found_issues.items(): try: if ((datetime.utcnow() - resource['resource'].resource_creation_date ).total_seconds() // 3600) >= self.grace_period: new_issues[resource_id] = resource except Exception as ex: self.log.error( 'Failed to construct new issue {}, Error: {}'.format( resource_id, ex)) db.session.commit() return known_issues, new_issues, fixed_issues def create_new_issues(self, new_issues): try: for non_compliant_resource in new_issues.values(): properties = { 'resource_id': non_compliant_resource['resource_id'], 'account_id': non_compliant_resource['resource'].account_id, 'location': non_compliant_resource['resource'].location, 'created': time.time(), 'last_alert': '-1 seconds', 'missing_tags': non_compliant_resource['missing_tags'], 'notes': non_compliant_resource['notes'], 'resource_type': non_compliant_resource['resource'].resource_name } issue = RequiredTagsIssue.create( non_compliant_resource['issue_id'], properties=properties) self.log.info('Trying to add new issue / {} {}'.format( properties['resource_id'], str(issue))) db.session.add(issue.issue) db.session.commit() yield issue except Exception as e: self.log.info('Could not add new issue / {}'.format(e)) finally: db.session.rollback() def get_contacts(self, issue): """Returns a list of contacts for an issue Args: issue (:obj:`RequiredTagsIssue`): Issue record Returns: `list` of `dict` """ # If the resources has been deleted, just return an empty list, to trigger issue deletion without notification if not issue.resource: return [] account_contacts = issue.resource.account.contacts try: resource_owners = issue.resource.get_owner_emails() # Double check get_owner_emails for it's return value if type(resource_owners) is list: for resource_owner in resource_owners: account_contacts.append({ 'type': 'email', 'value': resource_owner }) except AttributeError: pass return account_contacts def get_actions(self, issues): """Returns a list of actions to executed Args: issues (`list` of :obj:`RequiredTagsIssue`): List of issues Returns: `list` of `dict` """ actions = [] try: for issue in issues: action_item = self.determine_action(issue) if action_item['action'] != AuditActions.IGNORE: action_item['owners'] = self.get_contacts(issue) actions.append(action_item) finally: db.session.rollback() return actions def determine_alert(self, action_schedule, issue_creation_time, last_alert): """Determine if we need to trigger an alert Args: action_schedule (`list`): A list contains the alert schedule issue_creation_time (`int`): Time we create the issue last_alert (`str`): Time we sent the last alert Returns: (`None` or `str`) None if no alert should be sent. Otherwise return the alert we should send """ issue_age = time.time() - issue_creation_time alert_schedule_lookup = { pytimeparse.parse(action_time): action_time for action_time in action_schedule } alert_schedule = sorted(alert_schedule_lookup.keys()) last_alert_time = pytimeparse.parse(last_alert) for alert_time in alert_schedule: if last_alert_time < alert_time <= issue_age and last_alert_time != alert_time: return alert_schedule_lookup[alert_time] else: return None def determine_action(self, issue): """Determine the action we should take for the issue Args: issue: Issue to determine action for Returns: `dict` """ resource_type = self.resource_types[issue.resource.resource_type_id] issue_alert_schedule = self.alert_schedule[resource_type] if \ resource_type in self.alert_schedule \ else self.alert_schedule['*'] action_item = { 'action': None, 'action_description': None, 'last_alert': issue.last_alert, 'issue': issue, 'resource': self.resource_classes[self.resource_types[ issue.resource.resource_type_id]](issue.resource), 'owners': [], 'stop_after': issue_alert_schedule['stop'], 'remove_after': issue_alert_schedule['remove'], 'notes': issue.notes, 'missing_tags': issue.missing_tags } time_elapsed = time.time() - issue.created stop_schedule = pytimeparse.parse(issue_alert_schedule['stop']) remove_schedule = pytimeparse.parse(issue_alert_schedule['remove']) if self.collect_only: action_item['action'] = AuditActions.IGNORE elif remove_schedule and time_elapsed >= remove_schedule: action_item['action'] = AuditActions.REMOVE action_item['action_description'] = 'Resource removed' action_item['last_alert'] = remove_schedule elif stop_schedule and time_elapsed >= stop_schedule: if issue.get_property('state').value == AuditActions.STOP: action_item['action'] = AuditActions.IGNORE else: action_item['action'] = AuditActions.STOP action_item['action_description'] = 'Resource stopped' action_item['last_alert'] = stop_schedule else: alert_selection = self.determine_alert( issue_alert_schedule['alert'], issue.get_property('created').value, issue.get_property('last_alert').value) if alert_selection: action_item['action'] = AuditActions.ALERT action_item['action_description'] = '{} alert'.format( alert_selection) action_item['last_alert'] = alert_selection else: action_item['action'] = AuditActions.IGNORE return action_item def process_action(self, resource, action): return process_action(resource, action, self.ns) def process_actions(self, actions): """Process the actions we want to take Args: actions (`list`): List of actions we want to take Returns: `list` of notifications """ notices = {} notification_contacts = {} for action in actions: resource = action['resource'] action_status = ActionStatus.SUCCEED try: if action['action'] == AuditActions.REMOVE: action_status = self.process_action( resource, AuditActions.REMOVE) if action_status == ActionStatus.SUCCEED: db.session.delete(action['issue'].issue) elif action['action'] == AuditActions.STOP: action_status = self.process_action( resource, AuditActions.STOP) if action_status == ActionStatus.SUCCEED: action['issue'].update({ 'missing_tags': action['missing_tags'], 'notes': action['notes'], 'last_alert': action['last_alert'], 'state': action['action'] }) elif action['action'] == AuditActions.FIXED: db.session.delete(action['issue'].issue) elif action['action'] == AuditActions.ALERT: action['issue'].update({ 'missing_tags': action['missing_tags'], 'notes': action['notes'], 'last_alert': action['last_alert'], 'state': action['action'] }) db.session.commit() if action_status == ActionStatus.SUCCEED: for owner in [ dict(t) for t in { tuple(d.items()) for d in (action['owners'] + self.permanent_emails) } ]: if owner['value'] not in notification_contacts: contact = NotificationContact(type=owner['type'], value=owner['value']) notification_contacts[owner['value']] = contact notices[contact] = {'fixed': [], 'not_fixed': []} else: contact = notification_contacts[owner['value']] if action['action'] == AuditActions.FIXED: notices[contact]['fixed'].append(action) else: notices[contact]['not_fixed'].append(action) except Exception as ex: self.log.exception( 'Unexpected error while processing resource {}/{}/{}/{}'. format(action['resource'].account.account_name, action['resource'].id, action['resource'], ex)) return notices def validate_tag(self, key, value): """Check whether a tag value is valid Args: key: A tag key value: A tag value Returns: `(True or False)` A boolean indicating whether or not the value is valid """ if key == 'owner': return validate_email(value, self.partial_owner_match) elif key == self.gdpr_tag: return value in self.gdpr_tag_values else: return True def check_required_tags_compliance(self, resource): """Check whether a resource is compliance Args: resource: A single resource Returns: `(list, list)` A tuple contains missing tags (if there were any) and notes """ missing_tags = [] notes = [] resource_tags = {tag.key.lower(): tag.value for tag in resource.tags} # Do not audit this resource if it is not in the Account scope if resource.resource_type in self.alert_schedule: target_accounts = self.alert_schedule[ resource.resource_type]['scope'] else: target_accounts = self.alert_schedule['*']['scope'] if not (resource.account.account_name in target_accounts or '*' in target_accounts): return missing_tags, notes # Do not audit this resource if the ignore tag was set if self.audit_ignore_tag.lower() in resource_tags: return missing_tags, notes required_tags = list(self.required_tags) # Add GDPR tag to required tags if the account must be GDPR compliant if self.gdpr_enabled and resource.account.account_name in self.gdpr_accounts: required_tags.append(self.gdpr_tag) ''' # Do not audit this resource if it is still in grace period if (datetime.utcnow() - resource.resource_creation_date).total_seconds() // 3600 < self.grace_period: return missing_tags, notes ''' # Check if the resource is missing required tags or has invalid tag values for key in [tag.lower() for tag in required_tags]: if key not in resource_tags: missing_tags.append(key) elif not self.validate_tag(key, resource_tags[key]): missing_tags.append(key) notes.append('{} tag is not valid'.format(key)) if missing_tags and resource.resource_type == 'aws_rds_instance': notes.append('Instance name = {}'.format(resource.instance_name)) return missing_tags, notes def notify(self, notices): """Send notifications to the recipients provided Args: notices (:obj:`dict` of `str`: `list`): A dictionary mapping notification messages to the recipient. Returns: `None` """ tmpl_html = get_template('required_tags_notice.html') tmpl_text = get_template('required_tags_notice.txt') for recipient, data in list(notices.items()): body_html = tmpl_html.render(data=data) body_text = tmpl_text.render(data=data) send_notification(subsystem=self.ns, recipients=[recipient], subject=self.email_subject, body_html=body_html, body_text=body_text)
class VPCFlowLogsAuditor(BaseAuditor): name = 'VPC Flow Log Compliance' ns = NS_AUDITOR_VPC_FLOW_LOGS interval = dbconfig.get('interval', ns, 60) role_name = dbconfig.get('role_name', ns, 'VpcFlowLogsRole') start_delay = 0 options = (ConfigOption('enabled', False, 'bool', 'Enable the VPC Flow Logs auditor'), ConfigOption('interval', 60, 'int', 'Run frequency in minutes'), ConfigOption('role_name', 'VpcFlowLogsRole', 'str', 'Name of IAM Role used for VPC Flow Logs')) def __init__(self): super().__init__() self.session = None def run(self): """Main entry point for the auditor worker. Returns: `None` """ # Loop through all accounts that are marked as enabled accounts = list(AWSAccount.get_all(include_disabled=False).values()) for account in accounts: self.log.debug('Updating VPC Flow Logs for {}'.format(account)) self.session = get_aws_session(account) role_arn = self.confirm_iam_role(account) # region specific for aws_region in AWS_REGIONS: try: vpc_list = VPC.get_all(account, aws_region).values() need_vpc_flow_logs = [ x for x in vpc_list if x.vpc_flow_logs_status != 'ACTIVE' ] for vpc in need_vpc_flow_logs: if self.confirm_cw_log(account, aws_region, vpc.id): self.create_vpc_flow_logs(account, aws_region, vpc.id, role_arn) else: self.log.info( 'Failed to confirm log group for {}/{}'.format( account, aws_region)) except Exception: self.log.exception( 'Failed processing VPCs for {}/{}.'.format( account, aws_region)) db.session.commit() @retry def confirm_iam_role(self, account): """Return the ARN of the IAM Role on the provided account as a string. Returns an `IAMRole` object from boto3 Args: account (:obj:`Account`): Account where to locate the role Returns: :obj:`IAMRole` """ try: iam = self.session.client('iam') rolearn = iam.get_role(RoleName=self.role_name)['Role']['Arn'] return rolearn except ClientError as e: if e.response['Error']['Code'] == 'NoSuchEntity': self.create_iam_role(account) else: raise except Exception as e: self.log.exception( 'Failed validating IAM role for VPC Flow Log Auditing for {}'. format(e)) @retry def create_iam_role(self, account): """Create a new IAM role. Returns the ARN of the newly created role Args: account (:obj:`Account`): Account where to create the IAM role Returns: `str` """ try: iam = self.session.client('iam') trust = get_template('vpc_flow_logs_iam_role_trust.json').render() policy = get_template('vpc_flow_logs_role_policy.json').render() newrole = iam.create_role( Path='/', RoleName=self.role_name, AssumeRolePolicyDocument=trust)['Role']['Arn'] # Attach an inline policy to the role to avoid conflicts or hitting the Managed Policy Limit iam.put_role_policy(RoleName=self.role_name, PolicyName='VpcFlowPolicy', PolicyDocument=policy) self.log.debug('Created VPC Flow Logs role & policy for {}'.format( account.account_name)) auditlog(event='vpc_flow_logs.create_iam_role', actor=self.ns, data={ 'account': account.account_name, 'roleName': self.role_name, 'trustRelationship': trust, 'inlinePolicy': policy }) return newrole except Exception: self.log.exception( 'Failed creating the VPC Flow Logs role for {}.'.format( account)) @retry def confirm_cw_log(self, account, region, vpcname): """Create a new CloudWatch log group based on the VPC Name if none exists. Returns `True` if succesful Args: account (:obj:`Account`): Account to create the log group in region (`str`): Region to create the log group in vpcname (`str`): Name of the VPC the log group is fow Returns: `bool` """ try: cw = self.session.client('logs', region) token = None log_groups = [] while True: result = cw.describe_log_groups( ) if not token else cw.describe_log_groups(nextToken=token) token = result.get('nextToken') log_groups.extend( [x['logGroupName'] for x in result.get('logGroups', [])]) if not token: break if vpcname not in log_groups: cw.create_log_group(logGroupName=vpcname) cw_vpc = VPC.get(vpcname) cw_vpc.set_property('vpc_flow_logs_log_group', vpcname) self.log.info('Created log group {}/{}/{}'.format( account.account_name, region, vpcname)) auditlog(event='vpc_flow_logs.create_cw_log_group', actor=self.ns, data={ 'account': account.account_name, 'region': region, 'log_group_name': vpcname, 'vpc': vpcname }) return True except Exception: self.log.exception( 'Failed creating log group for {}/{}/{}.'.format( account, region, vpcname)) @retry def create_vpc_flow_logs(self, account, region, vpc_id, iam_role_arn): """Create a new VPC Flow log Args: account (:obj:`Account`): Account to create the flow in region (`str`): Region to create the flow in vpc_id (`str`): ID of the VPC to create the flow for iam_role_arn (`str`): ARN of the IAM role used to post logs to the log group Returns: `None` """ try: flow = self.session.client('ec2', region) flow.create_flow_logs(ResourceIds=[vpc_id], ResourceType='VPC', TrafficType='ALL', LogGroupName=vpc_id, DeliverLogsPermissionArn=iam_role_arn) fvpc = VPC.get(vpc_id) fvpc.set_property('vpc_flow_logs_status', 'ACTIVE') self.log.info('Enabled VPC Logging {}/{}/{}'.format( account, region, vpc_id)) auditlog(event='vpc_flow_logs.create_vpc_flow', actor=self.ns, data={ 'account': account.account_name, 'region': region, 'vpcId': vpc_id, 'arn': iam_role_arn }) except Exception: self.log.exception( 'Failed creating VPC Flow Logs for {}/{}/{}.'.format( account, region, vpc_id))
class EmailNotifier(BaseNotifier): name = 'Email Notifier' ns = NS_EMAIL notifier_type = 'email' validation = RGX_EMAIL_VALIDATION_PATTERN options = ( ConfigOption('enabled', True, 'bool', 'Enable the Email notifier plugin'), ConfigOption('from_address', '*****@*****.**', 'string', 'Sender address for emails'), ConfigOption('method', 'ses', 'string', 'EMail sending method, either ses or smtp'), ConfigOption( 'from_arn', '', 'string', 'If using cross-account SES, this is the "From ARN", otherwise leave blank' ), ConfigOption( 'return_path_arn', '', 'string', 'If using cross-account SES, this is the "Return Path ARN", otherwise leave blank' ), ConfigOption( 'source_arn', '', 'string', 'If using cross-account SES, this is the "Source ARN", otherwise leave blank' ), ConfigOption('ses_region', 'us-west-2', 'string', 'Which SES region to send emails from'), ConfigOption('smtp_server', 'localhost', 'string', 'Address of the SMTP server to use'), ConfigOption('smtp_port', 25, 'int', 'Port for the SMTP server'), ConfigOption( 'smtp_username', '', 'string', 'Username for SMTP authentication. Leave blank for no authentication' ), ConfigOption( 'smtp_password', '', 'string', 'Password for SMTP authentication. Leave blank for no authentication' ), ConfigOption('smtp_tls', False, 'bool', 'Use TLS for sending emails'), ) def __init__(self): super().__init__() self.sender = self.dbconfig.get('from_address', NS_EMAIL) def notify(self, subsystem, recipient, subject, body_html, body_text): """Method to send a notification. A plugin may use only part of the information, but all fields are required. Args: subsystem (`str`): Name of the subsystem originating the notification recipient (`str`): Recipient email address subject (`str`): Subject / title of the notification body_html (`str)`: HTML formatted version of the message body_text (`str`): Text formatted version of the message Returns: `None` """ if not re.match(RGX_EMAIL_VALIDATION_PATTERN, recipient, re.I): raise ValueError('Invalid recipient provided') email = Email() email.timestamp = datetime.now() email.subsystem = subsystem email.sender = self.sender email.recipients = recipient email.subject = subject email.uuid = uuid.uuid4() email.message_html = body_html email.message_text = body_text method = dbconfig.get('method', NS_EMAIL, 'ses') try: if method == 'ses': self.__send_ses_email([recipient], subject, body_html, body_text) elif method == 'smtp': self.__send_smtp_email([recipient], subject, body_html, body_text) else: raise ValueError('Invalid email method: {}'.format(method)) db.session.add(email) db.session.commit() except Exception as ex: raise EmailSendError(ex) def __send_ses_email(self, recipients, subject, body_html, body_text): """Send an email using SES Args: recipients (`1ist` of `str`): List of recipient email addresses subject (str): Subject of the email body_html (str): HTML body of the email body_text (str): Text body of the email Returns: `None` """ source_arn = dbconfig.get('source_arn', NS_EMAIL) return_arn = dbconfig.get('return_path_arn', NS_EMAIL) session = get_local_aws_session() ses = session.client('ses', region_name=dbconfig.get('ses_region', NS_EMAIL, 'us-west-2')) body = {} if body_html: body['Html'] = {'Data': body_html} if body_text: body['Text'] = {'Data': body_text} ses_options = { 'Source': self.sender, 'Destination': { 'ToAddresses': recipients }, 'Message': { 'Subject': { 'Data': subject }, 'Body': body } } # Set SES options if needed if source_arn and return_arn: ses_options.update({ 'SourceArn': source_arn, 'ReturnPathArn': return_arn }) ses.send_email(**ses_options) def __send_smtp_email(self, recipients, subject, html_body, text_body): """Send an email using SMTP Args: recipients (`list` of `str`): List of recipient email addresses subject (str): Subject of the email html_body (str): HTML body of the email text_body (str): Text body of the email Returns: `None` """ smtp = smtplib.SMTP(dbconfig.get('smtp_server', NS_EMAIL, 'localhost'), dbconfig.get('smtp_port', NS_EMAIL, 25)) source_arn = dbconfig.get('source_arn', NS_EMAIL) return_arn = dbconfig.get('return_path_arn', NS_EMAIL) from_arn = dbconfig.get('from_arn', NS_EMAIL) msg = MIMEMultipart('alternative') # Set SES options if needed if source_arn and from_arn and return_arn: msg['X-SES-SOURCE-ARN'] = source_arn msg['X-SES-FROM-ARN'] = from_arn msg['X-SES-RETURN-PATH-ARN'] = return_arn msg['Subject'] = subject msg['To'] = ','.join(recipients) msg['From'] = self.sender # Check body types to avoid exceptions if html_body: html_part = MIMEText(html_body, 'html') msg.attach(html_part) if text_body: text_part = MIMEText(text_body, 'plain') msg.attach(text_part) # TLS if needed if dbconfig.get('smtp_tls', NS_EMAIL, False): smtp.starttls() # Login if needed username = dbconfig.get('smtp_username', NS_EMAIL) password = dbconfig.get('smtp_password', NS_EMAIL) if username and password: smtp.login(username, password) smtp.sendmail(self.sender, recipients, msg.as_string()) smtp.quit()
class SlackNotifier(BaseNotifier): name = 'Slack Notifier' ns = NS_SLACK enabled = dbconfig.get('enabled', ns, True) options = ( ConfigOption('enabled', False, 'bool', 'Enable the Slack notifier plugin'), ConfigOption('api_key', '', 'string', 'API token for the slack notifications'), ConfigOption('bot_name', 'Inquisitor', 'string', 'Name of the bot in Slack'), ) def __init__(self, api_key=None): super().__init__() if not self.enabled: raise SlackError('Slack messaging is disabled') self.slack_client = SlackClient(api_key or dbconfig.get('api_key', self.ns)) self.bot_name = dbconfig.get('bot_name', self.ns, 'Inquisitor') if not self.__check(): raise SlackError('Invalid API KEY!') def __check(self): try: response = self.slack_client.api_call('auth.test') return response['ok'] except Exception: return False def __get_user_id(self, email): response = self.slack_client.api_call('users.list') try: if not response['ok']: raise SlackError('Failed to list Slack users!') for item in response['members']: _profile = item['profile'] if _profile.get('email', None) == email: return item['id'] else: SlackError('Failed to get user from Slack!') except Exception as ex: raise SlackError(ex) def __get_channel_for_user(self, user_email): user_id = self.__get_user_id(user_email) try: response = self.slack_client.api_call('im.open', user=user_id) if not response['ok']: raise SlackError('Failed to get channel for user!') return response['channel']['id'] except Exception as ex: raise SlackError(ex) def _send_message(self, target_type, target, message): if target_type == 'user': channel = self.__get_channel_for_user(target) else: channel = target result = self.slack_client.api_call( 'chat.postMessage', channel=channel, text=message, username=self.bot_name ) if not result.get('ok', False): raise SlackError('Failed to send message: {}'.format(result['error'])) @staticmethod def send_message(contacts, message): """List of contacts the send the message to. You can send messages either to channels and private groups by using the following formats #channel-name @username-direct-message If the channel is the name of a private group / channel, you must first invite the bot to the channel to ensure it is allowed to send messages to the group. Returns true if the message was sent, else `False` Args: contacts (:obj:`list` of `str`,`str`): List of contacts message (str): Message to send Returns: `bool` """ slack_api_object = SlackNotifier() if type(contacts) == str: contacts = [contacts] for contact in contacts: if contact.startswith('#'): target_type = 'channel' elif '@' in contact: target_type = 'user' else: raise SlackError('Unrecognized contact {}'.format(contact)) slack_api_object._send_message( target_type=target_type, target=contact, message=message ) return True
def run(self, **kwargs): # Setup the base application settings defaults = [ { 'prefix': 'default', 'name': 'Default', 'sort_order': 0, 'options': [ ConfigOption('debug', False, 'bool', 'Enable debug mode for flask'), ConfigOption('session_expire_time', 43200, 'int', 'Time in seconds before sessions expire'), ConfigOption( 'role_name', 'cinq_role', 'string', 'Role name Cloud Inquisitor will use in each account'), ConfigOption( 'ignored_aws_regions_regexp', '(^cn-|GLOBAL|-gov)', 'string', 'A regular expression used to filter out regions from the AWS static data' ), ConfigOption(name='auth_system', default_value={ 'enabled': ['Local Authentication'], 'available': ['Local Authentication'], 'max_items': 1, 'min_items': 1 }, type='choice', description='Enabled authentication module'), ConfigOption('scheduler', 'StandaloneScheduler', 'string', 'Default scheduler module'), ConfigOption( 'jwt_key_file_path', 'ssl/private.key', 'string', 'Path to the private key used to encrypt JWT session tokens. Can be relative to the ' 'folder containing the configuration file, or absolute path' ) ], }, { 'prefix': 'log', 'name': 'Logging', 'sort_order': 1, 'options': [ ConfigOption('log_level', 'INFO', 'string', 'Log level'), ConfigOption( 'enable_syslog_forwarding', False, 'bool', 'Enable forwarding logs to remote log collector'), ConfigOption('remote_syslog_server_addr', '127.0.0.1', 'string', 'Address of the remote log collector'), ConfigOption('remote_syslog_server_port', 514, 'string', 'Port of the remote log collector'), ConfigOption('log_keep_days', 31, 'int', 'Delete log entries older than n days'), ], }, { 'prefix': 'api', 'name': 'API', 'sort_order': 2, 'options': [ ConfigOption('host', '127.0.0.1', 'string', 'Host of the API server'), ConfigOption('port', 5000, 'int', 'Port of the API server'), ConfigOption( 'workers', 6, 'int', 'Number of worker processes spawned for the API') ] }, ] # Setup all the default base settings for data in defaults: nsobj = self.get_config_namespace(data['prefix'], data['name'], sort_order=data['sort_order']) for opt in data['options']: self.register_default_option(nsobj, opt) db.session.add(nsobj) db.session.commit() # Iterate over all of our plugins and setup their defaults for ptype, namespaces in list(PLUGIN_NAMESPACES.items()): for ns in namespaces: for ep in iter_entry_points(ns): cls = ep.load() if hasattr(cls, 'ns'): ns_name = '{}: {}'.format(ptype.capitalize(), cls.name) nsobj = self.get_config_namespace(cls.ns, ns_name) if not isinstance(cls.options, abstractproperty): if cls.options: for opt in cls.options: self.register_default_option(nsobj, opt) db.session.add(nsobj) db.session.commit() # Create the default roles if they are missing self.add_default_roles() # If there are no accounts created, ask the user if he/she wants to create one now if not kwargs['headless_mode'] and not Account.query.first(): if confirm( 'You have no accounts defined, do you wish to add the first account now?' ): self.init_account()
class IAMAuditor(BaseAuditor): """Validate and apply IAM policies for AWS Accounts """ name = 'IAM' ns = NS_AUDITOR_IAM interval = dbconfig.get('interval', ns, 30) start_delay = 0 manage_roles = dbconfig.get('manage_roles', ns, True) git_policies = None cfg_roles = None aws_managed_policies = None options = ( ConfigOption('enabled', False, 'bool', 'Enable the IAM roles and policy auditor'), ConfigOption('interval', 30, 'int', 'How often the auditor executes, in minutes'), ConfigOption('manage_roles', True, 'bool', 'Enable management of IAM roles'), ConfigOption( 'roles', '{ }', 'json', 'JSON document with roles to push to accounts. See documentation for examples' ), ConfigOption('delete_inline_policies', False, 'bool', 'Delete inline policies from existing roles'), ConfigOption('git_auth_token', 'CHANGE ME', 'string', 'API Auth token for Github'), ConfigOption('git_server', 'CHANGE ME', 'string', 'Address of the Github server'), ConfigOption('git_repo', 'CHANGE ME', 'string', 'Name of Github repo'), ConfigOption('git_no_ssl_verify', False, 'bool', 'Disable SSL verification of Github server'), ConfigOption('role_timeout', 8, 'int', 'AssumeRole timeout in hours')) def run(self, *args, **kwargs): """Iterate through all AWS accounts and apply roles and policies from Github Args: *args: Optional list of arguments **kwargs: Optional list of keyword arguments Returns: `None` """ accounts = list(AWSAccount.get_all(include_disabled=False).values()) self.manage_policies(accounts) def manage_policies(self, accounts): if not accounts: return self.git_policies = self.get_policies_from_git() self.manage_roles = self.dbconfig.get('manage_roles', self.ns, True) self.cfg_roles = self.dbconfig.get('roles', self.ns) self.aws_managed_policies = { policy['PolicyName']: policy for policy in self.get_policies_from_aws( get_aws_session(accounts[0]).client('iam'), 'AWS') } for account in accounts: try: if not account.ad_group_base: self.log.info( 'Account {} does not have AD Group Base set, skipping'. format(account.account_name)) continue # List all policies and roles from AWS, and generate a list of policies from Git sess = get_aws_session(account) iam = sess.client('iam') aws_roles = { role['RoleName']: role for role in self.get_roles(iam) } aws_policies = { policy['PolicyName']: policy for policy in self.get_policies_from_aws(iam) } account_policies = copy.deepcopy(self.git_policies['GLOBAL']) if account.account_name in self.git_policies: for role in self.git_policies[account.account_name]: account_policies.update( self.git_policies[account.account_name][role]) aws_policies.update( self.check_policies(account, account_policies, aws_policies)) self.check_roles(account, aws_policies, aws_roles) except Exception as exception: self.log.info( 'Unable to process account {}. Unhandled Exception {}'. format(account.account_name, exception)) @retry def check_policies(self, account, account_policies, aws_policies): """Iterate through the policies of a specific account and create or update the policy if its missing or does not match the policy documents from Git. Returns a dict of all the policies added to the account (does not include updated policies) Args: account (:obj:`Account`): Account to check policies for account_policies (`dict` of `str`: `dict`): A dictionary containing all the policies for the specific account aws_policies (`dict` of `str`: `dict`): A dictionary containing the non-AWS managed policies on the account Returns: :obj:`dict` of `str`: `str` """ self.log.debug('Fetching policies for {}'.format(account.account_name)) sess = get_aws_session(account) iam = sess.client('iam') added = {} for policyName, account_policy in account_policies.items(): # policies pulled from github a likely bytes and need to be converted if isinstance(account_policy, bytes): account_policy = account_policy.decode('utf-8') # Using re.sub instead of format since format breaks on the curly braces of json gitpol = json.loads( re.sub(r'{AD_Group}', account.ad_group_base or account.account_name, account_policy)) if policyName in aws_policies: pol = aws_policies[policyName] awspol = iam.get_policy_version( PolicyArn=pol['Arn'], VersionId=pol['DefaultVersionId'] )['PolicyVersion']['Document'] if awspol != gitpol: self.log.warn( 'IAM Policy {} on {} does not match Git policy documents, updating' .format(policyName, account.account_name)) self.create_policy(account, iam, json.dumps(gitpol, indent=4), policyName, arn=pol['Arn']) else: self.log.debug('IAM Policy {} on {} is up to date'.format( policyName, account.account_name)) else: self.log.warn('IAM Policy {} is missing on {}'.format( policyName, account.account_name)) response = self.create_policy(account, iam, json.dumps(gitpol), policyName) added[policyName] = response['Policy'] return added @retry def check_roles(self, account, aws_policies, aws_roles): """Iterate through the roles of a specific account and create or update the roles if they're missing or does not match the roles from Git. Args: account (:obj:`Account`): The account to check roles on aws_policies (:obj:`dict` of `str`: `dict`): A dictionary containing all the policies for the specific account aws_roles (:obj:`dict` of `str`: `dict`): A dictionary containing all the roles for the specific account Returns: `None` """ self.log.debug('Checking roles for {}'.format(account.account_name)) max_session_duration = self.dbconfig.get('role_timeout_in_hours', self.ns, 8) * 60 * 60 sess = get_aws_session(account) iam = sess.client('iam') # Build a list of default role policies and extra account specific role policies account_roles = copy.deepcopy(self.cfg_roles) if account.account_name in self.git_policies: for role in self.git_policies[account.account_name]: if role in account_roles: account_roles[role]['policies'] += list( self.git_policies[account.account_name][role].keys()) for role_name, data in list(account_roles.items()): if role_name not in aws_roles: iam.create_role(Path='/', RoleName=role_name, AssumeRolePolicyDocument=json.dumps( data['trust'], indent=4), MaxSessionDuration=max_session_duration) self.log.info('Created role {}/{}'.format( account.account_name, role_name)) else: try: if aws_roles[role_name][ 'MaxSessionDuration'] != max_session_duration: iam.update_role( RoleName=aws_roles[role_name]['RoleName'], MaxSessionDuration=max_session_duration) self.log.info( 'Adjusted MaxSessionDuration for role {} in account {} to {} seconds' .format(role_name, account.account_name, max_session_duration)) except ClientError: self.log.exception( 'Unable to adjust MaxSessionDuration for role {} in account {}' .format(role_name, account.account_name)) aws_role_policies = [ x['PolicyName'] for x in iam.list_attached_role_policies( RoleName=role_name)['AttachedPolicies'] ] aws_role_inline_policies = iam.list_role_policies( RoleName=role_name)['PolicyNames'] cfg_role_policies = data['policies'] missing_policies = list( set(cfg_role_policies) - set(aws_role_policies)) extra_policies = list( set(aws_role_policies) - set(cfg_role_policies)) if aws_role_inline_policies: self.log.info( 'IAM Role {} on {} has the following inline policies: {}'. format(role_name, account.account_name, ', '.join(aws_role_inline_policies))) if self.dbconfig.get('delete_inline_policies', self.ns, False) and self.manage_roles: for policy in aws_role_inline_policies: iam.delete_role_policy(RoleName=role_name, PolicyName=policy) auditlog( event='iam.check_roles.delete_inline_role_policy', actor=self.ns, data={ 'account': account.account_name, 'roleName': role_name, 'policy': policy }) if missing_policies: self.log.info( 'IAM Role {} on {} is missing the following policies: {}'. format(role_name, account.account_name, ', '.join(missing_policies))) if self.manage_roles: for policy in missing_policies: iam.attach_role_policy( RoleName=role_name, PolicyArn=aws_policies[policy]['Arn']) auditlog(event='iam.check_roles.attach_role_policy', actor=self.ns, data={ 'account': account.account_name, 'roleName': role_name, 'policyArn': aws_policies[policy]['Arn'] }) if extra_policies: self.log.info( 'IAM Role {} on {} has the following extra policies applied: {}' .format(role_name, account.account_name, ', '.join(extra_policies))) for policy in extra_policies: if policy in aws_policies: polArn = aws_policies[policy]['Arn'] elif policy in self.aws_managed_policies: polArn = self.aws_managed_policies[policy]['Arn'] else: polArn = None self.log.info( 'IAM Role {} on {} has an unknown policy attached: {}' .format(role_name, account.account_name, policy)) if self.manage_roles and polArn: iam.detach_role_policy(RoleName=role_name, PolicyArn=polArn) auditlog(event='iam.check_roles.detach_role_policy', actor=self.ns, data={ 'account': account.account_name, 'roleName': role_name, 'policyArn': polArn }) def get_policies_from_git(self): """Retrieve policies from the Git repo. Returns a dictionary containing all the roles and policies Returns: :obj:`dict` of `str`: `dict` """ fldr = mkdtemp() try: url = 'https://{token}:x-oauth-basic@{server}/{repo}'.format( **{ 'token': self.dbconfig.get('git_auth_token', self.ns), 'server': self.dbconfig.get('git_server', self.ns), 'repo': self.dbconfig.get('git_repo', self.ns) }) policies = {'GLOBAL': {}} if self.dbconfig.get('git_no_ssl_verify', self.ns, False): os.environ['GIT_SSL_NO_VERIFY'] = '1' repo = Repo.clone_from(url, fldr) for obj in repo.head.commit.tree: name, ext = os.path.splitext(obj.name) # Read the standard policies if ext == '.json': policies['GLOBAL'][name] = obj.data_stream.read() # Read any account role specific policies if name == 'roles' and obj.type == 'tree': for account in [x for x in obj.trees]: for role in [x for x in account.trees]: role_policies = { policy.name.replace('.json', ''): policy.data_stream.read() for policy in role.blobs if policy.name.endswith('.json') } if account.name in policies: if role.name in policies[account.name]: policies[account.name][ role.name] += role_policies else: policies[account.name][ role.name] = role_policies else: policies[account.name] = { role.name: role_policies } return policies finally: if os.path.exists(fldr) and os.path.isdir(fldr): shutil.rmtree(fldr) @staticmethod def get_policies_from_aws(client, scope='Local'): """Returns a list of all the policies currently applied to an AWS Account. Returns a list containing all the policies for the specified scope Args: client (:obj:`boto3.session.Session`): A boto3 Session object scope (`str`): The policy scope to use. Default: Local Returns: :obj:`list` of `dict` """ done = False marker = None policies = [] while not done: if marker: response = client.list_policies(Marker=marker, Scope=scope) else: response = client.list_policies(Scope=scope) policies += response['Policies'] if response['IsTruncated']: marker = response['Marker'] else: done = True return policies @staticmethod def get_roles(client): """Returns a list of all the roles for an account. Returns a list containing all the roles for the account. Args: client (:obj:`boto3.session.Session`): A boto3 Session object Returns: :obj:`list` of `dict` """ done = False marker = None roles = [] while not done: if marker: response = client.list_roles(Marker=marker) else: response = client.list_roles() roles += response['Roles'] if response['IsTruncated']: marker = response['Marker'] else: done = True return roles def create_policy(self, account, client, document, name, arn=None): """Create a new IAM policy. If the policy already exists, a new version will be added and if needed the oldest policy version not in use will be removed. Returns a dictionary containing the policy or version information Args: account (:obj:`Account`): Account to create the policy on client (:obj:`boto3.client`): A boto3 client object document (`str`): Policy document name (`str`): Name of the policy to create / update arn (`str`): Optional ARN for the policy to update Returns: `dict` """ if not arn and not name: raise ValueError( 'create_policy must be called with either arn or name in the argument list' ) if arn: response = client.list_policy_versions(PolicyArn=arn) # If we're at the max of the 5 possible versions, remove the oldest version that is not # the currently active policy if len(response['Versions']) >= 5: version = [ x for x in sorted(response['Versions'], key=lambda k: k['CreateDate']) if not x['IsDefaultVersion'] ][0] self.log.info( 'Deleting oldest IAM Policy version {}/{}'.format( arn, version['VersionId'])) client.delete_policy_version(PolicyArn=arn, VersionId=version['VersionId']) auditlog(event='iam.check_roles.delete_policy_version', actor=self.ns, data={ 'account': account.account_name, 'policyName': name, 'policyArn': arn, 'versionId': version['VersionId'] }) res = client.create_policy_version(PolicyArn=arn, PolicyDocument=document, SetAsDefault=True) else: res = client.create_policy(PolicyName=name, PolicyDocument=document) auditlog(event='iam.check_roles.create_policy', actor=self.ns, data={ 'account': account.account_name, 'policyName': name, 'policyArn': arn }) return res
class DNSCollector(BaseCollector): name = 'DNS' ns = 'collector_dns' type = CollectorType.GLOBAL interval = dbconfig.get('interval', ns, 15) options = (ConfigOption('enabled', False, 'bool', 'Enable the DNS collector plugin'), ConfigOption('interval', 15, 'int', 'Run frequency in minutes'), ConfigOption('cloudflare_enabled', False, 'bool', 'Enable CloudFlare as a source for DNS records'), ConfigOption('axfr_enabled', False, 'bool', 'Enable using DNS Zone Transfers for records')) def __init__(self): super().__init__() self.axfr_enabled = self.dbconfig.get('axfr_enabled', self.ns, False) self.cloudflare_enabled = self.dbconfig.get('cloudflare_enabled', self.ns, False) self.axfr_accounts = list(AXFRAccount.get_all().values()) self.cf_accounts = list(CloudFlareAccount.get_all().values()) self.cloudflare_initialized = defaultdict(lambda: False) self.cloudflare_session = {} def run(self): if self.axfr_enabled: try: for account in self.axfr_accounts: records = self.get_axfr_records(account.server, account.domains) self.process_zones(records, account) except: self.log.exception('Failed processing domains via AXFR') if self.cloudflare_enabled: try: for account in self.cf_accounts: records = self.get_cloudflare_records(account=account) self.process_zones(records, account) except: self.log.exception('Failed processing domains via CloudFlare') def process_zones(self, zones, account): self.log.info('Processing DNS records for {}'.format( account.account_name)) # region Update zones existing_zones = DNSZone.get_all(account) for data in zones: if data['zone_id'] in existing_zones: zone = DNSZone.get(data['zone_id']) if zone.update(data): self.log.debug('Change detected for DNS zone {}/{}'.format( account.account_name, zone.name)) db.session.add(zone.resource) else: DNSZone.create(data['zone_id'], account_id=account.account_id, properties={ k: v for k, v in data.items() if k not in ('records', 'zone_id', 'tags') }, tags=data['tags']) self.log.debug('Added DNS zone {}/{}'.format( account.account_name, data['name'])) db.session.commit() zk = set(x['zone_id'] for x in zones) ezk = set(existing_zones.keys()) for resource_id in ezk - zk: zone = existing_zones[resource_id] # Delete all the records for the zone for record in zone.records: db.session.delete(record.resource) db.session.delete(zone.resource) self.log.debug('Deleted DNS zone {}/{}'.format( account.account_name, zone.name.value)) db.session.commit() # endregion # region Update resource records for zone in zones: try: existing_zone = DNSZone.get(zone['zone_id']) existing_records = { rec.id: rec for rec in existing_zone.records } for data in zone['records']: if data['id'] in existing_records: record = existing_records[data['id']] if record.update(data): self.log.debug( 'Changed detected for DNSRecord {}/{}/{}'. format(account.account_name, zone.name, data['name'])) db.session.add(record.resource) else: record = DNSRecord.create( data['id'], account_id=account.account_id, properties={ k: v for k, v in data.items() if k not in ('records', 'zone_id') }, tags={}) self.log.debug('Added new DNSRecord {}/{}/{}'.format( account.account_name, zone['name'], data['name'])) existing_zone.add_record(record) db.session.commit() rk = set(x['id'] for x in zone['records']) erk = set(existing_records.keys()) for resource_id in erk - rk: record = existing_records[resource_id] db.session.delete(record.resource) self.log.debug('Deleted DNSRecord {}/{}/{}'.format( account.account_name, zone['zone_id'], record.name)) db.session.commit() except: self.log.exception( 'Error while attempting to update records for {}/{}'. format( account.account_name, zone['zone_id'], )) db.session.rollback() # endregion @retry def get_axfr_records(self, server, domains): """Return a `list` of `dict`s containing the zones and their records, obtained from the DNS server Returns: :obj:`list` of `dict` """ zones = [] for zoneName in domains: try: zone = { 'zone_id': get_resource_id('axfrz', zoneName), 'name': zoneName, 'source': 'AXFR', 'comment': None, 'tags': {}, 'records': [] } z = dns_zone.from_xfr(query.xfr(server, zoneName)) rdata_fields = ('name', 'ttl', 'rdata') for rr in [ dict(zip(rdata_fields, x)) for x in z.iterate_rdatas() ]: record_name = rr['name'].derelativize(z.origin).to_text() zone['records'].append({ 'id': get_resource_id( 'axfrr', record_name, ['{}={}'.format(k, str(v)) for k, v in rr.items()]), 'zone_id': zone['zone_id'], 'name': record_name, 'value': sorted([rr['rdata'].to_text()]), 'type': type_to_text(rr['rdata'].rdtype) }) if len(zone['records']) > 0: zones.append(zone) except Exception as ex: self.log.exception( 'Failed fetching DNS zone information for {}: {}'.format( zoneName, ex)) raise return zones def get_cloudflare_records(self, *, account): """Return a `list` of `dict`s containing the zones and their records, obtained from the CloudFlare API Returns: account (:obj:`CloudFlareAccount`): A CloudFlare Account object :obj:`list` of `dict` """ zones = [] for zobj in self.__cloudflare_list_zones(account=account): try: self.log.debug('Processing DNS zone CloudFlare/{}'.format( zobj['name'])) zone = { 'zone_id': get_resource_id('cfz', zobj['name']), 'name': zobj['name'], 'source': 'CloudFlare', 'comment': None, 'tags': {}, 'records': [] } for record in self.__cloudflare_list_zone_records( account=account, zoneID=zobj['id']): zone['records'].append({ 'id': get_resource_id( 'cfr', zobj['id'], ['{}={}'.format(k, v) for k, v in record.items()]), 'zone_id': zone['zone_id'], 'name': record['name'], 'value': record['value'], 'type': record['type'] }) if len(zone['records']) > 0: zones.append(zone) except CloudFlareError: self.log.exception( 'Failed getting records for CloudFlare zone {}'.format( zobj['name'])) return zones # region Helper functions for CloudFlare def __cloudflare_request(self, *, account, path, args=None): """Helper function to interact with the CloudFlare API. Args: account (:obj:`CloudFlareAccount`): CloudFlare Account object path (`str`): URL endpoint to communicate with args (:obj:`dict` of `str`: `str`): A dictionary of arguments for the endpoint to consume Returns: `dict` """ if not args: args = {} if not self.cloudflare_initialized[account.account_id]: self.cloudflare_session[account.account_id] = requests.Session() self.cloudflare_session[account.account_id].headers.update({ 'X-Auth-Email': account.email, 'X-Auth-Key': account.api_key, 'Content-Type': 'application/json' }) self.cloudflare_initialized[account.account_id] = True if 'per_page' not in args: args['per_page'] = 100 response = self.cloudflare_session[account.account_id].get( account.endpoint + path, params=args) if response.status_code != 200: raise CloudFlareError('Request failed: {}'.format(response.text)) return response.json() def __cloudflare_list_zones(self, *, account, **kwargs): """Helper function to list all zones registered in the CloudFlare system. Returns a `list` of the zones Args: account (:obj:`CloudFlareAccount`): A CloudFlare Account object **kwargs (`dict`): Extra arguments to pass to the API endpoint Returns: `list` of `dict` """ done = False zones = [] page = 1 while not done: kwargs['page'] = page response = self.__cloudflare_request(account=account, path='/zones', args=kwargs) info = response['result_info'] if 'total_pages' not in info or page == info['total_pages']: done = True else: page += 1 zones += response['result'] return zones def __cloudflare_list_zone_records(self, *, account, zoneID, **kwargs): """Helper function to list all records on a CloudFlare DNS Zone. Returns a `dict` containing the records and their information. Args: account (:obj:`CloudFlareAccount`): A CloudFlare Account object zoneID (`int`): Internal CloudFlare ID of the DNS zone **kwargs (`dict`): Additional arguments to be consumed by the API endpoint Returns: :obj:`dict` of `str`: `dict` """ done = False records = {} page = 1 while not done: kwargs['page'] = page response = self.__cloudflare_request( account=account, path='/zones/{}/dns_records'.format(zoneID), args=kwargs) info = response['result_info'] # Check if we have received all records, and if not iterate over the result set if 'total_pages' not in info or page >= info['total_pages']: done = True else: page += 1 for record in response['result']: if record['name'] in records: records[record['name']]['value'] = sorted( records[record['name']]['value'] + [record['content']]) else: records[record['name']] = { 'name': record['name'], 'value': sorted([record['content']]), 'type': record['type'] } return list(records.values())
class DomainHijackAuditor(BaseAuditor): """Domain Hijacking Auditor Checks DNS resource records for any pointers to non-existing assets in AWS (S3 buckets, Elastic Beanstalks, etc). """ name = 'Domain Hijacking' ns = NS_AUDITOR_DOMAIN_HIJACKING interval = dbconfig.get('interval', ns, 30) options = ( ConfigOption('enabled', False, 'bool', 'Enable the Domain Hijacking auditor'), ConfigOption('interval', 30, 'int', 'Run frequency in minutes'), ConfigOption('email_recipients', ['*****@*****.**'], 'array', 'List of emails to receive alerts'), ConfigOption('hijack_subject', 'Potential domain hijack detected', 'string', 'Email subject for domain hijack notifications'), ConfigOption('alert_frequency', 24, 'int', 'How frequent in hours, to alert'), ) def __init__(self): super().__init__() self.recipients = dbconfig.get('email_recipients', self.ns) self.subject = dbconfig.get('hijack_subject', self.ns, 'Potential domain hijack detected') self.alert_frequency = dbconfig.get('alert_frequency', self.ns, 24) def run(self, *args, **kwargs): """Update the cache of all DNS entries and perform checks Args: *args: Optional list of arguments **kwargs: Optional list of keyword arguments Returns: None """ try: zones = list(DNSZone.get_all().values()) buckets = {k.lower(): v for k, v in S3Bucket.get_all().items()} dists = list(CloudFrontDist.get_all().values()) ec2_public_ips = [x.public_ip for x in EC2Instance.get_all().values() if x.public_ip] beanstalks = {x.cname.lower(): x for x in BeanStalk.get_all().values()} existing_issues = DomainHijackIssue.get_all() issues = [] # List of different types of domain audits auditors = [ ElasticBeanstalkAudit(beanstalks), S3Audit(buckets), S3WithoutEndpointAudit(buckets), EC2PublicDns(ec2_public_ips), ] # region Build list of active issues for zone in zones: for record in zone.records: for auditor in auditors: if auditor.match(record): issues.extend(auditor.audit(record, zone)) for dist in dists: for org in dist.origins: if org['type'] == 's3': bucket = self.return_resource_name(org['source'], 's3') if bucket not in buckets: key = '{} ({})'.format(bucket, dist.type) issues.append({ 'key': key, 'value': 'S3Bucket {} doesnt exist on any known account. Referenced by {} on {}'.format( bucket, dist.domain_name, dist.account, ) }) # endregion # region Process new, old, fixed issue lists old_issues = {} new_issues = {} fixed_issues = [] for data in issues: issue_id = get_resource_id('dhi', ['{}={}'.format(k, v) for k, v in data.items()]) if issue_id in existing_issues: issue = existing_issues[issue_id] if issue.update({'state': 'EXISTING', 'end': None}): db.session.add(issue.issue) old_issues[issue_id] = issue else: properties = { 'issue_hash': issue_id, 'state': 'NEW', 'start': datetime.now(), 'end': None, 'source': data['key'], 'description': data['value'] } new_issues[issue_id] = DomainHijackIssue.create(issue_id, properties=properties) db.session.commit() for issue in list(existing_issues.values()): if issue.id not in new_issues and issue.id not in old_issues: fixed_issues.append(issue.to_json()) db.session.delete(issue.issue) # endregion # Only alert if its been more than a day since the last alert alert_cutoff = datetime.now() - timedelta(hours=self.alert_frequency) old_alerts = [] for issue_id, issue in old_issues.items(): if issue.last_alert and issue.last_alert < alert_cutoff: if issue.update({'last_alert': datetime.now()}): db.session.add(issue.issue) old_alerts.append(issue) db.session.commit() self.notify( [x.to_json() for x in new_issues.values()], [x.to_json() for x in old_alerts], fixed_issues ) finally: db.session.rollback() def notify(self, new_issues, existing_issues, fixed_issues): """Send notifications (email, slack, etc.) for any issues that are currently open or has just been closed Args: new_issues (`list` of :obj:`DomainHijackIssue`): List of newly discovered issues existing_issues (`list` of :obj:`DomainHijackIssue`): List of existing open issues fixed_issues (`list` of `dict`): List of fixed issues Returns: None """ if len(new_issues + existing_issues + fixed_issues) > 0: maxlen = max(len(x['properties']['source']) for x in (new_issues + existing_issues + fixed_issues)) + 2 text_tmpl = get_template('domain_hijacking.txt') html_tmpl = get_template('domain_hijacking.html') issues_text = text_tmpl.render( new_issues=new_issues, existing_issues=existing_issues, fixed_issues=fixed_issues, maxlen=maxlen ) issues_html = html_tmpl.render( new_issues=new_issues, existing_issues=existing_issues, fixed_issues=fixed_issues, maxlen=maxlen ) try: send_notification( subsystem=self.name, recipients=[NotificationContact('email', addr) for addr in self.recipients], subject=self.subject, body_html=issues_html, body_text=issues_text ) except Exception as ex: self.log.exception('Failed sending notification email: {}'.format(ex)) def return_resource_name(self, record, resource_type): """ Removes the trailing AWS domain from a DNS record to return the resource name e.g bucketname.s3.amazonaws.com will return bucketname Args: record (str): DNS record resource_type: AWS Resource type (i.e. S3 Bucket, Elastic Beanstalk, etc..) """ try: if resource_type == 's3': regex = re.compile('.*(\.(?:s3-|s3){1}(?:.*)?\.amazonaws\.com)') bucket_name = record.replace(regex.match(record).group(1), '') return bucket_name except Exception as e: self.log.error('Unable to parse DNS record {} for resource type {}/{}'.format(record, resource_type, e)) return record
class SQSScheduler(BaseScheduler): name = 'SQS Scheduler' ns = NS_SCHEDULER_SQS options = ( ConfigOption('queue_region', 'us-west-2', 'string', 'Region of the SQS Queues'), ConfigOption('job_queue_url', '', 'string', 'URL of the SQS Queue for pending jobs'), ConfigOption('status_queue_url', '', 'string', 'URL of the SQS Queue for worker reports'), ConfigOption('job_delay', 2, 'float', 'Time between each scheduled job, in seconds. Can be used to ' 'avoid spiky load during execution of tasks'), ) def __init__(self): """Initialize the SQSScheduler, setting up the process pools, scheduler and connecting to the required SQS Queues""" super().__init__() self.pool = ProcessPoolExecutor(1) self.scheduler = APScheduler( threadpool=self.pool, job_defaults={ 'coalesce': True, 'misfire_grace_time': 30 } ) session = get_local_aws_session() sqs = session.resource('sqs', self.dbconfig.get('queue_region', self.ns)) self.job_queue = sqs.Queue(self.dbconfig.get('job_queue_url', self.ns)) self.status_queue = sqs.Queue(self.dbconfig.get('status_queue_url', self.ns)) def execute_scheduler(self): """Main entry point for the scheduler. This method will start two scheduled jobs, `schedule_jobs` which takes care of scheduling the actual SQS messaging and `process_status_queue` which will track the current status of the jobs as workers are executing them Returns: `None` """ try: # Schedule periodic scheduling of jobs self.scheduler.add_job( self.schedule_jobs, trigger='interval', name='schedule_jobs', minutes=15, start_date=datetime.now() + timedelta(seconds=1) ) self.scheduler.add_job( self.process_status_queue, trigger='interval', name='process_status_queue', seconds=30, start_date=datetime.now() + timedelta(seconds=5), max_instances=1 ) self.scheduler.start() except KeyboardInterrupt: self.scheduler.shutdown() def list_current_jobs(self): """Return a list of the currently scheduled jobs in APScheduler Returns: `dict` of `str`: :obj:`apscheduler/job:Job` """ jobs = {} for job in self.scheduler.get_jobs(): if job.name not in ('schedule_jobs', 'process_status_queue'): jobs[job.name] = job return jobs def schedule_jobs(self): """Schedule or remove jobs as needed. Checks to see if there are any jobs that needs to be scheduled, after refreshing the database configuration as well as the list of collectors and auditors. Returns: `None` """ self.dbconfig.reload_data() self.collectors = {} self.auditors = [] self.load_plugins() _, accounts = BaseAccount.search(include_disabled=False) current_jobs = self.list_current_jobs() new_jobs = [] batch_id = str(uuid4()) batch = SchedulerBatch() batch.batch_id = batch_id batch.status = SchedulerStatus.PENDING db.session.add(batch) db.session.commit() start = datetime.now() + timedelta(seconds=1) job_delay = dbconfig.get('job_delay', self.ns, 0.5) # region Global collectors (non-aws) if CollectorType.GLOBAL in self.collectors: for worker in self.collectors[CollectorType.GLOBAL]: job_name = get_hash(worker) if job_name in current_jobs: continue self.scheduler.add_job( self.send_worker_queue_message, trigger='interval', name=job_name, minutes=worker.interval, start_date=start, kwargs={ 'batch_id': batch_id, 'job_name': job_name, 'entry_point': worker.entry_point, 'worker_args': {} } ) start += timedelta(seconds=job_delay) # endregion # region AWS collectors aws_accounts = list(filter(lambda x: x.account_type == AWSAccount.account_type, accounts)) if CollectorType.AWS_ACCOUNT in self.collectors: for worker in self.collectors[CollectorType.AWS_ACCOUNT]: for account in aws_accounts: job_name = get_hash((account.account_name, worker)) if job_name in current_jobs: continue new_jobs.append(job_name) self.scheduler.add_job( self.send_worker_queue_message, trigger='interval', name=job_name, minutes=worker.interval, start_date=start, kwargs={ 'batch_id': batch_id, 'job_name': job_name, 'entry_point': worker.entry_point, 'worker_args': { 'account': account.account_name } } ) start += timedelta(seconds=job_delay) if CollectorType.AWS_REGION in self.collectors: for worker in self.collectors[CollectorType.AWS_REGION]: for region in AWS_REGIONS: for account in aws_accounts: job_name = get_hash((account.account_name, region, worker)) if job_name in current_jobs: continue new_jobs.append(job_name) self.scheduler.add_job( self.send_worker_queue_message, trigger='interval', name=job_name, minutes=worker.interval, start_date=start, kwargs={ 'batch_id': batch_id, 'job_name': job_name, 'entry_point': worker.entry_point, 'worker_args': { 'account': account.account_name, 'region': region } } ) start += timedelta(seconds=job_delay) # endregion # region Auditors if app_config.log_level == 'DEBUG': audit_start = start + timedelta(seconds=5) else: audit_start = start + timedelta(minutes=5) for worker in self.auditors: job_name = get_hash((worker,)) if job_name in current_jobs: continue new_jobs.append(job_name) self.scheduler.add_job( self.send_worker_queue_message, trigger='interval', name=job_name, minutes=worker.interval, start_date=audit_start, kwargs={ 'batch_id': batch_id, 'job_name': job_name, 'entry_point': worker.entry_point, 'worker_args': {} } ) audit_start += timedelta(seconds=job_delay) # endregion def send_worker_queue_message(self, *, batch_id, job_name, entry_point, worker_args, retry_count=0): """Send a message to the `worker_queue` for a worker to execute the requests job Args: batch_id (`str`): Unique ID of the batch the job belongs to job_name (`str`): Non-unique ID of the job. This is used to ensure that the same job is only scheduled a single time per batch entry_point (`dict`): A dictionary providing the entry point information for the worker to load the class worker_args (`dict`): A dictionary with the arguments required by the worker class (if any, can be an empty dictionary) retry_count (`int`): The number of times this one job has been attempted to be executed. If a job fails to execute after 3 retries it will be marked as failed Returns: `None` """ try: job_id = str(uuid4()) self.job_queue.send_message( MessageBody=json.dumps({ 'batch_id': batch_id, 'job_id': job_id, 'job_name': job_name, 'entry_point': entry_point, 'worker_args': worker_args, }), MessageDeduplicationId=job_id, MessageGroupId=batch_id, MessageAttributes={ 'RetryCount': { 'StringValue': str(retry_count), 'DataType': 'Number' } } ) if retry_count == 0: job = SchedulerJob() job.job_id = job_id job.batch_id = batch_id job.status = SchedulerStatus.PENDING job.data = worker_args db.session.add(job) db.session.commit() except: self.log.exception('Error when processing worker task') def execute_worker(self): """Retrieve a message from the `worker_queue` and process the request. This function will read a single message from the `worker_queue` and load the specified `EntryPoint` and execute the worker with the provided arguments. Upon completion (failure or otherwise) a message is sent to the `status_queue` information the scheduler about the return status (success/failure) of the worker Returns: `None` """ try: try: messages = self.job_queue.receive_messages( MaxNumberOfMessages=1, MessageAttributeNames=('RetryCount',) ) except ClientError: self.log.exception('Failed fetching messages from SQS queue') return if not messages: self.log.debug('No pending jobs') return for message in messages: try: retry_count = int(message.message_attributes['RetryCount']['StringValue']) data = json.loads(message.body) try: # SQS FIFO queues will not allow another thread to get any new messages until the messages # in-flight are returned to the queue or deleted, so we remove the message from the queue as # soon as we've loaded the data self.send_status_message(data['job_id'], SchedulerStatus.STARTED) message.delete() cls = self.get_class_from_ep(data['entry_point']) worker = cls(**data['worker_args']) if hasattr(worker, 'type'): if worker.type == CollectorType.GLOBAL: self.log.info('RUN_INFO: {} starting at {}, next run will be at approximately {}'.format(data['entry_point']['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), (datetime.now() + timedelta(minutes=worker.interval)).strftime("%Y-%m-%d %H:%M:%S"))) elif worker.type == CollectorType.AWS_REGION: self.log.info('RUN_INFO: {} starting at {} for account {} / region {}, next run will be at approximately {}'.format(data['entry_point']['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), data['worker_args']['account'], data['worker_args']['region'], (datetime.now() + timedelta(minutes=worker.interval)).strftime("%Y-%m-%d %H:%M:%S"))) elif worker.type == CollectorType.AWS_ACCOUNT: self.log.info('RUN_INFO: {} starting at {} for account {} next run will be at approximately {}'.format(data['entry_point']['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), data['worker_args']['account'], (datetime.now() + timedelta(minutes=worker.interval)).strftime("%Y-%m-%d %H:%M:%S"))) else: self.log.info('RUN_INFO: {} starting at {} next run will be at approximately {}'.format(data['entry_point']['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), (datetime.now() + timedelta(minutes=worker.interval)).strftime("%Y-%m-%d %H:%M:%S"))) worker.run() self.send_status_message(data['job_id'], SchedulerStatus.COMPLETED) except InquisitorError: # If the job failed for some reason, reschedule it unless it has already been retried 3 times if retry_count >= 3: self.send_status_message(data['job_id'], SchedulerStatus.FAILED) else: self.send_worker_queue_message( batch_id=data['batch_id'], job_name=data['job_name'], entry_point=data['entry_point'], worker_args=data['worker_args'], retry_count=retry_count + 1 ) except: self.log.exception('Failed processing scheduler job: {}'.format(message.body)) except KeyboardInterrupt: self.log.info('Shutting down worker thread') @retry def send_status_message(self, object_id, status): """Send a message to the `status_queue` to update a job's status. Returns `True` if the message was sent, else `False` Args: object_id (`str`): ID of the job that was executed status (:obj:`SchedulerStatus`): Status of the job Returns: `bool` """ try: body = json.dumps({ 'id': object_id, 'status': status }) self.status_queue.send_message( MessageBody=body, MessageGroupId='job_status', MessageDeduplicationId=get_hash((object_id, status)) ) return True except Exception as ex: print(ex) return False @retry def process_status_queue(self): """Process all messages in the `status_queue` and check for any batches that needs to change status Returns: `None` """ self.log.debug('Start processing status queue') while True: messages = self.status_queue.receive_messages(MaxNumberOfMessages=10) if not messages: break for message in messages: data = json.loads(message.body) job = SchedulerJob.get(data['id']) try: if job and job.update_status(data['status']): db.session.commit() except SchedulerError as ex: if hasattr(ex, 'message') and ex.message == 'Attempting to update already completed job': pass message.delete() # Close any batch that is now complete open_batches = db.SchedulerBatch.find(SchedulerBatch.status < SchedulerStatus.COMPLETED) for batch in open_batches: open_jobs = list(filter(lambda x: x.status < SchedulerStatus.COMPLETED, batch.jobs)) if not open_jobs: open_batches.remove(batch) batch.update_status(SchedulerStatus.COMPLETED) self.log.debug('Closed completed batch {}'.format(batch.batch_id)) else: started_jobs = list(filter(lambda x: x.status > SchedulerStatus.PENDING, open_jobs)) if batch.status == SchedulerStatus.PENDING and len(started_jobs) > 0: batch.update_status(SchedulerStatus.STARTED) self.log.debug('Started batch manually {}'.format(batch.batch_id)) # Check for stale batches / jobs for batch in open_batches: if batch.started < datetime.now() - timedelta(hours=2): self.log.warning('Closing a stale scheduler batch: {}'.format(batch.batch_id)) for job in batch.jobs: if job.status < SchedulerStatus.COMPLETED: job.update_status(SchedulerStatus.ABORTED) batch.update_status(SchedulerStatus.ABORTED) db.session.commit()
class SlackNotifier(BaseNotifier): name = 'Slack Notifier' ns = NS_SLACK notifier_type = 'slack' validation = r'^(#[a-zA-Z0-9\-_]+|{})$'.format(RGX_EMAIL_VALIDATION_PATTERN) options = ( ConfigOption('enabled', False, 'bool', 'Enable the Slack notifier plugin'), ConfigOption('api_key', '', 'string', 'API token for the slack notifications'), ConfigOption('bot_name', 'Inquisitor', 'string', 'Name of the bot in Slack'), ConfigOption('bot_color', '#607d8b', 'string', 'Hex formatted color code for notifications'), ) def __init__(self, api_key=None): super().__init__() if not self.enabled: raise SlackError('Slack messaging is disabled') self.slack_client = SlackClient(api_key or dbconfig.get('api_key', self.ns)) self.bot_name = dbconfig.get('bot_name', self.ns, 'Inquisitor') self.color = dbconfig.get('bot_color', self.ns, '#607d8b') if not self._check_credentials(): raise SlackError('Failed authenticating to the slack api. Please check the API is valid') def _check_credentials(self): try: response = self.slack_client.api_call('auth.test') return response['ok'] except Exception: return False def _get_user_id_by_email(self, email): try: response = self.slack_client.api_call('users.list') if not response['ok']: raise SlackError('Failed to list Slack users: {}'.format(response['error'])) user = list(filter(lambda x: x['profile'].get('email') == email, response['members'])) if user: return user.pop()['id'] else: SlackError('Failed to get user from Slack!') except Exception as ex: raise SlackError(ex) def _get_channel_for_user(self, user_email): user_id = self._get_user_id_by_email(user_email) try: response = self.slack_client.api_call('im.open', user=user_id) if not response['ok']: raise SlackError('Failed to get channel for user: {}'.format(response['error'])) return response['channel']['id'] except Exception as ex: raise SlackError(ex) def _send_message(self, target_type, target, message, title): if target_type == 'user': channel = self._get_channel_for_user(target) else: channel = target result = self.slack_client.api_call( 'chat.postMessage', channel=channel, attachments=[ { 'fallback': message, 'color': self.color, 'title': title, 'text': message } ], username=self.bot_name ) if not result.get('ok', False): raise SlackError('Failed to send message: {}'.format(result['error'])) def notify(self, subsystem, recipient, subject, body_html, body_text): """You can send messages either to channels and private groups by using the following formats #channel-name @username-direct-message Args: subsystem (`str`): Name of the subsystem originating the notification recipient (`str`): Recipient subject (`str`): Subject / title of the notification, not used for this notifier body_html (`str)`: HTML formatted version of the message, not used for this notifier body_text (`str`): Text formatted version of the message Returns: `None` """ if not re.match(self.validation, recipient, re.I): raise ValueError('Invalid recipient provided') if recipient.startswith('#'): target_type = 'channel' elif recipient.find('@') != -1: target_type = 'user' else: self.log.error('Unknown contact type for Slack: {}'.format(recipient)) return try: self._send_message( target_type=target_type, target=recipient, message=body_text, title=subject ) except SlackError as ex: self.log.error('Failed sending message to {}: {}'.format(recipient, ex)) @staticmethod @deprecated('send_message has been deprecated, use cloud_inquisitor.utils.send_notifications instead') def send_message(contacts, message): """List of contacts the send the message to. You can send messages either to channels and private groups by using the following formats #channel-name @username-direct-message If the channel is the name of a private group / channel, you must first invite the bot to the channel to ensure it is allowed to send messages to the group. Returns true if the message was sent, else `False` Args: contacts (:obj:`list` of `str`,`str`): List of contacts message (str): Message to send Returns: `bool` """ if type(contacts) == str: contacts = [contacts] recipients = list(set(contacts)) send_notification( subsystem='UNKNOWN', recipients=[NotificationContact('slack', x) for x in recipients], subject=None, body_html=message, body_text=message )
class CloudTrailAuditor(BaseAuditor): """CloudTrail auditor Ensures that CloudTrail is enabled and logging to a central location and that SNS/SQS notifications are enabled and being sent to the correct queues for the CloudTrail Logs application """ name = 'CloudTrail' ns = NS_AUDITOR_CLOUDTRAIL interval = dbconfig.get('interval', ns, 60) options = ( ConfigOption('enabled', False, 'bool', 'Enable the Cloudtrail auditor'), ConfigOption('interval', 60, 'int', 'Run frequency in minutes'), ConfigOption( 'bucket_account', 'CHANGE ME', 'string', 'Name of the account in which to create the S3 bucket where CloudTrail logs will be delivered. ' 'The account must exist in the accounts section of the tool'), ConfigOption('bucket_name', 'CHANGE ME', 'string', 'Name of the S3 bucket to send CloudTrail logs to'), ConfigOption('bucket_region', 'us-west-2', 'string', 'Region for the S3 bucket for CloudTrail logs'), ConfigOption('global_cloudtrail_region', 'us-west-2', 'string', 'Region where to enable the global Cloudtrail'), ConfigOption('sns_topic_name', 'CHANGE ME', 'string', 'Name of the SNS topic for CloudTrail log delivery'), ConfigOption( 'sqs_queue_account', 'CHANGE ME', 'string', 'Name of the account which owns the SQS queue for CloudTrail log delivery notifications. ' 'This account must exist in the accounts section of the tool'), ConfigOption('sqs_queue_name', 'SET ME', 'string', 'Name of the SQS queue'), ConfigOption('sqs_queue_region', 'us-west-2', 'string', 'Region for the SQS queue'), ConfigOption('trail_name', 'Cinq_Auditing', 'string', 'Name of the CloudTrail trail to create'), ) def run(self, *args, **kwargs): """Entry point for the scheduler Args: *args: Optional arguments **kwargs: Optional keyword arguments Returns: None """ accounts = list(AWSAccount.get_all(include_disabled=False).values()) # S3 Bucket config s3_acl = get_template('cloudtrail_s3_bucket_policy.json') s3_bucket_name = self.dbconfig.get('bucket_name', self.ns) s3_bucket_region = self.dbconfig.get('bucket_region', self.ns, 'us-west-2') s3_bucket_account = AWSAccount.get( self.dbconfig.get('bucket_account', self.ns)) CloudTrail.create_s3_bucket(s3_bucket_name, s3_bucket_region, s3_bucket_account, s3_acl) self.validate_sqs_policy(accounts) for account in accounts: ct = CloudTrail(account, s3_bucket_name, s3_bucket_region, self.log) ct.run() def validate_sqs_policy(self, accounts): """Given a list of accounts, ensures that the SQS policy allows all the accounts to write to the queue Args: accounts (`list` of :obj:`Account`): List of accounts Returns: `None` """ sqs_queue_name = self.dbconfig.get('sqs_queue_name', self.ns) sqs_queue_region = self.dbconfig.get('sqs_queue_region', self.ns) sqs_account = AWSAccount.get( self.dbconfig.get('sqs_queue_account', self.ns)) session = get_aws_session(sqs_account) sqs = session.client('sqs', region_name=sqs_queue_region) sqs_queue_url = sqs.get_queue_url( QueueName=sqs_queue_name, QueueOwnerAWSAccountId=sqs_account.account_number) sqs_attribs = sqs.get_queue_attributes( QueueUrl=sqs_queue_url['QueueUrl'], AttributeNames=['Policy']) policy = json.loads(sqs_attribs['Attributes']['Policy']) for account in accounts: arn = 'arn:aws:sns:*:{}:{}'.format(account.account_number, sqs_queue_name) if arn not in policy['Statement'][0]['Condition'][ 'ForAnyValue:ArnEquals']['aws:SourceArn']: self.log.warning( 'SQS policy is missing condition for ARN {}'.format(arn)) policy['Statement'][0]['Condition']['ForAnyValue:ArnEquals'][ 'aws:SourceArn'].append(arn) sqs.set_queue_attributes(QueueUrl=sqs_queue_url['QueueUrl'], Attributes={'Policy': json.dumps(policy)})
class EmailNotifier(BaseNotifier): name = 'Email Notifier' ns = NS_EMAIL enabled = dbconfig.get('enabled', ns, True) options = ( ConfigOption('enabled', True, 'bool', 'Enable the Email notifier plugin'), ConfigOption('from_address', '*****@*****.**', 'string', 'Sender address for emails'), ConfigOption('method', 'ses', 'string', 'EMail sending method, either ses or smtp'), ConfigOption('from_arn', '', 'string', 'If using cross-account SES, this is the "From ARN", otherwise leave blank' ), ConfigOption('return_path_arn', '', 'string', 'If using cross-account SES, this is the "Return Path ARN", otherwise leave blank' ), ConfigOption('source_arn', '', 'string', 'If using cross-account SES, this is the "Source ARN", otherwise leave blank' ), ConfigOption('ses_region', 'us-west-2', 'string', 'Which SES region to send emails from'), ConfigOption('smtp_server', 'localhost', 'string', 'Address of the SMTP server to use'), ConfigOption('smtp_port', 25, 'int', 'Port for the SMTP server'), ConfigOption('smtp_username', '', 'string', 'Username for SMTP authentication. Leave blank for no authentication' ), ConfigOption('smtp_password', '', 'string', 'Password for SMTP authentication. Leave blank for no authentication' ), ConfigOption('smtp_tls', False, 'bool', 'Use TLS for sending emails'), )
class EBSAuditor(BaseAuditor): """Known issue: if this runs before collector, we don't have EBSVolume or EBSVolumeAttachment data.""" name = 'EBS Auditor' ns = NS_AUDITOR_EBS interval = dbconfig.get('interval', ns, 1440) options = ( ConfigOption('enabled', False, 'bool', 'Enable the EBS auditor'), ConfigOption('interval', 1440, 'int', 'How often the auditor runs, in minutes'), ConfigOption('renotify_delay_days', 14, 'int', 'Send another notifications n days after the last'), ConfigOption('email_subject', 'Unattached EBS Volumes', 'string', 'Subject of the notification emails'), ConfigOption( 'ignore_tags', ['cinq:ignore'], 'array', 'A list of tags that will cause the auditor to ignore the volume')) def __init__(self): super().__init__() self.subject = self.dbconfig.get('email_subject', self.ns) def run(self, *args, **kwargs): """Main execution point for the auditor Args: *args: **kwargs: Returns: `None` """ self.log.debug('Starting EBSAuditor') data = self.update_data() notices = defaultdict(list) for account, issues in data.items(): for issue in issues: for recipient in account.contacts: notices[NotificationContact( type=recipient['type'], value=recipient['value'])].append(issue) self.notify(notices) def update_data(self): """Update the database with the current state and return a dict containing the new / updated and fixed issues respectively, keyed by the account object Returns: `dict` """ existing_issues = EBSVolumeAuditIssue.get_all() volumes = self.get_unattached_volumes() new_issues = self.process_new_issues(volumes, existing_issues) fixed_issues = self.process_fixed_issues(volumes, existing_issues) # region Process the data to be returned output = defaultdict(list) for acct, data in new_issues.items(): output[acct] += data # endregion # region Update the database with the changes pending for issues in new_issues.values(): for issue in issues: db.session.add(issue.issue) for issue in fixed_issues: db.session.delete(issue.issue) db.session.commit() # endregion return output def get_unattached_volumes(self): """Build a list of all volumes missing tags and not ignored. Returns a `dict` keyed by the issue_id with the volume as the value Returns: :obj:`dict` of `str`: `EBSVolume` """ volumes = {} ignored_tags = dbconfig.get('ignore_tags', self.ns) for volume in EBSVolume.get_all().values(): issue_id = get_resource_id('evai', volume.id) if len(volume.attachments) == 0: if len( list( filter( set(ignored_tags).__contains__, [tag.key for tag in volume.tags]))): continue volumes[issue_id] = volume return volumes def process_new_issues(self, volumes, existing_issues): """Takes a dict of existing volumes missing tags and a dict of existing issues, and finds any new or updated issues. Args: volumes (:obj:`dict` of `str`: `EBSVolume`): Dict of current volumes with issues existing_issues (:obj:`dict` of `str`: `EBSVolumeAuditIssue`): Current list of issues Returns: :obj:`dict` of `str`: `EBSVolumeAuditIssue` """ new_issues = {} for issue_id, volume in volumes.items(): state = EBSIssueState.DETECTED.value if issue_id in existing_issues: issue = existing_issues[issue_id] data = { 'state': state, 'notes': issue.notes, 'last_notice': issue.last_notice } if issue.update(data): new_issues.setdefault(issue.volume.account, []).append(issue) self.log.debug( 'Updated EBSVolumeAuditIssue {}'.format(issue_id)) else: properties = { 'volume_id': volume.id, 'account_id': volume.account_id, 'location': volume.location, 'state': state, 'last_change': datetime.now(), 'last_notice': None, 'notes': [] } issue = EBSVolumeAuditIssue.create(issue_id, properties=properties) new_issues.setdefault(issue.volume.account, []).append(issue) return new_issues def process_fixed_issues(self, volumes, existing_issues): """Provided a list of volumes and existing issues, returns a list of fixed issues to be deleted Args: volumes (`dict`): A dictionary keyed on the issue id, with the :obj:`Volume` object as the value existing_issues (`dict`): A dictionary keyed on the issue id, with the :obj:`EBSVolumeAuditIssue` object as the value Returns: :obj:`list` of :obj:`EBSVolumeAuditIssue` """ fixed_issues = [] for issue_id, issue in list(existing_issues.items()): if issue_id not in volumes: fixed_issues.append(issue) return fixed_issues def notify(self, notices): """Send notifications to the users via. the provided methods Args: notices (:obj:`dict` of `str`: `dict`): List of the notifications to send Returns: `None` """ issues_html = get_template('unattached_ebs_volume.html') issues_text = get_template('unattached_ebs_volume.txt') for recipient, issues in list(notices.items()): if issues: message_html = issues_html.render(issues=issues) message_text = issues_text.render(issues=issues) send_notification(subsystem=self.name, recipients=[recipient], subject=self.subject, body_html=message_html, body_text=message_text)
class AWSRegionCollector(BaseCollector): name = 'EC2 Region Collector' ns = 'collector_ec2' type = CollectorType.AWS_REGION interval = dbconfig.get('interval', ns, 15) options = ( ConfigOption('enabled', True, 'bool', 'Enable the AWS Region-based Collector'), ConfigOption('interval', 15, 'int', 'Run frequency, in minutes'), ConfigOption('max_instances', 1000, 'int', 'Maximum number of instances per API call'), ) def __init__(self, account, region): super().__init__() if type(account) == str: account = AWSAccount.get(account) if not isinstance(account, AWSAccount): raise InquisitorError( 'The AWS Collector only supports AWS Accounts, got {}'.format( account.__class__.__name__)) self.account = account self.region = region self.session = get_aws_session(self.account) def run(self, *args, **kwargs): try: self.update_instances() self.update_volumes() self.update_snapshots() self.update_amis() self.update_beanstalks() self.update_vpcs() self.update_elbs() except Exception as ex: self.log.exception(ex) raise finally: del self.session @retry def update_instances(self): """Update list of EC2 Instances for the account / region Returns: `None` """ self.log.debug('Updating EC2Instances for {}/{}'.format( self.account.account_name, self.region)) ec2 = self.session.resource('ec2', region_name=self.region) try: existing_instances = EC2Instance.get_all(self.account, self.region) instances = {} api_instances = {x.id: x for x in ec2.instances.all()} try: for instance_id, data in api_instances.items(): if data.instance_id in existing_instances: instance = existing_instances[instance_id] if data.state['Name'] not in ('terminated', 'shutting-down'): instances[instance_id] = instance # Add object to transaction if it changed if instance.update(data): self.log.debug( 'Updating info for instance {} in {}/{}'. format(instance.resource.resource_id, self.account.account_name, self.region)) db.session.add(instance.resource) else: # New instance, if its not in state=terminated if data.state['Name'] in ('terminated', 'shutting-down'): continue tags = { tag['Key']: tag['Value'] for tag in data.tags or {} } properties = { 'launch_date': to_utc_date(data.launch_time).isoformat(), 'state': data.state['Name'], 'instance_type': data.instance_type, 'public_ip': getattr(data, 'public_ip_address', None), 'public_dns': getattr(data, 'public_dns_address', None), 'created': isoformat(datetime.now()) } instance = EC2Instance.create( data.instance_id, account_id=self.account.account_id, location=self.region, properties=properties, tags=tags) instances[instance.resource.resource_id] = instance self.log.debug('Added new EC2Instance {}/{}/{}'.format( self.account.account_name, self.region, instance.resource.resource_id)) # Check for deleted instances ik = set(list(instances.keys())) eik = set(list(existing_instances.keys())) for instanceID in eik - ik: db.session.delete(existing_instances[instanceID].resource) self.log.debug('Deleted EC2Instance {}/{}/{}'.format( self.account.account_name, self.region, instanceID)) db.session.commit() except: db.session.rollback() raise finally: del ec2 @retry def update_amis(self): """Update list of AMIs for the account / region Returns: `None` """ self.log.debug('Updating AMIs for {}/{}'.format( self.account.account_name, self.region)) ec2 = self.session.resource('ec2', region_name=self.region) try: existing_images = AMI.get_all(self.account, self.region) images = {x.id: x for x in ec2.images.filter(Owners=['self'])} for data in list(images.values()): if data.id in existing_images: ami = existing_images[data.id] if ami.update(data): self.log.debug( 'Changed detected for AMI {}/{}/{}'.format( self.account.account_name, self.region, ami.resource.resource_id)) else: properties = { 'architecture': data.architecture, 'creation_date': parse_date(data.creation_date or '1970-01-01 00:00:00'), 'description': data.description, 'name': data.name, 'platform': data.platform or 'Linux', 'state': data.state, } tags = { tag['Key']: tag['Value'] for tag in data.tags or {} } AMI.create(data.id, account_id=self.account.account_id, location=self.region, properties=properties, tags=tags) self.log.debug('Added new AMI {}/{}/{}'.format( self.account.account_name, self.region, data.id)) db.session.commit() # Check for deleted instances ik = set(list(images.keys())) eik = set(list(existing_images.keys())) try: for image_id in eik - ik: db.session.delete(existing_images[image_id].resource) self.log.debug('Deleted AMI {}/{}/{}'.format( self.account.account_name, self.region, image_id, )) db.session.commit() except: db.session.rollback() finally: del ec2 @retry def update_volumes(self): """Update list of EBS Volumes for the account / region Returns: `None` """ self.log.debug('Updating EBSVolumes for {}/{}'.format( self.account.account_name, self.region)) ec2 = self.session.resource('ec2', region_name=self.region) try: existing_volumes = EBSVolume.get_all(self.account, self.region) volumes = {x.id: x for x in ec2.volumes.all()} for data in list(volumes.values()): if data.id in existing_volumes: vol = existing_volumes[data.id] if vol.update(data): self.log.debug( 'Changed detected for EBSVolume {}/{}/{}'.format( self.account.account_name, self.region, vol.resource.resource_id)) else: properties = { 'create_time': data.create_time, 'encrypted': data.encrypted, 'iops': data.iops or 0, 'kms_key_id': data.kms_key_id, 'size': data.size, 'state': data.state, 'snapshot_id': data.snapshot_id, 'volume_type': data.volume_type, 'attachments': sorted([x['InstanceId'] for x in data.attachments]) } tags = {t['Key']: t['Value'] for t in data.tags or {}} vol = EBSVolume.create(data.id, account_id=self.account.account_id, location=self.region, properties=properties, tags=tags) self.log.debug('Added new EBSVolume {}/{}/{}'.format( self.account.account_name, self.region, vol.resource.resource_id)) db.session.commit() vk = set(list(volumes.keys())) evk = set(list(existing_volumes.keys())) try: for volumeID in evk - vk: db.session.delete(existing_volumes[volumeID].resource) self.log.debug('Deleted EBSVolume {}/{}/{}'.format( volumeID, self.account.account_name, self.region)) db.session.commit() except: self.log.exception('Failed removing deleted volumes') db.session.rollback() finally: del ec2 @retry def update_snapshots(self): """Update list of EBS Snapshots for the account / region Returns: `None` """ self.log.debug('Updating EBSSnapshots for {}/{}'.format( self.account.account_name, self.region)) ec2 = self.session.resource('ec2', region_name=self.region) try: existing_snapshots = EBSSnapshot.get_all(self.account, self.region) snapshots = { x.id: x for x in ec2.snapshots.filter( OwnerIds=[self.account.account_number]) } for data in list(snapshots.values()): if data.id in existing_snapshots: snapshot = existing_snapshots[data.id] if snapshot.update(data): self.log.debug( 'Change detected for EBSSnapshot {}/{}/{}'.format( self.account.account_name, self.region, snapshot.resource.resource_id)) else: properties = { 'create_time': data.start_time, 'encrypted': data.encrypted, 'kms_key_id': data.kms_key_id, 'state': data.state, 'state_message': data.state_message, 'volume_id': data.volume_id, 'volume_size': data.volume_size, } tags = {t['Key']: t['Value'] for t in data.tags or {}} snapshot = EBSSnapshot.create( data.id, account_id=self.account.account_id, location=self.region, properties=properties, tags=tags) self.log.debug('Added new EBSSnapshot {}/{}/{}'.format( self.account.account_name, self.region, snapshot.resource.resource_id)) db.session.commit() vk = set(list(snapshots.keys())) evk = set(list(existing_snapshots.keys())) try: for snapshotID in evk - vk: db.session.delete(existing_snapshots[snapshotID].resource) self.log.debug('Deleted EBSSnapshot {}/{}/{}'.format( self.account.account_name, self.region, snapshotID)) db.session.commit() except: self.log.exception('Failed removing deleted snapshots') db.session.rollback() finally: del ec2 @retry def update_beanstalks(self): """Update list of Elastic BeanStalks for the account / region Returns: `None` """ self.log.debug( 'Updating ElasticBeanStalk environments for {}/{}'.format( self.account.account_name, self.region)) ebclient = self.session.client('elasticbeanstalk', region_name=self.region) try: existing_beanstalks = BeanStalk.get_all(self.account, self.region) beanstalks = {} # region Fetch elastic beanstalks for env in ebclient.describe_environments()['Environments']: # Only get information for HTTP (non-worker) Beanstalks if env['Tier']['Type'] == 'Standard': if 'CNAME' in env: beanstalks[env['EnvironmentId']] = { 'id': env['EnvironmentId'], 'environment_name': env['EnvironmentName'], 'application_name': env['ApplicationName'], 'cname': env['CNAME'] } else: self.log.warning( 'Found a BeanStalk that does not have a CNAME: {} in {}/{}' .format(env['EnvironmentName'], self.account, self.region)) else: self.log.debug( 'Skipping worker tier ElasticBeanstalk environment {}/{}/{}' .format(self.account.account_name, self.region, env['EnvironmentName'])) # endregion try: for data in beanstalks.values(): if data['id'] in existing_beanstalks: beanstalk = existing_beanstalks[data['id']] if beanstalk.update(data): self.log.debug( 'Change detected for ElasticBeanStalk {}/{}/{}' .format(self.account.account_name, self.region, data['id'])) else: bid = data.pop('id') tags = {} BeanStalk.create(bid, account_id=self.account.account_id, location=self.region, properties=data, tags=tags) self.log.debug( 'Added new ElasticBeanStalk {}/{}/{}'.format( self.account.account_name, self.region, bid)) db.session.commit() bk = set(beanstalks.keys()) ebk = set(existing_beanstalks.keys()) for resource_id in ebk - bk: db.session.delete( existing_beanstalks[resource_id].resource) self.log.debug('Deleted ElasticBeanStalk {}/{}/{}'.format( self.account.account_name, self.region, resource_id)) db.session.commit() except: db.session.rollback() return beanstalks finally: del ebclient @retry def update_vpcs(self): """Update list of VPCs for the account / region Returns: `None` """ self.log.debug('Updating VPCs for {}/{}'.format( self.account.account_name, self.region)) existing_vpcs = VPC.get_all(self.account, self.region) try: ec2 = self.session.resource('ec2', region_name=self.region) ec2_client = self.session.client('ec2', region_name=self.region) vpcs = {x.id: x for x in ec2.vpcs.all()} for data in vpcs.values(): flow_logs = ec2_client.describe_flow_logs( Filters=[{ 'Name': 'resource-id', 'Values': [data.vpc_id] }]).get('FlowLogs') tags = {t['Key']: t['Value'] for t in data.tags or {}} properties = { 'vpc_id': data.vpc_id, 'cidr_v4': data.cidr_block, 'is_default': data.is_default, 'state': data.state, 'vpc_flow_logs_status': flow_logs[0]['FlowLogStatus'] if flow_logs else 'UNDEFINED', 'vpc_flow_logs_log_group': flow_logs[0]['LogGroupName'] if flow_logs else 'UNDEFINED', 'tags': tags } if data.id in existing_vpcs: vpc = existing_vpcs[data.vpc_id] if vpc.update(data, properties): self.log.debug( 'Change detected for VPC {}/{}/{} '.format( data.vpc_id, self.region, properties)) else: VPC.create(data.id, account_id=self.account.account_id, location=self.region, properties=properties, tags=tags) db.session.commit() # Removal of VPCs vk = set(vpcs.keys()) evk = set(existing_vpcs.keys()) for resource_id in evk - vk: db.session.delete(existing_vpcs[resource_id].resource) self.log.debug('Removed VPCs {}/{}/{}'.format( self.account.account_name, self.region, resource_id)) db.session.commit() except Exception: self.log.exception( 'There was a problem during VPC collection for {}/{}'.format( self.account.account_name, self.region)) db.session.rollback() @retry def update_elbs(self): """Update list of ELBs for the account / region Returns: `None` """ self.log.debug('Updating ELBs for {}/{}'.format( self.account.account_name, self.region)) # ELBs known to CINQ elbs_from_db = ELB.get_all(self.account, self.region) try: # ELBs known to AWS elb_client = self.session.client('elb', region_name=self.region) load_balancer_instances = elb_client.describe_load_balancers( )['LoadBalancerDescriptions'] elbs_from_api = {} for load_balancer in load_balancer_instances: key = '{}::{}'.format(self.region, load_balancer['LoadBalancerName']) elbs_from_api[key] = load_balancer # Process ELBs known to AWS for elb_identifier in elbs_from_api: data = elbs_from_api[elb_identifier] # ELB already in DB? if elb_identifier in elbs_from_db: elb = elbs_from_db[elb_identifier] if elb.update(data): self.log.info( 'Updating info for ELB {} in {}/{}'.format( elb.resource.resource_id, self.account.account_name, self.region)) db.session.add(elb.resource) else: # Not previously seen this ELB, so add it if 'Tags' in data: try: tags = { tag['Key']: tag['Value'] for tag in data['Tags'] } except AttributeError: tags = {} else: tags = {} vpc_data = (data['VPCId'] if ('VPCId' in data and data['VPCId']) else 'no vpc') properties = { 'lb_name': data['LoadBalancerName'], 'dns_name': data['DNSName'], 'instances': ' '.join([ instance['InstanceId'] for instance in data['Instances'] ]), 'num_instances': len([ instance['InstanceId'] for instance in data['Instances'] ]), 'vpc_id': vpc_data, 'state': 'not_reported' } if 'CanonicalHostedZoneName' in data: properties['canonical_hosted_zone_name'] = data[ 'CanonicalHostedZoneName'] else: properties['canonical_hosted_zone_name'] = None # LoadBalancerName doesn't have to be unique across all regions # Use region::LoadBalancerName as resource_id resource_id = '{}::{}'.format(self.region, data['LoadBalancerName']) # All done, create elb = ELB.create(resource_id, account_id=self.account.account_id, location=self.region, properties=properties, tags=tags) # elbs[elb.resource.resource_id] = elb self.log.info('Added new ELB {}/{}/{}'.format( self.account.account_name, self.region, elb.resource.resource_id)) # Delete no longer existing ELBs elb_keys_from_db = set(list(elbs_from_db.keys())) self.log.debug('elb_keys_from_db = %s', elb_keys_from_db) elb_keys_from_api = set(list(elbs_from_api.keys())) self.log.debug('elb_keys_from_api = %s', elb_keys_from_api) for elb_identifier in elb_keys_from_db - elb_keys_from_api: db.session.delete(elbs_from_db[elb_identifier].resource) self.log.info('Deleted ELB {}/{}/{}'.format( self.account.account_name, self.region, elb_identifier)) db.session.commit() except: self.log.exception( 'There was a problem during ELB collection for {}/{}'.format( self.account.account_name, self.region)) db.session.rollback()
class AWSAccountCollector(BaseCollector): name = 'AWS Account Collector' ns = 'collector_ec2' type = CollectorType.AWS_ACCOUNT interval = dbconfig.get('interval', ns, 15) s3_collection_enabled = dbconfig.get('s3_bucket_collection', ns, True) cloudfront_collection_enabled = dbconfig.get('cloudfront_collection', ns, True) route53_collection_enabled = dbconfig.get('route53_collection', ns, True) options = ( ConfigOption('s3_bucket_collection', True, 'bool', 'Enable S3 Bucket Collection'), ConfigOption('cloudfront_collection', True, 'bool', 'Enable Cloudfront DNS Collection'), ConfigOption('route53_collection', True, 'bool', 'Enable Route53 DNS Collection'), ) def __init__(self, account): super().__init__() if type(account) == str: account = AWSAccount.get(account) if not isinstance(account, AWSAccount): raise InquisitorError('The AWS Collector only supports AWS Accounts, got {}'.format( account.__class__.__name__ )) self.account = account self.session = get_aws_session(self.account) def run(self): try: if self.s3_collection_enabled: self.update_s3buckets() if self.cloudfront_collection_enabled: self.update_cloudfront() if self.route53_collection_enabled: self.update_route53() except Exception as ex: self.log.exception(ex) raise finally: del self.session @retry def update_s3buckets(self): """Update list of S3 Buckets for the account Returns: `None` """ self.log.debug('Updating S3Buckets for {}'.format(self.account.account_name)) s3 = self.session.resource('s3') s3c = self.session.client('s3') try: existing_buckets = S3Bucket.get_all(self.account) buckets = {bucket.name: bucket for bucket in s3.buckets.all()} for data in buckets.values(): # This section ensures that we handle non-existent or non-accessible sub-resources try: bucket_region = s3c.get_bucket_location(Bucket=data.name)['LocationConstraint'] if not bucket_region: bucket_region = 'us-east-1' except ClientError as e: self.log.info('Could not get bucket location..bucket possibly removed / {}'.format(e)) bucket_region = 'unavailable' try: bucket_policy = data.Policy().policy except ClientError as e: if e.response['Error']['Code'] == 'NoSuchBucketPolicy': bucket_policy = None else: self.log.info('There was a problem collecting bucket policy for bucket {} on account {}, {}' .format(data.name, self.account, e.response)) bucket_policy = 'cinq cannot poll' try: website_enabled = 'Enabled' if data.Website().index_document else 'Disabled' except ClientError as e: if e.response['Error']['Code'] == 'NoSuchWebsiteConfiguration': website_enabled = 'Disabled' else: self.log.info('There was a problem collecting website config for bucket {} on account {}' .format(data.name, self.account)) website_enabled = 'cinq cannot poll' try: tags = {t['Key']: t['Value'] for t in data.Tagging().tag_set} except ClientError: tags = {} try: bucket_size = self._get_bucket_statistics(data.name, bucket_region, 'StandardStorage', 'BucketSizeBytes', 3) bucket_obj_count = self._get_bucket_statistics(data.name, bucket_region, 'AllStorageTypes', 'NumberOfObjects', 3) metrics = {'size': bucket_size, 'object_count': bucket_obj_count} except Exception as e: self.log.info('Could not retrieve bucket statistics / {}'.format(e)) metrics = {'found': False} properties = { 'bucket_policy': bucket_policy, 'creation_date': data.creation_date, 'location': bucket_region, 'website_enabled': website_enabled, 'metrics': metrics, 'tags': tags } if data.name in existing_buckets: bucket = existing_buckets[data.name] if bucket.update(data, properties): self.log.debug('Change detected for S3Bucket {}/{}'.format( self.account.account_name, bucket.id )) bucket.save() else: # If a bucket has no tags, a boto3 error is thrown. We treat this as an empty tag set S3Bucket.create( data.name, account_id=self.account.account_id, properties=properties, location=bucket_region, tags=tags ) self.log.debug('Added new S3Bucket {}/{}'.format( self.account.account_name, data.name )) db.session.commit() bk = set(list(buckets.keys())) ebk = set(list(existing_buckets.keys())) try: for resource_id in ebk - bk: db.session.delete(existing_buckets[resource_id].resource) self.log.debug('Deleted S3Bucket {}/{}'.format( self.account.account_name, resource_id )) db.session.commit() except Exception as e: self.log.error( 'Could not update the current S3Bucket list for account {}/{}'.format(self.account.account_name, e)) db.session.rollback() finally: del s3, s3c @retry def update_cloudfront(self): """Update list of CloudFront Distributions for the account Returns: `None` """ self.log.debug('Updating CloudFront distributions for {}'.format(self.account.account_name)) cfr = self.session.client('cloudfront') try: existing_dists = CloudFrontDist.get_all(self.account, None) dists = [] # region Fetch information from API # region Web distributions done = False marker = None while not done: if marker: response = cfr.list_distributions(Marker=marker) else: response = cfr.list_distributions() dl = response['DistributionList'] if dl['IsTruncated']: marker = dl['NextMarker'] else: done = True if 'Items' in dl: for dist in dl['Items']: origins = [] for origin in dist['Origins']['Items']: if 'S3OriginConfig' in origin: origins.append( { 'type': 's3', 'source': origin['DomainName'] } ) elif 'CustomOriginConfig' in origin: origins.append( { 'type': 'custom-http', 'source': origin['DomainName'] } ) data = { 'arn': dist['ARN'], 'name': dist['DomainName'], 'origins': origins, 'enabled': dist['Enabled'], 'type': 'web', 'tags': self.__get_distribution_tags(cfr, dist['ARN']) } dists.append(data) # endregion # region Streaming distributions done = False marker = None while not done: if marker: response = cfr.list_streaming_distributions(Marker=marker) else: response = cfr.list_streaming_distributions() dl = response['StreamingDistributionList'] if dl['IsTruncated']: marker = dl['NextMarker'] else: done = True if 'Items' in dl: dists += [ { 'arn': x['ARN'], 'name': x['DomainName'], 'origins': [{'type': 's3', 'source': x['S3Origin']['DomainName']}], 'enabled': x['Enabled'], 'type': 'rtmp', 'tags': self.__get_distribution_tags(cfr, x['ARN']) } for x in dl['Items'] ] # endregion # endregion for data in dists: if data['arn'] in existing_dists: dist = existing_dists[data['arn']] if dist.update(data): self.log.debug('Updated CloudFrontDist {}/{}'.format( self.account.account_name, data['name'] )) dist.save() else: properties = { 'domain_name': data['name'], 'origins': data['origins'], 'enabled': data['enabled'], 'type': data['type'] } CloudFrontDist.create( data['arn'], account_id=self.account.account_id, properties=properties, tags=data['tags'] ) self.log.debug('Added new CloudFrontDist {}/{}'.format( self.account.account_name, data['name'] )) db.session.commit() dk = set(x['arn'] for x in dists) edk = set(existing_dists.keys()) try: for resource_id in edk - dk: db.session.delete(existing_dists[resource_id].resource) self.log.debug('Deleted CloudFrontDist {}/{}'.format( resource_id, self.account.account_name )) db.session.commit() except: db.session.rollback() finally: del cfr @retry def update_route53(self): """Update list of Route53 DNS Zones and their records for the account Returns: `None` """ self.log.debug('Updating Route53 information for {}'.format(self.account)) # region Update zones existing_zones = DNSZone.get_all(self.account) zones = self.__fetch_route53_zones() for resource_id, data in zones.items(): if resource_id in existing_zones: zone = DNSZone.get(resource_id) if zone.update(data): self.log.debug('Change detected for Route53 zone {}/{}'.format( self.account, zone.name )) zone.save() else: tags = data.pop('tags') DNSZone.create( resource_id, account_id=self.account.account_id, properties=data, tags=tags ) self.log.debug('Added Route53 zone {}/{}'.format( self.account, data['name'] )) db.session.commit() zk = set(zones.keys()) ezk = set(existing_zones.keys()) for resource_id in ezk - zk: zone = existing_zones[resource_id] db.session.delete(zone.resource) self.log.debug('Deleted Route53 zone {}/{}'.format( self.account.account_name, zone.name.value )) db.session.commit() # endregion # region Update resource records try: for zone_id, zone in DNSZone.get_all(self.account).items(): existing_records = {rec.id: rec for rec in zone.records} records = self.__fetch_route53_zone_records(zone.get_property('zone_id').value) for data in records: if data['id'] in existing_records: record = existing_records[data['id']] if record.update(data): self.log.debug('Changed detected for DNSRecord {}/{}/{}'.format( self.account, zone.name, data['name'] )) record.save() else: record = DNSRecord.create( data['id'], account_id=self.account.account_id, properties={k: v for k, v in data.items() if k != 'id'}, tags={} ) self.log.debug('Added new DNSRecord {}/{}/{}'.format( self.account, zone.name, data['name'] )) zone.add_record(record) db.session.commit() rk = set(x['id'] for x in records) erk = set(existing_records.keys()) for resource_id in erk - rk: record = existing_records[resource_id] zone.delete_record(record) self.log.debug('Deleted Route53 record {}/{}/{}'.format( self.account.account_name, zone_id, record.name )) db.session.commit() except: raise # endregion # region Helper functions @retry def __get_distribution_tags(self, client, arn): """Returns a dict containing the tags for a CloudFront distribution Args: client (botocore.client.CloudFront): Boto3 CloudFront client object arn (str): ARN of the distribution to get tags for Returns: `dict` """ return { t['Key']: t['Value'] for t in client.list_tags_for_resource( Resource=arn )['Tags']['Items'] } @retry def __fetch_route53_zones(self): """Return a list of all DNS zones hosted in Route53 Returns: :obj:`list` of `dict` """ done = False marker = None zones = {} route53 = self.session.client('route53') try: while not done: if marker: response = route53.list_hosted_zones(Marker=marker) else: response = route53.list_hosted_zones() if response['IsTruncated']: marker = response['NextMarker'] else: done = True for zone_data in response['HostedZones']: zones[get_resource_id('r53z', zone_data['Id'])] = { 'name': zone_data['Name'].rstrip('.'), 'source': 'AWS/{}'.format(self.account), 'comment': zone_data['Config']['Comment'] if 'Comment' in zone_data['Config'] else None, 'zone_id': zone_data['Id'], 'private_zone': zone_data['Config']['PrivateZone'], 'tags': self.__fetch_route53_zone_tags(zone_data['Id']) } return zones finally: del route53 @retry def __fetch_route53_zone_records(self, zone_id): """Return all resource records for a specific Route53 zone Args: zone_id (`str`): Name / ID of the hosted zone Returns: `dict` """ route53 = self.session.client('route53') done = False nextName = nextType = None records = {} try: while not done: if nextName and nextType: response = route53.list_resource_record_sets( HostedZoneId=zone_id, StartRecordName=nextName, StartRecordType=nextType ) else: response = route53.list_resource_record_sets(HostedZoneId=zone_id) if response['IsTruncated']: nextName = response['NextRecordName'] nextType = response['NextRecordType'] else: done = True if 'ResourceRecordSets' in response: for record in response['ResourceRecordSets']: # Cannot make this a list, due to a race-condition in the AWS api that might return the same # record more than once, so we use a dict instead to ensure that if we get duplicate records # we simply just overwrite the one already there with the same info. record_id = self._get_resource_hash(zone_id, record) if 'AliasTarget' in record: value = record['AliasTarget']['DNSName'] records[record_id] = { 'id': record_id, 'name': record['Name'].rstrip('.'), 'type': 'ALIAS', 'ttl': 0, 'value': [value] } else: value = [y['Value'] for y in record['ResourceRecords']] records[record_id] = { 'id': record_id, 'name': record['Name'].rstrip('.'), 'type': record['Type'], 'ttl': record['TTL'], 'value': value } return list(records.values()) finally: del route53 @retry def __fetch_route53_zone_tags(self, zone_id): """Return a dict with the tags for the zone Args: zone_id (`str`): ID of the hosted zone Returns: :obj:`dict` of `str`: `str` """ route53 = self.session.client('route53') try: return { tag['Key']: tag['Value'] for tag in route53.list_tags_for_resource( ResourceType='hostedzone', ResourceId=zone_id.split('/')[-1] )['ResourceTagSet']['Tags'] } finally: del route53 @staticmethod def _get_resource_hash(zone_name, record): """Returns the last ten digits of the sha256 hash of the combined arguments. Useful for generating unique resource IDs Args: zone_name (`str`): The name of the DNS Zone the record belongs to record (`dict`): A record dict to generate the hash from Returns: `str` """ record_data = defaultdict(int, record) if type(record_data['GeoLocation']) == dict: record_data['GeoLocation'] = ":".join(["{}={}".format(k, v) for k, v in record_data['GeoLocation'].items()]) args = [ zone_name, record_data['Name'], record_data['Type'], record_data['Weight'], record_data['Region'], record_data['GeoLocation'], record_data['Failover'], record_data['HealthCheckId'], record_data['TrafficPolicyInstanceId'] ] return get_resource_id('r53r', args) def _get_bucket_statistics(self, bucket_name, bucket_region, storage_type, statistic, days): """ Returns datapoints from cloudwatch for bucket statistics. Args: bucket_name `(str)`: The name of the bucket statistic `(str)`: The statistic you want to fetch from days `(int)`: Sample period for the statistic """ cw = self.session.client('cloudwatch', region_name=bucket_region) # gather cw stats try: obj_stats = cw.get_metric_statistics( Namespace='AWS/S3', MetricName=statistic, Dimensions=[ { 'Name': 'StorageType', 'Value': storage_type }, { 'Name': 'BucketName', 'Value': bucket_name } ], Period=86400, StartTime=datetime.utcnow() - timedelta(days=days), EndTime=datetime.utcnow(), Statistics=[ 'Average' ] ) stat_value = obj_stats['Datapoints'][0]['Average'] if obj_stats['Datapoints'] else 'NO_DATA' return stat_value except Exception as e: self.log.error( 'Could not get bucket statistic for account {} / bucket {} / {}'.format(self.account.account_name, bucket_name, e)) finally: del cw
class StandaloneScheduler(BaseScheduler): """Main workers refreshing data from AWS """ name = 'Standalone Scheduler' ns = 'scheduler_standalone' pool = None scheduler = None options = ( ConfigOption('worker_threads', 20, 'int', 'Number of worker threads to spawn'), ConfigOption('worker_interval', 30, 'int', 'Delay between each worker thread being spawned, in seconds'), ) def __init__(self): super().__init__() self.collectors = {} self.auditors = [] self.region_workers = [] self.pool = ProcessPoolExecutor(self.dbconfig.get('worker_threads', self.ns, 20)) self.scheduler = APScheduler( threadpool=self.pool, job_defaults={ 'coalesce': True, 'misfire_grace_time': 30 } ) self.load_plugins() def execute_scheduler(self): # Schedule a daily job to cleanup stuff thats been left around (eip's with no instances etc) self.scheduler.add_job( self.cleanup, trigger='cron', name='cleanup', hour=3, minute=0, second=0 ) # Schedule periodic scheduling of jobs self.scheduler.add_job( self.schedule_jobs, trigger='interval', name='schedule_jobs', seconds=60, start_date=datetime.now() + timedelta(seconds=1) ) # Periodically reload the dbconfiguration self.scheduler.add_job( self.dbconfig.reload_data, trigger='interval', name='reload_dbconfig', minutes=5, start_date=datetime.now() + timedelta(seconds=3) ) self.scheduler.start() def execute_worker(self): """This method is not used for the standalone scheduler.""" print('The standalone scheduler does not have a separate worker model. ' 'Executing the scheduler will also execute the workers') def schedule_jobs(self): current_jobs = { x.name: x for x in self.scheduler.get_jobs() if x.name not in ( 'cleanup', 'schedule_jobs', 'reload_dbconfig' ) } new_jobs = [] start = datetime.now() + timedelta(seconds=1) _, accounts = BaseAccount.search(include_disabled=False) # region Global collectors (non-aws) if CollectorType.GLOBAL in self.collectors: for wkr in self.collectors[CollectorType.GLOBAL]: job_name = 'global_{}'.format(wkr.name) new_jobs.append(job_name) if job_name in current_jobs: continue self.scheduler.add_job( self.execute_global_worker, trigger='interval', name=job_name, minutes=wkr.interval, start_date=start, args=[wkr], kwargs={} ) start += timedelta(seconds=30) # endregion # region AWS collectors aws_accounts = list(filter(lambda x: x.account_type == AWSAccount.account_type, accounts)) for acct in aws_accounts: if CollectorType.AWS_ACCOUNT in self.collectors: for wkr in self.collectors[CollectorType.AWS_ACCOUNT]: job_name = '{}_{}'.format(acct.account_name, wkr.name) new_jobs.append(job_name) if job_name in current_jobs: continue self.scheduler.add_job( self.execute_aws_account_worker, trigger='interval', name=job_name, minutes=wkr.interval, start_date=start, args=[wkr], kwargs={'account': acct.account_name} ) if CollectorType.AWS_REGION in self.collectors: for wkr in self.collectors[CollectorType.AWS_REGION]: for region in AWS_REGIONS: job_name = '{}_{}_{}'.format(acct.account_name, region, wkr.name) new_jobs.append(job_name) if job_name in current_jobs: continue self.scheduler.add_job( self.execute_aws_region_worker, trigger='interval', name=job_name, minutes=wkr.interval, start_date=start, args=[wkr], kwargs={'account': acct.account_name, 'region': region} ) db.session.commit() start += timedelta(seconds=self.dbconfig.get('worker_interval', self.ns, 30)) # endregion # region Auditors start = datetime.now() + timedelta(seconds=1) for wkr in self.auditors: job_name = 'auditor_{}'.format(wkr.name) new_jobs.append(job_name) if job_name in current_jobs: continue if app_config.log_level == 'DEBUG': audit_start = start + timedelta(seconds=5) else: audit_start = start + timedelta(minutes=5) self.scheduler.add_job( self.execute_auditor_worker, trigger='interval', name=job_name, minutes=wkr.interval, start_date=audit_start, args=[wkr], kwargs={} ) start += timedelta(seconds=self.dbconfig.get('worker_interval', self.ns, 30)) # endregion extra_jobs = list(set(current_jobs) - set(new_jobs)) for job in extra_jobs: self.log.warning('Removing job {} as it is no longer needed'.format(job)) current_jobs[job].remove() def execute_global_worker(self, data, **kwargs): try: cls = self.get_class_from_ep(data.entry_point) worker = cls(**kwargs) self.log.info('RUN_INFO: {} starting at {}, next run will be at approximately {}'.format(data.entry_point['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), (datetime.now() + timedelta(minutes=data.interval)).strftime("%Y-%m-%d %H:%M:%S"))) self.log.info('Starting global {} worker'.format(data.name)) worker.run() except Exception as ex: self.log.exception('Global Worker {}: {}'.format(data.name, ex)) finally: db.session.rollback() self.log.info('Completed run for global {} worker'.format(data.name)) def execute_aws_account_worker(self, data, **kwargs): try: cls = self.get_class_from_ep(data.entry_point) worker = cls(**kwargs) self.log.info('RUN_INFO: {} starting at {}, next run will be at approximately {}'.format(data.entry_point['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), (datetime.now() + timedelta(minutes=data.interval)).strftime("%Y-%m-%d %H:%M:%S"))) worker.run() except Exception as ex: self.log.exception('AWS Account Worker {}/{}: {}'.format(data.name, kwargs['account'], ex)) finally: db.session.rollback() self.log.info('Completed run for {} worker on {}'.format(data.name, kwargs['account'])) def execute_aws_region_worker(self, data, **kwargs): try: cls = self.get_class_from_ep(data.entry_point) worker = cls(**kwargs) self.log.info('RUN_INFO: {} starting at {} for account {} / region {}, next run will be at approximately {}'.format(data.entry_point['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), kwargs['account'], kwargs['region'], (datetime.now() + timedelta(minutes=data.interval)).strftime("%Y-%m-%d %H:%M:%S"))) worker.run() except Exception as ex: self.log.exception('AWS Region Worker {}/{}/{}: {}'.format( data.name, kwargs['account'], kwargs['region'], ex )) finally: db.session.rollback() self.log.info('Completed run for {} worker on {}/{}'.format( data.name, kwargs['account'], kwargs['region'] )) def execute_auditor_worker(self, data, **kwargs): try: cls = self.get_class_from_ep(data.entry_point) worker = cls(**kwargs) self.log.info('RUN_INFO: {} starting at {}, next run will be at approximately {}'.format(data.entry_point['module_name'], datetime.now().strftime("%Y-%m-%d %H:%M:%S"), (datetime.now() + timedelta(minutes=data.interval)).strftime("%Y-%m-%d %H:%M:%S"))) worker.run() except Exception as ex: self.log.exception('Auditor Worker {}: {}'.format(data.name, ex)) finally: db.session.rollback() self.log.info('Completed run for auditor {}'.format(data.name)) def cleanup(self): try: self.log.info('Running cleanup tasks') log_purge_date = datetime.now() - timedelta(days=self.dbconfig.get('log_keep_days', 'log', default=31)) db.LogEvent.find(LogEvent.timestamp < log_purge_date) db.session.commit() finally: db.session.rollback()