From a04cce6044360f4bc0987259d4f34d2823c027f0 Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Fri, 16 Oct 2020 10:40:11 -0700 Subject: [PATCH 1/7] Initial implementation --- docs/administration.rst | 17 +- docs/developer/plugins/index.rst | 13 +- lemur/notifications/messaging.py | 162 ++++++++---------- lemur/plugins/bases/notification.py | 11 +- lemur/plugins/lemur_aws/plugin.py | 59 ++++++- lemur/plugins/lemur_aws/sns.py | 56 ++++++ lemur/plugins/lemur_aws/tests/test_sns.py | 50 ++++++ lemur/plugins/lemur_email/plugin.py | 24 ++- lemur/plugins/lemur_email/tests/test_email.py | 12 ++ lemur/plugins/lemur_slack/plugin.py | 5 +- lemur/tests/plugins/notification_plugin.py | 1 + lemur/tests/test_messaging.py | 23 ++- setup.py | 1 + 13 files changed, 330 insertions(+), 104 deletions(-) create mode 100644 lemur/plugins/lemur_aws/sns.py create mode 100644 lemur/plugins/lemur_aws/tests/test_sns.py diff --git a/docs/administration.rst b/docs/administration.rst index 00da0c8a..cdbf0037 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -1441,7 +1441,7 @@ Slack Adds support for slack notifications. -AWS +AWS (Source) ---- :Authors: @@ -1454,7 +1454,7 @@ AWS Uses AWS IAM as a source of certificates to manage. Supports a multi-account deployment. -AWS +AWS (Destination) ---- :Authors: @@ -1467,6 +1467,19 @@ AWS Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment. +AWS (Notification) +----- + +:Authors: + Jasmine Schladen +:Type: + Notification +:Description: + Adds support for SNS notifications. SNS notifications (like other notification plugins) are currently only supported + for certificate expiration. Configuration requires a region, account number, and SNS topic name; these elements + are then combined to build the topic ARN. Lemur must have access to publish messages to the specified SNS topic. + + Kubernetes ---------- diff --git a/docs/developer/plugins/index.rst b/docs/developer/plugins/index.rst index 8af5e1c8..0223d9ca 100644 --- a/docs/developer/plugins/index.rst +++ b/docs/developer/plugins/index.rst @@ -215,18 +215,21 @@ Notification ------------ Lemur includes the ability to create Email notifications by **default**. These notifications -currently come in the form of expiration notices. Lemur periodically checks certifications expiration dates and +currently come in the form of expiration and rotation notices. Lemur periodically checks certifications expiration dates and determines if a given certificate is eligible for notification. There are currently only two parameters used to determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number of days the current date (UTC) is from that expiration date. -There are currently two objects that available for notification plugins the first is `NotficationPlugin`. This is the base object for -any notification within Lemur. Currently the only support notification type is an certificate expiration notification. If you +Expiration notifications can also be configured for Slack or AWS SNS. Rotation notifications are not configurable. +Notifications sent to a certificate owner and security team (`LEMUR_SECURITY_TEAM_EMAIL`) can currently only be sent via email. + +There are currently two objects that available for notification plugins. The first is `NotificationPlugin`, which is the base object for +any notification within Lemur. Currently the only supported notification type is an certificate expiration notification. If you are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on. You would also then need to build additional code to trigger the new notification type. -The second is `ExpirationNotificationPlugin`, this object inherits from `NotificationPlugin` object. -You will most likely want to base your plugin on, if you want to add new channels for expiration notices (Slack, HipChat, Jira, etc.). It adds default options that are required by +The second is `ExpirationNotificationPlugin`, which inherits from `NotificationPlugin` object. +You will most likely want to base your plugin on this object if you want to add new channels for expiration notices (HipChat, Jira, etc.). It adds default options that are required by all expiration notifications (interval, unit). This interface expects for the child to define the following function:: def send(self, notification_type, message, targets, options, **kwargs): diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index 82db7b6e..6bcf6bd3 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -29,13 +29,15 @@ from lemur.plugins.utils import get_plugin_option def get_certificates(exclude=None): """ - Finds all certificates that are eligible for notifications. + Finds all certificates that are eligible for expiration notifications. :param exclude: :return: """ now = arrow.utcnow() max = now + timedelta(days=90) + print("ALPACA: Checking for certs not after {0} with notify enabled and not expired".format(max)) + q = ( database.db.session.query(Certificate) .filter(Certificate.not_after <= max) @@ -43,6 +45,8 @@ def get_certificates(exclude=None): .filter(Certificate.expired == False) ) # noqa + print("ALPACA: Excluding {0}".format(exclude)) + exclude_conditions = [] if exclude: for e in exclude: @@ -56,51 +60,64 @@ def get_certificates(exclude=None): if needs_notification(c): certs.append(c) + print("ALPACA: Found {0} eligible certs".format(len(certs))) + return certs def get_eligible_certificates(exclude=None): """ - Finds all certificates that are eligible for certificate expiration. + Finds all certificates that are eligible for certificate expiration notification. + Returns the set of all eligible certificates, grouped by owner, with a list of applicable notifications. :param exclude: :return: """ certificates = defaultdict(dict) certs = get_certificates(exclude=exclude) + print("ALPACA: Found {0} certificates to check for notifications".format(len(certs))) + # group by owner for owner, items in groupby(certs, lambda x: x.owner): notification_groups = [] for certificate in items: notifications = needs_notification(certificate) + print("ALPACA: Considering sending {0} notifications for cert {1}".format(len(notifications), certificate)) if notifications: for notification in notifications: + print("ALPACA: Will send notification {0} for certificate {1}".format(notification, certificate)) notification_groups.append((notification, certificate)) # group by notification for notification, items in groupby(notification_groups, lambda x: x[0].label): certificates[owner][notification] = list(items) + print("ALPACA: Certificates that need notifications: {0}".format(certificates)) + return certificates -def send_notification(event_type, data, targets, notification): +def send_plugin_notification(event_type, data, recipients, notification): """ Executes the plugin and handles failure. :param event_type: :param data: - :param targets: + :param recipients: :param notification: :return: """ status = FAILURE_METRIC_STATUS try: - notification.plugin.send(event_type, data, targets, notification.options) + print("ALPACA: Trying to send notification {0} (plugin: {1})".format(notification, notification.plugin)) + notification.plugin.send(event_type, data, recipients, notification.options) status = SUCCESS_METRIC_STATUS except Exception as e: + current_app.logger.error( + "Unable to send notification {}.".format(notification), exc_info=True + ) sentry.captureException() metrics.send( @@ -140,36 +157,31 @@ def send_expiration_notifications(exclude): notification_data.append(cert_data) security_data.append(cert_data) - if send_notification( - "expiration", notification_data, [owner], notification + print("ALPACA: Sending owner notification to {0} for certificate {1}. Data: {2}".format(owner, certificates, notification_data)) + + if send_default_notification( + "expiration", notification_data, [owner], notification.options ): success += 1 else: failure += 1 - notification_recipient = get_plugin_option( - "recipients", notification.options - ) - if notification_recipient: - notification_recipient = notification_recipient.split(",") - # removing owner and security_email from notification_recipient - notification_recipient = [i for i in notification_recipient if i not in security_email and i != owner] + recipients = notification.plugin.filter_recipients(security_email + [owner], notification.options) - if ( - notification_recipient + print("ALPACA: Sending plugin notification {0} for certificate {1} to recipients {2}".format(notification, certificates, recipients)) + if send_plugin_notification( + "expiration", + notification_data, + recipients, + notification, ): - if send_notification( - "expiration", - notification_data, - notification_recipient, - notification, - ): - success += 1 - else: - failure += 1 + success += 1 + else: + failure += 1 - if send_notification( - "expiration", security_data, security_email, notification + print("ALPACA: Sending security notification to {0}".format(security_email)) + if send_default_notification( + "expiration", security_data, security_email, notification.options ): success += 1 else: @@ -178,29 +190,29 @@ def send_expiration_notifications(exclude): return success, failure -def send_rotation_notification(certificate, notification_plugin=None): +def send_default_notification(notification_type, data, targets, notification_options=None): """ - Sends a report to certificate owners when their certificate has been - rotated. + Sends a report to the specified target via the default notification plugin. Applicable for any notification_type. + At present, "default" means email, as the other notification plugins do not support dynamically configured targets. - :param certificate: - :param notification_plugin: + :param notification_type: + :param data: + :param targets: + :param notification_options: :return: """ status = FAILURE_METRIC_STATUS - if not notification_plugin: - notification_plugin = plugins.get( - current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN") - ) - - data = certificate_notification_output_schema.dump(certificate).data + notification_plugin = plugins.get( + current_app.config.get("LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification") + ) try: - notification_plugin.send("rotation", data, [data["owner"]]) + # we need the notification.options here because the email templates utilize the interval/unit info + notification_plugin.send(notification_type, data, targets, notification_options) status = SUCCESS_METRIC_STATUS except Exception as e: current_app.logger.error( - "Unable to send notification to {}.".format(data["owner"]), exc_info=True + "Unable to send notification to {}.".format(targets), exc_info=True ) sentry.captureException() @@ -208,77 +220,49 @@ def send_rotation_notification(certificate, notification_plugin=None): "notification", "counter", 1, - metric_tags={"status": status, "event_type": "rotation"}, + metric_tags={"status": status, "event_type": notification_type}, ) if status == SUCCESS_METRIC_STATUS: return True +def send_rotation_notification(certificate): + data = certificate_notification_output_schema.dump(certificate).data + return send_default_notification("rotation", data, [data["owner"]]) + + def send_pending_failure_notification( - pending_cert, notify_owner=True, notify_security=True, notification_plugin=None + pending_cert, notify_owner=True, notify_security=True ): """ Sends a report to certificate owners when their pending certificate failed to be created. :param pending_cert: - :param notification_plugin: + :param notify_owner: + :param notify_security: :return: """ - status = FAILURE_METRIC_STATUS - - if not notification_plugin: - notification_plugin = plugins.get( - current_app.config.get( - "LEMUR_DEFAULT_NOTIFICATION_PLUGIN", "email-notification" - ) - ) data = pending_certificate_output_schema.dump(pending_cert).data data["security_email"] = current_app.config.get("LEMUR_SECURITY_TEAM_EMAIL") + notify_owner_success = False if notify_owner: - try: - notification_plugin.send("failed", data, [data["owner"]], pending_cert) - status = SUCCESS_METRIC_STATUS - except Exception as e: - current_app.logger.error( - "Unable to send pending failure notification to {}.".format( - data["owner"] - ), - exc_info=True, - ) - sentry.captureException() + notify_owner_success = send_default_notification("failed", data, [data["owner"]], pending_cert) + notify_security_success = False if notify_security: - try: - notification_plugin.send( - "failed", data, data["security_email"], pending_cert - ) - status = SUCCESS_METRIC_STATUS - except Exception as e: - current_app.logger.error( - "Unable to send pending failure notification to " - "{}.".format(data["security_email"]), - exc_info=True, - ) - sentry.captureException() + notify_security_success = send_default_notification("failed", data, data["security_email"], pending_cert) - metrics.send( - "notification", - "counter", - 1, - metric_tags={"status": status, "event_type": "rotation"}, - ) - - if status == SUCCESS_METRIC_STATUS: - return True + return notify_owner_success or notify_security_success def needs_notification(certificate): """ - Determine if notifications for a given certificate should - currently be sent + Determine if notifications for a given certificate should currently be sent. + For each notification configured for the cert, verifies it is active, properly configured, + and that the configured expiration period is currently met. :param certificate: :return: @@ -288,9 +272,13 @@ def needs_notification(certificate): notifications = [] + print("ALPACA: Considering if cert {0} needs notifications".format(certificate)) + print("ALPACA: Notifications for {0}: {1}".format(certificate, certificate.notifications)) + for notification in certificate.notifications: + print("ALPACA: Considering if cert {0} needs notification {1}".format(certificate, notification)) if not notification.active or not notification.options: - return + continue interval = get_plugin_option("interval", notification.options) unit = get_plugin_option("unit", notification.options) @@ -309,6 +297,8 @@ def needs_notification(certificate): "Invalid base unit for expiration interval: {0}".format(unit) ) + print("ALPACA: Considering if cert {0} is applicable for notification {1}: {2} days remaining, configured as " + "{3} days".format(certificate, notification, days, interval)) if days == interval: notifications.append(notification) return notifications diff --git a/lemur/plugins/bases/notification.py b/lemur/plugins/bases/notification.py index 730f68be..0da0dad2 100644 --- a/lemur/plugins/bases/notification.py +++ b/lemur/plugins/bases/notification.py @@ -20,6 +20,15 @@ class NotificationPlugin(Plugin): def send(self, notification_type, message, targets, options, **kwargs): raise NotImplementedError + def filter_recipients(self, options, excluded_recipients): + """ + Given a set of options (which should include configured recipient info), filters out recipients that + we do NOT want to notify. + + For any notification types where recipients can't be dynamically modified, this returns an empty list. + """ + return [] + class ExpirationNotificationPlugin(NotificationPlugin): """ @@ -50,5 +59,5 @@ class ExpirationNotificationPlugin(NotificationPlugin): def options(self): return self.default_options + self.additional_options - def send(self, notification_type, message, targets, options, **kwargs): + def send(self, notification_type, message, excluded_targets, options, **kwargs): raise NotImplementedError diff --git a/lemur/plugins/lemur_aws/plugin.py b/lemur/plugins/lemur_aws/plugin.py index 8692348a..bd18fe52 100644 --- a/lemur/plugins/lemur_aws/plugin.py +++ b/lemur/plugins/lemur_aws/plugin.py @@ -32,13 +32,14 @@ .. moduleauthor:: Mikhail Khodorovskiy .. moduleauthor:: Harm Weites """ + from acme.errors import ClientError from flask import current_app -from lemur.extensions import sentry, metrics -from lemur.plugins import lemur_aws as aws +from lemur.extensions import sentry, metrics +from lemur.plugins import lemur_aws as aws, ExpirationNotificationPlugin from lemur.plugins.bases import DestinationPlugin, ExportDestinationPlugin, SourcePlugin -from lemur.plugins.lemur_aws import iam, s3, elb, ec2 +from lemur.plugins.lemur_aws import iam, s3, elb, ec2, sns def get_region_from_dns(dns): @@ -406,3 +407,55 @@ class S3DestinationPlugin(ExportDestinationPlugin): self.get_option("encrypt", options), account_number=self.get_option("accountNumber", options), ) + + +class SNSNotificationPlugin(ExpirationNotificationPlugin): + title = "AWS SNS" + slug = "aws-sns" + description = "Sends notifications to AWS SNS" + version = aws.VERSION + + author = "Jasmine Schladen " + author_url = "https://github.com/Netflix/lemur" + + additional_options = [ + { + "name": "accountNumber", + "type": "str", + "required": True, + "validation": "[0-9]{12}", + "helpMessage": "A valid AWS account number with permission to access the SNS topic", + }, + { + "name": "region", + "type": "str", + "required": True, + "validation": "[0-9a-z\\-]{1,25}", + "helpMessage": "Region in which the SNS topic is located, e.g. \"us-east-1\"", + }, + { + "name": "Topic Name", + "type": "str", + "required": True, + # base topic name is 1-256 characters (alphanumeric plus underscore and hyphen) + "validation": "^[a-zA-Z0-9_\\-]{1,256}$", + "helpMessage": "The name of the topic to use for expiration notifications", + } + ] + + def send(self, notification_type, message, excluded_targets, options, **kwargs): + """ + While we receive a `targets` parameter here, it is unused, as the SNS topic is pre-configured in the + plugin configuration, and can't reasonably be changed dynamically. + """ + + topic_arn = "arn:aws:sns:{0}:{1}:{2}".format(self.get_option("region", options), + self.get_option("accountNumber", options), + self.get_option("Topic Name", options)) + + current_app.logger.debug("Publishing {0} notification to topic {1}".format(notification_type, topic_arn)) + print("ALPACA: Trying to send {0} SNS notification to topic {1}".format(notification_type, topic_arn)) + try: + sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options)) + except Exception: + current_app.logger.exception("Error publishing {0} notification to topic {1}".format(notification_type, topic_arn)) \ No newline at end of file diff --git a/lemur/plugins/lemur_aws/sns.py b/lemur/plugins/lemur_aws/sns.py new file mode 100644 index 00000000..6e264acb --- /dev/null +++ b/lemur/plugins/lemur_aws/sns.py @@ -0,0 +1,56 @@ +""" +.. module: lemur.plugins.lemur_aws.sts + :platform: Unix + :copyright: (c) 2020 by Netflix Inc., see AUTHORS for more + :license: Apache, see LICENSE for more details. +.. moduleauthor:: Kevin Glisson +""" +import json + +import arrow +import boto3 +from flask import current_app + + +def publish(topic_arn, certificates, notification_type, **kwargs): + sns_client = boto3.client("sns", **kwargs) + print("ALPACA: SNS client: {0}, certificates: {1}".format(sns_client, certificates)) + message_ids = {} + for certificate in certificates: + message_ids[certificate["name"]] = publish_single(sns_client, topic_arn, certificate, notification_type) + + return message_ids + + +def publish_single(sns_client, topic_arn, certificate, notification_type): + response = sns_client.publish( + TopicArn=topic_arn, + Message=format_message(certificate, notification_type), + ) + + response_code = response["ResponseMetadata"]["HTTPStatusCode"] + if response_code != 200: + raise Exception("Failed to publish notification to SNS, response code was {}".format(response_code)) + + current_app.logger.debug( + "AWS SNS message published to topic [{0}]: [{1}]".format(topic_arn, response) + ) + + return response["MessageId"] + + +def create_certificate_url(name): + return "https://{hostname}/#/certificates/{name}".format( + hostname=current_app.config.get("LEMUR_HOSTNAME"), name=name + ) + + +def format_message(certificate, notification_type): + json_message = { + "notification_type": notification_type, + "certificate_name": certificate["name"], + "expires": arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY"), + "endpoints_detected": len(certificate["endpoints"]), + "details": create_certificate_url(certificate["name"]) + } + return json.dumps(json_message) diff --git a/lemur/plugins/lemur_aws/tests/test_sns.py b/lemur/plugins/lemur_aws/tests/test_sns.py new file mode 100644 index 00000000..67c230f7 --- /dev/null +++ b/lemur/plugins/lemur_aws/tests/test_sns.py @@ -0,0 +1,50 @@ +from moto import mock_sts, mock_sns, mock_sqs +import boto3 +import json + +import arrow +from lemur.plugins.lemur_aws.sns import format_message +from lemur.plugins.lemur_aws.sns import publish +from lemur.certificates.schemas import certificate_notification_output_schema + +@mock_sns() +def test_format(certificate, endpoint): + + data = [certificate_notification_output_schema.dump(certificate).data] + + for certificate in data: + expected_message = { + "notification_type": "expiration", + "certificate_name": certificate["name"], + "expires": arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY"), + "endpoints_detected": 0, + "details": "https://lemur.example.com/#/certificates/{name}".format(name=certificate["name"]) + } + assert expected_message == json.loads(format_message(certificate, "expiration")) + + +@mock_sns() +@mock_sqs() +def test_publish(certificate, endpoint): + + data = [certificate_notification_output_schema.dump(certificate).data] + + sns_client = boto3.client("sns", region_name="us-east-1") + topic_arn = sns_client.create_topic(Name='lemursnstest')["TopicArn"] + + sqs_client = boto3.client("sqs", region_name="us-east-1") + queue = sqs_client.create_queue(QueueName="lemursnstestqueue") + queue_url = queue["QueueUrl"] + queue_arn = sqs_client.get_queue_attributes(QueueUrl=queue_url)["Attributes"]["QueueArn"] + sns_client.subscribe(TopicArn=topic_arn, Protocol="sqs", Endpoint=queue_arn) + + message_ids = publish(topic_arn, data, "expiration", region_name="us-east-1") + assert len(message_ids) == len(data) + received_messages = sqs_client.receive_message(QueueUrl=queue_url)["Messages"] + + print("ALPACA: Received messages = {}".format(received_messages)) + + for certificate in data: + expected_message_id = message_ids[certificate["name"]] + actual_message = next((m for m in received_messages if json.loads(m["Body"])["MessageId"] == expected_message_id), None) + assert json.loads(actual_message["Body"])["Message"] == format_message(certificate, "expiration") diff --git a/lemur/plugins/lemur_email/plugin.py b/lemur/plugins/lemur_email/plugin.py index 241aa1b0..b74679be 100644 --- a/lemur/plugins/lemur_email/plugin.py +++ b/lemur/plugins/lemur_email/plugin.py @@ -17,6 +17,7 @@ from lemur.plugins.bases import ExpirationNotificationPlugin from lemur.plugins import lemur_email as email from lemur.plugins.lemur_email.templates.config import env +from lemur.plugins.utils import get_plugin_option def render_html(template_name, message): @@ -105,8 +106,23 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin): s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower() - if s_type == "ses": - send_via_ses(subject, body, targets) + current_app.logger.info("ALPACA: Would send an email to {0} with subject \"{1}\" here".format(targets, subject)) - elif s_type == "smtp": - send_via_smtp(subject, body, targets) + # if s_type == "ses": + # send_via_ses(subject, body, targets) + # + # elif s_type == "smtp": + # send_via_smtp(subject, body, targets) + + @staticmethod + def filter_recipients(options, excluded_recipients): + print("ALPACA: Getting recipients for notification {0}".format(options)) + notification_recipients = get_plugin_option("recipients", options) + print( + "ALPACA: Sending certificate notifications to recipients {0}".format(notification_recipients.split(","))) + if notification_recipients: + notification_recipients = notification_recipients.split(",") + # removing owner and security_email from notification_recipient + notification_recipients = [i for i in notification_recipients if i not in excluded_recipients] + + return notification_recipients diff --git a/lemur/plugins/lemur_email/tests/test_email.py b/lemur/plugins/lemur_email/tests/test_email.py index 43168cab..9555be86 100644 --- a/lemur/plugins/lemur_email/tests/test_email.py +++ b/lemur/plugins/lemur_email/tests/test_email.py @@ -34,3 +34,15 @@ def test_render(certificate, endpoint): hostname="lemur.test.example.com", ) ) + + +def test_filter_recipients(certificate, endpoint): + from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin + + options = [{"name": "recipients", "value": "security@netflix.com,bob@netflix.com,joe@netflix.com"}] + assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@netflix.com", "bob@netflix.com", + "joe@netflix.com"] + assert EmailNotificationPlugin.filter_recipients(options, ["security@netflix.com"]) == ["bob@netflix.com", + "joe@netflix.com"] + assert EmailNotificationPlugin.filter_recipients(options, ["security@netflix.com", "bob@netflix.com", + "joe@netflix.com"]) == [] diff --git a/lemur/plugins/lemur_slack/plugin.py b/lemur/plugins/lemur_slack/plugin.py index 7569d295..f62ebd3f 100644 --- a/lemur/plugins/lemur_slack/plugin.py +++ b/lemur/plugins/lemur_slack/plugin.py @@ -119,6 +119,9 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): """ A typical check can be performed using the notify command: `lemur notify` + + While we receive a `targets` parameter here, it is unused, as Slack webhooks do not allow + dynamic re-targeting of messages. The webhook itself specifies a channel. """ attachments = None if notification_type == "expiration": @@ -142,6 +145,6 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): if r.status_code not in [200]: raise Exception("Failed to send message") - current_app.logger.error( + current_app.logger.info( "Slack response: {0} Message Body: {1}".format(r.status_code, body) ) diff --git a/lemur/tests/plugins/notification_plugin.py b/lemur/tests/plugins/notification_plugin.py index 4ad79704..5078e1e0 100644 --- a/lemur/tests/plugins/notification_plugin.py +++ b/lemur/tests/plugins/notification_plugin.py @@ -14,4 +14,5 @@ class TestNotificationPlugin(NotificationPlugin): @staticmethod def send(notification_type, message, targets, options, **kwargs): + print("TODO REMOVE: sending email to {}".format(targets)) return diff --git a/lemur/tests/test_messaging.py b/lemur/tests/test_messaging.py index 98e9ebf3..e5975638 100644 --- a/lemur/tests/test_messaging.py +++ b/lemur/tests/test_messaging.py @@ -87,7 +87,9 @@ def test_send_expiration_notification(certificate, notification, notification_pl delta = certificate.not_after - timedelta(days=10) with freeze_time(delta.datetime): - assert send_expiration_notifications([]) == (2, 0) + # this will only send owner and security emails (no additional recipients), + # but it executes 3 successful send attempts + assert send_expiration_notifications([]) == (3, 0) @mock_ses @@ -103,6 +105,23 @@ def test_send_expiration_notification_with_no_notifications( @mock_ses def test_send_rotation_notification(notification_plugin, certificate): + from lemur.tests.factories import UserFactory + from lemur.tests.factories import CertificateFactory from lemur.notifications.messaging import send_rotation_notification - send_rotation_notification(certificate, notification_plugin=notification_plugin) + user = UserFactory(email="jschladen@netflix.com") + + new_cert = CertificateFactory(user=user) + assert send_rotation_notification(new_cert) + + +@mock_ses +def test_send_pending_failure_notification(certificate, endpoint): + from lemur.tests.factories import UserFactory + from lemur.tests.factories import PendingCertificateFactory + from lemur.notifications.messaging import send_pending_failure_notification + + user = UserFactory(email="jschladen@netflix.com") + + pending_cert = PendingCertificateFactory(user=user) + assert send_pending_failure_notification(pending_cert) diff --git a/setup.py b/setup.py index 4da14c3d..58367f14 100644 --- a/setup.py +++ b/setup.py @@ -135,6 +135,7 @@ setup( 'aws_destination = lemur.plugins.lemur_aws.plugin:AWSDestinationPlugin', 'aws_source = lemur.plugins.lemur_aws.plugin:AWSSourcePlugin', 'aws_s3 = lemur.plugins.lemur_aws.plugin:S3DestinationPlugin', + 'aws_sns = lemur.plugins.lemur_aws.plugin:SNSNotificationPlugin', 'email_notification = lemur.plugins.lemur_email.plugin:EmailNotificationPlugin', 'slack_notification = lemur.plugins.lemur_slack.plugin:SlackNotificationPlugin', 'java_truststore_export = lemur.plugins.lemur_jks.plugin:JavaTruststoreExportPlugin', From 4f552cb636252c77054879cc736555321084ff8c Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Tue, 20 Oct 2020 12:02:36 -0700 Subject: [PATCH 2/7] Code cleanup --- docs/administration.rst | 2 +- lemur/notifications/messaging.py | 4 ++-- lemur/plugins/lemur_aws/sns.py | 2 +- lemur/plugins/lemur_email/plugin.py | 10 ++++------ lemur/plugins/lemur_email/tests/test_email.py | 14 +++++++------- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/administration.rst b/docs/administration.rst index ee504865..ef3f8e38 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -1462,7 +1462,7 @@ AWS (Destination) Uses AWS IAM as a destination for Lemur generated certificates. Support a multi-account deployment. -AWS (Notification) +AWS (SNS Notification) ----- :Authors: diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index 51c9f18a..1fce7636 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -107,6 +107,7 @@ def send_plugin_notification(event_type, data, recipients, notification): } status = FAILURE_METRIC_STATUS try: + current_app.logger.debug(log_data) notification.plugin.send(event_type, data, recipients, notification.options) status = SUCCESS_METRIC_STATUS except Exception as e: @@ -203,6 +204,7 @@ def send_default_notification(notification_type, data, targets, notification_opt ) try: + current_app.logger.debug(log_data) # we need the notification.options here because the email templates utilize the interval/unit info notification_plugin.send(notification_type, data, targets, notification_options) status = SUCCESS_METRIC_STATUS @@ -288,8 +290,6 @@ def needs_notification(certificate): raise Exception( f"Invalid base unit for expiration interval: {unit}" ) - print(f"Does cert {certificate.name} need a notification {notification.label}? Actual: {days}, " - f"configured: {interval}") # TODO REMOVE if days == interval: notifications.append(notification) return notifications diff --git a/lemur/plugins/lemur_aws/sns.py b/lemur/plugins/lemur_aws/sns.py index 96c44f28..3aeb14da 100644 --- a/lemur/plugins/lemur_aws/sns.py +++ b/lemur/plugins/lemur_aws/sns.py @@ -3,7 +3,7 @@ :platform: Unix :copyright: (c) 2020 by Netflix Inc., see AUTHORS for more :license: Apache, see LICENSE for more details. -.. moduleauthor:: Kevin Glisson +.. moduleauthor:: Jasmine Schladen """ import json diff --git a/lemur/plugins/lemur_email/plugin.py b/lemur/plugins/lemur_email/plugin.py index 62e6b2d4..5b9c188e 100644 --- a/lemur/plugins/lemur_email/plugin.py +++ b/lemur/plugins/lemur_email/plugin.py @@ -107,13 +107,11 @@ class EmailNotificationPlugin(ExpirationNotificationPlugin): s_type = current_app.config.get("LEMUR_EMAIL_SENDER", "ses").lower() - print(f"Would send {s_type} email to {targets}: {subject}") + if s_type == "ses": + send_via_ses(subject, body, targets) -# if s_type == "ses": - # send_via_ses(subject, body, targets) - - # elif s_type == "smtp": - # send_via_smtp(subject, body, targets) + elif s_type == "smtp": + send_via_smtp(subject, body, targets) @staticmethod def filter_recipients(options, excluded_recipients, **kwargs): diff --git a/lemur/plugins/lemur_email/tests/test_email.py b/lemur/plugins/lemur_email/tests/test_email.py index d7f7a17d..fd4dc575 100644 --- a/lemur/plugins/lemur_email/tests/test_email.py +++ b/lemur/plugins/lemur_email/tests/test_email.py @@ -81,10 +81,10 @@ def test_send_pending_failure_notification(user, pending_certificate, async_issu def test_filter_recipients(certificate, endpoint): from lemur.plugins.lemur_email.plugin import EmailNotificationPlugin - options = [{"name": "recipients", "value": "security@netflix.com,bob@netflix.com,joe@netflix.com"}] - assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@netflix.com", "bob@netflix.com", - "joe@netflix.com"] - assert EmailNotificationPlugin.filter_recipients(options, ["security@netflix.com"]) == ["bob@netflix.com", - "joe@netflix.com"] - assert EmailNotificationPlugin.filter_recipients(options, ["security@netflix.com", "bob@netflix.com", - "joe@netflix.com"]) == [] + options = [{"name": "recipients", "value": "security@example.com,bob@example.com,joe@example.com"}] + assert EmailNotificationPlugin.filter_recipients(options, []) == ["security@example.com", "bob@example.com", + "joe@example.com"] + assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com"]) == ["bob@example.com", + "joe@example.com"] + assert EmailNotificationPlugin.filter_recipients(options, ["security@example.com", "bob@example.com", + "joe@example.com"]) == [] From 233f9768e84c583994662f17a841ed73a4d75676 Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Fri, 23 Oct 2020 09:34:33 -0700 Subject: [PATCH 3/7] Fix error handling --- lemur/notifications/messaging.py | 2 +- lemur/plugins/lemur_aws/plugin.py | 5 +---- lemur/plugins/lemur_aws/sns.py | 3 ++- lemur/plugins/lemur_slack/plugin.py | 6 +++--- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index 1fce7636..3dd4fff7 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -111,7 +111,7 @@ def send_plugin_notification(event_type, data, recipients, notification): notification.plugin.send(event_type, data, recipients, notification.options) status = SUCCESS_METRIC_STATUS except Exception as e: - log_data["message"] = f"Unable to send expiration notification to recipients {recipients}" + log_data["message"] = f"Unable to send {event_type} notification to recipients {recipients}" current_app.logger.error(log_data, exc_info=True) sentry.captureException() diff --git a/lemur/plugins/lemur_aws/plugin.py b/lemur/plugins/lemur_aws/plugin.py index a0b72d94..8a54b035 100644 --- a/lemur/plugins/lemur_aws/plugin.py +++ b/lemur/plugins/lemur_aws/plugin.py @@ -454,7 +454,4 @@ class SNSNotificationPlugin(ExpirationNotificationPlugin): f"{self.get_option('topicName', options)}" current_app.logger.debug(f"Publishing {notification_type} notification to topic {topic_arn}") - try: - sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options)) - except Exception: - current_app.logger.exception(f"Error publishing {notification_type} notification to topic {topic_arn}") + sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options)) diff --git a/lemur/plugins/lemur_aws/sns.py b/lemur/plugins/lemur_aws/sns.py index 3aeb14da..f9fd4a07 100644 --- a/lemur/plugins/lemur_aws/sns.py +++ b/lemur/plugins/lemur_aws/sns.py @@ -29,7 +29,8 @@ def publish_single(sns_client, topic_arn, certificate, notification_type): response_code = response["ResponseMetadata"]["HTTPStatusCode"] if response_code != 200: - raise Exception(f"Failed to publish notification to SNS, response code was {response_code}") + raise Exception(f"Failed to publish {notification_type} notification to SNS topic {topic_arn}. " + f"SNS response: {response_code} {response}") current_app.logger.debug(f"AWS SNS message published to topic [{topic_arn}]: [{response}]") diff --git a/lemur/plugins/lemur_slack/plugin.py b/lemur/plugins/lemur_slack/plugin.py index ba2baf40..70d97aa5 100644 --- a/lemur/plugins/lemur_slack/plugin.py +++ b/lemur/plugins/lemur_slack/plugin.py @@ -127,7 +127,7 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): raise Exception("Unable to create message attachments") body = { - "text": "Lemur {0} Notification".format(notification_type.capitalize()), + "text": f"Lemur {notification_type.capitalize()} Notification", "attachments": attachments, "channel": self.get_option("recipients", options), "username": self.get_option("username", options), @@ -136,8 +136,8 @@ class SlackNotificationPlugin(ExpirationNotificationPlugin): r = requests.post(self.get_option("webhook", options), json.dumps(body)) if r.status_code not in [200]: - raise Exception("Failed to send message") + raise Exception(f"Failed to send message. Slack response: {r.status_code} {body}") current_app.logger.info( - "Slack response: {0} Message Body: {1}".format(r.status_code, body) + f"Slack response: {r.status_code} Message Body: {body}" ) From a5cea4fb9a5a4e8eda99c46fdf808af5f3aab32f Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Fri, 23 Oct 2020 09:42:03 -0700 Subject: [PATCH 4/7] Skip revoked certs when looking for certs to notify --- lemur/notifications/messaging.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index 3dd4fff7..3928689e 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -42,6 +42,7 @@ def get_certificates(exclude=None): .filter(Certificate.not_after <= max) .filter(Certificate.notify == True) .filter(Certificate.expired == False) + .filter(Certificate.revoked == False) ) # noqa exclude_conditions = [] From fd12d4848c2016a410df717e3d92efaf05e16e64 Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Fri, 23 Oct 2020 11:26:11 -0700 Subject: [PATCH 5/7] Grammar fixes --- docs/administration.rst | 2 +- docs/developer/plugins/index.rst | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/administration.rst b/docs/administration.rst index ef3f8e38..724b136f 100644 --- a/docs/administration.rst +++ b/docs/administration.rst @@ -269,7 +269,7 @@ Certificates marked as inactive will **not** be notified of upcoming expiration. silence the expiration. If a certificate is active and is expiring the above will be notified according to the `LEMUR_DEFAULT_EXPIRATION_NOTIFICATION_INTERVALS` or 30, 15, 2 days before expiration if no intervals are set. -Lemur supports sending certification expiration notifications through SES and SMTP. +Lemur supports sending certificate expiration notifications through SES and SMTP. .. data:: LEMUR_EMAIL_SENDER diff --git a/docs/developer/plugins/index.rst b/docs/developer/plugins/index.rst index 0223d9ca..c2a8c48a 100644 --- a/docs/developer/plugins/index.rst +++ b/docs/developer/plugins/index.rst @@ -215,7 +215,7 @@ Notification ------------ Lemur includes the ability to create Email notifications by **default**. These notifications -currently come in the form of expiration and rotation notices. Lemur periodically checks certifications expiration dates and +currently come in the form of expiration and rotation notices. Lemur periodically checks certificate expiration dates and determines if a given certificate is eligible for notification. There are currently only two parameters used to determine if a certificate is eligible; validity expiration (date the certificate is no longer valid) and the number of days the current date (UTC) is from that expiration date. @@ -223,12 +223,12 @@ of days the current date (UTC) is from that expiration date. Expiration notifications can also be configured for Slack or AWS SNS. Rotation notifications are not configurable. Notifications sent to a certificate owner and security team (`LEMUR_SECURITY_TEAM_EMAIL`) can currently only be sent via email. -There are currently two objects that available for notification plugins. The first is `NotificationPlugin`, which is the base object for -any notification within Lemur. Currently the only supported notification type is an certificate expiration notification. If you +There are currently two objects that are available for notification plugins. The first is `NotificationPlugin`, which is the base object for +any notification within Lemur. Currently the only supported notification type is a certificate expiration notification. If you are trying to create a new notification type (audit, failed logins, etc.) this would be the object to base your plugin on. You would also then need to build additional code to trigger the new notification type. -The second is `ExpirationNotificationPlugin`, which inherits from `NotificationPlugin` object. +The second is `ExpirationNotificationPlugin`, which inherits from the `NotificationPlugin` object. You will most likely want to base your plugin on this object if you want to add new channels for expiration notices (HipChat, Jira, etc.). It adds default options that are required by all expiration notifications (interval, unit). This interface expects for the child to define the following function:: From 20b8c2fd93ddedf71eb3ade29757d10dc9797736 Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Tue, 27 Oct 2020 08:56:28 -0700 Subject: [PATCH 6/7] PR feedback --- lemur/notifications/messaging.py | 2 +- lemur/plugins/lemur_aws/plugin.py | 2 +- lemur/plugins/lemur_aws/sns.py | 3 ++- lemur/plugins/lemur_aws/tests/test_sns.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index 3928689e..ba4f331c 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -108,7 +108,7 @@ def send_plugin_notification(event_type, data, recipients, notification): } status = FAILURE_METRIC_STATUS try: - current_app.logger.debug(log_data) + current_app.logger.info(log_data) notification.plugin.send(event_type, data, recipients, notification.options) status = SUCCESS_METRIC_STATUS except Exception as e: diff --git a/lemur/plugins/lemur_aws/plugin.py b/lemur/plugins/lemur_aws/plugin.py index 8a54b035..1be641b0 100644 --- a/lemur/plugins/lemur_aws/plugin.py +++ b/lemur/plugins/lemur_aws/plugin.py @@ -453,5 +453,5 @@ class SNSNotificationPlugin(ExpirationNotificationPlugin): f"{self.get_option('accountNumber', options)}:" \ f"{self.get_option('topicName', options)}" - current_app.logger.debug(f"Publishing {notification_type} notification to topic {topic_arn}") + current_app.logger.info(f"Publishing {notification_type} notification to topic {topic_arn}") sns.publish(topic_arn, message, notification_type, region_name=self.get_option("region", options)) diff --git a/lemur/plugins/lemur_aws/sns.py b/lemur/plugins/lemur_aws/sns.py index f9fd4a07..c98bbc0c 100644 --- a/lemur/plugins/lemur_aws/sns.py +++ b/lemur/plugins/lemur_aws/sns.py @@ -32,6 +32,7 @@ def publish_single(sns_client, topic_arn, certificate, notification_type): raise Exception(f"Failed to publish {notification_type} notification to SNS topic {topic_arn}. " f"SNS response: {response_code} {response}") + current_app.logger.info(f"AWS SNS message published to topic [{topic_arn}] with message ID {response['MessageId']}") current_app.logger.debug(f"AWS SNS message published to topic [{topic_arn}]: [{response}]") return response["MessageId"] @@ -47,7 +48,7 @@ def format_message(certificate, notification_type): json_message = { "notification_type": notification_type, "certificate_name": certificate["name"], - "expires": arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY"), + "expires": arrow.get(certificate["validityEnd"]).format("YYYY-MM-ddTHH:mm:ss"), # 2047-12-T22:00:00 "endpoints_detected": len(certificate["endpoints"]), "details": create_certificate_url(certificate["name"]) } diff --git a/lemur/plugins/lemur_aws/tests/test_sns.py b/lemur/plugins/lemur_aws/tests/test_sns.py index df495f80..ce05c33c 100644 --- a/lemur/plugins/lemur_aws/tests/test_sns.py +++ b/lemur/plugins/lemur_aws/tests/test_sns.py @@ -20,7 +20,7 @@ def test_format(certificate, endpoint): expected_message = { "notification_type": "expiration", "certificate_name": certificate["name"], - "expires": arrow.get(certificate["validityEnd"]).format("dddd, MMMM D, YYYY"), + "expires": arrow.get(certificate["validityEnd"]).format("YYYY-MM-ddTHH:mm:ss"), "endpoints_detected": 0, "details": "https://lemur.example.com/#/certificates/{name}".format(name=certificate["name"]) } From 794e4d385511a0bde6f656aba1b0a89063823d00 Mon Sep 17 00:00:00 2001 From: Jasmine Schladen Date: Tue, 27 Oct 2020 17:36:01 -0700 Subject: [PATCH 7/7] Revert log to debug to be safe --- lemur/notifications/messaging.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lemur/notifications/messaging.py b/lemur/notifications/messaging.py index ba4f331c..3928689e 100644 --- a/lemur/notifications/messaging.py +++ b/lemur/notifications/messaging.py @@ -108,7 +108,7 @@ def send_plugin_notification(event_type, data, recipients, notification): } status = FAILURE_METRIC_STATUS try: - current_app.logger.info(log_data) + current_app.logger.debug(log_data) notification.plugin.send(event_type, data, recipients, notification.options) status = SUCCESS_METRIC_STATUS except Exception as e: