lemur/lemur/plugins/lemur_aws/plugin.py

568 lines
20 KiB
Python

"""
.. module: lemur.plugins.lemur_aws.plugin
:platform: Unix
:copyright: (c) 2018 by Netflix Inc., see AUTHORS for more
:license: Apache, see LICENSE for more details.
Terraform example to setup the destination bucket:
resource "aws_s3_bucket" "certs_log_bucket" {
bucket = "certs-log-access-bucket"
acl = "log-delivery-write"
}
resource "aws_s3_bucket" "certs_lemur" {
bucket = "certs-lemur"
acl = "private"
logging {
target_bucket = "${aws_s3_bucket.certs_log_bucket.id}"
target_prefix = "log/lemur"
}
}
The IAM role Lemur is running as should have the following actions on the destination bucket:
"S3:PutObject",
"S3:PutObjectAcl"
The reader should have the following actions:
"s3:GetObject"
.. moduleauthor:: Kevin Glisson <kglisson@netflix.com>
.. moduleauthor:: Mikhail Khodorovskiy <mikhail.khodorovskiy@jivesoftware.com>
.. moduleauthor:: Harm Weites <harm@weites.com>
"""
import sys
from acme.errors import ClientError
from flask import current_app
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, sns
def get_region_from_dns(dns):
# XXX.REGION.elb.amazonaws.com
if dns.endswith(".elb.amazonaws.com"):
return dns.split(".")[-4]
else:
# NLBs have a different pattern on the dns XXXX.elb.REGION.amazonaws.com
return dns.split(".")[-3]
def format_elb_cipher_policy_v2(policy):
"""
Attempts to format cipher policy information for elbv2 into a common format.
:param policy:
:return:
"""
ciphers = []
name = None
for descr in policy["SslPolicies"]:
name = descr["Name"]
for cipher in descr["Ciphers"]:
ciphers.append(cipher["Name"])
return dict(name=name, ciphers=ciphers)
def format_elb_cipher_policy(policy):
"""
Attempts to format cipher policy information into a common format.
:param policy:
:return:
"""
ciphers = []
name = None
for descr in policy["PolicyDescriptions"]:
for attr in descr["PolicyAttributeDescriptions"]:
if attr["AttributeName"] == "Reference-Security-Policy":
name = attr["AttributeValue"]
continue
if attr["AttributeValue"] == "true":
ciphers.append(attr["AttributeName"])
return dict(name=name, ciphers=ciphers)
def get_elb_endpoints(account_number, region, elb_dict):
"""
Retrieves endpoint information from elb response data.
:param account_number:
:param region:
:param elb_dict:
:return:
"""
endpoints = []
for listener in elb_dict["ListenerDescriptions"]:
if not listener["Listener"].get("SSLCertificateId"):
continue
if listener["Listener"]["SSLCertificateId"] == "Invalid-Certificate":
continue
endpoint = dict(
name=elb_dict["LoadBalancerName"],
dnsname=elb_dict["DNSName"],
type="elb",
port=listener["Listener"]["LoadBalancerPort"],
certificate_name=iam.get_name_from_arn(
listener["Listener"]["SSLCertificateId"]
),
)
if listener["PolicyNames"]:
policy = elb.describe_load_balancer_policies(
elb_dict["LoadBalancerName"],
listener["PolicyNames"],
account_number=account_number,
region=region,
)
endpoint["policy"] = format_elb_cipher_policy(policy)
current_app.logger.debug("Found new endpoint. Endpoint: {}".format(endpoint))
endpoints.append(endpoint)
return endpoints
def get_elb_endpoints_v2(account_number, region, elb_dict):
"""
Retrieves endpoint information from elbv2 response data.
:param account_number:
:param region:
:param elb_dict:
:return:
"""
endpoints = []
listeners = elb.describe_listeners_v2(
account_number=account_number,
region=region,
LoadBalancerArn=elb_dict["LoadBalancerArn"],
)
for listener in listeners["Listeners"]:
if not listener.get("Certificates"):
continue
for certificate in listener["Certificates"]:
endpoint = dict(
name=elb_dict["LoadBalancerName"],
dnsname=elb_dict["DNSName"],
type="elbv2",
port=listener["Port"],
certificate_name=iam.get_name_from_arn(certificate["CertificateArn"]),
)
if listener["SslPolicy"]:
policy = elb.describe_ssl_policies_v2(
[listener["SslPolicy"]], account_number=account_number, region=region
)
endpoint["policy"] = format_elb_cipher_policy_v2(policy)
endpoints.append(endpoint)
return endpoints
class AWSSourcePlugin(SourcePlugin):
title = "AWS"
slug = "aws-source"
description = "Discovers all SSL certificates and ELB endpoints in an AWS account"
version = aws.VERSION
author = "Kevin Glisson"
author_url = "https://github.com/netflix/lemur"
options = [
{
"name": "accountNumber",
"type": "str",
"required": True,
"validation": "/^[0-9]{12,12}$/",
"helpMessage": "Must be a valid AWS account number!",
},
{
"name": "regions",
"type": "str",
"helpMessage": "Comma separated list of regions to search in, if no region is specified we look in all regions.",
},
]
def get_certificates(self, options, **kwargs):
cert_data = iam.get_all_certificates(
account_number=self.get_option("accountNumber", options)
)
return [
dict(
body=c["CertificateBody"],
chain=c.get("CertificateChain"),
name=c["ServerCertificateMetadata"]["ServerCertificateName"],
)
for c in cert_data
]
def get_endpoints(self, options, **kwargs):
endpoints = []
account_number = self.get_option("accountNumber", options)
regions = self.get_option("regions", options)
if not regions:
regions = ec2.get_regions(account_number=account_number)
else:
regions = "".join(regions.split()).split(",")
for region in regions:
elbs = elb.get_all_elbs(account_number=account_number, region=region)
current_app.logger.info({
"message": "Describing classic load balancers",
"account_number": account_number,
"region": region,
"number_of_load_balancers": len(elbs)
})
for e in elbs:
endpoints.extend(get_elb_endpoints(account_number, region, e))
# fetch advanced ELBs
elbs_v2 = elb.get_all_elbs_v2(account_number=account_number, region=region)
current_app.logger.info({
"message": "Describing advanced load balancers",
"account_number": account_number,
"region": region,
"number_of_load_balancers": len(elbs_v2)
})
for e in elbs_v2:
endpoints.extend(get_elb_endpoints_v2(account_number, region, e))
return endpoints
def update_endpoint(self, endpoint, certificate):
options = endpoint.source.options
account_number = self.get_option("accountNumber", options)
# relies on the fact that region is included in DNS name
region = get_region_from_dns(endpoint.dnsname)
arn = iam.create_arn_from_cert(account_number, region, certificate.name)
if endpoint.type == "elbv2":
listener_arn = elb.get_listener_arn_from_endpoint(
endpoint.name,
endpoint.port,
account_number=account_number,
region=region,
)
elb.attach_certificate_v2(
listener_arn,
endpoint.port,
[{"CertificateArn": arn}],
account_number=account_number,
region=region,
)
else:
elb.attach_certificate(
endpoint.name,
endpoint.port,
arn,
account_number=account_number,
region=region,
)
def clean(self, certificate, options, **kwargs):
account_number = self.get_option("accountNumber", options)
iam.delete_cert(certificate.name, account_number=account_number)
def get_certificate_by_name(self, certificate_name, options):
account_number = self.get_option("accountNumber", options)
# certificate name may contain path, in which case we remove it
if "/" in certificate_name:
certificate_name = certificate_name.split('/')[-1]
try:
cert = iam.get_certificate(certificate_name, account_number=account_number)
if cert:
return dict(
body=cert["CertificateBody"],
chain=cert.get("CertificateChain"),
name=cert["ServerCertificateMetadata"]["ServerCertificateName"],
)
except ClientError:
current_app.logger.warning(
"get_elb_certificate_failed: Unable to get certificate for {0}".format(certificate_name))
sentry.captureException()
metrics.send(
"get_elb_certificate_failed", "counter", 1,
metric_tags={"certificate_name": certificate_name, "account_number": account_number}
)
return None
def get_endpoint_certificate_names(self, endpoint):
options = endpoint.source.options
account_number = self.get_option("accountNumber", options)
region = get_region_from_dns(endpoint.dnsname)
certificate_names = []
if endpoint.type == "elb":
elb_details = elb.get_elbs(account_number=account_number,
region=region,
LoadBalancerNames=[endpoint.name],)
for lb_description in elb_details["LoadBalancerDescriptions"]:
for listener_description in lb_description["ListenerDescriptions"]:
listener = listener_description.get("Listener")
if not listener.get("SSLCertificateId"):
continue
certificate_names.append(iam.get_name_from_arn(listener.get("SSLCertificateId")))
elif endpoint.type == "elbv2":
listeners = elb.describe_listeners_v2(
account_number=account_number,
region=region,
LoadBalancerArn=elb.get_load_balancer_arn_from_endpoint(endpoint.name,
account_number=account_number,
region=region),
)
for listener in listeners["Listeners"]:
if not listener.get("Certificates"):
continue
for certificate in listener["Certificates"]:
certificate_names.append(iam.get_name_from_arn(certificate["CertificateArn"]))
return certificate_names
class AWSDestinationPlugin(DestinationPlugin):
title = "AWS"
slug = "aws-destination"
description = "Allow the uploading of certificates to AWS IAM"
version = aws.VERSION
sync_as_source = True
sync_as_source_name = AWSSourcePlugin.slug
author = "Kevin Glisson"
author_url = "https://github.com/netflix/lemur"
options = [
{
"name": "accountNumber",
"type": "str",
"required": True,
"validation": "[0-9]{12}",
"helpMessage": "Must be a valid AWS account number!",
},
{
"name": "path",
"type": "str",
"default": "/",
"helpMessage": "Path to upload certificate.",
},
]
def upload(self, name, body, private_key, cert_chain, options, **kwargs):
try:
iam.upload_cert(
name,
body,
private_key,
self.get_option("path", options),
cert_chain=cert_chain,
account_number=self.get_option("accountNumber", options),
)
except ClientError:
sentry.captureException()
def deploy(self, elb_name, account, region, certificate):
pass
def clean(self, certificate, options, **kwargs):
account_number = self.get_option("accountNumber", options)
iam.delete_cert(certificate.name, account_number=account_number)
class S3DestinationPlugin(ExportDestinationPlugin):
title = "AWS-S3"
slug = "aws-s3"
description = "Allow the uploading of certificates to Amazon S3"
author = "Mikhail Khodorovskiy, Harm Weites <harm@weites.com>"
author_url = "https://github.com/Netflix/lemur"
additional_options = [
{
"name": "bucket",
"type": "str",
"required": True,
"validation": "[0-9a-z.-]{3,63}",
"helpMessage": "Must be a valid S3 bucket name!",
},
{
"name": "accountNumber",
"type": "str",
"required": True,
"validation": "[0-9]{12}",
"helpMessage": "A valid AWS account number with permission to access S3",
},
{
"name": "region",
"type": "str",
"default": "us-east-1",
"required": False,
"helpMessage": "Region bucket exists",
"available": ["us-east-1", "us-west-2", "eu-west-1"],
},
{
"name": "encrypt",
"type": "bool",
"required": False,
"helpMessage": "Enable server side encryption",
"default": True,
},
{
"name": "prefix",
"type": "str",
"required": False,
"helpMessage": "Must be a valid S3 object prefix!",
},
]
def __init__(self, *args, **kwargs):
super(S3DestinationPlugin, self).__init__(*args, **kwargs)
def upload(self, name, body, private_key, chain, options, **kwargs):
files = self.export(body, private_key, chain, options)
for ext, passphrase, data in files:
s3.put(
self.get_option("bucket", options),
self.get_option("region", options),
"{prefix}/{name}.{extension}".format(
prefix=self.get_option("prefix", options), name=name, extension=ext
),
data,
self.get_option("encrypt", options),
account_number=self.get_option("accountNumber", options),
)
def upload_acme_token(self, token_path, token, options, **kwargs):
"""
This is called from the acme http challenge
:param self:
:param token_path:
:param token:
:param options:
:param kwargs:
:return:
"""
current_app.logger.debug("S3 destination plugin is started to upload HTTP-01 challenge")
function = f"{__name__}.{sys._getframe().f_code.co_name}"
account_number = self.get_option("accountNumber", options)
bucket_name = self.get_option("bucket", options)
prefix = self.get_option("prefix", options)
region = self.get_option("region", options)
filename = token_path.split("/")[-1]
if not prefix.endswith("/"):
prefix + "/"
response = s3.put(bucket_name=bucket_name,
region_name=region,
prefix=prefix + filename,
data=token,
encrypt=False,
account_number=account_number)
res = "Success" if response else "Failure"
log_data = {
"function": function,
"message": "upload acme token challenge",
"result": res,
"bucket_name": bucket_name,
"filename": filename
}
current_app.logger.info(log_data)
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
"bucket_name": bucket_name,
"filename": filename})
return response
def delete_acme_token(self, token_path, options, **kwargs):
current_app.logger.debug("S3 destination plugin is started to delete HTTP-01 challenge")
function = f"{__name__}.{sys._getframe().f_code.co_name}"
account_number = self.get_option("accountNumber", options)
bucket_name = self.get_option("bucket", options)
prefix = self.get_option("prefix", options)
filename = token_path.split("/")[-1]
response = s3.delete(bucket_name=bucket_name,
prefixed_object_name=prefix + filename,
account_number=account_number)
res = "Success" if response else "Failure"
log_data = {
"function": function,
"message": "delete acme token challenge",
"result": res,
"bucket_name": bucket_name,
"filename": filename
}
current_app.logger.info(log_data)
metrics.send(f"{function}", "counter", 1, metric_tags={"result": res,
"bucket_name": bucket_name,
"filename": filename})
return response
class SNSNotificationPlugin(ExpirationNotificationPlugin):
title = "AWS SNS"
slug = "aws-sns"
description = "Sends notifications to AWS SNS"
version = aws.VERSION
author = "Jasmine Schladen <jschladen@netflix.com>"
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": "topicName",
"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 = f"arn:aws:sns:{self.get_option('region', options)}:" \
f"{self.get_option('accountNumber', options)}:" \
f"{self.get_option('topicName', options)}"
current_app.logger.info(f"Publishing {notification_type} notification to topic {topic_arn}")
sns.publish(topic_arn, message, notification_type, options, region_name=self.get_option("region", options))