diff --git a/.gitignore b/.gitignore index 97af00ca..72e85f26 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,11 @@ package-lock.json /lemur/static/dist/ /lemur/static/app/vendor/ /wheelhouse +/lemur/lib +/lemur/bin +/lemur/lib64 +/lemur/include + docs/_build .editorconfig .idea diff --git a/lemur/certificates/schemas.py b/lemur/certificates/schemas.py index f790d92f..78217de0 100644 --- a/lemur/certificates/schemas.py +++ b/lemur/certificates/schemas.py @@ -10,6 +10,7 @@ from marshmallow import fields, validate, validates_schema, post_load, pre_load from marshmallow.exceptions import ValidationError from lemur.authorities.schemas import AuthorityNestedOutputSchema +from lemur.certificates import utils as cert_utils from lemur.common import missing, utils, validators from lemur.common.fields import ArrowDateTime, Hex from lemur.common.schema import LemurInputSchema, LemurOutputSchema @@ -110,6 +111,11 @@ class CertificateInputSchema(CertificateCreationSchema): def load_data(self, data): if data.get('replacements'): data['replaces'] = data['replacements'] # TODO remove when field is deprecated + if data.get('csr'): + dns_names = cert_utils.get_dns_names_from_csr(data['csr']) + if not data['extensions']['subAltNames']['names']: + data['extensions']['subAltNames']['names'] = [] + data['extensions']['subAltNames']['names'] += dns_names return missing.convert_validity_years(data) diff --git a/lemur/certificates/utils.py b/lemur/certificates/utils.py new file mode 100644 index 00000000..933fe45e --- /dev/null +++ b/lemur/certificates/utils.py @@ -0,0 +1,42 @@ +""" +Utils to parse certificate data. + +.. module: lemur.certificates.hooks + :platform: Unix + :copyright: (c) 2019 by Javier Ramos, see AUTHORS for more + :license: Apache, see LICENSE for more details. + +.. moduleauthor:: Javier Ramos +""" + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from marshmallow.exceptions import ValidationError + + +def get_dns_names_from_csr(data): + """ + Fetches DNSNames from CSR. + Potentially extendable to any kind of SubjectAlternativeName + :param data: PEM-encoded string with CSR + :return: + """ + dns_names = [] + try: + request = x509.load_pem_x509_csr(data.encode('utf-8'), default_backend()) + except Exception: + raise ValidationError('CSR presented is not valid.') + + try: + alt_names = request.extensions.get_extension_for_class(x509.SubjectAlternativeName) + + for name in alt_names.value.get_values_for_type(x509.DNSName): + dns_name = { + 'nameType': 'DNSName', + 'value': name + } + dns_names.append(dns_name) + except x509.ExtensionNotFound: + pass + + return dns_names diff --git a/lemur/plugins/lemur_vault_dest/__init__.py b/lemur/plugins/lemur_vault_dest/__init__.py new file mode 100644 index 00000000..8ce5a7f3 --- /dev/null +++ b/lemur/plugins/lemur_vault_dest/__init__.py @@ -0,0 +1,5 @@ +try: + VERSION = __import__('pkg_resources') \ + .get_distribution(__name__).version +except Exception as e: + VERSION = 'unknown' diff --git a/lemur/plugins/lemur_vault_dest/plugin.py b/lemur/plugins/lemur_vault_dest/plugin.py new file mode 100644 index 00000000..91f6a07a --- /dev/null +++ b/lemur/plugins/lemur_vault_dest/plugin.py @@ -0,0 +1,156 @@ +""" +.. module: lemur.plugins.lemur_vault_dest.plugin + :platform: Unix + :copyright: (c) 2019 + :license: Apache, see LICENCE for more details. + + Plugin for uploading certificates and private key as secret to hashi vault + that can be pulled down by end point nodes. + +.. moduleauthor:: Christopher Jolley +""" +import hvac +from flask import current_app + +from lemur.common.defaults import common_name +from lemur.common.utils import parse_certificate +from lemur.plugins.bases import DestinationPlugin + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend + + +class VaultDestinationPlugin(DestinationPlugin): + """Hashicorp Vault Destination plugin for Lemur""" + title = 'Vault' + slug = 'hashi-vault-destination' + description = 'Allow the uploading of certificates to Hashi Vault as secret' + + author = 'Christopher Jolley' + author_url = 'https://github.com/alwaysjolley/lemur' + + options = [ + { + 'name': 'vaultUrl', + 'type': 'str', + 'required': True, + 'validation': '^https?://[a-zA-Z0-9.:-]+$', + 'helpMessage': 'Valid URL to Hashi Vault instance' + }, + { + 'name': 'vaultAuthTokenFile', + 'type': 'str', + 'required': True, + 'validation': '(/[^/]+)+', + 'helpMessage': 'Must be a valid file path!' + }, + { + 'name': 'vaultMount', + 'type': 'str', + 'required': True, + 'validation': '^\S+$', + 'helpMessage': 'Must be a valid Vault secrets mount name!' + }, + { + 'name': 'vaultPath', + 'type': 'str', + 'required': True, + 'validation': '^([a-zA-Z0-9_-]+/?)+$', + 'helpMessage': 'Must be a valid Vault secrets path' + }, + { + 'name': 'objectName', + 'type': 'str', + 'required': False, + 'validation': '[0-9a-zA-Z:_-]+', + 'helpMessage': 'Name to bundle certs under, if blank use cn' + }, + { + 'name': 'bundleChain', + 'type': 'select', + 'value': 'cert only', + 'available': [ + 'Nginx', + 'Apache', + 'no chain' + ], + 'required': True, + 'helpMessage': 'Bundle the chain into the certificate' + } + ] + + def __init__(self, *args, **kwargs): + super(VaultDestinationPlugin, self).__init__(*args, **kwargs) + + def upload(self, name, body, private_key, cert_chain, options, **kwargs): + """ + Upload certificate and private key + + :param private_key: + :param cert_chain: + :return: + """ + cname = common_name(parse_certificate(body)) + + url = self.get_option('vaultUrl', options) + token_file = self.get_option('vaultAuthTokenFile', options) + mount = self.get_option('vaultMount', options) + path = self.get_option('vaultPath', options) + bundle = self.get_option('bundleChain', options) + obj_name = self.get_option('objectName', options) + + with open(token_file, 'r') as file: + token = file.readline().rstrip('\n') + + client = hvac.Client(url=url, token=token) + if obj_name: + path = '{0}/{1}'.format(path, obj_name) + else: + path = '{0}/{1}'.format(path, cname) + + secret = get_secret(url, token, mount, path) + secret['data'][cname] = {} + + if bundle == 'Nginx' and cert_chain: + secret['data'][cname]['crt'] = '{0}\n{1}'.format(body, cert_chain) + elif bundle == 'Apache' and cert_chain: + secret['data'][cname]['crt'] = body + secret['data'][cname]['chain'] = cert_chain + else: + secret['data'][cname]['crt'] = body + secret['data'][cname]['key'] = private_key + san_list = get_san_list(body) + if isinstance(san_list, list): + secret['data'][cname]['san'] = san_list + try: + client.secrets.kv.v1.create_or_update_secret( + path=path, mount_point=mount, secret=secret['data']) + except ConnectionError as err: + current_app.logger.exception( + "Exception uploading secret to vault: {0}".format(err), exc_info=True) + + +def get_san_list(body): + """ parse certificate for SAN names and return list, return empty list on error """ + san_list = [] + try: + byte_body = body.encode('utf-8') + cert = x509.load_pem_x509_certificate(byte_body, default_backend()) + ext = cert.extensions.get_extension_for_oid(x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME) + san_list = ext.value.get_values_for_type(x509.DNSName) + except x509.extensions.ExtensionNotFound: + pass + finally: + return san_list + + +def get_secret(url, token, mount, path): + """ retreiive existing data from mount path and return dictionary """ + result = {'data': {}} + try: + client = hvac.Client(url=url, token=token) + result = client.secrets.kv.v1.read_secret(path=path, mount_point=mount) + except ConnectionError: + pass + finally: + return result diff --git a/lemur/plugins/lemur_vault_dest/tests/conftest.py b/lemur/plugins/lemur_vault_dest/tests/conftest.py new file mode 100644 index 00000000..0e1cd89f --- /dev/null +++ b/lemur/plugins/lemur_vault_dest/tests/conftest.py @@ -0,0 +1 @@ +from lemur.tests.conftest import * # noqa diff --git a/requirements-dev.txt b/requirements-dev.txt index 37202d97..36e2c9a4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,7 +14,6 @@ flake8==3.5.0 identify==1.4.0 # via pre-commit idna==2.8 # via requests importlib-metadata==0.8 # via pre-commit -importlib-resources==1.0.2 # via pre-commit invoke==1.2.0 mccabe==0.6.1 # via flake8 nodeenv==1.3.3 diff --git a/requirements-docs.txt b/requirements-docs.txt index 40cd73de..e99c9cdc 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -42,6 +42,7 @@ flask-sqlalchemy==2.3.2 flask==1.0.2 future==0.17.1 gunicorn==19.9.0 +hvac==0.7.2 idna==2.8 imagesize==1.1.0 # via sphinx inflection==0.3.1 diff --git a/requirements.in b/requirements.in index e3d0c66b..9b27f604 100644 --- a/requirements.in +++ b/requirements.in @@ -24,6 +24,7 @@ Flask Flask-Cors future gunicorn +hvac # required for the vault destination plugin inflection jinja2 kombu==4.3.0 # kombu 4.4.0 requires redis 3 @@ -46,4 +47,3 @@ SQLAlchemy-Utils tabulate xmltodict pyyaml>=4.2b1 #high severity alert - diff --git a/requirements.txt b/requirements.txt index 9adbdf37..c0e69fb4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,6 +40,7 @@ flask-sqlalchemy==2.3.2 flask==1.0.2 future==0.17.1 gunicorn==19.9.0 +hvac==0.7.2 idna==2.8 # via requests inflection==0.3.1 itsdangerous==1.1.0 # via flask diff --git a/setup.py b/setup.py index 882edb02..148f51b1 100644 --- a/setup.py +++ b/setup.py @@ -155,6 +155,7 @@ setup( 'digicert_cis_source = lemur.plugins.lemur_digicert.plugin:DigiCertCISSourcePlugin', 'csr_export = lemur.plugins.lemur_csr.plugin:CSRExportPlugin', 'sftp_destination = lemur.plugins.lemur_sftp.plugin:SFTPDestinationPlugin', + 'vault_desination = lemur.plugins.lemur_vault_dest.plugin:VaultDestinationPlugin', 'adcs_issuer = lemur.plugins.lemur_adcs.plugin:ADCSIssuerPlugin', 'adcs_source = lemur.plugins.lemur_adcs.plugin:ADCSSourcePlugin' ],