def run(self, event): """Run the alert processor! Args: event (dict): Lambda invocation event containing at least the rule name and alert ID. Returns: dict: Maps output (str) to whether it sent successfully (bool). An empty dict is returned if the Alert was improperly formatted. """ # Grab the alert record from Dynamo (if needed). if set(event) == {'AlertID', 'RuleName'}: LOGGER.info('Retrieving %s from alerts table', event) alert_record = self.alerts_table.get_alert_record(event['RuleName'], event['AlertID']) if not alert_record: LOGGER.error('%s does not exist in the alerts table', event) return {} else: alert_record = event # Convert record to an Alert instance. try: alert = Alert.create_from_dynamo_record(alert_record) except AlertCreationError: LOGGER.exception('Invalid alert %s', event) return {} # Remove normalization key from the record. # TODO: Consider including this in at least some outputs, e.g. default Athena firehose if NORMALIZATION_KEY in alert.record: del alert.record[NORMALIZATION_KEY] result = self._send_to_outputs(alert) self._update_table(alert, result) return result
def _establish_session(self, username, password): """Establish a cookie based Jira session via basic user auth. Args: username (str): The Jira username used for establishing the session password (str): The Jira password used for establishing the session Returns: str: Header value intended to be passed with every subsequent Jira request or False if unsuccessful """ login_url = os.path.join(self._base_url, self.LOGIN_ENDPOINT) auth_info = {'username': username, 'password': password} try: resp = self._post_request_retry(login_url, data=auth_info, headers=self._get_default_headers(), verify=False) except OutputRequestFailure: LOGGER.error("Failed to authenticate to Jira") return False resp_dict = resp.json() if not resp_dict: return False return '{}={}'.format(resp_dict['session']['name'], resp_dict['session']['value'])
def _create_dispatcher(self, output): """Create a dispatcher for the given output. Args: output (str): Alert output, e.g. "aws-sns:topic-name" Returns: OutputDispatcher: Based on the output type. Returns None if the output is invalid or not defined in the config. """ try: service, descriptor = output.split(':') except ValueError: LOGGER.error( 'Improperly formatted output [%s]. Outputs for rules must ' 'be declared with both a service and a descriptor for the ' 'integration (ie: \'slack:my_channel\')', output) return None if service not in self.config or descriptor not in self.config[service]: LOGGER.error('The output \'%s\' does not exist!', output) return None return StreamAlertOutput.create_dispatcher(service, self.region, self.account_id, self.prefix, self.config)
def dispatch(self, **kwargs): """Send alert to a Kinesis Firehose Delivery Stream Keyword Args: descriptor (str): Service descriptor (ie: slack channel, pd integration) rule_name (str): Name of the triggered rule alert (dict): Alert relevant to the triggered rule Returns: bool: Indicates a successful or failed dispatch of the alert """ @backoff.on_exception(backoff.fibo, ClientError, max_tries=self.MAX_BACKOFF_ATTEMPTS, jitter=backoff.full_jitter, on_backoff=backoff_handler, on_success=success_handler, on_giveup=giveup_handler) def _firehose_request_wrapper(json_alert, delivery_stream): """Make the PutRecord request to Kinesis Firehose with backoff Args: json_alert (str): The JSON dumped alert body delivery_stream (str): The Firehose Delivery Stream to send to Returns: dict: Firehose response in the format below {'RecordId': 'string'} """ return self.__aws_client__.put_record( DeliveryStreamName=delivery_stream, Record={'Data': json_alert}) if self.__aws_client__ is None: self.__aws_client__ = boto3.client('firehose', region_name=self.region) json_alert = json.dumps(kwargs['alert'], separators=(',', ':')) + '\n' if len(json_alert) > self.MAX_RECORD_SIZE: LOGGER.error('Alert too large to send to Firehose: \n%s...', json_alert[0:1000]) return False delivery_stream = self.config[self.__service__][kwargs['descriptor']] LOGGER.info('Sending alert [%s] to aws-firehose:%s', kwargs['rule_name'], delivery_stream) resp = _firehose_request_wrapper(json_alert, delivery_stream) if resp.get('RecordId'): LOGGER.info( 'Alert [%s] successfully sent to aws-firehose:%s with RecordId:%s', kwargs['rule_name'], delivery_stream, resp['RecordId']) return self._log_status(resp, kwargs['descriptor'])
def _log_status(cls, success): """Log the status of sending the alerts Args: success (bool or dict): Indicates if the dispatching of alerts was successful """ if success: LOGGER.info('Successfully sent alert to %s', cls.__service__) else: LOGGER.error('Failed to send alert to %s', cls.__service__) return bool(success)
def _log_status(cls, success, descriptor): """Log the status of sending the alerts Args: success (bool or dict): Indicates if the dispatching of alerts was successful descriptor (str): Service descriptor """ if success: LOGGER.info('Successfully sent alert to %s:%s', cls.__service__, descriptor) else: LOGGER.error('Failed to send alert to %s:%s', cls.__service__, descriptor)
def get_dispatcher(cls, service): """Returns the subclass that should handle this particular service Args: service (str): The service identifier for this output Returns: OutputDispatcher: Subclass of OutputDispatcher to use for sending alerts """ try: return cls._outputs[service] except KeyError: LOGGER.error('Designated output service [%s] does not exist', service)
def dispatch(self, **kwargs): """Send alert to Jira Args: **kwargs: consists of any combination of the following items: descriptor (str): Service descriptor (ie: slack channel, pd integration) rule_name (str): Name of the triggered rule alert (dict): Alert relevant to the triggered rule """ creds = self._load_creds(kwargs['descriptor']) if not creds: return self._log_status(False, kwargs['descriptor']) issue_id = None comment_id = None issue_summary = 'StreamAlert {}'.format(kwargs['rule_name']) alert_body = '{{code:JSON}}{}{{code}}'.format( json.dumps(kwargs['alert'])) self._base_url = creds['url'] self._auth_cookie = self._establish_session(creds['username'], creds['password']) # Validate successful authentication if not self._auth_cookie: return self._log_status(False, kwargs['descriptor']) # If aggregation is enabled, attempt to add alert to an existing issue. If a # failure occurs in this block, creation of a new Jira issue will be attempted. if creds.get('aggregate', '').lower() == 'yes': issue_id = self._get_existing_issue(issue_summary, creds['project_key']) if issue_id: comment_id = self._create_comment(issue_id, alert_body) if comment_id: LOGGER.debug( 'Sending alert to an existing Jira issue %s with comment %s', issue_id, comment_id) return self._log_status(True, kwargs['descriptor']) else: LOGGER.error( 'Encountered an error when adding alert to existing ' 'Jira issue %s. Attempting to create new Jira issue.', issue_id) # Create a new Jira issue issue_id = self._create_issue(issue_summary, creds['project_key'], creds['issue_type'], alert_body) if issue_id: LOGGER.debug('Sending alert to a new Jira issue %s', issue_id) return self._log_status(issue_id or comment_id, kwargs['descriptor'])
def dispatch(self, alert, descriptor): """Send alert to Jira Args: alert (Alert): Alert instance which triggered a rule descriptor (str): Output descriptor Returns: bool: True if alert was sent successfully, False otherwise """ creds = self._load_creds(descriptor) if not creds: return self._log_status(False, descriptor) issue_id = None comment_id = None issue_summary = 'StreamAlert {}'.format(alert.rule_name) alert_body = '{{code:JSON}}{}{{code}}'.format( json.dumps(alert.output_dict(), sort_keys=True)) self._base_url = creds['url'] self._auth_cookie = self._establish_session(creds['username'], creds['password']) # Validate successful authentication if not self._auth_cookie: return self._log_status(False, descriptor) # If aggregation is enabled, attempt to add alert to an existing issue. If a # failure occurs in this block, creation of a new Jira issue will be attempted. if creds.get('aggregate', '').lower() == 'yes': issue_id = self._get_existing_issue(issue_summary, creds['project_key']) if issue_id: comment_id = self._create_comment(issue_id, alert_body) if comment_id: LOGGER.debug('Sending alert to an existing Jira issue %s with comment %s', issue_id, comment_id) return self._log_status(True, descriptor) else: LOGGER.error('Encountered an error when adding alert to existing ' 'Jira issue %s. Attempting to create new Jira issue.', issue_id) # Create a new Jira issue issue_id = self._create_issue(issue_summary, creds['project_key'], creds['issue_type'], alert_body) if issue_id: LOGGER.debug('Sending alert to a new Jira issue %s', issue_id) return self._log_status(issue_id or comment_id, descriptor)
def _dispatch(self, alert, descriptor): """Send alert to a Kinesis Firehose Delivery Stream Args: alert (Alert): Alert instance which triggered a rule descriptor (str): Output descriptor Returns: bool: True if alert was sent successfully, False otherwise """ @backoff.on_exception(backoff.fibo, ClientError, max_tries=self.MAX_BACKOFF_ATTEMPTS, jitter=backoff.full_jitter, on_backoff=backoff_handler(), on_success=success_handler(), on_giveup=giveup_handler()) def _firehose_request_wrapper(json_alert, delivery_stream): """Make the PutRecord request to Kinesis Firehose with backoff Args: json_alert (str): The JSON dumped alert body delivery_stream (str): The Firehose Delivery Stream to send to Returns: dict: Firehose response in the format below {'RecordId': 'string'} """ self.__aws_client__.put_record(DeliveryStreamName=delivery_stream, Record={'Data': json_alert}) if self.__aws_client__ is None: self.__aws_client__ = boto3.client('firehose', region_name=self.region) json_alert = json.dumps(alert.output_dict(), separators=(',', ':')) + '\n' if len(json_alert) > self.MAX_RECORD_SIZE: LOGGER.error('Alert too large to send to Firehose: \n%s...', json_alert[0:1000]) return False delivery_stream = self.config[self.__service__][descriptor] LOGGER.info('Sending %s to aws-firehose:%s', alert, delivery_stream) _firehose_request_wrapper(json_alert, delivery_stream) LOGGER.info('%s successfully sent to aws-firehose:%s', alert, delivery_stream) return True
def _check_http_response(cls, response): """Method for checking for a valid HTTP response code Args: response (requests.Response): Response object from requests Returns: bool: Indicator of whether or not this request was successful """ success = response is not None and (200 <= response.status_code <= 299) if not success: LOGGER.error('Encountered an error while sending to %s:\n%s', cls.__service__, response.content) return success
def _load_output_config(config_path='conf/outputs.json'): """Load the outputs configuration file from disk Returns: dict: the output configuration settings """ with open(config_path) as outputs: try: config = json.load(outputs) except ValueError: LOGGER.error('The \'%s\' file could not be loaded into json', config_path) return return config
def _kms_decrypt(self, data): """Decrypt data with AWS KMS. Args: data (str): An encrypted ciphertext data blob Returns: str: Decrypted json string """ try: client = boto3.client('kms', region_name=self.region) response = client.decrypt(CiphertextBlob=data) return response['Plaintext'] except ClientError as err: LOGGER.error('an error occurred during credentials decryption: %s', err.response)
def _dispatch(self, alert, descriptor): """Send ban hash command to CarbonBlack Args: alert (Alert): Alert instance which triggered a rule descriptor (str): Output descriptor Returns: bool: True if alert was sent successfully, False otherwise """ if not alert.context: LOGGER.error('[%s] Alert must contain context to run actions', self.__service__) return False creds = self._load_creds(descriptor) if not creds: return False client = CbResponseAPI(**creds) # Get md5 hash 'value' from streamalert's rule processor action = alert.context.get('carbonblack', {}).get('action') if action == 'ban': binary_hash = alert.context.get('carbonblack', {}).get('value') # The binary should already exist in CarbonBlack binary = client.select(Binary, binary_hash) # Determine if the binary is currenty listed as banned if binary.banned: # Determine if the banned action is enabled, if true exit if binary.banned.enabled: return True # If the binary is banned and disabled, begin the banning hash operation banned_hash = client.select(BannedHash, binary_hash) banned_hash.enabled = True banned_hash.save() else: # Create a new BannedHash object to be saved banned_hash = client.create(BannedHash) # Begin the banning hash operation banned_hash.md5hash = binary.md5 banned_hash.text = "Banned from StreamAlert" banned_hash.enabled = True banned_hash.save() return banned_hash.enabled is True else: LOGGER.error('[%s] Action not supported: %s', self.__service__, action) return False
def run(alert, region, function_name, config): """Send an Alert to its described outputs. Args: alert (dict): dictionary representating an alert with the following structure: { 'record': record, 'rule_name': rule.rule_name, 'rule_description': rule.rule_function.__doc__, 'log_source': str(payload.log_source), 'log_type': payload.type, 'outputs': rule.outputs, 'source_service': payload.service, 'source_entity': payload.entity } region (str): The AWS region of the currently executing Lambda function function_name (str): The name of the lambda function config (dict): The loaded configuration for outputs from conf/outputs.json Yields: (bool, str): Dispatch status and name of the output to the handler """ if not validate_alert(alert): LOGGER.error('Invalid alert format:\n%s', json.dumps(alert, indent=2)) return LOGGER.debug('Sending alert to outputs:\n%s', json.dumps(alert, indent=2)) # strip out unnecessary keys and sort alert = _sort_dict(alert) outputs = alert['outputs'] # Get the output configuration for this rule and send the alert to each for output in set(outputs): try: service, descriptor = output.split(':') except ValueError: LOGGER.error( 'Improperly formatted output [%s]. Outputs for rules must ' 'be declared with both a service and a descriptor for the ' 'integration (ie: \'slack:my_channel\')', output) continue if service not in config or descriptor not in config[service]: LOGGER.error('The output \'%s\' does not exist!', output) continue # Retrieve the proper class to handle dispatching the alerts of this services output_dispatcher = get_output_dispatcher(service, region, function_name, config) if not output_dispatcher: continue LOGGER.debug('Sending alert to %s:%s', service, descriptor) sent = False try: sent = output_dispatcher.dispatch(descriptor=descriptor, rule_name=alert['rule_name'], alert=alert) except Exception as err: # pylint: disable=broad-except LOGGER.exception( 'An error occurred while sending alert ' 'to %s:%s: %s. alert:\n%s', service, descriptor, err, json.dumps(alert, indent=2)) # Yield back the result to the handler yield sent, output
def _dispatch(self, alert, descriptor): """Send incident to Pagerduty Incidents API v2 Args: alert (Alert): Alert instance which triggered a rule descriptor (str): Output descriptor Returns: bool: True if alert was sent successfully, False otherwise """ creds = self._load_creds(descriptor) if not creds: return False # Cache base_url self._base_url = creds['api'] # Preparing headers for API calls self._headers = { 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', 'Authorization': 'Token token={}'.format(creds['token']) } # Get user email to be added as From header and verify user_email = creds['email_from'] if not self._user_verify(user_email, False): LOGGER.error('Could not verify header From: %s, %s', user_email, self.__service__) return False # Add From to the headers after verifying self._headers['From'] = user_email # Cache default escalation policy self._escalation_policy_id = creds['escalation_policy_id'] # Extracting context data to assign the incident rule_context = alert.context if rule_context: rule_context = rule_context.get(self.__service__, {}) # Use the priority provided in the context, use it or the incident will be low priority incident_priority = self._priority_verify(rule_context) # Incident assignment goes in this order: # Provided user -> provided policy -> default policy assigned_key, assigned_value = self._incident_assignment(rule_context) # Start preparing the incident JSON blob to be sent to the API incident_title = 'StreamAlert Incident - Rule triggered: {}'.format( alert.rule_name) incident_body = { 'type': 'incident_body', 'details': alert.rule_description } # Using the service ID for the PagerDuty API incident_service = { 'id': creds['service_id'], 'type': 'service_reference' } incident_data = { 'incident': { 'type': 'incident', 'title': incident_title, 'service': incident_service, 'priority': incident_priority, 'body': incident_body, assigned_key: assigned_value } } incidents_url = self._get_endpoint(self._base_url, self.INCIDENTS_ENDPOINT) try: incident = self._post_request_retry(incidents_url, incident_data, self._headers, True) except OutputRequestFailure: incident = False if not incident: LOGGER.error('Could not create main incident, %s', self.__service__) return False # Extract the json blob from the response, returned by self._post_request_retry incident_json = incident.json() if not incident_json: return False # Extract the incident id from the incident that was just created incident_id = incident_json.get('incident', {}).get('id') # Create alert to hold all the incident details with_record = rule_context.get('with_record', True) event_data = events_v2_data(alert, creds['integration_key'], with_record) event = self._create_event(event_data) if not event: LOGGER.error('Could not create incident event, %s', self.__service__) return False # Lookup the incident_key returned as dedup_key to get the incident id incident_key = event.get('dedup_key') if not incident_key: LOGGER.error('Could not get incident key, %s', self.__service__) return False # Keep that id to be merged later with the created incident event_incident_id = self._get_event_incident_id(incident_key) # Merge the incident with the event, so we can have a rich context incident # assigned to a specific person, which the PagerDuty REST API v2 does not allow merging_url = '{}/{}/merge'.format(incidents_url, incident_id) merged = self._merge_incidents(merging_url, event_incident_id) # Add a note to the combined incident to help with triage if not merged: LOGGER.error('Could not add note to incident, %s', self.__service__) else: merged_id = merged.get('incident', {}).get('id') note = rule_context.get('note', 'Creating SOX Incident') self._add_incident_note(merged_id, note) return True
def validate_alert(alert): """Helper function to perform simple validation of an alert's keys and structure Args: alert (dict): the alert record to test that should be in the form of a dict Returns: bool: whether or not the alert has the proper structure """ if not isinstance(alert, dict): LOGGER.error('The alert must be a map (dict)') return False alert_keys = { 'record', 'rule_name', 'rule_description', 'log_type', 'log_source', 'outputs', 'source_service', 'source_entity', 'context' } if not set(alert.keys()) == alert_keys: LOGGER.error('The alert object must contain the following keys: %s', ', '.join(alert_keys)) return False valid = True for key in alert_keys: if key == 'record': if not isinstance(alert['record'], dict): LOGGER.error('The alert record must be a map (dict)') return False elif key == 'context': if not isinstance(alert['context'], dict): LOGGER.error('The alert context must be a map (dict)') return False elif key == 'outputs': if not isinstance(alert[key], list): LOGGER.error( 'The value of the \'outputs\' key must be an array (list) that ' 'contains at least one configured output.') valid = False continue for entry in alert[key]: if not isinstance(entry, (str, unicode)): LOGGER.error( 'The value of each entry in the \'outputs\' list ' 'must be a string (str).') valid = False elif not isinstance(alert[key], (str, unicode)): LOGGER.error( 'The value of the \'%s\' key must be a string (str), not %s', key, type(alert[key])) valid = False return valid
def dispatch(self, **kwargs): """Send incident to Pagerduty Incidents API v2 Keyword Args: **kwargs: consists of any combination of the following items: descriptor (str): Service descriptor (ie: slack channel, pd integration) rule_name (str): Name of the triggered rule alert (dict): Alert relevant to the triggered rule alert['context'] (dict): Provides user or escalation policy """ creds = self._load_creds(kwargs['descriptor']) if not creds: return self._log_status(False, kwargs['descriptor']) # Cache base_url self._base_url = creds['api'] # Preparing headers for API calls self._headers = { 'Accept': 'application/vnd.pagerduty+json;version=2', 'Content-Type': 'application/json', 'Authorization': 'Token token={}'.format(creds['token']) } # Get user email to be added as From header and verify user_email = creds['email_from'] if not self._user_verify(user_email, False): LOGGER.error('Could not verify header From: %s, %s', user_email, self.__service__) return self._log_status(False, kwargs['descriptor']) # Add From to the headers after verifying self._headers['From'] = user_email # Cache default escalation policy self._escalation_policy = creds['escalation_policy'] # Extracting context data to assign the incident rule_context = kwargs['alert'].get('context', {}) if rule_context: rule_context = rule_context.get(self.__service__, {}) # Use the priority provided in the context, use it or the incident will be low priority incident_priority = self._priority_verify(rule_context) # Incident assignment goes in this order: # Provided user -> provided policy -> default policy assigned_key, assigned_value = self._incident_assignment(rule_context) # Start preparing the incident JSON blob to be sent to the API incident_title = 'StreamAlert Incident - Rule triggered: {}'.format(kwargs['rule_name']) incident_body = { 'type': 'incident_body', 'details': kwargs['alert']['rule_description'] } # We need to get the service id from the API incident_service = self._service_verify(creds['service_name']) incident_data = { 'incident': { 'type': 'incident', 'title': incident_title, 'service': incident_service, 'priority': incident_priority, 'body': incident_body, assigned_key: assigned_value } } incidents_url = self._get_endpoint(self._base_url, self.INCIDENTS_ENDPOINT) try: incident = self._post_request_retry(incidents_url, incident_data, self._headers, True) except OutputRequestFailure: incident = False if not incident: LOGGER.error('Could not create main incident, %s', self.__service__) return self._log_status(False, kwargs['descriptor']) # Extract the json blob from the response, returned by self._post_request_retry incident_json = incident.json() if not incident_json: return self._log_status(False, kwargs['descriptor']) # Extract the incident id from the incident that was just created incident_id = incident_json.get('incident', {}).get('id') # Create alert to hold all the incident details with_record = rule_context.get('with_record', True) event_data = events_v2_data(creds['integration_key'], with_record, **kwargs) event = self._create_event(event_data) if not event: LOGGER.error('Could not create incident event, %s', self.__service__) return self._log_status(False, kwargs['descriptor']) # Lookup the incident_key returned as dedup_key to get the incident id incident_key = event.get('dedup_key') if not incident_key: LOGGER.error('Could not get incident key, %s', self.__service__) return self._log_status(False, kwargs['descriptor']) # Keep that id to be merged later with the created incident event_incident_id = self._get_event_incident_id(incident_key) # Merge the incident with the event, so we can have a rich context incident # assigned to a specific person, which the PagerDuty REST API v2 does not allow merging_url = '{}/{}/merge'.format(incidents_url, incident_id) merged = self._merge_incidents(merging_url, event_incident_id) # Add a note to the combined incident to help with triage if not merged: LOGGER.error('Could not add note to incident, %s', self.__service__) else: merged_id = merged.get('incident', {}).get('id') note = rule_context.get('note', 'Creating SOX Incident') self._add_incident_note(merged_id, note) return self._log_status(incident_id, kwargs['descriptor'])
def dispatch(self, **kwargs): """Send incident to Pagerduty Incidents API v2 Keyword Args: **kwargs: consists of any combination of the following items: descriptor (str): Service descriptor (ie: slack channel, pd integration) rule_name (str): Name of the triggered rule alert (dict): Alert relevant to the triggered rule alert['context'] (dict): Provides user or escalation policy """ creds = self._load_creds(kwargs['descriptor']) if not creds: return self._log_status(False) # Cache base_url self._base_url = creds['api'] # Preparing headers for API calls self._headers = { 'Authorization': 'Token token={}'.format(creds['token']), 'Accept': 'application/vnd.pagerduty+json;version=2' } # Get user email to be added as From header and verify user_email = creds['email_from'] if not self._user_verify(user_email, False): LOGGER.error('Could not verify header From: %s, %s', user_email, self.__service__) return self._log_status(False) # Add From to the headers after verifying self._headers['From'] = user_email # Cache default escalation policy self._escalation_policy = creds['escalation_policy'] # Extracting context data to assign the incident rule_context = kwargs['alert'].get('context', {}) if rule_context: rule_context = rule_context.get(self.__service__, {}) # Use the priority provided in the context, use it or the incident will be low priority incident_priority = self._priority_verify(rule_context) # Incident assignment goes in this order: # Provided user -> provided policy -> default policy assigned_key, assigned_value = self._incident_assignment(rule_context) # Start preparing the incident JSON blob to be sent to the API incident_title = 'StreamAlert Incident - Rule triggered: {}'.format( kwargs['rule_name']) incident_body = { 'type': 'incident_body', 'details': kwargs['alert']['rule_description'] } # We need to get the service id from the API incident_service = self._service_verify(creds['service_name']) incident = { 'incident': { 'type': 'incident', 'title': incident_title, 'service': incident_service, 'priority': incident_priority, 'body': incident_body, assigned_key: assigned_value } } incidents_url = self._get_endpoint(self._base_url, self.INCIDENTS_ENDPOINT) try: success = self._post_request_retry(incidents_url, incident, self._headers, True) except OutputRequestFailure: success = False return self._log_status(success)